operation nodes

This commit is contained in:
PozzettiAndrea
2026-03-05 20:39:51 +01:00
parent 6e8088fb32
commit 224d843d5a
86 changed files with 5997 additions and 3353 deletions
+17
View File
@@ -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/).
+3
View File
@@ -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']
+1 -1
View File
@@ -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
+42 -77
View File
@@ -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)"}
+16
View File
@@ -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']
+24
View File
@@ -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']
+34
View File
@@ -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)
+112
View File
@@ -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)"}
@@ -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)"}
+129
View File
@@ -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)"}
+109
View File
@@ -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)"}
+3
View File
@@ -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']
+14
View File
@@ -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']
+149
View File
@@ -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)"}
+3
View File
@@ -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']
+5 -1
View File
@@ -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']
+16
View File
@@ -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']
@@ -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)"}
+48 -92
View File
@@ -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",
}
+4
View File
@@ -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']
@@ -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']
@@ -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)"}
@@ -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)"}
@@ -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)"}
@@ -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)"}
+32 -362
View File
@@ -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,
+16
View File
@@ -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']
@@ -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)"}
+3
View File
@@ -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']
@@ -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']
@@ -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)"}
@@ -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)"}
@@ -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)"}
@@ -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)"}
@@ -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)"}
+70 -397
View File
@@ -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,
}
+3
View File
@@ -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)
+40 -263
View File
@@ -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,
}
@@ -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']
@@ -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)"}
@@ -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)"}
@@ -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)"}
@@ -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)"}
@@ -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)"}
+4 -2
View File
@@ -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,
}
+26
View File
@@ -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']
+26
View File
@@ -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
@@ -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)"}
+77
View File
@@ -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)"}
@@ -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)"}
+73
View File
@@ -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)"}
+58 -160
View File
@@ -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
+8
View File
@@ -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']
File diff suppressed because it is too large Load Diff
@@ -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']
@@ -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
@@ -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)"}
@@ -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)"}
@@ -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)"}
@@ -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)"}
@@ -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)"}
@@ -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)"}
@@ -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)"}
@@ -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)"}
+31 -251
View File
@@ -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,
@@ -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']
@@ -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)"}
@@ -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)"}
@@ -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)"}
@@ -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)"}
@@ -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)"}
+7 -3
View File
@@ -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']
+24
View File
@@ -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']
+121
View File
@@ -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)"}
+99
View File
@@ -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)"}
+94
View File
@@ -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)"}
+93
View File
@@ -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)"}
+39 -744
View File
@@ -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,
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long