/** * ComfyUI GeomPack - Multi Mesh Preview Widget * Grid viewer for 1-4 meshes with synchronized cameras */ import { app } from "../../../scripts/app.js"; import { createFullscreenButton } from "./utils/uiComponents.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: "geometrypack.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. // layout: "grid" (viewer_multi.html, default) | "wipe" (sliders). // grid_cols/grid_rows: null = auto (derived from mesh count). const viewerState = { layout: "grid", grid_cols: null, grid_rows: null, bar_collapsed: false, show_edges: false, camera_state: "", selected_field: "", selected_channel: "magnitude", selected_colormap: "erdc_rainbow_bright" }; const viewerUrl = () => (viewerState.layout === "wipe" ? "viewer_multi_slider.html" : "viewer_multi.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(); // Control bar: [collapse arrow] Layout:[Grid|Wipe] Cols:[ ] Rows:[ ] // Grid = tiled viewports (viewer_multi.html); Wipe = N-1 draggable // dividers (viewer_multi_slider.html). The arrow hides everything // but itself; cols/rows show only for the grid layout. const bar = document.createElement("div"); bar.style.cssText = "background:#1a1a1a;border-top:1px solid #444;padding:4px 8px;display:flex;gap:8px;align-items:center;font:11px monospace;color:#ccc;flex-shrink:0;"; const collapseBtn = document.createElement("button"); collapseBtn.title = "Show/hide controls"; collapseBtn.style.cssText = "background:none;border:none;color:#ccc;cursor:pointer;font:12px monospace;padding:0 4px;line-height:1;"; const controls = document.createElement("div"); // everything the arrow hides controls.style.cssText = "display:flex;gap:8px;align-items:center;"; 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 = ''; const mkNum = () => { const inp = document.createElement("input"); inp.type = "number"; inp.min = "1"; inp.step = "1"; inp.style.cssText = "width:40px;background:#333;color:#ccc;border:1px solid #555;border-radius:3px;font:11px monospace;padding:2px 4px;"; return inp; }; const colsInput = mkNum(); const rowsInput = mkNum(); const gridControls = document.createElement("span"); // shown only for grid gridControls.style.cssText = "display:flex;gap:4px;align-items:center;"; gridControls.appendChild(Object.assign(document.createElement("span"), { textContent: "Cols:" })); gridControls.appendChild(colsInput); gridControls.appendChild(Object.assign(document.createElement("span"), { textContent: "Rows:" })); gridControls.appendChild(rowsInput); // (Mode is controlled solely by the node's `mode` input widget.) controls.appendChild(Object.assign(document.createElement("span"), { textContent: "Layout:" })); controls.appendChild(layoutSel); controls.appendChild(gridControls); bar.appendChild(collapseBtn); bar.appendChild(controls); bar.appendChild(createFullscreenButton(container)); // outside `controls`: stays visible when collapsed // 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'; // Order: canvas on top, controls below it, info panel last container.appendChild(iframe); container.appendChild(bar); 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) {} if (viewerState.layout === "overlay") viewerState.layout = "grid"; // legacy name } }); 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, autoCols, autoRows } const buildAndSend = () => { if (!lastLoad || !iframe.contentWindow) return; let msg; if (viewerState.layout !== "wipe") { // grid (default; also legacy "overlay") // user override (grid_cols/grid_rows) else the auto dims from Python const cols = viewerState.grid_cols || lastLoad.autoCols; const rows = viewerState.grid_rows || lastLoad.autoRows; msg = { type: 'LOAD_MULTI_MESH', numMeshes: lastLoad.numMeshes, meshFiles: lastLoad.filepaths, gridCols: cols, gridRows: rows, 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(); }); // ---- control bar wiring ---- const syncLayoutUI = () => { gridControls.style.display = (viewerState.layout === "wipe") ? "none" : "flex"; }; const applyCollapsed = () => { controls.style.display = viewerState.bar_collapsed ? "none" : "flex"; collapseBtn.textContent = viewerState.bar_collapsed ? "▸" : "▾"; // ▸ / ▾ }; collapseBtn.addEventListener('click', () => { viewerState.bar_collapsed = !viewerState.bar_collapsed; applyCollapsed(); }); // Layout switch swaps the viewer iframe (different HTML); buildAndSend // fires on its load. Cols/Rows just re-send to the SAME grid iframe. layoutSel.value = viewerState.layout; layoutSel.addEventListener('change', () => { viewerState.layout = layoutSel.value; syncLayoutUI(); iframeLoaded = false; iframe.src = `/extensions/${EXTENSION_FOLDER}/${viewerUrl()}?v=` + Date.now(); }); // The edited field is authoritative; the OTHER dimension auto-raises // so cols*rows always fits every mesh. Without this, a grid smaller // than the mesh count overflows into implicit CSS rows (auto height) // and the layout stops matching the inputs. Both inputs are updated // so the UI always shows the grid actually in effect. const onDim = (edited) => () => { const n = lastLoad?.numMeshes || 1; let cols = Math.max(1, parseInt(colsInput.value, 10) || 0) || (lastLoad?.autoCols ?? 1); let rows = Math.max(1, parseInt(rowsInput.value, 10) || 0) || (lastLoad?.autoRows ?? 1); if (cols * rows < n) { if (edited === "cols") rows = Math.ceil(n / cols); else cols = Math.ceil(n / rows); } colsInput.value = cols; rowsInput.value = rows; viewerState.grid_cols = cols; viewerState.grid_rows = rows; buildAndSend(); // same iframe -> viewer re-tiles, no reload }; colsInput.addEventListener('change', onDim("cols")); rowsInput.addEventListener('change', onDim("rows")); syncLayoutUI(); applyCollapsed(); // 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 ? `[${b[0].map((x) => Number(x).toFixed(1)).join(', ')}]
→ [${b[1].map((x) => Number(x).toFixed(1)).join(', ')}]
` : '—'); let infoHTML = `
`; infoHTML += ``; for (let i = 0; i < numMeshes; i++) { infoHTML += `Mesh ${i + 1}`; } const row = (label, valFn) => { infoHTML += `${label}`; for (let i = 0; i < numMeshes; i++) infoHTML += `${valFn(i)}`; }; row('Vertices:', (i) => num(vertexCounts[i])); row('Faces:', (i) => num(faceCounts[i])); row('Watertight:', (i) => { const w = wt[i]; return `${w ? 'Yes' : 'No'}`; }); 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 ? `${f.join(', ')}` : '—'; }); } infoHTML += '
'; 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, autoCols: gridCols, autoRows: gridRows }; // Reflect the effective grid dims (user override, else the auto value) colsInput.value = viewerState.grid_cols ?? gridCols; rowsInput.value = viewerState.grid_rows ?? gridRows; buildAndSend(); }; return r; }; } } });