simview 3.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (163) hide show
  1. simview/__init__.py +69 -0
  2. simview/__main__.py +174 -0
  3. simview/launcher.py +109 -0
  4. simview/live.py +204 -0
  5. simview/merge.py +593 -0
  6. simview/model.py +690 -0
  7. simview/py.typed +0 -0
  8. simview/scene.py +603 -0
  9. simview/server.py +739 -0
  10. simview/state.py +190 -0
  11. simview/static/css/controls.css +36 -0
  12. simview/static/js/SimView.js +601 -0
  13. simview/static/js/components/AnimationController.js +654 -0
  14. simview/static/js/components/BatchManager.js +198 -0
  15. simview/static/js/components/InteractionController.js +446 -0
  16. simview/static/js/components/InteractionControls.js +116 -0
  17. simview/static/js/components/Scene.js +166 -0
  18. simview/static/js/components/StateStore.js +188 -0
  19. simview/static/js/config.js +233 -0
  20. simview/static/js/main.js +3 -0
  21. simview/static/js/objects/Body.js +616 -0
  22. simview/static/js/objects/StaticObject.js +181 -0
  23. simview/static/js/objects/Terrain.js +514 -0
  24. simview/static/js/objects/utils.js +378 -0
  25. simview/static/js/ui/AnalysisPanel.js +233 -0
  26. simview/static/js/ui/BatchLegend.js +207 -0
  27. simview/static/js/ui/BodyStateWindow.js +521 -0
  28. simview/static/js/ui/Controls.js +586 -0
  29. simview/static/js/ui/ErrorMetrics.js +664 -0
  30. simview/static/js/ui/Legend.js +87 -0
  31. simview/static/js/ui/PlaybackControls.js +314 -0
  32. simview/static/js/ui/ScalarPlotter.js +575 -0
  33. simview/static/js/utils/blobCodec.js +73 -0
  34. simview/static/js/utils/bodyTransforms.js +151 -0
  35. simview/static/js/utils/csv.js +46 -0
  36. simview/static/js/utils/errorMath.js +69 -0
  37. simview/static/js/utils/injectStyles.js +16 -0
  38. simview/static/js/utils/interpolate.js +65 -0
  39. simview/static/js/utils/liveFollow.js +17 -0
  40. simview/static/js/utils/loadRecordingLibs.js +34 -0
  41. simview/static/js/utils/viewState.js +226 -0
  42. simview/static/lib/chroma-js-3.1.2/index.min.js +8 -0
  43. simview/static/lib/chroma-js-3.1.2/src/Color.js +49 -0
  44. simview/static/lib/chroma-js-3.1.2/src/chroma.js +10 -0
  45. simview/static/lib/chroma-js-3.1.2/src/colors/colorbrewer.js +81 -0
  46. simview/static/lib/chroma-js-3.1.2/src/colors/w3cx11.js +164 -0
  47. simview/static/lib/chroma-js-3.1.2/src/generator/average.js +88 -0
  48. simview/static/lib/chroma-js-3.1.2/src/generator/bezier.js +86 -0
  49. simview/static/lib/chroma-js-3.1.2/src/generator/blend.js +57 -0
  50. simview/static/lib/chroma-js-3.1.2/src/generator/cubehelix.js +87 -0
  51. simview/static/lib/chroma-js-3.1.2/src/generator/mix.js +19 -0
  52. simview/static/lib/chroma-js-3.1.2/src/generator/random.js +12 -0
  53. simview/static/lib/chroma-js-3.1.2/src/generator/scale.js +394 -0
  54. simview/static/lib/chroma-js-3.1.2/src/interpolator/_hsx.js +59 -0
  55. simview/static/lib/chroma-js-3.1.2/src/interpolator/hcg.js +12 -0
  56. simview/static/lib/chroma-js-3.1.2/src/interpolator/hsi.js +12 -0
  57. simview/static/lib/chroma-js-3.1.2/src/interpolator/hsl.js +12 -0
  58. simview/static/lib/chroma-js-3.1.2/src/interpolator/hsv.js +12 -0
  59. simview/static/lib/chroma-js-3.1.2/src/interpolator/index.js +1 -0
  60. simview/static/lib/chroma-js-3.1.2/src/interpolator/lab.js +19 -0
  61. simview/static/lib/chroma-js-3.1.2/src/interpolator/lch.js +13 -0
  62. simview/static/lib/chroma-js-3.1.2/src/interpolator/lrgb.js +19 -0
  63. simview/static/lib/chroma-js-3.1.2/src/interpolator/num.js +15 -0
  64. simview/static/lib/chroma-js-3.1.2/src/interpolator/oklab.js +19 -0
  65. simview/static/lib/chroma-js-3.1.2/src/interpolator/oklch.js +12 -0
  66. simview/static/lib/chroma-js-3.1.2/src/interpolator/rgb.js +18 -0
  67. simview/static/lib/chroma-js-3.1.2/src/io/cmyk/cmyk2rgb.js +16 -0
  68. simview/static/lib/chroma-js-3.1.2/src/io/cmyk/index.js +27 -0
  69. simview/static/lib/chroma-js-3.1.2/src/io/cmyk/rgb2cmyk.js +17 -0
  70. simview/static/lib/chroma-js-3.1.2/src/io/css/css2rgb.js +238 -0
  71. simview/static/lib/chroma-js-3.1.2/src/io/css/hsl2css.js +26 -0
  72. simview/static/lib/chroma-js-3.1.2/src/io/css/index.js +27 -0
  73. simview/static/lib/chroma-js-3.1.2/src/io/css/lab2css.js +24 -0
  74. simview/static/lib/chroma-js-3.1.2/src/io/css/lch2css.js +24 -0
  75. simview/static/lib/chroma-js-3.1.2/src/io/css/oklab2css.js +16 -0
  76. simview/static/lib/chroma-js-3.1.2/src/io/css/oklch2css.js +16 -0
  77. simview/static/lib/chroma-js-3.1.2/src/io/css/rgb2css.js +61 -0
  78. simview/static/lib/chroma-js-3.1.2/src/io/gl/index.js +22 -0
  79. simview/static/lib/chroma-js-3.1.2/src/io/hcg/hcg2rgb.js +55 -0
  80. simview/static/lib/chroma-js-3.1.2/src/io/hcg/index.js +27 -0
  81. simview/static/lib/chroma-js-3.1.2/src/io/hcg/rgb2hcg.js +23 -0
  82. simview/static/lib/chroma-js-3.1.2/src/io/hex/hex2rgb.js +56 -0
  83. simview/static/lib/chroma-js-3.1.2/src/io/hex/index.js +29 -0
  84. simview/static/lib/chroma-js-3.1.2/src/io/hex/rgb2hex.js +29 -0
  85. simview/static/lib/chroma-js-3.1.2/src/io/hsi/hsi2rgb.js +45 -0
  86. simview/static/lib/chroma-js-3.1.2/src/io/hsi/index.js +27 -0
  87. simview/static/lib/chroma-js-3.1.2/src/io/hsi/rgb2hsi.js +31 -0
  88. simview/static/lib/chroma-js-3.1.2/src/io/hsl/hsl2rgb.js +35 -0
  89. simview/static/lib/chroma-js-3.1.2/src/io/hsl/index.js +27 -0
  90. simview/static/lib/chroma-js-3.1.2/src/io/hsl/rgb2hsl.js +45 -0
  91. simview/static/lib/chroma-js-3.1.2/src/io/hsv/hsv2rgb.js +47 -0
  92. simview/static/lib/chroma-js-3.1.2/src/io/hsv/index.js +27 -0
  93. simview/static/lib/chroma-js-3.1.2/src/io/hsv/rgb2hsv.js +32 -0
  94. simview/static/lib/chroma-js-3.1.2/src/io/input.js +4 -0
  95. simview/static/lib/chroma-js-3.1.2/src/io/lab/index.js +28 -0
  96. simview/static/lib/chroma-js-3.1.2/src/io/lab/lab-constants.js +120 -0
  97. simview/static/lib/chroma-js-3.1.2/src/io/lab/lab2rgb.js +101 -0
  98. simview/static/lib/chroma-js-3.1.2/src/io/lab/rgb2lab.js +67 -0
  99. simview/static/lib/chroma-js-3.1.2/src/io/lch/hcl2rgb.js +9 -0
  100. simview/static/lib/chroma-js-3.1.2/src/io/lch/index.js +35 -0
  101. simview/static/lib/chroma-js-3.1.2/src/io/lch/lab2lch.js +12 -0
  102. simview/static/lib/chroma-js-3.1.2/src/io/lch/lch2lab.js +18 -0
  103. simview/static/lib/chroma-js-3.1.2/src/io/lch/lch2rgb.js +13 -0
  104. simview/static/lib/chroma-js-3.1.2/src/io/lch/rgb2lch.js +12 -0
  105. simview/static/lib/chroma-js-3.1.2/src/io/named/index.js +30 -0
  106. simview/static/lib/chroma-js-3.1.2/src/io/num/index.js +32 -0
  107. simview/static/lib/chroma-js-3.1.2/src/io/num/num2rgb.js +13 -0
  108. simview/static/lib/chroma-js-3.1.2/src/io/num/rgb2num.js +8 -0
  109. simview/static/lib/chroma-js-3.1.2/src/io/oklab/index.js +27 -0
  110. simview/static/lib/chroma-js-3.1.2/src/io/oklab/oklab2rgb.js +34 -0
  111. simview/static/lib/chroma-js-3.1.2/src/io/oklab/rgb2oklab.js +37 -0
  112. simview/static/lib/chroma-js-3.1.2/src/io/oklch/index.js +27 -0
  113. simview/static/lib/chroma-js-3.1.2/src/io/oklch/oklch2rgb.js +13 -0
  114. simview/static/lib/chroma-js-3.1.2/src/io/oklch/rgb2oklch.js +12 -0
  115. simview/static/lib/chroma-js-3.1.2/src/io/rgb/index.js +44 -0
  116. simview/static/lib/chroma-js-3.1.2/src/io/temp/index.js +22 -0
  117. simview/static/lib/chroma-js-3.1.2/src/io/temp/rgb2temperature.js +30 -0
  118. simview/static/lib/chroma-js-3.1.2/src/io/temp/temperature2rgb.js +39 -0
  119. simview/static/lib/chroma-js-3.1.2/src/ops/alpha.js +13 -0
  120. simview/static/lib/chroma-js-3.1.2/src/ops/clipped.js +5 -0
  121. simview/static/lib/chroma-js-3.1.2/src/ops/darken.js +17 -0
  122. simview/static/lib/chroma-js-3.1.2/src/ops/get.js +13 -0
  123. simview/static/lib/chroma-js-3.1.2/src/ops/luminance.js +54 -0
  124. simview/static/lib/chroma-js-3.1.2/src/ops/mix.js +10 -0
  125. simview/static/lib/chroma-js-3.1.2/src/ops/premultiply.js +12 -0
  126. simview/static/lib/chroma-js-3.1.2/src/ops/saturate.js +15 -0
  127. simview/static/lib/chroma-js-3.1.2/src/ops/set.js +43 -0
  128. simview/static/lib/chroma-js-3.1.2/src/ops/shade.js +11 -0
  129. simview/static/lib/chroma-js-3.1.2/src/utils/analyze.js +191 -0
  130. simview/static/lib/chroma-js-3.1.2/src/utils/clip_rgb.js +15 -0
  131. simview/static/lib/chroma-js-3.1.2/src/utils/contrast.js +12 -0
  132. simview/static/lib/chroma-js-3.1.2/src/utils/contrastAPCA.js +68 -0
  133. simview/static/lib/chroma-js-3.1.2/src/utils/delta-e.js +62 -0
  134. simview/static/lib/chroma-js-3.1.2/src/utils/distance.js +17 -0
  135. simview/static/lib/chroma-js-3.1.2/src/utils/index.js +27 -0
  136. simview/static/lib/chroma-js-3.1.2/src/utils/last.js +8 -0
  137. simview/static/lib/chroma-js-3.1.2/src/utils/limit.js +5 -0
  138. simview/static/lib/chroma-js-3.1.2/src/utils/multiply-matrices.js +36 -0
  139. simview/static/lib/chroma-js-3.1.2/src/utils/scales.js +15 -0
  140. simview/static/lib/chroma-js-3.1.2/src/utils/type.js +18 -0
  141. simview/static/lib/chroma-js-3.1.2/src/utils/unpack.js +17 -0
  142. simview/static/lib/chroma-js-3.1.2/src/utils/valid.js +11 -0
  143. simview/static/lib/chroma-js-3.1.2/src/version.js +2 -0
  144. simview/static/lib/download.js +132 -0
  145. simview/static/lib/js-colormaps.js +19335 -0
  146. simview/static/lib/tar.js +334 -0
  147. simview/static/lib/three-0.174.0/addons/controls/OrbitControls.js +1556 -0
  148. simview/static/lib/three-0.174.0/addons/libs/lil-gui.module.min.js +8 -0
  149. simview/static/lib/three-0.174.0/three.core.js +48830 -0
  150. simview/static/lib/three-0.174.0/three.module.js +17313 -0
  151. simview/static/lib/uPlot.esm.js +6140 -0
  152. simview/static/lib/uPlot.min.css +2 -0
  153. simview/static/textures/contacts/red-cross0.png +0 -0
  154. simview/static/textures/points/ball0.png +0 -0
  155. simview/static/textures/points/ball1.png +0 -0
  156. simview/templates/index.html +81 -0
  157. simview/utils.py +41 -0
  158. simview-3.2.dist-info/METADATA +552 -0
  159. simview-3.2.dist-info/RECORD +163 -0
  160. simview-3.2.dist-info/WHEEL +5 -0
  161. simview-3.2.dist-info/entry_points.txt +2 -0
  162. simview-3.2.dist-info/licenses/LICENSE +28 -0
  163. simview-3.2.dist-info/top_level.txt +1 -0
simview/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}