# VisFEM > VisFEM: Web visualization of FEM models The complete documentation from https://visfem.de/docs, one section per page. --- ![VisFEM](images/favicon/visfem-100x100.png) # VisFEM: Web visualization of FEM models [![GitHub Actions CI/CD Status](https://github.com/matthiaskoenig/visfem/actions/workflows/ci-cd.yml/badge.svg)](https://github.com/matthiaskoenig/visfem/actions/workflows/ci-cd.yml) [![Documentation](https://img.shields.io/badge/docs-visfem-009688.svg)](https://visfem.de/docs/) [![Version](https://img.shields.io/pypi/v/visfem.svg)](https://pypi.org/project/visfem/) [![Python Versions](https://img.shields.io/pypi/pyversions/visfem.svg)](https://pypi.org/project/visfem/) [![MIT License](https://img.shields.io/pypi/l/visfem.svg)](https://opensource.org/licenses/MIT) [![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.19220183.svg)](https://doi.org/10.5281/zenodo.19220183) VisFEM is an interactive web app for visualizing Finite Element Method (FEM) simulation results, with a focus on biomedical applications. It runs in the browser on top of [trame](https://kitware.github.io/trame/), [PyVista](https://pyvista.org/) and [VTK](https://vtk.org/): the meshes are loaded and prepared on the server, the browser renders them with vtk.js. The source code is available from [https://github.com/matthiaskoenig/visfem](https://github.com/matthiaskoenig/visfem), the public instance runs at [app.visfem.de](https://app.visfem.de). ![VisFEM showing the four-chamber heart model](images/visfem_heart.webp) ## Features - **[Datasets](datasets.md)** - a dataset is a folder of mesh files plus a JSON descriptor with its scientific context: principal investigator, institution, organ system, references. VisFEM discovers every dataset of a data directory and groups them by organ system. - **[Renderers](renderers.md)** - seven built-in renderers cover static surfaces, multi-part geometries, region-coloured meshes with fibre glyphs, scalar fields and time series from XDMF, PVD, VTK series or LS-DYNA d3plot files. - **Mesh formats** - everything [meshio](https://github.com/nschloe/meshio) and PyVista read, e.g., VTK, VTU, STL, OBJ, PLY, XDMF+HDF5, PVD, plus FEBio 2.x models and LS-DYNA d3plot databases. - **Interaction** - categorical palettes and continuous colormaps, opacity, region legends, playback of time series, screenshots, a light and a dark theme, and WebXR for viewing a model in virtual reality. - **[Command line](usage.md)** - `visfem` launches the app and scaffolds, documents and validates datasets. - **[Example data](example-data.md)** - `visfem fetch-ircadb` and `visfem fetch-openknee` download two public datasets ready to view. ## Quickstart ```bash pip install visfem visfem ``` The app opens in the browser. Point `DATA_DIR` at a data directory to view your own datasets, see [Running the app](usage.md). ## How to cite [![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.19220183.svg)](https://doi.org/10.5281/zenodo.19220183) If you use VisFEM, please cite the software repository: > Elias, M. & König, M. (2026). *VisFEM: Web visualization of FEM models.* Zenodo. [https://doi.org/10.5281/zenodo.19220183](https://doi.org/10.5281/zenodo.19220183) The citation information is also available in the [CITATION.cff](https://github.com/matthiaskoenig/visfem/blob/develop/CITATION.cff) of the repository. ## License - Source code: [MIT](https://opensource.org/license/MIT) - Documentation: [CC BY-SA 4.0](http://creativecommons.org/licenses/by-sa/4.0/) The example datasets keep the licenses of their providers, see [Example data](example-data.md). The software ships as is; the MIT license in `LICENSE` disclaims all warranties, including merchantability and fitness for a particular purpose. ## Funding The German Research Foundation (DFG) supports Matthias König within the Research Unit Programme FOR 5151 "QuaLiPerF (Quantifying Liver Perfusion-Function Relationship in Complex Resection - A Systems Medicine Approach)" by grant number 436883643 and by grant number 465194077 (Priority Programme SPP2311, Subproject SimLivA). The Federal Ministry of Education and Research (BMBF, Germany) supported Matthias König within the research network Systems Medicine of the Liver (LiSyM, grant number 031L0054). The German Research Foundation (DFG) supports Michelle Elias under grant number 465194077 (Priority Programme SPP2311). © 2026 Michelle Elias & Matthias König --- # Installation VisFEM requires python >= 3.14 and is available from [pypi](https://pypi.org/project/visfem/). It is an application, so it is best installed as a tool into an environment of its own. ## With uv [uv](https://docs.astral.sh/uv/) is the recommended way to install VisFEM. As a tool, it gets an isolated environment and the `visfem` command is put on the `PATH`: ```bash uv tool install visfem visfem --help ``` `uvx visfem` runs it without installing it permanently. ## With pip ```bash python -m venv .venv source .venv/bin/activate # Linux and macOS .venv\Scripts\activate # Windows pip install visfem ``` ## Development version The current state of the `develop` branch is installed directly from GitHub: ```bash uv tool install "visfem @ git+https://github.com/matthiaskoenig/visfem.git@develop" ``` or, with pip, ```bash pip install git+https://github.com/matthiaskoenig/visfem.git@develop ``` To work on the repository itself, with the test and documentation tooling, see [Contributing](development.md). ## Dependencies The app is built with [trame](https://kitware.github.io/trame/) and its [vuetify](https://github.com/Kitware/trame-vuetify) and [vtk](https://github.com/Kitware/trame-vtk) widgets. The meshes are read with [PyVista](https://pyvista.org/), [VTK](https://vtk.org/), [meshio](https://github.com/nschloe/meshio) and [h5py](https://www.h5py.org/), the dataset descriptors are validated with [pydantic](https://docs.pydantic.dev/). All of them ship wheels for Linux, macOS and Windows, so no compiler is needed. The server renders off screen. On a headless Linux machine, e.g., in a container, VTK needs an OpenGL implementation such as OSMesa or EGL, see the [Dockerfile](https://github.com/matthiaskoenig/visfem/blob/develop/Dockerfile) of the deployment. --- # Running the app VisFEM runs as a local web server which the browser connects to; the `visfem` command starts it and prepares the datasets it shows. ## Launching ```bash visfem ``` starts the web app and opens it in the browser. `visfem` without a command is the same as `visfem serve`, which accepts the options of the trame server: ```bash visfem serve --server --port 9000 # no browser, fixed port visfem serve --host 0.0.0.0 # listen on all interfaces of the machine visfem serve --help # every option of the trame server ``` The server listens on `localhost` by default. A single `visfem serve` process is one session which every connected browser shares; a public instance runs behind the trame launcher, which starts one process per visitor, see [Deployment](deployment.md). ## Data directory VisFEM shows the datasets of a data directory. It reads the `datasets/` folder inside the directory that `DATA_DIR` names: ```bash export DATA_DIR=/path/to/your/data # datasets are read from /path/to/your/data/datasets visfem ``` Without `DATA_DIR`, a checkout of the repository uses its own `data/` folder and an installed VisFEM uses `./visfem_data` in the working directory, where the fetch commands of the [example data](example-data.md) put their datasets. How a dataset is laid out is described in [Datasets](datasets.md). ## Environment variables | variable | effect | | --- | --- | | `DATA_DIR` | data directory, the datasets are read from its `datasets/` folder; `VISFEM_DATA_DIR` is an alias | | `VISFEM_DATASETS` | comma separated allowlist of dataset keys, e.g. `heart,ircadb`; the other datasets stay hidden. Empty shows every dataset | | `VISFEM_LOG_LEVEL` | level of the log output, e.g. `DEBUG` or `INFO`; the default is `WARNING` | | `VISFEM_PRELOAD` | `1` loads every mesh into memory at startup, so the first selection of a dataset does not wait for the disk | ## Opening a dataset by link The `model` query parameter, or its alias `dataset`, selects a dataset when the app opens, e.g. [app.visfem.de/?model=heart](https://app.visfem.de/?model=heart). The value is the key of the dataset, i.e., the file name of its JSON descriptor without `.json`. The tiles of the landing page link to the models this way. ## Command line Besides `serve`, the `visfem` command documents, scaffolds and validates datasets and downloads the example data: | command | purpose | | --- | --- | | `visfem renderers` | the built-in renderers and what each one draws | | `visfem schema` | the fields of a dataset descriptor | | `visfem example ` | a minimal valid descriptor for a renderer | | `visfem new-dataset --renderer ` | scaffold `datasets//` with a descriptor template | | `visfem validate-data` | validate every descriptor and check that the files it references exist | | `visfem fetch-ircadb` | download the 3D-IRCADb-01 dataset, see [Example data](example-data.md) | | `visfem fetch-openknee` | download the Open Knee(s) geometries, see [Example data](example-data.md) | `visfem --help` lists the options of a command. --- # Datasets A dataset is a folder of mesh files plus a JSON descriptor. The descriptor carries the scientific context which the app shows next to the model, and optionally a `render` block which says how to draw the meshes. ## Layout The datasets live in the `datasets/` folder of the data directory, see [Data directory](usage.md#data-directory). Every JSON file below `datasets/` is a descriptor, except the `*.meta.json` files, which are caches of the mesh metadata written by the app. The file name without `.json` is the key of the dataset, e.g. in the `?model=` link; it has to be unique. ```text $DATA_DIR/ datasets/ my_model/ my_model.json # the descriptor, key "my_model" mesh.vtu # the meshes the descriptor refers to ``` `data_path` is the folder holding the meshes, relative to `datasets/`. For a PVD dataset (`mesh_format` is `PVD`) it is the `.pvd` file itself. It must stay inside `datasets/`: absolute paths and `..` are rejected. The quickest start is the scaffold of the command line: ```bash export DATA_DIR=/path/to/your/data visfem new-dataset my_model --renderer region_id # datasets/my_model/my_model.json # copy the mesh files into datasets/my_model/ and edit the JSON visfem validate-data # check the JSON and the referenced files visfem # my_model now appears ``` ## Descriptor fields | field | required | content | | --- | --- | --- | | `data_path` | yes | folder under `datasets/` holding the meshes, usually the key; the `.pvd` file for a PVD dataset | | `name` | yes | display name | | `pi` | yes | principal investigator | | `institution` | yes | list of institutions, at least one | | `biological_scale` | yes | one of `subcellular`, `cell`, `tissue`, `organ`, `organ_system` | | `organ_system` | yes | list of `abdominal`, `bone`, `heart`, `kidney`, `liver`, `lung`, `musculoskeletal`, `torso`, `vasculature`, at least one; the dataset tree lists the dataset under the first one, the dataset info shows all of them | | `description` | yes | short description, at most 500 characters | | `mesh_format` | yes | display label of the format, e.g. `VTK`, `VTU`, `STL`, `XDMF+HDF5`, `PVD` | | `references` | no | list of DOIs, URLs or citations | | `spp_project` | no | title of the SPP2311 project the dataset belongs to | | `spp_badge` | no | `true` shows the SPP2311 badge even without `spp_project` | | `subgroup` | no | label of a sub group within the organ system, e.g. `Vessel` | | `sort_order` | no | position within the organ system or sub group; without it the datasets are sorted by name | | `labels_file` | no | `LabelIDs.txt` file with the names of the regions per mesh, next to the meshes | | `render` | no | how to draw the dataset, see [Renderers](renderers.md); omitted fields are inferred | Unknown fields are rejected, so a typo does not go unnoticed. `visfem schema` prints the same reference on the command line. ## Example A region coloured mesh, as printed by `visfem example region_id`: ```json { "data_path": "my_dataset", "name": "My Dataset", "pi": "Your Name", "institution": ["Your Lab"], "biological_scale": "organ", "organ_system": ["bone"], "description": "Short description of the model.", "mesh_format": "VTK", "references": [], "render": { "renderer": "region_id", "mesh_file": "mesh.vtk", "region_array": "PartId", "categorical_palette": "clinical", "region_labels": { "1": ["Region one"], "2": ["Region two"] } } } ``` ## Validation ```bash visfem validate-data ``` validates every descriptor of the data directory against the schema and checks that the files it refers to exist. The app applies the same validation on startup: an invalid descriptor is skipped with a warning in the log, the other datasets load as usual. --- # Renderers The `render` block of a descriptor selects one of the built-in renderers and configures it. The whole block is optional: without it, the renderer is inferred from `mesh_format` and the files of the dataset, which covers a single surface mesh and an XDMF or PVD time series. `visfem example ` prints a ready to edit descriptor for every renderer. ## Built-in renderers | renderer | draws | | --- | --- | | `surface` | one solid coloured mesh (STL, VTU, OBJ), the default for a single static surface | | `multi_part` | several named part files merged and coloured together, e.g. arteries and veins | | `region_id` | one mesh coloured by an integer cell array (regions or materials), with optional fibre glyphs | | `scalar_field` | a static mesh coloured by a selectable scalar field, as a continuous ramp and/or as categorical zones | | `timeseries` | a scalar field animated over time, from XDMF, PVD, a flat VTK series or an LS-DYNA d3plot | | `pvd_phase_series` | region coloured phase surfaces per patient, driven by a `.pvd` manifest | | `patient_organs` | per patient folders of per organ `.vtk` meshes, the 3D-IRCADb-01 layout | ## Inference When `renderer` is not set, it is inferred in this order: 1. `series` or `database` is set: `timeseries` 2. `mesh_format` is `PVD`, contains `XDMF`, or the folder holds XDMF files: `timeseries` 3. the folder holds `patient_` folders: `pvd_phase_series` when a patient has a phase `.pvd`, `patient_organs` when a patient has several `.vtk` meshes 4. otherwise: `surface`, with `mesh_file` set to the only surface mesh of the folder ## Fields of the render block | field | renderers | content | | --- | --- | --- | | `renderer` | all | one of the renderers above | | `mesh_file` | `surface`, `region_id`, `scalar_field` | mesh file relative to the dataset folder; required by `region_id` and `scalar_field` | | `mesh_candidates` | `surface` | fallbacks tried in order, e.g. an `.stl` before an `.obj` | | `parts` | `multi_part` | list of `{"stem": ..., "label": ...}`, the stem is the path without extension relative to the dataset folder | | `part_extensions` | `multi_part` | extensions tried for every stem, default `.ply`, `.vtk`, `.vtu`, `.stl`, `.obj` | | `tube` | `multi_part` | `{"enabled": true, "n_sides": 8, "stride": 1}` draws line cells as tubes | | `categorical_palette` | all | `clinical`, `paired` (default), `tableau`, `wong` or `tol_bright` | | `continuous_cmap` | scalar fields | `viridis` (default), `plasma`, `cividis`, `inferno` or `turbo` | | `region_array` | `region_id` | integer cell array with the region ids, e.g. `Material` or `PartId` | | `region_labels` | region coloured | names per region id, e.g. `{"1": ["Ventricles"]}`; overrides `labels_file` | | `labels_mesh_key` | region coloured | the mesh section of `labels_file` to use, e.g. `M.vtu` | | `fiber` | `region_id` | `{"array": "Fiber", "stride": 5, "scale": 1.5}` draws glyphs oriented by a vector cell array | | `fields` | `scalar_field`, `timeseries` | ordered list of `{"name": ..., "label": ...}`, the selectable fields; required by `scalar_field`, a time series without it offers every field of its mesh | | `exclude_fields` | `timeseries` | discovered fields to drop | | `default_field` | `scalar_field`, `timeseries` | field shown first; without it the first listed or discovered field | | `categorical_fields` | `scalar_field` | fields drawn as discrete zones with a region legend | | `percentile_clamp` | `scalar_field` | upper percentile the continuous ramp is clamped to, 1 to 100, default 95 | | `series` | `timeseries` | glob of the per step mesh files relative to the dataset folder, natural sorted, e.g. `frame_*.vtk` | | `database` | `timeseries` | `"d3plot"`: the dataset folder is an LS-DYNA d3plot database, named by its master file `d3plot` | | `solid` | `pvd_phase_series` | `true` draws every phase in one colour | The names of the palettes and colormaps are checked when the descriptor is read, so an unknown name fails `visfem validate-data`. --- # Example data Two public datasets are downloaded and laid out by the command line, so VisFEM can be tried without data of your own. Both commands write into the data directory, i.e., `--data-dir`, else `DATA_DIR`, else the default of the app (`data/` of a source checkout, else `./visfem_data`), and print the `DATA_DIR` to launch the app with. ## 3D-IRCADb-01 The [3D-IRCADb-01](https://www.ircad.fr/research/data-sets/liver-segmentation-3d-ircadb-01/) dataset of IRCAD contains the CT segmented organ surface meshes of 20 patients (10 women, 10 men), hepatic tumours in 75% of the cases, covering up to 47 organ types per patient. ```bash export DATA_DIR=/path/to/your/data visfem fetch-ircadb # download (~3 GB) and lay out the dataset visfem # '3D-IRCADb-01' appears in the sidebar ``` `--keep-archive` keeps the downloaded archive below `/downloads/`. IRCAD provides the 3D-IRCADb-01 dataset under its own license terms. ## Open Knee(s) [Open Knee(s)](https://simtk.org/projects/openknee) of the Cleveland Clinic provides the segmented tissue surfaces of eight simulation ready specimens (16 STL surfaces each) and the Generation 1 tibiofemoral finite element model. The command fetches them from the public SVN repository of the project, which serves anonymous checkouts, and converts the FEBio model to VTU: ```bash visfem fetch-openknee # all eight specimens and the Generation 1 model (~220 MB) visfem fetch-openknee --specimens oks003 --no-gen1 # a single specimen visfem # the knees appear under 'Musculoskeletal' ``` The Open Knee(s) Development Team provides Open Knee(s) under Creative Commons terms (CC BY-SA 3.0 in the repository, CC BY 4.0 on the project page). Cite Chokhandre et al. (2023), [doi:10.1007/s10439-022-03074-0](https://doi.org/10.1007/s10439-022-03074-0), for the specimens and Erdemir (2016), [doi:10.1055/s-0035-1564600](https://doi.org/10.1055/s-0035-1564600), for the Generation 1 model. --- # Deployment Host VisFEM on a Linux server: - Landing page → `https://visfem.de/` - Web app → `https://app.visfem.de/` The setup is three pieces behind Caddy: a static landing page, the app in Docker, and Caddy routing by hostname and handling HTTPS. Until the server is live, `visfem.de` is served by GitHub Pages: the `documentation` workflow publishes the interim landing page of [`deploy/pages/`](https://github.com/matthiaskoenig/visfem/tree/develop/deploy/pages) at the root and this documentation below [`/docs/`](https://visfem.de/docs/), see [Documentation](development.md#documentation). Section 2 repoints the `visfem.de` DNS record from GitHub Pages to the server IP. Before that switch, move the custom domain of the GitHub Pages site to `docs.visfem.de` and set `site_url` in `zensical.toml` accordingly, so the documentation stays online. --- ## 1. Overview ``` ┌──────────────────────────────────────────┐ Internet ─────────► │ Caddy (HTTPS + routing by hostname) │ └──┬───────────────────────┬────────────────┘ │ │ visfem.de app.visfem.de │ │ ┌─────────▼─────────┐ ┌──────────▼─────────────────┐ │ static landing │ │ Docker container "visfem" │ │ /var/www/landing │ │ (the VisFEM web app) │ └───────────────────┘ └──────────┬─────────────────┘ │ reads (read-only) /opt/visfem/data/datasets/ ``` - Landing page: static files ([`deploy/landing/`](https://github.com/matthiaskoenig/visfem/tree/develop/deploy/landing)). Tiles link to the app with `?model=` to open a specific model. - Web app: Docker container `visfem`. Each visitor gets an isolated backend process; concurrency is set in [`setup/launcher.json`](https://github.com/matthiaskoenig/visfem/blob/develop/setup/launcher.json) (section 9). - Caddy: routes by hostname and manages Let's Encrypt certificates. Requirements: - Linux server (Ubuntu/Debian assumed) with a public IP and `sudo`. - Control over the `visfem.de` DNS records. - Ports 80 and 443 open (80 for the certificate challenge, 443 for HTTPS): ```bash sudo ufw allow 80,443/tcp # only if ufw is in use ``` - The dataset files (meshes), which live on the lab NAS. Section 5 stages them. --- ## 2. DNS Create two A records pointing at the server IP, one per hostname Caddy routes on: | Type | Name | Value | |------|------------------|-----------------| | A | `visfem.de` | `` | | A | `app.visfem.de` | `` | If `visfem.de` points at GitHub Pages, change it to the server IP. `app.visfem.de` is new. Add matching `AAAA` records if the server has IPv6. Caddy issues certificates once both names resolve to the server. Verify: ```bash dig +short visfem.de dig +short app.visfem.de ``` Both print the server IP once DNS has propagated. --- ## 3. Server prerequisites ### Docker + Compose ```bash curl -fsSL https://get.docker.com | sudo sh sudo usermod -aG docker $USER # grants $USER docker access; re-login to apply ``` Re-login (or `newgrp docker`), then confirm: ```bash docker --version docker compose version ``` ### Caddy ```bash sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https curl curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' \ | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' \ | sudo tee /etc/apt/sources.list.d/caddy-stable.list sudo apt update sudo apt install -y caddy ``` Caddy runs as a systemd service and reads `/etc/caddy/Caddyfile` (written in section 7). Confirm with `systemctl status caddy`. --- ## 4. Code checkout ```bash sudo mkdir -p /opt && cd /opt sudo git clone https://github.com/matthiaskoenig/visfem sudo chown -R $USER:$USER /opt/visfem cd /opt/visfem ``` All later commands run from `/opt/visfem`. --- ## 5. Dataset files Copy the dataset files from the lab NAS into the repo's data folder. ```bash # Set the source to the NAS mount or host; trailing slashes copy folder contents into the target folder. rsync -av :/path/to/data/datasets/ /opt/visfem/data/datasets/ ``` Stage all datasets. The allowlist in `compose.deploy.yml` (section 6) selects the public subset. --- ## 6. Web app ```bash cd /opt/visfem docker compose -f compose.deploy.yml up -d --build ``` This reads [`compose.deploy.yml`](https://github.com/matthiaskoenig/visfem/blob/develop/compose.deploy.yml) and: - builds the image `visfem:local` from the [`Dockerfile`](https://github.com/matthiaskoenig/visfem/blob/develop/Dockerfile); - starts the container `visfem` on `127.0.0.1:8080`, the loopback port Caddy proxies to; - mounts `./data` read-only and points the app at it via `DATA_DIR`; - sets `TRAME_USE_HOST: "wss://app.visfem.de"`, the public address the browser opens its WebSocket to; - restricts the public app to the datasets listed in `VISFEM_DATASETS`, a comma-separated allowlist of dataset keys (a key is a descriptor's `.json` filename stem, e.g. `heart`). Unlisted datasets stay hidden. Confirm it is up: ```bash docker compose -f compose.deploy.yml ps # STATUS: Up curl -I http://127.0.0.1:8080/ # HTTP/1.1 200 ``` Change a setting by editing [`compose.deploy.yml`](https://github.com/matthiaskoenig/visfem/blob/develop/compose.deploy.yml) and re-running `docker compose -f compose.deploy.yml up -d`. --- ## 7. Caddy configuration Copy the landing page to Caddy's web root: ```bash sudo mkdir -p /var/www/landing sudo cp -r /opt/visfem/deploy/landing/. /var/www/landing/ ``` The landing page ([`deploy/landing/`](https://github.com/matthiaskoenig/visfem/tree/develop/deploy/landing)) ships with tiles that point at `https://app.visfem.de/?model=...`. Back up any existing Caddy config, then write the new one: ```bash sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.bak-$(date +%Y%m%d-%H%M%S) 2>/dev/null || true sudo tee /etc/caddy/Caddyfile >/dev/null <<'EOF' visfem.de { root * /var/www/landing file_server } app.visfem.de { # the trame client reads its connection settings from the URL as well; a # link must not point a visitor's browser at another session server @session_override expression `{query.sessionURL} != "" || {query.sessionManagerURL} != "" || {query.secret} != ""` respond @session_override "Bad Request" 400 header { # the browser connects to this host only, the app is never framed Content-Security-Policy "connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" Strict-Transport-Security "max-age=31536000" X-Content-Type-Options "nosniff" Referrer-Policy "strict-origin-when-cross-origin" -Server } reverse_proxy 127.0.0.1:8080 } EOF ``` This serves the static landing page on `visfem.de` and forwards `app.visfem.de` traffic to the app container. The app block rejects links which override the session settings of the trame client and sends the security headers; `connect-src 'self'` keeps the browser from opening a WebSocket to any other host. Validate and load it: ```bash caddy validate --config /etc/caddy/Caddyfile sudo systemctl reload caddy ``` Caddy obtains and renews Let's Encrypt certificates for both names automatically, provided the section 2 DNS points at this server and ports 80/443 are open. --- ## 8. Verification - `https://visfem.de/` shows the landing page with dataset tiles. - `https://app.visfem.de/` loads the app; selecting a dataset renders its mesh. - Clicking a landing-page tile opens the app with that model loaded. The first request to each hostname may take a few seconds while Caddy fetches the certificate. Logs: ```bash cd /opt/visfem && docker compose -f compose.deploy.yml logs -f # app sudo journalctl -u caddy -f # Caddy ``` --- ## 9. Operation Run from `/opt/visfem`: ```bash docker compose -f compose.deploy.yml up -d # start / apply config changes docker compose -f compose.deploy.yml restart # restart docker compose -f compose.deploy.yml logs -f # logs docker compose -f compose.deploy.yml down # stop docker compose -f compose.deploy.yml up -d --build # rebuild after code/config changes ``` - `restart: unless-stopped` brings the container back after a reboot or crash. - Update the code with `git pull`, then rebuild (`docker compose -f compose.deploy.yml up -d --build`). Staged data and the landing page stay in place. - Concurrency: set in [`setup/launcher.json`](https://github.com/matthiaskoenig/visfem/blob/develop/setup/launcher.json), field `resources` → `port_range`. Each visitor holds one port from the range; the port count is the session cap. The default `[9001, 9006]` is 6 sessions. Widening the range (e.g. `[9001, 9020]`) needs a rebuild: `launcher.json` is baked into the image. `"timeout": 60` in the same file is how long the launcher waits for a new session to start. A session ends 30 seconds after its last client disconnected, set by `--timeout` in [`setup/apps.yml`](https://github.com/matthiaskoenig/visfem/blob/develop/setup/apps.yml); a session which is requested but never connected holds its port for that long, too. Memory scales with sessions and dataset size; check `docker stats visfem` under real use before widening. - Abuse: anybody can request sessions from the launcher without logging in, and 6 requests take every port for 30 seconds. Caddy has no rate limiting built in; if sessions run out without real visitors, rate limit `/paraview` with the [caddy-ratelimit](https://github.com/mholt/caddy-ratelimit) module or put the site behind basic auth (Appendix A). Adding `mem_limit`, `cpus` and `pids_limit` to `compose.deploy.yml` caps what the container can take from the server. --- ## Appendix A: private site during testing Password-protect with Caddy basic auth. Generate a hash: ```bash caddy hash-password --plaintext 'choose-a-password' ``` Wrap either site block in `/etc/caddy/Caddyfile` with `basic_auth`: ```caddy app.visfem.de { basic_auth { visfem } reverse_proxy 127.0.0.1:8080 } ``` Reload Caddy (`sudo systemctl reload caddy`). Remove the block and reload to go public. --- ## Appendix B: deployment files in the repo - [`Dockerfile`](https://github.com/matthiaskoenig/visfem/blob/develop/Dockerfile): builds the app image from the `kitware/trame:uv` base (Python 3.14) and installs OSMesa for software 3D rendering. - [`compose.deploy.yml`](https://github.com/matthiaskoenig/visfem/blob/develop/compose.deploy.yml): the production Compose file (image name, port binding, data mount, and the `DATA_DIR` / `TRAME_USE_HOST` / `VISFEM_DATASETS` settings). - [`setup/launcher.json`](https://github.com/matthiaskoenig/visfem/blob/develop/setup/launcher.json): the trame launcher config (concurrency `port_range`, start `timeout`), baked into the image at build. - [`setup/apps.yml`](https://github.com/matthiaskoenig/visfem/blob/develop/setup/apps.yml): the session command of the launcher, `visfem` with the idle `--timeout`. - [`setup/requirements.txt`](https://github.com/matthiaskoenig/visfem/blob/develop/setup/requirements.txt): the pinned dependencies of the image, exported from `uv.lock`, see [Deployment requirements](development.md#deployment-requirements). - [`deploy/landing/`](https://github.com/matthiaskoenig/visfem/tree/develop/deploy/landing): the static landing page. - `src/visfem/engine/discovery.py`: reads `DATA_DIR` and the `VISFEM_DATASETS` allowlist. - `src/visfem/log.py`: reads `VISFEM_LOG_LEVEL` (default `WARNING`), the level of the container log. - `src/visfem/app.py` and `src/visfem/ui/layout.py`: handle the `?model=` URL parameter and on-demand mesh loading. --- # API reference The API reference is generated from the docstrings of the package. VisFEM is an application: the reference documents its internals for development, not a stable library interface. ## Application | module | description | | --- | --- | | [app](app.md) | the trame application, its state and the callbacks of the UI | | [cli](cli.md) | the `visfem` command line | | [models](models.md) | the pydantic models of the dataset descriptors and the mesh metadata | | [mesh](mesh.md) | loading of meshes and time series, metadata extraction and caching | | [febio](febio.md) | reader of the geometry of FEBio 2.x models | | [log](log.md) | logging of the app | ## visfem.engine | module | description | | --- | --- | | [discovery](engine/discovery.md) | discovery of the datasets of the data directory | | [renderers](engine/renderers.md) | the renderer registry and the resolution of the `render` block | | [scene](engine/scene.md) | the plotter, its actors and the scalar bar | | [selection](engine/selection.md) | handlers of the dataset selection | | [playback](engine/playback.md) | autoplay of time series and the vtk.js warmup | | [xr_manager](engine/xr_manager.md) | WebXR sessions | | [colors](engine/colors.md) | colour utilities and the background colours | | [palettes](engine/palettes.md) | categorical palettes and continuous colormaps | ## visfem.ui | module | description | | --- | --- | | [layout](ui/layout.md) | assembly of the page | | [toolbar](ui/toolbar.md) | the toolbar | | [left_panel](ui/left_panel.md) | dataset tree and dataset info | | [right_panel](ui/right_panel.md) | view, colour, regions, fields and playback | | [footer](ui/footer.md) | the footer | | [widgets](ui/widgets.md) | widgets shared by the panels | | [theme](ui/theme.md) | design tokens and CSS fragments | --- # visfem.app Trame web application for FEM mesh visualization. Usage: visfem, or python -m visfem.app. VISFEM_PRELOAD=1 preloads every mesh; VISFEM_LOG_LEVEL sets the log level (default WARNING). ## class `VisfemApp(server: object = None) -> None` Main Trame application for VisFEM. ### `VisfemApp.apply_clim_override(self) -> None` Apply user-entered scalar range to the active continuous field. ### `VisfemApp.reset_camera(self) -> None` Restore the camera to the initial pose captured at dataset load. ### `VisfemApp.reset_category_colors(self, name: str | None = None) -> None` Clear the override of category *name*, or every override in scope when None, and recolour. ### `VisfemApp.select_color_scheme(self, name: str) -> None` Set *name* as the active palette or colormap and re-render the scene. ### `VisfemApp.select_dataset(self, key: str) -> None` Switch to dataset *key*. ### `VisfemApp.select_patient(self, dataset_key: str, patient: int) -> None` Switch to patient *patient* of dataset *dataset_key*. ### `VisfemApp.select_scalar_field(self, field: str) -> None` Re-render the active dataset with scalar field *field*. ### `VisfemApp.select_step(self, step: int) -> None` Show time step *step* of the active dataset. ### `VisfemApp.select_xdmf(self, key: str, stem: str) -> None` Switch to XDMF file *stem* of dataset *key*. ### `VisfemApp.set_category_color(self, name: str, color: str) -> None` Override category *name* with *color* and recolour the scene in place. ### `VisfemApp.take_screenshot(self) -> None` Trigger vtk.js canvas capture and browser PNG download. ### `VisfemApp.toggle_autoplay(self) -> None` Start or stop automatic step playback. ### `VisfemApp.toggle_color_reversed(self) -> None` Flip the color order of the active palette or colormap and re-render. ### `VisfemApp.toggle_left_panel(self) -> None` Toggle the left dataset panel open/closed. ### `VisfemApp.toggle_right_panel(self) -> None` Toggle the right view-controls panel open/closed. ### `VisfemApp.toggle_theme(self) -> None` Toggle between dark and light mode. ## function `main() -> None` Build the app, preload every mesh when VISFEM_PRELOAD=1, and start the server. --- # visfem.cli Command-line interface for VisFEM. Usage: visfem # launch the web app (same as `visfem serve`) visfem renderers # list the built-in renderers and what each is for visfem schema # reference of the dataset JSON fields visfem example # print a minimal valid dataset JSON for a renderer visfem new-dataset NAME # scaffold a dataset folder and JSON template visfem validate-data # validate every dataset JSON and its referenced files visfem fetch-ircadb # download the public 3D-IRCADb-01 example dataset visfem fetch-openknee # download the Open Knee(s) knee geometries from SimTK The data-authoring commands read and write the data directory: DATA_DIR or VISFEM_DATA_DIR, else the bundled data. ## function `build_parser() -> 'argparse.ArgumentParser'` Return the visfem parser; each subparser sets ``func`` and the ``commands`` default lists every subcommand. ## function `cmd_example(args: 'argparse.Namespace') -> 'int'` Print the minimal valid dataset JSON for args.renderer; returns 0. ## function `cmd_fetch_ircadb(args: 'argparse.Namespace') -> 'int'` Download 3D-IRCADb-01 and lay it out as a VisFEM dataset; returns 1 on error, else 0. ## function `cmd_fetch_openknee(args: 'argparse.Namespace') -> 'int'` Fetch Open Knee(s) datasets; returns 2 for an unknown specimen, 1 on error, else 0. ## function `cmd_new_dataset(args: 'argparse.Namespace') -> 'int'` Scaffold datasets//.json from a template; returns 1 when it exists or name is no valid key, else 0. ## function `cmd_renderers(_args: 'argparse.Namespace') -> 'int'` Print the built-in renderers with a one-line description each; returns 0. ## function `cmd_schema(_args: 'argparse.Namespace') -> 'int'` Print a reference of the dataset JSON fields; returns 0. ## function `cmd_validate_data(_args: 'argparse.Namespace') -> 'int'` Validate every dataset JSON and check its mesh files exist; returns 1 on a problem, else 0. ## function `main(argv: 'list[str] | None' = None) -> 'int'` Run the CLI on *argv* (default sys.argv[1:]) and return the exit code. ## function `openknee_gen1_descriptor() -> 'dict'` Return the dataset JSON for the Generation 1 tibiofemoral FEBio model. ## function `openknee_specimen_descriptor(specimen: 'str') -> 'dict'` Return the dataset JSON for one Generation 2 *specimen*. ## function `parse_connectivity(xml_text: 'str') -> 'dict[str, str]'` Map part code -> meshed surface filename from an Open Knee(s) Connectivity.xml *xml_text*. --- # visfem.models Pydantic models for mesh and project metadata validation. ## class `BiologicalScale(*values)` Biological scale at which the model operates. ## class `FiberOptions(*, array: str = 'Fiber', stride: Annotated[int, Ge(ge=1)] = 5, scale: float = 1.5) -> None` Fibre glyph overlay for the region_id renderer. ## class `FieldInfo(*, center: Literal['point', 'cell'], shape: list[int]) -> None` Descriptor for one scalar/vector/tensor field on a mesh. ## class `MeshMetadata(*, schema_hash: str = '', source_signature: str = '', format: str, n_steps: Annotated[int, Ge(ge=1)], times: list[float], n_points: Annotated[int, Ge(ge=0)], n_cells: Annotated[int, Ge(ge=0)], cell_types: list[str], fields: dict[str, visfem.models.FieldInfo], scalar_bounds: dict[str, list[float]] = {}) -> None` Per-mesh metadata generated from the mesh file and cached as a .meta.json sidecar. ## class `OrganSystem(*values)` Organ system addressed by the dataset. ## class `PartSpec(*, stem: str, label: str) -> None` One named part for the multi_part renderer. ## class `ProjectMetadata(*, data_path: str, labels_file: str | None = None, name: str, pi: str, institution: Annotated[list[str], MinLen(min_length=1)], biological_scale: visfem.models.BiologicalScale, organ_system: Annotated[list[visfem.models.OrganSystem], MinLen(min_length=1)], description: Annotated[str, MaxLen(max_length=500)], mesh_format: str, references: list[str] = [], spp_project: str | None = None, spp_badge: bool = False, subgroup: str | None = None, sort_order: int | None = None, render: visfem.models.RenderConfig | None = None) -> None` Hand-authored per-dataset descriptor with the scientific context shown in the UI, stored in data/datasets/**/*.json. ## class `RenderConfig(*, renderer: visfem.models.RendererName | None = None, mesh_file: str | None = None, mesh_candidates: list[str] = [], parts: list[visfem.models.PartSpec] = [], part_extensions: list[str] = ['.ply', '.vtk', '.vtu', '.stl', '.obj'], categorical_palette: str = 'paired', continuous_cmap: str = 'viridis', region_array: str | None = None, region_labels: dict[int, list[str]] = {}, labels_mesh_key: str | None = None, fields: list[visfem.models.ScalarField] = [], exclude_fields: list[str] = [], default_field: str | None = None, series: str | None = None, database: Literal['d3plot'] | None = None, fiber: visfem.models.FiberOptions | None = None, tube: visfem.models.TubeOptions | None = None, solid: bool = False, categorical_fields: list[str] = [], percentile_clamp: Annotated[int, Ge(ge=1), Le(le=100)] = 95) -> None` Per-dataset render config from the optional JSON `render` block; engine.renderers.resolve_render_config infers omitted fields from mesh_format and file discovery. ## class `RendererName(*values)` Built-in renderer selected by a dataset's render config. ## class `ScalarField(*, name: str, label: str | None = None) -> None` One selectable scalar field with a display label. ## class `TubeOptions(*, enabled: bool = True, n_sides: Annotated[int, Ge(ge=3)] = 8, stride: Annotated[int, Ge(ge=1)] = 1) -> None` Tubing options for the multi_part renderer (vessel centrelines). ## function `compute_mesh_metadata_hash() -> str` Return an 8-char hash of MeshMetadata field names and types. --- # visfem.mesh Mesh loading and metadata extraction for FEM simulation data. ## function `as_surface(mesh: pyvista.core.dataset.DataSet) -> pyvista.core.pointset.PolyData` Return *mesh* as PolyData: a surface passes through, a volume is skinned with its point and cell arrays. ## function `get_metadata(path: pathlib.Path) -> visfem.models.MeshMetadata` Return the MeshMetadata of the mesh at *path*, cached as a .meta.json sidecar keyed on the schema hash and the source files. ## function `load_mesh(path: pathlib.Path, step: int = 0) -> pyvista.core.dataset.DataSet` Return timestep *step* of the mesh at *path* (a file or a d3plot database directory) as a detached PyVista dataset. ## function `load_skin(path: pathlib.Path) -> tuple[pyvista.core.pointset.PolyData, numpy.ndarray | None, numpy.ndarray | None]` Return the skin of the static mesh file at *path* with its volume ids, cached per path. ## function `lsdyna_steps(ddir: pathlib.Path) -> list[float]` Return the timestep values of the d3plot database at *ddir*. ## function `metadata_for_d3plot(ddir: pathlib.Path) -> visfem.models.MeshMetadata` Return MeshMetadata for the LS-DYNA d3plot database at *ddir*, cached as ``/d3plot.meta.json`` keyed on the schema hash and the database files. ## function `metadata_for_series(paths: list[pathlib.Path]) -> visfem.models.MeshMetadata` Return MeshMetadata for the flat VTK series *paths*, the ordered list of per-step files, computed in memory. ## function `parse_labels_file(path: pathlib.Path) -> dict[str, dict[int, list[str]]]` Return {mesh: {material_id: [names]}} parsed from the LabelIDs.txt file at *path*. ## function `preload_all_meshes(project_metadata: dict) -> None` Fill the mesh cache from every dataset in *project_metadata* at server startup. ## function `pvd_steps(path: pathlib.Path) -> list[tuple[float, pathlib.Path]]` Return the (timestep, absolute vtu_path) pairs of the PVD file at *path*, sorted by time. ## function `skin_with_ids(mesh: pyvista.core.dataset.DataSet) -> tuple[pyvista.core.pointset.PolyData, numpy.ndarray | None, numpy.ndarray | None]` Return (skin, volume point ids, volume cell ids) for *mesh*, the ids mapping per-volume arrays (a new time step, a region remap) onto the skin; both id arrays are None when *mesh* already is a surface. ## function `source_signature(path: pathlib.Path) -> str` Return a hash of the names, sizes and modification times of the source files of *path*; it changes when a mesh is exported again. ## function `vtk_series_steps(ddir: pathlib.Path, pattern: str) -> list[tuple[float, pathlib.Path]]` Return (step_index, path) pairs for the files matching *pattern* under *ddir*, in natural sort order. --- # visfem.febio FEBio 2.x (.feb) geometry reader: and into a PyVista UnstructuredGrid. ## class `FebioPart(index: 'int', name: 'str', element_type: 'str', material_id: 'int', material_name: 'str', n_elements: 'int') -> None` One ```` block of a FEBio model. ## function `febio_to_vtu(src: 'Path', dst: 'Path') -> 'list[FebioPart]'` Convert the geometry of the FEBio model src to the VTU file dst; returns its element blocks. ## function `read_febio(path: 'Path') -> 'tuple[pv.UnstructuredGrid, list[FebioPart]]'` Parse the FEBio .feb file at path; returns the mesh with Material and Part cell arrays and one FebioPart per element block in file order. --- # visfem.log Logging setup: one RichHandler on the visfem logger, level from VISFEM_LOG_LEVEL. ## function `get_logger(name: str) -> logging.Logger` Return the logger for name; records propagate to the visfem logger configured on first call. --- # visfem.engine.discovery Dataset discovery and project metadata helpers. ## function `active_timeseries_path(meta: visfem.models.ProjectMetadata, state: Any, ddir: pathlib.Path) -> pathlib.Path | None` Return the active timeseries file of *meta*: its PVD for a PVD dataset, else the XDMF in *ddir* named by ``state.active_xdmf``, else the first XDMF in *ddir*, else None. ## function `dataset_dir(meta: visfem.models.ProjectMetadata) -> pathlib.Path` Return the dataset directory for *meta*. ## function `default_data_dir() -> pathlib.Path` Return the data directory without DATA_DIR: ``data/`` of a source checkout, else ``./visfem_data``, where the fetch commands put the example data. ## function `discover_xdmf(directory: pathlib.Path) -> dict[str, pathlib.Path]` Return stem->path for all .xdmf files with a matching .h5 in directory. ## function `format_organ_name(name: str) -> str` Return *name* with a space inserted after a known leading prefix word. ## function `grasp_phase_count(pvd_path: pathlib.Path) -> int` Return the number of contrast phases in a GRASP per-patient PVD. ## function `grasp_phase_pvd(patient_dir: pathlib.Path) -> pathlib.Path | None` Return the per-patient GRASP phase-series PVD path, or None if absent. ## function `group_by_organ_system(metadata: dict[str, visfem.models.ProjectMetadata]) -> dict[str, list[tuple[str, visfem.models.ProjectMetadata]]]` Return the datasets in *metadata* grouped by first organ_system value, groups sorted by that value and entries by sort_order then name. ## function `ircadb_organ_names(patient_dir: pathlib.Path) -> list[str]` Return sorted organ names available for a patient directory. ## function `iter_descriptor_paths() -> list[pathlib.Path]` Return the sorted paths of every dataset descriptor JSON under DATASETS_DIR. ## function `load_descriptor(path: pathlib.Path) -> visfem.models.ProjectMetadata` Return the validated ProjectMetadata parsed from the JSON at *path*; raises ValidationError or OSError. ## function `load_project_metadata() -> dict[str, visfem.models.ProjectMetadata]` Return ProjectMetadata for each readable, valid dataset JSON under DATASETS_DIR, keyed by file stem and filtered by ALLOWED_DATASETS. ## function `meta_to_state(meta: visfem.models.ProjectMetadata) -> dict[str, object]` Return a plain dict of *meta* for Trame state. ## function `patient_numbers(ddir: pathlib.Path) -> list[int]` Return the sorted numeric ids of the ``patient_`` folders in *ddir*. ## function `patient_subdir(ddir: pathlib.Path, patient: int) -> pathlib.Path` Return the ``patient_NN`` folder for *patient* under *ddir*. ## function `pvd_file_path(meta: visfem.models.ProjectMetadata) -> pathlib.Path | None` Return the PVD file path for PVD-format datasets, else None. ## function `xdmf_display_name(stem: str) -> str` Return a readable display name for an XDMF *stem*. --- # visfem.engine.renderers Renderer registry and render-config resolution for a dataset's JSON ``render`` block (``visfem.models.RenderConfig``). ## class `RenderContext(scene: 'Scene', ctrl: 'TrameCtrl', state: 'Any', ddir: 'Path', meta: 'ProjectMetadata', cfg: 'RenderConfig', xdmf_meta: 'dict[Path, MeshMetadata]', opacity: 'float', palette: 'list[str]', cmap: 'str', field: 'str | None' = None, step: 'int' = 0, reset_camera: 'bool' = True) -> None` Inputs for one renderer call, built per draw by the dispatch layer. ## function `apply_result_to_state(state: 'Any', result: 'RenderResult') -> 'None'` Write result's legend, mesh stats and scalar bar to Trame state. ## function `dispatch_render(ctx: 'RenderContext') -> 'RenderResult'` Render ctx with the registry entry for cfg.renderer and return the RenderResult. ## function `is_patient_renderer(cfg: 'RenderConfig') -> 'bool'` Return True when cfg.renderer draws on patient selection. ## function `populate_timeseries_state(state: 'Any', ctx: 'RenderContext') -> 'str | None'` Populate the timeseries field and step state from ctx and return the default field. ## function `render_multi_part(ctx: 'RenderContext') -> 'RenderResult'` Render cfg.parts merged, categorically coloured and tubed per cfg.tube; return the RenderResult. ## function `render_patient_organs(ctx: 'RenderContext') -> 'RenderResult'` Render the per-organ .vtk files (ircadb family) in the patient dir ctx.ddir; return the RenderResult. ## function `render_pvd_phase_series(ctx: 'RenderContext') -> 'RenderResult'` Render the region-coloured GRASP MRI phase surface at ctx.step for the patient dir ctx.ddir; return the RenderResult. ## function `render_region_id(ctx: 'RenderContext') -> 'RenderResult'` Render ctx's mesh coloured by the integer cell array cfg.region_array, with a per-region legend and the optional cfg.fiber overlay; return the RenderResult. ## function `render_scalar_field(ctx: 'RenderContext') -> 'RenderResult'` Render ctx's static mesh coloured by its field as a percentile-clamped ramp, or as discrete zones for cfg.categorical_fields; return the RenderResult. ## function `render_surface(ctx: 'RenderContext') -> 'RenderResult'` Render ctx's single solid-colour surface mesh and return the RenderResult. ## function `render_timeseries(ctx: 'RenderContext') -> 'RenderResult'` Render ctx's scalar-field time series at ctx.step from a flat VTK series (cfg.series, one file per step), an LS-DYNA database (cfg.database) or a PVD/XDMF manifest; return the RenderResult. ## function `resolve_mesh_file(ddir: 'Path', cfg: 'RenderConfig') -> 'str | None'` Return the surface filename in ddir that cfg selects, or None. ## function `resolve_render_config(meta: 'ProjectMetadata', ddir: 'Path') -> 'RenderConfig'` Return a copy of meta.render whose renderer, and whose surface mesh_file, are inferred from ddir when unset. --- # visfem.engine.scene Scene management and mesh rendering helpers. ## class `RenderResult(legend_items: list[dict[str, typing.Any]] = , mesh_stats: dict[str, Any] | None = None, scalar_bar_info: dict[str, Any] | None = None, fiber_actor: vtkmodules.vtkRenderingCore.vtkActor | None = None) -> None` Result of one render pass. ## class `Scene(plotter: pyvista.plotting.plotter.Plotter, active_actor: vtkmodules.vtkRenderingCore.vtkActor | None = None, xdmf_mesh: pyvista.core.dataset.DataSet | None = None, xdmf_actor: vtkmodules.vtkRenderingCore.vtkActor | None = None, xdmf_surface: pyvista.core.pointset.PolyData | None = None, xdmf_point_ids: numpy.ndarray | None = None, xdmf_cell_ids: numpy.ndarray | None = None) -> None` One plotter and the actors its in-place updates act on. ### `Scene.forget_actors(self) -> None` Drop every tracked actor and mesh. ## class `TrameCtrl(*args, **kwargs)` Trame controller hooks the scene pushes through. ### `TrameCtrl.view_push_camera(self) -> None` Push the server camera to the client. ### `TrameCtrl.view_push_scene(self) -> None` Send the full scene state to the client. ### `TrameCtrl.view_update(self) -> None` Send a delta view update to the client. ## function `apply_opacity(scene: visfem.engine.scene.Scene, opacity: float) -> None` Set *opacity* on every vtkActor in the renderer. ## function `clear_scene(scene: visfem.engine.scene.Scene, dark_mode: bool) -> None` Remove every actor from *scene*, drop its tracked actors and set the background for *dark_mode*. ## function `field_label(name: str) -> str` Return the display label for the scalar field *name*. ## function `push_scene(scene: visfem.engine.scene.Scene, ctrl: visfem.engine.scene.TrameCtrl, reset_camera: bool = True) -> None` Render *scene*, reset the camera when *reset_camera* and push the scene to the client. ## function `redraw_ircadb(scene: visfem.engine.scene.Scene, ctrl: visfem.engine.scene.TrameCtrl, patient_dir: pathlib.Path, dark_mode: bool, opacity: float, palette: list[str] | None = None, reset_camera: bool = True) -> visfem.engine.scene.RenderResult` Render every organ mesh in *patient_dir* as one merged actor and return the RenderResult. ## function `redraw_region_surface_step(scene: visfem.engine.scene.Scene, ctrl: visfem.engine.scene.TrameCtrl, pvd_path: pathlib.Path, dark_mode: bool, opacity: float, step: int, palette: list[str] | None = None, reset_camera: bool = True, solid: bool = True) -> visfem.engine.scene.RenderResult` Render phase *step* of the region-coloured PVD surface series at *pvd_path* (GRASP MRI) and return the RenderResult. ## function `redraw_scalar_field(scene: visfem.engine.scene.Scene, ctrl: visfem.engine.scene.TrameCtrl, dataset_dir: pathlib.Path, dark_mode: bool, opacity: float, mesh_file: str, field: str, categorical_fields: frozenset[str], zone_labels: dict[int, list[str]], palette: list[str] | None = None, cmap: str = 'viridis', percentile: int = 95, reset_camera: bool = True) -> visfem.engine.scene.RenderResult` Render *mesh_file* coloured by *field*, as zones with a legend when categorical or a ramp clamped at *percentile*, and return the RenderResult. ## function `redraw_stl_surface(scene: visfem.engine.scene.Scene, ctrl: visfem.engine.scene.TrameCtrl, dataset_dir: pathlib.Path, dark_mode: bool, opacity: float, filename: str, palette: list[str] | None = None, reset_camera: bool = True) -> visfem.engine.scene.RenderResult` Render the closed, geometry-only STL/OBJ surface *filename* in one solid colour and return the RenderResult. ## function `redraw_xdmf(scene: visfem.engine.scene.Scene, ctrl: visfem.engine.scene.TrameCtrl, path: pathlib.Path, xdmf_meta: dict[pathlib.Path, visfem.models.MeshMetadata], dark_mode: bool, opacity: float, field: str | None = None, step: int = 0, reset_camera: bool = True, cmap: str = 'viridis', mesh_meta: visfem.models.MeshMetadata | None = None) -> visfem.engine.scene.RenderResult` Render step *step* of the time series at *path* and return the RenderResult. ## function `set_background(scene: visfem.engine.scene.Scene, dark_mode: bool) -> None` Set the gradient background of *scene* for *dark_mode*. ## function `update_actor_palette(scene: visfem.engine.scene.Scene, ctrl: visfem.engine.scene.TrameCtrl, colors: list[str], n: int) -> None` Swap the active actor's categorical LUT to the first *n* of *colors* in place and push the scene. ## function `update_scalar_field_view(scene: visfem.engine.scene.Scene, ctrl: visfem.engine.scene.TrameCtrl, ddir: pathlib.Path, mesh_file: str, field: str, categorical_fields: frozenset[str], zone_labels: dict[int, list[str]], palette: list[str], cmap: str, percentile: int = 95) -> tuple[list[dict[str, typing.Any]], dict[str, typing.Any] | None]` Switch the active actor's mapper to *field* in place and return (legend, scalar bar). ## function `update_scalar_range(scene: visfem.engine.scene.Scene, ctrl: visfem.engine.scene.TrameCtrl, field: str, clim: list[float], cmap: str) -> dict | None` Set the active actor's scalar range to *clim* in place and return the scalar bar dict. ## function `update_xdmf_step(scene: visfem.engine.scene.Scene, ctrl: visfem.engine.scene.TrameCtrl, path: pathlib.Path, xdmf_meta: dict[pathlib.Path, visfem.models.MeshMetadata], step: int, field: str | None, cmap: str) -> tuple[bool, dict[str, typing.Any] | None, dict[str, typing.Any] | None]` Copy step *step* onto the live frame and its skin in place and return (updated, stats, scalar bar). --- # visfem.engine.selection Dataset selection handlers that dispatch through the renderer registry. ## function `recolor_categories(scene: visfem.engine.scene.Scene, ctrl: visfem.engine.scene.TrameCtrl, state: Any, project_metadata: dict[str, visfem.models.ProjectMetadata], xdmf_meta: dict[pathlib.Path, visfem.models.MeshMetadata]) -> None` Re-apply category colour overrides in place, or via select_color_scheme. ## function `resolve_cmap(state: Any) -> str` Return the active continuous colormap name from state. ## function `select_color_scheme(scene: visfem.engine.scene.Scene, ctrl: visfem.engine.scene.TrameCtrl, state: Any, project_metadata: dict[str, visfem.models.ProjectMetadata], xdmf_meta: dict[pathlib.Path, visfem.models.MeshMetadata]) -> None` Re-render the current scene after a palette or colormap change. ## function `select_dataset(scene: visfem.engine.scene.Scene, ctrl: visfem.engine.scene.TrameCtrl, state: Any, project_metadata: dict[str, visfem.models.ProjectMetadata], xdmf_meta: dict[pathlib.Path, visfem.models.MeshMetadata], key: str) -> vtkmodules.vtkRenderingCore.vtkActor | None` Render dataset *key* via its render config; return the fibre actor or None. ## function `select_patient(scene: visfem.engine.scene.Scene, ctrl: visfem.engine.scene.TrameCtrl, state: Any, project_metadata: dict[str, visfem.models.ProjectMetadata], dataset_key: str, patient: int) -> None` Load and render a specific patient from a multi-patient dataset. ## function `select_scalar_field(scene: visfem.engine.scene.Scene, ctrl: visfem.engine.scene.TrameCtrl, state: Any, project_metadata: dict[str, visfem.models.ProjectMetadata], xdmf_meta: dict[pathlib.Path, visfem.models.MeshMetadata], field: str) -> None` Re-render the current dataset with a different scalar field. ## function `select_step(scene: visfem.engine.scene.Scene, ctrl: visfem.engine.scene.TrameCtrl, state: Any, project_metadata: dict[str, visfem.models.ProjectMetadata], xdmf_meta: dict[pathlib.Path, visfem.models.MeshMetadata], step: int) -> None` Navigate the current time-series dataset to a different timestep. ## function `select_xdmf(scene: visfem.engine.scene.Scene, ctrl: visfem.engine.scene.TrameCtrl, state: Any, project_metadata: dict[str, visfem.models.ProjectMetadata], xdmf_meta: dict[pathlib.Path, visfem.models.MeshMetadata], key: str, stem: str) -> None` Load and render a specific XDMF file within a multi-file dataset. --- # visfem.engine.playback Async background coroutines: vtk.js warmup and autoplay. ## function `autoplay_loop(state: Any, scene: visfem.engine.scene.Scene, ctrl: Any, project_metadata: dict[str, visfem.models.ProjectMetadata], xdmf_meta: dict[pathlib.Path, visfem.models.MeshMetadata], frame_sleep: float) -> None` Advance *state.active_step* by *state.step_inc* through select_step with *scene*, *ctrl*, *project_metadata* and *xdmf_meta*, wrapping to step 0, every *frame_sleep* / *state.playback_speed* seconds until *state.autoplay* clears or a step fails. ## function `clear_overlay(state: Any) -> None` Clear the loading and busy flags on *state* and flush them to the client; returns None. ## function `keyframe_increment(n_steps: int, n_frames: int = 30) -> int` Return the step stride that spreads *n_steps* steps over at most *n_frames* keyframes. ## function `vtkjs_warmup(gen: int, get_gen: collections.abc.Callable[[], int], state: Any, path: pathlib.Path) -> None` Load every keyframe step of *path* into the server-side mesh cache, then clear the overlay on *state* when *get_gen()* still equals *gen*. --- # visfem.engine.xr_manager WebXR session management and in-VR exit panel. ## class `XRManager(plotter: 'pv.Plotter', state: 'Any', ctrl: 'Any') -> 'None'` Manages WebXR entry and exit and the three in-VR exit panel actors. ### `XRManager.on_enter_xr(self) -> 'None'` Save the camera, set xr_active and push a view update on XR entry. ### `XRManager.on_exit_triggered(self, triggered: 'bool') -> 'None'` Stop the XR session on a JS ray-sphere hit, signaled by `triggered`. ### `XRManager.on_exit_xr(self) -> 'None'` Clear xr_active, restore the camera and schedule the 250 ms refresh. ### `XRManager.on_session_ended(self, ended: 'bool') -> 'None'` Handle the JS session 'end' event flagged by `ended` (system-button exit). ### `XRManager.reset_on_reconnect(self) -> 'None'` Reset XR state when the client reconnects. ### `XRManager.toggle_xr(self) -> 'None'` Stop the XR session when active, else start an HmdVR session. --- # visfem.engine.colors Color utilities and background constants for VisFEM. ## function `active_overrides(state: Any) -> dict[str, str]` Return a dict of category -> hex overrides for the current scope. ## function `apply_color_overrides(legend_items: list[dict[str, typing.Any]], overrides: dict[str, str]) -> tuple[list[dict[str, typing.Any]], list[str]]` Apply *overrides* to *legend_items*; return the recolored items and their LUT colors in item order. ## function `apply_overrides_to_state(state: Any, legend_items: list[dict[str, typing.Any]]) -> list[str]` Write *legend_items* to state with overrides applied; return the LUT colors. ## function `category_key(item: dict[str, typing.Any]) -> str` Return the override key for *item*: its first display name (region label, organ filename or part label). ## function `clear_overrides(state: Any) -> None` Drop every override in the current scope. ## function `color_scope(state: Any) -> str` Return the override scope key for the current scene. ## function `css_rgb(rgb: tuple[float, float, float]) -> str` Return the CSS rgb() string for unit-range tuple *rgb*, each channel rounded to 0 to 255. ## function `is_valid_hex(color: Any) -> bool` Return True when *color* is a '#rrggbb' string a VTK lookup table accepts. ## function `region_colors(n: int, palette: list[str]) -> list[str]` Return *n* colors cycled from *palette*. ## function `scheme_to_hex(scheme_id: int) -> list[str]` Return the colors of PyVista color scheme *scheme_id* as '#rrggbb' strings. ## function `set_override(state: Any, name: str, color: str | None) -> None` Set the override for category *name* to *color* in the current scope; None clears it. --- # visfem.engine.palettes Color palette and colormap definitions for VisFEM. --- # visfem.ui.layout Top-level UI assembly for VisFEM. ## class `UICallbacks(on_select_dataset: object, on_select_xdmf: object, on_select_patient: object, on_select_scalar_field: object, on_select_color_scheme: object, on_toggle_color_reversed: object, on_set_category_color: object, on_reset_category_colors: object, on_apply_clim: object, on_toggle_autoplay: object, on_select_step: object, on_toggle_theme: object, on_toggle_left_panel: object, on_toggle_right_panel: object, on_take_screenshot: object, on_reset_camera: object, on_toggle_xr: object, on_enter_xr: object, on_exit_xr: object) -> None` Server-side callbacks that the UI widgets bind to. ## function `build_ui(server: object, plotter: pyvista.plotting.plotter.Plotter, ctrl: Any, organ_groups: dict[str, list[tuple[str, visfem.models.ProjectMetadata]]], patients_by_dataset: dict[str, list[int]], callbacks: visfem.ui.layout.UICallbacks) -> trame_vuetify.ui.vuetify3.SinglePageLayout` Build and return the SinglePageLayout for server, wiring plotter and ctrl to the VTK view and organ_groups, patients_by_dataset and callbacks to the panels. --- # visfem.ui.toolbar Toolbar UI for VisFEM. ## function `build_toolbar(toggle_theme: object, toggle_xr: object, toggle_left_panel: object, toggle_right_panel: object, take_screenshot: object) -> None` Build the toolbar; every parameter is a button click handler, toggle_xr for the commented-out VR button; returns None. --- # visfem.ui.left_panel Left panel with the dataset tree and the active dataset info. ## function `build_left_panel(organ_groups: dict[str, list[tuple[str, visfem.models.ProjectMetadata]]], patients_by_dataset: dict[str, list[int]], on_select_dataset: object, on_select_xdmf: object, on_select_patient: object) -> None` Build the left panel, the dataset tree above the active dataset info, from organ_groups and patients_by_dataset, with on_select_dataset, on_select_xdmf and on_select_patient as click callbacks; returns None. --- # visfem.ui.right_panel Right panel: view, fibers, scalar field, color, regions, playback and scalar bar sections. ## function `build_right_panel(on_reset_camera: object, on_select_scalar_field: object, on_select_color_scheme: object, on_toggle_color_reversed: object, on_select_step: object, on_toggle_autoplay: object, on_apply_clim: object, on_set_category_color: object, on_reset_category_colors: object) -> None` Build the right panel sections, each control wired to its on_* callback. --- # visfem.ui.footer Footer UI for VisFEM. ## function `build_footer() -> None` Populate the footer with the copyright line and the lab link. --- # visfem.ui.widgets Widgets shared by the panels: section headers and tooltip icon buttons. ## function `icon_button(tooltip: str | tuple[str, ...], *, v_if: str | None = None, location: str = 'bottom', **btn: Any) -> None` Render a compact text-variant VBtn inside a VTooltip; tooltip is the static text or a one-element binding tuple, v_if gates the tooltip, location places it, btn kwargs go to the VBtn; returns None. ## function `js_string(value: str) -> str` Return value as a single quoted JS string literal for a Vue template. The literal contains no quote, brace or angle bracket, so it is safe in a template interpolation, in a Vue expression and in an HTML attribute; text of descriptors and file names reaches the client this way. Args: value: text to quote. Returns: The JS string literal, e.g. ``'heart'``. ## function `js_text(value: str) -> str` Return a template interpolation which renders value as escaped text. ## function `section_header(icon: str, label: str, state_var: str, *, prefix: str = '', suffix: str = 'margin-bottom:6px;', action: collections.abc.Callable[[], None] | None = None) -> None` Render a collapsible section header toggling the boolean state_var, with icon, label and chevrons; prefix and suffix are CSS before and after the flex rule, action builds a widget between label and chevrons; returns None. --- # visfem.ui.theme Design tokens and CSS fragments for the VisFEM UI. --- # Development Contributions are welcome. The repository is [matthiaskoenig/visfem](https://github.com/matthiaskoenig/visfem); development happens against the `develop` branch via pull requests. ## Branch model Two branches are permanent: - **`develop`** is the default branch and the branch everything is integrated into. The documentation on [visfem.de/docs](https://visfem.de/docs/) is published from it. - **`main`** tracks the latest published release. It is fast-forwarded to the released commit by the `sync-main` job of the `CI-CD` workflow after the package went to pypi, so `main` and the newest version on pypi always agree. Nothing is developed on `main` and nothing is merged into it by hand. Work happens on short lived branches off `develop`, which GitHub deletes after the merge. Releases are tagged on `develop`, see [Release](#release). ## Pull requests Neither branch accepts a direct push, every change goes through a pull request against `develop`. This includes the maintainer, there is no bypass. A pull request can only be merged once the four required checks are green: | check | workflow | content | | ------- | ------------- | -------------------------------------------------------------------- | | `tests` | `ci-cd.yml` | python 3.14 on linux, macos and windows | | `ruff` | `ruff.yml` | `ruff check` and `ruff format --check` | | `ty` | `ty.yml` | `tox r -e ty` | | `docs` | `docs.yml` | the zensical build including the api reference, the agent files and the landing page | `tests` aggregates the test matrix into a single job, so the name of the required check stays the same when the matrix changes. Further rules of a pull request: - conversations have to be resolved before the merge - an approval is dismissed when new commits are pushed - the history stays linear, i.e., a pull request is merged with squash or rebase; merge commits are disabled - the maintainer is the code owner of the repository (`.github/CODEOWNERS`) and is requested for review on every pull request. Write access is limited to the two authors of VisFEM; a pull request of another contributor is reviewed and merged by one of them. The rulesets themselves do not require an approval, since requiring one would block an author whose co-author is not available, who cannot approve their own pull request [Auto-merge](https://docs.github.com/pull-requests/collaborating-with-pull-requests/incorporating-changes-from-a-pull-request/automatically-merging-a-pull-request) is enabled for the repository, so a pull request can be queued and is merged as soon as the checks pass and the required approval is there. ### Repository policies { #repository-policies } The protection is implemented with [repository rulesets](https://docs.github.com/repositories/configuring-branches-and-merges-in-your-repository/managing-rulesets/about-rulesets). They are part of the repository in `.github/rulesets/` instead of only living in the web interface, so a change to a policy is reviewed like any other change: | ruleset | applies to | rules | | ----------------------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------- | | `develop.json` | `develop` | pull request required, the four checks above, resolved conversations, linear history, no force push, no deletion. **No bypass, for anybody.** | | `main.json` | `main` | linear history, no force push, no deletion, no bypass. The fast-forward of the release workflow needs none, only a force push or a merge commit would be rejected | | `tags.json` | all tags | a tag cannot be deleted or moved, so a release tag keeps pointing at what was released | Changing a policy means changing the json and applying it: ```bash .github/rulesets/apply.sh ``` The script is idempotent: it updates the rulesets which exist and creates the missing ones. It also sets the merge settings of the repository, i.e., auto-merge, delete branch on merge, and squash and rebase as the only merge methods. It needs the [github cli](https://cli.github.com) authenticated as a user with admin permission on the repository. ## Setup development environment Development needs [uv](https://docs.astral.sh/uv/) and a checkout of the repository: ```bash git clone https://github.com/matthiaskoenig/visfem.git cd visfem ``` A single sync creates the virtual environment in `.venv`, installs `visfem` into it in editable mode and adds the complete tooling: ```bash uv sync --extra dev ``` The `dev` extra contains everything used below, i.e., pytest, ruff, ty, tox, pre-commit, zensical and bump-my-version, so nothing has to be installed separately. `uv.lock` pins the versions of the environment, `uv lock --upgrade` updates them. The python version is taken from `.python-version` (currently 3.14); VisFEM supports python 3.14 only. The tools are then run either with `uv run `, which uses the environment without activating it, or from the activated environment: ```bash source .venv/bin/activate # Linux and macOS .venv\Scripts\activate # Windows ``` The commands in this document are written without the `uv run` prefix; prepend it if the environment is not activated. The last step installs the git hook: ```bash uv run pre-commit install # install the hook, once per checkout uv run pre-commit run --all-files # check the current state of the repository ``` From now on every commit is checked with ruff (lint and format) and ty, i.e., the same checks that run in continuous integration. On a commit only the changed files are looked at, `--all-files` checks the whole repository and is what a newly added hook should be tried with. ## Testing The tests are written with pytest, tox runs them against every supported python version. The tox environments are named after the interpreter (`py3.14`, see `envlist` in `tox.ini`), a single one is run with ```bash tox r -e py3.14 ``` and the complete matrix, including the `ty` environment, in parallel with ```bash tox run-parallel ``` This needs the interpreters to be available, which uv installs with `uv python install 3.14`. Continuous integration runs the same environments as `uvx --with tox-uv tox -e py3.14`. Python 3.15 joins the matrix once [VTK](https://pypi.org/project/vtk/) publishes wheels for it. To run the tests directly against the development environment use ```bash pytest # the full suite pytest tests/test_app.py # a single module pytest tests/test_app.py::test_url_model # a single test ``` The tests do not need datasets: `tests/conftest.py` writes small synthetic meshes, PVD and XDMF series and dataset descriptors into a temporary data directory. They cover - the dataset descriptors and their validation (`test_models.py`) and the discovery of the datasets (`test_discovery.py`), - the loading of meshes and time series and their cached metadata (`test_mesh.py`), the FEBio reader (`test_febio.py`), - the resolution of the render block (`test_renderers.py`) and the colours (`test_colors.py`), - the command line, including `validate-data` and the IRCADb extraction (`test_cli.py`), - the app without a browser (`test_app.py`): it selects datasets, fields and time steps on an off-screen plotter and checks the state the client receives, - the escaping of text for the templates (`test_widgets.py`) and that `setup/requirements.txt` is the export of `uv.lock` (`test_requirements.py`). A warning fails a test (`filterwarnings` in `pyproject.toml`). To check a change of the user interface, run the app against a data directory, see [Running the app](usage.md), and look at the result in the browser. ## Linting and formatting Linting and formatting use [ruff](https://docs.astral.sh/ruff/): ```bash ruff check # lint ruff format # format ``` Logging calls use lazy `%s` formatting instead of f-strings (ruff `G`), every `except Exception` carries a `noqa` with its reason (ruff `BLE`). ## Type checking Type checking is performed with [ty](https://docs.astral.sh/ty/): ```bash tox r -e ty ``` Or directly in the working tree: ```bash uvx ty check ``` The configuration lives in `[tool.ty]` in `pyproject.toml`. Warnings are treated as errors, so the codebase is kept free of diagnostics. Suppress an unavoidable diagnostic with a rule specific `# ty: ignore[rule-name]` rather than a blanket comment. ## Documentation The documentation is built with [Zensical](https://zensical.org/), the static site generator of the Material for MkDocs authors. The sources are markdown files in `docs/`, the site is configured in `zensical.toml` in the repository root. Nothing rendered is committed: the site is built by the `documentation` workflow on every push and published to [visfem.de/docs](https://visfem.de/docs/) from the `develop` branch. The GitHub Pages site holds two parts: the interim landing page of `deploy/pages/` at [visfem.de](https://visfem.de/) and the documentation below `/docs/`. zensical therefore builds into `site/docs/` (`site_dir` in `zensical.toml`), and the workflow copies the landing page into `site/`. The landing page of the server deployment lives in `deploy/landing/`, see [Deployment](deployment.md). The Pages site of the repository is deployed by GitHub Actions (source `GitHub Actions` in the Pages settings) and carries the custom domain `visfem.de`. Build the documentation into `site/docs/`: ```bash uv run zensical build --clean ``` For writing, the preview rebuilds on save: ```bash uv run zensical serve ``` The API reference is rendered from the docstrings by [mkdocstrings](https://mkdocstrings.github.io/); a page in `docs/api/` only contains the module directive: ```markdown # mesh ::: visfem.mesh ``` Docstrings are therefore the place to document functions and classes, the markdown files provide the narrative around them. Adding a module to the reference means adding such a page and an entry to `nav` in `zensical.toml`. ### Files for agents { #files-for-agents } Agents and language models read markdown, not rendered html. `scripts/llms_txt.py` writes the files of the [llms.txt convention](https://llmstxt.org/) into the built site, i.e., [llms.txt](https://visfem.de/docs/llms.txt) as an annotated index of all pages, [llms-full.txt](https://visfem.de/docs/llms-full.txt) with the complete documentation in a single file, and the markdown of every page next to its html (`/docs/usage.md` for `/docs/usage/`). The markdown of the API reference is generated from the docstrings with `inspect`, since the pages themselves only contain the mkdocstrings directive. ```bash uv run zensical build --clean uv run python scripts/llms_txt.py cp -r deploy/pages/. site/ # the landing page, for a preview of the complete site ``` The `documentation` workflow runs both steps, so the files are regenerated with every push. `deploy/pages/robots.txt` at the root of the site points crawlers at the sitemap and at these files. ## Deployment requirements The container of the [deployment](deployment.md) installs the app from `setup/requirements.txt`, which is exported from `uv.lock`. Regenerate it whenever the dependencies change, never edit it by hand: ```bash uv export --frozen --no-dev --no-editable --no-emit-project -o setup/requirements.txt ``` ## Release A release is made from `develop`. Since `develop` only accepts pull requests, the release is prepared on a branch and tagged once that pull request is merged: 1. branch off `develop`: `git switch -c release/x.y.z develop` 2. write the release notes for the version in `release-notes/x.y.z.md` 3. make sure everything passes: `tox run-parallel`, `ruff check`, `tox r -e ty`, and that `setup/requirements.txt` is up to date with `uv.lock` 4. check the version bump: `uvx bump-my-version bump [major|minor|patch] --dry-run -vv` 5. bump the version: `uvx bump-my-version bump [major|minor|patch]`, which updates `src/visfem/__init__.py` and `CITATION.cff` and commits; set `date-released` in `CITATION.cff` in the same pull request. It does not create the tag; a squash or rebase merge would rewrite the commit and leave the tag behind on a commit which is not part of `develop` 6. push the branch, open the pull request against `develop` and merge it once the checks are green 7. tag the merged commit on `develop` and push the tag: ```bash git switch develop git pull git tag x.y.z git push origin x.y.z ``` This starts the `CI-CD` workflow, which runs the test matrix, publishes to [pypi](https://pypi.org/project/visfem/), creates the GitHub release from `release-notes/x.y.z.md` and fast-forwards `main` to the tagged commit. Check the version before pushing, a tag cannot be moved or deleted afterwards. 8. test the installation from pypi in a fresh environment: ```bash uv venv --python 3.14 uv pip install visfem ``` 9. check that Zenodo has archived the release. `CITATION.cff`, the `README.md` and the `How to cite` section of `docs/index.md` carry the concept DOI, which always resolves to the latest version, and stay as they are