diff --git a/docs/source/api/lab_physx/isaaclab_physx.sim.schemas.rst b/docs/source/api/lab_physx/isaaclab_physx.sim.schemas.rst index b11f64860d81..730bd1628772 100644 --- a/docs/source/api/lab_physx/isaaclab_physx.sim.schemas.rst +++ b/docs/source/api/lab_physx/isaaclab_physx.sim.schemas.rst @@ -43,7 +43,10 @@ isaaclab_physx.sim.schemas .. autosummary:: PhysxFixedTendonPropertiesCfg + PhysxTendonAxisRootCfg + PhysxTendonAxisCfg PhysxSpatialTendonPropertiesCfg + PhysxTendonAttachmentRootCfg .. rubric:: Deformable body @@ -130,11 +133,26 @@ Tendon :show-inheritance: :exclude-members: __init__ +.. autoclass:: PhysxTendonAxisRootCfg + :members: + :show-inheritance: + :exclude-members: __init__ + +.. autoclass:: PhysxTendonAxisCfg + :members: + :show-inheritance: + :exclude-members: __init__ + .. autoclass:: PhysxSpatialTendonPropertiesCfg :members: :show-inheritance: :exclude-members: __init__ +.. autoclass:: PhysxTendonAttachmentRootCfg + :members: + :show-inheritance: + :exclude-members: __init__ + Deformable Body --------------- @@ -177,11 +195,9 @@ The following classes are part of the public :mod:`isaaclab_physx.sim.schemas` A PhysxCollisionCfg PhysxConvexDecompositionCfg PhysxConvexHullCfg - PhysxFixedTendonCfg PhysxJointCfg PhysxRigidBodyCfg PhysxSDFMeshCfg - PhysxSpatialTendonCfg PhysxTriangleMeshCfg PhysxTriangleMeshSimplificationCfg RigidBodyPropertiesCfg @@ -223,9 +239,6 @@ The following classes are part of the public :mod:`isaaclab_physx.sim.schemas` A .. autoclass:: PhysxConvexHullCfg :show-inheritance: -.. autoclass:: PhysxFixedTendonCfg - :show-inheritance: - .. autoclass:: PhysxJointCfg :show-inheritance: @@ -235,9 +248,6 @@ The following classes are part of the public :mod:`isaaclab_physx.sim.schemas` A .. autoclass:: PhysxSDFMeshCfg :show-inheritance: -.. autoclass:: PhysxSpatialTendonCfg - :show-inheritance: - .. autoclass:: PhysxTriangleMeshCfg :show-inheritance: diff --git a/docs/source/overview/core-concepts/schema_fragments.rst b/docs/source/overview/core-concepts/schema_fragments.rst index 0d3b8a4d1a0a..6b7ba1794ded 100644 --- a/docs/source/overview/core-concepts/schema_fragments.rst +++ b/docs/source/overview/core-concepts/schema_fragments.rst @@ -60,10 +60,31 @@ imports a backend: - tendon-bearing prims (existing tendon instances) * - ``spatial_tendons_props`` - :func:`~isaaclab.sim.schemas.apply_spatial_tendon_properties` - - tendon attachment root / leaf prims + - tendon attachment root prims The tendon families are *tune-not-apply*: the tendon topology is authored in the source -asset, so their writers only tune existing instances and never create them. +asset, so their writers only tune existing instances and never create them. PhysX tendon +fragments use ``instance_names`` to select one or more instances on each matching prim; +``None`` (the default) broadcasts to all existing instances. + +A fixed tendon is split at the same boundary as the PhysX schemas: the root fragment owns +whole-tendon dynamics and limits, while the axis fragment owns each joint's contribution. +Prim-path matching chooses the joints and ``instance_names`` chooses the tendon on those +joints: + +.. code-block:: python + + from isaaclab_physx.sim.schemas import PhysxTendonAxisCfg, PhysxTendonAxisRootCfg + + fixed_tendons_props = { + "/joints/index_root": [ + PhysxTendonAxisRootCfg(instance_names="index_finger", stiffness=10.0), + PhysxTendonAxisCfg(instance_names="index_finger", gearing=[1.0], joint_axis=["rotX"]), + ], + "/joints/index_distal": [ + PhysxTendonAxisCfg(instance_names="index_finger", gearing=[-0.5], joint_axis=["rotX"]), + ], + } Targeting expressions --------------------- diff --git a/source/isaaclab/changelog.d/ooctipus-tendon-instance-addressing.minor.rst b/source/isaaclab/changelog.d/ooctipus-tendon-instance-addressing.minor.rst new file mode 100644 index 000000000000..4f64a6078564 --- /dev/null +++ b/source/isaaclab/changelog.d/ooctipus-tendon-instance-addressing.minor.rst @@ -0,0 +1,12 @@ +Changed +^^^^^^^ + +* Extended :func:`~isaaclab.sim.schemas.apply_fixed_tendon_properties` to target both + ``PhysxTendonAxisRootAPI`` and ``PhysxTendonAxisAPI`` instances, allowing backend fragments to + configure whole fixed tendons and their individual joint-axis contributions separately. + +Fixed +^^^^^ + +* Fixed the legacy fixed- and spatial-tendon writers authoring properties under the applied API + type name. They now use the schema-owned ``physxTendon::*`` namespace that PhysX reads. diff --git a/source/isaaclab/isaaclab/sim/schemas/schemas.py b/source/isaaclab/isaaclab/sim/schemas/schemas.py index 0656670faede..91a8d31635de 100644 --- a/source/isaaclab/isaaclab/sim/schemas/schemas.py +++ b/source/isaaclab/isaaclab/sim/schemas/schemas.py @@ -1629,18 +1629,49 @@ def modify_joint_drive_properties( """ -def _is_fixed_tendon_target(prim: Usd.Prim) -> bool: - """Whether a prim carries a fixed-tendon representation (PhysX multi-apply instance or MjcTendon prim).""" - if prim.GetTypeName() == "MjcTendon": - return True - return any("PhysxTendonAxisRootAPI" in s for s in prim.GetAppliedSchemas()) +_FIXED_TENDON_SCHEMAS = ("PhysxTendonAxisRootAPI", "PhysxTendonAxisAPI") +_SPATIAL_TENDON_SCHEMAS = ("PhysxTendonAttachmentRootAPI",) + +def _write_tendon_properties(prim, values, schema_type): + authored = False + for schema in prim.GetAppliedSchemas(): + applied_type, instance = Usd.SchemaRegistry.GetTypeNameAndInstance(str(schema)) + if applied_type != schema_type or not instance: + continue + authored = True + for name, value in values.items(): + attribute = f"physxTendon:{instance}:{to_camel_case(name, 'cC')}" + safe_set_attribute_on_usd_prim(prim, attribute, value, camel_case=False) + return authored -def _is_spatial_tendon_target(prim: Usd.Prim) -> bool: - """Whether a prim carries a spatial-tendon multi-apply instance.""" - return any( - "PhysxTendonAttachmentRootAPI" in s or "PhysxTendonAttachmentLeafAPI" in s for s in prim.GetAppliedSchemas() + +def _apply_tendon_fragments(prim_path_expr, fragments, schema_types, prim_types, family, stage): + fragments = list(fragments) + if stage is None: + stage = get_current_stage() + if not fragments: + return True + targets, _, any_skipped = _match_fragment_targets( + prim_path_expr, + lambda prim: prim.GetTypeName() in prim_types + or any( + Usd.SchemaRegistry.GetTypeNameAndInstance(str(schema))[0] in schema_types + for schema in prim.GetAppliedSchemas() + ), + stage, ) + if not targets: + logger.warning("No %s-tendon targets matched expression '%s'; nothing was authored.", family, prim_path_expr) + return False + success = not any_skipped + for cfg in fragments: + func = cfg.func if callable(cfg.func) else string_to_callable(cfg.func) + fragment_hit = False + for target in targets: + fragment_hit |= bool(func(cfg, target.GetPath().pathString, stage)) + success = fragment_hit and success + return success def apply_fixed_tendon_properties( @@ -1651,8 +1682,8 @@ def apply_fixed_tendon_properties( The prims to author on are matched with :func:`~isaaclab.sim.utils.find_matching_prims`: ``prim_path_expr`` is a plain regular expression over whole prim paths, so ``[^/]+`` selects one path segment and ``/World/Robot/.*`` every descendant of a prim. A matched prim is a - fixed-tendon target when it carries an applied ``PhysxTendonAxisRootAPI`` multi-apply - instance or is a ``MjcTendon`` prim. + fixed-tendon target when it carries an applied ``PhysxTendonAxisRootAPI`` or + ``PhysxTendonAxisAPI`` instance, or is a ``MjcTendon`` prim. Fixed tendons are a *tune-not-apply* family: the tendon topology is authored in the source asset, so this writer never creates instances -- it only dispatches each fragment via its @@ -1675,26 +1706,7 @@ def apply_fixed_tendon_properties( Returns: True if every fragment tuned at least one target and no instanced prim was skipped. """ - fragments = list(fragments) - if stage is None: - stage = get_current_stage() - if not fragments: - return True - targets, _, any_skipped = _match_fragment_targets(prim_path_expr, _is_fixed_tendon_target, stage) - if not targets: - logger.warning("No fixed-tendon targets matched expression '%s'; nothing was authored.", prim_path_expr) - return False - # per-fragment any-target aggregation: a fragment fails only when it tuned no target at all, - # since each backend's func legitimately no-ops on the other backend's tendon prims. - success = not any_skipped - for cfg in fragments: - func = cfg.func if callable(cfg.func) else string_to_callable(cfg.func) - fragment_hit = False - for target in targets: - if func(cfg, target.GetPath().pathString, stage): - fragment_hit = True - success = fragment_hit and success - return success + return _apply_tendon_fragments(prim_path_expr, fragments, _FIXED_TENDON_SCHEMAS, ("MjcTendon",), "fixed", stage) @apply_nested @@ -1735,36 +1747,12 @@ def modify_fixed_tendon_properties( if stage is None: stage = get_current_stage() - # get USD prim tendon_prim = stage.GetPrimAtPath(prim_path) - # check if prim has fixed tendon applied on it or if the mjc tendon prim exiss - applied_schemas = tendon_prim.GetAppliedSchemas() - prim_type = tendon_prim.GetTypeName() - if not any("PhysxTendonAxisRootAPI" in s for s in applied_schemas) and prim_type != "MjcTendon": - return False - - # resolve all available instances of the schema since it is multi-instance - cfg = cfg.to_dict() - if prim_type != "MjcTendon": - for schema_name in applied_schemas: - if "PhysxTendonAxisRootAPI" not in schema_name: - continue - # set into PhysX API by attribute prefix schema_name: (e.g. PhysxTendonAxisRootAPI:default:stiffness) - for attr_name, value in cfg.items(): - safe_set_attribute_on_usd_prim( - tendon_prim, - f"{schema_name}:{to_camel_case(attr_name, 'cC')}", - value, - 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( - tendon_prim, f"mjc:{to_camel_case(attr_name, 'cC')}", value, camel_case=False - ) - # success + values = cfg.to_dict() + if tendon_prim.GetTypeName() != "MjcTendon": + return _write_tendon_properties(tendon_prim, values, "PhysxTendonAxisRootAPI") + for name in ("stiffness", "damping"): + safe_set_attribute_on_usd_prim(tendon_prim, f"mjc:{name}", values.get(name), camel_case=False) return True @@ -1781,8 +1769,7 @@ def apply_spatial_tendon_properties( The prims to author on are matched with :func:`~isaaclab.sim.utils.find_matching_prims`: ``prim_path_expr`` is a plain regular expression over whole prim paths, so ``[^/]+`` selects one path segment and ``/World/Robot/.*`` every descendant of a prim. A matched prim is a - spatial-tendon target when it carries an applied ``PhysxTendonAttachmentRootAPI`` or - ``PhysxTendonAttachmentLeafAPI`` multi-apply instance. + spatial-tendon target when it carries an applied ``PhysxTendonAttachmentRootAPI`` instance. Spatial tendons are a *tune-not-apply* family: the tendon topology is authored in the source asset, so this writer never creates instances -- it only dispatches each fragment @@ -1805,26 +1792,7 @@ def apply_spatial_tendon_properties( Returns: True if every fragment tuned at least one target and no instanced prim was skipped. """ - fragments = list(fragments) - if stage is None: - stage = get_current_stage() - if not fragments: - return True - targets, _, any_skipped = _match_fragment_targets(prim_path_expr, _is_spatial_tendon_target, stage) - if not targets: - logger.warning("No spatial-tendon targets matched expression '%s'; nothing was authored.", prim_path_expr) - return False - # per-fragment any-target aggregation: a fragment fails only when it tuned no target at all, - # since each backend's func legitimately no-ops on the other backend's tendon prims. - success = not any_skipped - for cfg in fragments: - func = cfg.func if callable(cfg.func) else string_to_callable(cfg.func) - fragment_hit = False - for target in targets: - if func(cfg, target.GetPath().pathString, stage): - fragment_hit = True - success = fragment_hit and success - return success + return _apply_tendon_fragments(prim_path_expr, fragments, _SPATIAL_TENDON_SCHEMAS, (), "spatial", stage) @apply_nested @@ -1837,16 +1805,14 @@ def modify_spatial_tendon_properties( through length and limit constraints. For instance, it can be used to set up an equality constraint between a driven and passive revolute joints. - The schema comprises of attributes that belong to the `PhysxTendonAxisRootAPI`_ schema. + The schema comprises attributes that belong to the `PhysxTendonAttachmentRootAPI`_ schema. .. note:: This function is decorated with :func:`apply_nested` that sets the properties to all the prims (that have the schema applied on them) under the input prim path. .. _spatial tendon: https://nvidia-omniverse.github.io/PhysX/physx/5.4.1/_api_build/classPxArticulationSpatialTendon.html - .. _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 .. _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 - .. _PhysxTendonAttachmentLeafAPI: https://docs.omniverse.nvidia.com/kit/docs/omni_usd_schema_physics/104.2/class_physx_schema_physx_tendon_attachment_leaf_a_p_i.html Args: prim_path: The prim path to the tendon attachment. @@ -1866,29 +1832,8 @@ def modify_spatial_tendon_properties( # obtain stage if stage is None: stage = get_current_stage() - # get USD prim tendon_prim = stage.GetPrimAtPath(prim_path) - # check if prim has spatial tendon applied on it - applied_schemas = tendon_prim.GetAppliedSchemas() - has_spatial = any( - "PhysxTendonAttachmentRootAPI" in s or "PhysxTendonAttachmentLeafAPI" in s for s in applied_schemas - ) - if not has_spatial: - return False - - cfg = cfg.to_dict() - for schema_name in applied_schemas: - if "PhysxTendonAttachmentRootAPI" not in schema_name and "PhysxTendonAttachmentLeafAPI" not in schema_name: - continue - for attr_name, value in cfg.items(): - safe_set_attribute_on_usd_prim( - tendon_prim, - f"{schema_name}:{to_camel_case(attr_name, 'cC')}", - value, - camel_case=False, - ) - # success - return True + return _write_tendon_properties(tendon_prim, cfg.to_dict(), "PhysxTendonAttachmentRootAPI") """ diff --git a/source/isaaclab/isaaclab/sim/schemas/schemas_cfg.py b/source/isaaclab/isaaclab/sim/schemas/schemas_cfg.py index 4f91f975a67f..4957d1eccfe0 100644 --- a/source/isaaclab/isaaclab/sim/schemas/schemas_cfg.py +++ b/source/isaaclab/isaaclab/sim/schemas/schemas_cfg.py @@ -123,13 +123,12 @@ class SchemaFragment: left unchanged on the prim (partial update). .. important:: - Every dataclass field other than :attr:`func` is authored as a USD attribute - ``<_usd_namespace>:``. A fragment must not carry non-USD/bookkeeping - fields -- such state belongs on the spawner cfg or as a writer keyword argument (this is - why ``fix_root_link`` / ``ensure_drives_exist`` are not fragment fields). The generic - applier (:func:`~isaaclab.sim.schemas.apply_namespaced`) enforces the invariant: it raises - when a fragment has no ``_usd_namespace``, and unsupported (non-scalar) value types raise - when written. + For fragments using :func:`~isaaclab.sim.schemas.apply_namespaced`, every dataclass field + other than :attr:`func` is authored as ``<_usd_namespace>:``. Irregular + schemas may use a custom applier for value conversion. The PhysX tendon fragments are a + narrow exception: their custom appliers consume ``instance_names`` to address an existing + multiple-apply schema instance and never author it. Do not add bookkeeping fields or treat + multiple-apply behavior as a generic core-fragment convention. """ # -- Class metadata (not dataclass fields) -- @@ -226,10 +225,10 @@ class MeshCollisionFragment(SchemaFragment): 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 + Fixed tendons are a *tune-not-apply* family: the applied ``PhysxTendonAxisRootAPI`` and + ``PhysxTendonAxisAPI`` instances already exist on joint prims (authored in the source asset), + so the family writer (:func:`~isaaclab.sim.schemas.apply_fixed_tendon_properties`) does not + apply an anchor schema; it only tunes existing instances via each fragment's :attr:`~isaaclab.sim.schemas.SchemaFragment.func`. """ @@ -241,10 +240,9 @@ 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 + ``PhysxTendonAttachmentRootAPI`` instances 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 an anchor schema; it only tunes existing root instances via each fragment's :attr:`~isaaclab.sim.schemas.SchemaFragment.func`. """ diff --git a/source/isaaclab/test/sim/test_schemas.py b/source/isaaclab/test/sim/test_schemas.py index de9832f73c4c..cf01f7fe989c 100644 --- a/source/isaaclab/test/sim/test_schemas.py +++ b/source/isaaclab/test/sim/test_schemas.py @@ -934,13 +934,13 @@ def test_defining_articulation_properties_on_prim(setup_simulation): @pytest.mark.isaacsim_ci def test_multi_instance_schema_detection_on_tendon_joints(setup_simulation): - """Test that multi-instance PhysX tendon schemas are correctly detected via substring matching. + """Test that multi-instance PhysX tendon schema tokens are recognized with their instance suffixes. Multi-instance schemas (e.g. PhysxTendonAxisAPI, PhysxTendonAxisRootAPI) appear in GetAppliedSchemas() as 'SchemaName:instanceName' (e.g. 'PhysxTendonAxisAPI:inst0'). An exact ``in list`` check fails because 'PhysxTendonAxisAPI' != 'PhysxTendonAxisAPI:inst0'. - This test ensures the substring-based detection used by modify_joint_drive_properties - and modify_fixed_tendon_properties handles multi-instance schemas correctly. + This test ensures both the joint-drive skip predicate and the fixed-tendon writer handle + multiple-apply schema tokens correctly. We call the unwrapped functions directly (via ``__wrapped__``) to bypass the ``@apply_nested`` decorator, which traverses children and does not return the diff --git a/source/isaaclab/test/sim/test_tendon_fragments.py b/source/isaaclab/test/sim/test_tendon_fragments.py index 60032271c70d..53daf7b64799 100644 --- a/source/isaaclab/test/sim/test_tendon_fragments.py +++ b/source/isaaclab/test/sim/test_tendon_fragments.py @@ -12,12 +12,29 @@ """Rest everything follows.""" +import dataclasses + import pytest +from isaaclab_newton.sim.schemas import MujocoFixedTendonCfg, apply_mujoco_fixed_tendon +from isaaclab_physx.sim.schemas import ( + PhysxFixedTendonPropertiesCfg, + PhysxSpatialTendonPropertiesCfg, + PhysxTendonAttachmentRootCfg, + PhysxTendonAxisCfg, + PhysxTendonAxisRootCfg, +) from pxr import PhysxSchema, Sdf, Usd, UsdGeom import isaaclab.sim as sim_utils from isaaclab.sim import SimulationCfg, SimulationContext +from isaaclab.sim.schemas import ( + apply_fixed_tendon_properties, + apply_spatial_tendon_properties, + modify_fixed_tendon_properties, + modify_spatial_tendon_properties, +) +from isaaclab.utils.string import to_camel_case pytestmark = pytest.mark.integration @@ -29,9 +46,7 @@ def _new_sim(): 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) + prim = _make_xform(stage, path) token_op = Sdf.TokenListOp() token_op.explicitItems = schema_tokens prim.SetMetadata("apiSchemas", token_op) @@ -44,432 +59,213 @@ def _make_xform(stage, path="/World/Tendon"): 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() +def test_tendon_axis_root_fragment_writes_instanced_namespace(): + stage = _new_sim() 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 apply_fixed_tendon_properties( + "/World/FT", + [PhysxTendonAxisRootCfg(instance_names="t0", stiffness=3.0, damping=0.5, lower_limit=-0.2, upper_limit=0.4)], + stage, + ) + prefix = "physxTendon:t0" 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 + assert prim.GetAttribute(f"{prefix}:lowerLimit").Get() == pytest.approx(-0.2) + assert prim.GetAttribute(f"{prefix}:upperLimit").Get() == pytest.approx(0.4) -def test_apply_fixed_tendon_writer_descends_to_child_prims(): - # tendon schemas are authored on child joint prims, not the articulation root the spawner - # targets. Targeting is owned by the core writer: its subtree expression descends to - # every descendant carrying the schema, while the backend func is a per-prim tuner that - # no-ops on a prim without the schema. - from isaaclab_physx.sim.schemas import PhysxFixedTendonCfg, apply_fixed_tendon - - 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() - 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 - # the backend func is per-prim: applied at the root it tunes nothing - assert apply_fixed_tendon(PhysxFixedTendonCfg(stiffness=8.0), "/World/Robot", stage) is False - # the core writer's subtree expression descends from the root to the joint - assert apply_fixed_tendon_properties("/World/Robot(/.*)?", [PhysxFixedTendonCfg(stiffness=8.0)], 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_writer_descends_to_child_prims(): - from isaaclab_physx.sim.schemas import PhysxSpatialTendonCfg, apply_spatial_tendon - - 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() - UsdGeom.Xform.Define(stage, "/World/Robot2") # root: no tendon schema - child = _make_prim_with_schemas(stage, "/World/Robot2/joint", ["PhysxTendonAttachmentRootAPI:s0"]) - # the backend func is per-prim: applied at the root it tunes nothing - assert apply_spatial_tendon(PhysxSpatialTendonCfg(stiffness=5.0), "/World/Robot2", stage) is False - # the core writer's subtree expression descends from the root to the joint - assert apply_spatial_tendon_properties("/World/Robot2(/.*)?", [PhysxSpatialTendonCfg(stiffness=5.0)], 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() +@pytest.mark.parametrize( + ("instance_names", "selected"), + [("t0", {"t0"}), (None, {"t0", "t1", "t2"}), (["t0", "t2"], {"t0", "t2"})], +) +def test_fixed_tendon_instance_selection(instance_names, selected): + stage = _new_sim() prim = _make_prim_with_schemas( stage, - "/World/STmulti", - ["PhysxTendonAttachmentRootAPI:r0", "PhysxTendonAttachmentLeafAPI:l0"], + "/World/FTmulti", + ["PhysxTendonAxisRootAPI:t0", "PhysxTendonAxisRootAPI:t1", "PhysxTendonAxisRootAPI:t2"], ) - 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) -# ------------------------------------------------------------------------------------- - + cfg = PhysxTendonAxisRootCfg(instance_names=instance_names, stiffness=9.0) + assert apply_fixed_tendon_properties("/World/FTmulti", [cfg], stage) + for instance in ("t0", "t1", "t2"): + attr = prim.GetAttribute(f"physxTendon:{instance}:stiffness") + assert attr.HasAuthoredValue() is (instance in selected) + if instance in selected: + assert attr.Get() == pytest.approx(9.0) -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, +def test_fixed_tendon_axis_fragment_targets_root_and_child_axes(): + stage = _new_sim() + UsdGeom.Xform.Define(stage, "/World/Hand") + root = _make_fixed_tendon_prim(stage, "/World/Hand/root", instance="index_finger") + PhysxSchema.PhysxTendonAxisRootAPI.Apply(root, "shared_coupling") + child = _make_xform(stage, "/World/Hand/child") + PhysxSchema.PhysxTendonAxisAPI.Apply(child, "index_finger") + + cfg = PhysxTendonAxisCfg( + instance_names="index_finger", gearing=[-0.5], force_coefficient=[2.0], joint_axis=["rotX"] ) - 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 + assert apply_fixed_tendon_properties("/World/Hand(/.*)?", [cfg], stage) + for prim in (root, child): + prefix = "physxTendon:index_finger" + assert list(prim.GetAttribute(f"{prefix}:gearing").Get()) == pytest.approx([-0.5]) + assert list(prim.GetAttribute(f"{prefix}:forceCoefficient").Get()) == pytest.approx([2.0]) + assert list(prim.GetAttribute(f"{prefix}:jointAxis").Get()) == ["rotX"] + assert not root.GetAttribute("physxTendon:shared_coupling:gearing").HasAuthoredValue() + + +@pytest.mark.parametrize( + ("cfg_type", "schema_type"), + [ + (PhysxTendonAxisRootCfg, "PhysxTendonAxisRootAPI"), + (PhysxTendonAxisCfg, "PhysxTendonAxisAPI"), + (PhysxTendonAttachmentRootCfg, "PhysxTendonAttachmentRootAPI"), + ], +) +def test_tendon_fragment_fields_belong_to_schema(cfg_type, schema_type): + address_fields = {"func", "instance_names"} + definition = Usd.SchemaRegistry().FindAppliedAPIPrimDefinition(schema_type) + schema_properties = { + str(Usd.SchemaRegistry.GetMultipleApplyNameTemplateBaseName(str(name))) + for name in definition.GetPropertyNames() + if "__INSTANCE_NAME__" in str(name) + } + cfg_properties = { + to_camel_case(field.name, "cC") for field in dataclasses.fields(cfg_type) if field.name not in address_fields + } + assert cfg_properties <= schema_properties + + +@pytest.mark.parametrize( + ("writer", "cfg", "schema"), + [ + (apply_fixed_tendon_properties, PhysxTendonAxisRootCfg(stiffness=5.0), "PhysxTendonAxisRootAPI:t0"), + ( + apply_spatial_tendon_properties, + PhysxTendonAttachmentRootCfg(stiffness=5.0), + "PhysxTendonAttachmentRootAPI:t0", + ), + ], +) +def test_tendon_property_writers_descend_to_child_prims(writer, cfg, schema): + stage = _new_sim() + UsdGeom.Xform.Define(stage, "/World/Robot") + child = _make_prim_with_schemas(stage, "/World/Robot/joint", [schema]) + assert writer("/World/Robot(/.*)?", [cfg], stage) + assert child.GetAttribute("physxTendon:t0:stiffness").Get() == pytest.approx(5.0) - 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)], +def test_spatial_tendon_selects_root_instance_and_skips_leaves(): + stage = _new_sim() + prim = _make_prim_with_schemas( stage, + "/World/STmulti", + [ + "PhysxTendonAttachmentRootAPI:r0", + "PhysxTendonAttachmentRootAPI:r1", + "PhysxTendonAttachmentLeafAPI:l0", + ], ) - 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, + assert apply_spatial_tendon_properties( + "/World/STmulti", + [PhysxTendonAttachmentRootCfg(instance_names="r0", stiffness=4.0, limit_stiffness=0.25)], + stage, ) + assert prim.GetAttribute("physxTendon:r0:stiffness").Get() == pytest.approx(4.0) + assert prim.GetAttribute("physxTendon:r0:limitStiffness").Get() == pytest.approx(0.25) + assert not prim.GetAttribute("physxTendon:r1:stiffness").HasAuthoredValue() + assert not prim.GetAttribute("physxTendon:l0:stiffness").IsValid() -# ------------------------------------------------------------------------------------- -# core writer parity: invalid-prim guard + aggregated return -# ------------------------------------------------------------------------------------- - - -def test_apply_fixed_tendon_warns_on_unmatched_path(caplog): - from isaaclab_physx.sim.schemas import PhysxFixedTendonCfg - - from isaaclab.sim.schemas import apply_fixed_tendon_properties - - _new_sim() - stage = sim_utils.get_current_stage() - with caplog.at_level("WARNING"): - result = apply_fixed_tendon_properties("/World/DoesNotExist", [PhysxFixedTendonCfg(stiffness=1.0)], stage) - assert result is False - assert "/World/DoesNotExist" in caplog.text - - -def test_apply_spatial_tendon_warns_on_unmatched_path(caplog): - from isaaclab_physx.sim.schemas import PhysxSpatialTendonCfg - - from isaaclab.sim.schemas import apply_spatial_tendon_properties - - _new_sim() - stage = sim_utils.get_current_stage() - with caplog.at_level("WARNING"): - result = apply_spatial_tendon_properties("/World/DoesNotExist", [PhysxSpatialTendonCfg(stiffness=1.0)], stage) - assert result is False - assert "/World/DoesNotExist" in caplog.text - - -def test_apply_fixed_tendon_aggregates_fragment_results(): - from isaaclab.sim.schemas import UsdPhysicsRigidBodyCfg, apply_fixed_tendon_properties - +def test_legacy_spatial_tendon_writer_uses_root_property_namespace(): 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_properties_narrows_to_exact_path(): - from isaaclab_physx.sim.schemas import PhysxFixedTendonCfg - - from isaaclab.sim.schemas import apply_fixed_tendon_properties - - stage = _new_sim() - first = _make_fixed_tendon_prim(stage, "/World/Narrow/J0", instance="t1") - second = _make_fixed_tendon_prim(stage, "/World/Narrow/J1", instance="t1") - # the exact path of the first joint must tune only that joint - assert apply_fixed_tendon_properties("/World/Narrow/J0", [PhysxFixedTendonCfg(stiffness=50.0)], stage) is True - prefix = _tendon_attr_prefix(first, "PhysxTendonAxisRootAPI") - assert abs(first.GetAttribute(f"{prefix}:stiffness").Get() - 50.0) < 1e-6 - second_prefix = _tendon_attr_prefix(second, "PhysxTendonAxisRootAPI") - second_attr = second.GetAttribute(f"{second_prefix}:stiffness") - assert not (second_attr and second_attr.HasAuthoredValue()) - - -def test_apply_fixed_tendon_properties_bare_parent_path_does_not_descend(caplog): - from isaaclab_physx.sim.schemas import PhysxFixedTendonCfg + prim = _make_prim_with_schemas( + stage, + "/World/STlegacy", + ["PhysxTendonAttachmentRootAPI:r0", "PhysxTendonAttachmentLeafAPI:l0"], + ) + writer = modify_spatial_tendon_properties.__wrapped__ + assert writer("/World/STlegacy", PhysxSpatialTendonPropertiesCfg(stiffness=6.0), stage) + assert prim.GetAttribute("physxTendon:r0:stiffness").Get() == pytest.approx(6.0) + assert not prim.GetAttribute("physxTendon:l0:stiffness").IsValid() - from isaaclab.sim.schemas import apply_fixed_tendon_properties +def test_tendon_writer_dispatches_multiple_fragments(): stage = _new_sim() - UsdGeom.Xform.Define(stage, "/World/Parent") # plain Xform: carries no tendon schema - first = _make_fixed_tendon_prim(stage, "/World/Parent/J0", instance="t1") - second = _make_fixed_tendon_prim(stage, "/World/Parent/J1", instance="t1") - # a bare parent path (no ``(/.*)?`` suffix) matches only the parent, which is not a tendon target - with caplog.at_level("WARNING"): - result = apply_fixed_tendon_properties("/World/Parent", [PhysxFixedTendonCfg(stiffness=50.0)], stage) - assert result is False - for prim in (first, second): - prefix = _tendon_attr_prefix(prim, "PhysxTendonAxisRootAPI") - attr = prim.GetAttribute(f"{prefix}:stiffness") - assert not (attr and attr.HasAuthoredValue()) - assert "/World/Parent" in caplog.text - - -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") + prim = _make_prim_with_schemas(stage, "/World/Tendon", ["PhysxTendonAxisRootAPI:t0"]) + fragments = [PhysxTendonAxisRootCfg(stiffness=5.0), PhysxTendonAxisRootCfg(damping=0.75)] + assert apply_fixed_tendon_properties("/World/Tendon", fragments, stage) + assert prim.GetAttribute("physxTendon:t0:stiffness").Get() == pytest.approx(5.0) + assert prim.GetAttribute("physxTendon:t0:damping").Get() == pytest.approx(0.75) 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 = _new_sim() 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 +def test_legacy_physx_tendon_cfg_does_not_leak_physx_only_fields_to_mujoco(): + stage = _new_sim() + prim = stage.DefinePrim("/World/LegacyMjcTendon", "MjcTendon") + cfg = PhysxFixedTendonPropertiesCfg(stiffness=2.0, damping=0.25, lower_limit=-1.0, upper_limit=1.0) + assert modify_fixed_tendon_properties.__wrapped__(str(prim.GetPath()), cfg, stage) + assert prim.GetAttribute("mjc:stiffness").Get() == pytest.approx(2.0) + assert prim.GetAttribute("mjc:damping").Get() == pytest.approx(0.25) + assert not prim.HasAttribute("mjc:lowerLimit") + assert not prim.HasAttribute("mjc:upperLimit") - sim_utils.create_new_stage() - SimulationContext(SimulationCfg(dt=0.01)) - stage = sim_utils.get_current_stage() + +def test_apply_mujoco_fixed_tendon_returns_false_on_non_mjc_prim(): + stage = _new_sim() 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 + for root in ("/World/legacy", "/World/fragment"): 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)]) + apply_fixed_tendon_properties("/World/fragment(/.*)?", [PhysxTendonAxisRootCfg(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: + schema_type, instance = Usd.SchemaRegistry.GetTypeNameAndInstance(str(schema_name)) + if schema_type != "PhysxTendonAxisRootAPI": continue for suffix in ("limitStiffness", "damping"): - attr = prim.GetAttribute(f"{schema_name}:{suffix}") + attr_name = f"physxTendon:{instance}:{suffix}" + attr = prim.GetAttribute(attr_name) 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() + rel = prim.GetPath().pathString[len(root) :] + attrs[f"{rel}|{instance}:{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 + assert fragment == pytest.approx(legacy) def test_spawn_from_file_with_empty_tendon_lists_is_noop(tmp_path): - # a mapping entry with an empty fragment list is type-valid for the slot; the spawner must route - # it through the fragment path (a no-op) rather than the legacy modify_*_tendon_properties writer. asset = tmp_path / "mini.usda" src = Usd.Stage.CreateNew(str(asset)) UsdGeom.Xform.Define(src, "/Root") diff --git a/source/isaaclab/test/sim/test_tendon_fragments_codeless.py b/source/isaaclab/test/sim/test_tendon_fragments_codeless.py new file mode 100644 index 000000000000..be7526df629f --- /dev/null +++ b/source/isaaclab/test/sim/test_tendon_fragments_codeless.py @@ -0,0 +1,46 @@ +# 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 + +"""Kitless coverage for PhysX tendon fragments using OVPhysX's codeless USD schemas.""" + +import pytest + +ovphysx = pytest.importorskip("ovphysx", reason="ovphysx wheel not installed") + +from pxr import Plug, Usd + + +def test_tendon_fragments_use_codeless_schema_names_and_types(): + from isaaclab_physx.sim.schemas import PhysxTendonAttachmentRootCfg, PhysxTendonAxisCfg, PhysxTendonAxisRootCfg + + from isaaclab.sim.schemas import apply_fixed_tendon_properties, apply_spatial_tendon_properties + + registry = Plug.Registry() + registered_names = {plugin.name.casefold() for plugin in registry.GetAllPlugins()} + schema_paths = [ + str(path) for path in ovphysx.codeless_schema_paths() if path.parent.name.casefold() not in registered_names + ] + if schema_paths: + registry.RegisterPlugins(schema_paths) + + stage = Usd.Stage.CreateInMemory() + fixed = stage.DefinePrim("/Fixed", "Xform") + fixed.AddAppliedSchema("PhysxTendonAxisRootAPI:index") + spatial = stage.DefinePrim("/Spatial", "Xform") + spatial.AddAppliedSchema("PhysxTendonAttachmentRootAPI:cable") + + assert apply_fixed_tendon_properties( + "/Fixed", + [ + PhysxTendonAxisRootCfg(instance_names="index", stiffness=3.0), + PhysxTendonAxisCfg(instance_names="index", gearing=[-0.5], joint_axis=["rotX"]), + ], + stage, + ) + assert apply_spatial_tendon_properties("/Spatial", [PhysxTendonAttachmentRootCfg(stiffness=4.0)], stage) + + assert fixed.GetAttribute("physxTendon:index:stiffness").Get() == pytest.approx(3.0) + assert list(fixed.GetAttribute("physxTendon:index:gearing").Get()) == pytest.approx([-0.5]) + assert spatial.GetAttribute("physxTendon:cable:stiffness").Get() == pytest.approx(4.0) diff --git a/source/isaaclab_newton/changelog.d/fix-tendon-multi-apply-selection.skip b/source/isaaclab_newton/changelog.d/fix-tendon-multi-apply-selection.skip new file mode 100644 index 000000000000..e69de29bb2d1 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 3d470bf49ec7..53dd0e08fcf9 100644 --- a/source/isaaclab_newton/isaaclab_newton/sim/schemas/schemas_cfg.py +++ b/source/isaaclab_newton/isaaclab_newton/sim/schemas/schemas_cfg.py @@ -609,9 +609,10 @@ 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. 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. + prim type. Can be combined with + :class:`~isaaclab_physx.sim.schemas.PhysxTendonAxisRootCfg` 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 diff --git a/source/isaaclab_physx/changelog.d/ooctipus-tendon-instance-addressing.major.rst b/source/isaaclab_physx/changelog.d/ooctipus-tendon-instance-addressing.major.rst new file mode 100644 index 000000000000..ef610c88c7f0 --- /dev/null +++ b/source/isaaclab_physx/changelog.d/ooctipus-tendon-instance-addressing.major.rst @@ -0,0 +1,31 @@ +Added +^^^^^ + +* Added :class:`~isaaclab_physx.sim.schemas.PhysxTendonAxisCfg` for configuring the + ``PhysxTendonAxisAPI`` properties of existing fixed-tendon instances. +* Added ``lower_limit`` and ``upper_limit`` to + :class:`~isaaclab_physx.sim.schemas.PhysxTendonAxisRootCfg` and + :class:`~isaaclab_physx.sim.schemas.PhysxFixedTendonPropertiesCfg`. + +Changed +^^^^^^^ + +* Renamed ``PhysxFixedTendonCfg`` to + :class:`~isaaclab_physx.sim.schemas.PhysxTendonAxisRootCfg` and + ``PhysxSpatialTendonCfg`` to + :class:`~isaaclab_physx.sim.schemas.PhysxTendonAttachmentRootCfg` so every fragment name matches + its USD schema. No compatibility aliases are provided. +* Added ``instance_names`` to :class:`~isaaclab_physx.sim.schemas.PhysxTendonAxisRootCfg` and + :class:`~isaaclab_physx.sim.schemas.PhysxTendonAttachmentRootCfg`. Pass one name or a list to select + existing tendon instances; the default ``None`` preserves the previous broadcast behavior. +* Changed :class:`~isaaclab_physx.sim.schemas.PhysxTendonAttachmentRootCfg` to configure only + ``PhysxTendonAttachmentRootAPI`` instances. Leaf and intermediate attachment topology remains + asset-authored. + +Removed +^^^^^^^ + +* Removed the per-prim ``apply_fixed_tendon`` and ``apply_spatial_tendon`` functions from + :mod:`isaaclab_physx.sim.schemas`. Configure tendon fragments through + :func:`isaaclab.sim.schemas.apply_fixed_tendon_properties` and + :func:`isaaclab.sim.schemas.apply_spatial_tendon_properties`, respectively. diff --git a/source/isaaclab_physx/isaaclab_physx/sim/schemas/__init__.pyi b/source/isaaclab_physx/isaaclab_physx/sim/schemas/__init__.pyi index 1a17423efd32..8ca1676d7b55 100644 --- a/source/isaaclab_physx/isaaclab_physx/sim/schemas/__init__.pyi +++ b/source/isaaclab_physx/isaaclab_physx/sim/schemas/__init__.pyi @@ -5,8 +5,6 @@ __all__ = [ "apply_physx_joint", - "apply_fixed_tendon", - "apply_spatial_tendon", "define_deformable_body_properties", "modify_deformable_body_properties", "ArticulationRootPropertiesCfg", @@ -27,7 +25,6 @@ __all__ = [ "PhysxConvexHullCfg", "PhysxConvexHullPropertiesCfg", "PhysxDeformableBodyPropertiesCfg", - "PhysxFixedTendonCfg", "PhysxFixedTendonPropertiesCfg", "PhysxJointCfg", "PhysxJointDrivePropertiesCfg", @@ -35,8 +32,10 @@ __all__ = [ "PhysxRigidBodyPropertiesCfg", "PhysxSDFMeshCfg", "PhysxSDFMeshPropertiesCfg", - "PhysxSpatialTendonCfg", "PhysxSpatialTendonPropertiesCfg", + "PhysxTendonAttachmentRootCfg", + "PhysxTendonAxisCfg", + "PhysxTendonAxisRootCfg", "PhysxTriangleMeshCfg", "PhysxTriangleMeshPropertiesCfg", "PhysxTriangleMeshSimplificationCfg", @@ -48,13 +47,7 @@ __all__ = [ "TriangleMeshSimplificationPropertiesCfg", ] -from .schemas import ( - apply_physx_joint, - apply_fixed_tendon, - apply_spatial_tendon, - define_deformable_body_properties, - modify_deformable_body_properties, -) +from .schemas import apply_physx_joint, define_deformable_body_properties, modify_deformable_body_properties from .schemas_cfg import ( ArticulationRootPropertiesCfg, CollisionPropertiesCfg, @@ -74,7 +67,6 @@ from .schemas_cfg import ( PhysxConvexHullCfg, PhysxConvexHullPropertiesCfg, PhysxDeformableBodyPropertiesCfg, - PhysxFixedTendonCfg, PhysxFixedTendonPropertiesCfg, PhysxJointCfg, PhysxJointDrivePropertiesCfg, @@ -82,8 +74,10 @@ from .schemas_cfg import ( PhysxRigidBodyPropertiesCfg, PhysxSDFMeshCfg, PhysxSDFMeshPropertiesCfg, - PhysxSpatialTendonCfg, PhysxSpatialTendonPropertiesCfg, + PhysxTendonAttachmentRootCfg, + PhysxTendonAxisCfg, + PhysxTendonAxisRootCfg, PhysxTriangleMeshCfg, PhysxTriangleMeshPropertiesCfg, PhysxTriangleMeshSimplificationCfg, diff --git a/source/isaaclab_physx/isaaclab_physx/sim/schemas/schemas.py b/source/isaaclab_physx/isaaclab_physx/sim/schemas/schemas.py index b4768dea7385..df96cbc49288 100644 --- a/source/isaaclab_physx/isaaclab_physx/sim/schemas/schemas.py +++ b/source/isaaclab_physx/isaaclab_physx/sim/schemas/schemas.py @@ -6,9 +6,9 @@ """PhysX schema-fragment appliers and compatibility wrappers. The deformable schema writers are backend-aware but remain unified in -:mod:`isaaclab.sim.schemas`. This module additionally hosts the PhysX-specific fragment -applier funcs that override :attr:`~isaaclab.sim.schemas.SchemaFragment.func` for the -joint-drive and multi-instance tendon schemas, keeping the backend func out of the core package. +:mod:`isaaclab.sim.schemas`. This module additionally hosts PhysX-specific fragment +implementation used by joint-drive and multi-instance tendon configs, keeping backend behavior +out of the core package. """ from __future__ import annotations @@ -26,12 +26,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_physx_joint", - "apply_spatial_tendon", "define_deformable_body_properties", "modify_deformable_body_properties", ] @@ -75,97 +71,43 @@ def apply_physx_joint(cfg, prim_path: str, stage: Usd.Stage | None = None) -> bo return True -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 the multi-instance tendon schemas (matching one of ``markers``) on the prim at ``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). This is a strictly per-prim - tuner: the core writers (e.g. :func:`~isaaclab.sim.schemas.apply_fixed_tendon_properties`) own - targeting and hand it the exact resolved prim paths, so it never descends into descendants. It - writes each set fragment field as ``:`` across every matching - applied instance on the prim. 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 carrying the tendon schema instances. - 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 on the prim. - - Raises: - ValueError: If the prim at ``prim_path`` does not exist in the stage. - """ +def _tune_tendon_schema(cfg, prim_path: str, stage: Usd.Stage | None = None) -> bool: + """Tune selected instances of one concrete PhysX tendon API on one prim.""" + schema_type = type(cfg)._usd_applied_schema 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.") - matching_schemas = [s for s in prim.GetAppliedSchemas() if any(m in s for m in markers)] - if not matching_schemas: - return False - values = _strip_fragment_fields(cfg) - for schema_name in matching_schemas: - 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 + selected = [cfg.instance_names] if isinstance(cfg.instance_names, str) else cfg.instance_names + if selected is not None and ( + not selected or not all(isinstance(instance, str) and instance for instance in selected) + ): + raise ValueError("'instance_names' must contain at least one non-empty instance name.") + instances = [] + for applied_schema in prim.GetAppliedSchemas(): + applied_type, instance = Usd.SchemaRegistry.GetTypeNameAndInstance(str(applied_schema)) + if applied_type == schema_type and instance and (selected is None or instance in selected): + instances.append(instance) + if not instances: + return False -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") - ) + definition = Usd.SchemaRegistry().FindAppliedAPIPrimDefinition(schema_type) + if definition is None: + raise RuntimeError(f"USD schema '{schema_type}' is not registered.") + templates = { + str(Usd.SchemaRegistry.GetMultipleApplyNameTemplateBaseName(str(name))): str(name) + for name in definition.GetPropertyNames() + if "__INSTANCE_NAME__" in str(name) + } + for instance in instances: + for field in dataclasses.fields(cfg): + value = getattr(cfg, field.name) + if field.name in ("func", "instance_names") or value is None: + continue + template = templates.get(to_camel_case(field.name, "cC")) + if template is None: + raise TypeError(f"'{field.name}' is not a property of USD schema '{schema_type}'.") + attr_name = Usd.SchemaRegistry.MakeMultipleApplyNameInstance(template, instance) + if not prim.GetAttribute(attr_name).Set(value): + raise ValueError(f"Failed to set '{attr_name}' on prim '{prim_path}'.") + 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 f665da89e3bb..7f7b7b351c81 100644 --- a/source/isaaclab_physx/isaaclab_physx/sim/schemas/schemas_cfg.py +++ b/source/isaaclab_physx/isaaclab_physx/sim/schemas/schemas_cfg.py @@ -7,7 +7,7 @@ import warnings from collections.abc import Callable -from typing import ClassVar +from typing import ClassVar, Literal from isaaclab.sim.schemas.schemas_cfg import ( ArticulationRootBaseCfg, @@ -1174,7 +1174,13 @@ class PhysxFixedTendonPropertiesCfg: """ rest_length: float | None = None - """Spring rest length of the tendon.""" + """Spring rest length of the tendon [m].""" + + lower_limit: float | None = None + """Lower limit of the tendon's length [m].""" + + upper_limit: float | None = None + """Upper limit of the tendon's length [m].""" @configclass @@ -1205,8 +1211,8 @@ class PhysxSpatialTendonPropertiesCfg: Tendons are a PhysX-only feature -- Newton has no tendon system -- so this class is a pure data carrier that is consumed by the PhysX-specific writer :func:`~isaaclab.sim.schemas.modify_spatial_tendon_properties`. The writer authors - the multi-instance ``PhysxTendonAttachmentRootAPI`` / ``PhysxTendonAttachmentLeafAPI`` - schemas; this cfg class declares no metadata-driven writer plumbing of its own. + every existing ``PhysxTendonAttachmentRootAPI`` instance; this cfg class declares no + metadata-driven writer plumbing of its own. See :func:`~isaaclab.sim.schemas.modify_spatial_tendon_properties` for more information. @@ -1257,28 +1263,24 @@ def __post_init__(self): @configclass -class PhysxFixedTendonCfg(FixedTendonFragment): - """PhysX fixed-tendon attributes from `PhysxTendonAxisRootAPI`_. +class PhysxTendonAxisRootCfg(FixedTendonFragment): + """Whole-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. + This is a *tune-not-apply* fragment: the source asset owns the + ``PhysxTendonAxisRootAPI:`` topology, and this fragment selects existing instances + to tune. Use :class:`PhysxTendonAxisCfg` for the per-joint-axis properties of the same + fixed tendon. 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" + _usd_applied_schema: ClassVar[str | None] = "PhysxTendonAxisRootAPI" + func: Callable | str = "isaaclab_physx.sim.schemas.schemas:_tune_tendon_schema" + + instance_names: str | list[str] | None = None + """Existing tendon instances to tune; ``None`` selects all root instances.""" tendon_enabled: bool | None = None """Whether to enable or disable the tendon.""" @@ -1302,32 +1304,63 @@ class PhysxFixedTendonCfg(FixedTendonFragment): rest_length: float | None = None """Spring rest length of the tendon [m].""" + lower_limit: float | None = None + """Lower limit of the tendon's length [m].""" + + upper_limit: float | None = None + """Upper limit of the tendon's length [m].""" + @configclass -class PhysxSpatialTendonCfg(SpatialTendonFragment): - """PhysX spatial-tendon attributes from `PhysxTendonAttachmentRootAPI`_. +class PhysxTendonAxisCfg(FixedTendonFragment): + """Per-joint-axis attributes from `PhysxTendonAxisAPI`_. + + The source asset owns each ``PhysxTendonAxisAPI:``. This fragment selects existing + instances on the matched joint prims and tunes their contribution to a fixed tendon. A + ``PhysxTendonAxisRootAPI`` automatically includes the axis API with the same instance name, so + this fragment can target both the root joint and AxisAPI-only child joints. + + Dispatched via :func:`~isaaclab.sim.schemas.apply_fixed_tendon_properties`. + + .. _PhysxTendonAxisAPI: https://docs.omniverse.nvidia.com/kit/docs/omni_usd_schema_physics/104.2/class_physx_schema_physx_tendon_axis_a_p_i.html + """ + + _usd_applied_schema: ClassVar[str | None] = "PhysxTendonAxisAPI" + func: Callable | str = "isaaclab_physx.sim.schemas.schemas:_tune_tendon_schema" + + instance_names: str | list[str] | None = None + """Existing tendon-axis instances to tune; ``None`` selects all axis instances.""" + + gearing: list[float] | None = None + """Joint gearing per entry in :attr:`joint_axis` [unitless or m/deg, depending on joint axis].""" + + force_coefficient: list[float] | None = None + """Joint force coefficient per entry in :attr:`joint_axis` [unitless or m, depending on joint axis].""" + + joint_axis: list[Literal["transX", "transY", "transZ", "rotX", "rotY", "rotZ"]] | None = None + """Joint axes corresponding to :attr:`gearing` and :attr:`force_coefficient`.""" + + +@configclass +class PhysxTendonAttachmentRootCfg(SpatialTendonFragment): + """Whole-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. + PhysX spatial-tendon schema. This is a *tune-not-apply* fragment: the source asset owns the + ``PhysxTendonAttachmentRootAPI:`` topology, and this fragment selects existing roots + to tune. Its fields are whole-tendon dynamics; attachment and leaf properties remain + asset-authored. 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" + _usd_applied_schema: ClassVar[str | None] = "PhysxTendonAttachmentRootAPI" + func: Callable | str = "isaaclab_physx.sim.schemas.schemas:_tune_tendon_schema" + + instance_names: str | list[str] | None = None + """Existing spatial-tendon instances to tune; ``None`` selects all attachment roots.""" tendon_enabled: bool | None = None """Whether to enable or disable the tendon."""