simview 6.2.3__tar.gz → 6.3.1__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 (111) hide show
  1. {simview-6.2.3/simview.egg-info → simview-6.3.1}/PKG-INFO +1 -1
  2. {simview-6.2.3 → simview-6.3.1}/pyproject.toml +1 -1
  3. {simview-6.2.3 → simview-6.3.1}/simview/__main__.py +23 -3
  4. {simview-6.2.3 → simview-6.3.1}/simview/info.py +11 -5
  5. simview-6.3.1/simview/preload.py +69 -0
  6. {simview-6.2.3 → simview-6.3.1}/simview/server.py +195 -15
  7. {simview-6.2.3 → simview-6.3.1}/simview/static/js/SimView.js +71 -27
  8. {simview-6.2.3 → simview-6.3.1}/simview/static/js/components/BatchManager.js +9 -1
  9. {simview-6.2.3 → simview-6.3.1}/simview/static/js/components/Scene.js +55 -1
  10. {simview-6.2.3 → simview-6.3.1}/simview/static/js/components/StateStore.js +23 -0
  11. {simview-6.2.3 → simview-6.3.1}/simview/static/js/objects/Terrain.js +46 -17
  12. {simview-6.2.3 → simview-6.3.1}/simview/static/js/ui/TerrainProfile.js +13 -3
  13. {simview-6.2.3 → simview-6.3.1}/simview/static/js/utils/viewState.js +21 -0
  14. {simview-6.2.3 → simview-6.3.1}/simview/templates/index.html +11 -0
  15. {simview-6.2.3 → simview-6.3.1}/simview/view.py +7 -5
  16. {simview-6.2.3 → simview-6.3.1/simview.egg-info}/PKG-INFO +1 -1
  17. {simview-6.2.3 → simview-6.3.1}/simview.egg-info/SOURCES.txt +2 -0
  18. {simview-6.2.3 → simview-6.3.1}/tests/test_cli.py +4 -0
  19. {simview-6.2.3 → simview-6.3.1}/tests/test_live.py +61 -1
  20. simview-6.3.1/tests/test_preload.py +25 -0
  21. {simview-6.2.3 → simview-6.3.1}/tests/test_server.py +43 -1
  22. {simview-6.2.3 → simview-6.3.1}/tests/test_show.py +20 -0
  23. {simview-6.2.3 → simview-6.3.1}/LICENSE +0 -0
  24. {simview-6.2.3 → simview-6.3.1}/MANIFEST.in +0 -0
  25. {simview-6.2.3 → simview-6.3.1}/README.md +0 -0
  26. {simview-6.2.3 → simview-6.3.1}/setup.cfg +0 -0
  27. {simview-6.2.3 → simview-6.3.1}/simview/__init__.py +0 -0
  28. {simview-6.2.3 → simview-6.3.1}/simview/columnar.py +0 -0
  29. {simview-6.2.3 → simview-6.3.1}/simview/diff.py +0 -0
  30. {simview-6.2.3 → simview-6.3.1}/simview/launcher.py +0 -0
  31. {simview-6.2.3 → simview-6.3.1}/simview/live.py +0 -0
  32. {simview-6.2.3 → simview-6.3.1}/simview/merge.py +0 -0
  33. {simview-6.2.3 → simview-6.3.1}/simview/model.py +0 -0
  34. {simview-6.2.3 → simview-6.3.1}/simview/py.typed +0 -0
  35. {simview-6.2.3 → simview-6.3.1}/simview/remote.py +0 -0
  36. {simview-6.2.3 → simview-6.3.1}/simview/render.py +0 -0
  37. {simview-6.2.3 → simview-6.3.1}/simview/scene.py +0 -0
  38. {simview-6.2.3 → simview-6.3.1}/simview/state.py +0 -0
  39. {simview-6.2.3 → simview-6.3.1}/simview/static/css/controls.css +0 -0
  40. {simview-6.2.3 → simview-6.3.1}/simview/static/js/components/AnimationController.js +0 -0
  41. {simview-6.2.3 → simview-6.3.1}/simview/static/js/components/InteractionController.js +0 -0
  42. {simview-6.2.3 → simview-6.3.1}/simview/static/js/components/WindowedField.js +0 -0
  43. {simview-6.2.3 → simview-6.3.1}/simview/static/js/config.js +0 -0
  44. {simview-6.2.3 → simview-6.3.1}/simview/static/js/main.js +0 -0
  45. {simview-6.2.3 → simview-6.3.1}/simview/static/js/objects/Body.js +0 -0
  46. {simview-6.2.3 → simview-6.3.1}/simview/static/js/objects/Polyline.js +0 -0
  47. {simview-6.2.3 → simview-6.3.1}/simview/static/js/objects/StaticObject.js +0 -0
  48. {simview-6.2.3 → simview-6.3.1}/simview/static/js/objects/colormap.js +0 -0
  49. {simview-6.2.3 → simview-6.3.1}/simview/static/js/objects/similarity.js +0 -0
  50. {simview-6.2.3 → simview-6.3.1}/simview/static/js/objects/utils.js +0 -0
  51. {simview-6.2.3 → simview-6.3.1}/simview/static/js/ui/AnalysisPanel.js +0 -0
  52. {simview-6.2.3 → simview-6.3.1}/simview/static/js/ui/BatchLegend.js +0 -0
  53. {simview-6.2.3 → simview-6.3.1}/simview/static/js/ui/BodyStateWindow.js +0 -0
  54. {simview-6.2.3 → simview-6.3.1}/simview/static/js/ui/Controls.js +0 -0
  55. {simview-6.2.3 → simview-6.3.1}/simview/static/js/ui/ErrorMetrics.js +0 -0
  56. {simview-6.2.3 → simview-6.3.1}/simview/static/js/ui/Legend.js +0 -0
  57. {simview-6.2.3 → simview-6.3.1}/simview/static/js/ui/PlaybackControls.js +0 -0
  58. {simview-6.2.3 → simview-6.3.1}/simview/static/js/ui/ScalarPlotter.js +0 -0
  59. {simview-6.2.3 → simview-6.3.1}/simview/static/js/ui/chartControls.js +0 -0
  60. {simview-6.2.3 → simview-6.3.1}/simview/static/js/utils/batchPresets.js +0 -0
  61. {simview-6.2.3 → simview-6.3.1}/simview/static/js/utils/batchVisibility.js +0 -0
  62. {simview-6.2.3 → simview-6.3.1}/simview/static/js/utils/blobCodec.js +0 -0
  63. {simview-6.2.3 → simview-6.3.1}/simview/static/js/utils/blobWindow.js +0 -0
  64. {simview-6.2.3 → simview-6.3.1}/simview/static/js/utils/bodyTransforms.js +0 -0
  65. {simview-6.2.3 → simview-6.3.1}/simview/static/js/utils/cameraRange.js +0 -0
  66. {simview-6.2.3 → simview-6.3.1}/simview/static/js/utils/csv.js +0 -0
  67. {simview-6.2.3 → simview-6.3.1}/simview/static/js/utils/episodes.js +0 -0
  68. {simview-6.2.3 → simview-6.3.1}/simview/static/js/utils/errorMath.js +0 -0
  69. {simview-6.2.3 → simview-6.3.1}/simview/static/js/utils/html.js +0 -0
  70. {simview-6.2.3 → simview-6.3.1}/simview/static/js/utils/interpolate.js +0 -0
  71. {simview-6.2.3 → simview-6.3.1}/simview/static/js/utils/keyboard.js +0 -0
  72. {simview-6.2.3 → simview-6.3.1}/simview/static/js/utils/liveFollow.js +0 -0
  73. {simview-6.2.3 → simview-6.3.1}/simview/static/js/utils/scalarSeries.js +0 -0
  74. {simview-6.2.3 → simview-6.3.1}/simview/static/js/utils/terrainSample.js +0 -0
  75. {simview-6.2.3 → simview-6.3.1}/simview/static/js/utils/uplot.js +0 -0
  76. {simview-6.2.3 → simview-6.3.1}/simview/static/js/utils/viewerDefaults.js +0 -0
  77. {simview-6.2.3 → simview-6.3.1}/simview/static/js/utils/visibleRanges.js +0 -0
  78. {simview-6.2.3 → simview-6.3.1}/simview/static/js/vendor/js-colormaps.js +0 -0
  79. {simview-6.2.3 → simview-6.3.1}/simview/static/lib/three-0.174.0/addons/controls/OrbitControls.js +0 -0
  80. {simview-6.2.3 → simview-6.3.1}/simview/static/lib/three-0.174.0/addons/libs/lil-gui.module.min.js +0 -0
  81. {simview-6.2.3 → simview-6.3.1}/simview/static/lib/three-0.174.0/three.core.js +0 -0
  82. {simview-6.2.3 → simview-6.3.1}/simview/static/lib/three-0.174.0/three.module.js +0 -0
  83. {simview-6.2.3 → simview-6.3.1}/simview/static/lib/uPlot-1.6.32/uPlot.esm.js +0 -0
  84. {simview-6.2.3 → simview-6.3.1}/simview/static/lib/uPlot-1.6.32/uPlot.min.css +0 -0
  85. {simview-6.2.3 → simview-6.3.1}/simview/static/textures/contacts/red-cross0.png +0 -0
  86. {simview-6.2.3 → simview-6.3.1}/simview/static/textures/points/ball1.png +0 -0
  87. {simview-6.2.3 → simview-6.3.1}/simview/terrain.py +0 -0
  88. {simview-6.2.3 → simview-6.3.1}/simview/utils.py +0 -0
  89. {simview-6.2.3 → simview-6.3.1}/simview.egg-info/dependency_links.txt +0 -0
  90. {simview-6.2.3 → simview-6.3.1}/simview.egg-info/entry_points.txt +0 -0
  91. {simview-6.2.3 → simview-6.3.1}/simview.egg-info/requires.txt +0 -0
  92. {simview-6.2.3 → simview-6.3.1}/simview.egg-info/top_level.txt +0 -0
  93. {simview-6.2.3 → simview-6.3.1}/tests/test_batch_selection.py +0 -0
  94. {simview-6.2.3 → simview-6.3.1}/tests/test_columnar_ondisk.py +0 -0
  95. {simview-6.2.3 → simview-6.3.1}/tests/test_columnar_states.py +0 -0
  96. {simview-6.2.3 → simview-6.3.1}/tests/test_contacts.py +0 -0
  97. {simview-6.2.3 → simview-6.3.1}/tests/test_diff.py +0 -0
  98. {simview-6.2.3 → simview-6.3.1}/tests/test_embed_defaults.py +0 -0
  99. {simview-6.2.3 → simview-6.3.1}/tests/test_episodes.py +0 -0
  100. {simview-6.2.3 → simview-6.3.1}/tests/test_info.py +0 -0
  101. {simview-6.2.3 → simview-6.3.1}/tests/test_launcher.py +0 -0
  102. {simview-6.2.3 → simview-6.3.1}/tests/test_merge.py +0 -0
  103. {simview-6.2.3 → simview-6.3.1}/tests/test_pointcloud_features.py +0 -0
  104. {simview-6.2.3 → simview-6.3.1}/tests/test_remote.py +0 -0
  105. {simview-6.2.3 → simview-6.3.1}/tests/test_roundtrip.py +0 -0
  106. {simview-6.2.3 → simview-6.3.1}/tests/test_scene.py +0 -0
  107. {simview-6.2.3 → simview-6.3.1}/tests/test_terrain.py +0 -0
  108. {simview-6.2.3 → simview-6.3.1}/tests/test_terrain_query.py +0 -0
  109. {simview-6.2.3 → simview-6.3.1}/tests/test_trajectory.py +0 -0
  110. {simview-6.2.3 → simview-6.3.1}/tests/test_utils.py +0 -0
  111. {simview-6.2.3 → simview-6.3.1}/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.1
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.1"
8
8
  readme = "README.md"
9
9
  requires-python = ">=3.12"
10
10
  license = "BSD-3-Clause"
@@ -82,8 +82,14 @@ def export_static_viewer(directory: Path) -> None:
82
82
 
83
83
  package = files("simview")
84
84
  directory.mkdir(parents=True, exist_ok=True)
85
- shutil.copy(str(package / "templates" / "index.html"), directory / "index.html")
86
- shutil.copytree(str(package / "static"), directory / "static", dirs_exist_ok=True)
85
+ from simview.preload import render_index
86
+
87
+ index = package / "templates" / "index.html"
88
+ static = package / "static"
89
+ (directory / "index.html").write_text(
90
+ render_index(str(index), str(package)), encoding="utf-8"
91
+ )
92
+ shutil.copytree(str(static), directory / "static", dirs_exist_ok=True)
87
93
  logger.info("Static viewer written to %s", directory)
88
94
 
89
95
 
@@ -297,9 +303,18 @@ def save_merged(
297
303
  logger.error("Error: --save-merged requires at least 2 input files to merge.")
298
304
  sys.exit(1)
299
305
 
306
+ from simview.columnar import columnarize_states, inline_blob
300
307
  from simview.merge import merge_simulation_files
301
308
 
302
309
  merged = merge_simulation_files(paths, selections)
310
+ # Pack the merged states the same way SimulationScene.save does: merge
311
+ # decodes every blob to plain lists, so dumping it directly would write
312
+ # the fat legacy per-frame layout. Falls back silently when numpy is
313
+ # unavailable or the states are too irregular (columnarize_states
314
+ # returns None).
315
+ columnar_states = columnarize_states(merged["states"], merged["model"], inline_blob)
316
+ if columnar_states is not None:
317
+ merged["states"] = columnar_states
303
318
  payload = json.dumps(merged).encode("utf-8")
304
319
  if out_path.suffix == ".gz":
305
320
  payload = gzip.compress(payload, compresslevel=1)
@@ -372,7 +387,12 @@ def _add_host_port_flags(sp: argparse.ArgumentParser) -> None:
372
387
  "--host",
373
388
  type=str,
374
389
  default="127.0.0.1",
375
- help="Host/interface for the server to bind to (default: 127.0.0.1).",
390
+ help=(
391
+ "Host/interface for the server to bind to (default: 127.0.0.1). "
392
+ "Note: the server has no authentication, so binding to a "
393
+ "non-loopback address exposes the scene data to everyone who can "
394
+ "reach it."
395
+ ),
376
396
  )
377
397
  sp.add_argument(
378
398
  "--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"
@@ -0,0 +1,69 @@
1
+ """`<link rel="modulepreload">` tags for the viewer page.
2
+
3
+ The browser only discovers an ES module's imports after it has fetched and
4
+ parsed the importer, so the ~50-file module graph downloads one dependency
5
+ level at a time. Listing every module in the page head lets them all start at
6
+ once (a ~50 ms round trip each otherwise adds up level by level).
7
+ """
8
+
9
+ import json
10
+ import re
11
+ from functools import cache
12
+ from pathlib import Path
13
+
14
+ # `import ... from "x"`, `export ... from "x"` and side-effect `import "x"`.
15
+ # Dynamic import() is skipped: those modules are lazy on purpose.
16
+ _STATIC_IMPORT = re.compile(
17
+ r"""(?:^|[;\n])\s*(?:import|export)\b[^;'"]*?(?:\bfrom\s*)?["']([^"']+)["']\s*;?""",
18
+ )
19
+ _IMPORTMAP = re.compile(r'<script type="importmap">(.*?)</script>', re.S)
20
+ PLACEHOLDER = "<!-- @modulepreload -->"
21
+
22
+
23
+ def _resolve(
24
+ spec: str, importer: Path, root: Path, imports: dict[str, str]
25
+ ) -> Path | None:
26
+ """Filesystem path `spec` points at, or None for anything outside `root`."""
27
+ if spec.startswith("."):
28
+ target = importer.parent / spec
29
+ else:
30
+ # Bare specifier -> import map; "prefix/" entries map whole directories.
31
+ for key, value in imports.items():
32
+ if spec == key or (key.endswith("/") and spec.startswith(key)):
33
+ target = root / (value + spec[len(key) :])
34
+ break
35
+ else:
36
+ return None
37
+ target = target.resolve()
38
+ return target if target.is_file() and root in target.parents else None
39
+
40
+
41
+ def module_graph(html: str, root: Path, entry: str = "static/js/main.js") -> list[str]:
42
+ """Page-relative URLs (`./static/...`) of every module reachable from `entry`."""
43
+ match = _IMPORTMAP.search(html)
44
+ imports = json.loads(match.group(1))["imports"] if match else {}
45
+ imports = {k: v.removeprefix("./") for k, v in imports.items()}
46
+ root = root.resolve()
47
+ seen: dict[Path, None] = {}
48
+ todo = [(root / entry).resolve()]
49
+ while todo:
50
+ path = todo.pop()
51
+ if path in seen:
52
+ continue
53
+ seen[path] = None
54
+ for spec in _STATIC_IMPORT.findall(path.read_text(encoding="utf-8")):
55
+ dep = _resolve(spec, path, root, imports)
56
+ if dep is not None:
57
+ todo.append(dep)
58
+ return ["./" + p.relative_to(root).as_posix() for p in seen]
59
+
60
+
61
+ @cache
62
+ def render_index(index_path: str, root: str) -> str:
63
+ """index.html with its modulepreload placeholder filled in."""
64
+ html = Path(index_path).read_text(encoding="utf-8")
65
+ links = "\n ".join(
66
+ f'<link rel="modulepreload" href="{url}" />'
67
+ for url in module_graph(html, Path(root))
68
+ )
69
+ return html.replace(PLACEHOLDER, links)
@@ -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
@@ -20,12 +21,15 @@ import orjson
20
21
  import uvicorn
21
22
  from fastapi import FastAPI, Request, Response, WebSocket, WebSocketDisconnect
22
23
  from fastapi.middleware.cors import CORSMiddleware
23
- from fastapi.responses import FileResponse, JSONResponse
24
+ from fastapi.responses import HTMLResponse, JSONResponse
24
25
  from fastapi.staticfiles import StaticFiles
25
26
  from pydantic import BaseModel
27
+ from starlette.datastructures import Headers
28
+ from starlette.middleware.gzip import GZipMiddleware
26
29
  from starlette.types import Scope
27
30
 
28
31
  from simview.columnar import columnarize_states, is_columnar, rewrite_blobs
32
+ from simview.preload import render_index
29
33
  from simview.utils import dialable_host, find_free_port, read_maybe_gzipped_bytes
30
34
 
31
35
  logger = logging.getLogger("simview.server")
@@ -36,7 +40,31 @@ STATIC = str(files("simview").joinpath("static"))
36
40
  # Local-only viewer: CORS is restricted to localhost/127.0.0.1 on any port so a
37
41
  # browser tab open on another local dev server can't be silently allowed, while
38
42
  # 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+)?$"
43
+ # The host list is shared with the /ws/states origin check (see
44
+ # _LOCAL_ORIGIN_RE below), so the two can't drift apart.
45
+ _LOCAL_HOSTS = r"(localhost|127\.0\.0\.1|\[::1\])"
46
+ _ALLOWED_ORIGIN_REGEX = rf"^https?://{_LOCAL_HOSTS}(:\d+)?$"
47
+
48
+ # The same host list, compiled, for the /ws/states origin check (see
49
+ # _ws_origin_allowed): CORS does not apply to WebSockets, so the endpoint has
50
+ # to check the browser's Origin header itself.
51
+ _LOCAL_ORIGIN_RE = re.compile(rf"^{_LOCAL_HOSTS}(:\d+)?$")
52
+ # A bare address (IPv4 or IPv6 literal, optionally bracketed) -- what a Host
53
+ # header holds when the browser dialed the server by address rather than by a
54
+ # (rebindable) name. Also used by that check.
55
+ _IP_LITERAL_RE = re.compile(r"^\[?[0-9A-Fa-f:.]+\]?$")
56
+
57
+ # Hosts run()/ViewerHandle bind to without a warning (see _warn_public_bind).
58
+ _LOOPBACK_HOSTS = frozenset({"127.0.0.1", "localhost", "::1"})
59
+
60
+ # Hard cap on the declared size of any request body. /batch-names is the only
61
+ # endpoint that accepts one, and a scene's batch names are a handful of short
62
+ # strings, so 1 MiB is orders of magnitude above legitimate use while keeping a
63
+ # rogue POST (or a runaway client) from ballooning memory or the on-disk
64
+ # sidecar.
65
+ _MAX_REQUEST_BODY_BYTES = 1 << 20
66
+ # Per-name cap for POST /batch-names, same reasoning per string.
67
+ _MAX_BATCH_NAME_CHARS = 256
40
68
 
41
69
  # Subdirectories of simview/static that hold vendored, version-pinned third-party
42
70
  # libraries. These never change for a given release, so they get a long-lived,
@@ -101,6 +129,99 @@ def _not_found(what: str) -> JSONResponse:
101
129
  return JSONResponse({"message": f"{what} data not available"}, status_code=404)
102
130
 
103
131
 
132
+ def _warn_public_bind(host: str) -> None:
133
+ """Warn when the server is bound where other machines can reach it.
134
+
135
+ The server has no authentication: anyone who can connect can read the
136
+ scene data and persist batch names to disk next to the scene file. That is
137
+ fine on loopback (the default, and all a personal viewer needs) and a
138
+ deliberate choice on e.g. an HPC login node -- hence a warning, not an
139
+ error.
140
+ """
141
+ if host not in _LOOPBACK_HOSTS:
142
+ logger.warning(
143
+ "Serving on %s with NO authentication: anyone who can reach this "
144
+ "address can read the scene data and write batch names. Only bind "
145
+ "to a shared interface on networks you trust.",
146
+ host,
147
+ )
148
+
149
+
150
+ def _host_of(authority: str) -> str:
151
+ """The host part of a Host-header-style `host[:port]` authority, keeping
152
+ IPv6 brackets intact ('[::1]:5420' -> '[::1]')."""
153
+ if authority.startswith("["):
154
+ return authority.partition("]")[0] + "]"
155
+ return authority.partition(":")[0]
156
+
157
+
158
+ def _ws_origin_allowed(websocket: WebSocket) -> bool:
159
+ """Whether /ws/states should serve this connection.
160
+
161
+ Browsers always send an Origin header on WebSocket connections, and CORS
162
+ does not apply to them, so without this check any site open in the user's
163
+ browser could connect to a running live viewer and read every pushed
164
+ frame. Two origins are allowed:
165
+
166
+ * a localhost one (the viewer itself, another local dev server, the
167
+ Jupyter iframe), matching the CORS list above;
168
+ * a same-origin one -- the Origin's host:port equals the Host header, i.e.
169
+ a page this very server served, connecting back to the address it was
170
+ loaded from. The Host must additionally name an address or 'localhost':
171
+ a hostname that Origin and Host agree on is the DNS-rebinding signature,
172
+ while an address the browser actually dialed is by construction this
173
+ machine. (The trade-off: a live viewer reached by hostname over the LAN
174
+ is refused -- reach it by address, e.g. http://10.0.0.5:5420.)
175
+
176
+ A missing Origin (tests, non-browser tooling) is allowed; browsers never
177
+ omit it.
178
+ """
179
+ origin = websocket.headers.get("origin")
180
+ if origin is None:
181
+ return True
182
+ match = re.match(r"^https?://([^/?#]+)", origin)
183
+ if not match:
184
+ return False
185
+ if _LOCAL_ORIGIN_RE.match(match.group(1)):
186
+ return True
187
+ host_header = websocket.headers.get("host", "")
188
+ if match.group(1) != host_header:
189
+ return False
190
+ host = _host_of(host_header)
191
+ return host == "localhost" or _IP_LITERAL_RE.match(host) is not None
192
+
193
+
194
+ class _BodySizeLimitMiddleware:
195
+ """Rejects requests whose declared body exceeds _MAX_REQUEST_BODY_BYTES.
196
+
197
+ Checks Content-Length and answers 413 before FastAPI/uvicorn buffers and
198
+ parses anything. A chunked request with no Content-Length slips past this
199
+ -- acceptable for a local single-user tool, where the point is bounding
200
+ what a casual or runaway client can push, not defeating a deliberate
201
+ attacker with full protocol control.
202
+ """
203
+
204
+ def __init__(self, app):
205
+ self.app = app
206
+
207
+ async def __call__(self, scope: Scope, receive, send):
208
+ if scope["type"] == "http":
209
+ length = Headers(scope=scope).get("content-length")
210
+ if length is not None and length.isdigit():
211
+ if int(length) > _MAX_REQUEST_BODY_BYTES:
212
+ response = JSONResponse(
213
+ {
214
+ "message": (
215
+ f"Request body exceeds {_MAX_REQUEST_BODY_BYTES} bytes"
216
+ )
217
+ },
218
+ status_code=413,
219
+ )
220
+ await response(scope, receive, send)
221
+ return
222
+ await self.app(scope, receive, send)
223
+
224
+
104
225
  class BatchNamesRequest(BaseModel):
105
226
  names: list[str]
106
227
 
@@ -168,10 +289,15 @@ class SimViewServer:
168
289
  # SimViewServer doesn't need a reference to it. Bounded: on a run
169
290
  # longer than the cap the oldest frames are forgotten, so a late viewer
170
291
  # replays the most recent window instead of the entire history.
171
- self.frame_buffer: deque[dict] = deque(maxlen=frame_buffer_size)
292
+ # Each entry is (absolute frame index, frame): the index rides with
293
+ # the frame so a catch-up never has to reconstruct it from
294
+ # `frames_pushed - len(buffer)`, whose two halves are updated by
295
+ # different threads and could be read mid-update.
296
+ self.frame_buffer: deque[tuple[int, dict]] = deque(maxlen=frame_buffer_size)
172
297
  # 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.
298
+ # enter frame_buffer. Maintained by push_frame on the producer's thread
299
+ # and read there only (live.py); catch-up reads the indices stored in
300
+ # the buffer instead.
175
301
  self.frames_pushed = 0
176
302
  self._batch_selections = (
177
303
  None if batch_selections is None else [str(s) for s in batch_selections]
@@ -213,6 +339,10 @@ class SimViewServer:
213
339
  allow_methods=["GET", "POST"],
214
340
  allow_headers=["*"],
215
341
  )
342
+ # Body-size cap for the one endpoint with a request body. Added after
343
+ # CORS above, so it wraps it and rejects oversized payloads before any
344
+ # middleware or handler runs.
345
+ self.app.add_middleware(_BodySizeLimitMiddleware)
216
346
 
217
347
  # Mount static files. StaticFiles adds ETag/Last-Modified headers so
218
348
  # unchanged assets (vendored libs, textures, our own JS) are served
@@ -220,8 +350,17 @@ class SimViewServer:
220
350
  # libs as immutable and everything else short-lived (max-age=60), so
221
351
  # index.html's fixed ./static/... paths never need a cache-busting
222
352
  # query param.
353
+ # Gzipped on the fly (three.js alone is ~1.1 MB raw, ~230 KB gzipped);
354
+ # applied to /static only -- /blob is float data that barely
355
+ # compresses and is served with Range support.
223
356
  self.app.mount(
224
- "/static", CacheControlStaticFiles(directory=STATIC), name="static"
357
+ "/static",
358
+ GZipMiddleware(
359
+ CacheControlStaticFiles(directory=STATIC),
360
+ minimum_size=1024,
361
+ compresslevel=6,
362
+ ),
363
+ name="static",
225
364
  )
226
365
 
227
366
  # Pre-serialized, gzipped payloads for HTTP serving. The parsed dicts are
@@ -286,9 +425,13 @@ class SimViewServer:
286
425
  # write dead "/blob/..." URLs, and a second show() would serve stale
287
426
  # blob references). Deep-copying just the model is cheap: the big
288
427
  # payloads are immutable blob strings, which deepcopy shares rather
289
- # than duplicating. `states` is never mutated, so it isn't copied.
428
+ # than duplicating. A columnar `states` doc gets the same treatment
429
+ # below (rewrite_blobs rewrites its inline blobs in place too); the
430
+ # legacy per-frame list is only ever read.
290
431
  if preloaded and model_data is not None:
291
432
  model_data = copy.deepcopy(model_data)
433
+ if preloaded and is_columnar(states_data):
434
+ states_data = copy.deepcopy(states_data)
292
435
 
293
436
  # Taken once, at load: the sidecar must describe the batches that were
294
437
  # actually loaded, not whatever the file is by the time of a rename.
@@ -323,8 +466,11 @@ class SimViewServer:
323
466
  # Random per-load token folded into every blob URL so it's safe to cache
324
467
  # them forever: a later server restart serving a different scene on the
325
468
  # 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)
469
+ # stale cached response for blob id N from a previous load. It doubles
470
+ # as the only thing gating /blob responses (the /model payload that
471
+ # lists the URLs is CORS-restricted), so it is 128-bit rather than the
472
+ # few bits a pure cache-buster would need.
473
+ self._blob_token = secrets.token_hex(16)
328
474
 
329
475
  def register_blob(raw: bytes) -> str:
330
476
  blob_id = len(self.blobs)
@@ -386,7 +532,7 @@ class SimViewServer:
386
532
  def setup_routes(self):
387
533
  @self.app.get("/")
388
534
  async def index():
389
- return FileResponse(INDEX_HTML, media_type="text/html")
535
+ return HTMLResponse(render_index(INDEX_HTML, str(Path(STATIC).parent)))
390
536
 
391
537
  _gzip_headers = {"Content-Encoding": "gzip"}
392
538
 
@@ -461,6 +607,19 @@ class SimViewServer:
461
607
  media_type="application/json",
462
608
  status_code=400,
463
609
  )
610
+ if any(len(name) > _MAX_BATCH_NAME_CHARS for name in names):
611
+ return Response(
612
+ content=json.dumps(
613
+ {
614
+ "message": (
615
+ "Batch names must each be at most "
616
+ f"{_MAX_BATCH_NAME_CHARS} characters"
617
+ )
618
+ }
619
+ ),
620
+ media_type="application/json",
621
+ status_code=400,
622
+ )
464
623
 
465
624
  self.model_data["batchNames"] = names
466
625
  self._pack_model()
@@ -490,6 +649,23 @@ class SimViewServer:
490
649
  # so a viewer opened mid-run still sees the recent timeline.
491
650
  @self.app.websocket("/ws/states")
492
651
  async def ws_states(websocket: WebSocket):
652
+ if not _ws_origin_allowed(websocket):
653
+ # CORS doesn't apply to WebSockets, so the endpoint checks
654
+ # the browser's Origin itself (see _ws_origin_allowed).
655
+ # Rejected during the handshake, before any frame is sent.
656
+ # A warning, not info: the hostname case is a formerly
657
+ # working setup now refused, and this line is the only
658
+ # server-side clue.
659
+ logger.warning(
660
+ "Rejected /ws/states connection with Origin %r: only "
661
+ "localhost and same-origin pages may connect to the "
662
+ "live stream. A viewer opened by hostname must be "
663
+ "opened by its address instead (e.g. "
664
+ "http://10.0.0.5:5420).",
665
+ websocket.headers.get("origin"),
666
+ )
667
+ await websocket.close(code=1008)
668
+ return
493
669
  await websocket.accept()
494
670
  try:
495
671
  await self._send_catchup(websocket)
@@ -524,20 +700,22 @@ class SimViewServer:
524
700
  if not pending:
525
701
  self.ws_clients.add(websocket)
526
702
  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
703
  for start in range(0, len(pending), _CATCHUP_CHUNK_SIZE):
531
704
  chunk = pending[start : start + _CATCHUP_CHUNK_SIZE]
532
705
  await websocket.send_text(
533
- json.dumps({"states": chunk, "frameOffset": offset + start})
706
+ json.dumps(
707
+ {
708
+ "states": [frame for _, frame in chunk],
709
+ "frameOffset": pending[start][0],
710
+ }
711
+ )
534
712
  )
535
713
  replayed += len(pending)
536
714
 
537
715
  def push_frame(self, frame: dict) -> None:
538
716
  """Record one new frame for catch-up (live mode). The caller
539
717
  broadcasts it separately via `broadcast_frame`."""
540
- self.frame_buffer.append(frame)
718
+ self.frame_buffer.append((self.frames_pushed, frame))
541
719
  self.frames_pushed += 1
542
720
 
543
721
  def set_episodes(self, episodes: list[dict]) -> None:
@@ -599,6 +777,7 @@ class SimViewServer:
599
777
  open_browser: bool = False,
600
778
  ):
601
779
  """Serve on `host:port`, blocking until the server stops."""
780
+ _warn_public_bind(host)
602
781
  logger.info("SimView server running on http://%s:%s", host, port)
603
782
  if open_browser:
604
783
  import webbrowser
@@ -693,6 +872,7 @@ class ViewerHandle:
693
872
  self.port = find_free_port(host, preferred_port)
694
873
  self.view = view
695
874
  self.iframe_height = iframe_height
875
+ _warn_public_bind(host)
696
876
 
697
877
  # Prefer uvicorn's modern sansio websocket implementation when the
698
878
  # `websockets` package is available -- the default "auto" still selects