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/scene.py ADDED
@@ -0,0 +1,603 @@
1
+ import gzip
2
+ import json
3
+ import logging
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ import numpy as np
8
+ import torch
9
+
10
+ from .model import (
11
+ BodyShapeType, # If used directly by users of SimulationData for body creation
12
+ OptionalBodyStateAttribute, # If used directly
13
+ SimViewBody,
14
+ SimViewModel,
15
+ SimViewStaticObject,
16
+ SimViewTerrain,
17
+ _encode_blob,
18
+ )
19
+ from .server import SimViewServer
20
+ from .state import (
21
+ TRAJECTORY_VECTOR_FIELDS,
22
+ BodyTrajectory,
23
+ LocalTransformLike,
24
+ SimViewBodyState,
25
+ )
26
+ from .utils import read_maybe_gzipped_bytes
27
+
28
+ logger = logging.getLogger("simview.scene")
29
+
30
+
31
+ class ViewerHandle:
32
+ """A running, non-blocking SimView server for a snapshot of a scene.
33
+
34
+ Returned by `SimulationScene.show`. Holds the background server thread
35
+ started for that snapshot; `stop()` (also called automatically on
36
+ context-manager exit) shuts it down. `_repr_html_` lets Jupyter render the
37
+ viewer inline in an iframe just by evaluating the handle in a cell.
38
+ """
39
+
40
+ def __init__(self, threaded) -> None:
41
+ self._threaded = threaded
42
+
43
+ @property
44
+ def url(self) -> str:
45
+ return f"http://{self._threaded.bind_host}:{self._threaded.port}"
46
+
47
+ def stop(self) -> None:
48
+ """Stop the background server. Idempotent."""
49
+ self._threaded.stop()
50
+
51
+ def _repr_html_(self) -> str:
52
+ """Jupyter calls this automatically when the handle is the result of
53
+ a cell, embedding the viewer inline without the user having to open a
54
+ separate browser tab."""
55
+ url = self.url
56
+ return (
57
+ f'<iframe src="{url}" width="100%" height="600" '
58
+ f'style="border:none;" allow="fullscreen"></iframe>'
59
+ )
60
+
61
+ def __enter__(self) -> "ViewerHandle":
62
+ return self
63
+
64
+ def __exit__(self, exc_type, exc_value, traceback) -> None:
65
+ self.stop()
66
+
67
+
68
+ def _to_f4(value) -> np.ndarray:
69
+ """Coerce a tensor / array / nested list to a contiguous little-endian float32 array."""
70
+ if isinstance(value, torch.Tensor):
71
+ value = value.detach().cpu().numpy()
72
+ return np.ascontiguousarray(np.asarray(value, dtype="<f4"))
73
+
74
+
75
+ def _iter_names(name: str | list[str]):
76
+ """Yield each individual body name, whether `name` is a single string or
77
+ a list of names sharing one transform."""
78
+ return name if isinstance(name, list) else [name]
79
+
80
+
81
+ def _name_label(name: str | list[str]) -> str:
82
+ """Human-readable label for `name` in error messages."""
83
+ return ", ".join(name) if isinstance(name, list) else name
84
+
85
+
86
+ def _validate_body_name(name: str | list[str], model: SimViewModel) -> None:
87
+ """Raise ValueError if `name` (or any name in it, when a list) isn't a
88
+ body defined in `model`."""
89
+ if isinstance(name, list) and not name:
90
+ raise ValueError("Body name list must not be empty.")
91
+ for n in _iter_names(name):
92
+ if n not in model.bodies:
93
+ valid = sorted(model.bodies)
94
+ raise ValueError(
95
+ f"Unknown body '{n}'; not defined in the model. "
96
+ f"Valid body names: {valid}."
97
+ )
98
+
99
+
100
+ def _validate_not_rigid(name: str | list[str], model: SimViewModel) -> None:
101
+ """Raise ValueError if `name` (or any name in it, when a list) refers to a
102
+ rigidly-attached body (`local_transform` set on the model). Such bodies
103
+ never receive per-frame data -- their pose is derived by the viewer from
104
+ their parent's current pose plus the fixed offset -- so passing state data
105
+ for them here would be silently ignored on the wire, which is almost
106
+ certainly a mistake."""
107
+ for n in _iter_names(name):
108
+ body = model.bodies.get(n)
109
+ if body is not None and body.local_transform is not None:
110
+ raise ValueError(
111
+ f"Body '{n}' is rigidly attached (local_transform is set on the "
112
+ "model) and must not be given per-frame state data; its pose is "
113
+ "derived from its parent every frame."
114
+ )
115
+
116
+
117
+ def _as_tbk(value, T: int, B: int, k: int, field: str, body: str) -> np.ndarray:
118
+ """Normalize a per-body trajectory field to shape (T, B, k), float32.
119
+
120
+ Accepts (T, B, k), or (T, k) when B == 1. Validates T, B and the trailing
121
+ width so mistakes surface here rather than as a corrupt scene.
122
+ """
123
+ arr = _to_f4(value)
124
+ if arr.ndim == 2: # (T, k) -> single batch
125
+ arr = arr[:, None, :]
126
+ if arr.ndim != 3:
127
+ raise ValueError(
128
+ f"{body}.{field} must have shape (T, {k}) or (T, B, {k}); got {arr.shape}."
129
+ )
130
+ Tt, Bb, kk = arr.shape
131
+ if kk != k:
132
+ raise ValueError(f"{body}.{field} last dim is {kk}; expected {k}.")
133
+ if Tt != T:
134
+ raise ValueError(
135
+ f"{body}.{field} has {Tt} timesteps; expected {T} (from times)."
136
+ )
137
+ if Bb != B:
138
+ raise ValueError(
139
+ f"{body}.{field} has batch dim {Bb}; expected {B} "
140
+ f"(use (T, {k}) only when batch size is 1)."
141
+ )
142
+ return np.ascontiguousarray(arr)
143
+
144
+
145
+ def _as_tb(value, T: int, B: int, name: str) -> np.ndarray:
146
+ """Normalize a scalar time-series to shape (T, B). Accepts (T,) when B == 1."""
147
+ arr = _to_f4(value)
148
+ if arr.ndim == 1:
149
+ arr = arr[:, None]
150
+ if arr.shape != (T, B):
151
+ raise ValueError(
152
+ f"scalar '{name}' must have shape (T,) or (T, B) = ({T}, {B}); got {arr.shape}."
153
+ )
154
+ return arr
155
+
156
+
157
+ class SimulationScene:
158
+ def __init__(
159
+ self,
160
+ batch_size: int,
161
+ scalar_names: list[str],
162
+ dt: float,
163
+ collapse: bool = False,
164
+ terrain: SimViewTerrain | None = None,
165
+ bodies: dict[str, SimViewBody] | None = None,
166
+ static_objects: dict[str, SimViewStaticObject] | None = None,
167
+ batch_names: list[str] | None = None,
168
+ ) -> None:
169
+ """
170
+ Initializes the simulation data container.
171
+ Manages the SimViewModel and the time-series states.
172
+ """
173
+ self.model = SimViewModel(
174
+ batch_size=batch_size,
175
+ scalar_names=scalar_names,
176
+ dt=dt,
177
+ collapse=collapse,
178
+ terrain=terrain,
179
+ bodies=bodies if bodies is not None else {},
180
+ static_objects=static_objects if static_objects is not None else {},
181
+ batch_names=batch_names,
182
+ )
183
+ self.states: list[dict] = []
184
+
185
+ @classmethod
186
+ def from_dict(cls, d: dict) -> "SimulationScene":
187
+ """Reconstruct a SimulationScene from the dict produced by `save`/`to_json`
188
+ (i.e. the parsed `{"model": ..., "states": ...}` document).
189
+
190
+ Binary `__b64__`-encoded fields inside `states` (e.g. from
191
+ ``add_trajectory(binary=True)``) are left as-is, matching the on-disk
192
+ wire format, so a subsequent `save()` reproduces the same bytes for
193
+ those fields without a decode/re-encode round trip.
194
+ """
195
+ try:
196
+ model_dict = d["model"]
197
+ states = d["states"]
198
+ except KeyError as e:
199
+ raise ValueError(f"Scene dict is missing required key: {e}") from e
200
+
201
+ model = SimViewModel.from_dict(model_dict)
202
+ scene = cls(
203
+ batch_size=model.batch_size,
204
+ scalar_names=model.scalar_names,
205
+ dt=model.dt,
206
+ collapse=model.collapse,
207
+ terrain=model.terrain,
208
+ bodies=model.bodies,
209
+ static_objects=model.static_objects,
210
+ batch_names=model.batch_names,
211
+ )
212
+ scene.states = list(states)
213
+ return scene
214
+
215
+ @classmethod
216
+ def load(cls, path: str | Path) -> "SimulationScene":
217
+ """Load a SimulationScene previously written by `save`.
218
+
219
+ Transparently reads gzip-compressed files (detected by magic bytes,
220
+ regardless of extension) as well as plain JSON. Enables round-tripping
221
+ from Python: ``SimulationScene.load(p).save(p2)``.
222
+ """
223
+ data = json.loads(read_maybe_gzipped_bytes(path))
224
+ return cls.from_dict(data)
225
+
226
+ def add_state(
227
+ self,
228
+ time: float,
229
+ body_states: list[SimViewBodyState],
230
+ scalar_values: dict[str, torch.Tensor | np.ndarray | list] | None = None,
231
+ ) -> None:
232
+ """
233
+ Adds a new state (snapshot in time) to the simulation data.
234
+ """
235
+ for state in body_states:
236
+ _validate_body_name(state.body_name, self.model)
237
+ _validate_not_rigid(state.body_name, self.model)
238
+
239
+ if self.model.scalar_names:
240
+ if scalar_values is None:
241
+ raise ValueError(
242
+ "Scalar values must be provided when scalar_names are defined in the model."
243
+ )
244
+ if set(scalar_values.keys()) != set(self.model.scalar_names):
245
+ raise ValueError(
246
+ "Provided scalar_values keys do not match scalar_names in the model."
247
+ )
248
+
249
+ processed_scalars = {}
250
+ for k, v in scalar_values.items():
251
+ if isinstance(v, (torch.Tensor, np.ndarray)):
252
+ processed_scalars[k] = v.tolist()
253
+ elif isinstance(v, list):
254
+ processed_scalars[k] = v
255
+ else:
256
+ raise TypeError(
257
+ f"Scalar value for '{k}' must be a torch.Tensor, "
258
+ "numpy.ndarray, or a list."
259
+ )
260
+ else:
261
+ processed_scalars = {}
262
+ if scalar_values:
263
+ logger.warning(
264
+ "scalar_values provided but no scalar_names defined in the "
265
+ "model. These values will be ignored."
266
+ )
267
+
268
+ self.states.append(
269
+ {
270
+ "time": time,
271
+ "bodies": [state.to_json() for state in body_states],
272
+ **processed_scalars,
273
+ }
274
+ )
275
+
276
+ def add_trajectory(
277
+ self,
278
+ times,
279
+ trajectories: list[BodyTrajectory],
280
+ scalar_values: dict[str, torch.Tensor | np.ndarray | list] | None = None,
281
+ binary: bool = True,
282
+ ) -> None:
283
+ """Append an entire time-series in one call.
284
+
285
+ Equivalent to looping ``add_state`` over ``T`` frames, but converts each
286
+ body's pose/vector tensors once (vectorised) instead of per frame, which
287
+ is dramatically faster for long trajectories. With ``binary=True`` the
288
+ numeric per-body fields (``bodyTransform`` and any provided vectors) are
289
+ packed as float32 ``__b64__`` blobs, shrinking the output file and the
290
+ parse cost; the viewer and :func:`merge_simulation_files` decode these
291
+ transparently. Set ``binary=False`` to emit plain JSON lists. A body's
292
+ ``contacts`` (if provided on its :class:`BodyTrajectory`) are ragged and
293
+ always emitted as plain JSON per frame, using the same encoding as
294
+ ``SimViewBodyState`` / ``add_state``.
295
+
296
+ Args:
297
+ times: sequence of length ``T`` of snapshot times (seconds).
298
+ trajectories: one :class:`BodyTrajectory` per body; each body must
299
+ already exist in the model.
300
+ scalar_values: for a scene with ``scalar_names``, maps each name to a
301
+ ``(T, B)`` (or ``(T,)`` when ``B == 1``) series.
302
+ """
303
+ times = [
304
+ float(t)
305
+ for t in (
306
+ times.tolist()
307
+ if isinstance(times, (torch.Tensor, np.ndarray))
308
+ else times
309
+ )
310
+ ]
311
+ T = len(times)
312
+ B = self.model.batch_size
313
+
314
+ if self.model.scalar_names:
315
+ if scalar_values is None or set(scalar_values) != set(
316
+ self.model.scalar_names
317
+ ):
318
+ raise ValueError(
319
+ "scalar_values keys must match the model's scalar_names."
320
+ )
321
+ scalars = {
322
+ name: _as_tb(scalar_values[name], T, B, name)
323
+ for name in self.model.scalar_names
324
+ }
325
+ else:
326
+ if scalar_values:
327
+ logger.warning(
328
+ "scalar_values provided but no scalar_names defined; ignoring."
329
+ )
330
+ scalars = {}
331
+
332
+ # Pre-normalize every field to (T, B, k) float32 up front so the per-frame
333
+ # loop below only slices and encodes. Contacts are ragged (ints per body
334
+ # per batch), so they're normalized separately into a plain length-T list.
335
+ prepared: list[tuple[str | list[str], dict[str, np.ndarray]]] = []
336
+ prepared_contacts: list[tuple[str | list[str], list]] = []
337
+ for traj in trajectories:
338
+ _validate_body_name(traj.name, self.model)
339
+ _validate_not_rigid(traj.name, self.model)
340
+ label = _name_label(traj.name)
341
+ fields = {
342
+ "bodyTransform": np.concatenate(
343
+ [
344
+ _as_tbk(traj.positions, T, B, 3, "positions", label),
345
+ _as_tbk(traj.orientations, T, B, 4, "orientations", label),
346
+ ],
347
+ axis=-1,
348
+ )
349
+ }
350
+ for attr, wire_key in TRAJECTORY_VECTOR_FIELDS.items():
351
+ value = getattr(traj, attr)
352
+ if value is not None:
353
+ fields[wire_key] = _as_tbk(value, T, B, 3, attr, label)
354
+ prepared.append((traj.name, fields))
355
+
356
+ if traj.contacts is not None:
357
+ if len(traj.contacts) != T:
358
+ raise ValueError(
359
+ f"{label}.contacts has {len(traj.contacts)} timesteps; "
360
+ f"expected {T} (from times)."
361
+ )
362
+ contacts_per_t = [
363
+ SimViewBodyState._process_contacts(frame) for frame in traj.contacts
364
+ ]
365
+ prepared_contacts.append((traj.name, contacts_per_t))
366
+
367
+ def encode(slice_: np.ndarray):
368
+ return _encode_blob(slice_) if binary else slice_.tolist()
369
+
370
+ # dict keys must be hashable, so group names (lists) are keyed by tuple.
371
+ def _name_key(name):
372
+ return tuple(name) if isinstance(name, list) else name
373
+
374
+ contacts_by_name = {
375
+ _name_key(name): contacts for name, contacts in prepared_contacts
376
+ }
377
+ for t in range(T):
378
+ bodies = [
379
+ {
380
+ "name": name,
381
+ **{key: encode(arr[t]) for key, arr in fields.items()},
382
+ **(
383
+ {"contacts": contacts_by_name[_name_key(name)][t]}
384
+ if _name_key(name) in contacts_by_name
385
+ else {}
386
+ ),
387
+ }
388
+ for name, fields in prepared
389
+ ]
390
+ state = {"time": times[t], "bodies": bodies}
391
+ for name, arr in scalars.items():
392
+ state[name] = arr[t].tolist()
393
+ self.states.append(state)
394
+
395
+ def save(self, filepath: str | Path, compress: bool = False) -> None:
396
+ """
397
+ Exports the complete simulation data (model and states) to a JSON file.
398
+ Uses a streaming approach to reduce memory spikes for large simulations.
399
+
400
+ Args:
401
+ filepath: Destination path. If it ends in ``.gz`` the output is
402
+ gzip-compressed regardless of `compress`.
403
+ compress: If True, gzip-compress the output (useful for large
404
+ simulations, which can reach 100+ MB as plain JSON). If
405
+ `filepath` doesn't already end in ``.gz``, the suffix is
406
+ appended so the extension reflects the actual file contents.
407
+ """
408
+ if not self.model.is_complete:
409
+ raise ValueError(
410
+ "Cannot save data: The simulation model is not complete (e.g., terrain might be missing)."
411
+ )
412
+
413
+ # Reconcile available_attributes with actual data across all states
414
+ # (an attribute may be absent from earlier frames but present later)
415
+ if self.states:
416
+ provided_attrs_by_body = {}
417
+ for state in self.states:
418
+ for body_data in state.get("bodies", []):
419
+ name = body_data.get("name")
420
+ if name:
421
+ # Everything in the body's dict other than name and bodyTransform is an optional attribute
422
+ provided = set(body_data.keys()) - {"name", "bodyTransform"}
423
+ for n in _iter_names(name):
424
+ provided_attrs_by_body.setdefault(n, set()).update(provided)
425
+
426
+ for name, body in self.model.bodies.items():
427
+ if name in provided_attrs_by_body:
428
+ provided = provided_attrs_by_body[name]
429
+ if provided:
430
+ body.available_attributes = [
431
+ OptionalBodyStateAttribute(k) for k in provided
432
+ ]
433
+ else:
434
+ body.available_attributes = None
435
+
436
+ output_path = Path(filepath)
437
+ if compress and output_path.suffix != ".gz":
438
+ output_path = output_path.with_name(output_path.name + ".gz")
439
+ compress = compress or output_path.suffix == ".gz"
440
+ output_path.parent.mkdir(parents=True, exist_ok=True)
441
+
442
+ try:
443
+ logger.info("Saving simulation data to %s...", output_path)
444
+ open_fn = (
445
+ (lambda p: gzip.open(p, "wt")) if compress else (lambda p: open(p, "w"))
446
+ )
447
+ with open_fn(output_path) as f:
448
+ f.write("{\n")
449
+ f.write(' "model": ')
450
+ json.dump(self.model.to_json(), f, indent=2)
451
+ f.write(",\n")
452
+ f.write(' "states": [\n')
453
+ for i, state in enumerate(self.states):
454
+ if i > 0:
455
+ f.write(",\n")
456
+ f.write(" ")
457
+ json.dump(state, f)
458
+ f.write("\n ]\n}")
459
+ logger.info("Simulation data successfully saved to %s", output_path)
460
+ except Exception:
461
+ logger.exception("Error saving simulation data to %s", output_path)
462
+ raise
463
+
464
+ def show(
465
+ self,
466
+ host: str = "127.0.0.1",
467
+ preferred_port: int = 5420,
468
+ open_browser: bool = False,
469
+ ) -> ViewerHandle:
470
+ """Serve a snapshot of this scene on a background thread and return
471
+ immediately, instead of blocking like `SimViewLauncher`/`SimViewServer.run`.
472
+
473
+ Intended for Jupyter notebooks and scripts that want to keep running
474
+ (or keep the cell interactive) while the viewer is up: the returned
475
+ `ViewerHandle` renders inline via `_repr_html_` when it's a cell's
476
+ result, and its `stop()` (or exiting it as a context manager) shuts
477
+ the server down. The scene itself is left untouched -- unlike
478
+ `SimViewLauncher`, `show` doesn't clear `self.states`/`self.model`, so
479
+ the same scene can still be `save()`d or shown again afterwards.
480
+
481
+ Multiple concurrent `show()` calls (on the same or different scenes)
482
+ are fine -- each gets its own server thread and port (via
483
+ `find_free_port`).
484
+ """
485
+ if not self.model.is_complete:
486
+ raise ValueError(
487
+ "Cannot show scene: the simulation model is not complete "
488
+ "(e.g. terrain might be missing)."
489
+ )
490
+
491
+ # Local import: simview.live pulls in uvicorn, which authoring-only
492
+ # (torch-free-viewer) installs may not need until a viewer is
493
+ # actually started.
494
+ from .live import _ThreadedServer
495
+
496
+ data = {"model": self.model.to_json(), "states": self.states}
497
+ server = SimViewServer(data=data)
498
+
499
+ threaded = _ThreadedServer(
500
+ server.app,
501
+ host=host,
502
+ preferred_port=preferred_port,
503
+ thread_name="simview-show-server",
504
+ )
505
+ handle = ViewerHandle(threaded)
506
+
507
+ logger.info("SimView viewer running on %s", handle.url)
508
+ if open_browser:
509
+ import webbrowser
510
+
511
+ webbrowser.open(handle.url)
512
+
513
+ return handle
514
+
515
+ def create_terrain(
516
+ self,
517
+ heightmap: torch.Tensor,
518
+ normals: torch.Tensor,
519
+ x_lim: tuple[float, float],
520
+ y_lim: tuple[float, float],
521
+ friction_map: torch.Tensor | None = None,
522
+ stiffness_map: torch.Tensor | None = None,
523
+ ) -> None:
524
+ """Adds terrain to the simulation model."""
525
+ self.model.create_terrain(
526
+ heightmap,
527
+ normals,
528
+ x_lim,
529
+ y_lim,
530
+ friction_map=friction_map,
531
+ stiffness_map=stiffness_map,
532
+ )
533
+
534
+ def add_terrain_object(self, terrain: SimViewTerrain) -> None:
535
+ """Adds a pre-configured SimViewTerrain object to the model."""
536
+ self.model.add_terrain(terrain)
537
+
538
+ def create_body(
539
+ self,
540
+ body_name: str,
541
+ shape_type: BodyShapeType,
542
+ available_attributes: list[OptionalBodyStateAttribute | str] | None = None,
543
+ parent: str | None = None,
544
+ local_transform: LocalTransformLike | None = None,
545
+ **kwargs,
546
+ ) -> None:
547
+ """Creates and adds a dynamic body to the simulation model.
548
+
549
+ ``parent``/``local_transform`` attach this body to another body already
550
+ in the model, instead of it moving in world space:
551
+
552
+ - Rigid attachment (e.g. a wheel bolted to a chassis): pass both
553
+ ``parent`` and ``local_transform`` (a constant ``[x, y, z, w, qx, qy,
554
+ qz]`` offset). Never call ``add_state``/``add_trajectory`` for this
555
+ body afterwards -- its world pose is derived by the viewer every
556
+ frame from the parent's current pose plus this fixed offset.
557
+ - Articulated attachment (e.g. an arm joint): pass only ``parent``.
558
+ Keep supplying this body's pose every frame via ``add_state``/
559
+ ``add_trajectory`` as usual -- it's just interpreted as local to the
560
+ parent's current-frame pose instead of world space.
561
+ """
562
+ self.model.create_body(
563
+ body_name,
564
+ shape_type,
565
+ available_attributes=available_attributes,
566
+ parent=parent,
567
+ local_transform=local_transform,
568
+ **kwargs,
569
+ )
570
+
571
+ def add_body_object(self, body: SimViewBody) -> None:
572
+ """Adds a pre-configured SimViewBody object to the model. See
573
+ `create_body` for the meaning of `body.parent`/`body.local_transform`."""
574
+ self.model.add_body(body)
575
+
576
+ def create_static_object_singleton(
577
+ self, name: str, shape_type: BodyShapeType, **kwargs
578
+ ) -> None:
579
+ """Creates and adds a singleton static object to the simulation model."""
580
+ self.model.create_static_object_singleton(name, shape_type, **kwargs)
581
+
582
+ def create_static_object_batched(
583
+ self, name: str, shape_type: BodyShapeType, shapes_kwargs: list[dict[str, Any]]
584
+ ) -> None:
585
+ """Creates and adds a batched static object to the simulation model."""
586
+ self.model.create_static_object_batched(name, shape_type, shapes_kwargs)
587
+
588
+ def add_static_object_instance(self, static_object: SimViewStaticObject) -> None:
589
+ """Adds a pre-configured SimViewStaticObject to the model."""
590
+ self.model.add_static_object(static_object)
591
+
592
+ def _clear_internal_data(self) -> None:
593
+ """
594
+ Clears the stored simulation states and model data to free up memory.
595
+ """
596
+ self.states = []
597
+ # Clear large terrain data if present
598
+ if self.model and self.model.terrain:
599
+ self.model.terrain.height_data = []
600
+ self.model.terrain.normals = []
601
+ self.model.terrain.friction_data = None
602
+ self.model.terrain.stiffness_data = None
603
+ logger.info("SimulationScene: Internal data cleared.")