diff --git a/javascript/js/mesh_preview_dual.js b/javascript/js/mesh_preview_dual.js index b8cfb640..75006604 100644 --- a/javascript/js/mesh_preview_dual.js +++ b/javascript/js/mesh_preview_dual.js @@ -19,8 +19,10 @@ app.registerExtension({ 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" }; + // Viewer state persisted via DOM widget serialization. + // layout + opacities live HERE (not as node inputs): Python exports + // both view sets every run, so they are pure client-side choices. + const viewerState = { layout: "side_by_side", opacity_1: 1.0, opacity_2: 1.0, show_edges: false, camera_state: "", selected_field: "", selected_channel: "magnitude", selected_colormap: "erdc_rainbow_bright" }; // Create container for viewer + info panel const container = createContainer(); @@ -53,6 +55,22 @@ app.registerExtension({ bar.appendChild(layoutSel); bar.appendChild(Object.assign(document.createElement("span"), { textContent: "Mode:" })); bar.appendChild(modeSel); + // Opacity inputs (overlay only) -- applied client-side by re-posting + const mkOp = () => { + const inp = document.createElement("input"); + inp.type = "number"; inp.min = "0"; inp.max = "1"; inp.step = "0.1"; + inp.style.cssText = "width:44px;background:#333;color:#ccc;border:1px solid #555;border-radius:3px;font:11px monospace;padding:2px 4px;"; + return inp; + }; + const op1Input = mkOp(); + const op2Input = mkOp(); + const opControls = document.createElement("span"); + opControls.style.cssText = "display:flex;gap:4px;align-items:center;"; + opControls.appendChild(Object.assign(document.createElement("span"), { textContent: "Op 1:" })); + opControls.appendChild(op1Input); + opControls.appendChild(Object.assign(document.createElement("span"), { textContent: "Op 2:" })); + opControls.appendChild(op2Input); + bar.appendChild(opControls); bar.appendChild(createFullscreenButton(container)); // Add bar, iframe and info panel to container @@ -98,74 +116,60 @@ app.registerExtension({ // Set initial node size this.setSize([768, 680]); - // ---- widget <-> bar sync + opacity visibility ---- - const layoutWidget = this.widgets?.find(w => w.name === "layout"); + // ---- bar wiring ---- + // mode is the only remaining node input; layout + opacity live in + // viewerState (persisted via the DOM widget's serialization). const modeWidget = this.widgets?.find(w => w.name === "mode"); - // opacity_1/opacity_2 only mean anything in the overlay layout -- - // hide the widgets otherwise (same hidden-type trick as - // preview_mesh_batch_render.js). const setOpacityVisible = (visible) => { - let changed = false; - for (const nm of ["opacity_1", "opacity_2"]) { - const w = node.widgets?.find(x => x.name === nm); - if (!w) continue; - if (!visible && w.type !== "geometrypack_hidden") { - w._gpOrigType = w.type; - w._gpOrigComputeSize = w.computeSize; - w.type = "geometrypack_hidden"; // unknown type -> not drawn - w.computeSize = () => [0, -4]; - changed = true; - } else if (visible && w.type === "geometrypack_hidden") { - w.type = w._gpOrigType; - w.computeSize = w._gpOrigComputeSize; - changed = true; - } - } - if (changed) node.setSize(node.computeSize()); + opControls.style.display = visible ? "flex" : "none"; }; let lastMsg = null; // last execution message, for client-side switches const syncBar = () => { - if (layoutWidget) layoutSel.value = layoutWidget.value || "side_by_side"; + layoutSel.value = viewerState.layout || "side_by_side"; if (modeWidget) modeSel.value = modeWidget.value || "fields"; + op1Input.value = viewerState.opacity_1 ?? 1.0; + op2Input.value = viewerState.opacity_2 ?? 1.0; setOpacityVisible(layoutSel.value === "overlay"); }; - // Keep the bar honest when the node widgets are edited directly. - for (const [w, after] of [[layoutWidget, syncBar], [modeWidget, syncBar]]) { - if (!w) continue; - const orig = w.callback; - w.callback = function(value) { + // Keep the bar honest when the mode widget is edited directly. + if (modeWidget) { + const orig = modeWidget.callback; + modeWidget.callback = function(value) { const res = orig?.apply(this, arguments); - after(); + syncBar(); return res; }; } + // Layout: fully client-side -- every run exports both the separate + // pair AND the combined overlay file, so any switch just re-renders. layoutSel.addEventListener("change", () => { - const newLayout = layoutSel.value; - if (layoutWidget) layoutWidget.value = newLayout; // persists for next run - setOpacityVisible(newLayout === "overlay"); - // side_by_side <-> slider reuse the SAME exported files -> - // re-render client-side from the stored message. Overlay (either - // direction) needs the combined export -> re-run. - if (newLayout !== "overlay" && lastMsg?.mesh_1_file && lastMsg?.mesh_2_file) { - render(lastMsg, newLayout); - } else { - app.queuePrompt(); - } + viewerState.layout = layoutSel.value; + setOpacityVisible(viewerState.layout === "overlay"); + if (lastMsg) render(lastMsg); }); modeSel.addEventListener("change", () => { if (modeWidget) modeWidget.value = modeSel.value; app.queuePrompt(); // different export format -> must re-run }); + // Opacity: client-side re-post; persisted in viewerState. + const onOpacity = () => { + viewerState.opacity_1 = Math.max(0, Math.min(1, parseFloat(op1Input.value) || 0)); + viewerState.opacity_2 = Math.max(0, Math.min(1, parseFloat(op2Input.value) || 0)); + if (lastMsg) render(lastMsg); + }; + op1Input.addEventListener("change", onOpacity); + op2Input.addEventListener("change", onOpacity); syncBar(); - // Render one execution message; layoutOverride enables the - // client-side side_by_side <-> slider switch without a re-run. - const render = (message, layoutOverride) => { - const layout = layoutOverride || message.layout[0]; + // Render one execution message with the CURRENT client-side layout + // and opacities (viewerState). Called on execution and on any bar + // change -- both view sets are always present in the message. + const render = (message) => { + const layout = viewerState.layout || "side_by_side"; const mode = message.mode?.[0] || "fields"; // Determine viewer type and name @@ -216,8 +220,8 @@ app.registerExtension({ 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, + opacity1: viewerState.opacity_1 ?? 1.0, + opacity2: viewerState.opacity_2 ?? 1.0, showEdges: viewerState.show_edges, cameraState: viewerState.camera_state, selectedField: viewerState.selected_field, @@ -245,7 +249,8 @@ app.registerExtension({ faces: message.face_count_2?.[0] || 'N/A', hasTexture: message.has_texture_2?.[0] }, - commonFields: message.common_fields?.[0] || [] + // combined export injects a mesh_id field + commonFields: message.common_fields_overlay?.[0] || message.common_fields?.[0] || [] }); infoPanel.innerHTML = infoHTML; @@ -253,8 +258,8 @@ app.registerExtension({ postMessageData = createLoadDualMeshMessage({ layout: layout, meshFilepath: buildViewUrl(message.mesh_file[0]), - opacity1: message.opacity_1?.[0] || 1.0, - opacity2: message.opacity_2?.[0] || 1.0, + opacity1: viewerState.opacity_1 ?? 1.0, + opacity2: viewerState.opacity_2 ?? 1.0, showEdges: viewerState.show_edges, cameraState: viewerState.camera_state, selectedField: viewerState.selected_field, @@ -271,7 +276,7 @@ app.registerExtension({ const onExecuted = this.onExecuted; this.onExecuted = function(message) { onExecuted?.apply(this, arguments); - if (!message?.layout) return; + if (!message?.mesh_1_file) return; lastMsg = message; syncBar(); // reflect the layout/mode actually run + opacity visibility render(message); diff --git a/nodes/visualization/preview_mesh_dual.py b/nodes/visualization/preview_mesh_dual.py index 379872d9..b2086eec 100644 --- a/nodes/visualization/preview_mesh_dual.py +++ b/nodes/visualization/preview_mesh_dual.py @@ -110,10 +110,11 @@ class PreviewMeshDualNode(io.ComfyNode): inputs=[ io.Custom("TRIMESH").Input("mesh_1"), io.Custom("TRIMESH").Input("mesh_2"), - io.Combo.Input("layout", options=["side_by_side", "overlay", "slider"], default="side_by_side", optional=True), + # layout and opacity are NOT node inputs: layout is a pure viewer + # choice (both the separate pair AND the combined overlay file are + # exported every run, so the frontend switches without re-running) + # and opacity is applied client-side in the viewer. io.Combo.Input("mode", options=["fields", "texture"], default="fields", optional=True), - io.Float.Input("opacity_1", default=1.0, min=0.0, max=1.0, step=0.1, optional=True), - io.Float.Input("opacity_2", default=1.0, min=0.0, max=1.0, step=0.1, optional=True), ], outputs=[ io.Custom("TRIMESH").Output(display_name="meshes", is_output_list=True), @@ -121,22 +122,21 @@ class PreviewMeshDualNode(io.ComfyNode): ) @classmethod - def execute(cls, mesh_1, mesh_2, layout="side_by_side", mode="fields", opacity_1=1.0, opacity_2=1.0): + def execute(cls, mesh_1, mesh_2, mode="fields"): """ - Preview two meshes with chosen layout and visualization mode. + Preview two meshes; the layout (side-by-side / overlay / slider) is + chosen client-side, so BOTH the separate pair and the combined overlay + file are exported every run. Args: mesh_1: First trimesh object mesh_2: Second trimesh object - layout: "side_by_side" or "overlay" mode: "fields" (scientific visualization) or "texture" (textured rendering) - opacity_1: Opacity for mesh 1 (0.0-1.0) - opacity_2: Opacity for mesh 2 (0.0-1.0) Returns: - dict: UI data for frontend widget + io.NodeOutput with UI data for the frontend widget """ - log.info("Layout: %s, Mode: %s", layout, mode) + log.info("Mode: %s", mode) log.info("Mesh 1: %s - %d vertices, %d faces", get_geometry_type(mesh_1), len(mesh_1.vertices), get_face_count(mesh_1)) log.info("Mesh 2: %s - %d vertices, %d faces", get_geometry_type(mesh_2), len(mesh_2.vertices), get_face_count(mesh_2)) @@ -174,130 +174,64 @@ class PreviewMeshDualNode(io.ComfyNode): # Generate unique ID for this preview preview_id = uuid.uuid4().hex[:8] - if layout == "side_by_side" or layout == "slider": - # Export meshes separately based on mode - if mode == "texture": - # Texture mode: export as GLB - filename_1, filepath_1 = cls._export_mesh(mesh_1, f"preview_dual_1_{preview_id}", use_vtp=False, use_glb=True) - filename_2, filepath_2 = cls._export_mesh(mesh_2, f"preview_dual_2_{preview_id}", use_vtp=False, use_glb=True) - else: - # Fields mode: use VTP for fields OR point clouds - filename_1, filepath_1 = cls._export_mesh(mesh_1, f"preview_dual_1_{preview_id}", use_vtp=(mesh_1_has_fields or mesh_1_is_pc), use_glb=False) - filename_2, filepath_2 = cls._export_mesh(mesh_2, f"preview_dual_2_{preview_id}", use_vtp=(mesh_2_has_fields or mesh_2_is_pc), use_glb=False) + # Export BOTH view sets every run so the frontend can switch layouts + # client-side: the separate pair (side_by_side / slider) and the + # combined overlay file. + use_glb = mode == "texture" + filename_1, _ = cls._export_mesh( + mesh_1, f"preview_dual_1_{preview_id}", + use_vtp=(not use_glb and (mesh_1_has_fields or mesh_1_is_pc)), use_glb=use_glb) + filename_2, _ = cls._export_mesh( + mesh_2, f"preview_dual_2_{preview_id}", + use_vtp=(not use_glb and (mesh_2_has_fields or mesh_2_is_pc)), use_glb=use_glb) + filename_combined, _ = cls._export_combined_mesh( + mesh_1, mesh_2, preview_id, mesh_1_has_fields, mesh_2_has_fields, + use_glb=use_glb) - # Compute bounds from vertices (works for both meshes and point clouds) - bounds_1 = np.array([mesh_1.vertices.min(axis=0), mesh_1.vertices.max(axis=0)]) - bounds_2 = np.array([mesh_2.vertices.min(axis=0), mesh_2.vertices.max(axis=0)]) - extents_1 = bounds_1[1] - bounds_1[0] - extents_2 = bounds_2[1] - bounds_2[0] + # Bounds from vertices (works for both meshes and point clouds) + bounds_1 = np.array([mesh_1.vertices.min(axis=0), mesh_1.vertices.max(axis=0)]) + bounds_2 = np.array([mesh_2.vertices.min(axis=0), mesh_2.vertices.max(axis=0)]) + extents_1 = bounds_1[1] - bounds_1[0] + extents_2 = bounds_2[1] - bounds_2[0] - # Build UI data for side-by-side mode - ui_data = { - "layout": [layout], - "mode": [mode], - "mesh_1_file": [filename_1], - "mesh_2_file": [filename_2], - "vertex_count_1": [len(mesh_1.vertices)], - "vertex_count_2": [len(mesh_2.vertices)], - "face_count_1": [get_face_count(mesh_1)], - "face_count_2": [get_face_count(mesh_2)], - "bounds_min_1": [bounds_1[0].tolist()], - "bounds_max_1": [bounds_1[1].tolist()], - "bounds_min_2": [bounds_2[0].tolist()], - "bounds_max_2": [bounds_2[1].tolist()], - "extents_1": [extents_1.tolist()], - "extents_2": [extents_2.tolist()], - "is_watertight_1": [bool(mesh_1.is_watertight) if not is_point_cloud(mesh_1) else False], - "is_watertight_2": [bool(mesh_2.is_watertight) if not is_point_cloud(mesh_2) else False], - "opacity_1": [float(opacity_1)], - "opacity_2": [float(opacity_2)], - } + ui_data = { + "mode": [mode], + "mesh_1_file": [filename_1], + "mesh_2_file": [filename_2], + "mesh_file": [filename_combined], # overlay view + "vertex_count_1": [len(mesh_1.vertices)], + "vertex_count_2": [len(mesh_2.vertices)], + "face_count_1": [get_face_count(mesh_1)], + "face_count_2": [get_face_count(mesh_2)], + "bounds_min_1": [bounds_1[0].tolist()], + "bounds_max_1": [bounds_1[1].tolist()], + "bounds_min_2": [bounds_2[0].tolist()], + "bounds_max_2": [bounds_2[1].tolist()], + "extents_1": [extents_1.tolist()], + "extents_2": [extents_2.tolist()], + "is_watertight_1": [bool(mesh_1.is_watertight) if not is_point_cloud(mesh_1) else False], + "is_watertight_2": [bool(mesh_2.is_watertight) if not is_point_cloud(mesh_2) else False], + } - # Add mode-specific metadata - if mode == "texture": - # Texture mode metadata - ui_data.update({ - "has_texture_1": [texture_info_1['has_texture']], - "has_texture_2": [texture_info_2['has_texture']], - "visual_kind_1": [texture_info_1['visual_kind'] if texture_info_1['visual_kind'] else "none"], - "visual_kind_2": [texture_info_2['visual_kind'] if texture_info_2['visual_kind'] else "none"], - "has_vertex_colors_1": [texture_info_1['has_vertex_colors']], - "has_vertex_colors_2": [texture_info_2['has_vertex_colors']], - "has_material_1": [texture_info_1['has_material']], - "has_material_2": [texture_info_2['has_material']], - }) - else: - # Fields mode metadata - ui_data.update({ - "field_names_1": [field_names_1], - "field_names_2": [field_names_2], - "common_fields": [common_fields], - }) - - - else: # overlay - # Combine meshes with color coding - if mode == "texture": - # Texture mode: export combined mesh as GLB - filename, filepath = cls._export_combined_mesh( - mesh_1, mesh_2, preview_id, opacity_1, opacity_2, - mesh_1_has_fields, mesh_2_has_fields, use_glb=True - ) - else: - # Fields mode: export combined mesh as VTP - filename, filepath = cls._export_combined_mesh( - mesh_1, mesh_2, preview_id, opacity_1, opacity_2, - mesh_1_has_fields, mesh_2_has_fields, use_glb=False - ) - - # Compute bounds from vertices (works for both meshes and point clouds) - bounds_1 = np.array([mesh_1.vertices.min(axis=0), mesh_1.vertices.max(axis=0)]) - bounds_2 = np.array([mesh_2.vertices.min(axis=0), mesh_2.vertices.max(axis=0)]) - combined_bounds_min = np.minimum(bounds_1[0], bounds_2[0]) - combined_bounds_max = np.maximum(bounds_1[1], bounds_2[1]) - combined_extents = combined_bounds_max - combined_bounds_min - - # Build UI data for overlay mode - ui_data = { - "layout": [layout], - "mode": [mode], - "mesh_file": [filename], - "vertex_count_1": [len(mesh_1.vertices)], - "vertex_count_2": [len(mesh_2.vertices)], - "face_count_1": [get_face_count(mesh_1)], - "face_count_2": [get_face_count(mesh_2)], - "bounds_min": [combined_bounds_min.tolist()], - "bounds_max": [combined_bounds_max.tolist()], - "extents": [combined_extents.tolist()], - "opacity_1": [float(opacity_1)], - "opacity_2": [float(opacity_2)], - "is_watertight_1": [bool(mesh_1.is_watertight) if not is_point_cloud(mesh_1) else False], - "is_watertight_2": [bool(mesh_2.is_watertight) if not is_point_cloud(mesh_2) else False], - } - - # Add mode-specific metadata - if mode == "texture": - # Texture mode metadata - ui_data.update({ - "has_texture_1": [texture_info_1['has_texture']], - "has_texture_2": [texture_info_2['has_texture']], - "visual_kind_1": [texture_info_1['visual_kind'] if texture_info_1['visual_kind'] else "none"], - "visual_kind_2": [texture_info_2['visual_kind'] if texture_info_2['visual_kind'] else "none"], - "has_vertex_colors_1": [texture_info_1['has_vertex_colors']], - "has_vertex_colors_2": [texture_info_2['has_vertex_colors']], - "has_material_1": [texture_info_1['has_material']], - "has_material_2": [texture_info_2['has_material']], - }) - else: - # Fields mode metadata - include mesh_id which is added during export for overlay - overlay_fields_1 = field_names_1 + ["mesh_id"] if layout == "overlay" else field_names_1 - overlay_fields_2 = field_names_2 + ["mesh_id"] if layout == "overlay" else field_names_2 - overlay_common = common_fields + ["mesh_id"] if layout == "overlay" else common_fields - ui_data.update({ - "field_names_1": [overlay_fields_1], - "field_names_2": [overlay_fields_2], - "common_fields": [overlay_common], - }) + if mode == "texture": + ui_data.update({ + "has_texture_1": [texture_info_1['has_texture']], + "has_texture_2": [texture_info_2['has_texture']], + "visual_kind_1": [texture_info_1['visual_kind'] if texture_info_1['visual_kind'] else "none"], + "visual_kind_2": [texture_info_2['visual_kind'] if texture_info_2['visual_kind'] else "none"], + "has_vertex_colors_1": [texture_info_1['has_vertex_colors']], + "has_vertex_colors_2": [texture_info_2['has_vertex_colors']], + "has_material_1": [texture_info_1['has_material']], + "has_material_2": [texture_info_2['has_material']], + }) + else: + ui_data.update({ + "field_names_1": [field_names_1], + "field_names_2": [field_names_2], + "common_fields": [common_fields], + # the combined overlay export injects a mesh_id field + "common_fields_overlay": [common_fields + ["mesh_id"]], + }) log.info("Preview ready") return io.NodeOutput([mesh_1, mesh_2], ui=ui_data) @@ -338,7 +272,7 @@ class PreviewMeshDualNode(io.ComfyNode): return filename, filepath @staticmethod - def _export_combined_mesh(mesh_1, mesh_2, preview_id, opacity_1, opacity_2, + def _export_combined_mesh(mesh_1, mesh_2, preview_id, mesh_1_has_fields, mesh_2_has_fields, use_glb): """Export combined mesh for overlay mode as VTP or GLB.