Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions skrobot/apps/convert_urdf_mesh.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 '
Expand Down Expand Up @@ -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):
Expand Down
81 changes: 81 additions & 0 deletions skrobot/utils/_blender_decimate_core.py
Original file line number Diff line number Diff line change
@@ -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',
)
152 changes: 152 additions & 0 deletions skrobot/utils/blender_mesh.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
20 changes: 20 additions & 0 deletions skrobot/utils/urdf.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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):
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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(
Expand Down
5 changes: 5 additions & 0 deletions tests/skrobot_tests/test_console_scripts.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = []
Expand Down
Loading