From 5e6b21bf3e4211d9a11c1ddf89c0165cbbfaa9bc Mon Sep 17 00:00:00 2001
From: Feng <79072090+yufengsjtu@users.noreply.github.com>
Date: Tue, 29 Sep 2026 02:09:29 +0800
Subject: [PATCH 1/4] Enable detailed OvPhysX contact sensing and align TGS
force integration defaults (#7759)
# Description
Enable detailed contact sensing on OvPhysX and align its default TGS
external-force integration behavior with the PhysX backend.
This PR contains two separate commits to keep the sensor feature and
the default simulation behavior change independently reviewable.
### Detailed contact sensing
- Connect `ContactBinding.read_contact_data()` and
`ContactBinding.read_friction_data()` to the ContactSensor interface.
- Populate average filtered contact positions and summed filtered
friction forces.
- Support friction-force history and selective environment reset.
- Correct pattern-major indexing when mapping SDK detail buffers into
environment/sensor/filter buffers.
- Require non-empty filters and a positive detailed-contact capacity.
- Bound kernel reads to the returned buffer capacity. OVPhysX warns and
truncates
oversized contact data; increase the configured capacity to retain all
contacts.
Existing normal-only force aliases retain their behavior. Aggregate
friction reporting remains unsupported on OvPhysX, and existing pose
tracking limitations are unchanged.
### TGS default alignment
Change `OvPhysxCfg.enable_external_forces_every_iteration` from `False`
to `True`, matching the PhysX backend default.
Matched-state ANYmal diagnostics showed that this configuration
difference affected both normal and friction forces. With the flag
aligned, the two backends agreed to approximately `1e-4 N` in the tested
single-state, 5 ms probe. This is a targeted consistency check, not a
general accuracy ranking.
### Breaking behavior change and migration
Simulations relying on the previous OvPhysX default may produce
different
velocities, contact forces, and trajectories.
To retain the previous integration behavior, configure:
```python
OvPhysxCfg(enable_external_forces_every_iteration=False)
```
Explicit `True` and `False` overrides remain supported and are tested
through USD scene-attribute propagation.
### Scope and dependencies
- No dependency additions, version upgrades, or lockfile changes.
- Validation used develop's pinned OvPhysX 0.6.3.
- Experimental comparison scripts and generated reports are not included
in the repository changes.
- This PR does not change built-in `ContactSensor.debug_vis` behavior.
- Minor and major changelog fragments cover the feature and the default
behavior change respectively.
Related issue: None linked.
### Validation
- Physics-manager lifecycle and CPU/GPU Warp kernel tests: 51 passed.
- CPU rigid-object and contact-sensor tests: 37 passed, 1 expected
failure.
- GPU 0 rigid-object and contact-sensor tests: 30 passed, 1 expected
failure.
- Full repository formatting/pre-commit checks: passed.
- Changelog validation: passed.
Coverage includes raw SDK agreement, contact-position geometry checks,
optional tracking modes, no-contact values, history, selective reset,
multi-environment/multi-sensor indexing, invalid configuration, capacity
truncation, and default/explicit TGS configuration propagation.
The frictionless-sliding test checks tangential velocity, matching
PhysX; its
vertical contact-solver velocity is not a friction invariant. The
original CPU
failure was reproduced before correcting the assertion. Truncated-buffer
kernel
cases also failed before bounding reads and passed afterward.
Existing runtime deprecation warnings remain. Full-file Pyright is not
claimed clean; existing configuration-sentinel and USD-stub diagnostics
were kept outside this change.
## Type of change
- New feature (non-breaking change which adds functionality)
- Breaking change (default simulation behavior changes unless explicitly
overridden)
- Documentation update
## Release backport
- [x] Backport this pull request to the
active release branch after it merges into `develop`
## Screenshots
The first image contrasts the previous rejection of detailed tracking
options with the newly available filtered force data. The before panel
shows the runtime error as text; the after panel is an actual simulation
capture using Newton GL markers.
The second image compares:
- PhysX with external forces every iteration enabled and disabled.
- OvPhysX with the same option disabled and enabled.
All four cases use the same mechanical input state, joint torques, and
timestep. The displayed posture is the shared input state; force labels
come from the following physics step.
These images are validation artifacts, not changes to built-in sensor
debug visualization.
## Checklist
Docker and GPU tests run on demand. Push the commits you want tested,
then comment `run-ci` on the pull request.
- [x] I have read and understood the [contribution
guidelines](https://isaac-sim.github.io/IsaacLab/main/source/refs/contributing.html)
- [x] I have run the [pre-commit checks](https://pre-commit.com/) with
`uv run isaaclab -f`, the uv-managed equivalent of `./isaaclab.sh
--format`
- [x] I have made corresponding changes to the documentation
- [ ] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] I have added changelog fragments under `source//changelog.d/`
for every touched package
- [ ] I have added my name to `CONTRIBUTORS.md` or my name already
exists there
---------
Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Octi Zhang
---
.../concepts/sensors/contact_sensor.rst | 25 ++-
.../ovphysx-contact-details.minor.rst | 10 +
.../ovphysx-external-forces-default.major.rst | 8 +
.../physics/ovphysx_manager_cfg.py | 9 +-
.../sensors/contact_sensor/contact_sensor.py | 99 ++++++++--
.../sensors/contact_sensor/kernels.py | 97 +++++++---
.../test/assets/test_rigid_object.py | 7 +-
.../physics/test_ovphysx_manager_lifecycle.py | 19 ++
.../test/sensors/test_contact_sensor.py | 182 ++++++++++++++++--
.../sensors/test_contact_sensor_kernels.py | 103 +++++-----
10 files changed, 431 insertions(+), 128 deletions(-)
create mode 100644 source/isaaclab_ov/changelog.d/ovphysx-contact-details.minor.rst
create mode 100644 source/isaaclab_ov/changelog.d/ovphysx-external-forces-default.major.rst
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/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)
From 465ac73a45f8e1f9b8e9dcf1387fa83a22b5935a Mon Sep 17 00:00:00 2001
From: vidurv-nvidia
Date: Mon, 28 Sep 2026 14:53:22 -0500
Subject: [PATCH 2/4] Add volume and surface deformable schema fragment
families (#6673)
## Summary
Adds volume and surface deformable schema fragments to the existing
fragment API.
```python
spawn = sim_utils.MeshRectangleCfg(
size=(0.5, 0.5),
surface_deformable_props=sim_utils.OmniPhysicsDeformableBodyCfg(mass=0.2),
collision_props=sim_utils.UsdPhysicsCollisionCfg(collision_enabled=True),
physics_material=sim_utils.OmniPhysicsSurfaceDeformableMaterialCfg(surface_thickness=0.01),
)
```
- `volume_deformable_props` and `surface_deformable_props` accept a
fragment, list, or explicit path mapping. A bare fragment targets only
the spawn prim, not every mesh below it. Only one deformable slot may be
set.
- Creation prepares the simulation mesh, applies the active backend's
body/simulation schemas, then authors the fragments. Existing bodies are
tuned without rebuilding their meshes. A supplied TetMesh is reused.
- PhysX and Newton implement the setup hook. PhysX supplies rest/bind
poses; Newton supplies its native schema tokens and visual mesh setup.
- Physics materials accept deformable fragments and mixed
rigid/deformable lists. The generalized writer is
`spawn_physics_material_from_fragments`; its former rigid-only name
remains a deprecated forwarder.
- Legacy deformable configs and entry points remain available.
OVPhysX's new fragment-slot setup is not implemented in this PR; its
existing `deformable_props` path remains supported.
## Rebase and cleanup
Rebased onto develop `5e6b21bf3`.
Removed the empty Newton body placeholder, a material-warning subtree
walk and its suppression flag, two one-use spawner helpers, redundant
callable resolution, an unused backend-hook argument, and the unused
tetrahedralization argument on the surface writer. Removed stale
changelog fragments from the already-merged dependency.
Consolidated five new test files into the remaining deformable and
existing schema suites. Tests exercise authored USD results, including
nested-body ownership, rather than a stub manager or metadata-only
assertions.
Fixed two authoring errors:
- Bare collision fragments now tune the deformable simulation mesh, not
the inert body root.
- Mesh preparation uses the explicitly supplied stage rather than
searching the current stage.
The net PR addition decreased from 2,007 to 1,280 lines, including
production code from +964 to +766 and tests from +730 to +281. These
counts compare each branch against its respective develop merge base.
## Validation
- **187 tests passed** across deformable fragments, material fragments,
mesh spawning, Newton schemas, core schemas, backend separation, schema
fragments, and deprecation coverage.
- Ran against Isaac Sim 6.1, including both PhysX and Newton schema
setup. Two unrelated asset-dependent articulation tests were excluded
from this focused run.
- Verified the collision-target and explicit-stage regression cases fail
with the previous behavior restored.
- `uv run isaaclab -f` and changelog checks passed.
- Documentation builds and the broader test matrix are left to CI.
## Type of change
New feature. Existing public entry points are retained.
## Release backport
- [x] Backport this pull request to the
active release branch after it merges into `develop`
---
docs/source/api/lab/isaaclab.sim.schemas.rst | 16 +
docs/source/api/lab/isaaclab.sim.spawners.rst | 20 +
.../isaaclab_newton.sim.spawners.rst | 12 +
.../lab_physx/isaaclab_physx.sim.schemas.rst | 12 +
.../lab_physx/isaaclab_physx.sim.spawners.rst | 12 +
docs/source/concepts/schema_fragments.rst | 92 +++-
.../vidurv-deformable-fragments.minor.rst | 47 +++
.../isaaclab/physics/physics_manager.py | 26 ++
source/isaaclab/isaaclab/sim/__init__.pyi | 8 +
.../isaaclab/sim/schemas/__init__.pyi | 8 +
.../isaaclab/isaaclab/sim/schemas/schemas.py | 396 ++++++++++++++----
.../isaaclab/sim/schemas/schemas_cfg.py | 36 ++
.../sim/spawners/from_files/from_files.py | 13 +
.../sim/spawners/from_files/from_files_cfg.py | 8 +-
.../sim/spawners/materials/__init__.pyi | 12 +
.../spawners/materials/physics_materials.py | 114 +++--
.../materials/physics_materials_cfg.py | 57 ++-
.../isaaclab/sim/spawners/meshes/meshes.py | 48 ++-
.../sim/spawners/meshes/meshes_cfg.py | 8 +-
.../isaaclab/sim/spawners/spawner_cfg.py | 43 ++
.../isaaclab/isaaclab/sim/spawners/utils.py | 24 ++
.../sim/spawners/wrappers/wrappers.py | 10 +-
.../test/sim/test_deformable_fragments.py | 131 ++++++
.../test/sim/test_material_fragments.py | 70 +++-
source/isaaclab/test/sim/test_schemas.py | 48 +++
.../test/sim/test_schemas_deprecation.py | 3 +
source/isaaclab/test/sim/test_spawn_meshes.py | 14 +-
.../vidurv-deformable-fragments.minor.rst | 9 +
.../isaaclab_newton/physics/newton_manager.py | 33 ++
.../isaaclab_newton/sim/__init__.pyi | 4 +
.../sim/spawners/materials/__init__.pyi | 4 +
.../materials/physics_materials_cfg.py | 55 ++-
.../test/sim/test_newton_schemas.py | 43 +-
.../vidurv-deformable-fragments.minor.rst | 15 +
.../isaaclab_physx/physics/physx_manager.py | 39 +-
.../isaaclab_physx/sim/schemas/__init__.pyi | 4 +
.../isaaclab_physx/sim/schemas/schemas_cfg.py | 73 ++++
.../sim/spawners/materials/__init__.pyi | 4 +
.../materials/physics_materials_cfg.py | 23 +
39 files changed, 1437 insertions(+), 157 deletions(-)
create mode 100644 source/isaaclab/changelog.d/vidurv-deformable-fragments.minor.rst
create mode 100644 source/isaaclab/test/sim/test_deformable_fragments.py
create mode 100644 source/isaaclab_newton/changelog.d/vidurv-deformable-fragments.minor.rst
create mode 100644 source/isaaclab_physx/changelog.d/vidurv-deformable-fragments.minor.rst
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/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/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/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_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,
From 2ed561de8738f44c9ee84f44bffb26134fc36cac Mon Sep 17 00:00:00 2001
From: camevor
Date: Mon, 28 Sep 2026 23:30:47 +0200
Subject: [PATCH 3/4] [Newton] Verify coordinate-layout targets without a
compatibility pin (#7449)
## Summary
Newton 1.6 stores position targets in joint-coordinate layout: a
floating root uses seven coordinates but six DOFs, and a ball joint uses
four quaternion components but three DOFs. Reusing DOF indices for these
targets can address the wrong entries.
Isaac Lab already supports the coordinate layout through #7520. This PR
therefore no longer pins `newton.use_coord_layout_targets = False` or
changes Newton's process-wide settings. There are **no production
changes relative to develop**.
The existing coordinate test now uses a real Newton model instead of a
mocked articulation view. Across two worlds, it checks that public joint
positions remain DOF-shaped and command submission writes the correct
coordinate targets, including ball-joint quaternions, without modifying
floating-root targets. The separate pin-only test is removed.
Net diff: **45 additions, 53 deletions**, plus a test-only changelog
marker.
## Validation
With Newton 1.6's default layout and neither package applying a pin:
- 22 coordinate and shared actuator-adapter tests passed.
- 15 Newton rollout checks passed: DC motors, mixed implicit/explicit
actuators, delayed PD, and reversed joint ordering.
- 13 PhysX rollout checks passed with Newton-native DC motor, delayed
PD, and mixed actuators.
- The regression fails when the legacy layout is forced. Bypassing
command-side quaternion conversion also fails the ball-joint case.
- `uv run isaaclab -f` passed.
Rollouts used GPU 0 and Isaac Sim 6.1.
## Release backport
- [x] Backport this pull request to the
active release branch after it merges into `develop`
---------
Co-authored-by: Kelly Guo
Co-authored-by: Octi Zhang
---
.../newton-coordinate-targets.skip | 0
.../test/assets/test_joint_coordinates.py | 98 +++++++++----------
2 files changed, 45 insertions(+), 53 deletions(-)
create mode 100644 source/isaaclab_newton/changelog.d/newton-coordinate-targets.skip
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/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:
From 9485e4b935d637166659dfb12e6b52c66ab63d25 Mon Sep 17 00:00:00 2001
From: fanes <74020209+fatimaanes@users.noreply.github.com>
Date: Tue, 29 Sep 2026 07:21:55 +0900
Subject: [PATCH 4/4] Point nsys trace config at current OVRTX renderer symbols
(#8115)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
# Description
`scripts/benchmarks/nsys_trace.json` referenced six `OVRTXRenderer`
methods that were renamed or consolidated away, so `test_nsys_trace.py`
failed to resolve them. The test skips when `isaaclab_ov` is not
importable, which is why this only surfaces on PRs that install the `ov`
extra — for example #7137, where it fails the required `isaaclab (core)
[1/3]` check.
Each rename was verified against the commit that made it:
| Removed | Replaced by | Commit |
|---|---|---|
| `_clone_sources_in_ovrtx`, `_clone_sources_ovstage` | `_clone_sources`
| #8045 |
| `_update_scene_partitions_after_clone_ovstage` |
`_update_scene_partitions_after_clone` (already listed) | #8045 |
| `_setup_xform_bindings` | `_setup_xform_bindings_legacy`,
`_setup_xform_bindings_ovstage` | #6602 |
| `_setup_deformable_bindings`, `_setup_particle_bindings` |
`_setup_geometry_bindings_legacy`, `_setup_geometry_bindings_ovstage` |
#7995 |
Both the `legacy` and `ovstage` variants are listed because
`ISAAC_LAB_OVRTX_USE_OVSTAGE` selects the path at runtime, so the config
has to resolve under either setting.
Net 17 traced functions to 16. No code or behaviour change — this only
brings the NVTX trace definitions back in line with the functions that
exist.
## Type of change
- Bug fix (non-breaking change which fixes an issue)
## Checklist
- [x] I have read and understood the [contribution
guidelines](https://isaac-sim.github.io/IsaacLab/main/source/refs/contributing.html)
- [x] I have run the [`pre-commit` checks](https://pre-commit.com/) with
`./isaaclab.sh --format`
- [x] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] I have added a changelog fragment under
`source//changelog.d/` for every touched package
- [x] I have added my name to the `CONTRIBUTORS.md` or my name already
exists there
Notes on the checklist: no changelog fragment is required —
`tools/changelog/cli.py` scopes to `source/`, and this change is under
`scripts/`. No new test is added because `test_nsys_trace.py` already
covers this exactly; it is the test that currently fails and will pass
with this change. No documentation change is needed as no public API or
behaviour changes.
---
scripts/benchmarks/nsys_trace.json | 11 +++++------
1 file changed, 5 insertions(+), 6 deletions(-)
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",