diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
new file mode 100644
index 0000000..014df32
--- /dev/null
+++ b/.github/workflows/publish.yml
@@ -0,0 +1,36 @@
+name: Upload Packages
+
+on:
+ push:
+
+ release:
+ types: [published]
+
+jobs:
+ publish-python:
+ name: Publish Python package
+ runs-on: ubuntu-latest
+ environment: release
+ permissions:
+ # IMPORTANT: this permission is mandatory for trusted publishing
+ id-token: write
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+
+ - name: Setup Rye
+ env:
+ RYE_INSTALL_OPTION: --yes
+ run: |
+ curl -sSf https://rye.astral.sh/get | bash
+ echo "$HOME/.rye/shims" >> $GITHUB_PATH
+
+ - name: Configure Rye
+ run: rye config --set-bool behavior.use-uv=true
+
+ - name: Build package
+ run: rye build
+
+ - name: Publish to PyPI
+ if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags')
+ uses: pypa/gh-action-pypi-publish@release/v1
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
new file mode 100644
index 0000000..a92a9f8
--- /dev/null
+++ b/.github/workflows/tests.yml
@@ -0,0 +1,68 @@
+on:
+ push:
+ branches:
+ - main
+ pull_request:
+ workflow_dispatch:
+
+name: Tests
+
+env:
+ QT_QPA_PLATFORM: offscreen
+ DISPLAY: :99
+
+jobs:
+ pytest:
+ strategy:
+ fail-fast: false
+ matrix:
+ os: [macos-13, ubuntu-latest, windows-latest]
+ pyversion: ['3.9', '3.10', '3.11'] # 3.8 is not supporter by Rye, 3.12 by Sionna
+ runs-on: ${{ matrix.os }}
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+
+ - name: Setup Rye
+ if: matrix.os != 'windows-latest'
+ env:
+ RYE_TOOLCHAIN_VERSION: ${{ matrix.pyversion}}
+ RYE_INSTALL_OPTION: --yes
+ run: |
+ curl -sSf https://rye.astral.sh/get | bash
+ echo "$HOME/.rye/shims" >> $GITHUB_PATH
+
+ # Stolen from https://github.com/bluss/pyproject-local-kernel/blob/2b641290694adc998fb6bceea58d3737523a68b7/.github/workflows/ci.yaml
+ - name: Install Rye (Windows)
+ if: matrix.os == 'windows-latest'
+ shell: bash
+ run: |
+ C:/msys64/usr/bin/wget.exe -q 'https://github.com/astral-sh/rye/releases/latest/download/rye-x86_64-windows.exe' -O rye-x86_64-windows.exe
+ ./rye-x86_64-windows.exe self install --toolchain-version ${{ matrix.pyversion }} --modify-path -y
+ echo "$HOME\\.rye\\shims" >> $GITHUB_PATH
+
+ - name: Configure Rye
+ shell: bash
+ run: |
+ rye config --set-bool behavior.use-uv=true
+ rye pin ${{ matrix.pyversion }}
+
+ - name: Install Linux dependencies
+ if: matrix.os == 'ubuntu-latest'
+ run: |
+ sudo apt-get update
+ sudo apt-get install freeglut3-dev xvfb
+ sudo apt-get install x11-utils
+ nohup Xvfb $DISPLAY -screen 0 1400x900x24 -dpi 96 +extension RANDR +render &
+
+ - name: Install Mesa
+ if: matrix.os == 'windows-latest'
+ uses: ssciwr/setup-mesa-dist-win@v2
+
+ - name: Install local package
+ shell: bash
+ run: rye sync
+
+ - name: Run pytest
+ shell: bash
+ run: rye run pytest
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..ae8554d
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,10 @@
+# python generated files
+__pycache__/
+*.py[oc]
+build/
+dist/
+wheels/
+*.egg-info
+
+# venv
+.venv
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
new file mode 100644
index 0000000..e6fd123
--- /dev/null
+++ b/.pre-commit-config.yaml
@@ -0,0 +1,34 @@
+repos:
+- repo: https://github.com/pre-commit/pre-commit-hooks
+ rev: v4.6.0
+ hooks:
+ - id: check-yaml
+ - id: check-toml
+ - id: end-of-file-fixer
+ - id: trailing-whitespace
+- repo: https://github.com/macisamuele/language-formatters-pre-commit-hooks
+ rev: v2.13.0
+ hooks:
+ - id: pretty-format-yaml
+ args: [--autofix]
+ - id: pretty-format-toml
+ exclude: poetry.lock
+ args: [--autofix, --trailing-commas]
+- repo: https://github.com/astral-sh/ruff-pre-commit
+ rev: v0.4.8
+ hooks:
+ - id: ruff
+ args: [--fix]
+ types_or: [python, pyi, jupyter]
+ - id: ruff-format
+ types_or: [python, pyi, jupyter]
+- repo: https://github.com/RobertCraigie/pyright-python
+ rev: v1.1.367
+ hooks:
+ - id: pyright
+- repo: https://github.com/codespell-project/codespell
+ rev: v2.3.0
+ hooks:
+ - id: codespell
+ additional_dependencies:
+ - tomli
diff --git a/.python-version b/.python-version
new file mode 100644
index 0000000..b6d8b76
--- /dev/null
+++ b/.python-version
@@ -0,0 +1 @@
+3.11.8
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..9d533c7
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,27 @@
+# Changelog
+
+All notable changes to this project will be documented in this file.
+
+The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
+and follows the Sionna versioning:
+`(sionna_major, sionna_minor, sionna_patch, fix)`.
+
+The three first parts indicate the official supported version of
+Sionna, and `fix` is used to indicate patch version
+for our library.
+
+
+
+(unreleased)=
+## [Unreleased](https://github.com/jeertmans/sionna-vispy/compare/v0.18.0...HEAD)
+
+(v0.18.0)=
+## [v0.18.0](https://github.com/jeertmans/sionna-vispy/commits/v0.18.0)
+
+(v0.18.0-added)=
+### Added
+
+- Created first package.
+ [#1](https://github.com/jeertmans/sionna-vispy/pull/1)
+
+
diff --git a/README.md b/README.md
index 08cf619..3adde89 100644
--- a/README.md
+++ b/README.md
@@ -1,3 +1,129 @@
# sionna-vispy
-Describe your project here.
\ No newline at end of file
+A [VisPy](https://github.com/vispy/vispy)
+backend to preview
+[Sionna](https://github.com/NVlabs/sionna) scenes
+that works both inside and **outside** Jupyter Notebook.
+
+
+
+
+
+This library consists of two parts:
+
+1. a VisPy-based `InteractiveDisplay` to replace `sionna.rt.previewer`;
+2. and a `patch()` context manager that dynamically replaces
+ the old pythreejs previewer with the new VisPy previewer.
+
+## Installation
+
+For the best out-of-the-box experience, we recommend
+installing with Pip with `recommended` extras:
+
+```bash
+pip install sionna-vispy[recommended]
+```
+
+This will install this package, as well as PySide6 and jupyter-rfb,
+so that `scene.preview(...)` works inside and **outside** Jupyter Notebooks.
+
+Alternatively, you can install sionna-vispy with:
+
+```bash
+pip install sionna-vispy
+```
+
+And later install your preferred
+[VisPy backend(s)](https://vispy.org/installation.html).
+
+## Usage
+
+The VisPy scene previewer works both
+inside and **outside** Jupyter Notebooks.
+
+First, you need to import the package (import order does not matter):
+
+```python
+import sionna_vispy
+```
+
+Next, the usage of `patch` depends on the environment,
+see the subsections.
+
+> [!NOTE]
+> If `with patch():` is called before any
+> call to `scene.preview(...)`,
+> then you only need to call
+> `patch()` once.
+
+### Inside Notebooks[^1]
+
+Very simply, rewrite any
+
+```python
+scene.preview(...)
+```
+
+with the following:
+
+```python
+with sionna_vispy.patch():
+ canvas = scene.preview()
+
+canvas
+```
+
+> [!WARNING]
+> `canvas` must be the return variable
+> of the cell, because the `with` context
+> does not return an instance of
+> `InteractiveDisplay`.
+
+[^1]: Note that you need `jupyter_rfb` to work inside Jupyter Notebooks.
+
+### Outside Notebooks
+
+Canvas need to be *shown* and the VisPy application
+must be started to open a window:
+
+```python
+with sionna_vispy.patch():
+ canvas = scene.preview(...)
+
+canvas.show()
+canvas.app.run()
+```
+
+## How it works
+
+This package replaces the pythreejs previewer with some
+VisPy implementation by
+[*monkey-patching*](https://docs.python.org/3/library/unittest.mock.html#unittest.mock.patch)
+`sionna.rt.scene.InteractiveDisplay`.
+
+Additionally, `patch()` will (by default) look at
+any existing `sionna.rt.scene.Scene` class instance in the local
+namespace of the callee, and temporarily replace any
+existing preview widget to make sure to use the new previewer. You can
+opt-out of this by calling `patch(patch_existing=False)` instead.
+
+## Design goals
+
+This package aims to be a very minimal replacement to the pythreejs
+previewer, with maximum compatibility.
+
+As a result, **it does not aim to provide any additional feature**.
+
+Instead, it aims at providing a very similar look to that of
+pythreejs, with all the nice features that come with VisPy.
+
+## Contributing
+
+This project welcomes any contribution, and especially:
+
++ bug fixes;
++ graphical improvements to closely match the original pythreejs previewers;
++ or documentation typos.
+
+As stated above, new features are not expected to be added, unless they are also
+added to the original pythreejs previewer.
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..8224df8
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,119 @@
+[build-system]
+build-backend = "hatchling.build"
+requires = ["hatchling", "hatch-fancy-pypi-readme"]
+
+[project]
+authors = [
+ {name = "Jérome Eertmans", email = "jeertmans@icloud.com"},
+]
+dependencies = [
+ "sionna>=0.18.0",
+ "vispy>=0.14.2",
+]
+description = "VisPy scene previewer for Sionna"
+name = "sionna-vispy"
+readme = "README.md"
+requires-python = ">= 3.8"
+version = "0.1.0"
+
+[project.optional-dependencies]
+recommended = [
+ "jupyter-rfb>=0.4.4",
+ "pyside6>=6.0.0",
+]
+
+[tool.bumpversion]
+allow_dirty = false
+commit = true
+commit_args = ""
+current_version = "0.18.0"
+ignore_missing_version = false
+message = "chore(deps): bump version from {current_version} to {new_version}"
+parse = '(?P\d+)\.(?P\d+)\.(?P\d+)(\.(?P\d+))?'
+regex = false
+replace = "{new_version}"
+search = "{current_version}"
+serialize = ["{major}.{minor}.{patch}.{fix}", "{major}.{minor}.{patch}"]
+sign_tags = false
+tag = false
+tag_message = "chore(version): {current_version} → {new_version}"
+tag_name = "v{new_version}"
+
+[[tool.bumpversion.files]]
+filename = "src/sionna_vispy/__version__.py"
+replace = '__version__ = "{new_version}"'
+search = '__version__ = "{current_version}"'
+
+[[tool.bumpversion.files]]
+filename = "CHANGELOG.md"
+replace = "v{new_version}"
+search = "Unreleased"
+
+[[tool.bumpversion.files]]
+filename = "CHANGELOG.md"
+replace = "v{new_version}"
+search = "unreleased"
+
+[[tool.bumpversion.files]]
+filename = "CHANGELOG.md"
+replace = "v{current_version}...v{new_version}"
+search = "v{current_version}...HEAD"
+
+[[tool.bumpversion.files]]
+filename = "CHANGELOG.md"
+replace = '''
+
+(unreleased)=
+## [Unreleased](https://github.com/jeertmans/sionna-vispy/compare/{new_version}...HEAD)'''
+search = ""
+
+[tool.hatch.build.targets.wheel]
+packages = ["src/sionna_vispy"]
+
+[tool.hatch.metadata]
+allow-direct-references = true
+
+[tool.hatch.metadata.hooks.fancy-pypi-readme]
+content-type = "text/markdown"
+
+[[tool.hatch.metadata.hooks.fancy-pypi-readme.fragments]]
+text = """
+
+
+
+
+"""
+
+[[tool.hatch.metadata.hooks.fancy-pypi-readme.fragments]]
+path = "README.md"
+start-after = ""
+
+[[tool.hatch.metadata.hooks.fancy-pypi-readme.substitutions]]
+pattern = '> \[!([A-Z]+)\]'
+replacement = '> **\1:**'
+
+[tool.hatch.version]
+path = "src/sionna_vispy/__version__.py"
+
+[tool.pyright]
+include = ["src/sionna_vispy"]
+venv = ".venv"
+venvPath = "."
+
+[tool.ruff.format]
+docstring-code-format = true
+
+[tool.ruff.lint]
+extend-select = ["B", "C90", "I", "N", "RUF", "UP", "T"]
+isort = {known-first-party = ["sionna_vispy", "tests"]}
+
+[tool.rye]
+dev-dependencies = [
+ # dev
+ "bump-my-version>=0.23.0",
+ "pre-commit>=3.7.1",
+ # tests
+ "pytest>=8.2.2",
+ "pyside6>=6.0.0",
+]
+managed = true
diff --git a/requirements-dev.lock b/requirements-dev.lock
new file mode 100644
index 0000000..d6a01e1
--- /dev/null
+++ b/requirements-dev.lock
@@ -0,0 +1,276 @@
+# generated by rye
+# use `rye lock` or `rye sync` to update this lockfile
+#
+# last locked with the following flags:
+# pre: false
+# features: []
+# all-features: true
+# with-sources: false
+
+-e file:.
+absl-py==2.1.0
+ # via tensorboard
+ # via tensorflow
+annotated-types==0.7.0
+ # via pydantic
+asttokens==2.4.1
+ # via stack-data
+astunparse==1.6.3
+ # via tensorflow
+bracex==2.4
+ # via wcmatch
+bump-my-version==0.23.0
+cachetools==5.3.3
+ # via google-auth
+certifi==2024.6.2
+ # via requests
+cfgv==3.4.0
+ # via pre-commit
+charset-normalizer==3.3.2
+ # via requests
+click==8.1.7
+ # via bump-my-version
+ # via rich-click
+contourpy==1.2.1
+ # via matplotlib
+cycler==0.12.1
+ # via matplotlib
+decorator==5.1.1
+ # via ipython
+distlib==0.3.8
+ # via virtualenv
+drjit==0.4.6
+ # via mitsuba
+executing==2.0.1
+ # via stack-data
+filelock==3.15.1
+ # via virtualenv
+flatbuffers==24.3.25
+ # via tensorflow
+fonttools==4.53.0
+ # via matplotlib
+freetype-py==2.4.0
+ # via vispy
+gast==0.5.4
+ # via tensorflow
+google-auth==2.30.0
+ # via google-auth-oauthlib
+ # via tensorboard
+google-auth-oauthlib==1.2.0
+ # via tensorboard
+google-pasta==0.2.0
+ # via tensorflow
+grpcio==1.64.1
+ # via tensorboard
+ # via tensorflow
+h5py==3.11.0
+ # via tensorflow
+hsluv==5.0.4
+ # via vispy
+identify==2.5.36
+ # via pre-commit
+idna==3.7
+ # via requests
+importlib-resources==6.4.0
+ # via sionna
+iniconfig==2.0.0
+ # via pytest
+ipydatawidgets==4.3.2
+ # via pythreejs
+ # via sionna
+ipython==8.18.0
+ # via ipywidgets
+ipywidgets==8.0.5
+ # via ipydatawidgets
+ # via jupyter-rfb
+ # via pythreejs
+ # via sionna
+jedi==0.19.1
+ # via ipython
+jupyter-rfb==0.4.4
+ # via sionna-vispy
+jupyterlab-widgets==3.0.5
+ # via ipywidgets
+ # via jupyter-rfb
+ # via sionna
+keras==2.15.0
+ # via tensorflow
+kiwisolver==1.4.5
+ # via matplotlib
+ # via vispy
+libclang==18.1.1
+ # via tensorflow
+markdown==3.6
+ # via tensorboard
+markdown-it-py==3.0.0
+ # via rich
+markupsafe==2.1.5
+ # via werkzeug
+matplotlib==3.9.0
+ # via sionna
+matplotlib-inline==0.1.7
+ # via ipython
+mdurl==0.1.2
+ # via markdown-it-py
+mitsuba==3.5.2
+ # via sionna
+ml-dtypes==0.3.2
+ # via tensorflow
+nodeenv==1.9.1
+ # via pre-commit
+numpy==1.26.4
+ # via contourpy
+ # via h5py
+ # via ipydatawidgets
+ # via jupyter-rfb
+ # via matplotlib
+ # via ml-dtypes
+ # via opt-einsum
+ # via pythreejs
+ # via scipy
+ # via sionna
+ # via tensorboard
+ # via tensorflow
+ # via vispy
+oauthlib==3.2.2
+ # via requests-oauthlib
+opt-einsum==3.3.0
+ # via tensorflow
+packaging==24.1
+ # via matplotlib
+ # via pytest
+ # via tensorflow
+ # via vispy
+parso==0.8.4
+ # via jedi
+pexpect==4.9.0
+ # via ipython
+pillow==10.3.0
+ # via matplotlib
+platformdirs==4.2.2
+ # via virtualenv
+pluggy==1.5.0
+ # via pytest
+pre-commit==3.7.1
+prompt-toolkit==3.0.36
+ # via ipython
+ # via questionary
+protobuf==4.25.3
+ # via tensorboard
+ # via tensorflow
+ptyprocess==0.7.0
+ # via pexpect
+pure-eval==0.2.2
+ # via stack-data
+pyasn1==0.6.0
+ # via pyasn1-modules
+ # via rsa
+pyasn1-modules==0.4.0
+ # via google-auth
+pydantic==2.7.4
+ # via bump-my-version
+ # via pydantic-settings
+pydantic-core==2.18.4
+ # via pydantic
+pydantic-settings==2.3.3
+ # via bump-my-version
+pygments==2.18.0
+ # via ipython
+ # via rich
+pyparsing==3.1.2
+ # via matplotlib
+pyside6==6.7.1
+ # via sionna-vispy
+pyside6-addons==6.7.1
+ # via pyside6
+pyside6-essentials==6.7.1
+ # via pyside6
+ # via pyside6-addons
+pytest==8.2.2
+python-dateutil==2.9.0.post0
+ # via matplotlib
+python-dotenv==1.0.1
+ # via pydantic-settings
+pythreejs==2.4.2
+ # via sionna
+pyyaml==6.0.1
+ # via pre-commit
+questionary==2.0.1
+ # via bump-my-version
+requests==2.32.3
+ # via requests-oauthlib
+ # via tensorboard
+requests-oauthlib==2.0.0
+ # via google-auth-oauthlib
+rich==13.7.1
+ # via bump-my-version
+ # via rich-click
+rich-click==1.8.3
+ # via bump-my-version
+rsa==4.9
+ # via google-auth
+scipy==1.13.1
+ # via sionna
+setuptools==70.0.0
+ # via tensorboard
+ # via tensorflow
+shiboken6==6.7.1
+ # via pyside6
+ # via pyside6-addons
+ # via pyside6-essentials
+sionna==0.18.0
+ # via sionna-vispy
+six==1.16.0
+ # via asttokens
+ # via astunparse
+ # via google-pasta
+ # via python-dateutil
+ # via tensorboard
+ # via tensorflow
+stack-data==0.6.3
+ # via ipython
+tensorboard==2.15.2
+ # via tensorflow
+tensorboard-data-server==0.7.2
+ # via tensorboard
+tensorflow==2.15.1
+ # via sionna
+tensorflow-estimator==2.15.0
+ # via tensorflow
+tensorflow-io-gcs-filesystem==0.37.0
+ # via tensorflow
+termcolor==2.4.0
+ # via tensorflow
+tomlkit==0.12.5
+ # via bump-my-version
+traitlets==5.14.3
+ # via ipython
+ # via ipywidgets
+ # via matplotlib-inline
+ # via pythreejs
+ # via traittypes
+traittypes==0.2.1
+ # via ipydatawidgets
+typing-extensions==4.12.2
+ # via pydantic
+ # via pydantic-core
+ # via rich-click
+ # via tensorflow
+urllib3==2.2.1
+ # via requests
+virtualenv==20.26.2
+ # via pre-commit
+vispy==0.14.2
+ # via sionna-vispy
+wcmatch==8.5.2
+ # via bump-my-version
+wcwidth==0.2.13
+ # via prompt-toolkit
+werkzeug==3.0.3
+ # via tensorboard
+wheel==0.43.0
+ # via astunparse
+widgetsnbextension==4.0.11
+ # via ipywidgets
+wrapt==1.14.1
+ # via tensorflow
diff --git a/requirements.lock b/requirements.lock
new file mode 100644
index 0000000..4c286c9
--- /dev/null
+++ b/requirements.lock
@@ -0,0 +1,217 @@
+# generated by rye
+# use `rye lock` or `rye sync` to update this lockfile
+#
+# last locked with the following flags:
+# pre: false
+# features: []
+# all-features: true
+# with-sources: false
+
+-e file:.
+absl-py==2.1.0
+ # via tensorboard
+ # via tensorflow
+asttokens==2.4.1
+ # via stack-data
+astunparse==1.6.3
+ # via tensorflow
+cachetools==5.3.3
+ # via google-auth
+certifi==2024.6.2
+ # via requests
+charset-normalizer==3.3.2
+ # via requests
+contourpy==1.2.1
+ # via matplotlib
+cycler==0.12.1
+ # via matplotlib
+decorator==5.1.1
+ # via ipython
+drjit==0.4.6
+ # via mitsuba
+executing==2.0.1
+ # via stack-data
+flatbuffers==24.3.25
+ # via tensorflow
+fonttools==4.53.0
+ # via matplotlib
+freetype-py==2.4.0
+ # via vispy
+gast==0.5.4
+ # via tensorflow
+google-auth==2.30.0
+ # via google-auth-oauthlib
+ # via tensorboard
+google-auth-oauthlib==1.2.0
+ # via tensorboard
+google-pasta==0.2.0
+ # via tensorflow
+grpcio==1.64.1
+ # via tensorboard
+ # via tensorflow
+h5py==3.11.0
+ # via tensorflow
+hsluv==5.0.4
+ # via vispy
+idna==3.7
+ # via requests
+importlib-resources==6.4.0
+ # via sionna
+ipydatawidgets==4.3.2
+ # via pythreejs
+ # via sionna
+ipython==8.25.0
+ # via ipywidgets
+ipywidgets==8.0.5
+ # via ipydatawidgets
+ # via jupyter-rfb
+ # via pythreejs
+ # via sionna
+jedi==0.19.1
+ # via ipython
+jupyter-rfb==0.4.4
+ # via sionna-vispy
+jupyterlab-widgets==3.0.5
+ # via ipywidgets
+ # via jupyter-rfb
+ # via sionna
+keras==2.15.0
+ # via tensorflow
+kiwisolver==1.4.5
+ # via matplotlib
+ # via vispy
+libclang==18.1.1
+ # via tensorflow
+markdown==3.6
+ # via tensorboard
+markupsafe==2.1.5
+ # via werkzeug
+matplotlib==3.9.0
+ # via sionna
+matplotlib-inline==0.1.7
+ # via ipython
+mitsuba==3.5.2
+ # via sionna
+ml-dtypes==0.3.2
+ # via tensorflow
+numpy==1.26.4
+ # via contourpy
+ # via h5py
+ # via ipydatawidgets
+ # via jupyter-rfb
+ # via matplotlib
+ # via ml-dtypes
+ # via opt-einsum
+ # via pythreejs
+ # via scipy
+ # via sionna
+ # via tensorboard
+ # via tensorflow
+ # via vispy
+oauthlib==3.2.2
+ # via requests-oauthlib
+opt-einsum==3.3.0
+ # via tensorflow
+packaging==24.1
+ # via matplotlib
+ # via tensorflow
+ # via vispy
+parso==0.8.4
+ # via jedi
+pexpect==4.9.0
+ # via ipython
+pillow==10.3.0
+ # via matplotlib
+prompt-toolkit==3.0.47
+ # via ipython
+protobuf==4.25.3
+ # via tensorboard
+ # via tensorflow
+ptyprocess==0.7.0
+ # via pexpect
+pure-eval==0.2.2
+ # via stack-data
+pyasn1==0.6.0
+ # via pyasn1-modules
+ # via rsa
+pyasn1-modules==0.4.0
+ # via google-auth
+pygments==2.18.0
+ # via ipython
+pyparsing==3.1.2
+ # via matplotlib
+pyside6==6.7.1
+ # via sionna-vispy
+pyside6-addons==6.7.1
+ # via pyside6
+pyside6-essentials==6.7.1
+ # via pyside6
+ # via pyside6-addons
+python-dateutil==2.9.0.post0
+ # via matplotlib
+pythreejs==2.4.2
+ # via sionna
+requests==2.32.3
+ # via requests-oauthlib
+ # via tensorboard
+requests-oauthlib==2.0.0
+ # via google-auth-oauthlib
+rsa==4.9
+ # via google-auth
+scipy==1.13.1
+ # via sionna
+setuptools==70.0.0
+ # via tensorboard
+ # via tensorflow
+shiboken6==6.7.1
+ # via pyside6
+ # via pyside6-addons
+ # via pyside6-essentials
+sionna==0.18.0
+ # via sionna-vispy
+six==1.16.0
+ # via asttokens
+ # via astunparse
+ # via google-pasta
+ # via python-dateutil
+ # via tensorboard
+ # via tensorflow
+stack-data==0.6.3
+ # via ipython
+tensorboard==2.15.2
+ # via tensorflow
+tensorboard-data-server==0.7.2
+ # via tensorboard
+tensorflow==2.15.1
+ # via sionna
+tensorflow-estimator==2.15.0
+ # via tensorflow
+tensorflow-io-gcs-filesystem==0.37.0
+ # via tensorflow
+termcolor==2.4.0
+ # via tensorflow
+traitlets==5.14.3
+ # via ipython
+ # via ipywidgets
+ # via matplotlib-inline
+ # via pythreejs
+ # via traittypes
+traittypes==0.2.1
+ # via ipydatawidgets
+typing-extensions==4.12.2
+ # via ipython
+ # via tensorflow
+urllib3==2.2.1
+ # via requests
+vispy==0.14.2
+ # via sionna-vispy
+wcwidth==0.2.13
+ # via prompt-toolkit
+werkzeug==3.0.3
+ # via tensorboard
+wheel==0.43.0
+ # via astunparse
+widgetsnbextension==4.0.11
+ # via ipywidgets
+wrapt==1.14.1
+ # via tensorflow
diff --git a/src/sionna_vispy/__init__.py b/src/sionna_vispy/__init__.py
new file mode 100644
index 0000000..6e37573
--- /dev/null
+++ b/src/sionna_vispy/__init__.py
@@ -0,0 +1,48 @@
+import inspect
+import unittest.mock
+from collections.abc import Iterator
+from contextlib import contextmanager
+
+from sionna.rt.scene import Scene
+
+from .previewer import InteractiveDisplay
+
+
+@contextmanager
+def patch(*, patch_existing: bool = True) -> Iterator[type[InteractiveDisplay]]:
+ """
+ Monkey patch Sionna's scene previewer to use VisPy instead.
+
+ Input
+ -----
+ patch_existing : bool
+ Read the local variables from the callee and patch
+ any existing scene to reset the preview widget.
+ When leaving the context, the previous preview widgets will be put back,
+ if any were present (otherwise, the new ones are kept).
+ Defaults to `True`.
+ """
+ callee_locals = {}
+
+ if patch_existing:
+ callee_locals = inspect.stack()[2][0].f_locals
+
+ scenes = [
+ obj
+ for obj in callee_locals.values()
+ if isinstance(obj, Scene) and obj._preview_widget is not None
+ ]
+ previewers = [scene._preview_widget for scene in scenes]
+
+ try:
+ for scene in scenes:
+ scene._preview_widget = None
+
+ with unittest.mock.patch(
+ "sionna.rt.scene.InteractiveDisplay", new=InteractiveDisplay
+ ) as cls:
+ yield cls
+
+ finally:
+ for scene, previewer in zip(scenes, previewers):
+ scene._preview_widget = previewer
diff --git a/src/sionna_vispy/__version__.py b/src/sionna_vispy/__version__.py
new file mode 100644
index 0000000..1317d75
--- /dev/null
+++ b/src/sionna_vispy/__version__.py
@@ -0,0 +1 @@
+__version__ = "0.18.0"
diff --git a/src/sionna_vispy/previewer.py b/src/sionna_vispy/previewer.py
new file mode 100644
index 0000000..f8a7d0b
--- /dev/null
+++ b/src/sionna_vispy/previewer.py
@@ -0,0 +1,525 @@
+#
+# SPDX-FileCopyrightText: Copyright (c) 2021-2024 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+# SPDX-License-Identifier: Apache-2.0
+#
+"""
+3D scene and paths viewer using VisPy.
+
+This file is a modified version of :mod:`sionna.rt.previewer` to use
+VisPy as a viewer rather than pythreejs.
+"""
+
+from __future__ import annotations
+
+import drjit as dr
+import matplotlib
+import mitsuba as mi
+import numpy as np
+from sionna.rt.renderer import coverage_map_color_mapping
+from sionna.rt.utils import (
+ mitsuba_rectangle_to_world,
+ paths_to_segments,
+ rotate,
+ scene_scale,
+)
+from vispy.geometry.generation import create_cylinder
+from vispy.scene import SceneCanvas
+from vispy.scene.cameras.turntable import TurntableCamera
+from vispy.scene.visuals import Image, LinePlot, Markers, Mesh
+from vispy.visuals.filters.clipping_planes import PlanesClipper
+from vispy.visuals.transforms import MatrixTransform, STTransform
+
+
+class InteractiveDisplay(SceneCanvas):
+ """
+ Lightweight wrapper around the `vispy` library.
+
+ Input
+ -----
+ resolution : [2], int
+ Size of the viewer figure.
+
+ fov : float
+ Field of view, in degrees.
+
+ background : str
+ Background color in hex format prefixed by '#'.
+ """
+
+ def __init__(self, scene, resolution, fov, background) -> None:
+ super().__init__(keys="interactive", size=resolution, bgcolor=background)
+
+ self.unfreeze() # allow creating attributes
+
+ self._resolution = resolution
+ self._sionna_scene = scene # self._scene is already defined by VisPy
+
+ # List of objects in the scene
+ self._objects = []
+ # Bounding box of the scene
+ self._bbox = mi.ScalarBoundingBox3f() # type: ignore[reportAttributeAccessIssue]
+
+ ####################################################
+ # Setup the viewer
+ ####################################################
+
+ # View
+ self._view = self.central_widget.add_view()
+ self._view.camera = TurntableCamera(fov=fov)
+ self._camera = self._view.camera
+ self._camera.depth_value = 1e6
+ self._clipper = PlanesClipper()
+ self._view.attach(self._clipper)
+
+ self.freeze()
+
+ ####################################################
+ # Plot the scene geometry
+ ####################################################
+ self.plot_scene()
+
+ # Finally, ensure the camera is looking at the scene
+ self.center_view()
+
+ def reset(self):
+ """
+ Removes objects that are not flagged as persistent, i.e., the paths.
+ """
+ remaining = []
+ for obj, persist in self._objects:
+ if persist:
+ remaining.append((obj, persist))
+ else:
+ obj.parent = None
+ self._objects = remaining
+
+ def redraw_scene_geometry(self):
+ """
+ Redraw the scene geometry.
+ """
+ remaining = []
+ for obj, persist in self._objects:
+ if not persist: # Only scene objects are flagged as persistent
+ remaining.append((obj, persist))
+ else:
+ obj.parent = None
+ self._objects = remaining
+
+ # Plot the scene geometry
+ self.plot_scene()
+
+ def center_view(self):
+ """
+ Automatically place the camera and observable range.
+ """
+ self._camera.set_range()
+
+ def plot_radio_devices(self, show_orientations=False):
+ """
+ Plots the radio devices.
+
+ Input
+ -----
+ show_orientations : bool
+ Shows the radio devices' orientations.
+ Defaults to `False`.
+ """
+ scene = self._sionna_scene
+ sc, tx_positions, rx_positions, _, _ = scene_scale(scene)
+ transmitter_colors = [
+ transmitter.color.numpy() for transmitter in scene.transmitters.values()
+ ]
+ receiver_colors = [
+ receiver.color.numpy() for receiver in scene.receivers.values()
+ ]
+
+ # Radio emitters, shown as points
+ p = np.array(list(tx_positions.values()) + list(rx_positions.values()))
+ albedo = np.array(transmitter_colors + receiver_colors)
+
+ if p.shape[0] > 0:
+ # Radio devices are not persistent
+ radius = max(0.005 * sc, 1)
+ self._plot_points(p, persist=False, colors=albedo, radius=radius)
+ if show_orientations:
+ line_length = 0.0075 * sc
+ head_length = 0.15 * line_length
+ zeros = np.zeros((1, 3))
+
+ for devices in [
+ scene.transmitters.values(),
+ scene.receivers.values(),
+ scene.ris.values(),
+ ]:
+ if len(devices) == 0:
+ continue
+ starts, ends = [], []
+ color = None
+ for rd in devices:
+ # Arrow line
+ color = rd.color
+ starts.append(rd.position)
+ endpoint = rd.position + rotate(
+ [line_length, 0.0, 0.0], rd.orientation
+ )
+ ends.append(endpoint)
+
+ meshdata = create_cylinder(
+ rows=80,
+ cols=80,
+ radius=(0.3 * head_length, 0),
+ length=head_length,
+ )
+ angles = rd.orientation.numpy()
+ mesh = Mesh(color=color, meshdata=meshdata)
+ mesh.transform = MatrixTransform()
+ mesh.transform.rotate(np.rad2deg(angles[2]), (1, 0, 0))
+ mesh.transform.rotate(np.rad2deg(angles[1] + np.pi / 2), (0, 1, 0))
+ mesh.transform.rotate(np.rad2deg(angles[0]), (0, 0, 1))
+ mesh.transform.translate(np.append(endpoint.numpy(), 1))
+
+ self._add_child(mesh, zeros, zeros, persist=False)
+
+ self._plot_lines(
+ np.array(starts),
+ np.array(ends),
+ width=2,
+ color=color, # type: ignore[reportArgumentType]
+ )
+
+ def plot_paths(self, paths):
+ """
+ Plot the ``paths``.
+
+ Input
+ -----
+ paths : :class:`~sionna.rt.Paths`
+ Paths to plot
+ """
+ starts, ends = paths_to_segments(paths)
+ if starts and ends:
+ self._plot_lines(np.vstack(starts), np.vstack(ends))
+
+ def plot_scene(self):
+ """
+ Plots the meshes that make the scene.
+ """
+ shapes = self._sionna_scene.mi_scene.shapes()
+ n = len(shapes)
+ if n <= 0:
+ return
+
+ palette = None
+ si = dr.zeros(mi.SurfaceInteraction3f)
+ si.wi = mi.Vector3f(0, 0, 1)
+
+ # Shapes (e.g. buildings)
+ vertices, faces, albedos = [], [], []
+ f_offset = 0
+ for i, s in enumerate(shapes):
+ null_transmission = s.bsdf().eval_null_transmission(si).numpy()
+ if np.min(null_transmission) > 0.99:
+ # The BSDF for this shape was probably set to `null`, do not
+ # include it in the scene preview.
+ continue
+
+ n_vertices = s.vertex_count()
+ v = s.vertex_position(dr.arange(mi.UInt32, n_vertices))
+ vertices.append(v.numpy())
+ f = s.face_indices(dr.arange(mi.UInt32, s.face_count()))
+ faces.append(f.numpy() + f_offset)
+ f_offset += n_vertices
+
+ albedo = s.bsdf().eval_diffuse_reflectance(si).numpy()
+ if not np.any(albedo > 0.0):
+ if palette is None:
+ palette = matplotlib.colormaps.get_cmap("Pastel1_r")
+ albedo[:] = palette((i % palette.N + 0.5) / palette.N)[:3]
+
+ albedos.append(np.tile(albedo, (n_vertices, 1)))
+
+ # Plot all objects as a single PyThreeJS mesh, which is must faster
+ # than creating individual mesh objects in large scenes.
+ self._plot_mesh(
+ np.concatenate(vertices, axis=0),
+ np.concatenate(faces, axis=0),
+ persist=True, # The scene geometry is persistent
+ colors=np.concatenate(albedos, axis=0),
+ )
+
+ def plot_coverage_map(
+ self, coverage_map, tx=0, db_scale=True, vmin=None, vmax=None
+ ):
+ """
+ Plot the coverage map as a textured rectangle in the scene. Regions
+ where the coverage map is zero-valued are made transparent.
+ """
+ to_world = coverage_map.to_world()
+ # coverage_map = resample_to_corners(
+ # coverage_map[tx, :, :].numpy()
+ # )
+ coverage_map = coverage_map[tx, :, :].numpy()
+
+ # Create a rectangle from two triangles
+ p00 = to_world.transform_affine([-1, -1, 0])
+ p01 = to_world.transform_affine([1, -1, 0])
+ p10 = to_world.transform_affine([-1, 1, 0])
+ p11 = to_world.transform_affine([1, 1, 0])
+
+ vertices = np.array([p00, p01, p10, p11])
+ pmin = np.min(vertices, axis=0)
+ pmax = np.max(vertices, axis=0)
+
+ to_map, normalizer, color_map = coverage_map_color_mapping(
+ coverage_map, db_scale=db_scale, vmin=vmin, vmax=vmax
+ )
+ texture = color_map(normalizer(to_map)).astype(np.float32)
+ texture[:, :, 3] = (coverage_map > 0.0).astype(np.float32)
+ # Pre-multiply alpha
+ texture[:, :, :3] *= texture[:, :, 3, None]
+
+ n, m = texture.shape[:2]
+
+ xscale = abs(pmax[0] - pmin[0]) / m
+ yscale = abs(pmax[1] - pmin[1]) / n
+ xshift = pmin[0]
+ yshift = pmin[1]
+ zshift = (pmin[2] + pmax[2]) / 2
+
+ transform = STTransform(
+ scale=(xscale, yscale),
+ translate=(xshift, yshift, zshift),
+ )
+
+ image = Image(data=(255.0 * texture).astype(np.uint8))
+ image.transform = transform
+
+ self._add_child(image, pmin, pmax, persist=False)
+
+ def plot_ris(self):
+ """
+ Plot all RIS as a monochromatic rectangle in the scene
+ """
+ all_ris = list(self._sionna_scene.ris.values())
+
+ for ris in all_ris:
+ orientation = ris.orientation
+ to_world = mitsuba_rectangle_to_world(
+ ris.position, orientation, ris.size, ris=True
+ )
+ color = ris.color.numpy()
+
+ # Create a rectangle from two triangles
+ p00 = to_world.transform_affine([-1, -1, 0])
+ p01 = to_world.transform_affine([1, -1, 0])
+ p10 = to_world.transform_affine([-1, 1, 0])
+ p11 = to_world.transform_affine([1, 1, 0])
+
+ vertices = np.array([p00, p01, p10, p11])
+ pmin = np.min(vertices, axis=0)
+ pmax = np.max(vertices, axis=0)
+
+ faces = np.array(
+ [
+ [0, 1, 2],
+ [2, 1, 3],
+ ],
+ dtype=np.uint32,
+ )
+
+ mesh = Mesh(vertices=vertices, faces=faces, color=color)
+
+ self._add_child(mesh, pmin, pmax, persist=False)
+
+ def set_clipping_plane(self, offset, orientation):
+ """
+ Input
+ -----
+ clip_at : float
+ If not `None`, the scene preview will be clipped (cut) by a plane
+ with normal orientation ``clip_plane_orientation`` and offset
+ ``clip_at``. This allows visualizing the interior of meshes such
+ as buildings.
+
+ clip_plane_orientation : tuple[float, float, float]
+ Normal vector of the clipping plane.
+ """
+ # TODO: test me
+ if offset is None:
+ self._clipper.clipping_planes = None
+ else:
+ bbox = self._bbox if self._bbox.valid() else mi.ScalarBoundingBox3f(0.0) # type: ignore[reportAttributeAccessIssue]
+ center = bbox.center()
+ self._clipper.clipping_planes = np.array(
+ [[offset * orientation + center, orientation]]
+ )
+
+ @property
+ def camera(self):
+ """
+ vispy.scene.cameras.perspective.PerspectiveCamera : Get the camera
+ """
+ return self._camera
+
+ @property
+ def orbit(self):
+ """
+ None : Get the orbit
+ """
+ raise AttributeError("VisPy has not orbit controls like PyThreeJS")
+
+ def resolution(self):
+ """
+ Returns a tuple (width, height) with the rendering resolution.
+ """
+ return self._resolution
+
+ ##################################################
+ # Internal methods
+ ##################################################
+
+ def _plot_mesh(self, vertices, faces, persist, colors=None):
+ """
+ Plots a mesh.
+
+ Input
+ ------
+ vertices : [n,3], float
+ Position of the vertices
+
+ faces : [n,3], int
+ Indices of the triangles associated with ``vertices``
+
+ persist : bool
+ Flag indicating if the mesh is persistent, i.e., should not be
+ erased when ``reset()`` is called.
+
+ colors : [n,3] | [3] | None
+ Colors of the vertices. If `None`, black is used.
+ Defaults to `None`.
+ """
+ assert vertices.ndim == 2 and vertices.shape[1] == 3
+ assert faces.ndim == 2 and faces.shape[1] == 3
+ n_v = vertices.shape[0]
+ pmin, pmax = np.min(vertices, axis=0), np.max(vertices, axis=0)
+
+ # Assuming per-vertex colors
+ if colors is None:
+ # Black is default
+ colors = np.zeros((n_v, 3), dtype=np.float32)
+ elif colors.ndim == 1:
+ colors = np.tile(colors[None, :], (n_v, 1))
+ colors = colors.astype(np.float32)
+ assert (
+ (colors.ndim == 2) and (colors.shape[1] == 3) and (colors.shape[0] == n_v)
+ )
+
+ # Closer match to Mitsuba and Blender
+ colors = np.power(colors, 1 / 1.8)
+
+ mesh = Mesh(
+ vertices=vertices, faces=faces, vertex_colors=colors, shading="flat"
+ )
+ mesh.shading_filter.ambiant_light = (1, 1, 1, 0.8) # type: ignore
+ self._add_child(mesh, pmin, pmax, persist=persist)
+
+ def _plot_points(self, points, persist, colors=None, radius=0.05):
+ """
+ Plots a set of `n` points.
+
+ Input
+ -------
+ points : [n, 3], float
+ Coordinates of the `n` points.
+
+ persist : bool
+ Indicates if the points are persistent, i.e., should not be erased
+ when ``reset()`` is called.
+
+ colors : [n, 3], float | [3], float | None
+ Colors of the points.
+
+ radius : float
+ Radius of the points.
+ """
+ assert points.ndim == 2 and points.shape[1] == 3
+ n = points.shape[0]
+ pmin, pmax = np.min(points, axis=0), np.max(points, axis=0)
+
+ # Assuming per-vertex colors
+ if colors is None:
+ colors = np.zeros((n, 3), dtype=np.float32)
+ elif colors.ndim == 1:
+ colors = np.tile(colors[None, :], (n, 1))
+ colors = colors.astype(np.float32)
+ assert (colors.ndim == 2) and (colors.shape[1] == 3) and (colors.shape[0] == n)
+
+ markers = Markers(
+ pos=points,
+ size=2 * radius,
+ edge_width_rel=0.05,
+ face_color=colors,
+ scaling="scene",
+ alpha=0.5, # type: ignore[reportArgumentType]
+ )
+ self._add_child(markers, pmin, pmax, persist=persist)
+
+ def _add_child(self, obj, pmin, pmax, persist):
+ """
+ Adds an object for display
+
+ Input
+ ------
+ obj : VisPy node to display
+
+ pmin : [3], float
+ Lowest position for the bounding box
+
+ pmax : [3], float
+ Highest position for the bounding box
+
+ persist : bool
+ Flag that indicates if the object is persistent, i.e., if it should
+ be removed from the display when `reset()` is called.
+ """
+ self._objects.append((obj, persist))
+ self._view.add(obj)
+
+ self._bbox.expand(pmin)
+ self._bbox.expand(pmax)
+
+ def _plot_lines(self, starts, ends, width=0.5, color="black"):
+ """
+ Plots a set of `n` lines. This is used to plot the paths.
+
+ Input
+ ------
+ starts : [n, 3], float
+ Coordinates of the lines starting points
+
+ ends : [n, 3], float
+ Coordinates of the lines ending points
+
+ width : float
+ Width of the lines.
+ Defaults to 0.5.
+
+ color : str
+ Color of the lines.
+ Defaults to 'black'.
+ """
+ assert starts.ndim == 2 and starts.shape[1] == 3
+ assert ends.ndim == 2 and ends.shape[1] == 3
+ assert starts.shape[0] == ends.shape[0]
+
+ segments = np.hstack((starts, ends)).astype(np.float32).reshape(-1, 2, 3)
+ pmin = np.min(segments, axis=(0, 1))
+ pmax = np.max(segments, axis=(0, 1))
+
+ line_plot = LinePlot(
+ data=segments.reshape(-1, 3), color=color, width=width, marker_size=0
+ )
+
+ # Lines are not flagged as persistent as they correspond to paths, which
+ # can changes from one display to the next.
+ self._add_child(line_plot, pmin, pmax, persist=False)
diff --git a/tests/__init__.py b/tests/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/test_patch.py b/tests/test_patch.py
new file mode 100644
index 0000000..5e1194c
--- /dev/null
+++ b/tests/test_patch.py
@@ -0,0 +1,24 @@
+import importlib
+
+import sionna.rt.scene
+from vispy.scene import SceneCanvas
+
+from sionna_vispy import patch
+from sionna_vispy.previewer import InteractiveDisplay as NewInteractiveDisplay
+
+
+def test_patch() -> None:
+ importlib.reload(sionna.rt.scene)
+
+ assert not issubclass(sionna.rt.scene.InteractiveDisplay, SceneCanvas)
+
+ with patch() as cls:
+ assert cls == NewInteractiveDisplay
+
+ importlib.reload(sionna.rt.scene)
+
+ assert not issubclass(sionna.rt.scene.InteractiveDisplay, SceneCanvas)
+
+ importlib.reload(sionna.rt.scene)
+
+ assert not issubclass(sionna.rt.scene.InteractiveDisplay, SceneCanvas)
diff --git a/tests/test_preview.py b/tests/test_preview.py
new file mode 100644
index 0000000..49aae14
--- /dev/null
+++ b/tests/test_preview.py
@@ -0,0 +1,115 @@
+import pytest
+import sionna
+from sionna.rt import RIS, PlanarArray, Receiver, Transmitter, load_scene
+from sionna.rt.coverage_map import CoverageMap
+from sionna.rt.paths import Paths
+from sionna.rt.scene import Scene
+from vispy.scene import SceneCanvas
+
+import sionna_vispy
+
+
+@pytest.fixture
+def num_samples() -> int:
+ return 10
+
+
+@pytest.fixture
+def max_depth() -> int:
+ return 2
+
+
+@pytest.fixture
+def los() -> bool:
+ return True
+
+
+@pytest.fixture
+def reflection() -> bool:
+ return True
+
+
+@pytest.fixture
+def diffraction() -> bool:
+ return False
+
+
+@pytest.fixture
+def ris() -> bool:
+ return True
+
+
+@pytest.fixture
+def scene() -> Scene:
+ scene = load_scene(sionna.rt.scene.simple_street_canyon)
+ scene.frequency = 3e9
+ scene.tx_array = PlanarArray(1, 1, 0.5, 0.5, "iso", "V")
+ scene.rx_array = PlanarArray(1, 1, 0.5, 0.5, "iso", "V")
+
+ tx = Transmitter("tx", position=[-32, 10, 32], look_at=[0, 0, 0])
+ scene.add(tx)
+
+ rx = Receiver("rx", position=[22, 52, 1.7])
+ scene.add(rx)
+
+ ris = RIS(
+ name="ris",
+ position=[32, -9, 32],
+ num_rows=100,
+ num_cols=100,
+ num_modes=1,
+ look_at=(tx.position + rx.position) / 2, # type: ignore[reportOperatorIssue]
+ )
+ scene.add(ris)
+
+ ris.phase_gradient_reflector(tx.position, rx.position)
+
+ return scene
+
+
+@pytest.fixture
+def paths(
+ scene: Scene,
+ num_samples: int,
+ max_depth: int,
+ los: bool,
+ reflection: bool,
+ diffraction: bool,
+ ris: bool,
+) -> Paths:
+ return scene.compute_paths(
+ num_samples=num_samples,
+ max_depth=max_depth,
+ los=los,
+ reflection=reflection,
+ diffraction=diffraction,
+ ris=ris,
+ )
+
+
+@pytest.fixture
+def coverage_map(
+ scene: Scene,
+ num_samples: int,
+ max_depth: int,
+ los: bool,
+ reflection: bool,
+ diffraction: bool,
+ ris: bool,
+) -> CoverageMap:
+ return scene.coverage_map(
+ num_samples=num_samples,
+ max_depth=max_depth,
+ los=los,
+ reflection=reflection,
+ diffraction=diffraction,
+ ris=ris,
+ )
+
+
+def test_preview(scene: Scene, paths: Paths, coverage_map: CoverageMap) -> None:
+ with sionna_vispy.patch():
+ canvas = scene.preview(
+ paths=paths, coverage_map=coverage_map, show_orientations=True
+ )
+ assert isinstance(canvas, SceneCanvas)