Files
PozzettiAndrea-ComfyUI-Geom…/nodes.py
T
2025-11-14 21:38:06 +01:00

1082 lines
32 KiB
Python

"""
ComfyUI GeomPack - Custom Nodes for Geometry Processing
Now using trimesh and CGAL for enhanced mesh processing
"""
import numpy as np
import trimesh
import os
import subprocess
import tempfile
import shutil
from pathlib import Path
# ComfyUI folder paths
try:
import folder_paths
COMFYUI_INPUT_FOLDER = folder_paths.get_input_directory()
COMFYUI_OUTPUT_FOLDER = folder_paths.get_output_directory()
except:
# Fallback if folder_paths not available (e.g., during testing)
COMFYUI_INPUT_FOLDER = None
COMFYUI_OUTPUT_FOLDER = None
# Handle both relative and absolute imports
try:
from . import mesh_utils
except ImportError:
import mesh_utils
def _find_blender():
"""
Find Blender executable on the system.
Returns:
str: Path to Blender executable
Raises:
RuntimeError: If Blender not found
"""
# Try common locations
common_paths = [
'blender', # In PATH
'/Applications/Blender.app/Contents/MacOS/Blender', # macOS
'C:\\Program Files\\Blender Foundation\\Blender\\blender.exe', # Windows
'/usr/bin/blender', # Linux
'/usr/local/bin/blender', # Linux
]
for path in common_paths:
if shutil.which(path) or os.path.exists(path):
print(f"[Blender] Found Blender at: {path}")
return path
raise RuntimeError(
"Blender not found. Please install Blender and ensure it's in your PATH.\n"
"Download from: https://www.blender.org/download/"
)
class ExampleLibiglNode:
"""
Example node demonstrating the ComfyUI node structure.
This is a cookie-cutter template - replace with actual functionality.
"""
def __init__(self):
pass
@classmethod
def INPUT_TYPES(cls):
"""
Define the input parameters for this node.
Returns:
dict: Input type definitions with 'required' and 'optional' keys
"""
return {
"required": {
"text_input": ("STRING", {
"default": "Hello from GeomPack!",
"multiline": False
}),
"number_input": ("INT", {
"default": 10,
"min": 0,
"max": 100,
"step": 1
}),
"float_input": ("FLOAT", {
"default": 1.0,
"min": 0.0,
"max": 10.0,
"step": 0.1
}),
"mode": (["option1", "option2", "option3"], {
"default": "option1"
}),
},
"optional": {
"optional_input": ("STRING", {
"default": ""
}),
}
}
RETURN_TYPES = ("STRING", "INT", "FLOAT")
RETURN_NAMES = ("output_text", "output_number", "output_float")
FUNCTION = "execute"
CATEGORY = "geompack/examples"
OUTPUT_NODE = False
def execute(self, text_input, number_input, float_input, mode, optional_input=""):
"""
Execute the node logic.
Args:
text_input: String input
number_input: Integer input
float_input: Float input
mode: Selected mode from combo box
optional_input: Optional string input
Returns:
tuple: (output_text, output_number, output_float)
"""
# Example processing
result_text = f"{text_input} | Mode: {mode}"
if optional_input:
result_text += f" | Optional: {optional_input}"
result_number = number_input * 2
result_float = float_input * 1.5
print(f"[ExampleNode] Processing: {result_text}")
return (result_text, result_number, result_float)
class MeshInfoNode:
"""
Display detailed mesh information and statistics.
Now using trimesh for enhanced mesh analysis.
"""
def __init__(self):
pass
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"mesh": ("MESH",),
},
}
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("info",)
FUNCTION = "get_mesh_info"
CATEGORY = "geompack/analysis"
def get_mesh_info(self, mesh):
"""
Get information about the mesh.
Args:
mesh: trimesh.Trimesh object
Returns:
tuple: (info_string,)
"""
info = mesh_utils.compute_mesh_info(mesh)
return (info,)
class LoadMesh:
"""
Load a mesh from file (OBJ, PLY, STL, OFF, etc.)
Now returns trimesh.Trimesh objects for better mesh handling.
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"file_path": ("STRING", {
"default": "",
"multiline": False
}),
},
}
RETURN_TYPES = ("MESH",)
RETURN_NAMES = ("mesh",)
FUNCTION = "load_mesh"
CATEGORY = "geompack/io"
def load_mesh(self, file_path):
"""
Load mesh from file.
Looks for files in ComfyUI's input folder first, then tries absolute path.
Args:
file_path: Path to mesh file (relative to input folder or absolute)
Returns:
tuple: (trimesh.Trimesh,)
"""
if not file_path or file_path.strip() == "":
raise ValueError("File path cannot be empty")
# Try to find the file
full_path = None
# First, try in ComfyUI input folder
if COMFYUI_INPUT_FOLDER is not None:
input_path = os.path.join(COMFYUI_INPUT_FOLDER, file_path)
if os.path.exists(input_path):
full_path = input_path
print(f"[LoadMesh] Found mesh in input folder: {file_path}")
# If not found in input folder, try as absolute path
if full_path is None:
if os.path.exists(file_path):
full_path = file_path
print(f"[LoadMesh] Loading from absolute path: {file_path}")
else:
# Try one more time with input folder prefix for better error message
if COMFYUI_INPUT_FOLDER is not None:
raise ValueError(
f"File not found: '{file_path}'\n"
f"Searched in:\n"
f" - ComfyUI input folder: {os.path.join(COMFYUI_INPUT_FOLDER, file_path)}\n"
f" - Absolute path: {file_path}"
)
else:
raise ValueError(f"File not found: {file_path}")
# Load the mesh
mesh, error = mesh_utils.load_mesh_file(full_path)
if mesh is None:
raise ValueError(f"Failed to load mesh: {error}")
print(f"[LoadMesh] Loaded: {len(mesh.vertices)} vertices, {len(mesh.faces)} faces")
return (mesh,)
class SaveMesh:
"""
Save a mesh to file (OBJ, PLY, STL, OFF, etc.)
Supports all formats provided by trimesh.
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"mesh": ("MESH",),
"file_path": ("STRING", {
"default": "output.obj",
"multiline": False
}),
},
}
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("status",)
FUNCTION = "save_mesh"
CATEGORY = "geompack/io"
OUTPUT_NODE = True
def save_mesh(self, mesh, file_path):
"""
Save mesh to file.
Saves to ComfyUI's output folder if path is relative, otherwise uses absolute path.
Args:
mesh: trimesh.Trimesh object
file_path: Output file path (relative to output folder or absolute)
Returns:
tuple: (status_message,)
"""
if not file_path or file_path.strip() == "":
raise ValueError("File path cannot be empty")
# Determine full output path
full_path = file_path
# If path is relative and we have output folder, use it
if not os.path.isabs(file_path) and COMFYUI_OUTPUT_FOLDER is not None:
full_path = os.path.join(COMFYUI_OUTPUT_FOLDER, file_path)
print(f"[SaveMesh] Saving to output folder: {file_path}")
else:
print(f"[SaveMesh] Saving to: {file_path}")
# Save the mesh
success, error = mesh_utils.save_mesh_file(mesh, full_path)
if not success:
raise ValueError(f"Failed to save mesh: {error}")
status = f"Successfully saved mesh to: {full_path}\n"
status += f" Vertices: {len(mesh.vertices)}\n"
status += f" Faces: {len(mesh.faces)}"
print(f"[SaveMesh] {status}")
return (status,)
class CreatePrimitive:
"""
Create primitive geometry (cube, sphere, plane)
Uses trimesh creation functions for high-quality primitives.
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"shape": (["cube", "sphere", "plane"], {
"default": "cube"
}),
"size": ("FLOAT", {
"default": 1.0,
"min": 0.01,
"max": 100.0,
"step": 0.1
}),
},
"optional": {
"subdivisions": ("INT", {
"default": 2,
"min": 0,
"max": 5,
"step": 1
}),
}
}
RETURN_TYPES = ("MESH",)
RETURN_NAMES = ("mesh",)
FUNCTION = "create_primitive"
CATEGORY = "geompack/primitives"
def create_primitive(self, shape, size, subdivisions=2):
"""
Create a primitive mesh.
Args:
shape: Type of primitive (cube, sphere, plane)
size: Size of the primitive
subdivisions: Number of subdivisions (for sphere and plane)
Returns:
tuple: (trimesh.Trimesh,)
"""
if shape == "cube":
mesh = mesh_utils.create_cube(size)
elif shape == "sphere":
mesh = mesh_utils.create_sphere(radius=size/2.0, subdivisions=subdivisions)
elif shape == "plane":
mesh = mesh_utils.create_plane(size=size, subdivisions=subdivisions)
else:
raise ValueError(f"Unknown shape: {shape}")
print(f"[CreatePrimitive] Created {shape}: {len(mesh.vertices)} vertices, {len(mesh.faces)} faces")
return (mesh,)
class CGALRemeshNode:
"""
CGAL Isotropic Remeshing - Create uniform triangle meshes.
Implements the algorithm from Botsch & Kobbelt (2004).
This remeshing technique creates triangles with target edge length,
resulting in more uniform mesh quality.
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"mesh": ("MESH",),
"target_edge_length": ("FLOAT", {
"default": 0.1,
"min": 0.001,
"max": 10.0,
"step": 0.01,
"display": "number"
}),
"iterations": ("INT", {
"default": 3,
"min": 1,
"max": 20,
"step": 1
}),
},
}
RETURN_TYPES = ("MESH",)
RETURN_NAMES = ("remeshed_mesh",)
FUNCTION = "remesh"
CATEGORY = "geompack/cgal"
def remesh(self, mesh, target_edge_length, iterations):
"""
Apply CGAL isotropic remeshing.
Args:
mesh: Input trimesh.Trimesh object
target_edge_length: Target edge length for remeshed triangles
iterations: Number of remeshing iterations
Returns:
tuple: (remeshed_trimesh.Trimesh,)
"""
print(f"[CGALRemesh] Input: {len(mesh.vertices)} vertices, {len(mesh.faces)} faces")
print(f"[CGALRemesh] Target edge length: {target_edge_length}, Iterations: {iterations}")
remeshed_mesh, error = mesh_utils.cgal_isotropic_remesh(
mesh,
target_edge_length,
iterations
)
if remeshed_mesh is None:
raise ValueError(f"Remeshing failed: {error}")
print(f"[CGALRemesh] Output: {len(remeshed_mesh.vertices)} vertices, {len(remeshed_mesh.faces)} faces")
return (remeshed_mesh,)
class BlenderUVUnwrapNode:
"""
UV Unwrap mesh using Blender's Smart UV Project.
This node uses Blender's advanced UV unwrapping algorithm to generate
UV coordinates for texturing. The mesh is exported with UV coordinates.
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"mesh": ("MESH",),
"angle_limit": ("FLOAT", {
"default": 66.0,
"min": 1.0,
"max": 89.0,
"step": 1.0,
"display": "number"
}),
"island_margin": ("FLOAT", {
"default": 0.02,
"min": 0.0,
"max": 1.0,
"step": 0.01,
"display": "number"
}),
},
}
RETURN_TYPES = ("MESH",)
RETURN_NAMES = ("unwrapped_mesh",)
FUNCTION = "uv_unwrap"
CATEGORY = "geompack/blender"
def uv_unwrap(self, mesh, angle_limit, island_margin):
"""
UV unwrap mesh using Blender's Smart UV Project.
Args:
mesh: Input trimesh.Trimesh object
angle_limit: Angle threshold for creating seams (degrees)
island_margin: Spacing between UV islands (0-1)
Returns:
tuple: (unwrapped_trimesh.Trimesh,)
"""
print(f"[BlenderUVUnwrap] Input: {len(mesh.vertices)} vertices, {len(mesh.faces)} faces")
print(f"[BlenderUVUnwrap] Parameters: angle_limit={angle_limit}°, island_margin={island_margin}")
# Find Blender
blender_path = _find_blender()
# Create temp files
with tempfile.NamedTemporaryFile(suffix='.obj', delete=False) as f_in:
input_path = f_in.name
mesh.export(input_path)
with tempfile.NamedTemporaryFile(suffix='.obj', delete=False) as f_out:
output_path = f_out.name
try:
# Blender script for UV unwrapping
script = f"""
import bpy
import math
# Clear scene
bpy.ops.object.select_all(action='SELECT')
bpy.ops.object.delete()
# Import mesh (OBJ preserves geometry)
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')
bpy.ops.uv.smart_project(
angle_limit={np.radians(angle_limit)},
island_margin={island_margin},
area_weight=0.0,
correct_aspect=True,
scale_to_bounds=False
)
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"[BlenderUVUnwrap] 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
print(f"[BlenderUVUnwrap] Loading unwrapped mesh...")
unwrapped = trimesh.load(output_path, process=False)
# If it's a scene, dump to single mesh
if isinstance(unwrapped, trimesh.Scene):
unwrapped = unwrapped.dump(concatenate=True)
# Preserve metadata
unwrapped.metadata = mesh.metadata.copy()
unwrapped.metadata['uv_unwrap'] = {
'algorithm': 'blender_smart_uv',
'angle_limit': angle_limit,
'island_margin': island_margin
}
print(f"[BlenderUVUnwrap] ✓ Complete: {len(unwrapped.vertices)} vertices, {len(unwrapped.faces)} faces")
return (unwrapped,)
finally:
# Cleanup temp files
if os.path.exists(input_path):
os.unlink(input_path)
if os.path.exists(output_path):
os.unlink(output_path)
class BlenderVoxelRemeshNode:
"""
Voxel-based remeshing using Blender.
Creates a new mesh by voxelizing the input mesh and reconstructing
the surface. Good for creating uniform, watertight meshes.
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"mesh": ("MESH",),
"voxel_size": ("FLOAT", {
"default": 0.05,
"min": 0.001,
"max": 1.0,
"step": 0.01,
"display": "number"
}),
},
}
RETURN_TYPES = ("MESH",)
RETURN_NAMES = ("remeshed_mesh",)
FUNCTION = "voxel_remesh"
CATEGORY = "geompack/blender"
def voxel_remesh(self, mesh, voxel_size):
"""
Apply voxel remeshing using Blender.
Args:
mesh: Input trimesh.Trimesh object
voxel_size: Voxel size for remeshing (smaller = higher resolution)
Returns:
tuple: (remeshed_trimesh.Trimesh,)
"""
print(f"[BlenderVoxelRemesh] Input: {len(mesh.vertices)} vertices, {len(mesh.faces)} faces")
print(f"[BlenderVoxelRemesh] Voxel size: {voxel_size}")
# Find Blender
blender_path = _find_blender()
# Create temp files
with tempfile.NamedTemporaryFile(suffix='.obj', delete=False) as f_in:
input_path = f_in.name
mesh.export(input_path)
with tempfile.NamedTemporaryFile(suffix='.obj', delete=False) as f_out:
output_path = f_out.name
try:
# Blender script for voxel remeshing
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
# Apply voxel remesh
obj.data.remesh_voxel_size = {voxel_size}
bpy.ops.object.voxel_remesh()
# Export remeshed object
bpy.ops.wm.obj_export(
filepath='{output_path}',
export_selected_objects=True,
export_uv=False,
export_materials=False
)
"""
print(f"[BlenderVoxelRemesh] 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 remeshed mesh
print(f"[BlenderVoxelRemesh] Loading remeshed mesh...")
remeshed = trimesh.load(output_path, process=False)
# If it's a scene, dump to single mesh
if isinstance(remeshed, trimesh.Scene):
remeshed = remeshed.dump(concatenate=True)
# Preserve metadata
remeshed.metadata = mesh.metadata.copy()
remeshed.metadata['remeshing'] = {
'algorithm': 'blender_voxel',
'voxel_size': voxel_size,
'original_vertices': len(mesh.vertices),
'original_faces': len(mesh.faces),
'remeshed_vertices': len(remeshed.vertices),
'remeshed_faces': len(remeshed.faces)
}
vertex_change = len(remeshed.vertices) - len(mesh.vertices)
face_change = len(remeshed.faces) - len(mesh.faces)
print(f"[BlenderVoxelRemesh] ✓ Complete:")
print(f"[BlenderVoxelRemesh] Vertices: {len(mesh.vertices)} -> {len(remeshed.vertices)} ({vertex_change:+d})")
print(f"[BlenderVoxelRemesh] Faces: {len(mesh.faces)} -> {len(remeshed.faces)} ({face_change:+d})")
return (remeshed,)
finally:
# Cleanup temp files
if os.path.exists(input_path):
os.unlink(input_path)
if os.path.exists(output_path):
os.unlink(output_path)
class PreviewMeshNode:
"""
Preview mesh with interactive 3D viewer.
Displays mesh in an interactive Three.js viewer with orbit controls.
Allows rotating, panning, and zooming to inspect the mesh geometry.
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"mesh": ("MESH",),
},
}
RETURN_TYPES = ()
OUTPUT_NODE = True
FUNCTION = "preview_mesh"
CATEGORY = "geompack/visualization"
def preview_mesh(self, mesh):
"""
Export mesh to GLB and prepare for 3D preview.
Args:
mesh: Input trimesh.Trimesh object
Returns:
dict: UI data for frontend widget
"""
import uuid
print(f"[PreviewMesh] Preparing preview: {len(mesh.vertices)} vertices, {len(mesh.faces)} faces")
# Generate unique filename
filename = f"preview_{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 (best format for Three.js)
try:
mesh.export(filepath, file_type='glb')
print(f"[PreviewMesh] Exported to: {filepath}")
except Exception as e:
print(f"[PreviewMesh] Export failed: {e}")
# Fallback to OBJ
filename = filename.replace('.glb', '.obj')
filepath = filepath.replace('.glb', '.obj')
mesh.export(filepath, file_type='obj')
print(f"[PreviewMesh] Exported to OBJ: {filepath}")
# Calculate bounding box info for camera setup
bounds = mesh.bounds
extents = mesh.extents
max_extent = max(extents)
# Return metadata for frontend widget
return {
"ui": {
"mesh_file": [filename],
"vertex_count": [len(mesh.vertices)],
"face_count": [len(mesh.faces)],
"bounds_min": [bounds[0].tolist()],
"bounds_max": [bounds[1].tolist()],
"extents": [extents.tolist()],
"max_extent": [float(max_extent)],
}
}
class PreviewMeshVTKNode:
"""
Preview mesh with VTK.js scientific visualization viewer.
Displays mesh in an interactive VTK.js viewer with trackball controls.
Better for scientific visualization, mesh analysis, and large datasets.
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"mesh": ("MESH",),
},
}
RETURN_TYPES = ()
OUTPUT_NODE = True
FUNCTION = "preview_mesh_vtk"
CATEGORY = "geompack/visualization"
def preview_mesh_vtk(self, mesh):
"""
Export mesh to STL and prepare for VTK.js preview.
Args:
mesh: Input trimesh.Trimesh object
Returns:
dict: UI data for frontend widget
"""
import uuid
print(f"[PreviewMeshVTK] Preparing preview: {len(mesh.vertices)} vertices, {len(mesh.faces)} faces")
# Generate unique filename
filename = f"preview_vtk_{uuid.uuid4().hex[:8]}.stl"
# 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 STL (native format for VTK.js)
try:
mesh.export(filepath, file_type='stl')
print(f"[PreviewMeshVTK] Exported to: {filepath}")
except Exception as e:
print(f"[PreviewMeshVTK] Export failed: {e}")
# Fallback to OBJ
filename = filename.replace('.stl', '.obj')
filepath = filepath.replace('.stl', '.obj')
mesh.export(filepath, file_type='obj')
print(f"[PreviewMeshVTK] Exported to OBJ: {filepath}")
# Calculate bounding box info for camera setup
bounds = mesh.bounds
extents = mesh.extents
max_extent = max(extents)
# Return metadata for frontend widget
return {
"ui": {
"mesh_file": [filename],
"vertex_count": [len(mesh.vertices)],
"face_count": [len(mesh.faces)],
"bounds_min": [bounds[0].tolist()],
"bounds_max": [bounds[1].tolist()],
"extents": [extents.tolist()],
"max_extent": [float(max_extent)],
}
}
class CenterMeshNode:
"""
Center mesh at origin (0, 0, 0).
Uses bounding box center to translate the mesh so its center
is at the world origin. Useful for preparing meshes for export
or ensuring consistent positioning.
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"mesh": ("MESH",),
},
}
RETURN_TYPES = ("MESH",)
RETURN_NAMES = ("centered_mesh",)
FUNCTION = "center_mesh"
CATEGORY = "geompack/transforms"
def center_mesh(self, mesh):
"""
Center mesh at origin using bounding box center.
Args:
mesh: Input trimesh.Trimesh object
Returns:
tuple: (centered_trimesh.Trimesh,)
"""
print(f"[CenterMesh] Input: {len(mesh.vertices)} vertices, {len(mesh.faces)} faces")
# Calculate bounding box center
bounds_center = (mesh.bounds[0] + mesh.bounds[1]) / 2.0
print(f"[CenterMesh] Original center: [{bounds_center[0]:.3f}, {bounds_center[1]:.3f}, {bounds_center[2]:.3f}]")
# Apply translation to center at origin
mesh_centered = mesh.copy()
mesh_centered.apply_translation(-bounds_center)
# Verify centering
new_center = (mesh_centered.bounds[0] + mesh_centered.bounds[1]) / 2.0
print(f"[CenterMesh] New center: [{new_center[0]:.3f}, {new_center[1]:.3f}, {new_center[2]:.3f}]")
# Preserve metadata
mesh_centered.metadata = mesh.metadata.copy()
mesh_centered.metadata['centered'] = True
mesh_centered.metadata['original_center'] = bounds_center.tolist()
return (mesh_centered,)
class BlenderQuadriflowRemeshNode:
"""
Quadriflow remeshing using Blender.
Creates a quad-dominant mesh with good topology. Better for animation
and subdivision surfaces than triangle-based remeshing.
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"mesh": ("MESH",),
"target_face_count": ("INT", {
"default": 5000,
"min": 100,
"max": 100000,
"step": 100
}),
},
}
RETURN_TYPES = ("MESH",)
RETURN_NAMES = ("remeshed_mesh",)
FUNCTION = "quadriflow_remesh"
CATEGORY = "geompack/blender"
def quadriflow_remesh(self, mesh, target_face_count):
"""
Apply Quadriflow remeshing using Blender.
Args:
mesh: Input trimesh.Trimesh object
target_face_count: Target number of faces in output mesh
Returns:
tuple: (remeshed_trimesh.Trimesh,)
"""
print(f"[BlenderQuadriflow] Input: {len(mesh.vertices)} vertices, {len(mesh.faces)} faces")
print(f"[BlenderQuadriflow] Target face count: {target_face_count}")
# Find Blender
blender_path = _find_blender()
# Create temp files
with tempfile.NamedTemporaryFile(suffix='.obj', delete=False) as f_in:
input_path = f_in.name
mesh.export(input_path)
with tempfile.NamedTemporaryFile(suffix='.obj', delete=False) as f_out:
output_path = f_out.name
try:
# Blender script for Quadriflow remeshing
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
# Apply Quadriflow remesh
# Note: Different Blender versions have different parameters
# Using minimal set for maximum compatibility
bpy.ops.object.quadriflow_remesh(
use_mesh_symmetry=False,
use_preserve_sharp=False,
use_preserve_boundary=False,
smooth_normals=False,
mode='FACES',
target_faces={target_face_count},
seed=0
)
# Export remeshed object
bpy.ops.wm.obj_export(
filepath='{output_path}',
export_selected_objects=True,
export_uv=False,
export_materials=False
)
"""
print(f"[BlenderQuadriflow] 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 remeshed mesh
print(f"[BlenderQuadriflow] Loading remeshed mesh...")
remeshed = trimesh.load(output_path, process=False)
# If it's a scene, dump to single mesh
if isinstance(remeshed, trimesh.Scene):
remeshed = remeshed.dump(concatenate=True)
# Quadriflow produces quads, but trimesh will triangulate them
# Preserve metadata
remeshed.metadata = mesh.metadata.copy()
remeshed.metadata['remeshing'] = {
'algorithm': 'blender_quadriflow',
'target_face_count': target_face_count,
'original_vertices': len(mesh.vertices),
'original_faces': len(mesh.faces),
'remeshed_vertices': len(remeshed.vertices),
'remeshed_faces': len(remeshed.faces)
}
vertex_change = len(remeshed.vertices) - len(mesh.vertices)
face_change = len(remeshed.faces) - len(mesh.faces)
print(f"[BlenderQuadriflow] ✓ Complete:")
print(f"[BlenderQuadriflow] Vertices: {len(mesh.vertices)} -> {len(remeshed.vertices)} ({vertex_change:+d})")
print(f"[BlenderQuadriflow] Faces: {len(mesh.faces)} -> {len(remeshed.faces)} ({face_change:+d})")
return (remeshed,)
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 - this dictionary maps internal node names to classes
NODE_CLASS_MAPPINGS = {
"GeomPackExampleNode": ExampleLibiglNode,
"GeomPackMeshInfo": MeshInfoNode,
"GeomPackLoadMesh": LoadMesh,
"GeomPackSaveMesh": SaveMesh,
"GeomPackCreatePrimitive": CreatePrimitive,
"GeomPackCGALRemesh": CGALRemeshNode,
"GeomPackCenterMesh": CenterMeshNode,
"GeomPackPreviewMesh": PreviewMeshNode,
"GeomPackPreviewMeshVTK": PreviewMeshVTKNode,
"GeomPackBlenderUVUnwrap": BlenderUVUnwrapNode,
"GeomPackBlenderVoxelRemesh": BlenderVoxelRemeshNode,
"GeomPackBlenderQuadriflowRemesh": BlenderQuadriflowRemeshNode,
}
# Display name mappings - these are the names shown in ComfyUI's node browser
NODE_DISPLAY_NAME_MAPPINGS = {
"GeomPackExampleNode": "Example Node",
"GeomPackMeshInfo": "Mesh Info",
"GeomPackLoadMesh": "Load Mesh",
"GeomPackSaveMesh": "Save Mesh",
"GeomPackCreatePrimitive": "Create Primitive",
"GeomPackCGALRemesh": "CGAL Remesh (Isotropic)",
"GeomPackCenterMesh": "Center Mesh",
"GeomPackPreviewMesh": "Preview Mesh (3D)",
"GeomPackPreviewMeshVTK": "Preview Mesh (VTK)",
"GeomPackBlenderUVUnwrap": "Blender UV Unwrap",
"GeomPackBlenderVoxelRemesh": "Blender Voxel Remesh",
"GeomPackBlenderQuadriflowRemesh": "Blender Quadriflow Remesh",
}