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
9 changes: 9 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,15 @@ jobs:
sudo apt-get update -qq -y
sudo apt-get install -qq -y libspatialindex-dev freeglut3-dev libsuitesparse-dev libblas-dev liblapack-dev
sudo apt-get install -qq -y xvfb # for headless testing
- name: Install Blender
run: |
sudo apt-get install -qq -y wget
BLENDER_VERSION="4.2.0"
BLENDER_URL="https://download.blender.org/release/Blender4.2/blender-${BLENDER_VERSION}-linux-x64.tar.xz"
wget -q ${BLENDER_URL} -O /tmp/blender.tar.xz
sudo tar -xf /tmp/blender.tar.xz -C /opt
sudo ln -s /opt/blender-${BLENDER_VERSION}-linux-x64/blender /usr/local/bin/blender
blender --version
- name: Install Pytestp
run: |
python -m pip install --upgrade pip setuptools wheel
Expand Down
42 changes: 40 additions & 2 deletions skrobot/apps/convert_urdf_mesh.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,24 @@ def main():
'--scale', type=float, default=1.0,
help='Scale factor for link lengths and mesh geometries. '
'Default is 1.0 (no scaling).')
parser.add_argument(
'--blender-remesh', action='store_true',
help='Use Blender voxel remesher to create cleaner mesh topology '
'while preserving colors. Requires Blender to be installed.')
parser.add_argument(
'--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-executable', type=str, default=None,
help='Path to Blender executable. If not specified, automatically '
'searches for Blender in common installation locations. '
'Only used with --blender-remesh.')
parser.add_argument(
'--remeshed-suffix', type=str, default='_remeshed',
help='Suffix to append to remeshed mesh filenames. '
'Default is "_remeshed". Specify empty string "" to overwrite '
'original files. Only used with --blender-remesh.')

args = parser.parse_args()

Expand Down Expand Up @@ -128,17 +146,37 @@ def nullcontext(enter_result=None):
yield enter_result
force_visual_mesh_origin_to_zero_or_not = nullcontext

# Store source URDF path for mesh resolution
from skrobot.utils.urdf import _CONFIGURABLE_VALUES
# Convert to absolute path to ensure correct mesh resolution
urdf_path_abs = urdf_path.resolve()
source_urdf_dir = str(urdf_path_abs.parent)
_CONFIGURABLE_VALUES['_source_urdf_path'] = source_urdf_dir

with force_visual_mesh_origin_to_zero_or_not():
print(f"Loading URDF from: {urdf_path}")
r = RobotModel.from_urdf(urdf_path)
try:
r = RobotModel.from_urdf(str(urdf_path_abs))
except Exception as e:
print(f"[ERROR] Failed to load URDF: {e}")
sys.exit(1)

# Verify that the robot model has valid links
if r.urdf_robot_model is None or not hasattr(r.urdf_robot_model, 'links') or len(r.urdf_robot_model.links) == 0:
print("[ERROR] URDF does not contain any valid links. Cannot proceed.")
sys.exit(1)

with export_mesh_format(
'.' + args.format,
decimation_area_ratio_threshold=args.decimation_area_ratio_threshold, # NOQA
simplify_vertex_clustering_voxel_size=args.voxel_size,
target_triangles=args.target_triangles,
overwrite_mesh=args.overwrite_mesh,
collision_mesh_format='.' + args.collision_mesh_format), apply_scale(args.scale):
collision_mesh_format='.' + args.collision_mesh_format,
blender_remesh=args.blender_remesh,
blender_voxel_size=args.blender_voxel_size,
blender_executable=args.blender_executable,
remeshed_suffix=args.remeshed_suffix), apply_scale(args.scale):
print(f"Saving new URDF to: {output_path}")
# Ensure output directory exists
output_dir = output_path.parent
Expand Down
2 changes: 2 additions & 0 deletions skrobot/utils/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,5 @@
from skrobot.utils.visualization import get_trajectory_optimization_callback
from skrobot.utils.visualization import set_ik_visualization_enabled
from skrobot.utils.visualization import get_ik_visualization_enabled

from skrobot.utils.blender_mesh import remesh_with_blender
170 changes: 170 additions & 0 deletions skrobot/utils/_blender_remesh_core.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
"""
Core Blender remeshing functions.
This module is designed to be executed within Blender's Python environment.
"""
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 remesh_and_bake_file(input_path, output_path, voxel_size=0.002, export_format='DAE'):
"""
Imports a mesh, remeshes it, and applies materials based on nearest face colors.

Parameters
----------
input_path : str or pathlib.Path
Path to input mesh file.
output_path : str or pathlib.Path
Path to output mesh file.
voxel_size : float, optional
Voxel size for remeshing. Default is 0.002.
export_format : str, optional
Export format ('DAE' or 'STL'). Default is 'DAE'.
"""
import math

input_path = Path(input_path)
output_path = Path(output_path)

clear_scene()

# Import with fix_orientation to maintain Blender's Z-up coordinate system
bpy.ops.wm.collada_import(
filepath=str(input_path),
fix_orientation=True,
auto_connect=False,
import_units=True
)

# --- 1. COLLECT ORIGINAL COLORS ---
source_objects = [obj for obj in bpy.context.scene.objects if obj.type == 'MESH']
if not source_objects:
print("Warning: No mesh objects found")
return

# Build a map of face center positions to colors
face_color_data = []

for obj in source_objects:
world_matrix = obj.matrix_world

# Get vertex colors if available
if obj.data.vertex_colors:
vc = obj.data.vertex_colors.active.data

for poly in obj.data.polygons:
# Get average color for this face
color_sum = [0.0, 0.0, 0.0, 0.0]
for loop_index in poly.loop_indices:
for i in range(4):
color_sum[i] += vc[loop_index].color[i]

avg_color = tuple(c / len(poly.loop_indices) for c in color_sum)

# Get face center in world space
face_center = world_matrix @ poly.center

face_color_data.append({
'center': face_center.copy(),
'color': avg_color[:3]
})

# --- 2. JOIN ALL OBJECTS ---
bpy.ops.object.select_all(action='DESELECT')
for obj in source_objects:
obj.select_set(True)
if len(source_objects) > 1:
bpy.context.view_layer.objects.active = source_objects[0]
bpy.ops.object.join()

joined_obj = bpy.context.active_object

# --- 3. APPLY REMESH ---
remesh_mod = joined_obj.modifiers.new(name="Remesh", type='REMESH')
remesh_mod.mode = 'VOXEL'
remesh_mod.voxel_size = voxel_size
remesh_mod.use_remove_disconnected = True
bpy.ops.object.modifier_apply(modifier=remesh_mod.name)

# --- 4. ASSIGN MATERIALS BASED ON NEAREST ORIGINAL FACE ---
if face_color_data:
from mathutils.kdtree import KDTree

# Build KD-tree for fast nearest neighbor search
kd = KDTree(len(face_color_data))
for i, data in enumerate(face_color_data):
kd.insert(data['center'], i)
kd.balance()

# Clear existing materials
joined_obj.data.materials.clear()

# Map colors to material indices
color_to_material = {}
world_matrix = joined_obj.matrix_world

for poly in joined_obj.data.polygons:
# Get face center in world space
face_center = world_matrix @ poly.center

# Find nearest original face
_nearest_co, nearest_index, _nearest_dist = kd.find(face_center)
nearest_color = face_color_data[nearest_index]['color']

# Round color to reduce number of materials
rounded_color = tuple(round(c * 50) / 50 for c in nearest_color)

# Create material if it doesn't exist
if rounded_color not in color_to_material:
mat = bpy.data.materials.new(name=f"Color_{len(color_to_material):03d}")
mat.use_nodes = True
bsdf = mat.node_tree.nodes.get("Principled BSDF")
if bsdf:
bsdf.inputs['Base Color'].default_value = (*rounded_color, 1.0)

color_to_material[rounded_color] = len(joined_obj.data.materials)
joined_obj.data.materials.append(mat)

# Assign material to face
poly.material_index = color_to_material[rounded_color]

# Apply smooth shading
if joined_obj.data.polygons:
joined_obj.data.polygons.foreach_set('use_smooth', [True] * len(joined_obj.data.polygons))

# --- 5. EXPORT ---
# Convert from Blender Z-up to Collada Y-up by rotating X-axis -90 degrees
joined_obj.rotation_euler[0] -= math.pi / 2

# Apply transformation to vertices
bpy.context.view_layer.objects.active = joined_obj
joined_obj.select_set(True)
bpy.ops.object.transform_apply(location=False, rotation=True, scale=False)

if export_format.upper() == 'DAE':
bpy.ops.wm.collada_export(
filepath=str(output_path),
selected=True,
include_children=True,
include_armatures=False,
include_shapekeys=False,
apply_modifiers=True,
triangulate=True,
use_object_instantiation=False,
sort_by_name=False
)
elif export_format.upper() == 'STL':
bpy.ops.export_mesh.stl(
filepath=str(output_path),
use_selection=True,
ascii=False
)
Loading