simview 6.2.3__tar.gz → 6.3.0__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.3/simview.egg-info → simview-6.3.0}/PKG-INFO +1 -1
  2. {simview-6.2.3 → simview-6.3.0}/pyproject.toml +1 -1
  3. {simview-6.2.3 → simview-6.3.0}/simview/__main__.py +15 -1
  4. {simview-6.2.3 → simview-6.3.0}/simview/info.py +11 -5
  5. {simview-6.2.3 → simview-6.3.0}/simview/server.py +181 -12
  6. {simview-6.2.3 → simview-6.3.0}/simview/static/js/SimView.js +24 -4
  7. {simview-6.2.3 → simview-6.3.0}/simview/static/js/ui/TerrainProfile.js +13 -3
  8. {simview-6.2.3 → simview-6.3.0}/simview/static/js/utils/viewState.js +21 -0
  9. {simview-6.2.3 → simview-6.3.0}/simview/view.py +7 -5
  10. {simview-6.2.3 → simview-6.3.0/simview.egg-info}/PKG-INFO +1 -1
  11. {simview-6.2.3 → simview-6.3.0}/tests/test_cli.py +4 -0
  12. {simview-6.2.3 → simview-6.3.0}/tests/test_live.py +61 -1
  13. {simview-6.2.3 → simview-6.3.0}/tests/test_server.py +43 -1
  14. {simview-6.2.3 → simview-6.3.0}/tests/test_show.py +20 -0
  15. {simview-6.2.3 → simview-6.3.0}/LICENSE +0 -0
  16. {simview-6.2.3 → simview-6.3.0}/MANIFEST.in +0 -0
  17. {simview-6.2.3 → simview-6.3.0}/README.md +0 -0
  18. {simview-6.2.3 → simview-6.3.0}/setup.cfg +0 -0
  19. {simview-6.2.3 → simview-6.3.0}/simview/__init__.py +0 -0
  20. {simview-6.2.3 → simview-6.3.0}/simview/columnar.py +0 -0
  21. {simview-6.2.3 → simview-6.3.0}/simview/diff.py +0 -0
  22. {simview-6.2.3 → simview-6.3.0}/simview/launcher.py +0 -0
  23. {simview-6.2.3 → simview-6.3.0}/simview/live.py +0 -0
  24. {simview-6.2.3 → simview-6.3.0}/simview/merge.py +0 -0
  25. {simview-6.2.3 → simview-6.3.0}/simview/model.py +0 -0
  26. {simview-6.2.3 → simview-6.3.0}/simview/py.typed +0 -0
  27. {simview-6.2.3 → simview-6.3.0}/simview/remote.py +0 -0
  28. {simview-6.2.3 → simview-6.3.0}/simview/render.py +0 -0
  29. {simview-6.2.3 → simview-6.3.0}/simview/scene.py +0 -0
  30. {simview-6.2.3 → simview-6.3.0}/simview/state.py +0 -0
  31. {simview-6.2.3 → simview-6.3.0}/simview/static/css/controls.css +0 -0
  32. {simview-6.2.3 → simview-6.3.0}/simview/static/js/components/AnimationController.js +0 -0
  33. {simview-6.2.3 → simview-6.3.0}/simview/static/js/components/BatchManager.js +0 -0
  34. {simview-6.2.3 → simview-6.3.0}/simview/static/js/components/InteractionController.js +0 -0
  35. {simview-6.2.3 → simview-6.3.0}/simview/static/js/components/Scene.js +0 -0
  36. {simview-6.2.3 → simview-6.3.0}/simview/static/js/components/StateStore.js +0 -0
  37. {simview-6.2.3 → simview-6.3.0}/simview/static/js/components/WindowedField.js +0 -0
  38. {simview-6.2.3 → simview-6.3.0}/simview/static/js/config.js +0 -0
  39. {simview-6.2.3 → simview-6.3.0}/simview/static/js/main.js +0 -0
  40. {simview-6.2.3 → simview-6.3.0}/simview/static/js/objects/Body.js +0 -0
  41. {simview-6.2.3 → simview-6.3.0}/simview/static/js/objects/Polyline.js +0 -0
  42. {simview-6.2.3 → simview-6.3.0}/simview/static/js/objects/StaticObject.js +0 -0
  43. {simview-6.2.3 → simview-6.3.0}/simview/static/js/objects/Terrain.js +0 -0
  44. {simview-6.2.3 → simview-6.3.0}/simview/static/js/objects/colormap.js +0 -0
  45. {simview-6.2.3 → simview-6.3.0}/simview/static/js/objects/similarity.js +0 -0
  46. {simview-6.2.3 → simview-6.3.0}/simview/static/js/objects/utils.js +0 -0
  47. {simview-6.2.3 → simview-6.3.0}/simview/static/js/ui/AnalysisPanel.js +0 -0
  48. {simview-6.2.3 → simview-6.3.0}/simview/static/js/ui/BatchLegend.js +0 -0
  49. {simview-6.2.3 → simview-6.3.0}/simview/static/js/ui/BodyStateWindow.js +0 -0
  50. {simview-6.2.3 → simview-6.3.0}/simview/static/js/ui/Controls.js +0 -0
  51. {simview-6.2.3 → simview-6.3.0}/simview/static/js/ui/ErrorMetrics.js +0 -0
  52. {simview-6.2.3 → simview-6.3.0}/simview/static/js/ui/Legend.js +0 -0
  53. {simview-6.2.3 → simview-6.3.0}/simview/static/js/ui/PlaybackControls.js +0 -0
  54. {simview-6.2.3 → simview-6.3.0}/simview/static/js/ui/ScalarPlotter.js +0 -0
  55. {simview-6.2.3 → simview-6.3.0}/simview/static/js/ui/chartControls.js +0 -0
  56. {simview-6.2.3 → simview-6.3.0}/simview/static/js/utils/batchPresets.js +0 -0
  57. {simview-6.2.3 → simview-6.3.0}/simview/static/js/utils/batchVisibility.js +0 -0
  58. {simview-6.2.3 → simview-6.3.0}/simview/static/js/utils/blobCodec.js +0 -0
  59. {simview-6.2.3 → simview-6.3.0}/simview/static/js/utils/blobWindow.js +0 -0
  60. {simview-6.2.3 → simview-6.3.0}/simview/static/js/utils/bodyTransforms.js +0 -0
  61. {simview-6.2.3 → simview-6.3.0}/simview/static/js/utils/cameraRange.js +0 -0
  62. {simview-6.2.3 → simview-6.3.0}/simview/static/js/utils/csv.js +0 -0
  63. {simview-6.2.3 → simview-6.3.0}/simview/static/js/utils/episodes.js +0 -0
  64. {simview-6.2.3 → simview-6.3.0}/simview/static/js/utils/errorMath.js +0 -0
  65. {simview-6.2.3 → simview-6.3.0}/simview/static/js/utils/html.js +0 -0
  66. {simview-6.2.3 → simview-6.3.0}/simview/static/js/utils/interpolate.js +0 -0
  67. {simview-6.2.3 → simview-6.3.0}/simview/static/js/utils/keyboard.js +0 -0
  68. {simview-6.2.3 → simview-6.3.0}/simview/static/js/utils/liveFollow.js +0 -0
  69. {simview-6.2.3 → simview-6.3.0}/simview/static/js/utils/scalarSeries.js +0 -0
  70. {simview-6.2.3 → simview-6.3.0}/simview/static/js/utils/terrainSample.js +0 -0
  71. {simview-6.2.3 → simview-6.3.0}/simview/static/js/utils/uplot.js +0 -0
  72. {simview-6.2.3 → simview-6.3.0}/simview/static/js/utils/viewerDefaults.js +0 -0
  73. {simview-6.2.3 → simview-6.3.0}/simview/static/js/utils/visibleRanges.js +0 -0
  74. {simview-6.2.3 → simview-6.3.0}/simview/static/js/vendor/js-colormaps.js +0 -0
  75. {simview-6.2.3 → simview-6.3.0}/simview/static/lib/three-0.174.0/addons/controls/OrbitControls.js +0 -0
  76. {simview-6.2.3 → simview-6.3.0}/simview/static/lib/three-0.174.0/addons/libs/lil-gui.module.min.js +0 -0
  77. {simview-6.2.3 → simview-6.3.0}/simview/static/lib/three-0.174.0/three.core.js +0 -0
  78. {simview-6.2.3 → simview-6.3.0}/simview/static/lib/three-0.174.0/three.module.js +0 -0
  79. {simview-6.2.3 → simview-6.3.0}/simview/static/lib/uPlot-1.6.32/uPlot.esm.js +0 -0
  80. {simview-6.2.3 → simview-6.3.0}/simview/static/lib/uPlot-1.6.32/uPlot.min.css +0 -0
  81. {simview-6.2.3 → simview-6.3.0}/simview/static/textures/contacts/red-cross0.png +0 -0
  82. {simview-6.2.3 → simview-6.3.0}/simview/static/textures/points/ball1.png +0 -0
  83. {simview-6.2.3 → simview-6.3.0}/simview/templates/index.html +0 -0
  84. {simview-6.2.3 → simview-6.3.0}/simview/terrain.py +0 -0
  85. {simview-6.2.3 → simview-6.3.0}/simview/utils.py +0 -0
  86. {simview-6.2.3 → simview-6.3.0}/simview.egg-info/SOURCES.txt +0 -0
  87. {simview-6.2.3 → simview-6.3.0}/simview.egg-info/dependency_links.txt +0 -0
  88. {simview-6.2.3 → simview-6.3.0}/simview.egg-info/entry_points.txt +0 -0
  89. {simview-6.2.3 → simview-6.3.0}/simview.egg-info/requires.txt +0 -0
  90. {simview-6.2.3 → simview-6.3.0}/simview.egg-info/top_level.txt +0 -0
  91. {simview-6.2.3 → simview-6.3.0}/tests/test_batch_selection.py +0 -0
  92. {simview-6.2.3 → simview-6.3.0}/tests/test_columnar_ondisk.py +0 -0
  93. {simview-6.2.3 → simview-6.3.0}/tests/test_columnar_states.py +0 -0
  94. {simview-6.2.3 → simview-6.3.0}/tests/test_contacts.py +0 -0
  95. {simview-6.2.3 → simview-6.3.0}/tests/test_diff.py +0 -0
  96. {simview-6.2.3 → simview-6.3.0}/tests/test_embed_defaults.py +0 -0
  97. {simview-6.2.3 → simview-6.3.0}/tests/test_episodes.py +0 -0
  98. {simview-6.2.3 → simview-6.3.0}/tests/test_info.py +0 -0
  99. {simview-6.2.3 → simview-6.3.0}/tests/test_launcher.py +0 -0
  100. {simview-6.2.3 → simview-6.3.0}/tests/test_merge.py +0 -0
  101. {simview-6.2.3 → simview-6.3.0}/tests/test_pointcloud_features.py +0 -0
  102. {simview-6.2.3 → simview-6.3.0}/tests/test_remote.py +0 -0
  103. {simview-6.2.3 → simview-6.3.0}/tests/test_roundtrip.py +0 -0
  104. {simview-6.2.3 → simview-6.3.0}/tests/test_scene.py +0 -0
  105. {simview-6.2.3 → simview-6.3.0}/tests/test_terrain.py +0 -0
  106. {simview-6.2.3 → simview-6.3.0}/tests/test_terrain_query.py +0 -0
  107. {simview-6.2.3 → simview-6.3.0}/tests/test_trajectory.py +0 -0
  108. {simview-6.2.3 → simview-6.3.0}/tests/test_utils.py +0 -0
  109. {simview-6.2.3 → simview-6.3.0}/tests/test_view.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: simview
3
- Version: 6.2.3
3
+ Version: 6.3.0
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.3"
7
+ version = "6.3.0"
8
8
  readme = "README.md"
9
9
  requires-python = ">=3.12"
10
10
  license = "BSD-3-Clause"
@@ -297,9 +297,18 @@ def save_merged(
297
297
  logger.error("Error: --save-merged requires at least 2 input files to merge.")
298
298
  sys.exit(1)
299
299
 
300
+ from simview.columnar import columnarize_states, inline_blob
300
301
  from simview.merge import merge_simulation_files
301
302
 
302
303
  merged = merge_simulation_files(paths, selections)
304
+ # Pack the merged states the same way SimulationScene.save does: merge
305
+ # decodes every blob to plain lists, so dumping it directly would write
306
+ # the fat legacy per-frame layout. Falls back silently when numpy is
307
+ # unavailable or the states are too irregular (columnarize_states
308
+ # returns None).
309
+ columnar_states = columnarize_states(merged["states"], merged["model"], inline_blob)
310
+ if columnar_states is not None:
311
+ merged["states"] = columnar_states
303
312
  payload = json.dumps(merged).encode("utf-8")
304
313
  if out_path.suffix == ".gz":
305
314
  payload = gzip.compress(payload, compresslevel=1)
@@ -372,7 +381,12 @@ def _add_host_port_flags(sp: argparse.ArgumentParser) -> None:
372
381
  "--host",
373
382
  type=str,
374
383
  default="127.0.0.1",
375
- help="Host/interface for the server to bind to (default: 127.0.0.1).",
384
+ help=(
385
+ "Host/interface for the server to bind to (default: 127.0.0.1). "
386
+ "Note: the server has no authentication, so binding to a "
387
+ "non-loopback address exposes the scene data to everyone who can "
388
+ "reach it."
389
+ ),
376
390
  )
377
391
  sp.add_argument(
378
392
  "--port",
@@ -1,8 +1,7 @@
1
1
  """Structural summary of a scene JSON file, for `simview info` and for coding
2
2
  agents inspecting simview-generated data files.
3
3
 
4
- Deliberately dependency-free (stdlib only: json, gzip via
5
- `simview.utils.read_maybe_gzipped_bytes`, base64) so it works on a base
4
+ Deliberately dependency-free (stdlib only: json, gzip, base64) so it works on a base
6
5
  install without the `authoring` extra (torch/numpy) -- see "Lazy imports" in
7
6
  docs/dev/architecture.md. Does not import `simview.model`/`simview.server`:
8
7
  the former pulls in torch at module scope, the latter fastapi/uvicorn --
@@ -11,6 +10,7 @@ both wrong layering for a lightweight inspection tool. Shares its blob-decoding/
11
10
  which are stdlib-only on this read path.
12
11
  """
13
12
 
13
+ import gzip
14
14
  import json
15
15
  from pathlib import Path
16
16
  from typing import Any
@@ -23,7 +23,7 @@ from simview.columnar import (
23
23
  is_blob,
24
24
  is_columnar,
25
25
  )
26
- from simview.utils import body_label, cap, human_bytes, read_maybe_gzipped_bytes
26
+ from simview.utils import body_label, cap, human_bytes
27
27
 
28
28
  _MAX_DETAIL_ITEMS = 20
29
29
  _LARGE_FILE_BYTES = 100_000_000
@@ -218,9 +218,12 @@ def _summarize_states(states, model: dict | None, warnings: list[str]) -> dict:
218
218
 
219
219
  scalars_summary = {}
220
220
  for name in scalar_names:
221
- missing = [i for i, s in enumerate(states) if name not in s]
221
+ # One pass per scalar: collect missing-frame indices and check
222
+ # lengths in the same walk.
223
+ missing = []
222
224
  for i, s in enumerate(states):
223
225
  if name not in s:
226
+ missing.append(i)
224
227
  continue
225
228
  value = s[name]
226
229
  length = len(value) if isinstance(value, list) else 1
@@ -395,7 +398,10 @@ def summarize_scene(path: str | Path) -> dict:
395
398
  path = Path(path)
396
399
  raw = path.read_bytes()
397
400
  gzipped = raw[:2] == b"\x1f\x8b"
398
- data = json.loads(read_maybe_gzipped_bytes(path))
401
+ # Decompress from the bytes already in hand rather than calling
402
+ # read_maybe_gzipped_bytes(path), which would read the whole file a
403
+ # second time -- scene files are often hundreds of MB.
404
+ data = json.loads(gzip.decompress(raw) if gzipped else raw)
399
405
  if not isinstance(data, dict):
400
406
  raise ValueError(
401
407
  "scene file must contain a JSON object with 'model'/'states' keys"
@@ -7,6 +7,7 @@ import hashlib
7
7
  import json
8
8
  import logging
9
9
  import os
10
+ import re
10
11
  import secrets
11
12
  import threading
12
13
  import time
@@ -23,6 +24,7 @@ from fastapi.middleware.cors import CORSMiddleware
23
24
  from fastapi.responses import FileResponse, JSONResponse
24
25
  from fastapi.staticfiles import StaticFiles
25
26
  from pydantic import BaseModel
27
+ from starlette.datastructures import Headers
26
28
  from starlette.types import Scope
27
29
 
28
30
  from simview.columnar import columnarize_states, is_columnar, rewrite_blobs
@@ -36,7 +38,31 @@ STATIC = str(files("simview").joinpath("static"))
36
38
  # Local-only viewer: CORS is restricted to localhost/127.0.0.1 on any port so a
37
39
  # browser tab open on another local dev server can't be silently allowed, while
38
40
  # still letting the bundled UI (served from the same host) talk to the API.
39
- _ALLOWED_ORIGIN_REGEX = r"^https?://(localhost|127\.0\.0\.1)(:\d+)?$"
41
+ # The host list is shared with the /ws/states origin check (see
42
+ # _LOCAL_ORIGIN_RE below), so the two can't drift apart.
43
+ _LOCAL_HOSTS = r"(localhost|127\.0\.0\.1|\[::1\])"
44
+ _ALLOWED_ORIGIN_REGEX = rf"^https?://{_LOCAL_HOSTS}(:\d+)?$"
45
+
46
+ # The same host list, compiled, for the /ws/states origin check (see
47
+ # _ws_origin_allowed): CORS does not apply to WebSockets, so the endpoint has
48
+ # to check the browser's Origin header itself.
49
+ _LOCAL_ORIGIN_RE = re.compile(rf"^{_LOCAL_HOSTS}(:\d+)?$")
50
+ # A bare address (IPv4 or IPv6 literal, optionally bracketed) -- what a Host
51
+ # header holds when the browser dialed the server by address rather than by a
52
+ # (rebindable) name. Also used by that check.
53
+ _IP_LITERAL_RE = re.compile(r"^\[?[0-9A-Fa-f:.]+\]?$")
54
+
55
+ # Hosts run()/ViewerHandle bind to without a warning (see _warn_public_bind).
56
+ _LOOPBACK_HOSTS = frozenset({"127.0.0.1", "localhost", "::1"})
57
+
58
+ # Hard cap on the declared size of any request body. /batch-names is the only
59
+ # endpoint that accepts one, and a scene's batch names are a handful of short
60
+ # strings, so 1 MiB is orders of magnitude above legitimate use while keeping a
61
+ # rogue POST (or a runaway client) from ballooning memory or the on-disk
62
+ # sidecar.
63
+ _MAX_REQUEST_BODY_BYTES = 1 << 20
64
+ # Per-name cap for POST /batch-names, same reasoning per string.
65
+ _MAX_BATCH_NAME_CHARS = 256
40
66
 
41
67
  # Subdirectories of simview/static that hold vendored, version-pinned third-party
42
68
  # libraries. These never change for a given release, so they get a long-lived,
@@ -101,6 +127,99 @@ def _not_found(what: str) -> JSONResponse:
101
127
  return JSONResponse({"message": f"{what} data not available"}, status_code=404)
102
128
 
103
129
 
130
+ def _warn_public_bind(host: str) -> None:
131
+ """Warn when the server is bound where other machines can reach it.
132
+
133
+ The server has no authentication: anyone who can connect can read the
134
+ scene data and persist batch names to disk next to the scene file. That is
135
+ fine on loopback (the default, and all a personal viewer needs) and a
136
+ deliberate choice on e.g. an HPC login node -- hence a warning, not an
137
+ error.
138
+ """
139
+ if host not in _LOOPBACK_HOSTS:
140
+ logger.warning(
141
+ "Serving on %s with NO authentication: anyone who can reach this "
142
+ "address can read the scene data and write batch names. Only bind "
143
+ "to a shared interface on networks you trust.",
144
+ host,
145
+ )
146
+
147
+
148
+ def _host_of(authority: str) -> str:
149
+ """The host part of a Host-header-style `host[:port]` authority, keeping
150
+ IPv6 brackets intact ('[::1]:5420' -> '[::1]')."""
151
+ if authority.startswith("["):
152
+ return authority.partition("]")[0] + "]"
153
+ return authority.partition(":")[0]
154
+
155
+
156
+ def _ws_origin_allowed(websocket: WebSocket) -> bool:
157
+ """Whether /ws/states should serve this connection.
158
+
159
+ Browsers always send an Origin header on WebSocket connections, and CORS
160
+ does not apply to them, so without this check any site open in the user's
161
+ browser could connect to a running live viewer and read every pushed
162
+ frame. Two origins are allowed:
163
+
164
+ * a localhost one (the viewer itself, another local dev server, the
165
+ Jupyter iframe), matching the CORS list above;
166
+ * a same-origin one -- the Origin's host:port equals the Host header, i.e.
167
+ a page this very server served, connecting back to the address it was
168
+ loaded from. The Host must additionally name an address or 'localhost':
169
+ a hostname that Origin and Host agree on is the DNS-rebinding signature,
170
+ while an address the browser actually dialed is by construction this
171
+ machine. (The trade-off: a live viewer reached by hostname over the LAN
172
+ is refused -- reach it by address, e.g. http://10.0.0.5:5420.)
173
+
174
+ A missing Origin (tests, non-browser tooling) is allowed; browsers never
175
+ omit it.
176
+ """
177
+ origin = websocket.headers.get("origin")
178
+ if origin is None:
179
+ return True
180
+ match = re.match(r"^https?://([^/?#]+)", origin)
181
+ if not match:
182
+ return False
183
+ if _LOCAL_ORIGIN_RE.match(match.group(1)):
184
+ return True
185
+ host_header = websocket.headers.get("host", "")
186
+ if match.group(1) != host_header:
187
+ return False
188
+ host = _host_of(host_header)
189
+ return host == "localhost" or _IP_LITERAL_RE.match(host) is not None
190
+
191
+
192
+ class _BodySizeLimitMiddleware:
193
+ """Rejects requests whose declared body exceeds _MAX_REQUEST_BODY_BYTES.
194
+
195
+ Checks Content-Length and answers 413 before FastAPI/uvicorn buffers and
196
+ parses anything. A chunked request with no Content-Length slips past this
197
+ -- acceptable for a local single-user tool, where the point is bounding
198
+ what a casual or runaway client can push, not defeating a deliberate
199
+ attacker with full protocol control.
200
+ """
201
+
202
+ def __init__(self, app):
203
+ self.app = app
204
+
205
+ async def __call__(self, scope: Scope, receive, send):
206
+ if scope["type"] == "http":
207
+ length = Headers(scope=scope).get("content-length")
208
+ if length is not None and length.isdigit():
209
+ if int(length) > _MAX_REQUEST_BODY_BYTES:
210
+ response = JSONResponse(
211
+ {
212
+ "message": (
213
+ f"Request body exceeds {_MAX_REQUEST_BODY_BYTES} bytes"
214
+ )
215
+ },
216
+ status_code=413,
217
+ )
218
+ await response(scope, receive, send)
219
+ return
220
+ await self.app(scope, receive, send)
221
+
222
+
104
223
  class BatchNamesRequest(BaseModel):
105
224
  names: list[str]
106
225
 
@@ -168,10 +287,15 @@ class SimViewServer:
168
287
  # SimViewServer doesn't need a reference to it. Bounded: on a run
169
288
  # longer than the cap the oldest frames are forgotten, so a late viewer
170
289
  # replays the most recent window instead of the entire history.
171
- self.frame_buffer: deque[dict] = deque(maxlen=frame_buffer_size)
290
+ # Each entry is (absolute frame index, frame): the index rides with
291
+ # the frame so a catch-up never has to reconstruct it from
292
+ # `frames_pushed - len(buffer)`, whose two halves are updated by
293
+ # different threads and could be read mid-update.
294
+ self.frame_buffer: deque[tuple[int, dict]] = deque(maxlen=frame_buffer_size)
172
295
  # Absolute index (in the producer's scene.states) of the next frame to
173
- # enter frame_buffer, so a catch-up can tell the viewer where its
174
- # window starts -- episode startIndex values are absolute too.
296
+ # enter frame_buffer. Maintained by push_frame on the producer's thread
297
+ # and read there only (live.py); catch-up reads the indices stored in
298
+ # the buffer instead.
175
299
  self.frames_pushed = 0
176
300
  self._batch_selections = (
177
301
  None if batch_selections is None else [str(s) for s in batch_selections]
@@ -213,6 +337,10 @@ class SimViewServer:
213
337
  allow_methods=["GET", "POST"],
214
338
  allow_headers=["*"],
215
339
  )
340
+ # Body-size cap for the one endpoint with a request body. Added after
341
+ # CORS above, so it wraps it and rejects oversized payloads before any
342
+ # middleware or handler runs.
343
+ self.app.add_middleware(_BodySizeLimitMiddleware)
216
344
 
217
345
  # Mount static files. StaticFiles adds ETag/Last-Modified headers so
218
346
  # unchanged assets (vendored libs, textures, our own JS) are served
@@ -286,9 +414,13 @@ class SimViewServer:
286
414
  # write dead "/blob/..." URLs, and a second show() would serve stale
287
415
  # blob references). Deep-copying just the model is cheap: the big
288
416
  # payloads are immutable blob strings, which deepcopy shares rather
289
- # than duplicating. `states` is never mutated, so it isn't copied.
417
+ # than duplicating. A columnar `states` doc gets the same treatment
418
+ # below (rewrite_blobs rewrites its inline blobs in place too); the
419
+ # legacy per-frame list is only ever read.
290
420
  if preloaded and model_data is not None:
291
421
  model_data = copy.deepcopy(model_data)
422
+ if preloaded and is_columnar(states_data):
423
+ states_data = copy.deepcopy(states_data)
292
424
 
293
425
  # Taken once, at load: the sidecar must describe the batches that were
294
426
  # actually loaded, not whatever the file is by the time of a rename.
@@ -323,8 +455,11 @@ class SimViewServer:
323
455
  # Random per-load token folded into every blob URL so it's safe to cache
324
456
  # them forever: a later server restart serving a different scene on the
325
457
  # same port gets a different token, so it can never collide with a
326
- # stale cached response for blob id N from a previous load.
327
- self._blob_token = secrets.token_hex(4)
458
+ # stale cached response for blob id N from a previous load. It doubles
459
+ # as the only thing gating /blob responses (the /model payload that
460
+ # lists the URLs is CORS-restricted), so it is 128-bit rather than the
461
+ # few bits a pure cache-buster would need.
462
+ self._blob_token = secrets.token_hex(16)
328
463
 
329
464
  def register_blob(raw: bytes) -> str:
330
465
  blob_id = len(self.blobs)
@@ -461,6 +596,19 @@ class SimViewServer:
461
596
  media_type="application/json",
462
597
  status_code=400,
463
598
  )
599
+ if any(len(name) > _MAX_BATCH_NAME_CHARS for name in names):
600
+ return Response(
601
+ content=json.dumps(
602
+ {
603
+ "message": (
604
+ "Batch names must each be at most "
605
+ f"{_MAX_BATCH_NAME_CHARS} characters"
606
+ )
607
+ }
608
+ ),
609
+ media_type="application/json",
610
+ status_code=400,
611
+ )
464
612
 
465
613
  self.model_data["batchNames"] = names
466
614
  self._pack_model()
@@ -490,6 +638,23 @@ class SimViewServer:
490
638
  # so a viewer opened mid-run still sees the recent timeline.
491
639
  @self.app.websocket("/ws/states")
492
640
  async def ws_states(websocket: WebSocket):
641
+ if not _ws_origin_allowed(websocket):
642
+ # CORS doesn't apply to WebSockets, so the endpoint checks
643
+ # the browser's Origin itself (see _ws_origin_allowed).
644
+ # Rejected during the handshake, before any frame is sent.
645
+ # A warning, not info: the hostname case is a formerly
646
+ # working setup now refused, and this line is the only
647
+ # server-side clue.
648
+ logger.warning(
649
+ "Rejected /ws/states connection with Origin %r: only "
650
+ "localhost and same-origin pages may connect to the "
651
+ "live stream. A viewer opened by hostname must be "
652
+ "opened by its address instead (e.g. "
653
+ "http://10.0.0.5:5420).",
654
+ websocket.headers.get("origin"),
655
+ )
656
+ await websocket.close(code=1008)
657
+ return
493
658
  await websocket.accept()
494
659
  try:
495
660
  await self._send_catchup(websocket)
@@ -524,20 +689,22 @@ class SimViewServer:
524
689
  if not pending:
525
690
  self.ws_clients.add(websocket)
526
691
  return
527
- # Absolute index of buffered[0]: everything pushed minus what the
528
- # (bounded) buffer still holds.
529
- offset = self.frames_pushed - len(buffered) + replayed
530
692
  for start in range(0, len(pending), _CATCHUP_CHUNK_SIZE):
531
693
  chunk = pending[start : start + _CATCHUP_CHUNK_SIZE]
532
694
  await websocket.send_text(
533
- json.dumps({"states": chunk, "frameOffset": offset + start})
695
+ json.dumps(
696
+ {
697
+ "states": [frame for _, frame in chunk],
698
+ "frameOffset": pending[start][0],
699
+ }
700
+ )
534
701
  )
535
702
  replayed += len(pending)
536
703
 
537
704
  def push_frame(self, frame: dict) -> None:
538
705
  """Record one new frame for catch-up (live mode). The caller
539
706
  broadcasts it separately via `broadcast_frame`."""
540
- self.frame_buffer.append(frame)
707
+ self.frame_buffer.append((self.frames_pushed, frame))
541
708
  self.frames_pushed += 1
542
709
 
543
710
  def set_episodes(self, episodes: list[dict]) -> None:
@@ -599,6 +766,7 @@ class SimViewServer:
599
766
  open_browser: bool = False,
600
767
  ):
601
768
  """Serve on `host:port`, blocking until the server stops."""
769
+ _warn_public_bind(host)
602
770
  logger.info("SimView server running on http://%s:%s", host, port)
603
771
  if open_browser:
604
772
  import webbrowser
@@ -693,6 +861,7 @@ class ViewerHandle:
693
861
  self.port = find_free_port(host, preferred_port)
694
862
  self.view = view
695
863
  self.iframe_height = iframe_height
864
+ _warn_public_bind(host)
696
865
 
697
866
  # Prefer uvicorn's modern sansio websocket implementation when the
698
867
  # `websockets` package is available -- the default "auto" still selects
@@ -25,7 +25,7 @@ import { escapeHtml } from "./utils/html.js";
25
25
  import { bytesPerFrame, shouldWindowField } from "./utils/blobWindow.js";
26
26
  import { shouldFollowLive } from "./utils/liveFollow.js";
27
27
  import { shiftEpisodes } from "./utils/episodes.js";
28
- import { parseViewState, parseStartupOptions, serializeViewState, toggleMapFromUiState } from "./utils/viewState.js";
28
+ import { parseViewState, parseStartupOptions, safeStaticBase, serializeViewState, toggleMapFromUiState } from "./utils/viewState.js";
29
29
  import { mergeUiDefaults, applyViewerDomDefaults, panelHideCss } from "./utils/viewerDefaults.js";
30
30
 
31
31
  export class SimView {
@@ -80,10 +80,13 @@ export class SimView {
80
80
  console.log(`SimView: static demo mode, base='${simView.staticBase}'`);
81
81
  }
82
82
  // Startup hash keys (see parseStartupOptions): `data` wins over the global.
83
- // `hide = []`: a browser cache serving an older viewState.js alongside
84
- // this file must degrade to showing the panels, not a broken viewer.
83
+ // The `data` value is validated against this page's origin (see
84
+ // safeStaticBase) -- the hash is attacker-controllable via a crafted
85
+ // link. `hide = []`: a browser cache serving an older viewState.js
86
+ // alongside this file must degrade to showing the panels, not a
87
+ // broken viewer.
85
88
  const { data, ui, play, hide = [] } = parseStartupOptions(location.hash);
86
- if (data) simView.staticBase = data;
89
+ if (data) simView.staticBase = safeStaticBase(data);
87
90
  if (!ui) document.body.classList.add("sv-embed");
88
91
  simView.autoplay = play;
89
92
  // `hide=recording,legend`: same stylesheet mechanism as
@@ -306,6 +309,20 @@ export class SimView {
306
309
  console.log("Live stream closed (simulation finished serving).");
307
310
  this.liveSocket = null;
308
311
  this.hideLiveBadge();
312
+ // The splash is only removed once a frame arrives (above), so it
313
+ // still being attached means the connection died before the run
314
+ // started -- e.g. refused by the server's origin check (a viewer
315
+ // opened by hostname; see /ws/states). Show why instead of leaving
316
+ // "Fetching states…" up forever.
317
+ if (splash && splash.isConnected) {
318
+ splash.innerHTML =
319
+ `<h1 class="sv-splash-error">Couldn't start the live stream</h1>` +
320
+ `<p>${escapeHtml(
321
+ "The server refused the connection. If the viewer was opened by hostname, " +
322
+ "open it by its address instead (e.g. http://10.0.0.5:5420). " +
323
+ "See the browser console and the server log for details.",
324
+ )}</p>`;
325
+ }
309
326
  };
310
327
  socket.onerror = (event) => {
311
328
  console.error("Live stream error:", event);
@@ -364,6 +381,9 @@ export class SimView {
364
381
  if (this.scalarPlotter) {
365
382
  this.scalarPlotter.onStatesAppended();
366
383
  }
384
+ if (this.terrainProfile) {
385
+ this.terrainProfile.onStatesAppended();
386
+ }
367
387
  if (wasFollowingLive) {
368
388
  this.animationController.goToTime(this.store.lastTime());
369
389
  }
@@ -129,6 +129,14 @@ export class TerrainProfile {
129
129
  }
130
130
  }
131
131
 
132
+ // Called by SimView when live streaming appended states -- the cached
133
+ // whole-run series is now stale. Mirrors ErrorMetrics.onHistoryReady:
134
+ // recompute is deferred to the throttled animate() tick (and skipped
135
+ // entirely while hidden -- setVisible recomputes on open).
136
+ onStatesAppended() {
137
+ this.dirty = true;
138
+ }
139
+
132
140
  // Called by AnalysisPanel when this panel becomes/stops being the visible section.
133
141
  setVisible(visible) {
134
142
  if (this.isExpanded === visible) return;
@@ -174,8 +182,9 @@ export class TerrainProfile {
174
182
 
175
183
  // Recomputes the full (whole-timeline) series for the current
176
184
  // layer/body/path selection. This is the expensive step (bilinear
177
- // sampling every frame x every batch) -- only run on open or on a
178
- // control change while open, never per animation frame.
185
+ // sampling every frame x every batch) -- only run on open, on a control
186
+ // change while open, or (live mode) on the throttled animate() tick
187
+ // after new states arrived, never per animation frame.
179
188
  _recompute() {
180
189
  this.dirty = false;
181
190
  const store = this.app.animationController ? this.app.animationController.store : null;
@@ -311,7 +320,8 @@ export class TerrainProfile {
311
320
  if (!this.isExpanded) return;
312
321
  if (now - this.lastRenderTime < this.minRenderDelay) return;
313
322
  this.lastRenderTime = now;
314
- this._updateMarker();
323
+ if (this.dirty) this._recompute();
324
+ else this._updateMarker();
315
325
  }
316
326
 
317
327
  dispose() {
@@ -211,3 +211,24 @@ export function parseStartupOptions(hash) {
211
211
  hide: (params.get("hide") || "").split(",").filter(Boolean),
212
212
  };
213
213
  }
214
+
215
+ // The `data=` startup key, validated: relative URLs (the save_static bundle
216
+ // layout, `#data=./my-scene/`) and absolute URLs on this page's own origin
217
+ // pass; anything cross-origin is ignored with a warning. The hash is
218
+ // attacker-controllable (any crafted link can set it), and without this check
219
+ // such a link would make the viewer fetch and render scene data from an
220
+ // arbitrary server. The `window.__simviewStaticBase` global is set by whoever
221
+ // hosts the page itself, so it stays unrestricted.
222
+ export function safeStaticBase(data) {
223
+ try {
224
+ const url = new URL(data, location.href);
225
+ if (url.origin !== location.origin) {
226
+ console.warn(`SimView: ignoring cross-origin data= '${data}'`);
227
+ return null;
228
+ }
229
+ return data;
230
+ } catch {
231
+ console.warn(`SimView: ignoring invalid data= '${data}'`);
232
+ return null;
233
+ }
234
+ }
@@ -79,11 +79,13 @@ def view_hash(
79
79
  `point_clouds`, `track` (a body name, or ``"None"``), `color_map` and
80
80
  `speed` are carried as named keys; leaving one ``None`` leaves the
81
81
  viewer's current setting alone.
82
- `data` (base URL of a static bundle, see `SimulationScene.save_static`),
83
- `ui=False` (hide all panels), `play=True` (start playing once loaded) and
84
- `hide` (panel names as in `viewerDefaults.panels`, e.g. ``["recording"]``
85
- for the REC/format/screenshot controls) are startup options, not view
86
- state.
82
+ `data` (base URL of a static bundle, see `SimulationScene.save_static`;
83
+ must be same-origin with the viewer page -- cross-origin values are
84
+ ignored by the viewer, which sets ``window.__simviewStaticBase``
85
+ instead), `ui=False` (hide all panels), `play=True` (start playing once
86
+ loaded) and `hide` (panel names as in `viewerDefaults.panels`, e.g.
87
+ ``["recording"]`` for the REC/format/screenshot controls) are startup
88
+ options, not view state.
87
89
  """
88
90
  params = []
89
91
  if t is not None:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: simview
3
- Version: 6.2.3
3
+ Version: 6.3.0
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
@@ -15,6 +15,7 @@ pytest.importorskip("torch")
15
15
  from conftest import build_scene
16
16
 
17
17
  import simview.__main__ as cli
18
+ from simview.columnar import is_columnar
18
19
 
19
20
 
20
21
  def _fail_if_called(*args, **kwargs):
@@ -185,6 +186,9 @@ def test_save_merged_writes_json_without_starting_server(monkeypatch, tmp_path):
185
186
  assert out_path.is_file()
186
187
  merged = json.loads(out_path.read_text())
187
188
  assert merged["model"]["simBatches"] == 3
189
+ # Packed like SimulationScene.save, not the fat per-frame layout merge
190
+ # produces internally.
191
+ assert is_columnar(merged["states"])
188
192
 
189
193
 
190
194
  def test_save_merged_gzips_when_path_ends_in_gz(monkeypatch, tmp_path):
@@ -23,6 +23,7 @@ pytest.importorskip("torch")
23
23
 
24
24
  import torch
25
25
  from fastapi.testclient import TestClient
26
+ from starlette.websockets import WebSocketDisconnect
26
27
 
27
28
  from simview.live import LiveViewer
28
29
  from simview.scene import BodyShapeType, SimulationScene
@@ -126,7 +127,7 @@ def test_frame_buffer_is_bounded():
126
127
  server.push_frame({"time": float(i), "bodies": []})
127
128
 
128
129
  # Oldest frames are forgotten rather than growing without limit.
129
- assert [frame["time"] for frame in server.frame_buffer] == [7.0, 8.0, 9.0]
130
+ assert [frame["time"] for _, frame in server.frame_buffer] == [7.0, 8.0, 9.0]
130
131
 
131
132
 
132
133
  def test_websocket_receives_frame_pushed_after_connect(live_client):
@@ -144,6 +145,65 @@ def test_websocket_receives_frame_pushed_after_connect(live_client):
144
145
  assert message == {"states": [frame], "frameOffset": 0}
145
146
 
146
147
 
148
+ # --- /ws/states origin check --------------------------------------------------
149
+ #
150
+ # CORS does not apply to WebSockets, so the endpoint checks the browser's
151
+ # Origin header itself (see _ws_origin_allowed in server.py): a site open in
152
+ # the user's browser must not be able to connect and read the live stream.
153
+ # The existing tests above double as the "no Origin header" (non-browser
154
+ # client) case, which stays allowed.
155
+
156
+
157
+ def test_websocket_rejects_cross_origin(live_client):
158
+ _server, client = live_client
159
+ with pytest.raises(WebSocketDisconnect):
160
+ with client.websocket_connect(
161
+ "/ws/states", headers={"origin": "http://evil.example"}
162
+ ):
163
+ pass
164
+
165
+
166
+ def test_websocket_rejects_same_origin_hostname_host(live_client):
167
+ # The DNS-rebinding signature: Origin and Host agree, but on a hostname a
168
+ # browser could have re-resolved to loopback. Only an address (or
169
+ # 'localhost') is trusted there.
170
+ _server, client = live_client
171
+ with pytest.raises(WebSocketDisconnect):
172
+ with client.websocket_connect(
173
+ "/ws/states",
174
+ headers={
175
+ "origin": "http://evil.example:5420",
176
+ "host": "evil.example:5420",
177
+ },
178
+ ):
179
+ pass
180
+
181
+
182
+ def test_websocket_accepts_localhost_origin(live_client):
183
+ server, client = live_client
184
+ server.push_frame({"time": 0.0, "bodies": []})
185
+
186
+ with client.websocket_connect(
187
+ "/ws/states", headers={"origin": "http://localhost:3000"}
188
+ ) as ws:
189
+ message = json.loads(ws.receive_text())
190
+ assert message["states"] == [{"time": 0.0, "bodies": []}]
191
+
192
+
193
+ def test_websocket_accepts_same_origin_address_host(live_client):
194
+ # A page this server served, reached by address over the LAN (Host is an
195
+ # IP literal matching the Origin) -- same-origin, so allowed.
196
+ server, client = live_client
197
+ server.push_frame({"time": 0.0, "bodies": []})
198
+
199
+ with client.websocket_connect(
200
+ "/ws/states",
201
+ headers={"origin": "http://10.0.0.5:5420", "host": "10.0.0.5:5420"},
202
+ ) as ws:
203
+ message = json.loads(ws.receive_text())
204
+ assert message["states"] == [{"time": 0.0, "bodies": []}]
205
+
206
+
147
207
  def test_broadcast_drops_dead_connection_without_raising(live_client):
148
208
  server, _client = live_client
149
209
 
@@ -1,4 +1,5 @@
1
1
  import json
2
+ import logging
2
3
  import os
3
4
  import re
4
5
 
@@ -10,7 +11,7 @@ from conftest import build_scene
10
11
  from fastapi import FastAPI
11
12
  from fastapi.testclient import TestClient
12
13
 
13
- from simview.server import SimViewServer
14
+ from simview.server import SimViewServer, _warn_public_bind
14
15
 
15
16
 
16
17
  @pytest.fixture
@@ -151,6 +152,47 @@ def test_batch_names_endpoint_rejects_wrong_length(tmp_path):
151
152
  assert resp.status_code == 400
152
153
 
153
154
 
155
+ def test_batch_names_endpoint_rejects_overlong_names(client):
156
+ # Batch names are persisted to a sidecar next to the scene file; the
157
+ # per-name cap bounds what a single request can write (see
158
+ # _MAX_BATCH_NAME_CHARS). The rejection must also leave the served model
159
+ # untouched.
160
+ too_long = "x" * 257
161
+ resp = client.post("/batch-names", json={"names": ["fine", too_long]})
162
+ assert resp.status_code == 400
163
+ assert "batchNames" not in client.get("/model").json()
164
+
165
+ # Exactly at the cap is fine.
166
+ resp = client.post("/batch-names", json={"names": ["fine", "x" * 256]})
167
+ assert resp.status_code == 200
168
+
169
+
170
+ def test_oversized_request_body_rejected(client):
171
+ # Any endpoint, not just /batch-names: the middleware answers 413 off the
172
+ # Content-Length before anything parses the body.
173
+ resp = client.post("/batch-names", content=b"x" * (2 << 20))
174
+ assert resp.status_code == 413
175
+ # An in-bounds body still reaches the endpoint (here: 422, pydantic).
176
+ resp = client.post("/batch-names", content=b"{}")
177
+ assert resp.status_code == 422
178
+
179
+
180
+ def test_public_bind_warns_only_off_loopback(caplog):
181
+ # The server has no authentication, so binding anywhere other machines
182
+ # can reach must be loud about it; loopback (the default) stays quiet.
183
+ with caplog.at_level(logging.WARNING, logger="simview.server"):
184
+ _warn_public_bind("0.0.0.0")
185
+ _warn_public_bind("hpc-node03")
186
+ assert caplog.text.count("NO authentication") == 2
187
+
188
+ caplog.clear()
189
+ with caplog.at_level(logging.WARNING, logger="simview.server"):
190
+ _warn_public_bind("127.0.0.1")
191
+ _warn_public_bind("localhost")
192
+ _warn_public_bind("::1")
193
+ assert "NO authentication" not in caplog.text
194
+
195
+
154
196
  # --- Gzip support ------------------------------------------------------------
155
197
 
156
198
 
@@ -8,6 +8,7 @@ httpx), rather than FastAPI's in-process TestClient, since the whole point of
8
8
  `show` is that it doesn't block the caller.
9
9
  """
10
10
 
11
+ import json
11
12
  import socket
12
13
 
13
14
  import httpx
@@ -17,7 +18,9 @@ pytest.importorskip("torch")
17
18
 
18
19
  from conftest import build_scene
19
20
 
21
+ from simview.columnar import is_columnar
20
22
  from simview.scene import SimulationScene, ViewerHandle
23
+ from simview.server import SimViewServer
21
24
 
22
25
 
23
26
  def _port_is_free(host: str, port: int) -> bool:
@@ -113,6 +116,23 @@ def test_show_does_not_mutate_the_scene_model():
113
116
  assert len(blob_resp.content) == 5 * 3 * 4 # 5 points x 3 floats x 4 bytes
114
117
 
115
118
 
119
+ def test_preloaded_columnar_states_doc_is_not_mutated(tmp_path):
120
+ """Serving a preloaded columnar `data=` dict must not rewrite its inline
121
+ __b64__ state blobs into /blob/ URLs in place -- the states twin of the
122
+ model regression above (a later save of the same dict would write dead
123
+ references)."""
124
+ scene = build_scene(batch_size=1)
125
+ path = tmp_path / "scene.json"
126
+ scene.save(path)
127
+ doc = json.loads(path.read_text())
128
+ assert is_columnar(doc["states"])
129
+ states_before = json.dumps(doc["states"])
130
+
131
+ SimViewServer(data=doc)
132
+
133
+ assert json.dumps(doc["states"]) == states_before
134
+
135
+
116
136
  def test_concurrent_shows_get_independent_ports():
117
137
  scene_a = build_scene(batch_size=1)
118
138
  scene_b = build_scene(batch_size=1)
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes