diff --git a/README.md b/README.md
index 441c9c98..6af9da4b 100644
--- a/README.md
+++ b/README.md
@@ -109,6 +109,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 
 ### [Unreleased]
 
+#### Added
+- `ElementTetMini`, MINI-element for tetrahedral mesh
+
+#### Fixed
+- `Mesh3d.boundary_edges` incorrectly returned all edges where both nodes are on
+the boundary
+
 ### [1.0.0] - 2020-04-22
 
 #### Added
diff --git a/docs/examples/ex04.py b/docs/examples/ex04.py
index 97850549..5bcafc13 100644
--- a/docs/examples/ex04.py
+++ b/docs/examples/ex04.py
@@ -1,4 +1,4 @@
-"""Contact problem.
+r"""Contact problem.
 
 Mortar methods allow setting interface conditions on non-matching meshes.
 They are useful also when solving variational inequalities such as
diff --git a/docs/examples/ex05.py b/docs/examples/ex05.py
index 0b4026ad..f7b0dd33 100644
--- a/docs/examples/ex05.py
+++ b/docs/examples/ex05.py
@@ -1,4 +1,4 @@
-"""Integral condition.
+r"""Integral condition.
 
 This short example demonstrates the implementation of an integral boundary
  condition
diff --git a/docs/examples/ex06.py b/docs/examples/ex06.py
index 5dc20b69..3ec46d36 100644
--- a/docs/examples/ex06.py
+++ b/docs/examples/ex06.py
@@ -1,4 +1,4 @@
-"""High-order plotting.
+r"""High-order plotting.
 
 This simple example demonstrates the usage and visualisation of biquadratic
 finite element basis. Many plotting tools, including matplotlib, provide tools
diff --git a/skfem/mesh/mesh2d/mesh_tri.py b/skfem/mesh/mesh2d/mesh_tri.py
index ef835f4c..c0d5b383 100644
--- a/skfem/mesh/mesh2d/mesh_tri.py
+++ b/skfem/mesh/mesh2d/mesh_tri.py
@@ -273,7 +273,7 @@ class MeshTri(Mesh2D):
         self.t2f = ixb.reshape((3, self.t.shape[1]))
 
         # build facet-to-triangle mapping: 2 (triangles) x Nedges
-        e_tmp = np.hstack((self.t2f[0, :], self.t2f[1, :], self.t2f[2, :]))
+        e_tmp = np.hstack((self.t2f[0], self.t2f[1], self.t2f[2]))
         t_tmp = np.tile(np.arange(self.t.shape[1]), (1, 3))[0]
 
         e_first, ix_first = np.unique(e_tmp, return_index=True)
@@ -286,7 +286,7 @@ class MeshTri(Mesh2D):
         self.f2t[1, e_last] = t_tmp[ix_last]
 
         # second row to zero if repeated (i.e., on boundary)
-        self.f2t[1, np.nonzero(self.f2t[0, :] == self.f2t[1, :])[0]] = -1
+        self.f2t[1, np.nonzero(self.f2t[0] == self.f2t[1])[0]] = -1
 
     def _uniform_refine(self):
         """Perform a single mesh refine."""
@@ -298,23 +298,23 @@ class MeshTri(Mesh2D):
         t2f = self.t2f + sz
 
         # new vertices are the midpoints of edges
-        new_p = 0.5 * np.vstack((p[0, e[0, :]] + p[0, e[1, :]],
-                                 p[1, e[0, :]] + p[1, e[1, :]]))
+        new_p = 0.5 * np.vstack((p[0, e[0]] + p[0, e[1]],
+                                 p[1, e[0]] + p[1, e[1]]))
         self.p = np.hstack((p, new_p))
 
         # build new triangle definitions
         self.t = np.hstack((
-            np.vstack((t[0, :], t2f[0, :], t2f[2, :])),
-            np.vstack((t[1, :], t2f[0, :], t2f[1, :])),
-            np.vstack((t[2, :], t2f[2, :], t2f[1, :])),
-            np.vstack((t2f[0, :], t2f[1, :], t2f[2, :])),
+            np.vstack((t[0], t2f[0], t2f[2])),
+            np.vstack((t[1], t2f[0], t2f[1])),
+            np.vstack((t[2], t2f[2], t2f[1])),
+            np.vstack((t2f[0], t2f[1], t2f[2])),
         ))
 
         # mapping of indices between old and new facets
         new_facets = np.zeros((2, e.shape[1]), dtype=np.int64)
         ix0 = np.arange(t.shape[1], dtype=np.int64)
         ix1 = ix0 + t.shape[1]
-        ix2 = ix0 + 2*t.shape[1]
+        ix2 = ix0 + 2 * t.shape[1]
 
         # rebuild mappings
         self._build_mappings()
@@ -334,12 +334,12 @@ class MeshTri(Mesh2D):
 
         def sort_mesh(p, t):
             """Make (0, 2) the longest edge in t."""
-            l01 = np.sqrt(np.sum((p[:, t[0, :]] - p[:, t[1, :]])**2, axis=0))
-            l12 = np.sqrt(np.sum((p[:, t[1, :]] - p[:, t[2, :]])**2, axis=0))
-            l02 = np.sqrt(np.sum((p[:, t[0, :]] - p[:, t[2, :]])**2, axis=0))
+            l01 = np.sqrt(np.sum((p[:, t[0]] - p[:, t[1]]) ** 2, axis=0))
+            l12 = np.sqrt(np.sum((p[:, t[1]] - p[:, t[2]]) ** 2, axis=0))
+            l02 = np.sqrt(np.sum((p[:, t[0]] - p[:, t[2]]) ** 2, axis=0))
 
-            ix01 = (l01 > l02)*(l01 > l12)
-            ix12 = (l12 > l01)*(l12 > l02)
+            ix01 = (l01 > l02) * (l01 > l12)
+            ix12 = (l12 > l01) * (l12 > l02)
 
             # row swaps
             tmp = t[2, ix01]
@@ -372,11 +372,11 @@ class MeshTri(Mesh2D):
             ix[facets == 1] = np.arange(np.count_nonzero(facets)) + m.p.shape[1]
             ix = ix[m.t2f] # (0, 1) (1, 2) (0, 2)
 
-            red =   (ix[0, :] >= 0) * (ix[1, :] >= 0) * (ix[2, :] >= 0)
-            blue1 = (ix[0, :] ==-1) * (ix[1, :] >= 0) * (ix[2, :] >= 0)
-            blue2 = (ix[0, :] >= 0) * (ix[1, :] ==-1) * (ix[2, :] >= 0)
-            green = (ix[0, :] ==-1) * (ix[1, :] ==-1) * (ix[2, :] >= 0)
-            rest =  (ix[0, :] ==-1) * (ix[1, :] ==-1) * (ix[2, :] ==-1)
+            red =   (ix[0] >= 0) * (ix[1] >= 0) * (ix[2] >= 0)
+            blue1 = (ix[0] ==-1) * (ix[1] >= 0) * (ix[2] >= 0)
+            blue2 = (ix[0] >= 0) * (ix[1] ==-1) * (ix[2] >= 0)
+            green = (ix[0] ==-1) * (ix[1] ==-1) * (ix[2] >= 0)
+            rest =  (ix[0] ==-1) * (ix[1] ==-1) * (ix[2] ==-1)
 
             # new red elements
             t_red = np.hstack((
@@ -425,6 +425,6 @@ class MeshTri(Mesh2D):
     def element_finder(self):
         from matplotlib.tri import Triangulation
 
-        return Triangulation(self.p[0, :],
-                             self.p[1, :],
+        return Triangulation(self.p[0],
+                             self.p[1],
                              self.t.T).get_trifinder()
diff --git a/skfem/mesh/mesh3d/mesh3d.py b/skfem/mesh/mesh3d/mesh3d.py
index 2b3baf8c..c20afa3c 100644
--- a/skfem/mesh/mesh3d/mesh3d.py
+++ b/skfem/mesh/mesh3d/mesh3d.py
@@ -32,8 +32,14 @@ class Mesh3D(Mesh):
 
     def boundary_edges(self) -> ndarray:
         """Return an array of boundary edge indices."""
-        return np.nonzero(np.isin(self.edges,
-                                  self.boundary_nodes()).all(axis=0))[0]
+        facets = self.boundary_facets()
+        boundary_edges = np.hstack((
+            self.facets[0:2, facets],
+            self.facets[1:3, facets],
+            self.facets[0:3:2, facets],
+        )).T
+        return np.nonzero((self.edges.T[:, None] == boundary_edges)\
+                          .all(-1).any(-1))[0]
 
     def interior_edges(self) -> ndarray:
         """Return an array of interior edge indices."""

Incorrect edges returned by `Mesh3D.boundary_edges`
I've been working on applying the block-diagonally preconditioned MINRES+AMG Stokes solver of #342 & ex32 to other MeshTet geometries and found some odd errors.  It seems to have nothing to do with the operators but something to do with `.find_dofs` when there are several Gmsh elementary entities in a single Gmsh `Physical Surface`.  The situation here is three Physical Surfaces: inlet, outlet, and wall, with uniform positive pressure applied at the inlet, zero pressure at the outlet, and zero velocity on the wall.  Typically the inlet and outlet are single elementary surfaces and the walls include everything else.  Example: a cube with inlet and outlet on an opposing pair of faces and the other four being walls.

I generate a tetrahedral mesh with marked boundaries in Gmsh, marking the boundaries in two different ways: first with all four walls combined, second with a separate Physical Surface for each.
```
SetFactory("OpenCASCADE");

Box(1) = {0, 0, 0, 1, 1, 1};

Physical Volume("fluid", 13) = {1};

Physical Surface("inlet") = {1};
Physical Surface("outlet") = {2};

Physical Surface("wall") = {3, 4, 5, 6};

Physical Surface("wall3") = {3};
Physical Surface("wall4") = {4};
Physical Surface("wall5") = {5};
Physical Surface("wall6") = {6};
```

I load this with 
```
import numpy as np
import skfem

basis = skfem.InteriorBasis(skfem.MeshTet.load("duct.msh"), skfem.ElementTetP2())

walls = {"separate": np.concatenate([b.all() for k, b in basis.find_dofs().items()
                                     if k.startswith("wall") and k[len("wall") :]]),
         "combined": np.concatenate([b.all() for k, b in basis.find_dofs().items()
                                     if k.endswith("wall")])}
```

Now I was thinking that `walls["separate"]` and `walls["combined"]` would be equal arrays of degrees of freedom on the walls but apparently not.

```python
walls["diff"] = np.setdiff1d(walls["combined"], walls["separate"])
print('In "separate" but not "combined"',  np.setdiff1d(walls["separate"], walls["combined"]))  # empty
print('In "combined" but not "separate"', walls["diff"])  # several
print('In "combined", not "separate", and not edge', np.setdiff1d(walls["diff"], basis.edge_dofs))  # empty
```

The "combined" approach has more degrees of freedom on the walls, with the extra ones all being in `basis.edge_dofs`.  (I haven't located them more precisely yet.)  The result is typically a singular matrix.  The "separated" approach seems to lead to a correct matrix

I haven't ascertained whether the discrepancy is in Gmsh, meshio, or `skfem.MeshTet.load` (or in my understanding of `basis.find_dofs`).
diff --git a/tests/test_basis.py b/tests/test_basis.py
index 8d61a27c..93520994 100644
--- a/tests/test_basis.py
+++ b/tests/test_basis.py
@@ -63,12 +63,19 @@ class TestFacetExpansion(TestCase):
     def runTest(self):
 
         m = MeshTet()
+        m.refine(2)
 
         basis = InteriorBasis(m, ElementTetP2())
 
-        arr1 = basis.find_dofs({
-            'kek':m.facets_satisfying(lambda x: x[0] == 0)
-        })['kek'].edge['u']
-        arr2 = basis.edge_dofs[:, m.edges_satisfying(lambda x: x[0] == 0)]
-
-        assert_allclose(arr1, arr2.flatten())
+        for fun in [lambda x: x[0] == 0,
+                    lambda x: x[0] == 1,
+                    lambda x: x[1] == 0,
+                    lambda x: x[1] == 1,
+                    lambda x: x[2] == 0,
+                    lambda x: x[2] == 1]:
+            arr1 = basis.find_dofs({
+                'kek': m.facets_satisfying(fun)
+            })['kek'].edge['u']
+            arr2 = basis.edge_dofs[:, m.edges_satisfying(fun)]
+
+            assert_allclose(arr1, arr2.flatten())
diff --git a/tests/test_convergence.py b/tests/test_convergence.py
index cc2483c3..7a662592 100644
--- a/tests/test_convergence.py
+++ b/tests/test_convergence.py
@@ -224,9 +224,9 @@ class ConvergenceTetP1(ConvergenceQ1):
 
 
 class ConvergenceTetP2(ConvergenceTetP1):
-    rateL2 = 3.0
-    rateH1 = 2.0
-    eps = 0.132
+    rateL2 = 3.32
+    rateH1 = 2.16
+    eps = 0.01
 
     def create_basis(self, m):
         e = ElementTetP2()
diff --git a/tests/test_examples.py b/tests/test_examples.py
index a48ab7a0..6c27a5d9 100644
--- a/tests/test_examples.py
+++ b/tests/test_examples.py
@@ -228,7 +228,7 @@ class TestEx33(TestCase):
 
     def runTest(self):
         from docs.examples.ex33 import x
-        self.assertAlmostEqual(np.max(x), 0.1240571191753624, delta=1e-8)
+        self.assertAlmostEqual(np.max(x), 0.12412747286589934, delta=1e-8)
 
 
 class TestEx34(TestCase):
diff --git a/tests/test_mesh.py b/tests/test_mesh.py
index 030178d6..6646b1cb 100644
--- a/tests/test_mesh.py
+++ b/tests/test_mesh.py
@@ -141,5 +141,18 @@ class SerializeUnserializeCycle(unittest.TestCase):
                 self.assertTrue((m.subdomains[k] == M.subdomains[k]).all())
 
 
+class TestBoundaryEdges(unittest.TestCase):
+
+    def runTest(self):
+        m = MeshTet()
+        # default mesh has all edges on the boundary
+        self.assertTrue(len(m.boundary_edges()) == m.edges.shape[1])
+        m.refine()
+        # check that there is a correct amount of boundary edges:
+        # 12 (cube edges) * 2 (per cube edge) + 6 (cube faces) * 8 (per cube face)
+        # = 72 edges
+        self.assertTrue(len(m.boundary_edges()) == 72)
+
+
 if __name__ == '__main__':
     unittest.main()

pip install -e .[full]
parse_log_pytest
requirements.txt
pytest
apt-get update apt-get install -y gcc
3.9
requirements.txt
pytest --no-header -rA --tb=line --color=no -p no:cacheprovider -W ignore::DeprecationWarning
alabaster==0.7.16
asttokens==3.0.0
attrs==25.3.0
babel==2.17.0
cattrs==24.1.3
certifi==2025.1.31
cffi==1.17.1
charset-normalizer==3.4.1
contourpy==1.3.0
cryptography==44.0.2
cycler==0.12.1
decorator==5.2.1
distro==1.9.0
dmsh==0.3.4
docutils==0.21.2
exceptiongroup==1.2.2
executing==2.2.0
fonttools==4.56.0
getmac==0.9.5
gmsh==4.13.1
idna==3.10
imagesize==1.4.1
importlib_metadata==8.6.1
importlib_resources==6.5.2
iniconfig==2.1.0
ipython==8.18.1
jedi==0.19.2
Jinja2==3.1.6
kiwisolver==1.4.7
markdown-it-py==3.0.0
MarkupSafe==3.0.2
matplotlib==3.9.4
matplotlib-inline==0.1.7
mdurl==0.1.2
meshio==5.3.5
meshplex==0.19.7
npx==0.1.6
numpy==2.0.2
packaging==24.2
pacopy==0.2.16
parso==0.8.4
pexpect==4.9.0
pillow==11.1.0
platformdirs==4.3.7
pluggy==1.5.0
prompt_toolkit==3.0.50
ptyprocess==0.7.0
pure_eval==0.2.3
py-machineid==0.7.0
pycparser==2.22
Pygments==2.19.1
pygmsh==7.1.17
pyparsing==3.2.3
pytest==8.3.5
python-dateutil==2.9.0.post0
python-package-info==0.0.15
requests==2.32.3
requests-cache==1.2.1
rich==14.0.0
rich-argparse==1.7.0
-e git+https://github.com/kinnala/scikit-fem.git@cd048e6a67f67ef265b3f0ebae7b3ac78238dbae#egg=scikit_fem
scipy==1.13.1
six==1.17.0
snowballstemmer==2.2.0
Sphinx==7.4.7
sphinx-rtd-theme==3.0.2
sphinxcontrib-applehelp==2.0.0
sphinxcontrib-devhelp==2.0.0
sphinxcontrib-htmlhelp==2.1.0
sphinxcontrib-jquery==4.1
sphinxcontrib-jsmath==1.0.1
sphinxcontrib-qthelp==2.0.0
sphinxcontrib-serializinghtml==2.0.0
stack-data==0.6.3
stonefish-license-manager==0.5.31
tomli==2.2.1
traitlets==5.14.3
typing_extensions==4.13.0
url-normalize==2.2.0
urllib3==2.3.0
wcwidth==0.2.13
x21==0.5.3
x22==0.2.1
zipp==3.21.0

name: scikit-fem
channels:
  - defaults
  - https://repo.anaconda.com/pkgs/main
  - https://repo.anaconda.com/pkgs/r
  - conda-forge
dependencies:
  - _libgcc_mutex=0.1=main
  - _openmp_mutex=5.1=1_gnu
  - ca-certificates=2025.2.25=h06a4308_0
  - ld_impl_linux-64=2.40=h12ee557_0
  - libffi=3.4.4=h6a678d5_1
  - libgcc-ng=11.2.0=h1234567_1
  - libgomp=11.2.0=h1234567_1
  - libstdcxx-ng=11.2.0=h1234567_1
  - ncurses=6.4=h6a678d5_0
  - openssl=3.0.16=h5eee18b_0
  - pip=25.0=py39h06a4308_0
  - python=3.9.21=he870216_1
  - readline=8.2=h5eee18b_0
  - setuptools=75.8.0=py39h06a4308_0
  - sqlite=3.45.3=h5eee18b_0
  - tk=8.6.14=h39e8969_0
  - tzdata=2025a=h04d1e81_0
  - wheel=0.45.1=py39h06a4308_0
  - xz=5.6.4=h5eee18b_1
  - zlib=1.2.13=h5eee18b_1
  - pip:
      - alabaster==0.7.16
      - asttokens==3.0.0
      - attrs==25.3.0
      - babel==2.17.0
      - cattrs==24.1.3
      - certifi==2025.1.31
      - cffi==1.17.1
      - charset-normalizer==3.4.1
      - contourpy==1.3.0
      - cryptography==44.0.2
      - cycler==0.12.1
      - decorator==5.2.1
      - distro==1.9.0
      - dmsh==0.3.4
      - docutils==0.21.2
      - exceptiongroup==1.2.2
      - executing==2.2.0
      - fonttools==4.56.0
      - getmac==0.9.5
      - gmsh==4.13.1
      - idna==3.10
      - imagesize==1.4.1
      - importlib-metadata==8.6.1
      - importlib-resources==6.5.2
      - iniconfig==2.1.0
      - ipython==8.18.1
      - jedi==0.19.2
      - jinja2==3.1.6
      - kiwisolver==1.4.7
      - markdown-it-py==3.0.0
      - markupsafe==3.0.2
      - matplotlib==3.9.4
      - matplotlib-inline==0.1.7
      - mdurl==0.1.2
      - meshio==5.3.5
      - meshplex==0.19.7
      - npx==0.1.6
      - numpy==2.0.2
      - packaging==24.2
      - pacopy==0.2.16
      - parso==0.8.4
      - pexpect==4.9.0
      - pillow==11.1.0
      - platformdirs==4.3.7
      - pluggy==1.5.0
      - prompt-toolkit==3.0.50
      - ptyprocess==0.7.0
      - pure-eval==0.2.3
      - py-machineid==0.7.0
      - pycparser==2.22
      - pygments==2.19.1
      - pygmsh==7.1.17
      - pyparsing==3.2.3
      - pytest==8.3.5
      - python-dateutil==2.9.0.post0
      - python-package-info==0.0.15
      - requests==2.32.3
      - requests-cache==1.2.1
      - rich==14.0.0
      - rich-argparse==1.7.0
      - scipy==1.13.1
      - six==1.17.0
      - snowballstemmer==2.2.0
      - sphinx==7.4.7
      - sphinx-rtd-theme==3.0.2
      - sphinxcontrib-applehelp==2.0.0
      - sphinxcontrib-devhelp==2.0.0
      - sphinxcontrib-htmlhelp==2.1.0
      - sphinxcontrib-jquery==4.1
      - sphinxcontrib-jsmath==1.0.1
      - sphinxcontrib-qthelp==2.0.0
      - sphinxcontrib-serializinghtml==2.0.0
      - stack-data==0.6.3
      - stonefish-license-manager==0.5.31
      - tomli==2.2.1
      - traitlets==5.14.3
      - typing-extensions==4.13.0
      - url-normalize==2.2.0
      - urllib3==2.3.0
      - wcwidth==0.2.13
      - x21==0.5.3
      - x22==0.2.1
      - zipp==3.21.0
prefix: /opt/conda/envs/scikit-fem

tests/test_mesh.py::TestBoundaryEdges::runTest
tests/test_basis.py::TestCompositeSplitting::runTest tests/test_convergence.py::ConvergenceQ1::runTest tests/test_convergence.py::ConvergenceQ2::runTest tests/test_convergence.py::ConvergenceQuadS2::runTest tests/test_convergence.py::ConvergenceTriP1::runTest tests/test_convergence.py::ConvergenceTriP2::runTest tests/test_convergence.py::ConvergenceTriMini::runTest tests/test_convergence.py::ConvergenceHex1::runTest tests/test_convergence.py::ConvergenceTetP1::runTest tests/test_convergence.py::ConvergenceTetP2::runTest tests/test_convergence.py::ConvergenceTetMini::runTest tests/test_convergence.py::ConvergenceLineP1::runTest tests/test_convergence.py::ConvergenceLineP2::runTest tests/test_examples.py::TestEx01::runTest tests/test_examples.py::TestEx02::runTest tests/test_examples.py::TestEx03::runTest tests/test_examples.py::TestEx04::runTest tests/test_examples.py::TestEx05::runTest tests/test_examples.py::TestEx06::runTest tests/test_examples.py::TestEx07::runTest tests/test_examples.py::TestEx08::runTest tests/test_examples.py::TestEx10::runTest tests/test_examples.py::TestEx11::runTest tests/test_examples.py::TestEx12::runTest tests/test_examples.py::TestEx13::runTest tests/test_examples.py::TestEx14::runTest tests/test_examples.py::TestEx15::runTest tests/test_examples.py::TestEx17::runTest tests/test_examples.py::TestEx18::runTest tests/test_examples.py::TestEx19::runTest tests/test_examples.py::TestEx20::runTest tests/test_examples.py::TestEx21::runTest tests/test_examples.py::TestEx22::runTest tests/test_examples.py::TestEx23::runTest tests/test_examples.py::TestEx24::runTest tests/test_examples.py::TestEx25::runTest tests/test_examples.py::TestEx26::runTest tests/test_examples.py::TestEx27::runTest tests/test_examples.py::TestEx28::runTest tests/test_examples.py::TestEx29::runTest tests/test_examples.py::TestEx30::runTest tests/test_examples.py::TestEx31::runTest tests/test_examples.py::TestEx32::runTest tests/test_examples.py::TestEx33::runTest tests/test_examples.py::TestEx34::runTest tests/test_mesh.py::MeshTests::runTest tests/test_mesh.py::RefinePreserveSubsets::runTest tests/test_mesh.py::SerializeUnserializeCycle::runTest
tests/test_basis.py::TestFacetExpansion::runTest tests/test_convergence.py::FacetConvergenceTetP2::runTest tests/test_convergence.py::FacetConvergenceHex1::runTest tests/test_convergence.py::FacetConvergenceTetP1::runTest tests/test_examples.py::TestEx09::runTest tests/test_examples.py::TestEx16::runTest tests/test_mesh.py::FaultyInputs::runTest tests/test_mesh.py::Loading::runTest tests/test_mesh.py::SaveLoadCycle::runTest tests/test_mesh.py::SaveLoadCycleHex::runTest
skfem/mesh/mesh3d/mesh3d.py skfem/mesh/mesh2d/mesh_tri.py docs/examples/ex05.py docs/examples/ex06.py docs/examples/ex04.py README.md
skfem/mesh/mesh3d/mesh3d.py skfem/mesh/mesh2d/mesh_tri.py docs/examples/ex05.py docs/examples/ex06.py docs/examples/ex04.py README.md