# 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
"""String-keyed view over OVPhysX ``TensorBinding`` handles.
OVPhysX exposes physics attributes as a loose ``dict[TensorType, TensorBinding]``
with no view object -- unlike Newton's ``selection.ArticulationView`` or PhysX's
typed tensor views. :class:`OvPhysxView` wraps those bindings for one prim pattern
(or an explicit ``prim_paths`` list) behind a string-keyed surface that mirrors
Newton's selection ergonomics, but is simpler: there is no ``Model``/``State``/
``Control`` source object because the :class:`TensorType` already implies where the
data lives.
Attributes are addressed by the lowercased ``TensorType`` enum member name (derived
directly from the wheel enum -- no hand-maintained table) or by the enum member
itself::
view.get_attribute("articulation_dof_stiffness") # allocates and returns
view.read_into("articulation_root_pose", root_pose_buf) # zero-copy into a caller buffer
view.set_attribute("rigid_body_pose", values, mask=env_mask)
This view is the binding-management layer *inside* the OVPhysX asset classes, so it
exposes a raw :meth:`binding_for` accessor and a zero-copy :meth:`read_into` that
fills a caller-owned, possibly structured-dtype buffer via a binding-reported scalar
dtype reinterpret view -- the same mechanism the data containers use today.
**Device policy: no implicit CPU<->GPU conversion.** OVPhysX serves DOF/body
*property* tensor types from CPU memory even on a GPU sim (see :data:`_CPU_ONLY_NAMES`),
while *state* tensor types are device-resident. This view reads/writes each binding on
its native device and **raises** :class:`OvPhysxView.DeviceMismatch` if a caller hands
it a buffer on the wrong device. Staging a CPU property to/from the simulation device
is the caller's explicit responsibility, never hidden here.
**Dtype: binding-reported DLPack scalar dtype.** The view resolves each binding's DLPack
``code``, ``bits``, and ``lanes`` metadata to a Warp scalar dtype. Structured dtypes in
:data:`_ATTR_DTYPE`, e.g. ``wp.transformf``, are used only when they share that scalar.
All reads and writes reinterpret matching bits without conversion; a mismatched scalar or
unsupported DLPack dtype raises :class:`OvPhysxView.DtypeMismatch`. The public surface
(:attr:`~OvPhysxView.attribute_names`) remains name-validity rather than availability or
dtype compatibility.
"""
from __future__ import annotations
import logging
import math
from typing import Any, ClassVar, Protocol
import warp as wp
from isaaclab_ovphysx._runtime import import_ovphysx
from isaaclab_ovphysx.tensor_types import _CPU_ONLY_TYPES
logger = logging.getLogger(__name__)
# Pure-Python enum (no native dependency); safe to import regardless of USD state.
TensorType = import_ovphysx("ovphysx.types").TensorType
# Tensor types that cannot be written. The first group is read-only by PhysX
# convention (accelerations, inverse mass/inertia, projected joint force); the
# computed-dynamics group (jacobian, mass matrix, coriolis, gravity) is read-only
# in practice.
#
# TEMPORARY: this is a hand-maintained table and WILL drift as ``TensorType`` grows.
# The wheel has at least three access modes in practice -- read/write, read-only, and
# write-only control tensors -- and exposes no access metadata today. Replace this whole
# table once the wheel exposes a per-type ``access_mode`` enum (preferred over a boolean
# ``is_writable`` flag, so write-only control tensors stay distinguishable).
# TODO(ovphysx): source access mode from a wheel ``access_mode`` query when available.
_READ_ONLY_NAMES: frozenset[str] = frozenset(
{
"rigid_body_acceleration",
"rigid_body_inv_mass",
"rigid_body_inv_inertia",
"articulation_link_acceleration",
"articulation_link_incoming_joint_force",
"articulation_body_inv_mass",
"articulation_body_inv_inertia",
"articulation_dof_projected_joint_force",
"articulation_jacobian",
"articulation_mass_center_world",
"articulation_mass_center_local",
"articulation_mass_matrix",
"articulation_centroidal_momentum",
"articulation_coriolis_and_centrifugal_force",
"articulation_gravity_force",
"deformable_rest_nodal_position",
"deformable_sim_element_indices",
"deformable_collision_element_indices",
"surface_deformable_rest_position",
"surface_deformable_sim_element_indices",
}
)
# DOF/body property tensor types that are CPU-resident even on a GPU sim. Derived
# from the canonical, wheel-availability-gated set in ``isaaclab_ovphysx.tensor_types``
# so the two never drift.
_CPU_ONLY_NAMES: frozenset[str] = frozenset(tt.name.lower() for tt in _CPU_ONLY_TYPES)
# Structured Warp dtype for attributes whose flat trailing dimension has a fixed semantic
# layout: 7-scalar poses -> ``wp.transformf``, 6-scalar spatial vectors -> ``wp.spatial_vectorf``.
# :meth:`OvPhysxView.get_attribute` returns an array of this dtype, so callers get a typed
# ``[N, ...]`` array rather than a flat ``[N, ..., k]`` scalar one. Attributes absent from this
# map default to the binding-reported scalar dtype. The semantic layouts are hand-maintained.
# TODO(ovphysx): source structured layouts from a wheel dtype query if one is added.
_ATTR_DTYPE: dict[str, Any] = {
"articulation_root_pose": wp.transformf,
"articulation_link_pose": wp.transformf,
"articulation_body_com_pose": wp.transformf,
"rigid_body_pose": wp.transformf,
"rigid_body_com_pose": wp.transformf,
"articulation_root_velocity": wp.spatial_vectorf,
"articulation_link_velocity": wp.spatial_vectorf,
"articulation_link_acceleration": wp.spatial_vectorf,
"articulation_link_incoming_joint_force": wp.spatial_vectorf,
"rigid_body_velocity": wp.spatial_vectorf,
"rigid_body_acceleration": wp.spatial_vectorf,
}
class _BindingLike(Protocol):
"""Structural type of an ovphysx ``TensorBinding`` as used by this view.
``read`` fills the passed array in place; ``write`` consumes ``indices``/``mask``
for partial writes. ``shape`` is the binding's flat tensor shape; ``count`` is the
number of matched prims.
"""
shape: tuple[int, ...]
dtype: Any
count: int
prim_paths: list[str]
dof_names: list[str]
body_names: list[str]
joint_names: list[str]
dof_count: int
body_count: int
joint_count: int
is_fixed_base: bool
fixed_tendon_count: int
spatial_tendon_count: int
def read(self, tensor: wp.array) -> None: ...
def write(self, tensor: wp.array, indices: wp.array | None = None, mask: wp.array | None = None) -> None: ...
def destroy(self) -> None: ...
class _PhysXLike(Protocol):
"""Structural type of the ovphysx ``PhysX`` instance this view depends on."""
def create_tensor_binding(self, *, tensor_type: Any, pattern: str = ..., prim_paths: list[str] = ...) -> Any: ...
# -----------------------------------------------------------------------------
# Pure helpers (no native simulation required; testable against ``ovphysx.types``)
# -----------------------------------------------------------------------------
def attribute_vocabulary() -> list[str]:
"""Return every valid attribute name (sorted lowercased ``TensorType`` members)."""
return sorted(t.name.lower() for t in TensorType if t.name != "INVALID")
def resolve_tensor_type(name: str) -> Any:
"""Resolve a lowercased attribute name to its :class:`TensorType` member.
Args:
name: Lowercased enum name, e.g. ``"articulation_dof_stiffness"``.
Returns:
The matching :class:`TensorType` member.
Raises:
OvPhysxView.UnknownAttribute: If the name is not an addressable ``TensorType``.
"""
try:
tt = TensorType[name.upper()]
except KeyError:
raise OvPhysxView.UnknownAttribute(
f"Unknown attribute {name!r}. Valid names are the lowercased TensorType members, "
f"e.g. {attribute_vocabulary()[:4]} ... ({len(attribute_vocabulary())} total)."
) from None
if tt.name == "INVALID":
raise OvPhysxView.UnknownAttribute(f"{name!r} is not an addressable attribute.")
return tt
def tensor_type_name(tensor_type: Any) -> str:
"""Return the canonical lowercased name of a :class:`TensorType` member."""
return tensor_type.name.lower()
def is_read_only(name: str) -> bool:
"""Return whether an attribute name is read-only (cannot be written)."""
return name.lower() in _READ_ONLY_NAMES
def is_cpu_only(name: str) -> bool:
"""Return whether an attribute is CPU-resident even on a GPU simulation."""
return name.lower() in _CPU_ONLY_NAMES
_DLPACK_TO_WARP_SCALAR: dict[tuple[int, int, int], Any] = {
(2, 32, 1): wp.float32,
(0, 32, 1): wp.int32,
(1, 8, 1): wp.uint8,
}
def _binding_scalar_dtype(binding: _BindingLike) -> Any:
dtype = getattr(binding, "dtype", None)
if dtype is None:
raise OvPhysxView.DtypeMismatch(
"OVPhysX binding does not expose DLPack dtype metadata; install the OVPhysX 0.5 dependency."
)
code = getattr(dtype.code, "value", dtype.code)
key = (int(code), int(dtype.bits), int(dtype.lanes))
try:
return _DLPACK_TO_WARP_SCALAR[key]
except KeyError:
raise OvPhysxView.DtypeMismatch(
f"Unsupported OVPhysX DLPack dtype code={key[0]}, bits={key[1]}, lanes={key[2]}."
) from None
# -----------------------------------------------------------------------------
# The view
# -----------------------------------------------------------------------------
[docs]
class OvPhysxView:
"""A string-keyed, generic view over OVPhysX ``TensorBinding`` handles for one prim set.
Args:
physx: The OVPhysX ``PhysX`` instance exposing ``create_tensor_binding``.
pattern: An fnmatch glob selecting the prims this view addresses. Mutually
exclusive with ``prim_paths``.
device: Simulation device (e.g. ``"cuda:0"`` or ``"cpu"``). State bindings are
read/written on this device; CPU-only property bindings always use ``"cpu"``.
prim_paths: An explicit list of fnmatch globs for the fused multi-prim binding
form (``create_tensor_binding(prim_paths=[...])``). Mutually exclusive with
``pattern``.
key_aliases: Optional mapping ``requested_type -> created_type`` so a binding can
be stored under a different :class:`TensorType` key than the one created
(e.g. a ``RigidObjectCollection`` stores ``rigid_body_pose`` under ``link_pose``).
This is an **internal IsaacLab adapter** for the fused-collection binding path, not
a general public API: the requested key and the created binding type deliberately
differ, so a caller reasoning from the visible key can get different runtime
semantics. A public form would instead carry descriptor metadata (requested key,
source tensor type, shape, native device, access mode); that is deferred to
wheel-exposed descriptor metadata. Prefer not to rely on it outside the
collection adapter.
tensor_types: Explicit set of :class:`TensorType` members to instantiate eagerly.
Used only when ``eager`` is set; defaults to every applicable type.
eager: If ``True``, create bindings up front and raise if none could be created.
Defaults to ``False`` (lazy: bindings are created on first access).
"""
# Keep views reachable until manager shutdown so cached TensorBinding
# DLPack capsules can be destroyed before the OVPhysX runtime is released.
_live_views: ClassVar[set[OvPhysxView]] = set()
[docs]
class OvPhysxViewError(RuntimeError):
"""Base class for all errors raised by :class:`OvPhysxView`."""
[docs]
class UnknownAttribute(OvPhysxViewError):
"""The attribute name does not resolve to an addressable ``TensorType``."""
[docs]
class ReadOnlyAttribute(OvPhysxViewError):
"""A write was attempted on a read-only attribute."""
[docs]
class AttributeUnavailable(OvPhysxViewError):
"""No binding could be created for the attribute on this view's prims."""
[docs]
class ShapeMismatch(OvPhysxViewError):
"""A supplied buffer does not match the binding's element count."""
[docs]
class DtypeMismatch(OvPhysxViewError):
"""A supplied buffer does not use the binding-reported DLPack scalar dtype."""
[docs]
class DeviceMismatch(OvPhysxViewError):
"""A supplied buffer is on a different device than the binding requires."""
[docs]
def __init__(
self,
physx: _PhysXLike,
pattern: str | None = None,
device: str = "cpu",
*,
prim_paths: list[str] | None = None,
key_aliases: dict[Any, Any] | None = None,
tensor_types: list[Any] | None = None,
eager: bool = False,
) -> None:
if (pattern is None) == (prim_paths is None):
raise ValueError("Provide exactly one of 'pattern' or 'prim_paths'.")
if pattern is not None and not pattern:
raise ValueError("'pattern' must be a non-empty glob string.")
if prim_paths is not None and not prim_paths:
raise ValueError("'prim_paths' must contain at least one glob.")
if tensor_types is not None and not eager:
raise ValueError("'tensor_types' is only honored with eager=True; pass eager=True or omit it.")
self._physx = physx
self._pattern = pattern
self._prim_paths = prim_paths
# Canonicalize the device so a "cuda" alias compares equal to a buffer's "cuda:0"
# (warp canonicalizes buffer devices). Fall back to the raw string when the device
# cannot be resolved here (e.g. constructing a cuda view on a CPU-only CI box) -- the
# string is only used for comparison, so construction must not fail on it.
try:
self._device = str(wp.get_device(device))
except Exception: # noqa: BLE001 -- unresolvable device: keep the raw string for comparison
self._device = device
# Normalize key_aliases to TensorType members (accepts str names too) so string keys are
# honored rather than silently dropped, and reject aliases that cross the CPU/GPU residency
# or read-only boundary -- the device and read-only guards key on the requested type.
self._key_aliases: dict[Any, Any] = {}
for requested_type, created_type in (key_aliases or {}).items():
req_tt, made_tt = self._resolve(requested_type), self._resolve(created_type)
if (req_tt in _CPU_ONLY_TYPES) != (made_tt in _CPU_ONLY_TYPES):
raise ValueError(
f"key_alias {tensor_type_name(req_tt)!r} -> {tensor_type_name(made_tt)!r} crosses the "
"CPU/GPU residency boundary; the device policy would apply to the wrong type."
)
if is_read_only(tensor_type_name(req_tt)) != is_read_only(tensor_type_name(made_tt)):
raise ValueError(
f"key_alias {tensor_type_name(req_tt)!r} -> {tensor_type_name(made_tt)!r} mixes a read-only "
"and a writable type."
)
self._key_aliases[req_tt] = made_tt
self._bindings: dict[Any, Any] = {}
# Cache of binding-scalar reinterpret views for read_into / get_attribute, keyed by the
# destination and binding identities. Reusing the same reinterpret object per binding
# keeps the wheel's object-identity read cache (the TensorBinding.read fast path) warm.
self._read_views: dict[tuple[int, int], wp.array] = {}
self._closed = False
if eager:
try:
explicit = tensor_types is not None
requested = tensor_types if explicit else [t for t in TensorType if t.name != "INVALID"]
for tt in requested:
try:
self._binding(self._resolve(tt))
except OvPhysxView.AttributeUnavailable:
if explicit:
raise # caller named this exact type; surface the failure rather than drop it
logger.debug("eager binding skipped for %s", tt) # default sweep: skip inapplicable types
if not self._bindings:
raise OvPhysxView.AttributeUnavailable(
f"Could not create any bindings for {self._target_repr()}; "
"the pattern/prim_paths likely match no prims."
)
except Exception:
self.close()
raise
[docs]
def close(self) -> None:
"""Destroy cached bindings before the OVPhysX runtime is released."""
if self._closed:
return
self._closed = True
for binding in self._bindings.values():
try:
binding.destroy()
except Exception:
logger.warning("Failed to destroy OVPhysX binding during shutdown.", exc_info=True)
self._bindings.clear()
self._read_views.clear()
self._physx = None
self._live_views.discard(self)
@classmethod
def _close_all_for(cls, physx: _PhysXLike) -> None:
"""Close every live view backed by ``physx``."""
for view in tuple(cls._live_views):
if view._physx is physx:
view.close()
# -- core: string-keyed get / set / read-into ------------------------------
[docs]
def get_attribute(self, name: str | Any, *, out: wp.array | None = None) -> wp.array:
"""Read the full attribute tensor.
Reads are full-array (the wheel exposes no selective read); index into the
returned tensor for a subset.
Args:
name: Lowercased ``TensorType`` name or the member itself.
out: Optional destination buffer to fill (must be on the binding's native
device and match its element count). If omitted, a freshly allocated
:class:`warp.array` on the native device is returned.
Returns:
A :class:`warp.array` holding the attribute values, on the attribute's native
device -- ``cpu`` for CPU-only property types even on a GPU sim (see
:func:`is_cpu_only`). When ``out`` is omitted this is a fresh, caller-owned array;
its dtype is the attribute's structured Warp dtype when it has one (e.g.
``wp.transformf`` for poses, ``wp.spatial_vectorf`` for velocities). Otherwise,
a flat array matching the binding shape is allocated with the Warp scalar dtype
resolved from the binding's DLPack metadata (see :data:`_ATTR_DTYPE`).
"""
tt = self._resolve(name)
binding = self._binding(tt)
device = self._native_device(tt)
if out is not None:
self._check_device(out, device, tensor_type_name(tt), "destination")
binding.read(self._read_view(out, binding))
return out
alloc_shape, dtype = self._attribute_dtype(tt, binding)
buf = wp.zeros(alloc_shape, dtype=dtype, device=device)
# ``buf`` is freshly allocated here, so it is never a persistent destination: route it
# through ``_as_binding_view`` directly rather than ``_read_view``. Caching by ``id(buf)``
# could never hit on a later call and would leak one entry (and keep ``buf`` alive) per
# call in a step loop -- the read cache only pays off for a reused ``out``/``dst`` buffer.
binding.read(self._as_binding_view(buf, binding, "destination"))
return buf
[docs]
def read_into(self, name: str | Any, dst: wp.array) -> None:
"""Fill ``dst`` in place from the attribute binding (zero-copy).
``dst`` may be a structured-dtype buffer (e.g. ``wp.transformf``); it is read
through a binding-scalar reinterpret view that matches the binding's flat shape, so
the structured GPU/CPU buffer is filled directly with no extra copy. This is the
path the asset data containers use. The reinterpret view for a given ``dst`` is
built once and reused across calls (see :meth:`_read_view`) so the wheel's
object-identity read cache stays warm -- callers can pass the structured buffer
directly each step without maintaining their own reinterpret cache.
Args:
name: Lowercased ``TensorType`` name or the member itself.
dst: Caller-owned buffer on the binding's native device whose element count
matches the binding.
Raises:
OvPhysxView.DeviceMismatch: If ``dst`` is not on the binding's native device.
OvPhysxView.DtypeMismatch: If ``dst``'s scalar element type does not match the
binding-reported DLPack scalar dtype.
OvPhysxView.ShapeMismatch: If ``dst`` is non-contiguous or its element count does not match.
"""
tt = self._resolve(name)
binding = self._binding(tt)
self._check_device(dst, self._native_device(tt), tensor_type_name(tt), "destination")
binding.read(self._read_view(dst, binding))
[docs]
def set_attribute(
self,
name: str | Any,
values: wp.array,
*,
indices: wp.array | None = None,
mask: wp.array | None = None,
) -> None:
"""Write a full attribute tensor; ``indices``/``mask`` select which rows apply.
``values`` may be a structured-dtype buffer read through a binding-reported scalar
dtype reinterpret view. If both ``indices`` and ``mask`` are given, ``mask`` wins and
the wheel emits a ``UserWarning`` -- this view forwards both verbatim to
``TensorBinding.write`` and does not implement the precedence itself.
Args:
name: Lowercased ``TensorType`` name or the member itself.
values: Source buffer on the binding's native device, matching its element count.
indices: Optional integer row indices to write.
mask: Optional boolean row mask to write.
Raises:
OvPhysxView.ReadOnlyAttribute: If the attribute is read-only.
OvPhysxView.DeviceMismatch: If ``values`` is not on the binding's native device.
OvPhysxView.DtypeMismatch: If ``values``' scalar element type does not match the
binding-reported DLPack scalar dtype.
OvPhysxView.ShapeMismatch: If ``values`` is non-contiguous or its element count does not match.
"""
tt = self._resolve(name)
attr = tensor_type_name(tt)
if attr in _READ_ONLY_NAMES:
raise OvPhysxView.ReadOnlyAttribute(f"Attribute {attr!r} is read-only and cannot be written.")
binding = self._binding(tt)
device = self._native_device(tt)
src = self._as_wp(values, device)
self._check_device(src, device, attr, "source")
binding.write(self._as_binding_view(src, binding, "source"), indices=indices, mask=mask)
# -- raw binding access (for asset/data-container adoption) ----------------
[docs]
def binding_for(self, name: str | Any) -> _BindingLike:
"""Return the underlying ``TensorBinding`` for an attribute, creating it on first use.
This is a raw escape hatch for asset-internal binding management: the returned
binding's ``read``/``write`` **bypass** the view's device, dtype-reinterpret, shape,
and read-only guards. Prefer :meth:`get_attribute` / :meth:`read_into` /
:meth:`set_attribute` unless you are deliberately managing bindings directly.
"""
return self._binding(self._resolve(name))
[docs]
def try_binding_for(self, name: str | Any) -> _BindingLike | None:
"""Like :meth:`binding_for`, but return ``None`` instead of raising when the attribute
is valid yet **not available for this view's prims** (e.g. tendon types on a
tendon-less articulation, or a not-yet-created optional binding).
An invalid *name* still raises :class:`UnknownAttribute` -- that is a programming
error, not an availability question. Use this for the asset's ``binding or None``
pattern over optional bindings.
"""
try:
return self._binding(self._resolve(name))
except OvPhysxView.AttributeUnavailable:
return None
# -- discoverability -------------------------------------------------------
@property
def attribute_names(self) -> list[str]:
"""Every valid attribute name (the full ``TensorType`` vocabulary).
This is name *validity*, not availability for this view's prims -- a rigid-body view
still lists ``"articulation_*"`` names. Use :attr:`available_attributes` for what is
actually instantiated.
.. note::
A listed name is **not** a promise of binding availability or supported DLPack dtype
metadata. Those are validated when the view creates and accesses the binding.
"""
return attribute_vocabulary()
@property
def available_attributes(self) -> list[str]:
"""Names with a live binding instantiated for this view's prims."""
return sorted(tensor_type_name(tt) for tt in self._bindings)
[docs]
def has_attribute(self, name: str | Any) -> bool:
"""Return whether ``name`` is a valid attribute name (resolves to a ``TensorType``).
This checks name *validity* for any view, not availability for these prims: it can
return ``True`` for a name whose binding does not apply to this view's prims (in which
case :meth:`get_attribute` raises :class:`AttributeUnavailable`). It likewise does not
promise binding availability or supported DLPack dtype metadata.
"""
try:
self._resolve(name)
except OvPhysxView.UnknownAttribute:
return False
return True
def __contains__(self, name: str | Any) -> bool:
return self.has_attribute(name)
# -- metadata passthrough (from a sample binding) --------------------------
@property
def count(self) -> int:
"""Number of prims matched by this view."""
return self._sample().count
@property
def prim_paths(self) -> list[str]:
"""USD paths of the prims matched by this view."""
return list(self._sample().prim_paths)
@property
def dof_names(self) -> list[str]:
"""Per-articulation DOF names (articulation views only)."""
return list(self._sample().dof_names)
@property
def body_names(self) -> list[str]:
"""Per-articulation body (link) names (articulation views only)."""
return list(self._sample().body_names)
@property
def joint_names(self) -> list[str]:
"""Per-articulation joint names (articulation views only)."""
return list(self._sample().joint_names)
@property
def dof_count(self) -> int:
"""Number of DOFs per articulation (articulation views only)."""
return self._sample().dof_count
@property
def body_count(self) -> int:
"""Number of bodies (links) per articulation (articulation views only)."""
return self._sample().body_count
@property
def joint_count(self) -> int:
"""Number of joints per articulation (articulation views only)."""
return self._sample().joint_count
@property
def is_fixed_base(self) -> bool:
"""Whether the articulation has a fixed base (articulation views only)."""
return self._sample().is_fixed_base
@property
def fixed_tendon_count(self) -> int:
"""Number of fixed tendons per articulation (articulation views only)."""
return self._sample().fixed_tendon_count
@property
def spatial_tendon_count(self) -> int:
"""Number of spatial tendons per articulation (articulation views only)."""
return self._sample().spatial_tendon_count
# -- internals -------------------------------------------------------------
def _resolve(self, name: str | Any) -> Any:
"""Resolve a string name or a ``TensorType`` member to a ``TensorType``."""
if isinstance(name, str):
return resolve_tensor_type(name)
if isinstance(name, TensorType):
if name.name == "INVALID": # mirror the string path's INVALID rejection
raise OvPhysxView.UnknownAttribute(f"{name!r} is not an addressable attribute.")
return name
raise OvPhysxView.UnknownAttribute(
f"Attribute key must be a str name or a TensorType member, got {type(name).__name__}."
)
def _binding(self, tensor_type: Any) -> Any:
"""Return the cached ``TensorBinding`` for ``tensor_type``, creating it on first use."""
if self._closed:
raise OvPhysxView.OvPhysxViewError(f"Cannot access closed {self!r}.")
binding = self._bindings.get(tensor_type)
if binding is not None:
return binding
create_type = self._key_aliases.get(tensor_type, tensor_type)
kwargs: dict[str, Any] = {"tensor_type": create_type}
if self._prim_paths is not None:
kwargs["prim_paths"] = self._prim_paths
else:
kwargs["pattern"] = self._pattern
try:
binding = self._physx.create_tensor_binding(**kwargs)
except Exception as exc: # noqa: BLE001 -- wheel raises bare exceptions; surface the cause below
# The wheel raises both for "type not applicable to these prims" and for genuine
# failures (init/ABI/OOM); we can't tell them apart without a wheel-side exception
# type, so the underlying error is surfaced in the message (and chained) rather than
# hidden behind a generic "not available". TODO(ovphysx): a typed no-match error.
raise OvPhysxView.AttributeUnavailable(
f"Could not create the {tensor_type_name(tensor_type)!r} binding for "
f"{self._target_repr()}: create_tensor_binding raised {type(exc).__name__}: {exc}"
) from exc
# The wheel returns a 0-count binding when nothing matches. Access ``count`` directly so a
# malformed binding (missing ``count``) surfaces as an error rather than a phantom no-match.
if binding is None or binding.count == 0:
if binding is not None:
try:
binding.destroy()
except Exception:
logger.warning("Failed to destroy empty OVPhysX binding.", exc_info=True)
raise OvPhysxView.AttributeUnavailable(
f"Attribute {tensor_type_name(tensor_type)!r} is not available for {self._target_repr()} "
"(no matching prims)."
)
self._bindings[tensor_type] = binding
self._live_views.add(self)
return binding
def _sample(self) -> Any:
"""Return any instantiated binding to read view-level metadata from."""
if self._closed:
raise OvPhysxView.OvPhysxViewError(f"Cannot access closed {self!r}.")
if not self._bindings:
raise OvPhysxView.AttributeUnavailable(
"No bindings instantiated yet; access an attribute (or construct with eager=True) "
"before reading view metadata."
)
return next(iter(self._bindings.values()))
def _native_device(self, tensor_type: Any) -> str:
"""Device a buffer for ``tensor_type`` must live on (CPU for CPU-only types)."""
return "cpu" if tensor_type in _CPU_ONLY_TYPES else self._device
def _check_device(self, arr: wp.array, device: str, attr: str, role: str) -> None:
"""Raise if ``arr`` is not on the binding's native device (no implicit conversion)."""
if str(arr.device) != device:
raise OvPhysxView.DeviceMismatch(
f"{role} for {attr!r} must be on device {device!r}, got {str(arr.device)!r}. "
"OvPhysxView does not stage between CPU and GPU; move the buffer yourself."
)
def _as_binding_view(self, arr: wp.array, binding: Any, role: str) -> wp.array:
"""Return a binding-scalar view of ``arr`` matching the binding's flat shape.
``arr`` must have the scalar dtype reported by the binding's DLPack metadata (or a
composite built on it). The view **reinterprets bits, not values**, so a mismatched
scalar type would corrupt the data and is rejected. Given a matching scalar, it
validates the byte count and returns ``arr`` directly when it already matches the
binding shape; otherwise it returns a zero-copy reinterpret view.
"""
binding_scalar = _binding_scalar_dtype(binding)
scalar = getattr(arr.dtype, "_wp_scalar_type_", arr.dtype)
if scalar is not binding_scalar:
raise OvPhysxView.DtypeMismatch(
f"{role} must have {binding_scalar.__name__} scalar elements (got dtype "
f"{getattr(arr.dtype, '__name__', arr.dtype)}); the view reinterprets bits, "
"not values, so a mismatched scalar type would silently corrupt the buffer."
)
if not arr.is_contiguous:
raise OvPhysxView.ShapeMismatch(
f"{role} must be a contiguous array; the view reinterprets the buffer's raw memory, "
"so a strided/sliced view would read or write the wrong elements."
)
expected_bytes = math.prod(tuple(binding.shape)) * wp.types.type_size_in_bytes(binding_scalar)
actual_bytes = arr.size * wp.types.type_size_in_bytes(arr.dtype)
if actual_bytes != expected_bytes:
raise OvPhysxView.ShapeMismatch(
f"Shape mismatch for {role}: {actual_bytes} bytes, "
f"binding expects {expected_bytes} (shape {tuple(binding.shape)})."
)
if arr.dtype == binding_scalar and tuple(arr.shape) == tuple(binding.shape):
return arr
return wp.array(
ptr=arr.ptr, shape=tuple(binding.shape), dtype=binding_scalar, device=str(arr.device), copy=False
)
def _read_view(self, dst: wp.array, binding: Any) -> wp.array:
"""Return the binding-scalar view of ``dst`` to hand to ``binding.read``, reused across calls.
The wheel's ``TensorBinding.read`` has an object-identity read cache: it skips DLPack
acquisition and the attribute-chain lookup when handed the *same* tensor object as the
previous read. To keep that cache warm, the binding-scalar reinterpret of a structured
``dst`` is built once and reused for that destination buffer; a pointer-staleness guard
rebuilds it if the buffer's backing storage moved. A ``dst`` that is already flat
binding scalar is its own stable identity, so it is returned directly (and not cached).
"""
key = (id(dst), id(binding))
cached = self._read_views.get(key)
if cached is not None and cached.ptr == dst.ptr:
return cached
view = self._as_binding_view(dst, binding, "destination")
if view is not dst: # structured dst -> cache the reinterpret; a flat scalar dst caches nothing
self._read_views[key] = view
return view
def _attribute_dtype(self, tensor_type: Any, binding: Any) -> tuple[tuple[int, ...], Any]:
"""Return ``(alloc_shape, dtype)`` for :meth:`get_attribute`.
Maps an attribute to its structured Warp dtype (see :data:`_ATTR_DTYPE`) when the
binding reports the dtype's scalar type and its trailing dimension matches that scalar
count, dropping the trailing dimension from the allocation shape (e.g. ``[N, 7] -> ([N], wp.transformf)``).
Falls back to the flat binding-scalar shape for unmapped attributes or a mismatched layout.
"""
binding_scalar = _binding_scalar_dtype(binding)
dtype = _ATTR_DTYPE.get(tensor_type_name(tensor_type))
shape = tuple(binding.shape)
if (
dtype is not None
and getattr(dtype, "_wp_scalar_type_", dtype) is binding_scalar
and shape
and shape[-1] == wp.types.type_size_in_bytes(dtype) // wp.types.type_size_in_bytes(binding_scalar)
):
return shape[:-1], dtype
return shape, binding_scalar
def _as_wp(self, values: Any, device: str) -> wp.array:
"""Coerce ``values`` to a :class:`warp.array`.
A :class:`warp.array` is used as-is, keeping its own device (validated by the caller;
a mismatch raises and is never staged). Device-less host data (numpy arrays, lists)
carries no device, so it is materialized directly on ``device``.
This view is Warp-native and does **not** special-case framework tensors: bridge a
Torch tensor on the caller side with ``view.set_attribute(name, wp.from_torch(t))``.
This keeps the device policy explicit and avoids an optional Torch dependency and the
fragile detection a built-in conversion would require.
"""
if isinstance(values, wp.array):
return values
return wp.array(values, device=device)
def _target_repr(self) -> str:
return f"prim_paths={self._prim_paths!r}" if self._prim_paths is not None else f"pattern={self._pattern!r}"
def __repr__(self) -> str:
return f"OvPhysxView({self._target_repr()}, device={self._device!r}, instantiated={len(self._bindings)})"
# Backward-compatible module-level alias for the error base class.
OvPhysxViewError = OvPhysxView.OvPhysxViewError