simview 6.0.1__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.
Files changed (105) hide show
  1. {simview-6.0.1/simview.egg-info → simview-6.1.0}/PKG-INFO +2 -1
  2. {simview-6.0.1 → simview-6.1.0}/README.md +1 -0
  3. {simview-6.0.1 → simview-6.1.0}/pyproject.toml +1 -1
  4. {simview-6.0.1 → simview-6.1.0}/simview/__init__.py +4 -1
  5. {simview-6.0.1 → simview-6.1.0}/simview/columnar.py +30 -0
  6. {simview-6.0.1 → simview-6.1.0}/simview/diff.py +1 -1
  7. {simview-6.0.1 → simview-6.1.0}/simview/info.py +2 -2
  8. {simview-6.0.1 → simview-6.1.0}/simview/live.py +4 -0
  9. {simview-6.0.1 → simview-6.1.0}/simview/model.py +57 -3
  10. {simview-6.0.1 → simview-6.1.0}/simview/scene.py +42 -0
  11. {simview-6.0.1 → simview-6.1.0}/simview/server.py +10 -1
  12. {simview-6.0.1 → simview-6.1.0}/simview/static/css/controls.css +12 -0
  13. {simview-6.0.1 → simview-6.1.0}/simview/static/js/SimView.js +82 -3
  14. {simview-6.0.1 → simview-6.1.0}/simview/static/js/config.js +2 -2
  15. {simview-6.0.1 → simview-6.1.0}/simview/static/js/objects/Body.js +8 -0
  16. {simview-6.0.1 → simview-6.1.0}/simview/static/js/ui/BodyStateWindow.js +7 -11
  17. {simview-6.0.1 → simview-6.1.0}/simview/static/js/ui/Controls.js +5 -16
  18. {simview-6.0.1 → simview-6.1.0}/simview/static/js/ui/PlaybackControls.js +2 -0
  19. simview-6.1.0/simview/static/js/utils/keyboard.js +21 -0
  20. {simview-6.0.1 → simview-6.1.0}/simview/static/js/utils/viewState.js +9 -0
  21. simview-6.1.0/simview/static/js/utils/viewerDefaults.js +70 -0
  22. {simview-6.0.1 → simview-6.1.0}/simview/templates/index.html +5 -5
  23. {simview-6.0.1 → simview-6.1.0}/simview/terrain.py +1 -1
  24. simview-6.1.0/simview/view.py +90 -0
  25. {simview-6.0.1 → simview-6.1.0/simview.egg-info}/PKG-INFO +2 -1
  26. {simview-6.0.1 → simview-6.1.0}/simview.egg-info/SOURCES.txt +6 -1
  27. simview-6.1.0/tests/test_embed_defaults.py +75 -0
  28. {simview-6.0.1 → simview-6.1.0}/tests/test_server.py +8 -8
  29. simview-6.1.0/tests/test_view.py +74 -0
  30. {simview-6.0.1 → simview-6.1.0}/LICENSE +0 -0
  31. {simview-6.0.1 → simview-6.1.0}/MANIFEST.in +0 -0
  32. {simview-6.0.1 → simview-6.1.0}/setup.cfg +0 -0
  33. {simview-6.0.1 → simview-6.1.0}/simview/__main__.py +0 -0
  34. {simview-6.0.1 → simview-6.1.0}/simview/launcher.py +0 -0
  35. {simview-6.0.1 → simview-6.1.0}/simview/merge.py +0 -0
  36. {simview-6.0.1 → simview-6.1.0}/simview/py.typed +0 -0
  37. {simview-6.0.1 → simview-6.1.0}/simview/remote.py +0 -0
  38. {simview-6.0.1 → simview-6.1.0}/simview/render.py +0 -0
  39. {simview-6.0.1 → simview-6.1.0}/simview/state.py +0 -0
  40. {simview-6.0.1 → simview-6.1.0}/simview/static/js/components/AnimationController.js +0 -0
  41. {simview-6.0.1 → simview-6.1.0}/simview/static/js/components/BatchManager.js +0 -0
  42. {simview-6.0.1 → simview-6.1.0}/simview/static/js/components/InteractionController.js +0 -0
  43. {simview-6.0.1 → simview-6.1.0}/simview/static/js/components/Scene.js +0 -0
  44. {simview-6.0.1 → simview-6.1.0}/simview/static/js/components/StateStore.js +0 -0
  45. {simview-6.0.1 → simview-6.1.0}/simview/static/js/components/WindowedField.js +0 -0
  46. {simview-6.0.1 → simview-6.1.0}/simview/static/js/main.js +0 -0
  47. {simview-6.0.1 → simview-6.1.0}/simview/static/js/objects/StaticObject.js +0 -0
  48. {simview-6.0.1 → simview-6.1.0}/simview/static/js/objects/Terrain.js +0 -0
  49. {simview-6.0.1 → simview-6.1.0}/simview/static/js/objects/colormap.js +0 -0
  50. {simview-6.0.1 → simview-6.1.0}/simview/static/js/objects/similarity.js +0 -0
  51. {simview-6.0.1 → simview-6.1.0}/simview/static/js/objects/utils.js +0 -0
  52. {simview-6.0.1 → simview-6.1.0}/simview/static/js/ui/AnalysisPanel.js +0 -0
  53. {simview-6.0.1 → simview-6.1.0}/simview/static/js/ui/BatchLegend.js +0 -0
  54. {simview-6.0.1 → simview-6.1.0}/simview/static/js/ui/ErrorMetrics.js +0 -0
  55. {simview-6.0.1 → simview-6.1.0}/simview/static/js/ui/Legend.js +0 -0
  56. {simview-6.0.1 → simview-6.1.0}/simview/static/js/ui/ScalarPlotter.js +0 -0
  57. {simview-6.0.1 → simview-6.1.0}/simview/static/js/ui/TerrainProfile.js +0 -0
  58. {simview-6.0.1 → simview-6.1.0}/simview/static/js/ui/chartControls.js +0 -0
  59. {simview-6.0.1 → simview-6.1.0}/simview/static/js/utils/batchPresets.js +0 -0
  60. {simview-6.0.1 → simview-6.1.0}/simview/static/js/utils/batchVisibility.js +0 -0
  61. {simview-6.0.1 → simview-6.1.0}/simview/static/js/utils/blobCodec.js +0 -0
  62. {simview-6.0.1 → simview-6.1.0}/simview/static/js/utils/blobWindow.js +0 -0
  63. {simview-6.0.1 → simview-6.1.0}/simview/static/js/utils/bodyTransforms.js +0 -0
  64. {simview-6.0.1 → simview-6.1.0}/simview/static/js/utils/cameraRange.js +0 -0
  65. {simview-6.0.1 → simview-6.1.0}/simview/static/js/utils/csv.js +0 -0
  66. {simview-6.0.1 → simview-6.1.0}/simview/static/js/utils/episodes.js +0 -0
  67. {simview-6.0.1 → simview-6.1.0}/simview/static/js/utils/errorMath.js +0 -0
  68. {simview-6.0.1 → simview-6.1.0}/simview/static/js/utils/interpolate.js +0 -0
  69. {simview-6.0.1 → simview-6.1.0}/simview/static/js/utils/liveFollow.js +0 -0
  70. {simview-6.0.1 → simview-6.1.0}/simview/static/js/utils/terrainSample.js +0 -0
  71. {simview-6.0.1 → simview-6.1.0}/simview/static/js/utils/uplot.js +0 -0
  72. {simview-6.0.1 → simview-6.1.0}/simview/static/lib/js-colormaps.js +0 -0
  73. {simview-6.0.1 → simview-6.1.0}/simview/static/lib/three-0.174.0/addons/controls/OrbitControls.js +0 -0
  74. {simview-6.0.1 → simview-6.1.0}/simview/static/lib/three-0.174.0/addons/libs/lil-gui.module.min.js +0 -0
  75. {simview-6.0.1 → simview-6.1.0}/simview/static/lib/three-0.174.0/three.core.js +0 -0
  76. {simview-6.0.1 → simview-6.1.0}/simview/static/lib/three-0.174.0/three.module.js +0 -0
  77. {simview-6.0.1 → simview-6.1.0}/simview/static/lib/uPlot.esm.js +0 -0
  78. {simview-6.0.1 → simview-6.1.0}/simview/static/lib/uPlot.min.css +0 -0
  79. {simview-6.0.1 → simview-6.1.0}/simview/static/textures/contacts/red-cross0.png +0 -0
  80. {simview-6.0.1 → simview-6.1.0}/simview/static/textures/points/ball1.png +0 -0
  81. {simview-6.0.1 → simview-6.1.0}/simview/utils.py +0 -0
  82. {simview-6.0.1 → simview-6.1.0}/simview.egg-info/dependency_links.txt +0 -0
  83. {simview-6.0.1 → simview-6.1.0}/simview.egg-info/entry_points.txt +0 -0
  84. {simview-6.0.1 → simview-6.1.0}/simview.egg-info/requires.txt +0 -0
  85. {simview-6.0.1 → simview-6.1.0}/simview.egg-info/top_level.txt +0 -0
  86. {simview-6.0.1 → simview-6.1.0}/tests/test_batch_selection.py +0 -0
  87. {simview-6.0.1 → simview-6.1.0}/tests/test_cli.py +0 -0
  88. {simview-6.0.1 → simview-6.1.0}/tests/test_columnar_ondisk.py +0 -0
  89. {simview-6.0.1 → simview-6.1.0}/tests/test_columnar_states.py +0 -0
  90. {simview-6.0.1 → simview-6.1.0}/tests/test_contacts.py +0 -0
  91. {simview-6.0.1 → simview-6.1.0}/tests/test_diff.py +0 -0
  92. {simview-6.0.1 → simview-6.1.0}/tests/test_episodes.py +0 -0
  93. {simview-6.0.1 → simview-6.1.0}/tests/test_info.py +0 -0
  94. {simview-6.0.1 → simview-6.1.0}/tests/test_launcher.py +0 -0
  95. {simview-6.0.1 → simview-6.1.0}/tests/test_live.py +0 -0
  96. {simview-6.0.1 → simview-6.1.0}/tests/test_merge.py +0 -0
  97. {simview-6.0.1 → simview-6.1.0}/tests/test_pointcloud_features.py +0 -0
  98. {simview-6.0.1 → simview-6.1.0}/tests/test_remote.py +0 -0
  99. {simview-6.0.1 → simview-6.1.0}/tests/test_roundtrip.py +0 -0
  100. {simview-6.0.1 → simview-6.1.0}/tests/test_scene.py +0 -0
  101. {simview-6.0.1 → simview-6.1.0}/tests/test_show.py +0 -0
  102. {simview-6.0.1 → simview-6.1.0}/tests/test_terrain.py +0 -0
  103. {simview-6.0.1 → simview-6.1.0}/tests/test_terrain_query.py +0 -0
  104. {simview-6.0.1 → simview-6.1.0}/tests/test_trajectory.py +0 -0
  105. {simview-6.0.1 → 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.0.1
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
  ---
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "simview"
7
- version = "6.0.1"
7
+ version = "6.1.0"
8
8
  readme = "README.md"
9
9
  requires-python = ">=3.12"
10
10
  license = "BSD-3-Clause"
@@ -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, einops), so they are imported lazily: a viewing-only
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/einops/numpy) -- see CLAUDE.md. Shares its blob-decoding and
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/einops/numpy) -- see
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/einops at module scope, the latter fastapi/uvicorn just to
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
- kwargs["color"] = color
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
- kwargs["embedding"] = embedding
400
- return SimViewBody.create(
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="{self.url}" width="100%" height="600" '
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
- window.__debugSimView = simView;
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
@@ -124,14 +124,14 @@ const SHAPE_CONFIG = {
124
124
  opacity: 0.7,
125
125
  alphaTest: 0.5,
126
126
  transparent: false,
127
- texture: "static/textures/points/ball1.png",
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: "static/textures/contacts/red-cross0.png",
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({