Skip to content

Commit 4fb6fdf

Browse files
authored
[FEATURE] Add support of 'capsule' primitive in URDF file. (#2045)
1 parent 945a452 commit 4fb6fdf

File tree

4 files changed

+157
-4
lines changed

4 files changed

+157
-4
lines changed

genesis/ext/urdfpy/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
URDFType,
33
Box,
44
Cylinder,
5+
Capsule,
56
Sphere,
67
Mesh,
78
Geometry,
@@ -29,6 +30,7 @@
2930
"URDFType",
3031
"Box",
3132
"Cylinder",
33+
"Capsule",
3234
"Sphere",
3335
"Mesh",
3436
"Geometry",

genesis/ext/urdfpy/urdf.py

Lines changed: 101 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -441,6 +441,87 @@ def copy(self, prefix="", scale=None):
441441
return c
442442

443443

444+
class Capsule(URDFType):
445+
"""A capsule whose center is at the local origin.
446+
447+
Parameters
448+
----------
449+
radius : float
450+
The radius of the capsule in meters.
451+
length : float
452+
The length of the capsule in meters.
453+
"""
454+
455+
_ATTRIBS = {
456+
"radius": (float, True),
457+
"length": (float, True),
458+
}
459+
_TAG = "capsule"
460+
461+
def __init__(self, radius, length):
462+
self.radius = radius
463+
self.length = length
464+
self._meshes = []
465+
466+
@property
467+
def radius(self):
468+
"""float : The radius of the capsule in meters."""
469+
return self._radius
470+
471+
@radius.setter
472+
def radius(self, value):
473+
self._radius = float(value)
474+
self._meshes = []
475+
476+
@property
477+
def length(self):
478+
"""float : The length of the capsule in meters."""
479+
return self._length
480+
481+
@length.setter
482+
def length(self, value):
483+
self._length = float(value)
484+
self._meshes = []
485+
486+
@property
487+
def meshes(self):
488+
"""list of :class:`~trimesh.base.Trimesh` : The triangular meshes
489+
that represent this object.
490+
"""
491+
if len(self._meshes) == 0:
492+
self._meshes = [trimesh.creation.capsule(radius=self.radius, height=self.length)]
493+
return self._meshes
494+
495+
def copy(self, prefix="", scale=None):
496+
"""Create a deep copy with the prefix applied to all names.
497+
498+
Parameters
499+
----------
500+
prefix : str
501+
A prefix to apply to all names.
502+
503+
Returns
504+
-------
505+
:class:`.Capsule`
506+
A deep copy.
507+
"""
508+
if scale is None:
509+
scale = 1.0
510+
if isinstance(scale, (list, np.ndarray)):
511+
if scale[0] != scale[1]:
512+
raise ValueError("Cannot rescale capsule geometry with asymmetry in x/y")
513+
c = Capsule(
514+
radius=self.radius * scale[0],
515+
length=self.length * scale[2],
516+
)
517+
else:
518+
c = Capsule(
519+
radius=self.radius * scale,
520+
length=self.length * scale,
521+
)
522+
return c
523+
524+
444525
class Sphere(URDFType):
445526
"""A sphere whose center is at the local origin.
446527
@@ -657,6 +738,8 @@ class Geometry(URDFType):
657738
Box geometry.
658739
cylinder : :class:`.Cylinder`
659740
Cylindrical geometry.
741+
capsule : :class:`.Capsule`
742+
Capsule geometry.
660743
sphere : :class:`.Sphere`
661744
Spherical geometry.
662745
mesh : :class:`.Mesh`
@@ -666,16 +749,18 @@ class Geometry(URDFType):
666749
_ELEMENTS = {
667750
"box": (Box, False, False),
668751
"cylinder": (Cylinder, False, False),
752+
"capsule": (Capsule, False, False),
669753
"sphere": (Sphere, False, False),
670754
"mesh": (Mesh, False, False),
671755
}
672756
_TAG = "geometry"
673757

674-
def __init__(self, box=None, cylinder=None, sphere=None, mesh=None):
675-
if box is None and cylinder is None and sphere is None and mesh is None:
758+
def __init__(self, box=None, cylinder=None, capsule=None, sphere=None, mesh=None):
759+
if box is None and cylinder is None and capsule is None and sphere is None and mesh is None:
676760
raise ValueError("At least one geometry element must be set")
677761
self.box = box
678762
self.cylinder = cylinder
763+
self.capsule = capsule
679764
self.sphere = sphere
680765
self.mesh = mesh
681766

@@ -701,6 +786,17 @@ def cylinder(self, value):
701786
raise TypeError("Expected Cylinder type")
702787
self._cylinder = value
703788

789+
@property
790+
def capsule(self):
791+
""":class:`.Capsule` : Capsule geometry."""
792+
return self._capsule
793+
794+
@capsule.setter
795+
def capsule(self, value):
796+
if value is not None and not isinstance(value, Capsule):
797+
raise TypeError("Expected Capsule type")
798+
self._capsule = value
799+
704800
@property
705801
def sphere(self):
706802
""":class:`.Sphere` : Spherical geometry."""
@@ -732,6 +828,8 @@ def geometry(self):
732828
return self.box
733829
if self.cylinder is not None:
734830
return self.cylinder
831+
if self.capsule is not None:
832+
return self.capsule
735833
if self.sphere is not None:
736834
return self.sphere
737835
if self.mesh is not None:
@@ -761,6 +859,7 @@ def copy(self, prefix="", scale=None):
761859
v = Geometry(
762860
box=(self.box.copy(prefix=prefix, scale=scale) if self.box else None),
763861
cylinder=(self.cylinder.copy(prefix=prefix, scale=scale) if self.cylinder else None),
862+
capsule=(self.capsule.copy(prefix=prefix, scale=scale) if self.capsule else None),
764863
sphere=(self.sphere.copy(prefix=prefix, scale=scale) if self.sphere else None),
765864
mesh=(self.mesh.copy(prefix=prefix, scale=scale) if self.mesh else None),
766865
)

genesis/utils/urdf.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -138,17 +138,23 @@ def parse_urdf(morph, surface):
138138
g_info = {"mesh" if geom_is_col else "vmesh": mesh}
139139
link_g_infos_.append(g_info)
140140
else:
141-
# Each geometry primitive is one RigidGeom in genesis.
141+
# Each geometry primitive is one RigidGeom in genesis
142142
if isinstance(geom.geometry.geometry, urdfpy.Box):
143143
tmesh = trimesh.creation.box(extents=geom.geometry.geometry.size)
144144
geom_type = gs.GEOM_TYPE.BOX
145145
geom_data = np.array(geom.geometry.geometry.size)
146+
elif isinstance(geom.geometry.geometry, urdfpy.Capsule):
147+
tmesh = trimesh.creation.capsule(
148+
radius=geom.geometry.geometry.radius, height=geom.geometry.geometry.length
149+
)
150+
geom_type = gs.GEOM_TYPE.CAPSULE
151+
geom_data = np.array([geom.geometry.geometry.radius, geom.geometry.geometry.length])
146152
elif isinstance(geom.geometry.geometry, urdfpy.Cylinder):
147153
tmesh = trimesh.creation.cylinder(
148154
radius=geom.geometry.geometry.radius, height=geom.geometry.geometry.length
149155
)
150156
geom_type = gs.GEOM_TYPE.CYLINDER
151-
geom_data = None
157+
geom_data = np.array([geom.geometry.geometry.radius, geom.geometry.geometry.length])
152158
elif isinstance(geom.geometry.geometry, urdfpy.Sphere):
153159
if geom_is_col:
154160
tmesh = trimesh.creation.icosphere(radius=geom.geometry.geometry.radius, subdivisions=2)

tests/test_rigid_physics.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2466,6 +2466,52 @@ def _check_entity_positions(relative, tol):
24662466
_check_entity_positions(relative=True, tol=2e-3)
24672467

24682468

2469+
@pytest.mark.required
2470+
def test_urdf_capsule(tmp_path, show_viewer, tol):
2471+
urdf_path = tmp_path / "capsule.urdf"
2472+
with open(urdf_path, "w") as f:
2473+
f.write(
2474+
"""
2475+
<robot name="urdf_robot">
2476+
<link name="base_link">
2477+
<inertial>
2478+
<origin rpy="0 0 0" xyz="0 0 0"/>
2479+
<mass value=".1"/>
2480+
<inertia ixx="1" ixy="0" ixz="0" iyy="1" iyz="0" izz="1"/>
2481+
</inertial>
2482+
<collision>
2483+
<origin rpy="0 0 0" xyz="0 0 0"/>
2484+
<geometry>
2485+
<capsule length="0.1" radius="0.02"/>
2486+
</geometry>
2487+
</collision>
2488+
</link>
2489+
</robot>
2490+
"""
2491+
)
2492+
2493+
scene = gs.Scene(show_viewer=show_viewer)
2494+
scene.add_entity(gs.morphs.Plane())
2495+
robot = scene.add_entity(
2496+
gs.morphs.URDF(
2497+
file=urdf_path,
2498+
pos=(0.0, 0.0, 0.3),
2499+
),
2500+
vis_mode="collision",
2501+
)
2502+
scene.build()
2503+
2504+
(geom,) = robot.geoms
2505+
assert geom.type == gs.GEOM_TYPE.CAPSULE
2506+
assert_allclose(geom.data[:2], (0.02, 0.1), tol=gs.EPS)
2507+
2508+
for _ in range(40):
2509+
scene.step()
2510+
geom_verts = tensor_to_array(geom.get_verts())
2511+
assert np.linalg.norm(geom_verts - np.zeros(3), axis=-1, ord=np.inf).min() < 1e-3
2512+
assert np.linalg.norm(geom_verts - np.array((0.0, 0.0, 0.14)), axis=-1, ord=np.inf).min() < 1e-3
2513+
2514+
24692515
@pytest.mark.required
24702516
def test_mjcf_parsing_with_include():
24712517
scene = gs.Scene()

0 commit comments

Comments
 (0)