diff --git a/README.md b/README.md index 53909c7e..90424d99 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,23 @@ Questions or feature requests? Open a [Discussion](https://github.com/PozzettiAn Join the [Comfy3D Discord](https://discord.gg/bcdQCUjnHE) for help, updates, and chat about 3D workflows in ComfyUI. +## Multi-Backend Nodes + +Each of these nodes presents a single UI with a backend selector dropdown. Under the hood, ComfyUI's GraphBuilder dispatches to hidden backend-specific nodes, enabling cross-environment execution (main, Blender, GPU, CGAL). + +| Node | Backends | +|------|----------| +| **Remesh** | pymeshlab_isotropic, instant_meshes, quadriflow, mmg_adaptive, geogram_smooth, geogram_anisotropic, pmp_uniform, pmp_adaptive, quadwild, cgal_isotropic, blender_voxel, blender_smooth, blender_sharp, blender_blocks, gpu_cumesh | +| **Decimate Mesh** | quadric_edge_collapse, fast_simplification, vertex_clustering, cgal_edge_collapse, decimate_pro | +| **Fill Holes** | trimesh, pymeshlab, pymeshfix, igl_fan, cgal, gpu_cumesh | +| **Smooth Mesh** | taubin, laplacian, hc_laplacian, trimesh_laplacian, trimesh_taubin | +| **Sharpen Mesh** | two_step, unsharp_mask, libigl_unsharp, l0_minimize, guided_normal, vsa_snap, fast_effective, non_iterative | +| **Fix Normals** | trimesh, igl_bfs, igl_winding, igl_raycast, igl_signed_dist | +| **Reconstruct Surface** | poisson, ball_pivoting, alpha_shape, convex_hull, delaunay_2d, alpha_wrap (CGAL) | +| **UV Unwrap** | xatlas, cumesh (GPU), libigl_lscm, libigl_harmonic, libigl_arap, blender_smart, blender_cube, blender_cylinder, blender_sphere | +| **Extract Skeleton** | wavefront, vertex_clusters, edge_collapse, teasar | +| **Boolean** | libigl_cgal, blender_exact | + ## Credits Built on [trimesh](https://trimesh.org/), [libigl](https://libigl.github.io/), [PyMeshLab](https://pymeshlab.readthedocs.io/), and [CGAL](https://www.cgal.org/). diff --git a/nodes/blender/__init__.py b/nodes/blender/__init__.py index 46caabf4..5925baf9 100644 --- a/nodes/blender/__init__.py +++ b/nodes/blender/__init__.py @@ -3,6 +3,7 @@ from . import blender_io from . import boolean from . import remeshing from . import texture_remeshing +from . import uv # Collect all node class mappings NODE_CLASS_MAPPINGS = {} @@ -10,6 +11,7 @@ NODE_CLASS_MAPPINGS.update(blender_io.NODE_CLASS_MAPPINGS) NODE_CLASS_MAPPINGS.update(boolean.NODE_CLASS_MAPPINGS) NODE_CLASS_MAPPINGS.update(remeshing.NODE_CLASS_MAPPINGS) NODE_CLASS_MAPPINGS.update(texture_remeshing.NODE_CLASS_MAPPINGS) +NODE_CLASS_MAPPINGS.update(uv.NODE_CLASS_MAPPINGS) # Collect all display name mappings NODE_DISPLAY_NAME_MAPPINGS = {} @@ -17,5 +19,6 @@ NODE_DISPLAY_NAME_MAPPINGS.update(blender_io.NODE_DISPLAY_NAME_MAPPINGS) NODE_DISPLAY_NAME_MAPPINGS.update(boolean.NODE_DISPLAY_NAME_MAPPINGS) NODE_DISPLAY_NAME_MAPPINGS.update(remeshing.NODE_DISPLAY_NAME_MAPPINGS) NODE_DISPLAY_NAME_MAPPINGS.update(texture_remeshing.NODE_DISPLAY_NAME_MAPPINGS) +NODE_DISPLAY_NAME_MAPPINGS.update(uv.NODE_DISPLAY_NAME_MAPPINGS) __all__ = ['NODE_CLASS_MAPPINGS', 'NODE_DISPLAY_NAME_MAPPINGS'] diff --git a/nodes/blender/boolean/__init__.py b/nodes/blender/boolean/__init__.py index 635830ae..2f3f2b6e 100644 --- a/nodes/blender/boolean/__init__.py +++ b/nodes/blender/boolean/__init__.py @@ -2,7 +2,7 @@ # Copyright (C) 2025 ComfyUI-GeometryPack Contributors """ -Blender boolean nodes +Blender boolean backend nodes """ from .boolean import NODE_CLASS_MAPPINGS as BOOLEAN_MAPS, NODE_DISPLAY_NAME_MAPPINGS as BOOLEAN_DISP diff --git a/nodes/blender/boolean/boolean.py b/nodes/blender/boolean/boolean.py index 2cf47de2..70f6ef09 100644 --- a/nodes/blender/boolean/boolean.py +++ b/nodes/blender/boolean/boolean.py @@ -1,10 +1,7 @@ # SPDX-License-Identifier: GPL-3.0-or-later # Copyright (C) 2025 ComfyUI-GeometryPack Contributors -""" -Boolean Blender Node - CSG operations using bpy -Requires bpy (Blender Python module). -""" +"""Blender EXACT solver boolean operations backend node.""" import logging @@ -60,26 +57,16 @@ def _bpy_boolean_operation(vertices_a, faces_a, vertices_b, faces_b, operation): return {'vertices': result_vertices, 'faces': result_faces} -class BooleanBlenderNode(io.ComfyNode): - """ - Boolean Blender - Union, Difference, and Intersection using Blender's EXACT solver. - - Performs Constructive Solid Geometry (CSG) operations: - - union: Combine two meshes into one - - difference: Subtract mesh_b from mesh_a - - intersection: Keep only overlapping parts - - Uses Blender's bpy module with the EXACT boolean solver. - For CGAL-based booleans, use "Boolean CGAL" node. - """ - +class BooleanBlenderExactNode(io.ComfyNode): + """Blender EXACT solver boolean operations backend.""" @classmethod def define_schema(cls): return io.Schema( - node_id="GeomPackBooleanBlender", - display_name="Boolean Blender", + node_id="GeomPackBoolean_BlenderExact", + display_name="Boolean Blender Exact (backend)", category="geompack/boolean", + is_dev_only=True, is_output_node=True, inputs=[ io.Custom("TRIMESH").Input("mesh_a"), @@ -93,60 +80,46 @@ class BooleanBlenderNode(io.ComfyNode): ) @classmethod - def execute(cls, mesh_a, mesh_b, operation): - """ - Perform boolean operation on two meshes using Blender. - - Args: - mesh_a: First mesh (base mesh for difference) - mesh_b: Second mesh (subtracted mesh for difference) - operation: Boolean operation type - - Returns: - tuple: (result_mesh, info_string) - """ + def execute(cls, mesh_a, mesh_b, operation="union"): + log.info("Backend: blender_exact") log.info("Mesh A: %d vertices, %d faces", len(mesh_a.vertices), len(mesh_a.faces)) log.info("Mesh B: %d vertices, %d faces", len(mesh_b.vertices), len(mesh_b.faces)) log.info("Operation: %s", operation) - try: - log.info("Using Blender bpy backend...") + # Map operation to Blender modifier type + blender_op = { + "union": "UNION", + "difference": "DIFFERENCE", + "intersection": "INTERSECT" + }[operation] - # Map operation to Blender modifier type - blender_op = { - "union": "UNION", - "difference": "DIFFERENCE", - "intersection": "INTERSECT" - }[operation] + result_data = _bpy_boolean_operation( + vertices_a=np.asarray(mesh_a.vertices, dtype=np.float32), + faces_a=np.asarray(mesh_a.faces, dtype=np.int32), + vertices_b=np.asarray(mesh_b.vertices, dtype=np.float32), + faces_b=np.asarray(mesh_b.faces, dtype=np.int32), + operation=blender_op + ) - result_data = _bpy_boolean_operation( - vertices_a=np.asarray(mesh_a.vertices, dtype=np.float32), - faces_a=np.asarray(mesh_a.faces, dtype=np.int32), - vertices_b=np.asarray(mesh_b.vertices, dtype=np.float32), - faces_b=np.asarray(mesh_b.faces, dtype=np.int32), - operation=blender_op - ) + result = trimesh_module.Trimesh( + vertices=np.array(result_data['vertices'], dtype=np.float32), + faces=np.array(result_data['faces'], dtype=np.int32), + process=False + ) - result = trimesh_module.Trimesh( - vertices=np.array(result_data['vertices'], dtype=np.float32), - faces=np.array(result_data['faces'], dtype=np.int32), - process=False - ) + result.metadata = mesh_a.metadata.copy() + result.metadata['boolean'] = { + 'operation': operation, + 'engine': 'blender_bpy', + 'mesh_a_vertices': len(mesh_a.vertices), + 'mesh_a_faces': len(mesh_a.faces), + 'mesh_b_vertices': len(mesh_b.vertices), + 'mesh_b_faces': len(mesh_b.faces), + 'result_vertices': len(result.vertices), + 'result_faces': len(result.faces) + } - # Preserve metadata - result.metadata = mesh_a.metadata.copy() - result.metadata['boolean'] = { - 'operation': operation, - 'engine': 'blender_bpy', - 'mesh_a_vertices': len(mesh_a.vertices), - 'mesh_a_faces': len(mesh_a.faces), - 'mesh_b_vertices': len(mesh_b.vertices), - 'mesh_b_faces': len(mesh_b.faces), - 'result_vertices': len(result.vertices), - 'result_faces': len(result.faces) - } - - info = f"""Boolean Operation Results: + info = f"""Boolean Operation Results: Operation: {operation.upper()} Engine: Blender bpy (EXACT solver) @@ -166,17 +139,9 @@ Result: Watertight: {result.is_watertight} """ - log.info("Success: %d vertices, %d faces", len(result.vertices), len(result.faces)) - return io.NodeOutput(result, info, ui={"text": [info]}) - - except Exception as e: - raise RuntimeError(f"Boolean Blender operation failed: {e}") + log.info("Success: %d vertices, %d faces", len(result.vertices), len(result.faces)) + return io.NodeOutput(result, info, ui={"text": [info]}) -NODE_CLASS_MAPPINGS = { - "GeomPackBooleanBlender": BooleanBlenderNode, -} - -NODE_DISPLAY_NAME_MAPPINGS = { - "GeomPackBooleanBlender": "Boolean Blender", -} +NODE_CLASS_MAPPINGS = {"GeomPackBoolean_BlenderExact": BooleanBlenderExactNode} +NODE_DISPLAY_NAME_MAPPINGS = {"GeomPackBoolean_BlenderExact": "Boolean Blender Exact (backend)"} diff --git a/nodes/blender/uv/__init__.py b/nodes/blender/uv/__init__.py new file mode 100644 index 00000000..9b613b93 --- /dev/null +++ b/nodes/blender/uv/__init__.py @@ -0,0 +1,16 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2025 ComfyUI-GeometryPack Contributors + +""" +Blender UV unwrap nodes +""" + +from .backends import NODE_CLASS_MAPPINGS as BACKENDS_MAPS, NODE_DISPLAY_NAME_MAPPINGS as BACKENDS_DISP + +NODE_CLASS_MAPPINGS = {} +NODE_DISPLAY_NAME_MAPPINGS = {} + +NODE_CLASS_MAPPINGS.update(BACKENDS_MAPS) +NODE_DISPLAY_NAME_MAPPINGS.update(BACKENDS_DISP) + +__all__ = ['NODE_CLASS_MAPPINGS', 'NODE_DISPLAY_NAME_MAPPINGS'] diff --git a/nodes/blender/uv/backends/__init__.py b/nodes/blender/uv/backends/__init__.py new file mode 100644 index 00000000..131a8e76 --- /dev/null +++ b/nodes/blender/uv/backends/__init__.py @@ -0,0 +1,24 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2025 ComfyUI-GeometryPack Contributors + +"""Individual Blender UV unwrap backend nodes (hidden, invoked via unified UV Unwrap node).""" + +from .blender_smart import NODE_CLASS_MAPPINGS as SMART_MAPS, NODE_DISPLAY_NAME_MAPPINGS as SMART_DISP +from .blender_cube import NODE_CLASS_MAPPINGS as CUBE_MAPS, NODE_DISPLAY_NAME_MAPPINGS as CUBE_DISP +from .blender_cylinder import NODE_CLASS_MAPPINGS as CYL_MAPS, NODE_DISPLAY_NAME_MAPPINGS as CYL_DISP +from .blender_sphere import NODE_CLASS_MAPPINGS as SPH_MAPS, NODE_DISPLAY_NAME_MAPPINGS as SPH_DISP + +NODE_CLASS_MAPPINGS = {} +NODE_DISPLAY_NAME_MAPPINGS = {} + +NODE_CLASS_MAPPINGS.update(SMART_MAPS) +NODE_CLASS_MAPPINGS.update(CUBE_MAPS) +NODE_CLASS_MAPPINGS.update(CYL_MAPS) +NODE_CLASS_MAPPINGS.update(SPH_MAPS) + +NODE_DISPLAY_NAME_MAPPINGS.update(SMART_DISP) +NODE_DISPLAY_NAME_MAPPINGS.update(CUBE_DISP) +NODE_DISPLAY_NAME_MAPPINGS.update(CYL_DISP) +NODE_DISPLAY_NAME_MAPPINGS.update(SPH_DISP) + +__all__ = ['NODE_CLASS_MAPPINGS', 'NODE_DISPLAY_NAME_MAPPINGS'] diff --git a/nodes/blender/uv/backends/_helpers.py b/nodes/blender/uv/backends/_helpers.py new file mode 100644 index 00000000..8d4750d8 --- /dev/null +++ b/nodes/blender/uv/backends/_helpers.py @@ -0,0 +1,34 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2025 ComfyUI-GeometryPack Contributors + +"""Shared helpers for Blender UV unwrapping backends.""" + +import numpy as np + + +def _extract_uvs_from_blender_mesh(mesh, vertices_np): + """Extract UVs from a blender mesh, handling vertex splitting at seams.""" + uv_layer = mesh.uv_layers.active + if uv_layer: + loop_uvs = np.array([uv_layer.data[i].uv[:] for i in range(len(uv_layer.data))], dtype=np.float32) + new_vertices = [] + new_faces = [] + uvs_per_loop = [] + vertex_map = {} + + for poly in mesh.polygons: + new_face = [] + for loop_idx in poly.loop_indices: + orig_vert_idx = mesh.loops[loop_idx].vertex_index + uv = tuple(loop_uvs[loop_idx]) + key = (orig_vert_idx, uv) + if key not in vertex_map: + vertex_map[key] = len(new_vertices) + new_vertices.append(vertices_np[orig_vert_idx].tolist()) + uvs_per_loop.append(list(uv)) + new_face.append(vertex_map[key]) + new_faces.append(new_face) + + return new_vertices, new_faces, uvs_per_loop + else: + return vertices_np.tolist(), [list(p.vertices) for p in mesh.polygons], [[0.0, 0.0]] * len(vertices_np) diff --git a/nodes/blender/uv/backends/blender_cube.py b/nodes/blender/uv/backends/blender_cube.py new file mode 100644 index 00000000..9ff645f0 --- /dev/null +++ b/nodes/blender/uv/backends/blender_cube.py @@ -0,0 +1,112 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2025 ComfyUI-GeometryPack Contributors + +"""Blender Cube UV projection backend node.""" + +import logging + +import numpy as np +import trimesh as trimesh_module +from comfy_api.latest import io + +from ._helpers import _extract_uvs_from_blender_mesh + +log = logging.getLogger("geometrypack") + + +class UVBlenderCubeNode(io.ComfyNode): + """Blender Cube UV projection backend.""" + + @classmethod + def define_schema(cls): + return io.Schema( + node_id="GeomPackUV_BlenderCube", + display_name="UV Blender Cube (backend)", + category="geompack/uv", + is_dev_only=True, + is_output_node=True, + inputs=[ + io.Custom("TRIMESH").Input("trimesh"), + io.Float.Input("cube_size", default=1.0, min=0.1, max=10.0, step=0.1), + io.Combo.Input("scale_to_bounds", options=["true", "false"], default="true"), + ], + outputs=[ + io.Custom("TRIMESH").Output(display_name="unwrapped_mesh"), + io.String.Output(display_name="info"), + ], + ) + + @classmethod + def execute(cls, trimesh, cube_size=1.0, scale_to_bounds="true"): + """Blender Cube UV Project using bpy.""" + import bpy + import bmesh + + log.info("Backend: blender_cube") + log.info("Input: %d vertices, %d faces", len(trimesh.vertices), len(trimesh.faces)) + log.info("Parameters: cube_size=%s, scale_to_bounds=%s", cube_size, scale_to_bounds) + + vertices = np.asarray(trimesh.vertices, dtype=np.float32) + faces = np.asarray(trimesh.faces, dtype=np.int32) + + mesh = bpy.data.meshes.new("UVMesh") + obj = bpy.data.objects.new("UVObject", mesh) + bpy.context.collection.objects.link(obj) + bpy.context.view_layer.objects.active = obj + obj.select_set(True) + + mesh.from_pydata(vertices.tolist(), [], faces.tolist()) + mesh.update() + + bpy.ops.object.mode_set(mode='EDIT') + bm = bmesh.from_edit_mesh(mesh) + for face in bm.faces: + face.select = True + bmesh.update_edit_mesh(mesh) + + bpy.ops.uv.cube_project(cube_size=cube_size, scale_to_bounds=(scale_to_bounds == 'true')) + + bpy.ops.object.mode_set(mode='OBJECT') + + result_vertices = np.array([v.co[:] for v in mesh.vertices], dtype=np.float32) + result_verts, result_faces, result_uvs = _extract_uvs_from_blender_mesh(mesh, result_vertices) + + bpy.data.objects.remove(obj, do_unlink=True) + bpy.data.meshes.remove(mesh) + + unwrapped = trimesh_module.Trimesh( + vertices=np.array(result_verts, dtype=np.float32), + faces=np.array(result_faces, dtype=np.int32), + process=False + ) + + from trimesh.visual import TextureVisuals + unwrapped.visual = TextureVisuals(uv=np.array(result_uvs, dtype=np.float32)) + + # Preserve metadata + unwrapped.metadata = trimesh.metadata.copy() + unwrapped.metadata['uv_unwrap'] = { + 'algorithm': 'blender_cube_projection', + 'cube_size': cube_size, + 'scale_to_bounds': scale_to_bounds == 'true' + } + + log.info("Output: %d vertices, %d faces", len(unwrapped.vertices), len(unwrapped.faces)) + + info = f"""UV Unwrap Results (Blender Cube): + +Method: Cube Projection (bpy) +Cube Size: {cube_size} +Scale to Bounds: {scale_to_bounds} + +Vertices: {len(unwrapped.vertices):,} +Faces: {len(unwrapped.faces):,} + +Projects mesh onto 6 cube faces. +Best for box-like objects. +""" + return io.NodeOutput(unwrapped, info, ui={"text": [info]}) + + +NODE_CLASS_MAPPINGS = {"GeomPackUV_BlenderCube": UVBlenderCubeNode} +NODE_DISPLAY_NAME_MAPPINGS = {"GeomPackUV_BlenderCube": "UV Blender Cube (backend)"} diff --git a/nodes/blender/uv/backends/blender_cylinder.py b/nodes/blender/uv/backends/blender_cylinder.py new file mode 100644 index 00000000..92f15aec --- /dev/null +++ b/nodes/blender/uv/backends/blender_cylinder.py @@ -0,0 +1,113 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2025 ComfyUI-GeometryPack Contributors + +"""Blender Cylinder UV projection backend node.""" + +import logging + +import numpy as np +import trimesh as trimesh_module +from comfy_api.latest import io + +from ._helpers import _extract_uvs_from_blender_mesh + +log = logging.getLogger("geometrypack") + + +class UVBlenderCylinderNode(io.ComfyNode): + """Blender Cylinder UV projection backend.""" + + @classmethod + def define_schema(cls): + return io.Schema( + node_id="GeomPackUV_BlenderCylinder", + display_name="UV Blender Cylinder (backend)", + category="geompack/uv", + is_dev_only=True, + is_output_node=True, + inputs=[ + io.Custom("TRIMESH").Input("trimesh"), + io.Float.Input("cylinder_radius", default=1.0, min=0.1, max=10.0, step=0.1), + io.Combo.Input("scale_to_bounds", options=["true", "false"], default="true"), + ], + outputs=[ + io.Custom("TRIMESH").Output(display_name="unwrapped_mesh"), + io.String.Output(display_name="info"), + ], + ) + + @classmethod + def execute(cls, trimesh, cylinder_radius=1.0, scale_to_bounds="true"): + """Blender Cylinder UV Project using bpy.""" + import bpy + import bmesh + + log.info("Backend: blender_cylinder") + log.info("Input: %d vertices, %d faces", len(trimesh.vertices), len(trimesh.faces)) + log.info("Parameters: cylinder_radius=%s, scale_to_bounds=%s", cylinder_radius, scale_to_bounds) + + vertices = np.asarray(trimesh.vertices, dtype=np.float32) + faces = np.asarray(trimesh.faces, dtype=np.int32) + + mesh = bpy.data.meshes.new("UVMesh") + obj = bpy.data.objects.new("UVObject", mesh) + bpy.context.collection.objects.link(obj) + bpy.context.view_layer.objects.active = obj + obj.select_set(True) + + mesh.from_pydata(vertices.tolist(), [], faces.tolist()) + mesh.update() + + bpy.ops.object.mode_set(mode='EDIT') + bm = bmesh.from_edit_mesh(mesh) + for face in bm.faces: + face.select = True + bmesh.update_edit_mesh(mesh) + + # Frontend uses "cylinder_radius", bpy.ops uses "radius" + bpy.ops.uv.cylinder_project(radius=cylinder_radius, scale_to_bounds=(scale_to_bounds == 'true')) + + bpy.ops.object.mode_set(mode='OBJECT') + + result_vertices = np.array([v.co[:] for v in mesh.vertices], dtype=np.float32) + result_verts, result_faces, result_uvs = _extract_uvs_from_blender_mesh(mesh, result_vertices) + + bpy.data.objects.remove(obj, do_unlink=True) + bpy.data.meshes.remove(mesh) + + unwrapped = trimesh_module.Trimesh( + vertices=np.array(result_verts, dtype=np.float32), + faces=np.array(result_faces, dtype=np.int32), + process=False + ) + + from trimesh.visual import TextureVisuals + unwrapped.visual = TextureVisuals(uv=np.array(result_uvs, dtype=np.float32)) + + # Preserve metadata + unwrapped.metadata = trimesh.metadata.copy() + unwrapped.metadata['uv_unwrap'] = { + 'algorithm': 'blender_cylinder_projection', + 'cylinder_radius': cylinder_radius, + 'scale_to_bounds': scale_to_bounds == 'true' + } + + log.info("Output: %d vertices, %d faces", len(unwrapped.vertices), len(unwrapped.faces)) + + info = f"""UV Unwrap Results (Blender Cylinder): + +Method: Cylinder Projection (bpy) +Cylinder Radius: {cylinder_radius} +Scale to Bounds: {scale_to_bounds} + +Vertices: {len(unwrapped.vertices):,} +Faces: {len(unwrapped.faces):,} + +Cylindrical projection around vertical axis. +Best for cylindrical objects. +""" + return io.NodeOutput(unwrapped, info, ui={"text": [info]}) + + +NODE_CLASS_MAPPINGS = {"GeomPackUV_BlenderCylinder": UVBlenderCylinderNode} +NODE_DISPLAY_NAME_MAPPINGS = {"GeomPackUV_BlenderCylinder": "UV Blender Cylinder (backend)"} diff --git a/nodes/blender/uv/backends/blender_smart.py b/nodes/blender/uv/backends/blender_smart.py new file mode 100644 index 00000000..937cb782 --- /dev/null +++ b/nodes/blender/uv/backends/blender_smart.py @@ -0,0 +1,129 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2025 ComfyUI-GeometryPack Contributors + +"""Blender Smart UV Project backend node.""" + +import logging +import math + +import numpy as np +import trimesh as trimesh_module +from comfy_api.latest import io + +from ._helpers import _extract_uvs_from_blender_mesh + +log = logging.getLogger("geometrypack") + + +class UVBlenderSmartNode(io.ComfyNode): + """Blender Smart UV Project backend.""" + + @classmethod + def define_schema(cls): + return io.Schema( + node_id="GeomPackUV_BlenderSmart", + display_name="UV Blender Smart (backend)", + category="geompack/uv", + is_dev_only=True, + is_output_node=True, + inputs=[ + io.Custom("TRIMESH").Input("trimesh"), + io.Float.Input("angle_limit", default=66.0, min=1.0, max=89.0, step=1.0), + io.Float.Input("island_margin", default=0.02, min=0.0, max=1.0, step=0.01), + io.Combo.Input("scale_to_bounds", options=["true", "false"], default="true"), + ], + outputs=[ + io.Custom("TRIMESH").Output(display_name="unwrapped_mesh"), + io.String.Output(display_name="info"), + ], + ) + + @classmethod + def execute(cls, trimesh, angle_limit=66.0, island_margin=0.02, scale_to_bounds="true"): + """Blender Smart UV Project using bpy.""" + import bpy + import bmesh + + log.info("Backend: blender_smart") + log.info("Input: %d vertices, %d faces", len(trimesh.vertices), len(trimesh.faces)) + log.info("Parameters: angle_limit=%s, island_margin=%s, scale_to_bounds=%s", + angle_limit, island_margin, scale_to_bounds) + + angle_limit_rad = math.radians(angle_limit) + + vertices = np.asarray(trimesh.vertices, dtype=np.float32) + faces = np.asarray(trimesh.faces, dtype=np.int32) + + mesh = bpy.data.meshes.new("UVMesh") + obj = bpy.data.objects.new("UVObject", mesh) + bpy.context.collection.objects.link(obj) + bpy.context.view_layer.objects.active = obj + obj.select_set(True) + + mesh.from_pydata(vertices.tolist(), [], faces.tolist()) + mesh.update() + + bpy.ops.object.mode_set(mode='EDIT') + bm = bmesh.from_edit_mesh(mesh) + for face in bm.faces: + face.select = True + bmesh.update_edit_mesh(mesh) + + bpy.ops.uv.smart_project( + angle_limit=angle_limit_rad, + island_margin=island_margin, + area_weight=0.0, + correct_aspect=True, + scale_to_bounds=(scale_to_bounds == 'true') + ) + + bpy.ops.object.mode_set(mode='OBJECT') + + result_vertices = np.array([v.co[:] for v in mesh.vertices], dtype=np.float32) + result_verts, result_faces, result_uvs = _extract_uvs_from_blender_mesh(mesh, result_vertices) + + bpy.data.objects.remove(obj, do_unlink=True) + bpy.data.meshes.remove(mesh) + + unwrapped = trimesh_module.Trimesh( + vertices=np.array(result_verts, dtype=np.float32), + faces=np.array(result_faces, dtype=np.int32), + process=False + ) + + from trimesh.visual import TextureVisuals + unwrapped.visual = TextureVisuals(uv=np.array(result_uvs, dtype=np.float32)) + + # Preserve metadata + unwrapped.metadata = trimesh.metadata.copy() + unwrapped.metadata['uv_unwrap'] = { + 'algorithm': 'blender_smart_uv_project', + 'angle_limit': angle_limit, + 'island_margin': island_margin, + 'scale_to_bounds': scale_to_bounds == 'true' + } + + log.info("Output: %d vertices, %d faces", len(unwrapped.vertices), len(unwrapped.faces)) + + info = f"""UV Unwrap Results (Blender Smart UV): + +Method: Smart UV Project (bpy) +Angle Limit: {angle_limit}deg +Island Margin: {island_margin} +Scale to Bounds: {scale_to_bounds} + +Before: + Vertices: {len(trimesh.vertices):,} + Faces: {len(trimesh.faces):,} + +After: + Vertices: {len(unwrapped.vertices):,} + Faces: {len(unwrapped.faces):,} + +Automatic seam-based unwrapping with intelligent island creation. +""" + return io.NodeOutput(unwrapped, info, ui={"text": [info]}) + + +NODE_CLASS_MAPPINGS = {"GeomPackUV_BlenderSmart": UVBlenderSmartNode} +NODE_DISPLAY_NAME_MAPPINGS = {"GeomPackUV_BlenderSmart": "UV Blender Smart (backend)"} diff --git a/nodes/blender/uv/backends/blender_sphere.py b/nodes/blender/uv/backends/blender_sphere.py new file mode 100644 index 00000000..0e33382e --- /dev/null +++ b/nodes/blender/uv/backends/blender_sphere.py @@ -0,0 +1,109 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2025 ComfyUI-GeometryPack Contributors + +"""Blender Sphere UV projection backend node.""" + +import logging + +import numpy as np +import trimesh as trimesh_module +from comfy_api.latest import io + +from ._helpers import _extract_uvs_from_blender_mesh + +log = logging.getLogger("geometrypack") + + +class UVBlenderSphereNode(io.ComfyNode): + """Blender Sphere UV projection backend.""" + + @classmethod + def define_schema(cls): + return io.Schema( + node_id="GeomPackUV_BlenderSphere", + display_name="UV Blender Sphere (backend)", + category="geompack/uv", + is_dev_only=True, + is_output_node=True, + inputs=[ + io.Custom("TRIMESH").Input("trimesh"), + io.Combo.Input("scale_to_bounds", options=["true", "false"], default="true"), + ], + outputs=[ + io.Custom("TRIMESH").Output(display_name="unwrapped_mesh"), + io.String.Output(display_name="info"), + ], + ) + + @classmethod + def execute(cls, trimesh, scale_to_bounds="true"): + """Blender Sphere UV Project using bpy.""" + import bpy + import bmesh + + log.info("Backend: blender_sphere") + log.info("Input: %d vertices, %d faces", len(trimesh.vertices), len(trimesh.faces)) + log.info("Parameters: scale_to_bounds=%s", scale_to_bounds) + + vertices = np.asarray(trimesh.vertices, dtype=np.float32) + faces = np.asarray(trimesh.faces, dtype=np.int32) + + mesh = bpy.data.meshes.new("UVMesh") + obj = bpy.data.objects.new("UVObject", mesh) + bpy.context.collection.objects.link(obj) + bpy.context.view_layer.objects.active = obj + obj.select_set(True) + + mesh.from_pydata(vertices.tolist(), [], faces.tolist()) + mesh.update() + + bpy.ops.object.mode_set(mode='EDIT') + bm = bmesh.from_edit_mesh(mesh) + for face in bm.faces: + face.select = True + bmesh.update_edit_mesh(mesh) + + bpy.ops.uv.sphere_project(scale_to_bounds=(scale_to_bounds == 'true')) + + bpy.ops.object.mode_set(mode='OBJECT') + + result_vertices = np.array([v.co[:] for v in mesh.vertices], dtype=np.float32) + result_verts, result_faces, result_uvs = _extract_uvs_from_blender_mesh(mesh, result_vertices) + + bpy.data.objects.remove(obj, do_unlink=True) + bpy.data.meshes.remove(mesh) + + unwrapped = trimesh_module.Trimesh( + vertices=np.array(result_verts, dtype=np.float32), + faces=np.array(result_faces, dtype=np.int32), + process=False + ) + + from trimesh.visual import TextureVisuals + unwrapped.visual = TextureVisuals(uv=np.array(result_uvs, dtype=np.float32)) + + # Preserve metadata + unwrapped.metadata = trimesh.metadata.copy() + unwrapped.metadata['uv_unwrap'] = { + 'algorithm': 'blender_sphere_projection', + 'scale_to_bounds': scale_to_bounds == 'true' + } + + log.info("Output: %d vertices, %d faces", len(unwrapped.vertices), len(unwrapped.faces)) + + info = f"""UV Unwrap Results (Blender Sphere): + +Method: Sphere Projection (bpy) +Scale to Bounds: {scale_to_bounds} + +Vertices: {len(unwrapped.vertices):,} +Faces: {len(unwrapped.faces):,} + +Spherical/equirectangular projection. +Best for spherical objects. +""" + return io.NodeOutput(unwrapped, info, ui={"text": [info]}) + + +NODE_CLASS_MAPPINGS = {"GeomPackUV_BlenderSphere": UVBlenderSphereNode} +NODE_DISPLAY_NAME_MAPPINGS = {"GeomPackUV_BlenderSphere": "UV Blender Sphere (backend)"} diff --git a/nodes/gpu/__init__.py b/nodes/gpu/__init__.py index c820eb29..b8be1a0a 100644 --- a/nodes/gpu/__init__.py +++ b/nodes/gpu/__init__.py @@ -1,5 +1,6 @@ from . import remeshing_gpu from . import fill_holes_gpu +from . import uv_gpu NODE_CLASS_MAPPINGS = {} NODE_DISPLAY_NAME_MAPPINGS = {} @@ -7,5 +8,7 @@ NODE_CLASS_MAPPINGS.update(remeshing_gpu.NODE_CLASS_MAPPINGS) NODE_DISPLAY_NAME_MAPPINGS.update(remeshing_gpu.NODE_DISPLAY_NAME_MAPPINGS) NODE_CLASS_MAPPINGS.update(fill_holes_gpu.NODE_CLASS_MAPPINGS) NODE_DISPLAY_NAME_MAPPINGS.update(fill_holes_gpu.NODE_DISPLAY_NAME_MAPPINGS) +NODE_CLASS_MAPPINGS.update(uv_gpu.NODE_CLASS_MAPPINGS) +NODE_DISPLAY_NAME_MAPPINGS.update(uv_gpu.NODE_DISPLAY_NAME_MAPPINGS) __all__ = ['NODE_CLASS_MAPPINGS', 'NODE_DISPLAY_NAME_MAPPINGS'] diff --git a/nodes/gpu/uv_gpu/__init__.py b/nodes/gpu/uv_gpu/__init__.py new file mode 100644 index 00000000..a45ace41 --- /dev/null +++ b/nodes/gpu/uv_gpu/__init__.py @@ -0,0 +1,14 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2025 ComfyUI-GeometryPack Contributors + +"""GPU UV unwrapping backend nodes.""" + +from .cumesh_uv import NODE_CLASS_MAPPINGS as CUMESH_MAPS, NODE_DISPLAY_NAME_MAPPINGS as CUMESH_DISP + +NODE_CLASS_MAPPINGS = {} +NODE_DISPLAY_NAME_MAPPINGS = {} + +NODE_CLASS_MAPPINGS.update(CUMESH_MAPS) +NODE_DISPLAY_NAME_MAPPINGS.update(CUMESH_DISP) + +__all__ = ['NODE_CLASS_MAPPINGS', 'NODE_DISPLAY_NAME_MAPPINGS'] diff --git a/nodes/gpu/uv_gpu/cumesh_uv.py b/nodes/gpu/uv_gpu/cumesh_uv.py new file mode 100644 index 00000000..3cac1164 --- /dev/null +++ b/nodes/gpu/uv_gpu/cumesh_uv.py @@ -0,0 +1,149 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2025 ComfyUI-GeometryPack Contributors + +"""CuMesh GPU-accelerated UV unwrapping backend node.""" + +import logging + +import numpy as np +import trimesh as trimesh_module +from comfy_api.latest import io + +log = logging.getLogger("geometrypack") + + +class UVCuMeshNode(io.ComfyNode): + """CuMesh GPU-accelerated UV unwrapping backend.""" + + @classmethod + def define_schema(cls): + return io.Schema( + node_id="GeomPackUV_CuMesh", + display_name="UV CuMesh (backend)", + category="geompack/uv", + is_dev_only=True, + is_output_node=True, + inputs=[ + io.Custom("TRIMESH").Input("trimesh"), + io.Float.Input("chart_cone_angle", default=90.0, min=0.0, max=359.9, step=1.0), + io.Int.Input("chart_refine_iterations", default=0, min=0, max=10, step=1), + io.Int.Input("chart_global_iterations", default=1, min=0, max=10, step=1), + io.Int.Input("chart_smooth_strength", default=1, min=0, max=10, step=1), + ], + outputs=[ + io.Custom("TRIMESH").Output(display_name="unwrapped_mesh"), + io.String.Output(display_name="info"), + ], + ) + + @classmethod + def execute(cls, trimesh, chart_cone_angle=90.0, chart_refine_iterations=0, + chart_global_iterations=1, chart_smooth_strength=1): + """CuMesh GPU-accelerated UV unwrapping with fast clustering + xatlas.""" + try: + import torch + import cumesh as CuMesh + except ImportError as e: + raise ImportError( + f"cumesh not installed or CUDA not available: {e}\n" + "CuMesh requires CUDA and PyTorch. Install with:\n" + " pip install cumesh (or run install.py)\n" + "Alternatively, use 'xatlas' method which works on CPU." + ) + + import comfy.model_management + device = comfy.model_management.get_torch_device() + assert device.type == "cuda", f"CuMesh requires CUDA but got device '{device}' — cumesh is GPU-only, no CPU fallback" + + log.info("Backend: cumesh") + log.info("Input: %d vertices, %d faces", len(trimesh.vertices), len(trimesh.faces)) + log.info("CuMesh: chart_cone_angle=%s, refine_iter=%s, global_iter=%s, smooth=%s", + chart_cone_angle, chart_refine_iterations, chart_global_iterations, chart_smooth_strength) + + # Convert to torch tensors on GPU + vertices = torch.tensor(trimesh.vertices, dtype=torch.float32).to(device) + faces = torch.tensor(trimesh.faces, dtype=torch.int32).to(device) + + # Convert cone angle to radians + chart_cone_angle_rad = np.radians(chart_cone_angle) + + # Initialize CuMesh + cumesh = CuMesh.CuMesh() + cumesh.init(vertices, faces) + + # UV Unwrap with two-stage process (fast clustering + xatlas) + log.info("Running CuMesh UV unwrap...") + out_vertices, out_faces, out_uvs, out_vmaps = cumesh.uv_unwrap( + compute_charts_kwargs={ + "threshold_cone_half_angle_rad": chart_cone_angle_rad, + "refine_iterations": chart_refine_iterations, + "global_iterations": chart_global_iterations, + "smooth_strength": chart_smooth_strength, + }, + return_vmaps=True, + verbose=True, + ) + + # Convert back to numpy + out_vertices_np = out_vertices.cpu().numpy() + out_faces_np = out_faces.cpu().numpy() + out_uvs_np = out_uvs.cpu().numpy() + + # Flip V coordinate (cumesh uses different UV convention) + out_uvs_np[:, 1] = 1 - out_uvs_np[:, 1] + + # Build result trimesh + unwrapped = trimesh_module.Trimesh( + vertices=out_vertices_np, + faces=out_faces_np, + process=False + ) + + from trimesh.visual import TextureVisuals + unwrapped.visual = TextureVisuals(uv=out_uvs_np) + + # Preserve metadata + unwrapped.metadata = trimesh.metadata.copy() + unwrapped.metadata['uv_unwrap'] = { + 'algorithm': 'cumesh', + 'chart_cone_angle': chart_cone_angle, + 'chart_refine_iterations': chart_refine_iterations, + 'chart_global_iterations': chart_global_iterations, + 'chart_smooth_strength': chart_smooth_strength, + 'original_vertices': len(trimesh.vertices), + 'unwrapped_vertices': len(out_vertices_np), + 'vertex_duplication_ratio': len(out_vertices_np) / len(trimesh.vertices) + } + + # Clean up GPU memory + comfy.model_management.soft_empty_cache() + + log.info("Output: %d vertices, %d faces", len(unwrapped.vertices), len(unwrapped.faces)) + + info = f"""UV Unwrap Results (CuMesh): + +Algorithm: CuMesh GPU-accelerated (fast clustering + xatlas) +Optimized for: Large meshes, GPU acceleration + +Parameters: + Chart Cone Angle: {chart_cone_angle}° + Refine Iterations: {chart_refine_iterations} + Global Iterations: {chart_global_iterations} + Smooth Strength: {chart_smooth_strength} + +Before: + Vertices: {len(trimesh.vertices):,} + Faces: {len(trimesh.faces):,} + +After: + Vertices: {len(out_vertices_np):,} + Faces: {len(unwrapped.faces):,} + Vertex Duplication: {len(out_vertices_np)/len(trimesh.vertices):.2f}x + +Two-stage GPU-accelerated UV unwrapping with vertex splitting at seams. +""" + return io.NodeOutput(unwrapped, info, ui={"text": [info]}) + + +NODE_CLASS_MAPPINGS = {"GeomPackUV_CuMesh": UVCuMeshNode} +NODE_DISPLAY_NAME_MAPPINGS = {"GeomPackUV_CuMesh": "UV CuMesh (backend)"} diff --git a/nodes/main/__init__.py b/nodes/main/__init__.py index 35a793f3..e8d4e8bb 100644 --- a/nodes/main/__init__.py +++ b/nodes/main/__init__.py @@ -34,6 +34,7 @@ from . import boolean from . import reconstruction_cgal from . import remeshing_cgal from . import repair_cgal +from . import decimation_cgal # Collect all node class mappings NODE_CLASS_MAPPINGS = {} @@ -58,6 +59,7 @@ 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) # Collect all display name mappings NODE_DISPLAY_NAME_MAPPINGS = {} @@ -82,5 +84,6 @@ 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'] diff --git a/nodes/main/boolean/__init__.py b/nodes/main/boolean/__init__.py index db9545a5..8836b0fd 100644 --- a/nodes/main/boolean/__init__.py +++ b/nodes/main/boolean/__init__.py @@ -2,10 +2,11 @@ # Copyright (C) 2025 ComfyUI-GeometryPack Contributors """ -CGAL boolean nodes +Boolean nodes """ from .boolean import NODE_CLASS_MAPPINGS as BOOLEAN_MAPS, NODE_DISPLAY_NAME_MAPPINGS as BOOLEAN_DISP +from .backends import NODE_CLASS_MAPPINGS as BACKENDS_MAPS, NODE_DISPLAY_NAME_MAPPINGS as BACKENDS_DISP NODE_CLASS_MAPPINGS = {} NODE_DISPLAY_NAME_MAPPINGS = {} @@ -13,4 +14,7 @@ NODE_DISPLAY_NAME_MAPPINGS = {} NODE_CLASS_MAPPINGS.update(BOOLEAN_MAPS) NODE_DISPLAY_NAME_MAPPINGS.update(BOOLEAN_DISP) +NODE_CLASS_MAPPINGS.update(BACKENDS_MAPS) +NODE_DISPLAY_NAME_MAPPINGS.update(BACKENDS_DISP) + __all__ = ['NODE_CLASS_MAPPINGS', 'NODE_DISPLAY_NAME_MAPPINGS'] diff --git a/nodes/main/boolean/backends/__init__.py b/nodes/main/boolean/backends/__init__.py new file mode 100644 index 00000000..61d024cf --- /dev/null +++ b/nodes/main/boolean/backends/__init__.py @@ -0,0 +1,16 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2025 ComfyUI-GeometryPack Contributors + +""" +Individual boolean backend nodes (hidden from UI, invoked via unified Boolean node). +""" + +from .libigl_cgal 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'] diff --git a/nodes/main/boolean/backends/libigl_cgal.py b/nodes/main/boolean/backends/libigl_cgal.py new file mode 100644 index 00000000..4fe5152b --- /dev/null +++ b/nodes/main/boolean/backends/libigl_cgal.py @@ -0,0 +1,92 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2025 ComfyUI-GeometryPack Contributors + +"""libigl+CGAL boolean operations backend node.""" + +import logging + +import numpy as np +import trimesh as trimesh_module +from comfy_api.latest import io + +log = logging.getLogger("geometrypack") + + +class BooleanLibiglCGALNode(io.ComfyNode): + """libigl+CGAL boolean operations backend.""" + + @classmethod + def define_schema(cls): + return io.Schema( + node_id="GeomPackBoolean_LibiglCGAL", + display_name="Boolean libigl CGAL (backend)", + category="geompack/boolean", + is_dev_only=True, + is_output_node=True, + inputs=[ + io.Custom("TRIMESH").Input("mesh_a"), + io.Custom("TRIMESH").Input("mesh_b"), + io.Combo.Input("operation", options=["union", "difference", "intersection"]), + ], + outputs=[ + io.Custom("TRIMESH").Output(display_name="result_mesh"), + io.String.Output(display_name="info"), + ], + ) + + @classmethod + def execute(cls, mesh_a, mesh_b, operation="union"): + log.info("Backend: libigl_cgal") + log.info("Mesh A: %d vertices, %d faces", len(mesh_a.vertices), len(mesh_a.faces)) + log.info("Mesh B: %d vertices, %d faces", len(mesh_b.vertices), len(mesh_b.faces)) + log.info("Operation: %s", operation) + + import igl.copyleft.cgal as cgal + + VA = np.asarray(mesh_a.vertices, dtype=np.float64) + FA = np.asarray(mesh_a.faces, dtype=np.int64) + VB = np.asarray(mesh_b.vertices, dtype=np.float64) + FB = np.asarray(mesh_b.faces, dtype=np.int64) + + VC, FC, J = cgal.mesh_boolean(VA, FA, VB, FB, operation) + + result = trimesh_module.Trimesh(vertices=VC, faces=FC, process=False) + + result.metadata = mesh_a.metadata.copy() + result.metadata['boolean'] = { + 'operation': operation, + 'engine': 'libigl_cgal', + 'mesh_a_vertices': len(mesh_a.vertices), + 'mesh_a_faces': len(mesh_a.faces), + 'mesh_b_vertices': len(mesh_b.vertices), + 'mesh_b_faces': len(mesh_b.faces), + 'result_vertices': len(result.vertices), + 'result_faces': len(result.faces) + } + + info = f"""Boolean Operation Results: + +Operation: {operation.upper()} +Engine: libigl + CGAL + +Mesh A: + Vertices: {len(mesh_a.vertices):,} + Faces: {len(mesh_a.faces):,} + +Mesh B: + Vertices: {len(mesh_b.vertices):,} + Faces: {len(mesh_b.faces):,} + +Result: + Vertices: {len(result.vertices):,} + Faces: {len(result.faces):,} + +Watertight: {result.is_watertight} +""" + + log.info("Success: %d vertices, %d faces", len(result.vertices), len(result.faces)) + return io.NodeOutput(result, info, ui={"text": [info]}) + + +NODE_CLASS_MAPPINGS = {"GeomPackBoolean_LibiglCGAL": BooleanLibiglCGALNode} +NODE_DISPLAY_NAME_MAPPINGS = {"GeomPackBoolean_LibiglCGAL": "Boolean libigl CGAL (backend)"} diff --git a/nodes/main/boolean/boolean.py b/nodes/main/boolean/boolean.py index 32053161..0a6ea73e 100644 --- a/nodes/main/boolean/boolean.py +++ b/nodes/main/boolean/boolean.py @@ -2,44 +2,51 @@ # Copyright (C) 2025 ComfyUI-GeometryPack Contributors """ -Boolean CGAL Node - CSG operations using libigl+CGAL -Requires igl.copyleft.cgal. +Unified Boolean Node - Single frontend with backend selector. + +Uses ComfyUI's node expansion (GraphBuilder) to dispatch to hidden +backend-specific nodes, each running in its own isolation env. """ import logging - -import numpy as np -import trimesh as trimesh_module from comfy_api.latest import io log = logging.getLogger("geometrypack") -class BooleanCGALNode(io.ComfyNode): +class BooleanNode(io.ComfyNode): """ - Boolean CGAL - Union, Difference, and Intersection of meshes using CGAL. + Boolean - Unified CSG operations with backend selection. - Performs Constructive Solid Geometry (CSG) operations: - - union: Combine two meshes into one - - difference: Subtract mesh_b from mesh_a - - intersection: Keep only overlapping parts - - Uses libigl with CGAL backend for robust boolean operations. - For Blender-based booleans, use "Boolean Blender" node. + Dispatches to hidden backend nodes via node expansion. + Backends span multiple isolation envs (main, blender). """ + BACKEND_MAP = { + "libigl_cgal": "GeomPackBoolean_LibiglCGAL", + "blender_exact": "GeomPackBoolean_BlenderExact", + } @classmethod def define_schema(cls): return io.Schema( - node_id="GeomPackBooleanCGAL", - display_name="Boolean CGAL", + node_id="GeomPackBoolean", + display_name="Boolean", category="geompack/boolean", + enable_expand=True, is_output_node=True, inputs=[ io.Custom("TRIMESH").Input("mesh_a"), io.Custom("TRIMESH").Input("mesh_b"), io.Combo.Input("operation", options=["union", "difference", "intersection"]), + io.DynamicCombo.Input("backend", tooltip=( + "Boolean engine. " + "libigl_cgal=robust CGAL-based (best), " + "blender_exact=Blender EXACT solver" + ), options=[ + io.DynamicCombo.Option("libigl_cgal", []), + io.DynamicCombo.Option("blender_exact", []), + ]), ], outputs=[ io.Custom("TRIMESH").Output(display_name="result_mesh"), @@ -48,92 +55,41 @@ class BooleanCGALNode(io.ComfyNode): ) @classmethod - def execute(cls, mesh_a, mesh_b, operation): - """ - Perform boolean operation on two meshes using libigl+CGAL. + def execute(cls, mesh_a, mesh_b, operation, backend): + from comfy_execution.graph_utils import GraphBuilder - Args: - mesh_a: First mesh (base mesh for difference) - mesh_b: Second mesh (subtracted mesh for difference) - operation: Boolean operation type + if cls.SCHEMA is None: + cls.GET_SCHEMA() - Returns: - tuple: (result_mesh, info_string) - """ - log.info("Mesh A: %d vertices, %d faces", len(mesh_a.vertices), len(mesh_a.faces)) - log.info("Mesh B: %d vertices, %d faces", len(mesh_b.vertices), len(mesh_b.faces)) - log.info("Operation: %s", operation) + selected = backend["backend"] + node_id = cls.BACKEND_MAP[selected] - try: - import igl.copyleft.cgal as cgal - log.info("Using libigl+CGAL backend...") + log.info("Boolean dispatch: %s -> %s", selected, node_id) - # Convert trimesh to numpy arrays - VA = np.asarray(mesh_a.vertices, dtype=np.float64) - FA = np.asarray(mesh_a.faces, dtype=np.int64) - VB = np.asarray(mesh_b.vertices, dtype=np.float64) - FB = np.asarray(mesh_b.faces, dtype=np.int64) + kwargs = { + "mesh_a": mesh_a, + "mesh_b": mesh_b, + "operation": operation, + } + # Add any backend-specific params (currently none) + for k, v in backend.items(): + if k == "backend": + continue + kwargs[k] = v - # Map operation to igl type_str - op_map = { - "union": "union", - "difference": "difference", - "intersection": "intersection" - } + graph = GraphBuilder() + backend_node = graph.node(node_id, **kwargs) - if operation not in op_map: - raise ValueError(f"Unknown operation: {operation}") - - # Perform boolean operation using CGAL - VC, FC, J = cgal.mesh_boolean(VA, FA, VB, FB, op_map[operation]) - - # Create result trimesh - result = trimesh_module.Trimesh(vertices=VC, faces=FC, process=False) - - # Preserve metadata from mesh_a - result.metadata = mesh_a.metadata.copy() - result.metadata['boolean'] = { - 'operation': operation, - 'engine': 'libigl_cgal', - 'mesh_a_vertices': len(mesh_a.vertices), - 'mesh_a_faces': len(mesh_a.faces), - 'mesh_b_vertices': len(mesh_b.vertices), - 'mesh_b_faces': len(mesh_b.faces), - 'result_vertices': len(result.vertices), - 'result_faces': len(result.faces) - } - - info = f"""Boolean Operation Results: - -Operation: {operation.upper()} -Engine: libigl + CGAL - -Mesh A: - Vertices: {len(mesh_a.vertices):,} - Faces: {len(mesh_a.faces):,} - -Mesh B: - Vertices: {len(mesh_b.vertices):,} - Faces: {len(mesh_b.faces):,} - -Result: - Vertices: {len(result.vertices):,} - Faces: {len(result.faces):,} - -Watertight: {result.is_watertight} -""" - - log.info("Success: %d vertices, %d faces", len(result.vertices), len(result.faces)) - return io.NodeOutput(result, info, ui={"text": [info]}) - - except Exception as e: - raise RuntimeError(f"Boolean CGAL operation failed: {e}") + return { + "result": (backend_node.out(0), backend_node.out(1)), + "expand": graph.finalize(), + } NODE_CLASS_MAPPINGS = { - "GeomPackBooleanCGAL": BooleanCGALNode, + "GeomPackBoolean": BooleanNode, } NODE_DISPLAY_NAME_MAPPINGS = { - "GeomPackBooleanCGAL": "Boolean CGAL", + "GeomPackBoolean": "Boolean", } diff --git a/nodes/main/decimation/__init__.py b/nodes/main/decimation/__init__.py index a43c4070..ea271f59 100644 --- a/nodes/main/decimation/__init__.py +++ b/nodes/main/decimation/__init__.py @@ -6,6 +6,7 @@ Decimation nodes """ from .decimate import NODE_CLASS_MAPPINGS as DECIMATE_MAPS, NODE_DISPLAY_NAME_MAPPINGS as DECIMATE_DISP +from .backends import NODE_CLASS_MAPPINGS as BACKENDS_MAPS, NODE_DISPLAY_NAME_MAPPINGS as BACKENDS_DISP NODE_CLASS_MAPPINGS = {} NODE_DISPLAY_NAME_MAPPINGS = {} @@ -13,4 +14,7 @@ NODE_DISPLAY_NAME_MAPPINGS = {} NODE_CLASS_MAPPINGS.update(DECIMATE_MAPS) NODE_DISPLAY_NAME_MAPPINGS.update(DECIMATE_DISP) +NODE_CLASS_MAPPINGS.update(BACKENDS_MAPS) +NODE_DISPLAY_NAME_MAPPINGS.update(BACKENDS_DISP) + __all__ = ['NODE_CLASS_MAPPINGS', 'NODE_DISPLAY_NAME_MAPPINGS'] diff --git a/nodes/main/decimation/backends/__init__.py b/nodes/main/decimation/backends/__init__.py new file mode 100644 index 00000000..a04d4b45 --- /dev/null +++ b/nodes/main/decimation/backends/__init__.py @@ -0,0 +1,26 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2025 ComfyUI-GeometryPack Contributors + +""" +Individual decimation backend nodes (hidden from UI, invoked via unified Decimate Mesh node). +""" + +from .quadric_edge_collapse import NODE_CLASS_MAPPINGS as QEC_MAPS, NODE_DISPLAY_NAME_MAPPINGS as QEC_DISP +from .fast_simplification import NODE_CLASS_MAPPINGS as FS_MAPS, NODE_DISPLAY_NAME_MAPPINGS as FS_DISP +from .vertex_clustering import NODE_CLASS_MAPPINGS as VC_MAPS, NODE_DISPLAY_NAME_MAPPINGS as VC_DISP +from .decimate_pro import NODE_CLASS_MAPPINGS as DP_MAPS, NODE_DISPLAY_NAME_MAPPINGS as DP_DISP + +NODE_CLASS_MAPPINGS = {} +NODE_DISPLAY_NAME_MAPPINGS = {} + +NODE_CLASS_MAPPINGS.update(QEC_MAPS) +NODE_CLASS_MAPPINGS.update(FS_MAPS) +NODE_CLASS_MAPPINGS.update(VC_MAPS) +NODE_CLASS_MAPPINGS.update(DP_MAPS) + +NODE_DISPLAY_NAME_MAPPINGS.update(QEC_DISP) +NODE_DISPLAY_NAME_MAPPINGS.update(FS_DISP) +NODE_DISPLAY_NAME_MAPPINGS.update(VC_DISP) +NODE_DISPLAY_NAME_MAPPINGS.update(DP_DISP) + +__all__ = ['NODE_CLASS_MAPPINGS', 'NODE_DISPLAY_NAME_MAPPINGS'] diff --git a/nodes/main/decimation/backends/decimate_pro.py b/nodes/main/decimation/backends/decimate_pro.py new file mode 100644 index 00000000..0b506b51 --- /dev/null +++ b/nodes/main/decimation/backends/decimate_pro.py @@ -0,0 +1,114 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2025 ComfyUI-GeometryPack Contributors + +"""PyVista/VTK DecimatePro backend node.""" + +import logging +import numpy as np +import trimesh as trimesh_module +from comfy_api.latest import io + +log = logging.getLogger("geometrypack") + + +def _pyvista_decimate_pro(mesh, target_reduction, preserve_topology, feature_angle): + """VTK DecimatePro via PyVista.""" + import pyvista as pv + + vertices = np.asarray(mesh.vertices, dtype=np.float64) + faces_raw = np.asarray(mesh.faces, dtype=np.int32) + + # PyVista face format: [3, v0, v1, v2, 3, v0, v1, v2, ...] + n_faces = len(faces_raw) + pv_faces = np.empty((n_faces, 4), dtype=np.int32) + pv_faces[:, 0] = 3 + pv_faces[:, 1:] = faces_raw + pv_faces = pv_faces.ravel() + + pv_mesh = pv.PolyData(vertices, pv_faces) + + decimated_pv = pv_mesh.decimate_pro( + target_reduction, + preserve_topology=preserve_topology, + feature_angle=feature_angle, + ) + + out_verts = np.array(decimated_pv.points, dtype=np.float64) + out_faces_pv = decimated_pv.faces.reshape(-1, 4)[:, 1:] + + return trimesh_module.Trimesh( + vertices=out_verts, + faces=out_faces_pv, + process=False, + ) + + +class DecimateProNode(io.ComfyNode): + """PyVista/VTK DecimatePro backend.""" + + @classmethod + def define_schema(cls): + return io.Schema( + node_id="GeomPackDecimate_DecimatePro", + display_name="Decimate Pro (backend)", + category="geompack/decimation", + is_dev_only=True, + is_output_node=True, + inputs=[ + io.Custom("TRIMESH").Input("trimesh"), + io.Float.Input("target_reduction", default=0.5, min=0.01, max=0.99, step=0.01, tooltip="Fraction of faces to REMOVE. 0.5 = reduce to ~50%% of original faces, 0.9 = reduce to ~10%% of original."), + io.Float.Input("feature_angle", default=15.0, min=0.0, max=180.0, step=1.0, tooltip="Feature angle threshold (degrees). Edges with dihedral angle above this are preserved."), + io.Combo.Input("preserve_topology", options=["true", "false"], default="true", tooltip="Preserve mesh topology (genus) during decimation."), + ], + outputs=[ + io.Custom("TRIMESH").Output(display_name="decimated_mesh"), + io.String.Output(display_name="info"), + ], + ) + + @classmethod + def execute(cls, trimesh, target_reduction=0.5, feature_angle=15.0, preserve_topology="true"): + log.info("Backend: decimate_pro") + initial_vertices = len(trimesh.vertices) + initial_faces = len(trimesh.faces) + log.info("Input: %s vertices, %s faces", f"{initial_vertices:,}", f"{initial_faces:,}") + + decimated = _pyvista_decimate_pro( + trimesh, target_reduction, + preserve_topology == "true", + feature_angle, + ) + + if hasattr(trimesh, "metadata") and trimesh.metadata: + decimated.metadata = trimesh.metadata.copy() + decimated.metadata["decimation"] = { + "algorithm": "decimate_pro", + "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 (decimate_pro): + +Target Reduction: {target_reduction:.0%} +Preserve Topology: {preserve_topology} +Feature Angle: {feature_angle}\u00b0 + +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_DecimatePro": DecimateProNode} +NODE_DISPLAY_NAME_MAPPINGS = {"GeomPackDecimate_DecimatePro": "Decimate Pro (backend)"} diff --git a/nodes/main/decimation/backends/fast_simplification.py b/nodes/main/decimation/backends/fast_simplification.py new file mode 100644 index 00000000..d4597470 --- /dev/null +++ b/nodes/main/decimation/backends/fast_simplification.py @@ -0,0 +1,96 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2025 ComfyUI-GeometryPack Contributors + +"""Fast quadric mesh simplification backend node.""" + +import logging +import numpy as np +import trimesh as trimesh_module +from comfy_api.latest import io + +log = logging.getLogger("geometrypack") + + +def _fast_simplification_decimate(mesh, target_reduction, agg): + """Fast quadric mesh simplification.""" + import fast_simplification + + v = np.asarray(mesh.vertices, dtype=np.float64) + f = np.asarray(mesh.faces, dtype=np.int32) + + v_out, f_out = fast_simplification.simplify( + v, f, + target_reduction=target_reduction, + agg=agg, + ) + + return trimesh_module.Trimesh( + vertices=v_out, + faces=f_out, + process=False, + ) + + +class DecimateFastSimplificationNode(io.ComfyNode): + """Fast quadric mesh simplification backend.""" + + @classmethod + def define_schema(cls): + return io.Schema( + node_id="GeomPackDecimate_FastSimplification", + display_name="Decimate Fast Simplification (backend)", + category="geompack/decimation", + is_dev_only=True, + is_output_node=True, + inputs=[ + io.Custom("TRIMESH").Input("trimesh"), + io.Float.Input("target_reduction", default=0.5, min=0.01, max=0.99, step=0.01, tooltip="Fraction of faces to REMOVE. 0.5 = reduce to ~50%% of original faces, 0.9 = reduce to ~10%% of original."), + io.Int.Input("aggressiveness", default=7, min=1, max=15, step=1, tooltip="How aggressively to simplify. Higher = faster but lower quality. Default 7."), + ], + outputs=[ + io.Custom("TRIMESH").Output(display_name="decimated_mesh"), + io.String.Output(display_name="info"), + ], + ) + + @classmethod + def execute(cls, trimesh, target_reduction=0.5, aggressiveness=7): + log.info("Backend: fast_simplification") + initial_vertices = len(trimesh.vertices) + initial_faces = len(trimesh.faces) + log.info("Input: %s vertices, %s faces", f"{initial_vertices:,}", f"{initial_faces:,}") + + decimated = _fast_simplification_decimate(trimesh, target_reduction, aggressiveness) + + if hasattr(trimesh, "metadata") and trimesh.metadata: + decimated.metadata = trimesh.metadata.copy() + decimated.metadata["decimation"] = { + "algorithm": "fast_simplification", + "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 (fast_simplification): + +Target Reduction: {target_reduction:.0%} +Aggressiveness: {aggressiveness} + +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_FastSimplification": DecimateFastSimplificationNode} +NODE_DISPLAY_NAME_MAPPINGS = {"GeomPackDecimate_FastSimplification": "Decimate Fast Simplification (backend)"} diff --git a/nodes/main/decimation/backends/quadric_edge_collapse.py b/nodes/main/decimation/backends/quadric_edge_collapse.py new file mode 100644 index 00000000..af29c0c2 --- /dev/null +++ b/nodes/main/decimation/backends/quadric_edge_collapse.py @@ -0,0 +1,134 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2025 ComfyUI-GeometryPack Contributors + +"""PyMeshLab quadric 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 _pymeshlab_quadric_edge_collapse(mesh, target_face_count, quality_threshold, + preserve_boundary, preserve_normal, + preserve_topology, planar_quadric): + """Quadric edge collapse decimation via PyMeshLab.""" + import pymeshlab + + ms = pymeshlab.MeshSet() + pml_mesh = pymeshlab.Mesh( + vertex_matrix=np.asarray(mesh.vertices, dtype=np.float64), + face_matrix=np.asarray(mesh.faces, dtype=np.int32), + ) + ms.add_mesh(pml_mesh) + + kwargs = { + "targetfacenum": target_face_count, + "qualitythr": quality_threshold, + "preserveboundary": preserve_boundary, + "preservenormal": preserve_normal, + "preservetopology": preserve_topology, + "planarquadric": planar_quadric, + "autoclean": True, + } + + try: + ms.meshing_decimation_quadric_edge_collapse(**kwargs) + except AttributeError: + try: + ms.simplification_quadric_edge_collapse_decimation(**kwargs) + except AttributeError: + raise RuntimeError( + "PyMeshLab quadric edge collapse filter not available. " + "Ensure pymeshlab >= 2023.12 is installed." + ) + + out = ms.current_mesh() + return trimesh_module.Trimesh( + vertices=out.vertex_matrix(), + faces=out.face_matrix(), + process=False, + ) + + +class DecimateQuadricEdgeCollapseNode(io.ComfyNode): + """PyMeshLab quadric edge collapse decimation backend.""" + + @classmethod + def define_schema(cls): + return io.Schema( + node_id="GeomPackDecimate_QuadricEdgeCollapse", + display_name="Decimate Quadric 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.Float.Input("quality_threshold", default=0.3, min=0.0, max=1.0, step=0.05, tooltip="Quality threshold for edge collapse. Higher = more conservative, better triangle quality."), + io.Combo.Input("preserve_boundary", options=["true", "false"], default="true", tooltip="Preserve mesh boundary edges during decimation."), + io.Combo.Input("preserve_normal", options=["true", "false"], default="true", tooltip="Prevent face normal flips during decimation."), + io.Combo.Input("preserve_topology", options=["true", "false"], default="true", tooltip="Preserve mesh topology (genus) during decimation."), + io.Combo.Input("planar_quadric", options=["true", "false"], default="false", tooltip="Add penalty for non-planar faces. Helps preserve flat regions."), + ], + outputs=[ + io.Custom("TRIMESH").Output(display_name="decimated_mesh"), + io.String.Output(display_name="info"), + ], + ) + + @classmethod + def execute(cls, trimesh, target_face_count=5000, quality_threshold=0.3, + preserve_boundary="true", preserve_normal="true", + preserve_topology="true", planar_quadric="false"): + log.info("Backend: quadric_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:,}") + + decimated = _pymeshlab_quadric_edge_collapse( + trimesh, target_face_count, quality_threshold, + preserve_boundary == "true", + preserve_normal == "true", + preserve_topology == "true", + planar_quadric == "true", + ) + + if hasattr(trimesh, "metadata") and trimesh.metadata: + decimated.metadata = trimesh.metadata.copy() + decimated.metadata["decimation"] = { + "algorithm": "quadric_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 (quadric_edge_collapse): + +Target Face Count: {target_face_count:,} +Quality Threshold: {quality_threshold} +Preserve Boundary: {preserve_boundary} +Preserve Normal: {preserve_normal} +Preserve Topology: {preserve_topology} +Planar Quadric: {planar_quadric} + +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_QuadricEdgeCollapse": DecimateQuadricEdgeCollapseNode} +NODE_DISPLAY_NAME_MAPPINGS = {"GeomPackDecimate_QuadricEdgeCollapse": "Decimate Quadric Edge Collapse (backend)"} diff --git a/nodes/main/decimation/backends/vertex_clustering.py b/nodes/main/decimation/backends/vertex_clustering.py new file mode 100644 index 00000000..00deb2d2 --- /dev/null +++ b/nodes/main/decimation/backends/vertex_clustering.py @@ -0,0 +1,108 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2025 ComfyUI-GeometryPack Contributors + +"""PyMeshLab vertex clustering 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 _pymeshlab_vertex_clustering(mesh, threshold_percentage): + """Vertex clustering decimation via PyMeshLab.""" + import pymeshlab + + ms = pymeshlab.MeshSet() + pml_mesh = pymeshlab.Mesh( + vertex_matrix=np.asarray(mesh.vertices, dtype=np.float64), + face_matrix=np.asarray(mesh.faces, dtype=np.int32), + ) + ms.add_mesh(pml_mesh) + + try: + ms.meshing_decimation_clustering( + threshold=pymeshlab.PercentageValue(threshold_percentage), + ) + except AttributeError: + try: + ms.simplification_clustering_decimation( + threshold=pymeshlab.PercentageValue(threshold_percentage), + ) + except AttributeError: + raise RuntimeError( + "PyMeshLab vertex clustering filter not available. " + "Ensure pymeshlab >= 2023.12 is installed." + ) + + out = ms.current_mesh() + return trimesh_module.Trimesh( + vertices=out.vertex_matrix(), + faces=out.face_matrix(), + process=False, + ) + + +class DecimateVertexClusteringNode(io.ComfyNode): + """PyMeshLab vertex clustering decimation backend.""" + + @classmethod + def define_schema(cls): + return io.Schema( + node_id="GeomPackDecimate_VertexClustering", + display_name="Decimate Vertex Clustering (backend)", + category="geompack/decimation", + is_dev_only=True, + is_output_node=True, + inputs=[ + io.Custom("TRIMESH").Input("trimesh"), + io.Float.Input("cluster_threshold", default=1.0, min=0.1, max=10.0, step=0.1, tooltip="Clustering cell size as percentage of bounding box diagonal. Larger = more aggressive reduction."), + ], + outputs=[ + io.Custom("TRIMESH").Output(display_name="decimated_mesh"), + io.String.Output(display_name="info"), + ], + ) + + @classmethod + def execute(cls, trimesh, cluster_threshold=1.0): + log.info("Backend: vertex_clustering") + initial_vertices = len(trimesh.vertices) + initial_faces = len(trimesh.faces) + log.info("Input: %s vertices, %s faces", f"{initial_vertices:,}", f"{initial_faces:,}") + + decimated = _pymeshlab_vertex_clustering(trimesh, cluster_threshold) + + if hasattr(trimesh, "metadata") and trimesh.metadata: + decimated.metadata = trimesh.metadata.copy() + decimated.metadata["decimation"] = { + "algorithm": "vertex_clustering", + "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 (vertex_clustering): + +Cluster Threshold: {cluster_threshold}% + +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_VertexClustering": DecimateVertexClusteringNode} +NODE_DISPLAY_NAME_MAPPINGS = {"GeomPackDecimate_VertexClustering": "Decimate Vertex Clustering (backend)"} diff --git a/nodes/main/decimation/decimate.py b/nodes/main/decimation/decimate.py index f86298ae..a2c2692f 100644 --- a/nodes/main/decimation/decimate.py +++ b/nodes/main/decimation/decimate.py @@ -2,256 +2,40 @@ # Copyright (C) 2025 ComfyUI-GeometryPack Contributors """ -Decimate Node - Multiple mesh decimation/simplification backends. +Unified Decimate Mesh Node - Single frontend with backend selector. -Available backends: -- quadric_edge_collapse: PyMeshLab Garland-Heckbert quadric error (best quality) -- fast_simplification: Fast-Quadric-Mesh-Simplification (~10x faster) -- vertex_clustering: PyMeshLab vertex clustering (fastest, aggressive) -- cgal_edge_collapse: CGAL Lindstrom-Turk edge collapse (high geometric fidelity) -- decimate_pro: PyVista/VTK DecimatePro (topology-preserving) +Uses ComfyUI's node expansion (GraphBuilder) to dispatch to hidden +backend-specific nodes, each running in its own isolation env. """ import logging -import numpy as np -import trimesh as trimesh_module from comfy_api.latest import io log = logging.getLogger("geometrypack") -def _pymeshlab_quadric_edge_collapse(mesh, target_face_count, quality_threshold, - preserve_boundary, preserve_normal, - preserve_topology, planar_quadric): - """Quadric edge collapse decimation via PyMeshLab.""" - try: - import pymeshlab - except (ImportError, OSError): - return None, "pymeshlab is not installed." - - ms = pymeshlab.MeshSet() - pml_mesh = pymeshlab.Mesh( - vertex_matrix=np.asarray(mesh.vertices, dtype=np.float64), - face_matrix=np.asarray(mesh.faces, dtype=np.int32), - ) - ms.add_mesh(pml_mesh) - - kwargs = { - "targetfacenum": target_face_count, - "qualitythr": quality_threshold, - "preserveboundary": preserve_boundary, - "preservenormal": preserve_normal, - "preservetopology": preserve_topology, - "planarquadric": planar_quadric, - "autoclean": True, - } - - try: - ms.meshing_decimation_quadric_edge_collapse(**kwargs) - except AttributeError: - try: - ms.simplification_quadric_edge_collapse_decimation(**kwargs) - except AttributeError: - return None, ( - "PyMeshLab quadric edge collapse filter not available. " - "Ensure pymeshlab >= 2023.12 is installed." - ) - - out = ms.current_mesh() - result = trimesh_module.Trimesh( - vertices=out.vertex_matrix(), - faces=out.face_matrix(), - process=False, - ) - return result, "" - -def _fast_simplification_decimate(mesh, target_reduction, agg): - """Fast quadric mesh simplification.""" - try: - import fast_simplification - except (ImportError, OSError): - return None, "fast-simplification is not installed." - - v = np.asarray(mesh.vertices, dtype=np.float64) - f = np.asarray(mesh.faces, dtype=np.int32) - - v_out, f_out = fast_simplification.simplify( - v, f, - target_reduction=target_reduction, - agg=agg, - ) - - result = trimesh_module.Trimesh( - vertices=v_out, - faces=f_out, - process=False, - ) - return result, "" - -def _pymeshlab_vertex_clustering(mesh, threshold_percentage): - """Vertex clustering decimation via PyMeshLab.""" - try: - import pymeshlab - except (ImportError, OSError): - return None, "pymeshlab is not installed." - - ms = pymeshlab.MeshSet() - pml_mesh = pymeshlab.Mesh( - vertex_matrix=np.asarray(mesh.vertices, dtype=np.float64), - face_matrix=np.asarray(mesh.faces, dtype=np.int32), - ) - ms.add_mesh(pml_mesh) - - try: - ms.meshing_decimation_clustering( - threshold=pymeshlab.PercentageValue(threshold_percentage), - ) - except AttributeError: - try: - ms.simplification_clustering_decimation( - threshold=pymeshlab.PercentageValue(threshold_percentage), - ) - except AttributeError: - return None, ( - "PyMeshLab vertex clustering filter not available. " - "Ensure pymeshlab >= 2023.12 is installed." - ) - - out = ms.current_mesh() - result = trimesh_module.Trimesh( - vertices=out.vertex_matrix(), - faces=out.face_matrix(), - process=False, - ) - return result, "" - -def _cgal_edge_collapse(mesh, target_edge_count, cost_strategy): - """CGAL surface mesh simplification via edge collapse.""" - try: - 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 - except (ImportError, OSError): - return None, "CGAL Python bindings not available." - - vertices = np.asarray(mesh.vertices, dtype=np.float64) - faces = np.asarray(mesh.faces, dtype=np.int32) - - if len(vertices) == 0 or len(faces) == 0: - return None, "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: - # edge_length cost - 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) - - result = trimesh_module.Trimesh( - vertices=np.array(new_vertices, dtype=np.float64), - faces=np.array(new_faces, dtype=np.int32), - process=False, - ) - return result, "" - -def _pyvista_decimate_pro(mesh, target_reduction, preserve_topology, feature_angle): - """VTK DecimatePro via PyVista.""" - try: - import pyvista as pv - except (ImportError, OSError): - return None, "pyvista is not installed." - - vertices = np.asarray(mesh.vertices, dtype=np.float64) - faces_raw = np.asarray(mesh.faces, dtype=np.int32) - - # PyVista face format: [3, v0, v1, v2, 3, v0, v1, v2, ...] - n_faces = len(faces_raw) - pv_faces = np.empty((n_faces, 4), dtype=np.int32) - pv_faces[:, 0] = 3 - pv_faces[:, 1:] = faces_raw - pv_faces = pv_faces.ravel() - - pv_mesh = pv.PolyData(vertices, pv_faces) - - decimated = pv_mesh.decimate_pro( - target_reduction, - preserve_topology=preserve_topology, - feature_angle=feature_angle, - ) - - # Extract back - out_verts = np.array(decimated.points, dtype=np.float64) - out_faces_pv = decimated.faces.reshape(-1, 4)[:, 1:] - - result = trimesh_module.Trimesh( - vertices=out_verts, - faces=out_faces_pv, - process=False, - ) - return result, "" class DecimateMeshNode(io.ComfyNode): """ - Decimate Mesh - Reduce mesh face/vertex count using multiple algorithms. + Decimate Mesh - Unified decimation with backend selection. - Available backends: - - quadric_edge_collapse: Garland-Heckbert quadric error metric (PyMeshLab). - Best quality, most options. Industry standard. - - fast_simplification: Fast Quadric Mesh Simplification. - ~10x faster than PyMeshLab, slightly lower quality. - - vertex_clustering: Group nearby vertices (PyMeshLab). - Very fast, aggressive reduction. Good for massive meshes. - - cgal_edge_collapse: CGAL Lindstrom-Turk edge collapse. - Highest geometric fidelity, optimizes volume + boundary. - - decimate_pro: VTK DecimatePro (PyVista). - Topology-preserving vertex deletion with local re-triangulation. + Dispatches to hidden backend nodes via node expansion. """ + BACKEND_MAP = { + "quadric_edge_collapse": "GeomPackDecimate_QuadricEdgeCollapse", + "fast_simplification": "GeomPackDecimate_FastSimplification", + "vertex_clustering": "GeomPackDecimate_VertexClustering", + # cgal_edge_collapse disabled: CGAL Python bindings lack Surface_mesh_simplification module + "decimate_pro": "GeomPackDecimate_DecimatePro", + } + @classmethod def define_schema(cls): return io.Schema( node_id="GeomPackDecimateMesh", display_name="Decimate Mesh", category="geompack/decimation", + enable_expand=True, is_output_node=True, inputs=[ io.Custom("TRIMESH").Input("trimesh"), @@ -260,7 +44,6 @@ class DecimateMeshNode(io.ComfyNode): "quadric_edge_collapse=best quality, " "fast_simplification=fastest, " "vertex_clustering=aggressive, " - "cgal_edge_collapse=highest fidelity, " "decimate_pro=topology-preserving" ), options=[ io.DynamicCombo.Option("quadric_edge_collapse", [ @@ -278,10 +61,7 @@ class DecimateMeshNode(io.ComfyNode): io.DynamicCombo.Option("vertex_clustering", [ io.Float.Input("cluster_threshold", default=1.0, min=0.1, max=10.0, step=0.1, tooltip="Clustering cell size as percentage of bounding box diagonal. Larger = more aggressive reduction."), ]), - io.DynamicCombo.Option("cgal_edge_collapse", [ - 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)."), - ]), + io.DynamicCombo.Option("decimate_pro", [ io.Float.Input("target_reduction", default=0.5, min=0.01, max=0.99, step=0.01, tooltip="Fraction of faces to REMOVE. 0.5 = reduce to ~50%% of original faces, 0.9 = reduce to ~10%% of original."), io.Float.Input("feature_angle", default=15.0, min=0.0, max=180.0, step=1.0, tooltip="Feature angle threshold (degrees). Edges with dihedral angle above this are preserved."), @@ -297,140 +77,30 @@ class DecimateMeshNode(io.ComfyNode): @classmethod def execute(cls, trimesh, backend): - """Apply mesh decimation based on selected backend.""" + from comfy_execution.graph_utils import GraphBuilder + + if cls.SCHEMA is None: + cls.GET_SCHEMA() + selected = backend["backend"] - target_face_count = backend.get("target_face_count", 5000) - target_reduction = backend.get("target_reduction", 0.5) - quality_threshold = backend.get("quality_threshold", 0.3) - preserve_boundary = backend.get("preserve_boundary", "true") - preserve_normal = backend.get("preserve_normal", "true") - preserve_topology = backend.get("preserve_topology", "true") - planar_quadric = backend.get("planar_quadric", "false") - aggressiveness = backend.get("aggressiveness", 7) - cluster_threshold = backend.get("cluster_threshold", 1.0) - cost_strategy = backend.get("cost_strategy", "lindstrom_turk") - feature_angle = backend.get("feature_angle", 15.0) + node_id = cls.BACKEND_MAP[selected] - initial_vertices = len(trimesh.vertices) - initial_faces = len(trimesh.faces) + log.info("Decimate dispatch: %s -> %s", selected, node_id) - log.info("Decimate backend: %s", selected) - log.info("Input: %s vertices, %s faces", - f"{initial_vertices:,}", f"{initial_faces:,}") + kwargs = {"trimesh": trimesh} + for k, v in backend.items(): + if k == "backend": + continue + kwargs[k] = v - if selected == "quadric_edge_collapse": - log.info("Parameters: target_face_count=%d, quality_thr=%.2f, " - "preserve_boundary=%s, preserve_normal=%s, preserve_topology=%s", - target_face_count, quality_threshold, - preserve_boundary, preserve_normal, preserve_topology) - decimated, error = _pymeshlab_quadric_edge_collapse( - trimesh, target_face_count, quality_threshold, - preserve_boundary == "true", - preserve_normal == "true", - preserve_topology == "true", - planar_quadric == "true", - ) + graph = GraphBuilder() + backend_node = graph.node(node_id, **kwargs) - elif selected == "fast_simplification": - log.info("Parameters: target_reduction=%.2f, aggressiveness=%d", - target_reduction, aggressiveness) - decimated, error = _fast_simplification_decimate( - trimesh, target_reduction, aggressiveness, - ) - - elif selected == "vertex_clustering": - log.info("Parameters: cluster_threshold=%.1f%%", cluster_threshold) - decimated, error = _pymeshlab_vertex_clustering( - trimesh, cluster_threshold, - ) - - elif selected == "cgal_edge_collapse": - 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, error = _cgal_edge_collapse( - trimesh, target_edges, cost_strategy, - ) - - elif selected == "decimate_pro": - log.info("Parameters: target_reduction=%.2f, preserve_topology=%s, feature_angle=%.1f", - target_reduction, preserve_topology, feature_angle) - decimated, error = _pyvista_decimate_pro( - trimesh, target_reduction, - preserve_topology == "true", - feature_angle, - ) - - else: - raise ValueError(f"Unknown backend: {selected}") - - if decimated is None: - raise ValueError(f"Decimation failed ({selected}): {error}") - - # Copy metadata - if hasattr(trimesh, "metadata") and trimesh.metadata: - decimated.metadata = trimesh.metadata.copy() - decimated.metadata["decimation"] = { - "algorithm": selected, - "original_vertices": initial_vertices, - "original_faces": initial_faces, - "result_vertices": len(decimated.vertices), - "result_faces": len(decimated.faces), + return { + "result": (backend_node.out(0), backend_node.out(1)), + "expand": graph.finalize(), } - vertex_change = len(decimated.vertices) - initial_vertices - face_change = len(decimated.faces) - initial_faces - face_pct = (face_change / initial_faces) * 100 if initial_faces > 0 else 0 - - log.info("Output: %d vertices (%+d), %d faces (%+d, %.1f%%)", - len(decimated.vertices), vertex_change, - len(decimated.faces), face_change, face_pct) - - # Build backend-specific param block - if selected == "quadric_edge_collapse": - param_text = ( - f"Target Face Count: {target_face_count:,}\n" - f"Quality Threshold: {quality_threshold}\n" - f"Preserve Boundary: {preserve_boundary}\n" - f"Preserve Normal: {preserve_normal}\n" - f"Preserve Topology: {preserve_topology}\n" - f"Planar Quadric: {planar_quadric}" - ) - elif selected == "fast_simplification": - param_text = ( - f"Target Reduction: {target_reduction:.0%}\n" - f"Aggressiveness: {aggressiveness}" - ) - elif selected == "vertex_clustering": - param_text = f"Cluster Threshold: {cluster_threshold}%" - elif selected == "cgal_edge_collapse": - param_text = ( - f"Target Face Count: {target_face_count:,}\n" - f"Cost Strategy: {cost_strategy}" - ) - elif selected == "decimate_pro": - param_text = ( - f"Target Reduction: {target_reduction:.0%}\n" - f"Preserve Topology: {preserve_topology}\n" - f"Feature Angle: {feature_angle}\u00b0" - ) - else: - param_text = "" - - info = f"""Decimate Results ({selected}): - -{param_text} - -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 = { "GeomPackDecimateMesh": DecimateMeshNode, diff --git a/nodes/main/decimation_cgal/__init__.py b/nodes/main/decimation_cgal/__init__.py new file mode 100644 index 00000000..9ecf8669 --- /dev/null +++ b/nodes/main/decimation_cgal/__init__.py @@ -0,0 +1,16 @@ +# 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'] diff --git a/nodes/main/decimation_cgal/cgal_edge_collapse.py b/nodes/main/decimation_cgal/cgal_edge_collapse.py new file mode 100644 index 00000000..5ec018c0 --- /dev/null +++ b/nodes/main/decimation_cgal/cgal_edge_collapse.py @@ -0,0 +1,145 @@ +# 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)"} diff --git a/nodes/main/reconstruction/__init__.py b/nodes/main/reconstruction/__init__.py index 0635bf0d..26e4ea25 100644 --- a/nodes/main/reconstruction/__init__.py +++ b/nodes/main/reconstruction/__init__.py @@ -6,11 +6,14 @@ Reconstruction nodes (main dependencies only - no CGAL Alpha Wrap) """ from .reconstruct_surface import NODE_CLASS_MAPPINGS as RECONSTRUCT_MAPS, NODE_DISPLAY_NAME_MAPPINGS as RECONSTRUCT_DISP +from .backends import NODE_CLASS_MAPPINGS as BACKENDS_MAPS, NODE_DISPLAY_NAME_MAPPINGS as BACKENDS_DISP NODE_CLASS_MAPPINGS = {} NODE_DISPLAY_NAME_MAPPINGS = {} NODE_CLASS_MAPPINGS.update(RECONSTRUCT_MAPS) NODE_DISPLAY_NAME_MAPPINGS.update(RECONSTRUCT_DISP) +NODE_CLASS_MAPPINGS.update(BACKENDS_MAPS) +NODE_DISPLAY_NAME_MAPPINGS.update(BACKENDS_DISP) __all__ = ['NODE_CLASS_MAPPINGS', 'NODE_DISPLAY_NAME_MAPPINGS'] diff --git a/nodes/main/reconstruction/backends/__init__.py b/nodes/main/reconstruction/backends/__init__.py new file mode 100644 index 00000000..f3586129 --- /dev/null +++ b/nodes/main/reconstruction/backends/__init__.py @@ -0,0 +1,29 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2025 ComfyUI-GeometryPack Contributors + +""" +Individual reconstruction backend nodes (hidden from UI, invoked via unified Reconstruct Surface node). +""" + +from .poisson import NODE_CLASS_MAPPINGS as POISSON_MAPS, NODE_DISPLAY_NAME_MAPPINGS as POISSON_DISP +from .ball_pivoting import NODE_CLASS_MAPPINGS as BP_MAPS, NODE_DISPLAY_NAME_MAPPINGS as BP_DISP +from .alpha_shape import NODE_CLASS_MAPPINGS as AS_MAPS, NODE_DISPLAY_NAME_MAPPINGS as AS_DISP +from .convex_hull import NODE_CLASS_MAPPINGS as CH_MAPS, NODE_DISPLAY_NAME_MAPPINGS as CH_DISP +from .delaunay_2d import NODE_CLASS_MAPPINGS as D2D_MAPS, NODE_DISPLAY_NAME_MAPPINGS as D2D_DISP + +NODE_CLASS_MAPPINGS = {} +NODE_DISPLAY_NAME_MAPPINGS = {} + +NODE_CLASS_MAPPINGS.update(POISSON_MAPS) +NODE_CLASS_MAPPINGS.update(BP_MAPS) +NODE_CLASS_MAPPINGS.update(AS_MAPS) +NODE_CLASS_MAPPINGS.update(CH_MAPS) +NODE_CLASS_MAPPINGS.update(D2D_MAPS) + +NODE_DISPLAY_NAME_MAPPINGS.update(POISSON_DISP) +NODE_DISPLAY_NAME_MAPPINGS.update(BP_DISP) +NODE_DISPLAY_NAME_MAPPINGS.update(AS_DISP) +NODE_DISPLAY_NAME_MAPPINGS.update(CH_DISP) +NODE_DISPLAY_NAME_MAPPINGS.update(D2D_DISP) + +__all__ = ['NODE_CLASS_MAPPINGS', 'NODE_DISPLAY_NAME_MAPPINGS'] diff --git a/nodes/main/reconstruction/backends/alpha_shape.py b/nodes/main/reconstruction/backends/alpha_shape.py new file mode 100644 index 00000000..8354b2d6 --- /dev/null +++ b/nodes/main/reconstruction/backends/alpha_shape.py @@ -0,0 +1,111 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2025 ComfyUI-GeometryPack Contributors + +"""Alpha shape surface reconstruction backend node.""" + +import logging +from collections import Counter + +import numpy as np +import trimesh as trimesh_module +from comfy_api.latest import io + +log = logging.getLogger("geometrypack") + + +class ReconstructAlphaShapeNode(io.ComfyNode): + """Alpha shape reconstruction using scipy Delaunay.""" + + @classmethod + def define_schema(cls): + return io.Schema( + node_id="GeomPackReconstruct_AlphaShape", + display_name="Reconstruct Alpha Shape (backend)", + category="geompack/reconstruction", + is_dev_only=True, + is_output_node=True, + inputs=[ + io.Custom("TRIMESH").Input("points"), + io.Float.Input("alpha", default=0.0, min=0.0, max=100.0, step=0.01), + ], + outputs=[ + io.Custom("TRIMESH").Output(display_name="reconstructed_mesh"), + io.String.Output(display_name="info"), + ], + ) + + @classmethod + def execute(cls, points, alpha=0.0): + log.info("Backend: alpha_shape") + vertices = np.asarray(points.vertices, dtype=np.float64) + alpha_value = alpha + + log.info("Computing alpha shape...") + + if alpha_value == 0.0: + bbox_diag = np.linalg.norm(vertices.max(axis=0) - vertices.min(axis=0)) + alpha_value = bbox_diag * 0.1 + log.info("Auto alpha: %.4f", alpha_value) + + try: + from scipy.spatial import Delaunay + + tri = Delaunay(vertices) + + valid_faces = [] + for simplex in tri.simplices: + tet_verts = vertices[simplex] + + edges = [] + for i in range(4): + for j in range(i + 1, 4): + edges.append(np.linalg.norm(tet_verts[i] - tet_verts[j])) + max_edge = max(edges) + + if max_edge < alpha_value * 2: + for i in range(4): + face = tuple(sorted([simplex[j] for j in range(4) if j != i])) + valid_faces.append(face) + + face_counts = Counter(valid_faces) + boundary_faces = [list(f) for f, count in face_counts.items() if count == 1] + + if len(boundary_faces) == 0: + raise ValueError("Alpha value too small, no faces generated") + + result = trimesh_module.Trimesh( + vertices=vertices, + faces=boundary_faces, + process=True + ) + + info = f"""Reconstruct Surface Results (Alpha Shape): + +Alpha Value: {alpha_value:.4f} + +Input Points: {len(vertices):,} +Output Vertices: {len(result.vertices):,} +Output Faces: {len(result.faces):,} + +Alpha shapes capture the overall shape with controllable detail level. +""" + # Preserve metadata + if hasattr(points, 'metadata') and points.metadata: + result.metadata = points.metadata.copy() + else: + result.metadata = {} + result.metadata['reconstruction'] = { + 'method': 'alpha_shape', + 'input_points': len(vertices), + 'output_vertices': len(result.vertices), + 'output_faces': len(result.faces), + } + + return io.NodeOutput(result, info, ui={"text": [info]}) + + except ImportError: + raise ImportError("Alpha shape requires scipy. Install with: pip install scipy") + + +NODE_CLASS_MAPPINGS = {"GeomPackReconstruct_AlphaShape": ReconstructAlphaShapeNode} +NODE_DISPLAY_NAME_MAPPINGS = {"GeomPackReconstruct_AlphaShape": "Reconstruct Alpha Shape (backend)"} diff --git a/nodes/main/reconstruction/backends/ball_pivoting.py b/nodes/main/reconstruction/backends/ball_pivoting.py new file mode 100644 index 00000000..4341ac22 --- /dev/null +++ b/nodes/main/reconstruction/backends/ball_pivoting.py @@ -0,0 +1,114 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2025 ComfyUI-GeometryPack Contributors + +"""Ball pivoting surface reconstruction backend node.""" + +import logging +import numpy as np +import trimesh as trimesh_module +from comfy_api.latest import io + +log = logging.getLogger("geometrypack") + + +class ReconstructBallPivotingNode(io.ComfyNode): + """Ball pivoting algorithm using PyMeshLab.""" + + @classmethod + def define_schema(cls): + return io.Schema( + node_id="GeomPackReconstruct_BallPivoting", + display_name="Reconstruct Ball Pivoting (backend)", + category="geompack/reconstruction", + is_dev_only=True, + is_output_node=True, + inputs=[ + io.Custom("TRIMESH").Input("points"), + io.Float.Input("ball_radius", default=0.0, min=0.0, max=100.0, step=0.01), + io.Combo.Input("estimate_normals", options=["true", "false"], default="true"), + io.Float.Input("normal_radius", default=0.1, min=0.001, max=10.0, step=0.01), + ], + outputs=[ + io.Custom("TRIMESH").Output(display_name="reconstructed_mesh"), + io.String.Output(display_name="info"), + ], + ) + + @classmethod + def execute(cls, points, ball_radius=0.0, estimate_normals="true", normal_radius=0.1): + log.info("Backend: ball_pivoting") + vertices = np.asarray(points.vertices, dtype=np.float64) + normals = None + if hasattr(points, 'vertex_normals') and len(points.vertex_normals) > 0: + normals = np.asarray(points.vertex_normals, dtype=np.float64) + + do_estimate = estimate_normals == "true" + + try: + import pymeshlab + + log.info("Using PyMeshLab Ball Pivoting...") + + ms = pymeshlab.MeshSet() + + if normals is not None and not do_estimate: + pml_mesh = pymeshlab.Mesh( + vertex_matrix=vertices, + v_normals_matrix=normals + ) + else: + pml_mesh = pymeshlab.Mesh(vertex_matrix=vertices) + + ms.add_mesh(pml_mesh) + + if normals is None or do_estimate: + ms.compute_normal_for_point_clouds(k=10) + + if ball_radius == 0.0: + ms.generate_surface_reconstruction_ball_pivoting() + else: + ms.generate_surface_reconstruction_ball_pivoting( + ballradius=pymeshlab.PercentageValue(ball_radius * 100) + ) + + result_mesh = ms.current_mesh() + result = trimesh_module.Trimesh( + vertices=result_mesh.vertex_matrix(), + faces=result_mesh.face_matrix(), + process=False + ) + + info = f"""Reconstruct Surface Results (Ball Pivoting): + +Engine: PyMeshLab +Ball Radius: {'auto' if ball_radius == 0.0 else f'{ball_radius:.3f}'} + +Input Points: {len(vertices):,} +Output Vertices: {len(result.vertices):,} +Output Faces: {len(result.faces):,} + +Ball pivoting preserves fine details but may have holes. +""" + # Preserve metadata + if hasattr(points, 'metadata') and points.metadata: + result.metadata = points.metadata.copy() + else: + result.metadata = {} + result.metadata['reconstruction'] = { + 'method': 'ball_pivoting', + 'input_points': len(vertices), + 'output_vertices': len(result.vertices), + 'output_faces': len(result.faces), + } + + return io.NodeOutput(result, info, ui={"text": [info]}) + + except ImportError: + raise ImportError( + "Ball pivoting requires PyMeshLab.\n" + "Install with: pip install pymeshlab" + ) + + +NODE_CLASS_MAPPINGS = {"GeomPackReconstruct_BallPivoting": ReconstructBallPivotingNode} +NODE_DISPLAY_NAME_MAPPINGS = {"GeomPackReconstruct_BallPivoting": "Reconstruct Ball Pivoting (backend)"} diff --git a/nodes/main/reconstruction/backends/convex_hull.py b/nodes/main/reconstruction/backends/convex_hull.py new file mode 100644 index 00000000..05b7215e --- /dev/null +++ b/nodes/main/reconstruction/backends/convex_hull.py @@ -0,0 +1,70 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2025 ComfyUI-GeometryPack Contributors + +"""Convex hull surface reconstruction backend node.""" + +import logging +import numpy as np +import trimesh as trimesh_module +from comfy_api.latest import io + +log = logging.getLogger("geometrypack") + + +class ReconstructConvexHullNode(io.ComfyNode): + """Simple convex hull reconstruction.""" + + @classmethod + def define_schema(cls): + return io.Schema( + node_id="GeomPackReconstruct_ConvexHull", + display_name="Reconstruct Convex Hull (backend)", + category="geompack/reconstruction", + is_dev_only=True, + is_output_node=True, + inputs=[ + io.Custom("TRIMESH").Input("points"), + ], + outputs=[ + io.Custom("TRIMESH").Output(display_name="reconstructed_mesh"), + io.String.Output(display_name="info"), + ], + ) + + @classmethod + def execute(cls, points): + log.info("Backend: convex_hull") + vertices = np.asarray(points.vertices, dtype=np.float64) + + log.info("Computing convex hull...") + + result = trimesh_module.Trimesh(vertices=vertices).convex_hull + + info = f"""Reconstruct Surface Results (Convex Hull): + +Input Points: {len(vertices):,} +Output Vertices: {len(result.vertices):,} +Output Faces: {len(result.faces):,} + +Watertight: {result.is_watertight} +Volume: {result.volume:.6f} + +Convex hull is fast but loses all concave features. +""" + # Preserve metadata + if hasattr(points, 'metadata') and points.metadata: + result.metadata = points.metadata.copy() + else: + result.metadata = {} + result.metadata['reconstruction'] = { + 'method': 'convex_hull', + 'input_points': len(vertices), + 'output_vertices': len(result.vertices), + 'output_faces': len(result.faces), + } + + return io.NodeOutput(result, info, ui={"text": [info]}) + + +NODE_CLASS_MAPPINGS = {"GeomPackReconstruct_ConvexHull": ReconstructConvexHullNode} +NODE_DISPLAY_NAME_MAPPINGS = {"GeomPackReconstruct_ConvexHull": "Reconstruct Convex Hull (backend)"} diff --git a/nodes/main/reconstruction/backends/delaunay_2d.py b/nodes/main/reconstruction/backends/delaunay_2d.py new file mode 100644 index 00000000..fd4d3731 --- /dev/null +++ b/nodes/main/reconstruction/backends/delaunay_2d.py @@ -0,0 +1,81 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2025 ComfyUI-GeometryPack Contributors + +"""2D Delaunay triangulation surface reconstruction backend node.""" + +import logging +import numpy as np +import trimesh as trimesh_module +from comfy_api.latest import io + +log = logging.getLogger("geometrypack") + + +class ReconstructDelaunay2DNode(io.ComfyNode): + """2D Delaunay triangulation (for height fields).""" + + @classmethod + def define_schema(cls): + return io.Schema( + node_id="GeomPackReconstruct_Delaunay2D", + display_name="Reconstruct Delaunay 2D (backend)", + category="geompack/reconstruction", + is_dev_only=True, + is_output_node=True, + inputs=[ + io.Custom("TRIMESH").Input("points"), + ], + outputs=[ + io.Custom("TRIMESH").Output(display_name="reconstructed_mesh"), + io.String.Output(display_name="info"), + ], + ) + + @classmethod + def execute(cls, points): + log.info("Backend: delaunay_2d") + vertices = np.asarray(points.vertices, dtype=np.float64) + + log.info("Computing 2D Delaunay triangulation...") + + try: + from scipy.spatial import Delaunay + + points_2d = vertices[:, :2] + tri = Delaunay(points_2d) + + result = trimesh_module.Trimesh( + vertices=vertices, + faces=tri.simplices, + process=False + ) + + info = f"""Reconstruct Surface Results (2D Delaunay): + +Input Points: {len(vertices):,} +Output Vertices: {len(result.vertices):,} +Output Faces: {len(result.faces):,} + +2D Delaunay projects points to XY plane for triangulation. +Best for height fields and terrain data. +""" + # Preserve metadata + if hasattr(points, 'metadata') and points.metadata: + result.metadata = points.metadata.copy() + else: + result.metadata = {} + result.metadata['reconstruction'] = { + 'method': 'delaunay_2d', + 'input_points': len(vertices), + 'output_vertices': len(result.vertices), + 'output_faces': len(result.faces), + } + + return io.NodeOutput(result, info, ui={"text": [info]}) + + except ImportError: + raise ImportError("2D Delaunay requires scipy. Install with: pip install scipy") + + +NODE_CLASS_MAPPINGS = {"GeomPackReconstruct_Delaunay2D": ReconstructDelaunay2DNode} +NODE_DISPLAY_NAME_MAPPINGS = {"GeomPackReconstruct_Delaunay2D": "Reconstruct Delaunay 2D (backend)"} diff --git a/nodes/main/reconstruction/backends/poisson.py b/nodes/main/reconstruction/backends/poisson.py new file mode 100644 index 00000000..769fec6f --- /dev/null +++ b/nodes/main/reconstruction/backends/poisson.py @@ -0,0 +1,193 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2025 ComfyUI-GeometryPack Contributors + +"""Poisson surface reconstruction backend node.""" + +import logging +import numpy as np +import trimesh as trimesh_module +from comfy_api.latest import io + +log = logging.getLogger("geometrypack") + + +class ReconstructPoissonNode(io.ComfyNode): + """Poisson surface reconstruction using Open3D or PyMeshLab.""" + + @classmethod + def define_schema(cls): + return io.Schema( + node_id="GeomPackReconstruct_Poisson", + display_name="Reconstruct Poisson (backend)", + category="geompack/reconstruction", + is_dev_only=True, + is_output_node=True, + inputs=[ + io.Custom("TRIMESH").Input("points"), + io.Int.Input("poisson_depth", default=8, min=1, max=12, step=1), + io.Float.Input("poisson_scale", default=1.1, min=1.0, max=2.0, step=0.1), + io.Combo.Input("estimate_normals", options=["true", "false"], default="true"), + io.Float.Input("normal_radius", default=0.1, min=0.001, max=10.0, step=0.01), + ], + outputs=[ + io.Custom("TRIMESH").Output(display_name="reconstructed_mesh"), + io.String.Output(display_name="info"), + ], + ) + + @classmethod + def execute(cls, points, poisson_depth=8, poisson_scale=1.1, + estimate_normals="true", normal_radius=0.1): + log.info("Backend: poisson") + vertices = np.asarray(points.vertices, dtype=np.float64) + normals = None + if hasattr(points, 'vertex_normals') and len(points.vertex_normals) > 0: + normals = np.asarray(points.vertex_normals, dtype=np.float64) + log.info("Using normals from input") + + do_estimate = estimate_normals == "true" + depth = poisson_depth + scale = poisson_scale + + # Try Open3D first + try: + import open3d as o3d + + log.info("Using Open3D Poisson reconstruction...") + log.info("Step 1/5: Creating point cloud...") + + pcd = o3d.geometry.PointCloud() + pcd.points = o3d.utility.Vector3dVector(vertices) + + log.info("Step 2/5: Estimating normals...") + if normals is None or do_estimate: + pcd.estimate_normals( + search_param=o3d.geometry.KDTreeSearchParamHybrid( + radius=normal_radius, max_nn=30 + ) + ) + log.info("Step 3/5: Orienting normals...") + pcd.orient_normals_consistent_tangent_plane(k=10) + else: + pcd.normals = o3d.utility.Vector3dVector(normals) + + log.info("Step 4/5: Running Poisson reconstruction (depth=%d)... This may take a while.", depth) + mesh_o3d, densities = o3d.geometry.TriangleMesh.create_from_point_cloud_poisson( + pcd, depth=depth, scale=scale, linear_fit=False + ) + + # Remove low density vertices (noise) + log.info("Step 5/5: Cleaning up mesh...") + densities = np.asarray(densities) + density_threshold = np.quantile(densities, 0.01) + vertices_to_remove = densities < density_threshold + mesh_o3d.remove_vertices_by_mask(vertices_to_remove) + + result = trimesh_module.Trimesh( + vertices=np.asarray(mesh_o3d.vertices), + faces=np.asarray(mesh_o3d.triangles), + process=False + ) + + log.info("Done! Output: %s vertices, %s faces", + f"{len(result.vertices):,}", f"{len(result.faces):,}") + + info = f"""Reconstruct Surface Results (Poisson): + +Engine: Open3D +Depth: {depth} +Scale: {scale} + +Input Points: {len(vertices):,} +Output Vertices: {len(result.vertices):,} +Output Faces: {len(result.faces):,} + +Watertight: {result.is_watertight} + +Poisson reconstruction creates smooth, watertight surfaces. +""" + # Preserve metadata + if hasattr(points, 'metadata') and points.metadata: + result.metadata = points.metadata.copy() + else: + result.metadata = {} + result.metadata['reconstruction'] = { + 'method': 'poisson', + 'input_points': len(vertices), + 'output_vertices': len(result.vertices), + 'output_faces': len(result.faces), + } + + return io.NodeOutput(result, info, ui={"text": [info]}) + + except ImportError: + pass + + # Fallback to PyMeshLab + try: + import pymeshlab + + log.info("Using PyMeshLab Poisson reconstruction...") + + ms = pymeshlab.MeshSet() + + if normals is not None and not do_estimate: + pml_mesh = pymeshlab.Mesh( + vertex_matrix=vertices, + v_normals_matrix=normals + ) + else: + pml_mesh = pymeshlab.Mesh(vertex_matrix=vertices) + + ms.add_mesh(pml_mesh) + + if normals is None or do_estimate: + ms.compute_normal_for_point_clouds(k=10) + + ms.generate_surface_reconstruction_screened_poisson( + depth=depth, + scale=scale + ) + + result_mesh = ms.current_mesh() + result = trimesh_module.Trimesh( + vertices=result_mesh.vertex_matrix(), + faces=result_mesh.face_matrix(), + process=False + ) + + info = f"""Reconstruct Surface Results (Poisson): + +Engine: PyMeshLab +Depth: {depth} +Scale: {scale} + +Input Points: {len(vertices):,} +Output Vertices: {len(result.vertices):,} +Output Faces: {len(result.faces):,} + +Watertight: {result.is_watertight} +""" + # Preserve metadata + if hasattr(points, 'metadata') and points.metadata: + result.metadata = points.metadata.copy() + else: + result.metadata = {} + result.metadata['reconstruction'] = { + 'method': 'poisson', + 'input_points': len(vertices), + 'output_vertices': len(result.vertices), + 'output_faces': len(result.faces), + } + + return io.NodeOutput(result, info, ui={"text": [info]}) + + except ImportError: + raise ImportError( + "Poisson reconstruction requires Open3D or PyMeshLab.\n" + "Install with: pip install open3d or pip install pymeshlab" + ) + + +NODE_CLASS_MAPPINGS = {"GeomPackReconstruct_Poisson": ReconstructPoissonNode} +NODE_DISPLAY_NAME_MAPPINGS = {"GeomPackReconstruct_Poisson": "Reconstruct Poisson (backend)"} diff --git a/nodes/main/reconstruction/reconstruct_surface.py b/nodes/main/reconstruction/reconstruct_surface.py index 9faa6fe7..61ac5a3c 100644 --- a/nodes/main/reconstruction/reconstruct_surface.py +++ b/nodes/main/reconstruction/reconstruct_surface.py @@ -2,55 +2,79 @@ # Copyright (C) 2025 ComfyUI-GeometryPack Contributors """ -Surface Reconstruction Node - Point cloud to mesh conversion +Unified Reconstruct Surface Node - Single frontend with method selector. + +Uses ComfyUI's node expansion (GraphBuilder) to dispatch to hidden +backend-specific nodes, each running in its own isolation env. """ import logging -import numpy as np -import trimesh as trimesh_module from comfy_api.latest import io log = logging.getLogger("geometrypack") + class ReconstructSurfaceNode(io.ComfyNode): """ Reconstruct Surface - Convert point cloud to mesh. - Accepts TRIMESH objects (including point clouds from MeshToPointCloud node). - Point clouds are represented as TRIMESH with vertices only (0 faces). - - Multiple reconstruction algorithms: - - poisson: Screened Poisson surface reconstruction (smooth, watertight) - - ball_pivoting: Ball pivoting algorithm (preserves detail) - - alpha_shape: Alpha shape reconstruction - - convex_hull: Simple convex hull (fast, rough) - - delaunay_2d: 2D Delaunay triangulation (for height fields) - - Requires Open3D or PyMeshLab for advanced methods. + Dispatches to hidden backend nodes via node expansion. """ + BACKEND_MAP = { + "poisson": "GeomPackReconstruct_Poisson", + "ball_pivoting": "GeomPackReconstruct_BallPivoting", + "alpha_shape": "GeomPackReconstruct_AlphaShape", + "convex_hull": "GeomPackReconstruct_ConvexHull", + "delaunay_2d": "GeomPackReconstruct_Delaunay2D", + "alpha_wrap": "GeomPackAlphaWrap", + } + + # Remap frontend param names to backend param names where they differ + PARAM_REMAP = { + "alpha_wrap": {"points": "input_mesh"}, + } + @classmethod def define_schema(cls): return io.Schema( node_id="GeomPackReconstructSurface", display_name="Reconstruct Surface", category="geompack/reconstruction", + enable_expand=True, is_output_node=True, inputs=[ io.Custom("TRIMESH").Input("points"), - io.Combo.Input("method", options=[ - "poisson", - "ball_pivoting", - "alpha_shape", - "convex_hull", - "delaunay_2d" - ], default="poisson"), - io.Int.Input("poisson_depth", default=8, min=1, max=12, step=1, optional=True), - io.Float.Input("poisson_scale", default=1.1, min=1.0, max=2.0, step=0.1, optional=True), - io.Float.Input("ball_radius", default=0.0, min=0.0, max=100.0, step=0.01, optional=True), - io.Float.Input("alpha", default=0.0, min=0.0, max=100.0, step=0.01, optional=True), - io.Combo.Input("estimate_normals", options=["true", "false"], default="true", optional=True), - io.Float.Input("normal_radius", default=0.1, min=0.001, max=10.0, step=0.01, optional=True), + io.DynamicCombo.Input("method", tooltip=( + "Reconstruction algorithm. " + "poisson=smooth watertight, " + "ball_pivoting=preserves detail, " + "alpha_shape=controllable detail, " + "convex_hull=fast rough, " + "delaunay_2d=height fields, " + "alpha_wrap=shrink wrap (CGAL)" + ), options=[ + io.DynamicCombo.Option("poisson", [ + io.Int.Input("poisson_depth", default=8, min=1, max=12, step=1, tooltip="Octree depth for Poisson reconstruction. Higher = more detail, slower."), + io.Float.Input("poisson_scale", default=1.1, min=1.0, max=2.0, step=0.1, tooltip="Scale factor for reconstruction grid."), + io.Combo.Input("estimate_normals", options=["true", "false"], default="true", tooltip="Estimate normals from point cloud."), + io.Float.Input("normal_radius", default=0.1, min=0.001, max=10.0, step=0.01, tooltip="Search radius for normal estimation."), + ]), + io.DynamicCombo.Option("ball_pivoting", [ + io.Float.Input("ball_radius", default=0.0, min=0.0, max=100.0, step=0.01, tooltip="Ball radius for pivoting. 0 = auto."), + io.Combo.Input("estimate_normals", options=["true", "false"], default="true", tooltip="Estimate normals from point cloud."), + io.Float.Input("normal_radius", default=0.1, min=0.001, max=10.0, step=0.01, tooltip="Search radius for normal estimation."), + ]), + io.DynamicCombo.Option("alpha_shape", [ + io.Float.Input("alpha", default=0.0, min=0.0, max=100.0, step=0.01, tooltip="Alpha value. 0 = auto (10%% of bbox diagonal)."), + ]), + io.DynamicCombo.Option("convex_hull", []), + io.DynamicCombo.Option("delaunay_2d", []), + io.DynamicCombo.Option("alpha_wrap", [ + io.Float.Input("alpha_percent", default=2.0, min=0.001, max=50.0, step=0.1, tooltip="Wrap tightness as %% of bounding box diagonal. Smaller = tighter wrap with more detail, but MUCH slower."), + io.Float.Input("offset_percent", default=2.0, min=0.01, max=10.0, step=0.1, tooltip="Surface offset as %% of bounding box diagonal. Smaller = closer to original surface."), + ]), + ]), ], outputs=[ io.Custom("TRIMESH").Output(display_name="reconstructed_mesh"), @@ -59,385 +83,34 @@ class ReconstructSurfaceNode(io.ComfyNode): ) @classmethod - def execute(cls, points, method, - poisson_depth=8, poisson_scale=1.1, - ball_radius=0.0, alpha=0.0, - estimate_normals="true", normal_radius=0.1): - """ - Reconstruct surface from point cloud. + def execute(cls, points, method): + from comfy_execution.graph_utils import GraphBuilder - Args: - points: Point cloud as TRIMESH (may have 0 faces for pure point clouds) - method: Reconstruction algorithm - [other params]: Method-specific parameters + if cls.SCHEMA is None: + cls.GET_SCHEMA() - Returns: - tuple: (reconstructed_mesh, info_string) - """ - # Extract vertices from TRIMESH - if not hasattr(points, 'vertices'): - raise ValueError("Input must be a TRIMESH object with vertices") + selected = method["method"] + node_id = cls.BACKEND_MAP[selected] - vertices = points.vertices - normals = None + log.info("Reconstruct dispatch: %s -> %s", selected, node_id) - # Extract normals if available - if hasattr(points, 'vertex_normals') and len(points.vertex_normals) > 0: - normals = points.vertex_normals - log.info("Using normals from input") + # Build kwargs, applying param remaps for cross-env backends + remap = cls.PARAM_REMAP.get(selected, {}) + kwargs = {remap.get("points", "points"): points} + for k, v in method.items(): + if k == "method": + continue + kwargs[remap.get(k, k)] = v - # Check if this is a point cloud - is_point_cloud = False - face_count = len(points.faces) if hasattr(points, 'faces') and points.faces is not None else 0 + graph = GraphBuilder() + backend_node = graph.node(node_id, **kwargs) - if face_count == 0 or (hasattr(points, 'metadata') and points.metadata.get('is_point_cloud', False)): - is_point_cloud = True - log.info("Input type: Point cloud (%d points)", len(vertices)) - else: - log.info("Input type: TRIMESH (%d vertices, %d faces)", len(vertices), face_count) - - log.info("Method: %s", method) - - if method == "poisson": - result, info = cls._poisson(vertices, normals, poisson_depth, poisson_scale, - estimate_normals == "true", normal_radius) - elif method == "ball_pivoting": - result, info = cls._ball_pivoting(vertices, normals, ball_radius, - estimate_normals == "true", normal_radius) - elif method == "alpha_shape": - result, info = cls._alpha_shape(vertices, alpha) - elif method == "convex_hull": - result, info = cls._convex_hull(vertices) - elif method == "delaunay_2d": - result, info = cls._delaunay_2d(vertices) - else: - raise ValueError(f"Unknown method: {method}") - - # Preserve metadata - if hasattr(points, 'metadata'): - result.metadata = points.metadata.copy() - else: - result.metadata = {} - - # Add reconstruction info - result.metadata['reconstruction'] = { - 'method': method, - 'was_point_cloud': is_point_cloud, - 'input_points': len(vertices), - 'output_vertices': len(result.vertices), - 'output_faces': len(result.faces) + return { + "result": (backend_node.out(0), backend_node.out(1)), + "expand": graph.finalize(), } - log.info("Output: %d vertices, %d faces", len(result.vertices), len(result.faces)) - return io.NodeOutput(result, info, ui={"text": [info]}) - @staticmethod - def _poisson(vertices, normals, depth, scale, estimate_normals, normal_radius): - """Poisson surface reconstruction using Open3D or PyMeshLab.""" - # Try Open3D first - try: - import open3d as o3d - - log.info("Using Open3D Poisson reconstruction...") - log.info("Step 1/5: Creating point cloud...") - - # Create point cloud - pcd = o3d.geometry.PointCloud() - pcd.points = o3d.utility.Vector3dVector(vertices) - - # Estimate normals if needed - log.info("Step 2/5: Estimating normals...") - if normals is None or estimate_normals: - pcd.estimate_normals( - search_param=o3d.geometry.KDTreeSearchParamHybrid( - radius=normal_radius, max_nn=30 - ) - ) - log.info("Step 3/5: Orienting normals...") - pcd.orient_normals_consistent_tangent_plane(k=10) - else: - pcd.normals = o3d.utility.Vector3dVector(normals) - - # Poisson reconstruction - log.info("Step 4/5: Running Poisson reconstruction (depth=%d)... This may take a while.", depth) - mesh_o3d, densities = o3d.geometry.TriangleMesh.create_from_point_cloud_poisson( - pcd, depth=depth, scale=scale, linear_fit=False - ) - - # Remove low density vertices (noise) - log.info("Step 5/5: Cleaning up mesh...") - densities = np.asarray(densities) - density_threshold = np.quantile(densities, 0.01) - vertices_to_remove = densities < density_threshold - mesh_o3d.remove_vertices_by_mask(vertices_to_remove) - - # Convert to trimesh - result = trimesh_module.Trimesh( - vertices=np.asarray(mesh_o3d.vertices), - faces=np.asarray(mesh_o3d.triangles), - process=False - ) - - log.info("Done! Output: %s vertices, %s faces", f"{len(result.vertices):,}", f"{len(result.faces):,}") - - info = f"""Reconstruct Surface Results (Poisson): - -Engine: Open3D -Depth: {depth} -Scale: {scale} - -Input Points: {len(vertices):,} -Output Vertices: {len(result.vertices):,} -Output Faces: {len(result.faces):,} - -Watertight: {result.is_watertight} - -Poisson reconstruction creates smooth, watertight surfaces. -""" - return result, info - - except ImportError: - pass - - # Fallback to PyMeshLab - try: - import pymeshlab - - log.info("Using PyMeshLab Poisson reconstruction...") - - ms = pymeshlab.MeshSet() - - # Create point cloud mesh - if normals is not None and not estimate_normals: - pml_mesh = pymeshlab.Mesh( - vertex_matrix=vertices, - v_normals_matrix=normals - ) - else: - pml_mesh = pymeshlab.Mesh(vertex_matrix=vertices) - - ms.add_mesh(pml_mesh) - - # Estimate normals if needed - if normals is None or estimate_normals: - ms.compute_normal_for_point_clouds(k=10) - - # Poisson reconstruction - ms.generate_surface_reconstruction_screened_poisson( - depth=depth, - scale=scale - ) - - # Get result - result_mesh = ms.current_mesh() - result = trimesh_module.Trimesh( - vertices=result_mesh.vertex_matrix(), - faces=result_mesh.face_matrix(), - process=False - ) - - info = f"""Reconstruct Surface Results (Poisson): - -Engine: PyMeshLab -Depth: {depth} -Scale: {scale} - -Input Points: {len(vertices):,} -Output Vertices: {len(result.vertices):,} -Output Faces: {len(result.faces):,} - -Watertight: {result.is_watertight} -""" - return result, info - - except ImportError: - raise ImportError( - "Poisson reconstruction requires Open3D or PyMeshLab.\n" - "Install with: pip install open3d or pip install pymeshlab" - ) - - @staticmethod - def _ball_pivoting(vertices, normals, ball_radius, estimate_normals, normal_radius): - """Ball pivoting algorithm using PyMeshLab.""" - try: - import pymeshlab - - log.info("Using PyMeshLab Ball Pivoting...") - - ms = pymeshlab.MeshSet() - - # Create point cloud mesh - if normals is not None and not estimate_normals: - pml_mesh = pymeshlab.Mesh( - vertex_matrix=vertices, - v_normals_matrix=normals - ) - else: - pml_mesh = pymeshlab.Mesh(vertex_matrix=vertices) - - ms.add_mesh(pml_mesh) - - # Estimate normals if needed - if normals is None or estimate_normals: - ms.compute_normal_for_point_clouds(k=10) - - # Ball pivoting reconstruction - if ball_radius == 0.0: - # Auto radius based on point cloud density - ms.generate_surface_reconstruction_ball_pivoting() - else: - ms.generate_surface_reconstruction_ball_pivoting( - ballradius=pymeshlab.PercentageValue(ball_radius * 100) - ) - - result_mesh = ms.current_mesh() - result = trimesh_module.Trimesh( - vertices=result_mesh.vertex_matrix(), - faces=result_mesh.face_matrix(), - process=False - ) - - info = f"""Reconstruct Surface Results (Ball Pivoting): - -Engine: PyMeshLab -Ball Radius: {'auto' if ball_radius == 0.0 else f'{ball_radius:.3f}'} - -Input Points: {len(vertices):,} -Output Vertices: {len(result.vertices):,} -Output Faces: {len(result.faces):,} - -Ball pivoting preserves fine details but may have holes. -""" - return result, info - - except ImportError: - raise ImportError( - "Ball pivoting requires PyMeshLab.\n" - "Install with: pip install pymeshlab" - ) - - @staticmethod - def _alpha_shape(vertices, alpha_value): - """Alpha shape reconstruction.""" - log.info("Computing alpha shape...") - - if alpha_value == 0.0: - # Auto alpha: use 10% of bounding box diagonal - bbox_diag = np.linalg.norm(vertices.max(axis=0) - vertices.min(axis=0)) - alpha_value = bbox_diag * 0.1 - log.info("Auto alpha: %.4f", alpha_value) - - # Use trimesh's alpha shape - try: - from scipy.spatial import Delaunay - - # Compute Delaunay triangulation - tri = Delaunay(vertices) - - # Filter simplices by alpha criterion - # Alpha shape: keep tetrahedra with circumradius < alpha - valid_faces = [] - for simplex in tri.simplices: - # Get tetrahedron vertices - tet_verts = vertices[simplex] - - # Compute circumradius (approximation using edge lengths) - edges = [] - for i in range(4): - for j in range(i + 1, 4): - edges.append(np.linalg.norm(tet_verts[i] - tet_verts[j])) - max_edge = max(edges) - - if max_edge < alpha_value * 2: - # Add faces of this tetrahedron - for i in range(4): - face = tuple(sorted([simplex[j] for j in range(4) if j != i])) - valid_faces.append(face) - - # Remove duplicate faces (interior faces appear twice) - from collections import Counter - face_counts = Counter(valid_faces) - boundary_faces = [list(f) for f, count in face_counts.items() if count == 1] - - if len(boundary_faces) == 0: - raise ValueError("Alpha value too small, no faces generated") - - result = trimesh_module.Trimesh( - vertices=vertices, - faces=boundary_faces, - process=True - ) - - info = f"""Reconstruct Surface Results (Alpha Shape): - -Alpha Value: {alpha_value:.4f} - -Input Points: {len(vertices):,} -Output Vertices: {len(result.vertices):,} -Output Faces: {len(result.faces):,} - -Alpha shapes capture the overall shape with controllable detail level. -""" - return result, info - - except ImportError: - raise ImportError("Alpha shape requires scipy. Install with: pip install scipy") - - @staticmethod - def _convex_hull(vertices): - """Simple convex hull reconstruction.""" - log.info("Computing convex hull...") - - # Use trimesh's convex hull - result = trimesh_module.Trimesh(vertices=vertices).convex_hull - - info = f"""Reconstruct Surface Results (Convex Hull): - -Input Points: {len(vertices):,} -Output Vertices: {len(result.vertices):,} -Output Faces: {len(result.faces):,} - -Watertight: {result.is_watertight} -Volume: {result.volume:.6f} - -Convex hull is fast but loses all concave features. -""" - return result, info - - @staticmethod - def _delaunay_2d(vertices): - """2D Delaunay triangulation (for height fields).""" - log.info("Computing 2D Delaunay triangulation...") - - try: - from scipy.spatial import Delaunay - - # Project to XY plane for triangulation - points_2d = vertices[:, :2] - tri = Delaunay(points_2d) - - # Create mesh with original 3D coordinates - result = trimesh_module.Trimesh( - vertices=vertices, - faces=tri.simplices, - process=False - ) - - info = f"""Reconstruct Surface Results (2D Delaunay): - -Input Points: {len(vertices):,} -Output Vertices: {len(result.vertices):,} -Output Faces: {len(result.faces):,} - -2D Delaunay projects points to XY plane for triangulation. -Best for height fields and terrain data. -""" - return result, info - - except ImportError: - raise ImportError("2D Delaunay requires scipy. Install with: pip install scipy") - -# Node mappings NODE_CLASS_MAPPINGS = { "GeomPackReconstructSurface": ReconstructSurfaceNode, } diff --git a/nodes/main/repair/__init__.py b/nodes/main/repair/__init__.py index c44cb874..6171296e 100644 --- a/nodes/main/repair/__init__.py +++ b/nodes/main/repair/__init__.py @@ -12,6 +12,7 @@ from .visualize_normals import NODE_CLASS_MAPPINGS as VISUALIZE_NORMALS_MAPS, NO from .add_normals_to_pointcloud import NODE_CLASS_MAPPINGS as ADD_NORMALS_PC_MAPS, NODE_DISPLAY_NAME_MAPPINGS as ADD_NORMALS_PC_DISP from .fill_holes import NODE_CLASS_MAPPINGS as FILL_HOLES_MAPS, NODE_DISPLAY_NAME_MAPPINGS as FILL_HOLES_DISP from .backends import NODE_CLASS_MAPPINGS as FILL_HOLES_BACKENDS_MAPS, NODE_DISPLAY_NAME_MAPPINGS as FILL_HOLES_BACKENDS_DISP +from .fix_normals_backends import NODE_CLASS_MAPPINGS as FIX_NORMALS_BACKENDS_MAPS, NODE_DISPLAY_NAME_MAPPINGS as FIX_NORMALS_BACKENDS_DISP from .merge_vertices import NODE_CLASS_MAPPINGS as MERGE_VERTICES_MAPS, NODE_DISPLAY_NAME_MAPPINGS as MERGE_VERTICES_DISP from .fix_intersections_removal import NODE_CLASS_MAPPINGS as FIX_INTERSECTIONS_REMOVAL_MAPS, NODE_DISPLAY_NAME_MAPPINGS as FIX_INTERSECTIONS_REMOVAL_DISP from .remove_degenerate_faces import NODE_CLASS_MAPPINGS as REMOVE_DEGENERATE_MAPS, NODE_DISPLAY_NAME_MAPPINGS as REMOVE_DEGENERATE_DISP @@ -34,6 +35,8 @@ NODE_CLASS_MAPPINGS.update(FILL_HOLES_MAPS) NODE_DISPLAY_NAME_MAPPINGS.update(FILL_HOLES_DISP) NODE_CLASS_MAPPINGS.update(FILL_HOLES_BACKENDS_MAPS) NODE_DISPLAY_NAME_MAPPINGS.update(FILL_HOLES_BACKENDS_DISP) +NODE_CLASS_MAPPINGS.update(FIX_NORMALS_BACKENDS_MAPS) +NODE_DISPLAY_NAME_MAPPINGS.update(FIX_NORMALS_BACKENDS_DISP) NODE_CLASS_MAPPINGS.update(MERGE_VERTICES_MAPS) NODE_DISPLAY_NAME_MAPPINGS.update(MERGE_VERTICES_DISP) NODE_CLASS_MAPPINGS.update(FIX_INTERSECTIONS_REMOVAL_MAPS) diff --git a/nodes/main/repair/fix_normals.py b/nodes/main/repair/fix_normals.py index bce52ba3..52739cd3 100644 --- a/nodes/main/repair/fix_normals.py +++ b/nodes/main/repair/fix_normals.py @@ -2,159 +2,32 @@ # Copyright (C) 2025 ComfyUI-GeometryPack Contributors """ -Fix inconsistent normal orientations. +Unified Fix Normals Node - Single frontend with backend selector. + +Uses ComfyUI's node expansion (GraphBuilder) to dispatch to hidden +backend-specific nodes, each running in its own isolation env. """ import logging - -import trimesh -import numpy as np - -log = logging.getLogger("geometrypack") - -try: - import igl - HAS_IGL = True -except ImportError: - HAS_IGL = False from comfy_api.latest import io - -def _orient_outward_winding(V, F, face_normals): - """ - Use fast winding number to orient faces outward. - - For each face, query winding number at a point offset slightly along - the negative normal. If winding > 0.5, point is inside mesh = normal - points outward (correct). If winding < 0.5, normal points inward (flip). - - Args: - V: Vertices array (N, 3) - F: Faces array (M, 3) - face_normals: Face normals array (M, 3) - - Returns: - tuple: (F_out, flip_mask, num_flipped) - """ - # Compute face centroids - face_centroids = (V[F[:, 0]] + V[F[:, 1]] + V[F[:, 2]]) / 3.0 - - # Adaptive epsilon based on mesh scale - bbox_diag = np.linalg.norm(V.max(axis=0) - V.min(axis=0)) - eps = 1e-4 * bbox_diag - - # Query points offset slightly inside (along -normal direction) - query_inside = face_centroids - face_normals * eps - - # Compute winding numbers - W = igl.fast_winding_number(V, F, query_inside) - - # If winding < 0.5, the query point is outside, meaning normal points inward - flip_mask = W < 0.5 - - # Flip faces by reversing vertex order - F_out = F.copy() - F_out[flip_mask] = F_out[flip_mask][:, [0, 2, 1]] - - return F_out, flip_mask, np.sum(flip_mask) - - -def _orient_outward_raycast(V, F, face_normals): - """ - Use ray-mesh intersection to orient faces outward (odd/even test). - - Cast ray from face centroid along normal direction. Odd number of - intersections = pointing inward (flip needed). - - Args: - V: Vertices array (N, 3) - F: Faces array (M, 3) - face_normals: Face normals array (M, 3) - - Returns: - tuple: (F_out, flip_mask, num_flipped) - """ - # Compute face centroids - face_centroids = (V[F[:, 0]] + V[F[:, 1]] + V[F[:, 2]]) / 3.0 - - # Small offset to avoid self-intersection - eps = 1e-6 - - flip_mask = np.zeros(len(F), dtype=bool) - - for i in range(len(F)): - origin = face_centroids[i] + face_normals[i] * eps - direction = face_normals[i] - - # Cast ray and get hits - hits = igl.ray_mesh_intersect( - np.ascontiguousarray(origin, dtype=np.float64), - np.ascontiguousarray(direction, dtype=np.float64), - V, F - ) - - # Odd number of hits = pointing inward - if hits is not None and len(hits) % 2 == 1: - flip_mask[i] = True - - # Flip faces by reversing vertex order - F_out = F.copy() - F_out[flip_mask] = F_out[flip_mask][:, [0, 2, 1]] - - return F_out, flip_mask, np.sum(flip_mask) - - -def _orient_outward_signed_dist(V, F, face_normals): - """ - Use signed distance to orient faces outward. - - Query signed distance at points offset along normal direction. - Positive distance = inside surface = flip needed. - - Args: - V: Vertices array (N, 3) - F: Faces array (M, 3) - face_normals: Face normals array (M, 3) - - Returns: - tuple: (F_out, flip_mask, num_flipped) - """ - # Compute face centroids - face_centroids = (V[F[:, 0]] + V[F[:, 1]] + V[F[:, 2]]) / 3.0 - - # Adaptive epsilon based on mesh scale - bbox_diag = np.linalg.norm(V.max(axis=0) - V.min(axis=0)) - eps = 1e-4 * bbox_diag - - # Query points offset along normal direction (outside if normal correct) - query_points = face_centroids + face_normals * eps - - # Compute signed distance using pseudonormal method - S, I, C, N = igl.signed_distance( - np.ascontiguousarray(query_points, dtype=np.float64), - V, F, - igl.SIGNED_DISTANCE_TYPE_PSEUDONORMAL - ) - - # Positive signed distance = inside mesh = normal points inward - flip_mask = S > 0 - - # Flip faces by reversing vertex order - F_out = F.copy() - F_out[flip_mask] = F_out[flip_mask][:, [0, 2, 1]] - - return F_out, flip_mask, np.sum(flip_mask) +log = logging.getLogger("geometrypack") class FixNormalsNode(io.ComfyNode): """ - Fix inconsistent normal orientations. + Fix Normals - Unified normal fixing with backend selection. - Ensures all face normals point consistently (all outward or all inward). - Uses graph traversal to propagate consistent orientation across the trimesh. - Essential for proper rendering and boolean operations. + Dispatches to hidden backend nodes via node expansion. """ + BACKEND_MAP = { + "trimesh": "GeomPackFixNormals_Trimesh", + "igl_bfs": "GeomPackFixNormals_IglBfs", + "igl_winding": "GeomPackFixNormals_IglWinding", + "igl_raycast": "GeomPackFixNormals_IglRaycast", + "igl_signed_dist": "GeomPackFixNormals_IglSignedDist", + } @classmethod def define_schema(cls): @@ -162,10 +35,17 @@ class FixNormalsNode(io.ComfyNode): node_id="GeomPackFixNormals", display_name="Fix Normals", category="geompack/repair", + enable_expand=True, is_output_node=True, inputs=[ io.Custom("TRIMESH").Input("trimesh"), - io.Combo.Input("method", options=["trimesh", "igl_bfs", "igl_winding", "igl_raycast", "igl_signed_dist"], default="trimesh"), + io.DynamicCombo.Input("method", tooltip="Normal fixing algorithm and backend", options=[ + io.DynamicCombo.Option("trimesh", []), + io.DynamicCombo.Option("igl_bfs", []), + io.DynamicCombo.Option("igl_winding", []), + io.DynamicCombo.Option("igl_raycast", []), + io.DynamicCombo.Option("igl_signed_dist", []), + ]), ], outputs=[ io.Custom("TRIMESH").Output(display_name="fixed_mesh"), @@ -174,135 +54,32 @@ class FixNormalsNode(io.ComfyNode): ) @classmethod - def execute(cls, trimesh, method="trimesh"): - """ - Fix inconsistent face normal orientations. + def execute(cls, trimesh, method): + from comfy_execution.graph_utils import GraphBuilder - Args: - trimesh: Input trimesh.Trimesh object - method: Orientation method + if cls.SCHEMA is None: + cls.GET_SCHEMA() - Methods: - - trimesh: Basic trimesh fix_normals - - igl_bfs: BFS-based consistent orientation (best for thin/open surfaces) - - igl_winding: Fast winding number outward orientation (closed volumes) - - igl_raycast: Ray-mesh intersection odd/even test (closed volumes) - - igl_signed_dist: Signed distance pseudonormal (closed volumes) + selected = method["method"] + node_id = cls.BACKEND_MAP[selected] - Returns: - tuple: (fixed_trimesh, info_string) - """ - log.info("Input: %d vertices, %d faces", len(trimesh.vertices), len(trimesh.faces)) + log.info("Fix Normals dispatch: %s -> %s", selected, node_id) - # Create a copy to avoid modifying the original - fixed_mesh = trimesh.copy() + kwargs = {"trimesh": trimesh} + for k, v in method.items(): + if k == "method": + continue + kwargs[k] = v - # Check initial winding consistency - was_consistent = fixed_mesh.is_winding_consistent + graph = GraphBuilder() + backend_node = graph.node(node_id, **kwargs) - # Fix normals using selected method - method_used = method - num_components = None - num_flipped = None - extra_info = "" - - # Check if igl is required but not available - igl_methods = ["igl_bfs", "igl_winding", "igl_raycast", "igl_signed_dist"] - if method in igl_methods and not HAS_IGL: - log.warning("igl not available, falling back to trimesh method") - fixed_mesh.fix_normals() - method_used = "trimesh (fallback - igl not available)" - - elif method == "igl_bfs": - # Use libigl's BFS-based orientation (best for thin/open surfaces) - V = np.asarray(fixed_mesh.vertices, dtype=np.float64) - F = np.asarray(fixed_mesh.faces, dtype=np.int64) - - # Orient faces using BFS - FF, C = igl.bfs_orient(F) - - # Update mesh faces with oriented version - fixed_mesh.faces = FF - - # Track number of orientation components - num_components = len(np.unique(C)) - log.info("igl.bfs_orient: %d orientation components", num_components) - extra_info = "\nNote: BFS makes faces consistent but doesn't determine inside/outside" - - elif method == "igl_winding": - # Use fast winding number to orient outward (best for closed volumes) - V = np.asarray(fixed_mesh.vertices, dtype=np.float64) - F = np.asarray(fixed_mesh.faces, dtype=np.int64) - - # Compute face normals first - face_normals = igl.per_face_normals(V, F, np.array([1., 1., 1.])) - - # Orient faces outward using winding number - FF, flip_mask, num_flipped = _orient_outward_winding(V, F, face_normals) - fixed_mesh.faces = FF - - log.info("igl_winding: flipped %d/%d faces", num_flipped, len(F)) - extra_info = "\nNote: Winding number works best on closed/watertight meshes" - - elif method == "igl_raycast": - # Use ray-mesh intersection odd/even test - V = np.asarray(fixed_mesh.vertices, dtype=np.float64) - F = np.asarray(fixed_mesh.faces, dtype=np.int64) - - # Compute face normals first - face_normals = igl.per_face_normals(V, F, np.array([1., 1., 1.])) - - # Orient faces outward using raycasting - FF, flip_mask, num_flipped = _orient_outward_raycast(V, F, face_normals) - fixed_mesh.faces = FF - - log.info("igl_raycast: flipped %d/%d faces", num_flipped, len(F)) - extra_info = "\nNote: Raycasting works best on closed meshes without self-intersections" - - elif method == "igl_signed_dist": - # Use signed distance with pseudonormal - V = np.asarray(fixed_mesh.vertices, dtype=np.float64) - F = np.asarray(fixed_mesh.faces, dtype=np.int64) - - # Compute face normals first - face_normals = igl.per_face_normals(V, F, np.array([1., 1., 1.])) - - # Orient faces outward using signed distance - FF, flip_mask, num_flipped = _orient_outward_signed_dist(V, F, face_normals) - fixed_mesh.faces = FF - - log.info("igl_signed_dist: flipped %d/%d faces", num_flipped, len(F)) - extra_info = "\nNote: Signed distance works best on watertight meshes" - - else: - # Use trimesh's built-in method - fixed_mesh.fix_normals() - - # Check if it's now consistent - is_consistent = fixed_mesh.is_winding_consistent - - # Build info string - components_info = f"\nOrientation Components: {num_components}" if num_components is not None else "" - flipped_info = f"\nFaces Flipped: {num_flipped}" if num_flipped is not None else "" - - info = f"""Normal Orientation Fix: - -Method: {method_used} -Before: {'Consistent' if was_consistent else 'Inconsistent'} -After: {'Consistent' if is_consistent else 'Inconsistent'}{components_info}{flipped_info} - -Vertices: {len(fixed_mesh.vertices):,} -Faces: {len(fixed_mesh.faces):,} -{extra_info} -{'[OK] Normals are now consistently oriented!' if is_consistent else '[WARN] Some inconsistencies may remain (check mesh topology)'} -""" - - log.info("Normal orientation: %s -> %s", was_consistent, is_consistent) - - return io.NodeOutput(fixed_mesh, info, ui={"text": [info]}) + return { + "result": (backend_node.out(0), backend_node.out(1)), + "expand": graph.finalize(), + } -# Node mappings NODE_CLASS_MAPPINGS = { "GeomPackFixNormals": FixNormalsNode, } diff --git a/nodes/main/repair/fix_normals_backends/__init__.py b/nodes/main/repair/fix_normals_backends/__init__.py new file mode 100644 index 00000000..1ffa2bc5 --- /dev/null +++ b/nodes/main/repair/fix_normals_backends/__init__.py @@ -0,0 +1,29 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2025 ComfyUI-GeometryPack Contributors + +""" +Individual fix-normals backend nodes (hidden from UI, invoked via unified Fix Normals node). +""" + +from .trimesh_fix import NODE_CLASS_MAPPINGS as TM_MAPS, NODE_DISPLAY_NAME_MAPPINGS as TM_DISP +from .igl_bfs import NODE_CLASS_MAPPINGS as BFS_MAPS, NODE_DISPLAY_NAME_MAPPINGS as BFS_DISP +from .igl_winding import NODE_CLASS_MAPPINGS as WIND_MAPS, NODE_DISPLAY_NAME_MAPPINGS as WIND_DISP +from .igl_raycast import NODE_CLASS_MAPPINGS as RAY_MAPS, NODE_DISPLAY_NAME_MAPPINGS as RAY_DISP +from .igl_signed_dist import NODE_CLASS_MAPPINGS as SD_MAPS, NODE_DISPLAY_NAME_MAPPINGS as SD_DISP + +NODE_CLASS_MAPPINGS = {} +NODE_DISPLAY_NAME_MAPPINGS = {} + +NODE_CLASS_MAPPINGS.update(TM_MAPS) +NODE_CLASS_MAPPINGS.update(BFS_MAPS) +NODE_CLASS_MAPPINGS.update(WIND_MAPS) +NODE_CLASS_MAPPINGS.update(RAY_MAPS) +NODE_CLASS_MAPPINGS.update(SD_MAPS) + +NODE_DISPLAY_NAME_MAPPINGS.update(TM_DISP) +NODE_DISPLAY_NAME_MAPPINGS.update(BFS_DISP) +NODE_DISPLAY_NAME_MAPPINGS.update(WIND_DISP) +NODE_DISPLAY_NAME_MAPPINGS.update(RAY_DISP) +NODE_DISPLAY_NAME_MAPPINGS.update(SD_DISP) + +__all__ = ['NODE_CLASS_MAPPINGS', 'NODE_DISPLAY_NAME_MAPPINGS'] diff --git a/nodes/main/repair/fix_normals_backends/igl_bfs.py b/nodes/main/repair/fix_normals_backends/igl_bfs.py new file mode 100644 index 00000000..28aa88d9 --- /dev/null +++ b/nodes/main/repair/fix_normals_backends/igl_bfs.py @@ -0,0 +1,73 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2025 ComfyUI-GeometryPack Contributors + +"""IGL BFS-orient fix normals backend node.""" + +import logging +import numpy as np +from comfy_api.latest import io + +log = logging.getLogger("geometrypack") + + +class FixNormalsIglBfsNode(io.ComfyNode): + + @classmethod + def define_schema(cls): + return io.Schema( + node_id="GeomPackFixNormals_IglBfs", + display_name="Fix Normals IGL BFS (backend)", + category="geompack/repair", + is_dev_only=True, + is_output_node=True, + inputs=[ + io.Custom("TRIMESH").Input("trimesh"), + ], + outputs=[ + io.Custom("TRIMESH").Output(display_name="fixed_mesh"), + io.String.Output(display_name="info"), + ], + ) + + @classmethod + def execute(cls, trimesh): + import igl + + fixed_mesh = trimesh.copy() + + was_consistent = fixed_mesh.is_winding_consistent + + V = np.asarray(fixed_mesh.vertices, dtype=np.float64) + F = np.asarray(fixed_mesh.faces, dtype=np.int64) + + FF, C = igl.bfs_orient(F) + + fixed_mesh.faces = FF + + num_components = len(np.unique(C)) + log.info("igl.bfs_orient: %d orientation components", num_components) + + is_consistent = fixed_mesh.is_winding_consistent + + info = ( + f"Normal Orientation Fix:\n" + f"\n" + f"Method: igl_bfs\n" + f"Before: {'Consistent' if was_consistent else 'Inconsistent'}\n" + f"After: {'Consistent' if is_consistent else 'Inconsistent'}\n" + f"Orientation Components: {num_components}\n" + f"\n" + f"Vertices: {len(fixed_mesh.vertices):,}\n" + f"Faces: {len(fixed_mesh.faces):,}\n" + f"\n" + f"Note: BFS makes faces consistent but doesn't determine inside/outside\n" + f"{'[OK] Normals are now consistently oriented!' if is_consistent else '[WARN] Some inconsistencies may remain (check mesh topology)'}" + ) + + log.info("Normal orientation: %s -> %s", was_consistent, is_consistent) + + return io.NodeOutput(fixed_mesh, info, ui={"text": [info]}) + + +NODE_CLASS_MAPPINGS = {"GeomPackFixNormals_IglBfs": FixNormalsIglBfsNode} +NODE_DISPLAY_NAME_MAPPINGS = {"GeomPackFixNormals_IglBfs": "Fix Normals IGL BFS (backend)"} diff --git a/nodes/main/repair/fix_normals_backends/igl_raycast.py b/nodes/main/repair/fix_normals_backends/igl_raycast.py new file mode 100644 index 00000000..3ed35d72 --- /dev/null +++ b/nodes/main/repair/fix_normals_backends/igl_raycast.py @@ -0,0 +1,101 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2025 ComfyUI-GeometryPack Contributors + +"""IGL raycast fix normals backend node.""" + +import logging +import numpy as np +from comfy_api.latest import io + +log = logging.getLogger("geometrypack") + + +class FixNormalsIglRaycastNode(io.ComfyNode): + + @classmethod + def define_schema(cls): + return io.Schema( + node_id="GeomPackFixNormals_IglRaycast", + display_name="Fix Normals IGL Raycast (backend)", + category="geompack/repair", + is_dev_only=True, + is_output_node=True, + inputs=[ + io.Custom("TRIMESH").Input("trimesh"), + ], + outputs=[ + io.Custom("TRIMESH").Output(display_name="fixed_mesh"), + io.String.Output(display_name="info"), + ], + ) + + @classmethod + def execute(cls, trimesh): + import igl + + fixed_mesh = trimesh.copy() + + was_consistent = fixed_mesh.is_winding_consistent + + V = np.asarray(fixed_mesh.vertices, dtype=np.float64) + F = np.asarray(fixed_mesh.faces, dtype=np.int64) + + # Compute face normals + face_normals = igl.per_face_normals(V, F, np.array([1., 1., 1.])) + + # Compute face centroids + face_centroids = (V[F[:, 0]] + V[F[:, 1]] + V[F[:, 2]]) / 3.0 + + # Small offset to avoid self-intersection + eps = 1e-6 + + flip_mask = np.zeros(len(F), dtype=bool) + + for i in range(len(F)): + origin = face_centroids[i] + face_normals[i] * eps + direction = face_normals[i] + + # Cast ray and get hits + hits = igl.ray_mesh_intersect( + np.ascontiguousarray(origin, dtype=np.float64), + np.ascontiguousarray(direction, dtype=np.float64), + V, F + ) + + # Odd number of hits = pointing inward + if hits is not None and len(hits) % 2 == 1: + flip_mask[i] = True + + # Flip faces by reversing vertex order + F_out = F.copy() + F_out[flip_mask] = F_out[flip_mask][:, [0, 2, 1]] + + num_flipped = int(np.sum(flip_mask)) + fixed_mesh.faces = F_out + + log.info("igl_raycast: flipped %d/%d faces", num_flipped, len(F)) + + is_consistent = fixed_mesh.is_winding_consistent + + info = ( + f"Normal Orientation Fix:\n" + f"\n" + f"Method: igl_raycast\n" + f"Before: {'Consistent' if was_consistent else 'Inconsistent'}\n" + f"After: {'Consistent' if is_consistent else 'Inconsistent'}\n" + f"Faces Flipped: {num_flipped}\n" + f"\n" + f"Vertices: {len(fixed_mesh.vertices):,}\n" + f"Faces: {len(fixed_mesh.faces):,}\n" + f"\n" + f"Note: Raycasting works best on closed meshes without self-intersections\n" + f"{'[OK] Normals are now consistently oriented!' if is_consistent else '[WARN] Some inconsistencies may remain (check mesh topology)'}" + ) + + log.info("Normal orientation: %s -> %s", was_consistent, is_consistent) + + return io.NodeOutput(fixed_mesh, info, ui={"text": [info]}) + + +NODE_CLASS_MAPPINGS = {"GeomPackFixNormals_IglRaycast": FixNormalsIglRaycastNode} +NODE_DISPLAY_NAME_MAPPINGS = {"GeomPackFixNormals_IglRaycast": "Fix Normals IGL Raycast (backend)"} diff --git a/nodes/main/repair/fix_normals_backends/igl_signed_dist.py b/nodes/main/repair/fix_normals_backends/igl_signed_dist.py new file mode 100644 index 00000000..89572c15 --- /dev/null +++ b/nodes/main/repair/fix_normals_backends/igl_signed_dist.py @@ -0,0 +1,98 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2025 ComfyUI-GeometryPack Contributors + +"""IGL signed distance fix normals backend node.""" + +import logging +import numpy as np +from comfy_api.latest import io + +log = logging.getLogger("geometrypack") + + +class FixNormalsIglSignedDistNode(io.ComfyNode): + + @classmethod + def define_schema(cls): + return io.Schema( + node_id="GeomPackFixNormals_IglSignedDist", + display_name="Fix Normals IGL Signed Dist (backend)", + category="geompack/repair", + is_dev_only=True, + is_output_node=True, + inputs=[ + io.Custom("TRIMESH").Input("trimesh"), + ], + outputs=[ + io.Custom("TRIMESH").Output(display_name="fixed_mesh"), + io.String.Output(display_name="info"), + ], + ) + + @classmethod + def execute(cls, trimesh): + import igl + + fixed_mesh = trimesh.copy() + + was_consistent = fixed_mesh.is_winding_consistent + + V = np.asarray(fixed_mesh.vertices, dtype=np.float64) + F = np.asarray(fixed_mesh.faces, dtype=np.int64) + + # Compute face normals + face_normals = igl.per_face_normals(V, F, np.array([1., 1., 1.])) + + # Compute face centroids + face_centroids = (V[F[:, 0]] + V[F[:, 1]] + V[F[:, 2]]) / 3.0 + + # Adaptive epsilon based on mesh scale + bbox_diag = np.linalg.norm(V.max(axis=0) - V.min(axis=0)) + eps = 1e-4 * bbox_diag + + # Query points offset along normal direction (outside if normal correct) + query_points = face_centroids + face_normals * eps + + # Compute signed distance using pseudonormal method + S, I, C, N = igl.signed_distance( + np.ascontiguousarray(query_points, dtype=np.float64), + V, F, + igl.SIGNED_DISTANCE_TYPE_PSEUDONORMAL + ) + + # Positive signed distance = inside mesh = normal points inward + flip_mask = S > 0 + + # Flip faces by reversing vertex order + F_out = F.copy() + F_out[flip_mask] = F_out[flip_mask][:, [0, 2, 1]] + + num_flipped = int(np.sum(flip_mask)) + fixed_mesh.faces = F_out + + log.info("igl_signed_dist: flipped %d/%d faces", num_flipped, len(F)) + + is_consistent = fixed_mesh.is_winding_consistent + + info = ( + f"Normal Orientation Fix:\n" + f"\n" + f"Method: igl_signed_dist\n" + f"Before: {'Consistent' if was_consistent else 'Inconsistent'}\n" + f"After: {'Consistent' if is_consistent else 'Inconsistent'}\n" + f"Faces Flipped: {num_flipped}\n" + f"\n" + f"Vertices: {len(fixed_mesh.vertices):,}\n" + f"Faces: {len(fixed_mesh.faces):,}\n" + f"\n" + f"Note: Signed distance works best on watertight meshes\n" + f"{'[OK] Normals are now consistently oriented!' if is_consistent else '[WARN] Some inconsistencies may remain (check mesh topology)'}" + ) + + log.info("Normal orientation: %s -> %s", was_consistent, is_consistent) + + return io.NodeOutput(fixed_mesh, info, ui={"text": [info]}) + + +NODE_CLASS_MAPPINGS = {"GeomPackFixNormals_IglSignedDist": FixNormalsIglSignedDistNode} +NODE_DISPLAY_NAME_MAPPINGS = {"GeomPackFixNormals_IglSignedDist": "Fix Normals IGL Signed Dist (backend)"} diff --git a/nodes/main/repair/fix_normals_backends/igl_winding.py b/nodes/main/repair/fix_normals_backends/igl_winding.py new file mode 100644 index 00000000..96ce78f3 --- /dev/null +++ b/nodes/main/repair/fix_normals_backends/igl_winding.py @@ -0,0 +1,94 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2025 ComfyUI-GeometryPack Contributors + +"""IGL winding number fix normals backend node.""" + +import logging +import numpy as np +from comfy_api.latest import io + +log = logging.getLogger("geometrypack") + + +class FixNormalsIglWindingNode(io.ComfyNode): + + @classmethod + def define_schema(cls): + return io.Schema( + node_id="GeomPackFixNormals_IglWinding", + display_name="Fix Normals IGL Winding (backend)", + category="geompack/repair", + is_dev_only=True, + is_output_node=True, + inputs=[ + io.Custom("TRIMESH").Input("trimesh"), + ], + outputs=[ + io.Custom("TRIMESH").Output(display_name="fixed_mesh"), + io.String.Output(display_name="info"), + ], + ) + + @classmethod + def execute(cls, trimesh): + import igl + + fixed_mesh = trimesh.copy() + + was_consistent = fixed_mesh.is_winding_consistent + + V = np.asarray(fixed_mesh.vertices, dtype=np.float64) + F = np.asarray(fixed_mesh.faces, dtype=np.int64) + + # Compute face normals + face_normals = igl.per_face_normals(V, F, np.array([1., 1., 1.])) + + # Compute face centroids + face_centroids = (V[F[:, 0]] + V[F[:, 1]] + V[F[:, 2]]) / 3.0 + + # Adaptive epsilon based on mesh scale + bbox_diag = np.linalg.norm(V.max(axis=0) - V.min(axis=0)) + eps = 1e-4 * bbox_diag + + # Query points offset slightly inside (along -normal direction) + query_inside = face_centroids - face_normals * eps + + # Compute winding numbers + W = igl.fast_winding_number(V, F, query_inside) + + # If winding < 0.5, the query point is outside, meaning normal points inward + flip_mask = W < 0.5 + + # Flip faces by reversing vertex order + F_out = F.copy() + F_out[flip_mask] = F_out[flip_mask][:, [0, 2, 1]] + + num_flipped = int(np.sum(flip_mask)) + fixed_mesh.faces = F_out + + log.info("igl_winding: flipped %d/%d faces", num_flipped, len(F)) + + is_consistent = fixed_mesh.is_winding_consistent + + info = ( + f"Normal Orientation Fix:\n" + f"\n" + f"Method: igl_winding\n" + f"Before: {'Consistent' if was_consistent else 'Inconsistent'}\n" + f"After: {'Consistent' if is_consistent else 'Inconsistent'}\n" + f"Faces Flipped: {num_flipped}\n" + f"\n" + f"Vertices: {len(fixed_mesh.vertices):,}\n" + f"Faces: {len(fixed_mesh.faces):,}\n" + f"\n" + f"Note: Winding number works best on closed/watertight meshes\n" + f"{'[OK] Normals are now consistently oriented!' if is_consistent else '[WARN] Some inconsistencies may remain (check mesh topology)'}" + ) + + log.info("Normal orientation: %s -> %s", was_consistent, is_consistent) + + return io.NodeOutput(fixed_mesh, info, ui={"text": [info]}) + + +NODE_CLASS_MAPPINGS = {"GeomPackFixNormals_IglWinding": FixNormalsIglWindingNode} +NODE_DISPLAY_NAME_MAPPINGS = {"GeomPackFixNormals_IglWinding": "Fix Normals IGL Winding (backend)"} diff --git a/nodes/main/repair/fix_normals_backends/trimesh_fix.py b/nodes/main/repair/fix_normals_backends/trimesh_fix.py new file mode 100644 index 00000000..9cf7508f --- /dev/null +++ b/nodes/main/repair/fix_normals_backends/trimesh_fix.py @@ -0,0 +1,60 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2025 ComfyUI-GeometryPack Contributors + +"""Trimesh fix normals backend node.""" + +import logging +from comfy_api.latest import io + +log = logging.getLogger("geometrypack") + + +class FixNormalsTrimeshNode(io.ComfyNode): + + @classmethod + def define_schema(cls): + return io.Schema( + node_id="GeomPackFixNormals_Trimesh", + display_name="Fix Normals Trimesh (backend)", + category="geompack/repair", + is_dev_only=True, + is_output_node=True, + inputs=[ + io.Custom("TRIMESH").Input("trimesh"), + ], + outputs=[ + io.Custom("TRIMESH").Output(display_name="fixed_mesh"), + io.String.Output(display_name="info"), + ], + ) + + @classmethod + def execute(cls, trimesh): + fixed_mesh = trimesh.copy() + + was_consistent = fixed_mesh.is_winding_consistent + + fixed_mesh.fix_normals() + + is_consistent = fixed_mesh.is_winding_consistent + + info = ( + f"Normal Orientation Fix:\n" + f"\n" + f"Method: trimesh\n" + f"Before: {'Consistent' if was_consistent else 'Inconsistent'}\n" + f"After: {'Consistent' if is_consistent else 'Inconsistent'}\n" + f"\n" + f"Vertices: {len(fixed_mesh.vertices):,}\n" + f"Faces: {len(fixed_mesh.faces):,}\n" + f"\n" + f"{'[OK] Normals are now consistently oriented!' if is_consistent else '[WARN] Some inconsistencies may remain (check mesh topology)'}" + ) + + log.info("Normal orientation: %s -> %s", was_consistent, is_consistent) + + return io.NodeOutput(fixed_mesh, info, ui={"text": [info]}) + + +NODE_CLASS_MAPPINGS = {"GeomPackFixNormals_Trimesh": FixNormalsTrimeshNode} +NODE_DISPLAY_NAME_MAPPINGS = {"GeomPackFixNormals_Trimesh": "Fix Normals Trimesh (backend)"} diff --git a/nodes/main/skeleton/__init__.py b/nodes/main/skeleton/__init__.py index 0c0efbf5..5f969951 100644 --- a/nodes/main/skeleton/__init__.py +++ b/nodes/main/skeleton/__init__.py @@ -5,19 +5,21 @@ from .extract_skeleton import NODE_CLASS_MAPPINGS as EXTRACT_MAPPINGS from .extract_skeleton import NODE_DISPLAY_NAME_MAPPINGS as EXTRACT_DISPLAY +from .backends import NODE_CLASS_MAPPINGS as BACKENDS_MAPPINGS +from .backends import NODE_DISPLAY_NAME_MAPPINGS as BACKENDS_DISPLAY from .mesh_from_skeleton import NODE_CLASS_MAPPINGS as MESH_MAPPINGS from .mesh_from_skeleton import NODE_DISPLAY_NAME_MAPPINGS as MESH_DISPLAY -# Also import from parent skeleton.py for other nodes (ExtractSkeleton, SkeletonToTrimesh, SkeletonToMesh) - # Combine all mappings NODE_CLASS_MAPPINGS = { **EXTRACT_MAPPINGS, + **BACKENDS_MAPPINGS, **MESH_MAPPINGS, } NODE_DISPLAY_NAME_MAPPINGS = { **EXTRACT_DISPLAY, + **BACKENDS_DISPLAY, **MESH_DISPLAY, } diff --git a/nodes/main/skeleton/backends/__init__.py b/nodes/main/skeleton/backends/__init__.py new file mode 100644 index 00000000..88a99f32 --- /dev/null +++ b/nodes/main/skeleton/backends/__init__.py @@ -0,0 +1,26 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2025 ComfyUI-GeometryPack Contributors + +""" +Individual skeleton extraction backend nodes (hidden from UI, invoked via unified Extract Skeleton node). +""" + +from .wavefront import NODE_CLASS_MAPPINGS as WF_MAPS, NODE_DISPLAY_NAME_MAPPINGS as WF_DISP +from .vertex_clusters import NODE_CLASS_MAPPINGS as VC_MAPS, NODE_DISPLAY_NAME_MAPPINGS as VC_DISP +from .edge_collapse import NODE_CLASS_MAPPINGS as EC_MAPS, NODE_DISPLAY_NAME_MAPPINGS as EC_DISP +from .teasar import NODE_CLASS_MAPPINGS as TE_MAPS, NODE_DISPLAY_NAME_MAPPINGS as TE_DISP + +NODE_CLASS_MAPPINGS = {} +NODE_DISPLAY_NAME_MAPPINGS = {} + +NODE_CLASS_MAPPINGS.update(WF_MAPS) +NODE_CLASS_MAPPINGS.update(VC_MAPS) +NODE_CLASS_MAPPINGS.update(EC_MAPS) +NODE_CLASS_MAPPINGS.update(TE_MAPS) + +NODE_DISPLAY_NAME_MAPPINGS.update(WF_DISP) +NODE_DISPLAY_NAME_MAPPINGS.update(VC_DISP) +NODE_DISPLAY_NAME_MAPPINGS.update(EC_DISP) +NODE_DISPLAY_NAME_MAPPINGS.update(TE_DISP) + +__all__ = ['NODE_CLASS_MAPPINGS', 'NODE_DISPLAY_NAME_MAPPINGS'] diff --git a/nodes/main/skeleton/backends/_helpers.py b/nodes/main/skeleton/backends/_helpers.py new file mode 100644 index 00000000..ee308202 --- /dev/null +++ b/nodes/main/skeleton/backends/_helpers.py @@ -0,0 +1,26 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2025 ComfyUI-GeometryPack Contributors + +"""Shared helpers for skeleton backend nodes.""" + +import numpy as np + + +def normalize_skeleton(vertices): + """ + Normalize skeleton vertices to [-1, 1] range. + + Args: + vertices: Array of shape [N, 3] + + Returns: + Normalized vertices in [-1, 1] range + """ + min_coords = vertices.min(axis=0) + max_coords = vertices.max(axis=0) + center = (min_coords + max_coords) / 2 + vertices = vertices - center + scale = (max_coords - min_coords).max() / 2 + if scale > 0: + vertices = vertices / scale + return vertices diff --git a/nodes/main/skeleton/backends/edge_collapse.py b/nodes/main/skeleton/backends/edge_collapse.py new file mode 100644 index 00000000..5565909f --- /dev/null +++ b/nodes/main/skeleton/backends/edge_collapse.py @@ -0,0 +1,73 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2025 ComfyUI-GeometryPack Contributors + +"""Edge collapse skeletonization backend node.""" + +import logging +import numpy as np +from comfy_api.latest import io + +log = logging.getLogger("geometrypack") + + +class SkeletonEdgeCollapseNode(io.ComfyNode): + """Edge collapse skeletonization backend.""" + + @classmethod + def define_schema(cls): + return io.Schema( + node_id="GeomPackSkeleton_EdgeCollapse", + display_name="Skeleton Edge Collapse (backend)", + category="geompack/skeleton", + is_dev_only=True, + is_output_node=True, + inputs=[ + io.Custom("TRIMESH").Input("trimesh"), + io.Boolean.Input("fix_mesh", default=True, tooltip="Fix mesh issues before skeletonization"), + io.Boolean.Input("normalize", default=False, tooltip="Normalize skeleton to [-1, 1] range"), + io.Float.Input("shape_weight", default=1.0, min=0.0, max=10.0, tooltip="Shape preservation weight"), + io.Float.Input("sample_weight", default=0.1, min=0.0, max=10.0, tooltip="Sampling quality weight"), + ], + outputs=[ + io.Custom("SKELETON").Output(display_name="skeleton"), + ], + ) + + @classmethod + def execute(cls, trimesh, fix_mesh=True, normalize=False, shape_weight=1.0, sample_weight=0.1): + import skeletor as sk + + log.info("Backend: edge_collapse") + + if fix_mesh: + mesh = sk.pre.fix_mesh(trimesh, remove_disconnected=5, inplace=False) + else: + mesh = trimesh + + skel = sk.skeletonize.by_edge_collapse(mesh, shape_weight=shape_weight, sample_weight=sample_weight) + + vertices = np.array(skel.vertices) + edges = np.array(skel.edges) + + skel_min = vertices.min(axis=0) + skel_max = vertices.max(axis=0) + original_scale = float((skel_max - skel_min).max() / 2) + original_center = ((skel_min + skel_max) / 2).tolist() + + if normalize: + from ._helpers import normalize_skeleton + vertices = normalize_skeleton(vertices) + + skeleton = { + "vertices": vertices, + "edges": edges, + "scale": original_scale, + "center": original_center, + "normalized": normalize, + } + + return io.NodeOutput(skeleton) + + +NODE_CLASS_MAPPINGS = {"GeomPackSkeleton_EdgeCollapse": SkeletonEdgeCollapseNode} +NODE_DISPLAY_NAME_MAPPINGS = {"GeomPackSkeleton_EdgeCollapse": "Skeleton Edge Collapse (backend)"} diff --git a/nodes/main/skeleton/backends/teasar.py b/nodes/main/skeleton/backends/teasar.py new file mode 100644 index 00000000..0ddbabc0 --- /dev/null +++ b/nodes/main/skeleton/backends/teasar.py @@ -0,0 +1,77 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2025 ComfyUI-GeometryPack Contributors + +"""TEASAR skeletonization backend node.""" + +import logging +import numpy as np +from comfy_api.latest import io + +log = logging.getLogger("geometrypack") + + +class SkeletonTeasarNode(io.ComfyNode): + """TEASAR skeletonization backend.""" + + @classmethod + def define_schema(cls): + return io.Schema( + node_id="GeomPackSkeleton_Teasar", + display_name="Skeleton Teasar (backend)", + category="geompack/skeleton", + is_dev_only=True, + is_output_node=True, + inputs=[ + io.Custom("TRIMESH").Input("trimesh"), + io.Boolean.Input("fix_mesh", default=True, tooltip="Fix mesh issues before skeletonization"), + io.Boolean.Input("normalize", default=False, tooltip="Normalize skeleton to [-1, 1] range"), + io.Float.Input("inv_dist", default=10.0, min=1.0, max=100.0, tooltip="Invalidation distance (lower = more detail)"), + io.Float.Input("min_length", default=0.0, min=0.0, max=100.0, tooltip="Minimum branch length to keep"), + ], + outputs=[ + io.Custom("SKELETON").Output(display_name="skeleton"), + ], + ) + + @classmethod + def execute(cls, trimesh, fix_mesh=True, normalize=False, inv_dist=10.0, min_length=0.0): + import skeletor as sk + + log.info("Backend: teasar") + + if fix_mesh: + mesh = sk.pre.fix_mesh(trimesh, remove_disconnected=5, inplace=False) + else: + mesh = trimesh + + skel = sk.skeletonize.by_teasar( + mesh, + inv_dist=inv_dist, + min_length=min_length if min_length > 0 else None, + ) + + vertices = np.array(skel.vertices) + edges = np.array(skel.edges) + + skel_min = vertices.min(axis=0) + skel_max = vertices.max(axis=0) + original_scale = float((skel_max - skel_min).max() / 2) + original_center = ((skel_min + skel_max) / 2).tolist() + + if normalize: + from ._helpers import normalize_skeleton + vertices = normalize_skeleton(vertices) + + skeleton = { + "vertices": vertices, + "edges": edges, + "scale": original_scale, + "center": original_center, + "normalized": normalize, + } + + return io.NodeOutput(skeleton) + + +NODE_CLASS_MAPPINGS = {"GeomPackSkeleton_Teasar": SkeletonTeasarNode} +NODE_DISPLAY_NAME_MAPPINGS = {"GeomPackSkeleton_Teasar": "Skeleton Teasar (backend)"} diff --git a/nodes/main/skeleton/backends/vertex_clusters.py b/nodes/main/skeleton/backends/vertex_clusters.py new file mode 100644 index 00000000..96fbf6b7 --- /dev/null +++ b/nodes/main/skeleton/backends/vertex_clusters.py @@ -0,0 +1,73 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2025 ComfyUI-GeometryPack Contributors + +"""Vertex clusters skeletonization backend node.""" + +import logging +import numpy as np +from comfy_api.latest import io + +log = logging.getLogger("geometrypack") + + +class SkeletonVertexClustersNode(io.ComfyNode): + """Vertex clusters skeletonization backend.""" + + @classmethod + def define_schema(cls): + return io.Schema( + node_id="GeomPackSkeleton_VertexClusters", + display_name="Skeleton Vertex Clusters (backend)", + category="geompack/skeleton", + is_dev_only=True, + is_output_node=True, + inputs=[ + io.Custom("TRIMESH").Input("trimesh"), + io.Boolean.Input("fix_mesh", default=True, tooltip="Fix mesh issues before skeletonization"), + io.Boolean.Input("normalize", default=False, tooltip="Normalize skeleton to [-1, 1] range"), + io.Float.Input("sampling_dist", default=1.0, min=0.1, max=50.0, tooltip="Max distance for clustering"), + io.Combo.Input("cluster_pos", options=["median", "center"], default="median", tooltip="Cluster position method"), + ], + outputs=[ + io.Custom("SKELETON").Output(display_name="skeleton"), + ], + ) + + @classmethod + def execute(cls, trimesh, fix_mesh=True, normalize=False, sampling_dist=1.0, cluster_pos="median"): + import skeletor as sk + + log.info("Backend: vertex_clusters") + + if fix_mesh: + mesh = sk.pre.fix_mesh(trimesh, remove_disconnected=5, inplace=False) + else: + mesh = trimesh + + skel = sk.skeletonize.by_vertex_clusters(mesh, sampling_dist=sampling_dist, cluster_pos=cluster_pos) + + vertices = np.array(skel.vertices) + edges = np.array(skel.edges) + + skel_min = vertices.min(axis=0) + skel_max = vertices.max(axis=0) + original_scale = float((skel_max - skel_min).max() / 2) + original_center = ((skel_min + skel_max) / 2).tolist() + + if normalize: + from ._helpers import normalize_skeleton + vertices = normalize_skeleton(vertices) + + skeleton = { + "vertices": vertices, + "edges": edges, + "scale": original_scale, + "center": original_center, + "normalized": normalize, + } + + return io.NodeOutput(skeleton) + + +NODE_CLASS_MAPPINGS = {"GeomPackSkeleton_VertexClusters": SkeletonVertexClustersNode} +NODE_DISPLAY_NAME_MAPPINGS = {"GeomPackSkeleton_VertexClusters": "Skeleton Vertex Clusters (backend)"} diff --git a/nodes/main/skeleton/backends/wavefront.py b/nodes/main/skeleton/backends/wavefront.py new file mode 100644 index 00000000..323cb636 --- /dev/null +++ b/nodes/main/skeleton/backends/wavefront.py @@ -0,0 +1,73 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2025 ComfyUI-GeometryPack Contributors + +"""Wavefront skeletonization backend node.""" + +import logging +import numpy as np +from comfy_api.latest import io + +log = logging.getLogger("geometrypack") + + +class SkeletonWavefrontNode(io.ComfyNode): + """Wavefront skeletonization backend.""" + + @classmethod + def define_schema(cls): + return io.Schema( + node_id="GeomPackSkeleton_Wavefront", + display_name="Skeleton Wavefront (backend)", + category="geompack/skeleton", + is_dev_only=True, + is_output_node=True, + inputs=[ + io.Custom("TRIMESH").Input("trimesh"), + io.Boolean.Input("fix_mesh", default=True, tooltip="Fix mesh issues before skeletonization"), + io.Boolean.Input("normalize", default=False, tooltip="Normalize skeleton to [-1, 1] range"), + io.Int.Input("waves", default=1, min=1, max=20, tooltip="Number of waves"), + io.Float.Input("step_size", default=1.0, min=0.1, max=20.0, tooltip="Step size (higher = coarser)"), + ], + outputs=[ + io.Custom("SKELETON").Output(display_name="skeleton"), + ], + ) + + @classmethod + def execute(cls, trimesh, fix_mesh=True, normalize=False, waves=1, step_size=1.0): + import skeletor as sk + + log.info("Backend: wavefront") + + if fix_mesh: + mesh = sk.pre.fix_mesh(trimesh, remove_disconnected=5, inplace=False) + else: + mesh = trimesh + + skel = sk.skeletonize.by_wavefront(mesh, waves=waves, step_size=step_size) + + vertices = np.array(skel.vertices) + edges = np.array(skel.edges) + + skel_min = vertices.min(axis=0) + skel_max = vertices.max(axis=0) + original_scale = float((skel_max - skel_min).max() / 2) + original_center = ((skel_min + skel_max) / 2).tolist() + + if normalize: + from ._helpers import normalize_skeleton + vertices = normalize_skeleton(vertices) + + skeleton = { + "vertices": vertices, + "edges": edges, + "scale": original_scale, + "center": original_center, + "normalized": normalize, + } + + return io.NodeOutput(skeleton) + + +NODE_CLASS_MAPPINGS = {"GeomPackSkeleton_Wavefront": SkeletonWavefrontNode} +NODE_DISPLAY_NAME_MAPPINGS = {"GeomPackSkeleton_Wavefront": "Skeleton Wavefront (backend)"} diff --git a/nodes/main/skeleton/extract_skeleton.py b/nodes/main/skeleton/extract_skeleton.py index d859b859..c31ce32c 100644 --- a/nodes/main/skeleton/extract_skeleton.py +++ b/nodes/main/skeleton/extract_skeleton.py @@ -2,52 +2,31 @@ # Copyright (C) 2025 ComfyUI-GeometryPack Contributors """ -Extract Skeleton Node - Extract skeleton from 3D mesh +Extract Skeleton Node - Unified frontend with backend selector. + +Uses ComfyUI's node expansion (GraphBuilder) to dispatch to hidden +backend-specific nodes, each running in its own isolation env. """ import logging - -import numpy as np -import trimesh from comfy_api.latest import io log = logging.getLogger("geometrypack") -def normalize_skeleton(vertices: np.ndarray) -> np.ndarray: - """ - Normalize skeleton vertices to [-1, 1] range. - - Args: - vertices: Array of shape [N, 3] - - Returns: - Normalized vertices in [-1, 1] range - """ - # Find bounding box - min_coords = vertices.min(axis=0) - max_coords = vertices.max(axis=0) - - # Center at origin - center = (min_coords + max_coords) / 2 - vertices = vertices - center - - # Scale to [-1, 1] - scale = (max_coords - min_coords).max() / 2 - if scale > 0: - vertices = vertices / scale - - return vertices - - class ExtractSkeleton(io.ComfyNode): """ Extract skeleton from 3D mesh using Skeletor library. - Outputs skeleton data (vertices + edges) with optional normalization to [-1, 1] range. - By default, preserves the original mesh scale. + Dispatches to hidden backend nodes via node expansion. """ + BACKEND_MAP = { + "wavefront": "GeomPackSkeleton_Wavefront", + "vertex_clusters": "GeomPackSkeleton_VertexClusters", + "edge_collapse": "GeomPackSkeleton_EdgeCollapse", + "teasar": "GeomPackSkeleton_Teasar", + } @classmethod def define_schema(cls): @@ -55,19 +34,35 @@ class ExtractSkeleton(io.ComfyNode): node_id="GeomPackExtractSkeleton", display_name="Extract Skeleton", category="geompack/skeleton", + enable_expand=True, inputs=[ io.Custom("TRIMESH").Input("trimesh"), - io.Combo.Input("algorithm", options=["wavefront", "vertex_clusters", "edge_collapse", "teasar"], default="wavefront"), io.Boolean.Input("fix_mesh", default=True, tooltip="Fix mesh issues before skeletonization"), io.Boolean.Input("normalize", default=False, tooltip="Normalize skeleton to [-1, 1] range (False preserves original mesh scale)"), - io.Int.Input("waves", default=1, min=1, max=20, tooltip="Wavefront: number of waves", optional=True), - io.Float.Input("step_size", default=1.0, min=0.1, max=20.0, tooltip="Wavefront: step size (higher = coarser)", optional=True), - io.Float.Input("sampling_dist", default=1.0, min=0.1, max=50.0, tooltip="Vertex clusters: max distance for clustering", optional=True), - io.Combo.Input("cluster_pos", options=["median", "center"], default="median", tooltip="Vertex clusters: cluster position method", optional=True), - io.Float.Input("shape_weight", default=1.0, min=0.0, max=10.0, tooltip="Edge collapse: shape preservation weight", optional=True), - io.Float.Input("sample_weight", default=0.1, min=0.0, max=10.0, tooltip="Edge collapse: sampling quality weight", optional=True), - io.Float.Input("inv_dist", default=10.0, min=1.0, max=100.0, tooltip="TEASAR: invalidation distance (lower = more detail)", optional=True), - io.Float.Input("min_length", default=0.0, min=0.0, max=100.0, tooltip="TEASAR: minimum branch length to keep", optional=True), + io.DynamicCombo.Input("algorithm", tooltip=( + "Skeletonization algorithm. " + "wavefront=default, " + "vertex_clusters=fast clustering, " + "edge_collapse=shape-aware, " + "teasar=tree-structure" + ), options=[ + io.DynamicCombo.Option("wavefront", [ + io.Int.Input("waves", default=1, min=1, max=20, tooltip="Number of waves"), + io.Float.Input("step_size", default=1.0, min=0.1, max=20.0, tooltip="Step size (higher = coarser)"), + ]), + io.DynamicCombo.Option("vertex_clusters", [ + io.Float.Input("sampling_dist", default=1.0, min=0.1, max=50.0, tooltip="Max distance for clustering"), + io.Combo.Input("cluster_pos", options=["median", "center"], default="median", tooltip="Cluster position method"), + ]), + io.DynamicCombo.Option("edge_collapse", [ + io.Float.Input("shape_weight", default=1.0, min=0.0, max=10.0, tooltip="Shape preservation weight"), + io.Float.Input("sample_weight", default=0.1, min=0.0, max=10.0, tooltip="Sampling quality weight"), + ]), + io.DynamicCombo.Option("teasar", [ + io.Float.Input("inv_dist", default=10.0, min=1.0, max=100.0, tooltip="Invalidation distance (lower = more detail)"), + io.Float.Input("min_length", default=0.0, min=0.0, max=100.0, tooltip="Minimum branch length to keep"), + ]), + ]), ], outputs=[ io.Custom("SKELETON").Output(display_name="skeleton"), @@ -75,131 +70,34 @@ class ExtractSkeleton(io.ComfyNode): ) @classmethod - def execute(cls, trimesh, algorithm, fix_mesh, normalize, - waves=1, step_size=1.0, - sampling_dist=1.0, cluster_pos="median", - shape_weight=1.0, sample_weight=0.1, - inv_dist=10.0, min_length=0.0): - """Extract skeleton from mesh. + def execute(cls, trimesh, fix_mesh, normalize, algorithm): + from comfy_execution.graph_utils import GraphBuilder - Args: - trimesh: Input mesh - algorithm: Skeletonization algorithm to use - fix_mesh: Whether to fix mesh issues before extraction - normalize: If True, normalize skeleton to [-1, 1] range. If False, preserve original scale. - ... (algorithm-specific parameters) - """ - try: - import skeletor as sk - except ImportError: - raise ImportError( - "Skeletor library not found. Please install: pip install skeletor" - ) + if cls.SCHEMA is None: + cls.GET_SCHEMA() - log.info("Extracting skeleton using %s algorithm...", algorithm) + selected = algorithm["algorithm"] + node_id = cls.BACKEND_MAP[selected] - # Print input mesh bounding box - mesh_min = trimesh.bounds[0] - mesh_max = trimesh.bounds[1] - mesh_size = mesh_max - mesh_min - mesh_center = (mesh_min + mesh_max) / 2 - log.info("Input mesh bounding box:") - log.info(" Min: [%.3f, %.3f, %.3f]", mesh_min[0], mesh_min[1], mesh_min[2]) - log.info(" Max: [%.3f, %.3f, %.3f]", mesh_max[0], mesh_max[1], mesh_max[2]) - log.info(" Size: [%.3f, %.3f, %.3f]", mesh_size[0], mesh_size[1], mesh_size[2]) - log.info(" Center: [%.3f, %.3f, %.3f]", mesh_center[0], mesh_center[1], mesh_center[2]) + log.info("Extract Skeleton dispatch: %s -> %s", selected, node_id) - # Fix mesh if requested - if fix_mesh: - log.info("Fixing mesh...") - mesh = sk.pre.fix_mesh(trimesh, remove_disconnected=5, inplace=False) - else: - mesh = trimesh - - # Extract skeleton based on algorithm - try: - if algorithm == "wavefront": - log.info(" Parameters: waves=%d, step_size=%s", waves, step_size) - skel = sk.skeletonize.by_wavefront(mesh, waves=waves, step_size=step_size) - - elif algorithm == "vertex_clusters": - log.info(" Parameters: sampling_dist=%s, cluster_pos=%s", sampling_dist, cluster_pos) - skel = sk.skeletonize.by_vertex_clusters( - mesh, - sampling_dist=sampling_dist, - cluster_pos=cluster_pos - ) - - elif algorithm == "edge_collapse": - log.info(" Parameters: shape_weight=%s, sample_weight=%s", shape_weight, sample_weight) - skel = sk.skeletonize.by_edge_collapse( - mesh, - shape_weight=shape_weight, - sample_weight=sample_weight - ) - - elif algorithm == "teasar": - log.info(" Parameters: inv_dist=%s, min_length=%s", inv_dist, min_length) - skel = sk.skeletonize.by_teasar( - mesh, - inv_dist=inv_dist, - min_length=min_length if min_length > 0 else None - ) - - else: - raise ValueError(f"Unknown algorithm: {algorithm}") - - except Exception as e: - log.error("Error during skeletonization: %s", e) - raise RuntimeError(f"Skeletonization failed: {e}") - - # Get vertices and edges - vertices = np.array(skel.vertices) - edges = np.array(skel.edges) - - log.info("Extracted %d joints, %d bones", len(vertices), len(edges)) - - # Print skeleton bounding box before any normalization - skel_min = vertices.min(axis=0) - skel_max = vertices.max(axis=0) - skel_size = skel_max - skel_min - skel_center = (skel_min + skel_max) / 2 - log.info("Skeleton bounding box (original):") - log.info(" Min: [%.3f, %.3f, %.3f]", skel_min[0], skel_min[1], skel_min[2]) - log.info(" Max: [%.3f, %.3f, %.3f]", skel_max[0], skel_max[1], skel_max[2]) - log.info(" Size: [%.3f, %.3f, %.3f]", skel_size[0], skel_size[1], skel_size[2]) - log.info(" Center: [%.3f, %.3f, %.3f]", skel_center[0], skel_center[1], skel_center[2]) - - # Store original scale and center for metadata - original_scale = float((skel_max - skel_min).max() / 2) - original_center = skel_center.copy() - - # Conditionally normalize - if normalize: - vertices = normalize_skeleton(vertices) - - # Print skeleton bounding box after normalization - norm_min = vertices.min(axis=0) - norm_max = vertices.max(axis=0) - norm_size = norm_max - norm_min - log.info("Skeleton bounding box AFTER normalization:") - log.info(" Min: [%.3f, %.3f, %.3f]", norm_min[0], norm_min[1], norm_min[2]) - log.info(" Max: [%.3f, %.3f, %.3f]", norm_max[0], norm_max[1], norm_max[2]) - log.info(" Size: [%.3f, %.3f, %.3f]", norm_size[0], norm_size[1], norm_size[2]) - log.info(" Overall range: [%.3f, %.3f]", vertices.min(), vertices.max()) - else: - log.info("Normalization skipped - preserving original scale") - - # Package as skeleton data - skeleton = { - "vertices": vertices, # [N, 3] joint positions - "edges": edges, # [M, 2] bone connections (vertex indices) - "scale": original_scale, # Original scale factor (for denormalization if needed) - "center": original_center.tolist(), # Original center point - "normalized": normalize, # Whether this skeleton was normalized + kwargs = { + "trimesh": trimesh, + "fix_mesh": fix_mesh, + "normalize": normalize, } + for k, v in algorithm.items(): + if k == "algorithm": + continue + kwargs[k] = v - return io.NodeOutput(skeleton) + graph = GraphBuilder() + backend_node = graph.node(node_id, **kwargs) + + return { + "result": (backend_node.out(0),), + "expand": graph.finalize(), + } # Node mappings diff --git a/nodes/main/smoothing/__init__.py b/nodes/main/smoothing/__init__.py index 97a2714a..87e612fd 100644 --- a/nodes/main/smoothing/__init__.py +++ b/nodes/main/smoothing/__init__.py @@ -6,7 +6,9 @@ Smoothing nodes """ from .smooth import NODE_CLASS_MAPPINGS as SMOOTH_MAPS, NODE_DISPLAY_NAME_MAPPINGS as SMOOTH_DISP +from .smooth_backends import NODE_CLASS_MAPPINGS as SMOOTH_BE_MAPS, NODE_DISPLAY_NAME_MAPPINGS as SMOOTH_BE_DISP from .sharpen import NODE_CLASS_MAPPINGS as SHARPEN_MAPS, NODE_DISPLAY_NAME_MAPPINGS as SHARPEN_DISP +from .sharpen_backends import NODE_CLASS_MAPPINGS as SHARPEN_BE_MAPS, NODE_DISPLAY_NAME_MAPPINGS as SHARPEN_BE_DISP NODE_CLASS_MAPPINGS = {} NODE_DISPLAY_NAME_MAPPINGS = {} @@ -14,7 +16,13 @@ NODE_DISPLAY_NAME_MAPPINGS = {} NODE_CLASS_MAPPINGS.update(SMOOTH_MAPS) NODE_DISPLAY_NAME_MAPPINGS.update(SMOOTH_DISP) +NODE_CLASS_MAPPINGS.update(SMOOTH_BE_MAPS) +NODE_DISPLAY_NAME_MAPPINGS.update(SMOOTH_BE_DISP) + NODE_CLASS_MAPPINGS.update(SHARPEN_MAPS) NODE_DISPLAY_NAME_MAPPINGS.update(SHARPEN_DISP) +NODE_CLASS_MAPPINGS.update(SHARPEN_BE_MAPS) +NODE_DISPLAY_NAME_MAPPINGS.update(SHARPEN_BE_DISP) + __all__ = ['NODE_CLASS_MAPPINGS', 'NODE_DISPLAY_NAME_MAPPINGS'] diff --git a/nodes/main/smoothing/sharpen.py b/nodes/main/smoothing/sharpen.py index 75d34c99..f73cfa85 100644 --- a/nodes/main/smoothing/sharpen.py +++ b/nodes/main/smoothing/sharpen.py @@ -2,7 +2,10 @@ # Copyright (C) 2025 ComfyUI-GeometryPack Contributors """ -Sharpen Mesh Node - Edge-recovering sharpening for CAD reconstruction prep. +Unified Sharpen Mesh Node - Single frontend with backend selector. + +Uses ComfyUI's node expansion (GraphBuilder) to dispatch to hidden +backend-specific nodes. Available backends: - two_step: Two-phase bilateral normal filtering via pymeshlab. Smooths face @@ -35,862 +38,36 @@ Available backends: """ import logging -import numpy as np -import trimesh as trimesh_module from comfy_api.latest import io log = logging.getLogger("geometrypack") -# --------------------------------------------------------------------------- -# Shared helpers -# --------------------------------------------------------------------------- - -def _compute_face_geometry(V, F): - """Compute face normals, centroids, and areas from vertex/face arrays. - - Returns: - normals: (m, 3) unit face normals - centroids: (m, 3) face centroids - areas: (m,) face areas - """ - e1 = V[F[:, 1]] - V[F[:, 0]] - e2 = V[F[:, 2]] - V[F[:, 0]] - cross = np.cross(e1, e2) - area_2x = np.linalg.norm(cross, axis=1, keepdims=True) - normals = cross / (area_2x + 1e-12) - areas = area_2x.ravel() * 0.5 - centroids = (V[F[:, 0]] + V[F[:, 1]] + V[F[:, 2]]) / 3.0 - return normals, centroids, areas - - -def _update_vertices_from_normals(V, F, target_normals, vertex_iterations, - fixed_boundary=False): - """Update vertex positions to match target face normals via iterative - projection. For each iteration, projects each vertex onto the planes - defined by the target normals of its adjacent faces, then averages. - - Matches MeshDenoisingBase::updateVertexPosition from the C++ reference: - p += (1/N) * sum_j n_j * dot(n_j, c_j - p) - - Args: - fixed_boundary: If True, boundary vertices are kept in place. - - Returns: - V_new: (n, 3) updated vertex positions - """ - V = V.copy() - n_verts = len(V) - m_faces = len(F) - - # Detect boundary vertices if needed - if fixed_boundary: - edge_face_count = {} - for fi in range(m_faces): - for i in range(3): - e = (min(F[fi][i], F[fi][(i + 1) % 3]), - max(F[fi][i], F[fi][(i + 1) % 3])) - edge_face_count[e] = edge_face_count.get(e, 0) + 1 - is_boundary = np.zeros(n_verts, dtype=bool) - for (v0, v1), cnt in edge_face_count.items(): - if cnt == 1: - is_boundary[v0] = True - is_boundary[v1] = True - else: - is_boundary = None - - for _ in range(vertex_iterations): - new_V = np.zeros_like(V) - counts = np.zeros(n_verts) - - # Compute current centroids - centroids = (V[F[:, 0]] + V[F[:, 1]] + V[F[:, 2]]) / 3.0 - - for fi in range(m_faces): - c = centroids[fi] - n = target_normals[fi] - for vi in F[fi]: - if is_boundary is not None and is_boundary[vi]: - continue - d = np.dot(V[vi] - c, n) - new_V[vi] += V[vi] - d * n - counts[vi] += 1 - - mask = counts > 0 - new_V[mask] /= counts[mask, None] - new_V[~mask] = V[~mask] - V = new_V - - return V - - -def _build_vertex_to_faces(n_verts, F): - """Build vertex-to-face adjacency list. Returns list of lists.""" - vtf = [[] for _ in range(n_verts)] - for fi in range(len(F)): - for vi in F[fi]: - vtf[vi].append(fi) - return vtf - - -def _build_vertex_based_face_neighbors(F, vert_to_faces, include_central=True): - """Build vertex-based face neighbor lists. - - Two faces are neighbors if they share at least one vertex. - Returns list of sorted index lists, one per face. - """ - m = len(F) - neighbors = [] - for fi in range(m): - nbrs = set() - for vi in F[fi]: - for fj in vert_to_faces[vi]: - nbrs.add(fj) - if not include_central: - nbrs.discard(fi) - neighbors.append(sorted(nbrs)) - return neighbors - - -# --------------------------------------------------------------------------- -# Backend: pymeshlab two-step bilateral normal sharpening -# --------------------------------------------------------------------------- - -def _pymeshlab_two_step_sharpen(mesh, smooth_steps, normal_threshold, - normal_iterations, fit_iterations, selected_only): - """Two-step bilateral normal sharpening via PyMeshLab.""" - try: - import pymeshlab - except (ImportError, OSError): - return None, "pymeshlab is not installed. Install with: pip install pymeshlab" - - ms = pymeshlab.MeshSet() - pml_mesh = pymeshlab.Mesh( - vertex_matrix=np.asarray(mesh.vertices, dtype=np.float64), - face_matrix=np.asarray(mesh.faces, dtype=np.int32), - ) - ms.add_mesh(pml_mesh) - - try: - ms.apply_coord_two_steps_smoothing( - stepsmoothnum=smooth_steps, - normalthr=normal_threshold, - stepnormalnum=normal_iterations, - stepfitnum=fit_iterations, - selected=selected_only, - ) - except AttributeError: - try: - ms.two_steps_smooth( - stepsmoothnum=smooth_steps, - normalthr=normal_threshold, - stepnormalnum=normal_iterations, - stepfitnum=fit_iterations, - selected=selected_only, - ) - except AttributeError: - return None, ( - "PyMeshLab two-step smoothing filter not available. " - "Ensure pymeshlab >= 2023.12 is installed." - ) - - out = ms.current_mesh() - result = trimesh_module.Trimesh( - vertices=out.vertex_matrix(), - faces=out.face_matrix(), - process=False, - ) - return result, "" - - -# --------------------------------------------------------------------------- -# Backend: pymeshlab geometric unsharp mask -# --------------------------------------------------------------------------- - -def _pymeshlab_unsharp_mask_sharpen(mesh, weight, weight_original, iterations): - """Geometric unsharp mask sharpening via PyMeshLab.""" - try: - import pymeshlab - except (ImportError, OSError): - return None, "pymeshlab is not installed. Install with: pip install pymeshlab" - - ms = pymeshlab.MeshSet() - pml_mesh = pymeshlab.Mesh( - vertex_matrix=np.asarray(mesh.vertices, dtype=np.float64), - face_matrix=np.asarray(mesh.faces, dtype=np.int32), - ) - ms.add_mesh(pml_mesh) - - try: - ms.apply_coord_unsharp_mask( - weight=weight, - weightorig=weight_original, - iterations=iterations, - ) - except AttributeError: - try: - ms.coord_unsharp_mask( - weight=weight, - weightorig=weight_original, - iterations=iterations, - ) - except AttributeError: - return None, ( - "PyMeshLab coordinate unsharp mask filter not available. " - "Ensure pymeshlab >= 2023.12 is installed." - ) - - out = ms.current_mesh() - result = trimesh_module.Trimesh( - vertices=out.vertex_matrix(), - faces=out.face_matrix(), - process=False, - ) - return result, "" - - -# --------------------------------------------------------------------------- -# Backend: libigl cotangent-weighted unsharp mask -# --------------------------------------------------------------------------- - -def _libigl_unsharp_sharpen(mesh, lambda_, iterations): - """Cotangent-weighted geometric unsharp mask via libigl. - - V_sharp = V + lambda * M^{-1} * L * V - where L is the cotangent Laplacian and M is the Voronoi mass matrix. - """ - try: - import igl - except ImportError: - return None, "libigl is not installed. Install with: pip install libigl" - - V = np.asarray(mesh.vertices, dtype=np.float64) - F = np.asarray(mesh.faces, dtype=np.int64) - - if len(V) == 0 or len(F) == 0: - return None, "Empty mesh (no vertices or faces)." - - L = igl.cotmatrix(V, F) - M = igl.massmatrix(V, F, igl.MASSMATRIX_TYPE_VORONOI) - - # M is diagonal — invert via element-wise reciprocal - M_diag = M.diagonal().copy() - M_diag[M_diag == 0] = 1e-12 - M_inv = 1.0 / M_diag - - for _ in range(iterations): - # L @ V gives curvature-weighted displacement (n, 3) - LV = L @ V - delta = M_inv[:, None] * LV - V = V + lambda_ * delta - - result = trimesh_module.Trimesh( - vertices=V, - faces=np.asarray(mesh.faces, dtype=np.int32), - process=False, - ) - return result, "" - - -# --------------------------------------------------------------------------- -# Backend: L0 normal minimization (He & Schaefer 2013) -# --------------------------------------------------------------------------- - -def _l0_minimize_sharpen(mesh, alpha, beta, iterations): - """L0 normal minimization for piecewise-flat sharpening. - - Iteratively thresholds face normal differences across edges: if the - difference is below the current alpha threshold, adjacent normals are - snapped to their area-weighted average. Alpha grows by factor beta each - iteration, progressively eliminating small variations and forcing the - mesh into piecewise-constant normal regions with sharp edges at - boundaries. - """ - V = np.asarray(mesh.vertices, dtype=np.float64) - F = np.asarray(mesh.faces, dtype=np.int64) - - if len(V) == 0 or len(F) == 0: - return None, "Empty mesh (no vertices or faces)." - - adj_pairs = np.asarray(mesh.face_adjacency) - if len(adj_pairs) == 0: - return None, "Mesh has no face adjacency (disconnected or degenerate)." - - normals, centroids, areas = _compute_face_geometry(V, F) - current_alpha = alpha - - for it in range(iterations): - # Recompute normals from current vertex positions - normals, centroids, areas = _compute_face_geometry(V, F) - target_normals = normals.copy() - - # Threshold: snap adjacent normals whose difference^2 < current_alpha - for ei in range(len(adj_pairs)): - fi, fj = adj_pairs[ei] - ni, nj = target_normals[fi], target_normals[fj] - diff_sq = np.sum((ni - nj) ** 2) - if diff_sq < current_alpha: - ai, aj = areas[fi], areas[fj] - avg = (ai * ni + aj * nj) / (ai + aj + 1e-12) - norm_len = np.linalg.norm(avg) - if norm_len > 1e-12: - avg /= norm_len - target_normals[fi] = avg - target_normals[fj] = avg - - # Update vertex positions to match target normals - V = _update_vertices_from_normals(V, F, target_normals, vertex_iterations=1) - - current_alpha *= beta - log.debug("L0 iteration %d: alpha=%.6f", it, current_alpha) - - result = trimesh_module.Trimesh( - vertices=V, - faces=np.asarray(mesh.faces, dtype=np.int32), - process=False, - ) - return result, "" - - -# --------------------------------------------------------------------------- -# Backend: guided bilateral normal filtering + vertex update -# --------------------------------------------------------------------------- - -def _guided_normal_sharpen(mesh, normal_iterations, vertex_iterations, - sigma_s, sigma_r): - """Guided mesh normal filtering with interleaved vertex update. - - Matches GuidedMeshNormalFiltering::updateFilteredNormalsLocalScheme from - the C++ reference (Zhang et al. 2015). - - Per iteration: - 1. Recompute geometry from current vertices - 2. Compute guidance normals via min-range-metric: for each face's - vertex-based 1-ring, compute a sharpness metric (maxdiff * max_tv / - sum_tv). Pick the neighbor with minimum metric and use its area-weighted - average normal as guidance. - 3. Bilateral filter normals using guidance for range weight - 4. Immediately update vertex positions (interleaved) - """ - V = np.asarray(mesh.vertices, dtype=np.float64) - F = np.asarray(mesh.faces, dtype=np.int64) - - if len(V) == 0 or len(F) == 0: - return None, "Empty mesh (no vertices or faces)." - - m = len(F) - adj_pairs = np.asarray(mesh.face_adjacency) - if len(adj_pairs) == 0: - return None, "Mesh has no face adjacency (disconnected or degenerate)." - - # Build vertex-based face neighbor structures - vert_to_faces = _build_vertex_to_faces(len(V), F) - # Guided neighborhood: vertex-based including central face - guided_neighbors = _build_vertex_based_face_neighbors(F, vert_to_faces, include_central=True) - # Filtering neighborhood: same vertex-based including central - filter_neighbors = guided_neighbors - - # Precompute face-to-adjacency mapping for inner edge computation - face_to_adj = [[] for _ in range(m)] - for ai in range(len(adj_pairs)): - fi, fj = adj_pairs[ai] - face_to_adj[fi].append(ai) - face_to_adj[fj].append(ai) - - for normal_iter in range(normal_iterations): - # Recompute geometry from current vertex positions - normals, centroids, areas = _compute_face_geometry(V, F) - - # Compute sigma_s in absolute units (avg centroid-to-centroid distance * multiple) - edge_lens = np.concatenate([ - np.linalg.norm(V[F[:, 1]] - V[F[:, 0]], axis=1), - np.linalg.norm(V[F[:, 2]] - V[F[:, 1]], axis=1), - np.linalg.norm(V[F[:, 0]] - V[F[:, 2]], axis=1), - ]) - avg_edge_len = float(np.mean(edge_lens)) - sigma_s_abs = sigma_s * avg_edge_len - sigma_s_sq2 = 2.0 * sigma_s_abs * sigma_s_abs - sigma_r_sq2 = 2.0 * sigma_r * sigma_r - - # --- Step 1: Compute guidance normals via min-range-metric --- - # For each face compute (metric, area_weighted_avg_normal) over its - # guided neighborhood, matching getRangeAndMeanNormal in the C++ code. - metrics = np.zeros(m) - avg_normals = np.zeros((m, 3)) - - for fi in range(m): - patch = guided_neighbors[fi] - n_patch = len(patch) - - # Area-weighted average normal - patch_areas = areas[patch] - patch_normals = normals[patch] - avg_n = np.sum(patch_normals * patch_areas[:, None], axis=0) - norm_len = np.linalg.norm(avg_n) - if norm_len > 1e-12: - avg_n /= norm_len - avg_normals[fi] = avg_n - - # Max pairwise normal difference in patch - if n_patch > 1: - pn = normals[patch] - diffs = np.linalg.norm(pn[:, None, :] - pn[None, :, :], axis=2) - maxdiff = float(np.max(diffs)) - else: - maxdiff = 0.0 - - # Inner edges: adjacency pairs where both faces are in the patch - patch_set = set(patch) - max_tv = 0.0 - sum_tv = 0.0 - seen_edges = set() - for pf in patch: - for ai in face_to_adj[pf]: - if ai not in seen_edges: - seen_edges.add(ai) - fa, fb = adj_pairs[ai] - if fa in patch_set and fb in patch_set: - tv = float(np.linalg.norm(normals[fa] - normals[fb])) - if tv > max_tv: - max_tv = tv - sum_tv += tv - - metrics[fi] = maxdiff * max_tv / (sum_tv + 1e-9) - - # For each face, find the guided neighbor with minimum metric and use - # that neighbor's area-weighted average normal as guidance. - guided_normals = np.zeros((m, 3)) - for fi in range(m): - patch = guided_neighbors[fi] - min_metric = 1e8 - min_idx = patch[0] - for pf in patch: - if metrics[pf] < min_metric: - min_metric = metrics[pf] - min_idx = pf - guided_normals[fi] = avg_normals[min_idx] - - # --- Step 2: Bilateral filter normals using guidance --- - filtered = np.zeros((m, 3)) - for fi in range(m): - patch = filter_neighbors[fi] - if not patch: - filtered[fi] = normals[fi] - continue - - w_total = 0.0 - n_acc = np.zeros(3) - for fj in patch: - dist_sq = float(np.sum((centroids[fi] - centroids[fj]) ** 2)) - ws = np.exp(-dist_sq / (sigma_s_sq2 + 1e-12)) - - # Range weight uses guidance normal distance (matching C++ reference) - gdiff_sq = float(np.sum((guided_normals[fi] - guided_normals[fj]) ** 2)) - wr = np.exp(-gdiff_sq / (sigma_r_sq2 + 1e-12)) - - w = areas[fj] * ws * wr - n_acc += normals[fj] * w - w_total += w - - if w_total > 1e-12: - filtered[fi] = n_acc / w_total - else: - filtered[fi] = normals[fi] - - f_norms = np.linalg.norm(filtered, axis=1, keepdims=True) - filtered_normals = filtered / (f_norms + 1e-12) - - # --- Step 3: Interleaved vertex update (matching C++ reference) --- - V = _update_vertices_from_normals(V, F, filtered_normals, vertex_iterations) - - log.debug("Guided normal iteration %d/%d complete", normal_iter + 1, normal_iterations) - - result = trimesh_module.Trimesh( - vertices=V, - faces=np.asarray(mesh.faces, dtype=np.int32), - process=False, - ) - return result, "" - - -# --------------------------------------------------------------------------- -# Backend: Variational Shape Approximation face clustering + vertex snapping -# --------------------------------------------------------------------------- - -def _vsa_snap_sharpen(mesh, num_proxies, lloyd_iterations, snap_strength): - """Variational Shape Approximation clustering with vertex snapping. - - Clusters mesh faces into proxy plane groups via Lloyd iteration (using - area-weighted normal distance as the L2,1 metric), then snaps vertices: - - Interior vertices (one proxy): project onto proxy plane - - Edge vertices (two proxies): snap to plane intersection line - - Corner vertices (3+ proxies): snap to plane intersection point - """ - V = np.asarray(mesh.vertices, dtype=np.float64) - F = np.asarray(mesh.faces, dtype=np.int64) - - if len(V) == 0 or len(F) == 0: - return None, "Empty mesh (no vertices or faces)." - - m = len(F) - k = min(num_proxies, m) - - normals, centroids, areas = _compute_face_geometry(V, F) - - # --- Step 1: farthest-point seeding on area-weighted normal space --- - seeds = [0] - min_dists = np.full(m, np.inf) - - for _ in range(k - 1): - last = seeds[-1] - d = areas * np.linalg.norm(normals - normals[last], axis=1) - min_dists = np.minimum(min_dists, d) - seeds.append(int(np.argmax(min_dists))) - - proxy_normals = normals[seeds].copy() - proxy_centroids = centroids[seeds].copy() - - # --- Step 2: Lloyd iteration --- - labels = np.zeros(m, dtype=np.int32) - - for _ in range(lloyd_iterations): - # Assign each face to nearest proxy (L2,1 metric) - # (m, k) distance matrix - normal_diffs = np.linalg.norm( - normals[:, None, :] - proxy_normals[None, :, :], axis=2 - ) - weighted_diffs = areas[:, None] * normal_diffs - labels = np.argmin(weighted_diffs, axis=1).astype(np.int32) - - # Refit proxies - for p in range(k): - mask = labels == p - if not np.any(mask): - # Empty cluster — re-seed from farthest face - assigned_dists = weighted_diffs[np.arange(m), labels] - worst = int(np.argmax(assigned_dists)) - proxy_normals[p] = normals[worst] - proxy_centroids[p] = centroids[worst] - continue - a = areas[mask] - total_a = np.sum(a) + 1e-12 - avg_n = np.sum(normals[mask] * a[:, None], axis=0) / total_a - norm_len = np.linalg.norm(avg_n) - if norm_len > 1e-12: - avg_n /= norm_len - proxy_normals[p] = avg_n - proxy_centroids[p] = np.sum(centroids[mask] * a[:, None], axis=0) / total_a - - # --- Step 3: snap vertices --- - # Build vertex-to-face adjacency - vertex_faces = [[] for _ in range(len(V))] - for fi in range(m): - for vi in F[fi]: - vertex_faces[vi].append(fi) - - V_snapped = V.copy() - - for vi in range(len(V)): - adj_fi = vertex_faces[vi] - if not adj_fi: - continue - - adj_labels = list(set(labels[fi] for fi in adj_fi)) - - if len(adj_labels) == 1: - # Interior: project onto single proxy plane - p = adj_labels[0] - n = proxy_normals[p] - c = proxy_centroids[p] - d = np.dot(V[vi] - c, n) - V_snapped[vi] = V[vi] - snap_strength * d * n - - elif len(adj_labels) == 2: - # Edge: snap to intersection of two planes - p1, p2 = adj_labels - n1, c1 = proxy_normals[p1], proxy_centroids[p1] - n2, c2 = proxy_normals[p2], proxy_centroids[p2] - - line_dir = np.cross(n1, n2) - line_dir_norm = np.linalg.norm(line_dir) - - if line_dir_norm < 1e-8: - # Nearly parallel planes — project onto average - avg_n = (n1 + n2) - avg_n_norm = np.linalg.norm(avg_n) - if avg_n_norm > 1e-12: - avg_n /= avg_n_norm - avg_c = (c1 + c2) / 2.0 - d = np.dot(V[vi] - avg_c, avg_n) - V_snapped[vi] = V[vi] - snap_strength * d * avg_n - else: - line_dir /= line_dir_norm - # Find a point on the intersection line - A = np.array([n1, n2]) - b = np.array([np.dot(n1, c1), np.dot(n2, c2)]) - try: - AAT = A @ A.T - p0 = A.T @ np.linalg.solve(AAT, b) - except np.linalg.LinAlgError: - continue - # Project vertex onto the line - v_rel = V[vi] - p0 - t = np.dot(v_rel, line_dir) - closest = p0 + t * line_dir - V_snapped[vi] = V[vi] + snap_strength * (closest - V[vi]) - - else: - # Corner: snap to intersection of 3+ planes - A = np.array([proxy_normals[p] for p in adj_labels]) - b = np.array([np.dot(proxy_normals[p], proxy_centroids[p]) - for p in adj_labels]) - try: - if len(adj_labels) == 3: - corner = np.linalg.solve(A, b) - else: - corner, _, _, _ = np.linalg.lstsq(A, b, rcond=None) - V_snapped[vi] = V[vi] + snap_strength * (corner - V[vi]) - except np.linalg.LinAlgError: - continue - - result = trimesh_module.Trimesh( - vertices=V_snapped, - faces=np.asarray(mesh.faces, dtype=np.int32), - process=False, - ) - return result, "" - - -# --------------------------------------------------------------------------- -# Backend: Fast and Effective Feature-Preserving Mesh Denoising -# (Sun et al. TVCG 2007) -# --------------------------------------------------------------------------- - -def _fast_effective_sharpen(mesh, threshold_T, normal_iterations, vertex_iterations): - """Fast and Effective Feature-Preserving Mesh Denoising. - - Ported from FastAndEffectiveFeaturePreservingMeshDenoising.cpp. - - Normal filtering weight: w = max(0, dot(ni, nj) - T)^2 - where T is a threshold controlling which normals contribute. - Faces with normals more similar than T contribute with quadratic weight; - faces with normals less similar than T contribute nothing. This produces - sharp edges at dihedral angles corresponding to the threshold. - """ - V = np.asarray(mesh.vertices, dtype=np.float64) - F = np.asarray(mesh.faces, dtype=np.int64) - - if len(V) == 0 or len(F) == 0: - return None, "Empty mesh (no vertices or faces)." - - m = len(F) - - # Build vertex-based face neighbors (including central face, matching C++) - vert_to_faces = _build_vertex_to_faces(len(V), F) - all_face_neighbor = _build_vertex_based_face_neighbors( - F, vert_to_faces, include_central=True) - - normals, _, _ = _compute_face_geometry(V, F) - previous_normals = normals.copy() - - # Iterative normal filtering - for it in range(normal_iterations): - filtered = np.zeros((m, 3)) - for fi in range(m): - ni = previous_normals[fi] - temp_normal = np.zeros(3) - for fj in all_face_neighbor[fi]: - nj = previous_normals[fj] - value = float(np.dot(ni, nj)) - threshold_T - weight = value * value if value > 0.0 else 0.0 - temp_normal += nj * weight - norm_len = np.linalg.norm(temp_normal) - if norm_len > 1e-12: - temp_normal /= norm_len - filtered[fi] = temp_normal - previous_normals = filtered - log.debug("Fast effective normal iter %d/%d", it + 1, normal_iterations) - - # Update vertex positions (with fixed boundary matching C++ reference) - V = _update_vertices_from_normals(V, F, previous_normals, vertex_iterations, - fixed_boundary=True) - - result = trimesh_module.Trimesh( - vertices=V, - faces=np.asarray(mesh.faces, dtype=np.int32), - process=False, - ) - return result, "" - - -# --------------------------------------------------------------------------- -# Backend: Non-Iterative, Feature-Preserving Mesh Smoothing -# (Jones et al. SIGGRAPH 2003) -# --------------------------------------------------------------------------- - -def _non_iterative_sharpen(mesh, sigma_f_ratio, sigma_g_ratio): - """Non-Iterative Feature-Preserving Mesh Smoothing. - - Ported from NonIterativeFeaturePreservingMeshFiltering.cpp. - - 1. Mollify normals: smooth vertex positions with Gaussian (sigma_m = sigma_f/2), - then recompute face normals on the smoothed mesh. - 2. For each vertex, BFS-expand to find faces within radius 2*sigma_f. - 3. Project vertex onto each neighbor face plane (using mollified normal + - original centroid). Weight by area * spatial_Gaussian(sigma_f) * - influence_Gaussian(sigma_g). Average projections. - """ - V = np.asarray(mesh.vertices, dtype=np.float64) - F = np.asarray(mesh.faces, dtype=np.int64) - - if len(V) == 0 or len(F) == 0: - return None, "Empty mesh (no vertices or faces)." - - m = len(F) - n = len(V) - - normals, centroids, areas = _compute_face_geometry(V, F) - - # Compute average edge length - edge_lens = np.concatenate([ - np.linalg.norm(V[F[:, 1]] - V[F[:, 0]], axis=1), - np.linalg.norm(V[F[:, 2]] - V[F[:, 1]], axis=1), - np.linalg.norm(V[F[:, 0]] - V[F[:, 2]], axis=1), - ]) - avg_edge_len = float(np.mean(edge_lens)) - - sigma_f = sigma_f_ratio * avg_edge_len - sigma_g = sigma_g_ratio * avg_edge_len - sigma_m = sigma_f / 2.0 - radius = 2.0 * sigma_f - - # Build vertex-to-face adjacency - vert_to_faces = _build_vertex_to_faces(n, F) - - # Build vertex-based face adjacency for BFS expansion (matching C++ kVertexBased) - vbased_face_adj = _build_vertex_based_face_neighbors( - F, vert_to_faces, include_central=False) - - def _bfs_faces_in_radius(vertex_pos, seed_faces, rad): - """BFS from seed faces, expanding via vertex-based neighbors within radius.""" - visited = set() - queue = [] - for fi in seed_faces: - dist = float(np.linalg.norm(vertex_pos - centroids[fi])) - if dist <= rad: - queue.append(fi) - visited.add(fi) - result_faces = [] - head = 0 - while head < len(queue): - fi = queue[head] - result_faces.append(fi) - head += 1 - for fj in vbased_face_adj[fi]: - if fj not in visited: - visited.add(fj) - dist = float(np.linalg.norm(vertex_pos - centroids[fj])) - if dist <= rad: - queue.append(fj) - return result_faces - - # --- Step 1: Mollified normals --- - # Smooth vertex positions with area-weighted Gaussian (sigma_m) - V_smooth = V.copy() - for vi in range(n): - pt = V[vi] - seed_faces = vert_to_faces[vi] - if not seed_faces: - continue - - faces_in_range = _bfs_faces_in_radius(pt, seed_faces, radius) - - new_pt = np.zeros(3) - w_sum = 0.0 - for fi in faces_in_range: - c = centroids[fi] - dist = float(np.linalg.norm(c - pt)) - w = np.exp(-0.5 * dist * dist / (sigma_m * sigma_m + 1e-12)) - a = areas[fi] - new_pt += c * a * w - w_sum += a * w - - if w_sum > 1e-12: - V_smooth[vi] = new_pt / w_sum - - # Recompute normals on the smoothed mesh - mollified_normals, _, _ = _compute_face_geometry(V_smooth, F) - - # --- Step 2: Single-pass bilateral vertex update --- - V_new = V.copy() - sigma_f_sq = sigma_f * sigma_f - sigma_g_sq = sigma_g * sigma_g - - for vi in range(n): - pt = V[vi] - seed_faces = vert_to_faces[vi] - if not seed_faces: - continue - - faces_in_range = _bfs_faces_in_radius(pt, seed_faces, radius) - if not faces_in_range: - continue - - temp_pt = np.zeros(3) - w_sum = 0.0 - for fi in faces_in_range: - c = centroids[fi] - mn = mollified_normals[fi] - - # Project vertex onto face plane (mollified normal + original centroid) - proj = pt - mn * float(np.dot(pt - c, mn)) - - dist_spatial = float(np.linalg.norm(c - pt)) - w_spatial = np.exp(-0.5 * dist_spatial * dist_spatial / (sigma_f_sq + 1e-12)) - - dist_influence = float(np.linalg.norm(proj - pt)) - w_influence = np.exp(-0.5 * dist_influence * dist_influence / (sigma_g_sq + 1e-12)) - - a = areas[fi] - temp_pt += proj * a * w_spatial * w_influence - w_sum += a * w_spatial * w_influence - - if w_sum > 1e-12: - V_new[vi] = temp_pt / w_sum - - result = trimesh_module.Trimesh( - vertices=V_new, - faces=np.asarray(mesh.faces, dtype=np.int32), - process=False, - ) - return result, "" - - -# --------------------------------------------------------------------------- -# Node class -# --------------------------------------------------------------------------- - class SharpenMeshNode(io.ComfyNode): """ - Sharpen Mesh - Edge-recovering sharpening algorithms for CAD reconstruction prep. + Sharpen Mesh - Unified sharpening with backend selection. - Available backends: - - two_step: Bilateral normal filtering then vertex repositioning (pymeshlab). - - unsharp_mask: Geometric unsharp masking (pymeshlab). - - libigl_unsharp: Cotangent-weighted geometric unsharp mask (libigl). - - l0_minimize: L0 normal minimization (He & Schaefer 2013). - - guided_normal: Guided mesh normal filtering (Zhang et al. 2015). - - vsa_snap: Variational Shape Approximation face clustering + vertex snapping. - - fast_effective: Thresholded cosine-weight normal filtering (Sun et al. 2007). - - non_iterative: Mollified-normal single-pass bilateral filtering (Jones et al. 2003). + Dispatches to hidden backend nodes via node expansion. """ + BACKEND_MAP = { + "two_step": "GeomPackSharpen_TwoStep", + "unsharp_mask": "GeomPackSharpen_UnsharpMask", + "libigl_unsharp": "GeomPackSharpen_LibiglUnsharp", + "l0_minimize": "GeomPackSharpen_L0Minimize", + "guided_normal": "GeomPackSharpen_GuidedNormal", + "vsa_snap": "GeomPackSharpen_VSASnap", + "fast_effective": "GeomPackSharpen_FastEffective", + "non_iterative": "GeomPackSharpen_NonIterative", + } + @classmethod def define_schema(cls): return io.Schema( node_id="GeomPackSharpenMesh", display_name="Sharpen Mesh", category="geompack/smoothing", + enable_expand=True, is_output_node=True, inputs=[ io.Custom("TRIMESH").Input("trimesh"), @@ -1038,171 +215,30 @@ class SharpenMeshNode(io.ComfyNode): @classmethod def execute(cls, trimesh, backend): - """Apply mesh sharpening based on selected 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] - initial_vertices = len(trimesh.vertices) - initial_faces = len(trimesh.faces) + log.info("Sharpen dispatch: %s -> %s", selected, node_id) - log.info("Sharpen backend: %s", selected) - log.info("Input: %s vertices, %s faces", - f"{initial_vertices:,}", f"{initial_faces:,}") + kwargs = {"trimesh": trimesh} + for k, v in backend.items(): + if k == "backend": + continue + kwargs[k] = v - if selected == "two_step": - smooth_steps = backend.get("smooth_steps", 3) - normal_threshold = backend.get("normal_threshold", 60.0) - log.info("Parameters: smooth_steps=%d, normal_threshold=%.1f", - smooth_steps, normal_threshold) - sharpened, error = _pymeshlab_two_step_sharpen( - trimesh, smooth_steps, normal_threshold, - normal_iterations=20, fit_iterations=20, - selected_only=False, - ) - elif selected == "unsharp_mask": - weight = backend.get("weight", 0.3) - iterations = backend.get("iterations", 5) - log.info("Parameters: weight=%.3f, iterations=%d", weight, iterations) - sharpened, error = _pymeshlab_unsharp_mask_sharpen( - trimesh, weight, weight_original=1.0, iterations=iterations, - ) - elif selected == "libigl_unsharp": - lambda_ = backend.get("lambda_", 0.5) - iterations = backend.get("iterations", 3) - log.info("Parameters: lambda=%.3f, iterations=%d", lambda_, iterations) - sharpened, error = _libigl_unsharp_sharpen( - trimesh, lambda_, iterations, - ) - elif selected == "l0_minimize": - alpha = backend.get("alpha", 0.001) - beta_val = backend.get("beta", 2.0) - iterations = backend.get("iterations", 10) - log.info("Parameters: alpha=%.4f, beta=%.1f, iterations=%d", - alpha, beta_val, iterations) - sharpened, error = _l0_minimize_sharpen( - trimesh, alpha, beta_val, iterations, - ) - elif selected == "guided_normal": - normal_iterations = backend.get("normal_iterations", 5) - vertex_iterations = backend.get("vertex_iterations", 10) - sigma_s = backend.get("sigma_s", 1.0) - sigma_r = backend.get("sigma_r", 0.35) - log.info("Parameters: normal_iter=%d, vertex_iter=%d, sigma_s=%.2f, sigma_r=%.3f", - normal_iterations, vertex_iterations, sigma_s, sigma_r) - sharpened, error = _guided_normal_sharpen( - trimesh, normal_iterations, vertex_iterations, sigma_s, sigma_r, - ) - elif selected == "vsa_snap": - num_proxies = backend.get("num_proxies", 20) - lloyd_iterations = backend.get("lloyd_iterations", 10) - snap_strength = backend.get("snap_strength", 1.0) - log.info("Parameters: num_proxies=%d, lloyd_iter=%d, snap_strength=%.2f", - num_proxies, lloyd_iterations, snap_strength) - sharpened, error = _vsa_snap_sharpen( - trimesh, num_proxies, lloyd_iterations, snap_strength, - ) - elif selected == "fast_effective": - threshold_T = backend.get("threshold_T", 0.5) - normal_iterations = backend.get("normal_iterations", 20) - vertex_iterations = backend.get("vertex_iterations", 50) - log.info("Parameters: threshold_T=%.3f, normal_iter=%d, vertex_iter=%d", - threshold_T, normal_iterations, vertex_iterations) - sharpened, error = _fast_effective_sharpen( - trimesh, threshold_T, normal_iterations, vertex_iterations, - ) - elif selected == "non_iterative": - sigma_f = backend.get("sigma_f", 1.0) - sigma_g = backend.get("sigma_g", 1.0) - log.info("Parameters: sigma_f=%.3f, sigma_g=%.3f", sigma_f, sigma_g) - sharpened, error = _non_iterative_sharpen( - trimesh, sigma_f, sigma_g, - ) - else: - raise ValueError(f"Unknown backend: {selected}") + graph = GraphBuilder() + backend_node = graph.node(node_id, **kwargs) - if sharpened is None: - raise ValueError(f"Sharpening failed ({backend}): {error}") - - # Copy metadata - if hasattr(trimesh, "metadata") and trimesh.metadata: - sharpened.metadata = trimesh.metadata.copy() - sharpened.metadata["sharpening"] = { - "algorithm": selected, - "original_vertices": initial_vertices, - "original_faces": initial_faces, + return { + "result": (backend_node.out(0), backend_node.out(1)), + "expand": graph.finalize(), } - # Compute displacement stats - disp = np.linalg.norm( - np.asarray(sharpened.vertices) - np.asarray(trimesh.vertices), axis=1 - ) - avg_disp = float(np.mean(disp)) - max_disp = float(np.max(disp)) - - log.info("Output: %d vertices, %d faces", - len(sharpened.vertices), len(sharpened.faces)) - log.info("Avg vertex displacement: %.6f, max: %.6f", avg_disp, max_disp) - - # Build backend-specific param block - if selected == "two_step": - param_text = ( - f"Smooth Steps: {smooth_steps}\n" - f"Normal Threshold: {normal_threshold}\u00b0" - ) - elif selected == "unsharp_mask": - param_text = ( - f"Weight: {weight}\n" - f"Iterations: {iterations}" - ) - elif selected == "libigl_unsharp": - param_text = ( - f"Lambda: {lambda_}\n" - f"Iterations: {iterations}" - ) - elif selected == "l0_minimize": - param_text = ( - f"Alpha: {alpha}\n" - f"Beta: {beta_val}\n" - f"Iterations: {iterations}" - ) - elif selected == "guided_normal": - param_text = ( - f"Normal Iterations: {normal_iterations}\n" - f"Vertex Iterations: {vertex_iterations}\n" - f"Sigma S: {sigma_s}\n" - f"Sigma R: {sigma_r}" - ) - elif selected == "vsa_snap": - param_text = ( - f"Num Proxies: {num_proxies}\n" - f"Lloyd Iterations: {lloyd_iterations}\n" - f"Snap Strength: {snap_strength}" - ) - elif selected == "fast_effective": - param_text = ( - f"Threshold T: {threshold_T}\n" - f"Normal Iterations: {normal_iterations}\n" - f"Vertex Iterations: {vertex_iterations}" - ) - elif selected == "non_iterative": - param_text = ( - f"Sigma F: {sigma_f}\n" - f"Sigma G: {sigma_g}" - ) - else: - param_text = "" - - info = f"""Sharpen Mesh Results ({selected}): - -{param_text} - -Vertices: {initial_vertices:,} (unchanged) -Faces: {initial_faces:,} (unchanged) - -Displacement: - Average: {avg_disp:.6f} - Maximum: {max_disp:.6f} -""" - return io.NodeOutput(sharpened, info, ui={"text": [info]}) NODE_CLASS_MAPPINGS = { "GeomPackSharpenMesh": SharpenMeshNode, diff --git a/nodes/main/smoothing/sharpen_backends/__init__.py b/nodes/main/smoothing/sharpen_backends/__init__.py new file mode 100644 index 00000000..63392352 --- /dev/null +++ b/nodes/main/smoothing/sharpen_backends/__init__.py @@ -0,0 +1,38 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2025 ComfyUI-GeometryPack Contributors + +""" +Individual sharpening backend nodes (hidden from UI, invoked via unified Sharpen Mesh node). +""" + +from .two_step import NODE_CLASS_MAPPINGS as TS_MAPS, NODE_DISPLAY_NAME_MAPPINGS as TS_DISP +from .unsharp_mask import NODE_CLASS_MAPPINGS as UM_MAPS, NODE_DISPLAY_NAME_MAPPINGS as UM_DISP +from .libigl_unsharp import NODE_CLASS_MAPPINGS as LU_MAPS, NODE_DISPLAY_NAME_MAPPINGS as LU_DISP +from .l0_minimize import NODE_CLASS_MAPPINGS as L0_MAPS, NODE_DISPLAY_NAME_MAPPINGS as L0_DISP +from .guided_normal import NODE_CLASS_MAPPINGS as GN_MAPS, NODE_DISPLAY_NAME_MAPPINGS as GN_DISP +from .vsa_snap import NODE_CLASS_MAPPINGS as VS_MAPS, NODE_DISPLAY_NAME_MAPPINGS as VS_DISP +from .fast_effective import NODE_CLASS_MAPPINGS as FE_MAPS, NODE_DISPLAY_NAME_MAPPINGS as FE_DISP +from .non_iterative import NODE_CLASS_MAPPINGS as NI_MAPS, NODE_DISPLAY_NAME_MAPPINGS as NI_DISP + +NODE_CLASS_MAPPINGS = {} +NODE_DISPLAY_NAME_MAPPINGS = {} + +NODE_CLASS_MAPPINGS.update(TS_MAPS) +NODE_CLASS_MAPPINGS.update(UM_MAPS) +NODE_CLASS_MAPPINGS.update(LU_MAPS) +NODE_CLASS_MAPPINGS.update(L0_MAPS) +NODE_CLASS_MAPPINGS.update(GN_MAPS) +NODE_CLASS_MAPPINGS.update(VS_MAPS) +NODE_CLASS_MAPPINGS.update(FE_MAPS) +NODE_CLASS_MAPPINGS.update(NI_MAPS) + +NODE_DISPLAY_NAME_MAPPINGS.update(TS_DISP) +NODE_DISPLAY_NAME_MAPPINGS.update(UM_DISP) +NODE_DISPLAY_NAME_MAPPINGS.update(LU_DISP) +NODE_DISPLAY_NAME_MAPPINGS.update(L0_DISP) +NODE_DISPLAY_NAME_MAPPINGS.update(GN_DISP) +NODE_DISPLAY_NAME_MAPPINGS.update(VS_DISP) +NODE_DISPLAY_NAME_MAPPINGS.update(FE_DISP) +NODE_DISPLAY_NAME_MAPPINGS.update(NI_DISP) + +__all__ = ['NODE_CLASS_MAPPINGS', 'NODE_DISPLAY_NAME_MAPPINGS'] diff --git a/nodes/main/smoothing/sharpen_backends/_helpers.py b/nodes/main/smoothing/sharpen_backends/_helpers.py new file mode 100644 index 00000000..bb2dadab --- /dev/null +++ b/nodes/main/smoothing/sharpen_backends/_helpers.py @@ -0,0 +1,112 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2025 ComfyUI-GeometryPack Contributors + +"""Shared helper functions for sharpen backend nodes.""" + +import numpy as np + + +def _compute_face_geometry(V, F): + """Compute face normals, centroids, and areas from vertex/face arrays. + + Returns: + normals: (m, 3) unit face normals + centroids: (m, 3) face centroids + areas: (m,) face areas + """ + e1 = V[F[:, 1]] - V[F[:, 0]] + e2 = V[F[:, 2]] - V[F[:, 0]] + cross = np.cross(e1, e2) + area_2x = np.linalg.norm(cross, axis=1, keepdims=True) + normals = cross / (area_2x + 1e-12) + areas = area_2x.ravel() * 0.5 + centroids = (V[F[:, 0]] + V[F[:, 1]] + V[F[:, 2]]) / 3.0 + return normals, centroids, areas + + +def _update_vertices_from_normals(V, F, target_normals, vertex_iterations, + fixed_boundary=False): + """Update vertex positions to match target face normals via iterative + projection. For each iteration, projects each vertex onto the planes + defined by the target normals of its adjacent faces, then averages. + + Matches MeshDenoisingBase::updateVertexPosition from the C++ reference: + p += (1/N) * sum_j n_j * dot(n_j, c_j - p) + + Args: + fixed_boundary: If True, boundary vertices are kept in place. + + Returns: + V_new: (n, 3) updated vertex positions + """ + V = V.copy() + n_verts = len(V) + m_faces = len(F) + + # Detect boundary vertices if needed + if fixed_boundary: + edge_face_count = {} + for fi in range(m_faces): + for i in range(3): + e = (min(F[fi][i], F[fi][(i + 1) % 3]), + max(F[fi][i], F[fi][(i + 1) % 3])) + edge_face_count[e] = edge_face_count.get(e, 0) + 1 + is_boundary = np.zeros(n_verts, dtype=bool) + for (v0, v1), cnt in edge_face_count.items(): + if cnt == 1: + is_boundary[v0] = True + is_boundary[v1] = True + else: + is_boundary = None + + for _ in range(vertex_iterations): + new_V = np.zeros_like(V) + counts = np.zeros(n_verts) + + # Compute current centroids + centroids = (V[F[:, 0]] + V[F[:, 1]] + V[F[:, 2]]) / 3.0 + + for fi in range(m_faces): + c = centroids[fi] + n = target_normals[fi] + for vi in F[fi]: + if is_boundary is not None and is_boundary[vi]: + continue + d = np.dot(V[vi] - c, n) + new_V[vi] += V[vi] - d * n + counts[vi] += 1 + + mask = counts > 0 + new_V[mask] /= counts[mask, None] + new_V[~mask] = V[~mask] + V = new_V + + return V + + +def _build_vertex_to_faces(n_verts, F): + """Build vertex-to-face adjacency list. Returns list of lists.""" + vtf = [[] for _ in range(n_verts)] + for fi in range(len(F)): + for vi in F[fi]: + vtf[vi].append(fi) + return vtf + + +def _build_vertex_based_face_neighbors(F, vert_to_faces, include_central=True): + """Build vertex-based face neighbor lists. + + Two faces are neighbors if they share at least one vertex. + Returns list of sorted index lists, one per face. + """ + m = len(F) + neighbors = [] + for fi in range(m): + nbrs = set() + for vi in F[fi]: + for fj in vert_to_faces[vi]: + nbrs.add(fj) + if not include_central: + nbrs.discard(fi) + neighbors.append(sorted(nbrs)) + return neighbors diff --git a/nodes/main/smoothing/sharpen_backends/fast_effective.py b/nodes/main/smoothing/sharpen_backends/fast_effective.py new file mode 100644 index 00000000..b084aef5 --- /dev/null +++ b/nodes/main/smoothing/sharpen_backends/fast_effective.py @@ -0,0 +1,173 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2025 ComfyUI-GeometryPack Contributors + +"""Fast and Effective Feature-Preserving Mesh Denoising backend node +(Sun et al. TVCG 2007).""" + +import logging +import numpy as np +import trimesh as trimesh_module +from comfy_api.latest import io +from ._helpers import ( + _compute_face_geometry, + _update_vertices_from_normals, + _build_vertex_to_faces, + _build_vertex_based_face_neighbors, +) + +log = logging.getLogger("geometrypack") + + +def _fast_effective_sharpen(mesh, threshold_T, normal_iterations, vertex_iterations): + """Fast and Effective Feature-Preserving Mesh Denoising. + + Ported from FastAndEffectiveFeaturePreservingMeshDenoising.cpp. + + Normal filtering weight: w = max(0, dot(ni, nj) - T)^2 + where T is a threshold controlling which normals contribute. + Faces with normals more similar than T contribute with quadratic weight; + faces with normals less similar than T contribute nothing. This produces + sharp edges at dihedral angles corresponding to the threshold. + """ + V = np.asarray(mesh.vertices, dtype=np.float64) + F = np.asarray(mesh.faces, dtype=np.int64) + + if len(V) == 0 or len(F) == 0: + return None, "Empty mesh (no vertices or faces)." + + m = len(F) + + # Build vertex-based face neighbors (including central face, matching C++) + vert_to_faces = _build_vertex_to_faces(len(V), F) + all_face_neighbor = _build_vertex_based_face_neighbors( + F, vert_to_faces, include_central=True) + + normals, _, _ = _compute_face_geometry(V, F) + previous_normals = normals.copy() + + # Iterative normal filtering + for it in range(normal_iterations): + filtered = np.zeros((m, 3)) + for fi in range(m): + ni = previous_normals[fi] + temp_normal = np.zeros(3) + for fj in all_face_neighbor[fi]: + nj = previous_normals[fj] + value = float(np.dot(ni, nj)) - threshold_T + weight = value * value if value > 0.0 else 0.0 + temp_normal += nj * weight + norm_len = np.linalg.norm(temp_normal) + if norm_len > 1e-12: + temp_normal /= norm_len + filtered[fi] = temp_normal + previous_normals = filtered + log.debug("Fast effective normal iter %d/%d", it + 1, normal_iterations) + + # Update vertex positions (with fixed boundary matching C++ reference) + V = _update_vertices_from_normals(V, F, previous_normals, vertex_iterations, + fixed_boundary=True) + + result = trimesh_module.Trimesh( + vertices=V, + faces=np.asarray(mesh.faces, dtype=np.int32), + process=False, + ) + return result, "" + + +class SharpenFastEffectiveNode(io.ComfyNode): + """Fast and Effective Feature-Preserving Mesh Denoising backend.""" + + @classmethod + def define_schema(cls): + return io.Schema( + node_id="GeomPackSharpen_FastEffective", + display_name="Sharpen Fast Effective (backend)", + category="geompack/smoothing", + is_dev_only=True, + is_output_node=True, + inputs=[ + io.Custom("TRIMESH").Input("trimesh"), + io.Float.Input("threshold_T", default=0.5, min=1e-10, max=1.0, step=0.01, tooltip=( + "Cosine similarity threshold (Sun et al. TVCG 2007). " + "Normals with dot(ni,nj) > T contribute with weight " + "(dot-T)^2; below T they contribute nothing. " + "Lower = more normals averaged (smoother), " + "higher = only very similar normals averaged (sharper). " + "0.5 is a good default." + )), + io.Int.Input("normal_iterations", default=20, min=1, max=500, step=1, tooltip=( + "Iterations for normal filtering. More iterations " + "produce stronger flattening of near-flat regions." + )), + io.Int.Input("vertex_iterations", default=50, min=1, max=500, step=1, tooltip=( + "Iterations for vertex position update from filtered " + "normals. Boundary vertices are kept fixed." + )), + ], + outputs=[ + io.Custom("TRIMESH").Output(display_name="sharpened_mesh"), + io.String.Output(display_name="info"), + ], + ) + + @classmethod + def execute(cls, trimesh, threshold_T=0.5, normal_iterations=20, + vertex_iterations=50): + log.info("Backend: fast_effective") + log.info("Input: %d vertices, %d faces", len(trimesh.vertices), len(trimesh.faces)) + log.info("Parameters: threshold_T=%.3f, normal_iter=%d, vertex_iter=%d", + threshold_T, normal_iterations, vertex_iterations) + + initial_vertices = len(trimesh.vertices) + initial_faces = len(trimesh.faces) + + sharpened, error = _fast_effective_sharpen( + trimesh, threshold_T, normal_iterations, vertex_iterations, + ) + + if sharpened is None: + raise ValueError(f"Sharpening failed (fast_effective): {error}") + + # Copy metadata + if hasattr(trimesh, "metadata") and trimesh.metadata: + sharpened.metadata = trimesh.metadata.copy() + sharpened.metadata["sharpening"] = { + "algorithm": "fast_effective", + "original_vertices": initial_vertices, + "original_faces": initial_faces, + } + + # Compute displacement stats + disp = np.linalg.norm( + np.asarray(sharpened.vertices) - np.asarray(trimesh.vertices), axis=1 + ) + avg_disp = float(np.mean(disp)) + max_disp = float(np.max(disp)) + + log.info("Output: %d vertices, %d faces", + len(sharpened.vertices), len(sharpened.faces)) + log.info("Avg vertex displacement: %.6f, max: %.6f", avg_disp, max_disp) + + param_text = ( + f"Threshold T: {threshold_T}\n" + f"Normal Iterations: {normal_iterations}\n" + f"Vertex Iterations: {vertex_iterations}" + ) + + info = f"""Sharpen Mesh Results (fast_effective): + +{param_text} + +Vertices: {initial_vertices:,} (unchanged) +Faces: {initial_faces:,} (unchanged) + +Displacement: + Average: {avg_disp:.6f} + Maximum: {max_disp:.6f} +""" + return io.NodeOutput(sharpened, info, ui={"text": [info]}) + + +NODE_CLASS_MAPPINGS = {"GeomPackSharpen_FastEffective": SharpenFastEffectiveNode} +NODE_DISPLAY_NAME_MAPPINGS = {"GeomPackSharpen_FastEffective": "Sharpen Fast Effective (backend)"} diff --git a/nodes/main/smoothing/sharpen_backends/guided_normal.py b/nodes/main/smoothing/sharpen_backends/guided_normal.py new file mode 100644 index 00000000..d0d061e3 --- /dev/null +++ b/nodes/main/smoothing/sharpen_backends/guided_normal.py @@ -0,0 +1,277 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2025 ComfyUI-GeometryPack Contributors + +"""Guided mesh normal filtering sharpening backend node (Zhang et al. 2015).""" + +import logging +import numpy as np +import trimesh as trimesh_module +from comfy_api.latest import io +from ._helpers import ( + _compute_face_geometry, + _update_vertices_from_normals, + _build_vertex_to_faces, + _build_vertex_based_face_neighbors, +) + +log = logging.getLogger("geometrypack") + + +def _guided_normal_sharpen(mesh, normal_iterations, vertex_iterations, + sigma_s, sigma_r): + """Guided mesh normal filtering with interleaved vertex update. + + Matches GuidedMeshNormalFiltering::updateFilteredNormalsLocalScheme from + the C++ reference (Zhang et al. 2015). + + Per iteration: + 1. Recompute geometry from current vertices + 2. Compute guidance normals via min-range-metric: for each face's + vertex-based 1-ring, compute a sharpness metric (maxdiff * max_tv / + sum_tv). Pick the neighbor with minimum metric and use its area-weighted + average normal as guidance. + 3. Bilateral filter normals using guidance for range weight + 4. Immediately update vertex positions (interleaved) + """ + V = np.asarray(mesh.vertices, dtype=np.float64) + F = np.asarray(mesh.faces, dtype=np.int64) + + if len(V) == 0 or len(F) == 0: + return None, "Empty mesh (no vertices or faces)." + + m = len(F) + adj_pairs = np.asarray(mesh.face_adjacency) + if len(adj_pairs) == 0: + return None, "Mesh has no face adjacency (disconnected or degenerate)." + + # Build vertex-based face neighbor structures + vert_to_faces = _build_vertex_to_faces(len(V), F) + # Guided neighborhood: vertex-based including central face + guided_neighbors = _build_vertex_based_face_neighbors(F, vert_to_faces, include_central=True) + # Filtering neighborhood: same vertex-based including central + filter_neighbors = guided_neighbors + + # Precompute face-to-adjacency mapping for inner edge computation + face_to_adj = [[] for _ in range(m)] + for ai in range(len(adj_pairs)): + fi, fj = adj_pairs[ai] + face_to_adj[fi].append(ai) + face_to_adj[fj].append(ai) + + for normal_iter in range(normal_iterations): + # Recompute geometry from current vertex positions + normals, centroids, areas = _compute_face_geometry(V, F) + + # Compute sigma_s in absolute units (avg centroid-to-centroid distance * multiple) + edge_lens = np.concatenate([ + np.linalg.norm(V[F[:, 1]] - V[F[:, 0]], axis=1), + np.linalg.norm(V[F[:, 2]] - V[F[:, 1]], axis=1), + np.linalg.norm(V[F[:, 0]] - V[F[:, 2]], axis=1), + ]) + avg_edge_len = float(np.mean(edge_lens)) + sigma_s_abs = sigma_s * avg_edge_len + sigma_s_sq2 = 2.0 * sigma_s_abs * sigma_s_abs + sigma_r_sq2 = 2.0 * sigma_r * sigma_r + + # --- Step 1: Compute guidance normals via min-range-metric --- + # For each face compute (metric, area_weighted_avg_normal) over its + # guided neighborhood, matching getRangeAndMeanNormal in the C++ code. + metrics = np.zeros(m) + avg_normals = np.zeros((m, 3)) + + for fi in range(m): + patch = guided_neighbors[fi] + n_patch = len(patch) + + # Area-weighted average normal + patch_areas = areas[patch] + patch_normals = normals[patch] + avg_n = np.sum(patch_normals * patch_areas[:, None], axis=0) + norm_len = np.linalg.norm(avg_n) + if norm_len > 1e-12: + avg_n /= norm_len + avg_normals[fi] = avg_n + + # Max pairwise normal difference in patch + if n_patch > 1: + pn = normals[patch] + diffs = np.linalg.norm(pn[:, None, :] - pn[None, :, :], axis=2) + maxdiff = float(np.max(diffs)) + else: + maxdiff = 0.0 + + # Inner edges: adjacency pairs where both faces are in the patch + patch_set = set(patch) + max_tv = 0.0 + sum_tv = 0.0 + seen_edges = set() + for pf in patch: + for ai in face_to_adj[pf]: + if ai not in seen_edges: + seen_edges.add(ai) + fa, fb = adj_pairs[ai] + if fa in patch_set and fb in patch_set: + tv = float(np.linalg.norm(normals[fa] - normals[fb])) + if tv > max_tv: + max_tv = tv + sum_tv += tv + + metrics[fi] = maxdiff * max_tv / (sum_tv + 1e-9) + + # For each face, find the guided neighbor with minimum metric and use + # that neighbor's area-weighted average normal as guidance. + guided_normals = np.zeros((m, 3)) + for fi in range(m): + patch = guided_neighbors[fi] + min_metric = 1e8 + min_idx = patch[0] + for pf in patch: + if metrics[pf] < min_metric: + min_metric = metrics[pf] + min_idx = pf + guided_normals[fi] = avg_normals[min_idx] + + # --- Step 2: Bilateral filter normals using guidance --- + filtered = np.zeros((m, 3)) + for fi in range(m): + patch = filter_neighbors[fi] + if not patch: + filtered[fi] = normals[fi] + continue + + w_total = 0.0 + n_acc = np.zeros(3) + for fj in patch: + dist_sq = float(np.sum((centroids[fi] - centroids[fj]) ** 2)) + ws = np.exp(-dist_sq / (sigma_s_sq2 + 1e-12)) + + # Range weight uses guidance normal distance (matching C++ reference) + gdiff_sq = float(np.sum((guided_normals[fi] - guided_normals[fj]) ** 2)) + wr = np.exp(-gdiff_sq / (sigma_r_sq2 + 1e-12)) + + w = areas[fj] * ws * wr + n_acc += normals[fj] * w + w_total += w + + if w_total > 1e-12: + filtered[fi] = n_acc / w_total + else: + filtered[fi] = normals[fi] + + f_norms = np.linalg.norm(filtered, axis=1, keepdims=True) + filtered_normals = filtered / (f_norms + 1e-12) + + # --- Step 3: Interleaved vertex update (matching C++ reference) --- + V = _update_vertices_from_normals(V, F, filtered_normals, vertex_iterations) + + log.debug("Guided normal iteration %d/%d complete", normal_iter + 1, normal_iterations) + + result = trimesh_module.Trimesh( + vertices=V, + faces=np.asarray(mesh.faces, dtype=np.int32), + process=False, + ) + return result, "" + + +class SharpenGuidedNormalNode(io.ComfyNode): + """Guided mesh normal filtering sharpening backend.""" + + @classmethod + def define_schema(cls): + return io.Schema( + node_id="GeomPackSharpen_GuidedNormal", + display_name="Sharpen Guided Normal (backend)", + category="geompack/smoothing", + is_dev_only=True, + is_output_node=True, + inputs=[ + io.Custom("TRIMESH").Input("trimesh"), + io.Int.Input("normal_iterations", default=5, min=1, max=50, step=1, tooltip=( + "Iterations for guided bilateral normal filtering. " + "More iterations produce smoother/flatter regions while " + "preserving sharp edges." + )), + io.Int.Input("vertex_iterations", default=10, min=1, max=100, step=1, tooltip=( + "Iterations for updating vertex positions to match filtered " + "normals. More iterations give better convergence." + )), + io.Float.Input("sigma_s", default=1.0, min=0.1, max=10.0, step=0.1, tooltip=( + "Spatial weight sigma as a multiple of average edge length. " + "Controls the neighborhood size for normal filtering. " + "Larger = smoother but may blur sharp features." + )), + io.Float.Input("sigma_r", default=0.35, min=0.01, max=1.0, step=0.01, tooltip=( + "Normal similarity threshold. Controls which normals are " + "averaged together. Smaller = more aggressive edge " + "preservation. 0.35 corresponds to roughly 40 degree " + "dihedral angle threshold." + )), + ], + outputs=[ + io.Custom("TRIMESH").Output(display_name="sharpened_mesh"), + io.String.Output(display_name="info"), + ], + ) + + @classmethod + def execute(cls, trimesh, normal_iterations=5, vertex_iterations=10, + sigma_s=1.0, sigma_r=0.35): + log.info("Backend: guided_normal") + log.info("Input: %d vertices, %d faces", len(trimesh.vertices), len(trimesh.faces)) + log.info("Parameters: normal_iter=%d, vertex_iter=%d, sigma_s=%.2f, sigma_r=%.3f", + normal_iterations, vertex_iterations, sigma_s, sigma_r) + + initial_vertices = len(trimesh.vertices) + initial_faces = len(trimesh.faces) + + sharpened, error = _guided_normal_sharpen( + trimesh, normal_iterations, vertex_iterations, sigma_s, sigma_r, + ) + + if sharpened is None: + raise ValueError(f"Sharpening failed (guided_normal): {error}") + + # Copy metadata + if hasattr(trimesh, "metadata") and trimesh.metadata: + sharpened.metadata = trimesh.metadata.copy() + sharpened.metadata["sharpening"] = { + "algorithm": "guided_normal", + "original_vertices": initial_vertices, + "original_faces": initial_faces, + } + + # Compute displacement stats + disp = np.linalg.norm( + np.asarray(sharpened.vertices) - np.asarray(trimesh.vertices), axis=1 + ) + avg_disp = float(np.mean(disp)) + max_disp = float(np.max(disp)) + + log.info("Output: %d vertices, %d faces", + len(sharpened.vertices), len(sharpened.faces)) + log.info("Avg vertex displacement: %.6f, max: %.6f", avg_disp, max_disp) + + param_text = ( + f"Normal Iterations: {normal_iterations}\n" + f"Vertex Iterations: {vertex_iterations}\n" + f"Sigma S: {sigma_s}\n" + f"Sigma R: {sigma_r}" + ) + + info = f"""Sharpen Mesh Results (guided_normal): + +{param_text} + +Vertices: {initial_vertices:,} (unchanged) +Faces: {initial_faces:,} (unchanged) + +Displacement: + Average: {avg_disp:.6f} + Maximum: {max_disp:.6f} +""" + return io.NodeOutput(sharpened, info, ui={"text": [info]}) + + +NODE_CLASS_MAPPINGS = {"GeomPackSharpen_GuidedNormal": SharpenGuidedNormalNode} +NODE_DISPLAY_NAME_MAPPINGS = {"GeomPackSharpen_GuidedNormal": "Sharpen Guided Normal (backend)"} diff --git a/nodes/main/smoothing/sharpen_backends/l0_minimize.py b/nodes/main/smoothing/sharpen_backends/l0_minimize.py new file mode 100644 index 00000000..7e6ca2c0 --- /dev/null +++ b/nodes/main/smoothing/sharpen_backends/l0_minimize.py @@ -0,0 +1,165 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2025 ComfyUI-GeometryPack Contributors + +"""L0 normal minimization sharpening backend node (He & Schaefer 2013).""" + +import logging +import numpy as np +import trimesh as trimesh_module +from comfy_api.latest import io +from ._helpers import _compute_face_geometry, _update_vertices_from_normals + +log = logging.getLogger("geometrypack") + + +def _l0_minimize_sharpen(mesh, alpha, beta, iterations): + """L0 normal minimization for piecewise-flat sharpening. + + Iteratively thresholds face normal differences across edges: if the + difference is below the current alpha threshold, adjacent normals are + snapped to their area-weighted average. Alpha grows by factor beta each + iteration, progressively eliminating small variations and forcing the + mesh into piecewise-constant normal regions with sharp edges at + boundaries. + """ + V = np.asarray(mesh.vertices, dtype=np.float64) + F = np.asarray(mesh.faces, dtype=np.int64) + + if len(V) == 0 or len(F) == 0: + return None, "Empty mesh (no vertices or faces)." + + adj_pairs = np.asarray(mesh.face_adjacency) + if len(adj_pairs) == 0: + return None, "Mesh has no face adjacency (disconnected or degenerate)." + + normals, centroids, areas = _compute_face_geometry(V, F) + current_alpha = alpha + + for it in range(iterations): + # Recompute normals from current vertex positions + normals, centroids, areas = _compute_face_geometry(V, F) + target_normals = normals.copy() + + # Threshold: snap adjacent normals whose difference^2 < current_alpha + for ei in range(len(adj_pairs)): + fi, fj = adj_pairs[ei] + ni, nj = target_normals[fi], target_normals[fj] + diff_sq = np.sum((ni - nj) ** 2) + if diff_sq < current_alpha: + ai, aj = areas[fi], areas[fj] + avg = (ai * ni + aj * nj) / (ai + aj + 1e-12) + norm_len = np.linalg.norm(avg) + if norm_len > 1e-12: + avg /= norm_len + target_normals[fi] = avg + target_normals[fj] = avg + + # Update vertex positions to match target normals + V = _update_vertices_from_normals(V, F, target_normals, vertex_iterations=1) + + current_alpha *= beta + log.debug("L0 iteration %d: alpha=%.6f", it, current_alpha) + + result = trimesh_module.Trimesh( + vertices=V, + faces=np.asarray(mesh.faces, dtype=np.int32), + process=False, + ) + return result, "" + + +class SharpenL0MinimizeNode(io.ComfyNode): + """L0 normal minimization sharpening backend.""" + + @classmethod + def define_schema(cls): + return io.Schema( + node_id="GeomPackSharpen_L0Minimize", + display_name="Sharpen L0 Minimize (backend)", + category="geompack/smoothing", + is_dev_only=True, + is_output_node=True, + inputs=[ + io.Custom("TRIMESH").Input("trimesh"), + io.Float.Input("alpha", default=0.001, min=0.0001, max=0.1, step=0.0001, tooltip=( + "Initial regularization weight for L0 minimization. " + "Controls the threshold below which normal differences " + "are snapped to zero. Smaller = gentler start, " + "larger = more aggressive initial flattening." + )), + io.Float.Input("beta", default=2.0, min=1.1, max=10.0, step=0.1, tooltip=( + "Growth rate for alpha each iteration. Alpha is multiplied " + "by beta each step. 2.0 doubles per iteration. " + "Higher = faster convergence to piecewise-flat." + )), + io.Int.Input("iterations", default=10, min=1, max=50, step=1, tooltip=( + "Number of L0 optimization iterations. The algorithm " + "gradually increases the threshold, snapping more normals " + "flat each step." + )), + ], + outputs=[ + io.Custom("TRIMESH").Output(display_name="sharpened_mesh"), + io.String.Output(display_name="info"), + ], + ) + + @classmethod + def execute(cls, trimesh, alpha=0.001, beta=2.0, iterations=10): + log.info("Backend: l0_minimize") + log.info("Input: %d vertices, %d faces", len(trimesh.vertices), len(trimesh.faces)) + log.info("Parameters: alpha=%.4f, beta=%.1f, iterations=%d", + alpha, beta, iterations) + + initial_vertices = len(trimesh.vertices) + initial_faces = len(trimesh.faces) + + sharpened, error = _l0_minimize_sharpen( + trimesh, alpha, beta, iterations, + ) + + if sharpened is None: + raise ValueError(f"Sharpening failed (l0_minimize): {error}") + + # Copy metadata + if hasattr(trimesh, "metadata") and trimesh.metadata: + sharpened.metadata = trimesh.metadata.copy() + sharpened.metadata["sharpening"] = { + "algorithm": "l0_minimize", + "original_vertices": initial_vertices, + "original_faces": initial_faces, + } + + # Compute displacement stats + disp = np.linalg.norm( + np.asarray(sharpened.vertices) - np.asarray(trimesh.vertices), axis=1 + ) + avg_disp = float(np.mean(disp)) + max_disp = float(np.max(disp)) + + log.info("Output: %d vertices, %d faces", + len(sharpened.vertices), len(sharpened.faces)) + log.info("Avg vertex displacement: %.6f, max: %.6f", avg_disp, max_disp) + + param_text = ( + f"Alpha: {alpha}\n" + f"Beta: {beta}\n" + f"Iterations: {iterations}" + ) + + info = f"""Sharpen Mesh Results (l0_minimize): + +{param_text} + +Vertices: {initial_vertices:,} (unchanged) +Faces: {initial_faces:,} (unchanged) + +Displacement: + Average: {avg_disp:.6f} + Maximum: {max_disp:.6f} +""" + return io.NodeOutput(sharpened, info, ui={"text": [info]}) + + +NODE_CLASS_MAPPINGS = {"GeomPackSharpen_L0Minimize": SharpenL0MinimizeNode} +NODE_DISPLAY_NAME_MAPPINGS = {"GeomPackSharpen_L0Minimize": "Sharpen L0 Minimize (backend)"} diff --git a/nodes/main/smoothing/sharpen_backends/libigl_unsharp.py b/nodes/main/smoothing/sharpen_backends/libigl_unsharp.py new file mode 100644 index 00000000..3096f487 --- /dev/null +++ b/nodes/main/smoothing/sharpen_backends/libigl_unsharp.py @@ -0,0 +1,139 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2025 ComfyUI-GeometryPack Contributors + +"""Cotangent-weighted geometric unsharp mask backend node (libigl).""" + +import logging +import numpy as np +import trimesh as trimesh_module +from comfy_api.latest import io + +log = logging.getLogger("geometrypack") + + +def _libigl_unsharp_sharpen(mesh, lambda_, iterations): + """Cotangent-weighted geometric unsharp mask via libigl. + + V_sharp = V + lambda * M^{-1} * L * V + where L is the cotangent Laplacian and M is the Voronoi mass matrix. + """ + try: + import igl + except ImportError: + return None, "libigl is not installed. Install with: pip install libigl" + + V = np.asarray(mesh.vertices, dtype=np.float64) + F = np.asarray(mesh.faces, dtype=np.int64) + + if len(V) == 0 or len(F) == 0: + return None, "Empty mesh (no vertices or faces)." + + L = igl.cotmatrix(V, F) + M = igl.massmatrix(V, F, igl.MASSMATRIX_TYPE_VORONOI) + + # M is diagonal -- invert via element-wise reciprocal + M_diag = M.diagonal().copy() + M_diag[M_diag == 0] = 1e-12 + M_inv = 1.0 / M_diag + + for _ in range(iterations): + # L @ V gives curvature-weighted displacement (n, 3) + LV = L @ V + delta = M_inv[:, None] * LV + V = V + lambda_ * delta + + result = trimesh_module.Trimesh( + vertices=V, + faces=np.asarray(mesh.faces, dtype=np.int32), + process=False, + ) + return result, "" + + +class SharpenLibiglUnsharpNode(io.ComfyNode): + """Cotangent-weighted geometric unsharp mask backend (libigl).""" + + @classmethod + def define_schema(cls): + return io.Schema( + node_id="GeomPackSharpen_LibiglUnsharp", + display_name="Sharpen Libigl Unsharp (backend)", + category="geompack/smoothing", + is_dev_only=True, + is_output_node=True, + inputs=[ + io.Custom("TRIMESH").Input("trimesh"), + io.Float.Input("lambda_", default=0.5, min=0.01, max=5.0, step=0.01, tooltip=( + "Unsharp mask strength. Controls how much the cotangent " + "Laplacian displacement is amplified. Higher values produce " + "stronger sharpening but may cause overshooting. " + "Start with 0.3-0.5." + )), + io.Int.Input("iterations", default=3, min=1, max=50, step=1, tooltip=( + "Number of unsharp mask passes. Multiple light passes " + "are more stable than a single heavy pass." + )), + ], + outputs=[ + io.Custom("TRIMESH").Output(display_name="sharpened_mesh"), + io.String.Output(display_name="info"), + ], + ) + + @classmethod + def execute(cls, trimesh, lambda_=0.5, iterations=3): + log.info("Backend: libigl_unsharp") + log.info("Input: %d vertices, %d faces", len(trimesh.vertices), len(trimesh.faces)) + log.info("Parameters: lambda=%.3f, iterations=%d", lambda_, iterations) + + initial_vertices = len(trimesh.vertices) + initial_faces = len(trimesh.faces) + + sharpened, error = _libigl_unsharp_sharpen( + trimesh, lambda_, iterations, + ) + + if sharpened is None: + raise ValueError(f"Sharpening failed (libigl_unsharp): {error}") + + # Copy metadata + if hasattr(trimesh, "metadata") and trimesh.metadata: + sharpened.metadata = trimesh.metadata.copy() + sharpened.metadata["sharpening"] = { + "algorithm": "libigl_unsharp", + "original_vertices": initial_vertices, + "original_faces": initial_faces, + } + + # Compute displacement stats + disp = np.linalg.norm( + np.asarray(sharpened.vertices) - np.asarray(trimesh.vertices), axis=1 + ) + avg_disp = float(np.mean(disp)) + max_disp = float(np.max(disp)) + + log.info("Output: %d vertices, %d faces", + len(sharpened.vertices), len(sharpened.faces)) + log.info("Avg vertex displacement: %.6f, max: %.6f", avg_disp, max_disp) + + param_text = ( + f"Lambda: {lambda_}\n" + f"Iterations: {iterations}" + ) + + info = f"""Sharpen Mesh Results (libigl_unsharp): + +{param_text} + +Vertices: {initial_vertices:,} (unchanged) +Faces: {initial_faces:,} (unchanged) + +Displacement: + Average: {avg_disp:.6f} + Maximum: {max_disp:.6f} +""" + return io.NodeOutput(sharpened, info, ui={"text": [info]}) + + +NODE_CLASS_MAPPINGS = {"GeomPackSharpen_LibiglUnsharp": SharpenLibiglUnsharpNode} +NODE_DISPLAY_NAME_MAPPINGS = {"GeomPackSharpen_LibiglUnsharp": "Sharpen Libigl Unsharp (backend)"} diff --git a/nodes/main/smoothing/sharpen_backends/non_iterative.py b/nodes/main/smoothing/sharpen_backends/non_iterative.py new file mode 100644 index 00000000..280655bc --- /dev/null +++ b/nodes/main/smoothing/sharpen_backends/non_iterative.py @@ -0,0 +1,246 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2025 ComfyUI-GeometryPack Contributors + +"""Non-Iterative Feature-Preserving Mesh Smoothing backend node +(Jones et al. SIGGRAPH 2003).""" + +import logging +import numpy as np +import trimesh as trimesh_module +from comfy_api.latest import io +from ._helpers import ( + _compute_face_geometry, + _build_vertex_to_faces, + _build_vertex_based_face_neighbors, +) + +log = logging.getLogger("geometrypack") + + +def _non_iterative_sharpen(mesh, sigma_f_ratio, sigma_g_ratio): + """Non-Iterative Feature-Preserving Mesh Smoothing. + + Ported from NonIterativeFeaturePreservingMeshFiltering.cpp. + + 1. Mollify normals: smooth vertex positions with Gaussian (sigma_m = sigma_f/2), + then recompute face normals on the smoothed mesh. + 2. For each vertex, BFS-expand to find faces within radius 2*sigma_f. + 3. Project vertex onto each neighbor face plane (using mollified normal + + original centroid). Weight by area * spatial_Gaussian(sigma_f) * + influence_Gaussian(sigma_g). Average projections. + """ + V = np.asarray(mesh.vertices, dtype=np.float64) + F = np.asarray(mesh.faces, dtype=np.int64) + + if len(V) == 0 or len(F) == 0: + return None, "Empty mesh (no vertices or faces)." + + m = len(F) + n = len(V) + + normals, centroids, areas = _compute_face_geometry(V, F) + + # Compute average edge length + edge_lens = np.concatenate([ + np.linalg.norm(V[F[:, 1]] - V[F[:, 0]], axis=1), + np.linalg.norm(V[F[:, 2]] - V[F[:, 1]], axis=1), + np.linalg.norm(V[F[:, 0]] - V[F[:, 2]], axis=1), + ]) + avg_edge_len = float(np.mean(edge_lens)) + + sigma_f = sigma_f_ratio * avg_edge_len + sigma_g = sigma_g_ratio * avg_edge_len + sigma_m = sigma_f / 2.0 + radius = 2.0 * sigma_f + + # Build vertex-to-face adjacency + vert_to_faces = _build_vertex_to_faces(n, F) + + # Build vertex-based face adjacency for BFS expansion (matching C++ kVertexBased) + vbased_face_adj = _build_vertex_based_face_neighbors( + F, vert_to_faces, include_central=False) + + def _bfs_faces_in_radius(vertex_pos, seed_faces, rad): + """BFS from seed faces, expanding via vertex-based neighbors within radius.""" + visited = set() + queue = [] + for fi in seed_faces: + dist = float(np.linalg.norm(vertex_pos - centroids[fi])) + if dist <= rad: + queue.append(fi) + visited.add(fi) + result_faces = [] + head = 0 + while head < len(queue): + fi = queue[head] + result_faces.append(fi) + head += 1 + for fj in vbased_face_adj[fi]: + if fj not in visited: + visited.add(fj) + dist = float(np.linalg.norm(vertex_pos - centroids[fj])) + if dist <= rad: + queue.append(fj) + return result_faces + + # --- Step 1: Mollified normals --- + # Smooth vertex positions with area-weighted Gaussian (sigma_m) + V_smooth = V.copy() + for vi in range(n): + pt = V[vi] + seed_faces = vert_to_faces[vi] + if not seed_faces: + continue + + faces_in_range = _bfs_faces_in_radius(pt, seed_faces, radius) + + new_pt = np.zeros(3) + w_sum = 0.0 + for fi in faces_in_range: + c = centroids[fi] + dist = float(np.linalg.norm(c - pt)) + w = np.exp(-0.5 * dist * dist / (sigma_m * sigma_m + 1e-12)) + a = areas[fi] + new_pt += c * a * w + w_sum += a * w + + if w_sum > 1e-12: + V_smooth[vi] = new_pt / w_sum + + # Recompute normals on the smoothed mesh + mollified_normals, _, _ = _compute_face_geometry(V_smooth, F) + + # --- Step 2: Single-pass bilateral vertex update --- + V_new = V.copy() + sigma_f_sq = sigma_f * sigma_f + sigma_g_sq = sigma_g * sigma_g + + for vi in range(n): + pt = V[vi] + seed_faces = vert_to_faces[vi] + if not seed_faces: + continue + + faces_in_range = _bfs_faces_in_radius(pt, seed_faces, radius) + if not faces_in_range: + continue + + temp_pt = np.zeros(3) + w_sum = 0.0 + for fi in faces_in_range: + c = centroids[fi] + mn = mollified_normals[fi] + + # Project vertex onto face plane (mollified normal + original centroid) + proj = pt - mn * float(np.dot(pt - c, mn)) + + dist_spatial = float(np.linalg.norm(c - pt)) + w_spatial = np.exp(-0.5 * dist_spatial * dist_spatial / (sigma_f_sq + 1e-12)) + + dist_influence = float(np.linalg.norm(proj - pt)) + w_influence = np.exp(-0.5 * dist_influence * dist_influence / (sigma_g_sq + 1e-12)) + + a = areas[fi] + temp_pt += proj * a * w_spatial * w_influence + w_sum += a * w_spatial * w_influence + + if w_sum > 1e-12: + V_new[vi] = temp_pt / w_sum + + result = trimesh_module.Trimesh( + vertices=V_new, + faces=np.asarray(mesh.faces, dtype=np.int32), + process=False, + ) + return result, "" + + +class SharpenNonIterativeNode(io.ComfyNode): + """Non-Iterative Feature-Preserving Mesh Smoothing backend.""" + + @classmethod + def define_schema(cls): + return io.Schema( + node_id="GeomPackSharpen_NonIterative", + display_name="Sharpen Non Iterative (backend)", + category="geompack/smoothing", + is_dev_only=True, + is_output_node=True, + inputs=[ + io.Custom("TRIMESH").Input("trimesh"), + io.Float.Input("sigma_f", default=1.0, min=0.001, max=10.0, step=0.1, tooltip=( + "Spatial sigma as multiple of average edge length " + "(Jones et al. SIGGRAPH 2003). Controls spatial extent " + "of the bilateral filter. Face neighbors are searched " + "within radius 2*sigma_f. Larger = smoother." + )), + io.Float.Input("sigma_g", default=1.0, min=0.001, max=10.0, step=0.1, tooltip=( + "Influence sigma as multiple of average edge length. " + "Controls sensitivity to projection distance (how far " + "the vertex moves toward each face plane). Smaller = " + "more feature-preserving." + )), + ], + outputs=[ + io.Custom("TRIMESH").Output(display_name="sharpened_mesh"), + io.String.Output(display_name="info"), + ], + ) + + @classmethod + def execute(cls, trimesh, sigma_f=1.0, sigma_g=1.0): + log.info("Backend: non_iterative") + log.info("Input: %d vertices, %d faces", len(trimesh.vertices), len(trimesh.faces)) + log.info("Parameters: sigma_f=%.3f, sigma_g=%.3f", sigma_f, sigma_g) + + initial_vertices = len(trimesh.vertices) + initial_faces = len(trimesh.faces) + + sharpened, error = _non_iterative_sharpen( + trimesh, sigma_f, sigma_g, + ) + + if sharpened is None: + raise ValueError(f"Sharpening failed (non_iterative): {error}") + + # Copy metadata + if hasattr(trimesh, "metadata") and trimesh.metadata: + sharpened.metadata = trimesh.metadata.copy() + sharpened.metadata["sharpening"] = { + "algorithm": "non_iterative", + "original_vertices": initial_vertices, + "original_faces": initial_faces, + } + + # Compute displacement stats + disp = np.linalg.norm( + np.asarray(sharpened.vertices) - np.asarray(trimesh.vertices), axis=1 + ) + avg_disp = float(np.mean(disp)) + max_disp = float(np.max(disp)) + + log.info("Output: %d vertices, %d faces", + len(sharpened.vertices), len(sharpened.faces)) + log.info("Avg vertex displacement: %.6f, max: %.6f", avg_disp, max_disp) + + param_text = ( + f"Sigma F: {sigma_f}\n" + f"Sigma G: {sigma_g}" + ) + + info = f"""Sharpen Mesh Results (non_iterative): + +{param_text} + +Vertices: {initial_vertices:,} (unchanged) +Faces: {initial_faces:,} (unchanged) + +Displacement: + Average: {avg_disp:.6f} + Maximum: {max_disp:.6f} +""" + return io.NodeOutput(sharpened, info, ui={"text": [info]}) + + +NODE_CLASS_MAPPINGS = {"GeomPackSharpen_NonIterative": SharpenNonIterativeNode} +NODE_DISPLAY_NAME_MAPPINGS = {"GeomPackSharpen_NonIterative": "Sharpen Non Iterative (backend)"} diff --git a/nodes/main/smoothing/sharpen_backends/two_step.py b/nodes/main/smoothing/sharpen_backends/two_step.py new file mode 100644 index 00000000..c8b32930 --- /dev/null +++ b/nodes/main/smoothing/sharpen_backends/two_step.py @@ -0,0 +1,150 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2025 ComfyUI-GeometryPack Contributors + +"""Two-step bilateral normal sharpening backend node (pymeshlab).""" + +import logging +import numpy as np +import trimesh as trimesh_module +from comfy_api.latest import io + +log = logging.getLogger("geometrypack") + + +def _pymeshlab_two_step_sharpen(mesh, smooth_steps, normal_threshold, + normal_iterations, fit_iterations, selected_only): + """Two-step bilateral normal sharpening via PyMeshLab.""" + try: + import pymeshlab + except (ImportError, OSError): + return None, "pymeshlab is not installed. Install with: pip install pymeshlab" + + ms = pymeshlab.MeshSet() + pml_mesh = pymeshlab.Mesh( + vertex_matrix=np.asarray(mesh.vertices, dtype=np.float64), + face_matrix=np.asarray(mesh.faces, dtype=np.int32), + ) + ms.add_mesh(pml_mesh) + + try: + ms.apply_coord_two_steps_smoothing( + stepsmoothnum=smooth_steps, + normalthr=normal_threshold, + stepnormalnum=normal_iterations, + stepfitnum=fit_iterations, + selected=selected_only, + ) + except AttributeError: + try: + ms.two_steps_smooth( + stepsmoothnum=smooth_steps, + normalthr=normal_threshold, + stepnormalnum=normal_iterations, + stepfitnum=fit_iterations, + selected=selected_only, + ) + except AttributeError: + return None, ( + "PyMeshLab two-step smoothing filter not available. " + "Ensure pymeshlab >= 2023.12 is installed." + ) + + out = ms.current_mesh() + result = trimesh_module.Trimesh( + vertices=out.vertex_matrix(), + faces=out.face_matrix(), + process=False, + ) + return result, "" + + +class SharpenTwoStepNode(io.ComfyNode): + """Two-step bilateral normal sharpening backend (pymeshlab).""" + + @classmethod + def define_schema(cls): + return io.Schema( + node_id="GeomPackSharpen_TwoStep", + display_name="Sharpen Two Step (backend)", + category="geompack/smoothing", + is_dev_only=True, + is_output_node=True, + inputs=[ + io.Custom("TRIMESH").Input("trimesh"), + io.Int.Input("smooth_steps", default=3, min=1, max=50, step=1, tooltip=( + "Number of two-step smoothing passes. " + "More steps = stronger sharpening effect." + )), + io.Float.Input("normal_threshold", default=60.0, min=0.0, max=180.0, step=0.5, tooltip=( + "Dihedral angle threshold in degrees. " + "Edges sharper than this angle are preserved as features. " + "Lower = more aggressive (more edges treated as creases). " + "60 is a good default for most CAD models." + )), + ], + outputs=[ + io.Custom("TRIMESH").Output(display_name="sharpened_mesh"), + io.String.Output(display_name="info"), + ], + ) + + @classmethod + def execute(cls, trimesh, smooth_steps=3, normal_threshold=60.0): + log.info("Backend: two_step") + log.info("Input: %d vertices, %d faces", len(trimesh.vertices), len(trimesh.faces)) + log.info("Parameters: smooth_steps=%d, normal_threshold=%.1f", + smooth_steps, normal_threshold) + + initial_vertices = len(trimesh.vertices) + initial_faces = len(trimesh.faces) + + sharpened, error = _pymeshlab_two_step_sharpen( + trimesh, smooth_steps, normal_threshold, + normal_iterations=20, fit_iterations=20, + selected_only=False, + ) + + if sharpened is None: + raise ValueError(f"Sharpening failed (two_step): {error}") + + # Copy metadata + if hasattr(trimesh, "metadata") and trimesh.metadata: + sharpened.metadata = trimesh.metadata.copy() + sharpened.metadata["sharpening"] = { + "algorithm": "two_step", + "original_vertices": initial_vertices, + "original_faces": initial_faces, + } + + # Compute displacement stats + disp = np.linalg.norm( + np.asarray(sharpened.vertices) - np.asarray(trimesh.vertices), axis=1 + ) + avg_disp = float(np.mean(disp)) + max_disp = float(np.max(disp)) + + log.info("Output: %d vertices, %d faces", + len(sharpened.vertices), len(sharpened.faces)) + log.info("Avg vertex displacement: %.6f, max: %.6f", avg_disp, max_disp) + + param_text = ( + f"Smooth Steps: {smooth_steps}\n" + f"Normal Threshold: {normal_threshold}\u00b0" + ) + + info = f"""Sharpen Mesh Results (two_step): + +{param_text} + +Vertices: {initial_vertices:,} (unchanged) +Faces: {initial_faces:,} (unchanged) + +Displacement: + Average: {avg_disp:.6f} + Maximum: {max_disp:.6f} +""" + return io.NodeOutput(sharpened, info, ui={"text": [info]}) + + +NODE_CLASS_MAPPINGS = {"GeomPackSharpen_TwoStep": SharpenTwoStepNode} +NODE_DISPLAY_NAME_MAPPINGS = {"GeomPackSharpen_TwoStep": "Sharpen Two Step (backend)"} diff --git a/nodes/main/smoothing/sharpen_backends/unsharp_mask.py b/nodes/main/smoothing/sharpen_backends/unsharp_mask.py new file mode 100644 index 00000000..3c4748bb --- /dev/null +++ b/nodes/main/smoothing/sharpen_backends/unsharp_mask.py @@ -0,0 +1,140 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2025 ComfyUI-GeometryPack Contributors + +"""Geometric unsharp mask sharpening backend node (pymeshlab).""" + +import logging +import numpy as np +import trimesh as trimesh_module +from comfy_api.latest import io + +log = logging.getLogger("geometrypack") + + +def _pymeshlab_unsharp_mask_sharpen(mesh, weight, weight_original, iterations): + """Geometric unsharp mask sharpening via PyMeshLab.""" + try: + import pymeshlab + except (ImportError, OSError): + return None, "pymeshlab is not installed. Install with: pip install pymeshlab" + + ms = pymeshlab.MeshSet() + pml_mesh = pymeshlab.Mesh( + vertex_matrix=np.asarray(mesh.vertices, dtype=np.float64), + face_matrix=np.asarray(mesh.faces, dtype=np.int32), + ) + ms.add_mesh(pml_mesh) + + try: + ms.apply_coord_unsharp_mask( + weight=weight, + weightorig=weight_original, + iterations=iterations, + ) + except AttributeError: + try: + ms.coord_unsharp_mask( + weight=weight, + weightorig=weight_original, + iterations=iterations, + ) + except AttributeError: + return None, ( + "PyMeshLab coordinate unsharp mask filter not available. " + "Ensure pymeshlab >= 2023.12 is installed." + ) + + out = ms.current_mesh() + result = trimesh_module.Trimesh( + vertices=out.vertex_matrix(), + faces=out.face_matrix(), + process=False, + ) + return result, "" + + +class SharpenUnsharpMaskNode(io.ComfyNode): + """Geometric unsharp mask sharpening backend (pymeshlab).""" + + @classmethod + def define_schema(cls): + return io.Schema( + node_id="GeomPackSharpen_UnsharpMask", + display_name="Sharpen Unsharp Mask (backend)", + category="geompack/smoothing", + is_dev_only=True, + is_output_node=True, + inputs=[ + io.Custom("TRIMESH").Input("trimesh"), + io.Float.Input("weight", default=0.3, min=0.0, max=3.0, step=0.01, tooltip=( + "Unsharp mask weight controlling sharpening strength. " + "Higher = more pronounced sharpening." + )), + io.Int.Input("iterations", default=5, min=1, max=50, step=1, tooltip=( + "Smoothing iterations for the reference smooth mesh. " + "More iterations = larger-scale sharpening." + )), + ], + outputs=[ + io.Custom("TRIMESH").Output(display_name="sharpened_mesh"), + io.String.Output(display_name="info"), + ], + ) + + @classmethod + def execute(cls, trimesh, weight=0.3, iterations=5): + log.info("Backend: unsharp_mask") + log.info("Input: %d vertices, %d faces", len(trimesh.vertices), len(trimesh.faces)) + log.info("Parameters: weight=%.3f, iterations=%d", weight, iterations) + + initial_vertices = len(trimesh.vertices) + initial_faces = len(trimesh.faces) + + sharpened, error = _pymeshlab_unsharp_mask_sharpen( + trimesh, weight, weight_original=1.0, iterations=iterations, + ) + + if sharpened is None: + raise ValueError(f"Sharpening failed (unsharp_mask): {error}") + + # Copy metadata + if hasattr(trimesh, "metadata") and trimesh.metadata: + sharpened.metadata = trimesh.metadata.copy() + sharpened.metadata["sharpening"] = { + "algorithm": "unsharp_mask", + "original_vertices": initial_vertices, + "original_faces": initial_faces, + } + + # Compute displacement stats + disp = np.linalg.norm( + np.asarray(sharpened.vertices) - np.asarray(trimesh.vertices), axis=1 + ) + avg_disp = float(np.mean(disp)) + max_disp = float(np.max(disp)) + + log.info("Output: %d vertices, %d faces", + len(sharpened.vertices), len(sharpened.faces)) + log.info("Avg vertex displacement: %.6f, max: %.6f", avg_disp, max_disp) + + param_text = ( + f"Weight: {weight}\n" + f"Iterations: {iterations}" + ) + + info = f"""Sharpen Mesh Results (unsharp_mask): + +{param_text} + +Vertices: {initial_vertices:,} (unchanged) +Faces: {initial_faces:,} (unchanged) + +Displacement: + Average: {avg_disp:.6f} + Maximum: {max_disp:.6f} +""" + return io.NodeOutput(sharpened, info, ui={"text": [info]}) + + +NODE_CLASS_MAPPINGS = {"GeomPackSharpen_UnsharpMask": SharpenUnsharpMaskNode} +NODE_DISPLAY_NAME_MAPPINGS = {"GeomPackSharpen_UnsharpMask": "Sharpen Unsharp Mask (backend)"} diff --git a/nodes/main/smoothing/sharpen_backends/vsa_snap.py b/nodes/main/smoothing/sharpen_backends/vsa_snap.py new file mode 100644 index 00000000..e93d4835 --- /dev/null +++ b/nodes/main/smoothing/sharpen_backends/vsa_snap.py @@ -0,0 +1,252 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2025 ComfyUI-GeometryPack Contributors + +"""Variational Shape Approximation face clustering + vertex snapping backend node.""" + +import logging +import numpy as np +import trimesh as trimesh_module +from comfy_api.latest import io +from ._helpers import _compute_face_geometry + +log = logging.getLogger("geometrypack") + + +def _vsa_snap_sharpen(mesh, num_proxies, lloyd_iterations, snap_strength): + """Variational Shape Approximation clustering with vertex snapping. + + Clusters mesh faces into proxy plane groups via Lloyd iteration (using + area-weighted normal distance as the L2,1 metric), then snaps vertices: + - Interior vertices (one proxy): project onto proxy plane + - Edge vertices (two proxies): snap to plane intersection line + - Corner vertices (3+ proxies): snap to plane intersection point + """ + V = np.asarray(mesh.vertices, dtype=np.float64) + F = np.asarray(mesh.faces, dtype=np.int64) + + if len(V) == 0 or len(F) == 0: + return None, "Empty mesh (no vertices or faces)." + + m = len(F) + k = min(num_proxies, m) + + normals, centroids, areas = _compute_face_geometry(V, F) + + # --- Step 1: farthest-point seeding on area-weighted normal space --- + seeds = [0] + min_dists = np.full(m, np.inf) + + for _ in range(k - 1): + last = seeds[-1] + d = areas * np.linalg.norm(normals - normals[last], axis=1) + min_dists = np.minimum(min_dists, d) + seeds.append(int(np.argmax(min_dists))) + + proxy_normals = normals[seeds].copy() + proxy_centroids = centroids[seeds].copy() + + # --- Step 2: Lloyd iteration --- + labels = np.zeros(m, dtype=np.int32) + + for _ in range(lloyd_iterations): + # Assign each face to nearest proxy (L2,1 metric) + # (m, k) distance matrix + normal_diffs = np.linalg.norm( + normals[:, None, :] - proxy_normals[None, :, :], axis=2 + ) + weighted_diffs = areas[:, None] * normal_diffs + labels = np.argmin(weighted_diffs, axis=1).astype(np.int32) + + # Refit proxies + for p in range(k): + mask = labels == p + if not np.any(mask): + # Empty cluster -- re-seed from farthest face + assigned_dists = weighted_diffs[np.arange(m), labels] + worst = int(np.argmax(assigned_dists)) + proxy_normals[p] = normals[worst] + proxy_centroids[p] = centroids[worst] + continue + a = areas[mask] + total_a = np.sum(a) + 1e-12 + avg_n = np.sum(normals[mask] * a[:, None], axis=0) / total_a + norm_len = np.linalg.norm(avg_n) + if norm_len > 1e-12: + avg_n /= norm_len + proxy_normals[p] = avg_n + proxy_centroids[p] = np.sum(centroids[mask] * a[:, None], axis=0) / total_a + + # --- Step 3: snap vertices --- + # Build vertex-to-face adjacency + vertex_faces = [[] for _ in range(len(V))] + for fi in range(m): + for vi in F[fi]: + vertex_faces[vi].append(fi) + + V_snapped = V.copy() + + for vi in range(len(V)): + adj_fi = vertex_faces[vi] + if not adj_fi: + continue + + adj_labels = list(set(labels[fi] for fi in adj_fi)) + + if len(adj_labels) == 1: + # Interior: project onto single proxy plane + p = adj_labels[0] + n = proxy_normals[p] + c = proxy_centroids[p] + d = np.dot(V[vi] - c, n) + V_snapped[vi] = V[vi] - snap_strength * d * n + + elif len(adj_labels) == 2: + # Edge: snap to intersection of two planes + p1, p2 = adj_labels + n1, c1 = proxy_normals[p1], proxy_centroids[p1] + n2, c2 = proxy_normals[p2], proxy_centroids[p2] + + line_dir = np.cross(n1, n2) + line_dir_norm = np.linalg.norm(line_dir) + + if line_dir_norm < 1e-8: + # Nearly parallel planes -- project onto average + avg_n = (n1 + n2) + avg_n_norm = np.linalg.norm(avg_n) + if avg_n_norm > 1e-12: + avg_n /= avg_n_norm + avg_c = (c1 + c2) / 2.0 + d = np.dot(V[vi] - avg_c, avg_n) + V_snapped[vi] = V[vi] - snap_strength * d * avg_n + else: + line_dir /= line_dir_norm + # Find a point on the intersection line + A = np.array([n1, n2]) + b = np.array([np.dot(n1, c1), np.dot(n2, c2)]) + try: + AAT = A @ A.T + p0 = A.T @ np.linalg.solve(AAT, b) + except np.linalg.LinAlgError: + continue + # Project vertex onto the line + v_rel = V[vi] - p0 + t = np.dot(v_rel, line_dir) + closest = p0 + t * line_dir + V_snapped[vi] = V[vi] + snap_strength * (closest - V[vi]) + + else: + # Corner: snap to intersection of 3+ planes + A = np.array([proxy_normals[p] for p in adj_labels]) + b = np.array([np.dot(proxy_normals[p], proxy_centroids[p]) + for p in adj_labels]) + try: + if len(adj_labels) == 3: + corner = np.linalg.solve(A, b) + else: + corner, _, _, _ = np.linalg.lstsq(A, b, rcond=None) + V_snapped[vi] = V[vi] + snap_strength * (corner - V[vi]) + except np.linalg.LinAlgError: + continue + + result = trimesh_module.Trimesh( + vertices=V_snapped, + faces=np.asarray(mesh.faces, dtype=np.int32), + process=False, + ) + return result, "" + + +class SharpenVSASnapNode(io.ComfyNode): + """Variational Shape Approximation face clustering + vertex snapping backend.""" + + @classmethod + def define_schema(cls): + return io.Schema( + node_id="GeomPackSharpen_VSASnap", + display_name="Sharpen VSA Snap (backend)", + category="geompack/smoothing", + is_dev_only=True, + is_output_node=True, + inputs=[ + io.Custom("TRIMESH").Input("trimesh"), + io.Int.Input("num_proxies", default=20, min=2, max=500, step=1, tooltip=( + "Number of proxy planes (face clusters). More proxies " + "preserve more detail. Fewer proxies create a more " + "abstract/simplified appearance. 10-50 for simple shapes, " + "50-200 for complex shapes." + )), + io.Int.Input("lloyd_iterations", default=10, min=1, max=100, step=1, tooltip=( + "Number of Lloyd clustering iterations. 10 is usually " + "sufficient for convergence." + )), + io.Float.Input("snap_strength", default=1.0, min=0.0, max=1.0, step=0.01, tooltip=( + "How aggressively to snap vertices to proxy planes. " + "0.0 = no change, 1.0 = full snap. " + "Values like 0.5-0.8 give a subtle sharpening effect." + )), + ], + outputs=[ + io.Custom("TRIMESH").Output(display_name="sharpened_mesh"), + io.String.Output(display_name="info"), + ], + ) + + @classmethod + def execute(cls, trimesh, num_proxies=20, lloyd_iterations=10, snap_strength=1.0): + log.info("Backend: vsa_snap") + log.info("Input: %d vertices, %d faces", len(trimesh.vertices), len(trimesh.faces)) + log.info("Parameters: num_proxies=%d, lloyd_iter=%d, snap_strength=%.2f", + num_proxies, lloyd_iterations, snap_strength) + + initial_vertices = len(trimesh.vertices) + initial_faces = len(trimesh.faces) + + sharpened, error = _vsa_snap_sharpen( + trimesh, num_proxies, lloyd_iterations, snap_strength, + ) + + if sharpened is None: + raise ValueError(f"Sharpening failed (vsa_snap): {error}") + + # Copy metadata + if hasattr(trimesh, "metadata") and trimesh.metadata: + sharpened.metadata = trimesh.metadata.copy() + sharpened.metadata["sharpening"] = { + "algorithm": "vsa_snap", + "original_vertices": initial_vertices, + "original_faces": initial_faces, + } + + # Compute displacement stats + disp = np.linalg.norm( + np.asarray(sharpened.vertices) - np.asarray(trimesh.vertices), axis=1 + ) + avg_disp = float(np.mean(disp)) + max_disp = float(np.max(disp)) + + log.info("Output: %d vertices, %d faces", + len(sharpened.vertices), len(sharpened.faces)) + log.info("Avg vertex displacement: %.6f, max: %.6f", avg_disp, max_disp) + + param_text = ( + f"Num Proxies: {num_proxies}\n" + f"Lloyd Iterations: {lloyd_iterations}\n" + f"Snap Strength: {snap_strength}" + ) + + info = f"""Sharpen Mesh Results (vsa_snap): + +{param_text} + +Vertices: {initial_vertices:,} (unchanged) +Faces: {initial_faces:,} (unchanged) + +Displacement: + Average: {avg_disp:.6f} + Maximum: {max_disp:.6f} +""" + return io.NodeOutput(sharpened, info, ui={"text": [info]}) + + +NODE_CLASS_MAPPINGS = {"GeomPackSharpen_VSASnap": SharpenVSASnapNode} +NODE_DISPLAY_NAME_MAPPINGS = {"GeomPackSharpen_VSASnap": "Sharpen VSA Snap (backend)"} diff --git a/nodes/main/smoothing/smooth.py b/nodes/main/smoothing/smooth.py index 46151fa7..6d462754 100644 --- a/nodes/main/smoothing/smooth.py +++ b/nodes/main/smoothing/smooth.py @@ -2,183 +2,40 @@ # Copyright (C) 2025 ComfyUI-GeometryPack Contributors """ -Smooth Mesh Node - Multiple smoothing backends via pymeshlab and trimesh. +Unified Smooth Mesh Node - Single frontend with backend selector. -Available backends: -- laplacian: Classic Laplacian smoothing (fast, may shrink) -- taubin: Two-step Laplacian that prevents shrinkage -- hc_laplacian: Humphrey Classes correction (low shrinkage, good detail) +Uses ComfyUI's node expansion (GraphBuilder) to dispatch to hidden +backend-specific nodes. """ import logging -import numpy as np -import trimesh as trimesh_module from comfy_api.latest import io log = logging.getLogger("geometrypack") -def _pymeshlab_laplacian_smooth(mesh, iterations, cotangent_weight, selected_only): - """Laplacian smoothing via PyMeshLab.""" - try: - import pymeshlab - except (ImportError, OSError): - return None, "pymeshlab is not installed. Install with: pip install pymeshlab" - - ms = pymeshlab.MeshSet() - pml_mesh = pymeshlab.Mesh( - vertex_matrix=np.asarray(mesh.vertices, dtype=np.float64), - face_matrix=np.asarray(mesh.faces, dtype=np.int32), - ) - ms.add_mesh(pml_mesh) - - try: - ms.apply_coord_laplacian_smoothing( - stepsmoothnum=iterations, - cotangentweight=cotangent_weight, - selected=selected_only, - ) - except AttributeError: - try: - ms.laplacian_smooth( - stepsmoothnum=iterations, - cotangentweight=cotangent_weight, - selected=selected_only, - ) - except AttributeError: - return None, ( - "PyMeshLab Laplacian smoothing filter not available. " - "Ensure pymeshlab >= 2023.12 is installed." - ) - - out = ms.current_mesh() - result = trimesh_module.Trimesh( - vertices=out.vertex_matrix(), - faces=out.face_matrix(), - process=False, - ) - return result, "" - -def _pymeshlab_taubin_smooth(mesh, iterations, lambda_val, mu_val, selected_only): - """Taubin smoothing via PyMeshLab (shrinkage-free).""" - try: - import pymeshlab - except (ImportError, OSError): - return None, "pymeshlab is not installed. Install with: pip install pymeshlab" - - ms = pymeshlab.MeshSet() - pml_mesh = pymeshlab.Mesh( - vertex_matrix=np.asarray(mesh.vertices, dtype=np.float64), - face_matrix=np.asarray(mesh.faces, dtype=np.int32), - ) - ms.add_mesh(pml_mesh) - - try: - ms.apply_coord_taubin_smoothing( - lambda_=lambda_val, - mu=mu_val, - stepsmoothnum=iterations, - selected=selected_only, - ) - except AttributeError: - try: - ms.taubin_smooth( - lambda_=lambda_val, - mu=mu_val, - stepsmoothnum=iterations, - selected=selected_only, - ) - except AttributeError: - return None, ( - "PyMeshLab Taubin smoothing filter not available. " - "Ensure pymeshlab >= 2023.12 is installed." - ) - - out = ms.current_mesh() - result = trimesh_module.Trimesh( - vertices=out.vertex_matrix(), - faces=out.face_matrix(), - process=False, - ) - return result, "" - -def _pymeshlab_hc_laplacian_smooth(mesh, selected_only): - """HC Laplacian smoothing via PyMeshLab (low shrinkage).""" - try: - import pymeshlab - except (ImportError, OSError): - return None, "pymeshlab is not installed. Install with: pip install pymeshlab" - - ms = pymeshlab.MeshSet() - pml_mesh = pymeshlab.Mesh( - vertex_matrix=np.asarray(mesh.vertices, dtype=np.float64), - face_matrix=np.asarray(mesh.faces, dtype=np.int32), - ) - ms.add_mesh(pml_mesh) - - try: - ms.apply_coord_hc_laplacian_smoothing(selected=selected_only) - except AttributeError: - try: - ms.hc_laplacian_smooth(selected=selected_only) - except AttributeError: - return None, ( - "PyMeshLab HC Laplacian smoothing filter not available. " - "Ensure pymeshlab >= 2023.12 is installed." - ) - - out = ms.current_mesh() - result = trimesh_module.Trimesh( - vertices=out.vertex_matrix(), - faces=out.face_matrix(), - process=False, - ) - return result, "" - -def _trimesh_laplacian_smooth(mesh, iterations, lamb): - """Laplacian smoothing via trimesh (uniform weights).""" - result = mesh.copy() - - try: - from trimesh.smoothing import filter_laplacian - filter_laplacian(result, lamb=lamb, iterations=iterations) - except ImportError: - return None, "trimesh smoothing module not available." - - return result, "" - -def _trimesh_taubin_smooth(mesh, iterations, lamb, mu): - """Taubin smoothing via trimesh.""" - result = mesh.copy() - - try: - from trimesh.smoothing import filter_taubin - filter_taubin(result, lamb=lamb, mu=mu, iterations=iterations) - except ImportError: - return None, "trimesh Taubin smoothing not available." - - return result, "" class SmoothMeshNode(io.ComfyNode): """ - Smooth Mesh - Various mesh smoothing algorithms. + Smooth Mesh - Unified smoothing with backend selection. - Available backends: - - laplacian: Classic Laplacian smoothing. Fast but causes mesh shrinkage. - Cotangent weights give geometrically better results than uniform weights. - - taubin: Two-step (lambda/mu) Laplacian that prevents shrinkage. - Best general-purpose smoothing for preserving volume. - - hc_laplacian: Humphrey Classes Laplacian correction. - Minimal shrinkage, good detail preservation. - - trimesh_laplacian: Uniform Laplacian via trimesh (fast, no pymeshlab needed). - - trimesh_taubin: Taubin smoothing via trimesh (no pymeshlab needed). + Dispatches to hidden backend nodes via node expansion. """ + BACKEND_MAP = { + "taubin": "GeomPackSmooth_Taubin", + "laplacian": "GeomPackSmooth_Laplacian", + "hc_laplacian": "GeomPackSmooth_HCLaplacian", + "trimesh_laplacian": "GeomPackSmooth_TrimeshLaplacian", + "trimesh_taubin": "GeomPackSmooth_TrimeshTaubin", + } + @classmethod def define_schema(cls): return io.Schema( node_id="GeomPackSmoothMesh", display_name="Smooth Mesh", category="geompack/smoothing", + enable_expand=True, is_output_node=True, inputs=[ io.Custom("TRIMESH").Input("trimesh"), @@ -218,107 +75,30 @@ class SmoothMeshNode(io.ComfyNode): @classmethod def execute(cls, trimesh, backend): - """Apply mesh smoothing based on selected backend.""" + from comfy_execution.graph_utils import GraphBuilder + + if cls.SCHEMA is None: + cls.GET_SCHEMA() + selected = backend["backend"] - iterations = backend.get("iterations", 5) - lambda_ = backend.get("lambda_", 0.5) - mu = backend.get("mu", -0.53) - cotangent_weight = backend.get("cotangent_weight", "true") + node_id = cls.BACKEND_MAP[selected] - initial_vertices = len(trimesh.vertices) - initial_faces = len(trimesh.faces) + log.info("Smooth dispatch: %s -> %s", selected, node_id) - log.info("Smooth backend: %s", selected) - log.info("Input: %s vertices, %s faces", - f"{initial_vertices:,}", f"{initial_faces:,}") + kwargs = {"trimesh": trimesh} + for k, v in backend.items(): + if k == "backend": + continue + kwargs[k] = v - if selected == "laplacian": - cot = (cotangent_weight == "true") - log.info("Parameters: iterations=%d, cotangent_weight=%s", iterations, cot) - smoothed, error = _pymeshlab_laplacian_smooth( - trimesh, iterations, cot, False - ) - elif selected == "taubin": - log.info("Parameters: iterations=%d, lambda=%.3f, mu=%.3f", - iterations, lambda_, mu) - smoothed, error = _pymeshlab_taubin_smooth( - trimesh, iterations, lambda_, mu, False - ) - elif selected == "hc_laplacian": - log.info("Parameters: (none)") - smoothed, error = _pymeshlab_hc_laplacian_smooth(trimesh, False) - elif selected == "trimesh_laplacian": - log.info("Parameters: iterations=%d, lambda=%.3f", iterations, lambda_) - smoothed, error = _trimesh_laplacian_smooth( - trimesh, iterations, lambda_ - ) - elif selected == "trimesh_taubin": - log.info("Parameters: iterations=%d, lambda=%.3f, mu=%.3f", - iterations, lambda_, mu) - smoothed, error = _trimesh_taubin_smooth( - trimesh, iterations, lambda_, mu - ) - else: - raise ValueError(f"Unknown backend: {selected}") + graph = GraphBuilder() + backend_node = graph.node(node_id, **kwargs) - if smoothed is None: - raise ValueError(f"Smoothing failed ({selected}): {error}") - - # Copy metadata - if hasattr(trimesh, "metadata") and trimesh.metadata: - smoothed.metadata = trimesh.metadata.copy() - smoothed.metadata["smoothing"] = { - "algorithm": selected, - "iterations": iterations, - "original_vertices": initial_vertices, - "original_faces": initial_faces, + return { + "result": (backend_node.out(0), backend_node.out(1)), + "expand": graph.finalize(), } - # Compute displacement stats - disp = np.linalg.norm( - np.asarray(smoothed.vertices) - np.asarray(trimesh.vertices), axis=1 - ) - avg_disp = float(np.mean(disp)) - max_disp = float(np.max(disp)) - - log.info("Output: %d vertices, %d faces", - len(smoothed.vertices), len(smoothed.faces)) - log.info("Avg vertex displacement: %.6f, max: %.6f", avg_disp, max_disp) - - # Build backend-specific param block - if selected == "laplacian": - param_text = ( - f"Iterations: {iterations}\n" - f"Cotangent Weight: {cotangent_weight}" - ) - elif selected in ("taubin", "trimesh_taubin"): - param_text = ( - f"Iterations: {iterations}\n" - f"Lambda: {lambda_}\n" - f"Mu: {mu}" - ) - elif selected == "hc_laplacian": - param_text = "(single-pass HC correction)" - elif selected == "trimesh_laplacian": - param_text = ( - f"Iterations: {iterations}\n" - f"Lambda: {lambda_}" - ) - else: - param_text = "" - - info = f"""Smooth Mesh Results ({selected}): - -{param_text} - -Vertices: {initial_vertices:,} (unchanged) -Faces: {initial_faces:,} (unchanged) - -Displacement: - Average: {avg_disp:.6f} - Maximum: {max_disp:.6f} -""" - return io.NodeOutput(smoothed, info, ui={"text": [info]}) NODE_CLASS_MAPPINGS = { "GeomPackSmoothMesh": SmoothMeshNode, diff --git a/nodes/main/smoothing/smooth_backends/__init__.py b/nodes/main/smoothing/smooth_backends/__init__.py new file mode 100644 index 00000000..682516a9 --- /dev/null +++ b/nodes/main/smoothing/smooth_backends/__init__.py @@ -0,0 +1,29 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2025 ComfyUI-GeometryPack Contributors + +""" +Individual smoothing backend nodes (hidden from UI, invoked via unified Smooth Mesh node). +""" + +from .taubin import NODE_CLASS_MAPPINGS as TAU_MAPS, NODE_DISPLAY_NAME_MAPPINGS as TAU_DISP +from .laplacian import NODE_CLASS_MAPPINGS as LAP_MAPS, NODE_DISPLAY_NAME_MAPPINGS as LAP_DISP +from .hc_laplacian import NODE_CLASS_MAPPINGS as HC_MAPS, NODE_DISPLAY_NAME_MAPPINGS as HC_DISP +from .trimesh_laplacian import NODE_CLASS_MAPPINGS as TL_MAPS, NODE_DISPLAY_NAME_MAPPINGS as TL_DISP +from .trimesh_taubin import NODE_CLASS_MAPPINGS as TT_MAPS, NODE_DISPLAY_NAME_MAPPINGS as TT_DISP + +NODE_CLASS_MAPPINGS = {} +NODE_DISPLAY_NAME_MAPPINGS = {} + +NODE_CLASS_MAPPINGS.update(TAU_MAPS) +NODE_CLASS_MAPPINGS.update(LAP_MAPS) +NODE_CLASS_MAPPINGS.update(HC_MAPS) +NODE_CLASS_MAPPINGS.update(TL_MAPS) +NODE_CLASS_MAPPINGS.update(TT_MAPS) + +NODE_DISPLAY_NAME_MAPPINGS.update(TAU_DISP) +NODE_DISPLAY_NAME_MAPPINGS.update(LAP_DISP) +NODE_DISPLAY_NAME_MAPPINGS.update(HC_DISP) +NODE_DISPLAY_NAME_MAPPINGS.update(TL_DISP) +NODE_DISPLAY_NAME_MAPPINGS.update(TT_DISP) + +__all__ = ['NODE_CLASS_MAPPINGS', 'NODE_DISPLAY_NAME_MAPPINGS'] diff --git a/nodes/main/smoothing/smooth_backends/hc_laplacian.py b/nodes/main/smoothing/smooth_backends/hc_laplacian.py new file mode 100644 index 00000000..92f2f2cb --- /dev/null +++ b/nodes/main/smoothing/smooth_backends/hc_laplacian.py @@ -0,0 +1,107 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2025 ComfyUI-GeometryPack Contributors + +"""PyMeshLab HC Laplacian smoothing backend node.""" + +import logging +import numpy as np +import trimesh as trimesh_module +from comfy_api.latest import io + +log = logging.getLogger("geometrypack") + + +def _pymeshlab_hc_laplacian_smooth(mesh, selected_only): + """HC Laplacian smoothing via PyMeshLab (low shrinkage).""" + import pymeshlab + + ms = pymeshlab.MeshSet() + pml_mesh = pymeshlab.Mesh( + vertex_matrix=np.asarray(mesh.vertices, dtype=np.float64), + face_matrix=np.asarray(mesh.faces, dtype=np.int32), + ) + ms.add_mesh(pml_mesh) + + try: + ms.apply_coord_hc_laplacian_smoothing(selected=selected_only) + except AttributeError: + try: + ms.hc_laplacian_smooth(selected=selected_only) + except AttributeError: + raise RuntimeError( + "PyMeshLab HC Laplacian smoothing filter not available. " + "Ensure pymeshlab >= 2023.12 is installed." + ) + + out = ms.current_mesh() + return trimesh_module.Trimesh( + vertices=out.vertex_matrix(), + faces=out.face_matrix(), + process=False, + ) + + +class SmoothHCLaplacianNode(io.ComfyNode): + """PyMeshLab HC Laplacian smoothing backend.""" + + @classmethod + def define_schema(cls): + return io.Schema( + node_id="GeomPackSmooth_HCLaplacian", + display_name="Smooth HC Laplacian (backend)", + category="geompack/smoothing", + is_dev_only=True, + is_output_node=True, + inputs=[ + io.Custom("TRIMESH").Input("trimesh"), + ], + outputs=[ + io.Custom("TRIMESH").Output(display_name="smoothed_mesh"), + io.String.Output(display_name="info"), + ], + ) + + @classmethod + def execute(cls, trimesh): + log.info("Backend: hc_laplacian") + log.info("Input: %d vertices, %d faces", len(trimesh.vertices), len(trimesh.faces)) + + initial_vertices = len(trimesh.vertices) + initial_faces = len(trimesh.faces) + + smoothed = _pymeshlab_hc_laplacian_smooth(trimesh, False) + + if hasattr(trimesh, "metadata") and trimesh.metadata: + smoothed.metadata = trimesh.metadata.copy() + smoothed.metadata["smoothing"] = { + "algorithm": "hc_laplacian", + "iterations": 1, + "original_vertices": initial_vertices, + "original_faces": initial_faces, + } + + disp = np.linalg.norm( + np.asarray(smoothed.vertices) - np.asarray(trimesh.vertices), axis=1 + ) + avg_disp = float(np.mean(disp)) + max_disp = float(np.max(disp)) + + log.info("Output: %d vertices, %d faces", len(smoothed.vertices), len(smoothed.faces)) + log.info("Avg vertex displacement: %.6f, max: %.6f", avg_disp, max_disp) + + info = f"""Smooth Mesh Results (hc_laplacian): + +(single-pass HC correction) + +Vertices: {initial_vertices:,} (unchanged) +Faces: {initial_faces:,} (unchanged) + +Displacement: + Average: {avg_disp:.6f} + Maximum: {max_disp:.6f} +""" + return io.NodeOutput(smoothed, info, ui={"text": [info]}) + + +NODE_CLASS_MAPPINGS = {"GeomPackSmooth_HCLaplacian": SmoothHCLaplacianNode} +NODE_DISPLAY_NAME_MAPPINGS = {"GeomPackSmooth_HCLaplacian": "Smooth HC Laplacian (backend)"} diff --git a/nodes/main/smoothing/smooth_backends/laplacian.py b/nodes/main/smoothing/smooth_backends/laplacian.py new file mode 100644 index 00000000..0a381c2f --- /dev/null +++ b/nodes/main/smoothing/smooth_backends/laplacian.py @@ -0,0 +1,121 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2025 ComfyUI-GeometryPack Contributors + +"""PyMeshLab Laplacian smoothing backend node.""" + +import logging +import numpy as np +import trimesh as trimesh_module +from comfy_api.latest import io + +log = logging.getLogger("geometrypack") + + +def _pymeshlab_laplacian_smooth(mesh, iterations, cotangent_weight, selected_only): + """Laplacian smoothing via PyMeshLab.""" + import pymeshlab + + ms = pymeshlab.MeshSet() + pml_mesh = pymeshlab.Mesh( + vertex_matrix=np.asarray(mesh.vertices, dtype=np.float64), + face_matrix=np.asarray(mesh.faces, dtype=np.int32), + ) + ms.add_mesh(pml_mesh) + + try: + ms.apply_coord_laplacian_smoothing( + stepsmoothnum=iterations, + cotangentweight=cotangent_weight, + selected=selected_only, + ) + except AttributeError: + try: + ms.laplacian_smooth( + stepsmoothnum=iterations, + cotangentweight=cotangent_weight, + selected=selected_only, + ) + except AttributeError: + raise RuntimeError( + "PyMeshLab Laplacian smoothing filter not available. " + "Ensure pymeshlab >= 2023.12 is installed." + ) + + out = ms.current_mesh() + return trimesh_module.Trimesh( + vertices=out.vertex_matrix(), + faces=out.face_matrix(), + process=False, + ) + + +class SmoothLaplacianNode(io.ComfyNode): + """PyMeshLab Laplacian smoothing backend.""" + + @classmethod + def define_schema(cls): + return io.Schema( + node_id="GeomPackSmooth_Laplacian", + display_name="Smooth Laplacian (backend)", + category="geompack/smoothing", + is_dev_only=True, + is_output_node=True, + inputs=[ + io.Custom("TRIMESH").Input("trimesh"), + io.Int.Input("iterations", default=5, min=1, max=200, step=1, tooltip="Number of smoothing passes. More = smoother but slower."), + io.Combo.Input("cotangent_weight", options=["true", "false"], default="true", tooltip="Use cotangent weights instead of uniform weights. Cotangent weights respect mesh geometry better but may be unstable on degenerate meshes."), + ], + outputs=[ + io.Custom("TRIMESH").Output(display_name="smoothed_mesh"), + io.String.Output(display_name="info"), + ], + ) + + @classmethod + def execute(cls, trimesh, iterations=5, cotangent_weight="true"): + log.info("Backend: laplacian") + log.info("Input: %d vertices, %d faces", len(trimesh.vertices), len(trimesh.faces)) + + initial_vertices = len(trimesh.vertices) + initial_faces = len(trimesh.faces) + cot = (cotangent_weight == "true") + + log.info("Parameters: iterations=%d, cotangent_weight=%s", iterations, cot) + + smoothed = _pymeshlab_laplacian_smooth(trimesh, iterations, cot, False) + + if hasattr(trimesh, "metadata") and trimesh.metadata: + smoothed.metadata = trimesh.metadata.copy() + smoothed.metadata["smoothing"] = { + "algorithm": "laplacian", + "iterations": iterations, + "original_vertices": initial_vertices, + "original_faces": initial_faces, + } + + disp = np.linalg.norm( + np.asarray(smoothed.vertices) - np.asarray(trimesh.vertices), axis=1 + ) + avg_disp = float(np.mean(disp)) + max_disp = float(np.max(disp)) + + log.info("Output: %d vertices, %d faces", len(smoothed.vertices), len(smoothed.faces)) + log.info("Avg vertex displacement: %.6f, max: %.6f", avg_disp, max_disp) + + info = f"""Smooth Mesh Results (laplacian): + +Iterations: {iterations} +Cotangent Weight: {cotangent_weight} + +Vertices: {initial_vertices:,} (unchanged) +Faces: {initial_faces:,} (unchanged) + +Displacement: + Average: {avg_disp:.6f} + Maximum: {max_disp:.6f} +""" + return io.NodeOutput(smoothed, info, ui={"text": [info]}) + + +NODE_CLASS_MAPPINGS = {"GeomPackSmooth_Laplacian": SmoothLaplacianNode} +NODE_DISPLAY_NAME_MAPPINGS = {"GeomPackSmooth_Laplacian": "Smooth Laplacian (backend)"} diff --git a/nodes/main/smoothing/smooth_backends/taubin.py b/nodes/main/smoothing/smooth_backends/taubin.py new file mode 100644 index 00000000..64a13a95 --- /dev/null +++ b/nodes/main/smoothing/smooth_backends/taubin.py @@ -0,0 +1,123 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2025 ComfyUI-GeometryPack Contributors + +"""PyMeshLab Taubin smoothing backend node.""" + +import logging +import numpy as np +import trimesh as trimesh_module +from comfy_api.latest import io + +log = logging.getLogger("geometrypack") + + +def _pymeshlab_taubin_smooth(mesh, iterations, lambda_val, mu_val, selected_only): + """Taubin smoothing via PyMeshLab (shrinkage-free).""" + import pymeshlab + + ms = pymeshlab.MeshSet() + pml_mesh = pymeshlab.Mesh( + vertex_matrix=np.asarray(mesh.vertices, dtype=np.float64), + face_matrix=np.asarray(mesh.faces, dtype=np.int32), + ) + ms.add_mesh(pml_mesh) + + try: + ms.apply_coord_taubin_smoothing( + lambda_=lambda_val, + mu=mu_val, + stepsmoothnum=iterations, + selected=selected_only, + ) + except AttributeError: + try: + ms.taubin_smooth( + lambda_=lambda_val, + mu=mu_val, + stepsmoothnum=iterations, + selected=selected_only, + ) + except AttributeError: + raise RuntimeError( + "PyMeshLab Taubin smoothing filter not available. " + "Ensure pymeshlab >= 2023.12 is installed." + ) + + out = ms.current_mesh() + return trimesh_module.Trimesh( + vertices=out.vertex_matrix(), + faces=out.face_matrix(), + process=False, + ) + + +class SmoothTaubinNode(io.ComfyNode): + """PyMeshLab Taubin smoothing backend.""" + + @classmethod + def define_schema(cls): + return io.Schema( + node_id="GeomPackSmooth_Taubin", + display_name="Smooth Taubin (backend)", + category="geompack/smoothing", + is_dev_only=True, + is_output_node=True, + inputs=[ + io.Custom("TRIMESH").Input("trimesh"), + io.Int.Input("iterations", default=5, min=1, max=200, step=1, tooltip="Number of smoothing passes. More = smoother but slower."), + io.Float.Input("lambda_", default=0.5, min=0.01, max=1.0, step=0.01, tooltip="Smoothing strength per step. Higher = more aggressive smoothing per iteration."), + io.Float.Input("mu", default=-0.53, min=-1.0, max=-0.01, step=0.01, tooltip="Inflation factor (negative). Counteracts shrinkage from lambda. Must satisfy |mu| > lambda for stability. Typical: -0.53 for lambda=0.5."), + ], + outputs=[ + io.Custom("TRIMESH").Output(display_name="smoothed_mesh"), + io.String.Output(display_name="info"), + ], + ) + + @classmethod + def execute(cls, trimesh, iterations=5, lambda_=0.5, mu=-0.53): + log.info("Backend: taubin") + log.info("Input: %d vertices, %d faces", len(trimesh.vertices), len(trimesh.faces)) + log.info("Parameters: iterations=%d, lambda=%.3f, mu=%.3f", iterations, lambda_, mu) + + initial_vertices = len(trimesh.vertices) + initial_faces = len(trimesh.faces) + + smoothed = _pymeshlab_taubin_smooth(trimesh, iterations, lambda_, mu, False) + + if hasattr(trimesh, "metadata") and trimesh.metadata: + smoothed.metadata = trimesh.metadata.copy() + smoothed.metadata["smoothing"] = { + "algorithm": "taubin", + "iterations": iterations, + "original_vertices": initial_vertices, + "original_faces": initial_faces, + } + + disp = np.linalg.norm( + np.asarray(smoothed.vertices) - np.asarray(trimesh.vertices), axis=1 + ) + avg_disp = float(np.mean(disp)) + max_disp = float(np.max(disp)) + + log.info("Output: %d vertices, %d faces", len(smoothed.vertices), len(smoothed.faces)) + log.info("Avg vertex displacement: %.6f, max: %.6f", avg_disp, max_disp) + + info = f"""Smooth Mesh Results (taubin): + +Iterations: {iterations} +Lambda: {lambda_} +Mu: {mu} + +Vertices: {initial_vertices:,} (unchanged) +Faces: {initial_faces:,} (unchanged) + +Displacement: + Average: {avg_disp:.6f} + Maximum: {max_disp:.6f} +""" + return io.NodeOutput(smoothed, info, ui={"text": [info]}) + + +NODE_CLASS_MAPPINGS = {"GeomPackSmooth_Taubin": SmoothTaubinNode} +NODE_DISPLAY_NAME_MAPPINGS = {"GeomPackSmooth_Taubin": "Smooth Taubin (backend)"} diff --git a/nodes/main/smoothing/smooth_backends/trimesh_laplacian.py b/nodes/main/smoothing/smooth_backends/trimesh_laplacian.py new file mode 100644 index 00000000..7a85ea3a --- /dev/null +++ b/nodes/main/smoothing/smooth_backends/trimesh_laplacian.py @@ -0,0 +1,90 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2025 ComfyUI-GeometryPack Contributors + +"""Trimesh Laplacian smoothing backend node.""" + +import logging +import numpy as np +from comfy_api.latest import io + +log = logging.getLogger("geometrypack") + + +def _trimesh_laplacian_smooth(mesh, iterations, lamb): + """Laplacian smoothing via trimesh (uniform weights).""" + result = mesh.copy() + + from trimesh.smoothing import filter_laplacian + filter_laplacian(result, lamb=lamb, iterations=iterations) + + return result + + +class SmoothTrimeshLaplacianNode(io.ComfyNode): + """Trimesh Laplacian smoothing backend.""" + + @classmethod + def define_schema(cls): + return io.Schema( + node_id="GeomPackSmooth_TrimeshLaplacian", + display_name="Smooth Trimesh Laplacian (backend)", + category="geompack/smoothing", + is_dev_only=True, + is_output_node=True, + inputs=[ + io.Custom("TRIMESH").Input("trimesh"), + io.Int.Input("iterations", default=5, min=1, max=200, step=1, tooltip="Number of smoothing passes. More = smoother but slower."), + io.Float.Input("lambda_", default=0.5, min=0.01, max=1.0, step=0.01, tooltip="Smoothing strength per step. Higher = more aggressive smoothing per iteration."), + ], + outputs=[ + io.Custom("TRIMESH").Output(display_name="smoothed_mesh"), + io.String.Output(display_name="info"), + ], + ) + + @classmethod + def execute(cls, trimesh, iterations=5, lambda_=0.5): + log.info("Backend: trimesh_laplacian") + log.info("Input: %d vertices, %d faces", len(trimesh.vertices), len(trimesh.faces)) + log.info("Parameters: iterations=%d, lambda=%.3f", iterations, lambda_) + + initial_vertices = len(trimesh.vertices) + initial_faces = len(trimesh.faces) + + smoothed = _trimesh_laplacian_smooth(trimesh, iterations, lambda_) + + if hasattr(trimesh, "metadata") and trimesh.metadata: + smoothed.metadata = trimesh.metadata.copy() + smoothed.metadata["smoothing"] = { + "algorithm": "trimesh_laplacian", + "iterations": iterations, + "original_vertices": initial_vertices, + "original_faces": initial_faces, + } + + disp = np.linalg.norm( + np.asarray(smoothed.vertices) - np.asarray(trimesh.vertices), axis=1 + ) + avg_disp = float(np.mean(disp)) + max_disp = float(np.max(disp)) + + log.info("Output: %d vertices, %d faces", len(smoothed.vertices), len(smoothed.faces)) + log.info("Avg vertex displacement: %.6f, max: %.6f", avg_disp, max_disp) + + info = f"""Smooth Mesh Results (trimesh_laplacian): + +Iterations: {iterations} +Lambda: {lambda_} + +Vertices: {initial_vertices:,} (unchanged) +Faces: {initial_faces:,} (unchanged) + +Displacement: + Average: {avg_disp:.6f} + Maximum: {max_disp:.6f} +""" + return io.NodeOutput(smoothed, info, ui={"text": [info]}) + + +NODE_CLASS_MAPPINGS = {"GeomPackSmooth_TrimeshLaplacian": SmoothTrimeshLaplacianNode} +NODE_DISPLAY_NAME_MAPPINGS = {"GeomPackSmooth_TrimeshLaplacian": "Smooth Trimesh Laplacian (backend)"} diff --git a/nodes/main/smoothing/smooth_backends/trimesh_taubin.py b/nodes/main/smoothing/smooth_backends/trimesh_taubin.py new file mode 100644 index 00000000..8669b9ea --- /dev/null +++ b/nodes/main/smoothing/smooth_backends/trimesh_taubin.py @@ -0,0 +1,92 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2025 ComfyUI-GeometryPack Contributors + +"""Trimesh Taubin smoothing backend node.""" + +import logging +import numpy as np +from comfy_api.latest import io + +log = logging.getLogger("geometrypack") + + +def _trimesh_taubin_smooth(mesh, iterations, lamb, mu): + """Taubin smoothing via trimesh.""" + result = mesh.copy() + + from trimesh.smoothing import filter_taubin + filter_taubin(result, lamb=lamb, mu=mu, iterations=iterations) + + return result + + +class SmoothTrimeshTaubinNode(io.ComfyNode): + """Trimesh Taubin smoothing backend.""" + + @classmethod + def define_schema(cls): + return io.Schema( + node_id="GeomPackSmooth_TrimeshTaubin", + display_name="Smooth Trimesh Taubin (backend)", + category="geompack/smoothing", + is_dev_only=True, + is_output_node=True, + inputs=[ + io.Custom("TRIMESH").Input("trimesh"), + io.Int.Input("iterations", default=5, min=1, max=200, step=1, tooltip="Number of smoothing passes. More = smoother but slower."), + io.Float.Input("lambda_", default=0.5, min=0.01, max=1.0, step=0.01, tooltip="Smoothing strength per step. Higher = more aggressive smoothing per iteration."), + io.Float.Input("mu", default=-0.53, min=-1.0, max=-0.01, step=0.01, tooltip="Inflation factor (negative). Counteracts shrinkage from lambda. Must satisfy |mu| > lambda for stability. Typical: -0.53 for lambda=0.5."), + ], + outputs=[ + io.Custom("TRIMESH").Output(display_name="smoothed_mesh"), + io.String.Output(display_name="info"), + ], + ) + + @classmethod + def execute(cls, trimesh, iterations=5, lambda_=0.5, mu=-0.53): + log.info("Backend: trimesh_taubin") + log.info("Input: %d vertices, %d faces", len(trimesh.vertices), len(trimesh.faces)) + log.info("Parameters: iterations=%d, lambda=%.3f, mu=%.3f", iterations, lambda_, mu) + + initial_vertices = len(trimesh.vertices) + initial_faces = len(trimesh.faces) + + smoothed = _trimesh_taubin_smooth(trimesh, iterations, lambda_, mu) + + if hasattr(trimesh, "metadata") and trimesh.metadata: + smoothed.metadata = trimesh.metadata.copy() + smoothed.metadata["smoothing"] = { + "algorithm": "trimesh_taubin", + "iterations": iterations, + "original_vertices": initial_vertices, + "original_faces": initial_faces, + } + + disp = np.linalg.norm( + np.asarray(smoothed.vertices) - np.asarray(trimesh.vertices), axis=1 + ) + avg_disp = float(np.mean(disp)) + max_disp = float(np.max(disp)) + + log.info("Output: %d vertices, %d faces", len(smoothed.vertices), len(smoothed.faces)) + log.info("Avg vertex displacement: %.6f, max: %.6f", avg_disp, max_disp) + + info = f"""Smooth Mesh Results (trimesh_taubin): + +Iterations: {iterations} +Lambda: {lambda_} +Mu: {mu} + +Vertices: {initial_vertices:,} (unchanged) +Faces: {initial_faces:,} (unchanged) + +Displacement: + Average: {avg_disp:.6f} + Maximum: {max_disp:.6f} +""" + return io.NodeOutput(smoothed, info, ui={"text": [info]}) + + +NODE_CLASS_MAPPINGS = {"GeomPackSmooth_TrimeshTaubin": SmoothTrimeshTaubinNode} +NODE_DISPLAY_NAME_MAPPINGS = {"GeomPackSmooth_TrimeshTaubin": "Smooth Trimesh Taubin (backend)"} diff --git a/nodes/main/uv/__init__.py b/nodes/main/uv/__init__.py index 8faaf01d..158bf5ce 100644 --- a/nodes/main/uv/__init__.py +++ b/nodes/main/uv/__init__.py @@ -8,14 +8,18 @@ The unified UV Unwrap node consolidates all UV unwrapping methods into a single with dynamic parameter exposure based on the selected method. """ -# Unified UV unwrap node -from .uv_unwrap import UVUnwrapNode, NODE_CLASS_MAPPINGS as UV_UNWRAP_MAPS, NODE_DISPLAY_NAME_MAPPINGS as UV_UNWRAP_DISP +# Unified UV unwrap node (frontend) +from .uv_unwrap import NODE_CLASS_MAPPINGS as UV_UNWRAP_MAPS, NODE_DISPLAY_NAME_MAPPINGS as UV_UNWRAP_DISP + +# Main-env backend nodes +from .backends import NODE_CLASS_MAPPINGS as BACKENDS_MAPS, NODE_DISPLAY_NAME_MAPPINGS as BACKENDS_DISP NODE_CLASS_MAPPINGS = {} NODE_DISPLAY_NAME_MAPPINGS = {} -# Register only the unified node NODE_CLASS_MAPPINGS.update(UV_UNWRAP_MAPS) NODE_DISPLAY_NAME_MAPPINGS.update(UV_UNWRAP_DISP) +NODE_CLASS_MAPPINGS.update(BACKENDS_MAPS) +NODE_DISPLAY_NAME_MAPPINGS.update(BACKENDS_DISP) __all__ = ['NODE_CLASS_MAPPINGS', 'NODE_DISPLAY_NAME_MAPPINGS'] diff --git a/nodes/main/uv/backends/__init__.py b/nodes/main/uv/backends/__init__.py new file mode 100644 index 00000000..b92b85fc --- /dev/null +++ b/nodes/main/uv/backends/__init__.py @@ -0,0 +1,24 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2025 ComfyUI-GeometryPack Contributors + +"""Individual UV unwrap backend nodes (hidden from UI, invoked via unified UV Unwrap node).""" + +from .xatlas import NODE_CLASS_MAPPINGS as XATLAS_MAPS, NODE_DISPLAY_NAME_MAPPINGS as XATLAS_DISP +from .libigl_lscm import NODE_CLASS_MAPPINGS as LSCM_MAPS, NODE_DISPLAY_NAME_MAPPINGS as LSCM_DISP +from .libigl_harmonic import NODE_CLASS_MAPPINGS as HARM_MAPS, NODE_DISPLAY_NAME_MAPPINGS as HARM_DISP +from .libigl_arap import NODE_CLASS_MAPPINGS as ARAP_MAPS, NODE_DISPLAY_NAME_MAPPINGS as ARAP_DISP + +NODE_CLASS_MAPPINGS = {} +NODE_DISPLAY_NAME_MAPPINGS = {} + +NODE_CLASS_MAPPINGS.update(XATLAS_MAPS) +NODE_CLASS_MAPPINGS.update(LSCM_MAPS) +NODE_CLASS_MAPPINGS.update(HARM_MAPS) +NODE_CLASS_MAPPINGS.update(ARAP_MAPS) + +NODE_DISPLAY_NAME_MAPPINGS.update(XATLAS_DISP) +NODE_DISPLAY_NAME_MAPPINGS.update(LSCM_DISP) +NODE_DISPLAY_NAME_MAPPINGS.update(HARM_DISP) +NODE_DISPLAY_NAME_MAPPINGS.update(ARAP_DISP) + +__all__ = ['NODE_CLASS_MAPPINGS', 'NODE_DISPLAY_NAME_MAPPINGS'] diff --git a/nodes/main/uv/backends/libigl_arap.py b/nodes/main/uv/backends/libigl_arap.py new file mode 100644 index 00000000..580cd543 --- /dev/null +++ b/nodes/main/uv/backends/libigl_arap.py @@ -0,0 +1,121 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2025 ComfyUI-GeometryPack Contributors + +"""libigl ARAP UV unwrapping backend node.""" + +import logging + +import numpy as np +from comfy_api.latest import io + +log = logging.getLogger("geometrypack") + + +class UVLibiglARAPNode(io.ComfyNode): + """libigl ARAP-like parameterization backend.""" + + @classmethod + def define_schema(cls): + return io.Schema( + node_id="GeomPackUV_LibiglARAP", + display_name="UV libigl ARAP (backend)", + category="geompack/uv", + is_dev_only=True, + is_output_node=True, + inputs=[ + io.Custom("TRIMESH").Input("trimesh"), + io.Int.Input("iterations", default=10, min=1, max=100, step=1), + ], + outputs=[ + io.Custom("TRIMESH").Output(display_name="unwrapped_mesh"), + io.String.Output(display_name="info"), + ], + ) + + @classmethod + def execute(cls, trimesh, iterations=10): + """libigl ARAP-like parameterization.""" + try: + import igl + import scipy.sparse + except ImportError: + raise ImportError("libigl and scipy not installed") + + log.info("Backend: libigl_arap") + log.info("Input: %d vertices, %d faces", len(trimesh.vertices), len(trimesh.faces)) + log.info("Parameters: iterations=%d", iterations) + + # Find boundary + boundary_loop = igl.boundary_loop(np.asarray(trimesh.faces, dtype=np.int32)) + + if len(boundary_loop) == 0: + raise ValueError( + "Mesh has no boundary - ARAP parameterization requires an open mesh. " + "Try using xatlas or libigl_lscm for closed meshes." + ) + + # Map boundary to circle + bnd_angles = np.linspace(0, 2 * np.pi, len(boundary_loop), endpoint=False) + bnd_uv = np.column_stack([ + 0.5 + 0.5 * np.cos(bnd_angles), + 0.5 + 0.5 * np.sin(bnd_angles) + ]) + + # Initial harmonic solution + uv_init = igl.harmonic( + np.asarray(trimesh.vertices, dtype=np.float64), + np.asarray(trimesh.faces, dtype=np.int32), + boundary_loop.astype(np.int32), + bnd_uv.astype(np.float64), + 1 + ) + + # Apply iterative biharmonic refinement (ARAP-like) + uv = uv_init.copy() + for i in range(iterations): + uv = igl.harmonic( + np.asarray(trimesh.vertices, dtype=np.float64), + np.asarray(trimesh.faces, dtype=np.int32), + boundary_loop.astype(np.int32), + bnd_uv.astype(np.float64), + 2 # biharmonic for smoother result + ) + + # Normalize to [0, 1] + uv_min = uv.min(axis=0) + uv_max = uv.max(axis=0) + uv_range = uv_max - uv_min + uv_range[uv_range < 1e-10] = 1.0 + uv = (uv - uv_min) / uv_range + + # Create unwrapped mesh + unwrapped = trimesh.copy() + from trimesh.visual import TextureVisuals + unwrapped.visual = TextureVisuals(uv=uv) + + unwrapped.metadata['uv_unwrap'] = { + 'algorithm': 'libigl_arap_like', + 'iterations': iterations, + 'minimizes_distortion': True + } + + log.info("Output: %d vertices, %d faces", len(unwrapped.vertices), len(unwrapped.faces)) + + info = f"""UV Unwrap Results (libigl ARAP): + +Algorithm: As-Rigid-As-Possible (biharmonic approximation) +Properties: Minimizes distortion, preserves shape +Iterations: {iterations} + +Vertices: {len(trimesh.vertices):,} +Faces: {len(trimesh.faces):,} +Boundary Vertices: {len(boundary_loop):,} + +Iterative solver for higher quality results. +Better preservation of angles and shapes. +""" + return io.NodeOutput(unwrapped, info, ui={"text": [info]}) + + +NODE_CLASS_MAPPINGS = {"GeomPackUV_LibiglARAP": UVLibiglARAPNode} +NODE_DISPLAY_NAME_MAPPINGS = {"GeomPackUV_LibiglARAP": "UV libigl ARAP (backend)"} diff --git a/nodes/main/uv/backends/libigl_harmonic.py b/nodes/main/uv/backends/libigl_harmonic.py new file mode 100644 index 00000000..ec7aaf29 --- /dev/null +++ b/nodes/main/uv/backends/libigl_harmonic.py @@ -0,0 +1,99 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2025 ComfyUI-GeometryPack Contributors + +"""libigl harmonic UV unwrapping backend node.""" + +import logging + +import numpy as np +from comfy_api.latest import io + +log = logging.getLogger("geometrypack") + + +class UVLibiglHarmonicNode(io.ComfyNode): + """libigl harmonic (Laplacian) mapping backend.""" + + @classmethod + def define_schema(cls): + return io.Schema( + node_id="GeomPackUV_LibiglHarmonic", + display_name="UV libigl Harmonic (backend)", + category="geompack/uv", + is_dev_only=True, + is_output_node=True, + inputs=[ + io.Custom("TRIMESH").Input("trimesh"), + ], + outputs=[ + io.Custom("TRIMESH").Output(display_name="unwrapped_mesh"), + io.String.Output(display_name="info"), + ], + ) + + @classmethod + def execute(cls, trimesh): + """libigl harmonic (Laplacian) mapping.""" + try: + import igl + except ImportError: + raise ImportError("libigl not installed (should be in requirements.txt)") + + log.info("Backend: libigl_harmonic") + log.info("Input: %d vertices, %d faces", len(trimesh.vertices), len(trimesh.faces)) + + # Find boundary loop + boundary_loop = igl.boundary_loop(np.asarray(trimesh.faces, dtype=np.int32)) + + if len(boundary_loop) == 0: + raise ValueError( + "Mesh has no boundary - harmonic parameterization requires an open mesh. " + "Try using xatlas or libigl_lscm for closed meshes." + ) + + # Map boundary to circle + bnd_angles = np.linspace(0, 2 * np.pi, len(boundary_loop), endpoint=False) + bnd_uv = np.column_stack([ + 0.5 + 0.5 * np.cos(bnd_angles), + 0.5 + 0.5 * np.sin(bnd_angles) + ]) + + # Compute harmonic parameterization + uv = igl.harmonic( + np.asarray(trimesh.vertices, dtype=np.float64), + np.asarray(trimesh.faces, dtype=np.int32), + boundary_loop.astype(np.int32), + bnd_uv.astype(np.float64), + 1 # Laplacian type + ) + + # Create unwrapped mesh + unwrapped = trimesh.copy() + from trimesh.visual import TextureVisuals + unwrapped.visual = TextureVisuals(uv=uv) + + unwrapped.metadata['uv_unwrap'] = { + 'algorithm': 'libigl_harmonic', + 'boundary_vertices': len(boundary_loop), + 'guarantees_valid_uvs': True + } + + log.info("Output: %d vertices, %d faces", len(unwrapped.vertices), len(unwrapped.faces)) + + info = f"""UV Unwrap Results (libigl Harmonic): + +Algorithm: Harmonic (Laplacian) mapping +Properties: Guarantees valid non-overlapping UVs + +Vertices: {len(trimesh.vertices):,} +Faces: {len(trimesh.faces):,} +Boundary Vertices: {len(boundary_loop):,} + +Requires open mesh with boundary. +Simple, fast, and stable parameterization. +""" + return io.NodeOutput(unwrapped, info, ui={"text": [info]}) + + +NODE_CLASS_MAPPINGS = {"GeomPackUV_LibiglHarmonic": UVLibiglHarmonicNode} +NODE_DISPLAY_NAME_MAPPINGS = {"GeomPackUV_LibiglHarmonic": "UV libigl Harmonic (backend)"} diff --git a/nodes/main/uv/backends/libigl_lscm.py b/nodes/main/uv/backends/libigl_lscm.py new file mode 100644 index 00000000..9ae08cb0 --- /dev/null +++ b/nodes/main/uv/backends/libigl_lscm.py @@ -0,0 +1,94 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2025 ComfyUI-GeometryPack Contributors + +"""libigl LSCM UV unwrapping backend node.""" + +import logging + +import numpy as np +from comfy_api.latest import io + +log = logging.getLogger("geometrypack") + + +class UVLibiglLSCMNode(io.ComfyNode): + """libigl LSCM conformal mapping backend.""" + + @classmethod + def define_schema(cls): + return io.Schema( + node_id="GeomPackUV_LibiglLSCM", + display_name="UV libigl LSCM (backend)", + category="geompack/uv", + is_dev_only=True, + is_output_node=True, + inputs=[ + io.Custom("TRIMESH").Input("trimesh"), + ], + outputs=[ + io.Custom("TRIMESH").Output(display_name="unwrapped_mesh"), + io.String.Output(display_name="info"), + ], + ) + + @classmethod + def execute(cls, trimesh): + """libigl LSCM conformal mapping.""" + try: + import igl + except ImportError: + raise ImportError("libigl not installed (should be in requirements.txt)") + + log.info("Backend: libigl_lscm") + log.info("Input: %d vertices, %d faces", len(trimesh.vertices), len(trimesh.faces)) + + # Fix 2 vertices for unique solution + v_fixed = np.array([0, len(trimesh.vertices)-1], dtype=np.int32) + uv_fixed = np.array([[0.0, 0.0], [1.0, 0.0]], dtype=np.float64) + + # Compute LSCM + uv_result = igl.lscm( + np.asarray(trimesh.vertices, dtype=np.float64), + np.asarray(trimesh.faces, dtype=np.int32), + v_fixed, + uv_fixed + ) + uv = uv_result[0] if isinstance(uv_result, tuple) else uv_result + + # Normalize to [0, 1] + uv_min = uv.min(axis=0) + uv_max = uv.max(axis=0) + uv_range = uv_max - uv_min + uv_range[uv_range < 1e-10] = 1.0 + uv_normalized = (uv - uv_min) / uv_range + + # Create unwrapped mesh (copy) + unwrapped = trimesh.copy() + from trimesh.visual import TextureVisuals + unwrapped.visual = TextureVisuals(uv=uv_normalized) + + unwrapped.metadata['uv_unwrap'] = { + 'algorithm': 'libigl_lscm', + 'conformal': True, + 'angle_preserving': True, + 'fixed_vertices': v_fixed.tolist() + } + + log.info("Output: %d vertices, %d faces", len(unwrapped.vertices), len(unwrapped.faces)) + + info = f"""UV Unwrap Results (libigl LSCM): + +Algorithm: Least Squares Conformal Maps +Properties: Angle-preserving, conformal mapping + +Vertices: {len(trimesh.vertices):,} +Faces: {len(trimesh.faces):,} + +No vertex duplication - preserves original topology. +Minimizes angle distortion for organic shapes. +""" + return io.NodeOutput(unwrapped, info, ui={"text": [info]}) + + +NODE_CLASS_MAPPINGS = {"GeomPackUV_LibiglLSCM": UVLibiglLSCMNode} +NODE_DISPLAY_NAME_MAPPINGS = {"GeomPackUV_LibiglLSCM": "UV libigl LSCM (backend)"} diff --git a/nodes/main/uv/backends/xatlas.py b/nodes/main/uv/backends/xatlas.py new file mode 100644 index 00000000..d64cab3d --- /dev/null +++ b/nodes/main/uv/backends/xatlas.py @@ -0,0 +1,93 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2025 ComfyUI-GeometryPack Contributors + +"""XAtlas UV unwrapping backend node.""" + +import logging + +import numpy as np +import trimesh as trimesh_module +from comfy_api.latest import io + +log = logging.getLogger("geometrypack") + + +class UVXatlasNode(io.ComfyNode): + """XAtlas automatic UV unwrapping backend.""" + + @classmethod + def define_schema(cls): + return io.Schema( + node_id="GeomPackUV_Xatlas", + display_name="UV Xatlas (backend)", + category="geompack/uv", + is_dev_only=True, + is_output_node=True, + inputs=[ + io.Custom("TRIMESH").Input("trimesh"), + ], + outputs=[ + io.Custom("TRIMESH").Output(display_name="unwrapped_mesh"), + io.String.Output(display_name="info"), + ], + ) + + @classmethod + def execute(cls, trimesh): + """XAtlas automatic UV unwrapping.""" + try: + import xatlas + except ImportError: + raise ImportError( + "xatlas not installed. Install with: pip install xatlas\n" + "Required for fast UV unwrapping without Blender." + ) + + log.info("Backend: xatlas") + log.info("Input: %d vertices, %d faces", len(trimesh.vertices), len(trimesh.faces)) + + # Core unwrapping logic + vmapping, indices, uvs = xatlas.parametrize(trimesh.vertices, trimesh.faces) + new_vertices = trimesh.vertices[vmapping] + + unwrapped = trimesh_module.Trimesh( + vertices=new_vertices, + faces=indices, + process=False + ) + + from trimesh.visual import TextureVisuals + unwrapped.visual = TextureVisuals(uv=uvs) + + # Preserve metadata + unwrapped.metadata = trimesh.metadata.copy() + unwrapped.metadata['uv_unwrap'] = { + 'algorithm': 'xatlas', + 'original_vertices': len(trimesh.vertices), + 'unwrapped_vertices': len(new_vertices), + 'vertex_duplication_ratio': len(new_vertices) / len(trimesh.vertices) + } + + log.info("Output: %d vertices, %d faces", len(unwrapped.vertices), len(unwrapped.faces)) + + info = f"""UV Unwrap Results (XAtlas): + +Algorithm: XAtlas automatic unwrapping +Optimized for: Lightmaps and texture atlasing + +Before: + Vertices: {len(trimesh.vertices):,} + Faces: {len(trimesh.faces):,} + +After: + Vertices: {len(new_vertices):,} + Faces: {len(unwrapped.faces):,} + Vertex Duplication: {len(new_vertices)/len(trimesh.vertices):.2f}x + +Fast automatic UV unwrapping with vertex splitting at seams. +""" + return io.NodeOutput(unwrapped, info, ui={"text": [info]}) + + +NODE_CLASS_MAPPINGS = {"GeomPackUV_Xatlas": UVXatlasNode} +NODE_DISPLAY_NAME_MAPPINGS = {"GeomPackUV_Xatlas": "UV Xatlas (backend)"} diff --git a/nodes/main/uv/uv_unwrap.py b/nodes/main/uv/uv_unwrap.py index d22b0877..55f8c392 100644 --- a/nodes/main/uv/uv_unwrap.py +++ b/nodes/main/uv/uv_unwrap.py @@ -2,10 +2,14 @@ # Copyright (C) 2025 ComfyUI-GeometryPack Contributors """ -Unified UV Unwrap Node - Multiple UV unwrapping methods in one node. +Unified UV Unwrap Node - Single frontend with backend selector. + +Uses ComfyUI's node expansion (GraphBuilder) to dispatch to hidden +backend-specific nodes, each running in its own isolation env. Supports: - xatlas: Fast automatic UV unwrapping (vertex splitting) +- cumesh: GPU-accelerated UV unwrapping - libigl_lscm: Least Squares Conformal Maps (angle-preserving) - libigl_harmonic: Harmonic mapping (requires boundary) - libigl_arap: As-Rigid-As-Possible (iterative, high quality) @@ -13,193 +17,34 @@ Supports: - blender_cube: Cube projection (6 faces) - blender_cylinder: Cylindrical projection - blender_sphere: Spherical projection - -Parameters are method-specific; unused ones are ignored. """ import logging - -import numpy as np -import trimesh as trimesh_module from comfy_api.latest import io log = logging.getLogger("geometrypack") -def _extract_uvs_from_blender_mesh(mesh, vertices_np): - """Extract UVs from a blender mesh, handling vertex splitting at seams.""" - uv_layer = mesh.uv_layers.active - if uv_layer: - loop_uvs = np.array([uv_layer.data[i].uv[:] for i in range(len(uv_layer.data))], dtype=np.float32) - new_vertices = [] - new_faces = [] - uvs_per_loop = [] - vertex_map = {} - - for poly in mesh.polygons: - new_face = [] - for loop_idx in poly.loop_indices: - orig_vert_idx = mesh.loops[loop_idx].vertex_index - uv = tuple(loop_uvs[loop_idx]) - key = (orig_vert_idx, uv) - if key not in vertex_map: - vertex_map[key] = len(new_vertices) - new_vertices.append(vertices_np[orig_vert_idx].tolist()) - uvs_per_loop.append(list(uv)) - new_face.append(vertex_map[key]) - new_faces.append(new_face) - - return new_vertices, new_faces, uvs_per_loop - else: - return vertices_np.tolist(), [list(p.vertices) for p in mesh.polygons], [[0.0, 0.0]] * len(vertices_np) - - -def _bpy_smart_uv_project(vertices, faces, angle_limit, island_margin, scale_to_bounds): - """Blender Smart UV Project using bpy.""" - import bpy - import bmesh - - mesh = bpy.data.meshes.new("UVMesh") - obj = bpy.data.objects.new("UVObject", mesh) - bpy.context.collection.objects.link(obj) - bpy.context.view_layer.objects.active = obj - obj.select_set(True) - - mesh.from_pydata(vertices.tolist(), [], faces.tolist()) - mesh.update() - - bpy.ops.object.mode_set(mode='EDIT') - bm = bmesh.from_edit_mesh(mesh) - for face in bm.faces: - face.select = True - bmesh.update_edit_mesh(mesh) - - bpy.ops.uv.smart_project( - angle_limit=angle_limit, - island_margin=island_margin, - area_weight=0.0, - correct_aspect=True, - scale_to_bounds=scale_to_bounds - ) - - bpy.ops.object.mode_set(mode='OBJECT') - - result_vertices = np.array([v.co[:] for v in mesh.vertices], dtype=np.float32) - result_verts, result_faces, result_uvs = _extract_uvs_from_blender_mesh(mesh, result_vertices) - - bpy.data.objects.remove(obj, do_unlink=True) - bpy.data.meshes.remove(mesh) - - return {'vertices': result_verts, 'faces': result_faces, 'uvs': result_uvs} - - -def _bpy_cube_uv_project(vertices, faces, cube_size, scale_to_bounds): - """Blender Cube UV Project using bpy.""" - import bpy - import bmesh - - mesh = bpy.data.meshes.new("UVMesh") - obj = bpy.data.objects.new("UVObject", mesh) - bpy.context.collection.objects.link(obj) - bpy.context.view_layer.objects.active = obj - obj.select_set(True) - - mesh.from_pydata(vertices.tolist(), [], faces.tolist()) - mesh.update() - - bpy.ops.object.mode_set(mode='EDIT') - bm = bmesh.from_edit_mesh(mesh) - for face in bm.faces: - face.select = True - bmesh.update_edit_mesh(mesh) - - bpy.ops.uv.cube_project(cube_size=cube_size, scale_to_bounds=scale_to_bounds) - - bpy.ops.object.mode_set(mode='OBJECT') - - result_vertices = np.array([v.co[:] for v in mesh.vertices], dtype=np.float32) - result_verts, result_faces, result_uvs = _extract_uvs_from_blender_mesh(mesh, result_vertices) - - bpy.data.objects.remove(obj, do_unlink=True) - bpy.data.meshes.remove(mesh) - - return {'vertices': result_verts, 'faces': result_faces, 'uvs': result_uvs} - - -def _bpy_cylinder_uv_project(vertices, faces, radius, scale_to_bounds): - """Blender Cylinder UV Project using bpy.""" - import bpy - import bmesh - - mesh = bpy.data.meshes.new("UVMesh") - obj = bpy.data.objects.new("UVObject", mesh) - bpy.context.collection.objects.link(obj) - bpy.context.view_layer.objects.active = obj - obj.select_set(True) - - mesh.from_pydata(vertices.tolist(), [], faces.tolist()) - mesh.update() - - bpy.ops.object.mode_set(mode='EDIT') - bm = bmesh.from_edit_mesh(mesh) - for face in bm.faces: - face.select = True - bmesh.update_edit_mesh(mesh) - - bpy.ops.uv.cylinder_project(radius=radius, scale_to_bounds=scale_to_bounds) - - bpy.ops.object.mode_set(mode='OBJECT') - - result_vertices = np.array([v.co[:] for v in mesh.vertices], dtype=np.float32) - result_verts, result_faces, result_uvs = _extract_uvs_from_blender_mesh(mesh, result_vertices) - - bpy.data.objects.remove(obj, do_unlink=True) - bpy.data.meshes.remove(mesh) - - return {'vertices': result_verts, 'faces': result_faces, 'uvs': result_uvs} - - -def _bpy_sphere_uv_project(vertices, faces, scale_to_bounds): - """Blender Sphere UV Project using bpy.""" - import bpy - import bmesh - - mesh = bpy.data.meshes.new("UVMesh") - obj = bpy.data.objects.new("UVObject", mesh) - bpy.context.collection.objects.link(obj) - bpy.context.view_layer.objects.active = obj - obj.select_set(True) - - mesh.from_pydata(vertices.tolist(), [], faces.tolist()) - mesh.update() - - bpy.ops.object.mode_set(mode='EDIT') - bm = bmesh.from_edit_mesh(mesh) - for face in bm.faces: - face.select = True - bmesh.update_edit_mesh(mesh) - - bpy.ops.uv.sphere_project(scale_to_bounds=scale_to_bounds) - - bpy.ops.object.mode_set(mode='OBJECT') - - result_vertices = np.array([v.co[:] for v in mesh.vertices], dtype=np.float32) - result_verts, result_faces, result_uvs = _extract_uvs_from_blender_mesh(mesh, result_vertices) - - bpy.data.objects.remove(obj, do_unlink=True) - bpy.data.meshes.remove(mesh) - - return {'vertices': result_verts, 'faces': result_faces, 'uvs': result_uvs} - - class UVUnwrapNode(io.ComfyNode): """ Universal UV Unwrap - Unified UV unwrapping operations. - Consolidates multiple UV unwrapping backends into a single node. - Parameters are method-specific; unused parameters are ignored. + Dispatches to hidden backend nodes via node expansion. + Backends span multiple isolation envs (main, blender, gpu). """ + # Map DynamicCombo option key -> hidden backend node_id + BACKEND_MAP = { + "xatlas": "GeomPackUV_Xatlas", + "cumesh": "GeomPackUV_CuMesh", + "libigl_lscm": "GeomPackUV_LibiglLSCM", + "libigl_harmonic": "GeomPackUV_LibiglHarmonic", + "libigl_arap": "GeomPackUV_LibiglARAP", + "blender_smart": "GeomPackUV_BlenderSmart", + "blender_cube": "GeomPackUV_BlenderCube", + "blender_cylinder": "GeomPackUV_BlenderCylinder", + "blender_sphere": "GeomPackUV_BlenderSphere", + } @classmethod def define_schema(cls): @@ -207,6 +52,7 @@ class UVUnwrapNode(io.ComfyNode): node_id="GeomPackUVUnwrap", display_name="UV Unwrap", category="geompack/uv", + enable_expand=True, is_output_node=True, inputs=[ io.Custom("TRIMESH").Input("trimesh"), @@ -249,583 +95,32 @@ class UVUnwrapNode(io.ComfyNode): @classmethod def execute(cls, trimesh, method): - """Apply UV unwrapping based on selected method.""" + from comfy_execution.graph_utils import GraphBuilder + + # Ensure SCHEMA is initialized (worker subprocess doesn't call GET_SCHEMA) + if cls.SCHEMA is None: + cls.GET_SCHEMA() + selected = method["method"] + node_id = cls.BACKEND_MAP[selected] - initial_vertices = len(trimesh.vertices) - initial_faces = len(trimesh.faces) + log.info("UV Unwrap dispatch: %s -> %s", selected, node_id) - log.info("Input: %d vertices, %d faces", initial_vertices, initial_faces) - log.info("Method: %s", selected) + # Build kwargs for the backend node: mesh + method-specific params + kwargs = {"trimesh": trimesh} + for k, v in method.items(): + if k == "method": + continue + kwargs[k] = v - if selected == "xatlas": - unwrapped_mesh, info = cls._xatlas(trimesh) - elif selected == "cumesh": - unwrapped_mesh, info = cls._cumesh( - trimesh, - method.get("chart_cone_angle", 90.0), - method.get("chart_refine_iterations", 0), - method.get("chart_global_iterations", 1), - method.get("chart_smooth_strength", 1), - ) - elif selected == "libigl_lscm": - unwrapped_mesh, info = cls._libigl_lscm(trimesh) - elif selected == "libigl_harmonic": - unwrapped_mesh, info = cls._libigl_harmonic(trimesh) - elif selected == "libigl_arap": - unwrapped_mesh, info = cls._libigl_arap(trimesh, method.get("iterations", 10)) - elif selected == "blender_smart": - unwrapped_mesh, info = cls._blender_smart( - trimesh, - method.get("angle_limit", 66.0), - method.get("island_margin", 0.02), - method.get("scale_to_bounds", "true"), - ) - elif selected == "blender_cube": - unwrapped_mesh, info = cls._blender_cube( - trimesh, - method.get("cube_size", 1.0), - method.get("scale_to_bounds", "true"), - ) - elif selected == "blender_cylinder": - unwrapped_mesh, info = cls._blender_cylinder( - trimesh, - method.get("cylinder_radius", 1.0), - method.get("scale_to_bounds", "true"), - ) - elif selected == "blender_sphere": - unwrapped_mesh, info = cls._blender_sphere( - trimesh, - method.get("scale_to_bounds", "true"), - ) - else: - raise ValueError(f"Unknown method: {selected}") + graph = GraphBuilder() + backend_node = graph.node(node_id, **kwargs) - log.info("Output: %d vertices, %d faces", len(unwrapped_mesh.vertices), len(unwrapped_mesh.faces)) - - return io.NodeOutput(unwrapped_mesh, info, ui={"text": [info]}) - - @staticmethod - def _xatlas(trimesh): - """XAtlas automatic UV unwrapping.""" - try: - import xatlas - except ImportError: - raise ImportError( - "xatlas not installed. Install with: pip install xatlas\n" - "Required for fast UV unwrapping without Blender." - ) - - # Core unwrapping logic - vmapping, indices, uvs = xatlas.parametrize(trimesh.vertices, trimesh.faces) - new_vertices = trimesh.vertices[vmapping] - - unwrapped = trimesh_module.Trimesh( - vertices=new_vertices, - faces=indices, - process=False - ) - - from trimesh.visual import TextureVisuals - unwrapped.visual = TextureVisuals(uv=uvs) - - # Preserve metadata - unwrapped.metadata = trimesh.metadata.copy() - unwrapped.metadata['uv_unwrap'] = { - 'algorithm': 'xatlas', - 'original_vertices': len(trimesh.vertices), - 'unwrapped_vertices': len(new_vertices), - 'vertex_duplication_ratio': len(new_vertices) / len(trimesh.vertices) + return { + "result": (backend_node.out(0), backend_node.out(1)), + "expand": graph.finalize(), } - info = f"""UV Unwrap Results (XAtlas): - -Algorithm: XAtlas automatic unwrapping -Optimized for: Lightmaps and texture atlasing - -Before: - Vertices: {len(trimesh.vertices):,} - Faces: {len(trimesh.faces):,} - -After: - Vertices: {len(new_vertices):,} - Faces: {len(unwrapped.faces):,} - Vertex Duplication: {len(new_vertices)/len(trimesh.vertices):.2f}x - -Fast automatic UV unwrapping with vertex splitting at seams. -""" - return unwrapped, info - - @staticmethod - def _cumesh(trimesh, chart_cone_angle, chart_refine_iterations, - chart_global_iterations, chart_smooth_strength): - """CuMesh GPU-accelerated UV unwrapping with fast clustering + xatlas.""" - try: - import torch - import cumesh as CuMesh - except ImportError as e: - raise ImportError( - f"cumesh not installed or CUDA not available: {e}\n" - "CuMesh requires CUDA and PyTorch. Install with:\n" - " pip install cumesh (or run install.py)\n" - "Alternatively, use 'xatlas' method which works on CPU." - ) - - import comfy.model_management - device = comfy.model_management.get_torch_device() - assert device.type == "cuda", f"CuMesh requires CUDA but got device '{device}' — cumesh is GPU-only, no CPU fallback" - - log.info("CuMesh: chart_cone_angle=%s, refine_iter=%s, global_iter=%s, smooth=%s", - chart_cone_angle, chart_refine_iterations, chart_global_iterations, chart_smooth_strength) - - # Convert to torch tensors on GPU - vertices = torch.tensor(trimesh.vertices, dtype=torch.float32).to(device) - faces = torch.tensor(trimesh.faces, dtype=torch.int32).to(device) - - # Convert cone angle to radians - chart_cone_angle_rad = np.radians(chart_cone_angle) - - # Initialize CuMesh - cumesh = CuMesh.CuMesh() - cumesh.init(vertices, faces) - - # UV Unwrap with two-stage process (fast clustering + xatlas) - log.info("Running CuMesh UV unwrap...") - out_vertices, out_faces, out_uvs, out_vmaps = cumesh.uv_unwrap( - compute_charts_kwargs={ - "threshold_cone_half_angle_rad": chart_cone_angle_rad, - "refine_iterations": chart_refine_iterations, - "global_iterations": chart_global_iterations, - "smooth_strength": chart_smooth_strength, - }, - return_vmaps=True, - verbose=True, - ) - - # Convert back to numpy - out_vertices_np = out_vertices.cpu().numpy() - out_faces_np = out_faces.cpu().numpy() - out_uvs_np = out_uvs.cpu().numpy() - - # Flip V coordinate (cumesh uses different UV convention) - out_uvs_np[:, 1] = 1 - out_uvs_np[:, 1] - - # Build result trimesh - unwrapped = trimesh_module.Trimesh( - vertices=out_vertices_np, - faces=out_faces_np, - process=False - ) - - from trimesh.visual import TextureVisuals - unwrapped.visual = TextureVisuals(uv=out_uvs_np) - - # Preserve metadata - unwrapped.metadata = trimesh.metadata.copy() - unwrapped.metadata['uv_unwrap'] = { - 'algorithm': 'cumesh', - 'chart_cone_angle': chart_cone_angle, - 'chart_refine_iterations': chart_refine_iterations, - 'chart_global_iterations': chart_global_iterations, - 'chart_smooth_strength': chart_smooth_strength, - 'original_vertices': len(trimesh.vertices), - 'unwrapped_vertices': len(out_vertices_np), - 'vertex_duplication_ratio': len(out_vertices_np) / len(trimesh.vertices) - } - - # Clean up GPU memory - import comfy.model_management - comfy.model_management.soft_empty_cache() - - info = f"""UV Unwrap Results (CuMesh): - -Algorithm: CuMesh GPU-accelerated (fast clustering + xatlas) -Optimized for: Large meshes, GPU acceleration - -Parameters: - Chart Cone Angle: {chart_cone_angle}° - Refine Iterations: {chart_refine_iterations} - Global Iterations: {chart_global_iterations} - Smooth Strength: {chart_smooth_strength} - -Before: - Vertices: {len(trimesh.vertices):,} - Faces: {len(trimesh.faces):,} - -After: - Vertices: {len(out_vertices_np):,} - Faces: {len(unwrapped.faces):,} - Vertex Duplication: {len(out_vertices_np)/len(trimesh.vertices):.2f}x - -Two-stage GPU-accelerated UV unwrapping with vertex splitting at seams. -""" - return unwrapped, info - - @staticmethod - def _libigl_lscm(trimesh): - """libigl LSCM conformal mapping.""" - try: - import igl - except ImportError: - raise ImportError("libigl not installed (should be in requirements.txt)") - - # Fix 2 vertices for unique solution - v_fixed = np.array([0, len(trimesh.vertices)-1], dtype=np.int32) - uv_fixed = np.array([[0.0, 0.0], [1.0, 0.0]], dtype=np.float64) - - # Compute LSCM - uv_result = igl.lscm( - np.asarray(trimesh.vertices, dtype=np.float64), - np.asarray(trimesh.faces, dtype=np.int32), - v_fixed, - uv_fixed - ) - uv = uv_result[0] if isinstance(uv_result, tuple) else uv_result - - # Normalize to [0, 1] - uv_min = uv.min(axis=0) - uv_max = uv.max(axis=0) - uv_range = uv_max - uv_min - uv_range[uv_range < 1e-10] = 1.0 - uv_normalized = (uv - uv_min) / uv_range - - # Create unwrapped mesh (copy) - unwrapped = trimesh.copy() - from trimesh.visual import TextureVisuals - unwrapped.visual = TextureVisuals(uv=uv_normalized) - - unwrapped.metadata['uv_unwrap'] = { - 'algorithm': 'libigl_lscm', - 'conformal': True, - 'angle_preserving': True, - 'fixed_vertices': v_fixed.tolist() - } - - info = f"""UV Unwrap Results (libigl LSCM): - -Algorithm: Least Squares Conformal Maps -Properties: Angle-preserving, conformal mapping - -Vertices: {len(trimesh.vertices):,} -Faces: {len(trimesh.faces):,} - -No vertex duplication - preserves original topology. -Minimizes angle distortion for organic shapes. -""" - return unwrapped, info - - @staticmethod - def _libigl_harmonic(trimesh): - """libigl harmonic (Laplacian) mapping.""" - try: - import igl - except ImportError: - raise ImportError("libigl not installed (should be in requirements.txt)") - - # Find boundary loop - boundary_loop = igl.boundary_loop(np.asarray(trimesh.faces, dtype=np.int32)) - - if len(boundary_loop) == 0: - raise ValueError( - "Mesh has no boundary - harmonic parameterization requires an open mesh. " - "Try using xatlas or libigl_lscm for closed meshes." - ) - - # Map boundary to circle - bnd_angles = np.linspace(0, 2 * np.pi, len(boundary_loop), endpoint=False) - bnd_uv = np.column_stack([ - 0.5 + 0.5 * np.cos(bnd_angles), - 0.5 + 0.5 * np.sin(bnd_angles) - ]) - - # Compute harmonic parameterization - uv = igl.harmonic( - np.asarray(trimesh.vertices, dtype=np.float64), - np.asarray(trimesh.faces, dtype=np.int32), - boundary_loop.astype(np.int32), - bnd_uv.astype(np.float64), - 1 # Laplacian type - ) - - # Create unwrapped mesh - unwrapped = trimesh.copy() - from trimesh.visual import TextureVisuals - unwrapped.visual = TextureVisuals(uv=uv) - - unwrapped.metadata['uv_unwrap'] = { - 'algorithm': 'libigl_harmonic', - 'boundary_vertices': len(boundary_loop), - 'guarantees_valid_uvs': True - } - - info = f"""UV Unwrap Results (libigl Harmonic): - -Algorithm: Harmonic (Laplacian) mapping -Properties: Guarantees valid non-overlapping UVs - -Vertices: {len(trimesh.vertices):,} -Faces: {len(trimesh.faces):,} -Boundary Vertices: {len(boundary_loop):,} - -Requires open mesh with boundary. -Simple, fast, and stable parameterization. -""" - return unwrapped, info - - @staticmethod - def _libigl_arap(trimesh, iterations): - """libigl ARAP-like parameterization.""" - try: - import igl - import scipy.sparse - except ImportError: - raise ImportError("libigl and scipy not installed") - - # Find boundary - boundary_loop = igl.boundary_loop(np.asarray(trimesh.faces, dtype=np.int32)) - - if len(boundary_loop) == 0: - raise ValueError( - "Mesh has no boundary - ARAP parameterization requires an open mesh. " - "Try using xatlas or libigl_lscm for closed meshes." - ) - - # Map boundary to circle - bnd_angles = np.linspace(0, 2 * np.pi, len(boundary_loop), endpoint=False) - bnd_uv = np.column_stack([ - 0.5 + 0.5 * np.cos(bnd_angles), - 0.5 + 0.5 * np.sin(bnd_angles) - ]) - - # Initial harmonic solution - uv_init = igl.harmonic( - np.asarray(trimesh.vertices, dtype=np.float64), - np.asarray(trimesh.faces, dtype=np.int32), - boundary_loop.astype(np.int32), - bnd_uv.astype(np.float64), - 1 - ) - - # Apply iterative biharmonic refinement (ARAP-like) - uv = uv_init.copy() - for i in range(iterations): - uv = igl.harmonic( - np.asarray(trimesh.vertices, dtype=np.float64), - np.asarray(trimesh.faces, dtype=np.int32), - boundary_loop.astype(np.int32), - bnd_uv.astype(np.float64), - 2 # biharmonic for smoother result - ) - - # Normalize to [0, 1] - uv_min = uv.min(axis=0) - uv_max = uv.max(axis=0) - uv_range = uv_max - uv_min - uv_range[uv_range < 1e-10] = 1.0 - uv = (uv - uv_min) / uv_range - - # Create unwrapped mesh - unwrapped = trimesh.copy() - from trimesh.visual import TextureVisuals - unwrapped.visual = TextureVisuals(uv=uv) - - unwrapped.metadata['uv_unwrap'] = { - 'algorithm': 'libigl_arap_like', - 'iterations': iterations, - 'minimizes_distortion': True - } - - info = f"""UV Unwrap Results (libigl ARAP): - -Algorithm: As-Rigid-As-Possible (biharmonic approximation) -Properties: Minimizes distortion, preserves shape -Iterations: {iterations} - -Vertices: {len(trimesh.vertices):,} -Faces: {len(trimesh.faces):,} -Boundary Vertices: {len(boundary_loop):,} - -Iterative solver for higher quality results. -Better preservation of angles and shapes. -""" - return unwrapped, info - - @staticmethod - def _blender_smart(trimesh, angle_limit, island_margin, scale_to_bounds): - """Blender Smart UV Project using bpy.""" - import math - - angle_limit_rad = math.radians(angle_limit) - - log.info("Running Blender Smart UV Project...") - result = _bpy_smart_uv_project( - vertices=np.asarray(trimesh.vertices, dtype=np.float32), - faces=np.asarray(trimesh.faces, dtype=np.int32), - angle_limit=angle_limit_rad, - island_margin=island_margin, - scale_to_bounds=(scale_to_bounds == 'true') - ) - - unwrapped = trimesh_module.Trimesh( - vertices=np.array(result['vertices'], dtype=np.float32), - faces=np.array(result['faces'], dtype=np.int32), - process=False - ) - - from trimesh.visual import TextureVisuals - unwrapped.visual = TextureVisuals(uv=np.array(result['uvs'], dtype=np.float32)) - - # Preserve metadata - unwrapped.metadata = trimesh.metadata.copy() - unwrapped.metadata['uv_unwrap'] = { - 'algorithm': 'blender_smart_uv_project', - 'angle_limit': angle_limit, - 'island_margin': island_margin, - 'scale_to_bounds': scale_to_bounds == 'true' - } - - info = f"""UV Unwrap Results (Blender Smart UV): - -Method: Smart UV Project (bpy) -Angle Limit: {angle_limit}deg -Island Margin: {island_margin} -Scale to Bounds: {scale_to_bounds} - -Before: - Vertices: {len(trimesh.vertices):,} - Faces: {len(trimesh.faces):,} - -After: - Vertices: {len(unwrapped.vertices):,} - Faces: {len(unwrapped.faces):,} - -Automatic seam-based unwrapping with intelligent island creation. -""" - return unwrapped, info - - @staticmethod - def _blender_cube(trimesh, cube_size, scale_to_bounds): - """Blender Cube Projection using bpy.""" - log.info("Running Blender Cube Projection...") - result = _bpy_cube_uv_project( - vertices=np.asarray(trimesh.vertices, dtype=np.float32), - faces=np.asarray(trimesh.faces, dtype=np.int32), - cube_size=cube_size, - scale_to_bounds=(scale_to_bounds == 'true') - ) - - unwrapped = trimesh_module.Trimesh( - vertices=np.array(result['vertices'], dtype=np.float32), - faces=np.array(result['faces'], dtype=np.int32), - process=False - ) - - from trimesh.visual import TextureVisuals - unwrapped.visual = TextureVisuals(uv=np.array(result['uvs'], dtype=np.float32)) - - # Preserve metadata - unwrapped.metadata = trimesh.metadata.copy() - unwrapped.metadata['uv_unwrap'] = { - 'algorithm': 'blender_cube_projection', - 'cube_size': cube_size, - 'scale_to_bounds': scale_to_bounds == 'true' - } - - info = f"""UV Unwrap Results (Blender Cube): - -Method: Cube Projection (bpy) -Cube Size: {cube_size} -Scale to Bounds: {scale_to_bounds} - -Vertices: {len(unwrapped.vertices):,} -Faces: {len(unwrapped.faces):,} - -Projects mesh onto 6 cube faces. -Best for box-like objects. -""" - return unwrapped, info - - @staticmethod - def _blender_cylinder(trimesh, cylinder_radius, scale_to_bounds): - """Blender Cylinder Projection using bpy.""" - log.info("Running Blender Cylinder Projection...") - result = _bpy_cylinder_uv_project( - vertices=np.asarray(trimesh.vertices, dtype=np.float32), - faces=np.asarray(trimesh.faces, dtype=np.int32), - radius=cylinder_radius, - scale_to_bounds=(scale_to_bounds == 'true') - ) - - unwrapped = trimesh_module.Trimesh( - vertices=np.array(result['vertices'], dtype=np.float32), - faces=np.array(result['faces'], dtype=np.int32), - process=False - ) - - from trimesh.visual import TextureVisuals - unwrapped.visual = TextureVisuals(uv=np.array(result['uvs'], dtype=np.float32)) - - # Preserve metadata - unwrapped.metadata = trimesh.metadata.copy() - unwrapped.metadata['uv_unwrap'] = { - 'algorithm': 'blender_cylinder_projection', - 'cylinder_radius': cylinder_radius, - 'scale_to_bounds': scale_to_bounds == 'true' - } - - info = f"""UV Unwrap Results (Blender Cylinder): - -Method: Cylinder Projection (bpy) -Cylinder Radius: {cylinder_radius} -Scale to Bounds: {scale_to_bounds} - -Vertices: {len(unwrapped.vertices):,} -Faces: {len(unwrapped.faces):,} - -Cylindrical projection around vertical axis. -Best for cylindrical objects. -""" - return unwrapped, info - - @staticmethod - def _blender_sphere(trimesh, scale_to_bounds): - """Blender Sphere Projection using bpy.""" - log.info("Running Blender Sphere Projection...") - result = _bpy_sphere_uv_project( - vertices=np.asarray(trimesh.vertices, dtype=np.float32), - faces=np.asarray(trimesh.faces, dtype=np.int32), - scale_to_bounds=(scale_to_bounds == 'true') - ) - - unwrapped = trimesh_module.Trimesh( - vertices=np.array(result['vertices'], dtype=np.float32), - faces=np.array(result['faces'], dtype=np.int32), - process=False - ) - - from trimesh.visual import TextureVisuals - unwrapped.visual = TextureVisuals(uv=np.array(result['uvs'], dtype=np.float32)) - - # Preserve metadata - unwrapped.metadata = trimesh.metadata.copy() - unwrapped.metadata['uv_unwrap'] = { - 'algorithm': 'blender_sphere_projection', - 'scale_to_bounds': scale_to_bounds == 'true' - } - - info = f"""UV Unwrap Results (Blender Sphere): - -Method: Sphere Projection (bpy) -Scale to Bounds: {scale_to_bounds} - -Vertices: {len(unwrapped.vertices):,} -Faces: {len(unwrapped.faces):,} - -Spherical/equirectangular projection. -Best for spherical objects. -""" - return unwrapped, info - NODE_CLASS_MAPPINGS = { "GeomPackUVUnwrap": UVUnwrapNode, diff --git a/workflows/decimate_all.json b/workflows/decimate_all.json new file mode 100644 index 00000000..634b1ad9 --- /dev/null +++ b/workflows/decimate_all.json @@ -0,0 +1 @@ +{"id":"a1b2c3d4-e5f6-7890-abcd-decimate00001","revision":0,"last_node_id":8,"last_link_id":13,"nodes":[{"id":1,"type":"GeomPackLoadMesh","pos":[-834.8477636718761,-155.28021972656245],"size":[270,78],"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,2,3,5,6,13]},{"localized_name":"texture","name":"texture","type":"IMAGE","links":null}],"properties":{"cnr_id":"comfyui-geometrypack","ver":"6e8088fb3283086d421a58c3fdf5b061176519aa","Node name for S&R":"GeomPackLoadMesh"},"widgets_values":["3d/Stanford_Bunny.stl"]},{"id":8,"type":"GeomPackPreviewMeshMulti","pos":[726.3024951171867,-194.2822802734373],"size":[768,702],"flags":{},"order":6,"mode":0,"inputs":[{"localized_name":"mesh_1","name":"mesh_1","type":"TRIMESH","link":13},{"localized_name":"mesh_2","name":"mesh_2","shape":7,"type":"TRIMESH","link":11},{"localized_name":"mesh_3","name":"mesh_3","shape":7,"type":"TRIMESH","link":12},{"localized_name":"mesh_4","name":"mesh_4","shape":7,"type":"TRIMESH","link":null},{"localized_name":"mode","name":"mode","shape":7,"type":"COMBO","widget":{"name":"mode"},"link":null}],"outputs":[],"properties":{"cnr_id":"comfyui-geometrypack","ver":"6e8088fb3283086d421a58c3fdf5b061176519aa","Node name for S&R":"GeomPackPreviewMeshMulti"},"widgets_values":["fields","{\"show_edges\":true,\"camera_state\":\"{\\\"position\\\":[215.82045526389112,66.18171649191358,142.99319876297514],\\\"focalPoint\\\":[30.319300651550293,1.8948078155517578,59.22008657455444],\\\"viewUp\\\":[-0.28673184554851583,-0.3398393006551081,0.8957087129634218]}\",\"selected_field\":\"\"}"]},{"id":2,"type":"GeomPackDecimateMesh","pos":[-850.15067871094,-10.46611328125014],"size":[370,222],"flags":{},"order":1,"mode":0,"inputs":[{"localized_name":"trimesh","name":"trimesh","type":"TRIMESH","link":1},{"localized_name":"backend","name":"backend","type":"COMFY_DYNAMICCOMBO_V3","widget":{"name":"backend"},"link":null},{"localized_name":"backend.target_face_count","name":"backend.target_face_count","type":"INT","widget":{"name":"backend.target_face_count"},"link":null},{"localized_name":"backend.quality_threshold","name":"backend.quality_threshold","type":"FLOAT","widget":{"name":"backend.quality_threshold"},"link":null},{"localized_name":"backend.preserve_boundary","name":"backend.preserve_boundary","type":"COMBO","widget":{"name":"backend.preserve_boundary"},"link":null},{"localized_name":"backend.preserve_normal","name":"backend.preserve_normal","type":"COMBO","widget":{"name":"backend.preserve_normal"},"link":null},{"localized_name":"backend.preserve_topology","name":"backend.preserve_topology","type":"COMBO","widget":{"name":"backend.preserve_topology"},"link":null},{"localized_name":"backend.planar_quadric","name":"backend.planar_quadric","type":"COMBO","widget":{"name":"backend.planar_quadric"},"link":null}],"outputs":[{"localized_name":"decimated_mesh","name":"decimated_mesh","type":"TRIMESH","links":[7]},{"localized_name":"info","name":"info","type":"STRING","links":null}],"properties":{"cnr_id":"comfyui-geometrypack","ver":"6e8088fb3283086d421a58c3fdf5b061176519aa","Node name for S&R":"GeomPackDecimateMesh"},"widgets_values":["quadric_edge_collapse",5000,0.3,"true","true","true","false"]},{"id":3,"type":"GeomPackDecimateMesh","pos":[-846.0558691406263,267.5156445312493],"size":[370,126],"flags":{},"order":2,"mode":0,"inputs":[{"localized_name":"trimesh","name":"trimesh","type":"TRIMESH","link":2},{"localized_name":"backend","name":"backend","type":"COMFY_DYNAMICCOMBO_V3","widget":{"name":"backend"},"link":null},{"localized_name":"backend.target_reduction","name":"backend.target_reduction","type":"FLOAT","widget":{"name":"backend.target_reduction"},"link":null},{"localized_name":"backend.aggressiveness","name":"backend.aggressiveness","type":"INT","widget":{"name":"backend.aggressiveness"},"link":null}],"outputs":[{"localized_name":"decimated_mesh","name":"decimated_mesh","type":"TRIMESH","links":[8]},{"localized_name":"info","name":"info","type":"STRING","links":null}],"properties":{"cnr_id":"comfyui-geometrypack","ver":"6e8088fb3283086d421a58c3fdf5b061176519aa","Node name for S&R":"GeomPackDecimateMesh"},"widgets_values":["fast_simplification",0.8,7]},{"id":7,"type":"GeomPackPreviewMeshMulti","pos":[-459.3555810546881,-193.33342285156226],"size":[768,702],"flags":{},"order":5,"mode":0,"inputs":[{"localized_name":"mesh_1","name":"mesh_1","type":"TRIMESH","link":6},{"localized_name":"mesh_2","name":"mesh_2","shape":7,"type":"TRIMESH","link":7},{"localized_name":"mesh_3","name":"mesh_3","shape":7,"type":"TRIMESH","link":8},{"localized_name":"mesh_4","name":"mesh_4","shape":7,"type":"TRIMESH","link":null},{"localized_name":"mode","name":"mode","shape":7,"type":"COMBO","widget":{"name":"mode"},"link":null}],"outputs":[],"properties":{"cnr_id":"comfyui-geometrypack","ver":"6e8088fb3283086d421a58c3fdf5b061176519aa","Node name for S&R":"GeomPackPreviewMeshMulti"},"widgets_values":["fields","{\"show_edges\":true,\"camera_state\":\"{\\\"position\\\":[215.82045526389112,66.18171649191358,142.99319876297514],\\\"focalPoint\\\":[30.319300651550293,1.8948078155517578,59.22008657455444],\\\"viewUp\\\":[-0.28673184554851583,-0.3398393006551081,0.8957087129634218]}\",\"selected_field\":\"\"}"]},{"id":4,"type":"GeomPackDecimateMesh","pos":[336.95061523437454,93.11665039062464],"size":[370,102],"flags":{},"order":3,"mode":0,"inputs":[{"localized_name":"trimesh","name":"trimesh","type":"TRIMESH","link":3},{"localized_name":"backend","name":"backend","type":"COMFY_DYNAMICCOMBO_V3","widget":{"name":"backend"},"link":null},{"localized_name":"backend.cluster_threshold","name":"backend.cluster_threshold","type":"FLOAT","widget":{"name":"backend.cluster_threshold"},"link":null}],"outputs":[{"localized_name":"decimated_mesh","name":"decimated_mesh","type":"TRIMESH","links":[12]},{"localized_name":"info","name":"info","type":"STRING","links":null}],"properties":{"cnr_id":"comfyui-geometrypack","ver":"6e8088fb3283086d421a58c3fdf5b061176519aa","Node name for S&R":"GeomPackDecimateMesh"},"widgets_values":["vertex_clustering",3]},{"id":6,"type":"GeomPackDecimateMesh","pos":[331.6426025390617,-110.89962402343754],"size":[370,150],"flags":{},"order":4,"mode":0,"inputs":[{"localized_name":"trimesh","name":"trimesh","type":"TRIMESH","link":5},{"localized_name":"backend","name":"backend","type":"COMFY_DYNAMICCOMBO_V3","widget":{"name":"backend"},"link":null},{"localized_name":"backend.target_reduction","name":"backend.target_reduction","type":"FLOAT","widget":{"name":"backend.target_reduction"},"link":null},{"localized_name":"backend.feature_angle","name":"backend.feature_angle","type":"FLOAT","widget":{"name":"backend.feature_angle"},"link":null},{"localized_name":"backend.preserve_topology","name":"backend.preserve_topology","type":"COMBO","widget":{"name":"backend.preserve_topology"},"link":null}],"outputs":[{"localized_name":"decimated_mesh","name":"decimated_mesh","type":"TRIMESH","links":[11]},{"localized_name":"info","name":"info","type":"STRING","links":null}],"properties":{"cnr_id":"comfyui-geometrypack","ver":"6e8088fb3283086d421a58c3fdf5b061176519aa","Node name for S&R":"GeomPackDecimateMesh"},"widgets_values":["decimate_pro",0.8,15,"true"]}],"links":[[1,1,0,2,0,"TRIMESH"],[2,1,0,3,0,"TRIMESH"],[3,1,0,4,0,"TRIMESH"],[5,1,0,6,0,"TRIMESH"],[6,1,0,7,0,"TRIMESH"],[7,2,0,7,1,"TRIMESH"],[8,3,0,7,2,"TRIMESH"],[11,6,0,8,1,"TRIMESH"],[12,4,0,8,2,"TRIMESH"],[13,1,0,8,0,"TRIMESH"]],"groups":[],"config":{},"extra":{"ds":{"scale":0.8000000000000012,"offset":[533.8795750656052,458.7658745986985]},"frontendVersion":"1.30.6","workflowRendererVersion":"LG"},"version":0.4} \ No newline at end of file diff --git a/workflows/fill_holes_all.json b/workflows/fill_holes_all.json new file mode 100644 index 00000000..a38fbb1c --- /dev/null +++ b/workflows/fill_holes_all.json @@ -0,0 +1 @@ +{"id":"a1b2c3d4-e5f6-7890-abcd-fillholes0001","revision":0,"last_node_id":9,"last_link_id":13,"nodes":[{"id":2,"type":"GeomPackFillHoles","pos":[-400,-600],"size":[370,110],"flags":{},"order":1,"mode":0,"inputs":[{"localized_name":"mesh","name":"mesh","type":"TRIMESH","link":1},{"localized_name":"method","name":"method","type":"COMFY_DYNAMICCOMBO_V3","widget":{"name":"method"},"link":null}],"outputs":[{"localized_name":"filled_mesh","name":"filled_mesh","type":"TRIMESH","links":[8]},{"localized_name":"info","name":"info","type":"STRING","links":null}],"properties":{"cnr_id":"comfyui-geometrypack","ver":"6e8088fb3283086d421a58c3fdf5b061176519aa","Node name for S&R":"GeomPackFillHoles"},"widgets_values":["trimesh","false"]},{"id":3,"type":"GeomPackFillHoles","pos":[0,-600],"size":[370,134],"flags":{},"order":2,"mode":0,"inputs":[{"localized_name":"mesh","name":"mesh","type":"TRIMESH","link":2},{"localized_name":"method","name":"method","type":"COMFY_DYNAMICCOMBO_V3","widget":{"name":"method"},"link":null},{"localized_name":"method.maxholesize","name":"method.maxholesize","type":"INT","widget":{"name":"method.maxholesize"},"link":null}],"outputs":[{"localized_name":"filled_mesh","name":"filled_mesh","type":"TRIMESH","links":[9]},{"localized_name":"info","name":"info","type":"STRING","links":null}],"properties":{"cnr_id":"comfyui-geometrypack","ver":"6e8088fb3283086d421a58c3fdf5b061176519aa","Node name for S&R":"GeomPackFillHoles"},"widgets_values":["pymeshlab",0,"false"]},{"id":4,"type":"GeomPackFillHoles","pos":[400,-600],"size":[370,158],"flags":{},"order":3,"mode":0,"inputs":[{"localized_name":"mesh","name":"mesh","type":"TRIMESH","link":3},{"localized_name":"method","name":"method","type":"COMFY_DYNAMICCOMBO_V3","widget":{"name":"method"},"link":null},{"localized_name":"method.max_edges","name":"method.max_edges","type":"INT","widget":{"name":"method.max_edges"},"link":null},{"localized_name":"method.refine","name":"method.refine","type":"COMBO","widget":{"name":"method.refine"},"link":null}],"outputs":[{"localized_name":"filled_mesh","name":"filled_mesh","type":"TRIMESH","links":[10]},{"localized_name":"info","name":"info","type":"STRING","links":null}],"properties":{"cnr_id":"comfyui-geometrypack","ver":"6e8088fb3283086d421a58c3fdf5b061176519aa","Node name for S&R":"GeomPackFillHoles"},"widgets_values":["pymeshfix",0,"true","false"]},{"id":5,"type":"GeomPackFillHoles","pos":[-400,-300],"size":[370,110],"flags":{},"order":4,"mode":0,"inputs":[{"localized_name":"mesh","name":"mesh","type":"TRIMESH","link":4},{"localized_name":"method","name":"method","type":"COMFY_DYNAMICCOMBO_V3","widget":{"name":"method"},"link":null}],"outputs":[{"localized_name":"filled_mesh","name":"filled_mesh","type":"TRIMESH","links":[11]},{"localized_name":"info","name":"info","type":"STRING","links":null}],"properties":{"cnr_id":"comfyui-geometrypack","ver":"6e8088fb3283086d421a58c3fdf5b061176519aa","Node name for S&R":"GeomPackFillHoles"},"widgets_values":["igl_fan","false"]},{"id":6,"type":"GeomPackFillHoles","pos":[0,-300],"size":[370,134],"flags":{},"order":5,"mode":0,"inputs":[{"localized_name":"mesh","name":"mesh","type":"TRIMESH","link":5},{"localized_name":"method","name":"method","type":"COMFY_DYNAMICCOMBO_V3","widget":{"name":"method"},"link":null},{"localized_name":"method.quality","name":"method.quality","type":"COMBO","widget":{"name":"method.quality"},"link":null}],"outputs":[{"localized_name":"filled_mesh","name":"filled_mesh","type":"TRIMESH","links":[12]},{"localized_name":"info","name":"info","type":"STRING","links":null}],"properties":{"cnr_id":"comfyui-geometrypack","ver":"6e8088fb3283086d421a58c3fdf5b061176519aa","Node name for S&R":"GeomPackFillHoles"},"widgets_values":["cgal","refine","false"]},{"id":7,"type":"GeomPackFillHoles","pos":[400,-300],"size":[370,134],"flags":{},"order":6,"mode":0,"inputs":[{"localized_name":"mesh","name":"mesh","type":"TRIMESH","link":6},{"localized_name":"method","name":"method","type":"COMFY_DYNAMICCOMBO_V3","widget":{"name":"method"},"link":null},{"localized_name":"method.perimeter","name":"method.perimeter","type":"FLOAT","widget":{"name":"method.perimeter"},"link":null}],"outputs":[{"localized_name":"filled_mesh","name":"filled_mesh","type":"TRIMESH","links":[13]},{"localized_name":"info","name":"info","type":"STRING","links":null}],"properties":{"cnr_id":"comfyui-geometrypack","ver":"6e8088fb3283086d421a58c3fdf5b061176519aa","Node name for S&R":"GeomPackFillHoles"},"widgets_values":["gpu_cumesh",0.03,"false"]},{"id":8,"type":"GeomPackPreviewMeshMulti","pos":[-400,100],"size":[768,702],"flags":{},"order":7,"mode":0,"inputs":[{"localized_name":"mesh_1","name":"mesh_1","type":"TRIMESH","link":7},{"localized_name":"mesh_2","name":"mesh_2","shape":7,"type":"TRIMESH","link":8},{"localized_name":"mesh_3","name":"mesh_3","shape":7,"type":"TRIMESH","link":9},{"localized_name":"mesh_4","name":"mesh_4","shape":7,"type":"TRIMESH","link":10},{"localized_name":"mode","name":"mode","shape":7,"type":"COMBO","widget":{"name":"mode"},"link":null}],"outputs":[],"properties":{"cnr_id":"comfyui-geometrypack","ver":"6e8088fb3283086d421a58c3fdf5b061176519aa","Node name for S&R":"GeomPackPreviewMeshMulti"},"widgets_values":["fields","{\"show_edges\":true,\"camera_state\":\"{\\\"position\\\":[0.4013352586834127,-1.3034430870199674,-0.9081605838900391],\\\"focalPoint\\\":[0.000001996755599975586,-0.0636419951915741,-0.03211300075054169],\\\"viewUp\\\":[0.41668520115314117,-0.4307600853225743,0.8005118312884919]}\",\"selected_field\":\"\"}"]},{"id":9,"type":"GeomPackPreviewMeshMulti","pos":[500,100],"size":[768,702],"flags":{},"order":8,"mode":0,"inputs":[{"localized_name":"mesh_1","name":"mesh_1","type":"TRIMESH","link":11},{"localized_name":"mesh_2","name":"mesh_2","shape":7,"type":"TRIMESH","link":12},{"localized_name":"mesh_3","name":"mesh_3","shape":7,"type":"TRIMESH","link":13},{"localized_name":"mesh_4","name":"mesh_4","shape":7,"type":"TRIMESH","link":null},{"localized_name":"mode","name":"mode","shape":7,"type":"COMBO","widget":{"name":"mode"},"link":null}],"outputs":[],"properties":{"cnr_id":"comfyui-geometrypack","ver":"6e8088fb3283086d421a58c3fdf5b061176519aa","Node name for S&R":"GeomPackPreviewMeshMulti"},"widgets_values":["fields","{\"show_edges\":true,\"camera_state\":\"{\\\"position\\\":[0.4013352586834127,-1.3034430870199674,-0.9081605838900391],\\\"focalPoint\\\":[0.000001996755599975586,-0.0636419951915741,-0.03211300075054169],\\\"viewUp\\\":[0.41668520115314117,-0.4307600853225743,0.8005118312884919]}\",\"selected_field\":\"\"}"]},{"id":1,"type":"GeomPackLoadMesh","pos":[-800,-200],"size":[270,78],"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,2,3,4,5,6,7]},{"localized_name":"texture","name":"texture","type":"IMAGE","links":null}],"properties":{"cnr_id":"comfyui-geometrypack","ver":"6e8088fb3283086d421a58c3fdf5b061176519aa","Node name for S&R":"GeomPackLoadMesh"},"widgets_values":["3d/camelhead.off"]}],"links":[[1,1,0,2,0,"TRIMESH"],[2,1,0,3,0,"TRIMESH"],[3,1,0,4,0,"TRIMESH"],[4,1,0,5,0,"TRIMESH"],[5,1,0,6,0,"TRIMESH"],[6,1,0,7,0,"TRIMESH"],[7,1,0,8,0,"TRIMESH"],[8,2,0,8,1,"TRIMESH"],[9,3,0,8,2,"TRIMESH"],[10,4,0,8,3,"TRIMESH"],[11,5,0,9,0,"TRIMESH"],[12,6,0,9,1,"TRIMESH"],[13,7,0,9,2,"TRIMESH"]],"groups":[],"config":{},"extra":{"ds":{"scale":0.6611570247933886,"offset":[1231.005728515625,484.32482617187526]},"frontendVersion":"1.30.6","workflowRendererVersion":"LG"},"version":0.4} \ No newline at end of file diff --git a/workflows/remeshing_all.json b/workflows/remeshing_all.json new file mode 100644 index 00000000..0214fc4b --- /dev/null +++ b/workflows/remeshing_all.json @@ -0,0 +1 @@ +{"id":"fc31dc6e-fd82-4f42-9d3a-57283ccfadba","revision":0,"last_node_id":55,"last_link_id":68,"nodes":[{"id":1,"type":"GeomPackLoadMesh","pos":[-1014.1521350641405,-579.0137321727649],"size":[270,78],"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":[37,38,41,42,44,45,46,47,56,57,58,59,65,66,67,68]},{"localized_name":"texture","name":"texture","type":"IMAGE","links":null}],"properties":{"cnr_id":"comfyui-geometrypack","ver":"253a956368261760da54dc96750c2ab0224e2b1f","Node name for S&R":"GeomPackLoadMesh","aux_id":"PozzettiAndrea/ComfyUI-GeometryPack"},"widgets_values":["3d/Stanford_Bunny.stl"]},{"id":46,"type":"GeomPackPreviewMeshMulti","pos":[-1057.238644432902,106.33224496938243],"size":[768,702],"flags":{},"order":18,"mode":0,"inputs":[{"localized_name":"mesh_1","name":"mesh_1","type":"TRIMESH","link":52},{"localized_name":"mesh_2","name":"mesh_2","shape":7,"type":"TRIMESH","link":53},{"localized_name":"mesh_3","name":"mesh_3","shape":7,"type":"TRIMESH","link":54},{"localized_name":"mesh_4","name":"mesh_4","shape":7,"type":"TRIMESH","link":55},{"localized_name":"mode","name":"mode","shape":7,"type":"COMBO","widget":{"name":"mode"},"link":null}],"outputs":[],"properties":{"cnr_id":"comfyui-geometrypack","ver":"8f0378ab0ec8f20aad7b96ab2b1ec39ae6f82b6f","Node name for S&R":"GeomPackPreviewMeshMulti"},"widgets_values":["fields","{\"show_edges\":true,\"camera_state\":\"{\\\"position\\\":[71.59053240844639,69.24440924198683,75.42363905279093],\\\"focalPoint\\\":[30.322647094726562,1.8909549713134766,59.220962047576904],\\\"viewUp\\\":[-0.3360775929433742,-0.02059842888042879,0.9416090251527147]}\",\"selected_field\":\"\"}"]},{"id":50,"type":"GeomPackRemesh","pos":[-1054.5376867889013,-409.41668100861546],"size":[366.22833806818176,290],"flags":{},"order":8,"mode":4,"inputs":[{"localized_name":"trimesh","name":"trimesh","type":"TRIMESH","link":56},{"localized_name":"backend","name":"backend","type":"COMFY_DYNAMICCOMBO_V3","widget":{"name":"backend"},"link":null},{"localized_name":"backend.pmp_min_edge","name":"backend.pmp_min_edge","type":"FLOAT","widget":{"name":"backend.pmp_min_edge"},"link":null},{"localized_name":"backend.pmp_max_edge","name":"backend.pmp_max_edge","type":"FLOAT","widget":{"name":"backend.pmp_max_edge"},"link":null},{"localized_name":"backend.pmp_approx_error","name":"backend.pmp_approx_error","type":"FLOAT","widget":{"name":"backend.pmp_approx_error"},"link":null},{"localized_name":"backend.pmp_adaptive_iterations","name":"backend.pmp_adaptive_iterations","type":"INT","widget":{"name":"backend.pmp_adaptive_iterations"},"link":null},{"localized_name":"backend.pmp_adaptive_projection","name":"backend.pmp_adaptive_projection","type":"COMBO","widget":{"name":"backend.pmp_adaptive_projection"},"link":null}],"outputs":[{"localized_name":"remeshed_mesh","name":"remeshed_mesh","type":"TRIMESH","links":[52]},{"localized_name":"info","name":"info","type":"STRING","links":null}],"properties":{"cnr_id":"comfyui-geometrypack","ver":"f4d5713f5feef867c82be5a2d67e532e7a95933b","Node name for S&R":"GeomPackRemesh"},"widgets_values":["pmp_adaptive",0.01,0.05,0.01,1,"false","true"]},{"id":49,"type":"GeomPackRemesh","pos":[-661.8442867889027,-405.9802810086156],"size":[366.22833806818176,230],"flags":{},"order":9,"mode":0,"inputs":[{"localized_name":"trimesh","name":"trimesh","type":"TRIMESH","link":57},{"localized_name":"backend","name":"backend","type":"COMFY_DYNAMICCOMBO_V3","widget":{"name":"backend"},"link":null},{"localized_name":"backend.qw_sharp_angle","name":"backend.qw_sharp_angle","type":"FLOAT","widget":{"name":"backend.qw_sharp_angle"},"link":null},{"localized_name":"backend.qw_alpha","name":"backend.qw_alpha","type":"FLOAT","widget":{"name":"backend.qw_alpha"},"link":null},{"localized_name":"backend.qw_scale_factor","name":"backend.qw_scale_factor","type":"FLOAT","widget":{"name":"backend.qw_scale_factor"},"link":null},{"localized_name":"backend.qw_remesh","name":"backend.qw_remesh","type":"COMBO","widget":{"name":"backend.qw_remesh"},"link":null},{"localized_name":"backend.qw_smooth","name":"backend.qw_smooth","type":"COMBO","widget":{"name":"backend.qw_smooth"},"link":null}],"outputs":[{"localized_name":"remeshed_mesh","name":"remeshed_mesh","type":"TRIMESH","links":[53]},{"localized_name":"info","name":"info","type":"STRING","links":null}],"properties":{"cnr_id":"comfyui-geometrypack","ver":"f4d5713f5feef867c82be5a2d67e532e7a95933b","Node name for S&R":"GeomPackRemesh"},"widgets_values":["quadwild",35,0.02,1,"true","true","false"]},{"id":48,"type":"GeomPackRemesh","pos":[-666.1485987644222,-126.89669646146771],"size":[366.22833806818176,134],"flags":{},"order":11,"mode":0,"inputs":[{"localized_name":"trimesh","name":"trimesh","type":"TRIMESH","link":59},{"localized_name":"backend","name":"backend","type":"COMFY_DYNAMICCOMBO_V3","widget":{"name":"backend"},"link":null},{"localized_name":"backend.voxel_size","name":"backend.voxel_size","type":"FLOAT","widget":{"name":"backend.voxel_size"},"link":null}],"outputs":[{"localized_name":"remeshed_mesh","name":"remeshed_mesh","type":"TRIMESH","links":[55]},{"localized_name":"info","name":"info","type":"STRING","links":null}],"properties":{"cnr_id":"comfyui-geometrypack","ver":"f4d5713f5feef867c82be5a2d67e532e7a95933b","Node name for S&R":"GeomPackRemesh"},"widgets_values":["blender_voxel",1,"false"]},{"id":27,"type":"GeomPackRemesh","pos":[-252.4796326065235,-967.5718475471988],"size":[366.22833806818176,206],"flags":{},"order":1,"mode":0,"inputs":[{"localized_name":"trimesh","name":"trimesh","type":"TRIMESH","link":37},{"localized_name":"backend","name":"backend","type":"COMFY_DYNAMICCOMBO_V3","widget":{"name":"backend"},"link":null},{"localized_name":"backend.target_edge_length","name":"backend.target_edge_length","type":"FLOAT","widget":{"name":"backend.target_edge_length"},"link":null},{"localized_name":"backend.iterations","name":"backend.iterations","type":"INT","widget":{"name":"backend.iterations"},"link":null},{"localized_name":"backend.feature_angle","name":"backend.feature_angle","type":"FLOAT","widget":{"name":"backend.feature_angle"},"link":null},{"localized_name":"backend.adaptive","name":"backend.adaptive","type":"COMBO","widget":{"name":"backend.adaptive"},"link":null}],"outputs":[{"localized_name":"remeshed_mesh","name":"remeshed_mesh","type":"TRIMESH","links":[39]},{"localized_name":"info","name":"info","type":"STRING","links":null}],"properties":{"cnr_id":"comfyui-geometrypack","ver":"f4d5713f5feef867c82be5a2d67e532e7a95933b","Node name for S&R":"GeomPackRemesh"},"widgets_values":["pymeshlab_isotropic",1,3,30,"false","false"]},{"id":37,"type":"GeomPackRemesh","pos":[135.90945541795637,-709.2518630000504],"size":[366.22833806818176,182],"flags":{},"order":3,"mode":0,"inputs":[{"localized_name":"trimesh","name":"trimesh","type":"TRIMESH","link":42},{"localized_name":"backend","name":"backend","type":"COMFY_DYNAMICCOMBO_V3","widget":{"name":"backend"},"link":null},{"localized_name":"backend.target_face_count","name":"backend.target_face_count","type":"INT","widget":{"name":"backend.target_face_count"},"link":null},{"localized_name":"backend.preserve_sharp","name":"backend.preserve_sharp","type":"COMBO","widget":{"name":"backend.preserve_sharp"},"link":null},{"localized_name":"backend.preserve_boundary","name":"backend.preserve_boundary","type":"COMBO","widget":{"name":"backend.preserve_boundary"},"link":null}],"outputs":[{"localized_name":"remeshed_mesh","name":"remeshed_mesh","type":"TRIMESH","links":[43]},{"localized_name":"info","name":"info","type":"STRING","links":null}],"properties":{"cnr_id":"comfyui-geometrypack","ver":"f4d5713f5feef867c82be5a2d67e532e7a95933b","Node name for S&R":"GeomPackRemesh"},"widgets_values":["quadriflow",5000,"false","true","false"]},{"id":29,"type":"GeomPackPreviewMeshMulti","pos":[-250.65357747104312,-472.80821058805077],"size":[768,702],"flags":{},"order":16,"mode":0,"inputs":[{"localized_name":"mesh_1","name":"mesh_1","type":"TRIMESH","link":38},{"localized_name":"mesh_2","name":"mesh_2","shape":7,"type":"TRIMESH","link":39},{"localized_name":"mesh_3","name":"mesh_3","shape":7,"type":"TRIMESH","link":40},{"localized_name":"mesh_4","name":"mesh_4","shape":7,"type":"TRIMESH","link":43},{"localized_name":"mode","name":"mode","shape":7,"type":"COMBO","widget":{"name":"mode"},"link":null}],"outputs":[],"properties":{"cnr_id":"comfyui-geometrypack","ver":"8f0378ab0ec8f20aad7b96ab2b1ec39ae6f82b6f","Node name for S&R":"GeomPackPreviewMeshMulti"},"widgets_values":["fields","{\"show_edges\":true,\"camera_state\":\"{\\\"position\\\":[71.59053240844639,69.24440924198683,75.42363905279093],\\\"focalPoint\\\":[30.322647094726562,1.8909549713134766,59.220962047576904],\\\"viewUp\\\":[-0.3360775929433742,-0.02059842888042879,0.9416090251527147]}\",\"selected_field\":\"\"}"]},{"id":45,"type":"GeomPackRemesh","pos":[945.7681182179566,-963.8635115000499],"size":[366.22833806818176,206],"flags":{},"order":7,"mode":0,"inputs":[{"localized_name":"trimesh","name":"trimesh","type":"TRIMESH","link":47},{"localized_name":"backend","name":"backend","type":"COMFY_DYNAMICCOMBO_V3","widget":{"name":"backend"},"link":null},{"localized_name":"backend.nb_points","name":"backend.nb_points","type":"INT","widget":{"name":"backend.nb_points"},"link":null},{"localized_name":"backend.nb_lloyd_iter","name":"backend.nb_lloyd_iter","type":"INT","widget":{"name":"backend.nb_lloyd_iter"},"link":null},{"localized_name":"backend.nb_newton_iter","name":"backend.nb_newton_iter","type":"INT","widget":{"name":"backend.nb_newton_iter"},"link":null},{"localized_name":"backend.newton_m","name":"backend.newton_m","type":"INT","widget":{"name":"backend.newton_m"},"link":null}],"outputs":[{"localized_name":"remeshed_mesh","name":"remeshed_mesh","type":"TRIMESH","links":[49]},{"localized_name":"info","name":"info","type":"STRING","links":null}],"properties":{"cnr_id":"comfyui-geometrypack","ver":"f4d5713f5feef867c82be5a2d67e532e7a95933b","Node name for S&R":"GeomPackRemesh"},"widgets_values":["geogram_smooth",5000,5,30,7,"false"]},{"id":42,"type":"GeomPackRemesh","pos":[549.6658062424374,-696.6258269529004],"size":[366.22833806818176,158],"flags":{},"order":5,"mode":0,"inputs":[{"localized_name":"trimesh","name":"trimesh","type":"TRIMESH","link":45},{"localized_name":"backend","name":"backend","type":"COMFY_DYNAMICCOMBO_V3","widget":{"name":"backend"},"link":null},{"localized_name":"backend.nb_points_aniso","name":"backend.nb_points_aniso","type":"INT","widget":{"name":"backend.nb_points_aniso"},"link":null},{"localized_name":"backend.anisotropy","name":"backend.anisotropy","type":"FLOAT","widget":{"name":"backend.anisotropy"},"link":null}],"outputs":[{"localized_name":"remeshed_mesh","name":"remeshed_mesh","type":"TRIMESH","links":[50]},{"localized_name":"info","name":"info","type":"STRING","links":null}],"properties":{"cnr_id":"comfyui-geometrypack","ver":"f4d5713f5feef867c82be5a2d67e532e7a95933b","Node name for S&R":"GeomPackRemesh"},"widgets_values":["geogram_anisotropic",5000,0.04,"false"]},{"id":30,"type":"GeomPackPreviewMeshMulti","pos":[550.3737605739569,-480.5909855220511],"size":[768,702],"flags":{},"order":17,"mode":0,"inputs":[{"localized_name":"mesh_1","name":"mesh_1","type":"TRIMESH","link":51},{"localized_name":"mesh_2","name":"mesh_2","shape":7,"type":"TRIMESH","link":49},{"localized_name":"mesh_3","name":"mesh_3","shape":7,"type":"TRIMESH","link":50},{"localized_name":"mesh_4","name":"mesh_4","shape":7,"type":"TRIMESH","link":48},{"localized_name":"mode","name":"mode","shape":7,"type":"COMBO","widget":{"name":"mode"},"link":null}],"outputs":[],"properties":{"cnr_id":"comfyui-geometrypack","ver":"8f0378ab0ec8f20aad7b96ab2b1ec39ae6f82b6f","Node name for S&R":"GeomPackPreviewMeshMulti"},"widgets_values":["fields","{\"show_edges\":true,\"camera_state\":\"{\\\"position\\\":[71.59053240844639,69.24440924198683,75.42363905279093],\\\"focalPoint\\\":[30.322647094726562,1.8909549713134766,59.220962047576904],\\\"viewUp\\\":[-0.3360775929433742,-0.02059842888042879,0.9416090251527147]}\",\"selected_field\":\"\"}"]},{"id":39,"type":"GeomPackRemesh","pos":[-247.41854458204313,-710.5828630000495],"size":[366.22833806818176,182],"flags":{},"order":2,"mode":0,"inputs":[{"localized_name":"trimesh","name":"trimesh","type":"TRIMESH","link":41},{"localized_name":"backend","name":"backend","type":"COMFY_DYNAMICCOMBO_V3","widget":{"name":"backend"},"link":null},{"localized_name":"backend.target_vertex_count","name":"backend.target_vertex_count","type":"INT","widget":{"name":"backend.target_vertex_count"},"link":null},{"localized_name":"backend.deterministic","name":"backend.deterministic","type":"COMBO","widget":{"name":"backend.deterministic"},"link":null},{"localized_name":"backend.crease_angle","name":"backend.crease_angle","type":"FLOAT","widget":{"name":"backend.crease_angle"},"link":null}],"outputs":[{"localized_name":"remeshed_mesh","name":"remeshed_mesh","type":"TRIMESH","links":[40]},{"localized_name":"info","name":"info","type":"STRING","links":null}],"properties":{"cnr_id":"comfyui-geometrypack","ver":"f4d5713f5feef867c82be5a2d67e532e7a95933b","Node name for S&R":"GeomPackRemesh"},"widgets_values":["instant_meshes",5000,"true",0,"false"]},{"id":43,"type":"GeomPackRemesh","pos":[932.9938062424369,-711.3999269529013],"size":[366.22833806818176,182],"flags":{},"order":6,"mode":4,"inputs":[{"localized_name":"trimesh","name":"trimesh","type":"TRIMESH","link":46},{"localized_name":"backend","name":"backend","type":"COMFY_DYNAMICCOMBO_V3","widget":{"name":"backend"},"link":null},{"localized_name":"backend.pmp_edge_length","name":"backend.pmp_edge_length","type":"FLOAT","widget":{"name":"backend.pmp_edge_length"},"link":null},{"localized_name":"backend.pmp_iterations","name":"backend.pmp_iterations","type":"INT","widget":{"name":"backend.pmp_iterations"},"link":null},{"localized_name":"backend.pmp_use_projection","name":"backend.pmp_use_projection","type":"COMBO","widget":{"name":"backend.pmp_use_projection"},"link":null}],"outputs":[{"localized_name":"remeshed_mesh","name":"remeshed_mesh","type":"TRIMESH","links":[48]},{"localized_name":"info","name":"info","type":"STRING","links":null}],"properties":{"cnr_id":"comfyui-geometrypack","ver":"f4d5713f5feef867c82be5a2d67e532e7a95933b","Node name for S&R":"GeomPackRemesh"},"widgets_values":["pmp_uniform",1,1,"true","false"]},{"id":44,"type":"GeomPackRemesh","pos":[544.604718217957,-969.7199115000496],"size":[366.22833806818176,206],"flags":{},"order":4,"mode":0,"inputs":[{"localized_name":"trimesh","name":"trimesh","type":"TRIMESH","link":44},{"localized_name":"backend","name":"backend","type":"COMFY_DYNAMICCOMBO_V3","widget":{"name":"backend"},"link":null},{"localized_name":"backend.hausd","name":"backend.hausd","type":"FLOAT","widget":{"name":"backend.hausd"},"link":null},{"localized_name":"backend.hmin","name":"backend.hmin","type":"FLOAT","widget":{"name":"backend.hmin"},"link":null},{"localized_name":"backend.hmax","name":"backend.hmax","type":"FLOAT","widget":{"name":"backend.hmax"},"link":null},{"localized_name":"backend.hgrad","name":"backend.hgrad","type":"FLOAT","widget":{"name":"backend.hgrad"},"link":null}],"outputs":[{"localized_name":"remeshed_mesh","name":"remeshed_mesh","type":"TRIMESH","links":[51]},{"localized_name":"info","name":"info","type":"STRING","links":null}],"properties":{"cnr_id":"comfyui-geometrypack","ver":"f4d5713f5feef867c82be5a2d67e532e7a95933b","Node name for S&R":"GeomPackRemesh"},"widgets_values":["mmg_adaptive",0.05,0,0,1.3,"false"]},{"id":47,"type":"GeomPackRemesh","pos":[-1049.4765987644212,-112.12259646146691],"size":[366.22833806818176,182],"flags":{},"order":10,"mode":0,"inputs":[{"localized_name":"trimesh","name":"trimesh","type":"TRIMESH","link":58},{"localized_name":"backend","name":"backend","type":"COMFY_DYNAMICCOMBO_V3","widget":{"name":"backend"},"link":null},{"localized_name":"backend.cgal_edge_length","name":"backend.cgal_edge_length","type":"FLOAT","widget":{"name":"backend.cgal_edge_length"},"link":null},{"localized_name":"backend.cgal_iterations","name":"backend.cgal_iterations","type":"INT","widget":{"name":"backend.cgal_iterations"},"link":null},{"localized_name":"backend.protect_boundaries","name":"backend.protect_boundaries","type":"COMBO","widget":{"name":"backend.protect_boundaries"},"link":null}],"outputs":[{"localized_name":"remeshed_mesh","name":"remeshed_mesh","type":"TRIMESH","links":[54]},{"localized_name":"info","name":"info","type":"STRING","links":null}],"properties":{"cnr_id":"comfyui-geometrypack","ver":"f4d5713f5feef867c82be5a2d67e532e7a95933b","Node name for S&R":"GeomPackRemesh"},"widgets_values":["cgal_isotropic",1,3,"true","false"]},{"id":54,"type":"GeomPackRemesh","pos":[-229.90242463831174,281.7988805805725],"size":[366.22833806818176,158],"flags":{},"order":12,"mode":0,"inputs":[{"localized_name":"trimesh","name":"trimesh","type":"TRIMESH","link":65},{"localized_name":"backend","name":"backend","type":"COMFY_DYNAMICCOMBO_V3","widget":{"name":"backend"},"link":null},{"localized_name":"backend.octree_depth","name":"backend.octree_depth","type":"INT","widget":{"name":"backend.octree_depth"},"link":null},{"localized_name":"backend.scale","name":"backend.scale","type":"FLOAT","widget":{"name":"backend.scale"},"link":null}],"outputs":[{"localized_name":"remeshed_mesh","name":"remeshed_mesh","type":"TRIMESH","links":[64]},{"localized_name":"info","name":"info","type":"STRING","links":null}],"properties":{"cnr_id":"comfyui-geometrypack","ver":"f4d5713f5feef867c82be5a2d67e532e7a95933b","Node name for S&R":"GeomPackRemesh"},"widgets_values":["blender_smooth",6,0.9,"false"]},{"id":51,"type":"GeomPackRemesh","pos":[162.79097536168717,261.0352805805725],"size":[366.22833806818176,182],"flags":{},"order":13,"mode":0,"inputs":[{"localized_name":"trimesh","name":"trimesh","type":"TRIMESH","link":66},{"localized_name":"backend","name":"backend","type":"COMFY_DYNAMICCOMBO_V3","widget":{"name":"backend"},"link":null},{"localized_name":"backend.octree_depth","name":"backend.octree_depth","type":"INT","widget":{"name":"backend.octree_depth"},"link":null},{"localized_name":"backend.scale","name":"backend.scale","type":"FLOAT","widget":{"name":"backend.scale"},"link":null},{"localized_name":"backend.sharpness","name":"backend.sharpness","type":"FLOAT","widget":{"name":"backend.sharpness"},"link":null}],"outputs":[{"localized_name":"remeshed_mesh","name":"remeshed_mesh","type":"TRIMESH","links":[62]},{"localized_name":"info","name":"info","type":"STRING","links":null}],"properties":{"cnr_id":"comfyui-geometrypack","ver":"f4d5713f5feef867c82be5a2d67e532e7a95933b","Node name for S&R":"GeomPackRemesh"},"widgets_values":["blender_sharp",6,0.9,1,"false"]},{"id":53,"type":"GeomPackRemesh","pos":[-221.54133661383142,546.0929651277208],"size":[366.22833806818176,158],"flags":{},"order":14,"mode":0,"inputs":[{"localized_name":"trimesh","name":"trimesh","type":"TRIMESH","link":67},{"localized_name":"backend","name":"backend","type":"COMFY_DYNAMICCOMBO_V3","widget":{"name":"backend"},"link":null},{"localized_name":"backend.octree_depth","name":"backend.octree_depth","type":"INT","widget":{"name":"backend.octree_depth"},"link":null},{"localized_name":"backend.scale","name":"backend.scale","type":"FLOAT","widget":{"name":"backend.scale"},"link":null}],"outputs":[{"localized_name":"remeshed_mesh","name":"remeshed_mesh","type":"TRIMESH","links":[63]},{"localized_name":"info","name":"info","type":"STRING","links":null}],"properties":{"cnr_id":"comfyui-geometrypack","ver":"f4d5713f5feef867c82be5a2d67e532e7a95933b","Node name for S&R":"GeomPackRemesh"},"widgets_values":["blender_blocks",6,0.9,"false"]},{"id":52,"type":"GeomPackRemesh","pos":[158.48666338616763,540.1188651277203],"size":[366.22833806818176,158],"flags":{},"order":15,"mode":0,"inputs":[{"localized_name":"trimesh","name":"trimesh","type":"TRIMESH","link":68},{"localized_name":"backend","name":"backend","type":"COMFY_DYNAMICCOMBO_V3","widget":{"name":"backend"},"link":null},{"localized_name":"backend.gpu_target_face_count","name":"backend.gpu_target_face_count","type":"INT","widget":{"name":"backend.gpu_target_face_count"},"link":null},{"localized_name":"backend.remesh_band","name":"backend.remesh_band","type":"FLOAT","widget":{"name":"backend.remesh_band"},"link":null}],"outputs":[{"localized_name":"remeshed_mesh","name":"remeshed_mesh","type":"TRIMESH","links":[61]},{"localized_name":"info","name":"info","type":"STRING","links":null}],"properties":{"cnr_id":"comfyui-geometrypack","ver":"f4d5713f5feef867c82be5a2d67e532e7a95933b","Node name for S&R":"GeomPackRemesh"},"widgets_values":["gpu_cumesh",500000,1,"false"]},{"id":55,"type":"GeomPackPreviewMeshMulti","pos":[559.2474753616863,275.5298705805723],"size":[768,702],"flags":{},"order":19,"mode":0,"inputs":[{"localized_name":"mesh_1","name":"mesh_1","type":"TRIMESH","link":64},{"localized_name":"mesh_2","name":"mesh_2","shape":7,"type":"TRIMESH","link":62},{"localized_name":"mesh_3","name":"mesh_3","shape":7,"type":"TRIMESH","link":63},{"localized_name":"mesh_4","name":"mesh_4","shape":7,"type":"TRIMESH","link":61},{"localized_name":"mode","name":"mode","shape":7,"type":"COMBO","widget":{"name":"mode"},"link":null}],"outputs":[],"properties":{"cnr_id":"comfyui-geometrypack","ver":"8f0378ab0ec8f20aad7b96ab2b1ec39ae6f82b6f","Node name for S&R":"GeomPackPreviewMeshMulti"},"widgets_values":["fields","{\"show_edges\":true,\"camera_state\":\"{\\\"position\\\":[71.59053240844639,69.24440924198683,75.42363905279093],\\\"focalPoint\\\":[30.322647094726562,1.8909549713134766,59.220962047576904],\\\"viewUp\\\":[-0.3360775929433742,-0.02059842888042879,0.9416090251527147]}\",\"selected_field\":\"\"}"]}],"links":[[37,1,0,27,0,"TRIMESH"],[38,1,0,29,0,"TRIMESH"],[39,27,0,29,1,"TRIMESH"],[40,39,0,29,2,"TRIMESH"],[41,1,0,39,0,"TRIMESH"],[42,1,0,37,0,"TRIMESH"],[43,37,0,29,3,"TRIMESH"],[44,1,0,44,0,"TRIMESH"],[45,1,0,42,0,"TRIMESH"],[46,1,0,43,0,"TRIMESH"],[47,1,0,45,0,"TRIMESH"],[48,43,0,30,3,"TRIMESH"],[49,45,0,30,1,"TRIMESH"],[50,42,0,30,2,"TRIMESH"],[51,44,0,30,0,"TRIMESH"],[52,50,0,46,0,"TRIMESH"],[53,49,0,46,1,"TRIMESH"],[54,47,0,46,2,"TRIMESH"],[55,48,0,46,3,"TRIMESH"],[56,1,0,50,0,"TRIMESH"],[57,1,0,49,0,"TRIMESH"],[58,1,0,47,0,"TRIMESH"],[59,1,0,48,0,"TRIMESH"],[61,52,0,55,3,"TRIMESH"],[62,51,0,55,1,"TRIMESH"],[63,53,0,55,2,"TRIMESH"],[64,54,0,55,0,"TRIMESH"],[65,1,0,54,0,"TRIMESH"],[66,1,0,51,0,"TRIMESH"],[67,1,0,53,0,"TRIMESH"],[68,1,0,52,0,"TRIMESH"]],"groups":[],"config":{},"extra":{"ds":{"scale":0.620921323059155,"offset":[879.8499557246013,615.3931984957435]},"frontendVersion":"1.30.6","workflowRendererVersion":"LG"},"version":0.4} \ No newline at end of file diff --git a/workflows/sharpen_all.json b/workflows/sharpen_all.json new file mode 100644 index 00000000..e275ee65 --- /dev/null +++ b/workflows/sharpen_all.json @@ -0,0 +1 @@ +{"id":"a1b2c3d4-e5f6-7890-abcd-sharpen000001","revision":0,"last_node_id":12,"last_link_id":17,"nodes":[{"id":2,"type":"GeomPackSharpenMesh","pos":[-400,-900],"size":[370,126],"flags":{},"order":1,"mode":0,"inputs":[{"localized_name":"trimesh","name":"trimesh","type":"TRIMESH","link":1},{"localized_name":"backend","name":"backend","type":"COMFY_DYNAMICCOMBO_V3","widget":{"name":"backend"},"link":null},{"localized_name":"backend.smooth_steps","name":"backend.smooth_steps","type":"INT","widget":{"name":"backend.smooth_steps"},"link":null},{"localized_name":"backend.normal_threshold","name":"backend.normal_threshold","type":"FLOAT","widget":{"name":"backend.normal_threshold"},"link":null}],"outputs":[{"localized_name":"sharpened_mesh","name":"sharpened_mesh","type":"TRIMESH","links":[10]},{"localized_name":"info","name":"info","type":"STRING","links":null}],"properties":{"cnr_id":"comfyui-geometrypack","ver":"6e8088fb3283086d421a58c3fdf5b061176519aa","Node name for S&R":"GeomPackSharpenMesh"},"widgets_values":["two_step",3,60]},{"id":3,"type":"GeomPackSharpenMesh","pos":[0,-900],"size":[370,126],"flags":{},"order":2,"mode":0,"inputs":[{"localized_name":"trimesh","name":"trimesh","type":"TRIMESH","link":2},{"localized_name":"backend","name":"backend","type":"COMFY_DYNAMICCOMBO_V3","widget":{"name":"backend"},"link":null},{"localized_name":"backend.weight","name":"backend.weight","type":"FLOAT","widget":{"name":"backend.weight"},"link":null},{"localized_name":"backend.iterations","name":"backend.iterations","type":"INT","widget":{"name":"backend.iterations"},"link":null}],"outputs":[{"localized_name":"sharpened_mesh","name":"sharpened_mesh","type":"TRIMESH","links":[11]},{"localized_name":"info","name":"info","type":"STRING","links":null}],"properties":{"cnr_id":"comfyui-geometrypack","ver":"6e8088fb3283086d421a58c3fdf5b061176519aa","Node name for S&R":"GeomPackSharpenMesh"},"widgets_values":["unsharp_mask",0.3,5]},{"id":5,"type":"GeomPackSharpenMesh","pos":[-400,-600],"size":[370,150],"flags":{},"order":4,"mode":0,"inputs":[{"localized_name":"trimesh","name":"trimesh","type":"TRIMESH","link":4},{"localized_name":"backend","name":"backend","type":"COMFY_DYNAMICCOMBO_V3","widget":{"name":"backend"},"link":null},{"localized_name":"backend.alpha","name":"backend.alpha","type":"FLOAT","widget":{"name":"backend.alpha"},"link":null},{"localized_name":"backend.beta","name":"backend.beta","type":"FLOAT","widget":{"name":"backend.beta"},"link":null},{"localized_name":"backend.iterations","name":"backend.iterations","type":"INT","widget":{"name":"backend.iterations"},"link":null}],"outputs":[{"localized_name":"sharpened_mesh","name":"sharpened_mesh","type":"TRIMESH","links":[13]},{"localized_name":"info","name":"info","type":"STRING","links":null}],"properties":{"cnr_id":"comfyui-geometrypack","ver":"6e8088fb3283086d421a58c3fdf5b061176519aa","Node name for S&R":"GeomPackSharpenMesh"},"widgets_values":["l0_minimize",0.001,2,10]},{"id":10,"type":"GeomPackPreviewMeshMulti","pos":[-400,100],"size":[768,702],"flags":{},"order":9,"mode":0,"inputs":[{"localized_name":"mesh_1","name":"mesh_1","type":"TRIMESH","link":9},{"localized_name":"mesh_2","name":"mesh_2","shape":7,"type":"TRIMESH","link":10},{"localized_name":"mesh_3","name":"mesh_3","shape":7,"type":"TRIMESH","link":11},{"localized_name":"mesh_4","name":"mesh_4","shape":7,"type":"TRIMESH","link":12},{"localized_name":"mode","name":"mode","shape":7,"type":"COMBO","widget":{"name":"mode"},"link":null}],"outputs":[],"properties":{"cnr_id":"comfyui-geometrypack","ver":"6e8088fb3283086d421a58c3fdf5b061176519aa","Node name for S&R":"GeomPackPreviewMeshMulti"},"widgets_values":["fields","{\"show_edges\":true,\"camera_state\":\"{\\\"position\\\":[6.112168112444788,20.962661850752653,0.7300910516940223],\\\"focalPoint\\\":[2.429478704929352,15.20779800415039,-1.320433847606182],\\\"viewUp\\\":[0.15574863477274994,-0.41848455755995223,0.894769823949308]}\",\"selected_field\":\"\"}"]},{"id":1,"type":"GeomPackLoadMesh","pos":[-800,-400],"size":[270,78],"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,2,3,4,5,6,7,8,9]},{"localized_name":"texture","name":"texture","type":"IMAGE","links":null}],"properties":{"cnr_id":"comfyui-geometrypack","ver":"6e8088fb3283086d421a58c3fdf5b061176519aa","Node name for S&R":"GeomPackLoadMesh"},"widgets_values":["3d/noisy/Fandisk0.3/Noisy.obj"]},{"id":9,"type":"GeomPackSharpenMesh","pos":[0,-300],"size":[370,126],"flags":{},"order":8,"mode":0,"inputs":[{"localized_name":"trimesh","name":"trimesh","type":"TRIMESH","link":8},{"localized_name":"backend","name":"backend","type":"COMFY_DYNAMICCOMBO_V3","widget":{"name":"backend"},"link":null},{"localized_name":"backend.sigma_f","name":"backend.sigma_f","type":"FLOAT","widget":{"name":"backend.sigma_f"},"link":null},{"localized_name":"backend.sigma_g","name":"backend.sigma_g","type":"FLOAT","widget":{"name":"backend.sigma_g"},"link":null}],"outputs":[{"localized_name":"sharpened_mesh","name":"sharpened_mesh","type":"TRIMESH","links":[17]},{"localized_name":"info","name":"info","type":"STRING","links":null}],"properties":{"cnr_id":"comfyui-geometrypack","ver":"6e8088fb3283086d421a58c3fdf5b061176519aa","Node name for S&R":"GeomPackSharpenMesh"},"widgets_values":["non_iterative",1,1]},{"id":4,"type":"GeomPackSharpenMesh","pos":[400,-900],"size":[370,126],"flags":{},"order":3,"mode":4,"inputs":[{"localized_name":"trimesh","name":"trimesh","type":"TRIMESH","link":3},{"localized_name":"backend","name":"backend","type":"COMFY_DYNAMICCOMBO_V3","widget":{"name":"backend"},"link":null},{"localized_name":"backend.lambda_","name":"backend.lambda_","type":"FLOAT","widget":{"name":"backend.lambda_"},"link":null},{"localized_name":"backend.iterations","name":"backend.iterations","type":"INT","widget":{"name":"backend.iterations"},"link":null}],"outputs":[{"localized_name":"sharpened_mesh","name":"sharpened_mesh","type":"TRIMESH","links":[12]},{"localized_name":"info","name":"info","type":"STRING","links":null}],"properties":{"cnr_id":"comfyui-geometrypack","ver":"6e8088fb3283086d421a58c3fdf5b061176519aa","Node name for S&R":"GeomPackSharpenMesh"},"widgets_values":["libigl_unsharp",0.5,3]},{"id":7,"type":"GeomPackSharpenMesh","pos":[400,-600],"size":[370,150],"flags":{},"order":6,"mode":4,"inputs":[{"localized_name":"trimesh","name":"trimesh","type":"TRIMESH","link":6},{"localized_name":"backend","name":"backend","type":"COMFY_DYNAMICCOMBO_V3","widget":{"name":"backend"},"link":null},{"localized_name":"backend.num_proxies","name":"backend.num_proxies","type":"INT","widget":{"name":"backend.num_proxies"},"link":null},{"localized_name":"backend.lloyd_iterations","name":"backend.lloyd_iterations","type":"INT","widget":{"name":"backend.lloyd_iterations"},"link":null},{"localized_name":"backend.snap_strength","name":"backend.snap_strength","type":"FLOAT","widget":{"name":"backend.snap_strength"},"link":null}],"outputs":[{"localized_name":"sharpened_mesh","name":"sharpened_mesh","type":"TRIMESH","links":[15]},{"localized_name":"info","name":"info","type":"STRING","links":null}],"properties":{"cnr_id":"comfyui-geometrypack","ver":"6e8088fb3283086d421a58c3fdf5b061176519aa","Node name for S&R":"GeomPackSharpenMesh"},"widgets_values":["vsa_snap",20,10,1]},{"id":6,"type":"GeomPackSharpenMesh","pos":[-14.436383928571438,-609.2689732142853],"size":[370,174],"flags":{},"order":5,"mode":0,"inputs":[{"localized_name":"trimesh","name":"trimesh","type":"TRIMESH","link":5},{"localized_name":"backend","name":"backend","type":"COMFY_DYNAMICCOMBO_V3","widget":{"name":"backend"},"link":null},{"localized_name":"backend.normal_iterations","name":"backend.normal_iterations","type":"INT","widget":{"name":"backend.normal_iterations"},"link":null},{"localized_name":"backend.vertex_iterations","name":"backend.vertex_iterations","type":"INT","widget":{"name":"backend.vertex_iterations"},"link":null},{"localized_name":"backend.sigma_s","name":"backend.sigma_s","type":"FLOAT","widget":{"name":"backend.sigma_s"},"link":null},{"localized_name":"backend.sigma_r","name":"backend.sigma_r","type":"FLOAT","widget":{"name":"backend.sigma_r"},"link":null}],"outputs":[{"localized_name":"sharpened_mesh","name":"sharpened_mesh","type":"TRIMESH","links":[14]},{"localized_name":"info","name":"info","type":"STRING","links":null}],"properties":{"cnr_id":"comfyui-geometrypack","ver":"6e8088fb3283086d421a58c3fdf5b061176519aa","Node name for S&R":"GeomPackSharpenMesh"},"widgets_values":["guided_normal",5,10,1,0.35]},{"id":8,"type":"GeomPackSharpenMesh","pos":[-386.31138392857144,-305.70312499999994],"size":[370,150],"flags":{},"order":7,"mode":0,"inputs":[{"localized_name":"trimesh","name":"trimesh","type":"TRIMESH","link":7},{"localized_name":"backend","name":"backend","type":"COMFY_DYNAMICCOMBO_V3","widget":{"name":"backend"},"link":null},{"localized_name":"backend.threshold_T","name":"backend.threshold_T","type":"FLOAT","widget":{"name":"backend.threshold_T"},"link":null},{"localized_name":"backend.normal_iterations","name":"backend.normal_iterations","type":"INT","widget":{"name":"backend.normal_iterations"},"link":null},{"localized_name":"backend.vertex_iterations","name":"backend.vertex_iterations","type":"INT","widget":{"name":"backend.vertex_iterations"},"link":null}],"outputs":[{"localized_name":"sharpened_mesh","name":"sharpened_mesh","type":"TRIMESH","links":[16]},{"localized_name":"info","name":"info","type":"STRING","links":null}],"properties":{"cnr_id":"comfyui-geometrypack","ver":"6e8088fb3283086d421a58c3fdf5b061176519aa","Node name for S&R":"GeomPackSharpenMesh"},"widgets_values":["fast_effective",0.5,20,50]},{"id":12,"type":"GeomPackPreviewMeshMulti","pos":[1383.9397321428569,83.90066964285717],"size":[768,702],"flags":{},"order":11,"mode":0,"inputs":[{"localized_name":"mesh_1","name":"mesh_1","type":"TRIMESH","link":17},{"localized_name":"mesh_2","name":"mesh_2","shape":7,"type":"TRIMESH","link":null},{"localized_name":"mesh_3","name":"mesh_3","shape":7,"type":"TRIMESH","link":null},{"localized_name":"mesh_4","name":"mesh_4","shape":7,"type":"TRIMESH","link":null},{"localized_name":"mode","name":"mode","shape":7,"type":"COMBO","widget":{"name":"mode"},"link":null}],"outputs":[],"properties":{"cnr_id":"comfyui-geometrypack","ver":"6e8088fb3283086d421a58c3fdf5b061176519aa","Node name for S&R":"GeomPackPreviewMeshMulti"},"widgets_values":["fields","{\"show_edges\":true,\"camera_state\":\"{\\\"position\\\":[6.112168112444788,20.962661850752653,0.7300910516940223],\\\"focalPoint\\\":[2.429478704929352,15.20779800415039,-1.320433847606182],\\\"viewUp\\\":[0.15574863477274994,-0.41848455755995223,0.894769823949308]}\",\"selected_field\":\"\"}"]},{"id":11,"type":"GeomPackPreviewMeshMulti","pos":[544.0011160714283,97.48883928571423],"size":[768,702],"flags":{},"order":10,"mode":0,"inputs":[{"localized_name":"mesh_1","name":"mesh_1","type":"TRIMESH","link":13},{"localized_name":"mesh_2","name":"mesh_2","shape":7,"type":"TRIMESH","link":14},{"localized_name":"mesh_3","name":"mesh_3","shape":7,"type":"TRIMESH","link":15},{"localized_name":"mesh_4","name":"mesh_4","shape":7,"type":"TRIMESH","link":16},{"localized_name":"mode","name":"mode","shape":7,"type":"COMBO","widget":{"name":"mode"},"link":null}],"outputs":[],"properties":{"cnr_id":"comfyui-geometrypack","ver":"6e8088fb3283086d421a58c3fdf5b061176519aa","Node name for S&R":"GeomPackPreviewMeshMulti"},"widgets_values":["fields","{\"show_edges\":true,\"camera_state\":\"{\\\"position\\\":[6.112168112444788,20.962661850752653,0.7300910516940223],\\\"focalPoint\\\":[2.429478704929352,15.20779800415039,-1.320433847606182],\\\"viewUp\\\":[0.15574863477274994,-0.41848455755995223,0.894769823949308]}\",\"selected_field\":\"\"}"]}],"links":[[1,1,0,2,0,"TRIMESH"],[2,1,0,3,0,"TRIMESH"],[3,1,0,4,0,"TRIMESH"],[4,1,0,5,0,"TRIMESH"],[5,1,0,6,0,"TRIMESH"],[6,1,0,7,0,"TRIMESH"],[7,1,0,8,0,"TRIMESH"],[8,1,0,9,0,"TRIMESH"],[9,1,0,10,0,"TRIMESH"],[10,2,0,10,1,"TRIMESH"],[11,3,0,10,2,"TRIMESH"],[12,4,0,10,3,"TRIMESH"],[13,5,0,11,0,"TRIMESH"],[14,6,0,11,1,"TRIMESH"],[15,7,0,11,2,"TRIMESH"],[16,8,0,11,3,"TRIMESH"],[17,9,0,12,0,"TRIMESH"]],"groups":[],"config":{},"extra":{"ds":{"scale":0.7000000000000007,"offset":[659.2883155952372,415.7900813066036]},"frontendVersion":"1.30.6","workflowRendererVersion":"LG"},"version":0.4} \ No newline at end of file diff --git a/workflows/tests/decimate_all.json b/workflows/tests/decimate_all.json new file mode 100644 index 00000000..634b1ad9 --- /dev/null +++ b/workflows/tests/decimate_all.json @@ -0,0 +1 @@ +{"id":"a1b2c3d4-e5f6-7890-abcd-decimate00001","revision":0,"last_node_id":8,"last_link_id":13,"nodes":[{"id":1,"type":"GeomPackLoadMesh","pos":[-834.8477636718761,-155.28021972656245],"size":[270,78],"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,2,3,5,6,13]},{"localized_name":"texture","name":"texture","type":"IMAGE","links":null}],"properties":{"cnr_id":"comfyui-geometrypack","ver":"6e8088fb3283086d421a58c3fdf5b061176519aa","Node name for S&R":"GeomPackLoadMesh"},"widgets_values":["3d/Stanford_Bunny.stl"]},{"id":8,"type":"GeomPackPreviewMeshMulti","pos":[726.3024951171867,-194.2822802734373],"size":[768,702],"flags":{},"order":6,"mode":0,"inputs":[{"localized_name":"mesh_1","name":"mesh_1","type":"TRIMESH","link":13},{"localized_name":"mesh_2","name":"mesh_2","shape":7,"type":"TRIMESH","link":11},{"localized_name":"mesh_3","name":"mesh_3","shape":7,"type":"TRIMESH","link":12},{"localized_name":"mesh_4","name":"mesh_4","shape":7,"type":"TRIMESH","link":null},{"localized_name":"mode","name":"mode","shape":7,"type":"COMBO","widget":{"name":"mode"},"link":null}],"outputs":[],"properties":{"cnr_id":"comfyui-geometrypack","ver":"6e8088fb3283086d421a58c3fdf5b061176519aa","Node name for S&R":"GeomPackPreviewMeshMulti"},"widgets_values":["fields","{\"show_edges\":true,\"camera_state\":\"{\\\"position\\\":[215.82045526389112,66.18171649191358,142.99319876297514],\\\"focalPoint\\\":[30.319300651550293,1.8948078155517578,59.22008657455444],\\\"viewUp\\\":[-0.28673184554851583,-0.3398393006551081,0.8957087129634218]}\",\"selected_field\":\"\"}"]},{"id":2,"type":"GeomPackDecimateMesh","pos":[-850.15067871094,-10.46611328125014],"size":[370,222],"flags":{},"order":1,"mode":0,"inputs":[{"localized_name":"trimesh","name":"trimesh","type":"TRIMESH","link":1},{"localized_name":"backend","name":"backend","type":"COMFY_DYNAMICCOMBO_V3","widget":{"name":"backend"},"link":null},{"localized_name":"backend.target_face_count","name":"backend.target_face_count","type":"INT","widget":{"name":"backend.target_face_count"},"link":null},{"localized_name":"backend.quality_threshold","name":"backend.quality_threshold","type":"FLOAT","widget":{"name":"backend.quality_threshold"},"link":null},{"localized_name":"backend.preserve_boundary","name":"backend.preserve_boundary","type":"COMBO","widget":{"name":"backend.preserve_boundary"},"link":null},{"localized_name":"backend.preserve_normal","name":"backend.preserve_normal","type":"COMBO","widget":{"name":"backend.preserve_normal"},"link":null},{"localized_name":"backend.preserve_topology","name":"backend.preserve_topology","type":"COMBO","widget":{"name":"backend.preserve_topology"},"link":null},{"localized_name":"backend.planar_quadric","name":"backend.planar_quadric","type":"COMBO","widget":{"name":"backend.planar_quadric"},"link":null}],"outputs":[{"localized_name":"decimated_mesh","name":"decimated_mesh","type":"TRIMESH","links":[7]},{"localized_name":"info","name":"info","type":"STRING","links":null}],"properties":{"cnr_id":"comfyui-geometrypack","ver":"6e8088fb3283086d421a58c3fdf5b061176519aa","Node name for S&R":"GeomPackDecimateMesh"},"widgets_values":["quadric_edge_collapse",5000,0.3,"true","true","true","false"]},{"id":3,"type":"GeomPackDecimateMesh","pos":[-846.0558691406263,267.5156445312493],"size":[370,126],"flags":{},"order":2,"mode":0,"inputs":[{"localized_name":"trimesh","name":"trimesh","type":"TRIMESH","link":2},{"localized_name":"backend","name":"backend","type":"COMFY_DYNAMICCOMBO_V3","widget":{"name":"backend"},"link":null},{"localized_name":"backend.target_reduction","name":"backend.target_reduction","type":"FLOAT","widget":{"name":"backend.target_reduction"},"link":null},{"localized_name":"backend.aggressiveness","name":"backend.aggressiveness","type":"INT","widget":{"name":"backend.aggressiveness"},"link":null}],"outputs":[{"localized_name":"decimated_mesh","name":"decimated_mesh","type":"TRIMESH","links":[8]},{"localized_name":"info","name":"info","type":"STRING","links":null}],"properties":{"cnr_id":"comfyui-geometrypack","ver":"6e8088fb3283086d421a58c3fdf5b061176519aa","Node name for S&R":"GeomPackDecimateMesh"},"widgets_values":["fast_simplification",0.8,7]},{"id":7,"type":"GeomPackPreviewMeshMulti","pos":[-459.3555810546881,-193.33342285156226],"size":[768,702],"flags":{},"order":5,"mode":0,"inputs":[{"localized_name":"mesh_1","name":"mesh_1","type":"TRIMESH","link":6},{"localized_name":"mesh_2","name":"mesh_2","shape":7,"type":"TRIMESH","link":7},{"localized_name":"mesh_3","name":"mesh_3","shape":7,"type":"TRIMESH","link":8},{"localized_name":"mesh_4","name":"mesh_4","shape":7,"type":"TRIMESH","link":null},{"localized_name":"mode","name":"mode","shape":7,"type":"COMBO","widget":{"name":"mode"},"link":null}],"outputs":[],"properties":{"cnr_id":"comfyui-geometrypack","ver":"6e8088fb3283086d421a58c3fdf5b061176519aa","Node name for S&R":"GeomPackPreviewMeshMulti"},"widgets_values":["fields","{\"show_edges\":true,\"camera_state\":\"{\\\"position\\\":[215.82045526389112,66.18171649191358,142.99319876297514],\\\"focalPoint\\\":[30.319300651550293,1.8948078155517578,59.22008657455444],\\\"viewUp\\\":[-0.28673184554851583,-0.3398393006551081,0.8957087129634218]}\",\"selected_field\":\"\"}"]},{"id":4,"type":"GeomPackDecimateMesh","pos":[336.95061523437454,93.11665039062464],"size":[370,102],"flags":{},"order":3,"mode":0,"inputs":[{"localized_name":"trimesh","name":"trimesh","type":"TRIMESH","link":3},{"localized_name":"backend","name":"backend","type":"COMFY_DYNAMICCOMBO_V3","widget":{"name":"backend"},"link":null},{"localized_name":"backend.cluster_threshold","name":"backend.cluster_threshold","type":"FLOAT","widget":{"name":"backend.cluster_threshold"},"link":null}],"outputs":[{"localized_name":"decimated_mesh","name":"decimated_mesh","type":"TRIMESH","links":[12]},{"localized_name":"info","name":"info","type":"STRING","links":null}],"properties":{"cnr_id":"comfyui-geometrypack","ver":"6e8088fb3283086d421a58c3fdf5b061176519aa","Node name for S&R":"GeomPackDecimateMesh"},"widgets_values":["vertex_clustering",3]},{"id":6,"type":"GeomPackDecimateMesh","pos":[331.6426025390617,-110.89962402343754],"size":[370,150],"flags":{},"order":4,"mode":0,"inputs":[{"localized_name":"trimesh","name":"trimesh","type":"TRIMESH","link":5},{"localized_name":"backend","name":"backend","type":"COMFY_DYNAMICCOMBO_V3","widget":{"name":"backend"},"link":null},{"localized_name":"backend.target_reduction","name":"backend.target_reduction","type":"FLOAT","widget":{"name":"backend.target_reduction"},"link":null},{"localized_name":"backend.feature_angle","name":"backend.feature_angle","type":"FLOAT","widget":{"name":"backend.feature_angle"},"link":null},{"localized_name":"backend.preserve_topology","name":"backend.preserve_topology","type":"COMBO","widget":{"name":"backend.preserve_topology"},"link":null}],"outputs":[{"localized_name":"decimated_mesh","name":"decimated_mesh","type":"TRIMESH","links":[11]},{"localized_name":"info","name":"info","type":"STRING","links":null}],"properties":{"cnr_id":"comfyui-geometrypack","ver":"6e8088fb3283086d421a58c3fdf5b061176519aa","Node name for S&R":"GeomPackDecimateMesh"},"widgets_values":["decimate_pro",0.8,15,"true"]}],"links":[[1,1,0,2,0,"TRIMESH"],[2,1,0,3,0,"TRIMESH"],[3,1,0,4,0,"TRIMESH"],[5,1,0,6,0,"TRIMESH"],[6,1,0,7,0,"TRIMESH"],[7,2,0,7,1,"TRIMESH"],[8,3,0,7,2,"TRIMESH"],[11,6,0,8,1,"TRIMESH"],[12,4,0,8,2,"TRIMESH"],[13,1,0,8,0,"TRIMESH"]],"groups":[],"config":{},"extra":{"ds":{"scale":0.8000000000000012,"offset":[533.8795750656052,458.7658745986985]},"frontendVersion":"1.30.6","workflowRendererVersion":"LG"},"version":0.4} \ No newline at end of file diff --git a/workflows/tests/fill_holes_all.json b/workflows/tests/fill_holes_all.json new file mode 100644 index 00000000..a38fbb1c --- /dev/null +++ b/workflows/tests/fill_holes_all.json @@ -0,0 +1 @@ +{"id":"a1b2c3d4-e5f6-7890-abcd-fillholes0001","revision":0,"last_node_id":9,"last_link_id":13,"nodes":[{"id":2,"type":"GeomPackFillHoles","pos":[-400,-600],"size":[370,110],"flags":{},"order":1,"mode":0,"inputs":[{"localized_name":"mesh","name":"mesh","type":"TRIMESH","link":1},{"localized_name":"method","name":"method","type":"COMFY_DYNAMICCOMBO_V3","widget":{"name":"method"},"link":null}],"outputs":[{"localized_name":"filled_mesh","name":"filled_mesh","type":"TRIMESH","links":[8]},{"localized_name":"info","name":"info","type":"STRING","links":null}],"properties":{"cnr_id":"comfyui-geometrypack","ver":"6e8088fb3283086d421a58c3fdf5b061176519aa","Node name for S&R":"GeomPackFillHoles"},"widgets_values":["trimesh","false"]},{"id":3,"type":"GeomPackFillHoles","pos":[0,-600],"size":[370,134],"flags":{},"order":2,"mode":0,"inputs":[{"localized_name":"mesh","name":"mesh","type":"TRIMESH","link":2},{"localized_name":"method","name":"method","type":"COMFY_DYNAMICCOMBO_V3","widget":{"name":"method"},"link":null},{"localized_name":"method.maxholesize","name":"method.maxholesize","type":"INT","widget":{"name":"method.maxholesize"},"link":null}],"outputs":[{"localized_name":"filled_mesh","name":"filled_mesh","type":"TRIMESH","links":[9]},{"localized_name":"info","name":"info","type":"STRING","links":null}],"properties":{"cnr_id":"comfyui-geometrypack","ver":"6e8088fb3283086d421a58c3fdf5b061176519aa","Node name for S&R":"GeomPackFillHoles"},"widgets_values":["pymeshlab",0,"false"]},{"id":4,"type":"GeomPackFillHoles","pos":[400,-600],"size":[370,158],"flags":{},"order":3,"mode":0,"inputs":[{"localized_name":"mesh","name":"mesh","type":"TRIMESH","link":3},{"localized_name":"method","name":"method","type":"COMFY_DYNAMICCOMBO_V3","widget":{"name":"method"},"link":null},{"localized_name":"method.max_edges","name":"method.max_edges","type":"INT","widget":{"name":"method.max_edges"},"link":null},{"localized_name":"method.refine","name":"method.refine","type":"COMBO","widget":{"name":"method.refine"},"link":null}],"outputs":[{"localized_name":"filled_mesh","name":"filled_mesh","type":"TRIMESH","links":[10]},{"localized_name":"info","name":"info","type":"STRING","links":null}],"properties":{"cnr_id":"comfyui-geometrypack","ver":"6e8088fb3283086d421a58c3fdf5b061176519aa","Node name for S&R":"GeomPackFillHoles"},"widgets_values":["pymeshfix",0,"true","false"]},{"id":5,"type":"GeomPackFillHoles","pos":[-400,-300],"size":[370,110],"flags":{},"order":4,"mode":0,"inputs":[{"localized_name":"mesh","name":"mesh","type":"TRIMESH","link":4},{"localized_name":"method","name":"method","type":"COMFY_DYNAMICCOMBO_V3","widget":{"name":"method"},"link":null}],"outputs":[{"localized_name":"filled_mesh","name":"filled_mesh","type":"TRIMESH","links":[11]},{"localized_name":"info","name":"info","type":"STRING","links":null}],"properties":{"cnr_id":"comfyui-geometrypack","ver":"6e8088fb3283086d421a58c3fdf5b061176519aa","Node name for S&R":"GeomPackFillHoles"},"widgets_values":["igl_fan","false"]},{"id":6,"type":"GeomPackFillHoles","pos":[0,-300],"size":[370,134],"flags":{},"order":5,"mode":0,"inputs":[{"localized_name":"mesh","name":"mesh","type":"TRIMESH","link":5},{"localized_name":"method","name":"method","type":"COMFY_DYNAMICCOMBO_V3","widget":{"name":"method"},"link":null},{"localized_name":"method.quality","name":"method.quality","type":"COMBO","widget":{"name":"method.quality"},"link":null}],"outputs":[{"localized_name":"filled_mesh","name":"filled_mesh","type":"TRIMESH","links":[12]},{"localized_name":"info","name":"info","type":"STRING","links":null}],"properties":{"cnr_id":"comfyui-geometrypack","ver":"6e8088fb3283086d421a58c3fdf5b061176519aa","Node name for S&R":"GeomPackFillHoles"},"widgets_values":["cgal","refine","false"]},{"id":7,"type":"GeomPackFillHoles","pos":[400,-300],"size":[370,134],"flags":{},"order":6,"mode":0,"inputs":[{"localized_name":"mesh","name":"mesh","type":"TRIMESH","link":6},{"localized_name":"method","name":"method","type":"COMFY_DYNAMICCOMBO_V3","widget":{"name":"method"},"link":null},{"localized_name":"method.perimeter","name":"method.perimeter","type":"FLOAT","widget":{"name":"method.perimeter"},"link":null}],"outputs":[{"localized_name":"filled_mesh","name":"filled_mesh","type":"TRIMESH","links":[13]},{"localized_name":"info","name":"info","type":"STRING","links":null}],"properties":{"cnr_id":"comfyui-geometrypack","ver":"6e8088fb3283086d421a58c3fdf5b061176519aa","Node name for S&R":"GeomPackFillHoles"},"widgets_values":["gpu_cumesh",0.03,"false"]},{"id":8,"type":"GeomPackPreviewMeshMulti","pos":[-400,100],"size":[768,702],"flags":{},"order":7,"mode":0,"inputs":[{"localized_name":"mesh_1","name":"mesh_1","type":"TRIMESH","link":7},{"localized_name":"mesh_2","name":"mesh_2","shape":7,"type":"TRIMESH","link":8},{"localized_name":"mesh_3","name":"mesh_3","shape":7,"type":"TRIMESH","link":9},{"localized_name":"mesh_4","name":"mesh_4","shape":7,"type":"TRIMESH","link":10},{"localized_name":"mode","name":"mode","shape":7,"type":"COMBO","widget":{"name":"mode"},"link":null}],"outputs":[],"properties":{"cnr_id":"comfyui-geometrypack","ver":"6e8088fb3283086d421a58c3fdf5b061176519aa","Node name for S&R":"GeomPackPreviewMeshMulti"},"widgets_values":["fields","{\"show_edges\":true,\"camera_state\":\"{\\\"position\\\":[0.4013352586834127,-1.3034430870199674,-0.9081605838900391],\\\"focalPoint\\\":[0.000001996755599975586,-0.0636419951915741,-0.03211300075054169],\\\"viewUp\\\":[0.41668520115314117,-0.4307600853225743,0.8005118312884919]}\",\"selected_field\":\"\"}"]},{"id":9,"type":"GeomPackPreviewMeshMulti","pos":[500,100],"size":[768,702],"flags":{},"order":8,"mode":0,"inputs":[{"localized_name":"mesh_1","name":"mesh_1","type":"TRIMESH","link":11},{"localized_name":"mesh_2","name":"mesh_2","shape":7,"type":"TRIMESH","link":12},{"localized_name":"mesh_3","name":"mesh_3","shape":7,"type":"TRIMESH","link":13},{"localized_name":"mesh_4","name":"mesh_4","shape":7,"type":"TRIMESH","link":null},{"localized_name":"mode","name":"mode","shape":7,"type":"COMBO","widget":{"name":"mode"},"link":null}],"outputs":[],"properties":{"cnr_id":"comfyui-geometrypack","ver":"6e8088fb3283086d421a58c3fdf5b061176519aa","Node name for S&R":"GeomPackPreviewMeshMulti"},"widgets_values":["fields","{\"show_edges\":true,\"camera_state\":\"{\\\"position\\\":[0.4013352586834127,-1.3034430870199674,-0.9081605838900391],\\\"focalPoint\\\":[0.000001996755599975586,-0.0636419951915741,-0.03211300075054169],\\\"viewUp\\\":[0.41668520115314117,-0.4307600853225743,0.8005118312884919]}\",\"selected_field\":\"\"}"]},{"id":1,"type":"GeomPackLoadMesh","pos":[-800,-200],"size":[270,78],"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,2,3,4,5,6,7]},{"localized_name":"texture","name":"texture","type":"IMAGE","links":null}],"properties":{"cnr_id":"comfyui-geometrypack","ver":"6e8088fb3283086d421a58c3fdf5b061176519aa","Node name for S&R":"GeomPackLoadMesh"},"widgets_values":["3d/camelhead.off"]}],"links":[[1,1,0,2,0,"TRIMESH"],[2,1,0,3,0,"TRIMESH"],[3,1,0,4,0,"TRIMESH"],[4,1,0,5,0,"TRIMESH"],[5,1,0,6,0,"TRIMESH"],[6,1,0,7,0,"TRIMESH"],[7,1,0,8,0,"TRIMESH"],[8,2,0,8,1,"TRIMESH"],[9,3,0,8,2,"TRIMESH"],[10,4,0,8,3,"TRIMESH"],[11,5,0,9,0,"TRIMESH"],[12,6,0,9,1,"TRIMESH"],[13,7,0,9,2,"TRIMESH"]],"groups":[],"config":{},"extra":{"ds":{"scale":0.6611570247933886,"offset":[1231.005728515625,484.32482617187526]},"frontendVersion":"1.30.6","workflowRendererVersion":"LG"},"version":0.4} \ No newline at end of file diff --git a/workflows/tests/remeshing_all.json b/workflows/tests/remeshing_all.json index 15a089bd..0214fc4b 100644 --- a/workflows/tests/remeshing_all.json +++ b/workflows/tests/remeshing_all.json @@ -1 +1 @@ -{"id":"fc31dc6e-fd82-4f42-9d3a-57283ccfadba","revision":0,"last_node_id":55,"last_link_id":68,"nodes":[{"id":55,"type":"GeomPackPreviewMeshMulti","pos":[559.2474753616863,275.5298705805723],"size":[768,702],"flags":{},"order":19,"mode":4,"inputs":[{"localized_name":"mesh_1","name":"mesh_1","type":"TRIMESH","link":64},{"localized_name":"mesh_2","name":"mesh_2","shape":7,"type":"TRIMESH","link":62},{"localized_name":"mesh_3","name":"mesh_3","shape":7,"type":"TRIMESH","link":63},{"localized_name":"mesh_4","name":"mesh_4","shape":7,"type":"TRIMESH","link":61},{"localized_name":"mode","name":"mode","shape":7,"type":"COMBO","widget":{"name":"mode"},"link":null}],"outputs":[],"properties":{"cnr_id":"comfyui-geometrypack","ver":"8f0378ab0ec8f20aad7b96ab2b1ec39ae6f82b6f","Node name for S&R":"GeomPackPreviewMeshMulti"},"widgets_values":["fields","{\"show_edges\":true,\"camera_state\":\"{\\\"position\\\":[75.25515354619213,75.22544971314562,76.86244969670906],\\\"focalPoint\\\":[30.322647094726562,1.8909549713134766,59.220962047576904],\\\"viewUp\\\":[-0.3360775929433742,-0.02059842888042879,0.9416090251527147]}\",\"selected_field\":\"\"}"]},{"id":1,"type":"GeomPackLoadMesh","pos":[-1014.1521350641405,-579.0137321727649],"size":[270,78],"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":[37,38,41,42,44,45,46,47,56,57,58,59,65,66,67,68]},{"localized_name":"texture","name":"texture","type":"IMAGE","links":null}],"properties":{"cnr_id":"comfyui-geometrypack","ver":"253a956368261760da54dc96750c2ab0224e2b1f","Node name for S&R":"GeomPackLoadMesh","aux_id":"PozzettiAndrea/ComfyUI-GeometryPack"},"widgets_values":["3d/Stanford_Bunny.stl"]},{"id":46,"type":"GeomPackPreviewMeshMulti","pos":[-1057.238644432902,106.33224496938243],"size":[768,702],"flags":{},"order":18,"mode":0,"inputs":[{"localized_name":"mesh_1","name":"mesh_1","type":"TRIMESH","link":52},{"localized_name":"mesh_2","name":"mesh_2","shape":7,"type":"TRIMESH","link":53},{"localized_name":"mesh_3","name":"mesh_3","shape":7,"type":"TRIMESH","link":54},{"localized_name":"mesh_4","name":"mesh_4","shape":7,"type":"TRIMESH","link":55},{"localized_name":"mode","name":"mode","shape":7,"type":"COMBO","widget":{"name":"mode"},"link":null}],"outputs":[],"properties":{"cnr_id":"comfyui-geometrypack","ver":"8f0378ab0ec8f20aad7b96ab2b1ec39ae6f82b6f","Node name for S&R":"GeomPackPreviewMeshMulti"},"widgets_values":["fields","{\"show_edges\":true,\"camera_state\":\"{\\\"position\\\":[75.25515354619213,75.22544971314562,76.86244969670906],\\\"focalPoint\\\":[30.322647094726562,1.8909549713134766,59.220962047576904],\\\"viewUp\\\":[-0.3360775929433742,-0.02059842888042879,0.9416090251527147]}\",\"selected_field\":\"\"}"]},{"id":50,"type":"GeomPackRemesh","pos":[-1054.5376867889013,-409.41668100861546],"size":[366.22833806818176,290],"flags":{},"order":8,"mode":4,"inputs":[{"localized_name":"trimesh","name":"trimesh","type":"TRIMESH","link":56},{"localized_name":"backend","name":"backend","type":"COMFY_DYNAMICCOMBO_V3","widget":{"name":"backend"},"link":null},{"localized_name":"backend.pmp_min_edge","name":"backend.pmp_min_edge","type":"FLOAT","widget":{"name":"backend.pmp_min_edge"},"link":null},{"localized_name":"backend.pmp_max_edge","name":"backend.pmp_max_edge","type":"FLOAT","widget":{"name":"backend.pmp_max_edge"},"link":null},{"localized_name":"backend.pmp_approx_error","name":"backend.pmp_approx_error","type":"FLOAT","widget":{"name":"backend.pmp_approx_error"},"link":null},{"localized_name":"backend.pmp_adaptive_iterations","name":"backend.pmp_adaptive_iterations","type":"INT","widget":{"name":"backend.pmp_adaptive_iterations"},"link":null},{"localized_name":"backend.pmp_adaptive_projection","name":"backend.pmp_adaptive_projection","type":"COMBO","widget":{"name":"backend.pmp_adaptive_projection"},"link":null}],"outputs":[{"localized_name":"remeshed_mesh","name":"remeshed_mesh","type":"TRIMESH","links":[52]},{"localized_name":"info","name":"info","type":"STRING","links":null}],"properties":{"cnr_id":"comfyui-geometrypack","ver":"f4d5713f5feef867c82be5a2d67e532e7a95933b","Node name for S&R":"GeomPackRemesh"},"widgets_values":["pmp_adaptive",0.01,0.05,0.01,1,"false","true"]},{"id":49,"type":"GeomPackRemesh","pos":[-661.8442867889027,-405.9802810086156],"size":[366.22833806818176,230],"flags":{},"order":9,"mode":0,"inputs":[{"localized_name":"trimesh","name":"trimesh","type":"TRIMESH","link":57},{"localized_name":"backend","name":"backend","type":"COMFY_DYNAMICCOMBO_V3","widget":{"name":"backend"},"link":null},{"localized_name":"backend.qw_sharp_angle","name":"backend.qw_sharp_angle","type":"FLOAT","widget":{"name":"backend.qw_sharp_angle"},"link":null},{"localized_name":"backend.qw_alpha","name":"backend.qw_alpha","type":"FLOAT","widget":{"name":"backend.qw_alpha"},"link":null},{"localized_name":"backend.qw_scale_factor","name":"backend.qw_scale_factor","type":"FLOAT","widget":{"name":"backend.qw_scale_factor"},"link":null},{"localized_name":"backend.qw_remesh","name":"backend.qw_remesh","type":"COMBO","widget":{"name":"backend.qw_remesh"},"link":null},{"localized_name":"backend.qw_smooth","name":"backend.qw_smooth","type":"COMBO","widget":{"name":"backend.qw_smooth"},"link":null}],"outputs":[{"localized_name":"remeshed_mesh","name":"remeshed_mesh","type":"TRIMESH","links":[53]},{"localized_name":"info","name":"info","type":"STRING","links":null}],"properties":{"cnr_id":"comfyui-geometrypack","ver":"f4d5713f5feef867c82be5a2d67e532e7a95933b","Node name for S&R":"GeomPackRemesh"},"widgets_values":["quadwild",35,0.02,1,"true","true","false"]},{"id":48,"type":"GeomPackRemesh","pos":[-666.1485987644222,-126.89669646146771],"size":[366.22833806818176,134],"flags":{},"order":11,"mode":0,"inputs":[{"localized_name":"trimesh","name":"trimesh","type":"TRIMESH","link":59},{"localized_name":"backend","name":"backend","type":"COMFY_DYNAMICCOMBO_V3","widget":{"name":"backend"},"link":null},{"localized_name":"backend.voxel_size","name":"backend.voxel_size","type":"FLOAT","widget":{"name":"backend.voxel_size"},"link":null}],"outputs":[{"localized_name":"remeshed_mesh","name":"remeshed_mesh","type":"TRIMESH","links":[55]},{"localized_name":"info","name":"info","type":"STRING","links":null}],"properties":{"cnr_id":"comfyui-geometrypack","ver":"f4d5713f5feef867c82be5a2d67e532e7a95933b","Node name for S&R":"GeomPackRemesh"},"widgets_values":["blender_voxel",1,"false"]},{"id":47,"type":"GeomPackRemesh","pos":[-1049.4765987644212,-112.12259646146691],"size":[366.22833806818176,182],"flags":{},"order":10,"mode":0,"inputs":[{"localized_name":"trimesh","name":"trimesh","type":"TRIMESH","link":58},{"localized_name":"backend","name":"backend","type":"COMFY_DYNAMICCOMBO_V3","widget":{"name":"backend"},"link":null},{"localized_name":"backend.cgal_edge_length","name":"backend.cgal_edge_length","type":"FLOAT","widget":{"name":"backend.cgal_edge_length"},"link":null},{"localized_name":"backend.cgal_iterations","name":"backend.cgal_iterations","type":"INT","widget":{"name":"backend.cgal_iterations"},"link":null},{"localized_name":"backend.protect_boundaries","name":"backend.protect_boundaries","type":"COMBO","widget":{"name":"backend.protect_boundaries"},"link":null}],"outputs":[{"localized_name":"remeshed_mesh","name":"remeshed_mesh","type":"TRIMESH","links":[54]},{"localized_name":"info","name":"info","type":"STRING","links":null}],"properties":{"cnr_id":"comfyui-geometrypack","ver":"f4d5713f5feef867c82be5a2d67e532e7a95933b","Node name for S&R":"GeomPackRemesh"},"widgets_values":["cgal_isotropic",1,3,"true","false"]},{"id":27,"type":"GeomPackRemesh","pos":[-252.4796326065235,-967.5718475471988],"size":[366.22833806818176,206],"flags":{},"order":1,"mode":0,"inputs":[{"localized_name":"trimesh","name":"trimesh","type":"TRIMESH","link":37},{"localized_name":"backend","name":"backend","type":"COMFY_DYNAMICCOMBO_V3","widget":{"name":"backend"},"link":null},{"localized_name":"backend.target_edge_length","name":"backend.target_edge_length","type":"FLOAT","widget":{"name":"backend.target_edge_length"},"link":null},{"localized_name":"backend.iterations","name":"backend.iterations","type":"INT","widget":{"name":"backend.iterations"},"link":null},{"localized_name":"backend.feature_angle","name":"backend.feature_angle","type":"FLOAT","widget":{"name":"backend.feature_angle"},"link":null},{"localized_name":"backend.adaptive","name":"backend.adaptive","type":"COMBO","widget":{"name":"backend.adaptive"},"link":null}],"outputs":[{"localized_name":"remeshed_mesh","name":"remeshed_mesh","type":"TRIMESH","links":[39]},{"localized_name":"info","name":"info","type":"STRING","links":null}],"properties":{"cnr_id":"comfyui-geometrypack","ver":"f4d5713f5feef867c82be5a2d67e532e7a95933b","Node name for S&R":"GeomPackRemesh"},"widgets_values":["pymeshlab_isotropic",1,3,30,"false","false"]},{"id":39,"type":"GeomPackRemesh","pos":[-247.41854458204313,-710.5828630000495],"size":[366.22833806818176,182],"flags":{},"order":2,"mode":0,"inputs":[{"localized_name":"trimesh","name":"trimesh","type":"TRIMESH","link":41},{"localized_name":"backend","name":"backend","type":"COMFY_DYNAMICCOMBO_V3","widget":{"name":"backend"},"link":null},{"localized_name":"backend.target_vertex_count","name":"backend.target_vertex_count","type":"INT","widget":{"name":"backend.target_vertex_count"},"link":null},{"localized_name":"backend.deterministic","name":"backend.deterministic","type":"COMBO","widget":{"name":"backend.deterministic"},"link":null},{"localized_name":"backend.crease_angle","name":"backend.crease_angle","type":"FLOAT","widget":{"name":"backend.crease_angle"},"link":null}],"outputs":[{"localized_name":"remeshed_mesh","name":"remeshed_mesh","type":"TRIMESH","links":[40]},{"localized_name":"info","name":"info","type":"STRING","links":null}],"properties":{"cnr_id":"comfyui-geometrypack","ver":"f4d5713f5feef867c82be5a2d67e532e7a95933b","Node name for S&R":"GeomPackRemesh"},"widgets_values":["instant_meshes",5000,"true",0,"false"]},{"id":37,"type":"GeomPackRemesh","pos":[135.90945541795637,-709.2518630000504],"size":[366.22833806818176,182],"flags":{},"order":3,"mode":0,"inputs":[{"localized_name":"trimesh","name":"trimesh","type":"TRIMESH","link":42},{"localized_name":"backend","name":"backend","type":"COMFY_DYNAMICCOMBO_V3","widget":{"name":"backend"},"link":null},{"localized_name":"backend.target_face_count","name":"backend.target_face_count","type":"INT","widget":{"name":"backend.target_face_count"},"link":null},{"localized_name":"backend.preserve_sharp","name":"backend.preserve_sharp","type":"COMBO","widget":{"name":"backend.preserve_sharp"},"link":null},{"localized_name":"backend.preserve_boundary","name":"backend.preserve_boundary","type":"COMBO","widget":{"name":"backend.preserve_boundary"},"link":null}],"outputs":[{"localized_name":"remeshed_mesh","name":"remeshed_mesh","type":"TRIMESH","links":[43]},{"localized_name":"info","name":"info","type":"STRING","links":null}],"properties":{"cnr_id":"comfyui-geometrypack","ver":"f4d5713f5feef867c82be5a2d67e532e7a95933b","Node name for S&R":"GeomPackRemesh"},"widgets_values":["quadriflow",5000,"false","true","false"]},{"id":29,"type":"GeomPackPreviewMeshMulti","pos":[-250.65357747104312,-472.80821058805077],"size":[768,702],"flags":{},"order":16,"mode":0,"inputs":[{"localized_name":"mesh_1","name":"mesh_1","type":"TRIMESH","link":38},{"localized_name":"mesh_2","name":"mesh_2","shape":7,"type":"TRIMESH","link":39},{"localized_name":"mesh_3","name":"mesh_3","shape":7,"type":"TRIMESH","link":40},{"localized_name":"mesh_4","name":"mesh_4","shape":7,"type":"TRIMESH","link":43},{"localized_name":"mode","name":"mode","shape":7,"type":"COMBO","widget":{"name":"mode"},"link":null}],"outputs":[],"properties":{"cnr_id":"comfyui-geometrypack","ver":"8f0378ab0ec8f20aad7b96ab2b1ec39ae6f82b6f","Node name for S&R":"GeomPackPreviewMeshMulti"},"widgets_values":["fields","{\"show_edges\":true,\"camera_state\":\"{\\\"position\\\":[75.25515354619213,75.22544971314562,76.86244969670906],\\\"focalPoint\\\":[30.322647094726562,1.8909549713134766,59.220962047576904],\\\"viewUp\\\":[-0.3360775929433742,-0.02059842888042879,0.9416090251527147]}\",\"selected_field\":\"\"}"]},{"id":45,"type":"GeomPackRemesh","pos":[945.7681182179566,-963.8635115000499],"size":[366.22833806818176,206],"flags":{},"order":7,"mode":0,"inputs":[{"localized_name":"trimesh","name":"trimesh","type":"TRIMESH","link":47},{"localized_name":"backend","name":"backend","type":"COMFY_DYNAMICCOMBO_V3","widget":{"name":"backend"},"link":null},{"localized_name":"backend.nb_points","name":"backend.nb_points","type":"INT","widget":{"name":"backend.nb_points"},"link":null},{"localized_name":"backend.nb_lloyd_iter","name":"backend.nb_lloyd_iter","type":"INT","widget":{"name":"backend.nb_lloyd_iter"},"link":null},{"localized_name":"backend.nb_newton_iter","name":"backend.nb_newton_iter","type":"INT","widget":{"name":"backend.nb_newton_iter"},"link":null},{"localized_name":"backend.newton_m","name":"backend.newton_m","type":"INT","widget":{"name":"backend.newton_m"},"link":null}],"outputs":[{"localized_name":"remeshed_mesh","name":"remeshed_mesh","type":"TRIMESH","links":[49]},{"localized_name":"info","name":"info","type":"STRING","links":null}],"properties":{"cnr_id":"comfyui-geometrypack","ver":"f4d5713f5feef867c82be5a2d67e532e7a95933b","Node name for S&R":"GeomPackRemesh"},"widgets_values":["geogram_smooth",5000,5,30,7,"false"]},{"id":42,"type":"GeomPackRemesh","pos":[549.6658062424374,-696.6258269529004],"size":[366.22833806818176,158],"flags":{},"order":5,"mode":0,"inputs":[{"localized_name":"trimesh","name":"trimesh","type":"TRIMESH","link":45},{"localized_name":"backend","name":"backend","type":"COMFY_DYNAMICCOMBO_V3","widget":{"name":"backend"},"link":null},{"localized_name":"backend.nb_points_aniso","name":"backend.nb_points_aniso","type":"INT","widget":{"name":"backend.nb_points_aniso"},"link":null},{"localized_name":"backend.anisotropy","name":"backend.anisotropy","type":"FLOAT","widget":{"name":"backend.anisotropy"},"link":null}],"outputs":[{"localized_name":"remeshed_mesh","name":"remeshed_mesh","type":"TRIMESH","links":[50]},{"localized_name":"info","name":"info","type":"STRING","links":null}],"properties":{"cnr_id":"comfyui-geometrypack","ver":"f4d5713f5feef867c82be5a2d67e532e7a95933b","Node name for S&R":"GeomPackRemesh"},"widgets_values":["geogram_anisotropic",5000,0.04,"false"]},{"id":54,"type":"GeomPackRemesh","pos":[-229.90242463831174,281.7988805805725],"size":[366.22833806818176,230],"flags":{},"order":12,"mode":4,"inputs":[{"localized_name":"trimesh","name":"trimesh","type":"TRIMESH","link":65},{"localized_name":"backend","name":"backend","type":"COMFY_DYNAMICCOMBO_V3","widget":{"name":"backend"},"link":null},{"localized_name":"backend.pmp_min_edge","name":"backend.pmp_min_edge","type":"FLOAT","widget":{"name":"backend.pmp_min_edge"},"link":null},{"localized_name":"backend.pmp_max_edge","name":"backend.pmp_max_edge","type":"FLOAT","widget":{"name":"backend.pmp_max_edge"},"link":null},{"localized_name":"backend.pmp_approx_error","name":"backend.pmp_approx_error","type":"FLOAT","widget":{"name":"backend.pmp_approx_error"},"link":null},{"localized_name":"backend.pmp_adaptive_iterations","name":"backend.pmp_adaptive_iterations","type":"INT","widget":{"name":"backend.pmp_adaptive_iterations"},"link":null},{"localized_name":"backend.pmp_adaptive_projection","name":"backend.pmp_adaptive_projection","type":"COMBO","widget":{"name":"backend.pmp_adaptive_projection"},"link":null}],"outputs":[{"localized_name":"remeshed_mesh","name":"remeshed_mesh","type":"TRIMESH","links":[64]},{"localized_name":"info","name":"info","type":"STRING","links":null}],"properties":{"cnr_id":"comfyui-geometrypack","ver":"f4d5713f5feef867c82be5a2d67e532e7a95933b","Node name for S&R":"GeomPackRemesh"},"widgets_values":["pmp_adaptive",0.1,2,0.1,10,"true","false"]},{"id":51,"type":"GeomPackRemesh","pos":[162.79097536168717,261.0352805805725],"size":[366.22833806818176,230],"flags":{},"order":13,"mode":0,"inputs":[{"localized_name":"trimesh","name":"trimesh","type":"TRIMESH","link":66},{"localized_name":"backend","name":"backend","type":"COMFY_DYNAMICCOMBO_V3","widget":{"name":"backend"},"link":null},{"localized_name":"backend.qw_sharp_angle","name":"backend.qw_sharp_angle","type":"FLOAT","widget":{"name":"backend.qw_sharp_angle"},"link":null},{"localized_name":"backend.qw_alpha","name":"backend.qw_alpha","type":"FLOAT","widget":{"name":"backend.qw_alpha"},"link":null},{"localized_name":"backend.qw_scale_factor","name":"backend.qw_scale_factor","type":"FLOAT","widget":{"name":"backend.qw_scale_factor"},"link":null},{"localized_name":"backend.qw_remesh","name":"backend.qw_remesh","type":"COMBO","widget":{"name":"backend.qw_remesh"},"link":null},{"localized_name":"backend.qw_smooth","name":"backend.qw_smooth","type":"COMBO","widget":{"name":"backend.qw_smooth"},"link":null}],"outputs":[{"localized_name":"remeshed_mesh","name":"remeshed_mesh","type":"TRIMESH","links":[62]},{"localized_name":"info","name":"info","type":"STRING","links":null}],"properties":{"cnr_id":"comfyui-geometrypack","ver":"f4d5713f5feef867c82be5a2d67e532e7a95933b","Node name for S&R":"GeomPackRemesh"},"widgets_values":["quadwild",35,0.02,1,"true","true","false"]},{"id":52,"type":"GeomPackRemesh","pos":[158.48666338616763,540.1188651277203],"size":[366.22833806818176,134],"flags":{},"order":15,"mode":0,"inputs":[{"localized_name":"trimesh","name":"trimesh","type":"TRIMESH","link":68},{"localized_name":"backend","name":"backend","type":"COMFY_DYNAMICCOMBO_V3","widget":{"name":"backend"},"link":null},{"localized_name":"backend.voxel_size","name":"backend.voxel_size","type":"FLOAT","widget":{"name":"backend.voxel_size"},"link":null}],"outputs":[{"localized_name":"remeshed_mesh","name":"remeshed_mesh","type":"TRIMESH","links":[61]},{"localized_name":"info","name":"info","type":"STRING","links":null}],"properties":{"cnr_id":"comfyui-geometrypack","ver":"f4d5713f5feef867c82be5a2d67e532e7a95933b","Node name for S&R":"GeomPackRemesh"},"widgets_values":["blender_voxel",1,"false"]},{"id":53,"type":"GeomPackRemesh","pos":[-221.54133661383142,546.0929651277208],"size":[366.22833806818176,182],"flags":{},"order":14,"mode":0,"inputs":[{"localized_name":"trimesh","name":"trimesh","type":"TRIMESH","link":67},{"localized_name":"backend","name":"backend","type":"COMFY_DYNAMICCOMBO_V3","widget":{"name":"backend"},"link":null},{"localized_name":"backend.cgal_edge_length","name":"backend.cgal_edge_length","type":"FLOAT","widget":{"name":"backend.cgal_edge_length"},"link":null},{"localized_name":"backend.cgal_iterations","name":"backend.cgal_iterations","type":"INT","widget":{"name":"backend.cgal_iterations"},"link":null},{"localized_name":"backend.protect_boundaries","name":"backend.protect_boundaries","type":"COMBO","widget":{"name":"backend.protect_boundaries"},"link":null}],"outputs":[{"localized_name":"remeshed_mesh","name":"remeshed_mesh","type":"TRIMESH","links":[63]},{"localized_name":"info","name":"info","type":"STRING","links":null}],"properties":{"cnr_id":"comfyui-geometrypack","ver":"f4d5713f5feef867c82be5a2d67e532e7a95933b","Node name for S&R":"GeomPackRemesh"},"widgets_values":["cgal_isotropic",1,3,"true","false"]},{"id":44,"type":"GeomPackRemesh","pos":[544.604718217957,-969.7199115000496],"size":[366.22833806818176,206],"flags":{},"order":4,"mode":0,"inputs":[{"localized_name":"trimesh","name":"trimesh","type":"TRIMESH","link":44},{"localized_name":"backend","name":"backend","type":"COMFY_DYNAMICCOMBO_V3","widget":{"name":"backend"},"link":null},{"localized_name":"backend.hausd","name":"backend.hausd","type":"FLOAT","widget":{"name":"backend.hausd"},"link":null},{"localized_name":"backend.hmin","name":"backend.hmin","type":"FLOAT","widget":{"name":"backend.hmin"},"link":null},{"localized_name":"backend.hmax","name":"backend.hmax","type":"FLOAT","widget":{"name":"backend.hmax"},"link":null},{"localized_name":"backend.hgrad","name":"backend.hgrad","type":"FLOAT","widget":{"name":"backend.hgrad"},"link":null}],"outputs":[{"localized_name":"remeshed_mesh","name":"remeshed_mesh","type":"TRIMESH","links":[51]},{"localized_name":"info","name":"info","type":"STRING","links":null}],"properties":{"cnr_id":"comfyui-geometrypack","ver":"f4d5713f5feef867c82be5a2d67e532e7a95933b","Node name for S&R":"GeomPackRemesh"},"widgets_values":["mmg_adaptive",0.05,0,0,1.3,"false"]},{"id":30,"type":"GeomPackPreviewMeshMulti","pos":[550.3737605739569,-480.5909855220511],"size":[768,702],"flags":{},"order":17,"mode":0,"inputs":[{"localized_name":"mesh_1","name":"mesh_1","type":"TRIMESH","link":51},{"localized_name":"mesh_2","name":"mesh_2","shape":7,"type":"TRIMESH","link":49},{"localized_name":"mesh_3","name":"mesh_3","shape":7,"type":"TRIMESH","link":50},{"localized_name":"mesh_4","name":"mesh_4","shape":7,"type":"TRIMESH","link":48},{"localized_name":"mode","name":"mode","shape":7,"type":"COMBO","widget":{"name":"mode"},"link":null}],"outputs":[],"properties":{"cnr_id":"comfyui-geometrypack","ver":"8f0378ab0ec8f20aad7b96ab2b1ec39ae6f82b6f","Node name for S&R":"GeomPackPreviewMeshMulti"},"widgets_values":["fields","{\"show_edges\":true,\"camera_state\":\"{\\\"position\\\":[75.25515354619213,75.22544971314562,76.86244969670906],\\\"focalPoint\\\":[30.322647094726562,1.8909549713134766,59.220962047576904],\\\"viewUp\\\":[-0.3360775929433742,-0.02059842888042879,0.9416090251527147]}\",\"selected_field\":\"\"}"]},{"id":43,"type":"GeomPackRemesh","pos":[932.9938062424369,-711.3999269529013],"size":[366.22833806818176,182],"flags":{},"order":6,"mode":0,"inputs":[{"localized_name":"trimesh","name":"trimesh","type":"TRIMESH","link":46},{"localized_name":"backend","name":"backend","type":"COMFY_DYNAMICCOMBO_V3","widget":{"name":"backend"},"link":null},{"localized_name":"backend.pmp_edge_length","name":"backend.pmp_edge_length","type":"FLOAT","widget":{"name":"backend.pmp_edge_length"},"link":null},{"localized_name":"backend.pmp_iterations","name":"backend.pmp_iterations","type":"INT","widget":{"name":"backend.pmp_iterations"},"link":null},{"localized_name":"backend.pmp_use_projection","name":"backend.pmp_use_projection","type":"COMBO","widget":{"name":"backend.pmp_use_projection"},"link":null}],"outputs":[{"localized_name":"remeshed_mesh","name":"remeshed_mesh","type":"TRIMESH","links":[48]},{"localized_name":"info","name":"info","type":"STRING","links":null}],"properties":{"cnr_id":"comfyui-geometrypack","ver":"f4d5713f5feef867c82be5a2d67e532e7a95933b","Node name for S&R":"GeomPackRemesh"},"widgets_values":["pmp_uniform",1,1,"true","false"]}],"links":[[37,1,0,27,0,"TRIMESH"],[38,1,0,29,0,"TRIMESH"],[39,27,0,29,1,"TRIMESH"],[40,39,0,29,2,"TRIMESH"],[41,1,0,39,0,"TRIMESH"],[42,1,0,37,0,"TRIMESH"],[43,37,0,29,3,"TRIMESH"],[44,1,0,44,0,"TRIMESH"],[45,1,0,42,0,"TRIMESH"],[46,1,0,43,0,"TRIMESH"],[47,1,0,45,0,"TRIMESH"],[48,43,0,30,3,"TRIMESH"],[49,45,0,30,1,"TRIMESH"],[50,42,0,30,2,"TRIMESH"],[51,44,0,30,0,"TRIMESH"],[52,50,0,46,0,"TRIMESH"],[53,49,0,46,1,"TRIMESH"],[54,47,0,46,2,"TRIMESH"],[55,48,0,46,3,"TRIMESH"],[56,1,0,50,0,"TRIMESH"],[57,1,0,49,0,"TRIMESH"],[58,1,0,47,0,"TRIMESH"],[59,1,0,48,0,"TRIMESH"],[61,52,0,55,3,"TRIMESH"],[62,51,0,55,1,"TRIMESH"],[63,53,0,55,2,"TRIMESH"],[64,54,0,55,0,"TRIMESH"],[65,1,0,54,0,"TRIMESH"],[66,1,0,51,0,"TRIMESH"],[67,1,0,53,0,"TRIMESH"],[68,1,0,52,0,"TRIMESH"]],"groups":[],"config":{},"extra":{"ds":{"scale":0.8264462809917359,"offset":[246.7035416191196,890.9228611242487]},"frontendVersion":"1.30.6","workflowRendererVersion":"LG"},"version":0.4} \ No newline at end of file +{"id":"fc31dc6e-fd82-4f42-9d3a-57283ccfadba","revision":0,"last_node_id":55,"last_link_id":68,"nodes":[{"id":1,"type":"GeomPackLoadMesh","pos":[-1014.1521350641405,-579.0137321727649],"size":[270,78],"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":[37,38,41,42,44,45,46,47,56,57,58,59,65,66,67,68]},{"localized_name":"texture","name":"texture","type":"IMAGE","links":null}],"properties":{"cnr_id":"comfyui-geometrypack","ver":"253a956368261760da54dc96750c2ab0224e2b1f","Node name for S&R":"GeomPackLoadMesh","aux_id":"PozzettiAndrea/ComfyUI-GeometryPack"},"widgets_values":["3d/Stanford_Bunny.stl"]},{"id":46,"type":"GeomPackPreviewMeshMulti","pos":[-1057.238644432902,106.33224496938243],"size":[768,702],"flags":{},"order":18,"mode":0,"inputs":[{"localized_name":"mesh_1","name":"mesh_1","type":"TRIMESH","link":52},{"localized_name":"mesh_2","name":"mesh_2","shape":7,"type":"TRIMESH","link":53},{"localized_name":"mesh_3","name":"mesh_3","shape":7,"type":"TRIMESH","link":54},{"localized_name":"mesh_4","name":"mesh_4","shape":7,"type":"TRIMESH","link":55},{"localized_name":"mode","name":"mode","shape":7,"type":"COMBO","widget":{"name":"mode"},"link":null}],"outputs":[],"properties":{"cnr_id":"comfyui-geometrypack","ver":"8f0378ab0ec8f20aad7b96ab2b1ec39ae6f82b6f","Node name for S&R":"GeomPackPreviewMeshMulti"},"widgets_values":["fields","{\"show_edges\":true,\"camera_state\":\"{\\\"position\\\":[71.59053240844639,69.24440924198683,75.42363905279093],\\\"focalPoint\\\":[30.322647094726562,1.8909549713134766,59.220962047576904],\\\"viewUp\\\":[-0.3360775929433742,-0.02059842888042879,0.9416090251527147]}\",\"selected_field\":\"\"}"]},{"id":50,"type":"GeomPackRemesh","pos":[-1054.5376867889013,-409.41668100861546],"size":[366.22833806818176,290],"flags":{},"order":8,"mode":4,"inputs":[{"localized_name":"trimesh","name":"trimesh","type":"TRIMESH","link":56},{"localized_name":"backend","name":"backend","type":"COMFY_DYNAMICCOMBO_V3","widget":{"name":"backend"},"link":null},{"localized_name":"backend.pmp_min_edge","name":"backend.pmp_min_edge","type":"FLOAT","widget":{"name":"backend.pmp_min_edge"},"link":null},{"localized_name":"backend.pmp_max_edge","name":"backend.pmp_max_edge","type":"FLOAT","widget":{"name":"backend.pmp_max_edge"},"link":null},{"localized_name":"backend.pmp_approx_error","name":"backend.pmp_approx_error","type":"FLOAT","widget":{"name":"backend.pmp_approx_error"},"link":null},{"localized_name":"backend.pmp_adaptive_iterations","name":"backend.pmp_adaptive_iterations","type":"INT","widget":{"name":"backend.pmp_adaptive_iterations"},"link":null},{"localized_name":"backend.pmp_adaptive_projection","name":"backend.pmp_adaptive_projection","type":"COMBO","widget":{"name":"backend.pmp_adaptive_projection"},"link":null}],"outputs":[{"localized_name":"remeshed_mesh","name":"remeshed_mesh","type":"TRIMESH","links":[52]},{"localized_name":"info","name":"info","type":"STRING","links":null}],"properties":{"cnr_id":"comfyui-geometrypack","ver":"f4d5713f5feef867c82be5a2d67e532e7a95933b","Node name for S&R":"GeomPackRemesh"},"widgets_values":["pmp_adaptive",0.01,0.05,0.01,1,"false","true"]},{"id":49,"type":"GeomPackRemesh","pos":[-661.8442867889027,-405.9802810086156],"size":[366.22833806818176,230],"flags":{},"order":9,"mode":0,"inputs":[{"localized_name":"trimesh","name":"trimesh","type":"TRIMESH","link":57},{"localized_name":"backend","name":"backend","type":"COMFY_DYNAMICCOMBO_V3","widget":{"name":"backend"},"link":null},{"localized_name":"backend.qw_sharp_angle","name":"backend.qw_sharp_angle","type":"FLOAT","widget":{"name":"backend.qw_sharp_angle"},"link":null},{"localized_name":"backend.qw_alpha","name":"backend.qw_alpha","type":"FLOAT","widget":{"name":"backend.qw_alpha"},"link":null},{"localized_name":"backend.qw_scale_factor","name":"backend.qw_scale_factor","type":"FLOAT","widget":{"name":"backend.qw_scale_factor"},"link":null},{"localized_name":"backend.qw_remesh","name":"backend.qw_remesh","type":"COMBO","widget":{"name":"backend.qw_remesh"},"link":null},{"localized_name":"backend.qw_smooth","name":"backend.qw_smooth","type":"COMBO","widget":{"name":"backend.qw_smooth"},"link":null}],"outputs":[{"localized_name":"remeshed_mesh","name":"remeshed_mesh","type":"TRIMESH","links":[53]},{"localized_name":"info","name":"info","type":"STRING","links":null}],"properties":{"cnr_id":"comfyui-geometrypack","ver":"f4d5713f5feef867c82be5a2d67e532e7a95933b","Node name for S&R":"GeomPackRemesh"},"widgets_values":["quadwild",35,0.02,1,"true","true","false"]},{"id":48,"type":"GeomPackRemesh","pos":[-666.1485987644222,-126.89669646146771],"size":[366.22833806818176,134],"flags":{},"order":11,"mode":0,"inputs":[{"localized_name":"trimesh","name":"trimesh","type":"TRIMESH","link":59},{"localized_name":"backend","name":"backend","type":"COMFY_DYNAMICCOMBO_V3","widget":{"name":"backend"},"link":null},{"localized_name":"backend.voxel_size","name":"backend.voxel_size","type":"FLOAT","widget":{"name":"backend.voxel_size"},"link":null}],"outputs":[{"localized_name":"remeshed_mesh","name":"remeshed_mesh","type":"TRIMESH","links":[55]},{"localized_name":"info","name":"info","type":"STRING","links":null}],"properties":{"cnr_id":"comfyui-geometrypack","ver":"f4d5713f5feef867c82be5a2d67e532e7a95933b","Node name for S&R":"GeomPackRemesh"},"widgets_values":["blender_voxel",1,"false"]},{"id":27,"type":"GeomPackRemesh","pos":[-252.4796326065235,-967.5718475471988],"size":[366.22833806818176,206],"flags":{},"order":1,"mode":0,"inputs":[{"localized_name":"trimesh","name":"trimesh","type":"TRIMESH","link":37},{"localized_name":"backend","name":"backend","type":"COMFY_DYNAMICCOMBO_V3","widget":{"name":"backend"},"link":null},{"localized_name":"backend.target_edge_length","name":"backend.target_edge_length","type":"FLOAT","widget":{"name":"backend.target_edge_length"},"link":null},{"localized_name":"backend.iterations","name":"backend.iterations","type":"INT","widget":{"name":"backend.iterations"},"link":null},{"localized_name":"backend.feature_angle","name":"backend.feature_angle","type":"FLOAT","widget":{"name":"backend.feature_angle"},"link":null},{"localized_name":"backend.adaptive","name":"backend.adaptive","type":"COMBO","widget":{"name":"backend.adaptive"},"link":null}],"outputs":[{"localized_name":"remeshed_mesh","name":"remeshed_mesh","type":"TRIMESH","links":[39]},{"localized_name":"info","name":"info","type":"STRING","links":null}],"properties":{"cnr_id":"comfyui-geometrypack","ver":"f4d5713f5feef867c82be5a2d67e532e7a95933b","Node name for S&R":"GeomPackRemesh"},"widgets_values":["pymeshlab_isotropic",1,3,30,"false","false"]},{"id":37,"type":"GeomPackRemesh","pos":[135.90945541795637,-709.2518630000504],"size":[366.22833806818176,182],"flags":{},"order":3,"mode":0,"inputs":[{"localized_name":"trimesh","name":"trimesh","type":"TRIMESH","link":42},{"localized_name":"backend","name":"backend","type":"COMFY_DYNAMICCOMBO_V3","widget":{"name":"backend"},"link":null},{"localized_name":"backend.target_face_count","name":"backend.target_face_count","type":"INT","widget":{"name":"backend.target_face_count"},"link":null},{"localized_name":"backend.preserve_sharp","name":"backend.preserve_sharp","type":"COMBO","widget":{"name":"backend.preserve_sharp"},"link":null},{"localized_name":"backend.preserve_boundary","name":"backend.preserve_boundary","type":"COMBO","widget":{"name":"backend.preserve_boundary"},"link":null}],"outputs":[{"localized_name":"remeshed_mesh","name":"remeshed_mesh","type":"TRIMESH","links":[43]},{"localized_name":"info","name":"info","type":"STRING","links":null}],"properties":{"cnr_id":"comfyui-geometrypack","ver":"f4d5713f5feef867c82be5a2d67e532e7a95933b","Node name for S&R":"GeomPackRemesh"},"widgets_values":["quadriflow",5000,"false","true","false"]},{"id":29,"type":"GeomPackPreviewMeshMulti","pos":[-250.65357747104312,-472.80821058805077],"size":[768,702],"flags":{},"order":16,"mode":0,"inputs":[{"localized_name":"mesh_1","name":"mesh_1","type":"TRIMESH","link":38},{"localized_name":"mesh_2","name":"mesh_2","shape":7,"type":"TRIMESH","link":39},{"localized_name":"mesh_3","name":"mesh_3","shape":7,"type":"TRIMESH","link":40},{"localized_name":"mesh_4","name":"mesh_4","shape":7,"type":"TRIMESH","link":43},{"localized_name":"mode","name":"mode","shape":7,"type":"COMBO","widget":{"name":"mode"},"link":null}],"outputs":[],"properties":{"cnr_id":"comfyui-geometrypack","ver":"8f0378ab0ec8f20aad7b96ab2b1ec39ae6f82b6f","Node name for S&R":"GeomPackPreviewMeshMulti"},"widgets_values":["fields","{\"show_edges\":true,\"camera_state\":\"{\\\"position\\\":[71.59053240844639,69.24440924198683,75.42363905279093],\\\"focalPoint\\\":[30.322647094726562,1.8909549713134766,59.220962047576904],\\\"viewUp\\\":[-0.3360775929433742,-0.02059842888042879,0.9416090251527147]}\",\"selected_field\":\"\"}"]},{"id":45,"type":"GeomPackRemesh","pos":[945.7681182179566,-963.8635115000499],"size":[366.22833806818176,206],"flags":{},"order":7,"mode":0,"inputs":[{"localized_name":"trimesh","name":"trimesh","type":"TRIMESH","link":47},{"localized_name":"backend","name":"backend","type":"COMFY_DYNAMICCOMBO_V3","widget":{"name":"backend"},"link":null},{"localized_name":"backend.nb_points","name":"backend.nb_points","type":"INT","widget":{"name":"backend.nb_points"},"link":null},{"localized_name":"backend.nb_lloyd_iter","name":"backend.nb_lloyd_iter","type":"INT","widget":{"name":"backend.nb_lloyd_iter"},"link":null},{"localized_name":"backend.nb_newton_iter","name":"backend.nb_newton_iter","type":"INT","widget":{"name":"backend.nb_newton_iter"},"link":null},{"localized_name":"backend.newton_m","name":"backend.newton_m","type":"INT","widget":{"name":"backend.newton_m"},"link":null}],"outputs":[{"localized_name":"remeshed_mesh","name":"remeshed_mesh","type":"TRIMESH","links":[49]},{"localized_name":"info","name":"info","type":"STRING","links":null}],"properties":{"cnr_id":"comfyui-geometrypack","ver":"f4d5713f5feef867c82be5a2d67e532e7a95933b","Node name for S&R":"GeomPackRemesh"},"widgets_values":["geogram_smooth",5000,5,30,7,"false"]},{"id":42,"type":"GeomPackRemesh","pos":[549.6658062424374,-696.6258269529004],"size":[366.22833806818176,158],"flags":{},"order":5,"mode":0,"inputs":[{"localized_name":"trimesh","name":"trimesh","type":"TRIMESH","link":45},{"localized_name":"backend","name":"backend","type":"COMFY_DYNAMICCOMBO_V3","widget":{"name":"backend"},"link":null},{"localized_name":"backend.nb_points_aniso","name":"backend.nb_points_aniso","type":"INT","widget":{"name":"backend.nb_points_aniso"},"link":null},{"localized_name":"backend.anisotropy","name":"backend.anisotropy","type":"FLOAT","widget":{"name":"backend.anisotropy"},"link":null}],"outputs":[{"localized_name":"remeshed_mesh","name":"remeshed_mesh","type":"TRIMESH","links":[50]},{"localized_name":"info","name":"info","type":"STRING","links":null}],"properties":{"cnr_id":"comfyui-geometrypack","ver":"f4d5713f5feef867c82be5a2d67e532e7a95933b","Node name for S&R":"GeomPackRemesh"},"widgets_values":["geogram_anisotropic",5000,0.04,"false"]},{"id":30,"type":"GeomPackPreviewMeshMulti","pos":[550.3737605739569,-480.5909855220511],"size":[768,702],"flags":{},"order":17,"mode":0,"inputs":[{"localized_name":"mesh_1","name":"mesh_1","type":"TRIMESH","link":51},{"localized_name":"mesh_2","name":"mesh_2","shape":7,"type":"TRIMESH","link":49},{"localized_name":"mesh_3","name":"mesh_3","shape":7,"type":"TRIMESH","link":50},{"localized_name":"mesh_4","name":"mesh_4","shape":7,"type":"TRIMESH","link":48},{"localized_name":"mode","name":"mode","shape":7,"type":"COMBO","widget":{"name":"mode"},"link":null}],"outputs":[],"properties":{"cnr_id":"comfyui-geometrypack","ver":"8f0378ab0ec8f20aad7b96ab2b1ec39ae6f82b6f","Node name for S&R":"GeomPackPreviewMeshMulti"},"widgets_values":["fields","{\"show_edges\":true,\"camera_state\":\"{\\\"position\\\":[71.59053240844639,69.24440924198683,75.42363905279093],\\\"focalPoint\\\":[30.322647094726562,1.8909549713134766,59.220962047576904],\\\"viewUp\\\":[-0.3360775929433742,-0.02059842888042879,0.9416090251527147]}\",\"selected_field\":\"\"}"]},{"id":39,"type":"GeomPackRemesh","pos":[-247.41854458204313,-710.5828630000495],"size":[366.22833806818176,182],"flags":{},"order":2,"mode":0,"inputs":[{"localized_name":"trimesh","name":"trimesh","type":"TRIMESH","link":41},{"localized_name":"backend","name":"backend","type":"COMFY_DYNAMICCOMBO_V3","widget":{"name":"backend"},"link":null},{"localized_name":"backend.target_vertex_count","name":"backend.target_vertex_count","type":"INT","widget":{"name":"backend.target_vertex_count"},"link":null},{"localized_name":"backend.deterministic","name":"backend.deterministic","type":"COMBO","widget":{"name":"backend.deterministic"},"link":null},{"localized_name":"backend.crease_angle","name":"backend.crease_angle","type":"FLOAT","widget":{"name":"backend.crease_angle"},"link":null}],"outputs":[{"localized_name":"remeshed_mesh","name":"remeshed_mesh","type":"TRIMESH","links":[40]},{"localized_name":"info","name":"info","type":"STRING","links":null}],"properties":{"cnr_id":"comfyui-geometrypack","ver":"f4d5713f5feef867c82be5a2d67e532e7a95933b","Node name for S&R":"GeomPackRemesh"},"widgets_values":["instant_meshes",5000,"true",0,"false"]},{"id":43,"type":"GeomPackRemesh","pos":[932.9938062424369,-711.3999269529013],"size":[366.22833806818176,182],"flags":{},"order":6,"mode":4,"inputs":[{"localized_name":"trimesh","name":"trimesh","type":"TRIMESH","link":46},{"localized_name":"backend","name":"backend","type":"COMFY_DYNAMICCOMBO_V3","widget":{"name":"backend"},"link":null},{"localized_name":"backend.pmp_edge_length","name":"backend.pmp_edge_length","type":"FLOAT","widget":{"name":"backend.pmp_edge_length"},"link":null},{"localized_name":"backend.pmp_iterations","name":"backend.pmp_iterations","type":"INT","widget":{"name":"backend.pmp_iterations"},"link":null},{"localized_name":"backend.pmp_use_projection","name":"backend.pmp_use_projection","type":"COMBO","widget":{"name":"backend.pmp_use_projection"},"link":null}],"outputs":[{"localized_name":"remeshed_mesh","name":"remeshed_mesh","type":"TRIMESH","links":[48]},{"localized_name":"info","name":"info","type":"STRING","links":null}],"properties":{"cnr_id":"comfyui-geometrypack","ver":"f4d5713f5feef867c82be5a2d67e532e7a95933b","Node name for S&R":"GeomPackRemesh"},"widgets_values":["pmp_uniform",1,1,"true","false"]},{"id":44,"type":"GeomPackRemesh","pos":[544.604718217957,-969.7199115000496],"size":[366.22833806818176,206],"flags":{},"order":4,"mode":0,"inputs":[{"localized_name":"trimesh","name":"trimesh","type":"TRIMESH","link":44},{"localized_name":"backend","name":"backend","type":"COMFY_DYNAMICCOMBO_V3","widget":{"name":"backend"},"link":null},{"localized_name":"backend.hausd","name":"backend.hausd","type":"FLOAT","widget":{"name":"backend.hausd"},"link":null},{"localized_name":"backend.hmin","name":"backend.hmin","type":"FLOAT","widget":{"name":"backend.hmin"},"link":null},{"localized_name":"backend.hmax","name":"backend.hmax","type":"FLOAT","widget":{"name":"backend.hmax"},"link":null},{"localized_name":"backend.hgrad","name":"backend.hgrad","type":"FLOAT","widget":{"name":"backend.hgrad"},"link":null}],"outputs":[{"localized_name":"remeshed_mesh","name":"remeshed_mesh","type":"TRIMESH","links":[51]},{"localized_name":"info","name":"info","type":"STRING","links":null}],"properties":{"cnr_id":"comfyui-geometrypack","ver":"f4d5713f5feef867c82be5a2d67e532e7a95933b","Node name for S&R":"GeomPackRemesh"},"widgets_values":["mmg_adaptive",0.05,0,0,1.3,"false"]},{"id":47,"type":"GeomPackRemesh","pos":[-1049.4765987644212,-112.12259646146691],"size":[366.22833806818176,182],"flags":{},"order":10,"mode":0,"inputs":[{"localized_name":"trimesh","name":"trimesh","type":"TRIMESH","link":58},{"localized_name":"backend","name":"backend","type":"COMFY_DYNAMICCOMBO_V3","widget":{"name":"backend"},"link":null},{"localized_name":"backend.cgal_edge_length","name":"backend.cgal_edge_length","type":"FLOAT","widget":{"name":"backend.cgal_edge_length"},"link":null},{"localized_name":"backend.cgal_iterations","name":"backend.cgal_iterations","type":"INT","widget":{"name":"backend.cgal_iterations"},"link":null},{"localized_name":"backend.protect_boundaries","name":"backend.protect_boundaries","type":"COMBO","widget":{"name":"backend.protect_boundaries"},"link":null}],"outputs":[{"localized_name":"remeshed_mesh","name":"remeshed_mesh","type":"TRIMESH","links":[54]},{"localized_name":"info","name":"info","type":"STRING","links":null}],"properties":{"cnr_id":"comfyui-geometrypack","ver":"f4d5713f5feef867c82be5a2d67e532e7a95933b","Node name for S&R":"GeomPackRemesh"},"widgets_values":["cgal_isotropic",1,3,"true","false"]},{"id":54,"type":"GeomPackRemesh","pos":[-229.90242463831174,281.7988805805725],"size":[366.22833806818176,158],"flags":{},"order":12,"mode":0,"inputs":[{"localized_name":"trimesh","name":"trimesh","type":"TRIMESH","link":65},{"localized_name":"backend","name":"backend","type":"COMFY_DYNAMICCOMBO_V3","widget":{"name":"backend"},"link":null},{"localized_name":"backend.octree_depth","name":"backend.octree_depth","type":"INT","widget":{"name":"backend.octree_depth"},"link":null},{"localized_name":"backend.scale","name":"backend.scale","type":"FLOAT","widget":{"name":"backend.scale"},"link":null}],"outputs":[{"localized_name":"remeshed_mesh","name":"remeshed_mesh","type":"TRIMESH","links":[64]},{"localized_name":"info","name":"info","type":"STRING","links":null}],"properties":{"cnr_id":"comfyui-geometrypack","ver":"f4d5713f5feef867c82be5a2d67e532e7a95933b","Node name for S&R":"GeomPackRemesh"},"widgets_values":["blender_smooth",6,0.9,"false"]},{"id":51,"type":"GeomPackRemesh","pos":[162.79097536168717,261.0352805805725],"size":[366.22833806818176,182],"flags":{},"order":13,"mode":0,"inputs":[{"localized_name":"trimesh","name":"trimesh","type":"TRIMESH","link":66},{"localized_name":"backend","name":"backend","type":"COMFY_DYNAMICCOMBO_V3","widget":{"name":"backend"},"link":null},{"localized_name":"backend.octree_depth","name":"backend.octree_depth","type":"INT","widget":{"name":"backend.octree_depth"},"link":null},{"localized_name":"backend.scale","name":"backend.scale","type":"FLOAT","widget":{"name":"backend.scale"},"link":null},{"localized_name":"backend.sharpness","name":"backend.sharpness","type":"FLOAT","widget":{"name":"backend.sharpness"},"link":null}],"outputs":[{"localized_name":"remeshed_mesh","name":"remeshed_mesh","type":"TRIMESH","links":[62]},{"localized_name":"info","name":"info","type":"STRING","links":null}],"properties":{"cnr_id":"comfyui-geometrypack","ver":"f4d5713f5feef867c82be5a2d67e532e7a95933b","Node name for S&R":"GeomPackRemesh"},"widgets_values":["blender_sharp",6,0.9,1,"false"]},{"id":53,"type":"GeomPackRemesh","pos":[-221.54133661383142,546.0929651277208],"size":[366.22833806818176,158],"flags":{},"order":14,"mode":0,"inputs":[{"localized_name":"trimesh","name":"trimesh","type":"TRIMESH","link":67},{"localized_name":"backend","name":"backend","type":"COMFY_DYNAMICCOMBO_V3","widget":{"name":"backend"},"link":null},{"localized_name":"backend.octree_depth","name":"backend.octree_depth","type":"INT","widget":{"name":"backend.octree_depth"},"link":null},{"localized_name":"backend.scale","name":"backend.scale","type":"FLOAT","widget":{"name":"backend.scale"},"link":null}],"outputs":[{"localized_name":"remeshed_mesh","name":"remeshed_mesh","type":"TRIMESH","links":[63]},{"localized_name":"info","name":"info","type":"STRING","links":null}],"properties":{"cnr_id":"comfyui-geometrypack","ver":"f4d5713f5feef867c82be5a2d67e532e7a95933b","Node name for S&R":"GeomPackRemesh"},"widgets_values":["blender_blocks",6,0.9,"false"]},{"id":52,"type":"GeomPackRemesh","pos":[158.48666338616763,540.1188651277203],"size":[366.22833806818176,158],"flags":{},"order":15,"mode":0,"inputs":[{"localized_name":"trimesh","name":"trimesh","type":"TRIMESH","link":68},{"localized_name":"backend","name":"backend","type":"COMFY_DYNAMICCOMBO_V3","widget":{"name":"backend"},"link":null},{"localized_name":"backend.gpu_target_face_count","name":"backend.gpu_target_face_count","type":"INT","widget":{"name":"backend.gpu_target_face_count"},"link":null},{"localized_name":"backend.remesh_band","name":"backend.remesh_band","type":"FLOAT","widget":{"name":"backend.remesh_band"},"link":null}],"outputs":[{"localized_name":"remeshed_mesh","name":"remeshed_mesh","type":"TRIMESH","links":[61]},{"localized_name":"info","name":"info","type":"STRING","links":null}],"properties":{"cnr_id":"comfyui-geometrypack","ver":"f4d5713f5feef867c82be5a2d67e532e7a95933b","Node name for S&R":"GeomPackRemesh"},"widgets_values":["gpu_cumesh",500000,1,"false"]},{"id":55,"type":"GeomPackPreviewMeshMulti","pos":[559.2474753616863,275.5298705805723],"size":[768,702],"flags":{},"order":19,"mode":0,"inputs":[{"localized_name":"mesh_1","name":"mesh_1","type":"TRIMESH","link":64},{"localized_name":"mesh_2","name":"mesh_2","shape":7,"type":"TRIMESH","link":62},{"localized_name":"mesh_3","name":"mesh_3","shape":7,"type":"TRIMESH","link":63},{"localized_name":"mesh_4","name":"mesh_4","shape":7,"type":"TRIMESH","link":61},{"localized_name":"mode","name":"mode","shape":7,"type":"COMBO","widget":{"name":"mode"},"link":null}],"outputs":[],"properties":{"cnr_id":"comfyui-geometrypack","ver":"8f0378ab0ec8f20aad7b96ab2b1ec39ae6f82b6f","Node name for S&R":"GeomPackPreviewMeshMulti"},"widgets_values":["fields","{\"show_edges\":true,\"camera_state\":\"{\\\"position\\\":[71.59053240844639,69.24440924198683,75.42363905279093],\\\"focalPoint\\\":[30.322647094726562,1.8909549713134766,59.220962047576904],\\\"viewUp\\\":[-0.3360775929433742,-0.02059842888042879,0.9416090251527147]}\",\"selected_field\":\"\"}"]}],"links":[[37,1,0,27,0,"TRIMESH"],[38,1,0,29,0,"TRIMESH"],[39,27,0,29,1,"TRIMESH"],[40,39,0,29,2,"TRIMESH"],[41,1,0,39,0,"TRIMESH"],[42,1,0,37,0,"TRIMESH"],[43,37,0,29,3,"TRIMESH"],[44,1,0,44,0,"TRIMESH"],[45,1,0,42,0,"TRIMESH"],[46,1,0,43,0,"TRIMESH"],[47,1,0,45,0,"TRIMESH"],[48,43,0,30,3,"TRIMESH"],[49,45,0,30,1,"TRIMESH"],[50,42,0,30,2,"TRIMESH"],[51,44,0,30,0,"TRIMESH"],[52,50,0,46,0,"TRIMESH"],[53,49,0,46,1,"TRIMESH"],[54,47,0,46,2,"TRIMESH"],[55,48,0,46,3,"TRIMESH"],[56,1,0,50,0,"TRIMESH"],[57,1,0,49,0,"TRIMESH"],[58,1,0,47,0,"TRIMESH"],[59,1,0,48,0,"TRIMESH"],[61,52,0,55,3,"TRIMESH"],[62,51,0,55,1,"TRIMESH"],[63,53,0,55,2,"TRIMESH"],[64,54,0,55,0,"TRIMESH"],[65,1,0,54,0,"TRIMESH"],[66,1,0,51,0,"TRIMESH"],[67,1,0,53,0,"TRIMESH"],[68,1,0,52,0,"TRIMESH"]],"groups":[],"config":{},"extra":{"ds":{"scale":0.620921323059155,"offset":[879.8499557246013,615.3931984957435]},"frontendVersion":"1.30.6","workflowRendererVersion":"LG"},"version":0.4} \ No newline at end of file diff --git a/workflows/tests/sharpen_all.json b/workflows/tests/sharpen_all.json new file mode 100644 index 00000000..e275ee65 --- /dev/null +++ b/workflows/tests/sharpen_all.json @@ -0,0 +1 @@ +{"id":"a1b2c3d4-e5f6-7890-abcd-sharpen000001","revision":0,"last_node_id":12,"last_link_id":17,"nodes":[{"id":2,"type":"GeomPackSharpenMesh","pos":[-400,-900],"size":[370,126],"flags":{},"order":1,"mode":0,"inputs":[{"localized_name":"trimesh","name":"trimesh","type":"TRIMESH","link":1},{"localized_name":"backend","name":"backend","type":"COMFY_DYNAMICCOMBO_V3","widget":{"name":"backend"},"link":null},{"localized_name":"backend.smooth_steps","name":"backend.smooth_steps","type":"INT","widget":{"name":"backend.smooth_steps"},"link":null},{"localized_name":"backend.normal_threshold","name":"backend.normal_threshold","type":"FLOAT","widget":{"name":"backend.normal_threshold"},"link":null}],"outputs":[{"localized_name":"sharpened_mesh","name":"sharpened_mesh","type":"TRIMESH","links":[10]},{"localized_name":"info","name":"info","type":"STRING","links":null}],"properties":{"cnr_id":"comfyui-geometrypack","ver":"6e8088fb3283086d421a58c3fdf5b061176519aa","Node name for S&R":"GeomPackSharpenMesh"},"widgets_values":["two_step",3,60]},{"id":3,"type":"GeomPackSharpenMesh","pos":[0,-900],"size":[370,126],"flags":{},"order":2,"mode":0,"inputs":[{"localized_name":"trimesh","name":"trimesh","type":"TRIMESH","link":2},{"localized_name":"backend","name":"backend","type":"COMFY_DYNAMICCOMBO_V3","widget":{"name":"backend"},"link":null},{"localized_name":"backend.weight","name":"backend.weight","type":"FLOAT","widget":{"name":"backend.weight"},"link":null},{"localized_name":"backend.iterations","name":"backend.iterations","type":"INT","widget":{"name":"backend.iterations"},"link":null}],"outputs":[{"localized_name":"sharpened_mesh","name":"sharpened_mesh","type":"TRIMESH","links":[11]},{"localized_name":"info","name":"info","type":"STRING","links":null}],"properties":{"cnr_id":"comfyui-geometrypack","ver":"6e8088fb3283086d421a58c3fdf5b061176519aa","Node name for S&R":"GeomPackSharpenMesh"},"widgets_values":["unsharp_mask",0.3,5]},{"id":5,"type":"GeomPackSharpenMesh","pos":[-400,-600],"size":[370,150],"flags":{},"order":4,"mode":0,"inputs":[{"localized_name":"trimesh","name":"trimesh","type":"TRIMESH","link":4},{"localized_name":"backend","name":"backend","type":"COMFY_DYNAMICCOMBO_V3","widget":{"name":"backend"},"link":null},{"localized_name":"backend.alpha","name":"backend.alpha","type":"FLOAT","widget":{"name":"backend.alpha"},"link":null},{"localized_name":"backend.beta","name":"backend.beta","type":"FLOAT","widget":{"name":"backend.beta"},"link":null},{"localized_name":"backend.iterations","name":"backend.iterations","type":"INT","widget":{"name":"backend.iterations"},"link":null}],"outputs":[{"localized_name":"sharpened_mesh","name":"sharpened_mesh","type":"TRIMESH","links":[13]},{"localized_name":"info","name":"info","type":"STRING","links":null}],"properties":{"cnr_id":"comfyui-geometrypack","ver":"6e8088fb3283086d421a58c3fdf5b061176519aa","Node name for S&R":"GeomPackSharpenMesh"},"widgets_values":["l0_minimize",0.001,2,10]},{"id":10,"type":"GeomPackPreviewMeshMulti","pos":[-400,100],"size":[768,702],"flags":{},"order":9,"mode":0,"inputs":[{"localized_name":"mesh_1","name":"mesh_1","type":"TRIMESH","link":9},{"localized_name":"mesh_2","name":"mesh_2","shape":7,"type":"TRIMESH","link":10},{"localized_name":"mesh_3","name":"mesh_3","shape":7,"type":"TRIMESH","link":11},{"localized_name":"mesh_4","name":"mesh_4","shape":7,"type":"TRIMESH","link":12},{"localized_name":"mode","name":"mode","shape":7,"type":"COMBO","widget":{"name":"mode"},"link":null}],"outputs":[],"properties":{"cnr_id":"comfyui-geometrypack","ver":"6e8088fb3283086d421a58c3fdf5b061176519aa","Node name for S&R":"GeomPackPreviewMeshMulti"},"widgets_values":["fields","{\"show_edges\":true,\"camera_state\":\"{\\\"position\\\":[6.112168112444788,20.962661850752653,0.7300910516940223],\\\"focalPoint\\\":[2.429478704929352,15.20779800415039,-1.320433847606182],\\\"viewUp\\\":[0.15574863477274994,-0.41848455755995223,0.894769823949308]}\",\"selected_field\":\"\"}"]},{"id":1,"type":"GeomPackLoadMesh","pos":[-800,-400],"size":[270,78],"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,2,3,4,5,6,7,8,9]},{"localized_name":"texture","name":"texture","type":"IMAGE","links":null}],"properties":{"cnr_id":"comfyui-geometrypack","ver":"6e8088fb3283086d421a58c3fdf5b061176519aa","Node name for S&R":"GeomPackLoadMesh"},"widgets_values":["3d/noisy/Fandisk0.3/Noisy.obj"]},{"id":9,"type":"GeomPackSharpenMesh","pos":[0,-300],"size":[370,126],"flags":{},"order":8,"mode":0,"inputs":[{"localized_name":"trimesh","name":"trimesh","type":"TRIMESH","link":8},{"localized_name":"backend","name":"backend","type":"COMFY_DYNAMICCOMBO_V3","widget":{"name":"backend"},"link":null},{"localized_name":"backend.sigma_f","name":"backend.sigma_f","type":"FLOAT","widget":{"name":"backend.sigma_f"},"link":null},{"localized_name":"backend.sigma_g","name":"backend.sigma_g","type":"FLOAT","widget":{"name":"backend.sigma_g"},"link":null}],"outputs":[{"localized_name":"sharpened_mesh","name":"sharpened_mesh","type":"TRIMESH","links":[17]},{"localized_name":"info","name":"info","type":"STRING","links":null}],"properties":{"cnr_id":"comfyui-geometrypack","ver":"6e8088fb3283086d421a58c3fdf5b061176519aa","Node name for S&R":"GeomPackSharpenMesh"},"widgets_values":["non_iterative",1,1]},{"id":4,"type":"GeomPackSharpenMesh","pos":[400,-900],"size":[370,126],"flags":{},"order":3,"mode":4,"inputs":[{"localized_name":"trimesh","name":"trimesh","type":"TRIMESH","link":3},{"localized_name":"backend","name":"backend","type":"COMFY_DYNAMICCOMBO_V3","widget":{"name":"backend"},"link":null},{"localized_name":"backend.lambda_","name":"backend.lambda_","type":"FLOAT","widget":{"name":"backend.lambda_"},"link":null},{"localized_name":"backend.iterations","name":"backend.iterations","type":"INT","widget":{"name":"backend.iterations"},"link":null}],"outputs":[{"localized_name":"sharpened_mesh","name":"sharpened_mesh","type":"TRIMESH","links":[12]},{"localized_name":"info","name":"info","type":"STRING","links":null}],"properties":{"cnr_id":"comfyui-geometrypack","ver":"6e8088fb3283086d421a58c3fdf5b061176519aa","Node name for S&R":"GeomPackSharpenMesh"},"widgets_values":["libigl_unsharp",0.5,3]},{"id":7,"type":"GeomPackSharpenMesh","pos":[400,-600],"size":[370,150],"flags":{},"order":6,"mode":4,"inputs":[{"localized_name":"trimesh","name":"trimesh","type":"TRIMESH","link":6},{"localized_name":"backend","name":"backend","type":"COMFY_DYNAMICCOMBO_V3","widget":{"name":"backend"},"link":null},{"localized_name":"backend.num_proxies","name":"backend.num_proxies","type":"INT","widget":{"name":"backend.num_proxies"},"link":null},{"localized_name":"backend.lloyd_iterations","name":"backend.lloyd_iterations","type":"INT","widget":{"name":"backend.lloyd_iterations"},"link":null},{"localized_name":"backend.snap_strength","name":"backend.snap_strength","type":"FLOAT","widget":{"name":"backend.snap_strength"},"link":null}],"outputs":[{"localized_name":"sharpened_mesh","name":"sharpened_mesh","type":"TRIMESH","links":[15]},{"localized_name":"info","name":"info","type":"STRING","links":null}],"properties":{"cnr_id":"comfyui-geometrypack","ver":"6e8088fb3283086d421a58c3fdf5b061176519aa","Node name for S&R":"GeomPackSharpenMesh"},"widgets_values":["vsa_snap",20,10,1]},{"id":6,"type":"GeomPackSharpenMesh","pos":[-14.436383928571438,-609.2689732142853],"size":[370,174],"flags":{},"order":5,"mode":0,"inputs":[{"localized_name":"trimesh","name":"trimesh","type":"TRIMESH","link":5},{"localized_name":"backend","name":"backend","type":"COMFY_DYNAMICCOMBO_V3","widget":{"name":"backend"},"link":null},{"localized_name":"backend.normal_iterations","name":"backend.normal_iterations","type":"INT","widget":{"name":"backend.normal_iterations"},"link":null},{"localized_name":"backend.vertex_iterations","name":"backend.vertex_iterations","type":"INT","widget":{"name":"backend.vertex_iterations"},"link":null},{"localized_name":"backend.sigma_s","name":"backend.sigma_s","type":"FLOAT","widget":{"name":"backend.sigma_s"},"link":null},{"localized_name":"backend.sigma_r","name":"backend.sigma_r","type":"FLOAT","widget":{"name":"backend.sigma_r"},"link":null}],"outputs":[{"localized_name":"sharpened_mesh","name":"sharpened_mesh","type":"TRIMESH","links":[14]},{"localized_name":"info","name":"info","type":"STRING","links":null}],"properties":{"cnr_id":"comfyui-geometrypack","ver":"6e8088fb3283086d421a58c3fdf5b061176519aa","Node name for S&R":"GeomPackSharpenMesh"},"widgets_values":["guided_normal",5,10,1,0.35]},{"id":8,"type":"GeomPackSharpenMesh","pos":[-386.31138392857144,-305.70312499999994],"size":[370,150],"flags":{},"order":7,"mode":0,"inputs":[{"localized_name":"trimesh","name":"trimesh","type":"TRIMESH","link":7},{"localized_name":"backend","name":"backend","type":"COMFY_DYNAMICCOMBO_V3","widget":{"name":"backend"},"link":null},{"localized_name":"backend.threshold_T","name":"backend.threshold_T","type":"FLOAT","widget":{"name":"backend.threshold_T"},"link":null},{"localized_name":"backend.normal_iterations","name":"backend.normal_iterations","type":"INT","widget":{"name":"backend.normal_iterations"},"link":null},{"localized_name":"backend.vertex_iterations","name":"backend.vertex_iterations","type":"INT","widget":{"name":"backend.vertex_iterations"},"link":null}],"outputs":[{"localized_name":"sharpened_mesh","name":"sharpened_mesh","type":"TRIMESH","links":[16]},{"localized_name":"info","name":"info","type":"STRING","links":null}],"properties":{"cnr_id":"comfyui-geometrypack","ver":"6e8088fb3283086d421a58c3fdf5b061176519aa","Node name for S&R":"GeomPackSharpenMesh"},"widgets_values":["fast_effective",0.5,20,50]},{"id":12,"type":"GeomPackPreviewMeshMulti","pos":[1383.9397321428569,83.90066964285717],"size":[768,702],"flags":{},"order":11,"mode":0,"inputs":[{"localized_name":"mesh_1","name":"mesh_1","type":"TRIMESH","link":17},{"localized_name":"mesh_2","name":"mesh_2","shape":7,"type":"TRIMESH","link":null},{"localized_name":"mesh_3","name":"mesh_3","shape":7,"type":"TRIMESH","link":null},{"localized_name":"mesh_4","name":"mesh_4","shape":7,"type":"TRIMESH","link":null},{"localized_name":"mode","name":"mode","shape":7,"type":"COMBO","widget":{"name":"mode"},"link":null}],"outputs":[],"properties":{"cnr_id":"comfyui-geometrypack","ver":"6e8088fb3283086d421a58c3fdf5b061176519aa","Node name for S&R":"GeomPackPreviewMeshMulti"},"widgets_values":["fields","{\"show_edges\":true,\"camera_state\":\"{\\\"position\\\":[6.112168112444788,20.962661850752653,0.7300910516940223],\\\"focalPoint\\\":[2.429478704929352,15.20779800415039,-1.320433847606182],\\\"viewUp\\\":[0.15574863477274994,-0.41848455755995223,0.894769823949308]}\",\"selected_field\":\"\"}"]},{"id":11,"type":"GeomPackPreviewMeshMulti","pos":[544.0011160714283,97.48883928571423],"size":[768,702],"flags":{},"order":10,"mode":0,"inputs":[{"localized_name":"mesh_1","name":"mesh_1","type":"TRIMESH","link":13},{"localized_name":"mesh_2","name":"mesh_2","shape":7,"type":"TRIMESH","link":14},{"localized_name":"mesh_3","name":"mesh_3","shape":7,"type":"TRIMESH","link":15},{"localized_name":"mesh_4","name":"mesh_4","shape":7,"type":"TRIMESH","link":16},{"localized_name":"mode","name":"mode","shape":7,"type":"COMBO","widget":{"name":"mode"},"link":null}],"outputs":[],"properties":{"cnr_id":"comfyui-geometrypack","ver":"6e8088fb3283086d421a58c3fdf5b061176519aa","Node name for S&R":"GeomPackPreviewMeshMulti"},"widgets_values":["fields","{\"show_edges\":true,\"camera_state\":\"{\\\"position\\\":[6.112168112444788,20.962661850752653,0.7300910516940223],\\\"focalPoint\\\":[2.429478704929352,15.20779800415039,-1.320433847606182],\\\"viewUp\\\":[0.15574863477274994,-0.41848455755995223,0.894769823949308]}\",\"selected_field\":\"\"}"]}],"links":[[1,1,0,2,0,"TRIMESH"],[2,1,0,3,0,"TRIMESH"],[3,1,0,4,0,"TRIMESH"],[4,1,0,5,0,"TRIMESH"],[5,1,0,6,0,"TRIMESH"],[6,1,0,7,0,"TRIMESH"],[7,1,0,8,0,"TRIMESH"],[8,1,0,9,0,"TRIMESH"],[9,1,0,10,0,"TRIMESH"],[10,2,0,10,1,"TRIMESH"],[11,3,0,10,2,"TRIMESH"],[12,4,0,10,3,"TRIMESH"],[13,5,0,11,0,"TRIMESH"],[14,6,0,11,1,"TRIMESH"],[15,7,0,11,2,"TRIMESH"],[16,8,0,11,3,"TRIMESH"],[17,9,0,12,0,"TRIMESH"]],"groups":[],"config":{},"extra":{"ds":{"scale":0.7000000000000007,"offset":[659.2883155952372,415.7900813066036]},"frontendVersion":"1.30.6","workflowRendererVersion":"LG"},"version":0.4} \ No newline at end of file