bluemesh2d 0.1.1.dev0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- bluemesh2d/_version.py +24 -0
- bluemesh2d/aabb_tree/findball.py +121 -0
- bluemesh2d/aabb_tree/findtria.py +299 -0
- bluemesh2d/aabb_tree/maketree.py +147 -0
- bluemesh2d/aabb_tree/mapvert.py +72 -0
- bluemesh2d/aabb_tree/queryset.py +83 -0
- bluemesh2d/aabb_tree/scantree.py +124 -0
- bluemesh2d/dependencies.py +83 -0
- bluemesh2d/feedback.py +142 -0
- bluemesh2d/geom_util/boundary_util.py +216 -0
- bluemesh2d/geom_util/getiso.py +194 -0
- bluemesh2d/geom_util/poly_util.py +795 -0
- bluemesh2d/geom_util/proj_util.py +250 -0
- bluemesh2d/geomesh_util/border_util.py +283 -0
- bluemesh2d/geomesh_util/depth_field.py +333 -0
- bluemesh2d/geomesh_util/grd_util.py +1000 -0
- bluemesh2d/geomesh_util/interpolation_mesh.py +318 -0
- bluemesh2d/geomesh_util/merge_circumcenters.py +268 -0
- bluemesh2d/geomesh_util/water_polygon.py +406 -0
- bluemesh2d/hfun_util/build_hfun.py +589 -0
- bluemesh2d/hfun_util/hfun_dispersion.py +96 -0
- bluemesh2d/hfun_util/lfshfn.py +110 -0
- bluemesh2d/hfun_util/limhfn.py +65 -0
- bluemesh2d/hfun_util/make_constant_hfun.py +32 -0
- bluemesh2d/hfun_util/make_depth_hfun.py +51 -0
- bluemesh2d/hfun_util/smooth_and_precomput.py +188 -0
- bluemesh2d/hfun_util/trihfn.py +84 -0
- bluemesh2d/hjac_util/limgrad.py +105 -0
- bluemesh2d/mesh_ball/cdtbal1.py +38 -0
- bluemesh2d/mesh_ball/cdtbal2.py +104 -0
- bluemesh2d/mesh_ball/inv_2x2.py +61 -0
- bluemesh2d/mesh_ball/inv_3x3.py +72 -0
- bluemesh2d/mesh_ball/pwrbal2.py +134 -0
- bluemesh2d/mesh_ball/tribal2.py +27 -0
- bluemesh2d/mesh_cost/relhfn.py +62 -0
- bluemesh2d/mesh_cost/triang.py +59 -0
- bluemesh2d/mesh_cost/triarea.py +51 -0
- bluemesh2d/mesh_cost/trideg.py +44 -0
- bluemesh2d/mesh_cost/triscr.py +43 -0
- bluemesh2d/mesh_file/bnd_util.py +664 -0
- bluemesh2d/mesh_file/loadmsh.py +165 -0
- bluemesh2d/mesh_file/ugrid.py +308 -0
- bluemesh2d/mesh_util/cfmtri.py +118 -0
- bluemesh2d/mesh_util/deltri.py +131 -0
- bluemesh2d/mesh_util/idxtri.py +53 -0
- bluemesh2d/mesh_util/isfeat.py +90 -0
- bluemesh2d/mesh_util/minlen.py +46 -0
- bluemesh2d/mesh_util/setset.py +49 -0
- bluemesh2d/mesh_util/tricon.py +90 -0
- bluemesh2d/mesh_util/tridiv.py +187 -0
- bluemesh2d/meshgen.py +202 -0
- bluemesh2d/ortho_merge/constants.py +27 -0
- bluemesh2d/ortho_merge/geometry.py +64 -0
- bluemesh2d/ortho_merge/ortho_merge_iter.py +812 -0
- bluemesh2d/ortho_merge/orthogonalize.py +2989 -0
- bluemesh2d/pipeline.py +277 -0
- bluemesh2d/poly_data/airfoil.msh +958 -0
- bluemesh2d/poly_data/channel.msh +212 -0
- bluemesh2d/poly_data/islands.msh +13819 -0
- bluemesh2d/poly_data/lake.msh +612 -0
- bluemesh2d/poly_data/river.msh +690 -0
- bluemesh2d/poly_test/inpoly.py +105 -0
- bluemesh2d/poly_test/inpoly_mat.py +104 -0
- bluemesh2d/refine.py +972 -0
- bluemesh2d/smood.py +623 -0
- bluemesh2d/smooth.py +522 -0
- bluemesh2d/tricost.py +426 -0
- bluemesh2d/tridemo.py +723 -0
- bluemesh2d/triread.py +53 -0
- bluemesh2d-0.1.1.dev0.dist-info/METADATA +139 -0
- bluemesh2d-0.1.1.dev0.dist-info/RECORD +74 -0
- bluemesh2d-0.1.1.dev0.dist-info/WHEEL +5 -0
- bluemesh2d-0.1.1.dev0.dist-info/licenses/LICENSE +674 -0
- bluemesh2d-0.1.1.dev0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,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
|
+
)
|