bluemesh2d 0.1.1.dev0__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.
Files changed (74) hide show
  1. bluemesh2d/_version.py +24 -0
  2. bluemesh2d/aabb_tree/findball.py +121 -0
  3. bluemesh2d/aabb_tree/findtria.py +299 -0
  4. bluemesh2d/aabb_tree/maketree.py +147 -0
  5. bluemesh2d/aabb_tree/mapvert.py +72 -0
  6. bluemesh2d/aabb_tree/queryset.py +83 -0
  7. bluemesh2d/aabb_tree/scantree.py +124 -0
  8. bluemesh2d/dependencies.py +83 -0
  9. bluemesh2d/feedback.py +142 -0
  10. bluemesh2d/geom_util/boundary_util.py +216 -0
  11. bluemesh2d/geom_util/getiso.py +194 -0
  12. bluemesh2d/geom_util/poly_util.py +795 -0
  13. bluemesh2d/geom_util/proj_util.py +250 -0
  14. bluemesh2d/geomesh_util/border_util.py +283 -0
  15. bluemesh2d/geomesh_util/depth_field.py +333 -0
  16. bluemesh2d/geomesh_util/grd_util.py +1000 -0
  17. bluemesh2d/geomesh_util/interpolation_mesh.py +318 -0
  18. bluemesh2d/geomesh_util/merge_circumcenters.py +268 -0
  19. bluemesh2d/geomesh_util/water_polygon.py +406 -0
  20. bluemesh2d/hfun_util/build_hfun.py +589 -0
  21. bluemesh2d/hfun_util/hfun_dispersion.py +96 -0
  22. bluemesh2d/hfun_util/lfshfn.py +110 -0
  23. bluemesh2d/hfun_util/limhfn.py +65 -0
  24. bluemesh2d/hfun_util/make_constant_hfun.py +32 -0
  25. bluemesh2d/hfun_util/make_depth_hfun.py +51 -0
  26. bluemesh2d/hfun_util/smooth_and_precomput.py +188 -0
  27. bluemesh2d/hfun_util/trihfn.py +84 -0
  28. bluemesh2d/hjac_util/limgrad.py +105 -0
  29. bluemesh2d/mesh_ball/cdtbal1.py +38 -0
  30. bluemesh2d/mesh_ball/cdtbal2.py +104 -0
  31. bluemesh2d/mesh_ball/inv_2x2.py +61 -0
  32. bluemesh2d/mesh_ball/inv_3x3.py +72 -0
  33. bluemesh2d/mesh_ball/pwrbal2.py +134 -0
  34. bluemesh2d/mesh_ball/tribal2.py +27 -0
  35. bluemesh2d/mesh_cost/relhfn.py +62 -0
  36. bluemesh2d/mesh_cost/triang.py +59 -0
  37. bluemesh2d/mesh_cost/triarea.py +51 -0
  38. bluemesh2d/mesh_cost/trideg.py +44 -0
  39. bluemesh2d/mesh_cost/triscr.py +43 -0
  40. bluemesh2d/mesh_file/bnd_util.py +664 -0
  41. bluemesh2d/mesh_file/loadmsh.py +165 -0
  42. bluemesh2d/mesh_file/ugrid.py +308 -0
  43. bluemesh2d/mesh_util/cfmtri.py +118 -0
  44. bluemesh2d/mesh_util/deltri.py +131 -0
  45. bluemesh2d/mesh_util/idxtri.py +53 -0
  46. bluemesh2d/mesh_util/isfeat.py +90 -0
  47. bluemesh2d/mesh_util/minlen.py +46 -0
  48. bluemesh2d/mesh_util/setset.py +49 -0
  49. bluemesh2d/mesh_util/tricon.py +90 -0
  50. bluemesh2d/mesh_util/tridiv.py +187 -0
  51. bluemesh2d/meshgen.py +202 -0
  52. bluemesh2d/ortho_merge/constants.py +27 -0
  53. bluemesh2d/ortho_merge/geometry.py +64 -0
  54. bluemesh2d/ortho_merge/ortho_merge_iter.py +812 -0
  55. bluemesh2d/ortho_merge/orthogonalize.py +2989 -0
  56. bluemesh2d/pipeline.py +277 -0
  57. bluemesh2d/poly_data/airfoil.msh +958 -0
  58. bluemesh2d/poly_data/channel.msh +212 -0
  59. bluemesh2d/poly_data/islands.msh +13819 -0
  60. bluemesh2d/poly_data/lake.msh +612 -0
  61. bluemesh2d/poly_data/river.msh +690 -0
  62. bluemesh2d/poly_test/inpoly.py +105 -0
  63. bluemesh2d/poly_test/inpoly_mat.py +104 -0
  64. bluemesh2d/refine.py +972 -0
  65. bluemesh2d/smood.py +623 -0
  66. bluemesh2d/smooth.py +522 -0
  67. bluemesh2d/tricost.py +426 -0
  68. bluemesh2d/tridemo.py +723 -0
  69. bluemesh2d/triread.py +53 -0
  70. bluemesh2d-0.1.1.dev0.dist-info/METADATA +139 -0
  71. bluemesh2d-0.1.1.dev0.dist-info/RECORD +74 -0
  72. bluemesh2d-0.1.1.dev0.dist-info/WHEEL +5 -0
  73. bluemesh2d-0.1.1.dev0.dist-info/licenses/LICENSE +674 -0
  74. bluemesh2d-0.1.1.dev0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1000 @@
1
+ """
2
+ Delft3D-FM UGRID (xarray) builders, mixed tri/quad connectivity, and ADCIRC helpers.
3
+
4
+ Used by :mod:`bluemesh2d.smood` and ``merge_circumcenters``.
5
+
6
+ ``xarray`` is imported lazily so the array-level helpers (``build_ugrid_arrays``,
7
+ ``calculate_edges``, ...) stay usable without it; only the ``xr.Dataset``
8
+ builders (``adcirc2DFlowFM*``) require it.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ from datetime import datetime
13
+
14
+ import matplotlib.pyplot as plt
15
+ import numpy as np
16
+
17
+ try:
18
+ import xarray as xr
19
+ except ImportError: # optional: only needed for the xr.Dataset builders
20
+ xr = None
21
+
22
+
23
+ def _signed_area_tri_xy(xy: np.ndarray, i: int, j: int, k: int) -> float:
24
+ """Twice the signed triangle area in the x–y plane (CCW > 0)."""
25
+ p, q, r = xy[i], xy[j], xy[k]
26
+ return (q[0] - p[0]) * (r[1] - p[1]) - (r[0] - p[0]) * (q[1] - p[1])
27
+
28
+
29
+ def triangulate_mixed_face_row_to_tris(
30
+ node_xy: np.ndarray, nodes_valid: np.ndarray
31
+ ) -> list[tuple[int, int, int]]:
32
+ """Triangulate one mixed polygon face (3, 4, or more vertices).
33
+
34
+ Three-node faces yield one triangle. Four-node faces (quads stored as
35
+ ``[a, v1, b, v2]`` from ``merge_circumcenters``) split along the merged
36
+ diagonal ``(v1, v2)``. Faces with five or more nodes are fanned from the
37
+ first vertex.
38
+
39
+ Parameters
40
+ ----------
41
+ node_xy : ndarray of shape (N, 2)
42
+ Mesh node coordinates.
43
+ nodes_valid : ndarray
44
+ 0-based vertex indices for one face (length 3, 4, or more).
45
+
46
+ Returns
47
+ -------
48
+ list of tuple of int
49
+ Triangle connectivity as ``(i, j, k)`` tuples.
50
+ """
51
+ nodes = np.asarray(nodes_valid, dtype=np.int64).reshape(-1)
52
+ n = int(nodes.size)
53
+ if n < 3:
54
+ return []
55
+ xy = np.asarray(node_xy, dtype=np.float64)
56
+ if n == 3:
57
+ return [(int(nodes[0]), int(nodes[1]), int(nodes[2]))]
58
+ if n == 4:
59
+ a, v1, b, v2 = int(nodes[0]), int(nodes[1]), int(nodes[2]), int(nodes[3])
60
+
61
+ def sa(ii: int, jj: int, kk: int) -> float:
62
+ return _signed_area_tri_xy(xy, ii, jj, kk)
63
+
64
+ t1 = (a, v1, v2)
65
+ if sa(*t1) < 0:
66
+ t1 = (a, v2, v1)
67
+ o1 = sa(*t1)
68
+ t2 = None
69
+ for cand in ((b, v1, v2), (b, v2, v1)):
70
+ if o1 != 0.0 and sa(*cand) * o1 > 0:
71
+ t2 = cand
72
+ break
73
+ if t2 is None:
74
+ t2 = (b, v2, v1)
75
+ return [t1, t2]
76
+ tris: list[tuple[int, int, int]] = []
77
+ n0 = int(nodes[0])
78
+ for i in range(1, n - 1):
79
+ tris.append((n0, int(nodes[i]), int(nodes[i + 1])))
80
+ return tris
81
+
82
+
83
+ def face_nodes_0b_to_faces_list(face_nodes_0b: np.ndarray) -> list:
84
+ """Convert a padded face-node array to a list of variable-length faces.
85
+
86
+ Parameters
87
+ ----------
88
+ face_nodes_0b : ndarray of shape (F, 4)
89
+ Face-node connectivity (0-based, ``-1`` padding).
90
+
91
+ Returns
92
+ -------
93
+ list of ndarray
94
+ One array of vertex indices per face (length 3 for triangles, 4 for quads).
95
+ """
96
+ out: list = []
97
+ for row in np.asarray(face_nodes_0b, dtype=np.int64):
98
+ nodes = row[row >= 0]
99
+ if nodes.size >= 3:
100
+ out.append(nodes.copy())
101
+ return out
102
+
103
+
104
+ def validate_mixed_export_matches_smood_tria(
105
+ vert_xy: np.ndarray,
106
+ face_nodes_0b: np.ndarray,
107
+ tria_smood: np.ndarray,
108
+ ) -> tuple[int, int]:
109
+ """Verify that mixed-face export matches the ``smood`` triangle output.
110
+
111
+ Expands ``face_nodes_0b`` (final ortho+merge topology) and checks that the
112
+ resulting triangle set equals ``tria_smood`` from
113
+ :func:`bluemesh2d.smood.smood`.
114
+
115
+ Parameters
116
+ ----------
117
+ vert_xy : ndarray of shape (N, 2+)
118
+ Node coordinates.
119
+ face_nodes_0b : ndarray of shape (F, 4)
120
+ Mixed face-node connectivity (0-based, ``-1`` padding).
121
+ tria_smood : ndarray of shape (T, 3)
122
+ Triangle connectivity returned by ``smood``.
123
+
124
+ Returns
125
+ -------
126
+ n_tri_faces : int
127
+ Number of triangular faces in ``face_nodes_0b``.
128
+ n_quad_faces : int
129
+ Number of quadrilateral faces in ``face_nodes_0b``.
130
+
131
+ Raises
132
+ ------
133
+ ValueError
134
+ If connectivity is out of range or the expanded triangles do not match
135
+ ``tria_smood``.
136
+ """
137
+ vert_xy = np.asarray(vert_xy, dtype=np.float64)
138
+ if vert_xy.ndim != 2 or vert_xy.shape[1] < 2:
139
+ raise ValueError("vert_xy must have shape (N, 2+)")
140
+ xy = vert_xy[:, :2]
141
+ fn = np.asarray(face_nodes_0b, dtype=np.int64)
142
+ tria_smood = np.asarray(tria_smood, dtype=np.int64)
143
+ if tria_smood.ndim != 2 or tria_smood.shape[1] != 3:
144
+ raise ValueError("tria_smood must have shape (T, 3)")
145
+
146
+ expanded: list[tuple[int, int, int]] = []
147
+ n_tri_f = n_quad_f = 0
148
+ for row in fn:
149
+ nodes = row[row >= 0]
150
+ if nodes.size < 3:
151
+ continue
152
+ if int(nodes.min()) < 0 or int(nodes.max()) >= vert_xy.shape[0]:
153
+ raise ValueError(
154
+ f"face node index out of range [0, {vert_xy.shape[0]}): {nodes!r}"
155
+ )
156
+ if nodes.size == 4:
157
+ n_quad_f += 1
158
+ else:
159
+ n_tri_f += 1
160
+ expanded.extend(triangulate_mixed_face_row_to_tris(xy, nodes))
161
+
162
+ exp = np.asarray(expanded, dtype=np.int64)
163
+ if exp.shape != tria_smood.shape:
164
+ raise ValueError(
165
+ "Export topology mismatch: mixed faces expand to "
166
+ f"{exp.shape[0]} triangles, but smood returned {tria_smood.shape[0]}. "
167
+ "You may be exporting triangle-only (quads re-split) or a stale ``face_nodes``."
168
+ )
169
+
170
+ def _sort_rows(t: np.ndarray) -> np.ndarray:
171
+ return np.sort(np.asarray(t, dtype=np.int64), axis=1)
172
+
173
+ es = _sort_rows(exp)
174
+ ts = _sort_rows(tria_smood)
175
+ order_e = np.lexsort((es[:, 2], es[:, 1], es[:, 0]))
176
+ order_t = np.lexsort((ts[:, 2], ts[:, 1], ts[:, 0]))
177
+ if not np.array_equal(es[order_e], ts[order_t]):
178
+ raise ValueError(
179
+ "Triangle set from mixed faces ≠ smood triangle output (winding/diagonal mismatch)."
180
+ )
181
+ return int(n_tri_f), int(n_quad_f)
182
+
183
+
184
+ def _xr_dataset_from_ugrid_dict(ugrid: dict) -> xr.Dataset:
185
+ """Build the standard Delft3D-FM-style xarray Dataset from a ``build_ugrid_arrays*`` dict."""
186
+ if xr is None:
187
+ raise ImportError(
188
+ "xarray is required for the xr.Dataset builders (adcirc2DFlowFM*). "
189
+ "Either install xarray, or use build_ugrid_arrays() and write the "
190
+ "NetCDF yourself (e.g. with netCDF4)."
191
+ )
192
+ node_z_out = -ugrid["node_z"]
193
+ _WGS84_FILL = np.int32(-2147483647)
194
+ _UGRID_FILL = np.int32(-999)
195
+
196
+ coords = {
197
+ "mesh2d_node_x": xr.DataArray(
198
+ ugrid["node_x"],
199
+ dims=("mesh2d_nNodes",),
200
+ attrs={
201
+ "standard_name": "longitude",
202
+ "long_name": "x-coordinate of mesh nodes",
203
+ "units": "degrees_east",
204
+ },
205
+ ),
206
+ "mesh2d_node_y": xr.DataArray(
207
+ ugrid["node_y"],
208
+ dims=("mesh2d_nNodes",),
209
+ attrs={
210
+ "standard_name": "latitude",
211
+ "long_name": "y-coordinate of mesh nodes",
212
+ "units": "degrees_north",
213
+ },
214
+ ),
215
+ }
216
+
217
+ data_vars = {
218
+ "mesh2d_node_z": xr.DataArray(
219
+ node_z_out,
220
+ dims=("mesh2d_nNodes",),
221
+ attrs={
222
+ "mesh": "mesh2d",
223
+ "location": "node",
224
+ "units": "m",
225
+ "standard_name": "altitude",
226
+ "long_name": "z-coordinate of mesh nodes",
227
+ "grid_mapping": "wgs84",
228
+ },
229
+ ),
230
+ "mesh2d_edge_x": xr.DataArray(
231
+ ugrid["edge_x"],
232
+ dims=("mesh2d_nEdges",),
233
+ attrs={
234
+ "standard_name": "projection_x_coordinate",
235
+ "long_name": "characteristic x-coordinate of the mesh edge (e.g. midpoint)",
236
+ "units": "degrees_east",
237
+ # Keep standard_name as in GUI (longitude) instead of projection_x_coordinate.
238
+ "standard_name": "longitude",
239
+ },
240
+ ),
241
+ "mesh2d_edge_y": xr.DataArray(
242
+ ugrid["edge_y"],
243
+ dims=("mesh2d_nEdges",),
244
+ attrs={
245
+ "standard_name": "projection_y_coordinate",
246
+ "long_name": "characteristic y-coordinate of the mesh edge (e.g. midpoint)",
247
+ "units": "degrees_north",
248
+ "standard_name": "latitude",
249
+ },
250
+ ),
251
+ "mesh2d_edge_nodes": xr.DataArray(
252
+ ugrid["edge_nodes"],
253
+ dims=("mesh2d_nEdges", "Two"),
254
+ attrs={
255
+ "cf_role": "edge_node_connectivity",
256
+ "long_name": "Start and end nodes of mesh edges",
257
+ "start_index": 1,
258
+ },
259
+ ),
260
+ "mesh2d_edge_faces": xr.DataArray(
261
+ ugrid["edge_faces"],
262
+ dims=("mesh2d_nEdges", "Two"),
263
+ attrs={
264
+ "cf_role": "edge_face_connectivity",
265
+ "long_name": "Neighboring faces of mesh edges",
266
+ "start_index": np.int32(1),
267
+ },
268
+ ).assign_attrs(_FillValue=_UGRID_FILL),
269
+ "mesh2d_face_nodes": xr.DataArray(
270
+ ugrid["face_nodes"],
271
+ dims=("mesh2d_nFaces", "mesh2d_nMax_face_nodes"),
272
+ attrs={
273
+ "cf_role": "face_node_connectivity",
274
+ "long_name": "Vertex nodes of mesh faces (counterclockwise)",
275
+ "start_index": np.int32(1),
276
+ "coordinates": "mesh2d_node_x mesh2d_node_y",
277
+ },
278
+ ).assign_attrs(_FillValue=_UGRID_FILL),
279
+ "mesh2d_face_x": xr.DataArray(
280
+ ugrid["face_x"],
281
+ dims=("mesh2d_nFaces",),
282
+ attrs={
283
+ "units": "degrees_east",
284
+ "standard_name": "longitude",
285
+ "long_name": "Characteristic x-coordinate of mesh face",
286
+ "bounds": "mesh2d_face_x_bnd",
287
+ },
288
+ ),
289
+ "mesh2d_face_y": xr.DataArray(
290
+ ugrid["face_y"],
291
+ dims=("mesh2d_nFaces",),
292
+ attrs={
293
+ "units": "degrees_north",
294
+ "standard_name": "latitude",
295
+ "long_name": "Characteristic y-coordinate of mesh face",
296
+ "bounds": "mesh2d_face_y_bnd",
297
+ },
298
+ ),
299
+ "mesh2d_face_x_bnd": xr.DataArray(
300
+ ugrid["face_x_bnd"],
301
+ dims=("mesh2d_nFaces", "mesh2d_nMax_face_nodes"),
302
+ attrs={
303
+ "long_name": "x-coordinate bounds of mesh faces (i.e. corner coordinates)",
304
+ "units": "degrees_east",
305
+ "standard_name": "longitude",
306
+ },
307
+ ),
308
+ "mesh2d_face_y_bnd": xr.DataArray(
309
+ ugrid["face_y_bnd"],
310
+ dims=("mesh2d_nFaces", "mesh2d_nMax_face_nodes"),
311
+ attrs={
312
+ "long_name": "y-coordinate bounds of mesh faces (i.e. corner coordinates)",
313
+ "units": "degrees_north",
314
+ "standard_name": "latitude",
315
+ },
316
+ ),
317
+ "wgs84": xr.DataArray(
318
+ np.int32(4326),
319
+ dims=(),
320
+ attrs={
321
+ "name": "WGS 84",
322
+ "epsg": np.int32(4326),
323
+ "grid_mapping_name": "latitude_longitude",
324
+ "longitude_of_prime_meridian": 0.0,
325
+ "semi_major_axis": 6378137.0,
326
+ "semi_minor_axis": 6356752.314245,
327
+ "inverse_flattening": 298.257223563,
328
+ "EPSG_code": "",
329
+ "value": "value is equal to EPSG code",
330
+ "proj_string": "+proj=longlat +ellps=WGS84 +datum=WGS84 +no_defs",
331
+ },
332
+ ).assign_attrs(_FillValue=_WGS84_FILL),
333
+ "mesh2d": xr.DataArray(
334
+ _WGS84_FILL,
335
+ dims=(),
336
+ attrs={
337
+ "cf_role": "mesh_topology",
338
+ "long_name": "Topology data of 2D mesh",
339
+ "topology_dimension": 2,
340
+ "node_coordinates": "mesh2d_node_x mesh2d_node_y",
341
+ "node_dimension": "mesh2d_nNodes",
342
+ "edge_node_connectivity": "mesh2d_edge_nodes",
343
+ "edge_dimension": "mesh2d_nEdges",
344
+ "edge_coordinates": "mesh2d_edge_x mesh2d_edge_y",
345
+ "face_node_connectivity": "mesh2d_face_nodes",
346
+ "face_dimension": "mesh2d_nFaces",
347
+ "face_coordinates": "mesh2d_face_x mesh2d_face_y",
348
+ "max_face_nodes_dimension": "mesh2d_nMax_face_nodes",
349
+ "edge_face_connectivity": "mesh2d_edge_faces",
350
+ },
351
+ ),
352
+ }
353
+
354
+ attrs = {
355
+ "institution": "GeoOcean",
356
+ "references": "https://github.com/GeoOcean/BlueMath_tk",
357
+ "source": f"BlueMath tk {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}",
358
+ "history": "Created with OCSmesh",
359
+ "Conventions": "CF-1.8 UGRID-1.0 Deltares-0.10",
360
+ }
361
+
362
+ return xr.Dataset(data_vars=data_vars, coords=coords, attrs=attrs)
363
+
364
+
365
+ def adcirc2DFlowFM_mixed(NODE: np.ndarray, face_nodes_0b: np.ndarray) -> xr.Dataset:
366
+ """Build a UGRID dataset preserving mixed triangles and quads.
367
+
368
+ Use this after ``merge_circumcenters`` so short dual links removed by quad
369
+ merging are not reintroduced by re-splitting quads into triangles.
370
+
371
+ Parameters
372
+ ----------
373
+ NODE : ndarray of shape (n_nodes, 3)
374
+ Node coordinates ``(x, y, z)``.
375
+ face_nodes_0b : ndarray of shape (n_faces, 4)
376
+ Mixed face-node connectivity (0-based, ``-1`` padding).
377
+
378
+ Returns
379
+ -------
380
+ xarray.Dataset
381
+ Delft3D-FM UGRID mesh dataset with triangles and quads.
382
+ """
383
+ NODE = np.asarray(NODE, dtype=np.float64)
384
+ if NODE.ndim != 2 or NODE.shape[1] < 3:
385
+ raise ValueError("NODE must have shape (n_nodes, 3) with x, y, z")
386
+ faces_list = face_nodes_0b_to_faces_list(face_nodes_0b)
387
+ ugrid = build_ugrid_arrays_mixed(NODE, faces_list)
388
+ ds = _xr_dataset_from_ugrid_dict(ugrid)
389
+ # Defensive re-encoding:
390
+ # Delft3D-FM / GUI is sensitive to the raw NetCDF encoding of UGRID connectivity
391
+ # variables. In particular, `mesh2d_face_nodes` / `mesh2d_edge_faces` must be
392
+ # int32 with `_FillValue=-999`.
393
+ #
394
+ # Note: `_xr_dataset_from_ugrid_dict` already sets `_FillValue` in the variable *attrs*.
395
+ # Setting `_FillValue` again via `encoding` makes xarray error out with:
396
+ # "failed to prevent overwriting existing key _FillValue in attrs".
397
+ _UGRID_FILL = np.int32(-999)
398
+
399
+ if "mesh2d_face_nodes" in ds:
400
+ da = ds["mesh2d_face_nodes"]
401
+ vals = da.values
402
+ if np.issubdtype(vals.dtype, np.floating):
403
+ # Replace NaN padding with GUI-like -999 fill, then cast to int32.
404
+ vals = np.where(np.isnan(vals), _UGRID_FILL, vals)
405
+ ds["mesh2d_face_nodes"] = da.copy(data=np.asarray(vals, dtype=np.int32))
406
+ # Ensure dtype is int32 (but do not touch encoding _FillValue).
407
+ if ds["mesh2d_face_nodes"].dtype != np.int32:
408
+ ds["mesh2d_face_nodes"] = ds["mesh2d_face_nodes"].astype(np.int32)
409
+
410
+ if "mesh2d_edge_faces" in ds:
411
+ da = ds["mesh2d_edge_faces"]
412
+ vals = da.values
413
+ if np.issubdtype(vals.dtype, np.floating):
414
+ vals = np.where(np.isnan(vals), _UGRID_FILL, vals)
415
+ ds["mesh2d_edge_faces"] = da.copy(data=np.asarray(vals, dtype=np.int32))
416
+ if ds["mesh2d_edge_faces"].dtype != np.int32:
417
+ ds["mesh2d_edge_faces"] = ds["mesh2d_edge_faces"].astype(np.int32)
418
+
419
+ # Match GUI type for the WGS84 grid mapping scalar (dtype only).
420
+ if "wgs84" in ds and ds["wgs84"].dtype != np.int32:
421
+ ds["wgs84"] = ds["wgs84"].astype(np.int32)
422
+
423
+ n_quad = sum(1 for f in faces_list if len(f) == 4)
424
+ n_tri = sum(1 for f in faces_list if len(f) == 3)
425
+ ds.attrs["bluemesh2d_export"] = "adcirc2DFlowFM_mixed"
426
+ ds.attrs["bluemesh2d_n_faces"] = str(len(faces_list))
427
+ ds.attrs["bluemesh2d_n_triangle_faces"] = str(n_tri)
428
+ ds.attrs["bluemesh2d_n_quad_faces"] = str(n_quad)
429
+ return ds
430
+
431
+
432
+ def adcirc2DFlowFM(NODE: np.ndarray, EDGE: np.ndarray) -> xr.Dataset:
433
+ """Build a Delft3D-FM UGRID mesh dataset from ADCIRC-style data.
434
+
435
+ Parameters
436
+ ----------
437
+ NODE : ndarray of shape (n_nodes, 3)
438
+ Node coordinates ``(x, y, z)``.
439
+ EDGE : ndarray of shape (n_faces, 3) or (n_faces, 4)
440
+ Triangle connectivity (0-based), or padded face-node array with ``-1``
441
+ fill for mixed tri/quad export.
442
+
443
+ Returns
444
+ -------
445
+ xarray.Dataset
446
+ UGRID mesh dataset (``mesh2d_node_x/y/z``, ``mesh2d_face_nodes``, etc.).
447
+ """
448
+ # Shape-based export selection:
449
+ # - (T,3) int : triangle connectivity => export triangle-only mesh.
450
+ # - (F,4) int with negative padding (typically -1) => face_nodes_0b => export mixed
451
+ # tri+quad mesh using adcirc2DFlowFM_mixed.
452
+ # - (F,4) int without negative padding => assume legacy "face nodes" encoding and
453
+ # triangulate them (triangle-only export) for backward compatibility.
454
+ EDGE = np.asarray(EDGE)
455
+ if EDGE.ndim == 2 and EDGE.shape[1] == 4 and np.any(EDGE < 0):
456
+ # face_nodes_0b with -1 padding => keep mixed topology.
457
+ return adcirc2DFlowFM_mixed(NODE, EDGE)
458
+
459
+ if EDGE.ndim == 2 and EDGE.shape[1] == 4:
460
+ # Legacy: triangulate face rows to triangle-only.
461
+ node_xy = np.asarray(NODE[:, :2], dtype=np.float64)
462
+ tri: list[tuple[int, int, int]] = []
463
+ for row in EDGE:
464
+ nodes = row[row >= 0]
465
+ if nodes.size >= 3:
466
+ tri.extend(triangulate_mixed_face_row_to_tris(node_xy, nodes))
467
+ EDGE = np.asarray(tri, dtype=np.int64)
468
+
469
+ ugrid = build_ugrid_arrays(NODE, EDGE)
470
+ return _xr_dataset_from_ugrid_dict(ugrid)
471
+
472
+
473
+ def calculate_edges(Elmts: np.ndarray) -> np.ndarray:
474
+ """Extract unique edges from triangle connectivity.
475
+
476
+ Parameters
477
+ ----------
478
+ Elmts : ndarray of shape (n_elmts, 3)
479
+ Triangle element connectivity (0-based node indices).
480
+
481
+ Returns
482
+ -------
483
+ ndarray of shape (n_edges, 2)
484
+ Unique undirected edges as vertex-index pairs.
485
+ """
486
+
487
+ Links = np.zeros((len(Elmts) * 3, 2), dtype=int)
488
+ tel = 0
489
+ for elmt in Elmts:
490
+ Links[tel] = [elmt[0], elmt[1]]
491
+ tel += 1
492
+ Links[tel] = [elmt[1], elmt[2]]
493
+ tel += 1
494
+ Links[tel] = [elmt[2], elmt[0]]
495
+ tel += 1
496
+
497
+ Links_sorted = np.sort(Links, axis=1)
498
+ Links_unique = np.unique(Links_sorted, axis=0)
499
+
500
+ return Links_unique
501
+
502
+
503
+ def build_ugrid_arrays(NODE: np.ndarray, EDGE: np.ndarray) -> dict:
504
+ """Build UGRID mesh arrays from node coordinates and triangle connectivity.
505
+
506
+ Same logic as :func:`adcirc2DFlowFM` but returns arrays instead of an
507
+ ``xarray.Dataset``. Used to rebuild connectivity after edge flips.
508
+
509
+ Parameters
510
+ ----------
511
+ NODE : ndarray of shape (n_nodes, 3)
512
+ Node coordinates ``(x, y, z)``.
513
+ EDGE : ndarray of shape (n_faces, 3)
514
+ Triangle connectivity (0-based node indices).
515
+
516
+ Returns
517
+ -------
518
+ dict
519
+ UGRID arrays with keys ``node_x``, ``node_y``, ``node_z``,
520
+ ``face_nodes``, ``edge_nodes``, ``edge_faces``, ``face_x``, ``face_y``,
521
+ ``edge_x``, ``edge_y``, ``face_x_bnd``, ``face_y_bnd``, plus
522
+ ``num_nodes``, ``num_faces``, ``num_edges``. Connectivity arrays are
523
+ 1-based.
524
+ """
525
+ edges = calculate_edges(EDGE) + 1
526
+ EDGE_S = np.sort(EDGE, axis=1)
527
+ EDGE_S = EDGE_S[EDGE_S[:, 2].argsort()]
528
+ EDGE_S = EDGE_S[EDGE_S[:, 1].argsort()]
529
+ face_node = np.array(EDGE_S[EDGE_S[:, 0].argsort()], dtype=np.int32)
530
+ edge_node = np.zeros([len(edges), 2], dtype="i4")
531
+ edge_face = np.zeros([len(edges), 2], dtype=np.double)
532
+ edge_x = np.zeros(len(edges))
533
+ edge_y = np.zeros(len(edges))
534
+
535
+ face_x = (
536
+ NODE[EDGE[:, 0].astype(int), 0]
537
+ + NODE[EDGE[:, 1].astype(int), 0]
538
+ + NODE[EDGE[:, 2].astype(int), 0]
539
+ ) / 3
540
+ face_y = (
541
+ NODE[EDGE[:, 0].astype(int), 1]
542
+ + NODE[EDGE[:, 1].astype(int), 1]
543
+ + NODE[EDGE[:, 2].astype(int), 1]
544
+ ) / 3
545
+
546
+ edge_x = (NODE[edges[:, 0] - 1, 0] + NODE[edges[:, 1] - 1, 0]) / 2
547
+ edge_y = (NODE[edges[:, 0] - 1, 1] + NODE[edges[:, 1] - 1, 1]) / 2
548
+
549
+ face_node_dict = {}
550
+ for idx, face in enumerate(face_node):
551
+ for node in face:
552
+ if node not in face_node_dict:
553
+ face_node_dict[node] = []
554
+ face_node_dict[node].append(idx)
555
+
556
+ for i, edge in enumerate(edges):
557
+ node1, node2 = map(int, edge)
558
+ edge_node[i, 0] = node1
559
+ edge_node[i, 1] = node2
560
+ faces_node1 = face_node_dict.get(node1 - 1, [])
561
+ faces_node2 = face_node_dict.get(node2 - 1, [])
562
+ faces = list(set(faces_node1) & set(faces_node2))
563
+ if len(faces) < 2:
564
+ edge_face[i, 0] = faces[0] + 1 if faces else 0
565
+ edge_face[i, 1] = 0
566
+ else:
567
+ edge_face[i, 0] = faces[0] + 1
568
+ edge_face[i, 1] = faces[1] + 1
569
+
570
+ face_x = np.asarray(face_x, dtype=np.float64)
571
+ face_y = np.asarray(face_y, dtype=np.float64)
572
+ node_x = np.asarray(NODE[:, 0], dtype=np.float64)
573
+ node_y = np.asarray(NODE[:, 1], dtype=np.float64)
574
+ node_z = np.asarray(NODE[:, 2], dtype=np.float64)
575
+ face_x_bnd = np.asarray(node_x[face_node], dtype=np.float64)
576
+ face_y_bnd = np.asarray(node_y[face_node], dtype=np.float64)
577
+
578
+ return {
579
+ "node_x": node_x,
580
+ "node_y": node_y,
581
+ "node_z": node_z,
582
+ "face_nodes": face_node + 1,
583
+ "edge_nodes": edge_node,
584
+ "edge_faces": edge_face,
585
+ "face_x": face_x,
586
+ "face_y": face_y,
587
+ "edge_x": edge_x,
588
+ "edge_y": edge_y,
589
+ "face_x_bnd": face_x_bnd,
590
+ "face_y_bnd": face_y_bnd,
591
+ "num_nodes": NODE.shape[0],
592
+ "num_faces": EDGE.shape[0],
593
+ "num_edges": edges.shape[0],
594
+ }
595
+
596
+
597
+ def build_ugrid_arrays_mixed(NODE: np.ndarray, faces_list: list) -> dict:
598
+ """Build UGRID mesh arrays from mixed triangle and quadrilateral faces.
599
+
600
+ Parameters
601
+ ----------
602
+ NODE : ndarray of shape (n_nodes, 3)
603
+ Node coordinates ``(x, y, z)``.
604
+ faces_list : list of ndarray
605
+ One array of 3 or 4 node indices (0-based) per face.
606
+
607
+ Returns
608
+ -------
609
+ dict
610
+ Same keys as :func:`build_ugrid_arrays`. ``face_nodes`` has shape
611
+ ``(n_faces, 4)`` with triangles padded using fill value ``-999``.
612
+ """
613
+ n_nodes = NODE.shape[0]
614
+ n_faces = len(faces_list)
615
+ node_x = np.asarray(NODE[:, 0], dtype=np.float64)
616
+ node_y = np.asarray(NODE[:, 1], dtype=np.float64)
617
+ node_z = np.asarray(NODE[:, 2], dtype=np.float64)
618
+ # GUI-like connectivity encoding (raw NetCDF):
619
+ # - face_nodes / edge_faces are int32 with `_FillValue=-999`.
620
+ # Xarray will typically decode -999 -> NaN depending on how the file is read.
621
+ FILL = np.int32(-999)
622
+
623
+ face_nodes = np.full((n_faces, 4), FILL, dtype=np.int32)
624
+ face_x = np.zeros(n_faces, dtype=np.float64)
625
+ face_y = np.zeros(n_faces, dtype=np.float64)
626
+ face_x_bnd = np.zeros((n_faces, 4), dtype=np.float64)
627
+ face_y_bnd = np.zeros((n_faces, 4), dtype=np.float64)
628
+
629
+ faces_norm = [np.asarray(f, dtype=np.int32).reshape(-1) for f in faces_list]
630
+ nv_arr = np.fromiter((f.size for f in faces_norm), dtype=np.int64, count=n_faces)
631
+
632
+ # Half-edges of all faces (built CCW), in face-then-edge traversal order.
633
+ he_v1_parts: list[np.ndarray] = []
634
+ he_v2_parts: list[np.ndarray] = []
635
+ he_fi_parts: list[np.ndarray] = []
636
+ he_k_parts: list[np.ndarray] = []
637
+
638
+ # Vectorized per group of equal vertex count (3 or 4).
639
+ for nv in np.unique(nv_arr):
640
+ nv = int(nv)
641
+ gidx = np.where(nv_arr == nv)[0]
642
+ fv = np.vstack([faces_norm[i] for i in gidx])
643
+ x = node_x[fv]
644
+ y = node_y[fv]
645
+ kp1 = (np.arange(nv) + 1) % nv
646
+ if nv >= 3:
647
+ # Signed area in current vertex order; build faces in CCW order to
648
+ # match UGRID expectations and Delft3D-FM polygon orientation
649
+ # conventions. For convex polygons this reliably detects CW/CCW.
650
+ d1 = np.zeros(gidx.size, dtype=np.float64)
651
+ d2 = np.zeros(gidx.size, dtype=np.float64)
652
+ for k in range(nv):
653
+ d1 += x[:, k] * y[:, kp1[k]]
654
+ d2 += y[:, k] * x[:, kp1[k]]
655
+ flip = 0.5 * (d1 - d2) < 0.0
656
+ if np.any(flip):
657
+ fv[flip] = fv[flip, ::-1]
658
+ x = node_x[fv]
659
+ y = node_y[fv]
660
+
661
+ face_nodes[gidx[:, None], np.arange(nv)] = fv + 1
662
+ # Area-weighted polygon centroid (shoelace formula), with a fallback
663
+ # to the vertex mean for near-degenerate polygons.
664
+ cross = np.empty_like(x)
665
+ for k in range(nv):
666
+ cross[:, k] = x[:, k] * y[:, kp1[k]] - x[:, kp1[k]] * y[:, k]
667
+ area2 = np.zeros(gidx.size, dtype=np.float64)
668
+ sx = np.zeros(gidx.size, dtype=np.float64)
669
+ sy = np.zeros(gidx.size, dtype=np.float64)
670
+ for k in range(nv):
671
+ area2 += cross[:, k]
672
+ sx += (x[:, k] + x[:, kp1[k]]) * cross[:, k]
673
+ sy += (y[:, k] + y[:, kp1[k]]) * cross[:, k]
674
+ degenerate = np.abs(area2) < 1e-30
675
+ den = np.where(degenerate, 1.0, 3.0 * area2)
676
+ face_x[gidx] = np.where(degenerate, np.mean(x, axis=1), sx / den)
677
+ face_y[gidx] = np.where(degenerate, np.mean(y, axis=1), sy / den)
678
+ face_x_bnd[gidx[:, None], np.arange(nv)] = x
679
+ face_y_bnd[gidx[:, None], np.arange(nv)] = y
680
+ if nv == 3:
681
+ face_x_bnd[gidx, 3] = np.nan
682
+ face_y_bnd[gidx, 3] = np.nan
683
+
684
+ he_v1_parts.append(fv.ravel())
685
+ he_v2_parts.append(fv[:, kp1].ravel())
686
+ he_fi_parts.append(np.repeat(gidx, nv))
687
+ he_k_parts.append(np.tile(np.arange(nv), gidx.size))
688
+
689
+ # Build unique edges and edge->face mapping. Edges are numbered by first
690
+ # occurrence in face-then-edge traversal order, and each edge's faces are
691
+ # kept in traversal order (same as the historical dict-based loop).
692
+ if not he_v1_parts:
693
+ he_v1_parts = [np.zeros(0, dtype=np.int64)]
694
+ he_v2_parts = [np.zeros(0, dtype=np.int64)]
695
+ he_fi_parts = [np.zeros(0, dtype=np.int64)]
696
+ he_k_parts = [np.zeros(0, dtype=np.int64)]
697
+ he_v1 = np.concatenate(he_v1_parts).astype(np.int64)
698
+ he_v2 = np.concatenate(he_v2_parts).astype(np.int64)
699
+ he_fi = np.concatenate(he_fi_parts)
700
+ he_k = np.concatenate(he_k_parts)
701
+ order = np.lexsort((he_k, he_fi))
702
+ he_v1, he_v2, he_fi = he_v1[order], he_v2[order], he_fi[order]
703
+ lo = np.minimum(he_v1, he_v2)
704
+ hi = np.maximum(he_v1, he_v2)
705
+ key = lo * np.int64(n_nodes + 1) + hi
706
+ _, first_pos, inverse = np.unique(key, return_index=True, return_inverse=True)
707
+ insertion = np.argsort(first_pos, kind="stable")
708
+ rank = np.empty(insertion.size, dtype=np.int64)
709
+ rank[insertion] = np.arange(insertion.size)
710
+ edge_id = rank[inverse]
711
+
712
+ edges = np.column_stack(
713
+ [lo[first_pos][insertion], hi[first_pos][insertion]]
714
+ ).astype(np.int32)
715
+ edge_node = edges + 1
716
+ # GUI padding for boundary edges:
717
+ # In the working Delft3D-FM GUI export, missing neighbor faces in
718
+ # `mesh2d_edge_faces(:, 1)` are encoded as 0 (NOT as `_FillValue`).
719
+ # Using 0 instead of `_FillValue` makes UGRID import validation pass.
720
+ edge_face = np.zeros((len(edges), 2), dtype=np.int32)
721
+ pos = np.argsort(edge_id, kind="stable")
722
+ sid = edge_id[pos]
723
+ is_first = np.r_[True, sid[1:] != sid[:-1]]
724
+ starts = np.flatnonzero(is_first)
725
+ edge_face[sid[starts], 0] = he_fi[pos[starts]] + 1
726
+ seconds = starts + 1
727
+ seconds = seconds[seconds < sid.size]
728
+ seconds = seconds[~is_first[seconds]]
729
+ edge_face[sid[seconds], 1] = he_fi[pos[seconds]] + 1
730
+
731
+ edge_x = (node_x[edges[:, 0]] + node_x[edges[:, 1]]) / 2
732
+ edge_y = (node_y[edges[:, 0]] + node_y[edges[:, 1]]) / 2
733
+
734
+ return {
735
+ "node_x": node_x,
736
+ "node_y": node_y,
737
+ "node_z": node_z,
738
+ "face_nodes": face_nodes,
739
+ "edge_nodes": edge_node,
740
+ "edge_faces": edge_face,
741
+ "face_x": face_x,
742
+ "face_y": face_y,
743
+ "edge_x": edge_x,
744
+ "edge_y": edge_y,
745
+ "face_x_bnd": face_x_bnd,
746
+ "face_y_bnd": face_y_bnd,
747
+ "num_nodes": n_nodes,
748
+ "num_faces": n_faces,
749
+ "num_edges": edges.shape[0],
750
+ }
751
+
752
+
753
+ def build_loops(edges):
754
+ """Assemble closed node loops from an edge list.
755
+
756
+ Parameters
757
+ ----------
758
+ edges : ndarray of shape (N, 2)
759
+ Edge list as pairs of node indices.
760
+
761
+ Returns
762
+ -------
763
+ loops : list of list of int
764
+ Closed node-index loops.
765
+ """
766
+ edges = edges.tolist()
767
+ loops = []
768
+ while edges:
769
+ start, end = edges.pop(0)
770
+ loop = [start, end]
771
+ closed = False
772
+ while not closed:
773
+ found = False
774
+ for i, (a, b) in enumerate(edges):
775
+ if a == loop[-1]:
776
+ loop.append(b)
777
+ edges.pop(i)
778
+ found = True
779
+ break
780
+ elif b == loop[-1]:
781
+ loop.append(a)
782
+ edges.pop(i)
783
+ found = True
784
+ break
785
+ if not found:
786
+ break
787
+ if loop[-1] == loop[0]:
788
+ closed = True
789
+ loops.append(loop)
790
+ return loops
791
+
792
+
793
+ def export_to_grd(
794
+ filename, vert, tria, z, crs, edge_tag, edge_open=None, edge_land=None,
795
+ open_contours=None, land_contours=None,
796
+ ):
797
+ """Export a mesh to ADCIRC ``.grd`` format with boundary contours.
798
+
799
+ Prefer ``open_contours`` / ``land_contours`` from
800
+ :func:`bluemesh2d.geomesh_util.border_util.identify_boundary` (one ordered
801
+ node-index array per contour) to preserve discontinuities between contours.
802
+
803
+ Parameters
804
+ ----------
805
+ filename : str
806
+ Output ``.grd`` file path.
807
+ vert : ndarray of shape (N, 2)
808
+ Node coordinates ``(x, y)``.
809
+ tria : ndarray of shape (M, 3)
810
+ Triangle connectivity (0-based node indices).
811
+ z : ndarray of shape (N,)
812
+ Node depth or elevation values.
813
+ crs : str
814
+ Coordinate reference system string written to the file header.
815
+ edge_tag : ndarray of shape (K, 3)
816
+ Tagged boundary edges ``(node1, node2, tag)``; tag 1 = open, 2 = land.
817
+ edge_open : ndarray of shape (L, 2), optional
818
+ Flat open-boundary edges. Used when ``open_contours`` is ``None``.
819
+ edge_land : ndarray of shape (P, 2), optional
820
+ Flat land-boundary edges. Used when ``land_contours`` is ``None``.
821
+ open_contours : list of ndarray, optional
822
+ Ordered open-boundary contours (node indices per contour).
823
+ land_contours : list of ndarray, optional
824
+ Ordered land-boundary contours (node indices per contour).
825
+ """
826
+ if open_contours is not None:
827
+ open_loops = [np.asarray(c, dtype=int).tolist() if np.ndim(c) > 0 else [int(c)] for c in open_contours]
828
+ else:
829
+ if edge_open is None:
830
+ edge_open = edge_tag[edge_tag[:, 2] == 1, :2].astype(int)
831
+ open_loops = build_loops(edge_open) if edge_open.size > 0 else []
832
+
833
+ if land_contours is not None:
834
+ land_loops = [np.asarray(c, dtype=int).tolist() if np.ndim(c) > 0 else [int(c)] for c in land_contours]
835
+ else:
836
+ if edge_land is None:
837
+ edge_land = edge_tag[edge_tag[:, 2] == 2, :2].astype(int)
838
+ land_loops = build_loops(edge_land) if edge_land.size > 0 else []
839
+
840
+ # 3. Write to file
841
+ with open(filename, "w") as f:
842
+ # Header
843
+ f.write(f"{crs}\n")
844
+ f.write(f"{tria.shape[0]} {vert.shape[0]}\n")
845
+
846
+ # Nodes
847
+ for i, (x, y, zi) in enumerate(zip(vert[:, 0], vert[:, 1], z), start=1):
848
+ if np.isnan(zi):
849
+ f.write(f"{i} {x:.15f} {y:.15f} NAN\n")
850
+ else:
851
+ f.write(f"{i} {x:.15f} {y:.15f} {zi:.15f}\n")
852
+ # Triangles
853
+ for i, tri in enumerate(tria, start=1):
854
+ f.write(f"{i} 3 {tri[0] + 1} {tri[1] + 1} {tri[2] + 1}\n")
855
+
856
+ # Open boundaries
857
+ total_open_nodes = sum(len(loop) for loop in open_loops)
858
+ f.write(f"{len(open_loops)} ! total number of open boundaries\n")
859
+ f.write(f"{total_open_nodes} ! total number of open boundary nodes\n")
860
+
861
+ for ib, loop in enumerate(open_loops):
862
+ f.write(f"{len(loop)} ! number of nodes for open_boundary_{ib}\n")
863
+ for nid in loop:
864
+ f.write(f"{nid + 1}\n")
865
+
866
+ # Land boundaries
867
+ total_land_nodes = sum(len(loop) for loop in land_loops)
868
+ f.write(f"{len(land_loops)} ! total number of land boundaries\n")
869
+ f.write(f"{total_land_nodes} ! Total number of land boundary nodes\n")
870
+
871
+ for i, loop in enumerate(land_loops):
872
+ f.write(f"{len(loop)} 1 ! boundary 1:{i}\n")
873
+ for nid in loop:
874
+ f.write(f"{nid + 1}\n")
875
+
876
+
877
+ def plot_grd(filename, ax=None, show_boundaries=True):
878
+ """Plot a preview of an ADCIRC ``.grd`` mesh file.
879
+
880
+ Parameters
881
+ ----------
882
+ filename : str
883
+ Path to the ``.grd`` file.
884
+ ax : matplotlib.axes.Axes, optional
885
+ Axes to plot on. If ``None``, a new figure and axes are created.
886
+ show_boundaries : bool, optional
887
+ Whether to draw open and land boundary polylines. Default is ``True``.
888
+ """
889
+
890
+ if ax is None:
891
+ fig, ax = plt.subplots()
892
+
893
+ with open(filename, "r") as f:
894
+ lines = f.readlines()
895
+
896
+ # search header
897
+ for i, line in enumerate(lines):
898
+ if (
899
+ len(line.split()) == 2
900
+ and line.strip().replace(".", "", 1).replace("-", "", 1).isdigit() is False
901
+ ):
902
+ try:
903
+ nelem, nnode = map(int, line.split())
904
+ header_idx = i
905
+ break
906
+ except Exception:
907
+ continue
908
+
909
+ # Reading nodes
910
+ node_lines = lines[header_idx + 1 : header_idx + 1 + nnode]
911
+ vert = np.zeros((nnode, 3))
912
+ for i, ln in enumerate(node_lines):
913
+ parts = ln.split()
914
+ vert[i, 0] = float(parts[1]) # lon
915
+ vert[i, 1] = float(parts[2]) # lat
916
+ vert[i, 2] = float(parts[3]) # z
917
+
918
+ # Reading elements
919
+ elem_lines = lines[header_idx + 1 + nnode : header_idx + 1 + nnode + nelem]
920
+ tria = np.zeros((nelem, 3), dtype=int)
921
+ for i, ln in enumerate(elem_lines):
922
+ parts = ln.split()
923
+ tria[i, :] = np.array(parts[2:5], dtype=int) - 1 # indices 0-based
924
+
925
+ # Reading boundaries
926
+ open_boundaries = []
927
+ land_boundaries = []
928
+
929
+ if show_boundaries:
930
+ idx = header_idx + 1 + nnode + nelem
931
+ for j in range(idx, len(lines)):
932
+ line = lines[j].strip()
933
+ if "! total number of open boundaries" in line:
934
+ n_open = int(line.split()[0])
935
+ j += 1
936
+ n_open_nodes = int(lines[j].split()[0])
937
+ j += 1
938
+ for _ in range(n_open):
939
+ n_nodes = int(lines[j].split()[0])
940
+ j += 1
941
+ ids = []
942
+ for _ in range(n_nodes):
943
+ ids.append(int(lines[j].strip()) - 1)
944
+ j += 1
945
+ open_boundaries.append(ids)
946
+ if "! total number of land boundaries" in line:
947
+ n_land = int(line.split()[0])
948
+ j += 1
949
+ n_land_nodes = int(lines[j].split()[0])
950
+ j += 1
951
+ for _ in range(n_land):
952
+ parts = lines[j].split()
953
+ n_nodes = int(parts[0])
954
+ j += 1
955
+ ids = []
956
+ for _ in range(n_nodes):
957
+ ids.append(int(lines[j].strip()) - 1)
958
+ j += 1
959
+ land_boundaries.append(ids)
960
+ # break outer loop
961
+ if j >= len(lines):
962
+ break
963
+
964
+ if ax is None:
965
+ fig, ax = plt.subplots(figsize=(9, 8))
966
+
967
+ facecolors = np.mean(vert[:, 2][tria], axis=1)
968
+ pm = ax.tripcolor(
969
+ vert[:, 0],
970
+ vert[:, 1],
971
+ tria,
972
+ facecolors=facecolors,
973
+ edgecolors="k",
974
+ lw=0.2,
975
+ cmap="summer",
976
+ )
977
+ plt.colorbar(pm, ax=ax, label="Depth/Elevation")
978
+
979
+
980
+ for i, b in enumerate(open_boundaries):
981
+ ax.plot(
982
+ vert[b, 0],
983
+ vert[b, 1],
984
+ "r-",
985
+ lw=1.2,
986
+ label="Open boundary" if i == 0 else None,
987
+ )
988
+ for i, b in enumerate(land_boundaries):
989
+ ax.plot(
990
+ vert[b, 0],
991
+ vert[b, 1],
992
+ "k-",
993
+ lw=1.0,
994
+ label="Land boundary" if i == 0 else None,
995
+ )
996
+ ax.set_aspect("equal")
997
+ ax.set_xlabel("Longitude [°]")
998
+ ax.set_ylabel("Latitude [°]")
999
+ ax.set_title(f"Mesh preview: {filename}")
1000
+ ax.legend(loc="best", frameon=True)