/** * 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: "geometrypack.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"; // Dropdown to jump directly to any mesh in the batch (client-side) const dropdown = document.createElement("select"); dropdown.style.cssText = "background:#333;color:#ccc;border:1px solid #555;" + "border-radius:3px;font:11px monospace;padding:2px 6px;cursor:pointer;"; dropdown.innerHTML = ''; // Fullscreen toggle -- expands the whole widget (viewer + this nav // bar) so the arrows and dropdown stay usable in fullscreen. // (Mode is controlled solely by the node's `mode` input widget.) const fsButton = document.createElement("button"); fsButton.textContent = "⛶"; // fullscreen glyph fsButton.title = "Fullscreen (← → to navigate)"; fsButton.style.cssText = "padding:4px 10px;cursor:pointer;background:#333;color:#ccc;border:1px solid #555;border-radius:3px;font-size:13px;line-height:1;"; // Assemble navigation bar navBar.appendChild(prevButton); navBar.appendChild(indexLabel); navBar.appendChild(dropdown); navBar.appendChild(nextButton); navBar.appendChild(fsButton); // 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 = 'Mesh info will appear here after execution'; // 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; // Navigation is CLIENT-SIDE: arrows / dropdown / the index widget // switch the shown mesh by posting LOAD_MESH to THIS node's iframe. // They never call app.queuePrompt(), so navigating never re-runs the // graph or reloads any other node. (showIndex is defined below.) let suppressIndexCallback = false; if (indexWidget) { const originalCallback = indexWidget.callback; indexWidget.callback = function(value) { const result = originalCallback?.apply(this, arguments); // showIndex() sets the widget value itself -> ignore that echo if (!suppressIndexCallback) showIndex(Number(value)); return result; }; } prevButton.addEventListener("click", () => showIndex(currentIndex - 1)); nextButton.addEventListener("click", () => showIndex(currentIndex + 1)); dropdown.addEventListener("change", () => showIndex(Number(dropdown.value))); // Fullscreen the whole widget. The arrow BUTTONS stay clickable in // fullscreen; arrow KEYS also navigate while the widget (nav bar) has // focus. All listeners are on our own `container`, never document, so // this stays isolation-clean. container.tabIndex = 0; // focusable -> can receive keydown in fullscreen const isFs = () => (document.fullscreenElement || document.webkitFullscreenElement) === container; fsButton.addEventListener("click", () => { if (isFs()) { (document.exitFullscreen || document.webkitExitFullscreen)?.call(document); } else { (container.requestFullscreen || container.webkitRequestFullscreen)?.call(container); } }); ["fullscreenchange", "webkitfullscreenchange"].forEach((ev) => container.addEventListener(ev, () => { if (isFs()) container.focus(); })); container.addEventListener("keydown", (e) => { if (!isFs()) return; // only hijack arrows while WE are fullscreen if (e.key === "ArrowLeft") { showIndex(currentIndex - 1); e.preventDefault(); } else if (e.key === "ArrowRight") { showIndex(currentIndex + 1); e.preventDefault(); } }); // 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"; } }; // ---- client-side batch navigation state + helpers ---- // Filled by onExecuted with everything needed to switch meshes with // no server round-trip: { files:[names], viewerType, mode, meta:[{ // vertex_count, face_count, bounds_min, bounds_max, extents, // is_watertight, field_names, has_texture, has_vertex_colors, // visual_kind }, ...] }. let batchData = null; const postLoad = (i) => { if (!iframe.contentWindow || !batchData) return; const filepath = `/view?filename=${encodeURIComponent(batchData.files[i])}&type=output&subfolder=`; iframe.contentWindow.postMessage({ type: "LOAD_MESH", filepath, timestamp: Date.now(), showEdges: viewerState.show_edges, cameraState: viewerState.camera_state, selectedField: viewerState.selected_field, selectedChannel: viewerState.selected_channel, selectedColormap: viewerState.selected_colormap, }, "*"); }; const renderInfo = (i) => { const m = batchData?.meta?.[i]; if (!m) return; const num = (v) => (typeof v === "number" ? v.toLocaleString() : "N/A"); const esc = (s) => String(s ?? "").replace(/[&<>]/g, c => ({ "&": "&", "<": "<", ">": ">" }[c])); const name = batchData.names?.[i] || `mesh ${i + 1}`; const boundsStr = (Array.isArray(m.bounds_min) && Array.isArray(m.bounds_max) && m.bounds_min.length === 3 && m.bounds_max.length === 3) ? `[${m.bounds_min.map(v => v.toFixed(2)).join(", ")}] to [${m.bounds_max.map(v => v.toFixed(2)).join(", ")}]` : "N/A"; const extentsStr = (Array.isArray(m.extents) && m.extents.length === 3) ? m.extents.map(v => v.toFixed(2)).join(" x ") : "N/A"; const modeLabel = batchData.mode.charAt(0).toUpperCase() + batchData.mode.slice(1); const modeColor = batchData.viewerType === "texture" ? "#c8c" : "#6cc"; let html = `