simview 6.2.0__tar.gz → 6.2.2__tar.gz

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 (109) hide show
  1. {simview-6.2.0/simview.egg-info → simview-6.2.2}/PKG-INFO +1 -1
  2. {simview-6.2.0 → simview-6.2.2}/pyproject.toml +1 -1
  3. {simview-6.2.0 → simview-6.2.2}/simview/columnar.py +31 -16
  4. {simview-6.2.0 → simview-6.2.2}/simview/merge.py +8 -16
  5. {simview-6.2.0 → simview-6.2.2}/simview/server.py +20 -39
  6. {simview-6.2.0 → simview-6.2.2}/simview/static/css/controls.css +0 -1
  7. {simview-6.2.0 → simview-6.2.2}/simview/static/js/SimView.js +15 -13
  8. {simview-6.2.0 → simview-6.2.2}/simview/static/js/components/AnimationController.js +0 -16
  9. {simview-6.2.0 → simview-6.2.2}/simview/static/js/components/StateStore.js +7 -16
  10. {simview-6.2.0 → simview-6.2.2}/simview/static/js/config.js +0 -4
  11. {simview-6.2.0 → simview-6.2.2}/simview/static/js/objects/Body.js +3 -9
  12. {simview-6.2.0 → simview-6.2.2}/simview/static/js/objects/Terrain.js +142 -56
  13. {simview-6.2.0 → simview-6.2.2}/simview/static/js/objects/colormap.js +22 -2
  14. {simview-6.2.0 → simview-6.2.2}/simview/static/js/objects/utils.js +4 -15
  15. {simview-6.2.0 → simview-6.2.2}/simview/static/js/ui/AnalysisPanel.js +0 -12
  16. {simview-6.2.0 → simview-6.2.2}/simview/static/js/ui/BatchLegend.js +1 -3
  17. {simview-6.2.0 → simview-6.2.2}/simview/static/js/ui/BodyStateWindow.js +21 -40
  18. {simview-6.2.0 → simview-6.2.2}/simview/static/js/ui/Controls.js +38 -94
  19. {simview-6.2.0 → simview-6.2.2}/simview/static/js/ui/ErrorMetrics.js +41 -58
  20. {simview-6.2.0 → simview-6.2.2}/simview/static/js/ui/Legend.js +4 -7
  21. {simview-6.2.0 → simview-6.2.2}/simview/static/js/ui/PlaybackControls.js +7 -7
  22. {simview-6.2.0 → simview-6.2.2}/simview/static/js/ui/ScalarPlotter.js +5 -4
  23. {simview-6.2.0 → simview-6.2.2}/simview/static/js/ui/TerrainProfile.js +6 -6
  24. {simview-6.2.0 → simview-6.2.2}/simview/static/js/ui/chartControls.js +25 -12
  25. {simview-6.2.0 → simview-6.2.2}/simview/static/js/utils/episodes.js +3 -3
  26. simview-6.2.2/simview/static/js/utils/html.js +5 -0
  27. {simview-6.2.0 → simview-6.2.2}/simview/static/js/utils/scalarSeries.js +2 -2
  28. {simview-6.2.0 → simview-6.2.2}/simview/static/js/utils/terrainSample.js +6 -8
  29. {simview-6.2.0 → simview-6.2.2}/simview/static/lib/js-colormaps.js +3 -30
  30. {simview-6.2.0 → simview-6.2.2/simview.egg-info}/PKG-INFO +1 -1
  31. {simview-6.2.0 → simview-6.2.2}/simview.egg-info/SOURCES.txt +1 -0
  32. {simview-6.2.0 → simview-6.2.2}/LICENSE +0 -0
  33. {simview-6.2.0 → simview-6.2.2}/MANIFEST.in +0 -0
  34. {simview-6.2.0 → simview-6.2.2}/README.md +0 -0
  35. {simview-6.2.0 → simview-6.2.2}/setup.cfg +0 -0
  36. {simview-6.2.0 → simview-6.2.2}/simview/__init__.py +0 -0
  37. {simview-6.2.0 → simview-6.2.2}/simview/__main__.py +0 -0
  38. {simview-6.2.0 → simview-6.2.2}/simview/diff.py +0 -0
  39. {simview-6.2.0 → simview-6.2.2}/simview/info.py +0 -0
  40. {simview-6.2.0 → simview-6.2.2}/simview/launcher.py +0 -0
  41. {simview-6.2.0 → simview-6.2.2}/simview/live.py +0 -0
  42. {simview-6.2.0 → simview-6.2.2}/simview/model.py +0 -0
  43. {simview-6.2.0 → simview-6.2.2}/simview/py.typed +0 -0
  44. {simview-6.2.0 → simview-6.2.2}/simview/remote.py +0 -0
  45. {simview-6.2.0 → simview-6.2.2}/simview/render.py +0 -0
  46. {simview-6.2.0 → simview-6.2.2}/simview/scene.py +0 -0
  47. {simview-6.2.0 → simview-6.2.2}/simview/state.py +0 -0
  48. {simview-6.2.0 → simview-6.2.2}/simview/static/js/components/BatchManager.js +0 -0
  49. {simview-6.2.0 → simview-6.2.2}/simview/static/js/components/InteractionController.js +0 -0
  50. {simview-6.2.0 → simview-6.2.2}/simview/static/js/components/Scene.js +0 -0
  51. {simview-6.2.0 → simview-6.2.2}/simview/static/js/components/WindowedField.js +0 -0
  52. {simview-6.2.0 → simview-6.2.2}/simview/static/js/main.js +0 -0
  53. {simview-6.2.0 → simview-6.2.2}/simview/static/js/objects/Polyline.js +0 -0
  54. {simview-6.2.0 → simview-6.2.2}/simview/static/js/objects/StaticObject.js +0 -0
  55. {simview-6.2.0 → simview-6.2.2}/simview/static/js/objects/similarity.js +0 -0
  56. {simview-6.2.0 → simview-6.2.2}/simview/static/js/utils/batchPresets.js +0 -0
  57. {simview-6.2.0 → simview-6.2.2}/simview/static/js/utils/batchVisibility.js +0 -0
  58. {simview-6.2.0 → simview-6.2.2}/simview/static/js/utils/blobCodec.js +0 -0
  59. {simview-6.2.0 → simview-6.2.2}/simview/static/js/utils/blobWindow.js +0 -0
  60. {simview-6.2.0 → simview-6.2.2}/simview/static/js/utils/bodyTransforms.js +0 -0
  61. {simview-6.2.0 → simview-6.2.2}/simview/static/js/utils/cameraRange.js +0 -0
  62. {simview-6.2.0 → simview-6.2.2}/simview/static/js/utils/csv.js +0 -0
  63. {simview-6.2.0 → simview-6.2.2}/simview/static/js/utils/errorMath.js +0 -0
  64. {simview-6.2.0 → simview-6.2.2}/simview/static/js/utils/interpolate.js +0 -0
  65. {simview-6.2.0 → simview-6.2.2}/simview/static/js/utils/keyboard.js +0 -0
  66. {simview-6.2.0 → simview-6.2.2}/simview/static/js/utils/liveFollow.js +0 -0
  67. {simview-6.2.0 → simview-6.2.2}/simview/static/js/utils/uplot.js +0 -0
  68. {simview-6.2.0 → simview-6.2.2}/simview/static/js/utils/viewState.js +0 -0
  69. {simview-6.2.0 → simview-6.2.2}/simview/static/js/utils/viewerDefaults.js +0 -0
  70. {simview-6.2.0 → simview-6.2.2}/simview/static/js/utils/visibleRanges.js +0 -0
  71. {simview-6.2.0 → simview-6.2.2}/simview/static/lib/three-0.174.0/addons/controls/OrbitControls.js +0 -0
  72. {simview-6.2.0 → simview-6.2.2}/simview/static/lib/three-0.174.0/addons/libs/lil-gui.module.min.js +0 -0
  73. {simview-6.2.0 → simview-6.2.2}/simview/static/lib/three-0.174.0/three.core.js +0 -0
  74. {simview-6.2.0 → simview-6.2.2}/simview/static/lib/three-0.174.0/three.module.js +0 -0
  75. {simview-6.2.0 → simview-6.2.2}/simview/static/lib/uPlot.esm.js +0 -0
  76. {simview-6.2.0 → simview-6.2.2}/simview/static/lib/uPlot.min.css +0 -0
  77. {simview-6.2.0 → simview-6.2.2}/simview/static/textures/contacts/red-cross0.png +0 -0
  78. {simview-6.2.0 → simview-6.2.2}/simview/static/textures/points/ball1.png +0 -0
  79. {simview-6.2.0 → simview-6.2.2}/simview/templates/index.html +0 -0
  80. {simview-6.2.0 → simview-6.2.2}/simview/terrain.py +0 -0
  81. {simview-6.2.0 → simview-6.2.2}/simview/utils.py +0 -0
  82. {simview-6.2.0 → simview-6.2.2}/simview/view.py +0 -0
  83. {simview-6.2.0 → simview-6.2.2}/simview.egg-info/dependency_links.txt +0 -0
  84. {simview-6.2.0 → simview-6.2.2}/simview.egg-info/entry_points.txt +0 -0
  85. {simview-6.2.0 → simview-6.2.2}/simview.egg-info/requires.txt +0 -0
  86. {simview-6.2.0 → simview-6.2.2}/simview.egg-info/top_level.txt +0 -0
  87. {simview-6.2.0 → simview-6.2.2}/tests/test_batch_selection.py +0 -0
  88. {simview-6.2.0 → simview-6.2.2}/tests/test_cli.py +0 -0
  89. {simview-6.2.0 → simview-6.2.2}/tests/test_columnar_ondisk.py +0 -0
  90. {simview-6.2.0 → simview-6.2.2}/tests/test_columnar_states.py +0 -0
  91. {simview-6.2.0 → simview-6.2.2}/tests/test_contacts.py +0 -0
  92. {simview-6.2.0 → simview-6.2.2}/tests/test_diff.py +0 -0
  93. {simview-6.2.0 → simview-6.2.2}/tests/test_embed_defaults.py +0 -0
  94. {simview-6.2.0 → simview-6.2.2}/tests/test_episodes.py +0 -0
  95. {simview-6.2.0 → simview-6.2.2}/tests/test_info.py +0 -0
  96. {simview-6.2.0 → simview-6.2.2}/tests/test_launcher.py +0 -0
  97. {simview-6.2.0 → simview-6.2.2}/tests/test_live.py +0 -0
  98. {simview-6.2.0 → simview-6.2.2}/tests/test_merge.py +0 -0
  99. {simview-6.2.0 → simview-6.2.2}/tests/test_pointcloud_features.py +0 -0
  100. {simview-6.2.0 → simview-6.2.2}/tests/test_remote.py +0 -0
  101. {simview-6.2.0 → simview-6.2.2}/tests/test_roundtrip.py +0 -0
  102. {simview-6.2.0 → simview-6.2.2}/tests/test_scene.py +0 -0
  103. {simview-6.2.0 → simview-6.2.2}/tests/test_server.py +0 -0
  104. {simview-6.2.0 → simview-6.2.2}/tests/test_show.py +0 -0
  105. {simview-6.2.0 → simview-6.2.2}/tests/test_terrain.py +0 -0
  106. {simview-6.2.0 → simview-6.2.2}/tests/test_terrain_query.py +0 -0
  107. {simview-6.2.0 → simview-6.2.2}/tests/test_trajectory.py +0 -0
  108. {simview-6.2.0 → simview-6.2.2}/tests/test_utils.py +0 -0
  109. {simview-6.2.0 → simview-6.2.2}/tests/test_view.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: simview
3
- Version: 6.2.0
3
+ Version: 6.2.2
4
4
  Summary: THREE.js-based simulation visualization tool
5
5
  Author-email: Jan Vlk <vlkjan6@fel.cvut.cz>, Ales Kucera <kuceral4@fel.cvut.cz>, David Korcak <david.korcak@gmail.com>
6
6
  License-Expression: BSD-3-Clause
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "simview"
7
- version = "6.2.0"
7
+ version = "6.2.2"
8
8
  readme = "README.md"
9
9
  requires-python = ">=3.12"
10
10
  license = "BSD-3-Clause"
@@ -12,7 +12,7 @@ same document; only how a blob is referenced differs:
12
12
  URL the browser fetches in parallel as a `Float32Array`.
13
13
 
14
14
  That symmetry is the whole point: a columnar file needs no repacking at all
15
- to be served, and `server.py`'s existing `extract_blobs` walker does the
15
+ to be served, and `columnar.rewrite_blobs` (shared by `server.py`) does the
16
16
  rewriting for free.
17
17
 
18
18
  Shape (see README.md "JSON Format Specification" for the authoritative spec)::
@@ -122,6 +122,29 @@ def encode_floats(flat) -> str:
122
122
  return inline_blob(struct.pack(f"<{len(flat)}f", *flat))
123
123
 
124
124
 
125
+ def batch_rows(flat: list[float], per_row: int, batch_size: int) -> list | None:
126
+ """Split a decoded blob into `batch_size` rows of `per_row` floats: either
127
+ one row shared by every batch (replicated) or one row per batch. None if
128
+ `flat` is neither length -- callers raise their own error."""
129
+ if len(flat) == per_row:
130
+ return [flat] * batch_size
131
+ if len(flat) == batch_size * per_row:
132
+ return [flat[i : i + per_row] for i in range(0, len(flat), per_row)]
133
+ return None
134
+
135
+
136
+ def rewrite_blobs(obj: Any, register_blob) -> None:
137
+ """Replace, in place, every inline ``__b64__`` blob string nested anywhere
138
+ in `obj` with `register_blob(raw_bytes)`."""
139
+ if not isinstance(obj, (dict, list)):
140
+ return
141
+ for k, v in obj.items() if isinstance(obj, dict) else enumerate(obj):
142
+ if is_blob(v):
143
+ obj[k] = register_blob(blob_bytes(v))
144
+ else:
145
+ rewrite_blobs(v, register_blob)
146
+
147
+
125
148
  def decode_transform_row(value: Any, batch_size: int, batch_idx: int) -> list[float]:
126
149
  """Decode one state's `bodyTransform` field value for a single batch into
127
150
  a flat 7-element `[x, y, z, w, qx, qy, qz]` row, without numpy.
@@ -134,8 +157,6 @@ def decode_transform_row(value: Any, batch_size: int, batch_idx: int) -> list[fl
134
157
  if len(flat) == batch_size * width:
135
158
  start = batch_idx * width
136
159
  return flat[start : start + width]
137
- if len(flat) == width and batch_size == 1:
138
- return flat
139
160
  raise ValueError(
140
161
  f"bodyTransform has {len(flat)} floats; expected {width} "
141
162
  f"(batch_size=1, flat) or {batch_size * width} "
@@ -451,23 +472,17 @@ def write_static_bundle(data: dict, directory) -> None:
451
472
  (out / "blob").mkdir(parents=True, exist_ok=True)
452
473
  count = 0
453
474
 
454
- def externalize(obj):
475
+ def write_blob(raw: bytes) -> str:
455
476
  nonlocal count
456
- if not isinstance(obj, (dict, list)):
457
- return
458
- for k, v in obj.items() if isinstance(obj, dict) else enumerate(obj):
459
- if is_blob(v):
460
- (out / "blob" / str(count)).write_bytes(blob_bytes(v))
461
- obj[k] = f"/blob/{count}"
462
- count += 1
463
- else:
464
- externalize(v)
465
-
466
- externalize(data["model"])
477
+ (out / "blob" / str(count)).write_bytes(raw)
478
+ count += 1
479
+ return f"/blob/{count - 1}"
480
+
481
+ rewrite_blobs(data["model"], write_blob)
467
482
  # The viewer only resolves /blob/ URLs in the model and a columnar states
468
483
  # object; a legacy per-frame array's inline __b64__ fields are decoded
469
484
  # in place, so they must stay inline.
470
485
  if is_columnar(data["states"]):
471
- externalize(data["states"])
486
+ rewrite_blobs(data["states"], write_blob)
472
487
  for name in ("model", "states"):
473
488
  (out / f"{name}.json").write_text(json.dumps(data[name]))
@@ -27,6 +27,7 @@ import orjson
27
27
 
28
28
  from .columnar import (
29
29
  STATE_FIELD_WIDTHS,
30
+ batch_rows,
30
31
  blob_floats,
31
32
  encode_floats,
32
33
  expand_columnar_states,
@@ -212,12 +213,8 @@ def _decode_per_batch(
212
213
  return value
213
214
  flat = blob_floats(value)
214
215
  per_row = resolution * (vector_width or 1)
215
- if len(flat) == per_row:
216
- # One shared row (deduplicated singleton terrain).
217
- chunks = [list(flat)] * batch_size
218
- elif len(flat) == batch_size * per_row:
219
- chunks = [list(flat[i : i + per_row]) for i in range(0, len(flat), per_row)]
220
- else:
216
+ chunks = batch_rows(flat, per_row, batch_size)
217
+ if chunks is None:
221
218
  raise ValueError(
222
219
  f"terrain blob has {len(flat)} floats; expected {per_row} (one "
223
220
  f"shared row) or {batch_size * per_row} ({batch_size} batches "
@@ -679,15 +676,6 @@ def _nearest_index(sorted_times: list[float], t: float) -> int:
679
676
  return i - 1 if (t - before) <= (after - t) else i
680
677
 
681
678
 
682
- def _state_body_lookup(
683
- states: list[dict], file_idx: int, state_idx: int, cache: dict
684
- ) -> dict[str, dict]:
685
- key = (file_idx, state_idx)
686
- if key not in cache:
687
- cache[key] = bodies_by_name(states[state_idx].get("bodies"))
688
- return cache[key]
689
-
690
-
691
679
  def _merge_states(
692
680
  states_list: list[list[dict]],
693
681
  batch_sizes: list[int],
@@ -731,7 +719,11 @@ def _merge_states(
731
719
  zip(states_list, batch_sizes, selections)
732
720
  ):
733
721
  state_idx = state_idx_by_file[file_idx]
734
- lookup = _state_body_lookup(states, file_idx, state_idx, lookup_cache)
722
+ lookup = lookup_cache.get((file_idx, state_idx))
723
+ if lookup is None:
724
+ lookup = lookup_cache[(file_idx, state_idx)] = bodies_by_name(
725
+ states[state_idx].get("bodies")
726
+ )
735
727
  body_state = lookup.get(name)
736
728
  if body_state is None:
737
729
  raise ValueError(
@@ -18,12 +18,12 @@ import orjson
18
18
  import uvicorn
19
19
  from fastapi import FastAPI, Request, Response, WebSocket, WebSocketDisconnect
20
20
  from fastapi.middleware.cors import CORSMiddleware
21
- from fastapi.responses import FileResponse
21
+ from fastapi.responses import FileResponse, JSONResponse
22
22
  from fastapi.staticfiles import StaticFiles
23
23
  from pydantic import BaseModel
24
24
  from starlette.types import Scope
25
25
 
26
- from simview.columnar import blob_bytes, columnarize_states, is_blob, is_columnar
26
+ from simview.columnar import columnarize_states, is_columnar, rewrite_blobs
27
27
  from simview.utils import dialable_host, find_free_port, read_maybe_gzipped_bytes
28
28
 
29
29
  logger = logging.getLogger("simview.server")
@@ -95,6 +95,10 @@ def _parse_byte_range(header: str | None, size: int) -> tuple[int, int] | None:
95
95
  return start, min(end, size - 1)
96
96
 
97
97
 
98
+ def _not_found(what: str) -> JSONResponse:
99
+ return JSONResponse({"message": f"{what} data not available"}, status_code=404)
100
+
101
+
98
102
  class BatchNamesRequest(BaseModel):
99
103
  names: list[str]
100
104
 
@@ -192,9 +196,7 @@ class SimViewServer:
192
196
  self.app = FastAPI(lifespan=lifespan)
193
197
  # Instance-scoped state (self.model_data, self.model_bytes, ...) lives on this
194
198
  # object rather than in module-level globals, so multiple SimViewServer
195
- # instances (e.g. in tests) never share or clobber each other's data. It is
196
- # also mirrored onto app.state for the FastAPI-idiomatic access pattern.
197
- self.app.state.server = self
199
+ # instances (e.g. in tests) never share or clobber each other's data.
198
200
 
199
201
  # Local viewer only: restrict cross-origin requests to localhost/127.0.0.1.
200
202
  # No allow_credentials: the API doesn't use cookies or auth headers, and
@@ -225,6 +227,9 @@ class SimViewServer:
225
227
 
226
228
  self.setup_routes()
227
229
 
230
+ def _pack_model(self) -> None:
231
+ self.model_bytes = gzip.compress(orjson.dumps(self.model_data), compresslevel=1)
232
+
228
233
  def _names_sidecar_path(self) -> Path | None:
229
234
  """Where custom batch names get persisted, so they survive a server restart.
230
235
 
@@ -273,7 +278,7 @@ class SimViewServer:
273
278
  # In-memory callers (SimulationScene.show, LiveViewer, SimViewLauncher)
274
279
  # hand over dicts that still share structure with the live scene --
275
280
  # SimViewBody.to_json() returns `shape` by reference, for example.
276
- # extract_blobs below rewrites `__b64__` strings in place, which would
281
+ # rewrite_blobs below rewrites `__b64__` strings in place, which would
277
282
  # otherwise corrupt the caller's scene (a later scene.save() would
278
283
  # write dead "/blob/..." URLs, and a second show() would serve stale
279
284
  # blob references). Deep-copying just the model is cheap: the big
@@ -323,16 +328,6 @@ class SimViewServer:
323
328
  self.blobs.append(raw)
324
329
  return f"/blob/{self._blob_token}/{blob_id}"
325
330
 
326
- def extract_blobs(obj):
327
- """Rewrite every inline `__b64__` blob in `obj` into a /blob/ URL."""
328
- if not isinstance(obj, (dict, list)):
329
- return
330
- for k, v in obj.items() if isinstance(obj, dict) else enumerate(obj):
331
- if is_blob(v):
332
- obj[k] = register_blob(blob_bytes(v))
333
- else:
334
- extract_blobs(v)
335
-
336
331
  self.model_data = model_data
337
332
 
338
333
  if model_data is not None:
@@ -343,15 +338,15 @@ class SimViewServer:
343
338
  for k in ("metadata", "batchNames")
344
339
  if k in model_data
345
340
  }
346
- extract_blobs(model_data)
341
+ rewrite_blobs(model_data, register_blob)
347
342
  model_data.update(keep)
348
343
 
349
344
  if is_columnar(states_data):
350
345
  # The file is already columnar (SimulationScene.save's default) --
351
346
  # no repack at all, just rewrite its inline __b64__ blobs into
352
- # /blob/ URLs, which is exactly what extract_blobs does. This is
347
+ # /blob/ URLs, which is exactly what rewrite_blobs does. This is
353
348
  # the cheap path the on-disk format exists for.
354
- extract_blobs(states_data)
349
+ rewrite_blobs(states_data, register_blob)
355
350
  elif isinstance(states_data, list) and states_data:
356
351
  # Legacy per-frame file: repack into whole-trajectory columnar
357
352
  # blobs so the viewer parses one lightweight JSON index plus raw
@@ -371,7 +366,7 @@ class SimViewServer:
371
366
  # itself is kept around (it's small, unlike states_data) so /batch-names can
372
367
  # patch and re-serialize it without re-reading the source file.
373
368
  if model_data is not None:
374
- self.model_bytes = gzip.compress(orjson.dumps(model_data), compresslevel=1)
369
+ self._pack_model()
375
370
  if self.live:
376
371
  # Live mode: frames arrive over /ws/states instead, so /states just
377
372
  # tells the client to open the socket (see loadData in SimView.js).
@@ -401,11 +396,7 @@ class SimViewServer:
401
396
  media_type="application/json",
402
397
  headers=_gzip_headers,
403
398
  )
404
- return Response(
405
- content=b'{"message":"Model data not available"}',
406
- media_type="application/json",
407
- status_code=404,
408
- )
399
+ return _not_found("Model")
409
400
 
410
401
  @self.app.get("/states")
411
402
  async def get_states():
@@ -416,11 +407,7 @@ class SimViewServer:
416
407
  media_type="application/json",
417
408
  headers=_gzip_headers,
418
409
  )
419
- return Response(
420
- content=b'{"message":"States data not available"}',
421
- media_type="application/json",
422
- status_code=404,
423
- )
410
+ return _not_found("States")
424
411
 
425
412
  @self.app.get("/blob/{token}/{blob_id}")
426
413
  async def get_blob(token: str, blob_id: int, request: Request):
@@ -462,11 +449,7 @@ class SimViewServer:
462
449
  @self.app.post("/batch-names")
463
450
  async def set_batch_names(body: BatchNamesRequest):
464
451
  if self.model_data is None:
465
- return Response(
466
- content=b'{"message":"Model data not available"}',
467
- media_type="application/json",
468
- status_code=404,
469
- )
452
+ return _not_found("Model")
470
453
  names = body.names
471
454
  sim_batches = int(self.model_data.get("simBatches", 1))
472
455
  if len(names) != sim_batches:
@@ -477,9 +460,7 @@ class SimViewServer:
477
460
  )
478
461
 
479
462
  self.model_data["batchNames"] = names
480
- self.model_bytes = gzip.compress(
481
- orjson.dumps(self.model_data), compresslevel=1
482
- )
463
+ self._pack_model()
483
464
 
484
465
  names_path = self._names_sidecar_path()
485
466
  persisted = False
@@ -563,7 +544,7 @@ class SimViewServer:
563
544
  if self.model_data is None:
564
545
  return
565
546
  self.model_data["episodes"] = episodes
566
- self.model_bytes = gzip.compress(orjson.dumps(self.model_data), compresslevel=1)
547
+ self._pack_model()
567
548
 
568
549
  async def broadcast_episodes(self, episodes: list[dict]) -> None:
569
550
  """Push updated episode boundaries to every connected /ws/states client.
@@ -12,7 +12,6 @@
12
12
  --ctp-surface0: #313244;
13
13
  --ctp-surface1: #45475a;
14
14
  --ctp-surface2: #585b70;
15
- --ctp-overlay0: #6c7086;
16
15
  --ctp-subtext0: #a6adc8;
17
16
  --ctp-text: #cdd6f4;
18
17
  --ctp-blue: #89b4fa;
@@ -19,8 +19,9 @@ import { InteractionController } from "./components/InteractionController.js";
19
19
  import { buildBodyMeta, resolveStateBodies, topoSortBodies } from "./utils/bodyTransforms.js";
20
20
  import { hasBodyTrajectory } from "./utils/terrainSample.js";
21
21
  import { decodeStatesChunk, STATE_FIELD_WIDTHS } from "./utils/blobCodec.js";
22
- import { StateStore } from "./components/StateStore.js";
22
+ import { ColumnarStateStore, LegacyStateStore } from "./components/StateStore.js";
23
23
  import { WindowedField } from "./components/WindowedField.js";
24
+ import { escapeHtml } from "./utils/html.js";
24
25
  import { bytesPerFrame, shouldWindowField } from "./utils/blobWindow.js";
25
26
  import { shouldFollowLive } from "./utils/liveFollow.js";
26
27
  import { shiftEpisodes } from "./utils/episodes.js";
@@ -144,7 +145,7 @@ export class SimView {
144
145
  // Live streaming mode (see simview.live.LiveViewer): no states
145
146
  // yet, they arrive incrementally over /ws/states instead.
146
147
  console.debug("Live mode: opening /ws/states");
147
- this.store = StateStore.fromLegacy([]);
148
+ this.store = new LegacyStateStore([]);
148
149
  this.startLiveStream(splash);
149
150
  } else if (statesPayload && statesPayload.version === 4) {
150
151
  // Columnar wire shape (see server.py::_columnarize_states):
@@ -158,7 +159,7 @@ export class SimView {
158
159
  console.time("fetch_state_blobs");
159
160
  await this.fetchBlobs(statesPayload);
160
161
  console.timeEnd("fetch_state_blobs");
161
- this.store = StateStore.fromColumnar(statesPayload, this.batchManager.simBatches);
162
+ this.store = new ColumnarStateStore(statesPayload, this.batchManager.simBatches);
162
163
  this.onStoreReady();
163
164
  } else {
164
165
  throw new Error("Unrecognized /states payload shape");
@@ -172,7 +173,7 @@ export class SimView {
172
173
  } catch (error) {
173
174
  console.error("Critical error during initial data fetch:", error);
174
175
  if (splash) {
175
- splash.innerHTML = `<h1 class="sv-splash-error">Couldn't load the simulation</h1><p>${error.message}</p><p>See the browser console for details.</p>`;
176
+ splash.innerHTML = `<h1 class="sv-splash-error">Couldn't load the simulation</h1><p>${escapeHtml(error.message)}</p><p>See the browser console for details.</p>`;
176
177
  }
177
178
  throw error;
178
179
  }
@@ -354,7 +355,7 @@ export class SimView {
354
355
  decodeStatesChunk(chunk);
355
356
  const firstLoad = !this.animationController || !this.animationController.store;
356
357
  if (!this.store) {
357
- this.store = StateStore.fromLegacy(chunk);
358
+ this.store = new LegacyStateStore(chunk);
358
359
  } else if (firstLoad) {
359
360
  this.store.append(chunk);
360
361
  } else {
@@ -417,7 +418,7 @@ export class SimView {
417
418
  this.analysisPanel = new AnalysisPanel(this);
418
419
  }
419
420
  this.terrainProfile = new TerrainProfile(this);
420
- this.analysisPanel.attachTerrainProfile(this.terrainProfile);
421
+ this.analysisPanel.attach("terrain", this.terrainProfile, this.terrainProfile.content);
421
422
  }
422
423
 
423
424
  // Resolves and records position/orientation (and contacts, vectors, ...)
@@ -540,11 +541,12 @@ export class SimView {
540
541
  model.scalarNames
541
542
  );
542
543
  this.scalarPlotter = new ScalarPlotter(this, model.scalarNames);
543
- this.analysisPanel.attachScalarPlotter(this.scalarPlotter);
544
+ const { tabBar, plotArea } = this.scalarPlotter;
545
+ this.analysisPanel.attach("scalars", this.scalarPlotter, tabBar, plotArea);
544
546
  }
545
547
  if (hasErrorMetrics) {
546
548
  this.errorMetrics = new ErrorMetrics(this);
547
- this.analysisPanel.attachErrorMetrics(this.errorMetrics);
549
+ this.analysisPanel.attach("errorMetrics", this.errorMetrics, this.errorMetrics.content);
548
550
  }
549
551
  this.interactionController = new InteractionController(this);
550
552
  this.uiControls = new UIControls(this);
@@ -559,7 +561,7 @@ export class SimView {
559
561
  console.error("Error during initFromModel:", error);
560
562
  const splash = document.getElementById("loading-splash");
561
563
  if (splash) {
562
- splash.innerHTML = `<h1 class="sv-splash-error">Error during initialization</h1><p>${error.message}</p>`;
564
+ splash.innerHTML = `<h1 class="sv-splash-error">Error during initialization</h1><p>${escapeHtml(error.message)}</p>`;
563
565
  }
564
566
  throw error;
565
567
  }
@@ -584,7 +586,7 @@ export class SimView {
584
586
  console.error("Initialization failed:", error);
585
587
  const splash = document.getElementById("loading-splash");
586
588
  if (splash) {
587
- splash.innerHTML = `<h1 class="sv-splash-error">Failed to connect or initialize</h1><p>${error.message}</p>`;
589
+ splash.innerHTML = `<h1 class="sv-splash-error">Failed to connect or initialize</h1><p>${escapeHtml(error.message)}</p>`;
588
590
  }
589
591
  }
590
592
  }
@@ -662,7 +664,7 @@ export class SimView {
662
664
  getViewState() {
663
665
  const { camera, controls } = this.scene;
664
666
  return {
665
- time: this.animationController ? this.animationController.getCurrentTime() : undefined,
667
+ time: this.animationController ? this.animationController.currentTime : undefined,
666
668
  camera: {
667
669
  position: camera.position.clone(),
668
670
  target: controls.target.clone(),
@@ -770,11 +772,11 @@ export class SimView {
770
772
  // 1. Update states and time
771
773
  if (this.animationController) {
772
774
  this.animationController.animate(now);
773
- const index = this.animationController.getCurrentStateIndex();
775
+ const index = this.animationController.currentStateIndex;
774
776
  if (index !== this._lastDispatchedFrame && this.animationController.store) {
775
777
  this._lastDispatchedFrame = index;
776
778
  window.dispatchEvent(
777
- new CustomEvent("simview:frame", { detail: { index, time: this.animationController.getCurrentTime() } })
779
+ new CustomEvent("simview:frame", { detail: { index, time: this.animationController.currentTime } })
778
780
  );
779
781
  }
780
782
  }
@@ -309,22 +309,6 @@ export class AnimationController {
309
309
  return true;
310
310
  }
311
311
 
312
- getTotalTime() {
313
- return this.totalTime;
314
- }
315
-
316
- getCurrentTime() {
317
- return this.currentTime;
318
- }
319
-
320
- getCurrentStateIndex() {
321
- return this.currentStateIndex;
322
- }
323
-
324
- getFirstTime() {
325
- return this.firstTime;
326
- }
327
-
328
312
  // A windowed field (see WindowedField.js) just landed. While playing the
329
313
  // next tick picks it up anyway; paused, nothing would re-render the frame
330
314
  // the user scrubbed to, so redo it now (dropping the interpolation memo,
@@ -76,11 +76,11 @@ function validateColumnar(times, bodies, scalars, simBatches) {
76
76
  }
77
77
  }
78
78
 
79
- class ColumnarStateStore {
79
+ export class ColumnarStateStore {
80
80
  // `bodies`: [{name, fields: {fieldName: Float32Array}, contacts?}], each
81
81
  // fields Float32Array already reshaped to flat (T * B * k) row-major.
82
82
  // `scalars`: {scalarName: Float32Array}, each flat (T * B) row-major.
83
- constructor(times, bodies, scalars, simBatches) {
83
+ constructor({ times, bodies, scalars }, simBatches) {
84
84
  validateColumnar(times, bodies, scalars, simBatches);
85
85
  this.times = times;
86
86
  this._bodies = bodies;
@@ -168,7 +168,8 @@ class ColumnarStateStore {
168
168
  return state;
169
169
  }
170
170
 
171
- // Per-batch series for the scalar plotter: [{x: time, y: value}, ...] per batch.
171
+ // Per-batch series for the scalar plotter: one per-frame value array per
172
+ // batch, indexed like `times`.
172
173
  // `simBatches` is accepted (and ignored) for API parity with
173
174
  // LegacyStateStore.getScalarSeries -- the columnar store already knows
174
175
  // its own batch count.
@@ -179,14 +180,14 @@ class ColumnarStateStore {
179
180
  for (let i = 0; i < this.length; i++) {
180
181
  const base = i * B;
181
182
  for (let b = 0; b < B; b++) {
182
- series[b][i] = { x: this.times[i], y: flat[base + b] };
183
+ series[b][i] = flat[base + b];
183
184
  }
184
185
  }
185
186
  return series;
186
187
  }
187
188
  }
188
189
 
189
- class LegacyStateStore {
190
+ export class LegacyStateStore {
190
191
  // `statesArray`: the classic array of per-frame state objects (already
191
192
  // decoded -- __b64__ fields expanded via decodeStatesChunk).
192
193
  constructor(statesArray) {
@@ -234,7 +235,7 @@ class LegacyStateStore {
234
235
  if (values[b] === undefined) {
235
236
  throw new Error(`Scalar "${name}" not found in state at index ${b}.`);
236
237
  }
237
- series[b].push({ x: state.time, y: values[b] });
238
+ series[b].push(values[b]);
238
239
  }
239
240
  }
240
241
  return series;
@@ -250,13 +251,3 @@ class LegacyStateStore {
250
251
  return startIndex;
251
252
  }
252
253
  }
253
-
254
- export class StateStore {
255
- static fromColumnar({ times, bodies, scalars }, simBatches) {
256
- return new ColumnarStateStore(times, bodies, scalars, simBatches);
257
- }
258
-
259
- static fromLegacy(statesArray) {
260
- return new LegacyStateStore(statesArray);
261
- }
262
- }
@@ -155,22 +155,18 @@ export const BODY_VECTOR_CONFIG = {
155
155
  // Linear velocity
156
156
  velocity: {
157
157
  color: THEME.green,
158
- scale: 1.0,
159
158
  },
160
159
  // Angular velocity
161
160
  angularVelocity: {
162
161
  color: THEME.yellow,
163
- scale: 1.0,
164
162
  },
165
163
  // Force
166
164
  force: {
167
165
  color: THEME.peach,
168
- scale: 1.0,
169
166
  },
170
167
  // Torque
171
168
  torque: {
172
169
  color: THEME.red,
173
- scale: 1.0,
174
170
  },
175
171
  };
176
172
 
@@ -193,7 +193,7 @@ export class Body {
193
193
  }
194
194
  this._trailStateIndex = -1;
195
195
  const currentIndex = this.app.animationController
196
- ? this.app.animationController.getCurrentStateIndex()
196
+ ? this.app.animationController.currentStateIndex
197
197
  : 0;
198
198
  this.updateTrails(currentIndex);
199
199
  }
@@ -679,7 +679,7 @@ export class Body {
679
679
  this.updateAllInstances();
680
680
 
681
681
  if (this.trails.length > 0 && this.app.animationController) {
682
- this.updateTrails(this.app.animationController.getCurrentStateIndex());
682
+ this.updateTrails(this.app.animationController.currentStateIndex);
683
683
  }
684
684
  }
685
685
 
@@ -696,7 +696,6 @@ export class Body {
696
696
  config
697
697
  );
698
698
  vector.visible = this.app.uiState.attributeVisible[attr] || false;
699
- vector.userData = { scale: config.scale };
700
699
  batchGroup.add(vector);
701
700
  this.bodyVectors[batchIndex].set(attr, vector);
702
701
  }
@@ -706,8 +705,7 @@ export class Body {
706
705
  updateBodyVector(type, vector, batchIndex) {
707
706
  const arrow = this.bodyVectors[batchIndex]?.get(type);
708
707
  if (!arrow) return;
709
- const scale = arrow.userData.scale || 1.0;
710
- const length = vector.length() * scale;
708
+ const length = vector.length();
711
709
  // Written as !(>=) so a NaN vector is treated like a zero one instead
712
710
  // of feeding NaN into setDirection/setLength.
713
711
  if (!(length >= 1e-6)) {
@@ -850,10 +848,6 @@ export class Body {
850
848
  return this.group;
851
849
  }
852
850
 
853
- getAvailableAttributes() {
854
- return this.availableAttributes;
855
- }
856
-
857
851
  // Which of the mesh/wireframe/points representations this body actually has,
858
852
  // so the UI can only offer visualization modes that will show something.
859
853
  // A point-cloud body reports none: its cloud is the object itself, not a