simview 6.0.0__tar.gz → 6.1.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.
- {simview-6.0.0/simview.egg-info → simview-6.1.0}/PKG-INFO +2 -1
- {simview-6.0.0 → simview-6.1.0}/README.md +1 -0
- {simview-6.0.0 → simview-6.1.0}/pyproject.toml +1 -1
- {simview-6.0.0 → simview-6.1.0}/simview/__init__.py +4 -1
- {simview-6.0.0 → simview-6.1.0}/simview/columnar.py +30 -0
- {simview-6.0.0 → simview-6.1.0}/simview/diff.py +1 -1
- {simview-6.0.0 → simview-6.1.0}/simview/info.py +2 -2
- {simview-6.0.0 → simview-6.1.0}/simview/live.py +4 -0
- {simview-6.0.0 → simview-6.1.0}/simview/model.py +57 -3
- {simview-6.0.0 → simview-6.1.0}/simview/scene.py +42 -0
- {simview-6.0.0 → simview-6.1.0}/simview/server.py +10 -1
- {simview-6.0.0 → simview-6.1.0}/simview/static/css/controls.css +12 -0
- {simview-6.0.0 → simview-6.1.0}/simview/static/js/SimView.js +82 -3
- {simview-6.0.0 → simview-6.1.0}/simview/static/js/components/Scene.js +1 -1
- {simview-6.0.0 → simview-6.1.0}/simview/static/js/config.js +2 -2
- {simview-6.0.0 → simview-6.1.0}/simview/static/js/objects/Body.js +8 -0
- {simview-6.0.0 → simview-6.1.0}/simview/static/js/ui/BodyStateWindow.js +7 -11
- {simview-6.0.0 → simview-6.1.0}/simview/static/js/ui/Controls.js +5 -16
- {simview-6.0.0 → simview-6.1.0}/simview/static/js/ui/PlaybackControls.js +2 -0
- simview-6.1.0/simview/static/js/utils/keyboard.js +21 -0
- {simview-6.0.0 → simview-6.1.0}/simview/static/js/utils/viewState.js +9 -0
- simview-6.1.0/simview/static/js/utils/viewerDefaults.js +70 -0
- {simview-6.0.0 → simview-6.1.0}/simview/templates/index.html +5 -5
- {simview-6.0.0 → simview-6.1.0}/simview/terrain.py +1 -1
- simview-6.1.0/simview/view.py +90 -0
- {simview-6.0.0 → simview-6.1.0/simview.egg-info}/PKG-INFO +2 -1
- {simview-6.0.0 → simview-6.1.0}/simview.egg-info/SOURCES.txt +6 -1
- simview-6.1.0/tests/test_embed_defaults.py +75 -0
- {simview-6.0.0 → simview-6.1.0}/tests/test_server.py +8 -8
- simview-6.1.0/tests/test_view.py +74 -0
- {simview-6.0.0 → simview-6.1.0}/LICENSE +0 -0
- {simview-6.0.0 → simview-6.1.0}/MANIFEST.in +0 -0
- {simview-6.0.0 → simview-6.1.0}/setup.cfg +0 -0
- {simview-6.0.0 → simview-6.1.0}/simview/__main__.py +0 -0
- {simview-6.0.0 → simview-6.1.0}/simview/launcher.py +0 -0
- {simview-6.0.0 → simview-6.1.0}/simview/merge.py +0 -0
- {simview-6.0.0 → simview-6.1.0}/simview/py.typed +0 -0
- {simview-6.0.0 → simview-6.1.0}/simview/remote.py +0 -0
- {simview-6.0.0 → simview-6.1.0}/simview/render.py +0 -0
- {simview-6.0.0 → simview-6.1.0}/simview/state.py +0 -0
- {simview-6.0.0 → simview-6.1.0}/simview/static/js/components/AnimationController.js +0 -0
- {simview-6.0.0 → simview-6.1.0}/simview/static/js/components/BatchManager.js +0 -0
- {simview-6.0.0 → simview-6.1.0}/simview/static/js/components/InteractionController.js +0 -0
- {simview-6.0.0 → simview-6.1.0}/simview/static/js/components/StateStore.js +0 -0
- {simview-6.0.0 → simview-6.1.0}/simview/static/js/components/WindowedField.js +0 -0
- {simview-6.0.0 → simview-6.1.0}/simview/static/js/main.js +0 -0
- {simview-6.0.0 → simview-6.1.0}/simview/static/js/objects/StaticObject.js +0 -0
- {simview-6.0.0 → simview-6.1.0}/simview/static/js/objects/Terrain.js +0 -0
- {simview-6.0.0 → simview-6.1.0}/simview/static/js/objects/colormap.js +0 -0
- {simview-6.0.0 → simview-6.1.0}/simview/static/js/objects/similarity.js +0 -0
- {simview-6.0.0 → simview-6.1.0}/simview/static/js/objects/utils.js +0 -0
- {simview-6.0.0 → simview-6.1.0}/simview/static/js/ui/AnalysisPanel.js +0 -0
- {simview-6.0.0 → simview-6.1.0}/simview/static/js/ui/BatchLegend.js +0 -0
- {simview-6.0.0 → simview-6.1.0}/simview/static/js/ui/ErrorMetrics.js +0 -0
- {simview-6.0.0 → simview-6.1.0}/simview/static/js/ui/Legend.js +0 -0
- {simview-6.0.0 → simview-6.1.0}/simview/static/js/ui/ScalarPlotter.js +0 -0
- {simview-6.0.0 → simview-6.1.0}/simview/static/js/ui/TerrainProfile.js +0 -0
- {simview-6.0.0 → simview-6.1.0}/simview/static/js/ui/chartControls.js +0 -0
- {simview-6.0.0 → simview-6.1.0}/simview/static/js/utils/batchPresets.js +0 -0
- {simview-6.0.0 → simview-6.1.0}/simview/static/js/utils/batchVisibility.js +0 -0
- {simview-6.0.0 → simview-6.1.0}/simview/static/js/utils/blobCodec.js +0 -0
- {simview-6.0.0 → simview-6.1.0}/simview/static/js/utils/blobWindow.js +0 -0
- {simview-6.0.0 → simview-6.1.0}/simview/static/js/utils/bodyTransforms.js +0 -0
- {simview-6.0.0 → simview-6.1.0}/simview/static/js/utils/cameraRange.js +0 -0
- {simview-6.0.0 → simview-6.1.0}/simview/static/js/utils/csv.js +0 -0
- {simview-6.0.0 → simview-6.1.0}/simview/static/js/utils/episodes.js +0 -0
- {simview-6.0.0 → simview-6.1.0}/simview/static/js/utils/errorMath.js +0 -0
- {simview-6.0.0 → simview-6.1.0}/simview/static/js/utils/interpolate.js +0 -0
- {simview-6.0.0 → simview-6.1.0}/simview/static/js/utils/liveFollow.js +0 -0
- {simview-6.0.0 → simview-6.1.0}/simview/static/js/utils/terrainSample.js +0 -0
- {simview-6.0.0 → simview-6.1.0}/simview/static/js/utils/uplot.js +0 -0
- {simview-6.0.0 → simview-6.1.0}/simview/static/lib/js-colormaps.js +0 -0
- {simview-6.0.0 → simview-6.1.0}/simview/static/lib/three-0.174.0/addons/controls/OrbitControls.js +0 -0
- {simview-6.0.0 → simview-6.1.0}/simview/static/lib/three-0.174.0/addons/libs/lil-gui.module.min.js +0 -0
- {simview-6.0.0 → simview-6.1.0}/simview/static/lib/three-0.174.0/three.core.js +0 -0
- {simview-6.0.0 → simview-6.1.0}/simview/static/lib/three-0.174.0/three.module.js +0 -0
- {simview-6.0.0 → simview-6.1.0}/simview/static/lib/uPlot.esm.js +0 -0
- {simview-6.0.0 → simview-6.1.0}/simview/static/lib/uPlot.min.css +0 -0
- {simview-6.0.0 → simview-6.1.0}/simview/static/textures/contacts/red-cross0.png +0 -0
- {simview-6.0.0 → simview-6.1.0}/simview/static/textures/points/ball1.png +0 -0
- {simview-6.0.0 → simview-6.1.0}/simview/utils.py +0 -0
- {simview-6.0.0 → simview-6.1.0}/simview.egg-info/dependency_links.txt +0 -0
- {simview-6.0.0 → simview-6.1.0}/simview.egg-info/entry_points.txt +0 -0
- {simview-6.0.0 → simview-6.1.0}/simview.egg-info/requires.txt +0 -0
- {simview-6.0.0 → simview-6.1.0}/simview.egg-info/top_level.txt +0 -0
- {simview-6.0.0 → simview-6.1.0}/tests/test_batch_selection.py +0 -0
- {simview-6.0.0 → simview-6.1.0}/tests/test_cli.py +0 -0
- {simview-6.0.0 → simview-6.1.0}/tests/test_columnar_ondisk.py +0 -0
- {simview-6.0.0 → simview-6.1.0}/tests/test_columnar_states.py +0 -0
- {simview-6.0.0 → simview-6.1.0}/tests/test_contacts.py +0 -0
- {simview-6.0.0 → simview-6.1.0}/tests/test_diff.py +0 -0
- {simview-6.0.0 → simview-6.1.0}/tests/test_episodes.py +0 -0
- {simview-6.0.0 → simview-6.1.0}/tests/test_info.py +0 -0
- {simview-6.0.0 → simview-6.1.0}/tests/test_launcher.py +0 -0
- {simview-6.0.0 → simview-6.1.0}/tests/test_live.py +0 -0
- {simview-6.0.0 → simview-6.1.0}/tests/test_merge.py +0 -0
- {simview-6.0.0 → simview-6.1.0}/tests/test_pointcloud_features.py +0 -0
- {simview-6.0.0 → simview-6.1.0}/tests/test_remote.py +0 -0
- {simview-6.0.0 → simview-6.1.0}/tests/test_roundtrip.py +0 -0
- {simview-6.0.0 → simview-6.1.0}/tests/test_scene.py +0 -0
- {simview-6.0.0 → simview-6.1.0}/tests/test_show.py +0 -0
- {simview-6.0.0 → simview-6.1.0}/tests/test_terrain.py +0 -0
- {simview-6.0.0 → simview-6.1.0}/tests/test_terrain_query.py +0 -0
- {simview-6.0.0 → simview-6.1.0}/tests/test_trajectory.py +0 -0
- {simview-6.0.0 → simview-6.1.0}/tests/test_utils.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: simview
|
|
3
|
-
Version: 6.
|
|
3
|
+
Version: 6.1.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
|
|
@@ -61,6 +61,7 @@ Want to try it without installing anything? There's a
|
|
|
61
61
|
- **Python API**: Easy-to-use API for generating scenes and launching the visualizer directly from your code.
|
|
62
62
|
- **JSON Support**: Load and save simulation data using a portable JSON format.
|
|
63
63
|
- **Remote Files**: Open a scene straight off a compute host with `simview host:path/to/scene.json` — fetched over SSH, compressed on the wire, and cached locally.
|
|
64
|
+
- **Embeddable**: Drop the viewer into another web app as an iframe — drive time, camera and colour modes through the URL hash, hide its panels, load data from static files you host, and react to `simview:ready`/`simview:frame` events.
|
|
64
65
|
- **Comparing Runs**: Merge several scene files into one, taking whole files or just picked batches — `simview gt.json method_a.json#1 method_b.json#1`.
|
|
65
66
|
|
|
66
67
|
---
|
|
@@ -30,6 +30,7 @@ Want to try it without installing anything? There's a
|
|
|
30
30
|
- **Python API**: Easy-to-use API for generating scenes and launching the visualizer directly from your code.
|
|
31
31
|
- **JSON Support**: Load and save simulation data using a portable JSON format.
|
|
32
32
|
- **Remote Files**: Open a scene straight off a compute host with `simview host:path/to/scene.json` — fetched over SSH, compressed on the wire, and cached locally.
|
|
33
|
+
- **Embeddable**: Drop the viewer into another web app as an iframe — drive time, camera and colour modes through the URL hash, hide its panels, load data from static files you host, and react to `simview:ready`/`simview:frame` events.
|
|
33
34
|
- **Comparing Runs**: Merge several scene files into one, taking whole files or just picked batches — `simview gt.json method_a.json#1 method_b.json#1`.
|
|
34
35
|
|
|
35
36
|
---
|
|
@@ -3,6 +3,8 @@ from importlib.metadata import PackageNotFoundError
|
|
|
3
3
|
from importlib.metadata import version as _version
|
|
4
4
|
from typing import TYPE_CHECKING
|
|
5
5
|
|
|
6
|
+
from simview.view import view_hash # stdlib-only, safe to import eagerly
|
|
7
|
+
|
|
6
8
|
CACHE_DIR = ".simview_cache"
|
|
7
9
|
|
|
8
10
|
try:
|
|
@@ -19,7 +21,7 @@ del _version, PackageNotFoundError
|
|
|
19
21
|
logging.getLogger(__name__).addHandler(logging.NullHandler())
|
|
20
22
|
|
|
21
23
|
# Public authoring API. These live in submodules that depend on the optional
|
|
22
|
-
# `authoring` extra (torch,
|
|
24
|
+
# `authoring` extra (torch, numpy), so they are imported lazily: a viewing-only
|
|
23
25
|
# install can still `import simview` without those dependencies, and only pays
|
|
24
26
|
# the import cost (and dependency requirement) when an authoring symbol is used.
|
|
25
27
|
_LAZY_EXPORTS = {
|
|
@@ -55,6 +57,7 @@ if TYPE_CHECKING:
|
|
|
55
57
|
__all__ = [
|
|
56
58
|
"__version__",
|
|
57
59
|
"CACHE_DIR",
|
|
60
|
+
"view_hash",
|
|
58
61
|
"SimulationScene",
|
|
59
62
|
"ViewerHandle",
|
|
60
63
|
"SimViewBody",
|
|
@@ -414,3 +414,33 @@ def expand_columnar_states(states_doc: Any, batch_size: int) -> list[dict]:
|
|
|
414
414
|
states.append(state)
|
|
415
415
|
|
|
416
416
|
return states
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def write_static_bundle(data: dict, directory) -> None:
|
|
420
|
+
"""Write ``{"model": ..., "states": ...}`` (inline ``__b64__`` blobs) as a
|
|
421
|
+
static bundle any file server can host for the viewer's static mode:
|
|
422
|
+
``model.json``, ``states.json`` and raw ``blob/<id>`` files, with blob
|
|
423
|
+
references rewritten to ``/blob/<id>`` (the viewer maps these to
|
|
424
|
+
``<base>/blob/<id>``). Stdlib-only."""
|
|
425
|
+
import json
|
|
426
|
+
from pathlib import Path
|
|
427
|
+
|
|
428
|
+
out = Path(directory)
|
|
429
|
+
(out / "blob").mkdir(parents=True, exist_ok=True)
|
|
430
|
+
count = 0
|
|
431
|
+
|
|
432
|
+
def externalize(obj):
|
|
433
|
+
nonlocal count
|
|
434
|
+
if not isinstance(obj, (dict, list)):
|
|
435
|
+
return
|
|
436
|
+
for k, v in obj.items() if isinstance(obj, dict) else enumerate(obj):
|
|
437
|
+
if is_blob(v):
|
|
438
|
+
(out / "blob" / str(count)).write_bytes(blob_bytes(v))
|
|
439
|
+
obj[k] = f"/blob/{count}"
|
|
440
|
+
count += 1
|
|
441
|
+
else:
|
|
442
|
+
externalize(v)
|
|
443
|
+
|
|
444
|
+
for name in ("model", "states"):
|
|
445
|
+
externalize(data[name])
|
|
446
|
+
(out / f"{name}.json").write_text(json.dumps(data[name]))
|
|
@@ -5,7 +5,7 @@ each other.
|
|
|
5
5
|
|
|
6
6
|
Deliberately dependency-free (stdlib only: json, base64, struct, math via
|
|
7
7
|
its imports) so it works on a base install without the `authoring` extra
|
|
8
|
-
(torch/
|
|
8
|
+
(torch/numpy) -- see CLAUDE.md. Shares its blob-decoding and
|
|
9
9
|
body-resolution helpers with `simview/terrain.py`/`simview/info.py` via
|
|
10
10
|
`simview.columnar`/`simview.utils`, which are stdlib-only on this read path.
|
|
11
11
|
|
|
@@ -3,9 +3,9 @@ agents inspecting simview-generated data files.
|
|
|
3
3
|
|
|
4
4
|
Deliberately dependency-free (stdlib only: json, gzip via
|
|
5
5
|
`simview.utils.read_maybe_gzipped_bytes`, base64) so it works on a base
|
|
6
|
-
install without the `authoring` extra (torch/
|
|
6
|
+
install without the `authoring` extra (torch/numpy) -- see
|
|
7
7
|
CLAUDE.md. Does not import `simview.model`/`simview.server`: the former
|
|
8
|
-
pulls in torch
|
|
8
|
+
pulls in torch at module scope, the latter fastapi/uvicorn just to
|
|
9
9
|
reach a numpy-gated helper -- both wrong layering for a lightweight
|
|
10
10
|
inspection tool. Shares its blob-decoding/body-label helpers with
|
|
11
11
|
`simview/terrain.py`/`simview/diff.py` via `simview.columnar`/`simview.utils`,
|
|
@@ -82,6 +82,10 @@ class LiveViewer:
|
|
|
82
82
|
|
|
83
83
|
webbrowser.open(self._threaded.url)
|
|
84
84
|
|
|
85
|
+
@property
|
|
86
|
+
def url(self) -> str:
|
|
87
|
+
return self._threaded.url
|
|
88
|
+
|
|
85
89
|
def push_state(self, time, body_states, scalar_values=None) -> None:
|
|
86
90
|
"""Append one frame and hand it to the sender thread for broadcast.
|
|
87
91
|
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import json
|
|
1
2
|
import logging
|
|
2
3
|
import math
|
|
3
4
|
from dataclasses import dataclass, field
|
|
@@ -293,8 +294,24 @@ class SimViewBody:
|
|
|
293
294
|
# interpreted as local to the parent's current-frame pose rather than world.
|
|
294
295
|
parent: str | None = None
|
|
295
296
|
local_transform: list[float] | None = None
|
|
297
|
+
# Per-body look of the mesh/primitive representation (box/sphere/cylinder/
|
|
298
|
+
# mesh). `color` is an RGB triple in [0, 1]; `opacity` in (0, 1] (None =
|
|
299
|
+
# opaque); `visible=False` hides the whole body until the user shows it.
|
|
300
|
+
color: list[float] | None = None
|
|
301
|
+
opacity: float | None = None
|
|
302
|
+
visible: bool = True
|
|
296
303
|
|
|
297
304
|
def __post_init__(self):
|
|
305
|
+
if self.color is not None:
|
|
306
|
+
if len(self.color) != 3 or not all(0.0 <= c <= 1.0 for c in self.color):
|
|
307
|
+
raise ValueError(
|
|
308
|
+
f"Body '{self.name}' color must be an RGB triple of floats "
|
|
309
|
+
f"in [0, 1]; got {self.color!r}."
|
|
310
|
+
)
|
|
311
|
+
if self.opacity is not None and not 0.0 < self.opacity <= 1.0:
|
|
312
|
+
raise ValueError(
|
|
313
|
+
f"Body '{self.name}' opacity must be in (0, 1]; got {self.opacity!r}."
|
|
314
|
+
)
|
|
298
315
|
if self.local_transform is not None:
|
|
299
316
|
if self.parent is None:
|
|
300
317
|
raise ValueError(
|
|
@@ -340,6 +357,9 @@ class SimViewBody:
|
|
|
340
357
|
available_attributes: list[OptionalBodyStateAttribute | str] | None = None,
|
|
341
358
|
parent: str | None = None,
|
|
342
359
|
local_transform: Any | None = None,
|
|
360
|
+
color: Any | None = None,
|
|
361
|
+
opacity: float | None = None,
|
|
362
|
+
visible: bool = True,
|
|
343
363
|
**kwargs,
|
|
344
364
|
) -> "SimViewBody":
|
|
345
365
|
shape_dict = SimViewBody._create_shape_dict(body_type, **kwargs)
|
|
@@ -352,6 +372,9 @@ class SimViewBody:
|
|
|
352
372
|
local_transform=list(local_transform)
|
|
353
373
|
if local_transform is not None
|
|
354
374
|
else None,
|
|
375
|
+
color=[float(c) for c in color] if color is not None else None,
|
|
376
|
+
opacity=opacity,
|
|
377
|
+
visible=visible,
|
|
355
378
|
)
|
|
356
379
|
if available_attributes is not None:
|
|
357
380
|
body.set_available_attributes(available_attributes)
|
|
@@ -385,21 +408,28 @@ class SimViewBody:
|
|
|
385
408
|
f"points must have shape (N, 3); got {tuple(points.shape)}."
|
|
386
409
|
)
|
|
387
410
|
N = points.shape[0]
|
|
411
|
+
shape_extra: dict[str, Any] = {}
|
|
388
412
|
if color is not None:
|
|
389
413
|
if tuple(color.shape) != (N, 3):
|
|
390
414
|
raise ValueError(
|
|
391
415
|
f"color must have shape ({N}, 3) matching points; got {tuple(color.shape)}."
|
|
392
416
|
)
|
|
393
|
-
|
|
417
|
+
shape_extra["color"] = color
|
|
394
418
|
if embedding is not None:
|
|
395
419
|
if embedding.ndim != 2 or embedding.shape[0] != N:
|
|
396
420
|
raise ValueError(
|
|
397
421
|
f"embedding must have shape ({N}, K) matching points; got {tuple(embedding.shape)}."
|
|
398
422
|
)
|
|
399
|
-
|
|
400
|
-
|
|
423
|
+
shape_extra["embedding"] = embedding
|
|
424
|
+
# Per-point `color` lives in the shape, so it can't go through
|
|
425
|
+
# `create`'s kwargs (where `color` is the body-level tint).
|
|
426
|
+
body = SimViewBody.create(
|
|
401
427
|
name, BodyShapeType.POINTCLOUD, points=points, **kwargs
|
|
402
428
|
)
|
|
429
|
+
body.shape.update(
|
|
430
|
+
SimViewBody._create_shape_dict(BodyShapeType.POINTCLOUD, **shape_extra)
|
|
431
|
+
)
|
|
432
|
+
return body
|
|
403
433
|
|
|
404
434
|
@staticmethod
|
|
405
435
|
def create_mesh(
|
|
@@ -417,6 +447,12 @@ class SimViewBody:
|
|
|
417
447
|
r["parent"] = self.parent
|
|
418
448
|
if self.local_transform is not None:
|
|
419
449
|
r["localTransform"] = self.local_transform
|
|
450
|
+
if self.color is not None:
|
|
451
|
+
r["color"] = self.color
|
|
452
|
+
if self.opacity is not None:
|
|
453
|
+
r["opacity"] = self.opacity
|
|
454
|
+
if not self.visible:
|
|
455
|
+
r["visible"] = False
|
|
420
456
|
return r
|
|
421
457
|
|
|
422
458
|
@classmethod
|
|
@@ -438,6 +474,9 @@ class SimViewBody:
|
|
|
438
474
|
else None,
|
|
439
475
|
parent=d.get("parent"),
|
|
440
476
|
local_transform=d.get("localTransform"),
|
|
477
|
+
color=d.get("color"),
|
|
478
|
+
opacity=d.get("opacity"),
|
|
479
|
+
visible=d.get("visible", True),
|
|
441
480
|
)
|
|
442
481
|
|
|
443
482
|
|
|
@@ -612,8 +651,20 @@ class SimViewModel:
|
|
|
612
651
|
# Optional episode boundaries for an episodic (e.g. RL) recording -- see
|
|
613
652
|
# SimViewEpisode. None means "one continuous timeline", the default.
|
|
614
653
|
episodes: list[SimViewEpisode] | None = None
|
|
654
|
+
# Initial viewer UI state (see docs/dev/json-format.md "viewerDefaults");
|
|
655
|
+
# lenient on purpose -- the frontend owns the key set.
|
|
656
|
+
viewer_defaults: dict[str, Any] | None = None
|
|
615
657
|
|
|
616
658
|
def __post_init__(self):
|
|
659
|
+
if self.viewer_defaults is not None:
|
|
660
|
+
if not isinstance(self.viewer_defaults, dict):
|
|
661
|
+
raise ValueError("viewer_defaults must be a dict.")
|
|
662
|
+
try:
|
|
663
|
+
json.dumps(self.viewer_defaults)
|
|
664
|
+
except TypeError as e:
|
|
665
|
+
raise ValueError(
|
|
666
|
+
f"viewer_defaults must be JSON-serializable: {e}"
|
|
667
|
+
) from e
|
|
617
668
|
if self.batch_names is not None and len(self.batch_names) != self.batch_size:
|
|
618
669
|
raise ValueError(
|
|
619
670
|
f"batch_names length ({len(self.batch_names)}) must match batch size ({self.batch_size})"
|
|
@@ -661,6 +712,8 @@ class SimViewModel:
|
|
|
661
712
|
r["metadata"] = self.metadata
|
|
662
713
|
if self.episodes:
|
|
663
714
|
r["episodes"] = [e.to_json() for e in self.episodes]
|
|
715
|
+
if self.viewer_defaults is not None:
|
|
716
|
+
r["viewerDefaults"] = self.viewer_defaults
|
|
664
717
|
return r
|
|
665
718
|
|
|
666
719
|
@classmethod
|
|
@@ -710,6 +763,7 @@ class SimViewModel:
|
|
|
710
763
|
if d.get("episodes")
|
|
711
764
|
else None
|
|
712
765
|
),
|
|
766
|
+
viewer_defaults=d.get("viewerDefaults"),
|
|
713
767
|
)
|
|
714
768
|
|
|
715
769
|
@property
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import gzip
|
|
2
2
|
import json
|
|
3
3
|
import logging
|
|
4
|
+
import tempfile
|
|
4
5
|
from collections.abc import Sequence
|
|
5
6
|
from pathlib import Path
|
|
6
7
|
from typing import Any, cast
|
|
@@ -14,6 +15,7 @@ from .columnar import (
|
|
|
14
15
|
expand_columnar_states,
|
|
15
16
|
inline_blob,
|
|
16
17
|
is_columnar,
|
|
18
|
+
write_static_bundle,
|
|
17
19
|
)
|
|
18
20
|
from .model import (
|
|
19
21
|
BodyShapeType, # If used directly by users of SimulationData for body creation
|
|
@@ -134,6 +136,7 @@ class SimulationScene:
|
|
|
134
136
|
batch_names: list[str] | None = None,
|
|
135
137
|
metadata: dict[str, Any] | None = None,
|
|
136
138
|
episodes: list[SimViewEpisode] | None = None,
|
|
139
|
+
viewer_defaults: dict[str, Any] | None = None,
|
|
137
140
|
) -> None:
|
|
138
141
|
"""
|
|
139
142
|
Initializes the simulation data container.
|
|
@@ -148,6 +151,10 @@ class SimulationScene:
|
|
|
148
151
|
`episodes` marks the frames an episodic (e.g. RL) recording resets at.
|
|
149
152
|
Usually easier to build up as you go with `mark_episode()` than to pass
|
|
150
153
|
here up front.
|
|
154
|
+
|
|
155
|
+
`viewer_defaults` declares the viewer's initial UI state (hidden point
|
|
156
|
+
clouds, collapsed panels, open/closed GUI folders, ...); see
|
|
157
|
+
docs/dev/json-format.md for the supported keys.
|
|
151
158
|
"""
|
|
152
159
|
self.model = SimViewModel(
|
|
153
160
|
batch_size=batch_size,
|
|
@@ -160,6 +167,7 @@ class SimulationScene:
|
|
|
160
167
|
batch_names=batch_names,
|
|
161
168
|
metadata=metadata,
|
|
162
169
|
episodes=episodes,
|
|
170
|
+
viewer_defaults=viewer_defaults,
|
|
163
171
|
)
|
|
164
172
|
self.states: list[dict] = []
|
|
165
173
|
|
|
@@ -225,6 +233,7 @@ class SimulationScene:
|
|
|
225
233
|
batch_names=model.batch_names,
|
|
226
234
|
metadata=model.metadata,
|
|
227
235
|
episodes=model.episodes,
|
|
236
|
+
viewer_defaults=model.viewer_defaults,
|
|
228
237
|
)
|
|
229
238
|
scene.states = list(states)
|
|
230
239
|
return scene
|
|
@@ -512,11 +521,27 @@ class SimulationScene:
|
|
|
512
521
|
f.write("\n ]\n}")
|
|
513
522
|
logger.info("Simulation data successfully saved to %s", output_path)
|
|
514
523
|
|
|
524
|
+
def save_static(self, directory: str | Path) -> None:
|
|
525
|
+
"""Write a static bundle (`model.json`, `states.json`, `blob/<id>`)
|
|
526
|
+
that the viewer's static mode (`#data=<base url>`, see
|
|
527
|
+
`simview.view_hash`) can load from any plain static file server.
|
|
528
|
+
|
|
529
|
+
Uses the same columnar layout and fallback as `save`.
|
|
530
|
+
"""
|
|
531
|
+
# Reuse save() (attribute reconciliation, columnar fallback) via a
|
|
532
|
+
# temp file rather than duplicating it; ponytail: extra JSON roundtrip.
|
|
533
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
534
|
+
path = Path(tmp) / "scene.json"
|
|
535
|
+
self.save(path)
|
|
536
|
+
write_static_bundle(json.loads(path.read_text()), directory)
|
|
537
|
+
|
|
515
538
|
def show(
|
|
516
539
|
self,
|
|
517
540
|
host: str = "127.0.0.1",
|
|
518
541
|
preferred_port: int = 5420,
|
|
519
542
|
open_browser: bool = False,
|
|
543
|
+
view: str | None = None,
|
|
544
|
+
height: int = 600,
|
|
520
545
|
) -> ViewerHandle:
|
|
521
546
|
"""Serve a snapshot of this scene on a background thread and return
|
|
522
547
|
immediately, instead of blocking like `SimViewLauncher`/`SimViewServer.run`.
|
|
@@ -532,6 +557,10 @@ class SimulationScene:
|
|
|
532
557
|
Multiple concurrent `show()` calls (on the same or different scenes)
|
|
533
558
|
are fine -- each gets its own server thread and port (via
|
|
534
559
|
`find_free_port`).
|
|
560
|
+
|
|
561
|
+
`view` is a view-link fragment (see `simview.view_hash`) the inline
|
|
562
|
+
iframe opens with, e.g. a fixed camera or `ui=False`; `height` is the
|
|
563
|
+
iframe height in pixels.
|
|
535
564
|
"""
|
|
536
565
|
if not self.model.is_complete:
|
|
537
566
|
raise ValueError(
|
|
@@ -545,6 +574,8 @@ class SimulationScene:
|
|
|
545
574
|
host=host,
|
|
546
575
|
preferred_port=preferred_port,
|
|
547
576
|
thread_name="simview-show-server",
|
|
577
|
+
view=view,
|
|
578
|
+
iframe_height=height,
|
|
548
579
|
)
|
|
549
580
|
|
|
550
581
|
logger.info("SimView viewer running on %s", handle.url)
|
|
@@ -710,10 +741,18 @@ class SimulationScene:
|
|
|
710
741
|
available_attributes: list[OptionalBodyStateAttribute | str] | None = None,
|
|
711
742
|
parent: str | None = None,
|
|
712
743
|
local_transform: LocalTransformLike | None = None,
|
|
744
|
+
color: Sequence[float] | None = None,
|
|
745
|
+
opacity: float | None = None,
|
|
746
|
+
visible: bool = True,
|
|
713
747
|
**kwargs,
|
|
714
748
|
) -> None:
|
|
715
749
|
"""Creates and adds a dynamic body to the simulation model.
|
|
716
750
|
|
|
751
|
+
``color`` (RGB in [0, 1]) and ``opacity`` (in (0, 1]) style the
|
|
752
|
+
mesh/primitive representation (box/sphere/cylinder/mesh); a point
|
|
753
|
+
cloud's per-point colour is its shape's own ``color``. ``visible=False``
|
|
754
|
+
hides the body initially.
|
|
755
|
+
|
|
717
756
|
``parent``/``local_transform`` attach this body to another body already
|
|
718
757
|
in the model, instead of it moving in world space:
|
|
719
758
|
|
|
@@ -734,6 +773,9 @@ class SimulationScene:
|
|
|
734
773
|
available_attributes=available_attributes,
|
|
735
774
|
parent=parent,
|
|
736
775
|
local_transform=local_transform,
|
|
776
|
+
color=color,
|
|
777
|
+
opacity=opacity,
|
|
778
|
+
visible=visible,
|
|
737
779
|
**kwargs,
|
|
738
780
|
)
|
|
739
781
|
)
|
|
@@ -636,9 +636,13 @@ class ViewerHandle:
|
|
|
636
636
|
preferred_port: int = 5420,
|
|
637
637
|
thread_name: str = "simview-server",
|
|
638
638
|
log_level: str = "info",
|
|
639
|
+
view: str | None = None,
|
|
640
|
+
iframe_height: int = 600,
|
|
639
641
|
) -> None:
|
|
640
642
|
self.host = host
|
|
641
643
|
self.port = find_free_port(host, preferred_port)
|
|
644
|
+
self.view = view
|
|
645
|
+
self.iframe_height = iframe_height
|
|
642
646
|
|
|
643
647
|
# Prefer uvicorn's modern sansio websocket implementation when the
|
|
644
648
|
# `websockets` package is available -- the default "auto" still selects
|
|
@@ -678,6 +682,10 @@ class ViewerHandle:
|
|
|
678
682
|
def url(self) -> str:
|
|
679
683
|
return f"http://{dialable_host(self.host)}:{self.port}"
|
|
680
684
|
|
|
685
|
+
def url_for(self, view: str) -> str:
|
|
686
|
+
"""`url` with a view-link fragment (see `simview.view_hash`) appended."""
|
|
687
|
+
return f"{self.url}/#{view.lstrip('#')}"
|
|
688
|
+
|
|
681
689
|
def stop(self) -> None:
|
|
682
690
|
"""Signal the server to exit and wait for its thread to finish.
|
|
683
691
|
|
|
@@ -692,8 +700,9 @@ class ViewerHandle:
|
|
|
692
700
|
"""Jupyter calls this automatically when the handle is the result of
|
|
693
701
|
a cell, embedding the viewer inline without the user having to open a
|
|
694
702
|
separate browser tab."""
|
|
703
|
+
src = self.url_for(self.view) if self.view else self.url
|
|
695
704
|
return (
|
|
696
|
-
f'<iframe src="{
|
|
705
|
+
f'<iframe src="{src}" width="100%" height="{self.iframe_height}" '
|
|
697
706
|
f'style="border:none;" allow="fullscreen"></iframe>'
|
|
698
707
|
)
|
|
699
708
|
|
|
@@ -996,3 +996,15 @@ body {
|
|
|
996
996
|
.terrain-profile-controls .sv-control-group select {
|
|
997
997
|
max-width: 160px;
|
|
998
998
|
}
|
|
999
|
+
|
|
1000
|
+
/* Embedded mode (#ui=0): hide all floating chrome; the loading splash stays. */
|
|
1001
|
+
.sv-embed .lil-gui.root,
|
|
1002
|
+
.sv-embed .lil-gui.autoPlace,
|
|
1003
|
+
.sv-embed .sv-playback,
|
|
1004
|
+
.sv-embed .body-state-window,
|
|
1005
|
+
.sv-embed .analysis-container,
|
|
1006
|
+
.sv-embed .sv-legend,
|
|
1007
|
+
.sv-embed .batch-legend-container,
|
|
1008
|
+
.sv-embed .sv-live-badge {
|
|
1009
|
+
display: none !important;
|
|
1010
|
+
}
|
|
@@ -22,7 +22,8 @@ import { StateStore } from "./components/StateStore.js";
|
|
|
22
22
|
import { WindowedField } from "./components/WindowedField.js";
|
|
23
23
|
import { bytesPerFrame, shouldWindowField } from "./utils/blobWindow.js";
|
|
24
24
|
import { shouldFollowLive } from "./utils/liveFollow.js";
|
|
25
|
-
import { parseViewState } from "./utils/viewState.js";
|
|
25
|
+
import { parseViewState, parseStartupOptions, serializeViewState, toggleMapFromUiState } from "./utils/viewState.js";
|
|
26
|
+
import { mergeUiDefaults, applyViewerDomDefaults } from "./utils/viewerDefaults.js";
|
|
26
27
|
|
|
27
28
|
export class SimView {
|
|
28
29
|
constructor() {
|
|
@@ -52,6 +53,9 @@ export class SimView {
|
|
|
52
53
|
// flat static files (model.json, states.json, blob/N) instead of the
|
|
53
54
|
// Python backend API endpoints. Set to null in normal server mode.
|
|
54
55
|
this.staticBase = null;
|
|
56
|
+
this._rafId = null;
|
|
57
|
+
this._lastDispatchedFrame = -1;
|
|
58
|
+
this._onHashChange = () => this.applyViewStateFromHash();
|
|
55
59
|
}
|
|
56
60
|
|
|
57
61
|
static run() {
|
|
@@ -64,7 +68,12 @@ export class SimView {
|
|
|
64
68
|
simView.staticBase = window.__simviewStaticBase;
|
|
65
69
|
console.log(`SimView: static demo mode, base='${simView.staticBase}'`);
|
|
66
70
|
}
|
|
67
|
-
|
|
71
|
+
// Startup hash keys (see parseStartupOptions): `data` wins over the global.
|
|
72
|
+
const { data, ui } = parseStartupOptions(location.hash);
|
|
73
|
+
if (data) simView.staticBase = data;
|
|
74
|
+
if (!ui) document.body.classList.add("sv-embed");
|
|
75
|
+
window.simview = simView;
|
|
76
|
+
window.__debugSimView = simView; // alias kept for e2e tests / downstream consumers
|
|
68
77
|
simView.initAndAnimate();
|
|
69
78
|
}
|
|
70
79
|
|
|
@@ -439,6 +448,11 @@ export class SimView {
|
|
|
439
448
|
}
|
|
440
449
|
}
|
|
441
450
|
|
|
451
|
+
// Authoring-time UI defaults (see utils/viewerDefaults.js). After the
|
|
452
|
+
// auto-detect above so they win, and before any Body/Terrain/Controls
|
|
453
|
+
// read uiState.
|
|
454
|
+
this.uiState = mergeUiDefaults(this.uiState, model.viewerDefaults?.ui);
|
|
455
|
+
|
|
442
456
|
if (Array.isArray(model.bodies)) {
|
|
443
457
|
model.bodies.forEach((bodyData) => {
|
|
444
458
|
const body = new Body(bodyData, this);
|
|
@@ -499,6 +513,7 @@ export class SimView {
|
|
|
499
513
|
this.batchLegend = new BatchLegend(this);
|
|
500
514
|
}
|
|
501
515
|
this.animationController = new AnimationController(this, model.dt);
|
|
516
|
+
applyViewerDomDefaults(this, model.viewerDefaults);
|
|
502
517
|
} catch (error) {
|
|
503
518
|
console.error("Error during initFromModel:", error);
|
|
504
519
|
const splash = document.getElementById("loading-splash");
|
|
@@ -514,9 +529,11 @@ export class SimView {
|
|
|
514
529
|
this.scene = new Scene(this);
|
|
515
530
|
await this.loadData();
|
|
516
531
|
this.applyViewStateFromHash();
|
|
532
|
+
window.addEventListener("hashchange", this._onHashChange);
|
|
517
533
|
this.animate();
|
|
518
534
|
const splash = document.getElementById("loading-splash");
|
|
519
535
|
if (splash) splash.remove();
|
|
536
|
+
window.dispatchEvent(new CustomEvent("simview:ready", { detail: { simview: this } }));
|
|
520
537
|
} catch (error) {
|
|
521
538
|
console.error("Initialization failed:", error);
|
|
522
539
|
const splash = document.getElementById("loading-splash");
|
|
@@ -543,6 +560,14 @@ export class SimView {
|
|
|
543
560
|
// independently guarded.
|
|
544
561
|
applyViewStateFromHash() {
|
|
545
562
|
const state = parseViewState(location.hash);
|
|
563
|
+
if (state) this.setViewState(state);
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
/**
|
|
567
|
+
* Applies a parsed view-state object (see utils/viewState.js): batch, pause +
|
|
568
|
+
* seek when `time` is present, camera, and UI toggles. Never touches location.hash.
|
|
569
|
+
*/
|
|
570
|
+
setViewState(state) {
|
|
546
571
|
if (!state) return;
|
|
547
572
|
|
|
548
573
|
try {
|
|
@@ -584,6 +609,53 @@ export class SimView {
|
|
|
584
609
|
}
|
|
585
610
|
}
|
|
586
611
|
|
|
612
|
+
/** Returns the current view state: the object "Copy view link" serialises. */
|
|
613
|
+
getViewState() {
|
|
614
|
+
const { camera, controls } = this.scene;
|
|
615
|
+
return {
|
|
616
|
+
time: this.animationController ? this.animationController.getCurrentTime() : undefined,
|
|
617
|
+
camera: {
|
|
618
|
+
position: camera.position.clone(),
|
|
619
|
+
target: controls.target.clone(),
|
|
620
|
+
fov: camera.fov,
|
|
621
|
+
},
|
|
622
|
+
batchIndex: this.batchManager ? this.batchManager.currentlyActiveBatch : undefined,
|
|
623
|
+
bodyVisualizationMode: this.uiState.bodyVisualizationMode,
|
|
624
|
+
terrainColorMode: this.uiState.terrainColorMode,
|
|
625
|
+
toggles: toggleMapFromUiState(this.uiState),
|
|
626
|
+
};
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
/**
|
|
630
|
+
* Moves the camera and orbit target together so the target lands on the body's
|
|
631
|
+
* current world position. Returns false if the body/position isn't available yet.
|
|
632
|
+
*/
|
|
633
|
+
focusBody(name, batchIndex = this.batchManager.currentlyActiveBatch) {
|
|
634
|
+
const pos = this.bodies && this.bodies.get(name) && this.bodies.get(name).positions[batchIndex];
|
|
635
|
+
if (!pos) return false;
|
|
636
|
+
const offset = this.batchManager.getBatchOffset(batchIndex);
|
|
637
|
+
const target = new THREE.Vector3(pos.x + offset.x, pos.y + offset.y, pos.z + offset.z);
|
|
638
|
+
const { camera, controls } = this.scene;
|
|
639
|
+
camera.position.add(target.clone().sub(controls.target));
|
|
640
|
+
controls.target.copy(target);
|
|
641
|
+
controls.update();
|
|
642
|
+
return true;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
/** Stops the render loop, closes the live socket, and releases the scene, UI and renderer. */
|
|
646
|
+
destroy() {
|
|
647
|
+
cancelAnimationFrame(this._rafId);
|
|
648
|
+
if (this.liveSocket) this.liveSocket.close();
|
|
649
|
+
window.removeEventListener("hashchange", this._onHashChange);
|
|
650
|
+
this.disposeOfAll();
|
|
651
|
+
if (this.scene) {
|
|
652
|
+
this.scene.renderer.dispose();
|
|
653
|
+
this.scene.renderer.domElement.remove();
|
|
654
|
+
}
|
|
655
|
+
if (window.simview === this) window.simview = null;
|
|
656
|
+
if (window.__debugSimView === this) window.__debugSimView = null;
|
|
657
|
+
}
|
|
658
|
+
|
|
587
659
|
disposeOfAll() {
|
|
588
660
|
if (this.bodies) {
|
|
589
661
|
for (const body of this.bodies.values()) {
|
|
@@ -637,12 +709,19 @@ export class SimView {
|
|
|
637
709
|
}
|
|
638
710
|
|
|
639
711
|
animate() {
|
|
640
|
-
requestAnimationFrame(this.animate);
|
|
712
|
+
this._rafId = requestAnimationFrame(this.animate);
|
|
641
713
|
const now = performance.now();
|
|
642
714
|
|
|
643
715
|
// 1. Update states and time
|
|
644
716
|
if (this.animationController) {
|
|
645
717
|
this.animationController.animate(now);
|
|
718
|
+
const index = this.animationController.getCurrentStateIndex();
|
|
719
|
+
if (index !== this._lastDispatchedFrame && this.animationController.store) {
|
|
720
|
+
this._lastDispatchedFrame = index;
|
|
721
|
+
window.dispatchEvent(
|
|
722
|
+
new CustomEvent("simview:frame", { detail: { index, time: this.animationController.getCurrentTime() } })
|
|
723
|
+
);
|
|
724
|
+
}
|
|
646
725
|
}
|
|
647
726
|
|
|
648
727
|
// 2. Update UI components
|
|
@@ -65,7 +65,7 @@ export class Scene {
|
|
|
65
65
|
this.camera.aspect = window.innerWidth / window.innerHeight;
|
|
66
66
|
this.camera.updateProjectionMatrix();
|
|
67
67
|
this.renderer.setPixelRatio(window.devicePixelRatio);
|
|
68
|
-
this.renderer.setSize(window.innerWidth, window.innerHeight
|
|
68
|
+
this.renderer.setSize(window.innerWidth, window.innerHeight);
|
|
69
69
|
}
|
|
70
70
|
|
|
71
71
|
createRenderer() {
|
|
@@ -124,14 +124,14 @@ const SHAPE_CONFIG = {
|
|
|
124
124
|
opacity: 0.7,
|
|
125
125
|
alphaTest: 0.5,
|
|
126
126
|
transparent: false,
|
|
127
|
-
texture: "
|
|
127
|
+
texture: new URL("../textures/points/ball1.png", import.meta.url).href,
|
|
128
128
|
},
|
|
129
129
|
contactPoints: {
|
|
130
130
|
size: 0.7,
|
|
131
131
|
opacity: 1.0,
|
|
132
132
|
alphaTest: 0.5,
|
|
133
133
|
transparent: false,
|
|
134
|
-
texture: "
|
|
134
|
+
texture: new URL("../textures/contacts/red-cross0.png", import.meta.url).href,
|
|
135
135
|
},
|
|
136
136
|
};
|
|
137
137
|
|
|
@@ -92,6 +92,7 @@ export class Body {
|
|
|
92
92
|
this.isPointCloud = bodyData.shape?.type === "pointcloud";
|
|
93
93
|
this.representations = { mesh: [], wireframe: [], points: [] };
|
|
94
94
|
this.createBatchGroups(bodyData);
|
|
95
|
+
if (bodyData.visible === false) this.group.visible = false;
|
|
95
96
|
|
|
96
97
|
// Position/orientation history across all loaded states, per batch. Local
|
|
97
98
|
// (un-offset) coordinates, shared by trail rendering and error metrics.
|
|
@@ -482,6 +483,13 @@ export class Body {
|
|
|
482
483
|
let material;
|
|
483
484
|
if (type === "mesh") {
|
|
484
485
|
material = createMesh(source, config).material;
|
|
486
|
+
// Per-body look declared at authoring time (SimViewBody.color/opacity).
|
|
487
|
+
if (bodyData.color) material.color.setRGB(...bodyData.color);
|
|
488
|
+
if (bodyData.opacity != null) {
|
|
489
|
+
material.transparent = true;
|
|
490
|
+
material.opacity = bodyData.opacity;
|
|
491
|
+
material.depthWrite = false;
|
|
492
|
+
}
|
|
485
493
|
} else if (type === "wireframe") {
|
|
486
494
|
// InstancedMesh only works with Mesh primitives, so we use a Mesh material with wireframe: true
|
|
487
495
|
material = new THREE.MeshBasicMaterial({
|