4. Geometry, meshing and I/O
4. Geometry, meshing and I/O¶
| Tool | Version | Licence | Verdict |
|---|---|---|---|
Netgen OCC (netgen.occ) |
6.2.2606 | LGPL-2.1 | Default. Same OpenCASCADE kernel, in-process, no extra binary, no GPL exposure |
| Gmsh (Python API) | 4.15.2 | GPLv2+ | Optional backend. Best-in-class: BoundaryLayer (works in 2D), Distance/Threshold, Min/Restrict field algebra, Fan points, quads-in-BL |
| scikit-image | 0.26.0 | BSD-3 | measure.find_contours (sub-pixel marching squares), marching_cubes |
| Shapely | 2.1.2 | BSD-3 | LinearRing, is_valid, is_simple, make_valid, simplify, buffer |
| meshio | 5.3.5 | MIT | Reliable but last release 2024-01 — treat as stable-frozen |
| CadQuery / build123d | — | Apache-2.0 | 3D-solid-centric; overkill for a 2D (r,z) region |
| pythonocc-core | — | LGPLv3 | Raw OCCT; only for BRep edge cases |
Avoid: MeshPy — actively maintained (2026.1) but wraps Triangle, whose licence states
distribution as part of a commercial system is "permissible ONLY BY DIRECT ARRANGEMENT WITH THE
AUTHOR", and TetGen 1.5 (AGPLv3). Also avoid pygmsh (last release 2022-01) and
pygalmesh (2022-09), both stale — call the gmsh API directly instead.
Shapely offsetting: parallel_offset is not removed and emits no DeprecationWarning in
2.1.2 [tested: tests/tier1/test_library_behaviour.py::test_shapely_and_skimage_contour_and_offset_facts], but its docstring marks it legacy in favour of offset_curve (different
sign/quad_segs convention). For a closed contour, buffer(-d) is more robust than either.
find_contours orientation in (r, z) [tested: tests/tier1/test_library_behaviour.py::test_shapely_and_skimage_contour_and_offset_facts]. On a [z, r] array with
positive_orientation="high", a point (row, col) maps to (r, z) = (col·Δr, row·Δz), which
swaps the axes, so the winding flips from the one the docstring describes. A loop around values
above the level has a negative shoelace area in (r, z), clockwise, and a loop around a void
inside it is counter-clockwise. Stage 4 tells bodies from holes that way (nanopnp.geometry.contour,
WP20 D4). A contour that reaches the array's edge comes back open, with its first and last points
distinct, and fully_connected="high" joins diagonal neighbours above the level. Measured with
scikit-image 0.26.0 on 26 September 2026.
Shapely closing and opening [tested: tests/tier1/test_library_behaviour.py::test_shapely_and_skimage_contour_and_offset_facts]. region.buffer(δ, quad_segs=8, join_style="round")
.buffer(−δ, …) is a closing and the reverse order an opening; both keep a straight wall straight,
fill a slot narrower than 2δ completely, and remove a fin thinner than 2δ with no bump at its root.
A 90° corner moves by δ²(1 − π/4) in area, concave corners gaining and convex ones losing. The
output ring is densely sampled on its arcs, with edges of about δπ/16, which is why stage 4
resamples before it measures a feature size. Measured with Shapely 2.1.2 (VER-51).
Keep MSH 4.1 as the archival interchange format — physical groups survive round-trips there and get mangled in some VTU paths.
Netgen's own .vol round-trips boundary and material names [tested: tests/tier1/test_library_behaviour.py::test_netgen_vol_round_trip_and_read_gmsh_msh41_failure]. mesh.ngmesh.Save(path)
followed by ngsolve.Mesh(path) returns GetMaterials() and GetBoundaries() unchanged, so a
mesh written by one process and solved by another keeps the vocabulary the essential conditions and
the definedon restrictions are written against. Measured on a CylindricalPoreGeometry at
maxh = 4 nm: materials ['electrolyte', 'membrane', 'cis', 'trans'] and boundaries ['axis',
'cis', 'default', 'membrane', 'membrane_outer', 'trans', 'wall'] on both sides of the write. This
is what makes an externally supplied mesh usable at all — a format that dropped the names would
apply every essential condition to nothing and converge to the wrong problem in silence, so a
release that reads a mesh from a case file must restrict itself to formats that carry them.
Netgen's own reader is an MSH 2.2 parser and cannot read the archival format [tested: tests/tier1/test_library_behaviour.py::test_netgen_vol_round_trip_and_read_gmsh_msh41_failure].
netgen.read_gmsh.ReadGmsh handed a 4.1 file dies inside int() on the entity-block header, so the
reader for the format IF-06 mandates cannot be the one netgen ships. meshio is therefore not
optional bookkeeping — it is the only route in. CON-10 keeps gmsh itself off the default path for
an unrelated reason (GPLv2+), and the two constraints agree.
meshio 5.3.5's MSH 4.1 writer gets entity bookkeeping wrong in two ways, both silent [verified].
- One cell block is one entity.
_write_elementstakes the firstgmsh:geometricaltag of a block and writes the whole block under it. Four boundary groups packed into a singlelineblock come back with all four physical tags equal to the first — three no-flux walls silently becoming one. Cure: emit one cell block per (group, element type). - An entity that owns no node is never written.
_write_entitiesbuilds$Entitiesfromnp.unique(point_data["gmsh:dim_tags"])alone, so an entity a cell block references but no node claims is omitted, and reading the file back raisesKeyError— after the write reported success. Cure: assign each node its lowest-dimensional entity, then repair any group left with none.
Netgen's mesh tables mix 1-based and 0-based indices [tested: tests/tier1/test_mesh_adapter.py::test_if06_to_ngsolve_then_from_ngsolve_is_the_identity_on_the_tag_maps]. Through ngsolve.Mesh.ngmesh,
el.vertices[i].nr, Element2D.index (into GetMaterial) and Element1D.index (into
GetBCName(index - 1)) are 1-based, and FaceDescriptor.SetBCName is 0-based. Mixing the two shifts every
boundary name by one (wall becomes the axis) and nothing raises. Names survive an OCC boolean and a
glue, so naming an embedded body's edges before the chain of position-based names leaves them alone.
When every boundary node is shared by two groups (a closed loop of named edges), a greedy choice of one
node per entity can fail; choose the representatives by bipartite matching with augmenting paths, then
give each leftover node its lowest-dimensional entity, as gmsh does. Source: mesh/adapter.py
(from_ngsolve, to_ngsolve, _node_entities), mesh/primitives.py (name_edges).
meshio.Mesh.field_data is keyed by name, so it cannot carry the same name in two dimensions
[tested: tests/tier1/test_mesh_adapter.py::test_if06_a_name_used_as_a_domain_and_as_a_boundary_survives]. The §5.3.1 vocabulary uses cis, trans, membrane and analyte as both a domain name
and a boundary name, which is exactly the collision. Read $PhysicalNames off the file and key it
(dimension, tag) instead.
A round trip through MSH 4.1 permutes both nodes and cells [tested: tests/tier1/test_mesh_adapter.py::test_if06_the_hash_ignores_a_file_meshio_rewrote], because meshio's reader
returns nodes grouped by entity and cell blocks in entity order. A content hash taken over the arrays
in file order would therefore make a mesh differ from itself. Hash a canonical form: vertices sorted
by (r, z), connectivity renumbered into that order, elements sorted by group name — invariant under
everything the round trip does, and changing the moment an edge moves group.
meshio.read on a .msh file prints an empty line to standard output unless told the format
[tested: tests/tier1/test_library_behaviour.py::test_meshio_msh_stdout_and_xdmf_information_quirks]. meshio 5.3.5 maps the .msh suffix to both ansys and gmsh, tries ansys first, and
prints that reader's ReadError — whose text is empty — before falling through to gmsh. Found by
WP16 (23 September 2026) when a CLI test asserted that a refused nanopnp mesh wrote nothing to
stdout; every nanopnp run on an MSH case had been writing the same stray line, which a caller
parsing the IF-02 result stream would read. Pass file_format="gmsh".
netgen.occ facts, all measured on 6.2.2606 [tested: tests/tier1/test_library_behaviour.py::test_netgen_occ_orientation_glue_compound_and_edge_facts]:
- A clockwise wire gives OCC a face of negative area, and a negative face subtracts as an
addition. With the delivered ClyA table's own orientation (signed area −26.4939 nm²),
disc - quadreturns the disc split in two rather than the disc with a hole, andquad * discreturns nothing at all. Fix the orientation once, where the face is built. Glueis conformal andCompoundis not.occ.Glue([a, b])meshes with one node chain per seam;occ.Compound([a, b])meshes with two coincident chains. On two unit rectangles sharing the edgex = 1, meshed atmaxh = 0.5: glue gives 13 points, 13 of them distinct, 3 on the seam; compound gives 16 points, still 13 distinct, 6 on the seam. Two chains agree to the last digit on coordinates, so only a mesh-level check catches it, and what it costs is a potential free to jump across the seam with nothing raising.edge.centeris the centre of mass, and for an arc it lies off the curve. A classify-by-centre chain works for straight edges and silently misplaces every curved one. Useedge.parameter_intervalwithedge.Value(t)to get a point that is actually on the edge.edge.facesis empty. Traversal is downward only, so an edge cannot be named by which faces adjoin it; classify by geometry instead. The other direction works [tested: tests/tier1/test_library_behaviour.py::test_netgen_occ_orientation_glue_compound_and_edge_facts] (WP21, netgen 6.2.2606): a shape hashes and compares equal to the same topological entity reached another way, soset(face.edges)per named face after aGluegives each edge's adjacency by membership. A seam shared by two faces is in both sets, andset(shape.edges)removes the per-face duplicates (383 listed edges become 195 on the ClyA region). Stage 5 names every edge this way:proteinwithmembraneisinterface,proteinwithelectrolyteiswall,membranewithelectrolyteismembrane. Geometry is left to the two tests adjacency cannot make,r = 0and the reservoir arc.Circle(...).Face()is one closed edge with a seam at parameter zero, and the seam survives a boolean clip as an ordinary vertex. A half-disc of radius 250 clipped tor ≥ 0carries a vertex at(250, 0)that splits whatever arc segment spans it.- A boolean on shapes returns a compound.
.massand.nameon the result raiseNgException: Cannot query properties of compound shapes; read them offlist(shape.faces)[0]. - The mesh netgen generates is not bit-reproducible across platforms.
CylindricalPoreGeometry(2.0, 13.0, 50.0)atmaxh = 2.0 nm,wall_h = 0.05 nmmeshes to 8141 triangles on Linux and macOS and 8147 on Windows — same version, same options, a drift of 0.07 %. The advancing front takes its decisions on floating-point comparisons, and the compiler decides those. So an element count, or a minimum element quality read to four decimals, is a property of the build and not of the geometry: assert a band on either, and keep exact check values for figures computed in closed form on hand-built elements. The three smaller phase-0 geometries (192, 811 and 124 elements) do agree exactly across all three platforms, which is what makes this easy to miss until a mesh is large enough to have a choice to make.
Gmsh facts, measured on 4.15.2 with netgen 6.2.2606, Linux (WSL2), 29 September 2026 [tested: tests/tier1/test_mesh_gmsh.py::test_ver54_the_same_region_passes_the_same_gates_on_both_backends]
(WP23; the implementation is mesh/gmsh_backend.py, the evidence the WP23 plan's Design and
Outcomes):
gmsh.initialize()from Python turns the terminal on.General.Terminalreads 1 straight after it, so meshing writes Gmsh's progress to standard output, which IF-02 reserves. Set it to 0 before anything else, and collect the messages withgmsh.loggerinstead. PassreadConfigFiles=Falsetoo, or a user's~/.gmsh-optionschanges a mesh under an unchanged key. Passinterruptible=False, because the default installs a SIGINT handler, which only the main thread may do.- A stage-5 region goes into the built-in
geokernel from netgen's own coordinates. Readingset(shape.vertices),set(shape.edges)and each face'sset(face.edges)off the glued shape gives the graph: 193 vertices and 195 edges on the ClyA fixture. Its face areas, in closed form (Green's theorem,½(a × b)per segment and½R²Δθper arc), agree with OCC'sface.massto 1e-15 on the electrolyte and 1e-12 on the membrane. No BREP passes between netgen-occt and Gmsh's own OpenCASCADE. Every region vertex survives as a mesh node, the 185 profile vertices included. - The only curved edges are the reservoir arcs, and each spans less than π, which
geo.addCircleArcrequires. OCC's seam at(R, 0)is what splits the membrane's outer arc in two. A straight edge is told from an arc by its parameter midpoint lying on its chord, to 1e-9. - A clockwise curve loop meshes every triangle of its surface inverted. On the coarse
parallelogram region, the protein's loop handed to Gmsh reversed gave 14 triangles, all 14
inverted. Orient each loop counter-clockwise by its signed area before
addCurveLoop, as the pore face is oriented for OCC above. - Given only §5.2.2's size table, Gmsh does not match netgen's quality. Netgen limits the
element size near short edges and close edges by itself, and grades outwards from a face's
maxh. A GmshConstantfield does neither. On the fixture, with the table asDistanceandThresholdsources andConstantdomains, the minimum gamma is 0.3143 against VER-10's 0.3. With the domain boundaries also as graded sources it fails atsize_scale2 (0.2084, beside a 0.0412 nm profile edge) and at 4 (0.2348, where the 2.8 nm membrane strip meets the arc). The field the backend uses fixes that: sourcesh + 0.2 don every sized boundary and domain boundary, on each vertex whose shortest curve is shorter than its curves' smallest target, and on the membrane at its unscaled thickness. Its minimum gamma is 0.5840, 0.5241, 0.5655 and 0.5072 atsize_scale1, 2, 4 and 8.Mesh.MeshSizeExtendFromBoundary1 is the tempting shortcut. It doubles the element count, to 95,103 atsize_scale1, and still gives only 0.3876 at 4. - Frontal-Delaunay (
Mesh.Algorithm6) is the algorithm, and one thread makes the mesh reproducible. With the table-only field, algorithm 5 gives 10 % more triangles at a mean gamma of 0.95 against 0.99, and MeshAdapt (1) is slower and worse (WP23 plan, Design §3).Mesh.Smoothing5 is its optimisation pass, asoptsteps2dis netgen's. WithGeneral.NumThreadsandMesh.MaxNumThreads2Dat 1, two fresh processes give one content hash. - Gmsh's wall runs at or under its target, and netgen's over it. With the backend's field on the
fixture, the mean
wallsegment is 0.909, 0.931 and 0.945 of the target at 0.05, 0.03505 and 0.02715 nm, and the longest is 1.029–1.055 of it. Netgen's mean is 1.045–1.078. The mesh has 48,941 triangles in 3.3–4.2 s, against netgen's 44,316 in 6.4–7.0 s. Distance's sampling option isSampling(formerlyNumPointsPerCurve). A source curve needs about⌈L/h⌉samples, so a reservoir arc hundreds of nm long, sized at 5 nm, needs 80 or more, where the default is 20.- The wheel can install and still fail to import. On a bare Linux container
import gmshraisesOSError: libGLU.so.1, notImportError. A dispatch that turns a missing extra into a refusal has to catch both, astests/conftest.py'simport_gmshdoes for the tests. - On
ubuntu-latest, four packages make it import [verified]1. GitHub's image lackslibGLU.so.1. Afterapt-get install --no-install-recommends libglu1-mesa libxft2 libxinerama1 libxcursor1, Gmsh 4.15 imports and meshes on Python 3.11 to 3.14 (WP23 PR, run 36574260470, 29 September 2026). - Session cleanup and borrowed sessions [tested: tests/tier1/test_mesh_gmsh.py::test_ver54_an_open_gmsh_session_keeps_its_model_and_its_options].
gmsh_backend._sessionrecords each undo step (gmsh.model.remove(), restoring options,gmsh.finalize()) only once the change it reverses has been made, and runs every recorded step in reverse order, so a session it opened is finalised even when an intermediate step or the setup itself fails, and a borrowed session's own model is never removed in place of one that was never added. When an exception is active during cleanup, secondary failures are logged at WARNING and attached to the exception viaadd_note(), preventing the original error from being masked (REV-38). A borrowed session (gmsh.isInitialized()true on entry) leaves the caller's logger untouched (nostartorstop), preserving caller messages (REV-36). Ifgmsh.initialize()fails, aGmshInitialisationErroris raised, translated toMissingExtraErrornaminggmsh(REV-37). - The IF-07 export samples a fluid-restricted field through a whole-domain carrier [tested: tests/tier1/test_field_export.py::test_ver33_a_quadratic_survives_the_export_exactly_on_the_fluid].
Point evaluation of a space built with
definedon=fluidreturns zero at an interface node whose point search lands in a membrane element. On the coarse four-material pore that is 16 of the 208 fluid nodes, with a maximum error of 13.0 against a linear test function. Interpolating into a whole-domain space of the same order first and evaluating that is exact to 5.5e-14, because a P2 node's value depends only on the entities carrying it, each a fluid entity (post/export.pysample_at). - meshio 5.3.5 reads an XDMF
Informationelement only in its ownfield_dataencoding [tested: tests/tier1/test_library_behaviour.py::test_meshio_msh_stdout_and_xdmf_information_quirks]. Children carrykeyanddimattributes with integer text; anything else raisesParseError. The IF-07 scale set is therefore written as HDF5 root attributes (post/export.py). - Discontinuous export fields are cell-centred [tested: tests/tier1/test_field_export.py::test_if07_a_discontinuous_quantity_is_written_cell_centred].
material_idandeps_rjump across a material interface, so a node value there is one side chosen arbitrarily and a viewer draws a ramp across every boundary element; a barycentre lies strictly inside its element, so the point search is unambiguous.
-
docs/plans/wp23-gmsh-backend.md(CIubuntu-latestruntime library prerequisites forgmsh). ↩