simview 6.2.2__tar.gz → 6.2.3__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {simview-6.2.2/simview.egg-info → simview-6.2.3}/PKG-INFO +3 -3
- {simview-6.2.2 → simview-6.2.3}/README.md +2 -2
- {simview-6.2.2 → simview-6.2.3}/pyproject.toml +1 -1
- {simview-6.2.2 → simview-6.2.3}/simview/__main__.py +6 -5
- {simview-6.2.2 → simview-6.2.3}/simview/columnar.py +5 -4
- {simview-6.2.2 → simview-6.2.3}/simview/diff.py +10 -7
- {simview-6.2.2 → simview-6.2.3}/simview/info.py +6 -7
- {simview-6.2.2 → simview-6.2.3}/simview/launcher.py +14 -14
- {simview-6.2.2 → simview-6.2.3}/simview/live.py +3 -0
- {simview-6.2.2 → simview-6.2.3}/simview/merge.py +18 -8
- {simview-6.2.2 → simview-6.2.3}/simview/model.py +72 -35
- {simview-6.2.2 → simview-6.2.3}/simview/remote.py +1 -1
- {simview-6.2.2 → simview-6.2.3}/simview/render.py +6 -4
- {simview-6.2.2 → simview-6.2.3}/simview/scene.py +114 -112
- {simview-6.2.2 → simview-6.2.3}/simview/server.py +35 -22
- {simview-6.2.2 → simview-6.2.3}/simview/state.py +30 -24
- {simview-6.2.2 → simview-6.2.3}/simview/static/css/controls.css +84 -107
- {simview-6.2.2 → simview-6.2.3}/simview/static/js/SimView.js +42 -70
- {simview-6.2.2 → simview-6.2.3}/simview/static/js/components/AnimationController.js +8 -15
- {simview-6.2.2 → simview-6.2.3}/simview/static/js/components/BatchManager.js +6 -8
- {simview-6.2.2 → simview-6.2.3}/simview/static/js/components/InteractionController.js +3 -10
- {simview-6.2.2 → simview-6.2.3}/simview/static/js/components/Scene.js +6 -6
- {simview-6.2.2 → simview-6.2.3}/simview/static/js/components/StateStore.js +6 -11
- {simview-6.2.2 → simview-6.2.3}/simview/static/js/components/WindowedField.js +2 -0
- {simview-6.2.2 → simview-6.2.3}/simview/static/js/config.js +2 -1
- {simview-6.2.2 → simview-6.2.3}/simview/static/js/objects/Body.js +12 -12
- {simview-6.2.2 → simview-6.2.3}/simview/static/js/objects/StaticObject.js +2 -8
- {simview-6.2.2 → simview-6.2.3}/simview/static/js/objects/Terrain.js +11 -20
- {simview-6.2.2 → simview-6.2.3}/simview/static/js/objects/colormap.js +9 -16
- simview-6.2.3/simview/static/js/objects/similarity.js +25 -0
- {simview-6.2.2 → simview-6.2.3}/simview/static/js/objects/utils.js +15 -45
- {simview-6.2.2 → simview-6.2.3}/simview/static/js/ui/BodyStateWindow.js +20 -31
- {simview-6.2.2 → simview-6.2.3}/simview/static/js/ui/Controls.js +27 -32
- {simview-6.2.2 → simview-6.2.3}/simview/static/js/ui/ErrorMetrics.js +4 -3
- {simview-6.2.2 → simview-6.2.3}/simview/static/js/ui/Legend.js +7 -5
- {simview-6.2.2 → simview-6.2.3}/simview/static/js/ui/PlaybackControls.js +4 -3
- {simview-6.2.2 → simview-6.2.3}/simview/static/js/ui/ScalarPlotter.js +13 -8
- {simview-6.2.2 → simview-6.2.3}/simview/static/js/ui/TerrainProfile.js +1 -2
- {simview-6.2.2 → simview-6.2.3}/simview/static/js/utils/batchVisibility.js +1 -0
- {simview-6.2.2 → simview-6.2.3}/simview/static/js/utils/blobCodec.js +5 -5
- {simview-6.2.2 → simview-6.2.3}/simview/static/js/utils/blobWindow.js +9 -2
- {simview-6.2.2 → simview-6.2.3}/simview/static/js/utils/bodyTransforms.js +3 -4
- {simview-6.2.2 → simview-6.2.3}/simview/static/js/utils/cameraRange.js +6 -3
- {simview-6.2.2 → simview-6.2.3}/simview/static/js/utils/csv.js +3 -2
- {simview-6.2.2 → simview-6.2.3}/simview/static/js/utils/errorMath.js +2 -5
- {simview-6.2.2 → simview-6.2.3}/simview/static/js/utils/keyboard.js +3 -2
- {simview-6.2.2 → simview-6.2.3}/simview/static/js/utils/terrainSample.js +3 -7
- {simview-6.2.2 → simview-6.2.3}/simview/static/js/utils/uplot.js +4 -5
- {simview-6.2.2/simview/static/lib → simview-6.2.3/simview/static/js/vendor}/js-colormaps.js +25 -3
- {simview-6.2.2 → simview-6.2.3}/simview/templates/index.html +16 -12
- {simview-6.2.2 → simview-6.2.3}/simview/terrain.py +13 -13
- {simview-6.2.2 → simview-6.2.3}/simview/view.py +2 -1
- {simview-6.2.2 → simview-6.2.3/simview.egg-info}/PKG-INFO +3 -3
- {simview-6.2.2 → simview-6.2.3}/simview.egg-info/SOURCES.txt +3 -3
- {simview-6.2.2 → simview-6.2.3}/tests/test_cli.py +2 -2
- {simview-6.2.2 → simview-6.2.3}/tests/test_columnar_states.py +4 -3
- {simview-6.2.2 → simview-6.2.3}/tests/test_diff.py +1 -1
- {simview-6.2.2 → simview-6.2.3}/tests/test_info.py +1 -1
- {simview-6.2.2 → simview-6.2.3}/tests/test_launcher.py +1 -1
- {simview-6.2.2 → simview-6.2.3}/tests/test_roundtrip.py +1 -1
- {simview-6.2.2 → simview-6.2.3}/tests/test_scene.py +4 -4
- {simview-6.2.2 → simview-6.2.3}/tests/test_server.py +3 -3
- {simview-6.2.2 → simview-6.2.3}/tests/test_terrain_query.py +1 -1
- {simview-6.2.2 → simview-6.2.3}/tests/test_utils.py +1 -1
- simview-6.2.2/simview/static/js/objects/similarity.js +0 -38
- {simview-6.2.2 → simview-6.2.3}/LICENSE +0 -0
- {simview-6.2.2 → simview-6.2.3}/MANIFEST.in +0 -0
- {simview-6.2.2 → simview-6.2.3}/setup.cfg +0 -0
- {simview-6.2.2 → simview-6.2.3}/simview/__init__.py +0 -0
- {simview-6.2.2 → simview-6.2.3}/simview/py.typed +0 -0
- {simview-6.2.2 → simview-6.2.3}/simview/static/js/main.js +0 -0
- {simview-6.2.2 → simview-6.2.3}/simview/static/js/objects/Polyline.js +0 -0
- {simview-6.2.2 → simview-6.2.3}/simview/static/js/ui/AnalysisPanel.js +0 -0
- {simview-6.2.2 → simview-6.2.3}/simview/static/js/ui/BatchLegend.js +0 -0
- {simview-6.2.2 → simview-6.2.3}/simview/static/js/ui/chartControls.js +0 -0
- {simview-6.2.2 → simview-6.2.3}/simview/static/js/utils/batchPresets.js +0 -0
- {simview-6.2.2 → simview-6.2.3}/simview/static/js/utils/episodes.js +0 -0
- {simview-6.2.2 → simview-6.2.3}/simview/static/js/utils/html.js +0 -0
- {simview-6.2.2 → simview-6.2.3}/simview/static/js/utils/interpolate.js +0 -0
- {simview-6.2.2 → simview-6.2.3}/simview/static/js/utils/liveFollow.js +0 -0
- {simview-6.2.2 → simview-6.2.3}/simview/static/js/utils/scalarSeries.js +0 -0
- {simview-6.2.2 → simview-6.2.3}/simview/static/js/utils/viewState.js +0 -0
- {simview-6.2.2 → simview-6.2.3}/simview/static/js/utils/viewerDefaults.js +0 -0
- {simview-6.2.2 → simview-6.2.3}/simview/static/js/utils/visibleRanges.js +0 -0
- {simview-6.2.2 → simview-6.2.3}/simview/static/lib/three-0.174.0/addons/controls/OrbitControls.js +0 -0
- {simview-6.2.2 → simview-6.2.3}/simview/static/lib/three-0.174.0/addons/libs/lil-gui.module.min.js +0 -0
- {simview-6.2.2 → simview-6.2.3}/simview/static/lib/three-0.174.0/three.core.js +0 -0
- {simview-6.2.2 → simview-6.2.3}/simview/static/lib/three-0.174.0/three.module.js +0 -0
- {simview-6.2.2/simview/static/lib → simview-6.2.3/simview/static/lib/uPlot-1.6.32}/uPlot.esm.js +0 -0
- {simview-6.2.2/simview/static/lib → simview-6.2.3/simview/static/lib/uPlot-1.6.32}/uPlot.min.css +0 -0
- {simview-6.2.2 → simview-6.2.3}/simview/static/textures/contacts/red-cross0.png +0 -0
- {simview-6.2.2 → simview-6.2.3}/simview/static/textures/points/ball1.png +0 -0
- {simview-6.2.2 → simview-6.2.3}/simview/utils.py +0 -0
- {simview-6.2.2 → simview-6.2.3}/simview.egg-info/dependency_links.txt +0 -0
- {simview-6.2.2 → simview-6.2.3}/simview.egg-info/entry_points.txt +0 -0
- {simview-6.2.2 → simview-6.2.3}/simview.egg-info/requires.txt +0 -0
- {simview-6.2.2 → simview-6.2.3}/simview.egg-info/top_level.txt +0 -0
- {simview-6.2.2 → simview-6.2.3}/tests/test_batch_selection.py +0 -0
- {simview-6.2.2 → simview-6.2.3}/tests/test_columnar_ondisk.py +0 -0
- {simview-6.2.2 → simview-6.2.3}/tests/test_contacts.py +0 -0
- {simview-6.2.2 → simview-6.2.3}/tests/test_embed_defaults.py +0 -0
- {simview-6.2.2 → simview-6.2.3}/tests/test_episodes.py +0 -0
- {simview-6.2.2 → simview-6.2.3}/tests/test_live.py +0 -0
- {simview-6.2.2 → simview-6.2.3}/tests/test_merge.py +0 -0
- {simview-6.2.2 → simview-6.2.3}/tests/test_pointcloud_features.py +0 -0
- {simview-6.2.2 → simview-6.2.3}/tests/test_remote.py +0 -0
- {simview-6.2.2 → simview-6.2.3}/tests/test_show.py +0 -0
- {simview-6.2.2 → simview-6.2.3}/tests/test_terrain.py +0 -0
- {simview-6.2.2 → simview-6.2.3}/tests/test_trajectory.py +0 -0
- {simview-6.2.2 → simview-6.2.3}/tests/test_view.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: simview
|
|
3
|
-
Version: 6.2.
|
|
3
|
+
Version: 6.2.3
|
|
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
|
|
@@ -130,8 +130,8 @@ source .venv/bin/activate
|
|
|
130
130
|
|
|
131
131
|
SimView is distributed under the [BSD 3-Clause License](LICENSE). The web interface
|
|
132
132
|
vendors [uPlot](https://github.com/leeoniya/uPlot) and [three.js](https://github.com/mrdoob/three.js)
|
|
133
|
-
(both MIT licensed) under
|
|
134
|
-
`simview/static/
|
|
133
|
+
(both MIT licensed) under `simview/static/lib/`, and the matplotlib colour-map tables
|
|
134
|
+
(`js-colormaps.js`) under `simview/static/js/vendor/`, so the viewer works fully offline — see the
|
|
135
135
|
[documentation](https://vlk-jan.github.io/simview/dev/architecture/#vendored-third-party-libraries)
|
|
136
136
|
for details. All third-party libraries used by SimView are permissively licensed
|
|
137
137
|
(MIT/BSD), so there are no licensing restrictions on commercial use.
|
|
@@ -99,8 +99,8 @@ source .venv/bin/activate
|
|
|
99
99
|
|
|
100
100
|
SimView is distributed under the [BSD 3-Clause License](LICENSE). The web interface
|
|
101
101
|
vendors [uPlot](https://github.com/leeoniya/uPlot) and [three.js](https://github.com/mrdoob/three.js)
|
|
102
|
-
(both MIT licensed) under
|
|
103
|
-
`simview/static/
|
|
102
|
+
(both MIT licensed) under `simview/static/lib/`, and the matplotlib colour-map tables
|
|
103
|
+
(`js-colormaps.js`) under `simview/static/js/vendor/`, so the viewer works fully offline — see the
|
|
104
104
|
[documentation](https://vlk-jan.github.io/simview/dev/architecture/#vendored-third-party-libraries)
|
|
105
105
|
for details. All third-party libraries used by SimView are permissively licensed
|
|
106
106
|
(MIT/BSD), so there are no licensing restrictions on commercial use.
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
"""The `simview` command-line interface."""
|
|
2
|
+
|
|
1
3
|
import argparse
|
|
2
4
|
import gzip
|
|
3
5
|
import json
|
|
@@ -436,8 +438,8 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
436
438
|
metavar="PATH",
|
|
437
439
|
help=(
|
|
438
440
|
"Merge the given input files and write the result to PATH instead of "
|
|
439
|
-
"launching the viewer. Requires at least 2 input files
|
|
440
|
-
"output if PATH ends in .gz."
|
|
441
|
+
"launching the viewer. Requires at least 2 input files, or 1 with a "
|
|
442
|
+
"'#<batches>' selection. Gzips the output if PATH ends in .gz."
|
|
441
443
|
),
|
|
442
444
|
)
|
|
443
445
|
_add_remote_flags(view)
|
|
@@ -654,9 +656,8 @@ def main():
|
|
|
654
656
|
_configure_logging()
|
|
655
657
|
argv = sys.argv[1:]
|
|
656
658
|
|
|
657
|
-
# Checked
|
|
658
|
-
#
|
|
659
|
-
# before any other validation ran.
|
|
659
|
+
# Checked before parsing so --version works alongside any other
|
|
660
|
+
# arguments, without tripping their validation.
|
|
660
661
|
if "--version" in argv:
|
|
661
662
|
print(_package_version())
|
|
662
663
|
return
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
covering a whole trajectory, instead of thousands of small per-frame objects.
|
|
3
3
|
|
|
4
4
|
This is both a **wire** format (served by `simview/server.py`, consumed by
|
|
5
|
-
`static/js/components/StateStore.js`) and
|
|
5
|
+
`static/js/components/StateStore.js`) and an
|
|
6
6
|
**on-disk** format (`SimulationScene.save(columnar=True)`). The two are the
|
|
7
7
|
same document; only how a blob is referenced differs:
|
|
8
8
|
|
|
@@ -15,7 +15,7 @@ That symmetry is the whole point: a columnar file needs no repacking at all
|
|
|
15
15
|
to be served, and `columnar.rewrite_blobs` (shared by `server.py`) does the
|
|
16
16
|
rewriting for free.
|
|
17
17
|
|
|
18
|
-
Shape (see
|
|
18
|
+
Shape (see docs/dev/json-format.md for the authoritative spec)::
|
|
19
19
|
|
|
20
20
|
{"version": 4,
|
|
21
21
|
"times": [t0, t1, ...], # length T
|
|
@@ -171,7 +171,8 @@ def _decode_state_field_rows(value, width: int, batch_size: int):
|
|
|
171
171
|
Mirrors the shapes SimViewBodyState.to_json()/add_trajectory produce: a
|
|
172
172
|
``__b64__`` blob is always batch_size rows of `width` floats; a plain list
|
|
173
173
|
is either already nested (one row per batch) or, for a single-batch scene,
|
|
174
|
-
a flat list of `width` floats (see
|
|
174
|
+
a flat list of `width` floats (see docs/dev/json-format.md "Authoring whole
|
|
175
|
+
trajectories").
|
|
175
176
|
"""
|
|
176
177
|
assert np is not None
|
|
177
178
|
if isinstance(value, str):
|
|
@@ -221,7 +222,7 @@ def columnarize_states(states_data: list, model_data: dict | None, register_blob
|
|
|
221
222
|
Strict by design: this trades a bit of coverage (an inconsistent scene just
|
|
222
223
|
doesn't get the win) for never risking a subtly wrong repack. The one
|
|
223
224
|
deliberate exception is `contacts`, which may legitimately come and go per
|
|
224
|
-
frame (see
|
|
225
|
+
frame (see docs/dev/json-format.md) without disqualifying the rest of the scene.
|
|
225
226
|
"""
|
|
226
227
|
if np is None or not states_data:
|
|
227
228
|
return None
|
|
@@ -3,9 +3,9 @@
|
|
|
3
3
|
ground truth vs. prediction, baseline vs. post-adaptation) actually track
|
|
4
4
|
each other.
|
|
5
5
|
|
|
6
|
-
Deliberately dependency-free (stdlib only
|
|
7
|
-
|
|
8
|
-
|
|
6
|
+
Deliberately dependency-free (stdlib only) so it works on a base install
|
|
7
|
+
without the `authoring` extra (torch/numpy) -- see "Lazy imports" in
|
|
8
|
+
docs/dev/architecture.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
|
|
|
@@ -36,8 +36,8 @@ _MAX_SERIES_ROWS = 10
|
|
|
36
36
|
def _build_body_meta(model_data: dict, state_names: list) -> dict[str, dict]:
|
|
37
37
|
"""`name -> {"parent", "localTransform"}` for every body, from the model's
|
|
38
38
|
`bodies` section. Bodies that appear in the states but not in the model are
|
|
39
|
-
added as parentless roots, so a hand-edited or third-party file
|
|
40
|
-
|
|
39
|
+
added as parentless roots, so a hand-edited or third-party file whose
|
|
40
|
+
model omits them still diffs."""
|
|
41
41
|
meta: dict[str, dict] = {}
|
|
42
42
|
for entry in model_data.get("bodies") or []:
|
|
43
43
|
name = entry.get("name")
|
|
@@ -54,8 +54,8 @@ def _build_body_meta(model_data: dict, state_names: list) -> dict[str, dict]:
|
|
|
54
54
|
|
|
55
55
|
def _topo_sort_bodies(meta: dict[str, dict]) -> list[str]:
|
|
56
56
|
"""Order body names so every parent precedes its children. Independent
|
|
57
|
-
stdlib port of `static/js/utils/bodyTransforms.js`'s `topoSortBodies` (
|
|
58
|
-
|
|
57
|
+
stdlib port of `static/js/utils/bodyTransforms.js`'s `topoSortBodies` (the
|
|
58
|
+
viewer's JS can't be shared with the CLI); each body has at most one
|
|
59
59
|
parent, so a DFS post-order walk suffices."""
|
|
60
60
|
order: list[str] = []
|
|
61
61
|
status: dict[str, str] = {}
|
|
@@ -374,6 +374,9 @@ def compute_trajectory_diff(
|
|
|
374
374
|
|
|
375
375
|
|
|
376
376
|
def format_diff_text(result: dict) -> str:
|
|
377
|
+
"""Render `compute_trajectory_diff`'s output as a skimmable text report,
|
|
378
|
+
capping each body's per-frame table (use `--json`/`--csv` for the full
|
|
379
|
+
series)."""
|
|
377
380
|
lines = [
|
|
378
381
|
f"Batches {result['batch_a']} vs {result['batch_b']} every={result['every']}"
|
|
379
382
|
]
|
|
@@ -3,11 +3,10 @@ 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/numpy) -- see
|
|
7
|
-
|
|
8
|
-
pulls in torch at module scope, the latter fastapi/uvicorn
|
|
9
|
-
|
|
10
|
-
inspection tool. Shares its blob-decoding/body-label helpers with
|
|
6
|
+
install without the `authoring` extra (torch/numpy) -- see "Lazy imports" in
|
|
7
|
+
docs/dev/architecture.md. Does not import `simview.model`/`simview.server`:
|
|
8
|
+
the former pulls in torch at module scope, the latter fastapi/uvicorn --
|
|
9
|
+
both wrong layering for a lightweight inspection tool. Shares its blob-decoding/body-label helpers with
|
|
11
10
|
`simview/terrain.py`/`simview/diff.py` via `simview.columnar`/`simview.utils`,
|
|
12
11
|
which are stdlib-only on this read path.
|
|
13
12
|
"""
|
|
@@ -241,8 +240,8 @@ def _summarize_states(states, model: dict | None, warnings: list[str]) -> dict:
|
|
|
241
240
|
|
|
242
241
|
# Per-body walk, mirroring columnar.py's columnarize_states structural
|
|
243
242
|
# checks (field-set consistency, first-appearance-at-frame-0, no
|
|
244
|
-
# dropping out)
|
|
245
|
-
#
|
|
243
|
+
# dropping out) without numpy: blobs are decoded only to count their
|
|
244
|
+
# floats, never shaped into arrays.
|
|
246
245
|
body_order: list = []
|
|
247
246
|
body_first_frame: dict = {}
|
|
248
247
|
body_name_value: dict = {}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
"""Blocking launch of the viewer for a scene object or a scene file."""
|
|
2
|
+
|
|
1
3
|
import gc
|
|
2
4
|
import logging
|
|
3
5
|
from pathlib import Path
|
|
@@ -11,15 +13,19 @@ logger = logging.getLogger("simview.launcher")
|
|
|
11
13
|
|
|
12
14
|
|
|
13
15
|
class SimViewLauncher:
|
|
16
|
+
"""Serve a scene in the foreground until interrupted, then free it.
|
|
17
|
+
|
|
18
|
+
The blocking counterpart to `SimulationScene.show()`'s background viewer.
|
|
19
|
+
"""
|
|
20
|
+
|
|
14
21
|
def __init__(
|
|
15
22
|
self,
|
|
16
23
|
source: SimulationScene | str | Path,
|
|
17
24
|
) -> None:
|
|
18
|
-
"""
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
- If source is a path: it is used directly.
|
|
25
|
+
"""Validate `source`, a `SimulationScene` or a path to a scene file.
|
|
26
|
+
|
|
27
|
+
A scene is handed to the server in memory (no temporary file) and its
|
|
28
|
+
internal data is cleared once the server stops; a path is served as is.
|
|
23
29
|
"""
|
|
24
30
|
self._scene: SimulationScene | None = None
|
|
25
31
|
self._sim_file_path: Path | None = None
|
|
@@ -47,9 +53,7 @@ class SimViewLauncher:
|
|
|
47
53
|
)
|
|
48
54
|
|
|
49
55
|
def launch(self, host: str = "127.0.0.1", preferred_port: int = 5420) -> None:
|
|
50
|
-
"""
|
|
51
|
-
Launches the SimViewServer, then clears the in-memory scene (if any).
|
|
52
|
-
"""
|
|
56
|
+
"""Run the server until it stops, then clear the in-memory scene (if any)."""
|
|
53
57
|
try:
|
|
54
58
|
if self._scene is not None:
|
|
55
59
|
# Serialize the scene once and hand it to the server directly,
|
|
@@ -62,9 +66,7 @@ class SimViewLauncher:
|
|
|
62
66
|
server = SimViewServer(data=data)
|
|
63
67
|
server.run(host=host, port=find_free_port(host, preferred_port))
|
|
64
68
|
else:
|
|
65
|
-
# __init__
|
|
66
|
-
# is set (it raises otherwise), so if self._scene is None here,
|
|
67
|
-
# self._sim_file_path must have been set.
|
|
69
|
+
# __init__ sets exactly one of _scene / _sim_file_path.
|
|
68
70
|
assert self._sim_file_path is not None
|
|
69
71
|
SimViewServer.start(
|
|
70
72
|
sim_path=self._sim_file_path,
|
|
@@ -80,9 +82,7 @@ class SimViewLauncher:
|
|
|
80
82
|
self.cleanup()
|
|
81
83
|
|
|
82
84
|
def cleanup(self) -> None:
|
|
83
|
-
"""
|
|
84
|
-
Clears the in-memory scene's data. Safe to call multiple times.
|
|
85
|
-
"""
|
|
85
|
+
"""Clear the in-memory scene's data. Safe to call multiple times."""
|
|
86
86
|
if self._scene is not None:
|
|
87
87
|
logger.info(
|
|
88
88
|
"SimViewLauncher: Clearing source SimulationScene internal data..."
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
"""Stream a running simulation to the browser viewer over WebSocket."""
|
|
2
|
+
|
|
1
3
|
import asyncio
|
|
2
4
|
import logging
|
|
3
5
|
import queue
|
|
@@ -89,6 +91,7 @@ class LiveViewer:
|
|
|
89
91
|
|
|
90
92
|
@property
|
|
91
93
|
def url(self) -> str:
|
|
94
|
+
"""Base URL of the live viewer."""
|
|
92
95
|
return self._threaded.url
|
|
93
96
|
|
|
94
97
|
def push_state(self, time, body_states, scalar_values=None) -> None:
|
|
@@ -313,11 +313,14 @@ def _validate_doc(doc: dict, label: str) -> None:
|
|
|
313
313
|
def _expand_batched(
|
|
314
314
|
values: list | str, is_singleton: bool, batch_size: int, field: str, label: str
|
|
315
315
|
) -> list | str:
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
316
|
+
"""Broadcast a plain-list terrain field to `batch_size` rows.
|
|
317
|
+
|
|
318
|
+
``__b64__`` blobs pass through untouched: `_decode_per_batch` resolves
|
|
319
|
+
their row layout by length (one shared row for a deduplicated singleton
|
|
320
|
+
terrain, or `batch_size` rows for per-batch / legacy broadcast-singleton
|
|
321
|
+
data) and replicates the shared case itself. Only plain lists, where the
|
|
322
|
+
row count is directly observable, may need broadcasting here.
|
|
323
|
+
"""
|
|
321
324
|
if is_blob(values):
|
|
322
325
|
return values
|
|
323
326
|
|
|
@@ -633,8 +636,8 @@ def _merge_terrain(
|
|
|
633
636
|
)
|
|
634
637
|
|
|
635
638
|
def _bound(name: str, key: str, pick) -> float | None:
|
|
636
|
-
# A null bound means "unknown"
|
|
637
|
-
#
|
|
639
|
+
# A null bound means "unknown", and one unknown makes the merged
|
|
640
|
+
# range unknown too.
|
|
638
641
|
values = [t["properties"][name].get(key) for t in terrains]
|
|
639
642
|
return None if any(v is None for v in values) else pick(values)
|
|
640
643
|
|
|
@@ -684,6 +687,13 @@ def _merge_states(
|
|
|
684
687
|
labels: list[str],
|
|
685
688
|
selections: list[list[int] | None],
|
|
686
689
|
) -> list[dict]:
|
|
690
|
+
"""Build the merged per-frame states on the first file's timeline.
|
|
691
|
+
|
|
692
|
+
Every other file contributes its nearest-in-time frame (zero-order hold).
|
|
693
|
+
Per-body fields and scalars are concatenated in file order, keeping only
|
|
694
|
+
each file's selected batches; rigidly attached bodies are skipped, since
|
|
695
|
+
they never appear in states.
|
|
696
|
+
"""
|
|
687
697
|
ref_times = [s["time"] for s in states_list[0]]
|
|
688
698
|
times_by_file = [[s["time"] for s in states] for states in states_list]
|
|
689
699
|
total_batches = sum(_output_sizes(batch_sizes, selections))
|
|
@@ -926,7 +936,7 @@ def merge_simulation_files(
|
|
|
926
936
|
if dropped:
|
|
927
937
|
logger.warning(
|
|
928
938
|
"Dropping episode boundaries from %s: they index their own "
|
|
929
|
-
"timeline, and the merged scene uses '%s'
|
|
939
|
+
"timeline, and the merged scene uses '%s's timeline.",
|
|
930
940
|
", ".join(f"'{label}'" for label in dropped),
|
|
931
941
|
labels[0],
|
|
932
942
|
)
|
|
@@ -13,7 +13,7 @@ logger = logging.getLogger("simview.model")
|
|
|
13
13
|
|
|
14
14
|
|
|
15
15
|
def _encode_blob(array) -> str:
|
|
16
|
-
"""Encode a numpy array as an inline little-endian float32
|
|
16
|
+
"""Encode a numpy array as an inline little-endian float32 ``__b64__`` blob."""
|
|
17
17
|
return inline_blob(array.astype("<f4").tobytes())
|
|
18
18
|
|
|
19
19
|
|
|
@@ -72,6 +72,8 @@ def _validated_visible_ranges(owner: str, ranges: Any) -> list[list[float]] | No
|
|
|
72
72
|
|
|
73
73
|
|
|
74
74
|
class BodyShapeType(StrEnum):
|
|
75
|
+
"""Shape kind of a body or static object (the wire `shape.type`)."""
|
|
76
|
+
|
|
75
77
|
POINTCLOUD = "pointcloud"
|
|
76
78
|
MESH = "mesh"
|
|
77
79
|
BOX = "box"
|
|
@@ -80,6 +82,8 @@ class BodyShapeType(StrEnum):
|
|
|
80
82
|
|
|
81
83
|
|
|
82
84
|
class OptionalBodyStateAttribute(StrEnum):
|
|
85
|
+
"""Optional per-body state fields beyond `bodyTransform` (wire keys)."""
|
|
86
|
+
|
|
83
87
|
CONTACTS = "contacts"
|
|
84
88
|
VELOCITY = "velocity"
|
|
85
89
|
ANGULAR_VELOCITY = "angularVelocity"
|
|
@@ -100,7 +104,7 @@ class TerrainProperty:
|
|
|
100
104
|
"""One arbitrary named per-cell scalar field over the terrain grid (e.g.
|
|
101
105
|
friction, stiffness, or any other user-defined property), stored the same
|
|
102
106
|
way as `SimViewTerrain.height_data` -- a plain nested list, or an opaque
|
|
103
|
-
|
|
107
|
+
``__b64__``-prefixed blob string for compactness."""
|
|
104
108
|
|
|
105
109
|
data: list[list[float]] | str
|
|
106
110
|
# Value range used by the viewer to normalize the color map (analogous to
|
|
@@ -118,6 +122,9 @@ class TerrainProperty:
|
|
|
118
122
|
|
|
119
123
|
@dataclass
|
|
120
124
|
class SimViewTerrain:
|
|
125
|
+
"""Terrain heightmap (plus optional normals, named properties and
|
|
126
|
+
embeddings) over a regular x/y grid, shared or per batch."""
|
|
127
|
+
|
|
121
128
|
extent_x: float
|
|
122
129
|
extent_y: float
|
|
123
130
|
shape_x: int
|
|
@@ -128,10 +135,9 @@ class SimViewTerrain:
|
|
|
128
135
|
max_y: float
|
|
129
136
|
min_z: float
|
|
130
137
|
max_z: float
|
|
131
|
-
#
|
|
132
|
-
#
|
|
133
|
-
#
|
|
134
|
-
# as-is either way.
|
|
138
|
+
# `create()` always stores an opaque ``__b64__``-prefixed base64 blob
|
|
139
|
+
# string; hand-built instances may use plain nested lists. `to_json`/
|
|
140
|
+
# `from_dict` pass either through as-is.
|
|
135
141
|
height_data: list[list[float]] | str
|
|
136
142
|
# None when the terrain was authored with `compute_normals=False`: the
|
|
137
143
|
# viewer then shades from normals it computes itself, and the file is
|
|
@@ -140,9 +146,7 @@ class SimViewTerrain:
|
|
|
140
146
|
is_singleton: bool
|
|
141
147
|
# Arbitrary named per-cell scalar fields (e.g. "friction", "stiffness", or
|
|
142
148
|
# any other user-defined property), keyed by name -- see `TerrainProperty`.
|
|
143
|
-
#
|
|
144
|
-
# supply it by name in `create()`'s `properties` dict and it becomes
|
|
145
|
-
# selectable as a terrain color mode automatically.
|
|
149
|
+
# Any name passed in `create()`'s `properties` becomes a viewer color mode.
|
|
146
150
|
properties: dict[str, TerrainProperty] = field(default_factory=dict)
|
|
147
151
|
# Per-cell K-wide feature vector (e.g. a reduced-dim PCA projection of a
|
|
148
152
|
# learned backbone's features), stored the same way as `normals` (width
|
|
@@ -185,7 +189,7 @@ class SimViewTerrain:
|
|
|
185
189
|
"""Reconstruct a SimViewTerrain from the dict produced by `to_json`.
|
|
186
190
|
|
|
187
191
|
`heightData`/`normals`/each property's `data` are kept in whatever
|
|
188
|
-
form they were serialized in (plain nested lists or a
|
|
192
|
+
form they were serialized in (plain nested lists or a ``__b64__`` blob
|
|
189
193
|
string) -- decode with `simview.columnar.blob_floats` if you need the
|
|
190
194
|
flat float values back out.
|
|
191
195
|
"""
|
|
@@ -222,7 +226,7 @@ class SimViewTerrain:
|
|
|
222
226
|
|
|
223
227
|
@staticmethod
|
|
224
228
|
def create(
|
|
225
|
-
heightmap: torch.Tensor,
|
|
229
|
+
heightmap: torch.Tensor,
|
|
226
230
|
normals: torch.Tensor | None,
|
|
227
231
|
x_lim: tuple[float, float],
|
|
228
232
|
y_lim: tuple[float, float],
|
|
@@ -231,6 +235,17 @@ class SimViewTerrain:
|
|
|
231
235
|
property_bounds: dict[str, tuple[float, float]] | None = None,
|
|
232
236
|
embedding_map: torch.Tensor | None = None,
|
|
233
237
|
) -> "SimViewTerrain":
|
|
238
|
+
"""Build a terrain from batched tensors, encoding each field as a blob.
|
|
239
|
+
|
|
240
|
+
`heightmap` is `(B, Dy, Dx)` with torch's "xy" indexing: the column
|
|
241
|
+
index increases with x. `normals` is `(B, 3, Dy, Dx)` or None,
|
|
242
|
+
`properties` maps names to `(B, Dy, Dx)` maps, and `embedding_map` is
|
|
243
|
+
`(B, K, Dy, Dx)`. `x_lim`/`y_lim` are the `(min, max)` world extents.
|
|
244
|
+
`property_bounds` optionally pins a property's color-scale range;
|
|
245
|
+
otherwise the map's own data range is used. Every grid must match
|
|
246
|
+
the heightmap's. Batch broadcasting is the caller's job (see
|
|
247
|
+
`SimulationScene.create_terrain`).
|
|
248
|
+
"""
|
|
234
249
|
if heightmap.ndim != 3:
|
|
235
250
|
raise ValueError(
|
|
236
251
|
f"Heightmap must include a batch dimension (ndim=3); got ndim={heightmap.ndim}."
|
|
@@ -333,6 +348,9 @@ class SimViewTerrain:
|
|
|
333
348
|
|
|
334
349
|
@dataclass
|
|
335
350
|
class SimViewBody:
|
|
351
|
+
"""A dynamic body: its shape, look, optional parent attachment and the
|
|
352
|
+
optional state attributes its states carry."""
|
|
353
|
+
|
|
336
354
|
name: str
|
|
337
355
|
shape: dict
|
|
338
356
|
available_attributes: list[OptionalBodyStateAttribute] | None = None
|
|
@@ -382,6 +400,7 @@ class SimViewBody:
|
|
|
382
400
|
def set_available_attributes(
|
|
383
401
|
self, available_attributes: list[str | OptionalBodyStateAttribute]
|
|
384
402
|
) -> None:
|
|
403
|
+
"""Set the optional attributes this body's states carry (once only)."""
|
|
385
404
|
if self.available_attributes is not None:
|
|
386
405
|
raise ValueError("Available attributes already set")
|
|
387
406
|
self.available_attributes = [
|
|
@@ -393,7 +412,8 @@ class SimViewBody:
|
|
|
393
412
|
|
|
394
413
|
@staticmethod
|
|
395
414
|
def _create_shape_dict(body_type: BodyShapeType, **kwargs) -> dict:
|
|
396
|
-
"""
|
|
415
|
+
"""Build a shape dict, encoding multi-element tensors as blobs and
|
|
416
|
+
validating required primitive dimensions."""
|
|
397
417
|
shape_dict: dict[str, Any] = {"type": body_type.value}
|
|
398
418
|
for key, value in kwargs.items():
|
|
399
419
|
if isinstance(value, torch.Tensor):
|
|
@@ -430,6 +450,10 @@ class SimViewBody:
|
|
|
430
450
|
visible_ranges: list | None = None,
|
|
431
451
|
**kwargs,
|
|
432
452
|
) -> "SimViewBody":
|
|
453
|
+
"""Build a body of `body_type`; `**kwargs` are its shape parameters
|
|
454
|
+
(e.g. `hx`/`hy`/`hz` for a box, `radius` for a sphere, `radius`/
|
|
455
|
+
`height` for a cylinder, `vertices`/`faces` for a mesh). The other
|
|
456
|
+
parameters are as on `SimulationScene.create_body`."""
|
|
433
457
|
shape_dict = SimViewBody._create_shape_dict(body_type, **kwargs)
|
|
434
458
|
if local_transform is not None and hasattr(local_transform, "tolist"):
|
|
435
459
|
local_transform = local_transform.tolist()
|
|
@@ -453,6 +477,7 @@ class SimViewBody:
|
|
|
453
477
|
def create_box(
|
|
454
478
|
name: str, hx: float, hy: float, hz: float, **kwargs
|
|
455
479
|
) -> "SimViewBody":
|
|
480
|
+
"""Build a box body with half-extents `hx`/`hy`/`hz`."""
|
|
456
481
|
return SimViewBody.create(
|
|
457
482
|
name, BodyShapeType.BOX, hx=hx, hy=hy, hz=hz, **kwargs
|
|
458
483
|
)
|
|
@@ -465,13 +490,15 @@ class SimViewBody:
|
|
|
465
490
|
embedding: torch.Tensor | None = None,
|
|
466
491
|
**kwargs,
|
|
467
492
|
) -> "SimViewBody":
|
|
468
|
-
"""`
|
|
493
|
+
"""Build a point-cloud body from `(N, 3)` `points`.
|
|
494
|
+
|
|
495
|
+
`color` (N, 3) in [0, 1] is an optional static per-point RGB color,
|
|
469
496
|
used by the viewer for vertex-colored rendering. `embedding` (N, K) is
|
|
470
497
|
an optional per-point K-wide feature vector (e.g. a reduced-dim PCA
|
|
471
498
|
projection of a learned backbone's features) enabling the viewer's
|
|
472
499
|
click-to-similarity color mode: cosine similarity to a clicked point,
|
|
473
|
-
computed entirely in-browser from this data. Both
|
|
474
|
-
|
|
500
|
+
computed entirely in-browser from this data. Both are stored as
|
|
501
|
+
``__b64__`` blobs."""
|
|
475
502
|
if points.ndim != 2 or points.shape[1] != 3:
|
|
476
503
|
raise ValueError(
|
|
477
504
|
f"points must have shape (N, 3); got {tuple(points.shape)}."
|
|
@@ -504,6 +531,7 @@ class SimViewBody:
|
|
|
504
531
|
def create_mesh(
|
|
505
532
|
name: str, vertices: torch.Tensor, faces: torch.Tensor, **kwargs
|
|
506
533
|
) -> "SimViewBody":
|
|
534
|
+
"""Build a mesh body from `vertices` `(V, 3)` and `faces` `(F, 3)`."""
|
|
507
535
|
return SimViewBody.create(
|
|
508
536
|
name, BodyShapeType.MESH, vertices=vertices, faces=faces, **kwargs
|
|
509
537
|
)
|
|
@@ -554,6 +582,9 @@ class SimViewBody:
|
|
|
554
582
|
|
|
555
583
|
@dataclass
|
|
556
584
|
class SimViewStaticObject:
|
|
585
|
+
"""A static (never-moving) object: one shape shared by every batch
|
|
586
|
+
(singleton) or one shape per batch."""
|
|
587
|
+
|
|
557
588
|
name: str
|
|
558
589
|
is_singleton: bool
|
|
559
590
|
shape: dict | None = None # Used if is_singleton is True
|
|
@@ -578,9 +609,9 @@ class SimViewStaticObject:
|
|
|
578
609
|
visible_ranges: list | None = None,
|
|
579
610
|
**kwargs,
|
|
580
611
|
) -> "SimViewStaticObject":
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
612
|
+
"""Build a static object shared by every batch; `**kwargs` are the
|
|
613
|
+
shape parameters, as on `SimViewBody.create`."""
|
|
614
|
+
shape_dict = SimViewBody._create_shape_dict(shape_type, **kwargs)
|
|
584
615
|
return SimViewStaticObject(
|
|
585
616
|
name=name,
|
|
586
617
|
is_singleton=True,
|
|
@@ -595,16 +626,12 @@ class SimViewStaticObject:
|
|
|
595
626
|
shapes_kwargs: list[dict[str, Any]],
|
|
596
627
|
visible_ranges: list | None = None,
|
|
597
628
|
) -> "SimViewStaticObject":
|
|
598
|
-
"""
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
shapes_kwargs: A list of dictionaries, where each dictionary contains the
|
|
605
|
-
keyword arguments for creating the shape of one instance
|
|
606
|
-
in the batch (e.g., [{'hx': 0.1, 'hy': 0.1, 'hz': 0.1}, {'hx': 0.2, ...}]).
|
|
607
|
-
The length of this list must match the batch size.
|
|
629
|
+
"""Build a static object with one shape per batch, all of `shape_type`.
|
|
630
|
+
|
|
631
|
+
`name` names the group. `shapes_kwargs` holds one dict of shape
|
|
632
|
+
keyword arguments per batch instance (e.g. `[{"hx": 0.1, "hy": 0.1,
|
|
633
|
+
"hz": 0.1}, {"hx": 0.2, ...}]`); its length must match the model's
|
|
634
|
+
batch size.
|
|
608
635
|
"""
|
|
609
636
|
shapes_list = []
|
|
610
637
|
if not shapes_kwargs:
|
|
@@ -616,9 +643,7 @@ class SimViewStaticObject:
|
|
|
616
643
|
raise ValueError(
|
|
617
644
|
"Do not include 'type' in shapes_kwargs; use the shape_type argument."
|
|
618
645
|
)
|
|
619
|
-
shapes_list.append(
|
|
620
|
-
SimViewBody._create_shape_dict(shape_type, **kwargs)
|
|
621
|
-
) # Reuse helper
|
|
646
|
+
shapes_list.append(SimViewBody._create_shape_dict(shape_type, **kwargs))
|
|
622
647
|
return SimViewStaticObject(
|
|
623
648
|
name=name,
|
|
624
649
|
is_singleton=False,
|
|
@@ -658,7 +683,7 @@ class SimViewPolyline:
|
|
|
658
683
|
"""A world-space polyline: a fixed `points` list (a route), and/or a
|
|
659
684
|
per-frame `frames` blob (a replanned local path) of shape
|
|
660
685
|
`(T, B, max_vertices, 3)` float32, trailing NaN rows padding each frame
|
|
661
|
-
up to `max_vertices`. Model-level by design (
|
|
686
|
+
up to `max_vertices`. Model-level by design (not a states field), so it
|
|
662
687
|
rides the generic blob machinery; not supported in live mode."""
|
|
663
688
|
|
|
664
689
|
name: str
|
|
@@ -723,6 +748,7 @@ class SimViewPolyline:
|
|
|
723
748
|
|
|
724
749
|
@classmethod
|
|
725
750
|
def from_dict(cls, d: dict) -> "SimViewPolyline":
|
|
751
|
+
"""Reconstruct a SimViewPolyline from the dict produced by `to_json`."""
|
|
726
752
|
try:
|
|
727
753
|
name = d["name"]
|
|
728
754
|
except KeyError as e:
|
|
@@ -774,6 +800,7 @@ class SimViewEpisode:
|
|
|
774
800
|
|
|
775
801
|
@classmethod
|
|
776
802
|
def from_dict(cls, d: dict) -> "SimViewEpisode":
|
|
803
|
+
"""Reconstruct a SimViewEpisode from the dict produced by `to_json`."""
|
|
777
804
|
try:
|
|
778
805
|
start_index = d["startIndex"]
|
|
779
806
|
except (KeyError, TypeError) as e:
|
|
@@ -815,6 +842,9 @@ def _validate_parent_ref(name: str, parent: str | None, known_bodies: dict) -> N
|
|
|
815
842
|
|
|
816
843
|
@dataclass
|
|
817
844
|
class SimViewModel:
|
|
845
|
+
"""The static part of a scene: batch layout, terrain, bodies, static
|
|
846
|
+
objects, polylines and metadata (the wire `model` section)."""
|
|
847
|
+
|
|
818
848
|
batch_size: int
|
|
819
849
|
scalar_names: list[str]
|
|
820
850
|
# None means "not known"; the viewer then infers it from consecutive
|
|
@@ -859,12 +889,15 @@ class SimViewModel:
|
|
|
859
889
|
_validate_episodes(self.episodes)
|
|
860
890
|
|
|
861
891
|
def add_body(self, body: SimViewBody) -> None:
|
|
892
|
+
"""Add `body`; its name must be new and its parent already added."""
|
|
862
893
|
if body.name in self.bodies:
|
|
863
894
|
raise ValueError(f"Dynamic body {body.name} already exists")
|
|
864
895
|
_validate_parent_ref(body.name, body.parent, self.bodies)
|
|
865
896
|
self.bodies[body.name] = body
|
|
866
897
|
|
|
867
898
|
def add_static_object(self, static_object: SimViewStaticObject) -> None:
|
|
899
|
+
"""Add `static_object`; its name must be new and a batched one needs
|
|
900
|
+
one shape per batch."""
|
|
868
901
|
if static_object.name in self.static_objects:
|
|
869
902
|
raise ValueError(f"Static object {static_object.name} already exists")
|
|
870
903
|
if not static_object.is_singleton:
|
|
@@ -880,6 +913,7 @@ class SimViewModel:
|
|
|
880
913
|
self.static_objects[static_object.name] = static_object
|
|
881
914
|
|
|
882
915
|
def add_polyline(self, polyline: SimViewPolyline) -> None:
|
|
916
|
+
"""Add `polyline`; its name must be new."""
|
|
883
917
|
if polyline.name in self.polylines:
|
|
884
918
|
raise ValueError(f"Polyline {polyline.name} already exists")
|
|
885
919
|
self.polylines[polyline.name] = polyline
|
|
@@ -914,9 +948,11 @@ class SimViewModel:
|
|
|
914
948
|
def from_dict(cls, d: dict) -> "SimViewModel":
|
|
915
949
|
"""Reconstruct a SimViewModel from the dict produced by `to_json`.
|
|
916
950
|
|
|
917
|
-
Centralizes parsing of the wire format:
|
|
918
|
-
|
|
919
|
-
|
|
951
|
+
Centralizes parsing of the wire format: each part is rebuilt via its
|
|
952
|
+
own `from_dict`. Bodies are checked for duplicate names and parent
|
|
953
|
+
references inline; static objects and polylines go through
|
|
954
|
+
`add_static_object`/`add_polyline`, so they're validated exactly as
|
|
955
|
+
when authoring.
|
|
920
956
|
"""
|
|
921
957
|
try:
|
|
922
958
|
batch_size = d["simBatches"]
|
|
@@ -962,4 +998,5 @@ class SimViewModel:
|
|
|
962
998
|
|
|
963
999
|
@property
|
|
964
1000
|
def is_complete(self) -> bool:
|
|
1001
|
+
"""True once a terrain is set (required to save or show)."""
|
|
965
1002
|
return self.terrain is not None
|
|
@@ -121,7 +121,7 @@ def is_remote_spec(spec: str) -> bool:
|
|
|
121
121
|
|
|
122
122
|
|
|
123
123
|
def cache_dir() -> Path:
|
|
124
|
-
"""Root of simview's on-disk cache,
|
|
124
|
+
"""Root of simview's on-disk cache, honoring XDG_CACHE_HOME."""
|
|
125
125
|
base = os.environ.get("XDG_CACHE_HOME")
|
|
126
126
|
root = Path(base) if base else Path.home() / ".cache"
|
|
127
127
|
return root / CACHE_DIR
|