Source code for mjlab.terrains.primitive_terrains

"""Terrains composed of primitive geometries.

This module provides terrain generation functionality using primitive geometries,
adapted from the IsaacLab terrain generation system.

References:
  IsaacLab mesh terrain implementation:
  https://github.com/isaac-sim/IsaacLab/blob/main/source/isaaclab/isaaclab/terrains/trimesh/mesh_terrains.py
"""

from __future__ import annotations

from dataclasses import dataclass
from typing import Tuple

import mujoco
import numpy as np

from mjlab.terrains.terrain_generator import (
  SubTerrainCfg,
  TerrainGeometry,
  TerrainOutput,
)
from mjlab.terrains.utils import make_border, make_plane
from mjlab.utils.color import (
  HSV,
  brand_ramp,
  clamp,
  darken_rgba,
  hsv_to_rgb,
  rgb_to_hsv,
)

_MUJOCO_BLUE = (0.20, 0.45, 0.95)
_MUJOCO_RED = (0.90, 0.30, 0.30)
_MUJOCO_GREEN = (0.25, 0.80, 0.45)


def _get_platform_color(
  base_rgb: Tuple[float, float, float],
  desaturation_factor: float = 0.4,
  lightening_factor: float = 0.25,
) -> Tuple[float, float, float, float]:
  hsv = rgb_to_hsv(base_rgb)
  new_s = hsv.s * desaturation_factor
  new_v = clamp(hsv.v + lightening_factor)
  new_hsv = HSV(hsv.h, new_s, new_v)
  r, g, b = hsv_to_rgb(new_hsv)
  return (r, g, b, 1.0)


[docs] @dataclass(kw_only=True) class BoxFlatTerrainCfg(SubTerrainCfg):
[docs] def function( self, difficulty: float, spec: mujoco.MjSpec, rng: np.random.Generator ) -> TerrainOutput: del difficulty, rng # Unused. body = spec.body("terrain") origin = (self.size[0] / 2, self.size[1] / 2, 0.0) boxes = make_plane(body, self.size, 0.0, center_zero=False) box_colors = [(0.5, 0.5, 0.5, 1.0)] geometry = TerrainGeometry(geom=boxes[0], color=box_colors[0]) return TerrainOutput(origin=np.array(origin), geometries=[geometry])
[docs] @dataclass(kw_only=True) class BoxPyramidStairsTerrainCfg(SubTerrainCfg): """Configuration for a pyramid stairs terrain.""" border_width: float = 0.0 step_height_range: tuple[float, float] step_width: float platform_width: float = 1.0 holes: bool = False
[docs] def function( self, difficulty: float, spec: mujoco.MjSpec, rng: np.random.Generator ) -> TerrainOutput: del rng # Unused. boxes = [] box_colors = [] body = spec.body("terrain") step_height = self.step_height_range[0] + difficulty * ( self.step_height_range[1] - self.step_height_range[0] ) # Compute number of steps in x and y direction. num_steps_x = (self.size[0] - 2 * self.border_width - self.platform_width) // ( 2 * self.step_width ) + 1 num_steps_y = (self.size[1] - 2 * self.border_width - self.platform_width) // ( 2 * self.step_width ) + 1 num_steps = int(min(num_steps_x, num_steps_y)) first_step_rgba = brand_ramp(_MUJOCO_BLUE, 0.0) border_rgba = darken_rgba(first_step_rgba, 0.85) if self.border_width > 0.0 and not self.holes: border_center = (0.5 * self.size[0], 0.5 * self.size[1], -step_height / 2) border_inner_size = ( self.size[0] - 2 * self.border_width, self.size[1] - 2 * self.border_width, ) border_boxes = make_border( body, self.size, border_inner_size, step_height, border_center ) boxes.extend(border_boxes) for _ in range(len(border_boxes)): box_colors.append(border_rgba) terrain_center = [0.5 * self.size[0], 0.5 * self.size[1], 0.0] terrain_size = ( self.size[0] - 2 * self.border_width, self.size[1] - 2 * self.border_width, ) for k in range(num_steps): t = k / max(num_steps - 1, 1) rgba = brand_ramp(_MUJOCO_BLUE, t) for _ in range(4): box_colors.append(rgba) if self.holes: box_size = (self.platform_width, self.platform_width) else: box_size = ( terrain_size[0] - 2 * k * self.step_width, terrain_size[1] - 2 * k * self.step_width, ) box_z = terrain_center[2] + k * step_height / 2.0 box_offset = (k + 0.5) * self.step_width box_height = (k + 2) * step_height box_dims = (box_size[0], self.step_width, box_height) # Top. box_pos = ( terrain_center[0], terrain_center[1] + terrain_size[1] / 2.0 - box_offset, box_z, ) box = body.add_geom( type=mujoco.mjtGeom.mjGEOM_BOX, size=(box_dims[0] / 2.0, box_dims[1] / 2.0, box_dims[2] / 2.0), pos=box_pos, ) boxes.append(box) # Bottom. box_pos = ( terrain_center[0], terrain_center[1] - terrain_size[1] / 2.0 + box_offset, box_z, ) box = body.add_geom( type=mujoco.mjtGeom.mjGEOM_BOX, size=(box_dims[0] / 2.0, box_dims[1] / 2.0, box_dims[2] / 2.0), pos=box_pos, ) boxes.append(box) if self.holes: box_dims = (self.step_width, box_size[1], box_height) else: box_dims = (self.step_width, box_size[1] - 2 * self.step_width, box_height) # Right. box_pos = ( terrain_center[0] + terrain_size[0] / 2.0 - box_offset, terrain_center[1], box_z, ) box = body.add_geom( type=mujoco.mjtGeom.mjGEOM_BOX, size=(box_dims[0] / 2.0, box_dims[1] / 2.0, box_dims[2] / 2.0), pos=box_pos, ) boxes.append(box) # Left. box_pos = ( terrain_center[0] - terrain_size[0] / 2.0 + box_offset, terrain_center[1], box_z, ) box = body.add_geom( type=mujoco.mjtGeom.mjGEOM_BOX, size=(box_dims[0] / 2.0, box_dims[1] / 2.0, box_dims[2] / 2.0), pos=box_pos, ) boxes.append(box) # Generate final box for the middle of the terrain. box_dims = ( terrain_size[0] - 2 * num_steps * self.step_width, terrain_size[1] - 2 * num_steps * self.step_width, (num_steps + 2) * step_height, ) box_pos = ( terrain_center[0], terrain_center[1], terrain_center[2] + num_steps * step_height / 2, ) box = body.add_geom( type=mujoco.mjtGeom.mjGEOM_BOX, size=(box_dims[0] / 2.0, box_dims[1] / 2.0, box_dims[2] / 2.0), pos=box_pos, ) boxes.append(box) origin = np.array( [terrain_center[0], terrain_center[1], (num_steps + 1) * step_height] ) platform_rgba = _get_platform_color(_MUJOCO_BLUE) box_colors.append(platform_rgba) geometries = [ TerrainGeometry(geom=box, color=color) for box, color in zip(boxes, box_colors, strict=True) ] return TerrainOutput(origin=origin, geometries=geometries)
[docs] @dataclass(kw_only=True) class BoxInvertedPyramidStairsTerrainCfg(BoxPyramidStairsTerrainCfg):
[docs] def function( self, difficulty: float, spec: mujoco.MjSpec, rng: np.random.Generator ) -> TerrainOutput: del rng # Unused. boxes = [] box_colors = [] body = spec.body("terrain") step_height = self.step_height_range[0] + difficulty * ( self.step_height_range[1] - self.step_height_range[0] ) # Compute number of steps in x and y direction. num_steps_x = (self.size[0] - 2 * self.border_width - self.platform_width) // ( 2 * self.step_width ) + 1 num_steps_y = (self.size[1] - 2 * self.border_width - self.platform_width) // ( 2 * self.step_width ) + 1 num_steps = int(min(num_steps_x, num_steps_y)) total_height = (num_steps + 1) * step_height first_step_rgba = brand_ramp(_MUJOCO_RED, 0.0) border_rgba = darken_rgba(first_step_rgba, 0.85) if self.border_width > 0.0 and not self.holes: border_center = (0.5 * self.size[0], 0.5 * self.size[1], -0.5 * step_height) border_inner_size = ( self.size[0] - 2 * self.border_width, self.size[1] - 2 * self.border_width, ) border_boxes = make_border( body, self.size, border_inner_size, step_height, border_center ) boxes.extend(border_boxes) for _ in range(len(border_boxes)): box_colors.append(border_rgba) terrain_center = [0.5 * self.size[0], 0.5 * self.size[1], 0.0] terrain_size = ( self.size[0] - 2 * self.border_width, self.size[1] - 2 * self.border_width, ) for k in range(num_steps): t = k / max(num_steps - 1, 1) rgba = brand_ramp(_MUJOCO_RED, t) for _ in range(4): box_colors.append(rgba) if self.holes: box_size = (self.platform_width, self.platform_width) else: box_size = ( terrain_size[0] - 2 * k * self.step_width, terrain_size[1] - 2 * k * self.step_width, ) box_z = terrain_center[2] - total_height / 2 - (k + 1) * step_height / 2.0 box_offset = (k + 0.5) * self.step_width box_height = total_height - (k + 1) * step_height box_dims = (box_size[0], self.step_width, box_height) # Top. box_pos = ( terrain_center[0], terrain_center[1] + terrain_size[1] / 2.0 - box_offset, box_z, ) box = body.add_geom( type=mujoco.mjtGeom.mjGEOM_BOX, size=(box_dims[0] / 2.0, box_dims[1] / 2.0, box_dims[2] / 2.0), pos=box_pos, ) boxes.append(box) # Bottom. box_pos = ( terrain_center[0], terrain_center[1] - terrain_size[1] / 2.0 + box_offset, box_z, ) box = body.add_geom( type=mujoco.mjtGeom.mjGEOM_BOX, size=(box_dims[0] / 2.0, box_dims[1] / 2.0, box_dims[2] / 2.0), pos=box_pos, ) boxes.append(box) if self.holes: box_dims = (self.step_width, box_size[1], box_height) else: box_dims = (self.step_width, box_size[1] - 2 * self.step_width, box_height) # Right. box_pos = ( terrain_center[0] + terrain_size[0] / 2.0 - box_offset, terrain_center[1], box_z, ) box = body.add_geom( type=mujoco.mjtGeom.mjGEOM_BOX, size=(box_dims[0] / 2.0, box_dims[1] / 2.0, box_dims[2] / 2.0), pos=box_pos, ) boxes.append(box) # Left. box_pos = ( terrain_center[0] - terrain_size[0] / 2.0 + box_offset, terrain_center[1], box_z, ) box = body.add_geom( type=mujoco.mjtGeom.mjGEOM_BOX, size=(box_dims[0] / 2.0, box_dims[1] / 2.0, box_dims[2] / 2.0), pos=box_pos, ) boxes.append(box) # Generate final box for the middle of the terrain. box_dims = ( terrain_size[0] - 2 * num_steps * self.step_width, terrain_size[1] - 2 * num_steps * self.step_width, step_height, ) box_pos = ( terrain_center[0], terrain_center[1], terrain_center[2] - total_height - step_height / 2, ) box = body.add_geom( type=mujoco.mjtGeom.mjGEOM_BOX, size=(box_dims[0] / 2.0, box_dims[1] / 2.0, box_dims[2] / 2.0), pos=box_pos, ) boxes.append(box) origin = np.array( [terrain_center[0], terrain_center[1], -(num_steps + 1) * step_height] ) platform_rgba = _get_platform_color(_MUJOCO_RED) box_colors.append(platform_rgba) geometries = [ TerrainGeometry(geom=box, color=color) for box, color in zip(boxes, box_colors, strict=True) ] return TerrainOutput(origin=origin, geometries=geometries)
[docs] @dataclass(kw_only=True) class BoxRandomGridTerrainCfg(SubTerrainCfg): grid_width: float grid_height_range: tuple[float, float] platform_width: float = 1.0 holes: bool = False merge_similar_heights: bool = False height_merge_threshold: float = 0.05 max_merge_distance: int = 3
[docs] def function( self, difficulty: float, spec: mujoco.MjSpec, rng: np.random.Generator ) -> TerrainOutput: if self.size[0] != self.size[1]: raise ValueError(f"The terrain must be square. Received size: {self.size}.") grid_height = self.grid_height_range[0] + difficulty * ( self.grid_height_range[1] - self.grid_height_range[0] ) body = spec.body("terrain") boxes_list = [] box_colors = [] num_boxes_x = int(self.size[0] / self.grid_width) num_boxes_y = int(self.size[1] / self.grid_width) terrain_height = 1.0 border_width = self.size[0] - min(num_boxes_x, num_boxes_y) * self.grid_width if border_width <= 0: raise RuntimeError( "Border width must be greater than 0! Adjust the parameter 'self.grid_width'." ) border_thickness = border_width / 2 border_center_z = -terrain_height / 2 half_size = self.size[0] / 2 half_border = border_thickness / 2 half_terrain = terrain_height / 2 first_step_rgba = brand_ramp(_MUJOCO_GREEN, 0.0) border_rgba = darken_rgba(first_step_rgba, 0.85) border_specs = [ ( (half_size, half_border, half_terrain), (half_size, self.size[1] - half_border, border_center_z), ), ( (half_size, half_border, half_terrain), (half_size, half_border, border_center_z), ), ( (half_border, (self.size[1] - 2 * border_thickness) / 2, half_terrain), (half_border, half_size, border_center_z), ), ( (half_border, (self.size[1] - 2 * border_thickness) / 2, half_terrain), (self.size[0] - half_border, half_size, border_center_z), ), ] for size, pos in border_specs: box = body.add_geom( type=mujoco.mjtGeom.mjGEOM_BOX, size=size, pos=pos, ) boxes_list.append(box) box_colors.append(border_rgba) height_map = rng.uniform(-grid_height, grid_height, (num_boxes_x, num_boxes_y)) if self.merge_similar_heights and not self.holes: box_list_, box_color_ = self._create_merged_boxes( body, height_map, num_boxes_x, num_boxes_y, grid_height, terrain_height, border_width, ) boxes_list.extend(box_list_) box_colors.extend(box_color_) else: box_list_, box_color_ = self._create_individual_boxes( body, height_map, num_boxes_x, num_boxes_y, grid_height, terrain_height, border_width, ) boxes_list.extend(box_list_) box_colors.extend(box_color_) # Platform platform_height = terrain_height + grid_height platform_center_z = -terrain_height / 2 + grid_height / 2 half_platform = self.platform_width / 2 box = body.add_geom( type=mujoco.mjtGeom.mjGEOM_BOX, size=(half_platform, half_platform, platform_height / 2), pos=(self.size[0] / 2, self.size[1] / 2, platform_center_z), ) boxes_list.append(box) platform_rgba = _get_platform_color(_MUJOCO_GREEN) box_colors.append(platform_rgba) origin = np.array([self.size[0] / 2, self.size[1] / 2, grid_height]) geometries = [ TerrainGeometry(geom=box, color=color) for box, color in zip(boxes_list, box_colors, strict=True) ] return TerrainOutput(origin=origin, geometries=geometries)
def _create_merged_boxes( self, body, height_map, num_boxes_x, num_boxes_y, grid_height, terrain_height, border_width, ): """Create merged boxes for similar heights to reduce geom count.""" boxes = [] box_colors = [] visited = np.zeros((num_boxes_x, num_boxes_y), dtype=bool) half_border_width = border_width / 2 neg_half_terrain = -terrain_height / 2 # Quantize heights to create more merging opportunities quantized_heights = ( np.round(height_map / self.height_merge_threshold) * self.height_merge_threshold ) for i in range(num_boxes_x): for j in range(num_boxes_y): if visited[i, j]: continue # Find rectangular region with similar height height = quantized_heights[i, j] normalized_height = (height + grid_height) / (2 * grid_height) t = float(np.clip(normalized_height, 0.0, 1.0)) rgba = brand_ramp(_MUJOCO_GREEN, t) # Greedy expansion in x and y directions max_x = i + 1 max_y = j + 1 # Try to expand in x direction first while max_x < min(i + self.max_merge_distance, num_boxes_x): if not visited[max_x, j] and abs(quantized_heights[max_x, j] - height) < 1e-6: max_x += 1 else: break # Then expand in y direction for the found x range can_expand_y = True while max_y < min(j + self.max_merge_distance, num_boxes_y) and can_expand_y: for x in range(i, max_x): if visited[x, max_y] or abs(quantized_heights[x, max_y] - height) > 1e-6: can_expand_y = False break if can_expand_y: max_y += 1 # Mark region as visited visited[i:max_x, j:max_y] = True # Create merged box width_x = (max_x - i) * self.grid_width width_y = (max_y - j) * self.grid_width box_center_x = half_border_width + (i + (max_x - i) / 2) * self.grid_width box_center_y = half_border_width + (j + (max_y - j) / 2) * self.grid_width box_height = terrain_height + height box_center_z = neg_half_terrain + height / 2 box = body.add_geom( type=mujoco.mjtGeom.mjGEOM_BOX, size=(width_x / 2, width_y / 2, box_height / 2), pos=(box_center_x, box_center_y, box_center_z), ) boxes.append(box) box_colors.append(rgba) return boxes, box_colors def _create_individual_boxes( self, body, height_map, num_boxes_x, num_boxes_y, grid_height, terrain_height, border_width, ): """Original approach with individual boxes.""" boxes = [] box_colors = [] half_grid = self.grid_width / 2 half_border_width = border_width / 2 neg_half_terrain = -terrain_height / 2 if self.holes: platform_half = self.platform_width / 2 terrain_center = self.size[0] / 2 platform_min = terrain_center - platform_half platform_max = terrain_center + platform_half else: platform_min = None platform_max = None for i in range(num_boxes_x): box_center_x = half_border_width + (i + 0.5) * self.grid_width if self.holes and not (platform_min <= box_center_x <= platform_max): in_y_strip = False else: in_y_strip = True for j in range(num_boxes_y): box_center_y = half_border_width + (j + 0.5) * self.grid_width if self.holes: in_x_strip = platform_min <= box_center_y <= platform_max if not (in_x_strip or in_y_strip): continue height_noise = height_map[i, j] box_height = terrain_height + height_noise box_center_z = neg_half_terrain + height_noise / 2 normalized_height = (height_noise + grid_height) / (2 * grid_height) t = float(np.clip(normalized_height, 0.0, 1.0)) rgba = brand_ramp(_MUJOCO_GREEN, t) box_colors.append(rgba) box = body.add_geom( type=mujoco.mjtGeom.mjGEOM_BOX, size=(half_grid, half_grid, box_height / 2), pos=(box_center_x, box_center_y, box_center_z), ) boxes.append(box) return boxes, box_colors