From 4dfb8230e39b2b9b5b2572543f2433915e468182 Mon Sep 17 00:00:00 2001 From: Octi Zhang Date: Thu, 3 Sep 2026 03:45:12 -0700 Subject: [PATCH 1/6] Make PhysX tendon fragments instance-selective --- .../lab_physx/isaaclab_physx.sim.schemas.rst | 33 ++- .../core-concepts/schema_fragments.rst | 25 +- ...tipus-tendon-instance-addressing.minor.rst | 12 + .../isaaclab/isaaclab/sim/schemas/schemas.py | 59 +++-- .../isaaclab/sim/schemas/schemas_cfg.py | 28 +- source/isaaclab/test/sim/test_schemas.py | 6 +- .../test/sim/test_tendon_fragments.py | 239 ++++++++++++++++-- .../sim/test_tendon_fragments_codeless.py | 53 ++++ ...tipus-tendon-instance-addressing.minor.rst | 19 ++ .../isaaclab_physx/sim/schemas/__init__.pyi | 4 + .../isaaclab_physx/sim/schemas/schemas.py | 147 ++++++----- .../isaaclab_physx/sim/schemas/schemas_cfg.py | 105 ++++++-- 12 files changed, 559 insertions(+), 171 deletions(-) create mode 100644 source/isaaclab/changelog.d/ooctipus-tendon-instance-addressing.minor.rst create mode 100644 source/isaaclab/test/sim/test_tendon_fragments_codeless.py create mode 100644 source/isaaclab_physx/changelog.d/ooctipus-tendon-instance-addressing.minor.rst 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..b772b85a063a 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 + PhysxFixedTendonCfg + PhysxFixedTendonAxisCfg PhysxSpatialTendonPropertiesCfg + PhysxSpatialTendonCfg .. rubric:: Deformable body @@ -57,6 +60,9 @@ isaaclab_physx.sim.schemas .. autosummary:: + apply_fixed_tendon + apply_fixed_tendon_axis + apply_spatial_tendon define_deformable_body_properties modify_deformable_body_properties @@ -130,11 +136,30 @@ Tendon :show-inheritance: :exclude-members: __init__ +.. autoclass:: PhysxFixedTendonCfg + :members: + :show-inheritance: + :exclude-members: __init__ + +.. autoclass:: PhysxFixedTendonAxisCfg + :members: + :show-inheritance: + :exclude-members: __init__ + .. autoclass:: PhysxSpatialTendonPropertiesCfg :members: :show-inheritance: :exclude-members: __init__ +.. autoclass:: PhysxSpatialTendonCfg + :members: + :show-inheritance: + :exclude-members: __init__ + +.. autofunction:: apply_fixed_tendon +.. autofunction:: apply_fixed_tendon_axis +.. autofunction:: apply_spatial_tendon + Deformable Body --------------- @@ -177,11 +202,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 +246,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 +255,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..518be5c27391 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 PhysxFixedTendonAxisCfg, PhysxFixedTendonCfg + + fixed_tendons_props = { + "/joints/index_root": [ + PhysxFixedTendonCfg(instance_names="index_finger", stiffness=10.0), + PhysxFixedTendonAxisCfg(instance_names="index_finger", gearing=[1.0], joint_axis=["rotX"]), + ], + "/joints/index_distal": [ + PhysxFixedTendonAxisCfg(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..a49b72fe44ad 100644 --- a/source/isaaclab/isaaclab/sim/schemas/schemas.py +++ b/source/isaaclab/isaaclab/sim/schemas/schemas.py @@ -1629,18 +1629,27 @@ def modify_joint_drive_properties( """ +def _applied_schema_instance(applied_schema, schema_type: str) -> str | None: + """Return an applied multiple-apply schema's instance when its type matches exactly.""" + applied_type, instance = Usd.SchemaRegistry.GetTypeNameAndInstance(str(applied_schema)) + return instance if applied_type == schema_type and instance else None + + def _is_fixed_tendon_target(prim: Usd.Prim) -> bool: - """Whether a prim carries a fixed-tendon representation (PhysX multi-apply instance or MjcTendon prim).""" + """Whether a prim carries a fixed-tendon representation.""" if prim.GetTypeName() == "MjcTendon": return True - return any("PhysxTendonAxisRootAPI" in s for s in prim.GetAppliedSchemas()) + fixed_types = {"PhysxTendonAxisRootAPI", "PhysxTendonAxisAPI"} + return any( + _applied_schema_instance(schema, schema_type) + for schema in prim.GetAppliedSchemas() + for schema_type in fixed_types + ) 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() - ) + """Whether a prim carries a spatial-tendon root instance.""" + return any(_applied_schema_instance(schema, "PhysxTendonAttachmentRootAPI") for schema in prim.GetAppliedSchemas()) def apply_fixed_tendon_properties( @@ -1651,8 +1660,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 @@ -1740,27 +1749,33 @@ def modify_fixed_tendon_properties( # 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": + if ( + not any(_applied_schema_instance(schema, "PhysxTendonAxisRootAPI") for schema 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: + instance_name = _applied_schema_instance(schema_name, "PhysxTendonAxisRootAPI") + if instance_name is None: continue - # set into PhysX API by attribute prefix schema_name: (e.g. PhysxTendonAxisRootAPI:default:stiffness) for attr_name, value in cfg.items(): + template = f"physxTendon:__INSTANCE_NAME__:{to_camel_case(attr_name, 'cC')}" + attribute = Usd.SchemaRegistry.MakeMultipleApplyNameInstance(template, instance_name) safe_set_attribute_on_usd_prim( tendon_prim, - f"{schema_name}:{to_camel_case(attr_name, 'cC')}", + attribute, 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(): + for attr_name in ("stiffness", "damping"): + value = cfg.get(attr_name) safe_set_attribute_on_usd_prim( tendon_prim, f"mjc:{to_camel_case(attr_name, 'cC')}", value, camel_case=False ) @@ -1781,8 +1796,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 @@ -1837,16 +1851,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. @@ -1870,20 +1882,21 @@ def modify_spatial_tendon_properties( 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 - ) + has_spatial = any(_applied_schema_instance(schema, "PhysxTendonAttachmentRootAPI") for schema 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: + instance_name = _applied_schema_instance(schema_name, "PhysxTendonAttachmentRootAPI") + if instance_name is None: continue for attr_name, value in cfg.items(): + template = f"physxTendon:__INSTANCE_NAME__:{to_camel_case(attr_name, 'cC')}" + attribute = Usd.SchemaRegistry.MakeMultipleApplyNameInstance(template, instance_name) safe_set_attribute_on_usd_prim( tendon_prim, - f"{schema_name}:{to_camel_case(attr_name, 'cC')}", + attribute, value, camel_case=False, ) 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..e94ef416b7f1 100644 --- a/source/isaaclab/test/sim/test_tendon_fragments.py +++ b/source/isaaclab/test/sim/test_tendon_fragments.py @@ -12,6 +12,8 @@ """Rest everything follows.""" +import dataclasses + import pytest from pxr import PhysxSchema, Sdf, Usd, UsdGeom @@ -57,16 +59,13 @@ def _make_spatial_tendon_prim(stage, path, instance="default"): 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``). - """ +def _tendon_attr_prefix(prim, schema_type): + """Return the schema-declared property prefix for one applied tendon API.""" 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()}") + applied_type, instance = Usd.SchemaRegistry.GetTypeNameAndInstance(str(schema_name)) + if applied_type == schema_type: + return f"physxTendon:{instance}" + raise AssertionError(f"no {schema_type!r} instance on {prim.GetPath()}") # ------------------------------------------------------------------------------------- @@ -79,10 +78,21 @@ def test_fixed_tendon_fragment_metadata_defaults(): from isaaclab.sim.schemas import FixedTendonFragment, SchemaFragment - cfg = PhysxFixedTendonCfg(stiffness=1.0) + cfg = PhysxFixedTendonCfg(instance_names="index_finger", 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 + assert cfg.instance_names == "index_finger" and cfg.stiffness == 1.0 and cfg.damping is None + + +def test_fixed_tendon_axis_fragment_metadata_defaults(): + from isaaclab_physx.sim.schemas import PhysxFixedTendonAxisCfg + + from isaaclab.sim.schemas import FixedTendonFragment, SchemaFragment + + cfg = PhysxFixedTendonAxisCfg(instance_names="index_finger", gearing=[-0.5]) + assert isinstance(cfg, FixedTendonFragment) and isinstance(cfg, SchemaFragment) + assert cfg.func == "isaaclab_physx.sim.schemas:apply_fixed_tendon_axis" + assert cfg.instance_names == "index_finger" and cfg.gearing == [-0.5] def test_spatial_tendon_fragment_metadata_defaults(): @@ -90,10 +100,17 @@ def test_spatial_tendon_fragment_metadata_defaults(): from isaaclab.sim.schemas import SchemaFragment, SpatialTendonFragment - cfg = PhysxSpatialTendonCfg(stiffness=2.0) + cfg = PhysxSpatialTendonCfg(instance_names="cable", 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 + assert cfg.instance_names == "cable" and cfg.stiffness == 2.0 and cfg.damping is None + + +def test_multiple_apply_mechanics_are_not_a_public_core_fragment_convention(): + import isaaclab.sim.schemas as schemas + + assert "MultiApplyFragment" not in schemas.__all__ + assert "apply_multi_apply" not in schemas.__all__ # ------------------------------------------------------------------------------------- @@ -108,11 +125,18 @@ def test_physx_fixed_tendon_fragment_writes_instanced_namespace(): 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) + apply_fixed_tendon( + PhysxFixedTendonCfg(instance_names="t0", stiffness=3.0, damping=0.5, lower_limit=-0.2, upper_limit=0.4), + "/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 prim.GetAttribute(f"{prefix}:lowerLimit").Get() == pytest.approx(-0.2) + assert prim.GetAttribute(f"{prefix}:upperLimit").Get() == pytest.approx(0.4) + assert not prim.HasAttribute("PhysxTendonAxisRootAPI:t0:stiffness") + assert not prim.HasAttribute(f"{prefix}:instanceNames") assert not prim.HasAttribute(f"{prefix}:func") @@ -121,16 +145,127 @@ def test_physx_fixed_tendon_fragment_writes_instanced_namespace(): # ------------------------------------------------------------------------------------- -def test_apply_fixed_tendon_writes_all_instances(): +def test_apply_fixed_tendon_selects_one_instance(): 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 + assert apply_fixed_tendon(PhysxFixedTendonCfg(instance_names="t0", stiffness=9.0), "/World/FTmulti", stage) is True + assert prim.GetAttribute("physxTendon:t0:stiffness").Get() == pytest.approx(9.0) + assert not prim.GetAttribute("physxTendon:t1:stiffness").HasAuthoredValue() + + +def test_apply_fixed_tendon_broadcasts_by_default(): + from isaaclab_physx.sim.schemas import PhysxFixedTendonCfg, apply_fixed_tendon + + stage = _new_sim() + prim = _make_prim_with_schemas( + stage, "/World/FTbroadcast", ["PhysxTendonAxisRootAPI:t0", "PhysxTendonAxisRootAPI:t1"] + ) + assert apply_fixed_tendon(PhysxFixedTendonCfg(stiffness=9.0), "/World/FTbroadcast", stage) for inst in ("t0", "t1"): - assert abs(prim.GetAttribute(f"PhysxTendonAxisRootAPI:{inst}:stiffness").Get() - 9.0) < 1e-6 + assert prim.GetAttribute(f"physxTendon:{inst}:stiffness").Get() == pytest.approx(9.0) + + +def test_apply_fixed_tendon_selects_a_list_of_instances(): + from isaaclab_physx.sim.schemas import PhysxFixedTendonCfg, apply_fixed_tendon + + stage = _new_sim() + prim = _make_prim_with_schemas( + stage, + "/World/FTlist", + ["PhysxTendonAxisRootAPI:t0", "PhysxTendonAxisRootAPI:t1", "PhysxTendonAxisRootAPI:t2"], + ) + cfg = PhysxFixedTendonCfg(instance_names=["t0", "t2"], damping=0.25) + assert apply_fixed_tendon(cfg, "/World/FTlist", stage) + assert prim.GetAttribute("physxTendon:t0:damping").Get() == pytest.approx(0.25) + assert not prim.GetAttribute("physxTendon:t1:damping").HasAuthoredValue() + assert prim.GetAttribute("physxTendon:t2:damping").Get() == pytest.approx(0.25) + + +@pytest.mark.parametrize("instance_names", [[], ""]) +def test_apply_fixed_tendon_rejects_empty_instance_selection(instance_names): + from isaaclab_physx.sim.schemas import PhysxFixedTendonCfg, apply_fixed_tendon + + stage = _new_sim() + _make_fixed_tendon_prim(stage, "/World/FTempty", instance="t0") + with pytest.raises(ValueError, match="instance_names"): + apply_fixed_tendon(PhysxFixedTendonCfg(instance_names=instance_names, stiffness=1.0), "/World/FTempty", stage) + + +def test_fixed_tendon_axis_fragment_targets_root_and_child_axes(): + from isaaclab_physx.sim.schemas import PhysxFixedTendonAxisCfg + + from isaaclab.sim.schemas import apply_fixed_tendon_properties + + 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 = PhysxFixedTendonAxisCfg( + instance_names="index_finger", gearing=[-0.5], force_coefficient=[2.0], joint_axis=["rotX"] + ) + 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() + + +def test_tendon_fragments_match_schema_ownership(): + from isaaclab_physx.sim.schemas import PhysxFixedTendonAxisCfg, PhysxFixedTendonCfg, PhysxSpatialTendonCfg + + from isaaclab.utils.string import to_camel_case + + address_fields = {"func", "instance_names"} + root_fields = {field.name for field in dataclasses.fields(PhysxFixedTendonCfg)} - address_fields + axis_fields = {field.name for field in dataclasses.fields(PhysxFixedTendonAxisCfg)} - address_fields + spatial_fields = {field.name for field in dataclasses.fields(PhysxSpatialTendonCfg)} - address_fields + assert root_fields == { + "tendon_enabled", + "stiffness", + "damping", + "limit_stiffness", + "offset", + "rest_length", + "lower_limit", + "upper_limit", + } + assert axis_fields == {"gearing", "force_coefficient", "joint_axis"} + assert spatial_fields == {"tendon_enabled", "stiffness", "damping", "limit_stiffness", "offset"} + assert root_fields.isdisjoint(axis_fields) + + registry = Usd.SchemaRegistry() + for cfg_type, schema_type in ( + (PhysxFixedTendonCfg, "PhysxTendonAxisRootAPI"), + (PhysxFixedTendonAxisCfg, "PhysxTendonAxisAPI"), + (PhysxSpatialTendonCfg, "PhysxTendonAttachmentRootAPI"), + ): + definition = registry.FindAppliedAPIPrimDefinition(schema_type) + assert definition is not None + 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 + + with pytest.raises(TypeError): + PhysxFixedTendonCfg(instance_names="finger", gearing=[1.0]) + with pytest.raises(TypeError): + PhysxFixedTendonAxisCfg(instance_names="finger", stiffness=1.0) def test_apply_fixed_tendon_writer_descends_to_child_prims(): @@ -169,7 +304,7 @@ def test_apply_spatial_tendon_writer_descends_to_child_prims(): 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 + assert child.GetAttribute("physxTendon:s0:stiffness").Get() == pytest.approx(5.0) def test_physx_spatial_tendon_fragment_writes_instanced_namespace(): @@ -186,7 +321,7 @@ def test_physx_spatial_tendon_fragment_writes_instanced_namespace(): assert not prim.HasAttribute(f"{prefix}:func") -def test_apply_spatial_tendon_writes_all_instances(): +def test_apply_spatial_tendon_selects_roots_and_skips_leaves(): from isaaclab_physx.sim.schemas import PhysxSpatialTendonCfg, apply_spatial_tendon sim_utils.create_new_stage() @@ -195,11 +330,34 @@ def test_apply_spatial_tendon_writes_all_instances(): prim = _make_prim_with_schemas( stage, "/World/STmulti", + [ + "PhysxTendonAttachmentRootAPI:r0", + "PhysxTendonAttachmentRootAPI:r1", + "PhysxTendonAttachmentLeafAPI:l0", + ], + ) + assert apply_spatial_tendon(PhysxSpatialTendonCfg(instance_names="r0", stiffness=4.0), "/World/STmulti", stage) + assert prim.GetAttribute("physxTendon:r0:stiffness").Get() == pytest.approx(4.0) + assert not prim.GetAttribute("physxTendon:r1:stiffness").HasAuthoredValue() + assert not prim.GetAttribute("physxTendon:l0:stiffness").IsValid() + + +def test_legacy_spatial_tendon_writer_uses_root_property_namespace(): + from isaaclab_physx.sim.schemas import PhysxSpatialTendonPropertiesCfg + + from isaaclab.sim.schemas import modify_spatial_tendon_properties + + stage = _new_sim() + prim = _make_prim_with_schemas( + stage, + "/World/STlegacy", ["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 + 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.HasAttribute("PhysxTendonAttachmentRootAPI:r0:stiffness") + assert not prim.GetAttribute("physxTendon:l0:stiffness").IsValid() # ------------------------------------------------------------------------------------- @@ -218,7 +376,10 @@ def test_apply_fixed_tendon_properties_dispatches_fragments(): prim = _make_fixed_tendon_prim(stage, "/World/FT2", instance="t0") apply_fixed_tendon_properties( "/World/FT2", - [PhysxFixedTendonCfg(stiffness=5.0), PhysxFixedTendonCfg(damping=0.75)], + [ + PhysxFixedTendonCfg(stiffness=5.0), + PhysxFixedTendonCfg(damping=0.75), + ], stage, ) prefix = _tendon_attr_prefix(prim, "PhysxTendonAxisRootAPI") @@ -237,7 +398,10 @@ def test_apply_spatial_tendon_properties_dispatches_fragments(): prim = _make_spatial_tendon_prim(stage, "/World/ST2", instance="s0") apply_spatial_tendon_properties( "/World/ST2", - [PhysxSpatialTendonCfg(stiffness=6.0), PhysxSpatialTendonCfg(offset=0.1)], + [ + PhysxSpatialTendonCfg(stiffness=6.0), + PhysxSpatialTendonCfg(offset=0.1), + ], stage, ) prefix = _tendon_attr_prefix(prim, "PhysxTendonAttachmentRootAPI") @@ -252,9 +416,11 @@ def test_apply_spatial_tendon_properties_dispatches_fragments(): def test_public_imports(): from isaaclab_physx.sim.schemas import ( # noqa: F401 + PhysxFixedTendonAxisCfg, PhysxFixedTendonCfg, PhysxSpatialTendonCfg, apply_fixed_tendon, + apply_fixed_tendon_axis, apply_spatial_tendon, ) @@ -400,6 +566,21 @@ def test_apply_mujoco_fixed_tendon_writes_mjc_namespace(): assert not prim.HasAttribute("mjc:func") +def test_legacy_physx_tendon_cfg_does_not_leak_physx_only_fields_to_mujoco(): + from isaaclab_physx.sim.schemas import PhysxFixedTendonPropertiesCfg + + from isaaclab.sim.schemas import modify_fixed_tendon_properties + + 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") + + def test_apply_mujoco_fixed_tendon_returns_false_on_non_mjc_prim(): from isaaclab_newton.sim.schemas import MujocoFixedTendonCfg, apply_mujoco_fixed_tendon @@ -449,13 +630,15 @@ 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() + attrs[f"{rel}|{instance}:{suffix}"] = attr.Get() return attrs legacy = _collect("/World/legacy") 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..261dcd86d15a --- /dev/null +++ b/source/isaaclab/test/sim/test_tendon_fragments_codeless.py @@ -0,0 +1,53 @@ +# 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 _register_codeless_physx_schemas(): + 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) + + +def test_tendon_fragments_use_codeless_schema_names_and_types(): + from isaaclab_physx.sim.schemas import ( + PhysxFixedTendonAxisCfg, + PhysxFixedTendonCfg, + PhysxSpatialTendonCfg, + apply_fixed_tendon, + apply_fixed_tendon_axis, + apply_spatial_tendon, + ) + + _register_codeless_physx_schemas() + stage = Usd.Stage.CreateInMemory() + fixed = stage.DefinePrim("/Fixed", "Xform") + fixed.AddAppliedSchema("PhysxTendonAxisRootAPI:index") + fixed.AddAppliedSchema("PhysxTendonAxisRootAPI:middle") + spatial = stage.DefinePrim("/Spatial", "Xform") + spatial.AddAppliedSchema("PhysxTendonAttachmentRootAPI:cable") + + assert apply_fixed_tendon(PhysxFixedTendonCfg(instance_names="index", stiffness=3.0), "/Fixed", stage) + assert apply_fixed_tendon_axis( + PhysxFixedTendonAxisCfg(instance_names="index", gearing=[-0.5], joint_axis=["rotX"]), "/Fixed", stage + ) + assert apply_spatial_tendon(PhysxSpatialTendonCfg(stiffness=4.0), "/Spatial", stage) + + assert fixed.GetAttribute("physxTendon:index:stiffness").Get() == pytest.approx(3.0) + assert not fixed.GetAttribute("physxTendon:middle:stiffness").HasAuthoredValue() + assert list(fixed.GetAttribute("physxTendon:index:gearing").Get()) == pytest.approx([-0.5]) + assert list(fixed.GetAttribute("physxTendon:index:jointAxis").Get()) == ["rotX"] + assert spatial.GetAttribute("physxTendon:cable:stiffness").Get() == pytest.approx(4.0) diff --git a/source/isaaclab_physx/changelog.d/ooctipus-tendon-instance-addressing.minor.rst b/source/isaaclab_physx/changelog.d/ooctipus-tendon-instance-addressing.minor.rst new file mode 100644 index 000000000000..cef3d7a1b84d --- /dev/null +++ b/source/isaaclab_physx/changelog.d/ooctipus-tendon-instance-addressing.minor.rst @@ -0,0 +1,19 @@ +Added +^^^^^ + +* Added :class:`~isaaclab_physx.sim.schemas.PhysxFixedTendonAxisCfg` and + :func:`~isaaclab_physx.sim.schemas.apply_fixed_tendon_axis` for configuring the + ``PhysxTendonAxisAPI`` properties of existing fixed-tendon instances. +* Added ``lower_limit`` and ``upper_limit`` to + :class:`~isaaclab_physx.sim.schemas.PhysxFixedTendonCfg` and + :class:`~isaaclab_physx.sim.schemas.PhysxFixedTendonPropertiesCfg`. + +Changed +^^^^^^^ + +* Added ``instance_names`` to :class:`~isaaclab_physx.sim.schemas.PhysxFixedTendonCfg` and + :class:`~isaaclab_physx.sim.schemas.PhysxSpatialTendonCfg`. 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.PhysxSpatialTendonCfg` to configure only + ``PhysxTendonAttachmentRootAPI`` instances. Leaf and intermediate attachment topology remains + asset-authored. diff --git a/source/isaaclab_physx/isaaclab_physx/sim/schemas/__init__.pyi b/source/isaaclab_physx/isaaclab_physx/sim/schemas/__init__.pyi index 1a17423efd32..5dfca43b6401 100644 --- a/source/isaaclab_physx/isaaclab_physx/sim/schemas/__init__.pyi +++ b/source/isaaclab_physx/isaaclab_physx/sim/schemas/__init__.pyi @@ -6,6 +6,7 @@ __all__ = [ "apply_physx_joint", "apply_fixed_tendon", + "apply_fixed_tendon_axis", "apply_spatial_tendon", "define_deformable_body_properties", "modify_deformable_body_properties", @@ -27,6 +28,7 @@ __all__ = [ "PhysxConvexHullCfg", "PhysxConvexHullPropertiesCfg", "PhysxDeformableBodyPropertiesCfg", + "PhysxFixedTendonAxisCfg", "PhysxFixedTendonCfg", "PhysxFixedTendonPropertiesCfg", "PhysxJointCfg", @@ -51,6 +53,7 @@ __all__ = [ from .schemas import ( apply_physx_joint, apply_fixed_tendon, + apply_fixed_tendon_axis, apply_spatial_tendon, define_deformable_body_properties, modify_deformable_body_properties, @@ -74,6 +77,7 @@ from .schemas_cfg import ( PhysxConvexHullCfg, PhysxConvexHullPropertiesCfg, PhysxDeformableBodyPropertiesCfg, + PhysxFixedTendonAxisCfg, PhysxFixedTendonCfg, PhysxFixedTendonPropertiesCfg, PhysxJointCfg, diff --git a/source/isaaclab_physx/isaaclab_physx/sim/schemas/schemas.py b/source/isaaclab_physx/isaaclab_physx/sim/schemas/schemas.py index b4768dea7385..81e86f50cfd5 100644 --- a/source/isaaclab_physx/isaaclab_physx/sim/schemas/schemas.py +++ b/source/isaaclab_physx/isaaclab_physx/sim/schemas/schemas.py @@ -26,10 +26,11 @@ from isaaclab.sim.utils.stage import get_current_stage from isaaclab.utils.string import to_camel_case -from .schemas_cfg import PhysxFixedTendonCfg, PhysxSpatialTendonCfg +from .schemas_cfg import PhysxFixedTendonAxisCfg, PhysxFixedTendonCfg, PhysxSpatialTendonCfg __all__ = [ "apply_fixed_tendon", + "apply_fixed_tendon_axis", "apply_physx_joint", "apply_spatial_tendon", "define_deformable_body_properties", @@ -75,71 +76,73 @@ 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 _selected_tendon_instances(prim: Usd.Prim, schema_type: str, selection) -> list[str]: + """Return existing instances of ``schema_type`` selected by a tendon fragment.""" + if selection is None: + requested = None + elif isinstance(selection, str): + if not selection: + raise ValueError("'instance_names' must contain at least one non-empty instance name.") + requested = {selection} + elif isinstance(selection, list): + if not selection or any(not isinstance(name, str) or not name for name in selection): + raise ValueError("'instance_names' must contain at least one non-empty instance name.") + requested = set(selection) + else: + raise TypeError("'instance_names' must be a string, list of strings, or None.") + + instances = [] + for applied_schema in prim.GetAppliedSchemas(): + applied_type, instance = Usd.SchemaRegistry.GetTypeNameAndInstance(str(applied_schema)) + if applied_type == schema_type and instance and (requested is None or instance in requested): + instances.append(instance) + return instances + + +def _tune_tendon_schema(cfg, prim_path: str, stage: Usd.Stage | None, schema_type: str) -> bool: + """Tune selected instances of one concrete PhysX tendon API on one prim.""" 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: + instances = _selected_tendon_instances(prim, schema_type, cfg.instance_names) + if not instances: 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 - ) + + 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): + if field.name in ("func", "instance_names"): + continue + value = getattr(cfg, field.name) + if value is None: + continue + property_name = to_camel_case(field.name, "cC") + template = templates.get(property_name) + 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) + attr = prim.GetAttribute(attr_name) + if not attr: + raise RuntimeError(f"USD schema '{schema_type}' did not expose attribute '{attr_name}'.") + if not attr.Set(value): + raise ValueError(f"Failed to set '{attr_name}' on prim '{prim_path}'.") return True def apply_fixed_tendon(cfg: PhysxFixedTendonCfg, prim_path: str, stage: Usd.Stage | None = None) -> bool: - """Tune the multi-instance ``PhysxTendonAxisRootAPI`` schemas on a prim. + """Tune selected ``PhysxTendonAxisRootAPI`` instances 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. + The source asset owns the fixed-tendon topology, so this function applies no schema. It writes + only whole-tendon root properties to the instances selected by :attr:`PhysxFixedTendonCfg.instance_names`. Args: cfg: The :class:`PhysxFixedTendonCfg` fragment to apply. @@ -147,16 +150,34 @@ def apply_fixed_tendon(cfg: PhysxFixedTendonCfg, prim_path: str, stage: Usd.Stag 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. + True if at least one selected root instance exists, otherwise False. + """ + return _tune_tendon_schema(cfg, prim_path, stage, "PhysxTendonAxisRootAPI") + + +def apply_fixed_tendon_axis(cfg: PhysxFixedTendonAxisCfg, prim_path: str, stage: Usd.Stage | None = None) -> bool: + """Tune selected ``PhysxTendonAxisAPI`` instances on a joint prim. + + The source asset owns the fixed-tendon topology, so this function applies no schema. A root API + includes the axis API for the same instance; consequently this writer works for both root joints + and AxisAPI-only child joints. + + Args: + cfg: Per-axis properties and the existing instances to select. + prim_path: The joint prim carrying the tendon-axis instances. + stage: The stage where to find the prim. Defaults to the current stage. + + Returns: + True if at least one selected axis instance exists, otherwise False. """ - return _tune_multi_instance_tendon(cfg, prim_path, stage, ("PhysxTendonAxisRootAPI",)) + return _tune_tendon_schema(cfg, prim_path, stage, "PhysxTendonAxisAPI") 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. + """Tune selected ``PhysxTendonAttachmentRootAPI`` instances 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. + The fragment contains whole-tendon dynamics declared by the attachment root. Attachment and + leaf properties describe topology and are not modified by this function. Args: cfg: The :class:`PhysxSpatialTendonCfg` fragment to apply. @@ -164,8 +185,6 @@ def apply_spatial_tendon(cfg: PhysxSpatialTendonCfg, prim_path: str, stage: Usd. 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. + True if at least one selected attachment-root instance exists, otherwise False. """ - return _tune_multi_instance_tendon( - cfg, prim_path, stage, ("PhysxTendonAttachmentRootAPI", "PhysxTendonAttachmentLeafAPI") - ) + return _tune_tendon_schema(cfg, prim_path, stage, "PhysxTendonAttachmentRootAPI") 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..4ff4997db53a 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. @@ -1258,28 +1264,29 @@ def __post_init__(self): @configclass class PhysxFixedTendonCfg(FixedTendonFragment): - """PhysX fixed-tendon attributes from `PhysxTendonAxisRootAPI`_. + """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:`PhysxFixedTendonAxisCfg` 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" + instance_names: str | list[str] | None = None + """Names of existing tendon instances to tune. + + A string selects one instance, a list selects those instances, and ``None`` selects every + ``PhysxTendonAxisRootAPI`` instance on each targeted prim (the default). An empty string or list + is an error. This field addresses the schema instances and is not authored as a USD property. + """ + tendon_enabled: bool | None = None """Whether to enable or disable the tendon.""" @@ -1302,33 +1309,75 @@ 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 PhysxFixedTendonAxisCfg(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_namespace: ClassVar[str | None] = None + func: Callable | str = "isaaclab_physx.sim.schemas:apply_fixed_tendon_axis" + + instance_names: str | list[str] | None = None + """Names of existing tendon-axis instances to tune. + + A string selects one instance, a list selects those instances, and ``None`` selects every + ``PhysxTendonAxisAPI`` instance on each targeted prim (the default). An empty string or list is + an error. This field addresses the schema instances and is not authored as a USD property. + """ + + 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 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. + 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" + instance_names: str | list[str] | None = None + """Names of existing spatial-tendon root instances to tune. + + A string selects one instance, a list selects those instances, and ``None`` selects every + ``PhysxTendonAttachmentRootAPI`` instance on each targeted prim (the default). An empty string + or list is an error. This field addresses the schema instances and is not authored as a USD + property. + """ + tendon_enabled: bool | None = None """Whether to enable or disable the tendon.""" From 563b10e4921d746b6fef1470fa175b0d5790a128 Mon Sep 17 00:00:00 2001 From: Octi Zhang Date: Thu, 3 Sep 2026 03:55:15 -0700 Subject: [PATCH 2/6] Preserve tendon config positional order --- .../test/sim/test_tendon_fragments.py | 26 +++++++++++++ .../isaaclab_physx/sim/schemas/schemas_cfg.py | 38 ++++++++++--------- 2 files changed, 47 insertions(+), 17 deletions(-) diff --git a/source/isaaclab/test/sim/test_tendon_fragments.py b/source/isaaclab/test/sim/test_tendon_fragments.py index e94ef416b7f1..4923e536657d 100644 --- a/source/isaaclab/test/sim/test_tendon_fragments.py +++ b/source/isaaclab/test/sim/test_tendon_fragments.py @@ -106,6 +106,32 @@ def test_spatial_tendon_fragment_metadata_defaults(): assert cfg.instance_names == "cable" and cfg.stiffness == 2.0 and cfg.damping is None +def test_instance_selector_preserves_existing_tendon_config_field_order(): + from isaaclab_physx.sim.schemas import PhysxFixedTendonCfg, PhysxSpatialTendonCfg + + fixed_fields = [field.name for field in dataclasses.fields(PhysxFixedTendonCfg)] + spatial_fields = [field.name for field in dataclasses.fields(PhysxSpatialTendonCfg)] + assert fixed_fields[:7] == [ + "func", + "tendon_enabled", + "stiffness", + "damping", + "limit_stiffness", + "offset", + "rest_length", + ] + assert fixed_fields[-1] == "instance_names" + assert spatial_fields == [ + "func", + "tendon_enabled", + "stiffness", + "damping", + "limit_stiffness", + "offset", + "instance_names", + ] + + def test_multiple_apply_mechanics_are_not_a_public_core_fragment_convention(): import isaaclab.sim.schemas as schemas 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 4ff4997db53a..05eb9fee4908 100644 --- a/source/isaaclab_physx/isaaclab_physx/sim/schemas/schemas_cfg.py +++ b/source/isaaclab_physx/isaaclab_physx/sim/schemas/schemas_cfg.py @@ -1279,14 +1279,6 @@ class PhysxFixedTendonCfg(FixedTendonFragment): _usd_namespace: ClassVar[str | None] = None func: Callable | str = "isaaclab_physx.sim.schemas:apply_fixed_tendon" - instance_names: str | list[str] | None = None - """Names of existing tendon instances to tune. - - A string selects one instance, a list selects those instances, and ``None`` selects every - ``PhysxTendonAxisRootAPI`` instance on each targeted prim (the default). An empty string or list - is an error. This field addresses the schema instances and is not authored as a USD property. - """ - tendon_enabled: bool | None = None """Whether to enable or disable the tendon.""" @@ -1315,6 +1307,16 @@ class PhysxFixedTendonCfg(FixedTendonFragment): upper_limit: float | None = None """Upper limit of the tendon's length [m].""" + instance_names: str | list[str] | None = None + """Names of existing tendon instances to tune. + + A string selects one instance, a list selects those instances, and ``None`` selects every + ``PhysxTendonAxisRootAPI`` instance on each targeted prim (the default). An empty string or list + is an error. This field addresses the schema instances and is not authored as a USD property. + + This selector follows the pre-existing property fields to preserve their positional argument order. + """ + @configclass class PhysxFixedTendonAxisCfg(FixedTendonFragment): @@ -1369,15 +1371,6 @@ class PhysxSpatialTendonCfg(SpatialTendonFragment): _usd_namespace: ClassVar[str | None] = None func: Callable | str = "isaaclab_physx.sim.schemas:apply_spatial_tendon" - instance_names: str | list[str] | None = None - """Names of existing spatial-tendon root instances to tune. - - A string selects one instance, a list selects those instances, and ``None`` selects every - ``PhysxTendonAttachmentRootAPI`` instance on each targeted prim (the default). An empty string - or list is an error. This field addresses the schema instances and is not authored as a USD - property. - """ - tendon_enabled: bool | None = None """Whether to enable or disable the tendon.""" @@ -1396,3 +1389,14 @@ class PhysxSpatialTendonCfg(SpatialTendonFragment): 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. """ + + instance_names: str | list[str] | None = None + """Names of existing spatial-tendon root instances to tune. + + A string selects one instance, a list selects those instances, and ``None`` selects every + ``PhysxTendonAttachmentRootAPI`` instance on each targeted prim (the default). An empty string + or list is an error. This field addresses the schema instances and is not authored as a USD + property. + + This selector follows the pre-existing property fields to preserve their positional argument order. + """ From da01d7938d3896defb94bc5698906e9358a77721 Mon Sep 17 00:00:00 2001 From: Octi Zhang Date: Thu, 3 Sep 2026 04:16:13 -0700 Subject: [PATCH 3/6] Remove per-prim tendon appliers --- .../lab_physx/isaaclab_physx.sim.schemas.rst | 7 -- .../test/sim/test_tendon_fragments.py | 89 +++++++++++-------- .../sim/test_tendon_fragments_codeless.py | 17 ++-- ...ipus-tendon-instance-addressing.major.rst} | 11 ++- .../isaaclab_physx/sim/schemas/__init__.pyi | 12 +-- .../isaaclab_physx/sim/schemas/schemas.py | 68 ++------------ .../isaaclab_physx/sim/schemas/schemas_cfg.py | 9 +- 7 files changed, 84 insertions(+), 129 deletions(-) rename source/isaaclab_physx/changelog.d/{ooctipus-tendon-instance-addressing.minor.rst => ooctipus-tendon-instance-addressing.major.rst} (70%) 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 b772b85a063a..4968ef482a59 100644 --- a/docs/source/api/lab_physx/isaaclab_physx.sim.schemas.rst +++ b/docs/source/api/lab_physx/isaaclab_physx.sim.schemas.rst @@ -60,9 +60,6 @@ isaaclab_physx.sim.schemas .. autosummary:: - apply_fixed_tendon - apply_fixed_tendon_axis - apply_spatial_tendon define_deformable_body_properties modify_deformable_body_properties @@ -156,10 +153,6 @@ Tendon :show-inheritance: :exclude-members: __init__ -.. autofunction:: apply_fixed_tendon -.. autofunction:: apply_fixed_tendon_axis -.. autofunction:: apply_spatial_tendon - Deformable Body --------------- diff --git a/source/isaaclab/test/sim/test_tendon_fragments.py b/source/isaaclab/test/sim/test_tendon_fragments.py index 4923e536657d..1d802cbe1ec7 100644 --- a/source/isaaclab/test/sim/test_tendon_fragments.py +++ b/source/isaaclab/test/sim/test_tendon_fragments.py @@ -80,7 +80,8 @@ def test_fixed_tendon_fragment_metadata_defaults(): cfg = PhysxFixedTendonCfg(instance_names="index_finger", stiffness=1.0) assert isinstance(cfg, FixedTendonFragment) and isinstance(cfg, SchemaFragment) - assert cfg.func == "isaaclab_physx.sim.schemas:apply_fixed_tendon" + assert cfg.func == "isaaclab_physx.sim.schemas.schemas:_tune_tendon_schema" + assert type(cfg)._usd_applied_schema == "PhysxTendonAxisRootAPI" assert cfg.instance_names == "index_finger" and cfg.stiffness == 1.0 and cfg.damping is None @@ -91,7 +92,8 @@ def test_fixed_tendon_axis_fragment_metadata_defaults(): cfg = PhysxFixedTendonAxisCfg(instance_names="index_finger", gearing=[-0.5]) assert isinstance(cfg, FixedTendonFragment) and isinstance(cfg, SchemaFragment) - assert cfg.func == "isaaclab_physx.sim.schemas:apply_fixed_tendon_axis" + assert cfg.func == "isaaclab_physx.sim.schemas.schemas:_tune_tendon_schema" + assert type(cfg)._usd_applied_schema == "PhysxTendonAxisAPI" assert cfg.instance_names == "index_finger" and cfg.gearing == [-0.5] @@ -102,7 +104,8 @@ def test_spatial_tendon_fragment_metadata_defaults(): cfg = PhysxSpatialTendonCfg(instance_names="cable", stiffness=2.0) assert isinstance(cfg, SpatialTendonFragment) and isinstance(cfg, SchemaFragment) - assert cfg.func == "isaaclab_physx.sim.schemas:apply_spatial_tendon" + assert cfg.func == "isaaclab_physx.sim.schemas.schemas:_tune_tendon_schema" + assert type(cfg)._usd_applied_schema == "PhysxTendonAttachmentRootAPI" assert cfg.instance_names == "cable" and cfg.stiffness == 2.0 and cfg.damping is None @@ -133,10 +136,14 @@ def test_instance_selector_preserves_existing_tendon_config_field_order(): def test_multiple_apply_mechanics_are_not_a_public_core_fragment_convention(): + import isaaclab_physx.sim.schemas as physx_schemas + import isaaclab.sim.schemas as schemas assert "MultiApplyFragment" not in schemas.__all__ assert "apply_multi_apply" not in schemas.__all__ + for name in ("apply_fixed_tendon", "apply_fixed_tendon_axis", "apply_spatial_tendon"): + assert name not in physx_schemas.__all__ # ------------------------------------------------------------------------------------- @@ -145,15 +152,17 @@ def test_multiple_apply_mechanics_are_not_a_public_core_fragment_convention(): def test_physx_fixed_tendon_fragment_writes_instanced_namespace(): - from isaaclab_physx.sim.schemas import PhysxFixedTendonCfg, apply_fixed_tendon + 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/FT", instance="t0") - apply_fixed_tendon( - PhysxFixedTendonCfg(instance_names="t0", stiffness=3.0, damping=0.5, lower_limit=-0.2, upper_limit=0.4), + assert apply_fixed_tendon_properties( "/World/FT", + [PhysxFixedTendonCfg(instance_names="t0", stiffness=3.0, damping=0.5, lower_limit=-0.2, upper_limit=0.4)], stage, ) prefix = _tendon_attr_prefix(prim, "PhysxTendonAxisRootAPI") @@ -172,31 +181,39 @@ def test_physx_fixed_tendon_fragment_writes_instanced_namespace(): def test_apply_fixed_tendon_selects_one_instance(): - from isaaclab_physx.sim.schemas import PhysxFixedTendonCfg, apply_fixed_tendon + 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_prim_with_schemas(stage, "/World/FTmulti", ["PhysxTendonAxisRootAPI:t0", "PhysxTendonAxisRootAPI:t1"]) - assert apply_fixed_tendon(PhysxFixedTendonCfg(instance_names="t0", stiffness=9.0), "/World/FTmulti", stage) is True + assert apply_fixed_tendon_properties( + "/World/FTmulti", [PhysxFixedTendonCfg(instance_names="t0", stiffness=9.0)], stage + ) assert prim.GetAttribute("physxTendon:t0:stiffness").Get() == pytest.approx(9.0) assert not prim.GetAttribute("physxTendon:t1:stiffness").HasAuthoredValue() def test_apply_fixed_tendon_broadcasts_by_default(): - from isaaclab_physx.sim.schemas import PhysxFixedTendonCfg, apply_fixed_tendon + from isaaclab_physx.sim.schemas import PhysxFixedTendonCfg + + from isaaclab.sim.schemas import apply_fixed_tendon_properties stage = _new_sim() prim = _make_prim_with_schemas( stage, "/World/FTbroadcast", ["PhysxTendonAxisRootAPI:t0", "PhysxTendonAxisRootAPI:t1"] ) - assert apply_fixed_tendon(PhysxFixedTendonCfg(stiffness=9.0), "/World/FTbroadcast", stage) + assert apply_fixed_tendon_properties("/World/FTbroadcast", [PhysxFixedTendonCfg(stiffness=9.0)], stage) for inst in ("t0", "t1"): assert prim.GetAttribute(f"physxTendon:{inst}:stiffness").Get() == pytest.approx(9.0) def test_apply_fixed_tendon_selects_a_list_of_instances(): - from isaaclab_physx.sim.schemas import PhysxFixedTendonCfg, apply_fixed_tendon + from isaaclab_physx.sim.schemas import PhysxFixedTendonCfg + + from isaaclab.sim.schemas import apply_fixed_tendon_properties stage = _new_sim() prim = _make_prim_with_schemas( @@ -205,7 +222,7 @@ def test_apply_fixed_tendon_selects_a_list_of_instances(): ["PhysxTendonAxisRootAPI:t0", "PhysxTendonAxisRootAPI:t1", "PhysxTendonAxisRootAPI:t2"], ) cfg = PhysxFixedTendonCfg(instance_names=["t0", "t2"], damping=0.25) - assert apply_fixed_tendon(cfg, "/World/FTlist", stage) + assert apply_fixed_tendon_properties("/World/FTlist", [cfg], stage) assert prim.GetAttribute("physxTendon:t0:damping").Get() == pytest.approx(0.25) assert not prim.GetAttribute("physxTendon:t1:damping").HasAuthoredValue() assert prim.GetAttribute("physxTendon:t2:damping").Get() == pytest.approx(0.25) @@ -213,12 +230,16 @@ def test_apply_fixed_tendon_selects_a_list_of_instances(): @pytest.mark.parametrize("instance_names", [[], ""]) def test_apply_fixed_tendon_rejects_empty_instance_selection(instance_names): - from isaaclab_physx.sim.schemas import PhysxFixedTendonCfg, apply_fixed_tendon + from isaaclab_physx.sim.schemas import PhysxFixedTendonCfg + + from isaaclab.sim.schemas import apply_fixed_tendon_properties stage = _new_sim() _make_fixed_tendon_prim(stage, "/World/FTempty", instance="t0") with pytest.raises(ValueError, match="instance_names"): - apply_fixed_tendon(PhysxFixedTendonCfg(instance_names=instance_names, stiffness=1.0), "/World/FTempty", stage) + apply_fixed_tendon_properties( + "/World/FTempty", [PhysxFixedTendonCfg(instance_names=instance_names, stiffness=1.0)], stage + ) def test_fixed_tendon_axis_fragment_targets_root_and_child_axes(): @@ -294,12 +315,12 @@ def test_tendon_fragments_match_schema_ownership(): PhysxFixedTendonAxisCfg(instance_names="finger", stiffness=1.0) -def test_apply_fixed_tendon_writer_descends_to_child_prims(): +def test_apply_fixed_tendon_properties_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_physx.sim.schemas import PhysxFixedTendonCfg from isaaclab.sim.schemas import apply_fixed_tendon_properties @@ -308,16 +329,14 @@ def test_apply_fixed_tendon_writer_descends_to_child_prims(): 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 +def test_apply_spatial_tendon_properties_descends_to_child_prims(): + from isaaclab_physx.sim.schemas import PhysxSpatialTendonCfg from isaaclab.sim.schemas import apply_spatial_tendon_properties @@ -326,21 +345,23 @@ def test_apply_spatial_tendon_writer_descends_to_child_prims(): 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 child.GetAttribute("physxTendon:s0:stiffness").Get() == pytest.approx(5.0) def test_physx_spatial_tendon_fragment_writes_instanced_namespace(): - from isaaclab_physx.sim.schemas import PhysxSpatialTendonCfg, apply_spatial_tendon + 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/ST", instance="s0") - apply_spatial_tendon(PhysxSpatialTendonCfg(stiffness=4.0, limit_stiffness=0.25), "/World/ST", stage) + assert apply_spatial_tendon_properties( + "/World/ST", [PhysxSpatialTendonCfg(stiffness=4.0, limit_stiffness=0.25)], 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 @@ -348,7 +369,9 @@ def test_physx_spatial_tendon_fragment_writes_instanced_namespace(): def test_apply_spatial_tendon_selects_roots_and_skips_leaves(): - from isaaclab_physx.sim.schemas import PhysxSpatialTendonCfg, apply_spatial_tendon + 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)) @@ -362,7 +385,9 @@ def test_apply_spatial_tendon_selects_roots_and_skips_leaves(): "PhysxTendonAttachmentLeafAPI:l0", ], ) - assert apply_spatial_tendon(PhysxSpatialTendonCfg(instance_names="r0", stiffness=4.0), "/World/STmulti", stage) + assert apply_spatial_tendon_properties( + "/World/STmulti", [PhysxSpatialTendonCfg(instance_names="r0", stiffness=4.0)], stage + ) assert prim.GetAttribute("physxTendon:r0:stiffness").Get() == pytest.approx(4.0) assert not prim.GetAttribute("physxTendon:r1:stiffness").HasAuthoredValue() assert not prim.GetAttribute("physxTendon:l0:stiffness").IsValid() @@ -445,9 +470,6 @@ def test_public_imports(): PhysxFixedTendonAxisCfg, PhysxFixedTendonCfg, PhysxSpatialTendonCfg, - apply_fixed_tendon, - apply_fixed_tendon_axis, - apply_spatial_tendon, ) from isaaclab.sim.schemas import ( # noqa: F401 @@ -542,15 +564,6 @@ def test_apply_fixed_tendon_properties_bare_parent_path_does_not_descend(caplog) 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 diff --git a/source/isaaclab/test/sim/test_tendon_fragments_codeless.py b/source/isaaclab/test/sim/test_tendon_fragments_codeless.py index 261dcd86d15a..045997d8ac9d 100644 --- a/source/isaaclab/test/sim/test_tendon_fragments_codeless.py +++ b/source/isaaclab/test/sim/test_tendon_fragments_codeless.py @@ -27,11 +27,10 @@ def test_tendon_fragments_use_codeless_schema_names_and_types(): PhysxFixedTendonAxisCfg, PhysxFixedTendonCfg, PhysxSpatialTendonCfg, - apply_fixed_tendon, - apply_fixed_tendon_axis, - apply_spatial_tendon, ) + from isaaclab.sim.schemas import apply_fixed_tendon_properties, apply_spatial_tendon_properties + _register_codeless_physx_schemas() stage = Usd.Stage.CreateInMemory() fixed = stage.DefinePrim("/Fixed", "Xform") @@ -40,11 +39,15 @@ def test_tendon_fragments_use_codeless_schema_names_and_types(): spatial = stage.DefinePrim("/Spatial", "Xform") spatial.AddAppliedSchema("PhysxTendonAttachmentRootAPI:cable") - assert apply_fixed_tendon(PhysxFixedTendonCfg(instance_names="index", stiffness=3.0), "/Fixed", stage) - assert apply_fixed_tendon_axis( - PhysxFixedTendonAxisCfg(instance_names="index", gearing=[-0.5], joint_axis=["rotX"]), "/Fixed", stage + assert apply_fixed_tendon_properties( + "/Fixed", + [ + PhysxFixedTendonCfg(instance_names="index", stiffness=3.0), + PhysxFixedTendonAxisCfg(instance_names="index", gearing=[-0.5], joint_axis=["rotX"]), + ], + stage, ) - assert apply_spatial_tendon(PhysxSpatialTendonCfg(stiffness=4.0), "/Spatial", stage) + assert apply_spatial_tendon_properties("/Spatial", [PhysxSpatialTendonCfg(stiffness=4.0)], stage) assert fixed.GetAttribute("physxTendon:index:stiffness").Get() == pytest.approx(3.0) assert not fixed.GetAttribute("physxTendon:middle:stiffness").HasAuthoredValue() diff --git a/source/isaaclab_physx/changelog.d/ooctipus-tendon-instance-addressing.minor.rst b/source/isaaclab_physx/changelog.d/ooctipus-tendon-instance-addressing.major.rst similarity index 70% rename from source/isaaclab_physx/changelog.d/ooctipus-tendon-instance-addressing.minor.rst rename to source/isaaclab_physx/changelog.d/ooctipus-tendon-instance-addressing.major.rst index cef3d7a1b84d..3f097c57eab6 100644 --- a/source/isaaclab_physx/changelog.d/ooctipus-tendon-instance-addressing.minor.rst +++ b/source/isaaclab_physx/changelog.d/ooctipus-tendon-instance-addressing.major.rst @@ -1,8 +1,7 @@ Added ^^^^^ -* Added :class:`~isaaclab_physx.sim.schemas.PhysxFixedTendonAxisCfg` and - :func:`~isaaclab_physx.sim.schemas.apply_fixed_tendon_axis` for configuring the +* Added :class:`~isaaclab_physx.sim.schemas.PhysxFixedTendonAxisCfg` for configuring the ``PhysxTendonAxisAPI`` properties of existing fixed-tendon instances. * Added ``lower_limit`` and ``upper_limit`` to :class:`~isaaclab_physx.sim.schemas.PhysxFixedTendonCfg` and @@ -17,3 +16,11 @@ Changed * Changed :class:`~isaaclab_physx.sim.schemas.PhysxSpatialTendonCfg` 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 5dfca43b6401..a8629e388b16 100644 --- a/source/isaaclab_physx/isaaclab_physx/sim/schemas/__init__.pyi +++ b/source/isaaclab_physx/isaaclab_physx/sim/schemas/__init__.pyi @@ -5,9 +5,6 @@ __all__ = [ "apply_physx_joint", - "apply_fixed_tendon", - "apply_fixed_tendon_axis", - "apply_spatial_tendon", "define_deformable_body_properties", "modify_deformable_body_properties", "ArticulationRootPropertiesCfg", @@ -50,14 +47,7 @@ __all__ = [ "TriangleMeshSimplificationPropertiesCfg", ] -from .schemas import ( - apply_physx_joint, - apply_fixed_tendon, - apply_fixed_tendon_axis, - 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, diff --git a/source/isaaclab_physx/isaaclab_physx/sim/schemas/schemas.py b/source/isaaclab_physx/isaaclab_physx/sim/schemas/schemas.py index 81e86f50cfd5..8b0e534e7734 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,13 +26,8 @@ from isaaclab.sim.utils.stage import get_current_stage from isaaclab.utils.string import to_camel_case -from .schemas_cfg import PhysxFixedTendonAxisCfg, PhysxFixedTendonCfg, PhysxSpatialTendonCfg - __all__ = [ - "apply_fixed_tendon", - "apply_fixed_tendon_axis", "apply_physx_joint", - "apply_spatial_tendon", "define_deformable_body_properties", "modify_deformable_body_properties", ] @@ -99,8 +94,11 @@ def _selected_tendon_instances(prim: Usd.Prim, schema_type: str, selection) -> l return instances -def _tune_tendon_schema(cfg, prim_path: str, stage: Usd.Stage | None, schema_type: str) -> bool: +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 not schema_type: + raise TypeError(f"'{type(cfg).__name__}' does not identify a USD tendon schema.") if stage is None: stage = get_current_stage() prim = stage.GetPrimAtPath(prim_path) @@ -136,55 +134,3 @@ def _tune_tendon_schema(cfg, prim_path: str, stage: Usd.Stage | None, schema_typ if not attr.Set(value): raise ValueError(f"Failed to set '{attr_name}' on prim '{prim_path}'.") return True - - -def apply_fixed_tendon(cfg: PhysxFixedTendonCfg, prim_path: str, stage: Usd.Stage | None = None) -> bool: - """Tune selected ``PhysxTendonAxisRootAPI`` instances on a prim. - - The source asset owns the fixed-tendon topology, so this function applies no schema. It writes - only whole-tendon root properties to the instances selected by :attr:`PhysxFixedTendonCfg.instance_names`. - - 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 selected root instance exists, otherwise False. - """ - return _tune_tendon_schema(cfg, prim_path, stage, "PhysxTendonAxisRootAPI") - - -def apply_fixed_tendon_axis(cfg: PhysxFixedTendonAxisCfg, prim_path: str, stage: Usd.Stage | None = None) -> bool: - """Tune selected ``PhysxTendonAxisAPI`` instances on a joint prim. - - The source asset owns the fixed-tendon topology, so this function applies no schema. A root API - includes the axis API for the same instance; consequently this writer works for both root joints - and AxisAPI-only child joints. - - Args: - cfg: Per-axis properties and the existing instances to select. - prim_path: The joint prim carrying the tendon-axis instances. - stage: The stage where to find the prim. Defaults to the current stage. - - Returns: - True if at least one selected axis instance exists, otherwise False. - """ - return _tune_tendon_schema(cfg, prim_path, stage, "PhysxTendonAxisAPI") - - -def apply_spatial_tendon(cfg: PhysxSpatialTendonCfg, prim_path: str, stage: Usd.Stage | None = None) -> bool: - """Tune selected ``PhysxTendonAttachmentRootAPI`` instances on a prim. - - The fragment contains whole-tendon dynamics declared by the attachment root. Attachment and - leaf properties describe topology and are not modified by this function. - - 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 selected attachment-root instance exists, otherwise False. - """ - return _tune_tendon_schema(cfg, prim_path, stage, "PhysxTendonAttachmentRootAPI") 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 05eb9fee4908..9bcb1abd8bdf 100644 --- a/source/isaaclab_physx/isaaclab_physx/sim/schemas/schemas_cfg.py +++ b/source/isaaclab_physx/isaaclab_physx/sim/schemas/schemas_cfg.py @@ -1277,7 +1277,8 @@ class PhysxFixedTendonCfg(FixedTendonFragment): """ _usd_namespace: ClassVar[str | None] = None - 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" tendon_enabled: bool | None = None """Whether to enable or disable the tendon.""" @@ -1333,7 +1334,8 @@ class PhysxFixedTendonAxisCfg(FixedTendonFragment): """ _usd_namespace: ClassVar[str | None] = None - func: Callable | str = "isaaclab_physx.sim.schemas:apply_fixed_tendon_axis" + _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 """Names of existing tendon-axis instances to tune. @@ -1369,7 +1371,8 @@ class PhysxSpatialTendonCfg(SpatialTendonFragment): """ _usd_namespace: ClassVar[str | None] = None - 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" tendon_enabled: bool | None = None """Whether to enable or disable the tendon.""" From b7d9b46fdf143f16392d60b58ec4177965a43b3e Mon Sep 17 00:00:00 2001 From: Octi Zhang Date: Thu, 3 Sep 2026 04:29:53 -0700 Subject: [PATCH 4/6] Align tendon fragment names with USD schemas --- .../lab_physx/isaaclab_physx.sim.schemas.rst | 12 +- .../core-concepts/schema_fragments.rst | 8 +- .../test/sim/test_tendon_fragments.py | 220 ++++-------------- .../sim/test_tendon_fragments_codeless.py | 12 +- .../fix-tendon-multi-apply-selection.skip | 1 + .../sim/schemas/schemas_cfg.py | 7 +- ...tipus-tendon-instance-addressing.major.rst | 15 +- .../isaaclab_physx/sim/schemas/__init__.pyi | 12 +- .../isaaclab_physx/sim/schemas/schemas_cfg.py | 48 ++-- 9 files changed, 109 insertions(+), 226 deletions(-) create mode 100644 source/isaaclab_newton/changelog.d/fix-tendon-multi-apply-selection.skip 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 4968ef482a59..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,10 +43,10 @@ isaaclab_physx.sim.schemas .. autosummary:: PhysxFixedTendonPropertiesCfg - PhysxFixedTendonCfg - PhysxFixedTendonAxisCfg + PhysxTendonAxisRootCfg + PhysxTendonAxisCfg PhysxSpatialTendonPropertiesCfg - PhysxSpatialTendonCfg + PhysxTendonAttachmentRootCfg .. rubric:: Deformable body @@ -133,12 +133,12 @@ Tendon :show-inheritance: :exclude-members: __init__ -.. autoclass:: PhysxFixedTendonCfg +.. autoclass:: PhysxTendonAxisRootCfg :members: :show-inheritance: :exclude-members: __init__ -.. autoclass:: PhysxFixedTendonAxisCfg +.. autoclass:: PhysxTendonAxisCfg :members: :show-inheritance: :exclude-members: __init__ @@ -148,7 +148,7 @@ Tendon :show-inheritance: :exclude-members: __init__ -.. autoclass:: PhysxSpatialTendonCfg +.. autoclass:: PhysxTendonAttachmentRootCfg :members: :show-inheritance: :exclude-members: __init__ diff --git a/docs/source/overview/core-concepts/schema_fragments.rst b/docs/source/overview/core-concepts/schema_fragments.rst index 518be5c27391..6b7ba1794ded 100644 --- a/docs/source/overview/core-concepts/schema_fragments.rst +++ b/docs/source/overview/core-concepts/schema_fragments.rst @@ -74,15 +74,15 @@ joints: .. code-block:: python - from isaaclab_physx.sim.schemas import PhysxFixedTendonAxisCfg, PhysxFixedTendonCfg + from isaaclab_physx.sim.schemas import PhysxTendonAxisCfg, PhysxTendonAxisRootCfg fixed_tendons_props = { "/joints/index_root": [ - PhysxFixedTendonCfg(instance_names="index_finger", stiffness=10.0), - PhysxFixedTendonAxisCfg(instance_names="index_finger", gearing=[1.0], joint_axis=["rotX"]), + PhysxTendonAxisRootCfg(instance_names="index_finger", stiffness=10.0), + PhysxTendonAxisCfg(instance_names="index_finger", gearing=[1.0], joint_axis=["rotX"]), ], "/joints/index_distal": [ - PhysxFixedTendonAxisCfg(instance_names="index_finger", gearing=[-0.5], joint_axis=["rotX"]), + PhysxTendonAxisCfg(instance_names="index_finger", gearing=[-0.5], joint_axis=["rotX"]), ], } diff --git a/source/isaaclab/test/sim/test_tendon_fragments.py b/source/isaaclab/test/sim/test_tendon_fragments.py index 1d802cbe1ec7..dcf1325b3339 100644 --- a/source/isaaclab/test/sim/test_tendon_fragments.py +++ b/source/isaaclab/test/sim/test_tendon_fragments.py @@ -69,90 +69,12 @@ def _tendon_attr_prefix(prim, schema_type): # ------------------------------------------------------------------------------------- -# Fixed-tendon marker + metadata defaults +# PhysxTendonAxisRootCfg writes the multi-instance namespace # ------------------------------------------------------------------------------------- -def test_fixed_tendon_fragment_metadata_defaults(): - from isaaclab_physx.sim.schemas import PhysxFixedTendonCfg - - from isaaclab.sim.schemas import FixedTendonFragment, SchemaFragment - - cfg = PhysxFixedTendonCfg(instance_names="index_finger", stiffness=1.0) - assert isinstance(cfg, FixedTendonFragment) and isinstance(cfg, SchemaFragment) - assert cfg.func == "isaaclab_physx.sim.schemas.schemas:_tune_tendon_schema" - assert type(cfg)._usd_applied_schema == "PhysxTendonAxisRootAPI" - assert cfg.instance_names == "index_finger" and cfg.stiffness == 1.0 and cfg.damping is None - - -def test_fixed_tendon_axis_fragment_metadata_defaults(): - from isaaclab_physx.sim.schemas import PhysxFixedTendonAxisCfg - - from isaaclab.sim.schemas import FixedTendonFragment, SchemaFragment - - cfg = PhysxFixedTendonAxisCfg(instance_names="index_finger", gearing=[-0.5]) - assert isinstance(cfg, FixedTendonFragment) and isinstance(cfg, SchemaFragment) - assert cfg.func == "isaaclab_physx.sim.schemas.schemas:_tune_tendon_schema" - assert type(cfg)._usd_applied_schema == "PhysxTendonAxisAPI" - assert cfg.instance_names == "index_finger" and cfg.gearing == [-0.5] - - -def test_spatial_tendon_fragment_metadata_defaults(): - from isaaclab_physx.sim.schemas import PhysxSpatialTendonCfg - - from isaaclab.sim.schemas import SchemaFragment, SpatialTendonFragment - - cfg = PhysxSpatialTendonCfg(instance_names="cable", stiffness=2.0) - assert isinstance(cfg, SpatialTendonFragment) and isinstance(cfg, SchemaFragment) - assert cfg.func == "isaaclab_physx.sim.schemas.schemas:_tune_tendon_schema" - assert type(cfg)._usd_applied_schema == "PhysxTendonAttachmentRootAPI" - assert cfg.instance_names == "cable" and cfg.stiffness == 2.0 and cfg.damping is None - - -def test_instance_selector_preserves_existing_tendon_config_field_order(): - from isaaclab_physx.sim.schemas import PhysxFixedTendonCfg, PhysxSpatialTendonCfg - - fixed_fields = [field.name for field in dataclasses.fields(PhysxFixedTendonCfg)] - spatial_fields = [field.name for field in dataclasses.fields(PhysxSpatialTendonCfg)] - assert fixed_fields[:7] == [ - "func", - "tendon_enabled", - "stiffness", - "damping", - "limit_stiffness", - "offset", - "rest_length", - ] - assert fixed_fields[-1] == "instance_names" - assert spatial_fields == [ - "func", - "tendon_enabled", - "stiffness", - "damping", - "limit_stiffness", - "offset", - "instance_names", - ] - - -def test_multiple_apply_mechanics_are_not_a_public_core_fragment_convention(): - import isaaclab_physx.sim.schemas as physx_schemas - - import isaaclab.sim.schemas as schemas - - assert "MultiApplyFragment" not in schemas.__all__ - assert "apply_multi_apply" not in schemas.__all__ - for name in ("apply_fixed_tendon", "apply_fixed_tendon_axis", "apply_spatial_tendon"): - assert name not in physx_schemas.__all__ - - -# ------------------------------------------------------------------------------------- -# PhysxFixedTendonCfg writes the multi-instance namespace -# ------------------------------------------------------------------------------------- - - -def test_physx_fixed_tendon_fragment_writes_instanced_namespace(): - from isaaclab_physx.sim.schemas import PhysxFixedTendonCfg +def test_tendon_axis_root_fragment_writes_instanced_namespace(): + from isaaclab_physx.sim.schemas import PhysxTendonAxisRootCfg from isaaclab.sim.schemas import apply_fixed_tendon_properties @@ -162,7 +84,7 @@ def test_physx_fixed_tendon_fragment_writes_instanced_namespace(): prim = _make_fixed_tendon_prim(stage, "/World/FT", instance="t0") assert apply_fixed_tendon_properties( "/World/FT", - [PhysxFixedTendonCfg(instance_names="t0", stiffness=3.0, damping=0.5, lower_limit=-0.2, upper_limit=0.4)], + [PhysxTendonAxisRootCfg(instance_names="t0", stiffness=3.0, damping=0.5, lower_limit=-0.2, upper_limit=0.4)], stage, ) prefix = _tendon_attr_prefix(prim, "PhysxTendonAxisRootAPI") @@ -170,18 +92,15 @@ def test_physx_fixed_tendon_fragment_writes_instanced_namespace(): assert abs(prim.GetAttribute(f"{prefix}:damping").Get() - 0.5) < 1e-6 assert prim.GetAttribute(f"{prefix}:lowerLimit").Get() == pytest.approx(-0.2) assert prim.GetAttribute(f"{prefix}:upperLimit").Get() == pytest.approx(0.4) - assert not prim.HasAttribute("PhysxTendonAxisRootAPI:t0:stiffness") - assert not prim.HasAttribute(f"{prefix}:instanceNames") - assert not prim.HasAttribute(f"{prefix}:func") # ------------------------------------------------------------------------------------- -# PhysxSpatialTendonCfg writes the multi-instance namespace +# Instance selection # ------------------------------------------------------------------------------------- def test_apply_fixed_tendon_selects_one_instance(): - from isaaclab_physx.sim.schemas import PhysxFixedTendonCfg + from isaaclab_physx.sim.schemas import PhysxTendonAxisRootCfg from isaaclab.sim.schemas import apply_fixed_tendon_properties @@ -190,14 +109,14 @@ def test_apply_fixed_tendon_selects_one_instance(): stage = sim_utils.get_current_stage() prim = _make_prim_with_schemas(stage, "/World/FTmulti", ["PhysxTendonAxisRootAPI:t0", "PhysxTendonAxisRootAPI:t1"]) assert apply_fixed_tendon_properties( - "/World/FTmulti", [PhysxFixedTendonCfg(instance_names="t0", stiffness=9.0)], stage + "/World/FTmulti", [PhysxTendonAxisRootCfg(instance_names="t0", stiffness=9.0)], stage ) assert prim.GetAttribute("physxTendon:t0:stiffness").Get() == pytest.approx(9.0) assert not prim.GetAttribute("physxTendon:t1:stiffness").HasAuthoredValue() def test_apply_fixed_tendon_broadcasts_by_default(): - from isaaclab_physx.sim.schemas import PhysxFixedTendonCfg + from isaaclab_physx.sim.schemas import PhysxTendonAxisRootCfg from isaaclab.sim.schemas import apply_fixed_tendon_properties @@ -205,13 +124,13 @@ def test_apply_fixed_tendon_broadcasts_by_default(): prim = _make_prim_with_schemas( stage, "/World/FTbroadcast", ["PhysxTendonAxisRootAPI:t0", "PhysxTendonAxisRootAPI:t1"] ) - assert apply_fixed_tendon_properties("/World/FTbroadcast", [PhysxFixedTendonCfg(stiffness=9.0)], stage) + assert apply_fixed_tendon_properties("/World/FTbroadcast", [PhysxTendonAxisRootCfg(stiffness=9.0)], stage) for inst in ("t0", "t1"): assert prim.GetAttribute(f"physxTendon:{inst}:stiffness").Get() == pytest.approx(9.0) def test_apply_fixed_tendon_selects_a_list_of_instances(): - from isaaclab_physx.sim.schemas import PhysxFixedTendonCfg + from isaaclab_physx.sim.schemas import PhysxTendonAxisRootCfg from isaaclab.sim.schemas import apply_fixed_tendon_properties @@ -221,29 +140,15 @@ def test_apply_fixed_tendon_selects_a_list_of_instances(): "/World/FTlist", ["PhysxTendonAxisRootAPI:t0", "PhysxTendonAxisRootAPI:t1", "PhysxTendonAxisRootAPI:t2"], ) - cfg = PhysxFixedTendonCfg(instance_names=["t0", "t2"], damping=0.25) + cfg = PhysxTendonAxisRootCfg(instance_names=["t0", "t2"], damping=0.25) assert apply_fixed_tendon_properties("/World/FTlist", [cfg], stage) assert prim.GetAttribute("physxTendon:t0:damping").Get() == pytest.approx(0.25) assert not prim.GetAttribute("physxTendon:t1:damping").HasAuthoredValue() assert prim.GetAttribute("physxTendon:t2:damping").Get() == pytest.approx(0.25) -@pytest.mark.parametrize("instance_names", [[], ""]) -def test_apply_fixed_tendon_rejects_empty_instance_selection(instance_names): - from isaaclab_physx.sim.schemas import PhysxFixedTendonCfg - - from isaaclab.sim.schemas import apply_fixed_tendon_properties - - stage = _new_sim() - _make_fixed_tendon_prim(stage, "/World/FTempty", instance="t0") - with pytest.raises(ValueError, match="instance_names"): - apply_fixed_tendon_properties( - "/World/FTempty", [PhysxFixedTendonCfg(instance_names=instance_names, stiffness=1.0)], stage - ) - - def test_fixed_tendon_axis_fragment_targets_root_and_child_axes(): - from isaaclab_physx.sim.schemas import PhysxFixedTendonAxisCfg + from isaaclab_physx.sim.schemas import PhysxTendonAxisCfg from isaaclab.sim.schemas import apply_fixed_tendon_properties @@ -254,7 +159,7 @@ def test_fixed_tendon_axis_fragment_targets_root_and_child_axes(): child = _make_xform(stage, "/World/Hand/child") PhysxSchema.PhysxTendonAxisAPI.Apply(child, "index_finger") - cfg = PhysxFixedTendonAxisCfg( + cfg = PhysxTendonAxisCfg( instance_names="index_finger", gearing=[-0.5], force_coefficient=[2.0], joint_axis=["rotX"] ) assert apply_fixed_tendon_properties("/World/Hand(/.*)?", [cfg], stage) @@ -267,15 +172,15 @@ def test_fixed_tendon_axis_fragment_targets_root_and_child_axes(): def test_tendon_fragments_match_schema_ownership(): - from isaaclab_physx.sim.schemas import PhysxFixedTendonAxisCfg, PhysxFixedTendonCfg, PhysxSpatialTendonCfg + from isaaclab_physx.sim.schemas import PhysxTendonAttachmentRootCfg, PhysxTendonAxisCfg, PhysxTendonAxisRootCfg from isaaclab.utils.string import to_camel_case address_fields = {"func", "instance_names"} - root_fields = {field.name for field in dataclasses.fields(PhysxFixedTendonCfg)} - address_fields - axis_fields = {field.name for field in dataclasses.fields(PhysxFixedTendonAxisCfg)} - address_fields - spatial_fields = {field.name for field in dataclasses.fields(PhysxSpatialTendonCfg)} - address_fields - assert root_fields == { + axis_root_fields = {field.name for field in dataclasses.fields(PhysxTendonAxisRootCfg)} - address_fields + axis_fields = {field.name for field in dataclasses.fields(PhysxTendonAxisCfg)} - address_fields + attachment_root_fields = {field.name for field in dataclasses.fields(PhysxTendonAttachmentRootCfg)} - address_fields + assert axis_root_fields == { "tendon_enabled", "stiffness", "damping", @@ -286,14 +191,14 @@ def test_tendon_fragments_match_schema_ownership(): "upper_limit", } assert axis_fields == {"gearing", "force_coefficient", "joint_axis"} - assert spatial_fields == {"tendon_enabled", "stiffness", "damping", "limit_stiffness", "offset"} - assert root_fields.isdisjoint(axis_fields) + assert attachment_root_fields == {"tendon_enabled", "stiffness", "damping", "limit_stiffness", "offset"} + assert axis_root_fields.isdisjoint(axis_fields) registry = Usd.SchemaRegistry() for cfg_type, schema_type in ( - (PhysxFixedTendonCfg, "PhysxTendonAxisRootAPI"), - (PhysxFixedTendonAxisCfg, "PhysxTendonAxisAPI"), - (PhysxSpatialTendonCfg, "PhysxTendonAttachmentRootAPI"), + (PhysxTendonAxisRootCfg, "PhysxTendonAxisRootAPI"), + (PhysxTendonAxisCfg, "PhysxTendonAxisAPI"), + (PhysxTendonAttachmentRootCfg, "PhysxTendonAttachmentRootAPI"), ): definition = registry.FindAppliedAPIPrimDefinition(schema_type) assert definition is not None @@ -309,18 +214,13 @@ def test_tendon_fragments_match_schema_ownership(): } assert cfg_properties <= schema_properties - with pytest.raises(TypeError): - PhysxFixedTendonCfg(instance_names="finger", gearing=[1.0]) - with pytest.raises(TypeError): - PhysxFixedTendonAxisCfg(instance_names="finger", stiffness=1.0) - def test_apply_fixed_tendon_properties_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 + from isaaclab_physx.sim.schemas import PhysxTendonAxisRootCfg from isaaclab.sim.schemas import apply_fixed_tendon_properties @@ -330,13 +230,13 @@ def test_apply_fixed_tendon_properties_descends_to_child_prims(): 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 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 + assert apply_fixed_tendon_properties("/World/Robot(/.*)?", [PhysxTendonAxisRootCfg(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_properties_descends_to_child_prims(): - from isaaclab_physx.sim.schemas import PhysxSpatialTendonCfg + from isaaclab_physx.sim.schemas import PhysxTendonAttachmentRootCfg from isaaclab.sim.schemas import apply_spatial_tendon_properties @@ -346,12 +246,13 @@ def test_apply_spatial_tendon_properties_descends_to_child_prims(): UsdGeom.Xform.Define(stage, "/World/Robot2") # root: no tendon schema child = _make_prim_with_schemas(stage, "/World/Robot2/joint", ["PhysxTendonAttachmentRootAPI:s0"]) # 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 + cfg = PhysxTendonAttachmentRootCfg(stiffness=5.0) + assert apply_spatial_tendon_properties("/World/Robot2(/.*)?", [cfg], stage) is True assert child.GetAttribute("physxTendon:s0:stiffness").Get() == pytest.approx(5.0) -def test_physx_spatial_tendon_fragment_writes_instanced_namespace(): - from isaaclab_physx.sim.schemas import PhysxSpatialTendonCfg +def test_tendon_attachment_root_fragment_writes_instanced_namespace(): + from isaaclab_physx.sim.schemas import PhysxTendonAttachmentRootCfg from isaaclab.sim.schemas import apply_spatial_tendon_properties @@ -360,16 +261,15 @@ def test_physx_spatial_tendon_fragment_writes_instanced_namespace(): stage = sim_utils.get_current_stage() prim = _make_spatial_tendon_prim(stage, "/World/ST", instance="s0") assert apply_spatial_tendon_properties( - "/World/ST", [PhysxSpatialTendonCfg(stiffness=4.0, limit_stiffness=0.25)], stage + "/World/ST", [PhysxTendonAttachmentRootCfg(stiffness=4.0, limit_stiffness=0.25)], 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_selects_roots_and_skips_leaves(): - from isaaclab_physx.sim.schemas import PhysxSpatialTendonCfg + from isaaclab_physx.sim.schemas import PhysxTendonAttachmentRootCfg from isaaclab.sim.schemas import apply_spatial_tendon_properties @@ -386,7 +286,7 @@ def test_apply_spatial_tendon_selects_roots_and_skips_leaves(): ], ) assert apply_spatial_tendon_properties( - "/World/STmulti", [PhysxSpatialTendonCfg(instance_names="r0", stiffness=4.0)], stage + "/World/STmulti", [PhysxTendonAttachmentRootCfg(instance_names="r0", stiffness=4.0)], stage ) assert prim.GetAttribute("physxTendon:r0:stiffness").Get() == pytest.approx(4.0) assert not prim.GetAttribute("physxTendon:r1:stiffness").HasAuthoredValue() @@ -407,7 +307,6 @@ def test_legacy_spatial_tendon_writer_uses_root_property_namespace(): 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.HasAttribute("PhysxTendonAttachmentRootAPI:r0:stiffness") assert not prim.GetAttribute("physxTendon:l0:stiffness").IsValid() @@ -417,7 +316,7 @@ def test_legacy_spatial_tendon_writer_uses_root_property_namespace(): def test_apply_fixed_tendon_properties_dispatches_fragments(): - from isaaclab_physx.sim.schemas import PhysxFixedTendonCfg + from isaaclab_physx.sim.schemas import PhysxTendonAxisRootCfg from isaaclab.sim.schemas import apply_fixed_tendon_properties @@ -428,8 +327,8 @@ def test_apply_fixed_tendon_properties_dispatches_fragments(): apply_fixed_tendon_properties( "/World/FT2", [ - PhysxFixedTendonCfg(stiffness=5.0), - PhysxFixedTendonCfg(damping=0.75), + PhysxTendonAxisRootCfg(stiffness=5.0), + PhysxTendonAxisRootCfg(damping=0.75), ], stage, ) @@ -439,7 +338,7 @@ def test_apply_fixed_tendon_properties_dispatches_fragments(): def test_apply_spatial_tendon_properties_dispatches_fragments(): - from isaaclab_physx.sim.schemas import PhysxSpatialTendonCfg + from isaaclab_physx.sim.schemas import PhysxTendonAttachmentRootCfg from isaaclab.sim.schemas import apply_spatial_tendon_properties @@ -450,8 +349,8 @@ def test_apply_spatial_tendon_properties_dispatches_fragments(): apply_spatial_tendon_properties( "/World/ST2", [ - PhysxSpatialTendonCfg(stiffness=6.0), - PhysxSpatialTendonCfg(offset=0.1), + PhysxTendonAttachmentRootCfg(stiffness=6.0), + PhysxTendonAttachmentRootCfg(offset=0.1), ], stage, ) @@ -460,53 +359,34 @@ def test_apply_spatial_tendon_properties_dispatches_fragments(): 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 - PhysxFixedTendonAxisCfg, - PhysxFixedTendonCfg, - PhysxSpatialTendonCfg, - ) - - 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_warns_on_unmatched_path(caplog): - from isaaclab_physx.sim.schemas import PhysxFixedTendonCfg + from isaaclab_physx.sim.schemas import PhysxTendonAxisRootCfg 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) + result = apply_fixed_tendon_properties("/World/DoesNotExist", [PhysxTendonAxisRootCfg(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_physx.sim.schemas import PhysxTendonAttachmentRootCfg from isaaclab.sim.schemas import apply_spatial_tendon_properties _new_sim() stage = sim_utils.get_current_stage() + cfg = PhysxTendonAttachmentRootCfg(stiffness=1.0) with caplog.at_level("WARNING"): - result = apply_spatial_tendon_properties("/World/DoesNotExist", [PhysxSpatialTendonCfg(stiffness=1.0)], stage) + result = apply_spatial_tendon_properties("/World/DoesNotExist", [cfg], stage) assert result is False assert "/World/DoesNotExist" in caplog.text @@ -528,7 +408,7 @@ def test_apply_fixed_tendon_aggregates_fragment_results(): def test_apply_fixed_tendon_properties_narrows_to_exact_path(): - from isaaclab_physx.sim.schemas import PhysxFixedTendonCfg + from isaaclab_physx.sim.schemas import PhysxTendonAxisRootCfg from isaaclab.sim.schemas import apply_fixed_tendon_properties @@ -536,7 +416,7 @@ def test_apply_fixed_tendon_properties_narrows_to_exact_path(): 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 + assert apply_fixed_tendon_properties("/World/Narrow/J0", [PhysxTendonAxisRootCfg(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") @@ -545,7 +425,7 @@ def test_apply_fixed_tendon_properties_narrows_to_exact_path(): def test_apply_fixed_tendon_properties_bare_parent_path_does_not_descend(caplog): - from isaaclab_physx.sim.schemas import PhysxFixedTendonCfg + from isaaclab_physx.sim.schemas import PhysxTendonAxisRootCfg from isaaclab.sim.schemas import apply_fixed_tendon_properties @@ -555,7 +435,7 @@ def test_apply_fixed_tendon_properties_bare_parent_path_does_not_descend(caplog) 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) + result = apply_fixed_tendon_properties("/World/Parent", [PhysxTendonAxisRootCfg(stiffness=50.0)], stage) assert result is False for prim in (first, second): prefix = _tendon_attr_prefix(prim, "PhysxTendonAxisRootAPI") @@ -646,7 +526,7 @@ def test_legacy_and_fragment_fixed_tendon_produce_identical_attrs(): 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_physx.sim.schemas import PhysxFixedTendonPropertiesCfg, PhysxTendonAxisRootCfg from isaaclab.sim.schemas import apply_fixed_tendon_properties, modify_fixed_tendon_properties @@ -663,7 +543,7 @@ def _build(root): # 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 = {} diff --git a/source/isaaclab/test/sim/test_tendon_fragments_codeless.py b/source/isaaclab/test/sim/test_tendon_fragments_codeless.py index 045997d8ac9d..27a46113bab6 100644 --- a/source/isaaclab/test/sim/test_tendon_fragments_codeless.py +++ b/source/isaaclab/test/sim/test_tendon_fragments_codeless.py @@ -24,9 +24,9 @@ def _register_codeless_physx_schemas(): def test_tendon_fragments_use_codeless_schema_names_and_types(): from isaaclab_physx.sim.schemas import ( - PhysxFixedTendonAxisCfg, - PhysxFixedTendonCfg, - PhysxSpatialTendonCfg, + PhysxTendonAttachmentRootCfg, + PhysxTendonAxisCfg, + PhysxTendonAxisRootCfg, ) from isaaclab.sim.schemas import apply_fixed_tendon_properties, apply_spatial_tendon_properties @@ -42,12 +42,12 @@ def test_tendon_fragments_use_codeless_schema_names_and_types(): assert apply_fixed_tendon_properties( "/Fixed", [ - PhysxFixedTendonCfg(instance_names="index", stiffness=3.0), - PhysxFixedTendonAxisCfg(instance_names="index", gearing=[-0.5], joint_axis=["rotX"]), + PhysxTendonAxisRootCfg(instance_names="index", stiffness=3.0), + PhysxTendonAxisCfg(instance_names="index", gearing=[-0.5], joint_axis=["rotX"]), ], stage, ) - assert apply_spatial_tendon_properties("/Spatial", [PhysxSpatialTendonCfg(stiffness=4.0)], stage) + assert apply_spatial_tendon_properties("/Spatial", [PhysxTendonAttachmentRootCfg(stiffness=4.0)], stage) assert fixed.GetAttribute("physxTendon:index:stiffness").Get() == pytest.approx(3.0) assert not fixed.GetAttribute("physxTendon:middle:stiffness").HasAuthoredValue() 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..8b137891791f --- /dev/null +++ b/source/isaaclab_newton/changelog.d/fix-tendon-multi-apply-selection.skip @@ -0,0 +1 @@ + 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 index 3f097c57eab6..ef610c88c7f0 100644 --- a/source/isaaclab_physx/changelog.d/ooctipus-tendon-instance-addressing.major.rst +++ b/source/isaaclab_physx/changelog.d/ooctipus-tendon-instance-addressing.major.rst @@ -1,19 +1,24 @@ Added ^^^^^ -* Added :class:`~isaaclab_physx.sim.schemas.PhysxFixedTendonAxisCfg` for configuring the +* 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.PhysxFixedTendonCfg` and + :class:`~isaaclab_physx.sim.schemas.PhysxTendonAxisRootCfg` and :class:`~isaaclab_physx.sim.schemas.PhysxFixedTendonPropertiesCfg`. Changed ^^^^^^^ -* Added ``instance_names`` to :class:`~isaaclab_physx.sim.schemas.PhysxFixedTendonCfg` and - :class:`~isaaclab_physx.sim.schemas.PhysxSpatialTendonCfg`. Pass one name or a list to select +* 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.PhysxSpatialTendonCfg` to configure only +* Changed :class:`~isaaclab_physx.sim.schemas.PhysxTendonAttachmentRootCfg` to configure only ``PhysxTendonAttachmentRootAPI`` instances. Leaf and intermediate attachment topology remains asset-authored. diff --git a/source/isaaclab_physx/isaaclab_physx/sim/schemas/__init__.pyi b/source/isaaclab_physx/isaaclab_physx/sim/schemas/__init__.pyi index a8629e388b16..8ca1676d7b55 100644 --- a/source/isaaclab_physx/isaaclab_physx/sim/schemas/__init__.pyi +++ b/source/isaaclab_physx/isaaclab_physx/sim/schemas/__init__.pyi @@ -25,8 +25,6 @@ __all__ = [ "PhysxConvexHullCfg", "PhysxConvexHullPropertiesCfg", "PhysxDeformableBodyPropertiesCfg", - "PhysxFixedTendonAxisCfg", - "PhysxFixedTendonCfg", "PhysxFixedTendonPropertiesCfg", "PhysxJointCfg", "PhysxJointDrivePropertiesCfg", @@ -34,8 +32,10 @@ __all__ = [ "PhysxRigidBodyPropertiesCfg", "PhysxSDFMeshCfg", "PhysxSDFMeshPropertiesCfg", - "PhysxSpatialTendonCfg", "PhysxSpatialTendonPropertiesCfg", + "PhysxTendonAttachmentRootCfg", + "PhysxTendonAxisCfg", + "PhysxTendonAxisRootCfg", "PhysxTriangleMeshCfg", "PhysxTriangleMeshPropertiesCfg", "PhysxTriangleMeshSimplificationCfg", @@ -67,8 +67,6 @@ from .schemas_cfg import ( PhysxConvexHullCfg, PhysxConvexHullPropertiesCfg, PhysxDeformableBodyPropertiesCfg, - PhysxFixedTendonAxisCfg, - PhysxFixedTendonCfg, PhysxFixedTendonPropertiesCfg, PhysxJointCfg, PhysxJointDrivePropertiesCfg, @@ -76,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_cfg.py b/source/isaaclab_physx/isaaclab_physx/sim/schemas/schemas_cfg.py index 9bcb1abd8bdf..967d96b18646 100644 --- a/source/isaaclab_physx/isaaclab_physx/sim/schemas/schemas_cfg.py +++ b/source/isaaclab_physx/isaaclab_physx/sim/schemas/schemas_cfg.py @@ -1263,12 +1263,12 @@ def __post_init__(self): @configclass -class PhysxFixedTendonCfg(FixedTendonFragment): +class PhysxTendonAxisRootCfg(FixedTendonFragment): """Whole-tendon attributes from `PhysxTendonAxisRootAPI`_. This is a *tune-not-apply* fragment: the source asset owns the ``PhysxTendonAxisRootAPI:`` topology, and this fragment selects existing instances - to tune. Use :class:`PhysxFixedTendonAxisCfg` for the per-joint-axis properties of the same + 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`. @@ -1280,6 +1280,14 @@ class PhysxFixedTendonCfg(FixedTendonFragment): _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 + """Names of existing tendon instances to tune. + + A string selects one instance, a list selects those instances, and ``None`` selects every + ``PhysxTendonAxisRootAPI`` instance on each targeted prim (the default). An empty string or list + is an error. This field addresses the schema instances and is not authored as a USD property. + """ + tendon_enabled: bool | None = None """Whether to enable or disable the tendon.""" @@ -1308,19 +1316,9 @@ class PhysxFixedTendonCfg(FixedTendonFragment): upper_limit: float | None = None """Upper limit of the tendon's length [m].""" - instance_names: str | list[str] | None = None - """Names of existing tendon instances to tune. - - A string selects one instance, a list selects those instances, and ``None`` selects every - ``PhysxTendonAxisRootAPI`` instance on each targeted prim (the default). An empty string or list - is an error. This field addresses the schema instances and is not authored as a USD property. - - This selector follows the pre-existing property fields to preserve their positional argument order. - """ - @configclass -class PhysxFixedTendonAxisCfg(FixedTendonFragment): +class PhysxTendonAxisCfg(FixedTendonFragment): """Per-joint-axis attributes from `PhysxTendonAxisAPI`_. The source asset owns each ``PhysxTendonAxisAPI:``. This fragment selects existing @@ -1356,8 +1354,8 @@ class PhysxFixedTendonAxisCfg(FixedTendonFragment): @configclass -class PhysxSpatialTendonCfg(SpatialTendonFragment): - """PhysX spatial-tendon attributes from `PhysxTendonAttachmentRootAPI`_. +class PhysxTendonAttachmentRootCfg(SpatialTendonFragment): + """Whole-tendon attributes from `PhysxTendonAttachmentRootAPI`_. A spatial-tendon fragment (see :class:`~isaaclab.sim.schemas.SpatialTendonFragment`) for the PhysX spatial-tendon schema. This is a *tune-not-apply* fragment: the source asset owns the @@ -1374,6 +1372,15 @@ class PhysxSpatialTendonCfg(SpatialTendonFragment): _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 + """Names of existing spatial-tendon root instances to tune. + + A string selects one instance, a list selects those instances, and ``None`` selects every + ``PhysxTendonAttachmentRootAPI`` instance on each targeted prim (the default). An empty string + or list is an error. This field addresses the schema instances and is not authored as a USD + property. + """ + tendon_enabled: bool | None = None """Whether to enable or disable the tendon.""" @@ -1392,14 +1399,3 @@ class PhysxSpatialTendonCfg(SpatialTendonFragment): 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. """ - - instance_names: str | list[str] | None = None - """Names of existing spatial-tendon root instances to tune. - - A string selects one instance, a list selects those instances, and ``None`` selects every - ``PhysxTendonAttachmentRootAPI`` instance on each targeted prim (the default). An empty string - or list is an error. This field addresses the schema instances and is not authored as a USD - property. - - This selector follows the pre-existing property fields to preserve their positional argument order. - """ From a85c2422f93627c2f89280737156eb52e64c6180 Mon Sep 17 00:00:00 2001 From: Octi Zhang Date: Thu, 3 Sep 2026 04:31:39 -0700 Subject: [PATCH 5/6] Fix Newton changelog skip marker --- .../changelog.d/fix-tendon-multi-apply-selection.skip | 1 - 1 file changed, 1 deletion(-) 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 index 8b137891791f..e69de29bb2d1 100644 --- a/source/isaaclab_newton/changelog.d/fix-tendon-multi-apply-selection.skip +++ b/source/isaaclab_newton/changelog.d/fix-tendon-multi-apply-selection.skip @@ -1 +0,0 @@ - From 008ba2039612b97042f4a09024fea39803b76e91 Mon Sep 17 00:00:00 2001 From: Octi Zhang Date: Thu, 3 Sep 2026 04:48:12 -0700 Subject: [PATCH 6/6] Reduce tendon schema implementation redundancy --- .../isaaclab/isaaclab/sim/schemas/schemas.py | 162 ++---- .../test/sim/test_tendon_fragments.py | 474 ++++-------------- .../sim/test_tendon_fragments_codeless.py | 20 +- .../isaaclab_physx/sim/schemas/schemas.py | 51 +- .../isaaclab_physx/sim/schemas/schemas_cfg.py | 25 +- 5 files changed, 153 insertions(+), 579 deletions(-) diff --git a/source/isaaclab/isaaclab/sim/schemas/schemas.py b/source/isaaclab/isaaclab/sim/schemas/schemas.py index a49b72fe44ad..91a8d31635de 100644 --- a/source/isaaclab/isaaclab/sim/schemas/schemas.py +++ b/source/isaaclab/isaaclab/sim/schemas/schemas.py @@ -1629,27 +1629,49 @@ def modify_joint_drive_properties( """ -def _applied_schema_instance(applied_schema, schema_type: str) -> str | None: - """Return an applied multiple-apply schema's instance when its type matches exactly.""" - applied_type, instance = Usd.SchemaRegistry.GetTypeNameAndInstance(str(applied_schema)) - return instance if applied_type == schema_type and instance else None +_FIXED_TENDON_SCHEMAS = ("PhysxTendonAxisRootAPI", "PhysxTendonAxisAPI") +_SPATIAL_TENDON_SCHEMAS = ("PhysxTendonAttachmentRootAPI",) -def _is_fixed_tendon_target(prim: Usd.Prim) -> bool: - """Whether a prim carries a fixed-tendon representation.""" - if prim.GetTypeName() == "MjcTendon": - return True - fixed_types = {"PhysxTendonAxisRootAPI", "PhysxTendonAxisAPI"} - return any( - _applied_schema_instance(schema, schema_type) - for schema in prim.GetAppliedSchemas() - for schema_type in fixed_types - ) +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 root instance.""" - return any(_applied_schema_instance(schema, "PhysxTendonAttachmentRootAPI") for schema 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( @@ -1684,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 @@ -1744,42 +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(_applied_schema_instance(schema, "PhysxTendonAxisRootAPI") for schema 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: - instance_name = _applied_schema_instance(schema_name, "PhysxTendonAxisRootAPI") - if instance_name is None: - continue - for attr_name, value in cfg.items(): - template = f"physxTendon:__INSTANCE_NAME__:{to_camel_case(attr_name, 'cC')}" - attribute = Usd.SchemaRegistry.MakeMultipleApplyNameInstance(template, instance_name) - safe_set_attribute_on_usd_prim( - tendon_prim, - attribute, - 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 in ("stiffness", "damping"): - value = cfg.get(attr_name) - 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 @@ -1819,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 @@ -1878,30 +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(_applied_schema_instance(schema, "PhysxTendonAttachmentRootAPI") for schema in applied_schemas) - if not has_spatial: - return False - - cfg = cfg.to_dict() - for schema_name in applied_schemas: - instance_name = _applied_schema_instance(schema_name, "PhysxTendonAttachmentRootAPI") - if instance_name is None: - continue - for attr_name, value in cfg.items(): - template = f"physxTendon:__INSTANCE_NAME__:{to_camel_case(attr_name, 'cC')}" - attribute = Usd.SchemaRegistry.MakeMultipleApplyNameInstance(template, instance_name) - safe_set_attribute_on_usd_prim( - tendon_prim, - attribute, - value, - camel_case=False, - ) - # success - return True + return _write_tendon_properties(tendon_prim, cfg.to_dict(), "PhysxTendonAttachmentRootAPI") """ diff --git a/source/isaaclab/test/sim/test_tendon_fragments.py b/source/isaaclab/test/sim/test_tendon_fragments.py index dcf1325b3339..53daf7b64799 100644 --- a/source/isaaclab/test/sim/test_tendon_fragments.py +++ b/source/isaaclab/test/sim/test_tendon_fragments.py @@ -15,11 +15,26 @@ 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 @@ -31,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) @@ -46,112 +59,47 @@ 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_type): - """Return the schema-declared property prefix for one applied tendon API.""" - for schema_name in prim.GetAppliedSchemas(): - applied_type, instance = Usd.SchemaRegistry.GetTypeNameAndInstance(str(schema_name)) - if applied_type == schema_type: - return f"physxTendon:{instance}" - raise AssertionError(f"no {schema_type!r} instance on {prim.GetPath()}") - - -# ------------------------------------------------------------------------------------- -# PhysxTendonAxisRootCfg writes the multi-instance namespace -# ------------------------------------------------------------------------------------- - - def test_tendon_axis_root_fragment_writes_instanced_namespace(): - from isaaclab_physx.sim.schemas import PhysxTendonAxisRootCfg - - 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() + stage = _new_sim() prim = _make_fixed_tendon_prim(stage, "/World/FT", instance="t0") 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 = _tendon_attr_prefix(prim, "PhysxTendonAxisRootAPI") + 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 assert prim.GetAttribute(f"{prefix}:lowerLimit").Get() == pytest.approx(-0.2) assert prim.GetAttribute(f"{prefix}:upperLimit").Get() == pytest.approx(0.4) -# ------------------------------------------------------------------------------------- -# Instance selection -# ------------------------------------------------------------------------------------- - - -def test_apply_fixed_tendon_selects_one_instance(): - from isaaclab_physx.sim.schemas import PhysxTendonAxisRootCfg - - 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_prim_with_schemas(stage, "/World/FTmulti", ["PhysxTendonAxisRootAPI:t0", "PhysxTendonAxisRootAPI:t1"]) - assert apply_fixed_tendon_properties( - "/World/FTmulti", [PhysxTendonAxisRootCfg(instance_names="t0", stiffness=9.0)], stage - ) - assert prim.GetAttribute("physxTendon:t0:stiffness").Get() == pytest.approx(9.0) - assert not prim.GetAttribute("physxTendon:t1:stiffness").HasAuthoredValue() - - -def test_apply_fixed_tendon_broadcasts_by_default(): - from isaaclab_physx.sim.schemas import PhysxTendonAxisRootCfg - - from isaaclab.sim.schemas import apply_fixed_tendon_properties - - stage = _new_sim() - prim = _make_prim_with_schemas( - stage, "/World/FTbroadcast", ["PhysxTendonAxisRootAPI:t0", "PhysxTendonAxisRootAPI:t1"] - ) - assert apply_fixed_tendon_properties("/World/FTbroadcast", [PhysxTendonAxisRootCfg(stiffness=9.0)], stage) - for inst in ("t0", "t1"): - assert prim.GetAttribute(f"physxTendon:{inst}:stiffness").Get() == pytest.approx(9.0) - - -def test_apply_fixed_tendon_selects_a_list_of_instances(): - from isaaclab_physx.sim.schemas import PhysxTendonAxisRootCfg - - from isaaclab.sim.schemas import apply_fixed_tendon_properties - +@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/FTlist", + "/World/FTmulti", ["PhysxTendonAxisRootAPI:t0", "PhysxTendonAxisRootAPI:t1", "PhysxTendonAxisRootAPI:t2"], ) - cfg = PhysxTendonAxisRootCfg(instance_names=["t0", "t2"], damping=0.25) - assert apply_fixed_tendon_properties("/World/FTlist", [cfg], stage) - assert prim.GetAttribute("physxTendon:t0:damping").Get() == pytest.approx(0.25) - assert not prim.GetAttribute("physxTendon:t1:damping").HasAuthoredValue() - assert prim.GetAttribute("physxTendon:t2:damping").Get() == pytest.approx(0.25) + 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_fixed_tendon_axis_fragment_targets_root_and_child_axes(): - from isaaclab_physx.sim.schemas import PhysxTendonAxisCfg - - from isaaclab.sim.schemas import apply_fixed_tendon_properties - stage = _new_sim() UsdGeom.Xform.Define(stage, "/World/Hand") root = _make_fixed_tendon_prim(stage, "/World/Hand/root", instance="index_finger") @@ -171,111 +119,49 @@ def test_fixed_tendon_axis_fragment_targets_root_and_child_axes(): assert not root.GetAttribute("physxTendon:shared_coupling:gearing").HasAuthoredValue() -def test_tendon_fragments_match_schema_ownership(): - from isaaclab_physx.sim.schemas import PhysxTendonAttachmentRootCfg, PhysxTendonAxisCfg, PhysxTendonAxisRootCfg - - from isaaclab.utils.string import to_camel_case - - address_fields = {"func", "instance_names"} - axis_root_fields = {field.name for field in dataclasses.fields(PhysxTendonAxisRootCfg)} - address_fields - axis_fields = {field.name for field in dataclasses.fields(PhysxTendonAxisCfg)} - address_fields - attachment_root_fields = {field.name for field in dataclasses.fields(PhysxTendonAttachmentRootCfg)} - address_fields - assert axis_root_fields == { - "tendon_enabled", - "stiffness", - "damping", - "limit_stiffness", - "offset", - "rest_length", - "lower_limit", - "upper_limit", - } - assert axis_fields == {"gearing", "force_coefficient", "joint_axis"} - assert attachment_root_fields == {"tendon_enabled", "stiffness", "damping", "limit_stiffness", "offset"} - assert axis_root_fields.isdisjoint(axis_fields) - - registry = Usd.SchemaRegistry() - for cfg_type, schema_type in ( +@pytest.mark.parametrize( + ("cfg_type", "schema_type"), + [ (PhysxTendonAxisRootCfg, "PhysxTendonAxisRootAPI"), (PhysxTendonAxisCfg, "PhysxTendonAxisAPI"), (PhysxTendonAttachmentRootCfg, "PhysxTendonAttachmentRootAPI"), - ): - definition = registry.FindAppliedAPIPrimDefinition(schema_type) - assert definition is not None - 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 - - -def test_apply_fixed_tendon_properties_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 PhysxTendonAxisRootCfg - - 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 core writer's subtree expression descends from the root to the joint - assert apply_fixed_tendon_properties("/World/Robot(/.*)?", [PhysxTendonAxisRootCfg(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_properties_descends_to_child_prims(): - from isaaclab_physx.sim.schemas import PhysxTendonAttachmentRootCfg - - 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 core writer's subtree expression descends from the root to the joint - cfg = PhysxTendonAttachmentRootCfg(stiffness=5.0) - assert apply_spatial_tendon_properties("/World/Robot2(/.*)?", [cfg], stage) is True - assert child.GetAttribute("physxTendon:s0:stiffness").Get() == pytest.approx(5.0) - - -def test_tendon_attachment_root_fragment_writes_instanced_namespace(): - from isaaclab_physx.sim.schemas import PhysxTendonAttachmentRootCfg - - 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/ST", instance="s0") - assert apply_spatial_tendon_properties( - "/World/ST", [PhysxTendonAttachmentRootCfg(stiffness=4.0, limit_stiffness=0.25)], 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 - - -def test_apply_spatial_tendon_selects_roots_and_skips_leaves(): - from isaaclab_physx.sim.schemas import PhysxTendonAttachmentRootCfg + ], +) +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() +def test_spatial_tendon_selects_root_instance_and_skips_leaves(): + stage = _new_sim() prim = _make_prim_with_schemas( stage, "/World/STmulti", @@ -286,18 +172,17 @@ def test_apply_spatial_tendon_selects_roots_and_skips_leaves(): ], ) assert apply_spatial_tendon_properties( - "/World/STmulti", [PhysxTendonAttachmentRootCfg(instance_names="r0", stiffness=4.0)], stage + "/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() def test_legacy_spatial_tendon_writer_uses_root_property_namespace(): - from isaaclab_physx.sim.schemas import PhysxSpatialTendonPropertiesCfg - - from isaaclab.sim.schemas import modify_spatial_tendon_properties - stage = _new_sim() prim = _make_prim_with_schemas( stage, @@ -310,186 +195,25 @@ def test_legacy_spatial_tendon_writer_uses_root_property_namespace(): assert not prim.GetAttribute("physxTendon:l0:stiffness").IsValid() -# ------------------------------------------------------------------------------------- -# apply_fixed_tendon_properties dispatch (tune-not-apply, multi-fragment) -# ------------------------------------------------------------------------------------- - - -def test_apply_fixed_tendon_properties_dispatches_fragments(): - from isaaclab_physx.sim.schemas import PhysxTendonAxisRootCfg - - 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", - [ - PhysxTendonAxisRootCfg(stiffness=5.0), - PhysxTendonAxisRootCfg(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 PhysxTendonAttachmentRootCfg - - 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", - [ - PhysxTendonAttachmentRootCfg(stiffness=6.0), - PhysxTendonAttachmentRootCfg(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 - - -# ------------------------------------------------------------------------------------- -# core writer parity: invalid-prim guard + aggregated return -# ------------------------------------------------------------------------------------- - - -def test_apply_fixed_tendon_warns_on_unmatched_path(caplog): - from isaaclab_physx.sim.schemas import PhysxTendonAxisRootCfg - - 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", [PhysxTendonAxisRootCfg(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 PhysxTendonAttachmentRootCfg - - from isaaclab.sim.schemas import apply_spatial_tendon_properties - - _new_sim() - stage = sim_utils.get_current_stage() - cfg = PhysxTendonAttachmentRootCfg(stiffness=1.0) - with caplog.at_level("WARNING"): - result = apply_spatial_tendon_properties("/World/DoesNotExist", [cfg], 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_tendon_writer_dispatches_multiple_fragments(): 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 PhysxTendonAxisRootCfg - - 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", [PhysxTendonAxisRootCfg(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 PhysxTendonAxisRootCfg - - from isaaclab.sim.schemas import apply_fixed_tendon_properties - - 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", [PhysxTendonAxisRootCfg(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_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_legacy_physx_tendon_cfg_does_not_leak_physx_only_fields_to_mujoco(): - from isaaclab_physx.sim.schemas import PhysxFixedTendonPropertiesCfg - - from isaaclab.sim.schemas import modify_fixed_tendon_properties - 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) @@ -501,47 +225,21 @@ def test_legacy_physx_tendon_cfg_does_not_leak_physx_only_fields_to_mujoco(): 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() + 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 PhysxFixedTendonPropertiesCfg, PhysxTendonAxisRootCfg - - 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(/.*)?", [PhysxTendonAxisRootCfg(limit_stiffness=30.0, damping=0.1)]) @@ -556,7 +254,7 @@ def _collect(root): 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 + rel = prim.GetPath().pathString[len(root) :] attrs[f"{rel}|{instance}:{suffix}"] = attr.Get() return attrs @@ -564,14 +262,10 @@ def _collect(root): 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 index 27a46113bab6..be7526df629f 100644 --- a/source/isaaclab/test/sim/test_tendon_fragments_codeless.py +++ b/source/isaaclab/test/sim/test_tendon_fragments_codeless.py @@ -12,7 +12,11 @@ from pxr import Plug, Usd -def _register_codeless_physx_schemas(): +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 = [ @@ -21,21 +25,9 @@ def _register_codeless_physx_schemas(): if schema_paths: registry.RegisterPlugins(schema_paths) - -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 - - _register_codeless_physx_schemas() stage = Usd.Stage.CreateInMemory() fixed = stage.DefinePrim("/Fixed", "Xform") fixed.AddAppliedSchema("PhysxTendonAxisRootAPI:index") - fixed.AddAppliedSchema("PhysxTendonAxisRootAPI:middle") spatial = stage.DefinePrim("/Spatial", "Xform") spatial.AddAppliedSchema("PhysxTendonAttachmentRootAPI:cable") @@ -50,7 +42,5 @@ def test_tendon_fragments_use_codeless_schema_names_and_types(): assert apply_spatial_tendon_properties("/Spatial", [PhysxTendonAttachmentRootCfg(stiffness=4.0)], stage) assert fixed.GetAttribute("physxTendon:index:stiffness").Get() == pytest.approx(3.0) - assert not fixed.GetAttribute("physxTendon:middle:stiffness").HasAuthoredValue() assert list(fixed.GetAttribute("physxTendon:index:gearing").Get()) == pytest.approx([-0.5]) - assert list(fixed.GetAttribute("physxTendon:index:jointAxis").Get()) == ["rotX"] assert spatial.GetAttribute("physxTendon:cable:stiffness").Get() == pytest.approx(4.0) diff --git a/source/isaaclab_physx/isaaclab_physx/sim/schemas/schemas.py b/source/isaaclab_physx/isaaclab_physx/sim/schemas/schemas.py index 8b0e534e7734..df96cbc49288 100644 --- a/source/isaaclab_physx/isaaclab_physx/sim/schemas/schemas.py +++ b/source/isaaclab_physx/isaaclab_physx/sim/schemas/schemas.py @@ -71,40 +71,23 @@ def apply_physx_joint(cfg, prim_path: str, stage: Usd.Stage | None = None) -> bo return True -def _selected_tendon_instances(prim: Usd.Prim, schema_type: str, selection) -> list[str]: - """Return existing instances of ``schema_type`` selected by a tendon fragment.""" - if selection is None: - requested = None - elif isinstance(selection, str): - if not selection: - raise ValueError("'instance_names' must contain at least one non-empty instance name.") - requested = {selection} - elif isinstance(selection, list): - if not selection or any(not isinstance(name, str) or not name for name in selection): - raise ValueError("'instance_names' must contain at least one non-empty instance name.") - requested = set(selection) - else: - raise TypeError("'instance_names' must be a string, list of strings, or None.") - - instances = [] - for applied_schema in prim.GetAppliedSchemas(): - applied_type, instance = Usd.SchemaRegistry.GetTypeNameAndInstance(str(applied_schema)) - if applied_type == schema_type and instance and (requested is None or instance in requested): - instances.append(instance) - return instances - - 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 not schema_type: - raise TypeError(f"'{type(cfg).__name__}' does not identify a USD tendon 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.") - instances = _selected_tendon_instances(prim, schema_type, cfg.instance_names) + + 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 @@ -118,19 +101,13 @@ def _tune_tendon_schema(cfg, prim_path: str, stage: Usd.Stage | None = None) -> } for instance in instances: for field in dataclasses.fields(cfg): - if field.name in ("func", "instance_names"): - continue value = getattr(cfg, field.name) - if value is None: + if field.name in ("func", "instance_names") or value is None: continue - property_name = to_camel_case(field.name, "cC") - template = templates.get(property_name) + 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) - attr = prim.GetAttribute(attr_name) - if not attr: - raise RuntimeError(f"USD schema '{schema_type}' did not expose attribute '{attr_name}'.") - if not attr.Set(value): + 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 967d96b18646..7f7b7b351c81 100644 --- a/source/isaaclab_physx/isaaclab_physx/sim/schemas/schemas_cfg.py +++ b/source/isaaclab_physx/isaaclab_physx/sim/schemas/schemas_cfg.py @@ -1276,17 +1276,11 @@ class PhysxTendonAxisRootCfg(FixedTendonFragment): .. _PhysxTendonAxisRootAPI: https://docs.omniverse.nvidia.com/kit/docs/omni_usd_schema_physics/104.2/class_physx_schema_physx_tendon_axis_root_a_p_i.html """ - _usd_namespace: ClassVar[str | None] = None _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 - """Names of existing tendon instances to tune. - - A string selects one instance, a list selects those instances, and ``None`` selects every - ``PhysxTendonAxisRootAPI`` instance on each targeted prim (the default). An empty string or list - is an error. This field addresses the schema instances and is not authored as a USD property. - """ + """Existing tendon instances to tune; ``None`` selects all root instances.""" tendon_enabled: bool | None = None """Whether to enable or disable the tendon.""" @@ -1331,17 +1325,11 @@ class PhysxTendonAxisCfg(FixedTendonFragment): .. _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_namespace: ClassVar[str | None] = None _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 - """Names of existing tendon-axis instances to tune. - - A string selects one instance, a list selects those instances, and ``None`` selects every - ``PhysxTendonAxisAPI`` instance on each targeted prim (the default). An empty string or list is - an error. This field addresses the schema instances and is not authored as a USD property. - """ + """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].""" @@ -1368,18 +1356,11 @@ class PhysxTendonAttachmentRootCfg(SpatialTendonFragment): .. _PhysxTendonAttachmentRootAPI: https://docs.omniverse.nvidia.com/kit/docs/omni_usd_schema_physics/104.2/class_physx_schema_physx_tendon_attachment_root_a_p_i.html """ - _usd_namespace: ClassVar[str | None] = None _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 - """Names of existing spatial-tendon root instances to tune. - - A string selects one instance, a list selects those instances, and ``None`` selects every - ``PhysxTendonAttachmentRootAPI`` instance on each targeted prim (the default). An empty string - or list is an error. This field addresses the schema instances and is not authored as a USD - property. - """ + """Existing spatial-tendon instances to tune; ``None`` selects all attachment roots.""" tendon_enabled: bool | None = None """Whether to enable or disable the tendon."""