diff --git a/README.md b/README.md
index e722f5b..d1f3be6 100644
--- a/README.md
+++ b/README.md
@@ -14,6 +14,7 @@
| Date | Description |
| --- | --- |
+| **2026-02-08** | Fixed "Fill Holes" node progress bar
Updated Cumesh package
Added "Remesh with Quad" node
Added "Batch Simplify Mesh and Export" node|
| **2026-02-07** | Updated Cumesh package
Improved "Remesh" node when removing inner layer|
| **2026-02-02** | Added node "Smooth Normals"
Useful for "Low Poly" mesh to remove the "blocky" aspect|
|| Added "remove_background" parameter for "PreProcess Image" node
Using rembg package|
diff --git a/nodes.py b/nodes.py
index 5f63eb6..085ddc9 100644
--- a/nodes.py
+++ b/nodes.py
@@ -46,6 +46,28 @@ class AnyType(str):
any = AnyType("*")
+def parse_string_to_int_list(number_string):
+ """
+ Parses a string containing comma-separated numbers into a list of integers.
+
+ Args:
+ number_string: A string containing comma-separated numbers (e.g., "20000,10000,5000").
+
+ Returns:
+ A list of integers parsed from the input string.
+ Returns an empty list if the input string is empty or None.
+ """
+ if not number_string:
+ return []
+
+ try:
+ # Split the string by comma and convert each part to an integer
+ int_list = [int(num.strip()) for num in number_string.split(',')]
+ return int_list
+ except ValueError as e:
+ print(f"Error converting string to integer: {e}. Please ensure all values are valid numbers.")
+ return []
+
def reset_cuda():
# Force garbage collection of Python objects
gc.collect()
@@ -1434,6 +1456,8 @@ class Trellis2PostProcessAndUnWrapAndRasterizer:
mrmeshpy.fillHole(meshlib_mesh, e, params)
holes_filled += 1
progress_bar_holes.update(1)
+
+ progress_bar_holes.close()
new_vertices = mrmeshnumpy.getNumpyVerts(meshlib_mesh)
new_faces = mrmeshnumpy.getNumpyFaces(meshlib_mesh.topology)
@@ -2311,6 +2335,8 @@ class Trellis2FillHolesWithMeshlib:
holes_filled += 1
progress_bar.update(1)
pbar.update(1)
+
+ progress_bar.close()
new_vertices = mrmeshnumpy.getNumpyVerts(mesh)
new_faces = mrmeshnumpy.getNumpyFaces(mesh.topology)
@@ -2343,7 +2369,228 @@ class Trellis2SmoothNormals:
return (new_mesh,)
+class Trellis2RemeshWithQuad:
+ @classmethod
+ def INPUT_TYPES(s):
+ return {
+ "required": {
+ "mesh": ("MESHWITHVOXEL",),
+ "remesh_band": ("FLOAT",{"default":1.0}),
+ "remesh_project": ("FLOAT",{"default":0.0}),
+ "fill_holes": ("BOOLEAN", {"default":False}),
+ "fill_holes_max_perimeter": ("FLOAT",{"default":0.03,"min":0.001,"max":99.999,"step":0.001}),
+ "dual_contouring_resolution": (["Auto","128","256","512","1024","2048"],{"default":"Auto"}),
+ "remove_floaters": ("BOOLEAN",{"default":True}),
+ "remove_inner_faces": ("BOOLEAN",{"default":True}),
+ }
+ }
+
+ RETURN_TYPES = ("MESHWITHVOXEL",)
+ RETURN_NAMES = ("mesh",)
+ FUNCTION = "process"
+ CATEGORY = "Trellis2Wrapper"
+ OUTPUT_NODE = True
+
+ def process(self, mesh, remesh_band, remesh_project, fill_holes, fill_holes_max_perimeter, dual_contouring_resolution, remove_floaters, remove_inner_faces):
+ reset_cuda()
+ mesh_copy = copy.deepcopy(mesh)
+
+ if remove_floaters:
+ mesh_copy = remove_floater(mesh_copy)
+
+ aabb = [[-0.5, -0.5, -0.5], [0.5, 0.5, 0.5]]
+
+ vertices = mesh_copy.vertices
+ faces = mesh_copy.faces
+ attr_volume = mesh_copy.attrs
+ coords = mesh_copy.coords
+ attr_layout = mesh_copy.layout
+ voxel_size = mesh_copy.voxel_size
+
+ # --- Input Normalization (AABB, Voxel Size, Grid Size) ---
+ if isinstance(aabb, (list, tuple)):
+ aabb = np.array(aabb)
+ if isinstance(aabb, np.ndarray):
+ aabb = torch.tensor(aabb, dtype=torch.float32, device='cuda')
+
+ # Calculate grid dimensions based on AABB and voxel size
+ if voxel_size is not None:
+ if isinstance(voxel_size, float):
+ voxel_size = [voxel_size, voxel_size, voxel_size]
+ if isinstance(voxel_size, (list, tuple)):
+ voxel_size = np.array(voxel_size)
+ if isinstance(voxel_size, np.ndarray):
+ voxel_size = torch.tensor(voxel_size, dtype=torch.float32, device='cuda')
+ grid_size = ((aabb[1] - aabb[0]) / voxel_size).round().int()
+ else:
+ if isinstance(grid_size, int):
+ grid_size = [grid_size, grid_size, grid_size]
+ if isinstance(grid_size, (list, tuple)):
+ grid_size = np.array(grid_size)
+ if isinstance(grid_size, np.ndarray):
+ grid_size = torch.tensor(grid_size, dtype=torch.int32, device='cuda')
+ voxel_size = (aabb[1] - aabb[0]) / grid_size
+
+ # Move data to GPU
+ vertices = vertices.cuda()
+ faces = faces.cuda()
+
+ # Initialize CUDA mesh handler
+ cumesh = CuMesh.CuMesh()
+ cumesh.init(vertices, faces)
+ print(f"Current vertices: {cumesh.num_vertices}, faces: {cumesh.num_faces}")
+
+ # --- Initial Mesh Cleaning ---
+ # Fills holes as much as we can before processing
+ if fill_holes:
+ cumesh.fill_holes(max_hole_perimeter=fill_holes_max_perimeter)
+ print(f"After filling holes: {cumesh.num_vertices} vertices, {cumesh.num_faces} faces")
+
+ vertices, faces = cumesh.read()
+
+ del cumesh
+ gc.collect()
+
+ # Build BVH for the current mesh to guide remeshing
+ #print(f"Building BVH for current mesh...")
+ #bvh = CuMesh.cuBVH(vertices.detach().clone(), faces.detach().clone())
+
+ print("Cleaning mesh...")
+ center = aabb.mean(dim=0)
+ scale = (aabb[1] - aabb[0]).max().item()
+
+ if dual_contouring_resolution == "Auto":
+ resolution = grid_size.max().item()
+ print(f"Dual Contouring resolution: {resolution}")
+ else:
+ resolution = int(dual_contouring_resolution)
+
+ print('Performing Dual Contouring ...')
+ # Perform Dual Contouring remeshing (rebuilds topology)
+ vertices, faces = CuMesh.remeshing.remesh_narrow_band_dc_quad(
+ vertices, faces,
+ center = center,
+ scale = scale * 1.1, # old calculation (resolution + 3 * remesh_band) / resolution * scale,
+ resolution = resolution,
+ band = remesh_band,
+ project_back = remesh_project, # Snaps vertices back to original surface
+ verbose = True,
+ remove_inner_faces = remove_inner_faces,
+ #bvh = bvh,
+ )
+
+ if remove_floaters:
+ vertices, faces = remove_floater2(vertices.cpu().numpy(),faces.cpu().numpy())
+ vertices = torch.from_numpy(vertices).contiguous().float()
+ faces = torch.from_numpy(faces).contiguous().int()
+
+ print(f"After remeshing: {len(vertices)} vertices, {len(faces)} faces")
+
+ mesh_copy.vertices = vertices.to(mesh_copy.device)
+ mesh_copy.faces = faces.to(mesh_copy.device)
+
+ return (mesh_copy,)
+
+class Trellis2BatchSimplifyMeshAndExport:
+ @classmethod
+ def INPUT_TYPES(s):
+ return {
+ "required": {
+ "mesh": ("MESHWITHVOXEL",),
+ "target_face_num": ("STRING",{"default":"2000000,1000000,500000,100000,50000,10000,5000,2500,1000"}),
+ "method": (["Cumesh","Meshlib"],{"default":"Cumesh"}),
+ "fill_holes":("BOOLEAN",{"default":True}),
+ "reorient_vertices":(["None","90 degrees","-90 degrees"],{"default":"90 degrees"}),
+ "filename_prefix":("STRING",),
+ "file_format": (["glb", "obj", "ply", "stl", "3mf", "dae"],),
+ },
+ }
+
+ RETURN_TYPES = ("STRING", )
+ RETURN_NAMES = ("lst_glb_path", )
+ FUNCTION = "process"
+ CATEGORY = "Trellis2Wrapper"
+ OUTPUT_NODE = True
+
+ def process(self, mesh, target_face_num, method, fill_holes, reorient_vertices, filename_prefix, file_format):
+ lst_output_mesh = []
+ list_of_faces = parse_string_to_int_list(target_face_num)
+ if len(list_of_faces)>0:
+ cumesh = CuMesh.CuMesh()
+ mesh_copy = copy.deepcopy(mesh)
+
+ for target_nbfaces in list_of_faces:
+ print(f"Processing at {target_nbfaces} ...")
+
+ vertices = mesh_copy.vertices.detach().clone().cpu().numpy()
+ faces = mesh_copy.faces.detach().clone().cpu().numpy()
+
+ if method=="Cumesh":
+ cumesh.init(torch.from_numpy(vertices).float().cuda(), torch.from_numpy(faces).int().cuda())
+ cumesh.simplify(target_nbfaces, verbose=True)
+ vertices, faces = cumesh.read()
+ vertices = vertices.cpu().numpy()
+ faces = faces.cpu().numpy()
+ elif method=="Meshlib":
+ vertices, faces = simplify_with_meshlib(vertices, faces, target_nbfaces)
+ else:
+ raise Exception("Unknown simplification method")
+
+ if fill_holes:
+ import meshlib.mrmeshpy as mrmeshpy
+
+ mmesh = mrmeshnumpy.meshFromFacesVerts(faces, vertices)
+
+ hole_edges = mmesh.topology.findHoleRepresentiveEdges()
+
+ nb_holes = len(hole_edges)
+ print(f"{nb_holes} holes found")
+
+ if nb_holes>0:
+ progress_bar = tqdm(total=nb_holes,desc="Filling holes")
+
+ for e in hole_edges:
+ params = mrmeshpy.FillHoleParams()
+ params.metric = mrmeshpy.getUniversalMetric(mmesh)
+ mrmeshpy.fillHole(mmesh, e, params)
+ progress_bar.update(1)
+
+ progress_bar.close()
+
+ vertices = mrmeshnumpy.getNumpyVerts(mmesh)
+ faces = mrmeshnumpy.getNumpyFaces(mmesh.topology)
+
+ del mmesh
+ gc.collect()
+
+ if reorient_vertices == '90 degrees':
+ vertices[:, 1], vertices[:, 2] = vertices[:, 2], -vertices[:, 1]
+ elif reorient_vertices == '-90 degrees':
+ vertices[:, 1], vertices[:, 2] = -vertices[:, 2], vertices[:, 1]
+
+ trimesh = Trimesh.Trimesh(
+ vertices=vertices,
+ faces=faces,
+ process=False
+ )
+
+ filename_prefix_with_nbfaces = f"{filename_prefix}_{target_nbfaces}"
+
+ full_output_folder, filename, counter, subfolder, filename_prefix_with_nbfaces = folder_paths.get_save_image_path(filename_prefix_with_nbfaces, folder_paths.get_output_directory())
+ output_glb_path = Path(full_output_folder, f'{filename}_{counter:05}_.{file_format}')
+ output_glb_path.parent.mkdir(exist_ok=True)
+
+ trimesh.export(output_glb_path, file_type=file_format)
+
+ lst_output_mesh.append(str(output_glb_path))
+
+ del trimesh
+
+ del cumesh
+ del mesh_copy
+
+ return (lst_output_mesh,)
NODE_CLASS_MAPPINGS = {
"Trellis2LoadModel": Trellis2LoadModel,
@@ -2371,6 +2618,8 @@ NODE_CLASS_MAPPINGS = {
"Trellis2MeshWithVoxelToMeshlibMesh": Trellis2MeshWithVoxelToMeshlibMesh,
"Trellis2FillHolesWithMeshlib": Trellis2FillHolesWithMeshlib,
"Trellis2SmoothNormals": Trellis2SmoothNormals,
+ "Trellis2RemeshWithQuad": Trellis2RemeshWithQuad,
+ "Trellis2BatchSimplifyMeshAndExport": Trellis2BatchSimplifyMeshAndExport,
}
@@ -2400,4 +2649,6 @@ NODE_DISPLAY_NAME_MAPPINGS = {
"Trellis2MeshWithVoxelToMeshlibMesh": "Trellis2 - Mesh with Voxel to Meshlib Mesh",
"Trellis2FillHolesWithMeshlib": "Trellis2 - Fill Holes with Meshlib",
"Trellis2SmoothNormals": "Trellis2 - Smooth Normals",
+ "Trellis2RemeshWithQuad": "Trellis2 - Remesh With Quad",
+ "Trellis2BatchSimplifyMeshAndExport": "Trellis2 - Batch Simplify Mesh And Export",
}
diff --git a/pyproject.toml b/pyproject.toml
index ce854db..5cf688c 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,7 +1,7 @@
[project]
name = "trellis2"
description = "ComfyUI Wrapper for Microsoft Trellis.2 - Native and Compact Structured Latents for 3D Generation"
-version = "1.0.4"
+version = "1.0.5"
license = {file = "LICENSE"}
# classifiers = [
# # For OS-independent nodes (works on all operating systems)
diff --git a/trellis2/representations/mesh/base.py b/trellis2/representations/mesh/base.py
index 52d332f..2c28898 100644
--- a/trellis2/representations/mesh/base.py
+++ b/trellis2/representations/mesh/base.py
@@ -130,7 +130,10 @@ class Mesh:
print(f"Reduced faces, resulting in {len(new_vertices)} vertices and {len(new_faces)} faces")
self.vertices = torch.from_numpy(new_vertices).float().to(self.device)
- self.faces = torch.from_numpy(new_faces).int().to(self.device)
+ self.faces = torch.from_numpy(new_faces).int().to(self.device)
+
+ del mesh
+ gc.collect()
class TextureFilterMode:
diff --git a/wheels/Linux/Torch270/cumesh-0.0.1-cp312-cp312-linux_x86_64.whl b/wheels/Linux/Torch270/cumesh-0.0.1-cp312-cp312-linux_x86_64.whl
index 967fcff..cd3e224 100644
Binary files a/wheels/Linux/Torch270/cumesh-0.0.1-cp312-cp312-linux_x86_64.whl and b/wheels/Linux/Torch270/cumesh-0.0.1-cp312-cp312-linux_x86_64.whl differ
diff --git a/wheels/Linux/Torch291/cumesh-0.0.1-cp312-cp312-linux_x86_64.whl b/wheels/Linux/Torch291/cumesh-0.0.1-cp312-cp312-linux_x86_64.whl
index 7950a47..4656a52 100644
Binary files a/wheels/Linux/Torch291/cumesh-0.0.1-cp312-cp312-linux_x86_64.whl and b/wheels/Linux/Torch291/cumesh-0.0.1-cp312-cp312-linux_x86_64.whl differ
diff --git a/wheels/Windows/Torch270/cumesh-0.0.1-cp311-cp311-win_amd64.whl b/wheels/Windows/Torch270/cumesh-0.0.1-cp311-cp311-win_amd64.whl
index fd94e51..208e130 100644
Binary files a/wheels/Windows/Torch270/cumesh-0.0.1-cp311-cp311-win_amd64.whl and b/wheels/Windows/Torch270/cumesh-0.0.1-cp311-cp311-win_amd64.whl differ
diff --git a/wheels/Windows/Torch270/cumesh-0.0.1-cp312-cp312-win_amd64.whl b/wheels/Windows/Torch270/cumesh-0.0.1-cp312-cp312-win_amd64.whl
index e94f853..b6e20e9 100644
Binary files a/wheels/Windows/Torch270/cumesh-0.0.1-cp312-cp312-win_amd64.whl and b/wheels/Windows/Torch270/cumesh-0.0.1-cp312-cp312-win_amd64.whl differ
diff --git a/wheels/Windows/Torch280/cumesh-0.0.1-cp311-cp311-win_amd64.whl b/wheels/Windows/Torch280/cumesh-0.0.1-cp311-cp311-win_amd64.whl
index 0fe860d..df651ad 100644
Binary files a/wheels/Windows/Torch280/cumesh-0.0.1-cp311-cp311-win_amd64.whl and b/wheels/Windows/Torch280/cumesh-0.0.1-cp311-cp311-win_amd64.whl differ
diff --git a/wheels/Windows/Torch280/cumesh-0.0.1-cp312-cp312-win_amd64.whl b/wheels/Windows/Torch280/cumesh-0.0.1-cp312-cp312-win_amd64.whl
index 0ca69a0..43b8c39 100644
Binary files a/wheels/Windows/Torch280/cumesh-0.0.1-cp312-cp312-win_amd64.whl and b/wheels/Windows/Torch280/cumesh-0.0.1-cp312-cp312-win_amd64.whl differ