Migrate OVPhysX Articulation onto OvPhysxView (view series, part 2)#6225
Migrate OVPhysX Articulation onto OvPhysxView (view series, part 2)#6225AntoineRichard wants to merge 16 commits into
Conversation
c812358 to
ff3f2f7
Compare
OVPhysX exposes physics attributes as a loose dict of TensorType -> TensorBinding with no view object, unlike Newton's selection.ArticulationView and PhysX's typed tensor views. OvPhysxView wraps the bindings for one prim pattern behind a string-keyed get_attribute / set_attribute surface, addressing attributes by the lowercased TensorType enum name (e.g. "articulation_dof_stiffness"). It needs no Model/State/Control source object because the TensorType already implies where the data lives. Prototype per docs/superpowers/specs/2026-06-17-ovphysx-view-design.md. Adds unit tests covering name<->enum resolution, the read-only guard, discoverability, and get/set dispatch against a fake binding (no native simulation required).
Reworks the view from a convenience wrapper into a layer that can back the OVPhysX asset/data classes, per the PR review of isaac-sim#6224: - read_into(name, dst): zero-copy fill of a caller-owned, possibly structured-dtype buffer (e.g. wp.transformf) via a float32 reinterpret view -- the mechanism the data containers use today. - set_attribute: accepts structured-dtype sources via the same reinterpret; non-float32-width buffers are rejected rather than silently bit-cast. - prim_paths + key_aliases: support the fused multi-prim binding form (create_tensor_binding(prim_paths=[...])) and storing a binding under a different TensorType key, as RigidObjectCollection needs. - binding_for(): raw TensorBinding accessor for adoption. - _CPU_ONLY_NAMES is now derived from tensor_types._CPU_ONLY_TYPES (no drift). - Added joint/tendon/is_fixed_base metadata passthrough; eager construction raises if it creates zero bindings; get_attribute allocates a fresh buffer (no aliasing); nested error hierarchy; PhysX/binding Protocols. Device policy: no implicit CPU<->GPU conversion. CPU-resident property types are read/written on CPU; a buffer on the wrong device raises DeviceMismatch instead of being staged. Device-less host data (numpy/list) is materialized on the binding's native device.
The OvPhysxView addition is a significant new public surface for the OVPhysX backend, so promote the changelog fragment from a minor to a major bump. Reword the entry to describe the binding-manager surface (read_into, the no-device-conversion policy) and drop the internal design-note path from the user-facing changelog.
From the second PR review of isaac-sim#6224: - Critical: _as_binding_view now requires a float32 scalar dtype before the zero-copy reinterpret. A same-byte-width wrong dtype (int32) previously passed the count-only guard and was bit-reinterpreted into garbage on the write path; sub-4-byte dtypes (float16) produced a misleading "0 elements" error. Both are now rejected with a clear message. Regression tests added (verified failing without the guard). - _resolve enforces the str | TensorType union and raises UnknownAttribute on anything else, instead of letting a bogus key reach the wheel. - _binding accesses binding.count directly (a malformed binding surfaces instead of being masked as a phantom no-match) and surfaces the underlying create_tensor_binding exception in the AttributeUnavailable message. - Added docstrings to the six metadata properties; dropped the unused runtime_checkable decorator. - Tests: same-byte/sub-4-byte dtype rejection, get_attribute(out=) wrong device, both indices+mask forwarded, read/write through a prim_paths+key_aliases view, non-str/non-TensorType key, and a read-only-names-are-valid-vocabulary check.
From the API-hardening review of isaac-sim#6224. Validate at the boundary and fail loud instead of silently corrupting, mis-binding, or no-op'ing: - Reject non-contiguous buffers in _as_binding_view (a strided/sliced source would be reinterpreted as contiguous and read/write the wrong memory). - Canonicalize the device (wp.get_device) so a "cuda" view accepts a "cuda:0" buffer instead of raising a spurious, unsatisfiable DeviceMismatch; falls back to the raw string when the device can't be resolved locally. - Reject TensorType.INVALID via the member path too (string path already did). - Normalize key_aliases to TensorType members so string keys are honored rather than silently dropped, and reject aliases that cross the CPU/GPU residency or read-only boundary (the device/read-only guards key on the requested type). - Reject empty pattern/prim_paths and tensor_types-without-eager at construction. - Eager construction with an explicit tensor_types list now surfaces a failing type instead of swallowing it at debug level (default sweep still skips inapplicable types). - Document binding_for as an unguarded escape hatch, get_attribute's native-device return, and the has_attribute name-validity-vs-availability split. Adds regression tests for each (contiguity and INVALID verified failing without the guard).
Surfaced by dogfooding the view in the articulation migration: the assets branch on a "binding or None" pattern for optional/absent bindings (tendon types on a tendon-less articulation, not-yet-created bindings), which the raising binding_for can't express. try_binding_for returns None when the attribute is valid but not available for the view's prims, while still raising UnknownAttribute for an invalid name (a programming error, not an availability question).
…xView read_into now reuses the float32 reinterpret of a destination buffer across calls (keyed by buffer id, with a pointer-staleness guard), so the wheel's object-identity read cache stays warm even when callers hand a structured buffer each step -- they no longer need to maintain their own reinterpret cache. get_attribute returns a typed array for attributes with a known structured layout (transformf for poses, spatial_vectorf for velocities, via a hand-maintained _ATTR_DTYPE map) and flat float32 otherwise. This lets the asset data containers drop their bespoke _get_read_view caching and read structured buffers straight through the view.
ff3f2f7 to
699cf92
Compare
OvPhysxView (and OvPhysxFrameView) live in isaaclab_ovphysx.sim.views, which had no API-docs page. Add the automodule stub and wire it into the isaaclab_ovphysx autosummary so the new binding-manager view shows up in the rendered API reference alongside assets / cloner / physics.
Three fixes from the Part 1 review: - get_attribute (no out): route the freshly allocated buffer through _as_binding_view directly instead of the id()-keyed read cache. A fresh buffer can never hit the cache and would leak one entry (keeping the buffer alive) per call in a step loop; the cache only pays off for a reused out/dst buffer. Add a regression test asserting the no-out path leaves _read_views empty. - Raise a dedicated DtypeMismatch instead of ShapeMismatch when a buffer's scalar element type is not float32, so a dtype error no longer reads as a dimensions error. Update the affected tests and the Raises docstrings. - Make the view Warp-native: drop the fragile __module__ string-match that auto-converted Torch tensors on writes. Callers bridge a Torch tensor with wp.from_torch(t), keeping the device policy explicit and avoiding an optional Torch dependency.
bf1fc85 to
cff7ac3
Compare
Documentation/comment clarifications from the isaac-sim#6224 review (no behavior change): - Narrow the documented contract to float32-only: attribute_names/has_attribute and the module docstring now state that a listed name is name-validity, not a dtype-support promise; non-float dtype handling awaits wheel dtype metadata. - Mark _READ_ONLY_NAMES explicitly temporary; name the three access modes (read/write, read-only, write-only) and the wheel access_mode enum that should replace the table. - Document key_aliases as an internal collection adapter, not general public API, pending descriptor metadata. - Make the view test scope explicit: mock API mechanics here; live CPU-only-on-GPU / read-only+write-only / structured read_into coverage lives in the asset-integration tests.
Migrate the OVPhysX Articulation onto the OvPhysxView binding manager (view migration series, part 2; rebased onto develop). _initialize_impl builds one OvPhysxView and populates it via try_binding_for; the data container is built from the view; both _get_binding helpers delegate to it. All reads route through read_into (cached reinterpret, structured buffers handled off the binding shape -- the transform/spatial/scalar read helpers collapse to one) and all writes through set_attribute (including CPU-only properties, pre-staged to pinned CPU). root_view now returns the OvPhysxView (breaking). Tests route through the new API. Verified on both devices (cpu and cuda:0: 99 passed, 4 xfailed each).
…View The migration changed Articulation._process_tendons to read tendon counts off self._root_view (the OvPhysxView) instead of self._bindings, but the helpers unit test still mock-injected _bindings -- so the test broke (caught by the isaaclab_ov CI job, which runs the full suite). Wrap the mock binding set in a real OvPhysxView over a fake PhysX and inject _root_view instead. test_articulation_helpers.py: 2 passed.
The articulation-helpers tendon test wrapped the mock bindings in a real OvPhysxView over a fake PhysX to satisfy the migrated Articulation, which defeats the purpose of the mock binding layer. Add a MockOvPhysxView that mirrors the consumed OvPhysxView surface (binding_for/try_binding_for, get_attribute/read_into/set_attribute, the discoverability helpers, and the metadata passthrough) over the mock bindings, exposed via MockOvPhysxBindingSet.view. Inject that as the articulation's _root_view so the test stays within the mock framework.
cff7ac3 to
6268f9a
Compare
…ndings (view series, part 1) (#6224) > **📦 Part 1 of a 5-part series** adding an OvPhysX binding view and migrating the assets onto it: > 1. **This PR** — add `OvPhysxView` (the API) + tests. > 2. Migrate the OvPhysX **Articulation** to `root_view`. > 3. Migrate the **RigidObject**. > 4. Migrate the **RigidObjectCollection** (uses the fused `prim_paths`+`key_aliases` path). > 5. Migrate the **sensors** (pose tracking; contact forces use a separate `ContactBinding` API and may not fully adopt the view). > > Parts 2–5 each depend only on this PR and can be reviewed independently. --- # Description Adds **`OvPhysxView`**, a string-keyed binding-management layer over the OVPhysX tensor bindings, in `isaaclab_ovphysx`. **Why.** OVPhysX is the odd backend out: it exposes physics attributes as a loose `dict[TensorType, TensorBinding]` with **no view object**, whereas Newton has `selection.ArticulationView` and PhysX has typed tensor views. Today callers must hold the right `TensorType` enum member, manage a destination buffer, and create bindings against a USD glob — and the `create_tensor_binding` calls are scattered across the asset classes behind a private `_get_binding`. `OvPhysxView` gives OVPhysX a single, discoverable surface as pleasant as Newton's selection API, and one owner of binding creation/caching that the asset/data classes can delegate to. It is the narrow, independently-useful slice carved out of the (shelved) cross-backend `get_property`/`set_property` effort; the full rationale and decision log live in `docs/superpowers/specs/2026-06-17-ovphysx-view-design.md`. **What it provides** - String-keyed access by lowercased `TensorType` name (auto-derived from the wheel enum — no hand-maintained table); a `TensorType` member is also accepted. - `get_attribute(name, out=)` — fresh-allocates a **typed** array (`wp.transformf` for poses, `wp.spatial_vectorf` for velocities, flat `float32` otherwise; via a maintained `_ATTR_DTYPE` map), no aliasing; **`read_into(name, dst)`** — zero-copy fill of a caller-owned, possibly structured-dtype buffer (`wp.transformf`, …) via a `float32` reinterpret view that is **cached per destination buffer** so the wheel's object-identity read cache stays warm even when callers hand a structured buffer each step; `set_attribute(name, values, indices=/mask=)` (structured sources reinterpreted); and a raw `binding_for(name)` accessor for adoption. - The fused multi-prim form (`prim_paths=[...]` + `key_aliases`) so `RigidObjectCollection` can use it. - Discoverability (`attribute_names`, `available_attributes`, `has_attribute`), metadata passthrough, and a nested error hierarchy (`UnknownAttribute` / `ReadOnlyAttribute` / `AttributeUnavailable` / `ShapeMismatch` / `DeviceMismatch`). **Device policy:** no implicit CPU↔GPU conversion — CPU-resident property types are read/written on CPU, and a buffer on the wrong device raises `DeviceMismatch` rather than being staged. **Scope / status:** this PR adds the view plus 48 mock-based unit tests (no live sim). The asset adoption that replaces `_get_binding`/`_binding_read`/`_binding_write` lives in the follow-up PRs (Articulation #6225, RigidObject #6226), which dogfood the view against a live sim on CPU and GPU and have fed fixes back here (e.g. `try_binding_for`, the read-view cache, typed `get_attribute`). Fixes # — N/A (internal design tracking; surviving slice of the shelved cross-backend view effort). ## Type of change - New feature (non-breaking change which adds functionality) ## Screenshots N/A — no user-facing visual change. ## 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` - [ ] I have made corresponding changes to the documentation <!-- design note updated separately (docs/superpowers/specs/2026-06-17-ovphysx-view-design.md); no rendered API-doc change in this PR yet --> - [x] My changes generate no new warnings - [x] I have added tests that prove my feature works (`test/sim/test_ovphysx_view.py`, 48 tests) - [x] I have added a changelog fragment under `source/<pkg>/changelog.d/` for every touched package (do **not** edit `CHANGELOG.rst` or bump `extension.toml` — CI handles that) - [x] I have added my name to the `CONTRIBUTORS.md` or my name already exists there
Greptile SummaryThis PR migrates the OVPhysX
Confidence Score: 4/5The production code paths are mechanically correct and well-guarded; the real-backend test suite passes cleanly. The only open items are in the new MockOvPhysxView test double, where a structured-dtype mismatch in read_into and a StopIteration edge case on empty bindings are latent gaps that do not affect current tests but will surface when mock-based tests exercise transform or velocity reads. The core migration is thorough and consistent — every write and read path has been updated, the per-step guards are semantically equivalent to the old binding-existence checks, and the CPU staging path is preserved intact. The three retained binding instance attributes are unused after init but harmless. The mock surface gaps are real but confined to test infrastructure and do not affect simulation correctness. mock_ovphysx_bindings.py — MockOvPhysxView.read_into and _resolve have latent correctness gaps that will surface when mock-based tests exercise transform/velocity reads. Important Files Changed
Sequence Diagram%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
participant Sim as Simulation Step
participant Art as Articulation
participant View as OvPhysxView
participant Bind as TensorBinding
participant Data as ArticulationData
Note over Art,View: _initialize_impl
Art->>View: OvPhysxView(physx, pattern, device)
loop eager_types
Art->>View: try_binding_for(tt)
View->>Bind: create_tensor_binding(pattern, tt)
View-->>Art: binding (cached)
end
Art->>Data: ArticulationData(root_view, device)
Note over Art,Bind: write_data_to_sim (per-step)
Sim->>Art: write_data_to_sim()
Art->>View: set_attribute(DOF_ACTUATION_FORCE, effort_view)
View->>Bind: binding.write(values, indices, mask)
Art->>View: set_attribute(DOF_POSITION_TARGET, pos_view)
View->>Bind: binding.write(values, indices, mask)
Note over Data,Bind: lazy state read (property access)
Sim->>Data: joint_pos (property)
Data->>Data: _read_binding_into_buf(DOF_POSITION, buf)
Data->>View: read_into(DOF_POSITION, buf.data)
View->>Bind: binding.read(float32_view)
Data-->>Sim: cached tensor
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
participant Sim as Simulation Step
participant Art as Articulation
participant View as OvPhysxView
participant Bind as TensorBinding
participant Data as ArticulationData
Note over Art,View: _initialize_impl
Art->>View: OvPhysxView(physx, pattern, device)
loop eager_types
Art->>View: try_binding_for(tt)
View->>Bind: create_tensor_binding(pattern, tt)
View-->>Art: binding (cached)
end
Art->>Data: ArticulationData(root_view, device)
Note over Art,Bind: write_data_to_sim (per-step)
Sim->>Art: write_data_to_sim()
Art->>View: set_attribute(DOF_ACTUATION_FORCE, effort_view)
View->>Bind: binding.write(values, indices, mask)
Art->>View: set_attribute(DOF_POSITION_TARGET, pos_view)
View->>Bind: binding.write(values, indices, mask)
Note over Data,Bind: lazy state read (property access)
Sim->>Data: joint_pos (property)
Data->>Data: _read_binding_into_buf(DOF_POSITION, buf)
Data->>View: read_into(DOF_POSITION, buf.data)
View->>Bind: binding.read(float32_view)
Data-->>Sim: cached tensor
|
Clear self._root_view in _invalidate_initialize_callback so a stale/destroyed binding cached by the view is not held across a simulation stop; _initialize_impl rebuilds a fresh OvPhysxView on the next play. Teardown hygiene matching the joint-wrench sensor.
Description
Part 2 of the OVPhysX view-migration series. It migrates the OVPhysX
Articulationonto the newOvPhysxViewbinding manager introduced in#6224 (Part 1).
The goal of the series is to dogfood
OvPhysxViewon the real asset classesto surface weaknesses before the rigid-object/collection/sensor migrations, and
to consolidate the scattered
create_tensor_binding/binding.read/binding.writecalls behind one managed surface.What changed
Articulation._initialize_implnow builds a singleOvPhysxView(physx, pattern, device)and populates it viatry_binding_for(best-effort: tensor types that do not apply to these prims are skipped, the
asset still raises if no binding could be created). All binding creation,
caching, and the CPU/GPU device policy now live in the view.
ArticulationDatais constructed from the view; its counts come from theview metadata, and both
_get_bindinghelpers (asset + data container)delegate to
OvPhysxView.try_binding_for.root_view.set_attribute(...)instead ofcalling
binding.writeon a binding obtained from the view — the ~70 property/ state / target / wrench / tendon writes, including the per-step actuation
path. This brings the view's read-only, device, dtype, and contiguity guards
to the asset write path, which previously bypassed them.
root_view.read_into(...).ArticulationDatadrops its bespoke
_get_read_viewreinterpret cache; the view now caches thefloat32reinterpret per destination buffer (keeping the wheel's read cachewarm) and derives the structured layout from the binding shape, so the
transform / spatial-vector / scalar read helpers collapse to one
implementation. CPU-only reads keep their pinned-host staging.
Articulation.root_viewnow returns theOvPhysxViewinstead of a rawdict[TensorType, TensorBinding](breaking — see migration note below).(
root_view.get_attribute(...),root_view.try_binding_for(...)).Breaking change / migration
Articulation.root_viewreturns anOvPhysxView, not a dict:root_view[tensor_type]→root_view.try_binding_for(tensor_type)tensor_type in root_view(availability) →root_view.try_binding_for(tensor_type) is not Noneroot_view.get_attribute(tensor_type)Findings surfaced (the point of the exercise)
set_attribute. mass/COM/inertia/DOFlimits/stiffness/… are CPU-resident even on a GPU sim, and
set_attributerefuses to stage CPU↔GPU. This is fine because the asset already pre-stages
those to pinned CPU buffers, so the source is on the binding's native (CPU)
device and the no-stage policy is satisfied with no change. (An initial
assumption that the device policy would block these turned out to be wrong —
verified by the green
cuda:0run.)read_intoallocates a freshfloat32reinterpret view per call, which woulddefeat the wheel binding's internal read cache that the data container caches
by
(tensor_type, ptr); hot-path reads were kept on the cached-view path.set_attribute(a dict lookupnegligible because the pre-built
float32write-view passes through thereinterpret unchanged. A "bound writer" handle on the view would remove even
that lookup — a possible Part 1 follow-up.
store a phantom binding) — a correctness improvement.
Type of change
Checklist
pre-commitchecks with./isaaclab.sh --formatsource/isaaclab_ovphysx/changelog.d/)changelog.dfragment(s)Testing
Run kitless; cpu and cuda must be separate invocations (process-global device lock):