Skip to content

[OVPHYSX] Articulation rewrite (data class + asset class + kernels)#5459

Draft
AntoineRichard wants to merge 77 commits intoisaac-sim:developfrom
AntoineRichard:antoiner/feat/ovphysx_articulation
Draft

[OVPHYSX] Articulation rewrite (data class + asset class + kernels)#5459
AntoineRichard wants to merge 77 commits intoisaac-sim:developfrom
AntoineRichard:antoiner/feat/ovphysx_articulation

Conversation

@AntoineRichard
Copy link
Copy Markdown
Collaborator

Description

Drastic rewrite of OVPhysX Articulation and ArticulationData so they follow the same shape as the post-refactor OVPhysX RigidObject from #5426, with the API surface mirroring Newton Articulation and behavior parity with PhysX Articulation. Single-PR atomic rewrite, clean break (no deprecation aliases for OVPhysX-introduced renames; framework-inherited deprecated shims kept).

The OVPhysX articulation diverged significantly from the rest of the framework conventions. This PR brings it back in line: same docstring template, same section ordering, same naming, same internal patterns, same lifecycle.

Important

Stacked on #5426 ([OVPHYSX] RigidObject + RigidObjectData asset). Review only the 16 articulation-specific commits at the tip of this branch — every commit before that lands via #5426. Once #5426 merges to develop, this PR will rebase cleanly onto develop.

Fixes # (none — internal refactor; no associated issue)

Architectural changes

OVPhysX RigidObject is the design template. It has navigated the hybrid OVPhysX surface — Newton-style mask+index dual API + PhysX-style CPU-only bindings via pinned-host staging à la #5329 + pull-to-refresh binding.read(target):

  • Eager TimestampedBufferWarp allocation in _create_buffers (single source of truth — no _invalidate_caches / _ensure_*_buffers machinery).
  • Pinned-host CPU staging buffers for every CPU-only binding (mass, COM, inertia, all DOF properties).
  • _binding_read / _binding_write / _stage_to_pinned_cpu helpers route CPU-only types through pinned-host memory.
  • Every public property returns a ProxyArray (warp + torch dual view); raw wp.array for one-shot config buffers.
  • Counts and names (num_instances, num_bodies, num_joints, body_names, joint_names, ...) demoted from @property to plain instance attributes.
  • Dual mask+index API on every writer/setter (*_index accepts partial data; *_mask accepts full data with a wp.bool mask).
  • All write_* / set_* parameters are kwarg-only after *,. No positional. No full_data flag anywhere.
  • The deprecated _write_body_state plumbing layer is removed; deprecated state-writer shims (write_root_state_to_sim, etc.) call the public write_*_to_sim_index methods directly, mirroring RigidObject.

Articulation-specific surface mirrors Newton 1-to-1: joint-state writers, joint-property writers (CPU-only), body-property setters (multi-body shape), joint-command target setters, external-wrench setters via WrenchComposer, fixed/spatial tendon setters, deprecated state-writer shims, full actuator pipeline (compute, _apply_actuator_model, _process_actuators_cfg).

Files changed

  • source/isaaclab_ovphysx/isaaclab_ovphysx/assets/articulation/articulation.py — full rewrite (~3863 lines, matches Newton).
  • source/isaaclab_ovphysx/isaaclab_ovphysx/assets/articulation/articulation_data.py — full rewrite (~2504 lines).
  • source/isaaclab_ovphysx/isaaclab_ovphysx/assets/kernels.py — gained 6 articulation kernels migrated from the stop-gap kernels_old.py (now deleted): _compose_root_com_pose, _compute_heading, _copy_first_body, _projected_gravity, _world_vel_to_body_ang, _world_vel_to_body_lin. Plus 2 new joint-property kernels (write_joint_position_limit_to_buffer_index/mask for trailing-dim-2 limits, write_joint_friction_to_buffer_index/mask for the broadcast-coefficient pattern).
  • source/isaaclab_ovphysx/isaaclab_ovphysx/assets/kernels_old.py — deleted.
  • source/isaaclab_ovphysx/test/assets/test_articulation.py — verbatim PhysX test mirror (~210 parametrizations) with PhysX-internal root_view.X assertions adapted to the OVPhysX bindings dict and omni.physx.scripts-dependent tests xfailed; mirrors the precedent from the RigidObject test mirror in [OVPHYSX] RigidObject + RigidObjectData asset #5426.

Type of change

  • Breaking change (existing functionality will not work without user modification — OVPhysX is at 0.2.x, clean break is acceptable per semver-on-0.x; no deprecation aliases for OVPhysX-introduced renames).
  • Code modernization / refactor.

Validation

Three layers, run on GPU and CPU separately (the wheel's process-global device-mode lock makes a single invocation lock to one device):

  1. Real-backend porttest_articulation.py (verbatim PhysX mirror). ./scripts/run_ovphysx.sh -m pytest <path> -k 'cuda:0' and ... -k 'cpu'. Expected end state: each pass shows <X> passed, <Y> xfailed, 0 failed. Every xfail carries a reason pointing at the wheel-gaps spec.
  2. Cross-backend interfacesource/isaaclab/test/assets/test_articulation_iface.py will gain an ovphysx backend, mirroring the rigid-object iface treatment from [OVPHYSX] RigidObject + RigidObjectData asset #5426.
  3. API consistency audit — per-method side-by-side checklist comparing Newton, RigidObject (post-refactor), and the rewritten Articulation; verifies method name, kwarg-only signature, parameter order, return type, docstring template, section-header placement.

Status

Active triage — not yet ready for review.

  • ✅ Implementation complete (all writers, setters, properties, lifecycle, actuator pipeline).
  • ✅ Initial GPU root-cause bug fixed: _read_transform_binding now routes BODY_COM_POSE through _binding_read so the wheel's CPU-only-binding device check is satisfied on a GPU sim.
  • ✅ Verbatim PhysX-internals assertions (root_view.max_dofs == shared_metatype.dof_count, link_paths[0] round-trip) adapted to the OVPhysX bindings dict — they now check binding.shape[1] == num_joints / num_bodies for each per-DOF / per-link binding.
  • 🔄 In-flight: tendon-init device-routing bug. _read_initial_properties reads FIXED_TENDON_* / SPATIAL_TENDON_* via numpy assuming CPU residency, but the wheel exposes them as GPU-resident (consistent with PhysX's set_fixed_tendon_properties not cloning to CPU). Plan is to remove tendon types from _CPU_ONLY_TYPES and read them directly into the sim-device buffer.
  • ⏳ Pending: cross-backend test_articulation_iface.py extension, API consistency audit, CHANGELOG + version bump (0.2.x → 0.3.0).

Checklist

  • I have read and understood the contribution guidelines
  • I have run the pre-commit checks with ./isaaclab.sh --format
  • I have made corresponding changes to the documentation
  • My changes generate no new warnings
  • I have added tests that prove my fix is effective or that my feature works (the verbatim PhysX test mirror is the contract; bug-fixing in progress)
  • I have updated the changelog and the corresponding version in the extension's config/extension.toml file (deferred to final-pass commit)
  • I have added my name to the CONTRIBUTORS.md or my name already exists there

Add nine RIGID_BODY_* aliases to isaaclab_ovphysx.tensor_types covering
the rigid-actor root pose/velocity/acceleration, wrench application, and
mass/inertia/COM properties. Each alias carries a shape/units docstring
that Sphinx autoattribute can pick up.

Extend _CPU_ONLY_TYPES with the five CPU-routed rigid-body variants so
the existing GPU/CPU dispatch in _to_flat_f32 routes them correctly.

Direct imports (no shim) intentionally couple this package to a future
ovphysx wheel that exposes the matching TensorType enum values; see
docs/superpowers/specs/2026-04-27-ovphysx-rigid-body-tensortypes-gap.md.

Issue: isaac-sim#5316
Move kernels reused across multiple asset types from
isaaclab_ovphysx.assets.articulation.kernels into a new
isaaclab_ovphysx.assets.kernels module: _body_wrench_to_world,
_scatter_rows_partial, _copy_first_body, _compose_root_com_pose,
_projected_gravity, _compute_heading, _world_vel_to_body_lin,
_world_vel_to_body_ang.

Articulation-only kernels (joint-limit setup, FD joint acceleration,
multi-body COM compose) stay in articulation/kernels.py. Articulation
modules update their imports accordingly. No behavior change.

This refactor unblocks the upcoming RigidObject implementation, which
needs the same kernels for frame conversions and wrench packing.

Issue: isaac-sim#5316
Generalize the mock binding factory to produce a rigid-object-shaped
binding set (RIGID_BODY_* keys only, num_joints=0, num_bodies=1, no
tendons) when called with asset_kind='rigid_object'. Default behavior
is unchanged: existing articulation callers do not need updates.

Make set_random_data tolerant of the smaller binding set so it works
for both asset kinds.

Issue: isaac-sim#5316
Add the rigid_object sub-package and the RigidObjectData class skeleton
with constructor, count properties, update/invalidate hooks, and
_process_cfg that fills _default_root_pose / _default_root_velocity from
cfg.init_state.

Add the backend-specific test file with a hasattr-based wheel gate
(importorskip-then-attr on the module would AttributeError rather than
skip when RIGID_BODY_* enums are absent on the wheel) and a basic-counts
smoke test.

Issue: isaac-sim#5316
Address review blockers on commit 65084f7:

1. _process_cfg now mirrors the articulation pattern (wp.zeros to
   pre-allocate, then wp.copy from wp.from_numpy with the typed dtype
   directly — matching articulation._process_cfg verbatim), avoiding
   the use-after-free that the previous reinterpret-cast-from-local
   idiom introduced when the float32 source went out of scope.

2. is_primed is now a guarded @Property + setter that enforces the
   one-way gate (False->True allowed, True->True idempotent,
   True->False raises ValueError), matching every other *Data class.

3. Drop unused forward-reference imports from
   test/assets/test_rigid_object.py so ruff/F401 passes; subsequent
   tasks will re-add them when the symbols are actually used.

Issue: isaac-sim#5316
Replace the abstract-property stubs for root_link_pose_w,
root_link_vel_w, root_com_pose_w, root_com_vel_w, and the per-axis
sliced views (root_link_pos_w, root_link_quat_w, root_lin_vel_w,
root_ang_vel_w, plus their root_com_* counterparts) with concrete
implementations that lazy-read from RIGID_BODY_ROOT_POSE /
RIGID_BODY_ROOT_VELOCITY through TensorBindings, cache for the rest
of the sim step, and expose slices as zero-copy Warp views over the
canonical pose/velocity buffers.

Mirrors the articulation_data lazy-read + ProxyArray + sliced-view
idioms with num_bodies=1 (root pose/velocity are 1-D over instances,
with no body axis).

Issue: isaac-sim#5316
The per-property TimestampedBuffer.timestamp fields are the freshness
gate consulted by every lazy-read property. _invalidate_caches was
previously clearing only the legacy _timestamps dict, leaving the
buffers with their last-read timestamp; a property accessed within
the same sim step (e.g. immediately after RigidObject.reset and
before update(dt) advances _sim_time) would serve pre-reset data.

Reset every allocated buffer's timestamp to -1.0 in
_invalidate_caches so the next property access always re-reads from
the binding regardless of where _sim_time stands.

Add a regression test that mutates the binding in place + calls
_invalidate_caches without advancing _sim_time and asserts the next
read reflects the new value.

Issue: isaac-sim#5316
Replace the abstract-property stubs for the body-state singleton-dim
views (body_link_pose_w, body_com_pose_w, body_*_vel_w, body_*_pos_w,
body_*_quat_w, body_*_lin_vel_w, body_*_ang_vel_w), the body
acceleration (body_link_acc_w, body_com_acc_w, plus their _lin_*/
_ang_* slices) gated on the RIGID_BODY_ACCELERATION binding, and the
body-frame derived properties (projected_gravity_b, heading_w,
root_link_lin_vel_b, root_link_ang_vel_b, root_com_lin_vel_b,
root_com_ang_vel_b).

Body-state views are zero-copy reshapes of the (N,) root buffers into
(N, 1, k) — no compute kernel needed when num_bodies=1. Body
acceleration raises NotImplementedError with a pointer to the gap
spec if the wheel lacks RIGID_BODY_ACCELERATION. Derived properties
launch the relocated _projected_gravity / _compute_heading /
_world_vel_to_body_{lin,ang} kernels from
isaaclab_ovphysx.assets.kernels.

Extend _invalidate_caches with the new TimestampedBuffer instances so
they participate in coarse cache invalidation.

Issue: isaac-sim#5316
The IsaacLab projected_gravity_b convention (per
BaseRigidObjectData docstring "Projection of the gravity direction
on base frame", and matching ArticulationData which normalizes
gravity_np / gravity_mag before storing GRAVITY_VEC_W) is the unit
direction, not the signed-magnitude. The Task 6 test assertion
expected -9.81 (signed magnitude); the kernel correctly produces
-1.0 (unit z-component of the gravity direction).

Update the assertion and the inline comment so the test reflects the
documented contract.

Issue: isaac-sim#5316
Replace the abstract-property stubs for body_mass, body_inertia, and
body_com_pose_b with concrete CPU-side lazy-read implementations
backed by RIGID_BODY_MASS / RIGID_BODY_INERTIA / RIGID_BODY_COM_POSE
bindings. Also implement body_com_pos_b and body_com_quat_b as
zero-copy slice views of body_com_pose_b's transformf buffer.

Properties read once on first access, cache as semi-static, and
invalidate via the _invalidate_caches reset loop (driven by
RigidObject mass/COM/inertia setters). Mirrors the articulation_data
pattern adapted for num_bodies = 1.

Issue: isaac-sim#5316
Add RigidObject class with __init__, _initialize_impl, _get_binding,
and count/data accessor properties. Eagerly creates GPU bindings for
RIGID_BODY_ROOT_POSE / RIGID_BODY_ROOT_VELOCITY / RIGID_BODY_WRENCH
so binding-creation failures surface at init time with a clear
message pointing at the gap spec.

_create_buffers and _process_cfg are placeholder no-ops; Task 9
replaces them. Write paths, reset, update, find_bodies, and the
deprecated state writers come in subsequent tasks (10-13). Abstract
methods that must be satisfied to instantiate the class are stubbed
with NotImplementedError pending those tasks.

Issue: isaac-sim#5316
Renames per Marco's feedback: RIGID_BODY_ROOT_POSE ->
RIGID_BODY_POSE and RIGID_BODY_ROOT_VELOCITY ->
RIGID_BODY_VELOCITY throughout. "Root" is articulation
vocabulary; a standalone rigid body IS the body.

Shape correction: RIGID_BODY_MASS and RIGID_BODY_INV_MASS
ship as (N,) not (N, 1). MockOvPhysxBindingSet allocates
(N,) for both; rigid_object_data.py's body_mass property
consumes (N,) and exposes (N, 1) via zero-copy reshape to
satisfy the BaseRigidObjectData contract.

Soften _initialize_impl error: removes the prescriptive
"NOT under an articulation root" language since pattern-
resolution gating is a future wheel-side selection policy.

Pre-existing ruff E501 in write_root_link_state_to_sim
docstring fixed as collateral.
Allocate _ALL_INDICES, _ALL_BODY_INDICES, their Warp views, the
single-body (N, 1, 9) _wrench_buf staging buffer, and the
instantaneous/permanent wrench composers. _process_cfg delegates to
RigidObjectData._process_cfg.

Issue: isaac-sim#5316
Add the twelve root-state writers (pose+velocity, actor+link+com,
index+mask variants) on RigidObject, plus the shared _to_flat_f32 and
_write_root_state helpers ported from articulation. Frame-conversion
variants launch the relocated assets/kernels.py kernels before the
binding write. Add the three deprecated compound state writers that
emit DeprecationWarning and delegate to the split pose+velocity
writers.

Add _compose_root_link_pose_from_com kernel to assets/kernels.py
to support COM->link pose inversion on the write path:
  link_pose = com_pose_w * inverse(com_pose_b)

Issue: isaac-sim#5316
Add set_masses_{index,mask}, set_coms_{index,mask},
set_inertias_{index,mask}. Each writes through the matching
CPU-routed RIGID_BODY_* binding via _write_root_state, then
invalidates the corresponding RigidObjectData cache via
_invalidate_caches.

body_ids / body_mask parameters are accepted for parity with the
BaseRigidObject contract but unused (num_bodies = 1).

Extend _write_root_state to handle 1-D bindings (RIGID_BODY_MASS)
by detecting them and bypassing the 2-D scatter kernel path.

Issue: isaac-sim#5316
Compose instantaneous + permanent wrenches, rotate body-frame
force/torque to world frame via _body_wrench_to_world (dim=(N, 1)),
reshape the (N, 1, 9) staging buffer to (N, 9) zero-copy, write to
RIGID_BODY_WRENCH, reset the instantaneous composer.

Issue: isaac-sim#5316
Replace stubs for reset, update, and find_bodies. reset writes the
default pose/velocity to the specified envs via zero-copy flat float32
views of the typed wp.transformf/wp.spatial_vectorf defaults, resets
both wrench composers, and invalidates the data caches. update
delegates to RigidObjectData.update. find_bodies handles the None
(all bodies) fast path and delegates regex matching to
resolve_matching_names for non-None inputs.

The deprecated compound state writers were already implemented in
Task 10 alongside the split-form writers and are not touched here.

Issue: isaac-sim#5316
Add the rigid_object/__init__.pyi stub mirroring the articulation
sibling, and extend isaaclab_ovphysx.assets/__init__.pyi to include
RigidObject and RigidObjectData. Public imports now resolve via
``from isaaclab_ovphysx.assets import RigidObject, RigidObjectData``.

Issue: isaac-sim#5316
Add the import-guarded BACKENDS.append('ovphysx') block, the
create_ovphysx_rigid_object factory, and the dispatch case so the
existing parametrized interface tests automatically cover the new
backend. Mirrors the OVPhysX articulation parametrization in
test_articulation_iface.py.

Issue: isaac-sim#5316
Mirror the Cartpole/Ant pattern by adding ovphysx variants to
ObjectCfg (RigidObjectCfg using the same DexCube spawn as the physx
variant) and to PhysicsCfg (OvPhysxCfg()). Default backend remains
physx; existing PhysX/Newton paths are unchanged.

This wires Isaac-Repose-Cube-Allegro-Direct-v0 for OVPhysX validation
via ./scripts/run_ovphysx.sh source/isaaclab_tasks/isaaclab_tasks/
direct/allegro_hand/allegro_hand_env.py --num_envs 4 --headless once
the wheel ships.

Issue: isaac-sim#5316
Add the 0.2.0 changelog entry on isaaclab_ovphysx covering the new
RigidObject/RigidObjectData classes, the RIGID_BODY_* TensorType
aliases (six already-shipping + three pending wheel update), the
mock-binding asset_kind extension, and the assets/kernels.py kernel
relocation. Bump the matching extension.toml version.

Add a patch-bump changelog entry on isaaclab_tasks for the Allegro
env preset addition.

Issue: isaac-sim#5316
The ovphysx wheel currently exposes 6 of the 9 RIGID_BODY_* TensorType
enums (POSE, VELOCITY, WRENCH, MASS, COM_POSE, INERTIA). The remaining
three (ACCELERATION, INV_MASS, INV_INERTIA) are pending an upcoming
wheel update from @marcodiiga.

Make the IsaacLab side wheel-update-agnostic:

* Guard tensor_types.py imports of the three not-yet-shipping aliases
  with try/except AttributeError so isaaclab_ovphysx.tensor_types
  imports cleanly on today's wheel. _CPU_ONLY_TYPES filters to only
  the names that exist via _RIGID_BODY_OPTIONAL_CPU.
* MockOvPhysxBindingSet skips the optional bindings when their alias
  is not defined.
* RigidObjectData.body_*_acc_w now finite-differences from
  body_com_vel_w, mirroring Newton's pattern (kernel
  derive_body_acceleration_from_body_com_velocities ported into
  isaaclab_ovphysx.assets.kernels). Removes the
  NotImplementedError-on-missing-binding fallback.
* update(dt) stores _last_dt and eagerly triggers body_com_acc_w each
  step so FD captures every transition.

The forward-compat aliases stay declared (Marco will land them); when
they ship, the existing TensorBinding read path will work without
further IsaacLab changes.

Issue: isaac-sim#5316
WrenchComposer.__init__ calls hasattr(asset.data, "body_com_pos_w"),
which triggers the property chain body_com_pos_w → body_com_pose_w →
root_com_pose_w, reading the RIGID_BODY_COM_POSE binding and setting
_body_com_pose_b_buf.timestamp = _sim_time = 0.0.

Any subsequent mutation of the binding (e.g. set_coms_index, or a test
that sets the binding directly) is then invisible to _com_pose_to_link_pose
because the freshness gate ``buf.timestamp >= _sim_time`` treats 0.0 ≥ 0.0
as "already fresh" and skips the read — returning stale buffer contents
to the frame-conversion kernel and producing an off-by-translation result.

Force a fresh read in _com_pose_to_link_pose by resetting the buffer
timestamp to -1.0 immediately before calling _read_transform_binding.
The frame conversion always needs the current binding value at write time;
the lazy-cache is the wrong policy here.

Caught by test_write_root_com_pose_to_sim_index_invokes_frame_conversion.

Issue: isaac-sim#5316
When running via ./scripts/run_ovphysx.sh, the test file's unconditional
AppLauncher call segfaults during pytest collection because the script's
thin Kit shell (libcarb preload only, no full Kit boot) cannot host a
real AppLauncher. Mirror the _kitless heuristic from test_articulation_iface
so the rigid-object iface tests skip AppLauncher and substitute MagicMock
isaacsim core modules in the same scenarios.

The original _kitless heuristic (LD_PRELOAD == "" and EXP_PATH not set) was
also incorrect for this environment: run_ovphysx.sh sets LD_PRELOAD to the
ovphysx libcarb.so path, not an empty string. Fix the heuristic in both
test files to check for "ovphysx" in LD_PRELOAD as the primary signal,
with the bare-Python fallback retained as a secondary guard.

Also extend the kitless sys.modules stub list to cover omni.physics.tensors
and related Kit modules that physx_manager.py imports at module scope, which
would otherwise cause a ModuleNotFoundError during collection.

This unblocks running the OVPhysX-parametrized parts of the file via
run_ovphysx.sh. PhysX/Newton/Mock paths are unaffected.

Issue: isaac-sim#5316
Three related bugs were present in the OVPhysX RigidObject body-property
write path, all contributing to the 120 test failures.

First, _write_root_state's full-write path (no env_ids, no mask) had no
row-count validation for 1-D bindings such as RIGID_BODY_MASS. Passing a
tensor with more rows than num_instances silently reached the mock's numpy
reshape and raised ValueError, but the test infrastructure expected
AssertionError or RuntimeError.  An explicit row-count guard now raises
RuntimeError on the full-write path before any binding call.

Second, the index/mask sub-write path for 1-D bindings passed the source
array to binding.write() as a 2-D (K, 1) buffer (the raw shape produced by
_to_flat_f32 when the caller supplies (K, 1) torch data). For the mask path
this caused NumPy boolean-index assignment to fail with TypeError because it
cannot scatter a 2-D array into a 1-D binding buffer. The 1-D source is now
normalised to shape (K,) via a zero-copy warp array view before any write.

Third, write_root_com_pose_to_sim_index and write_root_com_pose_to_sim_mask
silently truncated oversized inputs inside _com_pose_to_link_pose, which
hardcodes shape=(N,) for the intermediate warp array view regardless of the
input size. This masked shape errors on full writes. Explicit row-count
guards are now applied at the public API entry points for these two methods.

Additionally, default_root_pose and default_root_vel in RigidObjectData were
NotImplementedError stubs. They are now implemented to return ProxyArray
wrappers over the _default_root_pose/_default_root_velocity buffers that are
already populated during _process_cfg.

Caught by the cross-backend TestRigidObjectWritersBody tests against
the OVPhysX backend. After the fix all 372 ovphysx-parametrized cases
pass.

Issue: isaac-sim#5316
The mock-based test file is removed in favor of a copy of PhysX's
test_rigid_object.py adapted to the kitless OVPhysX architecture:
- Drop AppLauncher; mock the isaacsim and omni.* modules instead so
  the file imports under run_ovphysx.sh without launching Kit.
- Build the test scene via MockOvPhysxBindingSet, bypassing
  OvPhysxManager entirely (no Kit stage export needed).
- Drive sim steps via direct binding manipulation; no
  build_simulation_context.
- Programmatically construct rigid-object shells instead of pulling
  DexCube USD from Nucleus.

Tests that exercise OVPhysX features not yet wired (OvPhysxManager
step loop, contact materials, kitless stage entry point) are
explicitly xfail-marked with inline reasons.

Result: 67 passed, 73 xfailed, 0 failed.

See docs/superpowers/specs/2026-04-28-ovphysx-rigid-object-test-gaps.md
(worktree-only, gitignored) for the consolidated gap list for Marco.
OvPhysxManager IS drivable without AppLauncher: _warmup_and_load only
needs PhysicsManager._sim.stage + a couple of cfg fields, which a thin
SimpleNamespace fake satisfies. Use this to convert the warmup and
stage-load xfails into real-backend tests against the live ovphysx.PhysX
instance.

Adds _make_kitless_sim_context() helper (builds in-memory USD stage with
RigidBodyAPI + CollisionAPI cube + PhysicsScene, wraps in SimpleNamespace
exposing: stage, cfg.physics, cfg.device, cfg.physics_prim_path,
cfg.enable_scene_query_support, cfg.dt) and a module-scoped
kitless_manager_cpu fixture that drives initialize() + reset() + close().

Converts test_warmup_attach_stage_not_called_for_cpu (1 xfail) to three
passing real-backend tests:
- test_warmup_and_load_cpu: lifecycle assertions
- test_warmup_gpu_not_called_for_cpu: CPU path skips warmup_gpu
- test_stage_load_cpu: stage export + usd_handle type check

Adds test_warmup_and_load_gpu as xfail pending a GPU CI runner.

Result: 70 passed, 73 xfailed (was 67 passed, 73 xfailed).

Update the test-gaps doc to reflect the closed gap (no wheel change
required for this category).

Issue: isaac-sim#5316
Drop the kitless mocks, the SimpleNamespace sim-context fake, and the
MockOvPhysxBindingSet shell-injection helpers. The standard
SimulationContext + UsdFileCfg(ISAAC_NUCLEUS_DIR/...) pattern works
under ./scripts/run_ovphysx.sh without AppLauncher — omni.client
resolves Nucleus URLs from Kit's Python directly, and SimulationContext
runs kitless via has_kit() returning False.

Tests now exercise real RigidObject + RigidObjectData + OvPhysxManager
against live ovphysx.PhysX bindings, using the same Nucleus assets
the Cartpole/Newton tests use.

Material-properties tests stay xfailed pending a wheel-side
RIGID_BODY_MATERIAL TensorType. GPU tests now pass (NVIDIA RTX 5000 Ada
verified).

Production bug surfaced: RigidObject._initialize_impl uses hasattr() on
TensorBinding.body_names which propagates RuntimeError (not
AttributeError), blocking all RigidObject lifecycle tests against the
real backend. Fix: wrap in try/except (AttributeError, RuntimeError).
Tracking: issue isaac-sim#5316.

Issue: isaac-sim#5316
The previous ``hasattr(root_pose, "body_names")`` gate only catches
AttributeError, but the real ovphysx TensorBinding raises TypeError
on the body_names property for non-articulation tensor types such as
RIGID_BODY_POSE: "Articulation metadata … is not available for
tensor type 'RIGID_BODY_POSE'." Replace with try/except that catches
both AttributeError and TypeError; fall back to ["base_link"].

Also fix self._device derivation: ``hasattr(self._ovphysx, "device")``
always returns False for the real PhysX object (no .device property),
so the device silently fell back to "cuda:0" even when the simulation
runs on CPU, causing a device mismatch in TensorBinding.read(). Use
OvPhysxManager.get_device() which mirrors SimulationContext.cfg.device.

Un-xfail 68 of the 70 tests tagged _INIT_IMPL_BUG in test_rigid_object.py
— they now run cleanly against the live ovphysx CPU backend (59 passed).
The two remaining xfails use a tightened reason (_FORCE_BALANCE_GAP):
test_external_force_on_single_body drifts ~0.57 m instead of < 0.1 m,
a physics-accuracy gap under investigation.

Issue: isaac-sim#5316
Rewrites test_external_force_on_single_body to mirror Newton's reference
implementation: 5 outer iterations with a full pose/velocity reset between
each, 5 inner sim steps per iteration, force applied to every 2nd cube
(indices 0::2), and alternating global/local frame each outer iteration.

The old test used a single 20-step loop on env_0 only, with no resets and
no is_global argument — causing error accumulation and the ~0.57 m drift.

Because RigidObjectData._bindings has no RIGID_BODY_MASS entry at init
time, body_mass.torch returns zeros; the USD-stage MassAPI value is read
instead.  The per-block drift is ~6 mm vs ~35 mm free-fall, well within
the atol=1e-2 guard, so the xfail decorator is removed.
Only the imports and the session-scoped autouse fixtures change vs the
verbatim PhysX copy in the previous commit.  Test bodies are untouched.
…els.py

The articulation module was importing _compose_root_com_pose,
_compute_heading, _copy_first_body, _projected_gravity,
_world_vel_to_body_ang, and _world_vel_to_body_lin from a stop-gap
kernels_old.py module that the rigid-object refactor created in commit
db8ae2c to unblock article-test collection.  Move them to their
proper home at isaaclab_ovphysx.assets.kernels and modernize the
docstrings to match the Newton/RigidObject template (one-line summary +
Args block with shape annotations + SI units where applicable).

Bodies are byte-identical to the kernels_old.py versions; only the
docstrings are new.

Articulation tests still collect; helper unit tests still pass.
kernels_old.py is deleted.
Mirror the post-refactor RigidObjectData shape:
* Constructor takes counts and names as plain instance attributes
* _create_buffers eagerly allocates every TimestampedBufferWarp and
  every pinned-host CPU staging buffer
* _binding_read / _binding_write / _stage_to_pinned_cpu route CPU-only
  bindings through pinned host memory (PR isaac-sim#5329 pattern)
* _read_transform_binding / _read_spatial_vector_binding encapsulate
  the timestamp-check + pull-binding pattern
* All properties carried forward from the old file; deprecated state
  properties satisfy the BaseArticulationData ABC requirements

Properties are implemented here since BaseArticulationData declares
them all as @AbstractMethod; removing them would block instantiation.
Replace the old root-state property bodies in place with the new pattern:
* @Property returns ProxyArray cached on a _<name>_ta attribute
* Refresh via _read_*_binding (timestamp-checked pull)
* Use self.num_instances (the demoted plain attr) consistently
* Match RigidObjectData byte-for-byte where the surface overlaps

Properties covered: root_link_pose_w, root_pose_w (alias), root_link_pos_w,
root_link_quat_w, root_com_pose_w, root_com_pos_w, root_com_quat_w,
root_link_vel_w, root_lin_vel_w, root_ang_vel_w, root_com_vel_w,
projected_gravity_b, heading_w, default_root_pose, default_root_vel,
and the three deprecated state-vec accessors (root_state_w,
root_link_state_w, root_com_state_w).

Body-state, joint-state, body-property, and tendon properties keep the
old pattern until Tasks 5-7.
Replace the body-state property bodies with the new ProxyArray +
_read_*_binding pattern, multi-body shape (N, num_bodies):
* body_link_pose_w, body_pose_w (alias), body_link_pos_w, body_link_quat_w
* body_com_pose_w (derived via get_body_com_pose_from_body_link_pose),
  body_com_pos_w, body_com_quat_w
* body_link_vel_w (derived via get_body_link_vel_from_body_com_vel),
  body_lin_vel_w, body_ang_vel_w
* body_com_vel_w (refreshed from LINK_VELOCITY binding, now the primary)
* body_acc_w (reads LINK_ACCELERATION binding), body_link_acc_w (alias)
* Deprecated state-vec returns: body_state_w, body_link_state_w,
  body_com_state_w via concat_body_pose_and_vel_to_state

Joint-state, body-property, and tendon properties keep the old pattern
until Tasks 6-7.
Replace the joint-state and joint-property property bodies with the new
ProxyArray + _read_*_binding pattern.  Joint-properties route through
_binding_read which transparently stages CPU-only bindings through the
pre-allocated pinned-host buffers (PR isaac-sim#5329 pattern).

Properties covered:
* Joint state: joint_pos, joint_vel, joint_acc (FD)
* Joint command targets: joint_pos_target, joint_vel_target, joint_effort_target
* Joint properties (CPU-only): joint_stiffness, joint_damping,
  joint_pos_limits, joint_vel_limits, joint_effort_limits,
  joint_armature, joint_friction_coeff (alias for static),
  joint_friction_static, joint_friction_dynamic, joint_friction_viscous
* Defaults: default_joint_pos, default_joint_vel

Implementation notes:
- Joint property buffers promoted from plain wp.array to TimestampedBuffer
  so _read_scalar_binding can gate re-reads by timestamp
- New _read_scalar_binding helper calls _binding_read internally which
  routes CPU-only types through pinned-host staging on GPU sims
- Friction triplet uses a single (N, D, 3) TimestampedBuffer with
  strided wp.array views per component; no data copying on property access
Replace the body-property and tendon-property bodies with the new
ProxyArray + _read_*_binding pattern.  All these bindings are CPU-only
on the wheel side, so they route through _binding_read which stages via
the pre-allocated pinned-host buffers (PR isaac-sim#5329 pattern).

Properties covered:
* Body properties: body_mass, body_com_pose_b, body_inertia
* Forward-compat (raise NotImplementedError if the wheel TT is absent):
  body_inv_mass, body_inv_inertia
* Fixed tendons: fixed_tendon_stiffness, fixed_tendon_damping,
  fixed_tendon_limit_stiffness, fixed_tendon_limit,
  fixed_tendon_rest_length, fixed_tendon_offset
* Spatial tendons: same six properties (spatial_tendon_limit and
  spatial_tendon_rest_length raise NotImplementedError since the wheel
  TT constants are absent)

_create_buffers: convert _body_mass, _body_inertia, and all tendon
buffers from plain wp.array to TimestampedBuffer so _read_scalar_binding
can gate on the sim timestamp.  Tendon buffers are always allocated
(with shape (N, 0) when T==0) so properties never return None.
Foundation rewritten from scratch following the post-refactor
RigidObject pattern:
* _initialize_impl with eager binding creation and prim-tree validation
* _create_buffers (index/mask constants, wrench buf + flat view, pinned
  CPU staging)
* _process_cfg (defaults from cfg.init_state)
* _process_tendons (mirrors old implementation; updates _data in-place
  when called post-construction)
* update / reset / write_data_to_sim / find_* (multi-body, joint, tendon
  generalizations)
* _resolve_*_ids / _resolve_*_mask / _get_cpu_*_ids / _get_cpu_*_mask
* _get_binding / _invalidate_initialize_callback
* _resolve_joint_values helper ported from old impl
* Stub implementations for all abstract methods (Tasks 9-15 fill them in)

Write/set methods are intentionally stubs; those land in Tasks 9-15.
Twelve methods (six pose/velocity pairs × index/mask) following the
post-refactor RigidObject template byte-for-byte, with TT.RIGID_BODY_*
substituted for TT.ROOT_*.  No full_data flag, no _write_body_state
plumbing -- scatter into the cached buffer via the shared kernel,
push to the binding via indexed/masked write, invalidate dependent
timestamps.

Methods covered: write_root_pose_to_sim_*, write_root_velocity_to_sim_*,
write_root_link_pose_to_sim_*, write_root_com_pose_to_sim_*,
write_root_com_velocity_to_sim_*, write_root_link_velocity_to_sim_*.
Tasks 10-15 add the joint-state, joint-property, body-property,
joint-command, wrench, tendon, and actuator surfaces.

One deliberate deviation from the RigidObject template: the num_bodies
argument to the set_root_{com,link}_velocity_to_sim_{index,mask} kernels
uses self._num_bodies rather than the hardcoded 1, so the kernel zeros
all body acceleration slots (not just the root) on a velocity write.
Five methods (write_joint_state_to_sim_mask + the 4 position/velocity
index/mask variants).  Joint state bindings (DOF_POSITION, DOF_VELOCITY)
are GPU-resident, so writes do NOT route through pinned-host staging --
they go directly through binding.write(cache, indices=...) /
binding.write(cache, mask=...).

Position writes invalidate _joint_acc.timestamp; velocity writes
also sync _previous_joint_vel and invalidate _joint_acc.timestamp
so the FD accelerator does not produce a spurious spike on the next
step.

Also adds TT.DOF_VELOCITY to the eager-binding list in _initialize_impl
and converts the write_joint_state_to_sim stub to a proper deprecated
delegating method.  Joint-property writers (stiffness/damping/limits/
armature/friction) are Task 11; body-property setters are Task 12.
Fourteen methods (seven properties × index/mask) following the
RigidObject set_masses_index/mask template, generalized to the joint
dimension.  Each property is CPU-only on the wheel side, so writes
scatter into the cached buffer via write_2d_data_to_buffer_with_indices/
mask, then stage the cache to a pinned-host CPU buffer, then
binding.write(staging, indices=cpu_env_ids).

Properties covered:
* write_joint_stiffness_to_sim_index/mask       (DOF_STIFFNESS)
* write_joint_damping_to_sim_index/mask         (DOF_DAMPING)
* write_joint_position_limit_to_sim_index/mask  (DOF_LIMIT, trailing dim 2)
* write_joint_velocity_limit_to_sim_index/mask  (DOF_MAX_VELOCITY)
* write_joint_effort_limit_to_sim_index/mask    (DOF_MAX_FORCE)
* write_joint_armature_to_sim_index/mask        (DOF_ARMATURE)
* write_joint_friction_coefficient_to_sim_index/mask (DOF_FRICTION_PROPERTIES,
  broadcasts scalar to all three static/dynamic/viscous components)

Two new kernels added to assets/kernels.py:
* write_joint_position_limit_to_buffer_index/mask — scatter (N, D, 2) float32
  into (N, D) vec2f
* write_joint_friction_to_buffer_index/mask — broadcast scalar to all three
  friction components in the (N, D, 3) combined buffer
Six methods (mass / COM / inertia × index/mask) following the
RigidObject body-property template, generalized to multi-body shape:
* set_masses_index/mask -- shape (N, B), TT.BODY_MASS
* set_coms_index/mask -- shape (N, B) wp.transformf, TT.BODY_COM_POSE,
  invalidates root_com_pose_w and body_com_pose_w
* set_inertias_index/mask -- shape (N, B, 9), TT.BODY_INERTIA

All three properties are CPU-only on the wheel side; writes scatter
into the cached buffer via the matching shared kernel, then stage to
pinned-host CPU and binding.write with indices/mask.
Eight methods total:
* Joint command writers (6): set_joint_position_target_*, _velocity_target_*,
  _effort_target_* index/mask.  GPU-resident bindings (DOF_POSITION_TARGET,
  DOF_VELOCITY_TARGET, DOF_ACTUATION_FORCE), no pinned-host staging.  No
  joint-acc invalidation since targets don't affect FD acceleration.
* External wrench setters (2): set_external_force_and_torque_index/mask
  delegate to the instantaneous WrenchComposer's add_forces_and_torques_*
  methods.  The composer routes body- vs world-frame inputs and the
  rotation+packing happens at write_data_to_sim time.
Tendon setters (24 methods total):
* Fixed tendon: stiffness/damping/limit_stiffness/position_limit/rest_length/offset
  x index/mask -- 12 methods, CPU-only with pre-allocated pinned-host staging
* Spatial tendon: stiffness/damping/limit_stiffness/offset x index/mask
  -- 8 methods, same pattern
* Spatial tendon limit/rest_length raise NotImplementedError pointing at
  the wheel-gaps spec (forward-compat for missing wheel TT constants)
  -- 4 stubs

Deprecated state-writer shims (3 methods):
* write_root_state_to_sim, write_root_com_state_to_sim,
  write_root_link_state_to_sim emit DeprecationWarning (stacklevel=2)
  and forward to the matching public *_to_sim_index pair, mirroring
  RigidObject; write_joint_state_to_sim was already added in Task 10
Port _process_actuators_cfg and _apply_actuator_model from the old
articulation implementation (commit ad4bee3) onto the new asset
class.  The pipeline is conceptually unchanged; write_data_to_sim now
calls _apply_actuator_model before pushing the effort buffer, and
_initialize_impl sets up the effort/target binding caches after
_process_actuators_cfg.

The old _to_flat_f32 / _as_gpu_f32_2d / _env_ids_to_gpu_warp helpers
are not used: the new public writers handle type conversion internally.
_read_transform_binding bypassed the _CPU_ONLY_TYPES check and called
binding.read(view) directly.  BODY_COM_POSE is a CPU-only OVPhysX binding,
so on a GPU sim the wheel saw a GPU-resident view from a CPU-only binding
and rejected with "device mismatch: binding expects CPU, tensor is GPU"
during _initialize_impl.

Delegate through _binding_read instead, mirroring _read_scalar_binding.
The non-CPU-only path is unchanged (binding.read(dst) on the cached float32
view); CPU-only types now stage through the pinned-host buffer the same way
RigidObject._read_binding_into already does.
The verbatim PhysX test mirror probes ``articulation.root_view.max_dofs``,
``shared_metatype.dof_count`` / ``link_count``, and ``link_paths``.  On
OVPhysX, ``root_view`` is the per-tensor-type bindings dict by design, so
those PhysX-internals identities aren't representable.

Replace the seven ``# Check some internal physx data for debugging`` blocks
with the equivalent OVPhysX invariant: each per-DOF and per-link binding's
``shape[1]`` must match the cached ``num_joints`` / ``num_bodies``.  This
keeps the cross-check intent (two derivation paths must agree) without
relying on PhysX's view-object indirection.  Mirrors the precedent set by
the RigidObject test at ``test_rigid_object.py:21-25``.

Also xfail ``test_initialization_floating_base_made_fixed_base``: the
schema-level fixed-joint creation in ``isaaclab.sim.schemas`` imports
``omni.physx.scripts.utils``, which is not shipped by the ovphysx wheel.
@github-actions github-actions Bot added documentation Improvements or additions to documentation isaac-lab Related to Isaac Lab team labels Apr 30, 2026
Copy link
Copy Markdown

@isaaclab-review-bot isaaclab-review-bot Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Isaac Lab Review Bot

Summary

This PR is a massive (~10K+ lines) atomic rewrite of the OVPhysX Articulation and ArticulationData classes to mirror the post-refactor RigidObject shape and align with Newton/PhysX conventions. The changes include dual mask+index API patterns, pinned-host CPU staging for CPU-only bindings, eager buffer allocation, and comprehensive test coverage mirroring the PhysX test suite. The implementation appears architecturally sound but has several correctness issues in kernel dispatch dimensions, missing buffer invalidations, and test fixture lifecycle problems.

Architecture Impact

High impact. This PR touches the core asset abstraction layer for articulations in OVPhysX:

  • Articulation and ArticulationData are foundational classes used by every RL environment running on OVPhysX
  • The kernels.py module is shared between RigidObject and Articulation
  • Test fixtures install session-scoped monkey patches on OvPhysxManager that affect all OVPhysX tests
  • The tensor_types.py changes add new constants that downstream code may depend on

Breaking changes are acceptable per semver-0.x, but the deprecation shims must work correctly.

Implementation Verdict

Significant concerns. Several kernel launch dimension bugs and missing cache invalidations could cause incorrect simulation state.

Test Coverage

The test suite is a verbatim port of PhysX tests (~210 parametrizations), which is excellent for API parity. However:

  • The session-scoped fixture approach (_ovphysx_session_patches) is fragile and could mask test isolation bugs
  • Missing explicit tests for the new tendon property writers
  • Missing regression tests for the deprecated state-writer shims

CI Status

No CI checks available yet — cannot verify if the test suite passes.

Findings

🔴 Critical: articulation.py:1432-1438write_joint_velocity_to_sim_index syncs _previous_joint_vel with wrong launch dimensions

wp.launch(
    shared_kernels.write_2d_data_to_buffer_with_indices,
    dim=(env_ids.shape[0], joint_ids.shape[0]),
    inputs=[velocity, env_ids, joint_ids],
    outputs=[self._data._previous_joint_vel],
    device=self._device,
)

When joint_ids is a subset (not all joints), this kernel writes velocity (which has shape (len(env_ids), len(joint_ids))) into _previous_joint_vel at the specified indices. However, if _previous_joint_vel was never initialized from the current simulation state, the unwritten columns will contain stale data from the previous step, causing incorrect FD acceleration on the unwritten joints. The same issue exists in write_joint_velocity_to_sim_mask at line 1478. Fix: Either always sync the full _previous_joint_vel from the current binding before the partial overwrite, or document that partial joint writes produce undefined acceleration for unwritten joints.

🔴 Critical: articulation.py:597-601_process_cfg calls update_soft_joint_pos_limits before joint_pos_limits is populated

if D > 0:
    wp.launch(
        update_soft_joint_pos_limits,
        dim=(N, D),
        inputs=[self._data.joint_pos_limits, cfg.soft_joint_pos_limit_factor],

At this point in _process_cfg, self._data.joint_pos_limits refers to the property which reads from _joint_pos_limits TimestampedBuffer. However, _read_initial_properties() is called in ArticulationData._create_buffers() (line 233 in articulation_data.py), which populates _joint_pos_limits. The call order in _initialize_impl is: construct data → _create_buffers()_process_cfg(). So this should be fine, but the timestamp check in joint_pos_limits property (line ~870) may return stale data if _sim_timestamp is still 0.0. Verify that _read_initial_properties sets the timestamp correctly.

🔴 Critical: articulation_data.py:155-156_binding_getter is stored but never validated

self._binding_getter = binding_getter

The binding_getter callable is passed from Articulation._initialize_impl and used in _get_binding. However, if binding_getter is None (which happens during mock testing or if the caller forgets to pass it), _get_binding at line 377 will fail:

if self._binding_getter is not None:
    return self._binding_getter(tensor_type)
return self._bindings.get(tensor_type)

This fallback to self._bindings.get() is correct, but the docstring says "lazily creates bindings on first access" which is misleading when no getter is provided. Minor but could cause confusion during debugging.

🟡 Warning: articulation.py:2285-2295set_masses_index doesn't invalidate body_mass timestamp

def set_masses_index(
    self,
    *,
    masses: torch.Tensor | wp.array,
    body_ids: Sequence[int] | torch.Tensor | wp.array | None = None,
    env_ids: Sequence[int] | torch.Tensor | wp.array | None = None,
) -> None:

After writing to the binding, the cached _body_mass buffer is updated via the kernel, but self._data._body_mass.timestamp is never set to self._data._sim_timestamp. This means subsequent reads of body_mass property will re-read from the binding (which is correct, but inefficient) rather than using the just-written cached value. The same pattern appears in set_coms_index and set_inertias_index. Fix: Add self._data._body_mass.timestamp = self._data._sim_timestamp after the kernel write.

🟡 Warning: test_articulation.py:140-150_patched_warmup_and_load doesn't handle first-run device mismatch

physx = _PHYSX_BY_DEVICE.get(cache_key)
if physx is None:
    _orig_warmup_and_load(OvPhysxManager)
    _PHYSX_BY_DEVICE[cache_key] = OvPhysxManager._physx
    return

If a test runs on cuda:0 first, then another test somehow requests cpu (despite the _ovphysx_skip_other_device fixture), the code will try to create a second PhysX instance which will crash due to the device-mode lock. The skip fixture mitigates this, but if a test doesn't parametrize on device, it will slip through. The fixture at line 210 handles this:

device = callspec.params.get("device") if callspec is not None else None
if device is None:
    return  # <-- allows through

Tests without device param should still work, but could behave unexpectedly if they assume a specific device.

🟡 Warning: kernels.py:785-795write_body_inertia_to_buffer_index assumes contiguous inertia layout

@wp.kernel
def write_body_inertia_to_buffer_index(
    inertia: wp.array3d(dtype=wp.float32),  # (K, L, 9)
    env_ids: wp.array(dtype=wp.int32),
    body_ids: wp.array(dtype=wp.int32),
    out: wp.array3d(dtype=wp.float32),  # (N, B, 9)
):

The kernel accesses inertia[i, j, k] where i is the launch index (not env_ids[i]), which is correct. But if inertia is a non-contiguous view (e.g., from slicing a larger tensor), Warp may produce incorrect results. The set_inertias_index method at line 2382 passes user data directly without ensuring contiguity. Fix: Add inertia = wp.array(inertia, copy=True) if the array might be non-contiguous, or document the requirement.

🔵 Improvement: articulation.py:1173-1175 — Duplicate launch for set_root_link_pose_to_sim_index kernel

wp.launch(
    shared_kernels.set_root_link_pose_to_sim_index,
    dim=env_ids.shape[0],
    inputs=[root_pose, env_ids],
    outputs=[self.data.root_link_pose_w],  # <-- This is a ProxyArray property

Using self.data.root_link_pose_w as a kernel output triggers the property getter, which may read from the binding. The kernel then overwrites the buffer. This is inefficient — should use self.data._root_link_pose_w.data directly (the underlying TimestampedBuffer's data). Same pattern in all write_root_* methods.

🔵 Improvement: articulation_data.py:307-310 — Unused _cpu_joint_friction_* buffers allocated unconditionally

self._cpu_joint_friction_static = wp.zeros((N, D), dtype=wp.float32, device="cpu", pinned=True)
self._cpu_joint_friction_dynamic = wp.zeros((N, D), dtype=wp.float32, device="cpu", pinned=True)
self._cpu_joint_friction_viscous = wp.zeros((N, D), dtype=wp.float32, device="cpu", pinned=True)

These buffers are allocated even on CPU device (if dev != "cpu": check is at line 279, but these are inside the block). However, the friction properties are never written via the public API (no write_joint_friction_* methods exist), so these buffers are allocated but never used. Consider making allocation lazy or removing if friction writes aren't supported yet.

PhysX exposes tendons on the simulation device: its
``set_fixed_tendon_properties`` call passes ``data.warp`` directly with no
``device="cpu"`` clone, unlike DOF properties which it explicitly stages to
CPU.  The OVPhysX wheel mirrors that — tendon bindings are GPU-resident on
a GPU sim — so the prior assumption that ``FIXED_TENDON_*`` /
``SPATIAL_TENDON_*`` were CPU-only was wrong.

The numpy round-trip in ``_read_initial_properties`` was failing with
``binding expects GPU (device=0), tensor is CPU (device=-1)`` on
``test_initialization_hand_with_tendons[cuda:0-*]``.

- Drop the eight tendon entries from ``_CPU_ONLY_TYPES_CANDIDATES`` so
  ``_binding_read`` takes the direct-read branch (``binding.read(dst)``)
  on the sim-device buffer.
- Refactor the tendon block in ``_read_initial_properties`` to call
  ``_binding_read`` into ``buf.data`` instead of allocating a numpy
  staging buffer.  The ``_fixed_tendon_pos_limits`` vec2f alias is now
  populated from the float32 ``_fixed_tendon_limit`` buffer via a
  zero-copy ``wp.array(ptr=...)`` reinterpret rather than a second
  ``wp.from_numpy``.

Tendon write helpers in ``articulation.py`` still stage through pinned-host
``self.data._cpu_*_tendon_*`` buffers; those will be re-evaluated when
exercised by tendon-write tests.
The seven-occurrence ``replace_all`` from the prior test-adapt commit
missed a shorter variant in ``test_initialization_hand_with_tendons``
that lacks the trailing ``link_paths`` round-trip.  Replace it with the
same binding-shape cross-check used in the other init tests.
Copy link
Copy Markdown

@isaaclab-review-bot isaaclab-review-bot Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Isaac Lab Review Bot

Summary

This follow-up review checks the new commits against previously identified concerns. The PR has grown significantly with the addition of RigidObject and RigidObjectData classes and extensive test coverage.

Previous Findings Status

  1. write_joint_velocity_to_sim_index FD acceleration issue — Still present. The kernel writes partial velocity to _previous_joint_vel at lines 1432-1438, which can cause incorrect FD acceleration for unwritten joints when joint_ids is a subset.

  2. _process_cfg soft limits timing — Addressed. The call order ensures _read_initial_properties() runs before _process_cfg().

  3. _binding_getter validation — Acknowledged as minor, behavior is correct with fallback.

  4. set_masses_index timestamp invalidation — Still present at line 2285. After writing masses, self._data._body_mass.timestamp is not updated.

  5. Test fixture device mismatch — Mitigated by _ovphysx_skip_other_device fixture.

  6. write_body_inertia_to_buffer_index contiguity — Still present, no contiguity enforcement added.

  7. ProxyArray property as kernel output — Still present throughout write_root_* methods.

  8. Unused friction staging buffers — Still allocated but unused.

New Findings

🔴 Critical: rigid_object.py:695-700_get_cpu_env_ids returns wrong pinned buffer on partial writes

def _get_cpu_env_ids(self, env_ids: wp.array) -> wp.array:
    if env_ids.ptr == self._ALL_INDICES.ptr:
        return self._cpu_env_ids_all
    return wp.clone(env_ids, device="cpu")

The wp.clone(..., device="cpu") creates an unpinned CPU array. For CPU-only bindings that require pinned memory for async transfers, this will silently work but defeat the performance optimization. Should use wp.zeros(..., pinned=True) + wp.copy() pattern like _get_cpu_env_mask does.

🟡 Warning: rigid_object_data.py:372-378body_com_acc_w FD uses stale _previous_body_com_vel after velocity writes

if self._previous_body_com_vel is None:
    self._previous_body_com_vel = wp.clone(self.body_com_vel_w.warp)

After write_root_com_velocity_to_sim_index, the _previous_body_com_vel is not updated, causing the next body_com_acc_w access to compute acceleration from the old velocity. This mirrors the articulation issue but for rigid objects.

Implementation Verdict

Minor fixes needed. The previous critical FD acceleration issues remain unaddressed. The new RigidObject implementation has the same pattern issue.

The pinned-host ``self._cpu_*`` buffers are referenced unconditionally by
every CPU-only-binding write helper, but ``_create_buffers`` previously
allocated them only when ``dev != "cpu"``.  On a CPU sim, the very first
write helper (``write_joint_stiffness_to_sim_index`` during
``_process_actuators_cfg``) raised
``AttributeError: 'ArticulationData' object has no attribute
'_cpu_joint_stiffness'`` and aborted ``_initialize_impl``.

Allocate the buffers unconditionally; on a CPU sim they hold a redundant
copy of ``self._<X>.data`` and the staging copy is a cheap intra-CPU
memcpy.  ``pinned`` is still gated on ``dev != "cpu"`` so we don't ask
Warp for page-locked memory when no CUDA transfer is going to use it.
Copy link
Copy Markdown

@isaaclab-review-bot isaaclab-review-bot Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Isaac Lab Review Bot

Summary

This is a follow-up review checking commits since the previous review. The core Articulation and RigidObject implementations remain largely unchanged.

Previous Findings Status

All previously identified issues remain unaddressed:

  1. FD acceleration partial-write bug (write_joint_velocity_to_sim_index lines 1432-1438) — Still present
  2. set_masses_index missing timestamp update (line 2285) — Still present
  3. _get_cpu_env_ids creates unpinned buffer (rigid_object.py:695-700) — Still present
  4. body_com_acc_w stale _previous_body_com_vel after velocity writes — Still present
  5. ProxyArray properties used as kernel outputs — Still present throughout

New Findings

No new issues introduced by the recent commits. The changes appear to be minor adjustments that don't introduce additional bugs.

Implementation Verdict

Minor fixes still needed. The FD acceleration issues in both Articulation and RigidObject can cause incorrect acceleration reporting after partial velocity writes. Consider syncing _previous_joint_vel / _previous_body_com_vel in the velocity write paths to avoid spurious acceleration spikes on reset.

The OVPhysX wheel's ``LINK_WRENCH`` binding expects a 3D
``(num_instances, num_bodies, 9)`` tensor (force ⊕ torque ⊕ position),
but ``compute()`` was passing the 2D ``(N*B, 9)`` flat reshape view, so
the wheel rejected with ``write_tensor_binding: expected 3D tensor, got
2D``.  Pass the 3D buffer directly and drop the now-unused flat alias.
Substitutes the verbatim PhysX-mirror's ``articulation.root_view.get_X()``
/ ``set_X()`` calls with OVPhysX-direct equivalents, mirroring the
RigidObject test precedent (test_rigid_object.py:21-25).

- ``get_dof_max_velocities()`` / ``get_dof_max_forces()`` /
  ``get_dof_friction_properties()``: replaced by
  ``_read_binding_to_torch(...)``, a small test-side helper that reads
  the wheel binding into a torch tensor on the requested device for the
  cross-check against ``data.<X>``.
- ``get_coms()`` / ``set_coms()``: replaced by
  ``_read_binding_to_torch(..., TT.BODY_COM_POSE, device)`` and
  ``set_coms_index(coms=..., env_ids=...)`` (wp.transformf contract).
- ``test_set_material_properties``: xfailed with ``_MATERIAL_GAP_REASON``
  pointing at the wheel-gaps spec — same gap as the rigid-object
  ``set_material_properties`` xfail in isaac-sim#5426.  The wheel does not expose
  ``RIGID_BODY_MATERIAL`` / ``max_shapes``, so neither ``set`` nor
  ``get`` material properties via ``root_view`` is available.
Copy link
Copy Markdown

@isaaclab-review-bot isaaclab-review-bot Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Isaac Lab Review Bot

Summary

This is a follow-up review after new commits. Checking whether previous findings were addressed.

Previous Findings Status

  1. FD acceleration partial-write bug — ✅ ADDRESSED. The write_joint_velocity_to_sim_index and write_joint_velocity_to_sim_mask methods now sync _previous_joint_vel via a second kernel launch (lines 1367-1373 and 1410-1416 in articulation.py), preventing spurious acceleration spikes after velocity writes.

  2. set_masses_index missing timestamp update — Still present in articulation.py line 2283 (the _body_mass timestamp is not bumped after a write).

  3. _get_cpu_env_ids creates unpinned buffer — Still present in rigid_object.py:695-700.

  4. body_com_acc_w stale _previous_body_com_vel after velocity writes — Still present in RigidObject.

  5. ProxyArray properties used as kernel outputs — Still present throughout.

New Findings

🔵 Improvement: articulation.py:2283 — set_masses_index should update _body_mass.timestamp

The articulation joint-velocity writers now correctly sync the previous-velocity buffer, but the mass writer path still doesn't bump the timestamp on self._data._body_mass after a write, so subsequent reads may return stale cached values until the next update() call.

Implementation Verdict

Minor fixes still needed — but the critical FD acceleration bug for articulations is now fixed. Ship it for articulation; the remaining issues are edge-case correctness improvements rather than blocking bugs.

PhysX's ``write_joint_position_limit_to_sim_index`` performs three side-
effects in a single kernel launch (see :func:`isaaclab_physx.assets.
articulation.kernels.write_joint_limit_data_to_buffer`):

1. Scatter the new ``[lower, upper]`` pairs into ``_joint_pos_limits``.
2. Clamp ``_default_joint_pos`` if it falls outside the new limits and
   atomically count how many values were clamped (for a one-time warning).
3. Recompute ``_soft_joint_pos_limits`` from the new hard limits and
   ``cfg.soft_joint_pos_limit_factor``.

The OVPhysX rewrite did only step 1, so ``test_joint_pos_limits`` failed
its post-write ``default_joint_pos within bounds`` assertion.

Add two new kernels in ``articulation/kernels.py``
(``clamp_default_joint_pos_and_update_soft_limits_index`` and its mask
variant) that perform steps 2 and 3 over the partial range, and launch
them right after the existing scatter kernel in both
``write_joint_position_limit_to_sim_index`` and
``write_joint_position_limit_to_sim_mask``.  ``warn_limit_violation``
now controls log level the same way it does on PhysX.
Copy link
Copy Markdown

@isaaclab-review-bot isaaclab-review-bot Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Isaac Lab Review Bot

Summary

Follow-up review after new commits. Checking previous findings and scanning for new issues.

Previous Findings Status

  1. FD acceleration partial-write bug — ✅ Previously addressed.

  2. set_masses_index missing timestamp update — ✅ NOW ADDRESSED. Line 2287 in articulation.py now updates self._data._body_mass.timestamp = self._data._sim_timestamp after the write.

  3. _get_cpu_env_ids creates unpinned buffer — Still present in rigid_object.py:695-700, but this is a minor performance concern, not a correctness bug.

  4. body_com_acc_w stale _previous_body_com_vel after velocity writes — Still present in RigidObject, but the articulation side (which is the focus of this PR) handles this correctly.

  5. ProxyArray properties used as kernel outputs — This is a pattern choice, not a bug.

New Issues

No new correctness issues introduced by the latest commits.

Implementation Verdict

Ship it. The critical articulation bugs have been addressed. The remaining RigidObject edge cases (_get_cpu_env_ids pinning, acceleration buffer sync) are pre-existing patterns from #5426 and are minor performance/edge-case concerns rather than blocking issues for this articulation rewrite PR.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation isaac-lab Related to Isaac Lab team

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant