Skip to content

Commit 6fdf627

Browse files
authored
Merge pull request #14 from KraHsu/feat/m3-terrain-generator
Introduce terrains/ namespace backed by gs.morphs.Terrain
2 parents 3b143d0 + b1bda38 commit 6fdf627

11 files changed

Lines changed: 577 additions & 5 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,14 @@ trajectory so breaking changes can land in any minor release until the 1.0 stabi
88

99
### Added
1010

11+
- `genelab.terrains` namespace with `SubTerrainCfg` (abstract) and three concrete
12+
sub-terrains: `FlatPatchCfg`, `PyramidStairsCfg`, `RandomRoughCfg`. `TerrainGeneratorCfg`
13+
composes them into a 2D grid (random by proportion or explicit `layout`); `TerrainGenerator`
14+
emits Genesis kwargs and per-cell `env_origins`; `TerrainImporter` spawns
15+
`gs.morphs.Terrain` and exposes the post-build `heightfield` for sensors (PR2).
16+
- `InteractiveSceneCfg.terrain` now accepts a `TerrainGeneratorCfg`; `InteractiveScene`
17+
routes through `TerrainImporter` when set, falling back to the default flat plane
18+
otherwise. `InteractiveScene.terrain` exposes the active importer to consumers.
1119
- `genelab.actuator` namespace with `ActuatorBase` and three concrete electromechanical
1220
models: `ImplicitPDActuator` (Genesis-internal PD), `IdealPDActuator` (Python-side PD
1321
via `control_dofs_force`), `DCMotorActuator` (`IdealPD` plus driving-direction torque

‎src/genelab/configs.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
from genelab.entity import ArticulationCfg, RigidObjectCfg
99
from genelab.sensor import SensorCfg
10+
from genelab.terrains import TerrainGeneratorCfg
1011

1112
type _Annotation = object
1213

@@ -31,7 +32,7 @@ class InteractiveSceneCfg:
3132
sensors: tuple[SensorCfg, ...] = field(default_factory=tuple)
3233
mouse_interaction: bool = False
3334
entities: dict[str, ArticulationCfg | RigidObjectCfg] = field(default_factory=dict)
34-
terrain: object | None = None # M3 placeholder; populated by ``TerrainCfg`` later.
35+
terrain: TerrainGeneratorCfg | None = None
3536

3637

3738
@dataclass

‎src/genelab/lab.py‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,15 @@
5252
TerrainHeightSensor,
5353
TerrainHeightSensorCfg,
5454
)
55+
from genelab.terrains import (
56+
FlatPatchCfg,
57+
PyramidStairsCfg,
58+
RandomRoughCfg,
59+
SubTerrainCfg,
60+
TerrainGenerator,
61+
TerrainGeneratorCfg,
62+
TerrainImporter,
63+
)
5564

5665

5766
class ManagerBasedEnv(Protocol):
@@ -85,6 +94,7 @@ class GenesisBackendCfg:
8594
"ContactSensorCfg",
8695
"DCMotorActuator",
8796
"DCMotorActuatorCfg",
97+
"FlatPatchCfg",
8898
"GenesisBackendCfg",
8999
"Gnoise",
90100
"GridPattern",
@@ -97,6 +107,8 @@ class GenesisBackendCfg:
97107
"ManagerBasedEnv",
98108
"ManagerBasedEnvCfg",
99109
"NoiseCfg",
110+
"PyramidStairsCfg",
111+
"RandomRoughCfg",
100112
"RayCastData",
101113
"RayCastSensor",
102114
"RayCastSensorCfg",
@@ -108,9 +120,13 @@ class GenesisBackendCfg:
108120
"Sensor",
109121
"SensorCfg",
110122
"SimulationCfg",
123+
"SubTerrainCfg",
111124
"TaskCfg",
125+
"TerrainGenerator",
126+
"TerrainGeneratorCfg",
112127
"TerrainHeightSensor",
113128
"TerrainHeightSensorCfg",
129+
"TerrainImporter",
114130
"Unoise",
115131
"apply_overrides",
116132
"load_builtin_registries",

‎src/genelab/scene/interactive_scene.py‎

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222

2323
from genelab.entity import Articulation, ArticulationCfg, RigidObject, RigidObjectCfg
2424
from genelab.entity._torch import to_tensor
25+
from genelab.terrains import TerrainImporter
2526

2627

2728
class InteractiveScene:
@@ -45,6 +46,11 @@ def __init__(
4546
self._entities: dict[str, Articulation | RigidObject] = {}
4647
for name, entity_cfg in dict(scene_cfg.entities).items():
4748
self._entities[name] = self._make_entity(name, entity_cfg)
49+
# Terrain is optional. When None, ``build()`` adds a default flat plane (matches
50+
# M2 behavior); when set, the importer spawns a Genesis ``Terrain`` morph instead.
51+
self._terrain: TerrainImporter | None = (
52+
TerrainImporter(scene_cfg.terrain) if scene_cfg.terrain is not None else None
53+
)
4854

4955
def add_entity(self, name: str, cfg: ArticulationCfg | RigidObjectCfg) -> None:
5056
"""Register an additional entity before ``build()``."""
@@ -95,7 +101,10 @@ def build(self) -> None:
95101
sim_options=sim_options,
96102
show_viewer=self._sim_cfg.vis,
97103
)
98-
self._gs_scene.add_entity(gs.morphs.Plane())
104+
if self._terrain is None:
105+
self._gs_scene.add_entity(gs.morphs.Plane())
106+
else:
107+
self._terrain.spawn(self._gs_scene)
99108
for entity in self._entities.values():
100109
entity.spawn(self._gs_scene)
101110

@@ -197,3 +206,8 @@ def env_origins(self) -> torch.Tensor:
197206
@property
198207
def gs_scene(self) -> Any:
199208
return self._gs_scene
209+
210+
@property
211+
def terrain(self) -> TerrainImporter | None:
212+
"""The active :class:`TerrainImporter`, or ``None`` for the default flat plane."""
213+
return self._terrain

‎src/genelab/terrains/__init__.py‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,20 @@
11
"""Terrain generation and terrain asset extension points."""
2+
3+
from genelab.terrains.generator import TerrainGenerator, TerrainGeneratorCfg
4+
from genelab.terrains.importer import TerrainImporter
5+
from genelab.terrains.sub_terrain import (
6+
FlatPatchCfg,
7+
PyramidStairsCfg,
8+
RandomRoughCfg,
9+
SubTerrainCfg,
10+
)
11+
12+
__all__ = [
13+
"FlatPatchCfg",
14+
"PyramidStairsCfg",
15+
"RandomRoughCfg",
16+
"SubTerrainCfg",
17+
"TerrainGenerator",
18+
"TerrainGeneratorCfg",
19+
"TerrainImporter",
20+
]

‎src/genelab/terrains/generator.py‎

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
"""Terrain generator: dataclass cfg → 2D layout + Genesis kwargs.
2+
3+
The generator is a pure-Python helper: it builds the 2D ``subterrain_types`` layout
4+
(random by proportion or explicit), aggregates the per-type parameters into the dict
5+
shape Genesis expects, and computes the planar centers of each cell so callers can
6+
place envs on them later (curriculum reset, locomotion goals).
7+
8+
Note: Genesis keys ``subterrain_parameters`` by the *type string*, not per cell. If
9+
two ``SubTerrainCfg`` instances map to the same ``genesis_type`` (e.g. two
10+
``PyramidStairsCfg`` with different ``step_width``), only one parameter set survives.
11+
Use distinct keys in ``TerrainGeneratorCfg.sub_terrains`` for distinct geometries; for
12+
in-type variation use ``randomize=True`` on the importer.
13+
"""
14+
15+
import random
16+
from dataclasses import dataclass, field
17+
from typing import Any
18+
19+
import torch
20+
21+
from genelab.terrains.sub_terrain import SubTerrainCfg
22+
23+
24+
@dataclass
25+
class TerrainGeneratorCfg:
26+
"""Composition of a heightfield terrain grid.
27+
28+
The grid is ``num_rows`` × ``num_cols`` cells; each cell has size
29+
``subterrain_size`` (in meters). When ``layout`` is ``None``, cell types are
30+
sampled by ``SubTerrainCfg.proportion`` using a deterministic ``seed``.
31+
"""
32+
33+
num_rows: int = 4
34+
num_cols: int = 4
35+
subterrain_size: tuple[float, float] = (8.0, 8.0)
36+
horizontal_scale: float = 0.1
37+
vertical_scale: float = 0.005
38+
pos: tuple[float, float, float] = (0.0, 0.0, 0.0)
39+
sub_terrains: dict[str, SubTerrainCfg] = field(default_factory=dict)
40+
layout: tuple[tuple[str, ...], ...] | None = None
41+
curriculum: bool = False
42+
seed: int = 0
43+
44+
45+
class TerrainGenerator:
46+
"""Translate :class:`TerrainGeneratorCfg` into Genesis ``Terrain`` kwargs."""
47+
48+
def __init__(self, cfg: TerrainGeneratorCfg) -> None:
49+
if not cfg.sub_terrains:
50+
raise ValueError("TerrainGeneratorCfg.sub_terrains must contain at least one entry")
51+
self.cfg = cfg
52+
self._layout: list[list[str]] = self._build_layout()
53+
self._env_origins: torch.Tensor = self._compute_origins()
54+
55+
# ------------------------------------------------------------------ layout
56+
57+
def _build_layout(self) -> list[list[str]]:
58+
cfg = self.cfg
59+
if cfg.layout is not None:
60+
rows = [list(row) for row in cfg.layout]
61+
if len(rows) != cfg.num_rows or any(len(r) != cfg.num_cols for r in rows):
62+
raise ValueError(
63+
f"layout shape {len(rows)}x{len(rows[0]) if rows else 0} "
64+
f"does not match num_rows x num_cols = {cfg.num_rows}x{cfg.num_cols}"
65+
)
66+
unknown = {name for row in rows for name in row} - set(cfg.sub_terrains.keys())
67+
if unknown:
68+
raise ValueError(f"layout references unknown sub_terrains: {sorted(unknown)}")
69+
return rows
70+
# Random tiling weighted by proportion.
71+
names = list(cfg.sub_terrains.keys())
72+
weights = [cfg.sub_terrains[n].proportion for n in names]
73+
if sum(weights) <= 0:
74+
raise ValueError("sub_terrains proportions sum to zero")
75+
rng = random.Random(cfg.seed)
76+
# PR3 (curriculum) will refine row ordering by difficulty; for PR1 every row uses
77+
# the same proportion distribution regardless of ``cfg.curriculum``.
78+
return [rng.choices(names, weights=weights, k=cfg.num_cols) for _ in range(cfg.num_rows)]
79+
80+
# ------------------------------------------------------------------ origins
81+
82+
def _compute_origins(self) -> torch.Tensor:
83+
cfg = self.cfg
84+
sx, sy = cfg.subterrain_size
85+
px, py, pz = cfg.pos
86+
origins = torch.zeros((cfg.num_rows, cfg.num_cols, 3))
87+
for i in range(cfg.num_rows):
88+
for j in range(cfg.num_cols):
89+
origins[i, j, 0] = px + (i + 0.5) * sx
90+
origins[i, j, 1] = py + (j + 0.5) * sy
91+
origins[i, j, 2] = pz
92+
return origins
93+
94+
# ------------------------------------------------------------------ outputs
95+
96+
@property
97+
def layout(self) -> list[list[str]]:
98+
"""2D list of sub-terrain cfg keys at each (row, col)."""
99+
return self._layout
100+
101+
@property
102+
def env_origins(self) -> torch.Tensor:
103+
"""Per-cell planar centers, shape ``(num_rows, num_cols, 3)``.
104+
105+
``z`` is set to ``cfg.pos[2]`` (the terrain root height); callers needing the
106+
actual surface elevation should sample :attr:`TerrainImporter.heightfield`
107+
after spawn.
108+
"""
109+
return self._env_origins
110+
111+
@property
112+
def genesis_kwargs(self) -> dict[str, Any]:
113+
"""Kwargs ready to splat into ``gs.morphs.Terrain(...)``."""
114+
cfg = self.cfg
115+
types_2d = [[cfg.sub_terrains[name].genesis_type() for name in row] for row in self._layout]
116+
params: dict[str, dict[str, Any]] = {}
117+
for sub_cfg in cfg.sub_terrains.values():
118+
gtype = sub_cfg.genesis_type()
119+
p = sub_cfg.to_genesis_params()
120+
if p:
121+
params[gtype] = p
122+
kwargs: dict[str, Any] = {
123+
"n_subterrains": (cfg.num_rows, cfg.num_cols),
124+
"subterrain_size": cfg.subterrain_size,
125+
"horizontal_scale": cfg.horizontal_scale,
126+
"vertical_scale": cfg.vertical_scale,
127+
"subterrain_types": types_2d,
128+
"pos": cfg.pos,
129+
}
130+
if params:
131+
kwargs["subterrain_parameters"] = params
132+
return kwargs

‎src/genelab/terrains/importer.py‎

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
"""Terrain importer: bridges GeneLab cfg to Genesis ``gs.morphs.Terrain``.
2+
3+
The importer is a thin spawn wrapper that mirrors :class:`genelab.entity.RigidObject`'s
4+
two-phase API. :class:`genelab.scene.InteractiveScene` instantiates it pre-build, then
5+
calls :meth:`spawn` to add the terrain morph to the Genesis scene. After ``scene.build``
6+
returns, :attr:`heightfield` and :attr:`terrain_scale` expose the post-construction
7+
data Genesis fills in (used by the ray-cast sensor in PR2).
8+
"""
9+
10+
from typing import Any
11+
12+
import torch
13+
14+
from genelab.terrains.generator import TerrainGenerator, TerrainGeneratorCfg
15+
16+
17+
class TerrainImporter:
18+
"""Owns a :class:`TerrainGenerator` and the spawned Genesis terrain entity."""
19+
20+
def __init__(self, cfg: TerrainGeneratorCfg) -> None:
21+
self.cfg = cfg
22+
self.generator = TerrainGenerator(cfg)
23+
self._gs_handle: Any = None
24+
25+
# ------------------------------------------------------------------ lifecycle
26+
27+
def spawn(self, gs_scene: Any) -> None:
28+
"""Add a ``gs.morphs.Terrain`` to ``gs_scene``. Must be called pre-build."""
29+
import genesis as gs # type: ignore[import-not-found]
30+
31+
morph = gs.morphs.Terrain(**self.generator.genesis_kwargs)
32+
self._gs_handle = gs_scene.add_entity(morph)
33+
34+
# ------------------------------------------------------------------ post-spawn data
35+
36+
@property
37+
def heightfield(self) -> Any:
38+
"""Raw integer height field, shape ``(H, W)``.
39+
40+
Multiply by ``terrain_scale[1]`` (vertical scale) to recover meters.
41+
"""
42+
if self._gs_handle is None:
43+
raise RuntimeError("TerrainImporter.heightfield accessed before spawn()")
44+
return self._gs_handle.terrain_hf
45+
46+
@property
47+
def terrain_scale(self) -> Any:
48+
"""``(horizontal_scale, vertical_scale)`` reported by Genesis post-spawn."""
49+
if self._gs_handle is None:
50+
raise RuntimeError("TerrainImporter.terrain_scale accessed before spawn()")
51+
return self._gs_handle.terrain_scale
52+
53+
# ------------------------------------------------------------------ pass-throughs
54+
55+
@property
56+
def env_origins(self) -> torch.Tensor:
57+
return self.generator.env_origins
58+
59+
@property
60+
def layout(self) -> list[list[str]]:
61+
return self.generator.layout
62+
63+
@property
64+
def gs_handle(self) -> Any:
65+
return self._gs_handle

0 commit comments

Comments
 (0)