simview 6.3.0__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.3.0/simview.egg-info → simview-6.3.1}/PKG-INFO +1 -1
  2. {simview-6.3.0 → simview-6.3.1}/pyproject.toml +1 -1
  3. {simview-6.3.0 → simview-6.3.1}/simview/__main__.py +8 -2
  4. simview-6.3.1/simview/preload.py +69 -0
  5. {simview-6.3.0 → simview-6.3.1}/simview/server.py +14 -3
  6. {simview-6.3.0 → simview-6.3.1}/simview/static/js/SimView.js +47 -23
  7. {simview-6.3.0 → simview-6.3.1}/simview/static/js/components/BatchManager.js +9 -1
  8. {simview-6.3.0 → simview-6.3.1}/simview/static/js/components/Scene.js +55 -1
  9. {simview-6.3.0 → simview-6.3.1}/simview/static/js/components/StateStore.js +23 -0
  10. {simview-6.3.0 → simview-6.3.1}/simview/static/js/objects/Terrain.js +46 -17
  11. {simview-6.3.0 → simview-6.3.1}/simview/templates/index.html +11 -0
  12. {simview-6.3.0 → simview-6.3.1/simview.egg-info}/PKG-INFO +1 -1
  13. {simview-6.3.0 → simview-6.3.1}/simview.egg-info/SOURCES.txt +2 -0
  14. simview-6.3.1/tests/test_preload.py +25 -0
  15. {simview-6.3.0 → simview-6.3.1}/LICENSE +0 -0
  16. {simview-6.3.0 → simview-6.3.1}/MANIFEST.in +0 -0
  17. {simview-6.3.0 → simview-6.3.1}/README.md +0 -0
  18. {simview-6.3.0 → simview-6.3.1}/setup.cfg +0 -0
  19. {simview-6.3.0 → simview-6.3.1}/simview/__init__.py +0 -0
  20. {simview-6.3.0 → simview-6.3.1}/simview/columnar.py +0 -0
  21. {simview-6.3.0 → simview-6.3.1}/simview/diff.py +0 -0
  22. {simview-6.3.0 → simview-6.3.1}/simview/info.py +0 -0
  23. {simview-6.3.0 → simview-6.3.1}/simview/launcher.py +0 -0
  24. {simview-6.3.0 → simview-6.3.1}/simview/live.py +0 -0
  25. {simview-6.3.0 → simview-6.3.1}/simview/merge.py +0 -0
  26. {simview-6.3.0 → simview-6.3.1}/simview/model.py +0 -0
  27. {simview-6.3.0 → simview-6.3.1}/simview/py.typed +0 -0
  28. {simview-6.3.0 → simview-6.3.1}/simview/remote.py +0 -0
  29. {simview-6.3.0 → simview-6.3.1}/simview/render.py +0 -0
  30. {simview-6.3.0 → simview-6.3.1}/simview/scene.py +0 -0
  31. {simview-6.3.0 → simview-6.3.1}/simview/state.py +0 -0
  32. {simview-6.3.0 → simview-6.3.1}/simview/static/css/controls.css +0 -0
  33. {simview-6.3.0 → simview-6.3.1}/simview/static/js/components/AnimationController.js +0 -0
  34. {simview-6.3.0 → simview-6.3.1}/simview/static/js/components/InteractionController.js +0 -0
  35. {simview-6.3.0 → simview-6.3.1}/simview/static/js/components/WindowedField.js +0 -0
  36. {simview-6.3.0 → simview-6.3.1}/simview/static/js/config.js +0 -0
  37. {simview-6.3.0 → simview-6.3.1}/simview/static/js/main.js +0 -0
  38. {simview-6.3.0 → simview-6.3.1}/simview/static/js/objects/Body.js +0 -0
  39. {simview-6.3.0 → simview-6.3.1}/simview/static/js/objects/Polyline.js +0 -0
  40. {simview-6.3.0 → simview-6.3.1}/simview/static/js/objects/StaticObject.js +0 -0
  41. {simview-6.3.0 → simview-6.3.1}/simview/static/js/objects/colormap.js +0 -0
  42. {simview-6.3.0 → simview-6.3.1}/simview/static/js/objects/similarity.js +0 -0
  43. {simview-6.3.0 → simview-6.3.1}/simview/static/js/objects/utils.js +0 -0
  44. {simview-6.3.0 → simview-6.3.1}/simview/static/js/ui/AnalysisPanel.js +0 -0
  45. {simview-6.3.0 → simview-6.3.1}/simview/static/js/ui/BatchLegend.js +0 -0
  46. {simview-6.3.0 → simview-6.3.1}/simview/static/js/ui/BodyStateWindow.js +0 -0
  47. {simview-6.3.0 → simview-6.3.1}/simview/static/js/ui/Controls.js +0 -0
  48. {simview-6.3.0 → simview-6.3.1}/simview/static/js/ui/ErrorMetrics.js +0 -0
  49. {simview-6.3.0 → simview-6.3.1}/simview/static/js/ui/Legend.js +0 -0
  50. {simview-6.3.0 → simview-6.3.1}/simview/static/js/ui/PlaybackControls.js +0 -0
  51. {simview-6.3.0 → simview-6.3.1}/simview/static/js/ui/ScalarPlotter.js +0 -0
  52. {simview-6.3.0 → simview-6.3.1}/simview/static/js/ui/TerrainProfile.js +0 -0
  53. {simview-6.3.0 → simview-6.3.1}/simview/static/js/ui/chartControls.js +0 -0
  54. {simview-6.3.0 → simview-6.3.1}/simview/static/js/utils/batchPresets.js +0 -0
  55. {simview-6.3.0 → simview-6.3.1}/simview/static/js/utils/batchVisibility.js +0 -0
  56. {simview-6.3.0 → simview-6.3.1}/simview/static/js/utils/blobCodec.js +0 -0
  57. {simview-6.3.0 → simview-6.3.1}/simview/static/js/utils/blobWindow.js +0 -0
  58. {simview-6.3.0 → simview-6.3.1}/simview/static/js/utils/bodyTransforms.js +0 -0
  59. {simview-6.3.0 → simview-6.3.1}/simview/static/js/utils/cameraRange.js +0 -0
  60. {simview-6.3.0 → simview-6.3.1}/simview/static/js/utils/csv.js +0 -0
  61. {simview-6.3.0 → simview-6.3.1}/simview/static/js/utils/episodes.js +0 -0
  62. {simview-6.3.0 → simview-6.3.1}/simview/static/js/utils/errorMath.js +0 -0
  63. {simview-6.3.0 → simview-6.3.1}/simview/static/js/utils/html.js +0 -0
  64. {simview-6.3.0 → simview-6.3.1}/simview/static/js/utils/interpolate.js +0 -0
  65. {simview-6.3.0 → simview-6.3.1}/simview/static/js/utils/keyboard.js +0 -0
  66. {simview-6.3.0 → simview-6.3.1}/simview/static/js/utils/liveFollow.js +0 -0
  67. {simview-6.3.0 → simview-6.3.1}/simview/static/js/utils/scalarSeries.js +0 -0
  68. {simview-6.3.0 → simview-6.3.1}/simview/static/js/utils/terrainSample.js +0 -0
  69. {simview-6.3.0 → simview-6.3.1}/simview/static/js/utils/uplot.js +0 -0
  70. {simview-6.3.0 → simview-6.3.1}/simview/static/js/utils/viewState.js +0 -0
  71. {simview-6.3.0 → simview-6.3.1}/simview/static/js/utils/viewerDefaults.js +0 -0
  72. {simview-6.3.0 → simview-6.3.1}/simview/static/js/utils/visibleRanges.js +0 -0
  73. {simview-6.3.0 → simview-6.3.1}/simview/static/js/vendor/js-colormaps.js +0 -0
  74. {simview-6.3.0 → simview-6.3.1}/simview/static/lib/three-0.174.0/addons/controls/OrbitControls.js +0 -0
  75. {simview-6.3.0 → simview-6.3.1}/simview/static/lib/three-0.174.0/addons/libs/lil-gui.module.min.js +0 -0
  76. {simview-6.3.0 → simview-6.3.1}/simview/static/lib/three-0.174.0/three.core.js +0 -0
  77. {simview-6.3.0 → simview-6.3.1}/simview/static/lib/three-0.174.0/three.module.js +0 -0
  78. {simview-6.3.0 → simview-6.3.1}/simview/static/lib/uPlot-1.6.32/uPlot.esm.js +0 -0
  79. {simview-6.3.0 → simview-6.3.1}/simview/static/lib/uPlot-1.6.32/uPlot.min.css +0 -0
  80. {simview-6.3.0 → simview-6.3.1}/simview/static/textures/contacts/red-cross0.png +0 -0
  81. {simview-6.3.0 → simview-6.3.1}/simview/static/textures/points/ball1.png +0 -0
  82. {simview-6.3.0 → simview-6.3.1}/simview/terrain.py +0 -0
  83. {simview-6.3.0 → simview-6.3.1}/simview/utils.py +0 -0
  84. {simview-6.3.0 → simview-6.3.1}/simview/view.py +0 -0
  85. {simview-6.3.0 → simview-6.3.1}/simview.egg-info/dependency_links.txt +0 -0
  86. {simview-6.3.0 → simview-6.3.1}/simview.egg-info/entry_points.txt +0 -0
  87. {simview-6.3.0 → simview-6.3.1}/simview.egg-info/requires.txt +0 -0
  88. {simview-6.3.0 → simview-6.3.1}/simview.egg-info/top_level.txt +0 -0
  89. {simview-6.3.0 → simview-6.3.1}/tests/test_batch_selection.py +0 -0
  90. {simview-6.3.0 → simview-6.3.1}/tests/test_cli.py +0 -0
  91. {simview-6.3.0 → simview-6.3.1}/tests/test_columnar_ondisk.py +0 -0
  92. {simview-6.3.0 → simview-6.3.1}/tests/test_columnar_states.py +0 -0
  93. {simview-6.3.0 → simview-6.3.1}/tests/test_contacts.py +0 -0
  94. {simview-6.3.0 → simview-6.3.1}/tests/test_diff.py +0 -0
  95. {simview-6.3.0 → simview-6.3.1}/tests/test_embed_defaults.py +0 -0
  96. {simview-6.3.0 → simview-6.3.1}/tests/test_episodes.py +0 -0
  97. {simview-6.3.0 → simview-6.3.1}/tests/test_info.py +0 -0
  98. {simview-6.3.0 → simview-6.3.1}/tests/test_launcher.py +0 -0
  99. {simview-6.3.0 → simview-6.3.1}/tests/test_live.py +0 -0
  100. {simview-6.3.0 → simview-6.3.1}/tests/test_merge.py +0 -0
  101. {simview-6.3.0 → simview-6.3.1}/tests/test_pointcloud_features.py +0 -0
  102. {simview-6.3.0 → simview-6.3.1}/tests/test_remote.py +0 -0
  103. {simview-6.3.0 → simview-6.3.1}/tests/test_roundtrip.py +0 -0
  104. {simview-6.3.0 → simview-6.3.1}/tests/test_scene.py +0 -0
  105. {simview-6.3.0 → simview-6.3.1}/tests/test_server.py +0 -0
  106. {simview-6.3.0 → simview-6.3.1}/tests/test_show.py +0 -0
  107. {simview-6.3.0 → simview-6.3.1}/tests/test_terrain.py +0 -0
  108. {simview-6.3.0 → simview-6.3.1}/tests/test_terrain_query.py +0 -0
  109. {simview-6.3.0 → simview-6.3.1}/tests/test_trajectory.py +0 -0
  110. {simview-6.3.0 → simview-6.3.1}/tests/test_utils.py +0 -0
  111. {simview-6.3.0 → 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.3.0
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.3.0"
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
 
@@ -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)
@@ -21,13 +21,15 @@ import orjson
21
21
  import uvicorn
22
22
  from fastapi import FastAPI, Request, Response, WebSocket, WebSocketDisconnect
23
23
  from fastapi.middleware.cors import CORSMiddleware
24
- from fastapi.responses import FileResponse, JSONResponse
24
+ from fastapi.responses import HTMLResponse, JSONResponse
25
25
  from fastapi.staticfiles import StaticFiles
26
26
  from pydantic import BaseModel
27
27
  from starlette.datastructures import Headers
28
+ from starlette.middleware.gzip import GZipMiddleware
28
29
  from starlette.types import Scope
29
30
 
30
31
  from simview.columnar import columnarize_states, is_columnar, rewrite_blobs
32
+ from simview.preload import render_index
31
33
  from simview.utils import dialable_host, find_free_port, read_maybe_gzipped_bytes
32
34
 
33
35
  logger = logging.getLogger("simview.server")
@@ -348,8 +350,17 @@ class SimViewServer:
348
350
  # libs as immutable and everything else short-lived (max-age=60), so
349
351
  # index.html's fixed ./static/... paths never need a cache-busting
350
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.
351
356
  self.app.mount(
352
- "/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",
353
364
  )
354
365
 
355
366
  # Pre-serialized, gzipped payloads for HTTP serving. The parsed dicts are
@@ -521,7 +532,7 @@ class SimViewServer:
521
532
  def setup_routes(self):
522
533
  @self.app.get("/")
523
534
  async def index():
524
- return FileResponse(INDEX_HTML, media_type="text/html")
535
+ return HTMLResponse(render_index(INDEX_HTML, str(Path(STATIC).parent)))
525
536
 
526
537
  _gzip_headers = {"Content-Encoding": "gzip"}
527
538
 
@@ -6,7 +6,7 @@ import { AnimationController } from "./components/AnimationController.js";
6
6
  import { UI_DEFAULT_CONFIG } from "./config.js";
7
7
  import { Body } from "./objects/Body.js";
8
8
  import { Terrain } from "./objects/Terrain.js";
9
- import { BatchManager } from "./components/BatchManager.js";
9
+ import { BatchManager, batchCountOf } from "./components/BatchManager.js";
10
10
  import { ScalarPlotter } from "./ui/ScalarPlotter.js";
11
11
  import { StaticObject } from "./objects/StaticObject.js";
12
12
  import { Polyline } from "./objects/Polyline.js";
@@ -109,10 +109,18 @@ export class SimView {
109
109
  async loadData() {
110
110
  const splash = document.getElementById("loading-splash");
111
111
  const base = this.staticBase;
112
+ // Requests the page started before the modules loaded (see
113
+ // templates/index.html), each usable once. /states doesn't depend on
114
+ // the model, so it's requested alongside it instead of after
115
+ // initFromModel.
116
+ const prefetched = base ? null : window.__simviewPrefetch;
117
+ window.__simviewPrefetch = null;
118
+ const statesFetch = prefetched?.states ?? fetch(base ? `${base}/states.json` : "/states");
119
+ statesFetch.catch(() => {}); // surfaced where it's awaited below
112
120
  try {
113
121
  if (splash) splash.innerHTML = "<h1>Fetching model…</h1>";
114
122
  console.time("fetch_model");
115
- const modelResponse = await fetch(base ? `${base}/model.json` : "/model");
123
+ const modelResponse = await (prefetched?.model ?? fetch(base ? `${base}/model.json` : "/model"));
116
124
  console.timeEnd("fetch_model");
117
125
  if (!modelResponse.ok)
118
126
  throw new Error(`Failed to fetch model: ${modelResponse.status} ${modelResponse.statusText}`);
@@ -122,6 +130,13 @@ export class SimView {
122
130
  const model = await modelResponse.json();
123
131
  console.timeEnd("parse_model");
124
132
 
133
+ // The states side needs only the batch count (not the built
134
+ // scene), so its parse + blob downloads overlap the model's blobs
135
+ // and initFromModel instead of queueing behind them.
136
+ const batchCount = batchCountOf(model);
137
+ const statesReady = this.fetchStatesPayload(statesFetch, batchCount);
138
+ statesReady.catch(() => {}); // surfaced where it's awaited below
139
+
125
140
  console.time("fetch_blobs");
126
141
  await this.fetchBlobs(model);
127
142
  console.timeEnd("fetch_blobs");
@@ -130,15 +145,7 @@ export class SimView {
130
145
  this.initFromModel(model);
131
146
 
132
147
  if (splash) splash.innerHTML = "<h1>Fetching states…</h1>";
133
- console.time("fetch_states");
134
- const statesResponse = await fetch(base ? `${base}/states.json` : "/states");
135
- console.timeEnd("fetch_states");
136
- if (!statesResponse.ok)
137
- throw new Error(`Failed to fetch states: ${statesResponse.status} ${statesResponse.statusText}`);
138
-
139
- console.time("parse_states");
140
- const statesPayload = await statesResponse.json();
141
- console.timeEnd("parse_states");
148
+ const statesPayload = await statesReady;
142
149
 
143
150
  if (Array.isArray(statesPayload)) {
144
151
  // Legacy wire shape: a plain per-frame array, possibly with
@@ -154,15 +161,9 @@ export class SimView {
154
161
  } else if (statesPayload && statesPayload.version === 4) {
155
162
  // Columnar wire shape (see columnar.py::columnarize_states):
156
163
  // a lightweight index plus /blob/... URLs for the actual
157
- // whole-trajectory float32 data, fetched in parallel below.
164
+ // whole-trajectory float32 data, already fetched by
165
+ // fetchStatesPayload.
158
166
  console.debug(`Received ${statesPayload.times.length} states (columnar)`);
159
- // Long trajectories: swap the biggest current-frame-only
160
- // fields for windowed readers before the bulk fetch, so they
161
- // are never materialized in full (see utils/blobWindow.js).
162
- this.windowLargeStateFields(statesPayload);
163
- console.time("fetch_state_blobs");
164
- await this.fetchBlobs(statesPayload);
165
- console.timeEnd("fetch_state_blobs");
166
167
  this.store = new ColumnarStateStore(statesPayload, this.batchManager.simBatches);
167
168
  this.onStoreReady();
168
169
  } else {
@@ -182,6 +183,30 @@ export class SimView {
182
183
  }
183
184
  }
184
185
 
186
+ // Fetches and parses /states and, for the columnar shape, downloads its
187
+ // blobs (long trajectories' biggest current-frame-only fields are swapped
188
+ // for windowed readers first, so they are never materialized in full --
189
+ // see utils/blobWindow.js). Resolves to the payload.
190
+ async fetchStatesPayload(statesFetch, batchCount) {
191
+ console.time("fetch_states");
192
+ const statesResponse = await statesFetch;
193
+ console.timeEnd("fetch_states");
194
+ if (!statesResponse.ok)
195
+ throw new Error(`Failed to fetch states: ${statesResponse.status} ${statesResponse.statusText}`);
196
+
197
+ console.time("parse_states");
198
+ const payload = await statesResponse.json();
199
+ console.timeEnd("parse_states");
200
+
201
+ if (payload && !Array.isArray(payload) && payload.version === 4) {
202
+ this.windowLargeStateFields(payload, batchCount);
203
+ console.time("fetch_state_blobs");
204
+ await this.fetchBlobs(payload);
205
+ console.timeEnd("fetch_state_blobs");
206
+ }
207
+ return payload;
208
+ }
209
+
185
210
  // Replaces the blob URL of every large, current-frame-only per-body field
186
211
  // with a WindowedField, so fetchBlobs skips it and it's read in windows
187
212
  // around the playhead instead. Static demo mode is excluded: it serves
@@ -190,10 +215,9 @@ export class SimView {
190
215
  // bodyTransform, contacts and the scalars are deliberately untouched --
191
216
  // trails, error metrics, the terrain profile and the scalar plots all walk
192
217
  // the whole trajectory, so windowing those would just move the cost.
193
- windowLargeStateFields(statesPayload) {
218
+ windowLargeStateFields(statesPayload, batchCount) {
194
219
  if (this.staticBase) return;
195
220
  const totalFrames = statesPayload.times.length;
196
- const batchCount = this.batchManager.simBatches;
197
221
  // Overridable so tests can exercise windowing without a fixture big
198
222
  // enough to cross the real threshold (same spirit as
199
223
  // window.__debugSimView); undefined in normal use.
@@ -435,12 +459,12 @@ export class SimView {
435
459
  // Resolves and records position/orientation (and contacts, vectors, ...)
436
460
  // history for every frame from `startIndex` to the end of the store, for
437
461
  // trails and the error metrics panel. Each frame is materialized
438
- // transiently via store.getFrame(i) (not retained) so this works
462
+ // transiently via store.getTransformFrame(i) (not retained) so this works
439
463
  // identically whether the store is legacy or columnar.
440
464
  appendBodyHistories(startIndex) {
441
465
  if (!this.bodies || this.bodies.size === 0) return;
442
466
  for (let s = startIndex; s < this.store.length; s++) {
443
- const bodyStates = this.store.getFrame(s).bodies;
467
+ const bodyStates = this.store.getTransformFrame(s).bodies;
444
468
  if (!bodyStates) continue;
445
469
  // Resolve off this historical state's own data (not any Body's
446
470
  // current/live pose, which reflects whatever frame is currently
@@ -8,6 +8,14 @@ import {
8
8
  visibleBatchSet,
9
9
  } from "../utils/batchVisibility.js";
10
10
 
11
+ // The batch count a model declares (1 when absent); also what loadData needs
12
+ // before any BatchManager exists.
13
+ export function batchCountOf(modelData) {
14
+ return modelData?.simBatches !== undefined
15
+ ? Math.max(1, parseInt(modelData.simBatches))
16
+ : 1;
17
+ }
18
+
11
19
  export class BatchManager {
12
20
  constructor(app, modelData) {
13
21
  this.app = app;
@@ -34,7 +42,7 @@ export class BatchManager {
34
42
 
35
43
  // Set batch count from model data
36
44
  if (modelData.simBatches !== undefined) {
37
- this.simBatches = Math.max(1, parseInt(modelData.simBatches));
45
+ this.simBatches = batchCountOf(modelData);
38
46
  console.log(
39
47
  `Initializing with ${this.simBatches} simulation batches`
40
48
  );
@@ -16,6 +16,51 @@ export class Scene {
16
16
  this.initScene();
17
17
  this.minRenderDelay = 1000 / FREQ_CONFIG.scene;
18
18
  this.lastRenderTime = Number.NEGATIVE_INFINITY;
19
+ this.setupInvalidation();
20
+ }
21
+
22
+ // The canvas only needs redrawing when something may have changed.
23
+ // Mutations are scattered (GUI toggles, panels, data landing), so rather
24
+ // than hooking each one, any user input or camera move marks the scene
25
+ // dirty, playback/recording/new frames always render, and an idle
26
+ // heartbeat repaints anything else (e.g. a Range window landing while
27
+ // paused) within IDLE_RENDER_MS.
28
+ static IDLE_RENDER_MS = 250;
29
+ invalidate() {
30
+ this.needsRender = true;
31
+ }
32
+
33
+ setupInvalidation() {
34
+ this.needsRender = true;
35
+ this._lastFrameIndex = null;
36
+ const mark = () => this.invalidate();
37
+ // pointermove only while a button is held: drags orbit/pan the camera.
38
+ this._onInput = (e) => {
39
+ if (e.type !== "pointermove" || e.buttons) this.invalidate();
40
+ };
41
+ this._inputEvents = [
42
+ "pointerdown", "pointermove", "pointerup", "wheel",
43
+ "keydown", "keyup", "input", "change", "click",
44
+ ];
45
+ this._inputEvents.forEach((t) =>
46
+ window.addEventListener(t, this._onInput, { capture: true, passive: true })
47
+ );
48
+ this.controls.addEventListener("change", mark);
49
+ }
50
+
51
+ #shouldRender(now) {
52
+ const ac = this.app.animationController;
53
+ if (
54
+ this.needsRender ||
55
+ ac?.isPlaying ||
56
+ ac?.isRecording ||
57
+ ac?.currentStateIndex !== this._lastFrameIndex ||
58
+ now - this.lastRenderTime >= Scene.IDLE_RENDER_MS
59
+ ) {
60
+ this._lastFrameIndex = ac?.currentStateIndex;
61
+ return true;
62
+ }
63
+ return false;
19
64
  }
20
65
 
21
66
  initScene() {
@@ -67,12 +112,17 @@ export class Scene {
67
112
  window.removeEventListener("resize", this._onResize);
68
113
  this._onResize = null;
69
114
  }
115
+ this._inputEvents?.forEach((t) =>
116
+ window.removeEventListener(t, this._onInput, { capture: true })
117
+ );
118
+ this._inputEvents = null;
70
119
  this.controls?.dispose();
71
120
  this.renderer?.dispose();
72
121
  this.renderer?.domElement?.remove();
73
122
  }
74
123
 
75
124
  #handleWindowResize() {
125
+ this.invalidate(); // resizing clears the canvas
76
126
  this.camera.aspect = window.innerWidth / window.innerHeight;
77
127
  this.camera.updateProjectionMatrix();
78
128
  this.renderer.setPixelRatio(window.devicePixelRatio);
@@ -139,7 +189,11 @@ export class Scene {
139
189
  // and B side by side, shifting the camera by each batch's offset from the
140
190
  // active one for its half of the viewport.
141
191
  animate(now) {
142
- if (now - this.lastRenderTime < this.minRenderDelay) return;
192
+ // The -1 absorbs rAF timestamp jitter: at 60 Hz the deltas hover
193
+ // around 16.67 ms and would otherwise drop every other frame.
194
+ if (now - this.lastRenderTime < this.minRenderDelay - 1) return;
195
+ if (!this.#shouldRender(now)) return;
196
+ this.needsRender = false;
143
197
 
144
198
  if (this.app.uiState && this.app.uiState.splitScreen && this.app.batchManager && this.app.batchManager.simBatches >= 2) {
145
199
  const batchA = this.app.uiState.splitBatchA !== undefined ? this.app.uiState.splitBatchA : 0;
@@ -167,6 +167,25 @@ export class ColumnarStateStore {
167
167
  return state;
168
168
  }
169
169
 
170
+ // Frame `i` carrying only each body's bodyTransform. For whole-run walks
171
+ // (trail/error-metric histories) that never read the windowed fields:
172
+ // getFrame() would start a Range fetch for every window of every one.
173
+ getTransformFrame(i) {
174
+ const bodies = [];
175
+ for (const body of this._bodies) {
176
+ const value = body.fields.bodyTransform;
177
+ const entry = { name: body.name };
178
+ if (value && typeof value.rowsAt === "function") {
179
+ const rows = value.rowsAt(i);
180
+ if (rows) entry.bodyTransform = rows;
181
+ } else if (value) {
182
+ entry.bodyTransform = this._sliceField(value, COLUMNAR_FIELD_WIDTHS.bodyTransform, i);
183
+ }
184
+ bodies.push(entry);
185
+ }
186
+ return { time: this.times[i], bodies };
187
+ }
188
+
170
189
  // Per-batch series for the scalar plotter: one per-frame value array per
171
190
  // batch, indexed like `times`.
172
191
  // `simBatches` is accepted (and ignored) for API parity with
@@ -217,6 +236,10 @@ export class LegacyStateStore {
217
236
  return this._states[i];
218
237
  }
219
238
 
239
+ getTransformFrame(i) {
240
+ return this._states[i];
241
+ }
242
+
220
243
  // `simBatches` is passed explicitly: the legacy shape records no batch
221
244
  // count of its own (callers use app.batchManager.simBatches).
222
245
  getScalarSeries(name, simBatches) {
@@ -4,6 +4,12 @@ import { colormapLUT, getCallableFromColorMapName } from "./colormap.js";
4
4
  import { toFlatFloat32Array } from "./utils.js";
5
5
 
6
6
  export class Terrain {
7
+ // Per-batch normals field and the built-once arrow template of a
8
+ // singleton terrain; the arrows themselves are built lazily, see
9
+ // #ensureNormals.
10
+ #normals = null;
11
+ #singletonNormals = null;
12
+
7
13
  constructor(terrainData, app) {
8
14
  this.app = app; // Store reference to app for accessing batch manager
9
15
  this.bounds = terrainData.bounds;
@@ -18,6 +24,7 @@ export class Terrain {
18
24
  this.#initEmbeddingData(terrainData.embeddingData);
19
25
 
20
26
  const normals = this.#normalizeVectorField(terrainData.normals, 3);
27
+ this.#normals = normals;
21
28
 
22
29
  this.#createVisualRepresentations(this.heightData, normals);
23
30
  }
@@ -197,7 +204,6 @@ export class Terrain {
197
204
 
198
205
  let singletonSurfaceGeometry = null;
199
206
  let singletonWireframeGeometry = null;
200
- let singletonNormals = null;
201
207
 
202
208
  if (this.isSingleton) {
203
209
  singletonSurfaceGeometry = this.#createSurfaceGeometryFromHeightData(
@@ -206,10 +212,6 @@ export class Terrain {
206
212
  normals?.[0]
207
213
  );
208
214
  singletonWireframeGeometry = this.#createWireframeGeometry(singletonSurfaceGeometry);
209
- singletonNormals = this.#createNormalVectors(
210
- heightData[0],
211
- normals?.[0]
212
- );
213
215
  }
214
216
 
215
217
  // Create geometry for each batch
@@ -240,11 +242,12 @@ export class Terrain {
240
242
  wireframeMesh.raycast = () => {};
241
243
  batchGroup.add(wireframeMesh);
242
244
 
243
- const surfaceNormals = this.isSingleton
244
- ? singletonNormals.clone()
245
- : this.#createNormalVectors(heightData[i], normals?.[i]);
246
-
245
+ // Filled on first use: 121 ArrowHelpers (3 objects each) per
246
+ // batch made up ~99% of a 512-env scene while hidden.
247
+ const surfaceNormals = new THREE.Group();
247
248
  surfaceNormals.name = "normals";
249
+ surfaceNormals.visible =
250
+ this.app.uiState.terrainVisualizationModes.normals || false;
248
251
  batchGroup.add(surfaceNormals);
249
252
 
250
253
  for (const [key, value] of Object.entries(
@@ -270,12 +273,35 @@ export class Terrain {
270
273
  refreshBatchVisibility() {
271
274
  const batchManager = this.app.batchManager;
272
275
  if (!batchManager?.isBatchVisible || !this.group) return;
276
+ const showNormals = this.app.uiState.terrainVisualizationModes?.normals;
273
277
  for (let i = 0; i < batchManager.simBatches; i++) {
274
- const batchGroup = this.group.getObjectByName(`batch${i}`);
275
- if (batchGroup) batchGroup.visible = batchManager.isBatchVisible(i);
278
+ // group.children[i] is batch i (built in order); getObjectByName
279
+ // searched every earlier batch's whole subtree, O(n^2) overall.
280
+ const batchGroup = this.group.children[i];
281
+ if (!batchGroup) continue;
282
+ batchGroup.visible = batchManager.isBatchVisible(i);
283
+ if (showNormals && batchGroup.visible) this.#ensureNormals(i);
276
284
  }
277
285
  }
278
286
 
287
+ // Builds batch i's normal arrows the first time they're wanted.
288
+ #ensureNormals(i) {
289
+ const target = this.group.children[i]?.getObjectByName("normals");
290
+ if (!target || target.userData.built || !this.#normals) return;
291
+ target.userData.built = true;
292
+ let source;
293
+ if (this.isSingleton) {
294
+ this.#singletonNormals ??= this.#createNormalVectors(
295
+ this.heightData[0],
296
+ this.#normals[0]
297
+ );
298
+ source = this.#singletonNormals.clone();
299
+ } else {
300
+ source = this.#createNormalVectors(this.heightData[i], this.#normals[i]);
301
+ }
302
+ target.add(...source.children);
303
+ }
304
+
279
305
  // Normalize a value into [0, 1] against an explicit [min, max] range.
280
306
  // Falls back to [0, 1] clamping when the range is missing or degenerate.
281
307
  #normalizeToRange(value, min, max) {
@@ -489,12 +515,15 @@ export class Terrain {
489
515
 
490
516
  // Shows/hides a named sub-object (surface/wireframe/normals) in every batch group.
491
517
  toggleVisualization(type, visible) {
492
- for (const batchGroup of this.group.children) {
518
+ const batchManager = this.app.batchManager;
519
+ this.group.children.forEach((batchGroup, i) => {
493
520
  const object = batchGroup.getObjectByName(type);
494
- if (object) {
495
- object.visible = visible;
521
+ if (!object) return;
522
+ object.visible = visible;
523
+ if (type === "normals" && visible && (batchManager?.isBatchVisible?.(i) ?? true)) {
524
+ this.#ensureNormals(i);
496
525
  }
497
- }
526
+ });
498
527
  }
499
528
 
500
529
  // Returns the per-batch data array for a diff layer name ("height", or
@@ -671,7 +700,7 @@ export class Terrain {
671
700
  const batchesToUpdate = this.isSingleton ? 1 : this.app.batchManager.simBatches;
672
701
 
673
702
  for (let i = 0; i < batchesToUpdate; i++) {
674
- const batchGroup = this.group.getObjectByName(`batch${i}`);
703
+ const batchGroup = this.group.children[i];
675
704
  const surfaceMesh = batchGroup.getObjectByName("surface");
676
705
  const geometry = surfaceMesh.geometry;
677
706
  this.#updateSurfaceColor(i, geometry, callableColormap);
@@ -684,7 +713,7 @@ export class Terrain {
684
713
  const batchesToUpdate = this.isSingleton ? 1 : this.app.batchManager.simBatches;
685
714
 
686
715
  for (let i = 0; i < batchesToUpdate; i++) {
687
- const batchGroup = this.group.getObjectByName(`batch${i}`);
716
+ const batchGroup = this.group.children[i];
688
717
  const surfaceMesh = batchGroup.getObjectByName("surface");
689
718
  const geometry = surfaceMesh.geometry;
690
719
  this.#updateSurfaceColor(i, geometry, callableColormap);
@@ -41,6 +41,17 @@
41
41
  }
42
42
  </script>
43
43
 
44
+ <!-- Start the model and states requests now rather than once the ES-module
45
+ graph has loaded, so they overlap it (SimView.loadData consumes
46
+ these). Skipped for the serverless viewer (#data=...), which has no
47
+ /model to ask. -->
48
+ <script>
49
+ if (/^https?:/.test(location.protocol) && !/[#&]data=/.test(location.hash) && !window.__simviewStaticBase) {
50
+ const quiet = (p) => (p.catch(() => {}), p);
51
+ window.__simviewPrefetch = { model: quiet(fetch("/model")), states: quiet(fetch("/states")) };
52
+ }
53
+ </script>
54
+ <!-- @modulepreload -->
44
55
  <script type="module" src="./static/js/main.js"></script>
45
56
  <script>
46
57
  window.onerror = function(msg, url, line, col, error) {
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: simview
3
- Version: 6.3.0
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
@@ -11,6 +11,7 @@ simview/launcher.py
11
11
  simview/live.py
12
12
  simview/merge.py
13
13
  simview/model.py
14
+ simview/preload.py
14
15
  simview/py.typed
15
16
  simview/remote.py
16
17
  simview/render.py
@@ -95,6 +96,7 @@ tests/test_launcher.py
95
96
  tests/test_live.py
96
97
  tests/test_merge.py
97
98
  tests/test_pointcloud_features.py
99
+ tests/test_preload.py
98
100
  tests/test_remote.py
99
101
  tests/test_roundtrip.py
100
102
  tests/test_scene.py
@@ -0,0 +1,25 @@
1
+ from pathlib import Path
2
+
3
+ from simview.preload import PLACEHOLDER, module_graph, render_index
4
+ from simview.server import INDEX_HTML, STATIC
5
+
6
+ ROOT = Path(STATIC).parent
7
+
8
+
9
+ def test_module_graph_reaches_every_viewer_module():
10
+ graph = set(module_graph(Path(INDEX_HTML).read_text(encoding="utf-8"), ROOT))
11
+ assert "./static/js/main.js" in graph
12
+ assert "./static/lib/three-0.174.0/three.core.js" in graph # via the import map
13
+ on_disk = {
14
+ "./" + p.relative_to(ROOT).as_posix()
15
+ for p in (ROOT / "static" / "js").rglob("*.js")
16
+ }
17
+ # A module the preload walk misses just loads late, but one that's on disk
18
+ # and unreachable is probably an import form the walk can't read.
19
+ assert on_disk <= graph
20
+
21
+
22
+ def test_render_index_fills_the_placeholder():
23
+ html = render_index(INDEX_HTML, str(ROOT))
24
+ assert PLACEHOLDER not in html
25
+ assert '<link rel="modulepreload" href="./static/js/SimView.js" />' in html
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
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes