various improvements to existing nodes, extended coverage

This commit is contained in:
PozzettiAndrea
2026-07-11 11:02:49 +02:00
parent 9636f8cac8
commit 2aac459bfd
27 changed files with 555 additions and 375 deletions
-3
View File
@@ -56,7 +56,6 @@ from . import reconstruction_cgal
from . import boolean
from . import remeshing_cgal
from . import repair_cgal
from . import decimation_cgal
# Collect all node class mappings
NODE_CLASS_MAPPINGS = {}
@@ -109,7 +108,6 @@ NODE_CLASS_MAPPINGS.update(remeshing_cgal.NODE_CLASS_MAPPINGS)
NODE_CLASS_MAPPINGS.update(repair_cgal.NODE_CLASS_MAPPINGS)
NODE_CLASS_MAPPINGS.update(smoothing.NODE_CLASS_MAPPINGS)
NODE_CLASS_MAPPINGS.update(decimation.NODE_CLASS_MAPPINGS)
NODE_CLASS_MAPPINGS.update(decimation_cgal.NODE_CLASS_MAPPINGS)
NODE_DISPLAY_NAME_MAPPINGS.update(io.NODE_DISPLAY_NAME_MAPPINGS)
NODE_DISPLAY_NAME_MAPPINGS.update(primitives.NODE_DISPLAY_NAME_MAPPINGS)
@@ -133,6 +131,5 @@ NODE_DISPLAY_NAME_MAPPINGS.update(remeshing_cgal.NODE_DISPLAY_NAME_MAPPINGS)
NODE_DISPLAY_NAME_MAPPINGS.update(repair_cgal.NODE_DISPLAY_NAME_MAPPINGS)
NODE_DISPLAY_NAME_MAPPINGS.update(smoothing.NODE_DISPLAY_NAME_MAPPINGS)
NODE_DISPLAY_NAME_MAPPINGS.update(decimation.NODE_DISPLAY_NAME_MAPPINGS)
NODE_DISPLAY_NAME_MAPPINGS.update(decimation_cgal.NODE_DISPLAY_NAME_MAPPINGS)
__all__ = ['NODE_CLASS_MAPPINGS', 'NODE_DISPLAY_NAME_MAPPINGS']
-16
View File
@@ -1,16 +0,0 @@
# SPDX-License-Identifier: GPL-3.0-or-later
# Copyright (C) 2025 ComfyUI-GeometryPack Contributors
"""
CGAL decimation backend nodes.
"""
from .cgal_edge_collapse import NODE_CLASS_MAPPINGS as CGAL_MAPS, NODE_DISPLAY_NAME_MAPPINGS as CGAL_DISP
NODE_CLASS_MAPPINGS = {}
NODE_DISPLAY_NAME_MAPPINGS = {}
NODE_CLASS_MAPPINGS.update(CGAL_MAPS)
NODE_DISPLAY_NAME_MAPPINGS.update(CGAL_DISP)
__all__ = ['NODE_CLASS_MAPPINGS', 'NODE_DISPLAY_NAME_MAPPINGS']
-145
View File
@@ -1,145 +0,0 @@
# SPDX-License-Identifier: GPL-3.0-or-later
# Copyright (C) 2025 ComfyUI-GeometryPack Contributors
"""CGAL edge collapse decimation backend node."""
import logging
import numpy as np
import trimesh as trimesh_module
from comfy_api.latest import io
log = logging.getLogger("geometrypack")
def _cgal_edge_collapse(mesh, target_edge_count, cost_strategy):
"""CGAL surface mesh simplification via edge collapse."""
from CGAL import CGAL_Surface_mesh_simplification
from CGAL.CGAL_Kernel import Point_3
from CGAL.CGAL_Polyhedron_3 import Polyhedron_3
from CGAL import CGAL_Polygon_mesh_processing
vertices = np.asarray(mesh.vertices, dtype=np.float64)
faces = np.asarray(mesh.faces, dtype=np.int32)
if len(vertices) == 0 or len(faces) == 0:
raise ValueError("Mesh is empty")
# Build CGAL Polyhedron_3
points = CGAL_Polygon_mesh_processing.Point_3_Vector()
points.reserve(len(vertices))
for v in vertices:
points.append(Point_3(float(v[0]), float(v[1]), float(v[2])))
polygons = [[int(idx) for idx in face] for face in faces]
P = Polyhedron_3()
CGAL_Polygon_mesh_processing.polygon_soup_to_polygon_mesh(points, polygons, P)
# Choose cost strategy
if cost_strategy == "lindstrom_turk":
cost = CGAL_Surface_mesh_simplification.LindstromTurk_cost()
placement = CGAL_Surface_mesh_simplification.LindstromTurk_placement()
else:
cost = CGAL_Surface_mesh_simplification.Edge_length_cost()
placement = CGAL_Surface_mesh_simplification.Midpoint_placement()
stop = CGAL_Surface_mesh_simplification.Count_stop_predicate(target_edge_count)
CGAL_Surface_mesh_simplification.edge_collapse(P, stop, cost, placement)
# Extract result
new_vertices = []
vertex_map = {}
for i, vertex in enumerate(P.vertices()):
point = vertex.point()
new_vertices.append([float(point.x()), float(point.y()), float(point.z())])
vertex_map[vertex] = i
new_faces = []
for facet in P.facets():
halfedge = facet.halfedge()
face_vertices = []
start = halfedge
current = start
while True:
face_vertices.append(vertex_map[current.vertex()])
current = current.next()
if current == start:
break
if len(face_vertices) == 3:
new_faces.append(face_vertices)
return trimesh_module.Trimesh(
vertices=np.array(new_vertices, dtype=np.float64),
faces=np.array(new_faces, dtype=np.int32),
process=False,
)
class DecimateCGALEdgeCollapseNode(io.ComfyNode):
"""CGAL Lindstrom-Turk edge collapse decimation backend."""
@classmethod
def define_schema(cls):
return io.Schema(
node_id="GeomPackDecimate_CGALEdgeCollapse",
display_name="Decimate CGAL Edge Collapse (backend)",
category="geompack/decimation",
is_dev_only=True,
is_output_node=True,
inputs=[
io.Custom("TRIMESH").Input("trimesh"),
io.Int.Input("target_face_count", default=5000, min=4, max=10000000, step=100, tooltip="Target number of output faces."),
io.Combo.Input("cost_strategy", options=["lindstrom_turk", "edge_length"], default="lindstrom_turk", tooltip="CGAL cost strategy. lindstrom_turk=optimizes geometry+volume+boundary (best), edge_length=collapses shortest edges first (fast)."),
],
outputs=[
io.Custom("TRIMESH").Output(display_name="decimated_mesh"),
io.String.Output(display_name="info"),
],
)
@classmethod
def execute(cls, trimesh, target_face_count=5000, cost_strategy="lindstrom_turk"):
log.info("Backend: cgal_edge_collapse")
initial_vertices = len(trimesh.vertices)
initial_faces = len(trimesh.faces)
log.info("Input: %s vertices, %s faces", f"{initial_vertices:,}", f"{initial_faces:,}")
target_edges = int(target_face_count * 1.5)
log.info("Parameters: target_edges=%d (from target_faces=%d), cost=%s",
target_edges, target_face_count, cost_strategy)
decimated = _cgal_edge_collapse(trimesh, target_edges, cost_strategy)
if hasattr(trimesh, "metadata") and trimesh.metadata:
decimated.metadata = trimesh.metadata.copy()
decimated.metadata["decimation"] = {
"algorithm": "cgal_edge_collapse",
"original_vertices": initial_vertices,
"original_faces": initial_faces,
"result_vertices": len(decimated.vertices),
"result_faces": len(decimated.faces),
}
face_change = len(decimated.faces) - initial_faces
face_pct = (face_change / initial_faces) * 100 if initial_faces > 0 else 0
info = f"""Decimate Results (cgal_edge_collapse):
Target Face Count: {target_face_count:,}
Cost Strategy: {cost_strategy}
Before:
Vertices: {initial_vertices:,}
Faces: {initial_faces:,}
After:
Vertices: {len(decimated.vertices):,}
Faces: {len(decimated.faces):,}
Reduction: {abs(face_pct):.1f}%
"""
return io.NodeOutput(decimated, info, ui={"text": [info]})
NODE_CLASS_MAPPINGS = {"GeomPackDecimate_CGALEdgeCollapse": DecimateCGALEdgeCollapseNode}
NODE_DISPLAY_NAME_MAPPINGS = {"GeomPackDecimate_CGALEdgeCollapse": "Decimate CGAL Edge Collapse (backend)"}
+6
View File
@@ -10,6 +10,8 @@ from .pymeshlab_fill import NODE_CLASS_MAPPINGS as PML_MAPS, NODE_DISPLAY_NAME_M
from .pymeshfix_fill import NODE_CLASS_MAPPINGS as PMF_MAPS, NODE_DISPLAY_NAME_MAPPINGS as PMF_DISP
from .igl_fan_fill import NODE_CLASS_MAPPINGS as IGL_MAPS, NODE_DISPLAY_NAME_MAPPINGS as IGL_DISP
from .pymeshlab_repair import NODE_CLASS_MAPPINGS as MR_PML_MAPS, NODE_DISPLAY_NAME_MAPPINGS as MR_PML_DISP
from .trimesh_repair import NODE_CLASS_MAPPINGS as MR_TM_MAPS, NODE_DISPLAY_NAME_MAPPINGS as MR_TM_DISP
from .pymeshfix_repair import NODE_CLASS_MAPPINGS as MR_PMF_MAPS, NODE_DISPLAY_NAME_MAPPINGS as MR_PMF_DISP
NODE_CLASS_MAPPINGS = {}
NODE_DISPLAY_NAME_MAPPINGS = {}
@@ -19,11 +21,15 @@ NODE_CLASS_MAPPINGS.update(PML_MAPS)
NODE_CLASS_MAPPINGS.update(PMF_MAPS)
NODE_CLASS_MAPPINGS.update(IGL_MAPS)
NODE_CLASS_MAPPINGS.update(MR_PML_MAPS)
NODE_CLASS_MAPPINGS.update(MR_TM_MAPS)
NODE_CLASS_MAPPINGS.update(MR_PMF_MAPS)
NODE_DISPLAY_NAME_MAPPINGS.update(TM_DISP)
NODE_DISPLAY_NAME_MAPPINGS.update(PML_DISP)
NODE_DISPLAY_NAME_MAPPINGS.update(PMF_DISP)
NODE_DISPLAY_NAME_MAPPINGS.update(IGL_DISP)
NODE_DISPLAY_NAME_MAPPINGS.update(MR_PML_DISP)
NODE_DISPLAY_NAME_MAPPINGS.update(MR_TM_DISP)
NODE_DISPLAY_NAME_MAPPINGS.update(MR_PMF_DISP)
__all__ = ['NODE_CLASS_MAPPINGS', 'NODE_DISPLAY_NAME_MAPPINGS']
+100
View File
@@ -0,0 +1,100 @@
# SPDX-License-Identifier: GPL-3.0-or-later
# Copyright (C) 2025 ComfyUI-GeometryPack Contributors
"""
PyMeshFix mesh-repair backend node (component cleanup + self-intersection/degenerate cleanup).
Trimmed from GeomPackMeshFix: hole-filling is intentionally NOT exposed here -- that's
GeomPackFillHoles's "pymeshfix" backend's job. This backend focuses on what's not
already covered by another dispatcher: small-component removal/joining and PyTMesh's
own clean() pass (self-intersection + degenerate-face removal).
"""
import logging
import numpy as np
import trimesh
from comfy_api.latest import io
log = logging.getLogger("geompack")
class MeshRepairPyMeshFixNode(io.ComfyNode):
"""PyMeshFix cleanup backend: small-component removal/joining + clean() pass."""
@classmethod
def define_schema(cls):
return io.Schema(
node_id="GeomPackMeshRepair_PyMeshFix",
display_name="Mesh Repair PyMeshFix (backend)",
category="geompack/repair",
is_dev_only=True,
is_output_node=True,
inputs=[
io.Custom("TRIMESH").Input("mesh"),
io.Combo.Input("remove_small_components", options=["true", "false"], default="true",
tooltip="Remove small isolated mesh fragments before cleaning.", optional=True),
io.Combo.Input("join_components", options=["true", "false"], default="false",
tooltip="Attempt to join nearby disconnected components.", optional=True),
io.Combo.Input("clean_mesh", options=["true", "false"], default="true",
tooltip="Remove self-intersections and degenerate faces (PyTMesh.clean).", optional=True),
io.Int.Input("clean_iterations", default=10, min=1, max=100, step=1,
tooltip="Max iterations for self-intersection removal.", optional=True),
io.Int.Input("inner_loops", default=3, min=1, max=10, step=1,
tooltip="Inner loops per clean iteration.", optional=True),
],
outputs=[
io.Custom("TRIMESH").Output(display_name="repaired_mesh"),
io.String.Output(display_name="info"),
],
)
@classmethod
def execute(cls, mesh, remove_small_components="true", join_components="false",
clean_mesh="true", clean_iterations=10, inner_loops=3):
remove_small_components = remove_small_components == "true"
join_components = join_components == "true"
clean_mesh = clean_mesh == "true"
initial_vertices, initial_faces = len(mesh.vertices), len(mesh.faces)
was_watertight = mesh.is_watertight
v = np.asarray(mesh.vertices, dtype=np.float64)
f = np.asarray(mesh.faces, dtype=np.int32)
import pymeshfix
tin = pymeshfix.PyTMesh()
tin.load_array(v, f)
operations = []
if remove_small_components:
tin.remove_smallest_components()
operations.append("Removed small components")
if join_components:
tin.join_closest_components()
operations.append("Joined nearby components")
if clean_mesh:
tin.clean(max_iters=clean_iterations, inner_loops=inner_loops)
operations.append(f"Cleaned (iters={clean_iterations})")
vclean, fclean = tin.return_arrays()
result_mesh = trimesh.Trimesh(vertices=vclean, faces=fclean, process=False)
if hasattr(mesh, "metadata") and mesh.metadata:
result_mesh.metadata = mesh.metadata.copy()
final_vertices, final_faces = len(result_mesh.vertices), len(result_mesh.faces)
is_watertight = result_mesh.is_watertight
info = (
f"Mesh Repair (pymeshfix):\n\n"
f"Applied: {', '.join(operations) if operations else '(none enabled)'}\n"
f"Vertices: {initial_vertices:,} -> {final_vertices:,} ({final_vertices - initial_vertices:+,})\n"
f"Faces: {initial_faces:,} -> {final_faces:,} ({final_faces - initial_faces:+,})\n"
f"Watertight: {was_watertight} -> {is_watertight}"
)
log.info("Mesh Repair (pymeshfix): applied [%s] | %dv/%df -> %dv/%df",
", ".join(operations), initial_vertices, initial_faces, final_vertices, final_faces)
return io.NodeOutput(result_mesh, info, ui={"text": [info]})
NODE_CLASS_MAPPINGS = {"GeomPackMeshRepair_PyMeshFix": MeshRepairPyMeshFixNode}
NODE_DISPLAY_NAME_MAPPINGS = {"GeomPackMeshRepair_PyMeshFix": "Mesh Repair PyMeshFix (backend)"}
+116
View File
@@ -0,0 +1,116 @@
# SPDX-License-Identifier: GPL-3.0-or-later
# Copyright (C) 2025 ComfyUI-GeometryPack Contributors
"""Trimesh mesh-repair backend node (merge duplicate vertices + drop degenerate/cap faces)."""
import logging
import numpy as np
import trimesh as trimesh_module
from comfy_api.latest import io
from ..remove_degenerate_faces import _face_max_angle_apex, _collapse_caps
log = logging.getLogger("geompack")
class MeshRepairTrimeshNode(io.ComfyNode):
"""Trimesh cleanup backend: merges duplicate vertices, then drops degenerate/cap faces."""
@classmethod
def define_schema(cls):
return io.Schema(
node_id="GeomPackMeshRepair_Trimesh",
display_name="Mesh Repair Trimesh (backend)",
category="geompack/repair",
is_dev_only=True,
is_output_node=True,
inputs=[
io.Custom("TRIMESH").Input("mesh"),
io.Float.Input("tolerance", default=1e-5, min=1e-8, max=1e-2, step=1e-6,
tooltip="Distance tolerance for merging duplicate vertices (1e-5 "
"recommended for CAD meshes). Runs first -- merging can "
"create degenerate faces, which the next step then cleans up."),
io.Float.Input("min_area", default=1e-10, min=0.0, max=1.0, step=1e-10,
optional=True,
tooltip="Faces with area below this are DELETED (zero/near-zero "
"area slivers). 0 disables this test."),
io.Float.Input("max_angle_deg", default=180.0, min=90.0, max=180.0, step=0.5,
optional=True,
tooltip="CAP-sliver removal by COLLAPSE. A face whose LARGEST "
"interior angle is >= this is a cap -- its apex is "
"collapsed onto the nearer base vertex. 180 = OFF; set "
"~175-179 to clean caps from OCC/image-derived meshes."),
],
outputs=[
io.Custom("TRIMESH").Output(display_name="repaired_mesh"),
io.String.Output(display_name="info"),
],
)
@classmethod
def execute(cls, mesh, tolerance=1e-5, min_area=1e-10, max_angle_deg=180.0):
verts_before, faces_before = len(mesh.vertices), len(mesh.faces)
# Step 1: merge duplicate/near-duplicate vertices.
merged = mesh.copy()
digits = max(0, -int(np.floor(np.log10(tolerance))))
merged.merge_vertices(digits_vertex=digits)
verts_after_merge, faces_after_merge = len(merged.vertices), len(merged.faces)
# Step 2: drop degenerate/cap faces (may have been created by the merge above).
cleaned = merged
n_caps = 0
if 0.0 < float(max_angle_deg) < 180.0:
V = np.asarray(cleaned.vertices, dtype=np.float64)
F = np.asarray(cleaned.faces, dtype=np.int64)
vattrs = {k: np.asarray(v) for k, v in dict(cleaned.vertex_attributes).items()}
fattrs = {k: np.asarray(v) for k, v in dict(cleaned.face_attributes).items()}
V2, F2, va2, fa2, n_caps = _collapse_caps(V, F, vattrs, fattrs,
np.radians(float(max_angle_deg)))
if n_caps > 0:
cleaned = trimesh_module.Trimesh(vertices=V2, faces=F2.astype(np.int32), process=False)
for k, v in va2.items():
cleaned.vertex_attributes[k] = v
for k, v in fa2.items():
cleaned.face_attributes[k] = v
duplicate_mask = np.array([len(set(f)) == 3 for f in cleaned.faces])
if np.any(~duplicate_mask):
cleaned.update_faces(duplicate_mask)
if hasattr(cleaned, 'nondegenerate_faces'):
area_mask = cleaned.nondegenerate_faces()
if np.any(~area_mask):
cleaned.update_faces(area_mask)
if min_area > 0 and len(cleaned.faces):
area_mask = cleaned.area_faces >= min_area
if np.any(~area_mask):
cleaned.update_faces(area_mask)
cleaned.remove_unreferenced_vertices()
verts_after, faces_after = len(cleaned.vertices), len(cleaned.faces)
merge_verts_removed = verts_before - verts_after_merge
degen_faces_removed = faces_after_merge - faces_after
info = f"""Mesh Repair (trimesh):
Merge Vertices (tolerance={tolerance:.2e}, {digits} decimal places):
Vertices: {verts_before:,} -> {verts_after_merge:,} ({-merge_verts_removed:+,})
Faces: {faces_before:,} -> {faces_after_merge:,} ({faces_after_merge - faces_before:+,})
Remove Degenerate Faces (cap angle >= {max_angle_deg:g} deg, {n_caps:,} collapsed):
Vertices: {verts_after_merge:,} -> {verts_after:,} ({verts_after - verts_after_merge:+,})
Faces: {faces_after_merge:,} -> {faces_after:,} ({-degen_faces_removed:+,})
Overall:
Vertices: {verts_before:,} -> {verts_after:,} ({verts_after - verts_before:+,})
Faces: {faces_before:,} -> {faces_after:,} ({faces_after - faces_before:+,})
"""
log.info("Mesh Repair (trimesh): %dv/%df -> %dv/%df", verts_before, faces_before, verts_after, faces_after)
return io.NodeOutput(cleaned, info, ui={"text": [info]})
NODE_CLASS_MAPPINGS = {"GeomPackMeshRepair_Trimesh": MeshRepairTrimeshNode}
NODE_DISPLAY_NAME_MAPPINGS = {"GeomPackMeshRepair_Trimesh": "Mesh Repair Trimesh (backend)"}
+3 -1
View File
@@ -83,11 +83,13 @@ class ComputeNormalsNode(io.ComfyNode):
result_mesh.metadata['normals_smoothed'] = True
log.info("Computed smooth vertex normals")
# Store normals as vertex attributes for visualization
# Store normals as vertex attributes for visualization: separate scalar
# components (existing consumers expect these) plus a combined (n,3) field.
result_mesh.vertex_attributes['normal_x'] = vertex_normals[:, 0]
result_mesh.vertex_attributes['normal_y'] = vertex_normals[:, 1]
result_mesh.vertex_attributes['normal_z'] = vertex_normals[:, 2]
result_mesh.vertex_attributes['normal_magnitude'] = np.linalg.norm(vertex_normals, axis=1)
result_mesh.vertex_attributes['normals_xyz'] = vertex_normals
return io.NodeOutput(result_mesh)
@@ -30,6 +30,7 @@ class FixSelfIntersectionsByRemovalNode(io.ComfyNode):
node_id="GeomPackFixSelfIntersectionsByRemoval",
display_name="Fix Self Intersections (Removal)",
category="geompack/repair",
is_dev_only=True,
is_output_node=True,
inputs=[
io.Custom("TRIMESH").Input("trimesh"),
+24 -2
View File
@@ -6,7 +6,9 @@ Unified Mesh Repair node - single frontend with backend selector.
Cleans up degenerate / sliver / duplicate / non-manifold elements. Dispatches to
hidden backend nodes via node expansion (GraphBuilder), each in its own isolation
env. Designed to grow more backends (pymeshfix, trimesh, cgal, ...) over time.
env. Self-intersection fixing lives in its own dispatcher (GeomPackFixSelfIntersections,
nodes/repair_cgal/fix_self_intersections.py) -- a distinct topology problem, not
degeneracy/duplication.
"""
import logging
@@ -19,7 +21,9 @@ class MeshRepairNode(io.ComfyNode):
"""Mesh Repair - unified degenerate/sliver cleanup with backend selection."""
BACKEND_MAP = {
"trimesh": "GeomPackMeshRepair_Trimesh",
"pymeshlab": "GeomPackMeshRepair_PyMeshLab",
"pymeshfix": "GeomPackMeshRepair_PyMeshFix",
}
@classmethod
@@ -33,14 +37,25 @@ class MeshRepairNode(io.ComfyNode):
"duplicate vertices, T-vertices, non-manifold edges, and small floating "
"components. Pick a backend; each exposes its own toggles.\n"
"\n"
"trimesh: lightweight, no extra deps -- merges duplicate vertices then drops "
"degenerate/cap-sliver faces.\n"
"pymeshlab: per-operation cleanup -- the real sliver/degenerate remover "
"(MeshLab's meshing_remove_* filters). More backends (pymeshfix, ...) to come."
"(MeshLab's meshing_remove_* filters).\n"
"pymeshfix: small-component removal/joining plus PyTMesh's own clean() pass."
),
enable_expand=True,
is_output_node=True,
inputs=[
io.Custom("TRIMESH").Input("mesh"),
io.DynamicCombo.Input("backend", tooltip="Repair algorithm and backend", options=[
io.DynamicCombo.Option("trimesh", [
io.Float.Input("tolerance", default=1e-5, min=1e-8, max=1e-2, step=1e-6,
tooltip="Distance tolerance for merging duplicate vertices (1e-5 recommended for CAD meshes)."),
io.Float.Input("min_area", default=1e-10, min=0.0, max=1.0, step=1e-10,
tooltip="Faces with area below this are deleted (0 disables this test)."),
io.Float.Input("max_angle_deg", default=180.0, min=90.0, max=180.0, step=0.5,
tooltip="Cap-sliver collapse threshold on largest interior angle. 180 = off."),
]),
io.DynamicCombo.Option("pymeshlab", [
io.Combo.Input("remove_duplicate_vertices", options=["true", "false"], default="true", tooltip="Merge coincident (duplicate) vertices first."),
io.Combo.Input("remove_null_faces", options=["true", "false"], default="true", tooltip="Remove null / zero-area (degenerate) faces -- the core sliver remover."),
@@ -51,6 +66,13 @@ class MeshRepairNode(io.ComfyNode):
io.Float.Input("remove_small_components_pct", default=0.0, min=0.0, max=100.0, step=0.5, display_mode="number", tooltip="Drop floating components below this PERCENT of the mesh's bbox diagonal (0 = off). Removes scan/recon crumbs."),
io.Combo.Input("remove_unreferenced_vertices", options=["true", "false"], default="true", tooltip="Drop orphan vertices not used by any face (last)."),
]),
io.DynamicCombo.Option("pymeshfix", [
io.Combo.Input("remove_small_components", options=["true", "false"], default="true", tooltip="Remove small isolated mesh fragments before cleaning."),
io.Combo.Input("join_components", options=["true", "false"], default="false", tooltip="Attempt to join nearby disconnected components."),
io.Combo.Input("clean_mesh", options=["true", "false"], default="true", tooltip="Remove self-intersections and degenerate faces (PyTMesh.clean)."),
io.Int.Input("clean_iterations", default=10, min=1, max=100, step=1, tooltip="Max iterations for self-intersection removal."),
io.Int.Input("inner_loops", default=3, min=1, max=10, step=1, tooltip="Inner loops per clean iteration."),
]),
]),
],
outputs=[
+3
View File
@@ -9,6 +9,7 @@ from .detect_intersections import NODE_CLASS_MAPPINGS as DETECT_INTERSECTIONS_MA
from .remesh_intersections import NODE_CLASS_MAPPINGS as REMESH_INTERSECTIONS_MAPS, NODE_DISPLAY_NAME_MAPPINGS as REMESH_INTERSECTIONS_DISP
from .fix_intersections_perturbation import NODE_CLASS_MAPPINGS as FIX_INTERSECTIONS_PERTURBATION_MAPS, NODE_DISPLAY_NAME_MAPPINGS as FIX_INTERSECTIONS_PERTURBATION_DISP
from .fill_holes_cgal import NODE_CLASS_MAPPINGS as FILL_HOLES_CGAL_MAPS, NODE_DISPLAY_NAME_MAPPINGS as FILL_HOLES_CGAL_DISP
from .fix_self_intersections import NODE_CLASS_MAPPINGS as FIX_SELF_INTERSECTIONS_MAPS, NODE_DISPLAY_NAME_MAPPINGS as FIX_SELF_INTERSECTIONS_DISP
NODE_CLASS_MAPPINGS = {}
NODE_DISPLAY_NAME_MAPPINGS = {}
@@ -21,5 +22,7 @@ NODE_CLASS_MAPPINGS.update(FIX_INTERSECTIONS_PERTURBATION_MAPS)
NODE_DISPLAY_NAME_MAPPINGS.update(FIX_INTERSECTIONS_PERTURBATION_DISP)
NODE_CLASS_MAPPINGS.update(FILL_HOLES_CGAL_MAPS)
NODE_DISPLAY_NAME_MAPPINGS.update(FILL_HOLES_CGAL_DISP)
NODE_CLASS_MAPPINGS.update(FIX_SELF_INTERSECTIONS_MAPS)
NODE_DISPLAY_NAME_MAPPINGS.update(FIX_SELF_INTERSECTIONS_DISP)
__all__ = ['NODE_CLASS_MAPPINGS', 'NODE_DISPLAY_NAME_MAPPINGS']
@@ -30,6 +30,7 @@ class FixSelfIntersectionsByPerturbationNode(io.ComfyNode):
node_id="GeomPackFixSelfIntersectionsByPerturbation",
display_name="Fix Self Intersections (Perturbation)",
category="geompack/repair",
is_dev_only=True,
is_output_node=True,
inputs=[
io.Custom("TRIMESH").Input("trimesh"),
+194
View File
@@ -0,0 +1,194 @@
# SPDX-License-Identifier: GPL-3.0-or-later
# Copyright (C) 2025 ComfyUI-GeometryPack Contributors
"""
Unified Fix Self Intersections node - single frontend with backend selector.
Dispatches to the existing standalone self-intersection fixers via node expansion
(GraphBuilder), auto-chaining GeomPackDetectSelfIntersections internally: every backend
gets a fresh field (face_attributes['self_intersecting'] / vertex_attributes
['intersection_flag']/['intersection_count']) recomputed on its OUTPUT, unconditionally
-- not left to each backend's own optional re-detect toggle. Removal/Perturbation
additionally get Detect run BEFORE them too, since they require the field as input
(no need to manually wire a separate Detect node upstream anymore). Remesh works from
raw geometry, so it only needs the after-step. GeomPackDetectSelfIntersections itself
stays a standalone, visible node -- it's inspection, not a fix.
"""
import logging
from comfy_api.latest import io
log = logging.getLogger("geometrypack")
class FixSelfIntersectionsNode(io.ComfyNode):
"""Fix Self Intersections - unified self-intersection repair with backend selection."""
BACKEND_MAP = {
"removal": "GeomPackFixSelfIntersectionsByRemoval",
"perturbation": "GeomPackFixSelfIntersectionsByPerturbation",
"remesh": "GeomPackRemeshSelfIntersections",
}
# Each backend's mesh input parameter has a different name (inherited unchanged
# from the standalone nodes being dispatched to).
MESH_PARAM = {
"removal": "trimesh",
"perturbation": "trimesh",
"remesh": "mesh",
}
@classmethod
def define_schema(cls):
return io.Schema(
node_id="GeomPackFixSelfIntersections",
display_name="Fix Self Intersections",
category="geompack/repair",
description=(
"Fix self-intersecting geometry. Pick a backend; each exposes its own toggles. "
"Self-intersection detection runs automatically (before and/or after, as needed) "
"-- no need to wire a separate 'Detect Self Intersections' node.\n"
"\n"
"removal: deletes intersecting faces, then optionally fills the resulting holes "
"and fixes normals.\n"
"perturbation: nudges vertices adjacent to intersections along their normals -- "
"non-destructive, preserves topology.\n"
"remesh: subdivides intersecting triangles via libigl/CGAL so intersections lie "
"exactly on edges."
),
enable_expand=True,
is_output_node=True,
inputs=[
io.Custom("TRIMESH").Input("mesh"),
io.DynamicCombo.Input("backend", tooltip="Self-intersection fix algorithm and backend", options=[
io.DynamicCombo.Option("removal", [
io.Boolean.Input("fill_holes", default=True, tooltip=(
"After deleting the faces flagged as self-intersecting, the removed patch "
"leaves an open hole in the mesh. When enabled, trimesh's fill_holes() "
"re-triangulates that gap so the surface stays closed. Disable if you'd "
"rather inspect the resulting hole, or if you plan to fill it yourself with "
"a more controllable node (the dedicated Fill Holes dispatcher offers "
"CGAL/pymeshfix/igl_fan backends with more quality control than this quick fill).")),
io.Boolean.Input("fix_normals", default=True, tooltip=(
"Recomputes/reorients face normals after the removal+fill step, since "
"deleting faces and adding new triangulated ones can leave inconsistent "
"winding at the seam. Cheap and safe to leave on; only disable if you're "
"already running a dedicated Fix Normals pass afterward anyway.")),
io.Int.Input("max_hole_size", default=100, min=3, max=10000, tooltip=(
"Upper bound, in boundary edges, on how large a hole this node's own "
"fill_holes step will attempt to close. Removing a cluster of intersecting "
"faces can leave a large, irregular hole; big holes are expensive and often "
"triangulate poorly with this simple fill. Holes larger than this are left "
"open -- feed the result into the dedicated Fill Holes node afterward for a "
"higher-quality fill on anything that didn't get closed here.")),
]),
io.DynamicCombo.Option("perturbation", [
io.Float.Input("epsilon", default=0.001, min=1e-8, max=1.0, step=0.0001, tooltip=(
"Base displacement distance, in the mesh's own world units (not a "
"percentage), applied along each affected vertex's normal. Too small and "
"the intersection may persist; too large and you visibly deform the "
"surface near the fix site. 0.001 is a reasonable starting point for a mesh "
"roughly normalized to unit scale -- for CAD-derived meshes in mm or with a "
"much larger/smaller bounding box, scale this relative to the mesh's "
"typical edge length rather than using the default blindly.")),
io.Int.Input("max_iterations", default=10, min=1, max=100, tooltip=(
"Number of perturbation passes. Displacement ramps up linearly across "
"iterations (iteration 1 moves epsilon/max_iterations, the final iteration "
"moves the full epsilon) -- so more iterations means a gentler ramp toward "
"the same final displacement, not more total movement. Increase if a single "
"pass still leaves faces marginally intersecting; each extra iteration also "
"recomputes vertex normals, so cost scales roughly linearly with this value.")),
io.Combo.Input("direction", options=["outward", "inward", "adaptive"], default="adaptive", tooltip=(
"Which way to nudge affected vertices along their normals. 'outward' pushes "
"vertices away from the surface (grows the mesh slightly at the intersection "
"site); 'inward' pulls them in (shrinks it slightly); 'adaptive' is intended "
"to choose per-vertex based on local geometry, but this backend's current "
"implementation falls back to the same behavior as 'outward' -- functionally "
"an alias for now. Kept as the default anyway since it's the intended "
"long-term behavior once true per-vertex direction selection lands; switch "
"to 'inward' explicitly if you specifically need the mesh to shrink rather "
"than grow at the fix site.")),
io.Boolean.Input("scale_by_intersection_count", default=True, tooltip=(
"When enabled, vertices touched by more intersecting faces get "
"proportionally larger displacement (scaled 0-1 against the mesh's maximum "
"per-vertex intersection count from the internal Detect pass), so heavily "
"tangled regions get nudged harder than lightly-affected ones. Disable for a "
"uniform epsilon everywhere, regardless of how many intersections each "
"vertex participates in -- useful if you want a predictable, constant "
"displacement rather than one that varies across the mesh.")),
]),
io.DynamicCombo.Option("remesh", [
io.Boolean.Input("remove_unreferenced", default=True, tooltip=(
"After CGAL subdivides intersecting triangles along their intersection "
"curves, some original vertices may end up referenced by no face at all. "
"Enabling this strips them out (via igl.remove_unreferenced) so the output "
"mesh's vertex list stays compact. Safe to leave on; only matters if "
"something downstream depends on vertex indices/count staying untouched "
"from the input mesh.")),
io.Boolean.Input("extract_outer_hull", default=False, tooltip=(
"Attempts to extract just the outer, manifold shell of the remeshed result "
"(igl.outer_hull_legacy), discarding any interior geometry the subdivision "
"created at the intersection sites. Produces a cleaner, genuinely manifold "
"mesh suitable for boolean operations or 3D printing, but is significantly "
"slower than the base remesh step and can fail or silently no-op on complex "
"topology. Leave off for a quick fix; enable when you specifically need a "
"watertight, printable result and are willing to pay the extra cost.")),
io.Boolean.Input("stitch_all", default=True, tooltip=(
"Passed straight through to CGAL's remesh_self_intersections call. When "
"enabled, CGAL attempts to stitch together the boundaries created by "
"subdividing intersecting triangles, keeping the result as connected/closed "
"as possible rather than leaving separate disconnected patches at each "
"intersection site. Recommended on by default; disabling it can leave more "
"fragmented geometry that's harder to clean up afterward.")),
]),
]),
],
outputs=[
io.Custom("TRIMESH").Output(display_name="fixed_mesh"),
io.String.Output(display_name="info"),
],
)
DETECT_NODE_ID = "GeomPackDetectSelfIntersections"
@classmethod
def execute(cls, mesh, backend):
from comfy_execution.graph_utils import GraphBuilder
if cls.SCHEMA is None:
cls.GET_SCHEMA()
selected = backend["backend"]
node_id = cls.BACKEND_MAP[selected]
mesh_param = cls.MESH_PARAM[selected]
log.info("Fix Self Intersections dispatch: %s -> %s", selected, node_id)
kwargs = {k: v for k, v in backend.items() if k != "backend"}
graph = GraphBuilder()
if selected == "remesh":
# Works from raw geometry -- no pre-detection needed, always a real fix pass.
kwargs["detect_only"] = False
fix_node = graph.node(node_id, mesh=mesh, **kwargs)
else:
# Removal/perturbation read the field to know which faces/vertices to touch,
# so detect first. The dispatcher re-detects after unconditionally (below),
# so the backend's own re-detect is redundant here -- skip it.
detect_before = graph.node(cls.DETECT_NODE_ID, trimesh=mesh)
kwargs["re_detect_after_fix"] = False
fix_node = graph.node(node_id, **{mesh_param: detect_before.out(0)}, **kwargs)
# Always recompute the field on the final output, regardless of backend, so the
# result never needs a manually-wired Detect node to inspect what (if anything) remains.
detect_after = graph.node(cls.DETECT_NODE_ID, trimesh=fix_node.out(0))
return {
"result": (detect_after.out(0), fix_node.out(1)),
"expand": graph.finalize(),
}
NODE_CLASS_MAPPINGS = {"GeomPackFixSelfIntersections": FixSelfIntersectionsNode}
NODE_DISPLAY_NAME_MAPPINGS = {"GeomPackFixSelfIntersections": "Fix Self Intersections"}
@@ -31,6 +31,7 @@ class RemeshSelfIntersectionsNode(io.ComfyNode):
node_id="GeomPackRemeshSelfIntersections",
display_name="Remesh Self Intersections",
category="geompack/repair",
is_dev_only=True,
is_output_node=True,
inputs=[
io.Custom("TRIMESH").Input("mesh"),
+5 -2
View File
@@ -44,6 +44,9 @@ class PreviewMeshNode(io.ComfyNode):
inputs=[
io.Custom("TRIMESH").Input("trimesh"),
],
outputs=[
io.Custom("TRIMESH").Output(display_name="mesh"),
],
)
@classmethod
@@ -85,8 +88,8 @@ class PreviewMeshNode(io.ComfyNode):
extents = trimesh.extents
max_extent = max(extents)
# Return metadata for frontend widget
return io.NodeOutput(ui={ "mesh_file": [filename], "vertex_count": [len(trimesh.vertices)], "face_count": [get_face_count(trimesh)], "bounds_min": [bounds[0].tolist()], "bounds_max": [bounds[1].tolist()], "extents": [extents.tolist()], "max_extent": [float(max_extent)], })
# Return metadata for frontend widget, and pass the mesh through as an output
return io.NodeOutput(trimesh, ui={ "mesh_file": [filename], "vertex_count": [len(trimesh.vertices)], "face_count": [get_face_count(trimesh)], "bounds_min": [bounds[0].tolist()], "bounds_max": [bounds[1].tolist()], "extents": [extents.tolist()], "max_extent": [float(max_extent)], })
NODE_CLASS_MAPPINGS = {
"GeomPackPreviewMesh": PreviewMeshNode,
+4 -1
View File
@@ -115,6 +115,9 @@ class PreviewMeshDualNode(io.ComfyNode):
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),
],
)
@classmethod
@@ -297,7 +300,7 @@ class PreviewMeshDualNode(io.ComfyNode):
})
log.info("Preview ready")
return io.NodeOutput(ui=ui_data)
return io.NodeOutput([mesh_1, mesh_2], ui=ui_data)
@staticmethod
def _export_mesh(mesh, base_filename, use_vtp, use_glb):
+4 -1
View File
@@ -96,6 +96,9 @@ class PreviewMeshMultiNode(io.ComfyNode):
io.Custom("TRIMESH").Input("mesh_4", optional=True),
io.Combo.Input("mode", options=["fields", "texture"], default="fields", optional=True),
],
outputs=[
io.Custom("TRIMESH").Output(display_name="meshes", is_output_list=True),
],
)
@classmethod
@@ -224,7 +227,7 @@ class PreviewMeshMultiNode(io.ComfyNode):
ui_data["field_names_list"] = [field_names_list]
log.info("Grid: %dx%d, Preview ready", grid_cols, grid_rows)
return io.NodeOutput(ui=ui_data)
return io.NodeOutput(meshes, ui=ui_data)
NODE_CLASS_MAPPINGS = {
+59 -5
View File
@@ -7,15 +7,26 @@ Preview Mesh Slicer - interactive VTK.js clip/slice preview.
Top: 3D view of the mesh with a live clipping plane. Bottom: an orthographic
pick view (toggle XY / YZ / XZ); click two points to define a cut line, which is
swept along the view axis into a clip plane. "Flip clip" slices the other side.
A "3d view:" row of +X/-X/+Y/-Y/+Z/-Z buttons sets an axis-aligned plane through
the mesh center directly, without needing to pick 2 points.
The Python node just exports the mesh to a .vtp; all the clipping is interactive
in the browser (viewer_slicer.html).
The Python node exports the mesh to a .vtp for the interactive viewer; the actual
clip plane (origin + normal) is picked entirely in the browser, then mirrored back
here via a hidden `plane_json` widget (see web_src/preview_mesh_slicer.js) so this
node can reproduce the same cut on the real mesh data and return it as `sliced_mesh`
-- matching whatever is currently shown in the top 3D view. Since the plane only
exists once the browser has posted it back, the very first run (or right after
loading a new mesh) has no plane yet and `sliced_mesh` is the mesh unchanged --
pick a cut in the viewer, then queue again to get the actual slice.
"""
import json
import logging
import os
import uuid
import numpy as np
import trimesh
from comfy_api.latest import io
log = logging.getLogger("geometrypack")
@@ -33,12 +44,18 @@ class PreviewMeshSlicer(io.ComfyNode):
is_output_node=True,
inputs=[
io.Custom("TRIMESH").Input("mesh", tooltip="Mesh to slice/clip interactively."),
io.String.Input("plane_json", default="", optional=True,
tooltip="Internal: clip plane picked in the viewer, mirrored "
"back by the frontend. Not meant to be edited by hand."),
],
outputs=[
io.Custom("TRIMESH").Output(display_name="sliced_mesh"),
io.String.Output(display_name="info"),
],
outputs=[],
)
@classmethod
def execute(cls, mesh):
def execute(cls, mesh, plane_json=""):
import folder_paths
from ._vtp_export import export_mesh_with_scalars_vtp
@@ -50,7 +67,44 @@ class PreviewMeshSlicer(io.ComfyNode):
nv = len(getattr(mesh, "vertices", []))
nf = len(getattr(mesh, "faces", []))
log.info("[PreviewMeshSlicer] exported %s (%d verts, %d faces)", filename, nv, nf)
return io.NodeOutput(ui={
sliced_mesh = mesh
info = (f"{nv:,} verts / {nf:,} faces -- no clip plane set yet. Pick a cut in the "
f"viewer (2 points, or a +X/-X/+Y/-Y/+Z/-Z button), then queue again.")
plane = None
if plane_json:
try:
plane = json.loads(plane_json)
except (ValueError, TypeError) as e:
log.warning("[PreviewMeshSlicer] bad plane_json: %s", e)
if plane and plane.get("hasPlane") and "origin" in plane and "normal" in plane:
# Matches the VTK view exactly: no capping (VTK's clipping plane doesn't cap the
# cross-section either), keep the side the normal points TOWARD (trimesh's own
# "positive normal side" convention for slice_faces_plane == VTK's kept side).
from trimesh.intersections import slice_faces_plane
origin = np.asarray(plane["origin"], dtype=np.float64)
normal = np.asarray(plane["normal"], dtype=np.float64)
V, F, _ = slice_faces_plane(
vertices=np.asarray(mesh.vertices, dtype=np.float64).copy(),
faces=np.asarray(mesh.faces, dtype=np.int64).copy(),
plane_normal=normal,
plane_origin=origin,
)
sliced_mesh = trimesh.Trimesh(vertices=V, faces=F, process=False)
if hasattr(mesh, "metadata") and mesh.metadata:
sliced_mesh.metadata = mesh.metadata.copy()
sv, sf = len(sliced_mesh.vertices), len(sliced_mesh.faces)
log.info("[PreviewMeshSlicer] sliced origin=%s normal=%s: %dv/%df -> %dv/%df",
origin.tolist(), normal.tolist(), nv, nf, sv, sf)
info = (f"Sliced at origin={np.round(origin, 4).tolist()}, "
f"normal={np.round(normal, 4).tolist()}\n"
f"{nv:,} -> {sv:,} verts, {nf:,} -> {sf:,} faces")
return io.NodeOutput(sliced_mesh, info, ui={
"mesh_file": [filename],
"summary": [f"{nv:,} verts / {nf:,} faces — slice interactively in the viewer"],
})
+4 -1
View File
@@ -53,6 +53,9 @@ class PreviewMeshVTKNode(io.ComfyNode):
io.Custom("TRIMESH").Input("trimesh", optional=True),
io.Custom("VOXELGRID").Input("voxelgrid", optional=True),
],
outputs=[
io.Custom("TRIMESH").Output(display_name="mesh"),
],
)
@classmethod
@@ -310,7 +313,7 @@ class PreviewMeshVTKNode(io.ComfyNode):
else:
log.info("Fields mode info: watertight=%s, volume=%s, area=%s, no fields", is_watertight, volume, area)
return io.NodeOutput(ui=ui_data)
return io.NodeOutput(trimesh, ui=ui_data)
NODE_CLASS_MAPPINGS = {
+1 -1
View File
@@ -1,3 +1,3 @@
comfy-env==0.4.3
comfy-3d-viewers==0.2.54
comfy-3d-viewers==0.2.55
trimesh[easy]>=4.0.0
+23
View File
@@ -42,6 +42,29 @@ app.registerExtension({
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;
};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
-194
View File
@@ -1,194 +0,0 @@
{
"id": "n4e5d6c7-8b9a-0123-def4-234567890123",
"revision": 0,
"last_node_id": 8,
"last_link_id": 9,
"nodes": [
{
"id": 2,
"type": "GeomPackCheckNormals",
"pos": [
-380,
-150
],
"size": [
320,
78
],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"name": "trimesh",
"type": "TRIMESH",
"link": 1
}
],
"outputs": [
{
"name": "report",
"type": "STRING",
"links": [
9
]
}
],
"properties": {
"cnr_id": "comfyui-geometrypack",
"ver": "0.1.1",
"Node name for S&R": "GeomPackCheckNormals"
},
"widgets_values": []
},
{
"id": 1,
"type": "GeomPackLoadMesh",
"pos": [
-700,
0
],
"size": [
270,
78
],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "mesh",
"type": "TRIMESH",
"links": [
1,
2
]
},
{
"name": "texture",
"type": "IMAGE",
"links": null
}
],
"properties": {
"cnr_id": "comfyui-geometrypack",
"ver": "0.1.1",
"Node name for S&R": "GeomPackLoadMesh"
},
"widgets_values": [
"3d/Stanford_Bunny.stl"
]
},
{
"id": 7,
"type": "GeomPackPreviewMeshVTK",
"pos": [
327.31770833333354,
6.866319444444557
],
"size": [
512,
694
],
"flags": {},
"order": 4,
"mode": 0,
"inputs": [
{
"name": "trimesh",
"type": "TRIMESH",
"link": 8
}
],
"outputs": [],
"properties": {
"cnr_id": "comfyui-geometrypack",
"ver": "991c7a75886f50d567e5ed6d36aed06d0729a938",
"Node name for S&R": "GeomPackPreviewMeshVTK"
},
"widgets_values": [
"fields",
"{\"show_edges\": false, \"camera_state\": \"\", \"selected_field\": \"\"}"
]
},
{
"id": 3,
"type": "GeomPackComputeNormals",
"pos": [
-291.1762152777778,
67.71267361111111
],
"size": [
320,
78
],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"name": "trimesh",
"type": "TRIMESH",
"link": 2
}
],
"outputs": [
{
"name": "mesh_with_normals",
"type": "TRIMESH",
"links": [
8
]
}
],
"properties": {
"cnr_id": "comfyui-geometrypack",
"ver": "0.1.1",
"Node name for S&R": "GeomPackComputeNormals"
},
"widgets_values": [
"true"
]
}
],
"links": [
[
1,
1,
0,
2,
0,
"TRIMESH"
],
[
2,
1,
0,
3,
0,
"TRIMESH"
],
[
8,
3,
0,
7,
0,
"TRIMESH"
]
],
"groups": [],
"config": {},
"extra": {
"ds": {
"scale": 0.9,
"offset": [
1002.3090277777774,
328.9756944444442
]
},
"frontendVersion": "1.30.6"
},
"version": 0.4
}
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
{"id":"3606b601-de0b-4543-94a3-8fcf692c36bb","revision":0,"last_node_id":22,"last_link_id":23,"nodes":[{"id":1,"type":"GeomPackLoadMesh","pos":[-641.7436980668338,130.9743348300825],"size":[270,126],"flags":{},"order":0,"mode":0,"inputs":[{"localized_name":"file_path","name":"file_path","type":"COMBO","widget":{"name":"file_path"},"link":null}],"outputs":[{"localized_name":"mesh","name":"mesh","type":"TRIMESH","links":[1]},{"localized_name":"texture","name":"texture","type":"IMAGE","links":null}],"properties":{"cnr_id":"comfyui-geometrypack","ver":"9636f8cac81c4eb817a1a6e1f55378c5f5a2e528","Node name for S&R":"GeomPackLoadMesh"},"widgets_values":["3d/Stanford_Bunny.stl","",null,null]},{"id":2,"type":"GeomPackPreviewMeshSlicer","pos":[-353.3955575674956,90.95840600979454],"size":[569.1532433847783,602],"flags":{},"order":1,"mode":0,"inputs":[{"localized_name":"mesh","name":"mesh","type":"TRIMESH","link":1},{"localized_name":"plane_json","name":"plane_json","shape":7,"type":"STRING","widget":{"name":"plane_json"},"link":null}],"outputs":[{"localized_name":"sliced_mesh","name":"sliced_mesh","type":"TRIMESH","links":[21]},{"localized_name":"info","name":"info","type":"STRING","links":null}],"properties":{"cnr_id":"comfyui-geometrypack","ver":"9636f8cac81c4eb817a1a6e1f55378c5f5a2e528","Node name for S&R":"GeomPackPreviewMeshSlicer"},"widgets_values":["{\"type\":\"PLANE_UPDATE\",\"hasPlane\":false}",""]},{"id":3,"type":"GeomPackSaveMesh","pos":[243.75112496899632,86.7700884931457],"size":[270,82],"flags":{},"order":2,"mode":0,"inputs":[{"localized_name":"trimesh","name":"trimesh","type":"TRIMESH","link":21},{"localized_name":"file_path","name":"file_path","type":"STRING","widget":{"name":"file_path"},"link":null},{"localized_name":"format","name":"format","type":"COMBO","widget":{"name":"format"},"link":null}],"outputs":[{"localized_name":"file_path","name":"file_path","type":"STRING","links":null}],"properties":{"cnr_id":"comfyui-geometrypack","ver":"9636f8cac81c4eb817a1a6e1f55378c5f5a2e528","Node name for S&R":"GeomPackSaveMesh"},"widgets_values":["test","obj"]}],"links":[[1,1,0,2,0,"TRIMESH"],[21,2,0,3,0,"TRIMESH"]],"groups":[],"config":{},"extra":{"ds":{"scale":0.9229599817706441,"offset":[1071.4329166717343,190.77899387464086]}},"version":0.4}
File diff suppressed because one or more lines are too long