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.
- simview/__init__.py +69 -0
- simview/__main__.py +174 -0
- simview/launcher.py +109 -0
- simview/live.py +204 -0
- simview/merge.py +593 -0
- simview/model.py +690 -0
- simview/py.typed +0 -0
- simview/scene.py +603 -0
- simview/server.py +739 -0
- simview/state.py +190 -0
- simview/static/css/controls.css +36 -0
- simview/static/js/SimView.js +601 -0
- simview/static/js/components/AnimationController.js +654 -0
- simview/static/js/components/BatchManager.js +198 -0
- simview/static/js/components/InteractionController.js +446 -0
- simview/static/js/components/InteractionControls.js +116 -0
- simview/static/js/components/Scene.js +166 -0
- simview/static/js/components/StateStore.js +188 -0
- simview/static/js/config.js +233 -0
- simview/static/js/main.js +3 -0
- simview/static/js/objects/Body.js +616 -0
- simview/static/js/objects/StaticObject.js +181 -0
- simview/static/js/objects/Terrain.js +514 -0
- simview/static/js/objects/utils.js +378 -0
- simview/static/js/ui/AnalysisPanel.js +233 -0
- simview/static/js/ui/BatchLegend.js +207 -0
- simview/static/js/ui/BodyStateWindow.js +521 -0
- simview/static/js/ui/Controls.js +586 -0
- simview/static/js/ui/ErrorMetrics.js +664 -0
- simview/static/js/ui/Legend.js +87 -0
- simview/static/js/ui/PlaybackControls.js +314 -0
- simview/static/js/ui/ScalarPlotter.js +575 -0
- simview/static/js/utils/blobCodec.js +73 -0
- simview/static/js/utils/bodyTransforms.js +151 -0
- simview/static/js/utils/csv.js +46 -0
- simview/static/js/utils/errorMath.js +69 -0
- simview/static/js/utils/injectStyles.js +16 -0
- simview/static/js/utils/interpolate.js +65 -0
- simview/static/js/utils/liveFollow.js +17 -0
- simview/static/js/utils/loadRecordingLibs.js +34 -0
- simview/static/js/utils/viewState.js +226 -0
- simview/static/lib/chroma-js-3.1.2/index.min.js +8 -0
- simview/static/lib/chroma-js-3.1.2/src/Color.js +49 -0
- simview/static/lib/chroma-js-3.1.2/src/chroma.js +10 -0
- simview/static/lib/chroma-js-3.1.2/src/colors/colorbrewer.js +81 -0
- simview/static/lib/chroma-js-3.1.2/src/colors/w3cx11.js +164 -0
- simview/static/lib/chroma-js-3.1.2/src/generator/average.js +88 -0
- simview/static/lib/chroma-js-3.1.2/src/generator/bezier.js +86 -0
- simview/static/lib/chroma-js-3.1.2/src/generator/blend.js +57 -0
- simview/static/lib/chroma-js-3.1.2/src/generator/cubehelix.js +87 -0
- simview/static/lib/chroma-js-3.1.2/src/generator/mix.js +19 -0
- simview/static/lib/chroma-js-3.1.2/src/generator/random.js +12 -0
- simview/static/lib/chroma-js-3.1.2/src/generator/scale.js +394 -0
- simview/static/lib/chroma-js-3.1.2/src/interpolator/_hsx.js +59 -0
- simview/static/lib/chroma-js-3.1.2/src/interpolator/hcg.js +12 -0
- simview/static/lib/chroma-js-3.1.2/src/interpolator/hsi.js +12 -0
- simview/static/lib/chroma-js-3.1.2/src/interpolator/hsl.js +12 -0
- simview/static/lib/chroma-js-3.1.2/src/interpolator/hsv.js +12 -0
- simview/static/lib/chroma-js-3.1.2/src/interpolator/index.js +1 -0
- simview/static/lib/chroma-js-3.1.2/src/interpolator/lab.js +19 -0
- simview/static/lib/chroma-js-3.1.2/src/interpolator/lch.js +13 -0
- simview/static/lib/chroma-js-3.1.2/src/interpolator/lrgb.js +19 -0
- simview/static/lib/chroma-js-3.1.2/src/interpolator/num.js +15 -0
- simview/static/lib/chroma-js-3.1.2/src/interpolator/oklab.js +19 -0
- simview/static/lib/chroma-js-3.1.2/src/interpolator/oklch.js +12 -0
- simview/static/lib/chroma-js-3.1.2/src/interpolator/rgb.js +18 -0
- simview/static/lib/chroma-js-3.1.2/src/io/cmyk/cmyk2rgb.js +16 -0
- simview/static/lib/chroma-js-3.1.2/src/io/cmyk/index.js +27 -0
- simview/static/lib/chroma-js-3.1.2/src/io/cmyk/rgb2cmyk.js +17 -0
- simview/static/lib/chroma-js-3.1.2/src/io/css/css2rgb.js +238 -0
- simview/static/lib/chroma-js-3.1.2/src/io/css/hsl2css.js +26 -0
- simview/static/lib/chroma-js-3.1.2/src/io/css/index.js +27 -0
- simview/static/lib/chroma-js-3.1.2/src/io/css/lab2css.js +24 -0
- simview/static/lib/chroma-js-3.1.2/src/io/css/lch2css.js +24 -0
- simview/static/lib/chroma-js-3.1.2/src/io/css/oklab2css.js +16 -0
- simview/static/lib/chroma-js-3.1.2/src/io/css/oklch2css.js +16 -0
- simview/static/lib/chroma-js-3.1.2/src/io/css/rgb2css.js +61 -0
- simview/static/lib/chroma-js-3.1.2/src/io/gl/index.js +22 -0
- simview/static/lib/chroma-js-3.1.2/src/io/hcg/hcg2rgb.js +55 -0
- simview/static/lib/chroma-js-3.1.2/src/io/hcg/index.js +27 -0
- simview/static/lib/chroma-js-3.1.2/src/io/hcg/rgb2hcg.js +23 -0
- simview/static/lib/chroma-js-3.1.2/src/io/hex/hex2rgb.js +56 -0
- simview/static/lib/chroma-js-3.1.2/src/io/hex/index.js +29 -0
- simview/static/lib/chroma-js-3.1.2/src/io/hex/rgb2hex.js +29 -0
- simview/static/lib/chroma-js-3.1.2/src/io/hsi/hsi2rgb.js +45 -0
- simview/static/lib/chroma-js-3.1.2/src/io/hsi/index.js +27 -0
- simview/static/lib/chroma-js-3.1.2/src/io/hsi/rgb2hsi.js +31 -0
- simview/static/lib/chroma-js-3.1.2/src/io/hsl/hsl2rgb.js +35 -0
- simview/static/lib/chroma-js-3.1.2/src/io/hsl/index.js +27 -0
- simview/static/lib/chroma-js-3.1.2/src/io/hsl/rgb2hsl.js +45 -0
- simview/static/lib/chroma-js-3.1.2/src/io/hsv/hsv2rgb.js +47 -0
- simview/static/lib/chroma-js-3.1.2/src/io/hsv/index.js +27 -0
- simview/static/lib/chroma-js-3.1.2/src/io/hsv/rgb2hsv.js +32 -0
- simview/static/lib/chroma-js-3.1.2/src/io/input.js +4 -0
- simview/static/lib/chroma-js-3.1.2/src/io/lab/index.js +28 -0
- simview/static/lib/chroma-js-3.1.2/src/io/lab/lab-constants.js +120 -0
- simview/static/lib/chroma-js-3.1.2/src/io/lab/lab2rgb.js +101 -0
- simview/static/lib/chroma-js-3.1.2/src/io/lab/rgb2lab.js +67 -0
- simview/static/lib/chroma-js-3.1.2/src/io/lch/hcl2rgb.js +9 -0
- simview/static/lib/chroma-js-3.1.2/src/io/lch/index.js +35 -0
- simview/static/lib/chroma-js-3.1.2/src/io/lch/lab2lch.js +12 -0
- simview/static/lib/chroma-js-3.1.2/src/io/lch/lch2lab.js +18 -0
- simview/static/lib/chroma-js-3.1.2/src/io/lch/lch2rgb.js +13 -0
- simview/static/lib/chroma-js-3.1.2/src/io/lch/rgb2lch.js +12 -0
- simview/static/lib/chroma-js-3.1.2/src/io/named/index.js +30 -0
- simview/static/lib/chroma-js-3.1.2/src/io/num/index.js +32 -0
- simview/static/lib/chroma-js-3.1.2/src/io/num/num2rgb.js +13 -0
- simview/static/lib/chroma-js-3.1.2/src/io/num/rgb2num.js +8 -0
- simview/static/lib/chroma-js-3.1.2/src/io/oklab/index.js +27 -0
- simview/static/lib/chroma-js-3.1.2/src/io/oklab/oklab2rgb.js +34 -0
- simview/static/lib/chroma-js-3.1.2/src/io/oklab/rgb2oklab.js +37 -0
- simview/static/lib/chroma-js-3.1.2/src/io/oklch/index.js +27 -0
- simview/static/lib/chroma-js-3.1.2/src/io/oklch/oklch2rgb.js +13 -0
- simview/static/lib/chroma-js-3.1.2/src/io/oklch/rgb2oklch.js +12 -0
- simview/static/lib/chroma-js-3.1.2/src/io/rgb/index.js +44 -0
- simview/static/lib/chroma-js-3.1.2/src/io/temp/index.js +22 -0
- simview/static/lib/chroma-js-3.1.2/src/io/temp/rgb2temperature.js +30 -0
- simview/static/lib/chroma-js-3.1.2/src/io/temp/temperature2rgb.js +39 -0
- simview/static/lib/chroma-js-3.1.2/src/ops/alpha.js +13 -0
- simview/static/lib/chroma-js-3.1.2/src/ops/clipped.js +5 -0
- simview/static/lib/chroma-js-3.1.2/src/ops/darken.js +17 -0
- simview/static/lib/chroma-js-3.1.2/src/ops/get.js +13 -0
- simview/static/lib/chroma-js-3.1.2/src/ops/luminance.js +54 -0
- simview/static/lib/chroma-js-3.1.2/src/ops/mix.js +10 -0
- simview/static/lib/chroma-js-3.1.2/src/ops/premultiply.js +12 -0
- simview/static/lib/chroma-js-3.1.2/src/ops/saturate.js +15 -0
- simview/static/lib/chroma-js-3.1.2/src/ops/set.js +43 -0
- simview/static/lib/chroma-js-3.1.2/src/ops/shade.js +11 -0
- simview/static/lib/chroma-js-3.1.2/src/utils/analyze.js +191 -0
- simview/static/lib/chroma-js-3.1.2/src/utils/clip_rgb.js +15 -0
- simview/static/lib/chroma-js-3.1.2/src/utils/contrast.js +12 -0
- simview/static/lib/chroma-js-3.1.2/src/utils/contrastAPCA.js +68 -0
- simview/static/lib/chroma-js-3.1.2/src/utils/delta-e.js +62 -0
- simview/static/lib/chroma-js-3.1.2/src/utils/distance.js +17 -0
- simview/static/lib/chroma-js-3.1.2/src/utils/index.js +27 -0
- simview/static/lib/chroma-js-3.1.2/src/utils/last.js +8 -0
- simview/static/lib/chroma-js-3.1.2/src/utils/limit.js +5 -0
- simview/static/lib/chroma-js-3.1.2/src/utils/multiply-matrices.js +36 -0
- simview/static/lib/chroma-js-3.1.2/src/utils/scales.js +15 -0
- simview/static/lib/chroma-js-3.1.2/src/utils/type.js +18 -0
- simview/static/lib/chroma-js-3.1.2/src/utils/unpack.js +17 -0
- simview/static/lib/chroma-js-3.1.2/src/utils/valid.js +11 -0
- simview/static/lib/chroma-js-3.1.2/src/version.js +2 -0
- simview/static/lib/download.js +132 -0
- simview/static/lib/js-colormaps.js +19335 -0
- simview/static/lib/tar.js +334 -0
- simview/static/lib/three-0.174.0/addons/controls/OrbitControls.js +1556 -0
- simview/static/lib/three-0.174.0/addons/libs/lil-gui.module.min.js +8 -0
- simview/static/lib/three-0.174.0/three.core.js +48830 -0
- simview/static/lib/three-0.174.0/three.module.js +17313 -0
- simview/static/lib/uPlot.esm.js +6140 -0
- simview/static/lib/uPlot.min.css +2 -0
- simview/static/textures/contacts/red-cross0.png +0 -0
- simview/static/textures/points/ball0.png +0 -0
- simview/static/textures/points/ball1.png +0 -0
- simview/templates/index.html +81 -0
- simview/utils.py +41 -0
- simview-3.2.dist-info/METADATA +552 -0
- simview-3.2.dist-info/RECORD +163 -0
- simview-3.2.dist-info/WHEEL +5 -0
- simview-3.2.dist-info/entry_points.txt +2 -0
- simview-3.2.dist-info/licenses/LICENSE +28 -0
- 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.")
|