lapy 1.5.0__tar.gz → 1.7.0.dev0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {lapy-1.5.0 → lapy-1.7.0.dev0}/PKG-INFO +17 -44
- {lapy-1.5.0 → lapy-1.7.0.dev0}/README.md +5 -3
- lapy-1.7.0.dev0/lapy/_cholmod.py +102 -0
- lapy-1.7.0.dev0/lapy/_gmsh_io.py +266 -0
- {lapy-1.5.0 → lapy-1.7.0.dev0}/lapy/_tet_io.py +11 -96
- {lapy-1.5.0 → lapy-1.7.0.dev0}/lapy/_tria_io.py +203 -256
- {lapy-1.5.0 → lapy-1.7.0.dev0}/lapy/conformal.py +354 -121
- {lapy-1.5.0 → lapy-1.7.0.dev0}/lapy/diffgeo.py +328 -145
- {lapy-1.5.0 → lapy-1.7.0.dev0}/lapy/heat.py +73 -28
- lapy-1.7.0.dev0/lapy/intersect.py +238 -0
- {lapy-1.5.0 → lapy-1.7.0.dev0}/lapy/plot.py +25 -26
- {lapy-1.5.0 → lapy-1.7.0.dev0}/lapy/polygon.py +70 -21
- lapy-1.7.0.dev0/lapy/remesh.py +434 -0
- {lapy-1.5.0 → lapy-1.7.0.dev0}/lapy/shapedna.py +19 -14
- {lapy-1.5.0 → lapy-1.7.0.dev0}/lapy/solver.py +224 -92
- {lapy-1.5.0 → lapy-1.7.0.dev0}/lapy/tet_mesh.py +2 -2
- {lapy-1.5.0 → lapy-1.7.0.dev0}/lapy/tria_mesh.py +1221 -242
- {lapy-1.5.0 → lapy-1.7.0.dev0}/lapy/utils/_config.py +3 -4
- {lapy-1.5.0 → lapy-1.7.0.dev0}/lapy/utils/_imports.py +2 -5
- {lapy-1.5.0 → lapy-1.7.0.dev0}/lapy.egg-info/PKG-INFO +17 -44
- {lapy-1.5.0 → lapy-1.7.0.dev0}/lapy.egg-info/SOURCES.txt +4 -1
- {lapy-1.5.0 → lapy-1.7.0.dev0}/lapy.egg-info/requires.txt +7 -15
- {lapy-1.5.0 → lapy-1.7.0.dev0}/pyproject.toml +36 -34
- lapy-1.5.0/setup.py +0 -3
- {lapy-1.5.0 → lapy-1.7.0.dev0}/LICENSE +0 -0
- {lapy-1.5.0 → lapy-1.7.0.dev0}/lapy/__init__.py +0 -0
- {lapy-1.5.0 → lapy-1.7.0.dev0}/lapy/_read_geometry.py +0 -0
- {lapy-1.5.0 → lapy-1.7.0.dev0}/lapy/_version.py +0 -0
- {lapy-1.5.0 → lapy-1.7.0.dev0}/lapy/commands/__init__.py +0 -0
- {lapy-1.5.0 → lapy-1.7.0.dev0}/lapy/commands/sys_info.py +0 -0
- {lapy-1.5.0 → lapy-1.7.0.dev0}/lapy/io.py +0 -0
- {lapy-1.5.0 → lapy-1.7.0.dev0}/lapy/utils/__init__.py +0 -0
- {lapy-1.5.0 → lapy-1.7.0.dev0}/lapy.egg-info/dependency_links.txt +0 -0
- {lapy-1.5.0 → lapy-1.7.0.dev0}/lapy.egg-info/entry_points.txt +0 -0
- {lapy-1.5.0 → lapy-1.7.0.dev0}/lapy.egg-info/top_level.txt +0 -0
- {lapy-1.5.0 → lapy-1.7.0.dev0}/setup.cfg +0 -0
|
@@ -1,31 +1,10 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: lapy
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.7.0.dev0
|
|
4
4
|
Summary: A package for differential geometry on meshes (Laplace, FEM)
|
|
5
5
|
Author-email: Martin Reuter <martin.reuter@dzne.de>
|
|
6
6
|
Maintainer-email: Martin Reuter <martin.reuter@dzne.de>
|
|
7
|
-
License: MIT
|
|
8
|
-
|
|
9
|
-
Copyright (c) 2020 Deep Medical Imaging Lab (PI Reuter)
|
|
10
|
-
|
|
11
|
-
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
12
|
-
of this software and associated documentation files (the "Software"), to deal
|
|
13
|
-
in the Software without restriction, including without limitation the rights
|
|
14
|
-
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
15
|
-
copies of the Software, and to permit persons to whom the Software is
|
|
16
|
-
furnished to do so, subject to the following conditions:
|
|
17
|
-
|
|
18
|
-
The above copyright notice and this permission notice shall be included in all
|
|
19
|
-
copies or substantial portions of the Software.
|
|
20
|
-
|
|
21
|
-
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
22
|
-
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
23
|
-
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
24
|
-
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
25
|
-
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
26
|
-
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
27
|
-
SOFTWARE.
|
|
28
|
-
|
|
7
|
+
License-Expression: MIT
|
|
29
8
|
Project-URL: homepage, https://Deep-MI.github.io/LaPy/dev/index.html
|
|
30
9
|
Project-URL: documentation, https://Deep-MI.github.io/LaPy/dev/index.html
|
|
31
10
|
Project-URL: source, https://github.com/Deep-MI/LaPy
|
|
@@ -35,45 +14,39 @@ Classifier: Operating System :: Microsoft :: Windows
|
|
|
35
14
|
Classifier: Operating System :: Unix
|
|
36
15
|
Classifier: Operating System :: MacOS
|
|
37
16
|
Classifier: Programming Language :: Python :: 3 :: Only
|
|
38
|
-
Classifier: Programming Language :: Python :: 3.9
|
|
39
|
-
Classifier: Programming Language :: Python :: 3.10
|
|
40
17
|
Classifier: Programming Language :: Python :: 3.11
|
|
41
18
|
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
42
21
|
Classifier: Natural Language :: English
|
|
43
|
-
Classifier: License :: OSI Approved :: MIT License
|
|
44
22
|
Classifier: Intended Audience :: Science/Research
|
|
45
|
-
Requires-Python: >=3.
|
|
23
|
+
Requires-Python: >=3.11
|
|
46
24
|
Description-Content-Type: text/markdown
|
|
47
25
|
License-File: LICENSE
|
|
48
|
-
Requires-Dist: nibabel
|
|
49
|
-
Requires-Dist: numpy>=1.
|
|
50
|
-
Requires-Dist: plotly
|
|
51
|
-
Requires-Dist: psutil
|
|
52
|
-
Requires-Dist: scipy!=1.13.0
|
|
26
|
+
Requires-Dist: nibabel>=4.0
|
|
27
|
+
Requires-Dist: numpy>=1.23.2
|
|
28
|
+
Requires-Dist: plotly>=5.0
|
|
29
|
+
Requires-Dist: psutil>=5.9
|
|
30
|
+
Requires-Dist: scipy!=1.13.0,>=1.9.2
|
|
53
31
|
Provides-Extra: build
|
|
54
32
|
Requires-Dist: build; extra == "build"
|
|
55
33
|
Requires-Dist: twine; extra == "build"
|
|
56
34
|
Provides-Extra: chol
|
|
57
|
-
Requires-Dist:
|
|
35
|
+
Requires-Dist: cholespy>=2.0; extra == "chol"
|
|
58
36
|
Provides-Extra: doc
|
|
59
37
|
Requires-Dist: furo!=2023.8.17; extra == "doc"
|
|
60
38
|
Requires-Dist: matplotlib; extra == "doc"
|
|
61
|
-
Requires-Dist: memory-profiler; extra == "doc"
|
|
62
39
|
Requires-Dist: numpydoc; extra == "doc"
|
|
63
|
-
Requires-Dist: sphinx!=7.2
|
|
40
|
+
Requires-Dist: sphinx!=7.2.*,>=7.0; extra == "doc"
|
|
64
41
|
Requires-Dist: sphinxcontrib-bibtex; extra == "doc"
|
|
65
42
|
Requires-Dist: sphinx-copybutton; extra == "doc"
|
|
66
43
|
Requires-Dist: sphinx-design; extra == "doc"
|
|
67
|
-
Requires-Dist: sphinx-gallery; extra == "doc"
|
|
68
|
-
Requires-Dist: sphinx-issues; extra == "doc"
|
|
69
|
-
Requires-Dist: pypandoc; extra == "doc"
|
|
70
44
|
Requires-Dist: nbsphinx; extra == "doc"
|
|
71
45
|
Requires-Dist: IPython; extra == "doc"
|
|
72
46
|
Requires-Dist: ipykernel; extra == "doc"
|
|
73
47
|
Provides-Extra: style
|
|
74
48
|
Requires-Dist: bibclean; extra == "style"
|
|
75
49
|
Requires-Dist: codespell; extra == "style"
|
|
76
|
-
Requires-Dist: pydocstyle[toml]; extra == "style"
|
|
77
50
|
Requires-Dist: ruff; extra == "style"
|
|
78
51
|
Provides-Extra: test
|
|
79
52
|
Requires-Dist: pytest; extra == "test"
|
|
@@ -85,8 +58,6 @@ Requires-Dist: lapy[chol]; extra == "all"
|
|
|
85
58
|
Requires-Dist: lapy[doc]; extra == "all"
|
|
86
59
|
Requires-Dist: lapy[style]; extra == "all"
|
|
87
60
|
Requires-Dist: lapy[test]; extra == "all"
|
|
88
|
-
Provides-Extra: full
|
|
89
|
-
Requires-Dist: lapy[all]; extra == "full"
|
|
90
61
|
Dynamic: license-file
|
|
91
62
|
|
|
92
63
|
[](https://pypi.org/project/lapy/)
|
|
@@ -109,13 +80,16 @@ Key design goals:
|
|
|
109
80
|
- [References](#references)
|
|
110
81
|
|
|
111
82
|
## Features
|
|
112
|
-
- `TriaMesh`: triangle mesh class with orientation checking, boundary handling, normals, smoothing and quality metrics; efficient edge and adjacency representations; IO for OFF, VTK and related formats.
|
|
83
|
+
- `TriaMesh`: triangle mesh class with orientation checking, boundary handling, normals, smoothing and quality metrics; efficient edge and adjacency representations; IO for OFF, VTK; Gifti and related formats.
|
|
113
84
|
- `TetMesh`: tetrahedral mesh utilities, boundary handling and IO.
|
|
85
|
+
- `Polygon`: polygon class representing open and closed polygon curves.
|
|
114
86
|
- `Solver`: FEM routines producing stiffness and mass matrices, sparse eigenvalue solvers, Poisson and heat equation solvers, and support for anisotropic operators.
|
|
115
87
|
- `diffgeo`: gradient, divergence, mean-curvature flow and related differential operators.
|
|
116
88
|
- `heat`: heat kernel and diffusion utilities, geodesics via heat method.
|
|
117
89
|
- `shapedna`: compute ShapeDNA (Laplace spectra) for shape descriptors.
|
|
118
90
|
- `conformal`: conformal mapping methods for genus-0 surfaces.
|
|
91
|
+
- `intersect`: self-intersection tests for triangle meshes, and undoing vertex moves that create intersections.
|
|
92
|
+
- `remesh`: isotropic remeshing to near-equilateral triangles that keeps the topology, and closest points on a surface.
|
|
119
93
|
- `io`: read/write vertex functions and eigenvector files.
|
|
120
94
|
- `plot`: lightweight Plotly wrappers for interactive visualization.
|
|
121
95
|
|
|
@@ -150,8 +124,7 @@ python3 -m pip install --user --src /my/preferred/location --editable git+https:
|
|
|
150
124
|
|
|
151
125
|
### Dependencies and optional backends
|
|
152
126
|
- Core: Python 3, NumPy, SciPy.
|
|
153
|
-
- Optional
|
|
154
|
-
Note: CHOLMOD (via scikit-sparse) is not currently installable via plain `pip` on all platforms; use `conda` when possible. If `use_cholmod=True` is requested, LaPy attempts to import CHOLMOD and will raise if it is unavailable. Install ordering: install `numpy` and `scipy` first, then `scikit-sparse`.
|
|
127
|
+
- Optional: cholespy (for CHOLMOD) to accelerate sparse solves with the Cholesky decomposition. Install it with `pip install lapy[chol]`. If `use_cholmod=True` is requested without cholespy, LaPy raises an ImportError.
|
|
155
128
|
|
|
156
129
|
## Solver backends
|
|
157
130
|
- Default: SciPy sparse LU/QR routines.
|
|
@@ -18,13 +18,16 @@ Key design goals:
|
|
|
18
18
|
- [References](#references)
|
|
19
19
|
|
|
20
20
|
## Features
|
|
21
|
-
- `TriaMesh`: triangle mesh class with orientation checking, boundary handling, normals, smoothing and quality metrics; efficient edge and adjacency representations; IO for OFF, VTK and related formats.
|
|
21
|
+
- `TriaMesh`: triangle mesh class with orientation checking, boundary handling, normals, smoothing and quality metrics; efficient edge and adjacency representations; IO for OFF, VTK; Gifti and related formats.
|
|
22
22
|
- `TetMesh`: tetrahedral mesh utilities, boundary handling and IO.
|
|
23
|
+
- `Polygon`: polygon class representing open and closed polygon curves.
|
|
23
24
|
- `Solver`: FEM routines producing stiffness and mass matrices, sparse eigenvalue solvers, Poisson and heat equation solvers, and support for anisotropic operators.
|
|
24
25
|
- `diffgeo`: gradient, divergence, mean-curvature flow and related differential operators.
|
|
25
26
|
- `heat`: heat kernel and diffusion utilities, geodesics via heat method.
|
|
26
27
|
- `shapedna`: compute ShapeDNA (Laplace spectra) for shape descriptors.
|
|
27
28
|
- `conformal`: conformal mapping methods for genus-0 surfaces.
|
|
29
|
+
- `intersect`: self-intersection tests for triangle meshes, and undoing vertex moves that create intersections.
|
|
30
|
+
- `remesh`: isotropic remeshing to near-equilateral triangles that keeps the topology, and closest points on a surface.
|
|
28
31
|
- `io`: read/write vertex functions and eigenvector files.
|
|
29
32
|
- `plot`: lightweight Plotly wrappers for interactive visualization.
|
|
30
33
|
|
|
@@ -59,8 +62,7 @@ python3 -m pip install --user --src /my/preferred/location --editable git+https:
|
|
|
59
62
|
|
|
60
63
|
### Dependencies and optional backends
|
|
61
64
|
- Core: Python 3, NumPy, SciPy.
|
|
62
|
-
- Optional
|
|
63
|
-
Note: CHOLMOD (via scikit-sparse) is not currently installable via plain `pip` on all platforms; use `conda` when possible. If `use_cholmod=True` is requested, LaPy attempts to import CHOLMOD and will raise if it is unavailable. Install ordering: install `numpy` and `scipy` first, then `scikit-sparse`.
|
|
65
|
+
- Optional: cholespy (for CHOLMOD) to accelerate sparse solves with the Cholesky decomposition. Install it with `pip install lapy[chol]`. If `use_cholmod=True` is requested without cholespy, LaPy raises an ImportError.
|
|
64
66
|
|
|
65
67
|
## Solver backends
|
|
66
68
|
- Default: SciPy sparse LU/QR routines.
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Sparse Cholesky factorization with CHOLMOD, provided by the cholespy package."""
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
from scipy import sparse
|
|
5
|
+
|
|
6
|
+
from .utils._imports import import_optional_dependency
|
|
7
|
+
|
|
8
|
+
_INT32_MAX = np.iinfo(np.int32).max
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def require_cholmod() -> None:
|
|
12
|
+
"""Raise an ImportError if cholespy, which provides CHOLMOD, is not installed."""
|
|
13
|
+
import_optional_dependency("cholespy", extra="It is required for use_cholmod=True.", raise_error=True)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class CholeskyFactor:
|
|
17
|
+
"""Cholesky factorization of a sparse symmetric positive definite matrix.
|
|
18
|
+
|
|
19
|
+
CHOLMOD reads only one triangle of the matrix, so the matrix has to be
|
|
20
|
+
genuinely symmetric. The factorization and the solves run in double
|
|
21
|
+
precision; solutions are returned in ``float32`` for a ``float32`` matrix
|
|
22
|
+
and in ``float64`` otherwise.
|
|
23
|
+
|
|
24
|
+
Parameters
|
|
25
|
+
----------
|
|
26
|
+
matrix : scipy.sparse.spmatrix
|
|
27
|
+
Sparse symmetric positive definite matrix of shape (n, n).
|
|
28
|
+
|
|
29
|
+
Raises
|
|
30
|
+
------
|
|
31
|
+
ImportError
|
|
32
|
+
If cholespy is not installed.
|
|
33
|
+
ValueError
|
|
34
|
+
If the matrix is not square, too large for 32-bit indices, or not
|
|
35
|
+
positive definite.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
def __init__(self, matrix) -> None:
|
|
39
|
+
cholespy = import_optional_dependency(
|
|
40
|
+
"cholespy", extra="It is required for use_cholmod=True.", raise_error=True
|
|
41
|
+
)
|
|
42
|
+
a = sparse.csc_matrix(matrix)
|
|
43
|
+
a.sum_duplicates()
|
|
44
|
+
a.sort_indices()
|
|
45
|
+
n = a.shape[0]
|
|
46
|
+
if a.shape != (n, n):
|
|
47
|
+
raise ValueError(f"Cholesky factorization needs a square matrix, got shape {a.shape}.")
|
|
48
|
+
# cholespy takes 32-bit indices; scipy switches to 64-bit ones beyond that range
|
|
49
|
+
if n > _INT32_MAX or a.nnz > _INT32_MAX:
|
|
50
|
+
raise ValueError(
|
|
51
|
+
f"Matrix with {n} rows and {a.nnz} non-zeros is too large for CHOLMOD's 32-bit indices."
|
|
52
|
+
)
|
|
53
|
+
# output precision; cholespy factorizes in double precision on the CPU either way
|
|
54
|
+
self.dtype = np.dtype(np.float32) if a.dtype == np.float32 else np.dtype(np.float64)
|
|
55
|
+
# Passed as CSR, which for a symmetric matrix holds the same arrays as CSC.
|
|
56
|
+
# cholespy reallocates the arrays of a CSC input in place, which corrupts the
|
|
57
|
+
# heap when NumPy owns them (it crashes on Windows), but copies a CSR input.
|
|
58
|
+
try:
|
|
59
|
+
self._solver = cholespy.CholeskySolverD(
|
|
60
|
+
n,
|
|
61
|
+
a.indptr.astype(np.int32),
|
|
62
|
+
a.indices.astype(np.int32),
|
|
63
|
+
a.data.astype(np.float64),
|
|
64
|
+
cholespy.MatrixType.CSR,
|
|
65
|
+
)
|
|
66
|
+
except ValueError as err:
|
|
67
|
+
if "positive definite" not in str(err):
|
|
68
|
+
raise
|
|
69
|
+
raise ValueError(
|
|
70
|
+
"Cholesky factorization failed because the matrix is not positive definite, "
|
|
71
|
+
"for example a Laplace operator without boundary conditions. "
|
|
72
|
+
"Use use_cholmod=False for the LU decomposition instead."
|
|
73
|
+
) from err
|
|
74
|
+
self.shape = (n, n)
|
|
75
|
+
|
|
76
|
+
def solve(self, b) -> np.ndarray:
|
|
77
|
+
"""Solve ``A x = b`` with the factorized matrix.
|
|
78
|
+
|
|
79
|
+
Parameters
|
|
80
|
+
----------
|
|
81
|
+
b : array_like or scipy.sparse.spmatrix
|
|
82
|
+
Right hand side of shape (n,) or (n, n_rhs).
|
|
83
|
+
|
|
84
|
+
Returns
|
|
85
|
+
-------
|
|
86
|
+
np.ndarray
|
|
87
|
+
Solution of the same shape as ``b``, ``float32`` for a ``float32``
|
|
88
|
+
matrix and ``float64`` otherwise.
|
|
89
|
+
|
|
90
|
+
Raises
|
|
91
|
+
------
|
|
92
|
+
ValueError
|
|
93
|
+
If the first dimension of ``b`` does not match the matrix.
|
|
94
|
+
"""
|
|
95
|
+
if sparse.issparse(b):
|
|
96
|
+
b = b.toarray()
|
|
97
|
+
b = np.ascontiguousarray(np.asarray(b), dtype=np.float64)
|
|
98
|
+
if b.ndim not in (1, 2) or b.shape[0] != self.shape[0]:
|
|
99
|
+
raise ValueError(f"Right hand side of shape {b.shape} does not match a matrix of shape {self.shape}.")
|
|
100
|
+
x = np.empty_like(b)
|
|
101
|
+
self._solver.solve(b, x)
|
|
102
|
+
return x.astype(self.dtype, copy=False)
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
"""Shared reader for GMSH meshes, MSH 2 ASCII format.
|
|
2
|
+
|
|
3
|
+
Both :mod:`lapy._tria_io` and :mod:`lapy._tet_io` parse the same file format,
|
|
4
|
+
so the parsing lives here once and each of them keeps only the thin wrapper
|
|
5
|
+
that turns the result into its own mesh type.
|
|
6
|
+
|
|
7
|
+
.. moduleauthor:: Nico Schloemer <nico.schloemer@gmail.com>
|
|
8
|
+
LICENSE MIT
|
|
9
|
+
https://github.com/nschloe/meshio
|
|
10
|
+
http://geuz.org/gmsh/doc/texinfo/gmsh.html#MSH-ASCII-file-format
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import logging
|
|
14
|
+
import os
|
|
15
|
+
import re
|
|
16
|
+
from itertools import islice
|
|
17
|
+
|
|
18
|
+
import numpy as np
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
# gmsh element code -> name, and the vertex count of each name.
|
|
23
|
+
GMSH_TO_NAME = {
|
|
24
|
+
15: "vertex",
|
|
25
|
+
1: "line",
|
|
26
|
+
2: "triangle",
|
|
27
|
+
3: "quad",
|
|
28
|
+
4: "tetra",
|
|
29
|
+
5: "hexahedron",
|
|
30
|
+
6: "wedge",
|
|
31
|
+
7: "pyramid",
|
|
32
|
+
8: "line3",
|
|
33
|
+
9: "triangle6",
|
|
34
|
+
10: "quad9",
|
|
35
|
+
11: "tetra10",
|
|
36
|
+
12: "hexahedron27",
|
|
37
|
+
13: "prism18",
|
|
38
|
+
14: "pyramid14",
|
|
39
|
+
26: "line4",
|
|
40
|
+
36: "quad16",
|
|
41
|
+
}
|
|
42
|
+
NAME_TO_GMSH = {v: k for k, v in GMSH_TO_NAME.items()}
|
|
43
|
+
NODES_PER_CELL = {
|
|
44
|
+
"vertex": 1,
|
|
45
|
+
"line": 2,
|
|
46
|
+
"triangle": 3,
|
|
47
|
+
"quad": 4,
|
|
48
|
+
"tetra": 4,
|
|
49
|
+
"hexahedron": 8,
|
|
50
|
+
"wedge": 6,
|
|
51
|
+
"pyramid": 5,
|
|
52
|
+
"line3": 3,
|
|
53
|
+
"triangle6": 6,
|
|
54
|
+
"quad9": 9,
|
|
55
|
+
"tetra10": 10,
|
|
56
|
+
"hexahedron27": 27,
|
|
57
|
+
"prism18": 18,
|
|
58
|
+
"pyramid14": 14,
|
|
59
|
+
"line4": 4,
|
|
60
|
+
"quad16": 16,
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _fail(msg: str) -> None:
|
|
65
|
+
"""Log and raise in the format the other readers use."""
|
|
66
|
+
msg = f"[{msg}] --> FAILED\n"
|
|
67
|
+
logger.error(msg)
|
|
68
|
+
raise ValueError(msg)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _expect(line: str, keyword: str) -> None:
|
|
72
|
+
"""Fail on a malformed file.
|
|
73
|
+
|
|
74
|
+
Not an assert: these validate untrusted file content, and assert is
|
|
75
|
+
stripped under ``python -O``, which would let a truncated file through and
|
|
76
|
+
only surface much later as a wrong mesh.
|
|
77
|
+
"""
|
|
78
|
+
if line.strip() != keyword:
|
|
79
|
+
_fail(f"{keyword} keyword not found")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _holds_type(block: str, name: str) -> bool:
|
|
83
|
+
"""Say whether an element block contains a cell type, without parsing it.
|
|
84
|
+
|
|
85
|
+
Each record starts ``number type ...``, so the type is the second token of
|
|
86
|
+
a line. Scanning the raw text for that is several times cheaper than
|
|
87
|
+
converting the whole block to integers, which lets a caller that asked for
|
|
88
|
+
triangles reject a volume mesh before paying for the conversion.
|
|
89
|
+
"""
|
|
90
|
+
return re.search(rf"(?m)^\s*\d+\s+{NAME_TO_GMSH[name]}\s", block) is not None
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _cells_from_block(flat: np.ndarray, num_cells: int) -> dict:
|
|
94
|
+
"""Split a flat MSH 2 element block into 0-based node indices per cell type.
|
|
95
|
+
|
|
96
|
+
A record is ``number type n_tags <tags...> <nodes...>``, so records only
|
|
97
|
+
share a width when the block holds a single element type with a single tag
|
|
98
|
+
count. That is the common case and reshapes in one step; a mixed block
|
|
99
|
+
falls back to walking the records.
|
|
100
|
+
|
|
101
|
+
Parameters
|
|
102
|
+
----------
|
|
103
|
+
flat : np.ndarray
|
|
104
|
+
All integers of the element block, in file order.
|
|
105
|
+
num_cells : int
|
|
106
|
+
Number of records the block declares.
|
|
107
|
+
|
|
108
|
+
Returns
|
|
109
|
+
-------
|
|
110
|
+
dict
|
|
111
|
+
Maps an element name to its (n_cells, n_vertices) index array.
|
|
112
|
+
"""
|
|
113
|
+
if num_cells == 0:
|
|
114
|
+
return {}
|
|
115
|
+
|
|
116
|
+
width, remainder = divmod(flat.size, num_cells)
|
|
117
|
+
if remainder == 0:
|
|
118
|
+
rows = flat.reshape(num_cells, width)
|
|
119
|
+
etype, n_tags = int(rows[0, 1]), int(rows[0, 2])
|
|
120
|
+
name = GMSH_TO_NAME[etype]
|
|
121
|
+
uniform = (
|
|
122
|
+
3 + n_tags + NODES_PER_CELL[name] == width
|
|
123
|
+
and bool((rows[:, 1] == etype).all())
|
|
124
|
+
and bool((rows[:, 2] == n_tags).all())
|
|
125
|
+
)
|
|
126
|
+
if uniform:
|
|
127
|
+
return {name: rows[:, -NODES_PER_CELL[name] :] - 1}
|
|
128
|
+
|
|
129
|
+
# Mixed block: walk the records. tolist() keeps the walk in plain python,
|
|
130
|
+
# which is far cheaper than numpy scalar indexing in a loop, and the node
|
|
131
|
+
# indices are extended into one flat list per type rather than appended as
|
|
132
|
+
# rows, so the final conversion is a single reshape.
|
|
133
|
+
values = flat.tolist()
|
|
134
|
+
chunks: dict = {}
|
|
135
|
+
pos, size = 0, len(values)
|
|
136
|
+
while pos < size:
|
|
137
|
+
name = GMSH_TO_NAME[values[pos + 1]]
|
|
138
|
+
n_nodes = NODES_PER_CELL[name]
|
|
139
|
+
end = pos + 3 + values[pos + 2] + n_nodes
|
|
140
|
+
chunks.setdefault(name, []).extend(values[end - n_nodes : end])
|
|
141
|
+
pos = end
|
|
142
|
+
return {
|
|
143
|
+
k: np.array(v, dtype=np.int64).reshape(-1, NODES_PER_CELL[k]) - 1
|
|
144
|
+
for k, v in chunks.items()
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def read_gmsh(filename: str, want: str | None = None) -> tuple[np.ndarray, dict]:
|
|
149
|
+
"""Read points and cells from a GMSH file, MSH 2 ASCII format.
|
|
150
|
+
|
|
151
|
+
Parameters
|
|
152
|
+
----------
|
|
153
|
+
filename : str
|
|
154
|
+
Filename to load, must end in ``.msh``.
|
|
155
|
+
want : str or None, default=None
|
|
156
|
+
Cell type the caller needs, for example ``"triangle"`` or ``"tetra"``.
|
|
157
|
+
When given, a file that cannot supply it is rejected as early as
|
|
158
|
+
possible: the node coordinates are skipped rather than parsed until the
|
|
159
|
+
element block has been checked, and the check itself is a text scan
|
|
160
|
+
rather than a full conversion. Reading a volume mesh as a triangle mesh
|
|
161
|
+
therefore costs a fraction of a full read.
|
|
162
|
+
|
|
163
|
+
Returns
|
|
164
|
+
-------
|
|
165
|
+
points : np.ndarray
|
|
166
|
+
Vertex coordinates, shape (n_points, 3).
|
|
167
|
+
cells : dict
|
|
168
|
+
Maps an element name to its (n_cells, n_vertices) index array.
|
|
169
|
+
|
|
170
|
+
Raises
|
|
171
|
+
------
|
|
172
|
+
OSError
|
|
173
|
+
If file is not found or not readable.
|
|
174
|
+
ValueError
|
|
175
|
+
If the extension is not ``.msh``, the file is binary or not version 2,
|
|
176
|
+
a section is malformed, or ``want`` is not present.
|
|
177
|
+
"""
|
|
178
|
+
if os.path.splitext(filename)[1] != ".msh":
|
|
179
|
+
_fail("no .msh file")
|
|
180
|
+
|
|
181
|
+
logger.debug("--> GMSH format ... ")
|
|
182
|
+
|
|
183
|
+
try:
|
|
184
|
+
f = open(filename)
|
|
185
|
+
except OSError:
|
|
186
|
+
logger.error("[file not found or not readable]")
|
|
187
|
+
raise
|
|
188
|
+
|
|
189
|
+
points = None
|
|
190
|
+
node_offset = None
|
|
191
|
+
num_nodes = 0
|
|
192
|
+
cells: dict = {}
|
|
193
|
+
|
|
194
|
+
with f:
|
|
195
|
+
while True:
|
|
196
|
+
line = f.readline()
|
|
197
|
+
if not line:
|
|
198
|
+
break
|
|
199
|
+
if not line.startswith("$"):
|
|
200
|
+
_fail(f"section keyword expected, found '{line.strip()}'")
|
|
201
|
+
section = line[1:].strip()
|
|
202
|
+
|
|
203
|
+
if section == "MeshFormat":
|
|
204
|
+
larr = list(filter(None, f.readline().split()))
|
|
205
|
+
if larr[0][0] != "2":
|
|
206
|
+
_fail(f"need mesh format 2, found {larr[0]}")
|
|
207
|
+
if larr[1] != "0":
|
|
208
|
+
# The file is opened in text mode, so the binary layout
|
|
209
|
+
# cannot be read from this handle at all.
|
|
210
|
+
_fail("binary format not implemented")
|
|
211
|
+
_expect(f.readline(), "$EndMeshFormat")
|
|
212
|
+
|
|
213
|
+
elif section == "Nodes":
|
|
214
|
+
num_nodes = int(f.readline())
|
|
215
|
+
if want is not None and not cells:
|
|
216
|
+
# Skip for now. If the element block turns out not to hold
|
|
217
|
+
# `want` we never pay to parse these coordinates, and
|
|
218
|
+
# skipping is about thirty times cheaper than parsing.
|
|
219
|
+
node_offset = f.tell()
|
|
220
|
+
for _ in islice(f, num_nodes):
|
|
221
|
+
pass
|
|
222
|
+
else:
|
|
223
|
+
points = _read_points(f, num_nodes)
|
|
224
|
+
_expect(f.readline(), "$EndNodes")
|
|
225
|
+
|
|
226
|
+
elif section == "Elements":
|
|
227
|
+
num_cells = int(f.readline())
|
|
228
|
+
block = "".join(islice(f, num_cells))
|
|
229
|
+
if want is not None:
|
|
230
|
+
first = block.split("\n", 1)[0].split()
|
|
231
|
+
known = len(first) > 1 and int(first[1]) == NAME_TO_GMSH[want]
|
|
232
|
+
if not known and not _holds_type(block, want):
|
|
233
|
+
_fail(f"file holds no {want} elements")
|
|
234
|
+
cells = _cells_from_block(
|
|
235
|
+
np.array(block.split(), dtype=np.int64), num_cells
|
|
236
|
+
)
|
|
237
|
+
_expect(f.readline(), "$EndElements")
|
|
238
|
+
|
|
239
|
+
else:
|
|
240
|
+
# Skip sections we do not use, such as $PhysicalNames,
|
|
241
|
+
# $Periodic or $NodeData. Erroring out instead would reject
|
|
242
|
+
# perfectly good files.
|
|
243
|
+
end = f"$End{section}"
|
|
244
|
+
while True:
|
|
245
|
+
line = f.readline()
|
|
246
|
+
if not line:
|
|
247
|
+
_fail(f"{end} keyword not found")
|
|
248
|
+
if line.strip() == end:
|
|
249
|
+
break
|
|
250
|
+
|
|
251
|
+
if points is None and node_offset is not None:
|
|
252
|
+
f.seek(node_offset)
|
|
253
|
+
points = _read_points(f, num_nodes)
|
|
254
|
+
|
|
255
|
+
if points is None:
|
|
256
|
+
_fail("$Nodes section not found")
|
|
257
|
+
if want is not None and want not in cells:
|
|
258
|
+
_fail(f"file holds no {want} elements")
|
|
259
|
+
|
|
260
|
+
return points, cells
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _read_points(f, num_nodes: int) -> np.ndarray:
|
|
264
|
+
"""Read a node block as (n, 3), dropping the leading index column."""
|
|
265
|
+
points = np.fromfile(f, "float32", num_nodes * 4, " ").reshape(num_nodes, 4)
|
|
266
|
+
return np.ascontiguousarray(points[:, 1:])
|
|
@@ -4,7 +4,6 @@ Should be called via the TetMesh member functions.
|
|
|
4
4
|
"""
|
|
5
5
|
|
|
6
6
|
import logging
|
|
7
|
-
import os.path
|
|
8
7
|
from typing import TYPE_CHECKING
|
|
9
8
|
|
|
10
9
|
import numpy as np
|
|
@@ -15,7 +14,7 @@ if TYPE_CHECKING:
|
|
|
15
14
|
logger = logging.getLogger(__name__)
|
|
16
15
|
|
|
17
16
|
def read_gmsh(filename: str) -> "TetMesh":
|
|
18
|
-
"""Load GMSH tetrahedron mesh.
|
|
17
|
+
"""Load GMSH tetrahedron mesh, MSH 2 ASCII format.
|
|
19
18
|
|
|
20
19
|
Parameters
|
|
21
20
|
----------
|
|
@@ -32,101 +31,17 @@ def read_gmsh(filename: str) -> "TetMesh":
|
|
|
32
31
|
OSError
|
|
33
32
|
If file is not found or not readable.
|
|
34
33
|
ValueError
|
|
35
|
-
If
|
|
34
|
+
If the extension is not ``.msh``, the file is binary or not version 2,
|
|
35
|
+
a section is malformed, or the file holds no tetrahedra.
|
|
36
36
|
"""
|
|
37
|
-
extension = os.path.splitext(filename)[1]
|
|
38
|
-
verbose = 1
|
|
39
|
-
if verbose > 0:
|
|
40
|
-
logger.info("--> GMSH format ... ")
|
|
41
|
-
if extension != ".msh":
|
|
42
|
-
msg = "[no .msh file] --> FAILED\n"
|
|
43
|
-
logger.error(msg)
|
|
44
|
-
raise ValueError(msg)
|
|
45
|
-
try:
|
|
46
|
-
f = open(filename)
|
|
47
|
-
except OSError:
|
|
48
|
-
logger.error("[file not found or not readable]")
|
|
49
|
-
raise
|
|
50
|
-
line = f.readline()
|
|
51
|
-
if not line.startswith("$MeshFormat"):
|
|
52
|
-
msg = "[$MeshFormat keyword not found] --> FAILED\n"
|
|
53
|
-
logger.error(msg)
|
|
54
|
-
f.close()
|
|
55
|
-
raise ValueError(msg)
|
|
56
|
-
line = f.readline()
|
|
57
|
-
larr = line.split()
|
|
58
|
-
ver = float(larr[0])
|
|
59
|
-
ftype = int(larr[1])
|
|
60
|
-
datatype = int(larr[2])
|
|
61
|
-
logger.debug(
|
|
62
|
-
"Msh file ver %s, ftype %s, datatype %s",
|
|
63
|
-
ver,
|
|
64
|
-
ftype,
|
|
65
|
-
datatype,
|
|
66
|
-
)
|
|
67
|
-
if ftype != 0:
|
|
68
|
-
msg = "[binary format not implemented] --> FAILED\n"
|
|
69
|
-
logger.error(msg)
|
|
70
|
-
f.close()
|
|
71
|
-
raise ValueError(msg)
|
|
72
|
-
line = f.readline()
|
|
73
|
-
if not line.startswith("$EndMeshFormat"):
|
|
74
|
-
msg = "[$EndMeshFormat keyword not found] --> FAILED\n"
|
|
75
|
-
logger.error(msg)
|
|
76
|
-
f.close()
|
|
77
|
-
raise ValueError(msg)
|
|
78
|
-
line = f.readline()
|
|
79
|
-
if not line.startswith("$Nodes"):
|
|
80
|
-
msg = "[$Nodes keyword not found] --> FAILED\n"
|
|
81
|
-
logger.error(msg)
|
|
82
|
-
f.close()
|
|
83
|
-
raise ValueError(msg)
|
|
84
|
-
pnum = int(f.readline())
|
|
85
|
-
# read (nodes X 4) matrix as chunk
|
|
86
|
-
# drop first column
|
|
87
|
-
v = np.fromfile(f, "float32", 4 * pnum, " ")
|
|
88
|
-
v.shape = (pnum, 4)
|
|
89
|
-
v = np.delete(v, 0, 1)
|
|
90
|
-
line = f.readline()
|
|
91
|
-
if not line.startswith("$EndNodes"):
|
|
92
|
-
msg = "[$EndNodes keyword not found] --> FAILED\n"
|
|
93
|
-
logger.error(msg)
|
|
94
|
-
f.close()
|
|
95
|
-
raise ValueError(msg)
|
|
96
|
-
line = f.readline()
|
|
97
|
-
if not line.startswith("$Elements"):
|
|
98
|
-
msg = "[$Elements keyword not found] --> FAILED\n"
|
|
99
|
-
logger.error(msg)
|
|
100
|
-
f.close()
|
|
101
|
-
raise ValueError(msg)
|
|
102
|
-
tnum = int(f.readline())
|
|
103
|
-
pos = f.tell()
|
|
104
|
-
line = f.readline()
|
|
105
|
-
f.seek(pos)
|
|
106
|
-
larr = line.split()
|
|
107
|
-
if int(larr[1]) != 4:
|
|
108
|
-
logger.debug("larr: %s", larr)
|
|
109
|
-
msg = "[can only read tetras] --> FAILED\n"
|
|
110
|
-
logger.error(msg)
|
|
111
|
-
f.close()
|
|
112
|
-
raise ValueError(msg)
|
|
113
|
-
# read (nodes X ?) matrix
|
|
114
|
-
t = np.fromfile(f, "int", tnum * len(larr), " ")
|
|
115
|
-
t.shape = (tnum, len(larr))
|
|
116
|
-
t = np.delete(t, np.s_[0 : len(larr) - 4], 1)
|
|
117
|
-
line = f.readline()
|
|
118
|
-
if not line.startswith("$EndElements"):
|
|
119
|
-
logger.debug("Line: %s", line)
|
|
120
|
-
msg = "[$EndElements keyword not found] --> FAILED\n"
|
|
121
|
-
logger.error(msg)
|
|
122
|
-
f.close()
|
|
123
|
-
raise ValueError(msg)
|
|
124
|
-
f.close()
|
|
125
|
-
logger.info(" --> DONE ( V: %d , T: %d )", v.shape[0], t.shape[0])
|
|
126
37
|
from . import TetMesh
|
|
38
|
+
from ._gmsh_io import read_gmsh as _read
|
|
127
39
|
|
|
128
|
-
|
|
129
|
-
|
|
40
|
+
points, cells = _read(filename, want="tetra")
|
|
41
|
+
logger.info(
|
|
42
|
+
" --> DONE ( V: %d , T: %d )", points.shape[0], cells["tetra"].shape[0]
|
|
43
|
+
)
|
|
44
|
+
return TetMesh(points, cells["tetra"])
|
|
130
45
|
|
|
131
46
|
def read_vtk(filename: str) -> "TetMesh":
|
|
132
47
|
"""Load VTK tetrahedron mesh.
|
|
@@ -194,7 +109,7 @@ def read_vtk(filename: str) -> "TetMesh":
|
|
|
194
109
|
pnum = int(larr[1])
|
|
195
110
|
# read points as chunk
|
|
196
111
|
v = np.fromfile(f, "float32", 3 * pnum, " ")
|
|
197
|
-
v
|
|
112
|
+
v = v.reshape(pnum, 3)
|
|
198
113
|
# expect polygon or tria_strip line
|
|
199
114
|
line = f.readline()
|
|
200
115
|
larr = line.split()
|
|
@@ -207,7 +122,7 @@ def read_vtk(filename: str) -> "TetMesh":
|
|
|
207
122
|
logger.error(msg)
|
|
208
123
|
raise ValueError(msg)
|
|
209
124
|
t = np.fromfile(f, "int", ttnum, " ")
|
|
210
|
-
t
|
|
125
|
+
t = t.reshape(tnum, 5)
|
|
211
126
|
if t[tnum - 1][0] != 4:
|
|
212
127
|
msg = "[can only read tetras] --> FAILED\n"
|
|
213
128
|
logger.error(msg)
|