diff --git a/README.md b/README.md index fe3d716e..582f94ff 100644 --- a/README.md +++ b/README.md @@ -1,531 +1,75 @@ -# ComfyUI-GeomPack +# ComfyUI-GeometryPack -Geometry processing custom nodes for ComfyUI, powered by [trimesh](https://trimesh.org/), [libigl](https://libigl.github.io/), and [CGAL](https://www.cgal.org/). +**⚠️ Work in Progress** - Active development, expect breaking changes. -## Overview - -ComfyUI-GeomPack provides a comprehensive set of mesh processing nodes for ComfyUI, combining the power of multiple geometry processing libraries. Perfect for mesh cleanup, optimization, remeshing, analysis, and transformation workflows. +Professional geometry processing nodes for ComfyUI. Load, analyze, remesh, unwrap, and visualize 3D meshes directly in your workflows. ## Features -- **Mesh I/O**: Load and save OBJ, PLY, STL, OFF, and more formats via trimesh -- **Dual 3D Preview**: Two interactive viewers - Three.js for general use, VTK.js for scientific visualization -- **Mesh Analysis**: Display statistics, compute normals, analyze geometry properties -- **Mesh Transforms**: Center meshes at origin with one click -- **CGAL Remeshing**: Isotropic remeshing for uniform triangle quality (via PyMeshLab) -- **Blender Integration**: UV unwrapping and remeshing via Blender subprocess -- **Primitive Creation**: Generate cubes, spheres, and planes -- **Trimesh Integration**: Enhanced mesh handling with automatic validation -- **High-poly Support**: Optimized for meshes with 100K+ faces +**Core Operations** +- Load/Save meshes (OBJ, PLY, STL, OFF, etc.) +- Primitive generation (cube, sphere, plane) +- Mesh analysis and statistics +- Interactive 3D preview (Three.js & VTK.js) + +**Remeshing** +- PyMeshLab isotropic remeshing +- CGAL isotropic remeshing (optional) +- Blender voxel & quadriflow remeshing + +**UV Mapping** +- xAtlas UV unwrapping (fast, no dependencies) +- libigl LSCM conformal mapping +- Blender projections (cube, cylinder, sphere) + +**Analysis** +- Boundary edge detection +- Hausdorff & Chamfer distance +- Signed distance fields (SDF) +- Point cloud conversion ## Installation -### Method 1: Manual Installation - -1. Clone this repository into your ComfyUI custom nodes directory: ```bash cd ComfyUI/custom_nodes/ -git clone https://github.com/yourusername/ComfyUI-GeomPack.git -``` - -2. Install dependencies: -```bash -cd ComfyUI-GeomPack +git clone https://github.com/yourusername/ComfyUI-GeometryPack.git +cd ComfyUI-GeometryPack pip install -r requirements.txt ``` -3. Install Blender (optional, required for Blender nodes): - - macOS: `brew install --cask blender` or download from https://www.blender.org/download/ - - Linux: `sudo apt install blender` or `sudo snap install blender` - - Windows: Download from https://www.blender.org/download/ +Restart ComfyUI. Nodes appear in the `geompack/` category. -4. Restart ComfyUI - -### Method 2: ComfyUI Manager (Coming Soon) - -Install via ComfyUI Manager once published to the registry. +**Optional Dependencies:** +- **Blender**: Install for UV unwrapping and remeshing nodes +- **CGAL**: Build tools for CGAL remeshing (see `cgal_tools/README.md`) ## Quick Start -### Basic Workflow -1. **Create a mesh**: Use the `Create Primitive` node to generate a cube, sphere, or plane -2. **View info**: Connect to `Mesh Info` node to see detailed statistics -3. **Apply remeshing**: Use `CGAL Remesh` to create uniform triangle meshes -4. **Save**: Export with `Save Mesh` node - -### CGAL Remeshing Workflow +**Basic workflow:** ``` -Create Primitive (cube) → Mesh Info → CGAL Remesh → Mesh Info → Save Mesh +Create Primitive → Mesh Info → Preview Mesh (3D) +Load Mesh → PyMeshLab Remesh → Save Mesh ``` -**Result**: Converts a simple 8-vertex cube into a uniformly remeshed 386-vertex mesh with consistent triangle sizes! +## Architecture -## Available Nodes +Codebase organized by function in `nodes/` directory: +- **io.py** - Load/Save +- **primitives.py** - Shape generation +- **analysis.py** - Mesh info, boundary detection +- **distance.py** - Hausdorff, Chamfer, SDF +- **conversion.py** - Mesh to point cloud +- **remeshing.py** - PyMeshLab, CGAL, Blender +- **uv.py** - UV unwrapping (xAtlas, libigl, Blender) +- **transforms.py** - Positioning +- **visualization.py** - 3D preview (Three.js, VTK.js) -### geompack/io -- **Load Mesh**: Load meshes from file (OBJ, PLY, STL, OFF, etc.) -- **Save Mesh**: Save meshes to file with trimesh export +All nodes use `trimesh.Trimesh` objects for mesh data. -### geompack/analysis -- **Mesh Info**: Display detailed statistics including: - - Vertex/face/edge counts - - Volume and surface area - - Watertight status - - Bounding box and extents - - Metadata +## Credits -### geompack/analysis/distance -- **Hausdorff Distance**: Compute Hausdorff distance between two meshes - - Measures maximum distance between point sets - - Returns symmetric and one-sided distances - - Configurable sample count - - Useful for measuring worst-case deviation -- **Chamfer Distance**: Compute Chamfer distance between two meshes - - Average of squared nearest neighbor distances - - More sensitive to overall shape similarity - - Configurable sample count - - Useful for shape matching -- **Compute SDF**: Generate Signed Distance Field for a mesh - - Voxel-based representation - - Negative values = inside, positive = outside - - Configurable resolution (16-256³) - - Useful for occupancy queries - -### geompack/conversion -- **Mesh to Point Cloud**: Sample points from mesh surface - - Multiple sampling methods (uniform, even, face_weighted) - - Optional normal computation - - Configurable sample count - - Returns POINT_CLOUD data type - -### geompack/primitives -- **Create Primitive**: Generate geometric primitives - - Cube (8 vertices, 12 faces) - - Sphere (icosphere with subdivision levels) - - Plane (subdivided grid) - -### geompack/pymeshlab -- **PyMeshLab Remesh (Isotropic)**: Isotropic remeshing algorithm - - Creates uniform triangle sizes - - Target edge length control - - Iteration count parameter - - Preserves volume - - Fast, no Blender dependency - -### geompack/transforms -- **Center Mesh**: Center mesh at origin (0, 0, 0) - - Uses bounding box center - - Simple one-click centering - - Preserves mesh metadata - -### geompack/visualization -- **Preview Mesh (3D)**: Interactive 3D mesh viewer - - Powered by Three.js - - Orbit controls (rotate, pan, zoom) - - Real-time preview directly in ComfyUI - - No need to save files first! -- **Preview Mesh (VTK)**: Scientific visualization viewer - - Powered by VTK.js - - Trackball camera controls - - Better for scientific visualization and analysis - - Excellent for large meshes and datasets - -### geompack/uv -- **xAtlas UV Unwrap**: Fast UV unwrapping using xatlas - - No Blender dependency - - Optimized for lightmaps and texture atlasing - - Same algorithm as Blender 3.6+ - - Production-ready quality -- **libigl LSCM Unwrap**: Conformal UV mapping - - Least Squares Conformal Maps - - Angle-preserving parameterization - - Fast, direct solution - - No Blender dependency -- **Blender Cube Projection**: Cube projection UV mapping - - Perfect for box-like geometry - - Projects onto 6 cube faces - - Configurable cube size -- **Blender Cylinder Projection**: Cylinder projection UV mapping - - Perfect for cylindrical objects (bottles, pipes) - - Configurable radius - - Equirectangular projection -- **Blender Sphere Projection**: Sphere projection UV mapping - - Perfect for spherical objects (planets, eyes) - - Equirectangular projection - - Single seam -- **Blender UV Unwrap**: Smart UV unwrapping using Blender - - Angle-based seam detection - - Configurable island margin - - Exports mesh with UV coordinates - -### geompack/blender -- **Blender Voxel Remesh**: Voxel-based remeshing - - Creates uniform, watertight meshes - - Configurable voxel size - - Good for mesh repair -- **Blender Quadriflow Remesh**: Quad-dominant remeshing - - Field-aligned quad mesh generation - - Target face count control - - Better for animation and subdivision - -### geompack/examples -- **Example Node**: Template for creating custom nodes - -## Mesh Data Format - -Meshes are now represented as **`trimesh.Trimesh`** objects, providing: - -```python -mesh = trimesh.Trimesh(vertices=np.ndarray, faces=np.ndarray) - -# Built-in properties: -mesh.vertices # (N, 3) numpy array of XYZ coordinates -mesh.faces # (F, 3) numpy array of triangle indices -mesh.vertex_normals # (N, 3) per-vertex normals (auto-computed) -mesh.bounds # [[min_x, min_y, min_z], [max_x, max_y, max_z]] -mesh.centroid # [x, y, z] center of mass -mesh.volume # Volume of the mesh (if watertight) -mesh.area # Total surface area -mesh.is_watertight # Boolean: mesh is closed? -mesh.metadata # Dictionary for custom data - -# Methods: -mesh.export('output.obj') # Save to file -mesh.apply_transform(matrix) # Transform mesh -# ... and many more! -``` - -## Example Workflows - -### CGAL Isotropic Remeshing -``` -Create Primitive (cube, size=1.0) - → CGAL Remesh (target_edge_length=0.15, iterations=3) - → Mesh Info - → Save Mesh (output.obj) -``` - -**Input**: 8 vertices, 12 faces -**Output**: 386 vertices, 768 faces with uniform triangle sizes -**Volume Preserved**: 1.000000 (0.00% error) - -### Mesh Loading and Analysis -``` -Load Mesh (input.obj) - → Mesh Info (view statistics) - → CGAL Remesh (clean up mesh) - → Save Mesh (output.obj) -``` - -### Primitive Creation -``` -Create Primitive (sphere, subdivisions=3) - → Mesh Info (check watertight status) - → Save Mesh (sphere.stl) -``` - -## Development Status - -**Current Version**: 0.2.0 - -### Recently Completed -- ✅ Migrated from dict-based meshes to trimesh.Trimesh objects -- ✅ Load Mesh (with fallback to libigl) -- ✅ Save Mesh (all trimesh-supported formats) -- ✅ Create Primitive (cube, sphere, plane) -- ✅ Mesh Info (enhanced with volume, watertight detection) -- ✅ PyMeshLab Isotropic Remeshing -- ✅ Blender UV Unwrapping (Smart UV Project) -- ✅ Blender Voxel Remeshing -- ✅ Blender Quadriflow Remeshing -- ✅ Center Mesh (transform to origin) -- ✅ Preview Mesh (3D) - Interactive Three.js viewer widget! -- ✅ Preview Mesh (VTK) - Scientific visualization with VTK.js with controls and mesh info panel! -- ✅ Distance Metrics - Hausdorff, Chamfer, and SDF computation -- ✅ Mesh to Point Cloud conversion with multiple sampling methods -- ✅ xAtlas UV Unwrap - Fast, production-ready UV unwrapping -- ✅ Blender UV Projections - Cube, Cylinder, and Sphere projections -- ✅ libigl LSCM - Conformal UV mapping - -### Coming Soon (Future Phases) -- [ ] Mesh Decimation -- [ ] Mesh Subdivision -- [ ] Mesh Scaling/Rotation nodes -- [ ] Laplacian Smoothing -- [ ] Boolean Operations (CGAL) -- [ ] Mesh Repair -- [ ] PyNanoInstantMeshes integration -- [ ] Native CGAL remeshing (when Python binding issues resolved) - -See [MVP_PLAN.md](MVP_PLAN.md) for the complete roadmap. - -## Requirements - -- Python 3.8+ -- ComfyUI -- **trimesh** 3.15.0+ (core mesh handling) -- **pymeshlab** 2022.2+ (isotropic remeshing) -- **libigl** 2.6.1+ (LSCM UV unwrapping) -- **numpy** 1.21.0+ -- **scipy** 1.7.0+ -- **matplotlib** 3.5.0+ -- **Pillow** 9.0.0+ -- **point-cloud-utils** 0.30.0+ (distance metrics) -- **mesh-to-sdf** 0.0.14+ (SDF computation) -- **xatlas** 0.0.11+ (fast UV unwrapping) -- **Blender** (optional, for Blender-based UV and remeshing nodes) - -## Technical Details - -### CGAL Isotropic Remeshing - -The `CGAL Remesh` node implements the incremental triangle-based isotropic remeshing algorithm from: - -> Botsch, M., & Kobbelt, L. (2004). "A remeshing approach to multiresolution modeling." -> Eurographics Symposium on Geometry Processing. - -**How it works:** -1. Converts trimesh to CGAL Polyhedron_3 -2. Applies isotropic remeshing with: - - **Edge splits**: Edges longer than target length - - **Edge collapses**: Edges shorter than target length - - **Edge flips**: Improve triangle quality - - **Tangential smoothing**: Relax vertex positions -3. Converts back to trimesh with metadata preservation - -**Parameters:** -- `target_edge_length`: Desired edge length for triangles (smaller = denser mesh) -- `iterations`: Number of refinement passes (typically 3-10) - -**Benefits:** -- More uniform triangle distribution -- Better numerical stability for simulations -- Improved mesh quality for rendering -- Volume preservation - -### Blender Integration - -The Blender nodes integrate Blender's powerful mesh processing capabilities via subprocess: - -**How it works:** -1. Mesh is exported to temporary OBJ file -2. Blender is launched in background mode (`--background`) -3. Python script is executed via `--python-expr` -4. Blender performs the operation (UV unwrap, remesh, etc.) -5. Result is exported to temporary OBJ file -6. Result is loaded back into trimesh - -**Available Blender Operations:** - -**UV Unwrapping (Smart UV Project):** -- Automatically creates UV seams based on angle threshold -- Configurable island margin for texture bleeding prevention -- Preserves mesh geometry, adds UV coordinates - -**Voxel Remeshing:** -- Voxelizes the input mesh at specified resolution -- Reconstructs surface from voxel grid -- Creates watertight, uniform meshes -- Good for mesh repair and cleanup - -**Quadriflow Remeshing:** -- Field-aligned quad mesh generation -- Better topology for animation and subdivision -- Target face count control -- Outputs quad-dominant mesh (triangulated by trimesh) - -**Note:** Blender must be installed and accessible in your PATH for these nodes to work. - -### 3D Preview Widgets - -ComfyUI-GeomPack provides two interactive 3D viewers directly in ComfyUI: - -#### Preview Mesh (3D) - Three.js Viewer - -The Three.js viewer provides a general-purpose 3D visualization with excellent rendering quality: - -**Features:** -- **Interactive Camera:** Orbit, pan, and zoom with mouse/trackpad controls -- **Real-time Rendering:** Smooth 60fps rendering with WebGL -- **Automatic Scaling:** Camera automatically adjusts to fit mesh -- **Professional Lighting:** Three-point lighting setup for clear visualization -- **Grid and Axes:** Visual reference for orientation - -**How it works:** -1. Mesh is exported to GLB format (optimized for web) -2. File is saved to ComfyUI's output directory -3. Three.js viewer loads and displays the mesh -4. OrbitControls enable interactive exploration - -**Controls:** -- **Left Mouse:** Rotate camera around mesh -- **Right Mouse / Two-finger drag:** Pan camera -- **Scroll / Pinch:** Zoom in/out -- **Automatic:** Camera resets to fit mesh on load - -**Technical Details:** -- Uses Three.js v0.160.0 from CDN (no local dependencies) -- Supports GLB and OBJ formats -- 512x512px viewport (customizable in code) -- Renders in ComfyUI's output directory (accessible via /view API) - -#### Preview Mesh (VTK) - VTK.js Viewer - -The VTK.js viewer is optimized for scientific visualization and large datasets: - -**Features:** -- **Trackball Controls:** Professional scientific visualization camera controls -- **Scientific Rendering:** Optimized for mesh analysis and inspection -- **High Performance:** Excellent for large meshes (100K+ vertices) -- **Clean Interface:** Focused on geometry visualization without distractions - -**How it works:** -1. Mesh is exported to STL format (native VTK.js format) -2. File is saved to ComfyUI's output directory -3. VTK.js viewer loads and displays the mesh -4. TrackballCamera enables precise exploration - -**Controls:** -- **Left Mouse:** Rotate camera (trackball mode) -- **Middle Mouse / Shift + Left:** Pan camera -- **Right Mouse / Scroll:** Zoom in/out -- **Automatic:** Camera resets to fit mesh on load - -**Technical Details:** -- Uses VTK.js v29.5.0 from Skypack CDN (no local dependencies) -- Supports STL and OBJ formats -- 512x512px viewport (customizable in code) -- Renders in ComfyUI's output directory (accessible via /view API) - -**When to use which viewer:** -- **Three.js**: General 3D visualization, artistic rendering, better lighting -- **VTK.js**: Scientific analysis, large datasets, mesh inspection, precise measurements - -**No External Tools Required:** Unlike other 3D preview solutions, both widgets render directly in the ComfyUI interface - no need to save files or use external viewers! - -## Testing - -Run the test suite to verify installation: - -```bash -python test_nodes.py -``` - -Expected output: -``` -============================================================ -🎉 ALL TESTS PASSED! -============================================================ - -The following nodes are working correctly: - ✓ CreatePrimitive (cube, sphere, plane) - ✓ SaveMesh (OBJ format) - ✓ LoadMesh (OBJ format) - ✓ MeshInfoNode (with trimesh enhancements) - ✓ CGALRemeshNode (isotropic remeshing) -``` - -## Contributing - -Contributions welcome! Please feel free to submit issues or pull requests. - -### Development Setup - -1. Fork the repository -2. Create a feature branch -3. Make your changes -4. Test with `python test_nodes.py` -5. Submit a pull request - -### Adding New Nodes - -Follow the pattern in `nodes.py`: - -```python -class MyCustomNode: - @classmethod - def INPUT_TYPES(cls): - return { - "required": { - "mesh": ("MESH",), # trimesh.Trimesh object - "parameter": ("FLOAT", {"default": 1.0}), - } - } - - RETURN_TYPES = ("MESH",) - FUNCTION = "process" - CATEGORY = "geompack/custom" - - def process(self, mesh, parameter): - # Your processing logic using trimesh API - # ... - return (processed_mesh,) -``` - -## Planned Features - -The following features are planned for future releases: - -### Point Cloud Visualization -- **plas.io Integration**: Advanced point cloud viewer for LAS/LAZ files - - Support for massive point cloud datasets - - Intensity and RGB visualization - - Fast WebGL-based rendering - - Interactive classification tools - -### VTK Viewer Enhancements -- **Interactive Controls** (✅ Implemented) - - Toggle edges on/off - - Switch between surface/wireframe/points rendering modes - - Screenshot capture and save - - Reset camera view -- **Mesh Information Panel** (✅ Implemented) - - Display vertex and face counts - - Show bounding box and extents - - Report watertight status - - Calculate volume and surface area - - List scalar field names -- **Scalar Field Visualization** (In Progress) - - Dropdown selector for different fields - - Color mapping based on field values - - Adjustable color transfer functions - - Scalar bar legend - -### Additional 3D Viewers -- **Gaussian Splatting**: GaussianSplats3D for neural radiance field visualization -- **model-viewer**: glTF/GLB viewer with AR support - -See the [Development Status](#development-status) section for the complete roadmap. +Built on [trimesh](https://trimesh.org/), [libigl](https://libigl.github.io/), [PyMeshLab](https://pymeshlab.readthedocs.io/), and [CGAL](https://www.cgal.org/). ## License MIT License - See LICENSE file for details - -## Credits - -- Built on [trimesh](https://trimesh.org/) by Michael Dawson-Haggerty -- CGAL integration via [CGAL Python bindings](https://github.com/CGAL/cgal-swig-bindings) -- [libigl](https://libigl.github.io/) by Alec Jacobson and others -- Inspired by existing ComfyUI 3D tools and workflows - -## Support - -- **Issues**: Report bugs or request features via GitHub Issues -- **Discussions**: Join the discussion in GitHub Discussions -- **Documentation**: See the [wiki](https://github.com/yourusername/ComfyUI-GeomPack/wiki) for detailed guides - -## Changelog - -### Version 0.2.0 (Current) -- **Major Refactor**: Migrated from dictionary-based meshes to trimesh.Trimesh objects -- **Added**: CGAL isotropic remeshing node -- **Added**: Primitive creation (cube, sphere, plane) -- **Enhanced**: Mesh Info now shows volume, watertight status, and more -- **Improved**: File I/O with trimesh (supports more formats) -- **Updated**: All nodes now use trimesh API -- **Added**: Comprehensive test suite - -### Version 0.1.0 -- Initial MVP release -- Basic node structure and boilerplate -- Example nodes demonstrating functionality diff --git a/__init__.py b/__init__.py index 37a6253a..92d395ca 100644 --- a/__init__.py +++ b/__init__.py @@ -5,6 +5,20 @@ This package provides mesh processing nodes for ComfyUI using trimesh, CGAL, and Includes custom 3D preview widget powered by Three.js. """ +import sys +import os +from pathlib import Path + +# Check if CGAL is available +try: + from CGAL import CGAL_Polygon_mesh_processing + print("[GeomPack] CGAL Python package found - CGAL Isotropic Remesh node available") +except ImportError: + print("[GeomPack] WARNING: CGAL Python package not found") + print("[GeomPack] The CGAL Isotropic Remesh node will not be available") + print("[GeomPack] Install with: pip install cgal") + print("[GeomPack] You can use PyMeshLab Remesh as an alternative") + from .nodes import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS # Set web directory for JavaScript extensions (3D mesh preview widget) diff --git a/nodes.py b/nodes.py deleted file mode 100644 index 806d6a6f..00000000 --- a/nodes.py +++ /dev/null @@ -1,2356 +0,0 @@ -""" -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 _export_mesh_with_scalars_vtp(mesh: trimesh.Trimesh, filepath: str): - """ - Export trimesh to VTK PolyData XML format (.vtp) with vertex scalar attributes. - - VTP format preserves vertex attributes (scalar fields) which can be visualized - in VTK.js with color mapping. - - Args: - mesh: Trimesh object with optional vertex_attributes - filepath: Output .vtp file path - """ - import xml.etree.ElementTree as ET - - print(f"[_export_mesh_with_scalars_vtp] Exporting to VTP: {filepath}") - - # Create VTK PolyData XML structure - vtk_file = ET.Element('VTKFile', type='PolyData', version='1.0', byte_order='LittleEndian') - poly_data = ET.SubElement(vtk_file, 'PolyData') - - num_verts = len(mesh.vertices) - num_faces = len(mesh.faces) - - piece = ET.SubElement(poly_data, 'Piece', NumberOfPoints=str(num_verts), NumberOfPolys=str(num_faces)) - - # Points section - points = ET.SubElement(piece, 'Points') - points_data_array = ET.SubElement(points, 'DataArray', - type='Float32', - NumberOfComponents='3', - format='ascii') - # Flatten vertices to space-separated string - verts_flat = mesh.vertices.flatten() - points_data_array.text = ' '.join(map(str, verts_flat)) - - # PointData section (scalar fields) - point_data = ET.SubElement(piece, 'PointData') - - # Add vertex attributes as scalar arrays - if hasattr(mesh, 'vertex_attributes') and mesh.vertex_attributes: - for attr_name, attr_values in mesh.vertex_attributes.items(): - print(f"[_export_mesh_with_scalars_vtp] Adding scalar field: {attr_name}") - scalar_array = ET.SubElement(point_data, 'DataArray', - type='Float32', - Name=attr_name, - format='ascii') - scalar_array.text = ' '.join(map(str, attr_values.flatten())) - - # Polys section (faces/triangles) - polys = ET.SubElement(piece, 'Polys') - - # Connectivity: vertex indices for each face - connectivity = ET.SubElement(polys, 'DataArray', - type='Int32', - Name='connectivity', - format='ascii') - faces_flat = mesh.faces.flatten() - connectivity.text = ' '.join(map(str, faces_flat)) - - # Offsets: cumulative count of indices (each triangle has 3 vertices) - offsets = ET.SubElement(polys, 'DataArray', - type='Int32', - Name='offsets', - format='ascii') - offset_values = [(i + 1) * 3 for i in range(num_faces)] - offsets.text = ' '.join(map(str, offset_values)) - - # Write to file with pretty formatting - tree = ET.ElementTree(vtk_file) - ET.indent(tree, space=' ') - tree.write(filepath, encoding='utf-8', xml_declaration=True) - - print(f"[_export_mesh_with_scalars_vtp] Export complete: {num_verts} vertices, {num_faces} faces") - - -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 PyMeshLabRemeshNode: - """ - PyMeshLab Isotropic Remeshing - Create uniform triangle meshes. - - Uses PyMeshLab's implementation of isotropic remeshing. - 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/pymeshlab" - - def remesh(self, mesh, target_edge_length, iterations): - """ - Apply PyMeshLab 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"[PyMeshLabRemesh] Input: {len(mesh.vertices)} vertices, {len(mesh.faces)} faces") - print(f"[PyMeshLabRemesh] Target edge length: {target_edge_length}, Iterations: {iterations}") - - remeshed_mesh, error = mesh_utils.pymeshlab_isotropic_remesh( - mesh, - target_edge_length, - iterations - ) - - if remeshed_mesh is None: - raise ValueError(f"Remeshing failed: {error}") - - print(f"[PyMeshLabRemesh] 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) - - # Check if mesh is watertight - is_watertight = mesh.is_watertight - - # Calculate volume and area (only if watertight) - volume = None - area = None - try: - if is_watertight: - volume = float(mesh.volume) - area = float(mesh.area) - except Exception as e: - print(f"[PreviewMeshVTK] Could not calculate volume/area: {e}") - - # Get field names (vertex/face data arrays) - field_names = [] - if hasattr(mesh, 'vertex_attributes') and mesh.vertex_attributes: - field_names.extend([f"vertex.{k}" for k in mesh.vertex_attributes.keys()]) - if hasattr(mesh, 'face_attributes') and mesh.face_attributes: - field_names.extend([f"face.{k}" for k in mesh.face_attributes.keys()]) - - # Return metadata for frontend widget - ui_data = { - "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)], - "is_watertight": [bool(is_watertight)], - } - - # Add optional fields if available - if volume is not None: - ui_data["volume"] = [volume] - if area is not None: - ui_data["area"] = [area] - if field_names: - ui_data["field_names"] = [field_names] - - print(f"[PreviewMeshVTK] Mesh info: watertight={is_watertight}, volume={volume}, area={area}, fields={len(field_names)}") - - return {"ui": ui_data} - - -class PreviewMeshVTKFiltersNode: - """ - Preview mesh with VTK.js scientific visualization viewer including filters. - - Displays mesh in an interactive VTK.js viewer with trackball controls - and filter controls (smoothing, outline, cutting plane). - 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_filters" - CATEGORY = "geompack/visualization" - - def preview_mesh_vtk_filters(self, mesh): - """ - Export mesh to STL and prepare for VTK.js preview with filters. - - Args: - mesh: Input trimesh.Trimesh object - - Returns: - dict: UI data for frontend widget - """ - import uuid - - print(f"[PreviewMeshVTKFilters] Preparing preview: {len(mesh.vertices)} vertices, {len(mesh.faces)} faces") - - # Check if mesh has vertex attributes (scalar fields) - has_vertex_attrs = hasattr(mesh, 'vertex_attributes') and len(mesh.vertex_attributes) > 0 - - print(f"[PreviewMeshVTKFilters] hasattr(mesh, 'vertex_attributes'): {hasattr(mesh, 'vertex_attributes')}") - if hasattr(mesh, 'vertex_attributes'): - print(f"[PreviewMeshVTKFilters] mesh.vertex_attributes: {mesh.vertex_attributes.keys()}") - print(f"[PreviewMeshVTKFilters] Number of vertex attributes: {len(mesh.vertex_attributes)}") - - # Generate unique filename - # Use VTP format if we have vertex attributes, otherwise STL - if has_vertex_attrs: - filename = f"preview_vtk_filters_{uuid.uuid4().hex[:8]}.vtp" - file_format = 'vtp' - print(f"[PreviewMeshVTKFilters] Mesh has vertex attributes, using VTP format") - else: - filename = f"preview_vtk_filters_{uuid.uuid4().hex[:8]}.stl" - file_format = 'stl' - print(f"[PreviewMeshVTKFilters] No vertex attributes found, using STL format") - - # 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 mesh with vertex attributes - try: - if file_format == 'vtp': - # Export to VTK PolyData format (XML) which preserves vertex attributes - _export_mesh_with_scalars_vtp(mesh, filepath) - print(f"[PreviewMeshVTKFilters] Exported VTP with {len(mesh.vertex_attributes)} scalar fields to: {filepath}") - else: - # Export to STL (no scalar data) - mesh.export(filepath, file_type='stl') - print(f"[PreviewMeshVTKFilters] Exported to STL: {filepath}") - except Exception as e: - print(f"[PreviewMeshVTKFilters] Export failed: {e}") - # Fallback to OBJ - filename = filename.replace('.vtp', '.obj').replace('.stl', '.obj') - filepath = filepath.replace('.vtp', '.obj').replace('.stl', '.obj') - mesh.export(filepath, file_type='obj') - print(f"[PreviewMeshVTKFilters] Exported to OBJ: {filepath}") - - # Calculate bounding box info for camera setup - bounds = mesh.bounds - extents = mesh.extents - max_extent = max(extents) - - # Check if mesh is watertight - is_watertight = mesh.is_watertight - - # Calculate volume and area (only if watertight) - volume = None - area = None - try: - if is_watertight: - volume = float(mesh.volume) - area = float(mesh.area) - except Exception as e: - print(f"[PreviewMeshVTKFilters] Could not calculate volume/area: {e}") - - # Get field names (vertex/face data arrays) - field_names = [] - if hasattr(mesh, 'vertex_attributes') and mesh.vertex_attributes: - field_names.extend([f"vertex.{k}" for k in mesh.vertex_attributes.keys()]) - if hasattr(mesh, 'face_attributes') and mesh.face_attributes: - field_names.extend([f"face.{k}" for k in mesh.face_attributes.keys()]) - - # Return metadata for frontend widget - ui_data = { - "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)], - "is_watertight": [bool(is_watertight)], - } - - # Add optional fields if available - if volume is not None: - ui_data["volume"] = [volume] - if area is not None: - ui_data["area"] = [area] - if field_names: - ui_data["field_names"] = [field_names] - - print(f"[PreviewMeshVTKFilters] Mesh info: watertight={is_watertight}, volume={volume}, area={area}, fields={len(field_names)}") - - return {"ui": ui_data} - - -class PreviewMeshVTKFieldsNode: - """ - Preview mesh with VTK.js viewer optimized for scalar field visualization. - - Specifically designed for visualizing vertex scalar fields (like boundary edges, - curvature, distance fields, etc.) with color mapping. Always exports as VTP - format to preserve scalar data. - """ - - @classmethod - def INPUT_TYPES(cls): - return { - "required": { - "mesh": ("MESH",), - }, - } - - RETURN_TYPES = () - OUTPUT_NODE = True - FUNCTION = "preview_mesh_vtk_fields" - CATEGORY = "geompack/visualization" - - def preview_mesh_vtk_fields(self, mesh): - """ - Export mesh with scalar fields to VTP and prepare for VTK.js preview. - - Args: - mesh: Input trimesh.Trimesh object - - Returns: - dict: UI data for frontend widget - """ - import uuid - - print(f"[PreviewMeshVTKFields] Preparing preview: {len(mesh.vertices)} vertices, {len(mesh.faces)} faces") - - # Check if mesh has vertex attributes (scalar fields) - has_vertex_attrs = hasattr(mesh, 'vertex_attributes') and len(mesh.vertex_attributes) > 0 - - if hasattr(mesh, 'vertex_attributes'): - print(f"[PreviewMeshVTKFields] mesh.vertex_attributes: {mesh.vertex_attributes.keys()}") - print(f"[PreviewMeshVTKFields] Number of vertex attributes: {len(mesh.vertex_attributes)}") - - if not has_vertex_attrs: - print(f"[PreviewMeshVTKFields] WARNING: No vertex attributes found. This node is for scalar field visualization.") - - # Always use VTP format for this node (designed for scalar fields) - filename = f"preview_vtk_fields_{uuid.uuid4().hex[:8]}.vtp" - - # 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 mesh with vertex attributes as VTP - try: - _export_mesh_with_scalars_vtp(mesh, filepath) - num_fields = len(mesh.vertex_attributes) if has_vertex_attrs else 0 - print(f"[PreviewMeshVTKFields] Exported VTP with {num_fields} scalar fields to: {filepath}") - except Exception as e: - print(f"[PreviewMeshVTKFields] Export failed: {e}") - import traceback - traceback.print_exc() - raise RuntimeError(f"Failed to export mesh with scalar fields: {e}") - - # Calculate bounding box info for camera setup - bounds = mesh.bounds - extents = mesh.extents - max_extent = max(extents) - - # Check if mesh is watertight - is_watertight = mesh.is_watertight - - # Get field names (vertex/face data arrays) - field_names = [] - if hasattr(mesh, 'vertex_attributes') and mesh.vertex_attributes: - field_names.extend(list(mesh.vertex_attributes.keys())) - if hasattr(mesh, 'face_attributes') and mesh.face_attributes: - field_names.extend([f"face.{k}" for k in mesh.face_attributes.keys()]) - - # Return metadata for frontend widget - ui_data = { - "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)], - "is_watertight": [bool(is_watertight)], - "field_names": [field_names], - } - - print(f"[PreviewMeshVTKFields] Scalar fields: {field_names}") - - return {"ui": ui_data} - - -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) - - -class HausdorffDistanceNode: - """ - Compute Hausdorff distance between two meshes or point clouds. - - Hausdorff distance measures the maximum distance from any point in one set - to its nearest point in the other set. Useful for measuring worst-case - deviation between meshes. - """ - - @classmethod - def INPUT_TYPES(cls): - return { - "required": { - "mesh_a": ("MESH",), - "mesh_b": ("MESH",), - "sample_count": ("INT", { - "default": 10000, - "min": 1000, - "max": 1000000, - "step": 1000 - }), - }, - } - - RETURN_TYPES = ("FLOAT", "STRING") - RETURN_NAMES = ("hausdorff_distance", "details") - FUNCTION = "compute_distance" - CATEGORY = "geompack/analysis/distance" - - def compute_distance(self, mesh_a, mesh_b, sample_count): - """ - Compute Hausdorff distance between two meshes. - - Args: - mesh_a: First trimesh.Trimesh object - mesh_b: Second trimesh.Trimesh object - sample_count: Number of points to sample from each mesh - - Returns: - tuple: (hausdorff_distance, details_string) - """ - try: - import point_cloud_utils as pcu - except ImportError: - raise ImportError( - "point-cloud-utils not installed. Install with: pip install point-cloud-utils" - ) - - print(f"[HausdorffDistance] Comparing meshes with {sample_count} samples each") - - # Sample point clouds from meshes - points_a = mesh_a.sample(sample_count) - points_b = mesh_b.sample(sample_count) - - # Compute Hausdorff distance (symmetric) - hd = pcu.hausdorff_distance(points_a, points_b) - - # Compute one-sided distances - hd_a_to_b = pcu.one_sided_hausdorff_distance(points_a, points_b) - hd_b_to_a = pcu.one_sided_hausdorff_distance(points_b, points_a) - - details = f"""Hausdorff Distance Analysis: -Total (symmetric): {hd:.6f} -A → B (one-sided): {hd_a_to_b:.6f} -B → A (one-sided): {hd_b_to_a:.6f} - -Sampled {sample_count:,} points from each mesh. -Mesh A: {len(mesh_a.vertices):,} vertices, {len(mesh_a.faces):,} faces -Mesh B: {len(mesh_b.vertices):,} vertices, {len(mesh_b.faces):,} faces -""" - - print(f"[HausdorffDistance] Result: {hd:.6f}") - - return (float(hd), details) - - -class ChamferDistanceNode: - """ - Compute Chamfer distance between two meshes or point clouds. - - Chamfer distance is the average of squared distances from each point - to its nearest neighbor in the other set. More sensitive to overall - shape similarity than Hausdorff distance. - """ - - @classmethod - def INPUT_TYPES(cls): - return { - "required": { - "mesh_a": ("MESH",), - "mesh_b": ("MESH",), - "sample_count": ("INT", { - "default": 10000, - "min": 1000, - "max": 1000000, - "step": 1000 - }), - }, - } - - RETURN_TYPES = ("FLOAT", "STRING") - RETURN_NAMES = ("chamfer_distance", "info") - FUNCTION = "compute_distance" - CATEGORY = "geompack/analysis/distance" - - def compute_distance(self, mesh_a, mesh_b, sample_count): - """ - Compute Chamfer distance between two meshes. - - Args: - mesh_a: First trimesh.Trimesh object - mesh_b: Second trimesh.Trimesh object - sample_count: Number of points to sample from each mesh - - Returns: - tuple: (chamfer_distance, info_string) - """ - try: - import point_cloud_utils as pcu - except ImportError: - raise ImportError( - "point-cloud-utils not installed. Install with: pip install point-cloud-utils" - ) - - print(f"[ChamferDistance] Comparing meshes with {sample_count} samples each") - - # Sample point clouds from meshes - points_a = mesh_a.sample(sample_count) - points_b = mesh_b.sample(sample_count) - - # Compute Chamfer distance - cd = pcu.chamfer_distance(points_a, points_b) - - info = f"""Chamfer Distance: {cd:.6f} - -Sampled {sample_count:,} points from each mesh. -Mesh A: {len(mesh_a.vertices):,} vertices, {len(mesh_a.faces):,} faces -Mesh B: {len(mesh_b.vertices):,} vertices, {len(mesh_b.faces):,} faces - -Note: Chamfer distance is more sensitive to overall shape -similarity compared to Hausdorff distance. -""" - - print(f"[ChamferDistance] Result: {cd:.6f}") - - return (float(cd), info) - - -class ComputeSDFNode: - """ - Compute Signed Distance Field (SDF) for a mesh. - - The SDF represents the distance from any point in 3D space to the - nearest surface, with negative values inside the mesh and positive - values outside. Useful for occupancy queries and implicit surface - representations. - """ - - @classmethod - def INPUT_TYPES(cls): - return { - "required": { - "mesh": ("MESH",), - "resolution": ("INT", { - "default": 64, - "min": 16, - "max": 256, - "step": 16 - }), - }, - } - - RETURN_TYPES = ("SDF_VOLUME", "STRING") - RETURN_NAMES = ("sdf_volume", "info") - FUNCTION = "compute_sdf" - CATEGORY = "geompack/analysis/distance" - - def compute_sdf(self, mesh, resolution): - """ - Compute signed distance field voxel grid for mesh. - - Args: - mesh: Input trimesh.Trimesh object - resolution: Grid resolution (N x N x N voxels) - - Returns: - tuple: (sdf_data_dict, info_string) - """ - try: - import mesh_to_sdf - except ImportError: - raise ImportError( - "mesh-to-sdf not installed. Install with: pip install mesh-to-sdf" - ) - - print(f"[ComputeSDF] Computing {resolution}³ SDF for mesh with {len(mesh.vertices):,} vertices") - - # Compute SDF voxel grid - voxels = mesh_to_sdf.mesh_to_voxels(mesh, resolution) - - info = f"""Signed Distance Field: -Resolution: {resolution}³ = {resolution**3:,} voxels -Value range: [{voxels.min():.3f}, {voxels.max():.3f}] - -Mesh bounds: {mesh.bounds.tolist()} -Mesh extents: {mesh.extents.tolist()} - -Negative values = inside mesh -Positive values = outside mesh -Zero = on surface -""" - - # Package SDF data - sdf_data = { - 'voxels': voxels, - 'resolution': resolution, - 'bounds': mesh.bounds.copy(), - 'extents': mesh.extents.copy(), - } - - print(f"[ComputeSDF] Complete - range: [{voxels.min():.3f}, {voxels.max():.3f}]") - - return (sdf_data, info) - - -class MeshToPointCloudNode: - """ - Convert mesh to point cloud by sampling surface points. - - Samples points from the mesh surface using various sampling methods. - Can optionally include normals and colors. - """ - - @classmethod - def INPUT_TYPES(cls): - return { - "required": { - "mesh": ("MESH",), - "sample_count": ("INT", { - "default": 10000, - "min": 100, - "max": 10000000, - "step": 100 - }), - "sampling_method": (["uniform", "even", "face_weighted"], { - "default": "uniform" - }), - }, - "optional": { - "include_normals": (["true", "false"], { - "default": "true" - }), - } - } - - RETURN_TYPES = ("POINT_CLOUD",) - RETURN_NAMES = ("point_cloud",) - FUNCTION = "mesh_to_pointcloud" - CATEGORY = "geompack/conversion" - - def mesh_to_pointcloud(self, mesh, sample_count, sampling_method, include_normals="true"): - """ - Sample points from mesh surface. - - Args: - mesh: Input trimesh.Trimesh object - sample_count: Number of points to sample - sampling_method: Sampling strategy - include_normals: Whether to compute surface normals - - Returns: - tuple: (point_cloud_dict,) - """ - print(f"[MeshToPointCloud] Sampling {sample_count:,} points using {sampling_method} method") - - if sampling_method == "uniform": - # Uniform random sampling - points, face_indices = mesh.sample(sample_count, return_index=True) - - elif sampling_method == "even": - # Approximately even spacing (rejection sampling) - # Calculate radius based on surface area and desired point count - radius = np.sqrt(mesh.area / sample_count) * 2.0 - points, face_indices = trimesh.sample.sample_surface_even( - mesh, sample_count, radius=radius - ) - print(f"[MeshToPointCloud] Even sampling produced {len(points):,} points (target: {sample_count:,})") - - elif sampling_method == "face_weighted": - # Weight by face area (default behavior) - points, face_indices = mesh.sample( - sample_count, - return_index=True, - face_weight=mesh.area_faces - ) - - # Optional: compute normals at sample points - normals = None - if include_normals == "true": - # Get face normals for each sampled point - normals = mesh.face_normals[face_indices] - - # Create point cloud data structure - pointcloud = { - 'points': points, - 'normals': normals, - 'face_indices': face_indices, - 'source_mesh_vertices': len(mesh.vertices), - 'source_mesh_faces': len(mesh.faces), - 'sample_count': len(points), - 'sampling_method': sampling_method, - } - - print(f"[MeshToPointCloud] Generated point cloud with {len(points):,} points") - - return (pointcloud,) - - -class MarkBoundaryEdgesNode: - """ - Detect and mark boundary edges on mesh. - - Analyzes the mesh topology to find boundary edges (edges belonging to - only one face). Creates a vertex scalar field 'boundary_vertex' where: - - 1.0 = vertex is on a boundary edge - - 0.0 = vertex is interior - - This field can be visualized in VTK viewer to highlight mesh boundaries. - """ - - @classmethod - def INPUT_TYPES(cls): - return { - "required": { - "mesh": ("MESH",), - }, - } - - RETURN_TYPES = ("MESH", "STRING") - RETURN_NAMES = ("mesh_with_field", "info") - FUNCTION = "mark_boundary" - CATEGORY = "geompack/analysis" - - def mark_boundary(self, mesh): - """ - Detect boundary edges and add boundary vertex field to mesh. - - Args: - mesh: Input trimesh.Trimesh object - - Returns: - tuple: (mesh_with_boundary_field, info_string) - """ - from . import mesh_utils - - print(f"[MarkBoundaryEdges] Analyzing mesh topology...") - - result_mesh, error = mesh_utils.mark_boundary_vertices(mesh) - - if error: - raise RuntimeError(f"Failed to detect boundary edges: {error}") - - # Get statistics from metadata - num_boundary_verts = result_mesh.metadata.get('boundary_vertices_count', 0) - num_boundary_edges = result_mesh.metadata.get('boundary_edges_count', 0) - - # Determine if mesh is watertight (no boundary edges) - is_watertight = num_boundary_edges == 0 - - info = f"""Boundary Edge Analysis: - -Boundary Edges: {num_boundary_edges:,} -Boundary Vertices: {num_boundary_verts:,} / {len(mesh.vertices):,} ({100.0 * num_boundary_verts / len(mesh.vertices):.1f}%) -Watertight: {'Yes' if is_watertight else 'No'} - -Field Added: 'boundary_vertex' - - Value 1.0 = on boundary - - Value 0.0 = interior - -Use VTK viewer to visualize the boundary field! -""" - - print(f"[MarkBoundaryEdges] Found {num_boundary_edges:,} boundary edges, {num_boundary_verts:,} boundary vertices") - - return (result_mesh, info) - - -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": { - "mesh": ("MESH",), - }, - } - - RETURN_TYPES = ("MESH",) - RETURN_NAMES = ("unwrapped_mesh",) - FUNCTION = "uv_unwrap" - CATEGORY = "geompack/uv" - - def uv_unwrap(self, mesh): - """ - UV unwrap mesh using xatlas. - - Args: - mesh: Input trimesh.Trimesh object - - Returns: - tuple: (unwrapped_trimesh.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(mesh.vertices)} vertices, {len(mesh.faces)} faces") - - # Parametrize with xatlas - vmapping, indices, uvs = xatlas.parametrize( - mesh.vertices, - mesh.faces - ) - - # Create new mesh with UV-split vertices - new_vertices = mesh.vertices[vmapping] - - # Create trimesh with UV coordinates - unwrapped = trimesh.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 = mesh.metadata.copy() - unwrapped.metadata['uv_unwrap'] = { - 'algorithm': 'xatlas', - 'original_vertices': len(mesh.vertices), - 'unwrapped_vertices': len(new_vertices), - 'vertex_duplication_ratio': len(new_vertices) / len(mesh.vertices) - } - - print(f"[XAtlasUVUnwrap] Output: {len(unwrapped.vertices)} vertices, {len(unwrapped.faces)} faces") - print(f"[XAtlasUVUnwrap] Vertex duplication: {len(new_vertices)/len(mesh.vertices):.2f}x") - - return (unwrapped,) - - -class BlenderCubeProjectionNode: - """ - UV Cube Projection using Blender. - - Projects mesh onto 6 faces of a cube. Perfect for box-like geometry. - Creates 6 overlapping UV islands that can be separated. - """ - - @classmethod - def INPUT_TYPES(cls): - return { - "required": { - "mesh": ("MESH",), - "cube_size": ("FLOAT", { - "default": 1.0, - "min": 0.1, - "max": 10.0, - "step": 0.1 - }), - }, - } - - RETURN_TYPES = ("MESH",) - RETURN_NAMES = ("unwrapped_mesh",) - FUNCTION = "uv_unwrap" - CATEGORY = "geompack/uv" - - def uv_unwrap(self, mesh, cube_size): - """ - UV cube projection using Blender. - - Args: - mesh: Input trimesh.Trimesh object - cube_size: Size of the projection cube - - Returns: - tuple: (unwrapped_trimesh.Trimesh,) - """ - print(f"[BlenderCubeProjection] Input: {len(mesh.vertices)} vertices, {len(mesh.faces)} faces") - print(f"[BlenderCubeProjection] Cube size: {cube_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 cube projection - 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 apply cube projection -bpy.ops.object.mode_set(mode='EDIT') -bpy.ops.mesh.select_all(action='SELECT') -bpy.ops.uv.cube_project( - cube_size={cube_size}, - correct_aspect=True, - clip_to_bounds=False, - 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"[BlenderCubeProjection] Running Blender...") - 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.load(output_path, process=False) - - if isinstance(unwrapped, trimesh.Scene): - unwrapped = unwrapped.dump(concatenate=True) - - # Preserve metadata - unwrapped.metadata = mesh.metadata.copy() - unwrapped.metadata['uv_unwrap'] = { - 'algorithm': 'blender_cube_projection', - 'cube_size': cube_size - } - - print(f"[BlenderCubeProjection] Complete") - - return (unwrapped,) - - finally: - # Cleanup - if os.path.exists(input_path): - os.unlink(input_path) - if os.path.exists(output_path): - os.unlink(output_path) - - -class BlenderCylinderProjectionNode: - """ - UV Cylinder Projection using Blender. - - Projects mesh onto a cylinder surface. Perfect for cylindrical objects - like bottles, columns, pipes, etc. - """ - - @classmethod - def INPUT_TYPES(cls): - return { - "required": { - "mesh": ("MESH",), - "radius": ("FLOAT", { - "default": 1.0, - "min": 0.1, - "max": 10.0, - "step": 0.1 - }), - }, - } - - RETURN_TYPES = ("MESH",) - RETURN_NAMES = ("unwrapped_mesh",) - FUNCTION = "uv_unwrap" - CATEGORY = "geompack/uv" - - def uv_unwrap(self, mesh, radius): - """ - UV cylinder projection using Blender. - - Args: - mesh: Input trimesh.Trimesh object - radius: Cylinder radius - - Returns: - tuple: (unwrapped_trimesh.Trimesh,) - """ - print(f"[BlenderCylinderProjection] Input: {len(mesh.vertices)} vertices, {len(mesh.faces)} faces") - print(f"[BlenderCylinderProjection] Radius: {radius}") - - # 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 cylinder projection - 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 apply cylinder projection -bpy.ops.object.mode_set(mode='EDIT') -bpy.ops.mesh.select_all(action='SELECT') -bpy.ops.uv.cylinder_project( - direction='VIEW_ON_EQUATOR', - align='POLAR_ZX', - radius={radius}, - 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"[BlenderCylinderProjection] Running Blender...") - 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.load(output_path, process=False) - - if isinstance(unwrapped, trimesh.Scene): - unwrapped = unwrapped.dump(concatenate=True) - - # Preserve metadata - unwrapped.metadata = mesh.metadata.copy() - unwrapped.metadata['uv_unwrap'] = { - 'algorithm': 'blender_cylinder_projection', - 'radius': radius - } - - print(f"[BlenderCylinderProjection] Complete") - - return (unwrapped,) - - finally: - # Cleanup - if os.path.exists(input_path): - os.unlink(input_path) - if os.path.exists(output_path): - os.unlink(output_path) - - -class BlenderSphereProjectionNode: - """ - UV Sphere Projection using Blender. - - Projects mesh onto a sphere surface. Perfect for spherical objects - like planets, balls, eyes, etc. Creates equirectangular projection. - """ - - @classmethod - def INPUT_TYPES(cls): - return { - "required": { - "mesh": ("MESH",), - }, - } - - RETURN_TYPES = ("MESH",) - RETURN_NAMES = ("unwrapped_mesh",) - FUNCTION = "uv_unwrap" - CATEGORY = "geompack/uv" - - def uv_unwrap(self, mesh): - """ - UV sphere projection using Blender. - - Args: - mesh: Input trimesh.Trimesh object - - Returns: - tuple: (unwrapped_trimesh.Trimesh,) - """ - print(f"[BlenderSphereProjection] Input: {len(mesh.vertices)} vertices, {len(mesh.faces)} faces") - - # 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 sphere projection - 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 apply sphere projection -bpy.ops.object.mode_set(mode='EDIT') -bpy.ops.mesh.select_all(action='SELECT') -bpy.ops.uv.sphere_project( - direction='VIEW_ON_EQUATOR', - align='POLAR_ZX', - 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"[BlenderSphereProjection] Running Blender...") - 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.load(output_path, process=False) - - if isinstance(unwrapped, trimesh.Scene): - unwrapped = unwrapped.dump(concatenate=True) - - # Preserve metadata - unwrapped.metadata = mesh.metadata.copy() - unwrapped.metadata['uv_unwrap'] = { - 'algorithm': 'blender_sphere_projection', - } - - print(f"[BlenderSphereProjection] Complete") - - return (unwrapped,) - - finally: - # Cleanup - if os.path.exists(input_path): - os.unlink(input_path) - if os.path.exists(output_path): - os.unlink(output_path) - - -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": { - "mesh": ("MESH",), - }, - } - - RETURN_TYPES = ("MESH",) - RETURN_NAMES = ("unwrapped_mesh",) - FUNCTION = "uv_unwrap" - CATEGORY = "geompack/uv" - - def uv_unwrap(self, mesh): - """ - LSCM UV parameterization using libigl. - - Args: - mesh: Input trimesh.Trimesh object - - Returns: - tuple: (unwrapped_trimesh.Trimesh,) - """ - try: - import igl - except ImportError: - raise ImportError("libigl not installed (should be in requirements.txt)") - - print(f"[LibiglLSCM] Input: {len(mesh.vertices)} vertices, {len(mesh.faces)} faces") - - # LSCM requires fixing 2 vertices for unique solution - # Choose first and last vertex - v_fixed = np.array([0, len(mesh.vertices)-1], dtype=np.int32) - uv_fixed = np.array([[0.0, 0.0], [1.0, 0.0]], dtype=np.float64) - - # Compute LSCM parameterization - uv = igl.lscm( - mesh.vertices.astype(np.float64), - mesh.faces.astype(np.int32), - v_fixed, - uv_fixed - ) - - # 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 = mesh.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 - this dictionary maps internal node names to classes -NODE_CLASS_MAPPINGS = { - "GeomPackExampleNode": ExampleLibiglNode, - "GeomPackMeshInfo": MeshInfoNode, - "GeomPackLoadMesh": LoadMesh, - "GeomPackSaveMesh": SaveMesh, - "GeomPackCreatePrimitive": CreatePrimitive, - "GeomPackPyMeshLabRemesh": PyMeshLabRemeshNode, - "GeomPackCenterMesh": CenterMeshNode, - "GeomPackPreviewMesh": PreviewMeshNode, - "GeomPackPreviewMeshVTK": PreviewMeshVTKNode, - "GeomPackPreviewMeshVTKFilters": PreviewMeshVTKFiltersNode, - "GeomPackPreviewMeshVTKFields": PreviewMeshVTKFieldsNode, - "GeomPackBlenderUVUnwrap": BlenderUVUnwrapNode, - "GeomPackBlenderVoxelRemesh": BlenderVoxelRemeshNode, - "GeomPackBlenderQuadriflowRemesh": BlenderQuadriflowRemeshNode, - # Distance metrics - "GeomPackHausdorffDistance": HausdorffDistanceNode, - "GeomPackChamferDistance": ChamferDistanceNode, - "GeomPackComputeSDF": ComputeSDFNode, - # Analysis - "GeomPackMarkBoundaryEdges": MarkBoundaryEdgesNode, - # Conversion - "GeomPackMeshToPointCloud": MeshToPointCloudNode, - # UV Mapping - "GeomPackXAtlasUVUnwrap": XAtlasUVUnwrapNode, - "GeomPackBlenderCubeProjection": BlenderCubeProjectionNode, - "GeomPackBlenderCylinderProjection": BlenderCylinderProjectionNode, - "GeomPackBlenderSphereProjection": BlenderSphereProjectionNode, - "GeomPackLibiglLSCM": LibiglLSCMNode, -} - -# 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", - "GeomPackPyMeshLabRemesh": "PyMeshLab Remesh (Isotropic)", - "GeomPackCenterMesh": "Center Mesh", - "GeomPackPreviewMesh": "Preview Mesh (3D)", - "GeomPackPreviewMeshVTK": "Preview Mesh (VTK)", - "GeomPackPreviewMeshVTKFilters": "Preview Mesh (VTK with Filters)", - "GeomPackPreviewMeshVTKFields": "Preview Mesh (VTK with Fields)", - "GeomPackBlenderUVUnwrap": "Blender UV Unwrap", - "GeomPackBlenderVoxelRemesh": "Blender Voxel Remesh", - "GeomPackBlenderQuadriflowRemesh": "Blender Quadriflow Remesh", - # Distance metrics - "GeomPackHausdorffDistance": "Hausdorff Distance", - "GeomPackChamferDistance": "Chamfer Distance", - "GeomPackComputeSDF": "Compute SDF", - # Analysis - "GeomPackMarkBoundaryEdges": "Mark Boundary Edges", - # Conversion - "GeomPackMeshToPointCloud": "Mesh to Point Cloud", - # UV Mapping - "GeomPackXAtlasUVUnwrap": "xAtlas UV Unwrap", - "GeomPackBlenderCubeProjection": "Blender Cube Projection", - "GeomPackBlenderCylinderProjection": "Blender Cylinder Projection", - "GeomPackBlenderSphereProjection": "Blender Sphere Projection", - "GeomPackLibiglLSCM": "libigl LSCM Unwrap", -} diff --git a/nodes/__init__.py b/nodes/__init__.py new file mode 100644 index 00000000..96777bd4 --- /dev/null +++ b/nodes/__init__.py @@ -0,0 +1,45 @@ +""" +ComfyUI GeometryPack Nodes +Organized by functional category +""" + +# Import all node modules +from . import io +from . import primitives +from . import analysis +from . import distance +from . import conversion +from . import remeshing +from . import uv +from . import transforms +from . import visualization +from . import examples + +# Collect all node class mappings +NODE_CLASS_MAPPINGS = {} +NODE_CLASS_MAPPINGS.update(io.NODE_CLASS_MAPPINGS) +NODE_CLASS_MAPPINGS.update(primitives.NODE_CLASS_MAPPINGS) +NODE_CLASS_MAPPINGS.update(analysis.NODE_CLASS_MAPPINGS) +NODE_CLASS_MAPPINGS.update(distance.NODE_CLASS_MAPPINGS) +NODE_CLASS_MAPPINGS.update(conversion.NODE_CLASS_MAPPINGS) +NODE_CLASS_MAPPINGS.update(remeshing.NODE_CLASS_MAPPINGS) +NODE_CLASS_MAPPINGS.update(uv.NODE_CLASS_MAPPINGS) +NODE_CLASS_MAPPINGS.update(transforms.NODE_CLASS_MAPPINGS) +NODE_CLASS_MAPPINGS.update(visualization.NODE_CLASS_MAPPINGS) +NODE_CLASS_MAPPINGS.update(examples.NODE_CLASS_MAPPINGS) + +# Collect all display name mappings +NODE_DISPLAY_NAME_MAPPINGS = {} +NODE_DISPLAY_NAME_MAPPINGS.update(io.NODE_DISPLAY_NAME_MAPPINGS) +NODE_DISPLAY_NAME_MAPPINGS.update(primitives.NODE_DISPLAY_NAME_MAPPINGS) +NODE_DISPLAY_NAME_MAPPINGS.update(analysis.NODE_DISPLAY_NAME_MAPPINGS) +NODE_DISPLAY_NAME_MAPPINGS.update(distance.NODE_DISPLAY_NAME_MAPPINGS) +NODE_DISPLAY_NAME_MAPPINGS.update(conversion.NODE_DISPLAY_NAME_MAPPINGS) +NODE_DISPLAY_NAME_MAPPINGS.update(remeshing.NODE_DISPLAY_NAME_MAPPINGS) +NODE_DISPLAY_NAME_MAPPINGS.update(uv.NODE_DISPLAY_NAME_MAPPINGS) +NODE_DISPLAY_NAME_MAPPINGS.update(transforms.NODE_DISPLAY_NAME_MAPPINGS) +NODE_DISPLAY_NAME_MAPPINGS.update(visualization.NODE_DISPLAY_NAME_MAPPINGS) +NODE_DISPLAY_NAME_MAPPINGS.update(examples.NODE_DISPLAY_NAME_MAPPINGS) + +# Export for ComfyUI +__all__ = ['NODE_CLASS_MAPPINGS', 'NODE_DISPLAY_NAME_MAPPINGS'] diff --git a/nodes/analysis.py b/nodes/analysis.py new file mode 100644 index 00000000..b4ffc4be --- /dev/null +++ b/nodes/analysis.py @@ -0,0 +1,118 @@ +""" +Analysis Nodes - Mesh information and topology analysis +""" + +from . import mesh_utils + + +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 MarkBoundaryEdgesNode: + """ + Detect and mark boundary edges on mesh. + + Analyzes the mesh topology to find boundary edges (edges belonging to + only one face). Creates a vertex scalar field 'boundary_vertex' where: + - 1.0 = vertex is on a boundary edge + - 0.0 = vertex is interior + + This field can be visualized in VTK viewer to highlight mesh boundaries. + """ + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "mesh": ("MESH",), + }, + } + + RETURN_TYPES = ("MESH", "STRING") + RETURN_NAMES = ("mesh_with_field", "info") + FUNCTION = "mark_boundary" + CATEGORY = "geompack/analysis" + + def mark_boundary(self, mesh): + """ + Detect boundary edges and add boundary vertex field to mesh. + + Args: + mesh: Input trimesh.Trimesh object + + Returns: + tuple: (mesh_with_boundary_field, info_string) + """ + result_mesh, error = mesh_utils.mark_boundary_vertices(mesh) + + if error: + raise RuntimeError(f"Failed to detect boundary edges: {error}") + + # Get statistics from metadata + num_boundary_verts = result_mesh.metadata.get('boundary_vertices_count', 0) + num_boundary_edges = result_mesh.metadata.get('boundary_edges_count', 0) + + # Determine if mesh is watertight (no boundary edges) + is_watertight = num_boundary_edges == 0 + + info = f"""Boundary Edge Analysis: + +Boundary Edges: {num_boundary_edges:,} +Boundary Vertices: {num_boundary_verts:,} / {len(mesh.vertices):,} ({100.0 * num_boundary_verts / len(mesh.vertices):.1f}%) +Watertight: {'Yes' if is_watertight else 'No'} + +Field Added: 'boundary_vertex' + - Value 1.0 = on boundary + - Value 0.0 = interior + +Use VTK viewer to visualize the boundary field! +""" + + print(f"[MarkBoundaryEdges] Found {num_boundary_edges:,} boundary edges, {num_boundary_verts:,} boundary vertices") + + return (result_mesh, info) + + +# Node mappings +NODE_CLASS_MAPPINGS = { + "GeomPackMeshInfo": MeshInfoNode, + "GeomPackMarkBoundaryEdges": MarkBoundaryEdgesNode, +} + +NODE_DISPLAY_NAME_MAPPINGS = { + "GeomPackMeshInfo": "Mesh Info", + "GeomPackMarkBoundaryEdges": "Mark Boundary Edges", +} diff --git a/nodes/conversion.py b/nodes/conversion.py new file mode 100644 index 00000000..2f392906 --- /dev/null +++ b/nodes/conversion.py @@ -0,0 +1,174 @@ +""" +Conversion Nodes - Convert between mesh and point cloud representations +""" + +import numpy as np +import trimesh + + +class StripMeshAdjacencyNode: + """ + Strip mesh adjacency information, leaving only vertex positions. + + Removes all face connectivity data from the mesh, effectively converting + it to a point cloud while preserving the original vertex positions. + No sampling or resampling is performed. + """ + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "mesh": ("MESH",), + }, + "optional": { + "include_normals": (["true", "false"], { + "default": "false" + }), + } + } + + RETURN_TYPES = ("POINT_CLOUD",) + RETURN_NAMES = ("point_cloud",) + FUNCTION = "strip_adjacency" + CATEGORY = "geompack/conversion" + + def strip_adjacency(self, mesh, include_normals="false"): + """ + Strip face adjacency from mesh, keeping only vertices. + + Args: + mesh: Input trimesh.Trimesh object + include_normals: Whether to include vertex normals + + Returns: + tuple: (point_cloud_dict,) + """ + print(f"[StripMeshAdjacency] Stripping adjacency from mesh with {len(mesh.vertices):,} vertices") + + # Extract vertices + points = mesh.vertices.copy() + + # Optional: include vertex normals + normals = None + if include_normals == "true" and hasattr(mesh, 'vertex_normals'): + normals = mesh.vertex_normals.copy() + print(f"[StripMeshAdjacency] Including vertex normals") + + # Create point cloud data structure + pointcloud = { + 'points': points, + 'normals': normals, + 'source_mesh_vertices': len(mesh.vertices), + 'source_mesh_faces': len(mesh.faces), + 'stripped_adjacency': True, + } + + print(f"[StripMeshAdjacency] Created point cloud with {len(points):,} points (original vertices, no sampling)") + + return (pointcloud,) + + +class MeshToPointCloudNode: + """ + Convert mesh to point cloud by sampling surface points. + + Samples points from the mesh surface using various sampling methods. + Can optionally include normals and colors. + """ + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "mesh": ("MESH",), + "sample_count": ("INT", { + "default": 10000, + "min": 100, + "max": 10000000, + "step": 100 + }), + "sampling_method": (["uniform", "even", "face_weighted"], { + "default": "uniform" + }), + }, + "optional": { + "include_normals": (["true", "false"], { + "default": "true" + }), + } + } + + RETURN_TYPES = ("POINT_CLOUD",) + RETURN_NAMES = ("point_cloud",) + FUNCTION = "mesh_to_pointcloud" + CATEGORY = "geompack/conversion" + + def mesh_to_pointcloud(self, mesh, sample_count, sampling_method, include_normals="true"): + """ + Sample points from mesh surface. + + Args: + mesh: Input trimesh.Trimesh object + sample_count: Number of points to sample + sampling_method: Sampling strategy + include_normals: Whether to compute surface normals + + Returns: + tuple: (point_cloud_dict,) + """ + print(f"[MeshToPointCloud] Sampling {sample_count:,} points using {sampling_method} method") + + if sampling_method == "uniform": + # Uniform random sampling + points, face_indices = mesh.sample(sample_count, return_index=True) + + elif sampling_method == "even": + # Approximately even spacing (rejection sampling) + # Calculate radius based on surface area and desired point count + radius = np.sqrt(mesh.area / sample_count) * 2.0 + points, face_indices = trimesh.sample.sample_surface_even( + mesh, sample_count, radius=radius + ) + print(f"[MeshToPointCloud] Even sampling produced {len(points):,} points (target: {sample_count:,})") + + elif sampling_method == "face_weighted": + # Weight by face area (default behavior) + points, face_indices = mesh.sample( + sample_count, + return_index=True, + face_weight=mesh.area_faces + ) + + # Optional: compute normals at sample points + normals = None + if include_normals == "true": + # Get face normals for each sampled point + normals = mesh.face_normals[face_indices] + + # Create point cloud data structure + pointcloud = { + 'points': points, + 'normals': normals, + 'face_indices': face_indices, + 'source_mesh_vertices': len(mesh.vertices), + 'source_mesh_faces': len(mesh.faces), + 'sample_count': len(points), + 'sampling_method': sampling_method, + } + + print(f"[MeshToPointCloud] Generated point cloud with {len(points):,} points") + + return (pointcloud,) + + +# Node mappings +NODE_CLASS_MAPPINGS = { + "GeomPackStripMeshAdjacency": StripMeshAdjacencyNode, + "GeomPackMeshToPointCloud": MeshToPointCloudNode, +} + +NODE_DISPLAY_NAME_MAPPINGS = { + "GeomPackStripMeshAdjacency": "Strip Mesh Adjacency", + "GeomPackMeshToPointCloud": "Mesh to Point Cloud", +} diff --git a/nodes/distance.py b/nodes/distance.py new file mode 100644 index 00000000..c46058e9 --- /dev/null +++ b/nodes/distance.py @@ -0,0 +1,244 @@ +""" +Distance Nodes - Distance metrics and signed distance fields +""" + +import numpy as np + + +class HausdorffDistanceNode: + """ + Compute Hausdorff distance between two meshes or point clouds. + + Hausdorff distance measures the maximum distance from any point in one set + to its nearest point in the other set. Useful for measuring worst-case + deviation between meshes. + """ + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "mesh_a": ("MESH",), + "mesh_b": ("MESH",), + "sample_count": ("INT", { + "default": 10000, + "min": 1000, + "max": 1000000, + "step": 1000 + }), + }, + } + + RETURN_TYPES = ("FLOAT", "STRING") + RETURN_NAMES = ("hausdorff_distance", "details") + FUNCTION = "compute_distance" + CATEGORY = "geompack/analysis/distance" + + def compute_distance(self, mesh_a, mesh_b, sample_count): + """ + Compute Hausdorff distance between two meshes. + + Args: + mesh_a: First trimesh.Trimesh object + mesh_b: Second trimesh.Trimesh object + sample_count: Number of points to sample from each mesh + + Returns: + tuple: (hausdorff_distance, details_string) + """ + try: + import point_cloud_utils as pcu + except ImportError: + raise ImportError( + "point-cloud-utils not installed. Install with: pip install point-cloud-utils" + ) + + print(f"[HausdorffDistance] Comparing meshes with {sample_count} samples each") + + # Sample point clouds from meshes + points_a = mesh_a.sample(sample_count) + points_b = mesh_b.sample(sample_count) + + # Compute Hausdorff distance (symmetric) + hd = pcu.hausdorff_distance(points_a, points_b) + + # Compute one-sided distances + hd_a_to_b = pcu.one_sided_hausdorff_distance(points_a, points_b) + hd_b_to_a = pcu.one_sided_hausdorff_distance(points_b, points_a) + + details = f"""Hausdorff Distance Analysis: +Total (symmetric): {hd:.6f} +A → B (one-sided): {hd_a_to_b:.6f} +B → A (one-sided): {hd_b_to_a:.6f} + +Sampled {sample_count:,} points from each mesh. +Mesh A: {len(mesh_a.vertices):,} vertices, {len(mesh_a.faces):,} faces +Mesh B: {len(mesh_b.vertices):,} vertices, {len(mesh_b.faces):,} faces +""" + + print(f"[HausdorffDistance] Result: {hd:.6f}") + + return (float(hd), details) + + +class ChamferDistanceNode: + """ + Compute Chamfer distance between two meshes or point clouds. + + Chamfer distance is the average of squared distances from each point + to its nearest neighbor in the other set. More sensitive to overall + shape similarity than Hausdorff distance. + """ + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "mesh_a": ("MESH",), + "mesh_b": ("MESH",), + "sample_count": ("INT", { + "default": 10000, + "min": 1000, + "max": 1000000, + "step": 1000 + }), + }, + } + + RETURN_TYPES = ("FLOAT", "STRING") + RETURN_NAMES = ("chamfer_distance", "info") + FUNCTION = "compute_distance" + CATEGORY = "geompack/analysis/distance" + + def compute_distance(self, mesh_a, mesh_b, sample_count): + """ + Compute Chamfer distance between two meshes. + + Args: + mesh_a: First trimesh.Trimesh object + mesh_b: Second trimesh.Trimesh object + sample_count: Number of points to sample from each mesh + + Returns: + tuple: (chamfer_distance, info_string) + """ + try: + import point_cloud_utils as pcu + except ImportError: + raise ImportError( + "point-cloud-utils not installed. Install with: pip install point-cloud-utils" + ) + + print(f"[ChamferDistance] Comparing meshes with {sample_count} samples each") + + # Sample point clouds from meshes + points_a = mesh_a.sample(sample_count) + points_b = mesh_b.sample(sample_count) + + # Compute Chamfer distance + cd = pcu.chamfer_distance(points_a, points_b) + + info = f"""Chamfer Distance: {cd:.6f} + +Sampled {sample_count:,} points from each mesh. +Mesh A: {len(mesh_a.vertices):,} vertices, {len(mesh_a.faces):,} faces +Mesh B: {len(mesh_b.vertices):,} vertices, {len(mesh_b.faces):,} faces + +Note: Chamfer distance is more sensitive to overall shape +similarity compared to Hausdorff distance. +""" + + print(f"[ChamferDistance] Result: {cd:.6f}") + + return (float(cd), info) + + +class ComputeSDFNode: + """ + Compute Signed Distance Field (SDF) for a mesh. + + The SDF represents the distance from any point in 3D space to the + nearest surface, with negative values inside the mesh and positive + values outside. Useful for occupancy queries and implicit surface + representations. + """ + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "mesh": ("MESH",), + "resolution": ("INT", { + "default": 64, + "min": 16, + "max": 256, + "step": 16 + }), + }, + } + + RETURN_TYPES = ("SDF_VOLUME", "STRING") + RETURN_NAMES = ("sdf_volume", "info") + FUNCTION = "compute_sdf" + CATEGORY = "geompack/analysis/distance" + + def compute_sdf(self, mesh, resolution): + """ + Compute signed distance field voxel grid for mesh. + + Args: + mesh: Input trimesh.Trimesh object + resolution: Grid resolution (N x N x N voxels) + + Returns: + tuple: (sdf_data_dict, info_string) + """ + try: + import mesh_to_sdf + except ImportError: + raise ImportError( + "mesh-to-sdf not installed. Install with: pip install mesh-to-sdf" + ) + + print(f"[ComputeSDF] Computing {resolution}³ SDF for mesh with {len(mesh.vertices):,} vertices") + + # Compute SDF voxel grid + voxels = mesh_to_sdf.mesh_to_voxels(mesh, resolution) + + info = f"""Signed Distance Field: +Resolution: {resolution}³ = {resolution**3:,} voxels +Value range: [{voxels.min():.3f}, {voxels.max():.3f}] + +Mesh bounds: {mesh.bounds.tolist()} +Mesh extents: {mesh.extents.tolist()} + +Negative values = inside mesh +Positive values = outside mesh +Zero = on surface +""" + + # Package SDF data + sdf_data = { + 'voxels': voxels, + 'resolution': resolution, + 'bounds': mesh.bounds.copy(), + 'extents': mesh.extents.copy(), + } + + print(f"[ComputeSDF] Complete - range: [{voxels.min():.3f}, {voxels.max():.3f}]") + + return (sdf_data, info) + + +# Node mappings +NODE_CLASS_MAPPINGS = { + "GeomPackHausdorffDistance": HausdorffDistanceNode, + "GeomPackChamferDistance": ChamferDistanceNode, + "GeomPackComputeSDF": ComputeSDFNode, +} + +NODE_DISPLAY_NAME_MAPPINGS = { + "GeomPackHausdorffDistance": "Hausdorff Distance", + "GeomPackChamferDistance": "Chamfer Distance", + "GeomPackComputeSDF": "Compute SDF", +} diff --git a/nodes/examples.py b/nodes/examples.py new file mode 100644 index 00000000..0af74b1c --- /dev/null +++ b/nodes/examples.py @@ -0,0 +1,92 @@ +""" +Example Nodes - Templates and demonstrations +""" + + +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) + + +# Node mappings +NODE_CLASS_MAPPINGS = { + "GeomPackExampleNode": ExampleLibiglNode, +} + +NODE_DISPLAY_NAME_MAPPINGS = { + "GeomPackExampleNode": "Example Node", +} diff --git a/nodes/io.py b/nodes/io.py new file mode 100644 index 00000000..0b18b2df --- /dev/null +++ b/nodes/io.py @@ -0,0 +1,169 @@ +""" +I/O Nodes - Load and save mesh files +""" + +import os + +# 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 + +from . import mesh_utils + + +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,) + + +# Node mappings +NODE_CLASS_MAPPINGS = { + "GeomPackLoadMesh": LoadMesh, + "GeomPackSaveMesh": SaveMesh, +} + +NODE_DISPLAY_NAME_MAPPINGS = { + "GeomPackLoadMesh": "Load Mesh", + "GeomPackSaveMesh": "Save Mesh", +} diff --git a/mesh_utils.py b/nodes/mesh_utils.py similarity index 70% rename from mesh_utils.py rename to nodes/mesh_utils.py index 45c79262..fa27bd5b 100644 --- a/mesh_utils.py +++ b/nodes/mesh_utils.py @@ -1,6 +1,6 @@ """ Mesh utilities for ComfyUI GeomPack nodes -Handles mesh data structures, I/O, and processing using trimesh and seagullmesh (CGAL) +Handles mesh data structures, I/O, and processing using trimesh and CGAL """ import numpy as np @@ -9,7 +9,7 @@ import igl import os from typing import Tuple, Optional -# PyMeshLab for remeshing (TODO: will switch back to CGAL eventually) +# PyMeshLab for remeshing (alternative to CGAL) try: import pymeshlab PYMESHLAB_AVAILABLE = True @@ -17,6 +17,16 @@ except ImportError: PYMESHLAB_AVAILABLE = False print("[mesh_utils] Warning: pymeshlab not available. Install with: pip install pymeshlab") +# Official CGAL Python bindings for isotropic remeshing +try: + from CGAL import CGAL_Polygon_mesh_processing + from CGAL.CGAL_Polyhedron_3 import Polyhedron_3 + from CGAL.CGAL_Kernel import Point_3, Point_3_Vector, Int_Vector, Polygon_Vector + CGAL_AVAILABLE = True +except ImportError: + CGAL_AVAILABLE = False + print("[mesh_utils] Warning: CGAL not available. Install with: pip install cgal") + def load_mesh_file(file_path: str) -> Tuple[Optional[trimesh.Trimesh], str]: """ @@ -478,3 +488,176 @@ def mark_boundary_vertices(mesh: trimesh.Trimesh) -> Tuple[Optional[trimesh.Trim import traceback traceback.print_exc() return None, f"Error detecting boundary edges: {str(e)}" + + +def cgal_isotropic_remesh( + mesh: trimesh.Trimesh, + target_edge_length: float, + iterations: int = 3, + protect_boundaries: bool = True +) -> Tuple[Optional[trimesh.Trimesh], str]: + """ + Apply CGAL isotropic remeshing using official Python bindings. + + Creates a uniform triangle mesh with specified edge length using CGAL's + high-quality remeshing algorithm. + + Args: + mesh: Input trimesh object + target_edge_length: Target edge length for output triangles + iterations: Number of remeshing iterations (1-20) + protect_boundaries: Preserve boundary edges + + Returns: + Tuple of (remeshed_mesh, error_message) + """ + print(f"[cgal_isotropic_remesh] ===== Starting CGAL Isotropic Remeshing =====") + print(f"[cgal_isotropic_remesh] Input: {len(mesh.vertices)} vertices, {len(mesh.faces)} faces") + print(f"[cgal_isotropic_remesh] Parameters: target_edge_length={target_edge_length}, iterations={iterations}, protect_boundaries={protect_boundaries}") + + if not CGAL_AVAILABLE: + error_msg = "CGAL is not installed. Install with: pip install cgal" + print(f"[cgal_isotropic_remesh] ERROR: {error_msg}") + return None, error_msg + + if not isinstance(mesh, trimesh.Trimesh): + return None, "Input must be a trimesh.Trimesh object" + + if len(mesh.vertices) == 0 or len(mesh.faces) == 0: + return None, "Mesh is empty" + + if target_edge_length <= 0: + return None, f"Target edge length must be positive, got {target_edge_length}" + + if iterations < 1 or iterations > 20: + return None, f"Iterations must be between 1 and 20, got {iterations}" + + try: + # Step 1: Convert trimesh to CGAL Polyhedron_3 + print(f"[cgal_isotropic_remesh] Converting to CGAL format...") + + # Create Point_3_Vector for vertices + points = Point_3_Vector() + points.reserve(len(mesh.vertices)) + for v in mesh.vertices: + points.append(Point_3(float(v[0]), float(v[1]), float(v[2]))) + + # Create Polygon_Vector for faces + polygons = Polygon_Vector() + for face in mesh.faces: + polygon = Int_Vector() + polygon.reserve(3) + for idx in face: + polygon.append(int(idx)) + polygons.append(polygon) + + # Create polyhedron from polygon soup + P = Polyhedron_3() + CGAL_Polygon_mesh_processing.polygon_soup_to_polygon_mesh(points, polygons, P) + + print(f"[cgal_isotropic_remesh] CGAL mesh created: {P.size_of_vertices()} vertices, {P.size_of_facets()} facets") + + # Step 2: Collect all facets for remeshing + flist = [] + for fh in P.facets(): + flist.append(fh) + + # Step 3: Handle boundary protection if requested + if protect_boundaries: + print(f"[cgal_isotropic_remesh] Collecting boundary halfedges for protection...") + hlist = [] + for hh in P.halfedges(): + if hh.is_border() or hh.opposite().is_border(): + hlist.append(hh) + + print(f"[cgal_isotropic_remesh] Found {len(hlist)} boundary halfedges") + + # Perform remeshing with boundary protection + print(f"[cgal_isotropic_remesh] Running CGAL isotropic_remeshing (with boundary protection)...") + CGAL_Polygon_mesh_processing.isotropic_remeshing( + flist, + target_edge_length, + P, + iterations, + hlist, + True # protect_constraints + ) + else: + # Perform remeshing without boundary protection + print(f"[cgal_isotropic_remesh] Running CGAL isotropic_remeshing...") + CGAL_Polygon_mesh_processing.isotropic_remeshing( + flist, + target_edge_length, + P, + iterations + ) + + print(f"[cgal_isotropic_remesh] Remeshing complete: {P.size_of_vertices()} vertices, {P.size_of_facets()} facets") + + # Step 4: Extract vertices back to numpy arrays + print(f"[cgal_isotropic_remesh] Converting back to trimesh...") + new_vertices = [] + vertex_map = {} + + for i, vertex in enumerate(P.vertices()): + point = vertex.point() + new_vertices.append([point.x(), point.y(), point.z()]) + vertex_map[vertex] = i + + new_vertices = np.array(new_vertices, dtype=np.float64) + + # Step 5: Extract faces back to numpy arrays + new_faces = [] + for facet in P.facets(): + halfedge = facet.halfedge() + face_vertices = [] + + start = halfedge + current = start + while True: + vertex_handle = current.vertex() + face_vertices.append(vertex_map[vertex_handle]) + current = current.next() + if current == start: + break + + if len(face_vertices) == 3: + new_faces.append(face_vertices) + + new_faces = np.array(new_faces, dtype=np.int32) + + # Create new trimesh object + remeshed_mesh = trimesh.Trimesh(vertices=new_vertices, faces=new_faces) + + # Preserve metadata + remeshed_mesh.metadata = mesh.metadata.copy() + remeshed_mesh.metadata['remeshing'] = { + 'algorithm': 'cgal_isotropic_python', + 'target_edge_length': target_edge_length, + 'iterations': iterations, + 'protect_boundaries': protect_boundaries, + 'original_vertices': len(mesh.vertices), + 'original_faces': len(mesh.faces), + 'remeshed_vertices': len(remeshed_mesh.vertices), + 'remeshed_faces': len(remeshed_mesh.faces) + } + + # Calculate statistics + vertex_change = len(remeshed_mesh.vertices) - len(mesh.vertices) + face_change = len(remeshed_mesh.faces) - len(mesh.faces) + vertex_pct = (vertex_change / len(mesh.vertices)) * 100 if len(mesh.vertices) > 0 else 0 + face_pct = (face_change / len(mesh.faces)) * 100 if len(mesh.faces) > 0 else 0 + + print(f"[cgal_isotropic_remesh] ===== Remeshing Complete =====") + print(f"[cgal_isotropic_remesh] Results:") + print(f"[cgal_isotropic_remesh] Vertices: {len(mesh.vertices)} -> {len(remeshed_mesh.vertices)} ({vertex_change:+d}, {vertex_pct:+.1f}%)") + print(f"[cgal_isotropic_remesh] Faces: {len(mesh.faces)} -> {len(remeshed_mesh.faces)} ({face_change:+d}, {face_pct:+.1f}%)") + + return remeshed_mesh, "" + + except Exception as e: + import traceback + traceback.print_exc() + error_msg = f"Error during CGAL remesh: {str(e)}" + print(f"[cgal_isotropic_remesh] ERROR: {error_msg}") + return None, error_msg diff --git a/nodes/primitives.py b/nodes/primitives.py new file mode 100644 index 00000000..77af918f --- /dev/null +++ b/nodes/primitives.py @@ -0,0 +1,76 @@ +""" +Primitive Nodes - Create basic geometric shapes +""" + +from . import mesh_utils + + +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,) + + +# Node mappings +NODE_CLASS_MAPPINGS = { + "GeomPackCreatePrimitive": CreatePrimitive, +} + +NODE_DISPLAY_NAME_MAPPINGS = { + "GeomPackCreatePrimitive": "Create Primitive", +} diff --git a/nodes/remeshing.py b/nodes/remeshing.py new file mode 100644 index 00000000..f3a26b78 --- /dev/null +++ b/nodes/remeshing.py @@ -0,0 +1,463 @@ +""" +Remeshing Nodes - Mesh remeshing and optimization algorithms +""" + +import numpy as np +import trimesh +import os +import subprocess +import tempfile +import shutil + +from . 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 PyMeshLabRemeshNode: + """ + PyMeshLab Isotropic Remeshing - Create uniform triangle meshes. + + Uses PyMeshLab's implementation of isotropic remeshing. + 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/pymeshlab" + + def remesh(self, mesh, target_edge_length, iterations): + """ + Apply PyMeshLab 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"[PyMeshLabRemesh] Input: {len(mesh.vertices)} vertices, {len(mesh.faces)} faces") + print(f"[PyMeshLabRemesh] Target edge length: {target_edge_length}, Iterations: {iterations}") + + remeshed_mesh, error = mesh_utils.pymeshlab_isotropic_remesh( + mesh, + target_edge_length, + iterations + ) + + if remeshed_mesh is None: + raise ValueError(f"Remeshing failed: {error}") + + print(f"[PyMeshLabRemesh] Output: {len(remeshed_mesh.vertices)} vertices, {len(remeshed_mesh.faces)} faces") + + return (remeshed_mesh,) + + +class CGALIsotropicRemeshNode: + """ + CGAL Isotropic Remeshing - Create uniform triangle meshes using CGAL. + + Uses CGAL's high-quality isotropic remeshing algorithm to create meshes + with uniform triangle sizes and improved quality. Preserves volume and + surface features while creating more regular triangulations. + + Requires the CGAL Python package: pip install cgal + """ + + @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 + }), + "protect_boundaries": (["true", "false"], { + "default": "true" + }), + }, + } + + RETURN_TYPES = ("MESH",) + RETURN_NAMES = ("remeshed_mesh",) + FUNCTION = "remesh" + CATEGORY = "geompack/cgal" + + def remesh(self, mesh, target_edge_length, iterations, protect_boundaries="true"): + """ + Apply CGAL isotropic remeshing. + + Args: + mesh: Input trimesh.Trimesh object + target_edge_length: Target edge length for remeshed triangles + iterations: Number of remeshing iterations (1-20) + protect_boundaries: Whether to preserve boundary edges ("true" or "false") + + 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}") + print(f"[CGALRemesh] Protect boundaries: {protect_boundaries}") + + protect = (protect_boundaries == "true") + + remeshed_mesh, error = mesh_utils.cgal_isotropic_remesh( + mesh, + target_edge_length, + iterations, + protect + ) + + if remeshed_mesh is None: + raise ValueError(f"CGAL remeshing failed: {error}") + + print(f"[CGALRemesh] Output: {len(remeshed_mesh.vertices)} vertices, {len(remeshed_mesh.faces)} faces") + + return (remeshed_mesh,) + + +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 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 mappings +NODE_CLASS_MAPPINGS = { + "GeomPackPyMeshLabRemesh": PyMeshLabRemeshNode, + "GeomPackCGALIsotropicRemesh": CGALIsotropicRemeshNode, + "GeomPackBlenderVoxelRemesh": BlenderVoxelRemeshNode, + "GeomPackBlenderQuadriflowRemesh": BlenderQuadriflowRemeshNode, +} + +NODE_DISPLAY_NAME_MAPPINGS = { + "GeomPackPyMeshLabRemesh": "PyMeshLab Remesh (Isotropic)", + "GeomPackCGALIsotropicRemesh": "CGAL Isotropic Remesh", + "GeomPackBlenderVoxelRemesh": "Blender Voxel Remesh", + "GeomPackBlenderQuadriflowRemesh": "Blender Quadriflow Remesh", +} diff --git a/nodes/transforms.py b/nodes/transforms.py new file mode 100644 index 00000000..41edb0fc --- /dev/null +++ b/nodes/transforms.py @@ -0,0 +1,68 @@ +""" +Transform Nodes - Mesh transformations and positioning +""" + + +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,) + + +# Node mappings +NODE_CLASS_MAPPINGS = { + "GeomPackCenterMesh": CenterMeshNode, +} + +NODE_DISPLAY_NAME_MAPPINGS = { + "GeomPackCenterMesh": "Center Mesh", +} diff --git a/nodes/uv.py b/nodes/uv.py new file mode 100644 index 00000000..80633bb5 --- /dev/null +++ b/nodes/uv.py @@ -0,0 +1,721 @@ +""" +UV Mapping Nodes - UV unwrapping and parameterization +""" + +import numpy as np +import trimesh +import os +import subprocess +import tempfile +import shutil + + +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 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": { + "mesh": ("MESH",), + }, + } + + RETURN_TYPES = ("MESH",) + RETURN_NAMES = ("unwrapped_mesh",) + FUNCTION = "uv_unwrap" + CATEGORY = "geompack/uv" + + def uv_unwrap(self, mesh): + """ + UV unwrap mesh using xatlas. + + Args: + mesh: Input trimesh.Trimesh object + + Returns: + tuple: (unwrapped_trimesh.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(mesh.vertices)} vertices, {len(mesh.faces)} faces") + + # Parametrize with xatlas + vmapping, indices, uvs = xatlas.parametrize( + mesh.vertices, + mesh.faces + ) + + # Create new mesh with UV-split vertices + new_vertices = mesh.vertices[vmapping] + + # Create trimesh with UV coordinates + unwrapped = trimesh.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 = mesh.metadata.copy() + unwrapped.metadata['uv_unwrap'] = { + 'algorithm': 'xatlas', + 'original_vertices': len(mesh.vertices), + 'unwrapped_vertices': len(new_vertices), + 'vertex_duplication_ratio': len(new_vertices) / len(mesh.vertices) + } + + print(f"[XAtlasUVUnwrap] Output: {len(unwrapped.vertices)} vertices, {len(unwrapped.faces)} faces") + print(f"[XAtlasUVUnwrap] Vertex duplication: {len(new_vertices)/len(mesh.vertices):.2f}x") + + return (unwrapped,) + + +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": { + "mesh": ("MESH",), + }, + } + + RETURN_TYPES = ("MESH",) + RETURN_NAMES = ("unwrapped_mesh",) + FUNCTION = "uv_unwrap" + CATEGORY = "geompack/uv" + + def uv_unwrap(self, mesh): + """ + LSCM UV parameterization using libigl. + + Args: + mesh: Input trimesh.Trimesh object + + Returns: + tuple: (unwrapped_trimesh.Trimesh,) + """ + try: + import igl + except ImportError: + raise ImportError("libigl not installed (should be in requirements.txt)") + + print(f"[LibiglLSCM] Input: {len(mesh.vertices)} vertices, {len(mesh.faces)} faces") + + # LSCM requires fixing 2 vertices for unique solution + # Choose first and last vertex + v_fixed = np.array([0, len(mesh.vertices)-1], dtype=np.int32) + uv_fixed = np.array([[0.0, 0.0], [1.0, 0.0]], dtype=np.float64) + + # Compute LSCM parameterization + uv = igl.lscm( + mesh.vertices.astype(np.float64), + mesh.faces.astype(np.int32), + v_fixed, + uv_fixed + ) + + # 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 = mesh.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,) + + +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 BlenderCubeProjectionNode: + """ + UV Cube Projection using Blender. + + Projects mesh onto 6 faces of a cube. Perfect for box-like geometry. + Creates 6 overlapping UV islands that can be separated. + """ + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "mesh": ("MESH",), + "cube_size": ("FLOAT", { + "default": 1.0, + "min": 0.1, + "max": 10.0, + "step": 0.1 + }), + }, + } + + RETURN_TYPES = ("MESH",) + RETURN_NAMES = ("unwrapped_mesh",) + FUNCTION = "uv_unwrap" + CATEGORY = "geompack/uv" + + def uv_unwrap(self, mesh, cube_size): + """ + UV cube projection using Blender. + + Args: + mesh: Input trimesh.Trimesh object + cube_size: Size of the projection cube + + Returns: + tuple: (unwrapped_trimesh.Trimesh,) + """ + print(f"[BlenderCubeProjection] Input: {len(mesh.vertices)} vertices, {len(mesh.faces)} faces") + print(f"[BlenderCubeProjection] Cube size: {cube_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 cube projection + 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 apply cube projection +bpy.ops.object.mode_set(mode='EDIT') +bpy.ops.mesh.select_all(action='SELECT') +bpy.ops.uv.cube_project( + cube_size={cube_size}, + correct_aspect=True, + clip_to_bounds=False, + 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"[BlenderCubeProjection] Running Blender...") + 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.load(output_path, process=False) + + if isinstance(unwrapped, trimesh.Scene): + unwrapped = unwrapped.dump(concatenate=True) + + # Preserve metadata + unwrapped.metadata = mesh.metadata.copy() + unwrapped.metadata['uv_unwrap'] = { + 'algorithm': 'blender_cube_projection', + 'cube_size': cube_size + } + + print(f"[BlenderCubeProjection] Complete") + + return (unwrapped,) + + finally: + # Cleanup + if os.path.exists(input_path): + os.unlink(input_path) + if os.path.exists(output_path): + os.unlink(output_path) + + +class BlenderCylinderProjectionNode: + """ + UV Cylinder Projection using Blender. + + Projects mesh onto a cylinder surface. Perfect for cylindrical objects + like bottles, columns, pipes, etc. + """ + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "mesh": ("MESH",), + "radius": ("FLOAT", { + "default": 1.0, + "min": 0.1, + "max": 10.0, + "step": 0.1 + }), + }, + } + + RETURN_TYPES = ("MESH",) + RETURN_NAMES = ("unwrapped_mesh",) + FUNCTION = "uv_unwrap" + CATEGORY = "geompack/uv" + + def uv_unwrap(self, mesh, radius): + """ + UV cylinder projection using Blender. + + Args: + mesh: Input trimesh.Trimesh object + radius: Cylinder radius + + Returns: + tuple: (unwrapped_trimesh.Trimesh,) + """ + print(f"[BlenderCylinderProjection] Input: {len(mesh.vertices)} vertices, {len(mesh.faces)} faces") + print(f"[BlenderCylinderProjection] Radius: {radius}") + + # 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 cylinder projection + 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 apply cylinder projection +bpy.ops.object.mode_set(mode='EDIT') +bpy.ops.mesh.select_all(action='SELECT') +bpy.ops.uv.cylinder_project( + direction='VIEW_ON_EQUATOR', + align='POLAR_ZX', + radius={radius}, + 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"[BlenderCylinderProjection] Running Blender...") + 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.load(output_path, process=False) + + if isinstance(unwrapped, trimesh.Scene): + unwrapped = unwrapped.dump(concatenate=True) + + # Preserve metadata + unwrapped.metadata = mesh.metadata.copy() + unwrapped.metadata['uv_unwrap'] = { + 'algorithm': 'blender_cylinder_projection', + 'radius': radius + } + + print(f"[BlenderCylinderProjection] Complete") + + return (unwrapped,) + + finally: + # Cleanup + if os.path.exists(input_path): + os.unlink(input_path) + if os.path.exists(output_path): + os.unlink(output_path) + + +class BlenderSphereProjectionNode: + """ + UV Sphere Projection using Blender. + + Projects mesh onto a sphere surface. Perfect for spherical objects + like planets, balls, eyes, etc. Creates equirectangular projection. + """ + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "mesh": ("MESH",), + }, + } + + RETURN_TYPES = ("MESH",) + RETURN_NAMES = ("unwrapped_mesh",) + FUNCTION = "uv_unwrap" + CATEGORY = "geompack/uv" + + def uv_unwrap(self, mesh): + """ + UV sphere projection using Blender. + + Args: + mesh: Input trimesh.Trimesh object + + Returns: + tuple: (unwrapped_trimesh.Trimesh,) + """ + print(f"[BlenderSphereProjection] Input: {len(mesh.vertices)} vertices, {len(mesh.faces)} faces") + + # 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 sphere projection + 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 apply sphere projection +bpy.ops.object.mode_set(mode='EDIT') +bpy.ops.mesh.select_all(action='SELECT') +bpy.ops.uv.sphere_project( + direction='VIEW_ON_EQUATOR', + align='POLAR_ZX', + 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"[BlenderSphereProjection] Running Blender...") + 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.load(output_path, process=False) + + if isinstance(unwrapped, trimesh.Scene): + unwrapped = unwrapped.dump(concatenate=True) + + # Preserve metadata + unwrapped.metadata = mesh.metadata.copy() + unwrapped.metadata['uv_unwrap'] = { + 'algorithm': 'blender_sphere_projection', + } + + print(f"[BlenderSphereProjection] Complete") + + return (unwrapped,) + + finally: + # Cleanup + if os.path.exists(input_path): + os.unlink(input_path) + if os.path.exists(output_path): + os.unlink(output_path) + + +# Node mappings +NODE_CLASS_MAPPINGS = { + "GeomPackXAtlasUVUnwrap": XAtlasUVUnwrapNode, + "GeomPackLibiglLSCM": LibiglLSCMNode, + "GeomPackBlenderUVUnwrap": BlenderUVUnwrapNode, + "GeomPackBlenderCubeProjection": BlenderCubeProjectionNode, + "GeomPackBlenderCylinderProjection": BlenderCylinderProjectionNode, + "GeomPackBlenderSphereProjection": BlenderSphereProjectionNode, +} + +NODE_DISPLAY_NAME_MAPPINGS = { + "GeomPackXAtlasUVUnwrap": "xAtlas UV Unwrap", + "GeomPackLibiglLSCM": "libigl LSCM Unwrap", + "GeomPackBlenderUVUnwrap": "Blender UV Unwrap", + "GeomPackBlenderCubeProjection": "Blender Cube Projection", + "GeomPackBlenderCylinderProjection": "Blender Cylinder Projection", + "GeomPackBlenderSphereProjection": "Blender Sphere Projection", +} diff --git a/nodes/visualization.py b/nodes/visualization.py new file mode 100644 index 00000000..290c6dd1 --- /dev/null +++ b/nodes/visualization.py @@ -0,0 +1,507 @@ +""" +Visualization Nodes - 3D mesh preview and visualization +""" + +import trimesh +import os +import tempfile +import uuid + + +# ComfyUI folder paths +try: + import folder_paths + COMFYUI_OUTPUT_FOLDER = folder_paths.get_output_directory() +except: + COMFYUI_OUTPUT_FOLDER = None + + +def _export_mesh_with_scalars_vtp(mesh: trimesh.Trimesh, filepath: str): + """ + Export trimesh to VTK PolyData XML format (.vtp) with vertex scalar attributes. + + VTP format preserves vertex attributes (scalar fields) which can be visualized + in VTK.js with color mapping. + + Args: + mesh: Trimesh object with optional vertex_attributes + filepath: Output .vtp file path + """ + import xml.etree.ElementTree as ET + + print(f"[_export_mesh_with_scalars_vtp] Exporting to VTP: {filepath}") + + # Create VTK PolyData XML structure + vtk_file = ET.Element('VTKFile', type='PolyData', version='1.0', byte_order='LittleEndian') + poly_data = ET.SubElement(vtk_file, 'PolyData') + + num_verts = len(mesh.vertices) + num_faces = len(mesh.faces) + + piece = ET.SubElement(poly_data, 'Piece', NumberOfPoints=str(num_verts), NumberOfPolys=str(num_faces)) + + # Points section + points = ET.SubElement(piece, 'Points') + points_data_array = ET.SubElement(points, 'DataArray', + type='Float32', + NumberOfComponents='3', + format='ascii') + # Flatten vertices to space-separated string + verts_flat = mesh.vertices.flatten() + points_data_array.text = ' '.join(map(str, verts_flat)) + + # PointData section (scalar fields) + point_data = ET.SubElement(piece, 'PointData') + + # Add vertex attributes as scalar arrays + if hasattr(mesh, 'vertex_attributes') and mesh.vertex_attributes: + for attr_name, attr_values in mesh.vertex_attributes.items(): + print(f"[_export_mesh_with_scalars_vtp] Adding scalar field: {attr_name}") + scalar_array = ET.SubElement(point_data, 'DataArray', + type='Float32', + Name=attr_name, + format='ascii') + scalar_array.text = ' '.join(map(str, attr_values.flatten())) + + # Polys section (faces/triangles) + polys = ET.SubElement(piece, 'Polys') + + # Connectivity: vertex indices for each face + connectivity = ET.SubElement(polys, 'DataArray', + type='Int32', + Name='connectivity', + format='ascii') + faces_flat = mesh.faces.flatten() + connectivity.text = ' '.join(map(str, faces_flat)) + + # Offsets: cumulative count of indices (each triangle has 3 vertices) + offsets = ET.SubElement(polys, 'DataArray', + type='Int32', + Name='offsets', + format='ascii') + offset_values = [(i + 1) * 3 for i in range(num_faces)] + offsets.text = ' '.join(map(str, offset_values)) + + # Write to file with pretty formatting + tree = ET.ElementTree(vtk_file) + ET.indent(tree, space=' ') + tree.write(filepath, encoding='utf-8', xml_declaration=True) + + print(f"[_export_mesh_with_scalars_vtp] Export complete: {num_verts} vertices, {num_faces} faces") + + +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 + """ + 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 + """ + 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) + + # Check if mesh is watertight + is_watertight = mesh.is_watertight + + # Calculate volume and area (only if watertight) + volume = None + area = None + try: + if is_watertight: + volume = float(mesh.volume) + area = float(mesh.area) + except Exception as e: + print(f"[PreviewMeshVTK] Could not calculate volume/area: {e}") + + # Get field names (vertex/face data arrays) + field_names = [] + if hasattr(mesh, 'vertex_attributes') and mesh.vertex_attributes: + field_names.extend([f"vertex.{k}" for k in mesh.vertex_attributes.keys()]) + if hasattr(mesh, 'face_attributes') and mesh.face_attributes: + field_names.extend([f"face.{k}" for k in mesh.face_attributes.keys()]) + + # Return metadata for frontend widget + ui_data = { + "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)], + "is_watertight": [bool(is_watertight)], + } + + # Add optional fields if available + if volume is not None: + ui_data["volume"] = [volume] + if area is not None: + ui_data["area"] = [area] + if field_names: + ui_data["field_names"] = [field_names] + + print(f"[PreviewMeshVTK] Mesh info: watertight={is_watertight}, volume={volume}, area={area}, fields={len(field_names)}") + + return {"ui": ui_data} + + +class PreviewMeshVTKFiltersNode: + """ + Preview mesh with VTK.js scientific visualization viewer including filters. + + Displays mesh in an interactive VTK.js viewer with trackball controls + and filter controls (smoothing, outline, cutting plane). + 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_filters" + CATEGORY = "geompack/visualization" + + def preview_mesh_vtk_filters(self, mesh): + """ + Export mesh to STL and prepare for VTK.js preview with filters. + + Args: + mesh: Input trimesh.Trimesh object + + Returns: + dict: UI data for frontend widget + """ + print(f"[PreviewMeshVTKFilters] Preparing preview: {len(mesh.vertices)} vertices, {len(mesh.faces)} faces") + + # Check if mesh has vertex attributes (scalar fields) + has_vertex_attrs = hasattr(mesh, 'vertex_attributes') and len(mesh.vertex_attributes) > 0 + + print(f"[PreviewMeshVTKFilters] hasattr(mesh, 'vertex_attributes'): {hasattr(mesh, 'vertex_attributes')}") + if hasattr(mesh, 'vertex_attributes'): + print(f"[PreviewMeshVTKFilters] mesh.vertex_attributes: {mesh.vertex_attributes.keys()}") + print(f"[PreviewMeshVTKFilters] Number of vertex attributes: {len(mesh.vertex_attributes)}") + + # Generate unique filename + # Use VTP format if we have vertex attributes, otherwise STL + if has_vertex_attrs: + filename = f"preview_vtk_filters_{uuid.uuid4().hex[:8]}.vtp" + file_format = 'vtp' + print(f"[PreviewMeshVTKFilters] Mesh has vertex attributes, using VTP format") + else: + filename = f"preview_vtk_filters_{uuid.uuid4().hex[:8]}.stl" + file_format = 'stl' + print(f"[PreviewMeshVTKFilters] No vertex attributes found, using STL format") + + # 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 mesh with vertex attributes + try: + if file_format == 'vtp': + # Export to VTK PolyData format (XML) which preserves vertex attributes + _export_mesh_with_scalars_vtp(mesh, filepath) + print(f"[PreviewMeshVTKFilters] Exported VTP with {len(mesh.vertex_attributes)} scalar fields to: {filepath}") + else: + # Export to STL (no scalar data) + mesh.export(filepath, file_type='stl') + print(f"[PreviewMeshVTKFilters] Exported to STL: {filepath}") + except Exception as e: + print(f"[PreviewMeshVTKFilters] Export failed: {e}") + # Fallback to OBJ + filename = filename.replace('.vtp', '.obj').replace('.stl', '.obj') + filepath = filepath.replace('.vtp', '.obj').replace('.stl', '.obj') + mesh.export(filepath, file_type='obj') + print(f"[PreviewMeshVTKFilters] Exported to OBJ: {filepath}") + + # Calculate bounding box info for camera setup + bounds = mesh.bounds + extents = mesh.extents + max_extent = max(extents) + + # Check if mesh is watertight + is_watertight = mesh.is_watertight + + # Calculate volume and area (only if watertight) + volume = None + area = None + try: + if is_watertight: + volume = float(mesh.volume) + area = float(mesh.area) + except Exception as e: + print(f"[PreviewMeshVTKFilters] Could not calculate volume/area: {e}") + + # Get field names (vertex/face data arrays) + field_names = [] + if hasattr(mesh, 'vertex_attributes') and mesh.vertex_attributes: + field_names.extend([f"vertex.{k}" for k in mesh.vertex_attributes.keys()]) + if hasattr(mesh, 'face_attributes') and mesh.face_attributes: + field_names.extend([f"face.{k}" for k in mesh.face_attributes.keys()]) + + # Return metadata for frontend widget + ui_data = { + "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)], + "is_watertight": [bool(is_watertight)], + } + + # Add optional fields if available + if volume is not None: + ui_data["volume"] = [volume] + if area is not None: + ui_data["area"] = [area] + if field_names: + ui_data["field_names"] = [field_names] + + print(f"[PreviewMeshVTKFilters] Mesh info: watertight={is_watertight}, volume={volume}, area={area}, fields={len(field_names)}") + + return {"ui": ui_data} + + +class PreviewMeshVTKFieldsNode: + """ + Preview mesh with VTK.js viewer optimized for scalar field visualization. + + Specifically designed for visualizing vertex scalar fields (like boundary edges, + curvature, distance fields, etc.) with color mapping. Always exports as VTP + format to preserve scalar data. + """ + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "mesh": ("MESH",), + }, + } + + RETURN_TYPES = () + OUTPUT_NODE = True + FUNCTION = "preview_mesh_vtk_fields" + CATEGORY = "geompack/visualization" + + def preview_mesh_vtk_fields(self, mesh): + """ + Export mesh with scalar fields to VTP and prepare for VTK.js preview. + + Args: + mesh: Input trimesh.Trimesh object + + Returns: + dict: UI data for frontend widget + """ + print(f"[PreviewMeshVTKFields] Preparing preview: {len(mesh.vertices)} vertices, {len(mesh.faces)} faces") + + # Check if mesh has vertex attributes (scalar fields) + has_vertex_attrs = hasattr(mesh, 'vertex_attributes') and len(mesh.vertex_attributes) > 0 + + if hasattr(mesh, 'vertex_attributes'): + print(f"[PreviewMeshVTKFields] mesh.vertex_attributes: {mesh.vertex_attributes.keys()}") + print(f"[PreviewMeshVTKFields] Number of vertex attributes: {len(mesh.vertex_attributes)}") + + if not has_vertex_attrs: + print(f"[PreviewMeshVTKFields] WARNING: No vertex attributes found. This node is for scalar field visualization.") + + # Always use VTP format for this node (designed for scalar fields) + filename = f"preview_vtk_fields_{uuid.uuid4().hex[:8]}.vtp" + + # 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 mesh with vertex attributes as VTP + try: + _export_mesh_with_scalars_vtp(mesh, filepath) + num_fields = len(mesh.vertex_attributes) if has_vertex_attrs else 0 + print(f"[PreviewMeshVTKFields] Exported VTP with {num_fields} scalar fields to: {filepath}") + except Exception as e: + print(f"[PreviewMeshVTKFields] Export failed: {e}") + import traceback + traceback.print_exc() + raise RuntimeError(f"Failed to export mesh with scalar fields: {e}") + + # Calculate bounding box info for camera setup + bounds = mesh.bounds + extents = mesh.extents + max_extent = max(extents) + + # Check if mesh is watertight + is_watertight = mesh.is_watertight + + # Get field names (vertex/face data arrays) + field_names = [] + if hasattr(mesh, 'vertex_attributes') and mesh.vertex_attributes: + field_names.extend(list(mesh.vertex_attributes.keys())) + if hasattr(mesh, 'face_attributes') and mesh.face_attributes: + field_names.extend([f"face.{k}" for k in mesh.face_attributes.keys()]) + + # Return metadata for frontend widget + ui_data = { + "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)], + "is_watertight": [bool(is_watertight)], + "field_names": [field_names], + } + + print(f"[PreviewMeshVTKFields] Scalar fields: {field_names}") + + return {"ui": ui_data} + + +# Node mappings +NODE_CLASS_MAPPINGS = { + "GeomPackPreviewMesh": PreviewMeshNode, + "GeomPackPreviewMeshVTK": PreviewMeshVTKNode, + "GeomPackPreviewMeshVTKFilters": PreviewMeshVTKFiltersNode, + "GeomPackPreviewMeshVTKFields": PreviewMeshVTKFieldsNode, +} + +NODE_DISPLAY_NAME_MAPPINGS = { + "GeomPackPreviewMesh": "Preview Mesh (3D)", + "GeomPackPreviewMeshVTK": "Preview Mesh (VTK)", + "GeomPackPreviewMeshVTKFilters": "Preview Mesh (VTK with Filters)", + "GeomPackPreviewMeshVTKFields": "Preview Mesh (VTK with Fields)", +} diff --git a/requirements.txt b/requirements.txt index 0cdab0f9..55cbf8c6 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,6 +4,7 @@ numpy>=1.21.0 scipy>=1.7.0 trimesh>=3.15.0 pymeshlab>=2022.2 +cgal>=0.9.0 # Official CGAL Python bindings for isotropic remeshing # Visualization matplotlib>=3.5.0 diff --git a/web/js/mesh_preview_vtk.js b/web/js/mesh_preview_vtk.js index 855c9643..746d6545 100644 --- a/web/js/mesh_preview_vtk.js +++ b/web/js/mesh_preview_vtk.js @@ -29,14 +29,15 @@ app.registerExtension({ container.style.display = "flex"; container.style.flexDirection = "column"; container.style.backgroundColor = "#2a2a2a"; + container.style.overflow = "hidden"; // Create iframe for VTK.js viewer const iframe = document.createElement("iframe"); iframe.style.width = "100%"; - iframe.style.flex = "1"; + iframe.style.flex = "1 1 0"; + iframe.style.minHeight = "0"; iframe.style.border = "none"; iframe.style.backgroundColor = "#2a2a2a"; - iframe.style.aspectRatio = "1"; // Point to VTK.js HTML viewer (with cache buster) iframe.src = "/extensions/ComfyUI-GeometryPack/viewer_vtk.html?v=" + Date.now(); @@ -45,11 +46,13 @@ app.registerExtension({ const infoPanel = document.createElement("div"); infoPanel.style.backgroundColor = "#1a1a1a"; infoPanel.style.borderTop = "1px solid #444"; - infoPanel.style.padding = "8px 12px"; - infoPanel.style.fontSize = "11px"; + infoPanel.style.padding = "6px 12px"; + infoPanel.style.fontSize = "10px"; infoPanel.style.fontFamily = "monospace"; infoPanel.style.color = "#ccc"; - infoPanel.style.lineHeight = "1.4"; + infoPanel.style.lineHeight = "1.3"; + infoPanel.style.flexShrink = "0"; + infoPanel.style.overflow = "hidden"; infoPanel.innerHTML = 'Mesh info will appear here after execution'; // Add iframe and info panel to container @@ -70,7 +73,7 @@ app.registerExtension({ console.log("[GeomPack VTK DEBUG] Widget.id:", widget?.id); console.log("[GeomPack VTK DEBUG] this.widgets after addDOMWidget:", this.widgets); - widget.computeSize = () => [512, 560]; // Increased height for info panel + widget.computeSize = () => [512, 640]; // Increased height for viewer + info panel // Store iframe and info panel references this.meshViewerIframeVTK = iframe; @@ -83,9 +86,57 @@ app.registerExtension({ iframeLoaded = true; }); + // Listen for screenshot messages from iframe + window.addEventListener('message', async (event) => { + if (event.data.type === 'SCREENSHOT' && event.data.image) { + console.log('[GeomPack VTK] Received screenshot from iframe'); + + try { + // Convert base64 data URL to blob + 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' }); + + // Generate filename with timestamp + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + const filename = `vtk-screenshot-${timestamp}.png`; + + // Create FormData for upload + const formData = new FormData(); + formData.append('image', blob, filename); + formData.append('type', 'output'); // Save to output directory + formData.append('subfolder', ''); // Root of output folder + + // Upload to ComfyUI backend + console.log('[GeomPack VTK] Uploading screenshot to server...'); + const response = await fetch('/upload/image', { + method: 'POST', + body: formData + }); + + if (response.ok) { + const result = await response.json(); + console.log('[GeomPack VTK] Screenshot saved to outputs folder:', result.name); + } else { + throw new Error(`Upload failed: ${response.status} ${response.statusText}`); + } + + } catch (error) { + console.error('[GeomPack VTK] Error saving screenshot:', error); + } + } + }); + // Set initial node size (increased for info panel) - this.setSize([512, 560]); - console.log("[GeomPack VTK DEBUG] Node size set to [512, 560]"); + this.setSize([512, 640]); + console.log("[GeomPack VTK DEBUG] Node size set to [512, 640]"); // Handle execution const onExecuted = this.onExecuted; @@ -119,7 +170,7 @@ app.registerExtension({ // Build info HTML let infoHTML = ` -