diff --git a/Gen_3D_Modules/PartCrafter/partcrafter_src/pipelines/pipeline_partcrafter.py b/Gen_3D_Modules/PartCrafter/partcrafter_src/pipelines/pipeline_partcrafter.py index 2c6794b..df8bc92 100755 --- a/Gen_3D_Modules/PartCrafter/partcrafter_src/pipelines/pipeline_partcrafter.py +++ b/Gen_3D_Modules/PartCrafter/partcrafter_src/pipelines/pipeline_partcrafter.py @@ -337,7 +337,7 @@ class PartCrafterPipeline(DiffusionPipeline, TransformerDiffusionMixin): max_num_expanded_coords=max_num_expanded_coords, # verbose=True ) - mesh = trimesh.Trimesh(mesh_v_f[0].astype(np.float32), mesh_v_f[1]) + mesh = trimesh.Trimesh(mesh_v_f[0].astype(np.float32), mesh_v_f[1], process=True) except: mesh_v_f = None mesh = None diff --git a/Gen_3D_Modules/PartCrafter/partcrafter_src/utils/render_utils.py b/Gen_3D_Modules/PartCrafter/partcrafter_src/utils/render_utils.py index fde6d15..ed0695d 100644 --- a/Gen_3D_Modules/PartCrafter/partcrafter_src/utils/render_utils.py +++ b/Gen_3D_Modules/PartCrafter/partcrafter_src/utils/render_utils.py @@ -10,9 +10,60 @@ from diffusers.utils import export_to_video from diffusers.utils.loading_utils import load_video import torch from torchvision.utils import make_grid +import math os.environ['PYOPENGL_PLATFORM'] = 'egl' +def explode_mesh(mesh, explosion_scale=0.4): + # ensure we have a Scene + if isinstance(mesh, trimesh.Trimesh): + scene = trimesh.Scene(mesh) + elif isinstance(mesh, trimesh.Scene): + scene = mesh + else: + raise ValueError(f"Expected Trimesh or Scene, got {type(mesh)}") + + if len(scene.geometry) <= 1: + print("Nothing to explode") + return scene + + # 1) collect (name, geom, world_center) + parts = [] + for name, geom in scene.geometry.items(): + # ← get(name) returns (4×4 world‐space matrix, parent_frame) + world_tf, _ = scene.graph.get(name) + pts = trimesh.transformations.transform_points(geom.vertices, world_tf) + center = pts.mean(axis=0) + parts.append((name, geom, center)) + + # compute global center + all_centers = np.stack([c for _,_,c in parts], axis=0) + global_center = all_centers.mean(axis=0) + + exploded = trimesh.Scene() + for name, geom, center in parts: + dir_vec = center - global_center + norm = np.linalg.norm(dir_vec) + if norm < 1e-6: + dir_vec = np.random.randn(3) + dir_vec /= np.linalg.norm(dir_vec) + else: + dir_vec /= norm + + offset = dir_vec * explosion_scale + + # fetch the same 4×4, then bump just the translation + world_tf, _ = scene.graph.get(name) + world_tf = world_tf.copy() + world_tf[:3, 3] += offset + + exploded.add_geometry(geom, transform=world_tf, geom_name=name) + print(f"[explode] {name} moved by {np.linalg.norm(offset):.4f}") + + return exploded + + + def render( scene: pyrender.Scene, renderer: pyrender.Renderer, @@ -123,13 +174,22 @@ def render_views_around_mesh( Tuple[List[Image.Image], List[Image.Image]], Tuple[List[np.ndarray], List[np.ndarray]] ]: + + meshes = [] + scenes = [] if not isinstance(mesh, (trimesh.Trimesh, trimesh.Scene)): raise ValueError("mesh must be a trimesh.Trimesh or trimesh.Scene object") if isinstance(mesh, trimesh.Trimesh): - mesh = trimesh.Scene(mesh) - - scene = pyrender.Scene.from_trimesh_scene(mesh) + for i in range(num_views): + scenes.append(pyrender.Scene.from_trimesh_scene(trimesh.Scene(mesh))) + else: + for i in range(num_views): + value = math.sin(math.pi * (i - 1) / num_views) + scenes.append(pyrender.Scene.from_trimesh_scene(explode_mesh(mesh, 0.2 * value), + ambient_light=[0.02, 0.02, 0.02], + bg_color=[0.0, 0.0, 0.0, 1.0])) + light = pyrender.DirectionalLight( color=np.ones(3), intensity=light_intensity @@ -149,9 +209,9 @@ def render_views_around_mesh( ) images, depths = [], [] - for pose in camera_poses: + for i, pose in enumerate(camera_poses): image, depth = render( - scene, renderer, camera, pose, light, + scenes[i], renderer, camera, pose, light, normalize_depth=normalize_depth, flags=flags, return_type=return_type diff --git a/_Example_Workflows/PartCrafter.json b/_Example_Workflows/PartCrafter.json index 0f84224..fccdf30 100644 --- a/_Example_Workflows/PartCrafter.json +++ b/_Example_Workflows/PartCrafter.json @@ -1,84 +1,22 @@ { "id": "8fb4df3a-d3bc-4b42-95f2-434fb48a8992", "revision": 0, - "last_node_id": 9, - "last_link_id": 7, + "last_node_id": 12, + "last_link_id": 12, "nodes": [ - { - "id": 1, - "type": "[Comfy3D] Load PartCrafter Pipeline", - "pos": [ - 2233.307861328125, - 1132.6119384765625 - ], - "size": [ - 197.09921264648438, - 26 - ], - "flags": {}, - "order": 0, - "mode": 0, - "inputs": [], - "outputs": [ - { - "name": "partcrafter_pipe", - "type": "DIFFUSERS_PIPE", - "links": [ - 1 - ] - } - ], - "properties": { - "Node name for S&R": "[Comfy3D] Load PartCrafter Pipeline" - }, - "widgets_values": [] - }, - { - "id": 8, - "type": "[Comfy3D] Preview 3DMesh", - "pos": [ - 3333.187744140625, - 1183.9188232421875 - ], - "size": [ - 600, - 500 - ], - "flags": {}, - "order": 6, - "mode": 0, - "inputs": [ - { - "name": "mesh_file_path", - "type": "STRING", - "widget": { - "name": "mesh_file_path" - }, - "link": 6 - } - ], - "outputs": [], - "properties": { - "Node name for S&R": "[Comfy3D] Preview 3DMesh" - }, - "widgets_values": [ - "", - null - ] - }, { "id": 3, "type": "LoadImage", "pos": [ - 2161.17333984375, - 1232.0296630859375 + 2166.6142578125, + 1234.06982421875 ], "size": [ 353.18182373046875, 489.7575378417969 ], "flags": {}, - "order": 1, + "order": 0, "mode": 0, "inputs": [], "outputs": [ @@ -105,12 +43,101 @@ "image" ] }, + { + "id": 1, + "type": "[Comfy3D] Load PartCrafter Pipeline", + "pos": [ + 2166.07470703125, + 1154.1156005859375 + ], + "size": [ + 346.0396423339844, + 28.720361709594727 + ], + "flags": {}, + "order": 1, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "partcrafter_pipe", + "type": "DIFFUSERS_PIPE", + "links": [ + 1 + ] + } + ], + "properties": { + "Node name for S&R": "[Comfy3D] Load PartCrafter Pipeline" + }, + "widgets_values": [] + }, + { + "id": 12, + "type": "[Comfy3D] Preview 3DMesh", + "pos": [ + 2927.549072265625, + 1245.26904296875 + ], + "size": [ + 600, + 500 + ], + "flags": {}, + "order": 4, + "mode": 0, + "inputs": [ + { + "name": "mesh_file_path", + "type": "STRING", + "widget": { + "name": "mesh_file_path" + }, + "link": 12 + } + ], + "outputs": [], + "properties": { + "Node name for S&R": "[Comfy3D] Preview 3DMesh" + }, + "widgets_values": [ + "", + null + ] + }, + { + "id": 9, + "type": "PreviewAny", + "pos": [ + 2927.620361328125, + 1096.464111328125 + ], + "size": [ + 342.048828125, + 88 + ], + "flags": {}, + "order": 3, + "mode": 0, + "inputs": [ + { + "name": "source", + "type": "*", + "link": 7 + } + ], + "outputs": [], + "properties": { + "Node name for S&R": "PreviewAny" + }, + "widgets_values": [] + }, { "id": 2, "type": "[Comfy3D] PartCrafter Generate", "pos": [ - 2550.86669921875, - 1193.7396240234375 + 2569.100341796875, + 1241.7021484375 ], "size": [ 318.76953125, @@ -140,18 +167,16 @@ ] }, { - "name": "merged_mesh", - "type": "MESH", + "name": "glb_mesh_path", + "type": "STRING", "links": [ - 5 + 12 ] }, { "name": "processed_image", "type": "IMAGE", - "links": [ - 3 - ] + "links": [] } ], "properties": { @@ -159,108 +184,16 @@ }, "widgets_values": [ 4, - 228764490511818, + 836283434168025, "randomize", 1024, 50, 7, 1000000000, false, - false, + true, 2 ] - }, - { - "id": 9, - "type": "PreviewAny", - "pos": [ - 2912.02490234375, - 1048.1806640625 - ], - "size": [ - 350.2099609375, - 88 - ], - "flags": {}, - "order": 3, - "mode": 0, - "inputs": [ - { - "name": "source", - "type": "*", - "link": 7 - } - ], - "outputs": [], - "properties": { - "Node name for S&R": "PreviewAny" - }, - "widgets_values": [] - }, - { - "id": 7, - "type": "[Comfy3D] Save 3D Mesh", - "pos": [ - 2933.991943359375, - 1229.566162109375 - ], - "size": [ - 315, - 58 - ], - "flags": {}, - "order": 4, - "mode": 0, - "inputs": [ - { - "name": "mesh", - "type": "MESH", - "link": 5 - } - ], - "outputs": [ - { - "name": "save_path", - "type": "STRING", - "slot_index": 0, - "links": [ - 6 - ] - } - ], - "properties": { - "Node name for S&R": "[Comfy3D] Save 3D Mesh" - }, - "widgets_values": [ - "PartCrafter_Test/mesh.glb" - ] - }, - { - "id": 5, - "type": "PreviewImage", - "pos": [ - 2928.69384765625, - 1368.6041259765625 - ], - "size": [ - 321.450439453125, - 394.6763916015625 - ], - "flags": {}, - "order": 5, - "mode": 0, - "inputs": [ - { - "name": "images", - "type": "IMAGE", - "link": 3 - } - ], - "outputs": [], - "properties": { - "Node name for S&R": "PreviewImage" - }, - "widgets_values": [] } ], "links": [ @@ -280,30 +213,6 @@ 1, "IMAGE" ], - [ - 3, - 2, - 2, - 5, - 0, - "IMAGE" - ], - [ - 5, - 2, - 1, - 7, - 0, - "MESH" - ], - [ - 6, - 7, - 0, - 8, - 0, - "STRING" - ], [ 7, 2, @@ -311,23 +220,31 @@ 9, 0, "*" + ], + [ + 12, + 2, + 1, + 12, + 0, + "STRING" ] ], "groups": [], "config": {}, "extra": { "ds": { - "scale": 0.9070294784580498, + "scale": 1.0292706480615603, "offset": [ - -2140.713805878563, - -975.661308705606 + -2024.039719890923, + -1063.3672924124076 ] }, - "node_versions": { - "ComfyUI-3D-Pack": "aedee80e370321bd78221b086445fe8a5f2f8906", - "comfy-core": "0.3.44" - }, "frontendVersion": "1.23.4", + "node_versions": { + "comfy-core": "0.3.44", + "ComfyUI-3D-Pack": "6fa0039ad586bdc6bc4f43af6be92ea3ebd479cb" + }, "VHS_latentpreview": false, "VHS_latentpreviewrate": 0, "VHS_MetadataImage": true, diff --git a/_Example_Workflows/_Example_Inputs_Files/robot.png b/_Example_Workflows/_Example_Inputs_Files/robot.png new file mode 100644 index 0000000..111fb80 Binary files /dev/null and b/_Example_Workflows/_Example_Inputs_Files/robot.png differ diff --git a/nodes.py b/nodes.py index 9930720..2d1b55b 100644 --- a/nodes.py +++ b/nodes.py @@ -126,7 +126,7 @@ from Gen_3D_Modules.Hunyuan3D_2_1.hy3dpaint.utils.torchvision_fix import apply_f apply_fix() from Gen_3D_Modules.PartCrafter.partcrafter_src.pipelines.pipeline_partcrafter import PartCrafterPipeline from Gen_3D_Modules.PartCrafter.partcrafter_src.utils.data_utils import get_colored_mesh_composition -from Gen_3D_Modules.PartCrafter.partcrafter_src.utils.image_utils import prepare_image +from Gen_3D_Modules.PartCrafter.partcrafter_src.utils.render_utils import explode_mesh import zipfile @@ -262,12 +262,15 @@ class Preview_3DMesh: mesh_folder_path, filename = os.path.split(mesh_file_path) if not os.path.isabs(mesh_file_path): - mesh_file_path = os.path.join(comfy_paths.output_directory, mesh_folder_path) + mesh_file_path = os.path.join(comfy_paths.output_directory, mesh_folder_path, filename) if not filename.lower().endswith(SUPPORTED_3D_EXTENSIONS): cstr(f"[{self.__class__.__name__}] File name {filename} does not end with supported 3D file extensions: {SUPPORTED_3D_EXTENSIONS}").error.print() mesh_file_path = "" + print(f"[Preview_3DMesh] Final mesh path: {mesh_file_path}") + print(f"[Preview_3DMesh] File exists: {os.path.exists(mesh_file_path) if mesh_file_path else False}") + previews = [ { "filepath": mesh_file_path, @@ -5584,8 +5587,7 @@ class Hunyuan3D_21_TexGen: except: pass - - +# --------------------- PARTCRAFTER --------------------- class Load_PartCrafter_Pipeline: """Load PartCrafter Pipeline""" @@ -5644,12 +5646,13 @@ class Load_PartCrafter_Pipeline: print(f"PartCrafter pipeline loaded") return (pipeline,) + class PartCrafter_Generate: - """PartCrafter Generation""" + """PartCrafter Generation - Creates multi-part 3D scenes with colored components""" CATEGORY = "Comfy3D/Algorithm/PartCrafter" - RETURN_TYPES = ("STRING", "MESH", "IMAGE") - RETURN_NAMES = ("parts_zip_path", "merged_mesh", "processed_image") + RETURN_TYPES = ("STRING", "STRING", "IMAGE") + RETURN_NAMES = ("parts_zip_path", "glb_mesh_path", "processed_image") FUNCTION = "generate" @classmethod @@ -5704,37 +5707,38 @@ class PartCrafter_Generate: use_flash_decoder=use_flash_decoder, ).meshes - # Process results - for i in range(len(outputs)): - if outputs[i] is None: - # If mesh is None (decoding error), use dummy mesh - outputs[i] = trimesh.Trimesh(vertices=[[0, 0, 0]], faces=[[0, 0, 0]]) + # Ensure no None outputs + for i, mesh in enumerate(outputs): + if mesh is None: + outputs[i] = trimesh.Trimesh(vertices=[[0,0,0]], faces=[[0,0,0]]) + print(f"Replaced None mesh at index {i} with dummy mesh") - # Create merged mesh merged_mesh_trimesh = get_colored_mesh_composition(outputs) + split_mesh = explode_mesh(merged_mesh_trimesh) # Debug logging - print(f"Merged mesh type: {type(merged_mesh_trimesh)}") - print(f"Merged mesh attributes: {dir(merged_mesh_trimesh)}") + print(f"PartCrafter result type: {type(merged_mesh_trimesh)}") if hasattr(merged_mesh_trimesh, 'geometry'): - print(f"Scene geometry keys: {list(merged_mesh_trimesh.geometry.keys())}") + print(f"Scene contains {len(merged_mesh_trimesh.geometry)} parts: {list(merged_mesh_trimesh.geometry.keys())}") # Create ZIP with individual parts parts_output_dir = "output/partcrafter_parts" os.makedirs(parts_output_dir, exist_ok=True) - zip_path = os.path.join(parts_output_dir, f"parts_seed_{seed}.zip") + zip_path = os.path.join(parts_output_dir, f"parts.zip") + parts = [] with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zipf: - for i, part in enumerate(outputs): - print(f"Part {i} type: {type(part)}") - if part is not None: + for idx, mesh in enumerate(outputs): + print(f"Part {idx} type: {type(mesh)}") + if mesh is not None: # Save part as GLB file - part_path = os.path.join(parts_output_dir, f"part_{i:02d}.glb") - part.export(part_path) + part_path = os.path.join(parts_output_dir, f"part_{idx:02d}.glb") + mesh.export(part_path) + parts.append(part_path) # Add to ZIP - zipf.write(part_path, f"part_{i:02d}.glb") + zipf.write(part_path, f"part_{idx:02d}.glb") # Clean up temporary file try: @@ -5742,56 +5746,48 @@ class PartCrafter_Generate: except: pass - print(f"Created parts ZIP: {zip_path}") + print(f"Created parts ZIP: {zip_path} with {len(parts)} parts") - # Handle Scene vs Mesh for merged result + # Save merged mesh + scene_output_dir = "output/partcrafter_scenes" + os.makedirs(scene_output_dir, exist_ok=True) + scene_file_path = os.path.join(scene_output_dir, f"scene.glb") + + # For Preview_3DMesh, return relative path from ComfyUI output directory + relative_scene_path = "partcrafter_scenes/scene.glb" + + # Export the merged mesh (same as app.py: merged.export(merged_path)) + merged_mesh_trimesh.export(scene_file_path) + print(f"Saved merged colored mesh to: {scene_file_path}") + + # Debug: check what was saved if isinstance(merged_mesh_trimesh, trimesh.Scene): - print("Merged result is Scene, combining all geometries") - if merged_mesh_trimesh.geometry: - # Combine all geometries into one mesh - all_meshes = [] - for geom_name in merged_mesh_trimesh.geometry: - geom = merged_mesh_trimesh.geometry[geom_name] - print(f"Geometry {geom_name}: {type(geom)}, vertices: {len(geom.vertices)}, faces: {len(geom.faces)}") - all_meshes.append(geom) - - # Combine all meshes into one - if len(all_meshes) > 1: - combined_mesh = trimesh.util.concatenate(all_meshes) - print(f"Combined mesh: vertices: {len(combined_mesh.vertices)}, faces: {len(combined_mesh.faces)}") - else: - combined_mesh = all_meshes[0] - - merged_mesh = Mesh.load_trimesh(given_mesh=combined_mesh) - else: - print("Scene has no geometry, creating dummy mesh") - # Create a dummy mesh if scene is empty - dummy_trimesh = trimesh.Trimesh(vertices=[[0, 0, 0]], faces=[[0, 0, 0]]) - merged_mesh = Mesh.load_trimesh(given_mesh=dummy_trimesh) + print(f"Exported Scene with {len(merged_mesh_trimesh.geometry)} parts") + for geom_name in merged_mesh_trimesh.geometry: + geom = merged_mesh_trimesh.geometry[geom_name] + print(f" Part {geom_name}: vertices: {len(geom.vertices)}, faces: {len(geom.faces)}") + if hasattr(geom.visual, 'vertex_colors') and geom.visual.vertex_colors is not None: + print(f" Has vertex colors: {geom.visual.vertex_colors.shape}") else: - print("Merged result is Mesh, loading directly") - merged_mesh = Mesh.load_trimesh(given_mesh=merged_mesh_trimesh) + print(f"Exported single mesh: vertices: {len(merged_mesh_trimesh.vertices)}, faces: {len(merged_mesh_trimesh.faces)}") - merged_mesh.auto_normal() - - # Save debug merged result + # Debug info about the scene try: - debug_dir = "output/partcrafter_debug" - os.makedirs(debug_dir, exist_ok=True) - - # Save merged result for debugging - if isinstance(merged_mesh_trimesh, trimesh.Scene): - merged_mesh_trimesh.export(os.path.join(debug_dir, f"merged_scene_seed_{seed}.glb")) + print(f"Scene file saved successfully: {scene_file_path}") + if os.path.exists(scene_file_path): + file_size = os.path.getsize(scene_file_path) / (1024 * 1024) # MB + print(f"Scene file size: {file_size:.2f} MB") + print(f"Relative path for Preview_3DMesh: {relative_scene_path}") else: - merged_mesh_trimesh.export(os.path.join(debug_dir, f"merged_mesh_seed_{seed}.obj")) - - print(f"Debug merged file saved to {debug_dir}") + print(f"Warning: Scene file was not created properly") except Exception as e: - print(f"Failed to save debug files: {e}") + print(f"Error checking scene file: {e}") # Processed image processed_image_tensor = pils_to_torch_imgs([pil_image]) - print(f"Generated {len(outputs)} parts") + print(f"PartCrafter: Generated {len(outputs)} parts with explode_mesh processing") + print(f"GLB mesh path for Preview_3DMesh: {relative_scene_path}") - return (zip_path, merged_mesh, processed_image_tensor) + return (zip_path, relative_scene_path, processed_image_tensor) +