Skip to content

Commit b9d8719

Browse files
authored
Merge pull request #599 from iory/primitives
Add convert-urdf-to-primitives command
2 parents 7d3ea18 + 8f4673b commit b9d8719

File tree

7 files changed

+1106
-77
lines changed

7 files changed

+1106
-77
lines changed

skrobot/apps/cli.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ def get_available_apps():
1414
app_metadata = {
1515
'visualize_urdf': ('Visualize URDF model', None),
1616
'convert_urdf_mesh': ('Convert URDF mesh files', (3, 6)),
17+
'convert_urdf_to_primitives': ('Convert URDF meshes to primitive shapes', None),
1718
'modularize_urdf': ('Modularize URDF files', None),
1819
'change_urdf_root': ('Change URDF root link', None),
1920
'transform_urdf': ('Add world link with transform to URDF', None),
Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
#!/usr/bin/env python
2+
"""
3+
Convert URDF mesh geometries to primitive shapes.
4+
5+
This tool analyzes mesh geometries in a URDF file and converts them to
6+
primitive shapes (box, cylinder, sphere) for more efficient physics
7+
simulation and simplified robot models.
8+
"""
9+
10+
import argparse
11+
import logging
12+
from pathlib import Path
13+
import sys
14+
15+
from skrobot.urdf import convert_meshes_to_primitives
16+
17+
18+
def main():
19+
parser = argparse.ArgumentParser(
20+
description='Convert URDF mesh geometries to primitive shapes',
21+
formatter_class=argparse.RawDescriptionHelpFormatter,
22+
epilog="""
23+
Examples:
24+
# Convert both visual and collision meshes to primitives
25+
convert-urdf-to-primitives robot.urdf
26+
27+
# Specify output file
28+
convert-urdf-to-primitives robot.urdf -o robot_primitives.urdf
29+
30+
# Preserve visual meshes, only convert collision
31+
convert-urdf-to-primitives robot.urdf --preserve-visual
32+
33+
# Force all meshes to be boxes
34+
convert-urdf-to-primitives robot.urdf --primitive-type box
35+
36+
# Modify the input file in place
37+
convert-urdf-to-primitives robot.urdf --inplace
38+
39+
# Process with verbose output
40+
convert-urdf-to-primitives robot.urdf -v
41+
"""
42+
)
43+
44+
parser.add_argument(
45+
'urdf_file',
46+
type=str,
47+
help='Path to the input URDF file'
48+
)
49+
50+
parser.add_argument(
51+
'-o', '--output',
52+
type=str,
53+
default=None,
54+
help='Path to the output URDF file (default: input_primitives.urdf)'
55+
)
56+
57+
parser.add_argument(
58+
'--inplace',
59+
action='store_true',
60+
help='Modify the input URDF file in place'
61+
)
62+
63+
parser.add_argument(
64+
'--preserve-visual',
65+
action='store_true',
66+
help='Preserve visual meshes, only convert collision meshes'
67+
)
68+
69+
parser.add_argument(
70+
'--preserve-collision',
71+
action='store_true',
72+
help='Preserve collision meshes, only convert visual meshes'
73+
)
74+
75+
parser.add_argument(
76+
'--primitive-type',
77+
type=str,
78+
choices=['box', 'cylinder', 'sphere', 'auto'],
79+
default='auto',
80+
help='Force a specific primitive type for all meshes (default: auto-detect)'
81+
)
82+
83+
parser.add_argument(
84+
'-v', '--verbose',
85+
action='store_true',
86+
help='Enable verbose output'
87+
)
88+
89+
parser.add_argument(
90+
'--force',
91+
action='store_true',
92+
help='Overwrite output file if it exists'
93+
)
94+
95+
args = parser.parse_args()
96+
97+
if args.verbose:
98+
logging.basicConfig(level=logging.INFO, format='%(message)s')
99+
else:
100+
logging.basicConfig(level=logging.WARNING, format='%(message)s')
101+
102+
input_path = Path(args.urdf_file)
103+
if not input_path.exists():
104+
print(f"Error: Input file '{args.urdf_file}' does not exist", file=sys.stderr)
105+
return 1
106+
107+
if args.inplace and args.output:
108+
print("Error: Cannot use both --output and --inplace options", file=sys.stderr)
109+
return 1
110+
111+
if args.preserve_visual and args.preserve_collision:
112+
print("Error: Cannot use both --preserve-visual and --preserve-collision", file=sys.stderr)
113+
return 1
114+
115+
if args.inplace:
116+
output_path = None
117+
elif args.output:
118+
output_path = Path(args.output)
119+
if output_path.exists() and not args.force:
120+
print(f"Error: Output file '{output_path}' already exists. Use --force to overwrite.", file=sys.stderr)
121+
return 1
122+
else:
123+
output_path = input_path.parent / f"{input_path.stem}_primitives.urdf"
124+
if output_path.exists() and not args.force:
125+
print(f"Error: Output file '{output_path}' already exists. Use --force to overwrite.", file=sys.stderr)
126+
return 1
127+
128+
convert_visual = not args.preserve_visual
129+
convert_collision = not args.preserve_collision
130+
131+
primitive_type = None if args.primitive_type == 'auto' else args.primitive_type
132+
133+
try:
134+
print(f"Loading URDF from: {input_path}")
135+
if args.preserve_visual:
136+
print("Preserving visual meshes, converting collision meshes to primitives...")
137+
elif args.preserve_collision:
138+
print("Preserving collision meshes, converting visual meshes to primitives...")
139+
else:
140+
print("Converting both visual and collision meshes to primitives...")
141+
142+
if primitive_type:
143+
print(f"Forcing primitive type: {primitive_type}")
144+
else:
145+
print("Auto-detecting best primitive type for each mesh")
146+
147+
modified_count = convert_meshes_to_primitives(
148+
str(input_path),
149+
str(output_path) if output_path else None,
150+
convert_visual=convert_visual,
151+
convert_collision=convert_collision,
152+
primitive_type=primitive_type
153+
)
154+
155+
if modified_count > 0:
156+
print(f"Converted {modified_count} geometry elements to primitives")
157+
else:
158+
print("No mesh geometries found to convert")
159+
160+
if args.inplace:
161+
print(f"Modified URDF saved in place: {input_path}")
162+
elif output_path:
163+
print(f"Modified URDF saved to: {output_path}")
164+
165+
print("Conversion completed successfully!")
166+
return 0
167+
168+
except Exception as e:
169+
print(f"Error: {e}", file=sys.stderr)
170+
if args.verbose:
171+
import traceback
172+
traceback.print_exc()
173+
return 1
174+
175+
176+
if __name__ == '__main__':
177+
sys.exit(main())

skrobot/coordinates/math.py

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1541,6 +1541,77 @@ def rotation_matrix_from_axis(
15411541
return np.vstack([e1, e2, e3])[np.argsort(indices)].T
15421542

15431543

1544+
def rotation_matrix_z_to_axis(axis):
1545+
"""Create rotation matrix that aligns Z-axis to target axis direction.
1546+
1547+
This is a convenience wrapper around rotation_matrix_from_axis that
1548+
automatically selects an appropriate second axis (X or Y) to fully determine
1549+
the rotation matrix. The resulting rotation matrix will transform the Z-axis
1550+
[0, 0, 1] to point in the target axis direction.
1551+
1552+
Difference from rotation_matrix_from_axis:
1553+
- rotation_matrix_from_axis: Requires explicit specification of both axes
1554+
- rotation_matrix_z_to_axis: Only requires target axis, automatically selects second axis
1555+
1556+
This function is particularly useful for URDF cylinder primitives, which are
1557+
defined with their central axis along the Z-axis.
1558+
1559+
Parameters
1560+
----------
1561+
axis : numpy.ndarray or list or tuple
1562+
Target axis direction for Z-axis alignment (will be normalized).
1563+
The Z-axis [0, 0, 1] of the resulting rotation matrix will be aligned
1564+
with this direction.
1565+
1566+
Returns
1567+
-------
1568+
rotation : numpy.ndarray
1569+
3x3 rotation matrix R such that R @ [0, 0, 1] is parallel to axis.
1570+
1571+
Examples
1572+
--------
1573+
>>> from skrobot.coordinates.math import rotation_matrix_z_to_axis
1574+
>>> import numpy as np
1575+
>>> # Align Z-axis with X-axis [1, 0, 0]
1576+
>>> R = rotation_matrix_z_to_axis([1, 0, 0])
1577+
>>> np.allclose(R @ [0, 0, 1], [1, 0, 0])
1578+
True
1579+
>>> # Align Z-axis with diagonal direction [0, 1, 1]
1580+
>>> R = rotation_matrix_z_to_axis([0, 1, 1])
1581+
>>> np.allclose(R @ [0, 0, 1], [0, 1/np.sqrt(2), 1/np.sqrt(2)])
1582+
True
1583+
1584+
See Also
1585+
--------
1586+
rotation_matrix_from_axis : Create rotation matrix from two explicit axes
1587+
1588+
Notes
1589+
-----
1590+
The second axis is automatically chosen to avoid gimbal lock:
1591+
- If the target axis is close to the X-axis, Y-axis is used as a hint
1592+
- Otherwise, X-axis is used as a hint
1593+
1594+
This ensures a stable rotation even when the target axis is aligned with
1595+
or close to a cardinal axis.
1596+
"""
1597+
axis = np.asarray(axis, dtype=float)
1598+
axis = axis / np.linalg.norm(axis)
1599+
1600+
# Select second axis hint to avoid gimbal lock
1601+
# If axis is close to X-axis, use Y-axis as hint; otherwise use X-axis
1602+
if abs(axis[0]) < 0.9:
1603+
second_axis = np.array([1, 0, 0]) # Use X-axis as hint
1604+
else:
1605+
second_axis = np.array([0, 1, 0]) # Use Y-axis as hint
1606+
1607+
# Create rotation matrix with Z-axis aligned to target and X-axis hint
1608+
return rotation_matrix_from_axis(
1609+
first_axis=axis,
1610+
second_axis=second_axis,
1611+
axes='zx'
1612+
)
1613+
1614+
15441615
def rodrigues(axis, theta=None, skip_normalization=False):
15451616
"""Rodrigues formula.
15461617

skrobot/urdf/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from .aggregate import aggregate_urdf_mesh_files
22
from .modularize_urdf import find_root_link
33
from .modularize_urdf import transform_urdf_to_macro
4+
from .primitives_converter import convert_meshes_to_primitives
45
from .transform_urdf import transform_urdf_with_world_link
56
from .wheel_collision_converter import convert_wheel_collisions_to_cylinders
67
from .wheel_collision_converter import get_mesh_dimensions
@@ -16,5 +17,6 @@
1617
'transform_urdf_with_world_link',
1718
'aggregate_urdf_mesh_files',
1819
'convert_wheel_collisions_to_cylinders',
20+
'convert_meshes_to_primitives',
1921
'get_mesh_dimensions',
2022
]

0 commit comments

Comments
 (0)