simview 3.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- simview/__init__.py +69 -0
- simview/__main__.py +174 -0
- simview/launcher.py +109 -0
- simview/live.py +204 -0
- simview/merge.py +593 -0
- simview/model.py +690 -0
- simview/py.typed +0 -0
- simview/scene.py +603 -0
- simview/server.py +739 -0
- simview/state.py +190 -0
- simview/static/css/controls.css +36 -0
- simview/static/js/SimView.js +601 -0
- simview/static/js/components/AnimationController.js +654 -0
- simview/static/js/components/BatchManager.js +198 -0
- simview/static/js/components/InteractionController.js +446 -0
- simview/static/js/components/InteractionControls.js +116 -0
- simview/static/js/components/Scene.js +166 -0
- simview/static/js/components/StateStore.js +188 -0
- simview/static/js/config.js +233 -0
- simview/static/js/main.js +3 -0
- simview/static/js/objects/Body.js +616 -0
- simview/static/js/objects/StaticObject.js +181 -0
- simview/static/js/objects/Terrain.js +514 -0
- simview/static/js/objects/utils.js +378 -0
- simview/static/js/ui/AnalysisPanel.js +233 -0
- simview/static/js/ui/BatchLegend.js +207 -0
- simview/static/js/ui/BodyStateWindow.js +521 -0
- simview/static/js/ui/Controls.js +586 -0
- simview/static/js/ui/ErrorMetrics.js +664 -0
- simview/static/js/ui/Legend.js +87 -0
- simview/static/js/ui/PlaybackControls.js +314 -0
- simview/static/js/ui/ScalarPlotter.js +575 -0
- simview/static/js/utils/blobCodec.js +73 -0
- simview/static/js/utils/bodyTransforms.js +151 -0
- simview/static/js/utils/csv.js +46 -0
- simview/static/js/utils/errorMath.js +69 -0
- simview/static/js/utils/injectStyles.js +16 -0
- simview/static/js/utils/interpolate.js +65 -0
- simview/static/js/utils/liveFollow.js +17 -0
- simview/static/js/utils/loadRecordingLibs.js +34 -0
- simview/static/js/utils/viewState.js +226 -0
- simview/static/lib/chroma-js-3.1.2/index.min.js +8 -0
- simview/static/lib/chroma-js-3.1.2/src/Color.js +49 -0
- simview/static/lib/chroma-js-3.1.2/src/chroma.js +10 -0
- simview/static/lib/chroma-js-3.1.2/src/colors/colorbrewer.js +81 -0
- simview/static/lib/chroma-js-3.1.2/src/colors/w3cx11.js +164 -0
- simview/static/lib/chroma-js-3.1.2/src/generator/average.js +88 -0
- simview/static/lib/chroma-js-3.1.2/src/generator/bezier.js +86 -0
- simview/static/lib/chroma-js-3.1.2/src/generator/blend.js +57 -0
- simview/static/lib/chroma-js-3.1.2/src/generator/cubehelix.js +87 -0
- simview/static/lib/chroma-js-3.1.2/src/generator/mix.js +19 -0
- simview/static/lib/chroma-js-3.1.2/src/generator/random.js +12 -0
- simview/static/lib/chroma-js-3.1.2/src/generator/scale.js +394 -0
- simview/static/lib/chroma-js-3.1.2/src/interpolator/_hsx.js +59 -0
- simview/static/lib/chroma-js-3.1.2/src/interpolator/hcg.js +12 -0
- simview/static/lib/chroma-js-3.1.2/src/interpolator/hsi.js +12 -0
- simview/static/lib/chroma-js-3.1.2/src/interpolator/hsl.js +12 -0
- simview/static/lib/chroma-js-3.1.2/src/interpolator/hsv.js +12 -0
- simview/static/lib/chroma-js-3.1.2/src/interpolator/index.js +1 -0
- simview/static/lib/chroma-js-3.1.2/src/interpolator/lab.js +19 -0
- simview/static/lib/chroma-js-3.1.2/src/interpolator/lch.js +13 -0
- simview/static/lib/chroma-js-3.1.2/src/interpolator/lrgb.js +19 -0
- simview/static/lib/chroma-js-3.1.2/src/interpolator/num.js +15 -0
- simview/static/lib/chroma-js-3.1.2/src/interpolator/oklab.js +19 -0
- simview/static/lib/chroma-js-3.1.2/src/interpolator/oklch.js +12 -0
- simview/static/lib/chroma-js-3.1.2/src/interpolator/rgb.js +18 -0
- simview/static/lib/chroma-js-3.1.2/src/io/cmyk/cmyk2rgb.js +16 -0
- simview/static/lib/chroma-js-3.1.2/src/io/cmyk/index.js +27 -0
- simview/static/lib/chroma-js-3.1.2/src/io/cmyk/rgb2cmyk.js +17 -0
- simview/static/lib/chroma-js-3.1.2/src/io/css/css2rgb.js +238 -0
- simview/static/lib/chroma-js-3.1.2/src/io/css/hsl2css.js +26 -0
- simview/static/lib/chroma-js-3.1.2/src/io/css/index.js +27 -0
- simview/static/lib/chroma-js-3.1.2/src/io/css/lab2css.js +24 -0
- simview/static/lib/chroma-js-3.1.2/src/io/css/lch2css.js +24 -0
- simview/static/lib/chroma-js-3.1.2/src/io/css/oklab2css.js +16 -0
- simview/static/lib/chroma-js-3.1.2/src/io/css/oklch2css.js +16 -0
- simview/static/lib/chroma-js-3.1.2/src/io/css/rgb2css.js +61 -0
- simview/static/lib/chroma-js-3.1.2/src/io/gl/index.js +22 -0
- simview/static/lib/chroma-js-3.1.2/src/io/hcg/hcg2rgb.js +55 -0
- simview/static/lib/chroma-js-3.1.2/src/io/hcg/index.js +27 -0
- simview/static/lib/chroma-js-3.1.2/src/io/hcg/rgb2hcg.js +23 -0
- simview/static/lib/chroma-js-3.1.2/src/io/hex/hex2rgb.js +56 -0
- simview/static/lib/chroma-js-3.1.2/src/io/hex/index.js +29 -0
- simview/static/lib/chroma-js-3.1.2/src/io/hex/rgb2hex.js +29 -0
- simview/static/lib/chroma-js-3.1.2/src/io/hsi/hsi2rgb.js +45 -0
- simview/static/lib/chroma-js-3.1.2/src/io/hsi/index.js +27 -0
- simview/static/lib/chroma-js-3.1.2/src/io/hsi/rgb2hsi.js +31 -0
- simview/static/lib/chroma-js-3.1.2/src/io/hsl/hsl2rgb.js +35 -0
- simview/static/lib/chroma-js-3.1.2/src/io/hsl/index.js +27 -0
- simview/static/lib/chroma-js-3.1.2/src/io/hsl/rgb2hsl.js +45 -0
- simview/static/lib/chroma-js-3.1.2/src/io/hsv/hsv2rgb.js +47 -0
- simview/static/lib/chroma-js-3.1.2/src/io/hsv/index.js +27 -0
- simview/static/lib/chroma-js-3.1.2/src/io/hsv/rgb2hsv.js +32 -0
- simview/static/lib/chroma-js-3.1.2/src/io/input.js +4 -0
- simview/static/lib/chroma-js-3.1.2/src/io/lab/index.js +28 -0
- simview/static/lib/chroma-js-3.1.2/src/io/lab/lab-constants.js +120 -0
- simview/static/lib/chroma-js-3.1.2/src/io/lab/lab2rgb.js +101 -0
- simview/static/lib/chroma-js-3.1.2/src/io/lab/rgb2lab.js +67 -0
- simview/static/lib/chroma-js-3.1.2/src/io/lch/hcl2rgb.js +9 -0
- simview/static/lib/chroma-js-3.1.2/src/io/lch/index.js +35 -0
- simview/static/lib/chroma-js-3.1.2/src/io/lch/lab2lch.js +12 -0
- simview/static/lib/chroma-js-3.1.2/src/io/lch/lch2lab.js +18 -0
- simview/static/lib/chroma-js-3.1.2/src/io/lch/lch2rgb.js +13 -0
- simview/static/lib/chroma-js-3.1.2/src/io/lch/rgb2lch.js +12 -0
- simview/static/lib/chroma-js-3.1.2/src/io/named/index.js +30 -0
- simview/static/lib/chroma-js-3.1.2/src/io/num/index.js +32 -0
- simview/static/lib/chroma-js-3.1.2/src/io/num/num2rgb.js +13 -0
- simview/static/lib/chroma-js-3.1.2/src/io/num/rgb2num.js +8 -0
- simview/static/lib/chroma-js-3.1.2/src/io/oklab/index.js +27 -0
- simview/static/lib/chroma-js-3.1.2/src/io/oklab/oklab2rgb.js +34 -0
- simview/static/lib/chroma-js-3.1.2/src/io/oklab/rgb2oklab.js +37 -0
- simview/static/lib/chroma-js-3.1.2/src/io/oklch/index.js +27 -0
- simview/static/lib/chroma-js-3.1.2/src/io/oklch/oklch2rgb.js +13 -0
- simview/static/lib/chroma-js-3.1.2/src/io/oklch/rgb2oklch.js +12 -0
- simview/static/lib/chroma-js-3.1.2/src/io/rgb/index.js +44 -0
- simview/static/lib/chroma-js-3.1.2/src/io/temp/index.js +22 -0
- simview/static/lib/chroma-js-3.1.2/src/io/temp/rgb2temperature.js +30 -0
- simview/static/lib/chroma-js-3.1.2/src/io/temp/temperature2rgb.js +39 -0
- simview/static/lib/chroma-js-3.1.2/src/ops/alpha.js +13 -0
- simview/static/lib/chroma-js-3.1.2/src/ops/clipped.js +5 -0
- simview/static/lib/chroma-js-3.1.2/src/ops/darken.js +17 -0
- simview/static/lib/chroma-js-3.1.2/src/ops/get.js +13 -0
- simview/static/lib/chroma-js-3.1.2/src/ops/luminance.js +54 -0
- simview/static/lib/chroma-js-3.1.2/src/ops/mix.js +10 -0
- simview/static/lib/chroma-js-3.1.2/src/ops/premultiply.js +12 -0
- simview/static/lib/chroma-js-3.1.2/src/ops/saturate.js +15 -0
- simview/static/lib/chroma-js-3.1.2/src/ops/set.js +43 -0
- simview/static/lib/chroma-js-3.1.2/src/ops/shade.js +11 -0
- simview/static/lib/chroma-js-3.1.2/src/utils/analyze.js +191 -0
- simview/static/lib/chroma-js-3.1.2/src/utils/clip_rgb.js +15 -0
- simview/static/lib/chroma-js-3.1.2/src/utils/contrast.js +12 -0
- simview/static/lib/chroma-js-3.1.2/src/utils/contrastAPCA.js +68 -0
- simview/static/lib/chroma-js-3.1.2/src/utils/delta-e.js +62 -0
- simview/static/lib/chroma-js-3.1.2/src/utils/distance.js +17 -0
- simview/static/lib/chroma-js-3.1.2/src/utils/index.js +27 -0
- simview/static/lib/chroma-js-3.1.2/src/utils/last.js +8 -0
- simview/static/lib/chroma-js-3.1.2/src/utils/limit.js +5 -0
- simview/static/lib/chroma-js-3.1.2/src/utils/multiply-matrices.js +36 -0
- simview/static/lib/chroma-js-3.1.2/src/utils/scales.js +15 -0
- simview/static/lib/chroma-js-3.1.2/src/utils/type.js +18 -0
- simview/static/lib/chroma-js-3.1.2/src/utils/unpack.js +17 -0
- simview/static/lib/chroma-js-3.1.2/src/utils/valid.js +11 -0
- simview/static/lib/chroma-js-3.1.2/src/version.js +2 -0
- simview/static/lib/download.js +132 -0
- simview/static/lib/js-colormaps.js +19335 -0
- simview/static/lib/tar.js +334 -0
- simview/static/lib/three-0.174.0/addons/controls/OrbitControls.js +1556 -0
- simview/static/lib/three-0.174.0/addons/libs/lil-gui.module.min.js +8 -0
- simview/static/lib/three-0.174.0/three.core.js +48830 -0
- simview/static/lib/three-0.174.0/three.module.js +17313 -0
- simview/static/lib/uPlot.esm.js +6140 -0
- simview/static/lib/uPlot.min.css +2 -0
- simview/static/textures/contacts/red-cross0.png +0 -0
- simview/static/textures/points/ball0.png +0 -0
- simview/static/textures/points/ball1.png +0 -0
- simview/templates/index.html +81 -0
- simview/utils.py +41 -0
- simview-3.2.dist-info/METADATA +552 -0
- simview-3.2.dist-info/RECORD +163 -0
- simview-3.2.dist-info/WHEEL +5 -0
- simview-3.2.dist-info/entry_points.txt +2 -0
- simview-3.2.dist-info/licenses/LICENSE +28 -0
- simview-3.2.dist-info/top_level.txt +1 -0
simview/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)
|