# Copyright (c) 2022-2024, The Isaac Lab Project Developers.
# All rights reserved.
#
# SPDX-License-Identifier: BSD-3-Clause
# needed to import for allowing type-hinting: np.ndarray | torch.Tensor | None
from __future__ import annotations
import numpy as np
import torch
import trimesh
import warp as wp
from omni.isaac.lab.utils.warp import raycast_mesh
[docs]def color_meshes_by_height(meshes: list[trimesh.Trimesh], **kwargs) -> trimesh.Trimesh:
"""
Color the vertices of a trimesh object based on the z-coordinate (height) of each vertex,
using the Turbo colormap. If the z-coordinates are all the same, the vertices will be colored
with a single color.
Args:
meshes: A list of trimesh objects.
Keyword Args:
color: A list of 3 integers in the range [0,255] representing the RGB
color of the mesh. Used when the z-coordinates of all vertices are the same.
Defaults to [172, 216, 230].
color_map: The name of the color map to be used. Defaults to "turbo".
Returns:
A trimesh object with the vertices colored based on the z-coordinate (height) of each vertex.
"""
# Combine all meshes into a single mesh
mesh = trimesh.util.concatenate(meshes)
# Get the z-coordinates of each vertex
heights = mesh.vertices[:, 2]
# Check if the z-coordinates are all the same
if np.max(heights) == np.min(heights):
# Obtain a single color: light blue
color = kwargs.pop("color", (172, 216, 230))
color = np.asarray(color, dtype=np.uint8)
# Set the color for all vertices
mesh.visual.vertex_colors = color
else:
# Normalize the heights to [0,1]
heights_normalized = (heights - np.min(heights)) / (np.max(heights) - np.min(heights))
# clip lower and upper bounds to have better color mapping
heights_normalized = np.clip(heights_normalized, 0.1, 0.9)
# Get the color for each vertex based on the height
color_map = kwargs.pop("color_map", "turbo")
colors = trimesh.visual.color.interpolate(heights_normalized, color_map=color_map)
# Set the vertex colors
mesh.visual.vertex_colors = colors
# Return the mesh
return mesh
[docs]def create_prim_from_mesh(prim_path: str, mesh: trimesh.Trimesh, **kwargs):
"""Create a USD prim with mesh defined from vertices and triangles.
The function creates a USD prim with a mesh defined from vertices and triangles. It performs the
following steps:
- Create a USD Xform prim at the path :obj:`prim_path`.
- Create a USD prim with a mesh defined from the input vertices and triangles at the path :obj:`{prim_path}/mesh`.
- Assign a physics material to the mesh at the path :obj:`{prim_path}/physicsMaterial`.
- Assign a visual material to the mesh at the path :obj:`{prim_path}/visualMaterial`.
Args:
prim_path: The path to the primitive to be created.
mesh: The mesh to be used for the primitive.
Keyword Args:
translation: The translation of the terrain. Defaults to None.
orientation: The orientation of the terrain. Defaults to None.
visual_material: The visual material to apply. Defaults to None.
physics_material: The physics material to apply. Defaults to None.
"""
# need to import these here to prevent isaacsim launching when importing this module
import omni.isaac.core.utils.prims as prim_utils
from pxr import UsdGeom
import omni.isaac.lab.sim as sim_utils
# create parent prim
prim_utils.create_prim(prim_path, "Xform")
# create mesh prim
prim = prim_utils.create_prim(
f"{prim_path}/mesh",
"Mesh",
translation=kwargs.get("translation"),
orientation=kwargs.get("orientation"),
attributes={
"points": mesh.vertices,
"faceVertexIndices": mesh.faces.flatten(),
"faceVertexCounts": np.asarray([3] * len(mesh.faces)),
"subdivisionScheme": "bilinear",
},
)
# apply collider properties
collider_cfg = sim_utils.CollisionPropertiesCfg(collision_enabled=True)
sim_utils.define_collision_properties(prim.GetPrimPath(), collider_cfg)
# add rgba color to the mesh primvars
if mesh.visual.vertex_colors is not None:
# obtain color from the mesh
rgba_colors = np.asarray(mesh.visual.vertex_colors).astype(np.float32) / 255.0
# displayColor is a primvar attribute that is used to color the mesh
color_prim_attr = prim.GetAttribute("primvars:displayColor")
color_prim_var = UsdGeom.Primvar(color_prim_attr)
color_prim_var.SetInterpolation(UsdGeom.Tokens.vertex)
color_prim_attr.Set(rgba_colors[:, :3])
# displayOpacity is a primvar attribute that is used to set the opacity of the mesh
display_prim_attr = prim.GetAttribute("primvars:displayOpacity")
display_prim_var = UsdGeom.Primvar(display_prim_attr)
display_prim_var.SetInterpolation(UsdGeom.Tokens.vertex)
display_prim_var.Set(rgba_colors[:, 3])
# create visual material
if kwargs.get("visual_material") is not None:
visual_material_cfg: sim_utils.VisualMaterialCfg = kwargs.get("visual_material")
# spawn the material
visual_material_cfg.func(f"{prim_path}/visualMaterial", visual_material_cfg)
sim_utils.bind_visual_material(prim.GetPrimPath(), f"{prim_path}/visualMaterial")
# create physics material
if kwargs.get("physics_material") is not None:
physics_material_cfg: sim_utils.RigidBodyMaterialCfg = kwargs.get("physics_material")
# spawn the material
physics_material_cfg.func(f"{prim_path}/physicsMaterial", physics_material_cfg)
sim_utils.bind_physics_material(prim.GetPrimPath(), f"{prim_path}/physicsMaterial")
[docs]def find_flat_patches(
wp_mesh: wp.Mesh,
num_patches: int,
patch_radius: float | list[float],
origin: np.ndarray | torch.Tensor | tuple[float, float, float],
x_range: tuple[float, float],
y_range: tuple[float, float],
z_range: tuple[float, float],
max_height_diff: float,
) -> torch.Tensor:
"""Finds flat patches of given radius in the input mesh.
The function finds flat patches of given radius based on the search space defined by the input ranges.
The search space is characterized by origin in the mesh frame, and the x, y, and z ranges. The x and y
ranges are used to sample points in the 2D region around the origin, and the z range is used to filter
patches based on the height of the points.
The function performs rejection sampling to find the patches based on the following steps:
1. Sample patch locations in the 2D region around the origin.
2. Define a ring of points around each patch location to query the height of the points using ray-casting.
3. Reject patches that are outside the z range or have a height difference that is too large.
4. Keep sampling until all patches are valid.
Args:
wp_mesh: The warp mesh to find patches in.
num_patches: The desired number of patches to find.
patch_radius: The radii used to form patches. If a list is provided, multiple patch sizes are checked.
This is useful to deal with holes or other artifacts in the mesh.
origin: The origin defining the center of the search space. This is specified in the mesh frame.
x_range: The range of X coordinates to sample from.
y_range: The range of Y coordinates to sample from.
z_range: The range of valid Z coordinates used for filtering patches.
max_height_diff: The maximum allowable distance between the lowest and highest points
on a patch to consider it as valid. If the difference is greater than this value,
the patch is rejected.
Returns:
A tensor of shape (num_patches, 3) containing the flat patches. The patches are defined in the mesh frame.
Raises:
RuntimeError: If the function fails to find valid patches. This can happen if the input parameters
are not suitable for finding valid patches and maximum number of iterations is reached.
"""
# set device to warp mesh device
device = wp.device_to_torch(wp_mesh.device)
# resolve inputs to consistent type
# -- patch radii
if isinstance(patch_radius, float):
patch_radius = [patch_radius]
# -- origin
if isinstance(origin, np.ndarray):
origin = torch.from_numpy(origin).to(torch.float).to(device)
elif isinstance(origin, torch.Tensor):
origin = origin.to(device)
else:
origin = torch.tensor(origin, dtype=torch.float, device=device)
# create ranges for the x and y coordinates around the origin.
# The provided ranges are bounded by the mesh's bounding box.
x_range = (
max(x_range[0] + origin[0].item(), wp_mesh.points.numpy()[:, 0].min()),
min(x_range[1] + origin[0].item(), wp_mesh.points.numpy()[:, 0].max()),
)
y_range = (
max(y_range[0] + origin[1].item(), wp_mesh.points.numpy()[:, 1].min()),
min(y_range[1] + origin[1].item(), wp_mesh.points.numpy()[:, 1].max()),
)
z_range = (
z_range[0] + origin[2].item(),
z_range[1] + origin[2].item(),
)
# create a circle of points around (0, 0) to query validity of the patches
# the ring of points is uniformly distributed around the circle
angle = torch.linspace(0, 2 * np.pi, 10, device=device)
query_x = []
query_y = []
for radius in patch_radius:
query_x.append(radius * torch.cos(angle))
query_y.append(radius * torch.sin(angle))
query_x = torch.cat(query_x).unsqueeze(1) # dim: (num_radii * 10, 1)
query_y = torch.cat(query_y).unsqueeze(1) # dim: (num_radii * 10, 1)
# dim: (num_radii * 10, 3)
query_points = torch.cat([query_x, query_y, torch.zeros_like(query_x)], dim=-1)
# create buffers
# -- a buffer to store indices of points that are not valid
points_ids = torch.arange(num_patches, device=device)
# -- a buffer to store the flat patches locations
flat_patches = torch.zeros(num_patches, 3, device=device)
# sample points and raycast to find the height.
# 1. Reject points that are outside the z_range or have a height difference that is too large.
# 2. Keep sampling until all points are valid.
iter_count = 0
while len(points_ids) > 0 and iter_count < 10000:
# sample points in the 2D region around the origin
pos_x = torch.empty(len(points_ids), device=device).uniform_(*x_range)
pos_y = torch.empty(len(points_ids), device=device).uniform_(*y_range)
flat_patches[points_ids, :2] = torch.stack([pos_x, pos_y], dim=-1)
# define the query points to check validity of the patch
# dim: (num_patches, num_radii * 10, 3)
points = flat_patches[points_ids].unsqueeze(1) + query_points
points[..., 2] = 100.0
# ray-cast direction is downwards
dirs = torch.zeros_like(points)
dirs[..., 2] = -1.0
# ray-cast to find the height of the patches
ray_hits = raycast_mesh(points.view(-1, 3), dirs.view(-1, 3), wp_mesh)[0]
heights = ray_hits.view(points.shape)[..., 2]
# set the height of the patches
# note: for invalid patches, they would be overwritten in the next iteration
# so it's safe to set the height to the last value
flat_patches[points_ids, 2] = heights[..., -1]
# check validity
# -- height is within the z range
not_valid = torch.any(torch.logical_or(heights < z_range[0], heights > z_range[1]), dim=1)
# -- height difference is within the max height difference
not_valid = torch.logical_or(not_valid, (heights.max(dim=1)[0] - heights.min(dim=1)[0]) > max_height_diff)
# remove invalid patches indices
points_ids = points_ids[not_valid]
# increment count
iter_count += 1
# check all patches are valid
if len(points_ids) > 0:
raise RuntimeError(
"Failed to find valid patches! Please check the input parameters."
f"\n\tMaximum number of iterations reached: {iter_count}"
f"\n\tNumber of invalid patches: {len(points_ids)}"
f"\n\tMaximum height difference: {max_height_diff}"
)
# return the flat patches (in the mesh frame)
return flat_patches - origin