Skip to content
Open
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
5 changes: 2 additions & 3 deletions docker/Dockerfile.kitless
Original file line number Diff line number Diff line change
Expand Up @@ -46,13 +46,12 @@ COPY source/ source/
COPY isaaclab.sh ./

# Same entry point as Dockerfile.base. The selectors are explicit because a bare
# --install excludes `ov` and `visualizer`; both take `[all]` here. `importers` carries
# the standalone URDF/MJCF importers that replace the Isaac Sim ones. The venv pins
# --install excludes `ov` and `visualizer`; both take `[all]` here. The venv pins
# python3.12 to match the runtime stage's libpython3.12, and isaaclab.sh resolves
# VIRTUAL_ENV first.
RUN uv venv --python /usr/bin/python3.12 --seed --no-managed-python "${VIRTUAL_ENV}" \
&& chmod +x "${ISAACLAB_PATH}/isaaclab.sh" \
&& "${ISAACLAB_PATH}/isaaclab.sh" --install newton,rl[all],ov[all],visualizer[all],importers \
&& "${ISAACLAB_PATH}/isaaclab.sh" --install newton,rl[all],ov[all],visualizer[all] \
&& python -c "import importlib.metadata as m; \
names = {d.metadata['Name'].lower() for d in m.distributions()}; \
assert 'isaacsim' not in names; \
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
Fixed
^^^^^

* Fixed URDF conversion producing an asset with no geometry when the URDF referenced its meshes
through ``package://`` URLs and fixed joints were merged. The ROS package is now derived from the
URDF's own location, so ``UrdfConverterCfg.ros_package_paths`` only has to be set for packages
laid out unconventionally.
34 changes: 33 additions & 1 deletion source/isaaclab/isaaclab/sim/converters/urdf_converter.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import os
import pathlib
import warnings
import xml.etree.ElementTree as ElementTree

from isaaclab.utils.version import has_kit

Expand All @@ -18,6 +19,31 @@
logger = logging.getLogger(__name__)


def _find_ros_package(urdf_path: str) -> dict[str, str] | None:
"""Find the ROS package a URDF belongs to, as a name/path mapping.

A ROS package is rooted at the directory holding its ``package.xml``, and ``package://<name>/``
URIs inside the URDF address that directory.

Args:
urdf_path: Path of the URDF file.

Returns:
The ``{"name": ..., "path": ...}`` mapping, or None when the URDF is not inside a package.
"""
for directory in pathlib.Path(urdf_path).resolve().parents:
manifest = directory / "package.xml"
if not manifest.is_file():
continue
try:
name = ElementTree.parse(manifest).getroot().findtext("name")
except (ElementTree.ParseError, OSError):
logger.warning(f"UrdfConverter: could not read '{manifest}' to resolve 'package://' URLs.")
return None
Comment on lines +38 to +42

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Manifest read errors abort conversion

If an ancestor package.xml exists but cannot be opened, ElementTree.parse raises an OSError that bypasses this handler, causing conversion to abort even for an otherwise readable URDF.

Suggested change
try:
name = ElementTree.parse(manifest).getroot().findtext("name")
except ElementTree.ParseError:
logger.warning(f"UrdfConverter: could not parse '{manifest}' to resolve 'package://' URLs.")
return None
try:
name = ElementTree.parse(manifest).getroot().findtext("name")
except (ElementTree.ParseError, OSError):
logger.warning(f"UrdfConverter: could not read or parse '{manifest}' to resolve 'package://' URLs.")
return None

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and fixed in df007d0. Verified the failure mode directly rather than by inspection: with chmod 000 on the manifest, Path.is_file() still returns True and ElementTree.parse raises PermissionError. ElementTree.ParseError derives from SyntaxError, not OSError, so the old handler could never have caught it. The handler is now except (ElementTree.ParseError, OSError).

return {"name": name.strip(), "path": str(directory)} if name and name.strip() else None
return None


class UrdfConverter(AssetConverterBase):
"""Converter for a URDF description file to a USD file.

Expand Down Expand Up @@ -92,6 +118,12 @@ def _convert_asset(self, cfg: UrdfConverterCfg):

# translate nested `JointDriveCfg` into flat importer fields
drive_type, target_type, stiffness, damping = self._unpack_joint_drive(cfg.joint_drive)
# Merging fixed joints rewrites the URDF into a scratch directory, and the importer then
# resolves ``package://`` against that copy, where no meshes exist. Naming the source
# package keeps the URLs anchored to it.
ros_package_paths = list(cfg.ros_package_paths)
if not ros_package_paths and (package := _find_ros_package(cfg.asset_path)):
ros_package_paths = [package]

import_config = URDFImporterConfig(
urdf_path=os.path.normpath(cfg.asset_path),
Expand All @@ -101,7 +133,7 @@ def _convert_asset(self, cfg: UrdfConverterCfg):
collision_from_visuals=cfg.collision_from_visuals,
collision_type=cfg.collision_type,
allow_self_collision=cfg.self_collision,
ros_package_paths=list(cfg.ros_package_paths),
ros_package_paths=ros_package_paths,
robot_type=cfg.robot_type,
fix_base=cfg.fix_base,
link_density=cfg.link_density if cfg.link_density > 0.0 else None,
Expand Down
70 changes: 70 additions & 0 deletions source/isaaclab/test/sim/test_urdf_converter.py
Original file line number Diff line number Diff line change
Expand Up @@ -823,3 +823,73 @@ def test_physics_variant_raises_again_on_retry(tmp_path):
for _ in range(2):
with pytest.raises(ValueError, match="no 'physx' physics variant"):
UrdfConverter(config)


def test_ros_package_derived_from_urdf_location(tmp_path):
"""The ROS package holding a URDF is derived from its path, so ``package://`` URLs resolve.

Merging fixed joints relocates the URDF to a scratch directory, and the importer resolves
``package://`` against that copy, so the mapping has to name the source package.
"""
from isaaclab.sim.converters.urdf_converter import _find_ros_package

package = tmp_path / "my_robot_description"
(package / "urdf").mkdir(parents=True)
(package / "package.xml").write_text("<package><name>my_robot_description</name></package>")
urdf = package / "urdf" / "robot.urdf"
urdf.write_text("<robot name='robot'/>")

assert _find_ros_package(str(urdf)) == {"name": "my_robot_description", "path": str(package)}
# a URDF outside any package has no mapping to derive

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Regression test skips conversion path

This assertion tests _find_ros_package directly but never runs fixed-joint conversion with a package:// mesh and verifies generated geometry, so the reported geometry-loss regression can recur while the test still passes.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, and this was a real gap — the helper test still passed with the call-site wiring deleted, which is exactly the failure mode you describe.

Added test_package_url_meshes_survive_fixed_joint_merge in 8b501c3: it builds a throwaway ROS package (a package.xml, an OBJ mesh, and a URDF whose only geometry is a package:// visual behind a fixed joint), converts with merge_fixed_joints=True, and asserts a mesh survives. Verified it fails with the wiring reverted and passes with it.

One detail worth recording for anyone extending these tests: the importer writes the geometry behind an instanceable reference, so the assertion traverses with Usd.TraverseInstanceProxies(). A plain Stage.Traverse() stops at the instance boundary and reports zero meshes even on a perfectly good conversion.

loose = tmp_path / "loose.urdf"
loose.write_text("<robot name='robot'/>")
assert _find_ros_package(str(loose)) is None


@pytest.mark.isaacsim_ci
def test_package_url_meshes_survive_fixed_joint_merge(sim_config, tmp_path):
"""A ``package://`` mesh survives fixed-joint merging without an explicit package mapping.

Merging rewrites the URDF into a scratch directory, so an unanchored ``package://`` URL
resolves against a copy holding no meshes and every mesh silently drops out.
"""
_, config = sim_config

package = tmp_path / "test_mesh_description"
(package / "meshes").mkdir(parents=True)
(package / "package.xml").write_text("<package><name>test_mesh_description</name></package>")
(package / "meshes" / "tetra.obj").write_text(
"v 0 0 0\nv 0.1 0 0\nv 0 0.1 0\nv 0 0 0.1\nf 1 3 2\nf 1 2 4\nf 1 4 3\nf 2 3 4\n"
)
# the mesh is the only geometry, so any Mesh prim in the output proves the URL resolved
urdf = package / "robot.urdf"
urdf.write_text(
"<robot name='test_package_url'>"
"<link name='root_link'/>"
"<joint name='root_to_base' type='fixed'>"
"<parent link='root_link'/><child link='base_link'/></joint>"
"<link name='base_link'><visual><geometry>"
"<mesh filename='package://test_mesh_description/meshes/tetra.obj'/>"
"</geometry></visual><inertial><mass value='1'/>"
"<inertia ixx='1.0' ixy='0.0' ixz='0.0' iyy='1.0' iyz='0.0' izz='1.0'/></inertial></link>"
"<joint name='base_to_link1' type='continuous'>"
"<parent link='base_link'/><child link='link_1'/>"
"<axis xyz='0 0 1'/><origin xyz='0 0 0.2'/></joint>"
"<link name='link_1'><inertial><mass value='1'/>"
"<inertia ixx='1.0' ixy='0.0' ixz='0.0' iyy='1.0' iyz='0.0' izz='1.0'/></inertial></link>"
"</robot>"
)

output_dir = os.path.join(str(tmp_path), "urdf_package_url")
os.makedirs(output_dir, exist_ok=True)
config.asset_path = str(urdf)
config.merge_fixed_joints = True
config.force_usd_conversion = True
config.usd_dir = output_dir

from pxr import Usd, UsdGeom

stage = Usd.Stage.Open(UrdfConverter(config).usd_path)
# the geometry is behind an instanceable reference, which a plain Traverse() does not descend into
meshes = [p for p in Usd.PrimRange.Stage(stage, Usd.TraverseInstanceProxies()) if p.IsA(UsdGeom.Mesh)]
assert len(meshes) > 0, "the 'package://' visual mesh was dropped by the merged conversion"
Loading