├── .github └── workflows │ ├── publish.yaml │ └── test.yaml ├── .gitignore ├── .pre-commit-config.yaml ├── .python-version ├── .readthedocs.yaml ├── Dockerfile ├── LICENSE ├── README.md ├── app ├── .gitignore ├── README.md ├── biome.json ├── bun.lockb ├── decs.d.ts ├── index.html ├── package.json ├── public │ └── zndraw.png ├── src │ ├── App.css │ ├── App.tsx │ ├── assets │ │ └── react.svg │ ├── components │ │ ├── api.tsx │ │ ├── cameraAndControls.tsx │ │ ├── data.tsx │ │ ├── floor.tsx │ │ ├── geometries.tsx │ │ ├── headbar.tsx │ │ ├── lines.tsx │ │ ├── meshes.tsx │ │ ├── overlays.tsx │ │ ├── particles.tsx │ │ ├── particlesEditor.tsx │ │ ├── plotting.tsx │ │ ├── progressbar.tsx │ │ ├── sidebar.tsx │ │ ├── tooltips.tsx │ │ ├── transforms.tsx │ │ ├── utils.tsx │ │ ├── utils │ │ │ └── mergeInstancedMesh.tsx │ │ └── vectorfield.tsx │ ├── index.css │ ├── main.tsx │ ├── socket.tsx │ └── vite-env.d.ts ├── tsconfig.json ├── tsconfig.node.json └── vite.config.ts ├── docker-compose.yaml ├── docs ├── Makefile ├── make.bat └── source │ ├── 2025 │ └── 01.rst │ ├── _static │ ├── zndraw-dark.svg │ └── zndraw-light.svg │ ├── adventofcode.rst │ ├── conf.py │ ├── index.rst │ └── python-api.rst ├── examples ├── md.ipynb ├── molecules.ipynb └── stress_testing │ ├── README.md │ ├── multi_connection.py │ └── single_connection.py ├── misc ├── darkmode │ ├── analysis.png │ ├── box.png │ ├── overview.png │ └── python.png └── lightmode │ ├── analysis.png │ ├── box.png │ ├── overview.png │ └── python.png ├── pyproject.toml ├── tests ├── conftest.py ├── test_analysis.py ├── test_bookmarks.py ├── test_camera.py ├── test_config.py ├── test_figures.py ├── test_geometries.py ├── test_modifier.py ├── test_points.py ├── test_selection.py ├── test_serializer.py ├── test_step.py ├── test_tasks.py ├── test_utils.py ├── test_vectorfields.py ├── test_vis.py └── test_zndraw.py ├── uv.lock ├── zndraw ├── .gitignore ├── __init__.py ├── abc.py ├── analyse │ └── __init__.py ├── app.py ├── base.py ├── bonds │ └── __init__.py ├── config.py ├── converter.py ├── draw │ └── __init__.py ├── exceptions.py ├── figure.py ├── modify │ ├── __init__.py │ └── private.py ├── queue.py ├── selection │ └── __init__.py ├── server │ ├── __init__.py │ ├── events.py │ └── routes.py ├── standalone.py ├── tasks │ └── __init__.py ├── type_defs.py ├── upload.py ├── utils.py └── zndraw.py └── zndraw_app ├── README.md ├── __init__.py ├── cli.py ├── healthcheck.py └── make_celery.py /.github/workflows/publish.yaml: -------------------------------------------------------------------------------- 1 | # This workflow uses actions that are not certified by GitHub. 2 | # They are provided by a third-party and are governed by 3 | # separate terms of service, privacy policy, and support 4 | # documentation. 5 | 6 | # GitHub recommends pinning actions to a commit SHA. 7 | # To get a newer version, you will need to update the SHA. 8 | # You can also reference a tag or branch, but the action may change without warning. 9 | 10 | name: Publish Docker image 11 | 12 | on: 13 | release: 14 | types: [published] 15 | 16 | jobs: 17 | push_to_registry: 18 | name: Push Docker image to Docker Hub 19 | runs-on: ubuntu-latest 20 | steps: 21 | - name: Check out the repo 22 | uses: actions/checkout@v4 23 | 24 | - name: Log in to Docker Hub 25 | uses: docker/login-action@f4ef78c080cd8ba55a85445d5b36e214a81df20a 26 | with: 27 | username: ${{ secrets.DOCKER_USERNAME }} 28 | password: ${{ secrets.DOCKER_ACCESS_TOKEN }} 29 | 30 | - name: Extract metadata (tags, labels) for Docker 31 | id: meta 32 | uses: docker/metadata-action@9ec57ed1fcdbf14dcef7dfbe97b2010124a938b7 33 | with: 34 | images: pythonf/zndraw 35 | 36 | - name: Build and push Docker image 37 | uses: docker/build-push-action@3b5e8027fcad23fda98b2e3ac259d8d67585f671 38 | with: 39 | context: . 40 | file: ./Dockerfile 41 | push: true 42 | tags: ${{ steps.meta.outputs.tags }} 43 | labels: ${{ steps.meta.outputs.labels }} 44 | 45 | publish-pypi: 46 | runs-on: ubuntu-latest 47 | steps: 48 | - uses: actions/checkout@v4 49 | - name: Install uv 50 | uses: astral-sh/setup-uv@v5 51 | - name: build frontend 52 | run: | 53 | npm install -g bun 54 | cd app && bun install && bun vite build && cd .. 55 | - name: Publish 56 | env: 57 | PYPI_TOKEN: ${{ secrets.PYPI_API_TOKEN }} 58 | run: | 59 | uv build 60 | uv publish --token $PYPI_TOKEN 61 | -------------------------------------------------------------------------------- /.github/workflows/test.yaml: -------------------------------------------------------------------------------- 1 | name: tests 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | schedule: 8 | - cron: "14 3 * * 1" # at 03:14 on Monday. 9 | 10 | jobs: 11 | pytest: 12 | runs-on: ${{ matrix.os }} 13 | strategy: 14 | fail-fast: false 15 | matrix: 16 | python-version: 17 | - "3.13" 18 | - "3.12" 19 | - "3.11" 20 | - "3.10" 21 | os: 22 | - ubuntu-latest 23 | 24 | services: 25 | # Label used to access the service container 26 | redis: 27 | # Docker Hub image 28 | image: redis 29 | # Set health checks to wait until redis has started 30 | options: >- 31 | --health-cmd "redis-cli ping" 32 | --health-interval 10s 33 | --health-timeout 5s 34 | --health-retries 5 35 | ports: 36 | # Maps port 6379 on service container to the host 37 | - 6379:6379 38 | 39 | steps: 40 | - uses: actions/checkout@v4 41 | - name: Install uv and set the python version 42 | uses: astral-sh/setup-uv@v5 43 | with: 44 | python-version: ${{ matrix.python-version }} 45 | - name: Install package 46 | run: | 47 | uv sync --all-extras --dev 48 | - name: Pytest 49 | run: | 50 | uv run python --version 51 | uv run pytest --cov --junitxml=junit.xml -o junit_family=legacy 52 | - name: Upload coverage to Codecov 53 | uses: codecov/codecov-action@v5 54 | with: 55 | token: ${{ secrets.CODECOV_TOKEN }} 56 | - name: Upload test results to Codecov 57 | if: ${{ !cancelled() }} 58 | uses: codecov/test-results-action@v1 59 | with: 60 | token: ${{ secrets.CODECOV_TOKEN }} 61 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # poetry 98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 102 | #poetry.lock 103 | 104 | # pdm 105 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 106 | #pdm.lock 107 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 108 | # in version control. 109 | # https://pdm.fming.dev/#use-with-ide 110 | .pdm.toml 111 | 112 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 113 | __pypackages__/ 114 | 115 | # Celery stuff 116 | celerybeat-schedule 117 | celerybeat.pid 118 | 119 | # SageMath parsed files 120 | *.sage.py 121 | 122 | # Environments 123 | .env 124 | .venv 125 | env/ 126 | venv/ 127 | ENV/ 128 | env.bak/ 129 | venv.bak/ 130 | 131 | # Spyder project settings 132 | .spyderproject 133 | .spyproject 134 | 135 | # Rope project settings 136 | .ropeproject 137 | 138 | # mkdocs documentation 139 | /site 140 | 141 | # mypy 142 | .mypy_cache/ 143 | .dmypy.json 144 | dmypy.json 145 | 146 | # Pyre type checker 147 | .pyre/ 148 | 149 | # pytype static type analyzer 150 | .pytype/ 151 | 152 | # Cython debug symbols 153 | cython_debug/ 154 | 155 | # PyCharm 156 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 157 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 158 | # and can be added to the global gitignore or merged into this file. For a more nuclear 159 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 160 | .idea/ 161 | 162 | tmp/ 163 | .vscode 164 | data/ 165 | .zndraw/ 166 | control/ 167 | .DS_Store 168 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | default_language_version: 2 | python: python3 3 | repos: 4 | - repo: https://github.com/pre-commit/pre-commit-hooks 5 | rev: v5.0.0 6 | hooks: 7 | - id: check-added-large-files 8 | - id: check-case-conflict 9 | - id: check-docstring-first 10 | - id: check-executables-have-shebangs 11 | # - id: check-json 12 | - id: check-merge-conflict 13 | args: ["--assume-in-merge"] 14 | - id: check-toml 15 | - id: check-yaml 16 | - id: debug-statements 17 | - id: end-of-file-fixer 18 | - id: mixed-line-ending 19 | args: ["--fix=lf"] 20 | - id: sort-simple-yaml 21 | - id: trailing-whitespace 22 | - repo: https://github.com/codespell-project/codespell 23 | rev: v2.4.1 24 | hooks: 25 | - id: codespell 26 | additional_dependencies: ["tomli"] 27 | - repo: https://github.com/biomejs/pre-commit 28 | rev: v0.6.1 # Use the sha / tag you want to point at 29 | hooks: 30 | - id: biome-format # not using check becasue there are lots of things that need fixed 31 | additional_dependencies: ["@biomejs/biome@1.9.4"] 32 | - repo: https://github.com/astral-sh/ruff-pre-commit 33 | rev: v0.9.6 34 | hooks: 35 | - id: ruff 36 | args: [--fix] 37 | - id: ruff-format 38 | -------------------------------------------------------------------------------- /.python-version: -------------------------------------------------------------------------------- 1 | 3.11 2 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | # .readthedocs.yaml 2 | # Read the Docs configuration file 3 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details 4 | 5 | version: 2 6 | 7 | submodules: 8 | include: all 9 | 10 | # Set the version of Python and other tools you might need 11 | build: 12 | os: ubuntu-22.04 13 | tools: 14 | python: "3.11" 15 | jobs: 16 | post_install: 17 | # see https://github.com/astral-sh/uv/issues/10074 18 | - pip install uv 19 | - UV_PROJECT_ENVIRONMENT=$READTHEDOCS_VIRTUALENV_PATH uv sync --all-extras --link-mode=copy --group=docs 20 | 21 | # Build documentation in the docs/ directory with Sphinx 22 | sphinx: 23 | configuration: docs/source/conf.py 24 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.12 2 | SHELL ["/bin/bash", "--login", "-c"] 3 | 4 | WORKDIR /usr/src/app 5 | 6 | # required for h5py 7 | RUN apt update && apt install -y gcc pkg-config libhdf5-dev build-essential 8 | RUN curl -fsSL https://bun.sh/install | bash 9 | 10 | COPY ./ ./ 11 | RUN cd app && bun install && bun vite build && cd .. 12 | RUN pip install -e . 13 | 14 | ENTRYPOINT ["zndraw", "--port", "5003", "--no-browser"] 15 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 |
6 | 7 | ![Logo](https://raw.githubusercontent.com/zincware/ZnDraw/refs/heads/main/docs/source/_static/zndraw-light.svg#gh-light-mode-only) 8 | ![Logo](https://raw.githubusercontent.com/zincware/ZnDraw/refs/heads/main/docs/source/_static/zndraw-dark.svg#gh-dark-mode-only) 9 | 10 | [![zincware](https://img.shields.io/badge/Powered%20by-zincware-darkcyan)](https://github.com/zincware) 11 | [![PyPI version](https://badge.fury.io/py/zndraw.svg)](https://badge.fury.io/py/zndraw) 12 | [![DOI](https://img.shields.io/badge/arXiv-2402.08708-red)](https://arxiv.org/abs/2402.08708) 13 | [![codecov](https://codecov.io/gh/zincware/ZnDraw/graph/badge.svg?token=3GPCKH1BBX)](https://codecov.io/gh/zincware/ZnDraw) 14 | [![Discord](https://img.shields.io/discord/1034511611802689557)](https://discord.gg/7ncfwhsnm4) 15 | [![Documentation Status](https://readthedocs.org/projects/zndraw/badge/?version=latest)](https://zndraw.readthedocs.io/en/latest/?badge=latest) 16 | !['Threejs](https://img.shields.io/badge/threejs-black?style=for-the-badge&logo=three.js&logoColor=white) 17 | 18 |
19 | 20 | # ZnDraw - Display and Edit Molecules 21 | Welcome to ZnDraw, a powerful tool for visualizing and interacting with your trajectories. 22 | 23 | ## Installation 24 | 25 | You can install ZnDraw directly from PyPi via: 26 | 27 | ```bash 28 | pip install zndraw 29 | ``` 30 | 31 | ## Quick Start 32 | 33 | Visualize your trajectories with a single command: 34 | 35 | ```bash 36 | zndraw 37 | ``` 38 | 39 | > [!NOTE] 40 | > ZnDraw's webapp-based approach allows you to use port forwarding to work with trajectories on remote systems. 41 | 42 | ![ZnDraw UI](https://raw.githubusercontent.com/zincware/ZnDraw/main/misc/darkmode/overview.png#gh-dark-mode-only "ZnDraw UI") 43 | ![ZnDraw UI](https://raw.githubusercontent.com/zincware/ZnDraw/main/misc/lightmode/overview.png#gh-light-mode-only "ZnDraw UI") 44 | 45 | ## Multi-User and Multi-Client Support 46 | 47 | ZnDraw supports multiple users and clients. Connect one or more Python clients to your ZnDraw instance: 48 | 49 | 1. Click on `Python access` in the ZnDraw UI. 50 | 2. Connect using the following code: 51 | 52 | ```python 53 | from zndraw import ZnDraw 54 | 55 | vis = ZnDraw(url="http://localhost:1234", token="") 56 | ``` 57 | 58 | ![ZnDraw UI](https://raw.githubusercontent.com/zincware/ZnDraw/main/misc/darkmode/python.png#gh-dark-mode-only "ZnDraw Python Client") 59 | ![ZnDraw UI](https://raw.githubusercontent.com/zincware/ZnDraw/main/misc/lightmode/python.png#gh-light-mode-only "ZnDraw Python Client") 60 | 61 | The `vis` object provides direct access to your visualized scene. It inherits from `abc.MutableSequence`, so any changes you make are reflected for all connected clients. 62 | 63 | ```python 64 | from ase.collections import s22 65 | vis.extend(list(s22)) 66 | ``` 67 | 68 | ## Additional Features 69 | 70 | You can modify various aspects of the visualization: 71 | 72 | - `vis.camera` 73 | - `vis.points` 74 | - `vis.selection` 75 | - `vis.step` 76 | - `vis.figures` 77 | - `vis.bookmarks` 78 | - `vis.geometries` 79 | 80 | For example, to add a geometry: 81 | 82 | ```python 83 | from zndraw import Box 84 | 85 | vis.geometries = [Box(position=[0, 1, 2])] 86 | ``` 87 | 88 | ![ZnDraw UI](https://raw.githubusercontent.com/zincware/ZnDraw/main/misc/darkmode/box.png#gh-dark-mode-only "ZnDraw Geometries") 89 | ![ZnDraw UI](https://raw.githubusercontent.com/zincware/ZnDraw/main/misc/lightmode/box.png#gh-light-mode-only "ZnDraw Geometries") 90 | 91 | ## Analyzing Data 92 | 93 | ZnDraw enables you to analyze your data and generate plots using [Plotly](https://plotly.com/). It automatically detects available properties and offers a convenient drop-down menu for selection. 94 | 95 | ![ZnDraw UI](https://raw.githubusercontent.com/zincware/ZnDraw/main/misc/darkmode/analysis.png#gh-dark-mode-only "ZnDraw Analysis") 96 | ![ZnDraw UI](https://raw.githubusercontent.com/zincware/ZnDraw/main/misc/lightmode/analysis.png#gh-light-mode-only "ZnDraw Analysis") 97 | 98 | ZnDraw will look for the `step` and `atom` index in the [customdata](https://plotly.com/python/reference/scatter/#scatter-customdata)`[0]` and `[1]` respectively to highlight the steps and atoms. 99 | 100 | ## Writing Extensions 101 | 102 | Make your tools accessible via the ZnDraw UI by writing an extension: 103 | 104 | ```python 105 | from zndraw import Extension 106 | 107 | class AddMolecule(Extension): 108 | name: str 109 | 110 | def run(self, vis, **kwargs) -> None: 111 | structures = kwargs["structures"] 112 | vis.append(structures[self.name]) 113 | vis.step = len(vis) - 1 114 | 115 | vis.register(AddMolecule, run_kwargs={"structures": s22}, public=True) 116 | vis.socket.wait() # This can be ignored when using Jupyter 117 | ``` 118 | 119 | The `AddMolecule` extension will appear for all `tokens` and can be used by any client. 120 | 121 | # Hosted Version 122 | 123 | A hosted version of ZnDraw is available at https://zndraw.icp.uni-stuttgart.de . To upload data, use: 124 | 125 | ```bash 126 | zndraw --url https://zndraw.icp.uni-stuttgart.de 127 | ``` 128 | 129 | ## Self-Hosting 130 | 131 | To host your own version of ZnDraw, use the following `docker-compose.yaml` setup: 132 | 133 | ```yaml 134 | version: "3.9" 135 | 136 | services: 137 | zndraw: 138 | image: pythonf/zndraw:latest 139 | command: --no-standalone /src/file.xyz 140 | volumes: 141 | - /path/to/files:/src 142 | restart: unless-stopped 143 | ports: 144 | - 5003:5003 145 | depends_on: 146 | - redis 147 | - worker 148 | environment: 149 | - FLASK_STORAGE=redis://redis:6379/0 150 | - FLASK_AUTH_TOKEN=super-secret-token 151 | 152 | worker: 153 | image: pythonf/zndraw:latest 154 | entrypoint: celery -A zndraw_app.make_celery worker --loglevel=info -P eventlet 155 | volumes: 156 | - /path/to/files:/src 157 | restart: unless-stopped 158 | depends_on: 159 | - redis 160 | environment: 161 | - FLASK_STORAGE=redis://redis:6379/0 162 | - FLASK_SERVER_URL="http://zndraw:5003" 163 | - FLASK_AUTH_TOKEN=super-secret-token 164 | 165 | redis: 166 | image: redis:latest 167 | restart: always 168 | environment: 169 | - REDIS_PORT=6379 170 | ``` 171 | 172 | If you want to host zndraw as subdirectory `domain.com/zndraw` you need to adjust the environmental variables as well as update `base: "/",` in the `app/vite.config.ts` before building the ap.. 173 | 174 | # References 175 | 176 | If you use ZnDraw in your research and find it helpful please cite us. 177 | 178 | ```bibtex 179 | @misc{elijosiusZeroShotMolecular2024, 180 | title = {Zero {{Shot Molecular Generation}} via {{Similarity Kernels}}}, 181 | author = {Elijo{\v s}ius, Rokas and Zills, Fabian and Batatia, Ilyes and Norwood, Sam Walton and Kov{\'a}cs, D{\'a}vid P{\'e}ter and Holm, Christian and Cs{\'a}nyi, G{\'a}bor}, 182 | year = {2024}, 183 | eprint = {2402.08708}, 184 | archiveprefix = {arxiv}, 185 | } 186 | ``` 187 | 188 | # Acknowledgements 189 | 190 | The creation of ZnDraw was supported by the Deutsche Forschungsgemeinschaft (DFG, German Research Foundation) in the framework of the priority program SPP 2363, “Utilization and Development of Machine Learning for Molecular Applications - Molecular Machine Learning” Project No. 497249646. Further funding though the DFG under Germany's Excellence Strategy - EXC 2075 - 390740016 and the Stuttgart Center for Simulation Science (SimTech) was provided. 191 | -------------------------------------------------------------------------------- /app/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | 26 | TODO.md 27 | -------------------------------------------------------------------------------- /app/README.md: -------------------------------------------------------------------------------- 1 | # React + TypeScript + Vite 2 | 3 | This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. 4 | 5 | Currently, two official plugins are available: 6 | 7 | - [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh 8 | - [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh 9 | 10 | ## Expanding the ESLint configuration 11 | 12 | If you are developing a production application, we recommend updating the configuration to enable type aware lint rules: 13 | 14 | - Configure the top-level `parserOptions` property like this: 15 | 16 | ```js 17 | export default { 18 | // other rules... 19 | parserOptions: { 20 | ecmaVersion: "latest", 21 | sourceType: "module", 22 | project: ["./tsconfig.json", "./tsconfig.node.json"], 23 | tsconfigRootDir: __dirname, 24 | }, 25 | }; 26 | ``` 27 | 28 | - Replace `plugin:@typescript-eslint/recommended` to `plugin:@typescript-eslint/recommended-type-checked` or `plugin:@typescript-eslint/strict-type-checked` 29 | - Optionally add `plugin:@typescript-eslint/stylistic-type-checked` 30 | - Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and add `plugin:react/recommended` & `plugin:react/jsx-runtime` to the `extends` list 31 | -------------------------------------------------------------------------------- /app/biome.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json", 3 | "vcs": { 4 | "enabled": false, 5 | "clientKind": "git", 6 | "useIgnoreFile": false 7 | }, 8 | "files": { 9 | "ignoreUnknown": false, 10 | "ignore": [] 11 | }, 12 | "formatter": { 13 | "enabled": true, 14 | "indentStyle": "tab" 15 | }, 16 | "organizeImports": { 17 | "enabled": true 18 | }, 19 | "linter": { 20 | "enabled": true, 21 | "rules": { 22 | "recommended": true 23 | } 24 | }, 25 | "javascript": { 26 | "formatter": { 27 | "quoteStyle": "double" 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /app/bun.lockb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zincware/ZnDraw/0a71d450976df5e1889b99d4764c0e793268b55e/app/bun.lockb -------------------------------------------------------------------------------- /app/decs.d.ts: -------------------------------------------------------------------------------- 1 | declare module "@json-editor/json-editor" { 2 | const JSONEditor: any; 3 | export default JSONEditor; 4 | } 5 | -------------------------------------------------------------------------------- /app/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | ZnDraw 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /app/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "zndraw", 3 | "private": true, 4 | "version": "0.5.8", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "tsc && vite build", 9 | "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", 10 | "preview": "vite preview", 11 | "format": "prettier . --write" 12 | }, 13 | "dependencies": { 14 | "@json-editor/json-editor": "^2.15.2", 15 | "@react-three/drei": "^9.116.3", 16 | "@react-three/fiber": "^8.17.10", 17 | "@react-three/gpu-pathtracer": "^0.2.0", 18 | "@types/lodash": "^4.17.13", 19 | "@types/three": "^0.165.0", 20 | "bootstrap": "5.3.3", 21 | "lodash": "^4.17.21", 22 | "plotly.js": "^2.35.2", 23 | "react": "^18.3.1", 24 | "react-bootstrap": "^2.10.5", 25 | "react-dom": "^18.3.1", 26 | "react-icons": "^5.3.0", 27 | "react-markdown": "^9.0.1", 28 | "react-plotly.js": "^2.6.0", 29 | "react-rnd": "^10.4.13", 30 | "react-select": "^5.8.3", 31 | "react-syntax-highlighter": "^15.6.1", 32 | "rehype-katex": "^7.0.1", 33 | "rehype-raw": "^7.0.0", 34 | "remark-breaks": "^4.0.0", 35 | "remark-gfm": "^4.0.0", 36 | "remark-math": "^6.0.0", 37 | "socket.io-client": "^4.8.1", 38 | "three": "^0.165.0", 39 | "znsocket": "^0.2.6" 40 | }, 41 | "devDependencies": { 42 | "@biomejs/biome": "1.9.4", 43 | "@types/react": "^18.3.12", 44 | "@types/react-dom": "^18.3.1", 45 | "@vitejs/plugin-react-swc": "^3.7.1", 46 | "typescript": "^5.6.3", 47 | "vite": "^5.4.11" 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /app/public/zndraw.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zincware/ZnDraw/0a71d450976df5e1889b99d4764c0e793268b55e/app/public/zndraw.png -------------------------------------------------------------------------------- /app/src/App.css: -------------------------------------------------------------------------------- 1 | #root { 2 | max-width: 1280px; 3 | margin: 0 auto; 4 | padding: 2rem; 5 | text-align: center; 6 | } 7 | 8 | .logo { 9 | height: 6em; 10 | padding: 1.5em; 11 | will-change: filter; 12 | transition: filter 300ms; 13 | } 14 | .logo:hover { 15 | filter: drop-shadow(0 0 2em #646cffaa); 16 | } 17 | .logo.react:hover { 18 | filter: drop-shadow(0 0 2em #61dafbaa); 19 | } 20 | 21 | @keyframes logo-spin { 22 | from { 23 | transform: rotate(0deg); 24 | } 25 | to { 26 | transform: rotate(360deg); 27 | } 28 | } 29 | 30 | @media (prefers-reduced-motion: no-preference) { 31 | a:nth-of-type(2) .logo { 32 | animation: logo-spin infinite 20s linear; 33 | } 34 | } 35 | 36 | .card { 37 | padding: 2em; 38 | } 39 | 40 | .read-the-docs { 41 | color: #888; 42 | } 43 | 44 | .canvas-container { 45 | position: fixed; 46 | top: 0; 47 | left: 0; 48 | width: 100%; 49 | height: 100%; 50 | z-index: 0; 51 | } 52 | 53 | .frame-progress-bar .progress-bar { 54 | transition: none; 55 | } 56 | 57 | .blur-bg-90 { 58 | opacity: 0.95 !important; 59 | -webkit-backdrop-filter: blur(10px); 60 | backdrop-filter: blur(10px); 61 | } 62 | 63 | .custom-modal .modal-dialog { 64 | max-width: 100%; 65 | } 66 | 67 | .custom-modal .modal-content { 68 | height: 80vh; 69 | display: flex; 70 | flex-direction: column; 71 | } 72 | 73 | .custom-modal .modal-body-custom { 74 | flex: 1; 75 | padding: 0; 76 | display: flex; 77 | } 78 | 79 | .custom-modal .iframe-custom { 80 | width: 100%; 81 | height: 100%; 82 | border: none; 83 | } 84 | :root { 85 | --handle-size: 12px; /* Base size for the handle */ 86 | --handle-color: rgb(48, 75, 183); /* Color of the handle */ 87 | } 88 | 89 | .square { 90 | width: var(--handle-size); 91 | height: var(--handle-size); 92 | background-color: var(--handle-color); /* Color of the square */ 93 | border-left: calc(var(--handle-size) / 2) solid transparent; 94 | border-right: calc(var(--handle-size) / 2) solid transparent; 95 | } 96 | 97 | .triangle { 98 | width: 0; 99 | height: 0; 100 | border-left: calc(var(--handle-size) / 2) solid transparent; 101 | border-right: calc(var(--handle-size) / 2) solid transparent; 102 | border-top: calc(var(--handle-size) / 2) solid var(--handle-color); /* Color of the triangle */ 103 | } 104 | 105 | .handle { 106 | position: absolute; 107 | top: -15px; /* Adjust this value based on your design */ 108 | transform: translateX(-50%); 109 | display: flex; 110 | align-items: center; 111 | flex-direction: column; 112 | z-index: 2; /* Ensure it is above other elements */ 113 | } 114 | 115 | .progress-bar-v-line { 116 | position: absolute; 117 | top: 0; 118 | left: 0; 119 | transform: translateX(-50%); 120 | width: 2px; /* Thickness of the line */ 121 | height: 100%; /* Full height of the column */ 122 | background-color: var(--handle-color); /* Color of the line */ 123 | z-index: 1; /* Ensure it is above tiles but below the bookmark */ 124 | } 125 | 126 | .progress-bar-tick-line { 127 | position: absolute; 128 | top: 0; 129 | left: 0; 130 | transform: translateX(-50%) translateY(-100%); 131 | width: 1px; /* Thickness of the line */ 132 | height: 14%; /* Size of the tick */ 133 | z-index: 1; /* Ensure it is visible */ 134 | } 135 | 136 | .progress-bar-bookmark { 137 | position: absolute; 138 | top: 0; 139 | left: 0; 140 | /* z-index: 1; */ 141 | transform: translateX(-50%); 142 | } 143 | -------------------------------------------------------------------------------- /app/src/assets/react.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /app/src/components/cameraAndControls.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | OrbitControls, 3 | OrthographicCamera, 4 | PerspectiveCamera, 5 | TrackballControls, 6 | } from "@react-three/drei"; 7 | import { Box } from "@react-three/drei"; 8 | import { debounce } from "lodash"; 9 | import { 10 | forwardRef, 11 | useCallback, 12 | useEffect, 13 | useMemo, 14 | useRef, 15 | useState, 16 | } from "react"; 17 | import * as THREE from "three"; 18 | import { getCentroid, useCentroid } from "./particlesEditor"; 19 | 20 | const zeroVector = new THREE.Vector3(0, 0, 0); 21 | const initialCameraVector = new THREE.Vector3(5, 5, 5); 22 | const upVector = new THREE.Vector3(0, 1, 0); 23 | 24 | const MoveCameraTarget = forwardRef( 25 | ({ colorMode }: { colorMode: string }, ref: React.Ref) => { 26 | const shortDimension = 0.05; 27 | const longDimension = 0.5; 28 | 29 | return ( 30 | 31 | {/* X axis box */} 32 | 33 | 36 | 37 | 38 | {/* Y axis box */} 39 | 40 | 43 | 44 | 45 | {/* Z axis box */} 46 | 47 | 50 | 51 | 52 | ); 53 | }, 54 | ); 55 | 56 | type CameraAndControls = { 57 | camera: THREE.Vector3; 58 | target: THREE.Vector3; 59 | }; 60 | 61 | type CameraAndControlsProps = { 62 | cameraConfig: any; 63 | cameraAndControls: CameraAndControls; 64 | setCameraAndControls: React.Dispatch>; 65 | currentFrame: any; 66 | selectedIds: Set; 67 | colorMode: string; 68 | }; 69 | 70 | const CameraAndControls: React.FC = ({ 71 | cameraConfig, 72 | cameraAndControls, 73 | setCameraAndControls, 74 | currentFrame, 75 | selectedIds, 76 | colorMode, 77 | }) => { 78 | const cameraRef = useRef(null); 79 | const controlsRef = useRef(null); 80 | const centroid = useCentroid({ 81 | frame: currentFrame, 82 | selectedIds: selectedIds, 83 | }); 84 | 85 | // need this extra for the crosshair 86 | const crossHairRef = useRef(null); 87 | 88 | const controlsOnChangeFn = useCallback((e: any) => { 89 | if (!crossHairRef.current) { 90 | return; 91 | } 92 | crossHairRef.current.position.copy(e.target.target); 93 | }, []); 94 | 95 | const controlsOnEndFn = useCallback( 96 | debounce(() => { 97 | if (!cameraRef.current || !controlsRef.current) { 98 | return; 99 | } 100 | setCameraAndControls({ 101 | camera: cameraRef.current.position, 102 | target: controlsRef.current.target, 103 | }); 104 | }, 100), 105 | [], 106 | ); 107 | 108 | const rollCamera = useCallback((angle: number) => { 109 | if (!cameraRef.current) { 110 | return; 111 | } 112 | // Get the current direction the camera is looking 113 | const looksTo = new THREE.Vector3(); 114 | cameraRef.current.getWorldDirection(looksTo); 115 | 116 | // Calculate the rotation axis (perpendicular to both yDir and looksTo) 117 | const rotationAxis = new THREE.Vector3(); 118 | rotationAxis.crossVectors(upVector, looksTo).normalize(); 119 | 120 | // Compute the quaternion for the roll rotation 121 | const quaternion = new THREE.Quaternion(); 122 | quaternion.setFromAxisAngle(looksTo, angle); 123 | 124 | // Update the `up` vector of the camera 125 | const newUp = new THREE.Vector3(); 126 | newUp.copy(cameraRef.current.up).applyQuaternion(quaternion).normalize(); 127 | cameraRef.current.up.copy(newUp); 128 | 129 | // Update the camera matrix and controls 130 | cameraRef.current.updateProjectionMatrix(); 131 | if (controlsRef.current) { 132 | controlsRef.current.update(); 133 | } 134 | }, []); 135 | 136 | const getResetCamera = useCallback(() => { 137 | if (currentFrame.positions.length === 0) { 138 | return; 139 | } 140 | if (cameraRef.current === null) { 141 | return; 142 | } 143 | // now calculate the camera positions 144 | const fullCentroid = getCentroid(currentFrame.positions, new Set()); 145 | 146 | // Compute the bounding sphere radius 147 | let maxDistance = 0; 148 | currentFrame.positions.forEach((x) => { 149 | maxDistance = Math.max(maxDistance, x.distanceTo(fullCentroid)); 150 | }); 151 | 152 | const fov = (cameraRef.current.fov * Math.PI) / 180; // Convert FOV to radians 153 | let distance = maxDistance / Math.tan(fov / 2); 154 | // if distance is NaN, return - happens for OrthographicCamera 155 | if (Number.isNaN(distance)) { 156 | distance = 0; 157 | } 158 | 159 | return { 160 | camera: new THREE.Vector3(distance, distance, distance), 161 | target: fullCentroid, 162 | }; 163 | }, [currentFrame.positions]); 164 | 165 | // if the camera positions and target positions is default, adapt them to the scene 166 | useEffect(() => { 167 | if ( 168 | cameraAndControls.camera.equals(initialCameraVector) && 169 | cameraAndControls.target.equals(zeroVector) 170 | ) { 171 | const resetCamera = getResetCamera(); 172 | if (resetCamera) { 173 | setCameraAndControls(resetCamera); 174 | } 175 | } 176 | }, [currentFrame.positions]); 177 | 178 | // if the camera changes, run resetCamera 179 | useEffect(() => { 180 | const resetCamera = getResetCamera(); 181 | if (resetCamera) { 182 | setCameraAndControls(resetCamera); 183 | } 184 | }, [cameraConfig.camera]); 185 | 186 | useEffect(() => { 187 | if (!cameraRef.current || !controlsRef.current) { 188 | return; 189 | } 190 | cameraRef.current.position.copy(cameraAndControls.camera); 191 | controlsRef.current.target.copy(cameraAndControls.target); 192 | controlsRef.current.update(); 193 | }, [cameraAndControls]); 194 | 195 | // keyboard controls 196 | useEffect(() => { 197 | // page initialization 198 | 199 | const handleKeyDown = (event: KeyboardEvent) => { 200 | // if canvas is not focused, don't do anything 201 | if (document.activeElement !== document.body) { 202 | return; 203 | } 204 | if (event.key === "c") { 205 | setCameraAndControls((prev: any) => ({ 206 | ...prev, 207 | target: centroid, 208 | })); 209 | } else if (event.key === "o") { 210 | const resetCamera = getResetCamera(); 211 | if (resetCamera) { 212 | setCameraAndControls(resetCamera); 213 | } 214 | // reset the camera roll 215 | if (cameraRef.current) { 216 | cameraRef.current.up.copy(upVector); 217 | cameraRef.current.updateProjectionMatrix(); 218 | if (controlsRef.current) { 219 | controlsRef.current.update(); 220 | } 221 | } 222 | } else if (event.key === "r") { 223 | const roll = Math.PI / 100; 224 | if (event.ctrlKey) { 225 | rollCamera(-roll); 226 | } else { 227 | rollCamera(roll); 228 | } 229 | } 230 | }; 231 | 232 | // Add the event listener 233 | window.addEventListener("keydown", handleKeyDown); 234 | 235 | // Clean up the event listener on unmount 236 | return () => { 237 | window.removeEventListener("keydown", handleKeyDown); 238 | }; 239 | }, [currentFrame, selectedIds]); 240 | 241 | return ( 242 | <> 243 | {cameraConfig.camera === "OrthographicCamera" && ( 244 | 251 | 252 | 253 | )} 254 | {cameraConfig.camera === "PerspectiveCamera" && ( 255 | 261 | 262 | 263 | )} 264 | {cameraConfig.controls === "OrbitControls" && ( 265 | 272 | )} 273 | {cameraConfig.controls === "TrackballControls" && ( 274 | 280 | )} 281 | {cameraConfig.crosshair && controlsRef.current.target && ( 282 | 283 | )} 284 | 285 | ); 286 | }; 287 | 288 | export default CameraAndControls; 289 | -------------------------------------------------------------------------------- /app/src/components/data.tsx: -------------------------------------------------------------------------------- 1 | import { Color } from "three"; 2 | 3 | export const JMOL_COLORS = [ 4 | new Color("#ff0000"), 5 | new Color("#ffffff"), 6 | new Color("#d9ffff"), 7 | new Color("#cc80ff"), 8 | new Color("#c2ff00"), 9 | new Color("#ffb5b5"), 10 | new Color("#909090"), 11 | new Color("#2f50f8"), 12 | new Color("#ff0d0d"), 13 | new Color("#90df50"), 14 | new Color("#b3e2f5"), 15 | new Color("#ab5cf1"), 16 | new Color("#89ff00"), 17 | new Color("#bea6a6"), 18 | new Color("#efc79f"), 19 | new Color("#ff8000"), 20 | new Color("#ffff2f"), 21 | new Color("#1fef1f"), 22 | new Color("#80d1e2"), 23 | new Color("#8f40d3"), 24 | new Color("#3cff00"), 25 | new Color("#e6e6e6"), 26 | new Color("#bec2c6"), 27 | new Color("#a6a6ab"), 28 | new Color("#8999c6"), 29 | new Color("#9c79c6"), 30 | new Color("#df6633"), 31 | new Color("#ef909f"), 32 | new Color("#50d050"), 33 | new Color("#c78033"), 34 | new Color("#7c80af"), 35 | new Color("#c28f8f"), 36 | new Color("#668f8f"), 37 | new Color("#bc80e2"), 38 | new Color("#ffa000"), 39 | new Color("#a62929"), 40 | new Color("#5cb8d1"), 41 | new Color("#6f2daf"), 42 | new Color("#00ff00"), 43 | new Color("#93ffff"), 44 | new Color("#93dfdf"), 45 | new Color("#73c2c8"), 46 | new Color("#53b5b5"), 47 | new Color("#3a9e9e"), 48 | new Color("#238f8f"), 49 | new Color("#097c8b"), 50 | new Color("#006985"), 51 | new Color("#c0c0c0"), 52 | new Color("#ffd98f"), 53 | new Color("#a67573"), 54 | new Color("#668080"), 55 | new Color("#9e62b5"), 56 | new Color("#d37900"), 57 | new Color("#930093"), 58 | new Color("#429eaf"), 59 | new Color("#56168f"), 60 | new Color("#00c800"), 61 | new Color("#6fd3ff"), 62 | new Color("#ffffc6"), 63 | new Color("#d9ffc6"), 64 | new Color("#c6ffc6"), 65 | new Color("#a2ffc6"), 66 | new Color("#8fffc6"), 67 | new Color("#60ffc6"), 68 | new Color("#45ffc6"), 69 | new Color("#2fffc6"), 70 | new Color("#1fffc6"), 71 | new Color("#00ff9c"), 72 | new Color("#00e675"), 73 | new Color("#00d352"), 74 | new Color("#00be38"), 75 | new Color("#00ab23"), 76 | new Color("#4dc2ff"), 77 | new Color("#4da6ff"), 78 | new Color("#2093d5"), 79 | new Color("#257cab"), 80 | new Color("#256695"), 81 | new Color("#165386"), 82 | new Color("#d0d0df"), 83 | new Color("#ffd122"), 84 | new Color("#b8b8d0"), 85 | new Color("#a6534d"), 86 | new Color("#565860"), 87 | new Color("#9e4fb5"), 88 | new Color("#ab5c00"), 89 | new Color("#754f45"), 90 | new Color("#428295"), 91 | new Color("#420066"), 92 | new Color("#007c00"), 93 | new Color("#6fabf9"), 94 | new Color("#00b9ff"), 95 | new Color("#00a0ff"), 96 | new Color("#008fff"), 97 | new Color("#0080ff"), 98 | new Color("#006bff"), 99 | new Color("#535cf1"), 100 | new Color("#785ce2"), 101 | new Color("#894fe2"), 102 | new Color("#a036d3"), 103 | new Color("#b31fd3"), 104 | new Color("#b31fb9"), 105 | new Color("#b30da6"), 106 | new Color("#bc0d86"), 107 | new Color("#c60066"), 108 | new Color("#cc0058"), 109 | new Color("#d1004f"), 110 | new Color("#d90045"), 111 | new Color("#df0038"), 112 | new Color("#e6002d"), 113 | new Color("#eb0025"), 114 | ]; 115 | 116 | export const covalentRadii = [ 117 | 1, 0.31, 0.28, 1.28, 0.96, 0.84, 0.76, 0.71, 0.66, 0.57, 0.58, 1.66, 1.41, 118 | 1.21, 1.11, 1.07, 1.05, 1.02, 1.06, 2.03, 1.76, 1.7, 1.6, 1.53, 1.39, 1.39, 119 | 1.32, 1.26, 1.24, 1.32, 1.22, 1.22, 1.2, 1.19, 1.2, 1.2, 1.16, 2.2, 1.95, 1.9, 120 | 1.75, 1.64, 1.54, 1.47, 1.46, 1.42, 1.39, 1.45, 1.44, 1.42, 1.39, 1.39, 1.38, 121 | 1.39, 1.4, 2.44, 2.15, 2.07, 2.04, 2.03, 2.01, 1.99, 1.98, 1.98, 1.96, 1.94, 122 | 1.92, 1.92, 1.89, 1.9, 1.87, 1.87, 1.75, 1.7, 1.62, 1.51, 1.44, 1.41, 1.36, 123 | 1.36, 1.32, 1.45, 1.46, 1.48, 1.4, 1.5, 1.5, 2.6, 2.21, 2.15, 2.06, 2.0, 1.96, 124 | 1.9, 1.87, 1.8, 1.69, 125 | ]; 126 | -------------------------------------------------------------------------------- /app/src/components/floor.tsx: -------------------------------------------------------------------------------- 1 | import { Plane } from "@react-three/drei"; 2 | import { Line } from "@react-three/drei"; 3 | import { useEffect, useState } from "react"; 4 | import * as THREE from "three"; 5 | 6 | function Grid({ 7 | position = [0, 0, 0], 8 | gridSpacing = 10, 9 | sizeX = 500, 10 | sizeY = 500, 11 | color = "black", 12 | }) { 13 | const lines = []; 14 | 15 | // Generate vertical lines 16 | for (let x = -sizeX / 2; x <= sizeX / 2; x += gridSpacing) { 17 | lines.push( 18 | , 27 | ); 28 | } 29 | 30 | // Generate horizontal lines 31 | for (let y = -sizeY / 2; y <= sizeY / 2; y += gridSpacing) { 32 | lines.push( 33 | , 42 | ); 43 | } 44 | 45 | return {lines}; 46 | } 47 | 48 | export const Floor: any = ({ colorMode, roomConfig }: any) => { 49 | const [bsColor, setBsColor] = useState({ 50 | "--bs-body-bg": "#fff", 51 | "--bs-secondary": "#fff", 52 | }); 53 | 54 | useEffect(() => { 55 | setBsColor({ 56 | "--bs-body-bg": getComputedStyle( 57 | document.documentElement, 58 | ).getPropertyValue("--bs-body-bg"), 59 | "--bs-secondary": getComputedStyle( 60 | document.documentElement, 61 | ).getPropertyValue("--bs-secondary"), 62 | }); 63 | }, [colorMode]); 64 | 65 | return ( 66 | <> 67 | {" "} 68 | 74 | 85 | 86 | 87 | 94 | 95 | ); 96 | }; 97 | -------------------------------------------------------------------------------- /app/src/components/lines.tsx: -------------------------------------------------------------------------------- 1 | import { CatmullRomLine, Dodecahedron } from "@react-three/drei"; 2 | import { useThree } from "@react-three/fiber"; 3 | import { useEffect, useState } from "react"; 4 | import { socket } from "../socket"; 5 | 6 | import * as THREE from "three"; 7 | 8 | const findClosestPoint = (points: THREE.Vector3[], position: THREE.Vector3) => { 9 | const closestPoint = new THREE.Vector3(); 10 | points.forEach((point) => { 11 | if (point.distanceTo(position) < closestPoint.distanceTo(position)) { 12 | closestPoint.copy(point); 13 | } 14 | }); 15 | return closestPoint; 16 | }; 17 | 18 | // TODO: ensure type consistency, every point/... should be THREE.Vector3 19 | export const Line3D = ({ 20 | points, 21 | setPoints, 22 | setSelectedPoint, 23 | isDrawing, 24 | colorMode, 25 | hoveredId, // if null, hover virtual canvas -> close line 26 | setIsDrawing, 27 | setLineLength, 28 | }: { 29 | points: THREE.Vector3[]; 30 | setPoints: any; 31 | setSelectedPoint: any; 32 | isDrawing: boolean; 33 | colorMode: string; 34 | hoveredId: number | null; 35 | setIsDrawing: any; 36 | setLineLength: (length: number) => void; 37 | }) => { 38 | // a virtual point is between every two points in the points array on the line 39 | const [virtualPoints, setVirtualPoints] = useState([]); 40 | const [lineColor, setLineColor] = useState("black"); 41 | const [pointColor, setPointColor] = useState("black"); 42 | const [virtualPointColor, setVirtualPointColor] = useState("darkcyan"); 43 | const initalTriggerRef = useRef(true); 44 | 45 | useEffect(() => { 46 | // TODO: use bootstrap colors 47 | if ((hoveredId == null || hoveredId === -1) && isDrawing) { 48 | setLineColor("#f01d23"); 49 | setPointColor("#710000"); 50 | } else if (colorMode === "light") { 51 | setLineColor("#454b66"); 52 | setPointColor("#191308"); 53 | setVirtualPointColor("#677db7"); 54 | } else { 55 | setLineColor("#f5fdc6"); 56 | setPointColor("#41521f"); 57 | setVirtualPointColor("#a89f68"); 58 | } 59 | }, [colorMode, hoveredId, isDrawing]); 60 | 61 | const handleClick = (event: any) => { 62 | if (!isDrawing) { 63 | setSelectedPoint(event.object.position.clone()); 64 | } else { 65 | if (hoveredId != null && hoveredId !== -1) { 66 | const point = event.point.clone(); 67 | setPoints([...points, point]); 68 | } else { 69 | setIsDrawing(false); 70 | } 71 | } 72 | }; 73 | 74 | const handleVirtualClick = (index: number, event: any) => { 75 | // make the virtual point a real point and insert it at the correct position 76 | const newPoints = [...points]; 77 | newPoints.splice(index + 1, 0, event.object.position.clone()); 78 | setPoints(newPoints); 79 | setSelectedPoint(event.object.position.clone()); 80 | }; 81 | 82 | useEffect(() => { 83 | if (points.length < 2) { 84 | return; 85 | } 86 | // TODO: do not compute the curve twice 87 | // TODO: clean up types, reuse vector objects here 88 | const curve = new THREE.CatmullRomCurve3(points); 89 | 90 | setLineLength(curve.getLength()); 91 | 92 | const linePoints = curve.getPoints(points.length * 20); 93 | const position = new THREE.Vector3(); 94 | let _newPoints: THREE.Vector3[] = []; 95 | for (let i = 0; i < points.length - 1; i++) { 96 | position.copy(points[i]); 97 | position.lerp(new THREE.Vector3(...points[i + 1]), 0.5); 98 | 99 | _newPoints = [..._newPoints, findClosestPoint(linePoints, position)]; 100 | } 101 | setVirtualPoints(_newPoints); 102 | }, [points]); 103 | 104 | useEffect(() => { 105 | if (initalTriggerRef.current) { 106 | initalTriggerRef.current = false; 107 | return; 108 | } 109 | if (points.length > 0) { 110 | // add the moving point when going from not drawing -> drawing 111 | // this removes a point when triggered initially 112 | // This should not trigger initially, so the initialTriggeRef is 113 | // a strange workaround 114 | if (isDrawing) { 115 | setPoints([...points, points[points.length - 1]]); 116 | } else { 117 | setPoints(points.slice(0, points.length - 1)); 118 | } 119 | } 120 | }, [isDrawing]); 121 | 122 | return ( 123 | <> 124 | {points.map((point, index) => ( 125 | 132 | ))} 133 | {points.length >= 2 && ( 134 | <> 135 | new THREE.Vector3(...point))} 137 | color={lineColor} 138 | lineWidth={2} 139 | segments={Number.parseInt(points.length * 20)} 140 | /> 141 | {virtualPoints.map((point, index) => ( 142 | handleVirtualClick(index, event)} 148 | /> 149 | ))} 150 | 151 | )} 152 | 153 | ); 154 | }; 155 | 156 | import { Plane } from "@react-three/drei"; 157 | import { useFrame } from "@react-three/fiber"; 158 | import { useRef } from "react"; 159 | 160 | export const VirtualCanvas = ({ 161 | isDrawing, 162 | setPoints, 163 | points, 164 | hoveredId, 165 | setHoveredId, 166 | }: { 167 | isDrawing: boolean; 168 | setPoints: any; 169 | points: THREE.Vector3[]; 170 | hoveredId: number | null; 171 | setHoveredId: (id: number | null) => void; 172 | }) => { 173 | const { camera, size } = useThree(); 174 | const [distance, setDistance] = useState(10); 175 | const [canvasVisible, setCanvasVisible] = useState(false); 176 | const canvasRef = useRef(); 177 | 178 | // setDistance to camera <-> last point distance 179 | 180 | // useEffect(() => { 181 | // if (canvasRef.current) { 182 | // // Set the initial size of the plane 183 | // updatePlaneSize(); 184 | // } 185 | // }, [camera, size]); 186 | 187 | const updatePlaneSize = () => { 188 | const vFOV = THREE.MathUtils.degToRad(camera.fov); // Convert vertical FOV to radians 189 | const height = 2 * Math.tan(vFOV / 2) * distance; // Visible height 190 | const width = height * camera.aspect; // Visible width 191 | 192 | canvasRef.current.scale.set(width, height, 1); // Update the scale of the plane 193 | }; 194 | 195 | const onHover = (event: any) => { 196 | if (isDrawing && event.object.visible) { 197 | if (!canvasRef.current) { 198 | return; 199 | } 200 | // this feature is temporarily disabled 201 | // if (event.shiftKey) { 202 | // setHoveredId(canvasRef.current); 203 | // // set opacity of the virtual canvas 204 | // setCanvasVisible(true); 205 | // } else { 206 | // setHoveredId(null); 207 | // console.log("virtual canvas"); 208 | // setCanvasVisible(false); 209 | // } 210 | 211 | // find the index of the closest visible point from the camera 212 | // if nothing is being hovered, this is the virtual canvas 213 | let i = 0; 214 | while ( 215 | i < event.intersections.length && 216 | !event.intersections[i].object.visible 217 | ) { 218 | i++; 219 | } 220 | 221 | setPoints((prevPoints: THREE.Vector3[]) => [ 222 | ...prevPoints.slice(0, prevPoints.length - 1), 223 | event.intersections[i].point, 224 | ]); 225 | } 226 | }; 227 | 228 | useFrame(() => { 229 | if (!isDrawing) { 230 | return; 231 | } 232 | if (canvasRef.current) { 233 | updatePlaneSize(); 234 | // if nothing is hovered, the canvas should be visible 235 | canvasRef.current.visible = 236 | hoveredId == null || 237 | hoveredId === canvasRef.current || 238 | hoveredId === -1; 239 | } 240 | 241 | if (points.length >= 2) { 242 | const lastPoint = points[points.length - 2]; 243 | // the lastPoint is the one we are currently drawing 244 | const dist = camera.position.distanceTo(lastPoint); 245 | setDistance(dist); 246 | } 247 | 248 | if (canvasRef.current) { 249 | const direction = new THREE.Vector3(0, 0, -1).applyQuaternion( 250 | camera.quaternion, 251 | ); 252 | const position = direction.multiplyScalar(distance).add(camera.position); 253 | canvasRef.current.position.copy(position); 254 | 255 | canvasRef.current.lookAt(camera.position); 256 | } 257 | }); 258 | 259 | return ( 260 | <> 261 | {isDrawing && ( 262 | 270 | 277 | 278 | )} 279 | 280 | ); 281 | }; 282 | -------------------------------------------------------------------------------- /app/src/components/meshes.tsx: -------------------------------------------------------------------------------- 1 | import type React from "react"; 2 | import { useEffect, useMemo, useRef } from "react"; 3 | import * as THREE from "three"; 4 | import { BufferGeometryUtils } from "three/examples/jsm/Addons.js"; 5 | import { type ColorRange, type HSLColor, interpolateColor } from "./utils"; 6 | import { useMergedMesh } from "./utils/mergeInstancedMesh"; 7 | 8 | function createArrowMesh() { 9 | const cylinderRadius = 0.04; 10 | const cylinderHeight = 0.6; 11 | const coneRadius = 0.1; 12 | const coneHeight = 0.4; 13 | 14 | const cylinderGeometry = new THREE.CylinderGeometry( 15 | cylinderRadius, 16 | cylinderRadius, 17 | cylinderHeight, 18 | 32, 19 | ); 20 | const coneGeometry = new THREE.ConeGeometry(coneRadius, coneHeight, 32); 21 | 22 | cylinderGeometry.translate(0, cylinderHeight / 2, 0); 23 | coneGeometry.translate(0, cylinderHeight + coneHeight / 2, 0); 24 | 25 | const arrowGeometry = BufferGeometryUtils.mergeGeometries([ 26 | cylinderGeometry, 27 | coneGeometry, 28 | ]); 29 | 30 | return arrowGeometry; 31 | } 32 | 33 | interface ArrowsProps { 34 | start: number[][]; 35 | end: number[][]; 36 | scale_vector_thickness?: boolean; 37 | colormap: HSLColor[]; 38 | colorrange: ColorRange; 39 | opacity?: number; 40 | rescale?: number; 41 | pathTracingSettings: any | undefined; 42 | } 43 | 44 | const Arrows: React.FC = ({ 45 | start, 46 | end, 47 | scale_vector_thickness, 48 | colormap, 49 | colorrange, 50 | opacity = 1.0, 51 | rescale = 1.0, 52 | pathTracingSettings = undefined, 53 | }) => { 54 | const meshRef = useRef(null); 55 | const materialRef = useRef(null); 56 | 57 | const geometry = useMemo(() => { 58 | const _geom = createArrowMesh(); 59 | if (pathTracingSettings?.enabled) { 60 | // make invisible when path tracing is enabled 61 | _geom.scale(0, 0, 0); 62 | } 63 | return _geom; 64 | }, [pathTracingSettings]); 65 | 66 | const instancedGeometry = useMemo(() => { 67 | return createArrowMesh(); 68 | }, []); 69 | 70 | const mergedMesh = useMergedMesh( 71 | meshRef, 72 | instancedGeometry, 73 | pathTracingSettings, 74 | [ 75 | start, 76 | end, 77 | scale_vector_thickness, 78 | colormap, 79 | colorrange, 80 | opacity, 81 | rescale, 82 | ], 83 | ); 84 | 85 | useEffect(() => { 86 | if (!meshRef.current) return; 87 | const matrix = new THREE.Matrix4(); 88 | const up = new THREE.Vector3(0, 1, 0); 89 | const startVector = new THREE.Vector3(); 90 | const endVector = new THREE.Vector3(); 91 | const direction = new THREE.Vector3(); 92 | const quaternion = new THREE.Quaternion(); 93 | 94 | for (let i = 0; i < start.length; i++) { 95 | startVector.fromArray(start[i]); 96 | endVector.fromArray(end[i]); 97 | direction.subVectors(endVector, startVector); 98 | let length = direction.length(); 99 | const color = interpolateColor(colormap, colorrange, length); 100 | // rescale after the color interpolation 101 | length *= rescale; 102 | 103 | const scale = scale_vector_thickness 104 | ? new THREE.Vector3(length, length, length) 105 | : new THREE.Vector3(1, length, 1); 106 | 107 | quaternion.setFromUnitVectors(up, direction.clone().normalize()); 108 | matrix.makeRotationFromQuaternion(quaternion); 109 | matrix.setPosition(startVector); 110 | matrix.scale(scale); 111 | 112 | meshRef.current.setColorAt(i, color); 113 | meshRef.current.setMatrixAt(i, matrix); 114 | } 115 | meshRef.current.instanceMatrix.needsUpdate = true; 116 | }, [start, end, scale_vector_thickness, colormap, colorrange]); 117 | 118 | useEffect(() => { 119 | if (!materialRef.current) return; 120 | materialRef.current.needsUpdate = true; // TODO: check for particles as well 121 | if (!meshRef.current) return; 122 | if (!meshRef.current.instanceColor) return; 123 | meshRef.current.instanceColor.needsUpdate = true; 124 | }, [start, end, scale_vector_thickness, colormap, colorrange]); 125 | 126 | return ( 127 | 128 | 134 | 135 | ); 136 | }; 137 | 138 | export default Arrows; 139 | -------------------------------------------------------------------------------- /app/src/components/overlays.tsx: -------------------------------------------------------------------------------- 1 | import { Button, Card } from "react-bootstrap"; 2 | import { Rnd } from "react-rnd"; 3 | import type { Frame } from "./particles"; 4 | 5 | export const ParticleInfoOverlay = ({ 6 | show, 7 | info, 8 | position, 9 | }: { 10 | show: boolean; 11 | info: { [key: string]: any }; 12 | position: { x: number; y: number }; 13 | }) => { 14 | return ( 15 | <> 16 | {show && ( 17 | 28 | 29 | 30 | {Object.entries(info).map(([key, value]) => ( 31 | <> 32 | {key}: {value} 33 |
34 | 35 | ))} 36 |
37 |
38 |
39 | )} 40 | 41 | ); 42 | }; 43 | 44 | export const SceneInfoOverlay = ({ 45 | frame, 46 | setShowParticleInfo, 47 | }: { 48 | frame: Frame; 49 | setShowParticleInfo: any; 50 | }) => { 51 | return ( 52 | 67 | 75 | 76 | Info 77 |