From 17b6cf8d04c790b28291a062beca26216c27a7fa Mon Sep 17 00:00:00 2001 From: Vidur Vij Date: Thu, 4 Jun 2026 18:21:59 -0700 Subject: [PATCH 01/14] feat(schemas): add tendons schema-fragment API Add the additive schema-fragment framework for the tendons family, mirroring the merged rigid-body pilot. Core gains the FixedTendonFragment / SpatialTendonFragment markers and the apply_fixed_tendon_properties / apply_spatial_tendon_properties family writers. Tendons are a tune-not-apply family, so the writers dispatch each fragment's func without applying a new anchor schema. PhysX gains PhysxFixedTendonCfg / PhysxSpatialTendonCfg data-carrier fragments that override func with thin apply_fixed_tendon / apply_spatial_tendon wrappers delegating to the existing multi-instance modify_fixed_/spatial_tendon_properties writers. The from_files spawn slots are widened to accept fragment lists with a transition bridge; legacy cfgs and writers stay intact. The legacy writer's mjc:* branch is kept inline with a future-split-candidate note. --- .../vidurv-schema-frag-tendons.minor.rst | 21 +++ source/isaaclab/isaaclab/sim/__init__.pyi | 8 + .../isaaclab/sim/schemas/__init__.pyi | 8 + .../isaaclab/isaaclab/sim/schemas/schemas.py | 57 ++++++ .../isaaclab/sim/schemas/schemas_cfg.py | 29 +++ .../sim/spawners/from_files/from_files.py | 20 +- .../sim/spawners/from_files/from_files_cfg.py | 20 +- .../test/sim/test_tendon_fragments.py | 178 ++++++++++++++++++ .../vidurv-schema-frag-tendons.minor.rst | 10 + .../isaaclab_physx/sim/schemas/__init__.pyi | 8 + .../isaaclab_physx/sim/schemas/schemas.py | 88 ++++++++- .../isaaclab_physx/sim/schemas/schemas_cfg.py | 91 +++++++++ 12 files changed, 528 insertions(+), 10 deletions(-) create mode 100644 source/isaaclab/changelog.d/vidurv-schema-frag-tendons.minor.rst create mode 100644 source/isaaclab/test/sim/test_tendon_fragments.py create mode 100644 source/isaaclab_physx/changelog.d/vidurv-schema-frag-tendons.minor.rst diff --git a/source/isaaclab/changelog.d/vidurv-schema-frag-tendons.minor.rst b/source/isaaclab/changelog.d/vidurv-schema-frag-tendons.minor.rst new file mode 100644 index 000000000000..a57dd87cdddf --- /dev/null +++ b/source/isaaclab/changelog.d/vidurv-schema-frag-tendons.minor.rst @@ -0,0 +1,21 @@ +Added +^^^^^ + +* Added the tendon schema-fragment markers + :class:`~isaaclab.sim.schemas.FixedTendonFragment` and + :class:`~isaaclab.sim.schemas.SpatialTendonFragment`, which type the spawner + ``fixed_tendons_props`` / ``spatial_tendons_props`` slots. +* Added :func:`~isaaclab.sim.schemas.apply_fixed_tendon_properties` and + :func:`~isaaclab.sim.schemas.apply_spatial_tendon_properties`, the family writers that + dispatch a list of tendon fragments via each fragment's ``func``. Tendons are a + *tune-not-apply* family, so the writers tune the existing multi-instance tendon schemas + without applying a new anchor schema. + +Changed +^^^^^^^ + +* Changed the spawner ``fixed_tendons_props`` / ``spatial_tendons_props`` slots + (:attr:`~isaaclab.sim.spawners.from_files.FileCfg.fixed_tendons_props`, + :attr:`~isaaclab.sim.spawners.from_files.FileCfg.spatial_tendons_props`) to also accept one + or more tendon fragments. 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 e0365d3e759c..447eefa9ba56 100644 --- a/source/isaaclab/isaaclab/sim/__init__.pyi +++ b/source/isaaclab/isaaclab/sim/__init__.pyi @@ -43,6 +43,7 @@ __all__ = [ "ConvexHullPropertiesCfg", "DeformableBodyPropertiesBaseCfg", "DeformableBodyPropertiesCfg", + "FixedTendonFragment", "FixedTendonPropertiesCfg", "JointDriveBaseCfg", "MassPropertiesCfg", @@ -61,9 +62,12 @@ __all__ = [ "RigidBodyBaseCfg", "RigidBodyFragment", "SchemaFragment", + "SpatialTendonFragment", "UsdPhysicsRigidBodyCfg", + "apply_fixed_tendon_properties", "apply_namespaced", "apply_rigid_body_properties", + "apply_spatial_tendon_properties", "SDFMeshPropertiesCfg", "SpatialTendonPropertiesCfg", "TriangleMeshPropertiesCfg", @@ -219,6 +223,7 @@ from .schemas import ( ConvexHullPropertiesCfg, DeformableBodyPropertiesBaseCfg, DeformableBodyPropertiesCfg, + FixedTendonFragment, FixedTendonPropertiesCfg, JointDriveBaseCfg, MassPropertiesCfg, @@ -229,13 +234,16 @@ from .schemas import ( RigidBodyFragment, SchemaFragment, SDFMeshPropertiesCfg, + SpatialTendonFragment, SpatialTendonPropertiesCfg, TriangleMeshPropertiesCfg, TriangleMeshSimplificationPropertiesCfg, UsdPhysicsRigidBodyCfg, activate_contact_sensors, + apply_fixed_tendon_properties, apply_namespaced, apply_rigid_body_properties, + apply_spatial_tendon_properties, define_articulation_root_properties, define_collision_properties, define_deformable_body_properties, diff --git a/source/isaaclab/isaaclab/sim/schemas/__init__.pyi b/source/isaaclab/isaaclab/sim/schemas/__init__.pyi index af153a60fc63..d82a630b7644 100644 --- a/source/isaaclab/isaaclab/sim/schemas/__init__.pyi +++ b/source/isaaclab/isaaclab/sim/schemas/__init__.pyi @@ -8,8 +8,10 @@ __all__ = [ "PHYSX_MESH_COLLISION_CFGS", "USD_MESH_COLLISION_CFGS", "activate_contact_sensors", + "apply_fixed_tendon_properties", "apply_namespaced", "apply_rigid_body_properties", + "apply_spatial_tendon_properties", "define_actuator_properties", "define_articulation_root_properties", "define_collision_properties", @@ -32,11 +34,13 @@ __all__ = [ "CollisionBaseCfg", "DeformableBodyPropertiesBaseCfg", "DeformableBodyPropertiesCfg", + "FixedTendonFragment", "JointDriveBaseCfg", "MassPropertiesCfg", "MeshCollisionBaseCfg", "RigidBodyFragment", "SchemaFragment", + "SpatialTendonFragment", "UsdPhysicsRigidBodyCfg", "MujocoJointDrivePropertiesCfg", "MujocoRigidBodyPropertiesCfg", @@ -55,8 +59,10 @@ from .schemas import ( PHYSX_MESH_COLLISION_CFGS, USD_MESH_COLLISION_CFGS, activate_contact_sensors, + apply_fixed_tendon_properties, apply_namespaced, apply_rigid_body_properties, + apply_spatial_tendon_properties, define_articulation_root_properties, define_collision_properties, define_deformable_body_properties, @@ -83,12 +89,14 @@ from .schemas_cfg import ( CollisionBaseCfg, DeformableBodyPropertiesBaseCfg, DeformableBodyPropertiesCfg, + FixedTendonFragment, JointDriveBaseCfg, MassPropertiesCfg, MeshCollisionBaseCfg, RigidBodyBaseCfg, RigidBodyFragment, SchemaFragment, + SpatialTendonFragment, UsdPhysicsRigidBodyCfg, ) diff --git a/source/isaaclab/isaaclab/sim/schemas/schemas.py b/source/isaaclab/isaaclab/sim/schemas/schemas.py index 6617543cad61..991224936bd2 100644 --- a/source/isaaclab/isaaclab/sim/schemas/schemas.py +++ b/source/isaaclab/isaaclab/sim/schemas/schemas.py @@ -927,6 +927,32 @@ def modify_joint_drive_properties( """ +def apply_fixed_tendon_properties(prim_path: str, fragments, stage: Usd.Stage | None = None) -> bool: + """Apply a list of fixed-tendon fragments to a prim. + + Fixed tendons are a *tune-not-apply* family: the applied ``PhysxTendonAxisRootAPI`` + multi-instance schemas already exist on the prim (authored in the source asset). This writer + therefore applies no anchor schema; it only dispatches each fragment via its + :attr:`~isaaclab.sim.schemas.SchemaFragment.func`, which tunes the existing instances. + Backend fragments carry backend-specific funcs, so core never imports a backend. + + Args: + prim_path: The prim path to apply the fixed-tendon schemas on. + fragments: An iterable of :class:`~isaaclab.sim.schemas.FixedTendonFragment` instances. + stage: The stage where to find the prim. Defaults to None, in which case the current + stage is used. + + Returns: + True if the properties were successfully set. + """ + if stage is None: + stage = get_current_stage() + for cfg in fragments: + func = cfg.func if callable(cfg.func) else string_to_callable(cfg.func) + func(cfg, prim_path, stage) + return True + + @apply_nested def modify_fixed_tendon_properties( prim_path: str, cfg: schemas_cfg.PhysxFixedTendonPropertiesCfg, stage: Usd.Stage | None = None @@ -985,6 +1011,10 @@ def modify_fixed_tendon_properties( camel_case=False, ) else: + # NOTE: This ``mjc:*`` branch (for the ``MjcTendon`` prim type) is a future split + # candidate -- it could become a separate Mjc tendon fragment + applier in + # isaaclab_newton, mirroring the PhysX/Mjc split done for other families. It is + # kept inline here for now so the schema-fragment migration stays additive. # only stiffness and damping in the cfg map to mjc attributes for attr_name, value in cfg.items(): safe_set_attribute_on_usd_prim( @@ -999,6 +1029,33 @@ def modify_fixed_tendon_properties( """ +def apply_spatial_tendon_properties(prim_path: str, fragments, stage: Usd.Stage | None = None) -> bool: + """Apply a list of spatial-tendon fragments to a prim. + + Spatial tendons are a *tune-not-apply* family: the applied + ``PhysxTendonAttachmentRootAPI`` / ``PhysxTendonAttachmentLeafAPI`` multi-instance schemas + already exist on the prim (authored in the source asset). This writer therefore applies no + anchor schema; it only dispatches each fragment via its + :attr:`~isaaclab.sim.schemas.SchemaFragment.func`, which tunes the existing instances. + Backend fragments carry backend-specific funcs, so core never imports a backend. + + Args: + prim_path: The prim path to apply the spatial-tendon schemas on. + fragments: An iterable of :class:`~isaaclab.sim.schemas.SpatialTendonFragment` instances. + stage: The stage where to find the prim. Defaults to None, in which case the current + stage is used. + + Returns: + True if the properties were successfully set. + """ + if stage is None: + stage = get_current_stage() + for cfg in fragments: + func = cfg.func if callable(cfg.func) else string_to_callable(cfg.func) + func(cfg, prim_path, stage) + return True + + @apply_nested def modify_spatial_tendon_properties( prim_path: str, cfg: schemas_cfg.PhysxSpatialTendonPropertiesCfg, 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 ac5d8b22c020..7a1baa4ebac7 100644 --- a/source/isaaclab/isaaclab/sim/schemas/schemas_cfg.py +++ b/source/isaaclab/isaaclab/sim/schemas/schemas_cfg.py @@ -176,6 +176,35 @@ class UsdPhysicsRigidBodyCfg(RigidBodyFragment): """ +@configclass +class FixedTendonFragment(SchemaFragment): + """Marker base for fixed-tendon fragments; types the ``fixed_tendons_props`` slot. + + Fixed tendons are a *tune-not-apply* family: the applied ``PhysxTendonAxisRootAPI`` + multi-instance schemas already exist on the prim (authored in the source asset), so the + family writer (:func:`~isaaclab.sim.schemas.apply_fixed_tendon_properties`) does not apply + any anchor schema; it only tunes the existing instances via each fragment's + :attr:`~isaaclab.sim.schemas.SchemaFragment.func`. + """ + + pass + + +@configclass +class SpatialTendonFragment(SchemaFragment): + """Marker base for spatial-tendon fragments; types the ``spatial_tendons_props`` slot. + + Spatial tendons are a *tune-not-apply* family: the applied + ``PhysxTendonAttachmentRootAPI`` / ``PhysxTendonAttachmentLeafAPI`` multi-instance schemas + already exist on the prim (authored in the source asset), so the family writer + (:func:`~isaaclab.sim.schemas.apply_spatial_tendon_properties`) does not apply any anchor + schema; it only tunes the existing instances via each fragment's + :attr:`~isaaclab.sim.schemas.SchemaFragment.func`. + """ + + pass + + @configclass class ArticulationRootBaseCfg: """Solver-common properties to apply to the root of an articulation. diff --git a/source/isaaclab/isaaclab/sim/spawners/from_files/from_files.py b/source/isaaclab/isaaclab/sim/spawners/from_files/from_files.py index 4545eb23e115..6d6a9e3df9f2 100644 --- a/source/isaaclab/isaaclab/sim/spawners/from_files/from_files.py +++ b/source/isaaclab/isaaclab/sim/spawners/from_files/from_files.py @@ -362,9 +362,25 @@ def _spawn_from_usd_file( 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) + # transition routing: new fragment list -> apply_*; legacy single cfg -> modify_* + fixed_tendon_frags = ( + cfg.fixed_tendons_props if isinstance(cfg.fixed_tendons_props, (list, tuple)) else [cfg.fixed_tendons_props] + ) + if fixed_tendon_frags and all(isinstance(f, schemas.SchemaFragment) for f in fixed_tendon_frags): + schemas.apply_fixed_tendon_properties(prim_path, fixed_tendon_frags) + else: + schemas.modify_fixed_tendon_properties(prim_path, cfg.fixed_tendons_props) if cfg.spatial_tendons_props is not None: - schemas.modify_spatial_tendon_properties(prim_path, cfg.spatial_tendons_props) + # transition routing: new fragment list -> apply_*; legacy single cfg -> modify_* + spatial_tendon_frags = ( + cfg.spatial_tendons_props + if isinstance(cfg.spatial_tendons_props, (list, tuple)) + else [cfg.spatial_tendons_props] + ) + if spatial_tendon_frags and all(isinstance(f, schemas.SchemaFragment) for f in spatial_tendon_frags): + schemas.apply_spatial_tendon_properties(prim_path, spatial_tendon_frags) + else: + schemas.modify_spatial_tendon_properties(prim_path, cfg.spatial_tendons_props) # define drive API on the joints # note: these are only for setting low-level simulation properties. all others should be set or are # and overridden by the articulation/actuator properties. 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..d002588cf827 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 @@ -37,11 +37,23 @@ class FileCfg(RigidObjectSpawnerCfg, DeformableObjectSpawnerCfg): articulation_props: schemas.ArticulationRootPropertiesCfg | None = None """Properties to apply to the articulation root.""" - fixed_tendons_props: schemas.FixedTendonPropertiesCfg | None = None - """Properties to apply to the fixed tendons (if any).""" + fixed_tendons_props: ( + schemas.FixedTendonPropertiesCfg | schemas.FixedTendonFragment | list[schemas.FixedTendonFragment] | None + ) = None + """Properties to apply to the fixed tendons (if any). - spatial_tendons_props: schemas.SpatialTendonPropertiesCfg | None = None - """Properties to apply to the spatial tendons (if any).""" + Accepts either the legacy :class:`~isaaclab_physx.sim.schemas.PhysxFixedTendonPropertiesCfg` + or one or more :class:`~isaaclab.sim.schemas.FixedTendonFragment` instances. + """ + + spatial_tendons_props: ( + schemas.SpatialTendonPropertiesCfg | schemas.SpatialTendonFragment | list[schemas.SpatialTendonFragment] | None + ) = None + """Properties to apply to the spatial tendons (if any). + + Accepts either the legacy :class:`~isaaclab_physx.sim.schemas.PhysxSpatialTendonPropertiesCfg` + or one or more :class:`~isaaclab.sim.schemas.SpatialTendonFragment` instances. + """ joint_drive_props: schemas.JointDriveBaseCfg | None = None """Properties to apply to a joint. diff --git a/source/isaaclab/test/sim/test_tendon_fragments.py b/source/isaaclab/test/sim/test_tendon_fragments.py new file mode 100644 index 000000000000..f7f224d1bdd6 --- /dev/null +++ b/source/isaaclab/test/sim/test_tendon_fragments.py @@ -0,0 +1,178 @@ +# 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 PhysxSchema, UsdGeom + +import isaaclab.sim as sim_utils +from isaaclab.sim import SimulationCfg, SimulationContext + + +def _make_xform(stage, path="/World/Tendon"): + UsdGeom.Xform.Define(stage, path) + return stage.GetPrimAtPath(path) + + +def _make_fixed_tendon_prim(stage, path, instance="default"): + """Create a prim with a multi-instance PhysxTendonAxisRootAPI applied.""" + prim = _make_xform(stage, path) + PhysxSchema.PhysxTendonAxisRootAPI.Apply(prim, instance) + return prim + + +def _make_spatial_tendon_prim(stage, path, instance="default"): + """Create a prim with a multi-instance PhysxTendonAttachmentRootAPI applied.""" + prim = _make_xform(stage, path) + PhysxSchema.PhysxTendonAttachmentRootAPI.Apply(prim, instance) + return prim + + +def _tendon_attr_prefix(prim, schema_substr): + """Return the applied-schema name used by the writer as the authored-attribute prefix. + + The legacy writer authors ``f"{schema_name}:{camelCase(field)}"`` where ``schema_name`` is + the entry returned by ``prim.GetAppliedSchemas()`` (e.g. ``PhysxTendonAxisRootAPI:t0``). + """ + for schema_name in prim.GetAppliedSchemas(): + if schema_substr in schema_name: + return schema_name + raise AssertionError(f"no applied schema containing {schema_substr!r} on {prim.GetPath()}") + + +# ------------------------------------------------------------------------------------- +# Fixed-tendon marker + metadata defaults +# ------------------------------------------------------------------------------------- + + +def test_fixed_tendon_fragment_metadata_defaults(): + from isaaclab_physx.sim.schemas import PhysxFixedTendonCfg + + from isaaclab.sim.schemas import FixedTendonFragment, SchemaFragment + + cfg = PhysxFixedTendonCfg(stiffness=1.0) + assert isinstance(cfg, FixedTendonFragment) and isinstance(cfg, SchemaFragment) + assert cfg.func == "isaaclab_physx.sim.schemas:apply_fixed_tendon" + assert cfg.stiffness == 1.0 and cfg.damping is None + + +def test_spatial_tendon_fragment_metadata_defaults(): + from isaaclab_physx.sim.schemas import PhysxSpatialTendonCfg + + from isaaclab.sim.schemas import SchemaFragment, SpatialTendonFragment + + cfg = PhysxSpatialTendonCfg(stiffness=2.0) + assert isinstance(cfg, SpatialTendonFragment) and isinstance(cfg, SchemaFragment) + assert cfg.func == "isaaclab_physx.sim.schemas:apply_spatial_tendon" + assert cfg.stiffness == 2.0 and cfg.damping is None + + +# ------------------------------------------------------------------------------------- +# PhysxFixedTendonCfg writes the multi-instance namespace +# ------------------------------------------------------------------------------------- + + +def test_physx_fixed_tendon_fragment_writes_instanced_namespace(): + from isaaclab_physx.sim.schemas import PhysxFixedTendonCfg, apply_fixed_tendon + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + prim = _make_fixed_tendon_prim(stage, "/World/FT", instance="t0") + apply_fixed_tendon(PhysxFixedTendonCfg(stiffness=3.0, damping=0.5), "/World/FT", stage) + prefix = _tendon_attr_prefix(prim, "PhysxTendonAxisRootAPI") + assert abs(prim.GetAttribute(f"{prefix}:stiffness").Get() - 3.0) < 1e-6 + assert abs(prim.GetAttribute(f"{prefix}:damping").Get() - 0.5) < 1e-6 + # the ``func`` plumbing field must not be authored as an attribute + assert not prim.HasAttribute(f"{prefix}:func") + + +# ------------------------------------------------------------------------------------- +# PhysxSpatialTendonCfg writes the multi-instance namespace +# ------------------------------------------------------------------------------------- + + +def test_physx_spatial_tendon_fragment_writes_instanced_namespace(): + from isaaclab_physx.sim.schemas import PhysxSpatialTendonCfg, apply_spatial_tendon + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + prim = _make_spatial_tendon_prim(stage, "/World/ST", instance="s0") + apply_spatial_tendon(PhysxSpatialTendonCfg(stiffness=4.0, limit_stiffness=0.25), "/World/ST", stage) + prefix = _tendon_attr_prefix(prim, "PhysxTendonAttachmentRootAPI") + assert abs(prim.GetAttribute(f"{prefix}:stiffness").Get() - 4.0) < 1e-6 + assert abs(prim.GetAttribute(f"{prefix}:limitStiffness").Get() - 0.25) < 1e-6 + + +# ------------------------------------------------------------------------------------- +# apply_fixed_tendon_properties dispatch (tune-not-apply, multi-fragment) +# ------------------------------------------------------------------------------------- + + +def test_apply_fixed_tendon_properties_dispatches_fragments(): + from isaaclab_physx.sim.schemas import PhysxFixedTendonCfg + + from isaaclab.sim.schemas import apply_fixed_tendon_properties + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + prim = _make_fixed_tendon_prim(stage, "/World/FT2", instance="t0") + apply_fixed_tendon_properties( + "/World/FT2", + [PhysxFixedTendonCfg(stiffness=5.0), PhysxFixedTendonCfg(damping=0.75)], + stage, + ) + prefix = _tendon_attr_prefix(prim, "PhysxTendonAxisRootAPI") + assert abs(prim.GetAttribute(f"{prefix}:stiffness").Get() - 5.0) < 1e-6 + assert abs(prim.GetAttribute(f"{prefix}:damping").Get() - 0.75) < 1e-6 + + +def test_apply_spatial_tendon_properties_dispatches_fragments(): + from isaaclab_physx.sim.schemas import PhysxSpatialTendonCfg + + from isaaclab.sim.schemas import apply_spatial_tendon_properties + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + prim = _make_spatial_tendon_prim(stage, "/World/ST2", instance="s0") + apply_spatial_tendon_properties( + "/World/ST2", + [PhysxSpatialTendonCfg(stiffness=6.0), PhysxSpatialTendonCfg(offset=0.1)], + stage, + ) + prefix = _tendon_attr_prefix(prim, "PhysxTendonAttachmentRootAPI") + assert abs(prim.GetAttribute(f"{prefix}:stiffness").Get() - 6.0) < 1e-6 + assert abs(prim.GetAttribute(f"{prefix}:offset").Get() - 0.1) < 1e-6 + + +# ------------------------------------------------------------------------------------- +# Public imports +# ------------------------------------------------------------------------------------- + + +def test_public_imports(): + from isaaclab_physx.sim.schemas import ( # noqa: F401 + PhysxFixedTendonCfg, + PhysxSpatialTendonCfg, + apply_fixed_tendon, + apply_spatial_tendon, + ) + + from isaaclab.sim.schemas import ( # noqa: F401 + FixedTendonFragment, + SpatialTendonFragment, + apply_fixed_tendon_properties, + apply_spatial_tendon_properties, + ) diff --git a/source/isaaclab_physx/changelog.d/vidurv-schema-frag-tendons.minor.rst b/source/isaaclab_physx/changelog.d/vidurv-schema-frag-tendons.minor.rst new file mode 100644 index 000000000000..ed7d5458f920 --- /dev/null +++ b/source/isaaclab_physx/changelog.d/vidurv-schema-frag-tendons.minor.rst @@ -0,0 +1,10 @@ +Added +^^^^^ + +* Added :class:`~isaaclab_physx.sim.schemas.PhysxFixedTendonCfg` and + :class:`~isaaclab_physx.sim.schemas.PhysxSpatialTendonCfg`, the PhysX tendon schema + fragments. They override ``func`` with + :func:`~isaaclab_physx.sim.schemas.apply_fixed_tendon` / + :func:`~isaaclab_physx.sim.schemas.apply_spatial_tendon`, which delegate to the existing + multi-instance tendon writers to tune every applied ``PhysxTendonAxisRootAPI`` / + ``PhysxTendonAttachmentRootAPI`` / ``PhysxTendonAttachmentLeafAPI`` instance. diff --git a/source/isaaclab_physx/isaaclab_physx/sim/schemas/__init__.pyi b/source/isaaclab_physx/isaaclab_physx/sim/schemas/__init__.pyi index 10d2502ddf2b..7f50b4ae19cb 100644 --- a/source/isaaclab_physx/isaaclab_physx/sim/schemas/__init__.pyi +++ b/source/isaaclab_physx/isaaclab_physx/sim/schemas/__init__.pyi @@ -4,6 +4,8 @@ # SPDX-License-Identifier: BSD-3-Clause __all__ = [ + "apply_fixed_tendon", + "apply_spatial_tendon", "define_deformable_body_properties", "modify_deformable_body_properties", "ArticulationRootPropertiesCfg", @@ -21,11 +23,13 @@ __all__ = [ "PhysxConvexHullPropertiesCfg", "PhysxDeformableBodyPropertiesCfg", "PhysxDeformableCollisionPropertiesCfg", + "PhysxFixedTendonCfg", "PhysxFixedTendonPropertiesCfg", "PhysxJointDrivePropertiesCfg", "PhysxRigidBodyCfg", "PhysxRigidBodyPropertiesCfg", "PhysxSDFMeshPropertiesCfg", + "PhysxSpatialTendonCfg", "PhysxSpatialTendonPropertiesCfg", "PhysxTriangleMeshPropertiesCfg", "PhysxTriangleMeshSimplificationPropertiesCfg", @@ -37,6 +41,8 @@ __all__ = [ ] from .schemas import ( + apply_fixed_tendon, + apply_spatial_tendon, define_deformable_body_properties, modify_deformable_body_properties, ) @@ -56,11 +62,13 @@ from .schemas_cfg import ( PhysxConvexHullPropertiesCfg, PhysxDeformableBodyPropertiesCfg, PhysxDeformableCollisionPropertiesCfg, + PhysxFixedTendonCfg, PhysxFixedTendonPropertiesCfg, PhysxJointDrivePropertiesCfg, PhysxRigidBodyCfg, PhysxRigidBodyPropertiesCfg, PhysxSDFMeshPropertiesCfg, + PhysxSpatialTendonCfg, PhysxSpatialTendonPropertiesCfg, PhysxTriangleMeshPropertiesCfg, PhysxTriangleMeshSimplificationPropertiesCfg, diff --git a/source/isaaclab_physx/isaaclab_physx/sim/schemas/schemas.py b/source/isaaclab_physx/isaaclab_physx/sim/schemas/schemas.py index 713c1cc3264c..6ca2505b199d 100644 --- a/source/isaaclab_physx/isaaclab_physx/sim/schemas/schemas.py +++ b/source/isaaclab_physx/isaaclab_physx/sim/schemas/schemas.py @@ -3,12 +3,92 @@ # # SPDX-License-Identifier: BSD-3-Clause -"""Compatibility wrappers for deformable schema writers. +"""Compatibility wrappers for deformable schema writers and PhysX fragment appliers. The deformable schema writers are backend-aware but remain unified in -:mod:`isaaclab.sim.schemas`. +:mod:`isaaclab.sim.schemas`. This module additionally hosts the PhysX-specific fragment +applier funcs that override :attr:`~isaaclab.sim.schemas.SchemaFragment.func` for the +multi-instance tendon schemas, keeping the backend func out of the core package. """ -from isaaclab.sim.schemas.schemas import define_deformable_body_properties, modify_deformable_body_properties +from __future__ import annotations -__all__ = ["define_deformable_body_properties", "modify_deformable_body_properties"] +import dataclasses + +from pxr import Usd + +from isaaclab.sim.schemas.schemas import ( + define_deformable_body_properties, + modify_deformable_body_properties, + modify_fixed_tendon_properties, + modify_spatial_tendon_properties, +) + +from . import schemas_cfg + +__all__ = [ + "apply_fixed_tendon", + "apply_spatial_tendon", + "define_deformable_body_properties", + "modify_deformable_body_properties", +] + + +def _strip_fragment_fields(cfg) -> dict: + """Collect a fragment's non-``None`` data fields, excluding the ``func`` plumbing field. + + Args: + cfg: The fragment instance to read fields from. + + Returns: + A mapping of set field names to their values, suitable for building a legacy cfg. + """ + return { + f.name: getattr(cfg, f.name) + for f in dataclasses.fields(cfg) + if f.name != "func" and getattr(cfg, f.name) is not None + } + + +def apply_fixed_tendon(cfg, prim_path: str, stage: Usd.Stage | None = None) -> bool: + """Apply a :class:`PhysxFixedTendonCfg` fragment by delegating to the legacy writer. + + Thin override-``func`` wrapper for fixed-tendon fragments. The fragment's data fields are + repackaged into a :class:`PhysxFixedTendonPropertiesCfg` (dropping the ``func`` plumbing + field) and handed to :func:`~isaaclab.sim.schemas.modify_fixed_tendon_properties`, which + tunes every existing ``PhysxTendonAxisRootAPI:`` instance on the prim (and the + ``MjcTendon`` ``mjc:*`` branch when present). + + Args: + cfg: The :class:`PhysxFixedTendonCfg` fragment to apply. + prim_path: The prim path to the tendon attachment. + stage: The stage where to find the prim. Defaults to None, in which case the current + stage is used. + + Returns: + True if the properties were successfully set, False otherwise. + """ + legacy = schemas_cfg.PhysxFixedTendonPropertiesCfg(**_strip_fragment_fields(cfg)) + return modify_fixed_tendon_properties(prim_path, legacy, stage) + + +def apply_spatial_tendon(cfg, prim_path: str, stage: Usd.Stage | None = None) -> bool: + """Apply a :class:`PhysxSpatialTendonCfg` fragment by delegating to the legacy writer. + + Thin override-``func`` wrapper for spatial-tendon fragments. The fragment's data fields are + repackaged into a :class:`PhysxSpatialTendonPropertiesCfg` (dropping the ``func`` plumbing + field) and handed to :func:`~isaaclab.sim.schemas.modify_spatial_tendon_properties`, which + tunes every existing ``PhysxTendonAttachmentRootAPI:`` / + ``PhysxTendonAttachmentLeafAPI:`` instance on the prim. + + Args: + cfg: The :class:`PhysxSpatialTendonCfg` fragment to apply. + prim_path: The prim path to the tendon attachment. + stage: The stage where to find the prim. Defaults to None, in which case the current + stage is used. + + Returns: + True if the properties were successfully set, False otherwise. + """ + legacy = schemas_cfg.PhysxSpatialTendonPropertiesCfg(**_strip_fragment_fields(cfg)) + return modify_spatial_tendon_properties(prim_path, legacy, stage) diff --git a/source/isaaclab_physx/isaaclab_physx/sim/schemas/schemas_cfg.py b/source/isaaclab_physx/isaaclab_physx/sim/schemas/schemas_cfg.py index 18a5de023f90..2c44a2075527 100644 --- a/source/isaaclab_physx/isaaclab_physx/sim/schemas/schemas_cfg.py +++ b/source/isaaclab_physx/isaaclab_physx/sim/schemas/schemas_cfg.py @@ -6,16 +6,19 @@ from __future__ import annotations import warnings +from collections.abc import Callable from typing import ClassVar from isaaclab.sim.schemas.schemas_cfg import ( ArticulationRootBaseCfg, CollisionBaseCfg, DeformableBodyPropertiesBaseCfg, + FixedTendonFragment, JointDriveBaseCfg, MeshCollisionBaseCfg, RigidBodyBaseCfg, RigidBodyFragment, + SpatialTendonFragment, ) from isaaclab.utils.configclass import configclass @@ -970,3 +973,91 @@ def __post_init__(self): stacklevel=2, ) super().__post_init__() + + +@configclass +class PhysxFixedTendonCfg(FixedTendonFragment): + """PhysX fixed-tendon attributes from `PhysxTendonAxisRootAPI`_. + + A fixed-tendon fragment (see :class:`~isaaclab.sim.schemas.FixedTendonFragment`) for the + PhysX fixed-tendon schema. Unlike single-namespace fragments, this is a *tune-not-apply* + fragment: the multi-instance ``PhysxTendonAxisRootAPI:`` schemas already exist on the + prim (authored in the source asset), so the fragment overrides + :attr:`~isaaclab.sim.schemas.SchemaFragment.func` with :func:`apply_fixed_tendon`, which + delegates to the multi-instance writer + :func:`~isaaclab.sim.schemas.modify_fixed_tendon_properties` to tune every existing instance. + + Dispatched via :func:`~isaaclab.sim.schemas.apply_fixed_tendon_properties`. + + .. _PhysxTendonAxisRootAPI: https://docs.omniverse.nvidia.com/kit/docs/omni_usd_schema_physics/104.2/class_physx_schema_physx_tendon_axis_root_a_p_i.html + """ + + # ``func`` is overridden because the existing writer iterates the multi-instance + # ``PhysxTendonAxisRootAPI:`` schemas; the generic ``apply_namespaced`` cannot. + func: Callable | str = "isaaclab_physx.sim.schemas:apply_fixed_tendon" + + tendon_enabled: bool | None = None + """Whether to enable or disable the tendon.""" + + stiffness: float | None = None + """Spring stiffness term acting on the tendon's length [N/m].""" + + damping: float | None = None + """The damping term acting on both the tendon length and the tendon-length limits [N·s/m].""" + + limit_stiffness: float | None = None + """Limit stiffness term acting on the tendon's length limits [N/m].""" + + offset: float | None = None + """Length offset term for the tendon [m]. + + It defines an amount to be added to the accumulated length computed for the tendon. This allows the application + to actuate the tendon by shortening or lengthening it. + """ + + rest_length: float | None = None + """Spring rest length of the tendon [m].""" + + +@configclass +class PhysxSpatialTendonCfg(SpatialTendonFragment): + """PhysX spatial-tendon attributes from `PhysxTendonAttachmentRootAPI`_. + + A spatial-tendon fragment (see :class:`~isaaclab.sim.schemas.SpatialTendonFragment`) for the + PhysX spatial-tendon schema. Unlike single-namespace fragments, this is a *tune-not-apply* + fragment: the multi-instance ``PhysxTendonAttachmentRootAPI:`` / + ``PhysxTendonAttachmentLeafAPI:`` schemas already exist on the prim (authored in the + source asset), so the fragment overrides + :attr:`~isaaclab.sim.schemas.SchemaFragment.func` with :func:`apply_spatial_tendon`, which + delegates to the multi-instance writer + :func:`~isaaclab.sim.schemas.modify_spatial_tendon_properties` to tune every existing + instance. + + Dispatched via :func:`~isaaclab.sim.schemas.apply_spatial_tendon_properties`. + + .. _PhysxTendonAttachmentRootAPI: https://docs.omniverse.nvidia.com/kit/docs/omni_usd_schema_physics/104.2/class_physx_schema_physx_tendon_attachment_root_a_p_i.html + """ + + # ``func`` is overridden because the existing writer iterates the multi-instance + # ``PhysxTendonAttachmentRootAPI:`` / ``PhysxTendonAttachmentLeafAPI:`` schemas; + # the generic ``apply_namespaced`` cannot. + func: Callable | str = "isaaclab_physx.sim.schemas:apply_spatial_tendon" + + tendon_enabled: bool | None = None + """Whether to enable or disable the tendon.""" + + stiffness: float | None = None + """Spring stiffness term acting on the tendon's length [N/m].""" + + damping: float | None = None + """The damping term acting on both the tendon length and the tendon-length limits [N·s/m].""" + + limit_stiffness: float | None = None + """Limit stiffness term acting on the tendon's length limits [N/m].""" + + offset: float | None = None + """Length offset term for the tendon [m]. + + It defines an amount to be added to the accumulated length computed for the tendon. This allows the application + to actuate the tendon by shortening or lengthening it. + """ From c37c28e02ba1995a408934f20aee032d664fe94c Mon Sep 17 00:00:00 2001 From: Vidur Vij Date: Thu, 4 Jun 2026 20:33:56 -0700 Subject: [PATCH 02/14] fix(schemas): tendon appliers return True (apply_nested wrappers return None) --- .../isaaclab_physx/sim/schemas/schemas.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/source/isaaclab_physx/isaaclab_physx/sim/schemas/schemas.py b/source/isaaclab_physx/isaaclab_physx/sim/schemas/schemas.py index 6ca2505b199d..f41aae4eeb6d 100644 --- a/source/isaaclab_physx/isaaclab_physx/sim/schemas/schemas.py +++ b/source/isaaclab_physx/isaaclab_physx/sim/schemas/schemas.py @@ -69,7 +69,10 @@ def apply_fixed_tendon(cfg, prim_path: str, stage: Usd.Stage | None = None) -> b True if the properties were successfully set, False otherwise. """ legacy = schemas_cfg.PhysxFixedTendonPropertiesCfg(**_strip_fragment_fields(cfg)) - return modify_fixed_tendon_properties(prim_path, legacy, stage) + # ``modify_fixed_tendon_properties`` is wrapped by ``apply_nested`` and returns ``None``; + # report success explicitly to honor the documented ``bool`` contract. + modify_fixed_tendon_properties(prim_path, legacy, stage) + return True def apply_spatial_tendon(cfg, prim_path: str, stage: Usd.Stage | None = None) -> bool: @@ -91,4 +94,7 @@ def apply_spatial_tendon(cfg, prim_path: str, stage: Usd.Stage | None = None) -> True if the properties were successfully set, False otherwise. """ legacy = schemas_cfg.PhysxSpatialTendonPropertiesCfg(**_strip_fragment_fields(cfg)) - return modify_spatial_tendon_properties(prim_path, legacy, stage) + # ``modify_spatial_tendon_properties`` is wrapped by ``apply_nested`` and returns ``None``; + # report success explicitly to honor the documented ``bool`` contract. + modify_spatial_tendon_properties(prim_path, legacy, stage) + return True From 16e4ec695458cf264f2d67b14140ea20b9bdce47 Mon Sep 17 00:00:00 2001 From: Vidur Vij Date: Fri, 5 Jun 2026 12:37:05 -0700 Subject: [PATCH 03/14] docs(schemas): mark transition shim if/else for removal post-migration --- .../isaaclab/isaaclab/sim/spawners/from_files/from_files.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 6d6a9e3df9f2..fb671af850a6 100644 --- a/source/isaaclab/isaaclab/sim/spawners/from_files/from_files.py +++ b/source/isaaclab/isaaclab/sim/spawners/from_files/from_files.py @@ -362,7 +362,7 @@ def _spawn_from_usd_file( schemas.modify_articulation_root_properties(prim_path, cfg.articulation_props) # modify tendon properties if cfg.fixed_tendons_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_* fixed_tendon_frags = ( cfg.fixed_tendons_props if isinstance(cfg.fixed_tendons_props, (list, tuple)) else [cfg.fixed_tendons_props] ) @@ -371,7 +371,7 @@ def _spawn_from_usd_file( else: schemas.modify_fixed_tendon_properties(prim_path, cfg.fixed_tendons_props) if cfg.spatial_tendons_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_* spatial_tendon_frags = ( cfg.spatial_tendons_props if isinstance(cfg.spatial_tendons_props, (list, tuple)) From 58080fe9da2e0dbd2d70bcb22b4fe410771948a1 Mon Sep 17 00:00:00 2001 From: Vidur Vij Date: Mon, 8 Jun 2026 20:11:55 -0700 Subject: [PATCH 04/14] style(schemas): trim verbose comments in tendon fragment code No behavior change; collapse over-explained inline comments to terse intent. --- source/isaaclab/isaaclab/sim/schemas/schemas.py | 5 +---- .../isaaclab_physx/isaaclab_physx/sim/schemas/schemas.py | 6 ++---- .../isaaclab_physx/sim/schemas/schemas_cfg.py | 8 +++----- 3 files changed, 6 insertions(+), 13 deletions(-) diff --git a/source/isaaclab/isaaclab/sim/schemas/schemas.py b/source/isaaclab/isaaclab/sim/schemas/schemas.py index 991224936bd2..ed101e285735 100644 --- a/source/isaaclab/isaaclab/sim/schemas/schemas.py +++ b/source/isaaclab/isaaclab/sim/schemas/schemas.py @@ -1011,10 +1011,7 @@ def modify_fixed_tendon_properties( camel_case=False, ) else: - # NOTE: This ``mjc:*`` branch (for the ``MjcTendon`` prim type) is a future split - # candidate -- it could become a separate Mjc tendon fragment + applier in - # isaaclab_newton, mirroring the PhysX/Mjc split done for other families. It is - # kept inline here for now so the schema-fragment migration stays additive. + # NOTE: ``mjc:*`` branch (``MjcTendon`` prim) kept inline; future split candidate into isaaclab_newton. # only stiffness and damping in the cfg map to mjc attributes for attr_name, value in cfg.items(): safe_set_attribute_on_usd_prim( diff --git a/source/isaaclab_physx/isaaclab_physx/sim/schemas/schemas.py b/source/isaaclab_physx/isaaclab_physx/sim/schemas/schemas.py index f41aae4eeb6d..3684751d61f7 100644 --- a/source/isaaclab_physx/isaaclab_physx/sim/schemas/schemas.py +++ b/source/isaaclab_physx/isaaclab_physx/sim/schemas/schemas.py @@ -69,8 +69,7 @@ def apply_fixed_tendon(cfg, prim_path: str, stage: Usd.Stage | None = None) -> b True if the properties were successfully set, False otherwise. """ legacy = schemas_cfg.PhysxFixedTendonPropertiesCfg(**_strip_fragment_fields(cfg)) - # ``modify_fixed_tendon_properties`` is wrapped by ``apply_nested`` and returns ``None``; - # report success explicitly to honor the documented ``bool`` contract. + # ``apply_nested`` wrapper returns ``None``; report success explicitly for the ``bool`` contract. modify_fixed_tendon_properties(prim_path, legacy, stage) return True @@ -94,7 +93,6 @@ def apply_spatial_tendon(cfg, prim_path: str, stage: Usd.Stage | None = None) -> True if the properties were successfully set, False otherwise. """ legacy = schemas_cfg.PhysxSpatialTendonPropertiesCfg(**_strip_fragment_fields(cfg)) - # ``modify_spatial_tendon_properties`` is wrapped by ``apply_nested`` and returns ``None``; - # report success explicitly to honor the documented ``bool`` contract. + # ``apply_nested`` wrapper returns ``None``; report success explicitly for the ``bool`` contract. modify_spatial_tendon_properties(prim_path, legacy, stage) return True 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 2c44a2075527..0b3a03f214bb 100644 --- a/source/isaaclab_physx/isaaclab_physx/sim/schemas/schemas_cfg.py +++ b/source/isaaclab_physx/isaaclab_physx/sim/schemas/schemas_cfg.py @@ -992,8 +992,7 @@ class PhysxFixedTendonCfg(FixedTendonFragment): .. _PhysxTendonAxisRootAPI: https://docs.omniverse.nvidia.com/kit/docs/omni_usd_schema_physics/104.2/class_physx_schema_physx_tendon_axis_root_a_p_i.html """ - # ``func`` is overridden because the existing writer iterates the multi-instance - # ``PhysxTendonAxisRootAPI:`` schemas; the generic ``apply_namespaced`` cannot. + # override ``func``: writer iterates multi-instance ``PhysxTendonAxisRootAPI`` schemas; ``apply_namespaced`` cannot. func: Callable | str = "isaaclab_physx.sim.schemas:apply_fixed_tendon" tendon_enabled: bool | None = None @@ -1038,9 +1037,8 @@ class PhysxSpatialTendonCfg(SpatialTendonFragment): .. _PhysxTendonAttachmentRootAPI: https://docs.omniverse.nvidia.com/kit/docs/omni_usd_schema_physics/104.2/class_physx_schema_physx_tendon_attachment_root_a_p_i.html """ - # ``func`` is overridden because the existing writer iterates the multi-instance - # ``PhysxTendonAttachmentRootAPI:`` / ``PhysxTendonAttachmentLeafAPI:`` schemas; - # the generic ``apply_namespaced`` cannot. + # override ``func``: writer iterates multi-instance ``PhysxTendonAttachment{Root,Leaf}API`` + # schemas, which the generic ``apply_namespaced`` cannot. func: Callable | str = "isaaclab_physx.sim.schemas:apply_spatial_tendon" tendon_enabled: bool | None = None From 2cad30be1c2569b43d65b3929d06c6aeb874a416 Mon Sep 17 00:00:00 2001 From: Vidur Vij Date: Wed, 24 Jun 2026 23:51:05 -0700 Subject: [PATCH 05/14] Add prim guard and result aggregation to tendon writers Bring apply_fixed_tendon_properties and apply_spatial_tendon_properties in line with sibling apply_* writers: raise ValueError on an invalid prim path instead of silently skipping, and AND each fragment's return value so a reported failure is not masked. Also annotate modify_fixed_tendon_properties and modify_spatial_tendon_properties as superseded by the fragment path; they are retained for back-compat with the transitional Physx*TendonPropertiesCfg configs until callers migrate. Tests added to test_tendon_fragments.py cover both the guard and the aggregation logic. --- .../isaaclab/isaaclab/sim/schemas/schemas.py | 33 +++++++++-- .../test/sim/test_tendon_fragments.py | 59 ++++++++++++++++++- 2 files changed, 85 insertions(+), 7 deletions(-) diff --git a/source/isaaclab/isaaclab/sim/schemas/schemas.py b/source/isaaclab/isaaclab/sim/schemas/schemas.py index ed101e285735..47c83f5bde2f 100644 --- a/source/isaaclab/isaaclab/sim/schemas/schemas.py +++ b/source/isaaclab/isaaclab/sim/schemas/schemas.py @@ -943,14 +943,21 @@ def apply_fixed_tendon_properties(prim_path: str, fragments, stage: Usd.Stage | stage is used. Returns: - True if the properties were successfully set. + True if all fragments applied successfully, False if any fragment reported failure. """ if stage is None: stage = get_current_stage() + prim = stage.GetPrimAtPath(prim_path) + # fail loudly on an invalid path (matches the sibling apply_* writers) + if not prim.IsValid(): + raise ValueError(f"Prim path '{prim_path}' is not valid.") + # tune-not-apply: the PhysxTendonAxisRootAPI instances already exist; apply no anchor. + # aggregate per-fragment results so a reported failure is not silently masked. + success = True for cfg in fragments: func = cfg.func if callable(cfg.func) else string_to_callable(cfg.func) - func(cfg, prim_path, stage) - return True + success = bool(func(cfg, prim_path, stage)) and success + return success @apply_nested @@ -984,6 +991,9 @@ def modify_fixed_tendon_properties( Raises: ValueError: If the input prim path is not valid. """ + # NOTE: superseded by the fragment path (apply_*_tendon_properties dispatching + # Physx*/Mujoco* tendon fragments). Retained for back-compat with the transitional + # Physx*TendonPropertiesCfg cfgs; slated for removal once callers migrate. # get stage handle if stage is None: stage = get_current_stage() @@ -1043,14 +1053,22 @@ def apply_spatial_tendon_properties(prim_path: str, fragments, stage: Usd.Stage stage is used. Returns: - True if the properties were successfully set. + True if all fragments applied successfully, False if any fragment reported failure. """ if stage is None: stage = get_current_stage() + prim = stage.GetPrimAtPath(prim_path) + # fail loudly on an invalid path (matches the sibling apply_* writers) + if not prim.IsValid(): + raise ValueError(f"Prim path '{prim_path}' is not valid.") + # tune-not-apply: the PhysxTendonAttachmentRootAPI / PhysxTendonAttachmentLeafAPI instances + # already exist; apply no anchor. + # aggregate per-fragment results so a reported failure is not silently masked. + success = True for cfg in fragments: func = cfg.func if callable(cfg.func) else string_to_callable(cfg.func) - func(cfg, prim_path, stage) - return True + success = bool(func(cfg, prim_path, stage)) and success + return success @apply_nested @@ -1086,6 +1104,9 @@ def modify_spatial_tendon_properties( Raises: ValueError: If the input prim path is not valid. """ + # NOTE: superseded by the fragment path (apply_*_tendon_properties dispatching + # Physx*/Mujoco* tendon fragments). Retained for back-compat with the transitional + # Physx*TendonPropertiesCfg cfgs; slated for removal once callers migrate. # obtain stage if stage is None: stage = get_current_stage() diff --git a/source/isaaclab/test/sim/test_tendon_fragments.py b/source/isaaclab/test/sim/test_tendon_fragments.py index f7f224d1bdd6..672aad663056 100644 --- a/source/isaaclab/test/sim/test_tendon_fragments.py +++ b/source/isaaclab/test/sim/test_tendon_fragments.py @@ -12,12 +12,30 @@ """Rest everything follows.""" -from pxr import PhysxSchema, UsdGeom +import pytest + +from pxr import PhysxSchema, Sdf, UsdGeom import isaaclab.sim as sim_utils from isaaclab.sim import SimulationCfg, SimulationContext +def _new_sim(): + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + return sim_utils.get_current_stage() + + +def _make_prim_with_schemas(stage, path, schema_tokens): + """Define an Xform and stamp ``apiSchemas`` metadata with the given multi-instance tokens.""" + UsdGeom.Xform.Define(stage, path) + prim = stage.GetPrimAtPath(path) + token_op = Sdf.TokenListOp() + token_op.explicitItems = schema_tokens + prim.SetMetadata("apiSchemas", token_op) + return prim + + def _make_xform(stage, path="/World/Tendon"): UsdGeom.Xform.Define(stage, path) return stage.GetPrimAtPath(path) @@ -176,3 +194,42 @@ def test_public_imports(): apply_fixed_tendon_properties, apply_spatial_tendon_properties, ) + + +# ------------------------------------------------------------------------------------- +# core writer parity: invalid-prim guard + aggregated return +# ------------------------------------------------------------------------------------- + + +def test_apply_fixed_tendon_raises_on_invalid_prim(): + from isaaclab.sim.schemas import apply_fixed_tendon_properties + + _new_sim() + stage = sim_utils.get_current_stage() + with pytest.raises(ValueError): + apply_fixed_tendon_properties("/World/DoesNotExist", [], stage) + + +def test_apply_spatial_tendon_raises_on_invalid_prim(): + from isaaclab.sim.schemas import apply_spatial_tendon_properties + + _new_sim() + stage = sim_utils.get_current_stage() + with pytest.raises(ValueError): + apply_spatial_tendon_properties("/World/DoesNotExist", [], stage) + + +def test_apply_fixed_tendon_aggregates_fragment_results(): + from isaaclab.sim.schemas import UsdPhysicsRigidBodyCfg, apply_fixed_tendon_properties + + stage = _new_sim() + _make_prim_with_schemas(stage, "/World/Agg", ["PhysxTendonAxisRootAPI:inst0"]) + + # a fragment whose applier reports failure makes the aggregate False + failing = UsdPhysicsRigidBodyCfg(rigid_body_enabled=True) + failing.func = lambda cfg, prim_path, stage=None: False + assert apply_fixed_tendon_properties("/World/Agg", [failing], stage) is False + + ok = UsdPhysicsRigidBodyCfg(rigid_body_enabled=True) + ok.func = lambda cfg, prim_path, stage=None: True + assert apply_fixed_tendon_properties("/World/Agg", [ok], stage) is True From 9971d3f5937068700d173cde98f68275989acdb0 Mon Sep 17 00:00:00 2001 From: Vidur Vij Date: Thu, 25 Jun 2026 00:15:12 -0700 Subject: [PATCH 06/14] Enumerate tendon instances directly in fragment appliers Replace the delegating bodies of apply_fixed_tendon and apply_spatial_tendon with direct multi-instance enumeration over PhysxTendonAxisRootAPI and PhysxTendonAttachment{Root,Leaf}API respectively. Remove the modify_fixed_tendon_properties / modify_spatial_tendon_properties imports and the legacy-cfg reconstruction; add safe_set_attribute_on_usd_prim, get_current_stage, and to_camel_case imports. Add test_apply_fixed_tendon_writes_all_instances as a two-instance regression guard. All 11 tendon tests pass. --- .../test/sim/test_tendon_fragments.py | 12 +++ .../isaaclab_physx/sim/schemas/schemas.py | 82 ++++++++++++------- 2 files changed, 64 insertions(+), 30 deletions(-) diff --git a/source/isaaclab/test/sim/test_tendon_fragments.py b/source/isaaclab/test/sim/test_tendon_fragments.py index 672aad663056..b9894dbb4fa0 100644 --- a/source/isaaclab/test/sim/test_tendon_fragments.py +++ b/source/isaaclab/test/sim/test_tendon_fragments.py @@ -119,6 +119,18 @@ def test_physx_fixed_tendon_fragment_writes_instanced_namespace(): # ------------------------------------------------------------------------------------- +def test_apply_fixed_tendon_writes_all_instances(): + from isaaclab_physx.sim.schemas import PhysxFixedTendonCfg, apply_fixed_tendon + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + prim = _make_prim_with_schemas(stage, "/World/FTmulti", ["PhysxTendonAxisRootAPI:t0", "PhysxTendonAxisRootAPI:t1"]) + assert apply_fixed_tendon(PhysxFixedTendonCfg(stiffness=9.0), "/World/FTmulti", stage) is True + for inst in ("t0", "t1"): + assert abs(prim.GetAttribute(f"PhysxTendonAxisRootAPI:{inst}:stiffness").Get() - 9.0) < 1e-6 + + def test_physx_spatial_tendon_fragment_writes_instanced_namespace(): from isaaclab_physx.sim.schemas import PhysxSpatialTendonCfg, apply_spatial_tendon diff --git a/source/isaaclab_physx/isaaclab_physx/sim/schemas/schemas.py b/source/isaaclab_physx/isaaclab_physx/sim/schemas/schemas.py index 3684751d61f7..ca850d597eb5 100644 --- a/source/isaaclab_physx/isaaclab_physx/sim/schemas/schemas.py +++ b/source/isaaclab_physx/isaaclab_physx/sim/schemas/schemas.py @@ -20,11 +20,10 @@ from isaaclab.sim.schemas.schemas import ( define_deformable_body_properties, modify_deformable_body_properties, - modify_fixed_tendon_properties, - modify_spatial_tendon_properties, ) - -from . import schemas_cfg +from isaaclab.sim.utils import safe_set_attribute_on_usd_prim +from isaaclab.sim.utils.stage import get_current_stage +from isaaclab.utils.string import to_camel_case __all__ = [ "apply_fixed_tendon", @@ -51,48 +50,71 @@ def _strip_fragment_fields(cfg) -> dict: def apply_fixed_tendon(cfg, prim_path: str, stage: Usd.Stage | None = None) -> bool: - """Apply a :class:`PhysxFixedTendonCfg` fragment by delegating to the legacy writer. + """Tune the multi-instance ``PhysxTendonAxisRootAPI`` schemas on a prim. - Thin override-``func`` wrapper for fixed-tendon fragments. The fragment's data fields are - repackaged into a :class:`PhysxFixedTendonPropertiesCfg` (dropping the ``func`` plumbing - field) and handed to :func:`~isaaclab.sim.schemas.modify_fixed_tendon_properties`, which - tunes every existing ``PhysxTendonAxisRootAPI:`` instance on the prim (and the - ``MjcTendon`` ``mjc:*`` branch when present). + Custom ``func`` override for :class:`PhysxFixedTendonCfg`. The fixed-tendon schema is + multi-instance and *tune-not-apply* (instances are authored in the source asset), so this + writes each set fragment field as ``:`` across every applied + ``PhysxTendonAxisRootAPI`` instance and applies no schema. Writes nothing for the ``mjc:`` + Mujoco path -- that is handled by + :func:`~isaaclab_newton.sim.schemas.apply_mujoco_fixed_tendon`. Args: cfg: The :class:`PhysxFixedTendonCfg` fragment to apply. - prim_path: The prim path to the tendon attachment. - stage: The stage where to find the prim. Defaults to None, in which case the current - stage is used. + prim_path: The prim path carrying the fixed-tendon schemas. + stage: The stage where to find the prim. Defaults to the current stage. Returns: - True if the properties were successfully set, False otherwise. + True if at least one ``PhysxTendonAxisRootAPI`` instance was tuned, False if none is applied. """ - legacy = schemas_cfg.PhysxFixedTendonPropertiesCfg(**_strip_fragment_fields(cfg)) - # ``apply_nested`` wrapper returns ``None``; report success explicitly for the ``bool`` contract. - modify_fixed_tendon_properties(prim_path, legacy, stage) + if stage is None: + stage = get_current_stage() + prim = stage.GetPrimAtPath(prim_path) + if not prim.IsValid(): + raise ValueError(f"Prim path '{prim_path}' is not valid.") + applied_schemas = prim.GetAppliedSchemas() + if not any("PhysxTendonAxisRootAPI" in s for s in applied_schemas): + return False + values = _strip_fragment_fields(cfg) + for schema_name in applied_schemas: + if "PhysxTendonAxisRootAPI" not in schema_name: + continue + for attr_name, value in values.items(): + safe_set_attribute_on_usd_prim( + prim, f"{schema_name}:{to_camel_case(attr_name, 'cC')}", value, camel_case=False + ) return True def apply_spatial_tendon(cfg, prim_path: str, stage: Usd.Stage | None = None) -> bool: - """Apply a :class:`PhysxSpatialTendonCfg` fragment by delegating to the legacy writer. + """Tune the multi-instance ``PhysxTendonAttachment{Root,Leaf}API`` schemas on a prim. - Thin override-``func`` wrapper for spatial-tendon fragments. The fragment's data fields are - repackaged into a :class:`PhysxSpatialTendonPropertiesCfg` (dropping the ``func`` plumbing - field) and handed to :func:`~isaaclab.sim.schemas.modify_spatial_tendon_properties`, which - tunes every existing ``PhysxTendonAttachmentRootAPI:`` / - ``PhysxTendonAttachmentLeafAPI:`` instance on the prim. + Custom ``func`` override for :class:`PhysxSpatialTendonCfg`. Writes each set fragment field + across every applied attachment-root and attachment-leaf instance and applies no schema. Args: cfg: The :class:`PhysxSpatialTendonCfg` fragment to apply. - prim_path: The prim path to the tendon attachment. - stage: The stage where to find the prim. Defaults to None, in which case the current - stage is used. + prim_path: The prim path carrying the spatial-tendon schemas. + stage: The stage where to find the prim. Defaults to the current stage. Returns: - True if the properties were successfully set, False otherwise. + True if at least one attachment instance was tuned, False if none is applied. """ - legacy = schemas_cfg.PhysxSpatialTendonPropertiesCfg(**_strip_fragment_fields(cfg)) - # ``apply_nested`` wrapper returns ``None``; report success explicitly for the ``bool`` contract. - modify_spatial_tendon_properties(prim_path, legacy, stage) + if stage is None: + stage = get_current_stage() + prim = stage.GetPrimAtPath(prim_path) + if not prim.IsValid(): + raise ValueError(f"Prim path '{prim_path}' is not valid.") + applied_schemas = prim.GetAppliedSchemas() + markers = ("PhysxTendonAttachmentRootAPI", "PhysxTendonAttachmentLeafAPI") + if not any(m in s for s in applied_schemas for m in markers): + return False + values = _strip_fragment_fields(cfg) + for schema_name in applied_schemas: + if not any(m in schema_name for m in markers): + continue + for attr_name, value in values.items(): + safe_set_attribute_on_usd_prim( + prim, f"{schema_name}:{to_camel_case(attr_name, 'cC')}", value, camel_case=False + ) return True From 21295dccf9b5762dbc01f9044eaa586b826e2e30 Mon Sep 17 00:00:00 2001 From: Vidur Vij Date: Thu, 25 Jun 2026 00:26:34 -0700 Subject: [PATCH 07/14] Add Mujoco fixed-tendon fragment and applier MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces MujocoFixedTendonCfg (a FixedTendonFragment subclass with _usd_namespace="mjc") and apply_mujoco_fixed_tendon in a new isaaclab_newton/sim/schemas/schemas.py. The applier gates on MjcTendon prim type (returns False otherwise) and writes mjc:stiffness / mjc:damping via the existing safe_set_attribute_on_usd_prim path. Exports are wired through __init__.pyi (alphabetically sorted). The spawner fixed_tendons_props slot already admitted FixedTendonFragment | list[FixedTendonFragment] — no widening needed. --- .../test/sim/test_tendon_fragments.py | 40 ++++++++++++++ .../vidurv-schema-frag-tendons.minor.rst | 6 +++ .../isaaclab_newton/sim/schemas/__init__.pyi | 6 +++ .../isaaclab_newton/sim/schemas/schemas.py | 52 +++++++++++++++++++ .../sim/schemas/schemas_cfg.py | 26 ++++++++++ .../vidurv-schema-frag-tendons.minor.rst | 8 +++ 6 files changed, 138 insertions(+) create mode 100644 source/isaaclab_newton/changelog.d/vidurv-schema-frag-tendons.minor.rst create mode 100644 source/isaaclab_newton/isaaclab_newton/sim/schemas/schemas.py diff --git a/source/isaaclab/test/sim/test_tendon_fragments.py b/source/isaaclab/test/sim/test_tendon_fragments.py index b9894dbb4fa0..0eb00212263e 100644 --- a/source/isaaclab/test/sim/test_tendon_fragments.py +++ b/source/isaaclab/test/sim/test_tendon_fragments.py @@ -245,3 +245,43 @@ def test_apply_fixed_tendon_aggregates_fragment_results(): ok = UsdPhysicsRigidBodyCfg(rigid_body_enabled=True) ok.func = lambda cfg, prim_path, stage=None: True assert apply_fixed_tendon_properties("/World/Agg", [ok], stage) is True + + +# ------------------------------------------------------------------------------------- +# MujocoFixedTendonCfg — Newton fragment for the mjc: namespace +# ------------------------------------------------------------------------------------- + + +def test_mujoco_fixed_tendon_metadata(): + from isaaclab_newton.sim.schemas import MujocoFixedTendonCfg + + from isaaclab.sim.schemas import FixedTendonFragment + + cfg = MujocoFixedTendonCfg(stiffness=2.0) + assert isinstance(cfg, FixedTendonFragment) + assert type(cfg)._usd_namespace == "mjc" + assert cfg.func == "isaaclab_newton.sim.schemas:apply_mujoco_fixed_tendon" + assert not hasattr(cfg, "rest_length") and not hasattr(cfg, "limit_stiffness") + + +def test_apply_mujoco_fixed_tendon_writes_mjc_namespace(): + from isaaclab_newton.sim.schemas import MujocoFixedTendonCfg, apply_mujoco_fixed_tendon + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + stage.DefinePrim("/World/MjcT", "MjcTendon") + assert apply_mujoco_fixed_tendon(MujocoFixedTendonCfg(stiffness=2.0, damping=0.25), "/World/MjcT", stage) is True + prim = stage.GetPrimAtPath("/World/MjcT") + assert abs(prim.GetAttribute("mjc:stiffness").Get() - 2.0) < 1e-6 + assert abs(prim.GetAttribute("mjc:damping").Get() - 0.25) < 1e-6 + + +def test_apply_mujoco_fixed_tendon_returns_false_on_non_mjc_prim(): + from isaaclab_newton.sim.schemas import MujocoFixedTendonCfg, apply_mujoco_fixed_tendon + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + UsdGeom.Xform.Define(stage, "/World/NotMjc") + assert apply_mujoco_fixed_tendon(MujocoFixedTendonCfg(stiffness=2.0), "/World/NotMjc", stage) is False diff --git a/source/isaaclab_newton/changelog.d/vidurv-schema-frag-tendons.minor.rst b/source/isaaclab_newton/changelog.d/vidurv-schema-frag-tendons.minor.rst new file mode 100644 index 000000000000..0f5b86c5fb2d --- /dev/null +++ b/source/isaaclab_newton/changelog.d/vidurv-schema-frag-tendons.minor.rst @@ -0,0 +1,6 @@ +Added +^^^^^ + +* Added :class:`~isaaclab_newton.sim.schemas.MujocoFixedTendonCfg` and its applier + ``apply_mujoco_fixed_tendon`` for tuning ``mjc:*`` fixed-tendon attributes on ``MjcTendon`` prims, + splitting the Mujoco tune path out of the PhysX fixed-tendon applier. diff --git a/source/isaaclab_newton/isaaclab_newton/sim/schemas/__init__.pyi b/source/isaaclab_newton/isaaclab_newton/sim/schemas/__init__.pyi index 89ca2d068afb..0b2781273c5b 100644 --- a/source/isaaclab_newton/isaaclab_newton/sim/schemas/__init__.pyi +++ b/source/isaaclab_newton/isaaclab_newton/sim/schemas/__init__.pyi @@ -4,6 +4,7 @@ # SPDX-License-Identifier: BSD-3-Clause __all__ = [ + "MujocoFixedTendonCfg", "MujocoJointDrivePropertiesCfg", "MujocoRigidBodyCfg", "MujocoRigidBodyPropertiesCfg", @@ -15,9 +16,14 @@ __all__ = [ "NewtonMeshCollisionPropertiesCfg", "NewtonRigidBodyPropertiesCfg", "NewtonSDFCollisionPropertiesCfg", + "apply_mujoco_fixed_tendon", ] +from .schemas import ( + apply_mujoco_fixed_tendon, +) from .schemas_cfg import ( + MujocoFixedTendonCfg, MujocoJointDrivePropertiesCfg, MujocoRigidBodyCfg, MujocoRigidBodyPropertiesCfg, diff --git a/source/isaaclab_newton/isaaclab_newton/sim/schemas/schemas.py b/source/isaaclab_newton/isaaclab_newton/sim/schemas/schemas.py new file mode 100644 index 000000000000..e639c2f543d1 --- /dev/null +++ b/source/isaaclab_newton/isaaclab_newton/sim/schemas/schemas.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 + +"""Newton-backend schema writers (custom appliers for irregular fragments).""" + +from __future__ import annotations + +import dataclasses + +from pxr import Usd + +from isaaclab.sim.utils import safe_set_attribute_on_usd_prim +from isaaclab.sim.utils.stage import get_current_stage +from isaaclab.utils.string import to_camel_case + +__all__ = ["apply_mujoco_fixed_tendon"] + + +def apply_mujoco_fixed_tendon(cfg, prim_path: str, stage: Usd.Stage | None = None) -> bool: + """Write ``mjc:*`` fixed-tendon attributes on a ``MjcTendon`` prim. + + Custom ``func`` override for :class:`~isaaclab_newton.sim.schemas.MujocoFixedTendonCfg`. + No-op (returns False) on any prim whose type is not ``MjcTendon``. + + Args: + cfg: The :class:`MujocoFixedTendonCfg` fragment to apply. + prim_path: The prim path of the ``MjcTendon`` prim. + stage: The stage where to find the prim. Defaults to the current stage. + + Returns: + True if the prim is a ``MjcTendon`` and was tuned, False otherwise. + + Raises: + ValueError: If the prim at ``prim_path`` does not exist in the stage. + """ + if stage is None: + stage = get_current_stage() + prim = stage.GetPrimAtPath(prim_path) + if not prim.IsValid(): + raise ValueError(f"Prim path '{prim_path}' is not valid.") + if prim.GetTypeName() != "MjcTendon": + return False + values = { + f.name: getattr(cfg, f.name) + for f in dataclasses.fields(cfg) + if f.name != "func" and getattr(cfg, f.name) is not None + } + for attr_name, value in values.items(): + safe_set_attribute_on_usd_prim(prim, f"mjc:{to_camel_case(attr_name, 'cC')}", value, camel_case=False) + return True diff --git a/source/isaaclab_newton/isaaclab_newton/sim/schemas/schemas_cfg.py b/source/isaaclab_newton/isaaclab_newton/sim/schemas/schemas_cfg.py index 4c99379e1ad8..6625635e4bac 100644 --- a/source/isaaclab_newton/isaaclab_newton/sim/schemas/schemas_cfg.py +++ b/source/isaaclab_newton/isaaclab_newton/sim/schemas/schemas_cfg.py @@ -5,12 +5,14 @@ from __future__ import annotations +from collections.abc import Callable from typing import ClassVar, Literal from isaaclab.sim.schemas.schemas_cfg import ( ArticulationRootBaseCfg, CollisionBaseCfg, DeformableBodyPropertiesBaseCfg, + FixedTendonFragment, JointDriveBaseCfg, MeshCollisionBaseCfg, RigidBodyBaseCfg, @@ -321,6 +323,30 @@ class NewtonMaterialPropertiesCfg(RigidBodyMaterialBaseCfg): """ +@configclass +class MujocoFixedTendonCfg(FixedTendonFragment): + """``mjc:*`` fixed-tendon attributes for a ``MjcTendon`` prim. + + The Mujoco fixed-tendon fragment. Newton has no tendon solver; this models only the ``mjc:*`` + tune path the Newton/Mujoco importer reads from a ``MjcTendon`` prim, carrying only the fields + that path maps. Overrides :attr:`func` with a custom applier + (:func:`~isaaclab_newton.sim.schemas.apply_mujoco_fixed_tendon`) that gates on the ``MjcTendon`` + prim type, and composes with :class:`~isaaclab_physx.sim.schemas.PhysxFixedTendonCfg` via + :func:`~isaaclab.sim.schemas.apply_fixed_tendon_properties`. + """ + + _usd_namespace: ClassVar[str | None] = "mjc" + _usd_applied_schema: ClassVar[str | None] = None + + func: Callable | str = "isaaclab_newton.sim.schemas:apply_mujoco_fixed_tendon" + + stiffness: float | None = None + """Spring stiffness term acting on the tendon's length [N/m].""" + + damping: float | None = None + """Damping term acting on the tendon length [N·s/m].""" + + @configclass class NewtonArticulationRootPropertiesCfg(ArticulationRootBaseCfg): """Newton-specific articulation root properties. diff --git a/source/isaaclab_physx/changelog.d/vidurv-schema-frag-tendons.minor.rst b/source/isaaclab_physx/changelog.d/vidurv-schema-frag-tendons.minor.rst index ed7d5458f920..34211b90615c 100644 --- a/source/isaaclab_physx/changelog.d/vidurv-schema-frag-tendons.minor.rst +++ b/source/isaaclab_physx/changelog.d/vidurv-schema-frag-tendons.minor.rst @@ -8,3 +8,11 @@ Added :func:`~isaaclab_physx.sim.schemas.apply_spatial_tendon`, which delegate to the existing multi-instance tendon writers to tune every applied ``PhysxTendonAxisRootAPI`` / ``PhysxTendonAttachmentRootAPI`` / ``PhysxTendonAttachmentLeafAPI`` instance. + +Changed +^^^^^^^ + +* Reworked :class:`~isaaclab_physx.sim.schemas.PhysxFixedTendonCfg` / + :class:`~isaaclab_physx.sim.schemas.PhysxSpatialTendonCfg` appliers to tune the multi-instance + PhysX tendon schemas directly, removing the dependency on the legacy + ``modify_*_tendon_properties`` writers and the legacy ``Physx*TendonPropertiesCfg`` reconstruction. From eeec065cdb04354a11a278a9fd768647ba138dcd Mon Sep 17 00:00:00 2001 From: Vidur Vij Date: Thu, 25 Jun 2026 00:43:26 -0700 Subject: [PATCH 08/14] Import schemas package in mesh converter for SchemaFragment mesh_converter imported the schemas module, which lacks SchemaFragment (defined in schemas_cfg, re-exported by the package). The transition shim's isinstance(f, schemas.SchemaFragment) check raised AttributeError at convert time. Import the package instead, matching the other spawners. --- source/isaaclab/isaaclab/sim/converters/mesh_converter.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/source/isaaclab/isaaclab/sim/converters/mesh_converter.py b/source/isaaclab/isaaclab/sim/converters/mesh_converter.py index f814e4fdb877..1f999238e59d 100644 --- a/source/isaaclab/isaaclab/sim/converters/mesh_converter.py +++ b/source/isaaclab/isaaclab/sim/converters/mesh_converter.py @@ -12,9 +12,9 @@ from isaacsim.core.experimental.utils.app import enable_extension from pxr import Gf, Tf, Usd, UsdGeom, UsdPhysics, UsdUtils +from isaaclab.sim import schemas from isaaclab.sim.converters.asset_converter_base import AssetConverterBase from isaaclab.sim.converters.mesh_converter_cfg import MeshConverterCfg -from isaaclab.sim.schemas import schemas from isaaclab.sim.utils import delete_prim, export_prim_to_file # import logger From 4df053be35a37cff91007116c999e0e5e5236a48 Mon Sep 17 00:00:00 2001 From: Vidur Vij Date: Thu, 25 Jun 2026 01:00:42 -0700 Subject: [PATCH 09/14] Address tendon review: docs, changelog, and test coverage Docstrings: add backend-composition note and Raises: ValueError to apply_fixed_tendon_properties / apply_spatial_tendon_properties; reword backward-compat NOTE comments to drop internal staging jargon; replace cross-package Sphinx role in physx applier docstring with prose; reword MujocoFixedTendonCfg composition sentence. Type annotations: annotate cfg params on apply_mujoco_fixed_tendon, apply_fixed_tendon, and apply_spatial_tendon with their fragment types via local schemas_cfg imports. Exports: move apply_mujoco_fixed_tendon before TitleCase classes in isaaclab_newton __init__.pyi __all__ (functions before classes). Changelogs: add isaaclab core fragment (ValueError + aggregated return); append migration guidance to physx Changed entry; convert plain literal to Sphinx :func: role in newton fragment. Tests: add test_apply_spatial_tendon_writes_all_instances (Root+Leaf); add raises-on-invalid-prim tests for both physx and newton appliers; extend non-mjc-prim test with HasAttribute check; add func-exclusion assertions to spatial write test and mjc positive write test. --- .../vidurv-schema-frag-tendons.minor.rst | 8 ++++ .../isaaclab/isaaclab/sim/schemas/schemas.py | 26 ++++++++++--- .../test/sim/test_tendon_fragments.py | 38 +++++++++++++++++++ .../vidurv-schema-frag-tendons.minor.rst | 3 +- .../isaaclab_newton/sim/schemas/__init__.pyi | 2 +- .../isaaclab_newton/sim/schemas/schemas.py | 4 +- .../sim/schemas/schemas_cfg.py | 5 ++- .../vidurv-schema-frag-tendons.minor.rst | 4 ++ .../isaaclab_physx/sim/schemas/schemas.py | 9 +++-- 9 files changed, 84 insertions(+), 15 deletions(-) diff --git a/source/isaaclab/changelog.d/vidurv-schema-frag-tendons.minor.rst b/source/isaaclab/changelog.d/vidurv-schema-frag-tendons.minor.rst index a57dd87cdddf..c32fab0d6686 100644 --- a/source/isaaclab/changelog.d/vidurv-schema-frag-tendons.minor.rst +++ b/source/isaaclab/changelog.d/vidurv-schema-frag-tendons.minor.rst @@ -19,3 +19,11 @@ Changed :attr:`~isaaclab.sim.spawners.from_files.FileCfg.spatial_tendons_props`) to also accept one or more tendon fragments. Legacy single cfgs continue to work through a transition bridge in the spawn writer. +* :func:`~isaaclab.sim.schemas.apply_fixed_tendon_properties` and + :func:`~isaaclab.sim.schemas.apply_spatial_tendon_properties` now raise + ``ValueError`` when the prim at ``prim_path`` does not exist in the stage. + Callers that previously relied on an implicit no-op for invalid paths must + either validate the path beforehand or catch ``ValueError``. + The aggregated return value is now ``False`` whenever any fragment applier + reports failure; callers must not assume the return is always ``True`` even + when the prim is valid. diff --git a/source/isaaclab/isaaclab/sim/schemas/schemas.py b/source/isaaclab/isaaclab/sim/schemas/schemas.py index 47c83f5bde2f..50eeabf2ade9 100644 --- a/source/isaaclab/isaaclab/sim/schemas/schemas.py +++ b/source/isaaclab/isaaclab/sim/schemas/schemas.py @@ -936,6 +936,10 @@ def apply_fixed_tendon_properties(prim_path: str, fragments, stage: Usd.Stage | :attr:`~isaaclab.sim.schemas.SchemaFragment.func`, which tunes the existing instances. Backend fragments carry backend-specific funcs, so core never imports a backend. + Each fragment tunes only its own schema and returns ``False`` when that schema is not + present on the prim. A prim carries a single tendon backend, so compose backends across + prims rather than mixing PhysX and Mujoco fragments in one list on one prim. + Args: prim_path: The prim path to apply the fixed-tendon schemas on. fragments: An iterable of :class:`~isaaclab.sim.schemas.FixedTendonFragment` instances. @@ -944,6 +948,9 @@ def apply_fixed_tendon_properties(prim_path: str, fragments, stage: Usd.Stage | Returns: True if all fragments applied successfully, False if any fragment reported failure. + + Raises: + ValueError: If the prim at ``prim_path`` is not valid. """ if stage is None: stage = get_current_stage() @@ -991,9 +998,9 @@ def modify_fixed_tendon_properties( Raises: ValueError: If the input prim path is not valid. """ - # NOTE: superseded by the fragment path (apply_*_tendon_properties dispatching - # Physx*/Mujoco* tendon fragments). Retained for back-compat with the transitional - # Physx*TendonPropertiesCfg cfgs; slated for removal once callers migrate. + # Retained for backward compatibility with callers passing PhysxFixedTendonPropertiesCfg + # directly. Will be removed in a future release once callers adopt the fragment-based + # apply_fixed_tendon_properties path. # get stage handle if stage is None: stage = get_current_stage() @@ -1046,6 +1053,10 @@ def apply_spatial_tendon_properties(prim_path: str, fragments, stage: Usd.Stage :attr:`~isaaclab.sim.schemas.SchemaFragment.func`, which tunes the existing instances. Backend fragments carry backend-specific funcs, so core never imports a backend. + Each fragment tunes only its own schema and returns ``False`` when that schema is not + present on the prim. A prim carries a single tendon backend, so compose backends across + prims rather than mixing PhysX and Mujoco fragments in one list on one prim. + Args: prim_path: The prim path to apply the spatial-tendon schemas on. fragments: An iterable of :class:`~isaaclab.sim.schemas.SpatialTendonFragment` instances. @@ -1054,6 +1065,9 @@ def apply_spatial_tendon_properties(prim_path: str, fragments, stage: Usd.Stage Returns: True if all fragments applied successfully, False if any fragment reported failure. + + Raises: + ValueError: If the prim at ``prim_path`` is not valid. """ if stage is None: stage = get_current_stage() @@ -1104,9 +1118,9 @@ def modify_spatial_tendon_properties( Raises: ValueError: If the input prim path is not valid. """ - # NOTE: superseded by the fragment path (apply_*_tendon_properties dispatching - # Physx*/Mujoco* tendon fragments). Retained for back-compat with the transitional - # Physx*TendonPropertiesCfg cfgs; slated for removal once callers migrate. + # Retained for backward compatibility with callers passing PhysxSpatialTendonPropertiesCfg + # directly. Will be removed in a future release once callers adopt the fragment-based + # apply_spatial_tendon_properties path. # obtain stage if stage is None: stage = get_current_stage() diff --git a/source/isaaclab/test/sim/test_tendon_fragments.py b/source/isaaclab/test/sim/test_tendon_fragments.py index 0eb00212263e..7727d282528a 100644 --- a/source/isaaclab/test/sim/test_tendon_fragments.py +++ b/source/isaaclab/test/sim/test_tendon_fragments.py @@ -142,6 +142,23 @@ def test_physx_spatial_tendon_fragment_writes_instanced_namespace(): prefix = _tendon_attr_prefix(prim, "PhysxTendonAttachmentRootAPI") assert abs(prim.GetAttribute(f"{prefix}:stiffness").Get() - 4.0) < 1e-6 assert abs(prim.GetAttribute(f"{prefix}:limitStiffness").Get() - 0.25) < 1e-6 + assert not prim.HasAttribute(f"{prefix}:func") + + +def test_apply_spatial_tendon_writes_all_instances(): + from isaaclab_physx.sim.schemas import PhysxSpatialTendonCfg, apply_spatial_tendon + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + prim = _make_prim_with_schemas( + stage, + "/World/STmulti", + ["PhysxTendonAttachmentRootAPI:r0", "PhysxTendonAttachmentLeafAPI:l0"], + ) + assert apply_spatial_tendon(PhysxSpatialTendonCfg(stiffness=4.0), "/World/STmulti", stage) is True + assert abs(prim.GetAttribute("PhysxTendonAttachmentRootAPI:r0:stiffness").Get() - 4.0) < 1e-6 + assert abs(prim.GetAttribute("PhysxTendonAttachmentLeafAPI:l0:stiffness").Get() - 4.0) < 1e-6 # ------------------------------------------------------------------------------------- @@ -247,6 +264,24 @@ def test_apply_fixed_tendon_aggregates_fragment_results(): assert apply_fixed_tendon_properties("/World/Agg", [ok], stage) is True +def test_apply_fixed_tendon_raises_on_invalid_prim_backend(): + from isaaclab_physx.sim.schemas import PhysxFixedTendonCfg, apply_fixed_tendon + + _new_sim() + stage = sim_utils.get_current_stage() + with pytest.raises(ValueError): + apply_fixed_tendon(PhysxFixedTendonCfg(stiffness=1.0), "/World/DoesNotExist", stage) + + +def test_apply_mujoco_fixed_tendon_raises_on_invalid_prim(): + from isaaclab_newton.sim.schemas import MujocoFixedTendonCfg, apply_mujoco_fixed_tendon + + _new_sim() + stage = sim_utils.get_current_stage() + with pytest.raises(ValueError): + apply_mujoco_fixed_tendon(MujocoFixedTendonCfg(stiffness=1.0), "/World/DoesNotExist", stage) + + # ------------------------------------------------------------------------------------- # MujocoFixedTendonCfg — Newton fragment for the mjc: namespace # ------------------------------------------------------------------------------------- @@ -275,6 +310,7 @@ def test_apply_mujoco_fixed_tendon_writes_mjc_namespace(): prim = stage.GetPrimAtPath("/World/MjcT") assert abs(prim.GetAttribute("mjc:stiffness").Get() - 2.0) < 1e-6 assert abs(prim.GetAttribute("mjc:damping").Get() - 0.25) < 1e-6 + assert not prim.HasAttribute("mjc:func") def test_apply_mujoco_fixed_tendon_returns_false_on_non_mjc_prim(): @@ -285,3 +321,5 @@ def test_apply_mujoco_fixed_tendon_returns_false_on_non_mjc_prim(): stage = sim_utils.get_current_stage() UsdGeom.Xform.Define(stage, "/World/NotMjc") assert apply_mujoco_fixed_tendon(MujocoFixedTendonCfg(stiffness=2.0), "/World/NotMjc", stage) is False + prim = stage.GetPrimAtPath("/World/NotMjc") + assert not prim.HasAttribute("mjc:stiffness") diff --git a/source/isaaclab_newton/changelog.d/vidurv-schema-frag-tendons.minor.rst b/source/isaaclab_newton/changelog.d/vidurv-schema-frag-tendons.minor.rst index 0f5b86c5fb2d..75ebce232ad2 100644 --- a/source/isaaclab_newton/changelog.d/vidurv-schema-frag-tendons.minor.rst +++ b/source/isaaclab_newton/changelog.d/vidurv-schema-frag-tendons.minor.rst @@ -2,5 +2,6 @@ Added ^^^^^ * Added :class:`~isaaclab_newton.sim.schemas.MujocoFixedTendonCfg` and its applier - ``apply_mujoco_fixed_tendon`` for tuning ``mjc:*`` fixed-tendon attributes on ``MjcTendon`` prims, + :func:`~isaaclab_newton.sim.schemas.apply_mujoco_fixed_tendon` for tuning ``mjc:*`` + fixed-tendon attributes on ``MjcTendon`` prims, splitting the Mujoco tune path out of the PhysX fixed-tendon applier. diff --git a/source/isaaclab_newton/isaaclab_newton/sim/schemas/__init__.pyi b/source/isaaclab_newton/isaaclab_newton/sim/schemas/__init__.pyi index 0b2781273c5b..c2a0e371078a 100644 --- a/source/isaaclab_newton/isaaclab_newton/sim/schemas/__init__.pyi +++ b/source/isaaclab_newton/isaaclab_newton/sim/schemas/__init__.pyi @@ -4,6 +4,7 @@ # SPDX-License-Identifier: BSD-3-Clause __all__ = [ + "apply_mujoco_fixed_tendon", "MujocoFixedTendonCfg", "MujocoJointDrivePropertiesCfg", "MujocoRigidBodyCfg", @@ -16,7 +17,6 @@ __all__ = [ "NewtonMeshCollisionPropertiesCfg", "NewtonRigidBodyPropertiesCfg", "NewtonSDFCollisionPropertiesCfg", - "apply_mujoco_fixed_tendon", ] from .schemas import ( diff --git a/source/isaaclab_newton/isaaclab_newton/sim/schemas/schemas.py b/source/isaaclab_newton/isaaclab_newton/sim/schemas/schemas.py index e639c2f543d1..f74d05dbbdc2 100644 --- a/source/isaaclab_newton/isaaclab_newton/sim/schemas/schemas.py +++ b/source/isaaclab_newton/isaaclab_newton/sim/schemas/schemas.py @@ -15,10 +15,12 @@ from isaaclab.sim.utils.stage import get_current_stage from isaaclab.utils.string import to_camel_case +from .schemas_cfg import MujocoFixedTendonCfg + __all__ = ["apply_mujoco_fixed_tendon"] -def apply_mujoco_fixed_tendon(cfg, prim_path: str, stage: Usd.Stage | None = None) -> bool: +def apply_mujoco_fixed_tendon(cfg: MujocoFixedTendonCfg, prim_path: str, stage: Usd.Stage | None = None) -> bool: """Write ``mjc:*`` fixed-tendon attributes on a ``MjcTendon`` prim. Custom ``func`` override for :class:`~isaaclab_newton.sim.schemas.MujocoFixedTendonCfg`. 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 6625635e4bac..f54d3b03a59d 100644 --- a/source/isaaclab_newton/isaaclab_newton/sim/schemas/schemas_cfg.py +++ b/source/isaaclab_newton/isaaclab_newton/sim/schemas/schemas_cfg.py @@ -331,8 +331,9 @@ class MujocoFixedTendonCfg(FixedTendonFragment): tune path the Newton/Mujoco importer reads from a ``MjcTendon`` prim, carrying only the fields that path maps. Overrides :attr:`func` with a custom applier (:func:`~isaaclab_newton.sim.schemas.apply_mujoco_fixed_tendon`) that gates on the ``MjcTendon`` - prim type, and composes with :class:`~isaaclab_physx.sim.schemas.PhysxFixedTendonCfg` via - :func:`~isaaclab.sim.schemas.apply_fixed_tendon_properties`. + prim type. Can be combined with :class:`~isaaclab_physx.sim.schemas.PhysxFixedTendonCfg` in the same + fragment list passed to :func:`~isaaclab.sim.schemas.apply_fixed_tendon_properties`, which + dispatches each fragment to its own applier independently. """ _usd_namespace: ClassVar[str | None] = "mjc" diff --git a/source/isaaclab_physx/changelog.d/vidurv-schema-frag-tendons.minor.rst b/source/isaaclab_physx/changelog.d/vidurv-schema-frag-tendons.minor.rst index 34211b90615c..78e06806579c 100644 --- a/source/isaaclab_physx/changelog.d/vidurv-schema-frag-tendons.minor.rst +++ b/source/isaaclab_physx/changelog.d/vidurv-schema-frag-tendons.minor.rst @@ -16,3 +16,7 @@ Changed :class:`~isaaclab_physx.sim.schemas.PhysxSpatialTendonCfg` appliers to tune the multi-instance PhysX tendon schemas directly, removing the dependency on the legacy ``modify_*_tendon_properties`` writers and the legacy ``Physx*TendonPropertiesCfg`` reconstruction. + Callers relying on :class:`~isaaclab_physx.sim.schemas.PhysxFixedTendonPropertiesCfg` + reconstruction inside the applier should pass a + :class:`~isaaclab_physx.sim.schemas.PhysxFixedTendonCfg` fragment directly to + :func:`~isaaclab.sim.schemas.apply_fixed_tendon_properties` instead. diff --git a/source/isaaclab_physx/isaaclab_physx/sim/schemas/schemas.py b/source/isaaclab_physx/isaaclab_physx/sim/schemas/schemas.py index ca850d597eb5..af5b19308a7e 100644 --- a/source/isaaclab_physx/isaaclab_physx/sim/schemas/schemas.py +++ b/source/isaaclab_physx/isaaclab_physx/sim/schemas/schemas.py @@ -25,6 +25,8 @@ from isaaclab.sim.utils.stage import get_current_stage from isaaclab.utils.string import to_camel_case +from .schemas_cfg import PhysxFixedTendonCfg, PhysxSpatialTendonCfg + __all__ = [ "apply_fixed_tendon", "apply_spatial_tendon", @@ -49,15 +51,14 @@ def _strip_fragment_fields(cfg) -> dict: } -def apply_fixed_tendon(cfg, prim_path: str, stage: Usd.Stage | None = None) -> bool: +def apply_fixed_tendon(cfg: PhysxFixedTendonCfg, prim_path: str, stage: Usd.Stage | None = None) -> bool: """Tune the multi-instance ``PhysxTendonAxisRootAPI`` schemas on a prim. Custom ``func`` override for :class:`PhysxFixedTendonCfg`. The fixed-tendon schema is multi-instance and *tune-not-apply* (instances are authored in the source asset), so this writes each set fragment field as ``:`` across every applied ``PhysxTendonAxisRootAPI`` instance and applies no schema. Writes nothing for the ``mjc:`` - Mujoco path -- that is handled by - :func:`~isaaclab_newton.sim.schemas.apply_mujoco_fixed_tendon`. + Mujoco path — a separate ``MjcTendon``-aware Newton fragment handles that path. Args: cfg: The :class:`PhysxFixedTendonCfg` fragment to apply. @@ -86,7 +87,7 @@ def apply_fixed_tendon(cfg, prim_path: str, stage: Usd.Stage | None = None) -> b return True -def apply_spatial_tendon(cfg, prim_path: str, stage: Usd.Stage | None = None) -> bool: +def apply_spatial_tendon(cfg: PhysxSpatialTendonCfg, prim_path: str, stage: Usd.Stage | None = None) -> bool: """Tune the multi-instance ``PhysxTendonAttachment{Root,Leaf}API`` schemas on a prim. Custom ``func`` override for :class:`PhysxSpatialTendonCfg`. Writes each set fragment field From 15fbbf3fa24d1edbc0cc28bfd73c9775abf1f2d7 Mon Sep 17 00:00:00 2001 From: Vidur Vij Date: Thu, 25 Jun 2026 09:59:02 -0700 Subject: [PATCH 10/14] Descend to child prims when applying tendon fragments Tendon schemas (PhysxTendonAxisRootAPI / PhysxTendonAttachment{Root,Leaf}API, MjcTendon) are authored on descendant joint prims, not the prim_path the spawner targets. The fragment appliers inspected only the single target prim, so tendons on child joints were silently skipped (a regression from the legacy apply_nested writers). Descend the whole subtree via Usd.PrimRange and tune every prim carrying the schema. Add regression tests applying at a root with the schema on a child joint (fixed and spatial). --- .../test/sim/test_tendon_fragments.py | 28 +++++++++ .../isaaclab_newton/sim/schemas/schemas.py | 19 +++--- .../isaaclab_physx/sim/schemas/schemas.py | 60 +++++++++++-------- 3 files changed, 76 insertions(+), 31 deletions(-) diff --git a/source/isaaclab/test/sim/test_tendon_fragments.py b/source/isaaclab/test/sim/test_tendon_fragments.py index 7727d282528a..49a4374304f0 100644 --- a/source/isaaclab/test/sim/test_tendon_fragments.py +++ b/source/isaaclab/test/sim/test_tendon_fragments.py @@ -131,6 +131,34 @@ def test_apply_fixed_tendon_writes_all_instances(): assert abs(prim.GetAttribute(f"PhysxTendonAxisRootAPI:{inst}:stiffness").Get() - 9.0) < 1e-6 +def test_apply_fixed_tendon_descends_to_child_prims(): + # tendon schemas are authored on child joint prims, not the articulation root the spawner + # targets; applying at the root must descend to every descendant carrying the schema. + from isaaclab_physx.sim.schemas import PhysxFixedTendonCfg, apply_fixed_tendon + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + UsdGeom.Xform.Define(stage, "/World/Robot") # root: no tendon schema + child = _make_fixed_tendon_prim(stage, "/World/Robot/joint", instance="t0") # child joint carries it + # apply at the ROOT, not the joint + assert apply_fixed_tendon(PhysxFixedTendonCfg(stiffness=8.0), "/World/Robot", stage) is True + prefix = _tendon_attr_prefix(child, "PhysxTendonAxisRootAPI") + assert abs(child.GetAttribute(f"{prefix}:stiffness").Get() - 8.0) < 1e-6 + + +def test_apply_spatial_tendon_descends_to_child_prims(): + from isaaclab_physx.sim.schemas import PhysxSpatialTendonCfg, apply_spatial_tendon + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + UsdGeom.Xform.Define(stage, "/World/Robot2") # root: no tendon schema + child = _make_prim_with_schemas(stage, "/World/Robot2/joint", ["PhysxTendonAttachmentRootAPI:s0"]) + assert apply_spatial_tendon(PhysxSpatialTendonCfg(stiffness=5.0), "/World/Robot2", stage) is True + assert abs(child.GetAttribute("PhysxTendonAttachmentRootAPI:s0:stiffness").Get() - 5.0) < 1e-6 + + def test_physx_spatial_tendon_fragment_writes_instanced_namespace(): from isaaclab_physx.sim.schemas import PhysxSpatialTendonCfg, apply_spatial_tendon diff --git a/source/isaaclab_newton/isaaclab_newton/sim/schemas/schemas.py b/source/isaaclab_newton/isaaclab_newton/sim/schemas/schemas.py index f74d05dbbdc2..46727e039374 100644 --- a/source/isaaclab_newton/isaaclab_newton/sim/schemas/schemas.py +++ b/source/isaaclab_newton/isaaclab_newton/sim/schemas/schemas.py @@ -39,16 +39,21 @@ def apply_mujoco_fixed_tendon(cfg: MujocoFixedTendonCfg, prim_path: str, stage: """ if stage is None: stage = get_current_stage() - prim = stage.GetPrimAtPath(prim_path) - if not prim.IsValid(): + root = stage.GetPrimAtPath(prim_path) + if not root.IsValid(): raise ValueError(f"Prim path '{prim_path}' is not valid.") - if prim.GetTypeName() != "MjcTendon": - return False values = { f.name: getattr(cfg, f.name) for f in dataclasses.fields(cfg) if f.name != "func" and getattr(cfg, f.name) is not None } - for attr_name, value in values.items(): - safe_set_attribute_on_usd_prim(prim, f"mjc:{to_camel_case(attr_name, 'cC')}", value, camel_case=False) - return True + # Descend the whole subtree (matching legacy apply_nested): ``MjcTendon`` prims may sit below + # the prim_path the spawner targets. Write ``mjc:*`` on every ``MjcTendon`` descendant. + found = False + for prim in Usd.PrimRange(root): + if prim.GetTypeName() != "MjcTendon": + continue + found = True + for attr_name, value in values.items(): + safe_set_attribute_on_usd_prim(prim, f"mjc:{to_camel_case(attr_name, 'cC')}", value, camel_case=False) + return found diff --git a/source/isaaclab_physx/isaaclab_physx/sim/schemas/schemas.py b/source/isaaclab_physx/isaaclab_physx/sim/schemas/schemas.py index af5b19308a7e..9f453a100fc6 100644 --- a/source/isaaclab_physx/isaaclab_physx/sim/schemas/schemas.py +++ b/source/isaaclab_physx/isaaclab_physx/sim/schemas/schemas.py @@ -70,21 +70,28 @@ def apply_fixed_tendon(cfg: PhysxFixedTendonCfg, prim_path: str, stage: Usd.Stag """ if stage is None: stage = get_current_stage() - prim = stage.GetPrimAtPath(prim_path) - if not prim.IsValid(): + root = stage.GetPrimAtPath(prim_path) + if not root.IsValid(): raise ValueError(f"Prim path '{prim_path}' is not valid.") - applied_schemas = prim.GetAppliedSchemas() - if not any("PhysxTendonAxisRootAPI" in s for s in applied_schemas): - return False values = _strip_fragment_fields(cfg) - for schema_name in applied_schemas: - if "PhysxTendonAxisRootAPI" not in schema_name: + # Tendon schemas are authored on descendant joint prims, not the prim_path the spawner targets, + # so descend the whole subtree (matching the legacy apply_nested behavior) and tune every prim + # carrying the schema. The fragment's ``_usd_applied_schema`` is informational; the schema marker + # is matched explicitly here. + found = False + for prim in Usd.PrimRange(root): + applied_schemas = prim.GetAppliedSchemas() + if not any("PhysxTendonAxisRootAPI" in s for s in applied_schemas): continue - for attr_name, value in values.items(): - safe_set_attribute_on_usd_prim( - prim, f"{schema_name}:{to_camel_case(attr_name, 'cC')}", value, camel_case=False - ) - return True + found = True + for schema_name in applied_schemas: + if "PhysxTendonAxisRootAPI" not in schema_name: + continue + for attr_name, value in values.items(): + safe_set_attribute_on_usd_prim( + prim, f"{schema_name}:{to_camel_case(attr_name, 'cC')}", value, camel_case=False + ) + return found def apply_spatial_tendon(cfg: PhysxSpatialTendonCfg, prim_path: str, stage: Usd.Stage | None = None) -> bool: @@ -103,19 +110,24 @@ def apply_spatial_tendon(cfg: PhysxSpatialTendonCfg, prim_path: str, stage: Usd. """ if stage is None: stage = get_current_stage() - prim = stage.GetPrimAtPath(prim_path) - if not prim.IsValid(): + root = stage.GetPrimAtPath(prim_path) + if not root.IsValid(): raise ValueError(f"Prim path '{prim_path}' is not valid.") - applied_schemas = prim.GetAppliedSchemas() markers = ("PhysxTendonAttachmentRootAPI", "PhysxTendonAttachmentLeafAPI") - if not any(m in s for s in applied_schemas for m in markers): - return False values = _strip_fragment_fields(cfg) - for schema_name in applied_schemas: - if not any(m in schema_name for m in markers): + # Descend the whole subtree (matching legacy apply_nested): attachment schemas live on + # descendant joint prims. Tune every prim carrying a root or leaf attachment instance. + found = False + for prim in Usd.PrimRange(root): + applied_schemas = prim.GetAppliedSchemas() + if not any(m in s for s in applied_schemas for m in markers): continue - for attr_name, value in values.items(): - safe_set_attribute_on_usd_prim( - prim, f"{schema_name}:{to_camel_case(attr_name, 'cC')}", value, camel_case=False - ) - return True + found = True + for schema_name in applied_schemas: + if not any(m in schema_name for m in markers): + continue + for attr_name, value in values.items(): + safe_set_attribute_on_usd_prim( + prim, f"{schema_name}:{to_camel_case(attr_name, 'cC')}", value, camel_case=False + ) + return found From 72fac5fa1b6a8d5825d781d4eab7f79a550311cb Mon Sep 17 00:00:00 2001 From: Vidur Vij Date: Thu, 25 Jun 2026 11:59:11 -0700 Subject: [PATCH 11/14] Mark tendon fragments as not namespace-driven The tendon appliers match their multi-instance schemas explicitly and do not use _usd_namespace, yet MujocoFixedTendonCfg advertised _usd_namespace = 'mjc' (the PhysX fragments inherited None implicitly). Set _usd_namespace = None explicitly on all three tendon fragments with a comment, so the intent is visible and the generic apply_namespaced guard would fire if one were ever mis-routed. Also refresh the PhysX fragment docstrings that still described the old delegating applier (now direct multi-instance enumeration). --- .../isaaclab/test/sim/test_tendon_fragments.py | 3 ++- .../isaaclab_newton/sim/schemas/schemas_cfg.py | 5 ++++- .../isaaclab_physx/sim/schemas/schemas_cfg.py | 17 ++++++++++++----- 3 files changed, 18 insertions(+), 7 deletions(-) diff --git a/source/isaaclab/test/sim/test_tendon_fragments.py b/source/isaaclab/test/sim/test_tendon_fragments.py index 49a4374304f0..8c8b2b600420 100644 --- a/source/isaaclab/test/sim/test_tendon_fragments.py +++ b/source/isaaclab/test/sim/test_tendon_fragments.py @@ -322,7 +322,8 @@ def test_mujoco_fixed_tendon_metadata(): cfg = MujocoFixedTendonCfg(stiffness=2.0) assert isinstance(cfg, FixedTendonFragment) - assert type(cfg)._usd_namespace == "mjc" + # not namespace-driven: the custom applier writes mjc:* itself, so _usd_namespace stays None + assert type(cfg)._usd_namespace is None assert cfg.func == "isaaclab_newton.sim.schemas:apply_mujoco_fixed_tendon" assert not hasattr(cfg, "rest_length") and not hasattr(cfg, "limit_stiffness") 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 f54d3b03a59d..837ff807f516 100644 --- a/source/isaaclab_newton/isaaclab_newton/sim/schemas/schemas_cfg.py +++ b/source/isaaclab_newton/isaaclab_newton/sim/schemas/schemas_cfg.py @@ -336,7 +336,10 @@ class MujocoFixedTendonCfg(FixedTendonFragment): dispatches each fragment to its own applier independently. """ - _usd_namespace: ClassVar[str | None] = "mjc" + # Not namespace-driven: the custom applier gates on the ``MjcTendon`` prim type and writes the + # ``mjc:*`` attributes itself, so ``_usd_namespace`` stays ``None`` -- this also guards against + # accidentally routing the fragment through the generic ``apply_namespaced``. + _usd_namespace: ClassVar[str | None] = None _usd_applied_schema: ClassVar[str | None] = None func: Callable | str = "isaaclab_newton.sim.schemas:apply_mujoco_fixed_tendon" 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 0b3a03f214bb..283584e24b3b 100644 --- a/source/isaaclab_physx/isaaclab_physx/sim/schemas/schemas_cfg.py +++ b/source/isaaclab_physx/isaaclab_physx/sim/schemas/schemas_cfg.py @@ -984,14 +984,18 @@ class PhysxFixedTendonCfg(FixedTendonFragment): fragment: the multi-instance ``PhysxTendonAxisRootAPI:`` schemas already exist on the prim (authored in the source asset), so the fragment overrides :attr:`~isaaclab.sim.schemas.SchemaFragment.func` with :func:`apply_fixed_tendon`, which - delegates to the multi-instance writer - :func:`~isaaclab.sim.schemas.modify_fixed_tendon_properties` to tune every existing instance. + descends the prim subtree and tunes every existing ``PhysxTendonAxisRootAPI:`` instance + directly. Dispatched via :func:`~isaaclab.sim.schemas.apply_fixed_tendon_properties`. .. _PhysxTendonAxisRootAPI: https://docs.omniverse.nvidia.com/kit/docs/omni_usd_schema_physics/104.2/class_physx_schema_physx_tendon_axis_root_a_p_i.html """ + # Not namespace-driven: the custom applier matches the multi-instance schema explicitly, so + # ``_usd_namespace`` stays ``None`` -- this also guards against accidentally routing the fragment + # through the generic ``apply_namespaced`` (which would raise on a missing namespace). + _usd_namespace: ClassVar[str | None] = None # override ``func``: writer iterates multi-instance ``PhysxTendonAxisRootAPI`` schemas; ``apply_namespaced`` cannot. func: Callable | str = "isaaclab_physx.sim.schemas:apply_fixed_tendon" @@ -1028,15 +1032,18 @@ class PhysxSpatialTendonCfg(SpatialTendonFragment): ``PhysxTendonAttachmentLeafAPI:`` schemas already exist on the prim (authored in the source asset), so the fragment overrides :attr:`~isaaclab.sim.schemas.SchemaFragment.func` with :func:`apply_spatial_tendon`, which - delegates to the multi-instance writer - :func:`~isaaclab.sim.schemas.modify_spatial_tendon_properties` to tune every existing - instance. + descends the prim subtree and tunes every existing ``PhysxTendonAttachmentRootAPI:`` / + ``PhysxTendonAttachmentLeafAPI:`` instance directly. Dispatched via :func:`~isaaclab.sim.schemas.apply_spatial_tendon_properties`. .. _PhysxTendonAttachmentRootAPI: https://docs.omniverse.nvidia.com/kit/docs/omni_usd_schema_physics/104.2/class_physx_schema_physx_tendon_attachment_root_a_p_i.html """ + # Not namespace-driven: the custom applier matches the multi-instance schemas explicitly, so + # ``_usd_namespace`` stays ``None`` -- this also guards against accidentally routing the fragment + # through the generic ``apply_namespaced`` (which would raise on a missing namespace). + _usd_namespace: ClassVar[str | None] = None # override ``func``: writer iterates multi-instance ``PhysxTendonAttachment{Root,Leaf}API`` # schemas, which the generic ``apply_namespaced`` cannot. func: Callable | str = "isaaclab_physx.sim.schemas:apply_spatial_tendon" From 2dfbeb4714b1da92138a2d3b1c9299c684e09b8a Mon Sep 17 00:00:00 2001 From: Vidur Vij Date: Thu, 25 Jun 2026 12:59:33 -0700 Subject: [PATCH 12/14] Share tendon enumeration between fixed and spatial appliers apply_fixed_tendon and apply_spatial_tendon were identical except for the schema marker(s) they match. Extract _tune_multi_instance_tendon(markers) and make both thin wrappers passing their markers, removing the duplicated descend-and-tune logic. --- .../isaaclab_physx/sim/schemas/schemas.py | 79 +++++++++---------- 1 file changed, 39 insertions(+), 40 deletions(-) diff --git a/source/isaaclab_physx/isaaclab_physx/sim/schemas/schemas.py b/source/isaaclab_physx/isaaclab_physx/sim/schemas/schemas.py index 9f453a100fc6..32c37aa4361b 100644 --- a/source/isaaclab_physx/isaaclab_physx/sim/schemas/schemas.py +++ b/source/isaaclab_physx/isaaclab_physx/sim/schemas/schemas.py @@ -51,22 +51,25 @@ def _strip_fragment_fields(cfg) -> dict: } -def apply_fixed_tendon(cfg: PhysxFixedTendonCfg, prim_path: str, stage: Usd.Stage | None = None) -> bool: - """Tune the multi-instance ``PhysxTendonAxisRootAPI`` schemas on a prim. +def _tune_multi_instance_tendon(cfg, prim_path: str, stage: Usd.Stage | None, markers: tuple[str, ...]) -> bool: + """Tune every multi-instance tendon schema (matching one of ``markers``) under ``prim_path``. - Custom ``func`` override for :class:`PhysxFixedTendonCfg`. The fixed-tendon schema is - multi-instance and *tune-not-apply* (instances are authored in the source asset), so this - writes each set fragment field as ``:`` across every applied - ``PhysxTendonAxisRootAPI`` instance and applies no schema. Writes nothing for the ``mjc:`` - Mujoco path — a separate ``MjcTendon``-aware Newton fragment handles that path. + Shared backend for :func:`apply_fixed_tendon` / :func:`apply_spatial_tendon`. These schemas are + *tune-not-apply* (instances are authored in the source asset) and are typically applied on the + descendant joint prims rather than the ``prim_path`` the spawner targets, so this descends the + whole subtree (matching the legacy ``apply_nested`` traversal) and writes each set fragment field + as ``:`` on every prim carrying a matching instance. Applies no + schema. The fragment's ``_usd_namespace`` is unused (these are not flat-namespace fragments); the + schema marker is matched explicitly via ``markers``. Args: - cfg: The :class:`PhysxFixedTendonCfg` fragment to apply. - prim_path: The prim path carrying the fixed-tendon schemas. - stage: The stage where to find the prim. Defaults to the current stage. + cfg: The tendon fragment whose set fields are written. + prim_path: The prim path (or articulation root) whose subtree carries the schemas. + stage: The stage to resolve the prim on. Defaults to the current stage. + markers: Substrings identifying the applied schema(s) to tune (e.g. ``("PhysxTendonAxisRootAPI",)``). Returns: - True if at least one ``PhysxTendonAxisRootAPI`` instance was tuned, False if none is applied. + True if at least one matching instance was tuned, False if none is applied. """ if stage is None: stage = get_current_stage() @@ -74,18 +77,14 @@ def apply_fixed_tendon(cfg: PhysxFixedTendonCfg, prim_path: str, stage: Usd.Stag if not root.IsValid(): raise ValueError(f"Prim path '{prim_path}' is not valid.") values = _strip_fragment_fields(cfg) - # Tendon schemas are authored on descendant joint prims, not the prim_path the spawner targets, - # so descend the whole subtree (matching the legacy apply_nested behavior) and tune every prim - # carrying the schema. The fragment's ``_usd_applied_schema`` is informational; the schema marker - # is matched explicitly here. found = False for prim in Usd.PrimRange(root): applied_schemas = prim.GetAppliedSchemas() - if not any("PhysxTendonAxisRootAPI" in s for s in applied_schemas): + if not any(m in s for s in applied_schemas for m in markers): continue found = True for schema_name in applied_schemas: - if "PhysxTendonAxisRootAPI" not in schema_name: + if not any(m in schema_name for m in markers): continue for attr_name, value in values.items(): safe_set_attribute_on_usd_prim( @@ -94,6 +93,26 @@ def apply_fixed_tendon(cfg: PhysxFixedTendonCfg, prim_path: str, stage: Usd.Stag return found +def apply_fixed_tendon(cfg: PhysxFixedTendonCfg, prim_path: str, stage: Usd.Stage | None = None) -> bool: + """Tune the multi-instance ``PhysxTendonAxisRootAPI`` schemas on a prim. + + Custom ``func`` override for :class:`PhysxFixedTendonCfg`. The fixed-tendon schema is + multi-instance and *tune-not-apply* (instances are authored in the source asset), so this + writes each set fragment field as ``:`` across every applied + ``PhysxTendonAxisRootAPI`` instance and applies no schema. Writes nothing for the ``mjc:`` + Mujoco path — a separate ``MjcTendon``-aware Newton fragment handles that path. + + Args: + cfg: The :class:`PhysxFixedTendonCfg` fragment to apply. + prim_path: The prim path carrying the fixed-tendon schemas. + stage: The stage where to find the prim. Defaults to the current stage. + + Returns: + True if at least one ``PhysxTendonAxisRootAPI`` instance was tuned, False if none is applied. + """ + return _tune_multi_instance_tendon(cfg, prim_path, stage, ("PhysxTendonAxisRootAPI",)) + + def apply_spatial_tendon(cfg: PhysxSpatialTendonCfg, prim_path: str, stage: Usd.Stage | None = None) -> bool: """Tune the multi-instance ``PhysxTendonAttachment{Root,Leaf}API`` schemas on a prim. @@ -108,26 +127,6 @@ def apply_spatial_tendon(cfg: PhysxSpatialTendonCfg, prim_path: str, stage: Usd. Returns: True if at least one attachment instance was tuned, False if none is applied. """ - if stage is None: - stage = get_current_stage() - root = stage.GetPrimAtPath(prim_path) - if not root.IsValid(): - raise ValueError(f"Prim path '{prim_path}' is not valid.") - markers = ("PhysxTendonAttachmentRootAPI", "PhysxTendonAttachmentLeafAPI") - values = _strip_fragment_fields(cfg) - # Descend the whole subtree (matching legacy apply_nested): attachment schemas live on - # descendant joint prims. Tune every prim carrying a root or leaf attachment instance. - found = False - for prim in Usd.PrimRange(root): - applied_schemas = prim.GetAppliedSchemas() - if not any(m in s for s in applied_schemas for m in markers): - continue - found = True - for schema_name in applied_schemas: - if not any(m in schema_name for m in markers): - continue - for attr_name, value in values.items(): - safe_set_attribute_on_usd_prim( - prim, f"{schema_name}:{to_camel_case(attr_name, 'cC')}", value, camel_case=False - ) - return found + return _tune_multi_instance_tendon( + cfg, prim_path, stage, ("PhysxTendonAttachmentRootAPI", "PhysxTendonAttachmentLeafAPI") + ) From f5fad4e5344b0ebf1a610df7a505e4644565718e Mon Sep 17 00:00:00 2001 From: Vidur Vij Date: Thu, 25 Jun 2026 13:10:40 -0700 Subject: [PATCH 13/14] Add legacy-vs-fragment tendon equivalence regression test Pins that apply_fixed_tendon_properties authors the same attributes as the legacy modify_fixed_tendon_properties writer, on a synthetic root + descendant -joint structure mirroring the Shadow Hand (so it runs without asset-server access). Guards against the two code paths drifting and re-covers the descend-to-child-prims behavior. --- .../test/sim/test_tendon_fragments.py | 57 ++++++++++++++++++- 1 file changed, 56 insertions(+), 1 deletion(-) diff --git a/source/isaaclab/test/sim/test_tendon_fragments.py b/source/isaaclab/test/sim/test_tendon_fragments.py index 8c8b2b600420..7422a87418cd 100644 --- a/source/isaaclab/test/sim/test_tendon_fragments.py +++ b/source/isaaclab/test/sim/test_tendon_fragments.py @@ -14,7 +14,7 @@ import pytest -from pxr import PhysxSchema, Sdf, UsdGeom +from pxr import PhysxSchema, Sdf, Usd, UsdGeom import isaaclab.sim as sim_utils from isaaclab.sim import SimulationCfg, SimulationContext @@ -352,3 +352,58 @@ def test_apply_mujoco_fixed_tendon_returns_false_on_non_mjc_prim(): assert apply_mujoco_fixed_tendon(MujocoFixedTendonCfg(stiffness=2.0), "/World/NotMjc", stage) is False prim = stage.GetPrimAtPath("/World/NotMjc") assert not prim.HasAttribute("mjc:stiffness") + + +# ------------------------------------------------------------------------------------- +# legacy-vs-fragment equivalence (the fragment API must be a behavioral no-op swap) +# ------------------------------------------------------------------------------------- + + +def test_legacy_and_fragment_fixed_tendon_produce_identical_attrs(): + """The fragment API must author the same tendon attributes as the legacy writer. + + Verified end-to-end on the Shadow Hand (the real tendon user, + ``FixedTendonPropertiesCfg(limit_stiffness=30.0, damping=0.1)``); replicated here on a synthetic + root + descendant-joint structure so it runs deterministically without asset-server access. Also + exercises the descend-to-child-prims behavior, since the schemas live on descendants of the + applied prim path (as they do on a real articulation). + """ + from isaaclab_physx.sim.schemas import PhysxFixedTendonCfg, PhysxFixedTendonPropertiesCfg + + from isaaclab.sim.schemas import apply_fixed_tendon_properties, modify_fixed_tendon_properties + + stage = _new_sim() + + def _build(root): + # tendon schemas on descendant joints (multi-instance), mirroring the Shadow Hand layout + UsdGeom.Xform.Define(stage, root) + _make_prim_with_schemas(stage, f"{root}/J0", ["PhysxTendonAxisRootAPI:t0", "PhysxTendonAxisRootAPI:t1"]) + _make_prim_with_schemas(stage, f"{root}/nested/J1", ["PhysxTendonAxisRootAPI:t0"]) + + _build("/World/legacy") + _build("/World/fragment") + + # apply each path at the ROOT; both must descend to the child joints + modify_fixed_tendon_properties("/World/legacy", PhysxFixedTendonPropertiesCfg(limit_stiffness=30.0, damping=0.1)) + apply_fixed_tendon_properties("/World/fragment", [PhysxFixedTendonCfg(limit_stiffness=30.0, damping=0.1)]) + + def _collect(root): + attrs = {} + for prim in Usd.PrimRange(stage.GetPrimAtPath(root)): + for schema_name in prim.GetAppliedSchemas(): + if "PhysxTendonAxisRootAPI" not in schema_name: + continue + for suffix in ("limitStiffness", "damping"): + attr = prim.GetAttribute(f"{schema_name}:{suffix}") + if attr and attr.HasAuthoredValue(): + rel = prim.GetPath().pathString[len(root) :] # key relative to root so paths compare + attrs[f"{rel}|{schema_name}:{suffix}"] = attr.Get() + return attrs + + legacy = _collect("/World/legacy") + fragment = _collect("/World/fragment") + + assert legacy, "legacy writer authored no tendon attributes (test would be vacuous)" + assert legacy.keys() == fragment.keys() + for key, value in legacy.items(): + assert abs(fragment[key] - value) < 1e-6 From cf268208b645c6102c6fdb6e38585fa002ea6e5f Mon Sep 17 00:00:00 2001 From: Vidur Vij Date: Thu, 25 Jun 2026 13:34:50 -0700 Subject: [PATCH 14/14] Address tendon PR review: empty-list shim routing and types Route tendon spawner slots on type (not truthiness) so an empty fixed/ spatial tendon list takes the fragment path as a no-op instead of crashing the legacy modify_*_tendon_properties writer with a list (mirrors the mass shim fix). Annotate the apply_*_tendon_properties fragments params with Iterable[...Fragment]. Add an empty-list spawn regression test and refresh the _strip_fragment_fields docstring (no longer builds a legacy cfg). --- .../isaaclab/isaaclab/sim/schemas/schemas.py | 8 +++++-- .../sim/spawners/from_files/from_files.py | 24 ++++++++++++------- .../test/sim/test_tendon_fragments.py | 16 +++++++++++++ .../isaaclab_physx/sim/schemas/schemas.py | 2 +- 4 files changed, 39 insertions(+), 11 deletions(-) diff --git a/source/isaaclab/isaaclab/sim/schemas/schemas.py b/source/isaaclab/isaaclab/sim/schemas/schemas.py index 87e1018498b5..c2332289d87b 100644 --- a/source/isaaclab/isaaclab/sim/schemas/schemas.py +++ b/source/isaaclab/isaaclab/sim/schemas/schemas.py @@ -960,7 +960,9 @@ def modify_joint_drive_properties( """ -def apply_fixed_tendon_properties(prim_path: str, fragments, stage: Usd.Stage | None = None) -> bool: +def apply_fixed_tendon_properties( + prim_path: str, fragments: Iterable[schemas_cfg.FixedTendonFragment], stage: Usd.Stage | None = None +) -> bool: """Apply a list of fixed-tendon fragments to a prim. Fixed tendons are a *tune-not-apply* family: the applied ``PhysxTendonAxisRootAPI`` @@ -1076,7 +1078,9 @@ def modify_fixed_tendon_properties( """ -def apply_spatial_tendon_properties(prim_path: str, fragments, stage: Usd.Stage | None = None) -> bool: +def apply_spatial_tendon_properties( + prim_path: str, fragments: Iterable[schemas_cfg.SpatialTendonFragment], stage: Usd.Stage | None = None +) -> bool: """Apply a list of spatial-tendon fragments to a prim. Spatial tendons are a *tune-not-apply* family: the applied 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 7211ed9b8f49..ea1120653fd2 100644 --- a/source/isaaclab/isaaclab/sim/spawners/from_files/from_files.py +++ b/source/isaaclab/isaaclab/sim/spawners/from_files/from_files.py @@ -367,22 +367,30 @@ def _spawn_from_usd_file( 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: new fragment list -> apply_*; legacy single cfg -> modify_* + # transition shim, remove later: fragment(s) -> apply_*; legacy cfg -> modify_* + # normalize a single fragment to a list so the convenience form (and an empty list) route like a list fixed_tendon_frags = ( - cfg.fixed_tendons_props if isinstance(cfg.fixed_tendons_props, (list, tuple)) else [cfg.fixed_tendons_props] + [cfg.fixed_tendons_props] + if isinstance(cfg.fixed_tendons_props, schemas.SchemaFragment) + else cfg.fixed_tendons_props ) - if fixed_tendon_frags and all(isinstance(f, schemas.SchemaFragment) for f in fixed_tendon_frags): + if isinstance(fixed_tendon_frags, (list, tuple)) and all( + isinstance(f, schemas.SchemaFragment) for f in fixed_tendon_frags + ): schemas.apply_fixed_tendon_properties(prim_path, fixed_tendon_frags) else: schemas.modify_fixed_tendon_properties(prim_path, cfg.fixed_tendons_props) if cfg.spatial_tendons_props is not None: - # transition shim, remove later: new fragment list -> apply_*; legacy single cfg -> modify_* + # transition shim, remove later: fragment(s) -> apply_*; legacy cfg -> modify_* + # normalize a single fragment to a list so the convenience form (and an empty list) route like a list spatial_tendon_frags = ( - cfg.spatial_tendons_props - if isinstance(cfg.spatial_tendons_props, (list, tuple)) - else [cfg.spatial_tendons_props] + [cfg.spatial_tendons_props] + if isinstance(cfg.spatial_tendons_props, schemas.SchemaFragment) + else cfg.spatial_tendons_props ) - if spatial_tendon_frags and all(isinstance(f, schemas.SchemaFragment) for f in spatial_tendon_frags): + if isinstance(spatial_tendon_frags, (list, tuple)) and all( + isinstance(f, schemas.SchemaFragment) for f in spatial_tendon_frags + ): schemas.apply_spatial_tendon_properties(prim_path, spatial_tendon_frags) else: schemas.modify_spatial_tendon_properties(prim_path, cfg.spatial_tendons_props) diff --git a/source/isaaclab/test/sim/test_tendon_fragments.py b/source/isaaclab/test/sim/test_tendon_fragments.py index 7422a87418cd..d18b6c26f8d9 100644 --- a/source/isaaclab/test/sim/test_tendon_fragments.py +++ b/source/isaaclab/test/sim/test_tendon_fragments.py @@ -407,3 +407,19 @@ def _collect(root): assert legacy.keys() == fragment.keys() for key, value in legacy.items(): assert abs(fragment[key] - value) < 1e-6 + + +def test_spawn_from_file_with_empty_tendon_lists_is_noop(tmp_path): + # an empty tendon list is type-valid for the slot; the spawner shim must route it through the + # fragment path (a no-op) rather than handing [] to the legacy modify_*_tendon_properties writer. + asset = tmp_path / "mini.usda" + src = Usd.Stage.CreateNew(str(asset)) + UsdGeom.Xform.Define(src, "/Root") + src.SetDefaultPrim(src.GetPrimAtPath("/Root")) + src.GetRootLayer().Save() + del src + + _new_sim() + cfg = sim_utils.UsdFileCfg(usd_path=str(asset), fixed_tendons_props=[], spatial_tendons_props=[]) + cfg.func("/World/Asset", cfg) # must not raise + assert sim_utils.get_current_stage().GetPrimAtPath("/World/Asset").IsValid() diff --git a/source/isaaclab_physx/isaaclab_physx/sim/schemas/schemas.py b/source/isaaclab_physx/isaaclab_physx/sim/schemas/schemas.py index 32c37aa4361b..7678e9ca9d3c 100644 --- a/source/isaaclab_physx/isaaclab_physx/sim/schemas/schemas.py +++ b/source/isaaclab_physx/isaaclab_physx/sim/schemas/schemas.py @@ -42,7 +42,7 @@ def _strip_fragment_fields(cfg) -> dict: cfg: The fragment instance to read fields from. Returns: - A mapping of set field names to their values, suitable for building a legacy cfg. + A mapping of set field names to their values, ready to author as namespaced USD attributes. """ return { f.name: getattr(cfg, f.name)