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..c32fab0d6686 --- /dev/null +++ b/source/isaaclab/changelog.d/vidurv-schema-frag-tendons.minor.rst @@ -0,0 +1,29 @@ +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. +* :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/__init__.pyi b/source/isaaclab/isaaclab/sim/__init__.pyi index d1e36946aa24..1204269f630f 100644 --- a/source/isaaclab/isaaclab/sim/__init__.pyi +++ b/source/isaaclab/isaaclab/sim/__init__.pyi @@ -44,6 +44,7 @@ __all__ = [ "ConvexHullPropertiesCfg", "DeformableBodyPropertiesBaseCfg", "DeformableBodyPropertiesCfg", + "FixedTendonFragment", "FixedTendonPropertiesCfg", "JointDriveBaseCfg", "MassCfg", @@ -64,12 +65,15 @@ __all__ = [ "RigidBodyBaseCfg", "RigidBodyFragment", "SchemaFragment", + "SpatialTendonFragment", "UsdPhysicsCollisionCfg", "UsdPhysicsRigidBodyCfg", "apply_collision_properties", + "apply_fixed_tendon_properties", "apply_mass_properties", "apply_namespaced", "apply_rigid_body_properties", + "apply_spatial_tendon_properties", "SDFMeshPropertiesCfg", "SpatialTendonPropertiesCfg", "TriangleMeshPropertiesCfg", @@ -226,6 +230,7 @@ from .schemas import ( ConvexHullPropertiesCfg, DeformableBodyPropertiesBaseCfg, DeformableBodyPropertiesCfg, + FixedTendonFragment, FixedTendonPropertiesCfg, JointDriveBaseCfg, MassCfg, @@ -238,6 +243,7 @@ from .schemas import ( RigidBodyFragment, SchemaFragment, SDFMeshPropertiesCfg, + SpatialTendonFragment, SpatialTendonPropertiesCfg, TriangleMeshPropertiesCfg, TriangleMeshSimplificationPropertiesCfg, @@ -245,9 +251,11 @@ from .schemas import ( UsdPhysicsRigidBodyCfg, activate_contact_sensors, apply_collision_properties, + apply_fixed_tendon_properties, apply_mass_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 baea5a920f9a..ef116b499ced 100644 --- a/source/isaaclab/isaaclab/sim/schemas/__init__.pyi +++ b/source/isaaclab/isaaclab/sim/schemas/__init__.pyi @@ -9,9 +9,11 @@ __all__ = [ "USD_MESH_COLLISION_CFGS", "activate_contact_sensors", "apply_collision_properties", + "apply_fixed_tendon_properties", "apply_mass_properties", "apply_namespaced", "apply_rigid_body_properties", + "apply_spatial_tendon_properties", "define_actuator_properties", "define_articulation_root_properties", "define_collision_properties", @@ -35,6 +37,7 @@ __all__ = [ "CollisionFragment", "DeformableBodyPropertiesBaseCfg", "DeformableBodyPropertiesCfg", + "FixedTendonFragment", "JointDriveBaseCfg", "MassCfg", "MassFragment", @@ -42,6 +45,7 @@ __all__ = [ "MeshCollisionBaseCfg", "RigidBodyFragment", "SchemaFragment", + "SpatialTendonFragment", "UsdPhysicsCollisionCfg", "UsdPhysicsRigidBodyCfg", "MujocoJointDrivePropertiesCfg", @@ -62,9 +66,11 @@ from .schemas import ( USD_MESH_COLLISION_CFGS, activate_contact_sensors, apply_collision_properties, + apply_fixed_tendon_properties, apply_mass_properties, apply_namespaced, apply_rigid_body_properties, + apply_spatial_tendon_properties, define_articulation_root_properties, define_collision_properties, define_deformable_body_properties, @@ -92,6 +98,7 @@ from .schemas_cfg import ( CollisionFragment, DeformableBodyPropertiesBaseCfg, DeformableBodyPropertiesCfg, + FixedTendonFragment, JointDriveBaseCfg, MassCfg, MassFragment, @@ -100,6 +107,7 @@ from .schemas_cfg import ( RigidBodyBaseCfg, RigidBodyFragment, SchemaFragment, + SpatialTendonFragment, UsdPhysicsCollisionCfg, UsdPhysicsRigidBodyCfg, ) diff --git a/source/isaaclab/isaaclab/sim/schemas/schemas.py b/source/isaaclab/isaaclab/sim/schemas/schemas.py index abbe115541b0..1e596973b030 100644 --- a/source/isaaclab/isaaclab/sim/schemas/schemas.py +++ b/source/isaaclab/isaaclab/sim/schemas/schemas.py @@ -990,6 +990,48 @@ def modify_joint_drive_properties( """ +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`` + 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. + + 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. + stage: The stage where to find the prim. Defaults to None, in which case the current + stage is used. + + 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() + 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) + success = bool(func(cfg, prim_path, stage)) and success + return success + + @apply_nested def modify_fixed_tendon_properties( prim_path: str, cfg: schemas_cfg.PhysxFixedTendonPropertiesCfg, stage: Usd.Stage | None = None @@ -1021,6 +1063,9 @@ def modify_fixed_tendon_properties( Raises: ValueError: If the input prim path is not valid. """ + # 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() @@ -1048,6 +1093,7 @@ def modify_fixed_tendon_properties( camel_case=False, ) else: + # 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( @@ -1062,6 +1108,50 @@ def modify_fixed_tendon_properties( """ +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 + ``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. + + 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. + stage: The stage where to find the prim. Defaults to None, in which case the current + stage is used. + + 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() + 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) + success = bool(func(cfg, prim_path, stage)) and success + return success + + @apply_nested def modify_spatial_tendon_properties( prim_path: str, cfg: schemas_cfg.PhysxSpatialTendonPropertiesCfg, stage: Usd.Stage | None = None @@ -1095,6 +1185,9 @@ def modify_spatial_tendon_properties( Raises: ValueError: If the input prim path is not valid. """ + # 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/isaaclab/sim/schemas/schemas_cfg.py b/source/isaaclab/isaaclab/sim/schemas/schemas_cfg.py index 29478e843cff..7729f661bbaa 100644 --- a/source/isaaclab/isaaclab/sim/schemas/schemas_cfg.py +++ b/source/isaaclab/isaaclab/sim/schemas/schemas_cfg.py @@ -183,6 +183,35 @@ class CollisionFragment(SchemaFragment): pass +@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 UsdPhysicsCollisionCfg(CollisionFragment): """``physics:*`` collision attributes from `UsdPhysics.CollisionAPI`_. diff --git a/source/isaaclab/isaaclab/sim/spawners/from_files/from_files.py b/source/isaaclab/isaaclab/sim/spawners/from_files/from_files.py index ff51fa973750..8486234b221f 100644 --- a/source/isaaclab/isaaclab/sim/spawners/from_files/from_files.py +++ b/source/isaaclab/isaaclab/sim/spawners/from_files/from_files.py @@ -372,9 +372,33 @@ 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 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, schemas.SchemaFragment) + else cfg.fixed_tendons_props + ) + 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: - schemas.modify_spatial_tendon_properties(prim_path, cfg.spatial_tendons_props) + # 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, schemas.SchemaFragment) + else cfg.spatial_tendons_props + ) + 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) # 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..d18b6c26f8d9 --- /dev/null +++ b/source/isaaclab/test/sim/test_tendon_fragments.py @@ -0,0 +1,425 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Launch Isaac Sim Simulator first.""" + +from isaaclab.app import AppLauncher + +# launch omniverse app +simulation_app = AppLauncher(headless=True).app + +"""Rest everything follows.""" + +import pytest + +from pxr import PhysxSchema, Sdf, Usd, 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) + + +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_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_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 + + 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 + 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 + + +# ------------------------------------------------------------------------------------- +# 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, + ) + + +# ------------------------------------------------------------------------------------- +# 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 + + +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 +# ------------------------------------------------------------------------------------- + + +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) + # 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") + + +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 + assert not prim.HasAttribute("mjc:func") + + +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 + 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 + + +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_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..75ebce232ad2 --- /dev/null +++ b/source/isaaclab_newton/changelog.d/vidurv-schema-frag-tendons.minor.rst @@ -0,0 +1,7 @@ +Added +^^^^^ + +* Added :class:`~isaaclab_newton.sim.schemas.MujocoFixedTendonCfg` and its applier + :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 f7413adbb883..1a94b3d659a5 100644 --- a/source/isaaclab_newton/isaaclab_newton/sim/schemas/__init__.pyi +++ b/source/isaaclab_newton/isaaclab_newton/sim/schemas/__init__.pyi @@ -4,6 +4,8 @@ # SPDX-License-Identifier: BSD-3-Clause __all__ = [ + "apply_mujoco_fixed_tendon", + "MujocoFixedTendonCfg", "MujocoJointDrivePropertiesCfg", "MujocoRigidBodyCfg", "MujocoRigidBodyPropertiesCfg", @@ -18,7 +20,11 @@ __all__ = [ "NewtonSDFCollisionPropertiesCfg", ] +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..46727e039374 --- /dev/null +++ b/source/isaaclab_newton/isaaclab_newton/sim/schemas/schemas.py @@ -0,0 +1,59 @@ +# 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 + +from .schemas_cfg import MujocoFixedTendonCfg + +__all__ = ["apply_mujoco_fixed_tendon"] + + +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`. + 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() + root = stage.GetPrimAtPath(prim_path) + if not root.IsValid(): + raise ValueError(f"Prim path '{prim_path}' is not valid.") + 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 + } + # 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_newton/isaaclab_newton/sim/schemas/schemas_cfg.py b/source/isaaclab_newton/isaaclab_newton/sim/schemas/schemas_cfg.py index edf007790b5e..85f14b37bc63 100644 --- a/source/isaaclab_newton/isaaclab_newton/sim/schemas/schemas_cfg.py +++ b/source/isaaclab_newton/isaaclab_newton/sim/schemas/schemas_cfg.py @@ -5,6 +5,7 @@ from __future__ import annotations +from collections.abc import Callable from typing import ClassVar, Literal from isaaclab.sim.schemas.schemas_cfg import ( @@ -12,6 +13,7 @@ CollisionBaseCfg, CollisionFragment, DeformableBodyPropertiesBaseCfg, + FixedTendonFragment, JointDriveBaseCfg, MeshCollisionBaseCfg, RigidBodyBaseCfg, @@ -357,6 +359,34 @@ 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. 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. + """ + + # 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" + + 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 new file mode 100644 index 000000000000..78e06806579c --- /dev/null +++ b/source/isaaclab_physx/changelog.d/vidurv-schema-frag-tendons.minor.rst @@ -0,0 +1,22 @@ +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. + +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. + 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/__init__.pyi b/source/isaaclab_physx/isaaclab_physx/sim/schemas/__init__.pyi index 767ba36f06d7..9541067c83a4 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", @@ -22,11 +24,13 @@ __all__ = [ "PhysxConvexHullPropertiesCfg", "PhysxDeformableBodyPropertiesCfg", "PhysxDeformableCollisionPropertiesCfg", + "PhysxFixedTendonCfg", "PhysxFixedTendonPropertiesCfg", "PhysxJointDrivePropertiesCfg", "PhysxRigidBodyCfg", "PhysxRigidBodyPropertiesCfg", "PhysxSDFMeshPropertiesCfg", + "PhysxSpatialTendonCfg", "PhysxSpatialTendonPropertiesCfg", "PhysxTriangleMeshPropertiesCfg", "PhysxTriangleMeshSimplificationPropertiesCfg", @@ -38,6 +42,8 @@ __all__ = [ ] from .schemas import ( + apply_fixed_tendon, + apply_spatial_tendon, define_deformable_body_properties, modify_deformable_body_properties, ) @@ -58,11 +64,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..7678e9ca9d3c 100644 --- a/source/isaaclab_physx/isaaclab_physx/sim/schemas/schemas.py +++ b/source/isaaclab_physx/isaaclab_physx/sim/schemas/schemas.py @@ -3,12 +3,130 @@ # # 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, +) +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 + +from .schemas_cfg import PhysxFixedTendonCfg, PhysxSpatialTendonCfg + +__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, ready to author as namespaced USD attributes. + """ + 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 _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``. + + 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 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 matching 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.") + values = _strip_fragment_fields(cfg) + 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 + + +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. + + 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 carrying the spatial-tendon schemas. + stage: The stage where to find the prim. Defaults to the current stage. + + Returns: + True if at least one attachment instance was tuned, False if none is applied. + """ + return _tune_multi_instance_tendon( + cfg, prim_path, stage, ("PhysxTendonAttachmentRootAPI", "PhysxTendonAttachmentLeafAPI") + ) diff --git a/source/isaaclab_physx/isaaclab_physx/sim/schemas/schemas_cfg.py b/source/isaaclab_physx/isaaclab_physx/sim/schemas/schemas_cfg.py index c81ea84f7101..45e0fda37d0b 100644 --- a/source/isaaclab_physx/isaaclab_physx/sim/schemas/schemas_cfg.py +++ b/source/isaaclab_physx/isaaclab_physx/sim/schemas/schemas_cfg.py @@ -6,6 +6,7 @@ from __future__ import annotations import warnings +from collections.abc import Callable from typing import ClassVar from isaaclab.sim.schemas.schemas_cfg import ( @@ -13,10 +14,12 @@ CollisionBaseCfg, CollisionFragment, DeformableBodyPropertiesBaseCfg, + FixedTendonFragment, JointDriveBaseCfg, MeshCollisionBaseCfg, RigidBodyBaseCfg, RigidBodyFragment, + SpatialTendonFragment, ) from isaaclab.utils.configclass import configclass @@ -1023,3 +1026,96 @@ 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 + 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" + + 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 + 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" + + 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. + """