simview 3.2__py3-none-any.whl

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 (163) hide show
  1. simview/__init__.py +69 -0
  2. simview/__main__.py +174 -0
  3. simview/launcher.py +109 -0
  4. simview/live.py +204 -0
  5. simview/merge.py +593 -0
  6. simview/model.py +690 -0
  7. simview/py.typed +0 -0
  8. simview/scene.py +603 -0
  9. simview/server.py +739 -0
  10. simview/state.py +190 -0
  11. simview/static/css/controls.css +36 -0
  12. simview/static/js/SimView.js +601 -0
  13. simview/static/js/components/AnimationController.js +654 -0
  14. simview/static/js/components/BatchManager.js +198 -0
  15. simview/static/js/components/InteractionController.js +446 -0
  16. simview/static/js/components/InteractionControls.js +116 -0
  17. simview/static/js/components/Scene.js +166 -0
  18. simview/static/js/components/StateStore.js +188 -0
  19. simview/static/js/config.js +233 -0
  20. simview/static/js/main.js +3 -0
  21. simview/static/js/objects/Body.js +616 -0
  22. simview/static/js/objects/StaticObject.js +181 -0
  23. simview/static/js/objects/Terrain.js +514 -0
  24. simview/static/js/objects/utils.js +378 -0
  25. simview/static/js/ui/AnalysisPanel.js +233 -0
  26. simview/static/js/ui/BatchLegend.js +207 -0
  27. simview/static/js/ui/BodyStateWindow.js +521 -0
  28. simview/static/js/ui/Controls.js +586 -0
  29. simview/static/js/ui/ErrorMetrics.js +664 -0
  30. simview/static/js/ui/Legend.js +87 -0
  31. simview/static/js/ui/PlaybackControls.js +314 -0
  32. simview/static/js/ui/ScalarPlotter.js +575 -0
  33. simview/static/js/utils/blobCodec.js +73 -0
  34. simview/static/js/utils/bodyTransforms.js +151 -0
  35. simview/static/js/utils/csv.js +46 -0
  36. simview/static/js/utils/errorMath.js +69 -0
  37. simview/static/js/utils/injectStyles.js +16 -0
  38. simview/static/js/utils/interpolate.js +65 -0
  39. simview/static/js/utils/liveFollow.js +17 -0
  40. simview/static/js/utils/loadRecordingLibs.js +34 -0
  41. simview/static/js/utils/viewState.js +226 -0
  42. simview/static/lib/chroma-js-3.1.2/index.min.js +8 -0
  43. simview/static/lib/chroma-js-3.1.2/src/Color.js +49 -0
  44. simview/static/lib/chroma-js-3.1.2/src/chroma.js +10 -0
  45. simview/static/lib/chroma-js-3.1.2/src/colors/colorbrewer.js +81 -0
  46. simview/static/lib/chroma-js-3.1.2/src/colors/w3cx11.js +164 -0
  47. simview/static/lib/chroma-js-3.1.2/src/generator/average.js +88 -0
  48. simview/static/lib/chroma-js-3.1.2/src/generator/bezier.js +86 -0
  49. simview/static/lib/chroma-js-3.1.2/src/generator/blend.js +57 -0
  50. simview/static/lib/chroma-js-3.1.2/src/generator/cubehelix.js +87 -0
  51. simview/static/lib/chroma-js-3.1.2/src/generator/mix.js +19 -0
  52. simview/static/lib/chroma-js-3.1.2/src/generator/random.js +12 -0
  53. simview/static/lib/chroma-js-3.1.2/src/generator/scale.js +394 -0
  54. simview/static/lib/chroma-js-3.1.2/src/interpolator/_hsx.js +59 -0
  55. simview/static/lib/chroma-js-3.1.2/src/interpolator/hcg.js +12 -0
  56. simview/static/lib/chroma-js-3.1.2/src/interpolator/hsi.js +12 -0
  57. simview/static/lib/chroma-js-3.1.2/src/interpolator/hsl.js +12 -0
  58. simview/static/lib/chroma-js-3.1.2/src/interpolator/hsv.js +12 -0
  59. simview/static/lib/chroma-js-3.1.2/src/interpolator/index.js +1 -0
  60. simview/static/lib/chroma-js-3.1.2/src/interpolator/lab.js +19 -0
  61. simview/static/lib/chroma-js-3.1.2/src/interpolator/lch.js +13 -0
  62. simview/static/lib/chroma-js-3.1.2/src/interpolator/lrgb.js +19 -0
  63. simview/static/lib/chroma-js-3.1.2/src/interpolator/num.js +15 -0
  64. simview/static/lib/chroma-js-3.1.2/src/interpolator/oklab.js +19 -0
  65. simview/static/lib/chroma-js-3.1.2/src/interpolator/oklch.js +12 -0
  66. simview/static/lib/chroma-js-3.1.2/src/interpolator/rgb.js +18 -0
  67. simview/static/lib/chroma-js-3.1.2/src/io/cmyk/cmyk2rgb.js +16 -0
  68. simview/static/lib/chroma-js-3.1.2/src/io/cmyk/index.js +27 -0
  69. simview/static/lib/chroma-js-3.1.2/src/io/cmyk/rgb2cmyk.js +17 -0
  70. simview/static/lib/chroma-js-3.1.2/src/io/css/css2rgb.js +238 -0
  71. simview/static/lib/chroma-js-3.1.2/src/io/css/hsl2css.js +26 -0
  72. simview/static/lib/chroma-js-3.1.2/src/io/css/index.js +27 -0
  73. simview/static/lib/chroma-js-3.1.2/src/io/css/lab2css.js +24 -0
  74. simview/static/lib/chroma-js-3.1.2/src/io/css/lch2css.js +24 -0
  75. simview/static/lib/chroma-js-3.1.2/src/io/css/oklab2css.js +16 -0
  76. simview/static/lib/chroma-js-3.1.2/src/io/css/oklch2css.js +16 -0
  77. simview/static/lib/chroma-js-3.1.2/src/io/css/rgb2css.js +61 -0
  78. simview/static/lib/chroma-js-3.1.2/src/io/gl/index.js +22 -0
  79. simview/static/lib/chroma-js-3.1.2/src/io/hcg/hcg2rgb.js +55 -0
  80. simview/static/lib/chroma-js-3.1.2/src/io/hcg/index.js +27 -0
  81. simview/static/lib/chroma-js-3.1.2/src/io/hcg/rgb2hcg.js +23 -0
  82. simview/static/lib/chroma-js-3.1.2/src/io/hex/hex2rgb.js +56 -0
  83. simview/static/lib/chroma-js-3.1.2/src/io/hex/index.js +29 -0
  84. simview/static/lib/chroma-js-3.1.2/src/io/hex/rgb2hex.js +29 -0
  85. simview/static/lib/chroma-js-3.1.2/src/io/hsi/hsi2rgb.js +45 -0
  86. simview/static/lib/chroma-js-3.1.2/src/io/hsi/index.js +27 -0
  87. simview/static/lib/chroma-js-3.1.2/src/io/hsi/rgb2hsi.js +31 -0
  88. simview/static/lib/chroma-js-3.1.2/src/io/hsl/hsl2rgb.js +35 -0
  89. simview/static/lib/chroma-js-3.1.2/src/io/hsl/index.js +27 -0
  90. simview/static/lib/chroma-js-3.1.2/src/io/hsl/rgb2hsl.js +45 -0
  91. simview/static/lib/chroma-js-3.1.2/src/io/hsv/hsv2rgb.js +47 -0
  92. simview/static/lib/chroma-js-3.1.2/src/io/hsv/index.js +27 -0
  93. simview/static/lib/chroma-js-3.1.2/src/io/hsv/rgb2hsv.js +32 -0
  94. simview/static/lib/chroma-js-3.1.2/src/io/input.js +4 -0
  95. simview/static/lib/chroma-js-3.1.2/src/io/lab/index.js +28 -0
  96. simview/static/lib/chroma-js-3.1.2/src/io/lab/lab-constants.js +120 -0
  97. simview/static/lib/chroma-js-3.1.2/src/io/lab/lab2rgb.js +101 -0
  98. simview/static/lib/chroma-js-3.1.2/src/io/lab/rgb2lab.js +67 -0
  99. simview/static/lib/chroma-js-3.1.2/src/io/lch/hcl2rgb.js +9 -0
  100. simview/static/lib/chroma-js-3.1.2/src/io/lch/index.js +35 -0
  101. simview/static/lib/chroma-js-3.1.2/src/io/lch/lab2lch.js +12 -0
  102. simview/static/lib/chroma-js-3.1.2/src/io/lch/lch2lab.js +18 -0
  103. simview/static/lib/chroma-js-3.1.2/src/io/lch/lch2rgb.js +13 -0
  104. simview/static/lib/chroma-js-3.1.2/src/io/lch/rgb2lch.js +12 -0
  105. simview/static/lib/chroma-js-3.1.2/src/io/named/index.js +30 -0
  106. simview/static/lib/chroma-js-3.1.2/src/io/num/index.js +32 -0
  107. simview/static/lib/chroma-js-3.1.2/src/io/num/num2rgb.js +13 -0
  108. simview/static/lib/chroma-js-3.1.2/src/io/num/rgb2num.js +8 -0
  109. simview/static/lib/chroma-js-3.1.2/src/io/oklab/index.js +27 -0
  110. simview/static/lib/chroma-js-3.1.2/src/io/oklab/oklab2rgb.js +34 -0
  111. simview/static/lib/chroma-js-3.1.2/src/io/oklab/rgb2oklab.js +37 -0
  112. simview/static/lib/chroma-js-3.1.2/src/io/oklch/index.js +27 -0
  113. simview/static/lib/chroma-js-3.1.2/src/io/oklch/oklch2rgb.js +13 -0
  114. simview/static/lib/chroma-js-3.1.2/src/io/oklch/rgb2oklch.js +12 -0
  115. simview/static/lib/chroma-js-3.1.2/src/io/rgb/index.js +44 -0
  116. simview/static/lib/chroma-js-3.1.2/src/io/temp/index.js +22 -0
  117. simview/static/lib/chroma-js-3.1.2/src/io/temp/rgb2temperature.js +30 -0
  118. simview/static/lib/chroma-js-3.1.2/src/io/temp/temperature2rgb.js +39 -0
  119. simview/static/lib/chroma-js-3.1.2/src/ops/alpha.js +13 -0
  120. simview/static/lib/chroma-js-3.1.2/src/ops/clipped.js +5 -0
  121. simview/static/lib/chroma-js-3.1.2/src/ops/darken.js +17 -0
  122. simview/static/lib/chroma-js-3.1.2/src/ops/get.js +13 -0
  123. simview/static/lib/chroma-js-3.1.2/src/ops/luminance.js +54 -0
  124. simview/static/lib/chroma-js-3.1.2/src/ops/mix.js +10 -0
  125. simview/static/lib/chroma-js-3.1.2/src/ops/premultiply.js +12 -0
  126. simview/static/lib/chroma-js-3.1.2/src/ops/saturate.js +15 -0
  127. simview/static/lib/chroma-js-3.1.2/src/ops/set.js +43 -0
  128. simview/static/lib/chroma-js-3.1.2/src/ops/shade.js +11 -0
  129. simview/static/lib/chroma-js-3.1.2/src/utils/analyze.js +191 -0
  130. simview/static/lib/chroma-js-3.1.2/src/utils/clip_rgb.js +15 -0
  131. simview/static/lib/chroma-js-3.1.2/src/utils/contrast.js +12 -0
  132. simview/static/lib/chroma-js-3.1.2/src/utils/contrastAPCA.js +68 -0
  133. simview/static/lib/chroma-js-3.1.2/src/utils/delta-e.js +62 -0
  134. simview/static/lib/chroma-js-3.1.2/src/utils/distance.js +17 -0
  135. simview/static/lib/chroma-js-3.1.2/src/utils/index.js +27 -0
  136. simview/static/lib/chroma-js-3.1.2/src/utils/last.js +8 -0
  137. simview/static/lib/chroma-js-3.1.2/src/utils/limit.js +5 -0
  138. simview/static/lib/chroma-js-3.1.2/src/utils/multiply-matrices.js +36 -0
  139. simview/static/lib/chroma-js-3.1.2/src/utils/scales.js +15 -0
  140. simview/static/lib/chroma-js-3.1.2/src/utils/type.js +18 -0
  141. simview/static/lib/chroma-js-3.1.2/src/utils/unpack.js +17 -0
  142. simview/static/lib/chroma-js-3.1.2/src/utils/valid.js +11 -0
  143. simview/static/lib/chroma-js-3.1.2/src/version.js +2 -0
  144. simview/static/lib/download.js +132 -0
  145. simview/static/lib/js-colormaps.js +19335 -0
  146. simview/static/lib/tar.js +334 -0
  147. simview/static/lib/three-0.174.0/addons/controls/OrbitControls.js +1556 -0
  148. simview/static/lib/three-0.174.0/addons/libs/lil-gui.module.min.js +8 -0
  149. simview/static/lib/three-0.174.0/three.core.js +48830 -0
  150. simview/static/lib/three-0.174.0/three.module.js +17313 -0
  151. simview/static/lib/uPlot.esm.js +6140 -0
  152. simview/static/lib/uPlot.min.css +2 -0
  153. simview/static/textures/contacts/red-cross0.png +0 -0
  154. simview/static/textures/points/ball0.png +0 -0
  155. simview/static/textures/points/ball1.png +0 -0
  156. simview/templates/index.html +81 -0
  157. simview/utils.py +41 -0
  158. simview-3.2.dist-info/METADATA +552 -0
  159. simview-3.2.dist-info/RECORD +163 -0
  160. simview-3.2.dist-info/WHEEL +5 -0
  161. simview-3.2.dist-info/entry_points.txt +2 -0
  162. simview-3.2.dist-info/licenses/LICENSE +28 -0
  163. simview-3.2.dist-info/top_level.txt +1 -0
simview/__init__.py ADDED
@@ -0,0 +1,69 @@
1
+ import logging
2
+ from typing import TYPE_CHECKING
3
+
4
+ CACHE_DIR = ".simview_cache"
5
+
6
+ # Library best practice: attach a NullHandler to the package root logger so
7
+ # `import simview` is silent by default for downstream users. Applications
8
+ # (including simview's own CLI, in __main__.py) are responsible for adding
9
+ # their own handler(s) if they want to see log output.
10
+ logging.getLogger(__name__).addHandler(logging.NullHandler())
11
+
12
+ # Public authoring API. These live in submodules that depend on the optional
13
+ # `authoring` extra (torch, einops), so they are imported lazily: a viewing-only
14
+ # install can still `import simview` without those dependencies, and only pays
15
+ # the import cost (and dependency requirement) when an authoring symbol is used.
16
+ _LAZY_EXPORTS = {
17
+ "SimulationScene": "simview.scene",
18
+ "ViewerHandle": "simview.scene",
19
+ "SimViewBody": "simview.model",
20
+ "SimViewStaticObject": "simview.model",
21
+ "SimViewTerrain": "simview.model",
22
+ "SimViewModel": "simview.model",
23
+ "BodyShapeType": "simview.model",
24
+ "OptionalBodyStateAttribute": "simview.model",
25
+ "SimViewBodyState": "simview.state",
26
+ "BodyTrajectory": "simview.state",
27
+ "LiveViewer": "simview.live",
28
+ }
29
+
30
+ if TYPE_CHECKING:
31
+ from simview.live import LiveViewer
32
+ from simview.model import (
33
+ BodyShapeType,
34
+ OptionalBodyStateAttribute,
35
+ SimViewBody,
36
+ SimViewModel,
37
+ SimViewStaticObject,
38
+ SimViewTerrain,
39
+ )
40
+ from simview.scene import SimulationScene, ViewerHandle
41
+ from simview.state import BodyTrajectory, SimViewBodyState
42
+
43
+ __all__ = [
44
+ "CACHE_DIR",
45
+ "SimulationScene",
46
+ "ViewerHandle",
47
+ "SimViewBody",
48
+ "SimViewStaticObject",
49
+ "SimViewTerrain",
50
+ "SimViewModel",
51
+ "BodyShapeType",
52
+ "OptionalBodyStateAttribute",
53
+ "SimViewBodyState",
54
+ "BodyTrajectory",
55
+ "LiveViewer",
56
+ ]
57
+
58
+
59
+ def __getattr__(name: str):
60
+ module_name = _LAZY_EXPORTS.get(name)
61
+ if module_name is None:
62
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
63
+ import importlib
64
+
65
+ return getattr(importlib.import_module(module_name), name)
66
+
67
+
68
+ def __dir__():
69
+ return sorted(__all__)
simview/__main__.py ADDED
@@ -0,0 +1,174 @@
1
+ import argparse
2
+ import gzip
3
+ import json
4
+ import logging
5
+ import shutil
6
+ import sys
7
+ import tempfile
8
+ from importlib.metadata import PackageNotFoundError, version
9
+ from pathlib import Path
10
+
11
+ from simview import CACHE_DIR
12
+ from simview.server import SimViewServer
13
+
14
+ logger = logging.getLogger("simview.cli")
15
+
16
+ _logging_configured = False
17
+
18
+
19
+ def _configure_logging() -> None:
20
+ """CLI entry point default: INFO-level, message-only output on stderr.
21
+
22
+ Kept minimal (no timestamps/level names) since this is user-facing CLI
23
+ chatter, not application log output meant for parsing. Idempotent, so
24
+ calling `main()` more than once in the same process (e.g. across tests)
25
+ doesn't stack up duplicate handlers.
26
+ """
27
+ global _logging_configured
28
+ if _logging_configured:
29
+ return
30
+ handler = logging.StreamHandler(sys.stderr)
31
+ handler.setFormatter(logging.Formatter("%(message)s"))
32
+ root = logging.getLogger("simview")
33
+ root.addHandler(handler)
34
+ root.setLevel(logging.INFO)
35
+ _logging_configured = True
36
+
37
+
38
+ def _package_version() -> str:
39
+ """The installed simview version, with a sensible fallback for editable
40
+ checkouts run without an installed distribution (e.g. `python -m simview`
41
+ from a source tree that was never `pip install -e`'d)."""
42
+ try:
43
+ return version("simview")
44
+ except PackageNotFoundError:
45
+ return "unknown (not installed)"
46
+
47
+
48
+ def clear_cache():
49
+ # Legacy cache directories (kept for cleanup of older installs).
50
+ for cache_dir in (Path("/tmp") / CACHE_DIR, Path.home() / ".cache" / CACHE_DIR):
51
+ if cache_dir.exists():
52
+ logger.info("Removing %s", cache_dir)
53
+ shutil.rmtree(cache_dir, ignore_errors=True)
54
+
55
+ # Temp scenes written by SimViewLauncher (tempfile.mkstemp with this prefix);
56
+ # these leak if a launched viewer is killed before cleanup runs.
57
+ removed = 0
58
+ for leftover in Path(tempfile.gettempdir()).glob("simview_viz_*.json"):
59
+ try:
60
+ leftover.unlink()
61
+ removed += 1
62
+ except OSError as e:
63
+ logger.warning("Could not remove %s: %s", leftover, e)
64
+ if removed:
65
+ logger.info("Removed %d leftover temporary scene file(s).", removed)
66
+
67
+ logger.info("Cache cleared.")
68
+
69
+
70
+ def save_merged(paths: list[Path], out_path: Path) -> None:
71
+ """Merge `paths` (must be >= 2) and write the result to `out_path`, gzipped
72
+ if it ends in .gz, without starting the server."""
73
+ if len(paths) < 2:
74
+ logger.error("Error: --save-merged requires at least 2 input files to merge.")
75
+ sys.exit(1)
76
+
77
+ from simview.merge import merge_simulation_files
78
+
79
+ merged = merge_simulation_files(paths)
80
+ payload = json.dumps(merged).encode("utf-8")
81
+ if out_path.suffix == ".gz":
82
+ payload = gzip.compress(payload, compresslevel=1)
83
+ out_path.write_bytes(payload)
84
+ logger.info("Merged scene written to %s", out_path)
85
+
86
+
87
+ def build_parser() -> argparse.ArgumentParser:
88
+ parser = argparse.ArgumentParser(description="SimView CLI")
89
+ parser.add_argument(
90
+ "inputs",
91
+ nargs="*",
92
+ help=(
93
+ "Path(s) to simulation JSON file(s) to visualize, or 'clear' to clear "
94
+ "cache. Multiple files are merged into one scene, each file's batches "
95
+ "appended as extra batches (e.g. a real-world recording plus a "
96
+ "simulated rerun)."
97
+ ),
98
+ )
99
+ parser.add_argument(
100
+ "--version",
101
+ action="store_true",
102
+ help="Print the installed simview version and exit.",
103
+ )
104
+ parser.add_argument(
105
+ "--host",
106
+ type=str,
107
+ default="127.0.0.1",
108
+ help="Host/interface for the server to bind to (default: 127.0.0.1).",
109
+ )
110
+ parser.add_argument(
111
+ "--port",
112
+ type=int,
113
+ default=5420,
114
+ help=(
115
+ "Port for the server to use (default: 5420). If it's already taken, "
116
+ "the next free port is used instead."
117
+ ),
118
+ )
119
+ parser.add_argument(
120
+ "--no-browser",
121
+ action="store_true",
122
+ help="Don't automatically open a browser tab once the server starts.",
123
+ )
124
+ parser.add_argument(
125
+ "--save-merged",
126
+ type=str,
127
+ default=None,
128
+ metavar="PATH",
129
+ help=(
130
+ "Merge the given input files and write the result to PATH instead of "
131
+ "launching the viewer. Requires at least 2 input files. Gzips the "
132
+ "output if PATH ends in .gz."
133
+ ),
134
+ )
135
+ return parser
136
+
137
+
138
+ def main():
139
+ _configure_logging()
140
+ parser = build_parser()
141
+ args = parser.parse_args()
142
+
143
+ if args.version:
144
+ print(_package_version())
145
+ return
146
+
147
+ if not args.inputs:
148
+ parser.print_help()
149
+ sys.exit(1)
150
+
151
+ if args.inputs == ["clear"]:
152
+ clear_cache()
153
+ return
154
+
155
+ paths = [Path(p) for p in args.inputs]
156
+ for path in paths:
157
+ if not (path.exists() and path.is_file()):
158
+ logger.error("Error: File '%s' not found or is not a file.", path)
159
+ sys.exit(1)
160
+
161
+ if args.save_merged:
162
+ save_merged(paths, Path(args.save_merged))
163
+ return
164
+
165
+ SimViewServer.start(
166
+ sim_path=paths if len(paths) > 1 else paths[0],
167
+ host=args.host,
168
+ preferred_port=args.port,
169
+ open_browser=not args.no_browser,
170
+ )
171
+
172
+
173
+ if __name__ == "__main__":
174
+ main()
simview/launcher.py ADDED
@@ -0,0 +1,109 @@
1
+ import gc
2
+ import logging
3
+ from pathlib import Path
4
+
5
+ from simview.server import SimViewServer
6
+ from simview.utils import find_free_port
7
+
8
+ from .scene import SimulationScene
9
+
10
+ logger = logging.getLogger("simview.launcher")
11
+
12
+
13
+ class SimViewLauncher:
14
+ def __init__(
15
+ self,
16
+ source: SimulationScene | str | Path,
17
+ ) -> None:
18
+ """
19
+ Initializes the visualizer.
20
+ - If source is a SimulationScene: it is handed to the server in memory (no
21
+ temporary file), and its internal data is cleared after the server stops.
22
+ - If source is a path: it is used directly.
23
+ """
24
+ self._scene: SimulationScene | None = None
25
+ self._sim_file_path: Path | None = None
26
+
27
+ if isinstance(source, SimulationScene):
28
+ if (
29
+ source.model is None
30
+ or not source.model.is_complete
31
+ or source.states is None
32
+ ):
33
+ raise ValueError(
34
+ "Cannot initialize visualizer: The provided SimulationScene "
35
+ "is incomplete, has no states, or has already been cleared."
36
+ )
37
+ self._scene = source
38
+ elif isinstance(source, (str, Path)):
39
+ self._sim_file_path = Path(source)
40
+ if not self._sim_file_path.exists():
41
+ raise FileNotFoundError(
42
+ f"Simulation JSON file not found at: {self._sim_file_path}"
43
+ )
44
+ logger.info(
45
+ "SimViewLauncher: Using existing simulation file: %s",
46
+ self._sim_file_path,
47
+ )
48
+ else:
49
+ raise TypeError(
50
+ "Source for SimViewLauncher must be a SimulationScene object or a file path (str/Path)."
51
+ )
52
+
53
+ def launch(self, host: str = "127.0.0.1", preferred_port: int = 5420) -> None:
54
+ """
55
+ Launches the SimViewServer, then clears the in-memory scene (if any).
56
+ """
57
+ try:
58
+ if self._scene is not None:
59
+ # Serialize the scene once and hand it to the server directly,
60
+ # avoiding a temp-file write + read-back round-trip.
61
+ logger.info("SimViewLauncher: Serving in-memory SimulationScene")
62
+ data = {
63
+ "model": self._scene.model.to_json(),
64
+ "states": self._scene.states,
65
+ }
66
+ server = SimViewServer(data=data)
67
+ port = find_free_port(host, preferred_port)
68
+ if port != preferred_port:
69
+ logger.warning(
70
+ "Preferred port %s is not available. Using port %s instead.",
71
+ preferred_port,
72
+ port,
73
+ )
74
+ server.run(host=host, port=port)
75
+ else:
76
+ # __init__ guarantees exactly one of self._scene / self._sim_file_path
77
+ # is set (it raises otherwise), so if self._scene is None here,
78
+ # self._sim_file_path must have been set.
79
+ assert self._sim_file_path is not None
80
+ SimViewServer.start(
81
+ sim_path=self._sim_file_path,
82
+ host=host,
83
+ preferred_port=preferred_port,
84
+ )
85
+ except KeyboardInterrupt:
86
+ logger.info("SimView server stopped by user.")
87
+ except Exception:
88
+ logger.exception("Error starting SimView server")
89
+ raise
90
+ finally:
91
+ self.cleanup()
92
+
93
+ def cleanup(self) -> None:
94
+ """
95
+ Clears the in-memory scene's data. Safe to call multiple times.
96
+ """
97
+ if self._scene is not None:
98
+ logger.info(
99
+ "SimViewLauncher: Clearing source SimulationScene internal data..."
100
+ )
101
+ self._scene._clear_internal_data()
102
+ self._scene = None
103
+ gc.collect()
104
+
105
+ def __enter__(self) -> "SimViewLauncher":
106
+ return self
107
+
108
+ def __exit__(self, exc_type, exc_value, traceback) -> None:
109
+ self.cleanup()
simview/live.py ADDED
@@ -0,0 +1,204 @@
1
+ import asyncio
2
+ import logging
3
+ import threading
4
+ import time
5
+
6
+ import uvicorn
7
+
8
+ from simview.scene import SimulationScene
9
+ from simview.server import SimViewServer
10
+ from simview.utils import find_free_port
11
+
12
+ logger = logging.getLogger("simview.live")
13
+
14
+ # How long to poll uvicorn's Server.started flag for in __init__ before giving
15
+ # up -- binding a localhost socket and completing FastAPI startup is normally
16
+ # well under this, so a timeout here almost always means something is wrong
17
+ # (bad host, port stolen after find_free_port checked it, ...).
18
+ _START_TIMEOUT = 10.0
19
+ _START_POLL_INTERVAL = 0.02
20
+
21
+
22
+ class _ThreadedServer:
23
+ """Runs a SimViewServer's uvicorn app on a background daemon thread.
24
+
25
+ Shared plumbing for anything that needs a non-blocking local server
26
+ (LiveViewer, SimulationScene.show): builds the uvicorn.Server, starts it
27
+ on a thread, blocks until the socket is actually bound (or raises if
28
+ startup fails/times out), and offers an idempotent `stop()`.
29
+ """
30
+
31
+ def __init__(
32
+ self,
33
+ app,
34
+ host: str = "127.0.0.1",
35
+ preferred_port: int = 5420,
36
+ thread_name: str = "simview-server",
37
+ ) -> None:
38
+ self.host = host
39
+ self.port = find_free_port(host, preferred_port)
40
+ if self.port != preferred_port:
41
+ logger.warning(
42
+ "Preferred port %s is not available. Using port %s instead.",
43
+ preferred_port,
44
+ self.port,
45
+ )
46
+
47
+ # uvloop/httptools are faster than the stdlib fallbacks but aren't
48
+ # available everywhere (uvloop doesn't support Windows). Use them
49
+ # opportunistically and fall back to uvicorn's "auto" detection.
50
+ try:
51
+ import uvloop # noqa: F401
52
+
53
+ loop = "uvloop"
54
+ except ImportError:
55
+ loop = "auto"
56
+ try:
57
+ import httptools # noqa: F401
58
+
59
+ http = "httptools"
60
+ except ImportError:
61
+ http = "auto"
62
+ config = uvicorn.Config(
63
+ app,
64
+ host=host,
65
+ port=self.port,
66
+ log_level="info",
67
+ loop=loop,
68
+ http=http,
69
+ )
70
+ self._uvicorn_server = uvicorn.Server(config)
71
+
72
+ self._thread = threading.Thread(
73
+ target=self._uvicorn_server.run, name=thread_name, daemon=True
74
+ )
75
+ self._thread.start()
76
+
77
+ # Block until the socket is actually bound, so a caller's very first
78
+ # action (push_state, an HTTP request, a browser opened right after
79
+ # construction, ...) never races server startup.
80
+ deadline = time.monotonic() + _START_TIMEOUT
81
+ while not self._uvicorn_server.started:
82
+ if not self._thread.is_alive():
83
+ raise RuntimeError("SimView server thread died during startup.")
84
+ if time.monotonic() > deadline:
85
+ raise TimeoutError(
86
+ f"SimView server did not start within {_START_TIMEOUT}s."
87
+ )
88
+ time.sleep(_START_POLL_INTERVAL)
89
+
90
+ @property
91
+ def bind_host(self) -> str:
92
+ """Host to put in URLs -- 0.0.0.0/:: aren't dialable, so localhost
93
+ stands in for them."""
94
+ return "127.0.0.1" if self.host in ("0.0.0.0", "::") else self.host
95
+
96
+ def is_alive(self) -> bool:
97
+ return self._thread.is_alive()
98
+
99
+ def stop(self) -> None:
100
+ """Signal the server to exit and wait for its thread to finish.
101
+
102
+ Idempotent -- safe to call multiple times.
103
+ """
104
+ if not self._thread.is_alive():
105
+ return
106
+ self._uvicorn_server.should_exit = True
107
+ self._thread.join(timeout=5.0)
108
+
109
+
110
+ class LiveViewer:
111
+ """Streams a running simulation to an already-open browser tab over
112
+ WebSocket, instead of the usual save-then-view flow.
113
+
114
+ ``scene`` should already have its complete model (terrain, bodies, ...) --
115
+ states are pushed incrementally afterwards via `push_state`, using the
116
+ same validation/encoding as `SimulationScene.add_state`. Because
117
+ `push_state` appends to `scene.states` exactly like `add_state` would, the
118
+ scene can still be `save()`d normally once streaming is done.
119
+ """
120
+
121
+ def __init__(
122
+ self,
123
+ scene: SimulationScene,
124
+ host: str = "127.0.0.1",
125
+ preferred_port: int = 5420,
126
+ open_browser: bool = False,
127
+ ) -> None:
128
+ if not scene.model.is_complete:
129
+ raise ValueError(
130
+ "Cannot start LiveViewer: the scene's model is not complete "
131
+ "(e.g. terrain might be missing)."
132
+ )
133
+ self.scene = scene
134
+ self.server = SimViewServer(
135
+ data={"model": scene.model.to_json(), "states": []}, live=True
136
+ )
137
+
138
+ self._threaded = _ThreadedServer(
139
+ self.server.app,
140
+ host=host,
141
+ preferred_port=preferred_port,
142
+ thread_name="simview-live-server",
143
+ )
144
+ self.host = host
145
+ self.port = self._threaded.port
146
+
147
+ logger.info(
148
+ "SimView live server running on http://%s:%s",
149
+ self._threaded.bind_host,
150
+ self.port,
151
+ )
152
+ if open_browser:
153
+ import webbrowser
154
+
155
+ webbrowser.open(f"http://{self._threaded.bind_host}:{self.port}")
156
+
157
+ def push_state(self, time, body_states, scalar_values=None) -> None:
158
+ """Append one frame and broadcast it to every connected viewer.
159
+
160
+ Runs on the caller's thread. Delegates to `scene.add_state` for the
161
+ same validation/encoding `SimulationScene` normally does (the frame
162
+ also lands in `self.scene.states`, so `scene.save()` still works after
163
+ streaming), then hands the just-appended frame to the server thread's
164
+ event loop for broadcast. Safe to call before any client has
165
+ connected -- the frame is simply buffered for the next connection's
166
+ catch-up message.
167
+ """
168
+ before = len(self.scene.states)
169
+ self.scene.add_state(time, body_states, scalar_values=scalar_values)
170
+ frame = self.scene.states[before]
171
+
172
+ self.server.frame_buffer.append(frame)
173
+
174
+ if self.server.loop is None:
175
+ # Startup blocks until uvicorn's Server.started is set, which
176
+ # happens after the lifespan startup hook that captures
177
+ # server.loop -- so in practice this is unreachable, but avoid
178
+ # raising into the caller's simulation loop if it somehow occurs.
179
+ logger.warning(
180
+ "SimView live server loop not ready yet; frame buffered only."
181
+ )
182
+ return
183
+
184
+ future = asyncio.run_coroutine_threadsafe(
185
+ self.server.broadcast_frame(frame), self.server.loop
186
+ )
187
+ try:
188
+ future.result(timeout=5.0)
189
+ except Exception:
190
+ logger.exception("Error broadcasting live state frame")
191
+
192
+ def stop(self) -> None:
193
+ """Signal the server to exit and wait for its thread to finish.
194
+
195
+ Idempotent -- safe to call multiple times (e.g. once explicitly and
196
+ once more via __exit__).
197
+ """
198
+ self._threaded.stop()
199
+
200
+ def __enter__(self) -> "LiveViewer":
201
+ return self
202
+
203
+ def __exit__(self, exc_type, exc_value, traceback) -> None:
204
+ self.stop()