Source code for omni.isaac.lab.terrains.terrain_importer

# Copyright (c) 2022-2024, The Isaac Lab Project Developers.
# All rights reserved.
#
# SPDX-License-Identifier: BSD-3-Clause

from __future__ import annotations

import numpy as np
import torch
import trimesh
from typing import TYPE_CHECKING

import warp
from pxr import UsdGeom

import omni.isaac.lab.sim as sim_utils
from omni.isaac.lab.markers import VisualizationMarkers
from omni.isaac.lab.markers.config import FRAME_MARKER_CFG
from omni.isaac.lab.utils.warp import convert_to_warp_mesh

from .terrain_generator import TerrainGenerator
from .trimesh.utils import make_plane
from .utils import create_prim_from_mesh

if TYPE_CHECKING:
    from .terrain_importer_cfg import TerrainImporterCfg


[docs]class TerrainImporter: r"""A class to handle terrain meshes and import them into the simulator. We assume that a terrain mesh comprises of sub-terrains that are arranged in a grid with rows ``num_rows`` and columns ``num_cols``. The terrain origins are the positions of the sub-terrains where the robot should be spawned. Based on the configuration, the terrain importer handles computing the environment origins from the sub-terrain origins. In a typical setup, the number of sub-terrains (:math:`num\_rows \times num\_cols`) is smaller than the number of environments (:math:`num\_envs`). In this case, the environment origins are computed by sampling the sub-terrain origins. If a curriculum is used, it is possible to update the environment origins to terrain origins that correspond to a harder difficulty. This is done by calling :func:`update_terrain_levels`. The idea comes from game-based curriculum. For example, in a game, the player starts with easy levels and progresses to harder levels. """ meshes: dict[str, trimesh.Trimesh] """A dictionary containing the names of the meshes and their keys.""" warp_meshes: dict[str, warp.Mesh] """A dictionary containing the names of the warp meshes and their keys.""" terrain_origins: torch.Tensor | None """The origins of the sub-terrains in the added terrain mesh. Shape is (num_rows, num_cols, 3). If None, then it is assumed no sub-terrains exist. The environment origins are computed in a grid. """ env_origins: torch.Tensor """The origins of the environments. Shape is (num_envs, 3)."""
[docs] def __init__(self, cfg: TerrainImporterCfg): """Initialize the terrain importer. Args: cfg: The configuration for the terrain importer. Raises: ValueError: If input terrain type is not supported. ValueError: If terrain type is 'generator' and no configuration provided for ``terrain_generator``. ValueError: If terrain type is 'usd' and no configuration provided for ``usd_path``. ValueError: If terrain type is 'usd' or 'plane' and no configuration provided for ``env_spacing``. """ # check that the config is valid cfg.validate() # store inputs self.cfg = cfg self.device = sim_utils.SimulationContext.instance().device # type: ignore # create a dict of meshes self.meshes = dict() self.warp_meshes = dict() self.env_origins = None self.terrain_origins = None # private variables self._terrain_flat_patches = dict() # auto-import the terrain based on the config if self.cfg.terrain_type == "generator": # check config is provided if self.cfg.terrain_generator is None: raise ValueError("Input terrain type is 'generator' but no value provided for 'terrain_generator'.") # generate the terrain terrain_generator = TerrainGenerator(cfg=self.cfg.terrain_generator, device=self.device) self.import_mesh("terrain", terrain_generator.terrain_mesh) # configure the terrain origins based on the terrain generator self.configure_env_origins(terrain_generator.terrain_origins) # refer to the flat patches self._terrain_flat_patches = terrain_generator.flat_patches elif self.cfg.terrain_type == "usd": # check if config is provided if self.cfg.usd_path is None: raise ValueError("Input terrain type is 'usd' but no value provided for 'usd_path'.") # import the terrain self.import_usd("terrain", self.cfg.usd_path) # configure the origins in a grid self.configure_env_origins() elif self.cfg.terrain_type == "plane": # load the plane self.import_ground_plane("terrain") # configure the origins in a grid self.configure_env_origins() else: raise ValueError(f"Terrain type '{self.cfg.terrain_type}' not available.") # set initial state of debug visualization self.set_debug_vis(self.cfg.debug_vis)
""" Properties. """ @property def has_debug_vis_implementation(self) -> bool: """Whether the terrain importer has a debug visualization implemented. This always returns True. """ return True @property def flat_patches(self) -> dict[str, torch.Tensor]: """A dictionary containing the sampled valid (flat) patches for the terrain. This is only available if the terrain type is 'generator'. For other terrain types, this feature is not available and the function returns an empty dictionary. Please refer to the :attr:`TerrainGenerator.flat_patches` for more information. """ return self._terrain_flat_patches """ Operations - Visibility. """
[docs] def set_debug_vis(self, debug_vis: bool) -> bool: """Set the debug visualization of the terrain importer. Args: debug_vis: Whether to visualize the terrain origins. Returns: Whether the debug visualization was successfully set. False if the terrain importer does not support debug visualization. Raises: RuntimeError: If terrain origins are not configured. """ # create a marker if necessary if debug_vis: if not hasattr(self, "origin_visualizer"): self.origin_visualizer = VisualizationMarkers( cfg=FRAME_MARKER_CFG.replace(prim_path="/Visuals/TerrainOrigin") ) if self.terrain_origins is not None: self.origin_visualizer.visualize(self.terrain_origins.reshape(-1, 3)) elif self.env_origins is not None: self.origin_visualizer.visualize(self.env_origins.reshape(-1, 3)) else: raise RuntimeError("Terrain origins are not configured.") # set visibility self.origin_visualizer.set_visibility(True) else: if hasattr(self, "origin_visualizer"): self.origin_visualizer.set_visibility(False) # report success return True
""" Operations - Import. """
[docs] def import_ground_plane(self, key: str, size: tuple[float, float] = (2.0e6, 2.0e6)): """Add a plane to the terrain importer. Args: key: The key to store the mesh. size: The size of the plane. Defaults to (2.0e6, 2.0e6). Raises: ValueError: If a terrain with the same key already exists. """ # check if key exists if key in self.meshes: raise ValueError(f"Mesh with key {key} already exists. Existing keys: {self.meshes.keys()}.") # create a plane mesh = make_plane(size, height=0.0, center_zero=True) # store the mesh self.meshes[key] = mesh # create a warp mesh device = "cuda" if "cuda" in self.device else "cpu" self.warp_meshes[key] = convert_to_warp_mesh(mesh.vertices, mesh.faces, device=device) # get the mesh ground_plane_cfg = sim_utils.GroundPlaneCfg(physics_material=self.cfg.physics_material, size=size) ground_plane_cfg.func(self.cfg.prim_path, ground_plane_cfg)
[docs] def import_mesh(self, key: str, mesh: trimesh.Trimesh): """Import a mesh into the simulator. The mesh is imported into the simulator under the prim path ``cfg.prim_path/{key}``. The created path contains the mesh as a :class:`pxr.UsdGeom` instance along with visual or physics material prims. Args: key: The key to store the mesh. mesh: The mesh to import. Raises: ValueError: If a terrain with the same key already exists. """ # check if key exists if key in self.meshes: raise ValueError(f"Mesh with key {key} already exists. Existing keys: {self.meshes.keys()}.") # store the mesh self.meshes[key] = mesh # create a warp mesh device = "cuda" if "cuda" in self.device else "cpu" self.warp_meshes[key] = convert_to_warp_mesh(mesh.vertices, mesh.faces, device=device) # get the mesh mesh = self.meshes[key] mesh_prim_path = self.cfg.prim_path + f"/{key}" # import the mesh create_prim_from_mesh( mesh_prim_path, mesh, visual_material=self.cfg.visual_material, physics_material=self.cfg.physics_material, )
[docs] def import_usd(self, key: str, usd_path: str): """Import a mesh from a USD file. We assume that the USD file contains a single mesh. If the USD file contains multiple meshes, then the first mesh is used. The function mainly helps in registering the mesh into the warp meshes and the meshes dictionary. Note: We do not apply any material properties to the mesh. The material properties should be defined in the USD file. Args: key: The key to store the mesh. usd_path: The path to the USD file. Raises: ValueError: If a terrain with the same key already exists. """ # add mesh to the dict if key in self.meshes: raise ValueError(f"Mesh with key {key} already exists. Existing keys: {self.meshes.keys()}.") # add the prim path cfg = sim_utils.UsdFileCfg(usd_path=usd_path) cfg.func(self.cfg.prim_path + f"/{key}", cfg) # traverse the prim and get the collision mesh # THINK: Should the user specify the collision mesh? mesh_prim = sim_utils.get_first_matching_child_prim( self.cfg.prim_path + f"/{key}", lambda prim: prim.GetTypeName() == "Mesh" ) # check if the mesh is valid if mesh_prim is None: raise ValueError(f"Could not find any collision mesh in {usd_path}. Please check asset.") # cast into UsdGeomMesh mesh_prim = UsdGeom.Mesh(mesh_prim) # store the mesh vertices = np.asarray(mesh_prim.GetPointsAttr().Get()) faces = np.asarray(mesh_prim.GetFaceVertexIndicesAttr().Get()).reshape(-1, 3) self.meshes[key] = trimesh.Trimesh(vertices=vertices, faces=faces) # create a warp mesh device = "cuda" if "cuda" in self.device else "cpu" self.warp_meshes[key] = convert_to_warp_mesh(vertices, faces, device=device)
""" Operations - Origins. """
[docs] def configure_env_origins(self, origins: np.ndarray | None = None): """Configure the origins of the environments based on the added terrain. Args: origins: The origins of the sub-terrains. Shape is (num_rows, num_cols, 3). """ # decide whether to compute origins in a grid or based on curriculum if origins is not None: # convert to numpy if isinstance(origins, np.ndarray): origins = torch.from_numpy(origins) # store the origins self.terrain_origins = origins.to(self.device, dtype=torch.float) # compute environment origins self.env_origins = self._compute_env_origins_curriculum(self.cfg.num_envs, self.terrain_origins) else: self.terrain_origins = None # check if env spacing is valid if self.cfg.env_spacing is None: raise ValueError("Environment spacing must be specified for configuring grid-like origins.") # compute environment origins self.env_origins = self._compute_env_origins_grid(self.cfg.num_envs, self.cfg.env_spacing)
[docs] def update_env_origins(self, env_ids: torch.Tensor, move_up: torch.Tensor, move_down: torch.Tensor): """Update the environment origins based on the terrain levels.""" # check if grid-like spawning if self.terrain_origins is None: return # update terrain level for the envs self.terrain_levels[env_ids] += 1 * move_up - 1 * move_down # robots that solve the last level are sent to a random one # the minimum level is zero self.terrain_levels[env_ids] = torch.where( self.terrain_levels[env_ids] >= self.max_terrain_level, torch.randint_like(self.terrain_levels[env_ids], self.max_terrain_level), torch.clip(self.terrain_levels[env_ids], 0), ) # update the env origins self.env_origins[env_ids] = self.terrain_origins[self.terrain_levels[env_ids], self.terrain_types[env_ids]]
""" Internal helpers. """ def _compute_env_origins_curriculum(self, num_envs: int, origins: torch.Tensor) -> torch.Tensor: """Compute the origins of the environments defined by the sub-terrains origins.""" # extract number of rows and cols num_rows, num_cols = origins.shape[:2] # maximum initial level possible for the terrains if self.cfg.max_init_terrain_level is None: max_init_level = num_rows - 1 else: max_init_level = min(self.cfg.max_init_terrain_level, num_rows - 1) # store maximum terrain level possible self.max_terrain_level = num_rows # define all terrain levels and types available self.terrain_levels = torch.randint(0, max_init_level + 1, (num_envs,), device=self.device) self.terrain_types = torch.div( torch.arange(num_envs, device=self.device), (num_envs / num_cols), rounding_mode="floor", ).to(torch.long) # create tensor based on number of environments env_origins = torch.zeros(num_envs, 3, device=self.device) env_origins[:] = origins[self.terrain_levels, self.terrain_types] return env_origins def _compute_env_origins_grid(self, num_envs: int, env_spacing: float) -> torch.Tensor: """Compute the origins of the environments in a grid based on configured spacing.""" # create tensor based on number of environments env_origins = torch.zeros(num_envs, 3, device=self.device) # create a grid of origins num_rows = np.ceil(num_envs / int(np.sqrt(num_envs))) num_cols = np.ceil(num_envs / num_rows) ii, jj = torch.meshgrid( torch.arange(num_rows, device=self.device), torch.arange(num_cols, device=self.device), indexing="ij" ) env_origins[:, 0] = -(ii.flatten()[:num_envs] - (num_rows - 1) / 2) * env_spacing env_origins[:, 1] = (jj.flatten()[:num_envs] - (num_cols - 1) / 2) * env_spacing env_origins[:, 2] = 0.0 return env_origins