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/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
|