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.
- bluemesh2d/_version.py +24 -0
- bluemesh2d/aabb_tree/findball.py +121 -0
- bluemesh2d/aabb_tree/findtria.py +299 -0
- bluemesh2d/aabb_tree/maketree.py +147 -0
- bluemesh2d/aabb_tree/mapvert.py +72 -0
- bluemesh2d/aabb_tree/queryset.py +83 -0
- bluemesh2d/aabb_tree/scantree.py +124 -0
- bluemesh2d/dependencies.py +83 -0
- bluemesh2d/feedback.py +142 -0
- bluemesh2d/geom_util/boundary_util.py +216 -0
- bluemesh2d/geom_util/getiso.py +194 -0
- bluemesh2d/geom_util/poly_util.py +795 -0
- bluemesh2d/geom_util/proj_util.py +250 -0
- bluemesh2d/geomesh_util/border_util.py +283 -0
- bluemesh2d/geomesh_util/depth_field.py +333 -0
- bluemesh2d/geomesh_util/grd_util.py +1000 -0
- bluemesh2d/geomesh_util/interpolation_mesh.py +318 -0
- bluemesh2d/geomesh_util/merge_circumcenters.py +268 -0
- bluemesh2d/geomesh_util/water_polygon.py +406 -0
- bluemesh2d/hfun_util/build_hfun.py +589 -0
- bluemesh2d/hfun_util/hfun_dispersion.py +96 -0
- bluemesh2d/hfun_util/lfshfn.py +110 -0
- bluemesh2d/hfun_util/limhfn.py +65 -0
- bluemesh2d/hfun_util/make_constant_hfun.py +32 -0
- bluemesh2d/hfun_util/make_depth_hfun.py +51 -0
- bluemesh2d/hfun_util/smooth_and_precomput.py +188 -0
- bluemesh2d/hfun_util/trihfn.py +84 -0
- bluemesh2d/hjac_util/limgrad.py +105 -0
- bluemesh2d/mesh_ball/cdtbal1.py +38 -0
- bluemesh2d/mesh_ball/cdtbal2.py +104 -0
- bluemesh2d/mesh_ball/inv_2x2.py +61 -0
- bluemesh2d/mesh_ball/inv_3x3.py +72 -0
- bluemesh2d/mesh_ball/pwrbal2.py +134 -0
- bluemesh2d/mesh_ball/tribal2.py +27 -0
- bluemesh2d/mesh_cost/relhfn.py +62 -0
- bluemesh2d/mesh_cost/triang.py +59 -0
- bluemesh2d/mesh_cost/triarea.py +51 -0
- bluemesh2d/mesh_cost/trideg.py +44 -0
- bluemesh2d/mesh_cost/triscr.py +43 -0
- bluemesh2d/mesh_file/bnd_util.py +664 -0
- bluemesh2d/mesh_file/loadmsh.py +165 -0
- bluemesh2d/mesh_file/ugrid.py +308 -0
- bluemesh2d/mesh_util/cfmtri.py +118 -0
- bluemesh2d/mesh_util/deltri.py +131 -0
- bluemesh2d/mesh_util/idxtri.py +53 -0
- bluemesh2d/mesh_util/isfeat.py +90 -0
- bluemesh2d/mesh_util/minlen.py +46 -0
- bluemesh2d/mesh_util/setset.py +49 -0
- bluemesh2d/mesh_util/tricon.py +90 -0
- bluemesh2d/mesh_util/tridiv.py +187 -0
- bluemesh2d/meshgen.py +202 -0
- bluemesh2d/ortho_merge/constants.py +27 -0
- bluemesh2d/ortho_merge/geometry.py +64 -0
- bluemesh2d/ortho_merge/ortho_merge_iter.py +812 -0
- bluemesh2d/ortho_merge/orthogonalize.py +2989 -0
- bluemesh2d/pipeline.py +277 -0
- bluemesh2d/poly_data/airfoil.msh +958 -0
- bluemesh2d/poly_data/channel.msh +212 -0
- bluemesh2d/poly_data/islands.msh +13819 -0
- bluemesh2d/poly_data/lake.msh +612 -0
- bluemesh2d/poly_data/river.msh +690 -0
- bluemesh2d/poly_test/inpoly.py +105 -0
- bluemesh2d/poly_test/inpoly_mat.py +104 -0
- bluemesh2d/refine.py +972 -0
- bluemesh2d/smood.py +623 -0
- bluemesh2d/smooth.py +522 -0
- bluemesh2d/tricost.py +426 -0
- bluemesh2d/tridemo.py +723 -0
- bluemesh2d/triread.py +53 -0
- bluemesh2d-0.1.1.dev0.dist-info/METADATA +139 -0
- bluemesh2d-0.1.1.dev0.dist-info/RECORD +74 -0
- bluemesh2d-0.1.1.dev0.dist-info/WHEEL +5 -0
- bluemesh2d-0.1.1.dev0.dist-info/licenses/LICENSE +674 -0
- 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
|
+
|