This commit is contained in:
Andrea Pozzetti
2025-11-14 21:41:06 +01:00
parent 6b4608065d
commit 397a75582b
18 changed files with 2989 additions and 2874 deletions
+47 -503
View File
@@ -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
View File
@@ -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)
-2356
View File
File diff suppressed because it is too large Load Diff
+45
View File
@@ -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']
+118
View File
@@ -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",
}
+174
View File
@@ -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",
}
+244
View File
@@ -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",
}
+92
View File
@@ -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
View File
@@ -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",
}
+185 -2
View File
@@ -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
+76
View File
@@ -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",
}
+463
View File
@@ -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",
}
+68
View File
@@ -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
View File
@@ -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",
}
+507
View File
@@ -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)",
}
+1
View File
@@ -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
View File
@@ -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
View File
@@ -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',