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. + +

+ Example VisPy canvas +

+ +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)