diff --git a/.clang-format b/.clang-format index 1fe23df..b53fb9f 100644 --- a/.clang-format +++ b/.clang-format @@ -1,5 +1,3 @@ ---- -Language: Cpp # BasedOnStyle: LLVM AccessModifierOffset: -2 AlignAfterOpenBracket: Align diff --git a/.gitignore b/.gitignore index e32467a..9f2aa4f 100644 --- a/.gitignore +++ b/.gitignore @@ -177,3 +177,6 @@ _probe*.py *.vtu *.pvd *.png + +# Interactive scenes the examples export for the gallery +examples/*.vtksz diff --git a/README.md b/README.md index 7caeabc..3a6b397 100644 --- a/README.md +++ b/README.md @@ -5,34 +5,29 @@ This package contains a collection of tools for scientific computing with a focu Many users that are transitioning from legacy FEniCS to FEniCSx may find the transition difficult due to the lack of some functionalities in FEniCSx. This package aims to provide some of the functionalities that are missing in FEniCSx. -See the [documentation](https://scientificcomputing.github.io/scifem/) for the API reference and runnable examples of the features below. + -## Backwards compatibility -We aim to support the last two stable releases of DOLFINx. Sometimes features are supported for longer if need be. - -Some features might depend on functionality added to DOLFINx, which means that they won't be backwards compatible. +# Features -## Features - -### Meshes, tags and submeshes +## Meshes, tags and submeshes - Create `MeshTags` from a list of tags and corresponding locator functions. - Extract a submesh of entities of any co-dimension. - Locate the interface between two subdomains, and the exterior facets of a subdomain. -### Periodic meshes +## Periodic meshes - Build a periodic mesh (MPI supported), by merging the vertices on opposite sides of the domain. The periodicity lives in the topology, so a continuous function space on the result is periodic with no constraint matrix, as opposed to [DOLFINx_MPC](https://github.com/jorgensd/dolfinx_mpc). - Take the vertex pairs from the `$Periodic` section of a gmsh model rather than from the coordinates. - Move mesh tags onto a periodic mesh, and move a solution back onto the mesh it was built from. Required for post-processing with Pyvista or Paraview. - Diagnose the two ways an input mesh can be unsuitable, a missing ghost layer and cells that collapse onto each other across a seam. -### Function spaces and degrees of freedom +## Function spaces and degrees of freedom - Spaces of functions that are constant on each subdomain. - Maps between degrees of freedom and vertices, in both directions. -### Assembly, sources and boundary conditions +## Assembly, sources and boundary conditions - Assemble a scalar or a norm over all processes in a single call. - Point sources for usage in DOLFINx (>=v0.8.0). @@ -41,7 +36,7 @@ Some features might depend on functionality added to DOLFINx, which means that t - Interpolate an expression onto the degrees of freedom of a set of facets. - Helpers for the PETSc vector operations that surround a solve: zeroing, ghost updates, lifting and boundary conditions. -### Evaluation, interpolation and geometry +## Evaluation, interpolation and geometry - Evaluate a function at arbitrary points, in parallel. - Find the extrema of a UFL expression within each cell, or over a whole domain. @@ -49,20 +44,30 @@ Some features might depend on functionality added to DOLFINx, which means that t - Build interpolation matrices from any `ufl.core.expr.Expr` into a compatible space. - Interpolate from a mesh onto a submesh of its facets, and extend a function on such a submesh back into the mesh, zero away from the facets, into Lagrange, H(div) or H(curl) spaces. -### Output +## Output - Save quadrature functions as point clouds. - Save any function that can tabulate dof coordinates as point clouds. -### Biomedical +## Biomedical - Read MRI data onto a mesh, as a function or as cell tags. Requires the `biomed` extra. -## Installation +# Documentation + +The [documentation](https://scientificcomputing.github.io/scifem/) has the API reference and runnable examples of the features above. + +# Backwards compatibility + +We aim to support the last two stable releases of DOLFINx. Sometimes features are supported for longer if need be. + +Some features might depend on functionality added to DOLFINx, which means that they won't be backwards compatible. + +# Installation The package is partly written in C++ and relies on `dolfinx`. Users are encouraged to install `scifem` with `pip` in an environment where `dolfinx` is already installed or with `conda`. -### `pip` +## `pip` To install the package with `pip` run ```bash @@ -77,7 +82,7 @@ python3 -m pip install --no-build-isolation git+https://github.com/scientificcom Note that you should pass the flag `--no-build-isolation` to `pip` to avoid issues with the build environment, such as incompatible versions of `nanobind`. -### `spack` +## `spack` The spack package manager is the recommended way to install scifem, and especially on HPC systems. For information about the package see: [spack-package: py-scifem](https://packages.spack.io/package.html?name=py-scifem) First, clone the spack repository and enable spack @@ -115,7 +120,7 @@ Finally, note that spack needs some packages already installed on your system. O ```bash apt update && apt install gcc unzip git python3-dev g++ gfortran xz-utils -y ``` -### `conda` +## `conda` To install the package with `conda` run @@ -123,6 +128,6 @@ To install the package with `conda` run conda install -c conda-forge scifem ``` -## Having issues or want to contribute? +# Having issues or want to contribute? If you are having issues, feature request or would like to contribute, please let us know. You can do so by opening an issue on the [issue tracker](https://github.com/scientificcomputing/scifem/issues). diff --git a/_config.yml b/_config.yml index e59e88c..1f4495c 100644 --- a/_config.yml +++ b/_config.yml @@ -30,6 +30,9 @@ parse: sphinx: + # Copies the demos' animations for the gallery on the front page + local_extensions: + demo_gallery: docs/_ext extra_extensions: - 'sphinx.ext.autodoc' - 'sphinx.ext.napoleon' diff --git a/_static/open-linked-dropdown.js b/_static/open-linked-dropdown.js new file mode 100644 index 0000000..bee3198 --- /dev/null +++ b/_static/open-linked-dropdown.js @@ -0,0 +1,37 @@ +// Open a collapsed `:class: dropdown` admonition when the page is navigated to +// it. +// +// sphinx-togglebutton collapses with a `toggle-hidden` class rather than a +// native +// `
`, so the browser cannot expand it for us and a link to a target +// inside one would scroll to a box that is still shut. +(function() { +function openTarget() +{ + if (!window.location.hash) + { + return; + } + var target = document.getElementById(window.location.hash.slice(1)); + if (!target) + { + return; + } + var box = target.closest('.admonition.toggle'); + if (box && box.classList.contains('toggle-hidden')) + { + box.classList.remove('toggle-hidden'); + // Keep the button's label and aria-expanded in step; togglebutton exposes + // this. + if (typeof window.syncAllToggleHints === 'function') + { + window.syncAllToggleHints(); + } + box.scrollIntoView({block : 'center'}); + } +} + +// togglebutton adds `toggle-hidden` on DOMContentLoaded, so run after it. +window.addEventListener('load', openTarget); +window.addEventListener('hashchange', openTarget); +})(); diff --git a/_toc.yml b/_toc.yml index d0e88df..1e8ea51 100644 --- a/_toc.yml +++ b/_toc.yml @@ -2,8 +2,12 @@ # Learn more at https://jupyterbook.org/customize/toc.html format: jb-book -root: README +root: index parts: + - caption: Getting started + chapters: + - file: "docs/installation" + - file: "docs/compatibility" - caption: Periodic meshes chapters: diff --git a/docs/_ext/demo_gallery.py b/docs/_ext/demo_gallery.py new file mode 100644 index 0000000..094528d --- /dev/null +++ b/docs/_ext/demo_gallery.py @@ -0,0 +1,243 @@ +# Copyright (C) 2026 Jørgen S. Dokken +# +# This file is part of scifem +# +# SPDX-License-Identifier: MIT +"""A gallery of the demos, for the front page: one card per demo in the table of contents, and one +vtk.js viewer shared by all interactive scenes of the book. + +The ``demo-gallery`` directive makes a section for each part of the table of contents that has +demos, titled with its caption, and a card for each demo, titled with the first heading of the +demo and linking to it. + +A demo names its figure after itself: ``demo_x.py`` writes ``demo_x.gif`` or ``demo_x.png``, and +may export an interactive scene, ``demo_x.vtksz``, with ``pyvista.Plotter.export_vtksz``. The demos +write them while the book executes them, which is after the front page has been read. The cards +therefore refer to copies under ``_static/gallery``, made here once the build has finished, and +name their picture without an extension, ``demo_x.*``. That is then resolved to the animation if +the demo wrote one, and else to the picture, and dropped if the demo wrote neither. A card has a +"3D" button, which shows the interactive scene in place of the picture, if the demo exported one, +with the script and stylesheet added here. + +The scenes share one viewer, the static viewer of ``trame_vtk``, in ``_static/scenes``, which +shows the scene named by ``?scene=``. Each scene is a script next to it, ``.vtksz.js``, as +browsers load scripts for a book opened from disk, ``file://``, but refuse it the request that the +viewer's own ``?fileURL=`` makes. Besides the scenes of the gallery, these are the plots that +pyvista's ``html`` Jupyter backend shows in the pages: it embeds a copy of the viewer, of about +1 MB, in each plot, which is replaced here by the shared viewer. A plot that is not recognised is +left as it is. +""" + +import base64 +import hashlib +import json +import re +import shutil +from pathlib import Path + +import yaml +from docutils import nodes +from docutils.statemachine import StringList +from sphinx.application import Sphinx +from sphinx.util import logging +from sphinx.util.docutils import SphinxDirective +from sphinx.util.nodes import nested_parse_with_titles +from trame_vtk.tools.vtksz2html import HTML_VIEWER_PATH + +logger = logging.getLogger(__name__) + +# Where the demos are, and write their figures, relative to the book's root +DEMOS = "examples" +PATTERNS = ("*.gif", "*.png") +# For a figure named without its extension, in order of preference +PREFERENCE = (".gif", ".png") +# The script and stylesheet of the "3D" buttons +STATIC = Path(__file__).parent / "static" +# Under the root of the built book: the pictures of the gallery, and the viewer and its scenes +GALLERY = "_static/gallery" +SCENES = "_static/scenes" +VIEWER = "viewer.html" +# Added to the viewer: load the script of the scene named by ?scene=, and show it +LOADER = """ +""" +# The state of the Jupyter widgets of a page, which holds the plots of the html backend +WIDGET_STATE = re.compile( + r'()', re.DOTALL +) +# A plot of the html backend: the viewer with the scene, in base64, as the source of a frame +EMBEDDED = re.compile(r'^\s*$', re.DOTALL) +EMBEDDED_SCENE = re.compile(r"var base64Str = "(?P[A-Za-z0-9+/=]+)";") + + +def _title(demo: Path) -> str: + """The first top-level heading of a demo, a jupytext script.""" + heading = re.search(r"^# # (.+)$", demo.read_text(), flags=re.MULTILINE) + return heading.group(1).strip() if heading else demo.stem + + +def _card(docname: str, title: str, prefix: str) -> str: + """A card linking to the demo `docname`, with its figure and a "3D" button.""" + stem = Path(docname).name + return ( + f"````{{grid-item-card}} {title}\n:link: {docname}\n:link-type: doc\n\n" + "```{raw} html\n" + '\n```\n````\n" + ) + + +class DemoGallery(SphinxDirective): + """A section per part of the table of contents that has demos, with a card per demo.""" + + has_content = False + + def run(self) -> list[nodes.Node]: + root = Path(self.env.srcdir) + toc_path = root / getattr(self.config, "external_toc_path", "_toc.yml") + self.env.note_dependency(str(toc_path)) + toc = yaml.safe_load(toc_path.read_text()) + # The pictures are relative to the root of the built book + prefix = "../" * self.env.docname.count("/") + text = [] + for part in toc.get("parts", []): + demos = [ + c["file"] for c in part.get("chapters", []) if c["file"].startswith(f"{DEMOS}/") + ] + if not demos: + continue + cards = [] + for docname in demos: + docname = str(Path(docname).with_suffix("")) + source = (root / docname).with_suffix(".py") + self.env.note_dependency(str(source)) + cards.append(_card(docname, _title(source), prefix)) + text.append( + f"## {part['caption']}\n\n`````{{grid}} 1 2 2 3\n:gutter: 3\n\n" + + "\n".join(cards) + + "`````\n" + ) + container = nodes.section() + nested_parse_with_titles(self.state, StringList("\n".join(text).splitlines()), container) + return container.children + + +def _write_scene(scenes: Path, name: str, content: str) -> None: + """Write the scene `name`, a .vtksz file in base64, as a script for the viewer.""" + (scenes / f"{name}.vtksz.js").write_text(f'window.vtkszBase64 = "{content}";\n') + + +def _resolve_gallery(html: str, gallery: Path, scenes: Path) -> str: + """Point each figure of a gallery named without an extension at the animation if there is one, + and else at the picture, and drop the figures and "3D" buttons that a demo did not write.""" + + def choose(match: re.Match) -> str: + path, stem = match.group(1), match.group(2) + for suffix in PREFERENCE: + if (gallery / f"{stem}{suffix}").exists(): + return match.group(0).replace(f"{path}{stem}.*", f"{path}{stem}{suffix}") + logger.info(f"The gallery shows {stem} without a picture: it wrote no {stem}.gif or .png") + return "" + + def keep_button(match: re.Match) -> str: + return match.group(0) if (scenes / f"{match.group(1)}.vtksz.js").exists() else "" + + html = re.sub(rf']*>\n?', choose, html) + return re.sub( + r'\n?", + keep_button, + html, + ) + + +def _share_viewer(html: str, scenes: Path, prefix: str) -> str: + """Replace the viewer that each plot of the html backend embeds by the shared viewer.""" + + def share(match: re.Match) -> str: + state = json.loads(match.group(2)) + shared = 0 + for model in state.get("state", {}).values(): + value = model.get("state", {}).get("value") + if not isinstance(value, str) or not value.startswith("" + ) + shared += 1 + if shared == 0: + return match.group(0) + # As in the original, " None: + """Write the viewer and the scenes of an HTML build, copy the figures of the demos, and point + the pages at them.""" + if exception is not None or app.builder.format != "html": + return + outdir = Path(app.outdir) + source = Path(app.srcdir) / DEMOS + gallery, scenes = outdir / GALLERY, outdir / SCENES + gallery.mkdir(parents=True, exist_ok=True) + scenes.mkdir(parents=True, exist_ok=True) + for pattern in PATTERNS: + for figure in sorted(source.glob(pattern)): + shutil.copy2(figure, gallery / figure.name) + for scene in sorted(source.glob("*.vtksz")): + _write_scene(scenes, scene.stem, base64.b64encode(scene.read_bytes()).decode()) + viewer = HTML_VIEWER_PATH.read_text(encoding="utf-8") + end = viewer.rindex("") + (scenes / VIEWER).write_text(viewer[:end] + LOADER + viewer[end:], encoding="utf-8") + static = outdir / "_static" + for page in outdir.rglob("*.html"): + if static in page.parents: + continue + html = page.read_text() + resolved = html + if f"{GALLERY}/" in resolved: + resolved = _resolve_gallery(resolved, gallery, scenes) + if "