Source code for isaaclab_ov.sensors.ray_caster.ray_caster

# 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 ray-caster sensors -- mixin + concrete RayCaster class."""

from __future__ import annotations

import contextlib
import logging
from types import SimpleNamespace
from typing import Any

import torch
import warp as wp

from pxr import UsdPhysics

import isaaclab.sim as sim_utils
from isaaclab.sensors.ray_caster.base_ray_caster import BaseRayCaster
from isaaclab.sensors.ray_caster.kernels import copy_mesh_transforms_to_table_kernel

from isaaclab_ov.physics import OvPhysxManager

logger = logging.getLogger(__name__)


def _find_physics_ancestor(prim):
    """Return the nearest ancestor of ``prim`` that carries ``UsdPhysics.RigidBodyAPI``.

    Walks upward from ``prim`` itself. Returns ``None`` if no ancestor in the
    USD hierarchy applies the API.
    """
    ancestor = prim
    while ancestor and ancestor.IsValid() and ancestor.GetPath().pathString != "/":
        if ancestor.HasAPI(UsdPhysics.RigidBodyAPI):
            return ancestor
        ancestor = ancestor.GetParent()
    return None


def _ovphysx_body_glob(body_expr: str) -> str:
    """Convert internal env regex/template expressions to ovphysx glob syntax.

    The ovphysx wheel's ``create_tensor_binding`` ``pattern=`` argument is an
    fnmatch glob, so ``{}`` template placeholders and ``.*`` regex segments
    both map to ``*``.
    """
    return sim_utils.path_expr_to_glob(body_expr.replace("{}", "*"))


class _OvPhysxRayCasterMixin:
    """OVPhysX pose tracking for ray-caster sensors.

    Lives as a multiple-inheritance mixin on top of the four
    :class:`~isaaclab.sensors.ray_caster.Base*` classes. Provides backend-
    specific pose tracking via the ovphysx ``RIGID_BODY_POSE`` tensor binding
    when the sensor prim has a rigid-body ancestor, or a one-time USD pose
    snapshot for non-physics sensor frames.

    All backend-specific surface is centralized here so the four concrete
    sensor classes can be 14-line composition modules.
    """

    @property
    def count(self: Any) -> int:
        """Number of tracked sensor frames (binding row count, or static count)."""
        return self._view_count

    def _initialize_pose_tracking(self: Any) -> None:
        """Resolve sensor prims to either a live ovphysx binding or a static snapshot."""
        from isaaclab_ov import tensor_types as TT  # noqa: PLC0415

        # Generic base-class hooks read ``self._view.count``; point that
        # adapter at the sensor itself rather than constructing a separate
        # view object (matches the PhysX trick).
        self._view = self

        try:
            body_expr, fixed_pos_b, fixed_quat_b = self._resolve_rigid_body_ancestor_expr()
        except RuntimeError:
            prims = sim_utils.find_matching_prims(self.cfg.prim_path)
            if len(prims) == 0:
                raise
            body = _find_physics_ancestor(prims[0])
            if body is None:
                self._initialize_static_pose_tracking(prims)
                return
            raise

        body_glob = _ovphysx_body_glob(body_expr)

        physx = OvPhysxManager.get_physx_instance()
        if physx is None:
            raise RuntimeError(
                "OvPhysxManager has no PhysX instance yet -- sensor was constructed before "
                "PhysicsEvent.PHYSICS_READY. Ensure the simulation has been reset at least once."
            )

        self._ovphysx_body_view = physx.create_tensor_binding(
            pattern=body_glob,
            tensor_type=TT.RIGID_BODY_POSE,
        )
        if self._ovphysx_body_view.shape[0] == 0:
            raise RuntimeError(f"OVPhysX RIGID_BODY_POSE binding for pattern {body_glob!r} matched zero bodies.")

        self._view_count = int(self._ovphysx_body_view.shape[0])
        self._pose_buf = wp.zeros(self._ovphysx_body_view.shape, dtype=wp.float32, device=self._device)
        # Zero-copy reinterpret of the ``(N, 7)`` float32 staging buffer as
        # ``(N,)`` ``wp.transformf``. Cached so per-step
        # ``_get_view_transforms_wp`` reads don't churn allocations.
        self._pose_buf_transformf = wp.array(
            ptr=self._pose_buf.ptr,
            shape=(self._view_count,),
            dtype=wp.transformf,
            device=str(self._pose_buf.device),
            copy=False,
        )

        if fixed_pos_b is None or fixed_quat_b is None:
            fixed_pos_b = (0.0, 0.0, 0.0)
            fixed_quat_b = (0.0, 0.0, 0.0, 1.0)
        offset_pos = [fixed_pos_b] * self._view_count
        offset_quat = [fixed_quat_b] * self._view_count
        self._offset_pos_wp = wp.array(offset_pos[: self._view_count], dtype=wp.vec3f, device=self._device)
        self._offset_quat_contiguous = torch.tensor(
            offset_quat[: self._view_count], dtype=torch.float32, device=self._device
        )
        self._offset_quat_wp = wp.from_torch(self._offset_quat_contiguous, dtype=wp.quatf)
        self._mesh_view_bufs = {}

    def _initialize_static_pose_tracking(self: Any, prims) -> None:
        """Cache authored USD poses for non-physics sensor frames.

        Used when the sensor prim has no rigid-body ancestor (e.g. an Xform
        marker under ``/World``). The cached poses are returned every frame
        unchanged -- static prims don't move.
        """
        poses = []
        for prim in prims:
            pos, quat = sim_utils.resolve_prim_pose(prim)
            poses.append((*pos, *quat))
        self._static_view_transforms_torch = torch.tensor(poses, dtype=torch.float32, device=self._device).contiguous()
        self._static_view_transforms_wp = wp.from_torch(self._static_view_transforms_torch).view(wp.transformf)
        self._ovphysx_body_view = None
        self._view_count = len(prims)
        self._offset_pos_wp = wp.zeros(self._view_count, dtype=wp.vec3f, device=self._device)
        identity_quat = torch.zeros(self._view_count, 4, device=self._device)
        identity_quat[:, 3] = 1.0
        self._offset_quat_contiguous = identity_quat.contiguous()
        self._offset_quat_wp = wp.from_torch(self._offset_quat_contiguous, dtype=wp.quatf)
        self._mesh_view_bufs = {}

    def _get_view_transforms_wp(self: Any) -> wp.array:
        """Return tracked sensor-frame transforms as a ``wp.transformf`` array.

        Live path reads the ovphysx binding into the cached staging buffer
        every call; static path returns the cached snapshot directly.
        """
        if self._ovphysx_body_view is None:
            return self._static_view_transforms_wp
        self._ovphysx_body_view.read(self._pose_buf)
        return self._pose_buf_transformf

    def get_world_poses(self: Any, indices=None):
        """Return world poses as ``(positions, orientations)`` pose tuples.

        Camera-derived base classes inheriting this mixin call this method
        and read ``.torch`` on the returned objects. We mirror PhysX's
        :class:`SimpleNamespace` shape so the contract is identical.
        """
        transforms = self._get_view_transforms_wp()
        transforms_t = wp.to_torch(transforms).reshape(-1, 7)
        if indices is not None:
            idx = wp.to_torch(indices).to(dtype=torch.long) if isinstance(indices, wp.array) else indices
            transforms_t = transforms_t[idx]
        return SimpleNamespace(torch=transforms_t[:, 0:3]), SimpleNamespace(torch=transforms_t[:, 3:7])

    def _create_tracked_target_view(self: Any, target_prim_paths: str | list[str]):
        """Create an ovphysx RIGID_BODY_POSE binding for dynamic multi-mesh targets.

        v1 limitation: target paths must dedup to a single env-wildcard
        pattern. Multi-pattern targets raise ``NotImplementedError`` -- the
        same limit the OVPhysX ``ContactSensor`` already documents for
        ``track_pose``.
        """
        from isaaclab_ov import tensor_types as TT  # noqa: PLC0415

        if isinstance(target_prim_paths, str):
            target_prim_paths = [target_prim_paths]
        if not target_prim_paths:
            raise RuntimeError(f"No tracked target bodies resolved from: {target_prim_paths}")

        patterns = sorted({_ovphysx_body_glob(path) for path in target_prim_paths})
        if len(patterns) > 1:
            raise NotImplementedError(
                f"OvPhysxRayCaster v1 supports a single body-type pattern for dynamic targets; "
                f"resolved {len(patterns)} patterns: {patterns}. Multi-pattern targets require "
                "per-pattern bindings and an interleaved-read kernel that does not exist yet."
            )

        physx = OvPhysxManager.get_physx_instance()
        if physx is None:
            raise RuntimeError(
                "OvPhysxManager has no PhysX instance yet -- multi-mesh target view requested "
                "before PhysicsEvent.PHYSICS_READY."
            )
        return physx.create_tensor_binding(pattern=patterns[0], tensor_type=TT.RIGID_BODY_POSE)

    def _update_mesh_transforms(self: Any) -> None:
        """Refresh dynamic multi-mesh target poses from their ovphysx bindings."""
        if not hasattr(self, "_mesh_views"):
            return
        mesh_idx = 0
        for view, target_cfg in zip(self._mesh_views, self._raycast_targets_cfg):
            if not target_cfg.track_mesh_transforms:
                mesh_idx += self._num_meshes_per_env[target_cfg.prim_expr]
                continue

            # ``view`` here is an ovphysx TensorBinding produced by
            # :meth:`_create_tracked_target_view`. Each binding owns its own
            # staging buffer cached in ``self._mesh_view_bufs`` (initialized
            # in :meth:`_initialize_pose_tracking`).
            buf = self._mesh_view_bufs.get(id(view))
            if buf is None:
                buf = wp.zeros(view.shape, dtype=wp.float32, device=self._device)
                self._mesh_view_bufs[id(view)] = buf

            view.read(buf)
            transforms_wp = wp.array(
                ptr=buf.ptr,
                shape=(int(view.shape[0]),),
                dtype=wp.transformf,
                device=str(buf.device),
                copy=False,
            )

            view_count = int(view.shape[0])
            meshes_per_env = view_count
            if view_count != 1:
                meshes_per_env = view_count // self._num_envs

            wp.launch(
                copy_mesh_transforms_to_table_kernel,
                dim=(self._num_envs, meshes_per_env),
                inputs=[
                    transforms_wp,
                    int(meshes_per_env),
                    int(mesh_idx),
                    bool(view_count == 1),
                    self._mesh_positions_w,
                    self._mesh_orientations_w,
                ],
                device=self._device,
            )
            mesh_idx += self._num_meshes_per_env[target_cfg.prim_expr]

    def _invalidate_initialize_callback(self: Any, event) -> None:
        """Release ovphysx native handles when the simulation stops."""
        super()._invalidate_initialize_callback(event)
        view = getattr(self, "_ovphysx_body_view", None)
        if view is not None:
            with contextlib.suppress(Exception):
                view.destroy()
        self._ovphysx_body_view = None

        for buf_view in getattr(self, "_mesh_views", []) or []:
            with contextlib.suppress(Exception):
                buf_view.destroy()
        self._mesh_views = []
        self._mesh_view_bufs = {}


[docs] class RayCaster(_OvPhysxRayCasterMixin, BaseRayCaster): """OVPhysX RayCaster implementation."""