Vendor frontend JS into javascript/; drop the prestartup copy dance
The viewer JS/HTML/bundles are now committed in javascript/ (declared via pyproject [tool.comfy] web = "javascript") instead of being copied from the shared comfy-3d-viewers pip package at prestartup. Consequences: - requirements.txt is now just comfy-env (host env installs nothing else, per the never-touch-the-host-env principle) - prestartup_script.py no longer copies viewers or web_src; it keeps only setup_env() and the input-asset copies - WEB_DIRECTORY attribute removed; the pyproject declaration is the single source (served at /extensions/comfyui-geometrypack/, keyed by project name) - web_src/ deleted; its 6 widgets now live in javascript/js/ as the tracked source, edited directly - viewer_pbr.html hardcoded-fallback URL updated to the new mount name Iframe URLs auto-detect the mount from import.meta.url, so the folder rename and mount-name change are transparent at runtime.
This commit is contained in:
@@ -0,0 +1,258 @@
|
||||
/**
|
||||
* BVH Viewer Widget - Interactive BVH skeleton animation viewer
|
||||
* Displays skeletal animations in BVH format with Three.js
|
||||
*/
|
||||
|
||||
import { app } from "../../../../scripts/app.js";
|
||||
|
||||
console.log("[BVHViewer] Loading BVH Viewer extension");
|
||||
|
||||
// Node types that use this viewer
|
||||
const BVH_VIEWER_NODES = [
|
||||
{
|
||||
nodeName: "BVHViewer",
|
||||
extensionName: "comfy3d.bvhviewer",
|
||||
logPrefix: "[BVHViewer]"
|
||||
}
|
||||
];
|
||||
|
||||
function detectExtensionFolder() {
|
||||
try {
|
||||
if (typeof import.meta !== 'undefined' && import.meta.url) {
|
||||
const match = import.meta.url.match(/\/extensions\/([^\/]+)\//);
|
||||
if (match) return match[1];
|
||||
}
|
||||
const scripts = document.getElementsByTagName('script');
|
||||
for (let i = scripts.length - 1; i >= 0; i--) {
|
||||
const src = scripts[i].src;
|
||||
if (src) {
|
||||
const match = src.match(/\/extensions\/([^\/]+)\//);
|
||||
if (match) return match[1];
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[BVHViewer] Could not detect extension folder:', e);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function getViewerUrl(extensionFolder) {
|
||||
return `/extensions/${extensionFolder}/viewer_bvh.html?v=` + Date.now();
|
||||
}
|
||||
|
||||
function createBVHViewerExtension(config) {
|
||||
const { extensionName, nodeName, logPrefix } = config;
|
||||
|
||||
console.log(`${logPrefix} Loading BVH viewer extension...`);
|
||||
|
||||
const extensionFolder = detectExtensionFolder();
|
||||
if (!extensionFolder) {
|
||||
console.error(`${logPrefix} Could not detect extension folder`);
|
||||
return;
|
||||
}
|
||||
|
||||
app.registerExtension({
|
||||
name: extensionName,
|
||||
|
||||
async beforeRegisterNodeDef(nodeType, nodeData, app) {
|
||||
if (nodeData.name !== nodeName) return;
|
||||
|
||||
console.log(`${logPrefix} Registering ${nodeName} node`);
|
||||
|
||||
const onNodeCreated = nodeType.prototype.onNodeCreated;
|
||||
|
||||
nodeType.prototype.onNodeCreated = function() {
|
||||
const result = onNodeCreated?.apply(this, arguments);
|
||||
|
||||
// Create container
|
||||
const container = document.createElement("div");
|
||||
container.style.cssText = "position: relative; width: 100%; background: #222;";
|
||||
|
||||
// Create iframe for Three.js viewer
|
||||
const iframe = document.createElement("iframe");
|
||||
iframe.style.cssText = "width: 100%; height: 600px; border: none; display: block; background: #1a1a1a;";
|
||||
iframe.src = getViewerUrl(extensionFolder);
|
||||
container.appendChild(iframe);
|
||||
|
||||
// Controls bar
|
||||
const controlsBar = document.createElement("div");
|
||||
controlsBar.style.cssText = "display: flex; flex-wrap: wrap; gap: 10px; padding: 10px; background: #252525; align-items: center; border-top: 1px solid #333;";
|
||||
|
||||
// Play/Pause button
|
||||
const playButton = document.createElement("button");
|
||||
playButton.textContent = "\u25B6";
|
||||
playButton.style.cssText = "width: 30px; height: 30px; border: none; border-radius: 4px; background: #4a9eff; color: white; font-size: 14px; cursor: pointer; flex-shrink: 0;";
|
||||
playButton.disabled = true;
|
||||
controlsBar.appendChild(playButton);
|
||||
|
||||
// Frame slider
|
||||
const frameSlider = document.createElement("input");
|
||||
frameSlider.type = "range";
|
||||
frameSlider.min = 0;
|
||||
frameSlider.max = 100;
|
||||
frameSlider.value = 0;
|
||||
frameSlider.disabled = true;
|
||||
frameSlider.style.cssText = "flex-grow: 1; height: 6px; min-width: 100px;";
|
||||
controlsBar.appendChild(frameSlider);
|
||||
|
||||
// Frame counter
|
||||
const frameCounter = document.createElement("div");
|
||||
frameCounter.style.cssText = "padding: 4px 8px; background: rgba(0,0,0,0.3); color: #aaa; border-radius: 3px; font-size: 11px; font-family: monospace; min-width: 80px; text-align: center;";
|
||||
frameCounter.textContent = "0 / 0";
|
||||
controlsBar.appendChild(frameCounter);
|
||||
|
||||
// Separator
|
||||
const sep = document.createElement("div");
|
||||
sep.style.cssText = "width: 1px; height: 20px; background: #444; margin: 0 5px;";
|
||||
controlsBar.appendChild(sep);
|
||||
|
||||
// Speed control container
|
||||
const speedContainer = document.createElement("div");
|
||||
speedContainer.style.cssText = "display: flex; align-items: center; gap: 5px;";
|
||||
|
||||
const speedLabel = document.createElement("span");
|
||||
speedLabel.textContent = "Spd:";
|
||||
speedLabel.style.cssText = "color: #aaa; font-size: 11px;";
|
||||
speedContainer.appendChild(speedLabel);
|
||||
|
||||
const speedSlider = document.createElement("input");
|
||||
speedSlider.type = "range";
|
||||
speedSlider.min = 0.1;
|
||||
speedSlider.max = 2.0;
|
||||
speedSlider.step = 0.1;
|
||||
speedSlider.value = 1.0;
|
||||
speedSlider.style.cssText = "width: 60px; height: 4px;";
|
||||
speedContainer.appendChild(speedSlider);
|
||||
|
||||
const speedValue = document.createElement("span");
|
||||
speedValue.textContent = "1.0x";
|
||||
speedValue.style.cssText = "color: #fff; font-size: 11px; min-width: 30px;";
|
||||
speedContainer.appendChild(speedValue);
|
||||
|
||||
controlsBar.appendChild(speedContainer);
|
||||
container.appendChild(controlsBar);
|
||||
|
||||
// State
|
||||
this.bvhViewerState = {
|
||||
iframe: iframe,
|
||||
container: container,
|
||||
playButton: playButton,
|
||||
frameSlider: frameSlider,
|
||||
frameCounter: frameCounter,
|
||||
speedSlider: speedSlider,
|
||||
speedValue: speedValue,
|
||||
isPlaying: false,
|
||||
currentFrame: 0,
|
||||
totalFrames: 0,
|
||||
bvhData: null,
|
||||
viewerReady: false
|
||||
};
|
||||
|
||||
// Add DOM widget
|
||||
this.addDOMWidget("bvh_viewer", "customIframe", container);
|
||||
|
||||
// Play button handler
|
||||
playButton.onclick = () => {
|
||||
const state = this.bvhViewerState;
|
||||
state.isPlaying = !state.isPlaying;
|
||||
playButton.textContent = state.isPlaying ? "\u23F8" : "\u25B6";
|
||||
iframe.contentWindow.postMessage({
|
||||
type: state.isPlaying ? 'play' : 'pause'
|
||||
}, '*');
|
||||
};
|
||||
|
||||
// Frame slider handler
|
||||
frameSlider.oninput = (e) => {
|
||||
const frame = parseInt(e.target.value);
|
||||
this.bvhViewerState.currentFrame = frame;
|
||||
iframe.contentWindow.postMessage({ type: 'setFrame', frame: frame }, '*');
|
||||
};
|
||||
|
||||
// Speed slider handler
|
||||
speedSlider.oninput = (e) => {
|
||||
const speed = parseFloat(e.target.value);
|
||||
speedValue.textContent = speed.toFixed(1) + 'x';
|
||||
iframe.contentWindow.postMessage({ type: 'setSpeed', speed: speed }, '*');
|
||||
};
|
||||
|
||||
// Listen for messages from iframe
|
||||
const messageHandler = (event) => {
|
||||
if (event.source !== iframe.contentWindow) return;
|
||||
|
||||
const data = event.data;
|
||||
const state = this.bvhViewerState;
|
||||
|
||||
if (data.type === 'VIEWER_READY') {
|
||||
state.viewerReady = true;
|
||||
if (state.bvhData) {
|
||||
this.loadBVHData(state.bvhData.bvhContent, state.bvhData.bvhInfo);
|
||||
}
|
||||
} else if (data.type === 'playing') {
|
||||
state.isPlaying = true;
|
||||
playButton.textContent = "\u23F8";
|
||||
} else if (data.type === 'paused') {
|
||||
state.isPlaying = false;
|
||||
playButton.textContent = "\u25B6";
|
||||
} else if (data.type === 'frameChanged') {
|
||||
state.currentFrame = data.frame;
|
||||
state.totalFrames = data.totalFrames;
|
||||
frameSlider.value = data.frame;
|
||||
frameCounter.textContent = `${data.frame} / ${data.totalFrames}`;
|
||||
} else if (data.type === 'looped') {
|
||||
state.currentFrame = 0;
|
||||
frameSlider.value = 0;
|
||||
}
|
||||
};
|
||||
window.addEventListener('message', messageHandler);
|
||||
|
||||
// Cleanup on removal
|
||||
const originalOnRemoved = this.onRemoved;
|
||||
this.onRemoved = function() {
|
||||
window.removeEventListener('message', messageHandler);
|
||||
if (originalOnRemoved) originalOnRemoved.apply(this, arguments);
|
||||
};
|
||||
|
||||
// Handle data from backend
|
||||
this.onExecuted = (message) => {
|
||||
if (message?.bvh_content) {
|
||||
const bvhContent = message.bvh_content[0];
|
||||
const bvhInfo = message.bvh_info ? message.bvh_info[0] : {};
|
||||
|
||||
this.bvhViewerState.bvhData = { bvhContent, bvhInfo };
|
||||
this.bvhViewerState.totalFrames = bvhInfo.num_frames || 0;
|
||||
|
||||
playButton.disabled = false;
|
||||
frameSlider.disabled = false;
|
||||
frameSlider.max = this.bvhViewerState.totalFrames - 1;
|
||||
frameCounter.textContent = `0 / ${this.bvhViewerState.totalFrames}`;
|
||||
|
||||
if (this.bvhViewerState.viewerReady) {
|
||||
this.loadBVHData(bvhContent, bvhInfo);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Method to load BVH data into viewer
|
||||
this.loadBVHData = function(bvhContent, bvhInfo) {
|
||||
if (iframe.contentWindow) {
|
||||
iframe.contentWindow.postMessage({
|
||||
type: 'loadBVH',
|
||||
bvhContent: bvhContent,
|
||||
bvhInfo: bvhInfo
|
||||
}, '*');
|
||||
}
|
||||
};
|
||||
|
||||
this.setSize([Math.max(400, this.size[0] || 400), 720]);
|
||||
return result;
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`${logPrefix} Extension registered: ${extensionName}`);
|
||||
}
|
||||
|
||||
// Auto-register all known BVH viewer node types
|
||||
BVH_VIEWER_NODES.forEach(config => createBVHViewerExtension(config));
|
||||
|
||||
export { createBVHViewerExtension, detectExtensionFolder };
|
||||
@@ -0,0 +1,219 @@
|
||||
/**
|
||||
* ComfyUI Compare Skeletons Widget
|
||||
*
|
||||
* Widget for UniRigCompareSkeletons node - displays two FBX skeletons side-by-side
|
||||
* with synced camera rotation and cross-bone highlighting.
|
||||
*/
|
||||
|
||||
import { app } from "../../../../scripts/app.js";
|
||||
import { detectExtensionFolder, getViewerUrl } from "./mesh_preview_fbx.js";
|
||||
|
||||
console.log("[UniRig Compare] Loading compare skeleton widget...");
|
||||
|
||||
const extensionFolder = detectExtensionFolder();
|
||||
if (!extensionFolder) {
|
||||
console.error("[UniRig Compare] Could not detect extension folder");
|
||||
}
|
||||
|
||||
console.log("[UniRig Compare] Detected extension folder:", extensionFolder);
|
||||
|
||||
app.registerExtension({
|
||||
name: "unirig.compareskeletons",
|
||||
|
||||
async beforeRegisterNodeDef(nodeType, nodeData, app) {
|
||||
if (nodeData.name === "CompareSkeletons" || nodeData.name === "UniRigCompareSkeletons") {
|
||||
console.log("[UniRig Compare] Registering Compare Skeletons node");
|
||||
|
||||
const onNodeCreated = nodeType.prototype.onNodeCreated;
|
||||
nodeType.prototype.onNodeCreated = function() {
|
||||
const r = onNodeCreated ? onNodeCreated.apply(this, arguments) : undefined;
|
||||
|
||||
console.log("[UniRig Compare] Node created, adding compare viewer widget");
|
||||
|
||||
// Create iframe for comparison viewer
|
||||
const iframe = document.createElement("iframe");
|
||||
iframe.style.width = "100%";
|
||||
iframe.style.flex = "1 1 0";
|
||||
iframe.style.minHeight = "0";
|
||||
iframe.style.border = "none";
|
||||
iframe.style.backgroundColor = "#2a2a2a";
|
||||
|
||||
// Load viewer from file URL
|
||||
const viewerUrl = getViewerUrl(extensionFolder, "viewer_fbx_compare.html");
|
||||
iframe.src = viewerUrl;
|
||||
console.log("[UniRig Compare] Setting iframe src to:", viewerUrl);
|
||||
|
||||
iframe.onload = () => {
|
||||
console.log("[UniRig Compare] Iframe loaded successfully");
|
||||
};
|
||||
iframe.onerror = (e) => {
|
||||
console.error("[UniRig Compare] Iframe failed to load:", e);
|
||||
};
|
||||
|
||||
// Add widget
|
||||
const widget = this.addDOMWidget("preview", "FBX_COMPARE_PREVIEW", iframe, {
|
||||
getValue() { return ""; },
|
||||
setValue(v) { }
|
||||
});
|
||||
|
||||
console.log("[UniRig Compare] Widget created:", widget);
|
||||
|
||||
// Set widget size - wider for side-by-side view
|
||||
widget.computeSize = function(width) {
|
||||
const w = width || 800;
|
||||
const h = w * 0.75; // Wider aspect ratio for dual view
|
||||
return [w, h];
|
||||
};
|
||||
|
||||
widget.element = iframe;
|
||||
|
||||
// Store iframe reference
|
||||
this.compareViewerIframe = iframe;
|
||||
this.compareViewerReady = false;
|
||||
|
||||
// Listen for ready message from iframe
|
||||
const onMessage = (event) => {
|
||||
if (event.data && event.data.type === 'VIEWER_READY') {
|
||||
console.log("[UniRig Compare] Viewer iframe is ready!");
|
||||
this.compareViewerReady = true;
|
||||
}
|
||||
};
|
||||
window.addEventListener('message', onMessage.bind(this));
|
||||
|
||||
const notifyIframeResize = () => {
|
||||
if (iframe.contentWindow) {
|
||||
const rect = iframe.getBoundingClientRect();
|
||||
iframe.contentWindow.postMessage({
|
||||
type: 'RESIZE',
|
||||
width: rect.width,
|
||||
height: rect.height
|
||||
}, '*');
|
||||
}
|
||||
};
|
||||
|
||||
this.onResize = function(size) {
|
||||
const isVueNodes = iframe.closest('[data-node-id]') !== null ||
|
||||
document.querySelector('.vue-graph-canvas') !== null;
|
||||
|
||||
if (!isVueNodes && size && size[1]) {
|
||||
const nodeHeight = size[1];
|
||||
const headerHeight = 70;
|
||||
const availableHeight = Math.max(200, nodeHeight - headerHeight);
|
||||
iframe.style.height = availableHeight + 'px';
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
notifyIframeResize();
|
||||
});
|
||||
};
|
||||
|
||||
let resizeTimeout = null;
|
||||
let lastSize = { width: 0, height: 0 };
|
||||
const resizeObserver = new ResizeObserver((entries) => {
|
||||
const entry = entries[0];
|
||||
const newWidth = entry.contentRect.width;
|
||||
const newHeight = entry.contentRect.height;
|
||||
|
||||
if (Math.abs(newWidth - lastSize.width) < 1 && Math.abs(newHeight - lastSize.height) < 1) {
|
||||
return;
|
||||
}
|
||||
lastSize = { width: newWidth, height: newHeight };
|
||||
|
||||
if (resizeTimeout) {
|
||||
clearTimeout(resizeTimeout);
|
||||
}
|
||||
resizeTimeout = setTimeout(() => {
|
||||
notifyIframeResize();
|
||||
}, 50);
|
||||
});
|
||||
resizeObserver.observe(iframe);
|
||||
|
||||
const originalOnRemoved = this.onRemoved;
|
||||
this.onRemoved = function() {
|
||||
resizeObserver.disconnect();
|
||||
if (resizeTimeout) {
|
||||
clearTimeout(resizeTimeout);
|
||||
}
|
||||
window.removeEventListener('message', onMessage);
|
||||
if (originalOnRemoved) {
|
||||
originalOnRemoved.apply(this, arguments);
|
||||
}
|
||||
};
|
||||
|
||||
// Set initial node size (wider for dual view)
|
||||
this.setSize([800, 600]);
|
||||
|
||||
// Handle execution
|
||||
const onExecuted = this.onExecuted;
|
||||
this.onExecuted = function(message) {
|
||||
console.log("[UniRig Compare] onExecuted called with message:", message);
|
||||
onExecuted?.apply(this, arguments);
|
||||
|
||||
// The message contains both FBX file paths
|
||||
const leftFile = message?.fbx_file_left?.[0];
|
||||
const rightFile = message?.fbx_file_right?.[0];
|
||||
|
||||
if (leftFile || rightFile) {
|
||||
console.log("[UniRig Compare] Loading FBX files - Left:", leftFile, "Right:", rightFile);
|
||||
|
||||
// Build file paths
|
||||
const buildFilepath = (filename) => {
|
||||
if (!filename) return null;
|
||||
if (!filename.includes('/') && !filename.includes('\\')) {
|
||||
return `${window.location.origin}/view?filename=${encodeURIComponent(filename)}&type=output&subfolder=`;
|
||||
} else {
|
||||
const basename = filename.split(/[/\\]/).pop();
|
||||
return `${window.location.origin}/view?filename=${encodeURIComponent(basename)}&type=output&subfolder=`;
|
||||
}
|
||||
};
|
||||
|
||||
const filepathLeft = buildFilepath(leftFile);
|
||||
const filepathRight = buildFilepath(rightFile);
|
||||
|
||||
// Send message to iframe
|
||||
const sendMessage = () => {
|
||||
if (iframe.contentWindow) {
|
||||
console.log("[UniRig Compare] Sending postMessage to iframe");
|
||||
iframe.contentWindow.postMessage({
|
||||
type: "LOAD_FBX_COMPARE",
|
||||
filepathLeft: filepathLeft,
|
||||
filepathRight: filepathRight,
|
||||
timestamp: Date.now()
|
||||
}, "*");
|
||||
} else {
|
||||
console.error("[UniRig Compare] Iframe contentWindow not available");
|
||||
}
|
||||
};
|
||||
|
||||
// Wait for iframe to be ready, or use timeout as fallback
|
||||
if (this.compareViewerReady) {
|
||||
sendMessage();
|
||||
} else {
|
||||
const checkReady = setInterval(() => {
|
||||
if (this.compareViewerReady) {
|
||||
clearInterval(checkReady);
|
||||
sendMessage();
|
||||
}
|
||||
}, 50);
|
||||
|
||||
// Fallback timeout after 2 seconds
|
||||
setTimeout(() => {
|
||||
clearInterval(checkReady);
|
||||
if (!this.compareViewerReady) {
|
||||
console.warn("[UniRig Compare] Iframe not ready after 2s, sending anyway");
|
||||
sendMessage();
|
||||
}
|
||||
}, 2000);
|
||||
}
|
||||
} else {
|
||||
console.log("[UniRig Compare] No FBX files in message data. Keys:", Object.keys(message || {}));
|
||||
}
|
||||
};
|
||||
|
||||
return r;
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
console.log("[UniRig Compare] Compare skeleton widget registered");
|
||||
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* Compare SMPL to BVH Viewer Widget
|
||||
* Side-by-side comparison of SMPL mesh and BVH skeleton using split view
|
||||
*/
|
||||
|
||||
import { app } from "../../../../scripts/app.js";
|
||||
|
||||
console.log("[CompareSMPLBVH] Loading Compare SMPL BVH extension");
|
||||
|
||||
// Node types that use this viewer
|
||||
const COMPARE_VIEWER_NODES = [
|
||||
{
|
||||
nodeName: "CompareSMPLtoBVH",
|
||||
extensionName: "comfy3d.comparesmplbvh",
|
||||
logPrefix: "[CompareSMPLBVH]",
|
||||
smplMeshApiPath: "/motioncapture/smpl_mesh"
|
||||
}
|
||||
];
|
||||
|
||||
function detectExtensionFolder() {
|
||||
try {
|
||||
if (typeof import.meta !== 'undefined' && import.meta.url) {
|
||||
const match = import.meta.url.match(/\/extensions\/([^\/]+)\//);
|
||||
if (match) return match[1];
|
||||
}
|
||||
const scripts = document.getElementsByTagName('script');
|
||||
for (let i = scripts.length - 1; i >= 0; i--) {
|
||||
const src = scripts[i].src;
|
||||
if (src) {
|
||||
const match = src.match(/\/extensions\/([^\/]+)\//);
|
||||
if (match) return match[1];
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[CompareSMPLBVH] Could not detect extension folder:', e);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function getViewerUrl(extensionFolder) {
|
||||
return `/extensions/${extensionFolder}/viewer_compare_smpl_bvh.html?v=` + Date.now();
|
||||
}
|
||||
|
||||
function createCompareViewerExtension(config) {
|
||||
const { extensionName, nodeName, logPrefix, smplMeshApiPath } = config;
|
||||
|
||||
console.log(`${logPrefix} Loading Compare SMPL BVH extension...`);
|
||||
|
||||
const extensionFolder = detectExtensionFolder();
|
||||
if (!extensionFolder) {
|
||||
console.error(`${logPrefix} Could not detect extension folder`);
|
||||
return;
|
||||
}
|
||||
|
||||
app.registerExtension({
|
||||
name: extensionName,
|
||||
|
||||
async beforeRegisterNodeDef(nodeType, nodeData, app) {
|
||||
if (nodeData.name !== nodeName) return;
|
||||
|
||||
console.log(`${logPrefix} Registering ${nodeName} node`);
|
||||
|
||||
const onNodeCreated = nodeType.prototype.onNodeCreated;
|
||||
|
||||
nodeType.prototype.onNodeCreated = function() {
|
||||
const result = onNodeCreated?.apply(this, arguments);
|
||||
|
||||
const container = document.createElement("div");
|
||||
container.style.width = "100%";
|
||||
container.style.height = "100%";
|
||||
container.style.background = "#222";
|
||||
container.style.display = "flex";
|
||||
container.style.flexDirection = "column";
|
||||
|
||||
const iframe = document.createElement("iframe");
|
||||
iframe.style.width = "100%";
|
||||
iframe.style.height = "100%";
|
||||
iframe.style.border = "none";
|
||||
iframe.style.flexGrow = "1";
|
||||
iframe.src = getViewerUrl(extensionFolder);
|
||||
container.appendChild(iframe);
|
||||
|
||||
// Store references
|
||||
this.compareViewerIframe = iframe;
|
||||
this.compareViewerReady = false;
|
||||
this.compareDataToLoad = null;
|
||||
|
||||
// Add widget with computeSize for automatic resizing
|
||||
const widget = this.addDOMWidget("compare_viewer", "iframe", container);
|
||||
widget.computeSize = (w) => [w, 500];
|
||||
|
||||
// Set an initial size for the node
|
||||
this.setSize([this.size[0], 500 + 30]);
|
||||
|
||||
// Listen for viewer ready message
|
||||
const messageHandler = (event) => {
|
||||
if (event.source !== iframe.contentWindow) return;
|
||||
if (event.data.type === 'VIEWER_READY') {
|
||||
this.compareViewerReady = true;
|
||||
if (this.compareDataToLoad) {
|
||||
this.loadCompareData(this.compareDataToLoad);
|
||||
}
|
||||
}
|
||||
};
|
||||
window.addEventListener('message', messageHandler);
|
||||
|
||||
// Cleanup on removal
|
||||
const originalOnRemoved = this.onRemoved;
|
||||
this.onRemoved = function() {
|
||||
window.removeEventListener('message', messageHandler);
|
||||
if (originalOnRemoved) originalOnRemoved.apply(this, arguments);
|
||||
};
|
||||
|
||||
// Method to load compare data
|
||||
this.loadCompareData = function(data) {
|
||||
if (!iframe.contentWindow) {
|
||||
this.compareDataToLoad = data;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.compareViewerReady) {
|
||||
this.compareDataToLoad = data;
|
||||
return;
|
||||
}
|
||||
|
||||
iframe.contentWindow.postMessage({
|
||||
type: 'loadData',
|
||||
smplFilename: data.smplFilename,
|
||||
bvhContent: data.bvhContent,
|
||||
smplMeshApiPath: smplMeshApiPath
|
||||
}, '*');
|
||||
this.compareDataToLoad = null;
|
||||
};
|
||||
|
||||
this.onExecuted = (msg) => {
|
||||
if (msg?.smpl_mesh_filename && msg?.bvh_content) {
|
||||
const data = {
|
||||
smplFilename: msg.smpl_mesh_filename[0],
|
||||
bvhContent: msg.bvh_content[0]
|
||||
};
|
||||
|
||||
if (this.compareViewerReady) {
|
||||
this.loadCompareData(data);
|
||||
} else {
|
||||
this.compareDataToLoad = data;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return result;
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`${logPrefix} Extension registered: ${extensionName}`);
|
||||
}
|
||||
|
||||
// Auto-register all known compare viewer node types
|
||||
COMPARE_VIEWER_NODES.forEach(config => createCompareViewerExtension(config));
|
||||
|
||||
export { createCompareViewerExtension, detectExtensionFolder };
|
||||
@@ -0,0 +1,10 @@
|
||||
import { createFBXPreviewExtension } from "./mesh_preview_fbx.js";
|
||||
|
||||
// Register UniRigDebugSkeleton with the debug viewer
|
||||
createFBXPreviewExtension({
|
||||
nodeName: "UniRigDebugSkeleton",
|
||||
extensionName: "unirig.debugskeleton",
|
||||
logPrefix: "[UniRig Debug]",
|
||||
fbxExportApiPath: "/unirig/export_posed_fbx",
|
||||
viewerFile: "viewer_fbx_debug.html"
|
||||
});
|
||||
@@ -0,0 +1,315 @@
|
||||
/**
|
||||
* FBX Animation Viewer Widget - Interactive animation playback with Three.js
|
||||
* Controls animation playback, speed, looping, and skeleton visibility
|
||||
*/
|
||||
|
||||
import { app } from "../../../../scripts/app.js";
|
||||
|
||||
console.log("[FBXAnimationViewer] Loading FBX Animation Viewer extension");
|
||||
|
||||
// Node types that use this viewer
|
||||
const FBX_ANIMATION_NODES = [
|
||||
{
|
||||
nodeName: "FBXAnimationViewer",
|
||||
extensionName: "comfy3d.fbxanimationviewer",
|
||||
logPrefix: "[FBXAnimationViewer]"
|
||||
}
|
||||
];
|
||||
|
||||
function detectExtensionFolder() {
|
||||
try {
|
||||
if (typeof import.meta !== 'undefined' && import.meta.url) {
|
||||
const match = import.meta.url.match(/\/extensions\/([^\/]+)\//);
|
||||
if (match) return match[1];
|
||||
}
|
||||
const scripts = document.getElementsByTagName('script');
|
||||
for (let i = scripts.length - 1; i >= 0; i--) {
|
||||
const src = scripts[i].src;
|
||||
if (src) {
|
||||
const match = src.match(/\/extensions\/([^\/]+)\//);
|
||||
if (match) return match[1];
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[FBXAnimationViewer] Could not detect extension folder:', e);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function getViewerUrl(extensionFolder) {
|
||||
return `/extensions/${extensionFolder}/viewer_fbx_animation.html?v=` + Date.now();
|
||||
}
|
||||
|
||||
function createFBXAnimationViewerExtension(config) {
|
||||
const { extensionName, nodeName, logPrefix } = config;
|
||||
|
||||
console.log(`${logPrefix} Loading FBX Animation Viewer extension...`);
|
||||
|
||||
const extensionFolder = detectExtensionFolder();
|
||||
if (!extensionFolder) {
|
||||
console.error(`${logPrefix} Could not detect extension folder`);
|
||||
return;
|
||||
}
|
||||
|
||||
app.registerExtension({
|
||||
name: extensionName,
|
||||
|
||||
async beforeRegisterNodeDef(nodeType, nodeData, app) {
|
||||
if (nodeData.name !== nodeName) return;
|
||||
|
||||
console.log(`${logPrefix} Registering ${nodeName} node`);
|
||||
|
||||
const onNodeCreated = nodeType.prototype.onNodeCreated;
|
||||
|
||||
nodeType.prototype.onNodeCreated = function() {
|
||||
const result = onNodeCreated?.apply(this, arguments);
|
||||
|
||||
// Main Container
|
||||
const mainContainer = document.createElement("div");
|
||||
mainContainer.style.cssText = "width: 100%; height: 100%; display: flex; flex-direction: column; overflow: hidden; background: #1a1a1a; border: 1px solid #444; box-sizing: border-box;";
|
||||
|
||||
// Viewer Area (Iframe)
|
||||
const viewerArea = document.createElement("div");
|
||||
viewerArea.style.cssText = "position: relative; flex-grow: 1; min-height: 200px; overflow: hidden;";
|
||||
|
||||
const iframe = document.createElement("iframe");
|
||||
iframe.style.cssText = "display: block; width: 100%; height: 100%; border: none;";
|
||||
iframe.src = getViewerUrl(extensionFolder);
|
||||
|
||||
viewerArea.appendChild(iframe);
|
||||
mainContainer.appendChild(viewerArea);
|
||||
|
||||
// Controls Area
|
||||
const controlsContainer = document.createElement("div");
|
||||
controlsContainer.style.cssText = "flex-shrink: 0; background: #2a2a2a; border-top: 1px solid #444; padding: 10px; box-sizing: border-box; color: white; font-size: 12px; font-family: Arial, sans-serif;";
|
||||
|
||||
controlsContainer.innerHTML = `
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-bottom: 10px;">
|
||||
<button id="playPauseBtn" style="padding: 6px; background: #444; color: white; border: none; border-radius: 4px; cursor: pointer;">\u25B6 Play</button>
|
||||
<button id="resetBtn" style="padding: 6px; background: #444; color: white; border: none; border-radius: 4px; cursor: pointer;">\u27F2 Reset</button>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom: 8px;">
|
||||
<input type="range" id="timeline" min="0" max="100" value="0" style="width: 100%; display: block; margin-bottom: 2px;" disabled>
|
||||
<div style="display: flex; justify-content: space-between; font-size: 10px; color: #aaa;">
|
||||
<span><span id="currentFrame">0</span> / <span id="totalFrames">0</span></span>
|
||||
<span id="currentTime">0.00s</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; gap: 8px; margin-bottom: 8px;">
|
||||
<select id="animationSelect" style="flex-grow: 1; padding: 4px; background: #333; color: white; border: 1px solid #555; border-radius: 4px;" disabled></select>
|
||||
<select id="speedControl" style="width: 60px; padding: 4px; background: #333; color: white; border: 1px solid #555; border-radius: 4px;" disabled>
|
||||
<option value="0.25">0.25x</option>
|
||||
<option value="0.5">0.5x</option>
|
||||
<option value="1" selected>1x</option>
|
||||
<option value="2">2x</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; flex-wrap: wrap; gap: 8px; font-size: 11px;">
|
||||
<label><input type="checkbox" id="loop" checked disabled> Loop</label>
|
||||
<label><input type="checkbox" id="showSkeleton" checked> Bones</label>
|
||||
<label><input type="checkbox" id="showMesh" checked> Mesh</label>
|
||||
<label><input type="checkbox" id="xraySkeleton"> X-Ray</label>
|
||||
</div>
|
||||
|
||||
<button id="resetCamera" style="width: 100%; padding: 8px; background: #444; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 12px; margin-top: 8px;">Reset Camera</button>
|
||||
`;
|
||||
|
||||
mainContainer.appendChild(controlsContainer);
|
||||
|
||||
// Add Single Widget
|
||||
const widget = this.addDOMWidget("fbx_viewer_unified", "main", mainContainer, {
|
||||
serialize: false,
|
||||
hideOnZoom: false
|
||||
});
|
||||
|
||||
widget.computeSize = (width) => [width, 500];
|
||||
|
||||
// Get control elements
|
||||
const playPauseBtn = controlsContainer.querySelector('#playPauseBtn');
|
||||
const resetBtn = controlsContainer.querySelector('#resetBtn');
|
||||
const timeline = controlsContainer.querySelector('#timeline');
|
||||
const speedControl = controlsContainer.querySelector('#speedControl');
|
||||
const loopCheckbox = controlsContainer.querySelector('#loop');
|
||||
const animationSelect = controlsContainer.querySelector('#animationSelect');
|
||||
const showSkeleton = controlsContainer.querySelector('#showSkeleton');
|
||||
const showMesh = controlsContainer.querySelector('#showMesh');
|
||||
const xraySkeleton = controlsContainer.querySelector('#xraySkeleton');
|
||||
const resetCamera = controlsContainer.querySelector('#resetCamera');
|
||||
const currentTimeEl = controlsContainer.querySelector('#currentTime');
|
||||
const currentFrameEl = controlsContainer.querySelector('#currentFrame');
|
||||
const totalFramesEl = controlsContainer.querySelector('#totalFrames');
|
||||
|
||||
// Store references
|
||||
this.animationViewerIframe = iframe;
|
||||
this.animationViewerReady = false;
|
||||
this.animationControls = {
|
||||
playPauseBtn, resetBtn, timeline, speedControl, loopCheckbox, animationSelect,
|
||||
showSkeleton, showMesh, xraySkeleton, resetCamera,
|
||||
currentTimeEl, currentFrameEl, totalFramesEl
|
||||
};
|
||||
|
||||
// Wire up controls
|
||||
playPauseBtn.addEventListener('click', () => {
|
||||
iframe.contentWindow.postMessage({ type: 'PLAY_PAUSE' }, '*');
|
||||
});
|
||||
|
||||
resetBtn.addEventListener('click', () => {
|
||||
iframe.contentWindow.postMessage({ type: 'RESET' }, '*');
|
||||
});
|
||||
|
||||
timeline.addEventListener('input', (e) => {
|
||||
const progress = parseFloat(e.target.value) / 100;
|
||||
iframe.contentWindow.postMessage({ type: 'SET_TIMELINE', progress }, '*');
|
||||
});
|
||||
|
||||
speedControl.addEventListener('change', (e) => {
|
||||
iframe.contentWindow.postMessage({ type: 'SET_SPEED', speed: parseFloat(e.target.value) }, '*');
|
||||
});
|
||||
|
||||
loopCheckbox.addEventListener('change', (e) => {
|
||||
iframe.contentWindow.postMessage({ type: 'SET_LOOP', loop: e.target.checked }, '*');
|
||||
});
|
||||
|
||||
animationSelect.addEventListener('change', (e) => {
|
||||
iframe.contentWindow.postMessage({ type: 'CHANGE_ANIMATION', index: parseInt(e.target.value) }, '*');
|
||||
});
|
||||
|
||||
showSkeleton.addEventListener('change', (e) => {
|
||||
iframe.contentWindow.postMessage({ type: 'TOGGLE_SKELETON', visible: e.target.checked }, '*');
|
||||
});
|
||||
|
||||
showMesh.addEventListener('change', (e) => {
|
||||
iframe.contentWindow.postMessage({ type: 'TOGGLE_MESH', visible: e.target.checked }, '*');
|
||||
});
|
||||
|
||||
xraySkeleton.addEventListener('change', (e) => {
|
||||
iframe.contentWindow.postMessage({ type: 'TOGGLE_XRAY', xray: e.target.checked }, '*');
|
||||
});
|
||||
|
||||
resetCamera.addEventListener('click', () => {
|
||||
iframe.contentWindow.postMessage({ type: 'RESET_CAMERA' }, '*');
|
||||
});
|
||||
|
||||
// Listen for messages from iframe
|
||||
const messageHandler = (event) => {
|
||||
if (event.source !== iframe.contentWindow) return;
|
||||
|
||||
const { type, ...data } = event.data;
|
||||
|
||||
switch(type) {
|
||||
case 'VIEWER_READY':
|
||||
this.animationViewerReady = true;
|
||||
if (this.fbxPathToLoad) {
|
||||
this.loadAnimationInViewer(this.fbxPathToLoad);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'ANIMATIONS_LOADED':
|
||||
animationSelect.innerHTML = '';
|
||||
data.animations.forEach(anim => {
|
||||
const option = document.createElement('option');
|
||||
option.value = anim.index;
|
||||
option.textContent = anim.name;
|
||||
animationSelect.appendChild(option);
|
||||
});
|
||||
|
||||
playPauseBtn.disabled = false;
|
||||
resetBtn.disabled = false;
|
||||
timeline.disabled = false;
|
||||
speedControl.disabled = false;
|
||||
loopCheckbox.disabled = false;
|
||||
animationSelect.disabled = data.animations.length <= 1;
|
||||
break;
|
||||
|
||||
case 'TIME_UPDATE':
|
||||
if (currentTimeEl) currentTimeEl.textContent = data.time.toFixed(2) + 's';
|
||||
if (currentFrameEl) currentFrameEl.textContent = data.frame;
|
||||
if (totalFramesEl) totalFramesEl.textContent = data.totalFrames;
|
||||
timeline.value = data.progress;
|
||||
break;
|
||||
|
||||
case 'PLAY_STATE_CHANGED':
|
||||
playPauseBtn.textContent = data.isPlaying ? '\u23F8 Pause' : '\u25B6 Play';
|
||||
break;
|
||||
|
||||
case 'ANIMATION_CHANGED':
|
||||
const fps = 30;
|
||||
if (totalFramesEl) totalFramesEl.textContent = Math.floor(data.duration * fps);
|
||||
playPauseBtn.textContent = '\u23F8 Pause';
|
||||
break;
|
||||
|
||||
case 'NO_ANIMATIONS':
|
||||
playPauseBtn.textContent = 'No Animation';
|
||||
playPauseBtn.disabled = true;
|
||||
break;
|
||||
}
|
||||
};
|
||||
window.addEventListener('message', messageHandler);
|
||||
|
||||
// Cleanup on removal
|
||||
const originalOnRemoved = this.onRemoved;
|
||||
this.onRemoved = function() {
|
||||
window.removeEventListener('message', messageHandler);
|
||||
if (originalOnRemoved) originalOnRemoved.apply(this, arguments);
|
||||
};
|
||||
|
||||
// Set initial node size
|
||||
const nodeWidth = Math.max(512, this.size[0] || 512);
|
||||
this.setSize([nodeWidth, 500]);
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
// Add method to load FBX
|
||||
nodeType.prototype.loadAnimationInViewer = function(fbxPath) {
|
||||
if (!this.animationViewerIframe || !this.animationViewerIframe.contentWindow) {
|
||||
this.fbxPathToLoad = fbxPath;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.animationViewerReady) {
|
||||
this.fbxPathToLoad = fbxPath;
|
||||
return;
|
||||
}
|
||||
|
||||
let relativePath = fbxPath;
|
||||
if (fbxPath.includes('/output/')) {
|
||||
relativePath = fbxPath.split('/output/')[1];
|
||||
} else if (fbxPath.includes('/input/')) {
|
||||
relativePath = fbxPath.split('/input/')[1];
|
||||
} else {
|
||||
relativePath = fbxPath.split('/').pop();
|
||||
}
|
||||
|
||||
const viewPath = window.location.origin + "/view?filename=" + encodeURIComponent(relativePath);
|
||||
|
||||
this.animationViewerIframe.contentWindow.postMessage({
|
||||
type: 'LOAD_FBX',
|
||||
path: viewPath
|
||||
}, '*');
|
||||
this.fbxPathToLoad = null;
|
||||
};
|
||||
|
||||
// Override onExecuted to load FBX
|
||||
const onExecuted = nodeType.prototype.onExecuted;
|
||||
nodeType.prototype.onExecuted = function(message) {
|
||||
if (onExecuted) onExecuted.apply(this, arguments);
|
||||
|
||||
if (message?.fbx_path?.[0]) {
|
||||
const fbxPath = message.fbx_path[0];
|
||||
this.loadAnimationInViewer(fbxPath);
|
||||
}
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`${logPrefix} Extension registered: ${extensionName}`);
|
||||
}
|
||||
|
||||
// Auto-register all known FBX animation viewer node types
|
||||
FBX_ANIMATION_NODES.forEach(config => createFBXAnimationViewerExtension(config));
|
||||
|
||||
export { createFBXAnimationViewerExtension, detectExtensionFolder };
|
||||
@@ -0,0 +1,249 @@
|
||||
/**
|
||||
* ComfyUI GeomPack - Gaussian Splat Preview Widget
|
||||
* Interactive 3D Gaussian Splatting viewer using gsplat.js
|
||||
*/
|
||||
|
||||
import { app } from "../../../scripts/app.js";
|
||||
|
||||
// Auto-detect extension folder name (handles ComfyUI-GeometryPack or comfyui-geometrypack)
|
||||
const EXTENSION_FOLDER = (() => {
|
||||
const url = import.meta.url;
|
||||
const match = url.match(/\/extensions\/([^/]+)\//);
|
||||
return match ? match[1] : "ComfyUI-GeometryPack";
|
||||
})();
|
||||
|
||||
console.log("[GeomPack Gaussian] Loading extension...");
|
||||
|
||||
app.registerExtension({
|
||||
name: "geompack.gaussianpreview",
|
||||
|
||||
async beforeRegisterNodeDef(nodeType, nodeData, app) {
|
||||
if (nodeData.name === "GeomPackPreviewGaussian") {
|
||||
console.log("[GeomPack Gaussian] Registering Preview Gaussian node");
|
||||
|
||||
const onNodeCreated = nodeType.prototype.onNodeCreated;
|
||||
nodeType.prototype.onNodeCreated = function() {
|
||||
const r = onNodeCreated ? onNodeCreated.apply(this, arguments) : undefined;
|
||||
|
||||
// Create container for viewer + info panel
|
||||
const container = document.createElement("div");
|
||||
container.style.width = "100%";
|
||||
container.style.height = "100%";
|
||||
container.style.display = "flex";
|
||||
container.style.flexDirection = "column";
|
||||
container.style.backgroundColor = "#1a1a1a";
|
||||
container.style.overflow = "hidden";
|
||||
|
||||
// Create iframe for gsplat.js viewer
|
||||
const iframe = document.createElement("iframe");
|
||||
iframe.style.width = "100%";
|
||||
iframe.style.flex = "1 1 0";
|
||||
iframe.style.minHeight = "0";
|
||||
iframe.style.border = "none";
|
||||
iframe.style.backgroundColor = "#1a1a1a";
|
||||
|
||||
// Point to gsplat.js HTML viewer (with cache buster)
|
||||
iframe.src = `/extensions/${EXTENSION_FOLDER}/viewer_gaussian.html?v=` + Date.now();
|
||||
|
||||
// Create info panel
|
||||
const infoPanel = document.createElement("div");
|
||||
infoPanel.style.backgroundColor = "#1a1a1a";
|
||||
infoPanel.style.borderTop = "1px solid #444";
|
||||
infoPanel.style.padding = "6px 12px";
|
||||
infoPanel.style.fontSize = "10px";
|
||||
infoPanel.style.fontFamily = "monospace";
|
||||
infoPanel.style.color = "#ccc";
|
||||
infoPanel.style.lineHeight = "1.3";
|
||||
infoPanel.style.flexShrink = "0";
|
||||
infoPanel.style.overflow = "hidden";
|
||||
infoPanel.innerHTML = '<span style="color: #888;">Gaussian splat info will appear here after execution</span>';
|
||||
|
||||
// Add iframe and info panel to container
|
||||
container.appendChild(iframe);
|
||||
container.appendChild(infoPanel);
|
||||
|
||||
// Add widget with required options
|
||||
const widget = this.addDOMWidget("preview_gaussian", "GAUSSIAN_PREVIEW", container, {
|
||||
getValue() { return ""; },
|
||||
setValue(v) { }
|
||||
});
|
||||
|
||||
// Store reference to node for dynamic resizing
|
||||
const node = this;
|
||||
let currentNodeSize = [512, 580];
|
||||
|
||||
widget.computeSize = () => currentNodeSize;
|
||||
|
||||
// Store references
|
||||
this.gaussianViewerIframe = iframe;
|
||||
this.gaussianInfoPanel = infoPanel;
|
||||
|
||||
// Function to resize node dynamically
|
||||
this.resizeToAspectRatio = function(imageWidth, imageHeight) {
|
||||
const aspectRatio = imageWidth / imageHeight;
|
||||
const nodeWidth = 512;
|
||||
const viewerHeight = Math.round(nodeWidth / aspectRatio);
|
||||
const nodeHeight = viewerHeight + 60; // Add space for info panel
|
||||
|
||||
currentNodeSize = [nodeWidth, nodeHeight];
|
||||
node.setSize(currentNodeSize);
|
||||
node.setDirtyCanvas(true, true);
|
||||
app.graph.setDirtyCanvas(true, true);
|
||||
|
||||
console.log("[GeomPack Gaussian] Resized node to:", nodeWidth, "x", nodeHeight, "(aspect ratio:", aspectRatio.toFixed(2), ")");
|
||||
};
|
||||
|
||||
// Track iframe load state
|
||||
let iframeLoaded = false;
|
||||
iframe.addEventListener('load', () => {
|
||||
iframeLoaded = true;
|
||||
});
|
||||
|
||||
// Listen for messages from iframe
|
||||
window.addEventListener('message', async (event) => {
|
||||
// Handle screenshot messages
|
||||
if (event.data.type === 'SCREENSHOT' && event.data.image) {
|
||||
try {
|
||||
// Convert base64 data URL to blob
|
||||
const base64Data = event.data.image.split(',')[1];
|
||||
const byteString = atob(base64Data);
|
||||
const arrayBuffer = new ArrayBuffer(byteString.length);
|
||||
const uint8Array = new Uint8Array(arrayBuffer);
|
||||
|
||||
for (let i = 0; i < byteString.length; i++) {
|
||||
uint8Array[i] = byteString.charCodeAt(i);
|
||||
}
|
||||
|
||||
const blob = new Blob([uint8Array], { type: 'image/png' });
|
||||
|
||||
// Generate filename with timestamp
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
const filename = `gaussian-screenshot-${timestamp}.png`;
|
||||
|
||||
// Create FormData for upload
|
||||
const formData = new FormData();
|
||||
formData.append('image', blob, filename);
|
||||
formData.append('type', 'output');
|
||||
formData.append('subfolder', '');
|
||||
|
||||
// Upload to ComfyUI backend
|
||||
const response = await fetch('/upload/image', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
console.log('[GeomPack Gaussian] Screenshot saved:', result.name);
|
||||
} else {
|
||||
throw new Error(`Upload failed: ${response.status}`);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('[GeomPack Gaussian] Error saving screenshot:', error);
|
||||
}
|
||||
}
|
||||
// Handle copy image to clipboard messages
|
||||
else if (event.data.type === 'COPY_IMAGE' && event.data.success) {
|
||||
console.log('[GeomPack Gaussian] Image copied to clipboard successfully');
|
||||
}
|
||||
else if (event.data.type === 'COPY_IMAGE' && !event.data.success) {
|
||||
console.error('[GeomPack Gaussian] Failed to copy image to clipboard:', event.data.error);
|
||||
}
|
||||
// Handle error messages from iframe
|
||||
else if (event.data.type === 'MESH_ERROR' && event.data.error) {
|
||||
console.error('[GeomPack Gaussian] Error from viewer:', event.data.error);
|
||||
if (infoPanel) {
|
||||
infoPanel.innerHTML = `<div style="color: #ff6b6b;">Error: ${event.data.error}</div>`;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Set initial node size
|
||||
this.setSize([512, 580]);
|
||||
|
||||
// Handle execution
|
||||
const onExecuted = this.onExecuted;
|
||||
this.onExecuted = function(message) {
|
||||
console.log("[GeomPack Gaussian] onExecuted called with:", message);
|
||||
onExecuted?.apply(this, arguments);
|
||||
|
||||
// Check for errors
|
||||
if (message?.error && message.error[0]) {
|
||||
infoPanel.innerHTML = `<div style="color: #ff6b6b;">Error: ${message.error[0]}</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
// The message IS the UI data (not message.ui)
|
||||
if (message?.ply_file && message.ply_file[0]) {
|
||||
const filename = message.ply_file[0];
|
||||
const displayName = message.filename?.[0] || filename;
|
||||
const fileSizeMb = message.file_size_mb?.[0] || 'N/A';
|
||||
|
||||
// Extract camera parameters if provided
|
||||
const extrinsics = message.extrinsics?.[0] || null;
|
||||
const intrinsics = message.intrinsics?.[0] || null;
|
||||
|
||||
// Resize node to match image aspect ratio from intrinsics
|
||||
if (intrinsics && intrinsics[0] && intrinsics[1]) {
|
||||
const imageWidth = intrinsics[0][2] * 2; // cx * 2
|
||||
const imageHeight = intrinsics[1][2] * 2; // cy * 2
|
||||
this.resizeToAspectRatio(imageWidth, imageHeight);
|
||||
}
|
||||
|
||||
// Update info panel
|
||||
infoPanel.innerHTML = `
|
||||
<div style="display: grid; grid-template-columns: auto 1fr; gap: 2px 8px;">
|
||||
<span style="color: #888;">File:</span>
|
||||
<span style="color: #6cc;">${displayName}</span>
|
||||
<span style="color: #888;">Size:</span>
|
||||
<span>${fileSizeMb} MB</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// ComfyUI serves files via /view API — parse type/subfolder/filename
|
||||
// Handles both absolute (/home/.../output/sub/file) and relative (output/sub/file) paths
|
||||
let filepath;
|
||||
const normalized = filename.replace(/\\/g, '/');
|
||||
const pathMatch = normalized.match(/(?:^|\/)(output|input|temp)\/(.+)$/);
|
||||
if (pathMatch) {
|
||||
const [, type, relPath] = pathMatch;
|
||||
const parts = relPath.split('/');
|
||||
const fname = parts.pop();
|
||||
const subfolder = parts.join('/');
|
||||
filepath = `/view?filename=${encodeURIComponent(fname)}&type=${type}&subfolder=${encodeURIComponent(subfolder)}`;
|
||||
} else {
|
||||
// Fallback: use just the basename
|
||||
const basename = normalized.split('/').pop();
|
||||
filepath = `/view?filename=${encodeURIComponent(basename)}&type=output&subfolder=`;
|
||||
}
|
||||
|
||||
// Send URL to iframe — gsplat.js fetches directly with streaming
|
||||
const sendUrl = () => {
|
||||
if (!iframe.contentWindow) {
|
||||
console.error("[GeomPack Gaussian] Iframe contentWindow not available");
|
||||
return;
|
||||
}
|
||||
iframe.contentWindow.postMessage({
|
||||
type: "LOAD_MESH_URL",
|
||||
url: filepath,
|
||||
filename: filename,
|
||||
extrinsics: extrinsics,
|
||||
intrinsics: intrinsics,
|
||||
timestamp: Date.now()
|
||||
}, "*");
|
||||
};
|
||||
|
||||
if (iframeLoaded) {
|
||||
sendUrl();
|
||||
} else {
|
||||
setTimeout(sendUrl, 500);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return r;
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,112 @@
|
||||
// Load Mesh Batch: folder-path autocomplete (type to navigate directories),
|
||||
// inspired by VHS "Load Video (Path)" but using GeometryPack's own /gpack/getpath
|
||||
// route and restricted to FOLDERS. Uses a CUSTOM floating dropdown (native
|
||||
// <datalist> is unreliable inside ComfyUI's transformed DOM widgets).
|
||||
import { app } from "../../../scripts/app.js";
|
||||
|
||||
const TAG = "[MeshBatchPath]";
|
||||
console.log(`${TAG} script loaded`);
|
||||
|
||||
// "output/meshes/fo" -> ["output/meshes/", "fo"] (dir, remainder)
|
||||
function pathStem(value) {
|
||||
const v = String(value || "");
|
||||
const i = v.lastIndexOf("/");
|
||||
return i < 0 ? ["", v] : [v.slice(0, i + 1), v.slice(i + 1)];
|
||||
}
|
||||
|
||||
app.registerExtension({
|
||||
name: "geompack.loadmeshbatch.path",
|
||||
async beforeRegisterNodeDef(nodeType, nodeData) {
|
||||
if (nodeData.name !== "GeomPackLoadMeshBatch") return;
|
||||
console.log(`${TAG} registering for GeomPackLoadMeshBatch`);
|
||||
|
||||
const onCreated = nodeType.prototype.onNodeCreated;
|
||||
nodeType.prototype.onNodeCreated = function () {
|
||||
const r = onCreated?.apply(this, arguments);
|
||||
const node = this;
|
||||
|
||||
// Replace the plain folder_path string widget with a DOM input of the
|
||||
// same name (so it still serializes as folder_path).
|
||||
const idx = (node.widgets || []).findIndex((w) => w.name === "folder_path");
|
||||
const initial = idx >= 0 ? (node.widgets[idx].value ?? "3d") : "3d";
|
||||
if (idx >= 0) node.widgets.splice(idx, 1);
|
||||
|
||||
const wrap = document.createElement("div");
|
||||
wrap.style.cssText = "width:100%;box-sizing:border-box;padding:0 6px;";
|
||||
const input = document.createElement("input");
|
||||
input.type = "text";
|
||||
input.value = initial;
|
||||
input.placeholder = "folder path — type to autocomplete (input/… output/… or absolute)";
|
||||
input.style.cssText = "width:100%;box-sizing:border-box;background:#222;color:#ddd;" +
|
||||
"border:1px solid #444;border-radius:4px;padding:3px 6px;font:12px monospace;";
|
||||
wrap.appendChild(input);
|
||||
|
||||
const widget = node.addDOMWidget("folder_path", "gpack_path", wrap, {
|
||||
getValue() { return input.value; },
|
||||
setValue(v) { input.value = v ?? ""; },
|
||||
serialize: true,
|
||||
});
|
||||
widget.computeSize = (w) => [w, 30];
|
||||
|
||||
// Floating dropdown attached to body (avoids canvas clipping/transform).
|
||||
const menu = document.createElement("div");
|
||||
menu.style.cssText = "position:fixed;z-index:10000;max-height:240px;overflow-y:auto;" +
|
||||
"background:#1e1e1e;border:1px solid #555;border-radius:4px;" +
|
||||
"box-shadow:0 4px 14px rgba(0,0,0,0.55);font:12px monospace;display:none;";
|
||||
document.body.appendChild(menu);
|
||||
|
||||
let items = [];
|
||||
let lastDir = null;
|
||||
const hide = () => { menu.style.display = "none"; };
|
||||
const place = () => {
|
||||
const b = input.getBoundingClientRect();
|
||||
menu.style.left = b.left + "px";
|
||||
menu.style.top = (b.bottom + 2) + "px";
|
||||
menu.style.minWidth = b.width + "px";
|
||||
};
|
||||
const render = (rem) => {
|
||||
const remL = rem.toLowerCase();
|
||||
const [dir] = pathStem(input.value);
|
||||
const matches = items.filter(
|
||||
(it) => it.endsWith("/") && it.toLowerCase().startsWith(remL));
|
||||
menu.innerHTML = "";
|
||||
if (!matches.length) { hide(); return; }
|
||||
for (const it of matches) {
|
||||
const row = document.createElement("div");
|
||||
row.textContent = it;
|
||||
row.style.cssText = "padding:4px 9px;color:#ddd;cursor:pointer;white-space:nowrap;";
|
||||
row.addEventListener("mouseenter", () => { row.style.background = "#37506b"; });
|
||||
row.addEventListener("mouseleave", () => { row.style.background = ""; });
|
||||
row.addEventListener("mousedown", (e) => {
|
||||
e.preventDefault(); // keep input focused
|
||||
input.value = dir + it; // descend into the folder
|
||||
fetchDir(true); // list its contents
|
||||
});
|
||||
menu.appendChild(row);
|
||||
}
|
||||
place();
|
||||
menu.style.display = "block";
|
||||
};
|
||||
const fetchDir = async (force) => {
|
||||
const [dir, rem] = pathStem(input.value);
|
||||
if (!force && dir === lastDir) { render(rem); return; }
|
||||
lastDir = dir;
|
||||
try {
|
||||
const resp = await fetch(`/gpack/getpath?path=${encodeURIComponent(dir || ".")}`);
|
||||
items = resp.ok ? await resp.json() : [];
|
||||
} catch (e) { items = []; }
|
||||
render(rem);
|
||||
};
|
||||
|
||||
input.addEventListener("input", () => fetchDir(false));
|
||||
input.addEventListener("focus", () => { lastDir = null; fetchDir(true); });
|
||||
input.addEventListener("blur", () => setTimeout(hide, 200));
|
||||
input.addEventListener("keydown", (e) => { if (e.key === "Escape") hide(); });
|
||||
|
||||
const origRemoved = node.onRemoved;
|
||||
node.onRemoved = function () { menu.remove(); return origRemoved?.apply(this, arguments); };
|
||||
|
||||
return r;
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,207 @@
|
||||
// Load Mesh: drag-n-drop / upload (with progress bar) + inline "View 3D",
|
||||
// for GeomPackLoadMesh. Upload mirrors ComfyUI's official Load Image
|
||||
// (POST /upload/image, subfolder=3d) but via XHR so we get upload progress.
|
||||
import { app } from "../../../scripts/app.js";
|
||||
|
||||
const TAG = "[LoadMeshUpload]";
|
||||
console.log(`${TAG} script loaded`);
|
||||
|
||||
const EXTENSION_FOLDER = (() => {
|
||||
const m = import.meta.url.match(/\/extensions\/([^/]+)\//);
|
||||
return m ? m[1] : "ComfyUI-GeometryPack";
|
||||
})();
|
||||
|
||||
const EXTS = [".obj", ".ply", ".stl", ".off", ".gltf", ".glb", ".fbx", ".dae", ".3ds", ".vtp"];
|
||||
const ACCEPT = EXTS.join(",");
|
||||
const isMesh = (name) => EXTS.some((x) => name.toLowerCase().endsWith(x));
|
||||
|
||||
// XHR upload so we can report upload progress (fetch can't). Returns combo value.
|
||||
function uploadMesh(file, onProgress) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const body = new FormData();
|
||||
body.append("image", file, file.name);
|
||||
body.append("subfolder", "3d");
|
||||
body.append("type", "input");
|
||||
body.append("overwrite", "true");
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open("POST", "/upload/image");
|
||||
xhr.upload.onprogress = (e) => {
|
||||
if (e.lengthComputable && onProgress) onProgress(e.loaded / e.total, e.loaded, e.total);
|
||||
};
|
||||
xhr.onload = () => {
|
||||
if (xhr.status === 200) {
|
||||
try {
|
||||
const d = JSON.parse(xhr.responseText);
|
||||
const sub = (d.subfolder || "").replace(/\\/g, "/");
|
||||
resolve(sub ? `${sub}/${d.name}` : d.name);
|
||||
} catch (e) { reject(e); }
|
||||
} else {
|
||||
reject(new Error(`${xhr.status} ${xhr.responseText}`));
|
||||
}
|
||||
};
|
||||
xhr.onerror = () => reject(new Error("network error"));
|
||||
if (onProgress) onProgress(0, 0, file.size);
|
||||
xhr.send(body);
|
||||
});
|
||||
}
|
||||
|
||||
function viewUrlFor(value) {
|
||||
const parts = String(value).replace(/\\/g, "/").split("/");
|
||||
const fname = parts.pop();
|
||||
return `/view?filename=${encodeURIComponent(fname)}&type=input&subfolder=${encodeURIComponent(parts.join("/"))}`;
|
||||
}
|
||||
|
||||
function fileWidget(node) {
|
||||
return node.widgets?.find((x) => x.name === "file_path");
|
||||
}
|
||||
|
||||
function selectValue(node, val) {
|
||||
const w = fileWidget(node);
|
||||
if (!w) { console.warn(`${TAG} no file_path widget`); return; }
|
||||
w.options = w.options || {};
|
||||
w.options.values = w.options.values || [];
|
||||
if (!w.options.values.includes(val)) w.options.values.push(val);
|
||||
w.value = val;
|
||||
try { w.callback?.(val); } catch (e) { /* noop */ }
|
||||
node.setDirtyCanvas(true, true);
|
||||
}
|
||||
|
||||
app.registerExtension({
|
||||
name: "geompack.loadmesh.upload",
|
||||
async beforeRegisterNodeDef(nodeType, nodeData) {
|
||||
if (nodeData.name !== "GeomPackLoadMesh") return;
|
||||
console.log(`${TAG} registering for GeomPackLoadMesh`);
|
||||
|
||||
const onCreated = nodeType.prototype.onNodeCreated;
|
||||
nodeType.prototype.onNodeCreated = function () {
|
||||
const r = onCreated?.apply(this, arguments);
|
||||
const node = this;
|
||||
|
||||
// --- progress bar (DOM widget; collapses to 0 height when idle) ---
|
||||
//
|
||||
// IMPORTANT: ComfyUI's DOM-widget host <div> ("dom-widget size-full",
|
||||
// position:fixed) is positioned/sized by its OWN reactive layer
|
||||
// (Vue), driven by widget.isVisible()/widget.hidden -- it does NOT
|
||||
// look at this element's own inline CSS (wrap.style.display) at all.
|
||||
// Toggling only wrap.style.display leaves the *host* div fully
|
||||
// "visible" from ComfyUI's point of view, at which point its height
|
||||
// falls back to the "size-full" CSS class default (100% of the
|
||||
// *viewport*, since it's position:fixed) with pointer-events:auto --
|
||||
// an invisible, click-eating overlay stretching far below the node,
|
||||
// permanently, regardless of whether anything is actually showing.
|
||||
// Confirmed empirically (Playwright): computeSize()'s return value
|
||||
// is not what drives this host div's real layout height either.
|
||||
// The only thing that correctly collapses the host div (display:none
|
||||
// AND pointer-events:none together) is the widget's own `hidden`
|
||||
// flag, so that's the actual on/off switch -- wrap's inline style
|
||||
// just controls the *content* once the host div is shown.
|
||||
const wrap = document.createElement("div");
|
||||
wrap.style.cssText = "width:100%;padding:0 6px;box-sizing:border-box;";
|
||||
const label = document.createElement("div");
|
||||
label.style.cssText = "font:10px monospace;color:#bbb;margin-bottom:2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;";
|
||||
const track = document.createElement("div");
|
||||
track.style.cssText = "width:100%;height:6px;background:rgba(255,255,255,0.18);border-radius:3px;overflow:hidden;";
|
||||
const bar = document.createElement("div");
|
||||
bar.style.cssText = "width:0%;height:100%;background:#00c8ff;transition:width 0.1s linear;";
|
||||
track.appendChild(bar); wrap.appendChild(label); wrap.appendChild(track);
|
||||
const progWidget = node.addDOMWidget("upload_progress", "div", wrap, {
|
||||
getValue() { return ""; }, setValue() { },
|
||||
});
|
||||
progWidget.computeSize = (w) => [w, 26];
|
||||
progWidget.hidden = true; // starts idle -- see note above
|
||||
const showProgress = (name, frac) => {
|
||||
progWidget.hidden = false;
|
||||
const pct = Math.max(0, Math.min(100, Math.round((frac || 0) * 100)));
|
||||
label.textContent = `⬆ ${name} — ${pct}%`;
|
||||
bar.style.width = pct + "%";
|
||||
node.setDirtyCanvas(true, true);
|
||||
};
|
||||
const hideProgress = () => { progWidget.hidden = true; node.setDirtyCanvas(true, true); };
|
||||
|
||||
async function uploadList(files) {
|
||||
const meshes = [...files].filter((f) => isMesh(f.name));
|
||||
if (!meshes.length) { console.warn(`${TAG} no mesh files in selection/drop`); return false; }
|
||||
for (const f of meshes) {
|
||||
try {
|
||||
showProgress(f.name, 0);
|
||||
const val = await uploadMesh(f, (frac) => showProgress(f.name, frac));
|
||||
selectValue(node, val);
|
||||
console.log(`${TAG} uploaded -> ${val}`);
|
||||
} catch (e) {
|
||||
console.error(`${TAG} upload failed for ${f.name}`, e);
|
||||
alert("Mesh upload failed: " + e.message);
|
||||
} finally {
|
||||
hideProgress();
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
node._gpUploadList = uploadList;
|
||||
|
||||
// hidden file picker + upload button
|
||||
const input = document.createElement("input");
|
||||
input.type = "file"; input.accept = ACCEPT; input.multiple = true; input.style.display = "none";
|
||||
input.addEventListener("change", async () => { await uploadList(input.files); input.value = ""; });
|
||||
document.body.appendChild(input);
|
||||
node._gpFileInput = input; // removed in onRemoved below
|
||||
node.addWidget("button", "⬆ upload / drop mesh", null, () => { console.log(`${TAG} upload button clicked`); input.click(); });
|
||||
|
||||
// inline View 3D
|
||||
let iframe = null, viewWidget = null, savedSize = null;
|
||||
const collapse = () => {
|
||||
if (viewWidget && node.widgets) {
|
||||
const i = node.widgets.indexOf(viewWidget); if (i >= 0) node.widgets.splice(i, 1);
|
||||
}
|
||||
if (iframe) iframe.remove(); iframe = null; viewWidget = null;
|
||||
// Restore the pre-expand size instead of leaving the +380px
|
||||
// stuck on the node -- otherwise every open/close cycle
|
||||
// permanently grows the node's bounding box downward, which
|
||||
// ends up silently overlapping (and blocking clicks on)
|
||||
// whatever node sits below it on the canvas.
|
||||
if (savedSize) { node.setSize(savedSize); savedSize = null; }
|
||||
node.setDirtyCanvas(true, true);
|
||||
};
|
||||
const sendMesh = () => {
|
||||
const w = fileWidget(node);
|
||||
if (iframe?.contentWindow && w?.value)
|
||||
iframe.contentWindow.postMessage({ type: "LOAD_MESH", filepath: viewUrlFor(w.value), timestamp: Date.now() }, "*");
|
||||
};
|
||||
node.addWidget("button", "👁 view 3d", null, () => {
|
||||
if (iframe) { collapse(); return; }
|
||||
const w = fileWidget(node);
|
||||
if (!w || !w.value) { alert("Pick or upload a mesh first."); return; }
|
||||
savedSize = [...node.size];
|
||||
iframe = document.createElement("iframe");
|
||||
iframe.style.cssText = "width:100%;height:100%;border:none;background:#2a2a2a;";
|
||||
// VTK viewer: robust OBJ/PLY/STL/VTP reader (+ the download progress bar),
|
||||
// unlike the Three.js viewer.html whose OBJLoader chokes on some OBJs.
|
||||
iframe.src = `/extensions/${EXTENSION_FOLDER}/viewer_vtk.html?v=` + Date.now();
|
||||
viewWidget = node.addDOMWidget("loadmesh_view3d", "MESH_PREVIEW", iframe, { getValue() { return ""; }, setValue() { } });
|
||||
viewWidget.computeSize = (width) => [width || 360, width || 360];
|
||||
iframe.addEventListener("load", () => setTimeout(sendMesh, 150));
|
||||
setTimeout(sendMesh, 600);
|
||||
node.setSize([Math.max(node.size[0], 380), savedSize[1] + 380]); node.setDirtyCanvas(true, true);
|
||||
});
|
||||
|
||||
return r;
|
||||
};
|
||||
|
||||
const onRemoved = nodeType.prototype.onRemoved;
|
||||
nodeType.prototype.onRemoved = function () {
|
||||
this._gpFileInput?.remove();
|
||||
return onRemoved?.apply(this, arguments);
|
||||
};
|
||||
|
||||
const onDragOver = nodeType.prototype.onDragOver;
|
||||
nodeType.prototype.onDragOver = function (e) {
|
||||
if ([...(e?.dataTransfer?.items || [])].some((it) => it.kind === "file")) return true;
|
||||
return onDragOver?.apply(this, arguments) ?? false;
|
||||
};
|
||||
const onDragDrop = nodeType.prototype.onDragDrop;
|
||||
nodeType.prototype.onDragDrop = async function (e) {
|
||||
console.log(`${TAG} drop; files=${e?.dataTransfer?.files?.length}`);
|
||||
if (this._gpUploadList && await this._gpUploadList(e?.dataTransfer?.files || [])) return true;
|
||||
return onDragDrop?.apply(this, arguments) ?? false;
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* ComfyUI GeomPack - 3D Mesh Preview Widget
|
||||
* Minimal implementation based on ComfyUI-3D-Pack pattern
|
||||
*/
|
||||
|
||||
import { app } from "../../../scripts/app.js";
|
||||
|
||||
// Auto-detect extension folder name (handles ComfyUI-GeometryPack or comfyui-geometrypack)
|
||||
const EXTENSION_FOLDER = (() => {
|
||||
const url = import.meta.url;
|
||||
const match = url.match(/\/extensions\/([^/]+)\//);
|
||||
return match ? match[1] : "ComfyUI-GeometryPack";
|
||||
})();
|
||||
|
||||
console.log("[GeomPack] Loading mesh preview extension...");
|
||||
|
||||
app.registerExtension({
|
||||
name: "geompack.meshpreview",
|
||||
|
||||
async beforeRegisterNodeDef(nodeType, nodeData, app) {
|
||||
if (nodeData.name === "GeomPackPreviewMesh") {
|
||||
console.log("[GeomPack] Registering Preview Mesh node");
|
||||
|
||||
const onNodeCreated = nodeType.prototype.onNodeCreated;
|
||||
nodeType.prototype.onNodeCreated = function() {
|
||||
const r = onNodeCreated ? onNodeCreated.apply(this, arguments) : undefined;
|
||||
|
||||
console.log("[GeomPack DEBUG] this.widgets before:", this.widgets);
|
||||
console.log("[GeomPack DEBUG] nodeData:", nodeData);
|
||||
console.log("[GeomPack] Node created, adding widget");
|
||||
|
||||
// Create iframe for 3D viewer
|
||||
const iframe = document.createElement("iframe");
|
||||
iframe.style.width = "100%";
|
||||
iframe.style.height = "100%";
|
||||
iframe.style.border = "none";
|
||||
iframe.style.backgroundColor = "#2a2a2a";
|
||||
iframe.style.aspectRatio = "1";
|
||||
|
||||
// Point to our HTML viewer (with cache buster)
|
||||
iframe.src = `/extensions/${EXTENSION_FOLDER}/viewer.html?v=` + Date.now();
|
||||
|
||||
// Add widget with required options
|
||||
console.log("[GeomPack DEBUG] About to call addDOMWidget");
|
||||
console.log("[GeomPack DEBUG] typeof this.addDOMWidget:", typeof this.addDOMWidget);
|
||||
|
||||
const widget = this.addDOMWidget("preview", "MESH_PREVIEW", iframe, {
|
||||
getValue() { return ""; },
|
||||
setValue(v) { }
|
||||
});
|
||||
|
||||
console.log("[GeomPack DEBUG] Widget created:", widget);
|
||||
console.log("[GeomPack DEBUG] Widget properties:", Object.keys(widget || {}));
|
||||
console.log("[GeomPack DEBUG] Widget.id:", widget?.id);
|
||||
console.log("[GeomPack DEBUG] this.widgets after addDOMWidget:", this.widgets);
|
||||
|
||||
// Set widget size - computeSize returns [width, height]
|
||||
widget.computeSize = function(width) {
|
||||
console.log("[GeomPack DEBUG] computeSize called with width:", width);
|
||||
const size = [width || 512, width || 512];
|
||||
console.log("[GeomPack DEBUG] computeSize returning:", size);
|
||||
return size;
|
||||
};
|
||||
|
||||
// Also try setting element size directly
|
||||
widget.element = iframe;
|
||||
|
||||
console.log("[GeomPack DEBUG] Widget computeSize set");
|
||||
console.log("[GeomPack DEBUG] Iframe dimensions:", iframe.style.width, iframe.style.height);
|
||||
|
||||
// Store iframe reference
|
||||
this.meshViewerIframe = iframe;
|
||||
|
||||
// Set initial node size to be square
|
||||
this.setSize([512, 512]);
|
||||
console.log("[GeomPack DEBUG] Node size set to [512, 512]");
|
||||
|
||||
// Handle execution
|
||||
const onExecuted = this.onExecuted;
|
||||
this.onExecuted = function(message) {
|
||||
console.log("[GeomPack] onExecuted called with message:", message);
|
||||
onExecuted?.apply(this, arguments);
|
||||
|
||||
// The message IS the UI data (not message.ui)
|
||||
if (message?.mesh_file && message.mesh_file[0]) {
|
||||
const filename = message.mesh_file[0];
|
||||
console.log(`[GeomPack] Loading mesh: ${filename}`);
|
||||
|
||||
// ComfyUI serves files via /view API — parse type/subfolder/filename
|
||||
// Handles both absolute (/home/.../output/sub/file) and relative (output/sub/file) paths
|
||||
let filepath;
|
||||
const normalized = filename.replace(/\\/g, '/');
|
||||
const pathMatch = normalized.match(/(?:^|\/)(output|input|temp)\/(.+)$/);
|
||||
if (pathMatch) {
|
||||
const [, type, relPath] = pathMatch;
|
||||
const parts = relPath.split('/');
|
||||
const fname = parts.pop();
|
||||
const subfolder = parts.join('/');
|
||||
filepath = `/view?filename=${encodeURIComponent(fname)}&type=${type}&subfolder=${encodeURIComponent(subfolder)}`;
|
||||
} else {
|
||||
// Fallback: use just the basename
|
||||
const basename = normalized.split('/').pop();
|
||||
filepath = `/view?filename=${encodeURIComponent(basename)}&type=output&subfolder=`;
|
||||
}
|
||||
|
||||
// Send message to iframe (with delay to ensure iframe is loaded)
|
||||
setTimeout(() => {
|
||||
if (iframe.contentWindow) {
|
||||
console.log(`[GeomPack] Sending postMessage to iframe: ${filepath}`);
|
||||
iframe.contentWindow.postMessage({
|
||||
type: "LOAD_MESH",
|
||||
filepath: filepath,
|
||||
timestamp: Date.now()
|
||||
}, "*");
|
||||
} else {
|
||||
console.error("[GeomPack] Iframe contentWindow not available");
|
||||
}
|
||||
}, 100);
|
||||
} else {
|
||||
console.log("[GeomPack] No mesh_file in message data. Keys:", Object.keys(message || {}));
|
||||
}
|
||||
};
|
||||
|
||||
return r;
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
console.log("[GeomPack] Mesh preview extension registered");
|
||||
@@ -0,0 +1,191 @@
|
||||
/**
|
||||
* ComfyUI GeomPack - Dual Mesh Preview Widget
|
||||
* Unified viewer for side-by-side and overlay dual mesh visualization
|
||||
* with full field visualization support
|
||||
*/
|
||||
|
||||
import { app } from "../../../scripts/app.js";
|
||||
import { EXTENSION_FOLDER, getViewerUrl } from "./utils/extensionFolder.js";
|
||||
import { createContainer, createIframe, createInfoPanel } from "./utils/uiComponents.js";
|
||||
import { buildDualMeshInfoHTML, formatExtents } from "./utils/formatting.js";
|
||||
import { createViewerManager, createErrorHandler, buildViewUrl, createLoadDualMeshMessage } from "./utils/postMessage.js";
|
||||
|
||||
app.registerExtension({
|
||||
name: "geompack.meshpreview.dual",
|
||||
|
||||
async beforeRegisterNodeDef(nodeType, nodeData, app) {
|
||||
if (nodeData.name === "GeomPackPreviewMeshDual") {
|
||||
const onNodeCreated = nodeType.prototype.onNodeCreated;
|
||||
nodeType.prototype.onNodeCreated = function() {
|
||||
const r = onNodeCreated ? onNodeCreated.apply(this, arguments) : undefined;
|
||||
|
||||
// Viewer state persisted via DOM widget serialization
|
||||
const viewerState = { show_edges: false, camera_state: "", selected_field: "", selected_channel: "magnitude", selected_colormap: "erdc_rainbow_bright" };
|
||||
|
||||
// Create container for viewer + info panel
|
||||
const container = createContainer();
|
||||
|
||||
// Create iframe for VTK.js viewer
|
||||
const iframe = createIframe(getViewerUrl("viewer_dual"), { minHeight: "550px" });
|
||||
|
||||
// Create mesh info panel
|
||||
const infoPanel = createInfoPanel("Mesh info will appear here after execution");
|
||||
|
||||
// Add iframe and info panel to container
|
||||
container.appendChild(iframe);
|
||||
container.appendChild(infoPanel);
|
||||
|
||||
// Add widget
|
||||
const widget = this.addDOMWidget("preview_dual", "MESH_PREVIEW_DUAL", container, {
|
||||
getValue() { return JSON.stringify(viewerState); },
|
||||
setValue(v) {
|
||||
try { Object.assign(viewerState, JSON.parse(v)); } catch(e) {}
|
||||
}
|
||||
});
|
||||
widget.computeSize = () => [768, 680];
|
||||
|
||||
// Store references
|
||||
this.meshViewerIframeDual = iframe;
|
||||
this.meshInfoPanelDual = infoPanel;
|
||||
|
||||
this.setSize(this.computeSize());
|
||||
|
||||
// Bidirectional sync: viewer → node widgets (viewerState + real widgets like opacity)
|
||||
const node = this;
|
||||
window.addEventListener('message', (event) => {
|
||||
// Without this check, every open viewer instance's listener
|
||||
// fires for every iframe's messages, not just its own.
|
||||
if (event.source !== iframe.contentWindow) return;
|
||||
if (event.data.type === 'WIDGET_UPDATE') {
|
||||
const { widget: name, value } = event.data;
|
||||
if (name in viewerState) viewerState[name] = value;
|
||||
const w = node.widgets?.find(w => w.name === name);
|
||||
if (w) w.value = value;
|
||||
}
|
||||
});
|
||||
|
||||
// Create viewer manager
|
||||
const viewerManager = createViewerManager(iframe, "[GeomPack Dual]");
|
||||
|
||||
// Listen for error messages
|
||||
window.addEventListener('message', createErrorHandler(infoPanel, "[GeomPack Dual]"));
|
||||
|
||||
// Set initial node size
|
||||
this.setSize([768, 680]);
|
||||
|
||||
// Handle execution
|
||||
const onExecuted = this.onExecuted;
|
||||
this.onExecuted = function(message) {
|
||||
onExecuted?.apply(this, arguments);
|
||||
|
||||
if (!message?.layout) {
|
||||
return;
|
||||
}
|
||||
|
||||
const layout = message.layout[0];
|
||||
const mode = message.mode?.[0] || "fields";
|
||||
|
||||
// Determine viewer type and name
|
||||
let viewerType, viewerName;
|
||||
if (layout === 'slider') {
|
||||
viewerType = "slider";
|
||||
viewerName = "viewer_dual_slider";
|
||||
} else if (mode === "texture") {
|
||||
viewerType = "texture";
|
||||
viewerName = "viewer_dual_textured";
|
||||
} else {
|
||||
viewerType = "fields";
|
||||
viewerName = "viewer_dual";
|
||||
}
|
||||
|
||||
let postMessageData;
|
||||
|
||||
if (layout === 'side_by_side' || layout === 'slider') {
|
||||
// Side-by-side mode
|
||||
if (!message?.mesh_1_file || !message?.mesh_2_file) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Build info HTML using utility
|
||||
const infoHTML = buildDualMeshInfoHTML({
|
||||
mode: mode,
|
||||
layout: layout,
|
||||
mesh1: {
|
||||
vertices: message.vertex_count_1?.[0] || 'N/A',
|
||||
faces: message.face_count_1?.[0] || 'N/A',
|
||||
extents: message.extents_1?.[0] || [],
|
||||
isWatertight: message.is_watertight_1?.[0],
|
||||
hasTexture: message.has_texture_1?.[0]
|
||||
},
|
||||
mesh2: {
|
||||
vertices: message.vertex_count_2?.[0] || 'N/A',
|
||||
faces: message.face_count_2?.[0] || 'N/A',
|
||||
extents: message.extents_2?.[0] || [],
|
||||
isWatertight: message.is_watertight_2?.[0],
|
||||
hasTexture: message.has_texture_2?.[0]
|
||||
},
|
||||
commonFields: message.common_fields?.[0] || []
|
||||
});
|
||||
|
||||
infoPanel.innerHTML = infoHTML;
|
||||
|
||||
postMessageData = createLoadDualMeshMessage({
|
||||
layout: layout,
|
||||
mesh1Filepath: buildViewUrl(message.mesh_1_file[0]),
|
||||
mesh2Filepath: buildViewUrl(message.mesh_2_file[0]),
|
||||
opacity1: message.opacity_1?.[0] || 1.0,
|
||||
opacity2: message.opacity_2?.[0] || 1.0,
|
||||
showEdges: viewerState.show_edges,
|
||||
cameraState: viewerState.camera_state,
|
||||
selectedField: viewerState.selected_field,
|
||||
selectedChannel: viewerState.selected_channel,
|
||||
selectedColormap: viewerState.selected_colormap,
|
||||
});
|
||||
|
||||
} else {
|
||||
// Overlay mode
|
||||
if (!message?.mesh_file) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Build info HTML using utility
|
||||
const infoHTML = buildDualMeshInfoHTML({
|
||||
mode: mode,
|
||||
layout: "overlay",
|
||||
mesh1: {
|
||||
vertices: message.vertex_count_1?.[0] || 'N/A',
|
||||
faces: message.face_count_1?.[0] || 'N/A',
|
||||
hasTexture: message.has_texture_1?.[0]
|
||||
},
|
||||
mesh2: {
|
||||
vertices: message.vertex_count_2?.[0] || 'N/A',
|
||||
faces: message.face_count_2?.[0] || 'N/A',
|
||||
hasTexture: message.has_texture_2?.[0]
|
||||
},
|
||||
commonFields: message.common_fields?.[0] || []
|
||||
});
|
||||
|
||||
infoPanel.innerHTML = infoHTML;
|
||||
|
||||
postMessageData = createLoadDualMeshMessage({
|
||||
layout: layout,
|
||||
meshFilepath: buildViewUrl(message.mesh_file[0]),
|
||||
opacity1: message.opacity_1?.[0] || 1.0,
|
||||
opacity2: message.opacity_2?.[0] || 1.0,
|
||||
showEdges: viewerState.show_edges,
|
||||
cameraState: viewerState.camera_state,
|
||||
selectedField: viewerState.selected_field,
|
||||
selectedChannel: viewerState.selected_channel,
|
||||
selectedColormap: viewerState.selected_colormap,
|
||||
});
|
||||
}
|
||||
|
||||
// Switch viewer if needed and send message
|
||||
viewerManager.switchViewer(viewerType, getViewerUrl(viewerName), postMessageData);
|
||||
};
|
||||
|
||||
return r;
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,325 @@
|
||||
/**
|
||||
* ComfyUI FBX Rigged Mesh Preview Widget
|
||||
*
|
||||
* Generic widget for FBX preview nodes. Loads viewer from file (not inline).
|
||||
* Used by UniRig, SAM3DBody, and other extensions that preview FBX files.
|
||||
*
|
||||
* This file auto-registers for all known FBX preview node types when loaded.
|
||||
* Extensions copy this file from the comfy-3d-viewers package at startup.
|
||||
*/
|
||||
|
||||
import { app } from "../../../../scripts/app.js";
|
||||
|
||||
// Auto-register for all known FBX preview node types
|
||||
const FBX_PREVIEW_NODES = [
|
||||
{
|
||||
nodeName: "UniRigPreviewRiggedMesh",
|
||||
extensionName: "unirig.fbxpreview",
|
||||
logPrefix: "[UniRig]",
|
||||
fbxExportApiPath: "/unirig/export_posed_fbx"
|
||||
},
|
||||
{
|
||||
nodeName: "SAM3DBodyPreviewRiggedMesh",
|
||||
extensionName: "sam3dbody.meshpreview",
|
||||
logPrefix: "[SAM3DBody]"
|
||||
// Note: SAM3DBody doesn't have FBX export API
|
||||
},
|
||||
{
|
||||
nodeName: "MocapPreviewRiggedMesh",
|
||||
extensionName: "motioncapture.meshpreview",
|
||||
logPrefix: "[MotionCapture]"
|
||||
},
|
||||
{
|
||||
nodeName: "FBXPreview",
|
||||
extensionName: "motioncapture.fbxpreview.legacy",
|
||||
logPrefix: "[MotionCapture]"
|
||||
}
|
||||
];
|
||||
|
||||
/**
|
||||
* Detect the extension folder name from the current script URL
|
||||
*/
|
||||
function detectExtensionFolder() {
|
||||
try {
|
||||
// Try to detect from import.meta.url if available
|
||||
if (typeof import.meta !== 'undefined' && import.meta.url) {
|
||||
const match = import.meta.url.match(/\/extensions\/([^\/]+)\//);
|
||||
if (match) {
|
||||
return match[1];
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: look at currently loading script
|
||||
const scripts = document.getElementsByTagName('script');
|
||||
for (let i = scripts.length - 1; i >= 0; i--) {
|
||||
const src = scripts[i].src;
|
||||
if (src) {
|
||||
const match = src.match(/\/extensions\/([^\/]+)\//);
|
||||
if (match) {
|
||||
return match[1];
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[FBX Preview] Could not detect extension folder:', e);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the viewer URL for the FBX viewer HTML file
|
||||
* @param {string} extensionFolder - The extension folder name
|
||||
* @returns {string} The URL to the viewer HTML
|
||||
*/
|
||||
function getViewerUrl(extensionFolder, viewerFile = "viewer_fbx.html") {
|
||||
// Add cache-busting timestamp
|
||||
return `/extensions/${extensionFolder}/${viewerFile}?v=` + Date.now();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create and register an FBX preview extension
|
||||
* @param {Object} config - Configuration object
|
||||
* @param {string} config.extensionName - Unique extension name (e.g., "unirig.fbxpreview")
|
||||
* @param {string} config.nodeName - Node type name to hook (e.g., "UniRigPreviewRiggedMesh")
|
||||
* @param {string} config.logPrefix - Log prefix for console messages (e.g., "[UniRig]")
|
||||
* @param {string} [config.extensionFolder] - Override extension folder detection
|
||||
* @param {string} [config.fbxExportApiPath] - API path for FBX export (e.g., "/unirig/export_posed_fbx")
|
||||
*/
|
||||
export function createFBXPreviewExtension(config) {
|
||||
const {
|
||||
extensionName,
|
||||
nodeName,
|
||||
logPrefix,
|
||||
extensionFolder: configExtensionFolder,
|
||||
fbxExportApiPath
|
||||
} = config;
|
||||
|
||||
console.log(`${logPrefix} Loading FBX mesh preview extension...`);
|
||||
|
||||
// Detect extension folder if not provided
|
||||
const extensionFolder = configExtensionFolder || detectExtensionFolder();
|
||||
if (!extensionFolder) {
|
||||
console.error(`${logPrefix} Could not detect extension folder`);
|
||||
return;
|
||||
}
|
||||
console.log(`${logPrefix} Detected extension folder: ${extensionFolder}`);
|
||||
|
||||
app.registerExtension({
|
||||
name: extensionName,
|
||||
|
||||
async beforeRegisterNodeDef(nodeType, nodeData, app) {
|
||||
if (nodeData.name === nodeName) {
|
||||
console.log(`${logPrefix} Registering Preview Rigged Mesh node`);
|
||||
|
||||
const onNodeCreated = nodeType.prototype.onNodeCreated;
|
||||
nodeType.prototype.onNodeCreated = function() {
|
||||
const r = onNodeCreated ? onNodeCreated.apply(this, arguments) : undefined;
|
||||
|
||||
console.log(`${logPrefix} Node created, adding FBX viewer widget`);
|
||||
|
||||
// Create iframe for FBX viewer
|
||||
const iframe = document.createElement("iframe");
|
||||
iframe.style.width = "100%";
|
||||
iframe.style.flex = "1 1 0";
|
||||
iframe.style.minHeight = "0";
|
||||
iframe.style.border = "none";
|
||||
iframe.style.backgroundColor = "#2a2a2a";
|
||||
|
||||
// Load viewer from file URL (not inline)
|
||||
const viewerUrl = getViewerUrl(extensionFolder);
|
||||
iframe.src = viewerUrl;
|
||||
console.log(`${logPrefix} Setting iframe src to: ${viewerUrl}`);
|
||||
|
||||
// Add load event listener
|
||||
iframe.onload = () => {
|
||||
console.log(`${logPrefix} Iframe loaded successfully`);
|
||||
};
|
||||
iframe.onerror = (e) => {
|
||||
console.error(`${logPrefix} Iframe failed to load:`, e);
|
||||
};
|
||||
|
||||
// Add widget
|
||||
const widget = this.addDOMWidget("preview", "FBX_PREVIEW", iframe, {
|
||||
getValue() { return ""; },
|
||||
setValue(v) { }
|
||||
});
|
||||
|
||||
console.log(`${logPrefix} Widget created:`, widget);
|
||||
|
||||
// Set widget size - allow flexible height
|
||||
widget.computeSize = function(width) {
|
||||
const w = width || 512;
|
||||
const h = w * 1.5; // Taller than wide to accommodate controls
|
||||
return [w, h];
|
||||
};
|
||||
|
||||
widget.element = iframe;
|
||||
|
||||
// Store iframe reference
|
||||
this.fbxViewerIframe = iframe;
|
||||
this.fbxViewerReady = false;
|
||||
|
||||
// Listen for ready message from iframe
|
||||
const onMessage = (event) => {
|
||||
if (event.data && event.data.type === 'VIEWER_READY') {
|
||||
console.log(`${logPrefix} Viewer iframe is ready!`);
|
||||
this.fbxViewerReady = true;
|
||||
}
|
||||
};
|
||||
window.addEventListener('message', onMessage.bind(this));
|
||||
|
||||
const notifyIframeResize = () => {
|
||||
if (iframe.contentWindow) {
|
||||
const rect = iframe.getBoundingClientRect();
|
||||
iframe.contentWindow.postMessage({
|
||||
type: 'RESIZE',
|
||||
width: rect.width,
|
||||
height: rect.height
|
||||
}, '*');
|
||||
}
|
||||
};
|
||||
|
||||
this.onResize = function(size) {
|
||||
const isVueNodes = iframe.closest('[data-node-id]') !== null ||
|
||||
document.querySelector('.vue-graph-canvas') !== null;
|
||||
|
||||
if (!isVueNodes && size && size[1]) {
|
||||
const nodeHeight = size[1];
|
||||
const headerHeight = 70;
|
||||
const availableHeight = Math.max(200, nodeHeight - headerHeight);
|
||||
iframe.style.height = availableHeight + 'px';
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
notifyIframeResize();
|
||||
});
|
||||
};
|
||||
|
||||
let resizeTimeout = null;
|
||||
let lastSize = { width: 0, height: 0 };
|
||||
const resizeObserver = new ResizeObserver((entries) => {
|
||||
const entry = entries[0];
|
||||
const newWidth = entry.contentRect.width;
|
||||
const newHeight = entry.contentRect.height;
|
||||
|
||||
if (Math.abs(newWidth - lastSize.width) < 1 && Math.abs(newHeight - lastSize.height) < 1) {
|
||||
return;
|
||||
}
|
||||
lastSize = { width: newWidth, height: newHeight };
|
||||
|
||||
if (resizeTimeout) {
|
||||
clearTimeout(resizeTimeout);
|
||||
}
|
||||
resizeTimeout = setTimeout(() => {
|
||||
notifyIframeResize();
|
||||
}, 50);
|
||||
});
|
||||
resizeObserver.observe(iframe);
|
||||
|
||||
const originalOnRemoved = this.onRemoved;
|
||||
this.onRemoved = function() {
|
||||
resizeObserver.disconnect();
|
||||
if (resizeTimeout) {
|
||||
clearTimeout(resizeTimeout);
|
||||
}
|
||||
window.removeEventListener('message', onMessage);
|
||||
if (originalOnRemoved) {
|
||||
originalOnRemoved.apply(this, arguments);
|
||||
}
|
||||
};
|
||||
|
||||
// Set initial node size (taller to accommodate controls)
|
||||
this.setSize([512, 768]);
|
||||
|
||||
// Handle execution
|
||||
const onExecuted = this.onExecuted;
|
||||
this.onExecuted = function(message) {
|
||||
console.log(`${logPrefix} onExecuted called with message:`, message);
|
||||
onExecuted?.apply(this, arguments);
|
||||
|
||||
// The message contains the FBX file path
|
||||
if (message?.fbx_file && message.fbx_file[0]) {
|
||||
const filename = message.fbx_file[0];
|
||||
console.log(`${logPrefix} Loading FBX: ${filename}`);
|
||||
|
||||
// Try different path formats based on filename
|
||||
let filepath;
|
||||
let folderType = 'output'; // default
|
||||
|
||||
// Detect folder type from path
|
||||
if (filename.includes('/input/') || filename.includes('\\input\\')) {
|
||||
folderType = 'input';
|
||||
}
|
||||
|
||||
// Extract basename if full path provided
|
||||
const basename = filename.includes('/') || filename.includes('\\')
|
||||
? filename.split(/[/\\]/).pop()
|
||||
: filename;
|
||||
|
||||
filepath = `${window.location.origin}/view?filename=${encodeURIComponent(basename)}&type=${folderType}&subfolder=`;
|
||||
|
||||
// Send message to iframe
|
||||
const sendMessage = () => {
|
||||
if (iframe.contentWindow) {
|
||||
console.log(`${logPrefix} Sending postMessage to iframe: ${filepath}`);
|
||||
const messageData = {
|
||||
type: "LOAD_FBX",
|
||||
filepath: filepath,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
// Include custom FBX export API path if configured
|
||||
if (fbxExportApiPath) {
|
||||
messageData.fbxExportApiPath = fbxExportApiPath;
|
||||
}
|
||||
iframe.contentWindow.postMessage(messageData, "*");
|
||||
} else {
|
||||
console.error(`${logPrefix} Iframe contentWindow not available`);
|
||||
}
|
||||
};
|
||||
|
||||
// Wait for iframe to be ready, or use timeout as fallback
|
||||
if (this.fbxViewerReady) {
|
||||
sendMessage();
|
||||
} else {
|
||||
const checkReady = setInterval(() => {
|
||||
if (this.fbxViewerReady) {
|
||||
clearInterval(checkReady);
|
||||
sendMessage();
|
||||
}
|
||||
}, 50);
|
||||
|
||||
// Fallback timeout after 2 seconds
|
||||
setTimeout(() => {
|
||||
clearInterval(checkReady);
|
||||
if (!this.fbxViewerReady) {
|
||||
console.warn(`${logPrefix} Iframe not ready after 2s, sending anyway`);
|
||||
sendMessage();
|
||||
}
|
||||
}, 2000);
|
||||
}
|
||||
} else {
|
||||
console.log(`${logPrefix} No fbx_file in message data. Keys:`, Object.keys(message || {}));
|
||||
}
|
||||
};
|
||||
|
||||
return r;
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`${logPrefix} FBX mesh preview extension registered`);
|
||||
}
|
||||
|
||||
// Export detectExtensionFolder for use by other modules
|
||||
export { detectExtensionFolder, getViewerUrl };
|
||||
|
||||
// Self-executing: register all known FBX preview node types
|
||||
console.log("[FBX Preview] Auto-registering for all known FBX preview node types...");
|
||||
FBX_PREVIEW_NODES.forEach(config => {
|
||||
try {
|
||||
createFBXPreviewExtension(config);
|
||||
} catch (e) {
|
||||
// Extension already registered by another custom node copy - skip
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,200 @@
|
||||
/**
|
||||
* ComfyUI GeomPack - Multi Mesh Preview Widget
|
||||
* Grid viewer for 1-4 meshes with synchronized cameras
|
||||
*/
|
||||
|
||||
import { app } from "../../../scripts/app.js";
|
||||
|
||||
// Auto-detect extension folder name
|
||||
const EXTENSION_FOLDER = (() => {
|
||||
const url = import.meta.url;
|
||||
const match = url.match(/\/extensions\/([^/]+)\//);
|
||||
return match ? match[1] : "ComfyUI-GeometryPack";
|
||||
})();
|
||||
|
||||
console.log('[GeomPack Multi JS] Loading mesh_preview_multi.js extension');
|
||||
|
||||
app.registerExtension({
|
||||
name: "geompack.meshpreview.multi",
|
||||
|
||||
async beforeRegisterNodeDef(nodeType, nodeData, app) {
|
||||
if (nodeData.name === "GeomPackPreviewMeshMulti") {
|
||||
const onNodeCreated = nodeType.prototype.onNodeCreated;
|
||||
nodeType.prototype.onNodeCreated = function() {
|
||||
const r = onNodeCreated ? onNodeCreated.apply(this, arguments) : undefined;
|
||||
|
||||
// Viewer state persisted via DOM widget serialization
|
||||
const viewerState = { layout: "wipe", show_edges: false, camera_state: "", selected_field: "", selected_channel: "magnitude", selected_colormap: "erdc_rainbow_bright" };
|
||||
const viewerUrl = () => (viewerState.layout === "overlay" ? "viewer_multi.html" : "viewer_multi_slider.html");
|
||||
|
||||
console.log('[GeomPack Multi JS] Creating PreviewMeshMulti node widget');
|
||||
|
||||
// Create container for viewer + info panel
|
||||
const container = document.createElement("div");
|
||||
container.style.width = "100%";
|
||||
container.style.height = "100%";
|
||||
container.style.display = "flex";
|
||||
container.style.flexDirection = "column";
|
||||
container.style.backgroundColor = "#2a2a2a";
|
||||
|
||||
// Create iframe for VTK.js viewer
|
||||
const iframe = document.createElement("iframe");
|
||||
iframe.style.width = "100%";
|
||||
iframe.style.flex = "1";
|
||||
iframe.style.minHeight = "450px";
|
||||
iframe.style.border = "none";
|
||||
iframe.style.backgroundColor = "#2a2a2a";
|
||||
iframe.src = `/extensions/${EXTENSION_FOLDER}/${viewerUrl()}?v=` + Date.now();
|
||||
|
||||
// Layout toggle bar (Wipe = N-1 draggable dividers, fixed order; Overlay = stacked)
|
||||
const bar = document.createElement("div");
|
||||
bar.style.cssText = "background:#1a1a1a;border-bottom:1px solid #444;padding:4px 8px;display:flex;gap:8px;align-items:center;font:11px monospace;color:#ccc;flex-shrink:0;";
|
||||
const layoutSel = document.createElement("select");
|
||||
layoutSel.style.cssText = "background:#333;color:#ccc;border:1px solid #555;border-radius:3px;font:11px monospace;padding:2px 6px;";
|
||||
layoutSel.innerHTML = '<option value="wipe">Wipe (sliders)</option><option value="overlay">Overlay</option>';
|
||||
bar.appendChild(Object.assign(document.createElement("span"), { textContent: "Layout:" }));
|
||||
bar.appendChild(layoutSel);
|
||||
|
||||
// Create mesh info panel
|
||||
const infoPanel = document.createElement("div");
|
||||
infoPanel.style.backgroundColor = "#1a1a1a";
|
||||
infoPanel.style.borderTop = "1px solid #444";
|
||||
infoPanel.style.padding = "6px 12px";
|
||||
infoPanel.style.fontSize = "10px";
|
||||
infoPanel.style.fontFamily = "monospace";
|
||||
infoPanel.style.color = "#ccc";
|
||||
infoPanel.style.lineHeight = "1.3";
|
||||
infoPanel.style.flexShrink = "0";
|
||||
infoPanel.style.overflow = "hidden";
|
||||
infoPanel.innerHTML = '<span style="color: #888;">Mesh info will appear here after execution</span>';
|
||||
|
||||
container.appendChild(bar);
|
||||
container.appendChild(iframe);
|
||||
container.appendChild(infoPanel);
|
||||
|
||||
// Add widget
|
||||
const widget = this.addDOMWidget("preview_multi", "MESH_PREVIEW_MULTI", container, {
|
||||
getValue() { return JSON.stringify(viewerState); },
|
||||
setValue(v) {
|
||||
try { Object.assign(viewerState, JSON.parse(v)); } catch(e) {}
|
||||
}
|
||||
});
|
||||
|
||||
widget.computeSize = () => [768, 580];
|
||||
|
||||
// Store references
|
||||
this.meshViewerIframeMulti = iframe;
|
||||
this.meshInfoPanelMulti = infoPanel;
|
||||
|
||||
this.setSize(this.computeSize());
|
||||
|
||||
// Bidirectional sync: viewer → node widgets (viewerState + real widgets)
|
||||
const node = this;
|
||||
window.addEventListener('message', (event) => {
|
||||
// Without this check, every open viewer instance's listener
|
||||
// fires for every iframe's messages, not just its own.
|
||||
if (event.source !== iframe.contentWindow) return;
|
||||
if (event.data.type === 'WIDGET_UPDATE') {
|
||||
const { widget: name, value } = event.data;
|
||||
if (name in viewerState) viewerState[name] = value;
|
||||
const w = node.widgets?.find(w => w.name === name);
|
||||
if (w) w.value = value;
|
||||
}
|
||||
});
|
||||
|
||||
// Track iframe load state + last loaded meshes (so a layout switch re-sends)
|
||||
let iframeLoaded = false;
|
||||
let lastLoad = null; // { numMeshes, filepaths }
|
||||
const buildAndSend = () => {
|
||||
if (!lastLoad || !iframe.contentWindow) return;
|
||||
let msg;
|
||||
if (viewerState.layout === "overlay") {
|
||||
msg = { type: 'LOAD_MULTI_MESH', numMeshes: lastLoad.numMeshes, meshFiles: lastLoad.filepaths,
|
||||
timestamp: Date.now(), showEdges: viewerState.show_edges, cameraState: viewerState.camera_state,
|
||||
selectedField: viewerState.selected_field, selectedChannel: viewerState.selected_channel,
|
||||
selectedColormap: viewerState.selected_colormap };
|
||||
} else {
|
||||
msg = { type: 'LOAD_MULTI_SLIDER', mesh_files: lastLoad.filepaths, timestamp: Date.now(),
|
||||
show_edges: viewerState.show_edges, camera_state: viewerState.camera_state };
|
||||
}
|
||||
iframe.contentWindow.postMessage(msg, "*");
|
||||
};
|
||||
iframe.addEventListener('load', () => { iframeLoaded = true; buildAndSend(); });
|
||||
|
||||
// Layout switch: swap the viewer iframe; buildAndSend fires on its load
|
||||
layoutSel.value = viewerState.layout;
|
||||
layoutSel.addEventListener('change', () => {
|
||||
viewerState.layout = layoutSel.value;
|
||||
iframeLoaded = false;
|
||||
iframe.src = `/extensions/${EXTENSION_FOLDER}/${viewerUrl()}?v=` + Date.now();
|
||||
});
|
||||
|
||||
// Set initial node size
|
||||
this.setSize([768, 580]);
|
||||
|
||||
// Handle execution
|
||||
const onExecuted = this.onExecuted;
|
||||
this.onExecuted = function(message) {
|
||||
onExecuted?.apply(this, arguments);
|
||||
|
||||
if (!message?.num_meshes) {
|
||||
return;
|
||||
}
|
||||
|
||||
const numMeshes = message.num_meshes[0];
|
||||
const meshFiles = message.mesh_files[0];
|
||||
const vertexCounts = message.vertex_counts[0];
|
||||
const faceCounts = message.face_counts[0];
|
||||
const gridCols = message.grid_cols[0];
|
||||
const gridRows = message.grid_rows[0];
|
||||
|
||||
console.log(`[GeomPack Multi] onExecuted: ${numMeshes} meshes, grid ${gridCols}x${gridRows}`);
|
||||
|
||||
// Per-mesh info, one column per mesh (matches the single Preview Mesh panel)
|
||||
const wt = message.is_watertight_list?.[0] || [];
|
||||
const avg = message.avg_edge_lengths?.[0] || [];
|
||||
const bnds = message.bounds_list?.[0] || [];
|
||||
const exts = message.extents_list?.[0] || [];
|
||||
const fields = message.field_names_list?.[0] || null;
|
||||
const num = (v) => (v == null ? '—' : Number(v).toLocaleString());
|
||||
const sig = (v) => (v == null ? '—' : Number(v).toLocaleString(undefined, { maximumSignificantDigits: 4 }));
|
||||
const ext = (e) => (e ? e.map((x) => Number(x).toFixed(2)).join(' × ') : '—');
|
||||
const bnd = (b) => (b
|
||||
? `<span style="font-size:9px;color:#aaa;">[${b[0].map((x) => Number(x).toFixed(1)).join(', ')}]<br>→ [${b[1].map((x) => Number(x).toFixed(1)).join(', ')}]</span>`
|
||||
: '—');
|
||||
|
||||
let infoHTML = `<div style="display: grid; grid-template-columns: auto repeat(${numMeshes}, 1fr); gap: 2px 12px; font: 11px monospace;">`;
|
||||
infoHTML += `<span style="color: #888;"></span>`;
|
||||
for (let i = 0; i < numMeshes; i++) {
|
||||
infoHTML += `<span style="color: #999; font-weight: bold; border-bottom: 1px solid #333;">Mesh ${i + 1}</span>`;
|
||||
}
|
||||
const row = (label, valFn) => {
|
||||
infoHTML += `<span style="color: #888;">${label}</span>`;
|
||||
for (let i = 0; i < numMeshes; i++) infoHTML += `<span>${valFn(i)}</span>`;
|
||||
};
|
||||
row('Vertices:', (i) => num(vertexCounts[i]));
|
||||
row('Faces:', (i) => num(faceCounts[i]));
|
||||
row('Watertight:', (i) => { const w = wt[i]; return `<span style="color:${w ? '#7c7' : '#c77'};">${w ? 'Yes' : 'No'}</span>`; });
|
||||
row('Avg edge:', (i) => sig(avg[i]));
|
||||
row('Extents:', (i) => ext(exts[i]));
|
||||
row('Bounds:', (i) => bnd(bnds[i]));
|
||||
if (fields) {
|
||||
row('Fields:', (i) => {
|
||||
const f = fields[i] || [];
|
||||
return f.length ? `<span style="font-size:9px;color:#9bd;">${f.join(', ')}</span>` : '<span style="color:#666;">—</span>';
|
||||
});
|
||||
}
|
||||
infoHTML += '</div>';
|
||||
infoPanel.innerHTML = infoHTML;
|
||||
|
||||
// Prepare file paths + store for (re)send (also used when layout is switched)
|
||||
const filepaths = meshFiles.map(f => `/view?filename=${encodeURIComponent(f)}&type=output&subfolder=`);
|
||||
lastLoad = { numMeshes, filepaths };
|
||||
buildAndSend();
|
||||
};
|
||||
|
||||
return r;
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* ComfyUI GeomPack - UV Mesh Preview Widget
|
||||
* Synchronized 3D mesh and 2D UV layout viewer with interactive picking
|
||||
*/
|
||||
|
||||
import { app } from "../../../scripts/app.js";
|
||||
|
||||
// Auto-detect extension folder name (handles ComfyUI-GeometryPack or comfyui-geometrypack)
|
||||
const EXTENSION_FOLDER = (() => {
|
||||
const url = import.meta.url;
|
||||
const match = url.match(/\/extensions\/([^/]+)\//);
|
||||
return match ? match[1] : "ComfyUI-GeometryPack";
|
||||
})();
|
||||
|
||||
console.log("[GeomPack] Loading UV mesh preview extension...");
|
||||
|
||||
app.registerExtension({
|
||||
name: "geompack.meshpreviewuv",
|
||||
|
||||
async beforeRegisterNodeDef(nodeType, nodeData, app) {
|
||||
if (nodeData.name === "GeomPackPreviewMeshUV") {
|
||||
console.log("[GeomPack] Registering Preview Mesh UV node");
|
||||
|
||||
const onNodeCreated = nodeType.prototype.onNodeCreated;
|
||||
nodeType.prototype.onNodeCreated = function() {
|
||||
const r = onNodeCreated ? onNodeCreated.apply(this, arguments) : undefined;
|
||||
|
||||
console.log("[GeomPack UV] Node created, adding widget");
|
||||
|
||||
// Create iframe for UV viewer
|
||||
const iframe = document.createElement("iframe");
|
||||
iframe.style.width = "100%";
|
||||
iframe.style.height = "100%";
|
||||
iframe.style.border = "none";
|
||||
iframe.style.backgroundColor = "#2a2a2a";
|
||||
iframe.style.aspectRatio = "2"; // Wide aspect for split view
|
||||
|
||||
// Point to our UV viewer HTML
|
||||
iframe.src = `/extensions/${EXTENSION_FOLDER}/viewer_uv.html?v=` + Date.now();
|
||||
|
||||
// Add widget
|
||||
const widget = this.addDOMWidget("uv_preview", "MESH_UV_PREVIEW", iframe, {
|
||||
getValue() { return ""; },
|
||||
setValue(v) { }
|
||||
});
|
||||
|
||||
// Set widget size - wider for split view
|
||||
widget.computeSize = function(width) {
|
||||
const w = width || 600;
|
||||
const h = w / 2; // 2:1 aspect ratio
|
||||
return [w, h];
|
||||
};
|
||||
|
||||
widget.element = iframe;
|
||||
|
||||
// Store iframe reference
|
||||
this.uvViewerIframe = iframe;
|
||||
|
||||
// Set initial node size (wide for split view)
|
||||
this.setSize([600, 350]);
|
||||
|
||||
// Handle execution
|
||||
const onExecuted = this.onExecuted;
|
||||
this.onExecuted = function(message) {
|
||||
console.log("[GeomPack UV] onExecuted called with message:", message);
|
||||
onExecuted?.apply(this, arguments);
|
||||
|
||||
if (message?.mesh_file && message.mesh_file[0]) {
|
||||
const meshFilename = message.mesh_file[0];
|
||||
const uvDataFilename = message.uv_data_file ? message.uv_data_file[0] : null;
|
||||
const showChecker = message.show_checker ? message.show_checker[0] : false;
|
||||
const showWireframe = message.show_wireframe ? message.show_wireframe[0] : true;
|
||||
|
||||
console.log(`[GeomPack UV] Loading mesh: ${meshFilename}`);
|
||||
console.log(`[GeomPack UV] UV data: ${uvDataFilename}`);
|
||||
console.log(`[GeomPack UV] Checker: ${showChecker}, Wireframe: ${showWireframe}`);
|
||||
|
||||
// Build file URLs
|
||||
const meshPath = `/view?filename=${encodeURIComponent(meshFilename)}&type=output&subfolder=`;
|
||||
const uvDataPath = uvDataFilename ?
|
||||
`/view?filename=${encodeURIComponent(uvDataFilename)}&type=output&subfolder=` : null;
|
||||
|
||||
// Send message to iframe
|
||||
setTimeout(() => {
|
||||
if (iframe.contentWindow) {
|
||||
console.log(`[GeomPack UV] Sending postMessage to iframe`);
|
||||
iframe.contentWindow.postMessage({
|
||||
type: "LOAD_MESH_UV",
|
||||
meshPath: meshPath,
|
||||
uvDataPath: uvDataPath,
|
||||
checker: showChecker,
|
||||
wireframe: showWireframe,
|
||||
timestamp: Date.now()
|
||||
}, "*");
|
||||
} else {
|
||||
console.error("[GeomPack UV] Iframe contentWindow not available");
|
||||
}
|
||||
}, 100);
|
||||
|
||||
// Update node title with UV info
|
||||
if (message.has_uvs && message.has_uvs[0]) {
|
||||
const coverage = message.uv_coverage ? message.uv_coverage[0].toFixed(3) : "?";
|
||||
const inUnit = message.uv_in_unit_square ? message.uv_in_unit_square[0] : false;
|
||||
console.log(`[GeomPack UV] UV Coverage: ${coverage}, In Unit Square: ${inUnit}`);
|
||||
} else {
|
||||
console.warn("[GeomPack UV] No UV data found on mesh");
|
||||
}
|
||||
} else {
|
||||
console.log("[GeomPack UV] No mesh_file in message data. Keys:", Object.keys(message || {}));
|
||||
}
|
||||
};
|
||||
|
||||
return r;
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
console.log("[GeomPack] UV mesh preview extension registered");
|
||||
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* ComfyUI GeomPack - VTK.js Mesh Preview Widget
|
||||
* Scientific visualization with VTK.js
|
||||
*/
|
||||
|
||||
import { app } from "../../../scripts/app.js";
|
||||
import { EXTENSION_FOLDER, getViewerUrl } from "./utils/extensionFolder.js";
|
||||
import { createContainer, createIframe, createInfoPanel, showPanelError } from "./utils/uiComponents.js";
|
||||
import { buildMeshInfoHTML } from "./utils/formatting.js";
|
||||
import { createScreenshotHandler } from "./utils/screenshot.js";
|
||||
import { createViewerManager, createErrorHandler, buildViewUrl } from "./utils/postMessage.js";
|
||||
|
||||
app.registerExtension({
|
||||
name: "geompack.meshpreview.vtk",
|
||||
|
||||
async beforeRegisterNodeDef(nodeType, nodeData, app) {
|
||||
if (nodeData.name === "GeomPackPreviewMeshVTK") {
|
||||
|
||||
const onNodeCreated = nodeType.prototype.onNodeCreated;
|
||||
nodeType.prototype.onNodeCreated = function() {
|
||||
const r = onNodeCreated ? onNodeCreated.apply(this, arguments) : undefined;
|
||||
|
||||
// Viewer state persisted via DOM widget serialization
|
||||
const viewerState = { show_edges: false, camera_state: "", selected_field: "", selected_channel: "magnitude", selected_colormap: "erdc_rainbow_bright" };
|
||||
|
||||
// Create container for viewer + info panel
|
||||
const container = createContainer();
|
||||
|
||||
// Create iframe for VTK.js viewer
|
||||
const iframe = createIframe(getViewerUrl("viewer_vtk"));
|
||||
|
||||
// Create mesh info panel
|
||||
const infoPanel = createInfoPanel("Mesh info will appear here after execution");
|
||||
|
||||
// Add iframe and info panel to container
|
||||
container.appendChild(iframe);
|
||||
container.appendChild(infoPanel);
|
||||
|
||||
// Add widget
|
||||
const widget = this.addDOMWidget("preview_vtk", "MESH_PREVIEW_VTK", container, {
|
||||
getValue() { return JSON.stringify(viewerState); },
|
||||
setValue(v) {
|
||||
try { Object.assign(viewerState, JSON.parse(v)); } catch(e) {}
|
||||
},
|
||||
// Low constant minimum: the DOM widget fills the node's height and
|
||||
// the node resizes freely down to ~120px. A CONSTANT can't feed back
|
||||
// the way a node.size-derived computeSize did (which grew forever),
|
||||
// so do NOT re-add a computeSize override here.
|
||||
getMinHeight: () => 120,
|
||||
});
|
||||
|
||||
// Store references
|
||||
this.meshViewerIframeVTK = iframe;
|
||||
this.meshInfoPanelVTK = infoPanel;
|
||||
|
||||
this.setSize([512, 640]);
|
||||
|
||||
// Bidirectional sync: viewer → node widgets (viewerState + real widgets)
|
||||
const node = this;
|
||||
window.addEventListener('message', (event) => {
|
||||
// Without this check, every open viewer instance's listener
|
||||
// fires for every iframe's messages, not just its own.
|
||||
if (event.source !== iframe.contentWindow) return;
|
||||
if (event.data.type === 'WIDGET_UPDATE') {
|
||||
const { widget: name, value } = event.data;
|
||||
if (name in viewerState) viewerState[name] = value;
|
||||
const w = node.widgets?.find(w => w.name === name);
|
||||
if (w) w.value = value;
|
||||
} else if (event.data.type === 'TOGGLE_INFO') {
|
||||
// viewer's minimise arrow also hides/shows this node's info panel
|
||||
infoPanel.style.display = event.data.collapsed ? 'none' : '';
|
||||
}
|
||||
});
|
||||
|
||||
// Create viewer manager for handling viewer switching
|
||||
const viewerManager = createViewerManager(iframe, "[GeomPack VTK]");
|
||||
|
||||
// Listen for screenshot and error messages
|
||||
window.addEventListener('message', createScreenshotHandler('vtk-screenshot'));
|
||||
window.addEventListener('message', createErrorHandler(infoPanel, "[GeomPack VTK]"));
|
||||
|
||||
// Set initial node size
|
||||
this.setSize([512, 640]);
|
||||
|
||||
// Handle execution
|
||||
const onExecuted = this.onExecuted;
|
||||
this.onExecuted = function(message) {
|
||||
onExecuted?.apply(this, arguments);
|
||||
|
||||
if (message?.mesh_file && message.mesh_file[0]) {
|
||||
const filename = message.mesh_file[0];
|
||||
const viewerType = message.viewer_type?.[0] || "fields";
|
||||
const mode = message.mode?.[0] || "fields";
|
||||
|
||||
// Determine which viewer HTML to use
|
||||
let viewerName;
|
||||
if (viewerType === "pbr") {
|
||||
viewerName = "viewer_pbr";
|
||||
} else if (viewerType === "texture") {
|
||||
viewerName = "viewer_vtk_textured";
|
||||
} else {
|
||||
viewerName = "viewer_vtk";
|
||||
}
|
||||
|
||||
// Build info HTML using utility
|
||||
const infoHTML = buildMeshInfoHTML({
|
||||
mode: mode,
|
||||
vertices: message.vertex_count?.[0] || 'N/A',
|
||||
faces: message.face_count?.[0] || 'N/A',
|
||||
boundsMin: message.bounds_min?.[0] || [],
|
||||
boundsMax: message.bounds_max?.[0] || [],
|
||||
extents: message.extents?.[0] || [],
|
||||
avgEdge: message.avg_edge_length?.[0],
|
||||
isWatertight: message.is_watertight?.[0],
|
||||
fieldNames: message.field_names?.[0] || [],
|
||||
fieldRanges: message.field_ranges?.[0] || [],
|
||||
hasTexture: message.has_texture?.[0],
|
||||
hasVertexColors: message.has_vertex_colors?.[0],
|
||||
visualKind: message.visual_kind?.[0]
|
||||
});
|
||||
|
||||
infoPanel.innerHTML = infoHTML;
|
||||
|
||||
// Click a field's min/max value to fly the camera to that element.
|
||||
const minPos = message.field_min_pos?.[0] || [];
|
||||
const maxPos = message.field_max_pos?.[0] || [];
|
||||
infoPanel.querySelectorAll('.field-extreme').forEach((el) => {
|
||||
el.addEventListener('click', () => {
|
||||
const i = parseInt(el.dataset.fidx, 10);
|
||||
const pos = (el.dataset.which === 'min' ? minPos : maxPos)[i];
|
||||
if (pos && pos.length === 3) {
|
||||
viewerManager.sendMessage({ type: 'FOCUS_ON_POINT', point: pos, timestamp: Date.now() });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Build file path and message
|
||||
const filepath = buildViewUrl(filename);
|
||||
const messageData = {
|
||||
type: "LOAD_MESH",
|
||||
filepath: filepath,
|
||||
timestamp: Date.now(),
|
||||
showEdges: viewerState.show_edges,
|
||||
cameraState: viewerState.camera_state,
|
||||
selectedField: viewerState.selected_field,
|
||||
selectedChannel: viewerState.selected_channel,
|
||||
selectedColormap: viewerState.selected_colormap,
|
||||
};
|
||||
|
||||
// Switch viewer if needed and send message
|
||||
viewerManager.switchViewer(viewerType, getViewerUrl(viewerName), messageData);
|
||||
}
|
||||
};
|
||||
|
||||
return r;
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,463 @@
|
||||
/**
|
||||
* ComfyUI GeomPack - VTK.js Batch Mesh Preview Widget
|
||||
* Scientific visualization with VTK.js and batch navigation
|
||||
*/
|
||||
|
||||
import { app } from "../../../scripts/app.js";
|
||||
|
||||
// Auto-detect extension folder name (handles ComfyUI-GeometryPack or comfyui-geometrypack)
|
||||
const EXTENSION_FOLDER = (() => {
|
||||
const url = import.meta.url;
|
||||
const match = url.match(/\/extensions\/([^/]+)\//);
|
||||
return match ? match[1] : "ComfyUI-GeometryPack";
|
||||
})();
|
||||
|
||||
app.registerExtension({
|
||||
name: "geompack.meshpreview.vtk.batch",
|
||||
|
||||
async beforeRegisterNodeDef(nodeType, nodeData, app) {
|
||||
if (nodeData.name === "GeomPackPreviewMeshVTKBatch") {
|
||||
|
||||
const onNodeCreated = nodeType.prototype.onNodeCreated;
|
||||
nodeType.prototype.onNodeCreated = function() {
|
||||
const r = onNodeCreated ? onNodeCreated.apply(this, arguments) : undefined;
|
||||
|
||||
// Viewer state persisted via DOM widget serialization
|
||||
const viewerState = { show_edges: false, camera_state: "", selected_field: "", selected_channel: "magnitude", selected_colormap: "erdc_rainbow_bright" };
|
||||
|
||||
// Create container for viewer + navigation + info panel
|
||||
const container = document.createElement("div");
|
||||
container.style.width = "100%";
|
||||
container.style.height = "100%";
|
||||
container.style.display = "flex";
|
||||
container.style.flexDirection = "column";
|
||||
container.style.backgroundColor = "#2a2a2a";
|
||||
container.style.overflow = "hidden";
|
||||
|
||||
// Create iframe for VTK.js viewer
|
||||
const iframe = document.createElement("iframe");
|
||||
iframe.style.width = "100%";
|
||||
iframe.style.flex = "1 1 0";
|
||||
iframe.style.minHeight = "0";
|
||||
iframe.style.border = "none";
|
||||
iframe.style.backgroundColor = "#2a2a2a";
|
||||
|
||||
// Point to VTK.js HTML viewer (with cache buster)
|
||||
// Note: viewer will be dynamically switched based on mode in onExecuted
|
||||
// Use unified v2 viewer with modular architecture
|
||||
iframe.src = `/extensions/${EXTENSION_FOLDER}/viewer_vtk.html?v=` + Date.now();
|
||||
|
||||
// Track current viewer type to avoid unnecessary reloads
|
||||
let currentViewerType = "fields";
|
||||
|
||||
// Create navigation bar for batch controls
|
||||
const navBar = document.createElement("div");
|
||||
navBar.style.backgroundColor = "#1a1a1a";
|
||||
navBar.style.borderTop = "1px solid #444";
|
||||
navBar.style.padding = "8px 12px";
|
||||
navBar.style.display = "flex";
|
||||
navBar.style.alignItems = "center";
|
||||
navBar.style.justifyContent = "center";
|
||||
navBar.style.gap = "12px";
|
||||
navBar.style.fontSize = "12px";
|
||||
navBar.style.color = "#ccc";
|
||||
navBar.style.flexShrink = "0";
|
||||
|
||||
// Previous button
|
||||
const prevButton = document.createElement("button");
|
||||
prevButton.textContent = "< Previous";
|
||||
prevButton.style.padding = "4px 12px";
|
||||
prevButton.style.cursor = "pointer";
|
||||
prevButton.style.backgroundColor = "#333";
|
||||
prevButton.style.color = "#ccc";
|
||||
prevButton.style.border = "1px solid #555";
|
||||
prevButton.style.borderRadius = "3px";
|
||||
prevButton.style.fontSize = "11px";
|
||||
|
||||
// Index display
|
||||
const indexLabel = document.createElement("span");
|
||||
indexLabel.textContent = "1 / 1";
|
||||
indexLabel.style.minWidth = "60px";
|
||||
indexLabel.style.textAlign = "center";
|
||||
indexLabel.style.fontFamily = "monospace";
|
||||
indexLabel.style.fontWeight = "bold";
|
||||
|
||||
// Next button
|
||||
const nextButton = document.createElement("button");
|
||||
nextButton.textContent = "Next >";
|
||||
nextButton.style.padding = "4px 12px";
|
||||
nextButton.style.cursor = "pointer";
|
||||
nextButton.style.backgroundColor = "#333";
|
||||
nextButton.style.color = "#ccc";
|
||||
nextButton.style.border = "1px solid #555";
|
||||
nextButton.style.borderRadius = "3px";
|
||||
nextButton.style.fontSize = "11px";
|
||||
|
||||
// Assemble navigation bar
|
||||
navBar.appendChild(prevButton);
|
||||
navBar.appendChild(indexLabel);
|
||||
navBar.appendChild(nextButton);
|
||||
|
||||
// Create mesh info panel
|
||||
const infoPanel = document.createElement("div");
|
||||
infoPanel.style.backgroundColor = "#1a1a1a";
|
||||
infoPanel.style.borderTop = "1px solid #444";
|
||||
infoPanel.style.padding = "6px 12px";
|
||||
infoPanel.style.fontSize = "10px";
|
||||
infoPanel.style.fontFamily = "monospace";
|
||||
infoPanel.style.color = "#ccc";
|
||||
infoPanel.style.lineHeight = "1.3";
|
||||
infoPanel.style.flexShrink = "0";
|
||||
infoPanel.style.overflow = "hidden";
|
||||
infoPanel.innerHTML = '<span style="color: #888;">Mesh info will appear here after execution</span>';
|
||||
|
||||
// Add iframe, navigation bar, and info panel to container
|
||||
container.appendChild(iframe);
|
||||
container.appendChild(navBar);
|
||||
container.appendChild(infoPanel);
|
||||
|
||||
// Add widget with required options
|
||||
|
||||
const widget = this.addDOMWidget("preview_vtk_batch", "MESH_PREVIEW_VTK_BATCH", container, {
|
||||
getValue() { return JSON.stringify(viewerState); },
|
||||
setValue(v) {
|
||||
try { Object.assign(viewerState, JSON.parse(v)); } catch(e) {}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
widget.computeSize = () => [512, 680]; // Increased height for viewer + navigation + info panel
|
||||
|
||||
// Store iframe and info panel references
|
||||
this.meshViewerIframeVTKBatch = iframe;
|
||||
this.meshInfoPanelVTKBatch = infoPanel;
|
||||
this.meshNavBarVTKBatch = navBar;
|
||||
|
||||
// Bidirectional sync: viewer → node widgets (viewerState + real widgets)
|
||||
const node = this;
|
||||
window.addEventListener('message', (event) => {
|
||||
// Without this check, every open GeomPackPreviewMeshVTKBatch
|
||||
// instance's listener fires for every iframe's messages, not
|
||||
// just its own -- e.g. toggling "show edges" in one viewer
|
||||
// updates every other open viewer's state too.
|
||||
if (event.source !== iframe.contentWindow) return;
|
||||
if (event.data.type === 'WIDGET_UPDATE') {
|
||||
const { widget: name, value } = event.data;
|
||||
if (name in viewerState) viewerState[name] = value;
|
||||
const w = node.widgets?.find(w => w.name === name);
|
||||
if (w) w.value = value;
|
||||
}
|
||||
});
|
||||
|
||||
// Track iframe load state
|
||||
let iframeLoaded = false;
|
||||
iframe.addEventListener('load', () => {
|
||||
iframeLoaded = true;
|
||||
});
|
||||
|
||||
// Find the index widget (created by ComfyUI from INPUT_TYPES)
|
||||
const indexWidget = this.widgets.find(w => w.name === "index");
|
||||
|
||||
// Track batch state
|
||||
let currentBatchSize = 1;
|
||||
let currentIndex = 0;
|
||||
|
||||
// Add callback to auto-execute when index changes manually
|
||||
if (indexWidget) {
|
||||
const originalCallback = indexWidget.callback;
|
||||
indexWidget.callback = function(value) {
|
||||
// Call original callback if it exists
|
||||
const result = originalCallback?.apply(this, arguments);
|
||||
|
||||
// Update tracked index
|
||||
currentIndex = value;
|
||||
|
||||
// Trigger re-execution (same as button clicks)
|
||||
app.queuePrompt();
|
||||
|
||||
return result;
|
||||
};
|
||||
}
|
||||
|
||||
// Button click handlers
|
||||
prevButton.addEventListener("click", () => {
|
||||
if (indexWidget && currentIndex > 0) {
|
||||
indexWidget.value = currentIndex - 1;
|
||||
// Trigger node queue/re-execution
|
||||
app.queuePrompt();
|
||||
}
|
||||
});
|
||||
|
||||
nextButton.addEventListener("click", () => {
|
||||
if (indexWidget && currentIndex < currentBatchSize - 1) {
|
||||
indexWidget.value = currentIndex + 1;
|
||||
// Trigger node queue/re-execution
|
||||
app.queuePrompt();
|
||||
}
|
||||
});
|
||||
|
||||
// Update button states
|
||||
const updateNavigationButtons = () => {
|
||||
prevButton.disabled = currentIndex === 0;
|
||||
nextButton.disabled = currentIndex >= currentBatchSize - 1;
|
||||
|
||||
// Style disabled buttons
|
||||
if (prevButton.disabled) {
|
||||
prevButton.style.opacity = "0.4";
|
||||
prevButton.style.cursor = "not-allowed";
|
||||
} else {
|
||||
prevButton.style.opacity = "1";
|
||||
prevButton.style.cursor = "pointer";
|
||||
}
|
||||
|
||||
if (nextButton.disabled) {
|
||||
nextButton.style.opacity = "0.4";
|
||||
nextButton.style.cursor = "not-allowed";
|
||||
} else {
|
||||
nextButton.style.opacity = "1";
|
||||
nextButton.style.cursor = "pointer";
|
||||
}
|
||||
};
|
||||
|
||||
// Listen for messages from iframe
|
||||
window.addEventListener('message', async (event) => {
|
||||
if (event.source !== iframe.contentWindow) return;
|
||||
// Handle screenshot messages
|
||||
if (event.data.type === 'SCREENSHOT' && event.data.image) {
|
||||
|
||||
try {
|
||||
// Convert base64 data URL to blob
|
||||
const base64Data = event.data.image.split(',')[1];
|
||||
const byteString = atob(base64Data);
|
||||
const arrayBuffer = new ArrayBuffer(byteString.length);
|
||||
const uint8Array = new Uint8Array(arrayBuffer);
|
||||
|
||||
for (let i = 0; i < byteString.length; i++) {
|
||||
uint8Array[i] = byteString.charCodeAt(i);
|
||||
}
|
||||
|
||||
const blob = new Blob([uint8Array], { type: 'image/png' });
|
||||
|
||||
// Generate filename with timestamp
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
const filename = `vtk-screenshot-${timestamp}.png`;
|
||||
|
||||
// Create FormData for upload
|
||||
const formData = new FormData();
|
||||
formData.append('image', blob, filename);
|
||||
formData.append('type', 'output'); // Save to output directory
|
||||
formData.append('subfolder', ''); // Root of output folder
|
||||
|
||||
// Upload to ComfyUI backend
|
||||
const response = await fetch('/upload/image', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
} else {
|
||||
throw new Error(`Upload failed: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('[GeomPack VTK Batch] Error saving screenshot:', error);
|
||||
}
|
||||
}
|
||||
// Handle error messages from iframe
|
||||
else if (event.data.type === 'MESH_ERROR' && event.data.error) {
|
||||
console.error('[GeomPack VTK Batch] Error from viewer:', event.data.error);
|
||||
if (infoPanel) {
|
||||
infoPanel.innerHTML = `<div style="color: #ff6b6b; padding: 8px;">Error: ${event.data.error}</div>`;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Set initial node size (increased for info panel + navigation)
|
||||
this.setSize([512, 680]);
|
||||
|
||||
// Handle execution
|
||||
const onExecuted = this.onExecuted;
|
||||
this.onExecuted = function(message) {
|
||||
onExecuted?.apply(this, arguments);
|
||||
|
||||
// The message IS the UI data (not message.ui)
|
||||
if (message?.mesh_file && message.mesh_file[0]) {
|
||||
const filename = message.mesh_file[0];
|
||||
const viewerType = message.viewer_type?.[0] || "fields";
|
||||
const mode = message.mode?.[0] || "fields";
|
||||
|
||||
// Get batch metadata
|
||||
currentBatchSize = message.batch_size?.[0] || 1;
|
||||
currentIndex = message.current_index?.[0] || 0;
|
||||
|
||||
// Update navigation controls
|
||||
indexLabel.textContent = `${currentIndex + 1} / ${currentBatchSize}`;
|
||||
updateNavigationButtons();
|
||||
|
||||
// Dynamically update index widget max based on actual batch size
|
||||
if (indexWidget) {
|
||||
indexWidget.options.max = currentBatchSize - 1;
|
||||
|
||||
// Clamp current value if out of range
|
||||
if (indexWidget.value >= currentBatchSize) {
|
||||
indexWidget.value = currentBatchSize - 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Determine which viewer HTML to use (unified v2 viewers)
|
||||
const viewerUrl = viewerType === "texture"
|
||||
? `/extensions/${EXTENSION_FOLDER}/viewer_vtk_textured.html`
|
||||
: `/extensions/${EXTENSION_FOLDER}/viewer_vtk.html`;
|
||||
|
||||
// Update mesh info panel with metadata
|
||||
const vertices = message.vertex_count?.[0] || 'N/A';
|
||||
const faces = message.face_count?.[0] || 'N/A';
|
||||
const boundsMin = message.bounds_min?.[0] || [];
|
||||
const boundsMax = message.bounds_max?.[0] || [];
|
||||
const extents = message.extents?.[0] || [];
|
||||
|
||||
// Format bounds
|
||||
let boundsStr = 'N/A';
|
||||
if (boundsMin.length === 3 && boundsMax.length === 3) {
|
||||
boundsStr = `[${boundsMin.map(v => v.toFixed(2)).join(', ')}] to [${boundsMax.map(v => v.toFixed(2)).join(', ')}]`;
|
||||
}
|
||||
|
||||
// Format extents
|
||||
let extentsStr = 'N/A';
|
||||
if (extents.length === 3) {
|
||||
extentsStr = `${extents.map(v => v.toFixed(2)).join(' x ')}`;
|
||||
}
|
||||
|
||||
// Build info HTML
|
||||
const modeLabel = mode.charAt(0).toUpperCase() + mode.slice(1);
|
||||
const modeColor = mode === "texture" ? '#c8c' : '#6cc';
|
||||
|
||||
let infoHTML = `
|
||||
<div style="display: grid; grid-template-columns: auto 1fr; gap: 2px 8px;">
|
||||
<span style="color: #888;">Batch:</span>
|
||||
<span style="color: #8c8; font-weight: bold;">${currentIndex + 1} / ${currentBatchSize}</span>
|
||||
|
||||
<span style="color: #888;">Mode:</span>
|
||||
<span style="color: ${modeColor}; font-weight: bold;">${modeLabel}</span>
|
||||
|
||||
<span style="color: #888;">Vertices:</span>
|
||||
<span>${vertices.toLocaleString()}</span>
|
||||
|
||||
<span style="color: #888;">Faces:</span>
|
||||
<span>${faces.toLocaleString()}</span>
|
||||
|
||||
<span style="color: #888;">Bounds:</span>
|
||||
<span style="font-size: 9px;">${boundsStr}</span>
|
||||
|
||||
<span style="color: #888;">Extents:</span>
|
||||
<span>${extentsStr}</span>
|
||||
`;
|
||||
|
||||
// Add watertight status (always shown)
|
||||
if (message.is_watertight !== undefined) {
|
||||
const watertight = message.is_watertight[0] ? 'Yes' : 'No';
|
||||
const color = message.is_watertight[0] ? '#6c6' : '#c66';
|
||||
infoHTML += `
|
||||
<span style="color: #888;">Watertight:</span>
|
||||
<span style="color: ${color};">${watertight}</span>
|
||||
`;
|
||||
}
|
||||
|
||||
// Add mode-specific info
|
||||
if (viewerType === "texture") {
|
||||
// Texture mode info
|
||||
if (message.visual_kind !== undefined) {
|
||||
const visualKind = message.visual_kind[0] || 'none';
|
||||
infoHTML += `
|
||||
<span style="color: #888;">Visual Kind:</span>
|
||||
<span>${visualKind}</span>
|
||||
`;
|
||||
}
|
||||
if (message.has_texture !== undefined) {
|
||||
const hasTexture = message.has_texture[0] ? 'Yes' : 'No';
|
||||
const texColor = message.has_texture[0] ? '#c8c' : '#888';
|
||||
infoHTML += `
|
||||
<span style="color: #888;">Textures:</span>
|
||||
<span style="color: ${texColor};">${hasTexture}</span>
|
||||
`;
|
||||
}
|
||||
if (message.has_vertex_colors !== undefined) {
|
||||
const hasColors = message.has_vertex_colors[0] ? 'Yes' : 'No';
|
||||
infoHTML += `
|
||||
<span style="color: #888;">Vertex Colors:</span>
|
||||
<span>${hasColors}</span>
|
||||
`;
|
||||
}
|
||||
} else {
|
||||
// Fields mode info
|
||||
if (message.field_names && message.field_names[0]?.length > 0) {
|
||||
const fields = message.field_names[0].join(', ');
|
||||
infoHTML += `
|
||||
<span style="color: #888;">Fields:</span>
|
||||
<span style="font-size: 9px; color: #6cc;">${fields}</span>
|
||||
`;
|
||||
} else {
|
||||
infoHTML += `
|
||||
<span style="color: #888;">Fields:</span>
|
||||
<span style="color: #888;">None</span>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
infoHTML += '</div>';
|
||||
|
||||
infoPanel.innerHTML = infoHTML;
|
||||
|
||||
// ComfyUI serves output files via /view API endpoint
|
||||
const filepath = `/view?filename=${encodeURIComponent(filename)}&type=output&subfolder=`;
|
||||
|
||||
// Function to send message
|
||||
const sendMessage = () => {
|
||||
if (iframe.contentWindow) {
|
||||
iframe.contentWindow.postMessage({
|
||||
type: "LOAD_MESH",
|
||||
filepath: filepath,
|
||||
timestamp: Date.now(),
|
||||
showEdges: viewerState.show_edges,
|
||||
cameraState: viewerState.camera_state,
|
||||
selectedField: viewerState.selected_field,
|
||||
selectedChannel: viewerState.selected_channel,
|
||||
selectedColormap: viewerState.selected_colormap,
|
||||
}, "*");
|
||||
} else {
|
||||
console.error("[GeomPack VTK Batch] Iframe contentWindow not available");
|
||||
}
|
||||
};
|
||||
|
||||
// Reload iframe if viewer type changed
|
||||
if (viewerType !== currentViewerType) {
|
||||
currentViewerType = viewerType;
|
||||
iframeLoaded = false;
|
||||
|
||||
// Set up one-time load listener before changing src
|
||||
const onViewerLoaded = () => {
|
||||
iframeLoaded = true;
|
||||
sendMessage();
|
||||
};
|
||||
iframe.addEventListener('load', onViewerLoaded, { once: true });
|
||||
|
||||
// Change iframe src to trigger reload
|
||||
iframe.src = viewerUrl + "?v=" + Date.now();
|
||||
} else {
|
||||
// No viewer change needed, send message immediately or after short delay
|
||||
if (iframeLoaded) {
|
||||
sendMessage();
|
||||
} else {
|
||||
setTimeout(sendMessage, 500);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
}
|
||||
};
|
||||
|
||||
return r;
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,261 @@
|
||||
/**
|
||||
* ComfyUI GeomPack - VTK.js Textured Mesh Preview Widget
|
||||
* Supports GLB/GLTF with textures and PBR materials via VTK.js GLTFImporter
|
||||
*/
|
||||
|
||||
import { app } from "../../../scripts/app.js";
|
||||
|
||||
// Auto-detect extension folder name (handles ComfyUI-GeometryPack or comfyui-geometrypack)
|
||||
const EXTENSION_FOLDER = (() => {
|
||||
const url = import.meta.url;
|
||||
const match = url.match(/\/extensions\/([^/]+)\//);
|
||||
return match ? match[1] : "ComfyUI-GeometryPack";
|
||||
})();
|
||||
|
||||
console.log("[GeomPack] Loading VTK.js textured mesh preview extension...");
|
||||
|
||||
app.registerExtension({
|
||||
name: "geompack.meshpreview.vtk.textured",
|
||||
|
||||
async beforeRegisterNodeDef(nodeType, nodeData, app) {
|
||||
if (nodeData.name === "GeomPackPreviewMeshVTKWithTexture") {
|
||||
console.log("[GeomPack] Registering Preview Mesh (VTK with Textures) node");
|
||||
|
||||
const onNodeCreated = nodeType.prototype.onNodeCreated;
|
||||
nodeType.prototype.onNodeCreated = function() {
|
||||
const r = onNodeCreated ? onNodeCreated.apply(this, arguments) : undefined;
|
||||
|
||||
// Create container for viewer + info panel
|
||||
const container = document.createElement("div");
|
||||
container.style.width = "100%";
|
||||
container.style.height = "100%";
|
||||
container.style.display = "flex";
|
||||
container.style.flexDirection = "column";
|
||||
container.style.backgroundColor = "#2a2a2a";
|
||||
container.style.overflow = "hidden";
|
||||
|
||||
// Create iframe for VTK.js textured viewer
|
||||
const iframe = document.createElement("iframe");
|
||||
iframe.style.width = "100%";
|
||||
iframe.style.flex = "1 1 0";
|
||||
iframe.style.minHeight = "0";
|
||||
iframe.style.border = "none";
|
||||
iframe.style.backgroundColor = "#2a2a2a";
|
||||
|
||||
// Point to VTK.js textured HTML viewer (with cache buster)
|
||||
// Use unified v2 viewer with modular architecture
|
||||
iframe.src = `/extensions/${EXTENSION_FOLDER}/viewer_vtk_textured.html?v=` + Date.now();
|
||||
|
||||
// Create mesh info panel
|
||||
const infoPanel = document.createElement("div");
|
||||
infoPanel.style.backgroundColor = "#1a1a1a";
|
||||
infoPanel.style.borderTop = "1px solid #444";
|
||||
infoPanel.style.padding = "6px 12px";
|
||||
infoPanel.style.fontSize = "10px";
|
||||
infoPanel.style.fontFamily = "monospace";
|
||||
infoPanel.style.color = "#ccc";
|
||||
infoPanel.style.lineHeight = "1.3";
|
||||
infoPanel.style.flexShrink = "0";
|
||||
infoPanel.style.overflow = "hidden";
|
||||
infoPanel.innerHTML = '<span style="color: #888;">Textured mesh info will appear here after execution</span>';
|
||||
|
||||
// Add iframe and info panel to container
|
||||
container.appendChild(iframe);
|
||||
container.appendChild(infoPanel);
|
||||
|
||||
// Add widget with required options
|
||||
const widget = this.addDOMWidget("preview_vtk_textured", "MESH_PREVIEW_VTK_TEXTURED", container, {
|
||||
getValue() { return ""; },
|
||||
setValue(v) { }
|
||||
});
|
||||
|
||||
widget.computeSize = () => [512, 640]; // Increased height for viewer + info panel
|
||||
|
||||
// Store iframe and info panel references
|
||||
this.meshViewerIframeVTKTextured = iframe;
|
||||
this.meshInfoPanelVTKTextured = infoPanel;
|
||||
|
||||
// Track iframe load state
|
||||
let iframeLoaded = false;
|
||||
iframe.addEventListener('load', () => {
|
||||
iframeLoaded = true;
|
||||
});
|
||||
|
||||
// Listen for messages from iframe
|
||||
window.addEventListener('message', async (event) => {
|
||||
// Handle screenshot messages
|
||||
if (event.data.type === 'SCREENSHOT' && event.data.image) {
|
||||
console.log('[GeomPack VTK Textured] Received screenshot from iframe');
|
||||
|
||||
try {
|
||||
// Convert base64 data URL to blob
|
||||
const base64Data = event.data.image.split(',')[1];
|
||||
const byteString = atob(base64Data);
|
||||
const arrayBuffer = new ArrayBuffer(byteString.length);
|
||||
const uint8Array = new Uint8Array(arrayBuffer);
|
||||
|
||||
for (let i = 0; i < byteString.length; i++) {
|
||||
uint8Array[i] = byteString.charCodeAt(i);
|
||||
}
|
||||
|
||||
const blob = new Blob([uint8Array], { type: 'image/png' });
|
||||
|
||||
// Generate filename with timestamp
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
const filename = `vtk-textured-screenshot-${timestamp}.png`;
|
||||
|
||||
// Create FormData for upload
|
||||
const formData = new FormData();
|
||||
formData.append('image', blob, filename);
|
||||
formData.append('type', 'output'); // Save to output directory
|
||||
formData.append('subfolder', ''); // Root of output folder
|
||||
|
||||
// Upload to ComfyUI backend
|
||||
console.log('[GeomPack VTK Textured] Uploading screenshot to server...');
|
||||
const response = await fetch('/upload/image', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
console.log('[GeomPack VTK Textured] Screenshot saved to outputs folder:', result.name);
|
||||
} else {
|
||||
throw new Error(`Upload failed: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('[GeomPack VTK Textured] Error saving screenshot:', error);
|
||||
}
|
||||
}
|
||||
// Handle error messages from iframe
|
||||
else if (event.data.type === 'MESH_ERROR' && event.data.error) {
|
||||
console.error('[GeomPack VTK Textured] Error from viewer:', event.data.error);
|
||||
if (infoPanel) {
|
||||
infoPanel.innerHTML = `<div style="color: #ff6b6b; padding: 8px;">Error: ${event.data.error}</div>`;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Set initial node size (increased for info panel)
|
||||
this.setSize([512, 640]);
|
||||
|
||||
// Handle execution
|
||||
const onExecuted = this.onExecuted;
|
||||
this.onExecuted = function(message) {
|
||||
onExecuted?.apply(this, arguments);
|
||||
|
||||
// The message IS the UI data (not message.ui)
|
||||
if (message?.mesh_file && message.mesh_file[0]) {
|
||||
const filename = message.mesh_file[0];
|
||||
|
||||
// Update mesh info panel with metadata
|
||||
const vertices = message.vertex_count?.[0] || 'N/A';
|
||||
const faces = message.face_count?.[0] || 'N/A';
|
||||
const boundsMin = message.bounds_min?.[0] || [];
|
||||
const boundsMax = message.bounds_max?.[0] || [];
|
||||
const extents = message.extents?.[0] || [];
|
||||
|
||||
// Texture/material info
|
||||
const hasTexture = message.has_texture?.[0] || false;
|
||||
const hasVertexColors = message.has_vertex_colors?.[0] || false;
|
||||
const hasMaterial = message.has_material?.[0] || false;
|
||||
const visualKind = message.visual_kind?.[0] || 'none';
|
||||
|
||||
// Format bounds
|
||||
let boundsStr = 'N/A';
|
||||
if (boundsMin.length === 3 && boundsMax.length === 3) {
|
||||
boundsStr = `[${boundsMin.map(v => v.toFixed(2)).join(', ')}] to [${boundsMax.map(v => v.toFixed(2)).join(', ')}]`;
|
||||
}
|
||||
|
||||
// Format extents
|
||||
let extentsStr = 'N/A';
|
||||
if (extents.length === 3) {
|
||||
extentsStr = `${extents.map(v => v.toFixed(2)).join(' x ')}`;
|
||||
}
|
||||
|
||||
// Build info HTML
|
||||
let infoHTML = `
|
||||
<div style="display: grid; grid-template-columns: auto 1fr; gap: 2px 8px;">
|
||||
<span style="color: #888;">Vertices:</span>
|
||||
<span>${vertices.toLocaleString()}</span>
|
||||
|
||||
<span style="color: #888;">Faces:</span>
|
||||
<span>${faces.toLocaleString()}</span>
|
||||
|
||||
<span style="color: #888;">Bounds:</span>
|
||||
<span style="font-size: 9px;">${boundsStr}</span>
|
||||
|
||||
<span style="color: #888;">Extents:</span>
|
||||
<span>${extentsStr}</span>
|
||||
|
||||
<span style="color: #888;">Visual Type:</span>
|
||||
<span style="color: #4fc3f7;">${visualKind}</span>
|
||||
|
||||
<span style="color: #888;">Texture:</span>
|
||||
<span style="color: ${hasTexture ? '#6c6' : '#888'};">${hasTexture ? 'Yes' : 'No'}</span>
|
||||
|
||||
<span style="color: #888;">Vertex Colors:</span>
|
||||
<span style="color: ${hasVertexColors ? '#6c6' : '#888'};">${hasVertexColors ? 'Yes' : 'No'}</span>
|
||||
|
||||
<span style="color: #888;">Material:</span>
|
||||
<span style="color: ${hasMaterial ? '#6c6' : '#888'};">${hasMaterial ? 'Yes' : 'No'}</span>
|
||||
`;
|
||||
|
||||
// Add optional fields if available
|
||||
if (message.is_watertight !== undefined) {
|
||||
const watertight = message.is_watertight[0] ? 'Yes' : 'No';
|
||||
const color = message.is_watertight[0] ? '#6c6' : '#c66';
|
||||
infoHTML += `
|
||||
<span style="color: #888;">Watertight:</span>
|
||||
<span style="color: ${color};">${watertight}</span>
|
||||
`;
|
||||
}
|
||||
|
||||
infoHTML += '</div>';
|
||||
|
||||
infoPanel.innerHTML = infoHTML;
|
||||
|
||||
// ComfyUI serves output files via /view API endpoint
|
||||
const filepath = `/view?filename=${encodeURIComponent(filename)}&type=output&subfolder=`;
|
||||
|
||||
// Prepare metadata to pass to viewer
|
||||
const metadata = {
|
||||
has_texture: hasTexture,
|
||||
has_vertex_colors: hasVertexColors,
|
||||
has_material: hasMaterial,
|
||||
visual_kind: visualKind
|
||||
};
|
||||
|
||||
// Function to send message
|
||||
const sendMessage = () => {
|
||||
if (iframe.contentWindow) {
|
||||
iframe.contentWindow.postMessage({
|
||||
type: "LOAD_MESH",
|
||||
filepath: filepath,
|
||||
metadata: metadata,
|
||||
timestamp: Date.now()
|
||||
}, "*");
|
||||
} else {
|
||||
console.error("[GeomPack VTK Textured] Iframe contentWindow not available");
|
||||
}
|
||||
};
|
||||
|
||||
// Send message after iframe is loaded
|
||||
if (iframeLoaded) {
|
||||
sendMessage();
|
||||
} else {
|
||||
setTimeout(sendMessage, 500);
|
||||
}
|
||||
} else {
|
||||
console.log("[GeomPack VTK Textured] No mesh_file in message data. Keys:", Object.keys(message || {}));
|
||||
}
|
||||
};
|
||||
|
||||
return r;
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
console.log("[GeomPack] VTK.js textured mesh preview extension registered");
|
||||
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* VTK.js Point Cloud Preview Widget
|
||||
* For point cloud visualization nodes (SAM3, DepthAnythingV3, etc.)
|
||||
*/
|
||||
|
||||
import { app } from "../../../scripts/app.js";
|
||||
import { EXTENSION_FOLDER, getViewerUrl } from "./utils/extensionFolder.js";
|
||||
import { createContainer, createIframe, createInfoPanel, createWidgetOptions } from "./utils/uiComponents.js";
|
||||
import { createViewerManager, createErrorHandler, buildViewUrl } from "./utils/postMessage.js";
|
||||
|
||||
const POINTCLOUD_NODES = ["SAM3D_PreviewPointCloud", "DA3_PreviewPointCloud"];
|
||||
|
||||
app.registerExtension({
|
||||
name: "comfy3d.pointcloud.vtk",
|
||||
|
||||
async beforeRegisterNodeDef(nodeType, nodeData, app) {
|
||||
if (POINTCLOUD_NODES.includes(nodeData.name)) {
|
||||
|
||||
const onNodeCreated = nodeType.prototype.onNodeCreated;
|
||||
nodeType.prototype.onNodeCreated = function() {
|
||||
const r = onNodeCreated ? onNodeCreated.apply(this, arguments) : undefined;
|
||||
|
||||
// Create container for viewer + info panel
|
||||
const container = createContainer();
|
||||
|
||||
// Create iframe for VTK.js viewer
|
||||
const iframe = createIframe(getViewerUrl("viewer_vtk"));
|
||||
|
||||
// Create info panel
|
||||
const infoPanel = createInfoPanel("Point cloud will appear after execution");
|
||||
|
||||
// Add iframe and info panel to container
|
||||
container.appendChild(iframe);
|
||||
container.appendChild(infoPanel);
|
||||
|
||||
// Add widget
|
||||
const widget = this.addDOMWidget("preview_vtk", "POINTCLOUD_PREVIEW_VTK", container, createWidgetOptions());
|
||||
widget.computeSize = () => [512, 580];
|
||||
|
||||
// Store references
|
||||
this._vtkIframe = iframe;
|
||||
this._infoPanel = infoPanel;
|
||||
|
||||
// Create viewer manager
|
||||
const viewerManager = createViewerManager(iframe, "[PointCloud VTK]");
|
||||
|
||||
// Listen for error messages
|
||||
window.addEventListener('message', createErrorHandler(infoPanel, "[PointCloud VTK]"));
|
||||
|
||||
// Set initial node size
|
||||
this.setSize([512, 580]);
|
||||
|
||||
return r;
|
||||
};
|
||||
|
||||
// Handle execution
|
||||
const onExecuted = nodeType.prototype.onExecuted;
|
||||
nodeType.prototype.onExecuted = function(message) {
|
||||
onExecuted?.apply(this, arguments);
|
||||
|
||||
// Get file path from ui.file_path
|
||||
let filePath = null;
|
||||
if (message?.ui?.file_path) {
|
||||
filePath = Array.isArray(message.ui.file_path) ? message.ui.file_path[0] : message.ui.file_path;
|
||||
} else if (message?.file_path) {
|
||||
filePath = Array.isArray(message.file_path) ? message.file_path[0] : message.file_path;
|
||||
}
|
||||
|
||||
if (filePath && filePath.trim() !== '') {
|
||||
// Normalize path
|
||||
filePath = filePath.replace(/\\/g, '/').trim();
|
||||
|
||||
// Build URL for ComfyUI view endpoint
|
||||
const outputMatch = filePath.match(/(?:^|\/)(output|input)\/(.+)$/);
|
||||
let url;
|
||||
if (outputMatch) {
|
||||
const [, type, relativePath] = outputMatch;
|
||||
const pathParts = relativePath.split('/');
|
||||
const filename = pathParts.pop();
|
||||
const subfolder = pathParts.join('/');
|
||||
url = `/view?filename=${encodeURIComponent(filename)}&type=${type}&subfolder=${encodeURIComponent(subfolder)}`;
|
||||
} else {
|
||||
const filename = filePath.split('/').pop();
|
||||
url = `/view?filename=${encodeURIComponent(filename)}&type=output&subfolder=`;
|
||||
}
|
||||
|
||||
// Update info panel
|
||||
if (this._infoPanel) {
|
||||
const filename = filePath.split('/').pop();
|
||||
this._infoPanel.innerHTML = `<strong>File:</strong> ${filename}`;
|
||||
}
|
||||
|
||||
// Send message to iframe
|
||||
if (this._vtkIframe?.contentWindow) {
|
||||
const sendMessage = () => {
|
||||
this._vtkIframe.contentWindow.postMessage({
|
||||
type: 'LOAD_MESH',
|
||||
filepath: url
|
||||
}, '*');
|
||||
};
|
||||
|
||||
if (this._vtkIframe.contentDocument?.readyState === 'complete') {
|
||||
sendMessage();
|
||||
} else {
|
||||
this._vtkIframe.addEventListener('load', () => setTimeout(sendMessage, 100), { once: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
// Preview Mesh Batch Render: show the edge_opacity widget only when show_edges
|
||||
// is enabled (collapse it otherwise).
|
||||
import { app } from "../../../scripts/app.js";
|
||||
|
||||
const TAG = "[PreviewMeshBatchRender]";
|
||||
console.log(`${TAG} script loaded`);
|
||||
|
||||
const findW = (node, name) => (node.widgets || []).find((w) => w.name === name);
|
||||
|
||||
function toggleWidget(w, show) {
|
||||
if (!w) return;
|
||||
if (!w.__gpOrig) w.__gpOrig = { type: w.type, computeSize: w.computeSize };
|
||||
if (show) {
|
||||
w.type = w.__gpOrig.type;
|
||||
w.computeSize = w.__gpOrig.computeSize;
|
||||
} else {
|
||||
w.type = "geompack_hidden"; // unknown type -> not drawn
|
||||
w.computeSize = () => [0, -4]; // collapse (cancels the 4px row gap)
|
||||
}
|
||||
}
|
||||
|
||||
app.registerExtension({
|
||||
name: "geompack.previewmeshbatchrender.ui",
|
||||
async beforeRegisterNodeDef(nodeType, nodeData) {
|
||||
if (nodeData.name !== "GeomPackPreviewMeshBatchRender") return;
|
||||
console.log(`${TAG} registering for GeomPackPreviewMeshBatchRender`);
|
||||
|
||||
const onCreated = nodeType.prototype.onNodeCreated;
|
||||
nodeType.prototype.onNodeCreated = function () {
|
||||
const r = onCreated?.apply(this, arguments);
|
||||
const node = this;
|
||||
const showEdges = findW(node, "show_edges");
|
||||
const edgeOpacity = findW(node, "edge_opacity");
|
||||
|
||||
const update = () => {
|
||||
toggleWidget(edgeOpacity, !!(showEdges && showEdges.value));
|
||||
node.setSize(node.computeSize());
|
||||
node.setDirtyCanvas(true, true);
|
||||
};
|
||||
|
||||
if (showEdges) {
|
||||
const cb = showEdges.callback;
|
||||
showEdges.callback = function () {
|
||||
const ret = cb?.apply(this, arguments);
|
||||
update();
|
||||
return ret;
|
||||
};
|
||||
}
|
||||
setTimeout(update, 0); // apply initial state (edges off -> hidden)
|
||||
return r;
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
// Preview Mesh Boundaries: interactive VTK.js viewer in the node. On execute the
|
||||
// Python node writes a .vtp (surface + thresholded boundary edges) to temp; here
|
||||
// we mount viewer_vtk.html and postMessage it the file URL to load.
|
||||
import { app } from "../../../scripts/app.js";
|
||||
|
||||
const TAG = "[PreviewMeshBoundaries]";
|
||||
console.log(`${TAG} script loaded`);
|
||||
|
||||
const EXTENSION_FOLDER = (() => {
|
||||
const m = import.meta.url.match(/\/extensions\/([^/]+)\//);
|
||||
return m ? m[1] : "ComfyUI-GeometryPack";
|
||||
})();
|
||||
|
||||
// Build a /view URL from a payload filename. Handles bare temp names and
|
||||
// type-prefixed relative paths (output/.. input/.. temp/..).
|
||||
function viewUrl(name) {
|
||||
const norm = String(name).replace(/\\/g, "/");
|
||||
const m = norm.match(/(?:^|\/)(output|input|temp)\/(.+)$/);
|
||||
if (m) {
|
||||
const [, type, rel] = m;
|
||||
const parts = rel.split("/");
|
||||
const fname = parts.pop();
|
||||
return `/view?filename=${encodeURIComponent(fname)}&type=${type}&subfolder=${encodeURIComponent(parts.join("/"))}`;
|
||||
}
|
||||
return `/view?filename=${encodeURIComponent(norm.split("/").pop())}&type=temp&subfolder=`;
|
||||
}
|
||||
|
||||
app.registerExtension({
|
||||
name: "geompack.previewmeshboundaries",
|
||||
async beforeRegisterNodeDef(nodeType, nodeData) {
|
||||
if (nodeData.name !== "GeomPackPreviewMeshBoundaries") return;
|
||||
console.log(`${TAG} registering for GeomPackPreviewMeshBoundaries`);
|
||||
|
||||
const onCreated = nodeType.prototype.onNodeCreated;
|
||||
nodeType.prototype.onNodeCreated = function () {
|
||||
const r = onCreated?.apply(this, arguments);
|
||||
const node = this;
|
||||
|
||||
const iframe = document.createElement("iframe");
|
||||
iframe.style.cssText = "width:100%;height:100%;border:none;background:#2a2a2a;aspect-ratio:1;";
|
||||
iframe.src = `/extensions/${EXTENSION_FOLDER}/viewer_vtk.html?v=` + Date.now();
|
||||
|
||||
const widget = node.addDOMWidget("preview", "MESH_PREVIEW", iframe, {
|
||||
getValue() { return ""; }, setValue() { },
|
||||
});
|
||||
widget.computeSize = (w) => [w || 512, w || 512];
|
||||
node.size = [Math.max(node.size?.[0] || 0, 380), Math.max(node.size?.[1] || 0, 420)];
|
||||
node._gpBoundIframe = iframe;
|
||||
return r;
|
||||
};
|
||||
|
||||
const onExecuted = nodeType.prototype.onExecuted;
|
||||
nodeType.prototype.onExecuted = function (message) {
|
||||
onExecuted?.apply(this, arguments);
|
||||
const iframe = this._gpBoundIframe;
|
||||
const name = message?.mesh_file?.[0];
|
||||
if (!iframe || !name) {
|
||||
console.warn(`${TAG} no mesh_file in payload`, message && Object.keys(message));
|
||||
return;
|
||||
}
|
||||
const url = viewUrl(name);
|
||||
const send = () => {
|
||||
if (iframe.contentWindow) {
|
||||
console.log(`${TAG} loading ${url}`);
|
||||
iframe.contentWindow.postMessage(
|
||||
{ type: "LOAD_MESH", filepath: url, timestamp: Date.now() }, "*");
|
||||
}
|
||||
};
|
||||
setTimeout(send, 250); // give the iframe time if just mounted
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* ComfyUI GeomPack - Multi Mesh Preview Widget (Batch Input)
|
||||
* Grid viewer for a mesh batch, with a user-chosen rows x cols layout.
|
||||
* Always uses the fields (scalar) viewer -- no wipe/slider/texture modes.
|
||||
*/
|
||||
|
||||
import { app } from "../../../scripts/app.js";
|
||||
|
||||
const EXTENSION_FOLDER = (() => {
|
||||
const url = import.meta.url;
|
||||
const match = url.match(/\/extensions\/([^/]+)\//);
|
||||
return match ? match[1] : "ComfyUI-GeometryPack";
|
||||
})();
|
||||
|
||||
console.log('[GeomPack Multi Batch JS] Loading preview_mesh_multi_batch.js extension');
|
||||
|
||||
app.registerExtension({
|
||||
name: "geompack.meshpreview.multibatch",
|
||||
|
||||
async beforeRegisterNodeDef(nodeType, nodeData, app) {
|
||||
if (nodeData.name === "GeomPackPreviewMeshMultiBatch") {
|
||||
const onNodeCreated = nodeType.prototype.onNodeCreated;
|
||||
nodeType.prototype.onNodeCreated = function() {
|
||||
const r = onNodeCreated ? onNodeCreated.apply(this, arguments) : undefined;
|
||||
|
||||
// Viewer state persisted via DOM widget serialization
|
||||
const viewerState = { show_edges: false, camera_state: "", selected_field: "", selected_channel: "magnitude", selected_colormap: "erdc_rainbow_bright" };
|
||||
|
||||
const container = document.createElement("div");
|
||||
container.style.width = "100%";
|
||||
container.style.height = "100%";
|
||||
container.style.display = "flex";
|
||||
container.style.flexDirection = "column";
|
||||
container.style.backgroundColor = "#2a2a2a";
|
||||
|
||||
const iframe = document.createElement("iframe");
|
||||
iframe.style.width = "100%";
|
||||
iframe.style.flex = "1";
|
||||
iframe.style.minHeight = "450px";
|
||||
iframe.style.border = "none";
|
||||
iframe.style.backgroundColor = "#2a2a2a";
|
||||
iframe.src = `/extensions/${EXTENSION_FOLDER}/viewer_multi.html?v=` + Date.now();
|
||||
|
||||
const infoPanel = document.createElement("div");
|
||||
infoPanel.style.backgroundColor = "#1a1a1a";
|
||||
infoPanel.style.borderTop = "1px solid #444";
|
||||
infoPanel.style.padding = "6px 12px";
|
||||
infoPanel.style.fontSize = "10px";
|
||||
infoPanel.style.fontFamily = "monospace";
|
||||
infoPanel.style.color = "#ccc";
|
||||
infoPanel.style.lineHeight = "1.3";
|
||||
infoPanel.style.flexShrink = "0";
|
||||
infoPanel.style.overflow = "hidden";
|
||||
infoPanel.innerHTML = '<span style="color: #888;">Mesh info will appear here after execution</span>';
|
||||
|
||||
container.appendChild(iframe);
|
||||
container.appendChild(infoPanel);
|
||||
|
||||
const widget = this.addDOMWidget("preview_multi_batch", "MESH_PREVIEW_MULTI_BATCH", container, {
|
||||
getValue() { return JSON.stringify(viewerState); },
|
||||
setValue(v) {
|
||||
try { Object.assign(viewerState, JSON.parse(v)); } catch(e) {}
|
||||
}
|
||||
});
|
||||
|
||||
widget.computeSize = () => [768, 580];
|
||||
|
||||
this.meshViewerIframeMultiBatch = iframe;
|
||||
this.meshInfoPanelMultiBatch = infoPanel;
|
||||
|
||||
this.setSize([768, 580]);
|
||||
|
||||
// Bidirectional sync: viewer -> node widgets (viewerState + real widgets)
|
||||
const node = this;
|
||||
window.addEventListener('message', (event) => {
|
||||
// Without this check, every open viewer instance's listener
|
||||
// fires for every iframe's messages, not just its own.
|
||||
if (event.source !== iframe.contentWindow) return;
|
||||
if (event.data.type === 'WIDGET_UPDATE') {
|
||||
const { widget: name, value } = event.data;
|
||||
if (name in viewerState) viewerState[name] = value;
|
||||
const w = node.widgets?.find(w => w.name === name);
|
||||
if (w) w.value = value;
|
||||
}
|
||||
});
|
||||
|
||||
let lastLoad = null; // { numMeshes, filepaths, gridCols, gridRows }
|
||||
const buildAndSend = () => {
|
||||
if (!lastLoad || !iframe.contentWindow) return;
|
||||
const msg = {
|
||||
type: 'LOAD_MULTI_MESH', numMeshes: lastLoad.numMeshes, meshFiles: lastLoad.filepaths,
|
||||
gridCols: lastLoad.gridCols, gridRows: lastLoad.gridRows,
|
||||
timestamp: Date.now(), showEdges: viewerState.show_edges, cameraState: viewerState.camera_state,
|
||||
selectedField: viewerState.selected_field, selectedChannel: viewerState.selected_channel,
|
||||
selectedColormap: viewerState.selected_colormap,
|
||||
};
|
||||
iframe.contentWindow.postMessage(msg, "*");
|
||||
};
|
||||
iframe.addEventListener('load', () => buildAndSend());
|
||||
|
||||
const onExecuted = this.onExecuted;
|
||||
this.onExecuted = function(message) {
|
||||
onExecuted?.apply(this, arguments);
|
||||
|
||||
if (!message?.num_meshes) {
|
||||
return;
|
||||
}
|
||||
|
||||
const numMeshes = message.num_meshes[0];
|
||||
const meshFiles = message.mesh_files[0];
|
||||
const vertexCounts = message.vertex_counts[0];
|
||||
const faceCounts = message.face_counts[0];
|
||||
const gridCols = message.grid_cols[0];
|
||||
const gridRows = message.grid_rows[0];
|
||||
|
||||
console.log(`[GeomPack Multi Batch] onExecuted: ${numMeshes} meshes, grid ${gridCols}x${gridRows}`);
|
||||
|
||||
const wt = message.is_watertight_list?.[0] || [];
|
||||
const avg = message.avg_edge_lengths?.[0] || [];
|
||||
const bnds = message.bounds_list?.[0] || [];
|
||||
const exts = message.extents_list?.[0] || [];
|
||||
const fields = message.field_names_list?.[0] || null;
|
||||
const num = (v) => (v == null ? '—' : Number(v).toLocaleString());
|
||||
const sig = (v) => (v == null ? '—' : Number(v).toLocaleString(undefined, { maximumSignificantDigits: 4 }));
|
||||
const ext = (e) => (e ? e.map((x) => Number(x).toFixed(2)).join(' × ') : '—');
|
||||
const bnd = (b) => (b
|
||||
? `<span style="font-size:9px;color:#aaa;">[${b[0].map((x) => Number(x).toFixed(1)).join(', ')}]<br>→ [${b[1].map((x) => Number(x).toFixed(1)).join(', ')}]</span>`
|
||||
: '—');
|
||||
|
||||
let infoHTML = `<div style="display: grid; grid-template-columns: auto repeat(${numMeshes}, 1fr); gap: 2px 12px; font: 11px monospace;">`;
|
||||
infoHTML += `<span style="color: #888;"></span>`;
|
||||
for (let i = 0; i < numMeshes; i++) {
|
||||
infoHTML += `<span style="color: #999; font-weight: bold; border-bottom: 1px solid #333;">Mesh ${i + 1}</span>`;
|
||||
}
|
||||
const row = (label, valFn) => {
|
||||
infoHTML += `<span style="color: #888;">${label}</span>`;
|
||||
for (let i = 0; i < numMeshes; i++) infoHTML += `<span>${valFn(i)}</span>`;
|
||||
};
|
||||
row('Vertices:', (i) => num(vertexCounts[i]));
|
||||
row('Faces:', (i) => num(faceCounts[i]));
|
||||
row('Watertight:', (i) => { const w = wt[i]; return `<span style="color:${w ? '#7c7' : '#c77'};">${w ? 'Yes' : 'No'}</span>`; });
|
||||
row('Avg edge:', (i) => sig(avg[i]));
|
||||
row('Extents:', (i) => ext(exts[i]));
|
||||
row('Bounds:', (i) => bnd(bnds[i]));
|
||||
if (fields) {
|
||||
row('Fields:', (i) => {
|
||||
const f = fields[i] || [];
|
||||
return f.length ? `<span style="font-size:9px;color:#9bd;">${f.join(', ')}</span>` : '<span style="color:#666;">—</span>';
|
||||
});
|
||||
}
|
||||
infoHTML += '</div>';
|
||||
infoPanel.innerHTML = infoHTML;
|
||||
|
||||
const filepaths = meshFiles.map(f => `/view?filename=${encodeURIComponent(f)}&type=output&subfolder=`);
|
||||
lastLoad = { numMeshes, filepaths, gridCols, gridRows };
|
||||
buildAndSend();
|
||||
};
|
||||
|
||||
return r;
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
// Preview Mesh Slicer: interactive clip/slice viewer (viewer_slicer.html) in-node.
|
||||
// On execute the Python node writes the mesh .vtp to temp; we mount the slicer
|
||||
// iframe and postMessage it the file URL.
|
||||
import { app } from "../../../scripts/app.js";
|
||||
|
||||
const TAG = "[PreviewMeshSlicer]";
|
||||
console.log(`${TAG} script loaded`);
|
||||
|
||||
const EXTENSION_FOLDER = (() => {
|
||||
const m = import.meta.url.match(/\/extensions\/([^/]+)\//);
|
||||
return m ? m[1] : "ComfyUI-GeometryPack";
|
||||
})();
|
||||
|
||||
function viewUrl(name) {
|
||||
const norm = String(name).replace(/\\/g, "/");
|
||||
const m = norm.match(/(?:^|\/)(output|input|temp)\/(.+)$/);
|
||||
if (m) {
|
||||
const [, type, rel] = m;
|
||||
const parts = rel.split("/");
|
||||
const fname = parts.pop();
|
||||
return `/view?filename=${encodeURIComponent(fname)}&type=${type}&subfolder=${encodeURIComponent(parts.join("/"))}`;
|
||||
}
|
||||
return `/view?filename=${encodeURIComponent(norm.split("/").pop())}&type=temp&subfolder=`;
|
||||
}
|
||||
|
||||
app.registerExtension({
|
||||
name: "geompack.previewmeshslicer",
|
||||
async beforeRegisterNodeDef(nodeType, nodeData) {
|
||||
if (nodeData.name !== "GeomPackPreviewMeshSlicer") return;
|
||||
console.log(`${TAG} registering for GeomPackPreviewMeshSlicer`);
|
||||
|
||||
const onCreated = nodeType.prototype.onNodeCreated;
|
||||
nodeType.prototype.onNodeCreated = function () {
|
||||
const r = onCreated?.apply(this, arguments);
|
||||
const node = this;
|
||||
const iframe = document.createElement("iframe");
|
||||
iframe.style.cssText = "width:100%;height:100%;border:none;background:#2a2a2a;";
|
||||
iframe.src = `/extensions/${EXTENSION_FOLDER}/viewer_slicer.html?v=` + Date.now();
|
||||
const widget = node.addDOMWidget("slicer", "MESH_PREVIEW", iframe, {
|
||||
getValue() { return ""; }, setValue() { },
|
||||
});
|
||||
widget.computeSize = (w) => [w || 480, Math.round((w || 480) * 1.15)];
|
||||
node.size = [Math.max(node.size?.[0] || 0, 420), Math.max(node.size?.[1] || 0, 540)];
|
||||
node._gpSlicerIframe = iframe;
|
||||
|
||||
// plane_json (a real schema input -> auto-created widget) is a machine-written
|
||||
// mirror of whatever clip plane is picked interactively in the iframe, not
|
||||
// something meant to be hand-edited -- hide it from the node body.
|
||||
const planeWidget = node.widgets?.find((w) => w.name === "plane_json");
|
||||
if (planeWidget) {
|
||||
planeWidget.hidden = true;
|
||||
planeWidget.computeSize = () => [0, -4];
|
||||
}
|
||||
|
||||
// The clip plane only ever exists as local JS state inside the iframe
|
||||
// (viewer_slicer.html) -- execute() has no other way to see it, so mirror it
|
||||
// into the (hidden) plane_json widget on every change; that's what gets
|
||||
// serialized into the prompt on the next queue.
|
||||
window.addEventListener("message", (event) => {
|
||||
if (event.source !== iframe.contentWindow) return;
|
||||
if (event.data?.type !== "PLANE_UPDATE") return;
|
||||
const w = node.widgets?.find((x) => x.name === "plane_json");
|
||||
if (!w) return;
|
||||
w.value = JSON.stringify(event.data);
|
||||
node.setDirtyCanvas(true, true);
|
||||
});
|
||||
|
||||
return r;
|
||||
};
|
||||
|
||||
const onExecuted = nodeType.prototype.onExecuted;
|
||||
nodeType.prototype.onExecuted = function (message) {
|
||||
onExecuted?.apply(this, arguments);
|
||||
const iframe = this._gpSlicerIframe;
|
||||
const name = message?.mesh_file?.[0];
|
||||
if (!iframe || !name) return;
|
||||
const url = viewUrl(name);
|
||||
setTimeout(() => {
|
||||
if (iframe.contentWindow) {
|
||||
console.log(`${TAG} loading ${url}`);
|
||||
iframe.contentWindow.postMessage({ type: "LOAD_MESH", filepath: url, timestamp: Date.now() }, "*");
|
||||
}
|
||||
}, 250);
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,185 @@
|
||||
/**
|
||||
* ComfyUI GeomPack - Generic Text Report Renderer
|
||||
* Collapsible info panel. Arrow toggle at top of box.
|
||||
*/
|
||||
|
||||
import { app } from "../../../scripts/app.js";
|
||||
import { buildTextReportHTML } from "./utils/analysisPanel.js";
|
||||
|
||||
const TEXT_REPORT_NODES = [
|
||||
// CADabra
|
||||
"CADUltimateInspection",
|
||||
// Analysis
|
||||
"GeomPackMeshInfo",
|
||||
"GeomPackMeshQuality",
|
||||
"GeomPackDegenerateFaces",
|
||||
"GeomPackConnectedComponents",
|
||||
"GeomPackOpenEdges",
|
||||
"GeomPackComputeCurvature",
|
||||
"GeomPackSegmentByCurvature",
|
||||
"GeomPackComputeFilletField",
|
||||
"GeomPackSegmentPatches",
|
||||
"GeomPackClusterFaces",
|
||||
// Repair
|
||||
"GeomPackFillHoles",
|
||||
"GeomPackMeshFix",
|
||||
"GeomPackCheckNormals",
|
||||
"GeomPackFixNormals",
|
||||
"GeomPackMeshRepair",
|
||||
"GeomPackRemoveDegenerateFaces",
|
||||
"GeomPackMergeVertices",
|
||||
"GeomPackVisualizeNormals",
|
||||
"GeomPackAddNormalsToPointCloud",
|
||||
"GeomPackFixSelfIntersectionsByRemoval",
|
||||
"GeomPackDetectSelfIntersections",
|
||||
"GeomPackFixSelfIntersectionsByPerturbation",
|
||||
"GeomPackRemeshSelfIntersections",
|
||||
// Smoothing / Decimation (unified dispatcher front nodes — info forwarded
|
||||
// from the hidden backend via the executed event's display_node)
|
||||
"GeomPackSharpenMesh",
|
||||
"GeomPackDecimateMesh",
|
||||
// Remeshing
|
||||
"GeomPackRemesh",
|
||||
"GeomPackRemeshCGAL",
|
||||
"GeomPackRemeshBlender",
|
||||
"GeomPackRemeshGPU",
|
||||
"RefineMesh",
|
||||
"GeomPackEdgeFlip",
|
||||
// Reconstruction
|
||||
"GeomPackReconstructSurface",
|
||||
"GeomPackAlphaWrap",
|
||||
// Texture remeshing
|
||||
"GeomPackTextureToGeometry",
|
||||
"GeomPackDepthNormalsToMesh",
|
||||
"GeomPackRemeshWithTexture",
|
||||
// Boolean
|
||||
"GeomPackBooleanCGAL",
|
||||
"GeomPackBooleanBlender",
|
||||
// Combine
|
||||
"GeomPackCombineMeshes",
|
||||
"GeomPackCombineMeshesBatch",
|
||||
"GeomPackSplitByField",
|
||||
// Transforms
|
||||
"GeomPackTransformMesh",
|
||||
"GeomPackNormalizeMeshToBBox",
|
||||
// UV
|
||||
"GeomPackUVUnwrap",
|
||||
// Distance
|
||||
"GeomPackPointToMeshDistance",
|
||||
"GeomPackMeshToMeshDistance",
|
||||
// IO
|
||||
"GeomPackLoadMeshFBX",
|
||||
"GeomPackLoadMeshBlend",
|
||||
// CADabra
|
||||
"CADEdgeAnalysis",
|
||||
"CAD_Quality_Metrics",
|
||||
"CADCurvature",
|
||||
"CADSewFaces",
|
||||
"CADCheckOverlappingFaces",
|
||||
"CADSplitComponents",
|
||||
"CADHealShape",
|
||||
"CADFixDegenerateFaces",
|
||||
"CADPrimitiveReconstruction",
|
||||
"CADFaceAnalysis",
|
||||
// MeshSegmenter
|
||||
"MeshSegGenerateMasks",
|
||||
"MeshSegLift2DTo3DLabels",
|
||||
"MeshSegSmoothLabels",
|
||||
"MeshSegApplyLabelsToMesh",
|
||||
];
|
||||
|
||||
const TOGGLE_HEIGHT = 28;
|
||||
const DEFAULT_CONTENT_HEIGHT = 60;
|
||||
|
||||
app.registerExtension({
|
||||
name: "geompack.text_report",
|
||||
|
||||
async beforeRegisterNodeDef(nodeType, nodeData, app) {
|
||||
if (!TEXT_REPORT_NODES.includes(nodeData.name)) return;
|
||||
|
||||
const onNodeCreated = nodeType.prototype.onNodeCreated;
|
||||
nodeType.prototype.onNodeCreated = function() {
|
||||
const r = onNodeCreated ? onNodeCreated.apply(this, arguments) : undefined;
|
||||
|
||||
let expanded = false;
|
||||
let contentHeight = DEFAULT_CONTENT_HEIGHT;
|
||||
let widgetHeight = TOGGLE_HEIGHT;
|
||||
const node = this;
|
||||
|
||||
// --- Single container widget ---
|
||||
const container = document.createElement("div");
|
||||
container.style.cssText = "background:#1a1a2e; border:1px solid #333; border-radius:4px; overflow:hidden; display:flex; flex-direction:column; height:100%;";
|
||||
|
||||
// Header bar (always visible, top of box)
|
||||
const header = document.createElement("div");
|
||||
header.style.cssText = "display:flex; align-items:center; gap:6px; padding:2px 8px; cursor:pointer; user-select:none; font-size:11px; color:#888; flex-shrink:0;";
|
||||
|
||||
const arrow = document.createElement("span");
|
||||
arrow.style.cssText = "font-size:8px; display:inline-block; transition:transform 0.15s;";
|
||||
arrow.textContent = "\u25B6";
|
||||
|
||||
const label = document.createElement("span");
|
||||
label.textContent = "Info";
|
||||
|
||||
header.appendChild(arrow);
|
||||
header.appendChild(label);
|
||||
|
||||
// Content area (hidden when collapsed)
|
||||
const content = document.createElement("div");
|
||||
content.style.cssText = "padding:6px 8px; border-top:1px solid #333; font-size:11px; color:#ccc; display:none; overflow-y:auto; flex:1; min-height:0;";
|
||||
|
||||
container.appendChild(header);
|
||||
container.appendChild(content);
|
||||
|
||||
// Use getMinHeight/getHeight — the API ComfyUI's DOMWidgetImpl actually reads
|
||||
const widget = this.addDOMWidget("report_panel", "REPORT_PANEL", container, {
|
||||
getValue() { return expanded ? "true" : "false"; },
|
||||
setValue(v) { if (v === "true") setExpanded(true); },
|
||||
getMinHeight: () => widgetHeight,
|
||||
getHeight: () => widgetHeight,
|
||||
});
|
||||
|
||||
// Force node to grow to fit the widget
|
||||
requestAnimationFrame(() => {
|
||||
node.setSize([node.size[0], node.computeSize()[1]]);
|
||||
node.setDirtyCanvas(true, true);
|
||||
});
|
||||
|
||||
function resize() {
|
||||
widgetHeight = expanded ? TOGGLE_HEIGHT + contentHeight : TOGGLE_HEIGHT;
|
||||
node.setSize([node.size[0], node.computeSize()[1]]);
|
||||
node.setDirtyCanvas(true, true);
|
||||
}
|
||||
|
||||
function setExpanded(val) {
|
||||
expanded = val;
|
||||
content.style.display = expanded ? "block" : "none";
|
||||
arrow.style.transform = expanded ? "rotate(90deg)" : "";
|
||||
resize();
|
||||
}
|
||||
|
||||
// Toggle click
|
||||
header.addEventListener("click", () => {
|
||||
setExpanded(!expanded);
|
||||
});
|
||||
|
||||
// Execution results
|
||||
const onExecuted = this.onExecuted;
|
||||
this.onExecuted = function(message) {
|
||||
onExecuted?.apply(this, arguments);
|
||||
|
||||
if (message?.text && message.text.length > 0) {
|
||||
const text = message.text[0];
|
||||
content.innerHTML = buildTextReportHTML(text);
|
||||
|
||||
const firstLine = text.split("\n")[0].trim();
|
||||
if (firstLine) label.textContent = firstLine;
|
||||
|
||||
resize();
|
||||
}
|
||||
};
|
||||
|
||||
return r;
|
||||
};
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* ComfyUI GeomPack - Ultimate Mesh Inspection
|
||||
* Embedded VTK.js viewer + clickable statistics table. Every defect is baked as a
|
||||
* scalar field on the exported VTP; clicking a stat row reloads the viewer with that
|
||||
* field as the active scalar (selectedField), lighting it up.
|
||||
*/
|
||||
|
||||
import { app } from "../../../scripts/app.js";
|
||||
|
||||
// Auto-detect extension folder name (same trick as the other viewers).
|
||||
const EXTENSION_FOLDER = (() => {
|
||||
const url = import.meta.url;
|
||||
const match = url.match(/\/extensions\/([^/]+)\//);
|
||||
return match ? match[1] : "ComfyUI-GeometryPack";
|
||||
})();
|
||||
|
||||
app.registerExtension({
|
||||
name: "geompack.ultimate_mesh_inspection",
|
||||
|
||||
async beforeRegisterNodeDef(nodeType, nodeData) {
|
||||
if (nodeData.name !== "GeomPackUltimateMeshInspection") return;
|
||||
|
||||
const onNodeCreated = nodeType.prototype.onNodeCreated;
|
||||
nodeType.prototype.onNodeCreated = function () {
|
||||
const r = onNodeCreated ? onNodeCreated.apply(this, arguments) : undefined;
|
||||
const node = this;
|
||||
node._umiFilename = null;
|
||||
node._umiActiveField = null;
|
||||
|
||||
const container = document.createElement("div");
|
||||
container.style.cssText =
|
||||
"width:100%;height:100%;display:flex;flex-direction:column;background:#222;overflow:hidden;";
|
||||
|
||||
// --- title / status bar ---
|
||||
const bar = document.createElement("div");
|
||||
bar.style.cssText =
|
||||
"display:flex;align-items:center;gap:8px;padding:4px 8px;background:#1a1a1a;" +
|
||||
"border-bottom:1px solid #444;font:11px monospace;color:#bbb;flex:0 0 auto;";
|
||||
const title = document.createElement("div");
|
||||
title.textContent = "Ultimate Mesh Inspection";
|
||||
title.style.cssText = "font-weight:bold;color:#9df;";
|
||||
const status = document.createElement("div");
|
||||
status.style.cssText = "flex:1;color:#888;text-align:right;";
|
||||
status.textContent = "(run to inspect)";
|
||||
const clearBtn = document.createElement("button");
|
||||
clearBtn.textContent = "clear highlight";
|
||||
clearBtn.style.cssText =
|
||||
"font:10px monospace;padding:2px 6px;border:1px solid #555;border-radius:3px;" +
|
||||
"background:#333;color:#ccc;cursor:pointer;";
|
||||
bar.appendChild(title); bar.appendChild(status); bar.appendChild(clearBtn);
|
||||
|
||||
// --- vtk.js iframe viewer ---
|
||||
const iframe = document.createElement("iframe");
|
||||
iframe.style.cssText = "width:100%;flex:1 1 0;min-height:0;border:none;background:#2a2a2a;";
|
||||
iframe.src = `/extensions/${EXTENSION_FOLDER}/viewer_vtk.html?v=` + Date.now();
|
||||
let iframeLoaded = false;
|
||||
iframe.addEventListener("load", () => { iframeLoaded = true; });
|
||||
|
||||
// --- scrollable statistics table ---
|
||||
const tableWrap = document.createElement("div");
|
||||
tableWrap.style.cssText =
|
||||
"flex:0 0 auto;max-height:260px;overflow:auto;background:#1a1a1a;border-top:1px solid #444;" +
|
||||
"font:11px monospace;color:#ccc;";
|
||||
const table = document.createElement("table");
|
||||
table.style.cssText = "width:100%;border-collapse:collapse;";
|
||||
tableWrap.appendChild(table);
|
||||
|
||||
container.appendChild(bar);
|
||||
container.appendChild(iframe);
|
||||
container.appendChild(tableWrap);
|
||||
|
||||
const widget = this.addDOMWidget("ultimate_inspection", "UMI", container, {
|
||||
getValue() { return ""; }, setValue() {},
|
||||
});
|
||||
widget.computeSize = () => [520, 720];
|
||||
this.setSize([520, 720]);
|
||||
|
||||
// --- helpers ---
|
||||
const loadMesh = (selectedField, focus) => {
|
||||
if (!node._umiFilename || !iframe.contentWindow) return;
|
||||
const filepath = `/view?filename=${encodeURIComponent(node._umiFilename)}&type=output&subfolder=`;
|
||||
iframe.contentWindow.postMessage({
|
||||
type: "LOAD_MESH",
|
||||
filepath,
|
||||
selectedField: selectedField || null,
|
||||
timestamp: Date.now(),
|
||||
}, "*");
|
||||
node._umiActiveField = selectedField || null;
|
||||
// for "worst" rows: zoom the camera onto that single face after it loads
|
||||
if (focus && focus.length === 3) {
|
||||
setTimeout(() => {
|
||||
iframe.contentWindow?.postMessage({
|
||||
type: "FOCUS_ON_POINT", point: focus, timestamp: Date.now(),
|
||||
}, "*");
|
||||
}, 500);
|
||||
}
|
||||
};
|
||||
|
||||
clearBtn.addEventListener("click", () => {
|
||||
loadMesh(null, null);
|
||||
table.querySelectorAll("tr.umi-active").forEach(tr => tr.classList.remove("umi-active"));
|
||||
});
|
||||
|
||||
const buildTable = (rows) => {
|
||||
table.innerHTML = "";
|
||||
let curGroup = null;
|
||||
rows.forEach((row) => {
|
||||
if (row.group !== curGroup) {
|
||||
curGroup = row.group;
|
||||
const gtr = document.createElement("tr");
|
||||
const gtd = document.createElement("td");
|
||||
gtd.colSpan = 2;
|
||||
gtd.textContent = curGroup;
|
||||
gtd.style.cssText =
|
||||
"padding:4px 8px 2px;color:#9df;font-weight:bold;border-top:1px solid #333;";
|
||||
gtr.appendChild(gtd); table.appendChild(gtr);
|
||||
}
|
||||
const tr = document.createElement("tr");
|
||||
const tdL = document.createElement("td");
|
||||
tdL.textContent = row.label;
|
||||
tdL.style.cssText = "padding:2px 8px;color:#999;white-space:nowrap;";
|
||||
const tdV = document.createElement("td");
|
||||
tdV.textContent = row.value;
|
||||
tdV.style.cssText = "padding:2px 8px;color:#eee;width:100%;";
|
||||
tr.appendChild(tdL); tr.appendChild(tdV);
|
||||
if (row.field) {
|
||||
tr.title = `highlight: ${row.field}`;
|
||||
tr.style.cursor = "pointer";
|
||||
tdL.style.color = "#cfc";
|
||||
tr.addEventListener("mouseenter", () => {
|
||||
if (!tr.classList.contains("umi-active")) tr.style.background = "#2c2c2c";
|
||||
});
|
||||
tr.addEventListener("mouseleave", () => {
|
||||
if (!tr.classList.contains("umi-active")) tr.style.background = "";
|
||||
});
|
||||
tr.addEventListener("click", () => {
|
||||
table.querySelectorAll("tr.umi-active").forEach(x => {
|
||||
x.classList.remove("umi-active"); x.style.background = "";
|
||||
});
|
||||
tr.classList.add("umi-active");
|
||||
tr.style.background = "#264";
|
||||
loadMesh(row.field, row.focus);
|
||||
});
|
||||
}
|
||||
table.appendChild(tr);
|
||||
});
|
||||
};
|
||||
|
||||
// --- on execute ---
|
||||
const onExecuted = this.onExecuted;
|
||||
this.onExecuted = function (message) {
|
||||
onExecuted?.apply(this, arguments);
|
||||
const rows = message?.report?.[0] || [];
|
||||
buildTable(rows);
|
||||
|
||||
const vc = message?.vertex_count?.[0] ?? 0;
|
||||
const fc = message?.face_count?.[0] ?? 0;
|
||||
const wt = message?.is_watertight?.[0];
|
||||
status.textContent = `V ${vc.toLocaleString?.() ?? vc} | F ${fc.toLocaleString?.() ?? fc} | ` +
|
||||
`watertight ${wt ? "yes" : "no"} — click a green row to highlight`;
|
||||
|
||||
const fn = message?.mesh_file?.[0];
|
||||
if (fn) {
|
||||
node._umiFilename = fn;
|
||||
const fire = () => loadMesh(null);
|
||||
if (iframeLoaded) fire(); else setTimeout(fire, 600);
|
||||
}
|
||||
};
|
||||
|
||||
return r;
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,296 @@
|
||||
/**
|
||||
* Analysis panel utilities for mesh analysis nodes
|
||||
* (Connected components, degenerate faces, open edges, self-intersections)
|
||||
*/
|
||||
|
||||
/**
|
||||
* Build table header HTML
|
||||
* @param {string[]} columns - Column headers
|
||||
* @returns {string} HTML string
|
||||
*/
|
||||
export function buildTableHeader(columns) {
|
||||
let html = '<tr style="color: #888; border-bottom: 1px solid #333;">';
|
||||
for (const col of columns) {
|
||||
const align = col.align || 'left';
|
||||
html += `<th style="text-align: ${align}; padding: 2px 4px;">${col.label}</th>`;
|
||||
}
|
||||
html += '</tr>';
|
||||
return html;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build table row HTML
|
||||
* @param {Object[]} cells - Cell data [{value, color, align}]
|
||||
* @returns {string} HTML string
|
||||
*/
|
||||
export function buildTableRow(cells) {
|
||||
let html = '<tr style="border-bottom: 1px solid #222;">';
|
||||
for (const cell of cells) {
|
||||
const color = cell.color || '#ccc';
|
||||
const align = cell.align || 'left';
|
||||
const value = cell.value;
|
||||
html += `<td style="text-align: ${align}; padding: 2px 4px; color: ${color};">${value}</td>`;
|
||||
}
|
||||
html += '</tr>';
|
||||
return html;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build "and N more" row
|
||||
* @param {number} remaining - Number of remaining items
|
||||
* @param {number} colSpan - Number of columns to span
|
||||
* @param {string} itemName - Name of items (e.g., "components", "faces")
|
||||
* @returns {string} HTML string
|
||||
*/
|
||||
export function buildMoreRow(remaining, colSpan, itemName = "items") {
|
||||
return `
|
||||
<tr><td colspan="${colSpan}" style="padding: 4px; color: #888; text-align: center;">
|
||||
... and ${remaining} more ${itemName}
|
||||
</td></tr>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build connected components HTML
|
||||
* @param {Object} meshData - Mesh component data
|
||||
* @param {number} maxDisplay - Max components to display (default: 20)
|
||||
* @returns {string} HTML string
|
||||
*/
|
||||
export function buildConnectedComponentsHTML(meshData, maxDisplay = 20) {
|
||||
const { mesh_name, num_components, total_faces, total_vertices, components } = meshData;
|
||||
|
||||
let html = '<div style="margin-bottom: 8px;">';
|
||||
|
||||
// Header
|
||||
html += `<div style="color: #fff; font-weight: bold; margin-bottom: 4px;">`;
|
||||
html += `${mesh_name}: <span style="color: #6cf;">${num_components}</span> component(s)`;
|
||||
html += `</div>`;
|
||||
|
||||
html += `<div style="color: #888; font-size: 9px; margin-bottom: 4px;">`;
|
||||
html += `Total: ${total_vertices.toLocaleString()} verts, ${total_faces.toLocaleString()} faces`;
|
||||
html += `</div>`;
|
||||
|
||||
// Table
|
||||
html += `<table style="width: 100%; border-collapse: collapse; font-size: 9px;">`;
|
||||
html += buildTableHeader([
|
||||
{ label: '#', align: 'left' },
|
||||
{ label: 'Faces', align: 'right' },
|
||||
{ label: 'Vertices', align: 'right' }
|
||||
]);
|
||||
|
||||
const displayComponents = components.slice(0, maxDisplay);
|
||||
const maxFaces = Math.max(...components.map(c => c.faces));
|
||||
|
||||
for (const comp of displayComponents) {
|
||||
const color = comp.faces === maxFaces ? '#6f6' : '#ccc';
|
||||
|
||||
html += buildTableRow([
|
||||
{ value: comp.id, color: '#888', align: 'left' },
|
||||
{ value: comp.faces.toLocaleString(), color: color, align: 'right' },
|
||||
{ value: comp.vertices.toLocaleString(), color: '#ccc', align: 'right' }
|
||||
]);
|
||||
|
||||
// Face indices for small components
|
||||
if (comp.face_indices && comp.face_indices.length > 0) {
|
||||
html += `<tr style="border-bottom: 1px solid #222;">`;
|
||||
html += `<td colspan="3" style="padding: 2px 4px 4px 16px; color: #888; font-size: 8px;">`;
|
||||
html += `faces: ${comp.face_indices.join(', ')}`;
|
||||
html += `</td></tr>`;
|
||||
}
|
||||
}
|
||||
|
||||
if (components.length > maxDisplay) {
|
||||
html += buildMoreRow(components.length - maxDisplay, 3, "components");
|
||||
}
|
||||
|
||||
html += '</table></div>';
|
||||
return html;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build self-intersections HTML
|
||||
* @param {Object} data - Self-intersection data
|
||||
* @param {number} maxDisplay - Max faces to display (default: 20)
|
||||
* @returns {string} HTML string
|
||||
*/
|
||||
export function buildSelfIntersectionsHTML(data, maxDisplay = 20) {
|
||||
const { mesh_name, num_intersecting_faces, num_intersection_pairs, total_faces, total_vertices, has_cgal, faces } = data;
|
||||
|
||||
let html = '<div style="margin-bottom: 8px;">';
|
||||
|
||||
const statusColor = num_intersecting_faces === 0 ? '#6f6' : '#f66';
|
||||
const statusText = num_intersecting_faces === 0 ? 'Clean' : `${num_intersecting_faces} intersecting face(s)`;
|
||||
|
||||
html += `<div style="color: #fff; font-weight: bold; margin-bottom: 4px;">`;
|
||||
html += `${mesh_name}: <span style="color: ${statusColor};">${statusText}</span>`;
|
||||
html += `</div>`;
|
||||
|
||||
if (!has_cgal) {
|
||||
html += `<div style="color: #f96; font-size: 9px; margin-bottom: 4px;">`;
|
||||
html += `CGAL not available - results may be incomplete`;
|
||||
html += `</div>`;
|
||||
}
|
||||
|
||||
if (num_intersecting_faces > 0) {
|
||||
html += `<div style="color: #888; font-size: 9px; margin-bottom: 4px;">`;
|
||||
html += `${num_intersection_pairs} intersection pair(s) detected`;
|
||||
html += `</div>`;
|
||||
|
||||
html += `<table style="width: 100%; border-collapse: collapse; font-size: 9px;">`;
|
||||
html += buildTableHeader([
|
||||
{ label: 'Face', align: 'left' },
|
||||
{ label: 'Vertices', align: 'left' }
|
||||
]);
|
||||
|
||||
const displayFaces = faces.slice(0, maxDisplay);
|
||||
for (const face of displayFaces) {
|
||||
html += buildTableRow([
|
||||
{ value: face.id, color: '#f66', align: 'left' },
|
||||
{ value: `[${face.vertices.join(', ')}]`, color: '#888', align: 'left' }
|
||||
]);
|
||||
}
|
||||
|
||||
if (faces.length > maxDisplay) {
|
||||
html += buildMoreRow(faces.length - maxDisplay, 2, "faces");
|
||||
}
|
||||
|
||||
html += '</table>';
|
||||
} else {
|
||||
html += `<div style="color: #888; font-size: 9px;">`;
|
||||
html += `Total: ${total_vertices.toLocaleString()} vertices, ${total_faces.toLocaleString()} faces`;
|
||||
html += `</div>`;
|
||||
}
|
||||
|
||||
html += '</div>';
|
||||
return html;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build degenerate faces HTML
|
||||
* @param {Object} data - Degenerate faces data
|
||||
* @param {number} maxDisplay - Max faces to display (default: 20)
|
||||
* @returns {string} HTML string
|
||||
*/
|
||||
export function buildDegenerateFacesHTML(data, maxDisplay = 20) {
|
||||
const { mesh_name, num_degenerate, total_faces, total_vertices, faces } = data;
|
||||
|
||||
let html = '<div style="margin-bottom: 8px;">';
|
||||
|
||||
const statusColor = num_degenerate === 0 ? '#6f6' : '#f66';
|
||||
const statusText = num_degenerate === 0 ? 'Clean' : `${num_degenerate} degenerate face(s)`;
|
||||
|
||||
html += `<div style="color: #fff; font-weight: bold; margin-bottom: 4px;">`;
|
||||
html += `${mesh_name}: <span style="color: ${statusColor};">${statusText}</span>`;
|
||||
html += `</div>`;
|
||||
|
||||
if (num_degenerate > 0 && faces && faces.length > 0) {
|
||||
html += `<table style="width: 100%; border-collapse: collapse; font-size: 9px;">`;
|
||||
html += buildTableHeader([
|
||||
{ label: 'Face', align: 'left' },
|
||||
{ label: 'Type', align: 'left' },
|
||||
{ label: 'Vertices', align: 'left' }
|
||||
]);
|
||||
|
||||
const displayFaces = faces.slice(0, maxDisplay);
|
||||
for (const face of displayFaces) {
|
||||
html += buildTableRow([
|
||||
{ value: face.id, color: '#f66', align: 'left' },
|
||||
{ value: face.type || 'degenerate', color: '#888', align: 'left' },
|
||||
{ value: `[${face.vertices.join(', ')}]`, color: '#888', align: 'left' }
|
||||
]);
|
||||
}
|
||||
|
||||
if (faces.length > maxDisplay) {
|
||||
html += buildMoreRow(faces.length - maxDisplay, 3, "faces");
|
||||
}
|
||||
|
||||
html += '</table>';
|
||||
} else {
|
||||
html += `<div style="color: #888; font-size: 9px;">`;
|
||||
html += `Total: ${total_vertices.toLocaleString()} vertices, ${total_faces.toLocaleString()} faces`;
|
||||
html += `</div>`;
|
||||
}
|
||||
|
||||
html += '</div>';
|
||||
return html;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build open edges HTML
|
||||
* @param {Object} data - Open edges data
|
||||
* @param {number} maxDisplay - Max edges to display (default: 20)
|
||||
* @returns {string} HTML string
|
||||
*/
|
||||
export function buildOpenEdgesHTML(data, maxDisplay = 20) {
|
||||
const { mesh_name, num_open_edges, total_faces, total_vertices, edges } = data;
|
||||
|
||||
let html = '<div style="margin-bottom: 8px;">';
|
||||
|
||||
const statusColor = num_open_edges === 0 ? '#6f6' : '#fc6';
|
||||
const statusText = num_open_edges === 0 ? 'Watertight' : `${num_open_edges} open edge(s)`;
|
||||
|
||||
html += `<div style="color: #fff; font-weight: bold; margin-bottom: 4px;">`;
|
||||
html += `${mesh_name}: <span style="color: ${statusColor};">${statusText}</span>`;
|
||||
html += `</div>`;
|
||||
|
||||
if (num_open_edges > 0 && edges && edges.length > 0) {
|
||||
html += `<table style="width: 100%; border-collapse: collapse; font-size: 9px;">`;
|
||||
html += buildTableHeader([
|
||||
{ label: 'Edge', align: 'left' },
|
||||
{ label: 'Vertices', align: 'left' }
|
||||
]);
|
||||
|
||||
const displayEdges = edges.slice(0, maxDisplay);
|
||||
for (let i = 0; i < displayEdges.length; i++) {
|
||||
const edge = displayEdges[i];
|
||||
html += buildTableRow([
|
||||
{ value: i, color: '#fc6', align: 'left' },
|
||||
{ value: `[${edge.vertices.join(', ')}]`, color: '#888', align: 'left' }
|
||||
]);
|
||||
}
|
||||
|
||||
if (edges.length > maxDisplay) {
|
||||
html += buildMoreRow(edges.length - maxDisplay, 2, "edges");
|
||||
}
|
||||
|
||||
html += '</table>';
|
||||
} else {
|
||||
html += `<div style="color: #888; font-size: 9px;">`;
|
||||
html += `Total: ${total_vertices.toLocaleString()} vertices, ${total_faces.toLocaleString()} faces`;
|
||||
html += `</div>`;
|
||||
}
|
||||
|
||||
html += '</div>';
|
||||
return html;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate dynamic height for analysis panel based on content
|
||||
* @param {number} numRows - Number of data rows
|
||||
* @param {number} maxRows - Maximum rows before truncation (default: 20)
|
||||
* @param {number} baseHeight - Base height for headers (default: 40)
|
||||
* @param {number} rowHeight - Height per row (default: 16)
|
||||
* @param {number} minHeight - Minimum panel height (default: 80)
|
||||
* @param {number} maxHeight - Maximum panel height (default: 250)
|
||||
* @returns {number} Panel height in pixels
|
||||
*/
|
||||
export function calculatePanelHeight(numRows, maxRows = 20, baseHeight = 40, rowHeight = 16, minHeight = 80, maxHeight = 250) {
|
||||
const displayRows = Math.min(numRows, maxRows) + 3; // +3 for header rows
|
||||
return Math.min(Math.max(minHeight, displayRows * rowHeight + baseHeight), maxHeight);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build text report HTML (for nodes that output plain text info/reports)
|
||||
* @param {string} text - Plain text report
|
||||
* @returns {string} HTML string with styling
|
||||
*/
|
||||
export function buildTextReportHTML(text) {
|
||||
// Convert newlines to <br>, escape HTML
|
||||
const escaped = text
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/\n/g, '<br>');
|
||||
|
||||
return `<div style="white-space: pre-wrap; font-size: 10px; line-height: 1.4;">${escaped}</div>`;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Auto-detect extension folder name from import.meta.url
|
||||
* Handles both ComfyUI-GeometryPack and comfyui-geometrypack (lowercase)
|
||||
*/
|
||||
|
||||
export const EXTENSION_FOLDER = (() => {
|
||||
const url = import.meta.url;
|
||||
const match = url.match(/\/extensions\/([^/]+)\//);
|
||||
return match ? match[1] : "ComfyUI-GeometryPack";
|
||||
})();
|
||||
|
||||
/**
|
||||
* Build a viewer URL with cache buster
|
||||
* @param {string} viewerName - Name of the viewer HTML file (e.g., "viewer_vtk")
|
||||
* @returns {string} Full URL with cache buster
|
||||
*/
|
||||
export function getViewerUrl(viewerName) {
|
||||
return `/extensions/${EXTENSION_FOLDER}/${viewerName}.html?v=` + Date.now();
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
/**
|
||||
* Formatting utilities for mesh metadata display
|
||||
*/
|
||||
|
||||
/**
|
||||
* Format bounds array as string
|
||||
* @param {number[]} boundsMin - [x, y, z] minimum bounds
|
||||
* @param {number[]} boundsMax - [x, y, z] maximum bounds
|
||||
* @param {number} decimals - Number of decimal places (default: 2)
|
||||
* @returns {string} Formatted bounds string or 'N/A'
|
||||
*/
|
||||
export function formatBounds(boundsMin, boundsMax, decimals = 2) {
|
||||
if (!boundsMin || !boundsMax || boundsMin.length !== 3 || boundsMax.length !== 3) {
|
||||
return 'N/A';
|
||||
}
|
||||
const min = boundsMin.map(v => v.toFixed(decimals)).join(', ');
|
||||
const max = boundsMax.map(v => v.toFixed(decimals)).join(', ');
|
||||
return `[${min}] to [${max}]`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format extents array as string
|
||||
* @param {number[]} extents - [x, y, z] extents
|
||||
* @param {number} decimals - Number of decimal places (default: 2)
|
||||
* @returns {string} Formatted extents string or 'N/A'
|
||||
*/
|
||||
export function formatExtents(extents, decimals = 2) {
|
||||
if (!extents || extents.length !== 3) {
|
||||
return 'N/A';
|
||||
}
|
||||
return extents.map(v => v.toFixed(decimals)).join(' x ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a {typeName: count} map as a sorted, human-readable string, e.g.
|
||||
* "3 Planes, 2 Cylinders, 1 BSpline".
|
||||
* @param {Object<string, number>} counts
|
||||
* @returns {string} Formatted string (empty string if counts is empty/missing)
|
||||
*/
|
||||
export function formatTypeCounts(counts) {
|
||||
if (!counts) return '';
|
||||
return Object.entries(counts)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.map(([name, count]) => `${count} ${name}${count === 1 ? '' : 's'}`)
|
||||
.join(', ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get color for mode label
|
||||
* @param {string} mode - Viewer mode (fields, texture, pbr)
|
||||
* @returns {string} CSS color
|
||||
*/
|
||||
export function getModeColor(mode) {
|
||||
switch (mode) {
|
||||
case "texture (PBR)":
|
||||
case "pbr":
|
||||
return '#fc6';
|
||||
case "texture":
|
||||
return '#c8c';
|
||||
default:
|
||||
return '#6cc';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get color for watertight status
|
||||
* @param {boolean} isWatertight
|
||||
* @returns {string} CSS color
|
||||
*/
|
||||
export function getWatertightColor(isWatertight) {
|
||||
return isWatertight ? '#6c6' : '#c66';
|
||||
}
|
||||
|
||||
/**
|
||||
* Build mesh info HTML for single mesh viewer
|
||||
* @param {Object} data - Mesh metadata
|
||||
* @returns {string} HTML string
|
||||
*/
|
||||
export function buildMeshInfoHTML(data) {
|
||||
const {
|
||||
mode = "fields",
|
||||
vertices = 'N/A',
|
||||
faces = 'N/A',
|
||||
boundsMin = [],
|
||||
boundsMax = [],
|
||||
extents = [],
|
||||
avgEdge,
|
||||
isWatertight,
|
||||
fieldNames = [],
|
||||
fieldRanges = [],
|
||||
hasTexture,
|
||||
hasVertexColors,
|
||||
visualKind
|
||||
} = data;
|
||||
|
||||
const modeLabel = mode.charAt(0).toUpperCase() + mode.slice(1);
|
||||
const modeColor = getModeColor(mode);
|
||||
const boundsStr = formatBounds(boundsMin, boundsMax);
|
||||
const extentsStr = formatExtents(extents);
|
||||
|
||||
let html = `
|
||||
<div style="display: grid; grid-template-columns: auto 1fr; gap: 2px 8px;">
|
||||
<span style="color: #888;">Mode:</span>
|
||||
<span style="color: ${modeColor}; font-weight: bold;">${modeLabel}</span>
|
||||
|
||||
<span style="color: #888;">Vertices:</span>
|
||||
<span>${typeof vertices === 'number' ? vertices.toLocaleString() : vertices}</span>
|
||||
|
||||
<span style="color: #888;">Faces:</span>
|
||||
<span>${typeof faces === 'number' ? faces.toLocaleString() : faces}</span>
|
||||
|
||||
<span style="color: #888;">Bounds:</span>
|
||||
<span style="font-size: 9px;">${boundsStr}</span>
|
||||
|
||||
<span style="color: #888;">Extents:</span>
|
||||
<span>${extentsStr}</span>
|
||||
`;
|
||||
|
||||
// Average edge length (mesh resolution)
|
||||
if (avgEdge !== undefined && avgEdge !== null) {
|
||||
const avgStr = (typeof avgEdge === 'number')
|
||||
? avgEdge.toLocaleString(undefined, { maximumSignificantDigits: 4 })
|
||||
: avgEdge;
|
||||
html += `
|
||||
<span style="color: #888;">Avg edge:</span>
|
||||
<span>${avgStr}</span>
|
||||
`;
|
||||
}
|
||||
|
||||
// Watertight status
|
||||
if (isWatertight !== undefined) {
|
||||
const watertightText = isWatertight ? 'Yes' : 'No';
|
||||
const watertightColor = getWatertightColor(isWatertight);
|
||||
html += `
|
||||
<span style="color: #888;">Watertight:</span>
|
||||
<span style="color: ${watertightColor};">${watertightText}</span>
|
||||
`;
|
||||
}
|
||||
|
||||
// Mode-specific info
|
||||
if (mode === "texture" || mode === "pbr") {
|
||||
if (visualKind !== undefined) {
|
||||
html += `
|
||||
<span style="color: #888;">Visual Kind:</span>
|
||||
<span>${visualKind}</span>
|
||||
`;
|
||||
}
|
||||
if (hasTexture !== undefined) {
|
||||
const texColor = hasTexture ? '#c8c' : '#888';
|
||||
html += `
|
||||
<span style="color: #888;">Textures:</span>
|
||||
<span style="color: ${texColor};">${hasTexture ? 'Yes' : 'No'}</span>
|
||||
`;
|
||||
}
|
||||
if (hasVertexColors !== undefined) {
|
||||
html += `
|
||||
<span style="color: #888;">Vertex Colors:</span>
|
||||
<span>${hasVertexColors ? 'Yes' : 'No'}</span>
|
||||
`;
|
||||
}
|
||||
} else {
|
||||
// Fields mode — annotate each field with its [min, max] value range
|
||||
if (fieldNames && fieldNames.length > 0) {
|
||||
const fmt = (n) => {
|
||||
if (n == null || !isFinite(n)) return '?';
|
||||
if (Number.isInteger(n) && Math.abs(n) < 1e7) return String(n);
|
||||
return Number(Number(n).toPrecision(4)).toString();
|
||||
};
|
||||
const ext = (i, which, v) =>
|
||||
`<span class="field-extreme" data-fidx="${i}" data-which="${which}" ` +
|
||||
`style="cursor:pointer;text-decoration:underline dotted;" ` +
|
||||
`title="focus camera on ${which}">${fmt(v)}</span>`;
|
||||
const fields = fieldNames.map((name, i) => {
|
||||
const r = fieldRanges && fieldRanges[i];
|
||||
return (r && r.length === 2)
|
||||
? `${name} [${ext(i, 'min', r[0])}, ${ext(i, 'max', r[1])}]`
|
||||
: name;
|
||||
}).join(', ');
|
||||
html += `
|
||||
<span style="color: #888;">Fields:</span>
|
||||
<span style="font-size: 9px; color: #6cc;">${fields}</span>
|
||||
`;
|
||||
} else {
|
||||
html += `
|
||||
<span style="color: #888;">Fields:</span>
|
||||
<span style="color: #888;">None</span>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
html += '</div>';
|
||||
return html;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build dual mesh info HTML for side-by-side comparison
|
||||
* @param {Object} data - Dual mesh metadata
|
||||
* @returns {string} HTML string
|
||||
*/
|
||||
export function buildDualMeshInfoHTML(data) {
|
||||
const {
|
||||
mode = "fields",
|
||||
layout = "side_by_side",
|
||||
mesh1 = {},
|
||||
mesh2 = {},
|
||||
commonFields = []
|
||||
} = data;
|
||||
|
||||
const modeLabel = mode.charAt(0).toUpperCase() + mode.slice(1);
|
||||
const modeColor = getModeColor(mode);
|
||||
|
||||
const extentsStr1 = formatExtents(mesh1.extents);
|
||||
const extentsStr2 = formatExtents(mesh2.extents);
|
||||
|
||||
const layoutSuffix = layout === "overlay" ? " (Overlay)" : "";
|
||||
|
||||
let html = `
|
||||
<div style="display: grid; grid-template-columns: auto 1fr 1fr; gap: 2px 12px;">
|
||||
<span style="color: #888;">Mode:</span>
|
||||
<span colspan="2" style="grid-column: 2 / 4; color: ${modeColor}; font-weight: bold;">${modeLabel}${layoutSuffix}</span>
|
||||
|
||||
<span style="color: #888;"></span>
|
||||
<span style="color: #999; font-weight: bold; border-bottom: 1px solid #333;">Mesh 1</span>
|
||||
<span style="color: #999; font-weight: bold; border-bottom: 1px solid #333;">Mesh 2</span>
|
||||
|
||||
<span style="color: #888;">Vertices:</span>
|
||||
<span>${(mesh1.vertices || 'N/A').toLocaleString()}</span>
|
||||
<span>${(mesh2.vertices || 'N/A').toLocaleString()}</span>
|
||||
|
||||
<span style="color: #888;">Faces:</span>
|
||||
<span>${(mesh1.faces || 'N/A').toLocaleString()}</span>
|
||||
<span>${(mesh2.faces || 'N/A').toLocaleString()}</span>
|
||||
`;
|
||||
|
||||
if (mesh1.faceTypes !== undefined && mesh2.faceTypes !== undefined) {
|
||||
html += `
|
||||
<span style="color: #888;">Face types:</span>
|
||||
<span style="font-size: 9px;">${mesh1.faceTypes || 'n/a'}</span>
|
||||
<span style="font-size: 9px;">${mesh2.faceTypes || 'n/a'}</span>
|
||||
`;
|
||||
}
|
||||
if (mesh1.edgeTypes !== undefined && mesh2.edgeTypes !== undefined) {
|
||||
html += `
|
||||
<span style="color: #888;">Edge types:</span>
|
||||
<span style="font-size: 9px;">${mesh1.edgeTypes || 'n/a'}</span>
|
||||
<span style="font-size: 9px;">${mesh2.edgeTypes || 'n/a'}</span>
|
||||
`;
|
||||
}
|
||||
|
||||
html += `
|
||||
<span style="color: #888;">Extents:</span>
|
||||
<span style="font-size: 9px;">${extentsStr1}</span>
|
||||
<span style="font-size: 9px;">${extentsStr2}</span>
|
||||
`;
|
||||
|
||||
// Watertight info
|
||||
if (mesh1.isWatertight !== undefined && mesh2.isWatertight !== undefined) {
|
||||
const color1 = getWatertightColor(mesh1.isWatertight);
|
||||
const color2 = getWatertightColor(mesh2.isWatertight);
|
||||
html += `
|
||||
<span style="color: #888;">Watertight:</span>
|
||||
<span style="color: ${color1};">${mesh1.isWatertight ? 'Yes' : 'No'}</span>
|
||||
<span style="color: ${color2};">${mesh2.isWatertight ? 'Yes' : 'No'}</span>
|
||||
`;
|
||||
}
|
||||
|
||||
// Mode-specific info
|
||||
if (mode === "texture") {
|
||||
if (mesh1.hasTexture !== undefined) {
|
||||
const tex1 = mesh1.hasTexture ? 'Yes' : 'No';
|
||||
const tex2 = mesh2.hasTexture ? 'Yes' : 'No';
|
||||
html += `
|
||||
<span style="color: #888;">Textures:</span>
|
||||
<span style="color: ${mesh1.hasTexture ? '#c8c' : '#888'};">${tex1}</span>
|
||||
<span style="color: ${mesh2.hasTexture ? '#c8c' : '#888'};">${tex2}</span>
|
||||
`;
|
||||
}
|
||||
} else {
|
||||
// Fields mode
|
||||
if (commonFields && commonFields.length > 0) {
|
||||
html += `
|
||||
<span style="color: #888;">Fields:</span>
|
||||
<span colspan="2" style="grid-column: 2 / 4; color: #9c9;">${commonFields.length} shared field(s)</span>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
html += '</div>';
|
||||
return html;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* ComfyUI 3D Viewer Utilities
|
||||
* Shared utilities for ComfyUI 3D viewer integration
|
||||
*/
|
||||
|
||||
export * from './extensionFolder.js';
|
||||
export * from './screenshot.js';
|
||||
export * from './uiComponents.js';
|
||||
export * from './formatting.js';
|
||||
export * from './analysisPanel.js';
|
||||
export * from './postMessage.js';
|
||||
@@ -0,0 +1,196 @@
|
||||
/**
|
||||
* PostMessage utilities for iframe communication
|
||||
*/
|
||||
|
||||
/**
|
||||
* Create a message sender function for an iframe
|
||||
* @param {HTMLIFrameElement} iframe - The iframe element
|
||||
* @param {string} logPrefix - Logging prefix (e.g., "[GeomPack VTK]")
|
||||
* @returns {Function} Message sending function
|
||||
*/
|
||||
export function createMessageSender(iframe, logPrefix = "[Viewer]") {
|
||||
return (data) => {
|
||||
if (iframe.contentWindow) {
|
||||
iframe.contentWindow.postMessage(data, "*");
|
||||
} else {
|
||||
console.error(`${logPrefix} Iframe contentWindow not available`);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a mesh load message
|
||||
* @param {string} filepath - URL path to the mesh file
|
||||
* @returns {Object} Message data object
|
||||
*/
|
||||
export function createLoadMeshMessage(filepath) {
|
||||
return {
|
||||
type: "LOAD_MESH",
|
||||
filepath: filepath,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a dual mesh load message
|
||||
* @param {Object} options - Message options
|
||||
* @returns {Object} Message data object
|
||||
*/
|
||||
export function createLoadDualMeshMessage(options) {
|
||||
const {
|
||||
layout,
|
||||
mesh1Filepath,
|
||||
mesh2Filepath,
|
||||
meshFilepath, // For overlay mode
|
||||
opacity1 = 1.0,
|
||||
opacity2 = 1.0,
|
||||
showEdges = false,
|
||||
cameraState = "",
|
||||
selectedField = "",
|
||||
selectedChannel = "magnitude",
|
||||
selectedColormap = "erdc_rainbow_bright",
|
||||
} = options;
|
||||
|
||||
return {
|
||||
type: 'LOAD_DUAL_MESH',
|
||||
layout: layout,
|
||||
mesh1Filepath,
|
||||
mesh2Filepath,
|
||||
meshFilepath,
|
||||
opacity1,
|
||||
opacity2,
|
||||
showEdges,
|
||||
cameraState,
|
||||
selectedField,
|
||||
selectedChannel,
|
||||
selectedColormap,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build ComfyUI view API URL for a file.
|
||||
* Handles absolute paths (/home/.../output/sub/file.glb),
|
||||
* relative paths (output/sub/file.glb), and bare filenames (file.glb).
|
||||
* @param {string} filename - The filename or path
|
||||
* @param {string} defaultType - Default file type (default: "output")
|
||||
* @param {string} defaultSubfolder - Default subfolder path (default: "")
|
||||
* @returns {string} API URL
|
||||
*/
|
||||
export function buildViewUrl(filename, defaultType = "output", defaultSubfolder = "") {
|
||||
const normalized = filename.replace(/\\/g, '/');
|
||||
const pathMatch = normalized.match(/(?:^|\/)(output|input|temp)\/(.+)$/);
|
||||
if (pathMatch) {
|
||||
const [, type, relPath] = pathMatch;
|
||||
const parts = relPath.split('/');
|
||||
const fname = parts.pop();
|
||||
const subfolder = parts.join('/');
|
||||
return `/view?filename=${encodeURIComponent(fname)}&type=${type}&subfolder=${encodeURIComponent(subfolder)}`;
|
||||
}
|
||||
// Bare filename fallback
|
||||
const basename = normalized.split('/').pop();
|
||||
return `/view?filename=${encodeURIComponent(basename)}&type=${defaultType}&subfolder=${defaultSubfolder}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an iframe viewer manager for handling viewer switching
|
||||
* @param {HTMLIFrameElement} iframe - The iframe element
|
||||
* @param {string} logPrefix - Logging prefix
|
||||
* @returns {Object} Viewer manager object
|
||||
*/
|
||||
export function createViewerManager(iframe, logPrefix = "[Viewer]") {
|
||||
let currentViewerType = null;
|
||||
let iframeLoaded = false;
|
||||
let pendingMessage = null;
|
||||
|
||||
// Track iframe load state
|
||||
iframe.addEventListener('load', () => {
|
||||
iframeLoaded = true;
|
||||
// Send pending message if any
|
||||
if (pendingMessage && iframe.contentWindow) {
|
||||
iframe.contentWindow.postMessage(pendingMessage, "*");
|
||||
pendingMessage = null;
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
/**
|
||||
* Get current viewer type
|
||||
* @returns {string|null}
|
||||
*/
|
||||
getCurrentType() {
|
||||
return currentViewerType;
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if iframe is loaded
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isLoaded() {
|
||||
return iframeLoaded;
|
||||
},
|
||||
|
||||
/**
|
||||
* Send a message to the iframe viewer
|
||||
* If iframe is not loaded yet, queue the message
|
||||
* @param {Object} data - Message data
|
||||
*/
|
||||
sendMessage(data) {
|
||||
if (iframeLoaded && iframe.contentWindow) {
|
||||
iframe.contentWindow.postMessage(data, "*");
|
||||
} else {
|
||||
pendingMessage = data;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Switch to a different viewer type if needed
|
||||
* @param {string} viewerType - New viewer type
|
||||
* @param {string} viewerUrl - URL of the new viewer
|
||||
* @param {Object} messageData - Message to send after viewer loads
|
||||
* @returns {boolean} True if viewer was switched, false if already on correct viewer
|
||||
*/
|
||||
switchViewer(viewerType, viewerUrl, messageData) {
|
||||
if (viewerType === currentViewerType) {
|
||||
// No switch needed, just send message
|
||||
this.sendMessage(messageData);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Viewer switch needed
|
||||
currentViewerType = viewerType;
|
||||
iframeLoaded = false;
|
||||
pendingMessage = messageData;
|
||||
|
||||
// Change iframe src to trigger reload
|
||||
iframe.src = viewerUrl + "?v=" + Date.now();
|
||||
return true;
|
||||
},
|
||||
|
||||
/**
|
||||
* Reset the manager state
|
||||
*/
|
||||
reset() {
|
||||
currentViewerType = null;
|
||||
iframeLoaded = false;
|
||||
pendingMessage = null;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an error message handler for iframe errors
|
||||
* @param {HTMLElement} infoPanel - The info panel to display errors
|
||||
* @param {string} logPrefix - Logging prefix
|
||||
* @returns {Function} Event handler function
|
||||
*/
|
||||
export function createErrorHandler(infoPanel, logPrefix = "[Viewer]") {
|
||||
return (event) => {
|
||||
if (event.data.type === 'MESH_ERROR' && event.data.error) {
|
||||
console.error(`${logPrefix} Error from viewer:`, event.data.error);
|
||||
if (infoPanel) {
|
||||
infoPanel.innerHTML = `<div style="color: #ff6b6b; padding: 8px;">Error: ${event.data.error}</div>`;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Screenshot capture and upload utility for ComfyUI viewers
|
||||
*/
|
||||
|
||||
/**
|
||||
* Convert base64 data URL to Blob
|
||||
* @param {string} dataUrl - Base64 data URL (e.g., "data:image/png;base64,...")
|
||||
* @returns {Blob} PNG image blob
|
||||
*/
|
||||
export function dataUrlToBlob(dataUrl) {
|
||||
const base64Data = dataUrl.split(',')[1];
|
||||
const byteString = atob(base64Data);
|
||||
const arrayBuffer = new ArrayBuffer(byteString.length);
|
||||
const uint8Array = new Uint8Array(arrayBuffer);
|
||||
|
||||
for (let i = 0; i < byteString.length; i++) {
|
||||
uint8Array[i] = byteString.charCodeAt(i);
|
||||
}
|
||||
|
||||
return new Blob([uint8Array], { type: 'image/png' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload screenshot to ComfyUI backend
|
||||
* @param {string} dataUrl - Base64 data URL of the screenshot
|
||||
* @param {string} prefix - Filename prefix (e.g., "vtk-screenshot")
|
||||
* @returns {Promise<{name: string, subfolder: string, type: string}>} Upload result
|
||||
*/
|
||||
export async function uploadScreenshot(dataUrl, prefix = 'screenshot') {
|
||||
const blob = dataUrlToBlob(dataUrl);
|
||||
|
||||
// Generate filename with timestamp
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
const filename = `${prefix}-${timestamp}.png`;
|
||||
|
||||
// Create FormData for upload
|
||||
const formData = new FormData();
|
||||
formData.append('image', blob, filename);
|
||||
formData.append('type', 'output'); // Save to output directory
|
||||
formData.append('subfolder', ''); // Root of output folder
|
||||
|
||||
// Upload to ComfyUI backend
|
||||
const response = await fetch('/upload/image', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Upload failed: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a message handler for screenshot events from iframe
|
||||
* @param {string} prefix - Filename prefix for screenshots
|
||||
* @param {Function} onError - Error callback
|
||||
* @returns {Function} Event handler function
|
||||
*/
|
||||
export function createScreenshotHandler(prefix = 'screenshot', onError = console.error) {
|
||||
return async (event) => {
|
||||
if (event.data.type === 'SCREENSHOT' && event.data.image) {
|
||||
try {
|
||||
await uploadScreenshot(event.data.image, prefix);
|
||||
} catch (error) {
|
||||
onError(`Error saving screenshot: ${error}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* UI component factory functions for ComfyUI 3D viewer widgets
|
||||
*/
|
||||
|
||||
/**
|
||||
* Create a container div for viewer + info panel
|
||||
* @param {Object} options - Container options
|
||||
* @param {string} options.backgroundColor - Background color (default: "#2a2a2a")
|
||||
* @returns {HTMLDivElement} Container element
|
||||
*/
|
||||
export function createContainer(options = {}) {
|
||||
const { backgroundColor = "#2a2a2a" } = options;
|
||||
|
||||
const container = document.createElement("div");
|
||||
container.style.width = "100%";
|
||||
container.style.height = "100%";
|
||||
container.style.display = "flex";
|
||||
container.style.flexDirection = "column";
|
||||
container.style.backgroundColor = backgroundColor;
|
||||
container.style.overflow = "hidden";
|
||||
|
||||
return container;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an iframe for embedding a viewer
|
||||
* @param {string} src - Initial iframe source URL
|
||||
* @param {Object} options - Iframe options
|
||||
* @param {string} options.minHeight - Minimum height (default: "0")
|
||||
* @param {string} options.backgroundColor - Background color (default: "#2a2a2a")
|
||||
* @returns {HTMLIFrameElement} Iframe element
|
||||
*/
|
||||
export function createIframe(src, options = {}) {
|
||||
const {
|
||||
minHeight = "0",
|
||||
backgroundColor = "#2a2a2a"
|
||||
} = options;
|
||||
|
||||
const iframe = document.createElement("iframe");
|
||||
iframe.style.width = "100%";
|
||||
iframe.style.flex = "1 1 0";
|
||||
iframe.style.minHeight = minHeight;
|
||||
iframe.style.border = "none";
|
||||
iframe.style.backgroundColor = backgroundColor;
|
||||
iframe.src = src;
|
||||
|
||||
return iframe;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an info panel for displaying mesh metadata
|
||||
* @param {string} placeholder - Initial placeholder text
|
||||
* @param {Object} options - Panel options
|
||||
* @returns {HTMLDivElement} Info panel element
|
||||
*/
|
||||
export function createInfoPanel(placeholder = "Info will appear here after execution", options = {}) {
|
||||
const {
|
||||
backgroundColor = "#1a1a1a",
|
||||
borderTop = "1px solid #444",
|
||||
padding = "6px 12px",
|
||||
fontSize = "10px",
|
||||
fontFamily = "monospace",
|
||||
color = "#ccc",
|
||||
lineHeight = "1.3"
|
||||
} = options;
|
||||
|
||||
const infoPanel = document.createElement("div");
|
||||
infoPanel.style.backgroundColor = backgroundColor;
|
||||
infoPanel.style.borderTop = borderTop;
|
||||
infoPanel.style.padding = padding;
|
||||
infoPanel.style.fontSize = fontSize;
|
||||
infoPanel.style.fontFamily = fontFamily;
|
||||
infoPanel.style.color = color;
|
||||
infoPanel.style.lineHeight = lineHeight;
|
||||
infoPanel.style.flexShrink = "0";
|
||||
infoPanel.style.overflow = "hidden";
|
||||
infoPanel.innerHTML = `<span style="color: #888;">${placeholder}</span>`;
|
||||
|
||||
return infoPanel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an analysis info panel (for connected components, self-intersections, etc.)
|
||||
* @param {string} placeholder - Initial placeholder text
|
||||
* @param {Object} options - Panel options
|
||||
* @returns {HTMLDivElement} Analysis panel element
|
||||
*/
|
||||
export function createAnalysisPanel(placeholder = "Run workflow to see results", options = {}) {
|
||||
const {
|
||||
backgroundColor = "#1a1a1a",
|
||||
padding = "8px",
|
||||
fontSize = "10px",
|
||||
fontFamily = "monospace",
|
||||
color = "#ccc",
|
||||
lineHeight = "1.4",
|
||||
maxHeight = "200px",
|
||||
borderRadius = "4px"
|
||||
} = options;
|
||||
|
||||
const panel = document.createElement("div");
|
||||
panel.style.backgroundColor = backgroundColor;
|
||||
panel.style.padding = padding;
|
||||
panel.style.fontSize = fontSize;
|
||||
panel.style.fontFamily = fontFamily;
|
||||
panel.style.color = color;
|
||||
panel.style.lineHeight = lineHeight;
|
||||
panel.style.maxHeight = maxHeight;
|
||||
panel.style.overflowY = "auto";
|
||||
panel.style.borderRadius = borderRadius;
|
||||
panel.innerHTML = `<span style="color: #666;">${placeholder}</span>`;
|
||||
|
||||
return panel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Show error in an info panel
|
||||
* @param {HTMLElement} panel - The info panel element
|
||||
* @param {string} message - Error message
|
||||
*/
|
||||
export function showPanelError(panel, message) {
|
||||
panel.innerHTML = `<div style="color: #ff6b6b; padding: 8px;">Error: ${message}</div>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create default widget options
|
||||
* @returns {Object} Widget options object
|
||||
*/
|
||||
export function createWidgetOptions() {
|
||||
return {
|
||||
getValue() { return ""; },
|
||||
setValue(v) { }
|
||||
};
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,276 @@
|
||||
/**
|
||||
* ActorManager - Unified actor/property management for VTK viewers
|
||||
*
|
||||
* Handles actor configuration, appearance settings, edge visibility,
|
||||
* and material properties. Works with both single actors and actor arrays.
|
||||
*/
|
||||
|
||||
import { hexToRgb } from '../utils/ColorUtils.js';
|
||||
|
||||
/**
|
||||
* Default actor appearance settings
|
||||
*/
|
||||
const DEFAULT_SETTINGS = {
|
||||
color: [1.0, 1.0, 1.0], // White
|
||||
edgeColor: [0.0, 0.0, 0.0], // Black edges
|
||||
ambient: 0.4,
|
||||
diffuse: 0.8,
|
||||
specular: 0.3,
|
||||
specularPower: 20,
|
||||
edgeVisibility: false,
|
||||
lineWidth: 1,
|
||||
pointSize: 5,
|
||||
representation: 2, // 0=Points, 1=Wireframe, 2=Surface
|
||||
opacity: 1.0
|
||||
};
|
||||
|
||||
/**
|
||||
* Representation mode constants
|
||||
*/
|
||||
export const Representation = {
|
||||
POINTS: 0,
|
||||
WIREFRAME: 1,
|
||||
SURFACE: 2
|
||||
};
|
||||
|
||||
export class ActorManager {
|
||||
/**
|
||||
* Create an ActorManager
|
||||
* @param {Object} renderWindow - VTK render window for triggering renders
|
||||
*/
|
||||
constructor(renderWindow) {
|
||||
this.renderWindow = renderWindow;
|
||||
this.actors = [];
|
||||
this.settings = { ...DEFAULT_SETTINGS };
|
||||
}
|
||||
|
||||
/**
|
||||
* Add an actor to be managed
|
||||
* @param {Object} actor - VTK actor instance
|
||||
*/
|
||||
addActor(actor) {
|
||||
this.actors.push(actor);
|
||||
this.configureActor(actor, this.settings);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove an actor from management
|
||||
* @param {Object} actor - VTK actor instance
|
||||
*/
|
||||
removeActor(actor) {
|
||||
const index = this.actors.indexOf(actor);
|
||||
if (index > -1) {
|
||||
this.actors.splice(index, 1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all managed actors
|
||||
*/
|
||||
clearActors() {
|
||||
this.actors = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Set actors to manage (replaces existing)
|
||||
* @param {Object[]} actors - Array of VTK actor instances
|
||||
*/
|
||||
setActors(actors) {
|
||||
this.actors = Array.isArray(actors) ? actors : [actors];
|
||||
this.actors.forEach(actor => this.configureActor(actor, this.settings));
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure a single actor with settings
|
||||
* @param {Object} actor - VTK actor instance
|
||||
* @param {Object} settings - Settings object
|
||||
*/
|
||||
configureActor(actor, settings = {}) {
|
||||
const merged = { ...this.settings, ...settings };
|
||||
const property = actor.getProperty();
|
||||
|
||||
if (!property) {
|
||||
console.warn('[ActorManager] Actor has no property object');
|
||||
return;
|
||||
}
|
||||
|
||||
// Color (supports hex string or RGB array)
|
||||
if (merged.color) {
|
||||
const rgb = typeof merged.color === 'string'
|
||||
? hexToRgb(merged.color)
|
||||
: merged.color;
|
||||
property.setColor(...rgb);
|
||||
}
|
||||
|
||||
// Edge color
|
||||
if (merged.edgeColor) {
|
||||
const rgb = typeof merged.edgeColor === 'string'
|
||||
? hexToRgb(merged.edgeColor)
|
||||
: merged.edgeColor;
|
||||
property.setEdgeColor(...rgb);
|
||||
}
|
||||
|
||||
// Lighting properties
|
||||
if (merged.ambient !== undefined) property.setAmbient(merged.ambient);
|
||||
if (merged.diffuse !== undefined) property.setDiffuse(merged.diffuse);
|
||||
if (merged.specular !== undefined) property.setSpecular(merged.specular);
|
||||
if (merged.specularPower !== undefined) property.setSpecularPower(merged.specularPower);
|
||||
|
||||
// Edge and line properties
|
||||
if (merged.edgeVisibility !== undefined) property.setEdgeVisibility(merged.edgeVisibility);
|
||||
if (merged.lineWidth !== undefined) property.setLineWidth(merged.lineWidth);
|
||||
|
||||
// Point properties
|
||||
if (merged.pointSize !== undefined) property.setPointSize(merged.pointSize);
|
||||
|
||||
// Representation mode
|
||||
if (merged.representation !== undefined) property.setRepresentation(merged.representation);
|
||||
|
||||
// Opacity
|
||||
if (merged.opacity !== undefined) property.setOpacity(merged.opacity);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply settings to all managed actors
|
||||
* @param {Object} settings - Settings object
|
||||
* @param {boolean} render - Whether to trigger a render (default: true)
|
||||
*/
|
||||
applySettings(settings, render = true) {
|
||||
this.settings = { ...this.settings, ...settings };
|
||||
this.actors.forEach(actor => this.configureActor(actor, this.settings));
|
||||
if (render) {
|
||||
this.renderWindow.render();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set edge visibility on all managed actors
|
||||
* @param {boolean} visible - Whether edges should be visible
|
||||
*/
|
||||
setEdgeVisibility(visible) {
|
||||
this.applySettings({ edgeVisibility: visible });
|
||||
}
|
||||
|
||||
/**
|
||||
* Set color on all managed actors
|
||||
* @param {string|number[]} color - Hex string or RGB array (0-1 range)
|
||||
*/
|
||||
setColor(color) {
|
||||
this.applySettings({ color });
|
||||
}
|
||||
|
||||
/**
|
||||
* Set opacity on all managed actors
|
||||
* @param {number} opacity - Opacity value (0-1)
|
||||
*/
|
||||
setOpacity(opacity) {
|
||||
this.applySettings({ opacity });
|
||||
}
|
||||
|
||||
/**
|
||||
* Set representation mode on all managed actors
|
||||
* @param {number} mode - 0=Points, 1=Wireframe, 2=Surface
|
||||
*/
|
||||
setRepresentation(mode) {
|
||||
this.applySettings({ representation: mode });
|
||||
}
|
||||
|
||||
/**
|
||||
* Set line width for edges/wireframe
|
||||
* @param {number} width - Line width in pixels
|
||||
*/
|
||||
setLineWidth(width) {
|
||||
this.applySettings({ lineWidth: width });
|
||||
}
|
||||
|
||||
/**
|
||||
* Set point size for point cloud rendering
|
||||
* @param {number} size - Point size in pixels
|
||||
*/
|
||||
setPointSize(size) {
|
||||
this.applySettings({ pointSize: size });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current settings
|
||||
* @returns {Object} Current settings object
|
||||
*/
|
||||
getSettings() {
|
||||
return { ...this.settings };
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply settings from a settings panel (handles hex colors and number parsing)
|
||||
* @param {Object} panelValues - Values from settings panel inputs
|
||||
*/
|
||||
applyPanelSettings(panelValues) {
|
||||
const settings = {};
|
||||
|
||||
if (panelValues.meshColor !== undefined) {
|
||||
settings.color = panelValues.meshColor;
|
||||
}
|
||||
if (panelValues.pointSize !== undefined) {
|
||||
settings.pointSize = parseFloat(panelValues.pointSize);
|
||||
}
|
||||
if (panelValues.edgeWidth !== undefined) {
|
||||
settings.lineWidth = parseFloat(panelValues.edgeWidth);
|
||||
}
|
||||
if (panelValues.representation !== undefined) {
|
||||
settings.representation = parseInt(panelValues.representation, 10);
|
||||
}
|
||||
if (panelValues.opacity !== undefined) {
|
||||
settings.opacity = parseFloat(panelValues.opacity);
|
||||
}
|
||||
|
||||
this.applySettings(settings);
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure for textured mode (disables scalar visibility, sets material)
|
||||
* @param {Object} actor - VTK actor instance
|
||||
*/
|
||||
configureForTexture(actor) {
|
||||
const property = actor.getProperty();
|
||||
const mapper = actor.getMapper();
|
||||
|
||||
if (property) {
|
||||
// Material settings for proper texture display
|
||||
if (typeof property.setMetallic === 'function') {
|
||||
property.setMetallic(0.0);
|
||||
}
|
||||
if (typeof property.setRoughness === 'function') {
|
||||
property.setRoughness(1.0);
|
||||
}
|
||||
if (typeof property.setInterpolation === 'function') {
|
||||
property.setInterpolation(2); // Phong shading
|
||||
}
|
||||
|
||||
// Remove metallic/roughness texture if present (common GLTF issue)
|
||||
if (typeof property.setRMTexture === 'function') {
|
||||
property.setRMTexture(null);
|
||||
}
|
||||
if (typeof property.setMetallicRoughnessTexture === 'function') {
|
||||
property.setMetallicRoughnessTexture(null);
|
||||
}
|
||||
}
|
||||
|
||||
if (mapper) {
|
||||
mapper.setScalarVisibility(false);
|
||||
mapper.modified();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable/disable scalar (field) visualization on mapper
|
||||
* @param {Object} mapper - VTK mapper instance
|
||||
* @param {boolean} enabled - Whether to show scalar colors
|
||||
*/
|
||||
setScalarVisibility(mapper, enabled) {
|
||||
if (mapper) {
|
||||
mapper.setScalarVisibility(enabled);
|
||||
mapper.modified();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default ActorManager;
|
||||
@@ -0,0 +1,657 @@
|
||||
/**
|
||||
* BaseViewer - Foundation class for VTK viewers
|
||||
*
|
||||
* Provides core lifecycle management, VTK setup, and feature composition.
|
||||
* Subclasses configure features and behavior through options.
|
||||
*/
|
||||
|
||||
import { CameraController } from './CameraController.js';
|
||||
import { ActorManager, Representation } from './ActorManager.js';
|
||||
import { SettingsPanel } from '../ui/SettingsPanel.js';
|
||||
import { ControlsBar, StandardControls } from '../ui/ControlsBar.js';
|
||||
import { loadMesh } from '../loaders/LoaderFactory.js';
|
||||
import { ScreenshotCapture } from '../features/ScreenshotCapture.js';
|
||||
import { AxisIndicator } from '../features/AxisIndicator.js';
|
||||
import { FieldVisualization } from '../features/FieldVisualization.js';
|
||||
import { TextureManager } from '../features/TextureManager.js';
|
||||
import { sendMeshLoaded, sendError, createMessageListener, extractFilename } from '../utils/MessageHandler.js';
|
||||
import { getCenter, getMaxDimension, isValidBounds } from '../utils/BoundsUtils.js';
|
||||
|
||||
/**
|
||||
* Default viewer configuration
|
||||
*/
|
||||
const DEFAULT_CONFIG = {
|
||||
// Appearance
|
||||
backgroundColor: [0.15, 0.15, 0.15],
|
||||
defaultMeshColor: [0.6, 0.8, 1.0],
|
||||
edgeColor: [0.2, 0.2, 0.2],
|
||||
edgeWidth: 1.0,
|
||||
showEdges: false,
|
||||
|
||||
// Camera
|
||||
cameraDistanceMultiplier: 2.5,
|
||||
parallelProjection: false,
|
||||
|
||||
// Lighting
|
||||
twoSidedLighting: false,
|
||||
|
||||
// Features (enabled/disabled)
|
||||
enableFields: true,
|
||||
enableTextures: false,
|
||||
enableScreenshot: true,
|
||||
enableAxisIndicator: false,
|
||||
enableSettings: true,
|
||||
enableControls: true,
|
||||
|
||||
// UI
|
||||
showLoadingOverlay: true,
|
||||
showInfoOverlay: true
|
||||
};
|
||||
|
||||
export class BaseViewer {
|
||||
/**
|
||||
* Create a BaseViewer
|
||||
* @param {HTMLElement} container - Container element for the viewer
|
||||
* @param {Object} config - Configuration options
|
||||
*/
|
||||
constructor(container, config = {}) {
|
||||
this.container = container;
|
||||
this.config = { ...DEFAULT_CONFIG, ...config };
|
||||
|
||||
// VTK objects (initialized in initialize())
|
||||
this.vtk = null;
|
||||
this.renderWindow = null;
|
||||
this.renderer = null;
|
||||
this.interactor = null;
|
||||
this.openGLRenderWindow = null;
|
||||
|
||||
// Feature modules
|
||||
this.cameraController = null;
|
||||
this.actorManager = null;
|
||||
this.settingsPanel = null;
|
||||
this.controlsBar = null;
|
||||
this.screenshotCapture = null;
|
||||
this.axisIndicator = null;
|
||||
this.fieldVisualization = null;
|
||||
this.textureManager = null;
|
||||
|
||||
// State
|
||||
this.actors = [];
|
||||
this.polyDatas = [];
|
||||
this.currentFilename = null;
|
||||
this.isInitialized = false;
|
||||
|
||||
// UI elements
|
||||
this.loadingOverlay = null;
|
||||
this.infoOverlay = null;
|
||||
|
||||
// Message listener cleanup
|
||||
this._messageCleanup = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the viewer
|
||||
* Must be called before loading meshes
|
||||
*/
|
||||
async initialize() {
|
||||
if (this.isInitialized) {
|
||||
console.warn('[BaseViewer] Already initialized');
|
||||
return;
|
||||
}
|
||||
|
||||
// Get VTK from global
|
||||
this.vtk = window.vtk;
|
||||
if (!this.vtk) {
|
||||
throw new Error('VTK.js not loaded. Include vtk-gltf.js before initializing viewer.');
|
||||
}
|
||||
|
||||
// Create VTK rendering pipeline
|
||||
this._createRenderingPipeline();
|
||||
|
||||
// Initialize feature modules
|
||||
this._initializeFeatures();
|
||||
|
||||
// Create UI elements
|
||||
this._createUI();
|
||||
|
||||
// Setup resize handling
|
||||
this._setupResizeHandler();
|
||||
|
||||
// Setup message listener for loading meshes
|
||||
this._setupMessageListener();
|
||||
|
||||
this.isInitialized = true;
|
||||
console.log('[BaseViewer] Initialized');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create VTK rendering pipeline
|
||||
* @private
|
||||
*/
|
||||
_createRenderingPipeline() {
|
||||
// Create render window
|
||||
this.renderWindow = this.vtk.Rendering.Core.vtkRenderWindow.newInstance();
|
||||
|
||||
// Create renderer
|
||||
this.renderer = this.vtk.Rendering.Core.vtkRenderer.newInstance();
|
||||
this.renderer.setBackground(...this.config.backgroundColor);
|
||||
this.renderWindow.addRenderer(this.renderer);
|
||||
|
||||
// Two-sided lighting
|
||||
if (this.config.twoSidedLighting) {
|
||||
this.renderer.setTwoSidedLighting(true);
|
||||
}
|
||||
|
||||
// Create OpenGL render window
|
||||
this.openGLRenderWindow = this.vtk.Rendering.OpenGL.vtkRenderWindow.newInstance();
|
||||
this.openGLRenderWindow.setContainer(this.container);
|
||||
this.renderWindow.addView(this.openGLRenderWindow);
|
||||
|
||||
// Create interactor
|
||||
this.interactor = this.vtk.Rendering.Core.vtkRenderWindowInteractor.newInstance();
|
||||
this.interactor.setView(this.openGLRenderWindow);
|
||||
this.interactor.initialize();
|
||||
this.interactor.bindEvents(this.container);
|
||||
|
||||
// Set interactor style
|
||||
const interactorStyle = this.vtk.Interaction.Style.vtkInteractorStyleTrackballCamera.newInstance();
|
||||
this.interactor.setInteractorStyle(interactorStyle);
|
||||
|
||||
// Size to container
|
||||
const { width, height } = this.container.getBoundingClientRect();
|
||||
this.openGLRenderWindow.setSize(width, height);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize feature modules
|
||||
* @private
|
||||
*/
|
||||
_initializeFeatures() {
|
||||
// Camera controller
|
||||
this.cameraController = new CameraController(this.renderer, {
|
||||
distanceMultiplier: this.config.cameraDistanceMultiplier,
|
||||
parallelProjection: this.config.parallelProjection
|
||||
});
|
||||
|
||||
// Actor manager
|
||||
this.actorManager = new ActorManager(this.vtk, {
|
||||
defaultColor: this.config.defaultMeshColor,
|
||||
edgeColor: this.config.edgeColor,
|
||||
edgeWidth: this.config.edgeWidth,
|
||||
showEdges: this.config.showEdges
|
||||
});
|
||||
|
||||
// Screenshot capture
|
||||
if (this.config.enableScreenshot) {
|
||||
this.screenshotCapture = new ScreenshotCapture(this.renderWindow);
|
||||
}
|
||||
|
||||
// Axis indicator
|
||||
if (this.config.enableAxisIndicator) {
|
||||
this.axisIndicator = new AxisIndicator(this.vtk, this.interactor, {
|
||||
enabled: true
|
||||
});
|
||||
}
|
||||
|
||||
// Field visualization
|
||||
if (this.config.enableFields) {
|
||||
this.fieldVisualization = new FieldVisualization(this.vtk, this.renderWindow);
|
||||
}
|
||||
|
||||
// Texture manager
|
||||
if (this.config.enableTextures) {
|
||||
this.textureManager = new TextureManager(this.vtk, this.renderWindow);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create UI elements
|
||||
* @private
|
||||
*/
|
||||
_createUI() {
|
||||
// Loading overlay
|
||||
if (this.config.showLoadingOverlay) {
|
||||
this.loadingOverlay = this._createLoadingOverlay();
|
||||
}
|
||||
|
||||
// Info overlay
|
||||
if (this.config.showInfoOverlay) {
|
||||
this.infoOverlay = this._createInfoOverlay();
|
||||
}
|
||||
|
||||
// Settings panel
|
||||
if (this.config.enableSettings && this.config.settingsConfig) {
|
||||
this.settingsPanel = new SettingsPanel(this.config.settingsConfig);
|
||||
this.settingsPanel.onApply((settings) => this._onSettingsApply(settings));
|
||||
}
|
||||
|
||||
// Controls bar
|
||||
if (this.config.enableControls && this.config.controlsConfig) {
|
||||
this.controlsBar = new ControlsBar(this.config.controlsConfig);
|
||||
this._setupControlsHandlers();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create loading overlay element
|
||||
* @private
|
||||
* @returns {HTMLElement}
|
||||
*/
|
||||
_createLoadingOverlay() {
|
||||
const overlay = document.createElement('div');
|
||||
overlay.id = 'loading';
|
||||
overlay.className = 'loading';
|
||||
overlay.innerHTML = `
|
||||
<div class="spinner"></div>
|
||||
<div class="loading-text">Loading mesh...</div>
|
||||
<div class="loading-progress" style="display:none;width:140px;height:6px;margin-top:8px;
|
||||
background:rgba(255,255,255,0.18);border-radius:3px;overflow:hidden;">
|
||||
<div class="loading-progress-bar" style="width:0%;height:100%;
|
||||
background:#00c8ff;transition:width 0.1s linear;"></div>
|
||||
</div>
|
||||
`;
|
||||
this.container.parentElement.appendChild(overlay);
|
||||
return overlay;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the download progress bar.
|
||||
* @param {number|null} fraction - 0..1, or null for indeterminate (hides the bar)
|
||||
*/
|
||||
setProgress(fraction) {
|
||||
if (!this.loadingOverlay) return;
|
||||
const wrap = this.loadingOverlay.querySelector('.loading-progress');
|
||||
const bar = this.loadingOverlay.querySelector('.loading-progress-bar');
|
||||
const textEl = this.loadingOverlay.querySelector('.loading-text');
|
||||
if (!wrap || !bar) return;
|
||||
if (fraction == null || isNaN(fraction)) {
|
||||
wrap.style.display = 'none'; // indeterminate -> spinner only
|
||||
return;
|
||||
}
|
||||
const pct = Math.max(0, Math.min(100, Math.round(fraction * 100)));
|
||||
wrap.style.display = 'block';
|
||||
bar.style.width = pct + '%';
|
||||
if (textEl) textEl.textContent = `Downloading mesh… ${pct}%`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create info overlay element
|
||||
* @private
|
||||
* @returns {HTMLElement}
|
||||
*/
|
||||
_createInfoOverlay() {
|
||||
const overlay = document.createElement('div');
|
||||
overlay.id = 'meshInfo';
|
||||
overlay.className = 'mesh-info';
|
||||
this.container.parentElement.appendChild(overlay);
|
||||
return overlay;
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup resize handler
|
||||
* @private
|
||||
*/
|
||||
_setupResizeHandler() {
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
const { width, height } = this.container.getBoundingClientRect();
|
||||
if (width > 0 && height > 0) {
|
||||
this.openGLRenderWindow.setSize(width, height);
|
||||
this.renderWindow.render();
|
||||
}
|
||||
});
|
||||
resizeObserver.observe(this.container);
|
||||
this._resizeObserver = resizeObserver;
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup message listener for postMessage API
|
||||
* @private
|
||||
*/
|
||||
_setupMessageListener() {
|
||||
this._messageCleanup = createMessageListener((data) => {
|
||||
if (data.type === 'loadMesh' && data.url) {
|
||||
this.loadMesh(data.url);
|
||||
} else if (data.type === 'screenshot') {
|
||||
this.captureScreenshot();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup controls bar handlers
|
||||
* @private
|
||||
*/
|
||||
_setupControlsHandlers() {
|
||||
if (!this.controlsBar) return;
|
||||
|
||||
// Camera view buttons
|
||||
this.controlsBar.onAction('camera-view', (direction) => {
|
||||
this.setCameraView(direction);
|
||||
});
|
||||
|
||||
// Screenshot button
|
||||
this.controlsBar.onAction('screenshot', () => {
|
||||
this.captureScreenshot();
|
||||
});
|
||||
|
||||
// Settings button
|
||||
this.controlsBar.onAction('settings', () => {
|
||||
if (this.settingsPanel) {
|
||||
this.settingsPanel.open();
|
||||
}
|
||||
});
|
||||
|
||||
// Edges toggle
|
||||
this.controlsBar.onAction('edges-toggle', (show) => {
|
||||
this.setEdgeVisibility(show);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle settings panel apply
|
||||
* @private
|
||||
* @param {Object} settings - Applied settings
|
||||
*/
|
||||
_onSettingsApply(settings) {
|
||||
// Apply to actor manager
|
||||
if (this.actorManager) {
|
||||
this.actorManager.applySettings(this.actors, settings);
|
||||
}
|
||||
|
||||
// Apply background color
|
||||
if (settings.backgroundColor) {
|
||||
this.renderer.setBackground(...settings.backgroundColor);
|
||||
}
|
||||
|
||||
this.render();
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a mesh from URL
|
||||
* @param {string} url - Mesh file URL
|
||||
* @returns {Promise<Object>} Load result with polyData, actors, fields
|
||||
*/
|
||||
async loadMesh(url) {
|
||||
this.showLoading(true, 'Downloading mesh…');
|
||||
this.setProgress(0);
|
||||
|
||||
try {
|
||||
// Clear existing actors
|
||||
this.clearScene();
|
||||
|
||||
// Detect format and load (with download progress)
|
||||
const result = await loadMesh(url, this.vtk, {
|
||||
renderer: this.renderer,
|
||||
onProgress: (frac) => this.setProgress(frac)
|
||||
});
|
||||
|
||||
// Store state
|
||||
this.actors = result.actors;
|
||||
this.polyDatas = result.polyData ? [result.polyData] : [];
|
||||
this.currentFilename = extractFilename(url);
|
||||
|
||||
// Add actors to renderer
|
||||
result.actors.forEach(actor => {
|
||||
this.renderer.addActor(actor);
|
||||
});
|
||||
|
||||
// Configure actors
|
||||
this._configureLoadedActors(result);
|
||||
|
||||
// Setup camera
|
||||
this._setupCameraForMesh();
|
||||
|
||||
// Update UI
|
||||
this._updateInfoOverlay(result);
|
||||
this.showLoading(false);
|
||||
|
||||
// Notify parent
|
||||
sendMeshLoaded({
|
||||
filename: this.currentFilename,
|
||||
format: result.format,
|
||||
actors: result.actors.length,
|
||||
hasTexture: result.hasTexture || false,
|
||||
hasVertexColors: result.hasVertexColors || false,
|
||||
fields: result.fields || []
|
||||
});
|
||||
|
||||
this.render();
|
||||
return result;
|
||||
|
||||
} catch (error) {
|
||||
console.error('[BaseViewer] Error loading mesh:', error);
|
||||
this.showLoading(false);
|
||||
sendError(error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure actors after loading
|
||||
* @private
|
||||
* @param {Object} result - Load result
|
||||
*/
|
||||
_configureLoadedActors(result) {
|
||||
// Apply texture fixes if enabled
|
||||
if (this.config.enableTextures && this.textureManager && result.hasTexture) {
|
||||
this.textureManager.applyTextureFixesMultiple(this.actors);
|
||||
this.textureManager.configureRenderer(this.renderer);
|
||||
} else if (result.hasVertexColors) {
|
||||
// Mesh has vertex colors - don't override with default color
|
||||
// Just configure edges if needed, colors are already set up by loader
|
||||
this.actors.forEach(actor => {
|
||||
const property = actor.getProperty();
|
||||
if (property) {
|
||||
if (this.config.showEdges) {
|
||||
property.setEdgeVisibility(true);
|
||||
if (this.config.edgeColor) {
|
||||
property.setEdgeColor(...this.config.edgeColor);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// Apply standard actor configuration
|
||||
this.actorManager.configureActors(this.actors, {
|
||||
color: this.config.defaultMeshColor,
|
||||
showEdges: this.config.showEdges,
|
||||
edgeColor: this.config.edgeColor
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup camera position for loaded mesh
|
||||
* @private
|
||||
*/
|
||||
_setupCameraForMesh() {
|
||||
if (this.actors.length === 0) return;
|
||||
|
||||
// Get combined bounds
|
||||
let bounds = this.actors[0].getBounds();
|
||||
for (let i = 1; i < this.actors.length; i++) {
|
||||
const actorBounds = this.actors[i].getBounds();
|
||||
bounds = [
|
||||
Math.min(bounds[0], actorBounds[0]),
|
||||
Math.max(bounds[1], actorBounds[1]),
|
||||
Math.min(bounds[2], actorBounds[2]),
|
||||
Math.max(bounds[3], actorBounds[3]),
|
||||
Math.min(bounds[4], actorBounds[4]),
|
||||
Math.max(bounds[5], actorBounds[5])
|
||||
];
|
||||
}
|
||||
|
||||
if (isValidBounds(bounds)) {
|
||||
this.cameraController.positionInitialCamera(bounds);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update info overlay with mesh information
|
||||
* @private
|
||||
* @param {Object} result - Load result
|
||||
*/
|
||||
_updateInfoOverlay(result) {
|
||||
if (!this.infoOverlay) return;
|
||||
|
||||
const lines = [`File: ${this.currentFilename}`];
|
||||
|
||||
if (result.polyData) {
|
||||
const numPoints = result.polyData.getNumberOfPoints();
|
||||
const numCells = result.polyData.getNumberOfCells();
|
||||
lines.push(`Points: ${numPoints.toLocaleString()}`);
|
||||
lines.push(`Cells: ${numCells.toLocaleString()}`);
|
||||
}
|
||||
|
||||
if (result.hasTexture) {
|
||||
lines.push('Textured: Yes');
|
||||
}
|
||||
|
||||
this.infoOverlay.innerHTML = lines.join('<br>');
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the scene
|
||||
*/
|
||||
clearScene() {
|
||||
this.actors.forEach(actor => {
|
||||
this.renderer.removeActor(actor);
|
||||
});
|
||||
this.actors = [];
|
||||
this.polyDatas = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Set camera view direction
|
||||
* @param {string} direction - '+X', '-X', '+Y', '-Y', '+Z', '-Z'
|
||||
*/
|
||||
setCameraView(direction) {
|
||||
if (this.actors.length === 0) return;
|
||||
|
||||
const bounds = this.actors[0].getBounds();
|
||||
this.cameraController.setCameraView(direction, bounds);
|
||||
this.render();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set edge visibility
|
||||
* @param {boolean} visible
|
||||
*/
|
||||
setEdgeVisibility(visible) {
|
||||
this.actors.forEach(actor => {
|
||||
this.actorManager.setEdgeVisibility(actor, visible);
|
||||
});
|
||||
this.render();
|
||||
}
|
||||
|
||||
/**
|
||||
* Capture screenshot
|
||||
* @returns {Promise<string>} Base64 image data
|
||||
*/
|
||||
async captureScreenshot() {
|
||||
if (this.screenshotCapture) {
|
||||
return this.screenshotCapture.captureAndSend();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show/hide loading overlay
|
||||
* @param {boolean} show
|
||||
* @param {string} text - Optional loading text
|
||||
*/
|
||||
showLoading(show, text = 'Loading mesh...') {
|
||||
if (this.loadingOverlay) {
|
||||
this.loadingOverlay.style.display = show ? 'flex' : 'none';
|
||||
const textEl = this.loadingOverlay.querySelector('.loading-text');
|
||||
if (textEl) {
|
||||
textEl.textContent = text;
|
||||
}
|
||||
if (!show) {
|
||||
// reset the progress bar for the next load
|
||||
const wrap = this.loadingOverlay.querySelector('.loading-progress');
|
||||
const bar = this.loadingOverlay.querySelector('.loading-progress-bar');
|
||||
if (wrap) wrap.style.display = 'none';
|
||||
if (bar) bar.style.width = '0%';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the scene
|
||||
*/
|
||||
render() {
|
||||
this.renderWindow.render();
|
||||
}
|
||||
|
||||
/**
|
||||
* Resize the viewer
|
||||
* @param {number} width
|
||||
* @param {number} height
|
||||
*/
|
||||
resize(width, height) {
|
||||
this.openGLRenderWindow.setSize(width, height);
|
||||
this.render();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current actors
|
||||
* @returns {Object[]}
|
||||
*/
|
||||
getActors() {
|
||||
return this.actors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get renderer
|
||||
* @returns {Object}
|
||||
*/
|
||||
getRenderer() {
|
||||
return this.renderer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup and destroy viewer
|
||||
*/
|
||||
destroy() {
|
||||
// Cleanup message listener
|
||||
if (this._messageCleanup) {
|
||||
this._messageCleanup();
|
||||
}
|
||||
|
||||
// Cleanup resize observer
|
||||
if (this._resizeObserver) {
|
||||
this._resizeObserver.disconnect();
|
||||
}
|
||||
|
||||
// Cleanup axis indicator
|
||||
if (this.axisIndicator) {
|
||||
this.axisIndicator.destroy();
|
||||
}
|
||||
|
||||
// Clear scene
|
||||
this.clearScene();
|
||||
|
||||
// Delete VTK objects
|
||||
if (this.interactor) {
|
||||
this.interactor.unbindEvents();
|
||||
}
|
||||
if (this.openGLRenderWindow) {
|
||||
this.openGLRenderWindow.delete();
|
||||
}
|
||||
if (this.renderWindow) {
|
||||
this.renderWindow.delete();
|
||||
}
|
||||
|
||||
// Remove UI elements
|
||||
if (this.loadingOverlay && this.loadingOverlay.parentElement) {
|
||||
this.loadingOverlay.parentElement.removeChild(this.loadingOverlay);
|
||||
}
|
||||
if (this.infoOverlay && this.infoOverlay.parentElement) {
|
||||
this.infoOverlay.parentElement.removeChild(this.infoOverlay);
|
||||
}
|
||||
|
||||
console.log('[BaseViewer] Destroyed');
|
||||
}
|
||||
}
|
||||
|
||||
export default BaseViewer;
|
||||
@@ -0,0 +1,271 @@
|
||||
/**
|
||||
* CameraController - Unified camera management for VTK viewers
|
||||
*
|
||||
* Handles camera positioning, view presets, and projection modes.
|
||||
* Configurable distance multiplier allows different viewers to use different defaults.
|
||||
*/
|
||||
|
||||
import { getCenter, getMaxDimension } from '../utils/BoundsUtils.js';
|
||||
|
||||
/**
|
||||
* View direction configurations
|
||||
* Maps direction strings to camera position offsets and viewUp vectors
|
||||
*/
|
||||
const VIEW_CONFIGS = {
|
||||
'+X': { offset: [1, 0, 0], viewUp: [0, 0, 1] },
|
||||
'-X': { offset: [-1, 0, 0], viewUp: [0, 0, 1] },
|
||||
'+Y': { offset: [0, 1, 0], viewUp: [0, 0, 1] },
|
||||
'-Y': { offset: [0, -1, 0], viewUp: [0, 0, 1] },
|
||||
'+Z': { offset: [0, 0, 1], viewUp: [0, 1, 0] },
|
||||
'-Z': { offset: [0, 0, -1], viewUp: [0, 1, 0] }
|
||||
};
|
||||
|
||||
/**
|
||||
* Default camera configuration
|
||||
*/
|
||||
const DEFAULT_CONFIG = {
|
||||
distanceMultiplier: 2.5, // Standard viewers use 2.5, textured uses 2.0
|
||||
initialViewOffset: [1, 1, 1], // Diagonal view for initial positioning
|
||||
initialViewUp: [0, 0, 1]
|
||||
};
|
||||
|
||||
export class CameraController {
|
||||
/**
|
||||
* Create a CameraController
|
||||
* @param {Object} renderer - VTK renderer instance
|
||||
* @param {Object} renderWindow - VTK render window instance
|
||||
* @param {Object} config - Configuration options
|
||||
* @param {number} config.distanceMultiplier - Camera distance = maxDim * multiplier (default: 2.5)
|
||||
*/
|
||||
constructor(renderer, renderWindow, config = {}) {
|
||||
this.renderer = renderer;
|
||||
this.renderWindow = renderWindow;
|
||||
this.config = { ...DEFAULT_CONFIG, ...config };
|
||||
this.currentBounds = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the active camera from the renderer
|
||||
* @returns {Object} VTK camera instance
|
||||
*/
|
||||
getCamera() {
|
||||
return this.renderer.getActiveCamera();
|
||||
}
|
||||
|
||||
/**
|
||||
* Store bounds for later use (e.g., view presets)
|
||||
* @param {number[]} bounds - VTK bounds [xmin, xmax, ymin, ymax, zmin, zmax]
|
||||
*/
|
||||
setBounds(bounds) {
|
||||
this.currentBounds = bounds;
|
||||
}
|
||||
|
||||
/**
|
||||
* Position camera for initial viewing of mesh
|
||||
* @param {number[]} bounds - VTK bounds [xmin, xmax, ymin, ymax, zmin, zmax]
|
||||
*/
|
||||
positionInitialCamera(bounds) {
|
||||
this.currentBounds = bounds;
|
||||
|
||||
const camera = this.getCamera();
|
||||
const center = getCenter(bounds);
|
||||
const maxDim = getMaxDimension(bounds);
|
||||
const distance = maxDim * this.config.distanceMultiplier;
|
||||
|
||||
const offset = this.config.initialViewOffset;
|
||||
const position = [
|
||||
center[0] + offset[0] * distance,
|
||||
center[1] + offset[1] * distance,
|
||||
center[2] + offset[2] * distance
|
||||
];
|
||||
|
||||
camera.setPosition(...position);
|
||||
camera.setFocalPoint(...center);
|
||||
camera.setViewUp(...this.config.initialViewUp);
|
||||
|
||||
this.renderer.resetCamera();
|
||||
this.renderWindow.render();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set camera to a specific axis-aligned view
|
||||
* @param {string} direction - One of '+X', '-X', '+Y', '-Y', '+Z', '-Z'
|
||||
* @param {number[]} bounds - Optional bounds, uses stored bounds if not provided
|
||||
*/
|
||||
setCameraView(direction, bounds = null) {
|
||||
const activeBounds = bounds || this.currentBounds;
|
||||
if (!activeBounds) {
|
||||
console.warn('[CameraController] No bounds available for setCameraView');
|
||||
return;
|
||||
}
|
||||
|
||||
const viewConfig = VIEW_CONFIGS[direction];
|
||||
if (!viewConfig) {
|
||||
console.warn(`[CameraController] Unknown view direction: ${direction}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const camera = this.getCamera();
|
||||
const center = getCenter(activeBounds);
|
||||
const maxDim = getMaxDimension(activeBounds);
|
||||
const distance = maxDim * this.config.distanceMultiplier;
|
||||
|
||||
const position = [
|
||||
center[0] + viewConfig.offset[0] * distance,
|
||||
center[1] + viewConfig.offset[1] * distance,
|
||||
center[2] + viewConfig.offset[2] * distance
|
||||
];
|
||||
|
||||
camera.setPosition(...position);
|
||||
camera.setFocalPoint(...center);
|
||||
camera.setViewUp(...viewConfig.viewUp);
|
||||
|
||||
this.renderer.resetCamera();
|
||||
this.renderWindow.render();
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset camera to fit the current bounds
|
||||
*/
|
||||
resetCamera() {
|
||||
if (this.currentBounds) {
|
||||
this.renderer.resetCamera(this.currentBounds);
|
||||
} else {
|
||||
this.renderer.resetCamera();
|
||||
}
|
||||
this.renderWindow.render();
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset camera clipping range (useful after mesh updates)
|
||||
*/
|
||||
resetClippingRange() {
|
||||
this.renderer.resetCameraClippingRange();
|
||||
this.renderWindow.render();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set parallel (orthographic) vs perspective projection
|
||||
* @param {boolean} enabled - True for parallel, false for perspective
|
||||
*/
|
||||
setParallelProjection(enabled) {
|
||||
const camera = this.getCamera();
|
||||
camera.setParallelProjection(enabled);
|
||||
this.renderWindow.render();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current parallel projection state
|
||||
* @returns {boolean} True if parallel projection is enabled
|
||||
*/
|
||||
getParallelProjection() {
|
||||
return this.getCamera().getParallelProjection();
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronize this camera with another camera
|
||||
* Useful for side-by-side viewers
|
||||
* @param {Object} sourceCamera - Camera to copy state from
|
||||
*/
|
||||
synchronizeFrom(sourceCamera) {
|
||||
const camera = this.getCamera();
|
||||
camera.setPosition(...sourceCamera.getPosition());
|
||||
camera.setFocalPoint(...sourceCamera.getFocalPoint());
|
||||
camera.setViewUp(...sourceCamera.getViewUp());
|
||||
camera.setParallelProjection(sourceCamera.getParallelProjection());
|
||||
camera.setClippingRange(...sourceCamera.getClippingRange());
|
||||
camera.setParallelScale(sourceCamera.getParallelScale());
|
||||
camera.setViewAngle(sourceCamera.getViewAngle());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get camera state for serialization
|
||||
* @returns {Object} Camera state object
|
||||
*/
|
||||
getState() {
|
||||
const camera = this.getCamera();
|
||||
return {
|
||||
position: camera.getPosition(),
|
||||
focalPoint: camera.getFocalPoint(),
|
||||
viewUp: camera.getViewUp(),
|
||||
parallelProjection: camera.getParallelProjection(),
|
||||
clippingRange: camera.getClippingRange(),
|
||||
parallelScale: camera.getParallelScale(),
|
||||
viewAngle: camera.getViewAngle()
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore camera state from serialized object
|
||||
* @param {Object} state - Camera state object from getState()
|
||||
*/
|
||||
setState(state) {
|
||||
const camera = this.getCamera();
|
||||
if (state.position) camera.setPosition(...state.position);
|
||||
if (state.focalPoint) camera.setFocalPoint(...state.focalPoint);
|
||||
if (state.viewUp) camera.setViewUp(...state.viewUp);
|
||||
if (state.parallelProjection !== undefined) camera.setParallelProjection(state.parallelProjection);
|
||||
if (state.clippingRange) camera.setClippingRange(...state.clippingRange);
|
||||
if (state.parallelScale !== undefined) camera.setParallelScale(state.parallelScale);
|
||||
if (state.viewAngle !== undefined) camera.setViewAngle(state.viewAngle);
|
||||
this.renderWindow.render();
|
||||
}
|
||||
|
||||
/**
|
||||
* Focus camera on a specific 3D point
|
||||
* Moves the camera to look at the point while maintaining relative view direction
|
||||
* @param {number[]} point - [x, y, z] coordinates to focus on
|
||||
* @param {number} viewRadius - Optional radius around point to view (defaults to 1/10 of mesh size)
|
||||
*/
|
||||
focusOnPoint(point, viewRadius = null) {
|
||||
const camera = this.getCamera();
|
||||
|
||||
// Get current camera direction (from focal point to camera position)
|
||||
const currentFocalPoint = camera.getFocalPoint();
|
||||
const currentPosition = camera.getPosition();
|
||||
const direction = [
|
||||
currentPosition[0] - currentFocalPoint[0],
|
||||
currentPosition[1] - currentFocalPoint[1],
|
||||
currentPosition[2] - currentFocalPoint[2]
|
||||
];
|
||||
|
||||
// Normalize direction
|
||||
const length = Math.sqrt(direction[0]**2 + direction[1]**2 + direction[2]**2);
|
||||
if (length > 0) {
|
||||
direction[0] /= length;
|
||||
direction[1] /= length;
|
||||
direction[2] /= length;
|
||||
} else {
|
||||
// Default to looking from +Z if no current direction
|
||||
direction[0] = 0;
|
||||
direction[1] = 0;
|
||||
direction[2] = 1;
|
||||
}
|
||||
|
||||
// Calculate view distance
|
||||
let distance;
|
||||
if (viewRadius !== null) {
|
||||
distance = viewRadius * this.config.distanceMultiplier;
|
||||
} else if (this.currentBounds) {
|
||||
const maxDim = getMaxDimension(this.currentBounds);
|
||||
distance = maxDim * 0.3; // Closer view for focused point
|
||||
} else {
|
||||
distance = 10; // Fallback
|
||||
}
|
||||
|
||||
// Position camera along the direction from the point
|
||||
const newPosition = [
|
||||
point[0] + direction[0] * distance,
|
||||
point[1] + direction[1] * distance,
|
||||
point[2] + direction[2] * distance
|
||||
];
|
||||
|
||||
camera.setFocalPoint(...point);
|
||||
camera.setPosition(...newPosition);
|
||||
|
||||
this.renderer.resetCameraClippingRange();
|
||||
this.renderWindow.render();
|
||||
}
|
||||
}
|
||||
|
||||
export default CameraController;
|
||||
@@ -0,0 +1,242 @@
|
||||
/**
|
||||
* AxisIndicator - Orientation widget for VTK viewers
|
||||
*
|
||||
* Creates and manages an annotated cube axis indicator that shows
|
||||
* the current camera orientation.
|
||||
*/
|
||||
|
||||
import { rgbToString } from '../utils/ColorUtils.js';
|
||||
|
||||
/**
|
||||
* Default axis colors
|
||||
*/
|
||||
const DEFAULT_COLORS = {
|
||||
x: '#ff5555', // Red for X axis
|
||||
y: '#55ff55', // Green for Y axis
|
||||
z: '#5555ff' // Blue for Z axis
|
||||
};
|
||||
|
||||
/**
|
||||
* Default widget configuration
|
||||
*/
|
||||
const DEFAULT_CONFIG = {
|
||||
enabled: false,
|
||||
corner: 'BOTTOM_LEFT', // BOTTOM_LEFT, BOTTOM_RIGHT, TOP_LEFT, TOP_RIGHT
|
||||
viewportSize: 0.15,
|
||||
minPixelSize: 100,
|
||||
maxPixelSize: 300,
|
||||
faceColor: rgbToString(0.5, 0.5, 0.5)
|
||||
};
|
||||
|
||||
export class AxisIndicator {
|
||||
/**
|
||||
* Create an AxisIndicator
|
||||
* @param {Object} vtk - VTK.js global object
|
||||
* @param {Object} interactor - VTK interactor (from renderWindow.getInteractor())
|
||||
* @param {Object} config - Configuration options
|
||||
*/
|
||||
constructor(vtk, interactor, config = {}) {
|
||||
this.vtk = vtk;
|
||||
this.interactor = interactor;
|
||||
this.config = { ...DEFAULT_CONFIG, ...config };
|
||||
|
||||
// VTK classes
|
||||
this.vtkOrientationMarkerWidget = vtk.Interaction.Widgets.vtkOrientationMarkerWidget;
|
||||
this.vtkAnnotatedCubeActor = vtk.Rendering.Core.vtkAnnotatedCubeActor;
|
||||
|
||||
// Widget state
|
||||
this.widget = null;
|
||||
this.cubeActor = null;
|
||||
this.enabled = this.config.enabled;
|
||||
|
||||
this._createWidget();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the orientation widget
|
||||
* @private
|
||||
*/
|
||||
_createWidget() {
|
||||
// Create annotated cube actor
|
||||
this.cubeActor = this.vtkAnnotatedCubeActor.newInstance();
|
||||
|
||||
// Set default style
|
||||
this.cubeActor.setDefaultStyle({
|
||||
text: '+X',
|
||||
fontStyle: 'bold',
|
||||
fontFamily: 'Arial',
|
||||
fontColor: 'white',
|
||||
fontSizeScale: (res) => res / 2,
|
||||
faceColor: this.config.faceColor,
|
||||
faceRotation: 0,
|
||||
edgeThickness: 0.1,
|
||||
edgeColor: 'white',
|
||||
resolution: 400,
|
||||
});
|
||||
|
||||
// Customize each face with axis colors
|
||||
this.cubeActor.setXPlusFaceProperty({ text: '+X', faceColor: DEFAULT_COLORS.x });
|
||||
this.cubeActor.setXMinusFaceProperty({ text: '-X', faceColor: DEFAULT_COLORS.x });
|
||||
this.cubeActor.setYPlusFaceProperty({ text: '+Y', faceColor: DEFAULT_COLORS.y });
|
||||
this.cubeActor.setYMinusFaceProperty({ text: '-Y', faceColor: DEFAULT_COLORS.y });
|
||||
this.cubeActor.setZPlusFaceProperty({ text: '+Z', faceColor: DEFAULT_COLORS.z });
|
||||
this.cubeActor.setZMinusFaceProperty({ text: '-Z', faceColor: DEFAULT_COLORS.z });
|
||||
|
||||
// Create orientation marker widget
|
||||
this.widget = this.vtkOrientationMarkerWidget.newInstance({
|
||||
actor: this.cubeActor,
|
||||
interactor: this.interactor,
|
||||
});
|
||||
|
||||
// Configure widget position and size
|
||||
this._applyCornerConfig();
|
||||
this.widget.setViewportSize(this.config.viewportSize);
|
||||
this.widget.setMinPixelSize(this.config.minPixelSize);
|
||||
this.widget.setMaxPixelSize(this.config.maxPixelSize);
|
||||
|
||||
// Set initial enabled state
|
||||
this.widget.setEnabled(this.enabled);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply corner configuration
|
||||
* @private
|
||||
*/
|
||||
_applyCornerConfig() {
|
||||
const Corners = this.vtkOrientationMarkerWidget.Corners;
|
||||
const cornerMap = {
|
||||
'BOTTOM_LEFT': Corners.BOTTOM_LEFT,
|
||||
'BOTTOM_RIGHT': Corners.BOTTOM_RIGHT,
|
||||
'TOP_LEFT': Corners.TOP_LEFT,
|
||||
'TOP_RIGHT': Corners.TOP_RIGHT
|
||||
};
|
||||
|
||||
const corner = cornerMap[this.config.corner] || Corners.BOTTOM_LEFT;
|
||||
this.widget.setViewportCorner(corner);
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable the axis indicator
|
||||
*/
|
||||
enable() {
|
||||
this.enabled = true;
|
||||
if (this.widget) {
|
||||
this.widget.setEnabled(true);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable the axis indicator
|
||||
*/
|
||||
disable() {
|
||||
this.enabled = false;
|
||||
if (this.widget) {
|
||||
this.widget.setEnabled(false);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle the axis indicator
|
||||
* @returns {boolean} New enabled state
|
||||
*/
|
||||
toggle() {
|
||||
if (this.enabled) {
|
||||
this.disable();
|
||||
} else {
|
||||
this.enable();
|
||||
}
|
||||
return this.enabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set enabled state
|
||||
* @param {boolean} enabled - Whether to enable the indicator
|
||||
*/
|
||||
setEnabled(enabled) {
|
||||
if (enabled) {
|
||||
this.enable();
|
||||
} else {
|
||||
this.disable();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if enabled
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isEnabled() {
|
||||
return this.enabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the corner position
|
||||
* @param {string} corner - 'BOTTOM_LEFT', 'BOTTOM_RIGHT', 'TOP_LEFT', 'TOP_RIGHT'
|
||||
*/
|
||||
setCorner(corner) {
|
||||
this.config.corner = corner;
|
||||
this._applyCornerConfig();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set viewport size (0-1 range)
|
||||
* @param {number} size - Viewport size as fraction
|
||||
*/
|
||||
setViewportSize(size) {
|
||||
this.config.viewportSize = size;
|
||||
if (this.widget) {
|
||||
this.widget.setViewportSize(size);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set axis colors
|
||||
* @param {Object} colors - Color object with x, y, z properties (hex strings)
|
||||
*/
|
||||
setAxisColors(colors) {
|
||||
if (colors.x) {
|
||||
this.cubeActor.setXPlusFaceProperty({ faceColor: colors.x });
|
||||
this.cubeActor.setXMinusFaceProperty({ faceColor: colors.x });
|
||||
}
|
||||
if (colors.y) {
|
||||
this.cubeActor.setYPlusFaceProperty({ faceColor: colors.y });
|
||||
this.cubeActor.setYMinusFaceProperty({ faceColor: colors.y });
|
||||
}
|
||||
if (colors.z) {
|
||||
this.cubeActor.setZPlusFaceProperty({ faceColor: colors.z });
|
||||
this.cubeActor.setZMinusFaceProperty({ faceColor: colors.z });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the underlying VTK widget
|
||||
* @returns {Object} VTK orientation marker widget
|
||||
*/
|
||||
getWidget() {
|
||||
return this.widget;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the cube actor
|
||||
* @returns {Object} VTK annotated cube actor
|
||||
*/
|
||||
getCubeActor() {
|
||||
return this.cubeActor;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup resources
|
||||
*/
|
||||
destroy() {
|
||||
if (this.widget) {
|
||||
this.widget.setEnabled(false);
|
||||
this.widget.delete();
|
||||
this.widget = null;
|
||||
}
|
||||
if (this.cubeActor) {
|
||||
this.cubeActor.delete();
|
||||
this.cubeActor = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default AxisIndicator;
|
||||
@@ -0,0 +1,352 @@
|
||||
/**
|
||||
* FieldVisualization - Scalar field coloring for VTK viewers
|
||||
*
|
||||
* Handles scalar field visualization including:
|
||||
* - Field detection (point/cell data)
|
||||
* - Colormap application
|
||||
* - Range calculation (auto/manual, single/synchronized)
|
||||
*/
|
||||
|
||||
/**
|
||||
* Popular colormaps to show at top of selector
|
||||
*/
|
||||
const POPULAR_COLORMAPS = [
|
||||
'erdc_rainbow_bright',
|
||||
'Cool to Warm',
|
||||
'Viridis (matplotlib)',
|
||||
'Plasma (matplotlib)',
|
||||
'Inferno (matplotlib)',
|
||||
'Magma (matplotlib)',
|
||||
'jet',
|
||||
'rainbow'
|
||||
];
|
||||
|
||||
export class FieldVisualization {
|
||||
/**
|
||||
* Create a FieldVisualization instance
|
||||
* @param {Object} vtk - VTK.js global object
|
||||
* @param {Object} renderWindow - VTK render window for triggering renders
|
||||
*/
|
||||
constructor(vtk, renderWindow) {
|
||||
this.vtk = vtk;
|
||||
this.renderWindow = renderWindow;
|
||||
|
||||
// VTK classes
|
||||
this.vtkColorTransferFunction = vtk.Rendering.Core.vtkColorTransferFunction;
|
||||
this.vtkColorMaps = vtk.Rendering.Core.vtkColorTransferFunction.vtkColorMaps;
|
||||
|
||||
// State
|
||||
this.currentField = null;
|
||||
this.currentColormap = 'erdc_rainbow_bright';
|
||||
this.autoRange = true;
|
||||
this.manualRange = [0, 1];
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract field information from polydata
|
||||
* @param {Object} polyData - VTK polydata object
|
||||
* @returns {Object} Field information with pointFields and cellFields arrays
|
||||
*/
|
||||
extractFields(polyData) {
|
||||
if (!polyData) {
|
||||
return { pointFields: [], cellFields: [], allFields: [] };
|
||||
}
|
||||
|
||||
// Fields to hide from dropdown (VTK internal arrays that aren't useful to visualize)
|
||||
const HIDDEN_FIELDS = ['Attribute', 'vtkOriginalPointIds', 'vtkOriginalCellIds'];
|
||||
|
||||
const pointData = polyData.getPointData();
|
||||
const cellData = polyData.getCellData();
|
||||
|
||||
const pointFields = [];
|
||||
const cellFields = [];
|
||||
|
||||
// Extract point (vertex) data arrays
|
||||
for (let i = 0; i < pointData.getNumberOfArrays(); i++) {
|
||||
const array = pointData.getArray(i);
|
||||
const name = array.getName();
|
||||
// Skip hidden/internal fields
|
||||
if (HIDDEN_FIELDS.includes(name)) continue;
|
||||
const range = array.getRange();
|
||||
pointFields.push({
|
||||
name,
|
||||
displayName: `${name} (vertex)`,
|
||||
type: 'point',
|
||||
fullName: `point:${name}`,
|
||||
numComponents: array.getNumberOfComponents(),
|
||||
range: [range[0], range[1]]
|
||||
});
|
||||
}
|
||||
|
||||
// Extract cell (face) data arrays
|
||||
for (let i = 0; i < cellData.getNumberOfArrays(); i++) {
|
||||
const array = cellData.getArray(i);
|
||||
const name = array.getName();
|
||||
// Skip hidden/internal fields
|
||||
if (HIDDEN_FIELDS.includes(name)) continue;
|
||||
const range = array.getRange();
|
||||
cellFields.push({
|
||||
name,
|
||||
displayName: `${name} (face)`,
|
||||
type: 'cell',
|
||||
fullName: `cell:${name}`,
|
||||
numComponents: array.getNumberOfComponents(),
|
||||
range: [range[0], range[1]]
|
||||
});
|
||||
}
|
||||
|
||||
const allFields = [...pointFields, ...cellFields];
|
||||
|
||||
return {
|
||||
pointFields,
|
||||
cellFields,
|
||||
allFields,
|
||||
hasFields: allFields.length > 0
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a scalar field to a mapper
|
||||
* @param {Object} mapper - VTK mapper
|
||||
* @param {Object} polyData - VTK polydata
|
||||
* @param {string} fieldName - Field name (e.g., "point:temperature" or "cell:pressure")
|
||||
* @param {Object} options - Options
|
||||
* @param {number[]} options.range - Manual range [min, max] (overrides auto)
|
||||
* @param {string} options.colormap - Colormap name
|
||||
* @param {boolean} options.render - Whether to trigger render (default: true)
|
||||
*/
|
||||
applyField(mapper, polyData, fieldName, options = {}) {
|
||||
const {
|
||||
range = null,
|
||||
colormap = this.currentColormap,
|
||||
render = true
|
||||
} = options;
|
||||
|
||||
if (!fieldName || fieldName === 'none' || !polyData) {
|
||||
this.disableScalarVisualization(mapper, render);
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse field name
|
||||
const isCellData = fieldName.startsWith('cell:');
|
||||
const actualFieldName = isCellData
|
||||
? fieldName.substring(5)
|
||||
: fieldName.startsWith('point:')
|
||||
? fieldName.substring(6)
|
||||
: fieldName;
|
||||
|
||||
// Get data array
|
||||
const data = isCellData ? polyData.getCellData() : polyData.getPointData();
|
||||
const scalarArray = data.getArrayByName(actualFieldName);
|
||||
|
||||
if (!scalarArray) {
|
||||
console.warn(`[FieldVisualization] Field "${actualFieldName}" not found`);
|
||||
this.disableScalarVisualization(mapper, render);
|
||||
return;
|
||||
}
|
||||
|
||||
// Get range
|
||||
const dataRange = scalarArray.getRange();
|
||||
const [minVal, maxVal] = range || (this.autoRange ? dataRange : this.manualRange);
|
||||
|
||||
// Check for degenerate range
|
||||
const epsilon = 1e-10;
|
||||
if (Math.abs(maxVal - minVal) < epsilon && !range) {
|
||||
console.warn(`[FieldVisualization] Field "${actualFieldName}" has zero-width range`);
|
||||
this.disableScalarVisualization(mapper, render);
|
||||
return;
|
||||
}
|
||||
|
||||
// Set active scalars
|
||||
data.setActiveScalars(actualFieldName);
|
||||
|
||||
// Configure mapper
|
||||
mapper.setScalarVisibility(true);
|
||||
if (isCellData) {
|
||||
mapper.setScalarModeToUseCellData();
|
||||
} else {
|
||||
mapper.setScalarModeToUsePointData();
|
||||
}
|
||||
mapper.setScalarRange(minVal, maxVal);
|
||||
|
||||
// Apply colormap
|
||||
this.applyColormap(mapper, colormap, minVal, maxVal);
|
||||
|
||||
// Update state
|
||||
this.currentField = fieldName;
|
||||
this.currentColormap = colormap;
|
||||
|
||||
// Update mapper to apply changes
|
||||
mapper.update();
|
||||
|
||||
if (render) {
|
||||
this.renderWindow.render();
|
||||
}
|
||||
|
||||
console.log(`[FieldVisualization] Applied field "${actualFieldName}" with range [${minVal.toFixed(3)}, ${maxVal.toFixed(3)}]`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a colormap to a mapper
|
||||
* @param {Object} mapper - VTK mapper
|
||||
* @param {string} colormapName - Colormap preset name
|
||||
* @param {number} minVal - Range minimum
|
||||
* @param {number} maxVal - Range maximum
|
||||
*/
|
||||
applyColormap(mapper, colormapName, minVal, maxVal) {
|
||||
try {
|
||||
const lookupTable = this.vtkColorTransferFunction.newInstance();
|
||||
const preset = this.vtkColorMaps.getPresetByName(colormapName);
|
||||
|
||||
if (preset) {
|
||||
lookupTable.applyColorMap(preset);
|
||||
lookupTable.setMappingRange(minVal, maxVal);
|
||||
lookupTable.updateRange();
|
||||
mapper.setLookupTable(lookupTable);
|
||||
} else {
|
||||
console.warn(`[FieldVisualization] Colormap "${colormapName}" not found`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[FieldVisualization] Error applying colormap:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable scalar visualization on a mapper
|
||||
* @param {Object} mapper - VTK mapper
|
||||
* @param {boolean} render - Whether to trigger render
|
||||
*/
|
||||
disableScalarVisualization(mapper, render = true) {
|
||||
if (mapper) {
|
||||
mapper.setScalarVisibility(false);
|
||||
}
|
||||
this.currentField = null;
|
||||
if (render) {
|
||||
this.renderWindow.render();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available colormap presets
|
||||
* @param {boolean} popularFirst - Put popular colormaps at top (default: true)
|
||||
* @returns {Array} Array of {value, label, isPopular} objects
|
||||
*/
|
||||
getColormapOptions(popularFirst = true) {
|
||||
const allPresets = this.vtkColorMaps.rgbPresetNames || [];
|
||||
const options = [];
|
||||
|
||||
if (popularFirst) {
|
||||
// Add popular colormaps first
|
||||
POPULAR_COLORMAPS.forEach(name => {
|
||||
if (allPresets.includes(name)) {
|
||||
options.push({ value: name, label: name, isPopular: true });
|
||||
}
|
||||
});
|
||||
|
||||
// Add separator
|
||||
options.push({ value: '', label: '----------', disabled: true });
|
||||
|
||||
// Add remaining colormaps
|
||||
allPresets
|
||||
.filter(name => !POPULAR_COLORMAPS.includes(name))
|
||||
.sort()
|
||||
.forEach(name => {
|
||||
options.push({ value: name, label: name, isPopular: false });
|
||||
});
|
||||
} else {
|
||||
allPresets.sort().forEach(name => {
|
||||
options.push({ value: name, label: name, isPopular: POPULAR_COLORMAPS.includes(name) });
|
||||
});
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set auto range mode
|
||||
* @param {boolean} auto - Whether to use auto range
|
||||
*/
|
||||
setAutoRange(auto) {
|
||||
this.autoRange = auto;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set manual range
|
||||
* @param {number} min - Range minimum
|
||||
* @param {number} max - Range maximum
|
||||
*/
|
||||
setManualRange(min, max) {
|
||||
this.manualRange = [min, max];
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate synchronized range for multiple polydatas
|
||||
* @param {Object[]} polydatas - Array of VTK polydata objects
|
||||
* @param {string} fieldName - Field name
|
||||
* @returns {number[]} Combined range [min, max]
|
||||
*/
|
||||
calculateSynchronizedRange(polydatas, fieldName) {
|
||||
let globalMin = Infinity;
|
||||
let globalMax = -Infinity;
|
||||
|
||||
const isCellData = fieldName.startsWith('cell:');
|
||||
const actualFieldName = isCellData
|
||||
? fieldName.substring(5)
|
||||
: fieldName.startsWith('point:')
|
||||
? fieldName.substring(6)
|
||||
: fieldName;
|
||||
|
||||
polydatas.forEach(polyData => {
|
||||
if (!polyData) return;
|
||||
|
||||
const data = isCellData ? polyData.getCellData() : polyData.getPointData();
|
||||
const scalarArray = data?.getArrayByName(actualFieldName);
|
||||
|
||||
if (scalarArray) {
|
||||
const range = scalarArray.getRange();
|
||||
globalMin = Math.min(globalMin, range[0]);
|
||||
globalMax = Math.max(globalMax, range[1]);
|
||||
}
|
||||
});
|
||||
|
||||
if (globalMin === Infinity || globalMax === -Infinity) {
|
||||
return [0, 1]; // Default range
|
||||
}
|
||||
|
||||
return [globalMin, globalMax];
|
||||
}
|
||||
|
||||
/**
|
||||
* Find common fields between multiple polydatas
|
||||
* @param {Object[]} polydatas - Array of VTK polydata objects
|
||||
* @returns {Object} Object with commonPointFields, commonCellFields, commonAllFields
|
||||
*/
|
||||
findCommonFields(polydatas) {
|
||||
if (!polydatas || polydatas.length === 0) {
|
||||
return { commonPointFields: [], commonCellFields: [], commonAllFields: [] };
|
||||
}
|
||||
|
||||
// Get fields from first polydata
|
||||
const firstFields = this.extractFields(polydatas[0]);
|
||||
let commonPointFields = firstFields.pointFields.map(f => f.name);
|
||||
let commonCellFields = firstFields.cellFields.map(f => f.name);
|
||||
|
||||
// Intersect with remaining polydatas
|
||||
for (let i = 1; i < polydatas.length; i++) {
|
||||
const fields = this.extractFields(polydatas[i]);
|
||||
const pointNames = fields.pointFields.map(f => f.name);
|
||||
const cellNames = fields.cellFields.map(f => f.name);
|
||||
|
||||
commonPointFields = commonPointFields.filter(n => pointNames.includes(n));
|
||||
commonCellFields = commonCellFields.filter(n => cellNames.includes(n));
|
||||
}
|
||||
|
||||
return {
|
||||
commonPointFields,
|
||||
commonCellFields,
|
||||
commonAllFields: [...commonPointFields, ...commonCellFields]
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default FieldVisualization;
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* ScreenshotCapture - Screenshot functionality for VTK viewers
|
||||
*
|
||||
* Handles capturing screenshots from VTK render windows and
|
||||
* sending them via postMessage or triggering downloads.
|
||||
*/
|
||||
|
||||
import { sendScreenshot } from '../utils/MessageHandler.js';
|
||||
|
||||
export class ScreenshotCapture {
|
||||
/**
|
||||
* Create a ScreenshotCapture instance
|
||||
* @param {Object} renderWindow - VTK render window
|
||||
*/
|
||||
constructor(renderWindow) {
|
||||
this.renderWindow = renderWindow;
|
||||
}
|
||||
|
||||
/**
|
||||
* Capture a screenshot as a base64 PNG data URL
|
||||
* @returns {Promise<string>} Base64 PNG data URL
|
||||
*/
|
||||
async capture() {
|
||||
const images = await this.renderWindow.captureImages();
|
||||
return images[0]; // Returns base64 PNG data URL
|
||||
}
|
||||
|
||||
/**
|
||||
* Capture and send screenshot to parent window via postMessage
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async captureAndSend() {
|
||||
try {
|
||||
const image = await this.capture();
|
||||
sendScreenshot(image);
|
||||
console.log('[ScreenshotCapture] Screenshot captured and sent to parent');
|
||||
} catch (error) {
|
||||
console.error('[ScreenshotCapture] Error capturing screenshot:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Capture and download screenshot as a file
|
||||
* @param {string} filename - Download filename (default: "screenshot.png")
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async captureAndDownload(filename = 'screenshot.png') {
|
||||
try {
|
||||
const image = await this.capture();
|
||||
|
||||
// Create download link
|
||||
const link = document.createElement('a');
|
||||
link.href = image;
|
||||
link.download = filename;
|
||||
link.style.display = 'none';
|
||||
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
|
||||
console.log('[ScreenshotCapture] Screenshot downloaded:', filename);
|
||||
} catch (error) {
|
||||
console.error('[ScreenshotCapture] Error downloading screenshot:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Capture screenshot and return as Blob
|
||||
* @returns {Promise<Blob>} PNG Blob
|
||||
*/
|
||||
async captureAsBlob() {
|
||||
const image = await this.capture();
|
||||
|
||||
// Convert base64 to blob
|
||||
const response = await fetch(image);
|
||||
return response.blob();
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a timestamp-based filename
|
||||
* @param {string} prefix - Filename prefix (default: "screenshot")
|
||||
* @param {string} extension - File extension (default: "png")
|
||||
* @returns {string} Filename with timestamp
|
||||
*/
|
||||
static generateFilename(prefix = 'screenshot', extension = 'png') {
|
||||
const now = new Date();
|
||||
const timestamp = now.toISOString()
|
||||
.replace(/[:.]/g, '-')
|
||||
.replace('T', '_')
|
||||
.slice(0, 19);
|
||||
return `${prefix}_${timestamp}.${extension}`;
|
||||
}
|
||||
}
|
||||
|
||||
export default ScreenshotCapture;
|
||||
@@ -0,0 +1,213 @@
|
||||
/**
|
||||
* TextureManager - Texture handling for GLTF/GLB meshes
|
||||
*
|
||||
* Manages texture extraction, material fixes, and rendering configuration
|
||||
* for properly displaying textured meshes in VTK.js.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Texture information structure
|
||||
* @typedef {Object} TextureInfo
|
||||
* @property {boolean} hasTexture - Whether a texture was found
|
||||
* @property {boolean} hasMaterial - Whether PBR material properties exist
|
||||
* @property {string} textureType - Type of texture found (baseColor, diffuse, etc.)
|
||||
*/
|
||||
|
||||
export class TextureManager {
|
||||
/**
|
||||
* Create a TextureManager
|
||||
* @param {Object} vtk - VTK.js global object
|
||||
* @param {Object} renderWindow - VTK render window
|
||||
*/
|
||||
constructor(vtk, renderWindow) {
|
||||
this.vtk = vtk;
|
||||
this.renderWindow = renderWindow;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply texture fixes to an actor
|
||||
* This fixes common issues with GLTF texture rendering in VTK.js
|
||||
* @param {Object} actor - VTK actor
|
||||
* @returns {TextureInfo} Texture information
|
||||
*/
|
||||
applyTextureFixes(actor) {
|
||||
const property = actor.getProperty();
|
||||
const mapper = actor.getMapper();
|
||||
const info = {
|
||||
hasTexture: false,
|
||||
hasMaterial: false,
|
||||
textureType: null
|
||||
};
|
||||
|
||||
if (!property) {
|
||||
return info;
|
||||
}
|
||||
|
||||
// CRITICAL FIX: Remove metallicRoughnessTexture if present
|
||||
// VTK.js GLTFImporter may use this instead of baseColorTexture
|
||||
if (typeof property.setRMTexture === 'function') {
|
||||
property.setRMTexture(null);
|
||||
}
|
||||
if (typeof property.setMetallicRoughnessTexture === 'function') {
|
||||
property.setMetallicRoughnessTexture(null);
|
||||
}
|
||||
|
||||
// Try multiple methods to get the base color texture
|
||||
let texture = null;
|
||||
|
||||
if (typeof property.getBaseColorTexture === 'function') {
|
||||
texture = property.getBaseColorTexture();
|
||||
if (texture) info.textureType = 'baseColor';
|
||||
}
|
||||
if (!texture && typeof property.getDiffuseTexture === 'function') {
|
||||
texture = property.getDiffuseTexture();
|
||||
if (texture) info.textureType = 'diffuse';
|
||||
}
|
||||
if (!texture && typeof property.getTexture === 'function') {
|
||||
texture = property.getTexture();
|
||||
if (texture) info.textureType = 'generic';
|
||||
}
|
||||
|
||||
if (texture) {
|
||||
info.hasTexture = true;
|
||||
actor.addTexture(texture);
|
||||
}
|
||||
|
||||
// Check for PBR material properties
|
||||
if (typeof property.getMetallic === 'function' ||
|
||||
typeof property.getRoughness === 'function') {
|
||||
info.hasMaterial = true;
|
||||
}
|
||||
|
||||
// Set material to non-metallic for proper texture display
|
||||
if (typeof property.setMetallic === 'function') {
|
||||
property.setMetallic(0.0);
|
||||
}
|
||||
if (typeof property.setRoughness === 'function') {
|
||||
property.setRoughness(1.0);
|
||||
}
|
||||
if (typeof property.setInterpolation === 'function') {
|
||||
property.setInterpolation(2); // Phong shading
|
||||
}
|
||||
|
||||
// Configure mapper for texture rendering
|
||||
if (mapper) {
|
||||
mapper.setScalarVisibility(false);
|
||||
mapper.modified();
|
||||
}
|
||||
|
||||
return info;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply texture fixes to multiple actors
|
||||
* @param {Object[]} actors - Array of VTK actors
|
||||
* @returns {TextureInfo} Combined texture information
|
||||
*/
|
||||
applyTextureFixesMultiple(actors) {
|
||||
const combinedInfo = {
|
||||
hasTexture: false,
|
||||
hasMaterial: false,
|
||||
texturedActors: 0
|
||||
};
|
||||
|
||||
actors.forEach(actor => {
|
||||
const info = this.applyTextureFixes(actor);
|
||||
if (info.hasTexture) {
|
||||
combinedInfo.hasTexture = true;
|
||||
combinedInfo.texturedActors++;
|
||||
}
|
||||
if (info.hasMaterial) {
|
||||
combinedInfo.hasMaterial = true;
|
||||
}
|
||||
});
|
||||
|
||||
return combinedInfo;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure renderer for texture display
|
||||
* @param {Object} renderer - VTK renderer
|
||||
*/
|
||||
configureRenderer(renderer) {
|
||||
// Enable two-sided lighting for better texture visibility
|
||||
if (typeof renderer.setTwoSidedLighting === 'function') {
|
||||
renderer.setTwoSidedLighting(true);
|
||||
}
|
||||
|
||||
// Disable shadows (can interfere with texture display)
|
||||
if (typeof renderer.setUseShadows === 'function') {
|
||||
renderer.setUseShadows(false);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set actor appearance for non-textured display (solid color)
|
||||
* @param {Object} actor - VTK actor
|
||||
* @param {number[]} color - RGB color array [0-1 range]
|
||||
*/
|
||||
setActorColor(actor, color = [1.0, 1.0, 1.0]) {
|
||||
const property = actor.getProperty();
|
||||
const mapper = actor.getMapper();
|
||||
|
||||
if (property) {
|
||||
property.setColor(...color);
|
||||
}
|
||||
|
||||
if (mapper) {
|
||||
mapper.setScalarVisibility(false);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract texture information from actor without modifying it
|
||||
* @param {Object} actor - VTK actor
|
||||
* @returns {TextureInfo} Texture information
|
||||
*/
|
||||
getTextureInfo(actor) {
|
||||
const property = actor.getProperty();
|
||||
const info = {
|
||||
hasTexture: false,
|
||||
hasMaterial: false,
|
||||
textureType: null
|
||||
};
|
||||
|
||||
if (!property) {
|
||||
return info;
|
||||
}
|
||||
|
||||
// Check for textures
|
||||
if (typeof property.getBaseColorTexture === 'function' && property.getBaseColorTexture()) {
|
||||
info.hasTexture = true;
|
||||
info.textureType = 'baseColor';
|
||||
} else if (typeof property.getDiffuseTexture === 'function' && property.getDiffuseTexture()) {
|
||||
info.hasTexture = true;
|
||||
info.textureType = 'diffuse';
|
||||
} else if (typeof property.getTexture === 'function' && property.getTexture()) {
|
||||
info.hasTexture = true;
|
||||
info.textureType = 'generic';
|
||||
}
|
||||
|
||||
// Check for material
|
||||
if (typeof property.getMetallic === 'function' ||
|
||||
typeof property.getRoughness === 'function') {
|
||||
info.hasMaterial = true;
|
||||
}
|
||||
|
||||
return info;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if GLTF texture support is available
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isTextureSupported() {
|
||||
try {
|
||||
return this.vtk.IO.Geometry.vtkGLTFImporter !== undefined;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default TextureManager;
|
||||
@@ -0,0 +1,199 @@
|
||||
/**
|
||||
* GeomPack VTK Viewer - Main Entry Point
|
||||
*
|
||||
* This module exports the ViewerFactory and all viewer components
|
||||
* for creating unified VTK viewers.
|
||||
*/
|
||||
|
||||
// Utils
|
||||
import ColorUtils from './utils/ColorUtils.js';
|
||||
import BoundsUtils from './utils/BoundsUtils.js';
|
||||
import MessageHandler from './utils/MessageHandler.js';
|
||||
|
||||
// Core
|
||||
import CameraController from './core/CameraController.js';
|
||||
import ActorManager, { Representation } from './core/ActorManager.js';
|
||||
import BaseViewer from './core/BaseViewer.js';
|
||||
|
||||
// UI
|
||||
import SettingsPanel, { SettingTypes } from './ui/SettingsPanel.js';
|
||||
import ControlsBar, { ControlTypes, StandardControls } from './ui/ControlsBar.js';
|
||||
|
||||
// Loaders
|
||||
import LoaderFactory, { FormatTypes } from './loaders/LoaderFactory.js';
|
||||
import STLLoader from './loaders/STLLoader.js';
|
||||
import OBJLoader from './loaders/OBJLoader.js';
|
||||
import VTPLoader from './loaders/VTPLoader.js';
|
||||
import GLTFLoader from './loaders/GLTFLoader.js';
|
||||
|
||||
// Features
|
||||
import FieldVisualization from './features/FieldVisualization.js';
|
||||
import TextureManager from './features/TextureManager.js';
|
||||
import ScreenshotCapture from './features/ScreenshotCapture.js';
|
||||
import AxisIndicator from './features/AxisIndicator.js';
|
||||
|
||||
// Viewers
|
||||
import StandardViewer, { createStandardViewer } from './viewers/StandardViewer.js';
|
||||
import TexturedViewer, { createTexturedViewer } from './viewers/TexturedViewer.js';
|
||||
import DualViewer, { createDualViewer, DualLayoutMode } from './viewers/DualViewer.js';
|
||||
|
||||
// Re-export utilities
|
||||
export { ColorUtils, BoundsUtils, MessageHandler };
|
||||
|
||||
// Re-export core
|
||||
export { CameraController, ActorManager, Representation, BaseViewer };
|
||||
|
||||
// Re-export UI
|
||||
export { SettingsPanel, SettingTypes, ControlsBar, ControlTypes, StandardControls };
|
||||
|
||||
// Re-export loaders
|
||||
export { LoaderFactory, FormatTypes, STLLoader, OBJLoader, VTPLoader, GLTFLoader };
|
||||
|
||||
// Re-export features
|
||||
export { FieldVisualization, TextureManager, ScreenshotCapture, AxisIndicator };
|
||||
|
||||
// Re-export viewers
|
||||
export { StandardViewer, TexturedViewer, DualViewer, DualLayoutMode };
|
||||
export { createStandardViewer, createTexturedViewer, createDualViewer };
|
||||
|
||||
// Export individual utility functions for convenience
|
||||
export { rgbToString, hexToRgb, rgbToHex } from './utils/ColorUtils.js';
|
||||
export { getCenter, getMaxDimension, getDimensions, combineBounds, isValidBounds } from './utils/BoundsUtils.js';
|
||||
export {
|
||||
MessageTypes,
|
||||
sendToParent,
|
||||
sendError,
|
||||
sendScreenshot,
|
||||
sendMeshLoaded,
|
||||
createMessageListener,
|
||||
extractFilename
|
||||
} from './utils/MessageHandler.js';
|
||||
|
||||
// Export loader factory functions
|
||||
export {
|
||||
loadMesh,
|
||||
loadMeshes,
|
||||
detectFormat,
|
||||
requiresRenderer,
|
||||
supportsTextures,
|
||||
supportsFields,
|
||||
getLoaderConfig
|
||||
} from './loaders/LoaderFactory.js';
|
||||
|
||||
/**
|
||||
* Viewer types for ViewerFactory
|
||||
*/
|
||||
export const ViewerTypes = {
|
||||
STANDARD: 'standard',
|
||||
TEXTURED: 'textured',
|
||||
DUAL: 'dual'
|
||||
};
|
||||
|
||||
/**
|
||||
* ViewerFactory - Creates viewer instances
|
||||
*
|
||||
* Usage:
|
||||
* const viewer = ViewerFactory.create('standard', containerEl);
|
||||
* await viewer.initialize();
|
||||
* await viewer.loadMesh('/path/to/mesh.stl');
|
||||
*/
|
||||
export const ViewerFactory = {
|
||||
/**
|
||||
* Create a viewer instance
|
||||
* @param {string} type - Viewer type ('standard', 'textured', 'dual')
|
||||
* @param {HTMLElement|HTMLElement[]} container - Container element(s)
|
||||
* @param {Object} options - Viewer options
|
||||
* @returns {BaseViewer|DualViewer} Viewer instance
|
||||
*/
|
||||
create(type, container, options = {}) {
|
||||
console.log(`[GeomPack Viewer] Creating ${type} viewer`);
|
||||
|
||||
switch (type) {
|
||||
case ViewerTypes.STANDARD:
|
||||
case 'standard':
|
||||
return new StandardViewer(container, options);
|
||||
|
||||
case ViewerTypes.TEXTURED:
|
||||
case 'textured':
|
||||
return new TexturedViewer(container, options);
|
||||
|
||||
case ViewerTypes.DUAL:
|
||||
case 'dual':
|
||||
// Dual viewer expects two containers
|
||||
if (Array.isArray(container)) {
|
||||
return new DualViewer(container[0], container[1], options);
|
||||
} else if (options.containerB) {
|
||||
return new DualViewer(container, options.containerB, options);
|
||||
} else {
|
||||
throw new Error('DualViewer requires two containers');
|
||||
}
|
||||
|
||||
default:
|
||||
console.warn(`[GeomPack Viewer] Unknown viewer type: ${type}, falling back to standard`);
|
||||
return new StandardViewer(container, options);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Create and initialize a viewer
|
||||
* @param {string} type - Viewer type
|
||||
* @param {HTMLElement|HTMLElement[]} container - Container element(s)
|
||||
* @param {Object} options - Viewer options
|
||||
* @returns {Promise<BaseViewer|DualViewer>} Initialized viewer
|
||||
*/
|
||||
async createAndInit(type, container, options = {}) {
|
||||
const viewer = this.create(type, container, options);
|
||||
await viewer.initialize();
|
||||
return viewer;
|
||||
}
|
||||
};
|
||||
|
||||
// Default export
|
||||
export default {
|
||||
// Utils
|
||||
ColorUtils,
|
||||
BoundsUtils,
|
||||
MessageHandler,
|
||||
|
||||
// Core
|
||||
CameraController,
|
||||
ActorManager,
|
||||
Representation,
|
||||
BaseViewer,
|
||||
|
||||
// UI
|
||||
SettingsPanel,
|
||||
SettingTypes,
|
||||
ControlsBar,
|
||||
ControlTypes,
|
||||
StandardControls,
|
||||
|
||||
// Loaders
|
||||
LoaderFactory,
|
||||
FormatTypes,
|
||||
STLLoader,
|
||||
OBJLoader,
|
||||
VTPLoader,
|
||||
GLTFLoader,
|
||||
|
||||
// Features
|
||||
FieldVisualization,
|
||||
TextureManager,
|
||||
ScreenshotCapture,
|
||||
AxisIndicator,
|
||||
|
||||
// Viewers
|
||||
StandardViewer,
|
||||
TexturedViewer,
|
||||
DualViewer,
|
||||
DualLayoutMode,
|
||||
ViewerTypes,
|
||||
|
||||
// Factory functions
|
||||
createStandardViewer,
|
||||
createTexturedViewer,
|
||||
createDualViewer,
|
||||
|
||||
// Factory
|
||||
ViewerFactory
|
||||
};
|
||||
@@ -0,0 +1,299 @@
|
||||
/**
|
||||
* GLTFLoader - GLTF/GLB mesh file loader with texture support
|
||||
*
|
||||
* Loads GLTF/GLB files using VTK.js vtkGLTFImporter.
|
||||
* Includes critical texture fixes for proper rendering:
|
||||
* - Removes metallicRoughnessTexture (causes rendering issues)
|
||||
* - Sets material to non-metallic for proper texture display
|
||||
* - Enables two-sided lighting
|
||||
*
|
||||
* This loader properly handles textured meshes, unlike simplified implementations.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Check if GLTFImporter is available
|
||||
* @param {Object} vtk - VTK.js global object
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isGLTFSupported(vtk) {
|
||||
try {
|
||||
return vtk.IO.Geometry.vtkGLTFImporter !== undefined;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a GLTF/GLB file with full texture support
|
||||
* @param {string} filepath - URL to the GLTF/GLB file
|
||||
* @param {Object} vtk - VTK.js global object
|
||||
* @param {Object} renderer - VTK renderer (required for GLTF import)
|
||||
* @param {Object} options - Loading options
|
||||
* @param {boolean} options.applyTextureFixes - Apply material fixes for proper texture display (default: true)
|
||||
* @param {boolean} options.twoSidedLighting - Enable two-sided lighting (default: true)
|
||||
* @returns {Object} Result object with actors and metadata
|
||||
*/
|
||||
export async function loadGLTF(filepath, vtk, renderer, options = {}) {
|
||||
const {
|
||||
applyTextureFixes = true,
|
||||
twoSidedLighting = true
|
||||
} = options;
|
||||
|
||||
const vtkGLTFImporter = vtk.IO.Geometry.vtkGLTFImporter;
|
||||
|
||||
if (!vtkGLTFImporter) {
|
||||
throw new Error('GLTFImporter not available in this VTK.js version');
|
||||
}
|
||||
|
||||
// Enable two-sided lighting for better texture visibility
|
||||
if (twoSidedLighting && renderer.setTwoSidedLighting) {
|
||||
renderer.setTwoSidedLighting(true);
|
||||
}
|
||||
|
||||
const importer = vtkGLTFImporter.newInstance();
|
||||
|
||||
// CRITICAL: Set renderer FIRST - importActors() needs this
|
||||
importer.setRenderer(renderer);
|
||||
|
||||
// Set the URL
|
||||
importer.setUrl(filepath);
|
||||
|
||||
// Wait for GLTF parsing to complete using onReady callback
|
||||
await new Promise((resolve, reject) => {
|
||||
importer.onReady(() => {
|
||||
resolve();
|
||||
});
|
||||
|
||||
// Start loading (this triggers parsing)
|
||||
importer.loadData().catch(reject);
|
||||
});
|
||||
|
||||
// Import actors (creates VTK actors and adds them to renderer)
|
||||
importer.importActors();
|
||||
|
||||
// Get actors from the importer (returns Map<string, vtkActor>)
|
||||
const actorsMap = importer.getActors();
|
||||
const actors = Array.from(actorsMap.values());
|
||||
|
||||
// Track texture and vertex color information
|
||||
let hasTexture = false;
|
||||
let hasMaterial = false;
|
||||
let hasVertexColors = false;
|
||||
|
||||
// Process actors and apply texture fixes
|
||||
if (applyTextureFixes) {
|
||||
actors.forEach((actor, index) => {
|
||||
const mapper = actor.getMapper();
|
||||
const property = actor.getProperty();
|
||||
const input = mapper?.getInputData();
|
||||
|
||||
if (input && input.getNumberOfPoints() > 0) {
|
||||
// Check for vertex colors (may be stored as scalars or as named array COLOR_0)
|
||||
const pointData = input.getPointData();
|
||||
let scalars = pointData?.getScalars();
|
||||
|
||||
// If no default scalars, check for COLOR_0 array (GLTF standard)
|
||||
if (!scalars && pointData) {
|
||||
const numArrays = pointData.getNumberOfArrays();
|
||||
for (let i = 0; i < numArrays; i++) {
|
||||
const arr = pointData.getArray(i);
|
||||
const name = arr?.getName() || '';
|
||||
if (name.toUpperCase().includes('COLOR') && arr.getNumberOfComponents() >= 3) {
|
||||
scalars = arr;
|
||||
// Set as active scalars so VTK renders them
|
||||
pointData.setScalars(arr);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (scalars && scalars.getNumberOfComponents() >= 3) {
|
||||
hasVertexColors = true;
|
||||
console.log('[GLTFLoader] Found vertex colors:', scalars.getName(), 'components:', scalars.getNumberOfComponents());
|
||||
}
|
||||
|
||||
if (property) {
|
||||
// CRITICAL FIX: Remove metallicRoughnessTexture if present
|
||||
// VTK.js GLTFImporter may use this instead of baseColorTexture
|
||||
if (typeof property.setRMTexture === 'function') {
|
||||
property.setRMTexture(null);
|
||||
}
|
||||
if (typeof property.setMetallicRoughnessTexture === 'function') {
|
||||
property.setMetallicRoughnessTexture(null);
|
||||
}
|
||||
|
||||
// Try multiple methods to get the base color texture
|
||||
let texture = null;
|
||||
if (typeof property.getBaseColorTexture === 'function') {
|
||||
texture = property.getBaseColorTexture();
|
||||
}
|
||||
if (!texture && typeof property.getDiffuseTexture === 'function') {
|
||||
texture = property.getDiffuseTexture();
|
||||
}
|
||||
if (!texture && typeof property.getTexture === 'function') {
|
||||
texture = property.getTexture();
|
||||
}
|
||||
|
||||
if (texture) {
|
||||
hasTexture = true;
|
||||
actor.addTexture(texture);
|
||||
}
|
||||
|
||||
// Check for material
|
||||
if (property.getMetallic !== undefined || property.getRoughness !== undefined) {
|
||||
hasMaterial = true;
|
||||
}
|
||||
|
||||
// Set material to non-metallic for proper texture display
|
||||
if (typeof property.setMetallic === 'function') {
|
||||
property.setMetallic(0.0);
|
||||
}
|
||||
if (typeof property.setRoughness === 'function') {
|
||||
property.setRoughness(1.0);
|
||||
}
|
||||
if (typeof property.setInterpolation === 'function') {
|
||||
property.setInterpolation(2); // Phong shading
|
||||
}
|
||||
}
|
||||
|
||||
// Configure mapper for rendering
|
||||
if (mapper) {
|
||||
console.log('[GLTFLoader] Configuring mapper - hasVertexColors:', hasVertexColors, 'hasTexture:', hasTexture);
|
||||
if (hasVertexColors && !hasTexture) {
|
||||
// Enable scalar visibility to show vertex colors
|
||||
console.log('[GLTFLoader] Enabling vertex color rendering');
|
||||
mapper.setScalarVisibility(true);
|
||||
mapper.setScalarModeToUsePointData();
|
||||
mapper.setColorModeToDirectScalars();
|
||||
|
||||
// Set actor color to white so vertex colors show through
|
||||
if (property) {
|
||||
console.log('[GLTFLoader] Setting actor color to white');
|
||||
property.setColor(1.0, 1.0, 1.0);
|
||||
// Also set PBR base color factor for GLTF materials
|
||||
if (typeof property.setBaseColorFactor === 'function') {
|
||||
console.log('[GLTFLoader] Setting PBR baseColorFactor to white');
|
||||
property.setBaseColorFactor([1.0, 1.0, 1.0, 1.0]);
|
||||
}
|
||||
// Set diffuse and ambient explicitly
|
||||
if (typeof property.setDiffuseColor === 'function') {
|
||||
property.setDiffuseColor(1.0, 1.0, 1.0);
|
||||
}
|
||||
if (typeof property.setAmbientColor === 'function') {
|
||||
property.setAmbientColor(1.0, 1.0, 1.0);
|
||||
}
|
||||
if (typeof property.setAmbient === 'function') {
|
||||
property.setAmbient(0.2);
|
||||
}
|
||||
if (typeof property.setDiffuse === 'function') {
|
||||
property.setDiffuse(0.8);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Disable scalars for texture rendering
|
||||
mapper.setScalarVisibility(false);
|
||||
}
|
||||
mapper.modified();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Calculate combined bounds from all actors
|
||||
let bounds = null;
|
||||
if (actors.length > 0) {
|
||||
bounds = [Infinity, -Infinity, Infinity, -Infinity, Infinity, -Infinity];
|
||||
actors.forEach(actor => {
|
||||
const actorBounds = actor.getBounds();
|
||||
bounds[0] = Math.min(bounds[0], actorBounds[0]);
|
||||
bounds[1] = Math.max(bounds[1], actorBounds[1]);
|
||||
bounds[2] = Math.min(bounds[2], actorBounds[2]);
|
||||
bounds[3] = Math.max(bounds[3], actorBounds[3]);
|
||||
bounds[4] = Math.min(bounds[4], actorBounds[4]);
|
||||
bounds[5] = Math.max(bounds[5], actorBounds[5]);
|
||||
});
|
||||
}
|
||||
|
||||
// Calculate total points/polys
|
||||
let totalPoints = 0;
|
||||
let totalPolys = 0;
|
||||
actors.forEach(actor => {
|
||||
const mapper = actor.getMapper();
|
||||
const input = mapper?.getInputData();
|
||||
if (input) {
|
||||
totalPoints += input.getNumberOfPoints();
|
||||
totalPolys += input.getNumberOfPolys();
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
actors,
|
||||
importer,
|
||||
bounds,
|
||||
hasTexture,
|
||||
hasVertexColors,
|
||||
metadata: {
|
||||
format: 'GLTF',
|
||||
numActors: actors.length,
|
||||
numPoints: totalPoints,
|
||||
numPolys: totalPolys,
|
||||
bounds,
|
||||
hasTexture,
|
||||
hasVertexColors,
|
||||
hasMaterial
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Simplified GLTF load for non-textured display (e.g., field visualization)
|
||||
* Uses parseAsArrayBuffer instead of URL loading for simpler cases
|
||||
* @param {ArrayBuffer} arrayBuffer - File data as ArrayBuffer
|
||||
* @param {Object} vtk - VTK.js global object
|
||||
* @returns {Object} Result object with polyData and metadata
|
||||
*/
|
||||
export async function loadGLTFSimple(arrayBuffer, vtk) {
|
||||
const vtkGLTFImporter = vtk.IO.Geometry.vtkGLTFImporter;
|
||||
|
||||
if (!vtkGLTFImporter) {
|
||||
throw new Error('GLTFImporter not available');
|
||||
}
|
||||
|
||||
const importer = vtkGLTFImporter.newInstance();
|
||||
importer.parseAsArrayBuffer(arrayBuffer);
|
||||
await importer.update();
|
||||
|
||||
const polyData = importer.getOutputData();
|
||||
if (!polyData) {
|
||||
throw new Error('Failed to extract polydata from GLTF');
|
||||
}
|
||||
|
||||
return {
|
||||
polyData,
|
||||
importer,
|
||||
metadata: {
|
||||
format: 'GLTF',
|
||||
numPoints: polyData.getNumberOfPoints(),
|
||||
numPolys: polyData.getNumberOfPolys(),
|
||||
bounds: polyData.getBounds()
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a filepath is a GLTF/GLB file
|
||||
* @param {string} filepath - File path or URL
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function canLoadGLTF(filepath) {
|
||||
const lower = filepath.toLowerCase();
|
||||
return lower.includes('.glb') || lower.includes('.gltf');
|
||||
}
|
||||
|
||||
export default {
|
||||
load: loadGLTF,
|
||||
loadSimple: loadGLTFSimple,
|
||||
canLoad: canLoadGLTF,
|
||||
isSupported: isGLTFSupported,
|
||||
format: 'GLTF'
|
||||
};
|
||||
@@ -0,0 +1,228 @@
|
||||
/**
|
||||
* LoaderFactory - Auto-detect and load mesh files
|
||||
*
|
||||
* Provides a unified interface for loading different mesh formats.
|
||||
* Automatically detects file type and uses the appropriate loader.
|
||||
*/
|
||||
|
||||
import { loadSTL, canLoadSTL } from './STLLoader.js';
|
||||
import { loadOBJ, canLoadOBJ } from './OBJLoader.js';
|
||||
import { loadVTP, canLoadVTP } from './VTPLoader.js';
|
||||
import { loadGLTF, loadGLTFSimple, canLoadGLTF, isGLTFSupported } from './GLTFLoader.js';
|
||||
|
||||
/**
|
||||
* Supported format types
|
||||
*/
|
||||
export const FormatTypes = {
|
||||
STL: 'STL',
|
||||
OBJ: 'OBJ',
|
||||
VTP: 'VTP',
|
||||
GLTF: 'GLTF',
|
||||
GLB: 'GLB'
|
||||
};
|
||||
|
||||
/**
|
||||
* Detect file format from filepath
|
||||
* @param {string} filepath - File path or URL
|
||||
* @returns {string|null} Format type or null if unknown
|
||||
*/
|
||||
export function detectFormat(filepath) {
|
||||
const lower = filepath.toLowerCase();
|
||||
|
||||
if (lower.includes('.stl')) return FormatTypes.STL;
|
||||
if (lower.includes('.obj')) return FormatTypes.OBJ;
|
||||
if (lower.includes('.vtp')) return FormatTypes.VTP;
|
||||
if (lower.includes('.glb')) return FormatTypes.GLB;
|
||||
if (lower.includes('.gltf')) return FormatTypes.GLTF;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a format requires special handling (e.g., GLTF needs renderer)
|
||||
* @param {string} format - Format type
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function requiresRenderer(format) {
|
||||
return format === FormatTypes.GLTF || format === FormatTypes.GLB;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a format supports textures
|
||||
* @param {string} format - Format type
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function supportsTextures(format) {
|
||||
return format === FormatTypes.GLTF || format === FormatTypes.GLB;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a format supports scalar fields
|
||||
* @param {string} format - Format type
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function supportsFields(format) {
|
||||
return format === FormatTypes.VTP;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch file data as ArrayBuffer
|
||||
* @param {string} filepath - URL to fetch
|
||||
* @returns {Promise<ArrayBuffer>}
|
||||
*/
|
||||
async function fetchFile(filepath, onProgress = null) {
|
||||
const response = await fetch(filepath);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
const total = Number(response.headers.get('Content-Length')) || 0;
|
||||
// Fall back to a plain (indeterminate) read if we can't stream or size it.
|
||||
if (!response.body || !total || typeof onProgress !== 'function') {
|
||||
if (typeof onProgress === 'function') onProgress(null);
|
||||
return response.arrayBuffer();
|
||||
}
|
||||
const reader = response.body.getReader();
|
||||
const chunks = [];
|
||||
let loaded = 0;
|
||||
onProgress(0, 0, total);
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
chunks.push(value);
|
||||
loaded += value.length;
|
||||
onProgress(Math.min(1, loaded / total), loaded, total);
|
||||
}
|
||||
const out = new Uint8Array(loaded);
|
||||
let offset = 0;
|
||||
for (const c of chunks) { out.set(c, offset); offset += c.length; }
|
||||
return out.buffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a mesh file with automatic format detection
|
||||
* @param {string} filepath - URL to the mesh file
|
||||
* @param {Object} vtk - VTK.js global object
|
||||
* @param {Object} options - Loading options
|
||||
* @param {Object} options.renderer - VTK renderer (required for GLTF with textures)
|
||||
* @param {boolean} options.withTextures - Load GLTF with full texture support (default: true)
|
||||
* @param {boolean} options.twoSidedLighting - Enable two-sided lighting for GLTF (default: true)
|
||||
* @returns {Promise<Object>} Result object with polyData/actors and metadata
|
||||
*/
|
||||
export async function loadMesh(filepath, vtk, options = {}) {
|
||||
const {
|
||||
renderer = null,
|
||||
withTextures = true,
|
||||
twoSidedLighting = true,
|
||||
onProgress = null
|
||||
} = options;
|
||||
|
||||
const format = detectFormat(filepath);
|
||||
|
||||
if (!format) {
|
||||
throw new Error(`Unsupported file format: ${filepath}`);
|
||||
}
|
||||
|
||||
console.log(`[LoaderFactory] Loading ${format} file:`, filepath);
|
||||
|
||||
// GLTF/GLB with texture support requires renderer and URL-based loading
|
||||
// (VTK fetches internally, so we can't byte-track it -> indeterminate).
|
||||
if ((format === FormatTypes.GLTF || format === FormatTypes.GLB) && withTextures && renderer) {
|
||||
if (typeof onProgress === 'function') onProgress(null);
|
||||
return loadGLTF(filepath, vtk, renderer, {
|
||||
applyTextureFixes: true,
|
||||
twoSidedLighting
|
||||
});
|
||||
}
|
||||
|
||||
// For all other cases, fetch the file first (with download progress)
|
||||
const arrayBuffer = await fetchFile(filepath, onProgress);
|
||||
|
||||
switch (format) {
|
||||
case FormatTypes.STL:
|
||||
return loadSTL(arrayBuffer, vtk);
|
||||
|
||||
case FormatTypes.OBJ:
|
||||
return loadOBJ(arrayBuffer, vtk);
|
||||
|
||||
case FormatTypes.VTP:
|
||||
return loadVTP(arrayBuffer, vtk);
|
||||
|
||||
case FormatTypes.GLTF:
|
||||
case FormatTypes.GLB:
|
||||
// Simple GLTF load without texture support
|
||||
return loadGLTFSimple(arrayBuffer, vtk);
|
||||
|
||||
default:
|
||||
throw new Error(`No loader available for format: ${format}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load multiple mesh files
|
||||
* @param {string[]} filepaths - Array of URLs to mesh files
|
||||
* @param {Object} vtk - VTK.js global object
|
||||
* @param {Object} options - Loading options (same as loadMesh)
|
||||
* @returns {Promise<Object[]>} Array of result objects
|
||||
*/
|
||||
export async function loadMeshes(filepaths, vtk, options = {}) {
|
||||
return Promise.all(filepaths.map(fp => loadMesh(fp, vtk, options)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a loader configuration for a specific format
|
||||
* Useful for manual loader selection
|
||||
* @param {string} format - Format type
|
||||
* @returns {Object} Loader configuration
|
||||
*/
|
||||
export function getLoaderConfig(format) {
|
||||
switch (format) {
|
||||
case FormatTypes.STL:
|
||||
return {
|
||||
load: loadSTL,
|
||||
canLoad: canLoadSTL,
|
||||
requiresRenderer: false,
|
||||
supportsTextures: false,
|
||||
supportsFields: false
|
||||
};
|
||||
case FormatTypes.OBJ:
|
||||
return {
|
||||
load: loadOBJ,
|
||||
canLoad: canLoadOBJ,
|
||||
requiresRenderer: false,
|
||||
supportsTextures: false,
|
||||
supportsFields: false
|
||||
};
|
||||
case FormatTypes.VTP:
|
||||
return {
|
||||
load: loadVTP,
|
||||
canLoad: canLoadVTP,
|
||||
requiresRenderer: false,
|
||||
supportsTextures: false,
|
||||
supportsFields: true
|
||||
};
|
||||
case FormatTypes.GLTF:
|
||||
case FormatTypes.GLB:
|
||||
return {
|
||||
load: loadGLTF,
|
||||
loadSimple: loadGLTFSimple,
|
||||
canLoad: canLoadGLTF,
|
||||
isSupported: isGLTFSupported,
|
||||
requiresRenderer: true,
|
||||
supportsTextures: true,
|
||||
supportsFields: false
|
||||
};
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
loadMesh,
|
||||
loadMeshes,
|
||||
detectFormat,
|
||||
requiresRenderer,
|
||||
supportsTextures,
|
||||
supportsFields,
|
||||
getLoaderConfig,
|
||||
FormatTypes
|
||||
};
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* OBJLoader - OBJ mesh file loader
|
||||
*
|
||||
* Loads Wavefront OBJ files using VTK.js vtkOBJReader.
|
||||
* Includes normals computation for proper shading.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Load an OBJ file and return polydata
|
||||
* @param {ArrayBuffer} arrayBuffer - File data as ArrayBuffer
|
||||
* @param {Object} vtk - VTK.js global object
|
||||
* @returns {Object} Result object with polyData and metadata
|
||||
*/
|
||||
export async function loadOBJ(arrayBuffer, vtk) {
|
||||
const vtkOBJReader = vtk.IO.Misc.vtkOBJReader;
|
||||
const vtkPolyDataNormals = vtk.Filters.Core.vtkPolyDataNormals;
|
||||
|
||||
const reader = vtkOBJReader.newInstance();
|
||||
|
||||
// OBJ files are text-based
|
||||
const text = new TextDecoder().decode(arrayBuffer);
|
||||
reader.parseAsText(text);
|
||||
|
||||
const rawPolyData = reader.getOutputData();
|
||||
if (!rawPolyData) {
|
||||
throw new Error('Failed to parse OBJ data');
|
||||
}
|
||||
|
||||
// Apply normals filter for proper shading
|
||||
const normalsFilter = vtkPolyDataNormals.newInstance();
|
||||
normalsFilter.setInputConnection(reader.getOutputPort());
|
||||
normalsFilter.update();
|
||||
|
||||
const polyData = normalsFilter.getOutputData();
|
||||
|
||||
return {
|
||||
polyData,
|
||||
reader,
|
||||
normalsFilter,
|
||||
outputPort: normalsFilter.getOutputPort(),
|
||||
metadata: {
|
||||
format: 'OBJ',
|
||||
numPoints: polyData.getNumberOfPoints(),
|
||||
numPolys: polyData.getNumberOfPolys(),
|
||||
bounds: polyData.getBounds()
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a filepath is an OBJ file
|
||||
* @param {string} filepath - File path or URL
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function canLoadOBJ(filepath) {
|
||||
return filepath.toLowerCase().includes('.obj');
|
||||
}
|
||||
|
||||
export default {
|
||||
load: loadOBJ,
|
||||
canLoad: canLoadOBJ,
|
||||
format: 'OBJ'
|
||||
};
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* STLLoader - STL mesh file loader
|
||||
*
|
||||
* Loads binary or ASCII STL files using VTK.js vtkSTLReader.
|
||||
* Includes normals computation for proper shading.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Load an STL file and return polydata
|
||||
* @param {ArrayBuffer} arrayBuffer - File data as ArrayBuffer
|
||||
* @param {Object} vtk - VTK.js global object
|
||||
* @returns {Object} Result object with polyData and metadata
|
||||
*/
|
||||
export async function loadSTL(arrayBuffer, vtk) {
|
||||
const vtkSTLReader = vtk.IO.Geometry.vtkSTLReader;
|
||||
const vtkPolyDataNormals = vtk.Filters.Core.vtkPolyDataNormals;
|
||||
|
||||
const reader = vtkSTLReader.newInstance();
|
||||
reader.parseAsArrayBuffer(arrayBuffer);
|
||||
|
||||
const rawPolyData = reader.getOutputData();
|
||||
if (!rawPolyData) {
|
||||
throw new Error('Failed to parse STL data');
|
||||
}
|
||||
|
||||
// Apply normals filter for proper shading
|
||||
const normalsFilter = vtkPolyDataNormals.newInstance();
|
||||
normalsFilter.setInputConnection(reader.getOutputPort());
|
||||
normalsFilter.update();
|
||||
|
||||
const polyData = normalsFilter.getOutputData();
|
||||
|
||||
return {
|
||||
polyData,
|
||||
reader,
|
||||
normalsFilter,
|
||||
outputPort: normalsFilter.getOutputPort(),
|
||||
metadata: {
|
||||
format: 'STL',
|
||||
numPoints: polyData.getNumberOfPoints(),
|
||||
numPolys: polyData.getNumberOfPolys(),
|
||||
bounds: polyData.getBounds()
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a filepath is an STL file
|
||||
* @param {string} filepath - File path or URL
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function canLoadSTL(filepath) {
|
||||
return filepath.toLowerCase().includes('.stl');
|
||||
}
|
||||
|
||||
export default {
|
||||
load: loadSTL,
|
||||
canLoad: canLoadSTL,
|
||||
format: 'STL'
|
||||
};
|
||||
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* VTPLoader - VTK XML PolyData file loader
|
||||
*
|
||||
* Loads VTP files using VTK.js vtkXMLPolyDataReader.
|
||||
* VTP files can contain scalar fields (vertex/face attributes).
|
||||
* Includes normals computation for proper shading.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Extract field information from polydata
|
||||
* @param {Object} polyData - VTK polydata object
|
||||
* @returns {Object} Field information
|
||||
*/
|
||||
function extractFieldInfo(polyData) {
|
||||
const pointData = polyData.getPointData();
|
||||
const cellData = polyData.getCellData();
|
||||
|
||||
const pointFields = [];
|
||||
const cellFields = [];
|
||||
|
||||
// Extract point (vertex) data arrays
|
||||
for (let i = 0; i < pointData.getNumberOfArrays(); i++) {
|
||||
const array = pointData.getArray(i);
|
||||
const name = array.getName();
|
||||
const range = array.getRange();
|
||||
pointFields.push({
|
||||
name,
|
||||
type: 'point',
|
||||
numComponents: array.getNumberOfComponents(),
|
||||
range: [range[0], range[1]]
|
||||
});
|
||||
}
|
||||
|
||||
// Extract cell (face) data arrays
|
||||
for (let i = 0; i < cellData.getNumberOfArrays(); i++) {
|
||||
const array = cellData.getArray(i);
|
||||
const name = array.getName();
|
||||
const range = array.getRange();
|
||||
cellFields.push({
|
||||
name,
|
||||
type: 'cell',
|
||||
numComponents: array.getNumberOfComponents(),
|
||||
range: [range[0], range[1]]
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
pointFields,
|
||||
cellFields,
|
||||
hasFields: pointFields.length > 0 || cellFields.length > 0
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a VTP file and return polydata
|
||||
* @param {ArrayBuffer} arrayBuffer - File data as ArrayBuffer
|
||||
* @param {Object} vtk - VTK.js global object
|
||||
* @returns {Object} Result object with polyData, fields, and metadata
|
||||
*/
|
||||
export async function loadVTP(arrayBuffer, vtk) {
|
||||
const vtkXMLPolyDataReader = vtk.IO.XML.vtkXMLPolyDataReader;
|
||||
const vtkPolyDataNormals = vtk.Filters.Core.vtkPolyDataNormals;
|
||||
|
||||
const reader = vtkXMLPolyDataReader.newInstance();
|
||||
reader.parseAsArrayBuffer(arrayBuffer);
|
||||
|
||||
const rawPolyData = reader.getOutputData();
|
||||
if (!rawPolyData) {
|
||||
throw new Error('Failed to parse VTP data');
|
||||
}
|
||||
|
||||
// Extract field info BEFORE normals filter (preserves original data)
|
||||
const fieldInfo = extractFieldInfo(rawPolyData);
|
||||
|
||||
// Apply normals filter for proper shading
|
||||
const normalsFilter = vtkPolyDataNormals.newInstance();
|
||||
normalsFilter.setInputConnection(reader.getOutputPort());
|
||||
normalsFilter.update();
|
||||
|
||||
const polyData = normalsFilter.getOutputData();
|
||||
|
||||
// Extract field info again from filtered data (should be same)
|
||||
const filteredFieldInfo = extractFieldInfo(polyData);
|
||||
|
||||
return {
|
||||
polyData,
|
||||
reader,
|
||||
normalsFilter,
|
||||
outputPort: normalsFilter.getOutputPort(),
|
||||
fields: filteredFieldInfo,
|
||||
metadata: {
|
||||
format: 'VTP',
|
||||
numPoints: polyData.getNumberOfPoints(),
|
||||
numPolys: polyData.getNumberOfPolys(),
|
||||
bounds: polyData.getBounds(),
|
||||
pointFields: filteredFieldInfo.pointFields.map(f => f.name),
|
||||
cellFields: filteredFieldInfo.cellFields.map(f => f.name),
|
||||
hasFields: filteredFieldInfo.hasFields
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a filepath is a VTP file
|
||||
* @param {string} filepath - File path or URL
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function canLoadVTP(filepath) {
|
||||
return filepath.toLowerCase().includes('.vtp');
|
||||
}
|
||||
|
||||
export default {
|
||||
load: loadVTP,
|
||||
canLoad: canLoadVTP,
|
||||
format: 'VTP'
|
||||
};
|
||||
@@ -0,0 +1,398 @@
|
||||
/**
|
||||
* ControlsBar - Configurable controls bar for VTK viewers
|
||||
*
|
||||
* Generates and manages the bottom controls bar with camera buttons,
|
||||
* toggles, selectors, and action buttons.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Control types
|
||||
*/
|
||||
export const ControlTypes = {
|
||||
BUTTON: 'button',
|
||||
CHECKBOX: 'checkbox',
|
||||
SELECT: 'select',
|
||||
DIVIDER: 'divider',
|
||||
LABEL: 'label'
|
||||
};
|
||||
|
||||
/**
|
||||
* Standard control configurations
|
||||
*/
|
||||
export const StandardControls = {
|
||||
settingsButton: {
|
||||
id: 'settingsButton',
|
||||
type: ControlTypes.BUTTON,
|
||||
label: '[Settings]',
|
||||
title: 'Appearance Settings'
|
||||
},
|
||||
divider: {
|
||||
type: ControlTypes.DIVIDER
|
||||
},
|
||||
edgesToggle: {
|
||||
id: 'showEdges',
|
||||
type: ControlTypes.CHECKBOX,
|
||||
label: 'Edges'
|
||||
},
|
||||
cameraButtons: {
|
||||
type: 'camera_group',
|
||||
buttons: ['+X', '-X', '+Y', '-Y', '+Z', '-Z']
|
||||
},
|
||||
screenshotButton: {
|
||||
id: 'screenshot',
|
||||
type: ControlTypes.BUTTON,
|
||||
label: 'Screenshot'
|
||||
},
|
||||
saveMeshButton: {
|
||||
id: 'saveMesh',
|
||||
type: ControlTypes.BUTTON,
|
||||
label: 'Save Mesh',
|
||||
className: 'save-button'
|
||||
},
|
||||
saveGLBButton: {
|
||||
id: 'saveGLB',
|
||||
type: ControlTypes.BUTTON,
|
||||
label: 'Save GLB',
|
||||
className: 'save-button'
|
||||
},
|
||||
resetCameraButton: {
|
||||
id: 'resetCamera',
|
||||
type: ControlTypes.BUTTON,
|
||||
label: 'Reset Camera'
|
||||
},
|
||||
fieldSelector: {
|
||||
id: 'fieldSelector',
|
||||
type: ControlTypes.SELECT,
|
||||
label: 'Field',
|
||||
wrapperClass: 'field-label',
|
||||
hidden: true
|
||||
},
|
||||
colormapSelector: {
|
||||
id: 'colormapSelector',
|
||||
type: ControlTypes.SELECT,
|
||||
label: 'Colormap',
|
||||
wrapperClass: 'colormap-label',
|
||||
hidden: true
|
||||
}
|
||||
};
|
||||
|
||||
export class ControlsBar {
|
||||
/**
|
||||
* Create a ControlsBar
|
||||
* @param {Object} options - Configuration options
|
||||
* @param {HTMLElement} options.container - Container element for the controls bar
|
||||
* @param {Object} options.handlers - Event handlers keyed by control ID
|
||||
* @param {Array} options.controls - Array of control configurations
|
||||
*/
|
||||
constructor(options = {}) {
|
||||
this.container = options.container;
|
||||
this.handlers = options.handlers || {};
|
||||
this.controls = options.controls || this._getDefaultControls();
|
||||
this.elements = {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default controls for standard viewer
|
||||
* @private
|
||||
*/
|
||||
_getDefaultControls() {
|
||||
return [
|
||||
StandardControls.settingsButton,
|
||||
StandardControls.divider,
|
||||
StandardControls.edgesToggle,
|
||||
StandardControls.divider,
|
||||
StandardControls.cameraButtons,
|
||||
StandardControls.divider,
|
||||
StandardControls.fieldSelector,
|
||||
StandardControls.colormapSelector,
|
||||
StandardControls.screenshotButton,
|
||||
StandardControls.saveMeshButton
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate HTML for the controls bar
|
||||
* @returns {string} HTML string
|
||||
*/
|
||||
generateHTML() {
|
||||
let html = '';
|
||||
|
||||
this.controls.forEach(control => {
|
||||
if (!control) return;
|
||||
|
||||
switch (control.type) {
|
||||
case ControlTypes.BUTTON:
|
||||
html += this._generateButton(control);
|
||||
break;
|
||||
case ControlTypes.CHECKBOX:
|
||||
html += this._generateCheckbox(control);
|
||||
break;
|
||||
case ControlTypes.SELECT:
|
||||
html += this._generateSelect(control);
|
||||
break;
|
||||
case ControlTypes.DIVIDER:
|
||||
html += '<span class="controls-divider"></span>';
|
||||
break;
|
||||
case ControlTypes.LABEL:
|
||||
html += `<span class="controls-label">${control.text || ''}</span>`;
|
||||
break;
|
||||
case 'camera_group':
|
||||
html += this._generateCameraGroup(control);
|
||||
break;
|
||||
default:
|
||||
if (typeof control.html === 'string') {
|
||||
html += control.html;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate button HTML
|
||||
* @private
|
||||
*/
|
||||
_generateButton(control) {
|
||||
const className = control.className || '';
|
||||
const style = control.id === 'settingsButton' ? 'width:32px;height:32px;padding:0;font-size:16px;' : '';
|
||||
const styleAttr = style ? `style="${style}"` : '';
|
||||
const classAttr = className ? `class="${className}"` : '';
|
||||
|
||||
return `<button id="${control.id}" ${classAttr} ${styleAttr} title="${control.title || ''}">${control.label}</button>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate checkbox HTML
|
||||
* @private
|
||||
*/
|
||||
_generateCheckbox(control) {
|
||||
const checked = control.checked ? 'checked' : '';
|
||||
return `
|
||||
<label>
|
||||
<input type="checkbox" id="${control.id}" ${checked}> ${control.label}
|
||||
</label>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate select HTML
|
||||
* @private
|
||||
*/
|
||||
_generateSelect(control) {
|
||||
const wrapperClass = control.wrapperClass || '';
|
||||
const hiddenStyle = control.hidden ? 'style="display:none;"' : '';
|
||||
const options = (control.options || []).map(opt =>
|
||||
`<option value="${opt.value}" ${opt.selected ? 'selected' : ''}>${opt.label}</option>`
|
||||
).join('');
|
||||
|
||||
return `
|
||||
<label id="${control.id}Label" class="${wrapperClass}" ${hiddenStyle}>
|
||||
${control.label}:
|
||||
<select id="${control.id}">${options}</select>
|
||||
</label>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate camera view buttons group
|
||||
* @private
|
||||
*/
|
||||
_generateCameraGroup(control) {
|
||||
const buttons = control.buttons || ['+X', '-X', '+Y', '-Y', '+Z', '-Z'];
|
||||
return buttons.map(dir => {
|
||||
const id = `view${dir.replace('+', 'Pos').replace('-', 'Neg')}`;
|
||||
return `<button id="${id}">${dir}</button>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the controls bar into the container
|
||||
*/
|
||||
render() {
|
||||
if (!this.container) {
|
||||
console.warn('[ControlsBar] No container specified');
|
||||
return;
|
||||
}
|
||||
|
||||
this.container.innerHTML = this.generateHTML();
|
||||
this._cacheElements();
|
||||
this._setupEventListeners();
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache references to control elements
|
||||
* @private
|
||||
*/
|
||||
_cacheElements() {
|
||||
this.controls.forEach(control => {
|
||||
if (control && control.id) {
|
||||
this.elements[control.id] = document.getElementById(control.id);
|
||||
}
|
||||
});
|
||||
|
||||
// Also cache camera buttons
|
||||
['viewPosX', 'viewNegX', 'viewPosY', 'viewNegY', 'viewPosZ', 'viewNegZ'].forEach(id => {
|
||||
const el = document.getElementById(id);
|
||||
if (el) this.elements[id] = el;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup event listeners based on handlers
|
||||
* @private
|
||||
*/
|
||||
_setupEventListeners() {
|
||||
// Standard handlers
|
||||
Object.keys(this.handlers).forEach(id => {
|
||||
const element = this.elements[id];
|
||||
const handler = this.handlers[id];
|
||||
|
||||
if (element && handler) {
|
||||
if (element.type === 'checkbox') {
|
||||
element.addEventListener('change', (e) => handler(e.target.checked, e));
|
||||
} else if (element.tagName === 'SELECT') {
|
||||
element.addEventListener('change', (e) => handler(e.target.value, e));
|
||||
} else {
|
||||
element.addEventListener('click', handler);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Camera buttons (special handling)
|
||||
if (this.handlers.cameraView) {
|
||||
const cameraDirections = ['+X', '-X', '+Y', '-Y', '+Z', '-Z'];
|
||||
cameraDirections.forEach(dir => {
|
||||
const id = `view${dir.replace('+', 'Pos').replace('-', 'Neg')}`;
|
||||
const element = this.elements[id];
|
||||
if (element) {
|
||||
element.addEventListener('click', () => this.handlers.cameraView(dir));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an element by ID
|
||||
* @param {string} id - Element ID
|
||||
* @returns {HTMLElement|null}
|
||||
*/
|
||||
getElement(id) {
|
||||
return this.elements[id] || document.getElementById(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a control element
|
||||
* @param {string} id - Element ID
|
||||
*/
|
||||
show(id) {
|
||||
const wrapper = document.getElementById(`${id}Label`) || this.elements[id];
|
||||
if (wrapper) {
|
||||
wrapper.style.display = 'flex';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide a control element
|
||||
* @param {string} id - Element ID
|
||||
*/
|
||||
hide(id) {
|
||||
const wrapper = document.getElementById(`${id}Label`) || this.elements[id];
|
||||
if (wrapper) {
|
||||
wrapper.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable a control element
|
||||
* @param {string} id - Element ID
|
||||
*/
|
||||
enable(id) {
|
||||
const element = this.elements[id];
|
||||
if (element) {
|
||||
element.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable a control element
|
||||
* @param {string} id - Element ID
|
||||
*/
|
||||
disable(id) {
|
||||
const element = this.elements[id];
|
||||
if (element) {
|
||||
element.disabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set button text
|
||||
* @param {string} id - Button ID
|
||||
* @param {string} text - New text
|
||||
*/
|
||||
setButtonText(id, text) {
|
||||
const element = this.elements[id];
|
||||
if (element) {
|
||||
element.textContent = text;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set button style (e.g., for save button states)
|
||||
* @param {string} id - Button ID
|
||||
* @param {Object} style - Style properties
|
||||
*/
|
||||
setButtonStyle(id, style) {
|
||||
const element = this.elements[id];
|
||||
if (element) {
|
||||
Object.assign(element.style, style);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Populate a select element with options
|
||||
* @param {string} id - Select element ID
|
||||
* @param {Array} options - Array of {value, label, selected?} objects
|
||||
* @param {boolean} clearFirst - Whether to clear existing options first
|
||||
*/
|
||||
populateSelect(id, options, clearFirst = true) {
|
||||
const element = this.elements[id] || document.getElementById(id);
|
||||
if (!element || element.tagName !== 'SELECT') return;
|
||||
|
||||
if (clearFirst) {
|
||||
element.innerHTML = '';
|
||||
}
|
||||
|
||||
options.forEach(opt => {
|
||||
const option = document.createElement('option');
|
||||
option.value = opt.value;
|
||||
option.textContent = opt.label;
|
||||
if (opt.selected) option.selected = true;
|
||||
if (opt.disabled) option.disabled = true;
|
||||
element.appendChild(option);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a handler for a control
|
||||
* @param {string} id - Control ID
|
||||
* @param {Function} handler - Event handler function
|
||||
*/
|
||||
addHandler(id, handler) {
|
||||
this.handlers[id] = handler;
|
||||
|
||||
// If already rendered, attach immediately
|
||||
const element = this.elements[id];
|
||||
if (element) {
|
||||
if (element.type === 'checkbox') {
|
||||
element.addEventListener('change', (e) => handler(e.target.checked, e));
|
||||
} else if (element.tagName === 'SELECT') {
|
||||
element.addEventListener('change', (e) => handler(e.target.value, e));
|
||||
} else {
|
||||
element.addEventListener('click', handler);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default ControlsBar;
|
||||
@@ -0,0 +1,383 @@
|
||||
/**
|
||||
* SettingsPanel - Configurable settings modal for VTK viewers
|
||||
*
|
||||
* Generates and manages a settings panel with configurable fields.
|
||||
* Different viewers can specify which settings to show.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Available setting types
|
||||
*/
|
||||
export const SettingTypes = {
|
||||
NUMBER: 'number',
|
||||
COLOR: 'color',
|
||||
SELECT: 'select',
|
||||
CHECKBOX: 'checkbox'
|
||||
};
|
||||
|
||||
/**
|
||||
* Default settings configuration
|
||||
*/
|
||||
const DEFAULT_SETTINGS_CONFIG = [
|
||||
{
|
||||
id: 'pointSize',
|
||||
label: 'Point Size',
|
||||
type: SettingTypes.NUMBER,
|
||||
min: 1,
|
||||
max: 20,
|
||||
step: 1,
|
||||
default: 5
|
||||
},
|
||||
{
|
||||
id: 'meshColor',
|
||||
label: 'Mesh Color',
|
||||
type: SettingTypes.COLOR,
|
||||
default: '#ffffff'
|
||||
},
|
||||
{
|
||||
id: 'edgeWidth',
|
||||
label: 'Edge Width',
|
||||
type: SettingTypes.NUMBER,
|
||||
min: 1,
|
||||
max: 10,
|
||||
step: 1,
|
||||
default: 1
|
||||
},
|
||||
{
|
||||
id: 'representation',
|
||||
label: 'Render Mode',
|
||||
type: SettingTypes.SELECT,
|
||||
options: [
|
||||
{ value: '2', label: 'Surface' },
|
||||
{ value: '1', label: 'Wireframe' },
|
||||
{ value: '0', label: 'Points' }
|
||||
],
|
||||
default: '2'
|
||||
},
|
||||
{
|
||||
id: 'parallelProjection',
|
||||
label: 'Parallel Projection',
|
||||
type: SettingTypes.CHECKBOX,
|
||||
default: false
|
||||
},
|
||||
{
|
||||
id: 'showAxisIndicator',
|
||||
label: 'Show Axis Indicator',
|
||||
type: SettingTypes.CHECKBOX,
|
||||
default: false
|
||||
}
|
||||
];
|
||||
|
||||
/**
|
||||
* Colormap range settings (optional, for field visualization)
|
||||
*/
|
||||
const COLORMAP_SETTINGS_CONFIG = [
|
||||
{
|
||||
id: 'autoColorRange',
|
||||
label: 'Auto Range',
|
||||
type: SettingTypes.CHECKBOX,
|
||||
default: true,
|
||||
section: 'Colormap Range'
|
||||
},
|
||||
{
|
||||
id: 'colormapMin',
|
||||
label: 'Min',
|
||||
type: SettingTypes.NUMBER,
|
||||
step: 'any',
|
||||
placeholder: 'Auto',
|
||||
default: '',
|
||||
section: 'Colormap Range'
|
||||
},
|
||||
{
|
||||
id: 'colormapMax',
|
||||
label: 'Max',
|
||||
type: SettingTypes.NUMBER,
|
||||
step: 'any',
|
||||
placeholder: 'Auto',
|
||||
default: '',
|
||||
section: 'Colormap Range'
|
||||
}
|
||||
];
|
||||
|
||||
export class SettingsPanel {
|
||||
/**
|
||||
* Create a SettingsPanel
|
||||
* @param {Object} options - Configuration options
|
||||
* @param {HTMLElement} options.container - Container element for the panel
|
||||
* @param {Function} options.onApply - Callback when settings are applied
|
||||
* @param {string[]} options.enabledSettings - Array of setting IDs to show (null = all)
|
||||
* @param {boolean} options.includeColormapSettings - Whether to include colormap range settings
|
||||
*/
|
||||
constructor(options = {}) {
|
||||
this.container = options.container || document.body;
|
||||
this.onApply = options.onApply || (() => {});
|
||||
this.enabledSettings = options.enabledSettings || null;
|
||||
this.includeColormapSettings = options.includeColormapSettings || false;
|
||||
|
||||
this.panelElement = null;
|
||||
this.overlayElement = null;
|
||||
this.currentValues = {};
|
||||
|
||||
// Build settings config
|
||||
this.settingsConfig = this._buildSettingsConfig();
|
||||
|
||||
// Initialize default values
|
||||
this.settingsConfig.forEach(setting => {
|
||||
this.currentValues[setting.id] = setting.default;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the settings configuration based on enabled settings
|
||||
* @private
|
||||
*/
|
||||
_buildSettingsConfig() {
|
||||
let config = [...DEFAULT_SETTINGS_CONFIG];
|
||||
|
||||
if (this.includeColormapSettings) {
|
||||
config = config.concat(COLORMAP_SETTINGS_CONFIG);
|
||||
}
|
||||
|
||||
if (this.enabledSettings) {
|
||||
config = config.filter(s => this.enabledSettings.includes(s.id));
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate the HTML for the settings panel
|
||||
* @returns {string} HTML string
|
||||
*/
|
||||
generateHTML() {
|
||||
let currentSection = null;
|
||||
let html = `
|
||||
<div id="settingsOverlay"></div>
|
||||
<div id="settingsPanel">
|
||||
<h3>Appearance Settings</h3>
|
||||
`;
|
||||
|
||||
this.settingsConfig.forEach(setting => {
|
||||
// Add section header if needed
|
||||
if (setting.section && setting.section !== currentSection) {
|
||||
currentSection = setting.section;
|
||||
html += `
|
||||
<div class="setting-row setting-section">
|
||||
<label class="setting-section-label">${setting.section}:</label>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
html += this._generateSettingRow(setting);
|
||||
});
|
||||
|
||||
html += `
|
||||
<div class="button-row">
|
||||
<button id="applySettings">Apply</button>
|
||||
<button id="cancelSettings">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate HTML for a single setting row
|
||||
* @private
|
||||
*/
|
||||
_generateSettingRow(setting) {
|
||||
let inputHtml = '';
|
||||
|
||||
switch (setting.type) {
|
||||
case SettingTypes.NUMBER:
|
||||
inputHtml = `
|
||||
<input type="number" id="${setting.id}"
|
||||
${setting.min !== undefined ? `min="${setting.min}"` : ''}
|
||||
${setting.max !== undefined ? `max="${setting.max}"` : ''}
|
||||
${setting.step !== undefined ? `step="${setting.step}"` : ''}
|
||||
${setting.placeholder ? `placeholder="${setting.placeholder}"` : ''}
|
||||
value="${setting.default}">
|
||||
`;
|
||||
break;
|
||||
|
||||
case SettingTypes.COLOR:
|
||||
inputHtml = `<input type="color" id="${setting.id}" value="${setting.default}">`;
|
||||
break;
|
||||
|
||||
case SettingTypes.SELECT:
|
||||
inputHtml = `<select id="${setting.id}">`;
|
||||
setting.options.forEach(opt => {
|
||||
const selected = opt.value === setting.default ? 'selected' : '';
|
||||
inputHtml += `<option value="${opt.value}" ${selected}>${opt.label}</option>`;
|
||||
});
|
||||
inputHtml += '</select>';
|
||||
break;
|
||||
|
||||
case SettingTypes.CHECKBOX:
|
||||
const checked = setting.default ? 'checked' : '';
|
||||
return `
|
||||
<div class="setting-row">
|
||||
<label>
|
||||
<input type="checkbox" id="${setting.id}" ${checked}> ${setting.label}
|
||||
</label>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="setting-row">
|
||||
<label>${setting.label}:</label>
|
||||
${inputHtml}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the panel into the container
|
||||
*/
|
||||
render() {
|
||||
// Create wrapper if needed
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.innerHTML = this.generateHTML();
|
||||
|
||||
// Find or create panel elements in container
|
||||
this.overlayElement = wrapper.querySelector('#settingsOverlay');
|
||||
this.panelElement = wrapper.querySelector('#settingsPanel');
|
||||
|
||||
// Append to container
|
||||
this.container.appendChild(this.overlayElement);
|
||||
this.container.appendChild(this.panelElement);
|
||||
|
||||
// Setup event listeners
|
||||
this._setupEventListeners();
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach to existing panel elements in the DOM
|
||||
* @param {HTMLElement} panel - The settings panel element
|
||||
* @param {HTMLElement} overlay - The overlay element
|
||||
*/
|
||||
attachToExisting(panel, overlay) {
|
||||
this.panelElement = panel;
|
||||
this.overlayElement = overlay;
|
||||
this._setupEventListeners();
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup event listeners
|
||||
* @private
|
||||
*/
|
||||
_setupEventListeners() {
|
||||
// Apply button
|
||||
const applyBtn = this.panelElement.querySelector('#applySettings');
|
||||
if (applyBtn) {
|
||||
applyBtn.addEventListener('click', () => this._handleApply());
|
||||
}
|
||||
|
||||
// Cancel button
|
||||
const cancelBtn = this.panelElement.querySelector('#cancelSettings');
|
||||
if (cancelBtn) {
|
||||
cancelBtn.addEventListener('click', () => this.close());
|
||||
}
|
||||
|
||||
// Overlay click
|
||||
if (this.overlayElement) {
|
||||
this.overlayElement.addEventListener('click', () => this.close());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle apply button click
|
||||
* @private
|
||||
*/
|
||||
_handleApply() {
|
||||
const values = this.getValues();
|
||||
this.currentValues = { ...values };
|
||||
this.onApply(values);
|
||||
this.close();
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the settings panel
|
||||
*/
|
||||
open() {
|
||||
// Update inputs with current values
|
||||
this.setValues(this.currentValues);
|
||||
|
||||
if (this.panelElement) {
|
||||
this.panelElement.classList.add('visible');
|
||||
}
|
||||
if (this.overlayElement) {
|
||||
this.overlayElement.classList.add('visible');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the settings panel
|
||||
*/
|
||||
close() {
|
||||
if (this.panelElement) {
|
||||
this.panelElement.classList.remove('visible');
|
||||
}
|
||||
if (this.overlayElement) {
|
||||
this.overlayElement.classList.remove('visible');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current values from all inputs
|
||||
* @returns {Object} Object with setting id -> value pairs
|
||||
*/
|
||||
getValues() {
|
||||
const values = {};
|
||||
|
||||
this.settingsConfig.forEach(setting => {
|
||||
const element = document.getElementById(setting.id);
|
||||
if (!element) return;
|
||||
|
||||
if (setting.type === SettingTypes.CHECKBOX) {
|
||||
values[setting.id] = element.checked;
|
||||
} else {
|
||||
values[setting.id] = element.value;
|
||||
}
|
||||
});
|
||||
|
||||
return values;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set values on all inputs
|
||||
* @param {Object} values - Object with setting id -> value pairs
|
||||
*/
|
||||
setValues(values) {
|
||||
this.settingsConfig.forEach(setting => {
|
||||
const element = document.getElementById(setting.id);
|
||||
if (!element || values[setting.id] === undefined) return;
|
||||
|
||||
if (setting.type === SettingTypes.CHECKBOX) {
|
||||
element.checked = values[setting.id];
|
||||
} else {
|
||||
element.value = values[setting.id];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update stored current values
|
||||
* @param {Object} values - Partial values to update
|
||||
*/
|
||||
updateCurrentValues(values) {
|
||||
this.currentValues = { ...this.currentValues, ...values };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current stored values
|
||||
* @returns {Object} Current values
|
||||
*/
|
||||
getCurrentValues() {
|
||||
return { ...this.currentValues };
|
||||
}
|
||||
}
|
||||
|
||||
export default SettingsPanel;
|
||||
@@ -0,0 +1,366 @@
|
||||
/**
|
||||
* Shared styles for all VTK viewers
|
||||
* Single source of truth - changes here affect all viewers
|
||||
*/
|
||||
|
||||
/* ========== Base Layout ========== */
|
||||
body {
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
background: #2a2a2a;
|
||||
font-family: monospace;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
#viewerContainer {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
#container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* ========== Loading Overlay ========== */
|
||||
#loading {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
color: white;
|
||||
font-size: 14px;
|
||||
text-align: center;
|
||||
z-index: 1000;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* ========== Controls Bar ========== */
|
||||
#controls {
|
||||
background: #1a1a1a;
|
||||
border-top: 1px solid #444;
|
||||
padding: 8px 12px;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
font-size: 12px;
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
#controls label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
#controls select,
|
||||
#controls button {
|
||||
background: #333;
|
||||
color: #ccc;
|
||||
border: 1px solid #555;
|
||||
padding: 4px 8px;
|
||||
border-radius: 3px;
|
||||
font-size: 12px;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
#controls button {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#controls button:hover {
|
||||
background: #444;
|
||||
}
|
||||
|
||||
#controls button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
#controls input[type="checkbox"] {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Divider between control groups */
|
||||
.controls-divider {
|
||||
width: 1px;
|
||||
height: 20px;
|
||||
background: #444;
|
||||
margin: 0 8px;
|
||||
}
|
||||
|
||||
/* ========== Settings Panel ========== */
|
||||
#settingsPanel {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
background: #1a1a1a;
|
||||
border: 1px solid #555;
|
||||
border-radius: 6px;
|
||||
padding: 16px;
|
||||
z-index: 2000;
|
||||
display: none;
|
||||
min-width: 280px;
|
||||
max-height: 80%;
|
||||
overflow-y: auto;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
#settingsPanel.visible {
|
||||
display: block;
|
||||
}
|
||||
|
||||
#settingsPanel h3 {
|
||||
margin: 0 0 12px 0;
|
||||
color: #ccc;
|
||||
font-size: 14px;
|
||||
font-weight: normal;
|
||||
border-bottom: 1px solid #333;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
#settingsPanel .setting-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
#settingsPanel .setting-row label {
|
||||
flex: 0 0 100px;
|
||||
color: #ccc;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
#settingsPanel .setting-row input[type="color"] {
|
||||
flex: 1;
|
||||
height: 32px;
|
||||
border: 1px solid #555;
|
||||
background: #333;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#settingsPanel .setting-row input[type="number"] {
|
||||
flex: 1;
|
||||
background: #333;
|
||||
color: #ccc;
|
||||
border: 1px solid #555;
|
||||
padding: 6px 8px;
|
||||
border-radius: 3px;
|
||||
font-size: 12px;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
#settingsPanel .setting-row select {
|
||||
flex: 1;
|
||||
background: #333;
|
||||
color: #ccc;
|
||||
border: 1px solid #555;
|
||||
padding: 6px 8px;
|
||||
border-radius: 3px;
|
||||
font-size: 12px;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
#settingsPanel .setting-row input[type="checkbox"] {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#settingsPanel .setting-section {
|
||||
margin-top: 12px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid #444;
|
||||
}
|
||||
|
||||
#settingsPanel .setting-section-label {
|
||||
font-weight: bold;
|
||||
color: #aaa;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
#settingsPanel .button-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 16px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid #333;
|
||||
}
|
||||
|
||||
#settingsPanel .button-row button {
|
||||
flex: 1;
|
||||
padding: 6px 12px;
|
||||
font-size: 12px;
|
||||
background: #333;
|
||||
color: #ccc;
|
||||
border: 1px solid #555;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#settingsPanel .button-row button:hover {
|
||||
background: #444;
|
||||
}
|
||||
|
||||
/* Settings overlay (dark backdrop) */
|
||||
#settingsOverlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
z-index: 1999;
|
||||
display: none;
|
||||
}
|
||||
|
||||
#settingsOverlay.visible {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Settings button (gear icon) */
|
||||
#settingsButton {
|
||||
background: #333;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
/* ========== Info Panel (for textured viewer) ========== */
|
||||
#infoPanel {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
left: 10px;
|
||||
background: rgba(26, 26, 26, 0.9);
|
||||
border: 1px solid #444;
|
||||
border-radius: 4px;
|
||||
padding: 8px 12px;
|
||||
font-size: 11px;
|
||||
color: #ccc;
|
||||
z-index: 100;
|
||||
display: none;
|
||||
}
|
||||
|
||||
#infoPanel.visible {
|
||||
display: block;
|
||||
}
|
||||
|
||||
#infoPanel .info-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
#infoPanel .info-row:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
#infoPanel .info-label {
|
||||
color: #888;
|
||||
}
|
||||
|
||||
#infoPanel .info-value {
|
||||
color: #4a9eff;
|
||||
}
|
||||
|
||||
#infoPanel .info-value.warning {
|
||||
color: #ffaa00;
|
||||
}
|
||||
|
||||
#infoPanel .info-value.error {
|
||||
color: #ff6b6b;
|
||||
}
|
||||
|
||||
#infoPanel .info-value.success {
|
||||
color: #6bff6b;
|
||||
}
|
||||
|
||||
/* ========== Dual Viewer Specific ========== */
|
||||
.viewer-dual #viewerContainer {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.viewer-dual .viewport-container {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.viewer-dual .viewport-label {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
left: 8px;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
color: #ccc;
|
||||
padding: 4px 8px;
|
||||
border-radius: 3px;
|
||||
font-size: 11px;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.viewer-dual #sideBySideContainer {
|
||||
display: none;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.viewer-dual #sideBySideContainer.active {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.viewer-dual #overlayContainer {
|
||||
display: none;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.viewer-dual #overlayContainer.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Field controls for dual viewer */
|
||||
.field-controls {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.field-controls.active {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* ========== Save Button Variants ========== */
|
||||
#saveMesh,
|
||||
#saveGLB {
|
||||
background: #2a5a2a;
|
||||
}
|
||||
|
||||
#saveMesh:hover,
|
||||
#saveGLB:hover {
|
||||
background: #3a6a3a;
|
||||
}
|
||||
|
||||
/* ========== Utility Classes ========== */
|
||||
.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.flex {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.flex-center {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* Bounds calculation utilities for VTK viewers
|
||||
*/
|
||||
|
||||
/**
|
||||
* Get the center point of bounds
|
||||
* @param {number[]} bounds - VTK bounds array [xmin, xmax, ymin, ymax, zmin, zmax]
|
||||
* @returns {number[]} Center point [x, y, z]
|
||||
*/
|
||||
export function getCenter(bounds) {
|
||||
return [
|
||||
(bounds[0] + bounds[1]) / 2,
|
||||
(bounds[2] + bounds[3]) / 2,
|
||||
(bounds[4] + bounds[5]) / 2
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the maximum dimension of bounds
|
||||
* @param {number[]} bounds - VTK bounds array [xmin, xmax, ymin, ymax, zmin, zmax]
|
||||
* @returns {number} Maximum dimension
|
||||
*/
|
||||
export function getMaxDimension(bounds) {
|
||||
return Math.max(
|
||||
bounds[1] - bounds[0],
|
||||
bounds[3] - bounds[2],
|
||||
bounds[5] - bounds[4]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all three dimensions of bounds
|
||||
* @param {number[]} bounds - VTK bounds array [xmin, xmax, ymin, ymax, zmin, zmax]
|
||||
* @returns {number[]} Dimensions [width, height, depth]
|
||||
*/
|
||||
export function getDimensions(bounds) {
|
||||
return [
|
||||
bounds[1] - bounds[0],
|
||||
bounds[3] - bounds[2],
|
||||
bounds[5] - bounds[4]
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Combine multiple bounds into a single bounding box
|
||||
* @param {number[][]} boundsArray - Array of VTK bounds
|
||||
* @returns {number[]} Combined bounds [xmin, xmax, ymin, ymax, zmin, zmax]
|
||||
*/
|
||||
export function combineBounds(boundsArray) {
|
||||
if (!boundsArray || boundsArray.length === 0) {
|
||||
return [0, 0, 0, 0, 0, 0];
|
||||
}
|
||||
|
||||
const combined = [...boundsArray[0]];
|
||||
|
||||
for (let i = 1; i < boundsArray.length; i++) {
|
||||
const b = boundsArray[i];
|
||||
combined[0] = Math.min(combined[0], b[0]); // xmin
|
||||
combined[1] = Math.max(combined[1], b[1]); // xmax
|
||||
combined[2] = Math.min(combined[2], b[2]); // ymin
|
||||
combined[3] = Math.max(combined[3], b[3]); // ymax
|
||||
combined[4] = Math.min(combined[4], b[4]); // zmin
|
||||
combined[5] = Math.max(combined[5], b[5]); // zmax
|
||||
}
|
||||
|
||||
return combined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if bounds are valid (non-empty)
|
||||
* @param {number[]} bounds - VTK bounds array
|
||||
* @returns {boolean} True if bounds are valid
|
||||
*/
|
||||
export function isValidBounds(bounds) {
|
||||
if (!bounds || bounds.length !== 6) return false;
|
||||
|
||||
// Check for NaN or Infinity
|
||||
for (const val of bounds) {
|
||||
if (!Number.isFinite(val)) return false;
|
||||
}
|
||||
|
||||
// Check that max >= min for all dimensions
|
||||
return bounds[1] >= bounds[0] &&
|
||||
bounds[3] >= bounds[2] &&
|
||||
bounds[5] >= bounds[4];
|
||||
}
|
||||
|
||||
export default {
|
||||
getCenter,
|
||||
getMaxDimension,
|
||||
getDimensions,
|
||||
combineBounds,
|
||||
isValidBounds
|
||||
};
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Color utility functions for VTK viewers
|
||||
*/
|
||||
|
||||
/**
|
||||
* Convert RGB values (0-1 range) to CSS rgb() string
|
||||
* @param {number} r - Red component (0-1)
|
||||
* @param {number} g - Green component (0-1)
|
||||
* @param {number} b - Blue component (0-1)
|
||||
* @returns {string} CSS rgb string like "rgb(255, 128, 0)"
|
||||
*/
|
||||
export function rgbToString(r, g, b) {
|
||||
const r255 = Math.round(r * 255);
|
||||
const g255 = Math.round(g * 255);
|
||||
const b255 = Math.round(b * 255);
|
||||
return `rgb(${r255}, ${g255}, ${b255})`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert hex color string to RGB array (0-1 range)
|
||||
* @param {string} hex - Hex color like "#ffffff" or "#fff"
|
||||
* @returns {number[]} RGB array [r, g, b] with values 0-1
|
||||
*/
|
||||
export function hexToRgb(hex) {
|
||||
// Remove # if present
|
||||
hex = hex.replace(/^#/, '');
|
||||
|
||||
// Handle shorthand hex (#fff -> #ffffff)
|
||||
if (hex.length === 3) {
|
||||
hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2];
|
||||
}
|
||||
|
||||
const r = parseInt(hex.substr(0, 2), 16) / 255;
|
||||
const g = parseInt(hex.substr(2, 2), 16) / 255;
|
||||
const b = parseInt(hex.substr(4, 2), 16) / 255;
|
||||
|
||||
return [r, g, b];
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert RGB array (0-1 range) to hex color string
|
||||
* @param {number} r - Red component (0-1)
|
||||
* @param {number} g - Green component (0-1)
|
||||
* @param {number} b - Blue component (0-1)
|
||||
* @returns {string} Hex color like "#ffffff"
|
||||
*/
|
||||
export function rgbToHex(r, g, b) {
|
||||
const toHex = (c) => {
|
||||
const hex = Math.round(c * 255).toString(16);
|
||||
return hex.length === 1 ? '0' + hex : hex;
|
||||
};
|
||||
return '#' + toHex(r) + toHex(g) + toHex(b);
|
||||
}
|
||||
|
||||
export default {
|
||||
rgbToString,
|
||||
hexToRgb,
|
||||
rgbToHex
|
||||
};
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* PostMessage communication utilities for VTK viewers
|
||||
*/
|
||||
|
||||
/**
|
||||
* Message types used by the viewer system
|
||||
*/
|
||||
export const MessageTypes = {
|
||||
// Incoming messages (from parent)
|
||||
LOAD_MESH: 'LOAD_MESH',
|
||||
LOAD_DUAL_MESH: 'LOAD_DUAL_MESH',
|
||||
|
||||
// Outgoing messages (to parent)
|
||||
MESH_LOADED: 'MESH_LOADED',
|
||||
MESH_ERROR: 'MESH_ERROR',
|
||||
SCREENSHOT: 'SCREENSHOT'
|
||||
};
|
||||
|
||||
/**
|
||||
* Send a message to the parent window
|
||||
* @param {string} type - Message type
|
||||
* @param {Object} data - Additional data to send
|
||||
*/
|
||||
export function sendToParent(type, data = {}) {
|
||||
if (window.parent && window.parent !== window) {
|
||||
window.parent.postMessage({
|
||||
type,
|
||||
...data
|
||||
}, '*');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an error message to the parent window
|
||||
* @param {string|Error} error - Error message or Error object
|
||||
*/
|
||||
export function sendError(error) {
|
||||
const message = error instanceof Error ? error.message : error;
|
||||
sendToParent(MessageTypes.MESH_ERROR, { error: message });
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a screenshot to the parent window
|
||||
* @param {string} imageDataUrl - Base64 PNG data URL
|
||||
*/
|
||||
export function sendScreenshot(imageDataUrl) {
|
||||
sendToParent(MessageTypes.SCREENSHOT, {
|
||||
image: imageDataUrl,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Send mesh loaded confirmation to parent
|
||||
*/
|
||||
export function sendMeshLoaded() {
|
||||
sendToParent(MessageTypes.MESH_LOADED, { error: null });
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a message listener for the viewer
|
||||
* @param {Object} handlers - Object mapping message types to handler functions
|
||||
* @returns {Function} Cleanup function to remove the listener
|
||||
*/
|
||||
export function createMessageListener(handlers) {
|
||||
const listener = (event) => {
|
||||
const { type } = event.data || {};
|
||||
|
||||
if (type && handlers[type]) {
|
||||
handlers[type](event.data);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('message', listener);
|
||||
|
||||
// Return cleanup function
|
||||
return () => {
|
||||
window.removeEventListener('message', listener);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract filename from a filepath URL
|
||||
* @param {string} filepath - URL or path to extract filename from
|
||||
* @returns {string} Extracted filename
|
||||
*/
|
||||
export function extractFilename(filepath) {
|
||||
const urlParams = new URLSearchParams(filepath.split('?')[1] || '');
|
||||
return urlParams.get('filename') || filepath.split('/').pop().split('?')[0];
|
||||
}
|
||||
|
||||
export default {
|
||||
MessageTypes,
|
||||
sendToParent,
|
||||
sendError,
|
||||
sendScreenshot,
|
||||
sendMeshLoaded,
|
||||
createMessageListener,
|
||||
extractFilename
|
||||
};
|
||||
@@ -0,0 +1,551 @@
|
||||
/**
|
||||
* DualViewer - VTK viewer for dual mesh comparison
|
||||
*
|
||||
* Supports side-by-side and overlay layouts with:
|
||||
* - Camera synchronization between viewports
|
||||
* - Synchronized scalar field ranges
|
||||
* - Multiple layout modes (side-by-side, overlay, split)
|
||||
* - Independent and linked settings
|
||||
*/
|
||||
|
||||
import { CameraController } from '../core/CameraController.js';
|
||||
import { ActorManager } from '../core/ActorManager.js';
|
||||
import { loadMesh, supportsTextures } from '../loaders/LoaderFactory.js';
|
||||
import { FieldVisualization } from '../features/FieldVisualization.js';
|
||||
import { TextureManager } from '../features/TextureManager.js';
|
||||
import { ScreenshotCapture } from '../features/ScreenshotCapture.js';
|
||||
import { sendMeshLoaded, sendError, createMessageListener, extractFilename } from '../utils/MessageHandler.js';
|
||||
import { combineBounds, isValidBounds } from '../utils/BoundsUtils.js';
|
||||
|
||||
/**
|
||||
* Layout modes for dual viewer
|
||||
*/
|
||||
export const LayoutMode = {
|
||||
SIDE_BY_SIDE: 'side-by-side',
|
||||
OVERLAY: 'overlay',
|
||||
VERTICAL: 'vertical'
|
||||
};
|
||||
|
||||
/**
|
||||
* Dual viewer configuration
|
||||
*/
|
||||
const DUAL_CONFIG = {
|
||||
// Camera
|
||||
cameraDistanceMultiplier: 2.5,
|
||||
|
||||
// Layout
|
||||
layout: LayoutMode.SIDE_BY_SIDE,
|
||||
|
||||
// Sync options
|
||||
syncCameras: true,
|
||||
syncFields: true,
|
||||
|
||||
// Appearance
|
||||
backgroundColor: [0.15, 0.15, 0.15],
|
||||
defaultMeshColorA: [0.6, 0.8, 1.0],
|
||||
defaultMeshColorB: [0.6, 0.8, 1.0],
|
||||
showEdges: false,
|
||||
|
||||
// Features
|
||||
enableFields: true,
|
||||
enableTextures: true
|
||||
};
|
||||
|
||||
export class DualViewer {
|
||||
/**
|
||||
* Create a DualViewer
|
||||
* @param {HTMLElement} containerA - Container for first viewport
|
||||
* @param {HTMLElement} containerB - Container for second viewport
|
||||
* @param {Object} config - Configuration options
|
||||
*/
|
||||
constructor(containerA, containerB, config = {}) {
|
||||
this.containerA = containerA;
|
||||
this.containerB = containerB;
|
||||
this.config = { ...DUAL_CONFIG, ...config };
|
||||
|
||||
// VTK objects
|
||||
this.vtk = null;
|
||||
|
||||
// Viewports (each has its own renderer, render window, etc.)
|
||||
this.viewportA = null;
|
||||
this.viewportB = null;
|
||||
|
||||
// Feature modules (shared)
|
||||
this.actorManager = null;
|
||||
this.fieldVisualization = null;
|
||||
|
||||
// State
|
||||
this.layout = this.config.layout;
|
||||
this.syncCameras = this.config.syncCameras;
|
||||
this.syncFields = this.config.syncFields;
|
||||
this.isInitialized = false;
|
||||
|
||||
// Camera sync state
|
||||
this._cameraObserver = null;
|
||||
this._isUpdatingCamera = false;
|
||||
|
||||
// Message listener cleanup
|
||||
this._messageCleanup = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the dual viewer
|
||||
*/
|
||||
async initialize() {
|
||||
if (this.isInitialized) return;
|
||||
|
||||
this.vtk = window.vtk;
|
||||
if (!this.vtk) {
|
||||
throw new Error('VTK.js not loaded');
|
||||
}
|
||||
|
||||
// Create both viewports
|
||||
this.viewportA = this._createViewport(this.containerA, 'A');
|
||||
this.viewportB = this._createViewport(this.containerB, 'B');
|
||||
|
||||
// Initialize shared modules
|
||||
this.actorManager = new ActorManager(this.vtk, {
|
||||
defaultColor: this.config.defaultMeshColorA
|
||||
});
|
||||
this.fieldVisualization = new FieldVisualization(this.vtk, this.viewportA.renderWindow);
|
||||
|
||||
// Setup camera sync if enabled
|
||||
if (this.syncCameras) {
|
||||
this._setupCameraSync();
|
||||
}
|
||||
|
||||
// Setup resize handlers
|
||||
this._setupResizeHandlers();
|
||||
|
||||
// Setup message listener
|
||||
this._setupMessageListener();
|
||||
|
||||
this.isInitialized = true;
|
||||
console.log('[DualViewer] Initialized');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a viewport
|
||||
* @private
|
||||
* @param {HTMLElement} container
|
||||
* @param {string} id
|
||||
* @returns {Object}
|
||||
*/
|
||||
_createViewport(container, id) {
|
||||
const renderWindow = this.vtk.Rendering.Core.vtkRenderWindow.newInstance();
|
||||
const renderer = this.vtk.Rendering.Core.vtkRenderer.newInstance();
|
||||
renderer.setBackground(...this.config.backgroundColor);
|
||||
renderWindow.addRenderer(renderer);
|
||||
|
||||
const openGLRenderWindow = this.vtk.Rendering.OpenGL.vtkRenderWindow.newInstance();
|
||||
openGLRenderWindow.setContainer(container);
|
||||
renderWindow.addView(openGLRenderWindow);
|
||||
|
||||
const interactor = this.vtk.Rendering.Core.vtkRenderWindowInteractor.newInstance();
|
||||
interactor.setView(openGLRenderWindow);
|
||||
interactor.initialize();
|
||||
interactor.bindEvents(container);
|
||||
|
||||
const interactorStyle = this.vtk.Interaction.Style.vtkInteractorStyleTrackballCamera.newInstance();
|
||||
interactor.setInteractorStyle(interactorStyle);
|
||||
|
||||
const { width, height } = container.getBoundingClientRect();
|
||||
openGLRenderWindow.setSize(width, height);
|
||||
|
||||
const cameraController = new CameraController(renderer, {
|
||||
distanceMultiplier: this.config.cameraDistanceMultiplier
|
||||
});
|
||||
|
||||
// Create texture manager for this viewport
|
||||
const textureManager = new TextureManager(this.vtk, renderWindow);
|
||||
|
||||
return {
|
||||
id,
|
||||
container,
|
||||
renderWindow,
|
||||
renderer,
|
||||
openGLRenderWindow,
|
||||
interactor,
|
||||
interactorStyle,
|
||||
cameraController,
|
||||
textureManager,
|
||||
actors: [],
|
||||
polyData: null,
|
||||
filename: null
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup camera synchronization between viewports
|
||||
* @private
|
||||
*/
|
||||
_setupCameraSync() {
|
||||
const syncCamera = (sourceViewport, targetViewport) => {
|
||||
if (this._isUpdatingCamera) return;
|
||||
this._isUpdatingCamera = true;
|
||||
|
||||
const sourceCamera = sourceViewport.renderer.getActiveCamera();
|
||||
const targetCamera = targetViewport.renderer.getActiveCamera();
|
||||
|
||||
targetCamera.setPosition(...sourceCamera.getPosition());
|
||||
targetCamera.setFocalPoint(...sourceCamera.getFocalPoint());
|
||||
targetCamera.setViewUp(...sourceCamera.getViewUp());
|
||||
targetCamera.setParallelScale(sourceCamera.getParallelScale());
|
||||
|
||||
targetViewport.renderWindow.render();
|
||||
this._isUpdatingCamera = false;
|
||||
};
|
||||
|
||||
// Subscribe to camera modified events
|
||||
const cameraA = this.viewportA.renderer.getActiveCamera();
|
||||
const cameraB = this.viewportB.renderer.getActiveCamera();
|
||||
|
||||
cameraA.onModified(() => {
|
||||
if (this.syncCameras) {
|
||||
syncCamera(this.viewportA, this.viewportB);
|
||||
}
|
||||
});
|
||||
|
||||
cameraB.onModified(() => {
|
||||
if (this.syncCameras) {
|
||||
syncCamera(this.viewportB, this.viewportA);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup resize handlers for both viewports
|
||||
* @private
|
||||
*/
|
||||
_setupResizeHandlers() {
|
||||
const handleResize = (viewport) => {
|
||||
const { width, height } = viewport.container.getBoundingClientRect();
|
||||
if (width > 0 && height > 0) {
|
||||
viewport.openGLRenderWindow.setSize(width, height);
|
||||
viewport.renderWindow.render();
|
||||
}
|
||||
};
|
||||
|
||||
this._resizeObserverA = new ResizeObserver(() => handleResize(this.viewportA));
|
||||
this._resizeObserverB = new ResizeObserver(() => handleResize(this.viewportB));
|
||||
|
||||
this._resizeObserverA.observe(this.viewportA.container);
|
||||
this._resizeObserverB.observe(this.viewportB.container);
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup message listener
|
||||
* @private
|
||||
*/
|
||||
_setupMessageListener() {
|
||||
this._messageCleanup = createMessageListener((data) => {
|
||||
switch (data.type) {
|
||||
case 'loadMeshA':
|
||||
this.loadMesh('A', data.url);
|
||||
break;
|
||||
case 'loadMeshB':
|
||||
this.loadMesh('B', data.url);
|
||||
break;
|
||||
case 'loadMesh':
|
||||
// Load to both or specified viewport
|
||||
if (data.viewport) {
|
||||
this.loadMesh(data.viewport, data.url);
|
||||
} else {
|
||||
this.loadMesh('A', data.url);
|
||||
}
|
||||
break;
|
||||
case 'setLayout':
|
||||
this.setLayout(data.layout);
|
||||
break;
|
||||
case 'setCameraSync':
|
||||
this.setCameraSync(data.enabled);
|
||||
break;
|
||||
case 'setField':
|
||||
this.applyField(data.field, data.options);
|
||||
break;
|
||||
case 'screenshot':
|
||||
this.captureScreenshot();
|
||||
break;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Load mesh into a viewport
|
||||
* @param {string} viewport - 'A' or 'B'
|
||||
* @param {string} url - Mesh URL
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
async loadMesh(viewport, url) {
|
||||
const vp = viewport === 'A' ? this.viewportA : this.viewportB;
|
||||
const defaultColor = viewport === 'A'
|
||||
? this.config.defaultMeshColorA
|
||||
: this.config.defaultMeshColorB;
|
||||
|
||||
try {
|
||||
// Clear existing actors
|
||||
vp.actors.forEach(actor => vp.renderer.removeActor(actor));
|
||||
vp.actors = [];
|
||||
|
||||
// Load mesh
|
||||
const result = await loadMesh(url, this.vtk, {
|
||||
renderer: vp.renderer
|
||||
});
|
||||
|
||||
vp.actors = result.actors;
|
||||
vp.polyData = result.polyData;
|
||||
vp.filename = extractFilename(url);
|
||||
|
||||
// Add actors to renderer
|
||||
result.actors.forEach(actor => {
|
||||
vp.renderer.addActor(actor);
|
||||
});
|
||||
|
||||
// Configure actors based on format
|
||||
if (result.hasTexture && this.config.enableTextures) {
|
||||
// Apply texture fixes for GLTF/GLB
|
||||
vp.textureManager.applyTextureFixesMultiple(result.actors);
|
||||
vp.textureManager.configureRenderer(vp.renderer);
|
||||
} else if (result.hasVertexColors) {
|
||||
// Mesh has vertex colors - don't override with default color
|
||||
// Just configure edges if needed, colors are already set up by loader
|
||||
result.actors.forEach(actor => {
|
||||
const property = actor.getProperty();
|
||||
if (property && this.config.showEdges) {
|
||||
property.setEdgeVisibility(true);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// Standard actor configuration - apply default color
|
||||
this.actorManager.configureActors(result.actors, {
|
||||
color: defaultColor,
|
||||
showEdges: this.config.showEdges
|
||||
});
|
||||
}
|
||||
|
||||
// Position camera
|
||||
this._positionCameraForViewport(vp);
|
||||
|
||||
// Render
|
||||
vp.renderWindow.render();
|
||||
|
||||
// Notify parent
|
||||
sendMeshLoaded({
|
||||
viewport,
|
||||
filename: vp.filename,
|
||||
format: result.format,
|
||||
hasTexture: result.hasTexture || false,
|
||||
hasVertexColors: result.hasVertexColors || false
|
||||
});
|
||||
|
||||
return result;
|
||||
|
||||
} catch (error) {
|
||||
console.error(`[DualViewer] Error loading mesh ${viewport}:`, error);
|
||||
sendError(`Failed to load mesh ${viewport}: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Position camera for a viewport
|
||||
* @private
|
||||
*/
|
||||
_positionCameraForViewport(viewport) {
|
||||
if (viewport.actors.length === 0) return;
|
||||
|
||||
let bounds = viewport.actors[0].getBounds();
|
||||
for (let i = 1; i < viewport.actors.length; i++) {
|
||||
bounds = combineBounds(bounds, viewport.actors[i].getBounds());
|
||||
}
|
||||
|
||||
if (isValidBounds(bounds)) {
|
||||
viewport.cameraController.positionInitialCamera(bounds);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply field visualization to both viewports
|
||||
* @param {string} fieldName - Field name
|
||||
* @param {Object} options - Options including colormap, range
|
||||
*/
|
||||
applyField(fieldName, options = {}) {
|
||||
if (!this.config.enableFields) return;
|
||||
|
||||
// Calculate synchronized range if enabled
|
||||
let range = options.range;
|
||||
if (this.syncFields && !range) {
|
||||
const polydatas = [
|
||||
this.viewportA.polyData,
|
||||
this.viewportB.polyData
|
||||
].filter(Boolean);
|
||||
|
||||
if (polydatas.length > 0) {
|
||||
range = this.fieldVisualization.calculateSynchronizedRange(polydatas, fieldName);
|
||||
}
|
||||
}
|
||||
|
||||
// Apply to viewport A
|
||||
if (this.viewportA.polyData && this.viewportA.actors.length > 0) {
|
||||
const mapperA = this.viewportA.actors[0].getMapper();
|
||||
this.fieldVisualization.applyField(mapperA, this.viewportA.polyData, fieldName, {
|
||||
...options,
|
||||
range,
|
||||
render: false
|
||||
});
|
||||
}
|
||||
|
||||
// Apply to viewport B
|
||||
if (this.viewportB.polyData && this.viewportB.actors.length > 0) {
|
||||
const mapperB = this.viewportB.actors[0].getMapper();
|
||||
// Need a separate FieldVisualization for viewport B's render window
|
||||
const fieldVizB = new FieldVisualization(this.vtk, this.viewportB.renderWindow);
|
||||
fieldVizB.applyField(mapperB, this.viewportB.polyData, fieldName, {
|
||||
...options,
|
||||
range,
|
||||
render: false
|
||||
});
|
||||
}
|
||||
|
||||
// Render both
|
||||
this.render();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set layout mode
|
||||
* @param {string} layout - LayoutMode value
|
||||
*/
|
||||
setLayout(layout) {
|
||||
this.layout = layout;
|
||||
// Layout changes are handled by CSS classes on the containers
|
||||
// This method just updates the state; the parent should update CSS
|
||||
console.log(`[DualViewer] Layout set to: ${layout}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set camera sync enabled
|
||||
* @param {boolean} enabled
|
||||
*/
|
||||
setCameraSync(enabled) {
|
||||
this.syncCameras = enabled;
|
||||
console.log(`[DualViewer] Camera sync: ${enabled}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set field range sync enabled
|
||||
* @param {boolean} enabled
|
||||
*/
|
||||
setFieldSync(enabled) {
|
||||
this.syncFields = enabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set camera view for both viewports
|
||||
* @param {string} direction - '+X', '-X', etc.
|
||||
*/
|
||||
setCameraView(direction) {
|
||||
// Get combined bounds from both viewports
|
||||
const allActors = [...this.viewportA.actors, ...this.viewportB.actors];
|
||||
if (allActors.length === 0) return;
|
||||
|
||||
let bounds = allActors[0].getBounds();
|
||||
for (let i = 1; i < allActors.length; i++) {
|
||||
bounds = combineBounds(bounds, allActors[i].getBounds());
|
||||
}
|
||||
|
||||
// Apply to viewport A (will sync to B if enabled)
|
||||
this.viewportA.cameraController.setCameraView(direction, bounds);
|
||||
|
||||
// If sync is disabled, also update B
|
||||
if (!this.syncCameras) {
|
||||
this.viewportB.cameraController.setCameraView(direction, bounds);
|
||||
}
|
||||
|
||||
this.render();
|
||||
}
|
||||
|
||||
/**
|
||||
* Capture screenshot of both viewports
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
async captureScreenshot() {
|
||||
const screenshotA = new ScreenshotCapture(this.viewportA.renderWindow);
|
||||
const screenshotB = new ScreenshotCapture(this.viewportB.renderWindow);
|
||||
|
||||
const [imageA, imageB] = await Promise.all([
|
||||
screenshotA.capture(),
|
||||
screenshotB.capture()
|
||||
]);
|
||||
|
||||
// Send combined screenshot info
|
||||
window.parent?.postMessage({
|
||||
type: 'screenshot',
|
||||
data: {
|
||||
viewportA: imageA,
|
||||
viewportB: imageB
|
||||
}
|
||||
}, '*');
|
||||
|
||||
return { viewportA: imageA, viewportB: imageB };
|
||||
}
|
||||
|
||||
/**
|
||||
* Render both viewports
|
||||
*/
|
||||
render() {
|
||||
this.viewportA.renderWindow.render();
|
||||
this.viewportB.renderWindow.render();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get viewport
|
||||
* @param {string} id - 'A' or 'B'
|
||||
* @returns {Object}
|
||||
*/
|
||||
getViewport(id) {
|
||||
return id === 'A' ? this.viewportA : this.viewportB;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup and destroy
|
||||
*/
|
||||
destroy() {
|
||||
if (this._messageCleanup) {
|
||||
this._messageCleanup();
|
||||
}
|
||||
|
||||
if (this._resizeObserverA) {
|
||||
this._resizeObserverA.disconnect();
|
||||
}
|
||||
if (this._resizeObserverB) {
|
||||
this._resizeObserverB.disconnect();
|
||||
}
|
||||
|
||||
// Destroy viewports
|
||||
[this.viewportA, this.viewportB].forEach(vp => {
|
||||
if (!vp) return;
|
||||
vp.actors.forEach(actor => vp.renderer.removeActor(actor));
|
||||
vp.interactor.unbindEvents();
|
||||
vp.openGLRenderWindow.delete();
|
||||
vp.renderWindow.delete();
|
||||
});
|
||||
|
||||
console.log('[DualViewer] Destroyed');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a DualViewer instance
|
||||
* @param {HTMLElement} containerA
|
||||
* @param {HTMLElement} containerB
|
||||
* @param {Object} config
|
||||
* @returns {DualViewer}
|
||||
*/
|
||||
export function createDualViewer(containerA, containerB, config = {}) {
|
||||
const viewer = new DualViewer(containerA, containerB, config);
|
||||
viewer.initialize();
|
||||
return viewer;
|
||||
}
|
||||
|
||||
export { LayoutMode as DualLayoutMode };
|
||||
export default DualViewer;
|
||||
@@ -0,0 +1,241 @@
|
||||
/**
|
||||
* StandardViewer - VTK viewer for standard mesh preview
|
||||
*
|
||||
* Supports STL, OBJ, VTP formats with:
|
||||
* - Scalar field visualization
|
||||
* - Colormap selection
|
||||
* - Settings panel (color, edges, lighting)
|
||||
* - Screenshot capture
|
||||
*/
|
||||
|
||||
import { BaseViewer } from '../core/BaseViewer.js';
|
||||
import { SettingTypes } from '../ui/SettingsPanel.js';
|
||||
import { ControlTypes, StandardControls } from '../ui/ControlsBar.js';
|
||||
|
||||
/**
|
||||
* Standard viewer configuration
|
||||
*/
|
||||
const STANDARD_CONFIG = {
|
||||
// Camera
|
||||
cameraDistanceMultiplier: 2.5,
|
||||
|
||||
// Features
|
||||
enableFields: true,
|
||||
enableTextures: false,
|
||||
enableScreenshot: true,
|
||||
enableAxisIndicator: false,
|
||||
enableSettings: true,
|
||||
enableControls: true,
|
||||
|
||||
// Appearance
|
||||
backgroundColor: [0.15, 0.15, 0.15],
|
||||
defaultMeshColor: [0.6, 0.8, 1.0],
|
||||
showEdges: false
|
||||
};
|
||||
|
||||
export class StandardViewer extends BaseViewer {
|
||||
/**
|
||||
* Create a StandardViewer
|
||||
* @param {HTMLElement} container - Container element
|
||||
* @param {Object} config - Additional configuration
|
||||
*/
|
||||
constructor(container, config = {}) {
|
||||
// Merge with standard config
|
||||
const mergedConfig = {
|
||||
...STANDARD_CONFIG,
|
||||
...config,
|
||||
// Settings panel configuration
|
||||
settingsConfig: {
|
||||
title: 'Settings',
|
||||
fields: [
|
||||
{
|
||||
type: SettingTypes.COLOR,
|
||||
id: 'meshColor',
|
||||
label: 'Mesh Color',
|
||||
defaultValue: config.defaultMeshColor || STANDARD_CONFIG.defaultMeshColor
|
||||
},
|
||||
{
|
||||
type: SettingTypes.CHECKBOX,
|
||||
id: 'showEdges',
|
||||
label: 'Show Edges',
|
||||
defaultValue: config.showEdges || STANDARD_CONFIG.showEdges
|
||||
},
|
||||
{
|
||||
type: SettingTypes.COLOR,
|
||||
id: 'edgeColor',
|
||||
label: 'Edge Color',
|
||||
defaultValue: config.edgeColor || [0.2, 0.2, 0.2]
|
||||
},
|
||||
{
|
||||
type: SettingTypes.COLOR,
|
||||
id: 'backgroundColor',
|
||||
label: 'Background',
|
||||
defaultValue: config.backgroundColor || STANDARD_CONFIG.backgroundColor
|
||||
}
|
||||
]
|
||||
},
|
||||
// Controls bar configuration
|
||||
controlsConfig: {
|
||||
controls: [
|
||||
StandardControls.CAMERA_VIEWS,
|
||||
StandardControls.SCREENSHOT,
|
||||
StandardControls.SETTINGS
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
super(container, mergedConfig);
|
||||
|
||||
// Field visualization state
|
||||
this.currentField = null;
|
||||
this.currentColormap = 'erdc_rainbow_bright';
|
||||
this.availableFields = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Load mesh and extract field information
|
||||
* @param {string} url - Mesh file URL
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
async loadMesh(url) {
|
||||
const result = await super.loadMesh(url);
|
||||
|
||||
// Extract field information for VTP files
|
||||
if (this.fieldVisualization && this.polyDatas.length > 0) {
|
||||
const fieldInfo = this.fieldVisualization.extractFields(this.polyDatas[0]);
|
||||
this.availableFields = fieldInfo.allFields;
|
||||
|
||||
// Notify parent about available fields
|
||||
if (this.availableFields.length > 0) {
|
||||
window.parent?.postMessage({
|
||||
type: 'fieldsAvailable',
|
||||
fields: this.availableFields.map(f => ({
|
||||
name: f.fullName,
|
||||
displayName: f.displayName,
|
||||
type: f.type,
|
||||
range: f.range
|
||||
}))
|
||||
}, '*');
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply scalar field visualization
|
||||
* @param {string} fieldName - Field name (e.g., 'point:temperature')
|
||||
* @param {Object} options - Visualization options
|
||||
*/
|
||||
applyField(fieldName, options = {}) {
|
||||
if (!this.fieldVisualization || this.polyDatas.length === 0) {
|
||||
console.warn('[StandardViewer] Field visualization not available');
|
||||
return;
|
||||
}
|
||||
|
||||
const mapper = this.actors[0]?.getMapper();
|
||||
if (!mapper) return;
|
||||
|
||||
this.fieldVisualization.applyField(
|
||||
mapper,
|
||||
this.polyDatas[0],
|
||||
fieldName,
|
||||
{
|
||||
colormap: options.colormap || this.currentColormap,
|
||||
range: options.range,
|
||||
render: true
|
||||
}
|
||||
);
|
||||
|
||||
this.currentField = fieldName;
|
||||
if (options.colormap) {
|
||||
this.currentColormap = options.colormap;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set colormap
|
||||
* @param {string} colormap - Colormap name
|
||||
*/
|
||||
setColormap(colormap) {
|
||||
this.currentColormap = colormap;
|
||||
if (this.currentField) {
|
||||
this.applyField(this.currentField, { colormap });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear field visualization (show solid color)
|
||||
*/
|
||||
clearField() {
|
||||
if (this.actors.length > 0) {
|
||||
const mapper = this.actors[0].getMapper();
|
||||
if (mapper) {
|
||||
this.fieldVisualization?.disableScalarVisualization(mapper, true);
|
||||
}
|
||||
}
|
||||
this.currentField = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available colormap options
|
||||
* @returns {Array}
|
||||
*/
|
||||
getColormapOptions() {
|
||||
return this.fieldVisualization?.getColormapOptions() || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available fields
|
||||
* @returns {Array}
|
||||
*/
|
||||
getAvailableFields() {
|
||||
return this.availableFields;
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup message listener with field support
|
||||
* @private
|
||||
* @override
|
||||
*/
|
||||
_setupMessageListener() {
|
||||
super._setupMessageListener();
|
||||
|
||||
// Add field-specific message handling
|
||||
window.addEventListener('message', (event) => {
|
||||
const data = event.data;
|
||||
if (!data || !data.type) return;
|
||||
|
||||
switch (data.type) {
|
||||
case 'setField':
|
||||
this.applyField(data.field, {
|
||||
colormap: data.colormap,
|
||||
range: data.range
|
||||
});
|
||||
break;
|
||||
|
||||
case 'setColormap':
|
||||
this.setColormap(data.colormap);
|
||||
break;
|
||||
|
||||
case 'clearField':
|
||||
this.clearField();
|
||||
break;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a StandardViewer instance
|
||||
* @param {HTMLElement} container
|
||||
* @param {Object} config
|
||||
* @returns {StandardViewer}
|
||||
*/
|
||||
export function createStandardViewer(container, config = {}) {
|
||||
const viewer = new StandardViewer(container, config);
|
||||
viewer.initialize();
|
||||
return viewer;
|
||||
}
|
||||
|
||||
export default StandardViewer;
|
||||
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* TexturedViewer - VTK viewer for textured GLTF/GLB meshes
|
||||
*
|
||||
* Specialized for displaying textured 3D models with proper:
|
||||
* - Material handling (metallic=0, roughness=1)
|
||||
* - Texture extraction and application
|
||||
* - Two-sided lighting
|
||||
* - Closer camera distance for better texture viewing
|
||||
*/
|
||||
|
||||
import { BaseViewer } from '../core/BaseViewer.js';
|
||||
import { SettingTypes } from '../ui/SettingsPanel.js';
|
||||
import { StandardControls } from '../ui/ControlsBar.js';
|
||||
|
||||
/**
|
||||
* Textured viewer configuration
|
||||
*/
|
||||
const TEXTURED_CONFIG = {
|
||||
// Camera - closer for texture detail
|
||||
cameraDistanceMultiplier: 2.0,
|
||||
|
||||
// Lighting
|
||||
twoSidedLighting: true,
|
||||
|
||||
// Features
|
||||
enableFields: false,
|
||||
enableTextures: true,
|
||||
enableScreenshot: true,
|
||||
enableAxisIndicator: false,
|
||||
enableSettings: true,
|
||||
enableControls: true,
|
||||
|
||||
// Appearance
|
||||
backgroundColor: [0.15, 0.15, 0.15],
|
||||
defaultMeshColor: [1.0, 1.0, 1.0],
|
||||
showEdges: false
|
||||
};
|
||||
|
||||
export class TexturedViewer extends BaseViewer {
|
||||
/**
|
||||
* Create a TexturedViewer
|
||||
* @param {HTMLElement} container - Container element
|
||||
* @param {Object} config - Additional configuration
|
||||
*/
|
||||
constructor(container, config = {}) {
|
||||
// Merge with textured config
|
||||
const mergedConfig = {
|
||||
...TEXTURED_CONFIG,
|
||||
...config,
|
||||
// Settings panel configuration
|
||||
settingsConfig: {
|
||||
title: 'Settings',
|
||||
fields: [
|
||||
{
|
||||
type: SettingTypes.COLOR,
|
||||
id: 'backgroundColor',
|
||||
label: 'Background',
|
||||
defaultValue: config.backgroundColor || TEXTURED_CONFIG.backgroundColor
|
||||
},
|
||||
{
|
||||
type: SettingTypes.CHECKBOX,
|
||||
id: 'showEdges',
|
||||
label: 'Show Edges',
|
||||
defaultValue: false
|
||||
}
|
||||
]
|
||||
},
|
||||
// Controls bar configuration
|
||||
controlsConfig: {
|
||||
controls: [
|
||||
StandardControls.CAMERA_VIEWS,
|
||||
StandardControls.SCREENSHOT,
|
||||
StandardControls.SETTINGS
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
super(container, mergedConfig);
|
||||
|
||||
// Texture state
|
||||
this.textureInfo = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure loaded actors for textured display
|
||||
* @private
|
||||
* @override
|
||||
*/
|
||||
_configureLoadedActors(result) {
|
||||
if (this.textureManager && result.actors.length > 0) {
|
||||
// Apply texture fixes to all actors
|
||||
this.textureInfo = this.textureManager.applyTextureFixesMultiple(result.actors);
|
||||
|
||||
// Configure renderer for texture display
|
||||
this.textureManager.configureRenderer(this.renderer);
|
||||
|
||||
console.log('[TexturedViewer] Texture info:', this.textureInfo);
|
||||
|
||||
// If no textures found, apply default white color
|
||||
if (!this.textureInfo.hasTexture) {
|
||||
result.actors.forEach(actor => {
|
||||
this.textureManager.setActorColor(actor, this.config.defaultMeshColor);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update info overlay with texture information
|
||||
* @private
|
||||
* @override
|
||||
*/
|
||||
_updateInfoOverlay(result) {
|
||||
if (!this.infoOverlay) return;
|
||||
|
||||
const lines = [`File: ${this.currentFilename}`];
|
||||
|
||||
if (result.actors) {
|
||||
lines.push(`Actors: ${result.actors.length}`);
|
||||
}
|
||||
|
||||
if (this.textureInfo) {
|
||||
lines.push(`Textured: ${this.textureInfo.hasTexture ? 'Yes' : 'No'}`);
|
||||
if (this.textureInfo.texturedActors > 0) {
|
||||
lines.push(`Textured actors: ${this.textureInfo.texturedActors}`);
|
||||
}
|
||||
}
|
||||
|
||||
this.infoOverlay.innerHTML = lines.join('<br>');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get texture information
|
||||
* @returns {Object|null}
|
||||
*/
|
||||
getTextureInfo() {
|
||||
return this.textureInfo;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if current mesh has textures
|
||||
* @returns {boolean}
|
||||
*/
|
||||
hasTextures() {
|
||||
return this.textureInfo?.hasTexture || false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a TexturedViewer instance
|
||||
* @param {HTMLElement} container
|
||||
* @param {Object} config
|
||||
* @returns {TexturedViewer}
|
||||
*/
|
||||
export function createTexturedViewer(container, config = {}) {
|
||||
const viewer = new TexturedViewer(container, config);
|
||||
viewer.initialize();
|
||||
return viewer;
|
||||
}
|
||||
|
||||
export default TexturedViewer;
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* ComfyUI GeomPack - Warp Mesh field selector.
|
||||
*
|
||||
* The Warp Mesh node (GeomPackWarpMesh) is an output node: running it reports the
|
||||
* candidate 3-component vertex fields. This extension renders them as clickable
|
||||
* chips in a box under the node; clicking one writes it into the `field_name`
|
||||
* widget. Re-run to apply the warp.
|
||||
*/
|
||||
|
||||
import { app } from "../../../scripts/app.js";
|
||||
|
||||
app.registerExtension({
|
||||
name: "geompack.warp_mesh_field_select",
|
||||
|
||||
async beforeRegisterNodeDef(nodeType, nodeData) {
|
||||
if (nodeData.name !== "GeomPackWarpMesh") return;
|
||||
|
||||
const onNodeCreated = nodeType.prototype.onNodeCreated;
|
||||
nodeType.prototype.onNodeCreated = function () {
|
||||
const r = onNodeCreated ? onNodeCreated.apply(this, arguments) : undefined;
|
||||
const node = this;
|
||||
|
||||
const container = document.createElement("div");
|
||||
container.style.cssText =
|
||||
"width:100%;box-sizing:border-box;padding:6px;background:#1c1c1c;" +
|
||||
"border:1px solid #444;border-radius:4px;font:11px monospace;color:#bbb;" +
|
||||
"display:flex;flex-direction:column;gap:6px;overflow:auto;";
|
||||
|
||||
const status = document.createElement("div");
|
||||
status.style.cssText = "color:#888;flex:0 0 auto;";
|
||||
status.textContent = "Run to list 3-D vertex fields.";
|
||||
|
||||
const chips = document.createElement("div");
|
||||
chips.style.cssText = "display:flex;flex-wrap:wrap;gap:4px;align-content:flex-start;";
|
||||
|
||||
container.appendChild(status);
|
||||
container.appendChild(chips);
|
||||
|
||||
const widget = node.addDOMWidget("warp_fields", "WARP_FIELDS", container, {
|
||||
getValue() { return ""; }, setValue() {},
|
||||
});
|
||||
widget.computeSize = () => [Math.max(node.size?.[0] || 240, 240), 150];
|
||||
|
||||
const fieldWidget = () => node.widgets?.find((w) => w.name === "field_name");
|
||||
|
||||
const render = (fields, selected) => {
|
||||
chips.innerHTML = "";
|
||||
if (!fields || fields.length === 0) {
|
||||
status.textContent = "No 3-D (n_vertices, 3) vertex fields on this mesh.";
|
||||
return;
|
||||
}
|
||||
status.textContent = "Click a field to warp by it:";
|
||||
fields.forEach((name) => {
|
||||
const chip = document.createElement("button");
|
||||
chip.textContent = name;
|
||||
const isSel = name === selected;
|
||||
chip.style.cssText =
|
||||
"font:11px monospace;padding:2px 8px;border-radius:10px;cursor:pointer;" +
|
||||
"border:1px solid " + (isSel ? "#6c6" : "#555") + ";" +
|
||||
"background:" + (isSel ? "#264" : "#2b2b2b") + ";" +
|
||||
"color:" + (isSel ? "#cfc" : "#ddd") + ";";
|
||||
chip.addEventListener("mouseenter", () => {
|
||||
if (name !== selected) chip.style.background = "#383838";
|
||||
});
|
||||
chip.addEventListener("mouseleave", () => {
|
||||
if (name !== selected) chip.style.background = "#2b2b2b";
|
||||
});
|
||||
chip.addEventListener("click", () => {
|
||||
const w = fieldWidget();
|
||||
if (w) {
|
||||
w.value = name;
|
||||
w.callback?.(name);
|
||||
}
|
||||
render(fields, name);
|
||||
node.setDirtyCanvas(true, true);
|
||||
});
|
||||
chips.appendChild(chip);
|
||||
});
|
||||
};
|
||||
|
||||
const onExecuted = node.onExecuted;
|
||||
node.onExecuted = function (message) {
|
||||
onExecuted?.apply(this, arguments);
|
||||
const fields = message?.fields?.[0] || [];
|
||||
const selected = message?.selected?.[0] || (fieldWidget()?.value ?? "");
|
||||
const warped = message?.warped?.[0];
|
||||
render(fields, selected);
|
||||
if (warped) status.textContent = "Warped by '" + selected + "'. Pick another to change.";
|
||||
};
|
||||
|
||||
return r;
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,218 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>GeomPack 3D Viewer</title>
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
background: #2a2a2a;
|
||||
font-family: monospace;
|
||||
}
|
||||
#canvas {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
}
|
||||
#loading {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
color: white;
|
||||
font-size: 14px;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<canvas id="canvas"></canvas>
|
||||
<div id="loading">Ready to load mesh</div>
|
||||
|
||||
<script type="importmap">
|
||||
{
|
||||
"imports": {
|
||||
"three": "https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js",
|
||||
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<script type="module">
|
||||
import * as THREE from 'three';
|
||||
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
|
||||
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
|
||||
import { OBJLoader } from 'three/addons/loaders/OBJLoader.js';
|
||||
|
||||
console.log('[GeomPack Viewer] Initializing...');
|
||||
|
||||
const canvas = document.getElementById('canvas');
|
||||
const loading = document.getElementById('loading');
|
||||
|
||||
// Scene
|
||||
const scene = new THREE.Scene();
|
||||
scene.background = new THREE.Color(0x2a2a2a);
|
||||
|
||||
// Camera
|
||||
const camera = new THREE.PerspectiveCamera(45, window.innerWidth / window.innerHeight, 0.1, 1000);
|
||||
camera.position.set(2, 2, 2);
|
||||
|
||||
// Renderer
|
||||
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
|
||||
renderer.setSize(window.innerWidth, window.innerHeight);
|
||||
renderer.setPixelRatio(window.devicePixelRatio);
|
||||
|
||||
// Controls
|
||||
const controls = new OrbitControls(camera, canvas);
|
||||
controls.enableDamping = true;
|
||||
controls.dampingFactor = 0.05;
|
||||
controls.zoomSpeed = 3.0; // 3x faster zoom
|
||||
|
||||
// Lighting
|
||||
const ambientLight = new THREE.AmbientLight(0xffffff, 0.6);
|
||||
scene.add(ambientLight);
|
||||
|
||||
const dirLight1 = new THREE.DirectionalLight(0xffffff, 0.8);
|
||||
dirLight1.position.set(5, 10, 7);
|
||||
scene.add(dirLight1);
|
||||
|
||||
const dirLight2 = new THREE.DirectionalLight(0xffffff, 0.4);
|
||||
dirLight2.position.set(-5, -5, -7);
|
||||
scene.add(dirLight2);
|
||||
|
||||
// Grid
|
||||
const grid = new THREE.GridHelper(10, 10, 0x444444, 0x222222);
|
||||
scene.add(grid);
|
||||
|
||||
// Axes
|
||||
const axes = new THREE.AxesHelper(1);
|
||||
scene.add(axes);
|
||||
|
||||
// Current mesh
|
||||
let currentMesh = null;
|
||||
|
||||
// Animation loop
|
||||
function animate() {
|
||||
requestAnimationFrame(animate);
|
||||
controls.update();
|
||||
renderer.render(scene, camera);
|
||||
}
|
||||
animate();
|
||||
|
||||
// Handle window resize
|
||||
window.addEventListener('resize', () => {
|
||||
camera.aspect = window.innerWidth / window.innerHeight;
|
||||
camera.updateProjectionMatrix();
|
||||
renderer.setSize(window.innerWidth, window.innerHeight);
|
||||
});
|
||||
|
||||
// Load mesh function
|
||||
function loadMesh(filepath) {
|
||||
console.log('[GeomPack Viewer] Loading:', filepath);
|
||||
console.log('[GeomPack Viewer DEBUG] Full filepath URL:', filepath);
|
||||
loading.textContent = 'Loading mesh...';
|
||||
loading.style.display = 'block';
|
||||
|
||||
// Remove old mesh
|
||||
if (currentMesh) {
|
||||
scene.remove(currentMesh);
|
||||
currentMesh = null;
|
||||
}
|
||||
|
||||
// Check if filepath contains .glb or .gltf (even with query params)
|
||||
const isGLB = filepath.includes('.glb') || filepath.includes('.gltf');
|
||||
const loader = isGLB ? new GLTFLoader() : new OBJLoader();
|
||||
|
||||
console.log('[GeomPack Viewer] File type detected:', isGLB ? 'GLB/GLTF' : 'OBJ');
|
||||
console.log('[GeomPack Viewer DEBUG] Using loader:', loader.constructor.name);
|
||||
console.log('[GeomPack Viewer DEBUG] About to call loader.load()...');
|
||||
|
||||
loader.load(
|
||||
filepath,
|
||||
(result) => {
|
||||
console.log('[GeomPack Viewer DEBUG] Loader success callback called');
|
||||
console.log('[GeomPack Viewer DEBUG] Result:', result);
|
||||
|
||||
currentMesh = isGLB ? result.scene : result;
|
||||
console.log('[GeomPack Viewer DEBUG] currentMesh:', currentMesh);
|
||||
|
||||
// Center mesh
|
||||
const box = new THREE.Box3().setFromObject(currentMesh);
|
||||
const center = box.getCenter(new THREE.Vector3());
|
||||
currentMesh.position.sub(center);
|
||||
|
||||
console.log('[GeomPack Viewer DEBUG] Mesh bounds:', box);
|
||||
console.log('[GeomPack Viewer DEBUG] Mesh center:', center);
|
||||
|
||||
// Add material if needed
|
||||
let meshCount = 0;
|
||||
currentMesh.traverse((child) => {
|
||||
if (child.isMesh) {
|
||||
meshCount++;
|
||||
if (!child.material) {
|
||||
child.material = new THREE.MeshStandardMaterial({
|
||||
color: 0x88aacc,
|
||||
metalness: 0.3,
|
||||
roughness: 0.7
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
console.log('[GeomPack Viewer DEBUG] Mesh count in scene:', meshCount);
|
||||
|
||||
// Add to scene
|
||||
scene.add(currentMesh);
|
||||
console.log('[GeomPack Viewer DEBUG] Mesh added to scene');
|
||||
|
||||
// Adjust camera
|
||||
const size = box.getSize(new THREE.Vector3());
|
||||
const maxDim = Math.max(size.x, size.y, size.z);
|
||||
const distance = maxDim * 2.5;
|
||||
camera.position.set(distance, distance, distance);
|
||||
camera.lookAt(0, 0, 0);
|
||||
controls.target.set(0, 0, 0);
|
||||
controls.update();
|
||||
|
||||
console.log('[GeomPack Viewer DEBUG] Camera adjusted to distance:', distance);
|
||||
|
||||
loading.style.display = 'none';
|
||||
console.log('[GeomPack Viewer] Mesh Loaded successfully');
|
||||
},
|
||||
(progress) => {
|
||||
if (progress.lengthComputable) {
|
||||
const percent = (progress.loaded / progress.total * 100).toFixed(0);
|
||||
loading.textContent = `Loading mesh... ${percent}%`;
|
||||
}
|
||||
},
|
||||
(error) => {
|
||||
console.error('[GeomPack Viewer] Error loading mesh:', error);
|
||||
console.error('[GeomPack Viewer DEBUG] Error type:', error?.constructor?.name);
|
||||
console.error('[GeomPack Viewer DEBUG] Error message:', error?.message);
|
||||
console.error('[GeomPack Viewer DEBUG] Error stack:', error?.stack);
|
||||
loading.textContent = 'Error loading mesh - check console';
|
||||
loading.style.color = '#ff6666';
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Listen for messages from parent window
|
||||
window.addEventListener('message', (event) => {
|
||||
console.log('[GeomPack Viewer DEBUG] Received postMessage:', event.data);
|
||||
console.log('[GeomPack Viewer DEBUG] Message type:', event.data?.type);
|
||||
console.log('[GeomPack Viewer DEBUG] Message filepath:', event.data?.filepath);
|
||||
|
||||
if (event.data.type === 'LOAD_MESH') {
|
||||
console.log('[GeomPack Viewer] Received LOAD_MESH message:', event.data.filepath);
|
||||
loadMesh(event.data.filepath);
|
||||
} else {
|
||||
console.warn('[GeomPack Viewer DEBUG] Unknown message type or missing type');
|
||||
}
|
||||
});
|
||||
|
||||
console.log('[GeomPack Viewer] Ready');
|
||||
console.log('[GeomPack Viewer DEBUG] Waiting for LOAD_MESH postMessage...');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,277 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<style>
|
||||
body { margin: 0; overflow: hidden; background: #1a1a1a; font-family: Arial, sans-serif; }
|
||||
#canvas-container { width: 100%; height: 100%; }
|
||||
#loading {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
color: white;
|
||||
font-size: 18px;
|
||||
z-index: 50;
|
||||
}
|
||||
#info {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
left: 10px;
|
||||
color: white;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
padding: 10px;
|
||||
border-radius: 5px;
|
||||
font-size: 14px;
|
||||
font-family: monospace;
|
||||
z-index: 100;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="loading">Loading BVH animation...</div>
|
||||
<div id="info" style="display: none;"></div>
|
||||
<div id="canvas-container"></div>
|
||||
|
||||
<script type="importmap">
|
||||
{
|
||||
"imports": {
|
||||
"three": "https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js",
|
||||
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<script type="module">
|
||||
import * as THREE from 'three';
|
||||
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
|
||||
import { BVHLoader } from 'three/addons/loaders/BVHLoader.js';
|
||||
|
||||
let scene, camera, renderer, controls;
|
||||
let skeletonHelper = null;
|
||||
let mixer = null;
|
||||
let currentAction = null;
|
||||
let clip = null;
|
||||
let clock = new THREE.Clock();
|
||||
let isPlaying = false;
|
||||
let currentFrame = 0;
|
||||
let totalFrames = 0;
|
||||
let frameTime = 0;
|
||||
let skeleton = null;
|
||||
let gridHelper, axesHelper;
|
||||
|
||||
function init() {
|
||||
scene = new THREE.Scene();
|
||||
scene.background = new THREE.Color(0x1a1a1a);
|
||||
|
||||
camera = new THREE.PerspectiveCamera(50, window.innerWidth / window.innerHeight, 0.1, 10000);
|
||||
camera.position.set(2, 2, 3);
|
||||
|
||||
renderer = new THREE.WebGLRenderer({ antialias: true });
|
||||
renderer.setSize(window.innerWidth, window.innerHeight);
|
||||
renderer.setPixelRatio(window.devicePixelRatio);
|
||||
document.getElementById('canvas-container').appendChild(renderer.domElement);
|
||||
|
||||
controls = new OrbitControls(camera, renderer.domElement);
|
||||
controls.enableDamping = true;
|
||||
controls.dampingFactor = 0.05;
|
||||
controls.target.set(0, 1, 0);
|
||||
controls.update();
|
||||
|
||||
const ambientLight = new THREE.AmbientLight(0xffffff, 0.7);
|
||||
scene.add(ambientLight);
|
||||
|
||||
const directionalLight = new THREE.DirectionalLight(0xffffff, 0.5);
|
||||
directionalLight.position.set(5, 10, 7.5);
|
||||
scene.add(directionalLight);
|
||||
|
||||
gridHelper = new THREE.GridHelper(10, 10, 0x444444, 0x222222);
|
||||
scene.add(gridHelper);
|
||||
|
||||
axesHelper = new THREE.AxesHelper(1);
|
||||
scene.add(axesHelper);
|
||||
|
||||
window.addEventListener('resize', onWindowResize);
|
||||
window.addEventListener('message', handleMessage);
|
||||
|
||||
animate();
|
||||
window.parent.postMessage({ type: 'VIEWER_READY' }, '*');
|
||||
}
|
||||
|
||||
function onWindowResize() {
|
||||
camera.aspect = window.innerWidth / window.innerHeight;
|
||||
camera.updateProjectionMatrix();
|
||||
renderer.setSize(window.innerWidth, window.innerHeight);
|
||||
}
|
||||
|
||||
function handleMessage(event) {
|
||||
const data = event.data;
|
||||
if (data.type === 'loadBVH') {
|
||||
loadBVHFromString(data.bvhContent, data.bvhInfo);
|
||||
} else if (data.type === 'play') {
|
||||
playAnimation();
|
||||
} else if (data.type === 'pause') {
|
||||
pauseAnimation();
|
||||
} else if (data.type === 'setFrame') {
|
||||
setFrame(data.frame);
|
||||
} else if (data.type === 'setSpeed') {
|
||||
if (mixer) mixer.timeScale = data.speed;
|
||||
}
|
||||
}
|
||||
|
||||
function loadBVHFromString(bvhContent, bvhInfo) {
|
||||
if (mixer) {
|
||||
mixer.stopAllAction();
|
||||
mixer.uncacheRoot(mixer.getRoot());
|
||||
mixer = null;
|
||||
}
|
||||
|
||||
const toRemove = [];
|
||||
scene.traverse((child) => {
|
||||
if (child.isSkeletonHelper || (child.isMesh && child !== gridHelper && child !== axesHelper) || child.type === 'Bone') {
|
||||
toRemove.push(child);
|
||||
}
|
||||
});
|
||||
|
||||
toRemove.forEach(child => {
|
||||
scene.remove(child);
|
||||
if (child.geometry) child.geometry.dispose();
|
||||
if (child.material) {
|
||||
if (Array.isArray(child.material)) child.material.forEach(m => m.dispose());
|
||||
else child.material.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
skeletonHelper = null;
|
||||
skeleton = null;
|
||||
|
||||
const loader = new BVHLoader();
|
||||
let result;
|
||||
|
||||
try {
|
||||
result = loader.parse(bvhContent);
|
||||
} catch (error) {
|
||||
document.getElementById('loading').textContent = 'Error loading BVH: ' + error.message;
|
||||
return;
|
||||
}
|
||||
|
||||
skeleton = result.skeleton;
|
||||
clip = result.clip;
|
||||
|
||||
scene.add(skeleton.bones[0]);
|
||||
|
||||
skeletonHelper = new THREE.SkeletonHelper(skeleton.bones[0]);
|
||||
skeletonHelper.skeleton = skeleton;
|
||||
skeletonHelper.material = new THREE.LineBasicMaterial({ color: 0x00ff00, linewidth: 3, depthTest: false });
|
||||
scene.add(skeletonHelper);
|
||||
|
||||
const jointGeometry = new THREE.SphereGeometry(0.02, 8, 8);
|
||||
const jointMaterial = new THREE.MeshBasicMaterial({ color: 0xff0000, depthTest: false });
|
||||
|
||||
skeleton.bones[0].traverse((bone) => {
|
||||
if (bone.isBone) {
|
||||
const jointMarker = new THREE.Mesh(jointGeometry, jointMaterial);
|
||||
bone.add(jointMarker);
|
||||
}
|
||||
});
|
||||
|
||||
mixer = new THREE.AnimationMixer(skeleton.bones[0]);
|
||||
currentAction = mixer.clipAction(clip);
|
||||
currentAction.setEffectiveWeight(1.0);
|
||||
|
||||
isPlaying = true;
|
||||
currentFrame = 0;
|
||||
|
||||
totalFrames = Math.floor(clip.duration / (1/30));
|
||||
if (bvhInfo && bvhInfo.num_frames) totalFrames = bvhInfo.num_frames;
|
||||
frameTime = clip.duration / totalFrames;
|
||||
|
||||
try {
|
||||
const box = new THREE.Box3().setFromObject(skeleton.bones[0]);
|
||||
const center = box.getCenter(new THREE.Vector3());
|
||||
const size = box.getSize(new THREE.Vector3());
|
||||
|
||||
if (size.lengthSq() < 0.001) {
|
||||
center.set(0, 1, 0);
|
||||
size.set(1, 2, 1);
|
||||
}
|
||||
|
||||
const maxDim = Math.max(size.x, size.y, size.z);
|
||||
const fov = camera.fov * (Math.PI / 180);
|
||||
let cameraZ = Math.abs(maxDim / 2 / Math.tan(fov / 2));
|
||||
cameraZ *= 1.5;
|
||||
|
||||
camera.position.set(center.x, center.y + size.y/2, center.z + cameraZ);
|
||||
camera.lookAt(center);
|
||||
controls.target.copy(center);
|
||||
controls.update();
|
||||
} catch (e) {
|
||||
console.warn("[BVHViewer] Camera framing failed:", e);
|
||||
}
|
||||
|
||||
const infoDiv = document.getElementById('info');
|
||||
infoDiv.innerHTML = `
|
||||
<strong>BVH Animation</strong><br>
|
||||
Bones: ${skeleton.bones.length}<br>
|
||||
Frames: ${totalFrames}<br>
|
||||
FPS: ${bvhInfo?.fps || 30}<br>
|
||||
Duration: ${clip.duration.toFixed(2)}s
|
||||
`;
|
||||
infoDiv.style.display = 'block';
|
||||
document.getElementById('loading').style.display = 'none';
|
||||
|
||||
playAnimation();
|
||||
window.parent.postMessage({ type: 'frameChanged', frame: 0, totalFrames: totalFrames }, '*');
|
||||
}
|
||||
|
||||
function playAnimation() {
|
||||
if (!currentAction) return;
|
||||
isPlaying = true;
|
||||
currentAction.play();
|
||||
clock.start();
|
||||
window.parent.postMessage({ type: 'playing' }, '*');
|
||||
}
|
||||
|
||||
function pauseAnimation() {
|
||||
if (!currentAction) return;
|
||||
isPlaying = false;
|
||||
currentAction.paused = true;
|
||||
window.parent.postMessage({ type: 'paused' }, '*');
|
||||
}
|
||||
|
||||
function setFrame(frame) {
|
||||
if (!mixer || !currentAction || !clip) return;
|
||||
currentFrame = Math.max(0, Math.min(frame, totalFrames - 1));
|
||||
const time = (currentFrame / totalFrames) * clip.duration;
|
||||
mixer.setTime(time);
|
||||
currentAction.time = time;
|
||||
mixer.update(0);
|
||||
window.parent.postMessage({ type: 'frameChanged', frame: currentFrame, totalFrames: totalFrames }, '*');
|
||||
}
|
||||
|
||||
function animate() {
|
||||
requestAnimationFrame(animate);
|
||||
controls.update();
|
||||
|
||||
if (mixer && isPlaying) {
|
||||
const delta = clock.getDelta();
|
||||
mixer.update(delta);
|
||||
|
||||
if (currentAction && clip) {
|
||||
const progress = currentAction.time / clip.duration;
|
||||
currentFrame = Math.floor(progress * totalFrames);
|
||||
|
||||
if (currentAction.time >= clip.duration - 0.01) {
|
||||
window.parent.postMessage({ type: 'looped' }, '*');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
renderer.render(scene, camera);
|
||||
}
|
||||
|
||||
init();
|
||||
console.log('[BVH Viewer] Loaded successfully');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,354 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<style>
|
||||
html, body { width: 100%; height: 100%; margin: 0; padding: 0; overflow: hidden; background: #1a1a1a; font-family: Arial, sans-serif; }
|
||||
#container { display: flex; flex-direction: column; width: 100%; height: 100%; }
|
||||
#canvas-container { flex: 1; position: relative; overflow: hidden; width: 100%; min-height: 0; background: #000; }
|
||||
#controls {
|
||||
height: 40px;
|
||||
flex-shrink: 0;
|
||||
background: #252525;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 10px;
|
||||
gap: 10px;
|
||||
border-top: 1px solid #333;
|
||||
z-index: 200;
|
||||
}
|
||||
.panel-label {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
font-weight: bold;
|
||||
font-size: 14px;
|
||||
pointer-events: none;
|
||||
background: rgba(0,0,0,0.5);
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
z-index: 10;
|
||||
}
|
||||
#label-smpl { left: 10px; }
|
||||
#label-bvh { left: 50%; margin-left: 10px; }
|
||||
#loading {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
color: white;
|
||||
font-size: 16px;
|
||||
background: rgba(0,0,0,0.8);
|
||||
padding: 15px 20px;
|
||||
border-radius: 8px;
|
||||
z-index: 100;
|
||||
}
|
||||
button {
|
||||
background: #4a9eff;
|
||||
border: none;
|
||||
color: white;
|
||||
border-radius: 4px;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
}
|
||||
button:hover { background: #3a8eef; }
|
||||
button:disabled { background: #444; cursor: default; }
|
||||
input[type=range] {
|
||||
flex-grow: 1;
|
||||
height: 6px;
|
||||
background: #444;
|
||||
border-radius: 3px;
|
||||
outline: none;
|
||||
}
|
||||
.frame-counter {
|
||||
color: #aaa;
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
min-width: 80px;
|
||||
text-align: right;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="container">
|
||||
<div id="canvas-container">
|
||||
<div id="loading">Waiting for data...</div>
|
||||
<div id="label-smpl" class="panel-label">SMPL (Mesh)</div>
|
||||
<div id="label-bvh" class="panel-label">BVH (Skeleton)</div>
|
||||
</div>
|
||||
<div id="controls">
|
||||
<button id="play-btn">▶</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
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,316 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<style>
|
||||
body { margin: 0; overflow: hidden; background: #1a1a1a; font-family: Arial, sans-serif; }
|
||||
#canvas-container { width: 100%; height: 100%; }
|
||||
#loading {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
color: white;
|
||||
font-size: 18px;
|
||||
z-index: 50;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="loading">Loading animated FBX...</div>
|
||||
<div id="canvas-container"></div>
|
||||
|
||||
<script type="importmap">
|
||||
{
|
||||
"imports": {
|
||||
"three": "https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js",
|
||||
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<script type="module">
|
||||
import * as THREE from 'three';
|
||||
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
|
||||
import { FBXLoader } from 'three/addons/loaders/FBXLoader.js';
|
||||
|
||||
let scene, camera, renderer, controls;
|
||||
let currentModel = null;
|
||||
let skeletonHelper = null;
|
||||
let modelBoundingBox = null;
|
||||
let mixer = null;
|
||||
let currentAction = null;
|
||||
let animations = [];
|
||||
let clock = new THREE.Clock();
|
||||
let isPlaying = false;
|
||||
let showSkeleton = true;
|
||||
let showMesh = true;
|
||||
|
||||
function init() {
|
||||
scene = new THREE.Scene();
|
||||
scene.background = new THREE.Color(0x1a1a1a);
|
||||
|
||||
camera = new THREE.PerspectiveCamera(50, window.innerWidth / window.innerHeight, 0.1, 10000);
|
||||
camera.position.set(0, 1.5, 3);
|
||||
|
||||
renderer = new THREE.WebGLRenderer({ antialias: true });
|
||||
renderer.setSize(window.innerWidth, window.innerHeight);
|
||||
renderer.setPixelRatio(window.devicePixelRatio);
|
||||
renderer.shadowMap.enabled = true;
|
||||
document.getElementById('canvas-container').appendChild(renderer.domElement);
|
||||
|
||||
controls = new OrbitControls(camera, renderer.domElement);
|
||||
controls.enableDamping = true;
|
||||
controls.dampingFactor = 0.05;
|
||||
|
||||
const ambientLight = new THREE.AmbientLight(0xffffff, 0.6);
|
||||
scene.add(ambientLight);
|
||||
|
||||
const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8);
|
||||
directionalLight.position.set(5, 10, 7.5);
|
||||
directionalLight.castShadow = true;
|
||||
scene.add(directionalLight);
|
||||
|
||||
const gridHelper = new THREE.GridHelper(10, 10, 0x444444, 0x222222);
|
||||
scene.add(gridHelper);
|
||||
|
||||
const axesHelper = new THREE.AxesHelper(1);
|
||||
scene.add(axesHelper);
|
||||
|
||||
animate();
|
||||
window.addEventListener('resize', onWindowResize);
|
||||
window.addEventListener('message', handleMessage);
|
||||
window.parent.postMessage({ type: 'VIEWER_READY' }, '*');
|
||||
}
|
||||
|
||||
function handleMessage(event) {
|
||||
const { type, ...data } = event.data;
|
||||
switch(type) {
|
||||
case 'LOAD_FBX': loadFBX(data.path); break;
|
||||
case 'PLAY_PAUSE': togglePlayPause(); break;
|
||||
case 'RESET': resetAnimation(); break;
|
||||
case 'SET_TIMELINE': setTimeline(data.progress); break;
|
||||
case 'SET_SPEED': setSpeed(data.speed); break;
|
||||
case 'SET_LOOP': setLoop(data.loop); break;
|
||||
case 'CHANGE_ANIMATION': playAnimation(data.index); break;
|
||||
case 'TOGGLE_SKELETON': toggleSkeleton(data.visible); break;
|
||||
case 'TOGGLE_MESH': toggleMesh(data.visible); break;
|
||||
case 'TOGGLE_XRAY': toggleXRay(data.xray); break;
|
||||
case 'RESET_CAMERA': resetCamera(); break;
|
||||
}
|
||||
}
|
||||
|
||||
function loadFBX(path) {
|
||||
const loader = new FBXLoader();
|
||||
const url = (path.startsWith('http') || path.startsWith('blob:')) ? path : window.parent.location.origin + path;
|
||||
|
||||
loader.load(url, (fbx) => {
|
||||
if (currentModel) scene.remove(currentModel);
|
||||
if (skeletonHelper) scene.remove(skeletonHelper);
|
||||
if (mixer) mixer.stopAllAction();
|
||||
|
||||
currentModel = fbx;
|
||||
scene.add(fbx);
|
||||
|
||||
fbx.traverse((child) => {
|
||||
if (child.isMesh) {
|
||||
child.castShadow = true;
|
||||
child.receiveShadow = true;
|
||||
child.visible = showMesh;
|
||||
}
|
||||
});
|
||||
|
||||
let skinnedMesh = null;
|
||||
fbx.traverse((child) => {
|
||||
if (child.isSkinnedMesh && !skinnedMesh) skinnedMesh = child;
|
||||
});
|
||||
|
||||
if (skinnedMesh && skinnedMesh.skeleton) {
|
||||
skeletonHelper = new THREE.SkeletonHelper(fbx);
|
||||
skeletonHelper.material.linewidth = 2;
|
||||
skeletonHelper.visible = showSkeleton;
|
||||
scene.add(skeletonHelper);
|
||||
}
|
||||
|
||||
animations = fbx.animations || [];
|
||||
if (animations.length > 0) {
|
||||
setupAnimations();
|
||||
} else {
|
||||
notifyParent({ type: 'NO_ANIMATIONS' });
|
||||
}
|
||||
|
||||
centerModel(fbx);
|
||||
document.getElementById('loading').style.display = 'none';
|
||||
}, (xhr) => {
|
||||
const percent = (xhr.loaded / xhr.total * 100).toFixed(0);
|
||||
document.getElementById('loading').textContent = 'Loading... ' + percent + '%';
|
||||
}, (error) => {
|
||||
document.getElementById('loading').textContent = 'Error loading FBX';
|
||||
});
|
||||
}
|
||||
|
||||
function setupAnimations() {
|
||||
mixer = new THREE.AnimationMixer(currentModel);
|
||||
const animationNames = animations.map((clip, i) => ({
|
||||
index: i,
|
||||
name: clip.name || ('Animation ' + (i + 1)),
|
||||
duration: clip.duration
|
||||
}));
|
||||
notifyParent({ type: 'ANIMATIONS_LOADED', animations: animationNames });
|
||||
playAnimation(0);
|
||||
}
|
||||
|
||||
function playAnimation(index) {
|
||||
if (mixer && animations[index]) {
|
||||
if (currentAction) currentAction.stop();
|
||||
currentAction = mixer.clipAction(animations[index]);
|
||||
currentAction.setLoop(THREE.LoopRepeat);
|
||||
currentAction.timeScale = 1.0;
|
||||
currentAction.play();
|
||||
isPlaying = true;
|
||||
notifyParent({ type: 'ANIMATION_CHANGED', index: index, duration: currentAction.getClip().duration });
|
||||
}
|
||||
}
|
||||
|
||||
function togglePlayPause() {
|
||||
if (!currentAction) return;
|
||||
if (isPlaying) {
|
||||
currentAction.paused = true;
|
||||
isPlaying = false;
|
||||
} else {
|
||||
currentAction.paused = false;
|
||||
isPlaying = true;
|
||||
}
|
||||
notifyParent({ type: 'PLAY_STATE_CHANGED', isPlaying });
|
||||
}
|
||||
|
||||
function resetAnimation() {
|
||||
if (currentAction) {
|
||||
currentAction.reset();
|
||||
currentAction.play();
|
||||
isPlaying = true;
|
||||
notifyParent({ type: 'PLAY_STATE_CHANGED', isPlaying: true });
|
||||
}
|
||||
}
|
||||
|
||||
function setTimeline(progress) {
|
||||
if (currentAction) {
|
||||
const duration = currentAction.getClip().duration;
|
||||
currentAction.time = progress * duration;
|
||||
}
|
||||
}
|
||||
|
||||
function setSpeed(speed) {
|
||||
if (currentAction) currentAction.timeScale = speed;
|
||||
}
|
||||
|
||||
function setLoop(loop) {
|
||||
if (currentAction) currentAction.setLoop(loop ? THREE.LoopRepeat : THREE.LoopOnce);
|
||||
}
|
||||
|
||||
function toggleSkeleton(visible) {
|
||||
showSkeleton = visible;
|
||||
if (skeletonHelper) skeletonHelper.visible = visible;
|
||||
}
|
||||
|
||||
function toggleMesh(visible) {
|
||||
showMesh = visible;
|
||||
if (currentModel) {
|
||||
currentModel.traverse((child) => {
|
||||
if (child.isMesh) child.visible = visible;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function toggleXRay(xray) {
|
||||
if (skeletonHelper) {
|
||||
skeletonHelper.material.depthTest = !xray;
|
||||
skeletonHelper.material.depthWrite = !xray;
|
||||
}
|
||||
}
|
||||
|
||||
function centerModel(model) {
|
||||
const box = new THREE.Box3().setFromObject(model);
|
||||
const center = box.getCenter(new THREE.Vector3());
|
||||
const size = box.getSize(new THREE.Vector3());
|
||||
|
||||
model.position.sub(center);
|
||||
|
||||
const maxDim = Math.max(size.x, size.y, size.z);
|
||||
const fov = camera.fov * (Math.PI / 180);
|
||||
let cameraZ = Math.abs(maxDim / 2 / Math.tan(fov / 2));
|
||||
cameraZ *= 1.5;
|
||||
|
||||
camera.position.set(0, size.y / 2, cameraZ);
|
||||
camera.lookAt(0, size.y / 2, 0);
|
||||
controls.target.set(0, size.y / 2, 0);
|
||||
controls.update();
|
||||
|
||||
modelBoundingBox = box;
|
||||
}
|
||||
|
||||
function resetCamera() {
|
||||
if (modelBoundingBox) {
|
||||
const size = modelBoundingBox.getSize(new THREE.Vector3());
|
||||
const maxDim = Math.max(size.x, size.y, size.z);
|
||||
const fov = camera.fov * (Math.PI / 180);
|
||||
let cameraZ = Math.abs(maxDim / 2 / Math.tan(fov / 2));
|
||||
cameraZ *= 1.5;
|
||||
|
||||
camera.position.set(0, size.y / 2, cameraZ);
|
||||
camera.lookAt(0, size.y / 2, 0);
|
||||
controls.target.set(0, size.y / 2, 0);
|
||||
controls.update();
|
||||
}
|
||||
}
|
||||
|
||||
function onWindowResize() {
|
||||
camera.aspect = window.innerWidth / window.innerHeight;
|
||||
camera.updateProjectionMatrix();
|
||||
renderer.setSize(window.innerWidth, window.innerHeight);
|
||||
}
|
||||
|
||||
function notifyParent(data) {
|
||||
window.parent.postMessage(data, '*');
|
||||
}
|
||||
|
||||
let lastTimeUpdate = 0;
|
||||
function sendTimeUpdate() {
|
||||
if (!currentAction) return;
|
||||
const currentTime = currentAction.time;
|
||||
const duration = currentAction.getClip().duration;
|
||||
const progress = (currentTime / duration) * 100;
|
||||
const fps = 30;
|
||||
const currentFrame = Math.floor(currentTime * fps);
|
||||
const totalFrames = Math.floor(duration * fps);
|
||||
|
||||
const now = Date.now();
|
||||
if (now - lastTimeUpdate > 50) {
|
||||
notifyParent({
|
||||
type: 'TIME_UPDATE',
|
||||
time: currentTime,
|
||||
duration: duration,
|
||||
progress: progress,
|
||||
frame: currentFrame,
|
||||
totalFrames: totalFrames
|
||||
});
|
||||
lastTimeUpdate = now;
|
||||
}
|
||||
}
|
||||
|
||||
function animate() {
|
||||
requestAnimationFrame(animate);
|
||||
const delta = clock.getDelta();
|
||||
if (mixer && isPlaying && !currentAction?.paused) {
|
||||
mixer.update(delta);
|
||||
sendTimeUpdate();
|
||||
}
|
||||
controls.update();
|
||||
renderer.render(scene, camera);
|
||||
}
|
||||
|
||||
init();
|
||||
console.log('[FBX Animation Viewer] Loaded successfully');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,958 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>GeomPack Gaussian Splat Viewer</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
background: #1a1a1a;
|
||||
overflow: hidden;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
#viewerContainer {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
canvas {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.error {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
text-align: center;
|
||||
color: #ff6b6b;
|
||||
padding: 20px;
|
||||
max-width: 80%;
|
||||
}
|
||||
|
||||
.error.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#controlsBar {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
background: #252525;
|
||||
padding: 8px 12px;
|
||||
border-top: 1px solid #333;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
#controlsBar button {
|
||||
background: #444;
|
||||
border: none;
|
||||
color: #fff;
|
||||
padding: 4px 10px;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
#controlsBar button:hover {
|
||||
background: #555;
|
||||
}
|
||||
|
||||
.scale-control {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.scale-control span {
|
||||
color: #888;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.scale-control input[type="number"] {
|
||||
width: 60px;
|
||||
background: #333;
|
||||
border: 1px solid #555;
|
||||
color: #fff;
|
||||
font-size: 11px;
|
||||
padding: 4px 6px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.info-panel {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
left: 8px;
|
||||
background: rgba(0,0,0,0.7);
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
color: #aaa;
|
||||
font-size: 11px;
|
||||
font-family: monospace;
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
.info-panel.hidden {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="viewerContainer">
|
||||
<canvas id="canvas"></canvas>
|
||||
|
||||
<div id="error" class="error hidden"></div>
|
||||
|
||||
<div id="infoPanel" class="info-panel hidden">
|
||||
<div id="infoContent"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="controlsBar">
|
||||
<div class="scale-control">
|
||||
<span>Scale:</span>
|
||||
<input type="number" id="gaussianScaleValue" min="0.01" max="100" step="0.1" value="1.0" title="Gaussian scale multiplier">
|
||||
</div>
|
||||
<button id="resetCamera" title="Reset camera view">Reset View</button>
|
||||
<button id="screenshot" title="Take screenshot">Screenshot</button>
|
||||
<button id="copyImage" title="Copy image to clipboard">Copy Image</button>
|
||||
</div>
|
||||
|
||||
<!-- Load bundled gsplat.js -->
|
||||
<script src="./js/gsplat-bundle.js"></script>
|
||||
|
||||
<script type="module">
|
||||
// Use the globally exported GSPLAT
|
||||
const SPLAT = window.GSPLAT;
|
||||
|
||||
// DOM elements
|
||||
const canvas = document.getElementById('canvas');
|
||||
const errorEl = document.getElementById('error');
|
||||
const infoPanel = document.getElementById('infoPanel');
|
||||
const infoContent = document.getElementById('infoContent');
|
||||
|
||||
// gsplat.js components
|
||||
let scene = null;
|
||||
let camera = null;
|
||||
let renderer = null;
|
||||
let controls = null;
|
||||
let animationId = null;
|
||||
let currentSplat = null;
|
||||
let originalScales = null; // Store original scales for dynamic adjustment
|
||||
|
||||
// Default focal length
|
||||
const DEFAULT_FOCAL_LENGTH = 30;
|
||||
|
||||
// Initialize the viewer
|
||||
function initViewer() {
|
||||
try {
|
||||
scene = new SPLAT.Scene();
|
||||
camera = new SPLAT.Camera();
|
||||
renderer = new SPLAT.WebGLRenderer(canvas);
|
||||
controls = new SPLAT.OrbitControls(camera, canvas);
|
||||
|
||||
// Set default focal length
|
||||
camera.data.fx = DEFAULT_FOCAL_LENGTH;
|
||||
camera.data.fy = DEFAULT_FOCAL_LENGTH;
|
||||
|
||||
// Handle resize
|
||||
const resize = () => {
|
||||
// Only resize renderer if resolution is NOT locked
|
||||
// If locked, the canvas resolution stays fixed (e.g. at 2K),
|
||||
// and CSS handles the visual fitting to container.
|
||||
if (!isResolutionLocked) {
|
||||
renderer.setSize(canvas.clientWidth, canvas.clientHeight);
|
||||
}
|
||||
};
|
||||
window.addEventListener('resize', resize);
|
||||
resize();
|
||||
|
||||
// Start render loop
|
||||
const frame = () => {
|
||||
controls.update();
|
||||
renderer.render(scene, camera);
|
||||
animationId = requestAnimationFrame(frame);
|
||||
};
|
||||
frame();
|
||||
|
||||
console.log('[GaussianViewer] Initialized successfully');
|
||||
} catch (err) {
|
||||
console.error('[GaussianViewer] Init error:', err);
|
||||
showError('Failed to initialize WebGL viewer: ' + err.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Show error
|
||||
function showError(message) {
|
||||
errorEl.textContent = message;
|
||||
errorEl.classList.remove('hidden');
|
||||
}
|
||||
|
||||
// Store initial camera params for reset
|
||||
let initialCameraData = null;
|
||||
|
||||
// Resolution lock state
|
||||
let isResolutionLocked = false;
|
||||
|
||||
// Scale compensation: when focal length is scaled down for canvas size,
|
||||
// gaussian scales need to be multiplied by this factor to maintain correct screen-space size
|
||||
let gaussianScaleCompensation = 1.0;
|
||||
|
||||
// Resume the animation loop (used after pausing for screenshot/clipboard)
|
||||
function resumeAnimationLoop() {
|
||||
const frame = () => {
|
||||
controls.update();
|
||||
renderer.render(scene, camera);
|
||||
animationId = requestAnimationFrame(frame);
|
||||
};
|
||||
frame();
|
||||
}
|
||||
|
||||
// Set camera from extrinsics and intrinsics
|
||||
// extrinsics: 4x4 matrix (identity for SHARP = camera at origin looking down +Z)
|
||||
// intrinsics: 3x3 matrix [[fx, 0, cx], [0, fy, cy], [0, 0, 1]]
|
||||
// splat: the loaded Gaussian splat object (optional, used for bounds)
|
||||
function setCameraFromExtrinsics(extrinsics, intrinsics, splat) {
|
||||
if (!camera || !controls) return;
|
||||
|
||||
console.log('[GaussianViewer] Setting camera from extrinsics/intrinsics');
|
||||
console.log('[GaussianViewer] Extrinsics:', JSON.stringify(extrinsics));
|
||||
console.log('[GaussianViewer] Intrinsics:', JSON.stringify(intrinsics));
|
||||
|
||||
// Parse extrinsics to get camera position
|
||||
// For a 4x4 extrinsics matrix [R|t], camera position in world space is -R^T * t
|
||||
// But for identity matrix, camera is simply at origin
|
||||
let camPosX = 0, camPosY = 0, camPosZ = 0;
|
||||
|
||||
if (extrinsics && Array.isArray(extrinsics) && extrinsics.length === 4) {
|
||||
// Extract rotation and translation from 4x4 extrinsics
|
||||
const R = [
|
||||
[extrinsics[0][0], extrinsics[0][1], extrinsics[0][2]],
|
||||
[extrinsics[1][0], extrinsics[1][1], extrinsics[1][2]],
|
||||
[extrinsics[2][0], extrinsics[2][1], extrinsics[2][2]]
|
||||
];
|
||||
const t = [extrinsics[0][3], extrinsics[1][3], extrinsics[2][3]];
|
||||
|
||||
// Camera position = -R^T * t
|
||||
camPosX = -(R[0][0] * t[0] + R[1][0] * t[1] + R[2][0] * t[2]);
|
||||
camPosY = -(R[0][1] * t[0] + R[1][1] * t[1] + R[2][1] * t[2]);
|
||||
camPosZ = -(R[0][2] * t[0] + R[1][2] * t[1] + R[2][2] * t[2]);
|
||||
|
||||
console.log('[GaussianViewer] Computed camera position from extrinsics:', camPosX, camPosY, camPosZ);
|
||||
}
|
||||
|
||||
// Calculate FOV and set camera focal length from intrinsics
|
||||
// intrinsics[0][0] = fx (focal length in pixels)
|
||||
// intrinsics[1][1] = fy (focal length in pixels)
|
||||
// intrinsics[1][2] = cy (principal point y = image_height / 2)
|
||||
let targetZ = 2; // default
|
||||
if (intrinsics && Array.isArray(intrinsics) && intrinsics.length >= 2) {
|
||||
const fx = intrinsics[0][0];
|
||||
const fy = intrinsics[1][1];
|
||||
const cx = intrinsics[0][2];
|
||||
const cy = intrinsics[1][2];
|
||||
const imageWidth = cx * 2; // cx is typically image_width / 2
|
||||
const imageHeight = cy * 2; // cy is typically image_height / 2
|
||||
|
||||
console.log('[GaussianViewer] Intrinsics: fx=', fx, 'fy=', fy, 'cx=', cx, 'cy=', cy);
|
||||
console.log('[GaussianViewer] Image dimensions (from intrinsics):', imageWidth, 'x', imageHeight);
|
||||
|
||||
// Determine if we should lock resolution to native image size
|
||||
// We lock resolution if intrinsics are provided to ensure "WYSIWYG" with high-res output
|
||||
isResolutionLocked = true;
|
||||
|
||||
// Get current container size for scale compensation calculation
|
||||
const containerWidth = canvas.clientWidth || 512;
|
||||
const containerHeight = canvas.clientHeight || 512;
|
||||
|
||||
// Lock renderer size to NATIVE image resolution
|
||||
console.log(`[GaussianViewer] Locking resolution to native: ${imageWidth}x${imageHeight}`);
|
||||
renderer.setSize(imageWidth, imageHeight);
|
||||
|
||||
// Ensure CSS keeps it fitted to container
|
||||
canvas.style.width = '100%';
|
||||
canvas.style.height = '100%';
|
||||
|
||||
// Use NATIVE focal length (no scaling down)
|
||||
camera.data.fx = fx;
|
||||
camera.data.fy = fy;
|
||||
|
||||
// IMPORTANT: Calculate scale compensation
|
||||
// gsplat.js renders points based on their world space size projected to screen space.
|
||||
// When we view a 2048x2048 image in a 512x512 container, we are effectively zooming out.
|
||||
// To make the points appear as "solid" as they do in the original training view,
|
||||
// we need to compensate for this difference.
|
||||
|
||||
// Empirical observation suggests we need a stronger compensation than just the ratio.
|
||||
// If user says Scale ~14 is good when ratio is ~4 (2048/512), then compensation might be squared ratio?
|
||||
// Or maybe the points from this trainer are just naturally small.
|
||||
// Let's stick to linear ratio for now but allow a base multiplier.
|
||||
|
||||
// For now, let's trust the ratio but maybe add a boost factor if needed.
|
||||
// User reported needing ~14x scale. If image is 2048 and container 512, ratio is 4.
|
||||
// 14 / 4 = 3.5. So maybe there's an inherent factor of 3-4 missing?
|
||||
// Actually, let's just apply the ratio logic strictly first:
|
||||
// If I render at 2048px width, a point of size 1 world unit covers X pixels.
|
||||
// If I render at 512px width, it covers X/4 pixels.
|
||||
// But visually, if I look at the 2048px image scaled down to 512px on my screen (via CSS),
|
||||
// the point covers X pixels of the *buffer*, which is X/4 pixels on *screen*.
|
||||
// Wait, if we render at high res, the points are physically "correct" size in pixels for that resolution.
|
||||
// The issue is likely that "correct" size is often too sparse for point clouds that rely on splatting overlap.
|
||||
|
||||
// Let's use the ratio, but ensure we aren't double counting.
|
||||
gaussianScaleCompensation = imageWidth / containerWidth;
|
||||
|
||||
// Add an extra boost because often raw splats are too small without the "dilation" effect of low-res rendering
|
||||
// When you render low-res, rasterization snaps to pixels, effectively making small points relatively larger.
|
||||
// High-res rendering loses this "free aliasing coverage".
|
||||
// Let's add a heuristic boost factor.
|
||||
// User said 14x was good. 14 / (2048/512) = 3.5.
|
||||
// Let's try boosting the compensation significantly.
|
||||
// gaussianScaleCompensation *= 3.0; // Boost factor
|
||||
|
||||
// Actually, let's change how we calculate this.
|
||||
// If the user wants "WYSIWYG" relative to the preview size:
|
||||
// We want the point to cover the same *percentage* of the screen width.
|
||||
// PointRadius / ImageWidth = Constant.
|
||||
// If we increase ImageWidth by factor S, PointRadius must increase by factor S.
|
||||
// This is what gaussianScaleCompensation = imageWidth / containerWidth does.
|
||||
|
||||
// However, the user reports it's still sparse. This implies the *base* size is too small.
|
||||
// Maybe the "preview" logic in other viewers implicitly boosts size?
|
||||
// Or maybe the training output relies on a specific focal length?
|
||||
|
||||
// Let's force a minimum compensation to avoid sparsity.
|
||||
// And let's default the UI scale slider to something higher if we detect high-res?
|
||||
// No, better to fix the compensation calculation.
|
||||
|
||||
// Let's try applying a stronger multiplier.
|
||||
// If resolution is locked, we are in "High Res Mode".
|
||||
// Let's scale up by the ratio AND a bit more.
|
||||
gaussianScaleCompensation = (imageWidth / containerWidth) * 3.0; // 3.0 is a heuristic "density boost"
|
||||
|
||||
console.log('[GaussianViewer] Native resolution:', imageWidth, 'x', imageHeight);
|
||||
console.log('[GaussianViewer] Container size:', containerWidth, 'x', containerHeight);
|
||||
console.log('[GaussianViewer] Gaussian scale compensation (with 3x boost):', gaussianScaleCompensation.toFixed(2), 'x');
|
||||
|
||||
// Calculate vertical FOV in radians for logging
|
||||
const fovY = 2 * Math.atan(imageHeight / (2 * fy));
|
||||
const fovYDeg = fovY * 180 / Math.PI;
|
||||
console.log('[GaussianViewer] Calculated FOV:', fovYDeg, 'degrees');
|
||||
|
||||
// For SHARP, the scene is in front of the camera
|
||||
// Use focal length to estimate a reasonable target distance
|
||||
// The disparity range in metadata can help estimate scene depth
|
||||
// For now, use a simple heuristic based on typical SHARP output
|
||||
targetZ = Math.max(1, fy / imageHeight * 2);
|
||||
console.log('[GaussianViewer] Target Z distance:', targetZ);
|
||||
}
|
||||
|
||||
// Determine the target position (what the camera looks at)
|
||||
// For the INITIAL view (matching original image), look STRAIGHT AHEAD along Z axis
|
||||
// NOT at the scene center - that would tilt the camera
|
||||
let targetX = 0, targetY = 0;
|
||||
let orbitZ = targetZ; // For orbiting, use a closer point
|
||||
if (splat && splat.bounds) {
|
||||
const center = splat.bounds.center();
|
||||
const size = splat.bounds.size();
|
||||
|
||||
// Use scene center Z for the view direction
|
||||
targetZ = center.z;
|
||||
|
||||
// For orbit center, use distance to closest object + 50%
|
||||
// Camera is at origin, so minZ is the distance to closest point
|
||||
const minZ = center.z - size.z / 2;
|
||||
orbitZ = minZ * 1.5; // 50% past the closest point
|
||||
|
||||
console.log('[GaussianViewer] Scene center:', center.x, -center.y, center.z);
|
||||
console.log('[GaussianViewer] Closest point Z:', minZ.toFixed(1));
|
||||
console.log('[GaussianViewer] Orbit center Z:', orbitZ.toFixed(1), '(closest + 50%)');
|
||||
}
|
||||
|
||||
// SHARP uses Y-down coordinate system, so flip camera Y as well
|
||||
camPosY = -camPosY;
|
||||
|
||||
// For SHARP: camera is at origin, scene is at positive Z
|
||||
// The orbit controls work by having the camera orbit around a target point.
|
||||
// To get the "original image view", we set camera at the computed position
|
||||
// and look at the scene center.
|
||||
|
||||
const camPos = new SPLAT.Vector3(camPosX, camPosY, camPosZ);
|
||||
// Use orbitZ for the target - closer to camera for better orbit feel
|
||||
const targetPos = new SPLAT.Vector3(targetX, targetY, orbitZ);
|
||||
|
||||
// Store for reset
|
||||
initialCameraData = {
|
||||
position: camPos,
|
||||
target: targetPos,
|
||||
fx: camera.data.fx,
|
||||
fy: camera.data.fy
|
||||
};
|
||||
|
||||
// Set camera position - IMPORTANT: must set camera.position, not camera.data.position
|
||||
// camera.position is used by OrbitControls
|
||||
// camera.data.position is used for rendering
|
||||
camera.position.x = camPosX;
|
||||
camera.position.y = camPosY;
|
||||
camera.position.z = camPosZ;
|
||||
|
||||
// Update orbit controls to orbit around the target point
|
||||
// This recalculates the internal orbit state from current camera.position
|
||||
controls.setCameraTarget(targetPos);
|
||||
|
||||
console.log('[GaussianViewer] Camera set to position:', camPosX, camPosY, camPosZ);
|
||||
console.log('[GaussianViewer] Looking at target:', targetX, targetY, targetZ);
|
||||
console.log('[GaussianViewer] This should match the original input image view');
|
||||
}
|
||||
|
||||
// Load a PLY file from ArrayBuffer data
|
||||
async function loadPLYFromData(arrayBuffer, filename, extrinsics, intrinsics) {
|
||||
try {
|
||||
// Reset lock state
|
||||
isResolutionLocked = false;
|
||||
|
||||
// Clear ALL existing objects from scene
|
||||
if (scene.objects && scene.objects.length > 0) {
|
||||
console.log('[GaussianViewer] Clearing', scene.objects.length, 'existing objects from scene');
|
||||
// Remove all objects (iterate backwards to avoid index issues)
|
||||
while (scene.objects.length > 0) {
|
||||
scene.removeObject(scene.objects[0]);
|
||||
}
|
||||
}
|
||||
currentSplat = null;
|
||||
|
||||
// Pre-calculate scale compensation from intrinsics BEFORE loading
|
||||
// This ensures gaussian scales are correct from the start
|
||||
gaussianScaleCompensation = 1.0; // Default if no intrinsics
|
||||
if (intrinsics && Array.isArray(intrinsics) && intrinsics.length >= 2) {
|
||||
const cx = intrinsics[0][2];
|
||||
const imageWidth = cx * 2;
|
||||
const containerWidth = canvas.clientWidth || 512;
|
||||
|
||||
// Base compensation: Ratio of Native / Display
|
||||
let ratio = imageWidth / containerWidth;
|
||||
|
||||
// Heuristic: Add a "Density Boost" for high-res rendering.
|
||||
// High-res rendering tends to make points look sparse because they don't overlap as much
|
||||
// as they do in lower resolutions (due to sub-pixel vs pixel rasterization effects).
|
||||
// We multiply by an extra factor to simulate the "fullness" of a lower-res view.
|
||||
// Based on user feedback, a factor of ~10.0 (total) is often needed for ideal density.
|
||||
const DENSITY_BOOST = 10.0;
|
||||
|
||||
gaussianScaleCompensation = ratio * DENSITY_BOOST;
|
||||
|
||||
console.log('[GaussianViewer] Pre-calculated scale compensation:', gaussianScaleCompensation.toFixed(2), 'x');
|
||||
console.log('[GaussianViewer] Ratio:', ratio.toFixed(2), 'Density Boost:', DENSITY_BOOST);
|
||||
console.log('[GaussianViewer] (Image:', imageWidth, 'px / Container:', containerWidth, 'px)');
|
||||
}
|
||||
|
||||
console.log('[GaussianViewer] Loading from data, size:', arrayBuffer.byteLength);
|
||||
|
||||
// Create a blob URL from the data
|
||||
const blob = new Blob([arrayBuffer], { type: 'application/octet-stream' });
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
|
||||
// Load the PLY file from blob URL using PLYLoader
|
||||
await SPLAT.PLYLoader.LoadAsync(blobUrl, scene);
|
||||
|
||||
// Clean up blob URL
|
||||
URL.revokeObjectURL(blobUrl);
|
||||
|
||||
// Get the loaded splat (last object in scene)
|
||||
if (scene.objects && scene.objects.length > 0) {
|
||||
currentSplat = scene.objects[scene.objects.length - 1];
|
||||
|
||||
// Log bounds info for debugging
|
||||
if (currentSplat.bounds) {
|
||||
const center = currentSplat.bounds.center();
|
||||
const size = currentSplat.bounds.size();
|
||||
console.log('[GaussianViewer] Scene bounds center:', center.x, center.y, center.z);
|
||||
console.log('[GaussianViewer] Scene bounds size:', size.x, size.y, size.z);
|
||||
}
|
||||
|
||||
// Store original scales and log statistics
|
||||
if (currentSplat.data && currentSplat.data.scales) {
|
||||
const scales = currentSplat.data.scales;
|
||||
const numGaussians = scales.length / 3;
|
||||
let minScale = Infinity, maxScale = -Infinity, sumScale = 0;
|
||||
|
||||
// Store a copy of original scales (BEFORE compensation) for dynamic adjustment
|
||||
// The stored scales include the compensation factor so user scale=1 shows correct size
|
||||
originalScales = new Float32Array(scales.length);
|
||||
for (let i = 0; i < scales.length; i++) {
|
||||
// Apply compensation: gaussians need to be larger when focal length is scaled down
|
||||
originalScales[i] = scales[i] * gaussianScaleCompensation;
|
||||
}
|
||||
console.log('[GaussianViewer] Stored compensated scales (factor:', gaussianScaleCompensation.toFixed(2), 'x)');
|
||||
|
||||
// Apply compensated scales immediately
|
||||
for (let i = 0; i < scales.length; i++) {
|
||||
scales[i] = originalScales[i];
|
||||
}
|
||||
|
||||
// Mark data as changed so gsplat.js re-uploads to GPU
|
||||
currentSplat.data.changed = true;
|
||||
|
||||
for (let i = 0; i < scales.length; i++) {
|
||||
const s = scales[i];
|
||||
minScale = Math.min(minScale, s);
|
||||
maxScale = Math.max(maxScale, s);
|
||||
sumScale += s;
|
||||
}
|
||||
|
||||
const avgScale = sumScale / scales.length;
|
||||
console.log('[GaussianViewer] === GAUSSIAN SCALE DEBUG (after compensation) ===');
|
||||
console.log('[GaussianViewer] Number of Gaussians:', numGaussians);
|
||||
console.log('[GaussianViewer] Compensated scale min:', minScale.toFixed(6));
|
||||
console.log('[GaussianViewer] Compensated scale max:', maxScale.toFixed(6));
|
||||
console.log('[GaussianViewer] Compensated scale avg:', avgScale.toFixed(6));
|
||||
console.log('[GaussianViewer] =================================================');
|
||||
} else {
|
||||
console.log('[GaussianViewer] No scales data found on splat object');
|
||||
console.log('[GaussianViewer] Splat properties:', Object.keys(currentSplat));
|
||||
if (currentSplat.data) {
|
||||
console.log('[GaussianViewer] Splat.data properties:', Object.keys(currentSplat.data));
|
||||
}
|
||||
originalScales = null;
|
||||
}
|
||||
|
||||
// Reset scale when loading new splat
|
||||
scaleInput.value = 1.0;
|
||||
currentScale = 1.0;
|
||||
}
|
||||
|
||||
// Set camera from extrinsics and intrinsics if provided
|
||||
if (extrinsics || intrinsics) {
|
||||
setCameraFromExtrinsics(extrinsics, intrinsics, currentSplat);
|
||||
if (controls) controls.update();
|
||||
}
|
||||
|
||||
// Show info panel
|
||||
infoPanel.classList.remove('hidden');
|
||||
infoContent.innerHTML = `<span style="color:#6cc;">Gaussian Splat Loaded</span><br><span style="color:#888;">${filename}</span>`;
|
||||
|
||||
// Notify parent
|
||||
window.parent.postMessage({
|
||||
type: 'MESH_LOADED',
|
||||
error: null,
|
||||
timestamp: Date.now()
|
||||
}, '*');
|
||||
|
||||
console.log('[GaussianViewer] Loaded successfully');
|
||||
|
||||
} catch (err) {
|
||||
console.error('[GaussianViewer] Load error:', err);
|
||||
showError('Failed to load PLY: ' + err.message);
|
||||
|
||||
window.parent.postMessage({
|
||||
type: 'MESH_ERROR',
|
||||
error: err.message,
|
||||
timestamp: Date.now()
|
||||
}, '*');
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPLYFromURL(url, filename, extrinsics, intrinsics) {
|
||||
try {
|
||||
// Reset lock state
|
||||
isResolutionLocked = false;
|
||||
|
||||
// Clear ALL existing objects from scene
|
||||
if (scene.objects && scene.objects.length > 0) {
|
||||
console.log('[GaussianViewer] Clearing', scene.objects.length, 'existing objects from scene');
|
||||
while (scene.objects.length > 0) {
|
||||
scene.removeObject(scene.objects[0]);
|
||||
}
|
||||
}
|
||||
currentSplat = null;
|
||||
|
||||
// Pre-calculate scale compensation from intrinsics BEFORE loading
|
||||
gaussianScaleCompensation = 1.0;
|
||||
if (intrinsics && Array.isArray(intrinsics) && intrinsics.length >= 2) {
|
||||
const cx = intrinsics[0][2];
|
||||
const imageWidth = cx * 2;
|
||||
const containerWidth = canvas.clientWidth || 512;
|
||||
let ratio = imageWidth / containerWidth;
|
||||
const DENSITY_BOOST = 10.0;
|
||||
gaussianScaleCompensation = ratio * DENSITY_BOOST;
|
||||
console.log('[GaussianViewer] Pre-calculated scale compensation:', gaussianScaleCompensation.toFixed(2), 'x');
|
||||
}
|
||||
|
||||
console.log('[GaussianViewer] Loading from URL:', url);
|
||||
|
||||
// Load PLY directly from URL — gsplat.js streams it
|
||||
await SPLAT.PLYLoader.LoadAsync(url, scene);
|
||||
|
||||
// Get the loaded splat (last object in scene)
|
||||
if (scene.objects && scene.objects.length > 0) {
|
||||
currentSplat = scene.objects[scene.objects.length - 1];
|
||||
|
||||
if (currentSplat.bounds) {
|
||||
const center = currentSplat.bounds.center();
|
||||
const size = currentSplat.bounds.size();
|
||||
console.log('[GaussianViewer] Scene bounds center:', center.x, center.y, center.z);
|
||||
console.log('[GaussianViewer] Scene bounds size:', size.x, size.y, size.z);
|
||||
}
|
||||
|
||||
// Store original scales and apply compensation
|
||||
if (currentSplat.data && currentSplat.data.scales) {
|
||||
const scales = currentSplat.data.scales;
|
||||
const numGaussians = scales.length / 3;
|
||||
|
||||
originalScales = new Float32Array(scales.length);
|
||||
for (let i = 0; i < scales.length; i++) {
|
||||
originalScales[i] = scales[i] * gaussianScaleCompensation;
|
||||
}
|
||||
|
||||
for (let i = 0; i < scales.length; i++) {
|
||||
scales[i] = originalScales[i];
|
||||
}
|
||||
currentSplat.data.changed = true;
|
||||
|
||||
console.log('[GaussianViewer] Applied scale compensation:', gaussianScaleCompensation.toFixed(2), 'x,', numGaussians, 'gaussians');
|
||||
} else {
|
||||
originalScales = null;
|
||||
}
|
||||
|
||||
scaleInput.value = 1.0;
|
||||
currentScale = 1.0;
|
||||
}
|
||||
|
||||
// Set camera from extrinsics and intrinsics if provided
|
||||
if (extrinsics || intrinsics) {
|
||||
setCameraFromExtrinsics(extrinsics, intrinsics, currentSplat);
|
||||
if (controls) controls.update();
|
||||
}
|
||||
|
||||
infoPanel.classList.remove('hidden');
|
||||
infoContent.innerHTML = `<span style="color:#6cc;">Gaussian Splat Loaded</span><br><span style="color:#888;">${filename}</span>`;
|
||||
|
||||
window.parent.postMessage({
|
||||
type: 'MESH_LOADED',
|
||||
error: null,
|
||||
timestamp: Date.now()
|
||||
}, '*');
|
||||
|
||||
console.log('[GaussianViewer] Loaded successfully from URL');
|
||||
|
||||
} catch (err) {
|
||||
console.error('[GaussianViewer] Load error:', err);
|
||||
showError('Failed to load PLY: ' + err.message);
|
||||
|
||||
window.parent.postMessage({
|
||||
type: 'MESH_ERROR',
|
||||
error: err.message,
|
||||
timestamp: Date.now()
|
||||
}, '*');
|
||||
}
|
||||
}
|
||||
|
||||
// Reset camera to initial position
|
||||
function resetCamera() {
|
||||
if (camera && controls) {
|
||||
if (initialCameraData) {
|
||||
// Set camera.position (used by OrbitControls), not camera.data.position
|
||||
const pos = initialCameraData.position;
|
||||
camera.position.x = pos.x;
|
||||
camera.position.y = pos.y;
|
||||
camera.position.z = pos.z;
|
||||
|
||||
// Restore focal lengths
|
||||
if (initialCameraData.fx) {
|
||||
camera.data.fx = initialCameraData.fx;
|
||||
}
|
||||
if (initialCameraData.fy) {
|
||||
camera.data.fy = initialCameraData.fy;
|
||||
}
|
||||
|
||||
// Set the target - this recalculates orbit state from current camera.position
|
||||
if (initialCameraData.target) {
|
||||
controls.setCameraTarget(initialCameraData.target);
|
||||
}
|
||||
|
||||
console.log('[GaussianViewer] Reset camera to position:', pos.x, pos.y, pos.z);
|
||||
console.log('[GaussianViewer] Reset camera target:', initialCameraData.target?.x, initialCameraData.target?.y, initialCameraData.target?.z);
|
||||
} else {
|
||||
// Default fallback
|
||||
camera.position.x = 0;
|
||||
camera.position.y = 0;
|
||||
camera.position.z = 5;
|
||||
console.log('[GaussianViewer] Reset camera to default position (0, 0, 5)');
|
||||
}
|
||||
controls.update();
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to draw black background behind transparent canvas for screenshot
|
||||
// This solves the issue where transparent parts of the gaussian render appear weirdly when pasted
|
||||
// or viewed in tools that don't handle premultiplied alpha correctly, or simply when the
|
||||
// viewer background (CSS) was providing the black color but the canvas itself was transparent.
|
||||
function getCanvasWithBlackBackground(sourceCanvas) {
|
||||
const tempCanvas = document.createElement('canvas');
|
||||
tempCanvas.width = sourceCanvas.width;
|
||||
tempCanvas.height = sourceCanvas.height;
|
||||
const ctx = tempCanvas.getContext('2d');
|
||||
|
||||
// Fill with black first
|
||||
ctx.fillStyle = '#000000';
|
||||
ctx.fillRect(0, 0, tempCanvas.width, tempCanvas.height);
|
||||
|
||||
// Draw original canvas on top
|
||||
ctx.drawImage(sourceCanvas, 0, 0);
|
||||
|
||||
return tempCanvas;
|
||||
}
|
||||
|
||||
// Take screenshot directly from canvas
|
||||
// Avoiding captureStream() because it can introduce artifacts or capture inter-frame states
|
||||
// Since we are now rendering at full resolution (WYSIWYG), toDataURL is the most accurate representation
|
||||
async function takeScreenshot() {
|
||||
if (!renderer || !renderer.canvas) return;
|
||||
|
||||
try {
|
||||
const canvas = renderer.canvas;
|
||||
const width = canvas.width;
|
||||
const height = canvas.height;
|
||||
|
||||
// Pause the animation loop to ensure we capture a stable frame
|
||||
if (animationId) cancelAnimationFrame(animationId);
|
||||
|
||||
// Force a single synchronous render to ensure the buffer is populated exactly as expected
|
||||
if (scene && camera) {
|
||||
controls.update();
|
||||
renderer.render(scene, camera);
|
||||
}
|
||||
|
||||
console.log('[GaussianViewer] Capturing screenshot via toDataURL:', width, 'x', height);
|
||||
|
||||
// Composite with black background to ensure consistency with preview
|
||||
// The preview looks black because of CSS background, but the canvas might be transparent
|
||||
const finalCanvas = getCanvasWithBlackBackground(canvas);
|
||||
const dataUrl = finalCanvas.toDataURL('image/png');
|
||||
|
||||
window.parent.postMessage({
|
||||
type: 'SCREENSHOT',
|
||||
image: dataUrl,
|
||||
timestamp: Date.now()
|
||||
}, '*');
|
||||
|
||||
resumeAnimationLoop();
|
||||
console.log('[GaussianViewer] Screenshot captured successfully');
|
||||
} catch (err) {
|
||||
console.error('[GaussianViewer] Screenshot error:', err);
|
||||
resumeAnimationLoop();
|
||||
}
|
||||
}
|
||||
|
||||
// Copy image to clipboard directly from canvas
|
||||
async function copyImageToClipboard() {
|
||||
if (!renderer || !renderer.canvas) return;
|
||||
|
||||
try {
|
||||
const canvas = renderer.canvas;
|
||||
const width = canvas.width;
|
||||
const height = canvas.height;
|
||||
|
||||
// Pause the animation loop
|
||||
if (animationId) cancelAnimationFrame(animationId);
|
||||
|
||||
// Force a single synchronous render
|
||||
if (scene && camera) {
|
||||
controls.update();
|
||||
renderer.render(scene, camera);
|
||||
}
|
||||
|
||||
console.log('[GaussianViewer] Copying to clipboard via toBlob:', width, 'x', height);
|
||||
|
||||
// Composite with black background
|
||||
const finalCanvas = getCanvasWithBlackBackground(canvas);
|
||||
|
||||
// Direct blob creation from composited canvas
|
||||
const blob = await canvasToBlob(finalCanvas);
|
||||
|
||||
try {
|
||||
await navigator.clipboard.write([
|
||||
new ClipboardItem({ 'image/png': blob })
|
||||
]);
|
||||
console.log('[GaussianViewer] Image copied to clipboard:', width, 'x', height);
|
||||
|
||||
window.parent.postMessage({
|
||||
type: 'COPY_IMAGE',
|
||||
success: true,
|
||||
timestamp: Date.now()
|
||||
}, '*');
|
||||
} catch (clipboardError) {
|
||||
console.error('[GaussianViewer] Clipboard write failed:', clipboardError);
|
||||
window.parent.postMessage({
|
||||
type: 'COPY_IMAGE',
|
||||
success: false,
|
||||
error: 'Clipboard write failed: ' + clipboardError.message,
|
||||
timestamp: Date.now()
|
||||
}, '*');
|
||||
}
|
||||
|
||||
resumeAnimationLoop();
|
||||
} catch (err) {
|
||||
console.error('[GaussianViewer] Copy to clipboard error:', err);
|
||||
resumeAnimationLoop();
|
||||
window.parent.postMessage({
|
||||
type: 'COPY_IMAGE',
|
||||
success: false,
|
||||
error: err.message,
|
||||
timestamp: Date.now()
|
||||
}, '*');
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to convert canvas to blob
|
||||
function canvasToBlob(canvas) {
|
||||
return new Promise((resolve, reject) => {
|
||||
canvas.toBlob(blob => {
|
||||
if (blob) {
|
||||
resolve(blob);
|
||||
} else {
|
||||
reject(new Error('Canvas toBlob failed'));
|
||||
}
|
||||
}, 'image/png');
|
||||
});
|
||||
}
|
||||
|
||||
// Gaussian scale control
|
||||
const scaleInput = document.getElementById('gaussianScaleValue');
|
||||
let currentScale = 1.0;
|
||||
let scaleUpdatePending = false;
|
||||
let lastScaleValue = 1.0;
|
||||
|
||||
function updateGaussianScale(newScale) {
|
||||
newScale = Math.max(0.01, Math.min(100, parseFloat(newScale) || 1.0));
|
||||
currentScale = newScale;
|
||||
|
||||
// Update input
|
||||
scaleInput.value = newScale;
|
||||
|
||||
// Apply scale to current splat by modifying the underlying scale data
|
||||
// gsplat.js applies Math.exp() during PLY loading, so data.scales contains LINEAR values
|
||||
// To multiply visual size by factor f, we multiply the scale values directly
|
||||
if (currentSplat && currentSplat.data && currentSplat.data.scales && originalScales) {
|
||||
console.log('[GaussianViewer] Updating gaussian scales with factor:', newScale);
|
||||
|
||||
const scales = currentSplat.data.scales;
|
||||
|
||||
// Apply scale factor by multiplying (scales are in linear space)
|
||||
for (let i = 0; i < scales.length; i++) {
|
||||
scales[i] = originalScales[i] * newScale;
|
||||
}
|
||||
|
||||
// Mark data as changed to trigger GPU re-upload
|
||||
// gsplat.js checks data.changed and data.detached
|
||||
currentSplat.data.changed = true;
|
||||
currentSplat.data.detached = false;
|
||||
console.log('[GaussianViewer] Set data.changed=true, data.detached=false');
|
||||
|
||||
// Get the render data (could be _renderData or renderData)
|
||||
const renderData = renderer._renderData || renderer.renderData;
|
||||
if (renderData) {
|
||||
console.log('[GaussianViewer] Found renderData, keys:', Object.keys(renderData).join(', '));
|
||||
|
||||
// Mark the splat as dirty
|
||||
if (renderData.markDirty) {
|
||||
renderData.markDirty(currentSplat);
|
||||
console.log('[GaussianViewer] Called markDirty()');
|
||||
}
|
||||
|
||||
// Try forcing the flags directly
|
||||
renderData.dataChanged = true;
|
||||
console.log('[GaussianViewer] Set renderData.dataChanged = true');
|
||||
|
||||
// Check needsRebuild status
|
||||
console.log('[GaussianViewer] renderData.needsRebuild:', renderData.needsRebuild);
|
||||
console.log('[GaussianViewer] renderData._updating size:', renderData._updating?.size);
|
||||
console.log('[GaussianViewer] renderData._dirty size:', renderData._dirty?.size);
|
||||
} else {
|
||||
console.log('[GaussianViewer] No renderData found on renderer');
|
||||
console.log('[GaussianViewer] renderer keys:', Object.keys(renderer).join(', '));
|
||||
}
|
||||
|
||||
// Also dispatch change event (triggers scene to pick up changes)
|
||||
if (currentSplat.dispatchEvent && currentSplat._changeEvent) {
|
||||
currentSplat.dispatchEvent(currentSplat._changeEvent);
|
||||
console.log('[GaussianViewer] Dispatched change event');
|
||||
}
|
||||
|
||||
// Throttled force refresh by removing and re-adding the splat to scene
|
||||
// This is a workaround for gsplat.js not supporting dynamic scale updates
|
||||
if (scene && scene.removeObject && scene.addObject) {
|
||||
lastScaleValue = newScale; // Always update to latest value
|
||||
|
||||
if (!scaleUpdatePending) {
|
||||
scaleUpdatePending = true;
|
||||
const splatToRefresh = currentSplat; // Capture reference
|
||||
|
||||
// Delay the refresh to avoid flickering during slider drag
|
||||
setTimeout(() => {
|
||||
scaleUpdatePending = false;
|
||||
|
||||
// Only refresh if splat is still current
|
||||
if (splatToRefresh === currentSplat) {
|
||||
console.log('[GaussianViewer] Forcing refresh by re-adding splat to scene');
|
||||
scene.removeObject(splatToRefresh);
|
||||
scene.addObject(splatToRefresh);
|
||||
console.log('[GaussianViewer] Splat re-added to scene');
|
||||
} else {
|
||||
console.log('[GaussianViewer] Skipping refresh, splat changed during delay');
|
||||
}
|
||||
}, 150); // 150ms debounce for smoother dragging
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[GaussianViewer] Scale update complete. New scale[0]:', scales[0].toFixed(6),
|
||||
'original[0]:', originalScales[0].toFixed(6), 'multiplier:', newScale);
|
||||
} else if (currentSplat) {
|
||||
console.log('[GaussianViewer] Cannot update scales - missing data or originalScales');
|
||||
console.log('[GaussianViewer] currentSplat.data:', !!currentSplat.data);
|
||||
console.log('[GaussianViewer] currentSplat.data.scales:', !!(currentSplat.data && currentSplat.data.scales));
|
||||
console.log('[GaussianViewer] originalScales:', !!originalScales);
|
||||
}
|
||||
}
|
||||
|
||||
scaleInput.addEventListener('change', (e) => updateGaussianScale(e.target.value));
|
||||
|
||||
// Event listeners
|
||||
document.getElementById('resetCamera').addEventListener('click', resetCamera);
|
||||
document.getElementById('screenshot').addEventListener('click', takeScreenshot);
|
||||
document.getElementById('copyImage').addEventListener('click', copyImageToClipboard);
|
||||
|
||||
// Listen for messages from parent
|
||||
window.addEventListener('message', (event) => {
|
||||
const { type, data, url, filename, extrinsics, intrinsics } = event.data;
|
||||
|
||||
if (type === 'LOAD_MESH_URL' && url) {
|
||||
console.log('[GaussianViewer] Received LOAD_MESH_URL:', url);
|
||||
loadPLYFromURL(url, filename || 'gaussian.ply', extrinsics, intrinsics);
|
||||
} else if (type === 'LOAD_MESH_DATA' && data) {
|
||||
console.log('[GaussianViewer] Received LOAD_MESH_DATA, size:', data.byteLength);
|
||||
loadPLYFromData(data, filename || 'gaussian.ply', extrinsics, intrinsics);
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize on load
|
||||
initViewer();
|
||||
|
||||
console.log('[GaussianViewer] Ready - waiting for LOAD_MESH message');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,476 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>GeomPack VTK.js Multi Mesh Slider Viewer</title>
|
||||
<style>
|
||||
body { margin: 0; overflow: hidden; background: #2a2a2a; font-family: monospace;
|
||||
display: flex; flex-direction: column; height: 100vh; }
|
||||
#viewerContainer { flex: 1; position: relative; min-height: 0; }
|
||||
.render-layer { position: absolute; top: 0; left: 0; width: 100%; height: 100%; }
|
||||
#loading { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);
|
||||
color: white; font-size: 14px; text-align: center; z-index: 1000; pointer-events: none; }
|
||||
#sliderOverlay { position: absolute; top: 0; left: 0; width: 100%; height: 100%;
|
||||
pointer-events: none; z-index: 100; }
|
||||
.slider-line { position: absolute; top: 0; width: 2px; height: 100%; background: #00e0ff;
|
||||
pointer-events: auto; cursor: ew-resize; box-shadow: 0 0 8px rgba(0,224,255,0.5); }
|
||||
.slider-handle { position: absolute; width: 20px; height: 20px; background: #00e0ff;
|
||||
border: 2px solid white; border-radius: 50%; transform: translate(-50%,-50%);
|
||||
pointer-events: auto; cursor: ew-resize; transition: transform 0.1s, box-shadow 0.1s;
|
||||
box-shadow: 0 0 8px rgba(0,224,255,0.5); }
|
||||
.slider-handle:hover, .slider-handle.dragging {
|
||||
transform: translate(-50%,-50%) scale(1.2); box-shadow: 0 0 15px rgba(0,224,255,0.8); }
|
||||
.mesh-label { position: absolute; top: 8px; color: white; font-size: 13px; font-weight: bold;
|
||||
text-shadow: 2px 2px 4px black; pointer-events: none; background: rgba(0,0,0,0.6);
|
||||
padding: 4px 10px; border-radius: 4px; z-index: 101; transform: translateX(-50%); white-space: nowrap; }
|
||||
#controls { background: #1a1a1a; border-top: 1px solid #444; padding: 8px 12px; display: flex;
|
||||
gap: 12px; align-items: center; flex-wrap: wrap; font-size: 12px; color: #ccc;
|
||||
min-height: 48px; overflow-x: auto; }
|
||||
#controls button, #controls select { background: #333; color: #ccc; border: 1px solid #555;
|
||||
padding: 4px 8px; border-radius: 3px; font-size: 12px; font-family: monospace; cursor: pointer; }
|
||||
#controls button:hover { background: #444; }
|
||||
#controls label { display: flex; align-items: center; gap: 4px; }
|
||||
.divider { width: 1px; height: 20px; background: #444; margin: 0 8px; }
|
||||
#settingsPanel { position: absolute; top: 50%; left: 50%; transform: translate(-50%,-50%);
|
||||
background: #1a1a1a; border: 1px solid #555; border-radius: 6px; padding: 16px; z-index: 2000;
|
||||
display: none; min-width: 280px; max-height: 80%; overflow-y: auto; box-shadow: 0 4px 12px rgba(0,0,0,0.5); }
|
||||
#settingsPanel.visible { display: block; }
|
||||
#settingsPanel h3 { margin: 0 0 12px 0; color: #ccc; font-size: 14px; font-weight: normal;
|
||||
border-bottom: 1px solid #333; padding-bottom: 8px; }
|
||||
#settingsPanel .setting-row { display: flex; align-items: center; gap: 12px; margin-bottom: 12px; }
|
||||
#settingsPanel .setting-row label { flex: 0 0 100px; color: #ccc; font-size: 12px; }
|
||||
#settingsPanel .setting-row input, #settingsPanel .setting-row select { flex: 1; background: #333;
|
||||
color: #ccc; border: 1px solid #555; padding: 6px 8px; border-radius: 3px; font-size: 12px; font-family: monospace; }
|
||||
#settingsPanel .button-row { display: flex; gap: 8px; margin-top: 16px; padding-top: 12px; border-top: 1px solid #333; }
|
||||
#settingsPanel .button-row button { flex: 1; padding: 6px 12px; font-size: 12px; }
|
||||
#settingsOverlay { position: absolute; top: 0; left: 0; width: 100%; height: 100%;
|
||||
background: rgba(0,0,0,0.5); z-index: 1999; display: none; }
|
||||
#settingsOverlay.visible { display: block; }
|
||||
#settingsButton { background: #333; width: 32px; height: 32px; padding: 0; display: flex;
|
||||
align-items: center; justify-content: center; font-size: 16px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="viewerContainer">
|
||||
<!-- render layers + slider overlay are created dynamically -->
|
||||
<div id="sliderOverlay"></div>
|
||||
<div id="loading">Ready to load meshes</div>
|
||||
|
||||
<div id="settingsOverlay"></div>
|
||||
<div id="settingsPanel">
|
||||
<h3>Appearance Settings</h3>
|
||||
<div class="setting-row"><label>Point Size:</label><input type="number" id="pointSize" min="1" max="20" step="1" value="10"></div>
|
||||
<div class="setting-row"><label>Edge Width:</label><input type="number" id="edgeWidth" min="1" max="10" step="1" value="1"></div>
|
||||
<div class="setting-row"><label>Render Mode:</label>
|
||||
<select id="representationSettings"><option value="2">Surface</option><option value="1">Wireframe</option><option value="0">Points</option></select></div>
|
||||
<div class="setting-row"><label><input type="checkbox" id="parallelProjectionSetting"> Parallel Projection</label></div>
|
||||
<div class="button-row"><button id="applySettings">Apply</button><button id="cancelSettings">Cancel</button></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="controls">
|
||||
<button id="settingsButton" title="Appearance Settings">[Settings]</button>
|
||||
<span class="divider"></span>
|
||||
<label><input type="checkbox" id="showEdges"> Edges</label>
|
||||
<span class="divider"></span>
|
||||
<button id="viewPosX">+X</button><button id="viewNegX">-X</button>
|
||||
<button id="viewPosY">+Y</button><button id="viewNegY">-Y</button>
|
||||
<button id="viewPosZ">+Z</button><button id="viewNegZ">-Z</button>
|
||||
<span class="divider"></span>
|
||||
<button id="resetCamera">Reset Camera</button>
|
||||
<button id="resetSlider">Reset Sliders</button>
|
||||
</div>
|
||||
|
||||
<script src="./js/vtk-gltf.js"></script>
|
||||
<script src="./js/viewer-bundle-vtk.js"></script>
|
||||
|
||||
<script>
|
||||
const vtkFullScreenRenderWindow = vtk.Rendering.Misc.vtkFullScreenRenderWindow;
|
||||
const vtkActor = vtk.Rendering.Core.vtkActor;
|
||||
const vtkMapper = vtk.Rendering.Core.vtkMapper;
|
||||
const vtkSTLReader = vtk.IO.Geometry.vtkSTLReader;
|
||||
const vtkOBJReader = vtk.IO.Misc.vtkOBJReader;
|
||||
const vtkXMLPolyDataReader = vtk.IO.XML.vtkXMLPolyDataReader;
|
||||
const vtkPolyDataNormals = vtk.Filters.Core.vtkPolyDataNormals;
|
||||
const vtkInteractorStyleTrackballCamera = vtk.Interaction.Style.vtkInteractorStyleTrackballCamera;
|
||||
const vtkGLTFImporter = vtk.IO.Geometry.vtkGLTFImporter;
|
||||
|
||||
console.log('[GeomPack Multi Slider] Initializing...');
|
||||
|
||||
const loading = document.getElementById('loading');
|
||||
const viewerContainer = document.getElementById('viewerContainer');
|
||||
const sliderOverlay = document.getElementById('sliderOverlay');
|
||||
|
||||
// distinct default colors per layer
|
||||
const PALETTE = [
|
||||
[0.30, 0.50, 0.95], // blue
|
||||
[0.30, 0.85, 0.40], // green
|
||||
[0.95, 0.65, 0.25], // orange
|
||||
[0.85, 0.40, 0.85], // magenta
|
||||
];
|
||||
|
||||
// per-layer arrays
|
||||
let N = 0;
|
||||
let containers = [], fsRenderers = [], renderers = [], renderWindows = [], interactors = [];
|
||||
let actors = [], mappers = [], boundsArr = [], gltfActorsArr = [], polydataArr = [];
|
||||
let combinedBounds = null;
|
||||
let positions = []; // N-1 divider positions in [0,1], strictly increasing
|
||||
let sliderLines = [], sliderHandles = [], bandLabels = [];
|
||||
let dragIndex = -1;
|
||||
const EPS = 0.02;
|
||||
|
||||
// camera sync
|
||||
let animatingIndex = -1, isSyncing = false;
|
||||
let _cameraWheelTimer = null;
|
||||
|
||||
function teardown() {
|
||||
fsRenderers.forEach(r => { try { r.delete(); } catch (e) {} });
|
||||
containers.forEach(c => c.remove());
|
||||
sliderOverlay.innerHTML = '';
|
||||
containers = []; fsRenderers = []; renderers = []; renderWindows = []; interactors = [];
|
||||
actors = []; mappers = []; boundsArr = []; gltfActorsArr = []; polydataArr = [];
|
||||
sliderLines = []; sliderHandles = []; bandLabels = []; combinedBounds = null;
|
||||
}
|
||||
|
||||
// Build DOM layers + dividers for n meshes
|
||||
function buildLayers(n) {
|
||||
teardown();
|
||||
N = n;
|
||||
for (let i = 0; i < n; i++) {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'render-layer';
|
||||
div.id = `meshContainer${i}`;
|
||||
div.style.zIndex = (i + 1).toString();
|
||||
viewerContainer.insertBefore(div, sliderOverlay);
|
||||
containers.push(div);
|
||||
}
|
||||
// N-1 dividers
|
||||
for (let j = 0; j < n - 1; j++) {
|
||||
const line = document.createElement('div'); line.className = 'slider-line';
|
||||
const handle = document.createElement('div'); handle.className = 'slider-handle';
|
||||
line.dataset.idx = j; handle.dataset.idx = j;
|
||||
sliderOverlay.appendChild(line); sliderOverlay.appendChild(handle);
|
||||
sliderLines.push(line); sliderHandles.push(handle);
|
||||
line.addEventListener('mousedown', (e) => startDrag(e, j));
|
||||
handle.addEventListener('mousedown', (e) => startDrag(e, j));
|
||||
line.addEventListener('touchstart', (e) => startDrag(e, j));
|
||||
handle.addEventListener('touchstart', (e) => startDrag(e, j));
|
||||
}
|
||||
// N band labels
|
||||
for (let i = 0; i < n; i++) {
|
||||
const lab = document.createElement('div'); lab.className = 'mesh-label';
|
||||
lab.textContent = `Mesh ${i + 1}`;
|
||||
sliderOverlay.appendChild(lab); bandLabels.push(lab);
|
||||
}
|
||||
// default evenly-spaced dividers
|
||||
positions = [];
|
||||
for (let j = 1; j < n; j++) positions.push(j / n);
|
||||
}
|
||||
|
||||
function initViewers(n) {
|
||||
for (let i = 0; i < n; i++) {
|
||||
const fsr = vtkFullScreenRenderWindow.newInstance({
|
||||
background: [0.16, 0.16, 0.16], container: containers[i] });
|
||||
const renderer = fsr.getRenderer();
|
||||
const rw = fsr.getRenderWindow();
|
||||
const interactor = rw.getInteractor();
|
||||
interactor.setInteractorStyle(vtkInteractorStyleTrackballCamera.newInstance());
|
||||
|
||||
const actor = vtkActor.newInstance();
|
||||
const mapper = vtkMapper.newInstance();
|
||||
actor.setMapper(mapper);
|
||||
const prop = actor.getProperty();
|
||||
const c = PALETTE[i % PALETTE.length];
|
||||
prop.setColor(c[0], c[1], c[2]);
|
||||
prop.setEdgeVisibility(false);
|
||||
prop.setAmbient(0.4); prop.setDiffuse(0.8); prop.setSpecular(0.3); prop.setSpecularPower(20);
|
||||
renderer.addActor(actor);
|
||||
|
||||
fsRenderers.push(fsr); renderers.push(renderer); renderWindows.push(rw);
|
||||
interactors.push(interactor); actors.push(actor); mappers.push(mapper);
|
||||
boundsArr.push(null); gltfActorsArr.push(null); polydataArr.push(null);
|
||||
fsr.resize();
|
||||
}
|
||||
setupCameraSync();
|
||||
console.log(`[GeomPack Multi Slider] ${n} viewers initialized`);
|
||||
}
|
||||
|
||||
function syncFrom(srcIdx) {
|
||||
if (isSyncing) return;
|
||||
isSyncing = true;
|
||||
const src = renderers[srcIdx].getActiveCamera();
|
||||
for (let i = 0; i < N; i++) {
|
||||
if (i === srcIdx) continue;
|
||||
const cam = renderers[i].getActiveCamera();
|
||||
cam.setPosition(...src.getPosition());
|
||||
cam.setFocalPoint(...src.getFocalPoint());
|
||||
cam.setViewUp(...src.getViewUp());
|
||||
cam.setParallelProjection(src.getParallelProjection());
|
||||
cam.setClippingRange(...src.getClippingRange());
|
||||
cam.setParallelScale(src.getParallelScale());
|
||||
cam.setViewAngle(src.getViewAngle());
|
||||
renderers[i].resetCameraClippingRange();
|
||||
renderWindows[i].render();
|
||||
}
|
||||
isSyncing = false;
|
||||
}
|
||||
|
||||
function sendCameraState() {
|
||||
const cam = renderers[0]?.getActiveCamera();
|
||||
if (!cam) return;
|
||||
window.parent.postMessage({ type: 'WIDGET_UPDATE', widget: 'camera_state',
|
||||
value: JSON.stringify({ position: cam.getPosition(), focalPoint: cam.getFocalPoint(), viewUp: cam.getViewUp() }) }, '*');
|
||||
}
|
||||
|
||||
function setupCameraSync() {
|
||||
for (let i = 0; i < N; i++) {
|
||||
const idx = i;
|
||||
interactors[idx].onStartAnimation(() => { animatingIndex = idx; });
|
||||
interactors[idx].onAnimation(() => { if (animatingIndex === idx) syncFrom(idx); });
|
||||
interactors[idx].onEndAnimation(() => {
|
||||
if (animatingIndex === idx) { animatingIndex = -1; syncFrom(idx); sendCameraState(); }
|
||||
});
|
||||
}
|
||||
setTimeout(() => {
|
||||
for (let i = 0; i < N; i++) {
|
||||
const idx = i;
|
||||
const canvas = containers[idx].querySelector('canvas');
|
||||
if (!canvas) continue;
|
||||
canvas.addEventListener('wheel', () => {
|
||||
setTimeout(() => syncFrom(idx), 0);
|
||||
clearTimeout(_cameraWheelTimer);
|
||||
_cameraWheelTimer = setTimeout(sendCameraState, 150);
|
||||
});
|
||||
}
|
||||
}, 500);
|
||||
}
|
||||
|
||||
async function loadMesh(filepath, idx) {
|
||||
console.log(`[GeomPack Multi Slider] Loading mesh ${idx + 1}:`, filepath);
|
||||
const isSTL = filepath.includes('.stl');
|
||||
const isOBJ = filepath.includes('.obj');
|
||||
const isVTP = filepath.includes('.vtp');
|
||||
const isGLB = filepath.includes('.glb');
|
||||
|
||||
if (isGLB) {
|
||||
const targetRenderer = renderers[idx];
|
||||
const prev = gltfActorsArr[idx];
|
||||
if (prev) prev.forEach(a => targetRenderer.removeActor(a));
|
||||
const importer = vtkGLTFImporter.newInstance();
|
||||
importer.setRenderer(targetRenderer);
|
||||
importer.setUrl(filepath);
|
||||
await new Promise((resolve, reject) => { importer.onReady(() => resolve()); importer.loadData().catch(reject); });
|
||||
importer.importActors();
|
||||
const acts = Array.from(importer.getActors().values());
|
||||
if (acts.length === 0) throw new Error('No actors found in GLTF');
|
||||
targetRenderer.removeActor(actors[idx]);
|
||||
let bounds = null;
|
||||
acts.forEach(actor => {
|
||||
const mapper = actor.getMapper(); const property = actor.getProperty(); const input = mapper?.getInputData();
|
||||
if (input) {
|
||||
const ab = actor.getBounds();
|
||||
if (!bounds) bounds = [...ab];
|
||||
else { bounds[0]=Math.min(bounds[0],ab[0]); bounds[1]=Math.max(bounds[1],ab[1]); bounds[2]=Math.min(bounds[2],ab[2]); bounds[3]=Math.max(bounds[3],ab[3]); bounds[4]=Math.min(bounds[4],ab[4]); bounds[5]=Math.max(bounds[5],ab[5]); }
|
||||
const scalars = input.getPointData()?.getScalars();
|
||||
if (scalars && scalars.getNumberOfComponents() >= 3) {
|
||||
mapper.setScalarVisibility(true); mapper.setScalarModeToUsePointData(); mapper.setColorModeToDirectScalars();
|
||||
}
|
||||
if (property) { if (property.setMetallic) property.setMetallic(0.0); if (property.setRoughness) property.setRoughness(1.0); }
|
||||
}
|
||||
});
|
||||
boundsArr[idx] = bounds; polydataArr[idx] = null; gltfActorsArr[idx] = acts;
|
||||
return;
|
||||
}
|
||||
|
||||
let reader;
|
||||
if (isSTL) reader = vtkSTLReader.newInstance();
|
||||
else if (isOBJ) reader = vtkOBJReader.newInstance();
|
||||
else if (isVTP) reader = vtkXMLPolyDataReader.newInstance();
|
||||
else throw new Error(`Unsupported file format: ${filepath}`);
|
||||
|
||||
const response = await fetch(filepath);
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
if (isSTL) reader.parseAsArrayBuffer(arrayBuffer);
|
||||
else if (isOBJ) reader.parseAsText(new TextDecoder().decode(arrayBuffer));
|
||||
else if (isVTP) reader.parseAsArrayBuffer(arrayBuffer);
|
||||
|
||||
const polydata = reader.getOutputData();
|
||||
if (!polydata) throw new Error('Failed to parse mesh data');
|
||||
const normalsFilter = vtkPolyDataNormals.newInstance();
|
||||
normalsFilter.setInputConnection(reader.getOutputPort());
|
||||
mappers[idx].setInputConnection(normalsFilter.getOutputPort());
|
||||
mappers[idx].setScalarVisibility(false);
|
||||
mappers[idx].update();
|
||||
const mp = mappers[idx].getInputData();
|
||||
boundsArr[idx] = mp.getBounds(); polydataArr[idx] = mp;
|
||||
}
|
||||
|
||||
function updateCombinedBounds() {
|
||||
const valid = boundsArr.filter(b => b);
|
||||
if (!valid.length) { combinedBounds = null; return; }
|
||||
combinedBounds = [...valid[0]];
|
||||
valid.forEach(b => {
|
||||
combinedBounds[0]=Math.min(combinedBounds[0],b[0]); combinedBounds[1]=Math.max(combinedBounds[1],b[1]);
|
||||
combinedBounds[2]=Math.min(combinedBounds[2],b[2]); combinedBounds[3]=Math.max(combinedBounds[3],b[3]);
|
||||
combinedBounds[4]=Math.min(combinedBounds[4],b[4]); combinedBounds[5]=Math.max(combinedBounds[5],b[5]);
|
||||
});
|
||||
}
|
||||
|
||||
// clip each layer to its band [leftEdge, rightEdge]; place dividers + labels
|
||||
function updateSliders() {
|
||||
if (!N) return;
|
||||
const rect = viewerContainer.getBoundingClientRect();
|
||||
for (let i = 0; i < N; i++) {
|
||||
const left = (i === 0) ? 0 : positions[i - 1];
|
||||
const right = (i === N - 1) ? 1 : positions[i];
|
||||
containers[i].style.clipPath = `inset(0 ${(1 - right) * 100}% 0 ${left * 100}%)`;
|
||||
const mid = (left + right) / 2;
|
||||
if (bandLabels[i]) bandLabels[i].style.left = `${mid * rect.width}px`;
|
||||
}
|
||||
for (let j = 0; j < positions.length; j++) {
|
||||
const x = positions[j] * rect.width;
|
||||
sliderLines[j].style.left = `${x}px`;
|
||||
sliderHandles[j].style.left = `${x}px`;
|
||||
sliderHandles[j].style.top = `${rect.height / 2}px`;
|
||||
}
|
||||
}
|
||||
|
||||
function positionCamera(bounds) {
|
||||
if (!bounds) return;
|
||||
const center = [(bounds[0]+bounds[1])/2, (bounds[2]+bounds[3])/2, (bounds[4]+bounds[5])/2];
|
||||
const maxDim = Math.max(bounds[1]-bounds[0], bounds[3]-bounds[2], bounds[5]-bounds[4]);
|
||||
const distance = maxDim * 2.5;
|
||||
for (let i = 0; i < N; i++) {
|
||||
const cam = renderers[i].getActiveCamera();
|
||||
cam.setPosition(center[0]+distance, center[1]+distance, center[2]+distance);
|
||||
cam.setFocalPoint(...center); cam.setViewUp(0, 0, 1);
|
||||
renderers[i].resetCamera(bounds); renderers[i].resetCameraClippingRange();
|
||||
}
|
||||
}
|
||||
|
||||
function renderAll() { for (let i = 0; i < N; i++) renderWindows[i].render(); }
|
||||
|
||||
// ----- divider drag (clamped, never crossing) -----
|
||||
function startDrag(e, j) { dragIndex = j; sliderHandles[j].classList.add('dragging'); e.preventDefault(); }
|
||||
function onDrag(e) {
|
||||
if (dragIndex < 0) return;
|
||||
const rect = viewerContainer.getBoundingClientRect();
|
||||
const clientX = e.touches ? e.touches[0].clientX : e.clientX;
|
||||
let p = (clientX - rect.left) / rect.width;
|
||||
const lo = (dragIndex === 0) ? 0 : positions[dragIndex - 1];
|
||||
const hi = (dragIndex === positions.length - 1) ? 1 : positions[dragIndex + 1];
|
||||
p = Math.max(lo + EPS, Math.min(hi - EPS, p));
|
||||
positions[dragIndex] = p;
|
||||
updateSliders();
|
||||
}
|
||||
function endDrag() { if (dragIndex >= 0) sliderHandles[dragIndex].classList.remove('dragging'); dragIndex = -1; }
|
||||
document.addEventListener('mousemove', onDrag);
|
||||
document.addEventListener('mouseup', endDrag);
|
||||
document.addEventListener('touchmove', onDrag);
|
||||
document.addEventListener('touchend', endDrag);
|
||||
|
||||
// ----- message protocol -----
|
||||
window.addEventListener('message', async (event) => {
|
||||
if (event.data.type !== 'LOAD_MULTI_SLIDER') return;
|
||||
const data = event.data;
|
||||
const files = (data.mesh_files || data.meshFiles || []).filter(Boolean);
|
||||
console.log('[GeomPack Multi Slider] LOAD_MULTI_SLIDER:', files.length, 'meshes');
|
||||
if (files.length < 2) {
|
||||
loading.textContent = 'Need at least 2 meshes for the wipe view'; loading.style.display = 'block'; return;
|
||||
}
|
||||
loading.textContent = 'Loading meshes...'; loading.style.display = 'block';
|
||||
try {
|
||||
buildLayers(files.length);
|
||||
initViewers(files.length);
|
||||
for (let i = 0; i < files.length; i++) await loadMesh(files[i], i);
|
||||
updateCombinedBounds();
|
||||
updateSliders();
|
||||
positionCamera(combinedBounds);
|
||||
const showEdges = data.show_edges || data.showEdges || false;
|
||||
document.getElementById('showEdges').checked = showEdges;
|
||||
actors.forEach(a => a.getProperty().setEdgeVisibility(showEdges));
|
||||
gltfActorsArr.forEach(acts => { if (acts) acts.forEach(a => a.getProperty().setEdgeVisibility(showEdges)); });
|
||||
renderAll();
|
||||
loading.style.display = 'none';
|
||||
} catch (err) {
|
||||
console.error('[GeomPack Multi Slider] load error:', err);
|
||||
loading.textContent = `Error: ${err.message}`; loading.style.color = '#ff6b6b';
|
||||
}
|
||||
});
|
||||
|
||||
// ----- controls -----
|
||||
document.getElementById('showEdges').addEventListener('change', (e) => {
|
||||
const v = e.target.checked;
|
||||
actors.forEach(a => a.getProperty().setEdgeVisibility(v));
|
||||
gltfActorsArr.forEach(acts => { if (acts) acts.forEach(a => a.getProperty().setEdgeVisibility(v)); });
|
||||
renderAll();
|
||||
window.parent.postMessage({ type: 'WIDGET_UPDATE', widget: 'show_edges', value: v }, '*');
|
||||
});
|
||||
document.getElementById('resetCamera').addEventListener('click', () => { positionCamera(combinedBounds); renderAll(); });
|
||||
document.getElementById('resetSlider').addEventListener('click', () => {
|
||||
positions = []; for (let j = 1; j < N; j++) positions.push(j / N); updateSliders();
|
||||
});
|
||||
|
||||
function setCameraView(direction) {
|
||||
if (!combinedBounds) return;
|
||||
const b = combinedBounds;
|
||||
const center = [(b[0]+b[1])/2, (b[2]+b[3])/2, (b[4]+b[5])/2];
|
||||
const maxDim = Math.max(b[1]-b[0], b[3]-b[2], b[5]-b[4]);
|
||||
const d = maxDim * 2.5;
|
||||
let position, viewUp;
|
||||
switch (direction) {
|
||||
case '+X': position=[center[0]+d,center[1],center[2]]; viewUp=[0,0,1]; break;
|
||||
case '-X': position=[center[0]-d,center[1],center[2]]; viewUp=[0,0,1]; break;
|
||||
case '+Y': position=[center[0],center[1]+d,center[2]]; viewUp=[0,0,1]; break;
|
||||
case '-Y': position=[center[0],center[1]-d,center[2]]; viewUp=[0,0,1]; break;
|
||||
case '+Z': position=[center[0],center[1],center[2]+d]; viewUp=[0,1,0]; break;
|
||||
case '-Z': position=[center[0],center[1],center[2]-d]; viewUp=[0,1,0]; break;
|
||||
}
|
||||
for (let i = 0; i < N; i++) {
|
||||
const cam = renderers[i].getActiveCamera();
|
||||
cam.setPosition(...position); cam.setFocalPoint(...center); cam.setViewUp(...viewUp);
|
||||
renderers[i].resetCamera(); renderers[i].resetCameraClippingRange();
|
||||
}
|
||||
renderAll();
|
||||
}
|
||||
document.getElementById('viewPosX').addEventListener('click', () => setCameraView('+X'));
|
||||
document.getElementById('viewNegX').addEventListener('click', () => setCameraView('-X'));
|
||||
document.getElementById('viewPosY').addEventListener('click', () => setCameraView('+Y'));
|
||||
document.getElementById('viewNegY').addEventListener('click', () => setCameraView('-Y'));
|
||||
document.getElementById('viewPosZ').addEventListener('click', () => setCameraView('+Z'));
|
||||
document.getElementById('viewNegZ').addEventListener('click', () => setCameraView('-Z'));
|
||||
|
||||
// ----- settings panel -----
|
||||
const settingsButton = document.getElementById('settingsButton');
|
||||
const settingsPanel = document.getElementById('settingsPanel');
|
||||
const settingsOverlay = document.getElementById('settingsOverlay');
|
||||
let currentPointSize = 3, currentEdgeWidth = 1, currentRepresentation = 2, currentParallelProjection = false;
|
||||
settingsButton.addEventListener('click', () => {
|
||||
document.getElementById('pointSize').value = currentPointSize;
|
||||
document.getElementById('edgeWidth').value = currentEdgeWidth;
|
||||
document.getElementById('representationSettings').value = currentRepresentation.toString();
|
||||
document.getElementById('parallelProjectionSetting').checked = currentParallelProjection;
|
||||
settingsPanel.classList.add('visible'); settingsOverlay.classList.add('visible');
|
||||
});
|
||||
function closeSettings() { settingsPanel.classList.remove('visible'); settingsOverlay.classList.remove('visible'); }
|
||||
settingsOverlay.addEventListener('click', closeSettings);
|
||||
document.getElementById('cancelSettings').addEventListener('click', closeSettings);
|
||||
document.getElementById('applySettings').addEventListener('click', () => {
|
||||
const ps = parseFloat(document.getElementById('pointSize').value);
|
||||
const ew = parseFloat(document.getElementById('edgeWidth').value);
|
||||
const rep = parseInt(document.getElementById('representationSettings').value);
|
||||
const pp = document.getElementById('parallelProjectionSetting').checked;
|
||||
actors.forEach(a => { const p=a.getProperty(); p.setPointSize(ps); p.setLineWidth(ew); p.setRepresentation(rep); });
|
||||
gltfActorsArr.forEach(acts => { if (acts) acts.forEach(a => { const p=a.getProperty(); p.setPointSize(ps); p.setLineWidth(ew); p.setRepresentation(rep); }); });
|
||||
for (let i = 0; i < N; i++) { renderers[i].getActiveCamera().setParallelProjection(pp); renderers[i].resetCameraClippingRange(); }
|
||||
currentPointSize=ps; currentEdgeWidth=ew; currentRepresentation=rep; currentParallelProjection=pp;
|
||||
renderAll(); closeSettings();
|
||||
});
|
||||
|
||||
window.addEventListener('resize', () => { fsRenderers.forEach(r => r.resize()); updateSliders(); renderAll(); });
|
||||
|
||||
console.log('[GeomPack Multi Slider] Ready');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,409 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>GeomPack PBR Viewer</title>
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
background: #1a1a1a;
|
||||
font-family: monospace;
|
||||
}
|
||||
#canvas {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
}
|
||||
#loading {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
color: white;
|
||||
font-size: 14px;
|
||||
text-align: center;
|
||||
}
|
||||
#info {
|
||||
position: absolute;
|
||||
bottom: 10px;
|
||||
left: 10px;
|
||||
color: #888;
|
||||
font-size: 11px;
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<canvas id="canvas"></canvas>
|
||||
<div id="loading">Ready to load mesh</div>
|
||||
<div id="info">PBR Viewer</div>
|
||||
|
||||
<script type="importmap">
|
||||
{
|
||||
"imports": {
|
||||
"three": "https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js",
|
||||
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<script type="module">
|
||||
import * as THREE from 'three';
|
||||
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
|
||||
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
|
||||
import { EXRLoader } from 'three/addons/loaders/EXRLoader.js';
|
||||
import { RGBELoader } from 'three/addons/loaders/RGBELoader.js';
|
||||
|
||||
console.log('[GeomPack PBR Viewer] Initializing...');
|
||||
|
||||
const canvas = document.getElementById('canvas');
|
||||
const loading = document.getElementById('loading');
|
||||
const info = document.getElementById('info');
|
||||
|
||||
// Scene
|
||||
const scene = new THREE.Scene();
|
||||
scene.background = new THREE.Color(0x1a1a1a);
|
||||
|
||||
// Camera
|
||||
const camera = new THREE.PerspectiveCamera(45, window.innerWidth / window.innerHeight, 0.01, 1000);
|
||||
camera.position.set(2, 2, 2);
|
||||
|
||||
// Renderer with PBR settings
|
||||
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
|
||||
renderer.setSize(window.innerWidth, window.innerHeight);
|
||||
renderer.setPixelRatio(window.devicePixelRatio);
|
||||
|
||||
// Critical PBR settings
|
||||
renderer.outputColorSpace = THREE.SRGBColorSpace;
|
||||
renderer.toneMapping = THREE.ACESFilmicToneMapping;
|
||||
renderer.toneMappingExposure = 1.2;
|
||||
renderer.sortObjects = true; // Enable proper transparency sorting
|
||||
|
||||
// Controls
|
||||
const controls = new OrbitControls(camera, canvas);
|
||||
controls.enableDamping = true;
|
||||
controls.dampingFactor = 0.05;
|
||||
controls.zoomSpeed = 3.0;
|
||||
|
||||
// Grid (subtle)
|
||||
const grid = new THREE.GridHelper(10, 20, 0x333333, 0x222222);
|
||||
scene.add(grid);
|
||||
|
||||
// Send camera state to parent node widget
|
||||
function sendCameraState() {
|
||||
window.parent.postMessage({
|
||||
type: 'WIDGET_UPDATE',
|
||||
widget: 'camera_state',
|
||||
value: JSON.stringify({
|
||||
position: [camera.position.x, camera.position.y, camera.position.z],
|
||||
focalPoint: [controls.target.x, controls.target.y, controls.target.z],
|
||||
viewUp: [camera.up.x, camera.up.y, camera.up.z]
|
||||
})
|
||||
}, '*');
|
||||
}
|
||||
|
||||
function applyCameraState(cameraState) {
|
||||
if (!cameraState) return false;
|
||||
try {
|
||||
const cs = typeof cameraState === 'string' ? JSON.parse(cameraState) : cameraState;
|
||||
if (!cs.position || !cs.focalPoint || !cs.viewUp) return false;
|
||||
camera.position.set(...cs.position);
|
||||
controls.target.set(...cs.focalPoint);
|
||||
camera.up.set(...cs.viewUp);
|
||||
camera.updateProjectionMatrix();
|
||||
controls.update();
|
||||
return true;
|
||||
} catch(e) { return false; }
|
||||
}
|
||||
|
||||
controls.addEventListener('end', sendCameraState);
|
||||
|
||||
let _pendingCameraState = null;
|
||||
|
||||
// Current mesh
|
||||
let currentMesh = null;
|
||||
let envMapLoaded = false;
|
||||
|
||||
// Get the extension folder path from the current URL
|
||||
const getExtensionPath = () => {
|
||||
const url = window.location.href;
|
||||
const match = url.match(/\/extensions\/([^/]+)\//);
|
||||
return match ? `/extensions/${match[1]}` : '/extensions/comfyui-geometrypack';
|
||||
};
|
||||
|
||||
// Load environment map for PBR reflections
|
||||
const loadEnvironment = () => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const extPath = getExtensionPath();
|
||||
const exrLoader = new EXRLoader();
|
||||
|
||||
const exrPath = `${extPath}/assets/studio.exr`;
|
||||
console.log('[GeomPack PBR Viewer] Loading environment map from:', exrPath);
|
||||
|
||||
exrLoader.load(
|
||||
exrPath,
|
||||
(texture) => {
|
||||
texture.mapping = THREE.EquirectangularReflectionMapping;
|
||||
scene.environment = texture;
|
||||
envMapLoaded = true;
|
||||
console.log('[GeomPack PBR Viewer] Environment map Loaded successfully');
|
||||
resolve(texture);
|
||||
},
|
||||
(progress) => {
|
||||
if (progress.lengthComputable) {
|
||||
console.log('[GeomPack PBR Viewer] Env map loading:', Math.round(progress.loaded / progress.total * 100) + '%');
|
||||
}
|
||||
},
|
||||
(error) => {
|
||||
console.error('[GeomPack PBR Viewer] Failed to load EXR environment:', error);
|
||||
console.warn('[GeomPack PBR Viewer] Using fallback gradient environment');
|
||||
// Fallback: create a simple gradient environment
|
||||
createFallbackEnvironment();
|
||||
resolve(null);
|
||||
}
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
// Fallback environment if EXR fails to load
|
||||
const createFallbackEnvironment = () => {
|
||||
const pmremGenerator = new THREE.PMREMGenerator(renderer);
|
||||
pmremGenerator.compileEquirectangularShader();
|
||||
|
||||
// Create a simple gradient sky
|
||||
const skyGeo = new THREE.SphereGeometry(500, 32, 15);
|
||||
const skyMat = new THREE.ShaderMaterial({
|
||||
uniforms: {
|
||||
topColor: { value: new THREE.Color(0x444466) },
|
||||
bottomColor: { value: new THREE.Color(0x222222) },
|
||||
offset: { value: 33 },
|
||||
exponent: { value: 0.6 }
|
||||
},
|
||||
vertexShader: `
|
||||
varying vec3 vWorldPosition;
|
||||
void main() {
|
||||
vec4 worldPosition = modelMatrix * vec4(position, 1.0);
|
||||
vWorldPosition = worldPosition.xyz;
|
||||
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
|
||||
}
|
||||
`,
|
||||
fragmentShader: `
|
||||
uniform vec3 topColor;
|
||||
uniform vec3 bottomColor;
|
||||
uniform float offset;
|
||||
uniform float exponent;
|
||||
varying vec3 vWorldPosition;
|
||||
void main() {
|
||||
float h = normalize(vWorldPosition + offset).y;
|
||||
gl_FragColor = vec4(mix(bottomColor, topColor, max(pow(max(h, 0.0), exponent), 0.0)), 1.0);
|
||||
}
|
||||
`,
|
||||
side: THREE.BackSide
|
||||
});
|
||||
|
||||
const sky = new THREE.Mesh(skyGeo, skyMat);
|
||||
|
||||
// Generate environment map from sky
|
||||
const envScene = new THREE.Scene();
|
||||
envScene.add(sky);
|
||||
const envMap = pmremGenerator.fromScene(envScene).texture;
|
||||
scene.environment = envMap;
|
||||
envMapLoaded = true;
|
||||
|
||||
pmremGenerator.dispose();
|
||||
console.log('[GeomPack PBR Viewer] Using fallback gradient environment');
|
||||
};
|
||||
|
||||
// Lighting setup matching standard ComfyUI Load3D viewer
|
||||
const ambientLight = new THREE.AmbientLight(0xffffff, 0.5);
|
||||
scene.add(ambientLight);
|
||||
|
||||
// Main light
|
||||
const mainLight = new THREE.DirectionalLight(0xffffff, 0.8);
|
||||
mainLight.position.set(0, 10, 10);
|
||||
scene.add(mainLight);
|
||||
|
||||
// Back light
|
||||
const backLight = new THREE.DirectionalLight(0xffffff, 0.5);
|
||||
backLight.position.set(0, 10, -10);
|
||||
scene.add(backLight);
|
||||
|
||||
// Left fill light
|
||||
const leftFillLight = new THREE.DirectionalLight(0xffffff, 0.3);
|
||||
leftFillLight.position.set(-10, 0, 0);
|
||||
scene.add(leftFillLight);
|
||||
|
||||
// Right fill light
|
||||
const rightFillLight = new THREE.DirectionalLight(0xffffff, 0.3);
|
||||
rightFillLight.position.set(10, 0, 0);
|
||||
scene.add(rightFillLight);
|
||||
|
||||
// Bottom light
|
||||
const bottomLight = new THREE.DirectionalLight(0xffffff, 0.2);
|
||||
bottomLight.position.set(0, -10, 0);
|
||||
scene.add(bottomLight);
|
||||
|
||||
// Animation loop
|
||||
function animate() {
|
||||
requestAnimationFrame(animate);
|
||||
controls.update();
|
||||
renderer.render(scene, camera);
|
||||
}
|
||||
animate();
|
||||
|
||||
// Handle window resize
|
||||
window.addEventListener('resize', () => {
|
||||
camera.aspect = window.innerWidth / window.innerHeight;
|
||||
camera.updateProjectionMatrix();
|
||||
renderer.setSize(window.innerWidth, window.innerHeight);
|
||||
});
|
||||
|
||||
// Load mesh function
|
||||
async function loadMesh(filepath) {
|
||||
console.log('[GeomPack PBR Viewer] Loading:', filepath);
|
||||
loading.textContent = 'Loading mesh...';
|
||||
loading.style.display = 'block';
|
||||
|
||||
// Ensure environment is loaded first
|
||||
if (!envMapLoaded) {
|
||||
loading.textContent = 'Loading environment...';
|
||||
await loadEnvironment();
|
||||
}
|
||||
|
||||
// Remove old mesh
|
||||
if (currentMesh) {
|
||||
scene.remove(currentMesh);
|
||||
currentMesh.traverse((child) => {
|
||||
if (child.geometry) child.geometry.dispose();
|
||||
if (child.material) {
|
||||
if (Array.isArray(child.material)) {
|
||||
child.material.forEach(m => m.dispose());
|
||||
} else {
|
||||
child.material.dispose();
|
||||
}
|
||||
}
|
||||
});
|
||||
currentMesh = null;
|
||||
}
|
||||
|
||||
loading.textContent = 'Loading mesh...';
|
||||
const loader = new GLTFLoader();
|
||||
|
||||
loader.load(
|
||||
filepath,
|
||||
(gltf) => {
|
||||
currentMesh = gltf.scene;
|
||||
|
||||
// Center mesh
|
||||
const box = new THREE.Box3().setFromObject(currentMesh);
|
||||
const center = box.getCenter(new THREE.Vector3());
|
||||
currentMesh.position.sub(center);
|
||||
|
||||
// Configure materials for PBR
|
||||
let meshCount = 0;
|
||||
let hasTextures = false;
|
||||
currentMesh.traverse((child) => {
|
||||
if (child.isMesh) {
|
||||
meshCount++;
|
||||
|
||||
// Enable environment map on all materials
|
||||
if (child.material) {
|
||||
const materials = Array.isArray(child.material) ? child.material : [child.material];
|
||||
materials.forEach(mat => {
|
||||
if (mat.isMeshStandardMaterial || mat.isMeshPhysicalMaterial) {
|
||||
mat.envMapIntensity = 1.5;
|
||||
|
||||
// Enable transparency support
|
||||
// Check if baseColorTexture has alpha channel
|
||||
const hasAlphaMap = mat.map && mat.map.format === THREE.RGBAFormat;
|
||||
const hasAlphaTest = mat.alphaTest > 0;
|
||||
const needsTransparency = mat.transparent || mat.opacity < 1.0 || hasAlphaMap;
|
||||
|
||||
if (needsTransparency || hasAlphaMap) {
|
||||
mat.transparent = true;
|
||||
mat.alphaTest = 0.01; // Discard nearly-transparent pixels
|
||||
mat.side = THREE.DoubleSide; // Render both sides
|
||||
mat.depthWrite = true; // Keep depth writing for proper sorting
|
||||
}
|
||||
|
||||
mat.needsUpdate = true;
|
||||
|
||||
// Check if has textures
|
||||
if (mat.map || mat.metalnessMap || mat.roughnessMap) {
|
||||
hasTextures = true;
|
||||
}
|
||||
|
||||
console.log('[GeomPack PBR Viewer] Material:', {
|
||||
name: mat.name,
|
||||
metalness: mat.metalness,
|
||||
roughness: mat.roughness,
|
||||
opacity: mat.opacity,
|
||||
transparent: mat.transparent,
|
||||
alphaTest: mat.alphaTest,
|
||||
hasMap: !!mat.map,
|
||||
mapFormat: mat.map ? mat.map.format : 'none',
|
||||
hasMetalnessMap: !!mat.metalnessMap,
|
||||
hasRoughnessMap: !!mat.roughnessMap,
|
||||
hasAlphaMap: hasAlphaMap
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Add to scene
|
||||
scene.add(currentMesh);
|
||||
|
||||
// Adjust camera (restore saved state or auto-position)
|
||||
const size = box.getSize(new THREE.Vector3());
|
||||
const maxDim = Math.max(size.x, size.y, size.z);
|
||||
|
||||
camera.near = maxDim * 0.001;
|
||||
camera.far = maxDim * 100;
|
||||
camera.updateProjectionMatrix();
|
||||
|
||||
if (!applyCameraState(_pendingCameraState)) {
|
||||
const distance = maxDim * 2.0;
|
||||
camera.position.set(distance * 0.7, distance * 0.5, distance * 0.7);
|
||||
camera.lookAt(0, 0, 0);
|
||||
controls.target.set(0, 0, 0);
|
||||
controls.update();
|
||||
}
|
||||
_pendingCameraState = null;
|
||||
|
||||
loading.style.display = 'none';
|
||||
info.textContent = `PBR Viewer | ${meshCount} mesh(es) | Textures: ${hasTextures ? 'Yes' : 'No'}`;
|
||||
console.log('[GeomPack PBR Viewer] Mesh Loaded successfully');
|
||||
},
|
||||
(progress) => {
|
||||
if (progress.lengthComputable) {
|
||||
const percent = (progress.loaded / progress.total * 100).toFixed(0);
|
||||
loading.textContent = `Loading mesh... ${percent}%`;
|
||||
}
|
||||
},
|
||||
(error) => {
|
||||
console.error('[GeomPack PBR Viewer] Error loading mesh:', error);
|
||||
loading.textContent = 'Error loading mesh - check console';
|
||||
loading.style.color = '#ff6666';
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Listen for messages from parent window
|
||||
window.addEventListener('message', (event) => {
|
||||
console.log('[GeomPack PBR Viewer] Received message:', event.data);
|
||||
if (event.data.type === 'LOAD_MESH') {
|
||||
_pendingCameraState = event.data.cameraState || null;
|
||||
loadMesh(event.data.filepath);
|
||||
}
|
||||
});
|
||||
|
||||
// Pre-load environment
|
||||
loadEnvironment().then(() => {
|
||||
console.log('[GeomPack PBR Viewer] Ready');
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,386 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>GeomPack Mesh Slicer</title>
|
||||
<style>
|
||||
html, body { margin: 0; padding: 0; height: 100%; background: #2a2a2a; overflow: hidden; font: 12px monospace; color: #ddd; }
|
||||
#wrap { display: flex; flex-direction: column; width: 100%; height: 100%; }
|
||||
#top { position: relative; flex: 1 1 0; min-height: 80px; background: #2a2a2a; }
|
||||
#toolbar { flex: 0 0 auto; display: flex; flex-direction: column; gap: 4px; padding: 4px 6px; background: #1d1d1d; border-top: 1px solid #444; border-bottom: 1px solid #444; }
|
||||
#toolbar .row { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; }
|
||||
#bottom { position: relative; flex: 1 1 0; min-height: 80px; background: #222; cursor: crosshair; }
|
||||
#section { position: relative; flex: 1 1 0; min-height: 80px; background: #151515; border-top: 1px solid #444; display: none; }
|
||||
#status { flex: 1 1 auto; color: #9ad; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
button { background: #333; color: #ddd; border: 1px solid #555; border-radius: 4px; padding: 3px 8px; cursor: pointer; font: 11px monospace; }
|
||||
button.active { background: #2c5d8a; border-color: #4a90c0; }
|
||||
button:hover { background: #444; }
|
||||
</style>
|
||||
<script src="./js/vtk-gltf.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="wrap">
|
||||
<div id="top"></div>
|
||||
<div id="toolbar">
|
||||
<div class="row">
|
||||
<span>3d view:</span>
|
||||
<button id="qpx">+X</button>
|
||||
<button id="qnx">−X</button>
|
||||
<button id="qpy">+Y</button>
|
||||
<button id="qny">−Y</button>
|
||||
<button id="qpz">+Z</button>
|
||||
<button id="qnz">−Z</button>
|
||||
<span id="status">load a mesh, then click 2 points in the bottom view to set the cut</span>
|
||||
</div>
|
||||
<div class="row">
|
||||
<span>cut view:</span>
|
||||
<button id="axXY" class="active">XY</button>
|
||||
<button id="axYZ">YZ</button>
|
||||
<button id="axXZ">XZ</button>
|
||||
<button id="lock" class="active">🔒 2D lock</button>
|
||||
<button id="flip">⇄ flip clip</button>
|
||||
<button id="reset">reset clip</button>
|
||||
<button id="r2d">▣ render 2D</button>
|
||||
<label style="color:#bbb;font:11px monospace;">edge <input id="thick" type="number" min="1" max="20" value="2" style="width:44px;background:#333;color:#ddd;border:1px solid #555;border-radius:4px;font:11px monospace;"></label>
|
||||
</div>
|
||||
</div>
|
||||
<div id="bottom"></div>
|
||||
<div id="section"></div>
|
||||
</div>
|
||||
<script>
|
||||
const vtkFullScreenRenderWindow = vtk.Rendering.Misc.vtkFullScreenRenderWindow;
|
||||
const vtkInteractorStyleTrackballCamera = vtk.Interaction.Style.vtkInteractorStyleTrackballCamera;
|
||||
const vtkActor = vtk.Rendering.Core.vtkActor;
|
||||
const vtkMapper = vtk.Rendering.Core.vtkMapper;
|
||||
const vtkXMLPolyDataReader = vtk.IO.XML.vtkXMLPolyDataReader;
|
||||
const vtkPlane = vtk.Common.DataModel.vtkPlane;
|
||||
const vtkCutter = vtk.Filters.Core.vtkCutter;
|
||||
|
||||
const statusEl = document.getElementById('status');
|
||||
const setStatus = (t) => { statusEl.textContent = t; };
|
||||
|
||||
// ---- top: interactive 3D view (shows the clipped mesh) ----
|
||||
const topWin = vtkFullScreenRenderWindow.newInstance({ background: [0.16, 0.16, 0.16], container: document.getElementById('top') });
|
||||
const topRen = topWin.getRenderer();
|
||||
const topRW = topWin.getRenderWindow();
|
||||
topRW.getInteractor().setInteractorStyle(vtkInteractorStyleTrackballCamera.newInstance());
|
||||
const topMapper = vtkMapper.newInstance();
|
||||
const topActor = vtkActor.newInstance();
|
||||
topActor.setMapper(topMapper);
|
||||
topActor.getProperty().setColor(0.8, 0.8, 0.82);
|
||||
topActor.getProperty().setEdgeVisibility(false);
|
||||
topRen.addActor(topActor);
|
||||
|
||||
// ---- bottom: orthographic pick view ----
|
||||
const bot = vtkFullScreenRenderWindow.newInstance({ background: [0.13, 0.13, 0.13], container: document.getElementById('bottom') });
|
||||
const botRen = bot.getRenderer();
|
||||
const botRW = bot.getRenderWindow();
|
||||
const botMapper = vtkMapper.newInstance();
|
||||
const botActor = vtkActor.newInstance();
|
||||
botActor.setMapper(botMapper);
|
||||
botActor.getProperty().setColor(0.7, 0.7, 0.72);
|
||||
botRen.addActor(botActor);
|
||||
|
||||
// ---- section: optional 2D cross-section (mesh ∩ plane) via vtkCutter ----
|
||||
const section = vtkFullScreenRenderWindow.newInstance({ background: [0.08, 0.08, 0.08], container: document.getElementById('section') });
|
||||
const sectionRen = section.getRenderer();
|
||||
const sectionRW = section.getRenderWindow();
|
||||
sectionRW.getInteractor().setInteractorStyle(null);
|
||||
const sectionMapper = vtkMapper.newInstance();
|
||||
const sectionActor = vtkActor.newInstance();
|
||||
sectionActor.setMapper(sectionMapper);
|
||||
sectionActor.getProperty().setColor(1.0, 0.85, 0.2);
|
||||
sectionActor.getProperty().setLineWidth(2);
|
||||
sectionRen.addActor(sectionActor);
|
||||
const cutter = vtkCutter.newInstance();
|
||||
let render2D = false;
|
||||
let thickness = 2;
|
||||
let meshPoly = null;
|
||||
|
||||
function resizeAll() { topWin.resize(); bot.resize(); if (render2D) section.resize(); if (typeof redrawMarkers === 'function') redrawMarkers(); }
|
||||
window.addEventListener('resize', resizeAll);
|
||||
|
||||
// axis basis: u = screen-right, v = screen-up, n = view normal (camera dir)
|
||||
const AXES = {
|
||||
XY: { n: [0, 0, 1], u: [1, 0, 0], v: [0, 1, 0] },
|
||||
YZ: { n: [1, 0, 0], u: [0, 1, 0], v: [0, 0, 1] },
|
||||
XZ: { n: [0, 1, 0], u: [1, 0, 0], v: [0, 0, 1] },
|
||||
};
|
||||
let axis = 'XY';
|
||||
let picks = []; // world points (the two cut endpoints) -- kept persistent
|
||||
let clipNormalSign = 1;
|
||||
let lastPlane = null; // {origin, normal}
|
||||
let bounds = [0, 1, 0, 1, 0, 1];
|
||||
let locked = true; // true = fixed 2D ortho (pick/drag enabled); false = orbit
|
||||
let dragging = false;
|
||||
|
||||
function center() { return [(bounds[0]+bounds[1])/2, (bounds[2]+bounds[3])/2, (bounds[4]+bounds[5])/2]; }
|
||||
function diag() { return Math.hypot(bounds[1]-bounds[0], bounds[3]-bounds[2], bounds[5]-bounds[4]) || 1; }
|
||||
|
||||
function positionBottomCamera() {
|
||||
const A = AXES[axis], c = center(), d = diag();
|
||||
const cam = botRen.getActiveCamera();
|
||||
cam.setParallelProjection(true);
|
||||
cam.setFocalPoint(c[0], c[1], c[2]);
|
||||
cam.setPosition(c[0] + A.n[0]*d, c[1] + A.n[1]*d, c[2] + A.n[2]*d);
|
||||
cam.setViewUp(A.v[0], A.v[1], A.v[2]);
|
||||
botRen.resetCamera();
|
||||
botRW.render();
|
||||
}
|
||||
|
||||
// --- SVG overlay over the bottom view: draggable endpoints + red cut line ---
|
||||
const SVGNS = 'http://www.w3.org/2000/svg';
|
||||
const overlay = document.createElementNS(SVGNS, 'svg');
|
||||
overlay.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;pointer-events:none;';
|
||||
document.getElementById('bottom').appendChild(overlay);
|
||||
const cutLine = document.createElementNS(SVGNS, 'line');
|
||||
cutLine.setAttribute('stroke', '#ff3b30'); cutLine.setAttribute('stroke-width', '2.5');
|
||||
cutLine.style.display = 'none';
|
||||
overlay.appendChild(cutLine);
|
||||
const handles = [0, 1].map((i) => {
|
||||
// big transparent grab area so near-misses still catch the handle
|
||||
const hit = document.createElementNS(SVGNS, 'circle');
|
||||
hit.setAttribute('r', '16'); hit.setAttribute('fill', 'transparent');
|
||||
hit.style.cssText = 'pointer-events:all;cursor:grab;display:none;';
|
||||
hit.addEventListener('mousedown', (ev) => { ev.preventDefault(); ev.stopPropagation(); startDrag(i); });
|
||||
hit.addEventListener('click', (ev) => ev.stopPropagation());
|
||||
const dot = document.createElementNS(SVGNS, 'circle');
|
||||
dot.setAttribute('r', '7'); dot.setAttribute('fill', i === 0 ? '#34c759' : '#ff9f0a');
|
||||
dot.setAttribute('stroke', '#fff'); dot.setAttribute('stroke-width', '2');
|
||||
dot.style.cssText = 'pointer-events:none;display:none;';
|
||||
overlay.appendChild(hit); overlay.appendChild(dot);
|
||||
return { hit, dot };
|
||||
});
|
||||
|
||||
const botInteractor = botRW.getInteractor();
|
||||
// Lock = no interactor style (no rotation) but interactor stays ENABLED so the
|
||||
// render loop keeps painting. Unlock swaps in a trackball style.
|
||||
botInteractor.setInteractorStyle(null);
|
||||
|
||||
// forward map: world -> screen px (inverse of clickToWorld; valid only when locked/ortho)
|
||||
function worldToScreen(world) {
|
||||
const A = AXES[axis];
|
||||
const cam = botRen.getActiveCamera();
|
||||
const fp = cam.getFocalPoint();
|
||||
const s = cam.getParallelScale();
|
||||
const rect = document.getElementById('bottom').getBoundingClientRect();
|
||||
const W = rect.width, H = rect.height, aspect = W / H;
|
||||
const dw = [world[0]-fp[0], world[1]-fp[1], world[2]-fp[2]];
|
||||
const ndcx = (dw[0]*A.u[0]+dw[1]*A.u[1]+dw[2]*A.u[2]) / (s*aspect);
|
||||
const ndcy = (dw[0]*A.v[0]+dw[1]*A.v[1]+dw[2]*A.v[2]) / s;
|
||||
return { x: (ndcx+1)/2*W, y: (1-ndcy)/2*H };
|
||||
}
|
||||
function redrawMarkers() {
|
||||
const show = locked; // handles/line only track the mesh in the fixed ortho view
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const h = handles[i];
|
||||
if (show && picks[i]) {
|
||||
const p = worldToScreen(picks[i]);
|
||||
for (const el of [h.hit, h.dot]) { el.setAttribute('cx', p.x); el.setAttribute('cy', p.y); el.style.display = 'block'; }
|
||||
} else { h.hit.style.display = 'none'; h.dot.style.display = 'none'; }
|
||||
}
|
||||
if (show && picks[0] && picks[1]) {
|
||||
const a = worldToScreen(picks[0]), b = worldToScreen(picks[1]);
|
||||
cutLine.setAttribute('x1', a.x); cutLine.setAttribute('y1', a.y);
|
||||
cutLine.setAttribute('x2', b.x); cutLine.setAttribute('y2', b.y);
|
||||
cutLine.style.display = 'block';
|
||||
} else cutLine.style.display = 'none';
|
||||
}
|
||||
|
||||
// click in bottom canvas -> world point on the focal plane (analytic, matches parallel cam)
|
||||
function clickToWorld(ev) {
|
||||
const A = AXES[axis];
|
||||
const rect = document.getElementById('bottom').getBoundingClientRect();
|
||||
const W = rect.width, H = rect.height;
|
||||
const px = ev.clientX - rect.left, py = ev.clientY - rect.top;
|
||||
const ndcx = (px / W) * 2 - 1;
|
||||
const ndcy = 1 - (py / H) * 2;
|
||||
const cam = botRen.getActiveCamera();
|
||||
const fp = cam.getFocalPoint();
|
||||
const s = cam.getParallelScale(); // half viewport height in world units
|
||||
const aspect = W / H;
|
||||
const out = [0, 0, 0];
|
||||
for (let i = 0; i < 3; i++) {
|
||||
out[i] = fp[i] + ndcx * s * aspect * A.u[i] + ndcy * s * A.v[i];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Tell the parent frame (preview_mesh_slicer.js) what's currently clipped/shown in the
|
||||
// top 3D view, so the Python node can reproduce the same cut on the actual mesh data
|
||||
// (execute() has no other way to see interactive browser-only state like lastPlane).
|
||||
function postPlaneUpdate() {
|
||||
if (!lastPlane) { parent.postMessage({ type: 'PLANE_UPDATE', hasPlane: false }, '*'); return; }
|
||||
const n = lastPlane.normal.map(x => x * clipNormalSign);
|
||||
parent.postMessage({ type: 'PLANE_UPDATE', hasPlane: true, origin: lastPlane.origin, normal: n }, '*');
|
||||
}
|
||||
|
||||
function applyClip() {
|
||||
if (!lastPlane) return;
|
||||
const n = lastPlane.normal.map(x => x * clipNormalSign);
|
||||
topMapper.removeAllClippingPlanes();
|
||||
const plane = vtkPlane.newInstance({ origin: lastPlane.origin, normal: n });
|
||||
topMapper.addClippingPlane(plane);
|
||||
topRW.render();
|
||||
updateSection();
|
||||
postPlaneUpdate();
|
||||
}
|
||||
|
||||
// Quick camera navigation for the TOP 3D view (view-cube style): reposition the
|
||||
// orbit camera to look along a cardinal axis, centered on the mesh. Does NOT touch
|
||||
// the clip plane -- that's still only ever set via the "cut view" pick controls below.
|
||||
function setCameraView(dir) {
|
||||
const c = center(), d = diag();
|
||||
const cam = topRen.getActiveCamera();
|
||||
cam.setFocalPoint(c[0], c[1], c[2]);
|
||||
cam.setPosition(c[0] + dir[0]*d*2, c[1] + dir[1]*d*2, c[2] + dir[2]*d*2);
|
||||
// pick an up vector that isn't parallel to the view direction
|
||||
const up = Math.abs(dir[1]) > 0.9 ? [0, 0, 1] : [0, 1, 0];
|
||||
cam.setViewUp(up[0], up[1], up[2]);
|
||||
topRen.resetCamera(); // keeps direction/up set above, just fits distance to bounds
|
||||
topRW.render();
|
||||
setStatus(`3d view: looking along [${dir.join(', ')}]`);
|
||||
}
|
||||
document.getElementById('qpx').onclick = () => setCameraView([1, 0, 0]);
|
||||
document.getElementById('qnx').onclick = () => setCameraView([-1, 0, 0]);
|
||||
document.getElementById('qpy').onclick = () => setCameraView([0, 1, 0]);
|
||||
document.getElementById('qny').onclick = () => setCameraView([0, -1, 0]);
|
||||
document.getElementById('qpz').onclick = () => setCameraView([0, 0, 1]);
|
||||
document.getElementById('qnz').onclick = () => setCameraView([0, 0, -1]);
|
||||
|
||||
function updateSection() {
|
||||
if (!render2D || !lastPlane || !meshPoly) return;
|
||||
try {
|
||||
const plane = vtkPlane.newInstance({ origin: lastPlane.origin, normal: lastPlane.normal });
|
||||
cutter.setCutFunction(plane);
|
||||
cutter.setInputData(meshPoly);
|
||||
cutter.update();
|
||||
sectionMapper.setInputData(cutter.getOutputData());
|
||||
sectionActor.getProperty().setLineWidth(thickness);
|
||||
// face-on camera: look along the plane normal
|
||||
const n = lastPlane.normal, o = lastPlane.origin, d = diag();
|
||||
const ref = Math.abs(n[0]) < 0.9 ? [1, 0, 0] : [0, 1, 0];
|
||||
let up = [ n[1]*ref[2]-n[2]*ref[1], n[2]*ref[0]-n[0]*ref[2], n[0]*ref[1]-n[1]*ref[0] ];
|
||||
const ul = Math.hypot(up[0], up[1], up[2]) || 1; up = up.map(x => x/ul);
|
||||
const cam = sectionRen.getActiveCamera();
|
||||
cam.setParallelProjection(true);
|
||||
cam.setFocalPoint(o[0], o[1], o[2]);
|
||||
cam.setPosition(o[0]+n[0]*d, o[1]+n[1]*d, o[2]+n[2]*d);
|
||||
cam.setViewUp(up[0], up[1], up[2]);
|
||||
sectionRen.resetCamera();
|
||||
sectionRW.render();
|
||||
} catch (e) { console.warn('[Slicer] section update failed', e); }
|
||||
}
|
||||
|
||||
function setPlaneFromPicks() {
|
||||
if (picks.length < 2) return;
|
||||
const A = AXES[axis];
|
||||
const [p1, p2] = picks;
|
||||
const dir = [p2[0]-p1[0], p2[1]-p1[1], p2[2]-p1[2]];
|
||||
// normal = dir x axis (lies in view plane, perpendicular to the drawn line)
|
||||
let nrm = [
|
||||
dir[1]*A.n[2] - dir[2]*A.n[1],
|
||||
dir[2]*A.n[0] - dir[0]*A.n[2],
|
||||
dir[0]*A.n[1] - dir[1]*A.n[0],
|
||||
];
|
||||
const L = Math.hypot(nrm[0], nrm[1], nrm[2]);
|
||||
if (L < 1e-9) { setStatus('points too close / colinear — move a handle'); return; }
|
||||
nrm = nrm.map(x => x / L);
|
||||
lastPlane = { origin: p1.slice(), normal: nrm };
|
||||
applyClip();
|
||||
setStatus(`clip on ${axis}. drag the dots to adjust · ⇄ flip · reset`);
|
||||
}
|
||||
|
||||
function startDrag(i) {
|
||||
if (!locked) return;
|
||||
dragging = true;
|
||||
const move = (e) => { picks[i] = clickToWorld(e); redrawMarkers(); setPlaneFromPicks(); };
|
||||
const up = () => { dragging = false; window.removeEventListener('mousemove', move); window.removeEventListener('mouseup', up); };
|
||||
window.addEventListener('mousemove', move);
|
||||
window.addEventListener('mouseup', up);
|
||||
}
|
||||
|
||||
document.getElementById('bottom').addEventListener('click', (ev) => {
|
||||
if (!locked || dragging || !botMapper.getInputData()) return;
|
||||
const w = clickToWorld(ev);
|
||||
if (picks.length >= 2) { // cut already set -- don't wipe it on a stray click
|
||||
setStatus('cut set — drag the dots to adjust, or "reset clip" to start a new one');
|
||||
return;
|
||||
}
|
||||
picks.push(w);
|
||||
redrawMarkers();
|
||||
if (picks.length === 2) setPlaneFromPicks();
|
||||
else setStatus(`point 1 set on ${axis} — click the 2nd point (then drag the dots)`);
|
||||
});
|
||||
|
||||
function setLocked(on) {
|
||||
locked = on;
|
||||
const b = document.getElementById('lock');
|
||||
b.classList.toggle('active', on);
|
||||
b.textContent = on ? '🔒 2D lock' : '🔓 rotate';
|
||||
if (on) { positionBottomCamera(); botInteractor.setInteractorStyle(null); document.getElementById('bottom').style.cursor = 'crosshair'; }
|
||||
else { botInteractor.setInteractorStyle(vtkInteractorStyleTrackballCamera.newInstance()); document.getElementById('bottom').style.cursor = 'grab'; }
|
||||
botRW.render();
|
||||
redrawMarkers();
|
||||
setStatus(on ? `2D locked (${axis}) — click 2 points / drag dots to set the cut`
|
||||
: 'view unlocked — drag to orbit & inspect; lock to edit the cut');
|
||||
}
|
||||
|
||||
function setAxis(a) {
|
||||
axis = a; picks = [];
|
||||
for (const k of ['XY', 'YZ', 'XZ']) document.getElementById('ax' + k).classList.toggle('active', k === a);
|
||||
setLocked(true); // axis pick implies locked 2D
|
||||
setStatus(`cut view: ${a}. click 2 points in the bottom view.`);
|
||||
}
|
||||
document.getElementById('axXY').onclick = () => setAxis('XY');
|
||||
document.getElementById('axYZ').onclick = () => setAxis('YZ');
|
||||
document.getElementById('axXZ').onclick = () => setAxis('XZ');
|
||||
document.getElementById('lock').onclick = () => setLocked(!locked);
|
||||
document.getElementById('flip').onclick = () => { clipNormalSign *= -1; applyClip(); };
|
||||
document.getElementById('reset').onclick = () => { lastPlane = null; picks = []; topMapper.removeAllClippingPlanes(); topRW.render(); redrawMarkers(); setStatus('clip cleared'); postPlaneUpdate(); };
|
||||
document.getElementById('r2d').onclick = () => {
|
||||
render2D = !render2D;
|
||||
document.getElementById('r2d').classList.toggle('active', render2D);
|
||||
document.getElementById('section').style.display = render2D ? 'block' : 'none';
|
||||
if (render2D) setTimeout(() => { section.resize(); updateSection(); }, 30);
|
||||
setStatus(render2D ? 'cross-section view on (mesh ∩ plane)' : 'cross-section view off');
|
||||
};
|
||||
document.getElementById('thick').addEventListener('input', (e) => {
|
||||
thickness = Math.max(1, Number(e.target.value) || 2);
|
||||
sectionActor.getProperty().setLineWidth(thickness);
|
||||
sectionRW.render();
|
||||
});
|
||||
|
||||
async function loadMesh(url) {
|
||||
setStatus('loading mesh…');
|
||||
const reader = vtkXMLPolyDataReader.newInstance();
|
||||
const resp = await fetch(url);
|
||||
if (!resp.ok) { setStatus('load failed: HTTP ' + resp.status); return; }
|
||||
const ab = await resp.arrayBuffer();
|
||||
reader.parseAsArrayBuffer(ab);
|
||||
const poly = reader.getOutputData(0);
|
||||
meshPoly = poly;
|
||||
topMapper.setInputData(poly);
|
||||
topMapper.setScalarVisibility(false);
|
||||
botMapper.setInputData(poly);
|
||||
botMapper.setScalarVisibility(false);
|
||||
bounds = poly.getBounds();
|
||||
lastPlane = null; clipNormalSign = 1; picks = [];
|
||||
topMapper.removeAllClippingPlanes();
|
||||
topRen.resetCamera();
|
||||
topRW.render();
|
||||
setLocked(true); // positions the ortho cam + redraws markers
|
||||
setStatus('mesh loaded — pick cut view (XY/YZ/XZ), click 2 points, drag the dots to adjust.');
|
||||
postPlaneUpdate(); // a new mesh invalidates any previous plane the parent had cached
|
||||
}
|
||||
|
||||
window.addEventListener('message', (event) => {
|
||||
const d = event.data || {};
|
||||
if (d.type === 'LOAD_MESH' && d.filepath) loadMesh(d.filepath);
|
||||
});
|
||||
|
||||
setTimeout(resizeAll, 50);
|
||||
console.log('[GeomPack Slicer] ready');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,733 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>GeomPack UV Viewer</title>
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
background: #2a2a2a;
|
||||
font-family: monospace;
|
||||
}
|
||||
#container {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
}
|
||||
.view-panel {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
border: 1px solid #444;
|
||||
}
|
||||
.view-label {
|
||||
position: absolute;
|
||||
top: 5px;
|
||||
left: 5px;
|
||||
color: #888;
|
||||
font-size: 11px;
|
||||
z-index: 10;
|
||||
background: rgba(0,0,0,0.5);
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
canvas {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
}
|
||||
#loading {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
color: white;
|
||||
font-size: 14px;
|
||||
text-align: center;
|
||||
z-index: 100;
|
||||
}
|
||||
#info {
|
||||
position: absolute;
|
||||
bottom: 5px;
|
||||
left: 5px;
|
||||
color: #aaa;
|
||||
font-size: 10px;
|
||||
z-index: 10;
|
||||
background: rgba(0,0,0,0.7);
|
||||
padding: 4px 8px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.highlight-point {
|
||||
position: absolute;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
background: #ff4444;
|
||||
border-radius: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
pointer-events: none;
|
||||
z-index: 20;
|
||||
box-shadow: 0 0 8px #ff4444;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="container">
|
||||
<div class="view-panel" id="mesh-panel">
|
||||
<div class="view-label">3D Mesh</div>
|
||||
<canvas id="mesh-canvas"></canvas>
|
||||
</div>
|
||||
<div class="view-panel" id="uv-panel">
|
||||
<div class="view-label">UV Layout</div>
|
||||
<canvas id="uv-canvas"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
<div id="loading">Ready to load mesh</div>
|
||||
<div id="info"></div>
|
||||
|
||||
<script type="importmap">
|
||||
{
|
||||
"imports": {
|
||||
"three": "https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js",
|
||||
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<script type="module">
|
||||
import * as THREE from 'three';
|
||||
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
|
||||
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
|
||||
import { OBJLoader } from 'three/addons/loaders/OBJLoader.js';
|
||||
|
||||
console.log('[GeomPack UV Viewer] Initializing...');
|
||||
|
||||
const loading = document.getElementById('loading');
|
||||
const infoDiv = document.getElementById('info');
|
||||
const meshCanvas = document.getElementById('mesh-canvas');
|
||||
const uvCanvas = document.getElementById('uv-canvas');
|
||||
const meshPanel = document.getElementById('mesh-panel');
|
||||
const uvPanel = document.getElementById('uv-panel');
|
||||
|
||||
// Configuration
|
||||
let showChecker = false;
|
||||
let showWireframe = true;
|
||||
|
||||
// 3D Mesh Scene Setup
|
||||
const meshScene = new THREE.Scene();
|
||||
meshScene.background = new THREE.Color(0x2a2a2a);
|
||||
|
||||
const meshCamera = new THREE.PerspectiveCamera(45, 1, 0.1, 1000);
|
||||
meshCamera.position.set(2, 2, 2);
|
||||
|
||||
const meshRenderer = new THREE.WebGLRenderer({ canvas: meshCanvas, antialias: true });
|
||||
meshRenderer.setPixelRatio(window.devicePixelRatio);
|
||||
|
||||
const meshControls = new OrbitControls(meshCamera, meshCanvas);
|
||||
meshControls.enableDamping = true;
|
||||
meshControls.dampingFactor = 0.05;
|
||||
meshControls.zoomSpeed = 3.0;
|
||||
|
||||
// Lighting for 3D mesh
|
||||
const ambientLight = new THREE.AmbientLight(0xffffff, 0.6);
|
||||
meshScene.add(ambientLight);
|
||||
const dirLight1 = new THREE.DirectionalLight(0xffffff, 0.8);
|
||||
dirLight1.position.set(5, 10, 7);
|
||||
meshScene.add(dirLight1);
|
||||
const dirLight2 = new THREE.DirectionalLight(0xffffff, 0.4);
|
||||
dirLight2.position.set(-5, -5, -7);
|
||||
meshScene.add(dirLight2);
|
||||
|
||||
// Grid and axes for mesh view
|
||||
const grid = new THREE.GridHelper(10, 10, 0x444444, 0x222222);
|
||||
meshScene.add(grid);
|
||||
const axes = new THREE.AxesHelper(1);
|
||||
meshScene.add(axes);
|
||||
|
||||
// 2D UV Scene Setup
|
||||
const uvScene = new THREE.Scene();
|
||||
uvScene.background = new THREE.Color(0x1a1a1a);
|
||||
|
||||
const uvCamera = new THREE.OrthographicCamera(0, 1, 1, 0, -1, 1);
|
||||
uvCamera.position.z = 0.5;
|
||||
|
||||
const uvRenderer = new THREE.WebGLRenderer({ canvas: uvCanvas, antialias: true });
|
||||
uvRenderer.setPixelRatio(window.devicePixelRatio);
|
||||
|
||||
// UV grid (0-1 space)
|
||||
const uvGridHelper = new THREE.GridHelper(1, 10, 0x444444, 0x333333);
|
||||
uvGridHelper.rotation.x = Math.PI / 2;
|
||||
uvGridHelper.position.set(0.5, 0.5, -0.1);
|
||||
uvScene.add(uvGridHelper);
|
||||
|
||||
// UV bounds box
|
||||
const uvBoundsGeom = new THREE.BufferGeometry();
|
||||
const uvBoundsVerts = new Float32Array([
|
||||
0, 0, 0, 1, 0, 0,
|
||||
1, 0, 0, 1, 1, 0,
|
||||
1, 1, 0, 0, 1, 0,
|
||||
0, 1, 0, 0, 0, 0
|
||||
]);
|
||||
uvBoundsGeom.setAttribute('position', new THREE.BufferAttribute(uvBoundsVerts, 3));
|
||||
const uvBoundsMat = new THREE.LineBasicMaterial({ color: 0x666666 });
|
||||
const uvBoundsLine = new THREE.LineSegments(uvBoundsGeom, uvBoundsMat);
|
||||
uvScene.add(uvBoundsLine);
|
||||
|
||||
// Current mesh and UV data
|
||||
let currentMesh = null;
|
||||
let currentWireframe = null;
|
||||
let uvLinesMesh = null;
|
||||
let uvData = null;
|
||||
let meshVertices = null;
|
||||
let meshFaces = null;
|
||||
|
||||
// Raycaster for picking
|
||||
const meshRaycaster = new THREE.Raycaster();
|
||||
const uvRaycaster = new THREE.Raycaster();
|
||||
|
||||
// Highlight markers
|
||||
let meshHighlightSphere = null; // 3D sphere in scene
|
||||
let uvHighlight = null;
|
||||
let highlightedPoint3D = null; // Store 3D world position
|
||||
let highlightedPointUV = null; // Store UV position
|
||||
|
||||
// Create highlight sphere for 3D mesh
|
||||
const highlightGeom = new THREE.SphereGeometry(1, 16, 16);
|
||||
const highlightMat = new THREE.MeshBasicMaterial({
|
||||
color: 0xff4444,
|
||||
transparent: true,
|
||||
opacity: 0.9,
|
||||
depthTest: true,
|
||||
depthWrite: true
|
||||
});
|
||||
|
||||
// Create crosshair lines for highlighting (unused - kept for reference)
|
||||
// function createCrosshair() {
|
||||
// const lineGeom = new THREE.BufferGeometry();
|
||||
// const positions = new Float32Array([
|
||||
// -2, 0, 0, 2, 0, 0, // X axis (red)
|
||||
// 0, -2, 0, 0, 2, 0, // Y axis (green)
|
||||
// 0, 0, -2, 0, 0, 2 // Z axis (blue)
|
||||
// ]);
|
||||
// lineGeom.setAttribute('position', new THREE.BufferAttribute(positions, 3));
|
||||
//
|
||||
// // Create different colors for each axis
|
||||
// const colors = new Float32Array([
|
||||
// 1, 0, 0, 1, 0, 0, // X axis - red
|
||||
// 0, 1, 0, 0, 1, 0, // Y axis - green
|
||||
// 0, 0, 1, 0, 0, 1 // Z axis - blue
|
||||
// ]);
|
||||
// lineGeom.setAttribute('color', new THREE.BufferAttribute(colors, 3));
|
||||
//
|
||||
// const lineMat = new THREE.LineBasicMaterial({
|
||||
// vertexColors: true,
|
||||
// linewidth: 2,
|
||||
// depthTest: true,
|
||||
// depthWrite: true,
|
||||
// transparent: true,
|
||||
// opacity: 0.8
|
||||
// });
|
||||
//
|
||||
// return new THREE.LineSegments(lineGeom, lineMat);
|
||||
// }
|
||||
|
||||
// Update highlight positions (call in animation loop)
|
||||
function updateHighlightPositions() {
|
||||
// Sphere position is already set, no need to update
|
||||
}
|
||||
|
||||
// Animation loop
|
||||
function animate() {
|
||||
requestAnimationFrame(animate);
|
||||
meshControls.update();
|
||||
updateHighlightPositions();
|
||||
meshRenderer.render(meshScene, meshCamera);
|
||||
uvRenderer.render(uvScene, uvCamera);
|
||||
}
|
||||
animate();
|
||||
|
||||
// Handle window resize
|
||||
function updateSizes() {
|
||||
const meshRect = meshPanel.getBoundingClientRect();
|
||||
const uvRect = uvPanel.getBoundingClientRect();
|
||||
|
||||
meshCamera.aspect = meshRect.width / meshRect.height;
|
||||
meshCamera.updateProjectionMatrix();
|
||||
meshRenderer.setSize(meshRect.width, meshRect.height);
|
||||
|
||||
// Update UV camera to maintain aspect ratio
|
||||
const uvAspect = uvRect.width / uvRect.height;
|
||||
if (uvAspect > 1) {
|
||||
uvCamera.left = -0.1;
|
||||
uvCamera.right = 1.1;
|
||||
uvCamera.top = 0.6 + 0.5 / uvAspect;
|
||||
uvCamera.bottom = 0.4 - 0.5 / uvAspect;
|
||||
} else {
|
||||
uvCamera.left = 0.5 - 0.5 * uvAspect - 0.1;
|
||||
uvCamera.right = 0.5 + 0.5 * uvAspect + 0.1;
|
||||
uvCamera.top = 1.1;
|
||||
uvCamera.bottom = -0.1;
|
||||
}
|
||||
uvCamera.updateProjectionMatrix();
|
||||
uvRenderer.setSize(uvRect.width, uvRect.height);
|
||||
}
|
||||
|
||||
window.addEventListener('resize', updateSizes);
|
||||
setTimeout(updateSizes, 100);
|
||||
|
||||
// Create checker texture
|
||||
function createCheckerTexture(size = 512, squares = 16) {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = size;
|
||||
canvas.height = size;
|
||||
const ctx = canvas.getContext('2d');
|
||||
const squareSize = size / squares;
|
||||
|
||||
for (let i = 0; i < squares; i++) {
|
||||
for (let j = 0; j < squares; j++) {
|
||||
ctx.fillStyle = (i + j) % 2 === 0 ? '#888888' : '#444444';
|
||||
ctx.fillRect(i * squareSize, j * squareSize, squareSize, squareSize);
|
||||
}
|
||||
}
|
||||
|
||||
const texture = new THREE.CanvasTexture(canvas);
|
||||
texture.wrapS = THREE.RepeatWrapping;
|
||||
texture.wrapT = THREE.RepeatWrapping;
|
||||
return texture;
|
||||
}
|
||||
|
||||
// Load mesh
|
||||
function loadMesh(filepath, uvDataPath) {
|
||||
console.log('[GeomPack UV Viewer] Loading mesh:', filepath);
|
||||
loading.textContent = 'Loading mesh...';
|
||||
loading.style.display = 'block';
|
||||
|
||||
// Remove old mesh
|
||||
if (currentMesh) {
|
||||
meshScene.remove(currentMesh);
|
||||
currentMesh = null;
|
||||
}
|
||||
if (currentWireframe) {
|
||||
meshScene.remove(currentWireframe);
|
||||
currentWireframe = null;
|
||||
}
|
||||
if (uvLinesMesh) {
|
||||
uvScene.remove(uvLinesMesh);
|
||||
uvLinesMesh = null;
|
||||
}
|
||||
|
||||
const isGLB = filepath.includes('.glb') || filepath.includes('.gltf');
|
||||
const loader = isGLB ? new GLTFLoader() : new OBJLoader();
|
||||
|
||||
loader.load(
|
||||
filepath,
|
||||
(result) => {
|
||||
currentMesh = isGLB ? result.scene : result;
|
||||
|
||||
// Center mesh
|
||||
const box = new THREE.Box3().setFromObject(currentMesh);
|
||||
const center = box.getCenter(new THREE.Vector3());
|
||||
currentMesh.position.sub(center);
|
||||
|
||||
// Store mesh geometry for picking
|
||||
currentMesh.traverse((child) => {
|
||||
if (child.isMesh) {
|
||||
meshVertices = child.geometry.attributes.position.array;
|
||||
if (child.geometry.index) {
|
||||
meshFaces = child.geometry.index.array;
|
||||
}
|
||||
|
||||
// Apply material
|
||||
if (showChecker) {
|
||||
child.material = new THREE.MeshStandardMaterial({
|
||||
map: createCheckerTexture(),
|
||||
metalness: 0.1,
|
||||
roughness: 0.9,
|
||||
side: THREE.DoubleSide
|
||||
});
|
||||
} else {
|
||||
child.material = new THREE.MeshStandardMaterial({
|
||||
color: 0x88aacc,
|
||||
metalness: 0.3,
|
||||
roughness: 0.7,
|
||||
side: THREE.DoubleSide
|
||||
});
|
||||
}
|
||||
|
||||
// Add wireframe overlay as child of mesh (inherits transforms)
|
||||
if (showWireframe) {
|
||||
const wireframeGeom = new THREE.WireframeGeometry(child.geometry);
|
||||
const wireframeMat = new THREE.LineBasicMaterial({
|
||||
color: 0x000000,
|
||||
opacity: 0.3,
|
||||
transparent: true
|
||||
});
|
||||
currentWireframe = new THREE.LineSegments(wireframeGeom, wireframeMat);
|
||||
// Add as child of the mesh child so it inherits position/rotation
|
||||
child.add(currentWireframe);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
meshScene.add(currentMesh);
|
||||
|
||||
// Adjust camera
|
||||
const size = box.getSize(new THREE.Vector3());
|
||||
const maxDim = Math.max(size.x, size.y, size.z);
|
||||
const distance = maxDim * 2.5;
|
||||
meshCamera.position.set(distance, distance, distance);
|
||||
meshCamera.lookAt(0, 0, 0);
|
||||
meshControls.target.set(0, 0, 0);
|
||||
meshControls.update();
|
||||
|
||||
loading.textContent = 'Loading UV data...';
|
||||
|
||||
// Load UV data
|
||||
if (uvDataPath) {
|
||||
fetch(uvDataPath)
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
uvData = data;
|
||||
createUVLayout(data);
|
||||
loading.style.display = 'none';
|
||||
updateInfo();
|
||||
console.log('[GeomPack UV Viewer] UV data loaded:', data.uvs.length, 'UVs');
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('[GeomPack UV Viewer] Error loading UV data:', err);
|
||||
loading.textContent = 'No UV data available';
|
||||
setTimeout(() => { loading.style.display = 'none'; }, 2000);
|
||||
});
|
||||
} else {
|
||||
loading.textContent = 'No UV data';
|
||||
setTimeout(() => { loading.style.display = 'none'; }, 2000);
|
||||
}
|
||||
|
||||
console.log('[GeomPack UV Viewer] Mesh Loaded successfully');
|
||||
},
|
||||
(progress) => {
|
||||
if (progress.lengthComputable) {
|
||||
const percent = (progress.loaded / progress.total * 100).toFixed(0);
|
||||
loading.textContent = `Loading mesh... ${percent}%`;
|
||||
}
|
||||
},
|
||||
(error) => {
|
||||
console.error('[GeomPack UV Viewer] Error loading mesh:', error);
|
||||
loading.textContent = 'Error loading mesh';
|
||||
loading.style.color = '#ff6666';
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Create UV layout wireframe
|
||||
function createUVLayout(data) {
|
||||
const uvs = data.uvs;
|
||||
const faces = data.faces;
|
||||
|
||||
// Create line segments for UV triangles
|
||||
const positions = [];
|
||||
for (const face of faces) {
|
||||
const uv0 = uvs[face[0]];
|
||||
const uv1 = uvs[face[1]];
|
||||
const uv2 = uvs[face[2]];
|
||||
|
||||
// Edge 0-1
|
||||
positions.push(uv0[0], uv0[1], 0);
|
||||
positions.push(uv1[0], uv1[1], 0);
|
||||
// Edge 1-2
|
||||
positions.push(uv1[0], uv1[1], 0);
|
||||
positions.push(uv2[0], uv2[1], 0);
|
||||
// Edge 2-0
|
||||
positions.push(uv2[0], uv2[1], 0);
|
||||
positions.push(uv0[0], uv0[1], 0);
|
||||
}
|
||||
|
||||
const geometry = new THREE.BufferGeometry();
|
||||
geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
|
||||
|
||||
const material = new THREE.LineBasicMaterial({
|
||||
color: 0x44aaff,
|
||||
opacity: 0.8,
|
||||
transparent: true
|
||||
});
|
||||
|
||||
uvLinesMesh = new THREE.LineSegments(geometry, material);
|
||||
uvScene.add(uvLinesMesh);
|
||||
}
|
||||
|
||||
// Update info display
|
||||
function updateInfo() {
|
||||
let info = '';
|
||||
if (uvData) {
|
||||
info = `UVs: ${uvData.uvs.length} | Faces: ${uvData.faces.length}`;
|
||||
}
|
||||
infoDiv.textContent = info;
|
||||
}
|
||||
|
||||
// Highlight corresponding points on click
|
||||
function highlightPoint(point3D, pointUV) {
|
||||
// Remove old highlights
|
||||
if (meshHighlightSphere) {
|
||||
meshScene.remove(meshHighlightSphere);
|
||||
meshHighlightSphere = null;
|
||||
}
|
||||
if (uvHighlight) {
|
||||
uvPanel.removeChild(uvHighlight);
|
||||
uvHighlight = null;
|
||||
}
|
||||
|
||||
// Store positions for continuous updates
|
||||
highlightedPoint3D = point3D ? point3D.clone() : null;
|
||||
highlightedPointUV = pointUV ? pointUV.clone() : null;
|
||||
|
||||
// Create 3D mesh highlight sphere
|
||||
if (point3D) {
|
||||
meshHighlightSphere = new THREE.Mesh(highlightGeom, highlightMat);
|
||||
meshHighlightSphere.position.copy(point3D);
|
||||
|
||||
// Scale sphere based on mesh size
|
||||
if (currentMesh) {
|
||||
const box = new THREE.Box3().setFromObject(currentMesh);
|
||||
const size = box.getSize(new THREE.Vector3());
|
||||
const maxDim = Math.max(size.x, size.y, size.z);
|
||||
const scale = maxDim * 0.03; // 3% of mesh size (slightly larger for better visibility)
|
||||
meshHighlightSphere.scale.setScalar(scale);
|
||||
}
|
||||
|
||||
// Ensure sphere always renders on top
|
||||
meshHighlightSphere.renderOrder = 999;
|
||||
|
||||
meshScene.add(meshHighlightSphere);
|
||||
console.log('[GeomPack UV Viewer] Sphere placed at:', point3D.x, point3D.y, point3D.z, 'scale:', meshHighlightSphere.scale.x);
|
||||
}
|
||||
|
||||
// Create UV highlight
|
||||
if (pointUV) {
|
||||
const uvRect = uvCanvas.getBoundingClientRect();
|
||||
// Convert UV coords to screen coords
|
||||
const ndcX = (pointUV.x - uvCamera.left) / (uvCamera.right - uvCamera.left);
|
||||
const ndcY = (pointUV.y - uvCamera.bottom) / (uvCamera.top - uvCamera.bottom);
|
||||
const x = ndcX * uvRect.width;
|
||||
const y = (1 - ndcY) * uvRect.height;
|
||||
|
||||
uvHighlight = document.createElement('div');
|
||||
uvHighlight.className = 'highlight-point';
|
||||
uvHighlight.style.left = x + 'px';
|
||||
uvHighlight.style.top = y + 'px';
|
||||
uvPanel.appendChild(uvHighlight);
|
||||
}
|
||||
}
|
||||
|
||||
// Mesh canvas click detection (using mousedown/mouseup to avoid OrbitControls interference)
|
||||
let meshMouseDownPos = null;
|
||||
let meshMouseDownTime = 0;
|
||||
|
||||
meshCanvas.addEventListener('mousedown', (event) => {
|
||||
meshMouseDownPos = new THREE.Vector2(event.clientX, event.clientY);
|
||||
meshMouseDownTime = Date.now();
|
||||
console.log('[GeomPack UV Viewer DEBUG] Mesh mousedown at:', event.clientX, event.clientY);
|
||||
});
|
||||
|
||||
meshCanvas.addEventListener('mouseup', (event) => {
|
||||
console.log('[GeomPack UV Viewer DEBUG] Mesh mouseup - currentMesh:', !!currentMesh, 'uvData:', !!uvData, 'mouseDownPos:', !!meshMouseDownPos);
|
||||
|
||||
if (!currentMesh || !uvData || !meshMouseDownPos) return;
|
||||
|
||||
const mouseUpPos = new THREE.Vector2(event.clientX, event.clientY);
|
||||
const dragDistance = meshMouseDownPos.distanceTo(mouseUpPos);
|
||||
const dragTime = Date.now() - meshMouseDownTime;
|
||||
|
||||
console.log('[GeomPack UV Viewer DEBUG] Drag distance:', dragDistance.toFixed(2), 'px, Time:', dragTime, 'ms');
|
||||
|
||||
// Only treat as click if mouse moved less than 5 pixels and took less than 300ms
|
||||
if (dragDistance < 5 && dragTime < 300) {
|
||||
console.log('[GeomPack UV Viewer DEBUG] Click threshold met, raycasting...');
|
||||
|
||||
const rect = meshCanvas.getBoundingClientRect();
|
||||
const mouse = new THREE.Vector2(
|
||||
((event.clientX - rect.left) / rect.width) * 2 - 1,
|
||||
-((event.clientY - rect.top) / rect.height) * 2 + 1
|
||||
);
|
||||
|
||||
meshRaycaster.setFromCamera(mouse, meshCamera);
|
||||
const intersects = meshRaycaster.intersectObject(currentMesh, true);
|
||||
|
||||
console.log('[GeomPack UV Viewer DEBUG] Intersections found:', intersects.length);
|
||||
|
||||
if (intersects.length > 0) {
|
||||
const hit = intersects[0];
|
||||
const hitMesh = hit.object;
|
||||
const faceIndex = hit.faceIndex;
|
||||
|
||||
console.log('[GeomPack UV Viewer DEBUG] Hit face index:', faceIndex);
|
||||
|
||||
// Get the 3 vertex positions from the hit face
|
||||
const geometry = hitMesh.geometry;
|
||||
const position = geometry.attributes.position;
|
||||
const index = geometry.index;
|
||||
|
||||
if (index && faceIndex * 3 + 2 < index.count) {
|
||||
const i0 = index.array[faceIndex * 3];
|
||||
const i1 = index.array[faceIndex * 3 + 1];
|
||||
const i2 = index.array[faceIndex * 3 + 2];
|
||||
|
||||
// Get vertex positions in local space
|
||||
const v0 = new THREE.Vector3(position.getX(i0), position.getY(i0), position.getZ(i0));
|
||||
const v1 = new THREE.Vector3(position.getX(i1), position.getY(i1), position.getZ(i1));
|
||||
const v2 = new THREE.Vector3(position.getX(i2), position.getY(i2), position.getZ(i2));
|
||||
|
||||
console.log('[GeomPack UV Viewer DEBUG] Hit face vertices:', v0.toArray(), v1.toArray(), v2.toArray());
|
||||
|
||||
// Find matching face in uvData by comparing vertex positions
|
||||
let matchedFaceIdx = -1;
|
||||
const tolerance = 0.0001;
|
||||
|
||||
currentMesh.traverse((child) => {
|
||||
if (child.isMesh && child.geometry && matchedFaceIdx === -1) {
|
||||
const childPos = child.geometry.attributes.position;
|
||||
|
||||
// Search through all faces to find matching vertices
|
||||
for (let fIdx = 0; fIdx < uvData.faces.length; fIdx++) {
|
||||
const face = uvData.faces[fIdx];
|
||||
const fv0 = new THREE.Vector3(childPos.getX(face[0]), childPos.getY(face[0]), childPos.getZ(face[0]));
|
||||
const fv1 = new THREE.Vector3(childPos.getX(face[1]), childPos.getY(face[1]), childPos.getZ(face[1]));
|
||||
const fv2 = new THREE.Vector3(childPos.getX(face[2]), childPos.getY(face[2]), childPos.getZ(face[2]));
|
||||
|
||||
// Check if vertices match (in any order)
|
||||
if ((v0.distanceTo(fv0) < tolerance && v1.distanceTo(fv1) < tolerance && v2.distanceTo(fv2) < tolerance) ||
|
||||
(v0.distanceTo(fv0) < tolerance && v1.distanceTo(fv2) < tolerance && v2.distanceTo(fv1) < tolerance) ||
|
||||
(v0.distanceTo(fv1) < tolerance && v1.distanceTo(fv0) < tolerance && v2.distanceTo(fv2) < tolerance) ||
|
||||
(v0.distanceTo(fv1) < tolerance && v1.distanceTo(fv2) < tolerance && v2.distanceTo(fv0) < tolerance) ||
|
||||
(v0.distanceTo(fv2) < tolerance && v1.distanceTo(fv0) < tolerance && v2.distanceTo(fv1) < tolerance) ||
|
||||
(v0.distanceTo(fv2) < tolerance && v1.distanceTo(fv1) < tolerance && v2.distanceTo(fv0) < tolerance)) {
|
||||
matchedFaceIdx = fIdx;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (matchedFaceIdx >= 0) {
|
||||
const face = uvData.faces[matchedFaceIdx];
|
||||
console.log('[GeomPack UV Viewer DEBUG] Matched to uvData face index:', matchedFaceIdx);
|
||||
|
||||
// Interpolate UV coordinates
|
||||
const uv0 = uvData.uvs[face[0]];
|
||||
const uv1 = uvData.uvs[face[1]];
|
||||
const uv2 = uvData.uvs[face[2]];
|
||||
|
||||
// Simple center of triangle for now
|
||||
const uvPoint = new THREE.Vector2(
|
||||
(uv0[0] + uv1[0] + uv2[0]) / 3,
|
||||
(uv0[1] + uv1[1] + uv2[1]) / 3
|
||||
);
|
||||
|
||||
highlightPoint(hit.point, uvPoint);
|
||||
console.log('[GeomPack UV Viewer] Clicked face:', matchedFaceIdx, 'UV:', uvPoint);
|
||||
} else {
|
||||
console.log('[GeomPack UV Viewer DEBUG] No matching face found in uvData');
|
||||
}
|
||||
} else {
|
||||
console.log('[GeomPack UV Viewer DEBUG] Invalid face index or geometry');
|
||||
}
|
||||
} else {
|
||||
console.log('[GeomPack UV Viewer DEBUG] No intersections - missed the mesh');
|
||||
}
|
||||
} else {
|
||||
console.log('[GeomPack UV Viewer DEBUG] Click threshold NOT met (drag or too slow)');
|
||||
}
|
||||
|
||||
meshMouseDownPos = null;
|
||||
});
|
||||
|
||||
// UV canvas click handler
|
||||
uvCanvas.addEventListener('click', (event) => {
|
||||
if (!currentMesh || !uvData) return;
|
||||
|
||||
const rect = uvCanvas.getBoundingClientRect();
|
||||
const x = (event.clientX - rect.left) / rect.width;
|
||||
const y = (event.clientY - rect.top) / rect.height;
|
||||
|
||||
// Convert screen coords to UV space
|
||||
const uvX = uvCamera.left + x * (uvCamera.right - uvCamera.left);
|
||||
const uvY = uvCamera.top - y * (uvCamera.top - uvCamera.bottom);
|
||||
|
||||
// Find nearest face in UV space
|
||||
let nearestFace = -1;
|
||||
let nearestDist = Infinity;
|
||||
|
||||
for (let i = 0; i < uvData.faces.length; i++) {
|
||||
const face = uvData.faces[i];
|
||||
const uv0 = uvData.uvs[face[0]];
|
||||
const uv1 = uvData.uvs[face[1]];
|
||||
const uv2 = uvData.uvs[face[2]];
|
||||
|
||||
// Check if point is inside triangle (simplified - just use center distance)
|
||||
const centerX = (uv0[0] + uv1[0] + uv2[0]) / 3;
|
||||
const centerY = (uv0[1] + uv1[1] + uv2[1]) / 3;
|
||||
const dist = Math.sqrt((uvX - centerX) ** 2 + (uvY - centerY) ** 2);
|
||||
|
||||
if (dist < nearestDist) {
|
||||
nearestDist = dist;
|
||||
nearestFace = i;
|
||||
}
|
||||
}
|
||||
|
||||
if (nearestFace >= 0 && nearestDist < 0.1) {
|
||||
// Get 3D position of face center
|
||||
const face = uvData.faces[nearestFace];
|
||||
const uv0 = uvData.uvs[face[0]];
|
||||
const uv1 = uvData.uvs[face[1]];
|
||||
const uv2 = uvData.uvs[face[2]];
|
||||
|
||||
const uvPoint = new THREE.Vector2(
|
||||
(uv0[0] + uv1[0] + uv2[0]) / 3,
|
||||
(uv0[1] + uv1[1] + uv2[1]) / 3
|
||||
);
|
||||
|
||||
// Get 3D face center from mesh
|
||||
let point3D = null;
|
||||
currentMesh.traverse((child) => {
|
||||
if (child.isMesh && child.geometry) {
|
||||
const pos = child.geometry.attributes.position;
|
||||
const idx = child.geometry.index;
|
||||
if (idx && nearestFace * 3 + 2 < idx.count) {
|
||||
const i0 = idx.array[nearestFace * 3];
|
||||
const i1 = idx.array[nearestFace * 3 + 1];
|
||||
const i2 = idx.array[nearestFace * 3 + 2];
|
||||
|
||||
const v0 = new THREE.Vector3(pos.getX(i0), pos.getY(i0), pos.getZ(i0));
|
||||
const v1 = new THREE.Vector3(pos.getX(i1), pos.getY(i1), pos.getZ(i1));
|
||||
const v2 = new THREE.Vector3(pos.getX(i2), pos.getY(i2), pos.getZ(i2));
|
||||
|
||||
point3D = new THREE.Vector3()
|
||||
.addVectors(v0, v1)
|
||||
.add(v2)
|
||||
.divideScalar(3);
|
||||
|
||||
// Transform from local object space to world space
|
||||
child.localToWorld(point3D);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
highlightPoint(point3D, uvPoint);
|
||||
console.log('[GeomPack UV Viewer] Clicked UV:', uvX.toFixed(3), uvY.toFixed(3), 'Face:', nearestFace);
|
||||
}
|
||||
});
|
||||
|
||||
// Listen for messages from parent window
|
||||
window.addEventListener('message', (event) => {
|
||||
console.log('[GeomPack UV Viewer] Received message:', event.data);
|
||||
|
||||
if (event.data.type === 'LOAD_MESH_UV') {
|
||||
const { meshPath, uvDataPath, checker, wireframe } = event.data;
|
||||
showChecker = checker || false;
|
||||
showWireframe = wireframe !== false;
|
||||
loadMesh(meshPath, uvDataPath);
|
||||
}
|
||||
});
|
||||
|
||||
console.log('[GeomPack UV Viewer] Ready');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,461 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>GeomPack VTK.js Textured Viewer (Unified)</title>
|
||||
<link rel="stylesheet" href="./js/viewer/ui/styles.css">
|
||||
<style>
|
||||
/* Minimal overrides for textured viewer */
|
||||
#infoPanel {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
left: 10px;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
color: #ccc;
|
||||
font-size: 11px;
|
||||
z-index: 100;
|
||||
}
|
||||
#infoPanel div {
|
||||
margin: 2px 0;
|
||||
}
|
||||
.info-label { color: #888; }
|
||||
.info-value { color: #4fc3f7; }
|
||||
.info-warning { color: #ffb74d; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="viewerContainer">
|
||||
<div id="container"></div>
|
||||
<div id="loading" class="loading">
|
||||
<div class="spinner"></div>
|
||||
<div class="loading-text">Ready to load textured mesh</div>
|
||||
</div>
|
||||
|
||||
<!-- Info panel for texture/material status -->
|
||||
<div id="infoPanel" style="display: none;">
|
||||
<div><span class="info-label">Visual:</span> <span id="infoVisual" class="info-value">-</span></div>
|
||||
<div><span class="info-label">Texture:</span> <span id="infoTexture" class="info-value">-</span></div>
|
||||
<div><span class="info-label">Materials:</span> <span id="infoMaterial" class="info-value">-</span></div>
|
||||
<div id="warningRow" style="display: none;"><span class="info-warning">PBR requires WebGPU</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="controls">
|
||||
<!-- Settings button -->
|
||||
<button id="settingsButton" class="settings-button" title="Appearance Settings">[Settings]</button>
|
||||
|
||||
<span class="control-separator"></span>
|
||||
|
||||
<label class="control-label">
|
||||
<input type="checkbox" id="showEdges"> Edges
|
||||
</label>
|
||||
|
||||
<span class="control-separator"></span>
|
||||
|
||||
<!-- Camera view buttons -->
|
||||
<button class="view-button" data-view="+X">+X</button>
|
||||
<button class="view-button" data-view="-X">-X</button>
|
||||
<button class="view-button" data-view="+Y">+Y</button>
|
||||
<button class="view-button" data-view="-Y">-Y</button>
|
||||
<button class="view-button" data-view="+Z">+Z</button>
|
||||
<button class="view-button" data-view="-Z">-Z</button>
|
||||
|
||||
<span class="control-separator"></span>
|
||||
|
||||
<button id="screenshot">Screenshot</button>
|
||||
<button id="saveMesh" class="save-button">Save GLB</button>
|
||||
</div>
|
||||
|
||||
<!-- Settings overlay and panel -->
|
||||
<div id="settingsOverlay" class="settings-overlay"></div>
|
||||
<div id="settingsPanel" class="settings-panel">
|
||||
<h3>Appearance Settings</h3>
|
||||
<div class="setting-row">
|
||||
<label>Opacity:</label>
|
||||
<input type="number" id="opacity" min="0" max="1" step="0.1" value="1.0">
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<label>Render Mode:</label>
|
||||
<select id="representationSettings">
|
||||
<option value="2">Surface</option>
|
||||
<option value="1">Wireframe</option>
|
||||
<option value="0">Points</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<label>
|
||||
<input type="checkbox" id="parallelProjection"> Parallel Projection
|
||||
</label>
|
||||
</div>
|
||||
<div class="button-row">
|
||||
<button id="applySettings">Apply</button>
|
||||
<button id="cancelSettings">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Load VTK.js bundle with GLTFImporter support -->
|
||||
<script src="./js/vtk-gltf.js"></script>
|
||||
<!-- Load unified viewer bundle -->
|
||||
<script src="./js/viewer-bundle-vtk.js"></script>
|
||||
|
||||
<script>
|
||||
// Use the unified viewer module
|
||||
const {
|
||||
CameraController,
|
||||
ActorManager,
|
||||
TextureManager,
|
||||
ScreenshotCapture,
|
||||
AxisIndicator,
|
||||
loadMesh,
|
||||
detectFormat,
|
||||
sendMeshLoaded,
|
||||
sendError,
|
||||
extractFilename,
|
||||
getCenter,
|
||||
getMaxDimension,
|
||||
isValidBounds,
|
||||
rgbToString
|
||||
} = window.GeomPackViewer;
|
||||
|
||||
// DOM elements
|
||||
const loading = document.getElementById('loading');
|
||||
const loadingText = loading.querySelector('.loading-text');
|
||||
const container = document.getElementById('container');
|
||||
const infoPanel = document.getElementById('infoPanel');
|
||||
|
||||
// VTK setup
|
||||
const vtkRenderWindow = vtk.Rendering.Core.vtkRenderWindow;
|
||||
const vtkRenderer = vtk.Rendering.Core.vtkRenderer;
|
||||
const vtkOpenGLRenderWindow = vtk.Rendering.OpenGL.vtkRenderWindow;
|
||||
const vtkRenderWindowInteractor = vtk.Rendering.Core.vtkRenderWindowInteractor;
|
||||
const vtkInteractorStyleTrackballCamera = vtk.Interaction.Style.vtkInteractorStyleTrackballCamera;
|
||||
|
||||
// Create render window
|
||||
const renderWindow = vtkRenderWindow.newInstance();
|
||||
const renderer = vtkRenderer.newInstance();
|
||||
renderer.setBackground(0.16, 0.16, 0.16);
|
||||
renderer.setTwoSidedLighting(true);
|
||||
renderer.setUseShadows(false);
|
||||
renderWindow.addRenderer(renderer);
|
||||
|
||||
// Create OpenGL view
|
||||
const openGLRenderWindow = vtkOpenGLRenderWindow.newInstance();
|
||||
openGLRenderWindow.setContainer(container);
|
||||
renderWindow.addView(openGLRenderWindow);
|
||||
|
||||
// Resize to container
|
||||
const { width, height } = container.getBoundingClientRect();
|
||||
openGLRenderWindow.setSize(width, height);
|
||||
|
||||
// Create interactor
|
||||
const interactor = vtkRenderWindowInteractor.newInstance();
|
||||
interactor.setView(openGLRenderWindow);
|
||||
interactor.initialize();
|
||||
interactor.bindEvents(container);
|
||||
|
||||
const interactorStyle = vtkInteractorStyleTrackballCamera.newInstance();
|
||||
interactor.setInteractorStyle(interactorStyle);
|
||||
|
||||
// Initialize modules
|
||||
const cameraController = new CameraController(renderer, renderWindow, {
|
||||
distanceMultiplier: 2.0 // Closer for texture detail
|
||||
});
|
||||
|
||||
const actorManager = new ActorManager(renderWindow);
|
||||
|
||||
const textureManager = new TextureManager(vtk, renderWindow);
|
||||
const screenshotCapture = new ScreenshotCapture(renderWindow);
|
||||
|
||||
// Create axis indicator
|
||||
const axisIndicator = new AxisIndicator(vtk, interactor, {
|
||||
enabled: true,
|
||||
corner: 'BOTTOM_LEFT'
|
||||
});
|
||||
|
||||
// State
|
||||
let currentActors = [];
|
||||
let meshBounds = null;
|
||||
let currentFilename = null;
|
||||
|
||||
// Check GLTF support
|
||||
const gltfSupported = vtk._gltfAvailable && vtk.IO.Geometry.vtkGLTFImporter !== null;
|
||||
|
||||
// Show loading
|
||||
function showLoading(show, text = 'Loading textured mesh...') {
|
||||
loading.style.display = show ? 'flex' : 'none';
|
||||
loadingText.textContent = text;
|
||||
}
|
||||
|
||||
// Send camera state to parent node widget
|
||||
let _cameraWheelTimer = null;
|
||||
function sendCameraState() {
|
||||
const cam = renderer.getActiveCamera();
|
||||
window.parent.postMessage({
|
||||
type: 'WIDGET_UPDATE',
|
||||
widget: 'camera_state',
|
||||
value: JSON.stringify({
|
||||
position: cam.getPosition(),
|
||||
focalPoint: cam.getFocalPoint(),
|
||||
viewUp: cam.getViewUp()
|
||||
})
|
||||
}, '*');
|
||||
}
|
||||
|
||||
function applyCameraState(cameraState) {
|
||||
if (!cameraState) return false;
|
||||
try {
|
||||
const cs = typeof cameraState === 'string' ? JSON.parse(cameraState) : cameraState;
|
||||
if (!cs.position || !cs.focalPoint || !cs.viewUp) return false;
|
||||
const cam = renderer.getActiveCamera();
|
||||
cam.setPosition(...cs.position);
|
||||
cam.setFocalPoint(...cs.focalPoint);
|
||||
cam.setViewUp(...cs.viewUp);
|
||||
renderer.resetCameraClippingRange();
|
||||
return true;
|
||||
} catch(e) { return false; }
|
||||
}
|
||||
|
||||
interactor.onEndAnimation(() => sendCameraState());
|
||||
container.addEventListener('wheel', () => {
|
||||
clearTimeout(_cameraWheelTimer);
|
||||
_cameraWheelTimer = setTimeout(sendCameraState, 150);
|
||||
});
|
||||
|
||||
let _pendingCameraState = null;
|
||||
|
||||
// Load mesh function
|
||||
async function loadTexturedMesh(filepath, metadata = {}) {
|
||||
showLoading(true, 'Loading textured mesh...');
|
||||
|
||||
// Extract filename
|
||||
const urlParams = new URLSearchParams(filepath.split('?')[1] || '');
|
||||
currentFilename = urlParams.get('filename') || filepath.split('/').pop().split('?')[0];
|
||||
|
||||
// Update info panel
|
||||
if (metadata.visual_kind) {
|
||||
document.getElementById('infoVisual').textContent = metadata.visual_kind;
|
||||
}
|
||||
if (metadata.has_texture !== undefined) {
|
||||
document.getElementById('infoTexture').textContent = metadata.has_texture ? 'Yes' : 'No';
|
||||
}
|
||||
if (metadata.has_material !== undefined) {
|
||||
document.getElementById('infoMaterial').textContent = metadata.has_material ? 'Yes' : 'No';
|
||||
}
|
||||
infoPanel.style.display = 'block';
|
||||
|
||||
// Show warning if textures but no GLTF support
|
||||
if (!gltfSupported && metadata.has_texture) {
|
||||
document.getElementById('warningRow').style.display = 'block';
|
||||
}
|
||||
|
||||
try {
|
||||
// Clear previous actors
|
||||
currentActors.forEach(actor => renderer.removeActor(actor));
|
||||
currentActors = [];
|
||||
|
||||
// Load mesh using unified loader
|
||||
const result = await loadMesh(filepath, vtk, {
|
||||
renderer: renderer,
|
||||
withTextures: true,
|
||||
twoSidedLighting: true
|
||||
});
|
||||
|
||||
currentActors = result.actors;
|
||||
|
||||
// Add actors to renderer (if not already added by GLTF loader)
|
||||
result.actors.forEach(actor => {
|
||||
if (!renderer.getActors().includes(actor)) {
|
||||
renderer.addActor(actor);
|
||||
}
|
||||
});
|
||||
|
||||
// Apply texture fixes only for textured meshes (not vertex-colored ones)
|
||||
if (result.hasTexture) {
|
||||
textureManager.applyTextureFixesMultiple(result.actors);
|
||||
textureManager.configureRenderer(renderer);
|
||||
} else if (result.hasVertexColors) {
|
||||
// For vertex-colored meshes, just configure renderer
|
||||
textureManager.configureRenderer(renderer);
|
||||
}
|
||||
|
||||
// Calculate bounds
|
||||
if (result.actors.length > 0) {
|
||||
meshBounds = result.actors[0].getBounds();
|
||||
for (let i = 1; i < result.actors.length; i++) {
|
||||
const b = result.actors[i].getBounds();
|
||||
meshBounds = [
|
||||
Math.min(meshBounds[0], b[0]),
|
||||
Math.max(meshBounds[1], b[1]),
|
||||
Math.min(meshBounds[2], b[2]),
|
||||
Math.max(meshBounds[3], b[3]),
|
||||
Math.min(meshBounds[4], b[4]),
|
||||
Math.max(meshBounds[5], b[5])
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// Position camera (restore saved state or auto-position)
|
||||
if (!applyCameraState(_pendingCameraState)) {
|
||||
renderer.resetCamera();
|
||||
}
|
||||
renderWindow.render();
|
||||
|
||||
// Second render for proper texture display
|
||||
setTimeout(() => {
|
||||
if (!_pendingCameraState) renderer.resetCamera();
|
||||
_pendingCameraState = null;
|
||||
renderWindow.render();
|
||||
}, 100);
|
||||
|
||||
showLoading(false);
|
||||
|
||||
// Notify parent
|
||||
sendMeshLoaded({
|
||||
filename: currentFilename,
|
||||
format: result.format,
|
||||
hasTexture: result.hasTexture || false,
|
||||
actors: result.actors.length
|
||||
});
|
||||
|
||||
return true;
|
||||
|
||||
} catch (error) {
|
||||
console.error('[GeomPack VTK Textured] Load failed:', error);
|
||||
showLoading(true, 'Failed to load mesh: ' + error.message);
|
||||
sendError(error.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Camera view function
|
||||
function setCameraView(direction) {
|
||||
if (!meshBounds) return;
|
||||
cameraController.setCameraView(direction, meshBounds);
|
||||
renderWindow.render();
|
||||
}
|
||||
|
||||
// Event listeners - Camera views
|
||||
document.querySelectorAll('.view-button').forEach(button => {
|
||||
button.addEventListener('click', () => {
|
||||
setCameraView(button.dataset.view);
|
||||
});
|
||||
});
|
||||
|
||||
// Edges toggle
|
||||
document.getElementById('showEdges').addEventListener('change', (e) => {
|
||||
currentActors.forEach(actor => {
|
||||
actor.getProperty().setEdgeVisibility(e.target.checked);
|
||||
});
|
||||
renderWindow.render();
|
||||
window.parent.postMessage({ type: 'WIDGET_UPDATE', widget: 'show_edges', value: e.target.checked }, '*');
|
||||
});
|
||||
|
||||
// Screenshot
|
||||
document.getElementById('screenshot').addEventListener('click', async () => {
|
||||
renderWindow.render();
|
||||
await screenshotCapture.captureAndDownload(`screenshot_${Date.now()}.png`);
|
||||
});
|
||||
|
||||
// Save mesh
|
||||
document.getElementById('saveMesh').addEventListener('click', () => {
|
||||
if (currentFilename) {
|
||||
const link = document.createElement('a');
|
||||
link.href = `/view/${currentFilename}`;
|
||||
link.download = currentFilename;
|
||||
link.click();
|
||||
}
|
||||
});
|
||||
|
||||
// Settings panel
|
||||
const settingsButton = document.getElementById('settingsButton');
|
||||
const settingsPanel = document.getElementById('settingsPanel');
|
||||
const settingsOverlay = document.getElementById('settingsOverlay');
|
||||
const applySettings = document.getElementById('applySettings');
|
||||
const cancelSettings = document.getElementById('cancelSettings');
|
||||
|
||||
settingsButton.addEventListener('click', () => {
|
||||
settingsPanel.classList.add('visible');
|
||||
settingsOverlay.classList.add('visible');
|
||||
});
|
||||
|
||||
cancelSettings.addEventListener('click', () => {
|
||||
settingsPanel.classList.remove('visible');
|
||||
settingsOverlay.classList.remove('visible');
|
||||
});
|
||||
|
||||
settingsOverlay.addEventListener('click', () => {
|
||||
settingsPanel.classList.remove('visible');
|
||||
settingsOverlay.classList.remove('visible');
|
||||
});
|
||||
|
||||
applySettings.addEventListener('click', () => {
|
||||
const opacity = parseFloat(document.getElementById('opacity').value);
|
||||
const representation = parseInt(document.getElementById('representationSettings').value);
|
||||
const parallel = document.getElementById('parallelProjection').checked;
|
||||
|
||||
currentActors.forEach(actor => {
|
||||
const property = actor.getProperty();
|
||||
property.setOpacity(opacity);
|
||||
property.setRepresentation(representation);
|
||||
});
|
||||
|
||||
cameraController.setParallelProjection(parallel);
|
||||
renderWindow.render();
|
||||
|
||||
settingsPanel.classList.remove('visible');
|
||||
settingsOverlay.classList.remove('visible');
|
||||
});
|
||||
|
||||
// Resize handling
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
const { width, height } = container.getBoundingClientRect();
|
||||
if (width > 0 && height > 0) {
|
||||
openGLRenderWindow.setSize(width, height);
|
||||
renderWindow.render();
|
||||
}
|
||||
});
|
||||
resizeObserver.observe(container);
|
||||
|
||||
// Expose for parent widget
|
||||
window.loadMesh = loadTexturedMesh;
|
||||
window.gltfSupported = gltfSupported;
|
||||
|
||||
// Listen for postMessage
|
||||
window.addEventListener('message', async (event) => {
|
||||
if (event.data.type === 'LOAD_MESH' && event.data.filepath) {
|
||||
const filepath = event.data.filepath;
|
||||
const metadata = event.data.metadata || {};
|
||||
const showEdges = event.data.showEdges || false;
|
||||
_pendingCameraState = event.data.cameraState || null;
|
||||
|
||||
const success = await loadTexturedMesh(filepath, metadata);
|
||||
|
||||
if (success) {
|
||||
// Apply showEdges state from parent widget
|
||||
const edgesCheckbox = document.getElementById('showEdges');
|
||||
edgesCheckbox.checked = showEdges;
|
||||
currentActors.forEach(actor => {
|
||||
actor.getProperty().setEdgeVisibility(showEdges);
|
||||
});
|
||||
renderWindow.render();
|
||||
}
|
||||
|
||||
if (window.parent && window.parent !== window) {
|
||||
window.parent.postMessage({
|
||||
type: success ? 'MESH_LOADED' : 'MESH_ERROR',
|
||||
error: success ? null : 'Failed to load mesh'
|
||||
}, '*');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Initial status
|
||||
loadingText.textContent = gltfSupported ?
|
||||
'Ready to load textured mesh (GLB/GLTF)' :
|
||||
'Ready - GLTFImporter loading...';
|
||||
console.log('[GeomPack VTK Textured Viewer] Loaded successfully');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user