sgio 0.3.1__py3-none-any.whl → 0.3.2__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.
@@ -0,0 +1,1115 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ from functools import partial
5
+ from typing import BinaryIO, TextIO, Union, Tuple, Dict, List, Optional
6
+
7
+ import numpy as np
8
+ import numpy.typing as npt
9
+ from meshio import CellBlock, Mesh
10
+
11
+
12
+ from ._common import (
13
+ _fast_forward_over_blank_lines,
14
+ _fast_forward_to_end_block,
15
+ _gmsh_to_meshio_order,
16
+ _gmsh_to_meshio_type,
17
+ _meshio_to_gmsh_order,
18
+ _meshio_to_gmsh_type,
19
+ _read_data,
20
+ _read_physical_names,
21
+ _write_data,
22
+ _write_physical_names,
23
+ num_nodes_per_cell,
24
+ cell_data_from_raw,
25
+ )
26
+ from sgio.iofunc._meshio import (
27
+ warn,
28
+ raw_from_cell_data,
29
+ WriteError,
30
+ ReadError,
31
+ )
32
+
33
+ """Gmsh 4.1 format mesh I/O.
34
+
35
+ This module implements reading and writing of Gmsh 4.1 MSH file format.
36
+ The format is specified at:
37
+ http://gmsh.info/doc/texinfo/gmsh.html#MSH-file-format
38
+
39
+ Main entry points:
40
+ - read_buffer(): Read a Gmsh 4.1 format mesh from a file buffer
41
+ - write_buffer(): Write a mesh to a file buffer in Gmsh 4.1 format
42
+
43
+ Both binary and ASCII formats are supported.
44
+
45
+ Format Structure:
46
+ - $MeshFormat section: Version and mode information
47
+ - $PhysicalNames section: Named physical groups (optional)
48
+ - $Entities section: Geometric entities and their relationships (optional)
49
+ - $Nodes section: Node coordinates and tags
50
+ - $Elements section: Element connectivity
51
+ - $Periodic section: Periodic boundary conditions (optional)
52
+ - $NodeData/$ElementData sections: Field data (optional)
53
+ - $ElementNodeData sections: Nodal data per element (optional)
54
+
55
+ Key Features:
56
+ - Unified GmshWriter class for binary/ASCII output
57
+ - Helper functions for modular code organization
58
+ - Comprehensive type hints for IDE support
59
+ - Support for entity tags, physical groups, and bounding entities
60
+ """
61
+
62
+ logger = logging.getLogger(__name__)
63
+
64
+ c_int = np.dtype("i")
65
+ c_size_t = np.dtype("P")
66
+ c_double = np.dtype("d")
67
+
68
+ # Type aliases
69
+ FileHandle = Union[BinaryIO, TextIO]
70
+ DimTag = Tuple[int, int] # (dimension, tag)
71
+ PhysicalTags = Tuple[Dict[int, List[int]], ...] # indexed by dimension
72
+ BoundingEntities = Tuple[Dict[int, npt.NDArray], ...]
73
+
74
+ # Gmsh format constants
75
+ GMSH_MAX_DIM = 4 # Maximum entity dimension (point=0, curve=1, surface=2, volume=3)
76
+ GMSH_BBOX_POINT_COORDS = 3 # Bounding box coordinates for point entities
77
+ GMSH_BBOX_ENTITY_COORDS = 6 # Bounding box coordinates for entities with dim > 0
78
+ GMSH_NODE_TAG_OFFSET = 1 # Gmsh uses 1-based indexing, Python uses 0-based
79
+
80
+ # Component count constraints (from Gmsh spec)
81
+ GMSH_VALID_COMPONENTS = (1, 3, 9) # Valid number of components per data field
82
+
83
+
84
+ def _size_type(data_size):
85
+ return np.dtype(f"u{data_size}")
86
+
87
+
88
+ class GmshWriter:
89
+ """Helper class to handle binary/ASCII writing modes uniformly."""
90
+
91
+ def __init__(self, fh: FileHandle, binary: bool):
92
+ self.fh = fh
93
+ self.binary = binary
94
+
95
+ def write_string(self, text: str) -> None:
96
+ """Write string in appropriate format."""
97
+ if self.binary:
98
+ self.fh.write(text.encode() if isinstance(text, str) else text)
99
+ else:
100
+ self.fh.write(text)
101
+
102
+ def write_array(self, data, dtype, sep: str = " ", fmt: str = None) -> None:
103
+ """Write numpy array in appropriate format."""
104
+ arr = np.asarray(data, dtype=dtype)
105
+ if self.binary:
106
+ arr.tofile(self.fh)
107
+ else:
108
+ if fmt:
109
+ arr = np.atleast_2d(arr)
110
+ np.savetxt(self.fh, arr, fmt=fmt, delimiter=sep, newline=sep)
111
+ else:
112
+ arr.tofile(self.fh, sep, "%d")
113
+
114
+ def write_section_header(self, section_name: str) -> None:
115
+ """Write section header like $Entities."""
116
+ self.write_string(f"${section_name}\n")
117
+
118
+ def write_section_footer(self, section_name: str) -> None:
119
+ """Write section footer like $EndEntities."""
120
+ self.write_string(f"$End{section_name}\n")
121
+
122
+ def newline(self) -> None:
123
+ """Write a newline."""
124
+ self.write_string("\n" if not self.binary else b"\n")
125
+
126
+
127
+ # ====================================================================
128
+ # Readers
129
+ # ====================================================================
130
+
131
+ def read_buffer(f: FileHandle, is_ascii: bool, data_size: int) -> Mesh:
132
+ """Read gmsh 4.1 format mesh from buffer.
133
+
134
+ The format is specified at
135
+ <http://gmsh.info/doc/texinfo/gmsh.html#MSH-file-format>.
136
+
137
+ Args:
138
+ f: File handle to read from
139
+ is_ascii: Whether the file is in ASCII mode
140
+ data_size: Size of size_t type in bytes
141
+
142
+ Returns:
143
+ Mesh object containing the mesh data
144
+ """
145
+ # Initialize the optional data fields
146
+ points = []
147
+ cells = None
148
+ field_data = {}
149
+ cell_data_raw = {}
150
+ cell_tags = {}
151
+ point_data = {}
152
+ physical_tags = None
153
+ bounding_entities = None
154
+ cell_sets = {}
155
+ periodic = None
156
+
157
+ while True:
158
+ # fast-forward over blank lines
159
+ line, is_eof = _fast_forward_over_blank_lines(f)
160
+ if is_eof:
161
+ break
162
+
163
+ if line[0] != "$":
164
+ raise ReadError(f"Unexpected line {repr(line)}")
165
+
166
+ environ = line[1:].strip()
167
+
168
+ if environ == "PhysicalNames":
169
+ _read_physical_names(f, field_data)
170
+ elif environ == "Entities":
171
+ # Read physical tags and information on bounding entities.
172
+ physical_tags, bounding_entities = _read_entities(f, is_ascii, data_size)
173
+ elif environ == "Nodes":
174
+ points, point_tags, point_entities = _read_nodes(f, is_ascii, data_size)
175
+ elif environ == "Elements":
176
+ cells, cell_tags, cell_sets = _read_elements(
177
+ f,
178
+ point_tags,
179
+ physical_tags,
180
+ bounding_entities,
181
+ is_ascii,
182
+ data_size,
183
+ field_data,
184
+ )
185
+ elif environ == "Periodic":
186
+ periodic = _read_periodic(f, is_ascii, data_size)
187
+ elif environ == "NodeData":
188
+ _read_data(f, "NodeData", point_data, data_size, is_ascii)
189
+ elif environ == "ElementData":
190
+ _read_data(f, "ElementData", cell_data_raw, data_size, is_ascii)
191
+ else:
192
+ # Skip unrecognized sections
193
+ _fast_forward_to_end_block(f, environ)
194
+
195
+ if cells is None:
196
+ raise ReadError("$Element section not found.")
197
+
198
+ cell_data = cell_data_from_raw(cells, cell_data_raw)
199
+ cell_data.update(cell_tags)
200
+
201
+ # Add node entity information to the point data
202
+ point_data.update({"gmsh:dim_tags": point_entities})
203
+
204
+ return Mesh(
205
+ points,
206
+ cells,
207
+ point_data=point_data,
208
+ cell_data=cell_data,
209
+ field_data=field_data,
210
+ cell_sets=cell_sets,
211
+ gmsh_periodic=periodic,
212
+ )
213
+
214
+
215
+ def _read_entities(f: FileHandle, is_ascii: bool, data_size: int) -> Tuple[PhysicalTags, BoundingEntities]:
216
+ """Read the entity section.
217
+
218
+ Args:
219
+ f: File handle to read from
220
+ is_ascii: Whether the file is in ASCII mode
221
+ data_size: Size of size_t type in bytes
222
+
223
+ Returns:
224
+ Tuple of (physical_tags, bounding_entities) where:
225
+ - physical_tags: Physical tags indexed by dimension
226
+ - bounding_entities: Bounding entities indexed by dimension
227
+ """
228
+ fromfile = partial(np.fromfile, sep=" " if is_ascii else "")
229
+ c_size_t = _size_type(data_size)
230
+ physical_tags = ({}, {}, {}, {})
231
+ bounding_entities = ({}, {}, {}, {})
232
+ number = fromfile(f, c_size_t, 4) # dims 0, 1, 2, 3
233
+
234
+ for d, n in enumerate(number):
235
+ for _ in range(n):
236
+ (tag,) = fromfile(f, c_int, 1)
237
+ fromfile(f, c_double, 3 if d == 0 else 6) # discard bounding-box
238
+ (num_physicals,) = fromfile(f, c_size_t, 1)
239
+ physical_tags[d][tag] = list(fromfile(f, c_int, num_physicals))
240
+ if d > 0:
241
+ # Number of bounding entities
242
+ num_BREP_ = fromfile(f, c_size_t, 1)[0]
243
+ # Store bounding entities
244
+ bounding_entities[d][tag] = fromfile(f, c_int, num_BREP_)
245
+
246
+ _fast_forward_to_end_block(f, "Entities")
247
+ return physical_tags, bounding_entities
248
+
249
+
250
+ def _read_nodes(f: FileHandle, is_ascii: bool, data_size: int) -> Tuple[npt.NDArray, npt.NDArray, npt.NDArray]:
251
+ """Read node data: Node coordinates and tags.
252
+
253
+ Also find the entities of the nodes, and store this as point_data.
254
+ Note that entity tags are 1-offset within each dimension, thus it is
255
+ necessary to keep track of both tag and dimension of the entity.
256
+
257
+ Args:
258
+ f: File handle to read from
259
+ is_ascii: Whether the file is in ASCII mode
260
+ data_size: Size of size_t type in bytes
261
+
262
+ Returns:
263
+ Tuple of (points, tags, dim_tags) where:
264
+ - points: Node coordinates (N, 3)
265
+ - tags: Node tags (N,)
266
+ - dim_tags: Entity dimension and tags for each node (N, 2)
267
+ """
268
+ fromfile = partial(np.fromfile, sep=" " if is_ascii else "")
269
+ c_size_t = _size_type(data_size)
270
+
271
+ # numEntityBlocks numNodes minNodeTag maxNodeTag (all size_t)
272
+ num_entity_blocks, total_num_nodes, _, _ = fromfile(f, c_size_t, 4)
273
+
274
+ points = np.empty((total_num_nodes, 3), dtype=float)
275
+ tags = np.empty(total_num_nodes, dtype=int)
276
+ dim_tags = np.empty((total_num_nodes, 2), dtype=int)
277
+
278
+ idx = 0
279
+ for _ in range(num_entity_blocks):
280
+ # entityDim(int) entityTag(int) parametric(int) numNodes(size_t)
281
+ dim, entity_tag, parametric = fromfile(f, c_int, 3)
282
+ if parametric != 0:
283
+ raise ReadError("parametric nodes not implemented")
284
+ num_nodes = int(fromfile(f, c_size_t, 1)[0])
285
+
286
+ ixx = slice(idx, idx + num_nodes)
287
+ tags[ixx] = fromfile(f, c_size_t, num_nodes) - 1
288
+
289
+ # x(double) y(double) z(double) (* numNodes)
290
+ points[ixx] = fromfile(f, c_double, num_nodes * 3).reshape((num_nodes, 3))
291
+
292
+ # Entity tag and entity dimension of the nodes
293
+ dim_tags[ixx, 0] = dim
294
+ dim_tags[ixx, 1] = entity_tag
295
+ idx += num_nodes
296
+
297
+ _fast_forward_to_end_block(f, "Nodes")
298
+ return points, tags, dim_tags
299
+
300
+
301
+ def _read_elements(
302
+ f: FileHandle,
303
+ point_tags: npt.NDArray,
304
+ physical_tags: Optional[PhysicalTags],
305
+ bounding_entities: Optional[BoundingEntities],
306
+ is_ascii: bool,
307
+ data_size: int,
308
+ field_data: Dict[str, Tuple[int, int]]
309
+ ) -> Tuple[List[CellBlock], Dict, Dict]:
310
+ """Read element data from gmsh 4.1 file.
311
+
312
+ Args:
313
+ f: File handle to read from
314
+ point_tags: Point tags array
315
+ physical_tags: Physical tags indexed by dimension
316
+ bounding_entities: Bounding entities indexed by dimension
317
+ is_ascii: Whether the file is in ASCII mode
318
+ data_size: Size of size_t type in bytes
319
+ field_data: Field data dictionary
320
+
321
+ Returns:
322
+ Tuple of (cells, cell_data, cell_sets)
323
+ """
324
+ fromfile = partial(np.fromfile, sep=" " if is_ascii else "")
325
+ c_size_t = _size_type(data_size)
326
+
327
+ # numEntityBlocks numElements minElementTag maxElementTag (all size_t)
328
+ num_entity_blocks, _, _, _ = fromfile(f, c_size_t, 4)
329
+
330
+ data = []
331
+ cell_data = {}
332
+ cell_sets = {k: [None] * num_entity_blocks for k in field_data.keys()}
333
+
334
+ for k in range(num_entity_blocks):
335
+ # entityDim(int) entityTag(int) elementType(int) numElements(size_t)
336
+ dim, tag, type_ele = fromfile(f, c_int, 3)
337
+ (num_ele,) = fromfile(f, c_size_t, 1)
338
+ for physical_name, cell_set in cell_sets.items():
339
+ cell_set[k] = np.arange(
340
+ num_ele
341
+ if (
342
+ physical_tags
343
+ and field_data[physical_name][1] == dim
344
+ and field_data[physical_name][0] in physical_tags[dim][tag]
345
+ )
346
+ else 0,
347
+ dtype=type(num_ele),
348
+ )
349
+ tpe = _gmsh_to_meshio_type[type_ele]
350
+ num_nodes_per_ele = num_nodes_per_cell[tpe]
351
+ d = fromfile(f, c_size_t, int(num_ele * (1 + num_nodes_per_ele))).reshape(
352
+ (num_ele, -1)
353
+ )
354
+
355
+ # Find physical tag, if defined; else it is None.
356
+ pt = None if not physical_tags else physical_tags[dim][tag]
357
+ # Bounding entities (of lower dimension) if defined.
358
+ if dim > 0 and bounding_entities:
359
+ be = bounding_entities[dim][tag]
360
+ else:
361
+ be = None
362
+ data.append((pt, be, tag, tpe, d))
363
+
364
+ _fast_forward_to_end_block(f, "Elements")
365
+
366
+ # Inverse point tags
367
+ inv_tags = np.full(np.max(point_tags) + 1, -1, dtype=int)
368
+ inv_tags[point_tags] = np.arange(len(point_tags))
369
+
370
+ # Note that the first column in the data array is the element tag; discard it.
371
+ # Ensure integer types for node indices
372
+ data = [
373
+ (physical_tag, bound_entity, geom_tag, tpe,
374
+ inv_tags[(d[:, 1:].astype(int) - 1)])
375
+ for physical_tag, bound_entity, geom_tag, tpe, d in data
376
+ ]
377
+
378
+ cells = []
379
+ for physical_tag, bound_entity, geom_tag, key, values in data:
380
+ # Ensure the cell data is integer type
381
+ cells.append(CellBlock(key, _gmsh_to_meshio_order(key, values.astype(int))))
382
+ if physical_tag:
383
+ if "gmsh:physical" not in cell_data:
384
+ cell_data["gmsh:physical"] = []
385
+ cell_data["gmsh:physical"].append(
386
+ np.full(len(values), physical_tag[0], int)
387
+ )
388
+ if "gmsh:geometrical" not in cell_data:
389
+ cell_data["gmsh:geometrical"] = []
390
+ cell_data["gmsh:geometrical"].append(np.full(len(values), geom_tag, int))
391
+
392
+ # The bounding entities is stored in the cell_sets.
393
+ if bounding_entities:
394
+ if "gmsh:bounding_entities" not in cell_sets:
395
+ cell_sets["gmsh:bounding_entities"] = []
396
+ cell_sets["gmsh:bounding_entities"].append(bound_entity)
397
+
398
+ return cells, cell_data, cell_sets
399
+
400
+
401
+ def _read_periodic(f: FileHandle, is_ascii: bool, data_size: int) -> List:
402
+ """Read periodic information from gmsh 4.1 file.
403
+
404
+ Args:
405
+ f: File handle to read from
406
+ is_ascii: Whether the file is in ASCII mode
407
+ data_size: Size of size_t type in bytes
408
+
409
+ Returns:
410
+ List of periodic boundary information
411
+ """
412
+ fromfile = partial(np.fromfile, sep=" " if is_ascii else "")
413
+ c_size_t = _size_type(data_size)
414
+ periodic = []
415
+ # numPeriodicLinks(size_t)
416
+ num_periodic = int(fromfile(f, c_size_t, 1)[0])
417
+ for _ in range(num_periodic):
418
+ # entityDim(int) entityTag(int) entityTagMaster(int)
419
+ edim, stag, mtag = fromfile(f, c_int, 3)
420
+ # numAffine(size_t) value(double) ...
421
+ num_affine = int(fromfile(f, c_size_t, 1)[0])
422
+ affine = fromfile(f, c_double, num_affine)
423
+ # numCorrespondingNodes(size_t)
424
+ num_nodes = int(fromfile(f, c_size_t, 1)[0])
425
+ # nodeTag(size_t) nodeTagMaster(size_t) ...
426
+ slave_master = fromfile(f, c_size_t, num_nodes * 2).reshape(-1, 2)
427
+ slave_master = slave_master - 1 # Subtract one, Python is 0-based
428
+ periodic.append([edim, (stag, mtag), affine, slave_master])
429
+
430
+ _fast_forward_to_end_block(f, "Periodic")
431
+ return periodic
432
+
433
+
434
+ # ====================================================================
435
+ # Writers
436
+ # ====================================================================
437
+
438
+ def write_buffer(
439
+ file: FileHandle,
440
+ mesh: Mesh,
441
+ float_fmt: str,
442
+ mesh_only: bool,
443
+ binary: bool,
444
+ **kwargs
445
+ ) -> None:
446
+ """Write mesh to msh file format.
447
+
448
+ Format specification:
449
+ <http://gmsh.info/doc/texinfo/gmsh.html#MSH-file-format>
450
+
451
+ Args:
452
+ file: File handle to write to
453
+ mesh: Mesh object to write
454
+ float_fmt: Format string for floating point numbers
455
+ mesh_only: If True, skip writing entity information
456
+ binary: Whether to write in binary mode
457
+ **kwargs: Additional keyword arguments (currently unused)
458
+ """
459
+ logger.debug('writing gmsh buffer...')
460
+ logger.debug(locals())
461
+
462
+ # Filter the point data: gmsh:dim_tags are tags, the rest is actual point data.
463
+ point_data = {}
464
+ for key, d in mesh.point_data.items():
465
+ if key not in ["gmsh:dim_tags"]:
466
+ point_data[key] = d
467
+
468
+ # Split the cell data: gmsh:physical and gmsh:geometrical are tags, the rest is
469
+ # actual cell data.
470
+ tag_data = {}
471
+ cell_data = {}
472
+ for key, d in mesh.cell_data.items():
473
+ if key in ["gmsh:physical", "gmsh:geometrical", "cell_tags"]:
474
+ tag_data[key] = d
475
+ else:
476
+ cell_data[key] = d
477
+
478
+ # with open(filename, "wb") as fh:
479
+ file_type = 1 if binary else 0
480
+ data_size = c_size_t.itemsize
481
+ file.write(f"$MeshFormat\n")
482
+ if binary:
483
+ file.write(f"4.1 {file_type} {data_size}\n".encode())
484
+ else:
485
+ file.write(f"4.1 {file_type} {data_size}\n")
486
+
487
+ if binary:
488
+ np.array([1], dtype=c_int).tofile(file)
489
+ file.write(b"\n")
490
+ file.write(b"$EndMeshFormat\n")
491
+ else:
492
+ file.write("$EndMeshFormat\n")
493
+
494
+ if mesh.field_data:
495
+ _write_physical_names(file, mesh.field_data)
496
+
497
+ if not mesh_only:
498
+ _write_entities(
499
+ file, mesh.cells, tag_data, mesh.cell_sets, mesh.point_data, binary
500
+ )
501
+
502
+ _write_nodes(file, mesh.points, mesh.cells, mesh.point_data, float_fmt, binary)
503
+ _write_elements(file, mesh.cells, tag_data, binary)
504
+ if mesh.gmsh_periodic is not None:
505
+ _write_periodic(file, mesh.gmsh_periodic, float_fmt, binary)
506
+
507
+ # if not mesh_only:
508
+ for name, dat in point_data.items():
509
+ _write_data(file, "NodeData", name, dat, binary)
510
+ cell_data_raw = raw_from_cell_data(cell_data)
511
+ for name, dat in cell_data_raw.items():
512
+ _write_data(file, "ElementData", name, dat, binary)
513
+
514
+ # Write cell_point_data (element nodal data) to ElementNodeData sections
515
+ if hasattr(mesh, 'cell_point_data') and mesh.cell_point_data:
516
+ _write_cell_point_data(file, mesh, binary)
517
+
518
+
519
+ def _prepare_entity_data(
520
+ cells: List[CellBlock],
521
+ tag_data: Dict,
522
+ point_data: Dict,
523
+ cell_sets: Dict
524
+ ) -> Tuple[npt.NDArray, npt.NDArray, bool]:
525
+ """Prepare entity-related data structures.
526
+
527
+ Args:
528
+ cells: List of cell blocks
529
+ tag_data: Tag data dictionary
530
+ point_data: Point data dictionary
531
+ cell_sets: Cell sets dictionary
532
+
533
+ Returns:
534
+ Tuple of (node_dim_tags, cell_dim_tags, has_bounding_entities) where:
535
+ - node_dim_tags: Unique (dim, tag) pairs from point data
536
+ - cell_dim_tags: (dim, tag) pairs for each cell block
537
+ - has_bounding_entities: Whether bounding entity info exists
538
+ """
539
+ # Uniquify node dimension-tags
540
+ node_dim_tags = np.unique(point_data["gmsh:dim_tags"], axis=0)
541
+
542
+ # Prepare cell dimension-tags
543
+ cell_dim_tags = np.empty((len(cells), 2), dtype=int)
544
+ for ci, cell_block in enumerate(cells):
545
+ cell_dim_tags[ci] = [
546
+ cell_block.dim,
547
+ tag_data["gmsh:geometrical"][ci][0],
548
+ ]
549
+
550
+ has_bounding_entities = "gmsh:bounding_entities" in cell_sets
551
+
552
+ return node_dim_tags, cell_dim_tags, has_bounding_entities
553
+
554
+
555
+ def _write_entity_bbox(writer: GmshWriter, dim: int) -> None:
556
+ """Write bounding box coordinates (zeros placeholder).
557
+
558
+ Args:
559
+ writer: GmshWriter instance
560
+ dim: Entity dimension (0 for point, >0 for higher-dimensional entities)
561
+ """
562
+ num_coords = GMSH_BBOX_POINT_COORDS if dim == 0 else GMSH_BBOX_ENTITY_COORDS
563
+ writer.write_array(np.zeros(num_coords), c_double)
564
+ if not writer.binary:
565
+ writer.fh.write(" ")
566
+
567
+
568
+ def _write_physical_and_bounding_tags(
569
+ writer: GmshWriter,
570
+ dim: int,
571
+ matching_cell_block: npt.NDArray,
572
+ tag_data: Dict,
573
+ cell_sets: Dict,
574
+ has_bounding_entities: bool
575
+ ) -> None:
576
+ """Write physical tags and bounding entities for an entity.
577
+
578
+ Handles three cases:
579
+ 1. Entity with physical tag
580
+ 2. Entity without physical tag
581
+ 3. Bounding entities (for dim > 0)
582
+
583
+ Args:
584
+ writer: GmshWriter instance
585
+ dim: Entity dimension
586
+ matching_cell_block: Matching cell block indices
587
+ tag_data: Tag data dictionary
588
+ cell_sets: Cell sets dictionary
589
+ has_bounding_entities: Whether bounding entity info exists
590
+ """
591
+ # Guard clause: no matching cell block
592
+ if matching_cell_block.size == 0:
593
+ writer.write_array([0], c_size_t)
594
+ if not writer.binary:
595
+ writer.fh.write("0 ")
596
+
597
+ if dim > 0:
598
+ writer.write_array([0], c_size_t)
599
+ if not writer.binary:
600
+ writer.fh.write("0\n")
601
+ elif not writer.binary:
602
+ writer.fh.write("\n")
603
+ return
604
+
605
+ # Main path: has matching cell block
606
+ # Write physical tag if available
607
+ try:
608
+ physical_tag = tag_data["gmsh:physical"][matching_cell_block[0]][0]
609
+ writer.write_array([1], c_size_t)
610
+ writer.write_array([physical_tag], c_int)
611
+ if not writer.binary:
612
+ writer.fh.write(" ")
613
+ except KeyError:
614
+ writer.write_array([0], c_size_t)
615
+ if not writer.binary:
616
+ writer.fh.write("0 ")
617
+
618
+ # Write bounding entities for dim > 0
619
+ if dim > 0:
620
+ if has_bounding_entities:
621
+ bounds = cell_sets["gmsh:bounding_entities"][matching_cell_block[0]]
622
+ num_bounds = len(bounds)
623
+
624
+ if num_bounds > 0:
625
+ writer.write_array([num_bounds], c_size_t)
626
+ writer.write_array(bounds, c_int)
627
+ if not writer.binary:
628
+ writer.fh.write("\n")
629
+ else:
630
+ writer.write_array([0], c_size_t)
631
+ if not writer.binary:
632
+ writer.fh.write("0\n")
633
+ else:
634
+ writer.write_array([0], c_size_t)
635
+ if not writer.binary:
636
+ writer.fh.write("0\n")
637
+ else:
638
+ # Dimension 0: enforce line change for ASCII
639
+ if not writer.binary:
640
+ writer.fh.write("\n")
641
+
642
+
643
+ def _write_entities(
644
+ fh: FileHandle,
645
+ cells: List[CellBlock],
646
+ tag_data: Dict,
647
+ cell_sets: Dict,
648
+ point_data: Dict,
649
+ binary: bool
650
+ ) -> None:
651
+ """Write entity section in a .msh file.
652
+
653
+ The entity section links three kinds of information:
654
+ 1) The geometric objects represented in the mesh
655
+ 2) Physical tags of geometric objects
656
+ 3) Bounding entities (for objects of dimension >= 1)
657
+
658
+ The entities of all geometric objects are pulled from point_data['gmsh:dim_tags'].
659
+ Physical tags are specified as tag_data, while the boundary of a geometric
660
+ object is specified in cell_sets.
661
+ """
662
+ # Early return if no entity data
663
+ if "gmsh:dim_tags" not in point_data:
664
+ return
665
+
666
+ writer = GmshWriter(fh, binary)
667
+ writer.write_section_header("Entities")
668
+
669
+ # Prepare data structures
670
+ node_dim_tags, cell_dim_tags, has_bounding_entities = _prepare_entity_data(
671
+ cells, tag_data, point_data, cell_sets
672
+ )
673
+
674
+ # Write number of entities per dimension
675
+ num_occ = np.bincount(node_dim_tags[:, 0], minlength=GMSH_MAX_DIM)
676
+ if num_occ.size > GMSH_MAX_DIM:
677
+ raise ValueError(f"Encountered entity with dimension > {GMSH_MAX_DIM - 1}")
678
+
679
+ writer.write_array(num_occ, c_size_t)
680
+ if not binary:
681
+ fh.write("\n")
682
+
683
+ # Write entity data
684
+ for dim, tag in node_dim_tags:
685
+ # Find matching cell block
686
+ matching_cell_block = np.where(
687
+ np.logical_and(cell_dim_tags[:, 0] == dim, cell_dim_tags[:, 1] == tag)
688
+ )[0]
689
+
690
+ if matching_cell_block.size > 1:
691
+ raise ValueError("Encountered non-unique CellBlock dim_tag")
692
+
693
+ # Write entity tag
694
+ writer.write_array([tag], c_int)
695
+ if not writer.binary:
696
+ writer.fh.write(" ")
697
+
698
+ # Write bounding box (placeholder zeros)
699
+ _write_entity_bbox(writer, dim)
700
+
701
+ # Write physical tags and bounding entities
702
+ _write_physical_and_bounding_tags(
703
+ writer, dim, matching_cell_block, tag_data, cell_sets, has_bounding_entities
704
+ )
705
+
706
+ writer.newline()
707
+ writer.write_section_footer("Entities")
708
+
709
+
710
+ def _prepare_node_dim_tags(
711
+ point_data: Dict,
712
+ cells: List[CellBlock],
713
+ n: int
714
+ ) -> Tuple[npt.NDArray, npt.NDArray]:
715
+ """Prepare node dimension-tag mappings.
716
+
717
+ Args:
718
+ point_data: Point data dictionary
719
+ cells: List of cell blocks
720
+ n: Number of points
721
+
722
+ Returns:
723
+ Tuple of (node_dim_tags, reverse_index_map) where:
724
+ - node_dim_tags: Unique (dim, tag) combinations
725
+ - reverse_index_map: Maps each node to its dim_tag index
726
+ """
727
+ if "gmsh:dim_tags" in point_data:
728
+ # reverse_index_map maps from all nodes to their respective representation in
729
+ # (the uniquified) node_dim_tags. This approach works for general orderings of
730
+ # the nodes
731
+ node_dim_tags, reverse_index_map = np.unique(
732
+ point_data["gmsh:dim_tags"],
733
+ axis=0,
734
+ return_inverse=True,
735
+ )
736
+ else:
737
+ # If entity information is not provided, assign the same entity for all nodes.
738
+ # This only makes sense if the cells are of a single type
739
+ if len(cells) != 1:
740
+ raise WriteError(
741
+ "Specify entity information (gmsh:dim_tags in point_data) "
742
+ "to deal with more than one cell type."
743
+ )
744
+
745
+ dim = cells[0].dim
746
+ tag = GMSH_NODE_TAG_OFFSET
747
+ node_dim_tags = np.array([[dim, tag]])
748
+ # All nodes map to the (single) dimension-entity object
749
+ reverse_index_map = np.full(n, 0, dtype=int)
750
+
751
+ return node_dim_tags, reverse_index_map
752
+
753
+
754
+ def _write_node_block(
755
+ writer: GmshWriter,
756
+ dim: int,
757
+ tag: int,
758
+ node_tags: npt.NDArray,
759
+ points: npt.NDArray,
760
+ float_fmt: str
761
+ ) -> None:
762
+ """Write a single node entity block.
763
+
764
+ Args:
765
+ writer: GmshWriter instance
766
+ dim: Entity dimension
767
+ tag: Entity tag
768
+ node_tags: Node indices for this block
769
+ points: All point coordinates
770
+ float_fmt: Format string for floating point numbers
771
+ """
772
+ num_points_this = node_tags.size
773
+ is_parametric = 0
774
+
775
+ writer.write_array([dim, tag, is_parametric], c_int)
776
+ writer.write_array([num_points_this], c_size_t)
777
+
778
+ if writer.binary:
779
+ (node_tags + GMSH_NODE_TAG_OFFSET).astype(c_size_t).tofile(writer.fh)
780
+ points[node_tags].tofile(writer.fh)
781
+ else:
782
+ (node_tags + GMSH_NODE_TAG_OFFSET).astype(c_size_t).tofile(writer.fh, "\n", "%d")
783
+ writer.fh.write("\n")
784
+ np.savetxt(writer.fh, points[node_tags], delimiter=" ", fmt="%" + float_fmt)
785
+
786
+
787
+ def _write_nodes(
788
+ fh: FileHandle,
789
+ points: npt.NDArray,
790
+ cells: List[CellBlock],
791
+ point_data: Dict,
792
+ float_fmt: str,
793
+ binary: bool
794
+ ) -> None:
795
+ """Write node information.
796
+
797
+ If data on dimension and tags of the geometric entities which the nodes belong to
798
+ is available, the nodes will be grouped accordingly. This data is specified as
799
+ point_data, using the key 'gmsh:dim_tags' and data as a num_points x 2 numpy
800
+ array (first column is the dimension of the geometric entity of this node,
801
+ second is the tag).
802
+
803
+ If dim_tags are not available, all nodes will be assigned the same tag. This only
804
+ makes sense if a single cell block is present in the mesh; an error will be raised
805
+ if len(cells) > 1.
806
+ """
807
+ # Ensure 3D points (msh4 requires 3D points)
808
+ if points.shape[1] == 2:
809
+ points = np.column_stack([points, np.zeros_like(points[:, 0])])
810
+
811
+ writer = GmshWriter(fh, binary)
812
+ writer.write_section_header("Nodes")
813
+
814
+ n = points.shape[0]
815
+ min_tag = GMSH_NODE_TAG_OFFSET
816
+ max_tag = n
817
+
818
+ # Prepare dimension-tag mappings
819
+ node_dim_tags, reverse_index_map = _prepare_node_dim_tags(point_data, cells, n)
820
+ num_blocks = node_dim_tags.shape[0]
821
+
822
+ # Validate and prepare points for binary mode
823
+ if binary and points.dtype != c_double:
824
+ warn(f"Binary Gmsh needs c_double points (got {points.dtype}). Converting.")
825
+ points = points.astype(c_double)
826
+
827
+ # Write preamble
828
+ writer.write_array([num_blocks, n, min_tag, max_tag], c_size_t)
829
+ if not binary:
830
+ fh.write("\n")
831
+
832
+ # Write node blocks
833
+ for j in range(num_blocks):
834
+ dim, tag = node_dim_tags[j]
835
+ node_tags = np.where(reverse_index_map == j)[0]
836
+ _write_node_block(writer, dim, tag, node_tags, points, float_fmt)
837
+
838
+ writer.newline()
839
+ writer.write_section_footer("Nodes")
840
+
841
+
842
+ def _write_element_block(
843
+ writer: GmshWriter,
844
+ cell_block: CellBlock,
845
+ tag_data: Dict,
846
+ block_index: int,
847
+ tag0: int
848
+ ) -> int:
849
+ """Write a single element entity block.
850
+
851
+ Args:
852
+ writer: GmshWriter instance
853
+ cell_block: CellBlock to write
854
+ tag_data: Tag data dictionary
855
+ block_index: Index of this cell block
856
+ tag0: Starting element tag
857
+
858
+ Returns:
859
+ Updated tag counter for next block
860
+ """
861
+ node_idcs = _meshio_to_gmsh_order(cell_block.type, cell_block.data)
862
+
863
+ # Get entity tag
864
+ if "gmsh:geometrical" in tag_data:
865
+ entity_tag = tag_data["gmsh:geometrical"][block_index][0]
866
+ else:
867
+ # Note: Binary uses 0, ASCII uses 1 as default (preserving original behavior)
868
+ entity_tag = 0 if writer.binary else 1
869
+
870
+ cell_type = _meshio_to_gmsh_type[cell_block.type]
871
+ n = len(cell_block.data)
872
+
873
+ # Write block header
874
+ writer.write_array([cell_block.dim, entity_tag, cell_type], c_int)
875
+ writer.write_array([n], c_size_t)
876
+
877
+ # Write element data
878
+ if writer.binary:
879
+ if node_idcs.dtype != c_size_t:
880
+ warn(f"Binary Gmsh cells need c_size_t (got {node_idcs.dtype}). Converting.")
881
+ node_idcs = node_idcs.astype(c_size_t)
882
+
883
+ np.column_stack([
884
+ np.arange(tag0, tag0 + n, dtype=c_size_t),
885
+ node_idcs + GMSH_NODE_TAG_OFFSET,
886
+ ]).tofile(writer.fh)
887
+ else:
888
+ np.savetxt(
889
+ writer.fh,
890
+ np.column_stack([
891
+ np.arange(tag0, tag0 + n),
892
+ node_idcs + GMSH_NODE_TAG_OFFSET
893
+ ]),
894
+ "%d",
895
+ " ",
896
+ )
897
+
898
+ return tag0 + n
899
+
900
+
901
+ def _write_elements(fh: FileHandle, cells: List[CellBlock], tag_data: Dict, binary: bool) -> None:
902
+ """Write the $Elements block.
903
+
904
+ $Elements
905
+ numEntityBlocks(size_t)
906
+ numElements(size_t) minElementTag(size_t) maxElementTag(size_t)
907
+ entityDim(int) entityTag(int) elementType(int) numElementsInBlock(size_t)
908
+ elementTag(size_t) nodeTag(size_t) ...
909
+ ...
910
+ ...
911
+ $EndElements
912
+ """
913
+ writer = GmshWriter(fh, binary)
914
+ writer.write_section_header("Elements")
915
+
916
+ total_num_cells = sum(len(c) for c in cells)
917
+ num_blocks = len(cells)
918
+ min_element_tag = GMSH_NODE_TAG_OFFSET
919
+ max_element_tag = total_num_cells
920
+
921
+ writer.write_array(
922
+ [num_blocks, total_num_cells, min_element_tag, max_element_tag],
923
+ c_size_t
924
+ )
925
+ if not binary:
926
+ fh.write("\n")
927
+
928
+ tag0 = GMSH_NODE_TAG_OFFSET
929
+ for ci, cell_block in enumerate(cells):
930
+ tag0 = _write_element_block(writer, cell_block, tag_data, ci, tag0)
931
+
932
+ writer.newline()
933
+ writer.write_section_footer("Elements")
934
+
935
+
936
+ def _write_periodic(fh: FileHandle, periodic, float_fmt: str, binary: bool) -> None:
937
+ """Write the $Periodic block.
938
+
939
+ Specified as:
940
+
941
+ $Periodic
942
+ numPeriodicLinks(size_t)
943
+ entityDim(int) entityTag(int) entityTagMaster(int)
944
+ numAffine(size_t) value(double) ...
945
+ numCorrespondingNodes(size_t)
946
+ nodeTag(size_t) nodeTagMaster(size_t)
947
+ ...
948
+ ...
949
+ $EndPeriodic
950
+ """
951
+ writer = GmshWriter(fh, binary)
952
+
953
+ writer.write_section_header("Periodic")
954
+ writer.write_array(len(periodic), c_size_t)
955
+
956
+ for dim, (stag, mtag), affine, slave_master in periodic:
957
+ writer.write_array([dim, stag, mtag], c_int)
958
+
959
+ if affine is None or len(affine) == 0:
960
+ writer.write_array(0, c_size_t)
961
+ else:
962
+ writer.write_array(len(affine), c_size_t, sep=" ")
963
+ writer.write_array(affine, c_double, fmt=float_fmt)
964
+
965
+ slave_master = np.array(slave_master, dtype=c_size_t)
966
+ slave_master = slave_master.reshape(-1, 2)
967
+ slave_master = slave_master + GMSH_NODE_TAG_OFFSET # Gmsh is 1-based
968
+ writer.write_array(len(slave_master), c_size_t)
969
+ writer.write_array(slave_master, c_size_t)
970
+
971
+ writer.newline()
972
+ writer.write_section_footer("Periodic")
973
+
974
+
975
+ def _determine_component_count(cell_blocks_data: List[npt.NDArray]) -> Optional[int]:
976
+ """Determine number of components from cell_point_data shape.
977
+
978
+ Args:
979
+ cell_blocks_data: List of cell block data arrays
980
+
981
+ Returns:
982
+ Number of components, or None if shape is invalid
983
+ """
984
+ first_block_data = cell_blocks_data[0]
985
+
986
+ if len(first_block_data.shape) == 2:
987
+ # Shape: (n_elements, n_nodes_per_element) - single component
988
+ return 1
989
+ elif len(first_block_data.shape) == 3:
990
+ # Shape: (n_elements, n_nodes_per_element, n_components)
991
+ return first_block_data.shape[2]
992
+ else:
993
+ return None
994
+
995
+
996
+ def _write_element_node_data_header(
997
+ writer: GmshWriter,
998
+ field_name: str,
999
+ num_components: int,
1000
+ total_elements: int
1001
+ ) -> None:
1002
+ """Write ElementNodeData section header.
1003
+
1004
+ Args:
1005
+ writer: GmshWriter instance
1006
+ field_name: Name of the data field
1007
+ num_components: Number of components per node
1008
+ total_elements: Total number of elements
1009
+ """
1010
+ writer.write_section_header("ElementNodeData")
1011
+
1012
+ # String tags
1013
+ writer.write_string(f"{1}\n")
1014
+ writer.write_string(f'"{field_name}"\n')
1015
+
1016
+ # Real tags
1017
+ writer.write_string(f"{1}\n")
1018
+ writer.write_string(f"{0.0}\n")
1019
+
1020
+ # Integer tags: time step, num components, num elements
1021
+ writer.write_string(f"{3}\n")
1022
+ writer.write_string(f"{0}\n") # time step
1023
+ writer.write_string(f"{num_components}\n")
1024
+ writer.write_string(f"{total_elements}\n")
1025
+
1026
+
1027
+ def _write_element_node_values(
1028
+ writer: GmshWriter,
1029
+ elem_id: int,
1030
+ elem_data: npt.NDArray,
1031
+ num_components: int
1032
+ ) -> None:
1033
+ """Write nodal data for a single element.
1034
+
1035
+ Args:
1036
+ writer: GmshWriter instance
1037
+ elem_id: Element ID
1038
+ elem_data: Nodal data for this element
1039
+ num_components: Number of components per node
1040
+ """
1041
+ if num_components == 1:
1042
+ # Single component: elem_data is 1D array
1043
+ num_nodes = len(elem_data)
1044
+ writer.write_array([elem_id, num_nodes], c_int)
1045
+
1046
+ if writer.binary:
1047
+ elem_data.astype(c_double).tofile(writer.fh)
1048
+ else:
1049
+ for val in elem_data:
1050
+ writer.fh.write(f" {float(val)}")
1051
+ writer.fh.write("\n")
1052
+ else:
1053
+ # Multi-component: elem_data is 2D array (n_nodes, n_components)
1054
+ num_nodes = elem_data.shape[0]
1055
+ writer.write_array([elem_id, num_nodes], c_int)
1056
+
1057
+ if writer.binary:
1058
+ # Flatten the data: all components for node 1, then node 2, etc.
1059
+ elem_data.astype(c_double).flatten().tofile(writer.fh)
1060
+ else:
1061
+ for node_vals in elem_data:
1062
+ for val in node_vals:
1063
+ writer.fh.write(f" {float(val)}")
1064
+ writer.fh.write("\n")
1065
+
1066
+
1067
+ def _write_cell_point_data(fh: FileHandle, mesh, binary: bool) -> None:
1068
+ """Write cell_point_data (element nodal data) to $ElementNodeData sections.
1069
+
1070
+ The cell_point_data structure is:
1071
+ {name: [array_for_cell_block_0, array_for_cell_block_1, ...]}
1072
+ where each array has shape (n_elements, n_nodes_per_element) for single
1073
+ component or (n_elements, n_nodes_per_element, n_components) for multi-
1074
+ component data.
1075
+
1076
+ Args:
1077
+ fh: File handle to write to
1078
+ mesh: Mesh object containing cell_point_data
1079
+ binary: Whether to write in binary mode
1080
+ """
1081
+ # Validate element_id exists
1082
+ if 'element_id' not in mesh.cell_data:
1083
+ logger.warning("Cannot write cell_point_data: mesh.cell_data['element_id'] not found")
1084
+ return
1085
+
1086
+ element_ids = mesh.cell_data['element_id']
1087
+ writer = GmshWriter(fh, binary)
1088
+
1089
+ # Process each field
1090
+ for field_name, cell_blocks_data in mesh.cell_point_data.items():
1091
+ # Determine component count
1092
+ num_components = _determine_component_count(cell_blocks_data)
1093
+ if num_components is None:
1094
+ logger.warning(
1095
+ f"Unexpected shape for cell_point_data '{field_name}': "
1096
+ f"{cell_blocks_data[0].shape}"
1097
+ )
1098
+ continue
1099
+
1100
+ total_elements = sum(len(block_data) for block_data in cell_blocks_data)
1101
+
1102
+ # Write header
1103
+ _write_element_node_data_header(writer, field_name, num_components, total_elements)
1104
+
1105
+ # Write data for each element
1106
+ for block_idx, block_data in enumerate(cell_blocks_data):
1107
+ block_element_ids = element_ids[block_idx]
1108
+
1109
+ for elem_idx, elem_data in enumerate(block_data):
1110
+ elem_id = int(block_element_ids[elem_idx])
1111
+ _write_element_node_values(writer, elem_id, elem_data, num_components)
1112
+
1113
+ # Write footer
1114
+ writer.newline()
1115
+ writer.write_section_footer("ElementNodeData")