Preview Mesh Dual: remove layout + opacity from Python inputs

layout and opacity_1/opacity_2 are gone from the schema; mode stays a real
node input (as directed).

Python: with no layout to branch on, every run exports BOTH view sets -- the
separate pair (side_by_side / slider) and the combined overlay file -- using
the same untouched export helpers (_export_mesh, _export_combined_mesh; the
combined helper just loses its never-used opacity params). ui_data carries
both file sets; opacity keys dropped (Python only ever echoed them);
common_fields_overlay added (the combined export injects mesh_id).

JS: layout and opacities move into viewerState (persisted via the DOM
widget's serialization, like show_edges already was). Every layout switch is
now CLIENT-SIDE -- overlay included, since its file always exists -- and
opacity changes re-post immediately. Only a mode change re-runs (different
export format). No widgets are hidden; mode renders as a normal node input.

Cost: one extra combined export per run. Old workflows whose saved widget
values included layout/opacity load with those values ignored.
This commit is contained in:
PozzettiAndrea
2026-08-14 14:27:20 +02:00
parent 9d25f10b25
commit fe3c01f585
2 changed files with 123 additions and 184 deletions
+57 -52
View File
@@ -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);
+66 -132
View File
@@ -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.