From 15ce93770cb269a875f2dae994a9e8a1d69e2144 Mon Sep 17 00:00:00 2001 From: Vidur Vij Date: Thu, 4 Jun 2026 18:19:27 -0700 Subject: [PATCH 1/6] feat(schemas): add articulation-root schema-fragment API --- .../vidurv-schema-frag-articulation.minor.rst | 17 ++ source/isaaclab/isaaclab/sim/__init__.pyi | 4 + .../isaaclab/sim/schemas/__init__.pyi | 4 + .../isaaclab/isaaclab/sim/schemas/schemas.py | 110 ++++++++++++ .../isaaclab/sim/schemas/schemas_cfg.py | 15 ++ .../sim/spawners/from_files/from_files.py | 11 +- .../sim/spawners/from_files/from_files_cfg.py | 32 +++- .../test/sim/test_articulation_fragments.py | 162 ++++++++++++++++++ .../vidurv-schema-frag-articulation.minor.rst | 8 + .../isaaclab_newton/sim/schemas/__init__.pyi | 2 + .../sim/schemas/schemas_cfg.py | 27 +++ .../vidurv-schema-frag-articulation.minor.rst | 8 + .../isaaclab_physx/sim/schemas/__init__.pyi | 2 + .../isaaclab_physx/sim/schemas/schemas_cfg.py | 46 +++++ 14 files changed, 445 insertions(+), 3 deletions(-) create mode 100644 source/isaaclab/changelog.d/vidurv-schema-frag-articulation.minor.rst create mode 100644 source/isaaclab/test/sim/test_articulation_fragments.py create mode 100644 source/isaaclab_newton/changelog.d/vidurv-schema-frag-articulation.minor.rst create mode 100644 source/isaaclab_physx/changelog.d/vidurv-schema-frag-articulation.minor.rst diff --git a/source/isaaclab/changelog.d/vidurv-schema-frag-articulation.minor.rst b/source/isaaclab/changelog.d/vidurv-schema-frag-articulation.minor.rst new file mode 100644 index 000000000000..ac26c8bafe91 --- /dev/null +++ b/source/isaaclab/changelog.d/vidurv-schema-frag-articulation.minor.rst @@ -0,0 +1,17 @@ +Added +^^^^^ + +* Added the articulation-root schema-fragment API: + :class:`~isaaclab.sim.schemas.ArticulationRootFragment` (marker) and + :func:`~isaaclab.sim.schemas.apply_articulation_root_properties`, which applies a list of + articulation-root fragments with ``UsdPhysics.ArticulationRootAPI`` as a presence-gated anchor + and reproduces the legacy ``fix_root_link`` fixed-joint logic via a spawner-level flag. + +Changed +^^^^^^^ + +* Changed the spawner ``articulation_props`` slot + (:attr:`~isaaclab.sim.spawners.UsdFileCfg.articulation_props`) to also accept a list of + :class:`~isaaclab.sim.schemas.ArticulationRootFragment` fragments, and added the spawner-level + :attr:`~isaaclab.sim.spawners.UsdFileCfg.fix_root_link` flag. Legacy single cfgs continue to + work through a transition bridge in the spawn writer. diff --git a/source/isaaclab/isaaclab/sim/__init__.pyi b/source/isaaclab/isaaclab/sim/__init__.pyi index 9578caa87b49..15220b0d8cab 100644 --- a/source/isaaclab/isaaclab/sim/__init__.pyi +++ b/source/isaaclab/isaaclab/sim/__init__.pyi @@ -58,10 +58,12 @@ __all__ = [ "NewtonSDFCollisionPropertiesCfg", "PhysxJointDrivePropertiesCfg", "PhysxRigidBodyPropertiesCfg", + "ArticulationRootFragment", "RigidBodyBaseCfg", "RigidBodyFragment", "SchemaFragment", "UsdPhysicsRigidBodyCfg", + "apply_articulation_root_properties", "apply_namespaced", "apply_rigid_body_properties", "SDFMeshPropertiesCfg", @@ -210,6 +212,7 @@ from .schemas import ( MESH_APPROXIMATION_TOKENS, PHYSX_MESH_COLLISION_CFGS, USD_MESH_COLLISION_CFGS, + ArticulationRootFragment, ArticulationRootPropertiesCfg, BoundingCubePropertiesCfg, BoundingSpherePropertiesCfg, @@ -233,6 +236,7 @@ from .schemas import ( TriangleMeshSimplificationPropertiesCfg, UsdPhysicsRigidBodyCfg, activate_contact_sensors, + apply_articulation_root_properties, apply_namespaced, apply_rigid_body_properties, define_articulation_root_properties, diff --git a/source/isaaclab/isaaclab/sim/schemas/__init__.pyi b/source/isaaclab/isaaclab/sim/schemas/__init__.pyi index af153a60fc63..fcbe7cc004bf 100644 --- a/source/isaaclab/isaaclab/sim/schemas/__init__.pyi +++ b/source/isaaclab/isaaclab/sim/schemas/__init__.pyi @@ -8,6 +8,7 @@ __all__ = [ "PHYSX_MESH_COLLISION_CFGS", "USD_MESH_COLLISION_CFGS", "activate_contact_sensors", + "apply_articulation_root_properties", "apply_namespaced", "apply_rigid_body_properties", "define_actuator_properties", @@ -35,6 +36,7 @@ __all__ = [ "JointDriveBaseCfg", "MassPropertiesCfg", "MeshCollisionBaseCfg", + "ArticulationRootFragment", "RigidBodyFragment", "SchemaFragment", "UsdPhysicsRigidBodyCfg", @@ -55,6 +57,7 @@ from .schemas import ( PHYSX_MESH_COLLISION_CFGS, USD_MESH_COLLISION_CFGS, activate_contact_sensors, + apply_articulation_root_properties, apply_namespaced, apply_rigid_body_properties, define_articulation_root_properties, @@ -78,6 +81,7 @@ from .schemas_actuators import ( ) from .schemas_cfg import ( ArticulationRootBaseCfg, + ArticulationRootFragment, BoundingCubePropertiesCfg, BoundingSpherePropertiesCfg, CollisionBaseCfg, diff --git a/source/isaaclab/isaaclab/sim/schemas/schemas.py b/source/isaaclab/isaaclab/sim/schemas/schemas.py index 11eca17349bc..73f92dccf801 100644 --- a/source/isaaclab/isaaclab/sim/schemas/schemas.py +++ b/source/isaaclab/isaaclab/sim/schemas/schemas.py @@ -254,6 +254,116 @@ def apply_namespaced(cfg, prim_path: str, stage: Usd.Stage | None = None) -> boo """ +def apply_articulation_root_properties( + prim_path: str, + fragments, + stage: Usd.Stage | None = None, + fix_root_link: bool | None = None, +) -> bool: + """Apply a list of articulation-root fragments to a prim. + + Applies ``UsdPhysics.ArticulationRootAPI`` as the anchor (presence-gated: this writer only + runs when the ``articulation_props`` slot carries fragments), then dispatches each fragment + via its :attr:`~isaaclab.sim.schemas.SchemaFragment.func`. Backend fragments carry + backend-specific funcs, so core never imports a backend. Finally, if ``fix_root_link`` is not + ``None``, the world-to-root fixed-joint logic from the legacy + :func:`modify_articulation_root_properties` writer is applied. + + Args: + prim_path: The prim path to apply the articulation-root schemas on. + fragments: An iterable of :class:`~isaaclab.sim.schemas.ArticulationRootFragment` instances. + stage: The stage where to find the prim. Defaults to None, in which case the current + stage is used. + fix_root_link: Whether to fix the root link of the articulation. This is a non-USD, + spawner-level behaviour flag (it is not a fragment field). See + :attr:`~isaaclab.sim.spawners.UsdFileCfg.fix_root_link` for the semantics. Defaults + to None, in which case the root link is not modified. + + Returns: + True if the properties were successfully set. + + Raises: + NotImplementedError: When the root prim is not a rigid body and a fixed joint is to be created. + """ + if stage is None: + stage = get_current_stage() + articulation_prim = stage.GetPrimAtPath(prim_path) + # apply the defining anchor (presence-gated by the caller) + if not UsdPhysics.ArticulationRootAPI(articulation_prim): + UsdPhysics.ArticulationRootAPI.Apply(articulation_prim) + # dispatch each fragment via its own applier (backend funcs live in backend packages) + for cfg in fragments: + func = cfg.func if callable(cfg.func) else string_to_callable(cfg.func) + func(cfg, prim_path, stage) + + # fix root link based on input + # we do the fixed joint processing later to not interfere with setting other properties. + # this logic is reproduced from the legacy ``modify_articulation_root_properties`` writer. + if fix_root_link is not None: + # check if a global fixed joint exists under the root prim + existing_fixed_joint_prim = find_global_fixed_joint_prim(prim_path) + + # if we found a fixed joint, enable/disable it based on the input + # otherwise, create a fixed joint between the world and the root link + if 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}." + ) + existing_fixed_joint_prim.GetJointEnabledAttr().Set(fix_root_link) + elif fix_root_link: + logger.info(f"Creating a fixed joint for the articulation: '{prim_path}'.") + + # note: we have to assume that the root prim is a rigid body, + # i.e. we don't handle the case where the root prim is not a rigid body but has articulation api on it + # Currently, there is no obvious way to get first rigid body link identified by the PhysX parser + if not articulation_prim.HasAPI(UsdPhysics.RigidBodyAPI): + raise NotImplementedError( + f"The articulation prim '{prim_path}' does not have the RigidBodyAPI applied." + " To create a fixed joint, we need to determine the first rigid body link in" + " the articulation tree. However, this is not implemented yet." + ) + + # create a fixed joint between the root link and the world frame + from omni.physx.scripts import utils as physx_utils + + physx_utils.createJoint(stage=stage, joint_type="Fixed", from_prim=None, to_prim=articulation_prim) + + # Having a fixed joint on a rigid body is not treated as "fixed base articulation". + # instead, it is treated as a part of the maximal coordinate tree. + # Moving the articulation root to the parent solves this issue. This is a limitation of the PhysX parser. + # get parent prim + parent_prim = articulation_prim.GetParent() + # apply api to parent + UsdPhysics.ArticulationRootAPI.Apply(parent_prim) + parent_applied = parent_prim.GetAppliedSchemas() + if "PhysxArticulationAPI" not in parent_applied: + parent_prim.AddAppliedSchema("PhysxArticulationAPI") + + # copy the attributes + # -- usd attributes + usd_articulation_api = UsdPhysics.ArticulationRootAPI(articulation_prim) + for attr_name in usd_articulation_api.GetSchemaAttributeNames(): + attr = articulation_prim.GetAttribute(attr_name) + parent_attr = parent_prim.GetAttribute(attr_name) + if not parent_attr: + parent_attr = parent_prim.CreateAttribute(attr_name, attr.GetTypeName()) + parent_attr.Set(attr.Get()) + # -- physx attributes (copy by name prefix) + for attr in articulation_prim.GetAttributes(): + aname = attr.GetName() + if aname.startswith("physxArticulation:"): + parent_attr = parent_prim.GetAttribute(aname) + if not parent_attr: + parent_attr = parent_prim.CreateAttribute(aname, attr.GetTypeName()) + parent_attr.Set(attr.Get()) + + # remove api from root + articulation_prim.RemoveAppliedSchema("PhysxArticulationAPI") + articulation_prim.RemoveAPI(UsdPhysics.ArticulationRootAPI) + + return True + + def define_articulation_root_properties( prim_path: str, cfg: schemas_cfg.ArticulationRootBaseCfg, stage: Usd.Stage | None = None ): diff --git a/source/isaaclab/isaaclab/sim/schemas/schemas_cfg.py b/source/isaaclab/isaaclab/sim/schemas/schemas_cfg.py index bbd29df6b652..db63cbe65deb 100644 --- a/source/isaaclab/isaaclab/sim/schemas/schemas_cfg.py +++ b/source/isaaclab/isaaclab/sim/schemas/schemas_cfg.py @@ -167,6 +167,21 @@ class UsdPhysicsRigidBodyCfg(RigidBodyFragment): """ +@configclass +class ArticulationRootFragment(SchemaFragment): + """Marker base for articulation-root fragments; types the ``articulation_props`` slot. + + Articulation-root fragments author backend-specific articulation properties (solver + iterations, sleep / stabilization thresholds, self-collision toggles). The defining + ``UsdPhysics.ArticulationRootAPI`` anchor is applied by the articulation-root family + writer (:func:`~isaaclab.sim.schemas.apply_articulation_root_properties`) only when the + ``articulation_props`` slot carries fragments (presence-gated, matching the legacy + :meth:`~isaaclab.sim.schemas.modify_articulation_root_properties` behaviour). + """ + + pass + + @configclass class ArticulationRootBaseCfg: """Solver-common properties to apply to the root of an articulation. diff --git a/source/isaaclab/isaaclab/sim/spawners/from_files/from_files.py b/source/isaaclab/isaaclab/sim/spawners/from_files/from_files.py index a2cf6b684c17..a09ae7e1586c 100644 --- a/source/isaaclab/isaaclab/sim/spawners/from_files/from_files.py +++ b/source/isaaclab/isaaclab/sim/spawners/from_files/from_files.py @@ -359,7 +359,16 @@ def _spawn_from_usd_file( # modify articulation root properties if cfg.articulation_props is not None: - schemas.modify_articulation_root_properties(prim_path, cfg.articulation_props) + # transition routing: new fragment list -> apply_*; legacy single cfg -> modify_* + articulation_frags = ( + cfg.articulation_props if isinstance(cfg.articulation_props, (list, tuple)) else [cfg.articulation_props] + ) + if articulation_frags and all(isinstance(f, schemas.SchemaFragment) for f in articulation_frags): + schemas.apply_articulation_root_properties( + prim_path, articulation_frags, fix_root_link=getattr(cfg, "fix_root_link", None) + ) + else: + schemas.modify_articulation_root_properties(prim_path, cfg.articulation_props) # modify tendon properties if cfg.fixed_tendons_props is not None: schemas.modify_fixed_tendon_properties(prim_path, cfg.fixed_tendons_props) diff --git a/source/isaaclab/isaaclab/sim/spawners/from_files/from_files_cfg.py b/source/isaaclab/isaaclab/sim/spawners/from_files/from_files_cfg.py index 22857f8d45b1..fabc62c5860e 100644 --- a/source/isaaclab/isaaclab/sim/spawners/from_files/from_files_cfg.py +++ b/source/isaaclab/isaaclab/sim/spawners/from_files/from_files_cfg.py @@ -34,8 +34,36 @@ class FileCfg(RigidObjectSpawnerCfg, DeformableObjectSpawnerCfg): scale: tuple[float, float, float] | None = None """Scale of the asset. Defaults to None, in which case the scale is not modified.""" - articulation_props: schemas.ArticulationRootPropertiesCfg | None = None - """Properties to apply to the articulation root.""" + articulation_props: ( + schemas.ArticulationRootBaseCfg + | schemas.ArticulationRootFragment + | list[schemas.ArticulationRootFragment] + | None + ) = None + """Properties to apply to the articulation root. + + Accepts either a single legacy cfg (e.g. :class:`~isaaclab.sim.schemas.ArticulationRootBaseCfg`) + or a list of :class:`~isaaclab.sim.schemas.ArticulationRootFragment` fragments + (e.g. ``[PhysxArticulationCfg(...), NewtonArticulationCfg(...)]``). When a fragment list is + given, ``UsdPhysics.ArticulationRootAPI`` is applied as the anchor (presence-gated) and each + fragment writes its own namespace. + """ + + fix_root_link: bool | None = None + """Whether to fix the root link of the articulation. Defaults to None. + + This is a non-USD, spawner-level behaviour flag consumed by + :func:`~isaaclab.sim.schemas.apply_articulation_root_properties` when + :attr:`articulation_props` is given as a fragment list: + + * If set to None, the root link is not modified. + * If the articulation already has a fixed root link, this flag enables or disables the fixed joint. + * If the articulation does not have a fixed root link, this flag creates a fixed joint between the + world frame and the root link (named "FixedJoint" under the articulation prim). + + When :attr:`articulation_props` is given as a legacy cfg, set + :attr:`~isaaclab.sim.schemas.ArticulationRootBaseCfg.fix_root_link` on that cfg instead. + """ fixed_tendons_props: schemas.FixedTendonPropertiesCfg | None = None """Properties to apply to the fixed tendons (if any).""" diff --git a/source/isaaclab/test/sim/test_articulation_fragments.py b/source/isaaclab/test/sim/test_articulation_fragments.py new file mode 100644 index 000000000000..d6245b3dee26 --- /dev/null +++ b/source/isaaclab/test/sim/test_articulation_fragments.py @@ -0,0 +1,162 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Launch Isaac Sim Simulator first.""" + +from isaaclab.app import AppLauncher + +# launch omniverse app +simulation_app = AppLauncher(headless=True).app + +"""Rest everything follows.""" + +from pxr import UsdGeom, UsdPhysics + +import isaaclab.sim as sim_utils +from isaaclab.sim import SimulationCfg, SimulationContext + + +def _make_xform(stage, path="/World/Art"): + UsdGeom.Xform.Define(stage, path) + return stage.GetPrimAtPath(path) + + +# ------------------------------------------------------------------------------------- +# ArticulationRootFragment marker + metadata +# ------------------------------------------------------------------------------------- + + +def test_articulation_fragment_metadata_defaults(): + from isaaclab_physx.sim.schemas import PhysxArticulationCfg + + from isaaclab.sim.schemas import ArticulationRootFragment, SchemaFragment + + cfg = PhysxArticulationCfg(articulation_enabled=True) + assert isinstance(cfg, ArticulationRootFragment) and isinstance(cfg, SchemaFragment) + assert type(cfg)._usd_namespace == "physxArticulation" + assert type(cfg)._usd_applied_schema == "PhysxArticulationAPI" + assert cfg.func == "isaaclab.sim.schemas:apply_namespaced" + assert cfg.articulation_enabled is True and cfg.enabled_self_collisions is None + + +# ------------------------------------------------------------------------------------- +# PhysxArticulationCfg writes physxArticulation:* namespace +# ------------------------------------------------------------------------------------- + + +def test_physx_articulation_fragment_writes_physx_namespace(): + from isaaclab_physx.sim.schemas import PhysxArticulationCfg + + from isaaclab.sim.schemas import apply_namespaced + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + prim = _make_xform(stage, "/World/A1") + UsdPhysics.ArticulationRootAPI.Apply(prim) + apply_namespaced( + PhysxArticulationCfg(articulation_enabled=True, enabled_self_collisions=False, sleep_threshold=0.1), + "/World/A1", + stage, + ) + assert prim.GetAttribute("physxArticulation:articulationEnabled").Get() is True + assert prim.GetAttribute("physxArticulation:enabledSelfCollisions").Get() is False + assert abs(prim.GetAttribute("physxArticulation:sleepThreshold").Get() - 0.1) < 1e-6 + + +# ------------------------------------------------------------------------------------- +# NewtonArticulationCfg writes newton:* namespace +# ------------------------------------------------------------------------------------- + + +def test_newton_articulation_fragment_writes_newton_namespace(): + from isaaclab_newton.sim.schemas import NewtonArticulationCfg + + from isaaclab.sim.schemas import apply_namespaced + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + prim = _make_xform(stage, "/World/A2") + UsdPhysics.ArticulationRootAPI.Apply(prim) + apply_namespaced(NewtonArticulationCfg(self_collision_enabled=True), "/World/A2", stage) + assert prim.GetAttribute("newton:selfCollisionEnabled").Get() is True + + +# ------------------------------------------------------------------------------------- +# apply_articulation_root_properties dispatch (anchor + multi-namespace composition) +# ------------------------------------------------------------------------------------- + + +def test_apply_articulation_root_properties_composes_namespaces(): + from isaaclab_newton.sim.schemas import NewtonArticulationCfg + from isaaclab_physx.sim.schemas import PhysxArticulationCfg + + from isaaclab.sim.schemas import apply_articulation_root_properties + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + _make_xform(stage, "/World/A3") + apply_articulation_root_properties( + "/World/A3", + [ + PhysxArticulationCfg(enabled_self_collisions=True, solver_position_iteration_count=8), + NewtonArticulationCfg(self_collision_enabled=True), + ], + stage, + ) + prim = stage.GetPrimAtPath("/World/A3") + assert bool(UsdPhysics.ArticulationRootAPI(prim)) # presence-gated anchor applied + assert prim.GetAttribute("physxArticulation:enabledSelfCollisions").Get() is True + assert prim.GetAttribute("physxArticulation:solverPositionIterationCount").Get() == 8 + assert prim.GetAttribute("newton:selfCollisionEnabled").Get() is True + + +# ------------------------------------------------------------------------------------- +# fix_root_link spawner-level flag: toggles an existing fixed joint +# ------------------------------------------------------------------------------------- + + +def test_apply_articulation_root_properties_toggles_existing_fixed_joint(): + from isaaclab_physx.sim.schemas import PhysxArticulationCfg + + from isaaclab.sim.schemas import apply_articulation_root_properties + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + # root prim with a rigid body and an articulation root + root = _make_xform(stage, "/World/A4") + UsdPhysics.RigidBodyAPI.Apply(root) + UsdPhysics.ArticulationRootAPI.Apply(root) + # author an existing global fixed joint between the world and the root link + joint = UsdPhysics.FixedJoint.Define(stage, "/World/A4/FixedJoint") + joint.CreateBody1Rel().SetTargets(["/World/A4"]) + joint.CreateJointEnabledAttr(True) + + apply_articulation_root_properties( + "/World/A4", + [PhysxArticulationCfg(articulation_enabled=True)], + stage, + fix_root_link=False, + ) + assert joint.GetJointEnabledAttr().Get() is False + + +# ------------------------------------------------------------------------------------- +# public imports +# ------------------------------------------------------------------------------------- + + +def test_public_imports(): + from isaaclab_newton.sim.schemas import NewtonArticulationCfg # noqa: F401 + from isaaclab_physx.sim.schemas import PhysxArticulationCfg # noqa: F401 + + from isaaclab.sim.schemas import ( # noqa: F401 + ArticulationRootFragment, + SchemaFragment, + apply_articulation_root_properties, + ) diff --git a/source/isaaclab_newton/changelog.d/vidurv-schema-frag-articulation.minor.rst b/source/isaaclab_newton/changelog.d/vidurv-schema-frag-articulation.minor.rst new file mode 100644 index 000000000000..85c12eb716b3 --- /dev/null +++ b/source/isaaclab_newton/changelog.d/vidurv-schema-frag-articulation.minor.rst @@ -0,0 +1,8 @@ +Added +^^^^^ + +* Added :class:`~isaaclab_newton.sim.schemas.NewtonArticulationCfg`, the ``newton:*`` + single-namespace articulation-root fragment (``newton:selfCollisionEnabled`` via + ``NewtonArticulationRootAPI``). It composes with + :class:`~isaaclab_physx.sim.schemas.PhysxArticulationCfg` in an ``articulation_props`` fragment + list applied via :func:`~isaaclab.sim.schemas.apply_articulation_root_properties`. diff --git a/source/isaaclab_newton/isaaclab_newton/sim/schemas/__init__.pyi b/source/isaaclab_newton/isaaclab_newton/sim/schemas/__init__.pyi index 89ca2d068afb..4dca42cb7a66 100644 --- a/source/isaaclab_newton/isaaclab_newton/sim/schemas/__init__.pyi +++ b/source/isaaclab_newton/isaaclab_newton/sim/schemas/__init__.pyi @@ -7,6 +7,7 @@ __all__ = [ "MujocoJointDrivePropertiesCfg", "MujocoRigidBodyCfg", "MujocoRigidBodyPropertiesCfg", + "NewtonArticulationCfg", "NewtonArticulationRootPropertiesCfg", "NewtonCollisionPropertiesCfg", "NewtonDeformableBodyPropertiesCfg", @@ -21,6 +22,7 @@ from .schemas_cfg import ( MujocoJointDrivePropertiesCfg, MujocoRigidBodyCfg, MujocoRigidBodyPropertiesCfg, + NewtonArticulationCfg, NewtonArticulationRootPropertiesCfg, NewtonCollisionPropertiesCfg, NewtonDeformableBodyPropertiesCfg, diff --git a/source/isaaclab_newton/isaaclab_newton/sim/schemas/schemas_cfg.py b/source/isaaclab_newton/isaaclab_newton/sim/schemas/schemas_cfg.py index 4c99379e1ad8..0a42d08904ed 100644 --- a/source/isaaclab_newton/isaaclab_newton/sim/schemas/schemas_cfg.py +++ b/source/isaaclab_newton/isaaclab_newton/sim/schemas/schemas_cfg.py @@ -9,6 +9,7 @@ from isaaclab.sim.schemas.schemas_cfg import ( ArticulationRootBaseCfg, + ArticulationRootFragment, CollisionBaseCfg, DeformableBodyPropertiesBaseCfg, JointDriveBaseCfg, @@ -345,3 +346,29 @@ class NewtonArticulationRootPropertiesCfg(ArticulationRootBaseCfg): Newton's resolver checks this native attribute first before falling back to ``physxArticulation:enabledSelfCollisions``. """ + + +@configclass +class NewtonArticulationCfg(ArticulationRootFragment): + """``newton:*`` articulation-root attributes for Newton. + + A single-namespace fragment (see :class:`~isaaclab.sim.schemas.SchemaFragment`) carrying + Newton-native self-collision control. It owns the ``NewtonArticulationRootAPI`` applied + schema. Composes with :class:`~isaaclab_physx.sim.schemas.PhysxArticulationCfg` in an + ``articulation_props`` fragment list; the ``UsdPhysics.ArticulationRootAPI`` anchor is + applied by :func:`~isaaclab.sim.schemas.apply_articulation_root_properties`. + + .. note:: + If the values are None, they are not modified. + """ + + _usd_namespace: ClassVar[str | None] = "newton" + _usd_applied_schema: ClassVar[str | None] = "NewtonArticulationRootAPI" + + self_collision_enabled: bool | None = None + """Whether self-collisions between bodies in this articulation are enabled. + + Written to ``newton:selfCollisionEnabled`` via ``NewtonArticulationRootAPI``. Newton's + resolver checks this native attribute first before falling back to the PhysX namespace + (:attr:`~isaaclab_physx.sim.schemas.PhysxArticulationCfg.enabled_self_collisions`). + """ diff --git a/source/isaaclab_physx/changelog.d/vidurv-schema-frag-articulation.minor.rst b/source/isaaclab_physx/changelog.d/vidurv-schema-frag-articulation.minor.rst new file mode 100644 index 000000000000..12b1a4211d50 --- /dev/null +++ b/source/isaaclab_physx/changelog.d/vidurv-schema-frag-articulation.minor.rst @@ -0,0 +1,8 @@ +Added +^^^^^ + +* Added :class:`~isaaclab_physx.sim.schemas.PhysxArticulationCfg`, the ``physxArticulation:*`` + single-namespace articulation-root fragment (PhysX ``PhysxArticulationAPI``). It carries + ``articulation_enabled``, ``enabled_self_collisions``, solver position / velocity iteration + counts, and sleep / stabilization thresholds, and composes in an ``articulation_props`` fragment + list applied via :func:`~isaaclab.sim.schemas.apply_articulation_root_properties`. diff --git a/source/isaaclab_physx/isaaclab_physx/sim/schemas/__init__.pyi b/source/isaaclab_physx/isaaclab_physx/sim/schemas/__init__.pyi index 10d2502ddf2b..16bfd212f01f 100644 --- a/source/isaaclab_physx/isaaclab_physx/sim/schemas/__init__.pyi +++ b/source/isaaclab_physx/isaaclab_physx/sim/schemas/__init__.pyi @@ -15,6 +15,7 @@ __all__ = [ "JointDrivePropertiesCfg", "MeshCollisionPropertiesCfg", "OmniPhysicsDeformableBodyPropertiesCfg", + "PhysxArticulationCfg", "PhysxArticulationRootPropertiesCfg", "PhysxCollisionPropertiesCfg", "PhysxConvexDecompositionPropertiesCfg", @@ -50,6 +51,7 @@ from .schemas_cfg import ( JointDrivePropertiesCfg, MeshCollisionPropertiesCfg, OmniPhysicsDeformableBodyPropertiesCfg, + PhysxArticulationCfg, PhysxArticulationRootPropertiesCfg, PhysxCollisionPropertiesCfg, PhysxConvexDecompositionPropertiesCfg, diff --git a/source/isaaclab_physx/isaaclab_physx/sim/schemas/schemas_cfg.py b/source/isaaclab_physx/isaaclab_physx/sim/schemas/schemas_cfg.py index 18a5de023f90..c17c0a89fd45 100644 --- a/source/isaaclab_physx/isaaclab_physx/sim/schemas/schemas_cfg.py +++ b/source/isaaclab_physx/isaaclab_physx/sim/schemas/schemas_cfg.py @@ -10,6 +10,7 @@ from isaaclab.sim.schemas.schemas_cfg import ( ArticulationRootBaseCfg, + ArticulationRootFragment, CollisionBaseCfg, DeformableBodyPropertiesBaseCfg, JointDriveBaseCfg, @@ -483,6 +484,51 @@ class PhysxArticulationRootPropertiesCfg(ArticulationRootBaseCfg): """The mass-normalized kinetic energy threshold below which an articulation may participate in stabilization.""" +@configclass +class PhysxArticulationCfg(ArticulationRootFragment): + """``physxArticulation:*`` articulation-root attributes from `PhysxArticulationAPI`_. + + A single-namespace fragment (see :class:`~isaaclab.sim.schemas.SchemaFragment`) for the PhysX + articulation add-on schema. Applied alongside other articulation-root fragments via + :func:`~isaaclab.sim.schemas.apply_articulation_root_properties`, which applies the + ``UsdPhysics.ArticulationRootAPI`` anchor (presence-gated). This fragment owns the + ``PhysxArticulationAPI`` applied schema. + + .. _PhysxArticulationAPI: https://docs.omniverse.nvidia.com/kit/docs/omni_usd_schema_physics/104.2/class_physx_schema_physx_articulation_a_p_i.html + """ + + _usd_namespace: ClassVar[str | None] = "physxArticulation" + _usd_applied_schema: ClassVar[str | None] = "PhysxArticulationAPI" + + articulation_enabled: bool | None = None + """Whether to enable or disable the articulation. + + PhysX honors this per-articulation at sim time via ``physxArticulation:articulationEnabled``: + setting False makes PhysX skip the articulation in its solver passes. + """ + + enabled_self_collisions: bool | None = None + """Whether self-collisions between bodies in the same articulation are enabled. + + Written to ``physxArticulation:enabledSelfCollisions``. The Newton-native counterpart is + :attr:`~isaaclab_newton.sim.schemas.NewtonArticulationCfg.self_collision_enabled` + (``newton:selfCollisionEnabled``). + """ + + solver_position_iteration_count: int | None = None + """Solver position iteration counts for the articulation.""" + + solver_velocity_iteration_count: int | None = None + """Solver velocity iteration counts for the articulation.""" + + sleep_threshold: float | None = None + """Mass-normalized kinetic energy threshold below which an actor may go to sleep [m²/s²].""" + + stabilization_threshold: float | None = None + """Mass-normalized kinetic energy threshold below which an articulation may participate in + stabilization [m²/s²].""" + + @configclass class ArticulationRootPropertiesCfg(PhysxArticulationRootPropertiesCfg): """Deprecated: use :class:`PhysxArticulationRootPropertiesCfg` or the solver-common base class. From 43188f1fce2f81ed7558d8df7962d0a401ad6c95 Mon Sep 17 00:00:00 2001 From: Vidur Vij Date: Fri, 5 Jun 2026 12:37:05 -0700 Subject: [PATCH 2/6] docs(schemas): mark transition shim if/else for removal post-migration --- source/isaaclab/isaaclab/sim/converters/mesh_converter.py | 2 +- .../isaaclab/isaaclab/sim/spawners/from_files/from_files.py | 4 ++-- source/isaaclab/isaaclab/sim/spawners/meshes/meshes.py | 2 +- source/isaaclab/isaaclab/sim/spawners/shapes/shapes.py | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/source/isaaclab/isaaclab/sim/converters/mesh_converter.py b/source/isaaclab/isaaclab/sim/converters/mesh_converter.py index 9c25697e88f1..f814e4fdb877 100644 --- a/source/isaaclab/isaaclab/sim/converters/mesh_converter.py +++ b/source/isaaclab/isaaclab/sim/converters/mesh_converter.py @@ -185,7 +185,7 @@ def _convert_asset(self, cfg: MeshConverterCfg): # apply mass properties if cfg.mass_props is not None: schemas.define_mass_properties(prim_path=xform_prim.GetPath(), cfg=cfg.mass_props, stage=stage) - # apply rigid body properties (transition routing: fragment list -> apply_*; legacy cfg -> define_*) + # apply rigid body properties (transition shim, remove later: fragment list -> apply_*; legacy cfg -> define_*) if cfg.rigid_props is not None: rigid_frags = cfg.rigid_props if isinstance(cfg.rigid_props, (list, tuple)) else [cfg.rigid_props] if rigid_frags and all(isinstance(f, schemas.SchemaFragment) for f in rigid_frags): diff --git a/source/isaaclab/isaaclab/sim/spawners/from_files/from_files.py b/source/isaaclab/isaaclab/sim/spawners/from_files/from_files.py index a09ae7e1586c..33e81dae10dc 100644 --- a/source/isaaclab/isaaclab/sim/spawners/from_files/from_files.py +++ b/source/isaaclab/isaaclab/sim/spawners/from_files/from_files.py @@ -344,7 +344,7 @@ def _spawn_from_usd_file( # modify rigid body properties if cfg.rigid_props is not None: - # transition routing: new fragment list -> apply_*; legacy single cfg -> modify_* + # transition shim, remove later: new fragment list -> apply_*; legacy single cfg -> modify_* rigid_frags = cfg.rigid_props if isinstance(cfg.rigid_props, (list, tuple)) else [cfg.rigid_props] if rigid_frags and all(isinstance(f, schemas.SchemaFragment) for f in rigid_frags): schemas.apply_rigid_body_properties(prim_path, rigid_frags) @@ -359,7 +359,7 @@ def _spawn_from_usd_file( # modify articulation root properties if cfg.articulation_props is not None: - # transition routing: new fragment list -> apply_*; legacy single cfg -> modify_* + # transition shim, remove later: new fragment list -> apply_*; legacy single cfg -> modify_* articulation_frags = ( cfg.articulation_props if isinstance(cfg.articulation_props, (list, tuple)) else [cfg.articulation_props] ) diff --git a/source/isaaclab/isaaclab/sim/spawners/meshes/meshes.py b/source/isaaclab/isaaclab/sim/spawners/meshes/meshes.py index d8eaffa5593a..27f815343c0a 100644 --- a/source/isaaclab/isaaclab/sim/spawners/meshes/meshes.py +++ b/source/isaaclab/isaaclab/sim/spawners/meshes/meshes.py @@ -444,7 +444,7 @@ def _spawn_mesh_geom_from_mesh( # apply mass properties if cfg.mass_props is not None: schemas.define_mass_properties(prim_path, cfg.mass_props, stage=stage) - # apply rigid properties (transition routing: fragment list -> apply_*; legacy cfg -> define_*) + # apply rigid properties (transition shim, remove later: fragment list -> apply_*; legacy cfg -> define_*) rigid_frags = cfg.rigid_props if isinstance(cfg.rigid_props, (list, tuple)) else [cfg.rigid_props] if rigid_frags and all(isinstance(f, schemas.SchemaFragment) for f in rigid_frags): schemas.apply_rigid_body_properties(prim_path, rigid_frags, stage=stage) diff --git a/source/isaaclab/isaaclab/sim/spawners/shapes/shapes.py b/source/isaaclab/isaaclab/sim/spawners/shapes/shapes.py index d4af64a14677..0601cbed5411 100644 --- a/source/isaaclab/isaaclab/sim/spawners/shapes/shapes.py +++ b/source/isaaclab/isaaclab/sim/spawners/shapes/shapes.py @@ -322,7 +322,7 @@ def _spawn_geom_from_prim_type( schemas.define_mass_properties(prim_path, cfg.mass_props, stage=stage) # apply rigid body properties if cfg.rigid_props is not None: - # transition routing: new fragment list -> apply_*; legacy single cfg -> define_* + # transition shim, remove later: new fragment list -> apply_*; legacy single cfg -> define_* rigid_frags = cfg.rigid_props if isinstance(cfg.rigid_props, (list, tuple)) else [cfg.rigid_props] if rigid_frags and all(isinstance(f, schemas.SchemaFragment) for f in rigid_frags): schemas.apply_rigid_body_properties(prim_path, rigid_frags, stage=stage) From 68cde1167e052b5d8943a79a8c23d5d9dde2368b Mon Sep 17 00:00:00 2001 From: Vidur Vij Date: Mon, 8 Jun 2026 19:51:31 -0700 Subject: [PATCH 3/6] fix(schemas): resolve articulation root before applying fragments apply_articulation_root_properties applied UsdPhysics.ArticulationRootAPI unconditionally on the input prim. USD assets author the root on a child prim (the root link / fixed joint), so this stamped a SECOND root on the top prim and wrote the fragment properties + fix_root_link logic to the wrong prim -- a duplicate root that also violates the single-root asset requirement. Descend the subtree to the existing root and tune it in place (matching the legacy @apply_nested modify_articulation_root_properties writer); only define a fresh root on the input prim when the subtree has none (primitive or programmatic spawns). Fragments and the fix_root_link reparent logic now target the resolved root. Also guard an invalid input prim path. Add a regression test covering the child-root case (verified to fail before the fix). --- .../isaaclab/isaaclab/sim/schemas/schemas.py | 82 +++++++++++++++---- .../test/sim/test_articulation_fragments.py | 38 +++++++++ 2 files changed, 102 insertions(+), 18 deletions(-) diff --git a/source/isaaclab/isaaclab/sim/schemas/schemas.py b/source/isaaclab/isaaclab/sim/schemas/schemas.py index 73f92dccf801..c3c09d40d5ee 100644 --- a/source/isaaclab/isaaclab/sim/schemas/schemas.py +++ b/source/isaaclab/isaaclab/sim/schemas/schemas.py @@ -254,6 +254,32 @@ def apply_namespaced(cfg, prim_path: str, stage: Usd.Stage | None = None) -> boo """ +def _find_articulation_root_prim(prim: Usd.Prim) -> Usd.Prim | None: + """Return the first prim at or under ``prim`` that already has ``UsdPhysics.ArticulationRootAPI``. + + USD assets author the articulation root on a child prim (the root link / fixed joint), so the + search descends the subtree like the legacy ``@apply_nested`` writer. Nested articulation roots + are not allowed, so the search stops at the first match per branch. Instanced prims are skipped + (their prototypes cannot be authored on). Returns ``None`` when no prim in the subtree carries + the articulation root. + + Args: + prim: The root of the subtree to search. + + Returns: + The prim that owns ``UsdPhysics.ArticulationRootAPI``, or ``None`` if none is found. + """ + queue = [prim] + while queue: + current = queue.pop(0) + if current.IsInstance(): + continue + if current.HasAPI(UsdPhysics.ArticulationRootAPI): + return current + queue.extend(current.GetChildren()) + return None + + def apply_articulation_root_properties( prim_path: str, fragments, @@ -262,15 +288,23 @@ def apply_articulation_root_properties( ) -> bool: """Apply a list of articulation-root fragments to a prim. - Applies ``UsdPhysics.ArticulationRootAPI`` as the anchor (presence-gated: this writer only - runs when the ``articulation_props`` slot carries fragments), then dispatches each fragment - via its :attr:`~isaaclab.sim.schemas.SchemaFragment.func`. Backend fragments carry - backend-specific funcs, so core never imports a backend. Finally, if ``fix_root_link`` is not - ``None``, the world-to-root fixed-joint logic from the legacy - :func:`modify_articulation_root_properties` writer is applied. + Resolves the articulation root before writing: USD assets author + ``UsdPhysics.ArticulationRootAPI`` on a child prim (the root link / fixed joint), so this + writer descends the subtree under ``prim_path`` and tunes the existing root in place (matching + the legacy :func:`modify_articulation_root_properties` writer). Only when no prim in the + subtree carries the root does it apply ``UsdPhysics.ArticulationRootAPI`` on ``prim_path`` + itself (define-fresh, e.g. for primitive or programmatic spawns). This guarantees exactly one + articulation root rather than stamping a duplicate on the input prim. + + Each fragment is then dispatched to the resolved root via its + :attr:`~isaaclab.sim.schemas.SchemaFragment.func`. Backend fragments carry backend-specific + funcs, so core never imports a backend. Finally, if ``fix_root_link`` is not ``None``, the + world-to-root fixed-joint logic from the legacy :func:`modify_articulation_root_properties` + writer is applied to the resolved root. Args: - prim_path: The prim path to apply the articulation-root schemas on. + prim_path: The prim path to search for the articulation root under (the root may be on a + descendant); the schemas are applied to the resolved root prim. fragments: An iterable of :class:`~isaaclab.sim.schemas.ArticulationRootFragment` instances. stage: The stage where to find the prim. Defaults to None, in which case the current stage is used. @@ -283,42 +317,54 @@ def apply_articulation_root_properties( True if the properties were successfully set. Raises: + ValueError: When the prim path is not valid. NotImplementedError: When the root prim is not a rigid body and a fixed joint is to be created. """ if stage is None: stage = get_current_stage() - articulation_prim = stage.GetPrimAtPath(prim_path) - # apply the defining anchor (presence-gated by the caller) - if not UsdPhysics.ArticulationRootAPI(articulation_prim): - UsdPhysics.ArticulationRootAPI.Apply(articulation_prim) - # dispatch each fragment via its own applier (backend funcs live in backend packages) + input_prim = stage.GetPrimAtPath(prim_path) + # check if prim path is valid + if not input_prim.IsValid(): + raise ValueError(f"Prim path '{prim_path}' is not valid.") + # USD assets author the ArticulationRootAPI on a child prim, and nested roots are not allowed, + # so tune the single existing root in place (matching the legacy @apply_nested writer) rather + # than stamping a duplicate on the input prim. Only define a fresh root on the input prim when + # the subtree has none (e.g. primitive or programmatic spawns). This keeps exactly one + # ArticulationRootAPI in the tree. + articulation_prim = _find_articulation_root_prim(input_prim) + if articulation_prim is None: + UsdPhysics.ArticulationRootAPI.Apply(input_prim) + articulation_prim = input_prim + root_path = articulation_prim.GetPath().pathString + # dispatch each fragment via its own applier to the resolved root prim (backend funcs live in + # backend packages, so core never imports a backend) for cfg in fragments: func = cfg.func if callable(cfg.func) else string_to_callable(cfg.func) - func(cfg, prim_path, stage) + func(cfg, root_path, stage) # fix root link based on input # we do the fixed joint processing later to not interfere with setting other properties. # this logic is reproduced from the legacy ``modify_articulation_root_properties`` writer. if fix_root_link is not None: - # check if a global fixed joint exists under the root prim - existing_fixed_joint_prim = find_global_fixed_joint_prim(prim_path) + # check if a global fixed joint exists under the resolved root prim + existing_fixed_joint_prim = find_global_fixed_joint_prim(root_path) # if we found a fixed joint, enable/disable it based on the input # otherwise, create a fixed joint between the world and the root link if 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}." + f"Found an existing fixed joint for the articulation: '{root_path}'. Setting it to: {fix_root_link}." ) existing_fixed_joint_prim.GetJointEnabledAttr().Set(fix_root_link) elif fix_root_link: - logger.info(f"Creating a fixed joint for the articulation: '{prim_path}'.") + logger.info(f"Creating a fixed joint for the articulation: '{root_path}'.") # note: we have to assume that the root prim is a rigid body, # i.e. we don't handle the case where the root prim is not a rigid body but has articulation api on it # Currently, there is no obvious way to get first rigid body link identified by the PhysX parser if not articulation_prim.HasAPI(UsdPhysics.RigidBodyAPI): raise NotImplementedError( - f"The articulation prim '{prim_path}' does not have the RigidBodyAPI applied." + f"The articulation prim '{root_path}' does not have the RigidBodyAPI applied." " To create a fixed joint, we need to determine the first rigid body link in" " the articulation tree. However, this is not implemented yet." ) diff --git a/source/isaaclab/test/sim/test_articulation_fragments.py b/source/isaaclab/test/sim/test_articulation_fragments.py index d6245b3dee26..e7105a812c41 100644 --- a/source/isaaclab/test/sim/test_articulation_fragments.py +++ b/source/isaaclab/test/sim/test_articulation_fragments.py @@ -115,6 +115,44 @@ def test_apply_articulation_root_properties_composes_namespaces(): assert prim.GetAttribute("newton:selfCollisionEnabled").Get() is True +# ------------------------------------------------------------------------------------- +# Regression: root on a CHILD prim (USD assets) must be tuned in place, not duplicated +# ------------------------------------------------------------------------------------- + + +def test_apply_articulation_root_properties_tunes_existing_child_root(): + """When the articulation root lives on a child prim (as in USD assets), the writer must tune + that existing root rather than stamp a second ``ArticulationRootAPI`` on the input (top) prim + -- a duplicate root mis-writes the properties and violates the 'exactly one root' invariant. + """ + from isaaclab_physx.sim.schemas import PhysxArticulationCfg + + from isaaclab.sim.schemas import apply_articulation_root_properties + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + top = _make_xform(stage, "/World/Asset") + child = _make_xform(stage, "/World/Asset/base") + UsdPhysics.ArticulationRootAPI.Apply(child) # asset already carries its root on a child prim + + apply_articulation_root_properties( + "/World/Asset", + [PhysxArticulationCfg(solver_position_iteration_count=8)], + stage, + ) + + # the existing child root is tuned ... + assert child.HasAPI(UsdPhysics.ArticulationRootAPI) + assert child.GetAttribute("physxArticulation:solverPositionIterationCount").Get() == 8 + # ... and NO duplicate root / stray write is added on the top prim + assert not top.HasAPI(UsdPhysics.ArticulationRootAPI) + assert not top.GetAttribute("physxArticulation:solverPositionIterationCount").HasAuthoredValue() + # exactly one ArticulationRootAPI exists in the subtree + roots = [p for p in stage.Traverse() if p.HasAPI(UsdPhysics.ArticulationRootAPI)] + assert len(roots) == 1 and roots[0] == child + + # ------------------------------------------------------------------------------------- # fix_root_link spawner-level flag: toggles an existing fixed joint # ------------------------------------------------------------------------------------- From 4b03c2242ba8ad7411949cc991d452019b82ae19 Mon Sep 17 00:00:00 2001 From: Vidur Vij Date: Mon, 8 Jun 2026 19:53:45 -0700 Subject: [PATCH 4/6] refactor(schemas): reuse get_first_matching_child_prim for root lookup Replace the hand-rolled subtree BFS with the existing get_first_matching_child_prim query helper (predicate = HasAPI(ArticulationRootAPI)). Same find-or-define behavior; the helper also validates the path, so the bespoke helper and the separate IsValid guard are dropped. Pass traverse_instance_prims=False to keep parity with the legacy @apply_nested writer, which does not author through instances. --- .../isaaclab/isaaclab/sim/schemas/schemas.py | 50 ++++++------------- 1 file changed, 14 insertions(+), 36 deletions(-) diff --git a/source/isaaclab/isaaclab/sim/schemas/schemas.py b/source/isaaclab/isaaclab/sim/schemas/schemas.py index c3c09d40d5ee..b3c421b2b03d 100644 --- a/source/isaaclab/isaaclab/sim/schemas/schemas.py +++ b/source/isaaclab/isaaclab/sim/schemas/schemas.py @@ -23,6 +23,7 @@ create_prim, find_global_fixed_joint_prim, get_all_matching_child_prims, + get_first_matching_child_prim, safe_set_attribute_on_usd_prim, safe_set_attribute_on_usd_schema, ) @@ -254,32 +255,6 @@ def apply_namespaced(cfg, prim_path: str, stage: Usd.Stage | None = None) -> boo """ -def _find_articulation_root_prim(prim: Usd.Prim) -> Usd.Prim | None: - """Return the first prim at or under ``prim`` that already has ``UsdPhysics.ArticulationRootAPI``. - - USD assets author the articulation root on a child prim (the root link / fixed joint), so the - search descends the subtree like the legacy ``@apply_nested`` writer. Nested articulation roots - are not allowed, so the search stops at the first match per branch. Instanced prims are skipped - (their prototypes cannot be authored on). Returns ``None`` when no prim in the subtree carries - the articulation root. - - Args: - prim: The root of the subtree to search. - - Returns: - The prim that owns ``UsdPhysics.ArticulationRootAPI``, or ``None`` if none is found. - """ - queue = [prim] - while queue: - current = queue.pop(0) - if current.IsInstance(): - continue - if current.HasAPI(UsdPhysics.ArticulationRootAPI): - return current - queue.extend(current.GetChildren()) - return None - - def apply_articulation_root_properties( prim_path: str, fragments, @@ -322,19 +297,22 @@ def apply_articulation_root_properties( """ if stage is None: stage = get_current_stage() - input_prim = stage.GetPrimAtPath(prim_path) - # check if prim path is valid - if not input_prim.IsValid(): - raise ValueError(f"Prim path '{prim_path}' is not valid.") # USD assets author the ArticulationRootAPI on a child prim, and nested roots are not allowed, # so tune the single existing root in place (matching the legacy @apply_nested writer) rather - # than stamping a duplicate on the input prim. Only define a fresh root on the input prim when - # the subtree has none (e.g. primitive or programmatic spawns). This keeps exactly one - # ArticulationRootAPI in the tree. - articulation_prim = _find_articulation_root_prim(input_prim) + # than stamping a duplicate on the input prim. ``get_first_matching_child_prim`` validates the + # path and descends depth-first; instance proxies are not traversed because the root cannot be + # authored onto a prototype's descendants. + articulation_prim = get_first_matching_child_prim( + prim_path, + lambda prim: prim.HasAPI(UsdPhysics.ArticulationRootAPI), + stage, + traverse_instance_prims=False, + ) + # only define a fresh root on the input prim when the subtree has none (e.g. primitive or + # programmatic spawns). This keeps exactly one ArticulationRootAPI in the tree. if articulation_prim is None: - UsdPhysics.ArticulationRootAPI.Apply(input_prim) - articulation_prim = input_prim + articulation_prim = stage.GetPrimAtPath(prim_path) + UsdPhysics.ArticulationRootAPI.Apply(articulation_prim) root_path = articulation_prim.GetPath().pathString # dispatch each fragment via its own applier to the resolved root prim (backend funcs live in # backend packages, so core never imports a backend) From a2cf3bc98ce3344768aa1580221eca4d2415d43a Mon Sep 17 00:00:00 2001 From: Vidur Vij Date: Mon, 8 Jun 2026 19:55:13 -0700 Subject: [PATCH 5/6] style(schemas): trim verbose comments in articulation root writer The function docstring already explains the find-or-define rationale, so collapse the inline comments to terse intent. The fix_root_link block is left untouched -- it is reproduced verbatim from the legacy writer. --- source/isaaclab/isaaclab/sim/schemas/schemas.py | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/source/isaaclab/isaaclab/sim/schemas/schemas.py b/source/isaaclab/isaaclab/sim/schemas/schemas.py index b3c421b2b03d..f93385cae2f7 100644 --- a/source/isaaclab/isaaclab/sim/schemas/schemas.py +++ b/source/isaaclab/isaaclab/sim/schemas/schemas.py @@ -297,25 +297,20 @@ def apply_articulation_root_properties( """ if stage is None: stage = get_current_stage() - # USD assets author the ArticulationRootAPI on a child prim, and nested roots are not allowed, - # so tune the single existing root in place (matching the legacy @apply_nested writer) rather - # than stamping a duplicate on the input prim. ``get_first_matching_child_prim`` validates the - # path and descends depth-first; instance proxies are not traversed because the root cannot be - # authored onto a prototype's descendants. + # tune the existing root in place (it may live on a child prim); instance proxies can't be + # authored on, so don't traverse them articulation_prim = get_first_matching_child_prim( prim_path, lambda prim: prim.HasAPI(UsdPhysics.ArticulationRootAPI), stage, traverse_instance_prims=False, ) - # only define a fresh root on the input prim when the subtree has none (e.g. primitive or - # programmatic spawns). This keeps exactly one ArticulationRootAPI in the tree. + # no existing root in the subtree: define one on the input prim if articulation_prim is None: articulation_prim = stage.GetPrimAtPath(prim_path) UsdPhysics.ArticulationRootAPI.Apply(articulation_prim) root_path = articulation_prim.GetPath().pathString - # dispatch each fragment via its own applier to the resolved root prim (backend funcs live in - # backend packages, so core never imports a backend) + # dispatch each fragment to the resolved root via its own applier for cfg in fragments: func = cfg.func if callable(cfg.func) else string_to_callable(cfg.func) func(cfg, root_path, stage) From 35e87e6caf0cbeab2f965af0f65789dcaa668c9a Mon Sep 17 00:00:00 2001 From: Vidur Vij Date: Mon, 8 Jun 2026 20:18:07 -0700 Subject: [PATCH 6/6] test(schemas): cover the fix_root_link create-new-joint path The create-new-joint branch of apply_articulation_root_properties was sim-unexercised (only the toggle-existing-joint case was tested). Add two tests: (1) fix_root_link=True with no existing joint creates a fixed joint and reparents the articulation root from the rigid-body root link to its parent; (2) fix_root_link=True on a non-rigid-body root raises NotImplementedError. --- .../test/sim/test_articulation_fragments.py | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/source/isaaclab/test/sim/test_articulation_fragments.py b/source/isaaclab/test/sim/test_articulation_fragments.py index e7105a812c41..8f16c6ab057d 100644 --- a/source/isaaclab/test/sim/test_articulation_fragments.py +++ b/source/isaaclab/test/sim/test_articulation_fragments.py @@ -12,6 +12,8 @@ """Rest everything follows.""" +import pytest + from pxr import UsdGeom, UsdPhysics import isaaclab.sim as sim_utils @@ -184,6 +186,67 @@ def test_apply_articulation_root_properties_toggles_existing_fixed_joint(): assert joint.GetJointEnabledAttr().Get() is False +# ------------------------------------------------------------------------------------- +# fix_root_link spawner-level flag: creates a fixed joint and reparents the root +# ------------------------------------------------------------------------------------- + + +def test_apply_articulation_root_properties_creates_fixed_joint_and_reparents_root(): + """fix_root_link=True with no existing fixed joint: a fixed joint is created and the + articulation root is moved from the rigid-body root link to its parent (PhysX parser + limitation -- a fixed joint on a rigid body is otherwise treated as a maximal-coordinate tree). + """ + from isaaclab_physx.sim.schemas import PhysxArticulationCfg + + from isaaclab.sim.schemas import apply_articulation_root_properties + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + # parent xform + a rigid-body root link carrying the articulation root + _make_xform(stage, "/World/Robot") + root = _make_xform(stage, "/World/Robot/base") + UsdPhysics.RigidBodyAPI.Apply(root) + UsdPhysics.ArticulationRootAPI.Apply(root) + # no existing global fixed joint -> the writer must create one + assert not any(p.IsA(UsdPhysics.FixedJoint) for p in stage.Traverse()) + + apply_articulation_root_properties( + "/World/Robot", + [PhysxArticulationCfg(articulation_enabled=True)], + stage, + fix_root_link=True, + ) + + parent = stage.GetPrimAtPath("/World/Robot") + # a fixed joint was created ... + assert any(p.IsA(UsdPhysics.FixedJoint) for p in stage.Traverse()) + # ... and the articulation root was moved from the root link to its parent + assert parent.HasAPI(UsdPhysics.ArticulationRootAPI) + assert not root.HasAPI(UsdPhysics.ArticulationRootAPI) + + +def test_apply_articulation_root_properties_fix_root_link_requires_rigid_body(): + """fix_root_link=True on a non-rigid-body root raises NotImplementedError: the writer cannot + determine the first rigid body link to anchor the fixed joint to.""" + from isaaclab_physx.sim.schemas import PhysxArticulationCfg + + from isaaclab.sim.schemas import apply_articulation_root_properties + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + root = _make_xform(stage, "/World/Robot2") + UsdPhysics.ArticulationRootAPI.Apply(root) # articulation root but NOT a rigid body + with pytest.raises(NotImplementedError): + apply_articulation_root_properties( + "/World/Robot2", + [PhysxArticulationCfg(articulation_enabled=True)], + stage, + fix_root_link=True, + ) + + # ------------------------------------------------------------------------------------- # public imports # -------------------------------------------------------------------------------------