simrig 0.2.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
simrig/rendering.py ADDED
@@ -0,0 +1,127 @@
1
+ """MuJoCo offscreen rendering helpers for browser viewers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ import os
7
+ import sys
8
+ from typing import Any
9
+
10
+
11
+ @dataclass
12
+ class CameraState:
13
+ azimuth: float = 135.0
14
+ elevation: float = -20.0
15
+ distance: float = 2.4
16
+ interactive: bool = True
17
+
18
+ def apply(self, camera: Any) -> Any:
19
+ if not self.interactive or not hasattr(camera, "azimuth"):
20
+ return camera
21
+ camera.azimuth = float(self.azimuth)
22
+ camera.elevation = float(self.elevation)
23
+ camera.distance = float(self.distance)
24
+ return camera
25
+
26
+ def update_from_query(self, query: dict[str, list[str]]) -> None:
27
+ for key in ("azimuth", "elevation", "distance"):
28
+ if key in query:
29
+ setattr(self, key, float(query[key][0]))
30
+
31
+ def to_dict(self) -> dict[str, Any]:
32
+ return {
33
+ "azimuth": self.azimuth,
34
+ "elevation": self.elevation,
35
+ "distance": self.distance,
36
+ "interactive": self.interactive,
37
+ }
38
+
39
+
40
+ def preferred_gl_backends() -> list[str | None]:
41
+ """Return MuJoCo GL backends to try, in order."""
42
+
43
+ configured = os.environ.get("MUJOCO_GL")
44
+ if configured:
45
+ return [configured]
46
+ if sys.platform == "darwin":
47
+ return ["glfw", None]
48
+ if os.environ.get("DISPLAY"):
49
+ return ["egl", "glfw", None]
50
+ return ["osmesa", "egl", None]
51
+
52
+
53
+ def ensure_offscreen_framebuffer(model: Any, *, height: int, width: int) -> None:
54
+ """Grow MuJoCo's offscreen buffer so browser frames can exceed 640x480."""
55
+
56
+ vis_global = model.vis.global_
57
+ vis_global.offwidth = max(int(vis_global.offwidth), int(width))
58
+ vis_global.offheight = max(int(vis_global.offheight), int(height))
59
+
60
+
61
+ def create_mujoco_renderer(
62
+ mujoco: Any,
63
+ model: Any,
64
+ *,
65
+ height: int,
66
+ width: int,
67
+ ) -> Any:
68
+ """Create a MuJoCo offscreen renderer, trying platform-appropriate GL backends."""
69
+
70
+ ensure_offscreen_framebuffer(model, height=height, width=width)
71
+ errors: list[str] = []
72
+ for backend in preferred_gl_backends():
73
+ if backend:
74
+ os.environ["MUJOCO_GL"] = backend
75
+ try:
76
+ return mujoco.Renderer(model, height=height, width=width)
77
+ except Exception as exc:
78
+ errors.append(f"{backend or 'default'}: {exc}")
79
+ raise RuntimeError(
80
+ "MuJoCo offscreen rendering failed. Tried "
81
+ + "; ".join(errors)
82
+ + ". On macOS, run from a desktop session and keep MUJOCO_GL=glfw."
83
+ )
84
+
85
+
86
+ def make_tracking_camera(
87
+ mujoco: Any,
88
+ model: Any,
89
+ data: Any,
90
+ camera: str | int | None,
91
+ ) -> tuple[Any, CameraState]:
92
+ """Build a MuJoCo camera object and mutable browser camera state."""
93
+
94
+ if isinstance(camera, int):
95
+ return camera, CameraState(interactive=False)
96
+ if isinstance(camera, str) and camera.isdigit():
97
+ return int(camera), CameraState(interactive=False)
98
+ if camera not in (None, "track", "tracking"):
99
+ return camera, CameraState(interactive=False)
100
+
101
+ cam = mujoco.MjvCamera()
102
+ cam.type = mujoco.mjtCamera.mjCAMERA_TRACKING
103
+ cam.trackbodyid = tracking_body_id(mujoco, model, data)
104
+ cam.distance = 2.4
105
+ cam.azimuth = 135
106
+ cam.elevation = -20
107
+ state = CameraState(
108
+ azimuth=float(cam.azimuth),
109
+ elevation=float(cam.elevation),
110
+ distance=float(cam.distance),
111
+ interactive=True,
112
+ )
113
+ return cam, state
114
+
115
+
116
+ def tracking_body_id(mujoco: Any, model: Any, data: Any | None = None) -> int:
117
+ """Pick a stable body for camera tracking."""
118
+
119
+ del data
120
+ for joint_id in range(model.njnt):
121
+ if model.jnt_type[joint_id] == mujoco.mjtJoint.mjJNT_FREE:
122
+ return int(model.jnt_bodyid[joint_id])
123
+ for name in ("trunk", "base", "torso", "torso_link", "pelvis", "body", "root"):
124
+ body_id = mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_BODY, name)
125
+ if body_id >= 0:
126
+ return int(body_id)
127
+ return 1 if model.nbody > 1 else 0
simrig/scaffold.py ADDED
@@ -0,0 +1,127 @@
1
+ """Editable starter files for custom environments."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from simrig.io import slugify
8
+
9
+ # Markers used by validate-env static checklist (keep in sync with template).
10
+ REQUIRED_SECTION_MARKERS = (
11
+ "SECTION: model loading",
12
+ "SECTION: reset",
13
+ "SECTION: action mapping",
14
+ "SECTION: observations",
15
+ "SECTION: rewards",
16
+ "SECTION: termination",
17
+ )
18
+
19
+ REQUIRED_CLASS_METHODS = (
20
+ "__init__",
21
+ "reset",
22
+ "step",
23
+ )
24
+
25
+
26
+ def new_env(name: str, model: str | Path, *, template: str = "mjx", root: Path | str = "envs") -> Path:
27
+ """Create an editable starter env module."""
28
+ if template != "mjx":
29
+ raise ValueError("SimRig v0 supports only the 'mjx' env template.")
30
+ module_name = slugify(name).replace("-", "_").replace(".", "_")
31
+ path = Path(root) / f"{module_name}.py"
32
+ if path.exists():
33
+ raise FileExistsError(f"Refusing to overwrite existing env template: {path}")
34
+ path.parent.mkdir(parents=True, exist_ok=True)
35
+ path.write_text(_mjx_template(name=name, model=str(model)), encoding="utf-8")
36
+ return path
37
+
38
+
39
+ def _mjx_template(*, name: str, model: str) -> str:
40
+ return f'''"""Editable SimRig MJX environment starter for {name}.
41
+
42
+ NOT TRAINABLE YET.
43
+ Fill the SECTION blocks below, remove this banner when reset/step work, then:
44
+
45
+ simrig validate-env PATH --runtime
46
+ simrig smoke PATH --steps 10
47
+ simrig train PATH --preset smoke
48
+
49
+ Prefer subclassing mujoco_playground._src.mjx_env.MjxEnv when Playground is
50
+ installed. Return obs as dict keys `state` and `privileged_state` for SimRig PPO.
51
+ """
52
+
53
+ from __future__ import annotations
54
+
55
+ from pathlib import Path
56
+ from typing import Any
57
+
58
+
59
+ MODEL_PATH = Path({model!r}).expanduser()
60
+ ENV_NAME = {name!r}
61
+
62
+
63
+ def default_config() -> dict[str, Any]:
64
+ return {{
65
+ "episode_length": 1000,
66
+ "action_scale": 1.0,
67
+ "ctrl_dt": 0.02,
68
+ "sim_dt": 0.002,
69
+ "impl": "jax",
70
+ }}
71
+
72
+
73
+ def make_env(config_overrides: dict[str, Any] | None = None) -> "CustomEnv":
74
+ """Factory used by `simrig smoke/train/eval` for this module."""
75
+ config = default_config()
76
+ if config_overrides:
77
+ config.update(config_overrides)
78
+ return CustomEnv(config=config)
79
+
80
+
81
+ class CustomEnv:
82
+ """Replace this starter with a real mjx_env.MjxEnv implementation.
83
+
84
+ Required for SimRig smoke/train/eval:
85
+ - reset(rng) -> state with .obs, .reward, .done, .data
86
+ - step(state, action) -> state
87
+ - observation_size (dict with `state` and `privileged_state` preferred)
88
+ - action_size
89
+ - mj_model / mjx_model (for demos and previews)
90
+ """
91
+
92
+ def __init__(self, config: dict[str, Any] | None = None) -> None:
93
+ self.config = config or default_config()
94
+ # SECTION: model loading
95
+ # Load MODEL_PATH into mj_model / mjx_model and cache actuator limits.
96
+ raise NotImplementedError(
97
+ "Define model loading, reset, step, rewards, observations, and termination."
98
+ )
99
+
100
+ def reset(self, rng: Any) -> Any:
101
+ # SECTION: reset
102
+ # Sample initial qpos/qvel (and any task state). Return a Brax/MJX state.
103
+ raise NotImplementedError("Implement reset randomization.")
104
+
105
+ def step(self, state: Any, action: Any) -> Any:
106
+ # SECTION: action mapping
107
+ # Scale/clip `action` into actuator controls.
108
+
109
+ # SECTION: observations
110
+ # Build policy obs (`state`) and privileged obs (`privileged_state`).
111
+
112
+ # SECTION: rewards
113
+ # Compute dense/sparse reward terms; do not invent them from the model name.
114
+
115
+ # SECTION: termination
116
+ # Set done / truncations from falls, limits, success, or time.
117
+
118
+ raise NotImplementedError("Implement step, including action mapping, obs, reward, termination.")
119
+
120
+ @property
121
+ def observation_size(self) -> Any:
122
+ raise NotImplementedError("Return dict observation sizes for state/privileged_state.")
123
+
124
+ @property
125
+ def action_size(self) -> int:
126
+ raise NotImplementedError("Return the actuator/action dimension.")
127
+ '''
simrig/three_scene.py ADDED
@@ -0,0 +1,107 @@
1
+ """Serialize compiled MuJoCo geometry for browser-side Three.js rendering."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ import numpy as np
8
+
9
+
10
+ def scene_payload(
11
+ mujoco: Any,
12
+ model: Any,
13
+ data: Any,
14
+ *,
15
+ model_name: str,
16
+ ) -> dict[str, Any]:
17
+ """Return visual geometry and current world transforms for a model."""
18
+
19
+ visible_geom_ids = [
20
+ geom_id
21
+ for geom_id in range(model.ngeom)
22
+ if int(model.geom_group[geom_id]) <= 2
23
+ ]
24
+ used_mesh_ids = sorted(
25
+ {
26
+ int(model.geom_dataid[geom_id])
27
+ for geom_id in visible_geom_ids
28
+ if int(model.geom_type[geom_id]) == int(mujoco.mjtGeom.mjGEOM_MESH)
29
+ and int(model.geom_dataid[geom_id]) >= 0
30
+ }
31
+ )
32
+ return {
33
+ "model_name": model_name,
34
+ "coordinate_system": "z-up",
35
+ "meshes": [_mesh_payload(mujoco, model, mesh_id) for mesh_id in used_mesh_ids],
36
+ "geoms": [_geom_payload(mujoco, model, geom_id) for geom_id in visible_geom_ids],
37
+ "transforms": geom_transforms(model, data),
38
+ }
39
+
40
+
41
+ def geom_transforms(model: Any, data: Any) -> list[dict[str, Any]]:
42
+ """Return each geom's current MuJoCo world position and rotation matrix."""
43
+
44
+ return [
45
+ {
46
+ "id": geom_id,
47
+ "position": np.asarray(data.geom_xpos[geom_id], dtype=float).tolist(),
48
+ "matrix": np.asarray(data.geom_xmat[geom_id], dtype=float).reshape(-1).tolist(),
49
+ }
50
+ for geom_id in range(model.ngeom)
51
+ ]
52
+
53
+
54
+ def _mesh_payload(mujoco: Any, model: Any, mesh_id: int) -> dict[str, Any]:
55
+ vert_adr = int(model.mesh_vertadr[mesh_id])
56
+ vert_num = int(model.mesh_vertnum[mesh_id])
57
+ face_adr = int(model.mesh_faceadr[mesh_id])
58
+ face_num = int(model.mesh_facenum[mesh_id])
59
+ name = mujoco.mj_id2name(model, mujoco.mjtObj.mjOBJ_MESH, mesh_id)
60
+ return {
61
+ "id": mesh_id,
62
+ "name": name or f"mesh_{mesh_id}",
63
+ "vertices": np.asarray(
64
+ model.mesh_vert[vert_adr : vert_adr + vert_num],
65
+ dtype=np.float32,
66
+ )
67
+ .reshape(-1)
68
+ .tolist(),
69
+ "indices": np.asarray(
70
+ model.mesh_face[face_adr : face_adr + face_num],
71
+ dtype=np.uint32,
72
+ )
73
+ .reshape(-1)
74
+ .tolist(),
75
+ }
76
+
77
+
78
+ def _geom_payload(mujoco: Any, model: Any, geom_id: int) -> dict[str, Any]:
79
+ material_id = int(model.geom_matid[geom_id])
80
+ if material_id >= 0:
81
+ rgba = np.asarray(model.mat_rgba[material_id], dtype=float)
82
+ specular = float(model.mat_specular[material_id])
83
+ shininess = float(model.mat_shininess[material_id])
84
+ reflectance = float(model.mat_reflectance[material_id])
85
+ emission = float(model.mat_emission[material_id])
86
+ else:
87
+ rgba = np.asarray(model.geom_rgba[geom_id], dtype=float)
88
+ specular = 0.25
89
+ shininess = 0.25
90
+ reflectance = 0.0
91
+ emission = 0.0
92
+ name = mujoco.mj_id2name(model, mujoco.mjtObj.mjOBJ_GEOM, geom_id)
93
+ return {
94
+ "id": geom_id,
95
+ "name": name or f"geom_{geom_id}",
96
+ "type": int(model.geom_type[geom_id]),
97
+ "mesh_id": int(model.geom_dataid[geom_id]),
98
+ "group": int(model.geom_group[geom_id]),
99
+ "size": np.asarray(model.geom_size[geom_id], dtype=float).tolist(),
100
+ "rgba": rgba.tolist(),
101
+ "material": {
102
+ "specular": specular,
103
+ "shininess": shininess,
104
+ "reflectance": reflectance,
105
+ "emission": emission,
106
+ },
107
+ }
simrig/validate_env.py ADDED
@@ -0,0 +1,211 @@
1
+ """Validation for custom env starter modules (static + optional runtime)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ast
6
+ from dataclasses import dataclass, field
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from simrig.scaffold import REQUIRED_CLASS_METHODS, REQUIRED_SECTION_MARKERS
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class EnvValidationResult:
15
+ """Result of custom-env validation.
16
+
17
+ ``trainable`` is True only when ``--runtime`` reset/step checks succeed.
18
+ Static-only passes never claim trainability.
19
+ """
20
+
21
+ path: str
22
+ passed: bool
23
+ trainable: bool = False
24
+ missing: list[str] = field(default_factory=list)
25
+ warnings: list[str] = field(default_factory=list)
26
+ notes: list[str] = field(default_factory=list)
27
+
28
+
29
+ def validate_env(path: Path | str, *, runtime: bool = False) -> EnvValidationResult:
30
+ """Validate a custom env module.
31
+
32
+ Static mode checks scaffold structure. Runtime mode also imports the module,
33
+ constructs the env, and runs a short reset/step smoke when JAX is available.
34
+ """
35
+ env_path = Path(path).expanduser()
36
+ missing: list[str] = []
37
+ warnings: list[str] = []
38
+ notes = [
39
+ "Static checklist checks structure only.",
40
+ "Use --runtime before proposing simrig smoke/train on a custom module.",
41
+ ]
42
+
43
+ if not env_path.is_file():
44
+ return EnvValidationResult(
45
+ path=str(env_path),
46
+ passed=False,
47
+ missing=[f"file not found: {env_path}"],
48
+ notes=notes,
49
+ )
50
+
51
+ source = env_path.read_text(encoding="utf-8")
52
+ for marker in REQUIRED_SECTION_MARKERS:
53
+ if marker not in source:
54
+ missing.append(f"section marker missing: {marker}")
55
+
56
+ try:
57
+ tree = ast.parse(source, filename=str(env_path))
58
+ except SyntaxError as exc:
59
+ return EnvValidationResult(
60
+ path=str(env_path),
61
+ passed=False,
62
+ missing=[f"syntax error: {exc}"],
63
+ notes=notes,
64
+ )
65
+
66
+ if "MODEL_PATH" not in source:
67
+ missing.append("symbol missing: MODEL_PATH")
68
+ if "ENV_NAME" not in source:
69
+ missing.append("symbol missing: ENV_NAME")
70
+ if "def default_config" not in source and "def make_env" not in source:
71
+ missing.append("function missing: default_config or make_env")
72
+
73
+ custom_env = _find_class(tree, "CustomEnv")
74
+ has_make_env = any(
75
+ isinstance(node, ast.FunctionDef) and node.name == "make_env" for node in tree.body
76
+ )
77
+ if custom_env is None and not has_make_env:
78
+ missing.append("class missing: CustomEnv (or define make_env)")
79
+ elif custom_env is not None:
80
+ method_names = {
81
+ node.name
82
+ for node in custom_env.body
83
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
84
+ }
85
+ for name in REQUIRED_CLASS_METHODS:
86
+ if name not in method_names:
87
+ missing.append(f"CustomEnv method missing: {name}")
88
+ for prop in ("observation_size", "action_size"):
89
+ if prop not in method_names:
90
+ missing.append(f"CustomEnv property missing: {prop}")
91
+
92
+ if "NotImplementedError" in source:
93
+ warnings.append("NotImplementedError still present; implementation is incomplete.")
94
+ if "NOT TRAINABLE YET" in source:
95
+ warnings.append("File still marked NOT TRAINABLE YET.")
96
+
97
+ passed = not missing
98
+ trainable = False
99
+
100
+ if runtime and passed:
101
+ runtime_missing, runtime_warnings, runtime_notes, trainable = _runtime_checks(env_path)
102
+ missing.extend(runtime_missing)
103
+ warnings.extend(runtime_warnings)
104
+ notes.extend(runtime_notes)
105
+ passed = not missing
106
+ elif passed:
107
+ notes.append(
108
+ "Checklist structure looks complete. Fill SECTION bodies, then "
109
+ "`simrig validate-env PATH --runtime` and `simrig smoke PATH`."
110
+ )
111
+
112
+ return EnvValidationResult(
113
+ path=str(env_path),
114
+ passed=passed,
115
+ trainable=trainable,
116
+ missing=missing,
117
+ warnings=warnings,
118
+ notes=notes,
119
+ )
120
+
121
+
122
+ def _runtime_checks(env_path: Path) -> tuple[list[str], list[str], list[str], bool]:
123
+ missing: list[str] = []
124
+ warnings: list[str] = []
125
+ notes: list[str] = []
126
+ trainable = False
127
+
128
+ try:
129
+ from simrig.custom_env import load_custom_env
130
+
131
+ env = load_custom_env(env_path)
132
+ except Exception as exc:
133
+ missing.append(f"runtime load failed: {exc}")
134
+ return missing, warnings, notes, False
135
+
136
+ notes.append("Custom env module constructed successfully.")
137
+ action_size = getattr(env, "action_size", None)
138
+ observation_size = getattr(env, "observation_size", None)
139
+
140
+ if action_size is None:
141
+ missing.append("runtime: action_size missing")
142
+ obs_warnings = _obs_size_warnings(observation_size)
143
+ warnings.extend(obs_warnings)
144
+
145
+ for attr in ("mj_model", "mjx_model"):
146
+ if getattr(env, attr, None) is None:
147
+ warnings.append(f"runtime: {attr} missing (needed for Playground/Brax demos).")
148
+
149
+ try:
150
+ import jax # type: ignore
151
+ import jax.numpy as jp # type: ignore
152
+ except ImportError:
153
+ warnings.append("runtime: JAX not installed; skipped reset/step smoke.")
154
+ notes.append("Install the playground extra to run reset/step validation.")
155
+ return missing, warnings, notes, False
156
+
157
+ try:
158
+ state = env.reset(jax.random.PRNGKey(0))
159
+ obs = getattr(state, "obs", None)
160
+ if isinstance(obs, dict):
161
+ if "state" not in obs:
162
+ missing.append("runtime: state.obs missing key `state`")
163
+ if "privileged_state" not in obs:
164
+ warnings.append("runtime: state.obs missing key `privileged_state`")
165
+ elif obs is None:
166
+ missing.append("runtime: state.obs missing")
167
+ else:
168
+ warnings.append(
169
+ "runtime: state.obs is flat; SimRig PPO defaults expect dict keys "
170
+ "`state` and `privileged_state`."
171
+ )
172
+
173
+ if action_size is None:
174
+ return missing, warnings, notes, False
175
+ state = env.step(state, jp.zeros(int(action_size)))
176
+ _ = float(state.reward), bool(state.done)
177
+ except Exception as exc:
178
+ missing.append(f"runtime reset/step failed: {exc}")
179
+ return missing, warnings, notes, False
180
+
181
+ if missing:
182
+ return missing, warnings, notes, False
183
+
184
+ trainable = True
185
+ notes.append("Runtime reset/step succeeded. Safe to try `simrig smoke` then `simrig train`.")
186
+ return missing, warnings, notes, trainable
187
+
188
+
189
+ def _obs_size_warnings(observation_size: Any) -> list[str]:
190
+ warnings: list[str] = []
191
+ if observation_size is None:
192
+ warnings.append("runtime: observation_size missing")
193
+ return warnings
194
+ if isinstance(observation_size, dict):
195
+ if "state" not in observation_size:
196
+ warnings.append("runtime: observation_size missing key `state`")
197
+ if "privileged_state" not in observation_size:
198
+ warnings.append("runtime: observation_size missing key `privileged_state`")
199
+ else:
200
+ warnings.append(
201
+ "runtime: observation_size is flat; SimRig PPO defaults expect "
202
+ "`state` / `privileged_state`."
203
+ )
204
+ return warnings
205
+
206
+
207
+ def _find_class(tree: ast.AST, name: str) -> ast.ClassDef | None:
208
+ for node in ast.walk(tree):
209
+ if isinstance(node, ast.ClassDef) and node.name == name:
210
+ return node
211
+ return None