Skip to content

Commit 9ef3f2a

Browse files
authored
Merge pull request #600 from iory/remesh
Add blender-based mesh remeshing support to reduce mesh size
2 parents b9d8719 + 3b9767d commit 9ef3f2a

File tree

7 files changed

+738
-36
lines changed

7 files changed

+738
-36
lines changed

.github/workflows/test.yml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,15 @@ jobs:
172172
sudo apt-get update -qq -y
173173
sudo apt-get install -qq -y libspatialindex-dev freeglut3-dev libsuitesparse-dev libblas-dev liblapack-dev
174174
sudo apt-get install -qq -y xvfb # for headless testing
175+
- name: Install Blender
176+
run: |
177+
sudo apt-get install -qq -y wget
178+
BLENDER_VERSION="4.2.0"
179+
BLENDER_URL="https://download.blender.org/release/Blender4.2/blender-${BLENDER_VERSION}-linux-x64.tar.xz"
180+
wget -q ${BLENDER_URL} -O /tmp/blender.tar.xz
181+
sudo tar -xf /tmp/blender.tar.xz -C /opt
182+
sudo ln -s /opt/blender-${BLENDER_VERSION}-linux-x64/blender /usr/local/bin/blender
183+
blender --version
175184
- name: Install Pytestp
176185
run: |
177186
python -m pip install --upgrade pip setuptools wheel

skrobot/apps/convert_urdf_mesh.py

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,24 @@ def main():
7878
'--scale', type=float, default=1.0,
7979
help='Scale factor for link lengths and mesh geometries. '
8080
'Default is 1.0 (no scaling).')
81+
parser.add_argument(
82+
'--blender-remesh', action='store_true',
83+
help='Use Blender voxel remesher to create cleaner mesh topology '
84+
'while preserving colors. Requires Blender to be installed.')
85+
parser.add_argument(
86+
'--blender-voxel-size', type=float, default=0.002,
87+
help='Voxel size for Blender remeshing. Smaller values create more '
88+
'detailed meshes. Default is 0.002. Only used with --blender-remesh.')
89+
parser.add_argument(
90+
'--blender-executable', type=str, default=None,
91+
help='Path to Blender executable. If not specified, automatically '
92+
'searches for Blender in common installation locations. '
93+
'Only used with --blender-remesh.')
94+
parser.add_argument(
95+
'--remeshed-suffix', type=str, default='_remeshed',
96+
help='Suffix to append to remeshed mesh filenames. '
97+
'Default is "_remeshed". Specify empty string "" to overwrite '
98+
'original files. Only used with --blender-remesh.')
8199

82100
args = parser.parse_args()
83101

@@ -128,17 +146,37 @@ def nullcontext(enter_result=None):
128146
yield enter_result
129147
force_visual_mesh_origin_to_zero_or_not = nullcontext
130148

149+
# Store source URDF path for mesh resolution
150+
from skrobot.utils.urdf import _CONFIGURABLE_VALUES
151+
# Convert to absolute path to ensure correct mesh resolution
152+
urdf_path_abs = urdf_path.resolve()
153+
source_urdf_dir = str(urdf_path_abs.parent)
154+
_CONFIGURABLE_VALUES['_source_urdf_path'] = source_urdf_dir
155+
131156
with force_visual_mesh_origin_to_zero_or_not():
132157
print(f"Loading URDF from: {urdf_path}")
133-
r = RobotModel.from_urdf(urdf_path)
158+
try:
159+
r = RobotModel.from_urdf(str(urdf_path_abs))
160+
except Exception as e:
161+
print(f"[ERROR] Failed to load URDF: {e}")
162+
sys.exit(1)
163+
164+
# Verify that the robot model has valid links
165+
if r.urdf_robot_model is None or not hasattr(r.urdf_robot_model, 'links') or len(r.urdf_robot_model.links) == 0:
166+
print("[ERROR] URDF does not contain any valid links. Cannot proceed.")
167+
sys.exit(1)
134168

135169
with export_mesh_format(
136170
'.' + args.format,
137171
decimation_area_ratio_threshold=args.decimation_area_ratio_threshold, # NOQA
138172
simplify_vertex_clustering_voxel_size=args.voxel_size,
139173
target_triangles=args.target_triangles,
140174
overwrite_mesh=args.overwrite_mesh,
141-
collision_mesh_format='.' + args.collision_mesh_format), apply_scale(args.scale):
175+
collision_mesh_format='.' + args.collision_mesh_format,
176+
blender_remesh=args.blender_remesh,
177+
blender_voxel_size=args.blender_voxel_size,
178+
blender_executable=args.blender_executable,
179+
remeshed_suffix=args.remeshed_suffix), apply_scale(args.scale):
142180
print(f"Saving new URDF to: {output_path}")
143181
# Ensure output directory exists
144182
output_dir = output_path.parent

skrobot/utils/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,5 @@
1313
from skrobot.utils.visualization import get_trajectory_optimization_callback
1414
from skrobot.utils.visualization import set_ik_visualization_enabled
1515
from skrobot.utils.visualization import get_ik_visualization_enabled
16+
17+
from skrobot.utils.blender_mesh import remesh_with_blender
Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
"""
2+
Core Blender remeshing functions.
3+
This module is designed to be executed within Blender's Python environment.
4+
"""
5+
from pathlib import Path
6+
7+
import bpy
8+
9+
10+
def clear_scene():
11+
"""Clear all objects from the scene"""
12+
if bpy.ops.object.mode_set.poll():
13+
bpy.ops.object.mode_set(mode='OBJECT')
14+
bpy.ops.object.select_all(action='SELECT')
15+
bpy.ops.object.delete()
16+
17+
18+
def remesh_and_bake_file(input_path, output_path, voxel_size=0.002, export_format='DAE'):
19+
"""
20+
Imports a mesh, remeshes it, and applies materials based on nearest face colors.
21+
22+
Parameters
23+
----------
24+
input_path : str or pathlib.Path
25+
Path to input mesh file.
26+
output_path : str or pathlib.Path
27+
Path to output mesh file.
28+
voxel_size : float, optional
29+
Voxel size for remeshing. Default is 0.002.
30+
export_format : str, optional
31+
Export format ('DAE' or 'STL'). Default is 'DAE'.
32+
"""
33+
import math
34+
35+
input_path = Path(input_path)
36+
output_path = Path(output_path)
37+
38+
clear_scene()
39+
40+
# Import with fix_orientation to maintain Blender's Z-up coordinate system
41+
bpy.ops.wm.collada_import(
42+
filepath=str(input_path),
43+
fix_orientation=True,
44+
auto_connect=False,
45+
import_units=True
46+
)
47+
48+
# --- 1. COLLECT ORIGINAL COLORS ---
49+
source_objects = [obj for obj in bpy.context.scene.objects if obj.type == 'MESH']
50+
if not source_objects:
51+
print("Warning: No mesh objects found")
52+
return
53+
54+
# Build a map of face center positions to colors
55+
face_color_data = []
56+
57+
for obj in source_objects:
58+
world_matrix = obj.matrix_world
59+
60+
# Get vertex colors if available
61+
if obj.data.vertex_colors:
62+
vc = obj.data.vertex_colors.active.data
63+
64+
for poly in obj.data.polygons:
65+
# Get average color for this face
66+
color_sum = [0.0, 0.0, 0.0, 0.0]
67+
for loop_index in poly.loop_indices:
68+
for i in range(4):
69+
color_sum[i] += vc[loop_index].color[i]
70+
71+
avg_color = tuple(c / len(poly.loop_indices) for c in color_sum)
72+
73+
# Get face center in world space
74+
face_center = world_matrix @ poly.center
75+
76+
face_color_data.append({
77+
'center': face_center.copy(),
78+
'color': avg_color[:3]
79+
})
80+
81+
# --- 2. JOIN ALL OBJECTS ---
82+
bpy.ops.object.select_all(action='DESELECT')
83+
for obj in source_objects:
84+
obj.select_set(True)
85+
if len(source_objects) > 1:
86+
bpy.context.view_layer.objects.active = source_objects[0]
87+
bpy.ops.object.join()
88+
89+
joined_obj = bpy.context.active_object
90+
91+
# --- 3. APPLY REMESH ---
92+
remesh_mod = joined_obj.modifiers.new(name="Remesh", type='REMESH')
93+
remesh_mod.mode = 'VOXEL'
94+
remesh_mod.voxel_size = voxel_size
95+
remesh_mod.use_remove_disconnected = True
96+
bpy.ops.object.modifier_apply(modifier=remesh_mod.name)
97+
98+
# --- 4. ASSIGN MATERIALS BASED ON NEAREST ORIGINAL FACE ---
99+
if face_color_data:
100+
from mathutils.kdtree import KDTree
101+
102+
# Build KD-tree for fast nearest neighbor search
103+
kd = KDTree(len(face_color_data))
104+
for i, data in enumerate(face_color_data):
105+
kd.insert(data['center'], i)
106+
kd.balance()
107+
108+
# Clear existing materials
109+
joined_obj.data.materials.clear()
110+
111+
# Map colors to material indices
112+
color_to_material = {}
113+
world_matrix = joined_obj.matrix_world
114+
115+
for poly in joined_obj.data.polygons:
116+
# Get face center in world space
117+
face_center = world_matrix @ poly.center
118+
119+
# Find nearest original face
120+
_nearest_co, nearest_index, _nearest_dist = kd.find(face_center)
121+
nearest_color = face_color_data[nearest_index]['color']
122+
123+
# Round color to reduce number of materials
124+
rounded_color = tuple(round(c * 50) / 50 for c in nearest_color)
125+
126+
# Create material if it doesn't exist
127+
if rounded_color not in color_to_material:
128+
mat = bpy.data.materials.new(name=f"Color_{len(color_to_material):03d}")
129+
mat.use_nodes = True
130+
bsdf = mat.node_tree.nodes.get("Principled BSDF")
131+
if bsdf:
132+
bsdf.inputs['Base Color'].default_value = (*rounded_color, 1.0)
133+
134+
color_to_material[rounded_color] = len(joined_obj.data.materials)
135+
joined_obj.data.materials.append(mat)
136+
137+
# Assign material to face
138+
poly.material_index = color_to_material[rounded_color]
139+
140+
# Apply smooth shading
141+
if joined_obj.data.polygons:
142+
joined_obj.data.polygons.foreach_set('use_smooth', [True] * len(joined_obj.data.polygons))
143+
144+
# --- 5. EXPORT ---
145+
# Convert from Blender Z-up to Collada Y-up by rotating X-axis -90 degrees
146+
joined_obj.rotation_euler[0] -= math.pi / 2
147+
148+
# Apply transformation to vertices
149+
bpy.context.view_layer.objects.active = joined_obj
150+
joined_obj.select_set(True)
151+
bpy.ops.object.transform_apply(location=False, rotation=True, scale=False)
152+
153+
if export_format.upper() == 'DAE':
154+
bpy.ops.wm.collada_export(
155+
filepath=str(output_path),
156+
selected=True,
157+
include_children=True,
158+
include_armatures=False,
159+
include_shapekeys=False,
160+
apply_modifiers=True,
161+
triangulate=True,
162+
use_object_instantiation=False,
163+
sort_by_name=False
164+
)
165+
elif export_format.upper() == 'STL':
166+
bpy.ops.export_mesh.stl(
167+
filepath=str(output_path),
168+
use_selection=True,
169+
ascii=False
170+
)

0 commit comments

Comments
 (0)