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