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..dd740d585249 --- /dev/null +++ b/source/isaaclab/changelog.d/vidurv-schema-frag-articulation.minor.rst @@ -0,0 +1,22 @@ +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. +* 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 +^^^^^^^ + +* 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/physics/physics_manager.py b/source/isaaclab/isaaclab/physics/physics_manager.py index 5c1a5d4403f1..3ea6abcd9ee2 100644 --- a/source/isaaclab/isaaclab/physics/physics_manager.py +++ b/source/isaaclab/isaaclab/physics/physics_manager.py @@ -14,6 +14,7 @@ from enum import Enum from typing import TYPE_CHECKING, Any, ClassVar +from isaaclab.sim.utils.stage import get_current_stage from isaaclab.utils._device import set_cuda_device if TYPE_CHECKING: @@ -101,6 +102,110 @@ def provides_implicit_damping(cls) -> bool: """ return True + @classmethod + def fix_articulation_root(cls, articulation_prim: Any, stage: Any = None) -> Any: + """Ensure that an articulation root has one enabled world fixed joint. + + The base implementation leaves the root in place. Backends whose parser requires a different + root topology may relocate it and return the resulting root prim. + + Args: + 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 backend normalization. + + 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. + # 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: + stage = get_current_stage() + 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"Cannot fix non-rigid articulation root '{root_path}'.") + + 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, joint_path) + joint.CreateBody1Rel().SetTargets([articulation_prim.GetPath()]) + joint.CreateLocalPos0Attr().Set(Gf.Vec3f(world_xform.ExtractTranslation())) + joint.CreateLocalRot0Attr().Set(Gf.Quatf(world_xform.ExtractRotationQuat())) + return articulation_prim + + @staticmethod + def _relocate_articulation_root( + articulation_prim: Any, + companion_schema: str, + 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( + f"Cannot relocate '{articulation_prim.GetPath()}' to existing articulation root '{new_root.GetPath()}'." + ) + + registry = Usd.SchemaRegistry() + root_schema = UsdPhysics.Tokens.PhysicsArticulationRootAPI + schemas_to_move = [] + for schema_name in articulation_prim.GetPrimTypeInfo().GetAppliedAPISchemas(): + 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 + schemas_to_move.append((schema_name, properties)) + + for schema_name, properties in schemas_to_move: + 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 '{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_name}' from '{articulation_prim.GetPath()}'.") + if articulation_prim.HasAPI(UsdPhysics.ArticulationRootAPI) or not new_root.HasAPI( + UsdPhysics.ArticulationRootAPI + ): + raise RuntimeError( + f"Failed to relocate articulation root '{articulation_prim.GetPath()}' to '{new_root.GetPath()}'." + ) + 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 38eef8c6f5e9..1b52e8f6cfcf 100644 --- a/source/isaaclab/isaaclab/sim/__init__.pyi +++ b/source/isaaclab/isaaclab/sim/__init__.pyi @@ -63,6 +63,7 @@ __all__ = [ "MeshCollisionFragment", "PhysxJointDrivePropertiesCfg", "PhysxRigidBodyPropertiesCfg", + "ArticulationRootFragment", "RigidBodyBaseCfg", "RigidBodyFragment", "SchemaFragment", @@ -70,6 +71,7 @@ __all__ = [ "UsdPhysicsCollisionCfg", "UsdPhysicsMeshCollisionCfg", "UsdPhysicsRigidBodyCfg", + "apply_articulation_root_properties", "apply_collision_properties", "apply_fixed_tendon_properties", "apply_mass_properties", @@ -228,6 +230,7 @@ from .schemas import ( MESH_APPROXIMATION_TOKENS, PHYSX_MESH_COLLISION_CFGS, USD_MESH_COLLISION_CFGS, + ArticulationRootFragment, ArticulationRootPropertiesCfg, BoundingCubePropertiesCfg, BoundingSpherePropertiesCfg, @@ -260,6 +263,7 @@ from .schemas import ( UsdPhysicsMeshCollisionCfg, UsdPhysicsRigidBodyCfg, activate_contact_sensors, + apply_articulation_root_properties, 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 b4f86cefcfc8..a735bf638e1a 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_fixed_tendon_properties", "apply_mass_properties", @@ -48,6 +49,7 @@ __all__ = [ "JointDriveFragment", "MassPropertiesCfg", "MeshCollisionBaseCfg", + "ArticulationRootFragment", "MeshCollisionFragment", "RigidBodyFragment", "SchemaFragment", @@ -73,6 +75,7 @@ from .schemas import ( PHYSX_MESH_COLLISION_CFGS, USD_MESH_COLLISION_CFGS, activate_contact_sensors, + apply_articulation_root_properties, apply_collision_properties, apply_fixed_tendon_properties, apply_mass_properties, @@ -104,6 +107,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 035f5509f4cf..b9e59f7d70c5 100644 --- a/source/isaaclab/isaaclab/sim/schemas/schemas.py +++ b/source/isaaclab/isaaclab/sim/schemas/schemas.py @@ -275,6 +275,110 @@ def apply_namespaced(cfg: schemas_cfg.SchemaFragment, prim_path: str, stage: Usd """ +def apply_articulation_root_properties( + prim_path: str, + fragments: Iterable[schemas_cfg.ArticulationRootFragment], + stage: Usd.Stage | None = None, + fix_root_link: bool | None = None, +) -> bool: + """Apply fragments to every top-level articulation root under a prim. + + 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. + + 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: 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 if every writable root and fragment succeeds and no instance root is skipped. + + Raises: + 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( + 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() + + roots = [] + for candidate in get_all_matching_child_prims( + prim_path, + lambda prim: prim.HasAPI(UsdPhysics.ArticulationRootAPI), + stage=stage, + traverse_instance_prims=True, + ): + if not any(candidate.GetPath().HasPrefix(root.GetPath()) for root in roots): + roots.append(candidate) + + writable_roots = [] + skipped_roots = [] + for root in roots: + if root.IsInstance() or root.IsInstanceProxy(): + skipped_roots.append(root) + else: + writable_roots.append(root) + + if not roots and fragments: + root = stage.GetPrimAtPath(prim_path) + if root.IsInstance() or root.IsInstanceProxy(): + skipped_roots.append(root) + else: + UsdPhysics.ArticulationRootAPI.Apply(root) + writable_roots.append(root) + + 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( + "No articulation root found under '%s': ignoring fix_root_link=%s.", prim_path, fix_root_link + ) + return not skipped_roots and fix_root_link is 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}' without an active simulation.") + + success = not skipped_roots + for root in writable_roots: + if fix_root_link: + root = sim.physics_manager.fix_articulation_root(root, stage) + elif fix_root_link is False: + 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 + + 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 f64838322141..797b907ed551 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 + :func:`~isaaclab.sim.schemas.modify_articulation_root_properties` behaviour). + """ + + pass + + @configclass class JointDriveFragment(SchemaFragment): """Marker base for joint-drive fragments; types the ``joint_drive_props`` slot.""" 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 80b9f940e417..985df6edca7d 100644 --- a/source/isaaclab/isaaclab/sim/spawners/from_files/from_files.py +++ b/source/isaaclab/isaaclab/sim/spawners/from_files/from_files.py @@ -369,8 +369,36 @@ 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: - schemas.modify_articulation_root_properties(prim_path, cfg.articulation_props) + # ``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 = 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 + # 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) + ): + 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 = ( + list(articulation_props) + if isinstance(articulation_props, (list, tuple)) + else ([articulation_props] if isinstance(articulation_props, schemas.SchemaFragment) else []) + ) + if articulation_frags or articulation_fix_root_link is not None: + schemas.apply_articulation_root_properties( + prim_path, articulation_frags, fix_root_link=articulation_fix_root_link + ) # 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/isaaclab/sim/spawners/from_files/from_files_cfg.py b/source/isaaclab/isaaclab/sim/spawners/from_files/from_files_cfg.py index 1c9c26e02525..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 @@ -34,8 +34,37 @@ 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` 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. + * 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 | schemas.FixedTendonFragment | list[schemas.FixedTendonFragment] | None 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..2319363122eb --- /dev/null +++ b/source/isaaclab/test/sim/test_articulation_fragments.py @@ -0,0 +1,724 @@ +# 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.""" + +import os + +import pytest + +from pxr import Gf, Sdf, Usd, 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 + + +# ------------------------------------------------------------------------------------- +# 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 + + +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 +# ------------------------------------------------------------------------------------- + + +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 + + +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 +# ------------------------------------------------------------------------------------- + + +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, + ) + + +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 import SimulationContext as _SimCtx + 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/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_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 + + 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/Robot5") + child = _make_xform(stage, "/World/Robot5/base") + UsdPhysics.RigidBodyAPI.Apply(child) + UsdPhysics.ArticulationRootAPI.Apply(child) + + apply_articulation_root_properties( + "/World/Robot5", + [ + PhysxArticulationCfg(solver_position_iteration_count=8), + NewtonArticulationCfg(self_collision_enabled=True), + ], + stage, + fix_root_link=True, + ) + + 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_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_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() + 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.MassAPI.Apply(child) + UsdPhysics.ArticulationRootAPI.Apply(child) + child.AddAppliedSchema("PhysxArticulationAPI") + 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.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(): + """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(): + """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)) + 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") + 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, + ) + + 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 + + +# ------------------------------------------------------------------------------------- +# fix_articulation_root is a PhysicsManager capability (resolved by cfg.physics.class_type) +# ------------------------------------------------------------------------------------- + + +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() + SimulationContext(SimulationCfg(dt=0.01)) + 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) + 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).""" + 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) + + +def test_articulation_fragment_and_legacy_cfg_match_physx_schema(): + """Both interfaces cover exactly all six attributes registered by PhysxArticulationAPI.""" + import dataclasses + + from isaaclab_physx.sim.schemas import PhysxArticulationCfg, PhysxArticulationRootPropertiesCfg + + from pxr import PhysxSchema + + from isaaclab.utils.string import to_camel_case + + def fields(cls): + return {field.name for field in dataclasses.fields(cls) if field.name != "func"} + + 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 + + +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 + + from isaaclab.sim.schemas import apply_articulation_root_properties, modify_articulation_root_properties + + 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 + + +# ------------------------------------------------------------------------------------- +# 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() + + +@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 + + 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=articulation_props, fix_root_link=True) + _spawn_from_usd_file("/World/FromUsdTopology", usd_path, cfg) + + stage = sim_utils.get_current_stage() + 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_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, "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, + ) + + _spawn_from_usd_file("/World/FromUsdFragments", usd_path, cfg) + + stage = sim_utils.get_current_stage() + 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 + + +# ------------------------------------------------------------------------------------- +# 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 a40c3fa1df50..33f530333171 100644 --- a/source/isaaclab_newton/isaaclab_newton/sim/schemas/__init__.pyi +++ b/source/isaaclab_newton/isaaclab_newton/sim/schemas/__init__.pyi @@ -10,6 +10,7 @@ __all__ = [ "MujocoJointDrivePropertiesCfg", "MujocoRigidBodyCfg", "MujocoRigidBodyPropertiesCfg", + "NewtonArticulationCfg", "NewtonArticulationRootPropertiesCfg", "NewtonCollisionCfg", "NewtonCollisionPropertiesCfg", @@ -34,6 +35,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 86485af3aa7f..ae19d511fe2d 100644 --- a/source/isaaclab_newton/isaaclab_newton/sim/schemas/schemas_cfg.py +++ b/source/isaaclab_newton/isaaclab_newton/sim/schemas/schemas_cfg.py @@ -10,6 +10,7 @@ from isaaclab.sim.schemas.schemas_cfg import ( ArticulationRootBaseCfg, + ArticulationRootFragment, CollisionBaseCfg, CollisionFragment, DeformableBodyPropertiesBaseCfg, @@ -574,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. @@ -583,3 +583,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_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]) 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 626d693c10bd..fe29bddb23fa 100644 --- a/source/isaaclab_ovphysx/isaaclab_ovphysx/physics/ovphysx_manager.py +++ b/source/isaaclab_ovphysx/isaaclab_ovphysx/physics/ovphysx_manager.py @@ -228,6 +228,18 @@ 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 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( 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 5adf38ad88de..07952dade515 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") @@ -724,6 +726,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/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/physics/physx_manager.py b/source/isaaclab_physx/isaaclab_physx/physics/physx_manager.py index 5059b4f0b8aa..d1d1ead0fe88 100644 --- a/source/isaaclab_physx/isaaclab_physx/physics/physx_manager.py +++ b/source/isaaclab_physx/isaaclab_physx/physics/physx_manager.py @@ -301,6 +301,18 @@ 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 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: """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 8892ccc6b593..35574c630061 100644 --- a/source/isaaclab_physx/isaaclab_physx/sim/schemas/__init__.py +++ b/source/isaaclab_physx/isaaclab_physx/sim/schemas/__init__.py @@ -11,6 +11,7 @@ lazy_export() + def _is_physx_tendon_child(prim) -> bool: """Return whether ``prim`` is a non-root PhysX fixed-tendon member. diff --git a/source/isaaclab_physx/isaaclab_physx/sim/schemas/__init__.pyi b/source/isaaclab_physx/isaaclab_physx/sim/schemas/__init__.pyi index 754d009af3a7..5895a8b5c48a 100644 --- a/source/isaaclab_physx/isaaclab_physx/sim/schemas/__init__.pyi +++ b/source/isaaclab_physx/isaaclab_physx/sim/schemas/__init__.pyi @@ -18,6 +18,7 @@ __all__ = [ "JointDrivePropertiesCfg", "MeshCollisionPropertiesCfg", "OmniPhysicsDeformableBodyPropertiesCfg", + "PhysxArticulationCfg", "PhysxArticulationRootPropertiesCfg", "PhysxCollisionCfg", "PhysxCollisionPropertiesCfg", @@ -65,6 +66,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 75c25c83826b..059795b57996 100644 --- a/source/isaaclab_physx/isaaclab_physx/sim/schemas/schemas_cfg.py +++ b/source/isaaclab_physx/isaaclab_physx/sim/schemas/schemas_cfg.py @@ -11,6 +11,7 @@ from isaaclab.sim.schemas.schemas_cfg import ( ArticulationRootBaseCfg, + ArticulationRootFragment, CollisionBaseCfg, CollisionFragment, DeformableBodyPropertiesBaseCfg, @@ -592,6 +593,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.