various improvements to existing nodes, extended coverage
This commit is contained in:
@@ -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']
|
||||
|
||||
@@ -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']
|
||||
@@ -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)"}
|
||||
@@ -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']
|
||||
|
||||
@@ -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)"}
|
||||
@@ -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)"}
|
||||
@@ -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"),
|
||||
|
||||
@@ -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=[
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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"],
|
||||
})
|
||||
|
||||
@@ -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
@@ -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
|
||||
@@ -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
@@ -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
@@ -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
Reference in New Issue
Block a user