simview 3.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.
Files changed (163) hide show
  1. simview/__init__.py +69 -0
  2. simview/__main__.py +174 -0
  3. simview/launcher.py +109 -0
  4. simview/live.py +204 -0
  5. simview/merge.py +593 -0
  6. simview/model.py +690 -0
  7. simview/py.typed +0 -0
  8. simview/scene.py +603 -0
  9. simview/server.py +739 -0
  10. simview/state.py +190 -0
  11. simview/static/css/controls.css +36 -0
  12. simview/static/js/SimView.js +601 -0
  13. simview/static/js/components/AnimationController.js +654 -0
  14. simview/static/js/components/BatchManager.js +198 -0
  15. simview/static/js/components/InteractionController.js +446 -0
  16. simview/static/js/components/InteractionControls.js +116 -0
  17. simview/static/js/components/Scene.js +166 -0
  18. simview/static/js/components/StateStore.js +188 -0
  19. simview/static/js/config.js +233 -0
  20. simview/static/js/main.js +3 -0
  21. simview/static/js/objects/Body.js +616 -0
  22. simview/static/js/objects/StaticObject.js +181 -0
  23. simview/static/js/objects/Terrain.js +514 -0
  24. simview/static/js/objects/utils.js +378 -0
  25. simview/static/js/ui/AnalysisPanel.js +233 -0
  26. simview/static/js/ui/BatchLegend.js +207 -0
  27. simview/static/js/ui/BodyStateWindow.js +521 -0
  28. simview/static/js/ui/Controls.js +586 -0
  29. simview/static/js/ui/ErrorMetrics.js +664 -0
  30. simview/static/js/ui/Legend.js +87 -0
  31. simview/static/js/ui/PlaybackControls.js +314 -0
  32. simview/static/js/ui/ScalarPlotter.js +575 -0
  33. simview/static/js/utils/blobCodec.js +73 -0
  34. simview/static/js/utils/bodyTransforms.js +151 -0
  35. simview/static/js/utils/csv.js +46 -0
  36. simview/static/js/utils/errorMath.js +69 -0
  37. simview/static/js/utils/injectStyles.js +16 -0
  38. simview/static/js/utils/interpolate.js +65 -0
  39. simview/static/js/utils/liveFollow.js +17 -0
  40. simview/static/js/utils/loadRecordingLibs.js +34 -0
  41. simview/static/js/utils/viewState.js +226 -0
  42. simview/static/lib/chroma-js-3.1.2/index.min.js +8 -0
  43. simview/static/lib/chroma-js-3.1.2/src/Color.js +49 -0
  44. simview/static/lib/chroma-js-3.1.2/src/chroma.js +10 -0
  45. simview/static/lib/chroma-js-3.1.2/src/colors/colorbrewer.js +81 -0
  46. simview/static/lib/chroma-js-3.1.2/src/colors/w3cx11.js +164 -0
  47. simview/static/lib/chroma-js-3.1.2/src/generator/average.js +88 -0
  48. simview/static/lib/chroma-js-3.1.2/src/generator/bezier.js +86 -0
  49. simview/static/lib/chroma-js-3.1.2/src/generator/blend.js +57 -0
  50. simview/static/lib/chroma-js-3.1.2/src/generator/cubehelix.js +87 -0
  51. simview/static/lib/chroma-js-3.1.2/src/generator/mix.js +19 -0
  52. simview/static/lib/chroma-js-3.1.2/src/generator/random.js +12 -0
  53. simview/static/lib/chroma-js-3.1.2/src/generator/scale.js +394 -0
  54. simview/static/lib/chroma-js-3.1.2/src/interpolator/_hsx.js +59 -0
  55. simview/static/lib/chroma-js-3.1.2/src/interpolator/hcg.js +12 -0
  56. simview/static/lib/chroma-js-3.1.2/src/interpolator/hsi.js +12 -0
  57. simview/static/lib/chroma-js-3.1.2/src/interpolator/hsl.js +12 -0
  58. simview/static/lib/chroma-js-3.1.2/src/interpolator/hsv.js +12 -0
  59. simview/static/lib/chroma-js-3.1.2/src/interpolator/index.js +1 -0
  60. simview/static/lib/chroma-js-3.1.2/src/interpolator/lab.js +19 -0
  61. simview/static/lib/chroma-js-3.1.2/src/interpolator/lch.js +13 -0
  62. simview/static/lib/chroma-js-3.1.2/src/interpolator/lrgb.js +19 -0
  63. simview/static/lib/chroma-js-3.1.2/src/interpolator/num.js +15 -0
  64. simview/static/lib/chroma-js-3.1.2/src/interpolator/oklab.js +19 -0
  65. simview/static/lib/chroma-js-3.1.2/src/interpolator/oklch.js +12 -0
  66. simview/static/lib/chroma-js-3.1.2/src/interpolator/rgb.js +18 -0
  67. simview/static/lib/chroma-js-3.1.2/src/io/cmyk/cmyk2rgb.js +16 -0
  68. simview/static/lib/chroma-js-3.1.2/src/io/cmyk/index.js +27 -0
  69. simview/static/lib/chroma-js-3.1.2/src/io/cmyk/rgb2cmyk.js +17 -0
  70. simview/static/lib/chroma-js-3.1.2/src/io/css/css2rgb.js +238 -0
  71. simview/static/lib/chroma-js-3.1.2/src/io/css/hsl2css.js +26 -0
  72. simview/static/lib/chroma-js-3.1.2/src/io/css/index.js +27 -0
  73. simview/static/lib/chroma-js-3.1.2/src/io/css/lab2css.js +24 -0
  74. simview/static/lib/chroma-js-3.1.2/src/io/css/lch2css.js +24 -0
  75. simview/static/lib/chroma-js-3.1.2/src/io/css/oklab2css.js +16 -0
  76. simview/static/lib/chroma-js-3.1.2/src/io/css/oklch2css.js +16 -0
  77. simview/static/lib/chroma-js-3.1.2/src/io/css/rgb2css.js +61 -0
  78. simview/static/lib/chroma-js-3.1.2/src/io/gl/index.js +22 -0
  79. simview/static/lib/chroma-js-3.1.2/src/io/hcg/hcg2rgb.js +55 -0
  80. simview/static/lib/chroma-js-3.1.2/src/io/hcg/index.js +27 -0
  81. simview/static/lib/chroma-js-3.1.2/src/io/hcg/rgb2hcg.js +23 -0
  82. simview/static/lib/chroma-js-3.1.2/src/io/hex/hex2rgb.js +56 -0
  83. simview/static/lib/chroma-js-3.1.2/src/io/hex/index.js +29 -0
  84. simview/static/lib/chroma-js-3.1.2/src/io/hex/rgb2hex.js +29 -0
  85. simview/static/lib/chroma-js-3.1.2/src/io/hsi/hsi2rgb.js +45 -0
  86. simview/static/lib/chroma-js-3.1.2/src/io/hsi/index.js +27 -0
  87. simview/static/lib/chroma-js-3.1.2/src/io/hsi/rgb2hsi.js +31 -0
  88. simview/static/lib/chroma-js-3.1.2/src/io/hsl/hsl2rgb.js +35 -0
  89. simview/static/lib/chroma-js-3.1.2/src/io/hsl/index.js +27 -0
  90. simview/static/lib/chroma-js-3.1.2/src/io/hsl/rgb2hsl.js +45 -0
  91. simview/static/lib/chroma-js-3.1.2/src/io/hsv/hsv2rgb.js +47 -0
  92. simview/static/lib/chroma-js-3.1.2/src/io/hsv/index.js +27 -0
  93. simview/static/lib/chroma-js-3.1.2/src/io/hsv/rgb2hsv.js +32 -0
  94. simview/static/lib/chroma-js-3.1.2/src/io/input.js +4 -0
  95. simview/static/lib/chroma-js-3.1.2/src/io/lab/index.js +28 -0
  96. simview/static/lib/chroma-js-3.1.2/src/io/lab/lab-constants.js +120 -0
  97. simview/static/lib/chroma-js-3.1.2/src/io/lab/lab2rgb.js +101 -0
  98. simview/static/lib/chroma-js-3.1.2/src/io/lab/rgb2lab.js +67 -0
  99. simview/static/lib/chroma-js-3.1.2/src/io/lch/hcl2rgb.js +9 -0
  100. simview/static/lib/chroma-js-3.1.2/src/io/lch/index.js +35 -0
  101. simview/static/lib/chroma-js-3.1.2/src/io/lch/lab2lch.js +12 -0
  102. simview/static/lib/chroma-js-3.1.2/src/io/lch/lch2lab.js +18 -0
  103. simview/static/lib/chroma-js-3.1.2/src/io/lch/lch2rgb.js +13 -0
  104. simview/static/lib/chroma-js-3.1.2/src/io/lch/rgb2lch.js +12 -0
  105. simview/static/lib/chroma-js-3.1.2/src/io/named/index.js +30 -0
  106. simview/static/lib/chroma-js-3.1.2/src/io/num/index.js +32 -0
  107. simview/static/lib/chroma-js-3.1.2/src/io/num/num2rgb.js +13 -0
  108. simview/static/lib/chroma-js-3.1.2/src/io/num/rgb2num.js +8 -0
  109. simview/static/lib/chroma-js-3.1.2/src/io/oklab/index.js +27 -0
  110. simview/static/lib/chroma-js-3.1.2/src/io/oklab/oklab2rgb.js +34 -0
  111. simview/static/lib/chroma-js-3.1.2/src/io/oklab/rgb2oklab.js +37 -0
  112. simview/static/lib/chroma-js-3.1.2/src/io/oklch/index.js +27 -0
  113. simview/static/lib/chroma-js-3.1.2/src/io/oklch/oklch2rgb.js +13 -0
  114. simview/static/lib/chroma-js-3.1.2/src/io/oklch/rgb2oklch.js +12 -0
  115. simview/static/lib/chroma-js-3.1.2/src/io/rgb/index.js +44 -0
  116. simview/static/lib/chroma-js-3.1.2/src/io/temp/index.js +22 -0
  117. simview/static/lib/chroma-js-3.1.2/src/io/temp/rgb2temperature.js +30 -0
  118. simview/static/lib/chroma-js-3.1.2/src/io/temp/temperature2rgb.js +39 -0
  119. simview/static/lib/chroma-js-3.1.2/src/ops/alpha.js +13 -0
  120. simview/static/lib/chroma-js-3.1.2/src/ops/clipped.js +5 -0
  121. simview/static/lib/chroma-js-3.1.2/src/ops/darken.js +17 -0
  122. simview/static/lib/chroma-js-3.1.2/src/ops/get.js +13 -0
  123. simview/static/lib/chroma-js-3.1.2/src/ops/luminance.js +54 -0
  124. simview/static/lib/chroma-js-3.1.2/src/ops/mix.js +10 -0
  125. simview/static/lib/chroma-js-3.1.2/src/ops/premultiply.js +12 -0
  126. simview/static/lib/chroma-js-3.1.2/src/ops/saturate.js +15 -0
  127. simview/static/lib/chroma-js-3.1.2/src/ops/set.js +43 -0
  128. simview/static/lib/chroma-js-3.1.2/src/ops/shade.js +11 -0
  129. simview/static/lib/chroma-js-3.1.2/src/utils/analyze.js +191 -0
  130. simview/static/lib/chroma-js-3.1.2/src/utils/clip_rgb.js +15 -0
  131. simview/static/lib/chroma-js-3.1.2/src/utils/contrast.js +12 -0
  132. simview/static/lib/chroma-js-3.1.2/src/utils/contrastAPCA.js +68 -0
  133. simview/static/lib/chroma-js-3.1.2/src/utils/delta-e.js +62 -0
  134. simview/static/lib/chroma-js-3.1.2/src/utils/distance.js +17 -0
  135. simview/static/lib/chroma-js-3.1.2/src/utils/index.js +27 -0
  136. simview/static/lib/chroma-js-3.1.2/src/utils/last.js +8 -0
  137. simview/static/lib/chroma-js-3.1.2/src/utils/limit.js +5 -0
  138. simview/static/lib/chroma-js-3.1.2/src/utils/multiply-matrices.js +36 -0
  139. simview/static/lib/chroma-js-3.1.2/src/utils/scales.js +15 -0
  140. simview/static/lib/chroma-js-3.1.2/src/utils/type.js +18 -0
  141. simview/static/lib/chroma-js-3.1.2/src/utils/unpack.js +17 -0
  142. simview/static/lib/chroma-js-3.1.2/src/utils/valid.js +11 -0
  143. simview/static/lib/chroma-js-3.1.2/src/version.js +2 -0
  144. simview/static/lib/download.js +132 -0
  145. simview/static/lib/js-colormaps.js +19335 -0
  146. simview/static/lib/tar.js +334 -0
  147. simview/static/lib/three-0.174.0/addons/controls/OrbitControls.js +1556 -0
  148. simview/static/lib/three-0.174.0/addons/libs/lil-gui.module.min.js +8 -0
  149. simview/static/lib/three-0.174.0/three.core.js +48830 -0
  150. simview/static/lib/three-0.174.0/three.module.js +17313 -0
  151. simview/static/lib/uPlot.esm.js +6140 -0
  152. simview/static/lib/uPlot.min.css +2 -0
  153. simview/static/textures/contacts/red-cross0.png +0 -0
  154. simview/static/textures/points/ball0.png +0 -0
  155. simview/static/textures/points/ball1.png +0 -0
  156. simview/templates/index.html +81 -0
  157. simview/utils.py +41 -0
  158. simview-3.2.dist-info/METADATA +552 -0
  159. simview-3.2.dist-info/RECORD +163 -0
  160. simview-3.2.dist-info/WHEEL +5 -0
  161. simview-3.2.dist-info/entry_points.txt +2 -0
  162. simview-3.2.dist-info/licenses/LICENSE +28 -0
  163. simview-3.2.dist-info/top_level.txt +1 -0
simview/model.py ADDED
@@ -0,0 +1,690 @@
1
+ import base64
2
+ import logging
3
+ from dataclasses import dataclass, field
4
+ from enum import StrEnum
5
+ from typing import Any
6
+
7
+ import numpy as np
8
+ import torch
9
+ from einops import rearrange
10
+
11
+ logger = logging.getLogger("simview.model")
12
+
13
+ BLOB_PREFIX = "__b64__"
14
+
15
+
16
+ def _encode_blob(array) -> str:
17
+ """Encode a numpy array as a little-endian float32 base64 blob string.
18
+
19
+ The `__b64__` prefix marks the value so the server (and merge) can round-trip
20
+ it as an opaque binary blob instead of verbose JSON.
21
+ """
22
+ return BLOB_PREFIX + base64.b64encode(array.astype("<f4").tobytes()).decode("utf-8")
23
+
24
+
25
+ def _decode_blob(value):
26
+ """Decode a `__b64__`-prefixed base64 blob string back into a flat list of
27
+ little-endian float32 values. Values that aren't blob strings (already plain
28
+ JSON lists, or None) pass through unchanged, so callers can use this
29
+ unconditionally on fields that may or may not be binary-encoded."""
30
+ if not (isinstance(value, str) and value.startswith(BLOB_PREFIX)):
31
+ return value
32
+ raw = base64.b64decode(value[len(BLOB_PREFIX) :])
33
+ return np.frombuffer(raw, dtype="<f4").tolist()
34
+
35
+
36
+ class BodyShapeType(StrEnum):
37
+ POINTCLOUD = "pointcloud"
38
+ MESH = "mesh"
39
+ BOX = "box"
40
+ SPHERE = "sphere"
41
+ CYLINDER = "cylinder"
42
+
43
+
44
+ class OptionalBodyStateAttribute(StrEnum):
45
+ CONTACTS = "contacts"
46
+ VELOCITY = "velocity"
47
+ ANGULAR_VELOCITY = "angularVelocity"
48
+ FORCE = "force"
49
+ TORQUE = "torque"
50
+
51
+
52
+ @dataclass
53
+ class SimViewTerrain:
54
+ extent_x: float
55
+ extent_y: float
56
+ shape_x: int
57
+ shape_y: int
58
+ min_x: float
59
+ min_y: float
60
+ max_x: float
61
+ max_y: float
62
+ min_z: float
63
+ max_z: float
64
+ # These are plain nested lists when constructed directly, but `create()` (and
65
+ # deserialization from JSON) may instead store an opaque `__b64__`-prefixed
66
+ # base64 blob string for compactness; `to_json`/`from_dict` pass them through
67
+ # as-is either way.
68
+ height_data: list[list[float]] | str
69
+ normals: list[list[list[float]]] | str
70
+ is_singleton: bool
71
+ friction_data: list[list[float]] | str | None = None
72
+ stiffness_data: list[list[float]] | str | None = None
73
+ # Value ranges used by the viewer to normalize the color map (analogous to
74
+ # min_z/max_z for height). None when the corresponding data is absent.
75
+ min_friction: float | None = None
76
+ max_friction: float | None = None
77
+ min_stiffness: float | None = None
78
+ max_stiffness: float | None = None
79
+
80
+ def to_json(self):
81
+ # min/max friction/stiffness are independent Optional fields (not tied to
82
+ # friction_data/stiffness_data by the type system, only by convention in
83
+ # create()/from_dict), so the dict's values are genuinely `float | None`.
84
+ bounds: dict[str, float | None] = {
85
+ "minX": self.min_x,
86
+ "minY": self.min_y,
87
+ "maxX": self.max_x,
88
+ "maxY": self.max_y,
89
+ "minZ": self.min_z,
90
+ "maxZ": self.max_z,
91
+ }
92
+ if self.friction_data is not None:
93
+ bounds["minFriction"] = self.min_friction
94
+ bounds["maxFriction"] = self.max_friction
95
+ if self.stiffness_data is not None:
96
+ bounds["minStiffness"] = self.min_stiffness
97
+ bounds["maxStiffness"] = self.max_stiffness
98
+ return {
99
+ "dimensions": {
100
+ "sizeX": self.extent_x,
101
+ "sizeY": self.extent_y,
102
+ "resolutionX": self.shape_x,
103
+ "resolutionY": self.shape_y,
104
+ },
105
+ "bounds": bounds,
106
+ "heightData": self.height_data,
107
+ "normals": self.normals,
108
+ "isSingleton": self.is_singleton,
109
+ "frictionData": self.friction_data,
110
+ "stiffnessData": self.stiffness_data,
111
+ }
112
+
113
+ @classmethod
114
+ def from_dict(cls, d: dict) -> "SimViewTerrain":
115
+ """Reconstruct a SimViewTerrain from the dict produced by `to_json`.
116
+
117
+ `heightData`/`normals`/`frictionData`/`stiffnessData` are kept in
118
+ whatever form they were serialized in (plain nested lists or a
119
+ `__b64__` blob string) -- decode with `simview.model._decode_blob` if
120
+ you need the flat float values back out.
121
+ """
122
+ try:
123
+ dimensions = d["dimensions"]
124
+ bounds = d["bounds"]
125
+ height_data = d["heightData"]
126
+ normals = d["normals"]
127
+ is_singleton = d["isSingleton"]
128
+ except KeyError as e:
129
+ raise ValueError(f"Terrain dict is missing required key: {e}") from e
130
+
131
+ return cls(
132
+ extent_x=dimensions["sizeX"],
133
+ extent_y=dimensions["sizeY"],
134
+ shape_x=dimensions["resolutionX"],
135
+ shape_y=dimensions["resolutionY"],
136
+ min_x=bounds["minX"],
137
+ min_y=bounds["minY"],
138
+ max_x=bounds["maxX"],
139
+ max_y=bounds["maxY"],
140
+ min_z=bounds["minZ"],
141
+ max_z=bounds["maxZ"],
142
+ height_data=height_data,
143
+ normals=normals,
144
+ is_singleton=is_singleton,
145
+ friction_data=d.get("frictionData"),
146
+ stiffness_data=d.get("stiffnessData"),
147
+ min_friction=bounds.get("minFriction"),
148
+ max_friction=bounds.get("maxFriction"),
149
+ min_stiffness=bounds.get("minStiffness"),
150
+ max_stiffness=bounds.get("maxStiffness"),
151
+ )
152
+
153
+ @staticmethod
154
+ def create(
155
+ heightmap: torch.Tensor, # ! remember the x,y indexing is assumed to follow torch's "xy" convention, so increasing column index is increasing x coordinate
156
+ normals: torch.Tensor,
157
+ x_lim: tuple[float, float],
158
+ y_lim: tuple[float, float],
159
+ is_singleton: bool,
160
+ friction_map: torch.Tensor | None = None,
161
+ stiffness_map: torch.Tensor | None = None,
162
+ ) -> "SimViewTerrain":
163
+ if heightmap.ndim != 3:
164
+ raise ValueError(
165
+ f"Heightmap must include a batch dimension (ndim=3); got ndim={heightmap.ndim}."
166
+ )
167
+ if normals.ndim != 4:
168
+ raise ValueError(
169
+ f"Normals must include a batch dimension (ndim=4); got ndim={normals.ndim}."
170
+ )
171
+ if normals.shape[1] != 3:
172
+ raise ValueError(
173
+ f"Normals must have 3 channels (shape[1] == 3); got shape={tuple(normals.shape)}."
174
+ )
175
+ B, Dy, Dx = heightmap.shape
176
+ min_x, max_x = x_lim
177
+ min_y, max_y = y_lim
178
+ min_z = heightmap.min().item()
179
+ max_z = heightmap.max().item()
180
+ extent_x = max_x - min_x
181
+ extent_y = max_y - min_y
182
+ height_data_list = _encode_blob(
183
+ rearrange(heightmap, "b d1 d2 -> b (d1 d2)").cpu().numpy()
184
+ )
185
+ normals_list = _encode_blob(
186
+ rearrange(normals, "b c d1 d2 -> b (d1 d2) c").cpu().numpy()
187
+ )
188
+
189
+ friction_data_list = None
190
+ min_friction = max_friction = None
191
+ if friction_map is not None:
192
+ if friction_map.ndim != 3:
193
+ raise ValueError(
194
+ f"Friction map must include a batch dimension (ndim=3); got ndim={friction_map.ndim}."
195
+ )
196
+ friction_data_list = _encode_blob(
197
+ rearrange(friction_map, "b d1 d2 -> b (d1 d2)").cpu().numpy()
198
+ )
199
+ min_friction = friction_map.min().item()
200
+ max_friction = friction_map.max().item()
201
+
202
+ stiffness_data_list = None
203
+ min_stiffness = max_stiffness = None
204
+ if stiffness_map is not None:
205
+ if stiffness_map.ndim != 3:
206
+ raise ValueError(
207
+ f"Stiffness map must include a batch dimension (ndim=3); got ndim={stiffness_map.ndim}."
208
+ )
209
+ stiffness_data_list = _encode_blob(
210
+ rearrange(stiffness_map, "b d1 d2 -> b (d1 d2)").cpu().numpy()
211
+ )
212
+ min_stiffness = stiffness_map.min().item()
213
+ max_stiffness = stiffness_map.max().item()
214
+
215
+ return SimViewTerrain(
216
+ extent_x=extent_x,
217
+ extent_y=extent_y,
218
+ shape_x=Dx,
219
+ shape_y=Dy,
220
+ min_x=min_x,
221
+ min_y=min_y,
222
+ max_x=max_x,
223
+ max_y=max_y,
224
+ min_z=min_z,
225
+ max_z=max_z,
226
+ height_data=height_data_list,
227
+ normals=normals_list,
228
+ is_singleton=is_singleton,
229
+ friction_data=friction_data_list,
230
+ stiffness_data=stiffness_data_list,
231
+ min_friction=min_friction,
232
+ max_friction=max_friction,
233
+ min_stiffness=min_stiffness,
234
+ max_stiffness=max_stiffness,
235
+ )
236
+
237
+
238
+ @dataclass
239
+ class SimViewBody:
240
+ name: str
241
+ shape: dict
242
+ available_attributes: list[OptionalBodyStateAttribute] | None = None
243
+ # `parent`/`local_transform` express this body's pose relative to another
244
+ # body instead of world space. `local_transform` (a constant [x,y,z,w,qx,qy,qz]
245
+ # offset) marks a *rigid* attachment (e.g. a wheel bolted to a chassis): this
246
+ # body never appears in any state's `bodies[]`, and its world pose is derived
247
+ # every frame from the parent's current pose plus this fixed offset. An
248
+ # *articulated* attachment (e.g. an arm joint) instead sets only `parent` and
249
+ # keeps supplying a per-frame `bodyTransform` in states as usual -- it's just
250
+ # interpreted as local to the parent's current-frame pose rather than world.
251
+ parent: str | None = None
252
+ local_transform: list[float] | None = None
253
+
254
+ def __post_init__(self):
255
+ if self.local_transform is not None:
256
+ if self.parent is None:
257
+ raise ValueError(
258
+ f"Body '{self.name}' has local_transform but no parent; "
259
+ "local_transform only makes sense relative to a parent body."
260
+ )
261
+ if len(self.local_transform) != 7:
262
+ raise ValueError(
263
+ f"Body '{self.name}' local_transform must have 7 elements "
264
+ f"([x, y, z, w, qx, qy, qz]); got {len(self.local_transform)}."
265
+ )
266
+
267
+ def set_available_attributes(
268
+ self, available_attributes: list[str | OptionalBodyStateAttribute]
269
+ ) -> None:
270
+ if self.available_attributes is not None:
271
+ raise ValueError("Available attributes already set")
272
+ self.available_attributes = [
273
+ v
274
+ if isinstance(v, OptionalBodyStateAttribute)
275
+ else OptionalBodyStateAttribute(v)
276
+ for v in available_attributes
277
+ ]
278
+
279
+ @staticmethod
280
+ def _create_shape_dict(body_type: BodyShapeType, **kwargs) -> dict:
281
+ """Helper to create the shape dictionary, converting tensors."""
282
+ shape_dict: dict[str, Any] = {"type": body_type.value}
283
+ for key, value in kwargs.items():
284
+ if isinstance(value, torch.Tensor):
285
+ if value.numel() > 1:
286
+ shape_dict[key] = _encode_blob(value.cpu().numpy())
287
+ else:
288
+ shape_dict[key] = value.item()
289
+ else:
290
+ shape_dict[key] = value
291
+ return shape_dict
292
+
293
+ @staticmethod
294
+ def create(
295
+ name: str,
296
+ body_type: BodyShapeType,
297
+ available_attributes: list[OptionalBodyStateAttribute | str] | None = None,
298
+ parent: str | None = None,
299
+ local_transform: Any | None = None,
300
+ **kwargs,
301
+ ) -> "SimViewBody":
302
+ shape_dict = SimViewBody._create_shape_dict(body_type, **kwargs)
303
+ if local_transform is not None and hasattr(local_transform, "tolist"):
304
+ local_transform = local_transform.tolist()
305
+ body = SimViewBody(
306
+ name=name,
307
+ shape=shape_dict,
308
+ parent=parent,
309
+ local_transform=list(local_transform)
310
+ if local_transform is not None
311
+ else None,
312
+ )
313
+ if available_attributes is not None:
314
+ body.set_available_attributes(available_attributes)
315
+ return body
316
+
317
+ @staticmethod
318
+ def create_box(
319
+ name: str, hx: float, hy: float, hz: float, **kwargs
320
+ ) -> "SimViewBody":
321
+ return SimViewBody.create(
322
+ name, BodyShapeType.BOX, hx=hx, hy=hy, hz=hz, **kwargs
323
+ )
324
+
325
+ @staticmethod
326
+ def create_sphere(name: str, radius: float, **kwargs) -> "SimViewBody":
327
+ return SimViewBody.create(name, BodyShapeType.SPHERE, radius=radius, **kwargs)
328
+
329
+ @staticmethod
330
+ def create_cylinder(
331
+ name: str, radius: float, height: float, **kwargs
332
+ ) -> "SimViewBody":
333
+ return SimViewBody.create(
334
+ name, BodyShapeType.CYLINDER, radius=radius, height=height, **kwargs
335
+ )
336
+
337
+ @staticmethod
338
+ def create_pointcloud(name: str, points: torch.Tensor, **kwargs) -> "SimViewBody":
339
+ return SimViewBody.create(
340
+ name, BodyShapeType.POINTCLOUD, points=points, **kwargs
341
+ )
342
+
343
+ @staticmethod
344
+ def create_mesh(
345
+ name: str, vertices: torch.Tensor, faces: torch.Tensor, **kwargs
346
+ ) -> "SimViewBody":
347
+ return SimViewBody.create(
348
+ name, BodyShapeType.MESH, vertices=vertices, faces=faces, **kwargs
349
+ )
350
+
351
+ def to_json(self) -> dict:
352
+ r = {"name": self.name, "shape": self.shape}
353
+ if self.available_attributes is not None:
354
+ r["availableAttributes"] = [v.value for v in self.available_attributes]
355
+ if self.parent is not None:
356
+ r["parent"] = self.parent
357
+ if self.local_transform is not None:
358
+ r["localTransform"] = self.local_transform
359
+ return r
360
+
361
+ @classmethod
362
+ def from_dict(cls, d: dict) -> "SimViewBody":
363
+ """Reconstruct a SimViewBody from the dict produced by `to_json`."""
364
+ try:
365
+ name = d["name"]
366
+ shape = d["shape"]
367
+ except KeyError as e:
368
+ raise ValueError(f"Body dict is missing required key: {e}") from e
369
+ available_attributes = d.get("availableAttributes")
370
+ return cls(
371
+ name=name,
372
+ shape=shape,
373
+ available_attributes=[
374
+ OptionalBodyStateAttribute(v) for v in available_attributes
375
+ ]
376
+ if available_attributes is not None
377
+ else None,
378
+ parent=d.get("parent"),
379
+ local_transform=d.get("localTransform"),
380
+ )
381
+
382
+
383
+ @dataclass
384
+ class SimViewStaticObject:
385
+ name: str
386
+ is_singleton: bool
387
+ shape: dict | None = None # Used if is_singleton is True
388
+ shapes: list[dict] | None = None # Used if is_singleton is False
389
+
390
+ def __post_init__(self):
391
+ if self.is_singleton and self.shape is None:
392
+ raise ValueError("Singleton static object requires 'shape'.")
393
+ if not self.is_singleton and self.shapes is None:
394
+ raise ValueError("Batched static object requires 'shapes'.")
395
+ if self.is_singleton and self.shapes is not None:
396
+ raise ValueError("Singleton static object cannot have 'shapes'.")
397
+ if not self.is_singleton and self.shape is not None:
398
+ raise ValueError("Batched static object cannot have 'shape'.")
399
+ # Basic validation for batched shapes length could be added if batch_size is known here
400
+
401
+ @staticmethod
402
+ def create_singleton(
403
+ name: str, shape_type: BodyShapeType, **kwargs
404
+ ) -> "SimViewStaticObject":
405
+ shape_dict = SimViewBody._create_shape_dict(
406
+ shape_type, **kwargs
407
+ ) # Reuse helper
408
+ return SimViewStaticObject(name=name, is_singleton=True, shape=shape_dict)
409
+
410
+ @staticmethod
411
+ def create_batched(
412
+ name: str, shape_type: BodyShapeType, shapes_kwargs: list[dict[str, Any]]
413
+ ) -> "SimViewStaticObject":
414
+ """
415
+ Creates a batched static object where all instances share the same shape type.
416
+
417
+ Args:
418
+ name: The name of the static object group.
419
+ shape_type: The BodyShapeType common to all instances in the batch.
420
+ shapes_kwargs: A list of dictionaries, where each dictionary contains the
421
+ keyword arguments for creating the shape of one instance
422
+ in the batch (e.g., [{'hx': 0.1, 'hy': 0.1, 'hz': 0.1}, {'hx': 0.2, ...}]).
423
+ The length of this list must match the batch size.
424
+ """
425
+ shapes_list = []
426
+ if not shapes_kwargs:
427
+ raise ValueError("Batched shapes kwargs list cannot be empty.")
428
+ # The check for list length matching batch_size happens in SimViewModel.add_static_object
429
+ for kwargs in shapes_kwargs:
430
+ # Ensure 'type' isn't passed within kwargs, as it's defined by shape_type
431
+ if "type" in kwargs:
432
+ raise ValueError(
433
+ "Do not include 'type' in shapes_kwargs; use the shape_type argument."
434
+ )
435
+ shapes_list.append(
436
+ SimViewBody._create_shape_dict(shape_type, **kwargs)
437
+ ) # Reuse helper
438
+ return SimViewStaticObject(name=name, is_singleton=False, shapes=shapes_list)
439
+
440
+ def to_json(self) -> dict:
441
+ r = {"name": self.name, "isSingleton": self.is_singleton}
442
+ if self.is_singleton:
443
+ r["shape"] = self.shape
444
+ else:
445
+ r["shapes"] = self.shapes
446
+ return r
447
+
448
+ @classmethod
449
+ def from_dict(cls, d: dict) -> "SimViewStaticObject":
450
+ """Reconstruct a SimViewStaticObject from the dict produced by `to_json`."""
451
+ try:
452
+ name = d["name"]
453
+ is_singleton = d["isSingleton"]
454
+ except KeyError as e:
455
+ raise ValueError(f"Static object dict is missing required key: {e}") from e
456
+ return cls(
457
+ name=name,
458
+ is_singleton=is_singleton,
459
+ shape=d.get("shape"),
460
+ shapes=d.get("shapes"),
461
+ )
462
+
463
+
464
+ def _validate_parent_ref(name: str, parent: str | None, known_bodies: dict) -> None:
465
+ """Raise ValueError if `parent` is self-referential or isn't already in
466
+ `known_bodies`. Requiring the parent to already be known (rather than doing
467
+ a full topological sort) structurally prevents cycles as each body is
468
+ added/parsed: a cycle would require some body to reference a not-yet-known
469
+ name, which this catches immediately."""
470
+ if parent is None:
471
+ return
472
+ if parent == name:
473
+ raise ValueError(f"Body '{name}' cannot be its own parent.")
474
+ if parent not in known_bodies:
475
+ raise ValueError(
476
+ f"Body '{name}' references unknown parent '{parent}'; the parent "
477
+ "must already be defined in the model (added/listed before its children)."
478
+ )
479
+
480
+
481
+ @dataclass
482
+ class SimViewModel:
483
+ batch_size: int
484
+ scalar_names: list[str]
485
+ dt: float
486
+ collapse: bool
487
+ terrain: SimViewTerrain | None = None
488
+ bodies: dict[str, SimViewBody] = field(default_factory=dict)
489
+ static_objects: dict[str, SimViewStaticObject] = field(default_factory=dict)
490
+ batch_names: list[str] | None = None
491
+
492
+ def __post_init__(self):
493
+ if self.batch_names is not None and len(self.batch_names) != self.batch_size:
494
+ raise ValueError(
495
+ f"batch_names length ({len(self.batch_names)}) must match batch size ({self.batch_size})"
496
+ )
497
+
498
+ def add_terrain(self, terrain: SimViewTerrain) -> None:
499
+ if self.terrain is not None:
500
+ raise ValueError("Terrain already exists")
501
+ self.terrain = terrain
502
+
503
+ def add_body(self, body: SimViewBody) -> None:
504
+ if body.name in self.bodies:
505
+ raise ValueError(f"Dynamic body {body.name} already exists")
506
+ _validate_parent_ref(body.name, body.parent, self.bodies)
507
+ self.bodies[body.name] = body
508
+
509
+ def add_static_object(self, static_object: SimViewStaticObject) -> None:
510
+ if static_object.name in self.static_objects:
511
+ raise ValueError(f"Static object {static_object.name} already exists")
512
+ if not static_object.is_singleton:
513
+ # SimViewStaticObject.__post_init__ guarantees `shapes` is set
514
+ # (not None) whenever `is_singleton` is False.
515
+ assert static_object.shapes is not None
516
+ if len(static_object.shapes) != self.batch_size:
517
+ raise ValueError(
518
+ f"Batched static object '{static_object.name}' shapes count "
519
+ f"({len(static_object.shapes)}) must match batch size "
520
+ f"({self.batch_size})."
521
+ )
522
+ self.static_objects[static_object.name] = static_object
523
+
524
+ def create_terrain(
525
+ self,
526
+ heightmap: torch.Tensor,
527
+ normals: torch.Tensor,
528
+ x_lim: tuple[float, float],
529
+ y_lim: tuple[float, float],
530
+ friction_map: torch.Tensor | None = None,
531
+ stiffness_map: torch.Tensor | None = None,
532
+ ) -> None:
533
+ if heightmap.ndim == 2:
534
+ heightmap = heightmap.unsqueeze(0) # add batch dim
535
+ if normals.ndim == 3: # channels first
536
+ normals = normals.unsqueeze(0) # add batch dim
537
+ if friction_map is not None and friction_map.ndim == 2:
538
+ friction_map = friction_map.unsqueeze(0)
539
+ if stiffness_map is not None and stiffness_map.ndim == 2:
540
+ stiffness_map = stiffness_map.unsqueeze(0)
541
+
542
+ # Each field's batch dim must be either 1 (shared across all batches) or
543
+ # exactly batch_size (per-batch). Anything else is a mistake, and the old
544
+ # code silently mishandled the mixed case (e.g. shared height + per-batch
545
+ # normals), producing an inconsistent isSingleton flag.
546
+ provided = {
547
+ "heightmap": heightmap,
548
+ "normals": normals,
549
+ "friction_map": friction_map,
550
+ "stiffness_map": stiffness_map,
551
+ }
552
+ for name, tensor in provided.items():
553
+ if tensor is not None and tensor.shape[0] not in (1, self.batch_size):
554
+ raise ValueError(
555
+ f"Terrain '{name}' batch dim ({tensor.shape[0]}) must be 1 "
556
+ f"(shared) or {self.batch_size} (per-batch)."
557
+ )
558
+
559
+ # Singleton only when every provided field is shared and there is more than
560
+ # one batch to share it across.
561
+ is_singleton = self.batch_size > 1 and all(
562
+ tensor.shape[0] == 1 for tensor in provided.values() if tensor is not None
563
+ )
564
+
565
+ # The viewer always splits terrain data into batch_size chunks, so broadcast
566
+ # any shared field up to the full batch size before encoding.
567
+ if self.batch_size > 1:
568
+ if heightmap.shape[0] == 1:
569
+ heightmap = heightmap.repeat(self.batch_size, 1, 1)
570
+ if normals.shape[0] == 1:
571
+ normals = normals.repeat(self.batch_size, 1, 1, 1)
572
+ if friction_map is not None and friction_map.shape[0] == 1:
573
+ friction_map = friction_map.repeat(self.batch_size, 1, 1)
574
+ if stiffness_map is not None and stiffness_map.shape[0] == 1:
575
+ stiffness_map = stiffness_map.repeat(self.batch_size, 1, 1)
576
+
577
+ self.terrain = SimViewTerrain.create(
578
+ heightmap=heightmap,
579
+ normals=normals,
580
+ x_lim=x_lim,
581
+ y_lim=y_lim,
582
+ is_singleton=is_singleton,
583
+ friction_map=friction_map,
584
+ stiffness_map=stiffness_map,
585
+ )
586
+
587
+ def create_body(
588
+ self,
589
+ body_name: str,
590
+ shape_type: BodyShapeType,
591
+ available_attributes: list[OptionalBodyStateAttribute | str] | None = None,
592
+ parent: str | None = None,
593
+ local_transform: Any | None = None,
594
+ **kwargs,
595
+ ) -> None:
596
+ if body_name in self.bodies:
597
+ raise ValueError(f"Dynamic body {body_name} already exists")
598
+ body = SimViewBody.create(
599
+ body_name,
600
+ shape_type,
601
+ available_attributes=available_attributes,
602
+ parent=parent,
603
+ local_transform=local_transform,
604
+ **kwargs,
605
+ )
606
+ self.add_body(body)
607
+
608
+ def create_static_object_singleton(
609
+ self, name: str, shape_type: BodyShapeType, **kwargs
610
+ ) -> None:
611
+ static_obj = SimViewStaticObject.create_singleton(name, shape_type, **kwargs)
612
+ self.add_static_object(static_obj)
613
+
614
+ def create_static_object_batched(
615
+ self, name: str, shape_type: BodyShapeType, shapes_kwargs: list[dict[str, Any]]
616
+ ) -> None:
617
+ """Helper method to create and add a batched static object."""
618
+ if len(shapes_kwargs) != self.batch_size:
619
+ raise ValueError(
620
+ f"Length of shapes_kwargs ({len(shapes_kwargs)}) must match batch size ({self.batch_size}) for '{name}'."
621
+ )
622
+ static_obj = SimViewStaticObject.create_batched(name, shape_type, shapes_kwargs)
623
+ self.add_static_object(
624
+ static_obj
625
+ ) # add_static_object already performs the length check
626
+
627
+ def to_json(self) -> dict:
628
+ if not self.bodies:
629
+ logger.warning("No dynamic bodies defined in the model.")
630
+ if self.terrain is None:
631
+ raise ValueError("No terrain defined")
632
+ r = {
633
+ "simBatches": self.batch_size,
634
+ "scalarNames": self.scalar_names,
635
+ "dt": self.dt,
636
+ "collapse": self.collapse,
637
+ "terrain": self.terrain.to_json(),
638
+ "bodies": [b.to_json() for b in self.bodies.values()],
639
+ "staticObjects": [s.to_json() for s in self.static_objects.values()],
640
+ }
641
+ if self.batch_names is not None:
642
+ r["batchNames"] = self.batch_names
643
+ return r
644
+
645
+ @classmethod
646
+ def from_dict(cls, d: dict) -> "SimViewModel":
647
+ """Reconstruct a SimViewModel from the dict produced by `to_json`.
648
+
649
+ Centralizes parsing of the wire format: terrain, bodies and static
650
+ objects are all rebuilt via their own `from_dict`, keyed by name so
651
+ `add_body`/`add_static_object`'s uniqueness checks stay meaningful.
652
+ """
653
+ try:
654
+ batch_size = d["simBatches"]
655
+ scalar_names = d["scalarNames"]
656
+ dt = d["dt"]
657
+ collapse = d["collapse"]
658
+ terrain_dict = d["terrain"]
659
+ body_dicts = d["bodies"]
660
+ static_object_dicts = d["staticObjects"]
661
+ except KeyError as e:
662
+ raise ValueError(f"Model dict is missing required key: {e}") from e
663
+
664
+ bodies = {}
665
+ for body_dict in body_dicts:
666
+ body = SimViewBody.from_dict(body_dict)
667
+ if body.name in bodies:
668
+ raise ValueError(f"Model dict has duplicate body name '{body.name}'")
669
+ _validate_parent_ref(body.name, body.parent, bodies)
670
+ bodies[body.name] = body
671
+
672
+ static_objects = {}
673
+ for static_object_dict in static_object_dicts:
674
+ static_object = SimViewStaticObject.from_dict(static_object_dict)
675
+ static_objects[static_object.name] = static_object
676
+
677
+ return cls(
678
+ batch_size=batch_size,
679
+ scalar_names=scalar_names,
680
+ dt=dt,
681
+ collapse=collapse,
682
+ terrain=SimViewTerrain.from_dict(terrain_dict),
683
+ bodies=bodies,
684
+ static_objects=static_objects,
685
+ batch_names=d.get("batchNames"),
686
+ )
687
+
688
+ @property
689
+ def is_complete(self) -> bool:
690
+ return self.terrain is not None
simview/py.typed ADDED
File without changes