pyafv 0.3.5__cp310-cp310-macosx_11_0_arm64.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.

Potentially problematic release.


This version of pyafv might be problematic. Click here for more details.

@@ -0,0 +1,972 @@
1
+ """
2
+ Finite Voronoi Model Simulator in 2D
3
+ ====================================
4
+ Created by Wei Wang, 2025.
5
+
6
+ Key public entry points:
7
+ ------------------------
8
+ - FiniteVoronoiSimulator: configure parameters.
9
+ - build(): build the finite Voronoi diagram and compute forces.
10
+ - plot_2d(): plot the finite Voronoi diagram with matplotlib.
11
+ - update_points(): update cell center positions.
12
+ - update_params(): update physical parameters.
13
+ """
14
+
15
+ from typing import Literal
16
+ import numpy as np
17
+ from scipy.spatial import Voronoi
18
+ from matplotlib import pyplot as plt
19
+ from matplotlib.axes import Axes
20
+
21
+ from .physical_params import PhysicalParams
22
+ from .backend import backend_impl, _BACKEND_NAME
23
+
24
+
25
+ # ---- tiny helpers to avoid tiny allocations in hot loops ----
26
+ def _row_dot(a: np.ndarray, b: np.ndarray) -> np.ndarray:
27
+ """Row-wise dot product for 2D arrays with shape (N,2)."""
28
+ return np.einsum("ij,ij->i", a, b)
29
+
30
+
31
+ class FiniteVoronoiSimulator:
32
+ """Simulator for the active-finite-Voronoi (AFV) model.
33
+
34
+ This class provides an interface to simulate the finite Voronoi model.
35
+ It wraps around the two backend implementations, which may be
36
+ either a Cython-accelerated version or a pure Python fallback.
37
+
38
+ Args:
39
+ pts: (N,2) array of initial cell center positions.
40
+ phys: Physical parameters used within this simulator.
41
+ backend: Optional, specify "python" to force the use of the pure Python fallback implementation.
42
+
43
+ Raises:
44
+ ValueError: If *pts* does not have shape (N,2).
45
+ TypeError: If *phys* is not an instance of PhysicalParams.
46
+ """
47
+
48
+ def __init__(self, pts: np.ndarray, phys: PhysicalParams, backend: Literal["cython", "python"] | None = None):
49
+ """
50
+ Constructor of the simulator.
51
+ """
52
+ pts = np.asarray(pts, dtype=float)
53
+ if pts.ndim != 2 or pts.shape[1] != 2:
54
+ raise ValueError("pts must have shape (N,2)")
55
+ if not isinstance(phys, PhysicalParams):
56
+ raise TypeError("phys must be an instance of PhysicalParams")
57
+
58
+ self.pts = pts.copy() # (N,2) array of initial points
59
+ self.N = pts.shape[0] # Number of points
60
+ self.phys = phys
61
+ self._preferred_areas = np.full(self.N, phys.A0, dtype=float) # (N,) preferred areas A0
62
+
63
+ if backend != "python":
64
+ self._BACKEND = _BACKEND_NAME
65
+ self._impl = backend_impl
66
+
67
+ if self._BACKEND not in {"cython", "numba"}: # pragma: no cover
68
+ # raise warning to inform user about fallback
69
+ import warnings
70
+ warnings.warn(
71
+ "Could not import the Cython-built extension module. "
72
+ "Falling back to the pure Python implementation, which may be slower. "
73
+ "To enable the accelerated version, ensure that all dependencies are installed.",
74
+ RuntimeWarning,
75
+ stacklevel=2,
76
+ )
77
+ else: # force the use of the pure Python fallback implementation.
78
+ self._BACKEND = "python"
79
+ from . import cell_geom_fallback as _impl
80
+ self._impl = _impl
81
+
82
+ # --------------------- Voronoi construction & extension ---------------------
83
+ def _build_voronoi_with_extensions(self) -> tuple[Voronoi, np.ndarray, list[list[int]], int, dict[tuple[int,int], int], dict[int, list[int]]]:
84
+ """
85
+ Build standard Voronoi structure for current points.
86
+
87
+ For N<=2, emulate regions.
88
+ For N>=3, extend infinite ridges, add extension vertices, and update
89
+ regions accordingly. Return the augmented structures.
90
+
91
+ .. warning::
92
+
93
+ This is an internal method. Use with caution.
94
+
95
+ Returns:
96
+ tuple[scipy.spatial.Voronoi, numpy.ndarray, list[list[int]], int, dict[tuple[int,int], int], dict[int, list[int]]] : A tuple containing:
97
+
98
+ - **vor**: SciPy Voronoi object for current points with extensions.
99
+ - **vertices_all**: (M,2) array of all Voronoi vertices including extensions.
100
+ - **ridge_vertices_all**: list of lists of vertex indices for each ridge, including extensions.
101
+ - **num_vertices**: Number of Voronoi vertices before adding extension.
102
+ - **vertexpair2ridge**: dict mapping vertex index pairs to ridge index.
103
+ - **vertex_points**: dict mapping vertex index to list of associated point indices.
104
+ """
105
+ r = self.phys.r
106
+ pts = self.pts
107
+ N = self.N
108
+
109
+ # Special handling: N == 1, 2
110
+ if N == 1:
111
+ vor = Voronoi(np.random.rand(3, 2))
112
+ vor.points[:N] = pts
113
+ vor.vertices = np.array([]).reshape(-1, 2)
114
+
115
+ vor.regions = [[]]
116
+ vor.ridge_vertices = np.array([]).reshape(-1, 2)
117
+ vor.point_region = np.array([0])
118
+ vor.ridge_points = np.array([]).reshape(-1, 2)
119
+
120
+ vertices_all = vor.vertices
121
+ ridge_vertices_all = vor.ridge_vertices
122
+ num_vertices = len(vor.vertices)
123
+
124
+
125
+ if N == 2:
126
+ vor = Voronoi(np.random.rand(3, 2))
127
+ vor.points[:N] = pts
128
+
129
+ p1, p2 = pts
130
+ center = (p1 + p2) / 2.0
131
+
132
+ t = p1 - p2
133
+ t_norm = np.linalg.norm(t)
134
+ t /= t_norm
135
+
136
+ n = np.array([-t[1], t[0]]) # Perpendicular vector of t
137
+
138
+ if t_norm >= 2 * r:
139
+ vor.vertices = np.array([]).reshape(-1, 2)
140
+ vor.regions = [[], []]
141
+ vor.ridge_vertices = np.array([]).reshape(-1, 2)
142
+ vor.ridge_points = np.array([]).reshape(-1, 2)
143
+ else:
144
+ v1 = center + 2 * r * n
145
+ v2 = center - 2 * r * n
146
+ v3 = center + 3 * r * (p1 - center) / np.linalg.norm(p2 - center)
147
+ v4 = center + 3 * r * (p2 - center) / np.linalg.norm(p2 - center)
148
+ vor.vertices = np.array([v1, v2, v3, v4]).reshape(-1, 2)
149
+ vor.ridge_vertices = np.array([[0, 1], [1, 2], [0, 2], [0, 3], [1, 3]])
150
+ vor.regions = [[0, 1, 2], [0, 1, 3]]
151
+ vor.ridge_points = np.array([[0, 1], [-1, 0], [-1, 0], [-1, 1], [-1, 1]])
152
+
153
+ vor.point_region = np.array([0, 1])
154
+
155
+ vertices_all = vor.vertices
156
+ ridge_vertices_all = vor.ridge_vertices
157
+ num_vertices = len(vor.vertices)
158
+
159
+
160
+ # N >= 3 (vectorized main path)
161
+ if N >= 3:
162
+ vor = Voronoi(pts)
163
+ """
164
+ Basic info from Voronoi object:
165
+ -------------------------------
166
+ vor.vertices # (K,2) Voronoi vertices (finite)
167
+ vor.ridge_points # (R,2) pairs of input point indices sharing a Voronoi ridge
168
+ vor.ridge_vertices # list of vertex-index lists (may contain -1 for infinity)
169
+ vor.point_region # for each input point, the region index
170
+ vor.regions # list of regions; each is a list of vertex indices (may include -1)
171
+ """
172
+ center = np.mean(pts, axis=0)
173
+
174
+ span_x = np.ptp(vor.vertices[:, 0]) # span in x
175
+ span_y = np.ptp(vor.vertices[:, 1]) # span in y
176
+ pts_span_x = np.ptp(pts[:, 0]) # span in x
177
+ pts_span_y = np.ptp(pts[:, 1]) # span in y
178
+ span = max(span_x, span_y, pts_span_x, pts_span_y, 10. * r) # overall span
179
+
180
+ # Base copies
181
+ vertices_base = vor.vertices # (K,2)
182
+ rv_arr = np.asarray(vor.ridge_vertices, dtype=int) # (R,2) may contain -1
183
+ rp_arr = np.asarray(vor.ridge_points, dtype=int) # (R,2)
184
+
185
+ # Remove -1 from regions (we will append extension ids later)
186
+ vor.regions = [[vid for vid in region if vid >= 0] for region in vor.regions]
187
+
188
+ # Identify ridges with an infinite endpoint
189
+ inf_mask = (rv_arr == -1).any(axis=1) # (R,)
190
+ num_inf = int(inf_mask.sum())
191
+
192
+ if num_inf > 0:
193
+ rv_inf = rv_arr[inf_mask] # (M,2)
194
+ rp_inf = rp_arr[inf_mask] # (M,2)
195
+
196
+ # finite endpoint index per infinite ridge
197
+ v_idx_finite = np.where(rv_inf[:, 0] != -1, rv_inf[:, 0], rv_inf[:, 1]) # (M,)
198
+
199
+ # geometry for normals
200
+ p1 = pts[rp_inf[:, 0]] # (M,2)
201
+ p2 = pts[rp_inf[:, 1]] # (M,2)
202
+ mid = (p1 + p2) / 2.0 # (M,2)
203
+
204
+ t = p1 - p2 # (M,2)
205
+ t_norm = np.linalg.norm(t, axis=1, keepdims=True) # (M,1)
206
+ t_unit = t / t_norm
207
+
208
+ # (M,2), perpendicular
209
+ n = np.column_stack([-t_unit[:, 1], t_unit[:, 0]])
210
+
211
+ # Ensure "outward" normal
212
+ sign = np.einsum("ij,ij->i", (mid - center), n) # (M,)
213
+ n[sign < 0] *= -1.0
214
+
215
+ # Build extension points
216
+ ext = vertices_base[v_idx_finite] + (100.0 * span) * n # (M,2), long rays (extension must be long enough!)
217
+
218
+ # Concatenate once
219
+ K = vertices_base.shape[0]
220
+ vertices_all = np.vstack([vertices_base, ext]) # (K+M,2)
221
+
222
+ # New vertex ids for extensions
223
+ ext_ids = np.arange(K, K + num_inf, dtype=int) # (M,)
224
+
225
+ # Replace -1 with ext_ids in a vectorized way
226
+ rv_new = rv_arr.copy() # (R,2)
227
+ rv_sub = rv_new[inf_mask] # view (M,2)
228
+
229
+ pos0 = (rv_sub[:, 0] == -1) # (M,)
230
+ rv_sub[pos0, 0] = ext_ids[pos0]
231
+ rv_sub[~pos0, 1] = ext_ids[~pos0]
232
+ rv_new[inf_mask] = rv_sub
233
+
234
+ ridge_vertices_all = rv_new.tolist()
235
+
236
+ # Append extension id to both adjacent regions (list-of-lists => tiny loop)
237
+ for m in range(num_inf):
238
+ i1, i2 = rp_inf[m]
239
+ e = ext_ids[m]
240
+ region_id = vor.point_region[i1]
241
+ vor.regions[region_id].append(e)
242
+ region_id = vor.point_region[i2]
243
+ vor.regions[region_id].append(e)
244
+ else:
245
+ vertices_all = vertices_base.copy()
246
+ ridge_vertices_all = rv_arr.tolist()
247
+
248
+ # number of native (finite) vertices
249
+ num_vertices = len(vor.vertices)
250
+
251
+ # Build vertexpair2ridge and vertex_points using Cython/Python backend function
252
+ vertexpair2ridge, vertex_points = self._impl.build_vertexpair_and_vertexpoints(ridge_vertices_all, vor.ridge_points, num_vertices, N)
253
+
254
+ return vor, vertices_all, ridge_vertices_all, num_vertices, vertexpair2ridge, vertex_points
255
+
256
+ # --------------------- Geometry & energy contributions per cell ---------------------
257
+ def _per_cell_geometry(self, vor: Voronoi, vertices_all: np.ndarray, ridge_vertices_all: np.ndarray, num_vertices: int, vertexpair2ridge: dict[tuple[int, int], int]) -> dict[str,object]:
258
+ """
259
+ Build the finite-Voronoi per-cell geometry and energy contributions.
260
+
261
+ Iterate each cell to:
262
+ - sort polygon/arc vertices around each cell
263
+ - classify edges (1 = straight Voronoi edge; 0 = circular arc)
264
+ - compute area/perimeter for each cell
265
+ - accumulate derivatives w.r.t. vertices (dA_poly/dh, dP_poly/dh)
266
+ - register "outer" vertices created at arc intersections and track their point pairs
267
+
268
+ .. warning::
269
+ This is an internal method. Use with caution.
270
+
271
+ Args:
272
+ vor: SciPy Voronoi object for current points with extensions.
273
+ vertices_all: (M,2) array of all Voronoi vertices including extensions.
274
+ ridge_vertices_all: list of lists of vertex indices for each ridge, including extensions.
275
+ num_vertices: Number of Voronoi vertices before adding extension.
276
+ vertexpair2ridge: dict mapping vertex index pairs to ridge index.
277
+
278
+ Returns:
279
+ dict[str, object]: A diagnostics dictionary containing:
280
+
281
+ - **vertex_in_id**: set of inner vertex ids.
282
+ - **vertex_out_id**: set of outer vertex ids.
283
+ - **vertices_out**: (L,2) array of outer vertex coordinates.
284
+ - **vertex_out_points**: (L,2) array of point index pairs associated with each outer vertex.
285
+ - **vertex_out_da_dtheta**: array of dA/dtheta for all outer vertices.
286
+ - **vertex_out_dl_dtheta**: array of dL/dtheta for all outer vertices.
287
+ - **dA_poly_dh**: array of dA_polygon/dh for each vertex.
288
+ - **dP_poly_dh**: array of dP_polygon/dh for each vertex.
289
+ - **area_list**: array of polygon areas for each cell.
290
+ - **perimeter_list**: array of polygon perimeters for each cell.
291
+ - **point_edges_type**: list of lists of edge types per cell.
292
+ - **point_vertices_f_idx**: list of lists of vertex ids per cell.
293
+ - **num_vertices_ext**: number of vertices including infinite extension vertices.
294
+ """
295
+ N = self.N
296
+ r = self.phys.r
297
+ A0_list = self._preferred_areas
298
+ P0 = self.phys.P0
299
+ pts = self.pts
300
+
301
+ num_vertices_ext = len(vertices_all) # number of vertices with infinite extension points
302
+
303
+ rv = np.asarray(ridge_vertices_all, dtype=int) # (R,2)
304
+ rp = np.asarray(vor.ridge_points, dtype=int) # (R,2)
305
+ num_ridges = rp.shape[0]
306
+
307
+ # init outer-vertex arrays (same shapes you used)
308
+ vertices_out = np.zeros((2 * num_ridges, 2), dtype=float)
309
+ vertex_out_points = np.zeros((2 * num_ridges, 2), dtype=int)
310
+
311
+ # unpack ridge endpoints and vertex indices
312
+ p1 = rp[:, 0].copy()
313
+ p2 = rp[:, 1].copy()
314
+ v1 = rv[:, 0].copy()
315
+ v2 = rv[:, 1].copy()
316
+
317
+ valid_pts = (p1 >= 0) & (p2 >= 0)
318
+
319
+ # coordinates
320
+ P1 = np.zeros((num_ridges, 2), dtype=float); P2 = np.zeros((num_ridges, 2), dtype=float)
321
+ P1[valid_pts] = self.pts[p1[valid_pts]]
322
+ P2[valid_pts] = self.pts[p2[valid_pts]]
323
+ V1 = vertices_all[v1]
324
+ V2 = vertices_all[v2]
325
+
326
+ # enforce V1->V2 clockwise around p1 (swap only p1<->p2, not v1/v2)
327
+ P12 = P2 - P1
328
+ V12 = V2 - V1
329
+ swap = (P12[:, 0] * V12[:, 1] - P12[:, 1] * V12[:, 0]) > 0
330
+ p1_sw = p1.copy(); p2_sw = p2.copy()
331
+ p1_sw[swap] = p2[swap]; p2_sw[swap] = p1[swap]
332
+ p1, p2 = p1_sw, p2_sw
333
+ P1_sw = P1.copy(); P2_sw = P2.copy()
334
+ P1[swap] = P2_sw[swap]; P2[swap] = P1_sw[swap]
335
+
336
+ # "inner" tests relative to p1 (keep your exact choices)
337
+ r = self.phys.r
338
+ d1 = np.linalg.norm(V1 - P1, axis=1)
339
+ d2 = np.linalg.norm(V2 - P1, axis=1)
340
+ inner1 = (d1 <= r) & valid_pts
341
+ inner2 = (d2 <= r) & valid_pts
342
+
343
+ # segment/intersection
344
+ dV = V2 - V1
345
+ segL = np.linalg.norm(dV, axis=1)
346
+ denom = np.where(segL > 0.0, segL, 1.0)
347
+ dx, dy = dV[:, 0], dV[:, 1]
348
+ x, y = P1[:, 0], P1[:, 1]
349
+ x1, y1 = V1[:, 0], V1[:, 1]
350
+ t = ((x - x1) * dx + (y - y1) * dy) / denom
351
+
352
+ # mid-point C = (p1+p2)/2 (your exact choice)
353
+ C = 0.5 * (P1 + P2)
354
+ cx, cy = C[:, 0], C[:, 1]
355
+ t1 = -t
356
+ t2 = t1 + denom
357
+
358
+ d = np.linalg.norm(C - P1, axis=1)
359
+ has_int = (d < r) & valid_pts
360
+ tr = np.full_like(d, np.nan)
361
+ tr[has_int] = np.sqrt(r * r - d[has_int] * d[has_int])
362
+
363
+ cond1 = (-tr < t2) & (-tr > t1) & valid_pts
364
+ cond2 = ( tr < t2) & ( tr > t1) & valid_pts
365
+
366
+ invL = np.where(denom > 0.0, 1.0 / denom, 0.0)
367
+ xr1 = cx - tr * dx * invL
368
+ yr1 = cy - tr * dy * invL
369
+ xr2 = cx + tr * dx * invL
370
+ yr2 = cy + tr * dy * invL
371
+
372
+ # fill outer-vertex arrays only where intersections happen
373
+ idx1 = np.where(cond1)[0]
374
+ if idx1.size:
375
+ vertices_out[2 * idx1 + 0, 0] = xr1[idx1]
376
+ vertices_out[2 * idx1 + 0, 1] = yr1[idx1]
377
+ pairs1 = np.sort(np.stack([p1[idx1], p2[idx1]], axis=1), axis=1)
378
+ vertex_out_points[2 * idx1 + 0] = pairs1
379
+
380
+ idx2 = np.where(cond2)[0]
381
+ if idx2.size:
382
+ vertices_out[2 * idx2 + 1, 0] = xr2[idx2]
383
+ vertices_out[2 * idx2 + 1, 1] = yr2[idx2]
384
+ pairs2 = np.sort(np.stack([p1[idx2], p2[idx2]], axis=1), axis=1)
385
+ vertex_out_points[2 * idx2 + 1] = pairs2
386
+
387
+ # sets (same semantics as before)
388
+ vertex_out_id = set((num_vertices_ext + np.where((np.arange(2 * num_ridges) % 2 == 0) & cond1.repeat(2))[0]).tolist())
389
+ vertex_out_id.update((num_vertices_ext + np.where((np.arange(2 * num_ridges) % 2 == 1) & cond2.repeat(2))[0]).tolist())
390
+
391
+ vertex_in_id = set(v1[inner1].astype(int).tolist())
392
+ vertex_in_id.update(v2[inner2].astype(int).tolist())
393
+
394
+ # -------- NEW: packed arrays instead of ridge_info dict --------
395
+ # each endpoint has up to 3 entries; absent slots are -1
396
+ p1_edges_pack = np.full((num_ridges, 3), -1, dtype=int)
397
+ p1_verts_pack = np.full((num_ridges, 3), -1, dtype=int)
398
+ p2_edges_pack = np.full((num_ridges, 3), -1, dtype=int)
399
+ p2_verts_pack = np.full((num_ridges, 3), -1, dtype=int)
400
+
401
+ out_id1 = num_vertices_ext + (2 * np.arange(num_ridges) + 0)
402
+ out_id2 = out_id1 + 1
403
+
404
+ # p1 order: [inner1 -> (1,v1)], [cond1 -> (1,out1)], [cond2 -> (0,out2)]
405
+ p1_edges_pack[inner1, 0] = 1
406
+ p1_verts_pack[inner1, 0] = v1[inner1]
407
+
408
+ p1_edges_pack[cond1, 1] = 1
409
+ p1_verts_pack[cond1, 1] = out_id1[cond1]
410
+
411
+ p1_edges_pack[cond2, 2] = 0
412
+ p1_verts_pack[cond2, 2] = out_id2[cond2]
413
+
414
+ # p2 was "append then reverse", which yields final order: [inner2, cond2, cond1]
415
+ p2_edges_pack[inner2, 0] = 1
416
+ p2_verts_pack[inner2, 0] = v2[inner2]
417
+
418
+ p2_edges_pack[cond2, 1] = 1
419
+ p2_verts_pack[cond2, 1] = out_id2[cond2]
420
+
421
+ p2_edges_pack[cond1, 2] = 0
422
+ p2_verts_pack[cond1, 2] = out_id1[cond1]
423
+
424
+ # append outer-vertex slots (unused rows stay zero like before)
425
+ vertices_all = np.vstack([vertices_all, vertices_out])
426
+
427
+ # --------------------------------------------------
428
+ # Part 1 in Cython/Python backend
429
+ # --------------------------------------------------
430
+ vor_regions = self._impl.pad_regions(vor.regions) # (R, Kmax) int64 with -1 padding
431
+ point_edges_type, point_vertices_f_idx = self._impl.build_point_edges(
432
+ vor_regions, vor.point_region.astype(np.int64),
433
+ vertices_all.astype(np.float64), pts.astype(np.float64),
434
+ int(num_vertices), vertexpair2ridge,
435
+ p1.astype(np.int64), p1_edges_pack.astype(np.int64), p1_verts_pack.astype(np.int64),
436
+ p2.astype(np.int64), p2_edges_pack.astype(np.int64), p2_verts_pack.astype(np.int64),
437
+ )
438
+
439
+ # --------------------------------------------------
440
+ # Part 2 in Cython/Python backend
441
+ # --------------------------------------------------
442
+ vertex_out_da_dtheta, vertex_out_dl_dtheta, dA_poly_dh, dP_poly_dh, area_list, perimeter_list = self._impl.compute_vertex_derivatives(
443
+ point_edges_type, # list-of-lists / arrays of edge types
444
+ point_vertices_f_idx, # list-of-lists / arrays of vertex ids
445
+ vertices_all.astype(np.float64, copy=False),
446
+ pts.astype(np.float64, copy=False),
447
+ float(r),
448
+ A0_list.astype(np.float64, copy=False),
449
+ float(P0),
450
+ int(num_vertices_ext),
451
+ int(num_ridges),
452
+ vertex_out_points.astype(np.int64, copy=False)
453
+ )
454
+
455
+ diagnostics = dict(
456
+ vertex_in_id=set(vertex_in_id),
457
+ vertex_out_id=set(vertex_out_id),
458
+ vertices_out=vertices_out,
459
+ vertex_out_points=vertex_out_points,
460
+ vertex_out_da_dtheta=vertex_out_da_dtheta,
461
+ vertex_out_dl_dtheta=vertex_out_dl_dtheta,
462
+ dA_poly_dh=dA_poly_dh,
463
+ dP_poly_dh=dP_poly_dh,
464
+ area_list=area_list,
465
+ perimeter_list=perimeter_list,
466
+ point_edges_type=point_edges_type,
467
+ point_vertices_f_idx=point_vertices_f_idx,
468
+ num_vertices_ext=num_vertices_ext,
469
+ )
470
+ return diagnostics, vertices_all
471
+
472
+ # --------------------- Force assembly ---------------------
473
+ def _assemble_forces(self, vertices_all: np.ndarray, num_vertices_ext: int,
474
+ vertex_points: dict[int, list[int]], vertex_in_id: list[int], vertex_out_id: list[int],
475
+ vertex_out_points: list[list[int]], vertex_out_da_dtheta: np.ndarray,
476
+ vertex_out_dl_dtheta: np.ndarray, dA_poly_dh: np.ndarray, dP_poly_dh: np.ndarray,
477
+ area_list: np.ndarray, perimeter_list: np.ndarray) -> np.ndarray:
478
+ """
479
+ Assemble forces on cell centers from polygon and arc contributions.
480
+ """
481
+ N = self.N
482
+ r = self.phys.r
483
+ A0_list = self._preferred_areas
484
+ P0 = self.phys.P0
485
+ KA = self.phys.KA
486
+ KP = self.phys.KP
487
+ Lambda = self.phys.lambda_tension
488
+ pts = self.pts
489
+
490
+ dE_poly_dh = 2.0 * (KA * dA_poly_dh + KP * dP_poly_dh)
491
+
492
+ fx = np.zeros(N)
493
+ fy = np.zeros(N)
494
+
495
+ # ===============================================================
496
+ # (1) Inner vertices contributions — vectorized + bincount scatter
497
+ # ===============================================================
498
+ if len(vertex_in_id) > 0:
499
+ H = np.asarray(list(vertex_in_id), dtype=int) # (H,)
500
+ # unpack triples (i,j,k) for each inner vertex
501
+ I = np.empty(len(H), dtype=int)
502
+ J = np.empty(len(H), dtype=int)
503
+ K = np.empty(len(H), dtype=int)
504
+ for t, h in enumerate(H):
505
+ I[t], J[t], K[t] = vertex_points[h]
506
+
507
+ ri = pts[I] # (H,2)
508
+ rj = pts[J]
509
+ rk = pts[K]
510
+
511
+ rj_minus_rk = rj - rk
512
+ ri_minus_rj = ri - rj
513
+ ri_minus_rk = ri - rk
514
+ rj_minus_ri = -ri_minus_rj
515
+ rk_minus_ri = -ri_minus_rk
516
+ rk_minus_rj = rk - rj
517
+
518
+ D0 = _row_dot(ri - rj, np.column_stack((rj_minus_rk[:,1], -rj_minus_rk[:,0]))) # cross2(ri-rj, rj-rk)
519
+ # rewrite cross robustly:
520
+ D0 = (ri[:,0]-rj[:,0])*(rj_minus_rk[:,1]) - (ri[:,1]-rj[:,1])*(rj_minus_rk[:,0])
521
+ D = 2.0 * (D0 ** 2)
522
+
523
+ # alphas
524
+ alpha_i = _row_dot(rj_minus_rk, rj_minus_rk) * _row_dot(ri_minus_rj, ri_minus_rk) / D
525
+ alpha_j = _row_dot(ri_minus_rk, ri_minus_rk) * _row_dot(rj_minus_ri, rj_minus_rk) / D
526
+ alpha_k = _row_dot(ri_minus_rj, ri_minus_rj) * _row_dot(rk_minus_ri, rk_minus_rj) / D
527
+
528
+ # d_alpha_j / d_ri and d_alpha_k / d_ri
529
+ cross_z = np.column_stack((rj_minus_rk[:, 1], -rj_minus_rk[:, 0])) # (H,2)
530
+ term_j_ri = (rk_minus_rj / _row_dot(rj_minus_ri, rj_minus_rk)[:, None]) + 2.0 * (ri_minus_rk / _row_dot(ri_minus_rk, ri_minus_rk)[:, None]) - 2.0 * (cross_z / D0[:, None])
531
+ term_k_ri = (rj_minus_rk / _row_dot(rk_minus_ri, rk_minus_rj)[:, None]) + 2.0 * (ri_minus_rj / _row_dot(ri_minus_rj, ri_minus_rj)[:, None]) - 2.0 * (cross_z / D0[:, None])
532
+ d_alpha_j_d_ri = (alpha_j[:, None] * term_j_ri) # (H,2)
533
+ d_alpha_k_d_ri = (alpha_k[:, None] * term_k_ri)
534
+
535
+ d_h_in_d_xi = alpha_i[:, None] * np.array([1.0, 0.0]) + d_alpha_j_d_ri[:, [0]] * (rj - ri) + d_alpha_k_d_ri[:, [0]] * (rk - ri)
536
+ d_h_in_d_yi = alpha_i[:, None] * np.array([0.0, 1.0]) + d_alpha_j_d_ri[:, [1]] * (rj - ri) + d_alpha_k_d_ri[:, [1]] * (rk - ri)
537
+
538
+ # d_alpha_i / d_rj and d_alpha_k / d_rj
539
+ cross_z = np.column_stack((-(ri_minus_rk)[:, 1], (ri_minus_rk)[:, 0]))
540
+ term_i_rj = (rk_minus_ri / _row_dot(ri_minus_rj, ri_minus_rk)[:, None]) + 2.0 * (rj_minus_rk / _row_dot(rj_minus_rk, rj_minus_rk)[:, None]) - 2.0 * (cross_z / D0[:, None])
541
+ term_k_rj = (ri_minus_rk / _row_dot(rk_minus_rj, rk_minus_ri)[:, None]) + 2.0 * (rj_minus_ri / _row_dot(rj_minus_ri, rj_minus_ri)[:, None]) - 2.0 * (cross_z / D0[:, None])
542
+ d_alpha_i_d_rj = (alpha_i[:, None] * term_i_rj)
543
+ d_alpha_k_d_rj = (alpha_k[:, None] * term_k_rj)
544
+
545
+ d_h_in_d_xj = d_alpha_i_d_rj[:, [0]] * (ri - rj) + alpha_j[:, None] * np.array([1.0, 0.0]) + d_alpha_k_d_rj[:, [0]] * (rk - rj)
546
+ d_h_in_d_yj = d_alpha_i_d_rj[:, [1]] * (ri - rj) + alpha_j[:, None] * np.array([0.0, 1.0]) + d_alpha_k_d_rj[:, [1]] * (rk - rj)
547
+
548
+ # d_alpha_i / d_rk and d_alpha_j / d_rk
549
+ cross_z = np.column_stack(((ri_minus_rj)[:, 1], -(ri_minus_rj)[:, 0]))
550
+ term_i_rk = (rj_minus_ri / _row_dot(ri_minus_rk, ri_minus_rj)[:, None]) + 2.0 * (rk_minus_rj / _row_dot(rk_minus_rj, rk_minus_rj)[:, None]) - 2.0 * (cross_z / D0[:, None])
551
+ term_j_rk = (ri_minus_rj / _row_dot(rj_minus_rk, rj_minus_ri)[:, None]) + 2.0 * (rk_minus_ri / _row_dot(rk_minus_ri, rk_minus_ri)[:, None]) - 2.0 * (cross_z / D0[:, None])
552
+ d_alpha_i_d_rk = (alpha_i[:, None] * term_i_rk)
553
+ d_alpha_j_d_rk = (alpha_j[:, None] * term_j_rk)
554
+
555
+ d_h_in_d_xk = d_alpha_i_d_rk[:, [0]] * (ri - rk) + d_alpha_j_d_rk[:, [0]] * (rj - rk) + alpha_k[:, None] * np.array([1.0, 0.0])
556
+ d_h_in_d_yk = d_alpha_i_d_rk[:, [1]] * (ri - rk) + d_alpha_j_d_rk[:, [1]] * (rj - rk) + alpha_k[:, None] * np.array([0.0, 1.0])
557
+
558
+ deh = dE_poly_dh[H] # (H,2)
559
+ contrib_x_i = _row_dot(deh, d_h_in_d_xi)
560
+ contrib_x_j = _row_dot(deh, d_h_in_d_xj)
561
+ contrib_x_k = _row_dot(deh, d_h_in_d_xk)
562
+ contrib_y_i = _row_dot(deh, d_h_in_d_yi)
563
+ contrib_y_j = _row_dot(deh, d_h_in_d_yj)
564
+ contrib_y_k = _row_dot(deh, d_h_in_d_yk)
565
+
566
+ # bincount-based scatter (faster than repeated np.add.at)
567
+ fx += (
568
+ np.bincount(I, weights=contrib_x_i, minlength=N)
569
+ + np.bincount(J, weights=contrib_x_j, minlength=N)
570
+ + np.bincount(K, weights=contrib_x_k, minlength=N)
571
+ )
572
+ fy += (
573
+ np.bincount(I, weights=contrib_y_i, minlength=N)
574
+ + np.bincount(J, weights=contrib_y_j, minlength=N)
575
+ + np.bincount(K, weights=contrib_y_k, minlength=N)
576
+ )
577
+
578
+ # ===============================================================
579
+ # (2) Outer vertices contributions — vectorized; bincount scatter
580
+ # ===============================================================
581
+ dA_arc_dr = np.zeros((N, 2))
582
+ dP_arc_dr = np.zeros((N,2))
583
+ dL_dr = np.zeros((N,2))
584
+
585
+ if len(vertex_out_id) > 0:
586
+ Vsel = np.asarray(vertex_out_id, dtype=int) # absolute vertex IDs in vertices_all
587
+ h_idx = Vsel - num_vertices_ext # rows into vertex_out_* arrays
588
+ # guard against any accidental negatives / out-of-range
589
+ valid_mask = (h_idx >= 0) & (h_idx < len(vertex_out_points))
590
+ if np.any(valid_mask):
591
+ Vsel = Vsel[valid_mask]
592
+ h_idx = h_idx[valid_mask]
593
+
594
+ # geometry slices
595
+ h_out = vertices_all[Vsel] # (M,2)
596
+ IJ = np.asarray(vertex_out_points, dtype=int)[h_idx] # (M,2)
597
+ I = IJ[:, 0]
598
+ J = IJ[:, 1]
599
+
600
+ ri = pts[I] # (M,2)
601
+ rj = pts[J] # (M,2)
602
+ rij_vec = ri - rj
603
+ rij = np.linalg.norm(rij_vec, axis=1) # (M,)
604
+ root = np.sqrt(4.0 * (r ** 2) - (rij ** 2))
605
+
606
+ # sign based on orientation: sign(cross(h_out-rj, ri-rj))
607
+ sign = np.sign((h_out[:, 0] - rj[:, 0]) * (ri[:, 1] - rj[:, 1])
608
+ - (h_out[:, 1] - rj[:, 1]) * (ri[:, 0] - rj[:, 0]))
609
+
610
+ x_unit = np.array([1.0, 0.0])[None, :]
611
+ y_unit = np.array([0.0, 1.0])[None, :]
612
+
613
+ cross_z = rij_vec[:, [1]] * x_unit - rij_vec[:, [0]] * y_unit # (M,2)
614
+ denom = (np.maximum(root[:, None], self.phys.delta) * (rij ** 3)[:, None]) # small offset to avoid singularities
615
+
616
+ dx_terms = - (2.0 * (r ** 2) * rij_vec[:, [0]] * cross_z / denom) \
617
+ - (root / (2.0 * rij))[:, None] * y_unit
618
+ dy_terms = - (2.0 * (r ** 2) * rij_vec[:, [1]] * cross_z / denom) \
619
+ + (root / (2.0 * rij))[:, None] * x_unit
620
+
621
+ d_h_out_d_xi = (x_unit / 2.0) + sign[:, None] * dx_terms
622
+ d_h_out_d_yi = (y_unit / 2.0) + sign[:, None] * dy_terms
623
+ d_h_out_d_xj = (x_unit / 2.0) - sign[:, None] * dx_terms
624
+ d_h_out_d_yj = (y_unit / 2.0) - sign[:, None] * dy_terms
625
+
626
+ # polygon part on these selected outer vertices
627
+ deh_out = dE_poly_dh[Vsel] # (M,2)
628
+
629
+ fx += (
630
+ np.bincount(I, weights=_row_dot(deh_out, d_h_out_d_xi), minlength=N)
631
+ + np.bincount(J, weights=_row_dot(deh_out, d_h_out_d_xj), minlength=N)
632
+ )
633
+ fy += (
634
+ np.bincount(I, weights=_row_dot(deh_out, d_h_out_d_yi), minlength=N)
635
+ + np.bincount(J, weights=_row_dot(deh_out, d_h_out_d_yj), minlength=N)
636
+ )
637
+
638
+ # ---- arc angle sensitivities (per-cell accumulators) ----
639
+ u_i = h_out - ri
640
+ u_j = h_out - rj
641
+ inv_ui2 = 1.0 / _row_dot(u_i, u_i)
642
+ inv_uj2 = 1.0 / _row_dot(u_j, u_j)
643
+ u_perp_i = np.column_stack((-u_i[:, 1], u_i[:, 0])) * inv_ui2[:, None]
644
+ u_perp_j = np.column_stack((-u_j[:, 1], u_j[:, 0])) * inv_uj2[:, None]
645
+
646
+ d_theta_i_d_xj = _row_dot(d_h_out_d_xj, u_perp_i)
647
+ d_theta_i_d_yj = _row_dot(d_h_out_d_yj, u_perp_i)
648
+ d_theta_i_d_xi = -d_theta_i_d_xj
649
+ d_theta_i_d_yi = -d_theta_i_d_yj
650
+
651
+ d_theta_j_d_xi = _row_dot(d_h_out_d_xi, u_perp_j)
652
+ d_theta_j_d_yi = _row_dot(d_h_out_d_yi, u_perp_j)
653
+ d_theta_j_d_xj = -d_theta_j_d_xi
654
+ d_theta_j_d_yj = -d_theta_j_d_yi
655
+
656
+ # weights (only for the selected outer vertices h_idx)
657
+ v_da = vertex_out_da_dtheta[h_idx] # (M,2)
658
+ v_dl = vertex_out_dl_dtheta[h_idx] # (M,2)
659
+
660
+ Ai_w_i = (area_list[I] - A0_list[I]) * v_da[:, 0]
661
+ Aj_w_j = (area_list[J] - A0_list[J]) * v_da[:, 1]
662
+ Pi_w_i = (perimeter_list[I] - P0) * v_dl[:, 0]
663
+ Pj_w_j = (perimeter_list[J] - P0) * v_dl[:, 1]
664
+
665
+ # accumulate with bincount
666
+ dA_arc_dr[:, 0] += np.bincount(I, Ai_w_i * d_theta_i_d_xi + Aj_w_j * d_theta_j_d_xi, minlength=N)
667
+ dA_arc_dr[:, 0] += np.bincount(J, Ai_w_i * d_theta_i_d_xj + Aj_w_j * d_theta_j_d_xj, minlength=N)
668
+ dA_arc_dr[:, 1] += np.bincount(I, Ai_w_i * d_theta_i_d_yi + Aj_w_j * d_theta_j_d_yi, minlength=N)
669
+ dA_arc_dr[:, 1] += np.bincount(J, Ai_w_i * d_theta_i_d_yj + Aj_w_j * d_theta_j_d_yj, minlength=N)
670
+
671
+ dP_arc_dr[:, 0] += np.bincount(I, Pi_w_i * d_theta_i_d_xi + Pj_w_j * d_theta_j_d_xi, minlength=N)
672
+ dP_arc_dr[:, 0] += np.bincount(J, Pi_w_i * d_theta_i_d_xj + Pj_w_j * d_theta_j_d_xj, minlength=N)
673
+ dP_arc_dr[:, 1] += np.bincount(I, Pi_w_i * d_theta_i_d_yi + Pj_w_j * d_theta_j_d_yi, minlength=N)
674
+ dP_arc_dr[:, 1] += np.bincount(J, Pi_w_i * d_theta_i_d_yj + Pj_w_j * d_theta_j_d_yj, minlength=N)
675
+
676
+ # line-tension contributions for Lambda
677
+ dL_arc_x = v_dl[:, 0] * d_theta_i_d_xi + v_dl[:, 1] * d_theta_j_d_xi
678
+ dL_arc_y = v_dl[:, 0] * d_theta_i_d_yi + v_dl[:, 1] * d_theta_j_d_yi
679
+ dL_arc_xJ = v_dl[:, 0] * d_theta_i_d_xj + v_dl[:, 1] * d_theta_j_d_xj
680
+ dL_arc_yJ = v_dl[:, 0] * d_theta_i_d_yj + v_dl[:, 1] * d_theta_j_d_yj
681
+
682
+ dL_dr[:, 0] += np.bincount(I, dL_arc_x, minlength=N)
683
+ dL_dr[:, 1] += np.bincount(I, dL_arc_y, minlength=N)
684
+ dL_dr[:, 0] += np.bincount(J, dL_arc_xJ, minlength=N)
685
+ dL_dr[:, 1] += np.bincount(J, dL_arc_yJ, minlength=N)
686
+
687
+ # combine arc terms
688
+ dE_arc_dr = 2.0 * (KA * dA_arc_dr + KP * dP_arc_dr) + Lambda * dL_dr
689
+
690
+ fx = -(fx + dE_arc_dr[:, 0])
691
+ fy = -(fy + dE_arc_dr[:, 1])
692
+
693
+ F = np.zeros((N, 2), dtype=float)
694
+ F[:, 0] = fx
695
+ F[:, 1] = fy
696
+ return F
697
+
698
+ # --------------------- One integration step ---------------------
699
+ def build(self) -> dict[str, object]:
700
+ """ Build the finite-Voronoi structure and compute forces, returning a dictionary of diagnostics.
701
+
702
+ Do the following:
703
+ - Build Voronoi (+ extensions)
704
+ - Get cell connectivity
705
+ - Compute per-cell quantities and derivatives
706
+ - Assemble forces
707
+
708
+
709
+ Returns:
710
+ dict[str, object]: A dictionary containing geometric properties with keys:
711
+
712
+ - **forces**: (N,2) array of forces on cell centers
713
+ - **areas**: (N,) array of cell areas
714
+ - **perimeters**: (N,) array of cell perimeters
715
+ - **vertices**: (M,2) array of all Voronoi + extension vertices
716
+ - **edges_type**: list-of-lists of edge types per cell (1=straight, 0=circular arc)
717
+ - **regions**: list-of-lists of vertex indices per cell
718
+ - **connections**: (M',2) array of connected cell index pairs
719
+ """
720
+ (vor, vertices_all, ridge_vertices_all, num_vertices,
721
+ vertexpair2ridge, vertex_points) = self._build_voronoi_with_extensions()
722
+
723
+ connections = self._get_connections(vor.ridge_points, vertices_all, ridge_vertices_all)
724
+
725
+ geom, vertices_all = self._per_cell_geometry(vor, vertices_all, ridge_vertices_all, num_vertices, vertexpair2ridge)
726
+
727
+ F = self._assemble_forces(
728
+ vertices_all=vertices_all,
729
+ num_vertices_ext=geom["num_vertices_ext"],
730
+ vertex_points=vertex_points,
731
+ vertex_in_id=list(geom["vertex_in_id"]),
732
+ vertex_out_id=list(geom["vertex_out_id"]),
733
+ vertex_out_points=geom["vertex_out_points"],
734
+ vertex_out_da_dtheta=geom["vertex_out_da_dtheta"],
735
+ vertex_out_dl_dtheta=geom["vertex_out_dl_dtheta"],
736
+ dA_poly_dh=geom["dA_poly_dh"],
737
+ dP_poly_dh=geom["dP_poly_dh"],
738
+ area_list=geom["area_list"],
739
+ perimeter_list=geom["perimeter_list"],
740
+ )
741
+
742
+ return dict(
743
+ forces=F,
744
+ areas=geom["area_list"],
745
+ perimeters=geom["perimeter_list"],
746
+ vertices=vertices_all,
747
+ edges_type=geom["point_edges_type"],
748
+ regions=geom["point_vertices_f_idx"],
749
+ connections=connections,
750
+ )
751
+
752
+ # --------------------- 2D plotting utilities ---------------------
753
+ def plot_2d(self, ax: Axes | None = None, show: bool = False) -> Axes:
754
+ """
755
+ Build the finite-Voronoi structure and render a 2D snapshot.
756
+
757
+ Basically a wrapper of :py:func:`_build_voronoi_with_extensions` and :py:func:`_per_cell_geometry` functions + plot.
758
+
759
+ Args:
760
+ ax: If provided, draw into this axes; otherwise get the current axes.
761
+ show: Whether to call ``plt.show()`` at the end.
762
+
763
+ Returns:
764
+ The matplotlib axes containing the plot.
765
+ """
766
+ (vor, vertices_all, ridge_vertices_all, num_vertices,
767
+ vertexpair2ridge, vertex_points) = self._build_voronoi_with_extensions()
768
+
769
+ geom, vertices_all = self._per_cell_geometry(vor, vertices_all, ridge_vertices_all, num_vertices, vertexpair2ridge)
770
+
771
+ if ax is None:
772
+ ax = plt.gca()
773
+
774
+ self._plot_routine(ax, vor, vertices_all, ridge_vertices_all,
775
+ geom["point_edges_type"], geom["point_vertices_f_idx"])
776
+
777
+ if show:
778
+ plt.show()
779
+ return ax
780
+
781
+ # --------------------- Paradigm of plotting ---------------------
782
+ def _plot_routine(self, ax: Axes, vor: Voronoi, vertices_all: np.ndarray, ridge_vertices_all: list[list[int]],
783
+ point_edges_type: list[list[int]], point_vertices_f_idx: list[list[int]]) -> None:
784
+ """
785
+ Low-level plot routine. Draws:
786
+ - All Voronoi edges (solid for finite, dashed for formerly-infinite)
787
+ - Cell centers
788
+ - Each cell boundary (poly edges and circular arcs)
789
+ """
790
+ pts = self.pts
791
+ r = self.phys.r
792
+ N = self.N
793
+
794
+ center = np.mean(pts, axis=0)
795
+ if N > 1:
796
+ span_x = np.ptp(pts[:, 0]) # pts span in x
797
+ span_y = np.ptp(pts[:, 1]) # pts span in y
798
+ L = max(span_x, span_y) + 3.0 * r
799
+ L *= 0.8
800
+ else:
801
+ L = 5.0 * r
802
+
803
+ # Draw Voronoi ridge segments
804
+ for idx in range(len(vor.ridge_vertices)):
805
+ x1, y1 = vertices_all[ridge_vertices_all[idx][0]]
806
+ x2, y2 = vertices_all[ridge_vertices_all[idx][1]]
807
+ if -1 not in vor.ridge_vertices[idx]:
808
+ ax.plot([x1, x2], [y1, y2], 'k-', lw=0.5)
809
+ else:
810
+ ax.plot([x1, x2], [y1, y2], 'k--', lw=0.5)
811
+
812
+ # Draw cell centers
813
+ ax.plot(pts[:, 0], pts[:, 1], 'o', color='C0', markersize=2)
814
+
815
+ # Draw each cell boundary
816
+ for idx in range(N):
817
+ edges_type = point_edges_type[idx]
818
+ vertices_f_idx = point_vertices_f_idx[idx]
819
+
820
+ x, y = pts[idx]
821
+ if len(edges_type) < 2:
822
+ angle = np.linspace(0, 2*np.pi, 100)
823
+ ax.plot(x + r * np.cos(angle), y + r * np.sin(angle), color="C6", zorder=2)
824
+ continue
825
+
826
+ for idx_f, edge_type in enumerate(edges_type):
827
+ v1_idx = vertices_f_idx[idx_f]
828
+ x1, y1 = vertices_all[v1_idx]
829
+ idx2 = idx_f + 1 if idx_f < len(edges_type)-1 else 0
830
+ v2_idx = vertices_f_idx[idx2]
831
+ x2, y2 = vertices_all[v2_idx]
832
+
833
+ if edge_type == 1:
834
+ ax.plot([x1, x2], [y1, y2], 'b-', zorder=1)
835
+ else:
836
+ angle1 = np.arctan2(y1-y, x1-x)
837
+ angle2 = np.arctan2(y2-y, x2-x)
838
+ dangle = np.linspace(0, (angle1 - angle2) % (2*np.pi), 100)
839
+
840
+ ax.plot(x + r * np.cos(angle2+dangle), y + r * np.sin(angle2+dangle), color="C6", zorder=2)
841
+
842
+ ax.set_aspect("equal")
843
+ ax.set_xlim(center[0]-L, center[0]+L)
844
+ ax.set_ylim(center[1]-L, center[1]+L)
845
+
846
+ # --------------------- Connections between cells ---------------------
847
+ def _get_connections(self, ridge_points: list[list[int]], vertices_all: np.ndarray, ridge_vertices_all: list[list[int]]) -> np.ndarray:
848
+ """
849
+ Determine which pairs of cells are connected, i.e.,
850
+ the distance from the cell center to its corresponding Voronoi ridge
851
+ segment is < self.phys.r.
852
+ """
853
+ ridge_points_arr = np.asarray(ridge_points, dtype=int).reshape(-1, 2) # (R, 2)
854
+ ridge_vertices_arr = np.asarray(ridge_vertices_all, dtype=int).reshape(-1, 2) # (R, 2)
855
+
856
+ # take p2 for each ridge, avoid -1 points (representing space)
857
+ p1_idx = ridge_points_arr[:, 0] # (R,)
858
+ p2_idx = ridge_points_arr[:, 1] # (R,)
859
+ p2 = self.pts[p2_idx] # (R, 2)
860
+
861
+ v1 = vertices_all[ridge_vertices_arr[:, 0]] # (R, 2)
862
+ v2 = vertices_all[ridge_vertices_arr[:, 1]] # (R, 2)
863
+
864
+ # vectorized point-to-segment distance
865
+ AB = v2 - v1 # (R, 2)
866
+ AP = p2 - v1 # (R, 2)
867
+ denom = np.einsum("ij,ij->i", AB, AB) # (R,)
868
+
869
+ t = np.einsum("ij,ij->i", AP, AB) / denom
870
+ t = np.clip(t, 0.0, 1.0)[:, None] # (R,1)
871
+
872
+ C = v1 + t * AB # closest point on segment, (R,2)
873
+ dists = np.linalg.norm(p2 - C, axis=1) # (R,)
874
+
875
+ mask = dists < self.phys.r
876
+
877
+ connect = np.stack([p1_idx[mask], p2_idx[mask]], axis=1)
878
+ if connect.size > 0:
879
+ connect = np.sort(connect, axis=1)
880
+ else:
881
+ connect = np.empty((0, 2), dtype=int)
882
+ return connect
883
+
884
+ # --------------------- Update positions ---------------------
885
+ def update_positions(self, pts: np.ndarray, A0: float | np.ndarray | None = None) -> None:
886
+ """
887
+ Update cell center positions.
888
+
889
+ .. note::
890
+ If the number of points changes, the preferred areas for all cells
891
+ are reset to the default value (set when initializing the simulator
892
+ instance or by :py:func:`update_params`) unless specified via the
893
+ *A0* argument.
894
+
895
+ Args:
896
+ pts: New cell center positions.
897
+ A0: Optional, set new preferred area(s).
898
+
899
+ Raises:
900
+ ValueError: If *pts* does not have shape (N,2).
901
+ ValueError: If *A0* is an array and does not have shape (N,).
902
+ """
903
+ pts = np.asarray(pts, dtype=float)
904
+ if pts.ndim != 2 or pts.shape[1] != 2:
905
+ raise ValueError("pts must have shape (N,2)")
906
+
907
+ N = pts.shape[0]
908
+ self.pts = pts
909
+
910
+ if N != self.N:
911
+ self.N = N
912
+ if A0 is None:
913
+ self._preferred_areas = np.full(N, self.phys.A0, dtype=float)
914
+ else:
915
+ self.update_preferred_areas(A0)
916
+ else:
917
+ if A0 is not None:
918
+ self.update_preferred_areas(A0)
919
+
920
+ # --------------------- Update physical parameters ---------------------
921
+ def update_params(self, phys: PhysicalParams) -> None:
922
+ """
923
+ Update physical parameters.
924
+
925
+ Args:
926
+ phys: New PhysicalParams object.
927
+
928
+ Raises:
929
+ TypeError: If *phys* is not an instance of PhysicalParams.
930
+
931
+ .. warning::
932
+ This also resets all preferred cell areas to the new value of *A0*.
933
+ """
934
+ if not isinstance(phys, PhysicalParams):
935
+ raise TypeError("phys must be an instance of PhysicalParams")
936
+
937
+ self.phys = phys
938
+ self.update_preferred_areas(phys.A0)
939
+
940
+ # --------------------- Update preferred area list ---------------------
941
+ def update_preferred_areas(self, A0: float | np.ndarray) -> None:
942
+ """
943
+ Update the preferred areas for all cells.
944
+
945
+ Args:
946
+ A0: New preferred area(s) for all cells.
947
+
948
+ Raises:
949
+ ValueError: If *A0* does not match cell number.
950
+ """
951
+ arr = np.asarray(A0, dtype=float)
952
+
953
+ # Accept scalar (0-d) or length-1 array as "uniform"
954
+ if arr.ndim == 0:
955
+ arr = np.full(self.N, float(arr), dtype=float)
956
+ elif arr.shape == (1,):
957
+ arr = np.full(self.N, float(arr[0]), dtype=float)
958
+ else:
959
+ if arr.shape != (self.N,):
960
+ raise ValueError(f"A0 must be scalar or have shape ({self.N},)")
961
+
962
+ self._preferred_areas = arr
963
+
964
+ @property
965
+ def preferred_areas(self) -> np.ndarray:
966
+ """
967
+ Preferred areas for all cells (read-only).
968
+
969
+ Returns:
970
+ numpy.ndarray: A copy of the internal preferred area array.
971
+ """
972
+ return self._preferred_areas.copy()