diff --git a/docs/source/api/lab/isaaclab.sim.schemas.rst b/docs/source/api/lab/isaaclab.sim.schemas.rst index d32ed1f9892d..a622d532165b 100644 --- a/docs/source/api/lab/isaaclab.sim.schemas.rst +++ b/docs/source/api/lab/isaaclab.sim.schemas.rst @@ -54,6 +54,8 @@ isaaclab.sim.schemas UsdPhysicsDriveCfg UsdPhysicsMeshCollisionCfg MassCfg + DeformableBodyFragment + OmniPhysicsDeformableBodyCfg .. rubric:: Fragment writers @@ -70,6 +72,8 @@ isaaclab.sim.schemas apply_namespaced apply_drive apply_mesh_collision + apply_volume_deformable_properties + apply_surface_deformable_properties .. rubric:: Functions @@ -164,6 +168,16 @@ Schema Fragments :show-inheritance: :exclude-members: __init__ +.. autoclass:: DeformableBodyFragment + :members: + :show-inheritance: + :exclude-members: __init__ + +.. autoclass:: OmniPhysicsDeformableBodyCfg + :members: + :show-inheritance: + :exclude-members: __init__ + .. autofunction:: apply_rigid_body_properties .. autofunction:: apply_collision_properties .. autofunction:: apply_mass_properties @@ -175,6 +189,8 @@ Schema Fragments .. autofunction:: apply_namespaced .. autofunction:: apply_drive .. autofunction:: apply_mesh_collision +.. autofunction:: apply_volume_deformable_properties +.. autofunction:: apply_surface_deformable_properties Articulation Root ----------------- diff --git a/docs/source/api/lab/isaaclab.sim.spawners.rst b/docs/source/api/lab/isaaclab.sim.spawners.rst index 80bf0323ab02..93ca9f4a2c14 100644 --- a/docs/source/api/lab/isaaclab.sim.spawners.rst +++ b/docs/source/api/lab/isaaclab.sim.spawners.rst @@ -302,6 +302,9 @@ Materials RigidBodyMaterialFragment UsdPhysicsRigidBodyMaterialCfg RigidBodyMaterialCfg + DeformableMaterialFragment + OmniPhysicsDeformableMaterialCfg + OmniPhysicsSurfaceDeformableMaterialCfg DeformableBodyMaterialBaseCfg SurfaceDeformableBodyMaterialBaseCfg DeformableBodyMaterialCfg @@ -354,6 +357,8 @@ Physical Materials :show-inheritance: :exclude-members: __init__, func +.. autofunction:: spawn_physics_material_from_fragments + .. autofunction:: spawn_rigid_body_material_from_fragments .. autoclass:: RigidBodyMaterialFragment @@ -370,6 +375,21 @@ Physical Materials :members: :exclude-members: __init__, func +.. autoclass:: DeformableMaterialFragment + :members: + :show-inheritance: + :exclude-members: __init__, func + +.. autoclass:: OmniPhysicsDeformableMaterialCfg + :members: + :show-inheritance: + :exclude-members: __init__, func + +.. autoclass:: OmniPhysicsSurfaceDeformableMaterialCfg + :members: + :show-inheritance: + :exclude-members: __init__, func + .. autofunction:: spawn_deformable_body_material .. autoclass:: DeformableBodyMaterialBaseCfg diff --git a/docs/source/api/lab_newton/isaaclab_newton.sim.spawners.rst b/docs/source/api/lab_newton/isaaclab_newton.sim.spawners.rst index 07aa45c4b299..0d953b2848f2 100644 --- a/docs/source/api/lab_newton/isaaclab_newton.sim.spawners.rst +++ b/docs/source/api/lab_newton/isaaclab_newton.sim.spawners.rst @@ -8,6 +8,8 @@ isaaclab_newton.sim.spawners .. autosummary:: NewtonMaterialCfg + NewtonVolumeDeformableMaterialCfg + NewtonSurfaceDeformableMaterialCfg NewtonDeformableBodyMaterialCfg NewtonDeformableMaterialCfg NewtonSurfaceDeformableBodyMaterialCfg @@ -26,6 +28,16 @@ Deformable Materials Newton provides the backend-specific deformable material cfgs. Deformable material spawning is unified in :func:`isaaclab.sim.spawners.materials.spawn_deformable_body_material`. +.. autoclass:: NewtonVolumeDeformableMaterialCfg + :members: + :show-inheritance: + :exclude-members: __init__, func + +.. autoclass:: NewtonSurfaceDeformableMaterialCfg + :members: + :show-inheritance: + :exclude-members: __init__, func + .. autoclass:: NewtonDeformableBodyMaterialCfg :members: :show-inheritance: 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 166d7b756021..86eb81f2d718 100644 --- a/docs/source/api/lab_physx/isaaclab_physx.sim.schemas.rst +++ b/docs/source/api/lab_physx/isaaclab_physx.sim.schemas.rst @@ -54,6 +54,8 @@ isaaclab_physx.sim.schemas OmniPhysicsDeformableBodyPropertiesCfg PhysxDeformableBodyPropertiesCfg + PhysxDeformableBodyCfg + PhysxSurfaceDeformableBodyCfg DeformableBodyPropertiesCfg .. rubric:: Functions @@ -166,6 +168,16 @@ Deformable Body :show-inheritance: :exclude-members: __init__ +.. autoclass:: PhysxDeformableBodyCfg + :members: + :show-inheritance: + :exclude-members: __init__, func + +.. autoclass:: PhysxSurfaceDeformableBodyCfg + :members: + :show-inheritance: + :exclude-members: __init__, func + .. autoclass:: DeformableBodyPropertiesCfg :members: :show-inheritance: diff --git a/docs/source/api/lab_physx/isaaclab_physx.sim.spawners.rst b/docs/source/api/lab_physx/isaaclab_physx.sim.spawners.rst index 9308a35c13c9..0136d12884a5 100644 --- a/docs/source/api/lab_physx/isaaclab_physx.sim.spawners.rst +++ b/docs/source/api/lab_physx/isaaclab_physx.sim.spawners.rst @@ -9,6 +9,8 @@ isaaclab_physx.sim.spawners PhysxRigidBodyMaterialCfg PhysxMaterialCfg + PhysxDeformableMaterialCfg + PhysxSurfaceDeformableMaterialCfg PhysxDeformableBodyMaterialCfg PhysxSurfaceDeformableBodyMaterialCfg PhysXDeformableMaterialCfg @@ -33,6 +35,16 @@ Deformable Materials PhysX provides the backend-specific deformable material cfgs. Deformable material spawning is unified in :func:`isaaclab.sim.spawners.materials.spawn_deformable_body_material`. +.. autoclass:: PhysxDeformableMaterialCfg + :members: + :show-inheritance: + :exclude-members: __init__, func + +.. autoclass:: PhysxSurfaceDeformableMaterialCfg + :members: + :show-inheritance: + :exclude-members: __init__, func + .. autoclass:: PhysxDeformableBodyMaterialCfg :members: :show-inheritance: diff --git a/docs/source/concepts/schema_fragments.rst b/docs/source/concepts/schema_fragments.rst index 6b7ba1794ded..7000917e5360 100644 --- a/docs/source/concepts/schema_fragments.rst +++ b/docs/source/concepts/schema_fragments.rst @@ -61,9 +61,17 @@ imports a backend: * - ``spatial_tendons_props`` - :func:`~isaaclab.sim.schemas.apply_spatial_tendon_properties` - tendon attachment root prims + * - ``volume_deformable_props`` + - :func:`~isaaclab.sim.schemas.apply_volume_deformable_properties` + - volume deformable-body prims (``UsdGeom.TetMesh`` simulation mesh) + * - ``surface_deformable_props`` + - :func:`~isaaclab.sim.schemas.apply_surface_deformable_properties` + - surface deformable-body prims (triangle-mesh simulation mesh) 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. PhysX tendon +asset, so their writers only tune existing instances and never create them. The deformable +families sit at the other end of that spectrum: see :ref:`schema-fragments-deformable`. +PhysX tendon fragments use ``instance_names`` to select one or more instances on each matching prim; ``None`` (the default) broadcasts to all existing instances. @@ -205,6 +213,88 @@ receives the API, so a too-broad pattern can, for example, give every mesh in a its own mass. Which bodies participate in an articulation is still decided by the asset's joints, not by the expression. Scope creation patterns deliberately. +.. _schema-fragments-deformable: + +Deformable bodies +------------------ + +The deformable families invert the "modify existing" default: the +``volume_deformable_props`` / ``surface_deformable_props`` writers +(:func:`~isaaclab.sim.schemas.apply_volume_deformable_properties` / +:func:`~isaaclab.sim.schemas.apply_surface_deformable_properties`) always create a full +deformable-body setup on a matched prim that lacks one, with no opt-in flag to set. Fragments +for both slots subclass :class:`~isaaclab.sim.schemas.DeformableBodyFragment`; the +core-owned :class:`~isaaclab.sim.schemas.OmniPhysicsDeformableBodyCfg` carries the +solver-common fields (``deformable_body_enabled``, ``kinematic_enabled``, ``mass``). PhysX +adds its own body-tuning fragments (``PhysxDeformableBodyCfg`` for shared solver/damping +attributes, ``PhysxSurfaceDeformableBodyCfg`` narrowed to surface deformables) in +:mod:`isaaclab_physx.sim.schemas`. Newton needs no additional body fragment. + +At most one of the two slots may be set on a spawner: a mesh is spawned as either a volume +deformable (tetrahedralized interior) or a surface deformable (cloth-like), never both. The +slot's mapping keys are regular-expression suffixes appended to the *spawn* prim — unlike the +other families, this holds for every spawner type, including shape and mesh spawners whose other +families anchor at the geometry or container prim instead. The empty string ``""`` again +selects the spawn prim itself, and ``"(/.*)?"`` selects it together with its subtree. + +Creating the deformable setup on a matched prim without the anchor happens in three steps: + +#. **Simulation mesh.** A ``sim_mesh`` child prim is authored under the target (the name is + configurable via the writer's ``sim_mesh_name`` argument). For surface deformables this is + a triangle-mesh copy of the visual mesh; for volume deformables it is a tetrahedralized + ``UsdGeom.TetMesh`` — reused in place, without re-tetrahedralizing, when the source asset + already ships a pre-tetrahedralized ``UsdGeom.TetMesh`` child. +#. **Backend anchor.** The active physics backend applies its own anchor schemas through + ``PhysicsManager.setup_deformable_body``: PhysX applies the real ``OmniPhysicsDeformableBodyAPI`` + to the body prim (the same single schema for both deformable types) and the type-dependent + ``OmniPhysicsVolumeDeformableSimAPI`` / ``OmniPhysicsSurfaceDeformableSimAPI`` to the + simulation-mesh prim, while Newton applies its own (currently token-only) ``Physics*`` schemas. + Core fragments own no applied schema of their own — + :class:`~isaaclab.sim.schemas.OmniPhysicsDeformableBodyCfg` only writes into the + ``omniphysics:*`` namespace once the anchor exists. +#. **Fragment dispatch.** Every fragment in the matched slot is applied to the target, in list + order, exactly like the other families. + +``UsdPhysics.MassAPI`` is not honored for deformables: set mass through +:attr:`~isaaclab.sim.schemas.OmniPhysicsDeformableBodyCfg.mass` instead. + +Because the simulation mesh already carries ``UsdPhysics.CollisionAPI`` once created, its +collision offsets ride the existing collision family rather than a deformable-specific one: +key ``collision_props`` to the simulation-mesh child (``"/sim_mesh"`` by default), +anchored the same way as the deformable slot itself, relative to the spawn prim. + +Physics materials widen the same way: ``physics_material`` accepts deformable material +fragments (subclasses of ``DeformableMaterialFragment`` in +:mod:`isaaclab.sim.spawners.materials`, e.g. ``OmniPhysicsDeformableMaterialCfg`` / +``OmniPhysicsSurfaceDeformableMaterialCfg``) and mixed lists that combine a rigid-body +material fragment with a deformable one. The ``UsdPhysics.MaterialAPI`` anchor is applied +only when the list contains a rigid-body material fragment; deformable material fragments +apply their own schema (e.g. ``OmniPhysicsDeformableMaterialAPI``) independently, since a +material prim bound only to deformable bodies has no rigid-body material semantics to anchor. + +A cuboid mesh spawned as a volume deformable, with PhysX solver tuning, a collision offset on +the simulation mesh, and a mixed PhysX/Newton material: + +.. code-block:: python + + import isaaclab.sim as sim_utils + from isaaclab_physx.sim.schemas import PhysxCollisionCfg, PhysxDeformableBodyCfg + from isaaclab.sim.spawners.materials import OmniPhysicsDeformableMaterialCfg + from isaaclab_newton.sim.spawners.materials import NewtonVolumeDeformableMaterialCfg + + sim_utils.MeshCuboidCfg( + size=(0.2, 0.2, 0.2), + volume_deformable_props=[ + sim_utils.schemas.OmniPhysicsDeformableBodyCfg(mass=0.5), + PhysxDeformableBodyCfg(solver_position_iteration_count=32), + ], + collision_props={"/sim_mesh": [PhysxCollisionCfg(contact_offset=0.01)]}, + physics_material=[ + OmniPhysicsDeformableMaterialCfg(youngs_modulus=1.0e6, poissons_ratio=0.45), + NewtonVolumeDeformableMaterialCfg(k_mu=1.0e5), + ], + ) + See also -------- diff --git a/docs/source/concepts/sensors/contact_sensor.rst b/docs/source/concepts/sensors/contact_sensor.rst index 31fe880bf28f..445072b4db00 100644 --- a/docs/source/concepts/sensors/contact_sensor.rst +++ b/docs/source/concepts/sensors/contact_sensor.rst @@ -73,13 +73,20 @@ Enable optional buffers only when they are needed. Their backend support differs * Isaac Sim PhysX supports pose, filtered contact-point, and filtered friction-force tracking, but not aggregate friction-force tracking. -* OvPhysX supports pose tracking for a single sensor body per environment, but not contact-point or - friction-force tracking. +* OvPhysX supports filtered contact-point and filtered friction-force tracking, but not aggregate + friction-force tracking. Pose tracking is supported for a single sensor body per environment. * Newton supports filtered contact-point and aggregate or filtered friction-force tracking, but not pose tracking. -Filtered contact points and friction-force matrices require filters. Contact-rich Isaac -Sim PhysX scenes may require a larger ``max_contact_data_count_per_prim``. +Filtered contact points and friction-force matrices require filters. Isaac Sim PhysX and OvPhysX +require a positive ``max_contact_data_count_per_prim`` for these features. Contact-rich scenes may +require a larger value. OvPhysX warns and truncates detailed contacts when capacity is exceeded; +increase the configured capacity before recreating the sensor to retain all contacts. + +Contact positions are averaged over each sensor/filter pair and are ``NaN`` when the pair has no +contacts. OvPhysX also resets contact positions to ``NaN``. Filtered friction forces sum the pair's +friction forces and are zero without contact. On PhysX and OvPhysX, contact points and friction +anchors can have different counts. Read the data ------------- @@ -108,19 +115,19 @@ principal Torch views have these contracts: - Normal force [N] from each filtered partner * - ``friction_force_matrix_w.torch`` - ``(E, S, F, 3)`` - - Friction force [N] from each filtered partner; Isaac Sim PhysX and Newton + - Friction force [N] from each filtered partner; Isaac Sim PhysX, OvPhysX, and Newton * - ``contact_pos_w.torch`` - ``(E, S, F, 3)`` - - Average filtered contact position [m] in world frame; unavailable on OvPhysX + - Average filtered contact position [m] in world frame * - ``current_air_time.torch`` / ``current_contact_time.torch`` - ``(E, S)`` - Current mode duration [s] Supported optional buffers are ``None`` unless their matching tracking option or filter is enabled. Normal and friction force histories follow the same shapes with an added ``H`` dimension. -OvPhysX rejects unsupported tracking options during initialization. Reading aggregate friction on -PhysX or OvPhysX raises ``NotImplementedError``. The compatibility alias ``friction_forces_w`` -returns the aggregate on Newton; on PhysX it returns ``friction_force_matrix_w`` with a warning. +Reading aggregate friction on PhysX or OvPhysX raises ``NotImplementedError``. The compatibility +alias ``friction_forces_w`` returns the aggregate on Newton; on PhysX and OvPhysX it returns +``friction_force_matrix_w`` with a warning. Reading pose data on Newton raises ``NotImplementedError``. .. code-block:: python diff --git a/scripts/benchmarks/nsys_trace.json b/scripts/benchmarks/nsys_trace.json index fa8523455cb8..8b01774b70ef 100644 --- a/scripts/benchmarks/nsys_trace.json +++ b/scripts/benchmarks/nsys_trace.json @@ -256,13 +256,12 @@ "functions": [ "OVRTXRenderer.prepare_stage", "OVRTXRenderer.create_render_data", - "OVRTXRenderer._clone_sources_in_ovrtx", - "OVRTXRenderer._clone_sources_ovstage", + "OVRTXRenderer._clone_sources", "OVRTXRenderer._update_scene_partitions_after_clone", - "OVRTXRenderer._update_scene_partitions_after_clone_ovstage", - "OVRTXRenderer._setup_xform_bindings", - "OVRTXRenderer._setup_deformable_bindings", - "OVRTXRenderer._setup_particle_bindings", + "OVRTXRenderer._setup_xform_bindings_legacy", + "OVRTXRenderer._setup_xform_bindings_ovstage", + "OVRTXRenderer._setup_geometry_bindings_legacy", + "OVRTXRenderer._setup_geometry_bindings_ovstage", "OVRTXRenderer.update_transforms", "OVRTXRenderer.update_geometries", "OVRTXRenderer.update_camera", diff --git a/source/isaaclab/changelog.d/vidurv-deformable-fragments.minor.rst b/source/isaaclab/changelog.d/vidurv-deformable-fragments.minor.rst new file mode 100644 index 000000000000..f1106a683910 --- /dev/null +++ b/source/isaaclab/changelog.d/vidurv-deformable-fragments.minor.rst @@ -0,0 +1,47 @@ +Added +^^^^^ + +* Added the deformable-body fragment families: :class:`~isaaclab.sim.schemas.DeformableBodyFragment` + and :class:`~isaaclab.sim.schemas.OmniPhysicsDeformableBodyCfg` with the expression-targeted + writers :func:`~isaaclab.sim.schemas.apply_volume_deformable_properties` and + :func:`~isaaclab.sim.schemas.apply_surface_deformable_properties`. +* Added deformable material fragments + (:class:`~isaaclab.sim.spawners.materials.OmniPhysicsDeformableMaterialCfg`, + :class:`~isaaclab.sim.spawners.materials.OmniPhysicsSurfaceDeformableMaterialCfg`) under the new + :class:`~isaaclab.sim.spawners.materials.DeformableMaterialFragment` marker, accepted by the + ``physics_material`` spawner fields. +* Added ``volume_deformable_props`` and ``surface_deformable_props`` mappings to + :class:`~isaaclab.sim.spawners.DeformableObjectSpawnerCfg`, applying deformable fragments by + target pattern relative to the spawn prim. A bare fragment or list is shorthand for the spawn + prim itself. Alongside either slot, ``collision_props`` must be given as collision fragments; a + legacy collision cfg raises, since it cannot reach the simulation mesh. +* Added :meth:`~isaaclab.physics.PhysicsManager.setup_deformable_body` so each physics backend + applies its own deformable anchor schemas. +* Added ``tetrahedralization_edge_length_fac`` to + :func:`~isaaclab.sim.schemas.apply_volume_deformable_properties`, so the volume fragment writer + controls the automatic tetrahedralization target edge length like + :func:`~isaaclab.sim.schemas.define_deformable_body_properties` does. + +Changed +^^^^^^^ + +* Changed :attr:`~isaaclab.sim.spawners.meshes.MeshCfg.edge_refinement` to apply to the + ``volume_deformable_props`` and ``surface_deformable_props`` slots as well, not only the legacy + ``deformable_props`` field. +* Changed physics-material fragment lists to allow mixing rigid-body and deformable material + fragments on one material prim; the ``UsdPhysics.MaterialAPI`` anchor is applied only when a + rigid-body fragment is present. The writer is now named + :func:`~isaaclab.sim.spawners.materials.spawn_physics_material_from_fragments` accordingly. +* Changed :func:`~isaaclab.sim.schemas.apply_volume_deformable_properties` and + :func:`~isaaclab.sim.schemas.apply_surface_deformable_properties` to skip matched prims that are + already authored as the other deformable type. Such a prim is reported with a warning and drags + the return value to ``False`` instead of receiving a family it does not simulate with. Target the + prim through the writer matching its authored type. + +Deprecated +^^^^^^^^^^ + +* Deprecated :func:`~isaaclab.sim.spawners.materials.spawn_rigid_body_material_from_fragments` in + favor of :func:`~isaaclab.sim.spawners.materials.spawn_physics_material_from_fragments`, which + also accepts deformable material fragments. The old name still forwards to the new writer and + emits a ``DeprecationWarning``; it is scheduled for removal in 3.2. diff --git a/source/isaaclab/isaaclab/physics/physics_manager.py b/source/isaaclab/isaaclab/physics/physics_manager.py index 2379c006902e..661ed78998c7 100644 --- a/source/isaaclab/isaaclab/physics/physics_manager.py +++ b/source/isaaclab/isaaclab/physics/physics_manager.py @@ -139,6 +139,32 @@ def fix_articulation_root(cls, articulation_prim: Any, stage: Any = None) -> Any create_world_fixed_joint(articulation_prim, stage) return articulation_prim + @classmethod + def setup_deformable_body(cls, prim: Any, deformable_type: str, sim_mesh_prim: Any, vis_mesh_prim: Any) -> None: + """Apply the backend's deformable anchor schemas to a prepared deformable body. + + Called by the deformable family writers after the backend-neutral mesh setup: the + simulation mesh already exists (a ``UsdGeom.TetMesh`` for volume, a triangle + ``UsdGeom.Mesh`` for surface) with collision enabled. The backend applies its sim and + body anchor schemas and any backend-specific authoring (rest state, pose bindings, + visual-mesh synchronization). + + The base raises: backends with deformable support override. + + Args: + prim: The deformable-body prim to anchor. + deformable_type: The deformable type, ``"volume"`` or ``"surface"``. + sim_mesh_prim: The prepared simulation-mesh prim. + vis_mesh_prim: The visual-mesh prim. + + Raises: + NotImplementedError: If the backend does not implement deformable fragment setup. + """ + raise NotImplementedError( + f"Physics backend '{cls.__name__}' does not implement deformable fragment setup, so the" + " 'volume_deformable_props' and 'surface_deformable_props' slots cannot be used with it." + ) + @staticmethod def _relocate_articulation_root( articulation_prim: Any, diff --git a/source/isaaclab/isaaclab/sim/__init__.pyi b/source/isaaclab/isaaclab/sim/__init__.pyi index 0a22d87898f6..49d378654f90 100644 --- a/source/isaaclab/isaaclab/sim/__init__.pyi +++ b/source/isaaclab/isaaclab/sim/__init__.pyi @@ -44,6 +44,8 @@ __all__ = [ "ConvexHullPropertiesCfg", "DeformableBodyPropertiesBaseCfg", "DeformableBodyPropertiesCfg", + "DeformableBodyFragment", + "OmniPhysicsDeformableBodyCfg", "FixedTendonFragment", "FixedTendonPropertiesCfg", "JointDriveBaseCfg", @@ -84,6 +86,8 @@ __all__ = [ "apply_namespaced", "apply_rigid_body_properties", "apply_spatial_tendon_properties", + "apply_surface_deformable_properties", + "apply_volume_deformable_properties", "SDFMeshPropertiesCfg", "SpatialTendonPropertiesCfg", "TriangleMeshPropertiesCfg", @@ -255,6 +259,8 @@ from .schemas import ( ConvexHullPropertiesCfg, DeformableBodyPropertiesBaseCfg, DeformableBodyPropertiesCfg, + DeformableBodyFragment, + OmniPhysicsDeformableBodyCfg, FixedTendonFragment, FixedTendonPropertiesCfg, JointDriveBaseCfg, @@ -290,6 +296,8 @@ from .schemas import ( apply_namespaced, apply_rigid_body_properties, apply_spatial_tendon_properties, + apply_surface_deformable_properties, + apply_volume_deformable_properties, define_articulation_root_properties, define_collision_properties, define_deformable_body_properties, diff --git a/source/isaaclab/isaaclab/sim/schemas/__init__.pyi b/source/isaaclab/isaaclab/sim/schemas/__init__.pyi index 2f7dad7321c6..c2464c956ea4 100644 --- a/source/isaaclab/isaaclab/sim/schemas/__init__.pyi +++ b/source/isaaclab/isaaclab/sim/schemas/__init__.pyi @@ -19,6 +19,8 @@ __all__ = [ "apply_namespaced", "apply_rigid_body_properties", "apply_spatial_tendon_properties", + "apply_surface_deformable_properties", + "apply_volume_deformable_properties", "define_actuator_properties", "define_articulation_root_properties", "define_collision_properties", @@ -41,6 +43,7 @@ __all__ = [ "BoundingSpherePropertiesCfg", "CollisionBaseCfg", "CollisionFragment", + "DeformableBodyFragment", "DeformableBodyPropertiesBaseCfg", "DeformableBodyPropertiesCfg", "FixedTendonFragment", @@ -52,6 +55,7 @@ __all__ = [ "MeshCollisionBaseCfg", "ArticulationRootFragment", "MeshCollisionFragment", + "OmniPhysicsDeformableBodyCfg", "RigidBodyFragment", "SchemaFragment", "SpatialTendonFragment", @@ -87,6 +91,8 @@ from .schemas import ( apply_namespaced, apply_rigid_body_properties, apply_spatial_tendon_properties, + apply_surface_deformable_properties, + apply_volume_deformable_properties, define_articulation_root_properties, define_collision_properties, define_deformable_body_properties, @@ -114,6 +120,7 @@ from .schemas_cfg import ( BoundingSpherePropertiesCfg, CollisionBaseCfg, CollisionFragment, + DeformableBodyFragment, DeformableBodyPropertiesBaseCfg, DeformableBodyPropertiesCfg, FixedTendonFragment, @@ -124,6 +131,7 @@ from .schemas_cfg import ( MassPropertiesCfg, MeshCollisionBaseCfg, MeshCollisionFragment, + OmniPhysicsDeformableBodyCfg, RigidBodyBaseCfg, RigidBodyFragment, SchemaFragment, diff --git a/source/isaaclab/isaaclab/sim/schemas/schemas.py b/source/isaaclab/isaaclab/sim/schemas/schemas.py index bd46ec74be6a..69392d502d42 100644 --- a/source/isaaclab/isaaclab/sim/schemas/schemas.py +++ b/source/isaaclab/isaaclab/sim/schemas/schemas.py @@ -721,6 +721,192 @@ def apply_rigid_body_properties( ) +""" +Deformable body properties. +""" + + +# Simulation-mesh API schemas that record whether an authored deformable body is a volume or a +# surface deformable. The generic deformable-body anchor on the body prim does not carry that +# split, so both backend spellings are matched here. +_DEFORMABLE_SIM_API_TYPES = { + "OmniPhysicsVolumeDeformableSimAPI": "volume", + "PhysicsVolumeDeformableSimAPI": "volume", + "OmniPhysicsSurfaceDeformableSimAPI": "surface", + "PhysicsSurfaceDeformableSimAPI": "surface", +} + + +def _authored_deformable_type(prim: Usd.Prim) -> str | None: + """Read this body’s simulation type, excluding nested deformable bodies.""" + authored_types = set() + descendants = iter(Usd.PrimRange(prim)) + for descendant in descendants: + if descendant != prim and has_deformable_body_api(descendant): + descendants.PruneChildren() + continue + for schema in descendant.GetPrimTypeInfo().GetAppliedAPISchemas(): + authored_type = _DEFORMABLE_SIM_API_TYPES.get(schema) + if authored_type is not None: + authored_types.add(authored_type) + return authored_types.pop() if len(authored_types) == 1 else None + + +def _apply_deformable_body_properties( + prim_path_expr: str, + fragments: Iterable[schemas_cfg.DeformableBodyFragment], + deformable_type: str, + create_if_missing: bool, + stage: Usd.Stage | None, + sim_mesh_name: str, + tetrahedralization_edge_length_fac: float, +) -> bool: + """Shared implementation of the volume/surface deformable family writers.""" + fragments = list(fragments) + if stage is None: + stage = get_current_stage() + if not fragments and not create_if_missing: + return True + for cfg in fragments: + if deformable_type not in type(cfg)._deformable_types: + logger.warning( + "Fragment '%s' is not meaningful for %s deformables; authoring anyway.", + type(cfg).__name__, + deformable_type, + ) + targets, creation_candidates, any_skipped = _match_fragment_targets(prim_path_expr, has_deformable_body_api, stage) + # the deformable-body anchor is type-agnostic, so a prim authored as the other deformable type + # also matches here. Authoring this family onto it would leave the body simulating as its + # authored type while carrying this family's attributes, so drop it instead. + matched_targets, targets = targets, [] + for prim in matched_targets: + authored_type = _authored_deformable_type(prim) + if authored_type is not None and authored_type != deformable_type: + any_skipped = True + logger.warning( + "Prim '%s' is already authored as a %s deformable but was matched by '%s' as a %s" + " deformable; skipping it. Author it through the %s deformable family instead.", + prim.GetPath().pathString, + authored_type, + prim_path_expr, + deformable_type, + authored_type, + ) + else: + targets.append(prim) + if create_if_missing and creation_candidates: + # Keep this import local to avoid the SimulationContext -> schemas import cycle. + from isaaclab.sim import SimulationContext # noqa: PLC0415 + + sim = SimulationContext.instance() + if sim is None: + raise RuntimeError( + f"Cannot create deformable bodies matched by '{prim_path_expr}' without an active simulation." + ) + for prim in creation_candidates: + sim_mesh_prim_path = f"{prim.GetPath().pathString}/{sim_mesh_name}" + sim_mesh_prim, vis_mesh_prim = _setup_deformable_meshes( + prim, deformable_type, sim_mesh_prim_path, stage, tetrahedralization_edge_length_fac + ) + sim.physics_manager.setup_deformable_body(prim, deformable_type, sim_mesh_prim, vis_mesh_prim) + targets.append(prim) + if not targets: + if not any_skipped: + logger.warning("No deformable-body targets matched expression '%s'; nothing was authored.", prim_path_expr) + return False + # aggregate per-target, per-fragment results so a reported failure is not masked + success = not any_skipped + for target in targets: + target_path = target.GetPath().pathString + for cfg in fragments: + success = bool(cfg.func(cfg, target_path, stage)) and success + return success + + +def apply_volume_deformable_properties( + prim_path_expr: str, + fragments: Iterable[schemas_cfg.DeformableBodyFragment], + create_if_missing: bool = False, + stage: Usd.Stage | None = None, + sim_mesh_name: str = "sim_mesh", + tetrahedralization_edge_length_fac: float = 0.1, +) -> bool: + """Apply deformable-body fragments to the volume deformables matched by an expression. + + 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/Body/.*`` every descendant of a prim. Matched prims that + already carry a deformable-body anchor (per :func:`~isaaclab.sim.utils.has_deformable_body_api`) + are modified in place: each fragment is dispatched to every such target via its + :attr:`~isaaclab.sim.schemas.SchemaFragment.func`. + + With :paramref:`create_if_missing`, every matched prim without the anchor receives a full + volume-deformable setup: the simulation ``TetMesh`` is created as a ``sim_mesh_name`` child + (reusing a pre-tetrahedralized ``UsdGeom.TetMesh`` child when present, tetrahedralizing the + visual mesh via the optional ``pytetwild`` package otherwise), collision is enabled on it, + and the anchor schemas are applied through the active physics backend. Creation therefore + requires an active simulation. Zero targets warn and return False; instanced matches are + skipped with a warning; fragments not meaningful for volume deformables warn but author. + Matched prims already authored as surface deformables are skipped with a warning rather than + being given a volume family they do not simulate with. + + Args: + prim_path_expr: The prim path expression matched against the stage. + fragments: An iterable of :class:`~isaaclab.sim.schemas.DeformableBodyFragment` instances. + create_if_missing: Whether to run the full deformable setup on matched prims that do + not carry the anchor. Defaults to False. + stage: The stage where to find the prims. Defaults to None, in which case the current + stage is used. + sim_mesh_name: Name of the simulation-mesh child prim created per target. Defaults to + ``"sim_mesh"``. + tetrahedralization_edge_length_fac: Relative target edge length for automatic + tetrahedralization. Defaults to ``0.1``. + + Returns: + True if every target and fragment succeeded and no matched prim was skipped. + + Raises: + RuntimeError: If creation is requested without an active simulation. + """ + return _apply_deformable_body_properties( + prim_path_expr, fragments, "volume", create_if_missing, stage, sim_mesh_name, tetrahedralization_edge_length_fac + ) + + +def apply_surface_deformable_properties( + prim_path_expr: str, + fragments: Iterable[schemas_cfg.DeformableBodyFragment], + create_if_missing: bool = False, + stage: Usd.Stage | None = None, + sim_mesh_name: str = "sim_mesh", +) -> bool: + """Apply deformable-body fragments to the surface deformables matched by an expression. + + Same contract as :func:`apply_volume_deformable_properties` with surface structural work: + the simulation mesh is a triangle-mesh copy of the visual mesh (no tetrahedralization), and + matched prims already authored as volume deformables are the ones skipped with a warning. + + Args: + prim_path_expr: The prim path expression matched against the stage. + fragments: An iterable of :class:`~isaaclab.sim.schemas.DeformableBodyFragment` instances. + create_if_missing: Whether to run the full deformable setup on matched prims that do + not carry the anchor. Defaults to False. + stage: The stage where to find the prims. Defaults to None, in which case the current + stage is used. + sim_mesh_name: Name of the simulation-mesh child prim created per target. Defaults to + ``"sim_mesh"``. + + Returns: + True if every target and fragment succeeded and no matched prim was skipped. + + Raises: + RuntimeError: If creation is requested without an active simulation. + """ + return _apply_deformable_body_properties( + prim_path_expr, fragments, "surface", create_if_missing, stage, sim_mesh_name, 0.1 + ) + + def apply_mesh_collision( cfg: schemas_cfg.MeshCollisionFragment, prim_path: str, stage: Usd.Stage | None = None ) -> bool: @@ -1931,62 +2117,47 @@ def define_deformable_curve_properties(prim_path: str, stage: Usd.Stage | None = raise RuntimeError(f"Failed to set deformable curve API on prim '{prim_path}'.") -def define_deformable_body_properties( - prim_path: str, - cfg: schemas_cfg.DeformableBodyPropertiesBaseCfg, - stage: Usd.Stage | None = None, - deformable_type: str = "volume", - sim_mesh_prim_path: str | None = None, +def _setup_deformable_meshes( + root_prim: Usd.Prim, + deformable_type: str, + sim_mesh_prim_path: str, + stage: Usd.Stage, tetrahedralization_edge_length_fac: float = 0.1, -): - """Apply the deformable body schema on the input prim and set its properties. The input prim should - have a visual surface mesh as child. Volume deformables will have their simulation tetrahedral mesh - automatically computed from the surface mesh of the input prim. Surface deformables simply copy the visual mesh - as simulation mesh. - - See :func:`modify_deformable_body_properties` for more details on how the properties are set. +) -> tuple[Usd.Prim, Usd.Prim]: + """Author the backend-neutral simulation and visual meshes for a deformable body. - .. note:: - If the input prim is not a mesh, this function will traverse the prim and find the first mesh - under it. If no mesh or multiple meshes are found, an error is raised. This is because the deformable - body schema can only be applied to a single mesh. - - .. note:: - This function authors a new deformable body setup from scratch. It does not remove or clear existing - deformable body schemas, simulation meshes, or pose data. Use :func:`modify_deformable_body_properties` - to update properties on an existing deformable body, or clear any previous setup before calling this - function. + This resolves the visual surface mesh under the deformable root prim, creates the simulation + mesh (a copy of the visual mesh for surface deformables, or a tetrahedralized volume for volume + deformables), applies the collision API, and hides the simulation mesh from rendering. The + backend-specific simulation APIs and rest-shape attributes are applied by the caller. Args: - prim_path: The prim path where to apply the deformable body schema. - cfg: The configuration for the deformable body. - stage: The stage where to find the prim. Defaults to None, in which case the - current stage is used. - deformable_type: The type of the deformable body (surface or volume). - This is used to determine which USD API to use for the deformable body. Defaults to "volume". - sim_mesh_prim_path: Optional override for the simulation mesh creation prim path. - Ignored when pre-tetrahedralized mesh is found for volume deformables. - If None, it is set to ``{prim_path}/sim_mesh``. - tetrahedralization_edge_length_fac: Relative target edge length for automatic tetrahedralization. - Defaults to ``0.1``. + root_prim: The deformable root prim under which to find or author the meshes. + deformable_type: The type of the deformable body ("surface" or "volume"). + sim_mesh_prim_path: The prim path at which to create the simulation mesh. Ignored when a + pre-tetrahedralized mesh is found for volume deformables. + stage: The stage on which the prims live. + tetrahedralization_edge_length_fac: Relative target edge length for automatic + tetrahedralization. Defaults to ``0.1``. + + Returns: + A tuple of the simulation mesh prim and the visual mesh prim. Raises: - ValueError: When the prim path is not valid. - ValueError: When the prim has no mesh or multiple meshes. - ModuleNotFoundError: When automatic volume tetrahedralization is requested - without its optional dependencies. - RuntimeError: When setting the deformable body properties fails. + ValueError: When the deformable type is unsupported, no mesh or multiple meshes are found, + or a resolved mesh prim is invalid. + RuntimeError: When applying the collision API fails. + """ - if stage is None: - stage = get_current_stage() - root_prim = stage.GetPrimAtPath(prim_path) - if not root_prim.IsValid(): - raise ValueError(f"Prim path '{prim_path}' is not valid.") + if deformable_type not in ("surface", "volume"): + raise ValueError(f"Unsupported deformable type: '{deformable_type}'. Expected surface or volume.") + + prim_path = str(root_prim.GetPrimPath()) # volume deformables may ship a pre-tetrahedralized TetMesh to use as the simulation mesh sim_mesh_prim = None if deformable_type == "volume": - matching_prims = get_all_matching_child_prims(prim_path, lambda p: p.GetTypeName() == "TetMesh") + matching_prims = get_all_matching_child_prims(prim_path, lambda p: p.GetTypeName() == "TetMesh", stage=stage) if len(matching_prims) > 1: mesh_paths = [p.GetPrimPath() for p in matching_prims] raise ValueError( @@ -1998,7 +2169,7 @@ def define_deformable_body_properties( if not sim_mesh_prim.IsValid(): raise ValueError(f"Mesh prim path '{sim_mesh_prim.GetPrimPath()}' is not valid.") - matching_prims = get_all_matching_child_prims(prim_path, lambda p: p.GetTypeName() == "Mesh") + matching_prims = get_all_matching_child_prims(prim_path, lambda p: p.GetTypeName() == "Mesh", stage=stage) if len(matching_prims) == 0: # with a TetMesh but no Mesh, the TetMesh surface becomes the visual mesh if sim_mesh_prim is not None: @@ -2032,11 +2203,7 @@ def define_deformable_body_properties( if not vis_mesh_prim.IsValid(): raise ValueError(f"Mesh prim path '{vis_mesh_prim.GetPrimPath()}' is not valid.") - # the cfg's USD namespace selects between the OmniPhysics (PhysX) and Newton deformable APIs - use_omni_physics_apis = getattr(cfg, "_usd_namespace", None) != "newton" - - if sim_mesh_prim_path is None: - sim_mesh_prim_path = prim_path + "/sim_mesh" + # extract visual surface mesh vertices and faces vertices = np.array(vis_mesh_prim.GetAttribute("points").Get()) faces = np.array(vis_mesh_prim.GetAttribute("faceVertexIndices").Get()).flatten() face_counts = np.array(vis_mesh_prim.GetAttribute("faceVertexCounts").Get()) @@ -2052,18 +2219,7 @@ def define_deformable_body_properties( }, stage=stage, ) - # apply sim API - if use_omni_physics_apis: - if not sim_mesh_prim.ApplyAPI("OmniPhysicsSurfaceDeformableSimAPI"): - raise RuntimeError(f"Failed to set surface deformable body API on prim '{sim_mesh_prim_path}'.") - # set rest-shape attributes required by OmniPhysicsSurfaceDeformableSimAPI - sim_mesh_prim.GetAttribute("omniphysics:restShapePoints").Set(vertices) - sim_mesh_prim.GetAttribute("omniphysics:restTriVtxIndices").Set(faces) - else: - if not sim_mesh_prim.AddAppliedSchema("PhysicsSurfaceDeformableSimAPI"): - raise RuntimeError(f"Failed to set surface deformable body API on prim '{sim_mesh_prim_path}'.") - - elif deformable_type == "volume": + else: if sim_mesh_prim is None: tet_mesh_points, tet_mesh_indices = _tetrahedralize_surface( vertices, faces, tetrahedralization_edge_length_fac, prim_path @@ -2078,34 +2234,112 @@ def define_deformable_body_properties( stage=stage, ) - # apply sim API - if use_omni_physics_apis: - if not sim_mesh_prim.ApplyAPI("OmniPhysicsVolumeDeformableSimAPI"): - raise RuntimeError(f"Failed to set volume deformable body API on prim '{sim_mesh_prim_path}'.") - else: - if not sim_mesh_prim.AddAppliedSchema("PhysicsVolumeDeformableSimAPI"): - raise RuntimeError(f"Failed to set volume deformable body API on prim '{sim_mesh_prim_path}'.") - - # set surface faces and rest-shape attributes required by OmniPhysicsVolumeDeformableSimAPI + # set surface faces required by the deformable simulation APIs surface_face_indices = UsdGeom.TetMesh.ComputeSurfaceFaces( UsdGeom.TetMesh(sim_mesh_prim), Usd.TimeCode.Default() ) UsdGeom.TetMesh(sim_mesh_prim).GetSurfaceFaceVertexIndicesAttr().Set(surface_face_indices) - if use_omni_physics_apis: - sim_mesh_prim.GetAttribute("omniphysics:restShapePoints").Set(sim_mesh_prim.GetAttribute("points").Get()) - sim_mesh_prim.GetAttribute("omniphysics:restTetVtxIndices").Set( - sim_mesh_prim.GetAttribute("tetVertexIndices").Get() - ) - else: - raise ValueError( - f"Unsupported deformable type: '{deformable_type}'. Only surface and volume deformables are supported." - ) if not sim_mesh_prim.ApplyAPI(UsdPhysics.CollisionAPI): raise RuntimeError(f"Failed to set {deformable_type} deformable collision API on prim '{sim_mesh_prim_path}'.") # the simulation mesh is not rendered UsdGeom.Imageable(sim_mesh_prim).GetPurposeAttr().Set(UsdGeom.Tokens.guide) + return sim_mesh_prim, vis_mesh_prim + + +def define_deformable_body_properties( + prim_path: str, + cfg: schemas_cfg.DeformableBodyPropertiesBaseCfg, + stage: Usd.Stage | None = None, + deformable_type: str = "volume", + sim_mesh_prim_path: str | None = None, + tetrahedralization_edge_length_fac: float = 0.1, +): + """Apply the deformable body schema on the input prim and set its properties. The input prim should + have a visual surface mesh as child. Volume deformables will have their simulation tetrahedral mesh + automatically computed from the surface mesh of the input prim. Surface deformables simply copy the visual mesh + as simulation mesh. + + See :func:`modify_deformable_body_properties` for more details on how the properties are set. + + .. note:: + If the input prim is not a mesh, this function will traverse the prim and find the first mesh + under it. If no mesh or multiple meshes are found, an error is raised. This is because the deformable + body schema can only be applied to a single mesh. + + .. note:: + This function authors a new deformable body setup from scratch. It does not remove or clear existing + deformable body schemas, simulation meshes, or pose data. Use :func:`modify_deformable_body_properties` + to update properties on an existing deformable body, or clear any previous setup before calling this + function. + + Args: + prim_path: The prim path where to apply the deformable body schema. + cfg: The configuration for the deformable body. + stage: The stage where to find the prim. Defaults to None, in which case the + current stage is used. + deformable_type: The type of the deformable body (surface or volume). + This is used to determine which USD API to use for the deformable body. Defaults to "volume". + sim_mesh_prim_path: Optional override for the simulation mesh creation prim path. + Ignored when pre-tetrahedralized mesh is found for volume deformables. + If None, it is set to ``{prim_path}/sim_mesh``. + tetrahedralization_edge_length_fac: Relative target edge length for automatic + tetrahedralization. Defaults to ``0.1``. + + Raises: + ValueError: When the prim path is not valid. + ValueError: When the prim has no mesh or multiple meshes. + RuntimeError: When setting the deformable body properties fails. + """ + # get stage handle + if stage is None: + stage = get_current_stage() + + # get USD prim + root_prim = stage.GetPrimAtPath(prim_path) + # check if prim path is valid + if not root_prim.IsValid(): + raise ValueError(f"Prim path '{prim_path}' is not valid.") + + # define authors a fresh deformable setup; callers must clear any previous setup before calling this function. + # We check the USD namespace to determine which API to use for the deformable body. + use_omni_physics_apis = getattr(cfg, "_usd_namespace", None) != "newton" + + # create the backend-neutral simulation and visual meshes + sim_mesh_prim_path = prim_path + "/sim_mesh" if sim_mesh_prim_path is None else sim_mesh_prim_path + sim_mesh_prim, vis_mesh_prim = _setup_deformable_meshes( + root_prim, deformable_type, sim_mesh_prim_path, stage, tetrahedralization_edge_length_fac + ) + + # apply the simulation API and rest state on the simulation mesh (backend-specific) + if deformable_type == "surface": + if use_omni_physics_apis: + if not sim_mesh_prim.ApplyAPI("OmniPhysicsSurfaceDeformableSimAPI"): + raise RuntimeError(f"Failed to set surface deformable body API on prim '{sim_mesh_prim_path}'.") + # set rest-shape attributes required by OmniPhysicsSurfaceDeformableSimAPI + sim_mesh_prim.GetAttribute("omniphysics:restShapePoints").Set(sim_mesh_prim.GetAttribute("points").Get()) + # flatten through numpy so USD coerces the flat index run into the Vec3i array the + # schema declares; a ``Vt.IntArray`` read straight back is rejected as a type mismatch + sim_mesh_prim.GetAttribute("omniphysics:restTriVtxIndices").Set( + np.asarray(sim_mesh_prim.GetAttribute("faceVertexIndices").Get()).flatten() + ) + else: + if not sim_mesh_prim.AddAppliedSchema("PhysicsSurfaceDeformableSimAPI"): + raise RuntimeError(f"Failed to set surface deformable body API on prim '{sim_mesh_prim_path}'.") + else: + if use_omni_physics_apis: + if not sim_mesh_prim.ApplyAPI("OmniPhysicsVolumeDeformableSimAPI"): + raise RuntimeError(f"Failed to set volume deformable body API on prim '{sim_mesh_prim_path}'.") + # set rest-shape attributes required by OmniPhysicsVolumeDeformableSimAPI + sim_mesh_prim.GetAttribute("omniphysics:restShapePoints").Set(sim_mesh_prim.GetAttribute("points").Get()) + sim_mesh_prim.GetAttribute("omniphysics:restTetVtxIndices").Set( + sim_mesh_prim.GetAttribute("tetVertexIndices").Get() + ) + else: + if not sim_mesh_prim.AddAppliedSchema("PhysicsVolumeDeformableSimAPI"): + raise RuntimeError(f"Failed to set volume deformable body API on prim '{sim_mesh_prim_path}'.") + if use_omni_physics_apis: # PhysX binds the visual mesh to the simulation mesh through the bind-pose deformable pose API purposes = ["bindPose"] diff --git a/source/isaaclab/isaaclab/sim/schemas/schemas_cfg.py b/source/isaaclab/isaaclab/sim/schemas/schemas_cfg.py index 773c006c4efa..6b5bf84cb359 100644 --- a/source/isaaclab/isaaclab/sim/schemas/schemas_cfg.py +++ b/source/isaaclab/isaaclab/sim/schemas/schemas_cfg.py @@ -253,6 +253,42 @@ class SpatialTendonFragment(SchemaFragment): """ +@configclass +class DeformableBodyFragment(SchemaFragment): + """Marker base for deformable-body fragments; types the ``volume_deformable_props`` and + ``surface_deformable_props`` slots. + + The deformable anchor schemas (sim and body APIs) are applied by the family writers through + the active physics backend, so fragments never own the anchor. A fragment meaningful for only + one deformable type narrows :attr:`_deformable_types`; the family writers warn (but still + author) when a fragment is passed to the other type's writer. + """ + + _deformable_types: ClassVar[tuple[str, ...]] = ("volume", "surface") + + +@configclass +class OmniPhysicsDeformableBodyCfg(DeformableBodyFragment): + """``omniphysics:*`` deformable-body attributes from ``OmniPhysicsDeformableBodyAPI``. + + The ``OmniPhysicsDeformableBodyAPI`` anchor is applied by the family writer through the + active physics backend, so this fragment owns no applied schema of its own. + """ + + _usd_namespace: ClassVar[str | None] = "omniphysics" + _usd_applied_schema: ClassVar[str | None] = None # anchor applied by the backend manager + + deformable_body_enabled: bool | None = None + """Whether the deformable body participates in the simulation.""" + + kinematic_enabled: bool | None = None + """Whether the body is kinematic (driven by animated or user-defined poses).""" + + mass: float | None = None + """The body mass [kg]. Overrides material-density-derived mass; ``UsdPhysics.MassAPI`` is + ignored for deformable bodies.""" + + @configclass class UsdPhysicsCollisionCfg(CollisionFragment): """``physics:*`` collision attributes from `UsdPhysics.CollisionAPI`_. diff --git a/source/isaaclab/isaaclab/sim/spawners/from_files/from_files.py b/source/isaaclab/isaaclab/sim/spawners/from_files/from_files.py index c71f1d8e48d8..2004caf2da5c 100644 --- a/source/isaaclab/isaaclab/sim/spawners/from_files/from_files.py +++ b/source/isaaclab/isaaclab/sim/spawners/from_files/from_files.py @@ -38,6 +38,7 @@ bare_fragments, fragment_mapping, props_expr, + resolve_deformable_slot, subtree_carries_api, ) @@ -710,6 +711,18 @@ def spawn_from_usd_file( if cfg.make_uninstanceable: make_uninstanceable(prim_path, stage=stage) + # author the deformable dict slot first so any collision_props keyed to the created sim mesh + # (e.g. {"/sim_mesh": [...]}) can match it in _apply_body_schema_properties below + deformable_slot = resolve_deformable_slot(cfg) + if deformable_slot is not None: + kind, mapping = deformable_slot + writer = ( + schemas.apply_volume_deformable_properties + if kind == "volume" + else schemas.apply_surface_deformable_properties + ) + for pattern, fragments in mapping.items(): + writer(props_expr(prim_path, pattern), fragments, create_if_missing=True, stage=stage) # modify rigid body, collision, and mass properties _apply_body_schema_properties(prim_path, cfg) # modify articulation root, tendon, and joint drive properties diff --git a/source/isaaclab/isaaclab/sim/spawners/from_files/from_files_cfg.py b/source/isaaclab/isaaclab/sim/spawners/from_files/from_files_cfg.py index 1981a7d81495..5659687bcb60 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 @@ -218,14 +218,16 @@ class FileCfg(RigidObjectSpawnerCfg, DeformableObjectSpawnerCfg): physics_material: ( materials.PhysicsMaterialCfg | materials.RigidBodyMaterialFragment - | list[materials.RigidBodyMaterialFragment] + | materials.DeformableMaterialFragment + | list[materials.RigidBodyMaterialFragment | materials.DeformableMaterialFragment] | None ) = None """Physics material properties. Accepts either a legacy material cfg, a single - :class:`~isaaclab.sim.spawners.materials.RigidBodyMaterialFragment`, or a list of such - single-namespace fragments. + :class:`~isaaclab.sim.spawners.materials.RigidBodyMaterialFragment` or + :class:`~isaaclab.sim.spawners.materials.DeformableMaterialFragment`, or a list of such + single-namespace fragments (rigid and deformable fragments may be mixed in one list). Note: If None, then no custom physics material will be added. diff --git a/source/isaaclab/isaaclab/sim/spawners/materials/__init__.pyi b/source/isaaclab/isaaclab/sim/spawners/materials/__init__.pyi index 771f8c3ebe8f..160d3cc7b18b 100644 --- a/source/isaaclab/isaaclab/sim/spawners/materials/__init__.pyi +++ b/source/isaaclab/isaaclab/sim/spawners/materials/__init__.pyi @@ -5,6 +5,7 @@ __all__ = [ "spawn_rigid_body_material", + "spawn_physics_material_from_fragments", "spawn_rigid_body_material_from_fragments", "spawn_physics_material", "spawn_deformable_body_material", @@ -15,6 +16,9 @@ __all__ = [ "UsdPhysicsRigidBodyMaterialCfg", "DeformableBodyMaterialBaseCfg", "DeformableBodyMaterialCfg", + "DeformableMaterialFragment", + "OmniPhysicsDeformableMaterialCfg", + "OmniPhysicsSurfaceDeformableMaterialCfg", "SurfaceDeformableBodyMaterialBaseCfg", "SurfaceDeformableBodyMaterialCfg", "spawn_from_mdl_file", @@ -29,6 +33,7 @@ __all__ = [ from .physics_materials import ( spawn_deformable_body_material, spawn_physics_material, + spawn_physics_material_from_fragments, spawn_rigid_body_material, spawn_rigid_body_material_from_fragments, ) @@ -39,6 +44,13 @@ from .physics_materials_cfg import ( PhysicsMaterialCfg, RigidBodyMaterialBaseCfg, RigidBodyMaterialFragment, + UsdPhysicsRigidBodyMaterialCfg, + DeformableBodyMaterialBaseCfg, + DeformableBodyMaterialCfg, + DeformableMaterialFragment, + OmniPhysicsDeformableMaterialCfg, + OmniPhysicsSurfaceDeformableMaterialCfg, + SurfaceDeformableBodyMaterialBaseCfg, SurfaceDeformableBodyMaterialCfg, UsdPhysicsRigidBodyMaterialCfg, diff --git a/source/isaaclab/isaaclab/sim/spawners/materials/physics_materials.py b/source/isaaclab/isaaclab/sim/spawners/materials/physics_materials.py index 749adec3cb5b..f66aeb8f2ab8 100644 --- a/source/isaaclab/isaaclab/sim/spawners/materials/physics_materials.py +++ b/source/isaaclab/isaaclab/sim/spawners/materials/physics_materials.py @@ -7,6 +7,8 @@ import dataclasses +from typing_extensions import deprecated + from pxr import Usd, UsdPhysics, UsdShade from isaaclab.utils import validate @@ -17,20 +19,22 @@ from . import physics_materials_cfg -def spawn_rigid_body_material_from_fragments( +def spawn_physics_material_from_fragments( prim_path: str, fragments: physics_materials_cfg.RigidBodyMaterialFragment - | list[physics_materials_cfg.RigidBodyMaterialFragment] - | tuple[physics_materials_cfg.RigidBodyMaterialFragment, ...], + | physics_materials_cfg.DeformableMaterialFragment + | list[physics_materials_cfg.RigidBodyMaterialFragment | physics_materials_cfg.DeformableMaterialFragment] + | tuple[physics_materials_cfg.RigidBodyMaterialFragment | physics_materials_cfg.DeformableMaterialFragment, ...], stage: Usd.Stage | None = None, ) -> Usd.Prim: - """Spawn a rigid-body physics material from one or more single-namespace fragments. + """Spawn a physics material from one or more single-namespace fragments. Creates (or reuses) the ``UsdShade.Material`` prim at ``prim_path``, applies the standard - ``UsdPhysics.MaterialAPI`` anchor, then dispatches each fragment via its - :attr:`~isaaclab.sim.schemas.SchemaFragment.func` to author its namespace onto the material prim. - Backend fragments carry backend-specific namespaces (e.g. PhysX ``physxMaterial:*``) without core - importing a backend. + ``UsdPhysics.MaterialAPI`` anchor when at least one rigid-body fragment is present, then dispatches + each fragment via its :attr:`~isaaclab.sim.schemas.SchemaFragment.func` to author its namespace onto + the material prim. Rigid-body and deformable fragments may be mixed in a single collection; each + fragment authors its own namespace. Backend fragments carry backend-specific namespaces (e.g. PhysX + ``physxMaterial:*``) without core importing a backend. .. note:: Unlike the ``@clone``-decorated :func:`spawn_rigid_body_material`, this writer expects a @@ -40,17 +44,19 @@ def spawn_rigid_body_material_from_fragments( Args: prim_path: The prim path to spawn the material at. - fragments: A single :class:`~isaaclab.sim.spawners.materials.RigidBodyMaterialFragment`, - or a list/tuple of them. + fragments: A single + :class:`~isaaclab.sim.spawners.materials.RigidBodyMaterialFragment` or + :class:`~isaaclab.sim.spawners.materials.DeformableMaterialFragment`, or a list/tuple of + them (rigid and deformable fragments may be mixed). stage: The stage to spawn on. Defaults to None, in which case the current stage is used. Returns: - The spawned rigid body material prim. + The spawned physics material prim. Raises: ValueError: When ``fragments`` is empty. ValueError: When a prim already exists at the path and is not a material. - TypeError: When an item is not a rigid-body material fragment. + TypeError: When an item is not a rigid-body or deformable material fragment. """ if not isinstance(fragments, (list, tuple)): fragments = [fragments] @@ -61,12 +67,15 @@ def spawn_rigid_body_material_from_fragments( invalid_types = [ type(fragment).__name__ for fragment in fragments - if not isinstance(fragment, physics_materials_cfg.RigidBodyMaterialFragment) + if not isinstance( + fragment, + (physics_materials_cfg.RigidBodyMaterialFragment, physics_materials_cfg.DeformableMaterialFragment), + ) ] if invalid_types: raise TypeError( - "A physics-material fragment collection must contain only RigidBodyMaterialFragment instances; got" - f" {invalid_types}." + "A physics-material fragment collection must contain only physics-material fragments" + f" (rigid or deformable); got {invalid_types}." ) if stage is None: stage = get_current_stage() @@ -78,9 +87,11 @@ def spawn_rigid_body_material_from_fragments( if not prim.IsA(UsdShade.Material): raise ValueError(f"A prim already exists at path: '{prim_path}' but is not a material.") - # apply the standard UsdPhysics MaterialAPI anchor (the defining schema for a physics material) - if not UsdPhysics.MaterialAPI(prim): - UsdPhysics.MaterialAPI.Apply(prim) + # the UsdPhysics MaterialAPI anchor defines a rigid-body physics material; deformable + # fragments never imply it + if any(isinstance(fragment, physics_materials_cfg.RigidBodyMaterialFragment) for fragment in fragments): + if not UsdPhysics.MaterialAPI(prim): + UsdPhysics.MaterialAPI.Apply(prim) # dispatch each fragment's applier (writes its single namespace onto the material prim) for cfg in fragments: @@ -88,25 +99,55 @@ def spawn_rigid_body_material_from_fragments( return prim +@deprecated("Use spawn_physics_material_from_fragments instead; this name will be removed in 3.2.") +def spawn_rigid_body_material_from_fragments( + prim_path: str, + fragments: physics_materials_cfg.RigidBodyMaterialFragment + | physics_materials_cfg.DeformableMaterialFragment + | list[physics_materials_cfg.RigidBodyMaterialFragment | physics_materials_cfg.DeformableMaterialFragment] + | tuple[physics_materials_cfg.RigidBodyMaterialFragment | physics_materials_cfg.DeformableMaterialFragment, ...], + stage: Usd.Stage | None = None, +) -> Usd.Prim: + """Deprecated: use :func:`spawn_physics_material_from_fragments`. + + .. deprecated:: 3.1 + ``spawn_rigid_body_material_from_fragments`` was renamed to + :func:`spawn_physics_material_from_fragments` now that the writer also accepts deformable + material fragments, and is scheduled for removal in 3.2. + + Args: + prim_path: The prim path to spawn the material at. + fragments: A single physics-material fragment, or a list/tuple of them. + stage: The stage to spawn on. Defaults to None, in which case the current stage is used. + + Returns: + The spawned physics material prim. + """ + return spawn_physics_material_from_fragments(prim_path, fragments, stage) + + def spawn_physics_material( prim_path: str, material: physics_materials_cfg.PhysicsMaterialCfg | physics_materials_cfg.RigidBodyMaterialFragment - | list[physics_materials_cfg.RigidBodyMaterialFragment] - | tuple[physics_materials_cfg.RigidBodyMaterialFragment, ...], + | physics_materials_cfg.DeformableMaterialFragment + | list[physics_materials_cfg.RigidBodyMaterialFragment | physics_materials_cfg.DeformableMaterialFragment] + | tuple[physics_materials_cfg.RigidBodyMaterialFragment | physics_materials_cfg.DeformableMaterialFragment, ...], stage: Usd.Stage | None = None, ) -> Usd.Prim: """Spawn a physics material from a spawner ``physics_material`` slot value. - Dispatches the two accepted slot forms: a single rigid-body fragment or list/tuple is spawned via - :func:`spawn_rigid_body_material_from_fragments`; otherwise the value is a legacy material cfg and is - spawned via its own :attr:`func`. Lets spawners accept both the fragment and the legacy interface - from one call site. + Dispatches the two accepted slot forms: a single rigid-body or deformable material fragment (or a + list/tuple of them) is spawned via :func:`spawn_physics_material_from_fragments`; otherwise the + value is a legacy material cfg and is spawned via its own :attr:`func`. A mixed list is legal -- + each fragment authors its own namespace. Lets spawners accept both the fragment and the legacy + interface from one call site. Args: prim_path: The prim path to spawn the material at. - material: A :class:`~isaaclab.sim.spawners.materials.RigidBodyMaterialFragment` (or list/tuple - of them), or a legacy material cfg carrying a :attr:`func`. + material: A :class:`~isaaclab.sim.spawners.materials.RigidBodyMaterialFragment` or + :class:`~isaaclab.sim.spawners.materials.DeformableMaterialFragment` (or list/tuple of + them, possibly mixed), or a legacy material cfg carrying a :attr:`func`. stage: The stage to spawn on. Defaults to None, in which case the current stage is used. A legacy material cfg only supports the current stage (or None); passing a different stage raises. @@ -118,16 +159,25 @@ def spawn_physics_material( ValueError: When ``material`` is a legacy material cfg and ``stage`` is neither ``None`` nor the current stage. TypeError: When ``material`` is a fragment collection containing anything other than - :class:`~isaaclab.sim.spawners.materials.RigidBodyMaterialFragment` instances. - TypeError: When ``material`` is neither a physics-material cfg nor a rigid-body fragment. + :class:`~isaaclab.sim.spawners.materials.RigidBodyMaterialFragment` or + :class:`~isaaclab.sim.spawners.materials.DeformableMaterialFragment` instances. + TypeError: When ``material`` is neither a physics-material cfg nor a physics-material fragment. """ # The family writer owns fragment validation so direct and dispatched calls have one contract. - if isinstance(material, (list, tuple, physics_materials_cfg.RigidBodyMaterialFragment)): - return spawn_rigid_body_material_from_fragments(prim_path, material, stage) + if isinstance( + material, + ( + list, + tuple, + physics_materials_cfg.RigidBodyMaterialFragment, + physics_materials_cfg.DeformableMaterialFragment, + ), + ): + return spawn_physics_material_from_fragments(prim_path, material, stage) if not isinstance(material, physics_materials_cfg.PhysicsMaterialCfg): raise TypeError( - "A physics material must be a PhysicsMaterialCfg or rigid-body material fragment; got" - f" '{type(material).__name__}'." + "A physics material must be a PhysicsMaterialCfg or a physics-material fragment" + f" (rigid or deformable); got '{type(material).__name__}'." ) # legacy single-cfg path (rigid or deformable material cfg with its own spawner ``func``). # Legacy material funcs take only ``(prim_path, cfg)`` and resolve the stage internally via diff --git a/source/isaaclab/isaaclab/sim/spawners/materials/physics_materials_cfg.py b/source/isaaclab/isaaclab/sim/spawners/materials/physics_materials_cfg.py index 001e3ef26ab1..b5c5b34b3dcd 100644 --- a/source/isaaclab/isaaclab/sim/spawners/materials/physics_materials_cfg.py +++ b/source/isaaclab/isaaclab/sim/spawners/materials/physics_materials_cfg.py @@ -159,7 +159,7 @@ class RigidBodyMaterialFragment(SchemaFragment): (:class:`UsdPhysicsRigidBodyMaterialCfg`) and any backend-specific namespace (e.g. PhysX ``physxMaterial:*`` via :class:`~isaaclab_physx.sim.spawners.materials.PhysxMaterialCfg`). The defining ``UsdPhysics.MaterialAPI`` anchor is applied by the family writer - (:func:`~isaaclab.sim.spawners.materials.spawn_rigid_body_material_from_fragments`). + (:func:`~isaaclab.sim.spawners.materials.spawn_physics_material_from_fragments`). """ pass @@ -196,6 +196,61 @@ class UsdPhysicsRigidBodyMaterialCfg(RigidBodyMaterialFragment): """ +@configclass +class DeformableMaterialFragment(SchemaFragment): + """Marker base for deformable physics-material fragments; widens the ``physics_material`` slot. + + Deformable material fragments never imply the ``UsdPhysics.MaterialAPI`` anchor; each + fragment applies its own schema when it owns one. + """ + + pass + + +@configclass +class OmniPhysicsDeformableMaterialCfg(DeformableMaterialFragment): + """``omniphysics:*`` deformable-material attributes from ``OmniPhysicsDeformableMaterialAPI``.""" + + _usd_namespace: ClassVar[str | None] = "omniphysics" + _usd_applied_schema: ClassVar[str | None] = "OmniPhysicsDeformableMaterialAPI" + + density: float | None = None + """The material density [kg/m^3].""" + + static_friction: float | None = None + """The static friction coefficient.""" + + dynamic_friction: float | None = None + """The dynamic friction coefficient.""" + + youngs_modulus: float | None = None + """The Young's modulus defining the body's stiffness [Pa].""" + + poissons_ratio: float | None = None + """The Poisson's ratio defining the body's volume preservation.""" + + +@configclass +class OmniPhysicsSurfaceDeformableMaterialCfg(DeformableMaterialFragment): + """``omniphysics:*`` surface-deformable-material attributes from + ``OmniPhysicsSurfaceDeformableMaterialAPI``.""" + + _usd_namespace: ClassVar[str | None] = "omniphysics" + _usd_applied_schema: ClassVar[str | None] = "OmniPhysicsSurfaceDeformableMaterialAPI" + + surface_thickness: float | None = None + """The thickness of the deformable surface [m].""" + + surface_stretch_stiffness: float | None = None + """The stretch stiffness of the deformable surface [Pa]. Zero means solver-derived.""" + + surface_shear_stiffness: float | None = None + """The shear stiffness of the deformable surface [Pa]. Zero means solver-derived.""" + + surface_bend_stiffness: float | None = None + """The bend stiffness of the deformable surface [Pa]. Zero means solver-derived.""" + + @configclass class DeformableBodyMaterialBaseCfg(PhysicsMaterialCfg): """Base physics material parameters for volume deformable bodies. diff --git a/source/isaaclab/isaaclab/sim/spawners/meshes/meshes.py b/source/isaaclab/isaaclab/sim/spawners/meshes/meshes.py index f6bd7c68b4ff..5d41e03bf837 100644 --- a/source/isaaclab/isaaclab/sim/spawners/meshes/meshes.py +++ b/source/isaaclab/isaaclab/sim/spawners/meshes/meshes.py @@ -22,7 +22,7 @@ SurfaceDeformableBodyMaterialBaseCfg, ) from ..materials.physics_materials import spawn_physics_material -from ..utils import apply_schema_props, fragment_mapping, props_expr +from ..utils import apply_schema_props, fragment_mapping, props_expr, resolve_deformable_slot if TYPE_CHECKING: from . import meshes_cfg @@ -274,7 +274,12 @@ def _refine_surface_mesh(mesh: trimesh.Trimesh, cfg: meshes_cfg.MeshCfg) -> trim """ if cfg.edge_refinement < 1.0: raise ValueError(f"Mesh edge refinement must be at least 1.0, got {cfg.edge_refinement}.") - if cfg.deformable_props is None or cfg.edge_refinement == 1.0: + # refinement applies to any deformable spelling, so the fragment slots get the same + # simulation-mesh resolution the legacy ``deformable_props`` field does + is_deformable = any( + props is not None for props in (cfg.deformable_props, cfg.volume_deformable_props, cfg.surface_deformable_props) + ) + if not is_deformable or cfg.edge_refinement == 1.0: return mesh max_edge = float(np.linalg.norm(mesh.bounding_box.extents)) / cfg.edge_refinement @@ -354,16 +359,12 @@ def _spawn_mesh_geom_from_mesh( prim = create_prim(prim_path, prim_type="Xform", translation=translation, orientation=orientation, stage=stage) # check that invalid schema types are not used - if cfg.deformable_props is not None and cfg.rigid_props is not None: + deformable_slot = resolve_deformable_slot(cfg) + if (deformable_slot is not None or cfg.deformable_props is not None) and cfg.rigid_props is not None: raise ValueError("Cannot use both deformable and rigid properties at the same time.") - if cfg.deformable_props is not None and cfg.collision_props is not None: + if (deformable_slot is not None or cfg.deformable_props is not None) and cfg.collision_props is not None: # only fragments resolve onto the simulation mesh, legacy cfgs would target the inert body prim - collision_props_mapping = fragment_mapping(cfg.collision_props) - if collision_props_mapping is not None: - frags = [frag for fragments in collision_props_mapping.values() for frag in fragments] - else: - frags = [cfg.collision_props] - if not frags or not all(isinstance(frag, schemas.SchemaFragment) for frag in frags): + if fragment_mapping(cfg.collision_props) is None: raise ValueError("Deformable bodies require 'collision_props' as collision fragments.") # check material types are correct if cfg.deformable_props is not None and cfg.physics_material is not None: @@ -375,7 +376,7 @@ def _spawn_mesh_geom_from_mesh( physics_material_frags = ( cfg.physics_material if isinstance(cfg.physics_material, (list, tuple)) else [cfg.physics_material] ) - is_rigid_material = isinstance(cfg.physics_material, RigidBodyMaterialBaseCfg) or all( + is_rigid_material = isinstance(cfg.physics_material, RigidBodyMaterialBaseCfg) or any( isinstance(frag, RigidBodyMaterialFragment) for frag in physics_material_frags ) if not is_rigid_material: @@ -396,7 +397,17 @@ def _spawn_mesh_geom_from_mesh( stage=stage, ) - if cfg.deformable_props is not None: + if deformable_slot is not None: + kind, mapping = deformable_slot + writer = ( + schemas.apply_volume_deformable_properties + if kind == "volume" + else schemas.apply_surface_deformable_properties + ) + kwargs = {"tetrahedralization_edge_length_fac": 1.0 / cfg.edge_refinement} if kind == "volume" else {} + for pattern, fragments in mapping.items(): + writer(props_expr(prim_path, pattern), fragments, create_if_missing=True, stage=stage, **kwargs) + elif cfg.deformable_props is not None: # apply deformable body properties deformable_type = ( "surface" if isinstance(cfg.physics_material, SurfaceDeformableBodyMaterialBaseCfg) else "volume" @@ -411,13 +422,6 @@ def _spawn_mesh_geom_from_mesh( deformable_type=deformable_type, **deformable_kwargs, ) - if cfg.collision_props is not None: - _apply_deformable_collision_props(prim_path, cfg.collision_props, stage) - if cfg.mass_props is not None: - raise ValueError( - """MassPropertiesCfg are not supported for deformable bodies - and should be set through deformable_props with mass=.""" - ) elif cfg.collision_props is not None: # decide on type of collision approximation based on the mesh if cfg.__class__.__name__ == "MeshSphereCfg": @@ -438,6 +442,12 @@ def _spawn_mesh_geom_from_mesh( stage, ) + if deformable_slot is not None or cfg.deformable_props is not None: + if cfg.collision_props is not None: + _apply_deformable_collision_props(prim_path, cfg.collision_props, stage) + if cfg.mass_props is not None: + raise ValueError("Set deformable mass through deformable body properties, not mass_props.") + if cfg.visual_material is not None: material_path = ( cfg.visual_material_path diff --git a/source/isaaclab/isaaclab/sim/spawners/meshes/meshes_cfg.py b/source/isaaclab/isaaclab/sim/spawners/meshes/meshes_cfg.py index b969c8afd71f..2580165740e0 100644 --- a/source/isaaclab/isaaclab/sim/spawners/meshes/meshes_cfg.py +++ b/source/isaaclab/isaaclab/sim/spawners/meshes/meshes_cfg.py @@ -63,14 +63,16 @@ class MeshCfg(RigidObjectSpawnerCfg, DeformableObjectSpawnerCfg): physics_material: ( materials.PhysicsMaterialCfg | materials.RigidBodyMaterialFragment - | list[materials.RigidBodyMaterialFragment] + | materials.DeformableMaterialFragment + | list[materials.RigidBodyMaterialFragment | materials.DeformableMaterialFragment] | None ) = None """Physics material properties. Accepts either a legacy material cfg, a single - :class:`~isaaclab.sim.spawners.materials.RigidBodyMaterialFragment`, or a list of such - single-namespace fragments. + :class:`~isaaclab.sim.spawners.materials.RigidBodyMaterialFragment` or + :class:`~isaaclab.sim.spawners.materials.DeformableMaterialFragment`, or a list of such + single-namespace fragments (rigid and deformable fragments may be mixed in one list). Note: If None, then no physics material will be added. diff --git a/source/isaaclab/isaaclab/sim/spawners/spawner_cfg.py b/source/isaaclab/isaaclab/sim/spawners/spawner_cfg.py index 64c1b73033ca..b4ff7e7e1bf7 100644 --- a/source/isaaclab/isaaclab/sim/spawners/spawner_cfg.py +++ b/source/isaaclab/isaaclab/sim/spawners/spawner_cfg.py @@ -179,6 +179,10 @@ class DeformableObjectSpawnerCfg(SpawnerCfg): Deformable bodies collide through their simulation mesh, so collision offsets are set through the mesh spawner's ``collision_props`` rather than :attr:`deformable_props`. + When a deformable slot (:attr:`volume_deformable_props` or :attr:`surface_deformable_props`) is set, + collision tuning rides the :attr:`~isaaclab.sim.spawners.RigidObjectSpawnerCfg.collision_props` family + keyed to the created simulation mesh (e.g. ``{"/sim_mesh": [...]}``), anchored at the spawn prim. + Note: By default, all properties are set to None. This means that no properties will be added or modified to the prim outside of the properties available by default when spawning the prim. @@ -189,3 +193,42 @@ class DeformableObjectSpawnerCfg(SpawnerCfg): deformable_props: schemas.DeformableBodyPropertiesBaseCfg | None = None """Deformable body properties.""" + + volume_deformable_props: ( + dict[str, list[schemas.DeformableBodyFragment]] + | schemas.DeformableBodyFragment + | list[schemas.DeformableBodyFragment] + | None + ) = None + """Volume (tetrahedral FEM) deformable-body properties as a mapping from target pattern to a + list of :class:`~isaaclab.sim.schemas.DeformableBodyFragment` fragments. + + Keys are regular-expression suffixes appended to the spawn prim (the container prim for both mesh and USD assets), + so a key carries its own leading ``/`` when it targets descendants (``""`` the spawn prim, ``"/[^/]+"`` its direct + children, ``"(/.*)?"`` the spawn prim and its whole subtree). Entries are applied in insertion order, so on + overlapping targets later entries override earlier ones per attribute. The spawner always creates missing + deformable setups on matched prims (equivalent to ``create_if_missing=True``); the simulation mesh is created as a + ``sim_mesh`` child of each target. + + As a shorthand, a bare fragment or a list of fragments is read as ``{"": [...]}``, i.e. the spawn + prim itself, on every spawner type. Unlike the rigid-body, collision, and mass slots, the shorthand + never widens to the spawn prim's subtree: the deformable writers create a whole simulation-mesh + setup on each target, so a subtree default would tetrahedralize every mesh under the asset. + + At most one of :attr:`volume_deformable_props`, :attr:`surface_deformable_props`, and the + legacy :attr:`deformable_props` may be set. ``UsdPhysics.MassAPI`` is ignored for deformable + bodies; set mass through :class:`~isaaclab.sim.schemas.OmniPhysicsDeformableBodyCfg`. + """ + + surface_deformable_props: ( + dict[str, list[schemas.DeformableBodyFragment]] + | schemas.DeformableBodyFragment + | list[schemas.DeformableBodyFragment] + | None + ) = None + """Surface (cloth/triangle-mesh) deformable-body properties as a mapping from target pattern + to a list of :class:`~isaaclab.sim.schemas.DeformableBodyFragment` fragments. + + Same key semantics and shorthand as :attr:`volume_deformable_props`; the simulation mesh is a + triangle-mesh copy of the visual mesh instead of a tetrahedral mesh. + """ diff --git a/source/isaaclab/isaaclab/sim/spawners/utils.py b/source/isaaclab/isaaclab/sim/spawners/utils.py index 63c0af4164e3..5e84db105f35 100644 --- a/source/isaaclab/isaaclab/sim/spawners/utils.py +++ b/source/isaaclab/isaaclab/sim/spawners/utils.py @@ -131,3 +131,27 @@ def subtree_carries_api(prim_path: str, api_type, stage) -> bool: if candidate.HasAPI(api_type): return True return False + + +def resolve_deformable_slot(cfg) -> tuple[str, dict] | None: + """Select one deformable family; convenience forms target only the spawn prim. + + Unlike rigid-body tuning, deformable creation must not expand to every mesh in a file. + Setting an empty slot still requests a deformable body with default properties. + """ + active = [ + (kind, value) + for kind, value in (("volume", cfg.volume_deformable_props), ("surface", cfg.surface_deformable_props)) + if value is not None + ] + if len(active) + (cfg.deformable_props is not None) > 1: + raise ValueError( + "Set only one deformable slot: volume_deformable_props, surface_deformable_props, or deformable_props." + ) + if not active: + return None + kind, value = active[0] + mapping = fragment_mapping(value) + if mapping is None: + raise TypeError(f"{kind}_deformable_props requires a fragment, fragment sequence, or target mapping.") + return kind, mapping or {"": []} diff --git a/source/isaaclab/isaaclab/sim/spawners/wrappers/wrappers.py b/source/isaaclab/isaaclab/sim/spawners/wrappers/wrappers.py index 9def05d9ac3d..55323299e325 100644 --- a/source/isaaclab/isaaclab/sim/spawners/wrappers/wrappers.py +++ b/source/isaaclab/isaaclab/sim/spawners/wrappers/wrappers.py @@ -83,7 +83,15 @@ def spawn_multi_asset( else: asset_cfg.semantic_tags += cfg.semantic_tags # override settings for properties - attr_names = ["mass_props", "rigid_props", "collision_props", "activate_contact_sensors", "deformable_props"] + attr_names = [ + "mass_props", + "rigid_props", + "collision_props", + "activate_contact_sensors", + "deformable_props", + "volume_deformable_props", + "surface_deformable_props", + ] for attr_name in attr_names: attr_value = getattr(cfg, attr_name) if hasattr(asset_cfg, attr_name) and attr_value is not None: diff --git a/source/isaaclab/test/sim/test_deformable_fragments.py b/source/isaaclab/test/sim/test_deformable_fragments.py new file mode 100644 index 000000000000..b99d827138a1 --- /dev/null +++ b/source/isaaclab/test/sim/test_deformable_fragments.py @@ -0,0 +1,131 @@ +# 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 + +"""Deformable creation, tuning, and spawner targeting.""" + +from isaaclab.test.utils import launch_test_simulation + +launch_test_simulation() + +import pytest + +from pxr import Usd, UsdGeom, UsdPhysics + +import isaaclab.sim as sim_utils +from isaaclab.sim import SimulationCfg, SimulationContext +from isaaclab.sim.schemas import OmniPhysicsDeformableBodyCfg, UsdPhysicsCollisionCfg +from isaaclab.sim.spawners.materials import OmniPhysicsSurfaceDeformableMaterialCfg + +pytestmark = pytest.mark.integration + + +@pytest.fixture +def stage(): + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(device="cpu")) + yield sim_utils.get_current_stage() + SimulationContext.clear_instance() + + +def _volume_asset(stage, path): + body = UsdGeom.Xform.Define(stage, path).GetPrim() + mesh = UsdGeom.TetMesh.Define(stage, path + "/tet") + mesh.CreatePointsAttr([(0, 0, 0), (1, 0, 0), (0, 1, 0), (0, 0, 1)]) + mesh.CreateTetVertexIndicesAttr([(0, 1, 2, 3)]) + return body + + +def test_create_and_modify_deformable(stage, caplog): + """Reuse a supplied TetMesh, tune without rebuilding, and report missing targets.""" + stage = Usd.Stage.CreateInMemory() + body = _volume_asset(stage, "/World/Soft") + assert sim_utils.apply_volume_deformable_properties( + "/World/Soft", [OmniPhysicsDeformableBodyCfg(mass=0.5)], create_if_missing=True, stage=stage + ) + assert sim_utils.has_deformable_body_api(body) + mesh = stage.GetPrimAtPath("/World/Soft/tet") + assert mesh.HasAPI(UsdPhysics.CollisionAPI) + assert body.GetAttribute("omniphysics:mass").Get() == pytest.approx(0.5) + points = mesh.GetAttribute("points").Get() + assert sim_utils.apply_volume_deformable_properties( + "/World/Soft", [OmniPhysicsDeformableBodyCfg(mass=1.0)], stage=stage + ) + assert mesh.GetAttribute("points").Get() == points + assert not stage.GetPrimAtPath("/World/Soft/sim_mesh") + assert body.GetAttribute("omniphysics:mass").Get() == pytest.approx(1.0) + assert not sim_utils.apply_volume_deformable_properties( + "/World/Missing", [OmniPhysicsDeformableBodyCfg()], stage=stage + ) + assert "No deformable-body targets matched" in caplog.text + + +def test_deformable_type_respects_nested_bodies(stage, caplog): + """Reject the wrong family without mistaking a nested body's mesh for its parent's.""" + outer = UsdGeom.Xform.Define(stage, "/World/Outer").GetPrim() + nested = UsdGeom.Xform.Define(stage, "/World/Outer/A_nested").GetPrim() + for body, kind in ((nested, "Surface"), (outer, "Volume")): + body.AddAppliedSchema("PhysicsDeformableBodyAPI") + UsdGeom.Mesh.Define(stage, str(body.GetPath()) + "/sim_mesh").GetPrim().AddAppliedSchema( + f"Physics{kind}DeformableSimAPI" + ) + assert not sim_utils.apply_volume_deformable_properties( + "/World/Outer(/A_nested)?", [OmniPhysicsDeformableBodyCfg(mass=1.0)], create_if_missing=True, stage=stage + ) + assert outer.GetAttribute("omniphysics:mass").Get() == pytest.approx(1.0) + assert not nested.GetAttribute("omniphysics:mass").IsValid() + assert "surface" in caplog.text and "volume" in caplog.text + assert sim_utils.apply_surface_deformable_properties( + "/World/Outer/A_nested", [OmniPhysicsDeformableBodyCfg(mass=2.0)], stage=stage + ) + assert nested.GetAttribute("omniphysics:mass").Get() == pytest.approx(2.0) + + +@pytest.mark.parametrize("props", [OmniPhysicsDeformableBodyCfg(mass=0.2), [], {}]) +def test_mesh_surface_deformable_spawn(stage, props): + """Bare and empty slots create one body; collision fragments target its simulation mesh.""" + cfg = sim_utils.MeshRectangleCfg( + size=(0.1, 0.1), + surface_deformable_props=props, + physics_material=OmniPhysicsSurfaceDeformableMaterialCfg(surface_thickness=0.01), + collision_props=UsdPhysicsCollisionCfg(collision_enabled=False), + ) + cfg.func("/World/Cloth", cfg) + body = stage.GetPrimAtPath("/World/Cloth") + assert sim_utils.has_deformable_body_api(body) + mesh = stage.GetPrimAtPath("/World/Cloth/sim_mesh") + assert mesh.GetAttribute("physics:collisionEnabled").Get() is False + assert not body.HasAPI(UsdPhysics.CollisionAPI) + assert not stage.GetPrimAtPath("/World/Cloth/geometry/mesh/sim_mesh") + if isinstance(props, OmniPhysicsDeformableBodyCfg): + assert body.GetAttribute("omniphysics:mass").Get() == pytest.approx(0.2) + + +@pytest.mark.parametrize( + "kwargs, message", + [ + ({"volume_deformable_props": []}, "one deformable"), + ({"deformable_props": sim_utils.DeformableBodyPropertiesBaseCfg()}, "one deformable"), + ({"rigid_props": sim_utils.UsdPhysicsRigidBodyCfg()}, "both deformable and rigid"), + ({"collision_props": sim_utils.CollisionBaseCfg()}, "collision fragments"), + ], +) +def test_mesh_rejects_conflicting_deformable_properties(stage, kwargs, message): + cfg = sim_utils.MeshRectangleCfg(size=(0.1, 0.1), surface_deformable_props=[], **kwargs) + with pytest.raises(ValueError, match=message): + cfg.func("/World/Bad", cfg) + + +def test_usd_file_deformable_targets_only_spawn_prim(stage, tmp_path): + asset = Usd.Stage.CreateNew(str(tmp_path / "tet.usda")) + asset.SetDefaultPrim(_volume_asset(asset, "/Asset")) + asset.Save() + cfg = sim_utils.UsdFileCfg( + usd_path=asset.GetRootLayer().identifier, volume_deformable_props=OmniPhysicsDeformableBodyCfg(mass=0.5) + ) + cfg.func("/World/Soft", cfg) + assert sim_utils.has_deformable_body_api(stage.GetPrimAtPath("/World/Soft")) + assert stage.GetPrimAtPath("/World/Soft/tet").HasAPI(UsdPhysics.CollisionAPI) + assert not stage.GetPrimAtPath("/World/Soft/sim_mesh") + assert not sim_utils.has_deformable_body_api(stage.GetPrimAtPath("/World/Soft/tet")) diff --git a/source/isaaclab/test/sim/test_material_fragments.py b/source/isaaclab/test/sim/test_material_fragments.py index 78b2cb30e200..a2ea9559d7c5 100644 --- a/source/isaaclab/test/sim/test_material_fragments.py +++ b/source/isaaclab/test/sim/test_material_fragments.py @@ -29,16 +29,16 @@ def cleanup_simulation_context(): # ------------------------------------------------------------------------------------- -def test_spawn_rigid_body_material_from_fragments_composes_namespaces(): +def test_spawn_physics_material_from_fragments_composes_namespaces(): from isaaclab_physx.sim.spawners.materials.physics_materials_cfg import PhysxMaterialCfg - from isaaclab.sim.spawners.materials.physics_materials import spawn_rigid_body_material_from_fragments + from isaaclab.sim.spawners.materials.physics_materials import spawn_physics_material_from_fragments from isaaclab.sim.spawners.materials.physics_materials_cfg import UsdPhysicsRigidBodyMaterialCfg sim_utils.create_new_stage() SimulationContext(SimulationCfg(dt=0.01)) stage = sim_utils.get_current_stage() - prim = spawn_rigid_body_material_from_fragments( + prim = spawn_physics_material_from_fragments( "/World/Mat", [ UsdPhysicsRigidBodyMaterialCfg(static_friction=0.7, dynamic_friction=0.6, restitution=0.1, density=1200.0), @@ -66,14 +66,14 @@ def test_spawn_rigid_body_material_from_fragments_composes_namespaces(): assert prim.GetAttribute("physxMaterial:compliantContactAccelerationSpring").Get() is True -def test_spawn_rigid_body_material_from_fragments_accepts_single_fragment(): - from isaaclab.sim.spawners.materials.physics_materials import spawn_rigid_body_material_from_fragments +def test_spawn_physics_material_from_fragments_accepts_single_fragment(): + from isaaclab.sim.spawners.materials.physics_materials import spawn_physics_material_from_fragments from isaaclab.sim.spawners.materials.physics_materials_cfg import UsdPhysicsRigidBodyMaterialCfg sim_utils.create_new_stage() SimulationContext(SimulationCfg(dt=0.01)) stage = sim_utils.get_current_stage() - prim = spawn_rigid_body_material_from_fragments( + prim = spawn_physics_material_from_fragments( "/World/Mat2", UsdPhysicsRigidBodyMaterialCfg(static_friction=0.3), stage ) assert prim.IsA(UsdShade.Material) @@ -81,6 +81,28 @@ def test_spawn_rigid_body_material_from_fragments_accepts_single_fragment(): assert prim.GetAttribute("physics:staticFriction").Get() == pytest.approx(0.3) +def test_spawn_rigid_body_material_from_fragments_alias_warns_and_forwards(): + """The pre-rename public name stays importable, warns, and authors through the new writer.""" + import warnings + + from isaaclab.sim.spawners.materials import spawn_rigid_body_material_from_fragments + from isaaclab.sim.spawners.materials.physics_materials_cfg import UsdPhysicsRigidBodyMaterialCfg + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + prim = spawn_rigid_body_material_from_fragments( + "/World/MatAlias", UsdPhysicsRigidBodyMaterialCfg(static_friction=0.5), stage + ) + assert any(issubclass(w.category, DeprecationWarning) for w in caught) + assert any("spawn_physics_material_from_fragments" in str(w.message) for w in caught) + assert prim.IsA(UsdShade.Material) + assert bool(UsdPhysics.MaterialAPI(prim)) + assert prim.GetAttribute("physics:staticFriction").Get() == pytest.approx(0.5) + + def test_spawn_physics_material_dispatches_fragments_and_legacy(): """The shared dispatcher handles both a rigid-body fragment collection and a legacy material cfg carrying its own ``func``.""" @@ -103,6 +125,36 @@ def test_spawn_physics_material_dispatches_fragments_and_legacy(): assert legacy_prim.GetAttribute("physics:staticFriction").Get() == pytest.approx(0.9) +def test_spawn_physics_material_accepts_deformable_and_mixed_fragments(): + from pxr import UsdPhysics, UsdShade + + import isaaclab.sim as sim_utils + from isaaclab.sim import SimulationCfg, SimulationContext + from isaaclab.sim.spawners.materials import ( + OmniPhysicsDeformableMaterialCfg, + UsdPhysicsRigidBodyMaterialCfg, + spawn_physics_material, + ) + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + # deformable-only list: NO MaterialAPI anchor + prim = spawn_physics_material("/World/DefMat", [OmniPhysicsDeformableMaterialCfg(youngs_modulus=1e6)], stage) + assert prim.IsA(UsdShade.Material) + assert not prim.HasAPI(UsdPhysics.MaterialAPI) + assert abs(prim.GetAttribute("omniphysics:youngsModulus").Get() - 1e6) < 1.0 + # mixed list: legal, anchor applied because a rigid fragment is present + prim2 = spawn_physics_material( + "/World/MixMat", + [UsdPhysicsRigidBodyMaterialCfg(static_friction=0.5), OmniPhysicsDeformableMaterialCfg(density=1000.0)], + stage, + ) + assert prim2.HasAPI(UsdPhysics.MaterialAPI) + assert prim2.GetAttribute("physics:staticFriction").Get() is not None + assert prim2.GetAttribute("omniphysics:density").Get() is not None + + def test_spawn_physics_material_rejects_non_current_stage_for_legacy(): """The legacy path is current-stage-bound; an explicit different stage raises instead of silently authoring on the current stage. Passing the current stage explicitly stays valid @@ -131,7 +183,7 @@ def test_fragment_writer_validates_inputs_before_authoring(): from isaaclab.sim.spawners.materials.physics_materials import ( spawn_physics_material, - spawn_rigid_body_material_from_fragments, + spawn_physics_material_from_fragments, ) from isaaclab.sim.spawners.materials.physics_materials_cfg import UsdPhysicsRigidBodyMaterialCfg @@ -140,7 +192,7 @@ def test_fragment_writer_validates_inputs_before_authoring(): stage = sim_utils.get_current_stage() with pytest.raises(ValueError): - spawn_rigid_body_material_from_fragments("/World/MatEmpty", [], stage) + spawn_physics_material_from_fragments("/World/MatEmpty", [], stage) # a list mixing a fragment with a legacy cfg is not a valid fragment list with pytest.raises(TypeError): spawn_physics_material( @@ -148,7 +200,7 @@ def test_fragment_writer_validates_inputs_before_authoring(): [UsdPhysicsRigidBodyMaterialCfg(static_friction=0.4), PhysxRigidBodyMaterialCfg(static_friction=0.9)], ) with pytest.raises(TypeError): - spawn_rigid_body_material_from_fragments("/World/MatLegacy", PhysxRigidBodyMaterialCfg(), stage) + spawn_physics_material_from_fragments("/World/MatLegacy", PhysxRigidBodyMaterialCfg(), stage) with pytest.raises(TypeError): spawn_physics_material("/World/MatInvalid", object()) for path in ("MatEmpty", "MatMixed", "MatLegacy", "MatInvalid"): diff --git a/source/isaaclab/test/sim/test_schemas.py b/source/isaaclab/test/sim/test_schemas.py index 4986ea580414..bc8d17308a5f 100644 --- a/source/isaaclab/test/sim/test_schemas.py +++ b/source/isaaclab/test/sim/test_schemas.py @@ -988,3 +988,51 @@ def _validate_joint_drive_properties_on_prim(prim_path: str, joint_cfg, verbose: ) elif verbose: print(f"Skipping prim {joint_prim.GetPrimPath()} as it is not a joint drive api.") + + +@pytest.mark.isaacsim_ci +@pytest.mark.parametrize("kind", ["volume", "surface"]) +def test_physx_deformable_fragments(setup_simulation, kind): + """Create OmniPhysics rest/bind poses and compose PhysX body and material fragments.""" + from isaaclab_physx.physics import PhysxManager + from isaaclab_physx.sim.schemas import PhysxDeformableBodyCfg, PhysxSurfaceDeformableBodyCfg + from isaaclab_physx.sim.spawners.materials import PhysxDeformableMaterialCfg, PhysxSurfaceDeformableMaterialCfg + + from pxr import Usd, UsdGeom + + from isaaclab.sim.spawners.materials import spawn_physics_material_from_fragments + + stage = Usd.Stage.CreateInMemory() + body = UsdGeom.Xform.Define(stage, "/Body").GetPrim() + visual = UsdGeom.Mesh.Define(stage, "/Body/visual") + visual.CreatePointsAttr([(0, 0, 0), (1, 0, 0), (0, 1, 0)]) + mesh = UsdGeom.TetMesh.Define(stage, "/Body/sim") if kind == "volume" else UsdGeom.Mesh.Define(stage, "/Body/sim") + mesh.CreatePointsAttr([(0, 0, 0), (1, 0, 0), (0, 1, 0), (0, 0, 1)]) + if kind == "volume": + mesh.CreateTetVertexIndicesAttr([(0, 1, 2, 3)]) + else: + mesh.CreateFaceVertexIndicesAttr([0, 1, 2]) + mesh.CreateFaceVertexCountsAttr([3]) + PhysxManager.setup_deformable_body(body, kind, mesh.GetPrim(), visual.GetPrim()) + assert body.HasAPI("OmniPhysicsDeformableBodyAPI") + assert mesh.GetPrim().HasAPI(f"OmniPhysics{kind.title()}DeformableSimAPI") + assert mesh.GetPrim().GetAttribute("omniphysics:restShapePoints").Get() == mesh.GetPointsAttr().Get() + rest_indices = "restTetVtxIndices" if kind == "volume" else "restTriVtxIndices" + assert len(mesh.GetPrim().GetAttribute("omniphysics:" + rest_indices).Get()) == 1 + assert ( + visual.GetPrim().GetAttribute("deformablePose:default:omniphysics:points").Get() == visual.GetPointsAttr().Get() + ) + body_cfg = PhysxDeformableBodyCfg(solver_position_iteration_count=32, linear_damping=0.1) + assert body_cfg.func(body_cfg, "/Body", stage) + assert body.GetAttribute("physxDeformableBody:solverPositionIterationCount").Get() == 32 + assert body.GetAttribute("physxDeformableBody:linearDamping").Get() == pytest.approx(0.1) + materials = [PhysxDeformableMaterialCfg(elasticity_damping=0.005)] + if kind == "surface": + surface_cfg = PhysxSurfaceDeformableBodyCfg(collision_pair_update_frequency=2) + assert surface_cfg.func(surface_cfg, "/Body", stage) + assert body.GetAttribute("physxDeformableBody:collisionPairUpdateFrequency").Get() == 2 + materials.append(PhysxSurfaceDeformableMaterialCfg(bend_damping=0.1)) + material = spawn_physics_material_from_fragments("/Material", materials, stage) + assert material.GetAttribute("physxDeformableMaterial:elasticityDamping").Get() == pytest.approx(0.005) + if kind == "surface": + assert material.GetAttribute("physxDeformableMaterial:bendDamping").Get() == pytest.approx(0.1) diff --git a/source/isaaclab/test/sim/test_schemas_deprecation.py b/source/isaaclab/test/sim/test_schemas_deprecation.py index eacb57c59bda..8cf9d6dee2e7 100644 --- a/source/isaaclab/test/sim/test_schemas_deprecation.py +++ b/source/isaaclab/test/sim/test_schemas_deprecation.py @@ -94,6 +94,7 @@ "UsdPhysicsCollisionCfg", "UsdPhysicsDriveCfg", "UsdPhysicsMeshCollisionCfg", + "OmniPhysicsDeformableBodyCfg", ] CURRENT_PHYSX_FRAGMENTS = [ @@ -106,6 +107,8 @@ "PhysxTriangleMeshCfg", "PhysxTriangleMeshSimplificationCfg", "PhysxSDFMeshCfg", + "PhysxDeformableBodyCfg", + "PhysxSurfaceDeformableBodyCfg", ] CURRENT_NEWTON_FRAGMENTS = [ diff --git a/source/isaaclab/test/sim/test_spawn_meshes.py b/source/isaaclab/test/sim/test_spawn_meshes.py index aa017e7bb126..282c9d48f0c6 100644 --- a/source/isaaclab/test/sim/test_spawn_meshes.py +++ b/source/isaaclab/test/sim/test_spawn_meshes.py @@ -98,8 +98,9 @@ def test_spawn_mesh_with_edge_refinement(sim, monkeypatch, cfg_type, kwargs, edg (sim_utils.MeshRectangleCfg, {"size": (1.0, 1.0)}, {}, sim_utils.PhysxSurfaceDeformableBodyMaterialCfg(), None), ], ) +@pytest.mark.parametrize("fragments", [False, True]) def test_edge_refinement_sets_tetrahedralization_resolution( - sim, monkeypatch, cfg_type, geometry_kwargs, refinement_kwargs, physics_material, expected_factor + sim, monkeypatch, cfg_type, geometry_kwargs, refinement_kwargs, physics_material, expected_factor, fragments ): """Test edge refinement is forwarded to volume tetrahedralization.""" captured_kwargs = {} @@ -107,9 +108,16 @@ def test_edge_refinement_sets_tetrahedralization_resolution( def capture_deformable_properties(*args, **kwargs): captured_kwargs.update(kwargs) - monkeypatch.setattr(mesh_spawner.schemas, "define_deformable_body_properties", capture_deformable_properties) + kind = "surface" if expected_factor is None else "volume" + writer = f"apply_{kind}_deformable_properties" if fragments else "define_deformable_body_properties" + monkeypatch.setattr(mesh_spawner.schemas, writer, capture_deformable_properties) + props = ( + {f"{kind}_deformable_props": sim_utils.OmniPhysicsDeformableBodyCfg()} + if fragments + else {"deformable_props": sim_utils.DeformableBodyPropertiesCfg()} + ) cfg = cfg_type( - deformable_props=sim_utils.DeformableBodyPropertiesCfg(), + **props, physics_material=physics_material, **geometry_kwargs, **refinement_kwargs, diff --git a/source/isaaclab_newton/changelog.d/newton-coordinate-targets.skip b/source/isaaclab_newton/changelog.d/newton-coordinate-targets.skip new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/source/isaaclab_newton/changelog.d/vidurv-deformable-fragments.minor.rst b/source/isaaclab_newton/changelog.d/vidurv-deformable-fragments.minor.rst new file mode 100644 index 000000000000..b8350cd20073 --- /dev/null +++ b/source/isaaclab_newton/changelog.d/vidurv-deformable-fragments.minor.rst @@ -0,0 +1,9 @@ +Added +^^^^^ + +* Added the Newton deformable material fragments + :class:`~isaaclab_newton.sim.spawners.materials.NewtonVolumeDeformableMaterialCfg` and + :class:`~isaaclab_newton.sim.spawners.materials.NewtonSurfaceDeformableMaterialCfg`, authoring the + ``newton:*`` attributes read by the Newton deformable-body builder hooks. +* Added :meth:`~isaaclab_newton.physics.NewtonManager.setup_deformable_body`, applying Newton's + token deformable anchor schemas and syncing the visual mesh geometry from the simulation mesh. diff --git a/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py b/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py index 0d01ef42dc7a..ef081c173a70 100644 --- a/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py +++ b/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py @@ -430,6 +430,39 @@ class NewtonManager(PhysicsManager): shadowing the base attributes on a concrete solver subclass. """ + @classmethod + def provides_implicit_damping(cls) -> bool: + # Newton's symplectic integrator has no implicit damping. + return False + + @classmethod + def setup_deformable_body(cls, prim: Any, deformable_type: str, sim_mesh_prim: Any, vis_mesh_prim: Any) -> None: + """Apply Newton's token deformable anchor schemas and sync the visual mesh geometry.""" + sim_mesh_path = sim_mesh_prim.GetPath().pathString + token = "PhysicsVolumeDeformableSimAPI" if deformable_type == "volume" else "PhysicsSurfaceDeformableSimAPI" + if not sim_mesh_prim.AddAppliedSchema(token): + raise RuntimeError(f"Failed to set {deformable_type} deformable sim API on prim '{sim_mesh_path}'.") + # Newton renders the simulation mesh directly: overwrite the visual mesh geometry until + # separate visual/simulation meshes are supported. + vis_mesh = UsdGeom.Mesh(vis_mesh_prim) + if deformable_type == "volume": + tet_mesh = UsdGeom.TetMesh(sim_mesh_prim) + surface_indices = tet_mesh.GetSurfaceFaceVertexIndicesAttr().Get() + if surface_indices is None or len(surface_indices) == 0: + raise ValueError( + f"Deformable body at '{prim.GetPath().pathString}' has no surface indices on its TetMesh" + " prim; cannot sync to visual mesh." + ) + vis_mesh.GetPointsAttr().Set(tet_mesh.GetPointsAttr().Get()) + vis_mesh.GetFaceVertexIndicesAttr().Set(np.asarray(surface_indices).flatten()) + vis_mesh.GetFaceVertexCountsAttr().Set([3] * len(surface_indices)) + else: + sim_mesh = UsdGeom.Mesh(sim_mesh_prim) + vis_mesh.GetFaceVertexIndicesAttr().Set(sim_mesh.GetFaceVertexIndicesAttr().Get()) + vis_mesh.GetFaceVertexCountsAttr().Set(sim_mesh.GetFaceVertexCountsAttr().Get()) + if not prim.AddAppliedSchema("PhysicsDeformableBodyAPI"): + raise RuntimeError(f"Failed to set deformable body API on prim '{prim.GetPath().pathString}'.") + _solver_dt: float = 1.0 / 200.0 _num_substeps: int = 1 _decimation: int = 1 diff --git a/source/isaaclab_newton/isaaclab_newton/sim/__init__.pyi b/source/isaaclab_newton/isaaclab_newton/sim/__init__.pyi index 1e684304e810..35174675c9e0 100644 --- a/source/isaaclab_newton/isaaclab_newton/sim/__init__.pyi +++ b/source/isaaclab_newton/isaaclab_newton/sim/__init__.pyi @@ -9,6 +9,8 @@ __all__ = [ "NewtonDeformableMaterialCfg", "NewtonMaterialCfg", "NewtonSurfaceDeformableBodyMaterialCfg", + "NewtonSurfaceDeformableMaterialCfg", + "NewtonVolumeDeformableMaterialCfg", "MPMGridCfg", "MPMParticleMaterialCfg", "MPMParticleSpawnerCfg", @@ -25,5 +27,7 @@ from .spawners.materials import ( NewtonDeformableMaterialCfg, NewtonMaterialCfg, NewtonSurfaceDeformableBodyMaterialCfg, + NewtonSurfaceDeformableMaterialCfg, + NewtonVolumeDeformableMaterialCfg, ) from .spawners.mpm import MPMGridCfg, MPMParticleMaterialCfg, MPMParticleSpawnerCfg, MPMPointsCfg diff --git a/source/isaaclab_newton/isaaclab_newton/sim/spawners/materials/__init__.pyi b/source/isaaclab_newton/isaaclab_newton/sim/spawners/materials/__init__.pyi index 5ee626b240bd..5e1afd974af2 100644 --- a/source/isaaclab_newton/isaaclab_newton/sim/spawners/materials/__init__.pyi +++ b/source/isaaclab_newton/isaaclab_newton/sim/spawners/materials/__init__.pyi @@ -9,6 +9,8 @@ __all__ = [ "NewtonDeformableMaterialCfg", "NewtonMaterialCfg", "NewtonSurfaceDeformableBodyMaterialCfg", + "NewtonSurfaceDeformableMaterialCfg", + "NewtonVolumeDeformableMaterialCfg", ] from .physics_materials import spawn_deformable_body_material @@ -17,4 +19,6 @@ from .physics_materials_cfg import ( NewtonDeformableMaterialCfg, NewtonMaterialCfg, NewtonSurfaceDeformableBodyMaterialCfg, + NewtonSurfaceDeformableMaterialCfg, + NewtonVolumeDeformableMaterialCfg, ) diff --git a/source/isaaclab_newton/isaaclab_newton/sim/spawners/materials/physics_materials_cfg.py b/source/isaaclab_newton/isaaclab_newton/sim/spawners/materials/physics_materials_cfg.py index d139cff41dff..0a0c32750114 100644 --- a/source/isaaclab_newton/isaaclab_newton/sim/spawners/materials/physics_materials_cfg.py +++ b/source/isaaclab_newton/isaaclab_newton/sim/spawners/materials/physics_materials_cfg.py @@ -10,6 +10,7 @@ from isaaclab.sim.spawners.materials.physics_materials_cfg import ( DeformableBodyMaterialBaseCfg, + DeformableMaterialFragment, RigidBodyMaterialFragment, SurfaceDeformableBodyMaterialBaseCfg, ) @@ -80,6 +81,58 @@ class NewtonSurfaceDeformableBodyMaterialCfg(SurfaceDeformableBodyMaterialBaseCf """Bending damping [N*m*s]. Used by Newton backend for cloth meshes.""" +@configclass +class NewtonVolumeDeformableMaterialCfg(DeformableMaterialFragment): + """``newton:*`` volume-deformable material attributes read by the Newton builder hooks.""" + + _usd_namespace: ClassVar[str | None] = "newton" + _usd_applied_schema: ClassVar[str | None] = None + + density: float | None = None + """The material density [kg/m^3].""" + + particle_radius: float | None = None + """Particle radius used for collision [m].""" + + k_mu: float | None = None + """First Lame material parameter [Pa].""" + + k_lambda: float | None = None + """Second Lame material parameter [Pa].""" + + k_damp: float | None = None + """Damping stiffness for tetrahedral elements [Pa*s].""" + + +@configclass +class NewtonSurfaceDeformableMaterialCfg(DeformableMaterialFragment): + """``newton:*`` surface-deformable (cloth) material attributes read by the Newton builder hooks.""" + + _usd_namespace: ClassVar[str | None] = "newton" + _usd_applied_schema: ClassVar[str | None] = None + + density: float | None = None + """The material areal density [kg/m^2].""" + + particle_radius: float | None = None + """Particle radius used for collision [m].""" + + tri_ke: float | None = None + """Triangle elastic stiffness [Pa].""" + + tri_ka: float | None = None + """Triangle area-preservation stiffness [Pa].""" + + tri_kd: float | None = None + """Triangle damping [Pa*s].""" + + edge_ke: float | None = None + """Edge bending stiffness [N*m].""" + + edge_kd: float | None = None + """Edge bending damping [N*m*s].""" + + @configclass class NewtonMaterialCfg(RigidBodyMaterialFragment): """``newton:*`` rigid-body material attributes read by Newton's USD material schema resolver. @@ -100,7 +153,7 @@ class NewtonMaterialCfg(RigidBodyMaterialFragment): Composes with other rigid-body material fragments (e.g. :class:`~isaaclab.sim.spawners.materials.UsdPhysicsRigidBodyMaterialCfg`) in the same fragment list passed to - :func:`~isaaclab.sim.spawners.materials.spawn_rigid_body_material_from_fragments`. For the + :func:`~isaaclab.sim.spawners.materials.spawn_physics_material_from_fragments`. For the legacy (non-fragment) equivalent, see :class:`~isaaclab_newton.sim.schemas.NewtonMaterialPropertiesCfg`. """ diff --git a/source/isaaclab_newton/test/assets/test_joint_coordinates.py b/source/isaaclab_newton/test/assets/test_joint_coordinates.py index 74d437408edf..11b19f4c4602 100644 --- a/source/isaaclab_newton/test/assets/test_joint_coordinates.py +++ b/source/isaaclab_newton/test/assets/test_joint_coordinates.py @@ -5,17 +5,22 @@ """Kitless tests for the Newton joint coordinate/DOF conversion.""" -from unittest.mock import MagicMock +from types import SimpleNamespace +import newton import numpy as np import pytest import warp as wp +from isaaclab_newton.assets.articulation.actuator_control import NewtonActuatorControl +from isaaclab_newton.assets.articulation.articulation_data import ArticulationData from isaaclab_newton.assets.articulation.joint_coordinates import ( BallJointCoordinateMap, build_ball_joint_coordinate_map, gather_joint_coordinates, scatter_joint_coordinates, ) +from isaaclab_newton.physics import NewtonManager +from newton.selection import ArticulationView from scipy.spatial.transform import Rotation # One revolute, one ball, one revolute -- the layout that hides an off-by-one when the tables are @@ -77,60 +82,47 @@ def test_identity_quaternion_gathers_to_zero_rotation_vector(sign: float) -> Non np.testing.assert_array_equal(out.numpy()[0, ball_dof : ball_dof + 3], [0.0, 0.0, 0.0]) -def test_joint_pos_is_dof_shaped_on_a_ball_jointed_mock() -> None: - """Regression for the original bug: :attr:`ArticulationData.joint_pos` must be DOF-shaped - even when the underlying view's ``joint_q`` is wider (a ball joint). This exercises - ``_create_simulation_bindings`` end to end, unlike the coordinate-table-only tests - above -- it fails on the pre-fix binding, which read ``get_dof_positions()`` (coordinate - space) straight into ``joint_pos`` with no conversion, so ``joint_pos.shape[1]`` would be - ``sum(TWO_BALL_COORD_COUNTS) == 10`` instead of ``sum(TWO_BALL_DOF_COUNTS) == 8``. - """ - import isaaclab_newton.assets.articulation.articulation_data as newton_data_module - from isaaclab_newton.assets.articulation.articulation_data import ArticulationData - from isaaclab_newton.test.fixtures.views import MockNewtonArticulationView - - num_instances = 2 - num_dofs = sum(TWO_BALL_DOF_COUNTS) - num_bodies = len(TWO_BALL_DOF_COUNTS) + 1 - - mock_view = MockNewtonArticulationView( - num_instances=num_instances, - num_bodies=num_bodies, - num_joints=num_dofs, - device="cpu", - is_fixed_base=True, - joint_coord_counts=TWO_BALL_COORD_COUNTS, +@pytest.mark.parametrize("ball_joint", [False, True]) +def test_joint_commands_preserve_coordinate_layout(monkeypatch, ball_joint): + """DOF commands reach native coordinate targets without touching either world's free root.""" + source = newton.ModelBuilder() + base, middle, tip = [source.add_link(mass=1.0, inertia=wp.mat33(np.eye(3))) for _ in range(3)] + add_joint = source.add_joint_ball if ball_joint else source.add_joint_revolute + source.add_articulation( + [source.add_joint_free(base), add_joint(base, middle), source.add_joint_revolute(middle, tip)], label="Robot" ) - mock_view.set_random_mock_data() - mock_view._noop_setters = True - - mock_model = MagicMock() - mock_model.world_count = num_instances - mock_model.gravity = wp.array( - np.tile(np.array([[0.0, 0.0, -9.81]], dtype=np.float32), (num_instances + 1, 1)), - dtype=wp.vec3f, - device="cpu", + builder = newton.ModelBuilder() + for _ in range(2): + builder.add_world(source) + model = builder.finalize(device="cpu") + state, control = model.state(), model.control() + assert model.joint_coord_count != model.joint_dof_count + assert control.joint_target_q.shape == state.joint_q.shape + monkeypatch.setattr(NewtonManager, "get_model", lambda: model) + monkeypatch.setattr(NewtonManager, "get_state_0", lambda: state) + monkeypatch.setattr(NewtonManager, "get_control", lambda: control) + view = ArticulationView(model, "Robot", exclude_joint_types=[newton.JointType.FREE]) + data = ArticulationData(view, "cpu") + data._apply_ordering_maps_after_resolve() + num_dofs = 4 if ball_joint else 2 + assert data.joint_pos.shape == (2, num_dofs) + + targets = np.arange(2 * num_dofs, dtype=np.float32).reshape(2, num_dofs) * 0.1 + expected = control.joint_target_q.numpy().reshape(2, -1).copy() + if ball_joint: + expected[:, 7:-1] = Rotation.from_rotvec(targets[:, :3]).as_quat() + expected[:, -1] = targets[:, -1] + else: + expected[:, 7:] = targets + collection = SimpleNamespace( + has_implicit_actuators=True, + _joint_pos_target_sim=wp.array(targets, device="cpu"), + _joint_vel_target_sim=wp.zeros((2, num_dofs), device="cpu"), + _joint_effort_target_sim=wp.zeros((2, num_dofs), device="cpu"), ) - mock_model.articulation_count = num_instances - mock_model.max_joints_per_articulation = num_bodies - mock_model.max_dofs_per_articulation = num_dofs - mock_model.joint_dof_count = num_instances * num_dofs - mock_model.body_count = num_instances * num_bodies - - mock_manager = MagicMock() - mock_manager.get_model.return_value = mock_model - mock_manager.get_state_0.return_value = MagicMock() - mock_manager.get_state_1.return_value = MagicMock() - mock_manager.get_control.return_value = MagicMock() - - original_sim_manager = newton_data_module.SimulationManager - newton_data_module.SimulationManager = mock_manager - try: - data = ArticulationData(mock_view, "cpu") - finally: - newton_data_module.SimulationManager = original_sim_manager - - assert data.joint_pos.torch.shape[1] == num_dofs + articulation = SimpleNamespace(data=data, _ALL_ENV_MASK=wp.array([True, True], dtype=wp.bool, device="cpu")) + NewtonActuatorControl(articulation).submit_commands(collection) + np.testing.assert_allclose(control.joint_target_q.numpy().reshape(2, -1), expected, atol=1e-6) def test_map_is_inert_without_ball_joints() -> None: diff --git a/source/isaaclab_newton/test/sim/test_newton_schemas.py b/source/isaaclab_newton/test/sim/test_newton_schemas.py index c4678bda4b0e..00b6efb8d6df 100644 --- a/source/isaaclab_newton/test/sim/test_newton_schemas.py +++ b/source/isaaclab_newton/test/sim/test_newton_schemas.py @@ -157,10 +157,10 @@ def test_newton_material_fragment_authors_all_six_newton_attrs(setup_sim): from newton._src.usd.schema_resolver import PrimType from newton._src.usd.schemas import SchemaResolverNewton - from isaaclab.sim.spawners.materials import spawn_rigid_body_material_from_fragments + from isaaclab.sim.spawners.materials import spawn_physics_material_from_fragments from isaaclab.sim.spawners.materials.physics_materials_cfg import UsdPhysicsRigidBodyMaterialCfg - prim = spawn_rigid_body_material_from_fragments( + prim = spawn_physics_material_from_fragments( "/World/newton_mat_contact", [ UsdPhysicsRigidBodyMaterialCfg(static_friction=0.6, dynamic_friction=0.5), @@ -322,3 +322,42 @@ def test_newton_mesh_collision_mixed_namespace_write(setup_sim): NewtonMeshCollisionPropertiesCfg(mesh_approximation_name="convexHull"), ) assert "NewtonMeshCollisionAPI" not in stage.GetPrimAtPath("/World/mesh_col2").GetAppliedSchemas() + + +@pytest.mark.parametrize("kind", ["volume", "surface"]) +def test_newton_deformable_schemas(kind): + """Create both native anchors and author the material fields consumed by the importer.""" + from isaaclab_newton.physics import NewtonManager + from isaaclab_newton.sim.spawners.materials import ( + NewtonSurfaceDeformableMaterialCfg, + NewtonVolumeDeformableMaterialCfg, + ) + + from pxr import Usd, UsdGeom + + from isaaclab.sim.spawners.materials import spawn_physics_material_from_fragments + + stage = Usd.Stage.CreateInMemory() + body = UsdGeom.Xform.Define(stage, "/Body").GetPrim() + visual = UsdGeom.Mesh.Define(stage, "/Body/visual") + mesh = UsdGeom.TetMesh.Define(stage, "/Body/sim") if kind == "volume" else UsdGeom.Mesh.Define(stage, "/Body/sim") + mesh.CreatePointsAttr([(0, 0, 0), (1, 0, 0), (0, 1, 0), (0, 0, 1)]) + if kind == "volume": + mesh.CreateTetVertexIndicesAttr([(0, 1, 2, 3)]) + faces = UsdGeom.TetMesh.ComputeSurfaceFaces(mesh, Usd.TimeCode.Default()) + mesh.CreateSurfaceFaceVertexIndicesAttr(faces) + material = NewtonVolumeDeformableMaterialCfg(k_mu=123.0, density=6.0) + attribute = "newton:kMu" + else: + mesh.CreateFaceVertexIndicesAttr([0, 1, 2]) + mesh.CreateFaceVertexCountsAttr([3]) + material = NewtonSurfaceDeformableMaterialCfg(tri_ke=123.0, density=6.0) + attribute = "newton:triKe" + NewtonManager.setup_deformable_body(body, kind, mesh.GetPrim(), visual.GetPrim()) + assert "PhysicsDeformableBodyAPI" in body.GetPrimTypeInfo().GetAppliedAPISchemas() + assert f"Physics{kind.title()}DeformableSimAPI" in mesh.GetPrim().GetPrimTypeInfo().GetAppliedAPISchemas() + assert len(visual.GetFaceVertexIndicesAttr().Get()) == (12 if kind == "volume" else 3) + prim = spawn_physics_material_from_fragments("/Material", material, stage) + assert prim.GetAttribute(attribute).Get() == pytest.approx(123.0) + assert prim.GetAttribute("newton:density").Get() == pytest.approx(6.0) + assert not prim.HasAPI(UsdPhysics.MaterialAPI) diff --git a/source/isaaclab_ov/changelog.d/ovphysx-contact-details.minor.rst b/source/isaaclab_ov/changelog.d/ovphysx-contact-details.minor.rst new file mode 100644 index 000000000000..b055b6444563 --- /dev/null +++ b/source/isaaclab_ov/changelog.d/ovphysx-contact-details.minor.rst @@ -0,0 +1,10 @@ +Added +^^^^^ + +* Added filtered contact-position and friction-force tracking to the OvPhysX contact + sensor, including friction-force history and selective reset. Both tracking options + required non-empty filters and a positive ``max_contact_data_count_per_prim``. + Truncated SDK contact buffers were bounded before aggregation. + Contact positions and friction forces were aggregated in a single kernel launch + while retaining independent raw-data buffers. Aggregate friction reporting and the + existing normal-only force aliases remained unchanged. diff --git a/source/isaaclab_ov/changelog.d/ovphysx-external-forces-default.major.rst b/source/isaaclab_ov/changelog.d/ovphysx-external-forces-default.major.rst new file mode 100644 index 000000000000..89a4c29d74f4 --- /dev/null +++ b/source/isaaclab_ov/changelog.d/ovphysx-external-forces-default.major.rst @@ -0,0 +1,8 @@ +Changed +^^^^^^^ + +* **Breaking:** Changed :attr:`~isaaclab_ov.physics.OvPhysxCfg.enable_external_forces_every_iteration` + to default to ``True``, matching the PhysX backend's TGS force integration setting. + This changed the default velocity updates and contact-force response of OvPhysX simulations. + Set ``enable_external_forces_every_iteration=False`` explicitly in ``OvPhysxCfg`` to retain + the previous behavior. diff --git a/source/isaaclab_ov/isaaclab_ov/physics/ovphysx_manager_cfg.py b/source/isaaclab_ov/isaaclab_ov/physics/ovphysx_manager_cfg.py index bbed894b3c89..99c080981368 100644 --- a/source/isaaclab_ov/isaaclab_ov/physics/ovphysx_manager_cfg.py +++ b/source/isaaclab_ov/isaaclab_ov/physics/ovphysx_manager_cfg.py @@ -68,11 +68,12 @@ class OvPhysxCfg(PhysicsCfg): .. _here: https://nvidia-omniverse.github.io/PhysX/physx/5.4.1/docs/RigidBodyDynamics.html#enhanced-determinism """ - enable_external_forces_every_iteration: bool = False - """Enable/disable external forces every position iteration in the TGS solver. Default is False. + enable_external_forces_every_iteration: bool = True + """Enable/disable external forces every position iteration in the TGS solver. Default is True. - This can help improve the accuracy of velocity updates. Consider enabling this flag if the - velocities generated by the simulation are noisy. + This matches the PhysX backend default and can improve the accuracy of velocity updates. + Set to ``False`` explicitly to preserve the previous OvPhysX default behavior. Changing this + setting can affect simulated velocities and contact forces. """ gpu_max_rigid_contact_count: int = 2**23 diff --git a/source/isaaclab_ov/isaaclab_ov/sensors/contact_sensor/contact_sensor.py b/source/isaaclab_ov/isaaclab_ov/sensors/contact_sensor/contact_sensor.py index 8df4adb42728..1e79c252814d 100644 --- a/source/isaaclab_ov/isaaclab_ov/sensors/contact_sensor/contact_sensor.py +++ b/source/isaaclab_ov/isaaclab_ov/sensors/contact_sensor/contact_sensor.py @@ -29,7 +29,8 @@ compute_first_transition_kernel, reset_contact_sensor_kernel, split_flat_pose_to_pos_quat, - unpack_contact_buffer_data, # noqa: F401 -- reserved for v2 contact-points support + unpack_contact_buffer_data, + update_filtered_force_history_kernel, update_net_forces_ovphysx_kernel, ) @@ -55,15 +56,13 @@ class ContactSensor(BaseContactSensor): :meth:`ContactBinding.read_force_matrix`. * ``track_air_time`` — air/contact time tracking and :meth:`compute_first_contact` / :meth:`compute_first_air`. + * ``track_contact_points`` — average contact positions [m] per sensor/filter pair. + * ``track_friction_forces`` — summed friction forces [N] per sensor/filter pair, including history. - The following config flags are not supported on the ovphysx backend yet - (the underlying ovphysx APIs do not expose tensor-friendly per-sensor - reads — see ``docs/superpowers/specs/2026-04-27-ovphysx-contact-api-gaps.md``): - - * ``track_contact_points`` - * ``track_friction_forces`` - - Setting either flag raises :class:`NotImplementedError` at initialization. + Contact-point and friction-force tracking require non-empty filters and a positive + ``max_contact_data_count_per_prim``. Aggregate friction forces are not supported. + The SDK warns and truncates detailed contacts when capacity is exceeded; increase + ``max_contact_data_count_per_prim`` before recreating the sensor to retain all contacts. """ cfg: ContactSensorCfg @@ -80,14 +79,7 @@ def __init__(self, cfg: ContactSensorCfg): """ super().__init__(cfg) - # Reject the v1 unsupported optional features early, before USD discovery. - if cfg.track_contact_points or cfg.track_friction_forces: - raise NotImplementedError( - "ovphysx ContactSensor does not yet support 'track_contact_points' or 'track_friction_forces'." - " ovphysx 0.3.7 lacks tensor-friendly per-sensor read APIs for these features." - " See docs/superpowers/specs/2026-04-27-ovphysx-contact-api-gaps.md for the maintainer asks." - ) - + # Validate detailed-contact options after initializing handles needed by teardown. self._data: ContactSensorData = ContactSensorData() # Backend handles, populated in _initialize_impl. self._physx_instance: Any = None @@ -100,6 +92,8 @@ def __init__(self, cfg: ContactSensorCfg): self._net_forces_flat_buf: wp.array | None = None self._force_matrix_flat_buf: wp.array | None = None self._poses_flat_buf: wp.array | None = None + self._contact_data_buffers: tuple[wp.array, ...] | None = None + self._friction_data_buffers: tuple[wp.array, ...] | None = None # Body names (resolved during init). self._body_names: list[str] = [] # Default backend tunables matching the PhysX backend. @@ -107,6 +101,15 @@ def __init__(self, cfg: ContactSensorCfg): self.cfg.max_contact_data_count_per_prim = 4 if self.cfg.force_threshold is None: self.cfg.force_threshold = 1.0 + if self.cfg.track_contact_points or self.cfg.track_friction_forces: + if not self.cfg.filter_prim_paths_expr: + raise ValueError( + "'filter_prim_paths_expr' must be non-empty to track contact points or friction forces." + ) + if self.cfg.max_contact_data_count_per_prim < 1: + raise ValueError( + "'max_contact_data_count_per_prim' must be positive to track contact points or friction forces." + ) def __str__(self) -> str: """Returns: A string containing information about the instance.""" @@ -330,6 +333,19 @@ def _create_buffers(self) -> None: else: self._force_matrix_flat_buf = None + capacity = self._contact_binding.max_contact_data_count + pair_shape = (flat_count, self._num_filter_shapes) + self._contact_data_buffers = None + self._friction_data_buffers = None + if self.cfg.track_contact_points: + self._contact_data_buffers = tuple( + wp.zeros((capacity, width), dtype=wp.float32, device=self._device) for width in (1, 3, 3, 1) + ) + tuple(wp.zeros(pair_shape, dtype=wp.uint32, device=self._device) for _ in range(2)) + if self.cfg.track_friction_forces: + self._friction_data_buffers = tuple( + wp.zeros((capacity, 3), dtype=wp.float32, device=self._device) for _ in range(2) + ) + tuple(wp.zeros(pair_shape, dtype=wp.uint32, device=self._device) for _ in range(2)) + # Pose buffer: [S, 7] for RIGID_BODY_POSE (px,py,pz,qx,qy,qz,qw). if self.cfg.track_pose: self._poses_flat_buf = wp.zeros((flat_count, 7), dtype=wp.float32, device=self._device) @@ -354,14 +370,23 @@ def _fetch_ovphysx_buffers(self, include_pose: bool = True) -> None: self._contact_binding.read_force_matrix(self._force_matrix_flat_buf) if self.cfg.track_pose and include_pose: # Read pose into [num_envs * num_sensors, 7] float32. + assert self._root_view is not None + assert self._poses_flat_buf is not None self._root_view.read_into(TT.RIGID_BODY_POSE, self._poses_flat_buf) def _update_buffers_impl(self, env_mask: wp.array | None = None) -> None: """Read contact data from ovphysx and update sensor buffers.""" env_mask = self._resolve_indices_and_mask(None, env_mask) - self._fetch_ovphysx_buffers() - # [num_envs * num_sensors, 3] float32 -> [num_envs * num_sensors] vec3f. + if self._contact_data_buffers is not None: + self._contact_binding.read_contact_data(*self._contact_data_buffers) + if self._friction_data_buffers is not None: + self._contact_binding.read_friction_data(*self._friction_data_buffers) + + # Pull aggregate forces into the pre-allocated flat buffer: + # shape [num_envs * num_sensors, 3] float32 -> [num_envs * num_sensors] vec3f. + self._fetch_ovphysx_buffers() + assert self._net_forces_flat_buf is not None net_forces_flat = self._net_forces_flat_buf.view(wp.vec3f) if self._force_matrix_flat_buf is not None: force_matrix_flat = self._force_matrix_flat_buf.view(wp.vec3f) @@ -396,7 +421,41 @@ def _update_buffers_impl(self, env_mask: wp.array | None = None) -> None: device=self._device, ) + contact_buffers = self._contact_data_buffers + friction_buffers = self._friction_data_buffers + if contact_buffers is not None or friction_buffers is not None: + wp.launch( + unpack_contact_buffer_data, + dim=(self._num_envs, self._num_sensors, self._num_filter_shapes), + inputs=[ + contact_buffers[1].view(wp.vec3f) if contact_buffers is not None else None, + contact_buffers[-2] if contact_buffers is not None else None, + contact_buffers[-1] if contact_buffers is not None else None, + friction_buffers[0].view(wp.vec3f) if friction_buffers is not None else None, + friction_buffers[-2] if friction_buffers is not None else None, + friction_buffers[-1] if friction_buffers is not None else None, + env_mask, + self._num_envs, + ], + outputs=[self._data._contact_pos_w, self._data._friction_force_matrix_w], + device=self._device, + ) + if self._friction_data_buffers is not None: + wp.launch( + update_filtered_force_history_kernel, + dim=(self._num_envs, self._num_sensors, self._num_filter_shapes), + inputs=[ + env_mask, + self._history_length, + self._data._friction_force_matrix_w, + self._data._friction_force_matrix_w_history, + ], + device=self._device, + ) + if self.cfg.track_pose: + # Read pose into [num_envs * num_sensors, 7] float32 -> view as transformf. + assert self._poses_flat_buf is not None poses_flat = self._poses_flat_buf.view(wp.transformf) wp.launch( split_flat_pose_to_pos_quat, @@ -575,3 +634,5 @@ def _invalidate_initialize_callback(self, event) -> None: # would keep a destroyed handle reachable. _initialize_impl rebuilds a fresh view on play. self._root_view = None self._physx_instance = None + self._contact_data_buffers = None + self._friction_data_buffers = None diff --git a/source/isaaclab_ov/isaaclab_ov/sensors/contact_sensor/kernels.py b/source/isaaclab_ov/isaaclab_ov/sensors/contact_sensor/kernels.py index 7ebf712ff763..a18ab38f0aeb 100644 --- a/source/isaaclab_ov/isaaclab_ov/sensors/contact_sensor/kernels.py +++ b/source/isaaclab_ov/isaaclab_ov/sensors/contact_sensor/kernels.py @@ -42,49 +42,62 @@ def split_flat_pose_to_pos_quat( @wp.kernel def unpack_contact_buffer_data( - contact_data: wp.array(dtype=wp.vec3f), - buffer_count: wp.array2d(dtype=wp.uint32), - buffer_start_indices: wp.array2d(dtype=wp.uint32), + contact_positions: wp.array(dtype=wp.vec3f), + contact_counts: wp.array2d(dtype=wp.uint32), + contact_starts: wp.array2d(dtype=wp.uint32), + friction_forces: wp.array(dtype=wp.vec3f), + friction_counts: wp.array2d(dtype=wp.uint32), + friction_starts: wp.array2d(dtype=wp.uint32), mask: wp.array(dtype=wp.bool), - num_bodies: wp.int32, - avg: bool, - default_val: wp.float32, - dst: wp.array3d(dtype=wp.vec3f), + num_envs: wp.int32, + contact_pos_w: wp.array3d(dtype=wp.vec3f), + friction_force_matrix_w: wp.array3d(dtype=wp.vec3f), ): - """Unpack and aggregate contact buffer data for each (env, body, filter) group. + """Average contact positions and sum friction forces in one launch. - Each thread handles one (body, filter) pair for one environment. It reads - `count` contact entries starting at `start_index` and either averages or - sums them. + Each thread handles one (env, body, filter) group in pattern-major input + order. Contact points and friction anchors have independent counts and start + indices. Disabled outputs and their corresponding inputs may be None. Args: - contact_data: Flat buffer of contact data. Shape is (total_contacts,) vec3f. - buffer_count: Count of contacts per (env*body, filter). Shape is (N*B, M) uint32. - buffer_start_indices: Start indices per (env*body, filter). Shape is (N*B, M) uint32. + contact_positions: Flat contact positions [m] in the world frame. Shape is (C,) vec3f. + contact_counts: Contact-point counts per (body*env, filter). Shape is (B*N, M). + contact_starts: Contact-point start indices per (body*env, filter). Shape is (B*N, M). + friction_forces: Flat friction forces [N] in the world frame. Shape is (C,) vec3f. + friction_counts: Friction-anchor counts per (body*env, filter). Shape is (B*N, M). + friction_starts: Friction-anchor start indices per (body*env, filter). Shape is (B*N, M). mask: Boolean mask for which environments to update. Shape is (N,). - num_bodies: Number of bodies per environment. - avg: If True, average the data; if False, sum it. - default_val: Default value for groups with zero contacts (e.g. NaN or 0.0). - dst: Destination buffer. Shape is (N, B, M). + num_envs: Number of environments. + contact_pos_w: Mean contact positions [m], or NaN without contacts. Shape is (N, B, M) vec3f. + friction_force_matrix_w: Summed friction forces [N], or zero without anchors. Shape is (N, B, M) vec3f. """ env, sensor, contact = wp.tid() if mask: if not mask[env]: return - flat_idx = env * num_bodies + sensor - count = wp.int32(buffer_count[flat_idx, contact]) - start = wp.int32(buffer_start_indices[flat_idx, contact]) + flat_idx = sensor * num_envs + env + # OVPhysX can report full counts even when the flat buffers were truncated. + if contact_pos_w: + count = wp.int32(contact_counts[flat_idx, contact]) + start = wp.int32(contact_starts[flat_idx, contact]) + count = wp.min(count, wp.max(0, contact_positions.shape[0] - start)) + position = wp.vec3f(wp.nan) + if count > 0: + position = wp.vec3f(0.0) + for c in range(count): + position = position + contact_positions[start + c] + position = position / wp.float32(count) + contact_pos_w[env, sensor, contact] = position - if count > 0: - accum = wp.vec3f(0.0, 0.0, 0.0) + if friction_force_matrix_w: + count = wp.int32(friction_counts[flat_idx, contact]) + start = wp.int32(friction_starts[flat_idx, contact]) + count = wp.min(count, wp.max(0, friction_forces.shape[0] - start)) + force = wp.vec3f(0.0) for c in range(count): - accum = accum + contact_data[start + c] - if avg: - accum = accum / wp.float32(count) - dst[env, sensor, contact] = accum - else: - dst[env, sensor, contact] = wp.vec3f(default_val, default_val, default_val) + force = force + friction_forces[start + c] + friction_force_matrix_w[env, sensor, contact] = force @wp.kernel @@ -169,7 +182,31 @@ def reset_contact_sensor_kernel( if contact_pos_w: for f in range(num_filter_objects): - contact_pos_w[env, sensor, f] = wp.vec3f(0.0) + contact_pos_w[env, sensor, f] = wp.vec3f(wp.nan) + + +@wp.kernel +def update_filtered_force_history_kernel( + mask: wp.array(dtype=wp.bool), + history_length: int, + forces: wp.array3d(dtype=wp.vec3f), + history: wp.array4d(dtype=wp.vec3f), +): + """Record filtered forces [N] for selected environments, with the newest sample at index zero. + + Args: + mask: Environment selection, shape (N,). + history_length: Number of history slots. + forces: Filtered forces [N], shape (N, B, F). + history: Force history [N], shape (N, H, B, F). + """ + env, sensor, partner = wp.tid() + if mask: + if not mask[env]: + return + for i in range(history_length - 1, 0, -1): + history[env, i, sensor, partner] = history[env, i - 1, sensor, partner] + history[env, 0, sensor, partner] = forces[env, sensor, partner] @wp.kernel diff --git a/source/isaaclab_ov/test/assets/test_rigid_object.py b/source/isaaclab_ov/test/assets/test_rigid_object.py index d93a9f052043..4253adb3807f 100644 --- a/source/isaaclab_ov/test/assets/test_rigid_object.py +++ b/source/isaaclab_ov/test/assets/test_rigid_object.py @@ -564,7 +564,7 @@ def test_reset_rigid_object(num_cubes, device): @pytest.mark.parametrize("num_cubes", [2]) @pytest.mark.parametrize("device", test_devices()) def test_rigid_body_no_friction(num_cubes, device): - """Test that a rigid object with no friction will maintain its velocity when sliding across a plane.""" + """Test that a rigid object with no friction maintains its tangential velocity on a plane.""" with _ovphysx_sim_context(device=device, auto_add_lighting=True) as sim: cube_object, _ = generate_cubes_scene(num_cubes=num_cubes, height=0.0, device=device) @@ -585,8 +585,7 @@ def test_rigid_body_no_friction(num_cubes, device): cube_materials = torch.zeros(num_cubes, num_shapes, 3, device="cpu") _write_shape_material(cube_object, cube_materials) - # Let the cube settle onto the plane so the initial ground-penetration transient (a small - # vertical velocity) dies out before we measure that the horizontal sliding velocity holds. + # Establish ground contact before measuring tangential sliding velocity. for _ in range(30): sim.step() cube_object.update(sim.cfg.dt) @@ -602,7 +601,7 @@ def test_rigid_body_no_friction(num_cubes, device): sim.step() cube_object.update(sim.cfg.dt) torch.testing.assert_close( - cube_object.data.root_lin_vel_w.torch, initial_velocity[:, :3], rtol=1e-5, atol=tolerance + cube_object.data.root_lin_vel_w.torch[:, :2], initial_velocity[:, :2], rtol=1e-5, atol=tolerance ) diff --git a/source/isaaclab_ov/test/physics/test_ovphysx_manager_lifecycle.py b/source/isaaclab_ov/test/physics/test_ovphysx_manager_lifecycle.py index 7af5f3c8de74..110f864f94ff 100644 --- a/source/isaaclab_ov/test/physics/test_ovphysx_manager_lifecycle.py +++ b/source/isaaclab_ov/test/physics/test_ovphysx_manager_lifecycle.py @@ -482,6 +482,25 @@ def test_retained_binding_preserves_uncaught_failure_exit_status(): _assert_no_atexit_errors(output) +@pytest.mark.parametrize("device", ["cpu", "gpu"]) +@pytest.mark.parametrize(("override", "expected"), [(None, True), (False, False), (True, True)]) +def test_scene_external_forces_every_iteration(monkeypatch, manager_module, device, override, expected): + """Default TGS force integration and explicit overrides reach the USD physics scene.""" + from isaaclab_ov.physics.ovphysx_manager_cfg import OvPhysxCfg + + from pxr import Usd + + from isaaclab.physics import PhysicsManager + + cfg = OvPhysxCfg() if override is None else OvPhysxCfg(enable_external_forces_every_iteration=override) + assert cfg.enable_external_forces_every_iteration is expected + monkeypatch.setattr(PhysicsManager, "_sim", None) + stage = Usd.Stage.CreateInMemory() + prim = stage.DefinePrim("/World/PhysicsScene", "PhysicsScene") + manager_module.OvPhysxManager._configure_physx_scene_prim(prim, cfg, device) + assert prim.GetAttribute("physxScene:enableExternalForcesEveryIteration").Get() is expected + + def test_construct_physx_forwards_cooked_collider_cache_dir(monkeypatch, manager_module, tmp_path): """Configured, default, and unset cache directories reach ``PhysXConfig`` unchanged.""" from isaaclab_ov.physics.ovphysx_manager_cfg import DEFAULT_COOKED_COLLIDER_CACHE_DIR, OvPhysxBackendCfg, OvPhysxCfg diff --git a/source/isaaclab_ov/test/sensors/test_contact_sensor.py b/source/isaaclab_ov/test/sensors/test_contact_sensor.py index 5f4ea0237bb8..7d61cc909aa1 100644 --- a/source/isaaclab_ov/test/sensors/test_contact_sensor.py +++ b/source/isaaclab_ov/test/sensors/test_contact_sensor.py @@ -8,9 +8,7 @@ """Real-backend tests for the OVPhysX ContactSensor. -Run via ``uv run python -m pytest``; the ovphysx wheel is now invocable -through the standard Kit Python entrypoint, so the older kitless -``./scripts/run_ovphysx.sh`` wrapper is no longer required. +Run via ``uv run --extra ovphysx --extra test python -m pytest`` without a Kit runtime. The OVPhysX runtime fixes device mode (CPU vs GPU) when the process creates its first ``ovphysx.PhysX`` instance and cannot switch it without a process @@ -20,9 +18,8 @@ :exc:`RuntimeError` by ``pytest.skip``-ing on the unlocked device so single-device runs finish cleanly. -The PhysX friction-force, contact-point, and their config-validation tests have no -counterpart here: :class:`ContactSensor` raises :exc:`NotImplementedError` for -``track_friction_forces`` and ``track_contact_points`` (issue #5325). +Detailed-contact tests cover filtered positions and friction, optional tracking modes, +force history, selective reset, multi-body indexing, and insufficient contact capacity. The ``disable_contact_processing`` PhysX/Kit setting is not available in the kitless OVPhysX flow; :func:`test_cube_contact_time` and @@ -378,6 +375,7 @@ def test_first_transition_with_aged_clock(device): def _in_contact() -> bool: """Ground truth for the contact state, read through the public data accessor.""" + assert sensor.data.net_normal_forces_w is not None return torch.norm(sensor.data.net_normal_forces_w.torch, dim=-1).max().item() > 0.1 def _hold(pose: torch.Tensor, num_steps: int) -> None: @@ -453,6 +451,7 @@ def test_cube_stack_contact_filtering(device): track_pose=True, debug_vis=False, update_period=0.0, + history_length=2, filter_prim_paths_expr=["{ENV_REGEX_NS}/Cube_2"], ) # -- contact sensor 2 @@ -481,6 +480,10 @@ def test_cube_stack_contact_filtering(device): _perform_sim_step(sim, scene, _SIM_DT) # Check values for cube 2 — cube 1 is the only collision for cube 2 + assert contact_sensor_2.data.normal_force_matrix_w is not None + assert contact_sensor_2.data.net_normal_forces_w is not None + assert contact_sensor.data.normal_force_matrix_w is not None + assert contact_sensor.data.net_normal_forces_w is not None torch.testing.assert_close( contact_sensor_2.data.normal_force_matrix_w.torch[:, :, 0], contact_sensor_2.data.net_normal_forces_w.torch, @@ -494,9 +497,17 @@ def test_cube_stack_contact_filtering(device): assert contact_sensor_2.data.net_normal_forces_w.torch.sum().item() > 0.0 assert contact_sensor.data.net_normal_forces_w.torch.sum().item() > 0.0 + # Normal-only history must also reset when detailed tracking is disabled. + data = contact_sensor.data + history = data.normal_force_matrix_w_history.torch.clone() + contact_sensor.reset(env_ids=[0]) + assert torch.count_nonzero(data.normal_force_matrix_w_history.torch[0]) == 0 + torch.testing.assert_close(data.normal_force_matrix_w_history.torch[1:], history[1:]) + @pytest.mark.parametrize("device", ["cuda:0", "cpu"]) -def test_multi_body_per_sensor_indexing(device): +@pytest.mark.parametrize("detail_mode", [None, "points", "friction", "both"]) +def test_multi_body_per_sensor_indexing(device, detail_mode): """Ground-truth body-index check for a single sensor that resolves to two bodies. OVPhysX :class:`ContactBinding` returns sensors in **pattern-major** order @@ -526,7 +537,12 @@ def test_multi_body_per_sensor_indexing(device): track_pose=False, debug_vis=False, update_period=0.0, - filter_prim_paths_expr=[], + track_contact_points=detail_mode in ("points", "both"), + track_friction_forces=detail_mode in ("friction", "both"), + max_contact_data_count_per_prim=32, + filter_prim_paths_expr=[scene_cfg.terrain.prim_path + "/terrain/GroundPlane/CollisionPlane"] + if detail_mode + else [], ) scene = InteractiveScene(scene_cfg) sim.reset() @@ -544,6 +560,7 @@ def test_multi_body_per_sensor_indexing(device): _perform_sim_step(sim, scene, _SIM_DT) # Net force readout: shape (num_envs, num_sensors=2, 3) after .torch. + assert contact_sensor.data.net_normal_forces_w is not None net_forces = contact_sensor.data.net_normal_forces_w.torch assert net_forces.shape == (num_envs, 2, 3) low_force_mag = net_forces[:, low_idx, :].abs().sum().item() @@ -557,8 +574,30 @@ def test_multi_body_per_sensor_indexing(device): " e.g. a Cube_low contact was attributed to Cube_high because the kernel" " assumed env-major instead of pattern-major flat-buffer layout." ) - # Without filter_prim_paths_expr there is no per-partner breakdown to report. - assert contact_sensor.data.normal_force_matrix_w is None + data = contact_sensor.data + if detail_mode in ("points", "both"): + assert data.contact_pos_w is not None + positions = data.contact_pos_w.torch + assert positions.shape == (num_envs, 2, 1, 3) + assert torch.isnan(positions[:, high_idx]).all() + torch.testing.assert_close( + positions[:, low_idx, 0, :2], scene["shape"].data.root_pos_w.torch[:, :2], atol=0.251, rtol=0 + ) + torch.testing.assert_close( + positions[:, low_idx, 0, 2], torch.zeros(num_envs, device=device), atol=1e-3, rtol=0 + ) + else: + assert data.contact_pos_w is None + if detail_mode in ("friction", "both"): + assert data.friction_force_matrix_w is not None + assert data.friction_force_matrix_w.torch.shape == (num_envs, 2, 1, 3) + assert torch.count_nonzero(data.friction_force_matrix_w.torch[:, high_idx]) == 0 + else: + assert data.friction_force_matrix_w is None + assert data.friction_force_matrix_w_history is None + if detail_mode is None: + # Without filters there is no per-partner breakdown to report. + assert data.normal_force_matrix_w is None def _author_nested_chain(prim_path: str) -> None: @@ -636,6 +675,7 @@ def test_nested_rigid_body_hierarchy(device, body_pattern, num_envs, body_names) for _ in range(2): sim.step() contact_sensor.update(_SIM_DT, force_recompute=True) + assert contact_sensor.data.net_normal_forces_w is not None net_forces = contact_sensor.data.net_normal_forces_w.torch assert net_forces.shape == (num_envs, len(body_names), 3) @@ -729,6 +769,115 @@ def run_policy_step(root_pose: torch.Tensor) -> float: assert max(air_forces[1:]) < 0.1, f"Stale contact force reported in the air: {air_forces}" +@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) +@pytest.mark.parametrize( + "grav_dir, history_length, max_count", + [((-10.0, 0.0, -9.81), 0, 32), ((0.0, -10.0, -9.81), 3, 32), ((-10.0, 0.0, -9.81), 3, 1)], + ids=["no-history", "history", "truncated"], +) +def test_friction_reporting(device, grav_dir, history_length, max_count): + """Compare full/truncated SDK details, history, reset and contact loss in one scene.""" + num_envs = 3 + sim_cfg = SimulationCfg(physics=OvPhysxCfg(), dt=_SIM_DT, device=device, gravity=grav_dir) + with build_simulation_context(device=device, sim_cfg=sim_cfg, add_lighting=False) as sim: + scene_cfg = ContactSensorSceneCfg(num_envs=num_envs, env_spacing=2.0, lazy_sensor_update=False) + scene_cfg.terrain = FLAT_TERRAIN_CFG + scene_cfg.shape = replace(CUBE_CFG, prim_path="{ENV_REGEX_NS}/Cube") + scene_cfg.shape.init_state.pos = (0.0, 0.0, CUBE_CFG.spawn.size[2] / 2.0) + scene_cfg.contact_sensor = ContactSensorCfg( + prim_path=scene_cfg.shape.prim_path, + history_length=history_length, + track_pose=True, + track_air_time=True, + track_contact_points=True, + track_friction_forces=True, + max_contact_data_count_per_prim=max_count, + filter_prim_paths_expr=[scene_cfg.terrain.prim_path + "/terrain/GroundPlane/CollisionPlane"], + ) + scene = InteractiveScene(scene_cfg) + sim.reset() + scene.reset() + sensor: ContactSensor = scene["contact_sensor"] + for _ in range(20): + _perform_sim_step(sim, scene, _SIM_DT) + effective_history = max(history_length, 1) + samples = [] + for _ in range(effective_history + 1): + _perform_sim_step(sim, scene, _SIM_DT) + samples.append(sensor.data.friction_force_matrix_w.torch.clone()) + + binding, data = sensor.contact_view, sensor.data + capacity = binding.max_contact_data_count + for widths, read, actual, average in ( + ((1, 3, 3, 1), binding.read_contact_data, data.contact_pos_w, True), + ((3, 3), binding.read_friction_data, data.friction_force_matrix_w, False), + ): + buffers = [wp.empty((capacity, width), dtype=wp.float32, device=device) for width in widths] + pair_shape = (binding.sensor_count, binding.filter_count) + counts, starts = [wp.empty(pair_shape, dtype=wp.uint32, device=device) for _ in range(2)] + read(*buffers, counts, starts) + raw = wp.to_torch(buffers[1 if average else 0]) + assert actual.torch.shape == (num_envs, 1, 1, 3) + for env, (start, count) in enumerate(zip(starts.numpy()[:, 0], counts.numpy()[:, 0])): + # Slicing naturally limits the reference to the contacts retained by the SDK. + values = raw[int(start) : int(start) + int(count)] + expected = values.mean(0) if average else values.sum(0) + torch.testing.assert_close(actual.torch[env, 0, 0], expected, equal_nan=True) + + # Truncation may leave later environments without friction entries. + gravity_xy = torch.tensor(grav_dir[:2], device=device) + projection = (data.friction_force_matrix_w.torch[:, 0, 0, :2] * gravity_xy).sum(-1) + assert torch.all(projection <= 0) and torch.any(projection < 0) + torch.testing.assert_close( + data.friction_force_matrix_w_history.torch, torch.stack(samples[-effective_history:][::-1], dim=1) + ) + with pytest.warns(UserWarning, match="total contact force"): + torch.testing.assert_close(data.force_matrix_w.torch, data.normal_force_matrix_w.torch) + with pytest.raises(NotImplementedError): + _ = data.net_friction_forces_w + + forces = ( + data.net_normal_forces_w, + data.net_normal_forces_w_history, + data.normal_force_matrix_w, + data.normal_force_matrix_w_history, + data.friction_force_matrix_w, + data.friction_force_matrix_w_history, + ) + before_reset = [force.torch.clone() for force in forces] + positions_before_reset = data.contact_pos_w.torch.clone() + sensor.reset(env_ids=[0]) + for force, before in zip(forces, before_reset): + assert torch.count_nonzero(before) > 0 + assert torch.count_nonzero(force.torch[0]) == 0 + torch.testing.assert_close(force.torch[1:], before[1:]) + assert torch.isnan(data.contact_pos_w.torch[0]).all() + torch.testing.assert_close(data.contact_pos_w.torch[1:], positions_before_reset[1:], equal_nan=True) + + shape: RigidObject = scene["shape"] + pose = shape.data.root_pose_w.torch.clone() + pose[:, 2] += 2.0 + shape.write_root_pose_to_sim_index(root_pose=pose) + for _ in range(effective_history + 1): + _perform_sim_step(sim, scene, _SIM_DT) + assert torch.isnan(sensor.data.contact_pos_w.torch).all() + assert torch.count_nonzero(sensor.data.friction_force_matrix_w.torch) == 0 + assert torch.count_nonzero(sensor.data.friction_force_matrix_w_history.torch) == 0 + + +@pytest.mark.parametrize("device", ["cpu"]) +@pytest.mark.parametrize("feature", ["track_contact_points", "track_friction_forces"]) +@pytest.mark.parametrize( + "option, value", + [("filter_prim_paths_expr", []), ("max_contact_data_count_per_prim", 0), ("max_contact_data_count_per_prim", -1)], +) +def test_invalid_contact_config(device, feature, option, value): + """Reject invalid detail options at construction, without spawning or starting physics.""" + options = {"filter_prim_paths_expr": ["/World/ground"], feature: True, option: value} + with _ovphysx_sim_context(device=device), pytest.raises(ValueError, match=option): + ContactSensor(ContactSensorCfg(prim_path="/World/Cube", **options)) + + ## # Internal helpers. ## @@ -751,12 +900,9 @@ def _run_contact_sensor_test( durations: Contact / air durations [s] to exercise. Note: - Unlike the PhysX variant, this helper never enables - ``track_contact_points`` or ``track_friction_forces`` because those - APIs are not yet available in the ovphysx v1 contact sensor (see - issue #5325). The ``test_contact_data`` path is therefore always - ``False``. The ``disable_contact_processing`` PhysX/Kit setting is - also not available in the kitless flow and is omitted. + These timing tests leave detailed-contact tracking disabled; dedicated tests + cover the optional buffers. The ``disable_contact_processing`` PhysX/Kit + setting is not available in the kitless flow and is omitted. """ for terrain in terrains: with _ovphysx_sim_context(device=device, dt=sim_dt, add_lighting=True) as sim: @@ -911,6 +1057,10 @@ def _check_prim_contact_state_times( # store current state of the contact prim in_air = expected_air_time > 0.0 in_contact = expected_contact_time > 0.0 + assert sensor.data.current_contact_time is not None + assert sensor.data.current_air_time is not None + assert sensor.data.last_contact_time is not None + assert sensor.data.last_air_time is not None measured_contact_time = sensor.data.current_contact_time.torch measured_air_time = sensor.data.current_air_time.torch measured_last_contact_time = sensor.data.last_contact_time.torch diff --git a/source/isaaclab_ov/test/sensors/test_contact_sensor_kernels.py b/source/isaaclab_ov/test/sensors/test_contact_sensor_kernels.py index 043457d93772..29805079a52c 100644 --- a/source/isaaclab_ov/test/sensors/test_contact_sensor_kernels.py +++ b/source/isaaclab_ov/test/sensors/test_contact_sensor_kernels.py @@ -6,55 +6,66 @@ """Unit tests for OvPhysx contact-sensor Warp kernels.""" import numpy as np +import pytest import warp as wp -from isaaclab_ov.sensors.contact_sensor.kernels import reset_contact_sensor_kernel +from isaaclab_ov.sensors.contact_sensor.kernels import unpack_contact_buffer_data -def test_reset_contact_sensor_kernel_clears_selected_force_matrix_history(): - """Reset clears filtered-force history only for selected environments.""" - num_envs = 2 - num_sensors = 1 - history_length = 2 - num_filter_shapes = 1 - device = "cpu" - env_mask = wp.array([True, False], dtype=wp.bool, device=device) - - net_normal_forces_w = wp.zeros((num_envs, num_sensors), dtype=wp.vec3f, device=device) - net_normal_forces_w_history = wp.zeros((num_envs, history_length, num_sensors), dtype=wp.vec3f, device=device) - normal_force_matrix_w = wp.zeros((num_envs, num_sensors, num_filter_shapes), dtype=wp.vec3f, device=device) - normal_force_matrix_w_history = wp.array( - np.ones((num_envs, history_length, num_sensors, num_filter_shapes, 3), dtype=np.float32), - dtype=wp.vec3f, - device=device, - ) - current_air_time = wp.zeros((num_envs, num_sensors), dtype=wp.float32, device=device) - last_air_time = wp.zeros((num_envs, num_sensors), dtype=wp.float32, device=device) - current_contact_time = wp.zeros((num_envs, num_sensors), dtype=wp.float32, device=device) - last_contact_time = wp.zeros((num_envs, num_sensors), dtype=wp.float32, device=device) - +@pytest.mark.parametrize("device", ["cpu", "cuda:0"]) +@pytest.mark.parametrize("track_points,track_friction", [(True, False), (False, True), (True, True)]) +@pytest.mark.parametrize("use_mask", [False, True]) +@pytest.mark.parametrize("capacity", [None, 5]) +def test_unpack_contact_buffer_data_pattern_major( + device: str, track_points: bool, track_friction: bool, use_mask: bool, capacity: int | None +): + """Aggregate independent contact and friction layouts, preserving unselected environments.""" + if device.startswith("cuda") and not wp.is_cuda_available(): + pytest.skip("CUDA is not available") + num_envs, num_sensors, num_filters = 3, 4, 2 + pair_shape = (num_envs * num_sensors, num_filters) + inputs, outputs, references = [], [], [] + for avg, enabled in ((True, track_points), (False, track_friction)): + if not enabled: + inputs.extend([None, None, None]) + outputs.append(None) + references.append(None) + continue + counts = (np.arange(num_envs * num_sensors * num_filters, dtype=np.uint32) + int(avg)) % 3 + starts = np.cumsum(counts, dtype=np.uint32) - counts + flat = np.arange(int(counts.sum()) * 3, dtype=np.float32).reshape(-1, 3) * (1.0 if avg else -1.0) + flat = flat[:capacity] + counts, starts = counts.reshape(pair_shape), starts.reshape(pair_shape) + default = float("nan") if avg else 0.0 + aggregate = np.mean if avg else np.sum + expected = np.full((num_envs, num_sensors, num_filters, 3), -1.0, dtype=np.float32) + for env in (0, 2) if use_mask else range(num_envs): + for sensor in range(num_sensors): + for partner in range(num_filters): + row = sensor * num_envs + env + start, count = int(starts[row, partner]), int(counts[row, partner]) + values = flat[start : start + count] + expected[env, sensor, partner] = aggregate(values, axis=0) if len(values) else default + inputs.extend( + [ + wp.array(flat, dtype=wp.vec3f, device=device), + wp.array(counts, dtype=wp.uint32, device=device), + wp.array(starts, dtype=wp.uint32, device=device), + ] + ) + outputs.append( + wp.full( + (num_envs, num_sensors, num_filters), value=wp.vec3f(wp.float32(-1.0)), dtype=wp.vec3f, device=device + ) + ) + references.append(expected) + mask = wp.array([True, False, True], dtype=wp.bool, device=device) if use_mask else None wp.launch( - reset_contact_sensor_kernel, - dim=(num_envs, num_sensors), - inputs=[ - history_length, - num_filter_shapes, - env_mask, - net_normal_forces_w, - net_normal_forces_w_history, - normal_force_matrix_w, - normal_force_matrix_w_history, - ], - outputs=[ - current_air_time, - last_air_time, - current_contact_time, - last_contact_time, - None, - None, - None, - ], + unpack_contact_buffer_data, + dim=(num_envs, num_sensors, num_filters), + inputs=[*inputs, mask, num_envs], + outputs=outputs, device=device, ) - - np.testing.assert_array_equal(normal_force_matrix_w_history.numpy()[0], 0.0) - np.testing.assert_array_equal(normal_force_matrix_w_history.numpy()[1], 1.0) + for output, expected in zip(outputs, references): + if output is not None: + np.testing.assert_allclose(output.numpy(), expected, equal_nan=True) diff --git a/source/isaaclab_physx/changelog.d/vidurv-deformable-fragments.minor.rst b/source/isaaclab_physx/changelog.d/vidurv-deformable-fragments.minor.rst new file mode 100644 index 000000000000..59399c84c3b3 --- /dev/null +++ b/source/isaaclab_physx/changelog.d/vidurv-deformable-fragments.minor.rst @@ -0,0 +1,15 @@ +Added +^^^^^ + +* Added the PhysX deformable-body fragments :class:`~isaaclab_physx.sim.schemas.PhysxDeformableBodyCfg` + and :class:`~isaaclab_physx.sim.schemas.PhysxSurfaceDeformableBodyCfg`, covering the solver, + damping, and self-collision attributes from ``PhysxBaseDeformableBodyAPI`` and the + surface-only collision attributes from ``PhysxSurfaceDeformableBodyAPI``. +* Added the PhysX deformable material fragments + :class:`~isaaclab_physx.sim.spawners.materials.PhysxDeformableMaterialCfg` and + :class:`~isaaclab_physx.sim.spawners.materials.PhysxSurfaceDeformableMaterialCfg`, authoring + ``physxDeformableMaterial:*`` attributes from ``PhysxDeformableMaterialAPI`` and + ``PhysxSurfaceDeformableMaterialAPI``. +* Added :meth:`~isaaclab_physx.physics.PhysxManager.setup_deformable_body`, applying the + OmniPhysics deformable sim and body anchor APIs, rest state, and visual bind pose to a prepared + deformable mesh. diff --git a/source/isaaclab_physx/isaaclab_physx/physics/physx_manager.py b/source/isaaclab_physx/isaaclab_physx/physics/physx_manager.py index f05032e67303..358e99ad4736 100644 --- a/source/isaaclab_physx/isaaclab_physx/physics/physx_manager.py +++ b/source/isaaclab_physx/isaaclab_physx/physics/physx_manager.py @@ -32,7 +32,7 @@ import omni.timeline import omni.usd import usdrt -from pxr import Sdf, UsdPhysics, UsdUtils +from pxr import Sdf, UsdGeom, UsdPhysics, UsdUtils import isaaclab.sim as sim_utils from isaaclab.physics import CallbackHandle, PhysicsEvent, PhysicsManager @@ -441,6 +441,43 @@ def fix_articulation_root(cls, articulation_prim: Any, stage: Any = None) -> Any ) return root + @classmethod + def setup_deformable_body(cls, prim: Any, deformable_type: str, sim_mesh_prim: Any, vis_mesh_prim: Any) -> None: + """Apply the OmniPhysics deformable anchor APIs, rest state, and bind pose.""" + sim_mesh_path = sim_mesh_prim.GetPath().pathString + if deformable_type == "surface": + if not sim_mesh_prim.ApplyAPI("OmniPhysicsSurfaceDeformableSimAPI"): + raise RuntimeError(f"Failed to set surface deformable sim API on prim '{sim_mesh_path}'.") + sim_mesh_prim.GetAttribute("omniphysics:restShapePoints").Set(sim_mesh_prim.GetAttribute("points").Get()) + # flatten through numpy so USD coerces the flat index run into the Vec3i array the + # schema declares; a ``Vt.IntArray`` read straight back is rejected as a type mismatch + sim_mesh_prim.GetAttribute("omniphysics:restTriVtxIndices").Set( + np.asarray(sim_mesh_prim.GetAttribute("faceVertexIndices").Get()).flatten() + ) + else: + if not sim_mesh_prim.ApplyAPI("OmniPhysicsVolumeDeformableSimAPI"): + raise RuntimeError(f"Failed to set volume deformable sim API on prim '{sim_mesh_path}'.") + sim_mesh_prim.GetAttribute("omniphysics:restShapePoints").Set(sim_mesh_prim.GetAttribute("points").Get()) + sim_mesh_prim.GetAttribute("omniphysics:restTetVtxIndices").Set( + sim_mesh_prim.GetAttribute("tetVertexIndices").Get() + ) + # bind visual to sim mesh through the default bind pose + purposes = ["bindPose"] + vis_mesh_prim.ApplyAPI("OmniPhysicsDeformablePoseAPI", "default") + vis_mesh_prim.CreateAttribute("deformablePose:default:omniphysics:purposes", Sdf.ValueTypeNames.TokenArray).Set( + purposes + ) + points = UsdGeom.PointBased(vis_mesh_prim).GetPointsAttr().Get() + vis_mesh_prim.CreateAttribute("deformablePose:default:omniphysics:points", Sdf.ValueTypeNames.Point3fArray).Set( + points + ) + sim_mesh_prim.ApplyAPI("OmniPhysicsDeformablePoseAPI", "default") + sim_mesh_prim.CreateAttribute("deformablePose:default:omniphysics:purposes", Sdf.ValueTypeNames.TokenArray).Set( + purposes + ) + if not prim.ApplyAPI("OmniPhysicsDeformableBodyAPI"): + raise RuntimeError(f"Failed to set deformable body API on prim '{prim.GetPath().pathString}'.") + @classmethod def reset(cls, soft: bool = False) -> None: """Reset the physics simulation.""" diff --git a/source/isaaclab_physx/isaaclab_physx/sim/schemas/__init__.pyi b/source/isaaclab_physx/isaaclab_physx/sim/schemas/__init__.pyi index 8ca1676d7b55..e36e8cf62af9 100644 --- a/source/isaaclab_physx/isaaclab_physx/sim/schemas/__init__.pyi +++ b/source/isaaclab_physx/isaaclab_physx/sim/schemas/__init__.pyi @@ -24,6 +24,7 @@ __all__ = [ "PhysxConvexDecompositionPropertiesCfg", "PhysxConvexHullCfg", "PhysxConvexHullPropertiesCfg", + "PhysxDeformableBodyCfg", "PhysxDeformableBodyPropertiesCfg", "PhysxFixedTendonPropertiesCfg", "PhysxJointCfg", @@ -33,6 +34,7 @@ __all__ = [ "PhysxSDFMeshCfg", "PhysxSDFMeshPropertiesCfg", "PhysxSpatialTendonPropertiesCfg", + "PhysxSurfaceDeformableBodyCfg", "PhysxTendonAttachmentRootCfg", "PhysxTendonAxisCfg", "PhysxTendonAxisRootCfg", @@ -66,6 +68,7 @@ from .schemas_cfg import ( PhysxConvexDecompositionPropertiesCfg, PhysxConvexHullCfg, PhysxConvexHullPropertiesCfg, + PhysxDeformableBodyCfg, PhysxDeformableBodyPropertiesCfg, PhysxFixedTendonPropertiesCfg, PhysxJointCfg, @@ -75,6 +78,7 @@ from .schemas_cfg import ( PhysxSDFMeshCfg, PhysxSDFMeshPropertiesCfg, PhysxSpatialTendonPropertiesCfg, + PhysxSurfaceDeformableBodyCfg, PhysxTendonAttachmentRootCfg, PhysxTendonAxisCfg, PhysxTendonAxisRootCfg, 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 931b4d4b126f..3a6e76d6abcd 100644 --- a/source/isaaclab_physx/isaaclab_physx/sim/schemas/schemas_cfg.py +++ b/source/isaaclab_physx/isaaclab_physx/sim/schemas/schemas_cfg.py @@ -14,6 +14,7 @@ ArticulationRootFragment, CollisionBaseCfg, CollisionFragment, + DeformableBodyFragment, DeformableBodyPropertiesBaseCfg, FixedTendonFragment, JointDriveBaseCfg, @@ -34,6 +35,10 @@ class OmniPhysicsDeformableBodyPropertiesCfg(DeformableBodyPropertiesBaseCfg): """OmniPhysics properties for a deformable body. These properties are set with the prefix ``omniphysics:``. + + This class is superseded by the fragment-based + :class:`~isaaclab.sim.schemas.OmniPhysicsDeformableBodyCfg`; prefer the fragment for new + configurations. """ _usd_namespace: ClassVar[str | None] = "omniphysics" @@ -177,6 +182,74 @@ def __post_init__(self): super().__post_init__() +@configclass +class PhysxDeformableBodyCfg(DeformableBodyFragment): + """``physxDeformableBody:*`` deformable-body attributes from `PhysxBaseDeformableBodyAPI`_. + + A single-namespace fragment (see :class:`~isaaclab.sim.schemas.SchemaFragment`) covering the + solver, damping, and self-collision attributes shared by volume and surface deformables. + + .. _PhysxBaseDeformableBodyAPI: https://docs.omniverse.nvidia.com/kit/docs/omni_physics/latest/dev_guide/deformables/physx_deformable_schema.html#physxbasedeformablebodyapi + """ + + _usd_namespace: ClassVar[str | None] = "physxDeformableBody" + _usd_applied_schema: ClassVar[str | None] = "PhysxBaseDeformableBodyAPI" + + solver_position_iteration_count: int | None = None + """Number of solver position iterations.""" + + linear_damping: float | None = None + """Linear velocity damping [1/s].""" + + max_linear_velocity: float | None = None + """Maximum linear velocity [m/s].""" + + settling_damping: float | None = None + """Damping applied while the body settles [1/s].""" + + settling_threshold: float | None = None + """Velocity threshold below which settling damping engages [m/s].""" + + sleep_threshold: float | None = None + """Velocity threshold below which the body may sleep [m/s].""" + + max_depenetration_velocity: float | None = None + """Maximum velocity used to resolve penetrations [m/s].""" + + self_collision: bool | None = None + """Whether the body collides with itself.""" + + self_collision_filter_distance: float | None = None + """Distance under which self-collision contacts are filtered [m].""" + + enable_speculative_c_c_d: bool | None = None + """Whether speculative continuous collision detection is enabled.""" + + disable_gravity: bool | None = None + """Whether gravity is disabled for this body.""" + + +@configclass +class PhysxSurfaceDeformableBodyCfg(DeformableBodyFragment): + """``physxDeformableBody:*`` surface-only attributes from `PhysxSurfaceDeformableBodyAPI`_. + + A single-namespace fragment (see :class:`~isaaclab.sim.schemas.SchemaFragment`) narrowed to + surface deformables via :attr:`~isaaclab.sim.schemas.DeformableBodyFragment._deformable_types`. + + .. _PhysxSurfaceDeformableBodyAPI: https://docs.omniverse.nvidia.com/kit/docs/omni_physics/latest/dev_guide/deformables/physx_deformable_schema.html#physxsurfacedeformablebodyapi + """ + + _usd_namespace: ClassVar[str | None] = "physxDeformableBody" + _usd_applied_schema: ClassVar[str | None] = "PhysxSurfaceDeformableBodyAPI" + _deformable_types: ClassVar[tuple[str, ...]] = ("surface",) + + collision_pair_update_frequency: int | None = None + """How often collision pairs are refreshed, in solver steps.""" + + collision_iteration_multiplier: float | None = None + """Multiplier on collision solver iterations.""" + + @deprecated_schema_cfg("[UsdPhysicsRigidBodyCfg(...), PhysxRigidBodyCfg(...)]") @configclass class PhysxRigidBodyPropertiesCfg(RigidBodyBaseCfg): diff --git a/source/isaaclab_physx/isaaclab_physx/sim/spawners/materials/__init__.pyi b/source/isaaclab_physx/isaaclab_physx/sim/spawners/materials/__init__.pyi index 7a018a14ecd6..273b296761bb 100644 --- a/source/isaaclab_physx/isaaclab_physx/sim/spawners/materials/__init__.pyi +++ b/source/isaaclab_physx/isaaclab_physx/sim/spawners/materials/__init__.pyi @@ -7,9 +7,11 @@ __all__ = [ "DeformableBodyMaterialCfg", "PhysXDeformableMaterialCfg", "PhysxDeformableBodyMaterialCfg", + "PhysxDeformableMaterialCfg", "PhysxMaterialCfg", "PhysxRigidBodyMaterialCfg", "PhysxSurfaceDeformableBodyMaterialCfg", + "PhysxSurfaceDeformableMaterialCfg", "RigidBodyMaterialCfg", "SurfaceDeformableBodyMaterialCfg", ] @@ -18,9 +20,11 @@ from .physics_materials_cfg import ( DeformableBodyMaterialCfg, PhysXDeformableMaterialCfg, PhysxDeformableBodyMaterialCfg, + PhysxDeformableMaterialCfg, PhysxMaterialCfg, PhysxRigidBodyMaterialCfg, PhysxSurfaceDeformableBodyMaterialCfg, + PhysxSurfaceDeformableMaterialCfg, RigidBodyMaterialCfg, SurfaceDeformableBodyMaterialCfg, ) diff --git a/source/isaaclab_physx/isaaclab_physx/sim/spawners/materials/physics_materials_cfg.py b/source/isaaclab_physx/isaaclab_physx/sim/spawners/materials/physics_materials_cfg.py index 148c852aac9c..6692bc753df4 100644 --- a/source/isaaclab_physx/isaaclab_physx/sim/spawners/materials/physics_materials_cfg.py +++ b/source/isaaclab_physx/isaaclab_physx/sim/spawners/materials/physics_materials_cfg.py @@ -11,6 +11,7 @@ from isaaclab.sim.spawners.materials.physics_materials_cfg import ( DeformableBodyMaterialBaseCfg, + DeformableMaterialFragment, RigidBodyMaterialBaseCfg, RigidBodyMaterialFragment, SurfaceDeformableBodyMaterialBaseCfg, @@ -81,6 +82,28 @@ class PhysXDeformableMaterialCfg: """The elasticity damping for the deformable material. Defaults to 0.005.""" +@configclass +class PhysxDeformableMaterialCfg(DeformableMaterialFragment): + """``physxDeformableMaterial:*`` attributes from ``PhysxDeformableMaterialAPI``.""" + + _usd_namespace: ClassVar[str | None] = "physxDeformableMaterial" + _usd_applied_schema: ClassVar[str | None] = "PhysxDeformableMaterialAPI" + + elasticity_damping: float | None = None + """Damping acting against elastic deformation velocity [1/s].""" + + +@configclass +class PhysxSurfaceDeformableMaterialCfg(DeformableMaterialFragment): + """``physxDeformableMaterial:*`` surface attributes from ``PhysxSurfaceDeformableMaterialAPI``.""" + + _usd_namespace: ClassVar[str | None] = "physxDeformableMaterial" + _usd_applied_schema: ClassVar[str | None] = "PhysxSurfaceDeformableMaterialAPI" + + bend_damping: float | None = None + """Damping acting against bend-resistance forces [1/s].""" + + @configclass class PhysxDeformableBodyMaterialCfg( DeformableBodyMaterialBaseCfg,