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/server.py ADDED
@@ -0,0 +1,739 @@
1
+ import asyncio
2
+ import base64
3
+ import gzip
4
+ import hashlib
5
+ import json
6
+ import logging
7
+ import secrets
8
+ import time
9
+ from collections.abc import Sequence
10
+ from contextlib import asynccontextmanager
11
+ from pathlib import Path
12
+
13
+ try:
14
+ import orjson
15
+ except ImportError:
16
+ orjson = None
17
+
18
+ try:
19
+ import numpy as np
20
+ except ImportError:
21
+ np = None
22
+
23
+ from importlib.resources import files
24
+
25
+ import uvicorn
26
+ from fastapi import FastAPI, Request, Response, WebSocket, WebSocketDisconnect
27
+ from fastapi.middleware.cors import CORSMiddleware
28
+ from fastapi.staticfiles import StaticFiles
29
+ from fastapi.templating import Jinja2Templates
30
+ from pydantic import BaseModel
31
+ from starlette.types import Scope
32
+
33
+ from simview.utils import find_free_port, read_maybe_gzipped_bytes
34
+
35
+ logger = logging.getLogger("simview.server")
36
+
37
+ TEMPLATES = str(files("simview").joinpath("templates"))
38
+ STATIC = str(files("simview").joinpath("static"))
39
+
40
+ # Local-only viewer: CORS is restricted to localhost/127.0.0.1 on any port so a
41
+ # browser tab open on another local dev server can't be silently allowed, while
42
+ # still letting the bundled UI (served from the same host) talk to the API.
43
+ _ALLOWED_ORIGIN_REGEX = r"^https?://(localhost|127\.0\.0\.1)(:\d+)?$"
44
+
45
+ # Subdirectories of simview/static that hold vendored, version-pinned third-party
46
+ # libraries. These never change for a given release, so they get a long-lived,
47
+ # immutable cache header. Everything else under /static (our own JS/CSS/textures)
48
+ # is cache-busted via the ?v= query param in index.html instead, so it only needs
49
+ # a short revalidation window.
50
+ _IMMUTABLE_STATIC_DIRS = ("lib/",)
51
+
52
+ # Per-body numeric state fields eligible for columnar (whole-trajectory) binary
53
+ # packing, with their trailing per-batch-row width. Same fields/widths
54
+ # SimViewBodyState/add_trajectory may binary-encode per frame (state.py,
55
+ # blobCodec.js) -- the columnar repack below just packs a whole (T, B, k) run
56
+ # instead of one (B, k) blob per frame.
57
+ _STATE_FIELD_WIDTHS = {
58
+ "bodyTransform": 7,
59
+ "velocity": 3,
60
+ "angularVelocity": 3,
61
+ "force": 3,
62
+ "torque": 3,
63
+ }
64
+
65
+
66
+ class _StatesShapeMismatch(Exception):
67
+ """Raised internally by _columnarize_states to bail out to the legacy
68
+ array response -- caught in one place rather than threading a bunch of
69
+ `if inconsistent: return None` checks through the nested loops below."""
70
+
71
+
72
+ def _decode_state_field_rows(value, width: int, batch_size: int):
73
+ """Decode one state's per-body field value (either a `__b64__` blob or a
74
+ plain nested/flat JSON list) into a (batch_size, width) float32 array.
75
+
76
+ Mirrors the shapes SimViewBodyState.to_json()/add_trajectory produce: a
77
+ `__b64__` blob is always batch_size rows of `width` floats; a plain list is
78
+ either already nested (one row per batch) or, for a single-batch scene, a
79
+ flat list of `width` floats (see README "Authoring whole trajectories").
80
+ """
81
+ assert np is not None
82
+ if isinstance(value, str):
83
+ if not value.startswith("__b64__"):
84
+ raise _StatesShapeMismatch(f"unexpected string value for field: {value!r}")
85
+ flat = np.frombuffer(base64.b64decode(value[7:]), dtype="<f4")
86
+ else:
87
+ arr = np.asarray(value, dtype="<f4")
88
+ if arr.ndim == 1:
89
+ if batch_size != 1:
90
+ raise _StatesShapeMismatch(
91
+ "flat (non-nested) field value with batch size != 1"
92
+ )
93
+ arr = arr[None, :]
94
+ flat = arr.reshape(-1)
95
+ if flat.size != batch_size * width:
96
+ raise _StatesShapeMismatch(
97
+ f"field has {flat.size} floats; expected {batch_size * width} "
98
+ f"({batch_size} batches x {width})"
99
+ )
100
+ return flat.reshape(batch_size, width)
101
+
102
+
103
+ def _body_key(name):
104
+ """Hashable key for a body's `name` (a string, or a list of grouped names
105
+ for bodies moving rigidly together -- see BodyTrajectory/SimViewBodyState).
106
+ The original `name` value (str or list) is what actually gets emitted in
107
+ the columnar payload; this is only used to identify "the same body slot"
108
+ across frames."""
109
+ return tuple(name) if isinstance(name, list) else name
110
+
111
+
112
+ def _columnarize_states(states_data: list, model_data: dict | None, register_blob):
113
+ """Repack the legacy per-frame `states` array into the columnar v4 payload
114
+ described in README.md, or return None if `states_data` doesn't meet the
115
+ strict consistency requirements (in which case the caller must fall back
116
+ to serving `states_data` exactly as today).
117
+
118
+ `register_blob(bytes) -> url` registers one whole-trajectory float32 blob
119
+ (e.g. in self.blobs) and returns its `/blob/{token}/{id}` URL.
120
+
121
+ Strict by design: this trades a bit of coverage (an inconsistent scene
122
+ just doesn't get the perf win) for never risking a subtly wrong repack
123
+ reaching the viewer. The one deliberate exception is `contacts`, which may
124
+ legitimately come and go per frame (see README) without disqualifying the
125
+ rest of the scene from columnar packing.
126
+ """
127
+ if np is None or not states_data:
128
+ return None
129
+ if model_data is None:
130
+ return None
131
+
132
+ batch_size = int(model_data.get("simBatches", 1))
133
+
134
+ try:
135
+ times = []
136
+ # Per body: ordered list of field names (first frame's order/set is
137
+ # the contract every other frame must match), plus the accumulated
138
+ # (T, B, k) rows for each field, and the original name value to emit.
139
+ body_order: list = []
140
+ body_fields: dict[object, list[str]] = {}
141
+ body_name_value: dict[object, object] = {}
142
+ body_rows: dict[object, dict[str, list]] = {}
143
+ body_contacts: dict[object, list] = {}
144
+ any_contacts: set = set()
145
+
146
+ for state_idx, state in enumerate(states_data):
147
+ if "time" not in state:
148
+ raise _StatesShapeMismatch(f"state {state_idx} is missing 'time'")
149
+ times.append(state["time"])
150
+
151
+ bodies = state.get("bodies") or []
152
+ seen_keys = set()
153
+ for body in bodies:
154
+ if not isinstance(body, dict) or "name" not in body:
155
+ raise _StatesShapeMismatch(
156
+ f"state {state_idx} has a body entry missing 'name'"
157
+ )
158
+ name = body["name"]
159
+ if not isinstance(name, (str, list)):
160
+ raise _StatesShapeMismatch(
161
+ f"state {state_idx} has a non-string/list body name"
162
+ )
163
+ key = _body_key(name)
164
+ if key in seen_keys:
165
+ raise _StatesShapeMismatch(
166
+ f"state {state_idx} lists body '{name}' more than once"
167
+ )
168
+ seen_keys.add(key)
169
+
170
+ fields = sorted(k for k in body if k in _STATE_FIELD_WIDTHS)
171
+ if key not in body_fields:
172
+ if state_idx != 0 and body_rows.get(key) is None:
173
+ # A body appearing for the first time after frame 0
174
+ # would leave earlier frames' rows undefined -- bail
175
+ # rather than guess a fill value.
176
+ raise _StatesShapeMismatch(
177
+ f"body '{name}' first appears at state {state_idx}, "
178
+ "not state 0"
179
+ )
180
+ body_order.append(key)
181
+ body_fields[key] = fields
182
+ body_name_value[key] = name
183
+ body_rows[key] = {f: [] for f in fields}
184
+ elif body_fields[key] != fields:
185
+ raise _StatesShapeMismatch(
186
+ f"body '{name}' has inconsistent field set across frames"
187
+ )
188
+
189
+ for field in fields:
190
+ width = _STATE_FIELD_WIDTHS[field]
191
+ rows = _decode_state_field_rows(body[field], width, batch_size)
192
+ body_rows[key][field].append(rows)
193
+
194
+ if "contacts" in body:
195
+ any_contacts.add(key)
196
+ body_contacts.setdefault(key, [None] * state_idx).append(
197
+ body["contacts"]
198
+ )
199
+ elif key in any_contacts:
200
+ body_contacts[key].append(None)
201
+
202
+ missing = set(body_order) - seen_keys
203
+ if missing:
204
+ raise _StatesShapeMismatch(
205
+ f"state {state_idx} is missing bodies present in earlier "
206
+ f"frames: {sorted(str(m) for m in missing)}"
207
+ )
208
+ # A body with contacts not yet seen this frame (declared later than
209
+ # its own first appearance) still needs a None placeholder so its
210
+ # contacts list stays length == number of frames seen so far.
211
+ for key in any_contacts:
212
+ lst = body_contacts[key]
213
+ if len(lst) < state_idx + 1:
214
+ lst.append(None)
215
+
216
+ scalar_names = model_data.get("scalarNames") or []
217
+ for name in scalar_names:
218
+ if name not in state:
219
+ raise _StatesShapeMismatch(
220
+ f"state {state_idx} is missing scalar '{name}'"
221
+ )
222
+
223
+ T = len(states_data)
224
+
225
+ bodies_payload = []
226
+ for key in body_order:
227
+ fields_payload = {}
228
+ for field, per_frame_rows in body_rows[key].items():
229
+ if len(per_frame_rows) != T:
230
+ raise _StatesShapeMismatch(
231
+ f"body '{body_name_value[key]}' field '{field}' is "
232
+ "missing from some frames"
233
+ )
234
+ stacked = np.ascontiguousarray(
235
+ np.stack(per_frame_rows, axis=0), dtype="<f4"
236
+ ) # (T, B, k)
237
+ fields_payload[field] = register_blob(stacked.tobytes())
238
+ entry = {"name": body_name_value[key], "fields": fields_payload}
239
+ if key in any_contacts:
240
+ entry["contacts"] = body_contacts[key]
241
+ bodies_payload.append(entry)
242
+
243
+ scalars_payload = {}
244
+ for name in model_data.get("scalarNames") or []:
245
+ per_frame = []
246
+ for state in states_data:
247
+ row = np.asarray(state[name], dtype="<f4")
248
+ if row.ndim == 0:
249
+ row = row.reshape(1)
250
+ if row.shape != (batch_size,):
251
+ raise _StatesShapeMismatch(
252
+ f"scalar '{name}' has shape {row.shape}; expected "
253
+ f"({batch_size},)"
254
+ )
255
+ per_frame.append(row)
256
+ stacked = np.ascontiguousarray(np.stack(per_frame, axis=0), dtype="<f4")
257
+ scalars_payload[name] = register_blob(stacked.tobytes())
258
+
259
+ return {
260
+ "version": 4,
261
+ "times": times,
262
+ "bodies": bodies_payload,
263
+ "scalars": scalars_payload,
264
+ }
265
+ except _StatesShapeMismatch as e:
266
+ logger.warning(
267
+ "States data is not columnar-repackable, falling back to the "
268
+ "legacy per-frame array response: %s",
269
+ e,
270
+ )
271
+ return None
272
+
273
+
274
+ class BatchNamesRequest(BaseModel):
275
+ names: list[str]
276
+
277
+
278
+ class CacheControlStaticFiles(StaticFiles):
279
+ """StaticFiles that adds a Cache-Control header based on the asset's path."""
280
+
281
+ def file_response(
282
+ self, full_path, stat_result, scope: Scope, status_code: int = 200
283
+ ):
284
+ response = super().file_response(full_path, stat_result, scope, status_code)
285
+ # full_path is the absolute filesystem path of the matched file; check it
286
+ # (rather than scope["path"]) since the latter is mount-relative and its
287
+ # exact shape depends on how the StaticFiles app was mounted.
288
+ # self.directory is set from the `directory=` kwarg we always pass to
289
+ # StaticFiles.__init__ (see the mount() call below), so it's never None
290
+ # here even though the base class types it as Optional for callers that
291
+ # use `packages=` instead.
292
+ assert self.directory is not None
293
+ rel_path = Path(full_path).relative_to(self.directory).as_posix()
294
+ if rel_path.startswith(_IMMUTABLE_STATIC_DIRS):
295
+ response.headers["Cache-Control"] = "public, max-age=31536000, immutable"
296
+ else:
297
+ response.headers["Cache-Control"] = "public, max-age=60"
298
+ return response
299
+
300
+
301
+ class SimViewServer:
302
+ def __init__(
303
+ self,
304
+ sim_path: str | Path | Sequence[str | Path] | None = None,
305
+ data: dict | None = None,
306
+ live: bool = False,
307
+ ):
308
+ if sim_path is None and data is None:
309
+ raise ValueError("Provide 'sim_path' and/or 'data'")
310
+ # Live streaming mode (see simview.live.LiveViewer): /states reports
311
+ # {"live": true} instead of serving a (possibly empty) states array,
312
+ # and a /ws/states endpoint is registered to push frames as they're
313
+ # produced. self.ws_clients is only ever mutated on self.loop (set by
314
+ # LiveViewer once the server thread's event loop is running) so
315
+ # push_state's broadcast, running on the caller's thread, never races
316
+ # a client connecting/disconnecting on the server thread.
317
+ self.live = live
318
+ self.loop = None
319
+ self.ws_clients: set[WebSocket] = set()
320
+ # All frames pushed so far (live mode only), replayed as the catch-up
321
+ # message to a client connecting after the run started. Mirrors
322
+ # scene.states, which LiveViewer.push_state also appends to via
323
+ # scene.add_state -- kept as a separate list here rather than reaching
324
+ # into the scene so SimViewServer doesn't need a reference to it.
325
+ self.frame_buffer: list[dict] = []
326
+ if sim_path is None:
327
+ self.sim_paths: list[Path] | None = None
328
+ elif isinstance(sim_path, (str, Path)):
329
+ self.sim_paths = [Path(sim_path)]
330
+ else:
331
+ self.sim_paths = [Path(p) for p in sim_path]
332
+ # Single-file convenience accessor, used by _load_data when nothing is preloaded.
333
+ self.sim_path = self.sim_paths[0] if self.sim_paths else None
334
+ self._preloaded_data = data
335
+ self.model_data = None
336
+
337
+ @asynccontextmanager
338
+ async def lifespan(app: FastAPI):
339
+ # Captured here (rather than e.g. in run()) because this runs on
340
+ # the server thread's event loop once uvicorn starts serving --
341
+ # LiveViewer needs this exact loop object to bridge push_state
342
+ # (caller's thread) into broadcast_frame via
343
+ # asyncio.run_coroutine_threadsafe.
344
+ self.loop = asyncio.get_running_loop()
345
+ yield
346
+
347
+ self.app = FastAPI(lifespan=lifespan)
348
+ # Instance-scoped state (self.model_data, self.model_bytes, ...) lives on this
349
+ # object rather than in module-level globals, so multiple SimViewServer
350
+ # instances (e.g. in tests) never share or clobber each other's data. It is
351
+ # also mirrored onto app.state for the FastAPI-idiomatic access pattern.
352
+ self.app.state.server = self
353
+
354
+ # Local viewer only: restrict cross-origin requests to localhost/127.0.0.1.
355
+ self.app.add_middleware(
356
+ CORSMiddleware,
357
+ allow_origin_regex=_ALLOWED_ORIGIN_REGEX,
358
+ allow_credentials=True,
359
+ allow_methods=["GET", "POST"],
360
+ allow_headers=["*"],
361
+ )
362
+
363
+ # Mount static files and setup templates. StaticFiles adds ETag/Last-Modified
364
+ # headers so unchanged assets (vendored libs, textures) are served from cache;
365
+ # our own JS is cache-busted via the ?v= query param in index.html. The
366
+ # Cache-Control subclass additionally marks vendored libs as immutable.
367
+ self.app.mount(
368
+ "/static", CacheControlStaticFiles(directory=STATIC), name="static"
369
+ )
370
+ self.templates = Jinja2Templates(directory=TEMPLATES)
371
+
372
+ # Pre-serialized, gzipped payloads for HTTP serving. The parsed dicts are
373
+ # discarded after compression to avoid holding the simulation twice in memory.
374
+ self.model_bytes = None
375
+ self.states_bytes = None
376
+ self._load_data()
377
+
378
+ self.setup_routes()
379
+
380
+ def _names_sidecar_path(self) -> Path | None:
381
+ """Where custom batch names get persisted, so they survive a server restart.
382
+
383
+ Keyed by a hash of all input paths (not just the first) so that merging the
384
+ same file with different partners doesn't collide on one sidecar."""
385
+ if not self.sim_paths:
386
+ return None
387
+ key = hashlib.sha1(
388
+ "|".join(str(p.resolve()) for p in self.sim_paths).encode()
389
+ ).hexdigest()[:10]
390
+ return (
391
+ self.sim_paths[0].parent
392
+ / f".{self.sim_paths[0].stem}.{key}.batchnames.json"
393
+ )
394
+
395
+ def _source_fingerprint(self) -> dict[str, float] | None:
396
+ """mtime of every source file, keyed by resolved path.
397
+
398
+ Saved alongside custom batch names so a later load can tell whether the
399
+ source file(s) were regenerated since the names were saved - if so, the
400
+ names no longer necessarily describe the current batches and must not be
401
+ applied."""
402
+ if not self.sim_paths:
403
+ return None
404
+ return {str(p.resolve()): p.stat().st_mtime for p in self.sim_paths}
405
+
406
+ def _load_data(self):
407
+ if self._preloaded_data is not None:
408
+ data = self._preloaded_data
409
+ self._preloaded_data = None # allow it to be garbage-collected
410
+ else:
411
+ # __init__ requires sim_path and/or data; if we get here,
412
+ # _preloaded_data was None, so sim_path (hence self.sim_path) was
413
+ # provided and is guaranteed non-None.
414
+ assert self.sim_path is not None
415
+ logger.info("Loading simulation data from %s...", self.sim_path)
416
+ raw = read_maybe_gzipped_bytes(self.sim_path)
417
+ data = orjson.loads(raw) if orjson else json.loads(raw)
418
+
419
+ model_data = data.get("model")
420
+ states_data = data.get("states")
421
+
422
+ names_path = self._names_sidecar_path()
423
+ if model_data is not None and names_path and names_path.is_file():
424
+ try:
425
+ payload = json.loads(names_path.read_text())
426
+ # Legacy sidecars are a bare list with no fingerprint; trust them as
427
+ # before. Current sidecars wrap the names with the mtimes of the
428
+ # source file(s) at save time, so a stale sidecar left over from a
429
+ # since-regenerated file can be detected and ignored.
430
+ if isinstance(payload, list):
431
+ saved_names, saved_fingerprint = payload, None
432
+ else:
433
+ saved_names = payload.get("names")
434
+ saved_fingerprint = payload.get("source_mtime")
435
+
436
+ sim_batches = int(model_data.get("simBatches", 1))
437
+ stale = (
438
+ saved_fingerprint is not None
439
+ and saved_fingerprint != self._source_fingerprint()
440
+ )
441
+ if stale:
442
+ logger.info(
443
+ "Ignoring batch names in %s: source file(s) changed since "
444
+ "they were saved.",
445
+ names_path,
446
+ )
447
+ elif isinstance(saved_names, list) and len(saved_names) == sim_batches:
448
+ model_data["batchNames"] = saved_names
449
+ except (OSError, ValueError, json.JSONDecodeError) as e:
450
+ logger.warning("Failed to load batch names from %s: %s", names_path, e)
451
+
452
+ self.blobs = []
453
+ # Random per-load token folded into every blob URL so it's safe to cache
454
+ # them forever: a later server restart serving a different scene on the
455
+ # same port gets a different token, so it can never collide with a
456
+ # stale cached response for blob id N from a previous load.
457
+ self._blob_token = secrets.token_hex(4)
458
+
459
+ def extract_blobs(obj):
460
+ if isinstance(obj, dict):
461
+ for k, v in obj.items():
462
+ if isinstance(v, str) and v.startswith("__b64__"):
463
+ blob_id = len(self.blobs)
464
+ self.blobs.append(base64.b64decode(v[7:]))
465
+ obj[k] = f"/blob/{self._blob_token}/{blob_id}"
466
+ else:
467
+ extract_blobs(v)
468
+ elif isinstance(obj, list):
469
+ for i, v in enumerate(obj):
470
+ if isinstance(v, str) and v.startswith("__b64__"):
471
+ blob_id = len(self.blobs)
472
+ self.blobs.append(base64.b64decode(v[7:]))
473
+ obj[i] = f"/blob/{self._blob_token}/{blob_id}"
474
+ else:
475
+ extract_blobs(v)
476
+
477
+ self.model_data = model_data
478
+
479
+ if self.model_data is not None:
480
+ extract_blobs(self.model_data)
481
+
482
+ def register_blob(raw: bytes) -> str:
483
+ blob_id = len(self.blobs)
484
+ self.blobs.append(raw)
485
+ return f"/blob/{self._blob_token}/{blob_id}"
486
+
487
+ # Repack the per-frame states array into whole-trajectory columnar
488
+ # blobs (wire format v4, see README "Binary state fields") so the
489
+ # viewer parses one lightweight JSON index plus raw binary instead of
490
+ # thousands of tiny per-frame objects/base64 strings. Falls back to
491
+ # serving `states_data` exactly as before if it isn't strictly
492
+ # consistent across frames (see _columnarize_states).
493
+ if isinstance(states_data, list) and states_data:
494
+ columnar = _columnarize_states(states_data, model_data, register_blob)
495
+ if columnar is not None:
496
+ states_data = columnar
497
+ # Discard the raw per-frame states list now that everything needed
498
+ # from it (columnar or not) has been extracted -- it can be large
499
+ # (the dominant memory user for a long simulation).
500
+ del data
501
+
502
+ # Pre-serialize and pre-compress once so HTTP endpoints never do work per request.
503
+ # compresslevel=1 is fastest (still typically 5-10x smaller for JSON). model_data
504
+ # itself is kept around (it's small, unlike states_data) so /batch-names can
505
+ # patch and re-serialize it without re-reading the source file.
506
+ self._dumps = orjson.dumps if orjson else (lambda o: json.dumps(o).encode())
507
+ if model_data is not None:
508
+ self.model_bytes = gzip.compress(self._dumps(model_data), compresslevel=1)
509
+ if self.live:
510
+ # Live mode: frames arrive over /ws/states instead, so /states just
511
+ # tells the client to open the socket (see loadData in SimView.js).
512
+ self.states_bytes = gzip.compress(
513
+ self._dumps({"live": True}), compresslevel=1
514
+ )
515
+ elif states_data is not None:
516
+ self.states_bytes = gzip.compress(self._dumps(states_data), compresslevel=1)
517
+
518
+ logger.info("Simulation data loaded successfully.")
519
+
520
+ def setup_routes(self):
521
+ @self.app.get("/")
522
+ async def index(request: Request):
523
+ return self.templates.TemplateResponse(
524
+ request=request,
525
+ name="index.html",
526
+ context={"request": request, "t": int(time.time())},
527
+ )
528
+
529
+ _gzip_headers = {"Content-Encoding": "gzip"}
530
+
531
+ @self.app.get("/model")
532
+ async def get_model():
533
+ logger.debug("HTTP: Client requested /model")
534
+ if self.model_bytes is not None:
535
+ return Response(
536
+ content=self.model_bytes,
537
+ media_type="application/json",
538
+ headers=_gzip_headers,
539
+ )
540
+ return Response(
541
+ content=b'{"message":"Model data not available"}',
542
+ media_type="application/json",
543
+ status_code=404,
544
+ )
545
+
546
+ @self.app.get("/states")
547
+ async def get_states():
548
+ logger.debug("HTTP: Client requested /states")
549
+ if self.states_bytes is not None:
550
+ return Response(
551
+ content=self.states_bytes,
552
+ media_type="application/json",
553
+ headers=_gzip_headers,
554
+ )
555
+ return Response(
556
+ content=b'{"message":"States data not available"}',
557
+ media_type="application/json",
558
+ status_code=404,
559
+ )
560
+
561
+ @self.app.get("/blob/{token}/{blob_id}")
562
+ async def get_blob(token: str, blob_id: int):
563
+ if token != self._blob_token or not (0 <= blob_id < len(self.blobs)):
564
+ return Response(status_code=404)
565
+ return Response(
566
+ content=self.blobs[blob_id],
567
+ media_type="application/octet-stream",
568
+ headers={"Cache-Control": "public, max-age=31536000, immutable"},
569
+ )
570
+
571
+ @self.app.post("/batch-names")
572
+ async def set_batch_names(body: BatchNamesRequest):
573
+ if self.model_data is None:
574
+ return Response(
575
+ content=b'{"message":"Model data not available"}',
576
+ media_type="application/json",
577
+ status_code=404,
578
+ )
579
+ names = body.names
580
+ sim_batches = int(self.model_data.get("simBatches", 1))
581
+ if len(names) != sim_batches:
582
+ return Response(
583
+ content=b'{"message":"Expected {\\"names\\": [str, ...]} matching simBatches"}',
584
+ media_type="application/json",
585
+ status_code=400,
586
+ )
587
+
588
+ self.model_data["batchNames"] = names
589
+ self.model_bytes = gzip.compress(
590
+ self._dumps(self.model_data), compresslevel=1
591
+ )
592
+
593
+ names_path = self._names_sidecar_path()
594
+ if names_path:
595
+ try:
596
+ payload = {
597
+ "names": names,
598
+ "source_mtime": self._source_fingerprint(),
599
+ }
600
+ names_path.write_text(json.dumps(payload))
601
+ except OSError as e:
602
+ logger.warning(
603
+ "Failed to persist batch names to %s: %s", names_path, e
604
+ )
605
+
606
+ return {"ok": True}
607
+
608
+ if self.live:
609
+ # Only registered in live mode: LiveViewer.push_state broadcasts
610
+ # each new frame to every connected socket (see broadcast_frame).
611
+ # Frames buffered before this client connected are replayed as one
612
+ # catch-up message first, so a viewer opened mid-run still sees
613
+ # the whole timeline so far.
614
+ @self.app.websocket("/ws/states")
615
+ async def ws_states(websocket: WebSocket):
616
+ await websocket.accept()
617
+ self.ws_clients.add(websocket)
618
+ try:
619
+ if self.frame_buffer:
620
+ await websocket.send_text(
621
+ json.dumps({"states": list(self.frame_buffer)})
622
+ )
623
+ while True:
624
+ # This endpoint is push-only; block here until the
625
+ # client disconnects (or the connection otherwise dies)
626
+ # so the `finally` below can discard it.
627
+ await websocket.receive_text()
628
+ except WebSocketDisconnect:
629
+ pass
630
+ finally:
631
+ self.ws_clients.discard(websocket)
632
+
633
+ async def broadcast_frame(self, frame: dict) -> None:
634
+ """Send one newly-pushed frame to every connected /ws/states client.
635
+
636
+ Must run on self.loop (the server thread's event loop) -- LiveViewer
637
+ schedules this via asyncio.run_coroutine_threadsafe rather than
638
+ calling it directly from the caller's thread. A dead/broken socket is
639
+ dropped rather than allowed to raise, since one slow/gone client must
640
+ never break the broadcast (or the caller's push_state) for the rest.
641
+ """
642
+ if not self.ws_clients:
643
+ return
644
+ message = json.dumps({"states": [frame]})
645
+ dead = []
646
+ for client in self.ws_clients:
647
+ try:
648
+ await client.send_text(message)
649
+ except Exception:
650
+ dead.append(client)
651
+ for client in dead:
652
+ self.ws_clients.discard(client)
653
+
654
+ def run(
655
+ self,
656
+ debug: bool = False,
657
+ host: str = "127.0.0.1",
658
+ port: int = 5420,
659
+ open_browser: bool = False,
660
+ ):
661
+ logger.info("SimView server running on http://%s:%s", host, port)
662
+ if open_browser:
663
+ import threading
664
+ import webbrowser
665
+
666
+ # uvicorn.run() below blocks until the server stops, so the browser is
667
+ # opened from a background timer instead of a startup hook (FastAPI's
668
+ # on_event/lifespan hooks are more ceremony than this one-shot needs).
669
+ # The short delay gives uvicorn a head start on binding the socket.
670
+ bind_host = "127.0.0.1" if host in ("0.0.0.0", "::") else host
671
+ threading.Timer(
672
+ 0.5, webbrowser.open, args=(f"http://{bind_host}:{port}",)
673
+ ).start()
674
+
675
+ # uvloop/httptools are faster than the stdlib fallbacks but aren't available
676
+ # everywhere (uvloop doesn't support Windows). Use them opportunistically and
677
+ # fall back to uvicorn's "auto" detection rather than crashing at startup.
678
+ try:
679
+ import uvloop # noqa: F401
680
+
681
+ loop = "uvloop"
682
+ except ImportError:
683
+ loop = "auto"
684
+ try:
685
+ import httptools # noqa: F401
686
+
687
+ http = "httptools"
688
+ except ImportError:
689
+ http = "auto"
690
+ uvicorn.run(
691
+ self.app,
692
+ host=host,
693
+ port=port,
694
+ log_level="debug" if debug else "info",
695
+ loop=loop,
696
+ http=http,
697
+ )
698
+
699
+ @staticmethod
700
+ def start(
701
+ sim_path: str | Path | Sequence[str | Path],
702
+ host: str = "127.0.0.1",
703
+ preferred_port: int = 5420,
704
+ open_browser: bool = False,
705
+ ):
706
+ paths = (
707
+ [Path(sim_path)]
708
+ if isinstance(sim_path, (str, Path))
709
+ else [Path(p) for p in sim_path]
710
+ )
711
+ for p in paths:
712
+ if not p.is_file():
713
+ raise FileNotFoundError(f"Simulation file '{p}' does not exist.")
714
+
715
+ if len(paths) > 1:
716
+ from simview.merge import merge_simulation_files
717
+
718
+ server = SimViewServer(data=merge_simulation_files(paths), sim_path=paths)
719
+ else:
720
+ server = SimViewServer(sim_path=paths[0])
721
+ port = find_free_port(host, preferred_port)
722
+ if port != preferred_port:
723
+ logger.warning(
724
+ "Preferred port %s is not available. Using port %s instead.",
725
+ preferred_port,
726
+ port,
727
+ )
728
+ server.run(host=host, port=port, open_browser=open_browser)
729
+
730
+
731
+ if __name__ == "__main__":
732
+ import argparse
733
+
734
+ parser = argparse.ArgumentParser(description="Run the SimView server.")
735
+ parser.add_argument(
736
+ "--sim_path", type=str, required=True, help="Path to the simulation JSON file."
737
+ )
738
+ args = parser.parse_args()
739
+ SimViewServer.start(args.sim_path)