Unverified Commit 17fbff54 authored by Mayank Mittal's avatar Mayank Mittal Committed by GitHub

Removes storage of meshes inside the TerrainImporter class (#1987)

# Description

Sometimes users want to import a USD file which doesn't have any
collider meshes. This MR allows having such USD files in the terrain
importer for visualization purposes.

Additionally, the MR removes the storage of warp and tri-mesh based
meshes inside the class. These are stored into the USD and are read
directly from there whenever needed. Their intended use-case for having
them inside the terrain class is not needed.

## Type of change

- Bug fix (non-breaking change which fixes an issue)
- Breaking change

## Checklist

- [x] I have run the [`pre-commit` checks](https://pre-commit.com/) with
`./isaaclab.sh --format`
- [x] I have made corresponding changes to the documentation
- [ ] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] I have updated the changelog and the corresponding version in the
extension's `config/extension.toml` file
- [x] I have added my name to the `CONTRIBUTORS.md` or my name already
exists there

---------
Signed-off-by: 's avatarMayank Mittal <12863862+Mayankm96@users.noreply.github.com>
Co-authored-by: 's avatarKelly Guo <kellyguo123@hotmail.com>
parent 46cbb5d6
[package] [package]
# Note: Semantic Versioning is used: https://semver.org/ # Note: Semantic Versioning is used: https://semver.org/
version = "0.35.0" version = "0.36.0"
# Description # Description
title = "Isaac Lab framework for Robot Learning" title = "Isaac Lab framework for Robot Learning"
......
Changelog Changelog
--------- ---------
0.36.0 (2025-03-07)
~~~~~~~~~~~~~~~~~~~
Removed
^^^^^^^
* Removed the storage of tri-meshes and warp meshes inside the :class:`~isaaclab.terrains.TerrainImporter` class.
Initially these meshes were added for ray-casting purposes. However, since the ray-caster reads the terrains
directly from the USD files, these meshes are no longer needed.
* Deprecated the :attr:`warp_meshes` and :attr:`meshes` attributes from the
:class:`~isaaclab.terrains.TerrainImporter` class. These attributes now return an empty dictionary
with a deprecation warning.
Changed
^^^^^^^
* Changed the prim path of the "plane" terrain inside the :class:`~isaaclab.terrains.TerrainImporter` class.
Earlier, the terrain was imported directly as the importer's prim path. Now, the terrain is imported as
``{importer_prim_path}/{name}``, where ``name`` is the name of the terrain.
0.35.0 (2025-03-07) 0.35.0 (2025-03-07)
~~~~~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~~~~~
......
...@@ -11,11 +11,10 @@ There are two main components in this package: ...@@ -11,11 +11,10 @@ There are two main components in this package:
sub-terrain configuration. It creates a ``trimesh`` mesh object and contains the origins of sub-terrain configuration. It creates a ``trimesh`` mesh object and contains the origins of
each generated sub-terrain. each generated sub-terrain.
* :class:`TerrainImporter`: This class mainly deals with importing terrains from different * :class:`TerrainImporter`: This class mainly deals with importing terrains from different
possible sources and adding them to the simulator as a prim object. It also stores the possible sources and adding them to the simulator as a prim object.
terrain mesh into a dictionary called :obj:`TerrainImporter.warp_meshes` that later can be used The following functions are available for importing terrains:
for ray-casting. The following functions are available for importing terrains:
* :meth:`TerrainImporter.import_ground_plane`: spawn a grid plane which is default in isaacsim/isaaclab. * :meth:`TerrainImporter.import_ground_plane`: spawn a grid plane which is default in Isaac Sim.
* :meth:`TerrainImporter.import_mesh`: spawn a prim from a ``trimesh`` object. * :meth:`TerrainImporter.import_mesh`: spawn a prim from a ``trimesh`` object.
* :meth:`TerrainImporter.import_usd`: spawn a prim as reference to input USD file. * :meth:`TerrainImporter.import_usd`: spawn a prim as reference to input USD file.
......
...@@ -11,16 +11,12 @@ import trimesh ...@@ -11,16 +11,12 @@ import trimesh
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
import omni.log import omni.log
import warp
from pxr import UsdGeom
import isaaclab.sim as sim_utils import isaaclab.sim as sim_utils
from isaaclab.markers import VisualizationMarkers from isaaclab.markers import VisualizationMarkers
from isaaclab.markers.config import FRAME_MARKER_CFG from isaaclab.markers.config import FRAME_MARKER_CFG
from isaaclab.utils.warp import convert_to_warp_mesh
from .terrain_generator import TerrainGenerator from .terrain_generator import TerrainGenerator
from .trimesh.utils import make_plane
from .utils import create_prim_from_mesh from .utils import create_prim_from_mesh
if TYPE_CHECKING: if TYPE_CHECKING:
...@@ -44,15 +40,16 @@ class TerrainImporter: ...@@ -44,15 +40,16 @@ class TerrainImporter:
curriculum. For example, in a game, the player starts with easy levels and progresses to harder levels. curriculum. For example, in a game, the player starts with easy levels and progresses to harder levels.
""" """
meshes: dict[str, trimesh.Trimesh] terrain_prim_paths: list[str]
"""A dictionary containing the names of the meshes and their keys.""" """A list containing the USD prim paths to the imported terrains."""
warp_meshes: dict[str, warp.Mesh]
"""A dictionary containing the names of the warp meshes and their keys."""
terrain_origins: torch.Tensor | None terrain_origins: torch.Tensor | None
"""The origins of the sub-terrains in the added terrain mesh. Shape is (num_rows, num_cols, 3). """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. If terrain origins is not None, the environment origins are computed based on the terrain origins.
Otherwise, the environment origins are computed based on the grid spacing.
""" """
env_origins: torch.Tensor env_origins: torch.Tensor
"""The origins of the environments. Shape is (num_envs, 3).""" """The origins of the environments. Shape is (num_envs, 3)."""
...@@ -74,11 +71,10 @@ class TerrainImporter: ...@@ -74,11 +71,10 @@ class TerrainImporter:
self.cfg = cfg self.cfg = cfg
self.device = sim_utils.SimulationContext.instance().device # type: ignore self.device = sim_utils.SimulationContext.instance().device # type: ignore
# create a dict of meshes # create buffers for the terrains
self.meshes = dict() self.terrain_prim_paths = list()
self.warp_meshes = dict()
self.env_origins = None
self.terrain_origins = None self.terrain_origins = None
self.env_origins = None # assigned later when `configure_env_origins` is called
# private variables # private variables
self._terrain_flat_patches = dict() self._terrain_flat_patches = dict()
...@@ -136,6 +132,11 @@ class TerrainImporter: ...@@ -136,6 +132,11 @@ class TerrainImporter:
""" """
return self._terrain_flat_patches return self._terrain_flat_patches
@property
def terrain_names(self) -> list[str]:
"""A list of names of the imported terrains."""
return [f"'{path.split('/')[-1]}'" for path in self.terrain_prim_paths]
""" """
Operations - Visibility. Operations - Visibility.
""" """
...@@ -177,26 +178,27 @@ class TerrainImporter: ...@@ -177,26 +178,27 @@ class TerrainImporter:
Operations - Import. Operations - Import.
""" """
def import_ground_plane(self, key: str, size: tuple[float, float] = (2.0e6, 2.0e6)): def import_ground_plane(self, name: str, size: tuple[float, float] = (2.0e6, 2.0e6)):
"""Add a plane to the terrain importer. """Add a plane to the terrain importer.
Args: Args:
key: The key to store the mesh. name: The name of the imported terrain. This name is used to create the USD prim
corresponding to the terrain.
size: The size of the plane. Defaults to (2.0e6, 2.0e6). size: The size of the plane. Defaults to (2.0e6, 2.0e6).
Raises: Raises:
ValueError: If a terrain with the same key already exists. ValueError: If a terrain with the same name already exists.
""" """
# create prim path for the terrain
prim_path = self.cfg.prim_path + f"/{name}"
# check if key exists # check if key exists
if key in self.meshes: if prim_path in self.terrain_prim_paths:
raise ValueError(f"Mesh with key {key} already exists. Existing keys: {self.meshes.keys()}.") raise ValueError(
# create a plane f"A terrain with the name '{name}' already exists. Existing terrains: {', '.join(self.terrain_names)}."
mesh = make_plane(size, height=0.0, center_zero=True) )
# store the mesh # store the mesh name
self.meshes[key] = mesh self.terrain_prim_paths.append(prim_path)
# 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)
# obtain ground plane color from the configured visual material # obtain ground plane color from the configured visual material
color = (0.0, 0.0, 0.0) color = (0.0, 0.0, 0.0)
if self.cfg.visual_material is not None: if self.cfg.visual_material is not None:
...@@ -212,89 +214,74 @@ class TerrainImporter: ...@@ -212,89 +214,74 @@ class TerrainImporter:
# get the mesh # get the mesh
ground_plane_cfg = sim_utils.GroundPlaneCfg(physics_material=self.cfg.physics_material, size=size, color=color) ground_plane_cfg = sim_utils.GroundPlaneCfg(physics_material=self.cfg.physics_material, size=size, color=color)
ground_plane_cfg.func(self.cfg.prim_path, ground_plane_cfg) ground_plane_cfg.func(prim_path, ground_plane_cfg)
def import_mesh(self, key: str, mesh: trimesh.Trimesh): def import_mesh(self, name: str, mesh: trimesh.Trimesh):
"""Import a mesh into the simulator. """Import a mesh into the simulator.
The mesh is imported into the simulator under the prim path ``cfg.prim_path/{key}``. The created path 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. contains the mesh as a :class:`pxr.UsdGeom` instance along with visual or physics material prims.
Args: Args:
key: The key to store the mesh. name: The name of the imported terrain. This name is used to create the USD prim
corresponding to the terrain.
mesh: The mesh to import. mesh: The mesh to import.
Raises: Raises:
ValueError: If a terrain with the same key already exists. ValueError: If a terrain with the same name already exists.
""" """
# create prim path for the terrain
prim_path = self.cfg.prim_path + f"/{name}"
# check if key exists # check if key exists
if key in self.meshes: if prim_path in self.terrain_prim_paths:
raise ValueError(f"Mesh with key {key} already exists. Existing keys: {self.meshes.keys()}.") raise ValueError(
# store the mesh f"A terrain with the name '{name}' already exists. Existing terrains: {', '.join(self.terrain_names)}."
self.meshes[key] = mesh )
# create a warp mesh # store the mesh name
device = "cuda" if "cuda" in self.device else "cpu" self.terrain_prim_paths.append(prim_path)
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 # import the mesh
create_prim_from_mesh( create_prim_from_mesh(
mesh_prim_path, prim_path, mesh, visual_material=self.cfg.visual_material, physics_material=self.cfg.physics_material
mesh,
visual_material=self.cfg.visual_material,
physics_material=self.cfg.physics_material,
) )
def import_usd(self, key: str, usd_path: str): def import_usd(self, name: str, usd_path: str):
"""Import a mesh from a USD file. """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 This function imports a USD file into the simulator as a terrain. It parses the USD file and
the first mesh is used. The function mainly helps in registering the mesh into the warp meshes stores the mesh under the prim path ``cfg.prim_path/{key}``. If multiple meshes are present in
and the meshes dictionary. the USD file, only the first mesh is imported.
Note: The function doe not apply any material properties to the mesh. The material properties should
We do not apply any material properties to the mesh. The material properties should be defined in the USD file.
be defined in the USD file.
Args: Args:
key: The key to store the mesh. name: The name of the imported terrain. This name is used to create the USD prim
corresponding to the terrain.
usd_path: The path to the USD file. usd_path: The path to the USD file.
Raises: Raises:
ValueError: If a terrain with the same key already exists. ValueError: If a terrain with the same name already exists.
""" """
# add mesh to the dict # create prim path for the terrain
if key in self.meshes: prim_path = self.cfg.prim_path + f"/{name}"
raise ValueError(f"Mesh with key {key} already exists. Existing keys: {self.meshes.keys()}.") # check if key exists
if prim_path in self.terrain_prim_paths:
raise ValueError(
f"A terrain with the name '{name}' already exists. Existing terrains: {', '.join(self.terrain_names)}."
)
# store the mesh name
self.terrain_prim_paths.append(prim_path)
# add the prim path # add the prim path
cfg = sim_utils.UsdFileCfg(usd_path=usd_path) cfg = sim_utils.UsdFileCfg(usd_path=usd_path)
cfg.func(self.cfg.prim_path + f"/{key}", cfg) cfg.func(prim_path, 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. Operations - Origins.
""" """
def configure_env_origins(self, origins: np.ndarray | None = None): def configure_env_origins(self, origins: np.ndarray | torch.Tensor | None = None):
"""Configure the origins of the environments based on the added terrain. """Configure the origins of the environments based on the added terrain.
Args: Args:
...@@ -352,9 +339,7 @@ class TerrainImporter: ...@@ -352,9 +339,7 @@ class TerrainImporter:
# define all terrain levels and types available # define all terrain levels and types available
self.terrain_levels = torch.randint(0, max_init_level + 1, (num_envs,), device=self.device) self.terrain_levels = torch.randint(0, max_init_level + 1, (num_envs,), device=self.device)
self.terrain_types = torch.div( self.terrain_types = torch.div(
torch.arange(num_envs, device=self.device), torch.arange(num_envs, device=self.device), (num_envs / num_cols), rounding_mode="floor"
(num_envs / num_cols),
rounding_mode="floor",
).to(torch.long) ).to(torch.long)
# create tensor based on number of environments # create tensor based on number of environments
env_origins = torch.zeros(num_envs, 3, device=self.device) env_origins = torch.zeros(num_envs, 3, device=self.device)
...@@ -375,3 +360,33 @@ class TerrainImporter: ...@@ -375,3 +360,33 @@ class TerrainImporter:
env_origins[:, 1] = (jj.flatten()[:num_envs] - (num_cols - 1) / 2) * env_spacing env_origins[:, 1] = (jj.flatten()[:num_envs] - (num_cols - 1) / 2) * env_spacing
env_origins[:, 2] = 0.0 env_origins[:, 2] = 0.0
return env_origins return env_origins
"""
Deprecated.
"""
@property
def warp_meshes(self):
"""A dictionary containing the terrain's names and their warp meshes.
.. deprecated:: v2.1.0
The `warp_meshes` attribute is deprecated. It is no longer stored inside the class.
"""
omni.log.warn(
"The `warp_meshes` attribute is deprecated. It is no longer stored inside the `TerrainImporter` class."
" Returning an empty dictionary."
)
return {}
@property
def meshes(self) -> dict[str, trimesh.Trimesh]:
"""A dictionary containing the terrain's names and their tri-meshes.
.. deprecated:: v2.1.0
The `meshes` attribute is deprecated. It is no longer stored inside the class.
"""
omni.log.warn(
"The `meshes` attribute is deprecated. It is no longer stored inside the `TerrainImporter` class."
" Returning an empty dictionary."
)
return {}
...@@ -14,7 +14,9 @@ simulation_app = AppLauncher(headless=True).app ...@@ -14,7 +14,9 @@ simulation_app = AppLauncher(headless=True).app
import numpy as np import numpy as np
import torch import torch
import trimesh
import unittest import unittest
from typing import Literal
import isaacsim.core.utils.prims as prim_utils import isaacsim.core.utils.prims as prim_utils
import omni.kit import omni.kit
...@@ -24,9 +26,10 @@ from isaacsim.core.api.objects import DynamicSphere ...@@ -24,9 +26,10 @@ from isaacsim.core.api.objects import DynamicSphere
from isaacsim.core.cloner import GridCloner from isaacsim.core.cloner import GridCloner
from isaacsim.core.prims import RigidPrim, SingleGeometryPrim, SingleRigidPrim from isaacsim.core.prims import RigidPrim, SingleGeometryPrim, SingleRigidPrim
from isaacsim.core.utils.extensions import enable_extension from isaacsim.core.utils.extensions import enable_extension
from pxr import UsdGeom
import isaaclab.terrains as terrain_gen import isaaclab.terrains as terrain_gen
from isaaclab.sim import PreviewSurfaceCfg, SimulationContext, build_simulation_context from isaaclab.sim import PreviewSurfaceCfg, SimulationContext, build_simulation_context, get_first_matching_child_prim
from isaaclab.terrains import TerrainImporter, TerrainImporterCfg from isaaclab.terrains import TerrainImporter, TerrainImporterCfg
from isaaclab.terrains.config.rough import ROUGH_TERRAINS_CFG from isaaclab.terrains.config.rough import ROUGH_TERRAINS_CFG
from isaaclab.utils.assets import ISAAC_NUCLEUS_DIR from isaaclab.utils.assets import ISAAC_NUCLEUS_DIR
...@@ -81,8 +84,12 @@ class TestTerrainImporter(unittest.TestCase): ...@@ -81,8 +84,12 @@ class TestTerrainImporter(unittest.TestCase):
) )
terrain_importer = TerrainImporter(terrain_importer_cfg) terrain_importer = TerrainImporter(terrain_importer_cfg)
# check mesh exists # check if mesh prim path exists
mesh = terrain_importer.meshes["terrain"] mesh_prim_path = terrain_importer.cfg.prim_path + "/terrain"
self.assertIn(mesh_prim_path, terrain_importer.terrain_prim_paths)
# obtain underling mesh
mesh = self._obtain_collision_mesh(mesh_prim_path, mesh_type="Mesh")
self.assertIsNotNone(mesh) self.assertIsNotNone(mesh)
# calculate expected size from config # calculate expected size from config
...@@ -105,9 +112,6 @@ class TestTerrainImporter(unittest.TestCase): ...@@ -105,9 +112,6 @@ class TestTerrainImporter(unittest.TestCase):
with build_simulation_context(device=device, auto_add_lighting=True) as sim: with build_simulation_context(device=device, auto_add_lighting=True) as sim:
sim._app_control_on_stop_handle = None sim._app_control_on_stop_handle = None
expectedSizeX = 2.0e6
expectedSizeY = 2.0e6
# create custom material # create custom material
visual_material = PreviewSurfaceCfg(diffuse_color=(1.0, 0.0, 0.0)) if use_custom_material else None visual_material = PreviewSurfaceCfg(diffuse_color=(1.0, 0.0, 0.0)) if use_custom_material else None
# Handler for terrains importing # Handler for terrains importing
...@@ -120,16 +124,13 @@ class TestTerrainImporter(unittest.TestCase): ...@@ -120,16 +124,13 @@ class TestTerrainImporter(unittest.TestCase):
) )
terrain_importer = TerrainImporter(terrain_importer_cfg) terrain_importer = TerrainImporter(terrain_importer_cfg)
# check mesh exists # check if mesh prim path exists
mesh = terrain_importer.meshes["terrain"] mesh_prim_path = terrain_importer.cfg.prim_path + "/terrain"
self.assertIsNotNone(mesh) self.assertIn(mesh_prim_path, terrain_importer.terrain_prim_paths)
# get size from mesh bounds
bounds = mesh.bounds
actualSize = abs(bounds[1] - bounds[0])
self.assertAlmostEqual(actualSize[0], expectedSizeX) # obtain underling mesh
self.assertAlmostEqual(actualSize[1], expectedSizeY) mesh = self._obtain_collision_mesh(mesh_prim_path, mesh_type="Plane")
self.assertIsNone(mesh)
def test_usd(self) -> None: def test_usd(self) -> None:
"""Imports terrain from a usd and tests that the resulting mesh has the correct size.""" """Imports terrain from a usd and tests that the resulting mesh has the correct size."""
...@@ -146,8 +147,12 @@ class TestTerrainImporter(unittest.TestCase): ...@@ -146,8 +147,12 @@ class TestTerrainImporter(unittest.TestCase):
) )
terrain_importer = TerrainImporter(terrain_importer_cfg) terrain_importer = TerrainImporter(terrain_importer_cfg)
# check mesh exists # check if mesh prim path exists
mesh = terrain_importer.meshes["terrain"] mesh_prim_path = terrain_importer.cfg.prim_path + "/terrain"
self.assertIn(mesh_prim_path, terrain_importer.terrain_prim_paths)
# obtain underling mesh
mesh = self._obtain_collision_mesh(mesh_prim_path, mesh_type="Mesh")
self.assertIsNotNone(mesh) self.assertIsNotNone(mesh)
# expect values from USD file # expect values from USD file
...@@ -226,6 +231,25 @@ class TestTerrainImporter(unittest.TestCase): ...@@ -226,6 +231,25 @@ class TestTerrainImporter(unittest.TestCase):
Helper functions. Helper functions.
""" """
def _obtain_collision_mesh(
self, mesh_prim_path: str, mesh_type: Literal["Mesh", "Plane"]
) -> trimesh.Trimesh | None:
"""Get the collision mesh from the terrain."""
# traverse the prim and get the collision mesh
mesh_prim = get_first_matching_child_prim(mesh_prim_path, lambda prim: prim.GetTypeName() == mesh_type)
# check it is valid
self.assertTrue(mesh_prim.IsValid())
if mesh_prim.GetTypeName() == "Mesh":
# 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)
return trimesh.Trimesh(vertices=vertices, faces=faces)
else:
return None
@staticmethod @staticmethod
def _obtain_grid_cloner_env_origins(num_envs: int, env_spacing: float, device: str) -> torch.Tensor: def _obtain_grid_cloner_env_origins(num_envs: int, env_spacing: float, device: str) -> torch.Tensor:
"""Obtain the env origins generated by IsaacSim GridCloner (grid_cloner.py).""" """Obtain the env origins generated by IsaacSim GridCloner (grid_cloner.py)."""
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment