From 1e04efbbed0f2762f17a0e30dc3e5b52625088c4 Mon Sep 17 00:00:00 2001 From: Vidur Vij Date: Thu, 4 Jun 2026 18:19:27 -0700 Subject: [PATCH 01/23] 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 d1e36946aa24..1808813a392f 100644 --- a/source/isaaclab/isaaclab/sim/__init__.pyi +++ b/source/isaaclab/isaaclab/sim/__init__.pyi @@ -61,11 +61,13 @@ __all__ = [ "NewtonSDFCollisionPropertiesCfg", "PhysxJointDrivePropertiesCfg", "PhysxRigidBodyPropertiesCfg", + "ArticulationRootFragment", "RigidBodyBaseCfg", "RigidBodyFragment", "SchemaFragment", "UsdPhysicsCollisionCfg", "UsdPhysicsRigidBodyCfg", + "apply_articulation_root_properties", "apply_collision_properties", "apply_mass_properties", "apply_namespaced", @@ -217,6 +219,7 @@ from .schemas import ( MESH_APPROXIMATION_TOKENS, PHYSX_MESH_COLLISION_CFGS, USD_MESH_COLLISION_CFGS, + ArticulationRootFragment, ArticulationRootPropertiesCfg, BoundingCubePropertiesCfg, BoundingSpherePropertiesCfg, @@ -244,6 +247,7 @@ from .schemas import ( UsdPhysicsCollisionCfg, UsdPhysicsRigidBodyCfg, activate_contact_sensors, + apply_articulation_root_properties, apply_collision_properties, apply_mass_properties, apply_namespaced, diff --git a/source/isaaclab/isaaclab/sim/schemas/__init__.pyi b/source/isaaclab/isaaclab/sim/schemas/__init__.pyi index baea5a920f9a..f4386effec12 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_collision_properties", "apply_mass_properties", "apply_namespaced", @@ -40,6 +41,7 @@ __all__ = [ "MassFragment", "MassPropertiesCfg", "MeshCollisionBaseCfg", + "ArticulationRootFragment", "RigidBodyFragment", "SchemaFragment", "UsdPhysicsCollisionCfg", @@ -61,6 +63,7 @@ from .schemas import ( PHYSX_MESH_COLLISION_CFGS, USD_MESH_COLLISION_CFGS, activate_contact_sensors, + apply_articulation_root_properties, apply_collision_properties, apply_mass_properties, apply_namespaced, @@ -86,6 +89,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 abbe115541b0..d774028354b4 100644 --- a/source/isaaclab/isaaclab/sim/schemas/schemas.py +++ b/source/isaaclab/isaaclab/sim/schemas/schemas.py @@ -267,6 +267,116 @@ def apply_namespaced(cfg: schemas_cfg.SchemaFragment, prim_path: str, stage: Usd """ +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 29478e843cff..1b848dd5c18e 100644 --- a/source/isaaclab/isaaclab/sim/schemas/schemas_cfg.py +++ b/source/isaaclab/isaaclab/sim/schemas/schemas_cfg.py @@ -183,6 +183,21 @@ class CollisionFragment(SchemaFragment): pass +@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 UsdPhysicsCollisionCfg(CollisionFragment): """``physics:*`` collision attributes from `UsdPhysics.CollisionAPI`_. 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 ff51fa973750..38e6f36ddafa 100644 --- a/source/isaaclab/isaaclab/sim/spawners/from_files/from_files.py +++ b/source/isaaclab/isaaclab/sim/spawners/from_files/from_files.py @@ -369,7 +369,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 f7413adbb883..fd8864dcd97a 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", "NewtonCollisionCfg", "NewtonCollisionPropertiesCfg", @@ -22,6 +23,7 @@ from .schemas_cfg import ( MujocoJointDrivePropertiesCfg, MujocoRigidBodyCfg, MujocoRigidBodyPropertiesCfg, + NewtonArticulationCfg, NewtonArticulationRootPropertiesCfg, NewtonCollisionCfg, NewtonCollisionPropertiesCfg, 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 edf007790b5e..76a51c41d654 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, CollisionFragment, DeformableBodyPropertiesBaseCfg, @@ -381,3 +382,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 767ba36f06d7..264c0e9e5c76 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", "PhysxCollisionCfg", "PhysxCollisionPropertiesCfg", @@ -51,6 +52,7 @@ from .schemas_cfg import ( JointDrivePropertiesCfg, MeshCollisionPropertiesCfg, OmniPhysicsDeformableBodyPropertiesCfg, + PhysxArticulationCfg, PhysxArticulationRootPropertiesCfg, PhysxCollisionCfg, PhysxCollisionPropertiesCfg, 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 c81ea84f7101..1b5c9926425e 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, CollisionFragment, DeformableBodyPropertiesBaseCfg, @@ -536,6 +537,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 064ba8e7aed0254c1370b93d3d974e48a70c84ee Mon Sep 17 00:00:00 2001 From: Vidur Vij Date: Fri, 5 Jun 2026 12:37:05 -0700 Subject: [PATCH 02/23] docs(schemas): mark transition shim if/else for removal post-migration --- source/isaaclab/isaaclab/sim/spawners/from_files/from_files.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 38e6f36ddafa..b7a866d5f30b 100644 --- a/source/isaaclab/isaaclab/sim/spawners/from_files/from_files.py +++ b/source/isaaclab/isaaclab/sim/spawners/from_files/from_files.py @@ -369,7 +369,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] ) From af0c39281b5d4102ee2c05cf4880e639d8038245 Mon Sep 17 00:00:00 2001 From: Vidur Vij Date: Mon, 8 Jun 2026 19:51:31 -0700 Subject: [PATCH 03/23] 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 d774028354b4..9f98c837bc25 100644 --- a/source/isaaclab/isaaclab/sim/schemas/schemas.py +++ b/source/isaaclab/isaaclab/sim/schemas/schemas.py @@ -267,6 +267,32 @@ def apply_namespaced(cfg: schemas_cfg.SchemaFragment, prim_path: str, stage: Usd """ +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, @@ -275,15 +301,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. @@ -296,42 +330,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 e80c7aa609d61dce70e19783e6647e1cdf5ea4b1 Mon Sep 17 00:00:00 2001 From: Vidur Vij Date: Mon, 8 Jun 2026 19:53:45 -0700 Subject: [PATCH 04/23] 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 9f98c837bc25..0f9d7c0deacb 100644 --- a/source/isaaclab/isaaclab/sim/schemas/schemas.py +++ b/source/isaaclab/isaaclab/sim/schemas/schemas.py @@ -24,6 +24,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, ) @@ -267,32 +268,6 @@ def apply_namespaced(cfg: schemas_cfg.SchemaFragment, prim_path: str, stage: Usd """ -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, @@ -335,19 +310,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 3fa37b858765136fdf24051c5ed34573a9fd40e2 Mon Sep 17 00:00:00 2001 From: Vidur Vij Date: Mon, 8 Jun 2026 19:55:13 -0700 Subject: [PATCH 05/23] 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 0f9d7c0deacb..2b1988b61cf5 100644 --- a/source/isaaclab/isaaclab/sim/schemas/schemas.py +++ b/source/isaaclab/isaaclab/sim/schemas/schemas.py @@ -310,25 +310,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 6892d5d63f940ea3227e3d24b0cd63f42dbf934a Mon Sep 17 00:00:00 2001 From: Vidur Vij Date: Mon, 8 Jun 2026 20:18:07 -0700 Subject: [PATCH 06/23] 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 # ------------------------------------------------------------------------------------- From 12852bc521a622e44e8c5933355916f1bb5b5d6b Mon Sep 17 00:00:00 2001 From: Vidur Vij Date: Fri, 26 Jun 2026 12:39:38 -0700 Subject: [PATCH 07/23] Move PhysX fixed-root-joint creation out of core into the PhysX backend The fix_root_link create path in apply_articulation_root_properties used omni.physx.scripts and a PhysxArticulationAPI root-relocation workaround (a PhysX-parser limitation) directly in core. Replace it with an inversion-of-control hook: core exposes register_fixed_root_joint_creator and delegates to the registered creator; the PhysX package registers _create_fixed_root_joint on import. Core's articulation-root writer now carries no PhysX-specific logic; the enable-existing-joint path stays in core (it is generic UsdPhysics). Legacy modify_articulation_root_properties is left untouched. --- .../isaaclab/isaaclab/sim/schemas/schemas.py | 77 ++++++++----------- .../isaaclab_physx/sim/schemas/__init__.py | 71 +++++++++++++++++ 2 files changed, 101 insertions(+), 47 deletions(-) diff --git a/source/isaaclab/isaaclab/sim/schemas/schemas.py b/source/isaaclab/isaaclab/sim/schemas/schemas.py index 5be1e9879ede..e59ab078daa9 100644 --- a/source/isaaclab/isaaclab/sim/schemas/schemas.py +++ b/source/isaaclab/isaaclab/sim/schemas/schemas.py @@ -268,6 +268,27 @@ def apply_namespaced(cfg: schemas_cfg.SchemaFragment, prim_path: str, stage: Usd """ +# Backend-registered creator that fixes an articulation base by authoring a world<->root fixed joint. +# Creating it is backend-specific (the PhysX parser requires relocating the articulation root to the +# parent prim), so the backend registers its implementation via :func:`register_fixed_root_joint_creator` +# rather than core carrying the backend logic. +_FIXED_ROOT_JOINT_CREATOR = None + + +def register_fixed_root_joint_creator(creator) -> None: + """Register the backend handler that fixes an articulation base via a world<->root fixed joint. + + Called by :func:`apply_articulation_root_properties` when ``fix_root_link=True`` and no fixed joint + yet exists. Keeps the backend-specific joint-creation logic (e.g. PhysX's parser workaround) out of + core; a physics backend extension (e.g. ``isaaclab_physx``) registers its creator on import. + + Args: + creator: A callable ``creator(articulation_prim, stage)`` that authors the fixed joint. + """ + global _FIXED_ROOT_JOINT_CREATOR + _FIXED_ROOT_JOINT_CREATOR = creator + + def apply_articulation_root_properties( prim_path: str, fragments, @@ -344,54 +365,16 @@ def apply_articulation_root_properties( existing_fixed_joint_prim.GetJointEnabledAttr().Set(fix_root_link) elif fix_root_link: 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 '{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." + # Creating a world<->root fixed joint to fix the base is backend-specific (the PhysX parser + # needs the articulation root relocated to the parent prim). Delegate to the backend-registered + # creator so core carries no backend logic; see register_fixed_root_joint_creator. + if _FIXED_ROOT_JOINT_CREATOR is None: + raise RuntimeError( + f"Cannot fix the articulation root '{root_path}': no fixed-root-joint creator is" + " registered. Import a physics backend extension (e.g. isaaclab_physx) so it can" + " register one." ) - - # 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) + _FIXED_ROOT_JOINT_CREATOR(articulation_prim, stage) return True diff --git a/source/isaaclab_physx/isaaclab_physx/sim/schemas/__init__.py b/source/isaaclab_physx/isaaclab_physx/sim/schemas/__init__.py index 47f0b1e8ae93..a30dc37af466 100644 --- a/source/isaaclab_physx/isaaclab_physx/sim/schemas/__init__.py +++ b/source/isaaclab_physx/isaaclab_physx/sim/schemas/__init__.py @@ -5,6 +5,77 @@ """Sub-module containing PhysX schema configuration exports.""" +from isaaclab.sim.schemas.schemas import register_fixed_root_joint_creator from isaaclab.utils.module import lazy_export lazy_export() + + +def _create_fixed_root_joint(articulation_prim, stage) -> None: + """Fix an articulation base by authoring a world<->root fixed joint (PhysX parser semantics). + + Backend creator registered with the core articulation-root writer. Creating the joint relies on + the PhysX parser, which does not treat a fixed joint on a rigid body as a fixed-base articulation; + relocating the ``UsdPhysics.ArticulationRootAPI`` (and its ``physxArticulation:*`` attributes) to + the parent prim works around that. This PhysX-specific logic lives in the PhysX package so core + carries none of it. + + Args: + articulation_prim: The resolved articulation-root prim to fix to the world frame. + stage: The stage the prim lives on. + + Raises: + NotImplementedError: When the root prim is not a rigid body (the first rigid-body link cannot + be determined to attach the fixed joint). + """ + from pxr import UsdPhysics + + from omni.physx.scripts import utils as physx_utils + + # note: we assume the root prim is a rigid body; there is no obvious way to get the first rigid + # body link identified by the PhysX parser when it is not. + if not articulation_prim.HasAPI(UsdPhysics.RigidBodyAPI): + raise NotImplementedError( + f"The articulation prim '{articulation_prim.GetPath().pathString}' 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 + 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"; it is treated as + # part of the maximal coordinate tree. Moving the articulation root to the parent solves this (a + # limitation of the PhysX parser). + parent_prim = articulation_prim.GetParent() + UsdPhysics.ArticulationRootAPI.Apply(parent_prim) + if "PhysxArticulationAPI" not in parent_prim.GetAppliedSchemas(): + parent_prim.AddAppliedSchema("PhysxArticulationAPI") + + # copy the attributes to the parent + # -- 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 the api from the (former) root + articulation_prim.RemoveAppliedSchema("PhysxArticulationAPI") + articulation_prim.RemoveAPI(UsdPhysics.ArticulationRootAPI) + + +# Keep PhysX articulation-root logic out of core: register the creator with the core articulation-root +# writer. Registered on import of this package (which a caller does to construct PhysX schema +# fragments), inverting the dependency so core carries no PhysX-specific articulation logic. +register_fixed_root_joint_creator(_create_fixed_root_joint) From f1892ef3e726f6a4ce831726a39e80e835d4edee Mon Sep 17 00:00:00 2001 From: Vidur Vij Date: Fri, 26 Jun 2026 12:56:38 -0700 Subject: [PATCH 08/23] Add core-level tests for the fixed-root-joint creator registry Cover the registry independent of PhysX: the writer delegates to a registered creator, and raises a clear error when none is registered. Uses monkeypatch to isolate the module-global creator. --- .../test/sim/test_articulation_fragments.py | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/source/isaaclab/test/sim/test_articulation_fragments.py b/source/isaaclab/test/sim/test_articulation_fragments.py index 8f16c6ab057d..15949b7c8aa5 100644 --- a/source/isaaclab/test/sim/test_articulation_fragments.py +++ b/source/isaaclab/test/sim/test_articulation_fragments.py @@ -247,6 +247,52 @@ def test_apply_articulation_root_properties_fix_root_link_requires_rigid_body(): ) +def test_apply_articulation_root_properties_fix_root_link_without_backend_raises(monkeypatch): + """Creating a fixed root joint with no backend creator registered raises a clear error: the + PhysX-specific creation logic lives in the backend, so core cannot fix the base on its own. + + Uses monkeypatch to clear the module-global creator (other tests register the PhysX creator + session-wide); monkeypatch restores it afterwards so this stays isolated. + """ + from isaaclab.sim.schemas import apply_articulation_root_properties + from isaaclab.sim.schemas import schemas as core_schemas + + monkeypatch.setattr(core_schemas, "_FIXED_ROOT_JOINT_CREATOR", None) + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + root = _make_xform(stage, "/World/Robot3") + UsdPhysics.RigidBodyAPI.Apply(root) + UsdPhysics.ArticulationRootAPI.Apply(root) + with pytest.raises(RuntimeError): + apply_articulation_root_properties("/World/Robot3", [], stage, fix_root_link=True) + + +def test_register_fixed_root_joint_creator_is_invoked(monkeypatch): + """The core writer delegates fixed-root-joint creation to whatever creator is registered.""" + from isaaclab.sim.schemas import apply_articulation_root_properties + from isaaclab.sim.schemas import schemas as core_schemas + from isaaclab.sim.schemas.schemas import register_fixed_root_joint_creator + + monkeypatch.setattr(core_schemas, "_FIXED_ROOT_JOINT_CREATOR", None) + called = {} + + def _creator(articulation_prim, stage): + called["path"] = articulation_prim.GetPath().pathString + + register_fixed_root_joint_creator(_creator) + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + root = _make_xform(stage, "/World/Robot4") + UsdPhysics.RigidBodyAPI.Apply(root) + UsdPhysics.ArticulationRootAPI.Apply(root) + apply_articulation_root_properties("/World/Robot4", [], stage, fix_root_link=True) + assert called["path"] == "/World/Robot4" + + # ------------------------------------------------------------------------------------- # public imports # ------------------------------------------------------------------------------------- From 45165ab9cedd6975cf60bef6a6e5ffdf8fc2bbc3 Mon Sep 17 00:00:00 2001 From: Vidur Vij Date: Fri, 26 Jun 2026 13:12:16 -0700 Subject: [PATCH 09/23] Document RuntimeError in apply_articulation_root_properties Address greptile: the Raises section omitted the RuntimeError raised when fix_root_link=True and no fixed-root-joint creator is registered. Also clarify that NotImplementedError is propagated from the registered backend creator rather than raised by core directly. --- source/isaaclab/isaaclab/sim/schemas/schemas.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/source/isaaclab/isaaclab/sim/schemas/schemas.py b/source/isaaclab/isaaclab/sim/schemas/schemas.py index e59ab078daa9..712232769f7c 100644 --- a/source/isaaclab/isaaclab/sim/schemas/schemas.py +++ b/source/isaaclab/isaaclab/sim/schemas/schemas.py @@ -327,7 +327,11 @@ def apply_articulation_root_properties( 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. + RuntimeError: When ``fix_root_link`` is True and a fixed joint must be created, but no backend + creator has been registered (see :func:`register_fixed_root_joint_creator`) -- e.g. no + physics backend extension such as ``isaaclab_physx`` was imported. + NotImplementedError: Propagated from the registered creator when it cannot create the fixed + joint (e.g. the resolved root prim is not a rigid body). """ if stage is None: stage = get_current_stage() From 6a0775e39aadf8d22d287dbbe3e662aa19755446 Mon Sep 17 00:00:00 2001 From: Vidur Vij Date: Fri, 26 Jun 2026 14:09:50 -0700 Subject: [PATCH 10/23] Make fixed-root-joint creation backend-aware so Newton runs on its own Previously only isaaclab_physx registered a fixed-root-joint creator, so a Newton-only import hit a RuntimeError on fix_root_link=True even though Newton needs the joint (it defaults a jointless root to floating). Make the registry backend-aware: each backend registers a creator plus an is_active predicate, and the writer selects the creator matching the running backend. - isaaclab_physx: createJoint + the PhysX-parser root relocation (active when the sim uses PhysxCfg). - isaaclab_newton: authors the world<->root UsdPhysics.FixedJoint via the Kit omni.physx helper (no relocation; Newton reads the joint directly), active when the sim uses NewtonCfg. Nothing is hand-rolled (omni.physx.createJoint is a Kit utility present in any Isaac Sim app). Core stays backend-free; both backends work standalone, and a both-imported run picks the active backend's creator. --- .../isaaclab/isaaclab/sim/schemas/schemas.py | 52 ++++++++++++------- .../test/sim/test_articulation_fragments.py | 22 ++++---- .../isaaclab_newton/sim/schemas/__init__.py | 47 +++++++++++++++++ .../isaaclab_physx/sim/schemas/__init__.py | 18 +++++-- 4 files changed, 106 insertions(+), 33 deletions(-) diff --git a/source/isaaclab/isaaclab/sim/schemas/schemas.py b/source/isaaclab/isaaclab/sim/schemas/schemas.py index 712232769f7c..92a1e0de65cc 100644 --- a/source/isaaclab/isaaclab/sim/schemas/schemas.py +++ b/source/isaaclab/isaaclab/sim/schemas/schemas.py @@ -268,25 +268,40 @@ def apply_namespaced(cfg: schemas_cfg.SchemaFragment, prim_path: str, stage: Usd """ -# Backend-registered creator that fixes an articulation base by authoring a world<->root fixed joint. -# Creating it is backend-specific (the PhysX parser requires relocating the articulation root to the -# parent prim), so the backend registers its implementation via :func:`register_fixed_root_joint_creator` -# rather than core carrying the backend logic. -_FIXED_ROOT_JOINT_CREATOR = None +# Backend-registered creators that fix an articulation base by authoring a world<->root fixed joint. +# Creating it is backend-specific (PhysX requires relocating the articulation root to the parent prim +# to work around its parser; Newton reads the fixed joint directly), so each backend registers its own +# creator plus a predicate that reports when that backend is the active simulation. This keeps core +# free of backend knowledge while letting each backend run on its own. +_FIXED_ROOT_JOINT_CREATORS: list = [] -def register_fixed_root_joint_creator(creator) -> None: - """Register the backend handler that fixes an articulation base via a world<->root fixed joint. +def register_fixed_root_joint_creator(creator, is_active) -> None: + """Register a backend creator that fixes an articulation base via a world<->root fixed joint. Called by :func:`apply_articulation_root_properties` when ``fix_root_link=True`` and no fixed joint - yet exists. Keeps the backend-specific joint-creation logic (e.g. PhysX's parser workaround) out of - core; a physics backend extension (e.g. ``isaaclab_physx``) registers its creator on import. + yet exists. Multiple backends may register; the creator whose ``is_active()`` predicate returns True + for the running simulation is selected, so each physics backend (e.g. ``isaaclab_physx``, + ``isaaclab_newton``) provides and is matched to its own implementation without core knowing any of + them. Args: creator: A callable ``creator(articulation_prim, stage)`` that authors the fixed joint. + is_active: A callable ``is_active() -> bool`` returning True when ``creator``'s backend is the + active simulation backend. """ - global _FIXED_ROOT_JOINT_CREATOR - _FIXED_ROOT_JOINT_CREATOR = creator + _FIXED_ROOT_JOINT_CREATORS.append((is_active, creator)) + + +def _resolve_fixed_root_joint_creator(): + """Return the creator whose backend is active, or None if no registered backend matches.""" + for is_active, creator in _FIXED_ROOT_JOINT_CREATORS: + try: + if is_active(): + return creator + except Exception: # noqa: BLE001 -- a backend's probe must never break creator resolution + continue + return None def apply_articulation_root_properties( @@ -369,16 +384,17 @@ def apply_articulation_root_properties( existing_fixed_joint_prim.GetJointEnabledAttr().Set(fix_root_link) elif fix_root_link: logger.info(f"Creating a fixed joint for the articulation: '{root_path}'.") - # Creating a world<->root fixed joint to fix the base is backend-specific (the PhysX parser - # needs the articulation root relocated to the parent prim). Delegate to the backend-registered - # creator so core carries no backend logic; see register_fixed_root_joint_creator. - if _FIXED_ROOT_JOINT_CREATOR is None: + # Creating a world<->root fixed joint to fix the base is backend-specific. Delegate to the + # creator for the active backend so core carries no backend logic; see + # register_fixed_root_joint_creator. + creator = _resolve_fixed_root_joint_creator() + if creator is None: raise RuntimeError( f"Cannot fix the articulation root '{root_path}': no fixed-root-joint creator is" - " registered. Import a physics backend extension (e.g. isaaclab_physx) so it can" - " register one." + " registered for the active physics backend. Import the matching backend extension" + " (e.g. isaaclab_physx or isaaclab_newton) so it can register one." ) - _FIXED_ROOT_JOINT_CREATOR(articulation_prim, stage) + creator(articulation_prim, stage) return True diff --git a/source/isaaclab/test/sim/test_articulation_fragments.py b/source/isaaclab/test/sim/test_articulation_fragments.py index 15949b7c8aa5..cd145277390e 100644 --- a/source/isaaclab/test/sim/test_articulation_fragments.py +++ b/source/isaaclab/test/sim/test_articulation_fragments.py @@ -248,16 +248,16 @@ def test_apply_articulation_root_properties_fix_root_link_requires_rigid_body(): def test_apply_articulation_root_properties_fix_root_link_without_backend_raises(monkeypatch): - """Creating a fixed root joint with no backend creator registered raises a clear error: the - PhysX-specific creation logic lives in the backend, so core cannot fix the base on its own. + """Creating a fixed root joint with no registered backend creator raises a clear error: the + backend-specific creation logic lives in the backends, so core cannot fix the base on its own. - Uses monkeypatch to clear the module-global creator (other tests register the PhysX creator + Uses monkeypatch to clear the module-global creator registry (other tests/backends register session-wide); monkeypatch restores it afterwards so this stays isolated. """ from isaaclab.sim.schemas import apply_articulation_root_properties from isaaclab.sim.schemas import schemas as core_schemas - monkeypatch.setattr(core_schemas, "_FIXED_ROOT_JOINT_CREATOR", None) + monkeypatch.setattr(core_schemas, "_FIXED_ROOT_JOINT_CREATORS", []) sim_utils.create_new_stage() SimulationContext(SimulationCfg(dt=0.01)) @@ -269,19 +269,19 @@ def test_apply_articulation_root_properties_fix_root_link_without_backend_raises apply_articulation_root_properties("/World/Robot3", [], stage, fix_root_link=True) -def test_register_fixed_root_joint_creator_is_invoked(monkeypatch): - """The core writer delegates fixed-root-joint creation to whatever creator is registered.""" +def test_register_fixed_root_joint_creator_selects_active_backend(monkeypatch): + """The writer selects the registered creator whose backend predicate reports active, and ignores + creators whose backend is inactive.""" from isaaclab.sim.schemas import apply_articulation_root_properties from isaaclab.sim.schemas import schemas as core_schemas from isaaclab.sim.schemas.schemas import register_fixed_root_joint_creator - monkeypatch.setattr(core_schemas, "_FIXED_ROOT_JOINT_CREATOR", None) + monkeypatch.setattr(core_schemas, "_FIXED_ROOT_JOINT_CREATORS", []) called = {} - def _creator(articulation_prim, stage): - called["path"] = articulation_prim.GetPath().pathString - - register_fixed_root_joint_creator(_creator) + # an inactive backend's creator must be skipped, the active one's used + register_fixed_root_joint_creator(lambda p, s: called.__setitem__("path", "INACTIVE"), lambda: False) + register_fixed_root_joint_creator(lambda p, s: called.__setitem__("path", p.GetPath().pathString), lambda: True) sim_utils.create_new_stage() SimulationContext(SimulationCfg(dt=0.01)) diff --git a/source/isaaclab_newton/isaaclab_newton/sim/schemas/__init__.py b/source/isaaclab_newton/isaaclab_newton/sim/schemas/__init__.py index e79e72e10dff..f3b406919c28 100644 --- a/source/isaaclab_newton/isaaclab_newton/sim/schemas/__init__.py +++ b/source/isaaclab_newton/isaaclab_newton/sim/schemas/__init__.py @@ -5,6 +5,53 @@ """Sub-module containing Newton schema configuration exports.""" +from isaaclab.sim.schemas.schemas import register_fixed_root_joint_creator from isaaclab.utils.module import lazy_export lazy_export() + + +def _create_fixed_root_joint_newton(articulation_prim, stage) -> None: + """Fix an articulation base by authoring a world<->root ``UsdPhysics.FixedJoint``. + + Backend creator registered with the core articulation-root writer for the Newton backend. Newton's + importer reads a ``UsdPhysics.FixedJoint`` directly as a fixed root joint (a jointless root would + otherwise default to floating), so -- unlike PhysX -- no articulation-root relocation is needed. The + joint is authored with the Kit ``omni.physx`` utility (a USD-authoring helper present in any Isaac + Sim app regardless of the active solver), so nothing is hand-rolled. + + Args: + articulation_prim: The resolved articulation-root prim to fix to the world frame. + stage: The stage the prim lives on. + + Raises: + NotImplementedError: When the root prim is not a rigid body (the first rigid-body link cannot be + determined to anchor the fixed joint). + """ + from pxr import UsdPhysics + + from omni.physx.scripts import utils as physx_utils + + if not articulation_prim.HasAPI(UsdPhysics.RigidBodyAPI): + raise NotImplementedError( + f"The articulation prim '{articulation_prim.GetPath().pathString}' 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." + ) + physx_utils.createJoint(stage=stage, joint_type="Fixed", from_prim=None, to_prim=articulation_prim) + + +def _newton_is_active() -> bool: + """Return whether the running simulation uses the Newton backend.""" + from isaaclab.sim import SimulationContext + + from isaaclab_newton.physics import NewtonCfg + + sim = SimulationContext.instance() + return sim is not None and isinstance(sim.cfg.physics, NewtonCfg) + + +# Register the Newton fixed-root-joint creator (matched to the active Newton backend) so fixing an +# articulation base works in a Newton-only run, without core carrying any backend logic. Registered on +# import of this package (which a caller does to construct Newton schema fragments). +register_fixed_root_joint_creator(_create_fixed_root_joint_newton, _newton_is_active) diff --git a/source/isaaclab_physx/isaaclab_physx/sim/schemas/__init__.py b/source/isaaclab_physx/isaaclab_physx/sim/schemas/__init__.py index a30dc37af466..0cea9e04b7c9 100644 --- a/source/isaaclab_physx/isaaclab_physx/sim/schemas/__init__.py +++ b/source/isaaclab_physx/isaaclab_physx/sim/schemas/__init__.py @@ -75,7 +75,17 @@ def _create_fixed_root_joint(articulation_prim, stage) -> None: articulation_prim.RemoveAPI(UsdPhysics.ArticulationRootAPI) -# Keep PhysX articulation-root logic out of core: register the creator with the core articulation-root -# writer. Registered on import of this package (which a caller does to construct PhysX schema -# fragments), inverting the dependency so core carries no PhysX-specific articulation logic. -register_fixed_root_joint_creator(_create_fixed_root_joint) +def _physx_is_active() -> bool: + """Return whether the running simulation uses the PhysX backend.""" + from isaaclab.sim import SimulationContext + + from isaaclab_physx.physics import PhysxCfg + + sim = SimulationContext.instance() + return sim is not None and isinstance(sim.cfg.physics, PhysxCfg) + + +# Keep PhysX articulation-root logic out of core: register the creator (matched to the active PhysX +# backend) with the core articulation-root writer. Registered on import of this package (which a caller +# does to construct PhysX schema fragments), inverting the dependency so core carries no PhysX logic. +register_fixed_root_joint_creator(_create_fixed_root_joint, _physx_is_active) From 44d5e35ffb3e48b2f13ff58968ec9c4b7e281686 Mon Sep 17 00:00:00 2001 From: Vidur Vij Date: Sat, 27 Jun 2026 08:28:43 -0700 Subject: [PATCH 11/23] Keep backend fixed-root-joint hook import free of USD libraries The PhysX and Newton schema packages registered their fixed-root-joint creators by importing the register function from the core schemas module at package import time. That core module imports pxr at module top, so importing a backend package eagerly pulled USD libraries into otherwise USD-free import paths (e.g. resolving a robot config), failing the omni/pxr-free import guard. Move the registry and registration API into a dedicated USD-free hooks module so a backend can register its hook at import time without dragging in pxr. --- .../isaaclab/sim/schemas/_backend_hooks.py | 52 +++++++++++++++++++ .../isaaclab/isaaclab/sim/schemas/schemas.py | 37 +------------ .../test/sim/test_articulation_fragments.py | 12 ++--- .../isaaclab_newton/sim/schemas/__init__.py | 2 +- .../isaaclab_physx/sim/schemas/__init__.py | 2 +- 5 files changed, 60 insertions(+), 45 deletions(-) create mode 100644 source/isaaclab/isaaclab/sim/schemas/_backend_hooks.py diff --git a/source/isaaclab/isaaclab/sim/schemas/_backend_hooks.py b/source/isaaclab/isaaclab/sim/schemas/_backend_hooks.py new file mode 100644 index 000000000000..17299bd81f71 --- /dev/null +++ b/source/isaaclab/isaaclab/sim/schemas/_backend_hooks.py @@ -0,0 +1,52 @@ +# 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 + +"""Backend registration hooks for schema writers. + +This module holds the inversion-of-control registries that let physics backends (e.g. +``isaaclab_physx``, ``isaaclab_newton``) inject backend-specific behaviour into the core schema +writers without core importing any backend. It is deliberately kept free of ``pxr``/``omni`` imports +so a backend can register its hook at package-import time without eagerly pulling USD libraries into +an otherwise USD-free import path. +""" + +from __future__ import annotations + +from collections.abc import Callable + +# Backend-registered creators that fix an articulation base by authoring a world<->root fixed joint. +# Creating it is backend-specific (PhysX requires relocating the articulation root to the parent prim +# to work around its parser; Newton reads the fixed joint directly), so each backend registers its own +# creator plus a predicate that reports when that backend is the active simulation. This keeps core +# free of backend knowledge while letting each backend run on its own. +_FIXED_ROOT_JOINT_CREATORS: list[tuple[Callable, Callable]] = [] + + +def register_fixed_root_joint_creator(creator: Callable, is_active: Callable) -> None: + """Register a backend creator that fixes an articulation base via a world<->root fixed joint. + + Called by :func:`~isaaclab.sim.schemas.apply_articulation_root_properties` when ``fix_root_link`` + is True and no fixed joint yet exists. Multiple backends may register; the creator whose + ``is_active()`` predicate returns True for the running simulation is selected, so each physics + backend (e.g. ``isaaclab_physx``, ``isaaclab_newton``) provides and is matched to its own + implementation without core knowing any of them. + + Args: + creator: A callable ``creator(articulation_prim, stage)`` that authors the fixed joint. + is_active: A callable ``is_active() -> bool`` returning True when ``creator``'s backend is the + active simulation backend. + """ + _FIXED_ROOT_JOINT_CREATORS.append((is_active, creator)) + + +def _resolve_fixed_root_joint_creator(): + """Return the creator whose backend is active, or None if no registered backend matches.""" + for is_active, creator in _FIXED_ROOT_JOINT_CREATORS: + try: + if is_active(): + return creator + except Exception: # noqa: BLE001 -- a backend's probe must never break creator resolution + continue + return None diff --git a/source/isaaclab/isaaclab/sim/schemas/schemas.py b/source/isaaclab/isaaclab/sim/schemas/schemas.py index 92a1e0de65cc..49a794cf5029 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_schema, ) from . import schemas_cfg +from ._backend_hooks import _resolve_fixed_root_joint_creator # import logger logger = logging.getLogger(__name__) @@ -268,42 +269,6 @@ def apply_namespaced(cfg: schemas_cfg.SchemaFragment, prim_path: str, stage: Usd """ -# Backend-registered creators that fix an articulation base by authoring a world<->root fixed joint. -# Creating it is backend-specific (PhysX requires relocating the articulation root to the parent prim -# to work around its parser; Newton reads the fixed joint directly), so each backend registers its own -# creator plus a predicate that reports when that backend is the active simulation. This keeps core -# free of backend knowledge while letting each backend run on its own. -_FIXED_ROOT_JOINT_CREATORS: list = [] - - -def register_fixed_root_joint_creator(creator, is_active) -> None: - """Register a backend creator that fixes an articulation base via a world<->root fixed joint. - - Called by :func:`apply_articulation_root_properties` when ``fix_root_link=True`` and no fixed joint - yet exists. Multiple backends may register; the creator whose ``is_active()`` predicate returns True - for the running simulation is selected, so each physics backend (e.g. ``isaaclab_physx``, - ``isaaclab_newton``) provides and is matched to its own implementation without core knowing any of - them. - - Args: - creator: A callable ``creator(articulation_prim, stage)`` that authors the fixed joint. - is_active: A callable ``is_active() -> bool`` returning True when ``creator``'s backend is the - active simulation backend. - """ - _FIXED_ROOT_JOINT_CREATORS.append((is_active, creator)) - - -def _resolve_fixed_root_joint_creator(): - """Return the creator whose backend is active, or None if no registered backend matches.""" - for is_active, creator in _FIXED_ROOT_JOINT_CREATORS: - try: - if is_active(): - return creator - except Exception: # noqa: BLE001 -- a backend's probe must never break creator resolution - continue - return None - - def apply_articulation_root_properties( prim_path: str, fragments, diff --git a/source/isaaclab/test/sim/test_articulation_fragments.py b/source/isaaclab/test/sim/test_articulation_fragments.py index cd145277390e..8275e69b8720 100644 --- a/source/isaaclab/test/sim/test_articulation_fragments.py +++ b/source/isaaclab/test/sim/test_articulation_fragments.py @@ -254,10 +254,9 @@ def test_apply_articulation_root_properties_fix_root_link_without_backend_raises Uses monkeypatch to clear the module-global creator registry (other tests/backends register session-wide); monkeypatch restores it afterwards so this stays isolated. """ - from isaaclab.sim.schemas import apply_articulation_root_properties - from isaaclab.sim.schemas import schemas as core_schemas + from isaaclab.sim.schemas import _backend_hooks, apply_articulation_root_properties - monkeypatch.setattr(core_schemas, "_FIXED_ROOT_JOINT_CREATORS", []) + monkeypatch.setattr(_backend_hooks, "_FIXED_ROOT_JOINT_CREATORS", []) sim_utils.create_new_stage() SimulationContext(SimulationCfg(dt=0.01)) @@ -272,11 +271,10 @@ def test_apply_articulation_root_properties_fix_root_link_without_backend_raises def test_register_fixed_root_joint_creator_selects_active_backend(monkeypatch): """The writer selects the registered creator whose backend predicate reports active, and ignores creators whose backend is inactive.""" - from isaaclab.sim.schemas import apply_articulation_root_properties - from isaaclab.sim.schemas import schemas as core_schemas - from isaaclab.sim.schemas.schemas import register_fixed_root_joint_creator + from isaaclab.sim.schemas import _backend_hooks, apply_articulation_root_properties + from isaaclab.sim.schemas._backend_hooks import register_fixed_root_joint_creator - monkeypatch.setattr(core_schemas, "_FIXED_ROOT_JOINT_CREATORS", []) + monkeypatch.setattr(_backend_hooks, "_FIXED_ROOT_JOINT_CREATORS", []) called = {} # an inactive backend's creator must be skipped, the active one's used diff --git a/source/isaaclab_newton/isaaclab_newton/sim/schemas/__init__.py b/source/isaaclab_newton/isaaclab_newton/sim/schemas/__init__.py index f3b406919c28..244b00e33360 100644 --- a/source/isaaclab_newton/isaaclab_newton/sim/schemas/__init__.py +++ b/source/isaaclab_newton/isaaclab_newton/sim/schemas/__init__.py @@ -5,7 +5,7 @@ """Sub-module containing Newton schema configuration exports.""" -from isaaclab.sim.schemas.schemas import register_fixed_root_joint_creator +from isaaclab.sim.schemas._backend_hooks import register_fixed_root_joint_creator from isaaclab.utils.module import lazy_export lazy_export() diff --git a/source/isaaclab_physx/isaaclab_physx/sim/schemas/__init__.py b/source/isaaclab_physx/isaaclab_physx/sim/schemas/__init__.py index 0cea9e04b7c9..2e41f5f71123 100644 --- a/source/isaaclab_physx/isaaclab_physx/sim/schemas/__init__.py +++ b/source/isaaclab_physx/isaaclab_physx/sim/schemas/__init__.py @@ -5,7 +5,7 @@ """Sub-module containing PhysX schema configuration exports.""" -from isaaclab.sim.schemas.schemas import register_fixed_root_joint_creator +from isaaclab.sim.schemas._backend_hooks import register_fixed_root_joint_creator from isaaclab.utils.module import lazy_export lazy_export() From 5137c592b5f150afacec93b9fbf9d11e080e16a3 Mon Sep 17 00:00:00 2001 From: Vidur Vij Date: Sat, 27 Jun 2026 14:34:52 -0700 Subject: [PATCH 12/23] Select fixed-root-joint creator by active physics-cfg type The fixed-root-joint registry stored (is_active, creator) pairs and resolved by calling every backend's is_active() probe. That had two problems: a backend being imported (hence registered) is not the same as it being active, and each probe imported its backend's cfg, so resolving during a Newton run could execute PhysX import code whenever both extensions were present. Key the registry by the backend's physics-cfg class instead and resolve with a direct lookup on the active simulation's cfg.physics type. "Active" now comes solely from the live cfg, and resolution never imports or executes a non-active backend. --- .../isaaclab/sim/schemas/_backend_hooks.py | 54 ++++++++++--------- .../isaaclab/isaaclab/sim/schemas/schemas.py | 11 ++-- .../test/sim/test_articulation_fragments.py | 26 +++++---- .../isaaclab_newton/sim/schemas/__init__.py | 22 +++----- .../isaaclab_physx/sim/schemas/__init__.py | 21 +++----- 5 files changed, 67 insertions(+), 67 deletions(-) diff --git a/source/isaaclab/isaaclab/sim/schemas/_backend_hooks.py b/source/isaaclab/isaaclab/sim/schemas/_backend_hooks.py index 17299bd81f71..55730d94b245 100644 --- a/source/isaaclab/isaaclab/sim/schemas/_backend_hooks.py +++ b/source/isaaclab/isaaclab/sim/schemas/_backend_hooks.py @@ -16,37 +16,39 @@ from collections.abc import Callable -# Backend-registered creators that fix an articulation base by authoring a world<->root fixed joint. -# Creating it is backend-specific (PhysX requires relocating the articulation root to the parent prim -# to work around its parser; Newton reads the fixed joint directly), so each backend registers its own -# creator plus a predicate that reports when that backend is the active simulation. This keeps core -# free of backend knowledge while letting each backend run on its own. -_FIXED_ROOT_JOINT_CREATORS: list[tuple[Callable, Callable]] = [] +# Backend-registered creators that fix an articulation base by authoring a world<->root fixed joint, +# keyed by the backend's physics-cfg class (the type of ``SimulationContext.cfg.physics``). Creating it +# is backend-specific (PhysX relocates the articulation root to the parent prim to work around its +# parser; Newton reads the fixed joint directly), so each backend registers its own creator under its +# cfg type. The writer looks up the creator for the active simulation's ``cfg.physics`` type -- a plain +# dict lookup that never imports or executes a non-active backend, and "active" comes solely from the +# live ``cfg.physics`` rather than from which backends happen to be imported. +_FIXED_ROOT_JOINT_CREATORS: dict[type, Callable] = {} -def register_fixed_root_joint_creator(creator: Callable, is_active: Callable) -> None: - """Register a backend creator that fixes an articulation base via a world<->root fixed joint. +def register_fixed_root_joint_creator(physics_cfg: type, creator: Callable) -> None: + """Register the creator that fixes an articulation base for a given physics-backend cfg type. Called by :func:`~isaaclab.sim.schemas.apply_articulation_root_properties` when ``fix_root_link`` - is True and no fixed joint yet exists. Multiple backends may register; the creator whose - ``is_active()`` predicate returns True for the running simulation is selected, so each physics - backend (e.g. ``isaaclab_physx``, ``isaaclab_newton``) provides and is matched to its own - implementation without core knowing any of them. + is True and no fixed joint yet exists. Each physics backend (e.g. ``isaaclab_physx``, + ``isaaclab_newton``) registers its own creator keyed by its physics-cfg class; the writer looks up + the creator for the active simulation's ``cfg.physics`` type, so core selects the right backend + without importing or probing any of them. Args: + physics_cfg: The backend's physics-cfg class (the type of ``SimulationContext.cfg.physics`` for + that backend), used as the lookup key. creator: A callable ``creator(articulation_prim, stage)`` that authors the fixed joint. - is_active: A callable ``is_active() -> bool`` returning True when ``creator``'s backend is the - active simulation backend. """ - _FIXED_ROOT_JOINT_CREATORS.append((is_active, creator)) - - -def _resolve_fixed_root_joint_creator(): - """Return the creator whose backend is active, or None if no registered backend matches.""" - for is_active, creator in _FIXED_ROOT_JOINT_CREATORS: - try: - if is_active(): - return creator - except Exception: # noqa: BLE001 -- a backend's probe must never break creator resolution - continue - return None + _FIXED_ROOT_JOINT_CREATORS[physics_cfg] = creator + + +def _resolve_fixed_root_joint_creator(physics_cfg: type | None) -> Callable | None: + """Return the creator registered for the active backend's physics-cfg type, or None. + + Args: + physics_cfg: The type of the active simulation's ``cfg.physics`` (or None if no simulation). + """ + if physics_cfg is None: + return None + return _FIXED_ROOT_JOINT_CREATORS.get(physics_cfg) diff --git a/source/isaaclab/isaaclab/sim/schemas/schemas.py b/source/isaaclab/isaaclab/sim/schemas/schemas.py index c9419d915751..e39e38095601 100644 --- a/source/isaaclab/isaaclab/sim/schemas/schemas.py +++ b/source/isaaclab/isaaclab/sim/schemas/schemas.py @@ -356,10 +356,13 @@ def apply_articulation_root_properties( existing_fixed_joint_prim.GetJointEnabledAttr().Set(fix_root_link) elif fix_root_link: logger.info(f"Creating a fixed joint for the articulation: '{root_path}'.") - # Creating a world<->root fixed joint to fix the base is backend-specific. Delegate to the - # creator for the active backend so core carries no backend logic; see - # register_fixed_root_joint_creator. - creator = _resolve_fixed_root_joint_creator() + # Creating a world<->root fixed joint to fix the base is backend-specific. Look up the + # creator registered for the active simulation's physics-cfg type so core carries no backend + # logic and never touches a non-active backend; see register_fixed_root_joint_creator. + from isaaclab.sim import SimulationContext + + sim = SimulationContext.instance() + creator = _resolve_fixed_root_joint_creator(type(sim.cfg.physics) if sim is not None else None) if creator is None: raise RuntimeError( f"Cannot fix the articulation root '{root_path}': no fixed-root-joint creator is" diff --git a/source/isaaclab/test/sim/test_articulation_fragments.py b/source/isaaclab/test/sim/test_articulation_fragments.py index 8275e69b8720..d4d131165a36 100644 --- a/source/isaaclab/test/sim/test_articulation_fragments.py +++ b/source/isaaclab/test/sim/test_articulation_fragments.py @@ -256,7 +256,7 @@ def test_apply_articulation_root_properties_fix_root_link_without_backend_raises """ from isaaclab.sim.schemas import _backend_hooks, apply_articulation_root_properties - monkeypatch.setattr(_backend_hooks, "_FIXED_ROOT_JOINT_CREATORS", []) + monkeypatch.setattr(_backend_hooks, "_FIXED_ROOT_JOINT_CREATORS", {}) sim_utils.create_new_stage() SimulationContext(SimulationCfg(dt=0.01)) @@ -269,20 +269,28 @@ def test_apply_articulation_root_properties_fix_root_link_without_backend_raises def test_register_fixed_root_joint_creator_selects_active_backend(monkeypatch): - """The writer selects the registered creator whose backend predicate reports active, and ignores - creators whose backend is inactive.""" + """The writer selects the creator registered for the active simulation's ``cfg.physics`` type and + ignores creators registered for other backend cfg types -- no probing of inactive backends.""" from isaaclab.sim.schemas import _backend_hooks, apply_articulation_root_properties - from isaaclab.sim.schemas._backend_hooks import register_fixed_root_joint_creator - monkeypatch.setattr(_backend_hooks, "_FIXED_ROOT_JOINT_CREATORS", []) + monkeypatch.setattr(_backend_hooks, "_FIXED_ROOT_JOINT_CREATORS", {}) called = {} - # an inactive backend's creator must be skipped, the active one's used - register_fixed_root_joint_creator(lambda p, s: called.__setitem__("path", "INACTIVE"), lambda: False) - register_fixed_root_joint_creator(lambda p, s: called.__setitem__("path", p.GetPath().pathString), lambda: True) - sim_utils.create_new_stage() SimulationContext(SimulationCfg(dt=0.01)) + active_cfg_type = type(SimulationContext.instance().cfg.physics) + + class _OtherBackendCfg: + """Stand-in for an inactive backend's physics-cfg type.""" + + # a creator keyed by an inactive backend's cfg type must be ignored; the active one's is used + _backend_hooks.register_fixed_root_joint_creator( + _OtherBackendCfg, lambda p, s: called.__setitem__("path", "INACTIVE") + ) + _backend_hooks.register_fixed_root_joint_creator( + active_cfg_type, lambda p, s: called.__setitem__("path", p.GetPath().pathString) + ) + stage = sim_utils.get_current_stage() root = _make_xform(stage, "/World/Robot4") UsdPhysics.RigidBodyAPI.Apply(root) diff --git a/source/isaaclab_newton/isaaclab_newton/sim/schemas/__init__.py b/source/isaaclab_newton/isaaclab_newton/sim/schemas/__init__.py index 244b00e33360..195ced9ac380 100644 --- a/source/isaaclab_newton/isaaclab_newton/sim/schemas/__init__.py +++ b/source/isaaclab_newton/isaaclab_newton/sim/schemas/__init__.py @@ -8,6 +8,8 @@ from isaaclab.sim.schemas._backend_hooks import register_fixed_root_joint_creator from isaaclab.utils.module import lazy_export +from isaaclab_newton.physics import NewtonCfg + lazy_export() @@ -41,17 +43,9 @@ def _create_fixed_root_joint_newton(articulation_prim, stage) -> None: physx_utils.createJoint(stage=stage, joint_type="Fixed", from_prim=None, to_prim=articulation_prim) -def _newton_is_active() -> bool: - """Return whether the running simulation uses the Newton backend.""" - from isaaclab.sim import SimulationContext - - from isaaclab_newton.physics import NewtonCfg - - sim = SimulationContext.instance() - return sim is not None and isinstance(sim.cfg.physics, NewtonCfg) - - -# Register the Newton fixed-root-joint creator (matched to the active Newton backend) so fixing an -# articulation base works in a Newton-only run, without core carrying any backend logic. Registered on -# import of this package (which a caller does to construct Newton schema fragments). -register_fixed_root_joint_creator(_create_fixed_root_joint_newton, _newton_is_active) +# Register the Newton fixed-root-joint creator with the core articulation-root writer keyed by +# ``NewtonCfg``, so the writer selects it only when Newton is the active simulation backend +# (``cfg.physics`` is a ``NewtonCfg``). This makes fixing an articulation base work in a Newton-only run +# without core carrying any backend logic. Registered on import of this package (which a caller does to +# construct Newton schema fragments). +register_fixed_root_joint_creator(NewtonCfg, _create_fixed_root_joint_newton) diff --git a/source/isaaclab_physx/isaaclab_physx/sim/schemas/__init__.py b/source/isaaclab_physx/isaaclab_physx/sim/schemas/__init__.py index 2e41f5f71123..2681f9f6a6d1 100644 --- a/source/isaaclab_physx/isaaclab_physx/sim/schemas/__init__.py +++ b/source/isaaclab_physx/isaaclab_physx/sim/schemas/__init__.py @@ -8,6 +8,8 @@ from isaaclab.sim.schemas._backend_hooks import register_fixed_root_joint_creator from isaaclab.utils.module import lazy_export +from isaaclab_physx.physics import PhysxCfg + lazy_export() @@ -75,17 +77,8 @@ def _create_fixed_root_joint(articulation_prim, stage) -> None: articulation_prim.RemoveAPI(UsdPhysics.ArticulationRootAPI) -def _physx_is_active() -> bool: - """Return whether the running simulation uses the PhysX backend.""" - from isaaclab.sim import SimulationContext - - from isaaclab_physx.physics import PhysxCfg - - sim = SimulationContext.instance() - return sim is not None and isinstance(sim.cfg.physics, PhysxCfg) - - -# Keep PhysX articulation-root logic out of core: register the creator (matched to the active PhysX -# backend) with the core articulation-root writer. Registered on import of this package (which a caller -# does to construct PhysX schema fragments), inverting the dependency so core carries no PhysX logic. -register_fixed_root_joint_creator(_create_fixed_root_joint, _physx_is_active) +# Keep PhysX articulation-root logic out of core: register the creator with the core articulation-root +# writer keyed by ``PhysxCfg``, so the writer selects it only when PhysX is the active simulation +# backend (``cfg.physics`` is a ``PhysxCfg``). Registered on import of this package (which a caller does +# to construct PhysX schema fragments), inverting the dependency so core carries no PhysX logic. +register_fixed_root_joint_creator(PhysxCfg, _create_fixed_root_joint) From 798954457ca88f75041d004f432e38bdbac483a2 Mon Sep 17 00:00:00 2001 From: Vidur Vij Date: Sat, 27 Jun 2026 14:50:01 -0700 Subject: [PATCH 13/23] Author fixed root joint with backend-neutral USD helper The Newton fixed-root-joint creator authored the world<->root joint via omni.physx's createJoint, pulling a PhysX-flavored Kit dependency into the Newton backend. Add a backend-neutral create_fixed_root_joint helper in core (pure USD: world-anchored UsdPhysics.FixedJoint whose local frame is set to the prim's current world pose so the body is pinned in place) and use it from both backends. The Newton backend now depends on nothing PhysX-flavored. Add tests for the helper (world-anchored joint, world-pose pinning, unique naming), the active-backend resolution (creator selected by the live cfg.physics type, unregistered type and no-sim return None), and the Newton creator (authors the joint without the PhysX root relocation). --- source/isaaclab/isaaclab/sim/__init__.pyi | 2 + .../isaaclab/sim/schemas/__init__.pyi | 2 + .../isaaclab/isaaclab/sim/schemas/schemas.py | 54 ++++++++ .../test/sim/test_articulation_fragments.py | 122 +++++++++++++++++- .../isaaclab_newton/sim/schemas/__init__.py | 8 +- .../isaaclab_physx/sim/schemas/__init__.py | 4 +- 6 files changed, 185 insertions(+), 7 deletions(-) diff --git a/source/isaaclab/isaaclab/sim/__init__.pyi b/source/isaaclab/isaaclab/sim/__init__.pyi index 5afba7dd039a..4ce3bbc2ef5b 100644 --- a/source/isaaclab/isaaclab/sim/__init__.pyi +++ b/source/isaaclab/isaaclab/sim/__init__.pyi @@ -72,6 +72,7 @@ __all__ = [ "UsdPhysicsMeshCollisionCfg", "UsdPhysicsRigidBodyCfg", "apply_articulation_root_properties", + "create_fixed_root_joint", "apply_collision_properties", "apply_fixed_tendon_properties", "apply_mass_properties", @@ -260,6 +261,7 @@ from .schemas import ( UsdPhysicsRigidBodyCfg, activate_contact_sensors, apply_articulation_root_properties, + create_fixed_root_joint, apply_collision_properties, apply_fixed_tendon_properties, apply_mass_properties, diff --git a/source/isaaclab/isaaclab/sim/schemas/__init__.pyi b/source/isaaclab/isaaclab/sim/schemas/__init__.pyi index 64198a45b353..1b2f922bfd83 100644 --- a/source/isaaclab/isaaclab/sim/schemas/__init__.pyi +++ b/source/isaaclab/isaaclab/sim/schemas/__init__.pyi @@ -9,6 +9,7 @@ __all__ = [ "USD_MESH_COLLISION_CFGS", "activate_contact_sensors", "apply_articulation_root_properties", + "create_fixed_root_joint", "apply_collision_properties", "apply_fixed_tendon_properties", "apply_mass_properties", @@ -72,6 +73,7 @@ from .schemas import ( USD_MESH_COLLISION_CFGS, activate_contact_sensors, apply_articulation_root_properties, + create_fixed_root_joint, apply_collision_properties, apply_fixed_tendon_properties, apply_mass_properties, diff --git a/source/isaaclab/isaaclab/sim/schemas/schemas.py b/source/isaaclab/isaaclab/sim/schemas/schemas.py index e39e38095601..61ec31d4e898 100644 --- a/source/isaaclab/isaaclab/sim/schemas/schemas.py +++ b/source/isaaclab/isaaclab/sim/schemas/schemas.py @@ -374,6 +374,60 @@ def apply_articulation_root_properties( return True +def create_fixed_root_joint(articulation_prim, stage: Usd.Stage | None = None): + """Author a world<->prim ``UsdPhysics.FixedJoint`` that pins a prim at its current world pose. + + Backend-neutral USD authoring with no Kit/``omni`` dependency: it mirrors the fixed, world-anchored + joint produced by Kit's ``omni.physx`` ``createJoint`` helper -- ``body0`` is left empty (the world + frame), ``body1`` targets the prim, and the joint's world-side local frame is set to the prim's + current world transform so the constraint pins the body where it is (otherwise it would be pinned to + the world origin). The joint prim is created under the first writable (non-instanced) ancestor. + + Args: + articulation_prim: The prim to fix to the world frame. + stage: The stage the prim lives on. Defaults to None, in which case the current stage is used. + + Returns: + The created ``UsdPhysics.FixedJoint`` prim. + """ + from pxr import Gf, UsdGeom + + if stage is None: + stage = get_current_stage() + + # host the joint under the first writable ancestor (an instanced/prototype prim is not writable) + base_prim = articulation_prim + while base_prim != stage.GetPseudoRoot(): + if base_prim.IsInPrototype() or base_prim.IsInstanceProxy() or base_prim.IsInstanceable(): + base_prim = base_prim.GetParent() + else: + break + base_path = str(base_prim.GetPrimPath()) + if base_path == "/": + base_path = "" + # pick a unique child name so repeated calls do not collide + name = "FixedJoint" + if stage.GetPrimAtPath(f"{base_path}/{name}").IsValid(): + index = 0 + while stage.GetPrimAtPath(f"{base_path}/{name}{index}").IsValid(): + index += 1 + name = f"{name}{index}" + joint = UsdPhysics.FixedJoint.Define(stage, f"{base_path}/{name}") + + # body0 empty -> world; body1 -> the prim, pinned at its current world pose + world_xform = UsdGeom.XformCache().GetLocalToWorldTransform(articulation_prim).RemoveScaleShear() + joint.CreateBody1Rel().SetTargets([articulation_prim.GetPath()]) + joint.CreateLocalPos0Attr().Set(Gf.Vec3f(world_xform.ExtractTranslation())) + joint.CreateLocalRot0Attr().Set(Gf.Quatf(world_xform.ExtractRotationQuat())) + joint.CreateLocalPos1Attr().Set(Gf.Vec3f(0.0)) + joint.CreateLocalRot1Attr().Set(Gf.Quatf(1.0)) + # disable joint breaking (matches Kit's createJoint defaults; float32 max) + max_float = 3.40282347e38 + joint.CreateBreakForceAttr().Set(max_float) + joint.CreateBreakTorqueAttr().Set(max_float) + return joint.GetPrim() + + def define_articulation_root_properties( prim_path: str, cfg: schemas_cfg.ArticulationRootBaseCfg, stage: Usd.Stage | None = None ): diff --git a/source/isaaclab/test/sim/test_articulation_fragments.py b/source/isaaclab/test/sim/test_articulation_fragments.py index d4d131165a36..227deb10e2c4 100644 --- a/source/isaaclab/test/sim/test_articulation_fragments.py +++ b/source/isaaclab/test/sim/test_articulation_fragments.py @@ -14,7 +14,7 @@ import pytest -from pxr import UsdGeom, UsdPhysics +from pxr import Gf, UsdGeom, UsdPhysics import isaaclab.sim as sim_utils from isaaclab.sim import SimulationCfg, SimulationContext @@ -299,6 +299,125 @@ class _OtherBackendCfg: assert called["path"] == "/World/Robot4" +# ------------------------------------------------------------------------------------- +# create_fixed_root_joint -- backend-neutral, pure-USD world<->prim fixed joint +# ------------------------------------------------------------------------------------- + + +def test_create_fixed_root_joint_authors_world_anchored_joint(): + """create_fixed_root_joint authors a world<->prim ``UsdPhysics.FixedJoint``: ``body1`` targets the + prim, ``body0`` is left empty (the world frame), and the body1-side local frame is identity.""" + from isaaclab.sim.schemas import create_fixed_root_joint + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + prim = _make_xform(stage, "/World/Fix1") + joint_prim = create_fixed_root_joint(prim, stage) + + assert joint_prim.IsA(UsdPhysics.FixedJoint) + joint = UsdPhysics.FixedJoint(joint_prim) + assert list(joint.GetBody1Rel().GetTargets()) == [prim.GetPath()] + # body0 left empty -> the world frame + body0_rel = joint.GetBody0Rel() + assert not body0_rel or list(body0_rel.GetTargets()) == [] + # the body1-side local frame is identity + assert joint_prim.GetAttribute("physics:localPos1").Get() == Gf.Vec3f(0.0) + assert joint_prim.GetAttribute("physics:localRot1").Get() == Gf.Quatf(1.0) + + +def test_create_fixed_root_joint_pins_prim_at_current_world_pose(): + """The world-side local frame is set to the prim's current world transform, so the constraint pins + the body where it is rather than teleporting it to the world origin.""" + from isaaclab.sim.schemas import create_fixed_root_joint + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + prim = _make_xform(stage, "/World/Fix2") + UsdGeom.Xform(prim).AddTranslateOp().Set(Gf.Vec3d(1.0, 2.0, 3.0)) + + joint_prim = create_fixed_root_joint(prim, stage) + pos0 = joint_prim.GetAttribute("physics:localPos0").Get() + assert (pos0[0], pos0[1], pos0[2]) == pytest.approx((1.0, 2.0, 3.0)) + + +def test_create_fixed_root_joint_uses_unique_names(): + """Repeated calls author distinct joint prims (no path collision).""" + from isaaclab.sim.schemas import create_fixed_root_joint + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + prim = _make_xform(stage, "/World/Fix3") + first = create_fixed_root_joint(prim, stage) + second = create_fixed_root_joint(prim, stage) + assert first.GetPath() != second.GetPath() + assert first.IsA(UsdPhysics.FixedJoint) and second.IsA(UsdPhysics.FixedJoint) + + +# ------------------------------------------------------------------------------------- +# active-backend detection: creator is resolved by the live cfg.physics type +# ------------------------------------------------------------------------------------- + + +def test_resolve_fixed_root_joint_creator_matches_cfg_type(monkeypatch): + """_resolve_fixed_root_joint_creator returns the creator registered for the given physics-cfg type, + and None for an unregistered type or no active simulation -- a pure dict lookup, no probing.""" + from isaaclab.sim.schemas import _backend_hooks + + monkeypatch.setattr(_backend_hooks, "_FIXED_ROOT_JOINT_CREATORS", {}) + + class _CfgA: + pass + + class _CfgB: + pass + + def _creator_a(prim, stage): + pass + + _backend_hooks.register_fixed_root_joint_creator(_CfgA, _creator_a) + + assert _backend_hooks._resolve_fixed_root_joint_creator(_CfgA) is _creator_a + assert _backend_hooks._resolve_fixed_root_joint_creator(_CfgB) is None + assert _backend_hooks._resolve_fixed_root_joint_creator(None) is None + + +def test_newton_creator_authors_joint_without_reparenting(): + """The Newton fixed-root-joint creator authors a ``UsdPhysics.FixedJoint`` but, unlike PhysX, leaves + the articulation root in place (Newton reads the fixed joint directly), and pulls in no PhysX deps.""" + from isaaclab_newton.sim.schemas import _create_fixed_root_joint_newton + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + _make_xform(stage, "/World/NewtonRobot") + root = _make_xform(stage, "/World/NewtonRobot/base") + UsdPhysics.RigidBodyAPI.Apply(root) + UsdPhysics.ArticulationRootAPI.Apply(root) + + _create_fixed_root_joint_newton(root, stage) + + assert any(p.IsA(UsdPhysics.FixedJoint) for p in stage.Traverse()) + # Newton does NOT relocate the articulation root to the parent (that is a PhysX-parser workaround) + assert root.HasAPI(UsdPhysics.ArticulationRootAPI) + assert not stage.GetPrimAtPath("/World/NewtonRobot").HasAPI(UsdPhysics.ArticulationRootAPI) + + +def test_newton_creator_requires_rigid_body(): + """The Newton creator raises NotImplementedError on a non-rigid-body root (cannot anchor the joint).""" + from isaaclab_newton.sim.schemas import _create_fixed_root_joint_newton + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + root = _make_xform(stage, "/World/NewtonNoRB") + UsdPhysics.ArticulationRootAPI.Apply(root) # articulation root but NOT a rigid body + with pytest.raises(NotImplementedError): + _create_fixed_root_joint_newton(root, stage) + + # ------------------------------------------------------------------------------------- # public imports # ------------------------------------------------------------------------------------- @@ -312,4 +431,5 @@ def test_public_imports(): ArticulationRootFragment, SchemaFragment, apply_articulation_root_properties, + create_fixed_root_joint, ) diff --git a/source/isaaclab_newton/isaaclab_newton/sim/schemas/__init__.py b/source/isaaclab_newton/isaaclab_newton/sim/schemas/__init__.py index 195ced9ac380..80f96e5ea27c 100644 --- a/source/isaaclab_newton/isaaclab_newton/sim/schemas/__init__.py +++ b/source/isaaclab_newton/isaaclab_newton/sim/schemas/__init__.py @@ -19,8 +19,8 @@ def _create_fixed_root_joint_newton(articulation_prim, stage) -> None: Backend creator registered with the core articulation-root writer for the Newton backend. Newton's importer reads a ``UsdPhysics.FixedJoint`` directly as a fixed root joint (a jointless root would otherwise default to floating), so -- unlike PhysX -- no articulation-root relocation is needed. The - joint is authored with the Kit ``omni.physx`` utility (a USD-authoring helper present in any Isaac - Sim app regardless of the active solver), so nothing is hand-rolled. + joint is authored with the backend-neutral :func:`~isaaclab.sim.schemas.create_fixed_root_joint` + helper (pure USD, no Kit/``omni`` dependency), so the Newton backend pulls in nothing PhysX-flavored. Args: articulation_prim: The resolved articulation-root prim to fix to the world frame. @@ -32,7 +32,7 @@ def _create_fixed_root_joint_newton(articulation_prim, stage) -> None: """ from pxr import UsdPhysics - from omni.physx.scripts import utils as physx_utils + from isaaclab.sim.schemas.schemas import create_fixed_root_joint if not articulation_prim.HasAPI(UsdPhysics.RigidBodyAPI): raise NotImplementedError( @@ -40,7 +40,7 @@ def _create_fixed_root_joint_newton(articulation_prim, stage) -> None: " 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." ) - physx_utils.createJoint(stage=stage, joint_type="Fixed", from_prim=None, to_prim=articulation_prim) + create_fixed_root_joint(articulation_prim, stage) # Register the Newton fixed-root-joint creator with the core articulation-root writer keyed by diff --git a/source/isaaclab_physx/isaaclab_physx/sim/schemas/__init__.py b/source/isaaclab_physx/isaaclab_physx/sim/schemas/__init__.py index 2681f9f6a6d1..87dec8418cdf 100644 --- a/source/isaaclab_physx/isaaclab_physx/sim/schemas/__init__.py +++ b/source/isaaclab_physx/isaaclab_physx/sim/schemas/__init__.py @@ -32,7 +32,7 @@ def _create_fixed_root_joint(articulation_prim, stage) -> None: """ from pxr import UsdPhysics - from omni.physx.scripts import utils as physx_utils + from isaaclab.sim.schemas.schemas import create_fixed_root_joint # note: we assume the root prim is a rigid body; there is no obvious way to get the first rigid # body link identified by the PhysX parser when it is not. @@ -44,7 +44,7 @@ def _create_fixed_root_joint(articulation_prim, stage) -> None: ) # create a fixed joint between the root link and the world frame - physx_utils.createJoint(stage=stage, joint_type="Fixed", from_prim=None, to_prim=articulation_prim) + create_fixed_root_joint(articulation_prim, stage) # Having a fixed joint on a rigid body is not treated as "fixed base articulation"; it is treated as # part of the maximal coordinate tree. Moving the articulation root to the parent solves this (a From aae491886f06fa4dab297cb2eff03c3356da1543 Mon Sep 17 00:00:00 2001 From: Vidur Vij Date: Sat, 27 Jun 2026 15:04:08 -0700 Subject: [PATCH 14/23] Document why backend creators defer their imports The articulation-root creators import pxr and the core schema helper at call time rather than module top. Add a comment explaining this is deliberate: the backend schema package is imported eagerly when a config references a backend cfg name, so its import path must stay free of the USD/Omniverse runtime; the creator itself only runs while a simulation is live, when those imports are available. --- .../isaaclab_newton/isaaclab_newton/sim/schemas/__init__.py | 4 ++++ source/isaaclab_physx/isaaclab_physx/sim/schemas/__init__.py | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/source/isaaclab_newton/isaaclab_newton/sim/schemas/__init__.py b/source/isaaclab_newton/isaaclab_newton/sim/schemas/__init__.py index 80f96e5ea27c..a15f8904bb58 100644 --- a/source/isaaclab_newton/isaaclab_newton/sim/schemas/__init__.py +++ b/source/isaaclab_newton/isaaclab_newton/sim/schemas/__init__.py @@ -30,6 +30,10 @@ def _create_fixed_root_joint_newton(articulation_prim, stage) -> None: NotImplementedError: When the root prim is not a rigid body (the first rigid-body link cannot be determined to anchor the fixed joint). """ + # Imports are deferred to call time on purpose: this creator only runs while a simulation is live + # (USD available), whereas this package is imported eagerly when a config references a Newton cfg + # name. ``pxr`` and ``schemas.schemas`` (which imports ``pxr`` at its top) must stay off that eager + # path to keep config import free of the USD/Omniverse runtime. from pxr import UsdPhysics from isaaclab.sim.schemas.schemas import create_fixed_root_joint diff --git a/source/isaaclab_physx/isaaclab_physx/sim/schemas/__init__.py b/source/isaaclab_physx/isaaclab_physx/sim/schemas/__init__.py index 87dec8418cdf..448533efd3e5 100644 --- a/source/isaaclab_physx/isaaclab_physx/sim/schemas/__init__.py +++ b/source/isaaclab_physx/isaaclab_physx/sim/schemas/__init__.py @@ -30,6 +30,10 @@ def _create_fixed_root_joint(articulation_prim, stage) -> None: NotImplementedError: When the root prim is not a rigid body (the first rigid-body link cannot be determined to attach the fixed joint). """ + # Imports are deferred to call time on purpose: this creator only runs while a simulation is live + # (USD available), whereas this package is imported eagerly when a config references a PhysX cfg + # name. ``pxr`` and ``schemas.schemas`` (which imports ``pxr`` at its top) must stay off that eager + # path to keep config import free of the USD/Omniverse runtime. from pxr import UsdPhysics from isaaclab.sim.schemas.schemas import create_fixed_root_joint From b0dcdd86ca4a755ebbb0f74a75ca8adf970ab524 Mon Sep 17 00:00:00 2001 From: Vidur Vij Date: Thu, 2 Jul 2026 12:48:45 -0700 Subject: [PATCH 15/23] Resolve articulation-root fixing via physics manager Replace the cfg-type fixed-root-joint creator registry with a fix_articulation_root capability on PhysicsManager. The registry keyed on the exact cfg type missed cfg subclasses (e.g. DeformableNewtonCfg) and backends that register no creator (OVPhysx), and depended on which backend schema module happened to be imported. Resolving the capability from cfg.physics.class_type dispatches through the normal method resolution order instead, so every backend and subclass inherits the correct behaviour. The base implementation authors a backend-neutral fixed joint; PhysX overrides it to relocate the articulation root to the parent prim. Fix the root link before writing fragments so relocation is resolved up front and every fragment is applied to the single resulting root. Previously fragments were written first and the PhysX relocation copied only its own schema, stranding a composed Newton articulation-root API on the former child and leaving two articulation roots. Route the spawner articulation_props slot by type even when the list is empty (an empty list no longer falls through to the legacy writer and raises), and honor the spawner-level fix_root_link flag independently of whether any schema properties were supplied. Scope the fixed-joint lookup to the supplied stage. --- .../vidurv-schema-frag-articulation.minor.rst | 5 + .../isaaclab/physics/physics_manager.py | 58 ++++ .../isaaclab/sim/schemas/_backend_hooks.py | 38 --- .../isaaclab/isaaclab/sim/schemas/schemas.py | 70 ++--- .../sim/spawners/from_files/from_files.py | 27 +- .../test/sim/test_articulation_fragments.py | 263 +++++++++++++----- .../isaaclab_newton/sim/schemas/__init__.py | 48 +--- .../isaaclab_physx/physics/physx_manager.py | 32 +++ .../isaaclab_physx/sim/schemas/__init__.py | 86 +----- 9 files changed, 361 insertions(+), 266 deletions(-) diff --git a/source/isaaclab/changelog.d/vidurv-schema-frag-articulation.minor.rst b/source/isaaclab/changelog.d/vidurv-schema-frag-articulation.minor.rst index ac26c8bafe91..dd740d585249 100644 --- a/source/isaaclab/changelog.d/vidurv-schema-frag-articulation.minor.rst +++ b/source/isaaclab/changelog.d/vidurv-schema-frag-articulation.minor.rst @@ -6,6 +6,11 @@ Added :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. +* Added the :meth:`~isaaclab.physics.PhysicsManager.fix_articulation_root` capability, which fixes an + articulation base to the world frame and returns the resulting root prim. The base implementation + authors a backend-neutral fixed joint; backends whose parser relocates the articulation root (e.g. + PhysX) override it, so :func:`~isaaclab.sim.schemas.apply_articulation_root_properties` applies every + fragment to the single resulting root regardless of backend. Changed ^^^^^^^ diff --git a/source/isaaclab/isaaclab/physics/physics_manager.py b/source/isaaclab/isaaclab/physics/physics_manager.py index 5255af873604..0a12acb72292 100644 --- a/source/isaaclab/isaaclab/physics/physics_manager.py +++ b/source/isaaclab/isaaclab/physics/physics_manager.py @@ -99,6 +99,64 @@ def provides_implicit_damping(cls) -> bool: """ return True + @classmethod + def fix_articulation_root(cls, articulation_prim: Any, stage: Any = None) -> Any: + """Fix an articulation base to the world frame and return the resulting root prim. + + Authors a world<->root fixed joint that pins the articulation base at its current world pose. + The base implementation authors a backend-neutral ``UsdPhysics.FixedJoint`` (via + :func:`~isaaclab.sim.schemas.create_fixed_root_joint`) and returns ``articulation_prim`` + unchanged -- sufficient for backends whose parser reads a fixed joint on the root directly + (e.g. Newton). Backends whose parser does not treat a fixed joint on a rigid body as a + fixed-base articulation (e.g. PhysX) override this to relocate the articulation root and + return the new root prim, so the caller applies articulation-root schemas to the actual root. + + This is a backend capability rather than a registry keyed by cfg type: the active backend's + manager class is resolved from ``cfg.physics.class_type``, so subclassed cfgs and every + supported backend inherit the correct behaviour through the normal method-resolution order. + + Args: + articulation_prim: The resolved articulation-root prim to fix to the world frame. + stage: The stage the prim lives on. Defaults to None, in which case the current stage is + used. + + Returns: + The articulation-root prim after fixing. May differ from ``articulation_prim`` when the + backend relocates the root (e.g. PhysX moves it to the parent prim). + + Raises: + NotImplementedError: When the root prim is not a rigid body and the first rigid-body link + cannot be determined to anchor the fixed joint. + """ + + from isaaclab.sim.schemas import create_fixed_root_joint # noqa: PLC0415 + + cls._require_rigid_body_root(articulation_prim) + create_fixed_root_joint(articulation_prim, stage) + return articulation_prim + + @staticmethod + def _require_rigid_body_root(articulation_prim: Any) -> None: + """Raise ``NotImplementedError`` when the articulation root is not a rigid body. + + A fixed root joint is anchored to the first rigid-body link of the articulation; when the + resolved root carries no ``RigidBodyAPI`` that link cannot be determined. + + Args: + articulation_prim: The resolved articulation-root prim. + + Raises: + NotImplementedError: When the prim does not have ``UsdPhysics.RigidBodyAPI`` applied. + """ + from pxr import UsdPhysics # noqa: PLC0415 + + if not articulation_prim.HasAPI(UsdPhysics.RigidBodyAPI): + raise NotImplementedError( + f"The articulation prim '{articulation_prim.GetPath().pathString}' 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." + ) + @classmethod def register_callback( cls, diff --git a/source/isaaclab/isaaclab/sim/schemas/_backend_hooks.py b/source/isaaclab/isaaclab/sim/schemas/_backend_hooks.py index 433d98cf2dd2..3c25d668a3fb 100644 --- a/source/isaaclab/isaaclab/sim/schemas/_backend_hooks.py +++ b/source/isaaclab/isaaclab/sim/schemas/_backend_hooks.py @@ -16,44 +16,6 @@ from collections.abc import Callable -# Backend-registered creators that fix an articulation base by authoring a world<->root fixed joint, -# keyed by the backend's physics-cfg class (the type of ``SimulationContext.cfg.physics``). Creating it -# is backend-specific (PhysX relocates the articulation root to the parent prim to work around its -# parser; Newton reads the fixed joint directly), so each backend registers its own creator under its -# cfg type. The writer looks up the creator for the active simulation's ``cfg.physics`` type -- a plain -# dict lookup that never imports or executes a non-active backend, and "active" comes solely from the -# live ``cfg.physics`` rather than from which backends happen to be imported. -_FIXED_ROOT_JOINT_CREATORS: dict[type, Callable] = {} - - -def register_fixed_root_joint_creator(physics_cfg: type, creator: Callable) -> None: - """Register the creator that fixes an articulation base for a given physics-backend cfg type. - - Called by :func:`~isaaclab.sim.schemas.apply_articulation_root_properties` when ``fix_root_link`` - is True and no fixed joint yet exists. Each physics backend (e.g. ``isaaclab_physx``, - ``isaaclab_newton``) registers its own creator keyed by its physics-cfg class; the writer looks up - the creator for the active simulation's ``cfg.physics`` type, so core selects the right backend - without importing or probing any of them. - - Args: - physics_cfg: The backend's physics-cfg class (the type of ``SimulationContext.cfg.physics`` for - that backend), used as the lookup key. - creator: A callable ``creator(articulation_prim, stage)`` that authors the fixed joint. - """ - _FIXED_ROOT_JOINT_CREATORS[physics_cfg] = creator - - -def _resolve_fixed_root_joint_creator(physics_cfg: type | None) -> Callable | None: - """Return the creator registered for the active backend's physics-cfg type, or None. - - Args: - physics_cfg: The type of the active simulation's ``cfg.physics`` (or None if no simulation). - """ - if physics_cfg is None: - return None - return _FIXED_ROOT_JOINT_CREATORS.get(physics_cfg) - - # Backend-registered predicates that exclude a joint prim from joint-drive authoring. Backends (e.g. # PhysX tendons) register here via :func:`register_joint_drive_skip_predicate` so the core joint-drive # writers can skip backend-controlled joints without core carrying any backend-specific schema name. diff --git a/source/isaaclab/isaaclab/sim/schemas/schemas.py b/source/isaaclab/isaaclab/sim/schemas/schemas.py index dc498d75c4c4..d85e68a91e53 100644 --- a/source/isaaclab/isaaclab/sim/schemas/schemas.py +++ b/source/isaaclab/isaaclab/sim/schemas/schemas.py @@ -29,7 +29,7 @@ safe_set_attribute_on_usd_schema, ) from . import schemas_cfg -from ._backend_hooks import _resolve_fixed_root_joint_creator, _skip_joint_drive +from ._backend_hooks import _skip_joint_drive # import logger logger = logging.getLogger(__name__) @@ -292,11 +292,16 @@ def apply_articulation_root_properties( 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. + When ``fix_root_link`` is not ``None`` the base is fixed to the world *before* any fragment is + written: an existing world-to-root fixed joint is enabled/disabled, otherwise (when True) one is + created via the active backend's + :meth:`~isaaclab.physics.PhysicsManager.fix_articulation_root` capability. That capability returns + the resulting root prim (some backends relocate it, e.g. PhysX moves the root to the parent), and + every fragment is then dispatched to *that* root via its + :attr:`~isaaclab.sim.schemas.SchemaFragment.func`. Writing fragments after the (possibly + relocating) fix keeps all backend schemas on the single resulting root -- so composing e.g. a + PhysX and a Newton fragment with ``fix_root_link=True`` never leaves a stray second root behind. + Backend fragments carry backend-specific funcs, so core never imports a backend. Args: prim_path: The prim path to search for the articulation root under (the root may be on a @@ -314,15 +319,14 @@ def apply_articulation_root_properties( Raises: ValueError: When the prim path is not valid. - RuntimeError: When ``fix_root_link`` is True and a fixed joint must be created, but no backend - creator has been registered (see :func:`register_fixed_root_joint_creator`) -- e.g. no - physics backend extension such as ``isaaclab_physx`` was imported. - NotImplementedError: Propagated from the registered creator when it cannot create the fixed + RuntimeError: When ``fix_root_link`` is True and a fixed joint must be created but there is no + active :class:`~isaaclab.sim.SimulationContext` to resolve the physics backend from. + NotImplementedError: Propagated from the backend capability when it cannot create the fixed joint (e.g. the resolved root prim is not a rigid body). """ if stage is None: stage = get_current_stage() - # tune the existing root in place (it may live on a child prim); instance proxies can't be + # resolve the existing root (it may live on a child prim in USD assets); instance proxies can't be # authored on, so don't traverse them articulation_prim = get_first_matching_child_prim( prim_path, @@ -334,42 +338,42 @@ def apply_articulation_root_properties( 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 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) - # 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. + # Fix the root link BEFORE writing fragments. Some backends relocate the root when fixing (PhysX + # moves the ``ArticulationRootAPI`` to the parent), so resolving the final root first lets every + # fragment land on that single root -- otherwise a relocation would strand fragment schemas on the + # former child and leave two articulation roots. + final_root = articulation_prim if fix_root_link is not None: - # 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 + root_path = articulation_prim.GetPath().pathString + # honor an explicit stage so the lookup does not fall back to the global current stage + existing_fixed_joint_prim = find_global_fixed_joint_prim(root_path, stage=stage) if existing_fixed_joint_prim is not None: + # a joint already exists: just enable/disable it in place (no relocation) logger.info( 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: '{root_path}'.") - # Creating a world<->root fixed joint to fix the base is backend-specific. Look up the - # creator registered for the active simulation's physics-cfg type so core carries no backend - # logic and never touches a non-active backend; see register_fixed_root_joint_creator. + # Creating (and possibly relocating) the root is backend-specific. Delegate to the active + # backend's manager capability so core carries no backend logic; the capability returns the + # resulting root prim. from isaaclab.sim import SimulationContext sim = SimulationContext.instance() - creator = _resolve_fixed_root_joint_creator(type(sim.cfg.physics) if sim is not None else None) - if creator is None: + if sim is None: raise RuntimeError( - f"Cannot fix the articulation root '{root_path}': no fixed-root-joint creator is" - " registered for the active physics backend. Import the matching backend extension" - " (e.g. isaaclab_physx or isaaclab_newton) so it can register one." + f"Cannot fix the articulation root '{root_path}': no active SimulationContext to" + " resolve the physics backend from. A simulation must be running to fix a base." ) - creator(articulation_prim, stage) + final_root = sim.physics_manager.fix_articulation_root(articulation_prim, stage) + + # dispatch each fragment to the resolved (possibly relocated) root via its own applier + final_root_path = final_root.GetPath().pathString + for cfg in fragments: + func = cfg.func if callable(cfg.func) else string_to_callable(cfg.func) + func(cfg, final_root_path, stage) return True 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 96d56733cae7..fb05a60e2f99 100644 --- a/source/isaaclab/isaaclab/sim/spawners/from_files/from_files.py +++ b/source/isaaclab/isaaclab/sim/spawners/from_files/from_files.py @@ -368,17 +368,30 @@ def _spawn_from_usd_file( schemas.modify_mass_properties(prim_path, cfg.mass_props) # modify articulation root properties - if cfg.articulation_props is not None: - # transition shim, remove later: new fragment list -> apply_*; legacy single cfg -> modify_* + # ``fix_root_link`` is a spawner-level topology flag (not a schema property); it is honored on the + # fragment path independently of whether any articulation schema properties were supplied. + articulation_props = cfg.articulation_props + articulation_fix_root_link = getattr(cfg, "fix_root_link", None) + # transition shim, remove later: route a legacy single cfg (a dataclass, not a fragment) to the + # legacy writer -- it owns its own ``fix_root_link`` field; everything else goes to the fragment + # writer, routing by type so an empty list is still a valid (topology-only) fragment collection + # rather than being mis-sent to the legacy writer. + if ( + articulation_props is not None + and not isinstance(articulation_props, (list, tuple)) + and not isinstance(articulation_props, schemas.SchemaFragment) + ): + schemas.modify_articulation_root_properties(prim_path, articulation_props) + else: articulation_frags = ( - cfg.articulation_props if isinstance(cfg.articulation_props, (list, tuple)) else [cfg.articulation_props] + list(articulation_props) + if isinstance(articulation_props, (list, tuple)) + else ([articulation_props] if isinstance(articulation_props, schemas.SchemaFragment) else []) ) - if articulation_frags and all(isinstance(f, schemas.SchemaFragment) for f in articulation_frags): + if articulation_frags or articulation_fix_root_link is not None: schemas.apply_articulation_root_properties( - prim_path, articulation_frags, fix_root_link=getattr(cfg, "fix_root_link", None) + prim_path, articulation_frags, fix_root_link=articulation_fix_root_link ) - else: - schemas.modify_articulation_root_properties(prim_path, cfg.articulation_props) # modify tendon properties if cfg.fixed_tendons_props is not None: # transition shim, remove later: fragment(s) -> apply_*; legacy cfg -> modify_* diff --git a/source/isaaclab/test/sim/test_articulation_fragments.py b/source/isaaclab/test/sim/test_articulation_fragments.py index 227deb10e2c4..2e92548d50bb 100644 --- a/source/isaaclab/test/sim/test_articulation_fragments.py +++ b/source/isaaclab/test/sim/test_articulation_fragments.py @@ -12,9 +12,11 @@ """Rest everything follows.""" +import os + import pytest -from pxr import Gf, UsdGeom, UsdPhysics +from pxr import Gf, Usd, UsdGeom, UsdPhysics import isaaclab.sim as sim_utils from isaaclab.sim import SimulationCfg, SimulationContext @@ -247,16 +249,13 @@ def test_apply_articulation_root_properties_fix_root_link_requires_rigid_body(): ) -def test_apply_articulation_root_properties_fix_root_link_without_backend_raises(monkeypatch): - """Creating a fixed root joint with no registered backend creator raises a clear error: the - backend-specific creation logic lives in the backends, so core cannot fix the base on its own. - - Uses monkeypatch to clear the module-global creator registry (other tests/backends register - session-wide); monkeypatch restores it afterwards so this stays isolated. +def test_apply_articulation_root_properties_fix_root_link_without_active_simulation_raises(monkeypatch): + """Creating a fixed root joint with no active ``SimulationContext`` raises a clear error: the + backend that authors (and possibly relocates) the root is resolved from the live simulation, so + without one core cannot fix the base on its own. """ - from isaaclab.sim.schemas import _backend_hooks, apply_articulation_root_properties - - monkeypatch.setattr(_backend_hooks, "_FIXED_ROOT_JOINT_CREATORS", {}) + from isaaclab.sim import SimulationContext as _SimCtx + from isaaclab.sim.schemas import apply_articulation_root_properties sim_utils.create_new_stage() SimulationContext(SimulationCfg(dt=0.01)) @@ -264,39 +263,81 @@ def test_apply_articulation_root_properties_fix_root_link_without_backend_raises root = _make_xform(stage, "/World/Robot3") UsdPhysics.RigidBodyAPI.Apply(root) UsdPhysics.ArticulationRootAPI.Apply(root) + # simulate "no active simulation" so the backend cannot be resolved + monkeypatch.setattr(_SimCtx, "instance", classmethod(lambda cls: None)) with pytest.raises(RuntimeError): apply_articulation_root_properties("/World/Robot3", [], stage, fix_root_link=True) -def test_register_fixed_root_joint_creator_selects_active_backend(monkeypatch): - """The writer selects the creator registered for the active simulation's ``cfg.physics`` type and - ignores creators registered for other backend cfg types -- no probing of inactive backends.""" - from isaaclab.sim.schemas import _backend_hooks, apply_articulation_root_properties +def test_physx_and_newton_fragments_fix_root_link_keeps_single_root(): + """Composing a PhysX and a Newton fragment with ``fix_root_link=True`` must leave exactly one + articulation root, with *every* backend's schema on that resulting root. + + Regression: PhysX relocates the root to the parent when fixing the base. Writing fragments after + the relocation lands both the PhysX and Newton schemas on the parent; the former child (root link) + must not retain ``NewtonArticulationRootAPI`` / ``newton:*`` (which would leave a stray second root + because that API composes ``PhysicsArticulationRootAPI``). + """ + from isaaclab_newton.sim.schemas import NewtonArticulationCfg + from isaaclab_physx.sim.schemas import PhysxArticulationCfg - monkeypatch.setattr(_backend_hooks, "_FIXED_ROOT_JOINT_CREATORS", {}) - called = {} + from isaaclab.sim.schemas import apply_articulation_root_properties sim_utils.create_new_stage() SimulationContext(SimulationCfg(dt=0.01)) - active_cfg_type = type(SimulationContext.instance().cfg.physics) - - class _OtherBackendCfg: - """Stand-in for an inactive backend's physics-cfg type.""" + stage = sim_utils.get_current_stage() + _make_xform(stage, "/World/Robot5") + child = _make_xform(stage, "/World/Robot5/base") + UsdPhysics.RigidBodyAPI.Apply(child) + UsdPhysics.ArticulationRootAPI.Apply(child) - # a creator keyed by an inactive backend's cfg type must be ignored; the active one's is used - _backend_hooks.register_fixed_root_joint_creator( - _OtherBackendCfg, lambda p, s: called.__setitem__("path", "INACTIVE") - ) - _backend_hooks.register_fixed_root_joint_creator( - active_cfg_type, lambda p, s: called.__setitem__("path", p.GetPath().pathString) + apply_articulation_root_properties( + "/World/Robot5", + [ + PhysxArticulationCfg(solver_position_iteration_count=8), + NewtonArticulationCfg(self_collision_enabled=True), + ], + stage, + fix_root_link=True, ) - stage = sim_utils.get_current_stage() - root = _make_xform(stage, "/World/Robot4") - UsdPhysics.RigidBodyAPI.Apply(root) + parent = stage.GetPrimAtPath("/World/Robot5") + # exactly one articulation root remains, and it is the (relocated) parent + roots = [p for p in stage.Traverse() if p.HasAPI(UsdPhysics.ArticulationRootAPI)] + assert len(roots) == 1 and roots[0] == parent + # every backend's schema is authored on that single resulting root ... + assert parent.GetAttribute("physxArticulation:solverPositionIterationCount").Get() == 8 + assert parent.GetAttribute("newton:selfCollisionEnabled").Get() is True + # ... and nothing is stranded on the former child root link + assert not child.HasAPI(UsdPhysics.ArticulationRootAPI) + assert not child.GetAttribute("newton:selfCollisionEnabled").HasAuthoredValue() + + +def test_apply_articulation_root_properties_honors_explicit_stage(): + """The writer authors to the explicitly supplied stage even when it is not the current stage -- + every lookup (root resolution, fixed-joint search) is scoped to that stage.""" + 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)) + # an in-memory stage that is deliberately NOT the current stage + other_stage = Usd.Stage.CreateInMemory() + UsdGeom.Xform.Define(other_stage, "/World") + root = UsdGeom.Xform.Define(other_stage, "/World/AltStageRoot").GetPrim() UsdPhysics.ArticulationRootAPI.Apply(root) - apply_articulation_root_properties("/World/Robot4", [], stage, fix_root_link=True) - assert called["path"] == "/World/Robot4" + + apply_articulation_root_properties( + "/World/AltStageRoot", + [PhysxArticulationCfg(solver_position_iteration_count=4)], + other_stage, + ) + + # authored on the supplied stage ... + assert root.GetAttribute("physxArticulation:solverPositionIterationCount").Get() == 4 + # ... and not leaked onto the current stage + assert not sim_utils.get_current_stage().GetPrimAtPath("/World/AltStageRoot").IsValid() # ------------------------------------------------------------------------------------- @@ -357,65 +398,159 @@ def test_create_fixed_root_joint_uses_unique_names(): # ------------------------------------------------------------------------------------- -# active-backend detection: creator is resolved by the live cfg.physics type +# fix_articulation_root is a PhysicsManager capability (resolved by cfg.physics.class_type) # ------------------------------------------------------------------------------------- -def test_resolve_fixed_root_joint_creator_matches_cfg_type(monkeypatch): - """_resolve_fixed_root_joint_creator returns the creator registered for the given physics-cfg type, - and None for an unregistered type or no active simulation -- a pure dict lookup, no probing.""" - from isaaclab.sim.schemas import _backend_hooks +def test_base_manager_fix_articulation_root_authors_joint_without_reparenting(): + """The base :class:`~isaaclab.physics.PhysicsManager` capability authors a ``UsdPhysics.FixedJoint`` + and leaves the articulation root in place, returning the same prim. - monkeypatch.setattr(_backend_hooks, "_FIXED_ROOT_JOINT_CREATORS", {}) + This is the default inherited by backends whose parser reads a fixed joint directly (e.g. Newton, + OVPhysx) and by any manager subclass that does not override it -- no PhysX-style relocation. + """ + from isaaclab.physics import PhysicsManager - class _CfgA: - pass + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + _make_xform(stage, "/World/BaseRobot") + root = _make_xform(stage, "/World/BaseRobot/base") + UsdPhysics.RigidBodyAPI.Apply(root) + UsdPhysics.ArticulationRootAPI.Apply(root) - class _CfgB: - pass + result = PhysicsManager.fix_articulation_root(root, stage) + + assert any(p.IsA(UsdPhysics.FixedJoint) for p in stage.Traverse()) + # base default returns the same prim and does NOT relocate the root to the parent + assert result == root + assert root.HasAPI(UsdPhysics.ArticulationRootAPI) + assert not stage.GetPrimAtPath("/World/BaseRobot").HasAPI(UsdPhysics.ArticulationRootAPI) - def _creator_a(prim, stage): - pass - _backend_hooks.register_fixed_root_joint_creator(_CfgA, _creator_a) +def test_base_manager_fix_articulation_root_requires_rigid_body(): + """The capability raises NotImplementedError on a non-rigid-body root (cannot anchor the joint).""" + from isaaclab.physics import PhysicsManager + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + root = _make_xform(stage, "/World/BaseNoRB") + UsdPhysics.ArticulationRootAPI.Apply(root) # articulation root but NOT a rigid body + with pytest.raises(NotImplementedError): + PhysicsManager.fix_articulation_root(root, stage) - assert _backend_hooks._resolve_fixed_root_joint_creator(_CfgA) is _creator_a - assert _backend_hooks._resolve_fixed_root_joint_creator(_CfgB) is None - assert _backend_hooks._resolve_fixed_root_joint_creator(None) is None +def test_fix_articulation_root_capability_inherited_by_manager_subclass(): + """A backend manager subclass inherits its parent backend's ``fix_articulation_root`` through the + normal method-resolution order. -def test_newton_creator_authors_joint_without_reparenting(): - """The Newton fixed-root-joint creator authors a ``UsdPhysics.FixedJoint`` but, unlike PhysX, leaves - the articulation root in place (Newton reads the fixed joint directly), and pulls in no PhysX deps.""" - from isaaclab_newton.sim.schemas import _create_fixed_root_joint_newton + This is what makes the capability robust where the previous cfg-type registry was not: manager + dispatch is resolved from ``cfg.physics.class_type``, so a subclassed cfg (e.g. an in-tree + ``DeformableNewtonCfg(NewtonCfg)``) whose ``class_type`` is an unchanged or subclassed manager + inherits the correct behaviour, rather than missing an exact-type registry key. + """ + from isaaclab.physics import PhysicsManager sim_utils.create_new_stage() SimulationContext(SimulationCfg(dt=0.01)) + + # a minimal concrete backend that OVERRIDES the capability (as PhysX does, relocating the root) ... + class _BackendLike(PhysicsManager): + @classmethod + def fix_articulation_root(cls, articulation_prim, stage=None): + from isaaclab.sim.schemas import create_fixed_root_joint + + cls._require_rigid_body_root(articulation_prim) + create_fixed_root_joint(articulation_prim, stage) + parent = articulation_prim.GetParent() + UsdPhysics.ArticulationRootAPI.Apply(parent) + articulation_prim.RemoveAPI(UsdPhysics.ArticulationRootAPI) + return parent + + # ... and a subclass that does not override it (stand-in for a subclassed cfg's class_type) + class _SubBackend(_BackendLike): + pass + stage = sim_utils.get_current_stage() - _make_xform(stage, "/World/NewtonRobot") - root = _make_xform(stage, "/World/NewtonRobot/base") + _make_xform(stage, "/World/SubRobot") + root = _make_xform(stage, "/World/SubRobot/base") UsdPhysics.RigidBodyAPI.Apply(root) UsdPhysics.ArticulationRootAPI.Apply(root) - _create_fixed_root_joint_newton(root, stage) + result = _SubBackend.fix_articulation_root(root, stage) + parent = stage.GetPrimAtPath("/World/SubRobot") + # the subclass inherited the overriding backend's behaviour through the MRO assert any(p.IsA(UsdPhysics.FixedJoint) for p in stage.Traverse()) - # Newton does NOT relocate the articulation root to the parent (that is a PhysX-parser workaround) - assert root.HasAPI(UsdPhysics.ArticulationRootAPI) - assert not stage.GetPrimAtPath("/World/NewtonRobot").HasAPI(UsdPhysics.ArticulationRootAPI) + assert result == parent + assert parent.HasAPI(UsdPhysics.ArticulationRootAPI) + assert not root.HasAPI(UsdPhysics.ArticulationRootAPI) + + # and the real active backend (PhysX) genuinely overrides the base -- so backends specialise via + # this same inheritance mechanism rather than a parallel registry + active_manager = SimulationContext.instance().physics_manager + assert active_manager.fix_articulation_root.__func__ is not PhysicsManager.fix_articulation_root.__func__ + + +# ------------------------------------------------------------------------------------- +# end-to-end: the from-files transition bridge routes articulation_props by type +# ------------------------------------------------------------------------------------- + + +def _author_articulation_usd(path: str) -> None: + """Author a minimal USD asset: a parent Xform with a rigid-body child carrying the articulation root.""" + asset_stage = Usd.Stage.CreateNew(path) + robot = UsdGeom.Xform.Define(asset_stage, "/Robot") + base = UsdGeom.Xform.Define(asset_stage, "/Robot/base").GetPrim() + UsdPhysics.RigidBodyAPI.Apply(base) + UsdPhysics.ArticulationRootAPI.Apply(base) + asset_stage.SetDefaultPrim(robot.GetPrim()) + asset_stage.Save() -def test_newton_creator_requires_rigid_body(): - """The Newton creator raises NotImplementedError on a non-rigid-body root (cannot anchor the joint).""" - from isaaclab_newton.sim.schemas import _create_fixed_root_joint_newton +def test_spawn_from_usd_file_empty_articulation_props_with_fix_root_link(tmp_path): + """An empty ``articulation_props=[]`` on a USD spawn is a valid (topology-only) fragment collection: + it must route to the fragment writer and honor ``fix_root_link`` -- not crash in the legacy writer. + + Regression: the legacy path called ``dataclasses.fields([])`` on the empty list and raised + ``TypeError``, so the direct-writer and spawner entry points disagreed on whether ``[]`` was valid. + """ + from isaaclab.sim.spawners.from_files.from_files import _spawn_from_usd_file + from isaaclab.sim.spawners.from_files.from_files_cfg import UsdFileCfg sim_utils.create_new_stage() SimulationContext(SimulationCfg(dt=0.01)) + usd_path = os.path.join(tmp_path, "articulation.usda") + _author_articulation_usd(usd_path) + + cfg = UsdFileCfg(usd_path=usd_path, articulation_props=[], fix_root_link=True) + _spawn_from_usd_file("/World/FromUsdA", usd_path, cfg) + stage = sim_utils.get_current_stage() - root = _make_xform(stage, "/World/NewtonNoRB") - UsdPhysics.ArticulationRootAPI.Apply(root) # articulation root but NOT a rigid body - with pytest.raises(NotImplementedError): - _create_fixed_root_joint_newton(root, stage) + # the topology flag was honored: a fixed joint was created and the root relocated to the parent + assert any(p.IsA(UsdPhysics.FixedJoint) for p in stage.Traverse()) + parent = stage.GetPrimAtPath("/World/FromUsdA") + assert parent.HasAPI(UsdPhysics.ArticulationRootAPI) + assert not stage.GetPrimAtPath("/World/FromUsdA/base").HasAPI(UsdPhysics.ArticulationRootAPI) + + +def test_spawn_from_usd_file_none_articulation_props_honors_fix_root_link(tmp_path): + """``fix_root_link`` is a spawner-level flag processed independently of ``articulation_props``: + with ``articulation_props=None`` it must still fix the base (previously it was silently ignored).""" + from isaaclab.sim.spawners.from_files.from_files import _spawn_from_usd_file + from isaaclab.sim.spawners.from_files.from_files_cfg import UsdFileCfg + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + usd_path = os.path.join(tmp_path, "articulation.usda") + _author_articulation_usd(usd_path) + + cfg = UsdFileCfg(usd_path=usd_path, articulation_props=None, fix_root_link=True) + _spawn_from_usd_file("/World/FromUsdB", usd_path, cfg) + + stage = sim_utils.get_current_stage() + assert any(p.IsA(UsdPhysics.FixedJoint) for p in stage.Traverse()) # ------------------------------------------------------------------------------------- diff --git a/source/isaaclab_newton/isaaclab_newton/sim/schemas/__init__.py b/source/isaaclab_newton/isaaclab_newton/sim/schemas/__init__.py index a15f8904bb58..f5a65d933f51 100644 --- a/source/isaaclab_newton/isaaclab_newton/sim/schemas/__init__.py +++ b/source/isaaclab_newton/isaaclab_newton/sim/schemas/__init__.py @@ -5,51 +5,11 @@ """Sub-module containing Newton schema configuration exports.""" -from isaaclab.sim.schemas._backend_hooks import register_fixed_root_joint_creator from isaaclab.utils.module import lazy_export -from isaaclab_newton.physics import NewtonCfg - lazy_export() - -def _create_fixed_root_joint_newton(articulation_prim, stage) -> None: - """Fix an articulation base by authoring a world<->root ``UsdPhysics.FixedJoint``. - - Backend creator registered with the core articulation-root writer for the Newton backend. Newton's - importer reads a ``UsdPhysics.FixedJoint`` directly as a fixed root joint (a jointless root would - otherwise default to floating), so -- unlike PhysX -- no articulation-root relocation is needed. The - joint is authored with the backend-neutral :func:`~isaaclab.sim.schemas.create_fixed_root_joint` - helper (pure USD, no Kit/``omni`` dependency), so the Newton backend pulls in nothing PhysX-flavored. - - Args: - articulation_prim: The resolved articulation-root prim to fix to the world frame. - stage: The stage the prim lives on. - - Raises: - NotImplementedError: When the root prim is not a rigid body (the first rigid-body link cannot be - determined to anchor the fixed joint). - """ - # Imports are deferred to call time on purpose: this creator only runs while a simulation is live - # (USD available), whereas this package is imported eagerly when a config references a Newton cfg - # name. ``pxr`` and ``schemas.schemas`` (which imports ``pxr`` at its top) must stay off that eager - # path to keep config import free of the USD/Omniverse runtime. - from pxr import UsdPhysics - - from isaaclab.sim.schemas.schemas import create_fixed_root_joint - - if not articulation_prim.HasAPI(UsdPhysics.RigidBodyAPI): - raise NotImplementedError( - f"The articulation prim '{articulation_prim.GetPath().pathString}' 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_fixed_root_joint(articulation_prim, stage) - - -# Register the Newton fixed-root-joint creator with the core articulation-root writer keyed by -# ``NewtonCfg``, so the writer selects it only when Newton is the active simulation backend -# (``cfg.physics`` is a ``NewtonCfg``). This makes fixing an articulation base work in a Newton-only run -# without core carrying any backend logic. Registered on import of this package (which a caller does to -# construct Newton schema fragments). -register_fixed_root_joint_creator(NewtonCfg, _create_fixed_root_joint_newton) +# Fixing an articulation base is a backend capability on the physics manager +# (:meth:`~isaaclab.physics.PhysicsManager.fix_articulation_root`). Newton reads a +# ``UsdPhysics.FixedJoint`` on the root directly, so it inherits the base manager behaviour (author the +# neutral fixed joint, leave the root in place) without a Newton-specific override here. diff --git a/source/isaaclab_physx/isaaclab_physx/physics/physx_manager.py b/source/isaaclab_physx/isaaclab_physx/physics/physx_manager.py index d399d95546be..23034cf2fddd 100644 --- a/source/isaaclab_physx/isaaclab_physx/physics/physx_manager.py +++ b/source/isaaclab_physx/isaaclab_physx/physics/physx_manager.py @@ -303,6 +303,38 @@ def initialize(cls, sim_context: SimulationContext) -> None: omni.kit.app.get_app().update() sim.set_setting("/app/player/playSimulations", True) # type: ignore[union-attr] + @classmethod + def fix_articulation_root(cls, articulation_prim: Any, stage: Any = None) -> Any: + """Fix an articulation base for the PhysX parser and return the relocated root prim. + + The PhysX parser does not treat a fixed joint on a rigid body as a fixed-base articulation -- + it folds it into the maximal-coordinate tree. To work around that, this authors the + world<->root fixed joint and then relocates the ``UsdPhysics.ArticulationRootAPI`` anchor from + the root link to its parent prim, returning the parent as the new root. Articulation-root + fragments are applied by the caller *after* this call, so they land on the returned root; no + attribute copying is needed here. + + Args: + articulation_prim: The resolved articulation-root prim to fix to the world frame. + stage: The stage the prim lives on. Defaults to None, in which case the current stage is + used. + + Returns: + The parent prim, which becomes the articulation root after relocation. + + Raises: + NotImplementedError: When the root prim is not a rigid body. + """ + from isaaclab.sim.schemas import create_fixed_root_joint + + cls._require_rigid_body_root(articulation_prim) + create_fixed_root_joint(articulation_prim, stage) + # Relocate the articulation root to the parent so the PhysX parser recognises a fixed base. + parent_prim = articulation_prim.GetParent() + UsdPhysics.ArticulationRootAPI.Apply(parent_prim) + articulation_prim.RemoveAPI(UsdPhysics.ArticulationRootAPI) + return parent_prim + @classmethod def reset(cls, soft: bool = False) -> None: """Reset the physics simulation.""" diff --git a/source/isaaclab_physx/isaaclab_physx/sim/schemas/__init__.py b/source/isaaclab_physx/isaaclab_physx/sim/schemas/__init__.py index c64ec59e949d..d5cb72c6b81c 100644 --- a/source/isaaclab_physx/isaaclab_physx/sim/schemas/__init__.py +++ b/source/isaaclab_physx/isaaclab_physx/sim/schemas/__init__.py @@ -5,90 +5,16 @@ """Sub-module containing PhysX schema configuration exports.""" -from isaaclab.sim.schemas._backend_hooks import ( - register_fixed_root_joint_creator, - register_joint_drive_skip_predicate, -) +from isaaclab.sim.schemas._backend_hooks import register_joint_drive_skip_predicate from isaaclab.utils.module import lazy_export -from isaaclab_physx.physics import PhysxCfg - lazy_export() - -def _create_fixed_root_joint(articulation_prim, stage) -> None: - """Fix an articulation base by authoring a world<->root fixed joint (PhysX parser semantics). - - Backend creator registered with the core articulation-root writer. Creating the joint relies on - the PhysX parser, which does not treat a fixed joint on a rigid body as a fixed-base articulation; - relocating the ``UsdPhysics.ArticulationRootAPI`` (and its ``physxArticulation:*`` attributes) to - the parent prim works around that. This PhysX-specific logic lives in the PhysX package so core - carries none of it. - - Args: - articulation_prim: The resolved articulation-root prim to fix to the world frame. - stage: The stage the prim lives on. - - Raises: - NotImplementedError: When the root prim is not a rigid body (the first rigid-body link cannot - be determined to attach the fixed joint). - """ - # Imports are deferred to call time on purpose: this creator only runs while a simulation is live - # (USD available), whereas this package is imported eagerly when a config references a PhysX cfg - # name. ``pxr`` and ``schemas.schemas`` (which imports ``pxr`` at its top) must stay off that eager - # path to keep config import free of the USD/Omniverse runtime. - from pxr import UsdPhysics - - from isaaclab.sim.schemas.schemas import create_fixed_root_joint - - # note: we assume the root prim is a rigid body; there is no obvious way to get the first rigid - # body link identified by the PhysX parser when it is not. - if not articulation_prim.HasAPI(UsdPhysics.RigidBodyAPI): - raise NotImplementedError( - f"The articulation prim '{articulation_prim.GetPath().pathString}' 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 - create_fixed_root_joint(articulation_prim, stage) - - # Having a fixed joint on a rigid body is not treated as "fixed base articulation"; it is treated as - # part of the maximal coordinate tree. Moving the articulation root to the parent solves this (a - # limitation of the PhysX parser). - parent_prim = articulation_prim.GetParent() - UsdPhysics.ArticulationRootAPI.Apply(parent_prim) - if "PhysxArticulationAPI" not in parent_prim.GetAppliedSchemas(): - parent_prim.AddAppliedSchema("PhysxArticulationAPI") - - # copy the attributes to the parent - # -- 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 the api from the (former) root - articulation_prim.RemoveAppliedSchema("PhysxArticulationAPI") - articulation_prim.RemoveAPI(UsdPhysics.ArticulationRootAPI) - - -# Keep PhysX articulation-root logic out of core: register the creator with the core articulation-root -# writer keyed by ``PhysxCfg``, so the writer selects it only when PhysX is the active simulation -# backend (``cfg.physics`` is a ``PhysxCfg``). Registered on import of this package (which a caller does -# to construct PhysX schema fragments), inverting the dependency so core carries no PhysX logic. -register_fixed_root_joint_creator(PhysxCfg, _create_fixed_root_joint) +# Fixing an articulation base is a backend capability on the physics manager +# (:meth:`~isaaclab_physx.physics.PhysxManager.fix_articulation_root`): PhysX authors the fixed joint +# and relocates the articulation root to the parent prim (its parser does not treat a fixed joint on a +# rigid body as a fixed-base articulation). That PhysX-specific logic lives on the manager, so this +# schema package no longer registers a creator. def _is_physx_tendon_child(prim) -> bool: From 4dc87bb8fc0871f8dbcbdbd13b673457055f2240 Mon Sep 17 00:00:00 2001 From: Vidur Vij Date: Thu, 2 Jul 2026 14:51:20 -0700 Subject: [PATCH 16/23] Move create_fixed_root_joint to sim.utils The fixed-root-joint authoring helper lived in sim.schemas, but after the fix operation moved onto the physics manager its only callers are the managers. That left the manager reaching back into sim.schemas for the primitive, so the runtime chain was schemas.apply_articulation_root_properties -> manager.fix_articulation_root -> schemas.create_fixed_root_joint, and sim.schemas was the physics manager's sole reason to import from schemas at all. Relocate the helper to sim.utils.prims next to the other USD authoring utilities (and its find_global_fixed_joint_prim sibling), so the dependency runs one way: both sim.schemas and the physics managers depend downward on sim.utils, and the physics package no longer imports sim.schemas. The public entry point isaaclab.sim.create_fixed_root_joint is unchanged; it is dropped from isaaclab.sim.schemas. --- .../isaaclab/physics/physics_manager.py | 4 +- source/isaaclab/isaaclab/sim/__init__.pyi | 2 +- .../isaaclab/sim/schemas/__init__.pyi | 2 - .../isaaclab/isaaclab/sim/schemas/schemas.py | 54 ------------------- .../isaaclab/isaaclab/sim/utils/__init__.pyi | 2 + source/isaaclab/isaaclab/sim/utils/prims.py | 54 +++++++++++++++++++ .../test/sim/test_articulation_fragments.py | 10 ++-- .../isaaclab_physx/physics/physx_manager.py | 2 +- 8 files changed, 65 insertions(+), 65 deletions(-) diff --git a/source/isaaclab/isaaclab/physics/physics_manager.py b/source/isaaclab/isaaclab/physics/physics_manager.py index 0a12acb72292..98e375e03318 100644 --- a/source/isaaclab/isaaclab/physics/physics_manager.py +++ b/source/isaaclab/isaaclab/physics/physics_manager.py @@ -105,7 +105,7 @@ def fix_articulation_root(cls, articulation_prim: Any, stage: Any = None) -> Any Authors a world<->root fixed joint that pins the articulation base at its current world pose. The base implementation authors a backend-neutral ``UsdPhysics.FixedJoint`` (via - :func:`~isaaclab.sim.schemas.create_fixed_root_joint`) and returns ``articulation_prim`` + :func:`~isaaclab.sim.utils.create_fixed_root_joint`) and returns ``articulation_prim`` unchanged -- sufficient for backends whose parser reads a fixed joint on the root directly (e.g. Newton). Backends whose parser does not treat a fixed joint on a rigid body as a fixed-base articulation (e.g. PhysX) override this to relocate the articulation root and @@ -129,7 +129,7 @@ def fix_articulation_root(cls, articulation_prim: Any, stage: Any = None) -> Any cannot be determined to anchor the fixed joint. """ - from isaaclab.sim.schemas import create_fixed_root_joint # noqa: PLC0415 + from isaaclab.sim.utils import create_fixed_root_joint # noqa: PLC0415 cls._require_rigid_body_root(articulation_prim) create_fixed_root_joint(articulation_prim, stage) diff --git a/source/isaaclab/isaaclab/sim/__init__.pyi b/source/isaaclab/isaaclab/sim/__init__.pyi index 55e12015fc95..06bfd85cc165 100644 --- a/source/isaaclab/isaaclab/sim/__init__.pyi +++ b/source/isaaclab/isaaclab/sim/__init__.pyi @@ -266,7 +266,6 @@ from .schemas import ( UsdPhysicsRigidBodyCfg, activate_contact_sensors, apply_articulation_root_properties, - create_fixed_root_joint, apply_collision_properties, apply_fixed_tendon_properties, apply_mass_properties, @@ -377,6 +376,7 @@ from .spawners import ( spawn_sphere, ) from .utils import ( + create_fixed_root_joint, add_labels, add_reference_to_stage, add_usd_reference, diff --git a/source/isaaclab/isaaclab/sim/schemas/__init__.pyi b/source/isaaclab/isaaclab/sim/schemas/__init__.pyi index bfc4463400a2..a735bf638e1a 100644 --- a/source/isaaclab/isaaclab/sim/schemas/__init__.pyi +++ b/source/isaaclab/isaaclab/sim/schemas/__init__.pyi @@ -9,7 +9,6 @@ __all__ = [ "USD_MESH_COLLISION_CFGS", "activate_contact_sensors", "apply_articulation_root_properties", - "create_fixed_root_joint", "apply_collision_properties", "apply_fixed_tendon_properties", "apply_mass_properties", @@ -77,7 +76,6 @@ from .schemas import ( USD_MESH_COLLISION_CFGS, activate_contact_sensors, apply_articulation_root_properties, - create_fixed_root_joint, apply_collision_properties, apply_fixed_tendon_properties, apply_mass_properties, diff --git a/source/isaaclab/isaaclab/sim/schemas/schemas.py b/source/isaaclab/isaaclab/sim/schemas/schemas.py index d85e68a91e53..c2e8413f365a 100644 --- a/source/isaaclab/isaaclab/sim/schemas/schemas.py +++ b/source/isaaclab/isaaclab/sim/schemas/schemas.py @@ -378,60 +378,6 @@ def apply_articulation_root_properties( return True -def create_fixed_root_joint(articulation_prim, stage: Usd.Stage | None = None): - """Author a world<->prim ``UsdPhysics.FixedJoint`` that pins a prim at its current world pose. - - Backend-neutral USD authoring with no Kit/``omni`` dependency: it mirrors the fixed, world-anchored - joint produced by Kit's ``omni.physx`` ``createJoint`` helper -- ``body0`` is left empty (the world - frame), ``body1`` targets the prim, and the joint's world-side local frame is set to the prim's - current world transform so the constraint pins the body where it is (otherwise it would be pinned to - the world origin). The joint prim is created under the first writable (non-instanced) ancestor. - - Args: - articulation_prim: The prim to fix to the world frame. - stage: The stage the prim lives on. Defaults to None, in which case the current stage is used. - - Returns: - The created ``UsdPhysics.FixedJoint`` prim. - """ - from pxr import Gf, UsdGeom - - if stage is None: - stage = get_current_stage() - - # host the joint under the first writable ancestor (an instanced/prototype prim is not writable) - base_prim = articulation_prim - while base_prim != stage.GetPseudoRoot(): - if base_prim.IsInPrototype() or base_prim.IsInstanceProxy() or base_prim.IsInstanceable(): - base_prim = base_prim.GetParent() - else: - break - base_path = str(base_prim.GetPrimPath()) - if base_path == "/": - base_path = "" - # pick a unique child name so repeated calls do not collide - name = "FixedJoint" - if stage.GetPrimAtPath(f"{base_path}/{name}").IsValid(): - index = 0 - while stage.GetPrimAtPath(f"{base_path}/{name}{index}").IsValid(): - index += 1 - name = f"{name}{index}" - joint = UsdPhysics.FixedJoint.Define(stage, f"{base_path}/{name}") - - # body0 empty -> world; body1 -> the prim, pinned at its current world pose - world_xform = UsdGeom.XformCache().GetLocalToWorldTransform(articulation_prim).RemoveScaleShear() - joint.CreateBody1Rel().SetTargets([articulation_prim.GetPath()]) - joint.CreateLocalPos0Attr().Set(Gf.Vec3f(world_xform.ExtractTranslation())) - joint.CreateLocalRot0Attr().Set(Gf.Quatf(world_xform.ExtractRotationQuat())) - joint.CreateLocalPos1Attr().Set(Gf.Vec3f(0.0)) - joint.CreateLocalRot1Attr().Set(Gf.Quatf(1.0)) - # disable joint breaking (matches Kit's createJoint defaults; float32 max) - max_float = 3.40282347e38 - joint.CreateBreakForceAttr().Set(max_float) - joint.CreateBreakTorqueAttr().Set(max_float) - return joint.GetPrim() - - def define_articulation_root_properties( prim_path: str, cfg: schemas_cfg.ArticulationRootBaseCfg, stage: Usd.Stage | None = None ): diff --git a/source/isaaclab/isaaclab/sim/utils/__init__.pyi b/source/isaaclab/isaaclab/sim/utils/__init__.pyi index cfe39d0933e1..2046f41a7761 100644 --- a/source/isaaclab/isaaclab/sim/utils/__init__.pyi +++ b/source/isaaclab/isaaclab/sim/utils/__init__.pyi @@ -13,6 +13,7 @@ __all__ = [ "define_prim", "get_prim_type_name", "get_next_free_path", + "create_fixed_root_joint", "create_prim", "delete_prim", "make_uninstanceable", @@ -73,6 +74,7 @@ from .legacy import ( get_next_free_path, ) from .prims import ( + create_fixed_root_joint, create_prim, delete_prim, make_uninstanceable, diff --git a/source/isaaclab/isaaclab/sim/utils/prims.py b/source/isaaclab/isaaclab/sim/utils/prims.py index d0f0e8d9042a..b52e5ea1500c 100644 --- a/source/isaaclab/isaaclab/sim/utils/prims.py +++ b/source/isaaclab/isaaclab/sim/utils/prims.py @@ -186,6 +186,60 @@ def create_prim( return prim +def create_fixed_root_joint(articulation_prim, stage: Usd.Stage | None = None): + """Author a world<->prim ``UsdPhysics.FixedJoint`` that pins a prim at its current world pose. + + Backend-neutral USD authoring with no Kit/``omni`` dependency: it mirrors the fixed, world-anchored + joint produced by Kit's ``omni.physx`` ``createJoint`` helper -- ``body0`` is left empty (the world + frame), ``body1`` targets the prim, and the joint's world-side local frame is set to the prim's + current world transform so the constraint pins the body where it is (otherwise it would be pinned to + the world origin). The joint prim is created under the first writable (non-instanced) ancestor. + + Args: + articulation_prim: The prim to fix to the world frame. + stage: The stage the prim lives on. Defaults to None, in which case the current stage is used. + + Returns: + The created ``UsdPhysics.FixedJoint`` prim. + """ + from pxr import Gf, UsdGeom, UsdPhysics + + if stage is None: + stage = get_current_stage() + + # host the joint under the first writable ancestor (an instanced/prototype prim is not writable) + base_prim = articulation_prim + while base_prim != stage.GetPseudoRoot(): + if base_prim.IsInPrototype() or base_prim.IsInstanceProxy() or base_prim.IsInstanceable(): + base_prim = base_prim.GetParent() + else: + break + base_path = str(base_prim.GetPrimPath()) + if base_path == "/": + base_path = "" + # pick a unique child name so repeated calls do not collide + name = "FixedJoint" + if stage.GetPrimAtPath(f"{base_path}/{name}").IsValid(): + index = 0 + while stage.GetPrimAtPath(f"{base_path}/{name}{index}").IsValid(): + index += 1 + name = f"{name}{index}" + joint = UsdPhysics.FixedJoint.Define(stage, f"{base_path}/{name}") + + # body0 empty -> world; body1 -> the prim, pinned at its current world pose + world_xform = UsdGeom.XformCache().GetLocalToWorldTransform(articulation_prim).RemoveScaleShear() + joint.CreateBody1Rel().SetTargets([articulation_prim.GetPath()]) + joint.CreateLocalPos0Attr().Set(Gf.Vec3f(world_xform.ExtractTranslation())) + joint.CreateLocalRot0Attr().Set(Gf.Quatf(world_xform.ExtractRotationQuat())) + joint.CreateLocalPos1Attr().Set(Gf.Vec3f(0.0)) + joint.CreateLocalRot1Attr().Set(Gf.Quatf(1.0)) + # disable joint breaking (matches Kit's createJoint defaults; float32 max) + max_float = 3.40282347e38 + joint.CreateBreakForceAttr().Set(max_float) + joint.CreateBreakTorqueAttr().Set(max_float) + return joint.GetPrim() + + def delete_prim(prim_path: str | Sequence[str], stage: Usd.Stage | None = None) -> bool: """Removes the USD Prim and its descendants from the scene if able. diff --git a/source/isaaclab/test/sim/test_articulation_fragments.py b/source/isaaclab/test/sim/test_articulation_fragments.py index 2e92548d50bb..0516d4741592 100644 --- a/source/isaaclab/test/sim/test_articulation_fragments.py +++ b/source/isaaclab/test/sim/test_articulation_fragments.py @@ -348,7 +348,7 @@ def test_apply_articulation_root_properties_honors_explicit_stage(): def test_create_fixed_root_joint_authors_world_anchored_joint(): """create_fixed_root_joint authors a world<->prim ``UsdPhysics.FixedJoint``: ``body1`` targets the prim, ``body0`` is left empty (the world frame), and the body1-side local frame is identity.""" - from isaaclab.sim.schemas import create_fixed_root_joint + from isaaclab.sim.utils import create_fixed_root_joint sim_utils.create_new_stage() SimulationContext(SimulationCfg(dt=0.01)) @@ -370,7 +370,7 @@ def test_create_fixed_root_joint_authors_world_anchored_joint(): def test_create_fixed_root_joint_pins_prim_at_current_world_pose(): """The world-side local frame is set to the prim's current world transform, so the constraint pins the body where it is rather than teleporting it to the world origin.""" - from isaaclab.sim.schemas import create_fixed_root_joint + from isaaclab.sim.utils import create_fixed_root_joint sim_utils.create_new_stage() SimulationContext(SimulationCfg(dt=0.01)) @@ -385,7 +385,7 @@ def test_create_fixed_root_joint_pins_prim_at_current_world_pose(): def test_create_fixed_root_joint_uses_unique_names(): """Repeated calls author distinct joint prims (no path collision).""" - from isaaclab.sim.schemas import create_fixed_root_joint + from isaaclab.sim.utils import create_fixed_root_joint sim_utils.create_new_stage() SimulationContext(SimulationCfg(dt=0.01)) @@ -459,7 +459,7 @@ def test_fix_articulation_root_capability_inherited_by_manager_subclass(): class _BackendLike(PhysicsManager): @classmethod def fix_articulation_root(cls, articulation_prim, stage=None): - from isaaclab.sim.schemas import create_fixed_root_joint + from isaaclab.sim.utils import create_fixed_root_joint cls._require_rigid_body_root(articulation_prim) create_fixed_root_joint(articulation_prim, stage) @@ -566,5 +566,5 @@ def test_public_imports(): ArticulationRootFragment, SchemaFragment, apply_articulation_root_properties, - create_fixed_root_joint, ) + from isaaclab.sim.utils import create_fixed_root_joint # noqa: F401 diff --git a/source/isaaclab_physx/isaaclab_physx/physics/physx_manager.py b/source/isaaclab_physx/isaaclab_physx/physics/physx_manager.py index 23034cf2fddd..3e0f43628ba3 100644 --- a/source/isaaclab_physx/isaaclab_physx/physics/physx_manager.py +++ b/source/isaaclab_physx/isaaclab_physx/physics/physx_manager.py @@ -325,7 +325,7 @@ def fix_articulation_root(cls, articulation_prim: Any, stage: Any = None) -> Any Raises: NotImplementedError: When the root prim is not a rigid body. """ - from isaaclab.sim.schemas import create_fixed_root_joint + from isaaclab.sim.utils import create_fixed_root_joint cls._require_rigid_body_root(articulation_prim) create_fixed_root_joint(articulation_prim, stage) From 3b9cf1cebf09b520bdb75d818c0971440cdbf381 Mon Sep 17 00:00:00 2001 From: Vidur Vij Date: Thu, 2 Jul 2026 15:08:13 -0700 Subject: [PATCH 17/23] Migrate pre-authored root schemas on PhysX relocation Writing fragments after the PhysX root relocation keeps supplied fragments on the resulting root, but asset-authored schemas were left behind. URDF/MJCF-imported assets can ship with a backend root API (e.g. NewtonArticulationRootAPI) already authored on the root link; that schema composes PhysicsArticulationRootAPI, so removing only the directly applied anchor left the former root link a second articulation root, with its authored values stranded on the wrong prim. Pre-authored PhysxArticulationAPI solver attributes were likewise dropped instead of moving with the root as the legacy writer did. Migrate them in the PhysX relocation: any directly authored API schema that the USD schema registry reports as composing PhysicsArticulationRootAPI moves to the parent together with its authored attributes (backend-agnostic detection, no backend names), and the PhysxArticulationAPI companion migrates the same way. Regression tests verified to fail without the migration. Also harden the fragment writer: reject non-fragment list items with a clear TypeError instead of an AttributeError deep in dispatch, gate the define-fresh ArticulationRootAPI anchor on fragments actually being supplied so a topology-only call cannot stamp a root as a side effect (the flag is ignored with a warning when no root exists), and warn when a spawner-level fix_root_link is ignored because articulation_props is a legacy cfg that owns its own flag. --- .../isaaclab/isaaclab/sim/schemas/schemas.py | 77 ++++++++++----- .../isaaclab/sim/schemas/schemas_cfg.py | 2 +- .../sim/spawners/from_files/from_files.py | 6 ++ .../test/sim/test_articulation_fragments.py | 99 ++++++++++++++++++- .../isaaclab_physx/physics/physx_manager.py | 59 ++++++++++- 5 files changed, 212 insertions(+), 31 deletions(-) diff --git a/source/isaaclab/isaaclab/sim/schemas/schemas.py b/source/isaaclab/isaaclab/sim/schemas/schemas.py index c2e8413f365a..e16a5a875c4d 100644 --- a/source/isaaclab/isaaclab/sim/schemas/schemas.py +++ b/source/isaaclab/isaaclab/sim/schemas/schemas.py @@ -318,12 +318,24 @@ def apply_articulation_root_properties( True if the properties were successfully set. Raises: + TypeError: When an item in ``fragments`` is not an + :class:`~isaaclab.sim.schemas.ArticulationRootFragment` (e.g. a legacy single cfg, which + goes through :func:`modify_articulation_root_properties` instead). ValueError: When the prim path is not valid. RuntimeError: When ``fix_root_link`` is True and a fixed joint must be created but there is no active :class:`~isaaclab.sim.SimulationContext` to resolve the physics backend from. NotImplementedError: Propagated from the backend capability when it cannot create the fixed joint (e.g. the resolved root prim is not a rigid body). """ + # validate early: a clear error beats an ``AttributeError`` deep inside fragment dispatch + fragments = list(fragments) + for frag in fragments: + if not isinstance(frag, schemas_cfg.ArticulationRootFragment): + raise TypeError( + "apply_articulation_root_properties: expected ArticulationRootFragment instances in" + f" 'fragments', got '{type(frag).__name__}'. Pass a legacy single cfg to" + " modify_articulation_root_properties instead." + ) if stage is None: stage = get_current_stage() # resolve the existing root (it may live on a child prim in USD assets); instance proxies can't be @@ -334,8 +346,10 @@ def apply_articulation_root_properties( stage, traverse_instance_prims=False, ) - # no existing root in the subtree: define one on the input prim - if articulation_prim is None: + # No existing root in the subtree: define one on the input prim -- but only when fragments are + # supplied (the anchor is presence-gated). A topology-only call (empty fragments, just + # ``fix_root_link``) must not stamp an articulation root as a side effect. + if articulation_prim is None and fragments: articulation_prim = stage.GetPrimAtPath(prim_path) UsdPhysics.ArticulationRootAPI.Apply(articulation_prim) @@ -345,35 +359,44 @@ def apply_articulation_root_properties( # former child and leave two articulation roots. final_root = articulation_prim if fix_root_link is not None: - root_path = articulation_prim.GetPath().pathString - # honor an explicit stage so the lookup does not fall back to the global current stage - existing_fixed_joint_prim = find_global_fixed_joint_prim(root_path, stage=stage) - if existing_fixed_joint_prim is not None: - # a joint already exists: just enable/disable it in place (no relocation) - logger.info( - f"Found an existing fixed joint for the articulation: '{root_path}'. Setting it to: {fix_root_link}." + if articulation_prim is None: + logger.warning( + f"No articulation root found under '{prim_path}': ignoring fix_root_link={fix_root_link}." + " The flag only applies to articulations." ) - existing_fixed_joint_prim.GetJointEnabledAttr().Set(fix_root_link) - elif fix_root_link: - logger.info(f"Creating a fixed joint for the articulation: '{root_path}'.") - # Creating (and possibly relocating) the root is backend-specific. Delegate to the active - # backend's manager capability so core carries no backend logic; the capability returns the - # resulting root prim. - from isaaclab.sim import SimulationContext - - sim = SimulationContext.instance() - if sim is None: - raise RuntimeError( - f"Cannot fix the articulation root '{root_path}': no active SimulationContext to" - " resolve the physics backend from. A simulation must be running to fix a base." + else: + root_path = articulation_prim.GetPath().pathString + # honor an explicit stage so the lookup does not fall back to the global current stage + existing_fixed_joint_prim = find_global_fixed_joint_prim(root_path, stage=stage) + if existing_fixed_joint_prim is not None: + # a joint already exists: just enable/disable it in place (no relocation) + logger.info( + f"Found an existing fixed joint for the articulation: '{root_path}'. Setting it to:" + f" {fix_root_link}." ) - final_root = sim.physics_manager.fix_articulation_root(articulation_prim, stage) + existing_fixed_joint_prim.GetJointEnabledAttr().Set(fix_root_link) + elif fix_root_link: + logger.info(f"Creating a fixed joint for the articulation: '{root_path}'.") + # Creating (and possibly relocating) the root is backend-specific. Delegate to the + # active backend's manager capability so core carries no backend logic; the capability + # returns the resulting root prim. + from isaaclab.sim import SimulationContext + + sim = SimulationContext.instance() + if sim is None: + raise RuntimeError( + f"Cannot fix the articulation root '{root_path}': no active SimulationContext" + " to resolve the physics backend from. A simulation must be running to fix a" + " base." + ) + final_root = sim.physics_manager.fix_articulation_root(articulation_prim, stage) # dispatch each fragment to the resolved (possibly relocated) root via its own applier - final_root_path = final_root.GetPath().pathString - for cfg in fragments: - func = cfg.func if callable(cfg.func) else string_to_callable(cfg.func) - func(cfg, final_root_path, stage) + if fragments: + final_root_path = final_root.GetPath().pathString + for cfg in fragments: + func = cfg.func if callable(cfg.func) else string_to_callable(cfg.func) + func(cfg, final_root_path, stage) return True diff --git a/source/isaaclab/isaaclab/sim/schemas/schemas_cfg.py b/source/isaaclab/isaaclab/sim/schemas/schemas_cfg.py index ab9f35998f1f..797b907ed551 100644 --- a/source/isaaclab/isaaclab/sim/schemas/schemas_cfg.py +++ b/source/isaaclab/isaaclab/sim/schemas/schemas_cfg.py @@ -192,7 +192,7 @@ class ArticulationRootFragment(SchemaFragment): ``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). + :func:`~isaaclab.sim.schemas.modify_articulation_root_properties` behaviour). """ pass 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 fb05a60e2f99..356d91a3053b 100644 --- a/source/isaaclab/isaaclab/sim/spawners/from_files/from_files.py +++ b/source/isaaclab/isaaclab/sim/spawners/from_files/from_files.py @@ -381,6 +381,12 @@ def _spawn_from_usd_file( and not isinstance(articulation_props, (list, tuple)) and not isinstance(articulation_props, schemas.SchemaFragment) ): + if articulation_fix_root_link is not None: + logger.warning( + f"Ignoring the spawner-level 'fix_root_link={articulation_fix_root_link}' because" + " 'articulation_props' is a legacy cfg, which owns its own 'fix_root_link' field. Set" + " it on that cfg instead." + ) schemas.modify_articulation_root_properties(prim_path, articulation_props) else: articulation_frags = ( diff --git a/source/isaaclab/test/sim/test_articulation_fragments.py b/source/isaaclab/test/sim/test_articulation_fragments.py index 0516d4741592..31403ad387f2 100644 --- a/source/isaaclab/test/sim/test_articulation_fragments.py +++ b/source/isaaclab/test/sim/test_articulation_fragments.py @@ -16,7 +16,7 @@ import pytest -from pxr import Gf, Usd, UsdGeom, UsdPhysics +from pxr import Gf, Sdf, Usd, UsdGeom, UsdPhysics import isaaclab.sim as sim_utils from isaaclab.sim import SimulationCfg, SimulationContext @@ -313,6 +313,103 @@ def test_physx_and_newton_fragments_fix_root_link_keeps_single_root(): assert not child.GetAttribute("newton:selfCollisionEnabled").HasAuthoredValue() +def test_physx_fix_root_link_migrates_preauthored_newton_root_api(): + """A pre-authored backend root API (as on URDF/MJCF-imported assets) moves with the root when + PhysX relocates it, together with its authored attributes. + + Regression: ``NewtonArticulationRootAPI`` composes ``PhysicsArticulationRootAPI``, so removing + only the directly applied anchor from the former root link leaves that prim an articulation root + through schema composition -- two roots -- with the ``newton:*`` values stranded on the wrong prim. + """ + 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/UrdfBot") + child = _make_xform(stage, "/World/UrdfBot/base") + UsdPhysics.RigidBodyAPI.Apply(child) + # the asset ships with the Newton root API (and its attribute) already authored on the root link + child.AddAppliedSchema("NewtonArticulationRootAPI") + child.CreateAttribute("newton:selfCollisionEnabled", Sdf.ValueTypeNames.Bool).Set(True) + # precondition: the composed schema makes the prim an articulation root without a direct anchor + assert child.HasAPI(UsdPhysics.ArticulationRootAPI) + + apply_articulation_root_properties("/World/UrdfBot", [], stage, fix_root_link=True) + + parent = stage.GetPrimAtPath("/World/UrdfBot") + # exactly one articulation root remains, and it is the (relocated) parent + roots = [p for p in stage.Traverse() if p.HasAPI(UsdPhysics.ArticulationRootAPI)] + assert len(roots) == 1 and roots[0] == parent + # the pre-authored backend schema and its value moved with the root + assert "NewtonArticulationRootAPI" in parent.GetAppliedSchemas() + assert parent.GetAttribute("newton:selfCollisionEnabled").Get() is True + # the former root link no longer carries the composed root API + assert not child.HasAPI(UsdPhysics.ArticulationRootAPI) + + +def test_physx_fix_root_link_migrates_preauthored_physx_attrs(): + """Asset-authored ``PhysxArticulationAPI`` attributes move to the relocated root (legacy parity): + the legacy writer copied ``physxArticulation:*`` values to the parent, so the fragment path must + not silently drop them on the former root link where the PhysX parser ignores them.""" + 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/UsdBot") + child = _make_xform(stage, "/World/UsdBot/base") + UsdPhysics.RigidBodyAPI.Apply(child) + UsdPhysics.ArticulationRootAPI.Apply(child) + # the asset ships with PhysX articulation solver settings authored on the root link + child.AddAppliedSchema("PhysxArticulationAPI") + child.CreateAttribute("physxArticulation:solverVelocityIterationCount", Sdf.ValueTypeNames.Int).Set(4) + + apply_articulation_root_properties("/World/UsdBot", [], stage, fix_root_link=True) + + parent = stage.GetPrimAtPath("/World/UsdBot") + assert parent.HasAPI(UsdPhysics.ArticulationRootAPI) + assert "PhysxArticulationAPI" in parent.GetAppliedSchemas() + assert parent.GetAttribute("physxArticulation:solverVelocityIterationCount").Get() == 4 + + +def test_apply_articulation_root_properties_rejects_non_fragment_items(): + """A list containing a non-fragment (e.g. a legacy single cfg) raises a clear ``TypeError`` -- + not an ``AttributeError`` deep inside fragment dispatch.""" + from isaaclab_physx.sim.schemas import PhysxArticulationRootPropertiesCfg + + 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/BadList") + with pytest.raises(TypeError, match="ArticulationRootFragment"): + apply_articulation_root_properties( + "/World/BadList", + [PhysxArticulationRootPropertiesCfg(solver_position_iteration_count=8)], + stage, + ) + + +def test_apply_articulation_root_properties_topology_only_does_not_stamp_root(): + """A topology-only call (empty fragments) on a prim with no articulation root anywhere must not + apply ``UsdPhysics.ArticulationRootAPI`` as a side effect -- the anchor is presence-gated on + fragments. The topology flag is ignored with a warning instead.""" + 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() + prim = _make_xform(stage, "/World/NoRoot") + UsdPhysics.RigidBodyAPI.Apply(prim) + + apply_articulation_root_properties("/World/NoRoot", [], stage, fix_root_link=False) + + assert not prim.HasAPI(UsdPhysics.ArticulationRootAPI) + assert not any(p.HasAPI(UsdPhysics.ArticulationRootAPI) for p in stage.Traverse()) + + def test_apply_articulation_root_properties_honors_explicit_stage(): """The writer authors to the explicitly supplied stage even when it is not the current stage -- every lookup (root resolution, fixed-joint search) is scoped to that stage.""" diff --git a/source/isaaclab_physx/isaaclab_physx/physics/physx_manager.py b/source/isaaclab_physx/isaaclab_physx/physics/physx_manager.py index 3e0f43628ba3..0889ccb286da 100644 --- a/source/isaaclab_physx/isaaclab_physx/physics/physx_manager.py +++ b/source/isaaclab_physx/isaaclab_physx/physics/physx_manager.py @@ -311,8 +311,15 @@ def fix_articulation_root(cls, articulation_prim: Any, stage: Any = None) -> Any it folds it into the maximal-coordinate tree. To work around that, this authors the world<->root fixed joint and then relocates the ``UsdPhysics.ArticulationRootAPI`` anchor from the root link to its parent prim, returning the parent as the new root. Articulation-root - fragments are applied by the caller *after* this call, so they land on the returned root; no - attribute copying is needed here. + fragments are applied by the caller *after* this call, so they land on the returned root. + + Asset-authored root schemas move with the root: any API schema directly authored on the former + root that composes ``PhysicsArticulationRootAPI`` (e.g. Newton's root API on URDF/MJCF-imported + assets) is migrated to the parent together with its authored attributes -- removing only the + directly applied anchor would leave such a schema behind and the former root would remain a + second articulation root through schema composition. The ``PhysxArticulationAPI`` companion + and its ``physxArticulation:*`` attributes are migrated the same way, matching the legacy + writer's relocation. Args: articulation_prim: The resolved articulation-root prim to fix to the world frame. @@ -333,8 +340,56 @@ def fix_articulation_root(cls, articulation_prim: Any, stage: Any = None) -> Any parent_prim = articulation_prim.GetParent() UsdPhysics.ArticulationRootAPI.Apply(parent_prim) articulation_prim.RemoveAPI(UsdPhysics.ArticulationRootAPI) + cls._migrate_authored_root_schemas(articulation_prim, parent_prim) return parent_prim + @staticmethod + def _migrate_authored_root_schemas(old_root: Usd.Prim, new_root: Usd.Prim) -> None: + """Move asset-authored articulation-root API schemas from the former root to the new root. + + Detection is backend-agnostic: a directly authored API schema is migrated when the USD schema + registry reports that it composes ``PhysicsArticulationRootAPI`` (so e.g. Newton's root API is + recognised without naming it). The PhysX ``PhysxArticulationAPI`` companion is migrated too -- + it does not compose the root API but carries the root's solver attributes. For each migrated + schema, its authored attributes are copied to the new root (attribute values already authored + on the new root are not overwritten by schema defaults; only authored values are copied). + + Args: + old_root: The former articulation-root prim (the root link being fixed). + new_root: The prim taking over the articulation-root role (the parent). + """ + registry = Usd.SchemaRegistry() + new_root_applied = set(new_root.GetPrimTypeInfo().GetAppliedAPISchemas()) + for schema_name in list(old_root.GetPrimTypeInfo().GetAppliedAPISchemas()): + # multi-apply instances carry an ":instance" suffix; the registry keys on the base name + base_name = schema_name.split(":", 1)[0] + prim_def = registry.FindAppliedAPIPrimDefinition(base_name) + composes_root = prim_def is not None and "PhysicsArticulationRootAPI" in prim_def.GetAppliedAPISchemas() + if not composes_root and base_name != "PhysxArticulationAPI": + continue + # move the applied schema to the new root + old_root.RemoveAppliedSchema(schema_name) + if schema_name not in new_root_applied: + new_root.AddAppliedSchema(schema_name) + new_root_applied.add(schema_name) + # copy the schema's authored attributes (property names come from the schema definition; + # fall back to the namespace prefix when the schema is not registered) + if prim_def is not None: + prop_names = list(prim_def.GetPropertyNames()) + else: + prefix = "physxArticulation:" if base_name == "PhysxArticulationAPI" else None + prop_names = [ + a.GetName() for a in old_root.GetAttributes() if prefix and a.GetName().startswith(prefix) + ] + for prop_name in prop_names: + attr = old_root.GetAttribute(prop_name) + if not attr or not attr.HasAuthoredValue(): + continue + new_attr = new_root.GetAttribute(prop_name) + if not new_attr: + new_attr = new_root.CreateAttribute(prop_name, attr.GetTypeName()) + new_attr.Set(attr.Get()) + @classmethod def reset(cls, soft: bool = False) -> None: """Reset the physics simulation.""" From d55e0ed817054478ab67847713d22b106a62f5ff Mon Sep 17 00:00:00 2001 From: Octi Zhang Date: Fri, 3 Jul 2026 22:49:39 -0700 Subject: [PATCH 18/23] Fix articulation-root fragment integration boundaries --- .../isaaclab/physics/physics_manager.py | 164 ++++++-- source/isaaclab/isaaclab/sim/__init__.pyi | 2 - .../isaaclab/isaaclab/sim/schemas/schemas.py | 195 +++++----- .../sim/spawners/from_files/from_files.py | 2 +- .../sim/spawners/from_files/from_files_cfg.py | 5 +- .../isaaclab/isaaclab/sim/utils/__init__.pyi | 2 - source/isaaclab/isaaclab/sim/utils/prims.py | 54 --- .../test/sim/test_articulation_fragments.py | 355 ++++++++++-------- .../isaaclab_newton/sim/schemas/__init__.py | 5 - .../sim/schemas/schemas_cfg.py | 1 - .../vidurv-schema-frag-articulation.rst | 6 + .../physics/ovphysx_manager.py | 28 ++ .../test/assets/test_articulation.py | 43 +++ .../isaaclab_physx/physics/physx_manager.py | 83 +--- .../isaaclab_physx/sim/schemas/__init__.py | 5 - 15 files changed, 542 insertions(+), 408 deletions(-) create mode 100644 source/isaaclab_ovphysx/changelog.d/vidurv-schema-frag-articulation.rst diff --git a/source/isaaclab/isaaclab/physics/physics_manager.py b/source/isaaclab/isaaclab/physics/physics_manager.py index 98e375e03318..4a818c16d7de 100644 --- a/source/isaaclab/isaaclab/physics/physics_manager.py +++ b/source/isaaclab/isaaclab/physics/physics_manager.py @@ -103,13 +103,12 @@ def provides_implicit_damping(cls) -> bool: def fix_articulation_root(cls, articulation_prim: Any, stage: Any = None) -> Any: """Fix an articulation base to the world frame and return the resulting root prim. - Authors a world<->root fixed joint that pins the articulation base at its current world pose. - The base implementation authors a backend-neutral ``UsdPhysics.FixedJoint`` (via - :func:`~isaaclab.sim.utils.create_fixed_root_joint`) and returns ``articulation_prim`` - unchanged -- sufficient for backends whose parser reads a fixed joint on the root directly - (e.g. Newton). Backends whose parser does not treat a fixed joint on a rigid body as a - fixed-base articulation (e.g. PhysX) override this to relocate the articulation root and - return the new root prim, so the caller applies articulation-root schemas to the actual root. + Ensures that a world-to-root fixed joint exists and is enabled. The operation is idempotent: + an existing disabled joint is enabled rather than duplicated. The base implementation leaves + ``articulation_prim`` unchanged, which is sufficient for backends whose parser reads a fixed + joint on the root directly (e.g. Newton). Backends whose parser requires the articulation-root + schema on the root link's parent override this method, call ``super()``, then relocate the root + and return the resulting prim. This is a backend capability rather than a registry keyed by cfg type: the active backend's manager class is resolved from ``cfg.physics.class_type``, so subclassed cfgs and every @@ -129,34 +128,151 @@ def fix_articulation_root(cls, articulation_prim: Any, stage: Any = None) -> Any cannot be determined to anchor the fixed joint. """ - from isaaclab.sim.utils import create_fixed_root_joint # noqa: PLC0415 + from isaaclab.sim.utils import find_global_fixed_joint_prim # noqa: PLC0415 + from isaaclab.sim.utils.stage import get_current_stage # noqa: PLC0415 - cls._require_rigid_body_root(articulation_prim) - create_fixed_root_joint(articulation_prim, stage) + if stage is None: + stage = get_current_stage() + existing_joint = find_global_fixed_joint_prim(articulation_prim.GetPath().pathString, stage=stage) + if existing_joint is not None: + existing_joint.GetJointEnabledAttr().Set(True) + return articulation_prim + + cls._create_fixed_root_joint(articulation_prim, stage) return articulation_prim @staticmethod - def _require_rigid_body_root(articulation_prim: Any) -> None: - """Raise ``NotImplementedError`` when the articulation root is not a rigid body. - - A fixed root joint is anchored to the first rigid-body link of the articulation; when the - resolved root carries no ``RigidBodyAPI`` that link cannot be determined. - - Args: - articulation_prim: The resolved articulation-root prim. - - Raises: - NotImplementedError: When the prim does not have ``UsdPhysics.RigidBodyAPI`` applied. - """ - from pxr import UsdPhysics # noqa: PLC0415 + def _create_fixed_root_joint(articulation_prim: Any, stage: Any) -> Any: + """Author a world-to-root fixed joint at the articulation's current world pose.""" + from pxr import Gf, UsdGeom, UsdPhysics # noqa: PLC0415 + root_path = articulation_prim.GetPath().pathString if not articulation_prim.HasAPI(UsdPhysics.RigidBodyAPI): raise NotImplementedError( - f"The articulation prim '{articulation_prim.GetPath().pathString}' does not have the" + 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." ) + joint_parent = articulation_prim + while joint_parent != stage.GetPseudoRoot(): + if joint_parent.IsInPrototype() or joint_parent.IsInstanceProxy() or joint_parent.IsInstanceable(): + joint_parent = joint_parent.GetParent() + else: + break + parent_path = joint_parent.GetPath().pathString + if parent_path == "/": + parent_path = "" + joint_name = "FixedJoint" + if stage.GetPrimAtPath(f"{parent_path}/{joint_name}").IsValid(): + index = 0 + while stage.GetPrimAtPath(f"{parent_path}/{joint_name}{index}").IsValid(): + index += 1 + joint_name = f"{joint_name}{index}" + + world_xform = UsdGeom.XformCache().GetLocalToWorldTransform(articulation_prim).RemoveScaleShear() + joint = UsdPhysics.FixedJoint.Define(stage, f"{parent_path}/{joint_name}") + joint.CreateBody1Rel().SetTargets([articulation_prim.GetPath()]) + joint.CreateLocalPos0Attr().Set(Gf.Vec3f(world_xform.ExtractTranslation())) + joint.CreateLocalRot0Attr().Set(Gf.Quatf(world_xform.ExtractRotationQuat())) + joint.CreateLocalPos1Attr().Set(Gf.Vec3f(0.0)) + joint.CreateLocalRot1Attr().Set(Gf.Quatf(1.0)) + max_float = 3.40282347e38 + joint.CreateBreakForceAttr().Set(max_float) + joint.CreateBreakTorqueAttr().Set(max_float) + return joint.GetPrim() + + @staticmethod + def _relocate_articulation_root( + articulation_prim: Any, + companion_schema_namespaces: dict[str, str] | None = None, + ) -> Any: + """Move an articulation-root schema family to the root link's parent. + + The pure-USD operation is shared by backends whose parser requires parent-root topology. + Every applied API that composes ``PhysicsArticulationRootAPI`` is moved, together with any + backend companion schemas named by ``companion_schema_namespaces``. Authored properties are + flattened before the old schemas are removed, preserving defaults, time samples, metadata, + connections, and relationship targets even when opinions come through a referenced asset. + """ + from pxr import Usd, UsdPhysics # noqa: PLC0415 + + companion_schema_namespaces = companion_schema_namespaces or {} + if not articulation_prim.HasAPI(UsdPhysics.ArticulationRootAPI): + raise ValueError( + f"Prim '{articulation_prim.GetPath()}' is not an articulation root and cannot be relocated." + ) + new_root = articulation_prim.GetParent() + if new_root.HasAPI(UsdPhysics.ArticulationRootAPI): + raise RuntimeError( + f"Cannot relocate articulation root '{articulation_prim.GetPath()}' to" + f" '{new_root.GetPath()}': the destination is already an articulation root." + " Relocation would merge distinct articulations." + ) + registry = Usd.SchemaRegistry() + root_schema_name = UsdPhysics.Tokens.PhysicsArticulationRootAPI + + schemas_to_move: list[tuple[str, list[Any]]] = [] + has_root_schema = False + for schema_name in articulation_prim.GetPrimTypeInfo().GetAppliedAPISchemas(): + base_name, instance_name = registry.GetTypeNameAndInstance(schema_name) + prim_def = registry.FindAppliedAPIPrimDefinition(base_name) + is_root_anchor = base_name == root_schema_name + composes_root = prim_def is not None and root_schema_name in prim_def.GetAppliedAPISchemas() + if not is_root_anchor and not composes_root and base_name not in companion_schema_namespaces: + continue + if is_root_anchor or composes_root: + has_root_schema = True + + property_names = list(prim_def.GetPropertyNames()) if prim_def is not None else [] + if instance_name: + property_names = [ + registry.MakeMultipleApplyNameInstance(name, instance_name) for name in property_names + ] + fallback_namespace = companion_schema_namespaces.get(base_name) + if fallback_namespace is not None: + property_names.extend( + prop.GetName() for prop in articulation_prim.GetAuthoredPropertiesInNamespace(fallback_namespace) + ) + properties = [] + for property_name in dict.fromkeys(property_names): + prop = articulation_prim.GetProperty(property_name) + if prop and prop.IsAuthored(): + properties.append(prop) + schemas_to_move.append((schema_name, properties)) + if not has_root_schema: + raise RuntimeError( + f"Cannot relocate articulation root '{articulation_prim.GetPath()}': its root API is" + " built in or auto-applied, so there is no movable root-schema opinion." + ) + + new_root_schemas = set(new_root.GetPrimTypeInfo().GetAppliedAPISchemas()) + for schema_name, properties in schemas_to_move: + if schema_name not in new_root_schemas: + if not new_root.AddAppliedSchema(schema_name): + raise RuntimeError(f"Failed to apply schema '{schema_name}' to '{new_root.GetPath()}'.") + new_root_schemas.add(schema_name) + for prop in properties: + if not prop.FlattenTo(new_root): + raise RuntimeError( + f"Failed to move property '{prop.GetPath()}' to articulation root '{new_root.GetPath()}'." + ) + + for schema_name, _ in schemas_to_move: + if not articulation_prim.RemoveAppliedSchema(schema_name): + raise RuntimeError( + f"Failed to remove schema '{schema_name}' from old articulation root" + f" '{articulation_prim.GetPath()}'." + ) + if not new_root.HasAPI(UsdPhysics.ArticulationRootAPI) or articulation_prim.HasAPI( + UsdPhysics.ArticulationRootAPI + ): + raise RuntimeError( + f"Relocating articulation root '{articulation_prim.GetPath()}' to '{new_root.GetPath()}'" + " did not produce exactly one root at the destination." + ) + return new_root + @classmethod def register_callback( cls, diff --git a/source/isaaclab/isaaclab/sim/__init__.pyi b/source/isaaclab/isaaclab/sim/__init__.pyi index 604a1b85b081..1b52e8f6cfcf 100644 --- a/source/isaaclab/isaaclab/sim/__init__.pyi +++ b/source/isaaclab/isaaclab/sim/__init__.pyi @@ -72,7 +72,6 @@ __all__ = [ "UsdPhysicsMeshCollisionCfg", "UsdPhysicsRigidBodyCfg", "apply_articulation_root_properties", - "create_fixed_root_joint", "apply_collision_properties", "apply_fixed_tendon_properties", "apply_mass_properties", @@ -375,7 +374,6 @@ from .spawners import ( spawn_sphere, ) from .utils import ( - create_fixed_root_joint, add_labels, add_reference_to_stage, add_usd_reference, diff --git a/source/isaaclab/isaaclab/sim/schemas/schemas.py b/source/isaaclab/isaaclab/sim/schemas/schemas.py index e16a5a875c4d..309dd8cc9fbd 100644 --- a/source/isaaclab/isaaclab/sim/schemas/schemas.py +++ b/source/isaaclab/isaaclab/sim/schemas/schemas.py @@ -24,7 +24,6 @@ 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, ) @@ -278,127 +277,131 @@ def apply_namespaced(cfg: schemas_cfg.SchemaFragment, prim_path: str, stage: Usd def apply_articulation_root_properties( prim_path: str, - fragments, + fragments: Iterable[schemas_cfg.ArticulationRootFragment], stage: Usd.Stage | None = None, fix_root_link: bool | None = None, ) -> bool: - """Apply a list of articulation-root fragments to a prim. - - 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. - - When ``fix_root_link`` is not ``None`` the base is fixed to the world *before* any fragment is - written: an existing world-to-root fixed joint is enabled/disabled, otherwise (when True) one is - created via the active backend's - :meth:`~isaaclab.physics.PhysicsManager.fix_articulation_root` capability. That capability returns - the resulting root prim (some backends relocate it, e.g. PhysX moves the root to the parent), and - every fragment is then dispatched to *that* root via its - :attr:`~isaaclab.sim.schemas.SchemaFragment.func`. Writing fragments after the (possibly - relocating) fix keeps all backend schemas on the single resulting root -- so composing e.g. a - PhysX and a Newton fragment with ``fix_root_link=True`` never leaves a stray second root behind. - Backend fragments carry backend-specific funcs, so core never imports a backend. + """Apply articulation-root fragments to every top-level articulation under a prim. + + Existing articulation roots may live on descendant root links, and a USD file may contain + several sibling articulations. This writer discovers every top-level (non-nested) root and + dispatches each fragment to each root, matching the traversal semantics of the legacy + :func:`modify_articulation_root_properties` writer. Only when no root exists and fragments are + present does it apply ``UsdPhysics.ArticulationRootAPI`` to ``prim_path`` as a fresh anchor. + + Instance proxies are traversed for discovery so a hidden root suppresses that define-fresh + fallback, but they are never authored through. Skipped instance roots make the operation return + ``False`` with a warning, matching the legacy nested writer. + + When ``fix_root_link`` is ``True``, each writable root is normalized by the active backend's + :meth:`~isaaclab.physics.PhysicsManager.fix_articulation_root` capability before fragments are + written. The capability creates or enables the world joint and returns the final root prim; + PhysX-based parsers may relocate it to the parent. ``False`` disables an existing world joint + without creating one. Backend fragments carry backend-specific funcs, so core imports no backend. Args: - 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. + prim_path: The prim path whose subtree is searched for articulation roots. 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. + stage: The stage where to find the prim. Defaults to the current stage. + fix_root_link: Whether to fix the root link. This is spawner topology state, not a fragment + field. ``None`` leaves the topology unchanged. Returns: - True if the properties were successfully set. + True when every fragment/root operation succeeds. False when any fragment reports failure or + an articulation root is skipped because it is instanced. Raises: TypeError: When an item in ``fragments`` is not an - :class:`~isaaclab.sim.schemas.ArticulationRootFragment` (e.g. a legacy single cfg, which - goes through :func:`modify_articulation_root_properties` instead). - ValueError: When the prim path is not valid. - RuntimeError: When ``fix_root_link`` is True and a fixed joint must be created but there is no - active :class:`~isaaclab.sim.SimulationContext` to resolve the physics backend from. - NotImplementedError: Propagated from the backend capability when it cannot create the fixed - joint (e.g. the resolved root prim is not a rigid body). + :class:`~isaaclab.sim.schemas.ArticulationRootFragment`. + ValueError: When ``prim_path`` is not valid. + RuntimeError: When fixing requires a backend but no active + :class:`~isaaclab.sim.SimulationContext` exists, or relocation would merge roots. + NotImplementedError: Propagated when a backend cannot fix the resolved root. """ - # validate early: a clear error beats an ``AttributeError`` deep inside fragment dispatch fragments = list(fragments) - for frag in fragments: - if not isinstance(frag, schemas_cfg.ArticulationRootFragment): + for fragment in fragments: + if not isinstance(fragment, schemas_cfg.ArticulationRootFragment): raise TypeError( "apply_articulation_root_properties: expected ArticulationRootFragment instances in" - f" 'fragments', got '{type(frag).__name__}'. Pass a legacy single cfg to" + f" 'fragments', got '{type(fragment).__name__}'. Pass a legacy single cfg to" " modify_articulation_root_properties instead." ) if stage is None: stage = get_current_stage() - # resolve the existing root (it may live on a child prim in USD assets); instance proxies can't be - # authored on, so don't traverse them - articulation_prim = get_first_matching_child_prim( + + # Discover proxies as well as writable prims so a root hidden inside an instance cannot trigger a + # second anchor on the instance root. Keep only top-level matches; nested articulations are pruned. + matches = get_all_matching_child_prims( prim_path, lambda prim: prim.HasAPI(UsdPhysics.ArticulationRootAPI), - stage, - traverse_instance_prims=False, + stage=stage, + traverse_instance_prims=True, ) - # No existing root in the subtree: define one on the input prim -- but only when fragments are - # supplied (the anchor is presence-gated). A topology-only call (empty fragments, just - # ``fix_root_link``) must not stamp an articulation root as a side effect. - if articulation_prim is None and fragments: - articulation_prim = stage.GetPrimAtPath(prim_path) - UsdPhysics.ArticulationRootAPI.Apply(articulation_prim) - - # Fix the root link BEFORE writing fragments. Some backends relocate the root when fixing (PhysX - # moves the ``ArticulationRootAPI`` to the parent), so resolving the final root first lets every - # fragment land on that single root -- otherwise a relocation would strand fragment schemas on the - # former child and leave two articulation roots. - final_root = articulation_prim - if fix_root_link is not None: - if articulation_prim is None: + top_level_roots = [] + for candidate in matches: + if not any(candidate.GetPath().HasPrefix(root.GetPath()) for root in top_level_roots): + top_level_roots.append(candidate) + + writable_roots = [] + skipped_roots = [] + for root in top_level_roots: + if root.IsInstance() or root.IsInstanceProxy() or root.IsInPrototype(): + skipped_roots.append(root) + else: + writable_roots.append(root) + + if not top_level_roots and fragments: + input_prim = stage.GetPrimAtPath(prim_path) + if input_prim.IsInstance() or input_prim.IsInstanceProxy() or input_prim.IsInPrototype(): + skipped_roots.append(input_prim) + else: + UsdPhysics.ArticulationRootAPI.Apply(input_prim) + writable_roots.append(input_prim) + + success = not skipped_roots + if skipped_roots: + logger.warning( + "Skipping articulation-root updates on instanced prims: %s.", + [root.GetPath().pathString for root in skipped_roots], + ) + if not writable_roots: + if fix_root_link is not None and not skipped_roots: logger.warning( - f"No articulation root found under '{prim_path}': ignoring fix_root_link={fix_root_link}." - " The flag only applies to articulations." + "No articulation root found under '%s': ignoring fix_root_link=%s.", prim_path, fix_root_link ) - else: - root_path = articulation_prim.GetPath().pathString - # honor an explicit stage so the lookup does not fall back to the global current stage - existing_fixed_joint_prim = find_global_fixed_joint_prim(root_path, stage=stage) - if existing_fixed_joint_prim is not None: - # a joint already exists: just enable/disable it in place (no relocation) - logger.info( - f"Found an existing fixed joint for the articulation: '{root_path}'. Setting it to:" - f" {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: '{root_path}'.") - # Creating (and possibly relocating) the root is backend-specific. Delegate to the - # active backend's manager capability so core carries no backend logic; the capability - # returns the resulting root prim. - from isaaclab.sim import SimulationContext - - sim = SimulationContext.instance() - if sim is None: - raise RuntimeError( - f"Cannot fix the articulation root '{root_path}': no active SimulationContext" - " to resolve the physics backend from. A simulation must be running to fix a" - " base." - ) - final_root = sim.physics_manager.fix_articulation_root(articulation_prim, stage) - - # dispatch each fragment to the resolved (possibly relocated) root via its own applier - if fragments: + success = False + return success + + physics_manager = None + if fix_root_link: + from isaaclab.sim import SimulationContext + + sim = SimulationContext.instance() + if sim is None: + raise RuntimeError( + f"Cannot fix articulation roots under '{prim_path}': no active SimulationContext to" + " resolve the physics backend from. A simulation must be running to fix a base." + ) + physics_manager = sim.physics_manager + + for articulation_prim in writable_roots: + final_root = articulation_prim + root_path = articulation_prim.GetPath().pathString + if fix_root_link: + logger.info("Ensuring a fixed root for articulation: '%s'.", root_path) + final_root = physics_manager.fix_articulation_root(articulation_prim, stage) + elif fix_root_link is False: + existing_joint = find_global_fixed_joint_prim(root_path, stage=stage) + if existing_joint is not None: + logger.info("Disabling the existing fixed joint for articulation: '%s'.", root_path) + existing_joint.GetJointEnabledAttr().Set(False) + final_root_path = final_root.GetPath().pathString - for cfg in fragments: - func = cfg.func if callable(cfg.func) else string_to_callable(cfg.func) - func(cfg, final_root_path, stage) + for fragment in fragments: + func = fragment.func if callable(fragment.func) else string_to_callable(fragment.func) + success = bool(func(fragment, final_root_path, stage)) and success - return True + return success def define_articulation_root_properties( 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 6595efaf356d..985df6edca7d 100644 --- a/source/isaaclab/isaaclab/sim/spawners/from_files/from_files.py +++ b/source/isaaclab/isaaclab/sim/spawners/from_files/from_files.py @@ -372,7 +372,7 @@ def _spawn_from_usd_file( # ``fix_root_link`` is a spawner-level topology flag (not a schema property); it is honored on the # fragment path independently of whether any articulation schema properties were supplied. articulation_props = cfg.articulation_props - articulation_fix_root_link = getattr(cfg, "fix_root_link", None) + articulation_fix_root_link = cfg.fix_root_link # transition shim, remove later: route a legacy single cfg (a dataclass, not a fragment) to the # legacy writer -- it owns its own ``fix_root_link`` field; everything else goes to the fragment # writer, routing by type so an empty list is still a valid (topology-only) fragment collection 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 6c8efe83d88a..ff047d6f9335 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 @@ -53,8 +53,9 @@ class FileCfg(RigidObjectSpawnerCfg, DeformableObjectSpawnerCfg): """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: + :func:`~isaaclab.sim.schemas.apply_articulation_root_properties` on the fragment/topology path, + including when :attr:`articulation_props` is ``None`` or an empty fragment collection. It is handled + independently of whether any schema properties are supplied: * 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. diff --git a/source/isaaclab/isaaclab/sim/utils/__init__.pyi b/source/isaaclab/isaaclab/sim/utils/__init__.pyi index 2046f41a7761..cfe39d0933e1 100644 --- a/source/isaaclab/isaaclab/sim/utils/__init__.pyi +++ b/source/isaaclab/isaaclab/sim/utils/__init__.pyi @@ -13,7 +13,6 @@ __all__ = [ "define_prim", "get_prim_type_name", "get_next_free_path", - "create_fixed_root_joint", "create_prim", "delete_prim", "make_uninstanceable", @@ -74,7 +73,6 @@ from .legacy import ( get_next_free_path, ) from .prims import ( - create_fixed_root_joint, create_prim, delete_prim, make_uninstanceable, diff --git a/source/isaaclab/isaaclab/sim/utils/prims.py b/source/isaaclab/isaaclab/sim/utils/prims.py index b52e5ea1500c..d0f0e8d9042a 100644 --- a/source/isaaclab/isaaclab/sim/utils/prims.py +++ b/source/isaaclab/isaaclab/sim/utils/prims.py @@ -186,60 +186,6 @@ def create_prim( return prim -def create_fixed_root_joint(articulation_prim, stage: Usd.Stage | None = None): - """Author a world<->prim ``UsdPhysics.FixedJoint`` that pins a prim at its current world pose. - - Backend-neutral USD authoring with no Kit/``omni`` dependency: it mirrors the fixed, world-anchored - joint produced by Kit's ``omni.physx`` ``createJoint`` helper -- ``body0`` is left empty (the world - frame), ``body1`` targets the prim, and the joint's world-side local frame is set to the prim's - current world transform so the constraint pins the body where it is (otherwise it would be pinned to - the world origin). The joint prim is created under the first writable (non-instanced) ancestor. - - Args: - articulation_prim: The prim to fix to the world frame. - stage: The stage the prim lives on. Defaults to None, in which case the current stage is used. - - Returns: - The created ``UsdPhysics.FixedJoint`` prim. - """ - from pxr import Gf, UsdGeom, UsdPhysics - - if stage is None: - stage = get_current_stage() - - # host the joint under the first writable ancestor (an instanced/prototype prim is not writable) - base_prim = articulation_prim - while base_prim != stage.GetPseudoRoot(): - if base_prim.IsInPrototype() or base_prim.IsInstanceProxy() or base_prim.IsInstanceable(): - base_prim = base_prim.GetParent() - else: - break - base_path = str(base_prim.GetPrimPath()) - if base_path == "/": - base_path = "" - # pick a unique child name so repeated calls do not collide - name = "FixedJoint" - if stage.GetPrimAtPath(f"{base_path}/{name}").IsValid(): - index = 0 - while stage.GetPrimAtPath(f"{base_path}/{name}{index}").IsValid(): - index += 1 - name = f"{name}{index}" - joint = UsdPhysics.FixedJoint.Define(stage, f"{base_path}/{name}") - - # body0 empty -> world; body1 -> the prim, pinned at its current world pose - world_xform = UsdGeom.XformCache().GetLocalToWorldTransform(articulation_prim).RemoveScaleShear() - joint.CreateBody1Rel().SetTargets([articulation_prim.GetPath()]) - joint.CreateLocalPos0Attr().Set(Gf.Vec3f(world_xform.ExtractTranslation())) - joint.CreateLocalRot0Attr().Set(Gf.Quatf(world_xform.ExtractRotationQuat())) - joint.CreateLocalPos1Attr().Set(Gf.Vec3f(0.0)) - joint.CreateLocalRot1Attr().Set(Gf.Quatf(1.0)) - # disable joint breaking (matches Kit's createJoint defaults; float32 max) - max_float = 3.40282347e38 - joint.CreateBreakForceAttr().Set(max_float) - joint.CreateBreakTorqueAttr().Set(max_float) - return joint.GetPrim() - - def delete_prim(prim_path: str | Sequence[str], stage: Usd.Stage | None = None) -> bool: """Removes the USD Prim and its descendants from the scene if able. diff --git a/source/isaaclab/test/sim/test_articulation_fragments.py b/source/isaaclab/test/sim/test_articulation_fragments.py index 31403ad387f2..2319363122eb 100644 --- a/source/isaaclab/test/sim/test_articulation_fragments.py +++ b/source/isaaclab/test/sim/test_articulation_fragments.py @@ -157,6 +157,61 @@ def test_apply_articulation_root_properties_tunes_existing_child_root(): assert len(roots) == 1 and roots[0] == child +def test_apply_articulation_root_properties_processes_siblings_and_aggregates_results(): + """Every non-nested sibling root is visited and a failing applier makes the family result False.""" + 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() + for path in ("/World/A", "/World/B", "/World/A/Nested"): + root = _make_xform(stage, path) + UsdPhysics.ArticulationRootAPI.Apply(root) + + visited = [] + + def record_result(_cfg, path, _stage): + visited.append(path) + return path != "/World/B" + + result = apply_articulation_root_properties("/World", [PhysxArticulationCfg(func=record_result)], stage) + + assert visited == ["/World/A", "/World/B"] + assert result is False + + +def test_apply_articulation_root_properties_does_not_duplicate_instance_proxy_root(caplog): + """A root hidden in an instance suppresses define-fresh but is skipped because proxies are read-only.""" + 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() + source = _make_xform(stage, "/World/Source") + source_base = _make_xform(stage, "/World/Source/base") + UsdPhysics.ArticulationRootAPI.Apply(source_base) + instance = _make_xform(stage, "/World/Asset") + instance.GetReferences().AddInternalReference(source.GetPath()) + instance.SetInstanceable(True) + proxy_root = stage.GetPrimAtPath("/World/Asset/base") + assert proxy_root.IsInstanceProxy() and proxy_root.HasAPI(UsdPhysics.ArticulationRootAPI) + + with caplog.at_level("WARNING"): + result = apply_articulation_root_properties( + "/World/Asset", [PhysxArticulationCfg(solver_position_iteration_count=4)], stage + ) + + assert result is False + assert not instance.HasAPI(UsdPhysics.ArticulationRootAPI) + assert proxy_root.HasAPI(UsdPhysics.ArticulationRootAPI) + assert not proxy_root.GetAttribute("physxArticulation:solverPositionIterationCount").HasAuthoredValue() + assert "/World/Asset/base" in caplog.text + + # ------------------------------------------------------------------------------------- # fix_root_link spawner-level flag: toggles an existing fixed joint # ------------------------------------------------------------------------------------- @@ -188,6 +243,30 @@ def test_apply_articulation_root_properties_toggles_existing_fixed_joint(): assert joint.GetJointEnabledAttr().Get() is False +def test_apply_articulation_root_properties_enables_existing_joint_and_relocates_root(): + """An existing joint is enabled, but PhysX root placement is still normalized through the manager.""" + 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 = _make_xform(stage, "/World/ExistingJointRobot") + root = _make_xform(stage, "/World/ExistingJointRobot/base") + UsdPhysics.RigidBodyAPI.Apply(root) + UsdPhysics.ArticulationRootAPI.Apply(root) + joint = UsdPhysics.FixedJoint.Define(stage, "/World/ExistingJointRobot/base/FixedJoint") + joint.CreateBody1Rel().SetTargets([root.GetPath()]) + joint.CreateJointEnabledAttr(False) + + apply_articulation_root_properties("/World/ExistingJointRobot", [], stage, fix_root_link=True) + + joints = [prim for prim in stage.Traverse() if prim.IsA(UsdPhysics.FixedJoint)] + assert joints == [joint.GetPrim()] + assert joint.GetJointEnabledAttr().Get() is True + assert parent.HasAPI(UsdPhysics.ArticulationRootAPI) + assert not root.HasAPI(UsdPhysics.ArticulationRootAPI) + + # ------------------------------------------------------------------------------------- # fix_root_link spawner-level flag: creates a fixed joint and reparents the root # ------------------------------------------------------------------------------------- @@ -348,10 +427,8 @@ def test_physx_fix_root_link_migrates_preauthored_newton_root_api(): assert not child.HasAPI(UsdPhysics.ArticulationRootAPI) -def test_physx_fix_root_link_migrates_preauthored_physx_attrs(): - """Asset-authored ``PhysxArticulationAPI`` attributes move to the relocated root (legacy parity): - the legacy writer copied ``physxArticulation:*`` values to the parent, so the fragment path must - not silently drop them on the former root link where the PhysX parser ignores them.""" +def test_physx_fix_root_link_preserves_complete_authored_property_spec(): + """Relocation preserves sampled, metadata, and connection opinions rather than one default value.""" from isaaclab.sim.schemas import apply_articulation_root_properties sim_utils.create_new_stage() @@ -360,17 +437,34 @@ def test_physx_fix_root_link_migrates_preauthored_physx_attrs(): _make_xform(stage, "/World/UsdBot") child = _make_xform(stage, "/World/UsdBot/base") UsdPhysics.RigidBodyAPI.Apply(child) + UsdPhysics.MassAPI.Apply(child) UsdPhysics.ArticulationRootAPI.Apply(child) - # the asset ships with PhysX articulation solver settings authored on the root link child.AddAppliedSchema("PhysxArticulationAPI") - child.CreateAttribute("physxArticulation:solverVelocityIterationCount", Sdf.ValueTypeNames.Int).Set(4) + driver = _make_xform(stage, "/World/Driver") + driver_attr = driver.CreateAttribute("output", Sdf.ValueTypeNames.Float) + driver_attr.Set(0.5) + source_attr = child.GetAttribute("physxArticulation:sleepThreshold") + source_attr.Set(0.1, Usd.TimeCode(1.0)) + source_attr.Set(0.2, Usd.TimeCode(2.0)) + source_attr.SetMetadata("documentation", "sampled sleep threshold") + source_attr.AddConnection(driver_attr.GetPath()) apply_articulation_root_properties("/World/UsdBot", [], stage, fix_root_link=True) parent = stage.GetPrimAtPath("/World/UsdBot") assert parent.HasAPI(UsdPhysics.ArticulationRootAPI) - assert "PhysxArticulationAPI" in parent.GetAppliedSchemas() - assert parent.GetAttribute("physxArticulation:solverVelocityIterationCount").Get() == 4 + assert "PhysxArticulationAPI" in parent.GetPrimTypeInfo().GetAppliedAPISchemas() + assert not child.HasAPI(UsdPhysics.ArticulationRootAPI) + assert "PhysxArticulationAPI" not in child.GetPrimTypeInfo().GetAppliedAPISchemas() + moved_attr = parent.GetAttribute("physxArticulation:sleepThreshold") + assert not stage.GetRootLayer().GetAttributeAtPath(moved_attr.GetPath()).HasInfo("default") + assert moved_attr.GetTimeSamples() == [1.0, 2.0] + assert moved_attr.Get(Usd.TimeCode(1.0)) == pytest.approx(0.1) + assert moved_attr.Get(Usd.TimeCode(2.0)) == pytest.approx(0.2) + assert moved_attr.GetMetadata("documentation") == "sampled sleep threshold" + assert moved_attr.GetConnections() == [driver_attr.GetPath()] + assert child.HasAPI(UsdPhysics.RigidBodyAPI) and child.HasAPI(UsdPhysics.MassAPI) + assert not parent.HasAPI(UsdPhysics.RigidBodyAPI) and not parent.HasAPI(UsdPhysics.MassAPI) def test_apply_articulation_root_properties_rejects_non_fragment_items(): @@ -411,87 +505,39 @@ def test_apply_articulation_root_properties_topology_only_does_not_stamp_root(): def test_apply_articulation_root_properties_honors_explicit_stage(): - """The writer authors to the explicitly supplied stage even when it is not the current stage -- - every lookup (root resolution, fixed-joint search) is scoped to that stage.""" + """Fragment writes and fixed-joint lookup both stay on the explicitly supplied stage.""" 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)) - # an in-memory stage that is deliberately NOT the current stage + current_stage = sim_utils.get_current_stage() + current_root = _make_xform(current_stage, "/World/AltStageRoot") + UsdPhysics.ArticulationRootAPI.Apply(current_root) + current_joint = UsdPhysics.FixedJoint.Define(current_stage, "/World/AltStageRoot/FixedJoint") + current_joint.CreateBody1Rel().SetTargets([current_root.GetPath()]) + current_joint.CreateJointEnabledAttr(True) + other_stage = Usd.Stage.CreateInMemory() UsdGeom.Xform.Define(other_stage, "/World") - root = UsdGeom.Xform.Define(other_stage, "/World/AltStageRoot").GetPrim() - UsdPhysics.ArticulationRootAPI.Apply(root) + other_root = UsdGeom.Xform.Define(other_stage, "/World/AltStageRoot").GetPrim() + UsdPhysics.ArticulationRootAPI.Apply(other_root) + other_joint = UsdPhysics.FixedJoint.Define(other_stage, "/World/AltStageRoot/FixedJoint") + other_joint.CreateBody1Rel().SetTargets([other_root.GetPath()]) + other_joint.CreateJointEnabledAttr(True) apply_articulation_root_properties( "/World/AltStageRoot", [PhysxArticulationCfg(solver_position_iteration_count=4)], other_stage, + fix_root_link=False, ) - # authored on the supplied stage ... - assert root.GetAttribute("physxArticulation:solverPositionIterationCount").Get() == 4 - # ... and not leaked onto the current stage - assert not sim_utils.get_current_stage().GetPrimAtPath("/World/AltStageRoot").IsValid() - - -# ------------------------------------------------------------------------------------- -# create_fixed_root_joint -- backend-neutral, pure-USD world<->prim fixed joint -# ------------------------------------------------------------------------------------- - - -def test_create_fixed_root_joint_authors_world_anchored_joint(): - """create_fixed_root_joint authors a world<->prim ``UsdPhysics.FixedJoint``: ``body1`` targets the - prim, ``body0`` is left empty (the world frame), and the body1-side local frame is identity.""" - from isaaclab.sim.utils import create_fixed_root_joint - - sim_utils.create_new_stage() - SimulationContext(SimulationCfg(dt=0.01)) - stage = sim_utils.get_current_stage() - prim = _make_xform(stage, "/World/Fix1") - joint_prim = create_fixed_root_joint(prim, stage) - - assert joint_prim.IsA(UsdPhysics.FixedJoint) - joint = UsdPhysics.FixedJoint(joint_prim) - assert list(joint.GetBody1Rel().GetTargets()) == [prim.GetPath()] - # body0 left empty -> the world frame - body0_rel = joint.GetBody0Rel() - assert not body0_rel or list(body0_rel.GetTargets()) == [] - # the body1-side local frame is identity - assert joint_prim.GetAttribute("physics:localPos1").Get() == Gf.Vec3f(0.0) - assert joint_prim.GetAttribute("physics:localRot1").Get() == Gf.Quatf(1.0) - - -def test_create_fixed_root_joint_pins_prim_at_current_world_pose(): - """The world-side local frame is set to the prim's current world transform, so the constraint pins - the body where it is rather than teleporting it to the world origin.""" - from isaaclab.sim.utils import create_fixed_root_joint - - sim_utils.create_new_stage() - SimulationContext(SimulationCfg(dt=0.01)) - stage = sim_utils.get_current_stage() - prim = _make_xform(stage, "/World/Fix2") - UsdGeom.Xform(prim).AddTranslateOp().Set(Gf.Vec3d(1.0, 2.0, 3.0)) - - joint_prim = create_fixed_root_joint(prim, stage) - pos0 = joint_prim.GetAttribute("physics:localPos0").Get() - assert (pos0[0], pos0[1], pos0[2]) == pytest.approx((1.0, 2.0, 3.0)) - - -def test_create_fixed_root_joint_uses_unique_names(): - """Repeated calls author distinct joint prims (no path collision).""" - from isaaclab.sim.utils import create_fixed_root_joint - - sim_utils.create_new_stage() - SimulationContext(SimulationCfg(dt=0.01)) - stage = sim_utils.get_current_stage() - prim = _make_xform(stage, "/World/Fix3") - first = create_fixed_root_joint(prim, stage) - second = create_fixed_root_joint(prim, stage) - assert first.GetPath() != second.GetPath() - assert first.IsA(UsdPhysics.FixedJoint) and second.IsA(UsdPhysics.FixedJoint) + assert other_root.GetAttribute("physxArticulation:solverPositionIterationCount").Get() == 4 + assert other_joint.GetJointEnabledAttr().Get() is False + assert not current_root.GetAttribute("physxArticulation:solverPositionIterationCount").HasAuthoredValue() + assert current_joint.GetJointEnabledAttr().Get() is True # ------------------------------------------------------------------------------------- @@ -499,13 +545,8 @@ def test_create_fixed_root_joint_uses_unique_names(): # ------------------------------------------------------------------------------------- -def test_base_manager_fix_articulation_root_authors_joint_without_reparenting(): - """The base :class:`~isaaclab.physics.PhysicsManager` capability authors a ``UsdPhysics.FixedJoint`` - and leaves the articulation root in place, returning the same prim. - - This is the default inherited by backends whose parser reads a fixed joint directly (e.g. Newton, - OVPhysx) and by any manager subclass that does not override it -- no PhysX-style relocation. - """ +def test_base_manager_fix_articulation_root_is_world_anchored_and_idempotent(): + """The neutral capability pins the current pose and enables rather than duplicates an existing joint.""" from isaaclab.physics import PhysicsManager sim_utils.create_new_stage() @@ -513,17 +554,23 @@ def test_base_manager_fix_articulation_root_authors_joint_without_reparenting(): stage = sim_utils.get_current_stage() _make_xform(stage, "/World/BaseRobot") root = _make_xform(stage, "/World/BaseRobot/base") + UsdGeom.Xform(root).AddTranslateOp().Set(Gf.Vec3d(1.0, 2.0, 3.0)) UsdPhysics.RigidBodyAPI.Apply(root) UsdPhysics.ArticulationRootAPI.Apply(root) result = PhysicsManager.fix_articulation_root(root, stage) - - assert any(p.IsA(UsdPhysics.FixedJoint) for p in stage.Traverse()) - # base default returns the same prim and does NOT relocate the root to the parent - assert result == root - assert root.HasAPI(UsdPhysics.ArticulationRootAPI) + joints = [prim for prim in stage.Traverse() if prim.IsA(UsdPhysics.FixedJoint)] + assert result == root and len(joints) == 1 + joint = UsdPhysics.FixedJoint(joints[0]) + assert list(joint.GetBody1Rel().GetTargets()) == [root.GetPath()] + assert joint.GetPrim().GetAttribute("physics:localPos0").Get() == Gf.Vec3f(1.0, 2.0, 3.0) assert not stage.GetPrimAtPath("/World/BaseRobot").HasAPI(UsdPhysics.ArticulationRootAPI) + joint.CreateJointEnabledAttr(False) + assert PhysicsManager.fix_articulation_root(root, stage) == root + assert joint.GetJointEnabledAttr().Get() is True + assert len([prim for prim in stage.Traverse() if prim.IsA(UsdPhysics.FixedJoint)]) == 1 + def test_base_manager_fix_articulation_root_requires_rigid_body(): """The capability raises NotImplementedError on a non-rigid-body root (cannot anchor the joint).""" @@ -538,56 +585,60 @@ def test_base_manager_fix_articulation_root_requires_rigid_body(): PhysicsManager.fix_articulation_root(root, stage) -def test_fix_articulation_root_capability_inherited_by_manager_subclass(): - """A backend manager subclass inherits its parent backend's ``fix_articulation_root`` through the - normal method-resolution order. +def test_articulation_fragment_and_legacy_cfg_match_physx_schema(): + """Both interfaces cover exactly all six attributes registered by PhysxArticulationAPI.""" + import dataclasses - This is what makes the capability robust where the previous cfg-type registry was not: manager - dispatch is resolved from ``cfg.physics.class_type``, so a subclassed cfg (e.g. an in-tree - ``DeformableNewtonCfg(NewtonCfg)``) whose ``class_type`` is an unchanged or subclassed manager - inherits the correct behaviour, rather than missing an exact-type registry key. - """ - from isaaclab.physics import PhysicsManager + from isaaclab_physx.sim.schemas import PhysxArticulationCfg, PhysxArticulationRootPropertiesCfg - sim_utils.create_new_stage() - SimulationContext(SimulationCfg(dt=0.01)) + from pxr import PhysxSchema - # a minimal concrete backend that OVERRIDES the capability (as PhysX does, relocating the root) ... - class _BackendLike(PhysicsManager): - @classmethod - def fix_articulation_root(cls, articulation_prim, stage=None): - from isaaclab.sim.utils import create_fixed_root_joint + from isaaclab.utils.string import to_camel_case - cls._require_rigid_body_root(articulation_prim) - create_fixed_root_joint(articulation_prim, stage) - parent = articulation_prim.GetParent() - UsdPhysics.ArticulationRootAPI.Apply(parent) - articulation_prim.RemoveAPI(UsdPhysics.ArticulationRootAPI) - return parent + def fields(cls): + return {field.name for field in dataclasses.fields(cls) if field.name != "func"} - # ... and a subclass that does not override it (stand-in for a subclassed cfg's class_type) - class _SubBackend(_BackendLike): - pass + schema_attrs = {name.split(":", 1)[1] for name in PhysxSchema.PhysxArticulationAPI.GetSchemaAttributeNames()} + fragment_attrs = {to_camel_case(name, "cC") for name in fields(PhysxArticulationCfg)} + legacy_attrs = { + to_camel_case(name, "cC") for name in fields(PhysxArticulationRootPropertiesCfg) - {"fix_root_link"} + } + assert fragment_attrs == schema_attrs + assert legacy_attrs == schema_attrs - stage = sim_utils.get_current_stage() - _make_xform(stage, "/World/SubRobot") - root = _make_xform(stage, "/World/SubRobot/base") - UsdPhysics.RigidBodyAPI.Apply(root) - UsdPhysics.ArticulationRootAPI.Apply(root) - result = _SubBackend.fix_articulation_root(root, stage) +def test_newton_legacy_cfg_matches_equivalent_fragment_composition(): + """A Newton legacy subclass still routes its inherited articulation_enabled base field.""" + from isaaclab_newton.sim.schemas import NewtonArticulationCfg, NewtonArticulationRootPropertiesCfg + from isaaclab_physx.sim.schemas import PhysxArticulationCfg - parent = stage.GetPrimAtPath("/World/SubRobot") - # the subclass inherited the overriding backend's behaviour through the MRO - assert any(p.IsA(UsdPhysics.FixedJoint) for p in stage.Traverse()) - assert result == parent - assert parent.HasAPI(UsdPhysics.ArticulationRootAPI) - assert not root.HasAPI(UsdPhysics.ArticulationRootAPI) + from isaaclab.sim.schemas import apply_articulation_root_properties, modify_articulation_root_properties - # and the real active backend (PhysX) genuinely overrides the base -- so backends specialise via - # this same inheritance mechanism rather than a parallel registry - active_manager = SimulationContext.instance().physics_manager - assert active_manager.fix_articulation_root.__func__ is not PhysicsManager.fix_articulation_root.__func__ + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + legacy = _make_xform(stage, "/World/LegacyNewton") + fragments = _make_xform(stage, "/World/FragmentNewton") + UsdPhysics.ArticulationRootAPI.Apply(legacy) + UsdPhysics.ArticulationRootAPI.Apply(fragments) + + modify_articulation_root_properties( + legacy.GetPath(), + NewtonArticulationRootPropertiesCfg(articulation_enabled=False, self_collision_enabled=True), + stage, + ) + apply_articulation_root_properties( + fragments.GetPath(), + [ + PhysxArticulationCfg(articulation_enabled=False), + NewtonArticulationCfg(self_collision_enabled=True), + ], + stage, + ) + + for prim in (legacy, fragments): + assert prim.GetAttribute("physxArticulation:articulationEnabled").Get() is False + assert prim.GetAttribute("newton:selfCollisionEnabled").Get() is True # ------------------------------------------------------------------------------------- @@ -606,13 +657,9 @@ def _author_articulation_usd(path: str) -> None: asset_stage.Save() -def test_spawn_from_usd_file_empty_articulation_props_with_fix_root_link(tmp_path): - """An empty ``articulation_props=[]`` on a USD spawn is a valid (topology-only) fragment collection: - it must route to the fragment writer and honor ``fix_root_link`` -- not crash in the legacy writer. - - Regression: the legacy path called ``dataclasses.fields([])`` on the empty list and raised - ``TypeError``, so the direct-writer and spawner entry points disagreed on whether ``[]`` was valid. - """ +@pytest.mark.parametrize("articulation_props", [None, []], ids=["none", "empty"]) +def test_spawn_from_usd_file_topology_only_honors_fix_root_link(tmp_path, articulation_props): + """None and an empty fragment collection both honor the independent topology flag.""" from isaaclab.sim.spawners.from_files.from_files import _spawn_from_usd_file from isaaclab.sim.spawners.from_files.from_files_cfg import UsdFileCfg @@ -621,33 +668,44 @@ def test_spawn_from_usd_file_empty_articulation_props_with_fix_root_link(tmp_pat usd_path = os.path.join(tmp_path, "articulation.usda") _author_articulation_usd(usd_path) - cfg = UsdFileCfg(usd_path=usd_path, articulation_props=[], fix_root_link=True) - _spawn_from_usd_file("/World/FromUsdA", usd_path, cfg) + cfg = UsdFileCfg(usd_path=usd_path, articulation_props=articulation_props, fix_root_link=True) + _spawn_from_usd_file("/World/FromUsdTopology", usd_path, cfg) stage = sim_utils.get_current_stage() - # the topology flag was honored: a fixed joint was created and the root relocated to the parent - assert any(p.IsA(UsdPhysics.FixedJoint) for p in stage.Traverse()) - parent = stage.GetPrimAtPath("/World/FromUsdA") - assert parent.HasAPI(UsdPhysics.ArticulationRootAPI) - assert not stage.GetPrimAtPath("/World/FromUsdA/base").HasAPI(UsdPhysics.ArticulationRootAPI) + assert any(prim.IsA(UsdPhysics.FixedJoint) for prim in stage.Traverse()) + assert stage.GetPrimAtPath("/World/FromUsdTopology").HasAPI(UsdPhysics.ArticulationRootAPI) + assert not stage.GetPrimAtPath("/World/FromUsdTopology/base").HasAPI(UsdPhysics.ArticulationRootAPI) -def test_spawn_from_usd_file_none_articulation_props_honors_fix_root_link(tmp_path): - """``fix_root_link`` is a spawner-level flag processed independently of ``articulation_props``: - with ``articulation_props=None`` it must still fix the base (previously it was silently ignored).""" +def test_spawn_from_usd_file_applies_composed_fragment_list(tmp_path): + """The real FileCfg transition bridge composes backend fragments on the relocated root.""" + from isaaclab_newton.sim.schemas import NewtonArticulationCfg + from isaaclab_physx.sim.schemas import PhysxArticulationCfg + from isaaclab.sim.spawners.from_files.from_files import _spawn_from_usd_file from isaaclab.sim.spawners.from_files.from_files_cfg import UsdFileCfg sim_utils.create_new_stage() SimulationContext(SimulationCfg(dt=0.01)) - usd_path = os.path.join(tmp_path, "articulation.usda") + usd_path = os.path.join(tmp_path, "fragment_articulation.usda") _author_articulation_usd(usd_path) + cfg = UsdFileCfg( + usd_path=usd_path, + articulation_props=[ + PhysxArticulationCfg(solver_position_iteration_count=8), + NewtonArticulationCfg(self_collision_enabled=True), + ], + fix_root_link=True, + ) - cfg = UsdFileCfg(usd_path=usd_path, articulation_props=None, fix_root_link=True) - _spawn_from_usd_file("/World/FromUsdB", usd_path, cfg) + _spawn_from_usd_file("/World/FromUsdFragments", usd_path, cfg) stage = sim_utils.get_current_stage() - assert any(p.IsA(UsdPhysics.FixedJoint) for p in stage.Traverse()) + root = stage.GetPrimAtPath("/World/FromUsdFragments") + roots = [prim for prim in stage.Traverse() if prim.HasAPI(UsdPhysics.ArticulationRootAPI)] + assert roots == [root] + assert root.GetAttribute("physxArticulation:solverPositionIterationCount").Get() == 8 + assert root.GetAttribute("newton:selfCollisionEnabled").Get() is True # ------------------------------------------------------------------------------------- @@ -664,4 +722,3 @@ def test_public_imports(): SchemaFragment, apply_articulation_root_properties, ) - from isaaclab.sim.utils import create_fixed_root_joint # noqa: F401 diff --git a/source/isaaclab_newton/isaaclab_newton/sim/schemas/__init__.py b/source/isaaclab_newton/isaaclab_newton/sim/schemas/__init__.py index f5a65d933f51..e79e72e10dff 100644 --- a/source/isaaclab_newton/isaaclab_newton/sim/schemas/__init__.py +++ b/source/isaaclab_newton/isaaclab_newton/sim/schemas/__init__.py @@ -8,8 +8,3 @@ from isaaclab.utils.module import lazy_export lazy_export() - -# Fixing an articulation base is a backend capability on the physics manager -# (:meth:`~isaaclab.physics.PhysicsManager.fix_articulation_root`). Newton reads a -# ``UsdPhysics.FixedJoint`` on the root directly, so it inherits the base manager behaviour (author the -# neutral fixed joint, leave the root in place) without a Newton-specific override here. 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 9b9e68e6f800..ae19d511fe2d 100644 --- a/source/isaaclab_newton/isaaclab_newton/sim/schemas/schemas_cfg.py +++ b/source/isaaclab_newton/isaaclab_newton/sim/schemas/schemas_cfg.py @@ -575,7 +575,6 @@ class NewtonArticulationRootPropertiesCfg(ArticulationRootBaseCfg): _usd_namespace: ClassVar[str | None] = "newton" _usd_applied_schema: ClassVar[str | None] = "NewtonArticulationRootAPI" - _usd_field_exceptions: ClassVar[dict] = {} self_collision_enabled: bool | None = None """Whether self-collisions between bodies in this articulation are enabled. diff --git a/source/isaaclab_ovphysx/changelog.d/vidurv-schema-frag-articulation.rst b/source/isaaclab_ovphysx/changelog.d/vidurv-schema-frag-articulation.rst new file mode 100644 index 000000000000..6825dbe2d714 --- /dev/null +++ b/source/isaaclab_ovphysx/changelog.d/vidurv-schema-frag-articulation.rst @@ -0,0 +1,6 @@ +Fixed +^^^^^ + +* Fixed ``fix_root_link=True`` for OVPhysX fragment-based articulation spawns by relocating the + articulation root to the parser-required parent, including when an existing disabled world fixed + joint is re-enabled. diff --git a/source/isaaclab_ovphysx/isaaclab_ovphysx/physics/ovphysx_manager.py b/source/isaaclab_ovphysx/isaaclab_ovphysx/physics/ovphysx_manager.py index 458f38f9b541..c0e2b2872b52 100644 --- a/source/isaaclab_ovphysx/isaaclab_ovphysx/physics/ovphysx_manager.py +++ b/source/isaaclab_ovphysx/isaaclab_ovphysx/physics/ovphysx_manager.py @@ -228,6 +228,34 @@ def get_dt(cls) -> float: """Get the physics timestep. Alias for get_physics_dt().""" return cls.get_physics_dt() + @classmethod + def fix_articulation_root(cls, articulation_prim: Any, stage: Any = None) -> Any: + """Fix an articulation base using the parent-root topology required by OVPhysX. + + OVPhysX uses the PhysX articulation parser, so a world joint on a rigid root link is not by + itself classified as a fixed-base articulation. The base manager creates or enables the + joint; this override then relocates the complete articulation-root schema family to the root + link's parent, preserving authored property specifications. + + Args: + articulation_prim: The resolved articulation-root prim to fix to the world frame. + stage: The stage the prim lives on. Defaults to None, in which case the current stage is + used. + + Returns: + The parent prim, which becomes the articulation root after relocation. + + Raises: + NotImplementedError: When the root prim is not a rigid body. + """ + fixed_root = super().fix_articulation_root(articulation_prim, stage) + if not fixed_root.HasAPI(UsdPhysics.RigidBodyAPI): + return fixed_root + return cls._relocate_articulation_root( + fixed_root, + companion_schema_namespaces={"PhysxArticulationAPI": "physxArticulation"}, + ) + @classmethod def register_clone( cls, source: str, targets: list[str], parent_positions: list[tuple[float, float, float]] | None = None diff --git a/source/isaaclab_ovphysx/test/assets/test_articulation.py b/source/isaaclab_ovphysx/test/assets/test_articulation.py index 9dff7ae6bca4..17a029a826dc 100644 --- a/source/isaaclab_ovphysx/test/assets/test_articulation.py +++ b/source/isaaclab_ovphysx/test/assets/test_articulation.py @@ -58,6 +58,8 @@ import torch import warp as wp +from pxr import UsdPhysics + # The OVPhysX runtime wheel is optional. Skip gracefully when it is not installed; # CI jobs that need OVPhysX coverage install it explicitly. pytest.importorskip("ovphysx.types", reason="ovphysx wheel not installed") @@ -732,6 +734,47 @@ 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("device", ["cpu"]) +def test_fragment_fix_root_reenables_existing_joint(sim, device): + """The fragment path must normalize OVPhysX topology even when a disabled fixed joint exists.""" + articulation_cfg = generate_articulation_cfg(articulation_type="anymal").copy() + articulation_cfg.spawn.articulation_props = [] + articulation_cfg.spawn.fix_root_link = None + articulation, _ = generate_articulation(articulation_cfg, num_articulations=1, device=device) + + stage = sim.stage + asset_path = "/World/Env_0/Robot" + root = sim_utils.get_first_matching_child_prim( + asset_path, lambda prim: prim.HasAPI(UsdPhysics.ArticulationRootAPI), stage=stage + ) + assert root is not None and root.HasAPI(UsdPhysics.RigidBodyAPI) + old_root_path = root.GetPath().pathString + final_root = root.GetParent() + + joint = UsdPhysics.FixedJoint.Define(stage, f"{old_root_path}/PreAuthoredFixedJoint") + joint.CreateBody1Rel().SetTargets([root.GetPath()]) + joint.CreateJointEnabledAttr().Set(False) + + assert sim_utils.apply_articulation_root_properties(asset_path, [], stage, fix_root_link=True) + assert joint.GetJointEnabledAttr().Get() is True + world_joints = [] + for prim in sim_utils.get_all_matching_child_prims( + asset_path, lambda prim: prim.IsA(UsdPhysics.FixedJoint), stage=stage + ): + usd_joint = UsdPhysics.Joint(prim) + has_body_0 = bool(usd_joint.GetBody0Rel().GetTargets()) + has_body_1 = bool(usd_joint.GetBody1Rel().GetTargets()) + if has_body_0 != has_body_1: + world_joints.append(prim) + assert world_joints == [joint.GetPrim()] + assert final_root.HasAPI(UsdPhysics.ArticulationRootAPI) + assert not root.HasAPI(UsdPhysics.ArticulationRootAPI) + + sim.reset() + assert articulation.is_initialized + assert articulation.is_fixed_base + + @pytest.mark.parametrize("num_articulations", [1, 2]) @pytest.mark.parametrize("device", ["cuda:0", "cpu"]) @pytest.mark.parametrize("add_ground_plane", [True]) diff --git a/source/isaaclab_physx/isaaclab_physx/physics/physx_manager.py b/source/isaaclab_physx/isaaclab_physx/physics/physx_manager.py index 87f769e51678..9499b3f07793 100644 --- a/source/isaaclab_physx/isaaclab_physx/physics/physx_manager.py +++ b/source/isaaclab_physx/isaaclab_physx/physics/physx_manager.py @@ -306,18 +306,15 @@ def fix_articulation_root(cls, articulation_prim: Any, stage: Any = None) -> Any """Fix an articulation base for the PhysX parser and return the relocated root prim. The PhysX parser does not treat a fixed joint on a rigid body as a fixed-base articulation -- - it folds it into the maximal-coordinate tree. To work around that, this authors the - world<->root fixed joint and then relocates the ``UsdPhysics.ArticulationRootAPI`` anchor from - the root link to its parent prim, returning the parent as the new root. Articulation-root - fragments are applied by the caller *after* this call, so they land on the returned root. + it folds it into the maximal-coordinate tree. The base manager first creates or enables the + world-to-root joint, then this override relocates the complete articulation-root schema family + from the rigid root link to its parent. Articulation-root fragments are applied by the caller + after this call, so they land on the returned root. Asset-authored root schemas move with the root: any API schema directly authored on the former - root that composes ``PhysicsArticulationRootAPI`` (e.g. Newton's root API on URDF/MJCF-imported - assets) is migrated to the parent together with its authored attributes -- removing only the - directly applied anchor would leave such a schema behind and the former root would remain a - second articulation root through schema composition. The ``PhysxArticulationAPI`` companion - and its ``physxArticulation:*`` attributes are migrated the same way, matching the legacy - writer's relocation. + root that composes ``PhysicsArticulationRootAPI`` is migrated without naming another backend. + The ``PhysxArticulationAPI`` companion moves with it. Property migration preserves complete + authored USD state, including time samples, metadata, connections, and referenced opinions. Args: articulation_prim: The resolved articulation-root prim to fix to the world frame. @@ -330,63 +327,15 @@ def fix_articulation_root(cls, articulation_prim: Any, stage: Any = None) -> Any Raises: NotImplementedError: When the root prim is not a rigid body. """ - from isaaclab.sim.utils import create_fixed_root_joint - - cls._require_rigid_body_root(articulation_prim) - create_fixed_root_joint(articulation_prim, stage) - # Relocate the articulation root to the parent so the PhysX parser recognises a fixed base. - parent_prim = articulation_prim.GetParent() - UsdPhysics.ArticulationRootAPI.Apply(parent_prim) - articulation_prim.RemoveAPI(UsdPhysics.ArticulationRootAPI) - cls._migrate_authored_root_schemas(articulation_prim, parent_prim) - return parent_prim - - @staticmethod - def _migrate_authored_root_schemas(old_root: Usd.Prim, new_root: Usd.Prim) -> None: - """Move asset-authored articulation-root API schemas from the former root to the new root. - - Detection is backend-agnostic: a directly authored API schema is migrated when the USD schema - registry reports that it composes ``PhysicsArticulationRootAPI`` (so e.g. Newton's root API is - recognised without naming it). The PhysX ``PhysxArticulationAPI`` companion is migrated too -- - it does not compose the root API but carries the root's solver attributes. For each migrated - schema, its authored attributes are copied to the new root (attribute values already authored - on the new root are not overwritten by schema defaults; only authored values are copied). - - Args: - old_root: The former articulation-root prim (the root link being fixed). - new_root: The prim taking over the articulation-root role (the parent). - """ - registry = Usd.SchemaRegistry() - new_root_applied = set(new_root.GetPrimTypeInfo().GetAppliedAPISchemas()) - for schema_name in list(old_root.GetPrimTypeInfo().GetAppliedAPISchemas()): - # multi-apply instances carry an ":instance" suffix; the registry keys on the base name - base_name = schema_name.split(":", 1)[0] - prim_def = registry.FindAppliedAPIPrimDefinition(base_name) - composes_root = prim_def is not None and "PhysicsArticulationRootAPI" in prim_def.GetAppliedAPISchemas() - if not composes_root and base_name != "PhysxArticulationAPI": - continue - # move the applied schema to the new root - old_root.RemoveAppliedSchema(schema_name) - if schema_name not in new_root_applied: - new_root.AddAppliedSchema(schema_name) - new_root_applied.add(schema_name) - # copy the schema's authored attributes (property names come from the schema definition; - # fall back to the namespace prefix when the schema is not registered) - if prim_def is not None: - prop_names = list(prim_def.GetPropertyNames()) - else: - prefix = "physxArticulation:" if base_name == "PhysxArticulationAPI" else None - prop_names = [ - a.GetName() for a in old_root.GetAttributes() if prefix and a.GetName().startswith(prefix) - ] - for prop_name in prop_names: - attr = old_root.GetAttribute(prop_name) - if not attr or not attr.HasAuthoredValue(): - continue - new_attr = new_root.GetAttribute(prop_name) - if not new_attr: - new_attr = new_root.CreateAttribute(prop_name, attr.GetTypeName()) - new_attr.Set(attr.Get()) + fixed_root = super().fix_articulation_root(articulation_prim, stage) + # A previously relocated root is already a non-rigid parent; the manager call above only + # re-enables its existing joint, so there is nothing further to normalize. + if not fixed_root.HasAPI(UsdPhysics.RigidBodyAPI): + return fixed_root + return cls._relocate_articulation_root( + fixed_root, + companion_schema_namespaces={"PhysxArticulationAPI": "physxArticulation"}, + ) @classmethod def reset(cls, soft: bool = False) -> None: diff --git a/source/isaaclab_physx/isaaclab_physx/sim/schemas/__init__.py b/source/isaaclab_physx/isaaclab_physx/sim/schemas/__init__.py index d5cb72c6b81c..35574c630061 100644 --- a/source/isaaclab_physx/isaaclab_physx/sim/schemas/__init__.py +++ b/source/isaaclab_physx/isaaclab_physx/sim/schemas/__init__.py @@ -10,11 +10,6 @@ lazy_export() -# Fixing an articulation base is a backend capability on the physics manager -# (:meth:`~isaaclab_physx.physics.PhysxManager.fix_articulation_root`): PhysX authors the fixed joint -# and relocates the articulation root to the parent prim (its parser does not treat a fixed joint on a -# rigid body as a fixed-base articulation). That PhysX-specific logic lives on the manager, so this -# schema package no longer registers a creator. def _is_physx_tendon_child(prim) -> bool: From 9b2cb9e3c1d7119f2482647e4e5ea1101bf66c8b Mon Sep 17 00:00:00 2001 From: Octi Zhang Date: Fri, 3 Jul 2026 23:19:37 -0700 Subject: [PATCH 19/23] Simplify articulation root handling --- .../isaaclab/physics/physics_manager.py | 169 +++++------------- .../isaaclab/isaaclab/sim/schemas/schemas.py | 113 +++++------- .../physics/ovphysx_manager.py | 34 +--- .../isaaclab_physx/physics/physx_manager.py | 42 +---- 4 files changed, 108 insertions(+), 250 deletions(-) diff --git a/source/isaaclab/isaaclab/physics/physics_manager.py b/source/isaaclab/isaaclab/physics/physics_manager.py index 4a818c16d7de..04544583b6ea 100644 --- a/source/isaaclab/isaaclab/physics/physics_manager.py +++ b/source/isaaclab/isaaclab/physics/physics_manager.py @@ -101,175 +101,98 @@ def provides_implicit_damping(cls) -> bool: @classmethod def fix_articulation_root(cls, articulation_prim: Any, stage: Any = None) -> Any: - """Fix an articulation base to the world frame and return the resulting root prim. + """Ensure that an articulation root has one enabled world fixed joint. - Ensures that a world-to-root fixed joint exists and is enabled. The operation is idempotent: - an existing disabled joint is enabled rather than duplicated. The base implementation leaves - ``articulation_prim`` unchanged, which is sufficient for backends whose parser reads a fixed - joint on the root directly (e.g. Newton). Backends whose parser requires the articulation-root - schema on the root link's parent override this method, call ``super()``, then relocate the root - and return the resulting prim. - - This is a backend capability rather than a registry keyed by cfg type: the active backend's - manager class is resolved from ``cfg.physics.class_type``, so subclassed cfgs and every - supported backend inherit the correct behaviour through the normal method-resolution order. + 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 resolved articulation-root prim to fix to the world frame. - stage: The stage the prim lives on. Defaults to None, in which case the current stage is - used. + articulation_prim: The articulation-root prim to fix. + stage: The stage containing the prim. Defaults to the current stage. Returns: - The articulation-root prim after fixing. May differ from ``articulation_prim`` when the - backend relocates the root (e.g. PhysX moves it to the parent prim). + The articulation-root prim after backend normalization. Raises: - NotImplementedError: When the root prim is not a rigid body and the first rigid-body link - cannot be determined to anchor the fixed joint. + NotImplementedError: If a new joint is needed and the root is not a rigid body. """ + from pxr import Gf, UsdGeom, UsdPhysics # noqa: PLC0415 from isaaclab.sim.utils import find_global_fixed_joint_prim # noqa: PLC0415 from isaaclab.sim.utils.stage import get_current_stage # noqa: PLC0415 if stage is None: stage = get_current_stage() - existing_joint = find_global_fixed_joint_prim(articulation_prim.GetPath().pathString, stage=stage) - if existing_joint is not None: - existing_joint.GetJointEnabledAttr().Set(True) - return articulation_prim - - cls._create_fixed_root_joint(articulation_prim, stage) - return articulation_prim - - @staticmethod - def _create_fixed_root_joint(articulation_prim: Any, stage: Any) -> Any: - """Author a world-to-root fixed joint at the articulation's current world pose.""" - from pxr import Gf, UsdGeom, UsdPhysics # noqa: PLC0415 - root_path = articulation_prim.GetPath().pathString + joint = find_global_fixed_joint_prim(root_path, stage=stage) + if joint is not None: + joint.GetJointEnabledAttr().Set(True) + return articulation_prim if not articulation_prim.HasAPI(UsdPhysics.RigidBodyAPI): - raise NotImplementedError( - 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." - ) + raise NotImplementedError(f"Cannot fix non-rigid articulation root '{root_path}'.") - joint_parent = articulation_prim - while joint_parent != stage.GetPseudoRoot(): - if joint_parent.IsInPrototype() or joint_parent.IsInstanceProxy() or joint_parent.IsInstanceable(): - joint_parent = joint_parent.GetParent() - else: - break - parent_path = joint_parent.GetPath().pathString - if parent_path == "/": - parent_path = "" - joint_name = "FixedJoint" - if stage.GetPrimAtPath(f"{parent_path}/{joint_name}").IsValid(): - index = 0 - while stage.GetPrimAtPath(f"{parent_path}/{joint_name}{index}").IsValid(): - index += 1 - joint_name = f"{joint_name}{index}" + joint_path = f"{root_path}/FixedJoint" + index = 0 + while stage.GetPrimAtPath(joint_path).IsValid(): + index += 1 + joint_path = f"{root_path}/FixedJoint{index}" world_xform = UsdGeom.XformCache().GetLocalToWorldTransform(articulation_prim).RemoveScaleShear() - joint = UsdPhysics.FixedJoint.Define(stage, f"{parent_path}/{joint_name}") + joint = UsdPhysics.FixedJoint.Define(stage, joint_path) joint.CreateBody1Rel().SetTargets([articulation_prim.GetPath()]) joint.CreateLocalPos0Attr().Set(Gf.Vec3f(world_xform.ExtractTranslation())) joint.CreateLocalRot0Attr().Set(Gf.Quatf(world_xform.ExtractRotationQuat())) - joint.CreateLocalPos1Attr().Set(Gf.Vec3f(0.0)) - joint.CreateLocalRot1Attr().Set(Gf.Quatf(1.0)) - max_float = 3.40282347e38 - joint.CreateBreakForceAttr().Set(max_float) - joint.CreateBreakTorqueAttr().Set(max_float) - return joint.GetPrim() + return articulation_prim @staticmethod def _relocate_articulation_root( articulation_prim: Any, - companion_schema_namespaces: dict[str, str] | None = None, + companion_schema: str, + companion_namespace: str, ) -> Any: - """Move an articulation-root schema family to the root link's parent. - - The pure-USD operation is shared by backends whose parser requires parent-root topology. - Every applied API that composes ``PhysicsArticulationRootAPI`` is moved, together with any - backend companion schemas named by ``companion_schema_namespaces``. Authored properties are - flattened before the old schemas are removed, preserving defaults, time samples, metadata, - connections, and relationship targets even when opinions come through a referenced asset. - """ + """Move root-bearing schemas and authored properties to the root link's parent.""" from pxr import Usd, UsdPhysics # noqa: PLC0415 - companion_schema_namespaces = companion_schema_namespaces or {} - if not articulation_prim.HasAPI(UsdPhysics.ArticulationRootAPI): - raise ValueError( - f"Prim '{articulation_prim.GetPath()}' is not an articulation root and cannot be relocated." - ) new_root = articulation_prim.GetParent() if new_root.HasAPI(UsdPhysics.ArticulationRootAPI): raise RuntimeError( - f"Cannot relocate articulation root '{articulation_prim.GetPath()}' to" - f" '{new_root.GetPath()}': the destination is already an articulation root." - " Relocation would merge distinct articulations." + f"Cannot relocate '{articulation_prim.GetPath()}' to existing articulation root '{new_root.GetPath()}'." ) - registry = Usd.SchemaRegistry() - root_schema_name = UsdPhysics.Tokens.PhysicsArticulationRootAPI - schemas_to_move: list[tuple[str, list[Any]]] = [] - has_root_schema = False + registry = Usd.SchemaRegistry() + root_schema = UsdPhysics.Tokens.PhysicsArticulationRootAPI + schemas_to_move = [] for schema_name in articulation_prim.GetPrimTypeInfo().GetAppliedAPISchemas(): - base_name, instance_name = registry.GetTypeNameAndInstance(schema_name) - prim_def = registry.FindAppliedAPIPrimDefinition(base_name) - is_root_anchor = base_name == root_schema_name - composes_root = prim_def is not None and root_schema_name in prim_def.GetAppliedAPISchemas() - if not is_root_anchor and not composes_root and base_name not in companion_schema_namespaces: + definition = registry.FindAppliedAPIPrimDefinition(schema_name) + if schema_name == companion_schema: + properties = list(articulation_prim.GetAuthoredPropertiesInNamespace(companion_namespace)) + elif schema_name == root_schema or ( + definition is not None and root_schema in definition.GetAppliedAPISchemas() + ): + properties = [] + if definition is not None: + for property_name in definition.GetPropertyNames(): + prop = articulation_prim.GetProperty(property_name) + if prop and prop.IsAuthored(): + properties.append(prop) + else: continue - if is_root_anchor or composes_root: - has_root_schema = True - - property_names = list(prim_def.GetPropertyNames()) if prim_def is not None else [] - if instance_name: - property_names = [ - registry.MakeMultipleApplyNameInstance(name, instance_name) for name in property_names - ] - fallback_namespace = companion_schema_namespaces.get(base_name) - if fallback_namespace is not None: - property_names.extend( - prop.GetName() for prop in articulation_prim.GetAuthoredPropertiesInNamespace(fallback_namespace) - ) - properties = [] - for property_name in dict.fromkeys(property_names): - prop = articulation_prim.GetProperty(property_name) - if prop and prop.IsAuthored(): - properties.append(prop) schemas_to_move.append((schema_name, properties)) - if not has_root_schema: - raise RuntimeError( - f"Cannot relocate articulation root '{articulation_prim.GetPath()}': its root API is" - " built in or auto-applied, so there is no movable root-schema opinion." - ) - new_root_schemas = set(new_root.GetPrimTypeInfo().GetAppliedAPISchemas()) for schema_name, properties in schemas_to_move: - if schema_name not in new_root_schemas: - if not new_root.AddAppliedSchema(schema_name): - raise RuntimeError(f"Failed to apply schema '{schema_name}' to '{new_root.GetPath()}'.") - new_root_schemas.add(schema_name) + if not new_root.AddAppliedSchema(schema_name): + raise RuntimeError(f"Failed to apply '{schema_name}' to '{new_root.GetPath()}'.") for prop in properties: if not prop.FlattenTo(new_root): - raise RuntimeError( - f"Failed to move property '{prop.GetPath()}' to articulation root '{new_root.GetPath()}'." - ) - + raise RuntimeError(f"Failed to move '{prop.GetPath()}' to '{new_root.GetPath()}'.") for schema_name, _ in schemas_to_move: if not articulation_prim.RemoveAppliedSchema(schema_name): - raise RuntimeError( - f"Failed to remove schema '{schema_name}' from old articulation root" - f" '{articulation_prim.GetPath()}'." - ) - if not new_root.HasAPI(UsdPhysics.ArticulationRootAPI) or articulation_prim.HasAPI( + raise RuntimeError(f"Failed to remove '{schema_name}' from '{articulation_prim.GetPath()}'.") + if articulation_prim.HasAPI(UsdPhysics.ArticulationRootAPI) or not new_root.HasAPI( UsdPhysics.ArticulationRootAPI ): raise RuntimeError( - f"Relocating articulation root '{articulation_prim.GetPath()}' to '{new_root.GetPath()}'" - " did not produce exactly one root at the destination." + f"Failed to relocate articulation root '{articulation_prim.GetPath()}' to '{new_root.GetPath()}'." ) return new_root diff --git a/source/isaaclab/isaaclab/sim/schemas/schemas.py b/source/isaaclab/isaaclab/sim/schemas/schemas.py index 309dd8cc9fbd..cb687ad40d01 100644 --- a/source/isaaclab/isaaclab/sim/schemas/schemas.py +++ b/source/isaaclab/isaaclab/sim/schemas/schemas.py @@ -281,84 +281,69 @@ def apply_articulation_root_properties( stage: Usd.Stage | None = None, fix_root_link: bool | None = None, ) -> bool: - """Apply articulation-root fragments to every top-level articulation under a prim. + """Apply fragments to every top-level articulation root under a prim. - Existing articulation roots may live on descendant root links, and a USD file may contain - several sibling articulations. This writer discovers every top-level (non-nested) root and - dispatches each fragment to each root, matching the traversal semantics of the legacy - :func:`modify_articulation_root_properties` writer. Only when no root exists and fragments are - present does it apply ``UsdPhysics.ArticulationRootAPI`` to ``prim_path`` as a fresh anchor. + Existing roots are discovered before a fresh anchor is applied, including roots hidden in + instances. Nested roots are pruned, while sibling roots are all processed. Instance roots are + reported but not authored. - Instance proxies are traversed for discovery so a hidden root suppresses that define-fresh - fallback, but they are never authored through. Skipped instance roots make the operation return - ``False`` with a warning, matching the legacy nested writer. - - When ``fix_root_link`` is ``True``, each writable root is normalized by the active backend's - :meth:`~isaaclab.physics.PhysicsManager.fix_articulation_root` capability before fragments are - written. The capability creates or enables the world joint and returns the final root prim; - PhysX-based parsers may relocate it to the parent. ``False`` disables an existing world joint - without creating one. Backend fragments carry backend-specific funcs, so core imports no backend. + When fix_root_link is True, the active physics manager creates or enables the world joint and + returns the backend's final root prim. False only disables an existing joint. Args: prim_path: The prim path whose subtree is searched for articulation roots. - fragments: An iterable of :class:`~isaaclab.sim.schemas.ArticulationRootFragment` instances. - stage: The stage where to find the prim. Defaults to the current stage. - fix_root_link: Whether to fix the root link. This is spawner topology state, not a fragment - field. ``None`` leaves the topology unchanged. + fragments: Articulation-root fragments to apply. + stage: The stage containing the prim. Defaults to the current stage. + fix_root_link: Whether to fix the root link. None leaves topology unchanged. Returns: - True when every fragment/root operation succeeds. False when any fragment reports failure or - an articulation root is skipped because it is instanced. + True if every writable root and fragment succeeds and no instance root is skipped. Raises: - TypeError: When an item in ``fragments`` is not an - :class:`~isaaclab.sim.schemas.ArticulationRootFragment`. - ValueError: When ``prim_path`` is not valid. - RuntimeError: When fixing requires a backend but no active - :class:`~isaaclab.sim.SimulationContext` exists, or relocation would merge roots. - NotImplementedError: Propagated when a backend cannot fix the resolved root. + TypeError: If fragments contains a non-articulation fragment. + ValueError: If prim_path is invalid. + RuntimeError: If fixing cannot resolve the active backend or relocate the root. + NotImplementedError: If the backend cannot fix the resolved root. """ fragments = list(fragments) for fragment in fragments: if not isinstance(fragment, schemas_cfg.ArticulationRootFragment): raise TypeError( - "apply_articulation_root_properties: expected ArticulationRootFragment instances in" - f" 'fragments', got '{type(fragment).__name__}'. Pass a legacy single cfg to" - " modify_articulation_root_properties instead." + f"Expected ArticulationRootFragment, got '{type(fragment).__name__}'." + " Pass legacy cfgs to modify_articulation_root_properties." ) + dispatchers = [ + fragment.func if callable(fragment.func) else string_to_callable(fragment.func) for fragment in fragments + ] if stage is None: stage = get_current_stage() - # Discover proxies as well as writable prims so a root hidden inside an instance cannot trigger a - # second anchor on the instance root. Keep only top-level matches; nested articulations are pruned. - matches = get_all_matching_child_prims( + roots = [] + for candidate in get_all_matching_child_prims( prim_path, lambda prim: prim.HasAPI(UsdPhysics.ArticulationRootAPI), stage=stage, traverse_instance_prims=True, - ) - top_level_roots = [] - for candidate in matches: - if not any(candidate.GetPath().HasPrefix(root.GetPath()) for root in top_level_roots): - top_level_roots.append(candidate) + ): + if not any(candidate.GetPath().HasPrefix(root.GetPath()) for root in roots): + roots.append(candidate) writable_roots = [] skipped_roots = [] - for root in top_level_roots: - if root.IsInstance() or root.IsInstanceProxy() or root.IsInPrototype(): + for root in roots: + if root.IsInstance() or root.IsInstanceProxy(): skipped_roots.append(root) else: writable_roots.append(root) - if not top_level_roots and fragments: - input_prim = stage.GetPrimAtPath(prim_path) - if input_prim.IsInstance() or input_prim.IsInstanceProxy() or input_prim.IsInPrototype(): - skipped_roots.append(input_prim) + if not roots and fragments: + root = stage.GetPrimAtPath(prim_path) + if root.IsInstance() or root.IsInstanceProxy(): + skipped_roots.append(root) else: - UsdPhysics.ArticulationRootAPI.Apply(input_prim) - writable_roots.append(input_prim) + UsdPhysics.ArticulationRootAPI.Apply(root) + writable_roots.append(root) - success = not skipped_roots if skipped_roots: logger.warning( "Skipping articulation-root updates on instanced prims: %s.", @@ -369,37 +354,27 @@ def apply_articulation_root_properties( logger.warning( "No articulation root found under '%s': ignoring fix_root_link=%s.", prim_path, fix_root_link ) - success = False - return success + return not skipped_roots and fix_root_link is None - physics_manager = None if fix_root_link: from isaaclab.sim import SimulationContext sim = SimulationContext.instance() if sim is None: - raise RuntimeError( - f"Cannot fix articulation roots under '{prim_path}': no active SimulationContext to" - " resolve the physics backend from. A simulation must be running to fix a base." - ) - physics_manager = sim.physics_manager + raise RuntimeError(f"Cannot fix articulation roots under '{prim_path}' without an active simulation.") - for articulation_prim in writable_roots: - final_root = articulation_prim - root_path = articulation_prim.GetPath().pathString + success = not skipped_roots + for root in writable_roots: if fix_root_link: - logger.info("Ensuring a fixed root for articulation: '%s'.", root_path) - final_root = physics_manager.fix_articulation_root(articulation_prim, stage) + root = sim.physics_manager.fix_articulation_root(root, stage) elif fix_root_link is False: - existing_joint = find_global_fixed_joint_prim(root_path, stage=stage) - if existing_joint is not None: - logger.info("Disabling the existing fixed joint for articulation: '%s'.", root_path) - existing_joint.GetJointEnabledAttr().Set(False) - - final_root_path = final_root.GetPath().pathString - for fragment in fragments: - func = fragment.func if callable(fragment.func) else string_to_callable(fragment.func) - success = bool(func(fragment, final_root_path, stage)) and success + joint = find_global_fixed_joint_prim(root.GetPath().pathString, stage=stage) + if joint is not None: + joint.GetJointEnabledAttr().Set(False) + + root_path = root.GetPath().pathString + for fragment, func in zip(fragments, dispatchers): + success = bool(func(fragment, root_path, stage)) and success return success diff --git a/source/isaaclab_ovphysx/isaaclab_ovphysx/physics/ovphysx_manager.py b/source/isaaclab_ovphysx/isaaclab_ovphysx/physics/ovphysx_manager.py index c0e2b2872b52..9e6efb7752bd 100644 --- a/source/isaaclab_ovphysx/isaaclab_ovphysx/physics/ovphysx_manager.py +++ b/source/isaaclab_ovphysx/isaaclab_ovphysx/physics/ovphysx_manager.py @@ -230,31 +230,15 @@ def get_dt(cls) -> float: @classmethod def fix_articulation_root(cls, articulation_prim: Any, stage: Any = None) -> Any: - """Fix an articulation base using the parent-root topology required by OVPhysX. - - OVPhysX uses the PhysX articulation parser, so a world joint on a rigid root link is not by - itself classified as a fixed-base articulation. The base manager creates or enables the - joint; this override then relocates the complete articulation-root schema family to the root - link's parent, preserving authored property specifications. - - Args: - articulation_prim: The resolved articulation-root prim to fix to the world frame. - stage: The stage the prim lives on. Defaults to None, in which case the current stage is - used. - - Returns: - The parent prim, which becomes the articulation root after relocation. - - Raises: - NotImplementedError: When the root prim is not a rigid body. - """ - fixed_root = super().fix_articulation_root(articulation_prim, stage) - if not fixed_root.HasAPI(UsdPhysics.RigidBodyAPI): - return fixed_root - return cls._relocate_articulation_root( - fixed_root, - companion_schema_namespaces={"PhysxArticulationAPI": "physxArticulation"}, - ) + """Fix and normalize an articulation root for the OVPhysX parser.""" + root = super().fix_articulation_root(articulation_prim, stage) + if root.HasAPI(UsdPhysics.RigidBodyAPI): + return cls._relocate_articulation_root( + root, + companion_schema="PhysxArticulationAPI", + companion_namespace="physxArticulation", + ) + return root @classmethod def register_clone( diff --git a/source/isaaclab_physx/isaaclab_physx/physics/physx_manager.py b/source/isaaclab_physx/isaaclab_physx/physics/physx_manager.py index 9499b3f07793..d1d1ead0fe88 100644 --- a/source/isaaclab_physx/isaaclab_physx/physics/physx_manager.py +++ b/source/isaaclab_physx/isaaclab_physx/physics/physx_manager.py @@ -303,39 +303,15 @@ def initialize(cls, sim_context: SimulationContext) -> None: @classmethod def fix_articulation_root(cls, articulation_prim: Any, stage: Any = None) -> Any: - """Fix an articulation base for the PhysX parser and return the relocated root prim. - - The PhysX parser does not treat a fixed joint on a rigid body as a fixed-base articulation -- - it folds it into the maximal-coordinate tree. The base manager first creates or enables the - world-to-root joint, then this override relocates the complete articulation-root schema family - from the rigid root link to its parent. Articulation-root fragments are applied by the caller - after this call, so they land on the returned root. - - Asset-authored root schemas move with the root: any API schema directly authored on the former - root that composes ``PhysicsArticulationRootAPI`` is migrated without naming another backend. - The ``PhysxArticulationAPI`` companion moves with it. Property migration preserves complete - authored USD state, including time samples, metadata, connections, and referenced opinions. - - Args: - articulation_prim: The resolved articulation-root prim to fix to the world frame. - stage: The stage the prim lives on. Defaults to None, in which case the current stage is - used. - - Returns: - The parent prim, which becomes the articulation root after relocation. - - Raises: - NotImplementedError: When the root prim is not a rigid body. - """ - fixed_root = super().fix_articulation_root(articulation_prim, stage) - # A previously relocated root is already a non-rigid parent; the manager call above only - # re-enables its existing joint, so there is nothing further to normalize. - if not fixed_root.HasAPI(UsdPhysics.RigidBodyAPI): - return fixed_root - return cls._relocate_articulation_root( - fixed_root, - companion_schema_namespaces={"PhysxArticulationAPI": "physxArticulation"}, - ) + """Fix and normalize an articulation root for the PhysX parser.""" + root = super().fix_articulation_root(articulation_prim, stage) + if root.HasAPI(UsdPhysics.RigidBodyAPI): + return cls._relocate_articulation_root( + root, + companion_schema="PhysxArticulationAPI", + companion_namespace="physxArticulation", + ) + return root @classmethod def reset(cls, soft: bool = False) -> None: From 0d4b413f46825d95b50bd6bd55f82bc16f681a17 Mon Sep 17 00:00:00 2001 From: Octi Zhang Date: Fri, 3 Jul 2026 23:45:55 -0700 Subject: [PATCH 20/23] Test Newton inherited fixed-root handling --- .../test/assets/test_articulation.py | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/source/isaaclab_newton/test/assets/test_articulation.py b/source/isaaclab_newton/test/assets/test_articulation.py index 287c88762c59..16cb5b9d55db 100644 --- a/source/isaaclab_newton/test/assets/test_articulation.py +++ b/source/isaaclab_newton/test/assets/test_articulation.py @@ -28,6 +28,8 @@ from isaaclab_newton.physics import NewtonManager as SimulationManager from newton.solvers import SolverNotifyFlags +from pxr import UsdPhysics + import isaaclab.sim as sim_utils import isaaclab.utils.math as math_utils import isaaclab.utils.string as string_utils @@ -782,6 +784,29 @@ def test_initialization_hand_with_tendons(sim, num_articulations, device, articu articulation.update(sim.cfg.dt) +@pytest.mark.parametrize("device", ["cpu"]) +@pytest.mark.parametrize("add_ground_plane", [True]) +@pytest.mark.parametrize("articulation_type", ["anymal"]) +def test_fragment_fix_root_link_uses_base_manager(sim, device, add_ground_plane, articulation_type): + """Newton consumes the base manager's world joint without relocating the root API.""" + articulation_cfg = deepcopy(generate_articulation_cfg(articulation_type=articulation_type)) + articulation_cfg.spawn.articulation_props = [] + articulation_cfg.spawn.fix_root_link = True + articulation, _ = generate_articulation(articulation_cfg, num_articulations=1, device=device) + + root = sim_utils.get_first_matching_child_prim( + "/World/Env_0/Robot", + lambda prim: prim.HasAPI(UsdPhysics.ArticulationRootAPI), + stage=sim.stage, + ) + assert root is not None and root.HasAPI(UsdPhysics.RigidBodyAPI) + assert sim_utils.find_global_fixed_joint_prim("/World/Env_0/Robot", stage=sim.stage) is not None + + sim.reset() + assert articulation.is_initialized + assert articulation.is_fixed_base + + @pytest.mark.parametrize("num_articulations", [1, 2]) @pytest.mark.parametrize("device", ["cuda:0", "cpu"]) @pytest.mark.parametrize("add_ground_plane", [True]) From 7774ba318ba37c89399efa2161cf27e9caad6dd0 Mon Sep 17 00:00:00 2001 From: Octi Zhang Date: Sat, 4 Jul 2026 00:52:19 -0700 Subject: [PATCH 21/23] Avoid local imports in articulation root handling --- .../isaaclab/physics/physics_manager.py | 12 ++-- .../isaaclab/isaaclab/sim/utils/__init__.pyi | 2 +- source/isaaclab/isaaclab/sim/utils/joints.py | 72 +++++++++++++++++++ source/isaaclab/isaaclab/sim/utils/queries.py | 65 +---------------- 4 files changed, 80 insertions(+), 71 deletions(-) create mode 100644 source/isaaclab/isaaclab/sim/utils/joints.py diff --git a/source/isaaclab/isaaclab/physics/physics_manager.py b/source/isaaclab/isaaclab/physics/physics_manager.py index 04544583b6ea..d2f7bbff288e 100644 --- a/source/isaaclab/isaaclab/physics/physics_manager.py +++ b/source/isaaclab/isaaclab/physics/physics_manager.py @@ -14,6 +14,11 @@ from enum import Enum from typing import TYPE_CHECKING, Any, ClassVar +from pxr import Gf, Usd, UsdGeom, UsdPhysics + +from isaaclab.sim.utils.joints import find_global_fixed_joint_prim +from isaaclab.sim.utils.stage import get_current_stage + if TYPE_CHECKING: from isaaclab.scene_data import SceneDataBackend from isaaclab.sim.simulation_context import SimulationContext @@ -116,11 +121,6 @@ def fix_articulation_root(cls, articulation_prim: Any, stage: Any = None) -> Any Raises: NotImplementedError: If a new joint is needed and the root is not a rigid body. """ - from pxr import Gf, UsdGeom, UsdPhysics # noqa: PLC0415 - - from isaaclab.sim.utils import find_global_fixed_joint_prim # noqa: PLC0415 - from isaaclab.sim.utils.stage import get_current_stage # noqa: PLC0415 - if stage is None: stage = get_current_stage() root_path = articulation_prim.GetPath().pathString @@ -151,8 +151,6 @@ def _relocate_articulation_root( companion_namespace: str, ) -> Any: """Move root-bearing schemas and authored properties to the root link's parent.""" - from pxr import Usd, UsdPhysics # noqa: PLC0415 - new_root = articulation_prim.GetParent() if new_root.HasAPI(UsdPhysics.ArticulationRootAPI): raise RuntimeError( diff --git a/source/isaaclab/isaaclab/sim/utils/__init__.pyi b/source/isaaclab/isaaclab/sim/utils/__init__.pyi index cfe39d0933e1..a13d34fc2e97 100644 --- a/source/isaaclab/isaaclab/sim/utils/__init__.pyi +++ b/source/isaaclab/isaaclab/sim/utils/__init__.pyi @@ -89,6 +89,7 @@ from .prims import ( get_usd_references, select_usd_variants, ) +from .joints import find_global_fixed_joint_prim from .queries import ( get_next_free_prim_path, get_first_matching_ancestor_prim, @@ -99,7 +100,6 @@ from .queries import ( matches_path_expr_prefix, resolve_matching_prims_from_source, find_matching_prim_paths, - find_global_fixed_joint_prim, ) from .semantics import ( add_labels, diff --git a/source/isaaclab/isaaclab/sim/utils/joints.py b/source/isaaclab/isaaclab/sim/utils/joints.py new file mode 100644 index 000000000000..2847e76955cb --- /dev/null +++ b/source/isaaclab/isaaclab/sim/utils/joints.py @@ -0,0 +1,72 @@ +# 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 + +"""Utilities for querying USD physics joints.""" + +from __future__ import annotations + +from pxr import Sdf, Usd, UsdPhysics + +from .stage import get_current_stage + + +def find_global_fixed_joint_prim( + prim_path: str | Sdf.Path, check_enabled_only: bool = False, stage: Usd.Stage | None = None +) -> UsdPhysics.Joint | None: + """Find the fixed joint prim under the specified prim path that connects the target to the simulation world. + + A joint is a connection between two bodies. A fixed joint is a joint that does not allow relative motion + 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. + + Args: + prim_path: The prim path to search for the fixed joint prim. + check_enabled_only: Whether to consider only enabled fixed joints. Defaults to False. + If False, then all joints (enabled or disabled) are considered. + 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. + + Raises: + ValueError: If the prim path is not global (i.e: does not start with '/'). + ValueError: If the prim path does not exist on the stage. + """ + # get stage handle + if stage is None: + stage = get_current_stage() + + # check prim path is global + if not prim_path.startswith("/"): + raise ValueError(f"Prim path '{prim_path}' is not global. It must start with '/'.") + + # check if prim exists + prim = stage.GetPrimAtPath(prim_path) + if not prim.IsValid(): + raise ValueError(f"Prim at path '{prim_path}' is not valid.") + + fixed_joint_prim = None + # we check all joints under the root prim and classify the asset as fixed base if there exists + # a fixed joint that has only one target (i.e. the root link). + for prim in Usd.PrimRange(prim): + # note: ideally checking if it is FixedJoint would have been enough, but some assets use "Joint" as the + # schema name which makes it difficult to distinguish between the two. + joint_prim = UsdPhysics.Joint(prim) + if joint_prim: + # if check_enabled_only is True, we only consider enabled joints + if check_enabled_only and not joint_prim.GetJointEnabledAttr().Get(): + continue + # check body 0 and body 1 exist + body_0_exist = joint_prim.GetBody0Rel().GetTargets() != [] + body_1_exist = joint_prim.GetBody1Rel().GetTargets() != [] + # if either body 0 or body 1 does not exist, we have a fixed joint that connects to the world + if not (body_0_exist and body_1_exist): + fixed_joint_prim = joint_prim + break + + return fixed_joint_prim diff --git a/source/isaaclab/isaaclab/sim/utils/queries.py b/source/isaaclab/isaaclab/sim/utils/queries.py index 4d5dcb3ac024..de7e728c8e19 100644 --- a/source/isaaclab/isaaclab/sim/utils/queries.py +++ b/source/isaaclab/isaaclab/sim/utils/queries.py @@ -15,10 +15,11 @@ from isaaclab.cloner.cloner_utils import resolve_clone_plan_source from isaaclab.sim.simulation_context import SimulationContext +from .joints import find_global_fixed_joint_prim as find_global_fixed_joint_prim from .stage import get_current_stage if TYPE_CHECKING: - from pxr import Sdf, Usd, UsdPhysics # noqa: F401 + from pxr import Sdf, Usd # noqa: F401 # import logger logger = logging.getLogger(__name__) @@ -500,65 +501,3 @@ def find_matching_prim_paths(prim_path_regex: str, stage: Usd.Stage | None = Non for prim in output_prims: output_prim_paths.append(prim.GetPath().pathString) return output_prim_paths - - -def find_global_fixed_joint_prim( - prim_path: str | Sdf.Path, check_enabled_only: bool = False, stage: Usd.Stage | None = None -) -> UsdPhysics.Joint | None: - """Find the fixed joint prim under the specified prim path that connects the target to the simulation world. - - A joint is a connection between two bodies. A fixed joint is a joint that does not allow relative motion - 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. - - Args: - prim_path: The prim path to search for the fixed joint prim. - check_enabled_only: Whether to consider only enabled fixed joints. Defaults to False. - If False, then all joints (enabled or disabled) are considered. - 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. - - Raises: - ValueError: If the prim path is not global (i.e: does not start with '/'). - ValueError: If the prim path does not exist on the stage. - """ - from pxr import Usd, UsdPhysics # noqa: PLC0415 - - # get stage handle - if stage is None: - stage = get_current_stage() - - # check prim path is global - if not prim_path.startswith("/"): - raise ValueError(f"Prim path '{prim_path}' is not global. It must start with '/'.") - - # check if prim exists - prim = stage.GetPrimAtPath(prim_path) - if not prim.IsValid(): - raise ValueError(f"Prim at path '{prim_path}' is not valid.") - - fixed_joint_prim = None - # we check all joints under the root prim and classify the asset as fixed base if there exists - # a fixed joint that has only one target (i.e. the root link). - for prim in Usd.PrimRange(prim): - # note: ideally checking if it is FixedJoint would have been enough, but some assets use "Joint" as the - # schema name which makes it difficult to distinguish between the two. - joint_prim = UsdPhysics.Joint(prim) - if joint_prim: - # if check_enabled_only is True, we only consider enabled joints - if check_enabled_only and not joint_prim.GetJointEnabledAttr().Get(): - continue - # check body 0 and body 1 exist - body_0_exist = joint_prim.GetBody0Rel().GetTargets() != [] - body_1_exist = joint_prim.GetBody1Rel().GetTargets() != [] - # if either body 0 or body 1 does not exist, we have a fixed joint that connects to the world - if not (body_0_exist and body_1_exist): - fixed_joint_prim = joint_prim - break - - return fixed_joint_prim From 280c20a02e57f01f56e50c180d40a00e65e78b52 Mon Sep 17 00:00:00 2001 From: Octi Zhang Date: Sat, 4 Jul 2026 02:57:43 -0700 Subject: [PATCH 22/23] Keep cyclic fixed-joint query import local --- .../isaaclab/physics/physics_manager.py | 5 +- .../isaaclab/isaaclab/sim/utils/__init__.pyi | 2 +- source/isaaclab/isaaclab/sim/utils/joints.py | 72 ------------------- source/isaaclab/isaaclab/sim/utils/queries.py | 65 ++++++++++++++++- 4 files changed, 68 insertions(+), 76 deletions(-) delete mode 100644 source/isaaclab/isaaclab/sim/utils/joints.py diff --git a/source/isaaclab/isaaclab/physics/physics_manager.py b/source/isaaclab/isaaclab/physics/physics_manager.py index d2f7bbff288e..840eef44e69e 100644 --- a/source/isaaclab/isaaclab/physics/physics_manager.py +++ b/source/isaaclab/isaaclab/physics/physics_manager.py @@ -16,7 +16,6 @@ from pxr import Gf, Usd, UsdGeom, UsdPhysics -from isaaclab.sim.utils.joints import find_global_fixed_joint_prim from isaaclab.sim.utils.stage import get_current_stage if TYPE_CHECKING: @@ -121,6 +120,10 @@ def fix_articulation_root(cls, articulation_prim: Any, stage: Any = None) -> Any Raises: NotImplementedError: If a new joint is needed and the root is not a rigid body. """ + # Keep this import local to avoid the SimulationContext -> PhysicsManager -> + # sim.utils.queries -> SimulationContext import cycle. + from isaaclab.sim.utils import find_global_fixed_joint_prim # noqa: PLC0415 + if stage is None: stage = get_current_stage() root_path = articulation_prim.GetPath().pathString diff --git a/source/isaaclab/isaaclab/sim/utils/__init__.pyi b/source/isaaclab/isaaclab/sim/utils/__init__.pyi index a13d34fc2e97..cfe39d0933e1 100644 --- a/source/isaaclab/isaaclab/sim/utils/__init__.pyi +++ b/source/isaaclab/isaaclab/sim/utils/__init__.pyi @@ -89,7 +89,6 @@ from .prims import ( get_usd_references, select_usd_variants, ) -from .joints import find_global_fixed_joint_prim from .queries import ( get_next_free_prim_path, get_first_matching_ancestor_prim, @@ -100,6 +99,7 @@ from .queries import ( matches_path_expr_prefix, resolve_matching_prims_from_source, find_matching_prim_paths, + find_global_fixed_joint_prim, ) from .semantics import ( add_labels, diff --git a/source/isaaclab/isaaclab/sim/utils/joints.py b/source/isaaclab/isaaclab/sim/utils/joints.py deleted file mode 100644 index 2847e76955cb..000000000000 --- a/source/isaaclab/isaaclab/sim/utils/joints.py +++ /dev/null @@ -1,72 +0,0 @@ -# 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 - -"""Utilities for querying USD physics joints.""" - -from __future__ import annotations - -from pxr import Sdf, Usd, UsdPhysics - -from .stage import get_current_stage - - -def find_global_fixed_joint_prim( - prim_path: str | Sdf.Path, check_enabled_only: bool = False, stage: Usd.Stage | None = None -) -> UsdPhysics.Joint | None: - """Find the fixed joint prim under the specified prim path that connects the target to the simulation world. - - A joint is a connection between two bodies. A fixed joint is a joint that does not allow relative motion - 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. - - Args: - prim_path: The prim path to search for the fixed joint prim. - check_enabled_only: Whether to consider only enabled fixed joints. Defaults to False. - If False, then all joints (enabled or disabled) are considered. - 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. - - Raises: - ValueError: If the prim path is not global (i.e: does not start with '/'). - ValueError: If the prim path does not exist on the stage. - """ - # get stage handle - if stage is None: - stage = get_current_stage() - - # check prim path is global - if not prim_path.startswith("/"): - raise ValueError(f"Prim path '{prim_path}' is not global. It must start with '/'.") - - # check if prim exists - prim = stage.GetPrimAtPath(prim_path) - if not prim.IsValid(): - raise ValueError(f"Prim at path '{prim_path}' is not valid.") - - fixed_joint_prim = None - # we check all joints under the root prim and classify the asset as fixed base if there exists - # a fixed joint that has only one target (i.e. the root link). - for prim in Usd.PrimRange(prim): - # note: ideally checking if it is FixedJoint would have been enough, but some assets use "Joint" as the - # schema name which makes it difficult to distinguish between the two. - joint_prim = UsdPhysics.Joint(prim) - if joint_prim: - # if check_enabled_only is True, we only consider enabled joints - if check_enabled_only and not joint_prim.GetJointEnabledAttr().Get(): - continue - # check body 0 and body 1 exist - body_0_exist = joint_prim.GetBody0Rel().GetTargets() != [] - body_1_exist = joint_prim.GetBody1Rel().GetTargets() != [] - # if either body 0 or body 1 does not exist, we have a fixed joint that connects to the world - if not (body_0_exist and body_1_exist): - fixed_joint_prim = joint_prim - break - - return fixed_joint_prim diff --git a/source/isaaclab/isaaclab/sim/utils/queries.py b/source/isaaclab/isaaclab/sim/utils/queries.py index de7e728c8e19..4d5dcb3ac024 100644 --- a/source/isaaclab/isaaclab/sim/utils/queries.py +++ b/source/isaaclab/isaaclab/sim/utils/queries.py @@ -15,11 +15,10 @@ from isaaclab.cloner.cloner_utils import resolve_clone_plan_source from isaaclab.sim.simulation_context import SimulationContext -from .joints import find_global_fixed_joint_prim as find_global_fixed_joint_prim from .stage import get_current_stage if TYPE_CHECKING: - from pxr import Sdf, Usd # noqa: F401 + from pxr import Sdf, Usd, UsdPhysics # noqa: F401 # import logger logger = logging.getLogger(__name__) @@ -501,3 +500,65 @@ def find_matching_prim_paths(prim_path_regex: str, stage: Usd.Stage | None = Non for prim in output_prims: output_prim_paths.append(prim.GetPath().pathString) return output_prim_paths + + +def find_global_fixed_joint_prim( + prim_path: str | Sdf.Path, check_enabled_only: bool = False, stage: Usd.Stage | None = None +) -> UsdPhysics.Joint | None: + """Find the fixed joint prim under the specified prim path that connects the target to the simulation world. + + A joint is a connection between two bodies. A fixed joint is a joint that does not allow relative motion + 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. + + Args: + prim_path: The prim path to search for the fixed joint prim. + check_enabled_only: Whether to consider only enabled fixed joints. Defaults to False. + If False, then all joints (enabled or disabled) are considered. + 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. + + Raises: + ValueError: If the prim path is not global (i.e: does not start with '/'). + ValueError: If the prim path does not exist on the stage. + """ + from pxr import Usd, UsdPhysics # noqa: PLC0415 + + # get stage handle + if stage is None: + stage = get_current_stage() + + # check prim path is global + if not prim_path.startswith("/"): + raise ValueError(f"Prim path '{prim_path}' is not global. It must start with '/'.") + + # check if prim exists + prim = stage.GetPrimAtPath(prim_path) + if not prim.IsValid(): + raise ValueError(f"Prim at path '{prim_path}' is not valid.") + + fixed_joint_prim = None + # we check all joints under the root prim and classify the asset as fixed base if there exists + # a fixed joint that has only one target (i.e. the root link). + for prim in Usd.PrimRange(prim): + # note: ideally checking if it is FixedJoint would have been enough, but some assets use "Joint" as the + # schema name which makes it difficult to distinguish between the two. + joint_prim = UsdPhysics.Joint(prim) + if joint_prim: + # if check_enabled_only is True, we only consider enabled joints + if check_enabled_only and not joint_prim.GetJointEnabledAttr().Get(): + continue + # check body 0 and body 1 exist + body_0_exist = joint_prim.GetBody0Rel().GetTargets() != [] + body_1_exist = joint_prim.GetBody1Rel().GetTargets() != [] + # if either body 0 or body 1 does not exist, we have a fixed joint that connects to the world + if not (body_0_exist and body_1_exist): + fixed_joint_prim = joint_prim + break + + return fixed_joint_prim From f6fd754ea74a4d8541ae41af120b7e07134de79e Mon Sep 17 00:00:00 2001 From: Vidur Vij Date: Mon, 6 Jul 2026 08:51:20 -0700 Subject: [PATCH 23/23] Defer pxr imports in the base physics manager The manager classes are imported while environment configs load (manager_base pulls PhysicsManager at module import), and config loading must stay free of USD/omni modules before the simulation app starts -- the kitless/Warp config-loading tests enforce this. A module-level "from pxr import ..." here therefore fails config loading for every registered environment. Import pxr inside the two methods that use it instead, mirroring the existing local import that avoids the SimulationContext import cycle. The module-level get_current_stage import is safe to keep: the stage utilities now defer their Isaac Sim integration until Kit is available. --- source/isaaclab/isaaclab/physics/physics_manager.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/source/isaaclab/isaaclab/physics/physics_manager.py b/source/isaaclab/isaaclab/physics/physics_manager.py index 840eef44e69e..fafab0e27010 100644 --- a/source/isaaclab/isaaclab/physics/physics_manager.py +++ b/source/isaaclab/isaaclab/physics/physics_manager.py @@ -14,8 +14,6 @@ from enum import Enum from typing import TYPE_CHECKING, Any, ClassVar -from pxr import Gf, Usd, UsdGeom, UsdPhysics - from isaaclab.sim.utils.stage import get_current_stage if TYPE_CHECKING: @@ -122,6 +120,11 @@ def fix_articulation_root(cls, articulation_prim: Any, stage: Any = None) -> Any """ # Keep this import local to avoid the SimulationContext -> PhysicsManager -> # sim.utils.queries -> SimulationContext import cycle. + # Keep pxr local as well: this module is imported while environment configs load (via the + # manager classes), and config loading must not pull USD/omni modules before the simulation + # app starts. + from pxr import Gf, UsdGeom, UsdPhysics # noqa: PLC0415 + from isaaclab.sim.utils import find_global_fixed_joint_prim # noqa: PLC0415 if stage is None: @@ -154,6 +157,11 @@ def _relocate_articulation_root( companion_namespace: str, ) -> Any: """Move root-bearing schemas and authored properties to the root link's parent.""" + # Keep pxr local: this module is imported while environment configs load (via the manager + # classes), and config loading must not pull USD/omni modules before the simulation app + # starts. + from pxr import Usd, UsdPhysics # noqa: PLC0415 + new_root = articulation_prim.GetParent() if new_root.HasAPI(UsdPhysics.ArticulationRootAPI): raise RuntimeError(