Files

181 lines
6.1 KiB
Python
Raw Permalink Normal View History

"""Tests for terrain generation."""
from dataclasses import dataclass
import mujoco
import numpy as np
import pytest
from mjlab.terrains.config import ALL_TERRAIN_PRESETS
from mjlab.terrains.primitive_terrains import (
_MIN_BORDER_HEIGHT,
BoxInvertedPyramidStairsTerrainCfg,
BoxPyramidStairsTerrainCfg,
BoxSteppingStonesTerrainCfg,
)
from mjlab.terrains.terrain_generator import (
SubTerrainCfg,
TerrainGenerator,
TerrainGeneratorCfg,
TerrainGeometry,
TerrainOutput,
)
_CFG = BoxSteppingStonesTerrainCfg(
proportion=1.0,
size=(8.0, 8.0),
stone_size_range=(0.2, 0.6),
stone_distance_range=(0.05, 0.25),
stone_height=0.2,
stone_height_variation=0.05,
stone_size_variation=0.05,
displacement_range=0.1,
floor_depth=2.0,
platform_width=1.5,
border_width=0.25,
)
def _generate_stones(
cfg: BoxSteppingStonesTerrainCfg,
difficulty: float,
rng: np.random.Generator,
) -> list[tuple[float, float, float, float]]:
"""Generate terrain and return stone (cx, cy, half_x, half_y) tuples."""
spec = mujoco.MjSpec()
spec.worldbody.add_body(name="terrain")
output = cfg.function(difficulty=difficulty, spec=spec, rng=rng)
center = cfg.size[0] / 2
stones = []
for geom_info in output.geometries:
geom = geom_info.geom
if geom is None:
continue
pos, size = geom.pos, geom.size
# Skip platform, floor, and border geoms. The platform is the geom centered
# exactly at the patch center (its size is grid-snapped, not the configured
# width, so it is identified by position alone).
is_platform = np.isclose(pos[0], center) and np.isclose(pos[1], center)
is_full_span = np.isclose(size[0], cfg.size[0] / 2) or np.isclose(
size[1], cfg.size[1] / 2
)
if is_platform or is_full_span:
continue
stones.append((pos[0], pos[1], size[0], size[1]))
return stones
def test_no_stone_centers_inside_platform():
"""No stone center should fall inside the platform."""
center = _CFG.size[0] / 2
p_half = _CFG.platform_width / 2
p_min, p_max = center - p_half, center + p_half
for difficulty in [0.0, 0.5, 1.0]:
stones = _generate_stones(_CFG, difficulty, np.random.default_rng(42))
for cx, cy, _, _ in stones:
assert not (p_min <= cx <= p_max and p_min <= cy <= p_max), (
f"Stone at ({cx:.3f}, {cy:.3f}) inside platform at difficulty={difficulty}"
)
def test_stone_size_decreases_with_difficulty():
"""Average stone size should be smaller at higher difficulty."""
sizes = {}
for difficulty in [0.0, 1.0]:
stones = _generate_stones(_CFG, difficulty, np.random.default_rng(42))
sizes[difficulty] = np.mean([hx + hy for _, _, hx, hy in stones])
assert sizes[0.0] > sizes[1.0]
@pytest.mark.parametrize(
"cfg_cls", [BoxPyramidStairsTerrainCfg, BoxInvertedPyramidStairsTerrainCfg]
)
def test_pyramid_stairs_border_present_at_zero_difficulty(cfg_cls):
"""At difficulty 0 the step height collapses to 0, but the flat border frame
must still be generated as solid, non-degenerate geometry (regression for the
empty-boundary bug, issue #1033)."""
cfg = cfg_cls(
size=(8.0, 8.0),
step_height_range=(0.0, 0.2),
step_width=0.3,
platform_width=3.0,
border_width=1.0,
)
spec = mujoco.MjSpec()
spec.worldbody.add_body(name="terrain")
output = cfg.function(difficulty=0.0, spec=spec, rng=np.random.default_rng(0))
# The border frame sits below z=0 (top flush at ground level); inner step
# boxes are centered at z=0. Identify the frame by its downward offset.
border_geoms = [
g.geom for g in output.geometries if g.geom is not None and g.geom.pos[2] < -1e-4
]
assert len(border_geoms) == 4, "Expected four border frame boxes."
for geom in border_geoms:
# Each frame box must be solid, not a degenerate zero-height geom, and its
# top must be flush with the ground plane at z=0.
assert geom.size[2] >= _MIN_BORDER_HEIGHT / 2 - 1e-9
assert np.isclose(geom.pos[2] + geom.size[2], 0.0, atol=1e-6)
@pytest.mark.parametrize("preset_name", sorted(ALL_TERRAIN_PRESETS))
@pytest.mark.parametrize("difficulty", [0.0, 1.0])
def test_preset_compiles_across_difficulty(preset_name, difficulty):
"""Every terrain preset must generate compilable MuJoCo geometry across the
full difficulty range. Difficulty 0 is exercised explicitly because curriculum
row 0 lands there deterministically, which previously produced degenerate
geometry (zero-height hfields, NaN colors, missing borders)."""
cfg = ALL_TERRAIN_PRESETS[preset_name](size=(8.0, 8.0))
spec = mujoco.MjSpec()
spec.worldbody.add_body(name="terrain")
cfg.function(difficulty=difficulty, spec=spec, rng=np.random.default_rng(0))
# Compiling validates geom/hfield sizes and rgba values (catches NaNs and
# non-positive sizes that MuJoCo rejects).
spec.compile()
@dataclass(kw_only=True)
class _BoxesTerrainCfg(SubTerrainCfg):
"""Test terrain that emits two boxes, named per `names` (None leaves unnamed)."""
names: tuple[str | None, str | None] = (None, None)
def function(
self, difficulty: float, spec: mujoco.MjSpec, rng: np.random.Generator
) -> TerrainOutput:
del difficulty, rng
body = spec.body("terrain")
geometries = []
for i in range(2):
geom = body.add_geom(
type=mujoco.mjtGeom.mjGEOM_BOX,
size=(0.5, 0.5, 0.5),
pos=(i, 0, 0),
name=self.names[i],
)
geometries.append(TerrainGeometry(geom=geom))
return TerrainOutput(origin=np.zeros(3), geometries=geometries)
def test_compile_preserves_custom_names_and_defaults_unnamed_geoms():
"""Geoms with a custom name from a sub-terrain must be preserved, while
unnamed geoms must fall back to the generator's default "terrain_{i}" name."""
cfg = TerrainGeneratorCfg(
size=(4.0, 4.0),
curriculum=True,
sub_terrains={
"named": _BoxesTerrainCfg(size=(4.0, 4.0), names=("foo_0", "foo_1")),
"unnamed": _BoxesTerrainCfg(size=(4.0, 4.0)),
},
)
generator = TerrainGenerator(cfg)
spec = mujoco.MjSpec()
generator.compile(spec)
model = spec.compile()
names = [model.geom(i).name for i in range(model.ngeom)]
assert names == ["foo_0", "foo_1", "terrain_2", "terrain_3"]