diff --git a/README.md b/README.md index 752e53fc..2139213b 100644 --- a/README.md +++ b/README.md @@ -4,8 +4,9 @@ Professional geometry processing nodes for ComfyUI. Load, analyze, remesh, unwrap, and visualize 3D meshes directly in your workflows. -  + + ## Features diff --git a/docs/remeshing.png b/docs/remeshing.png index 770e728c..7300cbc1 100644 Binary files a/docs/remeshing.png and b/docs/remeshing.png differ diff --git a/docs/self_intersection_fix.png b/docs/self_intersection_fix.png new file mode 100644 index 00000000..2385b8ad Binary files /dev/null and b/docs/self_intersection_fix.png differ diff --git a/docs/stanford_bunny.png b/docs/stanford_bunny.png deleted file mode 100644 index 2420b492..00000000 Binary files a/docs/stanford_bunny.png and /dev/null differ diff --git a/docs/uv_unwrapping.png b/docs/uv_unwrapping.png new file mode 100644 index 00000000..82e9578d Binary files /dev/null and b/docs/uv_unwrapping.png differ diff --git a/nodes/boolean/boolean_op.py b/nodes/boolean/boolean_op.py index e4259b27..203e7564 100644 --- a/nodes/boolean/boolean_op.py +++ b/nodes/boolean/boolean_op.py @@ -15,8 +15,8 @@ class BooleanOpNode: - difference: Subtract mesh_b from mesh_a - intersection: Keep only overlapping parts - Uses trimesh's boolean operations (requires manifold3d backend). - Fallback to Blender if manifold3d is not available. + Uses libigl with CGAL backend for robust boolean operations. + Fallback to Blender if CGAL is not available. """ @classmethod @@ -28,7 +28,7 @@ class BooleanOpNode: "operation": (["union", "difference", "intersection"],), }, "optional": { - "engine": (["auto", "manifold", "blender"], {"default": "auto"}), + "engine": (["auto", "libigl_cgal", "blender"], {"default": "auto"}), } } @@ -45,7 +45,7 @@ class BooleanOpNode: mesh_a: First mesh (base mesh for difference) mesh_b: Second mesh (subtracted mesh for difference) operation: Boolean operation type - engine: Backend to use + engine: Backend to use (auto, libigl_cgal, blender) Returns: tuple: (result_mesh, info_string) @@ -54,13 +54,13 @@ class BooleanOpNode: print(f"[Boolean] Mesh B: {len(mesh_b.vertices)} vertices, {len(mesh_b.faces)} faces") print(f"[Boolean] Operation: {operation}, Engine: {engine}") - # Try manifold backend first - if engine in ["auto", "manifold"]: - result, info = self._try_manifold(mesh_a, mesh_b, operation) + # Try libigl+CGAL backend first + if engine in ["auto", "libigl_cgal"]: + result, info = self._try_libigl_cgal(mesh_a, mesh_b, operation) if result is not None: return (result, info) - if engine == "manifold": - raise RuntimeError("Manifold backend failed and was explicitly requested") + if engine == "libigl_cgal": + raise RuntimeError("libigl+CGAL backend failed and was explicitly requested") # Fallback to Blender if engine in ["auto", "blender"]: @@ -70,26 +70,40 @@ class BooleanOpNode: raise RuntimeError(f"Boolean operation failed with all available backends") - def _try_manifold(self, mesh_a, mesh_b, operation): - """Try boolean operation using manifold3d (via trimesh).""" + def _try_libigl_cgal(self, mesh_a, mesh_b, operation): + """Try boolean operation using libigl with CGAL backend.""" try: - print(f"[Boolean] Attempting manifold backend...") + import igl.copyleft.cgal as cgal + print(f"[Boolean] Attempting libigl+CGAL backend...") - # trimesh uses manifold3d for boolean operations - if operation == "union": - result = mesh_a.union(mesh_b, engine="manifold") - elif operation == "difference": - result = mesh_a.difference(mesh_b, engine="manifold") - elif operation == "intersection": - result = mesh_a.intersection(mesh_b, engine="manifold") - else: + # 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) + + # Map operation to igl type_str + # mesh_boolean accepts string: "union", "intersection", "difference" + op_map = { + "union": "union", + "difference": "difference", + "intersection": "intersection" + } + + 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': 'manifold', + 'engine': 'libigl_cgal', 'mesh_a_vertices': len(mesh_a.vertices), 'mesh_a_faces': len(mesh_a.faces), 'mesh_b_vertices': len(mesh_b.vertices), @@ -101,7 +115,7 @@ class BooleanOpNode: info = f"""Boolean Operation Results: Operation: {operation.upper()} -Engine: manifold +Engine: libigl + CGAL Mesh A: Vertices: {len(mesh_a.vertices):,} @@ -118,11 +132,11 @@ Result: Watertight: {result.is_watertight} """ - print(f"[Boolean] Manifold success: {len(result.vertices)} vertices, {len(result.faces)} faces") + print(f"[Boolean] libigl+CGAL success: {len(result.vertices)} vertices, {len(result.faces)} faces") return result, info except Exception as e: - print(f"[Boolean] Manifold backend failed: {e}") + print(f"[Boolean] libigl+CGAL backend failed: {e}") return None, str(e) def _try_blender(self, mesh_a, mesh_b, operation): diff --git a/nodes/texture_remeshing/remesh_uv.py b/nodes/texture_remeshing/remesh_uv.py index 8a2a0dcc..8f01154a 100644 --- a/nodes/texture_remeshing/remesh_uv.py +++ b/nodes/texture_remeshing/remesh_uv.py @@ -88,6 +88,7 @@ class BlenderRemeshWithTexture: return { "required": { "trimesh": ("TRIMESH",), + "method": (["blender", "xatlas"], {"default": "blender"}), "remesh_method": (["voxel", "quadriflow"], {"default": "quadriflow"}), "voxel_size": ("FLOAT", { "default": 0.05, @@ -116,9 +117,16 @@ class BlenderRemeshWithTexture: FUNCTION = "remesh_with_texture" CATEGORY = "geompack/texture_remeshing" - def remesh_with_texture(self, trimesh, remesh_method, voxel_size, target_face_count, + def remesh_with_texture(self, trimesh, method, remesh_method, voxel_size, target_face_count, texture_size, bake_margin): """Remesh a textured mesh while preserving texture through Blender baking.""" + # Check if xatlas method is requested + if method == "xatlas": + raise NotImplementedError( + "XAtlas remeshing method is not yet implemented. " + "Please use 'blender' method or help contribute an implementation!" + ) + if not PIL_AVAILABLE: raise RuntimeError("PIL required. Install: pip install Pillow") @@ -283,9 +291,11 @@ Texture size: {texture_size}x{texture_size} # Node mappings NODE_CLASS_MAPPINGS = { - "GeomPackRemeshUV": BlenderRemeshWithTexture, + "GeomPackBlenderRemeshWithTexture": BlenderRemeshWithTexture, + "GeomPackXAtlasRemeshWithTexture": BlenderRemeshWithTexture, } NODE_DISPLAY_NAME_MAPPINGS = { - "GeomPackRemeshUV": "Remesh UV", + "GeomPackBlenderRemeshWithTexture": "Remesh with Texture", + "GeomPackXAtlasRemeshWithTexture": "Remesh with Texture", } diff --git a/nodes/uv/__init__.py b/nodes/uv/__init__.py index 69735010..cba79b53 100644 --- a/nodes/uv/__init__.py +++ b/nodes/uv/__init__.py @@ -1,20 +1,13 @@ """ UV nodes aggregation. -The unified UV Unwrap node consolidates all UV unwrapping methods into a single node. -Old separate nodes are kept in the codebase for reference but are no longer registered. +The unified UV Unwrap node consolidates all UV unwrapping methods into a single node +with dynamic parameter exposure based on the selected method. """ -# New unified UV unwrap node +# Unified UV unwrap node from .uv_unwrap import UVUnwrapNode, NODE_CLASS_MAPPINGS as UV_UNWRAP_MAPS, NODE_DISPLAY_NAME_MAPPINGS as UV_UNWRAP_DISP -# Old separate nodes (kept for reference, not registered) -# from .xatlas_unwrap import XAtlasUVUnwrapNode, NODE_CLASS_MAPPINGS as XATLAS_UNWRAP_MAPS, NODE_DISPLAY_NAME_MAPPINGS as XATLAS_UNWRAP_DISP -# from .libigl_lscm import LibiglLSCMNode, NODE_CLASS_MAPPINGS as LIBIGL_LSCM_MAPS, NODE_DISPLAY_NAME_MAPPINGS as LIBIGL_LSCM_DISP -# from .libigl_harmonic import LibiglHarmonicNode, NODE_CLASS_MAPPINGS as LIBIGL_HARMONIC_MAPS, NODE_DISPLAY_NAME_MAPPINGS as LIBIGL_HARMONIC_DISP -# from .libigl_arap import LibiglARAPNode, NODE_CLASS_MAPPINGS as LIBIGL_ARAP_MAPS, NODE_DISPLAY_NAME_MAPPINGS as LIBIGL_ARAP_DISP -# from .blender_uv import BlenderUVNode, NODE_CLASS_MAPPINGS as BLENDER_UV_MAPS, NODE_DISPLAY_NAME_MAPPINGS as BLENDER_UV_DISP - NODE_CLASS_MAPPINGS = {} NODE_DISPLAY_NAME_MAPPINGS = {} diff --git a/nodes/uv/blender_uv.py b/nodes/uv/blender_uv.py deleted file mode 100644 index f5073337..00000000 --- a/nodes/uv/blender_uv.py +++ /dev/null @@ -1,237 +0,0 @@ -""" -Unified Blender UV - Multiple UV projection methods via Blender. - -Supports: -- smart_uv_project: Automatic seam-based unwrapping -- cube_projection: Project onto 6 cube faces -- cylinder_projection: Cylindrical projection -- sphere_projection: Spherical/equirectangular projection - -Parameters are method-specific; unused ones are ignored. -""" - -import trimesh as trimesh_module -import os -import subprocess -import tempfile -import math - -from .._utils import blender_bridge - - -class BlenderUVNode: - """ - Unified Blender UV - Multiple UV projection methods via Blender. - - Supports: - - smart_uv_project: Automatic seam-based unwrapping - - cube_projection: Project onto 6 cube faces - - cylinder_projection: Cylindrical projection - - sphere_projection: Spherical/equirectangular projection - - Parameters are method-specific; unused ones are ignored. - """ - - @classmethod - def INPUT_TYPES(cls): - return { - "required": { - "trimesh": ("TRIMESH",), - "method": ([ - "smart_uv_project", - "cube_projection", - "cylinder_projection", - "sphere_projection" - ], {"default": "smart_uv_project"}), - }, - "optional": { - # Smart UV Project params - "angle_limit": ("FLOAT", { - "default": 66.0, - "min": 1.0, - "max": 89.0, - "step": 1.0 - }), - "island_margin": ("FLOAT", { - "default": 0.02, - "min": 0.0, - "max": 1.0, - "step": 0.01 - }), - # Projection params - "scale_to_bounds": (["true", "false"], {"default": "true"}), - "cube_size": ("FLOAT", { - "default": 1.0, - "min": 0.1, - "max": 10.0, - "step": 0.1 - }), - "cylinder_radius": ("FLOAT", { - "default": 1.0, - "min": 0.1, - "max": 10.0, - "step": 0.1 - }), - } - } - - RETURN_TYPES = ("TRIMESH", "STRING") - RETURN_NAMES = ("unwrapped_mesh", "info") - FUNCTION = "uv_project" - CATEGORY = "geompack/uv" - - def uv_project(self, trimesh, method, - angle_limit=66.0, island_margin=0.02, - scale_to_bounds="true", cube_size=1.0, cylinder_radius=1.0): - """ - Apply UV projection using Blender. - - Args: - trimesh: Input mesh - method: UV projection method - [other params]: Method-specific parameters - - Returns: - tuple: (unwrapped_mesh, info_string) - """ - print(f"[BlenderUV] Input: {len(trimesh.vertices)} vertices, {len(trimesh.faces)} faces") - print(f"[BlenderUV] Method: {method}") - - # Find Blender - blender_path = blender_bridge.find_blender() - - # Create temp files - with tempfile.NamedTemporaryFile(suffix='.obj', delete=False) as f_in: - input_path = f_in.name - trimesh.export(input_path) - - with tempfile.NamedTemporaryFile(suffix='.obj', delete=False) as f_out: - output_path = f_out.name - - try: - # Build method-specific script - if method == "smart_uv_project": - angle_limit_rad = math.radians(angle_limit) - uv_script = f""" -bpy.ops.uv.smart_project( - angle_limit={angle_limit_rad}, - island_margin={island_margin}, - area_weight=0.0, - correct_aspect=True, - scale_to_bounds={'True' if scale_to_bounds == 'true' else 'False'} -) -""" - info_params = f"Angle Limit: {angle_limit}°\nIsland Margin: {island_margin}" - - elif method == "cube_projection": - uv_script = f""" -bpy.ops.uv.cube_project( - cube_size={cube_size}, - scale_to_bounds={'True' if scale_to_bounds == 'true' else 'False'} -) -""" - info_params = f"Cube Size: {cube_size}" - - elif method == "cylinder_projection": - uv_script = f""" -bpy.ops.uv.cylinder_project( - radius={cylinder_radius}, - scale_to_bounds={'True' if scale_to_bounds == 'true' else 'False'} -) -""" - info_params = f"Cylinder Radius: {cylinder_radius}" - - elif method == "sphere_projection": - uv_script = f""" -bpy.ops.uv.sphere_project( - scale_to_bounds={'True' if scale_to_bounds == 'true' else 'False'} -) -""" - info_params = "Equirectangular projection" - - else: - raise ValueError(f"Unknown method: {method}") - - # Complete Blender script - script = f""" -import bpy - -# Clear scene -bpy.ops.object.select_all(action='SELECT') -bpy.ops.object.delete() - -# Import mesh -bpy.ops.wm.obj_import(filepath='{input_path}') - -# Get imported object -obj = bpy.context.selected_objects[0] -bpy.context.view_layer.objects.active = obj - -# Switch to edit mode and unwrap -bpy.ops.object.mode_set(mode='EDIT') -bpy.ops.mesh.select_all(action='SELECT') -{uv_script} -bpy.ops.object.mode_set(mode='OBJECT') - -# Export with UVs -bpy.ops.wm.obj_export( - filepath='{output_path}', - export_selected_objects=True, - export_uv=True, - export_materials=False -) -""" - - print(f"[BlenderUV] Running Blender in background mode...") - result = subprocess.run( - [blender_path, '--background', '--python-expr', script], - capture_output=True, - text=True, - timeout=300 - ) - - if result.returncode != 0: - raise RuntimeError(f"Blender failed: {result.stderr}") - - # Load the unwrapped mesh - unwrapped = trimesh_module.load(output_path, process=False) - - if isinstance(unwrapped, trimesh_module.Scene): - unwrapped = unwrapped.dump(concatenate=True) - - # Preserve metadata - unwrapped.metadata = trimesh.metadata.copy() - unwrapped.metadata['uv_unwrap'] = { - 'algorithm': f'blender_{method}', - 'method': method - } - - info = f"""Blender UV Results: - -Method: {method.replace('_', ' ').title()} -{info_params} - -Input: {len(trimesh.vertices):,} vertices, {len(trimesh.faces):,} faces -Output: {len(unwrapped.vertices):,} vertices, {len(unwrapped.faces):,} faces - -UV coordinates applied successfully. -""" - - print(f"[BlenderUV] Complete: {len(unwrapped.vertices)} vertices, {len(unwrapped.faces)} faces") - return (unwrapped, info) - - finally: - # Cleanup temp files - if os.path.exists(input_path): - os.unlink(input_path) - if os.path.exists(output_path): - os.unlink(output_path) - - -NODE_CLASS_MAPPINGS = { - "GeomPackBlenderUV": BlenderUVNode, -} - -NODE_DISPLAY_NAME_MAPPINGS = { - "GeomPackBlenderUV": "UV Unwrap (Blender)", -} diff --git a/nodes/uv/libigl_arap.py b/nodes/uv/libigl_arap.py deleted file mode 100644 index 377a7a3a..00000000 --- a/nodes/uv/libigl_arap.py +++ /dev/null @@ -1,153 +0,0 @@ -""" -ARAP (As-Rigid-As-Possible) UV Parameterization using libigl. - -Minimizes distortion by making triangles as rigid as possible. -Better preservation of shape and angles compared to simpler methods. -Iterative solver - slower but higher quality. -""" - -import numpy as np -import trimesh as trimesh_module - - -class LibiglARAPNode: - """ - ARAP (As-Rigid-As-Possible) UV Parameterization using libigl. - - Minimizes distortion by making triangles as rigid as possible. - Better preservation of shape and angles compared to simpler methods. - Iterative solver - slower but higher quality. - """ - - @classmethod - def INPUT_TYPES(cls): - return { - "required": { - "trimesh": ("TRIMESH",), - "iterations": ("INT", { - "default": 10, - "min": 1, - "max": 100, - "step": 1 - }), - }, - } - - RETURN_TYPES = ("TRIMESH",) - RETURN_NAMES = ("unwrapped_mesh",) - FUNCTION = "uv_unwrap" - CATEGORY = "geompack/uv" - - def uv_unwrap(self, trimesh, iterations): - """ - ARAP UV parameterization using libigl. - - Args: - trimesh: Input trimesh_module.Trimesh object - iterations: Number of ARAP iterations - - Returns: - tuple: (unwrapped_trimesh_module.Trimesh,) - """ - try: - import igl - import scipy.sparse as sp - except ImportError: - raise ImportError("libigl and scipy not installed") - - print(f"[LibiglARAP] Input: {len(trimesh.vertices)} vertices, {len(trimesh.faces)} faces") - print(f"[LibiglARAP] Iterations: {iterations}") - - # Start with harmonic initialization - # Convert TrackedArray to pure numpy array for igl compatibility - 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") - - # 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 - # Convert TrackedArray to pure numpy for igl compatibility - 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 - ) - - print(f"[LibiglARAP] Initial harmonic solution computed") - - # Apply ARAP - # Note: libigl's ARAP might need different setup depending on version - # Using a simplified approach with iterations - uv = uv_init.copy() - - try: - # Try to use igl.arap if available - # ARAP needs energy minimization setup - # For simplicity, we'll use multiple iterations of harmonic with adjusted weights - # This is a simplified ARAP-like approach - for i in range(iterations): - # Recompute with current UV as guidance - # This is a simplified version - full ARAP is more complex - 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 # Use biharmonic (k=2) for smoother result - ) - - print(f"[LibiglARAP] ARAP optimization complete ({iterations} iterations)") - - except Exception as e: - print(f"[LibiglARAP] Note: Using biharmonic approximation of ARAP") - # Fall back to biharmonic - 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 - ) - - # 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 (copy original) - unwrapped = trimesh.copy() - - # Store UV coordinates in visual - from trimesh.visual import TextureVisuals - unwrapped.visual = TextureVisuals(uv=uv) - - # Add metadata - unwrapped.metadata['uv_unwrap'] = { - 'algorithm': 'libigl_arap_like', - 'iterations': iterations, - 'minimizes_distortion': True - } - - print(f"[LibiglARAP] Complete - ARAP-like mapping") - - return (unwrapped,) - - -NODE_CLASS_MAPPINGS = { - "GeomPackLibiglARAP": LibiglARAPNode, -} - -NODE_DISPLAY_NAME_MAPPINGS = { - "GeomPackLibiglARAP": "UV Unwrap (libigl ARAP)", -} diff --git a/nodes/uv/libigl_harmonic.py b/nodes/uv/libigl_harmonic.py deleted file mode 100644 index 3fecaa51..00000000 --- a/nodes/uv/libigl_harmonic.py +++ /dev/null @@ -1,103 +0,0 @@ -""" -Harmonic UV Parameterization using libigl. - -Simple, fast UV unwrapping using harmonic (Laplacian) mapping. -Guarantees valid (non-overlapping) UVs with fixed boundary. -Less feature-preserving than LSCM or ABF, but very stable. -""" - -import numpy as np -import trimesh as trimesh_module - - -class LibiglHarmonicNode: - """ - Harmonic UV Parameterization using libigl. - - Simple, fast UV unwrapping using harmonic (Laplacian) mapping. - Guarantees valid (non-overlapping) UVs with fixed boundary. - Less feature-preserving than LSCM or ABF, but very stable. - """ - - @classmethod - def INPUT_TYPES(cls): - return { - "required": { - "trimesh": ("TRIMESH",), - }, - } - - RETURN_TYPES = ("TRIMESH",) - RETURN_NAMES = ("unwrapped_mesh",) - FUNCTION = "uv_unwrap" - CATEGORY = "geompack/uv" - - def uv_unwrap(self, trimesh): - """ - Harmonic UV parameterization using libigl. - - Args: - trimesh: Input trimesh_module.Trimesh object - - Returns: - tuple: (unwrapped_trimesh_module.Trimesh,) - """ - try: - import igl - except ImportError: - raise ImportError("libigl not installed (should be in requirements.txt)") - - print(f"[LibiglHarmonic] Input: {len(trimesh.vertices)} vertices, {len(trimesh.faces)} faces") - - # Harmonic requires fixing boundary vertices - # Find boundary loop - # Convert TrackedArray to pure numpy array for igl compatibility - 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") - - # Map boundary to circle/square - # Simple circular boundary - 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 - # Convert TrackedArray to pure numpy for igl compatibility - 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 (copy original) - unwrapped = trimesh.copy() - - # Store UV coordinates in visual - from trimesh.visual import TextureVisuals - unwrapped.visual = TextureVisuals(uv=uv) - - # Add metadata - unwrapped.metadata['uv_unwrap'] = { - 'algorithm': 'libigl_harmonic', - 'boundary_vertices': len(boundary_loop), - 'guarantees_valid_uvs': True - } - - print(f"[LibiglHarmonic] Complete - simple harmonic mapping with {len(boundary_loop)} boundary vertices") - - return (unwrapped,) - - -NODE_CLASS_MAPPINGS = { - "GeomPackLibiglHarmonic": LibiglHarmonicNode, -} - -NODE_DISPLAY_NAME_MAPPINGS = { - "GeomPackLibiglHarmonic": "UV Unwrap (libigl Harmonic)", -} diff --git a/nodes/uv/libigl_lscm.py b/nodes/uv/libigl_lscm.py deleted file mode 100644 index 05b6e261..00000000 --- a/nodes/uv/libigl_lscm.py +++ /dev/null @@ -1,104 +0,0 @@ -""" -LSCM UV Parameterization using libigl. - -Least Squares Conformal Maps - minimizes angle distortion. -Fast, conformal mapping suitable for texturing organic shapes. -No Blender dependency required. -""" - -import numpy as np -import trimesh as trimesh_module - - -class LibiglLSCMNode: - """ - LSCM UV Parameterization using libigl. - - Least Squares Conformal Maps - minimizes angle distortion. - Fast, conformal mapping suitable for texturing organic shapes. - No Blender dependency required. - """ - - @classmethod - def INPUT_TYPES(cls): - return { - "required": { - "trimesh": ("TRIMESH",), - }, - } - - RETURN_TYPES = ("TRIMESH",) - RETURN_NAMES = ("unwrapped_mesh",) - FUNCTION = "uv_unwrap" - CATEGORY = "geompack/uv" - - def uv_unwrap(self, trimesh): - """ - LSCM UV parameterization using libigl. - - Args: - trimesh: Input trimesh_module.Trimesh object - - Returns: - tuple: (unwrapped_trimesh_module.Trimesh,) - """ - try: - import igl - except ImportError: - raise ImportError("libigl not installed (should be in requirements.txt)") - - print(f"[LibiglLSCM] Input: {len(trimesh.vertices)} vertices, {len(trimesh.faces)} faces") - - # LSCM requires fixing 2 vertices for unique solution - # Choose first and last vertex - 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 parameterization - # Convert TrackedArray to pure numpy array for igl compatibility - uv_result = igl.lscm( - np.asarray(trimesh.vertices, dtype=np.float64), - np.asarray(trimesh.faces, dtype=np.int32), - v_fixed, - uv_fixed - ) - # igl.lscm returns a tuple (uv_coords, sparse_matrix) - uv = uv_result[0] if isinstance(uv_result, tuple) else uv_result - - # Normalize UVs to [0, 1] range - uv_min = uv.min(axis=0) - uv_max = uv.max(axis=0) - uv_range = uv_max - uv_min - - # Avoid division by zero - uv_range[uv_range < 1e-10] = 1.0 - - uv_normalized = (uv - uv_min) / uv_range - - # Create unwrapped mesh (copy original) - unwrapped = trimesh.copy() - - # Store UV coordinates in visual - from trimesh.visual import TextureVisuals - unwrapped.visual = TextureVisuals(uv=uv_normalized) - - # Add metadata - unwrapped.metadata['uv_unwrap'] = { - 'algorithm': 'libigl_lscm', - 'conformal': True, - 'angle_preserving': True, - 'fixed_vertices': v_fixed.tolist() - } - - print(f"[LibiglLSCM] Complete - conformal (angle-preserving) mapping") - - return (unwrapped,) - - -NODE_CLASS_MAPPINGS = { - "GeomPackLibiglLSCM": LibiglLSCMNode, -} - -NODE_DISPLAY_NAME_MAPPINGS = { - "GeomPackLibiglLSCM": "UV Unwrap (libigl LSCM)", -} diff --git a/nodes/uv/xatlas_unwrap.py b/nodes/uv/xatlas_unwrap.py deleted file mode 100644 index 4be907d7..00000000 --- a/nodes/uv/xatlas_unwrap.py +++ /dev/null @@ -1,96 +0,0 @@ -""" -UV Unwrap mesh using xatlas library. - -Fast, automatic UV unwrapping optimized for lightmaps and texture atlasing. -No Blender dependency required. Uses the same algorithm as Blender 3.6+ -for UV packing. -""" - -import numpy as np -import trimesh as trimesh_module - - -class XAtlasUVUnwrapNode: - """ - UV Unwrap mesh using xatlas library. - - Fast, automatic UV unwrapping optimized for lightmaps and texture atlasing. - No Blender dependency required. Uses the same algorithm as Blender 3.6+ - for UV packing. - """ - - @classmethod - def INPUT_TYPES(cls): - return { - "required": { - "trimesh": ("TRIMESH",), - }, - } - - RETURN_TYPES = ("TRIMESH",) - RETURN_NAMES = ("unwrapped_mesh",) - FUNCTION = "uv_unwrap" - CATEGORY = "geompack/uv" - - def uv_unwrap(self, trimesh): - """ - UV unwrap mesh using xatlas. - - Args: - trimesh: Input trimesh_module.Trimesh object - - Returns: - tuple: (unwrapped_trimesh_module.Trimesh,) - """ - try: - import xatlas - except ImportError: - raise ImportError( - "xatlas not installed. Install with: pip install xatlas\n" - "This is required for fast UV unwrapping without Blender." - ) - - print(f"[XAtlasUVUnwrap] Input: {len(trimesh.vertices)} vertices, {len(trimesh.faces)} faces") - - # Parametrize with xatlas - vmapping, indices, uvs = xatlas.parametrize( - trimesh.vertices, - trimesh.faces - ) - - # Create new mesh with UV-split vertices - new_vertices = trimesh.vertices[vmapping] - - # Create trimesh with UV coordinates - unwrapped = trimesh_module.Trimesh( - vertices=new_vertices, - faces=indices, - process=False - ) - - # Store UV coordinates in visual - 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) - } - - print(f"[XAtlasUVUnwrap] Output: {len(unwrapped.vertices)} vertices, {len(unwrapped.faces)} faces") - print(f"[XAtlasUVUnwrap] Vertex duplication: {len(new_vertices)/len(trimesh.vertices):.2f}x") - - return (unwrapped,) - - -NODE_CLASS_MAPPINGS = { - "GeomPackXAtlasUVUnwrap": XAtlasUVUnwrapNode, -} - -NODE_DISPLAY_NAME_MAPPINGS = { - "GeomPackXAtlasUVUnwrap": "UV Unwrap (XAtlas)", -} diff --git a/nodes/visualization/__init__.py b/nodes/visualization/__init__.py index e9582648..66fd8865 100644 --- a/nodes/visualization/__init__.py +++ b/nodes/visualization/__init__.py @@ -6,6 +6,7 @@ from .preview_mesh import PreviewMeshNode, NODE_CLASS_MAPPINGS as PREVIEW_MESH_M from .preview_mesh_vtk import PreviewMeshVTKNode, NODE_CLASS_MAPPINGS as PREVIEW_MESH_VTK_MAPS, NODE_DISPLAY_NAME_MAPPINGS as PREVIEW_MESH_VTK_DISP from .preview_mesh_vtk_split import PreviewMeshVTKSplitNode, NODE_CLASS_MAPPINGS as PREVIEW_MESH_VTK_SPLIT_MAPS, NODE_DISPLAY_NAME_MAPPINGS as PREVIEW_MESH_VTK_SPLIT_DISP from .preview_mesh_vtk_with_texture import PreviewMeshVTKWithTextureNode, NODE_CLASS_MAPPINGS as PREVIEW_MESH_VTK_WITH_TEXTURE_MAPS, NODE_DISPLAY_NAME_MAPPINGS as PREVIEW_MESH_VTK_WITH_TEXTURE_DISP +from .preview_mesh_vtk_dual import PreviewMeshVTKDualNode, NODE_CLASS_MAPPINGS as PREVIEW_MESH_VTK_DUAL_MAPS, NODE_DISPLAY_NAME_MAPPINGS as PREVIEW_MESH_VTK_DUAL_DISP from .preview_mesh_vtk_pointcloud import PreviewMeshVTKPointCloudNode, NODE_CLASS_MAPPINGS as PREVIEW_MESH_VTK_POINTCLOUD_MAPS, NODE_DISPLAY_NAME_MAPPINGS as PREVIEW_MESH_VTK_POINTCLOUD_DISP from .preview_mesh_vtk_with_normals import PreviewMeshVTKWithNormalsNode, NODE_CLASS_MAPPINGS as PREVIEW_MESH_VTK_WITH_NORMALS_MAPS, NODE_DISPLAY_NAME_MAPPINGS as PREVIEW_MESH_VTK_WITH_NORMALS_DISP from .preview_mesh_vtk_edges import PreviewMeshVTKEdgesNode, NODE_CLASS_MAPPINGS as PREVIEW_MESH_VTK_EDGES_MAPS, NODE_DISPLAY_NAME_MAPPINGS as PREVIEW_MESH_VTK_EDGES_DISP @@ -22,6 +23,8 @@ NODE_CLASS_MAPPINGS.update(PREVIEW_MESH_VTK_SPLIT_MAPS) NODE_DISPLAY_NAME_MAPPINGS.update(PREVIEW_MESH_VTK_SPLIT_DISP) NODE_CLASS_MAPPINGS.update(PREVIEW_MESH_VTK_WITH_TEXTURE_MAPS) NODE_DISPLAY_NAME_MAPPINGS.update(PREVIEW_MESH_VTK_WITH_TEXTURE_DISP) +NODE_CLASS_MAPPINGS.update(PREVIEW_MESH_VTK_DUAL_MAPS) +NODE_DISPLAY_NAME_MAPPINGS.update(PREVIEW_MESH_VTK_DUAL_DISP) NODE_CLASS_MAPPINGS.update(PREVIEW_MESH_VTK_POINTCLOUD_MAPS) NODE_DISPLAY_NAME_MAPPINGS.update(PREVIEW_MESH_VTK_POINTCLOUD_DISP) NODE_CLASS_MAPPINGS.update(PREVIEW_MESH_VTK_WITH_NORMALS_MAPS) diff --git a/nodes/visualization/preview_mesh_vtk_dual.py b/nodes/visualization/preview_mesh_vtk_dual.py new file mode 100644 index 00000000..255c7a48 --- /dev/null +++ b/nodes/visualization/preview_mesh_vtk_dual.py @@ -0,0 +1,178 @@ +""" +Preview two meshes simultaneously with VTK.js viewer. + +Combines two meshes with different vertex colors for easy visual comparison. +Useful for visualizing boolean operation inputs or comparing meshes side-by-side. +Exports to GLB format to preserve vertex colors. +""" + +import trimesh as trimesh_module +import numpy as np +import os +import tempfile +import uuid + +try: + import folder_paths + COMFYUI_OUTPUT_FOLDER = folder_paths.get_output_directory() +except: + COMFYUI_OUTPUT_FOLDER = None + + +class PreviewMeshVTKDualNode: + """ + Preview two meshes simultaneously with VTK.js viewer. + + Combines two meshes with different vertex colors for visual distinction. + Perfect for comparing boolean operation inputs or any two meshes. + """ + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "mesh_a": ("TRIMESH",), + "mesh_b": ("TRIMESH",), + }, + "optional": { + "color_a": (["red", "blue", "green", "yellow", "cyan", "magenta", "orange", "purple"], {"default": "red"}), + "color_b": (["red", "blue", "green", "yellow", "cyan", "magenta", "orange", "purple"], {"default": "blue"}), + "opacity": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 1.0, "step": 0.1}), + } + } + + RETURN_TYPES = () + OUTPUT_NODE = True + FUNCTION = "preview_dual_mesh" + CATEGORY = "geompack/visualization" + + def preview_dual_mesh(self, mesh_a, mesh_b, color_a="red", color_b="blue", opacity=1.0): + """ + Combine and preview two meshes with different colors. + + Args: + mesh_a: First trimesh object + mesh_b: Second trimesh object + color_a: Color for mesh_a + color_b: Color for mesh_b + opacity: Opacity for both meshes (0.0-1.0) + + Returns: + dict: UI data for frontend widget + """ + print(f"[PreviewMeshVTKDual] Mesh A: {len(mesh_a.vertices)} vertices, {len(mesh_a.faces)} faces") + print(f"[PreviewMeshVTKDual] Mesh B: {len(mesh_b.vertices)} vertices, {len(mesh_b.faces)} faces") + print(f"[PreviewMeshVTKDual] Colors: {color_a}, {color_b}") + + # Color mapping (RGB values, brighter for better visibility) + color_map = { + "red": [255, 100, 100], + "blue": [100, 150, 255], + "green": [100, 255, 100], + "yellow": [255, 255, 100], + "cyan": [100, 255, 255], + "magenta": [255, 100, 255], + "orange": [255, 180, 100], + "purple": [200, 100, 255], + } + + # Calculate alpha channel from opacity + alpha = int(opacity * 255) + + # Create copies to avoid modifying originals + mesh_a_colored = mesh_a.copy() + mesh_b_colored = mesh_b.copy() + + # Assign vertex colors to mesh_a + color_a_rgba = color_map.get(color_a, [255, 100, 100]) + [alpha] + mesh_a_colored.visual.vertex_colors = np.tile( + color_a_rgba, (len(mesh_a.vertices), 1) + ).astype(np.uint8) + + # Assign vertex colors to mesh_b + color_b_rgba = color_map.get(color_b, [100, 150, 255]) + [alpha] + mesh_b_colored.visual.vertex_colors = np.tile( + color_b_rgba, (len(mesh_b.vertices), 1) + ).astype(np.uint8) + + # Combine meshes + try: + combined = trimesh_module.util.concatenate([mesh_a_colored, mesh_b_colored]) + print(f"[PreviewMeshVTKDual] Combined mesh: {len(combined.vertices)} vertices, {len(combined.faces)} faces") + except Exception as e: + print(f"[PreviewMeshVTKDual] Failed to combine meshes: {e}") + # Fallback: just use mesh_a + combined = mesh_a_colored + + # Generate unique filename + filename = f"preview_vtk_dual_{uuid.uuid4().hex[:8]}.glb" + + # Use ComfyUI's output directory + if COMFYUI_OUTPUT_FOLDER: + filepath = os.path.join(COMFYUI_OUTPUT_FOLDER, filename) + else: + filepath = os.path.join(tempfile.gettempdir(), filename) + + # Export to GLB (preserves vertex colors) + try: + combined.export(filepath, file_type='glb', include_normals=True) + print(f"[PreviewMeshVTKDual] Exported to: {filepath}") + except Exception as e: + print(f"[PreviewMeshVTKDual] GLB export failed: {e}") + import traceback + traceback.print_exc() + # Fallback to OBJ + filename = filename.replace('.glb', '.obj') + filepath = filepath.replace('.glb', '.obj') + combined.export(filepath, file_type='obj') + print(f"[PreviewMeshVTKDual] Exported to OBJ fallback: {filepath}") + + # Calculate bounding box info + bounds = combined.bounds + extents = combined.extents + max_extent = max(extents) + + # Individual mesh info + bounds_a = mesh_a.bounds + bounds_b = mesh_b.bounds + + # Return metadata for frontend widget + ui_data = { + "mesh_file": [filename], + # Combined mesh info + "vertex_count": [len(combined.vertices)], + "face_count": [len(combined.faces)], + "bounds_min": [bounds[0].tolist()], + "bounds_max": [bounds[1].tolist()], + "extents": [extents.tolist()], + "max_extent": [float(max_extent)], + # Mesh A info + "mesh_a_vertices": [len(mesh_a.vertices)], + "mesh_a_faces": [len(mesh_a.faces)], + "mesh_a_bounds_min": [bounds_a[0].tolist()], + "mesh_a_bounds_max": [bounds_a[1].tolist()], + "mesh_a_watertight": [bool(mesh_a.is_watertight)], + "mesh_a_color": [color_a], + # Mesh B info + "mesh_b_vertices": [len(mesh_b.vertices)], + "mesh_b_faces": [len(mesh_b.faces)], + "mesh_b_bounds_min": [bounds_b[0].tolist()], + "mesh_b_bounds_max": [bounds_b[1].tolist()], + "mesh_b_watertight": [bool(mesh_b.is_watertight)], + "mesh_b_color": [color_b], + # Visual settings + "opacity": [float(opacity)], + } + + print(f"[PreviewMeshVTKDual] Preview ready") + + return {"ui": ui_data} + + +NODE_CLASS_MAPPINGS = { + "GeomPackPreviewMeshVTKDual": PreviewMeshVTKDualNode, +} + +NODE_DISPLAY_NAME_MAPPINGS = { + "GeomPackPreviewMeshVTKDual": "Preview Mesh (VTK Dual)", +} diff --git a/nodes/visualization/preview_mesh_vtk_pointcloud.py b/nodes/visualization/preview_mesh_vtk_pointcloud.py index 815a2865..92d0cc83 100644 --- a/nodes/visualization/preview_mesh_vtk_pointcloud.py +++ b/nodes/visualization/preview_mesh_vtk_pointcloud.py @@ -117,9 +117,9 @@ class PreviewMeshVTKPointCloudNode: NODE_CLASS_MAPPINGS = { - "GeomPackPreviewMeshVTKPointCloud": PreviewMeshVTKPointCloudNode, + "GeomPackPreviewMeshVTKFields": PreviewMeshVTKPointCloudNode, } NODE_DISPLAY_NAME_MAPPINGS = { - "GeomPackPreviewMeshVTKPointCloud": "Preview Mesh (VTK Point Cloud)", + "GeomPackPreviewMeshVTKFields": "Preview Mesh (VTK with Fields)", } diff --git a/web/js/mesh_preview_vtk_dual.js b/web/js/mesh_preview_vtk_dual.js new file mode 100644 index 00000000..b214caf6 --- /dev/null +++ b/web/js/mesh_preview_vtk_dual.js @@ -0,0 +1,261 @@ +/** + * ComfyUI GeomPack - Dual Mesh VTK.js Preview Widget + * Shows two meshes with different colors for comparison + */ + +import { app } from "../../../scripts/app.js"; + +console.log("[GeomPack] Loading VTK.js dual mesh preview extension..."); + +app.registerExtension({ + name: "geompack.meshpreview.vtk.dual", + + async beforeRegisterNodeDef(nodeType, nodeData, app) { + if (nodeData.name === "GeomPackPreviewMeshVTKDual") { + console.log("[GeomPack] Registering Preview Mesh (VTK Dual) node"); + + const onNodeCreated = nodeType.prototype.onNodeCreated; + nodeType.prototype.onNodeCreated = function() { + const r = onNodeCreated ? onNodeCreated.apply(this, arguments) : undefined; + + console.log("[GeomPack VTK Dual] Node created, adding widget"); + + // Create container for viewer + info panel + const container = document.createElement("div"); + container.style.width = "100%"; + container.style.height = "100%"; + container.style.display = "flex"; + container.style.flexDirection = "column"; + container.style.backgroundColor = "#2a2a2a"; + container.style.overflow = "hidden"; + + // Create iframe for VTK.js viewer (use textured viewer for GLB support) + const iframe = document.createElement("iframe"); + iframe.style.width = "100%"; + iframe.style.flex = "1 1 0"; + iframe.style.minHeight = "0"; + iframe.style.border = "none"; + iframe.style.backgroundColor = "#2a2a2a"; + + // Use textured viewer for GLB with vertex colors + iframe.src = "/extensions/ComfyUI-GeometryPack/viewer_vtk_textured.html?v=" + Date.now(); + + // Create mesh info panel (larger for two meshes) + const infoPanel = document.createElement("div"); + infoPanel.style.backgroundColor = "#1a1a1a"; + infoPanel.style.borderTop = "1px solid #444"; + infoPanel.style.padding = "8px 12px"; + infoPanel.style.fontSize = "10px"; + infoPanel.style.fontFamily = "monospace"; + infoPanel.style.color = "#ccc"; + infoPanel.style.lineHeight = "1.4"; + infoPanel.style.flexShrink = "0"; + infoPanel.style.overflow = "auto"; + infoPanel.style.maxHeight = "200px"; + infoPanel.innerHTML = 'Dual mesh info will appear here after execution'; + + // Add iframe and info panel to container + container.appendChild(iframe); + container.appendChild(infoPanel); + + // Add widget + const widget = this.addDOMWidget("preview_vtk_dual", "MESH_PREVIEW_VTK_DUAL", container, { + getValue() { return ""; }, + setValue(v) { } + }); + + widget.computeSize = () => [512, 700]; // Larger height for dual mesh info + + // Store references + this.meshViewerIframeVTKDual = iframe; + this.meshInfoPanelVTKDual = infoPanel; + + // Track iframe load state + let iframeLoaded = false; + iframe.addEventListener('load', () => { + console.log("[GeomPack VTK Dual] Iframe loaded"); + iframeLoaded = true; + }); + + // Listen for messages from iframe + window.addEventListener('message', async (event) => { + if (event.data.type === 'SCREENSHOT' && event.data.image) { + console.log('[GeomPack VTK Dual] Received screenshot'); + // Screenshot handling (same as regular VTK viewer) + try { + const base64Data = event.data.image.split(',')[1]; + const byteString = atob(base64Data); + const arrayBuffer = new ArrayBuffer(byteString.length); + const uint8Array = new Uint8Array(arrayBuffer); + + for (let i = 0; i < byteString.length; i++) { + uint8Array[i] = byteString.charCodeAt(i); + } + + const blob = new Blob([uint8Array], { type: 'image/png' }); + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + const filename = `vtk-dual-screenshot-${timestamp}.png`; + + const formData = new FormData(); + formData.append('image', blob, filename); + formData.append('type', 'output'); + formData.append('subfolder', ''); + + const response = await fetch('/upload/image', { + method: 'POST', + body: formData + }); + + if (response.ok) { + const result = await response.json(); + console.log('[GeomPack VTK Dual] Screenshot saved:', result.name); + } + } catch (error) { + console.error('[GeomPack VTK Dual] Error saving screenshot:', error); + } + } + else if (event.data.type === 'MESH_ERROR' && event.data.error) { + console.error('[GeomPack VTK Dual] Error from viewer:', event.data.error); + if (infoPanel) { + infoPanel.innerHTML = `