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,83 @@
1
+ import numpy as np
2
+
3
+
4
+ def queryset(tr, tm, fn, *args):
5
+ """Perform spatial queries on AABB-indexed collections.
6
+
7
+ Parameters
8
+ ----------
9
+ tr : dict
10
+ AABB tree from :func:`maketree`, with keys ``"xx"``, ``"ii"``, and
11
+ ``"ll"``.
12
+ tm : dict
13
+ Query-to-tree mapping (typically from :func:`mapvert`), with keys
14
+ ``"ii"`` (tree node indices) and ``"ll"`` (query indices per node).
15
+ fn : callable
16
+ Intersection kernel called as ``pk, ck = fn(pj, cj, *args)``, where
17
+ ``pj`` and ``cj`` are query and object indices within a node and
18
+ ``pk``/``ck`` are matching pairs.
19
+ *args
20
+ Extra arguments forwarded to ``fn``.
21
+
22
+ Returns
23
+ -------
24
+ qi : ndarray
25
+ Indices of query items with at least one intersection.
26
+ qp : ndarray of shape (N, 2)
27
+ Pointer ranges into ``qj`` for each entry in ``qi``; intersections
28
+ for ``qi[i]`` lie in ``qj[qp[i, 0]:qp[i, 1]]``.
29
+ qj : ndarray
30
+ Flattened list of intersecting object indices.
31
+
32
+ References
33
+ ----------
34
+ Translation of the MESH2D function ``QUERYSET``.
35
+ Original MATLAB source: https://github.com/dengwirda/mesh2d
36
+ """
37
+
38
+ if tr is None or len(tr) == 0:
39
+ return np.array([]), np.array([]), np.array([])
40
+ if not isinstance(tr, dict) or not isinstance(tm, dict):
41
+ raise TypeError("queryset: incorrect input class.")
42
+ if not all(k in tm for k in ("ii", "ll")):
43
+ raise ValueError("queryset: invalid aabb-maps obj.")
44
+ if not all(k in tr for k in ("xx", "ii", "ll")):
45
+ raise ValueError("queryset: invalid aabb-tree obj.")
46
+ ic = []
47
+ jc = []
48
+
49
+ for ip in range(len(tm["ii"])):
50
+ ni = tm["ii"][ip] # node (in tree)
51
+
52
+ qi, qj = fn(
53
+ tm["ll"][ip], # query in tile
54
+ tr["ll"][ni], # items in tile
55
+ *args,
56
+ )
57
+ ic.append(np.atleast_1d(qi))
58
+ jc.append(np.atleast_1d(qj))
59
+
60
+ if len(ic) == 0 or len(jc) == 0:
61
+ return np.array([]), np.array([]), np.array([])
62
+ qi = np.concatenate(ic)
63
+ qj = np.concatenate(jc)
64
+
65
+ if qj.size == 0:
66
+ return np.array([]), np.array([]), np.array([])
67
+
68
+ sort_idx = np.argsort(qi)
69
+ qi = qi[sort_idx]
70
+ qj = qj[sort_idx]
71
+
72
+ diff_idx = np.nonzero(np.diff(qi) != 0)[0]
73
+ ni = len(qi)
74
+
75
+ qi_unique = np.concatenate((qi[diff_idx], [qi[-1]]))
76
+ nj = len(qj)
77
+ ni = len(qi_unique)
78
+
79
+ qp = np.zeros((ni, 2), dtype=int)
80
+ qp[:, 0] = np.concatenate(([0], diff_idx + 1))
81
+ qp[:, 1] = np.concatenate((diff_idx, [nj - 1]))
82
+
83
+ return qi_unique, qp, qj
@@ -0,0 +1,124 @@
1
+ import numpy as np
2
+
3
+
4
+ def scantree(tr, pi, fn):
5
+ """Compute tree-to-item and item-to-tree mappings for an AABB tree.
6
+
7
+ Parameters
8
+ ----------
9
+ tr : dict
10
+ AABB tree from :func:`maketree`, with keys ``"xx"``, ``"ii"``, and
11
+ ``"ll"``. ``tr["xx"]`` holds node bounding boxes as ``[pmin, pmax]``;
12
+ ``tr["ii"]`` holds parent–child indices; ``tr["ll"]`` lists item
13
+ indices per node.
14
+ pi : ndarray
15
+ Query items (e.g. vertices or bounding boxes) to map against the tree.
16
+ fn : callable
17
+ Partition function called as ``ki, kj = fn(pj, ni, nj)``, where ``pj``
18
+ is a subset of items in the current node, ``ni`` and ``nj`` are child
19
+ node bounding boxes, and ``ki``/``kj`` are boolean masks of items
20
+ intersecting each child.
21
+
22
+ Returns
23
+ -------
24
+ tm : dict
25
+ Tree-to-item mapping with keys ``"ii"`` (node indices) and ``"ll"``
26
+ (lists of item indices per node).
27
+ im : dict
28
+ Item-to-tree mapping with keys ``"ii"`` (item indices) and ``"ll"``
29
+ (lists of tree node indices per item).
30
+
31
+ References
32
+ ----------
33
+ Translation of the MESH2D function ``SCANTREE``.
34
+ Original MATLAB source: https://github.com/dengwirda/mesh2d
35
+ """
36
+
37
+ tm = {"ii": [], "ll": []}
38
+ im = {"ii": [], "ll": []}
39
+
40
+ if pi is None or len(pi) == 0:
41
+ return tm, im
42
+ if tr is None or len(tr) == 0:
43
+ return tm, im
44
+ if not isinstance(tr, dict) or not isinstance(pi, np.ndarray) or not callable(fn):
45
+ raise TypeError("scantree: incorrect input class.")
46
+
47
+ if not all(k in tr for k in ("xx", "ii", "ll")):
48
+ raise ValueError("scantree: incorrect AABB struct.")
49
+
50
+ n_nodes = tr["ii"].shape[0]
51
+ tm["ii"] = np.zeros(n_nodes, dtype=int)
52
+ tm["ll"] = [None] * n_nodes
53
+
54
+ ss = np.zeros(n_nodes, dtype=int)
55
+ sl = [None] * n_nodes
56
+ sl[0] = np.arange(pi.shape[0])
57
+
58
+ tf = np.array([len(x) > 0 for x in tr["ll"]])
59
+ # Descend tree from root, push items amongst nodes
60
+ ss[0] = 0
61
+ ns = 1
62
+ no = 0
63
+
64
+ while ns > 0:
65
+ ns -= 1
66
+ ni = ss[ns] # pop
67
+
68
+ if tf[ni]:
69
+ # push onto tree-item mapping -- non-empty node NI contains items LL
70
+ tm["ii"][no] = ni
71
+ tm["ll"][no] = sl[ns]
72
+ no += 1
73
+
74
+ if tr["ii"][ni, 1] != 0:
75
+ c1 = tr["ii"][ni, 1]
76
+ c2 = tr["ii"][ni, 1] + 1
77
+ j1, j2 = fn(pi[sl[ns], :], tr["xx"][c1, :], tr["xx"][c2, :])
78
+ l1 = sl[ns][j1]
79
+ l2 = sl[ns][j2]
80
+
81
+ if l1.size > 0:
82
+ ss[ns] = c1
83
+ sl[ns] = l1
84
+ ns += 1
85
+ if l2.size > 0:
86
+ ss[ns] = c2
87
+ sl[ns] = l2
88
+ ns += 1
89
+
90
+ tm["ii"] = tm["ii"][:no]
91
+ tm["ll"] = tm["ll"][:no]
92
+
93
+ # Compute inverse map only if desired
94
+ if tm and im is None:
95
+ return tm
96
+
97
+ # Accumulate pair'd tree-item matches
98
+ ic = []
99
+ jc = tm["ll"]
100
+
101
+ for ip in range(no):
102
+ ni = tm["ii"][ip]
103
+ ic.append(np.full(len(jc[ip]), ni, dtype=int))
104
+
105
+ if len(ic) == 0:
106
+ return tm, im
107
+
108
+ ii = np.concatenate(ic)
109
+ jj = np.concatenate(jc)
110
+
111
+ im["ll"] = [None] * pi.shape[0]
112
+
113
+ jx = np.argsort(jj)
114
+ jj = jj[jx]
115
+ ii = ii[jx]
116
+
117
+ diff_idx = np.nonzero(np.diff(jj) != 0)[0]
118
+ im["ii"] = np.concatenate((jj[diff_idx], [jj[-1]]))
119
+ bounds = np.concatenate(([0], diff_idx + 1, [len(ii)]))
120
+ # Distribute single item-tree matches
121
+ for ip in range(len(im["ii"])):
122
+ im["ll"][ip] = ii[bounds[ip] : bounds[ip + 1]]
123
+
124
+ return tm, im
@@ -0,0 +1,83 @@
1
+ """Runtime dependency checks for the staged meshing pipeline."""
2
+ from __future__ import annotations
3
+
4
+
5
+
6
+
7
+ def check_dependencies():
8
+ """Check that the required runtime dependencies are importable.
9
+
10
+ ``xarray`` is not needed (the UGRID NetCDF is written directly with
11
+ ``netCDF4``) and ``triangle`` is optional (without it, ``deltri`` falls
12
+ back to the pure-scipy conforming Delaunay) -- see
13
+ :func:`optional_dependencies`.
14
+
15
+ Returns
16
+ -------
17
+ missing : list of str
18
+ Names of required packages that failed to import. Empty if all are
19
+ present.
20
+ """
21
+ missing = []
22
+ # contourpy is matplotlib's contouring backend, used by the stage-1
23
+ # coastline extraction; some QGIS bundles (macOS vcpkg builds) ship
24
+ # matplotlib without it, so it is checked explicitly.
25
+ # NOTE: keep this list in sync with REQUIRED in the QGIS plugin's
26
+ # deps_installer.py (kept separate on purpose: the installer must load
27
+ # even when this package is broken or missing).
28
+ # pyproj deliberately BEFORE rasterio: importing a pip rasterio wheel
29
+ # first loads its own libproj and breaks pyproj afterwards.
30
+ for mod in ("numpy", "scipy", "pyproj", "shapely", "rasterio",
31
+ "matplotlib", "contourpy", "netCDF4"):
32
+ try:
33
+ __import__(mod)
34
+ except Exception:
35
+ missing.append(mod)
36
+ return missing
37
+
38
+
39
+ def optional_dependencies():
40
+ """Check for optional packages that only affect speed or quality.
41
+
42
+ Returns
43
+ -------
44
+ missing : list of str
45
+ Names of optional packages that failed to import. Empty if all are
46
+ present.
47
+ """
48
+ missing = []
49
+ try:
50
+ __import__("triangle")
51
+ except Exception:
52
+ missing.append("triangle")
53
+ return missing
54
+
55
+
56
+ def smood_dependencies():
57
+ """Check for packages required only when ``smood`` (orthogonalization)
58
+ is used.
59
+
60
+ ``bluemesh2d.smood`` always builds an in-memory ``xarray.Dataset`` (via
61
+ ``ortho_merge_iterate_tria`` / ``adcirc2DFlowFM``) regardless of the
62
+ ``merge_small_links`` option, so ``xarray`` is required to call it even
63
+ though it is not needed anywhere else in this plugin (the UGRID NetCDF
64
+ exports write directly with ``netCDF4``).
65
+
66
+ Returns
67
+ -------
68
+ missing : list of str
69
+ Names of packages required by ``smood`` that failed to import. Empty
70
+ if all are present.
71
+ """
72
+ missing = []
73
+ try:
74
+ __import__("xarray")
75
+ except Exception:
76
+ missing.append("xarray")
77
+ return missing
78
+
79
+
80
+ # ===========================================================================
81
+ # Stage 1: raster -> water-domain polygon
82
+ # ===========================================================================
83
+
bluemesh2d/feedback.py ADDED
@@ -0,0 +1,142 @@
1
+ """Feedback plumbing shared by the staged meshing pipeline (QGIS-free)."""
2
+ from __future__ import annotations
3
+
4
+ import io
5
+ import sys
6
+
7
+
8
+
9
+ class MeshCanceled(Exception):
10
+ """Exception raised when a run is cancelled.
11
+
12
+ Raised when ``feedback.isCanceled()`` becomes ``True`` mid-run.
13
+ """
14
+
15
+
16
+ class _NullFeedback:
17
+ """No-op feedback so the facade runs without QGIS.
18
+
19
+ Writes to ``sys.__stdout__`` (the interpreter's real stdout) rather than
20
+ via ``print``, so it stays safe while ``refine``/``smooth`` output is
21
+ captured by ``contextlib.redirect_stdout`` (printing to the redirected
22
+ stdout would recurse).
23
+ """
24
+
25
+ def isCanceled(self):
26
+ return False
27
+
28
+ def pushInfo(self, msg):
29
+ sys.__stdout__.write(str(msg) + "\n")
30
+
31
+ def pushWarning(self, msg):
32
+ sys.__stdout__.write("WARNING: " + str(msg) + "\n")
33
+
34
+ def setProgress(self, pct):
35
+ pass
36
+
37
+
38
+ class _LogWriter(io.TextIOBase):
39
+ """File-like object that forwards captured stdout lines to feedback.
40
+
41
+ Parameters
42
+ ----------
43
+ feedback : object
44
+ Feedback sink exposing ``pushInfo(str)``, e.g. a
45
+ ``QgsProcessingFeedback`` or :class:`_NullFeedback`.
46
+ """
47
+
48
+ def __init__(self, feedback):
49
+ self._fb = feedback
50
+ self._buf = ""
51
+
52
+ def write(self, s):
53
+ self._buf += s
54
+ while "\n" in self._buf:
55
+ line, self._buf = self._buf.split("\n", 1)
56
+ if line.strip():
57
+ self._fb.pushInfo(line)
58
+ return len(s)
59
+
60
+ def flush(self):
61
+ if self._buf.strip():
62
+ self._fb.pushInfo(self._buf)
63
+ self._buf = ""
64
+
65
+
66
+ def _check(feedback):
67
+ if feedback.isCanceled():
68
+ raise MeshCanceled()
69
+
70
+
71
+ class _SubProgress:
72
+ """Feedback proxy mapping a sub-task's 0-100 progress into [lo, hi].
73
+
74
+ Lets a stage function report absolute progress while a multi-stage
75
+ caller (``generate_mesh``) keeps a monotonic overall bar.
76
+ """
77
+
78
+ def __init__(self, feedback, lo, hi):
79
+ self._fb = feedback
80
+ self._lo, self._hi = float(lo), float(hi)
81
+
82
+ def isCanceled(self):
83
+ return self._fb.isCanceled()
84
+
85
+ def pushInfo(self, msg):
86
+ self._fb.pushInfo(msg)
87
+
88
+ def pushWarning(self, msg):
89
+ self._fb.pushWarning(msg)
90
+
91
+ def setProgress(self, pct):
92
+ self._fb.setProgress(
93
+ self._lo + (self._hi - self._lo) * float(pct) / 100.0)
94
+
95
+
96
+ def _available_ram_bytes():
97
+ """Available system RAM in bytes, or ``None`` when it cannot be found."""
98
+ try:
99
+ import psutil
100
+ return int(psutil.virtual_memory().available)
101
+ except Exception:
102
+ pass
103
+ try: # Linux fallback, no psutil needed
104
+ with open("/proc/meminfo") as f:
105
+ for line in f:
106
+ if line.startswith("MemAvailable:"):
107
+ return int(line.split()[1]) * 1024
108
+ except Exception:
109
+ pass
110
+ return None
111
+
112
+
113
+ def _warn_if_ram_risk(feedback, nbytes, what, hint=None):
114
+ """Warn when an estimated allocation may exhaust the available RAM.
115
+
116
+ Parameters
117
+ ----------
118
+ feedback : object
119
+ Feedback sink exposing ``pushWarning``.
120
+ nbytes : float
121
+ Estimated memory need (bytes).
122
+ what : str
123
+ Short description of the allocation, used in the message.
124
+ hint : str or None, optional
125
+ Extra sentence suggesting how to reduce the memory need.
126
+
127
+ Returns
128
+ -------
129
+ risky : bool
130
+ ``True`` when a warning was emitted.
131
+ """
132
+ avail = _available_ram_bytes()
133
+ if avail is None or nbytes < 0.5 * avail:
134
+ return False
135
+ msg = (f"{what} needs roughly {nbytes / 1e9:.1f} GB "
136
+ f"(~{avail / 1e9:.1f} GB of RAM available). QGIS may become "
137
+ "unresponsive or crash.")
138
+ if hint:
139
+ msg += " " + hint
140
+ feedback.pushWarning(msg)
141
+ return True
142
+
@@ -0,0 +1,216 @@
1
+ """Stage 3: water polygon + hfun -> resampled boundary and PSLG."""
2
+ from __future__ import annotations
3
+
4
+
5
+ from ..feedback import _NullFeedback, _check
6
+
7
+
8
+ def _fixed_part_from_z(poly):
9
+ """Split a Z-flagged polygon into 2D geometry + resample ``part`` lists.
10
+
11
+ Stage 1 marks vertices lying on the extent polygon boundary with Z=1
12
+ (Z=0 elsewhere; the user can toggle any vertex in the Vertex Editor).
13
+ Each arc of edges between two consecutive flagged vertices becomes one
14
+ part, so every flagged vertex -- contiguous run or isolated -- is a part
15
+ junction that ``resample_polygon_hfun`` keeps exactly. Rings holding
16
+ flagged vertices are rotated to start at one, so the part junctions
17
+ line up with the ring seam. Returns ``(poly, None)`` for 2D polygons.
18
+ """
19
+ import numpy as np
20
+ from shapely.geometry import Polygon
21
+
22
+ if not getattr(poly, "has_z", False):
23
+ return poly, None
24
+
25
+ rings = [np.asarray(poly.exterior.coords)]
26
+ rings += [np.asarray(r.coords) for r in poly.interiors]
27
+ part = []
28
+ rings2d = []
29
+ offset = 0
30
+ for r in rings:
31
+ pts = r[:-1] if len(r) > 1 and np.allclose(r[0, :2], r[-1, :2]) else r
32
+ flag = pts[:, 2] > 0.5 if pts.shape[1] > 2 else np.zeros(len(pts), bool)
33
+ n = len(pts)
34
+ fidx = np.flatnonzero(flag)
35
+ if fidx.size == 0:
36
+ rings2d.append(pts[:, :2])
37
+ offset += n
38
+ continue
39
+ # start the ring at a flagged vertex so arcs don't cross the seam
40
+ k0 = int(fidx[0])
41
+ pts = np.roll(pts, -k0, axis=0)
42
+ fidx = np.flatnonzero(np.roll(flag, -k0)) # fidx[0] == 0
43
+ bounds = list(fidx) + [n]
44
+ for a, b in zip(bounds[:-1], bounds[1:]):
45
+ part.append(np.arange(offset + a, offset + b))
46
+ rings2d.append(pts[:, :2])
47
+ offset += n
48
+ poly2d = Polygon(rings2d[0], rings2d[1:])
49
+ return poly2d, (part if part else None)
50
+
51
+
52
+ def resample_boundary(poly, hfuns, min_angle_deg=25.0, min_hole_vertices=15,
53
+ feedback=None):
54
+ """Resample the water polygon to the size function and build the PSLG.
55
+
56
+ `poly` may be a Polygon or MultiPolygon (each part is resampled
57
+ independently).
58
+
59
+ Parameters
60
+ ----------
61
+ poly : shapely.geometry.Polygon or shapely.geometry.MultiPolygon
62
+ Water domain polygon (working CRS), e.g. from
63
+ :func:`extract_water_polygon`.
64
+ hfuns : callable
65
+ Element-size function, ``hfuns(xy) -> h``.
66
+ min_angle_deg : float, optional
67
+ Minimum interior angle (deg) enforced during resampling. Default is
68
+ 25.0.
69
+ min_hole_vertices : int, optional
70
+ Minimum vertex count for a hole to be kept. Default is 15.
71
+ feedback : object or None, optional
72
+ Feedback sink, see :func:`extract_water_polygon`.
73
+
74
+ Returns
75
+ -------
76
+ poly_comput : shapely.geometry.Polygon or shapely.geometry.MultiPolygon
77
+ Resampled polygon.
78
+ node : ndarray of shape (N, 2)
79
+ PSLG node coordinates.
80
+ edge : ndarray of shape (E, 2)
81
+ PSLG edges (0-based node indices).
82
+ """
83
+ feedback = feedback or _NullFeedback()
84
+ from shapely.geometry import MultiPolygon
85
+
86
+ from bluemesh2d.geom_util.poly_util import polygon_to_node_edge, resample_polygon_hfun
87
+
88
+ if poly.geom_type == "Polygon":
89
+ parts = [poly]
90
+ else:
91
+ parts = [g for g in poly.geoms if g.geom_type == "Polygon"]
92
+ if not parts:
93
+ raise RuntimeError("No polygon parts to resample.")
94
+ feedback.pushInfo(f"Water region(s) to mesh: {len(parts)}")
95
+
96
+ resampled = []
97
+ n_fixed_total = 0
98
+ for part in parts:
99
+ part2d, fixed_part = _fixed_part_from_z(part)
100
+ if fixed_part is not None:
101
+ # each fixed edge is its own part: every flagged vertex is a
102
+ # part junction and survives the resampling exactly
103
+ n_fixed_total += len(fixed_part)
104
+ rp = resample_polygon_hfun(part2d, hfuns,
105
+ min_angle_deg=min_angle_deg,
106
+ min_hole_vertices=min_hole_vertices,
107
+ part=fixed_part)
108
+ # drop parts that degenerate (smaller than the local element size)
109
+ if rp is not None and not rp.is_empty and rp.geom_type == "Polygon" \
110
+ and len(rp.exterior.coords) >= 4:
111
+ resampled.append(rp)
112
+ _check(feedback)
113
+ if not resampled:
114
+ raise RuntimeError(
115
+ "All water polygons are smaller than the requested element size; "
116
+ "decrease the minimum element size.")
117
+ if len(resampled) < len(parts):
118
+ feedback.pushInfo(
119
+ f"Dropped {len(parts) - len(resampled)} water region(s) smaller "
120
+ "than the local element size.")
121
+
122
+ if n_fixed_total:
123
+ feedback.pushInfo(
124
+ f"Fixed vertices preserved exactly: {n_fixed_total}")
125
+
126
+ poly_comput = resampled[0] if len(resampled) == 1 else MultiPolygon(resampled)
127
+ node, edge = polygon_to_node_edge(poly_comput)
128
+ feedback.pushInfo(f"Boundary: {len(node)} nodes, {len(edge)} edges")
129
+ return poly_comput, node, edge
130
+
131
+
132
+ def pslg_from_segments(segments, tol=1e-3, close_rings=True):
133
+ """Rebuild a PSLG (node, edge) from a boundary lines layer.
134
+
135
+ Every consecutive vertex pair of each polyline becomes an edge. Vertices
136
+ closer than `tol` are snapped to a single node, so nodes moved, added or
137
+ deleted while editing in QGIS reconnect cleanly. Zero-length edges and
138
+ duplicates are dropped.
139
+
140
+ Parameters
141
+ ----------
142
+ segments : iterable of array_like of shape (N, 2)
143
+ Boundary polylines (coordinates in the working CRS).
144
+ tol : float, optional
145
+ Snapping tolerance (m) for merging close vertices into one node.
146
+ Default is 1e-3.
147
+ close_rings : bool, optional
148
+ If ``True`` (default), each polyline is treated as a closed boundary
149
+ ring: when its two endpoints are distinct nodes, a closing edge is
150
+ added. Already-closed rings (duplicate end vertex) and 2-point
151
+ segments are unaffected, so both ring-style and segment-style layers
152
+ work.
153
+
154
+ Returns
155
+ -------
156
+ node : ndarray of shape (N, 2)
157
+ PSLG node coordinates.
158
+ edge : ndarray of shape (E, 2)
159
+ PSLG edges (0-based node indices).
160
+
161
+ Raises
162
+ ------
163
+ RuntimeError
164
+ If fewer than 3 edges result, or if the boundary does not form closed
165
+ loops (a node touches a number of edges other than two).
166
+ """
167
+ import numpy as np
168
+
169
+ nodes = []
170
+ index = {}
171
+
172
+ def node_id(pt):
173
+ key = (round(pt[0] / tol), round(pt[1] / tol))
174
+ i = index.get(key)
175
+ if i is None:
176
+ i = len(nodes)
177
+ index[key] = i
178
+ nodes.append((float(pt[0]), float(pt[1])))
179
+ return i
180
+
181
+ edges = set()
182
+ for seg in segments:
183
+ seg = np.atleast_2d(np.asarray(seg, dtype=float))
184
+ first = last = None
185
+ for a, b in zip(seg[:-1], seg[1:]):
186
+ i, j = node_id(a), node_id(b)
187
+ if first is None:
188
+ first = i
189
+ last = j
190
+ if i != j:
191
+ edges.add((min(i, j), max(i, j)))
192
+ if close_rings and first is not None and last is not None and first != last:
193
+ edges.add((min(first, last), max(first, last)))
194
+
195
+ if len(edges) < 3:
196
+ raise RuntimeError("Boundary edges layer yields fewer than 3 edges.")
197
+ node = np.asarray(nodes, dtype=float)
198
+ edge = np.asarray(sorted(edges), dtype=int)
199
+
200
+ # the mesher needs closed loops: every node on exactly 2 edges
201
+ counts = np.bincount(edge.ravel(), minlength=len(node))
202
+ bad = np.flatnonzero(counts != 2)
203
+ if bad.size:
204
+ sample = ", ".join(
205
+ f"({node[b][0]:.1f}, {node[b][1]:.1f})" for b in bad[:5])
206
+ raise RuntimeError(
207
+ f"{bad.size} boundary node(s) are dangling ends or junctions "
208
+ f"(not closed loops), e.g. near {sample} (working CRS, m). "
209
+ "Fix the edges layer: every vertex must join exactly two segments.")
210
+ return node, edge
211
+
212
+
213
+ # ===========================================================================
214
+ # Stage 4: PSLG + hfun -> mesh -> UGRID NetCDF
215
+ # ===========================================================================
216
+