Source code for isaaclab.utils.warp.particle_mesh

# 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

"""Counting particles inside closed meshes using Warp point-mesh queries.

This module provides :class:`ParticleMeshCounter`, a fast, solver-agnostic utility for counting
how many particles fall inside one or more closed (watertight) *region* meshes. It is intended for
training-time, privileged measurements such as "how many MPM media particles are inside the scoop
bowl / the source container / the target container" without relying on hand-tuned analytic regions.

The counter is built on Warp's BVH-accelerated point-mesh query
(:func:`warp.mesh_query_point_sign_winding_number`): each particle is transformed into a region's
local frame and tested for containment via the mesh winding number. The winding-number sign method
is robust for poorly conditioned, non-watertight meshes, which makes it a good default for region
geometry that is generated procedurally or extracted from USD assets.

Region meshes are static in their own local frame; only their per-environment world (or environment)
transform changes from step to step, so the BVH is built once and reused. The cost is therefore
``O(num_envs * num_particles * num_regions)`` queries, each ``O(log(num_faces))`` on the GPU.

The :func:`make_box_region_mesh` and :func:`make_frustum_region_mesh` helpers build watertight,
outward-oriented region meshes for the two most common regions of interest (axis-aligned boxes and
capped circular frusta / cup cavities).
"""

from __future__ import annotations

from collections.abc import Sequence

import numpy as np
import torch
import warp as wp

from .proxy_array import ProxyArray


@wp.kernel
def count_particles_in_meshes_kernel(
    particle_pos: wp.array2d(dtype=wp.vec3),
    region_mesh_ids: wp.array(dtype=wp.uint64),
    region_pos: wp.array2d(dtype=wp.vec3),
    region_quat: wp.array2d(dtype=wp.quat),
    max_query_dist: wp.float32,
    inside: wp.array3d(dtype=wp.float32),
):
    """Mark, per environment/particle/region, whether the particle is inside the region mesh.

    The thread grid is ``(num_envs, num_particles, num_regions)``. Each particle position is
    transformed into the region's local frame using the region's rigid transform and tested for
    containment with the mesh winding number.

    Args:
        particle_pos: Particle positions in a common frame, shape ``(num_envs, num_particles)``.
        region_mesh_ids: Warp mesh ids of the region meshes, shape ``(num_regions,)``.
        region_pos: Region origins in the same frame as ``particle_pos``, shape
            ``(num_regions, num_envs)``.
        region_quat: Region orientations as ``(x, y, z, w)`` quaternions, shape
            ``(num_regions, num_envs)``.
        max_query_dist: Maximum distance for the closest-point search [m].
        inside: Output containment flags (``1.0`` inside, ``0.0`` outside), shape
            ``(num_envs, num_particles, num_regions)``.
    """
    env_id, particle_id, region_id = wp.tid()
    point = particle_pos[env_id, particle_id]
    flag = wp.float32(0.0)
    region_tf = wp.transform(region_pos[region_id, env_id], region_quat[region_id, env_id])
    point_local = wp.transform_point(wp.transform_inverse(region_tf), point)
    query = wp.mesh_query_point_sign_winding_number(region_mesh_ids[region_id], point_local, max_query_dist)
    # Warp convention: a negative winding-number sign means the point is inside the mesh.
    if query.result and query.sign < 0.0:
        flag = wp.float32(1.0)
    inside[env_id, particle_id, region_id] = flag


[docs] class ParticleMeshCounter: """Counts particles inside closed region meshes using Warp winding-number point queries. The counter owns one Warp mesh per region and, on every :meth:`count` call, transforms each environment's particles into each region's local frame to test containment. Regions may move and rotate between calls (e.g. a scoop bowl welded to a gripper); only their transforms are passed in, the geometry is fixed in its local frame. Positions and region transforms must be expressed in a *common* frame (typically the per-env frame or the world frame). The counter does not assume any particular frame. Note on input layouts: region transforms are region-major (``(num_regions, num_envs, ...)``) while particle positions are env-major (``(num_envs, num_particles, 3)``). Keep this transposition in mind when assembling inputs. Example: .. code-block:: python verts, faces = make_frustum_region_mesh(0.02, 0.04, -0.02, 0.03) counter = ParticleMeshCounter([(verts, faces)], num_envs=128, device="cuda:0") counts = counter.count(particle_pos_e, region_pos, region_quat) # (num_envs, num_regions) in_bowl = counts[:, 0] Args: region_meshes: One entry per region, each either a built :class:`warp.Mesh` or a ``(vertices, indices)`` pair. ``vertices`` is shape ``(num_vertices, 3)`` [m]; ``indices`` is the flattened or ``(num_faces, 3)`` triangle index array. Pre-built meshes are used as-is and must be on :paramref:`device` with winding-number support enabled. num_envs: Number of environments. device: Torch device string the counter operates on (e.g. ``"cuda:0"`` or ``"cpu"``). max_query_dist: Maximum distance for the closest-point search [m]. Defaults to a large value so the winding-number sign is always resolved regardless of how deep a point sits inside. """
[docs] def __init__( self, region_meshes: Sequence[wp.Mesh | tuple[np.ndarray, np.ndarray]], num_envs: int, device: str, *, max_query_dist: float = 1.0e6, ) -> None: if len(region_meshes) == 0: raise ValueError("`region_meshes` must contain at least one region mesh.") self._device = str(device) self._num_envs = int(num_envs) self._max_query_dist = float(max_query_dist) self._meshes: tuple[wp.Mesh, ...] = tuple(self._make_region_mesh(mesh) for mesh in region_meshes) self._mesh_ids = wp.array([mesh.id for mesh in self._meshes], dtype=wp.uint64, device=self._device) self._inside: ProxyArray | None = None
@property def num_regions(self) -> int: """Number of region meshes.""" return len(self._meshes) @property def num_envs(self) -> int: """Number of environments.""" return self._num_envs @property def device(self) -> str: """Torch device string the counter operates on.""" return self._device
[docs] def count( self, particle_positions: torch.Tensor, region_positions: torch.Tensor, region_orientations: torch.Tensor | None = None, *, return_mask: bool = False, ) -> torch.Tensor | tuple[torch.Tensor, torch.Tensor]: """Count particles inside each region, per environment. Args: particle_positions: Particle positions in a common frame, shape ``(num_envs, num_particles, 3)`` [m]. region_positions: Region origins in the same frame, shape ``(num_regions, num_envs, 3)`` [m]. A ``(num_regions, 3)`` tensor is broadcast across environments (useful for regions that are static in the common frame). region_orientations: Region orientations as ``(x, y, z, w)`` quaternions, shape ``(num_regions, num_envs, 4)`` or ``(num_regions, 4)`` (broadcast). Defaults to identity for every region when ``None``. return_mask: When ``True``, also return the per-particle containment mask. Returns: The per-environment, per-region counts, shape ``(num_envs, num_regions)``, float. When :paramref:`return_mask` is ``True``, a tuple of the counts and the boolean containment mask of shape ``(num_envs, num_particles, num_regions)``. """ points = particle_positions.to(device=self._device, dtype=torch.float32) if points.dim() != 3 or points.shape[0] != self._num_envs or points.shape[2] != 3: raise ValueError( f"`particle_positions` must have shape (num_envs={self._num_envs}, num_particles, 3)," f" got {tuple(particle_positions.shape)}." ) points = points.contiguous() num_particles = points.shape[1] region_pos, region_quat = self._prepare_region_transforms(region_positions, region_orientations) inside_buffer = self._resize_inside_buffer(num_particles) wp.launch( count_particles_in_meshes_kernel, dim=(self._num_envs, num_particles, self.num_regions), inputs=[ wp.from_torch(points, dtype=wp.vec3), self._mesh_ids, wp.from_torch(region_pos, dtype=wp.vec3), wp.from_torch(region_quat, dtype=wp.quat), self._max_query_dist, inside_buffer.warp, ], device=self._device, ) inside = inside_buffer.torch counts = inside.sum(dim=1) if return_mask: return counts, inside > 0.5 return counts
def _make_region_mesh(self, mesh: wp.Mesh | tuple[np.ndarray, np.ndarray]) -> wp.Mesh: """Build tuple-backed region meshes on the counter's device.""" if isinstance(mesh, wp.Mesh): return mesh vertices, indices = mesh vertices = np.asarray(vertices, dtype=np.float32).reshape(-1, 3) indices = np.asarray(indices, dtype=np.int32).reshape(-1) return wp.Mesh( points=wp.array(vertices, dtype=wp.vec3, device=self._device), indices=wp.array(indices, dtype=wp.int32, device=self._device), support_winding_number=True, ) def _prepare_region_transforms( self, region_positions: torch.Tensor, region_orientations: torch.Tensor | None ) -> tuple[torch.Tensor, torch.Tensor]: """Validate and broadcast region transforms to ``(num_regions, num_envs, {3,4})``.""" region_pos = region_positions.to(device=self._device, dtype=torch.float32) if region_pos.dim() == 2: region_pos = region_pos.unsqueeze(1).expand(-1, self._num_envs, -1) if tuple(region_pos.shape) != (self.num_regions, self._num_envs, 3): raise ValueError( f"`region_positions` must broadcast to (num_regions={self.num_regions}," f" num_envs={self._num_envs}, 3), got {tuple(region_positions.shape)}." ) if region_orientations is None: region_quat = torch.zeros((self.num_regions, self._num_envs, 4), device=self._device, dtype=torch.float32) region_quat[..., 3] = 1.0 else: region_quat = region_orientations.to(device=self._device, dtype=torch.float32) if region_quat.dim() == 2: region_quat = region_quat.unsqueeze(1).expand(-1, self._num_envs, -1) if tuple(region_quat.shape) != (self.num_regions, self._num_envs, 4): raise ValueError( f"`region_orientations` must broadcast to (num_regions={self.num_regions}," f" num_envs={self._num_envs}, 4), got {tuple(region_orientations.shape)}." ) return region_pos.contiguous(), region_quat.contiguous() def _resize_inside_buffer(self, num_particles: int) -> ProxyArray: """Return the containment buffer, resizing it when the particle count changes.""" shape = (self._num_envs, num_particles, self.num_regions) if self._inside is None or self._inside.shape != shape: self._inside = ProxyArray(wp.empty(shape, dtype=wp.float32, device=self._device)) return self._inside
[docs] def make_box_region_mesh( half_extents: Sequence[float], center: Sequence[float] = (0.0, 0.0, 0.0) ) -> tuple[np.ndarray, np.ndarray]: """Build a closed, axis-aligned box region mesh with outward-facing triangles. Args: half_extents: Box half-extents ``(hx, hy, hz)`` [m]. center: Box center in the mesh-local frame [m]. Returns: A tuple of the vertices, shape ``(8, 3)`` float32 [m], and the triangle indices, shape ``(12, 3)`` int32. """ hx, hy, hz = (float(half_extents[0]), float(half_extents[1]), float(half_extents[2])) if hx <= 0.0 or hy <= 0.0 or hz <= 0.0: raise ValueError(f"`half_extents` must be positive, got {(hx, hy, hz)}.") cx, cy, cz = (float(center[0]), float(center[1]), float(center[2])) vertices = np.array( [ [-hx, -hy, -hz], [hx, -hy, -hz], [hx, hy, -hz], [-hx, hy, -hz], [-hx, -hy, hz], [hx, -hy, hz], [hx, hy, hz], [-hx, hy, hz], ], dtype=np.float32, ) + np.array([cx, cy, cz], dtype=np.float32) faces = np.array( [ [0, 2, 1], [0, 3, 2], # -z [4, 5, 6], [4, 6, 7], # +z [0, 1, 5], [0, 5, 4], # -y [1, 2, 6], [1, 6, 5], # +x [2, 3, 7], [2, 7, 6], # +y [3, 0, 4], [3, 4, 7], # -x ], dtype=np.int32, ) return vertices, faces
[docs] def make_frustum_region_mesh( radius_bottom: float, radius_top: float, z_bottom: float, z_top: float, num_segments: int = 24, ) -> tuple[np.ndarray, np.ndarray]: """Build a closed (capped) circular frustum region mesh aligned with the local +Z axis. This is the natural "cup cavity" region: a frustum that interpolates linearly in radius from :paramref:`radius_bottom` at :paramref:`z_bottom` to :paramref:`radius_top` at :paramref:`z_top`, capped at both ends so the mesh is watertight. Triangles face outward. Args: radius_bottom: Radius at the bottom ring [m]. radius_top: Radius at the top ring [m]. z_bottom: Local Z of the bottom ring [m]. z_top: Local Z of the top ring [m]. num_segments: Number of angular segments around the axis. Returns: A tuple of the vertices, shape ``(2 * num_segments + 2, 3)`` float32 [m], and the triangle indices, shape ``(4 * num_segments, 3)`` int32. """ n = int(num_segments) if n < 3: raise ValueError(f"`num_segments` must be >= 3, got {num_segments}.") if radius_bottom <= 0.0 or radius_top <= 0.0: raise ValueError(f"Radii must be positive, got bottom={radius_bottom}, top={radius_top}.") if z_bottom >= z_top: raise ValueError(f"`z_bottom` must be < `z_top`, got {z_bottom} >= {z_top}.") angles = np.linspace(0.0, 2.0 * np.pi, n, endpoint=False) cos_a, sin_a = np.cos(angles), np.sin(angles) bottom = np.stack([radius_bottom * cos_a, radius_bottom * sin_a, np.full(n, z_bottom)], axis=1) top = np.stack([radius_top * cos_a, radius_top * sin_a, np.full(n, z_top)], axis=1) center_bottom = np.array([[0.0, 0.0, z_bottom]]) center_top = np.array([[0.0, 0.0, z_top]]) vertices = np.concatenate([bottom, top, center_bottom, center_top], axis=0).astype(np.float32) idx_center_bottom, idx_center_top = 2 * n, 2 * n + 1 faces = [] for i in range(n): j = (i + 1) % n b_i, b_j, t_i, t_j = i, j, n + i, n + j # side wall (outward) faces.append([b_i, b_j, t_j]) faces.append([b_i, t_j, t_i]) # bottom cap (outward = -Z) faces.append([idx_center_bottom, b_j, b_i]) # top cap (outward = +Z) faces.append([idx_center_top, t_i, t_j]) return vertices, np.array(faces, dtype=np.int32)