From 476cc5963fe8322279a48e8c49a31edf3dac9928 Mon Sep 17 00:00:00 2001 From: Joergen Schartum Dokken Date: Wed, 7 Oct 2026 06:58:00 +0000 Subject: [PATCH 1/7] Use guard against unexpected, but valid user input, ref @jhale in https://github.com/FEniCS/dolfinx/pull/4524 --- src/scifem/mesh.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/scifem/mesh.py b/src/scifem/mesh.py index 4387832..f2301fc 100644 --- a/src/scifem/mesh.py +++ b/src/scifem/mesh.py @@ -115,7 +115,9 @@ def create_entity_markers( # Create marker function e_map = domain.topology.index_map(dim) num_entities_local = e_map.size_local + e_map.num_ghosts - markers = np.full(num_entities_local, -1, dtype=np.int32) + markers = np.zeros(num_entities_local, dtype=np.int32) + # Explicit flag rather than a sentinel tag value, as any int32 is a valid tag + is_marked = np.zeros(num_entities_local, dtype=np.bool_) locate_entities = lambda on_boundary: ( dolfinx.mesh.locate_entities_boundary if on_boundary else dolfinx.mesh.locate_entities @@ -126,9 +128,10 @@ def create_entity_markers( on_boundary = False if len(tagged_entity) == 2 else tagged_entity[2] entities = locate_entities(on_boundary)(domain, dim, tagged_entity[1]) markers[entities] = tagged_entity[0] + is_marked[entities] = True - facets = np.flatnonzero(markers != -1).astype(np.int32) - return dolfinx.mesh.meshtags(domain, dim, facets, markers[facets]) + marked_entities = np.flatnonzero(is_marked).astype(np.int32) + return dolfinx.mesh.meshtags(domain, dim, marked_entities, markers[marked_entities]) @deprecated( From 4427273b32a5213efc23f4d3c072342fc42dcc55 Mon Sep 17 00:00:00 2001 From: Joergen Schartum Dokken Date: Wed, 7 Oct 2026 06:58:39 +0000 Subject: [PATCH 2/7] Same fix in old transfer_meshtags_to_submesh --- src/scifem.cpp | 32 +++++++++++++++++++++++--------- 1 file changed, 23 insertions(+), 9 deletions(-) diff --git a/src/scifem.cpp b/src/scifem.cpp index 5e48318..18d2e98 100644 --- a/src/scifem.cpp +++ b/src/scifem.cpp @@ -637,11 +637,10 @@ transfer_meshtags_to_submesh( = sub_entity_map->size_local() + sub_entity_map->num_ghosts(); std::vector sub_entity_to_parent(num_sum_entities, -1); - // Initialize submesh values with numerical min - std::vector submesh_values(num_sum_entities, - std::numeric_limits::min()); - std::vector submesh_indices(num_sum_entities); - std::iota(submesh_indices.begin(), submesh_indices.end(), 0); + // Mapped entities are tracked with a flag, not a sentinel value, as any + // value of T is a valid tag + std::vector submesh_values(num_sum_entities); + std::vector submesh_value_found(num_sum_entities, 0); // Map tag indices to global index std::span tag_indices = tags.indices(); @@ -659,8 +658,8 @@ transfer_meshtags_to_submesh( std::ranges::fill(indices, -1); for (std::size_t i = 0; i < global_tag_indices.size(); ++i) indices[tag_indices[i]] = global_tag_indices[i]; - index_mapper.scatter_rev([](std::int32_t a, std::int32_t b) - { return std::max(a, b); }); + index_mapper.scatter_rev([](std::int64_t a, std::int64_t b) + { return std::max(a, b); }); index_mapper.scatter_fwd(); // Map tag values in a similar way (Allowing negative values) @@ -731,6 +730,7 @@ transfer_meshtags_to_submesh( // Found entity in submesh with the same vertices as in the // parent mesh submesh_values[sub_entity] = parent_value; + submesh_value_found[sub_entity] = 1; entity_found = true; sub_entity_to_parent[sub_entity] = parent_entity; } @@ -738,9 +738,23 @@ transfer_meshtags_to_submesh( } } } + // Keep only mapped entities + std::vector filtered_indices; + std::vector filtered_values; + std::vector filtered_to_parent; + for (std::size_t i = 0; i < num_sum_entities; ++i) + { + if (submesh_value_found[i]) + { + filtered_indices.push_back(i); + filtered_values.push_back(submesh_values[i]); + filtered_to_parent.push_back(sub_entity_to_parent[i]); + } + } + dolfinx::mesh::MeshTags new_meshtag(submesh_topology, tag_dim, - submesh_indices, submesh_values); - return std::make_tuple(new_meshtag, sub_entity_to_parent); + filtered_indices, filtered_values); + return std::make_tuple(new_meshtag, filtered_to_parent); } } // namespace scifem From 64e011bf45b46fe3fc319fea7a3359bd59043152 Mon Sep 17 00:00:00 2001 From: Joergen Schartum Dokken Date: Wed, 7 Oct 2026 08:14:39 +0000 Subject: [PATCH 3/7] Fix plotting --- examples/blocked_solver.py | 110 +++++++++++++++++++++++-------------- 1 file changed, 69 insertions(+), 41 deletions(-) diff --git a/examples/blocked_solver.py b/examples/blocked_solver.py index fc27f51..801e4ce 100644 --- a/examples/blocked_solver.py +++ b/examples/blocked_solver.py @@ -3,9 +3,10 @@ # Author: Henrik N. T. Finsberg # # SPDX-License-Identifier: MIT - -# In this example we will solve a nonlinear elasticity problem using a blocked Newton solver. -# We consider a unit cube domain $\Omega = [0, 1]^3$ with Dirichlet boundary conditions on the left face and traction force on the right face, and we seek a displacement field $\mathbf{u}: \Omega \to \mathbb{R}^3$ that solves the momentum balance equation +# +# In this example we will solve a nonlinear elasticity problem with a non-linear Lagrange multiplier. +# +# We consider a unit cube domain $\Omega = [0, 1]^3$ with Dirichlet boundary conditions on the left face and traction force on the right face, and we seek a displacement field $\mathbf{u}: \Omega \to \mathbb{R}^3$ that solves the momentum balance equation # # $$ # \begin{align} @@ -61,11 +62,13 @@ # # +from pathlib import Path import logging from mpi4py import MPI import numpy as np import ufl import dolfinx +import dolfinx.fem.petsc import scifem # Initialize logging and set log level to info @@ -79,18 +82,16 @@ ) V = dolfinx.fem.functionspace(mesh, ("Lagrange", 2, (3,))) Q = dolfinx.fem.functionspace(mesh, ("DPC", 1,)) +W = ufl.MixedFunctionSpace(V, Q) # And the test and trial functions # -v = ufl.TestFunction(V) -q = ufl.TestFunction(Q) -du = ufl.TrialFunction(V) -dp = ufl.TrialFunction(Q) +v, q = ufl.TestFunctions(W) +du, dp = ufl.TrialFunctions(W) u = dolfinx.fem.Function(V) p = dolfinx.fem.Function(Q) - # Next we create the facet tags for the left and right faces def left(x): @@ -129,64 +130,91 @@ def right(x): N = ufl.FacetNormal(mesh) # Material parameters and strain energy density -mu = dolfinx.fem.Constant(mesh, 10.0) +mu = dolfinx.fem.Constant(mesh, dolfinx.default_scalar_type(10.0)) psi = (mu / 2)*(I1 - 3) # We for the total Lagrangian -L = psi*ufl.dx - ufl.inner(t * N, u)*ufl.ds(subdomain_data=facet_tags, subdomain_id=2) + p * (J - 1) * ufl.dx +L = psi*ufl.dx - ufl.inner(u, t * N)*ufl.ds(subdomain_data=facet_tags, subdomain_id=2) + p * (J - 1) * ufl.dx # and take the first variation of the total Lagrangian to obtain the residual -r_u = ufl.derivative(L, u, v) -r_p = ufl.derivative(L, p, q) -R = [r_u, r_p] +R = ufl.derivative(L, [u, p], [v, q]) +if np.issubdtype(dolfinx.default_scalar_type, np.complexfloating): + # The derivative direction must be an Argument, so conjugate the test functions afterwards + R = ufl.replace(R, {v: ufl.conj(v), q: ufl.conj(q)}) +R_blocked = ufl.extract_blocks(R) # We do the same for the second variation to obtain the Jacobian -K = [ - [ufl.derivative(r_u, u, du), ufl.derivative(r_u, p, dp)], - [ufl.derivative(r_p, u, du), ufl.derivative(r_p, p, dp)], -] - +K = ufl.derivative(R, [u, p], [du, dp]) +K_blocked = ufl.extract_blocks(K) # Now we can create the Newton solver and solve the problem -petsc_options = {"ksp_type": "preonly", "pc_type": "lu", "pc_factor_mat_solver_type": "mumps"} -solver = scifem.NewtonSolver(R, K, [u, p], max_iterations=25, bcs=[bc], petsc_options=petsc_options) - -# We can also set a callback function that is called before and after the solve, which takes the solver object as input +petsc_options = {"ksp_type": "preonly", "pc_type": "lu", "pc_factor_mat_solver_type": "mumps", "ksp_error_if_not_converged": True, + "snes_error_if_not_converged": True, "snes_monitor": None} +problem = dolfinx.fem.petsc.NonlinearProblem(R_blocked, [u, p], bcs=[bc], J=K_blocked, petsc_options=petsc_options, + petsc_options_prefix="elasticity_") +# The underlying PETSc SNES solver is accessible through `problem.solver`, which we can inspect +# before and after the solve -def pre_solve(solver: scifem.NewtonSolver): - print(f"Starting solve with {solver.max_iterations} iterations") +def pre_solve(problem: dolfinx.fem.petsc.NonlinearProblem): + max_iterations = problem.solver.getTolerances()[3] + print(f"Starting solve with at most {max_iterations} iterations") -def post_solve(solver: scifem.NewtonSolver): - print(f"Solve completed in with correction norm {solver.dx.norm(0)}") +def post_solve(problem: dolfinx.fem.petsc.NonlinearProblem): + num_iterations = problem.solver.getIterationNumber() + dx_norm = problem.solver.getSolutionUpdate().norm() + print(f"Solve completed in {num_iterations} iterations with correction norm {dx_norm}") -solver.set_pre_solve_callback(pre_solve) -solver.set_post_solve_callback(post_solve) -solver.solve() +pre_solve(problem) +problem.solve() +post_solve(problem) # Finally, we can visualize the solution using `pyvista` +# We first generate the grids locally on each MPI rank, before we gather the results +# on rank `root` for visualization. import pyvista -p = pyvista.Plotter() -topology, cell_types, geometry = dolfinx.plot.vtk_mesh(V) +pyvista.global_theme.allow_empty_mesh= True +root = 0 +tdim = mesh.topology.dim +cell_map = mesh.topology.index_map(tdim) +num_owned_cells = cell_map.size_local +owned_cells = np.arange(num_owned_cells, dtype=np.int32) +topology, cell_types, geometry = dolfinx.plot.vtk_mesh(V, owned_cells) grid = pyvista.UnstructuredGrid(topology, cell_types, geometry) -linear_grid = pyvista.UnstructuredGrid(*dolfinx.plot.vtk_mesh(mesh)) -grid["u"] = u.x.array.reshape((geometry.shape[0], 3)) -p.add_mesh(linear_grid, style="wireframe", color="k") -warped = grid.warp_by_vector("u", factor=1.5) -p.add_mesh(warped, show_edges=False) -p.show_axes() -if not pyvista.OFF_SCREEN: - p.show() -else: - figure_as_array = p.screenshot("displacement.png") +linear_topology, linear_cell_types, linear_geometry = dolfinx.plot.vtk_mesh(mesh, tdim, owned_cells) + +if mesh.geometry.cmaps[0].degree == 1: + _dx_to_pv_lin = {dolfinx.mesh.CellType.hexahedron: pyvista.CellType.HEXAHEDRON, + dolfinx.mesh.CellType.tetrahedron: pyvista.CellType.TETRA, + dolfinx.mesh.CellType.triangle: pyvista.CellType.TRIANGLE, + dolfinx.mesh.CellType.quadrilateral: pyvista.CellType.QUAD} + pv_ct = _dx_to_pv_lin[mesh.topology.cell_type] + linear_cell_types = np.full_like(linear_cell_types, pv_ct) + linear_grid = pyvista.UnstructuredGrid(linear_topology, linear_cell_types, linear_geometry) +grid["u"] = u.x.array.real.reshape((geometry.shape[0], 3)) +gathered_grid = mesh.comm.gather(grid, root=root) +gathered_linear_grid = mesh.comm.gather(linear_grid, root=root) +if gathered_grid is not None: + p = pyvista.Plotter() + merged_grid = pyvista.merge(gathered_grid) + merged_linear_grid = pyvista.merge(gathered_linear_grid) + p.add_mesh(merged_linear_grid, style="wireframe", color="k") + warped = merged_grid.warp_by_vector("u", factor=1.5) + p.add_mesh(warped, show_edges=False) + p.show_axes() + figure = Path("blocked_solver.png") + if not pyvista.OFF_SCREEN: + p.show(screenshot=figure) + else: + _figure_as_array = p.screenshot(figure) # # References From f1e6ad8e73593ec82f27c36f7b604a8420be4981 Mon Sep 17 00:00:00 2001 From: Joergen Schartum Dokken Date: Wed, 7 Oct 2026 08:37:26 +0000 Subject: [PATCH 4/7] Rewrite documentation with gallery. --- .gitignore | 3 + README.md | 43 ++++---- _config.yml | 3 + _static/open-linked-dropdown.js | 29 +++++ _toc.yml | 6 +- docs/_ext/demo_gallery.py | 159 +++++++++++++++++++++++++++ docs/_ext/static/demo_gallery.css | 41 +++++++ docs/_ext/static/demo_gallery.js | 37 +++++++ docs/compatibility.md | 5 + docs/installation.md | 5 + examples/blocked_solver.py | 12 +- examples/evaluate_function.py | 24 ++++ examples/mark_entities.py | 4 +- examples/periodic_heat_gmsh.py | 6 +- examples/periodic_poisson.py | 6 +- examples/point_source.py | 4 +- examples/real_function_space.py | 4 +- examples/transfer_tags_to_submesh.py | 10 +- examples/xdmf_point_cloud.py | 4 +- index.md | 10 ++ 20 files changed, 365 insertions(+), 50 deletions(-) create mode 100644 _static/open-linked-dropdown.js create mode 100644 docs/_ext/demo_gallery.py create mode 100644 docs/_ext/static/demo_gallery.css create mode 100644 docs/_ext/static/demo_gallery.js create mode 100644 docs/compatibility.md create mode 100644 docs/installation.md create mode 100644 index.md diff --git a/.gitignore b/.gitignore index e32467a..c88d649 100644 --- a/.gitignore +++ b/.gitignore @@ -177,3 +177,6 @@ _probe*.py *.vtu *.pvd *.png + +# Interactive scenes the examples export for the gallery +examples/*.html 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..d935904 --- /dev/null +++ b/_static/open-linked-dropdown.js @@ -0,0 +1,29 @@ +// 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..2fc76cf --- /dev/null +++ b/docs/_ext/demo_gallery.py @@ -0,0 +1,159 @@ +# 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. + +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.html``. 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. +""" + +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 + +logger = logging.getLogger(__name__) + +# Where the demos are, and write their figures, relative to the book's root +DEMOS = "examples" +PATTERNS = ("*.gif", "*.png", "*.html") +# 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" + + +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 + gallery = f"{prefix}_static/gallery" + 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 _resolve(page: Path, target: Path) -> None: + """Point each figure of a built page 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.""" + html = page.read_text() + + def choose(match: re.Match) -> str: + path, stem = match.group(1), match.group(2) + for suffix in PREFERENCE: + if (target / 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 (target / f"{match.group(1)}.html").exists() else "" + + resolved = re.sub(r']*>\n?', choose, html) + resolved = re.sub( + r'\n?', + keep_button, + resolved, + ) + if resolved != html: + page.write_text(resolved) + + +def build_gallery(app: Sphinx, exception: Exception | None) -> None: + """Copy the figures of the demos to ``_static/gallery`` of an HTML build, and resolve those of + the pages with a gallery.""" + if exception is not None or app.builder.format != "html": + return + source = Path(app.srcdir) / DEMOS + target = Path(app.outdir) / "_static" / "gallery" + target.mkdir(parents=True, exist_ok=True) + for pattern in PATTERNS: + for figure in sorted(source.glob(pattern)): + shutil.copy2(figure, target / figure.name) + for page in Path(app.outdir).rglob("*.html"): + if target in page.parents: + continue + if "_static/gallery/" in page.read_text(): + _resolve(page, target) + + +def add_static_path(app: Sphinx) -> None: + """Serve the script and stylesheet of the "3D" buttons.""" + app.config.html_static_path.append(str(STATIC)) + + +def setup(app: Sphinx) -> dict: + app.add_directive("demo-gallery", DemoGallery) + app.connect("builder-inited", add_static_path) + app.connect("build-finished", build_gallery) + app.add_js_file("demo_gallery.js") + app.add_css_file("demo_gallery.css") + return {"parallel_read_safe": True, "parallel_write_safe": True} diff --git a/docs/_ext/static/demo_gallery.css b/docs/_ext/static/demo_gallery.css new file mode 100644 index 0000000..7cc9cf1 --- /dev/null +++ b/docs/_ext/static/demo_gallery.css @@ -0,0 +1,41 @@ +/* Copyright (C) 2026 Jørgen S. Dokken + * + * This file is part of scifem + * + * SPDX-License-Identifier: MIT + * + * A gallery card is a link, drawn as a layer over the whole card. The button and the + * interactive scene sit above that layer, so that they get the clicks and the drags. + */ + +.gallery-figure { + position: relative; +} + +.gallery-figure iframe { + position: relative; + z-index: 2; + display: block; + width: 100%; + border: 0; +} + +.gallery-3d { + position: absolute; + top: 0.5rem; + right: 0.5rem; + z-index: 3; + padding: 0.1rem 0.6rem; + border: 1px solid var(--pst-color-border, #ccc); + border-radius: 0.25rem; + background: var(--pst-color-background, #fff); + color: var(--pst-color-text-base, #222); + font-size: 0.8rem; + cursor: pointer; +} + +.gallery-3d:hover, +.gallery-3d:focus-visible { + border-color: var(--pst-color-primary, #0a7d91); + color: var(--pst-color-primary, #0a7d91); +} diff --git a/docs/_ext/static/demo_gallery.js b/docs/_ext/static/demo_gallery.js new file mode 100644 index 0000000..d2cb49a --- /dev/null +++ b/docs/_ext/static/demo_gallery.js @@ -0,0 +1,37 @@ +// Copyright (C) 2026 Jørgen S. Dokken +// +// This file is part of scifem +// +// SPDX-License-Identifier: MIT +// +// The "3D" button of a gallery card swaps the picture for the interactive scene of the demo, and +// back. The scene is only loaded when asked for, so the front page stays light. + +document.addEventListener("click", (event) => { + const button = event.target.closest(".gallery-3d"); + if (button === null) { + return; + } + // The card is a link: the button must not follow it + event.preventDefault(); + event.stopPropagation(); + const figure = button.closest(".gallery-figure"); + const picture = figure.querySelector("img"); + const scene = figure.querySelector("iframe"); + if (scene !== null) { + scene.remove(); + picture.hidden = false; + button.textContent = "3D"; + button.setAttribute("aria-pressed", "false"); + return; + } + const frame = document.createElement("iframe"); + frame.src = button.dataset.scene; + frame.title = `${picture.alt}, interactive`; + // The scene takes the place of the picture, at its size + frame.style.aspectRatio = `${picture.naturalWidth || 4} / ${picture.naturalHeight || 3}`; + picture.hidden = true; + figure.insertBefore(frame, button); + button.textContent = "2D"; + button.setAttribute("aria-pressed", "true"); +}); diff --git a/docs/compatibility.md b/docs/compatibility.md new file mode 100644 index 0000000..01cf43c --- /dev/null +++ b/docs/compatibility.md @@ -0,0 +1,5 @@ +# Backwards compatibility +```{include} ../README.md +:start-after: "# Backwards compatibility" +:end-before: "# Installation" +``` diff --git a/docs/installation.md b/docs/installation.md new file mode 100644 index 0000000..02b25c9 --- /dev/null +++ b/docs/installation.md @@ -0,0 +1,5 @@ +# Installation +```{include} ../README.md +:start-after: "# Installation" +:end-before: "# Having issues or want to contribute?" +``` diff --git a/examples/blocked_solver.py b/examples/blocked_solver.py index 801e4ce..9bedaf9 100644 --- a/examples/blocked_solver.py +++ b/examples/blocked_solver.py @@ -63,7 +63,6 @@ # from pathlib import Path -import logging from mpi4py import MPI import numpy as np import ufl @@ -71,10 +70,6 @@ import dolfinx.fem.petsc import scifem -# Initialize logging and set log level to info - -logging.basicConfig(level=logging.INFO) - # We create the mesh and the function spaces mesh = dolfinx.mesh.create_unit_cube( @@ -211,11 +206,8 @@ def post_solve(problem: dolfinx.fem.petsc.NonlinearProblem): p.add_mesh(warped, show_edges=False) p.show_axes() figure = Path("blocked_solver.png") - if not pyvista.OFF_SCREEN: - p.show(screenshot=figure) - else: - _figure_as_array = p.screenshot(figure) - + p.export_html(figure.with_suffix(".html")) + p.show(screenshot=figure) # # References # ```{bibliography} diff --git a/examples/evaluate_function.py b/examples/evaluate_function.py index 33fe1be..01f4bb9 100644 --- a/examples/evaluate_function.py +++ b/examples/evaluate_function.py @@ -49,3 +49,27 @@ u_values = evaluate_function(u, points) print(u_values) + +# Finally, we plot the function, and label each point with the value we evaluated there + +import pyvista + +grid = pyvista.UnstructuredGrid(*dolfinx.plot.vtk_mesh(V)) +grid.point_data["u"] = u.x.array.real +points_3d = np.zeros((points.shape[0], 3)) +points_3d[:, :2] = points + +plotter = pyvista.Plotter() +plotter.add_mesh(grid, scalars="u", show_edges=True, cmap="viridis") +plotter.add_point_labels( + points_3d, + [f"{value:.2f}" for value in np.asarray(u_values).real.flatten()], + point_size=14, + point_color="red", + render_points_as_spheres=True, + font_size=16, + always_visible=True, +) +plotter.view_xy() +plotter.export_html("evaluate_function.html") +plotter.show(screenshot="evaluate_function.png") diff --git a/examples/mark_entities.py b/examples/mark_entities.py index 67a2aed..18cf205 100644 --- a/examples/mark_entities.py +++ b/examples/mark_entities.py @@ -53,8 +53,8 @@ def inner(x): plotter = pyvista.Plotter() plotter.add_mesh(grid) plotter.view_xy() -if not pyvista.OFF_SCREEN: - plotter.show() +plotter.export_html("mark_entities.html") +plotter.show(screenshot="mark_entities.png") # We can also mark lower order entities, such as facets diff --git a/examples/periodic_heat_gmsh.py b/examples/periodic_heat_gmsh.py index f4a2aea..e722922 100644 --- a/examples/periodic_heat_gmsh.py +++ b/examples/periodic_heat_gmsh.py @@ -278,8 +278,6 @@ def line(xs): plotter.add_mesh(grid, style="wireframe", color="gray", opacity=0.25) plotter.camera_position = [(0.5, -2.1, 1.9), (0.5, 0.5, 0.15), (0.0, 0.0, 1.0)] plotter.camera.zoom(1.15) - if not pyvista.OFF_SCREEN: - plotter.show() - else: - plotter.screenshot("periodic_heat.png") + plotter.export_html("periodic_heat_gmsh.html") + plotter.show(screenshot="periodic_heat_gmsh.png") # - diff --git a/examples/periodic_poisson.py b/examples/periodic_poisson.py index da3046a..57ab3c5 100644 --- a/examples/periodic_poisson.py +++ b/examples/periodic_poisson.py @@ -573,8 +573,6 @@ def u_exact(x): # Looking down on the square, tilted just enough for the warp to read as height. plotter.camera_position = [(0.5, -2.0, 1.3), (0.5, 0.5, 0.0), (0.0, 0.0, 1.0)] plotter.camera.zoom(1.05) - if not pyvista.OFF_SCREEN: - plotter.show() - else: - plotter.screenshot("periodic_poisson_solution.png") + plotter.export_html("periodic_poisson.html") + plotter.show(screenshot="periodic_poisson.png") # - diff --git a/examples/point_source.py b/examples/point_source.py index 2f66da9..a32a738 100644 --- a/examples/point_source.py +++ b/examples/point_source.py @@ -143,5 +143,5 @@ plotter.add_mesh(grid, style="wireframe", color="k") plotter.add_mesh(glyphs) plotter.view_xy() -if not pyvista.OFF_SCREEN: - plotter.show() +plotter.export_html("point_source.html") +plotter.show(screenshot="point_source.png") diff --git a/examples/real_function_space.py b/examples/real_function_space.py index b96b32c..f59ab35 100644 --- a/examples/real_function_space.py +++ b/examples/real_function_space.py @@ -307,6 +307,6 @@ def u_exact(x): plotter = pyvista.Plotter() plotter.add_mesh(grid, style="wireframe") plotter.add_mesh(warped) -if not pyvista.OFF_SCREEN: - plotter.show() +plotter.export_html("real_function_space.html") +plotter.show(screenshot="real_function_space.png") # - diff --git a/examples/transfer_tags_to_submesh.py b/examples/transfer_tags_to_submesh.py index 1a894c2..f1d13c5 100644 --- a/examples/transfer_tags_to_submesh.py +++ b/examples/transfer_tags_to_submesh.py @@ -74,10 +74,10 @@ # + tags=["hide-input"] -def plot_mesh(mesh: dolfinx.mesh.Mesh, values=None): +def plot_mesh(mesh: dolfinx.mesh.Mesh, values=None, figure=None): """ Given a DOLFINx mesh, create a `pyvista.UnstructuredGrid`, - and plot it and the mesh nodes + and plot it and the mesh nodes, optionally saving it as `figure`.png and `figure`.html """ plotter = pyvista.Plotter() V_linear = dolfinx.fem.functionspace(mesh, ("Lagrange", 1)) @@ -97,7 +97,9 @@ def plot_mesh(mesh: dolfinx.mesh.Mesh, values=None): plotter.add_mesh(linear_grid, show_edges=True) plotter.show_axes() plotter.view_xy() - plotter.show() + if figure is not None: + plotter.export_html(f"{figure}.html") + plotter.show(screenshot=False if figure is None else f"{figure}.png") plot_mesh(circular_mesh, cell_marker.values) @@ -116,5 +118,5 @@ def plot_mesh(mesh: dolfinx.mesh.Mesh, values=None): # and visualize it # + tags=["hide-input"] -plot_mesh(submesh, sub_cell_marker.values) +plot_mesh(submesh, sub_cell_marker.values, figure="transfer_tags_to_submesh") # - diff --git a/examples/xdmf_point_cloud.py b/examples/xdmf_point_cloud.py index 6f1e2b5..9c59ed1 100644 --- a/examples/xdmf_point_cloud.py +++ b/examples/xdmf_point_cloud.py @@ -49,8 +49,8 @@ point_size=20, show_scalar_bar=False, ) -if not pyvista.OFF_SCREEN: - plotter.show() +plotter.export_html("xdmf_point_cloud.html") +plotter.show(screenshot="xdmf_point_cloud.png") # Using `scifem`, we can write the point cloud data to an XDMFFile that can be opened with Paraview. diff --git a/index.md b/index.md new file mode 100644 index 0000000..696414b --- /dev/null +++ b/index.md @@ -0,0 +1,10 @@ +# Scientific Computing Tools for Finite Element Methods +```{include} README.md +:start-after: "# Scientific Computing Tools for Finite Element Methods" +:end-before: "" +``` + +Below you will find several examples of how to use the library: + +```{demo-gallery} +``` From 1e62f249037a3e1713bcf139f1870d6c5cd2ff0f Mon Sep 17 00:00:00 2001 From: Joergen Schartum Dokken Date: Wed, 7 Oct 2026 08:50:37 +0000 Subject: [PATCH 5/7] Minor tweak to gallery view. Saving memory in exporting scenes. --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index c88d649..9f2aa4f 100644 --- a/.gitignore +++ b/.gitignore @@ -179,4 +179,4 @@ _probe*.py *.png # Interactive scenes the examples export for the gallery -examples/*.html +examples/*.vtksz From a5be8db1ebecd8b26433e97de0d6b09e099f9f42 Mon Sep 17 00:00:00 2001 From: Joergen Schartum Dokken Date: Wed, 7 Oct 2026 08:51:05 +0000 Subject: [PATCH 6/7] Export scenes in compact way. --- docs/_ext/demo_gallery.py | 156 ++++++++++++++++++++------- examples/blocked_solver.py | 2 +- examples/evaluate_function.py | 2 +- examples/mark_entities.py | 2 +- examples/periodic_heat_gmsh.py | 2 +- examples/periodic_poisson.py | 2 +- examples/point_source.py | 2 +- examples/real_function_space.py | 2 +- examples/transfer_tags_to_submesh.py | 2 +- examples/xdmf_point_cloud.py | 2 +- 10 files changed, 129 insertions(+), 45 deletions(-) diff --git a/docs/_ext/demo_gallery.py b/docs/_ext/demo_gallery.py index 2fc76cf..094528d 100644 --- a/docs/_ext/demo_gallery.py +++ b/docs/_ext/demo_gallery.py @@ -3,22 +3,34 @@ # 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. +"""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.html``. 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. +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 @@ -30,16 +42,43 @@ 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", "*.html") +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: @@ -51,14 +90,13 @@ def _title(demo: Path) -> str: 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 - gallery = f"{prefix}_static/gallery" return ( f"````{{grid-item-card}} {title}\n:link: {docname}\n:link-type: doc\n\n" "```{raw} html\n" '\n```\n````\n" ) @@ -99,50 +137,96 @@ def run(self) -> list[nodes.Node]: return container.children -def _resolve(page: Path, target: Path) -> None: - """Point each figure of a built page 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.""" - html = page.read_text() +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 (target / f"{stem}{suffix}").exists(): + 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 (target / f"{match.group(1)}.html").exists() else "" + return match.group(0) if (scenes / f"{match.group(1)}.vtksz.js").exists() else "" - resolved = re.sub(r']*>\n?', choose, html) - resolved = re.sub( + html = re.sub(rf']*>\n?', choose, html) + return re.sub( r'\n?', + rf'data-scene="(?:\.\./)*{SCENES}/{re.escape(VIEWER)}\?scene=([\w-]+)"' + r"[^>]*>3D\n?", keep_button, - resolved, + html, ) - if resolved != html: - page.write_text(resolved) -def build_gallery(app: Sphinx, exception: Exception | None) -> None: - """Copy the figures of the demos to ``_static/gallery`` of an HTML build, and resolve those of - the pages with a gallery.""" +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 - target = Path(app.outdir) / "_static" / "gallery" - target.mkdir(parents=True, exist_ok=True) + 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, target / figure.name) - for page in Path(app.outdir).rglob("*.html"): - if target in page.parents: + 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 - if "_static/gallery/" in page.read_text(): - _resolve(page, target) + html = page.read_text() + resolved = html + if f"{GALLERY}/" in resolved: + resolved = _resolve_gallery(resolved, gallery, scenes) + if "