dim-go2-sim 0.1.0__tar.gz

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.
Files changed (40) hide show
  1. dim_go2_sim-0.1.0/.gitignore +6 -0
  2. dim_go2_sim-0.1.0/LICENSE +17 -0
  3. dim_go2_sim-0.1.0/PKG-INFO +105 -0
  4. dim_go2_sim-0.1.0/README.md +82 -0
  5. dim_go2_sim-0.1.0/pyproject.toml +44 -0
  6. dim_go2_sim-0.1.0/src/dim_go2_sim/__init__.py +30 -0
  7. dim_go2_sim-0.1.0/src/dim_go2_sim/camera.py +175 -0
  8. dim_go2_sim-0.1.0/src/dim_go2_sim/config.py +82 -0
  9. dim_go2_sim-0.1.0/src/dim_go2_sim/contacts.py +103 -0
  10. dim_go2_sim-0.1.0/src/dim_go2_sim/data/go2/LICENSE +27 -0
  11. dim_go2_sim-0.1.0/src/dim_go2_sim/data/go2/README.md +57 -0
  12. dim_go2_sim-0.1.0/src/dim_go2_sim/data/go2/assets/base_0.obj +36346 -0
  13. dim_go2_sim-0.1.0/src/dim_go2_sim/data/go2/assets/base_1.obj +23131 -0
  14. dim_go2_sim-0.1.0/src/dim_go2_sim/data/go2/assets/base_2.obj +8367 -0
  15. dim_go2_sim-0.1.0/src/dim_go2_sim/data/go2/assets/base_3.obj +10932 -0
  16. dim_go2_sim-0.1.0/src/dim_go2_sim/data/go2/assets/base_4.obj +199205 -0
  17. dim_go2_sim-0.1.0/src/dim_go2_sim/data/go2/assets/calf_0.obj +24148 -0
  18. dim_go2_sim-0.1.0/src/dim_go2_sim/data/go2/assets/calf_1.obj +9243 -0
  19. dim_go2_sim-0.1.0/src/dim_go2_sim/data/go2/assets/calf_mirror_0.obj +24145 -0
  20. dim_go2_sim-0.1.0/src/dim_go2_sim/data/go2/assets/calf_mirror_1.obj +9252 -0
  21. dim_go2_sim-0.1.0/src/dim_go2_sim/data/go2/assets/foot.obj +30786 -0
  22. dim_go2_sim-0.1.0/src/dim_go2_sim/data/go2/assets/hip_0.obj +76960 -0
  23. dim_go2_sim-0.1.0/src/dim_go2_sim/data/go2/assets/hip_1.obj +73961 -0
  24. dim_go2_sim-0.1.0/src/dim_go2_sim/data/go2/assets/thigh_0.obj +75706 -0
  25. dim_go2_sim-0.1.0/src/dim_go2_sim/data/go2/assets/thigh_1.obj +40483 -0
  26. dim_go2_sim-0.1.0/src/dim_go2_sim/data/go2/assets/thigh_mirror_0.obj +75572 -0
  27. dim_go2_sim-0.1.0/src/dim_go2_sim/data/go2/assets/thigh_mirror_1.obj +40450 -0
  28. dim_go2_sim-0.1.0/src/dim_go2_sim/data/go2/go2.xml +219 -0
  29. dim_go2_sim-0.1.0/src/dim_go2_sim/data/mid360/NOTICE.md +21 -0
  30. dim_go2_sim-0.1.0/src/dim_go2_sim/data/mid360/fourier.npz +0 -0
  31. dim_go2_sim-0.1.0/src/dim_go2_sim/data/policy/LICENSE +201 -0
  32. dim_go2_sim-0.1.0/src/dim_go2_sim/data/policy/NOTICE +12 -0
  33. dim_go2_sim-0.1.0/src/dim_go2_sim/data/policy/policy.onnx +0 -0
  34. dim_go2_sim-0.1.0/src/dim_go2_sim/lidar.py +243 -0
  35. dim_go2_sim-0.1.0/src/dim_go2_sim/mid360.py +136 -0
  36. dim_go2_sim-0.1.0/src/dim_go2_sim/module.py +303 -0
  37. dim_go2_sim-0.1.0/src/dim_go2_sim/policy.py +102 -0
  38. dim_go2_sim-0.1.0/src/dim_go2_sim/world.py +254 -0
  39. dim_go2_sim-0.1.0/tests/test_module.py +175 -0
  40. dim_go2_sim-0.1.0/tests/test_sim.py +161 -0
@@ -0,0 +1,6 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.egg-info/
4
+ dist/
5
+ .pytest_cache/
6
+ .ruff_cache/
@@ -0,0 +1,17 @@
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ Copyright 2025 Dimensional Inc.
6
+
7
+ Licensed under the Apache License, Version 2.0 (the "License");
8
+ you may not use this file except in compliance with the License.
9
+ You may obtain a copy of the License at
10
+
11
+ http://www.apache.org/licenses/LICENSE-2.0
12
+
13
+ Unless required by applicable law or agreed to in writing, software
14
+ distributed under the License is distributed on an "AS IS" BASIS,
15
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16
+ See the License for the specific language governing permissions and
17
+ limitations under the License.
@@ -0,0 +1,105 @@
1
+ Metadata-Version: 2.5
2
+ Name: dim-go2-sim
3
+ Version: 0.1.0
4
+ Summary: A simulated Unitree Go2 for dimOS: cmd_vel in; Mid-360 lidar, textured camera, IMU, odometry and contacts out
5
+ Project-URL: Repository, https://github.com/jeff-hykin/dim-go2-sim
6
+ Author-email: Jeff Hykin <jeff.hykin@gmail.com>
7
+ License-Expression: Apache-2.0
8
+ License-File: LICENSE
9
+ License-File: src/dim_go2_sim/data/go2/LICENSE
10
+ License-File: src/dim_go2_sim/data/policy/LICENSE
11
+ Requires-Python: <3.13,>=3.10
12
+ Requires-Dist: dimos>=0.0.14
13
+ Requires-Dist: gltflib>=1.0.13
14
+ Requires-Dist: imageio>=2.30
15
+ Requires-Dist: mujoco>=3.3.4
16
+ Requires-Dist: numpy>=1.26
17
+ Requires-Dist: onnxruntime>=1.17
18
+ Requires-Dist: open3d>=0.18; platform_system != 'Linux' or platform_machine != 'aarch64'
19
+ Requires-Dist: pygfx<0.18,>=0.17
20
+ Requires-Dist: rendercanvas>=2.0
21
+ Requires-Dist: scipy>=1.15
22
+ Description-Content-Type: text/markdown
23
+
24
+ # dim-go2-sim
25
+
26
+ A simulated Unitree Go2 for [dimOS](https://github.com/dimensionalOS/dimos), as one module:
27
+ `cmd_vel` in; a rolling Livox Mid-360, a textured front camera, IMU, odometry, tf and contacts
28
+ out. It drops into a blueprint where a real Go2 would be.
29
+
30
+ ```bash
31
+ pip install dim-go2-sim
32
+ ```
33
+
34
+ ```python
35
+ from dimos.core.coordination.blueprints import autoconnect
36
+ from dim_go2_sim import Go2Sim
37
+
38
+ blueprint = autoconnect(
39
+ Go2Sim.blueprint(scene={"package": "supermarket", "start": (18.5, 10.0, -0.17)}),
40
+ MyNavigation.blueprint(), # anything that reads lidar/odom and writes cmd_vel
41
+ )
42
+ ```
43
+
44
+ With no scene it stands on bare ground. A scene is either a cooked dimOS scene package by name
45
+ (`scene.package`), or an MJCF of the collision geometry (`scene.mjcf`) and optionally a
46
+ textured glTF the camera draws (`scene.visual`).
47
+
48
+ ## Streams
49
+
50
+ | stream | type | |
51
+ |---|---|---|
52
+ | `cmd_vel` (in) | `Twist` | held for `physics.command_timeout`, then the robot stands still |
53
+ | `lidar` | `PointCloud2` | one 0.1 s scan, in `mid360_link` where each ray fired, with per-point `offset_times` and `lines`, as the device reports it |
54
+ | `color_image`, `camera_info` | `Image`, `CameraInfo` | the nose camera, RGB |
55
+ | `imu` | `Imu` | body angular velocity, specific force and orientation |
56
+ | `odom` | `PoseStamped` | the true body pose in `world` |
57
+ | `tf` | `TFMessage` | `world` → `base_link` → `mid360_link`, `camera_optical` |
58
+ | `contacts` | `dim_go2_sim.Contacts` | (robot part, floor/clutter) pairs touching, whenever they change; ground truth for grading |
59
+
60
+ ## Config
61
+
62
+ `Go2SimConfig` has a section per part, each overridable from a blueprint or the dimOS CLI:
63
+
64
+ - `scene`: `package`, `mjcf`, `visual`, `visual_y_up`, `start` (x, y, yaw), `floor_z`,
65
+ `floor_top_z`
66
+ - `physics`: `timestep`, `fitted`, `actuator_tau`, `policy`, `track_velocity`,
67
+ `command_timeout`, `max_velocity`, `realtime`, `cache_dir`
68
+ - `lidar`: `enabled`, `rate_hz`, `xyz`, `pitch_deg`, `point_rate_hz`, `downsample`,
69
+ `min_range`, `max_range`, `noise`, `dropout`, `self_occlusion`, `motion_bins_hz`, `seed`
70
+ - `camera`: `enabled`, `rate_hz`, `width`, `height`, `fovy_deg`, `xyz`, `pitch_deg`, `near`,
71
+ `far`, `ambient`, `sun`, `background`
72
+ - `odom_hz`, `imu_hz`, `frame_prefix`
73
+
74
+ See `src/dim_go2_sim/config.py` for what each means and its default.
75
+
76
+ ## How it works
77
+
78
+ - **Walking.** MuJoCo steps the Go2 under the open [rl_sar](https://github.com/fan-ziqi/rl_sar)
79
+ robot_lab policy at 50 Hz, with joint damping, friction, armature, masses, foot friction and
80
+ a 15 ms motor lag fitted to real Go2 runs under that policy. The policy alone ignores slow
81
+ commands and drifts when told to stand, so an integral loop on the true body velocity trims
82
+ its command, as the Go2's sport mode tracks it (`physics.track_velocity`).
83
+ - **Lidar.** The Mid-360's four-channel Fourier firing pattern, 200k points a second, cast in
84
+ 5 ms slices from wherever the head is then, with range noise and grazing-angle dropout. The
85
+ static scene is cast through Embree; the robot's own body, through MuJoCo. A cooked store of
86
+ 48k geoms scans at the full 10 Hz.
87
+ - **Camera.** pygfx draws the scene's textured glTF in a child process, its meshes merged per
88
+ material (a 47k-node store: 0.6 → 80+ fps on an M-series Mac). It draws the scene only, not
89
+ the robot. Without a GPU (e.g. Docker), it runs on Mesa's lavapipe: install
90
+ `mesa-vulkan-drivers`.
91
+ - The composed world is compiled once and cached (`physics.cache_dir`, default
92
+ `~/.cache/dim_go2_sim`); a store takes about 2 minutes the first time.
93
+
94
+ ## Development
95
+
96
+ ```bash
97
+ uv venv --python 3.12 && uv pip install -e . pytest
98
+ pytest
99
+ ```
100
+
101
+ ## Licenses
102
+
103
+ Apache-2.0. Bundled data keeps its own terms: the Go2 model (Unitree, BSD-3,
104
+ `data/go2/LICENSE`), the walking policy (rl_sar, Apache-2.0, `data/policy/NOTICE`), and the
105
+ Mid-360 pattern (`data/mid360/NOTICE.md`).
@@ -0,0 +1,82 @@
1
+ # dim-go2-sim
2
+
3
+ A simulated Unitree Go2 for [dimOS](https://github.com/dimensionalOS/dimos), as one module:
4
+ `cmd_vel` in; a rolling Livox Mid-360, a textured front camera, IMU, odometry, tf and contacts
5
+ out. It drops into a blueprint where a real Go2 would be.
6
+
7
+ ```bash
8
+ pip install dim-go2-sim
9
+ ```
10
+
11
+ ```python
12
+ from dimos.core.coordination.blueprints import autoconnect
13
+ from dim_go2_sim import Go2Sim
14
+
15
+ blueprint = autoconnect(
16
+ Go2Sim.blueprint(scene={"package": "supermarket", "start": (18.5, 10.0, -0.17)}),
17
+ MyNavigation.blueprint(), # anything that reads lidar/odom and writes cmd_vel
18
+ )
19
+ ```
20
+
21
+ With no scene it stands on bare ground. A scene is either a cooked dimOS scene package by name
22
+ (`scene.package`), or an MJCF of the collision geometry (`scene.mjcf`) and optionally a
23
+ textured glTF the camera draws (`scene.visual`).
24
+
25
+ ## Streams
26
+
27
+ | stream | type | |
28
+ |---|---|---|
29
+ | `cmd_vel` (in) | `Twist` | held for `physics.command_timeout`, then the robot stands still |
30
+ | `lidar` | `PointCloud2` | one 0.1 s scan, in `mid360_link` where each ray fired, with per-point `offset_times` and `lines`, as the device reports it |
31
+ | `color_image`, `camera_info` | `Image`, `CameraInfo` | the nose camera, RGB |
32
+ | `imu` | `Imu` | body angular velocity, specific force and orientation |
33
+ | `odom` | `PoseStamped` | the true body pose in `world` |
34
+ | `tf` | `TFMessage` | `world` → `base_link` → `mid360_link`, `camera_optical` |
35
+ | `contacts` | `dim_go2_sim.Contacts` | (robot part, floor/clutter) pairs touching, whenever they change; ground truth for grading |
36
+
37
+ ## Config
38
+
39
+ `Go2SimConfig` has a section per part, each overridable from a blueprint or the dimOS CLI:
40
+
41
+ - `scene`: `package`, `mjcf`, `visual`, `visual_y_up`, `start` (x, y, yaw), `floor_z`,
42
+ `floor_top_z`
43
+ - `physics`: `timestep`, `fitted`, `actuator_tau`, `policy`, `track_velocity`,
44
+ `command_timeout`, `max_velocity`, `realtime`, `cache_dir`
45
+ - `lidar`: `enabled`, `rate_hz`, `xyz`, `pitch_deg`, `point_rate_hz`, `downsample`,
46
+ `min_range`, `max_range`, `noise`, `dropout`, `self_occlusion`, `motion_bins_hz`, `seed`
47
+ - `camera`: `enabled`, `rate_hz`, `width`, `height`, `fovy_deg`, `xyz`, `pitch_deg`, `near`,
48
+ `far`, `ambient`, `sun`, `background`
49
+ - `odom_hz`, `imu_hz`, `frame_prefix`
50
+
51
+ See `src/dim_go2_sim/config.py` for what each means and its default.
52
+
53
+ ## How it works
54
+
55
+ - **Walking.** MuJoCo steps the Go2 under the open [rl_sar](https://github.com/fan-ziqi/rl_sar)
56
+ robot_lab policy at 50 Hz, with joint damping, friction, armature, masses, foot friction and
57
+ a 15 ms motor lag fitted to real Go2 runs under that policy. The policy alone ignores slow
58
+ commands and drifts when told to stand, so an integral loop on the true body velocity trims
59
+ its command, as the Go2's sport mode tracks it (`physics.track_velocity`).
60
+ - **Lidar.** The Mid-360's four-channel Fourier firing pattern, 200k points a second, cast in
61
+ 5 ms slices from wherever the head is then, with range noise and grazing-angle dropout. The
62
+ static scene is cast through Embree; the robot's own body, through MuJoCo. A cooked store of
63
+ 48k geoms scans at the full 10 Hz.
64
+ - **Camera.** pygfx draws the scene's textured glTF in a child process, its meshes merged per
65
+ material (a 47k-node store: 0.6 → 80+ fps on an M-series Mac). It draws the scene only, not
66
+ the robot. Without a GPU (e.g. Docker), it runs on Mesa's lavapipe: install
67
+ `mesa-vulkan-drivers`.
68
+ - The composed world is compiled once and cached (`physics.cache_dir`, default
69
+ `~/.cache/dim_go2_sim`); a store takes about 2 minutes the first time.
70
+
71
+ ## Development
72
+
73
+ ```bash
74
+ uv venv --python 3.12 && uv pip install -e . pytest
75
+ pytest
76
+ ```
77
+
78
+ ## Licenses
79
+
80
+ Apache-2.0. Bundled data keeps its own terms: the Go2 model (Unitree, BSD-3,
81
+ `data/go2/LICENSE`), the walking policy (rl_sar, Apache-2.0, `data/policy/NOTICE`), and the
82
+ Mid-360 pattern (`data/mid360/NOTICE.md`).
@@ -0,0 +1,44 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "dim-go2-sim"
7
+ version = "0.1.0"
8
+ description = "A simulated Unitree Go2 for dimOS: cmd_vel in; Mid-360 lidar, textured camera, IMU, odometry and contacts out"
9
+ readme = "README.md"
10
+ license = "Apache-2.0"
11
+ license-files = ["LICENSE", "src/dim_go2_sim/data/go2/LICENSE", "src/dim_go2_sim/data/policy/LICENSE"]
12
+ requires-python = ">=3.10,<3.13"
13
+ authors = [{ name = "Jeff Hykin", email = "jeff.hykin@gmail.com" }]
14
+ dependencies = [
15
+ "dimos>=0.0.14",
16
+ "mujoco>=3.3.4",
17
+ "numpy>=1.26",
18
+ "scipy>=1.15",
19
+ "open3d>=0.18; platform_system != 'Linux' or platform_machine != 'aarch64'",
20
+ "onnxruntime>=1.17",
21
+ "pygfx>=0.17,<0.18",
22
+ "gltflib>=1.0.13",
23
+ "imageio>=2.30", # glTF textures
24
+ "rendercanvas>=2.0",
25
+ ]
26
+
27
+ [project.urls]
28
+ Repository = "https://github.com/jeff-hykin/dim-go2-sim"
29
+
30
+ [dependency-groups]
31
+ dev = ["pytest>=8"]
32
+
33
+ [tool.hatch.build.targets.wheel]
34
+ packages = ["src/dim_go2_sim"]
35
+
36
+ [tool.ruff]
37
+ line-length = 100
38
+
39
+ [tool.ruff.lint]
40
+ select = ["E", "F", "I", "UP", "B", "RUF"]
41
+
42
+ [tool.ruff.lint.isort]
43
+ force-sort-within-sections = true
44
+ known-first-party = ["dim_go2_sim"]
@@ -0,0 +1,30 @@
1
+ # Copyright 2026 Dimensional Inc.
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ """A simulated Unitree Go2 for dimOS: cmd_vel in; Mid-360, camera, IMU, odometry, contacts out."""
16
+
17
+ from dim_go2_sim.config import CameraConfig, LidarConfig, PhysicsConfig, SceneConfig
18
+ from dim_go2_sim.contacts import Contact, Contacts
19
+ from dim_go2_sim.module import Go2Sim, Go2SimConfig
20
+
21
+ __all__ = [
22
+ "CameraConfig",
23
+ "Contact",
24
+ "Contacts",
25
+ "Go2Sim",
26
+ "Go2SimConfig",
27
+ "LidarConfig",
28
+ "PhysicsConfig",
29
+ "SceneConfig",
30
+ ]
@@ -0,0 +1,175 @@
1
+ # Copyright 2026 Dimensional Inc.
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ """Camera frames drawn from a scene's textured glTF with pygfx (after PimSim's mesh renderer).
16
+
17
+ Cooked scenes' MJCFs are collision-only, so MuJoCo would draw grey boxes. Only the static scene
18
+ is drawn, not the robot: a camera must not see its own body. Rendering runs in a fresh child
19
+ process, since GPU device state cannot cross dimOS's forkserver.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import json
25
+ import math
26
+ import pickle
27
+ import subprocess
28
+ import sys
29
+ from typing import Any
30
+
31
+ import numpy as np
32
+ from numpy.typing import NDArray
33
+
34
+ from dim_go2_sim.config import CameraConfig
35
+
36
+ # glTF is Y-up; the world is Z-up.
37
+ Y_UP_TO_Z_UP = np.array([[1, 0, 0, 0], [0, 0, -1, 0], [0, 1, 0, 0], [0, 0, 0, 1]], dtype=np.float64)
38
+
39
+
40
+ def focal_length(config: CameraConfig) -> float:
41
+ return config.height / (2 * math.tan(math.radians(config.fovy_deg) / 2))
42
+
43
+
44
+ class _Renderer:
45
+ def __init__(self, visual: str, y_up: bool, config: CameraConfig) -> None:
46
+ import pygfx as gfx # type: ignore[import-untyped]
47
+ from rendercanvas.offscreen import OffscreenRenderCanvas # type: ignore[import-untyped]
48
+
49
+ root = gfx.Group()
50
+ root.add(gfx.load_gltf(visual, quiet=True, remote_ok=False).scene)
51
+ if y_up:
52
+ root.local.matrix = Y_UP_TO_Z_UP
53
+ self._scene = gfx.Scene()
54
+ background = gfx.Background(None, gfx.BackgroundMaterial(config.background))
55
+ self._scene.add(background, _merged(gfx, root))
56
+ self._scene.add(gfx.AmbientLight("#ffffff", config.ambient))
57
+ sun = gfx.DirectionalLight("#fff4e6", config.sun)
58
+ sun.local.position = (4.0, -6.0, 10.0)
59
+ self._scene.add(sun)
60
+ canvas = OffscreenRenderCanvas(size=(config.width, config.height), pixel_ratio=1.0)
61
+ self._renderer = gfx.WgpuRenderer(canvas, pixel_ratio=1.0)
62
+ # pygfx's fov spans the mean of the view's height and width; match the vertical fovy.
63
+ aspect = config.width / config.height
64
+ fov = math.degrees(2 * math.atan(config.height * (1 + aspect) / (4 * focal_length(config))))
65
+ self._camera = gfx.PerspectiveCamera(
66
+ fov=fov, aspect=aspect, depth_range=(config.near, config.far)
67
+ )
68
+
69
+ def capture(
70
+ self, position: NDArray[np.float64], forward: NDArray[np.float64], up: NDArray[np.float64]
71
+ ) -> NDArray[np.uint8]:
72
+ self._camera.world.position = tuple(position)
73
+ self._camera.world.reference_up = tuple(up)
74
+ self._camera.look_at(tuple(position + forward))
75
+ self._renderer.render(self._scene, self._camera)
76
+ self._renderer.flush()
77
+ return np.ascontiguousarray(np.asarray(self._renderer.snapshot())[:, :, :3])
78
+
79
+
80
+ def _merged(gfx: Any, root: Any) -> Any:
81
+ """One mesh per material, in world coordinates: a store is ~47k nodes, one draw call each."""
82
+ meshes: list[Any] = []
83
+ root.traverse(lambda node: meshes.append(node) if isinstance(node, gfx.Mesh) else None)
84
+ by_material: dict[int, list[Any]] = {}
85
+ for mesh in meshes:
86
+ by_material.setdefault(id(mesh.material), []).append(mesh)
87
+ group = gfx.Group()
88
+ for same in by_material.values():
89
+ positions, normals, texcoords, indices, offset = [], [], [], [], 0
90
+ for mesh in same:
91
+ geometry, matrix = mesh.geometry, mesh.world.matrix
92
+ local = geometry.positions.data.reshape(-1, 3).astype(np.float64)
93
+ positions.append((local @ matrix[:3, :3].T + matrix[:3, 3]).astype(np.float32))
94
+ normal = getattr(geometry, "normals", None)
95
+ if normal is not None:
96
+ rotated = normal.data.reshape(-1, 3) @ np.linalg.inv(matrix[:3, :3])
97
+ rotated /= np.maximum(np.linalg.norm(rotated, axis=1, keepdims=True), 1e-12)
98
+ normals.append(rotated.astype(np.float32))
99
+ uv = getattr(geometry, "texcoords", None)
100
+ if uv is not None:
101
+ texcoords.append(uv.data.reshape(-1, 2).astype(np.float32))
102
+ index = getattr(geometry, "indices", None)
103
+ index = np.arange(len(local)) if index is None else index.data.reshape(-1)
104
+ indices.append(index.astype(np.uint32) + offset)
105
+ offset += len(local)
106
+ attributes: dict[str, NDArray[Any]] = {
107
+ "positions": np.concatenate(positions),
108
+ "indices": np.concatenate(indices).reshape(-1, 3),
109
+ }
110
+ # A partial attribute can't be merged; drop it rather than misalign vertices.
111
+ if len(normals) == len(same):
112
+ attributes["normals"] = np.concatenate(normals)
113
+ if len(texcoords) == len(same):
114
+ attributes["texcoords"] = np.concatenate(texcoords)
115
+ group.add(gfx.Mesh(gfx.Geometry(**attributes), same[0].material))
116
+ return group
117
+
118
+
119
+ def _serve(visual: str, y_up: bool, config: CameraConfig) -> None:
120
+ renderer = _Renderer(visual, y_up, config)
121
+ out = sys.stdout.buffer
122
+ pickle.dump(None, out)
123
+ out.flush()
124
+ while True:
125
+ try:
126
+ request = pickle.load(sys.stdin.buffer)
127
+ except EOFError: # the parent exited without saying goodbye
128
+ break
129
+ if request is None:
130
+ break
131
+ pickle.dump(renderer.capture(*request), out, protocol=pickle.HIGHEST_PROTOCOL)
132
+ out.flush()
133
+
134
+
135
+ class SceneCamera:
136
+ """Renders *visual* in a child process. Loading a large scene there takes a while."""
137
+
138
+ def __init__(self, visual: str, y_up: bool, config: CameraConfig) -> None:
139
+ self._process = subprocess.Popen(
140
+ [sys.executable, "-m", __name__, visual, json.dumps(y_up), config.model_dump_json()],
141
+ stdin=subprocess.PIPE,
142
+ stdout=subprocess.PIPE,
143
+ )
144
+ assert self._process.stdout is not None
145
+ try:
146
+ pickle.load(self._process.stdout)
147
+ except EOFError:
148
+ raise RuntimeError(
149
+ f"the camera renderer exited loading {visual}; see its stderr"
150
+ ) from None
151
+
152
+ def capture(
153
+ self, position: NDArray[np.float64], forward: NDArray[np.float64], up: NDArray[np.float64]
154
+ ) -> NDArray[np.uint8]:
155
+ assert self._process.stdin is not None and self._process.stdout is not None
156
+ pickle.dump((position, forward, up), self._process.stdin)
157
+ self._process.stdin.flush()
158
+ rgb: NDArray[np.uint8] = pickle.load(self._process.stdout)
159
+ return rgb
160
+
161
+ def close(self) -> None:
162
+ try:
163
+ assert self._process.stdin is not None
164
+ pickle.dump(None, self._process.stdin)
165
+ self._process.stdin.close()
166
+ except OSError:
167
+ pass
168
+ try:
169
+ self._process.wait(timeout=5)
170
+ except subprocess.TimeoutExpired:
171
+ self._process.kill()
172
+
173
+
174
+ if __name__ == "__main__":
175
+ _serve(sys.argv[1], json.loads(sys.argv[2]), CameraConfig.model_validate_json(sys.argv[3]))
@@ -0,0 +1,82 @@
1
+ # Copyright 2026 Dimensional Inc.
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ """Everything about a Go2Sim run: the scene, the physics, and each sensor (no dimOS imports:
16
+ the camera's child process loads this)."""
17
+
18
+ from __future__ import annotations
19
+
20
+ from pathlib import Path
21
+
22
+ from pydantic import BaseModel
23
+
24
+
25
+ class SceneConfig(BaseModel):
26
+ # A cooked dimOS scene package by name (resolved with dimOS's catalog), or explicit files.
27
+ package: str | None = None
28
+ mjcf: Path | None = None # collision geometry; a robot-free MJCF
29
+ visual: Path | None = None # textured glTF/GLB the camera draws; None draws nothing
30
+ visual_y_up: bool = True # glTF's convention; the world is Z-up
31
+ start: tuple[float, float, float] = (0.0, 0.0, 0.0) # x, y, yaw of the robot
32
+ floor_z: float = 0.0 # height the feet start on
33
+ # Scene geometry whose top is below this counts as floor for contacts; the rest is clutter.
34
+ floor_top_z: float = 0.05
35
+
36
+
37
+ class PhysicsConfig(BaseModel):
38
+ timestep: float = 0.0025 # seconds per MuJoCo step; the policy runs every 0.02 s
39
+ # The physics were fitted to real Go2 runs under the bundled policy (go2-sim, dimOS #4441).
40
+ fitted: bool = True
41
+ actuator_tau: float = 0.0151 # seconds of first-order lag between a torque and the joint
42
+ policy: Path | None = None # an rl_sar-layout ONNX walking policy; None is the bundled one
43
+ # Trim the policy's command closed-loop on the true body velocity, as a Go2's sport mode
44
+ # does: the policy alone ignores slow commands and drifts when told to stand.
45
+ track_velocity: bool = True
46
+ command_timeout: float = 0.5 # seconds a cmd_vel holds before the robot stands still
47
+ max_velocity: tuple[float, float, float] = (1.0, 0.5, 1.5) # vx, vy m/s and wz rad/s
48
+ realtime: bool = True # pace to the wall clock; False runs as fast as it can
49
+ cache_dir: Path | None = None # compiled worlds; None is the user cache directory
50
+
51
+
52
+ class LidarConfig(BaseModel):
53
+ enabled: bool = True
54
+ rate_hz: float = 10.0
55
+ # The Mid-360 on a Go2's head, from the body origin, pitched nose-down.
56
+ xyz: tuple[float, float, float] = (0.29515, -0.00003, 0.16297)
57
+ pitch_deg: float = 60.0
58
+ point_rate_hz: int = 200_000
59
+ downsample: int = 1
60
+ min_range: float = 0.16
61
+ max_range: float = 40.0
62
+ noise: bool = True
63
+ dropout: bool = True
64
+ self_occlusion: bool = True # the robot's own body blocks rays
65
+ motion_bins_hz: float = 200.0 # how often a scan's origin follows the moving body
66
+ seed: int = 0
67
+
68
+
69
+ class CameraConfig(BaseModel):
70
+ enabled: bool = True
71
+ rate_hz: float = 10.0
72
+ width: int = 640
73
+ height: int = 480
74
+ fovy_deg: float = 60.0
75
+ # The Go2's nose camera, from the body origin, looking straight ahead.
76
+ xyz: tuple[float, float, float] = (0.32715, -0.00003, 0.04297)
77
+ pitch_deg: float = 0.0
78
+ near: float = 0.05
79
+ far: float = 100.0
80
+ ambient: float = 1.8
81
+ sun: float = 2.5
82
+ background: str = "#1b2230"
@@ -0,0 +1,103 @@
1
+ # Copyright 2026 Dimensional Inc.
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ """Which scene geometry the robot touches, by robot part and kind of geometry. Ground truth."""
16
+
17
+ from __future__ import annotations
18
+
19
+ from dataclasses import dataclass
20
+ import json
21
+ import time
22
+ from typing import Literal
23
+
24
+ from dimos.types.timestamped import Timestamped
25
+ from dimos_lcm.std_msgs import String as LCMString
26
+ import mujoco
27
+ import numpy as np
28
+
29
+ Part = Literal["trunk", "leg", "foot"]
30
+ Kind = Literal["floor", "clutter"]
31
+
32
+
33
+ @dataclass(frozen=True, order=True)
34
+ class Contact:
35
+ part: Part
36
+ kind: Kind
37
+
38
+
39
+ class Contacts(Timestamped):
40
+ """The set of contacts at one instant, published whenever it changes."""
41
+
42
+ msg_name = "dim_go2_sim.Contacts"
43
+
44
+ def __init__(self, contacts: list[Contact] | None = None, ts: float | None = None) -> None:
45
+ self.contacts = contacts or []
46
+ self.ts = time.time() if ts is None else ts
47
+
48
+ def lcm_encode(self) -> bytes:
49
+ payload = {"ts": self.ts, "contacts": [[c.part, c.kind] for c in self.contacts]}
50
+ return LCMString(data=json.dumps(payload)).lcm_encode() # type: ignore[no-any-return]
51
+
52
+ @classmethod
53
+ def lcm_decode(cls, data: bytes) -> Contacts:
54
+ payload = json.loads(LCMString.lcm_decode(data).data)
55
+ return cls([Contact(part, kind) for part, kind in payload["contacts"]], payload["ts"])
56
+
57
+
58
+ class ContactSensor:
59
+ """Classifies each static scene geom once as floor (its top below floor_top_z) or clutter."""
60
+
61
+ def __init__(
62
+ self,
63
+ model: mujoco.MjModel,
64
+ data: mujoco.MjData,
65
+ robot_bodies: set[int],
66
+ trunk: int,
67
+ feet: tuple[int, ...],
68
+ floor_top_z: float,
69
+ ) -> None:
70
+ self._data = data
71
+ self._trunk = trunk
72
+ self._feet = set(feet)
73
+ self._robot = {g for g in range(model.ngeom) if int(model.geom_bodyid[g]) in robot_bodies}
74
+ self._body = model.geom_bodyid
75
+ self._kind: dict[int, Kind] = {}
76
+ for g in range(model.ngeom):
77
+ if g in self._robot or model.body_weldid[model.geom_bodyid[g]] != 0:
78
+ continue
79
+ if model.geom_type[g] == mujoco.mjtGeom.mjGEOM_PLANE:
80
+ self._kind[g] = "floor"
81
+ continue
82
+ up = data.geom_xmat[g].reshape(3, 3)[2]
83
+ center, half = model.geom_aabb[g, :3], model.geom_aabb[g, 3:]
84
+ top = data.geom_xpos[g, 2] + up @ center + np.abs(up) @ half
85
+ self._kind[g] = "floor" if top < floor_top_z else "clutter"
86
+
87
+ def contacts(self) -> list[Contact]:
88
+ """Every (robot part, scene kind) pair in contact right now, sorted."""
89
+ found: set[Contact] = set()
90
+ for i in range(self._data.ncon):
91
+ c = self._data.contact[i]
92
+ for scene, robot in ((c.geom1, c.geom2), (c.geom2, c.geom1)):
93
+ kind = self._kind.get(int(scene))
94
+ if kind is not None and int(robot) in self._robot:
95
+ found.add(Contact(self._part(int(robot)), kind))
96
+ return sorted(found)
97
+
98
+ def _part(self, geom: int) -> Part:
99
+ if geom in self._feet:
100
+ return "foot"
101
+ if self._body[geom] == self._trunk:
102
+ return "trunk"
103
+ return "leg"
@@ -0,0 +1,27 @@
1
+ Copyright (c) 2016-2022 HangZhou YuShu TECHNOLOGY CO.,LTD. ("Unitree Robotics")
2
+ All rights reserved.
3
+
4
+ Redistribution and use in source and binary forms, with or without
5
+ modification, are permitted provided that the following conditions are met:
6
+
7
+ * Redistributions of source code must retain the above copyright notice, this
8
+ list of conditions and the following disclaimer.
9
+
10
+ * Redistributions in binary form must reproduce the above copyright notice,
11
+ this list of conditions and the following disclaimer in the documentation
12
+ and/or other materials provided with the distribution.
13
+
14
+ * Neither the name of the copyright holder nor the names of its
15
+ contributors may be used to endorse or promote products derived from
16
+ this software without specific prior written permission.
17
+
18
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
19
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
20
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
21
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
22
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
23
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
24
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
25
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
26
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
27
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.