operation nodes
This commit is contained in:
@@ -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,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']
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)"}
|
||||
|
||||
@@ -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']
|
||||
@@ -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']
|
||||
@@ -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)
|
||||
@@ -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)"}
|
||||
@@ -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)"}
|
||||
@@ -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)"}
|
||||
@@ -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']
|
||||
|
||||
@@ -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']
|
||||
@@ -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)"}
|
||||
@@ -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']
|
||||
|
||||
@@ -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']
|
||||
|
||||
@@ -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)"}
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
@@ -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)"}
|
||||
@@ -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,
|
||||
|
||||
@@ -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)"}
|
||||
@@ -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)"}
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)"}
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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']
|
||||
@@ -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)"}
|
||||
@@ -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)"}
|
||||
@@ -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)"}
|
||||
@@ -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
|
||||
|
||||
@@ -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']
|
||||
|
||||
+35
-999
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
@@ -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)"}
|
||||
@@ -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']
|
||||
|
||||
@@ -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']
|
||||
@@ -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)"}
|
||||
@@ -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)"}
|
||||
@@ -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)"}
|
||||
@@ -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
@@ -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
Reference in New Issue
Block a user