import sys import os try: import bpy except ImportError: print("[Error] This script must be run inside Blender's python environment.") sys.exit(1) def delete_isolated_components(obj, threshold_vertices=100): # Store original name orig_name = obj.name # Deselect all, select obj, make active bpy.ops.object.select_all(action='DESELECT') obj.select_set(True) bpy.context.view_layer.objects.active = obj # Enter Edit Mode to separate bpy.ops.object.mode_set(mode='EDIT') bpy.ops.mesh.select_all(action='SELECT') # Separate loose parts bpy.ops.mesh.separate(type='LOOSE') # Go back to Object Mode to process parts bpy.ops.object.mode_set(mode='OBJECT') # The separated parts will be named like orig_name.001, orig_name.002, etc. # Collect all mesh objects that are part of this separation separated_objs = [o for o in bpy.context.scene.objects if o.type == 'MESH' and (o.name == orig_name or o.name.startswith(orig_name + "."))] if len(separated_objs) <= 1: print("[Blender] Mesh is already a single component.") return obj print(f"[Blender] Separated into {len(separated_objs)} loose components.") max_verts = max(len(o.data.vertices) for o in separated_objs) keep_objs = [] delete_objs = [] for o in separated_objs: num_verts = len(o.data.vertices) # Keep if it has at least threshold_vertices, or is at least 5% of the largest component size if num_verts >= threshold_vertices or num_verts >= (max_verts * 0.05): keep_objs.append(o) else: delete_objs.append(o) if not keep_objs: largest = max(separated_objs, key=lambda o: len(o.data.vertices)) keep_objs = [largest] delete_objs = [o for o in separated_objs if o != largest] if delete_objs: print(f"[Blender] Deleting {len(delete_objs)} isolated noise components with vertex count < {threshold_vertices}.") bpy.ops.object.select_all(action='DESELECT') for o in delete_objs: o.select_set(True) bpy.ops.object.delete() # Re-join remaining ones bpy.ops.object.select_all(action='DESELECT') for o in keep_objs: o.select_set(True) bpy.context.view_layer.objects.active = keep_objs[0] bpy.ops.object.join() joined_obj = bpy.context.active_object joined_obj.name = orig_name return joined_obj def strip_gltf_extensions(glb_path): # Pass-through: Modern Blender (4.0+) handles WebP and glTF 2.0 natively without binary byte patching. pass def run_blender_cleanup(input_path, output_path, target_faces=8000, remesh_method="cleanup"): print(f"[Blender] Loading model: {input_path} (target_faces={target_faces}, remesh_method={remesh_method})") # 1. Reset scene to factory default empty scene bpy.ops.wm.read_factory_settings(use_empty=True) # 2. Import GLTF/GLB bpy.ops.import_scene.gltf(filepath=input_path) # 3. Locate the imported mesh mesh_objs = [obj for obj in bpy.context.scene.objects if obj.type == 'MESH'] if not mesh_objs: print("[Error] No mesh found in imported GLTF.") sys.exit(1) dirty_obj = mesh_objs[0] dirty_obj.name = "DirtyMesh" print(f"[Blender] Found source mesh: {dirty_obj.name} with {len(dirty_obj.data.polygons)} polygons") # DIAGNOSTIC: Print material and texture details of DirtyMesh print("[Blender] --- DirtyMesh Material Diagnostics ---") for i, mat in enumerate(dirty_obj.data.materials): if not mat: print(f" Material {i}: None") continue print(f" Material {i}: {mat.name} (use_nodes={mat.use_nodes})") if mat.use_nodes: for node in mat.node_tree.nodes: print(f" Node: {node.name} (type={node.type})") if node.type == 'TEX_IMAGE': img = node.image if img: print(f" Image: {img.name}, size={img.size[:]}, filepath='{img.filepath}', has_data={img.has_data}") else: print(" Image: None") for link in mat.node_tree.links: print(f" Link: {link.from_node.name} ({link.from_socket.name}) -> {link.to_node.name} ({link.to_socket.name})") print("[Blender] ---------------------------------------") # Fast non-destructive cleanup option if remesh_method == "cleanup": print("[Blender] Running fast cleanup (merging duplicates and removing isolated parts)...") # 1. Clean up mesh (remove doubles & loose) bpy.ops.object.select_all(action='DESELECT') dirty_obj.select_set(True) bpy.context.view_layer.objects.active = dirty_obj bpy.ops.object.mode_set(mode='EDIT') bpy.ops.mesh.select_all(action='SELECT') bpy.ops.mesh.remove_doubles(threshold=0.001) bpy.ops.mesh.delete_loose() bpy.ops.mesh.normals_make_consistent(inside=False) bpy.ops.object.mode_set(mode='OBJECT') # 2. Delete isolated components dirty_obj = delete_isolated_components(dirty_obj, threshold_vertices=100) # 3. Export directly (retaining original materials/textures) bpy.ops.object.select_all(action='DESELECT') dirty_obj.select_set(True) bpy.context.view_layer.objects.active = dirty_obj print(f"[Blender] Exporting cleaned model to: {output_path}") bpy.ops.export_scene.gltf( filepath=output_path, export_format='GLB', use_selection=True ) # Also export FBX fbx_path = output_path.replace("_clean.glb", ".fbx").replace(".glb", ".fbx") print(f"[Blender] Exporting cleaned model as FBX: {fbx_path}") try: bpy.ops.export_scene.fbx( filepath=fbx_path, use_selection=True, path_mode='COPY', embed_textures=True ) print("[Blender] ✓ FBX export completed successfully.") except Exception as fe: print(f"[Blender] Error: FBX export failed: {fe}") print("[Blender] Fast cleanup completed successfully.") return # 4. Duplicate the mesh to create the clean version bpy.ops.object.select_all(action='DESELECT') dirty_obj.select_set(True) bpy.context.view_layer.objects.active = dirty_obj bpy.ops.object.duplicate(linked=False) clean_obj = bpy.context.active_object clean_obj.name = "CleanMesh" # 5. Clean up initial mesh (remove doubles) bpy.context.view_layer.objects.active = clean_obj bpy.ops.object.mode_set(mode='EDIT') bpy.ops.mesh.select_all(action='SELECT') # Merge close vertices (Merge by distance) bpy.ops.mesh.remove_doubles(threshold=0.001) bpy.ops.mesh.delete_loose() # Recalculate normals outwards bpy.ops.mesh.normals_make_consistent(inside=False) bpy.ops.object.mode_set(mode='OBJECT') # Delete isolated components before remeshing/decimating clean_obj = delete_isolated_components(clean_obj, threshold_vertices=100) # 6. Apply Decimate Modifier or QuadriFlow Remesh if remesh_method == "quadriflow": print("[Blender] Preparing mesh for QuadriFlow (running high-res Voxel Remesh to guarantee manifold geometry)...") try: bpy.context.view_layer.objects.active = clean_obj bbox_size = max(clean_obj.dimensions) voxel_size = max(0.002, bbox_size / 200.0) # clean high-resolution voxel shell clean_obj.data.remesh_voxel_size = voxel_size bpy.ops.object.voxel_remesh() print("[Blender] Voxel Remesh completed successfully.") except Exception as vre: print(f"[Blender] Voxel Remesh preparation warning: {vre}") print(f"[Blender] Running QuadriFlow Remesh to target {target_faces} faces...") try: bpy.context.view_layer.objects.active = clean_obj bpy.ops.object.quadriflow_remesh( use_preserve_sharp=True, use_preserve_boundary=True, target_faces=target_faces ) print(f"[Blender] ✓ QuadriFlow Remesh completed. New polygon count: {len(clean_obj.data.polygons)}") # Verify if it actually worked (if it failed silently, polygon count won't match target) if len(clean_obj.data.polygons) > target_faces * 1.5: raise Exception("Quadriflow failed silently (polygon count was not reduced).") except Exception as qfe: print(f"[Blender] Error: QuadriFlow Remesh failed: {qfe}. Falling back to decimation.") remesh_method = "tris_to_quads" if remesh_method == "tris_to_quads": poly_count_before = len(clean_obj.data.polygons) if poly_count_before > target_faces: ratio = target_faces / poly_count_before print(f"[Blender] Decimating mesh from {poly_count_before} to {target_faces} polygons (ratio={ratio:.4f})...") try: dec_mod = clean_obj.modifiers.new(name="AutoDecimate", type='DECIMATE') dec_mod.ratio = ratio dec_mod.use_symmetry = True dec_mod.symmetry_axis = 'X' bpy.ops.object.modifier_apply(modifier="AutoDecimate") print(f"[Blender] ✓ Decimation completed. New polygon count: {len(clean_obj.data.polygons)}") except Exception as de: print(f"[Blender] Warning: Decimation failed: {de}") else: print(f"[Blender] Mesh already has fewer polygons ({poly_count_before}) than target ({target_faces}). Skipping decimation.") # 7. Convert triangles to quads with high angle threshold (1.57 rad / 90 degrees) # This converts almost 100% of flat areas to quads while keeping the mesh completely watertight and sharp print("[Blender] Converting triangles to quads with high threshold...") try: bpy.ops.object.mode_set(mode='EDIT') bpy.ops.mesh.select_all(action='SELECT') bpy.ops.mesh.tris_convert_to_quads(face_threshold=1.570796, shape_threshold=1.570796) bpy.ops.object.mode_set(mode='OBJECT') print("[Blender] ✓ Conversion to quads completed.") except Exception as tq: print(f"[Blender] Warning: Triangles to quads conversion failed: {tq}") if clean_obj.mode != 'OBJECT': bpy.ops.object.mode_set(mode='OBJECT') # 8. Unwrap UVs on CleanMesh print("[Blender] Unwrapping UV coordinates...") bpy.ops.object.mode_set(mode='EDIT') bpy.ops.mesh.select_all(action='SELECT') bpy.ops.uv.smart_project(island_margin=0.002) bpy.ops.object.mode_set(mode='OBJECT') # 9. Setup Baking Material & Target Image Node print("[Blender] Setting up material for texture projection...") clean_mat = bpy.data.materials.new(name="CleanMaterial") clean_mat.use_nodes = True clean_obj.data.materials.clear() clean_obj.data.materials.append(clean_mat) bake_image = bpy.data.images.new(name="BakedTexture", width=2048, height=2048) nodes = clean_mat.node_tree.nodes texture_node = nodes.new(type='ShaderNodeTexImage') texture_node.image = bake_image texture_node.select = True nodes.active = texture_node # 10. Configure Cycles and Bake from Selected (DirtyMesh) to Active (CleanMesh) print("[Blender] Configuring Cycles baking engine...") bpy.context.scene.render.engine = 'CYCLES' bpy.context.scene.cycles.device = 'CPU' print("[Blender] Bypassing shader nodes for unlit EMIT baking...") for mat in dirty_obj.data.materials: if mat and mat.use_nodes: output_node = next((n for n in mat.node_tree.nodes if n.type == 'OUTPUT_MATERIAL'), None) bsdf_node = next((n for n in mat.node_tree.nodes if n.type == 'BSDF_PRINCIPLED'), None) if output_node and bsdf_node: base_color_input = bsdf_node.inputs['Base Color'] if base_color_input.is_linked: from_node = base_color_input.links[0].from_node from_socket = base_color_input.links[0].from_socket mat.node_tree.links.new(from_socket, output_node.inputs['Surface']) print(f" [Bypass] Linked {from_node.name} directly to Material Output Surface on {mat.name}") bpy.context.scene.cycles.bake_type = 'EMIT' bpy.context.scene.render.bake.use_selected_to_active = True bpy.context.scene.render.bake.margin = 16 bpy.context.scene.render.bake.margin_type = 'EXTEND' bpy.context.scene.render.bake.cage_extrusion = 0.02 bpy.context.scene.render.bake.max_ray_distance = 0.05 # Select DirtyMesh and CleanMesh, making CleanMesh active bpy.ops.object.select_all(action='DESELECT') dirty_obj.select_set(True) clean_obj.select_set(True) bpy.context.view_layer.objects.active = clean_obj print("[Blender] Projecting (baking) texture map using EMIT. Please wait...") try: bpy.ops.object.bake(type='EMIT') print("[Blender] ✓ Texture projection completed successfully.") bake_image.pack() # Link texture node outputs to material shader inputs bsdf_node = next(n for n in nodes if n.type == 'BSDF_PRINCIPLED') clean_mat.node_tree.links.new(texture_node.outputs['Color'], bsdf_node.inputs['Base Color']) except Exception as be: print(f"[Blender] Error: Texture baking failed: {be}") # 10. Delete the original dirty mesh before export bpy.ops.object.select_all(action='DESELECT') dirty_obj.select_set(True) bpy.ops.object.delete() # 11. Export CleanMesh as GLB (preserving original textures and materials) bpy.ops.object.select_all(action='DESELECT') clean_obj.select_set(True) bpy.context.view_layer.objects.active = clean_obj print(f"[Blender] Exporting clean model to: {output_path}") bpy.ops.export_scene.gltf( filepath=output_path, export_format='GLB', use_selection=True ) # 12. Also export as FBX if output_path.endswith("_clean.glb"): fbx_path = output_path[:-10] + ".fbx" elif output_path.endswith(".glb"): fbx_path = output_path[:-4] + ".fbx" else: fbx_path = output_path + ".fbx" print(f"[Blender] Exporting clean model as FBX: {fbx_path}") try: bpy.ops.export_scene.fbx( filepath=fbx_path, use_selection=True, path_mode='COPY', embed_textures=True ) print("[Blender] ✓ FBX export completed successfully.") except Exception as fe: print(f"[Blender] Error: FBX export failed: {fe}") print("[Blender] Process completed successfully.") if __name__ == "__main__": # Extract arguments passed after '--' try: args_idx = sys.argv.index("--") args = sys.argv[args_idx + 1:] except ValueError: args = [] if len(args) < 2: print("[Error] Usage: blender --background --python clean_mesh_blender.py -- [target_faces] [remesh_method]") sys.exit(1) target_faces = 60000 if len(args) >= 3: try: target_faces = int(args[2]) except ValueError: pass remesh_method = "cleanup" if len(args) >= 4: remesh_method = args[3] run_blender_cleanup(args[0], args[1], target_faces, remesh_method)