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,250 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
import pyproj
|
|
3
|
+
from shapely.ops import transform
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def get_utm_crs_from_crs(crs):
|
|
7
|
+
"""Return a UTM CRS suited to the given geographic CRS.
|
|
8
|
+
|
|
9
|
+
Parameters
|
|
10
|
+
----------
|
|
11
|
+
crs : pyproj.CRS
|
|
12
|
+
Input coordinate reference system.
|
|
13
|
+
|
|
14
|
+
Returns
|
|
15
|
+
-------
|
|
16
|
+
pyproj.CRS
|
|
17
|
+
UTM CRS for the origin of ``crs``, or ``crs`` unchanged if already projected.
|
|
18
|
+
"""
|
|
19
|
+
if crs.is_projected:
|
|
20
|
+
return crs
|
|
21
|
+
transformer = pyproj.Transformer.from_crs(crs, "EPSG:4326", always_xy=True)
|
|
22
|
+
lon0, lat0 = transformer.transform(0, 0)
|
|
23
|
+
utm_zone = int((lon0 + 180) / 6) + 1
|
|
24
|
+
epsg_code = 326 if lat0 >= 0 else 327
|
|
25
|
+
return pyproj.CRS.from_epsg(epsg_code * 100 + utm_zone)
|
|
26
|
+
|
|
27
|
+
def get_local_utm_crs(crs, x=None, y=None, bbox=None):
|
|
28
|
+
"""Return a local Transverse Mercator CRS centered on the data.
|
|
29
|
+
|
|
30
|
+
If ``crs`` is already projected, it is returned unchanged. Otherwise the
|
|
31
|
+
data center is converted to WGS84 and a Transverse Mercator projection is
|
|
32
|
+
built with that central meridian and origin latitude (units in metres).
|
|
33
|
+
|
|
34
|
+
Parameters
|
|
35
|
+
----------
|
|
36
|
+
crs : pyproj.CRS
|
|
37
|
+
CRS of the input data (geographic or projected).
|
|
38
|
+
x, y : array-like, optional
|
|
39
|
+
Point coordinates (same size). Ignored if bbox is provided.
|
|
40
|
+
bbox : tuple, optional
|
|
41
|
+
(xmin, ymin, xmax, ymax) in the input CRS.
|
|
42
|
+
Used if (x, y) are not provided.
|
|
43
|
+
|
|
44
|
+
Returns
|
|
45
|
+
-------
|
|
46
|
+
pyproj.CRS
|
|
47
|
+
Projected CRS in meters, centered on the data's area.
|
|
48
|
+
"""
|
|
49
|
+
crs = pyproj.CRS.from_user_input(crs)
|
|
50
|
+
if crs.is_projected:
|
|
51
|
+
return crs
|
|
52
|
+
|
|
53
|
+
# Compute the center in the source CRS
|
|
54
|
+
if bbox is not None:
|
|
55
|
+
xmin, ymin, xmax, ymax = bbox
|
|
56
|
+
x_center = (xmin + xmax) / 2.0
|
|
57
|
+
y_center = (ymin + ymax) / 2.0
|
|
58
|
+
elif x is not None and y is not None:
|
|
59
|
+
x_center = np.nanmean(np.asarray(x))
|
|
60
|
+
y_center = np.nanmean(np.asarray(y))
|
|
61
|
+
else:
|
|
62
|
+
raise ValueError("Provide either (x, y) or bbox.")
|
|
63
|
+
|
|
64
|
+
# Convert the center to WGS84
|
|
65
|
+
transformer = pyproj.Transformer.from_crs(crs, "EPSG:4326", always_xy=True)
|
|
66
|
+
lon_center, lat_center = transformer.transform(x_center, y_center)
|
|
67
|
+
|
|
68
|
+
# Local Transverse Mercator: central meridian = lon_center, origin latitude = lat_center
|
|
69
|
+
# k=1 at the central meridian, units in meters
|
|
70
|
+
wkt = (
|
|
71
|
+
f'PROJCS["UTM local",'
|
|
72
|
+
f'GEOGCS["WGS 84",DATUM["WGS_1984",SPHEROID["WGS 84",6378137,298.257223563]],'
|
|
73
|
+
f'PRIMEM["Greenwich",0],UNIT["degree",0.0174532925199433]],'
|
|
74
|
+
f'PROJECTION["Transverse_Mercator"],'
|
|
75
|
+
f'PARAMETER["latitude_of_origin",{lat_center}],'
|
|
76
|
+
f'PARAMETER["central_meridian",{lon_center}],'
|
|
77
|
+
f'PARAMETER["scale_factor",1],'
|
|
78
|
+
f'PARAMETER["false_easting",0],'
|
|
79
|
+
f'PARAMETER["false_northing",0],'
|
|
80
|
+
f'UNIT["metre",1]]'
|
|
81
|
+
)
|
|
82
|
+
return pyproj.CRS.from_wkt(wkt)
|
|
83
|
+
|
|
84
|
+
def get_proj_crs_from_ll(lon0, lat0):
|
|
85
|
+
"""Create a local Transverse Mercator CRS centered on given coordinates.
|
|
86
|
+
|
|
87
|
+
Designed to match Delft3D-FM cartesian distance calculations when
|
|
88
|
+
``jsferic=0``: Transverse Mercator with scale factor ``k=1.0`` at the
|
|
89
|
+
central meridian, units in metres.
|
|
90
|
+
|
|
91
|
+
Parameters
|
|
92
|
+
----------
|
|
93
|
+
lon0 : float
|
|
94
|
+
Longitude of the projection center (degrees).
|
|
95
|
+
lat0 : float
|
|
96
|
+
Latitude of the projection center (degrees).
|
|
97
|
+
|
|
98
|
+
Returns
|
|
99
|
+
-------
|
|
100
|
+
pyproj.CRS
|
|
101
|
+
Local Transverse Mercator CRS in metres, centered on ``(lon0, lat0)``.
|
|
102
|
+
|
|
103
|
+
Notes
|
|
104
|
+
-----
|
|
105
|
+
Uses ``k=1.0`` for local accuracy rather than the UTM standard ``k=0.9996``.
|
|
106
|
+
"""
|
|
107
|
+
|
|
108
|
+
# Use k=1.0 for better local accuracy (vs k=0.9996 for UTM)
|
|
109
|
+
# This matches Delft3D's local plane projection behavior
|
|
110
|
+
proj_local = pyproj.CRS.from_proj4(
|
|
111
|
+
f"+proj=tmerc +lat_0={lat0} +lon_0={lon0} +k=1.0 +x_0=0 +y_0=0 +datum=WGS84 +units=m +no_defs"
|
|
112
|
+
)
|
|
113
|
+
return proj_local
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def reproject_node(node, crs_from, crs_to):
|
|
117
|
+
"""Reproject 2D vertex coordinates from one CRS to another.
|
|
118
|
+
|
|
119
|
+
Parameters
|
|
120
|
+
----------
|
|
121
|
+
node : ndarray of shape (N, 2)
|
|
122
|
+
Array of vertex coordinates to be reprojected.
|
|
123
|
+
crs_from : pyproj.CRS
|
|
124
|
+
Source coordinate reference system.
|
|
125
|
+
crs_to : pyproj.CRS
|
|
126
|
+
Target coordinate reference system.
|
|
127
|
+
|
|
128
|
+
Returns
|
|
129
|
+
-------
|
|
130
|
+
ndarray of shape (N, 2)
|
|
131
|
+
Reprojected vertex coordinates.
|
|
132
|
+
"""
|
|
133
|
+
|
|
134
|
+
node = np.asarray(node)
|
|
135
|
+
transformer = pyproj.Transformer.from_crs(crs_from, crs_to, always_xy=True)
|
|
136
|
+
x2, y2 = transformer.transform(node[:, 0], node[:, 1])
|
|
137
|
+
return np.column_stack((x2, y2))
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def reproject_geometry(geom, crs_from, crs_to):
|
|
141
|
+
"""Reproject a Shapely geometry or coordinate array between CRSs.
|
|
142
|
+
|
|
143
|
+
Parameters
|
|
144
|
+
----------
|
|
145
|
+
geom : ndarray of shape (N, 2) or shapely geometry
|
|
146
|
+
Coordinates or geometry to reproject.
|
|
147
|
+
crs_from : pyproj.CRS or str
|
|
148
|
+
Source coordinate reference system.
|
|
149
|
+
crs_to : pyproj.CRS or str
|
|
150
|
+
Target coordinate reference system.
|
|
151
|
+
|
|
152
|
+
Returns
|
|
153
|
+
-------
|
|
154
|
+
ndarray or shapely geometry
|
|
155
|
+
Reprojected geometry or coordinate array (same type as input).
|
|
156
|
+
"""
|
|
157
|
+
|
|
158
|
+
transformer = pyproj.Transformer.from_crs(crs_from, crs_to, always_xy=True)
|
|
159
|
+
return transform(transformer.transform, geom).buffer(0)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
import contextlib as _contextlib
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
@_contextlib.contextmanager
|
|
166
|
+
def bundled_raster_data_env():
|
|
167
|
+
"""Point rasterio's bundled GDAL/PROJ at their own data files.
|
|
168
|
+
|
|
169
|
+
Hosts like QGIS export ``GDAL_DATA``/``PROJ_LIB`` for *their* libraries;
|
|
170
|
+
a pip rasterio wheel (which bundles its own GDAL and PROJ) would then
|
|
171
|
+
read foreign -- usually newer -- data files and fail with obscure errors
|
|
172
|
+
(``proj.db DATABASE.LAYOUT`` mismatches and the like). This context uses
|
|
173
|
+
rasterio's own configuration channels only (``rasterio.Env`` and its
|
|
174
|
+
private PROJ search path), so the process environment -- and therefore
|
|
175
|
+
the host's pyproj/PROJ -- is never touched. A no-op when rasterio is not
|
|
176
|
+
a wheel (Linux system packages have no bundled data dirs).
|
|
177
|
+
"""
|
|
178
|
+
import os
|
|
179
|
+
|
|
180
|
+
try:
|
|
181
|
+
import rasterio
|
|
182
|
+
base = os.path.dirname(rasterio.__file__)
|
|
183
|
+
except Exception:
|
|
184
|
+
yield
|
|
185
|
+
return
|
|
186
|
+
|
|
187
|
+
gdal_data = os.path.join(base, "gdal_data")
|
|
188
|
+
proj_data = os.path.join(base, "proj_data")
|
|
189
|
+
if os.path.isdir(proj_data):
|
|
190
|
+
try: # binds rasterio's own PROJ (only) to its own database
|
|
191
|
+
from rasterio._env import set_proj_data_search_path
|
|
192
|
+
set_proj_data_search_path(proj_data)
|
|
193
|
+
except Exception:
|
|
194
|
+
pass
|
|
195
|
+
if os.path.isdir(gdal_data):
|
|
196
|
+
with rasterio.Env(GDAL_DATA=gdal_data):
|
|
197
|
+
yield
|
|
198
|
+
return
|
|
199
|
+
yield
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def require_georeferenced(dataset, what="bathymetry raster"):
|
|
203
|
+
"""Raise a clear error when a raster has no CRS or no geotransform.
|
|
204
|
+
|
|
205
|
+
Without these the pipeline can't place the coastline / mesh in the world:
|
|
206
|
+
a missing CRS otherwise crashes deep in a PROJ call, and a missing
|
|
207
|
+
geotransform (rasterio substitutes the identity affine) would silently
|
|
208
|
+
produce geometry in pixel coordinates. Fail early with an actionable
|
|
209
|
+
message instead.
|
|
210
|
+
"""
|
|
211
|
+
if dataset.crs is None:
|
|
212
|
+
raise ValueError(
|
|
213
|
+
f"The {what} has no coordinate reference system (CRS). Assign one "
|
|
214
|
+
"(in QGIS: right-click the layer -> Properties -> Source -> Set "
|
|
215
|
+
"CRS, or `gdal_edit.py -a_srs EPSG:xxxx file.tif`) before meshing.")
|
|
216
|
+
if dataset.transform.is_identity:
|
|
217
|
+
raise ValueError(
|
|
218
|
+
f"The {what} is not georeferenced: it has no geotransform, so map "
|
|
219
|
+
"and pixel coordinates are the same. Georeference it (assign a "
|
|
220
|
+
"geotransform or a world file) before meshing.")
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _raster_crs(src):
|
|
224
|
+
"""Build a pyproj CRS from an open rasterio dataset, robustly.
|
|
225
|
+
|
|
226
|
+
Prefer the EPSG code (a single, well-trodden PROJ database lookup) over
|
|
227
|
+
parsing the full WKT: the WKT parser resolves the datum against the PROJ
|
|
228
|
+
database and has been observed to hard-crash (access violation) in some
|
|
229
|
+
QGIS/PROJ builds. Falls back to WKT only when there is no EPSG code.
|
|
230
|
+
|
|
231
|
+
Parameters
|
|
232
|
+
----------
|
|
233
|
+
src : rasterio.io.DatasetReader
|
|
234
|
+
Open rasterio dataset.
|
|
235
|
+
|
|
236
|
+
Returns
|
|
237
|
+
-------
|
|
238
|
+
crs : pyproj.CRS
|
|
239
|
+
CRS of ``src``.
|
|
240
|
+
"""
|
|
241
|
+
import pyproj
|
|
242
|
+
|
|
243
|
+
try:
|
|
244
|
+
epsg = src.crs.to_epsg()
|
|
245
|
+
except Exception:
|
|
246
|
+
epsg = None
|
|
247
|
+
if epsg:
|
|
248
|
+
return pyproj.CRS.from_epsg(epsg)
|
|
249
|
+
return pyproj.CRS.from_wkt(src.crs.to_wkt())
|
|
250
|
+
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
from shapely.geometry import LinearRing, Point, Polygon
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def read_poly_from_dat(dat_path, delimiter=None):
|
|
6
|
+
"""Read polygon contours from a ``.dat`` file into PSLG node/edge arrays.
|
|
7
|
+
|
|
8
|
+
Contours are separated by ``NaN NaN`` rows. Each contour is closed and
|
|
9
|
+
concatenated into global node and edge arrays.
|
|
10
|
+
|
|
11
|
+
Parameters
|
|
12
|
+
----------
|
|
13
|
+
dat_path : str
|
|
14
|
+
Path to the ``.dat`` file.
|
|
15
|
+
delimiter : str, optional
|
|
16
|
+
Delimiter for :func:`numpy.loadtxt`. Default is auto-detected.
|
|
17
|
+
|
|
18
|
+
Returns
|
|
19
|
+
-------
|
|
20
|
+
node : ndarray of shape (N, 2)
|
|
21
|
+
Vertex coordinates ``(x, y)``.
|
|
22
|
+
edge : ndarray of shape (M, 2), dtype int
|
|
23
|
+
Edge connectivity (0-based vertex indices).
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
# Load file
|
|
27
|
+
p0 = np.loadtxt(dat_path, delimiter=delimiter)
|
|
28
|
+
if p0.shape[1] < 2:
|
|
29
|
+
raise ValueError("The .dat file must contain at least two columns: x y")
|
|
30
|
+
|
|
31
|
+
# Find NaN separators
|
|
32
|
+
isnan = np.isnan(p0[:, 0])
|
|
33
|
+
s = np.where(isnan)[0]
|
|
34
|
+
s = np.concatenate(([0], s, [len(p0)]))
|
|
35
|
+
|
|
36
|
+
node = []
|
|
37
|
+
edge = []
|
|
38
|
+
cont = 0
|
|
39
|
+
|
|
40
|
+
# Loop over polygons
|
|
41
|
+
for i in range(len(s) - 1):
|
|
42
|
+
p = p0[s[i] : s[i + 1], :]
|
|
43
|
+
p = p[~np.isnan(p[:, 0])] # remove NaN rows
|
|
44
|
+
if len(p) == 0:
|
|
45
|
+
continue
|
|
46
|
+
|
|
47
|
+
n = len(p)
|
|
48
|
+
# Close the polygon by connecting last point to first
|
|
49
|
+
c = np.column_stack([np.arange(0, n), np.arange(1, n + 1)])
|
|
50
|
+
c[-1, 1] = 0 # last edge closes to first
|
|
51
|
+
|
|
52
|
+
# Apply offset to edge indices
|
|
53
|
+
c = c + cont
|
|
54
|
+
|
|
55
|
+
# Append
|
|
56
|
+
node.append(p)
|
|
57
|
+
edge.append(c)
|
|
58
|
+
|
|
59
|
+
cont += n # offset for next polygon
|
|
60
|
+
|
|
61
|
+
# Concatenate all nodes and edges
|
|
62
|
+
node = np.vstack(node)
|
|
63
|
+
edge = np.vstack(edge).astype(int)
|
|
64
|
+
|
|
65
|
+
return node, edge
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _split_edges_at_discontinuity(edges):
|
|
69
|
+
"""Split an edge list wherever consecutive rows share no vertex."""
|
|
70
|
+
if edges is None or edges.size == 0:
|
|
71
|
+
return []
|
|
72
|
+
edges = np.asarray(edges, dtype=int)
|
|
73
|
+
n = len(edges)
|
|
74
|
+
if n == 1:
|
|
75
|
+
return [edges]
|
|
76
|
+
|
|
77
|
+
chunks = []
|
|
78
|
+
start = 0
|
|
79
|
+
|
|
80
|
+
for i in range(1, n):
|
|
81
|
+
a, b = int(edges[i, 0]), int(edges[i, 1])
|
|
82
|
+
a_prev, b_prev = int(edges[i - 1, 0]), int(edges[i - 1, 1])
|
|
83
|
+
# Check if current edge shares a vertex with previous
|
|
84
|
+
shared = (a == a_prev or a == b_prev or b == a_prev or b == b_prev)
|
|
85
|
+
|
|
86
|
+
if not shared:
|
|
87
|
+
# Discontinuity: cut here
|
|
88
|
+
chunks.append(edges[start:i])
|
|
89
|
+
start = i
|
|
90
|
+
|
|
91
|
+
# Add the last chunk
|
|
92
|
+
chunks.append(edges[start:])
|
|
93
|
+
return chunks
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _ordered_edges_to_chains(edges):
|
|
97
|
+
"""Order edges into chains, then split at discontinuities."""
|
|
98
|
+
if edges is None or edges.size == 0:
|
|
99
|
+
return []
|
|
100
|
+
edges = np.asarray(edges, dtype=int)
|
|
101
|
+
n = len(edges)
|
|
102
|
+
|
|
103
|
+
# Build adjacency
|
|
104
|
+
adj = {}
|
|
105
|
+
for i in range(n):
|
|
106
|
+
u, v = int(edges[i, 0]), int(edges[i, 1])
|
|
107
|
+
if u not in adj:
|
|
108
|
+
adj[u] = []
|
|
109
|
+
adj[u].append((i, v))
|
|
110
|
+
if v not in adj:
|
|
111
|
+
adj[v] = []
|
|
112
|
+
adj[v].append((i, u))
|
|
113
|
+
|
|
114
|
+
used = set()
|
|
115
|
+
ordered_chains = []
|
|
116
|
+
|
|
117
|
+
def extend_forward(last_v, lst, used_set):
|
|
118
|
+
while True:
|
|
119
|
+
cands = [(ei, other) for ei, other in adj[last_v] if ei not in used_set]
|
|
120
|
+
if not cands:
|
|
121
|
+
break
|
|
122
|
+
ei, other = cands[0]
|
|
123
|
+
lst.append((int(edges[ei, 0]), int(edges[ei, 1])))
|
|
124
|
+
used_set.add(ei)
|
|
125
|
+
last_v = other
|
|
126
|
+
|
|
127
|
+
def extend_backward(first_v, lst, used_set):
|
|
128
|
+
while True:
|
|
129
|
+
cands = [(ei, other) for ei, other in adj[first_v] if ei not in used_set]
|
|
130
|
+
if not cands:
|
|
131
|
+
break
|
|
132
|
+
ei, other = cands[0]
|
|
133
|
+
lst.insert(0, (int(edges[ei, 0]), int(edges[ei, 1])))
|
|
134
|
+
used_set.add(ei)
|
|
135
|
+
first_v = other
|
|
136
|
+
|
|
137
|
+
# Build chains per connected component
|
|
138
|
+
for start_i in range(n):
|
|
139
|
+
if start_i in used:
|
|
140
|
+
continue
|
|
141
|
+
a0, b0 = int(edges[start_i, 0]), int(edges[start_i, 1])
|
|
142
|
+
chain = [(a0, b0)]
|
|
143
|
+
used.add(start_i)
|
|
144
|
+
extend_forward(b0, chain, used)
|
|
145
|
+
extend_backward(a0, chain, used)
|
|
146
|
+
ordered_chains.append(np.array(chain, dtype=int))
|
|
147
|
+
|
|
148
|
+
if not ordered_chains:
|
|
149
|
+
return []
|
|
150
|
+
|
|
151
|
+
# Concatenate all chains into one ordered array
|
|
152
|
+
flat = np.vstack(ordered_chains)
|
|
153
|
+
|
|
154
|
+
# Split at discontinuities (consecutive rows without shared vertex)
|
|
155
|
+
return _split_edges_at_discontinuity(flat)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _chain_edges_to_nodelist(edge_arr):
|
|
159
|
+
"""Ordered (m, 2) edges -> 1D array of node indices."""
|
|
160
|
+
if edge_arr is None or len(edge_arr) == 0:
|
|
161
|
+
return np.array([], dtype=int)
|
|
162
|
+
edge_arr = np.asarray(edge_arr, dtype=int)
|
|
163
|
+
out = [int(edge_arr[0, 0]), int(edge_arr[0, 1])]
|
|
164
|
+
for i in range(1, len(edge_arr)):
|
|
165
|
+
a, b = int(edge_arr[i, 0]), int(edge_arr[i, 1])
|
|
166
|
+
if a == out[-1]:
|
|
167
|
+
out.append(b)
|
|
168
|
+
elif b == out[-1]:
|
|
169
|
+
out.append(a)
|
|
170
|
+
elif a == out[0]:
|
|
171
|
+
out.insert(0, b)
|
|
172
|
+
elif b == out[0]:
|
|
173
|
+
out.insert(0, a)
|
|
174
|
+
else:
|
|
175
|
+
out.append(a)
|
|
176
|
+
out.append(b)
|
|
177
|
+
return np.array(out, dtype=int)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def identify_boundary(vert, tria, z, zlim=0.0, Manual_open_boundary=None):
|
|
181
|
+
"""Classify open and land boundaries from a triangulated mesh.
|
|
182
|
+
|
|
183
|
+
Boundary edges are the mesh edges adjacent to only one triangle. They are
|
|
184
|
+
tagged open when mean nodal elevation exceeds ``zlim`` or the edge midpoint
|
|
185
|
+
lies inside ``Manual_open_boundary``. Contours are ordered and split at
|
|
186
|
+
discontinuities.
|
|
187
|
+
|
|
188
|
+
Parameters
|
|
189
|
+
----------
|
|
190
|
+
vert : ndarray of shape (N, 2)
|
|
191
|
+
Node coordinates ``(x, y)``.
|
|
192
|
+
tria : ndarray of shape (M, 3)
|
|
193
|
+
Triangle connectivity (0-based node indices).
|
|
194
|
+
z : ndarray of shape (N,)
|
|
195
|
+
Nodal elevation values.
|
|
196
|
+
zlim : float, optional
|
|
197
|
+
Elevation threshold; edges with mean elevation above this are open.
|
|
198
|
+
Default is 0.0.
|
|
199
|
+
Manual_open_boundary : shapely.geometry.Polygon, optional
|
|
200
|
+
Edges whose midpoint lies inside this polygon are classified as open.
|
|
201
|
+
|
|
202
|
+
Returns
|
|
203
|
+
-------
|
|
204
|
+
dict
|
|
205
|
+
Dictionary with keys:
|
|
206
|
+
|
|
207
|
+
- ``edge_tag`` : ndarray of shape (K, 3), ``(node1, node2, tag)``
|
|
208
|
+
(tag 1 = open, 2 = land)
|
|
209
|
+
- ``edge_open`` : ndarray of shape (L, 2), flat open-boundary edges
|
|
210
|
+
- ``edge_land`` : ndarray of shape (P, 2), flat land-boundary edges
|
|
211
|
+
- ``open_contours`` : list of 1D node-index arrays (one per contour)
|
|
212
|
+
- ``land_contours`` : list of 1D node-index arrays (one per contour)
|
|
213
|
+
"""
|
|
214
|
+
edges = np.vstack([tria[:, [0, 1]], tria[:, [1, 2]], tria[:, [2, 0]]])
|
|
215
|
+
edges = np.sort(edges, axis=1)
|
|
216
|
+
edges_sorted, counts = np.unique(edges, axis=0, return_counts=True)
|
|
217
|
+
edge_free = edges_sorted[counts == 1]
|
|
218
|
+
if edge_free.size == 0:
|
|
219
|
+
return {
|
|
220
|
+
"edge_tag": np.empty((0, 3), dtype=int),
|
|
221
|
+
"edge_open": np.empty((0, 2), dtype=int),
|
|
222
|
+
"edge_land": np.empty((0, 2), dtype=int),
|
|
223
|
+
"open_contours": [],
|
|
224
|
+
"land_contours": [],
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
edge_open_list = []
|
|
228
|
+
edge_land_list = []
|
|
229
|
+
for (a, b) in edge_free:
|
|
230
|
+
zmean = 0.5 * (z[a] + z[b])
|
|
231
|
+
mid = (vert[a] + vert[b]) / 2.0
|
|
232
|
+
in_manual = (
|
|
233
|
+
Manual_open_boundary.contains(Point(mid))
|
|
234
|
+
if Manual_open_boundary is not None
|
|
235
|
+
else False
|
|
236
|
+
)
|
|
237
|
+
if zmean > zlim or in_manual:
|
|
238
|
+
edge_open_list.append([a, b])
|
|
239
|
+
else:
|
|
240
|
+
edge_land_list.append([a, b])
|
|
241
|
+
|
|
242
|
+
edge_open = (
|
|
243
|
+
np.array(edge_open_list, dtype=int)
|
|
244
|
+
if edge_open_list
|
|
245
|
+
else np.empty((0, 2), dtype=int)
|
|
246
|
+
)
|
|
247
|
+
edge_land = (
|
|
248
|
+
np.array(edge_land_list, dtype=int)
|
|
249
|
+
if edge_land_list
|
|
250
|
+
else np.empty((0, 2), dtype=int)
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
open_chains = _ordered_edges_to_chains(edge_open)
|
|
254
|
+
land_chains = _ordered_edges_to_chains(edge_land)
|
|
255
|
+
|
|
256
|
+
open_contours = [_chain_edges_to_nodelist(c) for c in open_chains]
|
|
257
|
+
land_contours = [_chain_edges_to_nodelist(c) for c in land_chains]
|
|
258
|
+
|
|
259
|
+
edge_open_flat = (
|
|
260
|
+
np.vstack(open_chains) if open_chains else np.empty((0, 2), dtype=int)
|
|
261
|
+
)
|
|
262
|
+
edge_land_flat = (
|
|
263
|
+
np.vstack(land_chains) if land_chains else np.empty((0, 2), dtype=int)
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
tag_open = np.ones((edge_open_flat.shape[0], 1), dtype=int)
|
|
267
|
+
tag_land = np.full((edge_land_flat.shape[0], 1), 2, dtype=int)
|
|
268
|
+
edge_tag_parts = []
|
|
269
|
+
if edge_open_flat.shape[0] > 0:
|
|
270
|
+
edge_tag_parts.append(np.hstack([edge_open_flat, tag_open]))
|
|
271
|
+
if edge_land_flat.shape[0] > 0:
|
|
272
|
+
edge_tag_parts.append(np.hstack([edge_land_flat, tag_land]))
|
|
273
|
+
edge_tag = (
|
|
274
|
+
np.vstack(edge_tag_parts) if edge_tag_parts else np.empty((0, 3), dtype=int)
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
return {
|
|
278
|
+
"edge_tag": edge_tag,
|
|
279
|
+
"edge_open": edge_open_flat,
|
|
280
|
+
"edge_land": edge_land_flat,
|
|
281
|
+
"open_contours": open_contours,
|
|
282
|
+
"land_contours": land_contours,
|
|
283
|
+
}
|