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. -![Stanford Bunny](docs/stanford_bunny.png) ![Remeshing](docs/remeshing.png) +![self_intersection_fix](docs/self_intersection_fix.png) +![uv_unwrapping](docs/uv_unwrapping.png) ## 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 = `
Error: ${event.data.error}
`; + } + } + }); + + // Set initial node size + this.setSize([512, 700]); + + // Handle execution + const onExecuted = this.onExecuted; + this.onExecuted = function(message) { + console.log("[GeomPack VTK Dual] onExecuted called with message:", message); + onExecuted?.apply(this, arguments); + + if (message?.mesh_file && message.mesh_file[0]) { + const filename = message.mesh_file[0]; + console.log(`[GeomPack VTK Dual] Loading combined mesh: ${filename}`); + + // Extract mesh A info + const meshAVertices = message.mesh_a_vertices?.[0] || 'N/A'; + const meshAFaces = message.mesh_a_faces?.[0] || 'N/A'; + const meshAWatertight = message.mesh_a_watertight?.[0]; + const meshAColor = message.mesh_a_color?.[0] || 'red'; + + // Extract mesh B info + const meshBVertices = message.mesh_b_vertices?.[0] || 'N/A'; + const meshBFaces = message.mesh_b_faces?.[0] || 'N/A'; + const meshBWatertight = message.mesh_b_watertight?.[0]; + const meshBColor = message.mesh_b_color?.[0] || 'blue'; + + // Combined mesh info + const totalVertices = message.vertex_count?.[0] || 'N/A'; + const totalFaces = message.face_count?.[0] || 'N/A'; + + // Color mapping for display + const colorDisplay = { + red: '#ff6b6b', + blue: '#64a0ff', + green: '#64ff64', + yellow: '#ffff64', + cyan: '#64ffff', + magenta: '#ff64ff', + orange: '#ffb464', + purple: '#c864ff' + }; + + // Build dual mesh info HTML + let infoHTML = ` +
+
Combined Mesh
+
+ Total Vertices: + ${typeof totalVertices === 'number' ? totalVertices.toLocaleString() : totalVertices} + Total Faces: + ${typeof totalFaces === 'number' ? totalFaces.toLocaleString() : totalFaces} +
+
+
+
+
+ ● Mesh A +
+
+ Vertices: + ${typeof meshAVertices === 'number' ? meshAVertices.toLocaleString() : meshAVertices} + Faces: + ${typeof meshAFaces === 'number' ? meshAFaces.toLocaleString() : meshAFaces} + `; + + if (meshAWatertight !== undefined) { + const watertightA = meshAWatertight ? 'Yes' : 'No'; + const colorA = meshAWatertight ? '#6c6' : '#c66'; + infoHTML += ` + Watertight: + ${watertightA} + `; + } + + infoHTML += ` +
+
+
+
+ ● Mesh B +
+
+ Vertices: + ${typeof meshBVertices === 'number' ? meshBVertices.toLocaleString() : meshBVertices} + Faces: + ${typeof meshBFaces === 'number' ? meshBFaces.toLocaleString() : meshBFaces} + `; + + if (meshBWatertight !== undefined) { + const watertightB = meshBWatertight ? 'Yes' : 'No'; + const colorB = meshBWatertight ? '#6c6' : '#c66'; + infoHTML += ` + Watertight: + ${watertightB} + `; + } + + infoHTML += ` +
+
+
+ `; + + infoPanel.innerHTML = infoHTML; + + // Load mesh via ComfyUI view API + const filepath = `/view?filename=${encodeURIComponent(filename)}&type=output&subfolder=`; + + const sendMessage = () => { + if (iframe.contentWindow) { + console.log(`[GeomPack VTK Dual] Sending postMessage: ${filepath}`); + iframe.contentWindow.postMessage({ + type: "LOAD_MESH", + filepath: filepath, + timestamp: Date.now() + }, "*"); + } else { + console.error("[GeomPack VTK Dual] Iframe contentWindow not available"); + } + }; + + if (iframeLoaded) { + sendMessage(); + } else { + setTimeout(sendMessage, 500); + } + } else { + console.log("[GeomPack VTK Dual] No mesh_file in message"); + } + }; + + return r; + }; + } + } +}); + +console.log("[GeomPack] VTK.js dual mesh preview extension loaded"); diff --git a/web/js/uv_unwrap_dynamic.js b/web/js/uv_unwrap_dynamic.js new file mode 100644 index 00000000..840bc13f --- /dev/null +++ b/web/js/uv_unwrap_dynamic.js @@ -0,0 +1,214 @@ +/** + * ComfyUI-GeometryPack UV Unwrap Dynamic Parameter Management + * + * This extension manages the visibility of method-specific parameters + * in the UV Unwrap node based on the selected method. + */ + +import { app } from "../../../scripts/app.js"; + +const DEBUG = false; // Set to true to enable console logging + +function log(...args) { + if (DEBUG) { + console.log("[GeomPack-UVUnwrap]", ...args); + } +} + +// Helper function to hide a widget (removes from array) +function hideWidget(node, widget) { + if (!widget) return; + if (widget._hidden) { + log("Widget already hidden:", widget.name); + return; + } + + log("Hiding widget:", widget.name); + + const index = node.widgets.indexOf(widget); + if (index === -1) { + log(" ERROR: Widget not found in array!"); + return; + } + + // Store original properties for restoration + if (!widget.origType) { + widget.origType = widget.type; + widget.origComputeSize = widget.computeSize; + widget.origSerializeValue = widget.serializeValue; + } + + widget._originalIndex = index; + widget._hidden = true; + + // Remove widget from array + node.widgets.splice(index, 1); + + log(" Widget removed from index:", index); + + // Hide linked widgets if any + if (widget.linkedWidgets) { + widget.linkedWidgets.forEach(w => hideWidget(node, w)); + } +} + +// Helper function to show a widget (re-inserts into array) +function showWidget(node, widget) { + if (!widget) return; + if (!widget._hidden) { + log("Widget already visible:", widget.name); + return; + } + + log("Showing widget:", widget.name); + + // Restore original properties + if (widget.origType) { + widget.type = widget.origType; + widget.computeSize = widget.origComputeSize; + if (widget.origSerializeValue) { + widget.serializeValue = widget.origSerializeValue; + } + } + + // Re-insert widget at original position + const insertIndex = Math.min(widget._originalIndex, node.widgets.length); + node.widgets.splice(insertIndex, 0, widget); + + log(" Widget restored to index:", insertIndex); + + widget._hidden = false; + + // Show linked widgets if any + if (widget.linkedWidgets) { + widget.linkedWidgets.forEach(w => showWidget(node, w)); + } +} + +// Helper to update node size and redraw +function updateNodeSize(node) { + node.setDirtyCanvas(true, true); + if (app.graph) { + app.graph.setDirtyCanvas(true, true); + } + + requestAnimationFrame(() => { + const newSize = node.computeSize(); + node.setSize([node.size[0], newSize[1]]); + node.setDirtyCanvas(true, true); + + if (app.canvas) { + app.canvas.setDirty(true, true); + } + + requestAnimationFrame(() => { + if (app.canvas) { + app.canvas.draw(true, true); + } + log("Widget visibility update complete"); + }); + }); +} + +// Main extension registration +app.registerExtension({ + name: "geompack.uv_unwrap_dynamic", + + async nodeCreated(node) { + // Handle UV Unwrap node + if (node.comfyClass === "GeomPackUVUnwrap") { + setTimeout(() => { + this.setupUVUnwrapNode(node); + }, 100); + } + }, + + setupUVUnwrapNode(node) { + log("Setting up GeomPackUVUnwrap node"); + + // Find the method selector widget + const methodWidget = node.widgets?.find(w => w.name === "method"); + if (!methodWidget) { + log("ERROR: Method widget not found!"); + return; + } + + log("Method widget found, current value:", methodWidget.value); + + // Find all parameter widgets + const iterationsWidget = node.widgets?.find(w => w.name === "iterations"); + const angleLimitWidget = node.widgets?.find(w => w.name === "angle_limit"); + const islandMarginWidget = node.widgets?.find(w => w.name === "island_margin"); + const scaleToBoundsWidget = node.widgets?.find(w => w.name === "scale_to_bounds"); + const cubeSizeWidget = node.widgets?.find(w => w.name === "cube_size"); + const cylinderRadiusWidget = node.widgets?.find(w => w.name === "cylinder_radius"); + + log("Found parameter widgets:", { + iterations: !!iterationsWidget, + angle_limit: !!angleLimitWidget, + island_margin: !!islandMarginWidget, + scale_to_bounds: !!scaleToBoundsWidget, + cube_size: !!cubeSizeWidget, + cylinder_radius: !!cylinderRadiusWidget + }); + + // Function to update widget visibility based on selected method + const updateWidgetVisibility = (selectedMethod) => { + log("Updating widget visibility for method:", selectedMethod); + + // Hide all optional parameters first + hideWidget(node, iterationsWidget); + hideWidget(node, angleLimitWidget); + hideWidget(node, islandMarginWidget); + hideWidget(node, scaleToBoundsWidget); + hideWidget(node, cubeSizeWidget); + hideWidget(node, cylinderRadiusWidget); + + // Show relevant parameters based on method + if (selectedMethod === "xatlas" || + selectedMethod === "libigl_lscm" || + selectedMethod === "libigl_harmonic") { + // No parameters for these methods + log(" No parameters needed for", selectedMethod); + } else if (selectedMethod === "libigl_arap") { + showWidget(node, iterationsWidget); + } else if (selectedMethod === "blender_smart") { + showWidget(node, angleLimitWidget); + showWidget(node, islandMarginWidget); + showWidget(node, scaleToBoundsWidget); + } else if (selectedMethod === "blender_cube") { + showWidget(node, cubeSizeWidget); + showWidget(node, scaleToBoundsWidget); + } else if (selectedMethod === "blender_cylinder") { + showWidget(node, cylinderRadiusWidget); + showWidget(node, scaleToBoundsWidget); + } else if (selectedMethod === "blender_sphere") { + showWidget(node, scaleToBoundsWidget); + } + + updateNodeSize(node); + }; + + // Store original callback + const origCallback = methodWidget.callback; + + // Override callback to update visibility when method changes + methodWidget.callback = function(value) { + log("Method changed to:", value); + + // Call original callback if it exists + const result = origCallback?.apply(this, arguments); + + // Update widget visibility + updateWidgetVisibility(value); + + return result; + }; + + // Initialize visibility on node creation + log("Initializing widget visibility..."); + updateWidgetVisibility(methodWidget.value); + } +}); + +log("GeomPack UV Unwrap dynamic parameters extension loaded"); diff --git a/workflows/boolean_ops.json b/workflows/boolean_ops.json new file mode 100644 index 00000000..f35bce10 --- /dev/null +++ b/workflows/boolean_ops.json @@ -0,0 +1,294 @@ +{ + "id": "boolean-operations-demo", + "revision": 1, + "last_node_id": 19, + "last_link_id": 24, + "nodes": [ + { + "id": 4, + "type": "GeomPackPreviewMeshVTK", + "pos": [ + 46.75901565551733, + -173.09612621307397 + ], + "size": [ + 450, + 678 + ], + "flags": {}, + "order": 4, + "mode": 0, + "inputs": [ + { + "name": "trimesh", + "type": "TRIMESH", + "link": 3 + } + ], + "outputs": [], + "properties": { + "cnr_id": "comfyui-geometrypack", + "ver": "8b8322e2f784d84097cd3427f097b55dc9a29d2a", + "Node name for S&R": "GeomPackPreviewMeshVTK" + }, + "widgets_values": [ + "" + ] + }, + { + "id": 3, + "type": "GeomPackBooleanOp", + "pos": [ + -411.98995552062996, + -248.6199945449829 + ], + "size": [ + 320, + 130 + ], + "flags": {}, + "order": 2, + "mode": 0, + "inputs": [ + { + "name": "mesh_a", + "type": "TRIMESH", + "link": 1 + }, + { + "name": "mesh_b", + "type": "TRIMESH", + "link": 2 + } + ], + "outputs": [ + { + "name": "result_mesh", + "type": "TRIMESH", + "links": [ + 3 + ] + }, + { + "name": "info", + "type": "STRING", + "links": [ + 22 + ] + } + ], + "properties": { + "cnr_id": "comfyui-geometrypack", + "ver": "8b8322e2f784d84097cd3427f097b55dc9a29d2a", + "Node name for S&R": "GeomPackBooleanOp" + }, + "widgets_values": [ + "union", + "libigl_cgal" + ] + }, + { + "id": 18, + "type": "PreviewAny", + "pos": [ + -280.61808267817673, + 57.969546505383164 + ], + "size": [ + 291.2499999999998, + 320.4200092315672 + ], + "flags": {}, + "order": 5, + "mode": 0, + "inputs": [ + { + "name": "source", + "type": "*", + "link": 22 + } + ], + "outputs": [], + "properties": { + "cnr_id": "comfy-core", + "ver": "0.3.70", + "Node name for S&R": "PreviewAny" + }, + "widgets_values": [] + }, + { + "id": 1, + "type": "GeomPackCreatePrimitive", + "pos": [ + -800, + -200 + ], + "size": [ + 300, + 150 + ], + "flags": {}, + "order": 0, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "mesh", + "type": "TRIMESH", + "links": [ + 1, + 23 + ] + } + ], + "properties": { + "cnr_id": "comfyui-geometrypack", + "ver": "8b8322e2f784d84097cd3427f097b55dc9a29d2a", + "Node name for S&R": "GeomPackCreatePrimitive" + }, + "widgets_values": [ + "cube", + 2, + 1 + ] + }, + { + "id": 2, + "type": "GeomPackCreatePrimitive", + "pos": [ + -800, + 50 + ], + "size": [ + 300, + 150 + ], + "flags": {}, + "order": 1, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "mesh", + "type": "TRIMESH", + "links": [ + 2, + 24 + ] + } + ], + "properties": { + "cnr_id": "comfyui-geometrypack", + "ver": "8b8322e2f784d84097cd3427f097b55dc9a29d2a", + "Node name for S&R": "GeomPackCreatePrimitive" + }, + "widgets_values": [ + "sphere", + 2.5, + 2 + ] + }, + { + "id": 19, + "type": "GeomPackPreviewMeshVTKDual", + "pos": [ + -481.5729971526634, + 155.11454028742656 + ], + "size": [ + 512, + 830 + ], + "flags": {}, + "order": 3, + "mode": 0, + "inputs": [ + { + "name": "mesh_a", + "type": "TRIMESH", + "link": 23 + }, + { + "name": "mesh_b", + "type": "TRIMESH", + "link": 24 + } + ], + "outputs": [], + "properties": { + "cnr_id": "comfyui-geometrypack", + "ver": "8b8322e2f784d84097cd3427f097b55dc9a29d2a", + "Node name for S&R": "GeomPackPreviewMeshVTKDual" + }, + "widgets_values": [ + "red", + "blue", + 0.5, + "" + ] + } + ], + "links": [ + [ + 1, + 1, + 0, + 3, + 0, + "TRIMESH" + ], + [ + 2, + 2, + 0, + 3, + 1, + "TRIMESH" + ], + [ + 3, + 3, + 0, + 4, + 0, + "TRIMESH" + ], + [ + 22, + 3, + 1, + 18, + 0, + "*" + ], + [ + 23, + 1, + 0, + 19, + 0, + "TRIMESH" + ], + [ + 24, + 2, + 0, + 19, + 1, + "TRIMESH" + ] + ], + "groups": [], + "config": {}, + "extra": { + "ds": { + "scale": 0.6611570247933891, + "offset": [ + 1335.9534281669653, + 339.7224902666414 + ] + }, + "frontendVersion": "1.28.8" + }, + "version": 0.4 +} \ No newline at end of file diff --git a/workflows/fix_self_intersection.json b/workflows/fix_self_intersection.json index 48299447..e91f61c5 100644 --- a/workflows/fix_self_intersection.json +++ b/workflows/fix_self_intersection.json @@ -1,15 +1,15 @@ { "id": "94c1c61e-cf5c-42a8-b9ec-914bb4f038ea", "revision": 0, - "last_node_id": 16, - "last_link_id": 22, + "last_node_id": 19, + "last_link_id": 29, "nodes": [ { "id": 1, "type": "GeomPackLoadMesh", "pos": [ - -415.0736028533223, - -8.86644680883306 + -736.7848476964324, + -65.70589089295333 ], "size": [ 270, @@ -24,7 +24,6 @@ "name": "mesh", "type": "TRIMESH", "links": [ - 13, 19 ] } @@ -39,119 +38,44 @@ ] }, { - "id": 12, - "type": "GeomPackPreviewMeshVTKFields", + "id": 16, + "type": "PreviewAny", "pos": [ - 222.62019740761482, - -638.0172723913844 + -205.21617522900593, + -65.83044266339267 ], "size": [ - 512, - 598 + 210, + 188.05807589552933 ], "flags": {}, - "order": 3, + "order": 4, "mode": 0, "inputs": [ { - "name": "trimesh", - "type": "TRIMESH", - "link": 16 + "name": "source", + "type": "*", + "link": 27 } ], "outputs": [], "properties": { - "cnr_id": "comfyui-geometrypack", - "ver": "253a956368261760da54dc96750c2ab0224e2b1f", - "Node name for S&R": "GeomPackPreviewMeshVTKFields" - }, - "widgets_values": [ - "" - ] - }, - { - "id": 4, - "type": "GeomPackPreviewMeshVTKFields", - "pos": [ - 412.0851081444848, - 164.1085252304552 - ], - "size": [ - 512, - 598 - ], - "flags": {}, - "order": 7, - "mode": 0, - "inputs": [ - { - "name": "trimesh", - "type": "TRIMESH", - "link": 18 - } - ], - "outputs": [], - "properties": { - "cnr_id": "comfyui-geometrypack", - "ver": "253a956368261760da54dc96750c2ab0224e2b1f", - "Node name for S&R": "GeomPackPreviewMeshVTKFields" - }, - "widgets_values": [ - "" - ] - }, - { - "id": 14, - "type": "GeomPackDetectSelfIntersections", - "pos": [ - -579.7979278570692, - 240.94106436197612 - ], - "size": [ - 191.6373046875, - 46 - ], - "flags": {}, - "order": 2, - "mode": 0, - "inputs": [ - { - "name": "trimesh", - "type": "TRIMESH", - "link": 19 - } - ], - "outputs": [ - { - "name": "mesh_with_field", - "type": "TRIMESH", - "links": [ - 20 - ] - }, - { - "name": "report", - "type": "STRING", - "links": null - } - ], - "properties": { - "cnr_id": "comfyui-geometrypack", - "ver": "c77434120c2984b0ad1dd308c733117a855a8a36", - "Node name for S&R": "GeomPackDetectSelfIntersections" + "cnr_id": "comfy-core", + "ver": "0.3.68", + "Node name for S&R": "PreviewAny" }, "widgets_values": [] }, { - "id": 11, - "type": "GeomPackFixIntersectionsByPerturbation", + "id": 13, + "type": "GeomPackDetectSelfIntersections", "pos": [ - -318.6883908062423, - 140.27070769437378 + -389.92211876029273, + 204.15017433752317 ], "size": [ - 330.2232421875, - 150 + 191.6373046875, + 46 ], "flags": {}, "order": 5, @@ -160,54 +84,7 @@ { "name": "trimesh", "type": "TRIMESH", - "link": 20 - } - ], - "outputs": [ - { - "name": "fixed_mesh", - "type": "TRIMESH", - "links": [ - 17 - ] - }, - { - "name": "report", - "type": "STRING", - "links": null - } - ], - "properties": { - "cnr_id": "comfyui-geometrypack", - "ver": "c77434120c2984b0ad1dd308c733117a855a8a36", - "Node name for S&R": "GeomPackFixIntersectionsByPerturbation" - }, - "widgets_values": [ - 0.0001, - 10, - "adaptive", - true - ] - }, - { - "id": 13, - "type": "GeomPackDetectSelfIntersections", - "pos": [ - 92.88357113378721, - 325.19554890815414 - ], - "size": [ - 191.6373046875, - 46 - ], - "flags": {}, - "order": 6, - "mode": 0, - "inputs": [ - { - "name": "trimesh", - "type": "TRIMESH", - "link": 17 + "link": 25 } ], "outputs": [ @@ -215,7 +92,7 @@ "name": "mesh_with_field", "type": "TRIMESH", "links": [ - 18 + 28 ] }, { @@ -233,92 +110,19 @@ }, "widgets_values": [] }, - { - "id": 2, - "type": "GeomPackDetectSelfIntersections", - "pos": [ - -262.875842931005, - -249.03333148672056 - ], - "size": [ - 191.6373046875, - 46 - ], - "flags": {}, - "order": 1, - "mode": 0, - "inputs": [ - { - "name": "trimesh", - "type": "TRIMESH", - "link": 13 - } - ], - "outputs": [ - { - "name": "mesh_with_field", - "type": "TRIMESH", - "links": [ - 16 - ] - }, - { - "name": "report", - "type": "STRING", - "links": [ - 22 - ] - } - ], - "properties": { - "cnr_id": "comfyui-geometrypack", - "ver": "253a956368261760da54dc96750c2ab0224e2b1f", - "Node name for S&R": "GeomPackDetectSelfIntersections" - }, - "widgets_values": [] - }, - { - "id": 16, - "type": "PreviewAny", - "pos": [ - 5.2375924142305585, - -115.34899783250772 - ], - "size": [ - 210, - 188.05807589552933 - ], - "flags": {}, - "order": 4, - "mode": 0, - "inputs": [ - { - "name": "source", - "type": "*", - "link": 22 - } - ], - "outputs": [], - "properties": { - "cnr_id": "comfy-core", - "ver": "0.3.68", - "Node name for S&R": "PreviewAny" - }, - "widgets_values": [] - }, { "id": 15, "type": "PreviewAny", "pos": [ - 77.77449234502006, - 515.8574693032172 + -409.15778316693945, + 315.03241892560544 ], "size": [ 228.0678339686183, 186.03206055464034 ], "flags": {}, - "order": 8, + "order": 7, "mode": 0, "inputs": [ { @@ -334,41 +138,164 @@ "Node name for S&R": "PreviewAny" }, "widgets_values": [] + }, + { + "id": 19, + "type": "GeomPackPreviewMeshVTKFields", + "pos": [ + -138.69342985165528, + 218.87010912332047 + ], + "size": [ + 512, + 598 + ], + "flags": {}, + "order": 6, + "mode": 0, + "inputs": [ + { + "name": "trimesh", + "type": "TRIMESH", + "link": 28 + } + ], + "outputs": [], + "properties": { + "cnr_id": "comfyui-geometrypack", + "ver": "8b8322e2f784d84097cd3427f097b55dc9a29d2a", + "Node name for S&R": "GeomPackPreviewMeshVTKFields" + }, + "widgets_values": [ + "" + ] + }, + { + "id": 14, + "type": "GeomPackDetectSelfIntersections", + "pos": [ + -446.7936200272169, + -59.79642561409345 + ], + "size": [ + 191.6373046875, + 46 + ], + "flags": {}, + "order": 1, + "mode": 0, + "inputs": [ + { + "name": "trimesh", + "type": "TRIMESH", + "link": 19 + } + ], + "outputs": [ + { + "name": "mesh_with_field", + "type": "TRIMESH", + "links": [ + 23, + 29 + ] + }, + { + "name": "report", + "type": "STRING", + "links": [ + 27 + ] + } + ], + "properties": { + "cnr_id": "comfyui-geometrypack", + "ver": "c77434120c2984b0ad1dd308c733117a855a8a36", + "Node name for S&R": "GeomPackDetectSelfIntersections" + }, + "widgets_values": [] + }, + { + "id": 18, + "type": "GeomPackPreviewMeshVTKFields", + "pos": [ + 392.49702699450677, + -85.99595077667209 + ], + "size": [ + 512, + 598 + ], + "flags": {}, + "order": 3, + "mode": 0, + "inputs": [ + { + "name": "trimesh", + "type": "TRIMESH", + "link": 29 + } + ], + "outputs": [], + "properties": { + "cnr_id": "comfyui-geometrypack", + "ver": "8b8322e2f784d84097cd3427f097b55dc9a29d2a", + "Node name for S&R": "GeomPackPreviewMeshVTKFields" + }, + "widgets_values": [ + "" + ] + }, + { + "id": 17, + "type": "GeomPackFixSelfIntersectionsByPerturbation", + "pos": [ + -729.5761795725482, + 67.01536068887431 + ], + "size": [ + 319.319921875, + 174 + ], + "flags": {}, + "order": 2, + "mode": 0, + "inputs": [ + { + "name": "trimesh", + "type": "TRIMESH", + "link": 23 + } + ], + "outputs": [ + { + "name": "fixed_mesh", + "type": "TRIMESH", + "links": [ + 25 + ] + }, + { + "name": "report", + "type": "STRING", + "links": null + } + ], + "properties": { + "cnr_id": "comfyui-geometrypack", + "ver": "8b8322e2f784d84097cd3427f097b55dc9a29d2a", + "Node name for S&R": "GeomPackFixSelfIntersectionsByPerturbation" + }, + "widgets_values": [ + 0.0001, + 12, + "adaptive", + true, + true + ] } ], "links": [ - [ - 13, - 1, - 0, - 2, - 0, - "TRIMESH" - ], - [ - 16, - 2, - 0, - 12, - 0, - "TRIMESH" - ], - [ - 17, - 11, - 0, - 13, - 0, - "TRIMESH" - ], - [ - 18, - 13, - 0, - 4, - 0, - "TRIMESH" - ], [ 19, 1, @@ -377,14 +304,6 @@ 0, "TRIMESH" ], - [ - 20, - 14, - 0, - 11, - 0, - "TRIMESH" - ], [ 21, 13, @@ -394,22 +313,54 @@ "*" ], [ - 22, - 2, + 23, + 14, + 0, + 17, + 0, + "TRIMESH" + ], + [ + 25, + 17, + 0, + 13, + 0, + "TRIMESH" + ], + [ + 27, + 14, 1, 16, 0, "*" + ], + [ + 28, + 13, + 0, + 19, + 0, + "TRIMESH" + ], + [ + 29, + 14, + 0, + 18, + 0, + "TRIMESH" ] ], "groups": [], "config": {}, "extra": { "ds": { - "scale": 0.703736650569655, + "scale": 0.6397605914269592, "offset": [ - 873.4369348389099, - 342.74007853075705 + 1105.9482911129744, + 150.99182977744076 ] }, "frontendVersion": "1.28.8"