simview 3.5__tar.gz → 3.6__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (192) hide show
  1. {simview-3.5/simview.egg-info → simview-3.6}/PKG-INFO +1 -1
  2. {simview-3.5 → simview-3.6}/pyproject.toml +1 -1
  3. {simview-3.5 → simview-3.6}/simview/__main__.py +131 -45
  4. {simview-3.5 → simview-3.6}/simview/info.py +7 -0
  5. {simview-3.5 → simview-3.6}/simview/model.py +47 -4
  6. {simview-3.5 → simview-3.6}/simview/scene.py +30 -7
  7. {simview-3.5 → simview-3.6}/simview/static/js/SimView.js +22 -1
  8. {simview-3.5 → simview-3.6}/simview/static/js/components/BatchManager.js +1 -0
  9. {simview-3.5 → simview-3.6}/simview/static/js/config.js +1 -0
  10. {simview-3.5 → simview-3.6}/simview/static/js/ui/AnalysisPanel.js +46 -25
  11. {simview-3.5 → simview-3.6}/simview/static/js/ui/Controls.js +15 -0
  12. {simview-3.5 → simview-3.6}/simview/static/js/ui/ScalarPlotter.js +6 -2
  13. simview-3.6/simview/static/js/ui/TerrainProfile.js +544 -0
  14. simview-3.6/simview/static/js/utils/terrainSample.js +92 -0
  15. {simview-3.5 → simview-3.6}/simview/terrain.py +492 -1
  16. {simview-3.5 → simview-3.6/simview.egg-info}/PKG-INFO +1 -1
  17. {simview-3.5 → simview-3.6}/simview.egg-info/SOURCES.txt +2 -0
  18. {simview-3.5 → simview-3.6}/tests/test_cli.py +355 -2
  19. {simview-3.5 → simview-3.6}/tests/test_info.py +26 -0
  20. {simview-3.5 → simview-3.6}/tests/test_roundtrip.py +13 -0
  21. {simview-3.5 → simview-3.6}/tests/test_scene.py +18 -0
  22. {simview-3.5 → simview-3.6}/tests/test_terrain_query.py +293 -0
  23. {simview-3.5 → simview-3.6}/LICENSE +0 -0
  24. {simview-3.5 → simview-3.6}/MANIFEST.in +0 -0
  25. {simview-3.5 → simview-3.6}/README.md +0 -0
  26. {simview-3.5 → simview-3.6}/setup.cfg +0 -0
  27. {simview-3.5 → simview-3.6}/simview/__init__.py +0 -0
  28. {simview-3.5 → simview-3.6}/simview/diff.py +0 -0
  29. {simview-3.5 → simview-3.6}/simview/launcher.py +0 -0
  30. {simview-3.5 → simview-3.6}/simview/live.py +0 -0
  31. {simview-3.5 → simview-3.6}/simview/merge.py +0 -0
  32. {simview-3.5 → simview-3.6}/simview/py.typed +0 -0
  33. {simview-3.5 → simview-3.6}/simview/render.py +0 -0
  34. {simview-3.5 → simview-3.6}/simview/server.py +0 -0
  35. {simview-3.5 → simview-3.6}/simview/state.py +0 -0
  36. {simview-3.5 → simview-3.6}/simview/static/css/controls.css +0 -0
  37. {simview-3.5 → simview-3.6}/simview/static/js/components/AnimationController.js +0 -0
  38. {simview-3.5 → simview-3.6}/simview/static/js/components/InteractionController.js +0 -0
  39. {simview-3.5 → simview-3.6}/simview/static/js/components/InteractionControls.js +0 -0
  40. {simview-3.5 → simview-3.6}/simview/static/js/components/Scene.js +0 -0
  41. {simview-3.5 → simview-3.6}/simview/static/js/components/StateStore.js +0 -0
  42. {simview-3.5 → simview-3.6}/simview/static/js/main.js +0 -0
  43. {simview-3.5 → simview-3.6}/simview/static/js/objects/Body.js +0 -0
  44. {simview-3.5 → simview-3.6}/simview/static/js/objects/StaticObject.js +0 -0
  45. {simview-3.5 → simview-3.6}/simview/static/js/objects/Terrain.js +0 -0
  46. {simview-3.5 → simview-3.6}/simview/static/js/objects/utils.js +0 -0
  47. {simview-3.5 → simview-3.6}/simview/static/js/ui/BatchLegend.js +0 -0
  48. {simview-3.5 → simview-3.6}/simview/static/js/ui/BodyStateWindow.js +0 -0
  49. {simview-3.5 → simview-3.6}/simview/static/js/ui/ErrorMetrics.js +0 -0
  50. {simview-3.5 → simview-3.6}/simview/static/js/ui/Legend.js +0 -0
  51. {simview-3.5 → simview-3.6}/simview/static/js/ui/PlaybackControls.js +0 -0
  52. {simview-3.5 → simview-3.6}/simview/static/js/utils/batchPresets.js +0 -0
  53. {simview-3.5 → simview-3.6}/simview/static/js/utils/blobCodec.js +0 -0
  54. {simview-3.5 → simview-3.6}/simview/static/js/utils/bodyTransforms.js +0 -0
  55. {simview-3.5 → simview-3.6}/simview/static/js/utils/csv.js +0 -0
  56. {simview-3.5 → simview-3.6}/simview/static/js/utils/errorMath.js +0 -0
  57. {simview-3.5 → simview-3.6}/simview/static/js/utils/injectStyles.js +0 -0
  58. {simview-3.5 → simview-3.6}/simview/static/js/utils/interpolate.js +0 -0
  59. {simview-3.5 → simview-3.6}/simview/static/js/utils/liveFollow.js +0 -0
  60. {simview-3.5 → simview-3.6}/simview/static/js/utils/loadRecordingLibs.js +0 -0
  61. {simview-3.5 → simview-3.6}/simview/static/js/utils/viewState.js +0 -0
  62. {simview-3.5 → simview-3.6}/simview/static/lib/chroma-js-3.1.2/index.min.js +0 -0
  63. {simview-3.5 → simview-3.6}/simview/static/lib/chroma-js-3.1.2/src/Color.js +0 -0
  64. {simview-3.5 → simview-3.6}/simview/static/lib/chroma-js-3.1.2/src/chroma.js +0 -0
  65. {simview-3.5 → simview-3.6}/simview/static/lib/chroma-js-3.1.2/src/colors/colorbrewer.js +0 -0
  66. {simview-3.5 → simview-3.6}/simview/static/lib/chroma-js-3.1.2/src/colors/w3cx11.js +0 -0
  67. {simview-3.5 → simview-3.6}/simview/static/lib/chroma-js-3.1.2/src/generator/average.js +0 -0
  68. {simview-3.5 → simview-3.6}/simview/static/lib/chroma-js-3.1.2/src/generator/bezier.js +0 -0
  69. {simview-3.5 → simview-3.6}/simview/static/lib/chroma-js-3.1.2/src/generator/blend.js +0 -0
  70. {simview-3.5 → simview-3.6}/simview/static/lib/chroma-js-3.1.2/src/generator/cubehelix.js +0 -0
  71. {simview-3.5 → simview-3.6}/simview/static/lib/chroma-js-3.1.2/src/generator/mix.js +0 -0
  72. {simview-3.5 → simview-3.6}/simview/static/lib/chroma-js-3.1.2/src/generator/random.js +0 -0
  73. {simview-3.5 → simview-3.6}/simview/static/lib/chroma-js-3.1.2/src/generator/scale.js +0 -0
  74. {simview-3.5 → simview-3.6}/simview/static/lib/chroma-js-3.1.2/src/interpolator/_hsx.js +0 -0
  75. {simview-3.5 → simview-3.6}/simview/static/lib/chroma-js-3.1.2/src/interpolator/hcg.js +0 -0
  76. {simview-3.5 → simview-3.6}/simview/static/lib/chroma-js-3.1.2/src/interpolator/hsi.js +0 -0
  77. {simview-3.5 → simview-3.6}/simview/static/lib/chroma-js-3.1.2/src/interpolator/hsl.js +0 -0
  78. {simview-3.5 → simview-3.6}/simview/static/lib/chroma-js-3.1.2/src/interpolator/hsv.js +0 -0
  79. {simview-3.5 → simview-3.6}/simview/static/lib/chroma-js-3.1.2/src/interpolator/index.js +0 -0
  80. {simview-3.5 → simview-3.6}/simview/static/lib/chroma-js-3.1.2/src/interpolator/lab.js +0 -0
  81. {simview-3.5 → simview-3.6}/simview/static/lib/chroma-js-3.1.2/src/interpolator/lch.js +0 -0
  82. {simview-3.5 → simview-3.6}/simview/static/lib/chroma-js-3.1.2/src/interpolator/lrgb.js +0 -0
  83. {simview-3.5 → simview-3.6}/simview/static/lib/chroma-js-3.1.2/src/interpolator/num.js +0 -0
  84. {simview-3.5 → simview-3.6}/simview/static/lib/chroma-js-3.1.2/src/interpolator/oklab.js +0 -0
  85. {simview-3.5 → simview-3.6}/simview/static/lib/chroma-js-3.1.2/src/interpolator/oklch.js +0 -0
  86. {simview-3.5 → simview-3.6}/simview/static/lib/chroma-js-3.1.2/src/interpolator/rgb.js +0 -0
  87. {simview-3.5 → simview-3.6}/simview/static/lib/chroma-js-3.1.2/src/io/cmyk/cmyk2rgb.js +0 -0
  88. {simview-3.5 → simview-3.6}/simview/static/lib/chroma-js-3.1.2/src/io/cmyk/index.js +0 -0
  89. {simview-3.5 → simview-3.6}/simview/static/lib/chroma-js-3.1.2/src/io/cmyk/rgb2cmyk.js +0 -0
  90. {simview-3.5 → simview-3.6}/simview/static/lib/chroma-js-3.1.2/src/io/css/css2rgb.js +0 -0
  91. {simview-3.5 → simview-3.6}/simview/static/lib/chroma-js-3.1.2/src/io/css/hsl2css.js +0 -0
  92. {simview-3.5 → simview-3.6}/simview/static/lib/chroma-js-3.1.2/src/io/css/index.js +0 -0
  93. {simview-3.5 → simview-3.6}/simview/static/lib/chroma-js-3.1.2/src/io/css/lab2css.js +0 -0
  94. {simview-3.5 → simview-3.6}/simview/static/lib/chroma-js-3.1.2/src/io/css/lch2css.js +0 -0
  95. {simview-3.5 → simview-3.6}/simview/static/lib/chroma-js-3.1.2/src/io/css/oklab2css.js +0 -0
  96. {simview-3.5 → simview-3.6}/simview/static/lib/chroma-js-3.1.2/src/io/css/oklch2css.js +0 -0
  97. {simview-3.5 → simview-3.6}/simview/static/lib/chroma-js-3.1.2/src/io/css/rgb2css.js +0 -0
  98. {simview-3.5 → simview-3.6}/simview/static/lib/chroma-js-3.1.2/src/io/gl/index.js +0 -0
  99. {simview-3.5 → simview-3.6}/simview/static/lib/chroma-js-3.1.2/src/io/hcg/hcg2rgb.js +0 -0
  100. {simview-3.5 → simview-3.6}/simview/static/lib/chroma-js-3.1.2/src/io/hcg/index.js +0 -0
  101. {simview-3.5 → simview-3.6}/simview/static/lib/chroma-js-3.1.2/src/io/hcg/rgb2hcg.js +0 -0
  102. {simview-3.5 → simview-3.6}/simview/static/lib/chroma-js-3.1.2/src/io/hex/hex2rgb.js +0 -0
  103. {simview-3.5 → simview-3.6}/simview/static/lib/chroma-js-3.1.2/src/io/hex/index.js +0 -0
  104. {simview-3.5 → simview-3.6}/simview/static/lib/chroma-js-3.1.2/src/io/hex/rgb2hex.js +0 -0
  105. {simview-3.5 → simview-3.6}/simview/static/lib/chroma-js-3.1.2/src/io/hsi/hsi2rgb.js +0 -0
  106. {simview-3.5 → simview-3.6}/simview/static/lib/chroma-js-3.1.2/src/io/hsi/index.js +0 -0
  107. {simview-3.5 → simview-3.6}/simview/static/lib/chroma-js-3.1.2/src/io/hsi/rgb2hsi.js +0 -0
  108. {simview-3.5 → simview-3.6}/simview/static/lib/chroma-js-3.1.2/src/io/hsl/hsl2rgb.js +0 -0
  109. {simview-3.5 → simview-3.6}/simview/static/lib/chroma-js-3.1.2/src/io/hsl/index.js +0 -0
  110. {simview-3.5 → simview-3.6}/simview/static/lib/chroma-js-3.1.2/src/io/hsl/rgb2hsl.js +0 -0
  111. {simview-3.5 → simview-3.6}/simview/static/lib/chroma-js-3.1.2/src/io/hsv/hsv2rgb.js +0 -0
  112. {simview-3.5 → simview-3.6}/simview/static/lib/chroma-js-3.1.2/src/io/hsv/index.js +0 -0
  113. {simview-3.5 → simview-3.6}/simview/static/lib/chroma-js-3.1.2/src/io/hsv/rgb2hsv.js +0 -0
  114. {simview-3.5 → simview-3.6}/simview/static/lib/chroma-js-3.1.2/src/io/input.js +0 -0
  115. {simview-3.5 → simview-3.6}/simview/static/lib/chroma-js-3.1.2/src/io/lab/index.js +0 -0
  116. {simview-3.5 → simview-3.6}/simview/static/lib/chroma-js-3.1.2/src/io/lab/lab-constants.js +0 -0
  117. {simview-3.5 → simview-3.6}/simview/static/lib/chroma-js-3.1.2/src/io/lab/lab2rgb.js +0 -0
  118. {simview-3.5 → simview-3.6}/simview/static/lib/chroma-js-3.1.2/src/io/lab/rgb2lab.js +0 -0
  119. {simview-3.5 → simview-3.6}/simview/static/lib/chroma-js-3.1.2/src/io/lch/hcl2rgb.js +0 -0
  120. {simview-3.5 → simview-3.6}/simview/static/lib/chroma-js-3.1.2/src/io/lch/index.js +0 -0
  121. {simview-3.5 → simview-3.6}/simview/static/lib/chroma-js-3.1.2/src/io/lch/lab2lch.js +0 -0
  122. {simview-3.5 → simview-3.6}/simview/static/lib/chroma-js-3.1.2/src/io/lch/lch2lab.js +0 -0
  123. {simview-3.5 → simview-3.6}/simview/static/lib/chroma-js-3.1.2/src/io/lch/lch2rgb.js +0 -0
  124. {simview-3.5 → simview-3.6}/simview/static/lib/chroma-js-3.1.2/src/io/lch/rgb2lch.js +0 -0
  125. {simview-3.5 → simview-3.6}/simview/static/lib/chroma-js-3.1.2/src/io/named/index.js +0 -0
  126. {simview-3.5 → simview-3.6}/simview/static/lib/chroma-js-3.1.2/src/io/num/index.js +0 -0
  127. {simview-3.5 → simview-3.6}/simview/static/lib/chroma-js-3.1.2/src/io/num/num2rgb.js +0 -0
  128. {simview-3.5 → simview-3.6}/simview/static/lib/chroma-js-3.1.2/src/io/num/rgb2num.js +0 -0
  129. {simview-3.5 → simview-3.6}/simview/static/lib/chroma-js-3.1.2/src/io/oklab/index.js +0 -0
  130. {simview-3.5 → simview-3.6}/simview/static/lib/chroma-js-3.1.2/src/io/oklab/oklab2rgb.js +0 -0
  131. {simview-3.5 → simview-3.6}/simview/static/lib/chroma-js-3.1.2/src/io/oklab/rgb2oklab.js +0 -0
  132. {simview-3.5 → simview-3.6}/simview/static/lib/chroma-js-3.1.2/src/io/oklch/index.js +0 -0
  133. {simview-3.5 → simview-3.6}/simview/static/lib/chroma-js-3.1.2/src/io/oklch/oklch2rgb.js +0 -0
  134. {simview-3.5 → simview-3.6}/simview/static/lib/chroma-js-3.1.2/src/io/oklch/rgb2oklch.js +0 -0
  135. {simview-3.5 → simview-3.6}/simview/static/lib/chroma-js-3.1.2/src/io/rgb/index.js +0 -0
  136. {simview-3.5 → simview-3.6}/simview/static/lib/chroma-js-3.1.2/src/io/temp/index.js +0 -0
  137. {simview-3.5 → simview-3.6}/simview/static/lib/chroma-js-3.1.2/src/io/temp/rgb2temperature.js +0 -0
  138. {simview-3.5 → simview-3.6}/simview/static/lib/chroma-js-3.1.2/src/io/temp/temperature2rgb.js +0 -0
  139. {simview-3.5 → simview-3.6}/simview/static/lib/chroma-js-3.1.2/src/ops/alpha.js +0 -0
  140. {simview-3.5 → simview-3.6}/simview/static/lib/chroma-js-3.1.2/src/ops/clipped.js +0 -0
  141. {simview-3.5 → simview-3.6}/simview/static/lib/chroma-js-3.1.2/src/ops/darken.js +0 -0
  142. {simview-3.5 → simview-3.6}/simview/static/lib/chroma-js-3.1.2/src/ops/get.js +0 -0
  143. {simview-3.5 → simview-3.6}/simview/static/lib/chroma-js-3.1.2/src/ops/luminance.js +0 -0
  144. {simview-3.5 → simview-3.6}/simview/static/lib/chroma-js-3.1.2/src/ops/mix.js +0 -0
  145. {simview-3.5 → simview-3.6}/simview/static/lib/chroma-js-3.1.2/src/ops/premultiply.js +0 -0
  146. {simview-3.5 → simview-3.6}/simview/static/lib/chroma-js-3.1.2/src/ops/saturate.js +0 -0
  147. {simview-3.5 → simview-3.6}/simview/static/lib/chroma-js-3.1.2/src/ops/set.js +0 -0
  148. {simview-3.5 → simview-3.6}/simview/static/lib/chroma-js-3.1.2/src/ops/shade.js +0 -0
  149. {simview-3.5 → simview-3.6}/simview/static/lib/chroma-js-3.1.2/src/utils/analyze.js +0 -0
  150. {simview-3.5 → simview-3.6}/simview/static/lib/chroma-js-3.1.2/src/utils/clip_rgb.js +0 -0
  151. {simview-3.5 → simview-3.6}/simview/static/lib/chroma-js-3.1.2/src/utils/contrast.js +0 -0
  152. {simview-3.5 → simview-3.6}/simview/static/lib/chroma-js-3.1.2/src/utils/contrastAPCA.js +0 -0
  153. {simview-3.5 → simview-3.6}/simview/static/lib/chroma-js-3.1.2/src/utils/delta-e.js +0 -0
  154. {simview-3.5 → simview-3.6}/simview/static/lib/chroma-js-3.1.2/src/utils/distance.js +0 -0
  155. {simview-3.5 → simview-3.6}/simview/static/lib/chroma-js-3.1.2/src/utils/index.js +0 -0
  156. {simview-3.5 → simview-3.6}/simview/static/lib/chroma-js-3.1.2/src/utils/last.js +0 -0
  157. {simview-3.5 → simview-3.6}/simview/static/lib/chroma-js-3.1.2/src/utils/limit.js +0 -0
  158. {simview-3.5 → simview-3.6}/simview/static/lib/chroma-js-3.1.2/src/utils/multiply-matrices.js +0 -0
  159. {simview-3.5 → simview-3.6}/simview/static/lib/chroma-js-3.1.2/src/utils/scales.js +0 -0
  160. {simview-3.5 → simview-3.6}/simview/static/lib/chroma-js-3.1.2/src/utils/type.js +0 -0
  161. {simview-3.5 → simview-3.6}/simview/static/lib/chroma-js-3.1.2/src/utils/unpack.js +0 -0
  162. {simview-3.5 → simview-3.6}/simview/static/lib/chroma-js-3.1.2/src/utils/valid.js +0 -0
  163. {simview-3.5 → simview-3.6}/simview/static/lib/chroma-js-3.1.2/src/version.js +0 -0
  164. {simview-3.5 → simview-3.6}/simview/static/lib/download.js +0 -0
  165. {simview-3.5 → simview-3.6}/simview/static/lib/js-colormaps.js +0 -0
  166. {simview-3.5 → simview-3.6}/simview/static/lib/tar.js +0 -0
  167. {simview-3.5 → simview-3.6}/simview/static/lib/three-0.174.0/addons/controls/OrbitControls.js +0 -0
  168. {simview-3.5 → simview-3.6}/simview/static/lib/three-0.174.0/addons/libs/lil-gui.module.min.js +0 -0
  169. {simview-3.5 → simview-3.6}/simview/static/lib/three-0.174.0/three.core.js +0 -0
  170. {simview-3.5 → simview-3.6}/simview/static/lib/three-0.174.0/three.module.js +0 -0
  171. {simview-3.5 → simview-3.6}/simview/static/lib/uPlot.esm.js +0 -0
  172. {simview-3.5 → simview-3.6}/simview/static/lib/uPlot.min.css +0 -0
  173. {simview-3.5 → simview-3.6}/simview/static/textures/contacts/red-cross0.png +0 -0
  174. {simview-3.5 → simview-3.6}/simview/static/textures/points/ball0.png +0 -0
  175. {simview-3.5 → simview-3.6}/simview/static/textures/points/ball1.png +0 -0
  176. {simview-3.5 → simview-3.6}/simview/templates/index.html +0 -0
  177. {simview-3.5 → simview-3.6}/simview/utils.py +0 -0
  178. {simview-3.5 → simview-3.6}/simview.egg-info/dependency_links.txt +0 -0
  179. {simview-3.5 → simview-3.6}/simview.egg-info/entry_points.txt +0 -0
  180. {simview-3.5 → simview-3.6}/simview.egg-info/requires.txt +0 -0
  181. {simview-3.5 → simview-3.6}/simview.egg-info/top_level.txt +0 -0
  182. {simview-3.5 → simview-3.6}/tests/test_columnar_states.py +0 -0
  183. {simview-3.5 → simview-3.6}/tests/test_contacts.py +0 -0
  184. {simview-3.5 → simview-3.6}/tests/test_diff.py +0 -0
  185. {simview-3.5 → simview-3.6}/tests/test_launcher.py +0 -0
  186. {simview-3.5 → simview-3.6}/tests/test_live.py +0 -0
  187. {simview-3.5 → simview-3.6}/tests/test_merge.py +0 -0
  188. {simview-3.5 → simview-3.6}/tests/test_server.py +0 -0
  189. {simview-3.5 → simview-3.6}/tests/test_show.py +0 -0
  190. {simview-3.5 → simview-3.6}/tests/test_terrain.py +0 -0
  191. {simview-3.5 → simview-3.6}/tests/test_trajectory.py +0 -0
  192. {simview-3.5 → simview-3.6}/tests/test_utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: simview
3
- Version: 3.5
3
+ Version: 3.6
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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "simview"
7
- version = "3.5"
7
+ version = "3.6"
8
8
  readme = "README.md"
9
9
  requires-python = ">=3.12"
10
10
  license = "BSD-3-Clause"
@@ -82,14 +82,18 @@ def run_info(path: Path, as_json: bool) -> None:
82
82
 
83
83
 
84
84
  def run_terrain(path: Path, args: argparse.Namespace) -> None:
85
- """Print terrain value(s) at a point or over an area, from the scene JSON
86
- at `path`, to stdout. With `--batches A B`, compares two batches instead
87
- of querying a single one."""
85
+ """Print terrain value(s) at a point, over an area, or along a body's
86
+ trajectory, from the scene JSON at `path`, to stdout. With `--batches
87
+ A B`, compares two batches instead of querying a single one (for
88
+ `--along-body`, both batches' terrains are sampled along batch A's
89
+ trajectory -- see `query_along_body_diff`)."""
88
90
  from simview import terrain as terrain_mod
89
91
 
90
- if (args.point is None) == (args.area is None):
92
+ mode_count = sum(v is not None for v in (args.point, args.area, args.along_body))
93
+ if mode_count != 1:
91
94
  logger.error(
92
- "Error: 'simview terrain' requires exactly one of --point or --area."
95
+ "Error: 'simview terrain' requires exactly one of --point, --area, "
96
+ "or --along-body."
93
97
  )
94
98
  sys.exit(1)
95
99
  if args.area is not None and len(args.area) not in (0, 4):
@@ -106,40 +110,67 @@ def run_terrain(path: Path, args: argparse.Namespace) -> None:
106
110
  diff_mode = args.batches is not None
107
111
 
108
112
  try:
109
- model_data = terrain_mod.load_scene_model(path)
110
- if diff_mode:
111
- batch_a, batch_b = args.batches
112
- if args.point is not None:
113
- result = terrain_mod.query_point_diff(
113
+ if args.along_body is not None:
114
+ model_data, states_data = terrain_mod.load_scene(path)
115
+ if diff_mode:
116
+ batch_a, batch_b = args.batches
117
+ result = terrain_mod.query_along_body_diff(
114
118
  model_data,
115
- args.point[0],
116
- args.point[1],
119
+ states_data,
120
+ args.along_body,
117
121
  batch_a,
118
122
  batch_b,
119
123
  args.layer,
124
+ args.every,
120
125
  )
121
- text = terrain_mod.format_point_diff_text(result)
122
- csv_text = terrain_mod.format_point_diff_csv(result)
126
+ text = terrain_mod.format_along_diff_text(result)
127
+ csv_text = terrain_mod.format_along_diff_csv(result)
123
128
  else:
124
- bounds = tuple(args.area) if args.area else None
125
- result = terrain_mod.query_area_diff(
126
- model_data, bounds, batch_a, batch_b, args.layer, args.stride
129
+ result = terrain_mod.query_along_body(
130
+ model_data,
131
+ states_data,
132
+ args.along_body,
133
+ args.layer,
134
+ args.batch,
135
+ args.every,
127
136
  )
128
- text = terrain_mod.format_area_diff_text(result)
129
- csv_text = terrain_mod.format_area_diff_csv(result)
130
- elif args.point is not None:
131
- result = terrain_mod.query_point(
132
- model_data, args.point[0], args.point[1], args.layer, args.batch
133
- )
134
- text = terrain_mod.format_point_text(result)
135
- csv_text = terrain_mod.format_point_csv(result)
137
+ text = terrain_mod.format_along_text(result)
138
+ csv_text = terrain_mod.format_along_csv(result)
136
139
  else:
137
- bounds = tuple(args.area) if args.area else None
138
- result = terrain_mod.query_area(
139
- model_data, bounds, args.layer, args.batch, args.stride
140
- )
141
- text = terrain_mod.format_area_text(result)
142
- csv_text = terrain_mod.format_area_csv(result)
140
+ model_data = terrain_mod.load_scene_model(path)
141
+ if diff_mode:
142
+ batch_a, batch_b = args.batches
143
+ if args.point is not None:
144
+ result = terrain_mod.query_point_diff(
145
+ model_data,
146
+ args.point[0],
147
+ args.point[1],
148
+ batch_a,
149
+ batch_b,
150
+ args.layer,
151
+ )
152
+ text = terrain_mod.format_point_diff_text(result)
153
+ csv_text = terrain_mod.format_point_diff_csv(result)
154
+ else:
155
+ bounds = tuple(args.area) if args.area else None
156
+ result = terrain_mod.query_area_diff(
157
+ model_data, bounds, batch_a, batch_b, args.layer, args.stride
158
+ )
159
+ text = terrain_mod.format_area_diff_text(result)
160
+ csv_text = terrain_mod.format_area_diff_csv(result)
161
+ elif args.point is not None:
162
+ result = terrain_mod.query_point(
163
+ model_data, args.point[0], args.point[1], args.layer, args.batch
164
+ )
165
+ text = terrain_mod.format_point_text(result)
166
+ csv_text = terrain_mod.format_point_csv(result)
167
+ else:
168
+ bounds = tuple(args.area) if args.area else None
169
+ result = terrain_mod.query_area(
170
+ model_data, bounds, args.layer, args.batch, args.stride
171
+ )
172
+ text = terrain_mod.format_area_text(result)
173
+ csv_text = terrain_mod.format_area_csv(result)
143
174
  except (json.JSONDecodeError, ValueError, KeyError) as e:
144
175
  logger.error("Error: %s", e)
145
176
  sys.exit(1)
@@ -154,7 +185,10 @@ def run_terrain(path: Path, args: argparse.Namespace) -> None:
154
185
 
155
186
  def run_diff(path: Path, args: argparse.Namespace) -> None:
156
187
  """Print a trajectory divergence report between two batches of the scene
157
- JSON at `path`, to stdout."""
188
+ JSON at `path`, to stdout. With `--fail-on-exceed`, additionally exits
189
+ with a non-zero code once printing is done: 0 = within thresholds, 1 =
190
+ usage/parse error, 2 = a threshold was exceeded (see `--fail-on-exceed`'s
191
+ help text)."""
158
192
  from simview import diff as diff_mod
159
193
 
160
194
  if args.batches is None:
@@ -166,6 +200,16 @@ def run_diff(path: Path, args: argparse.Namespace) -> None:
166
200
  if args.json and args.csv:
167
201
  logger.error("Error: --json and --csv are mutually exclusive.")
168
202
  sys.exit(1)
203
+ if (
204
+ args.fail_on_exceed
205
+ and args.pos_threshold is None
206
+ and args.rot_threshold_deg is None
207
+ ):
208
+ logger.error(
209
+ "Error: --fail-on-exceed requires --pos-threshold and/or "
210
+ "--rot-threshold-deg."
211
+ )
212
+ sys.exit(1)
169
213
 
170
214
  try:
171
215
  model_data, states_data = diff_mod.load_scene(path)
@@ -193,6 +237,15 @@ def run_diff(path: Path, args: argparse.Namespace) -> None:
193
237
  else:
194
238
  print(text)
195
239
 
240
+ if args.fail_on_exceed:
241
+ exceeded = any(
242
+ body["summary"]["first_frame_exceeding_pos_threshold"] is not None
243
+ or body["summary"]["first_frame_exceeding_rot_threshold"] is not None
244
+ for body in result["bodies"].values()
245
+ )
246
+ if exceeded:
247
+ sys.exit(2)
248
+
196
249
 
197
250
  def run_render(path: Path, args: argparse.Namespace) -> None:
198
251
  """Headlessly render one PNG screenshot of the scene JSON at `path`, for
@@ -249,12 +302,13 @@ def build_parser() -> argparse.ArgumentParser:
249
302
  help=(
250
303
  "Path(s) to simulation JSON file(s) to visualize, 'clear' to clear "
251
304
  "cache, 'info <file>' to print a structural summary of one scene "
252
- "file, 'terrain <file>' to query terrain values at a point or over "
253
- "an area, 'diff <file>' to compare two batches' trajectories, or "
254
- "'render <file>' to headlessly save a PNG screenshot (needs the "
255
- "'render' extra). Multiple visualize-mode files are merged into "
256
- "one scene, each file's batches appended as extra batches (e.g. a "
257
- "real-world recording plus a simulated rerun)."
305
+ "file, 'terrain <file>' to query terrain values at a point, over "
306
+ "an area, or along a body's trajectory, 'diff <file>' to compare "
307
+ "two batches' trajectories, or 'render <file>' to headlessly save "
308
+ "a PNG screenshot (needs the 'render' extra). Multiple "
309
+ "visualize-mode files are merged into one scene, each file's "
310
+ "batches appended as extra batches (e.g. a real-world recording "
311
+ "plus a simulated rerun)."
258
312
  ),
259
313
  )
260
314
  parser.add_argument(
@@ -287,7 +341,7 @@ def build_parser() -> argparse.ArgumentParser:
287
341
  help=(
288
342
  "With 'simview terrain <file>', query terrain value(s) at a single "
289
343
  "(x, y) point (bilinear-interpolated). Mutually exclusive with "
290
- "--area."
344
+ "--area and --along-body."
291
345
  ),
292
346
  )
293
347
  parser.add_argument(
@@ -299,7 +353,23 @@ def build_parser() -> argparse.ArgumentParser:
299
353
  "With 'simview terrain <file>', query terrain values over a "
300
354
  "rectangular area: pass no values for the whole terrain extent, "
301
355
  "or exactly 4 values 'XMIN XMAX YMIN YMAX' for a sub-box. Mutually "
302
- "exclusive with --point."
356
+ "exclusive with --point and --along-body."
357
+ ),
358
+ )
359
+ parser.add_argument(
360
+ "--along-body",
361
+ type=str,
362
+ default=None,
363
+ metavar="BODY",
364
+ help=(
365
+ "With 'simview terrain <file>', sample terrain value(s) "
366
+ "bilinear-interpolated at BODY's (x, y) position in every "
367
+ "sampled state (e.g. friction/stiffness under a robot's driven "
368
+ "path). BODY is matched the same way as 'simview diff' --body "
369
+ "(full label, or any single name inside a rigidly-grouped body). "
370
+ "With --batches A B, samples both batches' terrains along batch "
371
+ "A's trajectory instead and reports the delta per layer. "
372
+ "Mutually exclusive with --point and --area."
303
373
  ),
304
374
  )
305
375
  parser.add_argument(
@@ -333,10 +403,11 @@ def build_parser() -> argparse.ArgumentParser:
333
403
  metavar=("A", "B"),
334
404
  help=(
335
405
  "Two batch indices to compare. Required by 'simview diff <file>'. "
336
- "With 'simview terrain <file> --point|--area', switches into "
337
- "cross-batch diff mode (value_a/value_b/delta per layer) instead "
338
- "of a single-batch query, and takes precedence over --batch if "
339
- "both are given."
406
+ "With 'simview terrain <file> --point|--area|--along-body', "
407
+ "switches into cross-batch diff mode (value_a/value_b/delta per "
408
+ "layer) instead of a single-batch query, and takes precedence "
409
+ "over --batch if both are given. For --along-body, both batches' "
410
+ "terrains are sampled along batch A's trajectory."
340
411
  ),
341
412
  )
342
413
  parser.add_argument(
@@ -355,7 +426,10 @@ def build_parser() -> argparse.ArgumentParser:
355
426
  "--every",
356
427
  type=int,
357
428
  default=1,
358
- help="With 'simview diff <file>', sample every Nth frame (default: 1).",
429
+ help=(
430
+ "With 'simview diff <file>' or 'simview terrain <file> "
431
+ "--along-body', sample every Nth frame (default: 1)."
432
+ ),
359
433
  )
360
434
  parser.add_argument(
361
435
  "--pos-threshold",
@@ -386,6 +460,18 @@ def build_parser() -> argparse.ArgumentParser:
386
460
  "the browser Error Metrics panel's per-axis toggle."
387
461
  ),
388
462
  )
463
+ parser.add_argument(
464
+ "--fail-on-exceed",
465
+ action="store_true",
466
+ help=(
467
+ "With 'simview diff <file>', exit with code 2 (after printing the "
468
+ "normal output) if any diffed body's trajectory exceeds "
469
+ "--pos-threshold or --rot-threshold-deg; requires at least one of "
470
+ "them. Exit codes: 0 = within thresholds, 1 = usage/parse error, "
471
+ "2 = threshold exceeded -- lets scripts/CI tell divergence apart "
472
+ "from a broken invocation."
473
+ ),
474
+ )
389
475
  parser.add_argument(
390
476
  "--host",
391
477
  type=str,
@@ -142,6 +142,7 @@ def _summarize_model(model: dict, warnings: list[str]) -> dict:
142
142
  "scalar_names": model.get("scalarNames") or [],
143
143
  "dt": model.get("dt"),
144
144
  "collapse": model.get("collapse"),
145
+ "metadata": model.get("metadata"),
145
146
  "terrain": _summarize_terrain(terrain) if terrain is not None else None,
146
147
  "bodies": {
147
148
  "count": len(bodies),
@@ -445,6 +446,12 @@ def format_text(summary: dict) -> str:
445
446
  lines.append(f" Scalars: {', '.join(model['scalar_names']) or '(none)'}")
446
447
  lines.append(f" dt: {model['dt']} Collapse: {model['collapse']}")
447
448
 
449
+ metadata = model["metadata"]
450
+ if metadata:
451
+ lines.append("\nMetadata")
452
+ for key, value in metadata.items():
453
+ lines.append(f" {key}: {value}")
454
+
448
455
  terrain = model["terrain"]
449
456
  if terrain is None:
450
457
  lines.append("\nTerrain: (none)")
@@ -2,7 +2,7 @@ import base64
2
2
  import logging
3
3
  from dataclasses import dataclass, field
4
4
  from enum import StrEnum
5
- from typing import Any
5
+ from typing import Any, cast
6
6
 
7
7
  import numpy as np
8
8
  import torch
@@ -488,6 +488,10 @@ class SimViewModel:
488
488
  bodies: dict[str, SimViewBody] = field(default_factory=dict)
489
489
  static_objects: dict[str, SimViewStaticObject] = field(default_factory=dict)
490
490
  batch_names: list[str] | None = None
491
+ # Free-form, JSON-serializable run provenance (engine name, checkpoint path,
492
+ # git commit, CLI args, ...) with no meaning to the viewer itself -- just
493
+ # carried through so a scene saved months ago is still self-describing.
494
+ metadata: dict[str, Any] | None = None
491
495
 
492
496
  def __post_init__(self):
493
497
  if self.batch_names is not None and len(self.batch_names) != self.batch_size:
@@ -524,14 +528,50 @@ class SimViewModel:
524
528
  def create_terrain(
525
529
  self,
526
530
  heightmap: torch.Tensor,
527
- normals: torch.Tensor,
528
- x_lim: tuple[float, float],
529
- y_lim: tuple[float, float],
531
+ normals: torch.Tensor | None = None,
532
+ x_lim: tuple[float, float] | None = None,
533
+ y_lim: tuple[float, float] | None = None,
534
+ grid_res: float | None = None,
530
535
  friction_map: torch.Tensor | None = None,
531
536
  stiffness_map: torch.Tensor | None = None,
532
537
  ) -> None:
538
+ """Adds terrain to the internal simulation model.
539
+
540
+ Args:
541
+ heightmap (torch.Tensor): 2D or 3D tensor of terrain heights.
542
+ normals (torch.Tensor | None): 3D or 4D tensor of terrain normals. If None,
543
+ normals are automatically computed from the heightmap gradients.
544
+ x_lim (tuple[float, float] | None): (min, max) coordinates for the X axis.
545
+ y_lim (tuple[float, float] | None): (min, max) coordinates for the Y axis.
546
+ grid_res (float | None): Grid resolution. If x_lim and y_lim are omitted,
547
+ they will be automatically inferred assuming the grid is centered at 0.
548
+ friction_map (torch.Tensor | None): Optional friction coefficient map.
549
+ stiffness_map (torch.Tensor | None): Optional stiffness coefficient map.
550
+ """
533
551
  if heightmap.ndim == 2:
534
552
  heightmap = heightmap.unsqueeze(0) # add batch dim
553
+
554
+ if x_lim is None or y_lim is None:
555
+ if grid_res is None:
556
+ raise ValueError("Must provide either (x_lim, y_lim) or grid_res")
557
+ H_dim, W_dim = heightmap.shape[-2:]
558
+ x_lim = x_lim or (-W_dim * grid_res / 2.0, W_dim * grid_res / 2.0)
559
+ y_lim = y_lim or (-H_dim * grid_res / 2.0, H_dim * grid_res / 2.0)
560
+
561
+ if normals is None:
562
+ H_dim, W_dim = heightmap.shape[-2:]
563
+ res_x = (x_lim[1] - x_lim[0]) / W_dim
564
+ res_y = (y_lim[1] - y_lim[0]) / H_dim
565
+ dzdy, dzdx = torch.gradient(heightmap, spacing=(res_y, res_x), dim=(-2, -1))
566
+ nx = -dzdx
567
+ ny = -dzdy
568
+ nz = torch.ones_like(nx)
569
+ computed_normals = torch.stack([nx, ny, nz], dim=-3)
570
+ computed_normals = computed_normals / torch.linalg.norm(
571
+ computed_normals, dim=-3, keepdim=True
572
+ )
573
+ normals = cast(torch.Tensor, computed_normals.to(dtype=heightmap.dtype))
574
+
535
575
  if normals.ndim == 3: # channels first
536
576
  normals = normals.unsqueeze(0) # add batch dim
537
577
  if friction_map is not None and friction_map.ndim == 2:
@@ -640,6 +680,8 @@ class SimViewModel:
640
680
  }
641
681
  if self.batch_names is not None:
642
682
  r["batchNames"] = self.batch_names
683
+ if self.metadata is not None:
684
+ r["metadata"] = self.metadata
643
685
  return r
644
686
 
645
687
  @classmethod
@@ -683,6 +725,7 @@ class SimViewModel:
683
725
  bodies=bodies,
684
726
  static_objects=static_objects,
685
727
  batch_names=d.get("batchNames"),
728
+ metadata=d.get("metadata"),
686
729
  )
687
730
 
688
731
  @property
@@ -165,10 +165,17 @@ class SimulationScene:
165
165
  bodies: dict[str, SimViewBody] | None = None,
166
166
  static_objects: dict[str, SimViewStaticObject] | None = None,
167
167
  batch_names: list[str] | None = None,
168
+ metadata: dict[str, Any] | None = None,
168
169
  ) -> None:
169
170
  """
170
171
  Initializes the simulation data container.
171
172
  Manages the SimViewModel and the time-series states.
173
+
174
+ `metadata` is free-form, JSON-serializable run provenance (e.g. engine
175
+ name, checkpoint path, git commit, CLI args) with no meaning to the
176
+ viewer -- it's carried through to `simview info` and the browser so a
177
+ saved scene stays self-describing. Can also be set/updated later via
178
+ `self.model.metadata`.
172
179
  """
173
180
  self.model = SimViewModel(
174
181
  batch_size=batch_size,
@@ -179,6 +186,7 @@ class SimulationScene:
179
186
  bodies=bodies if bodies is not None else {},
180
187
  static_objects=static_objects if static_objects is not None else {},
181
188
  batch_names=batch_names,
189
+ metadata=metadata,
182
190
  )
183
191
  self.states: list[dict] = []
184
192
 
@@ -208,6 +216,7 @@ class SimulationScene:
208
216
  bodies=model.bodies,
209
217
  static_objects=model.static_objects,
210
218
  batch_names=model.batch_names,
219
+ metadata=model.metadata,
211
220
  )
212
221
  scene.states = list(states)
213
222
  return scene
@@ -515,18 +524,32 @@ class SimulationScene:
515
524
  def create_terrain(
516
525
  self,
517
526
  heightmap: torch.Tensor,
518
- normals: torch.Tensor,
519
- x_lim: tuple[float, float],
520
- y_lim: tuple[float, float],
527
+ normals: torch.Tensor | None = None,
528
+ x_lim: tuple[float, float] | None = None,
529
+ y_lim: tuple[float, float] | None = None,
530
+ grid_res: float | None = None,
521
531
  friction_map: torch.Tensor | None = None,
522
532
  stiffness_map: torch.Tensor | None = None,
523
533
  ) -> None:
524
- """Adds terrain to the simulation model."""
534
+ """Adds terrain to the simulation model.
535
+
536
+ Args:
537
+ heightmap (torch.Tensor): 2D or 3D tensor of terrain heights.
538
+ normals (torch.Tensor | None): 3D or 4D tensor of terrain normals. If None,
539
+ normals are automatically computed from the heightmap gradients.
540
+ x_lim (tuple[float, float] | None): (min, max) coordinates for the X axis.
541
+ y_lim (tuple[float, float] | None): (min, max) coordinates for the Y axis.
542
+ grid_res (float | None): Grid resolution. If x_lim and y_lim are omitted,
543
+ they will be automatically inferred assuming the grid is centered at 0.
544
+ friction_map (torch.Tensor | None): Optional friction coefficient map.
545
+ stiffness_map (torch.Tensor | None): Optional stiffness coefficient map.
546
+ """
525
547
  self.model.create_terrain(
526
548
  heightmap,
527
- normals,
528
- x_lim,
529
- y_lim,
549
+ normals=normals,
550
+ x_lim=x_lim,
551
+ y_lim=y_lim,
552
+ grid_res=grid_res,
530
553
  friction_map=friction_map,
531
554
  stiffness_map=stiffness_map,
532
555
  )
@@ -12,6 +12,7 @@ import { StaticObject } from "./objects/StaticObject.js";
12
12
  import { Legend } from "./ui/Legend.js";
13
13
  import { BatchLegend } from "./ui/BatchLegend.js";
14
14
  import { ErrorMetrics } from "./ui/ErrorMetrics.js";
15
+ import { TerrainProfile } from "./ui/TerrainProfile.js";
15
16
  import { AnalysisPanel } from "./ui/AnalysisPanel.js";
16
17
  import { InteractionController } from "./components/InteractionController.js";
17
18
  import { buildBodyMeta, resolveStateBodies, topoSortBodies } from "./utils/bodyTransforms.js";
@@ -33,6 +34,7 @@ export class SimView {
33
34
  this.animationController = null;
34
35
  this.scalarPlotter = null;
35
36
  this.errorMetrics = null;
37
+ this.terrainProfile = null;
36
38
  this.analysisPanel = null;
37
39
  this.legend = null;
38
40
  this.batchLegend = null;
@@ -325,6 +327,11 @@ export class SimView {
325
327
  try {
326
328
  this.disposeOfAll();
327
329
 
330
+ // Free-form run provenance carried through from the Python side
331
+ // (see SimViewModel.metadata) -- no meaning to the viewer itself,
332
+ // just displayed read-only in the "Scene Info" GUI folder.
333
+ this.metadata = model.metadata ?? null;
334
+
328
335
  this.batchManager = new BatchManager(this, model);
329
336
  this.bodies = new Map();
330
337
 
@@ -367,7 +374,10 @@ export class SimView {
367
374
  }
368
375
  const hasScalars = model.scalarNames && model.scalarNames.length > 0;
369
376
  const hasErrorMetrics = this.batchManager.simBatches >= 2;
370
- if (hasScalars || hasErrorMetrics) {
377
+ // Terrain tab needs an actual body path to sample against, so it's
378
+ // skipped for a scene with terrain but no bodies.
379
+ const hasTerrainProfile = !!this.terrain && this.bodies && this.bodies.size > 0;
380
+ if (hasScalars || hasErrorMetrics || hasTerrainProfile) {
371
381
  this.analysisPanel = new AnalysisPanel(this);
372
382
  }
373
383
  if (hasScalars) {
@@ -382,6 +392,10 @@ export class SimView {
382
392
  this.errorMetrics = new ErrorMetrics(this);
383
393
  this.analysisPanel.attachErrorMetrics(this.errorMetrics);
384
394
  }
395
+ if (hasTerrainProfile) {
396
+ this.terrainProfile = new TerrainProfile(this);
397
+ this.analysisPanel.attachTerrainProfile(this.terrainProfile);
398
+ }
385
399
  this.interactionController = new InteractionController(this);
386
400
  this.uiControls = new UIControls(this);
387
401
  this.bodyStateWindow = new BodyStateWindow(this);
@@ -509,6 +523,10 @@ export class SimView {
509
523
  this.errorMetrics.dispose();
510
524
  this.errorMetrics = null;
511
525
  }
526
+ if (this.terrainProfile) {
527
+ this.terrainProfile.dispose();
528
+ this.terrainProfile = null;
529
+ }
512
530
  if (this.analysisPanel) {
513
531
  this.analysisPanel.dispose();
514
532
  this.analysisPanel = null;
@@ -548,6 +566,9 @@ export class SimView {
548
566
  if (this.errorMetrics) {
549
567
  this.errorMetrics.animate(now);
550
568
  }
569
+ if (this.terrainProfile) {
570
+ this.terrainProfile.animate(now);
571
+ }
551
572
 
552
573
  // 3. Render the scene
553
574
  if (this.scene) {
@@ -79,6 +79,7 @@ export class BatchManager {
79
79
  this._persistBatchNames();
80
80
  if (this.app.batchLegend) this.app.batchLegend.refresh();
81
81
  if (this.app.errorMetrics) this.app.errorMetrics.refreshBatchLabels();
82
+ if (this.app.terrainProfile) this.app.terrainProfile.refreshBatchLabels();
82
83
  }
83
84
 
84
85
  async _persistBatchNames() {
@@ -26,6 +26,7 @@ export const FREQ_CONFIG = {
26
26
  bodyStateWindow: 30,
27
27
  playbackControls: 20,
28
28
  errorMetrics: 20,
29
+ terrainProfile: 20,
29
30
  };
30
31
 
31
32
  export const CONTROLS_CONFIG = {
@@ -11,6 +11,7 @@ export class AnalysisPanel {
11
11
  this.mode = "scalars";
12
12
  this.scalarPlotter = null;
13
13
  this.errorMetrics = null;
14
+ this.terrainProfile = null;
14
15
  this.modeTabElements = {};
15
16
 
16
17
  this._injectStyles();
@@ -132,15 +133,19 @@ export class AnalysisPanel {
132
133
  this.modeTabBar.className = "analysis-mode-tab-bar";
133
134
  this.modeTabElements.scalars = this._addModeTab("Scalars", "scalars");
134
135
  this.modeTabElements.errorMetrics = this._addModeTab("Error Metrics", "errorMetrics");
136
+ this.modeTabElements.terrain = this._addModeTab("Terrain", "terrain");
135
137
 
136
138
  this.scalarsSection = document.createElement("div");
137
139
  this.scalarsSection.className = "analysis-section";
138
140
  this.errorMetricsSection = document.createElement("div");
139
141
  this.errorMetricsSection.className = "analysis-section";
142
+ this.terrainSection = document.createElement("div");
143
+ this.terrainSection.className = "analysis-section";
140
144
 
141
145
  this.content.appendChild(this.modeTabBar);
142
146
  this.content.appendChild(this.scalarsSection);
143
147
  this.content.appendChild(this.errorMetricsSection);
148
+ this.content.appendChild(this.terrainSection);
144
149
  this.container.appendChild(this.header);
145
150
  this.container.appendChild(this.content);
146
151
  document.body.appendChild(this.container);
@@ -168,20 +173,45 @@ export class AnalysisPanel {
168
173
  this._refreshLayout();
169
174
  }
170
175
 
176
+ attachTerrainProfile(terrainProfile) {
177
+ this.terrainProfile = terrainProfile;
178
+ this.terrainSection.appendChild(terrainProfile.content);
179
+ this._refreshLayout();
180
+ }
181
+
182
+ // Sections currently attached, in tab order -- drives the tab bar
183
+ // (shown whenever >= 2 are attached, with individual tabs for any
184
+ // sections not attached hidden), the title fallback (the lone
185
+ // section's own title when only one is attached), and which mode a
186
+ // stale `this.mode` falls back to if its section gets detached.
187
+ _sections() {
188
+ return [
189
+ { key: "scalars", instance: this.scalarPlotter, title: "Scalars" },
190
+ { key: "errorMetrics", instance: this.errorMetrics, title: "Error Metrics" },
191
+ { key: "terrain", instance: this.terrainProfile, title: "Terrain" },
192
+ ];
193
+ }
194
+
171
195
  _refreshLayout() {
172
- const hasScalars = !!this.scalarPlotter;
173
- const hasErrorMetrics = !!this.errorMetrics;
174
- const showModeBar = hasScalars && hasErrorMetrics;
196
+ const sections = this._sections();
197
+ const attached = sections.filter((s) => !!s.instance);
198
+ const showModeBar = attached.length >= 2;
175
199
  this.modeTabBar.classList.toggle("visible", showModeBar);
176
200
 
177
- if (!hasScalars) this.mode = "errorMetrics";
178
- else if (!hasErrorMetrics) this.mode = "scalars";
201
+ for (const section of sections) {
202
+ const tab = this.modeTabElements[section.key];
203
+ if (tab) tab.style.display = section.instance ? "" : "none";
204
+ }
205
+
206
+ if (!attached.some((s) => s.key === this.mode)) {
207
+ this.mode = attached[0] ? attached[0].key : this.mode;
208
+ }
179
209
 
180
210
  this.titleEl.textContent = showModeBar
181
211
  ? "Analysis"
182
- : hasErrorMetrics
183
- ? "Error Metrics"
184
- : "Scalars";
212
+ : attached[0]
213
+ ? attached[0].title
214
+ : "Analysis";
185
215
 
186
216
  this._applyMode();
187
217
  }
@@ -192,23 +222,14 @@ export class AnalysisPanel {
192
222
  "visible",
193
223
  this.mode === "errorMetrics"
194
224
  );
195
- if (this.modeTabElements.scalars) {
196
- this.modeTabElements.scalars.classList.toggle(
197
- "active",
198
- this.mode === "scalars"
199
- );
200
- }
201
- if (this.modeTabElements.errorMetrics) {
202
- this.modeTabElements.errorMetrics.classList.toggle(
203
- "active",
204
- this.mode === "errorMetrics"
205
- );
206
- }
207
- if (this.scalarPlotter) {
208
- this.scalarPlotter.setVisible(this.isExpanded && this.mode === "scalars");
209
- }
210
- if (this.errorMetrics) {
211
- this.errorMetrics.setVisible(this.isExpanded && this.mode === "errorMetrics");
225
+ this.terrainSection.classList.toggle("visible", this.mode === "terrain");
226
+
227
+ for (const section of this._sections()) {
228
+ const tab = this.modeTabElements[section.key];
229
+ if (tab) tab.classList.toggle("active", this.mode === section.key);
230
+ if (section.instance) {
231
+ section.instance.setVisible(this.isExpanded && this.mode === section.key);
232
+ }
212
233
  }
213
234
  }
214
235