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,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