diff --git a/skrobot/apps/convert_urdf_mesh.py b/skrobot/apps/convert_urdf_mesh.py index 54af3f55..58b20223 100644 --- a/skrobot/apps/convert_urdf_mesh.py +++ b/skrobot/apps/convert_urdf_mesh.py @@ -86,6 +86,17 @@ def main(): '--blender-voxel-size', type=float, default=0.002, help='Voxel size for Blender remeshing. Smaller values create more ' 'detailed meshes. Default is 0.002. Only used with --blender-remesh.') + parser.add_argument( + '--blender-decimate', action='store_true', + help='Use Blender decimate (collapse) modifier to reduce the triangle ' + 'count while preserving shape and colors. Unlike --blender-remesh, ' + 'this keeps the original topology and works on Blender 5.x ' + '(meshes are exchanged via glTF). Requires Blender to be installed.') + parser.add_argument( + '--blender-decimate-ratio', type=float, default=0.1, + help='Collapse ratio for Blender decimation. The resulting triangle ' + 'count is approximately this fraction of the original. Must be in ' + '(0, 1]. Default is 0.1. Only used with --blender-decimate.') parser.add_argument( '--blender-executable', type=str, default=None, help='Path to Blender executable. If not specified, automatically ' @@ -204,6 +215,8 @@ def nullcontext(enter_result=None): collision_mesh_format='.' + args.collision_mesh_format, blender_remesh=args.blender_remesh, blender_voxel_size=args.blender_voxel_size, + blender_decimate=args.blender_decimate, + blender_decimate_ratio=args.blender_decimate_ratio, blender_executable=args.blender_executable, remeshed_suffix=args.remeshed_suffix, draco_compression=args.draco), apply_scale(args.scale): diff --git a/skrobot/utils/_blender_decimate_core.py b/skrobot/utils/_blender_decimate_core.py new file mode 100644 index 00000000..a7898795 --- /dev/null +++ b/skrobot/utils/_blender_decimate_core.py @@ -0,0 +1,81 @@ +""" +Core Blender decimation functions. +This module is designed to be executed within Blender's Python environment. + +Unlike the voxel remesher, decimation (collapse) reduces the triangle count +while preserving the original shape, materials and vertex colors. The mesh I/O +is done in glTF/glb format because Blender 5.x no longer ships the Collada +(``.dae``) importer/exporter; glTF is supported natively on every Blender +version that includes ``bpy`` and round-trips materials and vertex colors. +""" +from pathlib import Path + +import bpy + + +def clear_scene(): + """Clear all objects from the scene.""" + if bpy.ops.object.mode_set.poll(): + bpy.ops.object.mode_set(mode='OBJECT') + bpy.ops.object.select_all(action='SELECT') + bpy.ops.object.delete() + + +def decimate_glb_file(input_path, output_path, ratio=0.1): + """Import a glb, apply the decimate (collapse) modifier and re-export glb. + + Parameters + ---------- + input_path : str or pathlib.Path + Path to the input ``.glb`` file. + output_path : str or pathlib.Path + Path to the output ``.glb`` file. + ratio : float, optional + Collapse ratio passed to Blender's decimate modifier. The resulting + triangle count is approximately ``ratio`` times the original. Must be + in the range ``(0, 1]``. Default is 0.1. + """ + input_path = Path(input_path) + output_path = Path(output_path) + + clear_scene() + + bpy.ops.import_scene.gltf(filepath=str(input_path)) + + objs = [o for o in bpy.context.scene.objects if o.type == 'MESH'] + if not objs: + print("Warning: No mesh objects found in input") + return + before = sum(len(o.data.polygons) for o in objs) + + # Join all mesh objects so a single decimate modifier covers the whole link. + bpy.ops.object.select_all(action='DESELECT') + for o in objs: + o.select_set(True) + # Always set the active object explicitly; with a single imported mesh + # the active object would otherwise be None and join() is skipped. + bpy.context.view_layer.objects.active = objs[0] + if len(objs) > 1: + bpy.ops.object.join() + obj = bpy.context.active_object + + mod = obj.modifiers.new(name="Decimate", type='DECIMATE') + mod.decimate_type = 'COLLAPSE' + mod.ratio = ratio + mod.use_collapse_triangulate = True + bpy.ops.object.modifier_apply(modifier=mod.name) + + after = len(obj.data.polygons) + print(f"Decimation complete. Faces: {before} -> {after} (ratio={ratio})") + + if obj.data.polygons: + obj.data.polygons.foreach_set( + 'use_smooth', [True] * len(obj.data.polygons)) + + bpy.context.view_layer.objects.active = obj + obj.select_set(True) + bpy.ops.export_scene.gltf( + filepath=str(output_path), + use_selection=True, + export_format='GLB', + ) diff --git a/skrobot/utils/blender_mesh.py b/skrobot/utils/blender_mesh.py index 9342f13a..0414d56a 100644 --- a/skrobot/utils/blender_mesh.py +++ b/skrobot/utils/blender_mesh.py @@ -242,6 +242,158 @@ def remesh_with_blender( return remeshed_mesh +def decimate_with_blender( + mesh, + ratio=0.1, + blender_executable=None, + verbose=False): + """Decimate a mesh using Blender's decimate (collapse) modifier. + + Unlike :func:`remesh_with_blender`, this reduces the triangle count while + preserving the original shape, materials and vertex colors. The mesh is + exchanged with Blender in glTF/glb format (Blender 5.x no longer ships the + Collada importer/exporter), so colors round-trip as glTF materials/vertex + colors. + + Parameters + ---------- + mesh : trimesh.Trimesh or trimesh.Scene or list of trimesh.Trimesh or str or pathlib.Path + Mesh to be decimated. Can be a trimesh object, a list of trimesh + objects, a :class:`trimesh.Scene`, or a path to a mesh file. + ratio : float, optional + Collapse ratio passed to Blender's decimate modifier. The resulting + triangle count is approximately ``ratio`` times the original. Must be + in the range ``(0, 1]``. Default is 0.1. + blender_executable : str, optional + Path to Blender executable. If None, automatically searches for Blender + in common installation locations. Default is None. + verbose : bool, optional + Whether to print progress information. Default is False. + + Returns + ------- + list of trimesh.Trimesh + Decimated meshes with preserved colors. + + Raises + ------ + RuntimeError + If Blender decimation fails or the output file is not created. + """ + from pathlib import Path + import subprocess + import tempfile + + trimesh = _lazy_trimesh() + + if not 0.0 < ratio <= 1.0: + raise ValueError( + "ratio must be in the range (0, 1], got {}".format(ratio)) + + # Auto-detect Blender if not specified + if blender_executable is None: + blender_executable = _find_blender_executable() + if blender_executable is None: + raise RuntimeError( + "Blender executable not found. Please install Blender or " + "specify the path using the blender_executable parameter." + ) + if verbose: + print(f"Found Blender at: {blender_executable}") + + # Normalize the input into a trimesh.Scene so it can be exported to glb. + if isinstance(mesh, (str, Path)): + scene = trimesh.load(mesh, force='scene') + elif isinstance(mesh, trimesh.Scene): + scene = mesh + elif isinstance(mesh, (list, tuple)): + scene = trimesh.Scene(list(mesh)) + else: + scene = trimesh.Scene(mesh) + + with tempfile.TemporaryDirectory() as temp_dir: + temp_path = Path(temp_dir) + input_glb = temp_path / 'input.glb' + output_glb = temp_path / 'output.glb' + scene.export(str(input_glb)) + + script_path = temp_path / 'decimate_script.py' + _create_blender_decimate_script( + script_path, input_glb, output_glb, ratio) + + cmd = [ + blender_executable, + '--background', + '--python', str(script_path), + ] + + if verbose: + print(f"Running Blender decimate with ratio={ratio}...") + + result = subprocess.run(cmd, capture_output=not verbose, text=True) + + if result.returncode != 0: + error_msg = ( + "Blender decimation failed with return code " + f"{result.returncode}") + if not verbose and result.stderr: + error_msg += f"\nError: {result.stderr}" + raise RuntimeError(error_msg) + + if not output_glb.exists(): + raise RuntimeError( + f"Decimated output file not found: {output_glb}") + + decimated = trimesh.load(str(output_glb), force='scene') + + geometries = [g for g in decimated.dump() + if isinstance(g, trimesh.Trimesh)] + if not geometries: + raise RuntimeError("Decimated mesh contains no Trimesh geometry") + + if verbose: + total_faces = sum(len(g.faces) for g in geometries) + print(f"Decimation complete. Output faces: {total_faces}") + + return geometries + + +def _create_blender_decimate_script(script_path, input_path, output_path, ratio): + """Create a wrapper script that imports the core Blender decimate module. + + Parameters + ---------- + script_path : pathlib.Path + Path where the wrapper script will be written. + input_path : pathlib.Path + Path to the input glb file. + output_path : pathlib.Path + Path to the output glb file. + ratio : float + Collapse ratio for decimation. + """ + from pathlib import Path + + core_module_path = Path(__file__).parent / '_blender_decimate_core.py' + + script_content = f"""import sys +from pathlib import Path + +core_module_path = Path(r"{core_module_path}") +sys.path.insert(0, str(core_module_path.parent)) + +from _blender_decimate_core import decimate_glb_file + +input_path = Path(r"{input_path}") +output_path = Path(r"{output_path}") +ratio = {ratio} + +decimate_glb_file(input_path, output_path, ratio) +""" + + script_path.write_text(script_content) + + def _create_blender_wrapper_script(script_path, input_path, output_path, voxel_size, export_format): """Create a wrapper script that imports the core Blender remeshing module. diff --git a/skrobot/utils/urdf.py b/skrobot/utils/urdf.py index 8d7cf9d2..cd946f91 100644 --- a/skrobot/utils/urdf.py +++ b/skrobot/utils/urdf.py @@ -53,6 +53,8 @@ 'scale_factor': 1.0, 'blender_remesh': False, 'blender_voxel_size': 0.002, + 'blender_decimate': False, + 'blender_decimate_ratio': 0.1, 'blender_executable': None, '_current_geometry_context': None, # 'collision' or 'visual' '_source_urdf_path': None, # Original URDF file path for mesh resolution @@ -85,6 +87,8 @@ def export_mesh_format( collision_mesh_format=None, blender_remesh=False, blender_voxel_size=0.002, + blender_decimate=False, + blender_decimate_ratio=0.1, blender_executable=None, remeshed_suffix='_remeshed', draco_compression=False): @@ -99,6 +103,8 @@ def export_mesh_format( _CONFIGURABLE_VALUES["overwrite_mesh"] = overwrite_mesh _CONFIGURABLE_VALUES["blender_remesh"] = blender_remesh _CONFIGURABLE_VALUES["blender_voxel_size"] = blender_voxel_size + _CONFIGURABLE_VALUES["blender_decimate"] = blender_decimate + _CONFIGURABLE_VALUES["blender_decimate_ratio"] = blender_decimate_ratio _CONFIGURABLE_VALUES["blender_executable"] = blender_executable _CONFIGURABLE_VALUES["remeshed_suffix"] = remeshed_suffix _CONFIGURABLE_VALUES["draco_compression"] = draco_compression @@ -113,6 +119,8 @@ def export_mesh_format( _CONFIGURABLE_VALUES["overwrite_mesh"] = False _CONFIGURABLE_VALUES["blender_remesh"] = False _CONFIGURABLE_VALUES["blender_voxel_size"] = 0.002 + _CONFIGURABLE_VALUES["blender_decimate"] = False + _CONFIGURABLE_VALUES["blender_decimate_ratio"] = 0.1 _CONFIGURABLE_VALUES["blender_executable"] = None _CONFIGURABLE_VALUES["remeshed_suffix"] = '_remeshed' _CONFIGURABLE_VALUES["draco_compression"] = False @@ -1254,6 +1262,18 @@ def _to_xml(self, parent, path): # Mark as processed _REMESHED_FILES_CACHE[cache_key] = str(remeshed_path) + # Apply Blender decimation (collapse) if requested. This reduces the + # triangle count while preserving shape and colors, and works on + # Blender 5.x because the mesh is exchanged in glb (not Collada). + if _CONFIGURABLE_VALUES['blender_decimate'] and not blender_remesh_applied: + from skrobot.utils.blender_mesh import decimate_with_blender + meshes = decimate_with_blender( + meshes, + ratio=_CONFIGURABLE_VALUES['blender_decimate_ratio'], + blender_executable=_CONFIGURABLE_VALUES['blender_executable'], + verbose=True, + ) + if _CONFIGURABLE_VALUES["target_triangles"] is not None: from skrobot.utils.mesh import auto_simplify_quadric_decimation_with_texture_preservation meshes = auto_simplify_quadric_decimation_with_texture_preservation( diff --git a/tests/skrobot_tests/test_console_scripts.py b/tests/skrobot_tests/test_console_scripts.py index 11a86cd0..9a4e75a8 100644 --- a/tests/skrobot_tests/test_console_scripts.py +++ b/tests/skrobot_tests/test_console_scripts.py @@ -76,12 +76,17 @@ def test_convert_urdf_mesh(self): if _find_blender_executable() is not None: out_blender_dae = osp.join(urdf_dir, 'fetch_blender.urdf') out_blender_stl = osp.join(urdf_dir, 'fetch_blender_stl.urdf') + out_blender_decimate = osp.join( + urdf_dir, 'fetch_blender_decimate.urdf') blender_cmds = [ 'convert-urdf-mesh {} --blender-remesh --blender-voxel-size 0.005 --output {}'.format( urdfpath, out_blender_dae), ('convert-urdf-mesh {} --blender-remesh --blender-voxel-size 0.005 ' '--collision-mesh-format stl --output {}').format( urdfpath, out_blender_stl), + ('convert-urdf-mesh {} --blender-decimate ' + '--blender-decimate-ratio 0.5 --output {}').format( + urdfpath, out_blender_decimate), ] cmds.extend(blender_cmds) failures = []