Skip to content

Commit b2bcbe1

Browse files
authored
Add --blender-decimate option to convert-urdf-mesh (#752)
Reduce mesh triangle count with Blender's decimate (collapse) modifier while preserving shape and colors, exchanging meshes via glTF.
1 parent 517d093 commit b2bcbe1

5 files changed

Lines changed: 271 additions & 0 deletions

File tree

skrobot/apps/convert_urdf_mesh.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,17 @@ def main():
8686
'--blender-voxel-size', type=float, default=0.002,
8787
help='Voxel size for Blender remeshing. Smaller values create more '
8888
'detailed meshes. Default is 0.002. Only used with --blender-remesh.')
89+
parser.add_argument(
90+
'--blender-decimate', action='store_true',
91+
help='Use Blender decimate (collapse) modifier to reduce the triangle '
92+
'count while preserving shape and colors. Unlike --blender-remesh, '
93+
'this keeps the original topology and works on Blender 5.x '
94+
'(meshes are exchanged via glTF). Requires Blender to be installed.')
95+
parser.add_argument(
96+
'--blender-decimate-ratio', type=float, default=0.1,
97+
help='Collapse ratio for Blender decimation. The resulting triangle '
98+
'count is approximately this fraction of the original. Must be in '
99+
'(0, 1]. Default is 0.1. Only used with --blender-decimate.')
89100
parser.add_argument(
90101
'--blender-executable', type=str, default=None,
91102
help='Path to Blender executable. If not specified, automatically '
@@ -204,6 +215,8 @@ def nullcontext(enter_result=None):
204215
collision_mesh_format='.' + args.collision_mesh_format,
205216
blender_remesh=args.blender_remesh,
206217
blender_voxel_size=args.blender_voxel_size,
218+
blender_decimate=args.blender_decimate,
219+
blender_decimate_ratio=args.blender_decimate_ratio,
207220
blender_executable=args.blender_executable,
208221
remeshed_suffix=args.remeshed_suffix,
209222
draco_compression=args.draco), apply_scale(args.scale):
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
"""
2+
Core Blender decimation functions.
3+
This module is designed to be executed within Blender's Python environment.
4+
5+
Unlike the voxel remesher, decimation (collapse) reduces the triangle count
6+
while preserving the original shape, materials and vertex colors. The mesh I/O
7+
is done in glTF/glb format because Blender 5.x no longer ships the Collada
8+
(``.dae``) importer/exporter; glTF is supported natively on every Blender
9+
version that includes ``bpy`` and round-trips materials and vertex colors.
10+
"""
11+
from pathlib import Path
12+
13+
import bpy
14+
15+
16+
def clear_scene():
17+
"""Clear all objects from the scene."""
18+
if bpy.ops.object.mode_set.poll():
19+
bpy.ops.object.mode_set(mode='OBJECT')
20+
bpy.ops.object.select_all(action='SELECT')
21+
bpy.ops.object.delete()
22+
23+
24+
def decimate_glb_file(input_path, output_path, ratio=0.1):
25+
"""Import a glb, apply the decimate (collapse) modifier and re-export glb.
26+
27+
Parameters
28+
----------
29+
input_path : str or pathlib.Path
30+
Path to the input ``.glb`` file.
31+
output_path : str or pathlib.Path
32+
Path to the output ``.glb`` file.
33+
ratio : float, optional
34+
Collapse ratio passed to Blender's decimate modifier. The resulting
35+
triangle count is approximately ``ratio`` times the original. Must be
36+
in the range ``(0, 1]``. Default is 0.1.
37+
"""
38+
input_path = Path(input_path)
39+
output_path = Path(output_path)
40+
41+
clear_scene()
42+
43+
bpy.ops.import_scene.gltf(filepath=str(input_path))
44+
45+
objs = [o for o in bpy.context.scene.objects if o.type == 'MESH']
46+
if not objs:
47+
print("Warning: No mesh objects found in input")
48+
return
49+
before = sum(len(o.data.polygons) for o in objs)
50+
51+
# Join all mesh objects so a single decimate modifier covers the whole link.
52+
bpy.ops.object.select_all(action='DESELECT')
53+
for o in objs:
54+
o.select_set(True)
55+
# Always set the active object explicitly; with a single imported mesh
56+
# the active object would otherwise be None and join() is skipped.
57+
bpy.context.view_layer.objects.active = objs[0]
58+
if len(objs) > 1:
59+
bpy.ops.object.join()
60+
obj = bpy.context.active_object
61+
62+
mod = obj.modifiers.new(name="Decimate", type='DECIMATE')
63+
mod.decimate_type = 'COLLAPSE'
64+
mod.ratio = ratio
65+
mod.use_collapse_triangulate = True
66+
bpy.ops.object.modifier_apply(modifier=mod.name)
67+
68+
after = len(obj.data.polygons)
69+
print(f"Decimation complete. Faces: {before} -> {after} (ratio={ratio})")
70+
71+
if obj.data.polygons:
72+
obj.data.polygons.foreach_set(
73+
'use_smooth', [True] * len(obj.data.polygons))
74+
75+
bpy.context.view_layer.objects.active = obj
76+
obj.select_set(True)
77+
bpy.ops.export_scene.gltf(
78+
filepath=str(output_path),
79+
use_selection=True,
80+
export_format='GLB',
81+
)

skrobot/utils/blender_mesh.py

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,158 @@ def remesh_with_blender(
242242
return remeshed_mesh
243243

244244

245+
def decimate_with_blender(
246+
mesh,
247+
ratio=0.1,
248+
blender_executable=None,
249+
verbose=False):
250+
"""Decimate a mesh using Blender's decimate (collapse) modifier.
251+
252+
Unlike :func:`remesh_with_blender`, this reduces the triangle count while
253+
preserving the original shape, materials and vertex colors. The mesh is
254+
exchanged with Blender in glTF/glb format (Blender 5.x no longer ships the
255+
Collada importer/exporter), so colors round-trip as glTF materials/vertex
256+
colors.
257+
258+
Parameters
259+
----------
260+
mesh : trimesh.Trimesh or trimesh.Scene or list of trimesh.Trimesh or str or pathlib.Path
261+
Mesh to be decimated. Can be a trimesh object, a list of trimesh
262+
objects, a :class:`trimesh.Scene`, or a path to a mesh file.
263+
ratio : float, optional
264+
Collapse ratio passed to Blender's decimate modifier. The resulting
265+
triangle count is approximately ``ratio`` times the original. Must be
266+
in the range ``(0, 1]``. Default is 0.1.
267+
blender_executable : str, optional
268+
Path to Blender executable. If None, automatically searches for Blender
269+
in common installation locations. Default is None.
270+
verbose : bool, optional
271+
Whether to print progress information. Default is False.
272+
273+
Returns
274+
-------
275+
list of trimesh.Trimesh
276+
Decimated meshes with preserved colors.
277+
278+
Raises
279+
------
280+
RuntimeError
281+
If Blender decimation fails or the output file is not created.
282+
"""
283+
from pathlib import Path
284+
import subprocess
285+
import tempfile
286+
287+
trimesh = _lazy_trimesh()
288+
289+
if not 0.0 < ratio <= 1.0:
290+
raise ValueError(
291+
"ratio must be in the range (0, 1], got {}".format(ratio))
292+
293+
# Auto-detect Blender if not specified
294+
if blender_executable is None:
295+
blender_executable = _find_blender_executable()
296+
if blender_executable is None:
297+
raise RuntimeError(
298+
"Blender executable not found. Please install Blender or "
299+
"specify the path using the blender_executable parameter."
300+
)
301+
if verbose:
302+
print(f"Found Blender at: {blender_executable}")
303+
304+
# Normalize the input into a trimesh.Scene so it can be exported to glb.
305+
if isinstance(mesh, (str, Path)):
306+
scene = trimesh.load(mesh, force='scene')
307+
elif isinstance(mesh, trimesh.Scene):
308+
scene = mesh
309+
elif isinstance(mesh, (list, tuple)):
310+
scene = trimesh.Scene(list(mesh))
311+
else:
312+
scene = trimesh.Scene(mesh)
313+
314+
with tempfile.TemporaryDirectory() as temp_dir:
315+
temp_path = Path(temp_dir)
316+
input_glb = temp_path / 'input.glb'
317+
output_glb = temp_path / 'output.glb'
318+
scene.export(str(input_glb))
319+
320+
script_path = temp_path / 'decimate_script.py'
321+
_create_blender_decimate_script(
322+
script_path, input_glb, output_glb, ratio)
323+
324+
cmd = [
325+
blender_executable,
326+
'--background',
327+
'--python', str(script_path),
328+
]
329+
330+
if verbose:
331+
print(f"Running Blender decimate with ratio={ratio}...")
332+
333+
result = subprocess.run(cmd, capture_output=not verbose, text=True)
334+
335+
if result.returncode != 0:
336+
error_msg = (
337+
"Blender decimation failed with return code "
338+
f"{result.returncode}")
339+
if not verbose and result.stderr:
340+
error_msg += f"\nError: {result.stderr}"
341+
raise RuntimeError(error_msg)
342+
343+
if not output_glb.exists():
344+
raise RuntimeError(
345+
f"Decimated output file not found: {output_glb}")
346+
347+
decimated = trimesh.load(str(output_glb), force='scene')
348+
349+
geometries = [g for g in decimated.dump()
350+
if isinstance(g, trimesh.Trimesh)]
351+
if not geometries:
352+
raise RuntimeError("Decimated mesh contains no Trimesh geometry")
353+
354+
if verbose:
355+
total_faces = sum(len(g.faces) for g in geometries)
356+
print(f"Decimation complete. Output faces: {total_faces}")
357+
358+
return geometries
359+
360+
361+
def _create_blender_decimate_script(script_path, input_path, output_path, ratio):
362+
"""Create a wrapper script that imports the core Blender decimate module.
363+
364+
Parameters
365+
----------
366+
script_path : pathlib.Path
367+
Path where the wrapper script will be written.
368+
input_path : pathlib.Path
369+
Path to the input glb file.
370+
output_path : pathlib.Path
371+
Path to the output glb file.
372+
ratio : float
373+
Collapse ratio for decimation.
374+
"""
375+
from pathlib import Path
376+
377+
core_module_path = Path(__file__).parent / '_blender_decimate_core.py'
378+
379+
script_content = f"""import sys
380+
from pathlib import Path
381+
382+
core_module_path = Path(r"{core_module_path}")
383+
sys.path.insert(0, str(core_module_path.parent))
384+
385+
from _blender_decimate_core import decimate_glb_file
386+
387+
input_path = Path(r"{input_path}")
388+
output_path = Path(r"{output_path}")
389+
ratio = {ratio}
390+
391+
decimate_glb_file(input_path, output_path, ratio)
392+
"""
393+
394+
script_path.write_text(script_content)
395+
396+
245397
def _create_blender_wrapper_script(script_path, input_path, output_path, voxel_size, export_format):
246398
"""Create a wrapper script that imports the core Blender remeshing module.
247399

skrobot/utils/urdf.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,8 @@
5353
'scale_factor': 1.0,
5454
'blender_remesh': False,
5555
'blender_voxel_size': 0.002,
56+
'blender_decimate': False,
57+
'blender_decimate_ratio': 0.1,
5658
'blender_executable': None,
5759
'_current_geometry_context': None, # 'collision' or 'visual'
5860
'_source_urdf_path': None, # Original URDF file path for mesh resolution
@@ -85,6 +87,8 @@ def export_mesh_format(
8587
collision_mesh_format=None,
8688
blender_remesh=False,
8789
blender_voxel_size=0.002,
90+
blender_decimate=False,
91+
blender_decimate_ratio=0.1,
8892
blender_executable=None,
8993
remeshed_suffix='_remeshed',
9094
draco_compression=False):
@@ -99,6 +103,8 @@ def export_mesh_format(
99103
_CONFIGURABLE_VALUES["overwrite_mesh"] = overwrite_mesh
100104
_CONFIGURABLE_VALUES["blender_remesh"] = blender_remesh
101105
_CONFIGURABLE_VALUES["blender_voxel_size"] = blender_voxel_size
106+
_CONFIGURABLE_VALUES["blender_decimate"] = blender_decimate
107+
_CONFIGURABLE_VALUES["blender_decimate_ratio"] = blender_decimate_ratio
102108
_CONFIGURABLE_VALUES["blender_executable"] = blender_executable
103109
_CONFIGURABLE_VALUES["remeshed_suffix"] = remeshed_suffix
104110
_CONFIGURABLE_VALUES["draco_compression"] = draco_compression
@@ -113,6 +119,8 @@ def export_mesh_format(
113119
_CONFIGURABLE_VALUES["overwrite_mesh"] = False
114120
_CONFIGURABLE_VALUES["blender_remesh"] = False
115121
_CONFIGURABLE_VALUES["blender_voxel_size"] = 0.002
122+
_CONFIGURABLE_VALUES["blender_decimate"] = False
123+
_CONFIGURABLE_VALUES["blender_decimate_ratio"] = 0.1
116124
_CONFIGURABLE_VALUES["blender_executable"] = None
117125
_CONFIGURABLE_VALUES["remeshed_suffix"] = '_remeshed'
118126
_CONFIGURABLE_VALUES["draco_compression"] = False
@@ -1254,6 +1262,18 @@ def _to_xml(self, parent, path):
12541262
# Mark as processed
12551263
_REMESHED_FILES_CACHE[cache_key] = str(remeshed_path)
12561264

1265+
# Apply Blender decimation (collapse) if requested. This reduces the
1266+
# triangle count while preserving shape and colors, and works on
1267+
# Blender 5.x because the mesh is exchanged in glb (not Collada).
1268+
if _CONFIGURABLE_VALUES['blender_decimate'] and not blender_remesh_applied:
1269+
from skrobot.utils.blender_mesh import decimate_with_blender
1270+
meshes = decimate_with_blender(
1271+
meshes,
1272+
ratio=_CONFIGURABLE_VALUES['blender_decimate_ratio'],
1273+
blender_executable=_CONFIGURABLE_VALUES['blender_executable'],
1274+
verbose=True,
1275+
)
1276+
12571277
if _CONFIGURABLE_VALUES["target_triangles"] is not None:
12581278
from skrobot.utils.mesh import auto_simplify_quadric_decimation_with_texture_preservation
12591279
meshes = auto_simplify_quadric_decimation_with_texture_preservation(

tests/skrobot_tests/test_console_scripts.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,12 +76,17 @@ def test_convert_urdf_mesh(self):
7676
if _find_blender_executable() is not None:
7777
out_blender_dae = osp.join(urdf_dir, 'fetch_blender.urdf')
7878
out_blender_stl = osp.join(urdf_dir, 'fetch_blender_stl.urdf')
79+
out_blender_decimate = osp.join(
80+
urdf_dir, 'fetch_blender_decimate.urdf')
7981
blender_cmds = [
8082
'convert-urdf-mesh {} --blender-remesh --blender-voxel-size 0.005 --output {}'.format(
8183
urdfpath, out_blender_dae),
8284
('convert-urdf-mesh {} --blender-remesh --blender-voxel-size 0.005 '
8385
'--collision-mesh-format stl --output {}').format(
8486
urdfpath, out_blender_stl),
87+
('convert-urdf-mesh {} --blender-decimate '
88+
'--blender-decimate-ratio 0.5 --output {}').format(
89+
urdfpath, out_blender_decimate),
8590
]
8691
cmds.extend(blender_cmds)
8792
failures = []

0 commit comments

Comments
 (0)