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,2989 @@
1
+ """
2
+ Mesh orthogonalization and small-flow-link handling on in-memory arrays.
3
+
4
+ Ported from the Delft3D MeshKernel orthogonalizer, working directly on node
5
+ coordinates and triangle/mixed-face connectivity (no file I/O). Orthogonality
6
+ is measured by ``|cos(phi)|`` on flow links (edge vs. circumcenter line);
7
+ small flow links are those whose circumcenter separation is short relative to
8
+ the adjacent face sizes. Both are cleared by a zone smoother that combines
9
+ Laplacian smoothing, orthogonality-oriented node moves, quality-guarded edge
10
+ flips, and circumcenter-separation moves.
11
+
12
+ Geometry works in lon/lat degrees (``jsferic=1``, spherical formulas) or in a
13
+ projected planar CRS (``jsferic=0``). All internal indexing is 0-based, with
14
+ -1 marking an invalid entry.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from dataclasses import dataclass
20
+ from typing import Dict, Iterable, List, Optional, Set, Tuple
21
+ from collections import deque
22
+
23
+ import numpy as np
24
+
25
+ from .constants import (
26
+ EARTH_RADIUS,
27
+ DEG2RAD,
28
+ RAD2DEG,
29
+ EARTH_RADIUS_DEG2RAD,
30
+ EARTH_RADIUS_SQ,
31
+ DTOL_POLE,
32
+ DEFAULT_ORTHO_ALPHA,
33
+ )
34
+ from .geometry import build_edges_from_tria as _build_edges_from_tria
35
+
36
+
37
+ def _getdx_vec(
38
+ x1: np.ndarray, y1: np.ndarray, x2: np.ndarray, y2: np.ndarray, jsferic: int = 1
39
+ ) -> np.ndarray:
40
+ """Vectorized version of Delft's _getdx. x1, y1, x2, y2 same shape."""
41
+ if jsferic != 1:
42
+ return (x2 - x1).astype(np.float64)
43
+ pole1 = np.abs(np.abs(y1) - 90.0) <= DTOL_POLE
44
+ pole2 = np.abs(np.abs(y2) - 90.0) <= DTOL_POLE
45
+ different_poles = pole1 != pole2
46
+ xx1 = x1.astype(np.float64)
47
+ mask_hi = (xx1 - x2) > 180.0
48
+ mask_lo = (xx1 - x2) < -180.0
49
+ xx1 = np.where(mask_hi, xx1 - 360.0, xx1)
50
+ xx1 = np.where(mask_lo, xx1 + 360.0, xx1)
51
+ c = np.cos(0.5 * (y1 + y2) * DEG2RAD)
52
+ out = EARTH_RADIUS_DEG2RAD * c * (x2 - xx1)
53
+ out = np.where(different_poles, 0.0, out)
54
+ return out
55
+
56
+
57
+ def _getdy_vec(
58
+ x1: np.ndarray, y1: np.ndarray, x2: np.ndarray, y2: np.ndarray, jsferic: int = 1
59
+ ) -> np.ndarray:
60
+ """Vectorized version of Delft's _getdy."""
61
+ if jsferic != 1:
62
+ return (y2 - y1).astype(np.float64)
63
+ return (EARTH_RADIUS_DEG2RAD * (y2 - y1)).astype(np.float64)
64
+
65
+
66
+ def _gather_face_node_coords(
67
+ node_x: np.ndarray,
68
+ node_y: np.ndarray,
69
+ face_nodes: np.ndarray,
70
+ faces: np.ndarray,
71
+ n: int,
72
+ ) -> Tuple[np.ndarray, np.ndarray]:
73
+ """
74
+ Coordinates of the valid (1-based, > 0) nodes of `faces`, which must all
75
+ have exactly `n` valid nodes. Returns (xv, yv), each (len(faces), n),
76
+ with the valid nodes of each row in their original order.
77
+ """
78
+ sub = face_nodes[faces, :]
79
+ order = np.argsort(sub <= 0, axis=1, kind="stable")
80
+ idx = np.take_along_axis(sub, order[:, :n], axis=1) - 1
81
+ return node_x[idx].astype(np.float64), node_y[idx].astype(np.float64)
82
+
83
+
84
+ def _face_centers(
85
+ node_x: np.ndarray,
86
+ node_y: np.ndarray,
87
+ face_nodes: np.ndarray,
88
+ face_mask: Optional[np.ndarray] = None,
89
+ jsferic: int = 1,
90
+ ) -> Tuple[np.ndarray, np.ndarray]:
91
+ """Barycentric cell centers (comp_masscenter2D; jsferic=0 for planar x/y)."""
92
+ n_faces = face_nodes.shape[0]
93
+ if face_mask is not None:
94
+ face_x = np.full(n_faces, np.nan, dtype=np.float64)
95
+ face_y = np.full(n_faces, np.nan, dtype=np.float64)
96
+ faces_to_do = np.where(face_mask)[0]
97
+ else:
98
+ face_x = np.zeros(n_faces, dtype=np.float64)
99
+ face_y = np.zeros(n_faces, dtype=np.float64)
100
+ faces_to_do = np.arange(n_faces)
101
+ if faces_to_do.size == 0:
102
+ return face_x, face_y
103
+ counts = np.sum(face_nodes[faces_to_do, :] > 0, axis=1)
104
+ # Vectorized per group of equal node count (faces with 0 nodes keep the
105
+ # initial fill value, as in the historical per-face loop).
106
+ for n in np.unique(counts):
107
+ if n == 0:
108
+ continue
109
+ faces = faces_to_do[counts == n]
110
+ xin, yin = _gather_face_node_coords(node_x, node_y, face_nodes, faces, int(n))
111
+ y0 = yin[np.arange(faces.size), np.argmin(np.abs(yin), axis=1)]
112
+ x = xin.copy()
113
+ if jsferic == 1:
114
+ xmax = np.max(x, axis=1)
115
+ wrap = (xmax - np.min(x, axis=1)) > 180.0
116
+ if np.any(wrap):
117
+ x = np.where(
118
+ wrap[:, None] & (x < (xmax - 180.0)[:, None]), x + 360.0, x
119
+ )
120
+ x0 = np.min(x, axis=1)
121
+ dxs = np.empty_like(x)
122
+ dys = np.empty_like(x)
123
+ for i in range(n):
124
+ dxs[:, i] = _getdx_vec(x0, y0, x[:, i], yin[:, i], jsferic)
125
+ dys[:, i] = _getdy_vec(x0, y0, x[:, i], yin[:, i], jsferic)
126
+ area = np.zeros(faces.size, dtype=np.float64)
127
+ xcg = np.zeros(faces.size, dtype=np.float64)
128
+ ycg = np.zeros(faces.size, dtype=np.float64)
129
+ for i in range(n):
130
+ ip1 = (i + 1) % n
131
+ xc = 0.5 * (dxs[:, i] + dxs[:, ip1])
132
+ yc = 0.5 * (dys[:, i] + dys[:, ip1])
133
+ dxe = _getdx_vec(x[:, i], yin[:, i], x[:, ip1], yin[:, ip1], jsferic)
134
+ dye = _getdy_vec(x[:, i], yin[:, i], x[:, ip1], yin[:, ip1], jsferic)
135
+ dsx, dsy = dye, -dxe
136
+ xds = xc * dsx + yc * dsy
137
+ area += 0.5 * xds
138
+ xcg += xds * xc
139
+ ycg += xds * yc
140
+ degenerate = np.abs(area) < 1e-8
141
+ area_safe = np.where(
142
+ degenerate, 1.0, np.sign(area) * np.maximum(np.abs(area), 1e-8)
143
+ )
144
+ fac = 1.0 / (3.0 * area_safe)
145
+ if jsferic == 1:
146
+ fy = y0 + (ycg * fac) / EARTH_RADIUS_DEG2RAD
147
+ fx = x0 + (xcg * fac) / (EARTH_RADIUS_DEG2RAD * np.cos(fy * DEG2RAD))
148
+ else:
149
+ fy = y0 + ycg * fac
150
+ fx = x0 + xcg * fac
151
+ face_x[faces] = np.where(degenerate, np.mean(xin, axis=1), fx)
152
+ face_y[faces] = np.where(degenerate, np.mean(yin, axis=1), fy)
153
+ return face_x, face_y
154
+
155
+
156
+ def _sphertocart3d_vec(lon_deg: np.ndarray, lat_deg: np.ndarray) -> np.ndarray:
157
+ """Vectorized: (N,) -> (N, 3) in Cartesian meters."""
158
+ lon_deg = np.asarray(lon_deg, dtype=np.float64)
159
+ lat_deg = np.asarray(lat_deg, dtype=np.float64)
160
+ r = EARTH_RADIUS * np.cos(lat_deg * DEG2RAD)
161
+ xx = r * np.cos(lon_deg * DEG2RAD)
162
+ yy = r * np.sin(lon_deg * DEG2RAD)
163
+ zz = EARTH_RADIUS * np.sin(lat_deg * DEG2RAD)
164
+ return np.column_stack([xx, yy, zz])
165
+
166
+
167
+ def _cart3dtospher(
168
+ xx: float, yy: float, zz: float, xref_deg: float
169
+ ) -> Tuple[float, float]:
170
+ """Cartesian (meters) -> spherical (deg)."""
171
+ raddeg = 180.0 / np.pi
172
+ x1 = np.arctan2(yy, xx) * raddeg
173
+ y1 = np.arctan2(zz, np.sqrt(xx * xx + yy * yy)) * raddeg
174
+ x1 = x1 + np.round((xref_deg - x1) / 360.0) * 360.0
175
+ return float(x1), float(y1)
176
+
177
+
178
+ def _cart3dtospher_vec(
179
+ xx: np.ndarray, yy: np.ndarray, zz: np.ndarray, xref_deg: np.ndarray
180
+ ) -> Tuple[np.ndarray, np.ndarray]:
181
+ """Vectorized `_cart3dtospher`: all args (N,) -> (lon_deg, lat_deg)."""
182
+ raddeg = 180.0 / np.pi
183
+ x1 = np.arctan2(yy, xx) * raddeg
184
+ y1 = np.arctan2(zz, np.sqrt(xx * xx + yy * yy)) * raddeg
185
+ x1 = x1 + np.round((xref_deg - x1) / 360.0) * 360.0
186
+ return x1, y1
187
+
188
+
189
+ def _getdx(x1: float, y1: float, x2: float, y2: float, jsferic: int = 1) -> float:
190
+ """Scalar version of Delft _getdx (copied from meshkernel_orthogonality)."""
191
+ if jsferic != 1:
192
+ return float(x2 - x1)
193
+ if (abs(abs(y1) - 90.0) <= DTOL_POLE) != (abs(abs(y2) - 90.0) <= DTOL_POLE):
194
+ return 0.0
195
+ xx1, xx2 = x1, x2
196
+ if xx1 - xx2 > 180.0:
197
+ xx1 -= 360.0
198
+ elif xx1 - xx2 < -180.0:
199
+ xx1 += 360.0
200
+ c = np.cos(0.5 * (y1 + y2) * DEG2RAD)
201
+ return float(EARTH_RADIUS_DEG2RAD * c * (xx2 - xx1))
202
+
203
+
204
+ def _getdy(x1: float, y1: float, x2: float, y2: float, jsferic: int = 1) -> float:
205
+ """Scalar version of Delft _getdy (copied from meshkernel_orthogonality)."""
206
+ if jsferic != 1:
207
+ return float(y2 - y1)
208
+ return float(EARTH_RADIUS_DEG2RAD * (y2 - y1))
209
+
210
+
211
+ # Small flow links (Delft3D): circumcenters in lon/lat, dxlink < 0.9*thresh*0.5*(sqrt(ba1)+sqrt(ba2))
212
+
213
+
214
+ def _lonlat_to_local_xy(
215
+ node_x: np.ndarray, node_y: np.ndarray, jsferic: int = 1
216
+ ) -> Tuple[np.ndarray, float, float]:
217
+ """Lon/lat (deg) -> local planar (m) around reference (x0,y0) using _getdx/_getdy."""
218
+ node_x = np.asarray(node_x, dtype=np.float64)
219
+ node_y = np.asarray(node_y, dtype=np.float64)
220
+ x0 = float(np.nanmean(node_x))
221
+ y0 = float(np.nanmean(node_y))
222
+ x0_arr = np.full_like(node_x, x0)
223
+ y0_arr = np.full_like(node_y, y0)
224
+ dx = _getdx_vec(x0_arr, y0_arr, node_x, node_y, jsferic)
225
+ dy = _getdy_vec(x0_arr, y0_arr, node_x, node_y, jsferic)
226
+ vert_xy = np.column_stack([dx, dy])
227
+ return vert_xy, x0, y0
228
+
229
+
230
+ def _triarea_2d(pp: np.ndarray, tt: np.ndarray) -> np.ndarray:
231
+ """Signed triangle areas in 2D (pp: nnode x 2, tt: nface x 3, 0-based indices)."""
232
+ ev12 = pp[tt[:, 1], :] - pp[tt[:, 0], :]
233
+ ev13 = pp[tt[:, 2], :] - pp[tt[:, 0], :]
234
+ area = 0.5 * (ev12[:, 0] * ev13[:, 1] - ev12[:, 1] * ev13[:, 0])
235
+ return area
236
+
237
+
238
+ def _circumcenter_of_triangle_lonlat(
239
+ n0: np.ndarray, n1: np.ndarray, n2: np.ndarray
240
+ ) -> np.ndarray:
241
+ """Circumcenter of triangle in lon/lat (deg), MeshKernel spherical formula."""
242
+ x1, y1 = float(n0[0]), float(n0[1])
243
+ x2, y2 = float(n1[0]), float(n1[1])
244
+ x3, y3 = float(n2[0]), float(n2[1])
245
+ dx2 = _getdx(x1, y1, x2, y2, 1)
246
+ dy2 = _getdy(x1, y1, x2, y2, 1)
247
+ dx3 = _getdx(x1, y1, x3, y3, 1)
248
+ dy3 = _getdy(x1, y1, x3, y3, 1)
249
+ den = dy2 * dx3 - dy3 * dx2
250
+ z = (dx2 * (dx2 - dx3) + dy2 * (dy2 - dy3)) / den if abs(den) > 1e-20 else 0.0
251
+ phi = (y1 + y2 + y3) / 3.0
252
+ xf = 1.0 / np.cos(phi * DEG2RAD)
253
+ cx = x1 + xf * 0.5 * (dx3 - z * dy3) * RAD2DEG / EARTH_RADIUS
254
+ cy = y1 + 0.5 * (dy3 + z * dx3) * RAD2DEG / EARTH_RADIUS
255
+ return np.array([cx, cy], dtype=np.float64)
256
+
257
+
258
+ def _cross_product_cartesian2d(
259
+ seg_a: np.ndarray, seg_b: np.ndarray, point: np.ndarray
260
+ ) -> float:
261
+ """Cross product (seg_b - seg_a) x (point - seg_a) in (x,y) plane."""
262
+ return float(
263
+ (seg_b[0] - seg_a[0]) * (point[1] - seg_a[1])
264
+ - (seg_b[1] - seg_a[1]) * (point[0] - seg_a[0])
265
+ )
266
+
267
+
268
+ def _point_in_triangle_winding_lonlat(
269
+ point: np.ndarray, v0: np.ndarray, v1: np.ndarray, v2: np.ndarray
270
+ ) -> bool:
271
+ """Point-in-triangle via winding in (lon, lat), MeshKernel-style."""
272
+ tol = 1e-12
273
+ winding = 0
274
+ for va, vb in ((v0, v1), (v1, v2), (v2, v0)):
275
+ cp = _cross_product_cartesian2d(va, vb, point)
276
+ if abs(cp) <= tol:
277
+ return True
278
+ if va[1] <= point[1]:
279
+ if vb[1] > point[1] and cp > 0:
280
+ winding += 1
281
+ else:
282
+ if vb[1] <= point[1] and cp < 0:
283
+ winding -= 1
284
+ return winding != 0
285
+
286
+
287
+ def _segments_crossing_ratio_intersection_lonlat(
288
+ p1: np.ndarray, p2: np.ndarray, p3: np.ndarray, p4: np.ndarray
289
+ ) -> Optional[Tuple[float, np.ndarray]]:
290
+ """If (p1,p2) and (p3,p4) cross, return (ratio_first, intersection) in lon/lat."""
291
+ x21 = _getdx(p1[0], p1[1], p2[0], p2[1], 1)
292
+ y21 = _getdy(p1[0], p1[1], p2[0], p2[1], 1)
293
+ x43 = _getdx(p3[0], p3[1], p4[0], p4[1], 1)
294
+ y43 = _getdy(p3[0], p3[1], p4[0], p4[1], 1)
295
+ x31 = _getdx(p1[0], p1[1], p3[0], p3[1], 1)
296
+ y31 = _getdy(p1[0], p1[1], p3[0], p3[1], 1)
297
+ det = x43 * y21 - y43 * x21
298
+ max_val = max(abs(x21), abs(y21), abs(x43), abs(y43), 1e-30)
299
+ if abs(det) < max(1e-10 * max_val, 1e-15):
300
+ return None
301
+ ratio_second = (y31 * x21 - x31 * y21) / det
302
+ ratio_first = (y31 * x43 - x31 * y43) / det
303
+ if not (0.0 <= ratio_first <= 1.0 and 0.0 <= ratio_second <= 1.0):
304
+ return None
305
+ inter = p1 + ratio_first * (p2 - p1)
306
+ return (float(ratio_first), np.asarray(inter, dtype=np.float64))
307
+
308
+
309
+ def _point_in_triangle_winding_lonlat_vec(
310
+ px: np.ndarray,
311
+ py: np.ndarray,
312
+ v0: np.ndarray,
313
+ v1: np.ndarray,
314
+ v2: np.ndarray,
315
+ ) -> np.ndarray:
316
+ """Vectorized `_point_in_triangle_winding_lonlat`: (F,) points vs (F,2) vertices."""
317
+ tol = 1e-12
318
+ winding = np.zeros(px.shape[0], dtype=np.int64)
319
+ on_edge = np.zeros(px.shape[0], dtype=bool)
320
+ for va, vb in ((v0, v1), (v1, v2), (v2, v0)):
321
+ cp = (vb[:, 0] - va[:, 0]) * (py - va[:, 1]) - (vb[:, 1] - va[:, 1]) * (
322
+ px - va[:, 0]
323
+ )
324
+ on_edge |= np.abs(cp) <= tol
325
+ up = va[:, 1] <= py
326
+ winding += (up & (vb[:, 1] > py) & (cp > 0)).astype(np.int64)
327
+ winding -= (~up & (vb[:, 1] <= py) & (cp < 0)).astype(np.int64)
328
+ return on_edge | (winding != 0)
329
+
330
+
331
+ def _circumcenters_lonlat_compact(
332
+ v0: np.ndarray,
333
+ v1: np.ndarray,
334
+ v2: np.ndarray,
335
+ num_interior: np.ndarray,
336
+ jsferic: int = 1,
337
+ ) -> np.ndarray:
338
+ """
339
+ Circumcenters in lon/lat (or planar x/y for jsferic=0) for triangles given
340
+ by vertex coordinates (each (F, 2), all valid). Faces with
341
+ num_interior == 0 (boundary) use the mass center; circumcenters outside
342
+ their triangle are pulled inside. Returns (F, 2).
343
+ """
344
+ out = np.empty((v0.shape[0], 2), dtype=np.float64)
345
+ mass = (v0 + v1 + v2) / 3.0
346
+
347
+ boundary = num_interior == 0
348
+ out[boundary] = mass[boundary]
349
+
350
+ keep = np.where(~boundary)[0]
351
+ if keep.size == 0:
352
+ return out
353
+ v0, v1, v2, mass = v0[keep], v1[keep], v2[keep], mass[keep]
354
+
355
+ # Vectorized `_circumcenter_of_triangle_lonlat` (MeshKernel formula; the
356
+ # planar variant is the same construction without the metric conversions).
357
+ x1, y1 = v0[:, 0], v0[:, 1]
358
+ dx2 = _getdx_vec(x1, y1, v1[:, 0], v1[:, 1], jsferic)
359
+ dy2 = _getdy_vec(x1, y1, v1[:, 0], v1[:, 1], jsferic)
360
+ dx3 = _getdx_vec(x1, y1, v2[:, 0], v2[:, 1], jsferic)
361
+ dy3 = _getdy_vec(x1, y1, v2[:, 0], v2[:, 1], jsferic)
362
+ den = dy2 * dx3 - dy3 * dx2
363
+ den_ok = np.abs(den) > 1e-20
364
+ z = np.where(
365
+ den_ok,
366
+ (dx2 * (dx2 - dx3) + dy2 * (dy2 - dy3)) / np.where(den_ok, den, 1.0),
367
+ 0.0,
368
+ )
369
+ if jsferic == 1:
370
+ phi = (y1 + v1[:, 1] + v2[:, 1]) / 3.0
371
+ xf = 1.0 / np.cos(phi * DEG2RAD)
372
+ circum = np.column_stack(
373
+ [
374
+ x1 + xf * 0.5 * (dx3 - z * dy3) * RAD2DEG / EARTH_RADIUS,
375
+ y1 + 0.5 * (dy3 + z * dx3) * RAD2DEG / EARTH_RADIUS,
376
+ ]
377
+ )
378
+ else:
379
+ circum = np.column_stack(
380
+ [
381
+ x1 + 0.5 * (dx3 - z * dy3),
382
+ y1 + 0.5 * (dy3 + z * dx3),
383
+ ]
384
+ )
385
+
386
+ inside = _point_in_triangle_winding_lonlat_vec(
387
+ circum[:, 0], circum[:, 1], v0, v1, v2
388
+ )
389
+ out[keep[inside]] = circum[inside]
390
+
391
+ # Pull-inside for circumcenters outside their triangle: first crossing of
392
+ # the mass-center -> circumcenter segment with a triangle edge wins (as
393
+ # `_segments_crossing_ratio_intersection_lonlat`), else the mass center.
394
+ o = np.where(~inside)[0]
395
+ if o.size > 0:
396
+ p1 = mass[o]
397
+ p2 = circum[o]
398
+ x21 = _getdx_vec(p1[:, 0], p1[:, 1], p2[:, 0], p2[:, 1], jsferic)
399
+ y21 = _getdy_vec(p1[:, 0], p1[:, 1], p2[:, 0], p2[:, 1], jsferic)
400
+ vs = (v0[o], v1[o], v2[o])
401
+ result = p1.copy()
402
+ found = np.zeros(o.size, dtype=bool)
403
+ for n in range(3):
404
+ p3 = vs[n]
405
+ p4 = vs[(n + 1) % 3]
406
+ x43 = _getdx_vec(p3[:, 0], p3[:, 1], p4[:, 0], p4[:, 1], jsferic)
407
+ y43 = _getdy_vec(p3[:, 0], p3[:, 1], p4[:, 0], p4[:, 1], jsferic)
408
+ x31 = _getdx_vec(p1[:, 0], p1[:, 1], p3[:, 0], p3[:, 1], jsferic)
409
+ y31 = _getdy_vec(p1[:, 0], p1[:, 1], p3[:, 0], p3[:, 1], jsferic)
410
+ det = x43 * y21 - y43 * x21
411
+ max_val = np.maximum.reduce(
412
+ [np.abs(x21), np.abs(y21), np.abs(x43), np.abs(y43)]
413
+ )
414
+ max_val = np.maximum(max_val, 1e-30)
415
+ hit = np.abs(det) >= np.maximum(1e-10 * max_val, 1e-15)
416
+ det_safe = np.where(hit, det, 1.0)
417
+ ratio_second = (y31 * x21 - x31 * y21) / det_safe
418
+ ratio_first = (y31 * x43 - x31 * y43) / det_safe
419
+ hit &= (
420
+ (0.0 <= ratio_first)
421
+ & (ratio_first <= 1.0)
422
+ & (0.0 <= ratio_second)
423
+ & (ratio_second <= 1.0)
424
+ )
425
+ take = hit & ~found
426
+ if np.any(take):
427
+ inter = p1 + ratio_first[:, None] * (p2 - p1)
428
+ result[take] = inter[take]
429
+ found |= take
430
+ out[keep[o]] = result
431
+
432
+ return out
433
+
434
+
435
+ def _num_interior_edges_per_face(edge_faces: np.ndarray, nface: int) -> np.ndarray:
436
+ """Number of interior edges (edge with two distinct valid faces) per face."""
437
+ interior = (
438
+ (edge_faces[:, 0] >= 0)
439
+ & (edge_faces[:, 1] >= 0)
440
+ & (edge_faces[:, 0] != edge_faces[:, 1])
441
+ )
442
+ counts = np.bincount(
443
+ np.concatenate([edge_faces[interior, 0], edge_faces[interior, 1]]).astype(
444
+ np.intp
445
+ ),
446
+ minlength=nface,
447
+ )
448
+ return counts.astype(np.int32)
449
+
450
+
451
+ def _circumcenters_lonlat_ugrid(
452
+ vert_deg: np.ndarray,
453
+ face_nodes: np.ndarray,
454
+ edge_faces: np.ndarray,
455
+ face_mask: Optional[np.ndarray] = None,
456
+ jsferic: int = 1,
457
+ num_interior: Optional[np.ndarray] = None,
458
+ ) -> np.ndarray:
459
+ """
460
+ Circumcenters in lon/lat per face (UGRID). Boundary faces use mass center.
461
+ numberOfInteriorEdges is derived from edge_faces (or passed precomputed).
462
+ If face_mask is provided, only compute for faces where face_mask is True (faster for zones).
463
+ """
464
+ nface = face_nodes.shape[0]
465
+ if num_interior is None:
466
+ num_interior = _num_interior_edges_per_face(edge_faces, nface)
467
+
468
+ out = (
469
+ np.full((nface, 2), np.nan, dtype=np.float64)
470
+ if face_mask is not None
471
+ else np.zeros((nface, 2), dtype=np.float64)
472
+ )
473
+ tria = face_nodes[:, :3]
474
+ indices_to_compute = (
475
+ np.where(face_mask)[0]
476
+ if face_mask is not None
477
+ else np.arange(nface, dtype=np.int64)
478
+ )
479
+ if indices_to_compute.size == 0:
480
+ return out
481
+
482
+ t_nodes = tria[indices_to_compute]
483
+ invalid = np.any(t_nodes < 0, axis=1)
484
+ out[indices_to_compute[invalid]] = np.nan
485
+
486
+ faces = indices_to_compute[~invalid]
487
+ if faces.size == 0:
488
+ return out
489
+ out[faces] = _circumcenters_lonlat_compact(
490
+ vert_deg[tria[faces, 0]],
491
+ vert_deg[tria[faces, 1]],
492
+ vert_deg[tria[faces, 2]],
493
+ num_interior[faces],
494
+ jsferic=jsferic,
495
+ )
496
+ return out
497
+
498
+
499
+ def compute_small_links_from_arrays(
500
+ node_x: np.ndarray,
501
+ node_y: np.ndarray,
502
+ face_nodes: np.ndarray,
503
+ edge_nodes: np.ndarray,
504
+ edge_faces: np.ndarray,
505
+ removesmalllinkstrsh: float = 0.11,
506
+ edge_indices: Optional[np.ndarray] = None,
507
+ jsferic: int = 1,
508
+ num_interior: Optional[np.ndarray] = None,
509
+ ) -> Tuple[int, np.ndarray]:
510
+ """Count small flow links using the Delft3D circumcenter-separation test.
511
+
512
+ Parameters
513
+ ----------
514
+ node_x, node_y : ndarray of shape (N,)
515
+ Node coordinates (lon/lat degrees or planar x/y per ``jsferic``).
516
+ face_nodes : ndarray of shape (F, 3)
517
+ 0-based triangle connectivity (invalid entries ``-1``).
518
+ edge_nodes : ndarray of shape (E, 2)
519
+ Edge endpoint indices.
520
+ edge_faces : ndarray of shape (E, 2)
521
+ Adjacent face indices (``-1`` on boundary).
522
+ removesmalllinkstrsh : float, optional
523
+ Small flow-link threshold.
524
+ edge_indices : ndarray, optional
525
+ Restrict testing to these edge indices.
526
+ jsferic : int, optional
527
+ ``1`` for spherical lon/lat; ``0`` for planar coordinates.
528
+ num_interior : ndarray, optional
529
+ Precomputed interior-edge counts per face.
530
+
531
+ Returns
532
+ -------
533
+ n_small : int
534
+ Number of small flow links found.
535
+ small_edges : ndarray of shape (n_small,)
536
+ Edge indices flagged as small.
537
+ """
538
+ node_x = np.asarray(node_x, dtype=np.float64).ravel()
539
+ node_y = np.asarray(node_y, dtype=np.float64).ravel()
540
+ nface = face_nodes.shape[0]
541
+ n_edges = edge_faces.shape[0]
542
+ if nface == 0 or n_edges == 0:
543
+ return 0, np.array([], dtype=np.int64)
544
+
545
+ no_small = (0, np.array([], dtype=np.int64))
546
+ edges_to_test = (
547
+ np.asarray(edge_indices, dtype=np.int64).ravel()
548
+ if edge_indices is not None
549
+ else np.arange(n_edges, dtype=np.int64)
550
+ )
551
+ ee = edges_to_test[(edges_to_test >= 0) & (edges_to_test < n_edges)]
552
+ if ee.size == 0:
553
+ return no_small
554
+ f1 = edge_faces[ee, 0].astype(np.int64)
555
+ f2 = edge_faces[ee, 1].astype(np.int64)
556
+ ok = (f1 >= 0) & (f2 >= 0) & (f1 != f2) & (f1 < nface) & (f2 < nface)
557
+ if not np.any(ok):
558
+ return no_small
559
+
560
+ # Work only on the faces adjacent to the tested edges (big speedup when
561
+ # testing a small subset).
562
+ tria = face_nodes[:, :3]
563
+ cand = np.unique(np.concatenate([f1[ok], f2[ok]]))
564
+ valid_cand = (tria[cand] >= 0).all(axis=1)
565
+ rows = np.where(ok)[0]
566
+ g1 = np.searchsorted(cand, f1[rows])
567
+ g2 = np.searchsorted(cand, f2[rows])
568
+ keep = valid_cand[g1] & valid_cand[g2]
569
+ rows = rows[keep]
570
+ if rows.size == 0:
571
+ return no_small
572
+
573
+ faces_needed = cand[valid_cand]
574
+ pos_in_needed = np.cumsum(valid_cand) - 1
575
+ g1 = pos_in_needed[g1[keep]]
576
+ g2 = pos_in_needed[g2[keep]]
577
+
578
+ # Circumcenters (lon/lat) of the needed faces.
579
+ if num_interior is None:
580
+ num_interior = _num_interior_edges_per_face(edge_faces, nface)
581
+ t0, t1, t2 = tria[faces_needed, 0], tria[faces_needed, 1], tria[faces_needed, 2]
582
+ circ = _circumcenters_lonlat_compact(
583
+ np.column_stack([node_x[t0], node_y[t0]]),
584
+ np.column_stack([node_x[t1], node_y[t1]]),
585
+ np.column_stack([node_x[t2], node_y[t2]]),
586
+ num_interior[faces_needed],
587
+ jsferic=jsferic,
588
+ )
589
+
590
+ # Face areas in local planar coordinates (as `_lonlat_to_local_xy` +
591
+ # `_triarea_2d`, computed only for the nodes of the needed faces; the
592
+ # reference point stays the full-mesh mean).
593
+ x0 = float(np.nanmean(node_x))
594
+ y0 = float(np.nanmean(node_y))
595
+ nodes_used = np.unique(np.concatenate([t0, t1, t2]))
596
+ x0a = np.full(nodes_used.size, x0)
597
+ y0a = np.full(nodes_used.size, y0)
598
+ ux = _getdx_vec(x0a, y0a, node_x[nodes_used], node_y[nodes_used], jsferic)
599
+ uy = _getdy_vec(x0a, y0a, node_x[nodes_used], node_y[nodes_used], jsferic)
600
+ q0 = np.searchsorted(nodes_used, t0)
601
+ q1 = np.searchsorted(nodes_used, t1)
602
+ q2 = np.searchsorted(nodes_used, t2)
603
+ ev12x, ev12y = ux[q1] - ux[q0], uy[q1] - uy[q0]
604
+ ev13x, ev13y = ux[q2] - ux[q0], uy[q2] - uy[q0]
605
+ ba = np.abs(0.5 * (ev12x * ev13y - ev12y * ev13x))
606
+
607
+ c1 = circ[g1]
608
+ c2 = circ[g2]
609
+ good = ~np.any(np.isnan(c1), axis=1) & ~np.any(np.isnan(c2), axis=1)
610
+ dx = _getdx_vec(c1[:, 0], c1[:, 1], c2[:, 0], c2[:, 1], jsferic)
611
+ dy = _getdy_vec(c1[:, 0], c1[:, 1], c2[:, 0], c2[:, 1], jsferic)
612
+ dxlink = np.sqrt(dx * dx + dy * dy)
613
+ sqrt_ba1 = np.sqrt(np.maximum(ba[g1], 1e-20))
614
+ sqrt_ba2 = np.sqrt(np.maximum(ba[g2], 1e-20))
615
+ dxlim = 0.9 * removesmalllinkstrsh * 0.5 * (sqrt_ba1 + sqrt_ba2)
616
+ small_edges = ee[rows[good & (dxlink < dxlim)]]
617
+
618
+ return int(small_edges.size), small_edges.astype(np.int64)
619
+
620
+
621
+ def _signed_area_tri_deg(
622
+ node_x: np.ndarray, node_y: np.ndarray, i: int, j: int, k: int
623
+ ) -> float:
624
+ """Signed area (doubled) of triangle (i,j,k) in lon/lat degrees. Positive = CCW."""
625
+ xi, yi = node_x[i], node_y[i]
626
+ xj, yj = node_x[j], node_y[j]
627
+ xk, yk = node_x[k], node_y[k]
628
+ return (xj - xi) * (yk - yi) - (xk - xi) * (yj - yi)
629
+
630
+
631
+ def try_flip_small_flow_edge_ugrid(
632
+ mesh: "MeshData",
633
+ edge_index: int,
634
+ ) -> bool:
635
+ """Flip a shared edge to remove a small flow link.
636
+
637
+ Parameters
638
+ ----------
639
+ mesh : MeshData
640
+ Mesh to modify in place.
641
+ edge_index : int
642
+ Index of the edge to flip.
643
+
644
+ Returns
645
+ -------
646
+ bool
647
+ ``True`` if the edge was flipped.
648
+ """
649
+ face_nodes = mesh.face_nodes
650
+ edge_nodes = mesh.edge_nodes
651
+ edge_faces = mesh.edge_faces
652
+ node_x = mesh.node_x
653
+ node_y = mesh.node_y
654
+ n_face = face_nodes.shape[0]
655
+ if edge_index < 0 or edge_index >= edge_faces.shape[0]:
656
+ return False
657
+ k3, k4 = int(edge_nodes[edge_index, 0]), int(edge_nodes[edge_index, 1])
658
+ f1, f2 = int(edge_faces[edge_index, 0]), int(edge_faces[edge_index, 1])
659
+ if k3 < 0 or k4 < 0 or f1 < 0 or f2 < 0 or f1 == f2 or f1 >= n_face or f2 >= n_face:
660
+ return False
661
+ tri1 = face_nodes[f1, :3].copy()
662
+ tri2 = face_nodes[f2, :3].copy()
663
+ opp1 = None
664
+ for v in tri1:
665
+ v = int(v)
666
+ if v >= 0 and v != k3 and v != k4:
667
+ opp1 = v
668
+ break
669
+ opp2 = None
670
+ for v in tri2:
671
+ v = int(v)
672
+ if v >= 0 and v != k3 and v != k4:
673
+ opp2 = v
674
+ break
675
+ if opp1 is None or opp2 is None or opp1 == opp2:
676
+ return False
677
+ # Convex quad: k3 and k4 on opposite sides of line (opp1, opp2)
678
+ sa1 = _signed_area_tri_deg(node_x, node_y, opp1, opp2, k3)
679
+ sa2 = _signed_area_tri_deg(node_x, node_y, opp1, opp2, k4)
680
+ if sa1 * sa2 >= 0:
681
+ return False
682
+ # Both new triangles must have positive area (CCW). The sign of sa1 only
683
+ # reflects the arbitrary storage order of the apexes: if k3 is on the
684
+ # negative side of (opp1 -> opp2), swap the apexes instead of refusing a
685
+ # geometrically valid flip.
686
+ if sa1 <= 0:
687
+ opp1, opp2 = opp2, opp1
688
+ # New triangles: (opp1, opp2, k3) and (opp2, opp1, k4)
689
+ face_nodes[f1, 0], face_nodes[f1, 1], face_nodes[f1, 2] = opp1, opp2, k3
690
+ face_nodes[f2, 0], face_nodes[f2, 1], face_nodes[f2, 2] = opp2, opp1, k4
691
+ return True
692
+
693
+
694
+ def _edges_among_nodes(edge_nodes: np.ndarray, nodes: np.ndarray) -> np.ndarray:
695
+ """Indices of edges whose both endpoints are in `nodes`."""
696
+ m = np.isin(edge_nodes[:, 0], nodes) & np.isin(edge_nodes[:, 1], nodes)
697
+ return np.where(m)[0]
698
+
699
+
700
+ def try_flip_candidate_edges_ugrid(
701
+ mesh: "MeshData",
702
+ candidate_edges: np.ndarray,
703
+ removesmalllinkstrsh: float,
704
+ max_flip_iter: int = 20,
705
+ max_cosphi_allowed: Optional[float] = None,
706
+ jsferic: int = 1,
707
+ ) -> int:
708
+ """Flip candidate edges with optional local ``|cos φ|`` quality guard.
709
+
710
+ Parameters
711
+ ----------
712
+ mesh : MeshData
713
+ Mesh to modify in place.
714
+ candidate_edges : ndarray
715
+ Edge indices to attempt flipping.
716
+ removesmalllinkstrsh : float
717
+ Small flow-link threshold (for bookkeeping).
718
+ max_flip_iter : int, optional
719
+ Maximum flip attempts per candidate.
720
+ max_cosphi_allowed : float, optional
721
+ Revert flips that exceed this local ``|cos φ|`` cap.
722
+ jsferic : int, optional
723
+ ``1`` for spherical lon/lat; ``0`` for planar coordinates.
724
+
725
+ Returns
726
+ -------
727
+ int
728
+ Number of edges successfully flipped.
729
+ """
730
+ candidate_edges = np.asarray(candidate_edges, dtype=np.int64).ravel()
731
+ if candidate_edges.size == 0:
732
+ return 0
733
+
734
+ total_flipped = 0
735
+
736
+ def _quad_max_cosphi(quad_edges: np.ndarray) -> float:
737
+ cos = _cosphi_abs_for_edges(
738
+ mesh.node_x,
739
+ mesh.node_y,
740
+ mesh.face_nodes,
741
+ mesh.edge_nodes,
742
+ mesh.edge_faces,
743
+ quad_edges,
744
+ use_circumcenter_3d=True,
745
+ jsferic=jsferic,
746
+ )
747
+ vals = cos[quad_edges]
748
+ vals = vals[np.isfinite(vals)]
749
+ return float(np.max(vals)) if vals.size else 0.0
750
+
751
+ for _ in range(max_flip_iter):
752
+ flipped_any = False
753
+ for ei in range(candidate_edges.size):
754
+ e = int(candidate_edges[ei])
755
+ quad_nodes = None
756
+ if max_cosphi_allowed is not None and 0 <= e < mesh.edge_faces.shape[0]:
757
+ f1, f2 = int(mesh.edge_faces[e, 0]), int(mesh.edge_faces[e, 1])
758
+ if f1 >= 0 and f2 >= 0:
759
+ quad_nodes = np.unique(
760
+ np.concatenate(
761
+ [mesh.face_nodes[f1, :3], mesh.face_nodes[f2, :3]]
762
+ )
763
+ )
764
+ quad_nodes = quad_nodes[quad_nodes >= 0]
765
+ # The flip only rewires the quad: its 5 edges keep their
766
+ # slots (the new diagonal reuses slot `e`), so snapshot
767
+ # them and update surgically instead of rebuilding the
768
+ # full edge arrays twice per rejected attempt.
769
+ quad_edges = _edges_among_nodes(mesh.edge_nodes, quad_nodes)
770
+ rows_before = (
771
+ f1,
772
+ mesh.face_nodes[f1].copy(),
773
+ f2,
774
+ mesh.face_nodes[f2].copy(),
775
+ )
776
+ edge_nodes_before = mesh.edge_nodes[e].copy()
777
+ edge_faces_before = mesh.edge_faces[quad_edges].copy()
778
+ max_before = _quad_max_cosphi(quad_edges)
779
+
780
+ if not try_flip_small_flow_edge_ugrid(mesh, e):
781
+ continue
782
+
783
+ if quad_nodes is None:
784
+ mesh.edge_nodes, mesh.edge_faces = _build_edges_from_tria(
785
+ mesh.face_nodes[:, :3]
786
+ )
787
+ total_flipped += 1
788
+ flipped_any = True
789
+ break
790
+
791
+ # Surgical topology update: slot `e` becomes the new diagonal
792
+ # (the node pair shared by the two rewritten faces) and each quad
793
+ # edge's face pair is recomputed against the new rows.
794
+ r1 = set(int(x) for x in mesh.face_nodes[f1, :3])
795
+ r2 = set(int(x) for x in mesh.face_nodes[f2, :3])
796
+ diag = sorted(r1 & r2)
797
+ mesh.edge_nodes[e, 0] = diag[0]
798
+ mesh.edge_nodes[e, 1] = diag[1]
799
+ for eidx in quad_edges:
800
+ a, b = int(mesh.edge_nodes[eidx, 0]), int(mesh.edge_nodes[eidx, 1])
801
+ fa, fb = int(mesh.edge_faces[eidx, 0]), int(mesh.edge_faces[eidx, 1])
802
+ adj = []
803
+ if a in r1 and b in r1:
804
+ adj.append(f1)
805
+ if a in r2 and b in r2:
806
+ adj.append(f2)
807
+ adj.extend(f for f in (fa, fb) if f >= 0 and f != f1 and f != f2)
808
+ mesh.edge_faces[eidx, 0] = adj[0] if len(adj) >= 1 else -1
809
+ mesh.edge_faces[eidx, 1] = adj[1] if len(adj) >= 2 else -1
810
+
811
+ max_after = _quad_max_cosphi(quad_edges)
812
+ if max_after > max(float(max_cosphi_allowed), max_before) + 1.0e-12:
813
+ # Revert: restore face rows and the snapshotted edge entries.
814
+ f1r, row1, f2r, row2 = rows_before
815
+ mesh.face_nodes[f1r] = row1
816
+ mesh.face_nodes[f2r] = row2
817
+ mesh.edge_nodes[e] = edge_nodes_before
818
+ mesh.edge_faces[quad_edges] = edge_faces_before
819
+ continue
820
+
821
+ # Accepted: the surgically updated arrays are already consistent
822
+ # (the caller re-canonicalizes edge numbering after the batch).
823
+ total_flipped += 1
824
+ flipped_any = True
825
+ break
826
+
827
+ if not flipped_any:
828
+ break
829
+
830
+ _, candidate_edges = compute_small_links_from_arrays(
831
+ mesh.node_x,
832
+ mesh.node_y,
833
+ mesh.face_nodes,
834
+ mesh.edge_nodes,
835
+ mesh.edge_faces,
836
+ removesmalllinkstrsh=removesmalllinkstrsh,
837
+ jsferic=jsferic,
838
+ )
839
+
840
+ return total_flipped
841
+
842
+
843
+ def _point_in_polygon(
844
+ px: float, py: float, xv: np.ndarray, yv: np.ndarray, x0: float, y0: float
845
+ ) -> bool:
846
+ """Exact copy from meshkernel_orthogonality: ray casting in projected coords."""
847
+ n = len(xv)
848
+ if n < 3:
849
+ return False
850
+ dxp = _getdx(x0, y0, px, py, 1)
851
+ dyp = _getdy(x0, y0, px, py, 1)
852
+ count = 0
853
+ for i in range(n):
854
+ ip1 = (i + 1) % n
855
+ dx_i = _getdx(x0, y0, xv[i], yv[i], 1)
856
+ dy_i = _getdy(x0, y0, xv[i], yv[i], 1)
857
+ dx_ip1 = _getdx(x0, y0, xv[ip1], yv[ip1], 1)
858
+ dy_ip1 = _getdy(x0, y0, xv[ip1], yv[ip1], 1)
859
+ if (dy_i <= dyp < dy_ip1) or (dy_ip1 <= dyp < dy_i):
860
+ if abs(dy_ip1 - dy_i) < 1e-12:
861
+ continue
862
+ t = (dyp - dy_i) / (dy_ip1 - dy_i)
863
+ x_cross = dx_i + t * (dx_ip1 - dx_i)
864
+ if x_cross > dxp:
865
+ count += 1
866
+ return (count % 2) == 1
867
+
868
+
869
+ def _segment_edge_intersect(
870
+ x1: float,
871
+ y1: float,
872
+ x2: float,
873
+ y2: float,
874
+ xa: float,
875
+ ya: float,
876
+ xb: float,
877
+ yb: float,
878
+ x0: float,
879
+ y0: float,
880
+ ):
881
+ """Exact copy from meshkernel_orthogonality: segment–edge intersection in projected coords."""
882
+ dx1 = _getdx(x0, y0, x1, y1, 1)
883
+ dy1 = _getdy(x0, y0, x1, y1, 1)
884
+ dx2 = _getdx(x0, y0, x2, y2, 1)
885
+ dy2 = _getdy(x0, y0, x2, y2, 1)
886
+ dxa = _getdx(x0, y0, xa, ya, 1)
887
+ dya = _getdy(x0, y0, xa, ya, 1)
888
+ dxb = _getdx(x0, y0, xb, yb, 1)
889
+ dyb = _getdy(x0, y0, xb, yb, 1)
890
+ den = (dx2 - dx1) * (dyb - dya) - (dy2 - dy1) * (dxb - dxa)
891
+ if abs(den) < 1e-15:
892
+ return None
893
+ t = ((dxa - dx1) * (dyb - dya) - (dya - dy1) * (dxb - dxa)) / den
894
+ s = ((dxa - dx1) * (dy2 - dy1) - (dya - dy1) * (dx2 - dx1)) / den
895
+ if not (0 <= t <= 1 and 0 <= s <= 1):
896
+ return None
897
+ xcr_l = dx1 + t * (dx2 - dx1)
898
+ ycr_l = dy1 + t * (dy2 - dy1)
899
+ ycr_deg = y0 + ycr_l / EARTH_RADIUS_DEG2RAD
900
+ xcr_deg = x0 + xcr_l / (EARTH_RADIUS_DEG2RAD * np.cos(ycr_deg * DEG2RAD))
901
+ return (xcr_deg, ycr_deg, t)
902
+
903
+
904
+ def _circumcenter3d(xv: np.ndarray, yv: np.ndarray) -> Tuple[float, float]:
905
+ """Spherical 3D circumcenter (comp_circumcenter3D). xv, yv in deg -> (xz, yz) deg."""
906
+ N = len(xv)
907
+ if N < 2:
908
+ return float(xv[0]), float(yv[0])
909
+ xx = _sphertocart3d_vec(
910
+ np.asarray(xv, dtype=np.float64), np.asarray(yv, dtype=np.float64)
911
+ )
912
+ xxc, yyc, zzc = np.mean(xx[:, 0]), np.mean(xx[:, 1]), np.mean(xx[:, 2])
913
+ dtol, deps, maxiter = 1e-8, 1e-8, 100
914
+ ip1 = np.arange(N)
915
+ ip1 = (ip1 + 1) % N
916
+ ttx = xx[ip1, 0] - xx[:, 0]
917
+ tty = xx[ip1, 1] - xx[:, 1]
918
+ ttz = xx[ip1, 2] - xx[:, 2]
919
+ ds = np.sqrt(ttx * ttx + tty * tty + ttz * ttz)
920
+ valid = ds >= dtol
921
+ dsi = np.where(valid, 1.0 / ds, 0.0)
922
+ ttx = ttx * dsi
923
+ tty = tty * dsi
924
+ ttz = ttz * dsi
925
+ xxe = 0.5 * (xx[:, 0] + xx[ip1, 0])
926
+ yye = 0.5 * (xx[:, 1] + xx[ip1, 1])
927
+ zze = 0.5 * (xx[:, 2] + xx[ip1, 2])
928
+ lam = 0.0
929
+ for _ in range(maxiter):
930
+ A = np.zeros((4, 4))
931
+ rhs = np.zeros(4)
932
+ for i in range(N):
933
+ if ds[i] < dtol:
934
+ continue
935
+ A[0, 0] += ttx[i] * ttx[i]
936
+ A[0, 1] += ttx[i] * tty[i]
937
+ A[0, 2] += ttx[i] * ttz[i]
938
+ A[1, 1] += tty[i] * tty[i]
939
+ A[1, 2] += tty[i] * ttz[i]
940
+ A[2, 2] += ttz[i] * ttz[i]
941
+ dinpr = (
942
+ (xxc - xxe[i]) * ttx[i]
943
+ + (yyc - yye[i]) * tty[i]
944
+ + (zzc - zze[i]) * ttz[i]
945
+ )
946
+ rhs[0] -= dinpr * ttx[i]
947
+ rhs[1] -= dinpr * tty[i]
948
+ rhs[2] -= dinpr * ttz[i]
949
+ A[0, 0] -= 2 * lam
950
+ A[1, 1] -= 2 * lam
951
+ A[2, 2] -= 2 * lam
952
+ A[0, 3], A[1, 3], A[2, 3] = -2 * xxc, -2 * yyc, -2 * zzc
953
+ A[3, 3] = 0.0
954
+ rhs[0] += 2 * lam * xxc
955
+ rhs[1] += 2 * lam * yyc
956
+ rhs[2] += 2 * lam * zzc
957
+ rhs[3] = xxc * xxc + yyc * yyc + zzc * zzc - EARTH_RADIUS_SQ
958
+ A[1, 0], A[2, 0], A[2, 1] = A[0, 1], A[0, 2], A[1, 2]
959
+ A[3, 0], A[3, 1], A[3, 2] = A[0, 3], A[1, 3], A[2, 3]
960
+ try:
961
+ sol = np.linalg.solve(A, rhs)
962
+ except np.linalg.LinAlgError:
963
+ break
964
+ xxc += sol[0]
965
+ yyc += sol[1]
966
+ zzc += sol[2]
967
+ lam += sol[3]
968
+ if sol[0] ** 2 + sol[1] ** 2 + sol[2] ** 2 < deps:
969
+ break
970
+ return _cart3dtospher(xxc, yyc, zzc, float(np.max(xv)))
971
+
972
+
973
+ def _circumcenters3d_batch(
974
+ xv: np.ndarray, yv: np.ndarray
975
+ ) -> Tuple[np.ndarray, np.ndarray]:
976
+ """
977
+ Batched `_circumcenter3d` for faces with an equal node count.
978
+ xv, yv: (F, N) in deg -> (xz, yz), each (F,) in deg.
979
+
980
+ Runs the same constrained Newton iteration as the scalar version, with a
981
+ per-face active mask so each face performs the identical update sequence.
982
+ """
983
+ F, N = xv.shape
984
+ if F == 0:
985
+ return np.zeros(0, dtype=np.float64), np.zeros(0, dtype=np.float64)
986
+ if N < 2:
987
+ return xv[:, 0].astype(np.float64), yv[:, 0].astype(np.float64)
988
+ xx = _sphertocart3d_vec(xv.ravel(), yv.ravel()).reshape(F, N, 3)
989
+ xxc = np.mean(xx[:, :, 0], axis=1)
990
+ yyc = np.mean(xx[:, :, 1], axis=1)
991
+ zzc = np.mean(xx[:, :, 2], axis=1)
992
+ dtol, deps, maxiter = 1e-8, 1e-8, 100
993
+ ip1 = (np.arange(N) + 1) % N
994
+ ttx = xx[:, ip1, 0] - xx[:, :, 0]
995
+ tty = xx[:, ip1, 1] - xx[:, :, 1]
996
+ ttz = xx[:, ip1, 2] - xx[:, :, 2]
997
+ ds = np.sqrt(ttx * ttx + tty * tty + ttz * ttz)
998
+ valid = ds >= dtol
999
+ dsi = np.where(valid, 1.0 / np.where(valid, ds, 1.0), 0.0)
1000
+ ttx = ttx * dsi
1001
+ tty = tty * dsi
1002
+ ttz = ttz * dsi
1003
+ xxe = 0.5 * (xx[:, :, 0] + xx[:, ip1, 0])
1004
+ yye = 0.5 * (xx[:, :, 1] + xx[:, ip1, 1])
1005
+ zze = 0.5 * (xx[:, :, 2] + xx[:, ip1, 2])
1006
+ # The A-matrix tangent terms are invariant across Newton iterations:
1007
+ # sequential accumulation over nodes, same summation order as the scalar
1008
+ # loop (invalid edges have tt == 0, so their terms are 0).
1009
+ a00_full = np.zeros(F)
1010
+ a01_full = np.zeros(F)
1011
+ a02_full = np.zeros(F)
1012
+ a11_full = np.zeros(F)
1013
+ a12_full = np.zeros(F)
1014
+ a22_full = np.zeros(F)
1015
+ for i in range(N):
1016
+ a00_full += ttx[:, i] * ttx[:, i]
1017
+ a01_full += ttx[:, i] * tty[:, i]
1018
+ a02_full += ttx[:, i] * ttz[:, i]
1019
+ a11_full += tty[:, i] * tty[:, i]
1020
+ a12_full += tty[:, i] * ttz[:, i]
1021
+ a22_full += ttz[:, i] * ttz[:, i]
1022
+
1023
+ lam = np.zeros(F, dtype=np.float64)
1024
+ active = np.ones(F, dtype=bool)
1025
+ # For small batches, gathering the active lanes costs more than computing
1026
+ # discarded updates for converged ones; updates are masked either way.
1027
+ gather = F >= 64
1028
+ for _ in range(maxiter):
1029
+ if not np.any(active):
1030
+ break
1031
+ if gather:
1032
+ act = np.where(active)[0]
1033
+ tx, ty, tz = ttx[act], tty[act], ttz[act]
1034
+ xe, ye, ze = xxe[act], yye[act], zze[act]
1035
+ cx, cy, cz = xxc[act], yyc[act], zzc[act]
1036
+ lam_a = lam[act]
1037
+ a00, a01, a02 = a00_full[act], a01_full[act], a02_full[act]
1038
+ a11, a12, a22 = a11_full[act], a12_full[act], a22_full[act]
1039
+ else:
1040
+ act = None
1041
+ tx, ty, tz = ttx, tty, ttz
1042
+ xe, ye, ze = xxe, yye, zze
1043
+ cx, cy, cz = xxc, yyc, zzc
1044
+ lam_a = lam
1045
+ a00, a01, a02 = a00_full, a01_full, a02_full
1046
+ a11, a12, a22 = a11_full, a12_full, a22_full
1047
+ na = cx.shape[0]
1048
+ r0 = np.zeros(na)
1049
+ r1 = np.zeros(na)
1050
+ r2 = np.zeros(na)
1051
+ for i in range(N):
1052
+ dinpr = (
1053
+ (cx - xe[:, i]) * tx[:, i]
1054
+ + (cy - ye[:, i]) * ty[:, i]
1055
+ + (cz - ze[:, i]) * tz[:, i]
1056
+ )
1057
+ r0 -= dinpr * tx[:, i]
1058
+ r1 -= dinpr * ty[:, i]
1059
+ r2 -= dinpr * tz[:, i]
1060
+ A = np.zeros((na, 4, 4))
1061
+ A[:, 0, 0] = a00 - 2 * lam_a
1062
+ A[:, 1, 1] = a11 - 2 * lam_a
1063
+ A[:, 2, 2] = a22 - 2 * lam_a
1064
+ A[:, 0, 1] = A[:, 1, 0] = a01
1065
+ A[:, 0, 2] = A[:, 2, 0] = a02
1066
+ A[:, 1, 2] = A[:, 2, 1] = a12
1067
+ A[:, 0, 3] = A[:, 3, 0] = -2 * cx
1068
+ A[:, 1, 3] = A[:, 3, 1] = -2 * cy
1069
+ A[:, 2, 3] = A[:, 3, 2] = -2 * cz
1070
+ rhs = np.empty((na, 4))
1071
+ rhs[:, 0] = r0 + 2 * lam_a * cx
1072
+ rhs[:, 1] = r1 + 2 * lam_a * cy
1073
+ rhs[:, 2] = r2 + 2 * lam_a * cz
1074
+ rhs[:, 3] = cx * cx + cy * cy + cz * cz - EARTH_RADIUS_SQ
1075
+ solved = np.ones(na, dtype=bool)
1076
+ try:
1077
+ sol = np.linalg.solve(A, rhs[:, :, None])[:, :, 0]
1078
+ except np.linalg.LinAlgError:
1079
+ # Some face has a singular system: solve per face; a singular face
1080
+ # stops iterating with its current center (as in the scalar code).
1081
+ sol = np.zeros((na, 4))
1082
+ for j in range(na):
1083
+ try:
1084
+ sol[j] = np.linalg.solve(A[j], rhs[j])
1085
+ except np.linalg.LinAlgError:
1086
+ solved[j] = False
1087
+ conv = sol[:, 0] ** 2 + sol[:, 1] ** 2 + sol[:, 2] ** 2 < deps
1088
+ if act is not None:
1089
+ upd = act[solved]
1090
+ xxc[upd] += sol[solved, 0]
1091
+ yyc[upd] += sol[solved, 1]
1092
+ zzc[upd] += sol[solved, 2]
1093
+ lam[upd] += sol[solved, 3]
1094
+ active[act[~solved]] = False
1095
+ active[act[solved & conv]] = False
1096
+ else:
1097
+ upd = active & solved
1098
+ xxc = np.where(upd, xxc + sol[:, 0], xxc)
1099
+ yyc = np.where(upd, yyc + sol[:, 1], yyc)
1100
+ zzc = np.where(upd, zzc + sol[:, 2], zzc)
1101
+ lam = np.where(upd, lam + sol[:, 3], lam)
1102
+ active &= solved & ~conv
1103
+ return _cart3dtospher_vec(xxc, yyc, zzc, np.max(xv, axis=1))
1104
+
1105
+
1106
+ def _circumcenters2d_batch(
1107
+ xv: np.ndarray, yv: np.ndarray
1108
+ ) -> Tuple[np.ndarray, np.ndarray]:
1109
+ """
1110
+ Planar (jsferic=0) counterpart of `_circumcenters3d_batch` for faces with
1111
+ an equal node count. xv, yv: (F, N) in x/y -> (xz, yz), each (F,).
1112
+
1113
+ The circumcenter is the least-squares intersection of the edge
1114
+ perpendicular bisectors: minimize sum_i ((c - e_i) . t_i)^2 over edge
1115
+ midpoints e_i and unit tangents t_i (exact circumcenter for triangles).
1116
+ Degenerate faces (collinear or too-short edges) fall back to the vertex
1117
+ mean, like the spherical version's singular-system fallback.
1118
+ """
1119
+ F, N = xv.shape
1120
+ if F == 0:
1121
+ return np.zeros(0, dtype=np.float64), np.zeros(0, dtype=np.float64)
1122
+ if N < 2:
1123
+ return xv[:, 0].astype(np.float64), yv[:, 0].astype(np.float64)
1124
+ dtol = 1e-8
1125
+ ip1 = (np.arange(N) + 1) % N
1126
+ ttx = xv[:, ip1] - xv
1127
+ tty = yv[:, ip1] - yv
1128
+ ds = np.sqrt(ttx * ttx + tty * tty)
1129
+ valid = ds >= dtol
1130
+ dsi = np.where(valid, 1.0 / np.where(valid, ds, 1.0), 0.0)
1131
+ ttx = ttx * dsi
1132
+ tty = tty * dsi
1133
+ xxe = 0.5 * (xv + xv[:, ip1])
1134
+ yye = 0.5 * (yv + yv[:, ip1])
1135
+ a00 = np.zeros(F)
1136
+ a01 = np.zeros(F)
1137
+ a11 = np.zeros(F)
1138
+ b0 = np.zeros(F)
1139
+ b1 = np.zeros(F)
1140
+ for i in range(N):
1141
+ a00 += ttx[:, i] * ttx[:, i]
1142
+ a01 += ttx[:, i] * tty[:, i]
1143
+ a11 += tty[:, i] * tty[:, i]
1144
+ einpr = xxe[:, i] * ttx[:, i] + yye[:, i] * tty[:, i]
1145
+ b0 += einpr * ttx[:, i]
1146
+ b1 += einpr * tty[:, i]
1147
+ det = a00 * a11 - a01 * a01
1148
+ ok = np.abs(det) >= 1e-12
1149
+ det_safe = np.where(ok, det, 1.0)
1150
+ cx = (b0 * a11 - b1 * a01) / det_safe
1151
+ cy = (a00 * b1 - a01 * b0) / det_safe
1152
+ mean_x = np.mean(xv, axis=1)
1153
+ mean_y = np.mean(yv, axis=1)
1154
+ return np.where(ok, cx, mean_x), np.where(ok, cy, mean_y)
1155
+
1156
+
1157
+ def _point_in_polygon_vec(
1158
+ px: np.ndarray,
1159
+ py: np.ndarray,
1160
+ xv: np.ndarray,
1161
+ yv: np.ndarray,
1162
+ x0: np.ndarray,
1163
+ y0: np.ndarray,
1164
+ jsferic: int = 1,
1165
+ ) -> np.ndarray:
1166
+ """Vectorized `_point_in_polygon`: (F,) points vs (F, N) polygons."""
1167
+ F, N = xv.shape
1168
+ if N < 3:
1169
+ return np.zeros(F, dtype=bool)
1170
+ dxp = _getdx_vec(x0, y0, px, py, jsferic)
1171
+ dyp = _getdy_vec(x0, y0, px, py, jsferic)
1172
+ dxs = np.empty_like(xv)
1173
+ dys = np.empty_like(yv)
1174
+ for i in range(N):
1175
+ dxs[:, i] = _getdx_vec(x0, y0, xv[:, i], yv[:, i], jsferic)
1176
+ dys[:, i] = _getdy_vec(x0, y0, xv[:, i], yv[:, i], jsferic)
1177
+ count = np.zeros(F, dtype=np.int64)
1178
+ for i in range(N):
1179
+ ip1 = (i + 1) % N
1180
+ dy_i = dys[:, i]
1181
+ dy_ip1 = dys[:, ip1]
1182
+ crosses = ((dy_i <= dyp) & (dyp < dy_ip1)) | ((dy_ip1 <= dyp) & (dyp < dy_i))
1183
+ crosses &= np.abs(dy_ip1 - dy_i) >= 1e-12
1184
+ denom = np.where(crosses, dy_ip1 - dy_i, 1.0)
1185
+ t = (dyp - dy_i) / denom
1186
+ x_cross = dxs[:, i] + t * (dxs[:, ip1] - dxs[:, i])
1187
+ count += (crosses & (x_cross > dxp)).astype(np.int64)
1188
+ return (count % 2) == 1
1189
+
1190
+
1191
+ def _face_centers_circumcenter3d(
1192
+ node_x: np.ndarray,
1193
+ node_y: np.ndarray,
1194
+ face_nodes: np.ndarray,
1195
+ dcenterinside: float = 1.0,
1196
+ face_mask: Optional[np.ndarray] = None,
1197
+ jsferic: int = 1,
1198
+ ) -> Tuple[np.ndarray, np.ndarray]:
1199
+ """
1200
+ Centers = 3D circumcenter (planar 2D circumcenter for jsferic=0), with
1201
+ pull-inside when outside cell (as in Delft).
1202
+ If face_mask is provided, only compute for faces where face_mask[f] is True;
1203
+ others are left as nan.
1204
+ """
1205
+ mass_x, mass_y = _face_centers(
1206
+ node_x, node_y, face_nodes, face_mask=face_mask, jsferic=jsferic
1207
+ )
1208
+ n_faces = face_nodes.shape[0]
1209
+ if face_mask is not None:
1210
+ face_x = np.full(n_faces, np.nan, dtype=np.float64)
1211
+ face_y = np.full(n_faces, np.nan, dtype=np.float64)
1212
+ faces_to_do = np.where(face_mask)[0]
1213
+ else:
1214
+ face_x = np.zeros(n_faces, dtype=np.float64)
1215
+ face_y = np.zeros(n_faces, dtype=np.float64)
1216
+ faces_to_do = np.arange(n_faces)
1217
+ if faces_to_do.size == 0:
1218
+ return face_x, face_y
1219
+ counts = np.sum(face_nodes[faces_to_do, :] > 0, axis=1)
1220
+
1221
+ for n in np.unique(counts):
1222
+ faces = faces_to_do[counts == n]
1223
+ if n == 0:
1224
+ face_x[faces] = mass_x[faces]
1225
+ face_y[faces] = mass_y[faces]
1226
+ continue
1227
+ if n == 1:
1228
+ xv1, yv1 = _gather_face_node_coords(node_x, node_y, face_nodes, faces, 1)
1229
+ face_x[faces] = xv1[:, 0]
1230
+ face_y[faces] = yv1[:, 0]
1231
+ continue
1232
+ n = int(n)
1233
+ xv, yv = _gather_face_node_coords(node_x, node_y, face_nodes, faces, n)
1234
+ if jsferic == 1:
1235
+ xz, yz = _circumcenters3d_batch(xv, yv)
1236
+ else:
1237
+ xz, yz = _circumcenters2d_batch(xv, yv)
1238
+ if n == 3:
1239
+ # Axis-aligned right triangles: use the circumcenter of the
1240
+ # bounding rectangle (as in Delft).
1241
+ has_v = np.zeros(faces.size, dtype=bool)
1242
+ has_h = np.zeros(faces.size, dtype=bool)
1243
+ for k in range(3):
1244
+ k2 = (k + 1) % 3
1245
+ has_v |= np.abs(xv[:, k] - xv[:, k2]) < 1e-10
1246
+ has_h |= np.abs(yv[:, k] - yv[:, k2]) < 1e-10
1247
+ rect = has_v & has_h
1248
+ if np.any(rect):
1249
+ xmin = xv[rect].min(axis=1)
1250
+ xmax = xv[rect].max(axis=1)
1251
+ ymin = yv[rect].min(axis=1)
1252
+ ymax = yv[rect].max(axis=1)
1253
+ xh = np.column_stack([xmin, xmax, xmax, xmin])
1254
+ yh = np.column_stack([ymin, ymin, ymax, ymax])
1255
+ if jsferic == 1:
1256
+ xz[rect], yz[rect] = _circumcenters3d_batch(xh, yh)
1257
+ else:
1258
+ xz[rect], yz[rect] = _circumcenters2d_batch(xh, yh)
1259
+ if 0 <= dcenterinside <= 1:
1260
+ x0 = np.min(xv, axis=1)
1261
+ y0 = yv[np.arange(faces.size), np.argmin(np.abs(yv), axis=1)]
1262
+ inside = _point_in_polygon_vec(xz, yz, xv, yv, x0, y0, jsferic=jsferic)
1263
+ # Pull-inside for the centers outside their face: intersect the
1264
+ # mass-center -> circumcenter segment with each face edge and keep
1265
+ # the crossing with the smallest ratio (as `_segment_edge_intersect`).
1266
+ out = np.where(~inside)[0]
1267
+ if out.size > 0:
1268
+ x0o, y0o = x0[out], y0[out]
1269
+ dx1 = _getdx_vec(
1270
+ x0o, y0o, mass_x[faces[out]], mass_y[faces[out]], jsferic
1271
+ )
1272
+ dy1 = _getdy_vec(
1273
+ x0o, y0o, mass_x[faces[out]], mass_y[faces[out]], jsferic
1274
+ )
1275
+ dx2 = _getdx_vec(x0o, y0o, xz[out], yz[out], jsferic)
1276
+ dy2 = _getdy_vec(x0o, y0o, xz[out], yz[out], jsferic)
1277
+ dxs = np.empty((out.size, n))
1278
+ dys = np.empty((out.size, n))
1279
+ for i in range(n):
1280
+ dxs[:, i] = _getdx_vec(x0o, y0o, xv[out, i], yv[out, i], jsferic)
1281
+ dys[:, i] = _getdy_vec(x0o, y0o, xv[out, i], yv[out, i], jsferic)
1282
+ best_t = np.full(out.size, 2.0)
1283
+ xcr = xz[out].copy()
1284
+ ycr = yz[out].copy()
1285
+ for i in range(n):
1286
+ ip1 = (i + 1) % n
1287
+ dxa, dya = dxs[:, i], dys[:, i]
1288
+ dxb, dyb = dxs[:, ip1], dys[:, ip1]
1289
+ den = (dx2 - dx1) * (dyb - dya) - (dy2 - dy1) * (dxb - dxa)
1290
+ hit = np.abs(den) >= 1e-15
1291
+ den_safe = np.where(hit, den, 1.0)
1292
+ t = (
1293
+ (dxa - dx1) * (dyb - dya) - (dya - dy1) * (dxb - dxa)
1294
+ ) / den_safe
1295
+ s = (
1296
+ (dxa - dx1) * (dy2 - dy1) - (dya - dy1) * (dx2 - dx1)
1297
+ ) / den_safe
1298
+ hit &= (0 <= t) & (t <= 1) & (0 <= s) & (s <= 1)
1299
+ xcr_l = dx1 + t * (dx2 - dx1)
1300
+ ycr_l = dy1 + t * (dy2 - dy1)
1301
+ if jsferic == 1:
1302
+ ycr_deg = y0o + ycr_l / EARTH_RADIUS_DEG2RAD
1303
+ xcr_deg = x0o + xcr_l / (
1304
+ EARTH_RADIUS_DEG2RAD * np.cos(ycr_deg * DEG2RAD)
1305
+ )
1306
+ else:
1307
+ ycr_deg = y0o + ycr_l
1308
+ xcr_deg = x0o + xcr_l
1309
+ upd = hit & (t < best_t)
1310
+ best_t = np.where(upd, t, best_t)
1311
+ xcr = np.where(upd, xcr_deg, xcr)
1312
+ ycr = np.where(upd, ycr_deg, ycr)
1313
+ xz[out] = xcr
1314
+ yz[out] = ycr
1315
+ face_x[faces] = xz
1316
+ face_y[faces] = yz
1317
+ return face_x, face_y
1318
+
1319
+
1320
+ def _dcosphi_sph_vec(
1321
+ x1: np.ndarray,
1322
+ y1: np.ndarray,
1323
+ x2: np.ndarray,
1324
+ y2: np.ndarray,
1325
+ x3: np.ndarray,
1326
+ y3: np.ndarray,
1327
+ x4: np.ndarray,
1328
+ y4: np.ndarray,
1329
+ ) -> np.ndarray:
1330
+ """Vectorized: all args (n_edges,) -> (n_edges,) |cos(phi)|."""
1331
+ p1 = _sphertocart3d_vec(x1, y1)
1332
+ p2 = _sphertocart3d_vec(x2, y2)
1333
+ p3 = _sphertocart3d_vec(x3, y3)
1334
+ p4 = _sphertocart3d_vec(x4, y4)
1335
+ d1 = p2 - p1
1336
+ d2 = p4 - p3
1337
+ r1 = np.sqrt(np.sum(d1 * d1, axis=1))
1338
+ r2 = np.sqrt(np.sum(d2 * d2, axis=1))
1339
+ dot = np.sum(d1 * d2, axis=1)
1340
+ cosphi = np.where(
1341
+ (r1 > 0) & (r2 > 0),
1342
+ dot / (r1 * r2),
1343
+ 0.0,
1344
+ )
1345
+ cosphi = np.clip(cosphi, -1.0, 1.0)
1346
+ return np.abs(cosphi)
1347
+
1348
+
1349
+ def _dcosphi_flat_vec(
1350
+ x1: np.ndarray,
1351
+ y1: np.ndarray,
1352
+ x2: np.ndarray,
1353
+ y2: np.ndarray,
1354
+ x3: np.ndarray,
1355
+ y3: np.ndarray,
1356
+ x4: np.ndarray,
1357
+ y4: np.ndarray,
1358
+ ) -> np.ndarray:
1359
+ """Planar (jsferic=0) counterpart of `_dcosphi_sph_vec`: |cos(phi)| in 2D."""
1360
+ d1x = x2 - x1
1361
+ d1y = y2 - y1
1362
+ d2x = x4 - x3
1363
+ d2y = y4 - y3
1364
+ r1 = np.sqrt(d1x * d1x + d1y * d1y)
1365
+ r2 = np.sqrt(d2x * d2x + d2y * d2y)
1366
+ dot = d1x * d2x + d1y * d2y
1367
+ cosphi = np.where(
1368
+ (r1 > 0) & (r2 > 0),
1369
+ dot / np.where((r1 > 0) & (r2 > 0), r1 * r2, 1.0),
1370
+ 0.0,
1371
+ )
1372
+ cosphi = np.clip(cosphi, -1.0, 1.0)
1373
+ return np.abs(cosphi)
1374
+
1375
+
1376
+ def _opposite_sides_vec(
1377
+ xk3: np.ndarray,
1378
+ yk3: np.ndarray,
1379
+ xk4: np.ndarray,
1380
+ yk4: np.ndarray,
1381
+ xc1: np.ndarray,
1382
+ yc1: np.ndarray,
1383
+ xc2: np.ndarray,
1384
+ yc2: np.ndarray,
1385
+ jsferic: int = 1,
1386
+ ) -> np.ndarray:
1387
+ """Vectorized version of _opposite_sides. Returns bool (n_edges,)."""
1388
+ ex = _getdx_vec(xk3, yk3, xk4, yk4, jsferic)
1389
+ ey = _getdy_vec(xk3, yk3, xk4, yk4, jsferic)
1390
+ c1 = ex * _getdy_vec(xk3, yk3, xc1, yc1, jsferic) - ey * _getdx_vec(
1391
+ xk3, yk3, xc1, yc1, jsferic
1392
+ )
1393
+ c2 = ex * _getdy_vec(xk3, yk3, xc2, yc2, jsferic) - ey * _getdx_vec(
1394
+ xk3, yk3, xc2, yc2, jsferic
1395
+ )
1396
+ return c1 * c2 < 0.0
1397
+
1398
+
1399
+ def _edge_faces_from_faces_edges(
1400
+ face_nodes: np.ndarray, edge_nodes: np.ndarray
1401
+ ) -> np.ndarray:
1402
+ """Rebuild edge_faces (1-based) from faces/edges connectivity."""
1403
+ n_edges = edge_nodes.shape[0]
1404
+ n_faces = face_nodes.shape[0]
1405
+ if n_faces == 0 or n_edges == 0:
1406
+ return np.zeros((n_edges, 2), dtype=np.int64)
1407
+ max_node = int(face_nodes.max())
1408
+ node_to_faces: List[List[int]] = [[] for _ in range(max_node + 1)]
1409
+ for f in range(n_faces):
1410
+ for n in face_nodes[f, :]:
1411
+ if n > 0:
1412
+ node_to_faces[int(n)].append(f)
1413
+ edge_faces = np.zeros((n_edges, 2), dtype=np.int64)
1414
+ for e in range(n_edges):
1415
+ n1, n2 = edge_nodes[e, :]
1416
+ if n1 <= 0 or n2 <= 0:
1417
+ continue
1418
+ common = list(set(node_to_faces[int(n1)]) & set(node_to_faces[int(n2)]))
1419
+ if len(common) >= 1:
1420
+ edge_faces[e, 0] = common[0] + 1
1421
+ if len(common) >= 2:
1422
+ edge_faces[e, 1] = common[1] + 1
1423
+ return edge_faces
1424
+
1425
+
1426
+ # Index conversion: UGRID/orthogonality use 1-based; we use 0-based internally.
1427
+
1428
+
1429
+ def _to_0b(arr: np.ndarray) -> np.ndarray:
1430
+ """Convert 1-based indices to 0-based (valid: 0..n-1, invalid: -1)."""
1431
+ out = np.where(arr > 0, arr - 1, -1)
1432
+ return out.astype(arr.dtype)
1433
+
1434
+
1435
+ def _to_1b(arr: np.ndarray) -> np.ndarray:
1436
+ """Convert 0-based indices to 1-based for meshkernel_orthogonality (invalid -1 -> 0)."""
1437
+ out = np.where(arr >= 0, arr + 1, 0)
1438
+ return out.astype(arr.dtype)
1439
+
1440
+
1441
+ # Orthogonality: |cosphi| on in-memory arrays (0-based in, cosphi_abs out)
1442
+
1443
+
1444
+ def compute_cosphi_abs_from_arrays(
1445
+ node_x: np.ndarray,
1446
+ node_y: np.ndarray,
1447
+ face_nodes: np.ndarray,
1448
+ edge_nodes: np.ndarray,
1449
+ edge_faces: Optional[np.ndarray],
1450
+ use_file_centers: bool = False,
1451
+ use_circumcenter_3d: bool = True,
1452
+ edge_indices: Optional[np.ndarray] = None,
1453
+ jsferic: int = 1,
1454
+ ) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
1455
+ """Compute ``|cos φ|`` on internal flow links.
1456
+
1457
+ Parameters
1458
+ ----------
1459
+ node_x, node_y : ndarray of shape (N,)
1460
+ Node coordinates.
1461
+ face_nodes : ndarray of shape (F, 3)
1462
+ 0-based face connectivity (invalid ``-1``).
1463
+ edge_nodes : ndarray of shape (E, 2)
1464
+ Edge endpoint indices.
1465
+ edge_faces : ndarray of shape (E, 2), optional
1466
+ Adjacent face indices; rebuilt if ``None``.
1467
+ use_file_centers : bool, optional
1468
+ Must be ``False`` (unsupported in-memory variant).
1469
+ use_circumcenter_3d : bool, optional
1470
+ Use 3D spherical circumcenters when ``True``.
1471
+ edge_indices : ndarray, optional
1472
+ Restrict computation to these edges.
1473
+ jsferic : int, optional
1474
+ ``1`` for spherical lon/lat; ``0`` for planar coordinates.
1475
+
1476
+ Returns
1477
+ -------
1478
+ edge_nodes : ndarray of shape (E, 2)
1479
+ Edge endpoints (1-based when full path is used).
1480
+ edge_faces : ndarray of shape (E, 2)
1481
+ Adjacent faces (1-based when full path is used).
1482
+ cosphi_abs : ndarray of shape (E,)
1483
+ Absolute cosine of angle between edge and circumcenter line.
1484
+ """
1485
+ if use_file_centers:
1486
+ raise ValueError(
1487
+ "use_file_centers=True is not supported in this in-memory variant."
1488
+ )
1489
+ if edge_indices is not None and edge_faces is not None:
1490
+ # Fast path: compute face centers/cosphi only for the requested edges
1491
+ # (0-based throughout; same formulas as the full path).
1492
+ cosphi_abs = _cosphi_abs_for_edges(
1493
+ node_x,
1494
+ node_y,
1495
+ face_nodes,
1496
+ edge_nodes,
1497
+ edge_faces,
1498
+ edge_indices,
1499
+ use_circumcenter_3d=use_circumcenter_3d,
1500
+ jsferic=jsferic,
1501
+ )
1502
+ return _to_1b(edge_nodes), _to_1b(edge_faces), cosphi_abs
1503
+
1504
+ # Convert to 1-based for orthogonality helpers (unchanged formulas)
1505
+ face_nodes = _to_1b(face_nodes)
1506
+ edge_nodes = _to_1b(edge_nodes)
1507
+ if edge_faces is not None:
1508
+ edge_faces = _to_1b(edge_faces)
1509
+
1510
+ if edge_faces is None:
1511
+ edge_faces = _edge_faces_from_faces_edges(face_nodes, edge_nodes)
1512
+
1513
+ n_faces = face_nodes.shape[0]
1514
+ n_edges = edge_nodes.shape[0]
1515
+
1516
+ # When edge_indices provided, only compute face centers for faces adjacent to those edges
1517
+ face_mask: Optional[np.ndarray] = None
1518
+ if edge_indices is not None:
1519
+ edge_indices = np.asarray(edge_indices, dtype=np.int64).ravel()
1520
+ face_mask = np.zeros(n_faces, dtype=bool)
1521
+ for e in edge_indices:
1522
+ if e < 0 or e >= n_edges:
1523
+ continue
1524
+ for j in (0, 1):
1525
+ f1b = edge_faces[e, j]
1526
+ if f1b > 0:
1527
+ face_mask[int(f1b) - 1] = True
1528
+
1529
+ if use_circumcenter_3d:
1530
+ face_x, face_y = _face_centers_circumcenter3d(
1531
+ node_x, node_y, face_nodes, face_mask=face_mask, jsferic=jsferic
1532
+ )
1533
+ else:
1534
+ face_x, face_y = _face_centers(
1535
+ node_x, node_y, face_nodes, face_mask=face_mask, jsferic=jsferic
1536
+ )
1537
+
1538
+ cosphi_abs = np.full(n_edges, np.nan, dtype=np.float64)
1539
+
1540
+ k3 = edge_nodes[:, 0]
1541
+ k4 = edge_nodes[:, 1]
1542
+ f1 = edge_faces[:, 0]
1543
+ f2 = edge_faces[:, 1]
1544
+ valid = (k3 > 0) & (k4 > 0) & (f1 > 0) & (f2 > 0) & (f1 != f2)
1545
+ idx = np.where(valid)[0]
1546
+ if edge_indices is not None:
1547
+ idx = np.intersect1d(idx, edge_indices, assume_unique=True)
1548
+ if idx.size == 0:
1549
+ return edge_nodes, edge_faces, cosphi_abs
1550
+
1551
+ k3i = k3[idx] - 1
1552
+ k4i = k4[idx] - 1
1553
+ f1i = f1[idx] - 1
1554
+ f2i = f2[idx] - 1
1555
+
1556
+ if not use_circumcenter_3d:
1557
+ opp = _opposite_sides_vec(
1558
+ node_x[k3i],
1559
+ node_y[k3i],
1560
+ node_x[k4i],
1561
+ node_y[k4i],
1562
+ face_x[f1i],
1563
+ face_y[f1i],
1564
+ face_x[f2i],
1565
+ face_y[f2i],
1566
+ jsferic=jsferic,
1567
+ )
1568
+ idx = idx[opp]
1569
+ k3i, k4i, f1i, f2i = k3i[opp], k4i[opp], f1i[opp], f2i[opp]
1570
+ if idx.size == 0:
1571
+ return edge_nodes, edge_faces, cosphi_abs
1572
+
1573
+ dx_edge = _getdx_vec(node_x[k3i], node_y[k3i], node_x[k4i], node_y[k4i], jsferic)
1574
+ dy_edge = _getdy_vec(node_x[k3i], node_y[k3i], node_x[k4i], node_y[k4i], jsferic)
1575
+ d = np.hypot(dx_edge, dy_edge)
1576
+ valid_d = d >= 1.0e-6
1577
+ idx = idx[valid_d]
1578
+ k3i, k4i, f1i, f2i = k3i[valid_d], k4i[valid_d], f1i[valid_d], f2i[valid_d]
1579
+ if idx.size == 0:
1580
+ return edge_nodes, edge_faces, cosphi_abs
1581
+
1582
+ dcosphi = _dcosphi_sph_vec if jsferic == 1 else _dcosphi_flat_vec
1583
+ cosphi_abs[idx] = dcosphi(
1584
+ face_x[f1i],
1585
+ face_y[f1i],
1586
+ face_x[f2i],
1587
+ face_y[f2i],
1588
+ node_x[k3i],
1589
+ node_y[k3i],
1590
+ node_x[k4i],
1591
+ node_y[k4i],
1592
+ )
1593
+ return edge_nodes, edge_faces, cosphi_abs
1594
+
1595
+
1596
+ def _cosphi_abs_for_edges(
1597
+ node_x: np.ndarray,
1598
+ node_y: np.ndarray,
1599
+ face_nodes: np.ndarray,
1600
+ edge_nodes: np.ndarray,
1601
+ edge_faces: np.ndarray,
1602
+ edge_indices: np.ndarray,
1603
+ use_circumcenter_3d: bool = True,
1604
+ jsferic: int = 1,
1605
+ ) -> np.ndarray:
1606
+ """
1607
+ |cosphi| restricted to `edge_indices` (0-based inputs, invalid = -1),
1608
+ computing face centers only for the adjacent faces. Returns a full-length
1609
+ (n_edges,) array, NaN outside the requested edges — the same values the
1610
+ full `compute_cosphi_abs_from_arrays` produces for those edges.
1611
+ """
1612
+ n_faces = face_nodes.shape[0]
1613
+ n_edges = edge_nodes.shape[0]
1614
+ cosphi_abs = np.full(n_edges, np.nan, dtype=np.float64)
1615
+
1616
+ edge_indices = np.asarray(edge_indices, dtype=np.int64).ravel()
1617
+ idx = np.unique(edge_indices[(edge_indices >= 0) & (edge_indices < n_edges)])
1618
+ if idx.size == 0:
1619
+ return cosphi_abs
1620
+ k3 = edge_nodes[idx, 0]
1621
+ k4 = edge_nodes[idx, 1]
1622
+ f1 = edge_faces[idx, 0]
1623
+ f2 = edge_faces[idx, 1]
1624
+ valid = (
1625
+ (k3 >= 0)
1626
+ & (k4 >= 0)
1627
+ & (f1 >= 0)
1628
+ & (f2 >= 0)
1629
+ & (f1 != f2)
1630
+ & (f1 < n_faces)
1631
+ & (f2 < n_faces)
1632
+ )
1633
+ idx, k3, k4, f1, f2 = idx[valid], k3[valid], k4[valid], f1[valid], f2[valid]
1634
+ if idx.size == 0:
1635
+ return cosphi_abs
1636
+
1637
+ faces_needed = np.unique(np.concatenate([f1, f2]))
1638
+ sub_face_nodes_1b = _to_1b(np.asarray(face_nodes)[faces_needed])
1639
+ if use_circumcenter_3d:
1640
+ fx, fy = _face_centers_circumcenter3d(
1641
+ node_x, node_y, sub_face_nodes_1b, jsferic=jsferic
1642
+ )
1643
+ else:
1644
+ fx, fy = _face_centers(node_x, node_y, sub_face_nodes_1b, jsferic=jsferic)
1645
+ p1 = np.searchsorted(faces_needed, f1)
1646
+ p2 = np.searchsorted(faces_needed, f2)
1647
+
1648
+ if not use_circumcenter_3d:
1649
+ opp = _opposite_sides_vec(
1650
+ node_x[k3],
1651
+ node_y[k3],
1652
+ node_x[k4],
1653
+ node_y[k4],
1654
+ fx[p1],
1655
+ fy[p1],
1656
+ fx[p2],
1657
+ fy[p2],
1658
+ jsferic=jsferic,
1659
+ )
1660
+ idx, k3, k4, p1, p2 = idx[opp], k3[opp], k4[opp], p1[opp], p2[opp]
1661
+ if idx.size == 0:
1662
+ return cosphi_abs
1663
+
1664
+ dx_edge = _getdx_vec(node_x[k3], node_y[k3], node_x[k4], node_y[k4], jsferic)
1665
+ dy_edge = _getdy_vec(node_x[k3], node_y[k3], node_x[k4], node_y[k4], jsferic)
1666
+ d = np.hypot(dx_edge, dy_edge)
1667
+ valid_d = d >= 1.0e-6
1668
+ idx, k3, k4, p1, p2 = (
1669
+ idx[valid_d],
1670
+ k3[valid_d],
1671
+ k4[valid_d],
1672
+ p1[valid_d],
1673
+ p2[valid_d],
1674
+ )
1675
+ if idx.size == 0:
1676
+ return cosphi_abs
1677
+
1678
+ dcosphi = _dcosphi_sph_vec if jsferic == 1 else _dcosphi_flat_vec
1679
+ cosphi_abs[idx] = dcosphi(
1680
+ fx[p1],
1681
+ fy[p1],
1682
+ fx[p2],
1683
+ fy[p2],
1684
+ node_x[k3],
1685
+ node_y[k3],
1686
+ node_x[k4],
1687
+ node_y[k4],
1688
+ )
1689
+ return cosphi_abs
1690
+
1691
+
1692
+ # Utility structures for zones
1693
+
1694
+
1695
+ @dataclass
1696
+ class MeshData:
1697
+ node_x: np.ndarray
1698
+ node_y: np.ndarray
1699
+ face_nodes: np.ndarray
1700
+ edge_nodes: np.ndarray
1701
+ edge_faces: np.ndarray
1702
+
1703
+
1704
+ def _classify_zone_nodes(
1705
+ face_nodes: np.ndarray,
1706
+ edge_nodes: np.ndarray,
1707
+ faces_zone: Set[int],
1708
+ ) -> Tuple[np.ndarray, np.ndarray]:
1709
+ """
1710
+ Identify internal vs boundary nodes for a given zone.
1711
+
1712
+ - Internal node: belongs only to faces in the zone AND has no edge
1713
+ to a node outside the zone.
1714
+ - Boundary node: any zone node that is not internal.
1715
+ """
1716
+ zone_nodes = zone_nodes_from_faces(face_nodes, faces_zone)
1717
+ if zone_nodes.size == 0:
1718
+ return (
1719
+ np.empty(0, dtype=np.int64),
1720
+ np.empty(0, dtype=np.int64),
1721
+ )
1722
+
1723
+ max_node = int(
1724
+ max(int(zone_nodes.max()), int(edge_nodes.max()), int(face_nodes.max()))
1725
+ )
1726
+ in_zone = np.zeros(max_node + 2, dtype=bool)
1727
+ in_zone[zone_nodes] = True
1728
+
1729
+ # Node appears in a face outside the zone -> boundary.
1730
+ zone_face_mask = np.zeros(face_nodes.shape[0], dtype=bool)
1731
+ zone_face_mask[np.fromiter(faces_zone, dtype=np.int64, count=len(faces_zone))] = (
1732
+ True
1733
+ )
1734
+ out_nodes = face_nodes[~zone_face_mask, :].ravel()
1735
+ out_nodes = out_nodes[out_nodes >= 0]
1736
+ appears_outside = np.zeros(max_node + 2, dtype=bool)
1737
+ appears_outside[out_nodes] = True
1738
+
1739
+ # Node connected by an edge to a node outside the zone -> boundary.
1740
+ n1 = edge_nodes[:, 0]
1741
+ n2 = edge_nodes[:, 1]
1742
+ ok = (n1 >= 0) & (n2 >= 0)
1743
+ n1c = np.where(ok, n1, max_node + 1)
1744
+ n2c = np.where(ok, n2, max_node + 1)
1745
+ edge_to_outside = np.zeros(max_node + 2, dtype=bool)
1746
+ m1 = ok & in_zone[n1c] & ~in_zone[n2c]
1747
+ m2 = ok & in_zone[n2c] & ~in_zone[n1c]
1748
+ edge_to_outside[n1c[m1]] = True
1749
+ edge_to_outside[n2c[m2]] = True
1750
+
1751
+ zone_sorted = np.sort(np.asarray(zone_nodes, dtype=np.int64))
1752
+ is_boundary_sorted = (appears_outside | edge_to_outside)[zone_sorted]
1753
+ internal = zone_sorted[~is_boundary_sorted]
1754
+ boundary = zone_sorted[is_boundary_sorted]
1755
+ return internal, boundary
1756
+
1757
+
1758
+ # Zone graph (face adjacency, BFS, zone nodes)
1759
+
1760
+
1761
+ def build_face_adjacency(edge_faces: np.ndarray, n_faces: int) -> List[List[int]]:
1762
+ """Build a face adjacency list from shared edges.
1763
+
1764
+ Parameters
1765
+ ----------
1766
+ edge_faces : ndarray of shape (E, 2)
1767
+ Adjacent face indices per edge.
1768
+ n_faces : int
1769
+ Total number of faces.
1770
+
1771
+ Returns
1772
+ -------
1773
+ neigh : list of list of int
1774
+ Neighbouring face indices for each face (sorted, unique).
1775
+ """
1776
+ ef = np.asarray(edge_faces)
1777
+ m = (ef[:, 0] >= 0) & (ef[:, 1] >= 0) & (ef[:, 0] != ef[:, 1])
1778
+ a = ef[m, 0].astype(np.int64)
1779
+ b = ef[m, 1].astype(np.int64)
1780
+ src = np.concatenate([a, b])
1781
+ dst = np.concatenate([b, a])
1782
+ order = np.lexsort((dst, src))
1783
+ src, dst = src[order], dst[order]
1784
+ keep = (
1785
+ np.r_[True, (src[1:] != src[:-1]) | (dst[1:] != dst[:-1])]
1786
+ if src.size
1787
+ else np.zeros(0, dtype=bool)
1788
+ )
1789
+ src, dst = src[keep], dst[keep]
1790
+ counts = (
1791
+ np.bincount(src, minlength=n_faces)
1792
+ if src.size
1793
+ else np.zeros(n_faces, dtype=np.int64)
1794
+ )
1795
+ dst_list = dst.tolist()
1796
+ neigh: List[List[int]] = []
1797
+ idx = 0
1798
+ for f in range(n_faces):
1799
+ c = int(counts[f])
1800
+ neigh.append(dst_list[idx : idx + c])
1801
+ idx += c
1802
+ return neigh
1803
+
1804
+
1805
+ def bfs_faces(
1806
+ start_faces: Iterable[int],
1807
+ neighbors: List[List[int]],
1808
+ max_depth: int,
1809
+ ) -> Set[int]:
1810
+ """Collect faces within a given topological depth via BFS.
1811
+
1812
+ Parameters
1813
+ ----------
1814
+ start_faces : iterable of int
1815
+ Seed face indices.
1816
+ neighbors : list of list of int
1817
+ Face adjacency graph.
1818
+ max_depth : int
1819
+ Maximum BFS depth (inclusive).
1820
+
1821
+ Returns
1822
+ -------
1823
+ visited : set of int
1824
+ Faces at distance ``<= max_depth`` from any seed.
1825
+ """
1826
+ visited: Set[int] = set()
1827
+ frontier: Set[int] = set(int(f) for f in start_faces)
1828
+ depth = 0
1829
+ while frontier and depth <= max_depth:
1830
+ visited.update(frontier)
1831
+ next_frontier: Set[int] = set()
1832
+ for f in frontier:
1833
+ for g in neighbors[f]:
1834
+ if g not in visited:
1835
+ next_frontier.add(g)
1836
+ frontier = next_frontier
1837
+ depth += 1
1838
+ return visited
1839
+
1840
+
1841
+ def zone_nodes_from_faces(face_nodes: np.ndarray, faces_zone: Set[int]) -> np.ndarray:
1842
+ """Return sorted unique node indices used by a face subset.
1843
+
1844
+ Parameters
1845
+ ----------
1846
+ face_nodes : ndarray of shape (F, 3)
1847
+ Face connectivity (0-based).
1848
+ faces_zone : set of int
1849
+ Face indices defining the zone.
1850
+
1851
+ Returns
1852
+ -------
1853
+ nodes : ndarray of shape (M,)
1854
+ Unique node indices in the zone.
1855
+ """
1856
+ if not faces_zone:
1857
+ return np.empty(0, dtype=np.int64)
1858
+ f_idx = np.fromiter(faces_zone, dtype=np.int64)
1859
+ nodes = face_nodes[f_idx, :].ravel()
1860
+ nodes = nodes[nodes >= 0]
1861
+ return np.unique(nodes.astype(np.int64))
1862
+
1863
+
1864
+ def apply_combined_ortho_smoother_to_zone(
1865
+ mesh: MeshData,
1866
+ faces_zone: Set[int],
1867
+ cosphi_abs: np.ndarray,
1868
+ cosphi_threshold: float,
1869
+ it: int,
1870
+ max_global_iter: int,
1871
+ n_inner: int = 2,
1872
+ mu_max: float = 0.4,
1873
+ relax: float = 0.2,
1874
+ small_edges_global: Optional[np.ndarray] = None,
1875
+ removesmalllinkstrsh: float = 0.1,
1876
+ verbose: bool = True,
1877
+ jsferic: int = 1,
1878
+ smalllink_priority: bool = False,
1879
+ fixed_mask: Optional[np.ndarray] = None,
1880
+ ) -> Tuple[bool, bool]:
1881
+ """Apply combined Laplacian smoothing and orthogonality displacement to a zone.
1882
+
1883
+ Parameters
1884
+ ----------
1885
+ mesh : MeshData
1886
+ Mesh to modify in place.
1887
+ faces_zone : set of int
1888
+ Face indices in the zone.
1889
+ cosphi_abs : ndarray of shape (E,)
1890
+ Current ``|cos φ|`` per edge.
1891
+ cosphi_threshold : float
1892
+ Target maximum ``|cos φ|``.
1893
+ it : int
1894
+ Global iteration index (controls blending factor ``mu``).
1895
+ max_global_iter : int
1896
+ Total global iterations (for ``mu`` schedule).
1897
+ n_inner : int, optional
1898
+ Inner smoothing sub-iterations.
1899
+ mu_max : float, optional
1900
+ Maximum orthogonality blend factor.
1901
+ relax : float, optional
1902
+ Relaxation on coordinate updates.
1903
+ small_edges_global : ndarray, optional
1904
+ Global small-link edge indices.
1905
+ removesmalllinkstrsh : float, optional
1906
+ Small flow-link threshold.
1907
+ verbose : bool, optional
1908
+ Log per-zone accept/reject decisions.
1909
+ jsferic : int, optional
1910
+ ``1`` for spherical lon/lat; ``0`` for planar coordinates.
1911
+ smalllink_priority : bool, optional
1912
+ Prioritize small-link displacement over orthogonality.
1913
+ fixed_mask : ndarray of bool of shape (N,), optional
1914
+ Nodes flagged ``True`` are never displaced.
1915
+
1916
+ Returns
1917
+ -------
1918
+ improved : bool
1919
+ ``True`` if the zone quality improved.
1920
+ zone_was_good : bool
1921
+ ``True`` if the zone already met thresholds before updating.
1922
+ """
1923
+ if not faces_zone:
1924
+ return (False, False)
1925
+
1926
+ # Zone nodes (0-based indices)
1927
+ zone_nodes = zone_nodes_from_faces(mesh.face_nodes, faces_zone)
1928
+ if zone_nodes.size == 0:
1929
+ return (False, False)
1930
+
1931
+ # Internal / boundary classification
1932
+ internal_global, boundary_global = _classify_zone_nodes(
1933
+ mesh.face_nodes, mesh.edge_nodes, faces_zone
1934
+ )
1935
+ internal_set: Set[int] = set(int(n) for n in internal_global.tolist())
1936
+
1937
+ # Neighbors in the zone (for smooth term); also all neighbors (incl. out-of-zone) for boundary
1938
+ zone_set: Set[int] = set(int(n) for n in zone_nodes.tolist())
1939
+ neighbors: Dict[int, Set[int]] = {int(n): set() for n in zone_nodes.tolist()}
1940
+ all_neighbors: Dict[int, Set[int]] = {int(n): set() for n in zone_nodes.tolist()}
1941
+ en1 = mesh.edge_nodes[:, 0]
1942
+ en2 = mesh.edge_nodes[:, 1]
1943
+ ok_e = (en1 >= 0) & (en2 >= 0)
1944
+ zmask = np.zeros(
1945
+ int(max(zone_nodes.max(), en1.max(initial=0), en2.max(initial=0))) + 2,
1946
+ dtype=bool,
1947
+ )
1948
+ zmask[zone_nodes] = True
1949
+ in1 = ok_e & zmask[np.where(ok_e, en1, -1)]
1950
+ in2 = ok_e & zmask[np.where(ok_e, en2, -1)]
1951
+ edges_in_zone: List[int] = np.where(in1 & in2)[0].tolist()
1952
+ ring_edges: List[int] = np.where(in1 ^ in2)[0].tolist()
1953
+ for e in edges_in_zone:
1954
+ g1 = int(en1[e])
1955
+ g2 = int(en2[e])
1956
+ neighbors[g1].add(g2)
1957
+ neighbors[g2].add(g1)
1958
+ for e in np.where(in1 | in2)[0]:
1959
+ g1 = int(en1[e])
1960
+ g2 = int(en2[e])
1961
+ if in1[e]:
1962
+ all_neighbors[g1].add(g2)
1963
+ if in2[e]:
1964
+ all_neighbors[g2].add(g1)
1965
+ # Out-of-zone neighbors for boundary nodes (for weighted Laplacian)
1966
+ neighbors_out: Dict[int, Set[int]] = {}
1967
+ for gid in zone_nodes:
1968
+ gid = int(gid)
1969
+ out_set = all_neighbors[gid] - zone_set
1970
+ if out_set:
1971
+ neighbors_out[gid] = out_set
1972
+ w_in, w_out = 1.0, 0.3
1973
+
1974
+ # If no internal edge in the zone, nothing to do
1975
+ if not edges_in_zone:
1976
+ return (False, False)
1977
+
1978
+ edges_in_zone_arr = np.array(edges_in_zone, dtype=int)
1979
+ # For acceptance/rollback tests, also include a "crown" of edges crossing the zone boundary
1980
+ if ring_edges:
1981
+ eval_edges_arr = np.array(edges_in_zone + ring_edges, dtype=int)
1982
+ else:
1983
+ eval_edges_arr = edges_in_zone_arr
1984
+ # Small-link edges in this zone (when global small list is provided)
1985
+ small_edges_set = (
1986
+ set(small_edges_global.tolist())
1987
+ if small_edges_global is not None and small_edges_global.size > 0
1988
+ else set()
1989
+ )
1990
+ small_in_zone = [e for e in edges_in_zone if e in small_edges_set]
1991
+ n_small_zone_before = 0
1992
+ # Topology is fixed during this zone call: precompute the per-face interior
1993
+ # edge counts used by every small-link evaluation below (recomputing them is
1994
+ # an O(n_edges) scan per trial and dominated the runtime).
1995
+ num_interior_pre: Optional[np.ndarray] = None
1996
+ if small_edges_global is not None and small_edges_global.size > 0:
1997
+ num_interior_pre = _num_interior_edges_per_face(
1998
+ mesh.edge_faces, mesh.face_nodes.shape[0]
1999
+ )
2000
+ _, small_zone_list = compute_small_links_from_arrays(
2001
+ mesh.node_x,
2002
+ mesh.node_y,
2003
+ mesh.face_nodes,
2004
+ mesh.edge_nodes,
2005
+ mesh.edge_faces,
2006
+ removesmalllinkstrsh=removesmalllinkstrsh,
2007
+ edge_indices=edges_in_zone_arr,
2008
+ jsferic=jsferic,
2009
+ num_interior=num_interior_pre,
2010
+ )
2011
+ n_small_zone_before = len(small_zone_list)
2012
+
2013
+ # Local quality before displacement (max |cosphi| on zone edges)
2014
+ cos_zone_before = np.abs(cosphi_abs[edges_in_zone_arr])
2015
+ mask_before = np.isfinite(cos_zone_before)
2016
+ if not np.any(mask_before):
2017
+ return (False, False)
2018
+ max_cosphi_before = float(np.max(cos_zone_before[mask_before]))
2019
+ min_cosphi_before = float(np.min(cos_zone_before[mask_before]))
2020
+
2021
+ # Build a distance-based weight in the zone: nodes near the worst edges get full ortho;
2022
+ # nodes near the buffer boundary get a reduced ortho weight to avoid degrading good areas.
2023
+ core_nodes: Set[int] = set()
2024
+ for e in edges_in_zone:
2025
+ if abs(cosphi_abs[e]) > cosphi_threshold:
2026
+ n1, n2 = mesh.edge_nodes[e, :]
2027
+ if n1 >= 0:
2028
+ core_nodes.add(int(n1))
2029
+ if n2 >= 0:
2030
+ core_nodes.add(int(n2))
2031
+ dist_weight: Dict[int, float] = {}
2032
+ if core_nodes:
2033
+ # BFS on zone graph (neighbors) starting from core_nodes
2034
+ INF = 10**9
2035
+ dist: Dict[int, int] = {int(n): INF for n in zone_nodes.tolist()}
2036
+ q: deque[int] = deque()
2037
+ for n in core_nodes:
2038
+ if n in dist:
2039
+ dist[n] = 0
2040
+ q.append(n)
2041
+ while q:
2042
+ u = q.popleft()
2043
+ du = dist[u]
2044
+ for v in neighbors.get(u, set()):
2045
+ if v in dist and dist[v] == INF:
2046
+ dist[v] = du + 1
2047
+ q.append(v)
2048
+ # Convert distances to weights in [0.2, 1.0]
2049
+ finite_d = [d for d in dist.values() if d < INF]
2050
+ if finite_d:
2051
+ max_d = max(finite_d)
2052
+ decay = max(1.0, max_d / 2.0)
2053
+ for n, d in dist.items():
2054
+ if d >= INF:
2055
+ w = 0.5
2056
+ else:
2057
+ w = 1.0 - float(d) / (decay + 1e-9)
2058
+ w = max(0.2, min(1.0, w))
2059
+ dist_weight[n] = w
2060
+ else:
2061
+ # No clearly bad edges in this zone: uniform weight
2062
+ for n in zone_nodes.tolist():
2063
+ dist_weight[int(n)] = 1.0
2064
+
2065
+ # When zone is already below cosphi threshold (e.g. only small-link), use smaller steps
2066
+ # and no Laplacian (scale_smooth=0) to avoid degrading orthogonality at zone boundaries
2067
+ zone_already_good = max_cosphi_before <= cosphi_threshold
2068
+ relax_zone = 0.12 if zone_already_good else relax
2069
+ scale_smooth = 0.0 if zone_already_good else 1.0
2070
+ # Very-good zones (max_cosphi already very low, only small-link): cap relax further
2071
+ cosphi_very_good = 0.05
2072
+ if (
2073
+ zone_already_good
2074
+ and max_cosphi_before < cosphi_very_good
2075
+ and n_small_zone_before > 0
2076
+ ):
2077
+ relax_zone = min(relax_zone, 0.08)
2078
+
2079
+ # Skip [good] zones with no small links: nothing to do, avoid useless rollbacks
2080
+ if zone_already_good and n_small_zone_before == 0:
2081
+ if verbose:
2082
+ print(
2083
+ f" [ZONE] it={it} faces={len(faces_zone)} [good] skip (n_small_zone=0, nothing to do)"
2084
+ )
2085
+ return (False, True)
2086
+
2087
+ # Snapshot of zone node coordinates (for possible rollback)
2088
+ zone_idx = zone_nodes.astype(np.int64) # 0-based
2089
+ x_old = mesh.node_x[zone_idx].copy()
2090
+ y_old = mesh.node_y[zone_idx].copy()
2091
+
2092
+ # Factor mu(it) increasing from 0 to mu_max
2093
+ if max_global_iter > 0:
2094
+ mu_it = mu_max * float(it + 1) / float(max_global_iter)
2095
+ mu_it = max(0.0, min(mu_max, mu_it))
2096
+ else:
2097
+ mu_it = mu_max
2098
+
2099
+ alpha = DEFAULT_ORTHO_ALPHA # Base amplitude of ortho displacement per edge
2100
+ # Cumulative targeted ortho displacement (kept separate so the line search
2101
+ # can retry it alone when the combined delta fails acceptance).
2102
+ ortho_cum_x = np.zeros_like(mesh.node_x)
2103
+ ortho_cum_y = np.zeros_like(mesh.node_y)
2104
+ tag = "good" if zone_already_good else "bad"
2105
+ # Log start for this zone (relax_zone, scale_smooth help tune when many rollbacks)
2106
+ if verbose:
2107
+ print(
2108
+ f" [ZONE] it={it} faces={len(faces_zone)} [{tag}] "
2109
+ f"max|cosphi|_before={max_cosphi_before:.6f} min|cosphi|_before={min_cosphi_before:.6f} "
2110
+ f"relax_zone={relax_zone:.2f} scale_smooth={scale_smooth:.2f} mu={mu_it:.3f} n_small_zone={n_small_zone_before}"
2111
+ )
2112
+
2113
+ for inner_i in range(max(1, int(n_inner))):
2114
+ # Smooth term (local Laplacian); boundary: include out-of-zone neighbors with weight w_out
2115
+ dx_s = np.zeros_like(mesh.node_x)
2116
+ dy_s = np.zeros_like(mesh.node_y)
2117
+ for g in zone_nodes:
2118
+ gid = int(g)
2119
+ if gid not in internal_set:
2120
+ continue
2121
+ neigh = neighbors.get(gid, set())
2122
+ if not neigh:
2123
+ continue
2124
+ out_neigh = neighbors_out.get(gid, set())
2125
+ if not out_neigh:
2126
+ idxs = np.array(list(neigh), dtype=np.int64)
2127
+ dx_s[gid] = mesh.node_x[idxs].mean() - mesh.node_x[gid]
2128
+ dy_s[gid] = mesh.node_y[idxs].mean() - mesh.node_y[gid]
2129
+ else:
2130
+ idxs_in = np.array(list(neigh), dtype=np.int64)
2131
+ idxs_out = np.array(list(out_neigh), dtype=np.int64)
2132
+ bary_in_x = mesh.node_x[idxs_in].mean()
2133
+ bary_in_y = mesh.node_y[idxs_in].mean()
2134
+ bary_out_x = mesh.node_x[idxs_out].mean()
2135
+ bary_out_y = mesh.node_y[idxs_out].mean()
2136
+ bary_x = (bary_in_x * w_in + bary_out_x * w_out) / (w_in + w_out)
2137
+ bary_y = (bary_in_y * w_in + bary_out_y * w_out) / (w_in + w_out)
2138
+ dx_s[gid] = bary_x - mesh.node_x[gid]
2139
+ dy_s[gid] = bary_y - mesh.node_y[gid]
2140
+
2141
+ # Ortho term: small corrections on the worst edges in the zone.
2142
+ # Skip when zone is already below threshold (e.g. zone only for small-link) to save cost.
2143
+ dx_o = np.zeros_like(mesh.node_x)
2144
+ dy_o = np.zeros_like(mesh.node_y)
2145
+ if edges_in_zone and max_cosphi_before > cosphi_threshold:
2146
+ cos_vals = np.abs(cosphi_abs[edges_in_zone])
2147
+ bad_mask = cos_vals > cosphi_threshold
2148
+ if np.any(bad_mask):
2149
+ bad_idx = np.where(bad_mask)[0]
2150
+ # Limit number of edges to stay local
2151
+ sort_loc = np.argsort(cos_vals[bad_idx])[::-1]
2152
+ top_loc = bad_idx[sort_loc[: min(3, sort_loc.size)]]
2153
+ for li in top_loc:
2154
+ e = edges_in_zone[li]
2155
+ k3, k4 = mesh.edge_nodes[e, :]
2156
+ if k3 < 0 or k4 < 0:
2157
+ continue
2158
+ g3 = int(k3)
2159
+ g4 = int(k4)
2160
+ move3 = g3 in internal_set
2161
+ move4 = g4 in internal_set
2162
+ # Movable apex (opposite) vertices of the two adjacent
2163
+ # faces: for obtuse pairs (pulled-inside circumcenters)
2164
+ # moving the apex is often the only effective correction.
2165
+ opps: List[int] = []
2166
+ for fe in (int(mesh.edge_faces[e, 0]), int(mesh.edge_faces[e, 1])):
2167
+ if fe >= 0:
2168
+ for vtx in mesh.face_nodes[fe, :3]:
2169
+ vtx = int(vtx)
2170
+ if (
2171
+ vtx >= 0
2172
+ and vtx != g3
2173
+ and vtx != g4
2174
+ and vtx in internal_set
2175
+ ):
2176
+ opps.append(vtx)
2177
+ if not (move3 or move4 or opps):
2178
+ continue
2179
+ x3, y3 = mesh.node_x[g3], mesh.node_y[g3]
2180
+ x4, y4 = mesh.node_x[g4], mesh.node_y[g4]
2181
+ ex = x4 - x3
2182
+ ey = y4 - y3
2183
+ norm_e = np.hypot(ex, ey)
2184
+ if norm_e < 1.0e-8:
2185
+ continue
2186
+ # Fresh |cosphi| at the current positions: the global
2187
+ # cosphi_abs snapshot goes stale as nodes move, and a stale
2188
+ # base makes the improvement test accept no-ops forever.
2189
+ base_arr = _cosphi_abs_for_edges(
2190
+ mesh.node_x,
2191
+ mesh.node_y,
2192
+ mesh.face_nodes,
2193
+ mesh.edge_nodes,
2194
+ mesh.edge_faces,
2195
+ np.array([e]),
2196
+ use_circumcenter_3d=True,
2197
+ jsferic=jsferic,
2198
+ )
2199
+ base_val = float(np.abs(base_arr[e]))
2200
+ if not np.isfinite(base_val):
2201
+ continue
2202
+ w_excess = base_val - cosphi_threshold
2203
+ if w_excess <= 0.0:
2204
+ continue
2205
+ px = -ey / norm_e
2206
+ py = ex / norm_e
2207
+ midx = 0.5 * (x3 + x4)
2208
+ midy = 0.5 * (y3 + y4)
2209
+ # Candidate move sets: endpoints perpendicular to the edge
2210
+ # (both senses) and each movable apex radially from the
2211
+ # edge midpoint (both senses).
2212
+ candidates: List[List[Tuple[int, float, float]]] = []
2213
+ for s in (1.0, -1.0):
2214
+ moves: List[Tuple[int, float, float]] = []
2215
+ if move3:
2216
+ moves.append((g3, -s * px, -s * py))
2217
+ if move4:
2218
+ moves.append((g4, s * px, s * py))
2219
+ if moves:
2220
+ candidates.append(moves)
2221
+ for vo in opps:
2222
+ rx = float(mesh.node_x[vo]) - midx
2223
+ ry = float(mesh.node_y[vo]) - midy
2224
+ rn = np.hypot(rx, ry)
2225
+ if rn < 1.0e-12:
2226
+ continue
2227
+ for s in (1.0, -1.0):
2228
+ candidates.append([(vo, s * rx / rn, s * ry / rn)])
2229
+ # Line search over fractions of the local edge length: the
2230
+ # zone-level acceptance (strict improvement + rollback)
2231
+ # still gates the combined displacement, so trying larger
2232
+ # steps here is safe. An absolute step (as before) is
2233
+ # metres-invisible on projected meshes and kilometres-huge
2234
+ # on lon/lat ones.
2235
+ best_improve = 0.0
2236
+ best_moves: Optional[List[Tuple[int, float, float]]] = None
2237
+ done = False
2238
+ for moves in candidates:
2239
+ for frac in (0.3, 0.1, 0.03):
2240
+ step = frac * norm_e
2241
+ trial_x = mesh.node_x.copy()
2242
+ trial_y = mesh.node_y.copy()
2243
+ for nd, udx, udy in moves:
2244
+ trial_x[nd] += step * udx
2245
+ trial_y[nd] += step * udy
2246
+ cosphi_trial = _cosphi_abs_for_edges(
2247
+ trial_x,
2248
+ trial_y,
2249
+ mesh.face_nodes,
2250
+ mesh.edge_nodes,
2251
+ mesh.edge_faces,
2252
+ np.array([e]),
2253
+ use_circumcenter_3d=True,
2254
+ jsferic=jsferic,
2255
+ )
2256
+ new_val = float(np.abs(cosphi_trial[e]))
2257
+ if not np.isfinite(new_val):
2258
+ continue
2259
+ improve = base_val - new_val
2260
+ if improve > best_improve and new_val < base_val:
2261
+ best_improve = improve
2262
+ best_moves = [
2263
+ (nd, step * udx, step * udy)
2264
+ for nd, udx, udy in moves
2265
+ ]
2266
+ if new_val <= cosphi_threshold:
2267
+ done = True
2268
+ break
2269
+ if done:
2270
+ break
2271
+ if best_moves is not None:
2272
+ for nd, ddx, ddy in best_moves:
2273
+ dx_o[nd] += ddx
2274
+ dy_o[nd] += ddy
2275
+
2276
+ # Small-link term: move opposite vertices along circumcenter separation
2277
+ dx_small = np.zeros_like(mesh.node_x)
2278
+ dy_small = np.zeros_like(mesh.node_y)
2279
+ alpha_small = 0.05
2280
+ beta_small = 0.5
2281
+ max_small_edges_per_zone = 6
2282
+ step_small = min(alpha_small * 0.12, 0.004)
2283
+ # Much less aggressive: only in fairly good zones, and smaller steps
2284
+ # - Only apply when the zone is already reasonably orthogonal (max_cosphi_before <= 0.30)
2285
+ # - Softer aggressive_factor
2286
+ # - Clamp step_meters tightly to avoid overshoot and cosphi blow-up
2287
+ if n_small_zone_before <= 1:
2288
+ aggressive_factor = 1.5
2289
+ elif n_small_zone_before <= 3:
2290
+ aggressive_factor = 1.2
2291
+ else:
2292
+ aggressive_factor = 1.0
2293
+ # In small-link priority mode (triangles-only pipeline: no quad merge
2294
+ # available) the term must fire for every good zone and every inner
2295
+ # iteration — the 0.30 gate and parity skip were tuned for the merge
2296
+ # pipeline where remaining links were merge's job.
2297
+ if small_in_zone and (
2298
+ (smalllink_priority and zone_already_good)
2299
+ or (zone_already_good and max_cosphi_before <= 0.30 and (inner_i % 2 == 0))
2300
+ ):
2301
+ _, small_zone_current = compute_small_links_from_arrays(
2302
+ mesh.node_x,
2303
+ mesh.node_y,
2304
+ mesh.face_nodes,
2305
+ mesh.edge_nodes,
2306
+ mesh.edge_faces,
2307
+ removesmalllinkstrsh=removesmalllinkstrsh,
2308
+ edge_indices=edges_in_zone_arr,
2309
+ jsferic=jsferic,
2310
+ num_interior=num_interior_pre,
2311
+ )
2312
+ n_small_zone_current = len(small_zone_current)
2313
+ nface = mesh.face_nodes.shape[0]
2314
+ # Circumcenters/areas are only read for the faces adjacent to the
2315
+ # zone's small links; computing them mesh-wide per inner iteration
2316
+ # dominated the runtime on large meshes.
2317
+ link_faces = sorted(
2318
+ {
2319
+ int(ff)
2320
+ for e2 in small_in_zone[:max_small_edges_per_zone]
2321
+ for ff in mesh.edge_faces[e2]
2322
+ if ff >= 0 and ff < nface
2323
+ }
2324
+ )
2325
+ link_faces_arr = np.asarray(link_faces, dtype=np.int64)
2326
+ face_mask_links = np.zeros(nface, dtype=bool)
2327
+ face_mask_links[link_faces_arr] = True
2328
+ vert_deg = np.column_stack([mesh.node_x, mesh.node_y])
2329
+ circum_ll = _circumcenters_lonlat_ugrid(
2330
+ vert_deg,
2331
+ mesh.face_nodes,
2332
+ mesh.edge_faces,
2333
+ face_mask=face_mask_links,
2334
+ jsferic=jsferic,
2335
+ num_interior=num_interior_pre,
2336
+ )
2337
+ tria = mesh.face_nodes[:, :3]
2338
+ # Face areas in the same local frame `_lonlat_to_local_xy` uses
2339
+ # (full-mesh mean reference), computed only for the link faces.
2340
+ x0m = float(np.nanmean(mesh.node_x))
2341
+ y0m = float(np.nanmean(mesh.node_y))
2342
+ ba = np.zeros(nface, dtype=np.float64)
2343
+ if link_faces_arr.size > 0:
2344
+ tlf = tria[link_faces_arr]
2345
+ valid_lf = (tlf >= 0).all(axis=1)
2346
+ tv = tlf[valid_lf]
2347
+ nodes_lf = np.unique(tv)
2348
+ x0a = np.full(nodes_lf.size, x0m)
2349
+ y0a = np.full(nodes_lf.size, y0m)
2350
+ ux = _getdx_vec(
2351
+ x0a, y0a, mesh.node_x[nodes_lf], mesh.node_y[nodes_lf], jsferic
2352
+ )
2353
+ uy = _getdy_vec(
2354
+ x0a, y0a, mesh.node_x[nodes_lf], mesh.node_y[nodes_lf], jsferic
2355
+ )
2356
+ q0 = np.searchsorted(nodes_lf, tv[:, 0])
2357
+ q1 = np.searchsorted(nodes_lf, tv[:, 1])
2358
+ q2 = np.searchsorted(nodes_lf, tv[:, 2])
2359
+ ev12x, ev12y = ux[q1] - ux[q0], uy[q1] - uy[q0]
2360
+ ev13x, ev13y = ux[q2] - ux[q0], uy[q2] - uy[q0]
2361
+ ba[link_faces_arr[valid_lf]] = np.abs(
2362
+ 0.5 * (ev12x * ev13y - ev12y * ev13x)
2363
+ )
2364
+ for li, e in enumerate(small_in_zone[:max_small_edges_per_zone]):
2365
+ f1, f2 = mesh.edge_faces[e, 0], mesh.edge_faces[e, 1]
2366
+ k3, k4 = mesh.edge_nodes[e, 0], mesh.edge_nodes[e, 1]
2367
+ if k3 < 0 or k4 < 0 or f1 < 0 or f2 < 0 or f1 >= nface or f2 >= nface:
2368
+ continue
2369
+ f1, f2, k3, k4 = int(f1), int(f2), int(k3), int(k4)
2370
+ tri1 = mesh.face_nodes[f1, :3]
2371
+ tri2 = mesh.face_nodes[f2, :3]
2372
+ opp1 = next(
2373
+ (int(v) for v in tri1 if v >= 0 and v != k3 and v != k4), None
2374
+ )
2375
+ opp2 = next(
2376
+ (int(v) for v in tri2 if v >= 0 and v != k3 and v != k4), None
2377
+ )
2378
+ if opp1 is None or opp2 is None or opp1 == opp2:
2379
+ continue
2380
+ move_opp1 = opp1 in internal_set
2381
+ move_opp2 = opp2 in internal_set
2382
+ if not (move_opp1 or move_opp2):
2383
+ if smalllink_priority and (
2384
+ k3 in internal_set or k4 in internal_set
2385
+ ):
2386
+ # Boundary-locked apexes: fall back to searching moves
2387
+ # of the shared edge's endpoints instead.
2388
+ opp1, opp2 = k3, k4
2389
+ move_opp1 = k3 in internal_set
2390
+ move_opp2 = k4 in internal_set
2391
+ else:
2392
+ continue
2393
+ cc1, cc2 = circum_ll[f1], circum_ll[f2]
2394
+ if np.any(np.isnan(cc1)) or np.any(np.isnan(cc2)):
2395
+ continue
2396
+ dx_m = _getdx(cc1[0], cc1[1], cc2[0], cc2[1], jsferic)
2397
+ dy_m = _getdy(cc1[0], cc1[1], cc2[0], cc2[1], jsferic)
2398
+ dxlink = np.sqrt(dx_m * dx_m + dy_m * dy_m)
2399
+ if not smalllink_priority and dxlink < 1e-12:
2400
+ continue
2401
+ sqrt_ba1 = np.sqrt(max(ba[f1], 1e-20))
2402
+ sqrt_ba2 = np.sqrt(max(ba[f2], 1e-20))
2403
+ dxlim = 0.9 * removesmalllinkstrsh * 0.5 * (sqrt_ba1 + sqrt_ba2)
2404
+ if dxlink >= dxlim:
2405
+ continue
2406
+ max_step_meters = (
2407
+ min(np.sqrt(max(ba[f1], 1e-20)), np.sqrt(max(ba[f2], 1e-20))) * 0.5
2408
+ )
2409
+
2410
+ if smalllink_priority:
2411
+ # Probe-style search: move the apexes along the edge's
2412
+ # perpendicular bisector (both circumcenters live on that
2413
+ # axis; the cc-difference direction is numerical noise for
2414
+ # near-cocircular links), trying independent per-apex sign
2415
+ # combinations and ascending step sizes. Accept the first
2416
+ # candidate that reduces the zone's link count without
2417
+ # pushing the local |cosphi| above the threshold.
2418
+ exk = float(mesh.node_x[k4] - mesh.node_x[k3])
2419
+ eyk = float(mesh.node_y[k4] - mesh.node_y[k3])
2420
+ eln = np.hypot(exk, eyk)
2421
+ if eln < 1.0e-12:
2422
+ continue
2423
+ axx = -eyk / eln
2424
+ axy = exk / eln
2425
+ quad_nodes = np.unique(np.concatenate([tri1, tri2]))
2426
+ quad_nodes = quad_nodes[quad_nodes >= 0]
2427
+ quad_edges = _edges_among_nodes(mesh.edge_nodes, quad_nodes)
2428
+ s1_opts = (1.0, -1.0) if move_opp1 else (0.0,)
2429
+ s2_opts = (1.0, -1.0) if move_opp2 else (0.0,)
2430
+ found = False
2431
+ # Trial by in-place displace/restore of the two apexes:
2432
+ # copying the full coordinate arrays per candidate is the
2433
+ # dominant allocation cost of this search.
2434
+ keep1 = (float(mesh.node_x[opp1]), float(mesh.node_y[opp1]))
2435
+ keep2 = (float(mesh.node_x[opp2]), float(mesh.node_y[opp2]))
2436
+ for frac in (0.25, 0.5, 1.0, 2.0):
2437
+ step = frac * max_step_meters
2438
+ for s1 in s1_opts:
2439
+ for s2 in s2_opts:
2440
+ if s1 == 0.0 and s2 == 0.0:
2441
+ continue
2442
+ if move_opp1:
2443
+ mesh.node_x[opp1] = keep1[0] + s1 * step * axx
2444
+ mesh.node_y[opp1] = keep1[1] + s1 * step * axy
2445
+ if move_opp2:
2446
+ mesh.node_x[opp2] = keep2[0] + s2 * step * axx
2447
+ mesh.node_y[opp2] = keep2[1] + s2 * step * axy
2448
+ n_tr, _ = compute_small_links_from_arrays(
2449
+ mesh.node_x,
2450
+ mesh.node_y,
2451
+ mesh.face_nodes,
2452
+ mesh.edge_nodes,
2453
+ mesh.edge_faces,
2454
+ removesmalllinkstrsh=removesmalllinkstrsh,
2455
+ edge_indices=edges_in_zone_arr,
2456
+ jsferic=jsferic,
2457
+ num_interior=num_interior_pre,
2458
+ )
2459
+ ok_trial = int(n_tr) < n_small_zone_current
2460
+ if ok_trial:
2461
+ cq = _cosphi_abs_for_edges(
2462
+ mesh.node_x,
2463
+ mesh.node_y,
2464
+ mesh.face_nodes,
2465
+ mesh.edge_nodes,
2466
+ mesh.edge_faces,
2467
+ quad_edges,
2468
+ use_circumcenter_3d=True,
2469
+ jsferic=jsferic,
2470
+ )
2471
+ vals = cq[quad_edges]
2472
+ vals = vals[np.isfinite(vals)]
2473
+ if (
2474
+ vals.size
2475
+ and float(np.max(vals)) > cosphi_threshold
2476
+ ):
2477
+ ok_trial = False
2478
+ mesh.node_x[opp1], mesh.node_y[opp1] = keep1
2479
+ mesh.node_x[opp2], mesh.node_y[opp2] = keep2
2480
+ if not ok_trial:
2481
+ continue
2482
+ if move_opp1:
2483
+ dx_small[opp1] += s1 * step * axx
2484
+ dy_small[opp1] += s1 * step * axy
2485
+ if move_opp2:
2486
+ dx_small[opp2] += s2 * step * axx
2487
+ dy_small[opp2] += s2 * step * axy
2488
+ found = True
2489
+ break
2490
+ if found:
2491
+ break
2492
+ if found:
2493
+ break
2494
+ continue
2495
+
2496
+ needed_distance = (dxlim - dxlink) * aggressive_factor
2497
+ cc_diff_deg = np.array(
2498
+ [cc2[0] - cc1[0], cc2[1] - cc1[1]], dtype=np.float64
2499
+ )
2500
+ # Conservative step: at most 25% of needed distance, and at most half the current link length
2501
+ step_meters = min(needed_distance * 0.25, max_step_meters, 0.5 * dxlink)
2502
+ if step_meters < 1e-12:
2503
+ continue
2504
+ disp_deg = cc_diff_deg * (step_meters / dxlink)
2505
+ best_n_small = n_small_zone_current
2506
+ best_dopp1 = np.zeros(2)
2507
+ best_dopp2 = np.zeros(2)
2508
+ for sign in (1, -1):
2509
+ trial_x = mesh.node_x.copy()
2510
+ trial_y = mesh.node_y.copy()
2511
+ d1 = (-sign * 0.5 * disp_deg) if move_opp1 else np.zeros(2)
2512
+ d2 = (sign * 0.5 * disp_deg) if move_opp2 else np.zeros(2)
2513
+ if move_opp1:
2514
+ trial_x[opp1] = mesh.node_x[opp1] + d1[0]
2515
+ trial_y[opp1] = mesh.node_y[opp1] + d1[1]
2516
+ if move_opp2:
2517
+ trial_x[opp2] = mesh.node_x[opp2] + d2[0]
2518
+ trial_y[opp2] = mesh.node_y[opp2] + d2[1]
2519
+ _, small_trial = compute_small_links_from_arrays(
2520
+ trial_x,
2521
+ trial_y,
2522
+ mesh.face_nodes,
2523
+ mesh.edge_nodes,
2524
+ mesh.edge_faces,
2525
+ removesmalllinkstrsh=removesmalllinkstrsh,
2526
+ edge_indices=edges_in_zone_arr,
2527
+ jsferic=jsferic,
2528
+ num_interior=num_interior_pre,
2529
+ )
2530
+ if len(small_trial) < best_n_small:
2531
+ best_n_small = len(small_trial)
2532
+ best_dopp1 = d1
2533
+ best_dopp2 = d2
2534
+ if best_n_small < n_small_zone_current:
2535
+ if move_opp1:
2536
+ dx_small[opp1] += best_dopp1[0]
2537
+ dy_small[opp1] += best_dopp1[1]
2538
+ if move_opp2:
2539
+ dx_small[opp2] += best_dopp2[0]
2540
+ dy_small[opp2] += best_dopp2[1]
2541
+
2542
+ # Combine and update: smooth + ortho + small_link (scale_smooth dampens Laplacian for "good" zones)
2543
+ new_x = mesh.node_x.copy()
2544
+ new_y = mesh.node_y.copy()
2545
+ for g in zone_nodes:
2546
+ gid = int(g)
2547
+ if gid not in internal_set:
2548
+ continue
2549
+ if fixed_mask is not None and fixed_mask[gid]:
2550
+ continue
2551
+ w_node = dist_weight.get(gid, 1.0)
2552
+ if smalllink_priority:
2553
+ # The circumcenter-separation push was validated by the sign
2554
+ # trial at this exact magnitude; apply it at full weight like
2555
+ # the ortho displacement (the mu*relax damping below shrinks
2556
+ # it ~20x, which cannot clear a link).
2557
+ full_dx = w_node * (dx_o[gid] + dx_small[gid])
2558
+ full_dy = w_node * (dy_o[gid] + dy_small[gid])
2559
+ dx = (1.0 - mu_it) * scale_smooth * dx_s[gid]
2560
+ dy = (1.0 - mu_it) * scale_smooth * dy_s[gid]
2561
+ else:
2562
+ full_dx = w_node * dx_o[gid]
2563
+ full_dy = w_node * dy_o[gid]
2564
+ dx_small_w = w_node * beta_small * dx_small[gid]
2565
+ dy_small_w = w_node * beta_small * dy_small[gid]
2566
+ dx = (1.0 - mu_it) * scale_smooth * dx_s[gid] + mu_it * dx_small_w
2567
+ dy = (1.0 - mu_it) * scale_smooth * dy_s[gid] + mu_it * dy_small_w
2568
+ # The ortho displacement was validated by a per-edge line search at
2569
+ # this exact magnitude; damping it by relax*mu makes it ineffective
2570
+ # (a fraction of a percent of the local edge length). Apply it at
2571
+ # full weight — the zone-level acceptance/rollback still gates it.
2572
+ new_x[gid] += relax_zone * dx + full_dx
2573
+ new_y[gid] += relax_zone * dy + full_dy
2574
+ ortho_cum_x[gid] += full_dx
2575
+ ortho_cum_y[gid] += full_dy
2576
+ mesh.node_x[:] = new_x
2577
+ mesh.node_y[:] = new_y
2578
+
2579
+ # Line search: try factors 1.0, 0.5, 0.25 on total displacement to avoid rollback
2580
+ # For [good] zones: accept "no degradation" (max_ca <= threshold, n_small_za <= n_small_before)
2581
+ delta_x = mesh.node_x[zone_idx].copy() - x_old
2582
+ delta_y = mesh.node_y[zone_idx].copy() - y_old
2583
+ mesh.node_x[zone_idx] = x_old
2584
+ mesh.node_y[zone_idx] = y_old
2585
+ # Candidate displacements: the combined delta first; if it fails, the
2586
+ # accumulated targeted ortho moves alone. On strongly graded meshes the
2587
+ # Laplacian part of the combined delta can degrade the crown and veto a
2588
+ # valid per-edge repair bundled in the same displacement.
2589
+ delta_candidates = [(delta_x, delta_y)]
2590
+ delta_ox = ortho_cum_x[zone_idx].copy()
2591
+ delta_oy = ortho_cum_y[zone_idx].copy()
2592
+ if np.any(delta_ox != 0.0) or np.any(delta_oy != 0.0):
2593
+ delta_candidates.append((delta_ox, delta_oy))
2594
+ improved = False
2595
+ best_factor = 0.0
2596
+ best_delta = delta_candidates[0]
2597
+ max_cosphi_after = float("inf")
2598
+ min_cosphi_after = 0.0
2599
+ n_small_zone_after = n_small_zone_before
2600
+ for cand_dx, cand_dy in delta_candidates:
2601
+ if improved:
2602
+ break
2603
+ for factor in (1.0, 0.5, 0.25):
2604
+ mesh.node_x[zone_idx] = x_old + factor * cand_dx
2605
+ mesh.node_y[zone_idx] = y_old + factor * cand_dy
2606
+ cosphi_after = _cosphi_abs_for_edges(
2607
+ mesh.node_x,
2608
+ mesh.node_y,
2609
+ mesh.face_nodes,
2610
+ mesh.edge_nodes,
2611
+ mesh.edge_faces,
2612
+ eval_edges_arr,
2613
+ use_circumcenter_3d=True,
2614
+ jsferic=jsferic,
2615
+ )
2616
+ cos_zone_after = np.abs(cosphi_after[eval_edges_arr])
2617
+ mask_after = np.isfinite(cos_zone_after)
2618
+ if not np.any(mask_after):
2619
+ continue
2620
+ max_ca = float(np.max(cos_zone_after[mask_after]))
2621
+ min_ca = float(np.min(cos_zone_after[mask_after]))
2622
+ n_small_za = 0
2623
+ if small_edges_global is not None and small_edges_global.size > 0:
2624
+ _, small_zone_after_list = compute_small_links_from_arrays(
2625
+ mesh.node_x,
2626
+ mesh.node_y,
2627
+ mesh.face_nodes,
2628
+ mesh.edge_nodes,
2629
+ mesh.edge_faces,
2630
+ removesmalllinkstrsh=removesmalllinkstrsh,
2631
+ edge_indices=edges_in_zone_arr,
2632
+ jsferic=jsferic,
2633
+ num_interior=num_interior_pre,
2634
+ )
2635
+ n_small_za = len(small_zone_after_list)
2636
+ is_strict_improved = max_ca < max_cosphi_before or (
2637
+ small_edges_global is not None
2638
+ and small_edges_global.size > 0
2639
+ and n_small_za < n_small_zone_before
2640
+ and max_ca <= cosphi_threshold
2641
+ )
2642
+ if zone_already_good:
2643
+ # For [good]: accept only if we don't degrade orthogonality near the zone boundary too much.
2644
+ # This prevents "small-link only" actions from quietly increasing max|cosphi| in the crown.
2645
+ ortho_slack = 0.02
2646
+ if smalllink_priority:
2647
+ # Clearing a link may legitimately trade |cosphi| slack below
2648
+ # the criteria threshold; capping at max_before+0.02 vetoes
2649
+ # valid repairs in already-very-orthogonal zones.
2650
+ ortho_cap = cosphi_threshold
2651
+ else:
2652
+ ortho_cap = min(cosphi_threshold, max_cosphi_before + ortho_slack)
2653
+ is_acceptable = (max_ca <= ortho_cap) and (
2654
+ n_small_za <= n_small_zone_before
2655
+ )
2656
+ if is_acceptable and not improved:
2657
+ improved = True
2658
+ best_factor = factor
2659
+ best_delta = (cand_dx, cand_dy)
2660
+ max_cosphi_after = max_ca
2661
+ min_cosphi_after = min_ca
2662
+ n_small_zone_after = n_small_za
2663
+ elif is_acceptable and improved:
2664
+ if smalllink_priority:
2665
+ # Prefer clearing links first, then smaller max_ca, then larger factor
2666
+ better = n_small_za < n_small_zone_after or (
2667
+ n_small_za == n_small_zone_after
2668
+ and (
2669
+ max_ca < max_cosphi_after
2670
+ or (max_ca == max_cosphi_after and factor > best_factor)
2671
+ )
2672
+ )
2673
+ else:
2674
+ # Prefer smaller max_ca, then smaller n_small_za, then larger factor
2675
+ better = max_ca < max_cosphi_after or (
2676
+ max_ca == max_cosphi_after
2677
+ and (
2678
+ n_small_za < n_small_zone_after
2679
+ or (
2680
+ n_small_za == n_small_zone_after
2681
+ and factor > best_factor
2682
+ )
2683
+ )
2684
+ )
2685
+ if better:
2686
+ best_factor = factor
2687
+ best_delta = (cand_dx, cand_dy)
2688
+ max_cosphi_after = max_ca
2689
+ min_cosphi_after = min_ca
2690
+ n_small_zone_after = n_small_za
2691
+ else:
2692
+ # [bad] zone: first strict improvement wins
2693
+ if is_strict_improved:
2694
+ improved = True
2695
+ best_factor = factor
2696
+ best_delta = (cand_dx, cand_dy)
2697
+ max_cosphi_after = max_ca
2698
+ min_cosphi_after = min_ca
2699
+ n_small_zone_after = n_small_za
2700
+ break
2701
+ if improved and zone_already_good:
2702
+ # Apply best factor (we may have tried several)
2703
+ mesh.node_x[zone_idx] = x_old + best_factor * best_delta[0]
2704
+ mesh.node_y[zone_idx] = y_old + best_factor * best_delta[1]
2705
+ elif improved and not zone_already_good:
2706
+ mesh.node_x[zone_idx] = x_old + best_factor * best_delta[0]
2707
+ mesh.node_y[zone_idx] = y_old + best_factor * best_delta[1]
2708
+ if not improved:
2709
+ mesh.node_x[zone_idx] = x_old
2710
+ mesh.node_y[zone_idx] = y_old
2711
+ if verbose:
2712
+ print(
2713
+ f" [ZONE] rollback [{tag}]: max_before={max_cosphi_before:.6f} "
2714
+ f"(line_search: all factors 1.0, 0.5, 0.25 failed)"
2715
+ )
2716
+ else:
2717
+ log_small = ""
2718
+ if small_edges_global is not None and small_edges_global.size > 0:
2719
+ log_small = f" n_small_zone={n_small_zone_before}->{n_small_zone_after}"
2720
+ if verbose:
2721
+ print(
2722
+ f" [ZONE] accept [{tag}]: max_before={max_cosphi_before:.6f} "
2723
+ f"max_after={max_cosphi_after:.6f} "
2724
+ f"min_after={min_cosphi_after:.6f} factor={best_factor:.2f}{log_small}"
2725
+ )
2726
+ return (improved, zone_already_good)
2727
+
2728
+
2729
+ # Triangle-mesh entry point: orthogonalize (vert, tria) directly.
2730
+
2731
+
2732
+ @dataclass
2733
+ class TriaOrthoResult:
2734
+ vert: np.ndarray # (N,2) float64
2735
+ tria: np.ndarray # (T,3) int64 (topology unchanged unless edge flips are enabled)
2736
+ max_cosphi: float
2737
+ n_small_flow_links: int
2738
+ n_zones_orthogonalized: int
2739
+
2740
+
2741
+ def orthogonalize_tria_mesh(
2742
+ vert: np.ndarray,
2743
+ tria: np.ndarray,
2744
+ *,
2745
+ cosphi_threshold: float = 0.49,
2746
+ removesmalllinkstrsh: float = 0.1,
2747
+ buffer_layers: int = 2,
2748
+ max_global_iter: int = 8,
2749
+ smooth_iter: int = 16,
2750
+ enable_edge_flips: bool = True,
2751
+ verbose: bool = True,
2752
+ jsferic: int = 1,
2753
+ smalllink_priority: bool = False,
2754
+ fixed: Optional[np.ndarray] = None,
2755
+ ) -> TriaOrthoResult:
2756
+ """Orthogonalize a pure triangle mesh in memory.
2757
+
2758
+ Parameters
2759
+ ----------
2760
+ vert : ndarray of shape (N, 2)
2761
+ Node coordinates (lon/lat degrees or planar x/y per ``jsferic``).
2762
+ tria : ndarray of shape (T, 3)
2763
+ 0-based triangle connectivity.
2764
+ cosphi_threshold : float, optional
2765
+ Maximum allowed ``|cos φ|`` on internal flow links.
2766
+ removesmalllinkstrsh : float, optional
2767
+ Small flow-link threshold.
2768
+ buffer_layers : int, optional
2769
+ Zone buffer depth around bad edges.
2770
+ max_global_iter : int, optional
2771
+ Global orthogonalization passes.
2772
+ smooth_iter : int, optional
2773
+ Smoothing iterations per zone pass.
2774
+ enable_edge_flips : bool, optional
2775
+ Allow quality-guarded edge flips.
2776
+ verbose : bool, optional
2777
+ Log per-zone progress.
2778
+ jsferic : int, optional
2779
+ ``1`` for spherical lon/lat; ``0`` for planar coordinates.
2780
+ smalllink_priority : bool, optional
2781
+ Enable in-ortho small-link handling (flips + separation moves).
2782
+ fixed : array_like of int, optional
2783
+ Indices of nodes to hold fixed (never displaced).
2784
+
2785
+ Returns
2786
+ -------
2787
+ TriaOrthoResult
2788
+ Updated ``vert``/``tria`` plus ``max_cosphi``, ``n_small_flow_links``,
2789
+ and ``n_zones_orthogonalized``.
2790
+
2791
+ Notes
2792
+ -----
2793
+ Topology changes only via local edge flips inside convex quads; no quad
2794
+ merging is performed here.
2795
+ """
2796
+ vert = np.asarray(vert, dtype=np.float64)
2797
+ if vert.ndim != 2 or vert.shape[1] != 2:
2798
+ raise ValueError("vert must be an array of shape (N,2)")
2799
+ tria = np.asarray(tria, dtype=np.int64)
2800
+
2801
+ fixed_mask: Optional[np.ndarray] = None
2802
+ if fixed is not None:
2803
+ fixed_idx = np.unique(np.asarray(fixed, dtype=np.int64).ravel())
2804
+ if fixed_idx.size:
2805
+ if fixed_idx.min() < 0 or fixed_idx.max() >= vert.shape[0]:
2806
+ raise ValueError("orthogonalize_tria_mesh: invalid FIXED indices")
2807
+ fixed_mask = np.zeros(vert.shape[0], dtype=bool)
2808
+ fixed_mask[fixed_idx] = True
2809
+
2810
+ face_nodes = tria.copy()
2811
+ edge_nodes, edge_faces = _build_edges_from_tria(tria)
2812
+
2813
+ mesh = MeshData(
2814
+ node_x=vert[:, 0].copy(),
2815
+ node_y=vert[:, 1].copy(),
2816
+ face_nodes=face_nodes,
2817
+ edge_nodes=edge_nodes,
2818
+ edge_faces=edge_faces,
2819
+ )
2820
+
2821
+ n_faces = face_nodes.shape[0]
2822
+ face_neighbors = build_face_adjacency(mesh.edge_faces, n_faces)
2823
+
2824
+ # Number of zone passes run (reported for the pipeline's progress log).
2825
+ n_zones_orthogonalized = 0
2826
+
2827
+ for it in range(int(max_global_iter)):
2828
+ _, _, cosphi_abs = compute_cosphi_abs_from_arrays(
2829
+ mesh.node_x,
2830
+ mesh.node_y,
2831
+ mesh.face_nodes,
2832
+ mesh.edge_nodes,
2833
+ mesh.edge_faces,
2834
+ use_circumcenter_3d=True,
2835
+ jsferic=jsferic,
2836
+ )
2837
+ mask = ~np.isnan(cosphi_abs)
2838
+ if not np.any(mask):
2839
+ break
2840
+
2841
+ max_cosphi = float(np.nanmax(cosphi_abs[mask]))
2842
+ bad_edges = np.where((mask) & (cosphi_abs > cosphi_threshold))[0]
2843
+ n_small, small_edges_arr = compute_small_links_from_arrays(
2844
+ mesh.node_x,
2845
+ mesh.node_y,
2846
+ mesh.face_nodes,
2847
+ mesh.edge_nodes,
2848
+ mesh.edge_faces,
2849
+ removesmalllinkstrsh=removesmalllinkstrsh,
2850
+ jsferic=jsferic,
2851
+ )
2852
+
2853
+ # Edge-flip pre-pass (still triangles). If no small links remain but
2854
+ # orthogonality is still bad, let the problematic edges participate too.
2855
+ if enable_edge_flips:
2856
+ flip_candidates = small_edges_arr if n_small > 0 else bad_edges
2857
+ # Always cap the local |cosphi| a flip may introduce: an unguarded
2858
+ # flip can create very obtuse triangles with |cosphi| ~ 1.0 that no
2859
+ # later node movement can repair.
2860
+ try_flip_candidate_edges_ugrid(
2861
+ mesh,
2862
+ flip_candidates,
2863
+ removesmalllinkstrsh,
2864
+ max_cosphi_allowed=cosphi_threshold,
2865
+ jsferic=jsferic,
2866
+ )
2867
+ # Topology changed: rebuild edges/faces and re-measure.
2868
+ mesh.edge_nodes, mesh.edge_faces = _build_edges_from_tria(
2869
+ mesh.face_nodes[:, :3]
2870
+ )
2871
+ face_neighbors = build_face_adjacency(mesh.edge_faces, n_faces)
2872
+ _, _, cosphi_abs = compute_cosphi_abs_from_arrays(
2873
+ mesh.node_x,
2874
+ mesh.node_y,
2875
+ mesh.face_nodes,
2876
+ mesh.edge_nodes,
2877
+ mesh.edge_faces,
2878
+ use_circumcenter_3d=True,
2879
+ jsferic=jsferic,
2880
+ )
2881
+ mask = ~np.isnan(cosphi_abs)
2882
+ max_cosphi = (
2883
+ float(np.nanmax(cosphi_abs[mask])) if np.any(mask) else max_cosphi
2884
+ )
2885
+ n_small, small_edges_arr = compute_small_links_from_arrays(
2886
+ mesh.node_x,
2887
+ mesh.node_y,
2888
+ mesh.face_nodes,
2889
+ mesh.edge_nodes,
2890
+ mesh.edge_faces,
2891
+ removesmalllinkstrsh=removesmalllinkstrsh,
2892
+ jsferic=jsferic,
2893
+ )
2894
+
2895
+ if max_cosphi <= cosphi_threshold and n_small == 0:
2896
+ break
2897
+
2898
+ bad_edges = np.where((mask) & (cosphi_abs > cosphi_threshold))[0]
2899
+ bad_set = set(int(e) for e in bad_edges.tolist())
2900
+ sort_idx = (
2901
+ np.argsort(cosphi_abs[bad_edges])[::-1]
2902
+ if bad_edges.size > 0
2903
+ else np.array([], dtype=np.int64)
2904
+ )
2905
+ bad_edges_sorted = (
2906
+ bad_edges[sort_idx] if bad_edges.size > 0 else np.array([], dtype=np.int64)
2907
+ )
2908
+ small_only = np.array(
2909
+ [e for e in small_edges_arr.tolist() if e not in bad_set], dtype=np.int64
2910
+ )
2911
+ problematic_edges = (
2912
+ np.concatenate([bad_edges_sorted, small_only])
2913
+ if bad_edges_sorted.size > 0
2914
+ else small_only
2915
+ )
2916
+ if problematic_edges.size == 0:
2917
+ break
2918
+
2919
+ visited_faces_global: Set[int] = set()
2920
+ for e in problematic_edges:
2921
+ f1, f2 = mesh.edge_faces[e, :]
2922
+ start_faces: List[int] = []
2923
+ if f1 >= 0:
2924
+ start_faces.append(int(f1))
2925
+ if f2 >= 0:
2926
+ start_faces.append(int(f2))
2927
+ if not start_faces:
2928
+ continue
2929
+
2930
+ this_buffer = buffer_layers + (1 if int(e) in bad_set else 0)
2931
+ faces_zone = bfs_faces(start_faces, face_neighbors, this_buffer)
2932
+ if faces_zone.issubset(visited_faces_global):
2933
+ continue
2934
+
2935
+ apply_combined_ortho_smoother_to_zone(
2936
+ mesh=mesh,
2937
+ faces_zone=faces_zone,
2938
+ cosphi_abs=cosphi_abs,
2939
+ cosphi_threshold=cosphi_threshold,
2940
+ it=it,
2941
+ max_global_iter=max_global_iter,
2942
+ n_inner=max(1, int(smooth_iter)),
2943
+ small_edges_global=small_edges_arr,
2944
+ removesmalllinkstrsh=removesmalllinkstrsh,
2945
+ verbose=verbose,
2946
+ jsferic=jsferic,
2947
+ smalllink_priority=smalllink_priority,
2948
+ fixed_mask=fixed_mask,
2949
+ )
2950
+ n_zones_orthogonalized += 1
2951
+ visited_faces_global.update(faces_zone)
2952
+
2953
+ # Final metrics.
2954
+ _, _, cosphi_abs_final = compute_cosphi_abs_from_arrays(
2955
+ mesh.node_x,
2956
+ mesh.node_y,
2957
+ mesh.face_nodes,
2958
+ mesh.edge_nodes,
2959
+ mesh.edge_faces,
2960
+ use_circumcenter_3d=True,
2961
+ jsferic=jsferic,
2962
+ )
2963
+ mask_final = ~np.isnan(cosphi_abs_final)
2964
+ max_final = (
2965
+ float(np.nanmax(cosphi_abs_final[mask_final]))
2966
+ if np.any(mask_final)
2967
+ else float("nan")
2968
+ )
2969
+ n_small_final, _ = compute_small_links_from_arrays(
2970
+ mesh.node_x,
2971
+ mesh.node_y,
2972
+ mesh.face_nodes,
2973
+ mesh.edge_nodes,
2974
+ mesh.edge_faces,
2975
+ removesmalllinkstrsh=removesmalllinkstrsh,
2976
+ jsferic=jsferic,
2977
+ )
2978
+
2979
+ vert_out = np.column_stack([mesh.node_x, mesh.node_y]).astype(
2980
+ np.float64, copy=False
2981
+ )
2982
+ tria_out = np.asarray(mesh.face_nodes[:, :3], dtype=np.int64)
2983
+ return TriaOrthoResult(
2984
+ vert=vert_out,
2985
+ tria=tria_out,
2986
+ max_cosphi=max_final,
2987
+ n_small_flow_links=int(n_small_final),
2988
+ n_zones_orthogonalized=int(n_zones_orthogonalized),
2989
+ )