From f156b8616cd8d072eefb7a1d1a9db406cc17066c Mon Sep 17 00:00:00 2001 From: Zeng Qingcheng <60593302+NeoZng@users.noreply.github.com> Date: Tue, 29 Sep 2026 17:40:21 +0800 Subject: [PATCH 1/9] Fix floating PhysX articulations of converted fixed-base assets With fix_base=True, the Isaac Sim importers keep a fixed joint that already attaches the root link to the world, the root_joint that the URDF importer adds or the weld of an MJCF root body without joints, but leave the articulation root on the root link. UsdPhysics treats such an articulation as floating, so PhysX simulated these assets as floating articulations that the joint only held as an extra constraint: they reported a floating base and undid root-pose writes. Newton treated them as fixed-base. Root the articulation at that fixed world joint after the import, as the importers do for a fixed joint they create. Revolute, prismatic and D6 world joints keep a body root, since PhysX would weld the root of an articulation rooted at them. Let fix_root_link=True enable the fixed joint that roots an articulation, instead of raising NotImplementedError or adding a second world joint. --- docs/source/how-to/make_fixed_prim.rst | 17 +++ .../fix-fixed-base-articulation-root.rst | 22 ++++ .../isaaclab/physics/physics_manager.py | 10 +- .../sim/converters/asset_converter_base.py | 113 ++++++++++++++++++ .../isaaclab/sim/converters/mjcf_converter.py | 8 +- .../sim/converters/mjcf_converter_cfg.py | 12 +- .../isaaclab/sim/converters/urdf_converter.py | 8 +- .../sim/converters/urdf_converter_cfg.py | 8 +- .../isaaclab/isaaclab/sim/schemas/schemas.py | 6 +- .../isaaclab/test/sim/test_mjcf_converter.py | 46 +++++++ .../isaaclab/test/sim/test_urdf_converter.py | 22 ++-- .../fix-fixed-base-articulation-root.skip | 1 + .../test/assets/test_articulation.py | 55 ++++++++- .../fix-fixed-base-articulation-root.skip | 1 + .../test/assets/test_articulation.py | 59 ++++++++- 15 files changed, 361 insertions(+), 27 deletions(-) create mode 100644 source/isaaclab/changelog.d/fix-fixed-base-articulation-root.rst create mode 100644 source/isaaclab_newton/changelog.d/fix-fixed-base-articulation-root.skip create mode 100644 source/isaaclab_physx/changelog.d/fix-fixed-base-articulation-root.skip diff --git a/docs/source/how-to/make_fixed_prim.rst b/docs/source/how-to/make_fixed_prim.rst index ce385a7ba363..7451bdd3bf90 100644 --- a/docs/source/how-to/make_fixed_prim.rst +++ b/docs/source/how-to/make_fixed_prim.rst @@ -163,6 +163,23 @@ before the backend imports the articulation. └── Link0Xform (RigidBody and ArticulationRoot schema) └── FixedJoint (connecting the world frame and Link0Xform) + 5. **Articulation root schema on the fixed joint**: + + UsdPhysics also allows the articulation root properties on the fixed joint that connects the root link to the + world. The articulation is parsed as a fixed-base system rooted at that joint. The URDF and MJCF converters + produce this arrangement with ``fix_base=True``. Setting + :attr:`~sim.schemas.ArticulationRootBaseCfg.fix_root_link` to True enables the joint. Setting it to False does + not make such an articulation floating. It is ignored when the joint sets both ``body0`` and ``body1``, as the + joints of the converters do. A joint with an empty ``body0``, such as the joint that the MJCF importer adds + for a free root body, is disabled: PhysX then cannot create the articulation, while Newton simulates a + floating base. To obtain a floating base, convert the asset with ``fix_base=False`` instead. + + .. code-block:: text + + ArticulationXform + └── Link0Xform (RigidBody schema) + └── FixedJoint (ArticulationRoot schema, connecting the world frame and Link0Xform) + For floating base articulations, the root prim usually has both the rigid body and the articulation root properties. However, directly connecting this prim to the world frame will cause the simulation to consider the fixed joint as a part of the maximal coordinate tree. This is different from PhysX diff --git a/source/isaaclab/changelog.d/fix-fixed-base-articulation-root.rst b/source/isaaclab/changelog.d/fix-fixed-base-articulation-root.rst new file mode 100644 index 000000000000..57c68baf4abf --- /dev/null +++ b/source/isaaclab/changelog.d/fix-fixed-base-articulation-root.rst @@ -0,0 +1,22 @@ +Fixed +^^^^^ + +* Fixed :class:`~isaaclab.sim.converters.UrdfConverter` and :class:`~isaaclab.sim.converters.MjcfConverter` + producing a floating-base PhysX articulation with ``fix_base=True`` when a fixed joint attaches the root link to + the world: the ``root_joint`` that the URDF importer adds, or the weld of an MJCF root body without joints. The + Isaac Sim importers left the articulation root on the root link, which UsdPhysics treats as a floating base, so + PhysX held the base only through that joint: ``is_fixed_base`` was False, root-pose writes were undone within a + few steps, and joints near the base could gain energy. The converters rooted the articulation at that joint + instead. Newton already treated these assets as fixed-base. +* Fixed ``fix_root_link=True`` raising ``NotImplementedError`` for an articulation rooted at its fixed world joint + when the joint sets both bodies, as for the converted assets above. It enabled that joint instead, so these assets + no longer got a second world joint, which Newton failed to import. + +Changed +^^^^^^^ + +* Changed the articulations of these converted assets to fixed-base on PhysX. ``num_base_dofs`` is 0, the Jacobian, + mass matrix and gravity compensation forces have no base degrees of freedom, a ``body_ordering`` must keep the root + link first, and root-pose writes and ``init_state`` place the base. On Newton, an explicit + ``articulation_root_prim_path`` has to name the fixed joint instead of the root link. USD files converted earlier, + including those in a reused ``usd_dir``, keep the floating-base layout until they are converted again. diff --git a/source/isaaclab/isaaclab/physics/physics_manager.py b/source/isaaclab/isaaclab/physics/physics_manager.py index 661ed78998c7..7eb3b375b8f9 100644 --- a/source/isaaclab/isaaclab/physics/physics_manager.py +++ b/source/isaaclab/isaaclab/physics/physics_manager.py @@ -103,8 +103,9 @@ def _prepare_stage_creation(cls) -> None: def fix_articulation_root(cls, articulation_prim: Any, stage: Any = None) -> Any: """Ensure that an articulation root has one enabled world fixed joint. - The base implementation leaves the root in place. Backends whose parser requires a different - root topology may relocate it and return the resulting root prim. + An articulation rooted at a fixed joint is attached to the world by that joint, as UsdPhysics roots + fixed-base articulations. The base implementation leaves the root in place. Backends whose parser + requires a different root topology may relocate it and return the resulting root prim. Args: articulation_prim: The articulation-root prim to fix. @@ -129,7 +130,10 @@ def fix_articulation_root(cls, articulation_prim: Any, stage: Any = None) -> Any if stage is None: stage = get_current_stage() root_path = articulation_prim.GetPath().pathString - joint = find_global_fixed_joint_prim(root_path, stage=stage) + if articulation_prim.IsA(UsdPhysics.FixedJoint): + joint = UsdPhysics.Joint(articulation_prim) + else: + joint = find_global_fixed_joint_prim(root_path, stage=stage) if joint is not None: joint.GetJointEnabledAttr().Set(True) return articulation_prim diff --git a/source/isaaclab/isaaclab/sim/converters/asset_converter_base.py b/source/isaaclab/isaaclab/sim/converters/asset_converter_base.py index 753601334829..240891a1a041 100644 --- a/source/isaaclab/isaaclab/sim/converters/asset_converter_base.py +++ b/source/isaaclab/isaaclab/sim/converters/asset_converter_base.py @@ -3,6 +3,8 @@ # # SPDX-License-Identifier: BSD-3-Clause +from __future__ import annotations + import abc import hashlib import json @@ -12,14 +14,21 @@ import random import tempfile from datetime import datetime +from typing import TYPE_CHECKING from ...utils import to_dict, validate from ...utils.assets import check_file_path from ...utils.io import dump_yaml from .asset_converter_base_cfg import AssetConverterBaseCfg +if TYPE_CHECKING: + from pxr import Usd + logger = logging.getLogger(__name__) +_ARTICULATION_ROOT_ATTRIBUTES = ("newton:selfCollisionEnabled", "newton:jointsAddMobility") +"""Attributes of the Newton articulation-root schema, which move with the articulation root.""" + class AssetConverterBase(abc.ABC): """Base class for converting an asset file from different formats into USD format. @@ -199,6 +208,52 @@ def _select_physics_variant(self, variant: str): variant_set.SetVariantSelection(variant) stage.GetRootLayer().Save() + def _root_articulations_at_world_joints(self, layered: bool): + """Root each fixed-base articulation of the converted asset at the fixed joint that attaches it to the world. + + With ``fix_base``, the Isaac Sim importers keep a fixed world joint that the asset already has, such as the + root joint that the URDF importer adds or the weld of an MJCF body without joints, but leave the articulation + root on the root body. UsdPhysics roots a fixed-base articulation at its world joint or at an ancestor of + that joint, while an articulation rooted at a rigid body is floating: PhysX simulates such an asset as a + floating articulation that the world joint only holds as an additional constraint. The importers root the + fixed joint that they create themselves in the same way. A root body on a revolute, prismatic, or D6 world + joint keeps the root, since PhysX would weld an articulation rooted at that joint. + + The importers author the articulation roots in the physics layer, which the other physics layers include. + Isaac Sim's ``fix_articulation_root_for_fixed_base`` does not apply here: it skips the prims that the + physics layer only overrides, and it also roots non-fixed world joints. + + Remove this correction when the pinned Isaac Sim importers root an existing world joint as well + (isaac-sim/IsaacSim#859). + + Args: + layered: Whether the asset transformer of the importer split the asset into layers. + """ + from pxr import Usd, UsdPhysics + + if layered: + usd_path = os.path.join(os.path.dirname(self.usd_path), "payloads", "Physics", "physics.usda") + else: + usd_path = self.usd_path + if not os.path.isfile(usd_path): + logger.warning(f"Cannot root the articulations at their world joints: '{usd_path}' does not exist.") + return + stage = Usd.Stage.Open(usd_path) + # the physics layer only adds opinions under the asset's prims, so it has to be traversed with the + # all-prims predicate to reach them + prims = list(stage.TraverseAll()) + joints = [prim for prim in prims if prim.IsA(UsdPhysics.FixedJoint)] + modified = False + for body in prims: + if not (body.HasAPI(UsdPhysics.ArticulationRootAPI) and body.HasAPI(UsdPhysics.RigidBodyAPI)): + continue + world_joint = next((joint for joint in joints if _attaches_to_world(joint, body)), None) + if world_joint is not None: + _move_articulation_root(body, world_joint) + modified = True + if modified: + stage.GetRootLayer().Save() + @staticmethod def _config_to_hash(cfg: AssetConverterBaseCfg) -> str: """Converts the configuration object and asset file to an MD5 hash of a string. @@ -234,3 +289,61 @@ def _config_to_hash(cfg: AssetConverterBaseCfg) -> str: md5.update(data) # return the hash return md5.hexdigest() + + +def _attaches_to_world(joint: Usd.Prim, body: Usd.Prim) -> bool: + """Return whether an articulation joint attaches a body, as its second body, to the world. + + UsdPhysics attaches a joint to the closest rigid-body ancestor of its target, and to the world without one. + """ + from pxr import UsdPhysics + + joint_api = UsdPhysics.Joint(joint) + if not joint.IsActive() or not joint_api.GetJointEnabledAttr().Get(): + return False + if joint_api.GetExcludeFromArticulationAttr().Get(): + return False + if joint_api.GetBody1Rel().GetTargets() != [body.GetPath()]: + return False + targets = joint_api.GetBody0Rel().GetTargets() + prim = joint.GetStage().GetPrimAtPath(targets[0]) if targets else None + while prim and not prim.IsPseudoRoot(): + if prim.HasAPI(UsdPhysics.RigidBodyAPI): + return False + prim = prim.GetParent() + return True + + +def _move_articulation_root(source: Usd.Prim, target: Usd.Prim): + """Move the articulation-root schemas and their authored attributes from one prim to another. + + These are the schemas and attributes that the Isaac Sim importers move to a fixed joint that they create. The + schema names are read from the authored ``apiSchemas``, since USD leaves schemas without a registered + definition out of :meth:`~pxr.Usd.Prim.GetAppliedSchemas`, which happens without Kit. + + Raises: + RuntimeError: If a schema or an attribute cannot be moved. + """ + from pxr import Sdf + + api_schemas = source.GetMetadata("apiSchemas") or Sdf.TokenListOp() + names = [ + name + for name in api_schemas.GetAddedOrExplicitItems() + if "ArticulationRoot" in name or name == "PhysxArticulationAPI" + ] + for name in names: + if not target.AddAppliedSchema(name): + raise RuntimeError(f"Failed to apply '{name}' to '{target.GetPath()}'.") + for attr in source.GetAttributes(): + name = attr.GetName() + is_root_attribute = name.startswith("physxArticulation:") or name in _ARTICULATION_ROOT_ATTRIBUTES + if not (is_root_attribute and attr.HasAuthoredValue()): + continue + if not target.CreateAttribute(name, attr.GetTypeName(), custom=False).Set(attr.Get()): + raise RuntimeError(f"Failed to move '{attr.GetPath()}' to '{target.GetPath()}'.") + if not source.RemoveProperty(name): + raise RuntimeError(f"Failed to remove '{attr.GetPath()}'.") + for name in names: + if not source.RemoveAppliedSchema(name): + raise RuntimeError(f"Failed to remove '{name}' from '{source.GetPath()}'.") diff --git a/source/isaaclab/isaaclab/sim/converters/mjcf_converter.py b/source/isaaclab/isaaclab/sim/converters/mjcf_converter.py index 085758f2921e..f6536ec47836 100644 --- a/source/isaaclab/isaaclab/sim/converters/mjcf_converter.py +++ b/source/isaaclab/isaaclab/sim/converters/mjcf_converter.py @@ -21,8 +21,10 @@ class MjcfConverter(AssetConverterBase): from the standalone ``isaacsim-asset-isolated`` package. All conversion logic (USD schema application, fix-base, density, actuator gains, self-collision, mesh merging, asset transformer profile) is performed by :class:`~isaacsim.asset.importer.mjcf.MJCFImporter` — - this class only translates :class:`MjcfConverterCfg` into a flat - :class:`~isaacsim.asset.importer.mjcf.MJCFImporterConfig`. + this class translates :class:`MjcfConverterCfg` into a flat + :class:`~isaacsim.asset.importer.mjcf.MJCFImporterConfig`. After the import, it only selects the + configured physics variant and roots a fixed-base articulation at its world joint (see + :attr:`MjcfConverterCfg.fix_base`). .. caution:: The current lazy conversion implementation does not automatically trigger USD generation if @@ -94,3 +96,5 @@ def _convert_asset(self, cfg: MjcfConverterCfg): if generated_usd_path: generated_usd_path = os.path.normpath(generated_usd_path) self._usd_file_name = os.path.relpath(generated_usd_path, self.usd_dir) + if cfg.fix_base: + self._root_articulations_at_world_joints(layered=cfg.run_asset_transformer) diff --git a/source/isaaclab/isaaclab/sim/converters/mjcf_converter_cfg.py b/source/isaaclab/isaaclab/sim/converters/mjcf_converter_cfg.py index 11ee9d1f80bd..f17e61c0a4cf 100644 --- a/source/isaaclab/isaaclab/sim/converters/mjcf_converter_cfg.py +++ b/source/isaaclab/isaaclab/sim/converters/mjcf_converter_cfg.py @@ -18,7 +18,8 @@ class MjcfConverterCfg(AssetConverterBaseCfg): Maps to :class:`~isaacsim.asset.importer.mjcf.MJCFImporterConfig` from the Isaac Sim MJCF importer. All post-import USD edits (fix-base, density override, actuator gain overrides, self-collision, mesh merging, asset transformer profile) are performed by - the Isaac Sim importer — this config just forwards the user's choices. + the Isaac Sim importer — this config just forwards the user's choices. The converter only roots a + fixed-base articulation at its world joint afterwards, see :attr:`fix_base`. .. note:: From Isaac Sim 5.0 onwards, the MJCF importer was rewritten to use the @@ -46,11 +47,12 @@ class MjcfConverterCfg(AssetConverterBaseCfg): """Import the physics scene (time step per second, gravity, etc.) from the MJCF file. Defaults to False.""" fix_base: bool = False - """Add a fixed joint from the world to the root rigid-body link. Defaults to False. + """Fix the root body of the articulation to the world. Defaults to False. - When enabled, :class:`~isaacsim.asset.importer.mjcf.MJCFImporter` inserts a ``FixedJoint`` - between the world and the articulation root and relocates ``ArticulationRootAPI`` onto the - appropriate ancestor prim so PhysX treats the articulation as fixed-base. + When enabled, :class:`~isaacsim.asset.importer.mjcf.MJCFImporter` adds a ``FixedJoint`` between the world and + a free root body and keeps the weld of a root body without joints, and the articulation is rooted at that fixed + joint so that PhysX treats it as fixed-base. A root body with hinge or slide joints stays attached to the world + by those joints, which PhysX simulates outside the articulation. """ link_density: float = 0.0 diff --git a/source/isaaclab/isaaclab/sim/converters/urdf_converter.py b/source/isaaclab/isaaclab/sim/converters/urdf_converter.py index 5585686c6def..7d73799d52a4 100644 --- a/source/isaaclab/isaaclab/sim/converters/urdf_converter.py +++ b/source/isaaclab/isaaclab/sim/converters/urdf_converter.py @@ -28,8 +28,10 @@ class UrdfConverter(AssetConverterBase): The heavy lifting (URDF parsing, fixed-joint merging, fix-base insertion, joint-drive configuration, density override, asset transformer profile) is delegated to Isaac Sim's :class:`~isaacsim.asset.importer.urdf.URDFImporter` together with - :class:`~isaacsim.asset.importer.urdf.URDFImporterConfig`. IsaacLab only translates its - user-friendly :class:`UrdfConverterCfg` into the flat importer config. + :class:`~isaacsim.asset.importer.urdf.URDFImporterConfig`. IsaacLab translates its + user-friendly :class:`UrdfConverterCfg` into the flat importer config. After the import, it only + selects the configured physics variant and roots a fixed-base articulation at its world joint (see + :attr:`UrdfConverterCfg.fix_base`). .. caution:: The current lazy conversion implementation does not automatically trigger USD generation if @@ -117,6 +119,8 @@ def _convert_asset(self, cfg: UrdfConverterCfg): if generated_usd_path: generated_usd_path = os.path.normpath(generated_usd_path) self._usd_file_name = os.path.relpath(generated_usd_path, self.usd_dir) + if cfg.fix_base: + self._root_articulations_at_world_joints(layered=cfg.run_asset_transformer) @staticmethod def _warn_unsupported_features(cfg: UrdfConverterCfg): diff --git a/source/isaaclab/isaaclab/sim/converters/urdf_converter_cfg.py b/source/isaaclab/isaaclab/sim/converters/urdf_converter_cfg.py index 729dceaa4b52..c319647b69ad 100644 --- a/source/isaaclab/isaaclab/sim/converters/urdf_converter_cfg.py +++ b/source/isaaclab/isaaclab/sim/converters/urdf_converter_cfg.py @@ -102,7 +102,13 @@ class NaturalFrequencyGainsCfg: """The drive gains configuration.""" fix_base: bool = MISSING - """Create a fix joint to the root/base link.""" + """Fix the root link to the world. + + The articulation is rooted at the fixed joint that attaches the root link to the world, the ``root_joint`` that + the importer adds or the fixed joint from a ``world`` link, so that PhysX treats it as fixed-base. A root link + attached to a ``world`` link by a non-fixed joint keeps the articulation root, and PhysX simulates that joint + outside the articulation. + """ root_link_name: str | None = None """The name of the root link. Defaults to None. diff --git a/source/isaaclab/isaaclab/sim/schemas/schemas.py b/source/isaaclab/isaaclab/sim/schemas/schemas.py index 69392d502d42..0f4e82837ef6 100644 --- a/source/isaaclab/isaaclab/sim/schemas/schemas.py +++ b/source/isaaclab/isaaclab/sim/schemas/schemas.py @@ -517,7 +517,11 @@ def modify_articulation_root_properties( apply_namespaced_schemas(articulation_prim, cfg, cfg_dict) if fix_root_link is not None: - existing_fixed_joint_prim = find_global_fixed_joint_prim(prim_path, stage=stage) + if fix_root_link and articulation_prim.IsA(UsdPhysics.FixedJoint): + # an articulation rooted at a fixed joint is attached to the world by that joint + existing_fixed_joint_prim = UsdPhysics.Joint(articulation_prim) + else: + existing_fixed_joint_prim = find_global_fixed_joint_prim(prim_path, stage=stage) # enable/disable an existing world joint, otherwise create one if existing_fixed_joint_prim is not None: logger.info( diff --git a/source/isaaclab/test/sim/test_mjcf_converter.py b/source/isaaclab/test/sim/test_mjcf_converter.py index 7a0d05435d41..1b2014b89388 100644 --- a/source/isaaclab/test/sim/test_mjcf_converter.py +++ b/source/isaaclab/test/sim/test_mjcf_converter.py @@ -42,6 +42,22 @@ # Portable NVIDIA Ant MJCF; ``newton`` is a base dependency of every environment. _PORTABLE_MJCF = os.path.join(os.path.dirname(newton.__file__), "examples", "assets", "nv_ant.xml") +# Root body with one hinge child. Without a joint of its own, MJCF welds the root body to the world. +_FIXED_BASE_MJCF = """ + + + + {root_joint} + + + + + + + + +""" + @pytest.fixture(autouse=True) def test_setup_teardown(): @@ -292,6 +308,36 @@ def test_run_asset_transformer_disabled(test_setup_teardown, tmp_path): assert sim.stage.GetPrimAtPath(prim_path).IsValid() +@pytest.mark.parametrize( + "root_joint, root_name", + [('', "fix_base_joint"), ("", "PhysicsFixedJoint"), ('', "base")], + ids=["free", "welded", "slide"], +) +def test_fix_base_roots_articulation_at_world_joint(test_setup_teardown, tmp_path, root_joint, root_name): + """Verify that fix_base=True roots the articulation at the fixed joint that attaches the root body to the world. + + The importer adds ``fix_base_joint`` for a free root body and keeps the weld of a root body without joints. + UsdPhysics roots a fixed-base articulation at its world joint, while an articulation rooted at the root body is + floating. A root body on a slide joint keeps the root, since PhysX would weld an articulation rooted at the + slide joint. + """ + sim, config = test_setup_teardown + mjcf_path = tmp_path / "fixed_base.xml" + mjcf_path.write_text(_FIXED_BASE_MJCF.format(root_joint=root_joint)) + + config.asset_path = str(mjcf_path) + config.fix_base = True + config.force_usd_conversion = True + config.usd_dir = str(tmp_path / "usd") + mjcf_converter = MjcfConverter(config) + + from pxr import Usd, UsdPhysics + + stage = Usd.Stage.Open(mjcf_converter.usd_path) + roots = [prim.GetName() for prim in stage.Traverse() if prim.HasAPI(UsdPhysics.ArticulationRootAPI)] + assert roots == [root_name] + + @pytest.mark.isaacsim_ci def test_override_actuator_gains(test_setup_teardown, tmp_path): """Verify that actuator gain overrides are written to ``MjcActuator`` prims. diff --git a/source/isaaclab/test/sim/test_urdf_converter.py b/source/isaaclab/test/sim/test_urdf_converter.py index 1a49377c1f87..7da16b07ef08 100644 --- a/source/isaaclab/test/sim/test_urdf_converter.py +++ b/source/isaaclab/test/sim/test_urdf_converter.py @@ -234,13 +234,19 @@ def test_merge_fixed_joints_converter(sim_config, tmp_path): @pytest.mark.isaacsim_ci -def test_fix_base_creates_fixed_joint(sim_config, tmp_path): - """Verify that fix_base=True creates a FixedJoint in the output USD.""" +@pytest.mark.parametrize("run_asset_transformer", [True, False], ids=["layered", "flat"]) +def test_fix_base_roots_articulation_at_world_joint(sim_config, tmp_path, run_asset_transformer): + """Verify that fix_base=True roots the articulation at the fixed joint that attaches the root link to the world. + + The importer adds that joint as ``root_joint``. UsdPhysics roots a fixed-base articulation at its world joint, + while an articulation rooted at the root link is floating. + """ sim, config = sim_config output_dir = os.path.join(str(tmp_path), "urdf_fix_base") os.makedirs(output_dir, exist_ok=True) config.fix_base = True + config.run_asset_transformer = run_asset_transformer config.force_usd_conversion = True config.usd_dir = output_dir urdf_converter = UrdfConverter(config) @@ -248,15 +254,9 @@ def test_fix_base_creates_fixed_joint(sim_config, tmp_path): from pxr import Usd, UsdPhysics stage = Usd.Stage.Open(urdf_converter.usd_path) - - # search for a FixedJoint in the output - fixed_joints = [p for p in stage.Traverse() if p.IsA(UsdPhysics.FixedJoint)] - assert len(fixed_joints) > 0, "Expected at least one FixedJoint from fix_base=True" - - # the first FixedJoint should target a rigid body link via body1 - fj = UsdPhysics.FixedJoint(fixed_joints[0]) - body1_targets = fj.GetBody1Rel().GetTargets() - assert len(body1_targets) > 0, "FixedJoint should target a rigid body link via body1" + roots = [prim for prim in stage.Traverse() if prim.HasAPI(UsdPhysics.ArticulationRootAPI)] + assert [root.GetName() for root in roots] == ["root_joint"] + assert roots[0].IsA(UsdPhysics.FixedJoint) @pytest.mark.isaacsim_ci diff --git a/source/isaaclab_newton/changelog.d/fix-fixed-base-articulation-root.skip b/source/isaaclab_newton/changelog.d/fix-fixed-base-articulation-root.skip new file mode 100644 index 000000000000..d5826dbb9cb0 --- /dev/null +++ b/source/isaaclab_newton/changelog.d/fix-fixed-base-articulation-root.skip @@ -0,0 +1 @@ +Added Newton articulation tests for URDF assets converted with a fixed base. diff --git a/source/isaaclab_newton/test/assets/test_articulation.py b/source/isaaclab_newton/test/assets/test_articulation.py index f0f4b5da6dbc..ebd9ff9fe44e 100644 --- a/source/isaaclab_newton/test/assets/test_articulation.py +++ b/source/isaaclab_newton/test/assets/test_articulation.py @@ -42,7 +42,7 @@ from newton.selection import ArticulationView from newton.solvers import SolverMuJoCo -from pxr import UsdPhysics +from pxr import Usd, UsdPhysics import isaaclab.assets.articulation.ordering_resolvers as ordering_resolvers import isaaclab.sim as sim_utils @@ -186,6 +186,27 @@ class CustomDrive(ImplicitActuator): """Implicit actuator with a class name that does not encode its execution type.""" +# Base link with one revolute child. The URDF importer attaches the base link to the asset's root prim. +_FIXED_BASE_URDF = """ + + + + + + + + + + + + + + + + +""" + + def generate_articulation_cfg( articulation_type: str, stiffness: float | None = 10.0, @@ -1679,6 +1700,38 @@ def test_fragment_fix_root_link_uses_base_manager(sim, device, add_ground_plane, torch.testing.assert_close(articulation.data.root_com_vel_w.torch, default_root_vel) +@pytest.mark.parametrize("num_articulations", [2]) +@pytest.mark.parametrize("device", test_devices(DeviceScope.CUDA)) +@pytest.mark.parametrize("articulation_type", ["single_joint_implicit"]) +@pytest.mark.parametrize("fix_root_link", [None, True]) +def test_initialization_fixed_base_urdf(sim, num_articulations, device, articulation_type, fix_root_link, tmp_path): + """Test that a URDF converted with ``fix_base=True`` initializes as a fixed-base articulation. + + The articulation is rooted at the fixed joint ``root_joint`` that the importer adds. Fixing the root link through + the spawner as well must reuse that joint instead of adding a second one, which Newton cannot import. + """ + urdf_path = tmp_path / "fixed_base.urdf" + urdf_path.write_text(_FIXED_BASE_URDF) + spawn = sim_utils.UrdfFileCfg( + asset_path=str(urdf_path), usd_dir=str(tmp_path / "usd"), fix_base=True, fix_root_link=fix_root_link + ) + articulation, translations = generate_articulation( + ArticulationCfg(spawn=spawn, actuators={}), num_articulations, device + ) + + replicate(sim.get_clone_plan()) + sim.reset() + assert articulation.is_fixed_base + for _ in range(10): + sim.step() + articulation.update(sim.cfg.dt) + torch.testing.assert_close(articulation.data.root_link_pose_w.torch[:, :3], translations) + + robot = sim.stage.GetPrimAtPath("/World/Env_0/Robot") + fixed_joints = [prim.GetName() for prim in Usd.PrimRange(robot) if prim.IsA(UsdPhysics.FixedJoint)] + assert fixed_joints == ["root_joint"] + + @pytest.mark.parametrize("num_articulations", [2]) @pytest.mark.parametrize("device", test_devices(DeviceScope.CUDA)) @pytest.mark.parametrize("add_ground_plane", [True]) diff --git a/source/isaaclab_physx/changelog.d/fix-fixed-base-articulation-root.skip b/source/isaaclab_physx/changelog.d/fix-fixed-base-articulation-root.skip new file mode 100644 index 000000000000..255b26b424cc --- /dev/null +++ b/source/isaaclab_physx/changelog.d/fix-fixed-base-articulation-root.skip @@ -0,0 +1 @@ +Added PhysX articulation tests for URDF assets converted with a fixed base, with physics replication. diff --git a/source/isaaclab_physx/test/assets/test_articulation.py b/source/isaaclab_physx/test/assets/test_articulation.py index 7078c528911e..12ddfb4c1bc9 100644 --- a/source/isaaclab_physx/test/assets/test_articulation.py +++ b/source/isaaclab_physx/test/assets/test_articulation.py @@ -31,13 +31,14 @@ from isaaclab_physx.assets import Articulation from isaaclab_physx.sim.schemas import PhysxJointCfg -from pxr import UsdPhysics +from pxr import Usd, UsdPhysics import isaaclab.sim as sim_utils import isaaclab.utils.math as math_utils import isaaclab.utils.string as string_utils from isaaclab.actuators import IdealPDActuatorCfg, ImplicitActuatorCfg from isaaclab.assets import ArticulationCfg, get_articulation_name_ordering +from isaaclab.cloner import CloneCfg, clone_plan_from_env_0, replicate from isaaclab.controllers import DifferentialIKController, DifferentialIKControllerCfg from isaaclab.envs.mdp.terminations import joint_effort_out_of_limit from isaaclab.managers import SceneEntityCfg @@ -56,6 +57,26 @@ ) from isaaclab_assets.robots.shadow_hand import SHADOW_HAND_PHYSX_CFG +# Base link with one revolute child. The URDF importer attaches the base link to the asset's root prim. +_FIXED_BASE_URDF = """ + + + + + + + + + + + + + + + + +""" + def generate_articulation_cfg( articulation_type: str, @@ -783,6 +804,42 @@ def test_initialization_floating_base_made_fixed_base(sim, num_articulations, de torch.testing.assert_close(articulation.data.root_com_vel_w.torch, default_root_vel) +@pytest.mark.parametrize("num_articulations", [2]) +@pytest.mark.parametrize("device", test_devices(DeviceScope.CUDA)) +@pytest.mark.parametrize("fix_root_link", [None, True]) +def test_initialization_fixed_base_urdf(sim, num_articulations, device, fix_root_link, tmp_path): + """Test that a URDF converted with ``fix_base=True`` initializes as a fixed-base articulation. + + The importer attaches the root link to the world with the fixed joint ``root_joint``. The articulation has to be + rooted at that joint, since PhysX simulates an articulation rooted at the root link as a floating base. With + physics replication, each environment has to keep its base at its origin. Fixing the root link through the + spawner as well must reuse that joint instead of adding a second one. + """ + urdf_path = tmp_path / "fixed_base.urdf" + urdf_path.write_text(_FIXED_BASE_URDF) + spawn = sim_utils.UrdfFileCfg( + asset_path=str(urdf_path), usd_dir=str(tmp_path / "usd"), fix_base=True, fix_root_link=fix_root_link + ) + cfg = ArticulationCfg(prim_path="/World/Env_[^/]*/Robot", spawn=spawn, actuators={}) + sim_utils.create_prim("/World/Env_0", "Xform") + # the grid places every environment away from the world origin + plan = clone_plan_from_env_0(CloneCfg(clone_template="/World/Env_{}"), (cfg,), num_articulations, 2.5) + articulation = Articulation(cfg) + replicate(plan) + + sim.reset() + assert articulation.is_fixed_base + for _ in range(10): + sim.step() + articulation.update(sim.cfg.dt) + env_origins = torch.as_tensor(plan.positions, device=device) + torch.testing.assert_close(articulation.data.root_link_pose_w.torch[:, :3], env_origins) + + robot = sim_utils.get_current_stage().GetPrimAtPath("/World/Env_0/Robot") + fixed_joints = [prim.GetName() for prim in Usd.PrimRange(robot) if prim.IsA(UsdPhysics.FixedJoint)] + assert fixed_joints == ["root_joint"] + + @pytest.mark.parametrize("num_articulations", [2]) @pytest.mark.parametrize("device", test_devices()) @pytest.mark.parametrize("add_ground_plane", [True]) From 8a43e9d7b10a372fc49a9e110399b549fdaf5498 Mon Sep 17 00:00:00 2001 From: Zeng Qingcheng <60593302+NeoZng@users.noreply.github.com> Date: Wed, 30 Sep 2026 00:51:12 +0800 Subject: [PATCH 2/9] Check that a fixed-joint articulation root attaches to the world fix_root_link=True treated any fixed joint that roots an articulation as the joint that attaches it to the world. A fixed joint between two bodies does not, so enable the joint only when one of its sides is the world, and keep raising NotImplementedError otherwise, as before. --- .../isaaclab/physics/physics_manager.py | 10 ++++---- .../isaaclab/isaaclab/sim/schemas/schemas.py | 23 +++++++++++++++++-- source/isaaclab/test/sim/test_schemas.py | 20 ++++++++++++++++ 3 files changed, 46 insertions(+), 7 deletions(-) diff --git a/source/isaaclab/isaaclab/physics/physics_manager.py b/source/isaaclab/isaaclab/physics/physics_manager.py index 7eb3b375b8f9..26f07b412f81 100644 --- a/source/isaaclab/isaaclab/physics/physics_manager.py +++ b/source/isaaclab/isaaclab/physics/physics_manager.py @@ -103,9 +103,9 @@ def _prepare_stage_creation(cls) -> None: def fix_articulation_root(cls, articulation_prim: Any, stage: Any = None) -> Any: """Ensure that an articulation root has one enabled world fixed joint. - An articulation rooted at a fixed joint is attached to the world by that joint, as UsdPhysics roots - fixed-base articulations. The base implementation leaves the root in place. Backends whose parser - requires a different root topology may relocate it and return the resulting root prim. + An articulation rooted at a fixed joint that attaches it to the world, as UsdPhysics roots fixed-base + articulations, is fixed by that joint. The base implementation leaves the root in place. Backends whose + parser requires a different root topology may relocate it and return the resulting root prim. Args: articulation_prim: The articulation-root prim to fix. @@ -124,13 +124,13 @@ def fix_articulation_root(cls, articulation_prim: Any, stage: Any = None) -> Any # managers.manager_base before the simulation app starts. from pxr import UsdPhysics # noqa: PLC0415 - from ..sim.schemas.schemas import create_world_fixed_joint # noqa: PLC0415 + from ..sim.schemas.schemas import _is_world_fixed_joint, create_world_fixed_joint # noqa: PLC0415 from ..sim.utils import find_global_fixed_joint_prim # noqa: PLC0415 if stage is None: stage = get_current_stage() root_path = articulation_prim.GetPath().pathString - if articulation_prim.IsA(UsdPhysics.FixedJoint): + if _is_world_fixed_joint(articulation_prim): joint = UsdPhysics.Joint(articulation_prim) else: joint = find_global_fixed_joint_prim(root_path, stage=stage) diff --git a/source/isaaclab/isaaclab/sim/schemas/schemas.py b/source/isaaclab/isaaclab/sim/schemas/schemas.py index 0f4e82837ef6..b7a946ebc045 100644 --- a/source/isaaclab/isaaclab/sim/schemas/schemas.py +++ b/source/isaaclab/isaaclab/sim/schemas/schemas.py @@ -403,6 +403,25 @@ def define_articulation_root_properties( modify_articulation_root_properties.__wrapped__(prim_path, cfg, stage) +def _is_world_fixed_joint(prim: Usd.Prim) -> bool: + """Return whether a prim is a fixed joint that attaches a body to the world. + + UsdPhysics attaches a side of a joint to the world when its body target is empty or has no rigid-body ancestor. + """ + if not prim.IsA(UsdPhysics.FixedJoint): + return False + joint = UsdPhysics.FixedJoint(prim) + stage = prim.GetStage() + for rel in (joint.GetBody0Rel(), joint.GetBody1Rel()): + targets = rel.GetTargets() + body = stage.GetPrimAtPath(targets[0]) if targets else None + while body and not body.IsPseudoRoot() and not body.HasAPI(UsdPhysics.RigidBodyAPI): + body = body.GetParent() + if not body or body.IsPseudoRoot(): + return True + return False + + def create_world_fixed_joint(articulation_prim: Usd.Prim, stage: Usd.Stage) -> None: """Author a ``UsdPhysics.FixedJoint`` fixing an articulation root link to the world frame. @@ -517,8 +536,8 @@ def modify_articulation_root_properties( apply_namespaced_schemas(articulation_prim, cfg, cfg_dict) if fix_root_link is not None: - if fix_root_link and articulation_prim.IsA(UsdPhysics.FixedJoint): - # an articulation rooted at a fixed joint is attached to the world by that joint + if fix_root_link and _is_world_fixed_joint(articulation_prim): + # an articulation rooted at its fixed world joint is attached to the world by that joint existing_fixed_joint_prim = UsdPhysics.Joint(articulation_prim) else: existing_fixed_joint_prim = find_global_fixed_joint_prim(prim_path, stage=stage) diff --git a/source/isaaclab/test/sim/test_schemas.py b/source/isaaclab/test/sim/test_schemas.py index bc8d17308a5f..8d4f0afcc681 100644 --- a/source/isaaclab/test/sim/test_schemas.py +++ b/source/isaaclab/test/sim/test_schemas.py @@ -406,6 +406,26 @@ def test_modify_articulation_root_fix_root_link_uses_given_stage(setup_simulatio assert joint.GetJointEnabledAttr().Get() is False +def test_fix_root_link_rejects_internal_fixed_joint_root(setup_simulation): + """``fix_root_link=True`` must not treat an articulation root on a fixed joint between two bodies as fixed.""" + stage = sim_utils.get_current_stage() + sim_utils.create_prim("/World/Robot", prim_type="Xform") + for name in ("base", "link"): + UsdPhysics.RigidBodyAPI.Apply(sim_utils.create_prim(f"/World/Robot/{name}", prim_type="Xform")) + joint = UsdPhysics.FixedJoint.Define(stage, "/World/Robot/joint") + joint.CreateBody0Rel().SetTargets(["/World/Robot/base"]) + joint.CreateBody1Rel().SetTargets(["/World/Robot/link"]) + UsdPhysics.ArticulationRootAPI.Apply(joint.GetPrim()) + + with pytest.raises(NotImplementedError): + schemas.apply_articulation_root_properties("/World/Robot/joint", [], fix_root_link=True) + with pytest.warns(DeprecationWarning, match="modify_articulation_root_properties"): + with pytest.raises(NotImplementedError): + schemas.modify_articulation_root_properties( + "/World/Robot/joint", schemas.ArticulationRootBaseCfg(fix_root_link=True) + ) + + @pytest.mark.isaacsim_ci def test_physx_articulation_root_writes_self_collisions(setup_simulation): """Setting ``enabled_self_collisions`` on ``PhysxArticulationRootPropertiesCfg`` must author From a3cf9b1ef833f8a5a9634f6ff60f87816b890aca Mon Sep 17 00:00:00 2001 From: Zeng Qingcheng <60593302+NeoZng@users.noreply.github.com> Date: Wed, 30 Sep 2026 02:32:29 +0800 Subject: [PATCH 3/9] Treat a collider target as a static body in the world-joint check UsdPhysics resolves a joint target that is a collider without a rigid body to a static body, and roots no articulation at that joint, so do not treat it as the world. Annotate the new helpers that return nothing and assert that the MJCF fixed-base roots are fixed joints. --- .../sim/converters/asset_converter_base.py | 4 ++-- source/isaaclab/isaaclab/sim/schemas/schemas.py | 7 +++++-- source/isaaclab/test/sim/test_mjcf_converter.py | 17 ++++++++++++----- source/isaaclab/test/sim/test_schemas.py | 15 +++++++++++---- 4 files changed, 30 insertions(+), 13 deletions(-) diff --git a/source/isaaclab/isaaclab/sim/converters/asset_converter_base.py b/source/isaaclab/isaaclab/sim/converters/asset_converter_base.py index 240891a1a041..65dffed71d4c 100644 --- a/source/isaaclab/isaaclab/sim/converters/asset_converter_base.py +++ b/source/isaaclab/isaaclab/sim/converters/asset_converter_base.py @@ -208,7 +208,7 @@ def _select_physics_variant(self, variant: str): variant_set.SetVariantSelection(variant) stage.GetRootLayer().Save() - def _root_articulations_at_world_joints(self, layered: bool): + def _root_articulations_at_world_joints(self, layered: bool) -> None: """Root each fixed-base articulation of the converted asset at the fixed joint that attaches it to the world. With ``fix_base``, the Isaac Sim importers keep a fixed world joint that the asset already has, such as the @@ -314,7 +314,7 @@ def _attaches_to_world(joint: Usd.Prim, body: Usd.Prim) -> bool: return True -def _move_articulation_root(source: Usd.Prim, target: Usd.Prim): +def _move_articulation_root(source: Usd.Prim, target: Usd.Prim) -> None: """Move the articulation-root schemas and their authored attributes from one prim to another. These are the schemas and attributes that the Isaac Sim importers move to a fixed joint that they create. The diff --git a/source/isaaclab/isaaclab/sim/schemas/schemas.py b/source/isaaclab/isaaclab/sim/schemas/schemas.py index b7a946ebc045..f9873072fe73 100644 --- a/source/isaaclab/isaaclab/sim/schemas/schemas.py +++ b/source/isaaclab/isaaclab/sim/schemas/schemas.py @@ -406,7 +406,8 @@ def define_articulation_root_properties( def _is_world_fixed_joint(prim: Usd.Prim) -> bool: """Return whether a prim is a fixed joint that attaches a body to the world. - UsdPhysics attaches a side of a joint to the world when its body target is empty or has no rigid-body ancestor. + UsdPhysics attaches a side of a joint to the world when its body target is empty, or when neither the target nor + an ancestor is a rigid body or a collider. A collider without a rigid-body ancestor is a static body. """ if not prim.IsA(UsdPhysics.FixedJoint): return False @@ -415,7 +416,9 @@ def _is_world_fixed_joint(prim: Usd.Prim) -> bool: for rel in (joint.GetBody0Rel(), joint.GetBody1Rel()): targets = rel.GetTargets() body = stage.GetPrimAtPath(targets[0]) if targets else None - while body and not body.IsPseudoRoot() and not body.HasAPI(UsdPhysics.RigidBodyAPI): + while body and not body.IsPseudoRoot(): + if body.HasAPI(UsdPhysics.RigidBodyAPI) or body.HasAPI(UsdPhysics.CollisionAPI): + break body = body.GetParent() if not body or body.IsPseudoRoot(): return True diff --git a/source/isaaclab/test/sim/test_mjcf_converter.py b/source/isaaclab/test/sim/test_mjcf_converter.py index 1b2014b89388..d6fbfe9bedb8 100644 --- a/source/isaaclab/test/sim/test_mjcf_converter.py +++ b/source/isaaclab/test/sim/test_mjcf_converter.py @@ -309,11 +309,17 @@ def test_run_asset_transformer_disabled(test_setup_teardown, tmp_path): @pytest.mark.parametrize( - "root_joint, root_name", - [('', "fix_base_joint"), ("", "PhysicsFixedJoint"), ('', "base")], + "root_joint, root_name, root_is_fixed_joint", + [ + ('', "fix_base_joint", True), + ("", "PhysicsFixedJoint", True), + ('', "base", False), + ], ids=["free", "welded", "slide"], ) -def test_fix_base_roots_articulation_at_world_joint(test_setup_teardown, tmp_path, root_joint, root_name): +def test_fix_base_roots_articulation_at_world_joint( + test_setup_teardown, tmp_path, root_joint, root_name, root_is_fixed_joint +): """Verify that fix_base=True roots the articulation at the fixed joint that attaches the root body to the world. The importer adds ``fix_base_joint`` for a free root body and keeps the weld of a root body without joints. @@ -334,8 +340,9 @@ def test_fix_base_roots_articulation_at_world_joint(test_setup_teardown, tmp_pat from pxr import Usd, UsdPhysics stage = Usd.Stage.Open(mjcf_converter.usd_path) - roots = [prim.GetName() for prim in stage.Traverse() if prim.HasAPI(UsdPhysics.ArticulationRootAPI)] - assert roots == [root_name] + roots = [prim for prim in stage.Traverse() if prim.HasAPI(UsdPhysics.ArticulationRootAPI)] + assert [root.GetName() for root in roots] == [root_name] + assert roots[0].IsA(UsdPhysics.FixedJoint) is root_is_fixed_joint @pytest.mark.isaacsim_ci diff --git a/source/isaaclab/test/sim/test_schemas.py b/source/isaaclab/test/sim/test_schemas.py index 8d4f0afcc681..c9fd3c85eacd 100644 --- a/source/isaaclab/test/sim/test_schemas.py +++ b/source/isaaclab/test/sim/test_schemas.py @@ -406,12 +406,19 @@ def test_modify_articulation_root_fix_root_link_uses_given_stage(setup_simulatio assert joint.GetJointEnabledAttr().Get() is False -def test_fix_root_link_rejects_internal_fixed_joint_root(setup_simulation): - """``fix_root_link=True`` must not treat an articulation root on a fixed joint between two bodies as fixed.""" +@pytest.mark.parametrize("static_base", [False, True], ids=["rigid_base", "static_collider_base"]) +def test_fix_root_link_rejects_internal_fixed_joint_root(setup_simulation, static_base): + """``fix_root_link=True`` must not treat an articulation root on a fixed joint between two bodies as fixed. + + A collider without a rigid body is a static body, not the world. + """ stage = sim_utils.get_current_stage() sim_utils.create_prim("/World/Robot", prim_type="Xform") - for name in ("base", "link"): - UsdPhysics.RigidBodyAPI.Apply(sim_utils.create_prim(f"/World/Robot/{name}", prim_type="Xform")) + if static_base: + UsdPhysics.CollisionAPI.Apply(sim_utils.create_prim("/World/Robot/base", prim_type="Cube")) + else: + UsdPhysics.RigidBodyAPI.Apply(sim_utils.create_prim("/World/Robot/base", prim_type="Xform")) + UsdPhysics.RigidBodyAPI.Apply(sim_utils.create_prim("/World/Robot/link", prim_type="Xform")) joint = UsdPhysics.FixedJoint.Define(stage, "/World/Robot/joint") joint.CreateBody0Rel().SetTargets(["/World/Robot/base"]) joint.CreateBody1Rel().SetTargets(["/World/Robot/link"]) From 14020d9970660985db165770d8076cf63afb6cb2 Mon Sep 17 00:00:00 2001 From: Zeng Qingcheng <60593302+NeoZng@users.noreply.github.com> Date: Thu, 1 Oct 2026 02:07:44 +0800 Subject: [PATCH 4/9] Keep only the fix_root_link handling of fixed-joint roots The importer layout is to be fixed in the Isaac Sim importer (isaac-sim/IsaacSim#859), so drop the post-import correction from the converters and its tests. Keep the fix_root_link=True handling of an articulation rooted at its fixed world joint, which such assets need once the importer roots them there, and test it on PhysX and Newton with a hand-authored USD in that layout. --- docs/source/how-to/make_fixed_prim.rst | 12 +- .../fix-fixed-base-articulation-root.rst | 23 +--- .../sim/converters/asset_converter_base.py | 113 ------------------ .../isaaclab/sim/converters/mjcf_converter.py | 8 +- .../sim/converters/mjcf_converter_cfg.py | 12 +- .../isaaclab/sim/converters/urdf_converter.py | 8 +- .../sim/converters/urdf_converter_cfg.py | 8 +- .../isaaclab/test/sim/test_mjcf_converter.py | 53 -------- source/isaaclab/test/sim/test_schemas.py | 31 +++++ .../isaaclab/test/sim/test_urdf_converter.py | 22 ++-- .../fix-fixed-base-articulation-root.skip | 2 +- .../test/assets/test_articulation.py | 96 ++++++++++----- .../fix-fixed-base-articulation-root.skip | 2 +- .../test/assets/test_articulation.py | 99 ++++++++++----- 14 files changed, 203 insertions(+), 286 deletions(-) diff --git a/docs/source/how-to/make_fixed_prim.rst b/docs/source/how-to/make_fixed_prim.rst index 7451bdd3bf90..ecf6a7b5c1e2 100644 --- a/docs/source/how-to/make_fixed_prim.rst +++ b/docs/source/how-to/make_fixed_prim.rst @@ -166,13 +166,13 @@ before the backend imports the articulation. 5. **Articulation root schema on the fixed joint**: UsdPhysics also allows the articulation root properties on the fixed joint that connects the root link to the - world. The articulation is parsed as a fixed-base system rooted at that joint. The URDF and MJCF converters - produce this arrangement with ``fix_base=True``. Setting + world. The articulation is parsed as a fixed-base system rooted at that joint. Setting :attr:`~sim.schemas.ArticulationRootBaseCfg.fix_root_link` to True enables the joint. Setting it to False does - not make such an articulation floating. It is ignored when the joint sets both ``body0`` and ``body1``, as the - joints of the converters do. A joint with an empty ``body0``, such as the joint that the MJCF importer adds - for a free root body, is disabled: PhysX then cannot create the articulation, while Newton simulates a - floating base. To obtain a floating base, convert the asset with ``fix_base=False`` instead. + not make such an articulation floating. It is ignored when the joint sets both ``body0`` and ``body1``, for + example when ``body0`` targets the asset prim. A joint with an empty ``body0``, such as the joint that the MJCF + importer adds for a free root body, is disabled: PhysX then cannot create the articulation, while Newton + simulates a floating base. To obtain a floating base, author the asset without the world joint, or convert it + with ``fix_base=False``. .. code-block:: text diff --git a/source/isaaclab/changelog.d/fix-fixed-base-articulation-root.rst b/source/isaaclab/changelog.d/fix-fixed-base-articulation-root.rst index 57c68baf4abf..0dadb23d5a49 100644 --- a/source/isaaclab/changelog.d/fix-fixed-base-articulation-root.rst +++ b/source/isaaclab/changelog.d/fix-fixed-base-articulation-root.rst @@ -1,22 +1,7 @@ Fixed ^^^^^ -* Fixed :class:`~isaaclab.sim.converters.UrdfConverter` and :class:`~isaaclab.sim.converters.MjcfConverter` - producing a floating-base PhysX articulation with ``fix_base=True`` when a fixed joint attaches the root link to - the world: the ``root_joint`` that the URDF importer adds, or the weld of an MJCF root body without joints. The - Isaac Sim importers left the articulation root on the root link, which UsdPhysics treats as a floating base, so - PhysX held the base only through that joint: ``is_fixed_base`` was False, root-pose writes were undone within a - few steps, and joints near the base could gain energy. The converters rooted the articulation at that joint - instead. Newton already treated these assets as fixed-base. -* Fixed ``fix_root_link=True`` raising ``NotImplementedError`` for an articulation rooted at its fixed world joint - when the joint sets both bodies, as for the converted assets above. It enabled that joint instead, so these assets - no longer got a second world joint, which Newton failed to import. - -Changed -^^^^^^^ - -* Changed the articulations of these converted assets to fixed-base on PhysX. ``num_base_dofs`` is 0, the Jacobian, - mass matrix and gravity compensation forces have no base degrees of freedom, a ``body_ordering`` must keep the root - link first, and root-pose writes and ``init_state`` place the base. On Newton, an explicit - ``articulation_root_prim_path`` has to name the fixed joint instead of the root link. USD files converted earlier, - including those in a reused ``usd_dir``, keep the floating-base layout until they are converted again. +* Fixed ``fix_root_link=True`` raising ``NotImplementedError`` for an articulation rooted at the fixed joint that + attaches it to the world when the joint's ``body0`` targets the asset prim, the fixed-base layout that UsdPhysics + recommends. It enabled that joint instead of raising or adding a second world joint. A fixed joint between two + bodies, or between a body and a static collider, still raises the error. diff --git a/source/isaaclab/isaaclab/sim/converters/asset_converter_base.py b/source/isaaclab/isaaclab/sim/converters/asset_converter_base.py index 65dffed71d4c..753601334829 100644 --- a/source/isaaclab/isaaclab/sim/converters/asset_converter_base.py +++ b/source/isaaclab/isaaclab/sim/converters/asset_converter_base.py @@ -3,8 +3,6 @@ # # SPDX-License-Identifier: BSD-3-Clause -from __future__ import annotations - import abc import hashlib import json @@ -14,21 +12,14 @@ import random import tempfile from datetime import datetime -from typing import TYPE_CHECKING from ...utils import to_dict, validate from ...utils.assets import check_file_path from ...utils.io import dump_yaml from .asset_converter_base_cfg import AssetConverterBaseCfg -if TYPE_CHECKING: - from pxr import Usd - logger = logging.getLogger(__name__) -_ARTICULATION_ROOT_ATTRIBUTES = ("newton:selfCollisionEnabled", "newton:jointsAddMobility") -"""Attributes of the Newton articulation-root schema, which move with the articulation root.""" - class AssetConverterBase(abc.ABC): """Base class for converting an asset file from different formats into USD format. @@ -208,52 +199,6 @@ def _select_physics_variant(self, variant: str): variant_set.SetVariantSelection(variant) stage.GetRootLayer().Save() - def _root_articulations_at_world_joints(self, layered: bool) -> None: - """Root each fixed-base articulation of the converted asset at the fixed joint that attaches it to the world. - - With ``fix_base``, the Isaac Sim importers keep a fixed world joint that the asset already has, such as the - root joint that the URDF importer adds or the weld of an MJCF body without joints, but leave the articulation - root on the root body. UsdPhysics roots a fixed-base articulation at its world joint or at an ancestor of - that joint, while an articulation rooted at a rigid body is floating: PhysX simulates such an asset as a - floating articulation that the world joint only holds as an additional constraint. The importers root the - fixed joint that they create themselves in the same way. A root body on a revolute, prismatic, or D6 world - joint keeps the root, since PhysX would weld an articulation rooted at that joint. - - The importers author the articulation roots in the physics layer, which the other physics layers include. - Isaac Sim's ``fix_articulation_root_for_fixed_base`` does not apply here: it skips the prims that the - physics layer only overrides, and it also roots non-fixed world joints. - - Remove this correction when the pinned Isaac Sim importers root an existing world joint as well - (isaac-sim/IsaacSim#859). - - Args: - layered: Whether the asset transformer of the importer split the asset into layers. - """ - from pxr import Usd, UsdPhysics - - if layered: - usd_path = os.path.join(os.path.dirname(self.usd_path), "payloads", "Physics", "physics.usda") - else: - usd_path = self.usd_path - if not os.path.isfile(usd_path): - logger.warning(f"Cannot root the articulations at their world joints: '{usd_path}' does not exist.") - return - stage = Usd.Stage.Open(usd_path) - # the physics layer only adds opinions under the asset's prims, so it has to be traversed with the - # all-prims predicate to reach them - prims = list(stage.TraverseAll()) - joints = [prim for prim in prims if prim.IsA(UsdPhysics.FixedJoint)] - modified = False - for body in prims: - if not (body.HasAPI(UsdPhysics.ArticulationRootAPI) and body.HasAPI(UsdPhysics.RigidBodyAPI)): - continue - world_joint = next((joint for joint in joints if _attaches_to_world(joint, body)), None) - if world_joint is not None: - _move_articulation_root(body, world_joint) - modified = True - if modified: - stage.GetRootLayer().Save() - @staticmethod def _config_to_hash(cfg: AssetConverterBaseCfg) -> str: """Converts the configuration object and asset file to an MD5 hash of a string. @@ -289,61 +234,3 @@ def _config_to_hash(cfg: AssetConverterBaseCfg) -> str: md5.update(data) # return the hash return md5.hexdigest() - - -def _attaches_to_world(joint: Usd.Prim, body: Usd.Prim) -> bool: - """Return whether an articulation joint attaches a body, as its second body, to the world. - - UsdPhysics attaches a joint to the closest rigid-body ancestor of its target, and to the world without one. - """ - from pxr import UsdPhysics - - joint_api = UsdPhysics.Joint(joint) - if not joint.IsActive() or not joint_api.GetJointEnabledAttr().Get(): - return False - if joint_api.GetExcludeFromArticulationAttr().Get(): - return False - if joint_api.GetBody1Rel().GetTargets() != [body.GetPath()]: - return False - targets = joint_api.GetBody0Rel().GetTargets() - prim = joint.GetStage().GetPrimAtPath(targets[0]) if targets else None - while prim and not prim.IsPseudoRoot(): - if prim.HasAPI(UsdPhysics.RigidBodyAPI): - return False - prim = prim.GetParent() - return True - - -def _move_articulation_root(source: Usd.Prim, target: Usd.Prim) -> None: - """Move the articulation-root schemas and their authored attributes from one prim to another. - - These are the schemas and attributes that the Isaac Sim importers move to a fixed joint that they create. The - schema names are read from the authored ``apiSchemas``, since USD leaves schemas without a registered - definition out of :meth:`~pxr.Usd.Prim.GetAppliedSchemas`, which happens without Kit. - - Raises: - RuntimeError: If a schema or an attribute cannot be moved. - """ - from pxr import Sdf - - api_schemas = source.GetMetadata("apiSchemas") or Sdf.TokenListOp() - names = [ - name - for name in api_schemas.GetAddedOrExplicitItems() - if "ArticulationRoot" in name or name == "PhysxArticulationAPI" - ] - for name in names: - if not target.AddAppliedSchema(name): - raise RuntimeError(f"Failed to apply '{name}' to '{target.GetPath()}'.") - for attr in source.GetAttributes(): - name = attr.GetName() - is_root_attribute = name.startswith("physxArticulation:") or name in _ARTICULATION_ROOT_ATTRIBUTES - if not (is_root_attribute and attr.HasAuthoredValue()): - continue - if not target.CreateAttribute(name, attr.GetTypeName(), custom=False).Set(attr.Get()): - raise RuntimeError(f"Failed to move '{attr.GetPath()}' to '{target.GetPath()}'.") - if not source.RemoveProperty(name): - raise RuntimeError(f"Failed to remove '{attr.GetPath()}'.") - for name in names: - if not source.RemoveAppliedSchema(name): - raise RuntimeError(f"Failed to remove '{name}' from '{source.GetPath()}'.") diff --git a/source/isaaclab/isaaclab/sim/converters/mjcf_converter.py b/source/isaaclab/isaaclab/sim/converters/mjcf_converter.py index f6536ec47836..085758f2921e 100644 --- a/source/isaaclab/isaaclab/sim/converters/mjcf_converter.py +++ b/source/isaaclab/isaaclab/sim/converters/mjcf_converter.py @@ -21,10 +21,8 @@ class MjcfConverter(AssetConverterBase): from the standalone ``isaacsim-asset-isolated`` package. All conversion logic (USD schema application, fix-base, density, actuator gains, self-collision, mesh merging, asset transformer profile) is performed by :class:`~isaacsim.asset.importer.mjcf.MJCFImporter` — - this class translates :class:`MjcfConverterCfg` into a flat - :class:`~isaacsim.asset.importer.mjcf.MJCFImporterConfig`. After the import, it only selects the - configured physics variant and roots a fixed-base articulation at its world joint (see - :attr:`MjcfConverterCfg.fix_base`). + this class only translates :class:`MjcfConverterCfg` into a flat + :class:`~isaacsim.asset.importer.mjcf.MJCFImporterConfig`. .. caution:: The current lazy conversion implementation does not automatically trigger USD generation if @@ -96,5 +94,3 @@ def _convert_asset(self, cfg: MjcfConverterCfg): if generated_usd_path: generated_usd_path = os.path.normpath(generated_usd_path) self._usd_file_name = os.path.relpath(generated_usd_path, self.usd_dir) - if cfg.fix_base: - self._root_articulations_at_world_joints(layered=cfg.run_asset_transformer) diff --git a/source/isaaclab/isaaclab/sim/converters/mjcf_converter_cfg.py b/source/isaaclab/isaaclab/sim/converters/mjcf_converter_cfg.py index f17e61c0a4cf..11ee9d1f80bd 100644 --- a/source/isaaclab/isaaclab/sim/converters/mjcf_converter_cfg.py +++ b/source/isaaclab/isaaclab/sim/converters/mjcf_converter_cfg.py @@ -18,8 +18,7 @@ class MjcfConverterCfg(AssetConverterBaseCfg): Maps to :class:`~isaacsim.asset.importer.mjcf.MJCFImporterConfig` from the Isaac Sim MJCF importer. All post-import USD edits (fix-base, density override, actuator gain overrides, self-collision, mesh merging, asset transformer profile) are performed by - the Isaac Sim importer — this config just forwards the user's choices. The converter only roots a - fixed-base articulation at its world joint afterwards, see :attr:`fix_base`. + the Isaac Sim importer — this config just forwards the user's choices. .. note:: From Isaac Sim 5.0 onwards, the MJCF importer was rewritten to use the @@ -47,12 +46,11 @@ class MjcfConverterCfg(AssetConverterBaseCfg): """Import the physics scene (time step per second, gravity, etc.) from the MJCF file. Defaults to False.""" fix_base: bool = False - """Fix the root body of the articulation to the world. Defaults to False. + """Add a fixed joint from the world to the root rigid-body link. Defaults to False. - When enabled, :class:`~isaacsim.asset.importer.mjcf.MJCFImporter` adds a ``FixedJoint`` between the world and - a free root body and keeps the weld of a root body without joints, and the articulation is rooted at that fixed - joint so that PhysX treats it as fixed-base. A root body with hinge or slide joints stays attached to the world - by those joints, which PhysX simulates outside the articulation. + When enabled, :class:`~isaacsim.asset.importer.mjcf.MJCFImporter` inserts a ``FixedJoint`` + between the world and the articulation root and relocates ``ArticulationRootAPI`` onto the + appropriate ancestor prim so PhysX treats the articulation as fixed-base. """ link_density: float = 0.0 diff --git a/source/isaaclab/isaaclab/sim/converters/urdf_converter.py b/source/isaaclab/isaaclab/sim/converters/urdf_converter.py index 7d73799d52a4..5585686c6def 100644 --- a/source/isaaclab/isaaclab/sim/converters/urdf_converter.py +++ b/source/isaaclab/isaaclab/sim/converters/urdf_converter.py @@ -28,10 +28,8 @@ class UrdfConverter(AssetConverterBase): The heavy lifting (URDF parsing, fixed-joint merging, fix-base insertion, joint-drive configuration, density override, asset transformer profile) is delegated to Isaac Sim's :class:`~isaacsim.asset.importer.urdf.URDFImporter` together with - :class:`~isaacsim.asset.importer.urdf.URDFImporterConfig`. IsaacLab translates its - user-friendly :class:`UrdfConverterCfg` into the flat importer config. After the import, it only - selects the configured physics variant and roots a fixed-base articulation at its world joint (see - :attr:`UrdfConverterCfg.fix_base`). + :class:`~isaacsim.asset.importer.urdf.URDFImporterConfig`. IsaacLab only translates its + user-friendly :class:`UrdfConverterCfg` into the flat importer config. .. caution:: The current lazy conversion implementation does not automatically trigger USD generation if @@ -119,8 +117,6 @@ def _convert_asset(self, cfg: UrdfConverterCfg): if generated_usd_path: generated_usd_path = os.path.normpath(generated_usd_path) self._usd_file_name = os.path.relpath(generated_usd_path, self.usd_dir) - if cfg.fix_base: - self._root_articulations_at_world_joints(layered=cfg.run_asset_transformer) @staticmethod def _warn_unsupported_features(cfg: UrdfConverterCfg): diff --git a/source/isaaclab/isaaclab/sim/converters/urdf_converter_cfg.py b/source/isaaclab/isaaclab/sim/converters/urdf_converter_cfg.py index c319647b69ad..729dceaa4b52 100644 --- a/source/isaaclab/isaaclab/sim/converters/urdf_converter_cfg.py +++ b/source/isaaclab/isaaclab/sim/converters/urdf_converter_cfg.py @@ -102,13 +102,7 @@ class NaturalFrequencyGainsCfg: """The drive gains configuration.""" fix_base: bool = MISSING - """Fix the root link to the world. - - The articulation is rooted at the fixed joint that attaches the root link to the world, the ``root_joint`` that - the importer adds or the fixed joint from a ``world`` link, so that PhysX treats it as fixed-base. A root link - attached to a ``world`` link by a non-fixed joint keeps the articulation root, and PhysX simulates that joint - outside the articulation. - """ + """Create a fix joint to the root/base link.""" root_link_name: str | None = None """The name of the root link. Defaults to None. diff --git a/source/isaaclab/test/sim/test_mjcf_converter.py b/source/isaaclab/test/sim/test_mjcf_converter.py index d6fbfe9bedb8..7a0d05435d41 100644 --- a/source/isaaclab/test/sim/test_mjcf_converter.py +++ b/source/isaaclab/test/sim/test_mjcf_converter.py @@ -42,22 +42,6 @@ # Portable NVIDIA Ant MJCF; ``newton`` is a base dependency of every environment. _PORTABLE_MJCF = os.path.join(os.path.dirname(newton.__file__), "examples", "assets", "nv_ant.xml") -# Root body with one hinge child. Without a joint of its own, MJCF welds the root body to the world. -_FIXED_BASE_MJCF = """ - - - - {root_joint} - - - - - - - - -""" - @pytest.fixture(autouse=True) def test_setup_teardown(): @@ -308,43 +292,6 @@ def test_run_asset_transformer_disabled(test_setup_teardown, tmp_path): assert sim.stage.GetPrimAtPath(prim_path).IsValid() -@pytest.mark.parametrize( - "root_joint, root_name, root_is_fixed_joint", - [ - ('', "fix_base_joint", True), - ("", "PhysicsFixedJoint", True), - ('', "base", False), - ], - ids=["free", "welded", "slide"], -) -def test_fix_base_roots_articulation_at_world_joint( - test_setup_teardown, tmp_path, root_joint, root_name, root_is_fixed_joint -): - """Verify that fix_base=True roots the articulation at the fixed joint that attaches the root body to the world. - - The importer adds ``fix_base_joint`` for a free root body and keeps the weld of a root body without joints. - UsdPhysics roots a fixed-base articulation at its world joint, while an articulation rooted at the root body is - floating. A root body on a slide joint keeps the root, since PhysX would weld an articulation rooted at the - slide joint. - """ - sim, config = test_setup_teardown - mjcf_path = tmp_path / "fixed_base.xml" - mjcf_path.write_text(_FIXED_BASE_MJCF.format(root_joint=root_joint)) - - config.asset_path = str(mjcf_path) - config.fix_base = True - config.force_usd_conversion = True - config.usd_dir = str(tmp_path / "usd") - mjcf_converter = MjcfConverter(config) - - from pxr import Usd, UsdPhysics - - stage = Usd.Stage.Open(mjcf_converter.usd_path) - roots = [prim for prim in stage.Traverse() if prim.HasAPI(UsdPhysics.ArticulationRootAPI)] - assert [root.GetName() for root in roots] == [root_name] - assert roots[0].IsA(UsdPhysics.FixedJoint) is root_is_fixed_joint - - @pytest.mark.isaacsim_ci def test_override_actuator_gains(test_setup_teardown, tmp_path): """Verify that actuator gain overrides are written to ``MjcActuator`` prims. diff --git a/source/isaaclab/test/sim/test_schemas.py b/source/isaaclab/test/sim/test_schemas.py index c9fd3c85eacd..6da2325c0e20 100644 --- a/source/isaaclab/test/sim/test_schemas.py +++ b/source/isaaclab/test/sim/test_schemas.py @@ -406,6 +406,37 @@ def test_modify_articulation_root_fix_root_link_uses_given_stage(setup_simulatio assert joint.GetJointEnabledAttr().Get() is False +@pytest.mark.parametrize("legacy_writer", [False, True], ids=["fragments", "legacy"]) +def test_fix_root_link_enables_fixed_world_joint_root(setup_simulation, legacy_writer): + """``fix_root_link=True`` must enable the fixed joint that roots an articulation and attaches it to the world. + + The joint targets the asset prim, which is not a rigid body, so it attaches the root link to the world. No second + world joint is created and the root stays on the joint. + """ + stage = sim_utils.get_current_stage() + sim_utils.create_prim("/World/Robot", prim_type="Xform") + UsdPhysics.RigidBodyAPI.Apply(sim_utils.create_prim("/World/Robot/base", prim_type="Xform")) + joint = UsdPhysics.FixedJoint.Define(stage, "/World/Robot/root_joint") + joint.CreateBody0Rel().SetTargets(["/World/Robot"]) + joint.CreateBody1Rel().SetTargets(["/World/Robot/base"]) + joint.CreateJointEnabledAttr(False) + UsdPhysics.ArticulationRootAPI.Apply(joint.GetPrim()) + + if legacy_writer: + with pytest.warns(DeprecationWarning, match="modify_articulation_root_properties"): + schemas.modify_articulation_root_properties( + "/World/Robot/root_joint", schemas.ArticulationRootBaseCfg(fix_root_link=True) + ) + else: + schemas.apply_articulation_root_properties("/World/Robot/root_joint", [], fix_root_link=True) + + assert joint.GetJointEnabledAttr().Get() is True + robot = stage.GetPrimAtPath("/World/Robot") + assert [prim.GetName() for prim in Usd.PrimRange(robot) if prim.IsA(UsdPhysics.Joint)] == ["root_joint"] + roots = [prim.GetName() for prim in Usd.PrimRange(robot) if prim.HasAPI(UsdPhysics.ArticulationRootAPI)] + assert roots == ["root_joint"] + + @pytest.mark.parametrize("static_base", [False, True], ids=["rigid_base", "static_collider_base"]) def test_fix_root_link_rejects_internal_fixed_joint_root(setup_simulation, static_base): """``fix_root_link=True`` must not treat an articulation root on a fixed joint between two bodies as fixed. diff --git a/source/isaaclab/test/sim/test_urdf_converter.py b/source/isaaclab/test/sim/test_urdf_converter.py index 7da16b07ef08..1a49377c1f87 100644 --- a/source/isaaclab/test/sim/test_urdf_converter.py +++ b/source/isaaclab/test/sim/test_urdf_converter.py @@ -234,19 +234,13 @@ def test_merge_fixed_joints_converter(sim_config, tmp_path): @pytest.mark.isaacsim_ci -@pytest.mark.parametrize("run_asset_transformer", [True, False], ids=["layered", "flat"]) -def test_fix_base_roots_articulation_at_world_joint(sim_config, tmp_path, run_asset_transformer): - """Verify that fix_base=True roots the articulation at the fixed joint that attaches the root link to the world. - - The importer adds that joint as ``root_joint``. UsdPhysics roots a fixed-base articulation at its world joint, - while an articulation rooted at the root link is floating. - """ +def test_fix_base_creates_fixed_joint(sim_config, tmp_path): + """Verify that fix_base=True creates a FixedJoint in the output USD.""" sim, config = sim_config output_dir = os.path.join(str(tmp_path), "urdf_fix_base") os.makedirs(output_dir, exist_ok=True) config.fix_base = True - config.run_asset_transformer = run_asset_transformer config.force_usd_conversion = True config.usd_dir = output_dir urdf_converter = UrdfConverter(config) @@ -254,9 +248,15 @@ def test_fix_base_roots_articulation_at_world_joint(sim_config, tmp_path, run_as from pxr import Usd, UsdPhysics stage = Usd.Stage.Open(urdf_converter.usd_path) - roots = [prim for prim in stage.Traverse() if prim.HasAPI(UsdPhysics.ArticulationRootAPI)] - assert [root.GetName() for root in roots] == ["root_joint"] - assert roots[0].IsA(UsdPhysics.FixedJoint) + + # search for a FixedJoint in the output + fixed_joints = [p for p in stage.Traverse() if p.IsA(UsdPhysics.FixedJoint)] + assert len(fixed_joints) > 0, "Expected at least one FixedJoint from fix_base=True" + + # the first FixedJoint should target a rigid body link via body1 + fj = UsdPhysics.FixedJoint(fixed_joints[0]) + body1_targets = fj.GetBody1Rel().GetTargets() + assert len(body1_targets) > 0, "FixedJoint should target a rigid body link via body1" @pytest.mark.isaacsim_ci diff --git a/source/isaaclab_newton/changelog.d/fix-fixed-base-articulation-root.skip b/source/isaaclab_newton/changelog.d/fix-fixed-base-articulation-root.skip index d5826dbb9cb0..6c948a921cd0 100644 --- a/source/isaaclab_newton/changelog.d/fix-fixed-base-articulation-root.skip +++ b/source/isaaclab_newton/changelog.d/fix-fixed-base-articulation-root.skip @@ -1 +1 @@ -Added Newton articulation tests for URDF assets converted with a fixed base. +Added Newton articulation tests for an articulation rooted at its fixed world joint. diff --git a/source/isaaclab_newton/test/assets/test_articulation.py b/source/isaaclab_newton/test/assets/test_articulation.py index ebd9ff9fe44e..2d501e529579 100644 --- a/source/isaaclab_newton/test/assets/test_articulation.py +++ b/source/isaaclab_newton/test/assets/test_articulation.py @@ -186,24 +186,68 @@ class CustomDrive(ImplicitActuator): """Implicit actuator with a class name that does not encode its execution type.""" -# Base link with one revolute child. The URDF importer attaches the base link to the asset's root prim. -_FIXED_BASE_URDF = """ - - - - - - - - - - - - - - - - +# Base link with one revolute child, rooted at the fixed joint that attaches the base link to the asset prim, the +# fixed-base layout that UsdPhysics recommends. +_FIXED_JOINT_ROOT_USDA = """#usda 1.0 +( + defaultPrim = "Robot" + metersPerUnit = 1 + upAxis = "Z" +) + +def Xform "Robot" +{ + def Xform "base" ( + prepend apiSchemas = ["PhysicsRigidBodyAPI", "PhysicsMassAPI"] + ) + { + float physics:mass = 5 + float3 physics:diagonalInertia = (0.05, 0.05, 0.05) + + def Cube "collision" ( + prepend apiSchemas = ["PhysicsCollisionAPI"] + ) + { + double size = 0.2 + } + } + + def Xform "arm" ( + prepend apiSchemas = ["PhysicsRigidBodyAPI", "PhysicsMassAPI"] + ) + { + float physics:mass = 1 + float3 physics:diagonalInertia = (0.001, 0.01, 0.01) + double3 xformOp:translate = (0.25, 0, 0) + uniform token[] xformOpOrder = ["xformOp:translate"] + + def Cube "collision" ( + prepend apiSchemas = ["PhysicsCollisionAPI"] + ) + { + double size = 0.1 + } + } + + def PhysicsFixedJoint "root_joint" ( + prepend apiSchemas = ["PhysicsArticulationRootAPI"] + ) + { + rel physics:body0 = + rel physics:body1 = + } + + def PhysicsRevoluteJoint "hinge" + { + rel physics:body0 = + rel physics:body1 = + uniform token physics:axis = "Y" + point3f physics:localPos0 = (0.1, 0, 0) + point3f physics:localPos1 = (-0.15, 0, 0) + float physics:lowerLimit = -60 + float physics:upperLimit = 60 + } +} """ @@ -1704,17 +1748,15 @@ def test_fragment_fix_root_link_uses_base_manager(sim, device, add_ground_plane, @pytest.mark.parametrize("device", test_devices(DeviceScope.CUDA)) @pytest.mark.parametrize("articulation_type", ["single_joint_implicit"]) @pytest.mark.parametrize("fix_root_link", [None, True]) -def test_initialization_fixed_base_urdf(sim, num_articulations, device, articulation_type, fix_root_link, tmp_path): - """Test that a URDF converted with ``fix_base=True`` initializes as a fixed-base articulation. +def test_initialization_fixed_joint_root(sim, num_articulations, device, articulation_type, fix_root_link, tmp_path): + """Test an articulation rooted at the fixed joint that attaches its root link to the asset prim. - The articulation is rooted at the fixed joint ``root_joint`` that the importer adds. Fixing the root link through - the spawner as well must reuse that joint instead of adding a second one, which Newton cannot import. + It initializes as a fixed-base articulation. Fixing the root link through the spawner as well must enable that + joint instead of raising or adding a second one. """ - urdf_path = tmp_path / "fixed_base.urdf" - urdf_path.write_text(_FIXED_BASE_URDF) - spawn = sim_utils.UrdfFileCfg( - asset_path=str(urdf_path), usd_dir=str(tmp_path / "usd"), fix_base=True, fix_root_link=fix_root_link - ) + usd_path = tmp_path / "fixed_joint_root.usda" + usd_path.write_text(_FIXED_JOINT_ROOT_USDA) + spawn = sim_utils.UsdFileCfg(usd_path=str(usd_path), fix_root_link=fix_root_link) articulation, translations = generate_articulation( ArticulationCfg(spawn=spawn, actuators={}), num_articulations, device ) diff --git a/source/isaaclab_physx/changelog.d/fix-fixed-base-articulation-root.skip b/source/isaaclab_physx/changelog.d/fix-fixed-base-articulation-root.skip index 255b26b424cc..b790c36ec24c 100644 --- a/source/isaaclab_physx/changelog.d/fix-fixed-base-articulation-root.skip +++ b/source/isaaclab_physx/changelog.d/fix-fixed-base-articulation-root.skip @@ -1 +1 @@ -Added PhysX articulation tests for URDF assets converted with a fixed base, with physics replication. +Added PhysX articulation tests for an articulation rooted at its fixed world joint, with physics replication. diff --git a/source/isaaclab_physx/test/assets/test_articulation.py b/source/isaaclab_physx/test/assets/test_articulation.py index 12ddfb4c1bc9..0f6f34491200 100644 --- a/source/isaaclab_physx/test/assets/test_articulation.py +++ b/source/isaaclab_physx/test/assets/test_articulation.py @@ -57,24 +57,68 @@ ) from isaaclab_assets.robots.shadow_hand import SHADOW_HAND_PHYSX_CFG -# Base link with one revolute child. The URDF importer attaches the base link to the asset's root prim. -_FIXED_BASE_URDF = """ - - - - - - - - - - - - - - - - +# Base link with one revolute child, rooted at the fixed joint that attaches the base link to the asset prim, the +# fixed-base layout that UsdPhysics recommends. +_FIXED_JOINT_ROOT_USDA = """#usda 1.0 +( + defaultPrim = "Robot" + metersPerUnit = 1 + upAxis = "Z" +) + +def Xform "Robot" +{ + def Xform "base" ( + prepend apiSchemas = ["PhysicsRigidBodyAPI", "PhysicsMassAPI"] + ) + { + float physics:mass = 5 + float3 physics:diagonalInertia = (0.05, 0.05, 0.05) + + def Cube "collision" ( + prepend apiSchemas = ["PhysicsCollisionAPI"] + ) + { + double size = 0.2 + } + } + + def Xform "arm" ( + prepend apiSchemas = ["PhysicsRigidBodyAPI", "PhysicsMassAPI"] + ) + { + float physics:mass = 1 + float3 physics:diagonalInertia = (0.001, 0.01, 0.01) + double3 xformOp:translate = (0.25, 0, 0) + uniform token[] xformOpOrder = ["xformOp:translate"] + + def Cube "collision" ( + prepend apiSchemas = ["PhysicsCollisionAPI"] + ) + { + double size = 0.1 + } + } + + def PhysicsFixedJoint "root_joint" ( + prepend apiSchemas = ["PhysicsArticulationRootAPI"] + ) + { + rel physics:body0 = + rel physics:body1 = + } + + def PhysicsRevoluteJoint "hinge" + { + rel physics:body0 = + rel physics:body1 = + uniform token physics:axis = "Y" + point3f physics:localPos0 = (0.1, 0, 0) + point3f physics:localPos1 = (-0.15, 0, 0) + float physics:lowerLimit = -60 + float physics:upperLimit = 60 + } +} """ @@ -807,19 +851,16 @@ def test_initialization_floating_base_made_fixed_base(sim, num_articulations, de @pytest.mark.parametrize("num_articulations", [2]) @pytest.mark.parametrize("device", test_devices(DeviceScope.CUDA)) @pytest.mark.parametrize("fix_root_link", [None, True]) -def test_initialization_fixed_base_urdf(sim, num_articulations, device, fix_root_link, tmp_path): - """Test that a URDF converted with ``fix_base=True`` initializes as a fixed-base articulation. +def test_initialization_fixed_joint_root(sim, num_articulations, device, fix_root_link, tmp_path): + """Test an articulation rooted at the fixed joint that attaches its root link to the asset prim. - The importer attaches the root link to the world with the fixed joint ``root_joint``. The articulation has to be - rooted at that joint, since PhysX simulates an articulation rooted at the root link as a floating base. With - physics replication, each environment has to keep its base at its origin. Fixing the root link through the - spawner as well must reuse that joint instead of adding a second one. + It initializes as a fixed-base articulation, and with physics replication each environment keeps its base at its + origin. Fixing the root link through the spawner as well must enable that joint instead of raising or adding a + second one. """ - urdf_path = tmp_path / "fixed_base.urdf" - urdf_path.write_text(_FIXED_BASE_URDF) - spawn = sim_utils.UrdfFileCfg( - asset_path=str(urdf_path), usd_dir=str(tmp_path / "usd"), fix_base=True, fix_root_link=fix_root_link - ) + usd_path = tmp_path / "fixed_joint_root.usda" + usd_path.write_text(_FIXED_JOINT_ROOT_USDA) + spawn = sim_utils.UsdFileCfg(usd_path=str(usd_path), fix_root_link=fix_root_link) cfg = ArticulationCfg(prim_path="/World/Env_[^/]*/Robot", spawn=spawn, actuators={}) sim_utils.create_prim("/World/Env_0", "Xform") # the grid places every environment away from the world origin From 762c3fe2f42216738c96e160ea45a7938f58297a Mon Sep 17 00:00:00 2001 From: Zeng Qingcheng <60593302+NeoZng@users.noreply.github.com> Date: Thu, 1 Oct 2026 18:38:02 +0800 Subject: [PATCH 5/9] Move the world fixed-joint check to the sim utilities Move _is_world_fixed_joint to isaaclab.sim.utils.queries, next to find_global_fixed_joint_prim, and keep it private: only the legacy schema writer and PhysicsManager.fix_articulation_root use it. Author the test asset rooted at its fixed world joint with the USD API instead of a USDA string, and reduce the PhysX and Newton tests to fix_root_link=True on that asset. Write the changelog fragment in the towncrier format and empty the skip markers. --- ...ix-fixed-base-articulation-root.fixed.rst} | 5 +- .../isaaclab/physics/physics_manager.py | 3 +- .../isaaclab/isaaclab/sim/schemas/schemas.py | 23 +--- source/isaaclab/isaaclab/sim/utils/queries.py | 32 +++++ source/isaaclab/test/sim/test_schemas.py | 11 +- .../fix-fixed-base-articulation-root.skip | 1 - .../test/assets/test_articulation.py | 117 ++++------------- .../fix-fixed-base-articulation-root.skip | 1 - .../test/assets/test_articulation.py | 120 ++++-------------- 9 files changed, 93 insertions(+), 220 deletions(-) rename source/isaaclab/changelog.d/{fix-fixed-base-articulation-root.rst => fix-fixed-base-articulation-root.fixed.rst} (70%) diff --git a/source/isaaclab/changelog.d/fix-fixed-base-articulation-root.rst b/source/isaaclab/changelog.d/fix-fixed-base-articulation-root.fixed.rst similarity index 70% rename from source/isaaclab/changelog.d/fix-fixed-base-articulation-root.rst rename to source/isaaclab/changelog.d/fix-fixed-base-articulation-root.fixed.rst index 0dadb23d5a49..588770553ba2 100644 --- a/source/isaaclab/changelog.d/fix-fixed-base-articulation-root.rst +++ b/source/isaaclab/changelog.d/fix-fixed-base-articulation-root.fixed.rst @@ -1,7 +1,4 @@ -Fixed -^^^^^ - * Fixed ``fix_root_link=True`` raising ``NotImplementedError`` for an articulation rooted at the fixed joint that attaches it to the world when the joint's ``body0`` targets the asset prim, the fixed-base layout that UsdPhysics - recommends. It enabled that joint instead of raising or adding a second world joint. A fixed joint between two + recommends. It now enables that joint instead of raising or adding a second world joint. A fixed joint between two bodies, or between a body and a static collider, still raises the error. diff --git a/source/isaaclab/isaaclab/physics/physics_manager.py b/source/isaaclab/isaaclab/physics/physics_manager.py index 26f07b412f81..cb8cadce6f42 100644 --- a/source/isaaclab/isaaclab/physics/physics_manager.py +++ b/source/isaaclab/isaaclab/physics/physics_manager.py @@ -124,8 +124,9 @@ def fix_articulation_root(cls, articulation_prim: Any, stage: Any = None) -> Any # managers.manager_base before the simulation app starts. from pxr import UsdPhysics # noqa: PLC0415 - from ..sim.schemas.schemas import _is_world_fixed_joint, create_world_fixed_joint # noqa: PLC0415 + from ..sim.schemas.schemas import create_world_fixed_joint # noqa: PLC0415 from ..sim.utils import find_global_fixed_joint_prim # noqa: PLC0415 + from ..sim.utils.queries import _is_world_fixed_joint # noqa: PLC0415 if stage is None: stage = get_current_stage() diff --git a/source/isaaclab/isaaclab/sim/schemas/schemas.py b/source/isaaclab/isaaclab/sim/schemas/schemas.py index f9873072fe73..eb595be7ffcb 100644 --- a/source/isaaclab/isaaclab/sim/schemas/schemas.py +++ b/source/isaaclab/isaaclab/sim/schemas/schemas.py @@ -29,6 +29,7 @@ safe_set_attribute_on_usd_prim, safe_set_attribute_on_usd_schema, ) +from ..utils.queries import _is_world_fixed_joint from ..utils.stage import get_current_stage from . import schemas_cfg from .backend_hooks import skip_joint_drive @@ -403,28 +404,6 @@ def define_articulation_root_properties( modify_articulation_root_properties.__wrapped__(prim_path, cfg, stage) -def _is_world_fixed_joint(prim: Usd.Prim) -> bool: - """Return whether a prim is a fixed joint that attaches a body to the world. - - UsdPhysics attaches a side of a joint to the world when its body target is empty, or when neither the target nor - an ancestor is a rigid body or a collider. A collider without a rigid-body ancestor is a static body. - """ - if not prim.IsA(UsdPhysics.FixedJoint): - return False - joint = UsdPhysics.FixedJoint(prim) - stage = prim.GetStage() - for rel in (joint.GetBody0Rel(), joint.GetBody1Rel()): - targets = rel.GetTargets() - body = stage.GetPrimAtPath(targets[0]) if targets else None - while body and not body.IsPseudoRoot(): - if body.HasAPI(UsdPhysics.RigidBodyAPI) or body.HasAPI(UsdPhysics.CollisionAPI): - break - body = body.GetParent() - if not body or body.IsPseudoRoot(): - return True - return False - - def create_world_fixed_joint(articulation_prim: Usd.Prim, stage: Usd.Stage) -> None: """Author a ``UsdPhysics.FixedJoint`` fixing an articulation root link to the world frame. diff --git a/source/isaaclab/isaaclab/sim/utils/queries.py b/source/isaaclab/isaaclab/sim/utils/queries.py index 0994b049c44c..32f7dd0f9d17 100644 --- a/source/isaaclab/isaaclab/sim/utils/queries.py +++ b/source/isaaclab/isaaclab/sim/utils/queries.py @@ -564,6 +564,38 @@ def find_global_fixed_joint_prim( return fixed_joint_prim +def _is_world_fixed_joint(prim: Usd.Prim) -> bool: + """Check whether a prim is a fixed joint that attaches a body to the simulation world. + + UsdPhysics attaches a side of a joint to the world when its body target is empty, or when neither the target nor + an ancestor is a rigid body or a collider. A collider without a rigid-body ancestor is a static body, not the + world. Unlike :func:`find_global_fixed_joint_prim`, which only treats an empty body target as the world, this + also recognizes a fixed joint whose ``body0`` targets a prim such as the asset's root Xform. + + Args: + prim: The USD prim to check. + + Returns: + True if the prim is a fixed joint with a side attached to the world. + """ + from pxr import UsdPhysics # noqa: PLC0415 + + if not prim.IsA(UsdPhysics.FixedJoint): + return False + joint = UsdPhysics.FixedJoint(prim) + stage = prim.GetStage() + for rel in (joint.GetBody0Rel(), joint.GetBody1Rel()): + targets = rel.GetTargets() + body = stage.GetPrimAtPath(targets[0]) if targets else None + while body and not body.IsPseudoRoot(): + if body.HasAPI(UsdPhysics.RigidBodyAPI) or body.HasAPI(UsdPhysics.CollisionAPI): + break + body = body.GetParent() + if not body or body.IsPseudoRoot(): + return True + return False + + def has_deformable_body_api(prim: Usd.Prim) -> bool: """Check whether a deformable body API schema is applied on the prim. diff --git a/source/isaaclab/test/sim/test_schemas.py b/source/isaaclab/test/sim/test_schemas.py index 6da2325c0e20..59a5f472775c 100644 --- a/source/isaaclab/test/sim/test_schemas.py +++ b/source/isaaclab/test/sim/test_schemas.py @@ -408,11 +408,7 @@ def test_modify_articulation_root_fix_root_link_uses_given_stage(setup_simulatio @pytest.mark.parametrize("legacy_writer", [False, True], ids=["fragments", "legacy"]) def test_fix_root_link_enables_fixed_world_joint_root(setup_simulation, legacy_writer): - """``fix_root_link=True`` must enable the fixed joint that roots an articulation and attaches it to the world. - - The joint targets the asset prim, which is not a rigid body, so it attaches the root link to the world. No second - world joint is created and the root stays on the joint. - """ + """``fix_root_link=True`` enables the fixed world joint that roots an articulation instead of adding one.""" stage = sim_utils.get_current_stage() sim_utils.create_prim("/World/Robot", prim_type="Xform") UsdPhysics.RigidBodyAPI.Apply(sim_utils.create_prim("/World/Robot/base", prim_type="Xform")) @@ -439,10 +435,7 @@ def test_fix_root_link_enables_fixed_world_joint_root(setup_simulation, legacy_w @pytest.mark.parametrize("static_base", [False, True], ids=["rigid_base", "static_collider_base"]) def test_fix_root_link_rejects_internal_fixed_joint_root(setup_simulation, static_base): - """``fix_root_link=True`` must not treat an articulation root on a fixed joint between two bodies as fixed. - - A collider without a rigid body is a static body, not the world. - """ + """``fix_root_link=True`` still raises for an articulation rooted at a fixed joint between two bodies.""" stage = sim_utils.get_current_stage() sim_utils.create_prim("/World/Robot", prim_type="Xform") if static_base: diff --git a/source/isaaclab_newton/changelog.d/fix-fixed-base-articulation-root.skip b/source/isaaclab_newton/changelog.d/fix-fixed-base-articulation-root.skip index 6c948a921cd0..e69de29bb2d1 100644 --- a/source/isaaclab_newton/changelog.d/fix-fixed-base-articulation-root.skip +++ b/source/isaaclab_newton/changelog.d/fix-fixed-base-articulation-root.skip @@ -1 +0,0 @@ -Added Newton articulation tests for an articulation rooted at its fixed world joint. diff --git a/source/isaaclab_newton/test/assets/test_articulation.py b/source/isaaclab_newton/test/assets/test_articulation.py index 2d501e529579..a6b4b310154f 100644 --- a/source/isaaclab_newton/test/assets/test_articulation.py +++ b/source/isaaclab_newton/test/assets/test_articulation.py @@ -42,7 +42,7 @@ from newton.selection import ArticulationView from newton.solvers import SolverMuJoCo -from pxr import Usd, UsdPhysics +from pxr import Usd, UsdGeom, UsdPhysics import isaaclab.assets.articulation.ordering_resolvers as ordering_resolvers import isaaclab.sim as sim_utils @@ -186,71 +186,6 @@ class CustomDrive(ImplicitActuator): """Implicit actuator with a class name that does not encode its execution type.""" -# Base link with one revolute child, rooted at the fixed joint that attaches the base link to the asset prim, the -# fixed-base layout that UsdPhysics recommends. -_FIXED_JOINT_ROOT_USDA = """#usda 1.0 -( - defaultPrim = "Robot" - metersPerUnit = 1 - upAxis = "Z" -) - -def Xform "Robot" -{ - def Xform "base" ( - prepend apiSchemas = ["PhysicsRigidBodyAPI", "PhysicsMassAPI"] - ) - { - float physics:mass = 5 - float3 physics:diagonalInertia = (0.05, 0.05, 0.05) - - def Cube "collision" ( - prepend apiSchemas = ["PhysicsCollisionAPI"] - ) - { - double size = 0.2 - } - } - - def Xform "arm" ( - prepend apiSchemas = ["PhysicsRigidBodyAPI", "PhysicsMassAPI"] - ) - { - float physics:mass = 1 - float3 physics:diagonalInertia = (0.001, 0.01, 0.01) - double3 xformOp:translate = (0.25, 0, 0) - uniform token[] xformOpOrder = ["xformOp:translate"] - - def Cube "collision" ( - prepend apiSchemas = ["PhysicsCollisionAPI"] - ) - { - double size = 0.1 - } - } - - def PhysicsFixedJoint "root_joint" ( - prepend apiSchemas = ["PhysicsArticulationRootAPI"] - ) - { - rel physics:body0 = - rel physics:body1 = - } - - def PhysicsRevoluteJoint "hinge" - { - rel physics:body0 = - rel physics:body1 = - uniform token physics:axis = "Y" - point3f physics:localPos0 = (0.1, 0, 0) - point3f physics:localPos1 = (-0.15, 0, 0) - float physics:lowerLimit = -60 - float physics:upperLimit = 60 - } -} -""" - - def generate_articulation_cfg( articulation_type: str, stiffness: float | None = 10.0, @@ -1744,34 +1679,38 @@ def test_fragment_fix_root_link_uses_base_manager(sim, device, add_ground_plane, torch.testing.assert_close(articulation.data.root_com_vel_w.torch, default_root_vel) -@pytest.mark.parametrize("num_articulations", [2]) -@pytest.mark.parametrize("device", test_devices(DeviceScope.CUDA)) -@pytest.mark.parametrize("articulation_type", ["single_joint_implicit"]) -@pytest.mark.parametrize("fix_root_link", [None, True]) -def test_initialization_fixed_joint_root(sim, num_articulations, device, articulation_type, fix_root_link, tmp_path): - """Test an articulation rooted at the fixed joint that attaches its root link to the asset prim. +def _author_fixed_joint_root_robot(usd_path: str) -> None: + """Author a two-link robot rooted at the fixed joint that attaches its base to the asset prim.""" + stage = Usd.Stage.CreateNew(usd_path) + stage.SetDefaultPrim(UsdGeom.Xform.Define(stage, "/Robot").GetPrim()) + for name in ("base", "link"): + body = UsdGeom.Xform.Define(stage, f"/Robot/{name}").GetPrim() + UsdPhysics.RigidBodyAPI.Apply(body) + UsdPhysics.MassAPI.Apply(body).CreateMassAttr(1.0) + # body0 is the asset prim, which UsdPhysics resolves to the world + root_joint = UsdPhysics.FixedJoint.Define(stage, "/Robot/root_joint") + root_joint.CreateBody0Rel().SetTargets(["/Robot"]) + root_joint.CreateBody1Rel().SetTargets(["/Robot/base"]) + UsdPhysics.ArticulationRootAPI.Apply(root_joint.GetPrim()) + joint = UsdPhysics.RevoluteJoint.Define(stage, "/Robot/joint") + joint.CreateBody0Rel().SetTargets(["/Robot/base"]) + joint.CreateBody1Rel().SetTargets(["/Robot/link"]) + stage.Save() - It initializes as a fixed-base articulation. Fixing the root link through the spawner as well must enable that - joint instead of raising or adding a second one. - """ - usd_path = tmp_path / "fixed_joint_root.usda" - usd_path.write_text(_FIXED_JOINT_ROOT_USDA) - spawn = sim_utils.UsdFileCfg(usd_path=str(usd_path), fix_root_link=fix_root_link) - articulation, translations = generate_articulation( - ArticulationCfg(spawn=spawn, actuators={}), num_articulations, device - ) +@pytest.mark.parametrize("num_articulations", [1]) +@pytest.mark.parametrize("device", test_devices(DeviceScope.CUDA)) +@pytest.mark.parametrize("articulation_type", ["single_joint_implicit"]) # consumed by the sim fixture +def test_initialization_fixed_joint_root(sim, num_articulations, device, articulation_type, tmp_path): + """``fix_root_link=True`` keeps an articulation rooted at its fixed world joint fixed-base.""" + usd_path = str(tmp_path / "fixed_joint_root.usda") + _author_fixed_joint_root_robot(usd_path) + articulation_cfg = ArticulationCfg(spawn=sim_utils.UsdFileCfg(usd_path=usd_path, fix_root_link=True), actuators={}) + articulation, _ = generate_articulation(articulation_cfg, num_articulations, device=device) replicate(sim.get_clone_plan()) sim.reset() - assert articulation.is_fixed_base - for _ in range(10): - sim.step() - articulation.update(sim.cfg.dt) - torch.testing.assert_close(articulation.data.root_link_pose_w.torch[:, :3], translations) - robot = sim.stage.GetPrimAtPath("/World/Env_0/Robot") - fixed_joints = [prim.GetName() for prim in Usd.PrimRange(robot) if prim.IsA(UsdPhysics.FixedJoint)] - assert fixed_joints == ["root_joint"] + assert articulation.is_fixed_base @pytest.mark.parametrize("num_articulations", [2]) diff --git a/source/isaaclab_physx/changelog.d/fix-fixed-base-articulation-root.skip b/source/isaaclab_physx/changelog.d/fix-fixed-base-articulation-root.skip index b790c36ec24c..e69de29bb2d1 100644 --- a/source/isaaclab_physx/changelog.d/fix-fixed-base-articulation-root.skip +++ b/source/isaaclab_physx/changelog.d/fix-fixed-base-articulation-root.skip @@ -1 +0,0 @@ -Added PhysX articulation tests for an articulation rooted at its fixed world joint, with physics replication. diff --git a/source/isaaclab_physx/test/assets/test_articulation.py b/source/isaaclab_physx/test/assets/test_articulation.py index 0f6f34491200..3373ec2182dd 100644 --- a/source/isaaclab_physx/test/assets/test_articulation.py +++ b/source/isaaclab_physx/test/assets/test_articulation.py @@ -31,14 +31,13 @@ from isaaclab_physx.assets import Articulation from isaaclab_physx.sim.schemas import PhysxJointCfg -from pxr import Usd, UsdPhysics +from pxr import Usd, UsdGeom, UsdPhysics import isaaclab.sim as sim_utils import isaaclab.utils.math as math_utils import isaaclab.utils.string as string_utils from isaaclab.actuators import IdealPDActuatorCfg, ImplicitActuatorCfg from isaaclab.assets import ArticulationCfg, get_articulation_name_ordering -from isaaclab.cloner import CloneCfg, clone_plan_from_env_0, replicate from isaaclab.controllers import DifferentialIKController, DifferentialIKControllerCfg from isaaclab.envs.mdp.terminations import joint_effort_out_of_limit from isaaclab.managers import SceneEntityCfg @@ -57,70 +56,6 @@ ) from isaaclab_assets.robots.shadow_hand import SHADOW_HAND_PHYSX_CFG -# Base link with one revolute child, rooted at the fixed joint that attaches the base link to the asset prim, the -# fixed-base layout that UsdPhysics recommends. -_FIXED_JOINT_ROOT_USDA = """#usda 1.0 -( - defaultPrim = "Robot" - metersPerUnit = 1 - upAxis = "Z" -) - -def Xform "Robot" -{ - def Xform "base" ( - prepend apiSchemas = ["PhysicsRigidBodyAPI", "PhysicsMassAPI"] - ) - { - float physics:mass = 5 - float3 physics:diagonalInertia = (0.05, 0.05, 0.05) - - def Cube "collision" ( - prepend apiSchemas = ["PhysicsCollisionAPI"] - ) - { - double size = 0.2 - } - } - - def Xform "arm" ( - prepend apiSchemas = ["PhysicsRigidBodyAPI", "PhysicsMassAPI"] - ) - { - float physics:mass = 1 - float3 physics:diagonalInertia = (0.001, 0.01, 0.01) - double3 xformOp:translate = (0.25, 0, 0) - uniform token[] xformOpOrder = ["xformOp:translate"] - - def Cube "collision" ( - prepend apiSchemas = ["PhysicsCollisionAPI"] - ) - { - double size = 0.1 - } - } - - def PhysicsFixedJoint "root_joint" ( - prepend apiSchemas = ["PhysicsArticulationRootAPI"] - ) - { - rel physics:body0 = - rel physics:body1 = - } - - def PhysicsRevoluteJoint "hinge" - { - rel physics:body0 = - rel physics:body1 = - uniform token physics:axis = "Y" - point3f physics:localPos0 = (0.1, 0, 0) - point3f physics:localPos1 = (-0.15, 0, 0) - float physics:lowerLimit = -60 - float physics:upperLimit = 60 - } -} -""" - def generate_articulation_cfg( articulation_type: str, @@ -848,37 +783,36 @@ def test_initialization_floating_base_made_fixed_base(sim, num_articulations, de torch.testing.assert_close(articulation.data.root_com_vel_w.torch, default_root_vel) -@pytest.mark.parametrize("num_articulations", [2]) -@pytest.mark.parametrize("device", test_devices(DeviceScope.CUDA)) -@pytest.mark.parametrize("fix_root_link", [None, True]) -def test_initialization_fixed_joint_root(sim, num_articulations, device, fix_root_link, tmp_path): - """Test an articulation rooted at the fixed joint that attaches its root link to the asset prim. +def _author_fixed_joint_root_robot(usd_path: str) -> None: + """Author a two-link robot rooted at the fixed joint that attaches its base to the asset prim.""" + stage = Usd.Stage.CreateNew(usd_path) + stage.SetDefaultPrim(UsdGeom.Xform.Define(stage, "/Robot").GetPrim()) + for name in ("base", "link"): + body = UsdGeom.Xform.Define(stage, f"/Robot/{name}").GetPrim() + UsdPhysics.RigidBodyAPI.Apply(body) + UsdPhysics.MassAPI.Apply(body).CreateMassAttr(1.0) + # body0 is the asset prim, which UsdPhysics resolves to the world + root_joint = UsdPhysics.FixedJoint.Define(stage, "/Robot/root_joint") + root_joint.CreateBody0Rel().SetTargets(["/Robot"]) + root_joint.CreateBody1Rel().SetTargets(["/Robot/base"]) + UsdPhysics.ArticulationRootAPI.Apply(root_joint.GetPrim()) + joint = UsdPhysics.RevoluteJoint.Define(stage, "/Robot/joint") + joint.CreateBody0Rel().SetTargets(["/Robot/base"]) + joint.CreateBody1Rel().SetTargets(["/Robot/link"]) + stage.Save() - It initializes as a fixed-base articulation, and with physics replication each environment keeps its base at its - origin. Fixing the root link through the spawner as well must enable that joint instead of raising or adding a - second one. - """ - usd_path = tmp_path / "fixed_joint_root.usda" - usd_path.write_text(_FIXED_JOINT_ROOT_USDA) - spawn = sim_utils.UsdFileCfg(usd_path=str(usd_path), fix_root_link=fix_root_link) - cfg = ArticulationCfg(prim_path="/World/Env_[^/]*/Robot", spawn=spawn, actuators={}) - sim_utils.create_prim("/World/Env_0", "Xform") - # the grid places every environment away from the world origin - plan = clone_plan_from_env_0(CloneCfg(clone_template="/World/Env_{}"), (cfg,), num_articulations, 2.5) - articulation = Articulation(cfg) - replicate(plan) +@pytest.mark.parametrize("num_articulations", [1]) +@pytest.mark.parametrize("device", test_devices(DeviceScope.CUDA)) +def test_initialization_fixed_joint_root(sim, num_articulations, device, tmp_path): + """``fix_root_link=True`` keeps an articulation rooted at its fixed world joint fixed-base.""" + usd_path = str(tmp_path / "fixed_joint_root.usda") + _author_fixed_joint_root_robot(usd_path) + articulation_cfg = ArticulationCfg(spawn=sim_utils.UsdFileCfg(usd_path=usd_path, fix_root_link=True), actuators={}) + articulation, _ = generate_articulation(articulation_cfg, num_articulations, device=device) sim.reset() - assert articulation.is_fixed_base - for _ in range(10): - sim.step() - articulation.update(sim.cfg.dt) - env_origins = torch.as_tensor(plan.positions, device=device) - torch.testing.assert_close(articulation.data.root_link_pose_w.torch[:, :3], env_origins) - robot = sim_utils.get_current_stage().GetPrimAtPath("/World/Env_0/Robot") - fixed_joints = [prim.GetName() for prim in Usd.PrimRange(robot) if prim.IsA(UsdPhysics.FixedJoint)] - assert fixed_joints == ["root_joint"] + assert articulation.is_fixed_base @pytest.mark.parametrize("num_articulations", [2]) From 20b68e18c4b68a0749db2ba47b86978ac2a48030 Mon Sep 17 00:00:00 2001 From: Zeng Qingcheng <60593302+NeoZng@users.noreply.github.com> Date: Thu, 1 Oct 2026 23:20:16 +0800 Subject: [PATCH 6/9] Reuse get_first_matching_ancestor_prim in the world fixed-joint check Replace the hand-written ancestor walk with the existing query. A dangling body target is still taken as the world, as UsdPhysics does; it is checked first because the query raises for a missing prim. --- source/isaaclab/isaaclab/sim/utils/queries.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/source/isaaclab/isaaclab/sim/utils/queries.py b/source/isaaclab/isaaclab/sim/utils/queries.py index 12373a66407a..c2a06ea7ad57 100644 --- a/source/isaaclab/isaaclab/sim/utils/queries.py +++ b/source/isaaclab/isaaclab/sim/utils/queries.py @@ -574,12 +574,13 @@ def _is_world_fixed_joint(prim: Usd.Prim) -> bool: stage = prim.GetStage() for rel in (joint.GetBody0Rel(), joint.GetBody1Rel()): targets = rel.GetTargets() - body = stage.GetPrimAtPath(targets[0]) if targets else None - while body and not body.IsPseudoRoot(): - if body.HasAPI(UsdPhysics.RigidBodyAPI) or body.HasAPI(UsdPhysics.CollisionAPI): - break - body = body.GetParent() - if not body or body.IsPseudoRoot(): + # an empty or dangling target attaches the joint to the world + if not targets or not stage.GetPrimAtPath(targets[0]): + return True + body = get_first_matching_ancestor_prim( + targets[0], lambda p: p.HasAPI(UsdPhysics.RigidBodyAPI) or p.HasAPI(UsdPhysics.CollisionAPI), stage=stage + ) + if body is None: return True return False From 9b9c96f02b36fdab679bad1be76bd16e3748f7f5 Mon Sep 17 00:00:00 2001 From: Zeng Qingcheng <60593302+NeoZng@users.noreply.github.com> Date: Tue, 6 Oct 2026 19:28:05 +0800 Subject: [PATCH 7/9] Tighten the world fixed-joint check and warn for fix_root_link=False A fixed joint between two prims that are not rigid bodies, such as one whose targets both name the asset prim, could count as a world joint, so fix_root_link=True enabled it instead of raising. Require one side to resolve to a rigid body and the other to the world. fix_root_link=False is not supported for an articulation rooted at its fixed world joint, so both writers now log a warning for it. Say that UsdPhysics allows this layout rather than recommends it, present the world rule as Isaac Lab's, and note why a plain UsdPhysics.Joint is excluded. --- docs/source/how-to/make_fixed_prim.rst | 12 ++--- ...fix-fixed-base-articulation-root.fixed.rst | 7 +-- .../isaaclab/physics/physics_manager.py | 6 +-- .../isaaclab/isaaclab/sim/schemas/schemas.py | 17 +++++- source/isaaclab/isaaclab/sim/utils/queries.py | 34 ++++++------ source/isaaclab/test/sim/test_schemas.py | 53 +++++++++++++++---- .../test/assets/test_articulation.py | 2 +- .../test/assets/test_articulation.py | 2 +- 8 files changed, 94 insertions(+), 39 deletions(-) diff --git a/docs/source/how-to/make_fixed_prim.rst b/docs/source/how-to/make_fixed_prim.rst index ecf6a7b5c1e2..2e30646dd37d 100644 --- a/docs/source/how-to/make_fixed_prim.rst +++ b/docs/source/how-to/make_fixed_prim.rst @@ -167,12 +167,12 @@ before the backend imports the articulation. UsdPhysics also allows the articulation root properties on the fixed joint that connects the root link to the world. The articulation is parsed as a fixed-base system rooted at that joint. Setting - :attr:`~sim.schemas.ArticulationRootBaseCfg.fix_root_link` to True enables the joint. Setting it to False does - not make such an articulation floating. It is ignored when the joint sets both ``body0`` and ``body1``, for - example when ``body0`` targets the asset prim. A joint with an empty ``body0``, such as the joint that the MJCF - importer adds for a free root body, is disabled: PhysX then cannot create the articulation, while Newton - simulates a floating base. To obtain a floating base, author the asset without the world joint, or convert it - with ``fix_base=False``. + :attr:`~sim.schemas.ArticulationRootBaseCfg.fix_root_link` to True enables the joint. Setting it to False is not + supported for such an articulation and logs a warning. It is ignored when the joint sets both ``body0`` and + ``body1``, for example when ``body0`` targets the asset prim. A joint with an empty ``body0``, such as the + ``fix_base_joint`` that the URDF and MJCF importers add to a free root body with ``fix_base=True``, is disabled: + PhysX then cannot create the articulation, while Newton simulates a floating base. To obtain a floating base, + author the asset without the world joint, or convert it with ``fix_base=False``. .. code-block:: text diff --git a/source/isaaclab/changelog.d/fix-fixed-base-articulation-root.fixed.rst b/source/isaaclab/changelog.d/fix-fixed-base-articulation-root.fixed.rst index 588770553ba2..d6f85f54650b 100644 --- a/source/isaaclab/changelog.d/fix-fixed-base-articulation-root.fixed.rst +++ b/source/isaaclab/changelog.d/fix-fixed-base-articulation-root.fixed.rst @@ -1,4 +1,5 @@ * Fixed ``fix_root_link=True`` raising ``NotImplementedError`` for an articulation rooted at the fixed joint that - attaches it to the world when the joint's ``body0`` targets the asset prim, the fixed-base layout that UsdPhysics - recommends. It now enables that joint instead of raising or adding a second world joint. A fixed joint between two - bodies, or between a body and a static collider, still raises the error. + attaches it to the world when the joint's ``body0`` targets the asset prim, a fixed-base layout that UsdPhysics + allows. It now enables that joint instead of raising or adding a second world joint. A fixed joint between two + bodies, between a body and a static collider, or between two prims that are not rigid bodies still raises the error. + ``fix_root_link=False`` is not supported for an articulation rooted at its fixed world joint and now logs a warning. diff --git a/source/isaaclab/isaaclab/physics/physics_manager.py b/source/isaaclab/isaaclab/physics/physics_manager.py index cb8cadce6f42..df8b75345c76 100644 --- a/source/isaaclab/isaaclab/physics/physics_manager.py +++ b/source/isaaclab/isaaclab/physics/physics_manager.py @@ -103,9 +103,9 @@ def _prepare_stage_creation(cls) -> None: def fix_articulation_root(cls, articulation_prim: Any, stage: Any = None) -> Any: """Ensure that an articulation root has one enabled world fixed joint. - An articulation rooted at a fixed joint that attaches it to the world, as UsdPhysics roots fixed-base - articulations, is fixed by that joint. The base implementation leaves the root in place. Backends whose - parser requires a different root topology may relocate it and return the resulting root prim. + An articulation rooted at a fixed joint that attaches it to the world, a layout that UsdPhysics allows, is + fixed by that joint. The base implementation leaves the root in place. Backends whose parser requires a + different root topology may relocate it and return the resulting root prim. Args: articulation_prim: The articulation-root prim to fix. diff --git a/source/isaaclab/isaaclab/sim/schemas/schemas.py b/source/isaaclab/isaaclab/sim/schemas/schemas.py index 7a6f5b5c9df1..ce85f02b0e93 100644 --- a/source/isaaclab/isaaclab/sim/schemas/schemas.py +++ b/source/isaaclab/isaaclab/sim/schemas/schemas.py @@ -268,6 +268,16 @@ def apply_namespaced(cfg: schemas_cfg.SchemaFragment, prim_path: str, stage: Usd """ +def _warn_fix_root_link_false_unsupported(root_path: str) -> None: + """Warn that ``fix_root_link=False`` is not supported for an articulation rooted at its fixed world joint.""" + logger.warning( + "fix_root_link=False is not supported for articulation '%s', which is rooted at the fixed joint that attaches" + " it to the world. To make it floating, author the asset without that joint, or convert it with" + " fix_base=False.", + root_path, + ) + + def apply_articulation_root_properties( prim_path_expr: str, fragments: Iterable[schemas_cfg.ArticulationRootFragment], @@ -357,6 +367,8 @@ def apply_articulation_root_properties( if fix_root_link: root = sim.physics_manager.fix_articulation_root(root, stage) elif fix_root_link is False: + if _is_world_fixed_joint(root): + _warn_fix_root_link_false_unsupported(root.GetPath().pathString) joint = find_global_fixed_joint_prim(root.GetPath().pathString, stage=stage) if joint is not None: joint.GetJointEnabledAttr().Set(False) @@ -518,7 +530,10 @@ def modify_articulation_root_properties( apply_namespaced_schemas(articulation_prim, cfg, cfg_dict) if fix_root_link is not None: - if fix_root_link and _is_world_fixed_joint(articulation_prim): + world_joint_root = _is_world_fixed_joint(articulation_prim) + if world_joint_root and not fix_root_link: + _warn_fix_root_link_false_unsupported(prim_path) + if world_joint_root and fix_root_link: # an articulation rooted at its fixed world joint is attached to the world by that joint existing_fixed_joint_prim = UsdPhysics.Joint(articulation_prim) else: diff --git a/source/isaaclab/isaaclab/sim/utils/queries.py b/source/isaaclab/isaaclab/sim/utils/queries.py index c2a06ea7ad57..e889bdc6b86f 100644 --- a/source/isaaclab/isaaclab/sim/utils/queries.py +++ b/source/isaaclab/isaaclab/sim/utils/queries.py @@ -553,36 +553,40 @@ def find_global_fixed_joint_prim( def _is_world_fixed_joint(prim: Usd.Prim) -> bool: - """Check whether a prim is a fixed joint that attaches a body to the simulation world. + """Check whether a prim is a fixed joint that attaches a rigid body to the simulation world. - UsdPhysics attaches a side of a joint to the world when its body target is empty, or when neither the target nor - an ancestor is a rigid body or a collider. A collider without a rigid-body ancestor is a static body, not the - world. Unlike :func:`find_global_fixed_joint_prim`, which only treats an empty body target as the world, this - also recognizes a fixed joint whose ``body0`` targets a prim such as the asset's root Xform. + One body target of the joint must be a rigid body or a prim under one. The other side counts as the world when + its body target is empty or names no prim, or when neither the target nor an ancestor is a rigid body or a + collider. A collider without a rigid-body ancestor is a static body, not the world. Unlike + :func:`find_global_fixed_joint_prim`, which only treats an empty body target as the world, this also recognizes + a fixed joint whose ``body0`` targets a prim such as the asset's root Xform. Args: prim: The USD prim to check. Returns: - True if the prim is a fixed joint with a side attached to the world. + True if the prim is a fixed joint between a rigid body and the world. """ from pxr import UsdPhysics # noqa: PLC0415 + # a plain UsdPhysics.Joint is a free D6 joint, so it is excluded on purpose, unlike in find_global_fixed_joint_prim if not prim.IsA(UsdPhysics.FixedJoint): return False joint = UsdPhysics.FixedJoint(prim) stage = prim.GetStage() - for rel in (joint.GetBody0Rel(), joint.GetBody1Rel()): + + def resolve(rel: Usd.Relationship) -> str: + """Resolve a body relationship of the joint to ``"body"``, ``"static"`` or ``"world"``.""" targets = rel.GetTargets() - # an empty or dangling target attaches the joint to the world if not targets or not stage.GetPrimAtPath(targets[0]): - return True - body = get_first_matching_ancestor_prim( - targets[0], lambda p: p.HasAPI(UsdPhysics.RigidBodyAPI) or p.HasAPI(UsdPhysics.CollisionAPI), stage=stage - ) - if body is None: - return True - return False + return "world" + if get_first_matching_ancestor_prim(targets[0], lambda p: p.HasAPI(UsdPhysics.RigidBodyAPI), stage=stage): + return "body" + if get_first_matching_ancestor_prim(targets[0], lambda p: p.HasAPI(UsdPhysics.CollisionAPI), stage=stage): + return "static" + return "world" + + return sorted((resolve(joint.GetBody0Rel()), resolve(joint.GetBody1Rel()))) == ["body", "world"] def has_deformable_body_api(prim: Usd.Prim) -> bool: diff --git a/source/isaaclab/test/sim/test_schemas.py b/source/isaaclab/test/sim/test_schemas.py index 59a5f472775c..2828072e41b7 100644 --- a/source/isaaclab/test/sim/test_schemas.py +++ b/source/isaaclab/test/sim/test_schemas.py @@ -433,19 +433,27 @@ def test_fix_root_link_enables_fixed_world_joint_root(setup_simulation, legacy_w assert roots == ["root_joint"] -@pytest.mark.parametrize("static_base", [False, True], ids=["rigid_base", "static_collider_base"]) -def test_fix_root_link_rejects_internal_fixed_joint_root(setup_simulation, static_base): - """``fix_root_link=True`` still raises for an articulation rooted at a fixed joint between two bodies.""" +@pytest.mark.parametrize( + "body0, body1", + [ + ("/World/Robot/base", "/World/Robot/link"), + ("/World/Robot/static_base", "/World/Robot/link"), + ("/World/Robot", "/World/Robot"), + ("/World/Robot", "/World/Robot/static_base"), + ], + ids=["rigid_base", "static_collider_base", "no_rigid_body", "world_static_collider"], +) +def test_fix_root_link_rejects_non_world_fixed_joint_root(setup_simulation, body0, body1): + """``fix_root_link=True`` still raises for a fixed joint root between two bodies, a body and a static collider, + or two prims that are not rigid bodies.""" stage = sim_utils.get_current_stage() sim_utils.create_prim("/World/Robot", prim_type="Xform") - if static_base: - UsdPhysics.CollisionAPI.Apply(sim_utils.create_prim("/World/Robot/base", prim_type="Cube")) - else: - UsdPhysics.RigidBodyAPI.Apply(sim_utils.create_prim("/World/Robot/base", prim_type="Xform")) + UsdPhysics.RigidBodyAPI.Apply(sim_utils.create_prim("/World/Robot/base", prim_type="Xform")) + UsdPhysics.CollisionAPI.Apply(sim_utils.create_prim("/World/Robot/static_base", prim_type="Cube")) UsdPhysics.RigidBodyAPI.Apply(sim_utils.create_prim("/World/Robot/link", prim_type="Xform")) joint = UsdPhysics.FixedJoint.Define(stage, "/World/Robot/joint") - joint.CreateBody0Rel().SetTargets(["/World/Robot/base"]) - joint.CreateBody1Rel().SetTargets(["/World/Robot/link"]) + joint.CreateBody0Rel().SetTargets([body0]) + joint.CreateBody1Rel().SetTargets([body1]) UsdPhysics.ArticulationRootAPI.Apply(joint.GetPrim()) with pytest.raises(NotImplementedError): @@ -457,6 +465,33 @@ def test_fix_root_link_rejects_internal_fixed_joint_root(setup_simulation, stati ) +@pytest.mark.parametrize( + "body0, legacy_writer", + [(["/World/Robot"], False), ([], True)], + ids=["asset_prim_body0-fragments", "empty_body0-legacy"], +) +def test_fix_root_link_false_warns_for_fixed_world_joint_root(setup_simulation, caplog, body0, legacy_writer): + """``fix_root_link=False`` warns that it is not supported for an articulation rooted at its fixed world joint.""" + stage = sim_utils.get_current_stage() + sim_utils.create_prim("/World/Robot", prim_type="Xform") + UsdPhysics.RigidBodyAPI.Apply(sim_utils.create_prim("/World/Robot/base", prim_type="Xform")) + joint = UsdPhysics.FixedJoint.Define(stage, "/World/Robot/root_joint") + joint.CreateBody0Rel().SetTargets(body0) + joint.CreateBody1Rel().SetTargets(["/World/Robot/base"]) + UsdPhysics.ArticulationRootAPI.Apply(joint.GetPrim()) + + with caplog.at_level("WARNING"): + if legacy_writer: + with pytest.warns(DeprecationWarning, match="modify_articulation_root_properties"): + schemas.modify_articulation_root_properties( + "/World/Robot/root_joint", schemas.ArticulationRootBaseCfg(fix_root_link=False) + ) + else: + schemas.apply_articulation_root_properties("/World/Robot/root_joint", [], fix_root_link=False) + + assert "fix_root_link=False is not supported for articulation '/World/Robot/root_joint'" in caplog.text + + @pytest.mark.isaacsim_ci def test_physx_articulation_root_writes_self_collisions(setup_simulation): """Setting ``enabled_self_collisions`` on ``PhysxArticulationRootPropertiesCfg`` must author diff --git a/source/isaaclab_newton/test/assets/test_articulation.py b/source/isaaclab_newton/test/assets/test_articulation.py index d221d568a82f..a23daa610b31 100644 --- a/source/isaaclab_newton/test/assets/test_articulation.py +++ b/source/isaaclab_newton/test/assets/test_articulation.py @@ -529,7 +529,7 @@ def _author_fixed_joint_root_robot(usd_path: str) -> None: body = UsdGeom.Xform.Define(stage, f"/Robot/{name}").GetPrim() UsdPhysics.RigidBodyAPI.Apply(body) UsdPhysics.MassAPI.Apply(body).CreateMassAttr(1.0) - # body0 is the asset prim, which UsdPhysics resolves to the world + # body0 targets the asset prim, neither a rigid body nor a collider, so the joint attaches the base to the world root_joint = UsdPhysics.FixedJoint.Define(stage, "/Robot/root_joint") root_joint.CreateBody0Rel().SetTargets(["/Robot"]) root_joint.CreateBody1Rel().SetTargets(["/Robot/base"]) diff --git a/source/isaaclab_physx/test/assets/test_articulation.py b/source/isaaclab_physx/test/assets/test_articulation.py index 751adb4275ea..d740648b0b40 100644 --- a/source/isaaclab_physx/test/assets/test_articulation.py +++ b/source/isaaclab_physx/test/assets/test_articulation.py @@ -289,7 +289,7 @@ def _author_fixed_joint_root_robot(usd_path: str) -> None: body = UsdGeom.Xform.Define(stage, f"/Robot/{name}").GetPrim() UsdPhysics.RigidBodyAPI.Apply(body) UsdPhysics.MassAPI.Apply(body).CreateMassAttr(1.0) - # body0 is the asset prim, which UsdPhysics resolves to the world + # body0 targets the asset prim, neither a rigid body nor a collider, so the joint attaches the base to the world root_joint = UsdPhysics.FixedJoint.Define(stage, "/Robot/root_joint") root_joint.CreateBody0Rel().SetTargets(["/Robot"]) root_joint.CreateBody1Rel().SetTargets(["/Robot/base"]) From b1cd675ba75b2cf4a66d72f30f2a87c7801cc2d6 Mon Sep 17 00:00:00 2001 From: Jichuan Hu Date: Thu, 15 Oct 2026 16:36:30 -0700 Subject: [PATCH 8/9] Resolve the root world joint in find_global_fixed_joint_prim The three fix_root_link call sites each checked whether the articulation root is its own fixed world joint before calling find_global_fixed_joint_prim. The finder now does that check first, and the callers use it alone. fix_root_link=False on such a root warned that it is not supported and then still disabled the joint when its body0 was empty, because the subtree search also visits the root prim. Both writers now warn and leave the joint enabled. --- ...fix-fixed-base-articulation-root.fixed.rst | 4 +++- .../isaaclab/physics/physics_manager.py | 6 +---- .../isaaclab/isaaclab/sim/schemas/schemas.py | 23 ++++++++----------- source/isaaclab/isaaclab/sim/utils/queries.py | 22 ++++++++++++------ source/isaaclab/test/sim/test_schemas.py | 4 +++- 5 files changed, 32 insertions(+), 27 deletions(-) diff --git a/source/isaaclab/changelog.d/fix-fixed-base-articulation-root.fixed.rst b/source/isaaclab/changelog.d/fix-fixed-base-articulation-root.fixed.rst index d6f85f54650b..33a275825a26 100644 --- a/source/isaaclab/changelog.d/fix-fixed-base-articulation-root.fixed.rst +++ b/source/isaaclab/changelog.d/fix-fixed-base-articulation-root.fixed.rst @@ -2,4 +2,6 @@ attaches it to the world when the joint's ``body0`` targets the asset prim, a fixed-base layout that UsdPhysics allows. It now enables that joint instead of raising or adding a second world joint. A fixed joint between two bodies, between a body and a static collider, or between two prims that are not rigid bodies still raises the error. - ``fix_root_link=False`` is not supported for an articulation rooted at its fixed world joint and now logs a warning. + ``fix_root_link=False`` is not supported for an articulation rooted at its fixed world joint; it now logs a warning + and leaves the joint enabled. :func:`~isaaclab.sim.utils.find_global_fixed_joint_prim` returns that joint when + called on it. diff --git a/source/isaaclab/isaaclab/physics/physics_manager.py b/source/isaaclab/isaaclab/physics/physics_manager.py index df8b75345c76..1c8d955d40ef 100644 --- a/source/isaaclab/isaaclab/physics/physics_manager.py +++ b/source/isaaclab/isaaclab/physics/physics_manager.py @@ -126,15 +126,11 @@ def fix_articulation_root(cls, articulation_prim: Any, stage: Any = None) -> Any from ..sim.schemas.schemas import create_world_fixed_joint # noqa: PLC0415 from ..sim.utils import find_global_fixed_joint_prim # noqa: PLC0415 - from ..sim.utils.queries import _is_world_fixed_joint # noqa: PLC0415 if stage is None: stage = get_current_stage() root_path = articulation_prim.GetPath().pathString - if _is_world_fixed_joint(articulation_prim): - joint = UsdPhysics.Joint(articulation_prim) - else: - joint = find_global_fixed_joint_prim(root_path, stage=stage) + joint = find_global_fixed_joint_prim(root_path, stage=stage) if joint is not None: joint.GetJointEnabledAttr().Set(True) return articulation_prim diff --git a/source/isaaclab/isaaclab/sim/schemas/schemas.py b/source/isaaclab/isaaclab/sim/schemas/schemas.py index e192a1c31bb5..bfa77f54ec6f 100644 --- a/source/isaaclab/isaaclab/sim/schemas/schemas.py +++ b/source/isaaclab/isaaclab/sim/schemas/schemas.py @@ -29,7 +29,6 @@ safe_set_attribute_on_usd_prim, safe_set_attribute_on_usd_schema, ) -from ..utils.queries import _is_world_fixed_joint from ..utils.stage import get_current_stage from . import schemas_cfg from .backend_hooks import skip_joint_drive @@ -367,10 +366,10 @@ def apply_articulation_root_properties( if fix_root_link: root = sim.physics_manager.fix_articulation_root(root, stage) elif fix_root_link is False: - if _is_world_fixed_joint(root): - _warn_fix_root_link_false_unsupported(root.GetPath().pathString) joint = find_global_fixed_joint_prim(root.GetPath().pathString, stage=stage) - if joint is not None: + if joint is not None and joint.GetPrim() == root: + _warn_fix_root_link_false_unsupported(root.GetPath().pathString) + elif joint is not None: joint.GetJointEnabledAttr().Set(False) root_path = root.GetPath().pathString @@ -530,16 +529,14 @@ def modify_articulation_root_properties( apply_namespaced_schemas(articulation_prim, cfg, cfg_dict) if fix_root_link is not None: - world_joint_root = _is_world_fixed_joint(articulation_prim) - if world_joint_root and not fix_root_link: - _warn_fix_root_link_false_unsupported(prim_path) - if world_joint_root and fix_root_link: - # an articulation rooted at its fixed world joint is attached to the world by that joint - existing_fixed_joint_prim = UsdPhysics.Joint(articulation_prim) - else: - existing_fixed_joint_prim = find_global_fixed_joint_prim(prim_path, stage=stage) + existing_fixed_joint_prim = find_global_fixed_joint_prim(prim_path, stage=stage) # enable/disable an existing world joint, otherwise create one - if existing_fixed_joint_prim is not None: + root_is_joint = ( + existing_fixed_joint_prim is not None and existing_fixed_joint_prim.GetPrim() == articulation_prim + ) + if root_is_joint and not fix_root_link: + _warn_fix_root_link_false_unsupported(prim_path) + elif existing_fixed_joint_prim is not None: logger.info( f"Found an existing fixed joint for the articulation: '{prim_path}'. Setting it to: {fix_root_link}." ) diff --git a/source/isaaclab/isaaclab/sim/utils/queries.py b/source/isaaclab/isaaclab/sim/utils/queries.py index e889bdc6b86f..e1bf0889ec28 100644 --- a/source/isaaclab/isaaclab/sim/utils/queries.py +++ b/source/isaaclab/isaaclab/sim/utils/queries.py @@ -510,8 +510,10 @@ def find_global_fixed_joint_prim( between the two bodies. When a fixed joint has only one target body, it is considered to attach the body to the simulation world. - This function finds the fixed joint prim that has only one target under the specified prim path. If no such - fixed joint prim exists, it returns None. + If the prim itself is a fixed joint that attaches a rigid body to the world, as for an articulation rooted at + its fixed world joint, that joint is returned (see :func:`_is_world_fixed_joint`). Otherwise, this function + finds the joint prim that has only one target under the specified prim path. If no such joint prim exists, it + returns None. Args: prim_path: The prim path to search for the fixed joint prim. @@ -520,7 +522,7 @@ def find_global_fixed_joint_prim( stage: The stage where the prim exists. Defaults to None, in which case the current stage is used. Returns: - The fixed joint prim that has only one target. If no such fixed joint prim exists, it returns None. + The fixed joint prim that attaches the prim to the world. If no such joint prim exists, it returns None. Raises: ValueError: If the prim path is not global (i.e: does not start with '/'). @@ -539,6 +541,12 @@ def find_global_fixed_joint_prim( if not prim.IsValid(): raise ValueError(f"Prim at path '{prim_path}' is not valid.") + # an articulation rooted at its fixed world joint: the prim itself is that joint + if _is_world_fixed_joint(prim): + joint_prim = UsdPhysics.Joint(prim) + if not check_enabled_only or joint_prim.GetJointEnabledAttr().Get(): + return joint_prim + # note: some assets author fixed joints with the generic "Joint" schema, so every joint is checked # and a joint missing one of its two body targets is taken to attach the asset to the world. for child_prim in Usd.PrimRange(prim): @@ -557,9 +565,9 @@ def _is_world_fixed_joint(prim: Usd.Prim) -> bool: One body target of the joint must be a rigid body or a prim under one. The other side counts as the world when its body target is empty or names no prim, or when neither the target nor an ancestor is a rigid body or a - collider. A collider without a rigid-body ancestor is a static body, not the world. Unlike - :func:`find_global_fixed_joint_prim`, which only treats an empty body target as the world, this also recognizes - a fixed joint whose ``body0`` targets a prim such as the asset's root Xform. + collider. A collider without a rigid-body ancestor is a static body, not the world. + :func:`find_global_fixed_joint_prim` applies this rule to the prim it is called on; for the joints under that + prim it treats only an empty body target as the world. Args: prim: The USD prim to check. @@ -569,7 +577,7 @@ def _is_world_fixed_joint(prim: Usd.Prim) -> bool: """ from pxr import UsdPhysics # noqa: PLC0415 - # a plain UsdPhysics.Joint is a free D6 joint, so it is excluded on purpose, unlike in find_global_fixed_joint_prim + # a plain UsdPhysics.Joint is a free D6 joint, so it is excluded on purpose, unlike in the subtree search if not prim.IsA(UsdPhysics.FixedJoint): return False joint = UsdPhysics.FixedJoint(prim) diff --git a/source/isaaclab/test/sim/test_schemas.py b/source/isaaclab/test/sim/test_schemas.py index 721afc075ece..98a4b29d3541 100644 --- a/source/isaaclab/test/sim/test_schemas.py +++ b/source/isaaclab/test/sim/test_schemas.py @@ -477,7 +477,8 @@ def test_fix_root_link_rejects_non_world_fixed_joint_root(setup_simulation, body ids=["asset_prim_body0-fragments", "empty_body0-legacy"], ) def test_fix_root_link_false_warns_for_fixed_world_joint_root(setup_simulation, caplog, body0, legacy_writer): - """``fix_root_link=False`` warns that it is not supported for an articulation rooted at its fixed world joint.""" + """``fix_root_link=False`` warns that it is not supported for an articulation rooted at its fixed world joint + and leaves that joint enabled.""" stage = sim_utils.get_current_stage() sim_utils.create_prim("/World/Robot", prim_type="Xform") UsdPhysics.RigidBodyAPI.Apply(sim_utils.create_prim("/World/Robot/base", prim_type="Xform")) @@ -496,6 +497,7 @@ def test_fix_root_link_false_warns_for_fixed_world_joint_root(setup_simulation, schemas.apply_articulation_root_properties("/World/Robot/root_joint", [], fix_root_link=False) assert "fix_root_link=False is not supported for articulation '/World/Robot/root_joint'" in caplog.text + assert joint.GetJointEnabledAttr().Get() is True @pytest.mark.isaacsim_ci From f334b2703b366f97e7229301fb64a126d11fe855 Mon Sep 17 00:00:00 2001 From: Zeng Qingcheng <60593302+NeoZng@users.noreply.github.com> Date: Fri, 16 Oct 2026 07:51:46 +0800 Subject: [PATCH 9/9] Match the fixed-joint-root docs to the fix_root_link=False behavior fix_root_link=False now leaves an articulation's root world joint enabled, so the how-to no longer says that a root joint with an empty body0 is disabled. The changelog fragment drops "or adding a second world joint": for this layout develop always raised. --- docs/source/how-to/make_fixed_prim.rst | 7 ++----- .../changelog.d/fix-fixed-base-articulation-root.fixed.rst | 4 ++-- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/docs/source/how-to/make_fixed_prim.rst b/docs/source/how-to/make_fixed_prim.rst index 3fdc5b6ab0b2..9d9872c030cf 100644 --- a/docs/source/how-to/make_fixed_prim.rst +++ b/docs/source/how-to/make_fixed_prim.rst @@ -164,11 +164,8 @@ and instancing layout. ``fix_root_link`` is consumed on this path regardless of UsdPhysics also allows the articulation root properties on the fixed joint that connects the root link to the world. The articulation is parsed as a fixed-base system rooted at that joint. Setting :attr:`~isaaclab.sim.spawners.from_files.FileCfg.fix_root_link` to True enables the joint. Setting it to False - is not supported for such an articulation and logs a warning. It is ignored when the joint sets both ``body0`` - and ``body1``, for example when ``body0`` targets the asset prim. A joint with an empty ``body0``, such as the - ``fix_base_joint`` that the URDF and MJCF importers add to a free root body with ``fix_base=True``, is disabled: - PhysX then cannot create the articulation, while Newton simulates a floating base. To obtain a floating base, - author the asset without the world joint, or convert it with ``fix_base=False``. + is not supported for such an articulation: it logs a warning and leaves the joint enabled. To obtain a floating + base, author the asset without the world joint, or convert it with ``fix_base=False``. .. code-block:: text diff --git a/source/isaaclab/changelog.d/fix-fixed-base-articulation-root.fixed.rst b/source/isaaclab/changelog.d/fix-fixed-base-articulation-root.fixed.rst index 33a275825a26..70f23789c03d 100644 --- a/source/isaaclab/changelog.d/fix-fixed-base-articulation-root.fixed.rst +++ b/source/isaaclab/changelog.d/fix-fixed-base-articulation-root.fixed.rst @@ -1,7 +1,7 @@ * Fixed ``fix_root_link=True`` raising ``NotImplementedError`` for an articulation rooted at the fixed joint that attaches it to the world when the joint's ``body0`` targets the asset prim, a fixed-base layout that UsdPhysics - allows. It now enables that joint instead of raising or adding a second world joint. A fixed joint between two - bodies, between a body and a static collider, or between two prims that are not rigid bodies still raises the error. + allows. It now enables that joint instead of raising. A fixed joint between two bodies, between a body and a static + collider, or between two prims that are not rigid bodies still raises the error. ``fix_root_link=False`` is not supported for an articulation rooted at its fixed world joint; it now logs a warning and leaves the joint enabled. :func:`~isaaclab.sim.utils.find_global_fixed_joint_prim` returns that joint when called on it.