diff --git a/source/isaaclab/changelog.d/vidurv-schema-frag-jointdrive.minor.rst b/source/isaaclab/changelog.d/vidurv-schema-frag-jointdrive.minor.rst new file mode 100644 index 000000000000..03873c2b0efb --- /dev/null +++ b/source/isaaclab/changelog.d/vidurv-schema-frag-jointdrive.minor.rst @@ -0,0 +1,22 @@ +Added +^^^^^ + +* Added the joint-drive schema-fragment API: the + :class:`~isaaclab.sim.schemas.JointDriveFragment` marker and + :class:`~isaaclab.sim.schemas.UsdPhysicsDriveCfg` (writing the typed multi-instance + ``UsdPhysics.DriveAPI`` attributes). The drive fragment overrides its ``func`` with + :func:`~isaaclab.sim.schemas.apply_drive`, which selects the angular/linear instance, performs + the radian-to-degree conversion for angular drives, and skips tendon child prims. +* Added :func:`~isaaclab.sim.schemas.apply_joint_drive_properties` (applies a list of joint-drive + fragments to all joint prims under a path; ``UsdPhysics.DriveAPI`` is presence-gated and applied + only when a :class:`~isaaclab.sim.schemas.UsdPhysicsDriveCfg` fragment is present). + +Changed +^^^^^^^ + +* Changed the spawner ``joint_drive_props`` slot + (:attr:`~isaaclab.sim.spawners.FileCfg.joint_drive_props`) to also accept a list of + :class:`~isaaclab.sim.schemas.JointDriveFragment` fragments. Legacy single cfgs continue to work + through a transition bridge at the from-files spawn site. Added the spawner-level + ``ensure_drives_exist`` flag to reproduce the legacy minimal-stiffness behaviour for the fragment + path. diff --git a/source/isaaclab/isaaclab/sim/__init__.pyi b/source/isaaclab/isaaclab/sim/__init__.pyi index 9578caa87b49..ddc4e4668e27 100644 --- a/source/isaaclab/isaaclab/sim/__init__.pyi +++ b/source/isaaclab/isaaclab/sim/__init__.pyi @@ -45,6 +45,7 @@ __all__ = [ "DeformableBodyPropertiesCfg", "FixedTendonPropertiesCfg", "JointDriveBaseCfg", + "JointDriveFragment", "MassPropertiesCfg", "MeshCollisionPropertiesCfg", "MujocoJointDrivePropertiesCfg", @@ -61,7 +62,10 @@ __all__ = [ "RigidBodyBaseCfg", "RigidBodyFragment", "SchemaFragment", + "UsdPhysicsDriveCfg", "UsdPhysicsRigidBodyCfg", + "apply_drive", + "apply_joint_drive_properties", "apply_namespaced", "apply_rigid_body_properties", "SDFMeshPropertiesCfg", @@ -220,6 +224,7 @@ from .schemas import ( DeformableBodyPropertiesCfg, FixedTendonPropertiesCfg, JointDriveBaseCfg, + JointDriveFragment, MassPropertiesCfg, MeshCollisionPropertiesCfg, PhysxJointDrivePropertiesCfg, @@ -231,8 +236,11 @@ from .schemas import ( SpatialTendonPropertiesCfg, TriangleMeshPropertiesCfg, TriangleMeshSimplificationPropertiesCfg, + UsdPhysicsDriveCfg, UsdPhysicsRigidBodyCfg, activate_contact_sensors, + apply_drive, + apply_joint_drive_properties, apply_namespaced, apply_rigid_body_properties, define_articulation_root_properties, diff --git a/source/isaaclab/isaaclab/sim/converters/mesh_converter.py b/source/isaaclab/isaaclab/sim/converters/mesh_converter.py index 9c25697e88f1..f814e4fdb877 100644 --- a/source/isaaclab/isaaclab/sim/converters/mesh_converter.py +++ b/source/isaaclab/isaaclab/sim/converters/mesh_converter.py @@ -185,7 +185,7 @@ def _convert_asset(self, cfg: MeshConverterCfg): # apply mass properties if cfg.mass_props is not None: schemas.define_mass_properties(prim_path=xform_prim.GetPath(), cfg=cfg.mass_props, stage=stage) - # apply rigid body properties (transition routing: fragment list -> apply_*; legacy cfg -> define_*) + # apply rigid body properties (transition shim, remove later: fragment list -> apply_*; legacy cfg -> define_*) if cfg.rigid_props is not None: rigid_frags = cfg.rigid_props if isinstance(cfg.rigid_props, (list, tuple)) else [cfg.rigid_props] if rigid_frags and all(isinstance(f, schemas.SchemaFragment) for f in rigid_frags): diff --git a/source/isaaclab/isaaclab/sim/schemas/__init__.pyi b/source/isaaclab/isaaclab/sim/schemas/__init__.pyi index af153a60fc63..87d049e2706d 100644 --- a/source/isaaclab/isaaclab/sim/schemas/__init__.pyi +++ b/source/isaaclab/isaaclab/sim/schemas/__init__.pyi @@ -8,6 +8,8 @@ __all__ = [ "PHYSX_MESH_COLLISION_CFGS", "USD_MESH_COLLISION_CFGS", "activate_contact_sensors", + "apply_drive", + "apply_joint_drive_properties", "apply_namespaced", "apply_rigid_body_properties", "define_actuator_properties", @@ -33,10 +35,12 @@ __all__ = [ "DeformableBodyPropertiesBaseCfg", "DeformableBodyPropertiesCfg", "JointDriveBaseCfg", + "JointDriveFragment", "MassPropertiesCfg", "MeshCollisionBaseCfg", "RigidBodyFragment", "SchemaFragment", + "UsdPhysicsDriveCfg", "UsdPhysicsRigidBodyCfg", "MujocoJointDrivePropertiesCfg", "MujocoRigidBodyPropertiesCfg", @@ -55,6 +59,8 @@ from .schemas import ( PHYSX_MESH_COLLISION_CFGS, USD_MESH_COLLISION_CFGS, activate_contact_sensors, + apply_drive, + apply_joint_drive_properties, apply_namespaced, apply_rigid_body_properties, define_articulation_root_properties, @@ -84,11 +90,13 @@ from .schemas_cfg import ( DeformableBodyPropertiesBaseCfg, DeformableBodyPropertiesCfg, JointDriveBaseCfg, + JointDriveFragment, MassPropertiesCfg, MeshCollisionBaseCfg, RigidBodyBaseCfg, RigidBodyFragment, SchemaFragment, + UsdPhysicsDriveCfg, UsdPhysicsRigidBodyCfg, ) diff --git a/source/isaaclab/isaaclab/sim/schemas/schemas.py b/source/isaaclab/isaaclab/sim/schemas/schemas.py index 11eca17349bc..b323c82657f8 100644 --- a/source/isaaclab/isaaclab/sim/schemas/schemas.py +++ b/source/isaaclab/isaaclab/sim/schemas/schemas.py @@ -792,6 +792,216 @@ def activate_contact_sensors(prim_path: str, threshold: float = 0.0, stage: Usd. """ +def apply_drive(cfg, prim_path: str, stage: Usd.Stage | None = None) -> bool: + """Apply a :class:`~isaaclab.sim.schemas.UsdPhysicsDriveCfg` fragment to a single joint prim. + + This is the override ``func`` for the ``UsdPhysics.DriveAPI`` fragment: the drive attributes + live under a multi-instance schema, so the generic :func:`apply_namespaced` writer cannot be + used. The writer reproduces the solver-common drive logic of + :func:`modify_joint_drive_properties`: + + * Selects the drive instance: ``"angular"`` for a revolute joint, ``"linear"`` for a prismatic + joint. For any other prim type, the function is a no-op and returns ``False``. + * Skips tendon child prims (prims carrying ``PhysxTendonAxisAPI`` without + ``PhysxTendonAxisRootAPI``), returning ``False``. + * Applies ``UsdPhysics.DriveAPI`` for the selected instance (presence-gated -- only applied when + this fragment is present). + * Converts angular-drive :attr:`stiffness` and :attr:`damping` from radians to degrees + (``N·m/rad`` -> ``N·m/deg`` and ``N·m·s/rad`` -> ``N·m·s/deg``); linear drives are written + as-is. + * Writes the typed ``drive::physics:{type,maxForce,stiffness,damping}`` attributes, + mapping the :attr:`drive_type` field to the USD attribute named ``type``. + + Args: + cfg: The :class:`~isaaclab.sim.schemas.UsdPhysicsDriveCfg` fragment to apply. + prim_path: The joint prim path to author on. + stage: The stage where to find the prim. Defaults to None, in which case the current + stage is used. + + Returns: + True if the drive was applied to a joint prim, False if the prim is not a revolute or + prismatic joint (or is a tendon child). + """ + 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.") + + # select the drive instance based on the joint type + if prim.IsA(UsdPhysics.RevoluteJoint): + drive_api_name = "angular" + elif prim.IsA(UsdPhysics.PrismaticJoint): + drive_api_name = "linear" + else: + return False + # skip tendon child prims (carry PhysxTendonAxisAPI but not the root API) + applied_schemas_str = str(prim.GetAppliedSchemas()) + if "PhysxTendonAxisAPI" in applied_schemas_str and "PhysxTendonAxisRootAPI" not in applied_schemas_str: + return False + + # apply the multi-instance drive API (presence-gated anchor for the joint-drive family) + usd_drive_api = UsdPhysics.DriveAPI(prim, drive_api_name) + if not usd_drive_api: + usd_drive_api = UsdPhysics.DriveAPI.Apply(prim, drive_api_name) + + # gather the solver-common drive fields + drive_type = cfg.drive_type + max_force = cfg.max_force + stiffness = cfg.stiffness + damping = cfg.damping + + # angular drives use degree units in USD; convert stiffness/damping from radian units + is_linear_drive = prim.IsA(UsdPhysics.PrismaticJoint) + if not is_linear_drive: + if stiffness is not None: + # N-m/rad --> N-m/deg + stiffness = stiffness * math.pi / 180.0 + if damping is not None: + # N-m-s/rad --> N-m-s/deg + damping = damping * math.pi / 180.0 + + # ``drive_type`` is a permanent inline carve-out: the USD attribute is named ``type`` + # (a Python keyword-like name we cannot use as a cfg field). All other solver-common + # joint-drive fields follow the snake_case = camelCase convention. + for field_name, value in ( + ("drive_type", drive_type), + ("max_force", max_force), + ("stiffness", stiffness), + ("damping", damping), + ): + if value is None: + continue + usd_attr_name = "type" if field_name == "drive_type" else field_name + safe_set_attribute_on_usd_schema(usd_drive_api, usd_attr_name, value, camel_case=True) + + return True + + +def apply_joint_drive_properties( + prim_path: str, fragments, stage: Usd.Stage | None = None, ensure_drives_exist: bool = False +) -> bool: + """Apply a list of joint-drive fragments to all joint prims under a prim path. + + Mirrors the recursion behaviour of :func:`modify_joint_drive_properties` (decorated with + :func:`~isaaclab.sim.utils.apply_nested`): the prim path and all its descendants are visited, + and the fragments are dispatched to every revolute/prismatic joint prim found. As soon as a + prim is successfully handled, its children are not descended into (nested joints are not + allowed). + + Unlike :func:`apply_rigid_body_properties`, the joint-drive family has no implicit anchor: + ``UsdPhysics.DriveAPI`` is *presence-gated* and applied only by :func:`apply_drive` when a + :class:`~isaaclab.sim.schemas.UsdPhysicsDriveCfg` fragment is present in ``fragments``. Each + fragment is dispatched via its :attr:`~isaaclab.sim.schemas.SchemaFragment.func`, so backend + fragments carry backend-specific funcs and core never imports a backend. + + Args: + prim_path: The root prim path to search for joint prims under. + fragments: An iterable of :class:`~isaaclab.sim.schemas.JointDriveFragment` instances. + stage: The stage where to find the prim. Defaults to None, in which case the current + stage is used. + ensure_drives_exist: If True, write a minimal stiffness (``1e-3``) to any drive whose + authored stiffness *and* damping are both zero, so that backends (e.g. Newton) treat + the drive as active. Reproduces the legacy + :attr:`~isaaclab.sim.schemas.JointDriveBaseCfg.ensure_drives_exist` behaviour. This is + a spawner-level flag, not a fragment field. + + Returns: + True if the fragments were applied to at least one joint prim, False otherwise. + """ + if stage is None: + stage = get_current_stage() + + fragments = list(fragments) + # detect whether a UsdPhysics.DriveAPI fragment is present (governs presence-gating + the + # ensure_drives_exist behaviour, which only makes sense for the solver-common drive fragment) + drive_cfg = next((f for f in fragments if isinstance(f, schemas_cfg.UsdPhysicsDriveCfg)), None) + + root_prim = stage.GetPrimAtPath(prim_path) + if not root_prim.IsValid(): + raise ValueError(f"Prim path '{prim_path}' is not valid.") + + count_success = 0 + instanced_prim_paths = [] + all_prims = [root_prim] + while len(all_prims) > 0: + child_prim = all_prims.pop(0) + child_prim_path = child_prim.GetPath().pathString + # skip instanced prims (cannot author on prototypes) + if child_prim.IsInstance(): + instanced_prim_paths.append(child_prim_path) + continue + # a prim is a valid joint-drive target only if it is a revolute/prismatic joint + is_joint = child_prim.IsA(UsdPhysics.RevoluteJoint) or child_prim.IsA(UsdPhysics.PrismaticJoint) + if not is_joint: + all_prims += child_prim.GetChildren() + continue + # skip tendon-child joints (PhysxTendonAxisAPI without the root API) wholesale: no fragment + # may author on them, matching the legacy modify_joint_drive_properties writer. apply_drive + # guards itself too, but the physxJoint/mjc fragments would otherwise leak onto them. + applied_schemas_str = str(child_prim.GetAppliedSchemas()) + if "PhysxTendonAxisAPI" in applied_schemas_str and "PhysxTendonAxisRootAPI" not in applied_schemas_str: + all_prims += child_prim.GetChildren() + continue + # dispatch each fragment via its func + success = False + for cfg in fragments: + func = cfg.func if callable(cfg.func) else string_to_callable(cfg.func) + if func(cfg, child_prim_path, stage): + success = True + # seed a minimal stiffness only when the drive fragment authored neither stiffness nor + # damping and the resulting drive is fully passive + if ensure_drives_exist and drive_cfg is not None and success: + _ensure_drive_exists(drive_cfg, child_prim) + if success: + count_success += 1 + else: + all_prims += child_prim.GetChildren() + + if count_success == 0: + logger.warning( + f"Could not apply joint-drive properties on any prims under: '{prim_path}'." + " This might be because no revolute/prismatic joint prims were found, or they are" + f" instanced. Discovered list of instanced prim paths: {instanced_prim_paths}" + ) + return count_success > 0 + + +def _ensure_drive_exists(drive_cfg, prim) -> None: + """Seed a minimal stiffness on a fully-passive drive so backends treat it as active. + + Reproduces the legacy ``ensure_drives_exist`` behaviour: if the drive fragment authored + neither :attr:`stiffness` nor :attr:`damping` and the authored drive currently has zero + (or unset) stiffness *and* damping, write a minimal stiffness of ``1e-3`` directly to the + drive API (converted to degree units for angular drives, matching :func:`apply_drive`). The + fragment is not mutated, so this is safe across multiple joint prims sharing one fragment. + + Args: + drive_cfg: The :class:`~isaaclab.sim.schemas.UsdPhysicsDriveCfg` fragment. + prim: The joint prim being authored. + """ + if drive_cfg.stiffness is not None or drive_cfg.damping is not None: + return + if prim.IsA(UsdPhysics.RevoluteJoint): + drive_api_name = "angular" + elif prim.IsA(UsdPhysics.PrismaticJoint): + drive_api_name = "linear" + else: + return + usd_drive_api = UsdPhysics.DriveAPI(prim, drive_api_name) + if not usd_drive_api: + usd_drive_api = UsdPhysics.DriveAPI.Apply(prim, drive_api_name) + cur_stiffness = usd_drive_api.GetStiffnessAttr().Get() + cur_damping = usd_drive_api.GetDampingAttr().Get() + if (cur_stiffness is None or cur_stiffness == 0.0) and (cur_damping is None or cur_damping == 0.0): + # mirror the legacy writer: 1e-3 is set before the rad->deg conversion, so an angular + # drive ends up with ``1e-3 * pi / 180``. + stiffness = 1e-3 + if drive_api_name == "angular": + stiffness = stiffness * math.pi / 180.0 + safe_set_attribute_on_usd_schema(usd_drive_api, "stiffness", stiffness, camel_case=True) + + @apply_nested def modify_joint_drive_properties( prim_path: str, cfg: schemas_cfg.JointDriveBaseCfg, stage: Usd.Stage | None = None diff --git a/source/isaaclab/isaaclab/sim/schemas/schemas_cfg.py b/source/isaaclab/isaaclab/sim/schemas/schemas_cfg.py index bbd29df6b652..a09e01065377 100644 --- a/source/isaaclab/isaaclab/sim/schemas/schemas_cfg.py +++ b/source/isaaclab/isaaclab/sim/schemas/schemas_cfg.py @@ -167,6 +167,94 @@ class UsdPhysicsRigidBodyCfg(RigidBodyFragment): """ +@configclass +class JointDriveFragment(SchemaFragment): + """Marker base for joint-drive fragments; types the ``joint_drive_props`` slot.""" + + pass + + +@configclass +class UsdPhysicsDriveCfg(JointDriveFragment): + """``drive::physics:*`` joint-drive attributes from `UsdPhysics.DriveAPI`_. + + The drive attributes live under a multi-instance ``UsdPhysics.DriveAPI`` (instance + ``"angular"`` for revolute joints, ``"linear"`` for prismatic joints), so this fragment + cannot use the generic :func:`~isaaclab.sim.schemas.apply_namespaced` writer. It overrides + :attr:`func` with :func:`~isaaclab.sim.schemas.apply_drive`, which selects the instance, + applies ``UsdPhysics.DriveAPI`` (presence-gated, the conditional anchor for the joint-drive + family), performs the radian-to-degree conversion for angular drives, and writes the typed + ``drive::physics:{type,maxForce,stiffness,damping}`` attributes. + + .. note:: + Unlike most fragments, this one is not a metadata-driven write. ``DriveAPI`` is applied + only when this fragment is present in the slot. + + .. _UsdPhysics.DriveAPI: https://openusd.org/dev/api/class_usd_physics_drive_a_p_i.html + """ + + # No metadata-driven namespace: the typed multi-instance ``UsdPhysics.DriveAPI`` is written + # directly by ``apply_drive``. ``DriveAPI`` is presence-gated, not an implicit anchor. + _usd_namespace: ClassVar[str | None] = None + _usd_applied_schema: ClassVar[str | None] = None + + func: Callable | str = "isaaclab.sim.schemas:apply_drive" + + def __post_init__(self): + # Deprecation alias: ``max_effort`` -> ``max_force`` (the USD attr is ``maxForce``). + # Mirrors the legacy :class:`JointDriveBaseCfg` alias forwarding. + _deprecate_field_alias(self, "max_effort", "max_force") + + drive_type: Literal["force", "acceleration"] | None = None + """Joint drive type to apply. + + If the drive type is ``"force"``, then the joint is driven by a force. If the drive type is + ``"acceleration"``, then the joint is driven by an acceleration (usually used for kinematic + joints). Written to ``drive::physics:type`` (the USD attr is ``type``, a permanent + inline carve-out from the snake-to-camel convention). + """ + + max_force: float | None = None + """Maximum force/torque that can be applied to the joint [N for linear joints, N·m for angular joints]. + + Written to ``drive::physics:maxForce`` via :class:`UsdPhysics.DriveAPI`. + """ + + max_effort: float | None = None + """Deprecated alias for :attr:`max_force`. + + .. deprecated:: 4.6.25 + Use :attr:`max_force` instead. The cfg field is renamed so its snake_case name maps + identity-style to the USD camelCase attribute (``maxForce`` on ``UsdPhysics.DriveAPI``). + The alias is forwarded to :attr:`max_force` in :meth:`__post_init__` and will be removed + in 4.0. + """ + + stiffness: float | None = None + """Stiffness of the joint drive. + + The unit depends on the joint model: + + * For linear joints, the unit is kg-m/s² (N/m). + * For angular joints, the unit is kg-m²/s²/rad (N·m/rad). + + Angular drives are converted from radians to degrees (``N·m/rad`` -> ``N·m/deg``) before + being written to ``drive:angular:physics:stiffness``. + """ + + damping: float | None = None + """Damping of the joint drive. + + The unit depends on the joint model: + + * For linear joints, the unit is kg-m/s (N·s/m). + * For angular joints, the unit is kg-m²/s/rad (N·m·s/rad). + + Angular drives are converted from radians to degrees (``N·m·s/rad`` -> ``N·m·s/deg``) before + being written to ``drive:angular:physics:damping``. + """ + + @configclass class ArticulationRootBaseCfg: """Solver-common properties to apply to the root of an articulation. diff --git a/source/isaaclab/isaaclab/sim/spawners/from_files/from_files.py b/source/isaaclab/isaaclab/sim/spawners/from_files/from_files.py index a2cf6b684c17..77627c122e8b 100644 --- a/source/isaaclab/isaaclab/sim/spawners/from_files/from_files.py +++ b/source/isaaclab/isaaclab/sim/spawners/from_files/from_files.py @@ -344,7 +344,7 @@ def _spawn_from_usd_file( # modify rigid body properties if cfg.rigid_props is not None: - # transition routing: new fragment list -> apply_*; legacy single cfg -> modify_* + # transition shim, remove later: new fragment list -> apply_*; legacy single cfg -> modify_* rigid_frags = cfg.rigid_props if isinstance(cfg.rigid_props, (list, tuple)) else [cfg.rigid_props] if rigid_frags and all(isinstance(f, schemas.SchemaFragment) for f in rigid_frags): schemas.apply_rigid_body_properties(prim_path, rigid_frags) @@ -374,11 +374,19 @@ def _spawn_from_usd_file( # Only auto-populates when the user did not already set ``gravcomp`` themselves; # an explicit ``MujocoRigidBodyPropertiesCfg(gravcomp=0.5)`` is preserved as-is. from isaaclab_newton.sim.schemas.schemas_cfg import ( + MujocoJointCfg, MujocoJointDrivePropertiesCfg, MujocoRigidBodyCfg, MujocoRigidBodyPropertiesCfg, ) + # transition shim, remove later: a fragment list -> apply_joint_drive_properties; a legacy single cfg + # -> modify_joint_drive_properties. + joint_frags = ( + cfg.joint_drive_props if isinstance(cfg.joint_drive_props, (list, tuple)) else [cfg.joint_drive_props] + ) + is_fragment_path = bool(joint_frags) and all(isinstance(f, schemas.SchemaFragment) for f in joint_frags) + # gravcomp may be authored either via the legacy MujocoRigidBodyPropertiesCfg or via a # MujocoRigidBodyCfg fragment in a rigid_props list. Treat either as "already set". rigid_props_list = cfg.rigid_props if isinstance(cfg.rigid_props, (list, tuple)) else [cfg.rigid_props] @@ -386,17 +394,22 @@ def _spawn_from_usd_file( isinstance(f, (MujocoRigidBodyPropertiesCfg, MujocoRigidBodyCfg)) and f.gravcomp is not None for f in rigid_props_list ) - if ( - isinstance(cfg.joint_drive_props, MujocoJointDrivePropertiesCfg) - and cfg.joint_drive_props.actuatorgravcomp - and body_gravcomp_unset - ): + # joint-level actuatorgravcomp may be requested via the legacy MujocoJointDrivePropertiesCfg + # or via a MujocoJointCfg fragment in a joint-drive list. + actuatorgravcomp_requested = any( + isinstance(f, (MujocoJointDrivePropertiesCfg, MujocoJointCfg)) and f.actuatorgravcomp for f in joint_frags + ) + if actuatorgravcomp_requested and body_gravcomp_unset: logger.info( "Joint-level actuator gravity compensation requires body-level gravcomp." " Auto-setting MujocoRigidBodyPropertiesCfg(gravcomp=1.0)." ) schemas.modify_rigid_body_properties(prim_path, MujocoRigidBodyPropertiesCfg(gravcomp=1.0)) - schemas.modify_joint_drive_properties(prim_path, cfg.joint_drive_props) + + if is_fragment_path: + schemas.apply_joint_drive_properties(prim_path, joint_frags, ensure_drives_exist=cfg.ensure_drives_exist) + else: + schemas.modify_joint_drive_properties(prim_path, cfg.joint_drive_props) # define deformable body properties, or modify if deformable body API is present (PhysX only) if cfg.deformable_props is not None: diff --git a/source/isaaclab/isaaclab/sim/spawners/from_files/from_files_cfg.py b/source/isaaclab/isaaclab/sim/spawners/from_files/from_files_cfg.py index 22857f8d45b1..f32d674dd861 100644 --- a/source/isaaclab/isaaclab/sim/spawners/from_files/from_files_cfg.py +++ b/source/isaaclab/isaaclab/sim/spawners/from_files/from_files_cfg.py @@ -43,9 +43,18 @@ class FileCfg(RigidObjectSpawnerCfg, DeformableObjectSpawnerCfg): spatial_tendons_props: schemas.SpatialTendonPropertiesCfg | None = None """Properties to apply to the spatial tendons (if any).""" - joint_drive_props: schemas.JointDriveBaseCfg | None = None + joint_drive_props: ( + schemas.JointDriveBaseCfg | schemas.JointDriveFragment | list[schemas.JointDriveFragment] | None + ) = None """Properties to apply to a joint. + Accepts either a single legacy cfg (e.g. :class:`~isaaclab.sim.schemas.JointDriveBaseCfg`) or a + list of :class:`~isaaclab.sim.schemas.JointDriveFragment` fragments + (e.g. ``[UsdPhysicsDriveCfg(...), PhysxJointCfg(...)]``). When a fragment list is given, + ``UsdPhysics.DriveAPI`` is applied (presence-gated) only when a + :class:`~isaaclab.sim.schemas.UsdPhysicsDriveCfg` fragment is present, and each fragment writes + its own namespace. + .. note:: The joint drive properties set the USD attributes of all the joint drives in the asset. We recommend using this attribute sparingly and only when necessary. Instead, please use the @@ -53,6 +62,17 @@ class FileCfg(RigidObjectSpawnerCfg, DeformableObjectSpawnerCfg): for specific joints in an articulation. """ + ensure_drives_exist: bool = False + """Whether to ensure every joint drive is active when authoring :attr:`joint_drive_props`. + + When True, any joint drive whose authored stiffness *and* damping are both zero is given a + minimal stiffness (``1e-3``) so that backends (e.g. Newton) create proper actuators for it. + This is a spawner-level behavior flag (not a USD attribute and not a fragment field). It is + only consumed when :attr:`joint_drive_props` is given as a fragment list; legacy + :class:`~isaaclab.sim.schemas.JointDriveBaseCfg` cfgs carry their own + ``ensure_drives_exist`` field. + """ + visual_material_path: str = "material" """Path to the visual material to use for the prim. Defaults to "material". diff --git a/source/isaaclab/isaaclab/sim/spawners/meshes/meshes.py b/source/isaaclab/isaaclab/sim/spawners/meshes/meshes.py index d8eaffa5593a..27f815343c0a 100644 --- a/source/isaaclab/isaaclab/sim/spawners/meshes/meshes.py +++ b/source/isaaclab/isaaclab/sim/spawners/meshes/meshes.py @@ -444,7 +444,7 @@ def _spawn_mesh_geom_from_mesh( # apply mass properties if cfg.mass_props is not None: schemas.define_mass_properties(prim_path, cfg.mass_props, stage=stage) - # apply rigid properties (transition routing: fragment list -> apply_*; legacy cfg -> define_*) + # apply rigid properties (transition shim, remove later: fragment list -> apply_*; legacy cfg -> define_*) rigid_frags = cfg.rigid_props if isinstance(cfg.rigid_props, (list, tuple)) else [cfg.rigid_props] if rigid_frags and all(isinstance(f, schemas.SchemaFragment) for f in rigid_frags): schemas.apply_rigid_body_properties(prim_path, rigid_frags, stage=stage) diff --git a/source/isaaclab/isaaclab/sim/spawners/shapes/shapes.py b/source/isaaclab/isaaclab/sim/spawners/shapes/shapes.py index d4af64a14677..0601cbed5411 100644 --- a/source/isaaclab/isaaclab/sim/spawners/shapes/shapes.py +++ b/source/isaaclab/isaaclab/sim/spawners/shapes/shapes.py @@ -322,7 +322,7 @@ def _spawn_geom_from_prim_type( schemas.define_mass_properties(prim_path, cfg.mass_props, stage=stage) # apply rigid body properties if cfg.rigid_props is not None: - # transition routing: new fragment list -> apply_*; legacy single cfg -> define_* + # transition shim, remove later: new fragment list -> apply_*; legacy single cfg -> define_* rigid_frags = cfg.rigid_props if isinstance(cfg.rigid_props, (list, tuple)) else [cfg.rigid_props] if rigid_frags and all(isinstance(f, schemas.SchemaFragment) for f in rigid_frags): schemas.apply_rigid_body_properties(prim_path, rigid_frags, stage=stage) diff --git a/source/isaaclab/test/sim/test_joint_drive_fragments.py b/source/isaaclab/test/sim/test_joint_drive_fragments.py new file mode 100644 index 000000000000..eec8b5f2cee0 --- /dev/null +++ b/source/isaaclab/test/sim/test_joint_drive_fragments.py @@ -0,0 +1,283 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Launch Isaac Sim Simulator first.""" + +from isaaclab.app import AppLauncher + +# launch omniverse app +simulation_app = AppLauncher(headless=True).app + +"""Rest everything follows.""" + +import math + +import pytest + +from pxr import UsdGeom, UsdPhysics + +import isaaclab.sim as sim_utils +from isaaclab.sim import SimulationCfg, SimulationContext + + +def _make_revolute_joint(stage, path="/World/Articulation/joint_0"): + UsdGeom.Xform.Define(stage, "/World/Articulation") + UsdGeom.Cube.Define(stage, "/World/Articulation/body0") + UsdGeom.Cube.Define(stage, "/World/Articulation/body1") + UsdPhysics.RevoluteJoint.Define(stage, path) + return stage.GetPrimAtPath(path) + + +def _make_prismatic_joint(stage, path="/World/Articulation/joint_p"): + UsdGeom.Xform.Define(stage, "/World/Articulation") + UsdGeom.Cube.Define(stage, "/World/Articulation/body0") + UsdGeom.Cube.Define(stage, "/World/Articulation/body1") + UsdPhysics.PrismaticJoint.Define(stage, path) + return stage.GetPrimAtPath(path) + + +# ------------------------------------------------------------------------------------- +# Fragment metadata +# ------------------------------------------------------------------------------------- + + +def test_drive_fragment_metadata_defaults(): + from isaaclab.sim.schemas import JointDriveFragment, SchemaFragment, UsdPhysicsDriveCfg + + cfg = UsdPhysicsDriveCfg(drive_type="acceleration", max_force=80.0, stiffness=10.0, damping=0.1) + assert isinstance(cfg, JointDriveFragment) and isinstance(cfg, SchemaFragment) + assert type(cfg)._usd_namespace is None # typed multi-instance DriveAPI, no namespace writes + assert type(cfg)._usd_applied_schema is None # DriveAPI applied by apply_drive (presence-gated) + assert cfg.func == "isaaclab.sim.schemas:apply_drive" + assert cfg.stiffness == 10.0 and cfg.damping == 0.1 + + +def test_drive_fragment_max_effort_alias(): + from isaaclab.sim.schemas import UsdPhysicsDriveCfg + + with pytest.warns(DeprecationWarning, match="max_effort"): + cfg = UsdPhysicsDriveCfg(max_effort=42.0) + assert cfg.max_force == 42.0 + assert cfg.max_effort is None + + +# ------------------------------------------------------------------------------------- +# apply_drive -- revolute (angular) rad->deg conversion +# ------------------------------------------------------------------------------------- + + +def test_apply_drive_revolute_converts_rad_to_deg(): + from isaaclab.sim.schemas import UsdPhysicsDriveCfg, apply_drive + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + prim = _make_revolute_joint(stage) + assert apply_drive( + UsdPhysicsDriveCfg(drive_type="acceleration", max_force=80.0, stiffness=10.0, damping=0.1), + prim.GetPath().pathString, + stage, + ) + assert bool(UsdPhysics.DriveAPI(prim, "angular")) + assert prim.GetAttribute("drive:angular:physics:type").Get() == "acceleration" + assert prim.GetAttribute("drive:angular:physics:maxForce").Get() == pytest.approx(80.0, rel=1e-6) + # angular stiffness/damping are converted from radian to degree units + assert prim.GetAttribute("drive:angular:physics:stiffness").Get() == pytest.approx(10.0 * math.pi / 180.0, rel=1e-6) + assert prim.GetAttribute("drive:angular:physics:damping").Get() == pytest.approx(0.1 * math.pi / 180.0, rel=1e-6) + + +# ------------------------------------------------------------------------------------- +# apply_drive -- prismatic (linear) no conversion +# ------------------------------------------------------------------------------------- + + +def test_apply_drive_prismatic_writes_linear_unchanged(): + from isaaclab.sim.schemas import UsdPhysicsDriveCfg, apply_drive + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + prim = _make_prismatic_joint(stage) + assert apply_drive( + UsdPhysicsDriveCfg(drive_type="force", max_force=42.0, stiffness=10.0, damping=0.1), + prim.GetPath().pathString, + stage, + ) + assert bool(UsdPhysics.DriveAPI(prim, "linear")) + assert prim.GetAttribute("drive:linear:physics:type").Get() == "force" + assert prim.GetAttribute("drive:linear:physics:maxForce").Get() == pytest.approx(42.0, rel=1e-6) + # linear drives are written as authored (no rad->deg conversion) + assert prim.GetAttribute("drive:linear:physics:stiffness").Get() == pytest.approx(10.0, rel=1e-6) + assert prim.GetAttribute("drive:linear:physics:damping").Get() == pytest.approx(0.1, rel=1e-6) + + +def test_apply_drive_returns_false_on_non_joint(): + from isaaclab.sim.schemas import UsdPhysicsDriveCfg, apply_drive + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + UsdGeom.Xform.Define(stage, "/World/NotAJoint") + assert apply_drive(UsdPhysicsDriveCfg(stiffness=1.0), "/World/NotAJoint", stage) is False + + +# ------------------------------------------------------------------------------------- +# PhysxJointCfg (isaaclab_physx) -- physxJoint namespace via apply_namespaced +# ------------------------------------------------------------------------------------- + + +def test_physx_joint_fragment_converts_max_velocity_by_joint_type(): + from isaaclab_physx.sim.schemas import PhysxJointCfg, apply_physx_joint + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + # angular (revolute) joint: rad/s -> deg/s conversion + rev = _make_revolute_joint(stage) + apply_physx_joint(PhysxJointCfg(max_joint_velocity=10.0), rev.GetPath().pathString, stage) + assert rev.GetAttribute("physxJoint:maxJointVelocity").Get() == pytest.approx(10.0 * 180.0 / math.pi, rel=1e-6) + # linear (prismatic) joint: written unchanged + prismatic = _make_prismatic_joint(stage) + apply_physx_joint(PhysxJointCfg(max_joint_velocity=10.0), prismatic.GetPath().pathString, stage) + assert prismatic.GetAttribute("physxJoint:maxJointVelocity").Get() == pytest.approx(10.0, rel=1e-6) + + +def test_physx_joint_fragment_max_velocity_alias(): + from isaaclab_physx.sim.schemas import PhysxJointCfg + + with pytest.warns(DeprecationWarning, match="max_velocity"): + cfg = PhysxJointCfg(max_velocity=10.0) + assert cfg.max_joint_velocity == 10.0 + assert cfg.max_velocity is None + + +# ------------------------------------------------------------------------------------- +# MujocoJointCfg (isaaclab_newton) -- mjc namespace via apply_namespaced +# ------------------------------------------------------------------------------------- + + +def test_mujoco_joint_fragment_writes_mjc_namespace(): + from isaaclab_newton.sim.schemas import MujocoJointCfg + + from isaaclab.sim.schemas import apply_namespaced + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + prim = _make_revolute_joint(stage) + apply_namespaced(MujocoJointCfg(actuatorgravcomp=True), prim.GetPath().pathString, stage) + assert prim.GetAttribute("mjc:actuatorgravcomp").Get() is True + + +# ------------------------------------------------------------------------------------- +# apply_joint_drive_properties dispatch (presence-gated DriveAPI + multi-namespace) +# ------------------------------------------------------------------------------------- + + +def test_apply_joint_drive_properties_composes_namespaces(): + from isaaclab_newton.sim.schemas import MujocoJointCfg + from isaaclab_physx.sim.schemas import PhysxJointCfg + + from isaaclab.sim.schemas import UsdPhysicsDriveCfg, apply_joint_drive_properties + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + prim = _make_revolute_joint(stage) + apply_joint_drive_properties( + "/World/Articulation", + [ + UsdPhysicsDriveCfg(drive_type="acceleration", max_force=80.0, stiffness=10.0, damping=0.1), + PhysxJointCfg(max_joint_velocity=5.0), + MujocoJointCfg(actuatorgravcomp=True), + ], + stage, + ) + assert bool(UsdPhysics.DriveAPI(prim, "angular")) # presence-gated anchor applied + assert prim.GetAttribute("drive:angular:physics:maxForce").Get() == pytest.approx(80.0, rel=1e-6) + assert prim.GetAttribute("drive:angular:physics:stiffness").Get() == pytest.approx(10.0 * math.pi / 180.0, rel=1e-6) + # revolute joint -> rad/s to deg/s conversion via apply_physx_joint + assert prim.GetAttribute("physxJoint:maxJointVelocity").Get() == pytest.approx(5.0 * 180.0 / math.pi, rel=1e-6) + assert prim.GetAttribute("mjc:actuatorgravcomp").Get() is True + + +def test_apply_joint_drive_properties_without_drive_does_not_apply_drive_api(): + from isaaclab_physx.sim.schemas import PhysxJointCfg + + from isaaclab.sim.schemas import apply_joint_drive_properties + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + prim = _make_revolute_joint(stage) + apply_joint_drive_properties("/World/Articulation", [PhysxJointCfg(max_joint_velocity=5.0)], stage) + # DriveAPI is presence-gated: not applied when no UsdPhysicsDriveCfg fragment is present + assert not bool(UsdPhysics.DriveAPI(prim, "angular")) + # revolute joint -> rad/s to deg/s conversion via apply_physx_joint + assert prim.GetAttribute("physxJoint:maxJointVelocity").Get() == pytest.approx(5.0 * 180.0 / math.pi, rel=1e-6) + + +def test_apply_joint_drive_properties_skips_tendon_child_joint(): + """A tendon-child joint (``PhysxTendonAxisAPI`` without the root API) must be skipped wholesale + by the dispatch loop: no fragment -- drive, physxJoint, or mjc -- may author on it, matching the + legacy :func:`modify_joint_drive_properties` writer (which skipped the whole prim).""" + from isaaclab_physx.sim.schemas import PhysxJointCfg + + from pxr import PhysxSchema + + from isaaclab.sim.schemas import UsdPhysicsDriveCfg, apply_joint_drive_properties + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + joint = _make_revolute_joint(stage) + PhysxSchema.PhysxTendonAxisAPI.Apply(joint, "axis0") # tendon child: axis API, no root API + applied = str(joint.GetAppliedSchemas()) + assert "PhysxTendonAxisAPI" in applied and "PhysxTendonAxisRootAPI" not in applied + + apply_joint_drive_properties( + "/World/Articulation", + [ + UsdPhysicsDriveCfg(drive_type="acceleration", max_force=80.0, stiffness=10.0, damping=0.1), + PhysxJointCfg(max_joint_velocity=5.0), + ], + stage, + ) + # neither the presence-gated DriveAPI nor the physxJoint fragment may author on a tendon child + assert not bool(UsdPhysics.DriveAPI(joint, "angular")) + assert not joint.GetAttribute("physxJoint:maxJointVelocity").HasAuthoredValue() + + +def test_apply_joint_drive_properties_ensure_drives_exist_seeds_stiffness(): + from isaaclab.sim.schemas import UsdPhysicsDriveCfg, apply_joint_drive_properties + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + prim = _make_revolute_joint(stage) + # neither stiffness nor damping authored -> ensure_drives_exist seeds a minimal stiffness + apply_joint_drive_properties( + "/World/Articulation", [UsdPhysicsDriveCfg(max_force=1.0)], stage, ensure_drives_exist=True + ) + assert bool(UsdPhysics.DriveAPI(prim, "angular")) + assert prim.GetAttribute("drive:angular:physics:stiffness").Get() == pytest.approx(1e-3 * math.pi / 180.0, rel=1e-6) + + +# ------------------------------------------------------------------------------------- +# public imports +# ------------------------------------------------------------------------------------- + + +def test_public_imports(): + from isaaclab_newton.sim.schemas import MujocoJointCfg # noqa: F401 + from isaaclab_physx.sim.schemas import PhysxJointCfg # noqa: F401 + + from isaaclab.sim.schemas import ( # noqa: F401 + JointDriveFragment, + SchemaFragment, + UsdPhysicsDriveCfg, + apply_drive, + apply_joint_drive_properties, + ) diff --git a/source/isaaclab_newton/changelog.d/vidurv-schema-frag-jointdrive.minor.rst b/source/isaaclab_newton/changelog.d/vidurv-schema-frag-jointdrive.minor.rst new file mode 100644 index 000000000000..52ff628dcbdf --- /dev/null +++ b/source/isaaclab_newton/changelog.d/vidurv-schema-frag-jointdrive.minor.rst @@ -0,0 +1,8 @@ +Added +^^^^^ + +* Added the :class:`~isaaclab_newton.sim.schemas.MujocoJointCfg` joint-drive fragment + (``mjc:*`` / ``MjcJointAPI``), carrying joint-level ``actuatorgravcomp``. Applied alongside + :class:`~isaaclab.sim.schemas.UsdPhysicsDriveCfg` via + :func:`~isaaclab.sim.schemas.apply_joint_drive_properties`. The from-files spawn site continues + to auto-enable body-level gravcomp for the fragment path. diff --git a/source/isaaclab_newton/isaaclab_newton/sim/schemas/__init__.pyi b/source/isaaclab_newton/isaaclab_newton/sim/schemas/__init__.pyi index 89ca2d068afb..60a5eb3b688c 100644 --- a/source/isaaclab_newton/isaaclab_newton/sim/schemas/__init__.pyi +++ b/source/isaaclab_newton/isaaclab_newton/sim/schemas/__init__.pyi @@ -4,6 +4,7 @@ # SPDX-License-Identifier: BSD-3-Clause __all__ = [ + "MujocoJointCfg", "MujocoJointDrivePropertiesCfg", "MujocoRigidBodyCfg", "MujocoRigidBodyPropertiesCfg", @@ -18,6 +19,7 @@ __all__ = [ ] from .schemas_cfg import ( + MujocoJointCfg, MujocoJointDrivePropertiesCfg, MujocoRigidBodyCfg, MujocoRigidBodyPropertiesCfg, diff --git a/source/isaaclab_newton/isaaclab_newton/sim/schemas/schemas_cfg.py b/source/isaaclab_newton/isaaclab_newton/sim/schemas/schemas_cfg.py index 4c99379e1ad8..a2280a558050 100644 --- a/source/isaaclab_newton/isaaclab_newton/sim/schemas/schemas_cfg.py +++ b/source/isaaclab_newton/isaaclab_newton/sim/schemas/schemas_cfg.py @@ -12,6 +12,7 @@ CollisionBaseCfg, DeformableBodyPropertiesBaseCfg, JointDriveBaseCfg, + JointDriveFragment, MeshCollisionBaseCfg, RigidBodyBaseCfg, RigidBodyFragment, @@ -110,6 +111,29 @@ class MujocoRigidBodyCfg(RigidBodyFragment): """ +@configclass +class MujocoJointCfg(JointDriveFragment): + """``mjc:*`` joint attributes for Newton's MuJoCo solver from ``MjcJointAPI``. + + A single-namespace fragment (see :class:`~isaaclab.sim.schemas.SchemaFragment`) carrying + joint-level gravity compensation. Applied alongside + :class:`~isaaclab.sim.schemas.UsdPhysicsDriveCfg` via + :func:`~isaaclab.sim.schemas.apply_joint_drive_properties` and written with the generic + :func:`~isaaclab.sim.schemas.apply_namespaced` writer. + """ + + _usd_namespace: ClassVar[str | None] = "mjc" + _usd_applied_schema: ClassVar[str | None] = "MjcJointAPI" + + actuatorgravcomp: bool | None = None + """Route gravity compensation forces through the actuator channel. + + When ``True``, compensation forces go to ``qfrc_actuator`` (subject to force limits). + Requires body-level :attr:`MujocoRigidBodyCfg.gravcomp`. Written to ``mjc:actuatorgravcomp`` + via ``MjcJointAPI``. + """ + + @configclass class NewtonJointDrivePropertiesCfg(JointDriveBaseCfg): """Newton-targeted joint drive properties. diff --git a/source/isaaclab_physx/changelog.d/vidurv-schema-frag-jointdrive.minor.rst b/source/isaaclab_physx/changelog.d/vidurv-schema-frag-jointdrive.minor.rst new file mode 100644 index 000000000000..93ed43935d82 --- /dev/null +++ b/source/isaaclab_physx/changelog.d/vidurv-schema-frag-jointdrive.minor.rst @@ -0,0 +1,11 @@ +Added +^^^^^ + +* Added the :class:`~isaaclab_physx.sim.schemas.PhysxJointCfg` joint-drive fragment + (``physxJoint:*`` / ``PhysxJointAPI``), carrying ``max_joint_velocity`` (with the legacy + ``max_velocity`` deprecation alias). Applied alongside + :class:`~isaaclab.sim.schemas.UsdPhysicsDriveCfg` via + :func:`~isaaclab.sim.schemas.apply_joint_drive_properties`. +* Added :func:`~isaaclab_physx.sim.schemas.apply_physx_joint`, the dedicated applier for + :class:`~isaaclab_physx.sim.schemas.PhysxJointCfg` that converts ``max_joint_velocity`` from + rad/s to deg/s for angular (revolute) joints, matching the legacy joint-drive unit convention. diff --git a/source/isaaclab_physx/isaaclab_physx/sim/schemas/__init__.pyi b/source/isaaclab_physx/isaaclab_physx/sim/schemas/__init__.pyi index 10d2502ddf2b..6acd35f212a8 100644 --- a/source/isaaclab_physx/isaaclab_physx/sim/schemas/__init__.pyi +++ b/source/isaaclab_physx/isaaclab_physx/sim/schemas/__init__.pyi @@ -4,6 +4,7 @@ # SPDX-License-Identifier: BSD-3-Clause __all__ = [ + "apply_physx_joint", "define_deformable_body_properties", "modify_deformable_body_properties", "ArticulationRootPropertiesCfg", @@ -22,6 +23,7 @@ __all__ = [ "PhysxDeformableBodyPropertiesCfg", "PhysxDeformableCollisionPropertiesCfg", "PhysxFixedTendonPropertiesCfg", + "PhysxJointCfg", "PhysxJointDrivePropertiesCfg", "PhysxRigidBodyCfg", "PhysxRigidBodyPropertiesCfg", @@ -37,6 +39,7 @@ __all__ = [ ] from .schemas import ( + apply_physx_joint, define_deformable_body_properties, modify_deformable_body_properties, ) @@ -57,6 +60,7 @@ from .schemas_cfg import ( PhysxDeformableBodyPropertiesCfg, PhysxDeformableCollisionPropertiesCfg, PhysxFixedTendonPropertiesCfg, + PhysxJointCfg, PhysxJointDrivePropertiesCfg, PhysxRigidBodyCfg, PhysxRigidBodyPropertiesCfg, diff --git a/source/isaaclab_physx/isaaclab_physx/sim/schemas/schemas.py b/source/isaaclab_physx/isaaclab_physx/sim/schemas/schemas.py index 713c1cc3264c..af67bf17e9f1 100644 --- a/source/isaaclab_physx/isaaclab_physx/sim/schemas/schemas.py +++ b/source/isaaclab_physx/isaaclab_physx/sim/schemas/schemas.py @@ -3,12 +3,64 @@ # # SPDX-License-Identifier: BSD-3-Clause -"""Compatibility wrappers for deformable schema writers. +"""PhysX schema-fragment appliers and compatibility wrappers. The deformable schema writers are backend-aware but remain unified in :mod:`isaaclab.sim.schemas`. """ +from __future__ import annotations + +import dataclasses +import math + +from pxr import Usd, UsdPhysics + from isaaclab.sim.schemas.schemas import define_deformable_body_properties, modify_deformable_body_properties +from isaaclab.sim.utils import safe_set_attribute_on_usd_prim +from isaaclab.sim.utils.stage import get_current_stage +from isaaclab.utils.string import to_camel_case + +__all__ = [ + "apply_physx_joint", + "define_deformable_body_properties", + "modify_deformable_body_properties", +] + + +def apply_physx_joint(cfg, prim_path: str, stage: Usd.Stage | None = None) -> bool: + """Apply a :class:`~isaaclab_physx.sim.schemas.PhysxJointCfg` fragment to a joint prim. + + Like :func:`~isaaclab.sim.schemas.apply_namespaced`, this applies ``PhysxJointAPI`` and writes + each non-``None`` field under the ``physxJoint:`` namespace. It additionally reproduces the + legacy joint-drive unit convention: for angular (revolute) joints, ``max_joint_velocity`` is + converted from rad/s to deg/s, since PhysX stores angular joint velocity limits in degrees. + + Args: + cfg: The :class:`~isaaclab_physx.sim.schemas.PhysxJointCfg` fragment. + prim_path: The prim path of the joint. + stage: The stage where to find the prim. Defaults to None, in which case the current + stage is used. -__all__ = ["define_deformable_body_properties", "modify_deformable_body_properties"] + Returns: + True if the properties were successfully set. + """ + if stage is None: + stage = get_current_stage() + prim = stage.GetPrimAtPath(prim_path) + namespace = type(cfg)._usd_namespace + applied = type(cfg)._usd_applied_schema + if applied and applied not in prim.GetAppliedSchemas(): + prim.AddAppliedSchema(applied) + # angular (revolute) joints store velocity limits in degrees; linear (prismatic) in meters. + is_angular = prim.IsA(UsdPhysics.RevoluteJoint) + for f in dataclasses.fields(cfg): + if f.name == "func": + continue + value = getattr(cfg, f.name) + if value is None: + continue + if f.name == "max_joint_velocity" and is_angular: + value = value * 180.0 / math.pi # rad/s -> deg/s + safe_set_attribute_on_usd_prim(prim, f"{namespace}:{to_camel_case(f.name, 'cC')}", value, camel_case=False) + 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 18a5de023f90..1a413d1d31c5 100644 --- a/source/isaaclab_physx/isaaclab_physx/sim/schemas/schemas_cfg.py +++ b/source/isaaclab_physx/isaaclab_physx/sim/schemas/schemas_cfg.py @@ -6,6 +6,7 @@ from __future__ import annotations import warnings +from collections.abc import Callable from typing import ClassVar from isaaclab.sim.schemas.schemas_cfg import ( @@ -13,9 +14,11 @@ CollisionBaseCfg, DeformableBodyPropertiesBaseCfg, JointDriveBaseCfg, + JointDriveFragment, MeshCollisionBaseCfg, RigidBodyBaseCfg, RigidBodyFragment, + _deprecate_field_alias, ) from isaaclab.utils.configclass import configclass @@ -343,6 +346,56 @@ def __post_init__(self): super().__post_init__() +@configclass +class PhysxJointCfg(JointDriveFragment): + """``physxJoint:*`` joint attributes from `PhysxJointAPI`_. + + A single-namespace fragment (see :class:`~isaaclab.sim.schemas.SchemaFragment`) for the + PhysX joint add-on schema. Applied alongside :class:`~isaaclab.sim.schemas.UsdPhysicsDriveCfg` + via :func:`~isaaclab.sim.schemas.apply_joint_drive_properties`. Written with the dedicated + :func:`~isaaclab_physx.sim.schemas.apply_physx_joint` writer, which converts + :attr:`max_joint_velocity` from rad/s to deg/s for angular (revolute) joints. + + .. _PhysxJointAPI: https://docs.omniverse.nvidia.com/kit/docs/omni_usd_schema_physics/104.2/class_physx_schema_physx_joint_a_p_i.html + """ + + _usd_namespace: ClassVar[str | None] = "physxJoint" + _usd_applied_schema: ClassVar[str | None] = "PhysxJointAPI" + # Override the generic applier: ``max_joint_velocity`` needs joint-type-aware rad->deg + # conversion for angular joints, which ``apply_namespaced`` cannot do. + func: Callable | str = "isaaclab_physx.sim.schemas:apply_physx_joint" + + def __post_init__(self): + # Deprecation alias: ``max_velocity`` -> ``max_joint_velocity`` (the USD attr is + # ``maxJointVelocity``). Mirrors the legacy :class:`JointDriveBaseCfg` alias forwarding. + _deprecate_field_alias(self, "max_velocity", "max_joint_velocity") + + max_joint_velocity: float | None = None + """Maximum velocity of the joint [m/s for linear joints, rad/s for angular joints]. + + Notes: + Today this writes ``physxJoint:maxJointVelocity`` (a PhysX add-on schema attribute). + Newton's USD importer consumes the same attribute via its PhysX-bridge resolver and + populates ``Model.joint_velocity_limit``; the PhysX engine consumes it natively. + + .. note:: + Authored in rad/s; :func:`~isaaclab_physx.sim.schemas.apply_physx_joint` converts it to + deg/s for angular (revolute) joints (PhysX's angular convention) and writes linear + (prismatic) joints unchanged, matching the legacy + :func:`~isaaclab.sim.schemas.modify_joint_drive_properties`. + """ + + max_velocity: float | None = None + """Deprecated alias for :attr:`max_joint_velocity`. + + .. deprecated:: 4.6.25 + Use :attr:`max_joint_velocity` instead. The cfg field is renamed so its snake_case name + maps identity-style to the USD camelCase attribute (``physxJoint:maxJointVelocity``). The + alias is forwarded to :attr:`max_joint_velocity` in :meth:`__post_init__` and will be + removed in 4.0. + """ + + @configclass class PhysxJointDrivePropertiesCfg(JointDriveBaseCfg): """PhysX-specific joint drive properties.