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.
- simview/__init__.py +69 -0
- simview/__main__.py +174 -0
- simview/launcher.py +109 -0
- simview/live.py +204 -0
- simview/merge.py +593 -0
- simview/model.py +690 -0
- simview/py.typed +0 -0
- simview/scene.py +603 -0
- simview/server.py +739 -0
- simview/state.py +190 -0
- simview/static/css/controls.css +36 -0
- simview/static/js/SimView.js +601 -0
- simview/static/js/components/AnimationController.js +654 -0
- simview/static/js/components/BatchManager.js +198 -0
- simview/static/js/components/InteractionController.js +446 -0
- simview/static/js/components/InteractionControls.js +116 -0
- simview/static/js/components/Scene.js +166 -0
- simview/static/js/components/StateStore.js +188 -0
- simview/static/js/config.js +233 -0
- simview/static/js/main.js +3 -0
- simview/static/js/objects/Body.js +616 -0
- simview/static/js/objects/StaticObject.js +181 -0
- simview/static/js/objects/Terrain.js +514 -0
- simview/static/js/objects/utils.js +378 -0
- simview/static/js/ui/AnalysisPanel.js +233 -0
- simview/static/js/ui/BatchLegend.js +207 -0
- simview/static/js/ui/BodyStateWindow.js +521 -0
- simview/static/js/ui/Controls.js +586 -0
- simview/static/js/ui/ErrorMetrics.js +664 -0
- simview/static/js/ui/Legend.js +87 -0
- simview/static/js/ui/PlaybackControls.js +314 -0
- simview/static/js/ui/ScalarPlotter.js +575 -0
- simview/static/js/utils/blobCodec.js +73 -0
- simview/static/js/utils/bodyTransforms.js +151 -0
- simview/static/js/utils/csv.js +46 -0
- simview/static/js/utils/errorMath.js +69 -0
- simview/static/js/utils/injectStyles.js +16 -0
- simview/static/js/utils/interpolate.js +65 -0
- simview/static/js/utils/liveFollow.js +17 -0
- simview/static/js/utils/loadRecordingLibs.js +34 -0
- simview/static/js/utils/viewState.js +226 -0
- simview/static/lib/chroma-js-3.1.2/index.min.js +8 -0
- simview/static/lib/chroma-js-3.1.2/src/Color.js +49 -0
- simview/static/lib/chroma-js-3.1.2/src/chroma.js +10 -0
- simview/static/lib/chroma-js-3.1.2/src/colors/colorbrewer.js +81 -0
- simview/static/lib/chroma-js-3.1.2/src/colors/w3cx11.js +164 -0
- simview/static/lib/chroma-js-3.1.2/src/generator/average.js +88 -0
- simview/static/lib/chroma-js-3.1.2/src/generator/bezier.js +86 -0
- simview/static/lib/chroma-js-3.1.2/src/generator/blend.js +57 -0
- simview/static/lib/chroma-js-3.1.2/src/generator/cubehelix.js +87 -0
- simview/static/lib/chroma-js-3.1.2/src/generator/mix.js +19 -0
- simview/static/lib/chroma-js-3.1.2/src/generator/random.js +12 -0
- simview/static/lib/chroma-js-3.1.2/src/generator/scale.js +394 -0
- simview/static/lib/chroma-js-3.1.2/src/interpolator/_hsx.js +59 -0
- simview/static/lib/chroma-js-3.1.2/src/interpolator/hcg.js +12 -0
- simview/static/lib/chroma-js-3.1.2/src/interpolator/hsi.js +12 -0
- simview/static/lib/chroma-js-3.1.2/src/interpolator/hsl.js +12 -0
- simview/static/lib/chroma-js-3.1.2/src/interpolator/hsv.js +12 -0
- simview/static/lib/chroma-js-3.1.2/src/interpolator/index.js +1 -0
- simview/static/lib/chroma-js-3.1.2/src/interpolator/lab.js +19 -0
- simview/static/lib/chroma-js-3.1.2/src/interpolator/lch.js +13 -0
- simview/static/lib/chroma-js-3.1.2/src/interpolator/lrgb.js +19 -0
- simview/static/lib/chroma-js-3.1.2/src/interpolator/num.js +15 -0
- simview/static/lib/chroma-js-3.1.2/src/interpolator/oklab.js +19 -0
- simview/static/lib/chroma-js-3.1.2/src/interpolator/oklch.js +12 -0
- simview/static/lib/chroma-js-3.1.2/src/interpolator/rgb.js +18 -0
- simview/static/lib/chroma-js-3.1.2/src/io/cmyk/cmyk2rgb.js +16 -0
- simview/static/lib/chroma-js-3.1.2/src/io/cmyk/index.js +27 -0
- simview/static/lib/chroma-js-3.1.2/src/io/cmyk/rgb2cmyk.js +17 -0
- simview/static/lib/chroma-js-3.1.2/src/io/css/css2rgb.js +238 -0
- simview/static/lib/chroma-js-3.1.2/src/io/css/hsl2css.js +26 -0
- simview/static/lib/chroma-js-3.1.2/src/io/css/index.js +27 -0
- simview/static/lib/chroma-js-3.1.2/src/io/css/lab2css.js +24 -0
- simview/static/lib/chroma-js-3.1.2/src/io/css/lch2css.js +24 -0
- simview/static/lib/chroma-js-3.1.2/src/io/css/oklab2css.js +16 -0
- simview/static/lib/chroma-js-3.1.2/src/io/css/oklch2css.js +16 -0
- simview/static/lib/chroma-js-3.1.2/src/io/css/rgb2css.js +61 -0
- simview/static/lib/chroma-js-3.1.2/src/io/gl/index.js +22 -0
- simview/static/lib/chroma-js-3.1.2/src/io/hcg/hcg2rgb.js +55 -0
- simview/static/lib/chroma-js-3.1.2/src/io/hcg/index.js +27 -0
- simview/static/lib/chroma-js-3.1.2/src/io/hcg/rgb2hcg.js +23 -0
- simview/static/lib/chroma-js-3.1.2/src/io/hex/hex2rgb.js +56 -0
- simview/static/lib/chroma-js-3.1.2/src/io/hex/index.js +29 -0
- simview/static/lib/chroma-js-3.1.2/src/io/hex/rgb2hex.js +29 -0
- simview/static/lib/chroma-js-3.1.2/src/io/hsi/hsi2rgb.js +45 -0
- simview/static/lib/chroma-js-3.1.2/src/io/hsi/index.js +27 -0
- simview/static/lib/chroma-js-3.1.2/src/io/hsi/rgb2hsi.js +31 -0
- simview/static/lib/chroma-js-3.1.2/src/io/hsl/hsl2rgb.js +35 -0
- simview/static/lib/chroma-js-3.1.2/src/io/hsl/index.js +27 -0
- simview/static/lib/chroma-js-3.1.2/src/io/hsl/rgb2hsl.js +45 -0
- simview/static/lib/chroma-js-3.1.2/src/io/hsv/hsv2rgb.js +47 -0
- simview/static/lib/chroma-js-3.1.2/src/io/hsv/index.js +27 -0
- simview/static/lib/chroma-js-3.1.2/src/io/hsv/rgb2hsv.js +32 -0
- simview/static/lib/chroma-js-3.1.2/src/io/input.js +4 -0
- simview/static/lib/chroma-js-3.1.2/src/io/lab/index.js +28 -0
- simview/static/lib/chroma-js-3.1.2/src/io/lab/lab-constants.js +120 -0
- simview/static/lib/chroma-js-3.1.2/src/io/lab/lab2rgb.js +101 -0
- simview/static/lib/chroma-js-3.1.2/src/io/lab/rgb2lab.js +67 -0
- simview/static/lib/chroma-js-3.1.2/src/io/lch/hcl2rgb.js +9 -0
- simview/static/lib/chroma-js-3.1.2/src/io/lch/index.js +35 -0
- simview/static/lib/chroma-js-3.1.2/src/io/lch/lab2lch.js +12 -0
- simview/static/lib/chroma-js-3.1.2/src/io/lch/lch2lab.js +18 -0
- simview/static/lib/chroma-js-3.1.2/src/io/lch/lch2rgb.js +13 -0
- simview/static/lib/chroma-js-3.1.2/src/io/lch/rgb2lch.js +12 -0
- simview/static/lib/chroma-js-3.1.2/src/io/named/index.js +30 -0
- simview/static/lib/chroma-js-3.1.2/src/io/num/index.js +32 -0
- simview/static/lib/chroma-js-3.1.2/src/io/num/num2rgb.js +13 -0
- simview/static/lib/chroma-js-3.1.2/src/io/num/rgb2num.js +8 -0
- simview/static/lib/chroma-js-3.1.2/src/io/oklab/index.js +27 -0
- simview/static/lib/chroma-js-3.1.2/src/io/oklab/oklab2rgb.js +34 -0
- simview/static/lib/chroma-js-3.1.2/src/io/oklab/rgb2oklab.js +37 -0
- simview/static/lib/chroma-js-3.1.2/src/io/oklch/index.js +27 -0
- simview/static/lib/chroma-js-3.1.2/src/io/oklch/oklch2rgb.js +13 -0
- simview/static/lib/chroma-js-3.1.2/src/io/oklch/rgb2oklch.js +12 -0
- simview/static/lib/chroma-js-3.1.2/src/io/rgb/index.js +44 -0
- simview/static/lib/chroma-js-3.1.2/src/io/temp/index.js +22 -0
- simview/static/lib/chroma-js-3.1.2/src/io/temp/rgb2temperature.js +30 -0
- simview/static/lib/chroma-js-3.1.2/src/io/temp/temperature2rgb.js +39 -0
- simview/static/lib/chroma-js-3.1.2/src/ops/alpha.js +13 -0
- simview/static/lib/chroma-js-3.1.2/src/ops/clipped.js +5 -0
- simview/static/lib/chroma-js-3.1.2/src/ops/darken.js +17 -0
- simview/static/lib/chroma-js-3.1.2/src/ops/get.js +13 -0
- simview/static/lib/chroma-js-3.1.2/src/ops/luminance.js +54 -0
- simview/static/lib/chroma-js-3.1.2/src/ops/mix.js +10 -0
- simview/static/lib/chroma-js-3.1.2/src/ops/premultiply.js +12 -0
- simview/static/lib/chroma-js-3.1.2/src/ops/saturate.js +15 -0
- simview/static/lib/chroma-js-3.1.2/src/ops/set.js +43 -0
- simview/static/lib/chroma-js-3.1.2/src/ops/shade.js +11 -0
- simview/static/lib/chroma-js-3.1.2/src/utils/analyze.js +191 -0
- simview/static/lib/chroma-js-3.1.2/src/utils/clip_rgb.js +15 -0
- simview/static/lib/chroma-js-3.1.2/src/utils/contrast.js +12 -0
- simview/static/lib/chroma-js-3.1.2/src/utils/contrastAPCA.js +68 -0
- simview/static/lib/chroma-js-3.1.2/src/utils/delta-e.js +62 -0
- simview/static/lib/chroma-js-3.1.2/src/utils/distance.js +17 -0
- simview/static/lib/chroma-js-3.1.2/src/utils/index.js +27 -0
- simview/static/lib/chroma-js-3.1.2/src/utils/last.js +8 -0
- simview/static/lib/chroma-js-3.1.2/src/utils/limit.js +5 -0
- simview/static/lib/chroma-js-3.1.2/src/utils/multiply-matrices.js +36 -0
- simview/static/lib/chroma-js-3.1.2/src/utils/scales.js +15 -0
- simview/static/lib/chroma-js-3.1.2/src/utils/type.js +18 -0
- simview/static/lib/chroma-js-3.1.2/src/utils/unpack.js +17 -0
- simview/static/lib/chroma-js-3.1.2/src/utils/valid.js +11 -0
- simview/static/lib/chroma-js-3.1.2/src/version.js +2 -0
- simview/static/lib/download.js +132 -0
- simview/static/lib/js-colormaps.js +19335 -0
- simview/static/lib/tar.js +334 -0
- simview/static/lib/three-0.174.0/addons/controls/OrbitControls.js +1556 -0
- simview/static/lib/three-0.174.0/addons/libs/lil-gui.module.min.js +8 -0
- simview/static/lib/three-0.174.0/three.core.js +48830 -0
- simview/static/lib/three-0.174.0/three.module.js +17313 -0
- simview/static/lib/uPlot.esm.js +6140 -0
- simview/static/lib/uPlot.min.css +2 -0
- simview/static/textures/contacts/red-cross0.png +0 -0
- simview/static/textures/points/ball0.png +0 -0
- simview/static/textures/points/ball1.png +0 -0
- simview/templates/index.html +81 -0
- simview/utils.py +41 -0
- simview-3.2.dist-info/METADATA +552 -0
- simview-3.2.dist-info/RECORD +163 -0
- simview-3.2.dist-info/WHEEL +5 -0
- simview-3.2.dist-info/entry_points.txt +2 -0
- simview-3.2.dist-info/licenses/LICENSE +28 -0
- 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()
|