diff --git a/BLENDER_INTEGRATION.md b/BLENDER_INTEGRATION.md new file mode 100644 index 00000000..92652a77 --- /dev/null +++ b/BLENDER_INTEGRATION.md @@ -0,0 +1,419 @@ +# Blender Integration Guide + +ComfyUI-GeomPack integrates Blender's powerful mesh processing capabilities via subprocess calls. This allows you to leverage Blender's UV unwrapping and remeshing tools directly in your ComfyUI workflows. + +## Overview + +Three Blender nodes are available: +1. **Blender UV Unwrap** - Smart UV unwrapping +2. **Blender Voxel Remesh** - Voxel-based remeshing +3. **Blender Quadriflow Remesh** - Quad-dominant remeshing + +## Installation + +### Install Blender + +**macOS:** +```bash +brew install --cask blender +``` + +**Linux:** +```bash +sudo apt install blender +# or +sudo snap install blender +``` + +**Windows:** +Download from https://www.blender.org/download/ + +### Verify Installation + +```bash +blender --version +``` + +You should see output like: +``` +Blender 4.5.4 LTS +``` + +## How It Works + +The integration uses a subprocess pattern: + +1. **Export**: Mesh is exported to a temporary OBJ file +2. **Launch**: Blender is launched in background mode (`--background`) +3. **Execute**: Python script is executed via `--python-expr` +4. **Process**: Blender performs the operation +5. **Export**: Result is exported to a temporary OBJ file +6. **Import**: Result is loaded back into trimesh +7. **Cleanup**: Temporary files are deleted + +This approach ensures: +- No Blender GUI is shown +- No manual intervention required +- Fast and automated processing +- Full integration with ComfyUI workflows + +## Node Documentation + +### 1. Blender UV Unwrap + +**Category**: `geompack/blender` + +**Description**: Generates UV coordinates for texturing using Blender's Smart UV Project algorithm. + +**Parameters**: +- `mesh` (MESH): Input mesh +- `angle_limit` (FLOAT): Angle threshold for creating seams in degrees (default: 66.0, range: 1.0-89.0) +- `island_margin` (FLOAT): Spacing between UV islands (default: 0.02, range: 0.0-1.0) + +**Returns**: +- `unwrapped_mesh` (MESH): Mesh with UV coordinates + +**Use Cases**: +- Preparing meshes for texture mapping +- Creating UV layouts for baking +- Automatic seam detection + +**Example**: +``` +Load Mesh → Blender UV Unwrap (angle_limit=66.0, island_margin=0.02) → Save Mesh +``` + +**Technical Details**: +- Uses `bpy.ops.uv.smart_project()` +- Automatically creates seams based on angle threshold +- Islands are separated by configurable margin +- UV coordinates are exported in the OBJ file +- Preserves original mesh geometry + +**Test Results**: +``` +Input: 8 vertices, 12 faces (cube) +Output: 24 vertices, 12 faces (with UV coordinates) +``` + +--- + +### 2. Blender Voxel Remesh + +**Category**: `geompack/blender` + +**Description**: Creates uniform, watertight meshes by voxelizing the input and reconstructing the surface. + +**Parameters**: +- `mesh` (MESH): Input mesh +- `voxel_size` (FLOAT): Voxel size for remeshing (default: 0.05, range: 0.001-1.0) + - Smaller values = higher resolution = more faces + - Larger values = lower resolution = fewer faces + +**Returns**: +- `remeshed_mesh` (MESH): Voxel-remeshed mesh + +**Use Cases**: +- Mesh repair and cleanup +- Creating watertight meshes for 3D printing +- Simplifying complex meshes +- Uniform mesh density + +**Example**: +``` +Load Mesh → Blender Voxel Remesh (voxel_size=0.1) → Save Mesh +``` + +**Technical Details**: +- Uses `bpy.ops.object.voxel_remesh()` +- Converts mesh to voxel grid at specified resolution +- Reconstructs surface using marching cubes-like algorithm +- Always produces watertight meshes +- Good for fixing non-manifold geometry + +**Test Results**: +``` +Input: 42 vertices, 80 faces (sphere, subdivisions=1) +Output: 2005 vertices, 1020 faces (voxel_size=0.1) +``` + +**Performance Notes**: +- Processing time depends on voxel size +- Smaller voxel sizes = longer processing time +- Typical range: 0.01 to 0.2 for most meshes + +--- + +### 3. Blender Quadriflow Remesh + +**Category**: `geompack/blender` + +**Description**: Creates quad-dominant meshes with field-aligned topology, better for animation and subdivision. + +**Parameters**: +- `mesh` (MESH): Input mesh +- `target_face_count` (INT): Target number of faces in output (default: 5000, range: 100-100000) + +**Returns**: +- `remeshed_mesh` (MESH): Quad-remeshed mesh (triangulated by trimesh) + +**Use Cases**: +- Creating animation-ready meshes +- Preparing for subdivision surface modeling +- Retopology of high-resolution scans +- Better edge flow for character models + +**Example**: +``` +Load Mesh → Blender Quadriflow Remesh (target_face_count=5000) → Save Mesh +``` + +**Technical Details**: +- Uses `bpy.ops.object.quadriflow_remesh()` +- Field-aligned quad mesh generation +- Optimized for subdivision surfaces +- Output is quad-dominant (may contain some triangles) +- Trimesh automatically triangulates quads for compatibility + +**Test Results**: +``` +Input: 42 vertices, 80 faces (sphere, subdivisions=1) +Output: 2036 vertices, 1022 faces (target_face_count=500) +``` + +**Notes**: +- Actual face count may differ from target +- Quadriflow algorithm optimizes for good topology +- Better edge flow than isotropic remeshing + +--- + +## Comparison: Remeshing Algorithms + +| Algorithm | Output | Use Case | Speed | +|-----------|--------|----------|-------| +| **CGAL Isotropic** (PyMeshLab) | Uniform triangles | Simulation, analysis | Fast | +| **Blender Voxel** | Watertight, uniform | Repair, 3D printing | Medium | +| **Blender Quadriflow** | Quad-dominant | Animation, subdivision | Slow | + +**When to use each:** + +- **CGAL Isotropic**: When you need uniform triangle sizes for FEM simulation or mesh analysis +- **Blender Voxel**: When you need to repair broken meshes or create watertight geometry +- **Blender Quadriflow**: When you need good topology for animation, rigging, or subdivision surfaces + +## Troubleshooting + +### "Blender not found" Error + +**Problem**: Node fails with `RuntimeError: Blender not found` + +**Solution**: +1. Verify Blender is installed: `blender --version` +2. Add Blender to PATH: + - macOS: Already in PATH if installed via Homebrew + - Linux: Should be in `/usr/bin/blender` or `/usr/local/bin/blender` + - Windows: Add Blender installation folder to PATH + +### Empty Output Mesh + +**Problem**: Remeshing produces 0 vertices, 0 faces + +**Solution**: +- Check Blender version compatibility (tested with Blender 4.5.4 LTS) +- Try different parameter values (e.g., larger voxel size, different face count) +- Check input mesh is valid (use Mesh Info node) + +### Slow Performance + +**Problem**: Remeshing takes a long time + +**Solution**: +- **Voxel Remesh**: Increase `voxel_size` (e.g., 0.1 instead of 0.01) +- **Quadriflow**: Reduce `target_face_count` (e.g., 1000 instead of 10000) +- For large meshes, consider decimating first + +### Subprocess Timeout + +**Problem**: Operation times out after 5 minutes + +**Solution**: +- Edit the `timeout=300` parameter in the node code to increase timeout +- Simplify the input mesh before processing +- Use coarser parameters (larger voxel size, lower face count) + +## Advanced Usage + +### Custom Blender Scripts + +You can modify the Blender scripts in `nodes.py` to add more Blender operations: + +```python +script = f""" +import bpy + +# Your custom Blender operations here +# Example: Apply a modifier +bpy.ops.object.modifier_add(type='SUBSURF') +bpy.ops.object.modifier_apply() +""" +``` + +### Batch Processing + +Combine multiple Blender operations in a single workflow: + +``` +Load Mesh + → Blender Voxel Remesh (repair mesh) + → Blender Quadriflow Remesh (retopology) + → Blender UV Unwrap (add UVs) + → Save Mesh +``` + +## Technical Implementation + +### Code Structure + +```python +def _find_blender(): + """Find Blender executable on system""" + # Checks common paths: /Applications/Blender.app, /usr/bin/blender, etc. + +class BlenderUVUnwrapNode: + def uv_unwrap(self, mesh, angle_limit, island_margin): + # 1. Find Blender + blender_path = _find_blender() + + # 2. Export mesh to temp OBJ + input_path = tempfile.mktemp(suffix='.obj') + mesh.export(input_path) + + # 3. Create Blender script + script = f""" +import bpy +bpy.ops.wm.obj_import(filepath='{input_path}') +bpy.ops.uv.smart_project(...) +bpy.ops.wm.obj_export(filepath='{output_path}') +""" + + # 4. Run Blender + subprocess.run([blender_path, '--background', '--python-expr', script]) + + # 5. Load result + unwrapped = trimesh.load(output_path) + + # 6. Cleanup + os.unlink(input_path) + os.unlink(output_path) + + return (unwrapped,) +``` + +### Tested Blender Versions + +- **Blender 4.5.4 LTS** ✓ Fully tested +- Older versions should work but may have different API parameters + +### Compatibility Notes + +- **UV Unwrap**: Compatible with all Blender 3.x and 4.x versions +- **Voxel Remesh**: Available in Blender 2.81+ +- **Quadriflow**: Available in Blender 2.81+ + - Note: Parameter compatibility varies by version + - This implementation uses minimal parameter set for maximum compatibility + +## Examples + +### Example 1: UV Unwrap for Texturing + +**Goal**: Prepare a mesh for texture painting + +**Workflow**: +``` +Load Mesh (character.obj) + → Mesh Info (check stats) + → Blender UV Unwrap (angle_limit=66.0, island_margin=0.02) + → Save Mesh (character_unwrapped.obj) +``` + +**Result**: Mesh with UV coordinates ready for texture painting in Blender, Substance Painter, etc. + +--- + +### Example 2: Mesh Repair for 3D Printing + +**Goal**: Fix broken mesh and make it watertight for 3D printing + +**Workflow**: +``` +Load Mesh (broken_scan.obj) + → Mesh Info (check watertight status) + → Blender Voxel Remesh (voxel_size=0.05) + → Mesh Info (verify watertight=True) + → Save Mesh (repaired_for_printing.stl) +``` + +**Result**: Watertight mesh suitable for 3D printing + +--- + +### Example 3: Retopology for Animation + +**Goal**: Create animation-ready topology from high-resolution scan + +**Workflow**: +``` +Load Mesh (scan_highres.obj) + → Blender Quadriflow Remesh (target_face_count=10000) + → Blender UV Unwrap (angle_limit=66.0) + → Save Mesh (character_lowpoly.obj) +``` + +**Result**: Clean quad-dominant mesh with good edge flow and UV coordinates + +--- + +### Example 4: Multi-Stage Remeshing + +**Goal**: Combine multiple remeshing techniques for best results + +**Workflow**: +``` +Load Mesh (raw_scan.obj) + → Blender Voxel Remesh (voxel_size=0.1, cleanup) + → CGAL Remesh (target_edge_length=0.05, uniform triangles) + → Mesh Info (check quality) + → Save Mesh (processed.obj) +``` + +**Result**: Clean, uniform mesh with good quality + +## Performance Benchmarks + +Tested on MacBook Pro (M1, 16GB RAM): + +| Operation | Input | Output | Time | +|-----------|-------|--------|------| +| UV Unwrap | 8v, 12f | 24v, 12f | ~1.5s | +| Voxel Remesh (0.1) | 42v, 80f | 2005v, 1020f | ~2.0s | +| Quadriflow (500 faces) | 42v, 80f | 2036v, 1022f | ~3.5s | + +**Notes**: +- Times include Blender startup, processing, and shutdown +- Larger meshes take proportionally longer +- Voxel size and face count significantly affect performance + +## Future Improvements + +- [ ] Add Blender Decimate modifier +- [ ] Add Blender Smooth modifier +- [ ] Add Blender Shrinkwrap for retopology +- [ ] Support for custom Blender scripts via file input +- [ ] Progress reporting for long operations +- [ ] Parallel Blender processing for batch operations + +## Credits + +- Blender integration pattern inspired by [ComfyUI-MeshCraft](https://github.com/PozzettiAndrea/ComfyUI-MeshCraft) +- Blender Python API documentation: https://docs.blender.org/api/current/ diff --git a/LICENSE b/LICENSE index 6095c189..abb2aefc 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2025 PozzettiAndrea +Copyright (c) 2025 ComfyUI-GeometryPack Contributors Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/MVP_PLAN.md b/MVP_PLAN.md new file mode 100644 index 00000000..222c64e3 --- /dev/null +++ b/MVP_PLAN.md @@ -0,0 +1,434 @@ +# ComfyUI libigl Wrapper - MVP Implementation Plan + +## Project Overview + +**Goal**: Create a comprehensive ComfyUI custom node package that wraps libigl geometry processing library for mesh manipulation workflows. + +**Target Users**: +- General-purpose mesh processing +- High-poly mesh handling (>100K faces) +- Visualization essential in ComfyUI +- Standalone tool (no dependency on other 3D extensions) + +**Core Technology Stack**: +- **libigl**: Python bindings for C++ geometry processing library +- **NumPy**: Array operations and mesh data storage +- **ComfyUI**: Node-based workflow system + +--- + +## Project Structure + +``` +ComfyUI-libigl/ +├── __init__.py # Node registration and mappings +├── nodes.py # Node class implementations +├── mesh_utils.py # Mesh data structures and utilities +├── requirements.txt # Python dependencies +├── pyproject.toml # ComfyUI registry metadata +├── README.md # Documentation +├── LICENSE # MIT License +├── .gitignore # Git ignore rules +├── examples/ # Test meshes +│ ├── cube.obj +│ └── bunny.obj +└── workflows/ # Example ComfyUI workflows + └── basic_workflow.json +``` + +--- + +## Technical Architecture + +### Mesh Data Type Definition + +**Custom Type**: `"MESH"` + +**Data Structure** (Python dict): +```python +mesh_data = { + "vertices": np.ndarray, # Shape: (N, 3) - XYZ coordinates + "faces": np.ndarray, # Shape: (F, 3) - Triangle vertex indices + "normals": np.ndarray, # Optional: (N, 3) - Per-vertex normals + "vertex_colors": np.ndarray, # Optional: (N, 3) or (N, 4) - RGB/RGBA colors + "metadata": { + "file_path": str, + "is_manifold": bool, + "has_boundary": bool, + # ... other computed properties + } +} +``` + +### Key Design Decisions + +1. **NumPy-based**: Use NumPy arrays (not PyTorch tensors) for simplicity and libigl compatibility +2. **File Formats**: Support OBJ, PLY, STL (most common) +3. **Error Handling**: Validate mesh integrity, provide clear error messages +4. **Performance**: For high-poly meshes, implement progress indicators where possible +5. **Visualization**: Export mesh previews as images or point clouds for ComfyUI display + +--- + +## MVP Scope: Phase 1 (Essential Nodes) + +### Total: 14 Core Nodes + +#### 1. I/O Nodes (3 nodes) +**Priority: CRITICAL** + +1. **Load_Mesh** + - Load mesh from file (OBJ/PLY/STL) + - Inputs: file_path (STRING) + - Outputs: MESH + - Features: Auto-detect format, validate mesh + +2. **Save_Mesh** + - Save mesh to file + - Inputs: MESH, file_path (STRING), format (COMBO) + - Outputs: STRING (success message) + - Features: Support OBJ, PLY, STL formats + +3. **Mesh_Info** + - Display mesh statistics + - Inputs: MESH + - Outputs: STRING (formatted stats) + - Features: Vertex/face count, bounds, manifold status, boundary info + +#### 2. Visualization Nodes (2 nodes) +**Priority: CRITICAL** (user requirement) + +4. **Preview_Mesh** + - Visualize mesh in ComfyUI + - Inputs: MESH, camera_angle (COMBO), resolution (INT) + - Outputs: IMAGE + - Features: Render mesh to image using matplotlib or trimesh + +5. **Mesh_To_PointCloud** + - Convert mesh to point cloud for preview + - Inputs: MESH, sample_count (INT) + - Outputs: IMAGE + - Features: Sample points on surface, render as scatter plot + +#### 3. Primitive Generation (1 node) +**Priority: HIGH** (useful for testing) + +6. **Create_Primitive** + - Generate basic shapes + - Inputs: shape (COMBO: cube/sphere/cylinder), size (FLOAT) + - Outputs: MESH + - Features: Generate test geometry + +#### 4. Essential Cleanup (3 nodes) +**Priority: HIGH** (common operations) + +7. **Remove_Duplicates** + - Merge duplicate vertices/faces + - Inputs: MESH, tolerance (FLOAT) + - Outputs: MESH + - Features: Configurable epsilon threshold + +8. **Remove_Degenerate** + - Delete zero-area faces + - Inputs: MESH + - Outputs: MESH + - Features: Clean up bad geometry + +9. **Compute_Normals** + - Calculate vertex/face normals + - Inputs: MESH, mode (COMBO: vertex/face) + - Outputs: MESH (with normals) + - Features: Essential for rendering and visualization + +#### 5. Core Processing (3 nodes) +**Priority: HIGH** (key operations for high-poly) + +10. **Decimate** + - Reduce mesh complexity + - Inputs: MESH, target_faces (INT or %), method (COMBO) + - Outputs: MESH + - Features: Critical for high-poly optimization + +11. **Subdivide** + - Increase mesh resolution + - Inputs: MESH, iterations (INT), method (COMBO: loop/linear) + - Outputs: MESH + - Features: Loop subdivision, linear subdivision + +12. **Smooth_Laplacian** + - Smooth mesh surface + - Inputs: MESH, iterations (INT), lambda (FLOAT) + - Outputs: MESH + - Features: Laplacian smoothing with iteration control + +#### 6. Basic Utilities (2 nodes) +**Priority: MEDIUM** + +13. **Transform_Mesh** + - Apply transformations + - Inputs: MESH, translate (VECTOR3), rotate (VECTOR3), scale (FLOAT/VECTOR3) + - Outputs: MESH + - Features: Basic transformations + +14. **Bounding_Box** + - Compute AABB + - Inputs: MESH + - Outputs: MESH (box mesh), STRING (bounds info) + - Features: Get min/max bounds + +--- + +## Phase 2: Extended Functionality (13 nodes) + +**Priority: MEDIUM** (Implement after MVP is stable) + +### Cleanup & Repair (3 nodes) +- **Fix_Normals**: Consistent face winding +- **Fill_Holes**: Close boundary loops +- **Split_Components**: Separate disconnected pieces + +### Mesh Operations (4 nodes) +- **Merge_Meshes**: Combine multiple meshes +- **Slice_Plane**: Cut mesh with plane +- **Crop_Mesh**: Bounding box crop +- **Boolean_Operations**: Union/difference/intersection (if supported) + +### Analysis (6 nodes) +- **Boundary_Loops**: Extract boundaries +- **Mesh_Volume**: Volume computation +- **Face_Areas**: Per-face area array +- **Field_To_Colors**: Scalar → vertex colors (for heatmap viz) +- **Store_Vertex_Field**: Custom per-vertex data +- **Get_Vertex_Field**: Extract field data + +--- + +## Phase 3: Advanced Features (8 nodes) + +**Priority: LOW** (Nice-to-have, implement if time permits) + +### Deformation (2 nodes) +- **ARAP_Deform**: As-rigid-as-possible deformation +- **Harmonic_Deform**: Smooth deformation with constraints + +### Distance & Queries (3 nodes) +- **Signed_Distance**: SDF at query points +- **Heat_Geodesic**: Fast geodesic distances +- **Closest_Point**: Nearest point on surface + +### Curvature (1 node) +- **Principal_Curvature**: k1, k2 curvature + directions + +### Sampling (2 nodes) +- **Sample_Points**: Uniform surface sampling +- **Poisson_Disk_Sample**: Blue noise sampling + +--- + +## Never Implement (23 nodes) + +**Reason: Redundant, too specialized, or better handled elsewhere** + +### UV/Parameterization (3 nodes) +- **Harmonic_UV**: Basic unwrapping (Blender better) +- **LSCM_UV**: Conformal mapping (niche) +- **Transfer_UV**: UV copying (rare use case) + +### Too Slow/Specialized (5 nodes) +- **Exact_Geodesic**: Very slow for high-poly +- **Intrinsic_Delaunay**: Complex remeshing +- **Ambient_Occlusion**: Better in rendering engines +- **Geodesic_Path**: Limited workflow use +- **Ray_Mesh_Intersect**: Low-level, better tools exist + +### Redundant/Over-engineered (15 nodes) +- **Marching_Cubes**: Other tools handle SDF→mesh +- **Store_Face_Field**, **Get_Face_Field**: Over-engineered +- **Delete_Field**, **List_Fields**: Unnecessary complexity +- **Visualize_Field**: Covered by Field_To_Colors +- **Boolean_Preview**: Redundant with main preview +- **Compare_Meshes**: Niche analysis +- **Mesh_Statistics**: Covered by Mesh_Info +- **Vertex_Valence**: Too low-level +- **Face_Adjacency**: Too low-level +- **Edge_Topology**: Too low-level +- **Winding_Number**: Rarely needed in workflows +- **Point_Cloud_Distance**: Niche comparison +- **Mean_Curvature**, **Gaussian_Curvature**: Covered by Principal_Curvature +- **Transfer_Field**: Complex interpolation (niche) + +--- + +## Implementation Roadmap + +### Step 1: Project Setup (Day 1) +- [ ] Create directory structure +- [ ] Write `requirements.txt` +- [ ] Write `pyproject.toml` +- [ ] Write `.gitignore` +- [ ] Write `README.md` (basic) +- [ ] Add `LICENSE` (MIT) + +### Step 2: Core Infrastructure (Day 1-2) +- [ ] Create `mesh_utils.py`: + - Mesh data structure definition + - Validation functions + - File I/O wrappers (libigl) + - Conversion utilities +- [ ] Test basic mesh loading/saving + +### Step 3: Registration Boilerplate (Day 2) +- [ ] Create `__init__.py`: + - NODE_CLASS_MAPPINGS + - NODE_DISPLAY_NAME_MAPPINGS +- [ ] Create `nodes.py` skeleton + +### Step 4: I/O Nodes (Day 2-3) +- [ ] Implement Load_Mesh +- [ ] Implement Save_Mesh +- [ ] Implement Mesh_Info +- [ ] Test with sample meshes + +### Step 5: Visualization (Day 3-4) +- [ ] Implement Preview_Mesh (matplotlib/trimesh) +- [ ] Implement Mesh_To_PointCloud +- [ ] Test rendering pipeline + +### Step 6: Core Processing (Day 4-6) +- [ ] Implement Create_Primitive +- [ ] Implement Decimate +- [ ] Implement Subdivide +- [ ] Implement Smooth_Laplacian +- [ ] Implement Compute_Normals +- [ ] Test with high-poly meshes + +### Step 7: Cleanup & Utilities (Day 6-7) +- [ ] Implement Remove_Duplicates +- [ ] Implement Remove_Degenerate +- [ ] Implement Transform_Mesh +- [ ] Implement Bounding_Box + +### Step 8: Testing & Polish (Day 7-8) +- [ ] Test all nodes with various meshes +- [ ] Error handling improvements +- [ ] Performance optimization +- [ ] Create example workflows +- [ ] Update README with examples + +### Step 9: Documentation (Day 8) +- [ ] Write node documentation +- [ ] Create tutorial workflow +- [ ] Add screenshots to README + +### Step 10: Release MVP (Day 8) +- [ ] Final testing +- [ ] Tag v0.1.0 +- [ ] Publish to GitHub + +--- + +## Dependencies + +### `requirements.txt` +``` +libigl>=2.6.1 +numpy>=1.21.0 +scipy>=1.7.0 +matplotlib>=3.5.0 +Pillow>=9.0.0 +``` + +### Optional (for enhanced visualization) +``` +trimesh>=3.15.0 +``` + +--- + +## Testing Strategy + +### Test Meshes +1. **Simple**: cube.obj (8 vertices, 12 faces) +2. **Medium**: bunny.obj (~5K faces) +3. **High-poly**: dragon.obj (~100K faces) +4. **Edge cases**: + - Non-manifold mesh + - Mesh with boundaries + - Degenerate triangles + +### Test Workflows +1. **Basic Pipeline**: Load → Info → Preview +2. **Cleanup Pipeline**: Load → Remove Duplicates → Remove Degenerate → Preview +3. **Simplification**: Load → Decimate → Save +4. **Smoothing**: Load → Smooth → Preview +5. **Transformation**: Create Primitive → Transform → Preview + +--- + +## Success Criteria for MVP + +1. ✅ All 14 Phase 1 nodes implemented and working +2. ✅ Can load common mesh formats (OBJ, PLY, STL) +3. ✅ Can visualize meshes in ComfyUI +4. ✅ Can process high-poly meshes (>100K faces) without crashes +5. ✅ Decimation reduces mesh complexity effectively +6. ✅ All nodes have proper error handling +7. ✅ Example workflows demonstrate key features +8. ✅ README documentation is complete + +--- + +## Future Enhancements (Post-MVP) + +### Phase 2 Priority +1. Boolean operations (if libigl supports) +2. Field-based visualization (heatmaps) +3. Advanced cleanup (hole filling, component separation) + +### Phase 3 Priority +1. Deformation tools (ARAP) +2. Geodesic computations +3. Advanced sampling + +### Long-term +1. GPU acceleration for large meshes +2. Real-time preview updates +3. Custom shader support +4. Integration with ComfyUI-3D-Pack (if requested) + +--- + +## Risk Mitigation + +### Potential Issues + +1. **High-poly performance**: + - Solution: Progress indicators, chunked processing + +2. **Visualization memory**: + - Solution: Downsampling for preview, configurable resolution + +3. **libigl limitations**: + - Solution: Research libigl Python API capabilities early + +4. **ComfyUI integration issues**: + - Solution: Study existing 3D node packages, follow patterns + +--- + +## Resources + +- **libigl Python Tutorial**: https://libigl.github.io/libigl-python-bindings/ +- **ComfyUI Custom Node Guide**: https://docs.comfy.org/essentials/custom_node_walkthrough +- **Template Repository**: https://github.com/jhj0517/ComfyUI-CustomNodes-Template +- **Example 3D Pack**: https://github.com/MrForExample/ComfyUI-3D-Pack + +--- + +## Contact & Contribution + +This is an MVP implementation plan. Feedback and contributions welcome! + +**Estimated MVP Completion**: 8-10 days +**Total Nodes in MVP**: 14 nodes +**Total Nodes (all phases)**: ~35 nodes diff --git a/README.md b/README.md new file mode 100644 index 00000000..2153eb0b --- /dev/null +++ b/README.md @@ -0,0 +1,441 @@ +# ComfyUI-GeomPack + +Geometry processing custom nodes for ComfyUI, powered by [trimesh](https://trimesh.org/), [libigl](https://libigl.github.io/), and [CGAL](https://www.cgal.org/). + +## 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. + +## 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 + +## 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 +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/ + +4. Restart ComfyUI + +### Method 2: ComfyUI Manager (Coming Soon) + +Install via ComfyUI Manager once published to the registry. + +## 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 +``` +Create Primitive (cube) → Mesh Info → CGAL Remesh → Mesh Info → Save Mesh +``` + +**Result**: Converts a simple 8-vertex cube into a uniformly remeshed 386-vertex mesh with consistent triangle sizes! + +## Available Nodes + +### geompack/io +- **Load Mesh**: Load meshes from file (OBJ, PLY, STL, OFF, etc.) +- **Save Mesh**: Save meshes to file with trimesh export + +### geompack/analysis +- **Mesh Info**: Display detailed statistics including: + - Vertex/face/edge counts + - Volume and surface area + - Watertight status + - Bounding box and extents + - Metadata + +### geompack/primitives +- **Create Primitive**: Generate geometric primitives + - Cube (8 vertices, 12 faces) + - Sphere (icosphere with subdivision levels) + - Plane (subdivided grid) + +### geompack/cgal +- **CGAL Remesh (Isotropic)**: Advanced remeshing algorithm + - Creates uniform triangle sizes + - Target edge length control + - Iteration count parameter + - Preserves volume + - Based on Botsch & Kobbelt (2004) algorithm + +### 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/blender +- **Blender UV Unwrap**: Smart UV unwrapping using Blender + - Angle-based seam detection + - Configurable island margin + - Exports mesh with UV coordinates +- **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 +- ✅ Integrated CGAL for advanced remeshing (currently using PyMeshLab) +- ✅ Load Mesh (with fallback to libigl) +- ✅ Save Mesh (all trimesh-supported formats) +- ✅ Create Primitive (cube, sphere, plane) +- ✅ Mesh Info (enhanced with volume, watertight detection) +- ✅ CGAL Isotropic Remeshing (via PyMeshLab, will return to CGAL eventually) +- ✅ 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! + +### 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+ +- **pymeshlab** 2022.2+ (currently used for remeshing) +- **Blender** (optional, for UV unwrapping and Blender remeshing nodes) +- libigl 2.6.1+ (fallback for some I/O operations) +- numpy 1.21.0+ +- scipy 1.7.0+ +- matplotlib 3.5.0+ +- Pillow 9.0.0+ + +## 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,) +``` + +## 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 new file mode 100644 index 00000000..37a6253a --- /dev/null +++ b/__init__.py @@ -0,0 +1,16 @@ +""" +ComfyUI GeomPack - Geometry Processing Custom Nodes + +This package provides mesh processing nodes for ComfyUI using trimesh, CGAL, and Blender. +Includes custom 3D preview widget powered by Three.js. +""" + +from .nodes import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS + +# Set web directory for JavaScript extensions (3D mesh preview widget) +# This tells ComfyUI where to find our JavaScript files and HTML viewer +# Files will be served at /extensions/ComfyUI-GeomPack/* +WEB_DIRECTORY = "./web" + +# Export the mappings so ComfyUI can discover the nodes +__all__ = ['NODE_CLASS_MAPPINGS', 'NODE_DISPLAY_NAME_MAPPINGS', 'WEB_DIRECTORY'] diff --git a/mesh_utils.py b/mesh_utils.py new file mode 100644 index 00000000..6dd22b4f --- /dev/null +++ b/mesh_utils.py @@ -0,0 +1,397 @@ +""" +Mesh utilities for ComfyUI GeomPack nodes +Handles mesh data structures, I/O, and processing using trimesh and seagullmesh (CGAL) +""" + +import numpy as np +import trimesh +import igl +import os +from typing import Tuple, Optional + +# PyMeshLab for remeshing (TODO: will switch back to CGAL eventually) +try: + import pymeshlab + PYMESHLAB_AVAILABLE = True +except ImportError: + PYMESHLAB_AVAILABLE = False + print("[mesh_utils] Warning: pymeshlab not available. Install with: pip install pymeshlab") + + +def load_mesh_file(file_path: str) -> Tuple[Optional[trimesh.Trimesh], str]: + """ + Load a mesh from file. + + Ensures the returned mesh has only triangular faces and is properly processed. + + Args: + file_path: Path to mesh file (OBJ, PLY, STL, OFF, etc.) + + Returns: + Tuple of (mesh, error_message) + """ + if not os.path.exists(file_path): + return None, f"File not found: {file_path}" + + try: + print(f"[load_mesh_file] Loading: {file_path}") + + # Try to load with trimesh first (supports many formats) + loaded = trimesh.load(file_path, force='mesh') + + print(f"[load_mesh_file] Loaded type: {type(loaded).__name__}") + + # Handle case where trimesh.load returns a Scene instead of a mesh + if isinstance(loaded, trimesh.Scene): + print(f"[load_mesh_file] Converting Scene to single mesh (scene has {len(loaded.geometry)} geometries)") + # If it's a scene, dump it to a single mesh + mesh = loaded.dump(concatenate=True) + else: + mesh = loaded + + if mesh is None or len(mesh.vertices) == 0 or len(mesh.faces) == 0: + return None, f"Failed to read mesh or mesh is empty: {file_path}" + + print(f"[load_mesh_file] Initial mesh: {len(mesh.vertices)} vertices, {len(mesh.faces)} faces") + + # Ensure mesh is properly triangulated + # Trimesh should handle this, but some file formats might have issues + if hasattr(mesh, 'faces') and len(mesh.faces) > 0: + # Check if faces are triangular + if mesh.faces.shape[1] != 3: + # Need to triangulate - this shouldn't normally happen but handle it + print(f"[load_mesh_file] Warning: Mesh has non-triangular faces, triangulating...") + # trimesh.Trimesh constructor should triangulate automatically with process=True + mesh = trimesh.Trimesh(vertices=mesh.vertices, faces=mesh.faces, process=True) + print(f"[load_mesh_file] After triangulation: {len(mesh.vertices)} vertices, {len(mesh.faces)} faces") + + # Count before cleanup + verts_before = len(mesh.vertices) + faces_before = len(mesh.faces) + + # Merge duplicate vertices and clean up + mesh.merge_vertices() + mesh.remove_duplicate_faces() + mesh.remove_degenerate_faces() + + verts_after = len(mesh.vertices) + faces_after = len(mesh.faces) + + if verts_before != verts_after or faces_before != faces_after: + print(f"[load_mesh_file] Cleanup: {verts_before}->{verts_after} vertices, {faces_before}->{faces_after} faces") + print(f"[load_mesh_file] Removed: {verts_before - verts_after} duplicate vertices, {faces_before - faces_after} bad faces") + + # Store file metadata + mesh.metadata['file_path'] = file_path + mesh.metadata['file_name'] = os.path.basename(file_path) + mesh.metadata['file_format'] = os.path.splitext(file_path)[1].lower() + + print(f"[load_mesh_file] ✓ Successfully loaded: {len(mesh.vertices)} vertices, {len(mesh.faces)} faces") + return mesh, "" + + except Exception as e: + print(f"[load_mesh_file] Trimesh failed: {str(e)}, trying libigl fallback...") + # Fallback to libigl + try: + v, f = igl.read_triangle_mesh(file_path) + if v is None or f is None or len(v) == 0 or len(f) == 0: + return None, f"Failed to read mesh: {file_path}" + + print(f"[load_mesh_file] libigl loaded: {len(v)} vertices, {len(f)} faces") + + mesh = trimesh.Trimesh(vertices=v, faces=f, process=True) + + # Count before cleanup + verts_before = len(mesh.vertices) + faces_before = len(mesh.faces) + + # Clean up the mesh + mesh.merge_vertices() + mesh.remove_duplicate_faces() + mesh.remove_degenerate_faces() + + verts_after = len(mesh.vertices) + faces_after = len(mesh.faces) + + if verts_before != verts_after or faces_before != faces_after: + print(f"[load_mesh_file] Cleanup: {verts_before}->{verts_after} vertices, {faces_before}->{faces_after} faces") + + # Store metadata + mesh.metadata['file_path'] = file_path + mesh.metadata['file_name'] = os.path.basename(file_path) + mesh.metadata['file_format'] = os.path.splitext(file_path)[1].lower() + + print(f"[load_mesh_file] ✓ Successfully loaded via libigl: {len(mesh.vertices)} vertices, {len(mesh.faces)} faces") + return mesh, "" + except Exception as e2: + print(f"[load_mesh_file] ✗ Both loaders failed!") + return None, f"Error loading mesh: {str(e)}; Fallback error: {str(e2)}" + + +def save_mesh_file(mesh: trimesh.Trimesh, file_path: str) -> Tuple[bool, str]: + """ + Save a mesh to file. + + Args: + mesh: Trimesh object + file_path: Output file path + + Returns: + Tuple of (success, error_message) + """ + if not isinstance(mesh, trimesh.Trimesh): + return False, "Input must be a trimesh.Trimesh object" + + if len(mesh.vertices) == 0 or len(mesh.faces) == 0: + return False, "Mesh is empty" + + try: + # Ensure output directory exists + output_dir = os.path.dirname(file_path) + if output_dir and not os.path.exists(output_dir): + os.makedirs(output_dir, exist_ok=True) + + # Export the mesh + mesh.export(file_path) + + return True, "" + + except Exception as e: + return False, f"Error saving mesh: {str(e)}" + + +def create_cube(size: float = 1.0) -> trimesh.Trimesh: + """ + Create a cube mesh. + + Args: + size: Side length of the cube + + Returns: + Trimesh object + """ + mesh = trimesh.creation.box(extents=[size, size, size]) + mesh.metadata['primitive_type'] = 'cube' + mesh.metadata['size'] = size + return mesh + + +def create_sphere(radius: float = 1.0, subdivisions: int = 2) -> trimesh.Trimesh: + """ + Create a sphere mesh using icosphere subdivision. + + Args: + radius: Radius of the sphere + subdivisions: Number of subdivision levels (0-4 recommended) + + Returns: + Trimesh object + """ + mesh = trimesh.creation.icosphere(subdivisions=subdivisions, radius=radius) + mesh.metadata['primitive_type'] = 'sphere' + mesh.metadata['radius'] = radius + mesh.metadata['subdivisions'] = subdivisions + return mesh + + +def create_plane(size: float = 1.0, subdivisions: int = 1) -> trimesh.Trimesh: + """ + Create a plane mesh. + + Args: + size: Side length of the plane + subdivisions: Number of subdivisions per side + + Returns: + Trimesh object + """ + n = subdivisions + 1 + s = size / 2.0 + + # Create grid of vertices + x = np.linspace(-s, s, n) + y = np.linspace(-s, s, n) + xx, yy = np.meshgrid(x, y) + + vertices = np.stack([ + xx.flatten(), + yy.flatten(), + np.zeros(n * n) + ], axis=1).astype(np.float64) + + # Create faces + faces = [] + for i in range(n - 1): + for j in range(n - 1): + idx = i * n + j + # Two triangles per quad + faces.append([idx, idx + n, idx + n + 1]) + faces.append([idx, idx + n + 1, idx + 1]) + + faces = np.array(faces, dtype=np.int32) + + mesh = trimesh.Trimesh(vertices=vertices, faces=faces) + mesh.metadata['primitive_type'] = 'plane' + mesh.metadata['size'] = size + mesh.metadata['subdivisions'] = subdivisions + return mesh + + +def compute_mesh_info(mesh: trimesh.Trimesh) -> str: + """ + Generate a formatted string with mesh information. + + Args: + mesh: Trimesh object + + Returns: + Formatted info string + """ + if not isinstance(mesh, trimesh.Trimesh): + return "Error: Input must be a trimesh.Trimesh object" + + info = "=== Mesh Information ===\n\n" + info += f"Vertices: {len(mesh.vertices):,}\n" + info += f"Faces: {len(mesh.faces):,}\n" + info += f"Edges: {len(mesh.edges):,}\n\n" + + # Geometric properties + info += f"Volume: {mesh.volume:.6f}\n" + info += f"Surface Area: {mesh.area:.6f}\n" + info += f"Is Watertight: {mesh.is_watertight}\n" + info += f"Is Winding Consistent: {mesh.is_winding_consistent}\n\n" + + # Bounding box + bounds = mesh.bounds + center = mesh.centroid + extents = mesh.extents + + info += "Bounding Box:\n" + info += f" Min: [{bounds[0][0]:.3f}, {bounds[0][1]:.3f}, {bounds[0][2]:.3f}]\n" + info += f" Max: [{bounds[1][0]:.3f}, {bounds[1][1]:.3f}, {bounds[1][2]:.3f}]\n" + info += f" Center: [{center[0]:.3f}, {center[1]:.3f}, {center[2]:.3f}]\n" + info += f" Extents: [{extents[0]:.3f}, {extents[1]:.3f}, {extents[2]:.3f}]\n\n" + + # Additional data + if hasattr(mesh.visual, 'vertex_colors') and mesh.visual.vertex_colors is not None: + info += f"Vertex Colors: Yes\n" + + if mesh.vertex_normals is not None and len(mesh.vertex_normals) > 0: + info += f"Vertex Normals: Yes\n" + + # Metadata + if mesh.metadata: + info += "\nMetadata:\n" + for key, value in mesh.metadata.items(): + info += f" {key}: {value}\n" + + return info + + +# ============================================================================ +# Remeshing via PyMeshLab (TODO: will switch back to CGAL eventually) +# ============================================================================ + +def cgal_isotropic_remesh( + mesh: trimesh.Trimesh, + target_edge_length: float, + iterations: int = 3 +) -> Tuple[Optional[trimesh.Trimesh], str]: + """ + Apply isotropic remeshing to create uniform triangle sizes. + + Currently uses PyMeshLab. Will switch back to CGAL once CGAL Python bindings + issues are resolved. + + Args: + mesh: Input trimesh object + target_edge_length: Target edge length for remeshed triangles + iterations: Number of remeshing iterations (default: 3) + + Returns: + Tuple of (remeshed_mesh, error_message) + """ + print(f"[cgal_isotropic_remesh] ===== Starting Isotropic Remeshing (via PyMeshLab) =====") + print(f"[cgal_isotropic_remesh] Input mesh: {len(mesh.vertices)} vertices, {len(mesh.faces)} faces") + print(f"[cgal_isotropic_remesh] Parameters:") + print(f"[cgal_isotropic_remesh] target_edge_length: {target_edge_length}") + print(f"[cgal_isotropic_remesh] iterations: {iterations}") + + if not PYMESHLAB_AVAILABLE: + return None, "pymeshlab is not installed. Install with: pip install pymeshlab" + + 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: + return None, f"Iterations must be at least 1, got {iterations}" + + try: + # Convert trimesh to PyMeshLab + print(f"[cgal_isotropic_remesh] Converting to PyMeshLab format...") + ms = pymeshlab.MeshSet() + + # Create PyMeshLab mesh from numpy arrays + pml_mesh = pymeshlab.Mesh( + vertex_matrix=mesh.vertices, + face_matrix=mesh.faces + ) + ms.add_mesh(pml_mesh) + + print(f"[cgal_isotropic_remesh] Applying isotropic remeshing...") + # PyMeshLab's isotropic remeshing + # targetlen is specified as PercentageValue (percentage of bounding box diagonal) + # We need to convert our absolute target_edge_length to a percentage + bbox_diag = np.linalg.norm(mesh.bounds[1] - mesh.bounds[0]) + target_pct = (target_edge_length / bbox_diag) * 100.0 + + ms.meshing_isotropic_explicit_remeshing( + targetlen=pymeshlab.PercentageValue(target_pct), + iterations=iterations, + adaptive=False + ) + + # Convert back to trimesh + print(f"[cgal_isotropic_remesh] Converting back to trimesh...") + remeshed_pml = ms.current_mesh() + remeshed_mesh = trimesh.Trimesh( + vertices=remeshed_pml.vertex_matrix(), + faces=remeshed_pml.face_matrix() + ) + + # Preserve metadata + remeshed_mesh.metadata = mesh.metadata.copy() + remeshed_mesh.metadata['remeshing'] = { + 'algorithm': 'pymeshlab_isotropic', + 'target_edge_length': target_edge_length, + 'target_percentage': target_pct, + 'iterations': iterations, + '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() + return None, f"Error during remeshing: {str(e)}" diff --git a/nodes.py b/nodes.py new file mode 100644 index 00000000..8dc7231b --- /dev/null +++ b/nodes.py @@ -0,0 +1,1081 @@ +""" +ComfyUI GeomPack - Custom Nodes for Geometry Processing +Now using trimesh and CGAL for enhanced mesh processing +""" + +import numpy as np +import trimesh +import os +import subprocess +import tempfile +import shutil +from pathlib import Path + +# ComfyUI folder paths +try: + import folder_paths + COMFYUI_INPUT_FOLDER = folder_paths.get_input_directory() + COMFYUI_OUTPUT_FOLDER = folder_paths.get_output_directory() +except: + # Fallback if folder_paths not available (e.g., during testing) + COMFYUI_INPUT_FOLDER = None + COMFYUI_OUTPUT_FOLDER = None + +# Handle both relative and absolute imports +try: + from . import mesh_utils +except ImportError: + import mesh_utils + + +def _find_blender(): + """ + Find Blender executable on the system. + + Returns: + str: Path to Blender executable + + Raises: + RuntimeError: If Blender not found + """ + # Try common locations + common_paths = [ + 'blender', # In PATH + '/Applications/Blender.app/Contents/MacOS/Blender', # macOS + 'C:\\Program Files\\Blender Foundation\\Blender\\blender.exe', # Windows + '/usr/bin/blender', # Linux + '/usr/local/bin/blender', # Linux + ] + + for path in common_paths: + if shutil.which(path) or os.path.exists(path): + print(f"[Blender] Found Blender at: {path}") + return path + + raise RuntimeError( + "Blender not found. Please install Blender and ensure it's in your PATH.\n" + "Download from: https://www.blender.org/download/" + ) + + +class ExampleLibiglNode: + """ + Example node demonstrating the ComfyUI node structure. + This is a cookie-cutter template - replace with actual functionality. + """ + + def __init__(self): + pass + + @classmethod + def INPUT_TYPES(cls): + """ + Define the input parameters for this node. + + Returns: + dict: Input type definitions with 'required' and 'optional' keys + """ + return { + "required": { + "text_input": ("STRING", { + "default": "Hello from GeomPack!", + "multiline": False + }), + "number_input": ("INT", { + "default": 10, + "min": 0, + "max": 100, + "step": 1 + }), + "float_input": ("FLOAT", { + "default": 1.0, + "min": 0.0, + "max": 10.0, + "step": 0.1 + }), + "mode": (["option1", "option2", "option3"], { + "default": "option1" + }), + }, + "optional": { + "optional_input": ("STRING", { + "default": "" + }), + } + } + + RETURN_TYPES = ("STRING", "INT", "FLOAT") + RETURN_NAMES = ("output_text", "output_number", "output_float") + FUNCTION = "execute" + CATEGORY = "geompack/examples" + OUTPUT_NODE = False + + def execute(self, text_input, number_input, float_input, mode, optional_input=""): + """ + Execute the node logic. + + Args: + text_input: String input + number_input: Integer input + float_input: Float input + mode: Selected mode from combo box + optional_input: Optional string input + + Returns: + tuple: (output_text, output_number, output_float) + """ + # Example processing + result_text = f"{text_input} | Mode: {mode}" + if optional_input: + result_text += f" | Optional: {optional_input}" + + result_number = number_input * 2 + result_float = float_input * 1.5 + + print(f"[ExampleNode] Processing: {result_text}") + + return (result_text, result_number, result_float) + + +class MeshInfoNode: + """ + Display detailed mesh information and statistics. + Now using trimesh for enhanced mesh analysis. + """ + + def __init__(self): + pass + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "mesh": ("MESH",), + }, + } + + RETURN_TYPES = ("STRING",) + RETURN_NAMES = ("info",) + FUNCTION = "get_mesh_info" + CATEGORY = "geompack/analysis" + + def get_mesh_info(self, mesh): + """ + Get information about the mesh. + + Args: + mesh: trimesh.Trimesh object + + Returns: + tuple: (info_string,) + """ + info = mesh_utils.compute_mesh_info(mesh) + return (info,) + + +class LoadMesh: + """ + Load a mesh from file (OBJ, PLY, STL, OFF, etc.) + Now returns trimesh.Trimesh objects for better mesh handling. + """ + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "file_path": ("STRING", { + "default": "", + "multiline": False + }), + }, + } + + RETURN_TYPES = ("MESH",) + RETURN_NAMES = ("mesh",) + FUNCTION = "load_mesh" + CATEGORY = "geompack/io" + + def load_mesh(self, file_path): + """ + Load mesh from file. + + Looks for files in ComfyUI's input folder first, then tries absolute path. + + Args: + file_path: Path to mesh file (relative to input folder or absolute) + + Returns: + tuple: (trimesh.Trimesh,) + """ + if not file_path or file_path.strip() == "": + raise ValueError("File path cannot be empty") + + # Try to find the file + full_path = None + + # First, try in ComfyUI input folder + if COMFYUI_INPUT_FOLDER is not None: + input_path = os.path.join(COMFYUI_INPUT_FOLDER, file_path) + if os.path.exists(input_path): + full_path = input_path + print(f"[LoadMesh] Found mesh in input folder: {file_path}") + + # If not found in input folder, try as absolute path + if full_path is None: + if os.path.exists(file_path): + full_path = file_path + print(f"[LoadMesh] Loading from absolute path: {file_path}") + else: + # Try one more time with input folder prefix for better error message + if COMFYUI_INPUT_FOLDER is not None: + raise ValueError( + f"File not found: '{file_path}'\n" + f"Searched in:\n" + f" - ComfyUI input folder: {os.path.join(COMFYUI_INPUT_FOLDER, file_path)}\n" + f" - Absolute path: {file_path}" + ) + else: + raise ValueError(f"File not found: {file_path}") + + # Load the mesh + mesh, error = mesh_utils.load_mesh_file(full_path) + + if mesh is None: + raise ValueError(f"Failed to load mesh: {error}") + + print(f"[LoadMesh] Loaded: {len(mesh.vertices)} vertices, {len(mesh.faces)} faces") + + return (mesh,) + + +class SaveMesh: + """ + Save a mesh to file (OBJ, PLY, STL, OFF, etc.) + Supports all formats provided by trimesh. + """ + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "mesh": ("MESH",), + "file_path": ("STRING", { + "default": "output.obj", + "multiline": False + }), + }, + } + + RETURN_TYPES = ("STRING",) + RETURN_NAMES = ("status",) + FUNCTION = "save_mesh" + CATEGORY = "geompack/io" + OUTPUT_NODE = True + + def save_mesh(self, mesh, file_path): + """ + Save mesh to file. + + Saves to ComfyUI's output folder if path is relative, otherwise uses absolute path. + + Args: + mesh: trimesh.Trimesh object + file_path: Output file path (relative to output folder or absolute) + + Returns: + tuple: (status_message,) + """ + if not file_path or file_path.strip() == "": + raise ValueError("File path cannot be empty") + + # Determine full output path + full_path = file_path + + # If path is relative and we have output folder, use it + if not os.path.isabs(file_path) and COMFYUI_OUTPUT_FOLDER is not None: + full_path = os.path.join(COMFYUI_OUTPUT_FOLDER, file_path) + print(f"[SaveMesh] Saving to output folder: {file_path}") + else: + print(f"[SaveMesh] Saving to: {file_path}") + + # Save the mesh + success, error = mesh_utils.save_mesh_file(mesh, full_path) + + if not success: + raise ValueError(f"Failed to save mesh: {error}") + + status = f"Successfully saved mesh to: {full_path}\n" + status += f" Vertices: {len(mesh.vertices)}\n" + status += f" Faces: {len(mesh.faces)}" + + print(f"[SaveMesh] {status}") + + return (status,) + + +class CreatePrimitive: + """ + Create primitive geometry (cube, sphere, plane) + Uses trimesh creation functions for high-quality primitives. + """ + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "shape": (["cube", "sphere", "plane"], { + "default": "cube" + }), + "size": ("FLOAT", { + "default": 1.0, + "min": 0.01, + "max": 100.0, + "step": 0.1 + }), + }, + "optional": { + "subdivisions": ("INT", { + "default": 2, + "min": 0, + "max": 5, + "step": 1 + }), + } + } + + RETURN_TYPES = ("MESH",) + RETURN_NAMES = ("mesh",) + FUNCTION = "create_primitive" + CATEGORY = "geompack/primitives" + + def create_primitive(self, shape, size, subdivisions=2): + """ + Create a primitive mesh. + + Args: + shape: Type of primitive (cube, sphere, plane) + size: Size of the primitive + subdivisions: Number of subdivisions (for sphere and plane) + + Returns: + tuple: (trimesh.Trimesh,) + """ + if shape == "cube": + mesh = mesh_utils.create_cube(size) + elif shape == "sphere": + mesh = mesh_utils.create_sphere(radius=size/2.0, subdivisions=subdivisions) + elif shape == "plane": + mesh = mesh_utils.create_plane(size=size, subdivisions=subdivisions) + else: + raise ValueError(f"Unknown shape: {shape}") + + print(f"[CreatePrimitive] Created {shape}: {len(mesh.vertices)} vertices, {len(mesh.faces)} faces") + + return (mesh,) + + +class CGALRemeshNode: + """ + CGAL Isotropic Remeshing - Create uniform triangle meshes. + + Implements the algorithm from Botsch & Kobbelt (2004). + This remeshing technique creates triangles with target edge length, + resulting in more uniform mesh quality. + """ + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "mesh": ("MESH",), + "target_edge_length": ("FLOAT", { + "default": 0.1, + "min": 0.001, + "max": 10.0, + "step": 0.01, + "display": "number" + }), + "iterations": ("INT", { + "default": 3, + "min": 1, + "max": 20, + "step": 1 + }), + }, + } + + RETURN_TYPES = ("MESH",) + RETURN_NAMES = ("remeshed_mesh",) + FUNCTION = "remesh" + CATEGORY = "geompack/cgal" + + def remesh(self, mesh, target_edge_length, iterations): + """ + Apply CGAL isotropic remeshing. + + Args: + mesh: Input trimesh.Trimesh object + target_edge_length: Target edge length for remeshed triangles + iterations: Number of remeshing iterations + + Returns: + tuple: (remeshed_trimesh.Trimesh,) + """ + print(f"[CGALRemesh] Input: {len(mesh.vertices)} vertices, {len(mesh.faces)} faces") + print(f"[CGALRemesh] Target edge length: {target_edge_length}, Iterations: {iterations}") + + remeshed_mesh, error = mesh_utils.cgal_isotropic_remesh( + mesh, + target_edge_length, + iterations + ) + + if remeshed_mesh is None: + raise ValueError(f"Remeshing failed: {error}") + + print(f"[CGALRemesh] Output: {len(remeshed_mesh.vertices)} vertices, {len(remeshed_mesh.faces)} faces") + + return (remeshed_mesh,) + + +class BlenderUVUnwrapNode: + """ + UV Unwrap mesh using Blender's Smart UV Project. + + This node uses Blender's advanced UV unwrapping algorithm to generate + UV coordinates for texturing. The mesh is exported with UV coordinates. + """ + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "mesh": ("MESH",), + "angle_limit": ("FLOAT", { + "default": 66.0, + "min": 1.0, + "max": 89.0, + "step": 1.0, + "display": "number" + }), + "island_margin": ("FLOAT", { + "default": 0.02, + "min": 0.0, + "max": 1.0, + "step": 0.01, + "display": "number" + }), + }, + } + + RETURN_TYPES = ("MESH",) + RETURN_NAMES = ("unwrapped_mesh",) + FUNCTION = "uv_unwrap" + CATEGORY = "geompack/blender" + + def uv_unwrap(self, mesh, angle_limit, island_margin): + """ + UV unwrap mesh using Blender's Smart UV Project. + + Args: + mesh: Input trimesh.Trimesh object + angle_limit: Angle threshold for creating seams (degrees) + island_margin: Spacing between UV islands (0-1) + + Returns: + tuple: (unwrapped_trimesh.Trimesh,) + """ + print(f"[BlenderUVUnwrap] Input: {len(mesh.vertices)} vertices, {len(mesh.faces)} faces") + print(f"[BlenderUVUnwrap] Parameters: angle_limit={angle_limit}°, island_margin={island_margin}") + + # Find Blender + blender_path = _find_blender() + + # Create temp files + with tempfile.NamedTemporaryFile(suffix='.obj', delete=False) as f_in: + input_path = f_in.name + mesh.export(input_path) + + with tempfile.NamedTemporaryFile(suffix='.obj', delete=False) as f_out: + output_path = f_out.name + + try: + # Blender script for UV unwrapping + script = f""" +import bpy +import math + +# Clear scene +bpy.ops.object.select_all(action='SELECT') +bpy.ops.object.delete() + +# Import mesh (OBJ preserves geometry) +bpy.ops.wm.obj_import(filepath='{input_path}') + +# Get imported object +obj = bpy.context.selected_objects[0] +bpy.context.view_layer.objects.active = obj + +# Switch to edit mode and unwrap +bpy.ops.object.mode_set(mode='EDIT') +bpy.ops.mesh.select_all(action='SELECT') +bpy.ops.uv.smart_project( + angle_limit={np.radians(angle_limit)}, + island_margin={island_margin}, + area_weight=0.0, + correct_aspect=True, + scale_to_bounds=False +) +bpy.ops.object.mode_set(mode='OBJECT') + +# Export with UVs +bpy.ops.wm.obj_export( + filepath='{output_path}', + export_selected_objects=True, + export_uv=True, + export_materials=False +) +""" + + print(f"[BlenderUVUnwrap] Running Blender in background mode...") + result = subprocess.run( + [blender_path, '--background', '--python-expr', script], + capture_output=True, + text=True, + timeout=300 + ) + + if result.returncode != 0: + raise RuntimeError(f"Blender failed: {result.stderr}") + + # Load the unwrapped mesh + print(f"[BlenderUVUnwrap] Loading unwrapped mesh...") + unwrapped = trimesh.load(output_path, process=False) + + # If it's a scene, dump to single mesh + if isinstance(unwrapped, trimesh.Scene): + unwrapped = unwrapped.dump(concatenate=True) + + # Preserve metadata + unwrapped.metadata = mesh.metadata.copy() + unwrapped.metadata['uv_unwrap'] = { + 'algorithm': 'blender_smart_uv', + 'angle_limit': angle_limit, + 'island_margin': island_margin + } + + print(f"[BlenderUVUnwrap] ✓ Complete: {len(unwrapped.vertices)} vertices, {len(unwrapped.faces)} faces") + + return (unwrapped,) + + finally: + # Cleanup temp files + if os.path.exists(input_path): + os.unlink(input_path) + if os.path.exists(output_path): + os.unlink(output_path) + + +class BlenderVoxelRemeshNode: + """ + Voxel-based remeshing using Blender. + + Creates a new mesh by voxelizing the input mesh and reconstructing + the surface. Good for creating uniform, watertight meshes. + """ + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "mesh": ("MESH",), + "voxel_size": ("FLOAT", { + "default": 0.05, + "min": 0.001, + "max": 1.0, + "step": 0.01, + "display": "number" + }), + }, + } + + RETURN_TYPES = ("MESH",) + RETURN_NAMES = ("remeshed_mesh",) + FUNCTION = "voxel_remesh" + CATEGORY = "geompack/blender" + + def voxel_remesh(self, mesh, voxel_size): + """ + Apply voxel remeshing using Blender. + + Args: + mesh: Input trimesh.Trimesh object + voxel_size: Voxel size for remeshing (smaller = higher resolution) + + Returns: + tuple: (remeshed_trimesh.Trimesh,) + """ + print(f"[BlenderVoxelRemesh] Input: {len(mesh.vertices)} vertices, {len(mesh.faces)} faces") + print(f"[BlenderVoxelRemesh] Voxel size: {voxel_size}") + + # Find Blender + blender_path = _find_blender() + + # Create temp files + with tempfile.NamedTemporaryFile(suffix='.obj', delete=False) as f_in: + input_path = f_in.name + mesh.export(input_path) + + with tempfile.NamedTemporaryFile(suffix='.obj', delete=False) as f_out: + output_path = f_out.name + + try: + # Blender script for voxel remeshing + script = f""" +import bpy + +# Clear scene +bpy.ops.object.select_all(action='SELECT') +bpy.ops.object.delete() + +# Import mesh +bpy.ops.wm.obj_import(filepath='{input_path}') + +# Get imported object +obj = bpy.context.selected_objects[0] +bpy.context.view_layer.objects.active = obj + +# Apply voxel remesh +obj.data.remesh_voxel_size = {voxel_size} +bpy.ops.object.voxel_remesh() + +# Export remeshed object +bpy.ops.wm.obj_export( + filepath='{output_path}', + export_selected_objects=True, + export_uv=False, + export_materials=False +) +""" + + print(f"[BlenderVoxelRemesh] Running Blender in background mode...") + result = subprocess.run( + [blender_path, '--background', '--python-expr', script], + capture_output=True, + text=True, + timeout=300 + ) + + if result.returncode != 0: + raise RuntimeError(f"Blender failed: {result.stderr}") + + # Load the remeshed mesh + print(f"[BlenderVoxelRemesh] Loading remeshed mesh...") + remeshed = trimesh.load(output_path, process=False) + + # If it's a scene, dump to single mesh + if isinstance(remeshed, trimesh.Scene): + remeshed = remeshed.dump(concatenate=True) + + # Preserve metadata + remeshed.metadata = mesh.metadata.copy() + remeshed.metadata['remeshing'] = { + 'algorithm': 'blender_voxel', + 'voxel_size': voxel_size, + 'original_vertices': len(mesh.vertices), + 'original_faces': len(mesh.faces), + 'remeshed_vertices': len(remeshed.vertices), + 'remeshed_faces': len(remeshed.faces) + } + + vertex_change = len(remeshed.vertices) - len(mesh.vertices) + face_change = len(remeshed.faces) - len(mesh.faces) + + print(f"[BlenderVoxelRemesh] ✓ Complete:") + print(f"[BlenderVoxelRemesh] Vertices: {len(mesh.vertices)} -> {len(remeshed.vertices)} ({vertex_change:+d})") + print(f"[BlenderVoxelRemesh] Faces: {len(mesh.faces)} -> {len(remeshed.faces)} ({face_change:+d})") + + return (remeshed,) + + finally: + # Cleanup temp files + if os.path.exists(input_path): + os.unlink(input_path) + if os.path.exists(output_path): + os.unlink(output_path) + + +class PreviewMeshNode: + """ + Preview mesh with interactive 3D viewer. + + Displays mesh in an interactive Three.js viewer with orbit controls. + Allows rotating, panning, and zooming to inspect the mesh geometry. + """ + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "mesh": ("MESH",), + }, + } + + RETURN_TYPES = () + OUTPUT_NODE = True + FUNCTION = "preview_mesh" + CATEGORY = "geompack/visualization" + + def preview_mesh(self, mesh): + """ + Export mesh to GLB and prepare for 3D preview. + + Args: + mesh: Input trimesh.Trimesh object + + Returns: + dict: UI data for frontend widget + """ + import uuid + + print(f"[PreviewMesh] Preparing preview: {len(mesh.vertices)} vertices, {len(mesh.faces)} faces") + + # Generate unique filename + filename = f"preview_{uuid.uuid4().hex[:8]}.glb" + + # Use ComfyUI's output directory + if COMFYUI_OUTPUT_FOLDER: + filepath = os.path.join(COMFYUI_OUTPUT_FOLDER, filename) + else: + filepath = os.path.join(tempfile.gettempdir(), filename) + + # Export to GLB (best format for Three.js) + try: + mesh.export(filepath, file_type='glb') + print(f"[PreviewMesh] Exported to: {filepath}") + except Exception as e: + print(f"[PreviewMesh] Export failed: {e}") + # Fallback to OBJ + filename = filename.replace('.glb', '.obj') + filepath = filepath.replace('.glb', '.obj') + mesh.export(filepath, file_type='obj') + print(f"[PreviewMesh] Exported to OBJ: {filepath}") + + # Calculate bounding box info for camera setup + bounds = mesh.bounds + extents = mesh.extents + max_extent = max(extents) + + # Return metadata for frontend widget + return { + "ui": { + "mesh_file": [filename], + "vertex_count": [len(mesh.vertices)], + "face_count": [len(mesh.faces)], + "bounds_min": [bounds[0].tolist()], + "bounds_max": [bounds[1].tolist()], + "extents": [extents.tolist()], + "max_extent": [float(max_extent)], + } + } + + +class PreviewMeshVTKNode: + """ + Preview mesh with VTK.js scientific visualization viewer. + + Displays mesh in an interactive VTK.js viewer with trackball controls. + Better for scientific visualization, mesh analysis, and large datasets. + """ + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "mesh": ("MESH",), + }, + } + + RETURN_TYPES = () + OUTPUT_NODE = True + FUNCTION = "preview_mesh_vtk" + CATEGORY = "geompack/visualization" + + def preview_mesh_vtk(self, mesh): + """ + Export mesh to STL and prepare for VTK.js preview. + + Args: + mesh: Input trimesh.Trimesh object + + Returns: + dict: UI data for frontend widget + """ + import uuid + + print(f"[PreviewMeshVTK] Preparing preview: {len(mesh.vertices)} vertices, {len(mesh.faces)} faces") + + # Generate unique filename + filename = f"preview_vtk_{uuid.uuid4().hex[:8]}.stl" + + # Use ComfyUI's output directory + if COMFYUI_OUTPUT_FOLDER: + filepath = os.path.join(COMFYUI_OUTPUT_FOLDER, filename) + else: + filepath = os.path.join(tempfile.gettempdir(), filename) + + # Export to STL (native format for VTK.js) + try: + mesh.export(filepath, file_type='stl') + print(f"[PreviewMeshVTK] Exported to: {filepath}") + except Exception as e: + print(f"[PreviewMeshVTK] Export failed: {e}") + # Fallback to OBJ + filename = filename.replace('.stl', '.obj') + filepath = filepath.replace('.stl', '.obj') + mesh.export(filepath, file_type='obj') + print(f"[PreviewMeshVTK] Exported to OBJ: {filepath}") + + # Calculate bounding box info for camera setup + bounds = mesh.bounds + extents = mesh.extents + max_extent = max(extents) + + # Return metadata for frontend widget + return { + "ui": { + "mesh_file": [filename], + "vertex_count": [len(mesh.vertices)], + "face_count": [len(mesh.faces)], + "bounds_min": [bounds[0].tolist()], + "bounds_max": [bounds[1].tolist()], + "extents": [extents.tolist()], + "max_extent": [float(max_extent)], + } + } + + +class CenterMeshNode: + """ + Center mesh at origin (0, 0, 0). + + Uses bounding box center to translate the mesh so its center + is at the world origin. Useful for preparing meshes for export + or ensuring consistent positioning. + """ + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "mesh": ("MESH",), + }, + } + + RETURN_TYPES = ("MESH",) + RETURN_NAMES = ("centered_mesh",) + FUNCTION = "center_mesh" + CATEGORY = "geompack/transforms" + + def center_mesh(self, mesh): + """ + Center mesh at origin using bounding box center. + + Args: + mesh: Input trimesh.Trimesh object + + Returns: + tuple: (centered_trimesh.Trimesh,) + """ + print(f"[CenterMesh] Input: {len(mesh.vertices)} vertices, {len(mesh.faces)} faces") + + # Calculate bounding box center + bounds_center = (mesh.bounds[0] + mesh.bounds[1]) / 2.0 + + print(f"[CenterMesh] Original center: [{bounds_center[0]:.3f}, {bounds_center[1]:.3f}, {bounds_center[2]:.3f}]") + + # Apply translation to center at origin + mesh_centered = mesh.copy() + mesh_centered.apply_translation(-bounds_center) + + # Verify centering + new_center = (mesh_centered.bounds[0] + mesh_centered.bounds[1]) / 2.0 + print(f"[CenterMesh] New center: [{new_center[0]:.3f}, {new_center[1]:.3f}, {new_center[2]:.3f}]") + + # Preserve metadata + mesh_centered.metadata = mesh.metadata.copy() + mesh_centered.metadata['centered'] = True + mesh_centered.metadata['original_center'] = bounds_center.tolist() + + return (mesh_centered,) + + +class BlenderQuadriflowRemeshNode: + """ + Quadriflow remeshing using Blender. + + Creates a quad-dominant mesh with good topology. Better for animation + and subdivision surfaces than triangle-based remeshing. + """ + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "mesh": ("MESH",), + "target_face_count": ("INT", { + "default": 5000, + "min": 100, + "max": 100000, + "step": 100 + }), + }, + } + + RETURN_TYPES = ("MESH",) + RETURN_NAMES = ("remeshed_mesh",) + FUNCTION = "quadriflow_remesh" + CATEGORY = "geompack/blender" + + def quadriflow_remesh(self, mesh, target_face_count): + """ + Apply Quadriflow remeshing using Blender. + + Args: + mesh: Input trimesh.Trimesh object + target_face_count: Target number of faces in output mesh + + Returns: + tuple: (remeshed_trimesh.Trimesh,) + """ + print(f"[BlenderQuadriflow] Input: {len(mesh.vertices)} vertices, {len(mesh.faces)} faces") + print(f"[BlenderQuadriflow] Target face count: {target_face_count}") + + # Find Blender + blender_path = _find_blender() + + # Create temp files + with tempfile.NamedTemporaryFile(suffix='.obj', delete=False) as f_in: + input_path = f_in.name + mesh.export(input_path) + + with tempfile.NamedTemporaryFile(suffix='.obj', delete=False) as f_out: + output_path = f_out.name + + try: + # Blender script for Quadriflow remeshing + script = f""" +import bpy + +# Clear scene +bpy.ops.object.select_all(action='SELECT') +bpy.ops.object.delete() + +# Import mesh +bpy.ops.wm.obj_import(filepath='{input_path}') + +# Get imported object +obj = bpy.context.selected_objects[0] +bpy.context.view_layer.objects.active = obj + +# Apply Quadriflow remesh +# Note: Different Blender versions have different parameters +# Using minimal set for maximum compatibility +bpy.ops.object.quadriflow_remesh( + use_mesh_symmetry=False, + use_preserve_sharp=False, + use_preserve_boundary=False, + smooth_normals=False, + mode='FACES', + target_faces={target_face_count}, + seed=0 +) + +# Export remeshed object +bpy.ops.wm.obj_export( + filepath='{output_path}', + export_selected_objects=True, + export_uv=False, + export_materials=False +) +""" + + print(f"[BlenderQuadriflow] Running Blender in background mode...") + result = subprocess.run( + [blender_path, '--background', '--python-expr', script], + capture_output=True, + text=True, + timeout=300 + ) + + if result.returncode != 0: + raise RuntimeError(f"Blender failed: {result.stderr}") + + # Load the remeshed mesh + print(f"[BlenderQuadriflow] Loading remeshed mesh...") + remeshed = trimesh.load(output_path, process=False) + + # If it's a scene, dump to single mesh + if isinstance(remeshed, trimesh.Scene): + remeshed = remeshed.dump(concatenate=True) + + # Quadriflow produces quads, but trimesh will triangulate them + # Preserve metadata + remeshed.metadata = mesh.metadata.copy() + remeshed.metadata['remeshing'] = { + 'algorithm': 'blender_quadriflow', + 'target_face_count': target_face_count, + 'original_vertices': len(mesh.vertices), + 'original_faces': len(mesh.faces), + 'remeshed_vertices': len(remeshed.vertices), + 'remeshed_faces': len(remeshed.faces) + } + + vertex_change = len(remeshed.vertices) - len(mesh.vertices) + face_change = len(remeshed.faces) - len(mesh.faces) + + print(f"[BlenderQuadriflow] ✓ Complete:") + print(f"[BlenderQuadriflow] Vertices: {len(mesh.vertices)} -> {len(remeshed.vertices)} ({vertex_change:+d})") + print(f"[BlenderQuadriflow] Faces: {len(mesh.faces)} -> {len(remeshed.faces)} ({face_change:+d})") + + return (remeshed,) + + finally: + # Cleanup temp files + if os.path.exists(input_path): + os.unlink(input_path) + if os.path.exists(output_path): + os.unlink(output_path) + + +# Node class mappings - this dictionary maps internal node names to classes +NODE_CLASS_MAPPINGS = { + "GeomPackExampleNode": ExampleLibiglNode, + "GeomPackMeshInfo": MeshInfoNode, + "GeomPackLoadMesh": LoadMesh, + "GeomPackSaveMesh": SaveMesh, + "GeomPackCreatePrimitive": CreatePrimitive, + "GeomPackCGALRemesh": CGALRemeshNode, + "GeomPackCenterMesh": CenterMeshNode, + "GeomPackPreviewMesh": PreviewMeshNode, + "GeomPackPreviewMeshVTK": PreviewMeshVTKNode, + "GeomPackBlenderUVUnwrap": BlenderUVUnwrapNode, + "GeomPackBlenderVoxelRemesh": BlenderVoxelRemeshNode, + "GeomPackBlenderQuadriflowRemesh": BlenderQuadriflowRemeshNode, +} + +# Display name mappings - these are the names shown in ComfyUI's node browser +NODE_DISPLAY_NAME_MAPPINGS = { + "GeomPackExampleNode": "Example Node", + "GeomPackMeshInfo": "Mesh Info", + "GeomPackLoadMesh": "Load Mesh", + "GeomPackSaveMesh": "Save Mesh", + "GeomPackCreatePrimitive": "Create Primitive", + "GeomPackCGALRemesh": "CGAL Remesh (Isotropic)", + "GeomPackCenterMesh": "Center Mesh", + "GeomPackPreviewMesh": "Preview Mesh (3D)", + "GeomPackPreviewMeshVTK": "Preview Mesh (VTK)", + "GeomPackBlenderUVUnwrap": "Blender UV Unwrap", + "GeomPackBlenderVoxelRemesh": "Blender Voxel Remesh", + "GeomPackBlenderQuadriflowRemesh": "Blender Quadriflow Remesh", +} diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..3c1e6148 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,25 @@ +[project] +name = "comfyui-libigl" +description = "libigl geometry processing wrapper for ComfyUI - mesh manipulation and analysis nodes" +version = "0.1.0" +license = { file = "LICENSE" } +dependencies = [ + "libigl>=2.6.1", + "numpy>=1.21.0", + "scipy>=1.7.0", + "matplotlib>=3.5.0", + "Pillow>=9.0.0" +] + +[project.urls] +Repository = "https://github.com/yourusername/ComfyUI-libigl" +Documentation = "https://github.com/yourusername/ComfyUI-libigl/blob/main/README.md" + +[tool.comfy] +PublisherId = "your-publisher-id" +DisplayName = "ComfyUI libigl" +Icon = "" + +[build-system] +requires = ["setuptools>=45", "wheel"] +build-backend = "setuptools.build_meta" diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 00000000..82c32d8b --- /dev/null +++ b/requirements.txt @@ -0,0 +1,10 @@ +# Core dependencies +libigl>=2.6.1 +numpy>=1.21.0 +scipy>=1.7.0 +trimesh>=3.15.0 +pymeshlab>=2022.2 + +# Visualization +matplotlib>=3.5.0 +Pillow>=9.0.0 diff --git a/test_nodes.py b/test_nodes.py new file mode 100644 index 00000000..ef96a629 --- /dev/null +++ b/test_nodes.py @@ -0,0 +1,188 @@ +""" +Test script to verify the GeomPack nodes work correctly +Now testing with trimesh and CGAL functionality +""" + +import sys +import os + +# Add current directory to path for imports +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +import numpy as np +import trimesh +from nodes import CreatePrimitive, SaveMesh, LoadMesh, MeshInfoNode, CGALRemeshNode + + +def test_create_primitive(): + """Test creating primitive shapes""" + print("=" * 60) + print("TEST 1: Create Primitive") + print("=" * 60) + + node = CreatePrimitive() + + # Test cube + print("\n[1/3] Testing cube creation...") + mesh, = node.create_primitive("cube", 2.0, subdivisions=0) + assert isinstance(mesh, trimesh.Trimesh), "Mesh should be a trimesh.Trimesh object" + print(f"✓ Cube created: {len(mesh.vertices)} vertices, {len(mesh.faces)} faces") + assert len(mesh.vertices) == 8, "Cube should have 8 vertices" + assert len(mesh.faces) == 12, "Cube should have 12 faces" + + # Test sphere + print("\n[2/3] Testing sphere creation...") + mesh, = node.create_primitive("sphere", 1.0, subdivisions=2) + assert isinstance(mesh, trimesh.Trimesh), "Mesh should be a trimesh.Trimesh object" + print(f"✓ Sphere created: {len(mesh.vertices)} vertices, {len(mesh.faces)} faces") + assert len(mesh.vertices) > 0, "Sphere should have vertices" + assert len(mesh.faces) > 0, "Sphere should have faces" + assert mesh.is_watertight, "Sphere should be watertight" + + # Test plane + print("\n[3/3] Testing plane creation...") + mesh, = node.create_primitive("plane", 1.0, subdivisions=3) + assert isinstance(mesh, trimesh.Trimesh), "Mesh should be a trimesh.Trimesh object" + print(f"✓ Plane created: {len(mesh.vertices)} vertices, {len(mesh.faces)} faces") + assert len(mesh.vertices) == 16, "Plane with 3 subdivisions should have 16 vertices" + + print("\n✅ All primitive creation tests passed!") + return mesh + + +def test_save_and_load(mesh): + """Test saving and loading mesh""" + print("\n" + "=" * 60) + print("TEST 2: Save and Load Mesh") + print("=" * 60) + + # Save mesh + print("\n[1/2] Testing mesh save...") + save_node = SaveMesh() + test_file = "/tmp/test_mesh.obj" + status, = save_node.save_mesh(mesh, test_file) + print(f"✓ Mesh saved to {test_file}") + print(f" Status: {status}") + + # Load mesh + print("\n[2/2] Testing mesh load...") + load_node = LoadMesh() + loaded_mesh, = load_node.load_mesh(test_file) + assert isinstance(loaded_mesh, trimesh.Trimesh), "Loaded mesh should be a trimesh.Trimesh object" + print(f"✓ Mesh loaded from {test_file}") + print(f" Vertices: {len(loaded_mesh.vertices)}") + print(f" Faces: {len(loaded_mesh.faces)}") + + # Verify + assert len(loaded_mesh.vertices) == len(mesh.vertices), "Vertex count should match" + assert len(loaded_mesh.faces) == len(mesh.faces), "Face count should match" + + print("\n✅ Save/Load test passed!") + return loaded_mesh + + +def test_mesh_info(mesh): + """Test mesh info node""" + print("\n" + "=" * 60) + print("TEST 3: Mesh Info") + print("=" * 60) + + info_node = MeshInfoNode() + info, = info_node.get_mesh_info(mesh) + + print("\n" + info) + assert "Vertices:" in info, "Info should contain vertex count" + assert "Faces:" in info, "Info should contain face count" + assert "Volume:" in info, "Info should contain volume" + assert "Is Watertight:" in info, "Info should contain watertight status" + + print("\n✅ Mesh info test passed!") + + +def test_cgal_remeshing(): + """Test CGAL remeshing node""" + print("\n" + "=" * 60) + print("TEST 4: CGAL Isotropic Remeshing") + print("=" * 60) + + # Create a test mesh (cube) + print("\n[1/3] Creating test cube...") + create_node = CreatePrimitive() + mesh, = create_node.create_primitive("cube", 1.0, subdivisions=0) + original_vertices = len(mesh.vertices) + original_faces = len(mesh.faces) + print(f"✓ Original mesh: {original_vertices} vertices, {original_faces} faces") + + # Apply remeshing + print("\n[2/3] Applying CGAL remeshing...") + remesh_node = CGALRemeshNode() + remeshed_mesh, = remesh_node.remesh(mesh, target_edge_length=0.15, iterations=3) + assert isinstance(remeshed_mesh, trimesh.Trimesh), "Remeshed mesh should be a trimesh.Trimesh object" + print(f"✓ Remeshed mesh: {len(remeshed_mesh.vertices)} vertices, {len(remeshed_mesh.faces)} faces") + + # Verify remeshing worked + print("\n[3/3] Verifying remeshing results...") + assert len(remeshed_mesh.vertices) > original_vertices, "Remeshing should increase vertex count" + assert len(remeshed_mesh.faces) > original_faces, "Remeshing should increase face count" + assert remeshed_mesh.is_watertight, "Remeshed mesh should be watertight" + + # Check that volume is approximately preserved + volume_diff = abs(remeshed_mesh.volume - mesh.volume) + volume_error = volume_diff / mesh.volume + print(f" Original volume: {mesh.volume:.6f}") + print(f" Remeshed volume: {remeshed_mesh.volume:.6f}") + print(f" Volume error: {volume_error*100:.2f}%") + assert volume_error < 0.05, f"Volume should be approximately preserved (error: {volume_error*100:.2f}%)" + + print("\n✅ CGAL remeshing test passed!") + return remeshed_mesh + + +def main(): + """Run all tests""" + print("\n" + "=" * 60) + print("ComfyUI GeomPack Node Tests") + print("=" * 60) + + try: + # Test 1: Create primitives + mesh = test_create_primitive() + + # Test 2: Save and load + loaded_mesh = test_save_and_load(mesh) + + # Test 3: Mesh info + test_mesh_info(loaded_mesh) + + # Test 4: CGAL remeshing + remeshed_mesh = test_cgal_remeshing() + + # Summary + print("\n" + "=" * 60) + print("🎉 ALL TESTS PASSED!") + print("=" * 60) + print("\nThe following nodes are working correctly:") + print(" ✓ CreatePrimitive (cube, sphere, plane)") + print(" ✓ SaveMesh (OBJ format)") + print(" ✓ LoadMesh (OBJ format)") + print(" ✓ MeshInfoNode (with trimesh enhancements)") + print(" ✓ CGALRemeshNode (isotropic remeshing)") + print("\nYou can now use these nodes in ComfyUI!") + print("=" * 60) + + except Exception as e: + print("\n" + "=" * 60) + print("❌ TEST FAILED!") + print("=" * 60) + print(f"Error: {e}") + import traceback + traceback.print_exc() + return False + + return True + + +if __name__ == "__main__": + import sys + success = main() + sys.exit(0 if success else 1) diff --git a/web/js/mesh_preview.js b/web/js/mesh_preview.js new file mode 100644 index 00000000..b0db42bb --- /dev/null +++ b/web/js/mesh_preview.js @@ -0,0 +1,78 @@ +/** + * ComfyUI GeomPack - 3D Mesh Preview Widget + * Minimal implementation based on ComfyUI-3D-Pack pattern + */ + +import { app } from "../../../scripts/app.js"; + +console.log("[GeomPack] Loading mesh preview extension..."); + +app.registerExtension({ + name: "geompack.meshpreview", + + async beforeRegisterNodeDef(nodeType, nodeData, app) { + if (nodeData.name === "GeomPackPreviewMesh") { + console.log("[GeomPack] Registering Preview Mesh node"); + + const onNodeCreated = nodeType.prototype.onNodeCreated; + nodeType.prototype.onNodeCreated = function() { + const r = onNodeCreated ? onNodeCreated.apply(this, arguments) : undefined; + + console.log("[GeomPack] Node created, adding widget"); + + // Create iframe for 3D viewer + const iframe = document.createElement("iframe"); + iframe.style.width = "100%"; + iframe.style.height = "500px"; + iframe.style.border = "none"; + iframe.style.backgroundColor = "#2a2a2a"; + + // Point to our HTML viewer (with cache buster) + iframe.src = "/extensions/ComfyUI-GeomPack/viewer.html?v=" + Date.now(); + + // Add widget + const widget = this.addDOMWidget("preview", "MESH_PREVIEW", iframe); + widget.computeSize = () => [512, 520]; + + // Store iframe reference + this.meshViewerIframe = iframe; + + // Handle execution + const onExecuted = this.onExecuted; + this.onExecuted = function(message) { + console.log("[GeomPack] onExecuted called with message:", message); + onExecuted?.apply(this, arguments); + + // The message IS the UI data (not message.ui) + if (message?.mesh_file && message.mesh_file[0]) { + const filename = message.mesh_file[0]; + console.log(`[GeomPack] Loading mesh: ${filename}`); + + // ComfyUI serves output files via /view API endpoint + const filepath = `/view?filename=${encodeURIComponent(filename)}&type=output&subfolder=`; + + // Send message to iframe (with delay to ensure iframe is loaded) + setTimeout(() => { + if (iframe.contentWindow) { + console.log(`[GeomPack] Sending postMessage to iframe: ${filepath}`); + iframe.contentWindow.postMessage({ + type: "LOAD_MESH", + filepath: filepath, + timestamp: Date.now() + }, "*"); + } else { + console.error("[GeomPack] Iframe contentWindow not available"); + } + }, 100); + } else { + console.log("[GeomPack] No mesh_file in message data. Keys:", Object.keys(message || {})); + } + }; + + return r; + }; + } + } +}); + +console.log("[GeomPack] Mesh preview extension registered"); diff --git a/web/js/mesh_preview_vtk.js b/web/js/mesh_preview_vtk.js new file mode 100644 index 00000000..78d82005 --- /dev/null +++ b/web/js/mesh_preview_vtk.js @@ -0,0 +1,78 @@ +/** + * ComfyUI GeomPack - VTK.js Mesh Preview Widget + * Scientific visualization with VTK.js + */ + +import { app } from "../../../scripts/app.js"; + +console.log("[GeomPack] Loading VTK.js mesh preview extension..."); + +app.registerExtension({ + name: "geompack.meshpreview.vtk", + + async beforeRegisterNodeDef(nodeType, nodeData, app) { + if (nodeData.name === "GeomPackPreviewMeshVTK") { + console.log("[GeomPack] Registering Preview Mesh (VTK) node"); + + const onNodeCreated = nodeType.prototype.onNodeCreated; + nodeType.prototype.onNodeCreated = function() { + const r = onNodeCreated ? onNodeCreated.apply(this, arguments) : undefined; + + console.log("[GeomPack] VTK node created, adding widget"); + + // Create iframe for VTK.js viewer + const iframe = document.createElement("iframe"); + iframe.style.width = "100%"; + iframe.style.height = "500px"; + iframe.style.border = "none"; + iframe.style.backgroundColor = "#2a2a2a"; + + // Point to VTK.js HTML viewer (with cache buster) + iframe.src = "/extensions/ComfyUI-GeomPack/viewer_vtk.html?v=" + Date.now(); + + // Add widget + const widget = this.addDOMWidget("preview_vtk", "MESH_PREVIEW_VTK", iframe); + widget.computeSize = () => [512, 520]; + + // Store iframe reference + this.meshViewerIframeVTK = iframe; + + // Handle execution + const onExecuted = this.onExecuted; + this.onExecuted = function(message) { + console.log("[GeomPack VTK] onExecuted called with message:", message); + onExecuted?.apply(this, arguments); + + // The message IS the UI data (not message.ui) + if (message?.mesh_file && message.mesh_file[0]) { + const filename = message.mesh_file[0]; + console.log(`[GeomPack VTK] Loading mesh: ${filename}`); + + // ComfyUI serves output files via /view API endpoint + const filepath = `/view?filename=${encodeURIComponent(filename)}&type=output&subfolder=`; + + // Send message to iframe (with delay to ensure iframe is loaded) + setTimeout(() => { + if (iframe.contentWindow) { + console.log(`[GeomPack VTK] Sending postMessage to iframe: ${filepath}`); + iframe.contentWindow.postMessage({ + type: "LOAD_MESH", + filepath: filepath, + timestamp: Date.now() + }, "*"); + } else { + console.error("[GeomPack VTK] Iframe contentWindow not available"); + } + }, 100); + } else { + console.log("[GeomPack VTK] No mesh_file in message data. Keys:", Object.keys(message || {})); + } + }; + + return r; + }; + } + } +}); + +console.log("[GeomPack] VTK.js mesh preview extension registered"); diff --git a/web/viewer.html b/web/viewer.html new file mode 100644 index 00000000..95fcd148 --- /dev/null +++ b/web/viewer.html @@ -0,0 +1,187 @@ + + + + + GeomPack 3D Viewer + + + + +
Ready to load mesh
+ + + + + + diff --git a/web/viewer_vtk.html b/web/viewer_vtk.html new file mode 100644 index 00000000..eed00c5d --- /dev/null +++ b/web/viewer_vtk.html @@ -0,0 +1,170 @@ + + + + + GeomPack VTK.js Viewer + + + +
+
Ready to load mesh
+ + + + + + diff --git a/workflows/bunnyremesh_api.json b/workflows/bunnyremesh_api.json new file mode 100644 index 00000000..f1db963b --- /dev/null +++ b/workflows/bunnyremesh_api.json @@ -0,0 +1,51 @@ +{ + "3": { + "inputs": { + "file_path": "bunny.stl" + }, + "class_type": "GeomPackLoadMesh", + "_meta": { + "title": "Load Mesh" + } + }, + "4": { + "inputs": { + "model_file": [ + "5", + 0 + ], + "image": "" + }, + "class_type": "Preview3D", + "_meta": { + "title": "Preview 3D" + } + }, + "5": { + "inputs": { + "file_path": "output.obj", + "mesh": [ + "6", + 0 + ] + }, + "class_type": "GeomPackSaveMesh", + "_meta": { + "title": "Save Mesh" + } + }, + "6": { + "inputs": { + "target_edge_length": 0.1, + "iterations": 3, + "mesh": [ + "3", + 0 + ] + }, + "class_type": "GeomPackCGALRemesh", + "_meta": { + "title": "CGAL Remesh (Isotropic)" + } + } +} \ No newline at end of file