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,795 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
from shapely.geometry import Polygon, LineString
|
|
3
|
+
|
|
4
|
+
try:
|
|
5
|
+
from scipy.spatial import cKDTree
|
|
6
|
+
except ImportError:
|
|
7
|
+
cKDTree = None
|
|
8
|
+
|
|
9
|
+
def simplify_polygon_by_angle(
|
|
10
|
+
polygon: Polygon,
|
|
11
|
+
min_angle_deg: float = 3.0,
|
|
12
|
+
) -> Polygon:
|
|
13
|
+
"""Remove vertices whose interior angle is below a threshold.
|
|
14
|
+
|
|
15
|
+
Applies to the exterior ring and all interior rings (holes).
|
|
16
|
+
|
|
17
|
+
Parameters
|
|
18
|
+
----------
|
|
19
|
+
polygon : shapely.geometry.Polygon
|
|
20
|
+
Polygon to simplify.
|
|
21
|
+
min_angle_deg : float, optional
|
|
22
|
+
Minimum interior angle in degrees. Vertices below this threshold are
|
|
23
|
+
removed. Default is 3.0.
|
|
24
|
+
|
|
25
|
+
Returns
|
|
26
|
+
-------
|
|
27
|
+
shapely.geometry.Polygon
|
|
28
|
+
Simplified polygon.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
def _calculate_interior_angle(p1, p2, p3):
|
|
32
|
+
"""Interior angle at point p2 between (p1-p2) and (p2-p3), in degrees (0 to 180)."""
|
|
33
|
+
v1 = np.asarray(p1) - np.asarray(p2)
|
|
34
|
+
v2 = np.asarray(p3) - np.asarray(p2)
|
|
35
|
+
norm1 = np.linalg.norm(v1)
|
|
36
|
+
norm2 = np.linalg.norm(v2)
|
|
37
|
+
if norm1 < 1e-10 or norm2 < 1e-10:
|
|
38
|
+
return 180.0
|
|
39
|
+
v1_norm = v1 / norm1
|
|
40
|
+
v2_norm = v2 / norm2
|
|
41
|
+
cos_angle = np.clip(np.dot(v1_norm, v2_norm), -1.0, 1.0)
|
|
42
|
+
return np.degrees(np.arccos(cos_angle))
|
|
43
|
+
|
|
44
|
+
def _simplify_ring_by_angle(coords, min_angle_deg):
|
|
45
|
+
"""Simplify a ring (exterior or interior) by removing only the small angles."""
|
|
46
|
+
coords = np.asarray(coords)
|
|
47
|
+
if len(coords) < 3:
|
|
48
|
+
return coords
|
|
49
|
+
if len(coords) > 0 and np.allclose(coords[0], coords[-1]):
|
|
50
|
+
coords = coords[:-1]
|
|
51
|
+
if len(coords) < 3:
|
|
52
|
+
return coords
|
|
53
|
+
|
|
54
|
+
result = list(coords)
|
|
55
|
+
max_iterations = len(coords) * 10
|
|
56
|
+
|
|
57
|
+
for _ in range(max_iterations):
|
|
58
|
+
if len(result) < 3:
|
|
59
|
+
break
|
|
60
|
+
n_before = len(result)
|
|
61
|
+
n = len(result)
|
|
62
|
+
i = 0
|
|
63
|
+
while i < n:
|
|
64
|
+
prev_idx = (i - 1) % n
|
|
65
|
+
curr_idx = i
|
|
66
|
+
next_idx = (i + 1) % n
|
|
67
|
+
p1 = result[prev_idx]
|
|
68
|
+
p2 = result[curr_idx]
|
|
69
|
+
p3 = result[next_idx]
|
|
70
|
+
interior_angle = _calculate_interior_angle(p1, p2, p3)
|
|
71
|
+
if interior_angle < min_angle_deg:
|
|
72
|
+
result.pop(curr_idx)
|
|
73
|
+
n -= 1
|
|
74
|
+
else:
|
|
75
|
+
i += 1
|
|
76
|
+
if len(result) == n_before:
|
|
77
|
+
break
|
|
78
|
+
|
|
79
|
+
if len(result) < 3:
|
|
80
|
+
return coords[:3] if len(coords) >= 3 else coords
|
|
81
|
+
return np.array(result)
|
|
82
|
+
|
|
83
|
+
if polygon.is_empty:
|
|
84
|
+
return polygon
|
|
85
|
+
if polygon.type == "MultiPolygon":
|
|
86
|
+
polygon = max(polygon.geoms, key=lambda p: p.area)
|
|
87
|
+
|
|
88
|
+
# Exterior ring
|
|
89
|
+
exterior_coords = np.array(polygon.exterior.coords[:-1])
|
|
90
|
+
exterior_simplified = _simplify_ring_by_angle(exterior_coords, min_angle_deg)
|
|
91
|
+
|
|
92
|
+
# Holes: same treatment as the exterior ring
|
|
93
|
+
interiors_simplified = []
|
|
94
|
+
for interior in polygon.interiors:
|
|
95
|
+
interior_coords = np.array(interior.coords[:-1])
|
|
96
|
+
ring_simplified = _simplify_ring_by_angle(interior_coords, min_angle_deg)
|
|
97
|
+
if len(ring_simplified) >= 3:
|
|
98
|
+
interiors_simplified.append(ring_simplified)
|
|
99
|
+
|
|
100
|
+
return Polygon(exterior_simplified, interiors_simplified)
|
|
101
|
+
|
|
102
|
+
def _resample_ring_hfun(ring_coords, hfun, harg=()):
|
|
103
|
+
"""Resample one closed ring using a mesh-size function.
|
|
104
|
+
|
|
105
|
+
Parameters
|
|
106
|
+
----------
|
|
107
|
+
ring_coords : ndarray of shape (N, 2)
|
|
108
|
+
Coordinates of the ring vertices.
|
|
109
|
+
hfun : float or callable
|
|
110
|
+
Mesh-size function.
|
|
111
|
+
harg : tuple, optional
|
|
112
|
+
Extra arguments passed to hfun when callable.
|
|
113
|
+
|
|
114
|
+
Returns
|
|
115
|
+
-------
|
|
116
|
+
ndarray of shape (M, 2)
|
|
117
|
+
Resampled ring coordinates (closed, first point repeated at end).
|
|
118
|
+
"""
|
|
119
|
+
polygon = np.asarray(ring_coords, dtype=float)
|
|
120
|
+
n = polygon.shape[0]
|
|
121
|
+
if n < 2:
|
|
122
|
+
if n == 0:
|
|
123
|
+
# Empty ring: return empty array (will be skipped in resample_polygon_hfun)
|
|
124
|
+
return np.array([]).reshape(0, 2)
|
|
125
|
+
if n == 1:
|
|
126
|
+
# Single point: create a minimal valid ring (4 points)
|
|
127
|
+
p = polygon[0]
|
|
128
|
+
eps_ring = max(np.linalg.norm(p) * 1e-6, 1e-6)
|
|
129
|
+
return np.array([
|
|
130
|
+
p,
|
|
131
|
+
p + np.array([eps_ring, 0]),
|
|
132
|
+
p + np.array([eps_ring, eps_ring]),
|
|
133
|
+
p, # closed
|
|
134
|
+
])
|
|
135
|
+
# Two points: add a third point to form a valid ring
|
|
136
|
+
p1, p2 = polygon[0], polygon[1]
|
|
137
|
+
mid = (p1 + p2) / 2.0
|
|
138
|
+
perp = np.array([-(p2[1] - p1[1]), p2[0] - p1[0]])
|
|
139
|
+
perp_norm = np.linalg.norm(perp)
|
|
140
|
+
if perp_norm > 0:
|
|
141
|
+
perp = perp / perp_norm * np.linalg.norm(p2 - p1) * 0.1
|
|
142
|
+
else:
|
|
143
|
+
perp = np.array([1e-6, 0])
|
|
144
|
+
p3 = mid + perp
|
|
145
|
+
return np.array([p1, p2, p3, p1]) # closed
|
|
146
|
+
|
|
147
|
+
# Cumulative arc length for the closed contour
|
|
148
|
+
nxt = np.roll(polygon, -1, axis=0)
|
|
149
|
+
seg_len = np.linalg.norm(nxt - polygon, axis=1)
|
|
150
|
+
eps = np.finfo(float).eps
|
|
151
|
+
seg_len = np.maximum(seg_len, eps)
|
|
152
|
+
s_cum = np.concatenate([[0.0], np.cumsum(seg_len)])
|
|
153
|
+
s_total = s_cum[-1]
|
|
154
|
+
if s_total <= 0:
|
|
155
|
+
# Degenerate ring: ensure at least 3 distinct points
|
|
156
|
+
if n < 3:
|
|
157
|
+
# Create minimal valid ring from available points
|
|
158
|
+
if n == 1:
|
|
159
|
+
p = polygon[0]
|
|
160
|
+
eps_ring = max(np.linalg.norm(p) * 1e-6, 1e-6)
|
|
161
|
+
return np.array([
|
|
162
|
+
p,
|
|
163
|
+
p + np.array([eps_ring, 0]),
|
|
164
|
+
p + np.array([eps_ring, eps_ring]),
|
|
165
|
+
p,
|
|
166
|
+
])
|
|
167
|
+
elif n == 2:
|
|
168
|
+
p1, p2 = polygon[0], polygon[1]
|
|
169
|
+
mid = (p1 + p2) / 2.0
|
|
170
|
+
perp = np.array([-(p2[1] - p1[1]), p2[0] - p1[0]])
|
|
171
|
+
perp_norm = np.linalg.norm(perp)
|
|
172
|
+
if perp_norm > 0:
|
|
173
|
+
perp = perp / perp_norm * np.linalg.norm(p2 - p1) * 0.1
|
|
174
|
+
else:
|
|
175
|
+
perp = np.array([1e-6, 0])
|
|
176
|
+
p3 = mid + perp
|
|
177
|
+
return np.array([p1, p2, p3, p1])
|
|
178
|
+
return np.vstack([polygon[:3], polygon[0:1]]) # At least 3 points + closure
|
|
179
|
+
|
|
180
|
+
def s_to_xy(s):
|
|
181
|
+
"""Map arc length(s) in [0, s_total] to (x, y) on the contour (vectorised)."""
|
|
182
|
+
s = np.clip(np.asarray(s, dtype=float), 0.0, s_total)
|
|
183
|
+
i = np.clip(np.searchsorted(s_cum, s, side="right") - 1, 0, n - 1)
|
|
184
|
+
t = (s - s_cum[i]) / seg_len[i]
|
|
185
|
+
return (1.0 - t)[:, None] * polygon[i] + t[:, None] * nxt[i]
|
|
186
|
+
|
|
187
|
+
# hfun evaluator with NaN filled from the nearest input vertex
|
|
188
|
+
eval_h = _make_hfun_evaluator(polygon, hfun, harg)
|
|
189
|
+
|
|
190
|
+
# Build the mesh-size density integral I(s) = int_0^s ds' / h(s') on a fine
|
|
191
|
+
# arc-length grid. Node count and positions come from this, so the input
|
|
192
|
+
# vertex density does not influence the result.
|
|
193
|
+
h_min = np.nanmin(eval_h(polygon))
|
|
194
|
+
if not np.isfinite(h_min) or h_min <= 0:
|
|
195
|
+
h_min = max(s_total * 0.01, eps)
|
|
196
|
+
ds_fine = max(min(h_min / 4.0, s_total / 200.0), s_total / 20000.0)
|
|
197
|
+
n_fine = int(np.ceil(s_total / ds_fine)) + 1
|
|
198
|
+
s_fine = np.linspace(0.0, s_total, n_fine)
|
|
199
|
+
h_fine = eval_h(s_to_xy(s_fine))
|
|
200
|
+
h_fine = np.where(np.isfinite(h_fine) & (h_fine > 0), h_fine, h_min)
|
|
201
|
+
inv_h = 1.0 / h_fine
|
|
202
|
+
integral = np.concatenate(
|
|
203
|
+
[[0.0], np.cumsum(0.5 * (inv_h[1:] + inv_h[:-1]) * np.diff(s_fine))]
|
|
204
|
+
)
|
|
205
|
+
total_units = integral[-1]
|
|
206
|
+
|
|
207
|
+
# Equidistribute nodes: spacing is uniform (= 1 h-unit) in the normalised
|
|
208
|
+
# coordinate, so every edge length is ~h(p). Because that coordinate wraps
|
|
209
|
+
# exactly at total_units, the closing edge is ~h(p) as well -- no vertices
|
|
210
|
+
# end up abnormally close, including at the seam.
|
|
211
|
+
n_nodes = max(int(round(total_units)), 3)
|
|
212
|
+
t_targets = np.arange(n_nodes) * (total_units / n_nodes)
|
|
213
|
+
node = s_to_xy(np.interp(t_targets, integral, s_fine))
|
|
214
|
+
|
|
215
|
+
# Close ring: first point repeated at end
|
|
216
|
+
return np.vstack([node, node[0:1]])
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _resample_arc_hfun(arc_coords, hfun, harg=()):
|
|
220
|
+
"""Resample one open polyline (arc) using a mesh-size function.
|
|
221
|
+
|
|
222
|
+
Both endpoints are preserved exactly; interior nodes are equidistributed
|
|
223
|
+
in arc length weighted by ``1/h(p)``, like :func:`_resample_ring_hfun`.
|
|
224
|
+
|
|
225
|
+
Parameters
|
|
226
|
+
----------
|
|
227
|
+
arc_coords : ndarray of shape (N, 2)
|
|
228
|
+
Coordinates of the arc vertices (open polyline, N >= 2).
|
|
229
|
+
hfun : float or callable
|
|
230
|
+
Mesh-size function.
|
|
231
|
+
harg : tuple, optional
|
|
232
|
+
Extra arguments passed to hfun when callable.
|
|
233
|
+
|
|
234
|
+
Returns
|
|
235
|
+
-------
|
|
236
|
+
ndarray of shape (M, 2)
|
|
237
|
+
Resampled arc coordinates (M >= 2, endpoints unchanged).
|
|
238
|
+
"""
|
|
239
|
+
arc = np.asarray(arc_coords, dtype=float)
|
|
240
|
+
if arc.shape[0] < 2:
|
|
241
|
+
return arc
|
|
242
|
+
|
|
243
|
+
seg = np.diff(arc, axis=0)
|
|
244
|
+
eps = np.finfo(float).eps
|
|
245
|
+
seg_len = np.maximum(np.linalg.norm(seg, axis=1), eps)
|
|
246
|
+
s_cum = np.concatenate([[0.0], np.cumsum(seg_len)])
|
|
247
|
+
s_total = s_cum[-1]
|
|
248
|
+
if s_total <= 0:
|
|
249
|
+
return arc[[0, -1]]
|
|
250
|
+
|
|
251
|
+
def s_to_xy(s):
|
|
252
|
+
s = np.clip(np.asarray(s, dtype=float), 0.0, s_total)
|
|
253
|
+
i = np.clip(np.searchsorted(s_cum, s, side="right") - 1, 0, len(seg_len) - 1)
|
|
254
|
+
t = (s - s_cum[i]) / seg_len[i]
|
|
255
|
+
return (1.0 - t)[:, None] * arc[i] + t[:, None] * arc[i + 1]
|
|
256
|
+
|
|
257
|
+
eval_h = _make_hfun_evaluator(arc, hfun, harg)
|
|
258
|
+
|
|
259
|
+
h_min = np.nanmin(eval_h(arc))
|
|
260
|
+
if not np.isfinite(h_min) or h_min <= 0:
|
|
261
|
+
h_min = max(s_total * 0.01, eps)
|
|
262
|
+
ds_fine = max(min(h_min / 4.0, s_total / 200.0), s_total / 20000.0)
|
|
263
|
+
n_fine = int(np.ceil(s_total / ds_fine)) + 1
|
|
264
|
+
s_fine = np.linspace(0.0, s_total, n_fine)
|
|
265
|
+
h_fine = eval_h(s_to_xy(s_fine))
|
|
266
|
+
h_fine = np.where(np.isfinite(h_fine) & (h_fine > 0), h_fine, h_min)
|
|
267
|
+
inv_h = 1.0 / h_fine
|
|
268
|
+
integral = np.concatenate(
|
|
269
|
+
[[0.0], np.cumsum(0.5 * (inv_h[1:] + inv_h[:-1]) * np.diff(s_fine))]
|
|
270
|
+
)
|
|
271
|
+
total_units = integral[-1]
|
|
272
|
+
|
|
273
|
+
# Equidistribute with the endpoints pinned: n_seg edges of ~h(p) each.
|
|
274
|
+
n_seg = max(int(round(total_units)), 1)
|
|
275
|
+
t_targets = np.linspace(0.0, total_units, n_seg + 1)
|
|
276
|
+
pts = s_to_xy(np.interp(t_targets, integral, s_fine))
|
|
277
|
+
pts[0] = arc[0]
|
|
278
|
+
pts[-1] = arc[-1]
|
|
279
|
+
return pts
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _resample_ring_parts(ring_coords, labels, hfun, harg=()):
|
|
283
|
+
"""Resample a closed ring arc-by-arc between part transitions.
|
|
284
|
+
|
|
285
|
+
Vertices where the edge part label changes are treated as fixed points:
|
|
286
|
+
they are preserved exactly and each arc between two consecutive fixed
|
|
287
|
+
points is resampled independently with :func:`_resample_arc_hfun`.
|
|
288
|
+
|
|
289
|
+
Parameters
|
|
290
|
+
----------
|
|
291
|
+
ring_coords : ndarray of shape (N, 2)
|
|
292
|
+
Open ring coordinates (no duplicated closing point).
|
|
293
|
+
labels : ndarray of shape (N,), dtype int
|
|
294
|
+
Part label of edge ``i`` (joining vertex ``i`` to ``i+1 mod N``).
|
|
295
|
+
hfun : float or callable
|
|
296
|
+
Mesh-size function.
|
|
297
|
+
harg : tuple, optional
|
|
298
|
+
Extra arguments passed to hfun when callable.
|
|
299
|
+
|
|
300
|
+
Returns
|
|
301
|
+
-------
|
|
302
|
+
ring : ndarray of shape (M, 2)
|
|
303
|
+
Resampled ring (closed, first point repeated at end).
|
|
304
|
+
protect : ndarray of shape (M-1,), dtype bool
|
|
305
|
+
True at the fixed (part-junction) vertices of the open ring.
|
|
306
|
+
"""
|
|
307
|
+
ring = np.asarray(ring_coords, dtype=float)
|
|
308
|
+
labels = np.asarray(labels, dtype=int)
|
|
309
|
+
n = ring.shape[0]
|
|
310
|
+
|
|
311
|
+
# vertex i is fixed when the labels of its two incident edges differ
|
|
312
|
+
breaks = [i for i in range(n) if labels[i - 1] != labels[i]]
|
|
313
|
+
if not breaks and labels.size and labels[0] >= 0:
|
|
314
|
+
# ring uniformly covered by one explicit part: anchor it at vertex 0
|
|
315
|
+
# (a single fixed point on an otherwise free ring)
|
|
316
|
+
breaks = [0]
|
|
317
|
+
if not breaks:
|
|
318
|
+
closed = _resample_ring_hfun(ring, hfun, harg)
|
|
319
|
+
return closed, np.zeros(max(len(closed) - 1, 0), dtype=bool)
|
|
320
|
+
if len(breaks) == 1:
|
|
321
|
+
# single fixed vertex: the whole ring is one arc that starts and
|
|
322
|
+
# ends at that vertex (kept exactly), resampled as an open polyline
|
|
323
|
+
i0 = breaks[0]
|
|
324
|
+
arc = np.vstack([ring[i0:], ring[: i0 + 1]])
|
|
325
|
+
res = _resample_arc_hfun(arc, hfun, harg)
|
|
326
|
+
pts = res[:-1]
|
|
327
|
+
protect = np.concatenate([[True], np.zeros(len(pts) - 1, dtype=bool)])
|
|
328
|
+
return np.vstack([pts, pts[0:1]]), protect
|
|
329
|
+
|
|
330
|
+
pieces = []
|
|
331
|
+
protect = []
|
|
332
|
+
for k, i0 in enumerate(breaks):
|
|
333
|
+
i1 = breaks[(k + 1) % len(breaks)]
|
|
334
|
+
if i1 > i0:
|
|
335
|
+
arc = ring[i0 : i1 + 1]
|
|
336
|
+
else: # wrap around the seam
|
|
337
|
+
arc = np.vstack([ring[i0:], ring[: i1 + 1]])
|
|
338
|
+
res = _resample_arc_hfun(arc, hfun, harg)
|
|
339
|
+
# drop the shared endpoint (added by the next arc's first vertex)
|
|
340
|
+
pieces.append(res[:-1])
|
|
341
|
+
protect.append(
|
|
342
|
+
np.concatenate([[True], np.zeros(len(res) - 2, dtype=bool)])
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
pts = np.vstack(pieces)
|
|
346
|
+
protect = np.concatenate(protect)
|
|
347
|
+
return np.vstack([pts, pts[0:1]]), protect
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _make_hfun_evaluator(reference_pts, hfun, harg=()):
|
|
351
|
+
"""Build a mesh-size evaluator with NaN filled from the nearest reference vertex."""
|
|
352
|
+
|
|
353
|
+
def eval_h_raw(pts):
|
|
354
|
+
pts = np.atleast_2d(pts)
|
|
355
|
+
if np.isscalar(hfun) or isinstance(hfun, (int, float, np.number)):
|
|
356
|
+
return np.full(pts.shape[0], float(hfun))
|
|
357
|
+
return np.asarray(hfun(pts, *harg)).ravel()[: pts.shape[0]]
|
|
358
|
+
|
|
359
|
+
reference_pts = np.asarray(reference_pts, dtype=float)
|
|
360
|
+
h_verts = eval_h_raw(reference_pts).astype(float)
|
|
361
|
+
nan_mask = np.isnan(h_verts)
|
|
362
|
+
if np.any(nan_mask):
|
|
363
|
+
tree_ref = cKDTree(reference_pts)
|
|
364
|
+
ok = np.where(~nan_mask)[0]
|
|
365
|
+
if len(ok) == 0:
|
|
366
|
+
h_verts[:] = 1.0
|
|
367
|
+
else:
|
|
368
|
+
for i in np.where(nan_mask)[0]:
|
|
369
|
+
_, j = tree_ref.query(reference_pts[i], k=1)
|
|
370
|
+
j = j if np.isscalar(j) else j[0]
|
|
371
|
+
if nan_mask[j]:
|
|
372
|
+
h_verts[i] = (
|
|
373
|
+
np.nanmean(h_verts[~nan_mask])
|
|
374
|
+
if np.any(~nan_mask)
|
|
375
|
+
else 1.0
|
|
376
|
+
)
|
|
377
|
+
else:
|
|
378
|
+
h_verts[i] = h_verts[j]
|
|
379
|
+
tree_ref = cKDTree(reference_pts)
|
|
380
|
+
|
|
381
|
+
def eval_h(pts):
|
|
382
|
+
pts = np.atleast_2d(pts)
|
|
383
|
+
h = eval_h_raw(pts).astype(float)
|
|
384
|
+
nan_pts = np.isnan(h)
|
|
385
|
+
if np.any(nan_pts):
|
|
386
|
+
idx_nan = np.where(nan_pts)[0]
|
|
387
|
+
_, nearest = tree_ref.query(pts[idx_nan], k=1)
|
|
388
|
+
if np.ndim(nearest) == 0:
|
|
389
|
+
nearest = np.array([nearest])
|
|
390
|
+
h[idx_nan] = h_verts[nearest]
|
|
391
|
+
return h
|
|
392
|
+
|
|
393
|
+
return eval_h
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def _signed_area(ring):
|
|
397
|
+
"""Signed area (positive = CCW). ring: (N, 2), closed (last point = first)."""
|
|
398
|
+
r = np.asarray(ring)
|
|
399
|
+
if len(r) < 4:
|
|
400
|
+
return 0.0
|
|
401
|
+
r = r[:-1] if np.allclose(r[0], r[-1]) else r
|
|
402
|
+
x, y = r[:, 0], r[:, 1]
|
|
403
|
+
return 0.5 * (np.dot(x, np.roll(y, 1)) - np.dot(y, np.roll(x, 1)))
|
|
404
|
+
|
|
405
|
+
def _ensure_ring_orientation(ring_coords, want_ccw):
|
|
406
|
+
"""In-place: reverse ring if needed so that CCW == want_ccw."""
|
|
407
|
+
r = np.asarray(ring_coords)
|
|
408
|
+
if len(r) < 4:
|
|
409
|
+
return r
|
|
410
|
+
if np.allclose(r[0], r[-1]):
|
|
411
|
+
r = r[:-1]
|
|
412
|
+
area = _signed_area(np.vstack([r, r[0:1]]))
|
|
413
|
+
is_ccw = area > 0
|
|
414
|
+
if is_ccw != want_ccw:
|
|
415
|
+
r = r[::-1]
|
|
416
|
+
return np.vstack([r, r[0:1]])
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def _ring_interior_angles(pts):
|
|
420
|
+
"""Interior angle (degrees, 0..180) at each vertex of an open ring (N, 2)."""
|
|
421
|
+
prev = np.roll(pts, 1, axis=0)
|
|
422
|
+
nxt = np.roll(pts, -1, axis=0)
|
|
423
|
+
v1 = prev - pts
|
|
424
|
+
v2 = nxt - pts
|
|
425
|
+
n1 = np.linalg.norm(v1, axis=1)
|
|
426
|
+
n2 = np.linalg.norm(v2, axis=1)
|
|
427
|
+
denom = n1 * n2
|
|
428
|
+
with np.errstate(invalid="ignore", divide="ignore"):
|
|
429
|
+
cos = np.einsum("ij,ij->i", v1, v2) / denom
|
|
430
|
+
cos = np.clip(cos, -1.0, 1.0)
|
|
431
|
+
ang = np.degrees(np.arccos(cos))
|
|
432
|
+
ang[denom < 1e-20] = 180.0 # degenerate (repeated point) -> not sharp
|
|
433
|
+
return ang
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def _prune_ring_by_angle(ring_coords, min_angle_deg, protect=None):
|
|
437
|
+
"""Remove vertices at sharp interior angles from a closed ring.
|
|
438
|
+
|
|
439
|
+
``protect`` is an optional boolean mask over the open ring's vertices
|
|
440
|
+
(``ring_coords[:-1]``); protected vertices are never removed.
|
|
441
|
+
"""
|
|
442
|
+
if min_angle_deg <= 0:
|
|
443
|
+
return ring_coords
|
|
444
|
+
ring = np.asarray(ring_coords, dtype=float)
|
|
445
|
+
if len(ring) < 4:
|
|
446
|
+
return ring
|
|
447
|
+
pts = ring[:-1].copy() if np.allclose(ring[0], ring[-1]) else ring.copy()
|
|
448
|
+
if len(pts) < 3:
|
|
449
|
+
return ring
|
|
450
|
+
if protect is None:
|
|
451
|
+
prot = np.zeros(len(pts), dtype=bool)
|
|
452
|
+
else:
|
|
453
|
+
prot = np.asarray(protect, dtype=bool)[: len(pts)].copy()
|
|
454
|
+
|
|
455
|
+
for _ in range(len(pts) * 2):
|
|
456
|
+
if len(pts) <= 3:
|
|
457
|
+
break
|
|
458
|
+
ang = _ring_interior_angles(pts)
|
|
459
|
+
sharp = (ang < min_angle_deg) & ~prot
|
|
460
|
+
if not sharp.any():
|
|
461
|
+
break
|
|
462
|
+
keep = ~sharp
|
|
463
|
+
if keep.sum() < 3: # never drop below a valid triangle
|
|
464
|
+
keep = prot.copy()
|
|
465
|
+
order = np.argsort(ang)[::-1]
|
|
466
|
+
for j in order:
|
|
467
|
+
if keep.sum() >= 3:
|
|
468
|
+
break
|
|
469
|
+
keep[j] = True
|
|
470
|
+
pts = pts[keep]
|
|
471
|
+
prot = prot[keep]
|
|
472
|
+
|
|
473
|
+
if len(pts) < 3:
|
|
474
|
+
return ring
|
|
475
|
+
return np.vstack([pts, pts[0:1]])
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def resample_polygon_hfun(
|
|
479
|
+
polygon,
|
|
480
|
+
hfun,
|
|
481
|
+
harg=(),
|
|
482
|
+
min_angle_deg=25,
|
|
483
|
+
min_hole_vertices=15,
|
|
484
|
+
part=None,
|
|
485
|
+
):
|
|
486
|
+
"""Resample a polygon boundary at approximately ``h(p)`` spacing.
|
|
487
|
+
|
|
488
|
+
Nodes are equidistributed in arc length weighted by the mesh-size function.
|
|
489
|
+
NaN values from ``hfun`` are replaced by the value at the nearest polygon
|
|
490
|
+
vertex. Optional angle pruning and hole filtering are applied per ring.
|
|
491
|
+
|
|
492
|
+
When ``part`` is given, the boundary is resampled iteratively between
|
|
493
|
+
fixed points: every vertex where two different parts meet is preserved
|
|
494
|
+
exactly, and each arc between two consecutive fixed points is resampled
|
|
495
|
+
independently (fixed points are also protected from angle pruning).
|
|
496
|
+
|
|
497
|
+
Parameters
|
|
498
|
+
----------
|
|
499
|
+
polygon : shapely.geometry.Polygon or ndarray of shape (N, 2)
|
|
500
|
+
Polygon to resample. Either a Shapely Polygon (exterior and interiors
|
|
501
|
+
are resampled) or an array of vertices (x, y) in order along the boundary.
|
|
502
|
+
hfun : float or callable
|
|
503
|
+
Mesh-size function. If callable, must have signature hfun(pts, *harg)
|
|
504
|
+
with pts of shape (M, 2), returning mesh-size values (M,) or scalar.
|
|
505
|
+
If float, a constant spacing is used.
|
|
506
|
+
harg : tuple, optional
|
|
507
|
+
Extra arguments passed to hfun when callable.
|
|
508
|
+
min_angle_deg : float, optional
|
|
509
|
+
After resampling, remove vertices whose interior angle is below this
|
|
510
|
+
threshold (sharp spikes / needles that are poor for meshing). Applied to
|
|
511
|
+
the exterior and every hole. Default 0.0 (disabled).
|
|
512
|
+
min_hole_vertices : int, optional
|
|
513
|
+
Drop holes (interiors) that end up with fewer than this many vertices
|
|
514
|
+
after resampling and angle pruning. Default 4 (the minimum for a valid
|
|
515
|
+
ring); raise it to discard small gaps.
|
|
516
|
+
part : list of ndarray, optional
|
|
517
|
+
Boundary partition, same form as ``refine``'s ``part`` argument: each
|
|
518
|
+
entry is an array of 0-based boundary-edge indices defining one part.
|
|
519
|
+
Edges are numbered like :func:`polygon_to_node_edge` builds them:
|
|
520
|
+
edge ``i`` joins boundary vertex ``i`` to vertex ``i+1`` around each
|
|
521
|
+
ring (exterior ring first, then each hole in order). Edges listed in
|
|
522
|
+
no part form an implicit extra part. Vertices where two parts meet
|
|
523
|
+
are kept fixed by the resampling.
|
|
524
|
+
|
|
525
|
+
Returns
|
|
526
|
+
-------
|
|
527
|
+
shapely.geometry.Polygon
|
|
528
|
+
Resampled polygon with exterior and holes (interiors) preserved.
|
|
529
|
+
Invalid geometries are fixed with buffer(0).
|
|
530
|
+
|
|
531
|
+
Raises
|
|
532
|
+
------
|
|
533
|
+
ValueError
|
|
534
|
+
If polygon is not a Shapely Polygon or an (N, 2) array.
|
|
535
|
+
ImportError
|
|
536
|
+
If scipy is not available (required for nearest-neighbor NaN fill).
|
|
537
|
+
"""
|
|
538
|
+
if cKDTree is None:
|
|
539
|
+
raise ImportError("resample_polygon_hfun requires scipy (scipy.spatial.cKDTree)")
|
|
540
|
+
|
|
541
|
+
# Check if input is a Shapely Polygon with holes
|
|
542
|
+
has_interiors = False
|
|
543
|
+
interiors = []
|
|
544
|
+
if hasattr(polygon, "exterior") and hasattr(polygon.exterior, "coords"):
|
|
545
|
+
# Extract exterior
|
|
546
|
+
coords = np.array(polygon.exterior.coords)
|
|
547
|
+
if len(coords) > 1 and np.allclose(coords[0], coords[-1]):
|
|
548
|
+
coords = coords[:-1]
|
|
549
|
+
exterior_coords = np.asarray(coords, dtype=float)
|
|
550
|
+
|
|
551
|
+
# Extract interiors (holes) if present
|
|
552
|
+
if hasattr(polygon, "interiors") and len(polygon.interiors) > 0:
|
|
553
|
+
has_interiors = True
|
|
554
|
+
for interior in polygon.interiors:
|
|
555
|
+
interior_coords = np.array(interior.coords)
|
|
556
|
+
if len(interior_coords) > 1 and np.allclose(interior_coords[0], interior_coords[-1]):
|
|
557
|
+
interior_coords = interior_coords[:-1]
|
|
558
|
+
interiors.append(np.asarray(interior_coords, dtype=float))
|
|
559
|
+
|
|
560
|
+
polygon = exterior_coords
|
|
561
|
+
else:
|
|
562
|
+
polygon = np.asarray(polygon, dtype=float)
|
|
563
|
+
|
|
564
|
+
if polygon.ndim != 2 or polygon.shape[1] != 2:
|
|
565
|
+
raise ValueError("polygon must be a Shapely Polygon or an (N, 2) array")
|
|
566
|
+
|
|
567
|
+
# Per-ring edge part labels (edge i joins ring vertex i to i+1 mod N;
|
|
568
|
+
# global numbering: exterior edges first, then each hole's, in order)
|
|
569
|
+
ring_sizes = [len(polygon)] + [len(r) for r in interiors]
|
|
570
|
+
nedge_total = sum(ring_sizes)
|
|
571
|
+
ring_labels = [None] * len(ring_sizes)
|
|
572
|
+
if part is not None:
|
|
573
|
+
labels_global = np.full(nedge_total, -1, dtype=int)
|
|
574
|
+
for k, p in enumerate(part):
|
|
575
|
+
p = np.asarray(p, dtype=int).ravel()
|
|
576
|
+
if p.size and (p.min() < 0 or p.max() >= nedge_total):
|
|
577
|
+
raise ValueError(
|
|
578
|
+
"resample_polygon_hfun: invalid PART edge indices")
|
|
579
|
+
labels_global[p] = k
|
|
580
|
+
offset = 0
|
|
581
|
+
for r, sz in enumerate(ring_sizes):
|
|
582
|
+
lab = labels_global[offset : offset + sz]
|
|
583
|
+
# any explicitly-listed edge: the ring either crosses part
|
|
584
|
+
# boundaries or is one whole part (anchored at its start vertex)
|
|
585
|
+
if np.any(lab >= 0):
|
|
586
|
+
ring_labels[r] = lab
|
|
587
|
+
offset += sz
|
|
588
|
+
|
|
589
|
+
# Resample exterior, then remove sharp "closed angle" spikes
|
|
590
|
+
if ring_labels[0] is not None:
|
|
591
|
+
exterior_ring, prot = _resample_ring_parts(
|
|
592
|
+
polygon, ring_labels[0], hfun, harg)
|
|
593
|
+
exterior_ring = _prune_ring_by_angle(exterior_ring, min_angle_deg, prot)
|
|
594
|
+
else:
|
|
595
|
+
exterior_ring = _resample_ring_hfun(polygon, hfun, harg)
|
|
596
|
+
exterior_ring = _prune_ring_by_angle(exterior_ring, min_angle_deg)
|
|
597
|
+
exterior_ring = _ensure_ring_orientation(exterior_ring, want_ccw=True)
|
|
598
|
+
|
|
599
|
+
# Ensure exterior has at least 4 points (required for LinearRing)
|
|
600
|
+
if len(exterior_ring) < 4:
|
|
601
|
+
# If exterior is invalid, return empty polygon
|
|
602
|
+
return Polygon()
|
|
603
|
+
|
|
604
|
+
# Resample interiors (holes) if present
|
|
605
|
+
min_hole_vertices = max(int(min_hole_vertices), 4)
|
|
606
|
+
resampled_interiors = []
|
|
607
|
+
if has_interiors:
|
|
608
|
+
for hidx, interior_coords in enumerate(interiors):
|
|
609
|
+
lab = ring_labels[1 + hidx]
|
|
610
|
+
if lab is not None:
|
|
611
|
+
resampled_interior, prot = _resample_ring_parts(
|
|
612
|
+
interior_coords, lab, hfun, harg)
|
|
613
|
+
resampled_interior = _prune_ring_by_angle(
|
|
614
|
+
resampled_interior, min_angle_deg, prot)
|
|
615
|
+
else:
|
|
616
|
+
resampled_interior = _resample_ring_hfun(interior_coords, hfun, harg)
|
|
617
|
+
resampled_interior = _prune_ring_by_angle(resampled_interior, min_angle_deg)
|
|
618
|
+
# Drop holes ("gaps") with too few vertices (need >= 4 for a ring)
|
|
619
|
+
n_distinct = len(resampled_interior)
|
|
620
|
+
if n_distinct > 1 and np.allclose(resampled_interior[0], resampled_interior[-1]):
|
|
621
|
+
n_distinct -= 1
|
|
622
|
+
if n_distinct >= min_hole_vertices:
|
|
623
|
+
resampled_interiors.append(resampled_interior)
|
|
624
|
+
|
|
625
|
+
# Create Polygon with exterior and holes
|
|
626
|
+
if len(resampled_interiors) > 0:
|
|
627
|
+
poly_resampled = Polygon(exterior_ring, resampled_interiors)
|
|
628
|
+
else:
|
|
629
|
+
poly_resampled = Polygon(exterior_ring)
|
|
630
|
+
|
|
631
|
+
if not poly_resampled.is_valid:
|
|
632
|
+
poly_resampled = poly_resampled.buffer(0)
|
|
633
|
+
return poly_resampled
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
def _resample_ring_by_spacing(xy: np.ndarray, spacing: float) -> np.ndarray:
|
|
637
|
+
"""Resample a closed ring at uniform arc-length spacing."""
|
|
638
|
+
xy = np.asarray(xy, dtype=float)
|
|
639
|
+
if len(xy) < 2:
|
|
640
|
+
return xy
|
|
641
|
+
|
|
642
|
+
# Close the ring for length/interpolation purposes
|
|
643
|
+
if not np.allclose(xy[0], xy[-1]):
|
|
644
|
+
xy = np.vstack([xy, xy[0:1]])
|
|
645
|
+
n = len(xy)
|
|
646
|
+
|
|
647
|
+
# Cumulative distance along the contour (includes the closing segment)
|
|
648
|
+
d = np.cumsum(
|
|
649
|
+
np.r_[0, np.sqrt(((np.diff(xy, axis=0)) ** 2).sum(axis=1))]
|
|
650
|
+
)
|
|
651
|
+
total_length = d[-1]
|
|
652
|
+
if total_length <= 0:
|
|
653
|
+
return xy[:1]
|
|
654
|
+
|
|
655
|
+
# Number of points to get segments >= spacing
|
|
656
|
+
n_pts = max(4, int(np.floor(total_length / spacing)))
|
|
657
|
+
n_pts = min(n_pts, max(4, n - 1))
|
|
658
|
+
|
|
659
|
+
# Regularly spaced curvilinear abscissas (without duplicating the closing point)
|
|
660
|
+
d_sampled = np.linspace(0, total_length, n_pts, endpoint=False)
|
|
661
|
+
|
|
662
|
+
# Interpolate x and y
|
|
663
|
+
x_new = np.interp(d_sampled, d, xy[:, 0])
|
|
664
|
+
y_new = np.interp(d_sampled, d, xy[:, 1])
|
|
665
|
+
xy_interp = np.column_stack([x_new, y_new])
|
|
666
|
+
|
|
667
|
+
# Closed ring for Shapely (first point repeated at the end)
|
|
668
|
+
return np.vstack([xy_interp, xy_interp[0:1]])
|
|
669
|
+
|
|
670
|
+
|
|
671
|
+
def resample_polygon(
|
|
672
|
+
polygon: Polygon,
|
|
673
|
+
spacing: float,
|
|
674
|
+
) -> Polygon:
|
|
675
|
+
"""Resample polygon boundaries at uniform arc-length spacing.
|
|
676
|
+
|
|
677
|
+
Parameters
|
|
678
|
+
----------
|
|
679
|
+
polygon : shapely.geometry.Polygon
|
|
680
|
+
Polygon to resample (exterior and interior rings).
|
|
681
|
+
spacing : float
|
|
682
|
+
Target spacing between consecutive vertices along each ring.
|
|
683
|
+
|
|
684
|
+
Returns
|
|
685
|
+
-------
|
|
686
|
+
shapely.geometry.Polygon
|
|
687
|
+
Resampled polygon.
|
|
688
|
+
|
|
689
|
+
Raises
|
|
690
|
+
------
|
|
691
|
+
ValueError
|
|
692
|
+
If ``spacing`` is not positive.
|
|
693
|
+
"""
|
|
694
|
+
if spacing <= 0:
|
|
695
|
+
raise ValueError("spacing must be positive")
|
|
696
|
+
if polygon.is_empty:
|
|
697
|
+
return polygon
|
|
698
|
+
if polygon.geom_type == "MultiPolygon":
|
|
699
|
+
polygon = max(polygon.geoms, key=lambda p: p.area)
|
|
700
|
+
|
|
701
|
+
# Exterior
|
|
702
|
+
exterior_coords = np.asarray(polygon.exterior.coords)
|
|
703
|
+
exterior_ring = _resample_ring_by_spacing(exterior_coords, spacing)
|
|
704
|
+
if len(exterior_ring) < 4:
|
|
705
|
+
return polygon
|
|
706
|
+
|
|
707
|
+
# Interiors
|
|
708
|
+
interiors_rings = []
|
|
709
|
+
for interior in polygon.interiors:
|
|
710
|
+
ring = _resample_ring_by_spacing(np.asarray(interior.coords), spacing)
|
|
711
|
+
if len(ring) >= 4:
|
|
712
|
+
interiors_rings.append(ring)
|
|
713
|
+
|
|
714
|
+
poly_new = Polygon(exterior_ring, interiors_rings)
|
|
715
|
+
if not poly_new.is_valid:
|
|
716
|
+
poly_new = poly_new.buffer(0)
|
|
717
|
+
return poly_new
|
|
718
|
+
|
|
719
|
+
|
|
720
|
+
def buffer_area(polygon: Polygon, area_factor: float) -> Polygon:
|
|
721
|
+
"""Buffer a polygon by a distance proportional to its area-to-perimeter ratio.
|
|
722
|
+
|
|
723
|
+
Parameters
|
|
724
|
+
----------
|
|
725
|
+
polygon : shapely.geometry.Polygon
|
|
726
|
+
Polygon to buffer.
|
|
727
|
+
area_factor : float
|
|
728
|
+
Multiplier applied to ``polygon.area / polygon.length``.
|
|
729
|
+
|
|
730
|
+
Returns
|
|
731
|
+
-------
|
|
732
|
+
shapely.geometry.Polygon
|
|
733
|
+
Buffered polygon.
|
|
734
|
+
"""
|
|
735
|
+
|
|
736
|
+
return polygon.buffer(area_factor * polygon.area / polygon.length)
|
|
737
|
+
|
|
738
|
+
|
|
739
|
+
def polygon_to_node_edge(poly):
|
|
740
|
+
"""Extract PSLG node and edge arrays from a Shapely polygon.
|
|
741
|
+
|
|
742
|
+
Parameters
|
|
743
|
+
----------
|
|
744
|
+
poly : shapely.geometry.Polygon or shapely.geometry.MultiPolygon
|
|
745
|
+
Input polygon geometry.
|
|
746
|
+
|
|
747
|
+
Returns
|
|
748
|
+
-------
|
|
749
|
+
node : ndarray of shape (N, 2)
|
|
750
|
+
Vertex coordinates ``(x, y)``.
|
|
751
|
+
edge : ndarray of shape (E, 2), dtype int
|
|
752
|
+
Edge connectivity (0-based vertex indices).
|
|
753
|
+
|
|
754
|
+
Raises
|
|
755
|
+
------
|
|
756
|
+
ValueError
|
|
757
|
+
If any vertex has odd connectivity (open contour).
|
|
758
|
+
"""
|
|
759
|
+
# Handle MultiPolygon recursively
|
|
760
|
+
if poly.geom_type == "MultiPolygon":
|
|
761
|
+
nodes_all, edges_all = [], []
|
|
762
|
+
offset = 0
|
|
763
|
+
for p in poly.geoms:
|
|
764
|
+
node, edge = polygon_to_node_edge(p)
|
|
765
|
+
edges_all.append(edge + offset)
|
|
766
|
+
nodes_all.append(node)
|
|
767
|
+
offset += len(node)
|
|
768
|
+
return np.vstack(nodes_all), np.vstack(edges_all)
|
|
769
|
+
|
|
770
|
+
ext = np.array(poly.exterior.coords)
|
|
771
|
+
node = [ext[:-1]] # remove duplicate closing point
|
|
772
|
+
edge = [np.column_stack([np.arange(len(ext) - 1), np.arange(1, len(ext))])]
|
|
773
|
+
edge[-1][-1, 1] = 0 # close loop explicitly
|
|
774
|
+
|
|
775
|
+
for hole in poly.interiors:
|
|
776
|
+
pts = np.array(hole.coords)
|
|
777
|
+
n0 = len(np.vstack(node))
|
|
778
|
+
node.append(pts[:-1]) # skip duplicate closure
|
|
779
|
+
e = np.column_stack(
|
|
780
|
+
[np.arange(n0, n0 + len(pts) - 1), np.arange(n0 + 1, n0 + len(pts))]
|
|
781
|
+
)
|
|
782
|
+
e[-1, 1] = n0
|
|
783
|
+
edge.append(e)
|
|
784
|
+
|
|
785
|
+
node = np.vstack(node)
|
|
786
|
+
edge = np.vstack(edge).astype(int)
|
|
787
|
+
|
|
788
|
+
nnod = node.shape[0]
|
|
789
|
+
nadj = np.bincount(edge.ravel(), minlength=nnod)
|
|
790
|
+
if np.any(nadj % 2 != 0):
|
|
791
|
+
raise ValueError(
|
|
792
|
+
"Invalid topology: some nodes are not closed (odd connectivity)."
|
|
793
|
+
)
|
|
794
|
+
|
|
795
|
+
return node, edge
|