From 04975a58e4e0883e830a3cecba62e5a3737259da Mon Sep 17 00:00:00 2001 From: jichuanh Date: Wed, 12 Aug 2026 23:41:38 -0700 Subject: [PATCH 1/4] Drop the dead importers token from the kit-less image `--install ... ,importers` names a token the install CLI does not define, so every kit-less image build logs "Unknown install token 'importers'. Skipping" and the selector reads as if it installs something. Nothing else references it. --- docker/Dockerfile.kitless | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/docker/Dockerfile.kitless b/docker/Dockerfile.kitless index 08ec1f1d84ec..403cbfe67d7c 100644 --- a/docker/Dockerfile.kitless +++ b/docker/Dockerfile.kitless @@ -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; \ From 54368e1996b69a1455ba9c404e2d13c300d1f302 Mon Sep 17 00:00:00 2001 From: jichuanh Date: Thu, 13 Aug 2026 13:13:04 -0700 Subject: [PATCH 2/4] Derive the ROS package root from the URDF path Merging fixed joints rewrites the URDF into a scratch directory, and the importer then resolves `package://` URLs against that copy, where no meshes exist. Every mesh silently dropped, so conversion produced an articulation with no geometry. Deriving the package from the URDF's own `package.xml` keeps the URLs anchored to the source tree, so `ros_package_paths` is only needed for packages laid out unconventionally. --- .../jichuanh-urdf-ros-package-root.rst | 7 ++++ .../isaaclab/sim/converters/urdf_converter.py | 34 ++++++++++++++++++- .../isaaclab/test/sim/test_urdf_converter.py | 21 ++++++++++++ 3 files changed, 61 insertions(+), 1 deletion(-) create mode 100644 source/isaaclab/changelog.d/jichuanh-urdf-ros-package-root.rst diff --git a/source/isaaclab/changelog.d/jichuanh-urdf-ros-package-root.rst b/source/isaaclab/changelog.d/jichuanh-urdf-ros-package-root.rst new file mode 100644 index 000000000000..7b566c5fb2a1 --- /dev/null +++ b/source/isaaclab/changelog.d/jichuanh-urdf-ros-package-root.rst @@ -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. diff --git a/source/isaaclab/isaaclab/sim/converters/urdf_converter.py b/source/isaaclab/isaaclab/sim/converters/urdf_converter.py index b870dc0a1bf5..9d4c296bece1 100644 --- a/source/isaaclab/isaaclab/sim/converters/urdf_converter.py +++ b/source/isaaclab/isaaclab/sim/converters/urdf_converter.py @@ -9,6 +9,7 @@ import os import pathlib import warnings +import xml.etree.ElementTree as ElementTree from isaaclab.utils.version import has_kit @@ -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:///`` + 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: + logger.warning(f"UrdfConverter: could not parse '{manifest}' to resolve 'package://' URLs.") + return None + 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. @@ -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), @@ -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, diff --git a/source/isaaclab/test/sim/test_urdf_converter.py b/source/isaaclab/test/sim/test_urdf_converter.py index 0be2819bd33b..f9f1fca2e75c 100644 --- a/source/isaaclab/test/sim/test_urdf_converter.py +++ b/source/isaaclab/test/sim/test_urdf_converter.py @@ -823,3 +823,24 @@ 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("my_robot_description") + urdf = package / "urdf" / "robot.urdf" + urdf.write_text("") + + assert _find_ros_package(str(urdf)) == {"name": "my_robot_description", "path": str(package)} + # a URDF outside any package has no mapping to derive + loose = tmp_path / "loose.urdf" + loose.write_text("") + assert _find_ros_package(str(loose)) is None From df007d0be365a187383fd9b23a2f75077fee6e88 Mon Sep 17 00:00:00 2001 From: jichuanh Date: Thu, 13 Aug 2026 17:09:53 -0700 Subject: [PATCH 3/4] Handle unreadable ROS package manifests `ElementTree.ParseError` derives from `SyntaxError`, so an ancestor `package.xml` that exists but cannot be opened raised an uncaught `OSError` and aborted an otherwise valid conversion. --- source/isaaclab/isaaclab/sim/converters/urdf_converter.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/source/isaaclab/isaaclab/sim/converters/urdf_converter.py b/source/isaaclab/isaaclab/sim/converters/urdf_converter.py index 9d4c296bece1..b40b12dcaff4 100644 --- a/source/isaaclab/isaaclab/sim/converters/urdf_converter.py +++ b/source/isaaclab/isaaclab/sim/converters/urdf_converter.py @@ -37,8 +37,8 @@ def _find_ros_package(urdf_path: str) -> dict[str, str] | None: continue try: name = ElementTree.parse(manifest).getroot().findtext("name") - except ElementTree.ParseError: - logger.warning(f"UrdfConverter: could not parse '{manifest}' to resolve 'package://' URLs.") + except (ElementTree.ParseError, OSError): + logger.warning(f"UrdfConverter: could not read '{manifest}' to resolve 'package://' URLs.") return None return {"name": name.strip(), "path": str(directory)} if name and name.strip() else None return None From 8b501c321475fd0ed27948ef20f468e4747d2474 Mon Sep 17 00:00:00 2001 From: jichuanh Date: Thu, 13 Aug 2026 17:09:55 -0700 Subject: [PATCH 4/4] Cover the package:// mesh regression end to end The helper test asserted only that a package root is discoverable, so it still passed with the call-site wiring deleted. The new test converts a URDF whose only geometry is a `package://` visual behind a fixed joint and asserts a mesh survives, which fails without the wiring. The geometry is behind an instanceable reference, so it is reached through `Usd.TraverseInstanceProxies()`; a plain `Stage.Traverse()` stops at the instance boundary and sees no meshes at all. --- .../isaaclab/test/sim/test_urdf_converter.py | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/source/isaaclab/test/sim/test_urdf_converter.py b/source/isaaclab/test/sim/test_urdf_converter.py index f9f1fca2e75c..90459376efdb 100644 --- a/source/isaaclab/test/sim/test_urdf_converter.py +++ b/source/isaaclab/test/sim/test_urdf_converter.py @@ -844,3 +844,52 @@ def test_ros_package_derived_from_urdf_location(tmp_path): loose = tmp_path / "loose.urdf" loose.write_text("") 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("test_mesh_description") + (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( + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + ) + + 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"