Updated docs, dual view node, boolean ops

This commit is contained in:
Andrea Pozzetti
2025-11-19 23:11:44 +01:00
parent c886682c92
commit eeaa30307b
20 changed files with 1228 additions and 1002 deletions
+2 -1
View File
@@ -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
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

After

Width:  |  Height:  |  Size: 229 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 282 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 263 KiB

+38 -24
View File
@@ -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):
+13 -3
View File
@@ -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",
}
+3 -10
View File
@@ -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 = {}
-237
View File
@@ -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)",
}
-153
View File
@@ -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)",
}
-103
View File
@@ -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)",
}
-104
View File
@@ -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)",
}
-96
View File
@@ -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)",
}
+3
View File
@@ -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)
@@ -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)",
}
@@ -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)",
}
+261
View File
@@ -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 = '<span style="color: #888;">Dual mesh info will appear here after execution</span>';
// 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 = `<div style="color: #ff6b6b; padding: 8px;">Error: ${event.data.error}</div>`;
}
}
});
// 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 = `
<div style="margin-bottom: 8px; padding-bottom: 8px; border-bottom: 1px solid #444;">
<div style="font-size: 11px; font-weight: bold; color: #fff; margin-bottom: 4px;">Combined Mesh</div>
<div style="display: grid; grid-template-columns: auto 1fr; gap: 2px 8px; font-size: 10px;">
<span style="color: #888;">Total Vertices:</span>
<span>${typeof totalVertices === 'number' ? totalVertices.toLocaleString() : totalVertices}</span>
<span style="color: #888;">Total Faces:</span>
<span>${typeof totalFaces === 'number' ? totalFaces.toLocaleString() : totalFaces}</span>
</div>
</div>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 12px;">
<div>
<div style="font-size: 11px; font-weight: bold; margin-bottom: 4px;">
<span style="color: ${colorDisplay[meshAColor] || '#ff6b6b'};">● Mesh A</span>
</div>
<div style="display: grid; grid-template-columns: auto 1fr; gap: 2px 6px; font-size: 9px;">
<span style="color: #888;">Vertices:</span>
<span>${typeof meshAVertices === 'number' ? meshAVertices.toLocaleString() : meshAVertices}</span>
<span style="color: #888;">Faces:</span>
<span>${typeof meshAFaces === 'number' ? meshAFaces.toLocaleString() : meshAFaces}</span>
`;
if (meshAWatertight !== undefined) {
const watertightA = meshAWatertight ? 'Yes' : 'No';
const colorA = meshAWatertight ? '#6c6' : '#c66';
infoHTML += `
<span style="color: #888;">Watertight:</span>
<span style="color: ${colorA};">${watertightA}</span>
`;
}
infoHTML += `
</div>
</div>
<div>
<div style="font-size: 11px; font-weight: bold; margin-bottom: 4px;">
<span style="color: ${colorDisplay[meshBColor] || '#64a0ff'};">● Mesh B</span>
</div>
<div style="display: grid; grid-template-columns: auto 1fr; gap: 2px 6px; font-size: 9px;">
<span style="color: #888;">Vertices:</span>
<span>${typeof meshBVertices === 'number' ? meshBVertices.toLocaleString() : meshBVertices}</span>
<span style="color: #888;">Faces:</span>
<span>${typeof meshBFaces === 'number' ? meshBFaces.toLocaleString() : meshBFaces}</span>
`;
if (meshBWatertight !== undefined) {
const watertightB = meshBWatertight ? 'Yes' : 'No';
const colorB = meshBWatertight ? '#6c6' : '#c66';
infoHTML += `
<span style="color: #888;">Watertight:</span>
<span style="color: ${colorB};">${watertightB}</span>
`;
}
infoHTML += `
</div>
</div>
</div>
`;
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");
+214
View File
@@ -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");
+294
View File
@@ -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
}
+220 -269
View File
@@ -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"