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/merge.py
ADDED
|
@@ -0,0 +1,593 @@
|
|
|
1
|
+
"""Merge multiple SimView JSON simulation files into a single multi-batch scene.
|
|
2
|
+
|
|
3
|
+
Each input file contributes its `simBatches` as extra batches in the output
|
|
4
|
+
(e.g. a single-batch real-world recording plus a single-batch simulated rerun
|
|
5
|
+
become a 2-batch scene, viewable and comparable side by side). All files must
|
|
6
|
+
describe the same physical setup -- identical bodies and terrain grid -- since
|
|
7
|
+
that's what makes the batches comparable.
|
|
8
|
+
|
|
9
|
+
Files are not required to share a timeline: the first file's timestamps become
|
|
10
|
+
the merged timeline, and every other file is resampled onto it by nearest
|
|
11
|
+
timestamp (zero-order hold, no interpolation). Put the recording you care most
|
|
12
|
+
about matching frame-for-frame first.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import base64
|
|
16
|
+
import bisect
|
|
17
|
+
import json
|
|
18
|
+
import logging
|
|
19
|
+
import struct
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Sequence
|
|
22
|
+
|
|
23
|
+
try:
|
|
24
|
+
import orjson
|
|
25
|
+
except ImportError:
|
|
26
|
+
orjson = None
|
|
27
|
+
|
|
28
|
+
from .utils import read_maybe_gzipped_bytes
|
|
29
|
+
|
|
30
|
+
logger = logging.getLogger("simview.merge")
|
|
31
|
+
|
|
32
|
+
_OPTIONAL_VECTOR_ATTRS = ["velocity", "angularVelocity", "force", "torque"]
|
|
33
|
+
|
|
34
|
+
# Trailing width of each binary per-body state field, used to reshape a decoded
|
|
35
|
+
# flat float32 buffer back into per-batch rows.
|
|
36
|
+
_STATE_FIELD_WIDTHS = {
|
|
37
|
+
"bodyTransform": 7,
|
|
38
|
+
"velocity": 3,
|
|
39
|
+
"angularVelocity": 3,
|
|
40
|
+
"force": 3,
|
|
41
|
+
"torque": 3,
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _decode_b64_floats(value) -> tuple[float, ...] | None:
|
|
46
|
+
"""Decode a binary ``__b64__`` blob to a flat tuple of floats, or return
|
|
47
|
+
None if `value` isn't such a blob (e.g. it's an already-batched plain
|
|
48
|
+
list). Kept dependency-free (no numpy/torch) so it works in
|
|
49
|
+
viewing-only installs."""
|
|
50
|
+
if not (isinstance(value, str) and value.startswith("__b64__")):
|
|
51
|
+
return None
|
|
52
|
+
raw = base64.b64decode(value[7:])
|
|
53
|
+
return struct.unpack(f"<{len(raw) // 4}f", raw)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _decode_state_field(value, width: int):
|
|
57
|
+
"""Expand a binary ``__b64__`` per-body state field to a list of per-batch
|
|
58
|
+
rows. Plain lists pass through unchanged, so merged output is always JSON
|
|
59
|
+
lists regardless of whether inputs used binary encoding."""
|
|
60
|
+
flat = _decode_b64_floats(value)
|
|
61
|
+
if flat is None:
|
|
62
|
+
return value
|
|
63
|
+
return [list(flat[i : i + width]) for i in range(0, len(flat), width)]
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _decode_per_batch(value: list | str, batch_size: int) -> list:
|
|
67
|
+
"""Normalize a terrain data field (heightData/normals/frictionData/
|
|
68
|
+
stiffnessData) to a plain list of length `batch_size`, one entry per
|
|
69
|
+
batch, regardless of whether it's a binary ``__b64__`` blob (one flat
|
|
70
|
+
buffer covering all batches) or an already-batched plain list. Used so
|
|
71
|
+
that inputs mixing binary and plain-list encoding can still be
|
|
72
|
+
concatenated -- each file's field is decoded independently rather than
|
|
73
|
+
branching on a single file's encoding."""
|
|
74
|
+
flat = _decode_b64_floats(value)
|
|
75
|
+
if flat is None:
|
|
76
|
+
# `_decode_b64_floats` only returns None for non-`__b64__` input, and
|
|
77
|
+
# the only `str` values these terrain fields ever take on are
|
|
78
|
+
# `__b64__` blobs (see `_expand_batched`, which requires a `str` here
|
|
79
|
+
# to start with that prefix) -- so a plain (non-b64) `value` is
|
|
80
|
+
# always a `list` at this point.
|
|
81
|
+
assert isinstance(value, list)
|
|
82
|
+
return value
|
|
83
|
+
width = len(flat) // batch_size
|
|
84
|
+
return [list(flat[i : i + width]) for i in range(0, len(flat), width)]
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _load_json(path: Path) -> dict:
|
|
88
|
+
raw = read_maybe_gzipped_bytes(path)
|
|
89
|
+
return orjson.loads(raw) if orjson else json.loads(raw)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _require(doc, key: str, expected_type: type | tuple[type, ...], label: str):
|
|
93
|
+
"""Look up a dotted `key` (e.g. "model.terrain.bounds") in `doc`, raising a
|
|
94
|
+
clear ValueError naming `label` and the offending key if it's missing or
|
|
95
|
+
has the wrong type. Returns the value on success."""
|
|
96
|
+
node = doc
|
|
97
|
+
parts = key.split(".")
|
|
98
|
+
for i, part in enumerate(parts):
|
|
99
|
+
if not isinstance(node, dict) or part not in node:
|
|
100
|
+
raise ValueError(
|
|
101
|
+
f"File '{label}' is missing '{key}' -- is it a valid SimView scene?"
|
|
102
|
+
)
|
|
103
|
+
node = node[part]
|
|
104
|
+
is_last = i == len(parts) - 1
|
|
105
|
+
if is_last and not isinstance(node, expected_type):
|
|
106
|
+
type_names = (
|
|
107
|
+
expected_type.__name__
|
|
108
|
+
if isinstance(expected_type, type)
|
|
109
|
+
else " or ".join(t.__name__ for t in expected_type)
|
|
110
|
+
)
|
|
111
|
+
raise ValueError(
|
|
112
|
+
f"File '{label}' has '{key}' of type {type(node).__name__}; "
|
|
113
|
+
f"expected {type_names} -- is it a valid SimView scene?"
|
|
114
|
+
)
|
|
115
|
+
return node
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _validate_doc(doc: dict, label: str) -> None:
|
|
119
|
+
"""Lightweight upfront structural check for a loaded SimView JSON document,
|
|
120
|
+
so malformed input fails fast with a clear message instead of a deep
|
|
121
|
+
KeyError once the merge logic starts walking nested fields."""
|
|
122
|
+
if not isinstance(doc, dict):
|
|
123
|
+
raise ValueError(
|
|
124
|
+
f"File '{label}' does not contain a JSON object at the top level "
|
|
125
|
+
"-- is it a valid SimView scene?"
|
|
126
|
+
)
|
|
127
|
+
_require(doc, "model", dict, label)
|
|
128
|
+
_require(doc, "model.bodies", list, label)
|
|
129
|
+
for idx, body in enumerate(doc["model"]["bodies"]):
|
|
130
|
+
if not isinstance(body, dict) or "name" not in body:
|
|
131
|
+
raise ValueError(
|
|
132
|
+
f"File '{label}' has 'model.bodies[{idx}]' missing 'name' -- "
|
|
133
|
+
"is it a valid SimView scene?"
|
|
134
|
+
)
|
|
135
|
+
_require(doc, "model.terrain", dict, label)
|
|
136
|
+
_require(doc, "model.terrain.dimensions", dict, label)
|
|
137
|
+
_require(doc, "model.terrain.bounds", dict, label)
|
|
138
|
+
_require(doc, "model.terrain.heightData", (list, str), label)
|
|
139
|
+
_require(doc, "model.terrain.normals", (list, str), label)
|
|
140
|
+
_require(doc, "states", list, label)
|
|
141
|
+
if not doc["states"]:
|
|
142
|
+
raise ValueError(f"'{label}' has no states")
|
|
143
|
+
for idx, state in enumerate(doc["states"]):
|
|
144
|
+
if not isinstance(state, dict) or "time" not in state:
|
|
145
|
+
raise ValueError(
|
|
146
|
+
f"File '{label}' has 'states[{idx}]' missing 'time' -- is it "
|
|
147
|
+
"a valid SimView scene?"
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _expand_batched(
|
|
152
|
+
values: list | str, is_singleton: bool, batch_size: int, field: str, label: str
|
|
153
|
+
) -> list | str:
|
|
154
|
+
# A __b64__ blob's decoded length is always taken to span batch_size rows
|
|
155
|
+
# (see _decode_per_batch, and Terrain.js's identical convention on the
|
|
156
|
+
# viewer side) regardless of isSingleton. Singleton data is nominally a
|
|
157
|
+
# single shared entry, but the only producer that ever sets isSingleton on
|
|
158
|
+
# terrain (SimViewModel.create_terrain) already broadcasts it out to
|
|
159
|
+
# batch_size rows before encoding, so the blob is never a lone unbroadcast
|
|
160
|
+
# row that needs expanding here -- doing so would double it to
|
|
161
|
+
# batch_size^2 rows. Leave b64 blobs untouched; only plain lists (where the
|
|
162
|
+
# row count is directly observable) may need broadcasting below.
|
|
163
|
+
if isinstance(values, str) and values.startswith("__b64__"):
|
|
164
|
+
return values
|
|
165
|
+
|
|
166
|
+
if len(values) == batch_size:
|
|
167
|
+
return values
|
|
168
|
+
if is_singleton and len(values) == 1:
|
|
169
|
+
return list(values) * batch_size
|
|
170
|
+
raise ValueError(
|
|
171
|
+
f"'{label}': '{field}' has {len(values)} entries; expected 1 (singleton) "
|
|
172
|
+
f"or {batch_size} (simBatches)."
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _normalize_per_batch(value: list, batch_size: int) -> list:
|
|
177
|
+
"""Normalize a body-state vector field that may be a flat single-batch
|
|
178
|
+
vector or an already-batched list of vectors into a list of length
|
|
179
|
+
batch_size."""
|
|
180
|
+
if batch_size == 1 and value and not isinstance(value[0], list):
|
|
181
|
+
return [value]
|
|
182
|
+
return value
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _merge_bodies(models: list[dict], labels: list[str]) -> list[dict]:
|
|
186
|
+
bodies = models[0].get("bodies", [])
|
|
187
|
+
for model, label in zip(models[1:], labels[1:]):
|
|
188
|
+
if model.get("bodies", []) != bodies:
|
|
189
|
+
raise ValueError(
|
|
190
|
+
f"'{label}' defines different bodies than '{labels[0]}'. All merged "
|
|
191
|
+
"files must describe identical bodies (name, shape, availableAttributes) "
|
|
192
|
+
"for batches to be comparable."
|
|
193
|
+
)
|
|
194
|
+
return bodies
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _default_batch_names(paths: Sequence[Path], batch_sizes: list[int]) -> list[str]:
|
|
198
|
+
"""One name per output batch, derived from the source file it came from.
|
|
199
|
+
Single-batch files just use the file stem; multi-batch files get an index
|
|
200
|
+
suffix so batches from the same file are still distinguishable."""
|
|
201
|
+
names = []
|
|
202
|
+
for path, batch_size in zip(paths, batch_sizes):
|
|
203
|
+
stem = path.stem
|
|
204
|
+
if batch_size == 1:
|
|
205
|
+
names.append(stem)
|
|
206
|
+
else:
|
|
207
|
+
names.extend(f"{stem}[{j}]" for j in range(batch_size))
|
|
208
|
+
return names
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _merge_scalar_names(models: list[dict], labels: list[str]) -> list[str]:
|
|
212
|
+
names = models[0].get("scalarNames") or []
|
|
213
|
+
name_set = set(names)
|
|
214
|
+
for model, label in zip(models[1:], labels[1:]):
|
|
215
|
+
other = set(model.get("scalarNames") or [])
|
|
216
|
+
if other != name_set:
|
|
217
|
+
raise ValueError(
|
|
218
|
+
f"'{label}' has scalarNames {sorted(other)}, expected {sorted(name_set)} "
|
|
219
|
+
f"(from '{labels[0]}'). All merged files must define the same scalars."
|
|
220
|
+
)
|
|
221
|
+
return names
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _merge_static_objects(
|
|
225
|
+
models: list[dict], batch_sizes: list[int], labels: list[str]
|
|
226
|
+
) -> list[dict]:
|
|
227
|
+
first = models[0].get("staticObjects") or []
|
|
228
|
+
names = [s["name"] for s in first]
|
|
229
|
+
for model, label in zip(models[1:], labels[1:]):
|
|
230
|
+
other_names = [s["name"] for s in (model.get("staticObjects") or [])]
|
|
231
|
+
if other_names != names:
|
|
232
|
+
raise ValueError(
|
|
233
|
+
f"'{label}' defines different static objects than '{labels[0]}'."
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
merged = []
|
|
237
|
+
for idx, name in enumerate(names):
|
|
238
|
+
is_singleton = first[idx]["isSingleton"]
|
|
239
|
+
entry = {"name": name, "isSingleton": is_singleton}
|
|
240
|
+
if is_singleton:
|
|
241
|
+
shape = first[idx]["shape"]
|
|
242
|
+
for model, label in zip(models[1:], labels[1:]):
|
|
243
|
+
if model["staticObjects"][idx]["shape"] != shape:
|
|
244
|
+
raise ValueError(
|
|
245
|
+
f"Singleton static object '{name}' differs between "
|
|
246
|
+
f"'{labels[0]}' and '{label}'."
|
|
247
|
+
)
|
|
248
|
+
entry["shape"] = shape
|
|
249
|
+
else:
|
|
250
|
+
shapes = []
|
|
251
|
+
for model, batch_size, label in zip(models, batch_sizes, labels):
|
|
252
|
+
shapes.extend(
|
|
253
|
+
_expand_batched(
|
|
254
|
+
model["staticObjects"][idx]["shapes"],
|
|
255
|
+
False,
|
|
256
|
+
batch_size,
|
|
257
|
+
"shapes",
|
|
258
|
+
label,
|
|
259
|
+
)
|
|
260
|
+
)
|
|
261
|
+
entry["shapes"] = shapes
|
|
262
|
+
merged.append(entry)
|
|
263
|
+
return merged
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _merge_terrain(
|
|
267
|
+
models: list[dict], batch_sizes: list[int], labels: list[str]
|
|
268
|
+
) -> dict:
|
|
269
|
+
first_terrain = models[0]["terrain"]
|
|
270
|
+
dims = first_terrain["dimensions"]
|
|
271
|
+
for model, label in zip(models[1:], labels[1:]):
|
|
272
|
+
other_dims = model["terrain"]["dimensions"]
|
|
273
|
+
if other_dims != dims:
|
|
274
|
+
raise ValueError(
|
|
275
|
+
f"'{label}' terrain dimensions {other_dims} do not match "
|
|
276
|
+
f"'{labels[0]}' dimensions {dims}."
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
has_friction = all(
|
|
280
|
+
model["terrain"].get("frictionData") is not None for model in models
|
|
281
|
+
)
|
|
282
|
+
has_stiffness = all(
|
|
283
|
+
model["terrain"].get("stiffnessData") is not None for model in models
|
|
284
|
+
)
|
|
285
|
+
if not has_friction and any(
|
|
286
|
+
model["terrain"].get("frictionData") is not None for model in models
|
|
287
|
+
):
|
|
288
|
+
logger.warning(
|
|
289
|
+
"Not all files provide terrain friction data; dropping "
|
|
290
|
+
"frictionData from the merged terrain."
|
|
291
|
+
)
|
|
292
|
+
if not has_stiffness and any(
|
|
293
|
+
model["terrain"].get("stiffnessData") is not None for model in models
|
|
294
|
+
):
|
|
295
|
+
logger.warning(
|
|
296
|
+
"Not all files provide terrain stiffness data; dropping "
|
|
297
|
+
"stiffnessData from the merged terrain."
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
def _concat_lists_or_b64(items: list[tuple]) -> list | None:
|
|
301
|
+
# Each item is decoded independently, keyed by its own batch_size (not
|
|
302
|
+
# branched on items[0]'s encoding), so a mix of binary and plain-list
|
|
303
|
+
# inputs merges correctly instead of crashing -- or silently
|
|
304
|
+
# corrupting shapes -- on a differently-encoded item.
|
|
305
|
+
if not items:
|
|
306
|
+
return None
|
|
307
|
+
merged = []
|
|
308
|
+
for value, batch_size in items:
|
|
309
|
+
merged.extend(_decode_per_batch(value, batch_size))
|
|
310
|
+
return merged
|
|
311
|
+
|
|
312
|
+
height_data, normals, friction_data, stiffness_data = [], [], [], []
|
|
313
|
+
min_z = max_z = None
|
|
314
|
+
min_friction = max_friction = min_stiffness = max_stiffness = None
|
|
315
|
+
for model, batch_size, label in zip(models, batch_sizes, labels):
|
|
316
|
+
terrain = model["terrain"]
|
|
317
|
+
singleton = terrain.get("isSingleton", False)
|
|
318
|
+
height_data.append(
|
|
319
|
+
(
|
|
320
|
+
_expand_batched(
|
|
321
|
+
terrain["heightData"], singleton, batch_size, "heightData", label
|
|
322
|
+
),
|
|
323
|
+
batch_size,
|
|
324
|
+
)
|
|
325
|
+
)
|
|
326
|
+
normals.append(
|
|
327
|
+
(
|
|
328
|
+
_expand_batched(
|
|
329
|
+
terrain["normals"], singleton, batch_size, "normals", label
|
|
330
|
+
),
|
|
331
|
+
batch_size,
|
|
332
|
+
)
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
bounds = terrain["bounds"]
|
|
336
|
+
min_z = bounds["minZ"] if min_z is None else min(min_z, bounds["minZ"])
|
|
337
|
+
max_z = bounds["maxZ"] if max_z is None else max(max_z, bounds["maxZ"])
|
|
338
|
+
if has_friction:
|
|
339
|
+
friction_data.append(
|
|
340
|
+
(
|
|
341
|
+
_expand_batched(
|
|
342
|
+
terrain["frictionData"],
|
|
343
|
+
singleton,
|
|
344
|
+
batch_size,
|
|
345
|
+
"frictionData",
|
|
346
|
+
label,
|
|
347
|
+
),
|
|
348
|
+
batch_size,
|
|
349
|
+
)
|
|
350
|
+
)
|
|
351
|
+
min_friction = (
|
|
352
|
+
bounds["minFriction"]
|
|
353
|
+
if min_friction is None
|
|
354
|
+
else min(min_friction, bounds["minFriction"])
|
|
355
|
+
)
|
|
356
|
+
max_friction = (
|
|
357
|
+
bounds["maxFriction"]
|
|
358
|
+
if max_friction is None
|
|
359
|
+
else max(max_friction, bounds["maxFriction"])
|
|
360
|
+
)
|
|
361
|
+
if has_stiffness:
|
|
362
|
+
stiffness_data.append(
|
|
363
|
+
(
|
|
364
|
+
_expand_batched(
|
|
365
|
+
terrain["stiffnessData"],
|
|
366
|
+
singleton,
|
|
367
|
+
batch_size,
|
|
368
|
+
"stiffnessData",
|
|
369
|
+
label,
|
|
370
|
+
),
|
|
371
|
+
batch_size,
|
|
372
|
+
)
|
|
373
|
+
)
|
|
374
|
+
min_stiffness = (
|
|
375
|
+
bounds["minStiffness"]
|
|
376
|
+
if min_stiffness is None
|
|
377
|
+
else min(min_stiffness, bounds["minStiffness"])
|
|
378
|
+
)
|
|
379
|
+
max_stiffness = (
|
|
380
|
+
bounds["maxStiffness"]
|
|
381
|
+
if max_stiffness is None
|
|
382
|
+
else max(max_stiffness, bounds["maxStiffness"])
|
|
383
|
+
)
|
|
384
|
+
|
|
385
|
+
merged_bounds = {
|
|
386
|
+
"minX": first_terrain["bounds"]["minX"],
|
|
387
|
+
"maxX": first_terrain["bounds"]["maxX"],
|
|
388
|
+
"minY": first_terrain["bounds"]["minY"],
|
|
389
|
+
"maxY": first_terrain["bounds"]["maxY"],
|
|
390
|
+
"minZ": min_z,
|
|
391
|
+
"maxZ": max_z,
|
|
392
|
+
}
|
|
393
|
+
if has_friction:
|
|
394
|
+
merged_bounds["minFriction"] = min_friction
|
|
395
|
+
merged_bounds["maxFriction"] = max_friction
|
|
396
|
+
if has_stiffness:
|
|
397
|
+
merged_bounds["minStiffness"] = min_stiffness
|
|
398
|
+
merged_bounds["maxStiffness"] = max_stiffness
|
|
399
|
+
|
|
400
|
+
return {
|
|
401
|
+
"dimensions": dims,
|
|
402
|
+
"bounds": merged_bounds,
|
|
403
|
+
"isSingleton": False,
|
|
404
|
+
"heightData": _concat_lists_or_b64(height_data),
|
|
405
|
+
"normals": _concat_lists_or_b64(normals),
|
|
406
|
+
"frictionData": _concat_lists_or_b64(friction_data) if has_friction else None,
|
|
407
|
+
"stiffnessData": _concat_lists_or_b64(stiffness_data)
|
|
408
|
+
if has_stiffness
|
|
409
|
+
else None,
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def _nearest_index(sorted_times: list[float], t: float) -> int:
|
|
414
|
+
i = bisect.bisect_left(sorted_times, t)
|
|
415
|
+
if i == 0:
|
|
416
|
+
return 0
|
|
417
|
+
if i >= len(sorted_times):
|
|
418
|
+
return len(sorted_times) - 1
|
|
419
|
+
before, after = sorted_times[i - 1], sorted_times[i]
|
|
420
|
+
return i - 1 if (t - before) <= (after - t) else i
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def _state_body_lookup(
|
|
424
|
+
states: list[dict], file_idx: int, state_idx: int, cache: dict
|
|
425
|
+
) -> dict[str, dict]:
|
|
426
|
+
key = (file_idx, state_idx)
|
|
427
|
+
lookup = cache.get(key)
|
|
428
|
+
if lookup is None:
|
|
429
|
+
lookup = {}
|
|
430
|
+
for b in states[state_idx].get("bodies", []):
|
|
431
|
+
name = b["name"]
|
|
432
|
+
# `name` may be a list of body names sharing one transform (see
|
|
433
|
+
# SimulationScene.add_state/add_trajectory); index each under its
|
|
434
|
+
# own key so per-body lookups below don't need to know about it.
|
|
435
|
+
for n in name if isinstance(name, list) else [name]:
|
|
436
|
+
lookup[n] = b
|
|
437
|
+
cache[key] = lookup
|
|
438
|
+
return lookup
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def _merge_states(
|
|
442
|
+
states_list: list[list[dict]],
|
|
443
|
+
batch_sizes: list[int],
|
|
444
|
+
bodies: list[dict],
|
|
445
|
+
scalar_names: list[str],
|
|
446
|
+
labels: list[str],
|
|
447
|
+
) -> list[dict]:
|
|
448
|
+
ref_times = [s["time"] for s in states_list[0]]
|
|
449
|
+
times_by_file = [[s["time"] for s in states] for states in states_list]
|
|
450
|
+
total_batches = sum(batch_sizes)
|
|
451
|
+
lookup_cache: dict = {}
|
|
452
|
+
|
|
453
|
+
merged_states = []
|
|
454
|
+
for out_idx, t in enumerate(ref_times):
|
|
455
|
+
state_idx_by_file = [
|
|
456
|
+
out_idx if file_idx == 0 else _nearest_index(times_by_file[file_idx], t)
|
|
457
|
+
for file_idx in range(len(states_list))
|
|
458
|
+
]
|
|
459
|
+
|
|
460
|
+
merged_bodies = []
|
|
461
|
+
for body in bodies:
|
|
462
|
+
name = body["name"]
|
|
463
|
+
if body.get("localTransform") is not None:
|
|
464
|
+
# Rigidly-attached body: never appears in any state's `bodies[]`
|
|
465
|
+
# (its pose is derived by the viewer from its parent + this
|
|
466
|
+
# constant offset), so there's nothing to merge -- keep the
|
|
467
|
+
# merged output just as compact by skipping it here too.
|
|
468
|
+
continue
|
|
469
|
+
available = set(body.get("availableAttributes") or [])
|
|
470
|
+
transform = []
|
|
471
|
+
attr_values = {
|
|
472
|
+
attr: [] for attr in _OPTIONAL_VECTOR_ATTRS if attr in available
|
|
473
|
+
}
|
|
474
|
+
contacts = [] if "contacts" in available else None
|
|
475
|
+
|
|
476
|
+
for file_idx, (states, batch_size) in enumerate(
|
|
477
|
+
zip(states_list, batch_sizes)
|
|
478
|
+
):
|
|
479
|
+
state_idx = state_idx_by_file[file_idx]
|
|
480
|
+
lookup = _state_body_lookup(states, file_idx, state_idx, lookup_cache)
|
|
481
|
+
body_state = lookup.get(name)
|
|
482
|
+
if body_state is None:
|
|
483
|
+
raise ValueError(
|
|
484
|
+
f"'{labels[file_idx]}' is missing body '{name}' at "
|
|
485
|
+
f"t={states[state_idx]['time']}."
|
|
486
|
+
)
|
|
487
|
+
transform.extend(
|
|
488
|
+
_normalize_per_batch(
|
|
489
|
+
_decode_state_field(
|
|
490
|
+
body_state["bodyTransform"],
|
|
491
|
+
_STATE_FIELD_WIDTHS["bodyTransform"],
|
|
492
|
+
),
|
|
493
|
+
batch_size,
|
|
494
|
+
)
|
|
495
|
+
)
|
|
496
|
+
for attr in attr_values:
|
|
497
|
+
if attr not in body_state:
|
|
498
|
+
raise ValueError(
|
|
499
|
+
f"'{labels[file_idx]}' body '{name}' declares '{attr}' as "
|
|
500
|
+
f"available but is missing it at t={states[state_idx]['time']}."
|
|
501
|
+
)
|
|
502
|
+
attr_values[attr].extend(
|
|
503
|
+
_normalize_per_batch(
|
|
504
|
+
_decode_state_field(
|
|
505
|
+
body_state[attr], _STATE_FIELD_WIDTHS[attr]
|
|
506
|
+
),
|
|
507
|
+
batch_size,
|
|
508
|
+
)
|
|
509
|
+
)
|
|
510
|
+
if contacts is not None:
|
|
511
|
+
if "contacts" not in body_state:
|
|
512
|
+
raise ValueError(
|
|
513
|
+
f"'{labels[file_idx]}' body '{name}' declares 'contacts' as "
|
|
514
|
+
f"available but is missing it at t={states[state_idx]['time']}."
|
|
515
|
+
)
|
|
516
|
+
contacts.extend(body_state["contacts"])
|
|
517
|
+
|
|
518
|
+
if len(transform) != total_batches:
|
|
519
|
+
raise ValueError(
|
|
520
|
+
f"Merged 'bodyTransform' for body '{name}' has {len(transform)} "
|
|
521
|
+
f"rows; expected {total_batches} (sum of simBatches across "
|
|
522
|
+
f"{', '.join(repr(label) for label in labels)}). Check that "
|
|
523
|
+
"each file's per-body state rows match its declared simBatches."
|
|
524
|
+
)
|
|
525
|
+
merged_body = {"name": name, "bodyTransform": transform, **attr_values}
|
|
526
|
+
if contacts is not None:
|
|
527
|
+
merged_body["contacts"] = contacts
|
|
528
|
+
merged_bodies.append(merged_body)
|
|
529
|
+
|
|
530
|
+
merged_state = {"time": t, "bodies": merged_bodies}
|
|
531
|
+
for scalar_name in scalar_names:
|
|
532
|
+
values = []
|
|
533
|
+
for file_idx, (states, batch_size) in enumerate(
|
|
534
|
+
zip(states_list, batch_sizes)
|
|
535
|
+
):
|
|
536
|
+
state_idx = state_idx_by_file[file_idx]
|
|
537
|
+
scalar_values = states[state_idx].get(scalar_name)
|
|
538
|
+
if scalar_values is None:
|
|
539
|
+
raise ValueError(
|
|
540
|
+
f"'{labels[file_idx]}' is missing scalar '{scalar_name}' at "
|
|
541
|
+
f"t={states[state_idx]['time']}."
|
|
542
|
+
)
|
|
543
|
+
values.extend(scalar_values)
|
|
544
|
+
merged_state[scalar_name] = values
|
|
545
|
+
merged_states.append(merged_state)
|
|
546
|
+
|
|
547
|
+
return merged_states
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
def merge_simulation_files(paths: Sequence[str | Path]) -> dict:
|
|
551
|
+
"""Load and merge `paths` into a single `{"model": ..., "states": ...}` dict
|
|
552
|
+
where each file's batches are concatenated into the output's batch dimension."""
|
|
553
|
+
if len(paths) < 2:
|
|
554
|
+
raise ValueError("merge_simulation_files requires at least 2 files")
|
|
555
|
+
|
|
556
|
+
resolved_paths = [Path(p) for p in paths]
|
|
557
|
+
labels = [p.name for p in resolved_paths]
|
|
558
|
+
docs = [_load_json(p) for p in resolved_paths]
|
|
559
|
+
for doc, label in zip(docs, labels):
|
|
560
|
+
_validate_doc(doc, label)
|
|
561
|
+
models = [doc["model"] for doc in docs]
|
|
562
|
+
states_list = [doc["states"] for doc in docs]
|
|
563
|
+
batch_sizes = [int(m.get("simBatches", 1)) for m in models]
|
|
564
|
+
|
|
565
|
+
bodies = _merge_bodies(models, labels)
|
|
566
|
+
scalar_names = _merge_scalar_names(models, labels)
|
|
567
|
+
static_objects = _merge_static_objects(models, batch_sizes, labels)
|
|
568
|
+
terrain = _merge_terrain(models, batch_sizes, labels)
|
|
569
|
+
merged_states = _merge_states(
|
|
570
|
+
states_list, batch_sizes, bodies, scalar_names, labels
|
|
571
|
+
)
|
|
572
|
+
|
|
573
|
+
total_batches = sum(batch_sizes)
|
|
574
|
+
offsets = [sum(batch_sizes[:i]) for i in range(len(batch_sizes))]
|
|
575
|
+
ranges = ", ".join(
|
|
576
|
+
f"'{label}' -> batches {offset}-{offset + size - 1}"
|
|
577
|
+
for label, offset, size in zip(labels, offsets, batch_sizes)
|
|
578
|
+
)
|
|
579
|
+
logger.info(
|
|
580
|
+
"Merged %d files into %d batches (%s)", len(paths), total_batches, ranges
|
|
581
|
+
)
|
|
582
|
+
|
|
583
|
+
merged_model = {
|
|
584
|
+
"simBatches": total_batches,
|
|
585
|
+
"batchNames": _default_batch_names(resolved_paths, batch_sizes),
|
|
586
|
+
"scalarNames": scalar_names,
|
|
587
|
+
"dt": models[0].get("dt"),
|
|
588
|
+
"collapse": models[0].get("collapse", False),
|
|
589
|
+
"terrain": terrain,
|
|
590
|
+
"bodies": bodies,
|
|
591
|
+
"staticObjects": static_objects,
|
|
592
|
+
}
|
|
593
|
+
return {"model": merged_model, "states": merged_states}
|