Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
1afba8b
Add OvPhysxView string-keyed tensor-binding view
AntoineRichard Jun 22, 2026
3a9cf1e
Make OvPhysxView a binding manager; raise on device mismatch
AntoineRichard Jun 22, 2026
42dcd5b
Bump OvPhysxView changelog to major; refine entry
AntoineRichard Jun 22, 2026
7d3d305
Address review: reject non-float32 buffers; harden errors
AntoineRichard Jun 22, 2026
e74cc82
Harden OvPhysxView API against adversarial inputs
AntoineRichard Jun 22, 2026
ff06224
Add OvPhysxView.try_binding_for (non-raising availability accessor)
AntoineRichard Jun 22, 2026
5bc49c0
feat(ovphysx): cache read reinterprets + type get_attribute in OvPhys…
AntoineRichard Jun 22, 2026
43d34b2
docs(ovphysx): add isaaclab_ovphysx.sim.views to the API reference
AntoineRichard Jun 22, 2026
8144819
fix(ovphysx): address Greptile review of OvPhysxView
AntoineRichard Jun 23, 2026
0757a79
docs(ovphysx): address Marco's API-contract review of OvPhysxView
AntoineRichard Jun 23, 2026
c72cf8c
feat(ovphysx): route Articulation through OvPhysxView
AntoineRichard Jun 22, 2026
a4229a6
test(ovphysx): route articulation-helpers tendon test through OvPhysx…
AntoineRichard Jun 22, 2026
6268f9a
test(ovphysx): add MockOvPhysxView instead of a real view in helpers
AntoineRichard Jun 22, 2026
a42c393
Merge branch 'develop' into antoiner/feat/ovphysx_articulation_view
AntoineRichard Jun 25, 2026
c8b680b
Merge branch 'develop' into antoiner/feat/ovphysx_articulation_view
AntoineRichard Jun 25, 2026
ba1fd6a
fix(ovphysx): drop root_view on articulation invalidate
AntoineRichard Jun 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
Changed
^^^^^^^

* **Breaking:** :attr:`~isaaclab_ovphysx.assets.Articulation.root_view` now returns an
:class:`~isaaclab_ovphysx.sim.views.OvPhysxView` binding manager instead of a raw
``dict`` mapping ``TensorType`` to ``TensorBinding``. The view owns all tensor-binding
creation, caching, reads, and writes for the articulation. Address bindings by attribute
name or ``TensorType`` member through
:meth:`~isaaclab_ovphysx.sim.views.OvPhysxView.try_binding_for` /
:meth:`~isaaclab_ovphysx.sim.views.OvPhysxView.get_attribute` rather than indexing the
dict, e.g. replace ``root_view[tensor_type]`` with
``root_view.try_binding_for(tensor_type)`` and ``tensor_type in root_view`` with
``root_view.try_binding_for(tensor_type) is not None``.

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
vec13f,
)
from isaaclab_ovphysx.physics import OvPhysxManager
from isaaclab_ovphysx.sim.views.ovphysx_view import OvPhysxView

from .kernels import _fd_joint_acc

Expand Down Expand Up @@ -72,30 +73,28 @@ class ArticulationData(BaseArticulationData):
__backend_name__: str = "ovphysx"
"""The name of the backend for the articulation data."""

def __init__(self, bindings: dict[int, Any], device: str) -> None:
def __init__(self, view: OvPhysxView, device: str) -> None:
"""Initialize the articulation data container.

Args:
bindings: Dictionary of OVPhysX :class:`TensorBinding` objects keyed
by :class:`isaaclab_ovphysx.tensor_types.TensorType`. All counts
(instances, bodies, DOFs, fixed/spatial tendons) are derived
from the binding metadata. Name lists are assigned by
:meth:`~isaaclab_ovphysx.assets.Articulation._initialize_impl`
after construction.
view: The :class:`~isaaclab_ovphysx.sim.views.OvPhysxView` binding manager
for this articulation. All counts (instances, bodies, DOFs,
fixed/spatial tendons) are derived from the view metadata. Name lists
are assigned by
:meth:`~isaaclab_ovphysx.assets.Articulation._initialize_impl` after
construction.
device: Simulation device string (e.g., ``"cuda:0"`` or ``"cpu"``).
"""
super().__init__(root_view=None, device=device)
self._bindings = bindings

# Every OVPhysX TensorBinding carries the articulation metadata
# (instance count, dof_count, body_count, fixed/spatial tendon counts);
# any binding will do for the read.
sample = next(iter(bindings.values()))
self.num_instances = sample.count
self.num_bodies = sample.body_count
self.num_joints = sample.dof_count
self.num_fixed_tendons = getattr(sample, "fixed_tendon_count", 0)
self.num_spatial_tendons = getattr(sample, "spatial_tendon_count", 0)
self._view = view

# The view exposes the articulation metadata (instance count, dof_count,
# body_count, fixed/spatial tendon counts) read from any instantiated binding.
self.num_instances = view.count
self.num_bodies = view.body_count
self.num_joints = view.dof_count
self.num_fixed_tendons = view.fixed_tendon_count
self.num_spatial_tendons = view.spatial_tendon_count
# private aliases used throughout _create_buffers and property bodies
self._num_instances = self.num_instances
self._num_bodies = self.num_bodies
Expand All @@ -109,8 +108,6 @@ def __init__(self, bindings: dict[int, Any], device: str) -> None:
self._is_primed: bool = False
# pinned-host staging buffers for CPU-only bindings (keyed by tensor_type)
self._cpu_staging_buffers: dict[int, wp.array] = {}
# scratch buffers for _get_read_view cache (keyed by (tensor_type, ptr))
self._read_scratch: dict = {}

# obtain gravity from the simulation configuration (fall back to standard
# gravity when the simulation has not been configured yet, e.g. in unit tests)
Expand Down Expand Up @@ -1553,35 +1550,37 @@ def _create_buffers(self) -> None: # noqa: C901
# Initialize ProxyArray wrappers (lazily created on first property access).
self._pin_proxy_arrays()

def _binding_read(self, tensor_type: int, binding: Any, dst: wp.array) -> None:
"""Read *binding* into *dst*, staging through a pinned-host buffer for CPU-only bindings.
def _binding_read(self, tensor_type: int, dst: wp.array) -> None:
"""Refresh *dst* from the binding via the view, staging for CPU-only bindings.

For GPU-resident state bindings (pose, velocity, etc.) the read goes directly
into the destination array. For CPU-only property bindings (mass, COM, limits,
stiffness, …) the wheel writes into a pinned-host staging buffer first, then
:func:`wp.copy` moves the data to the simulation device asynchronously.
GPU-resident state bindings (pose, velocity, …) fill *dst* directly through
:meth:`~isaaclab_ovphysx.sim.views.OvPhysxView.read_into`, which reinterprets a
structured *dst* and reuses that reinterpret across calls so the wheel's read cache
stays warm. CPU-only property bindings (mass, COM, limits, stiffness, …) are read
into a pinned-host staging buffer first (the view does not stage across devices),
then :func:`wp.copy` moves the data to the simulation device.

Args:
tensor_type: TensorType key identifying the binding.
binding: OVPhysX TensorBinding whose ``read`` method is called.
dst: Destination :class:`wp.array` on the simulation device.
"""
if tensor_type not in TT._CPU_ONLY_TYPES or self.device == "cpu":
binding.read(dst)
self._view.read_into(tensor_type, dst)
return
# Route through a lazily-allocated pinned-host staging buffer.
# Route through a lazily-allocated pinned-host staging buffer (read_into refuses to
# cross devices), then copy to the simulation device.
staging = self._cpu_staging_buffers.get(tensor_type)
if staging is None:
staging = wp.zeros(binding.shape, dtype=wp.float32, device="cpu", pinned=True)
staging = wp.zeros(self._view.binding_for(tensor_type).shape, dtype=wp.float32, device="cpu", pinned=True)
self._cpu_staging_buffers[tensor_type] = staging
binding.read(staging)
# Build a flat float32 view of dst matching the binding's flat shape.
self._view.read_into(tensor_type, staging)
# Build a flat float32 view of dst matching the staging's flat shape.
if dst.dtype == wp.float32:
view = dst
else:
view = wp.array(
ptr=dst.ptr,
shape=binding.shape,
shape=staging.shape,
dtype=wp.float32,
device=str(dst.device),
copy=False,
Expand Down Expand Up @@ -1741,11 +1740,11 @@ def _read_cpu(tensor_type):
]:
binding = self._get_binding(tt)
if binding is not None:
self._binding_read(tt, binding, buf.data)
self._binding_read(tt, buf.data)
buf.timestamp = self._sim_timestamp
binding = self._get_binding(TT.FIXED_TENDON_LIMIT)
if binding is not None:
self._binding_read(TT.FIXED_TENDON_LIMIT, binding, self._fixed_tendon_pos_limits.data)
self._binding_read(TT.FIXED_TENDON_LIMIT, self._fixed_tendon_pos_limits.data)
self._fixed_tendon_pos_limits.timestamp = self._sim_timestamp

# Spatial tendon properties (sim-device, see fixed-tendon comment above).
Expand All @@ -1759,7 +1758,7 @@ def _read_cpu(tensor_type):
]:
binding = self._get_binding(tt)
if binding is not None:
self._binding_read(tt, binding, buf.data)
self._binding_read(tt, buf.data)
buf.timestamp = self._sim_timestamp

def _pin_proxy_arrays(self) -> None:
Expand Down Expand Up @@ -1920,135 +1919,47 @@ def _invalidate_initialize_callback(self, event) -> None:
val.timestamp = -1.0

def _get_binding(self, tensor_type: int):
"""Return the cached binding for :paramref:`tensor_type`, or ``None`` if absent.
"""Return the binding for :paramref:`tensor_type`, or ``None`` if unavailable.

Args:
tensor_type: TensorType key.

Returns:
The TensorBinding, or ``None`` if not present in the binding dict.
"""
return self._bindings.get(tensor_type)

def _get_read_view(self, tensor_type: int, wp_array: wp.array, floats_per_elem: int = 0) -> wp.array | None:
"""Return a stable float32 view of a warp buffer for reading from a binding.

For structured-dtype buffers (transformf, spatial_vectorf), the view
reinterprets the same GPU memory as a flat float32 array matching the
binding's shape. For plain float32 buffers, returns the array as-is.

The returned view is cached so that ``binding.read(view)`` sees the
same object on every call, enabling the binding's internal read cache.
Delegates to :attr:`root_view`'s
:meth:`~isaaclab_ovphysx.sim.views.OvPhysxView.try_binding_for`, which returns the
cached binding (creating it on first access) or ``None`` for tensor types that do
not apply to these prims.

Args:
tensor_type: TensorType key.
wp_array: Destination warp array.
floats_per_elem: Number of float32 elements per logical element
(e.g. 7 for transformf, 6 for spatial_vectorf). Pass 0 to
return the array as-is.

Returns:
Float32 view suitable for ``binding.read()``, or ``None``.
The TensorBinding, or ``None`` if not available for these prims.
"""
if not hasattr(self, "_read_view_cache"):
self._read_view_cache = {}
cache_key = (tensor_type, wp_array.ptr)
cached = self._read_view_cache.get(cache_key)
if cached is not None:
return cached

binding = self._get_binding(tensor_type)
if binding is None:
self._read_view_cache[cache_key] = None
return None

if floats_per_elem > 0:
view = wp.array(
ptr=wp_array.ptr,
shape=binding.shape,
dtype=wp.float32,
device=str(wp_array.device),
copy=False,
)
else:
view = wp_array

self._read_view_cache[cache_key] = view
return view
return self._view.try_binding_for(tensor_type)

def _read_binding_into_buf(self, tensor_type: int, buf: TimestampedBuffer) -> None:
"""Read from an ovphysx binding into a :class:`TimestampedBuffer`, skipping if fresh.
"""Refresh *buf* from the matching binding via the view, skipping if fresh or absent.

Args:
tensor_type: TensorType key.
buf: Timestamped buffer to refresh.
"""
if buf.timestamp >= self._sim_timestamp:
return
view = self._get_read_view(tensor_type, buf.data)
if view is None:
return
self._get_binding(tensor_type).read(view)
buf.timestamp = self._sim_timestamp

def _read_transform_binding(self, tensor_type: int, buf: TimestampedBuffer) -> None:
"""Read a pose binding (float32 view of transformf buffer), skipping if fresh.

CPU-only bindings (e.g. ``BODY_COM_POSE``) are routed through a
pinned-host staging buffer via :meth:`_binding_read` so the wheel's
device-match requirement is satisfied even on a GPU sim.

Args:
tensor_type: TensorType key.
buf: Timestamped :class:`wp.transformf` buffer to refresh.
"""
if buf.timestamp >= self._sim_timestamp:
return
binding = self._get_binding(tensor_type)
if binding is None:
return
view = self._get_read_view(tensor_type, buf.data, 7)
if view is None:
return
self._binding_read(tensor_type, binding, view)
buf.timestamp = self._sim_timestamp

def _read_spatial_vector_binding(self, tensor_type: int, buf: TimestampedBuffer) -> None:
"""Read a velocity binding (float32 view of spatial_vectorf buffer), skipping if fresh.
Reads route through :meth:`~isaaclab_ovphysx.sim.views.OvPhysxView.read_into`, which
derives the structured reinterpret from the binding shape (so transform, spatial-vector,
and scalar buffers all use the same path) and reuses that reinterpret across calls;
CPU-only property bindings are staged onto the simulation device inside
:meth:`_binding_read`.

Args:
tensor_type: TensorType key.
buf: Timestamped :class:`wp.spatial_vectorf` buffer to refresh.
buf: Timestamped buffer to refresh.
"""
if buf.timestamp >= self._sim_timestamp:
return
view = self._get_read_view(tensor_type, buf.data, 6)
if view is None:
if self._get_binding(tensor_type) is None:
return
self._get_binding(tensor_type).read(view)
self._binding_read(tensor_type, buf.data)
buf.timestamp = self._sim_timestamp

def _read_scalar_binding(self, tensor_type: int, buf: TimestampedBuffer) -> None:
"""Refresh a scalar or flat float32 buffer from the matching binding if stale.

Identical timestamp-gating contract as :meth:`_read_transform_binding`
but without a structured-dtype reinterpret cast. CPU-only bindings
(e.g. ``DOF_STIFFNESS``, ``DOF_LIMIT``) are routed through a
pre-allocated pinned-host staging buffer via :meth:`_binding_read` so
the wheel's device-match requirement is satisfied even on a GPU sim.

Args:
tensor_type: TensorType key identifying the binding.
buf: Timestamped buffer whose :attr:`~TimestampedBuffer.data` field
will be refreshed.
"""
if buf.timestamp >= self._sim_timestamp:
return
binding = self._get_binding(tensor_type)
if binding is None:
return
self._binding_read(tensor_type, binding, buf.data)
buf.timestamp = self._sim_timestamp
# ``read_into`` derives the reinterpret from the binding shape and ``_binding_read`` handles
# CPU-only staging, so the transform / spatial-vector / scalar read paths are now identical;
# keep the distinct names as aliases for call-site readability.
_read_transform_binding = _read_binding_into_buf
_read_spatial_vector_binding = _read_binding_into_buf
_read_scalar_binding = _read_binding_into_buf

def _get_pos_from_transform(self, transform: wp.array) -> wp.array:
"""Return a position view aliased into a transform array.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,4 @@
#
# SPDX-License-Identifier: BSD-3-Clause

from .views import MockTensorBinding, MockOvPhysxBindingSet
from .views import MockOvPhysxBindingSet, MockOvPhysxView, MockTensorBinding
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,4 @@
#
# SPDX-License-Identifier: BSD-3-Clause

from .mock_ovphysx_bindings import MockTensorBinding, MockOvPhysxBindingSet
from .mock_ovphysx_bindings import MockOvPhysxBindingSet, MockOvPhysxView, MockTensorBinding
Loading
Loading