shapemesh 0.1.0__py3-none-any.whl

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.
shapemesh/__init__.py ADDED
@@ -0,0 +1,103 @@
1
+ # Copyright (C) 2026 Mads Kruse
2
+ #
3
+ # This file is part of shapemesh.
4
+ #
5
+ # shapemesh is free software: you can redistribute it and/or modify it under
6
+ # the terms of the GNU Lesser General Public License as published by the Free
7
+ # Software Foundation, either version 3 of the License, or (at your option)
8
+ # any later version.
9
+ #
10
+ # shapemesh is distributed in the hope that it will be useful, but WITHOUT
11
+ # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
12
+ # FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License
13
+ # for more details.
14
+ #
15
+ # You should have received a copy of the GNU Lesser General Public License
16
+ # along with shapemesh. If not, see <https://www.gnu.org/licenses/>.
17
+
18
+ """shapemesh — a Python geometry and meshing library powered by Gmsh."""
19
+
20
+ from __future__ import annotations
21
+
22
+ from .builder import build
23
+ from .errors import (
24
+ BuildError,
25
+ GeometryError,
26
+ LabelError,
27
+ MeshFormatError,
28
+ ShapeMeshError,
29
+ )
30
+ from .mesh import (
31
+ ElementBlock,
32
+ ElementType,
33
+ EntityRecord,
34
+ Mesh,
35
+ MeshMetadata,
36
+ NodeTable,
37
+ NumpyElementBlock,
38
+ NumpyMesh,
39
+ PhysicalGroup,
40
+ )
41
+ from .settings import Algorithm2D, Algorithm3D, MeshSettings
42
+ from .shapes import (
43
+ Box,
44
+ Circle,
45
+ Cylinder,
46
+ Difference,
47
+ Disk,
48
+ Ellipse,
49
+ EllipticalDisk,
50
+ Intersection,
51
+ Line,
52
+ Point,
53
+ Polygon,
54
+ Polyline,
55
+ Rectangle,
56
+ Shape,
57
+ Sphere,
58
+ Union,
59
+ Vec3,
60
+ Vec3Like,
61
+ )
62
+
63
+ # Hatchling reads this value; keep it as the single source of truth.
64
+ __version__ = "0.1.0"
65
+
66
+ __all__ = [
67
+ "Algorithm2D",
68
+ "Algorithm3D",
69
+ "Box",
70
+ "BuildError",
71
+ "Circle",
72
+ "Cylinder",
73
+ "Difference",
74
+ "Disk",
75
+ "ElementBlock",
76
+ "ElementType",
77
+ "Ellipse",
78
+ "EllipticalDisk",
79
+ "EntityRecord",
80
+ "GeometryError",
81
+ "Intersection",
82
+ "LabelError",
83
+ "Line",
84
+ "Mesh",
85
+ "MeshFormatError",
86
+ "MeshMetadata",
87
+ "MeshSettings",
88
+ "NodeTable",
89
+ "NumpyElementBlock",
90
+ "NumpyMesh",
91
+ "PhysicalGroup",
92
+ "Point",
93
+ "Polygon",
94
+ "Polyline",
95
+ "Rectangle",
96
+ "Shape",
97
+ "ShapeMeshError",
98
+ "Sphere",
99
+ "Union",
100
+ "Vec3",
101
+ "Vec3Like",
102
+ "build",
103
+ ]
shapemesh/builder.py ADDED
@@ -0,0 +1,301 @@
1
+ # Copyright (C) 2026 Mads Kruse
2
+ #
3
+ # This file is part of shapemesh.
4
+ #
5
+ # shapemesh is free software: you can redistribute it and/or modify it under
6
+ # the terms of the GNU Lesser General Public License as published by the Free
7
+ # Software Foundation, either version 3 of the License, or (at your option)
8
+ # any later version.
9
+ #
10
+ # shapemesh is distributed in the hope that it will be useful, but WITHOUT
11
+ # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
12
+ # FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License
13
+ # for more details.
14
+ #
15
+ # You should have received a copy of the GNU Lesser General Public License
16
+ # along with shapemesh. If not, see <https://www.gnu.org/licenses/>.
17
+
18
+ """Gmsh build pipeline: shapes + settings -> a validated `Mesh`.
19
+
20
+ The pipeline validates shapes and settings, realizes them into a Gmsh model,
21
+ fragments overlapping entities to preserve labels, meshes, and reads the
22
+ entity-classified mesh back. Gmsh is imported lazily and a process-wide lock
23
+ serializes builds.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import threading
29
+ from collections.abc import Sequence
30
+ from typing import Any
31
+
32
+ import numpy as np
33
+
34
+ from .constants import GMSH_TERMINAL_SILENT, GMSH_TERMINAL_VERBOSE
35
+ from .errors import BuildError, GeometryError, ShapeMeshError
36
+ from .mesh import (
37
+ _NODE_COUNT,
38
+ ElementBlock,
39
+ ElementType,
40
+ EntityRecord,
41
+ Mesh,
42
+ MeshMetadata,
43
+ NodeTable,
44
+ PhysicalGroup,
45
+ )
46
+ from .settings import MeshSettings
47
+ from .shapes import Shape
48
+
49
+ __all__ = ["build"]
50
+
51
+ _LOCK = threading.RLock()
52
+
53
+ _RESERVED_OPTIONS = frozenset(
54
+ {"General.Terminal", "Mesh.Algorithm", "Mesh.Algorithm3D", "Mesh.ElementOrder"}
55
+ )
56
+
57
+
58
+ def build(shapes: Shape | Sequence[Shape], settings: MeshSettings) -> Mesh:
59
+ """Realize ``shapes`` with Gmsh and return the resulting `Mesh`.
60
+
61
+ A single `Shape` is normalized to a one-element sequence. Each shape with a
62
+ label becomes a physical group of the same name in the resulting mesh.
63
+
64
+ Args:
65
+ shapes: A shape or a sequence of shapes to mesh.
66
+ settings: The mesh settings.
67
+
68
+ Returns:
69
+ The entity-classified mesh.
70
+
71
+ Raises:
72
+ GeometryError: If ``shapes`` is empty or contains a non-shape.
73
+ ValueError: If an ``advanced`` option conflicts with an explicit
74
+ setting.
75
+ BuildError: If a Gmsh call fails.
76
+ RuntimeError: If Gmsh must be initialized from a non-main thread.
77
+ """
78
+ shape_list = _normalize_shapes(shapes)
79
+ _validate_build(shape_list)
80
+ _validate_settings(settings)
81
+
82
+ with _LOCK:
83
+ import gmsh
84
+
85
+ owned = not gmsh.isInitialized()
86
+ if owned:
87
+ if threading.current_thread() is not threading.main_thread():
88
+ raise RuntimeError(
89
+ "build() must be called from the main thread to initialize Gmsh; "
90
+ "initialize Gmsh on the main thread first, or use multiprocessing"
91
+ )
92
+ # Keep interruptible=True (the default) so Ctrl-C stops meshing
93
+ # gracefully; this is also why initialization needs the main thread.
94
+ gmsh.initialize()
95
+ added = False
96
+ saved_options: list[tuple[str, float | int | str]] = []
97
+ try:
98
+ if not owned:
99
+ saved_options = _snapshot_options(gmsh, settings)
100
+ gmsh.model.add("shapemesh")
101
+ added = True
102
+ _apply_settings(gmsh, settings)
103
+ return _build_mesh(gmsh, shape_list, settings)
104
+ except ShapeMeshError:
105
+ raise
106
+ except Exception as exc:
107
+ raise BuildError(str(exc), _capture_log(gmsh)) from exc
108
+ finally:
109
+ if added:
110
+ gmsh.model.remove()
111
+ if owned:
112
+ gmsh.finalize()
113
+ else:
114
+ _restore_options(gmsh, saved_options)
115
+
116
+
117
+ def _normalize_shapes(shapes: Shape | Sequence[Shape]) -> list[Shape]:
118
+ if isinstance(shapes, Shape):
119
+ return [shapes]
120
+ if isinstance(shapes, (str, bytes)):
121
+ raise GeometryError("shapes must be a Shape or a sequence of Shape")
122
+ return list(shapes)
123
+
124
+
125
+ def _validate_build(shapes: Sequence[Shape]) -> None:
126
+ if not shapes:
127
+ raise GeometryError("build requires at least one shape")
128
+ for shape in shapes:
129
+ if not isinstance(shape, Shape):
130
+ raise GeometryError(f"build shapes must be Shape instances, got {type(shape).__name__}")
131
+
132
+
133
+ def _validate_settings(settings: MeshSettings) -> None:
134
+ for name, _ in settings.advanced:
135
+ if name in _RESERVED_OPTIONS:
136
+ raise ValueError(f"advanced option {name!r} conflicts with an explicit setting")
137
+
138
+
139
+ def _configured_options(settings: MeshSettings) -> list[tuple[str, float | int | str]]:
140
+ options: list[tuple[str, float | int | str]] = [
141
+ (
142
+ "General.Terminal",
143
+ GMSH_TERMINAL_VERBOSE if settings.verbose else GMSH_TERMINAL_SILENT,
144
+ ),
145
+ ("Mesh.ElementOrder", settings.order),
146
+ ]
147
+ if settings.algorithm_2d is not None:
148
+ options.append(("Mesh.Algorithm", int(settings.algorithm_2d)))
149
+ if settings.algorithm_3d is not None:
150
+ options.append(("Mesh.Algorithm3D", int(settings.algorithm_3d)))
151
+ options.extend(settings.advanced)
152
+ return options
153
+
154
+
155
+ def _apply_settings(gmsh: Any, settings: MeshSettings) -> None:
156
+ for name, value in _configured_options(settings):
157
+ if isinstance(value, str):
158
+ gmsh.option.setString(name, value)
159
+ else:
160
+ gmsh.option.setNumber(name, value)
161
+
162
+
163
+ def _snapshot_options(gmsh: Any, settings: MeshSettings) -> list[tuple[str, float | int | str]]:
164
+ saved: list[tuple[str, float | int | str]] = []
165
+ for name, value in _configured_options(settings):
166
+ if isinstance(value, str):
167
+ previous = gmsh.option.getString(name)
168
+ else:
169
+ previous = gmsh.option.getNumber(name)
170
+ saved.append((name, previous))
171
+ return saved
172
+
173
+
174
+ def _restore_options(gmsh: Any, options: list[tuple[str, float | int | str]]) -> None:
175
+ for name, value in options:
176
+ if isinstance(value, str):
177
+ gmsh.option.setString(name, value)
178
+ else:
179
+ gmsh.option.setNumber(name, value)
180
+
181
+
182
+ def _capture_log(gmsh: Any) -> str:
183
+ try:
184
+ lines = list(gmsh.logger.get())
185
+ except Exception:
186
+ lines = []
187
+ try:
188
+ last_error = gmsh.logger.getLastError()
189
+ except Exception:
190
+ last_error = ""
191
+ if last_error and last_error not in lines:
192
+ lines.append(last_error)
193
+ return "\n".join(lines)
194
+
195
+
196
+ # -- pipeline --------------------------------------------------------------
197
+
198
+
199
+ def _build_mesh(gmsh: Any, shapes: Sequence[Shape], settings: MeshSettings) -> Mesh:
200
+ occ = gmsh.model.occ
201
+ label_map: dict[tuple[str, int], set[tuple[int, int]]] = {}
202
+ live: list[tuple[int, int]] = []
203
+ for shape in shapes:
204
+ dimtags = shape._realize(gmsh)
205
+ live.extend(dimtags)
206
+ if shape.label is not None:
207
+ label_map.setdefault((shape.label, shape.dimension), set()).update(dimtags)
208
+
209
+ occ.synchronize()
210
+ if len(live) > 1:
211
+ label_map = _fragment_remap(gmsh, live, label_map)
212
+ occ.synchronize()
213
+
214
+ groups = _add_groups(gmsh, label_map)
215
+ dim = gmsh.model.getDimension()
216
+ if settings.element_size is not None:
217
+ gmsh.model.mesh.setSize(gmsh.model.getEntities(0), settings.element_size)
218
+ gmsh.model.mesh.generate(max(dim, 1))
219
+ if settings.optimize and not (dim == 0 and settings.order > 1):
220
+ gmsh.model.mesh.optimize("" if settings.order == 1 else "HighOrder")
221
+ return _read_mesh(gmsh, groups)
222
+
223
+
224
+ def _fragment_remap(
225
+ gmsh: Any,
226
+ live: list[tuple[int, int]],
227
+ label_map: dict[tuple[str, int], set[tuple[int, int]]],
228
+ ) -> dict[tuple[str, int], set[tuple[int, int]]]:
229
+ occ = gmsh.model.occ
230
+ _, outmap = occ.fragment(live, [], removeObject=True, removeTool=True)
231
+ occ.synchronize()
232
+ index = {dimtag: i for i, dimtag in enumerate(live)}
233
+ remapped: dict[tuple[str, int], set[tuple[int, int]]] = {}
234
+ for (label, dim), dimtags in label_map.items():
235
+ outputs: set[tuple[int, int]] = set()
236
+ for dimtag in dimtags:
237
+ for out in outmap[index[dimtag]]:
238
+ if out[0] == dim:
239
+ outputs.add(out)
240
+ remapped[(label, dim)] = outputs
241
+ return remapped
242
+
243
+
244
+ def _add_groups(
245
+ gmsh: Any, label_map: dict[tuple[str, int], set[tuple[int, int]]]
246
+ ) -> list[PhysicalGroup]:
247
+ groups: list[PhysicalGroup] = []
248
+ for label, dim in sorted(label_map):
249
+ dimtags = sorted(label_map[(label, dim)])
250
+ if not dimtags:
251
+ continue
252
+ tags = [tag for _, tag in dimtags]
253
+ group_tag = gmsh.model.addPhysicalGroup(dim, tags, name=label)
254
+ groups.append(PhysicalGroup(label, dim, group_tag, tuple(dimtags)))
255
+ return groups
256
+
257
+
258
+ def _read_mesh(gmsh: Any, groups: list[PhysicalGroup]) -> Mesh:
259
+ entities: list[EntityRecord] = []
260
+ for dim in range(4):
261
+ for _, tag in gmsh.model.getEntities(dim):
262
+ bbox = tuple(gmsh.model.getBoundingBox(dim, tag))
263
+ boundary = tuple(gmsh.model.getBoundary([(dim, tag)], oriented=True))
264
+ entities.append(EntityRecord(dim, tag, bbox, boundary))
265
+
266
+ node_tags, coords, _ = gmsh.model.mesh.getNodes()
267
+ node_tags_arr = np.asarray(node_tags, dtype=np.int64)
268
+ coords_arr = np.asarray(coords, dtype=np.float64).reshape(-1, 3)
269
+ tag_to_index = {int(t): i for i, t in enumerate(node_tags)}
270
+ entity_dim = np.full(len(node_tags), -1, dtype=np.int64)
271
+ entity_tag = np.full(len(node_tags), -1, dtype=np.int64)
272
+ for dim in range(4):
273
+ for _, tag in gmsh.model.getEntities(dim):
274
+ ent_tags, _, _ = gmsh.model.mesh.getNodes(dim, tag)
275
+ for node_tag in ent_tags:
276
+ idx = tag_to_index[int(node_tag)]
277
+ entity_dim[idx] = dim
278
+ entity_tag[idx] = tag
279
+
280
+ blocks: list[ElementBlock] = []
281
+ for dim in range(4):
282
+ for _, tag in gmsh.model.getEntities(dim):
283
+ types, elem_tags, node_tags = gmsh.model.mesh.getElements(dim, tag)
284
+ for type_code, etags, ntags in zip(types, elem_tags, node_tags, strict=True):
285
+ element_type = ElementType(int(type_code))
286
+ k = _NODE_COUNT[element_type]
287
+ conn = np.asarray(ntags, dtype=np.int64).reshape(-1, k)
288
+ blocks.append(
289
+ ElementBlock(dim, tag, element_type, np.asarray(etags, dtype=np.int64), conn)
290
+ )
291
+
292
+ model_dim = gmsh.model.getDimension()
293
+ version = gmsh.option.getString("General.Version")
294
+ metadata = MeshMetadata(dim=model_dim, msh_version="4.1", gmsh_version=version, source=None)
295
+ return Mesh(
296
+ NodeTable(node_tags_arr, coords_arr, entity_dim, entity_tag),
297
+ tuple(blocks),
298
+ tuple(entities),
299
+ tuple(groups),
300
+ metadata,
301
+ )
shapemesh/constants.py ADDED
@@ -0,0 +1,32 @@
1
+ # Copyright (C) 2026 Mads Kruse
2
+ #
3
+ # This file is part of shapemesh.
4
+ #
5
+ # shapemesh is free software: you can redistribute it and/or modify it under
6
+ # the terms of the GNU Lesser General Public License as published by the Free
7
+ # Software Foundation, either version 3 of the License, or (at your option)
8
+ # any later version.
9
+ #
10
+ # shapemesh is distributed in the hope that it will be useful, but WITHOUT
11
+ # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
12
+ # FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License
13
+ # for more details.
14
+ #
15
+ # You should have received a copy of the GNU Lesser General Public License
16
+ # along with shapemesh. If not, see <https://www.gnu.org/licenses/>.
17
+
18
+ """Central numeric constants for shapemesh."""
19
+
20
+ # Polygon vertices are considered coplanar when their perpendicular distance
21
+ # from the fitted plane is below this fraction of the bounding-box diagonal.
22
+ PLANARITY_TOLERANCE = 1e-7
23
+
24
+ # Two polygon edges are considered non-collinear when the sine of the angle
25
+ # between them exceeds this value.
26
+ COLLINEARITY_TOLERANCE = 1e-7
27
+
28
+ # `General.Terminal` is a gmsh verbosity level (0 = silent, higher = more
29
+ # verbose), not a binary toggle. shapemesh is silent by default and uses
30
+ # gmsh's normal level when `verbose` is set.
31
+ GMSH_TERMINAL_SILENT = 0
32
+ GMSH_TERMINAL_VERBOSE = 1
shapemesh/errors.py ADDED
@@ -0,0 +1,62 @@
1
+ # Copyright (C) 2026 Mads Kruse
2
+ #
3
+ # This file is part of shapemesh.
4
+ #
5
+ # shapemesh is free software: you can redistribute it and/or modify it under
6
+ # the terms of the GNU Lesser General Public License as published by the Free
7
+ # Software Foundation, either version 3 of the License, or (at your option)
8
+ # any later version.
9
+ #
10
+ # shapemesh is distributed in the hope that it will be useful, but WITHOUT
11
+ # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
12
+ # FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License
13
+ # for more details.
14
+ #
15
+ # You should have received a copy of the GNU Lesser General Public License
16
+ # along with shapemesh. If not, see <https://www.gnu.org/licenses/>.
17
+
18
+ """Exception hierarchy for shapemesh.
19
+
20
+ Every structured library error derives from `ShapeMeshError`. The
21
+ geometry/label/format errors additionally subclass the builtin ``ValueError``
22
+ (and `BuildError` subclasses ``RuntimeError``) so callers can catch them
23
+ with the usual builtins while still having a single library-wide base.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ __all__ = [
29
+ "BuildError",
30
+ "GeometryError",
31
+ "LabelError",
32
+ "MeshFormatError",
33
+ "ShapeMeshError",
34
+ ]
35
+
36
+
37
+ class ShapeMeshError(Exception):
38
+ """Base class for all shapemesh errors."""
39
+
40
+
41
+ class GeometryError(ShapeMeshError, ValueError):
42
+ """Invalid shape or geometry, e.g. an empty boolean result."""
43
+
44
+
45
+ class LabelError(ShapeMeshError, ValueError):
46
+ """Invalid or conflicting physical-group labels."""
47
+
48
+
49
+ class MeshFormatError(ShapeMeshError, ValueError):
50
+ """Corrupt or unsupported MSH file."""
51
+
52
+
53
+ class BuildError(ShapeMeshError, RuntimeError):
54
+ """A Gmsh call failed during a build.
55
+
56
+ Attributes:
57
+ log: The last Gmsh log text, captured before Gmsh was finalized.
58
+ """
59
+
60
+ def __init__(self, message: str, log: str = "") -> None:
61
+ super().__init__(message)
62
+ self.log = log