Source code for isaaclab_ov.sim.views.ovphysx_frame_view

# 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

"""OVPhysX-backed FrameView -- Warp-native, GPU-resident pose queries."""

from __future__ import annotations

import logging
import re
from typing import Any

import warp as wp

from pxr import Gf, Usd, UsdGeom, UsdPhysics

import isaaclab.sim as sim_utils
from isaaclab import cloner
from isaaclab.physics import PhysicsEvent
from isaaclab.sim.views.base_frame_view import BaseFrameView
from isaaclab.sim.views.usd_frame_view import UsdFrameView
from isaaclab.sim.views.xform_space_writer import FrameViewLocalSpaceWriter, FrameViewWorldSpaceWriter
from isaaclab.utils.warp import ProxyArray

from isaaclab_ov.physics import OvPhysxManager

logger = logging.getLogger(__name__)

WORLD_BODY_INDEX = -1


@wp.kernel
def _compute_site_world_transforms(
    body_q: wp.array(dtype=wp.transformf),
    site_body: wp.array(dtype=wp.int32),
    site_local: wp.array(dtype=wp.transformf),
    out_pos: wp.array(dtype=wp.vec3f),
    out_quat: wp.array(dtype=wp.vec4f),
):
    """Compute world-space transforms for every site in the view.

    For each site *i*, computes ``world = body_q[site_body[i]] * site_local[i]``
    and splits the result into position and quaternion outputs.  When
    ``site_body[i] == -1`` the site is world-attached and ``site_local[i]`` is
    returned directly.

    Args:
        body_q: Rigid-body world transforms from the OVPhysX-backed Newton state,
            shape ``[num_bodies]``.
        site_body: Per-site body index (flat model-level), shape ``[num_sites]``.
            ``-1`` indicates a world-attached site.
        site_local: Per-site local offset relative to its parent body, shape ``[num_sites]``.
        out_pos: Output world positions [m], shape ``[num_sites]``.
        out_quat: Output world orientations as ``(qx, qy, qz, qw)``, shape ``[num_sites]``.
    """
    i = wp.tid()
    bid = site_body[i]
    if bid == -1:
        world = site_local[i]
    else:
        world = wp.transform_multiply(body_q[bid], site_local[i])
    out_pos[i] = wp.transform_get_translation(world)
    q = wp.transform_get_rotation(world)
    out_quat[i] = wp.vec4f(q[0], q[1], q[2], q[3])


@wp.kernel
def _compute_site_world_transforms_indexed(
    body_q: wp.array(dtype=wp.transformf),
    site_body: wp.array(dtype=wp.int32),
    site_local: wp.array(dtype=wp.transformf),
    indices: wp.array(dtype=wp.int32),
    out_pos: wp.array(dtype=wp.vec3f),
    out_quat: wp.array(dtype=wp.vec4f),
):
    """Indexed variant of :func:`_compute_site_world_transforms`."""
    i = wp.tid()
    si = indices[i]
    bid = site_body[si]
    if bid == -1:
        world = site_local[si]
    else:
        world = wp.transform_multiply(body_q[bid], site_local[si])
    out_pos[i] = wp.transform_get_translation(world)
    q = wp.transform_get_rotation(world)
    out_quat[i] = wp.vec4f(q[0], q[1], q[2], q[3])


@wp.kernel
def _write_site_local_from_world_poses(
    body_q: wp.array(dtype=wp.transformf),
    site_body: wp.array(dtype=wp.int32),
    world_pos: wp.array(dtype=wp.vec3f),
    world_quat: wp.array(dtype=wp.vec4f),
    site_local: wp.array(dtype=wp.transformf),
):
    """Update site local offsets so that sites reach desired world poses.

    For each site *i*, sets ``site_local[i] = inv(body_q[bid]) * desired_world``
    so that subsequent reads produce the requested world pose.  Does **not**
    modify ``body_q``.  World-attached sites (``site_body[i] == -1``) receive
    the desired world transform directly.

    Args:
        body_q: Rigid-body world transforms, shape ``[num_bodies]``.
        site_body: Per-site body index, shape ``[num_sites]``.
        world_pos: Desired world positions [m], shape ``[num_sites]``.
        world_quat: Desired world orientations as ``(qx, qy, qz, qw)``, shape ``[num_sites]``.
        site_local: Per-site local offset (modified in-place), shape ``[num_sites]``.
    """
    i = wp.tid()
    w_pos = world_pos[i]
    w_q = world_quat[i]
    desired_world = wp.transform(w_pos, wp.quatf(w_q[0], w_q[1], w_q[2], w_q[3]))
    bid = site_body[i]
    if bid == -1:
        site_local[i] = desired_world
    else:
        site_local[i] = wp.transform_multiply(wp.transform_inverse(body_q[bid]), desired_world)


@wp.kernel
def _write_site_local_from_world_poses_indexed(
    body_q: wp.array(dtype=wp.transformf),
    site_body: wp.array(dtype=wp.int32),
    indices: wp.array(dtype=wp.int32),
    world_pos: wp.array(dtype=wp.vec3f),
    world_quat: wp.array(dtype=wp.vec4f),
    site_local: wp.array(dtype=wp.transformf),
):
    """Indexed variant of :func:`_write_site_local_from_world_poses`."""
    i = wp.tid()
    si = indices[i]
    w_pos = world_pos[i]
    w_q = world_quat[i]
    desired_world = wp.transform(w_pos, wp.quatf(w_q[0], w_q[1], w_q[2], w_q[3]))
    bid = site_body[si]
    if bid == -1:
        site_local[si] = desired_world
    else:
        site_local[si] = wp.transform_multiply(wp.transform_inverse(body_q[bid]), desired_world)


@wp.kernel
def _compute_site_local_transforms(
    body_q: wp.array(dtype=wp.transformf),
    site_body: wp.array(dtype=wp.int32),
    site_local: wp.array(dtype=wp.transformf),
    parent_site_body: wp.array(dtype=wp.int32),
    parent_site_local: wp.array(dtype=wp.transformf),
    out_pos: wp.array(dtype=wp.vec3f),
    out_quat: wp.array(dtype=wp.vec4f),
):
    """Compute parent-relative transforms for every site in the view.

    For each site *i*, computes the world pose of both the site and its USD
    parent, then returns ``inv(parent_world) * prim_world``.  World-attached
    sites/parents use ``site_local`` / ``parent_site_local`` directly.
    """
    i = wp.tid()
    prim_bid = site_body[i]
    if prim_bid == -1:
        prim_world = site_local[i]
    else:
        prim_world = wp.transform_multiply(body_q[prim_bid], site_local[i])
    parent_bid = parent_site_body[i]
    if parent_bid == -1:
        parent_world = parent_site_local[i]
    else:
        parent_world = wp.transform_multiply(body_q[parent_bid], parent_site_local[i])
    local_tf = wp.transform_multiply(wp.transform_inverse(parent_world), prim_world)
    out_pos[i] = wp.transform_get_translation(local_tf)
    q = wp.transform_get_rotation(local_tf)
    out_quat[i] = wp.vec4f(q[0], q[1], q[2], q[3])


@wp.kernel
def _compute_site_local_transforms_indexed(
    body_q: wp.array(dtype=wp.transformf),
    site_body: wp.array(dtype=wp.int32),
    site_local: wp.array(dtype=wp.transformf),
    parent_site_body: wp.array(dtype=wp.int32),
    parent_site_local: wp.array(dtype=wp.transformf),
    indices: wp.array(dtype=wp.int32),
    out_pos: wp.array(dtype=wp.vec3f),
    out_quat: wp.array(dtype=wp.vec4f),
):
    """Indexed variant of :func:`_compute_site_local_transforms`."""
    i = wp.tid()
    si = indices[i]
    prim_bid = site_body[si]
    if prim_bid == -1:
        prim_world = site_local[si]
    else:
        prim_world = wp.transform_multiply(body_q[prim_bid], site_local[si])
    parent_bid = parent_site_body[si]
    if parent_bid == -1:
        parent_world = parent_site_local[si]
    else:
        parent_world = wp.transform_multiply(body_q[parent_bid], parent_site_local[si])
    local_tf = wp.transform_multiply(wp.transform_inverse(parent_world), prim_world)
    out_pos[i] = wp.transform_get_translation(local_tf)
    q = wp.transform_get_rotation(local_tf)
    out_quat[i] = wp.vec4f(q[0], q[1], q[2], q[3])


@wp.kernel
def _write_site_local_from_local_poses(
    body_q: wp.array(dtype=wp.transformf),
    site_body: wp.array(dtype=wp.int32),
    parent_site_body: wp.array(dtype=wp.int32),
    parent_site_local: wp.array(dtype=wp.transformf),
    local_pos: wp.array(dtype=wp.vec3f),
    local_quat: wp.array(dtype=wp.vec4f),
    site_local: wp.array(dtype=wp.transformf),
):
    """Update site local offsets so that sites reach desired parent-relative poses."""
    i = wp.tid()
    parent_bid = parent_site_body[i]
    if parent_bid == -1:
        parent_world = parent_site_local[i]
    else:
        parent_world = wp.transform_multiply(body_q[parent_bid], parent_site_local[i])
    l_pos = local_pos[i]
    l_q = local_quat[i]
    local_tf = wp.transform(l_pos, wp.quatf(l_q[0], l_q[1], l_q[2], l_q[3]))
    desired_world = wp.transform_multiply(parent_world, local_tf)
    bid = site_body[i]
    if bid == -1:
        site_local[i] = desired_world
    else:
        site_local[i] = wp.transform_multiply(wp.transform_inverse(body_q[bid]), desired_world)


@wp.kernel
def _write_site_local_from_local_poses_indexed(
    body_q: wp.array(dtype=wp.transformf),
    site_body: wp.array(dtype=wp.int32),
    parent_site_body: wp.array(dtype=wp.int32),
    parent_site_local: wp.array(dtype=wp.transformf),
    indices: wp.array(dtype=wp.int32),
    local_pos: wp.array(dtype=wp.vec3f),
    local_quat: wp.array(dtype=wp.vec4f),
    site_local: wp.array(dtype=wp.transformf),
):
    """Indexed variant of :func:`_write_site_local_from_local_poses`."""
    i = wp.tid()
    si = indices[i]
    parent_bid = parent_site_body[si]
    if parent_bid == -1:
        parent_world = parent_site_local[si]
    else:
        parent_world = wp.transform_multiply(body_q[parent_bid], parent_site_local[si])
    l_pos = local_pos[i]
    l_q = local_quat[i]
    local_tf = wp.transform(l_pos, wp.quatf(l_q[0], l_q[1], l_q[2], l_q[3]))
    desired_world = wp.transform_multiply(parent_world, local_tf)
    bid = site_body[si]
    if bid == -1:
        site_local[si] = desired_world
    else:
        site_local[si] = wp.transform_multiply(wp.transform_inverse(body_q[bid]), desired_world)


[docs] class OvPhysxFrameView(BaseFrameView): """Batched prim view for non-physics prims tracked as sites on OVPhysX bodies. Each matched USD prim is resolved at init to a ``(body_index, site_local)`` pair via ancestor walk: the nearest ancestor carrying ``UsdPhysics.RigidBodyAPI`` becomes the attachment body, and the relative USD transform becomes the site offset. If no rigid-body ancestor exists, the prim is attached to the world frame (``body_index = WORLD_BODY_INDEX``) and ``site_local`` stores the prim's USD world transform. Body world poses are read each step via an OVPhysX ``RIGID_BODY_POSE`` tensor binding -- the same data path the contact sensor uses -- and **not** via the scene data provider's Newton model. This keeps the view usable in scenes that do not declare ``requires_newton_model=True``. World poses are computed on GPU as ``body_q[body_index] * site_local`` via a Warp kernel, with the world-attached branch returning ``site_local`` directly. Both :meth:`set_world_poses` and :meth:`set_local_poses` update the view-owned ``site_local`` buffer -- neither writes to the physics state. Scales and visibility delegate to an internal :class:`UsdFrameView` (lazy-constructed on first call). Getters return :class:`~isaaclab.utils.warp.ProxyArray`. Setters accept ``wp.array``. Limitations (v1): All resolved rigid-body ancestors (plus their USD parents for local-pose queries) must share a single env-wildcarded path pattern. Mixed body-types per view raise :class:`NotImplementedError`. The common case (one body type, wildcarded across envs) is fully supported. """
[docs] def __init__(self, prim_path: str, device: str = "cpu", stage: Usd.Stage | None = None, **kwargs): """Initialize the OVPhysX site-based frame view. Args: prim_path: USD prim path pattern (may contain regex). device: Warp device for GPU arrays (e.g. ``"cuda:0"``). stage: USD stage to search. Defaults to the current stage. **kwargs: Forwarded to the lazy internal :class:`UsdFrameView` (e.g. ``validate_xform_ops``); accepted for backend-agnostic kwarg passing through the :class:`FrameView` factory. """ self._prim_path = prim_path self._device = device self._kwargs = kwargs stage = sim_utils.get_current_stage() if stage is None else stage self._stage = stage sim = sim_utils.SimulationContext.instance() plan = sim.get_clone_plan() if sim is not None else None source_matches = tuple(cloner.query.iter_sources(plan, prim_path)) if plan is not None else () self._source_records = [] self._prims: list[Usd.Prim] = [] for source_root, destination_template, source_path, env_ids in source_matches: source_pattern = re.compile(source_path) source_prims = sim_utils.get_all_matching_child_prims( source_root, lambda prim: source_pattern.fullmatch(prim.GetPath().pathString) is not None, stage=stage, ) self._prims.extend(source_prims) self._source_records.extend((source_root, destination_template, prim, env_ids) for prim in source_prims) if not source_matches: self._prims = sim_utils.find_matching_prims(prim_path, stage=stage) if not self._prims: raise ValueError(f"OvPhysxFrameView: pattern {prim_path!r} matched zero prims.") # Lazy USD view for scales / visibility. self._usd_view: UsdFrameView | None = None # Try synchronous init; defer to PHYSICS_READY if the PhysX instance is not yet alive. physx = self._try_get_physx() if physx is not None: self._initialize_impl(physx) else: OvPhysxManager.register_callback( self._on_physics_ready, PhysicsEvent.PHYSICS_READY, name=f"ovphysx_frame_view_{prim_path}", )
@staticmethod def _try_get_physx() -> Any | None: """Return the active OVPhysX ``PhysX`` instance, or ``None`` if not yet created.""" return OvPhysxManager.get_physx_instance() def _on_physics_ready(self, _event) -> None: """Replace any prior root view when the OVPhysX ``PhysX`` instance becomes ready.""" physx = self._try_get_physx() if physx is None: raise RuntimeError("OvPhysxFrameView: PHYSICS_READY fired but OvPhysxManager has no PhysX instance.") previous_root_view = getattr(self, "_root_view", None) if previous_root_view is not None: previous_root_view.close() self._root_view = None self._pose_binding = None self._initialize_impl(physx) def _initialize_impl(self, physx: Any) -> None: """Resolve prims to rigid-body ancestors and create a RIGID_BODY_POSE tensor binding. With a ClonePlan, site discovery reads only its authored source prims, whether or not destination USD prims exist. The RIGID_BODY_POSE binding is the source of truth for the site count, and per-env site paths are synthesized from the source prim paths. """ from isaaclab_ov import tensor_types as TT # noqa: PLC0415 from isaaclab_ov.sim.views.ovphysx_view import OvPhysxView # noqa: PLC0415 xform_cache = UsdGeom.XformCache(Usd.TimeCode.Default()) identity_xform7 = [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0] # 0. Reject prim_paths that resolve to a rigid body itself: a FrameView # should track a non-physics child of a body (a sensor frame), not # the body. Mirrors the Newton guard at # ``newton_site_frame_view.py:572-584``. for prim in self._prims: if self._prim_or_template_has_rigid_body_api(prim): raise ValueError( f"OvPhysxFrameView prim '{prim.GetPath().pathString}' resolves to a rigid body. " "FrameView should only be used for non-physics prims (cameras, sensor mounts, " "Xform markers). Use OvPhysX's RigidObject or Articulation APIs to control " "physics bodies directly, or point prim_path at a non-physics child of the body." ) # 1. Resolve each (template) prim's ancestor body + the prim->ancestor offset. per_prim_ancestor: list[str | None] = [] per_prim_site_local: list[list[float]] = [] for prim in self._prims: ap, sl = self._resolve_rigid_body_ancestor(prim, xform_cache) per_prim_ancestor.append(ap) per_prim_site_local.append(sl) # 2. Same resolution for each prim's USD parent (used by local-pose queries). parent_ancestor: list[str | None] = [] parent_site_local: list[list[float]] = [] for prim in self._prims: parent = prim.GetParent() if parent and parent.IsValid() and parent.GetPath().pathString != "/": pap, psl = self._resolve_rigid_body_ancestor(parent, xform_cache) else: pap, psl = None, identity_xform7 parent_ancestor.append(pap) parent_site_local.append(psl) # 3. Dedup discovered ancestor paths into env-wildcarded patterns (one binding per pattern). all_ancestors = [p for p in (per_prim_ancestor + parent_ancestor) if p is not None] patterns = sorted({self._env_wildcardify(p) for p in all_ancestors}) if len(patterns) > 1: raise NotImplementedError( f"OvPhysxFrameView v1 supports a single body-type pattern; resolved {len(patterns)}" f" patterns under prim_path={self._prim_path!r}: {patterns}." ) # 4. Create the RIGID_BODY_POSE binding (or operate in world-only mode). if patterns: pattern = patterns[0] self._root_view = OvPhysxView(physx, pattern=pattern, device=self._device) # ``try_binding_for`` returns None for a zero-match binding (the view rejects a # 0-count binding); surface that as the explicit zero-bodies error below. self._pose_binding = self._root_view.try_binding_for(TT.RIGID_BODY_POSE) if self._pose_binding is None: raise RuntimeError( f"OvPhysxFrameView: RIGID_BODY_POSE binding for pattern {pattern!r} matched zero bodies." ) self._pose_buf = wp.zeros(self._pose_binding.shape, dtype=wp.float32, device=self._device) binding_paths: list[str] = list(self._pose_binding.prim_paths) else: # All prims resolved as world-attached: no binding needed; kernels only hit the -1 branch. self._root_view = None self._pose_binding = None self._pose_buf = wp.zeros((1, 7), dtype=wp.float32, device=self._device) binding_paths = [] world_sites = self._expand_world_sites_from_clone_plan(xform_cache) if not binding_paths else [] # 5. Expand source prim data to one entry per binding row. if binding_paths and len(binding_paths) > len(self._prims): template_ancestor = per_prim_ancestor[0] template_site_local = per_prim_site_local[0] template_parent_ancestor = parent_ancestor[0] template_parent_site_local = parent_site_local[0] template_path = self._prims[0].GetPath().pathString per_prim_ancestor = [] per_prim_site_local = [] parent_ancestor = [] parent_site_local = [] synthetic_prim_paths: list[str] = [] for body_path in binding_paths: env_match = re.search(r"/World/envs/env_(\d+)", body_path) env_token = env_match.group(0) if env_match else None # Re-target the template path's env segment to this row's env_id. if env_token is not None: synthetic_path = re.sub(r"/World/envs/env_\d+", env_token, template_path) ap = re.sub(r"/World/envs/env_\d+", env_token, template_ancestor) if template_ancestor else None pap = ( re.sub(r"/World/envs/env_\d+", env_token, template_parent_ancestor) if template_parent_ancestor else None ) else: synthetic_path = template_path ap = template_ancestor pap = template_parent_ancestor per_prim_ancestor.append(ap) per_prim_site_local.append(template_site_local) parent_ancestor.append(pap) parent_site_local.append(template_parent_site_local) synthetic_prim_paths.append(synthetic_path) self._synthetic_prim_paths: list[str] | None = synthetic_prim_paths self._prims = [self._prims[0]] * len(binding_paths) elif world_sites: _, self._prims, per_prim_site_local, parent_site_local, synthetic_paths = map( list, zip(*world_sites, strict=True) ) per_prim_ancestor = [None] * len(world_sites) parent_ancestor = [None] * len(world_sites) self._synthetic_prim_paths = synthetic_paths else: self._synthetic_prim_paths = None # 6. Build site_body and parent_site_body indices into the binding's row order. path_to_row = {p: i for i, p in enumerate(binding_paths)} site_bodies = [ path_to_row.get(ap, WORLD_BODY_INDEX) if ap is not None else WORLD_BODY_INDEX for ap in per_prim_ancestor ] parent_bodies = [ path_to_row.get(pap, WORLD_BODY_INDEX) if pap is not None else WORLD_BODY_INDEX for pap in parent_ancestor ] # 7. Allocate Warp arrays. device = self._device self._site_body = wp.array(site_bodies, dtype=wp.int32, device=device) self._site_local = wp.array([wp.transform(*x) for x in per_prim_site_local], dtype=wp.transformf, device=device) self._parent_site_body = wp.array(parent_bodies, dtype=wp.int32, device=device) self._parent_site_local = wp.array( [wp.transform(*x) for x in parent_site_local], dtype=wp.transformf, device=device ) count = len(per_prim_ancestor) self._pos_buf = wp.zeros(count, dtype=wp.vec3f, device=device) self._quat_buf = wp.zeros(count, dtype=wp.vec4f, device=device) self._local_pos_buf = wp.zeros(count, dtype=wp.vec3f, device=device) self._local_quat_buf = wp.zeros(count, dtype=wp.vec4f, device=device) self._pos_ta = ProxyArray(self._pos_buf) self._quat_ta = ProxyArray(self._quat_buf) self._local_pos_ta = ProxyArray(self._local_pos_buf) self._local_quat_ta = ProxyArray(self._local_quat_buf) def _expand_world_sites_from_clone_plan( self, xform_cache: UsdGeom.XformCache ) -> list[tuple[int, Usd.Prim, list[float], list[float], str]]: """Return row-ordered source prims and projected poses for source-only world sites.""" if sum(len(env_ids) for _, _, _, env_ids in self._source_records) <= len(self._prims): return [] records: list[tuple[int, Usd.Prim, list[float], list[float], str]] = [] for source_root, destination_template, source_prim, env_ids in self._source_records: source_prim_path = source_prim.GetPath().pathString suffix = cloner.path.relative_to(source_prim_path, source_root) if suffix is None: raise RuntimeError(f"OvPhysxFrameView source prim {source_prim_path!r} is not under {source_root!r}.") source_world = xform_cache.GetLocalToWorldTransform(source_prim) source_parent_world = xform_cache.GetLocalToWorldTransform(source_prim.GetParent()) for env_id in env_ids: destination_root = destination_template.format(env_id) source_anchor_path, destination_anchor_path = source_root, destination_root destination_anchor = self._stage.GetPrimAtPath(destination_anchor_path) while not destination_anchor.IsValid() and destination_anchor_path != "/": source_anchor_path = source_anchor_path.rsplit("/", 1)[0] or "/" destination_anchor_path = destination_anchor_path.rsplit("/", 1)[0] or "/" destination_anchor = self._stage.GetPrimAtPath(destination_anchor_path) source_anchor = self._stage.GetPrimAtPath(source_anchor_path) if not source_anchor.IsValid() or not destination_anchor.IsValid(): raise RuntimeError(f"OvPhysxFrameView could not project {source_prim_path!r} into env {env_id}.") source_inverse = xform_cache.GetLocalToWorldTransform(source_anchor).GetInverse() destination_world = xform_cache.GetLocalToWorldTransform(destination_anchor) site_world = _gf_matrix_to_xform7(source_world * source_inverse * destination_world) parent_world = _gf_matrix_to_xform7(source_parent_world * source_inverse * destination_world) records.append((env_id, source_prim, site_world, parent_world, destination_root + suffix)) records.sort(key=lambda record: record[0]) return records def _resolve_rigid_body_ancestor( self, prim: Usd.Prim, xform_cache: UsdGeom.XformCache, ) -> tuple[str | None, list[float]]: """Walk USD ancestors to find the nearest prim with ``UsdPhysics.RigidBodyAPI``. Under OVPhysX scenes built with ``clone_usd=False`` (the default for :class:`~isaaclab.scene.InteractiveScene`), only ``env_0`` carries the authored RigidBodyAPI -- ``env_1..N`` exist only as physics-layer clones and the corresponding USD prims (when present) are untyped Xforms. :meth:`_prim_or_template_has_rigid_body_api` handles this by checking the prim's env_0 equivalent when the API is not directly applied. Returns: ``(ancestor_path, [tx, ty, tz, qx, qy, qz, qw])``. ``ancestor_path`` is ``None`` when no rigid-body ancestor exists; the local transform in that case is the prim's world USD transform. """ prim_world_tf = xform_cache.GetLocalToWorldTransform(prim) prim_world_tf.Orthonormalize() # If the prim itself is a rigid body (directly or via env_0 template), the site offset is identity. if self._prim_or_template_has_rigid_body_api(prim): return prim.GetPath().pathString, [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0] ancestor = prim.GetParent() while ancestor and ancestor.IsValid() and ancestor.GetPath().pathString != "/": if self._prim_or_template_has_rigid_body_api(ancestor): ancestor_world_tf = xform_cache.GetLocalToWorldTransform(ancestor) ancestor_world_tf.Orthonormalize() local_tf = prim_world_tf * ancestor_world_tf.GetInverse() return ancestor.GetPath().pathString, _gf_matrix_to_xform7(local_tf) ancestor = ancestor.GetParent() return None, _gf_matrix_to_xform7(prim_world_tf) def _prim_or_template_has_rigid_body_api(self, prim: Usd.Prim) -> bool: """Return whether the prim (or its ``env_0`` equivalent) has ``RigidBodyAPI`` applied. Falls back to the env_0 template lookup so that ``clone_usd=False`` envs (whose USD prims lack physics schemas) still resolve to the right body. """ if prim.HasAPI(UsdPhysics.RigidBodyAPI): return True path = prim.GetPath().pathString env_zero_path = self._env_zero_equivalent(path) if env_zero_path == path: return False template_prim = self._stage.GetPrimAtPath(env_zero_path) if self._stage is not None else None if template_prim is None or not template_prim.IsValid(): return False return template_prim.HasAPI(UsdPhysics.RigidBodyAPI) @staticmethod def _env_zero_equivalent(path: str) -> str: """Replace ``/World/envs/env_<digits>`` with ``/World/envs/env_0`` for template lookup.""" return re.sub(r"/World/envs/env_\d+", "/World/envs/env_0", path) @staticmethod def _env_wildcardify(path: str) -> str: """Replace ``/World/envs/env_<digits>`` with ``/World/envs/env_*`` for binding patterns.""" return re.sub(r"/World/envs/env_\d+", "/World/envs/env_*", path) # ------------------------------------------------------------------ # Properties # ------------------------------------------------------------------ @property def prims(self) -> list[Usd.Prim]: """List of one authored USD prim per site. Source-only clones repeat their source prim handle so the list stays aligned with the view count; prim_paths contains their logical destination paths. """ return self._prims @property def prim_paths(self) -> list[str]: """List of one prim path per site. For ``clone_usd=False`` scenes (where ``env_1..N`` have no USD prim) the paths are synthesized by replacing ``env_0`` in the template prim's path with each binding row's env_id. """ if hasattr(self, "_synthetic_prim_paths") and self._synthetic_prim_paths is not None: return self._synthetic_prim_paths if not hasattr(self, "_prim_paths_cache"): self._prim_paths_cache = [p.GetPath().pathString for p in self._prims] return self._prim_paths_cache @property def count(self) -> int: """Number of sites in this view (one per binding row, or per matched prim in world-only mode).""" if hasattr(self, "_site_body"): return int(self._site_body.shape[0]) return len(self._prims) @property def device(self) -> str: """Device where arrays are allocated (``"cpu"`` or ``"cuda:0"``).""" return self._device # ------------------------------------------------------------------ # Initialization guard for deferred-init users # ------------------------------------------------------------------ def _require_initialized(self) -> None: if not hasattr(self, "_site_body"): raise RuntimeError( "OvPhysxFrameView used before initialization. The view defers initialization " "until OvPhysxManager dispatches PhysicsEvent.PHYSICS_READY. Step the " "simulation once (or wait for physics to be ready) before calling pose methods." ) def _current_body_q(self) -> wp.array: """Refresh and return the body-pose array sourced from the OVPhysX tensor binding. Reads ``RIGID_BODY_POSE`` data into ``self._pose_buf`` and returns a ``wp.transformf`` view. When no rigid-body ancestors were resolved at init time (every prim was world-attached), the binding is ``None`` and the returned view is a single-element placeholder buffer -- kernels access it only via the world-attached (``site_body[i] == -1``) branch. Returns: ``wp.array(dtype=wp.transformf)`` -- a view over the binding-pose buffer ``[num_bodies]``. """ if self._pose_binding is not None: self._root_view.read_into("rigid_body_pose", self._pose_buf) return self._pose_buf.view(wp.transformf) # ------------------------------------------------------------------ # World / local pose APIs (Tasks 5 & 6) # ------------------------------------------------------------------ # ------------------------------------------------------------------ # World poses # ------------------------------------------------------------------ # ------------------------------------------------------------------ # Writer factory hooks (pass-through; OvPhysX has no separate Fabric storage) # ------------------------------------------------------------------ def _make_world_space_writer(self) -> FrameViewWorldSpaceWriter: return _OvPhysxWorldSpaceWriter(self) def _make_local_space_writer(self) -> FrameViewLocalSpaceWriter: return _OvPhysxLocalSpaceWriter(self) # ------------------------------------------------------------------ # Backend hooks # ------------------------------------------------------------------ def _get_world_poses_impl(self, indices: wp.array | None = None) -> tuple[ProxyArray, ProxyArray]: """Get world-space positions and orientations.""" self._require_initialized() body_q = self._current_body_q() if indices is not None: n = len(indices) pos_buf = wp.zeros(n, dtype=wp.vec3f, device=self._device) quat_buf = wp.zeros(n, dtype=wp.vec4f, device=self._device) wp.launch( _compute_site_world_transforms_indexed, dim=n, inputs=[body_q, self._site_body, self._site_local, indices], outputs=[pos_buf, quat_buf], device=self._device, ) return ProxyArray(pos_buf), ProxyArray(quat_buf) wp.launch( _compute_site_world_transforms, dim=self.count, inputs=[body_q, self._site_body, self._site_local], outputs=[self._pos_buf, self._quat_buf], device=self._device, ) return self._pos_ta, self._quat_ta def _apply_world_pose_write( self, positions: wp.array | None = None, orientations: wp.array | None = None, indices: wp.array | None = None, ) -> None: """Set world-space positions and/or orientations.""" if positions is None and orientations is None: return self._require_initialized() body_q = self._current_body_q() if positions is None or orientations is None: cur_pos_ta, cur_quat_ta = self._get_world_poses_impl(indices) if positions is None: positions = cur_pos_ta.warp if orientations is None: orientations = cur_quat_ta.warp if indices is not None: wp.launch( _write_site_local_from_world_poses_indexed, dim=len(indices), inputs=[body_q, self._site_body, indices, positions, orientations, self._site_local], device=self._device, ) else: wp.launch( _write_site_local_from_world_poses, dim=self.count, inputs=[body_q, self._site_body, positions, orientations, self._site_local], device=self._device, ) # ------------------------------------------------------------------ # Local poses (parent-relative) # ------------------------------------------------------------------ def _get_local_poses_impl(self, indices: wp.array | None = None) -> tuple[ProxyArray, ProxyArray]: """Get parent-relative positions and orientations.""" self._require_initialized() body_q = self._current_body_q() if indices is not None: n = len(indices) pos_buf = wp.zeros(n, dtype=wp.vec3f, device=self._device) quat_buf = wp.zeros(n, dtype=wp.vec4f, device=self._device) wp.launch( _compute_site_local_transforms_indexed, dim=n, inputs=[ body_q, self._site_body, self._site_local, self._parent_site_body, self._parent_site_local, indices, ], outputs=[pos_buf, quat_buf], device=self._device, ) return ProxyArray(pos_buf), ProxyArray(quat_buf) wp.launch( _compute_site_local_transforms, dim=self.count, inputs=[ body_q, self._site_body, self._site_local, self._parent_site_body, self._parent_site_local, ], outputs=[self._local_pos_buf, self._local_quat_buf], device=self._device, ) return self._local_pos_ta, self._local_quat_ta def _apply_local_pose_write( self, translations: wp.array | None = None, orientations: wp.array | None = None, indices: wp.array | None = None, ) -> None: """Set parent-relative translations and/or orientations.""" if translations is None and orientations is None: return self._require_initialized() body_q = self._current_body_q() if translations is None or orientations is None: cur_pos_ta, cur_quat_ta = self._get_local_poses_impl(indices) if translations is None: translations = cur_pos_ta.warp if orientations is None: orientations = cur_quat_ta.warp if indices is not None: wp.launch( _write_site_local_from_local_poses_indexed, dim=len(indices), inputs=[ body_q, self._site_body, self._parent_site_body, self._parent_site_local, indices, translations, orientations, self._site_local, ], device=self._device, ) else: wp.launch( _write_site_local_from_local_poses, dim=self.count, inputs=[ body_q, self._site_body, self._parent_site_body, self._parent_site_local, translations, orientations, self._site_local, ], device=self._device, ) # ------------------------------------------------------------------ # Scales & visibility -- delegate to UsdFrameView # ------------------------------------------------------------------ def _ensure_usd_view(self) -> UsdFrameView: if self._usd_view is None: self._usd_view = UsdFrameView( self._prim_path, device=self._device, validate_xform_ops=self._kwargs.get("validate_xform_ops", True), stage=self._stage, ) return self._usd_view def _get_local_scales_impl(self, indices: wp.array | None = None) -> ProxyArray: """Get local-space scales (xformOp:scale) via the USD view. .. note:: This reads the *static* USD authored value, not a live physics-state value. OVPhysX does not maintain a per-shape ``shape_scale`` array equivalent to Newton's ``model.shape_scale``, so sim-driven scale updates are not reflected here. """ return self._ensure_usd_view()._get_local_scales_impl(indices) def _get_world_scales_impl(self, indices: wp.array | None = None) -> ProxyArray: """Get world-space (composed) scales via the USD view.""" return self._ensure_usd_view()._get_world_scales_impl(indices) def _apply_local_scale_write(self, scales: wp.array, indices: wp.array | None = None) -> None: """Set local-space scales (xformOp:scale) via the USD view. .. note:: The write lands in the USD stage but does *not* propagate to any OVPhysX-side collision-shape scale. PhysX is unaffected; this is a stage-only annotation. """ self._ensure_usd_view()._apply_local_scale_write(scales, indices) def _apply_world_scale_write(self, scales: wp.array, indices: wp.array | None = None) -> None: """Set world-space scales via the USD view.""" self._ensure_usd_view()._apply_world_scale_write(scales, indices) def _get_scales_impl(self, indices=None): """OvPhysX legacy: deprecated get_scales returns local scales.""" return self._get_local_scales_impl(indices) def _set_scales_impl(self, scales, indices=None): """OvPhysX legacy: deprecated set_scales writes local scales via a one-shot writer scope.""" with self.xform_local_space_writer() as writer: writer.set_scales(scales, indices) def get_visibility(self, indices: wp.array | None = None): """Get visibility for prims in the view (USD-backed). Note: OVPhysX runs without a Kit renderer, so visibility reads return the static USD stage state. Writes succeed at the USD layer but produce no visible change. """ return self._ensure_usd_view().get_visibility(indices) def set_visibility(self, visibility, indices: wp.array | None = None) -> None: """Set visibility for prims in the view (USD-backed; no renderer effect under OVPhysX).""" self._ensure_usd_view().set_visibility(visibility, indices)
def _gf_matrix_to_xform7(mat: Gf.Matrix4d) -> list[float]: """Convert a ``Gf.Matrix4d`` to ``[tx, ty, tz, qx, qy, qz, qw]``.""" mat.Orthonormalize() t = mat.ExtractTranslation() q = mat.ExtractRotationQuat() imag = q.GetImaginary() return [float(t[0]), float(t[1]), float(t[2]), float(imag[0]), float(imag[1]), float(imag[2]), float(q.GetReal())] # ---------------------------------------------------------------------- # Pass-through writer classes # ---------------------------------------------------------------------- class _OvPhysxWorldSpaceWriter(FrameViewWorldSpaceWriter): """OvPhysX world-space writer: pass-through to backend ``_apply_*`` hooks.""" def set_poses(self, positions=None, orientations=None, indices=None) -> None: self._view._apply_world_pose_write(positions, orientations, indices) # type: ignore[attr-defined] def set_scales(self, scales, indices=None) -> None: self._view._apply_world_scale_write(scales, indices) # type: ignore[attr-defined] def get_poses(self, indices=None) -> tuple[ProxyArray, ProxyArray]: return self._view._get_world_poses_impl(indices) # type: ignore[attr-defined] def get_scales(self, indices=None) -> ProxyArray: return self._view._get_world_scales_impl(indices) # type: ignore[attr-defined] class _OvPhysxLocalSpaceWriter(FrameViewLocalSpaceWriter): """OvPhysX local-space writer: pass-through to backend ``_apply_*`` hooks.""" def set_poses(self, positions=None, orientations=None, indices=None) -> None: self._view._apply_local_pose_write(positions, orientations, indices) # type: ignore[attr-defined] def set_scales(self, scales, indices=None) -> None: self._view._apply_local_scale_write(scales, indices) # type: ignore[attr-defined] def get_poses(self, indices=None) -> tuple[ProxyArray, ProxyArray]: return self._view._get_local_poses_impl(indices) # type: ignore[attr-defined] def get_scales(self, indices=None) -> ProxyArray: return self._view._get_local_scales_impl(indices) # type: ignore[attr-defined]