emerge 0.4.7__py3-none-any.whl → 0.4.8__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.

Potentially problematic release.


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

Files changed (78) hide show
  1. emerge/__init__.py +14 -14
  2. emerge/_emerge/__init__.py +42 -0
  3. emerge/_emerge/bc.py +197 -0
  4. emerge/_emerge/coord.py +119 -0
  5. emerge/_emerge/cs.py +523 -0
  6. emerge/_emerge/dataset.py +36 -0
  7. emerge/_emerge/elements/__init__.py +19 -0
  8. emerge/_emerge/elements/femdata.py +212 -0
  9. emerge/_emerge/elements/index_interp.py +64 -0
  10. emerge/_emerge/elements/legrange2.py +172 -0
  11. emerge/_emerge/elements/ned2_interp.py +645 -0
  12. emerge/_emerge/elements/nedelec2.py +140 -0
  13. emerge/_emerge/elements/nedleg2.py +217 -0
  14. emerge/_emerge/geo/__init__.py +24 -0
  15. emerge/_emerge/geo/horn.py +107 -0
  16. emerge/_emerge/geo/modeler.py +449 -0
  17. emerge/_emerge/geo/operations.py +254 -0
  18. emerge/_emerge/geo/pcb.py +1244 -0
  19. emerge/_emerge/geo/pcb_tools/calculator.py +28 -0
  20. emerge/_emerge/geo/pcb_tools/macro.py +79 -0
  21. emerge/_emerge/geo/pmlbox.py +204 -0
  22. emerge/_emerge/geo/polybased.py +529 -0
  23. emerge/_emerge/geo/shapes.py +427 -0
  24. emerge/_emerge/geo/step.py +77 -0
  25. emerge/_emerge/geo2d.py +86 -0
  26. emerge/_emerge/geometry.py +510 -0
  27. emerge/_emerge/howto.py +214 -0
  28. emerge/_emerge/logsettings.py +5 -0
  29. emerge/_emerge/material.py +118 -0
  30. emerge/_emerge/mesh3d.py +730 -0
  31. emerge/_emerge/mesher.py +339 -0
  32. emerge/_emerge/mth/common_functions.py +33 -0
  33. emerge/_emerge/mth/integrals.py +71 -0
  34. emerge/_emerge/mth/optimized.py +357 -0
  35. emerge/_emerge/periodic.py +263 -0
  36. emerge/_emerge/physics/__init__.py +0 -0
  37. emerge/_emerge/physics/microwave/__init__.py +1 -0
  38. emerge/_emerge/physics/microwave/adaptive_freq.py +279 -0
  39. emerge/_emerge/physics/microwave/assembly/assembler.py +569 -0
  40. emerge/_emerge/physics/microwave/assembly/curlcurl.py +448 -0
  41. emerge/_emerge/physics/microwave/assembly/generalized_eigen.py +426 -0
  42. emerge/_emerge/physics/microwave/assembly/robinbc.py +433 -0
  43. emerge/_emerge/physics/microwave/microwave_3d.py +1150 -0
  44. emerge/_emerge/physics/microwave/microwave_bc.py +915 -0
  45. emerge/_emerge/physics/microwave/microwave_data.py +1148 -0
  46. emerge/_emerge/physics/microwave/periodic.py +82 -0
  47. emerge/_emerge/physics/microwave/port_functions.py +53 -0
  48. emerge/_emerge/physics/microwave/sc.py +175 -0
  49. emerge/_emerge/physics/microwave/simjob.py +147 -0
  50. emerge/_emerge/physics/microwave/sparam.py +138 -0
  51. emerge/_emerge/physics/microwave/touchstone.py +140 -0
  52. emerge/_emerge/plot/__init__.py +0 -0
  53. emerge/_emerge/plot/display.py +394 -0
  54. emerge/_emerge/plot/grapher.py +93 -0
  55. emerge/_emerge/plot/matplotlib/mpldisplay.py +264 -0
  56. emerge/_emerge/plot/pyvista/__init__.py +1 -0
  57. emerge/_emerge/plot/pyvista/display.py +931 -0
  58. emerge/_emerge/plot/pyvista/display_settings.py +24 -0
  59. emerge/_emerge/plot/simple_plots.py +551 -0
  60. emerge/_emerge/plot.py +225 -0
  61. emerge/_emerge/projects/__init__.py +0 -0
  62. emerge/_emerge/projects/_gen_base.txt +32 -0
  63. emerge/_emerge/projects/_load_base.txt +24 -0
  64. emerge/_emerge/projects/generate_project.py +40 -0
  65. emerge/_emerge/selection.py +596 -0
  66. emerge/_emerge/simmodel.py +444 -0
  67. emerge/_emerge/simulation_data.py +411 -0
  68. emerge/_emerge/solver.py +993 -0
  69. emerge/_emerge/system.py +54 -0
  70. emerge/cli.py +19 -0
  71. emerge/lib.py +1 -1
  72. emerge/plot.py +1 -1
  73. {emerge-0.4.7.dist-info → emerge-0.4.8.dist-info}/METADATA +1 -1
  74. emerge-0.4.8.dist-info/RECORD +78 -0
  75. emerge-0.4.8.dist-info/entry_points.txt +2 -0
  76. emerge-0.4.7.dist-info/RECORD +0 -9
  77. emerge-0.4.7.dist-info/entry_points.txt +0 -2
  78. {emerge-0.4.7.dist-info → emerge-0.4.8.dist-info}/WHEEL +0 -0
@@ -0,0 +1,931 @@
1
+ # EMerge is an open source Python based FEM EM simulation module.
2
+ # Copyright (C) 2025 Robert Fennis.
3
+
4
+ # This program is free software; you can redistribute it and/or
5
+ # modify it under the terms of the GNU General Public License
6
+ # as published by the Free Software Foundation; either version 2
7
+ # of the License, or (at your option) any later version.
8
+
9
+ # This program is distributed in the hope that it will be useful,
10
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
11
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12
+ # GNU General Public License for more details.
13
+
14
+ # You should have received a copy of the GNU General Public License
15
+ # along with this program; if not, see
16
+ # <https://www.gnu.org/licenses/>.
17
+ from __future__ import annotations
18
+ import time
19
+ from ...mesh3d import Mesh3D
20
+ from ...geometry import GeoObject
21
+ from ...selection import FaceSelection, DomainSelection, EdgeSelection, Selection, encode_data
22
+ from ...physics.microwave.microwave_bc import PortBC, ModalPort
23
+ import numpy as np
24
+ import pyvista as pv
25
+ from typing import Iterable, Literal, Callable
26
+ from ..display import BaseDisplay
27
+ from .display_settings import PVDisplaySettings
28
+ from matplotlib.colors import ListedColormap
29
+
30
+ ### Color scale
31
+
32
+ # Define the colors we want to use
33
+ col1 = np.array([57, 179, 227, 255])/255
34
+ col2 = np.array([22, 36, 125, 255])/255
35
+ col3 = np.array([33, 33, 33, 255])/255
36
+ col4 = np.array([173, 76, 7, 255])/255
37
+ col5 = np.array([250, 75, 148, 255])/255
38
+
39
+ cmap_names = Literal['bgy','bgyw','kbc','blues','bmw','bmy','kgy','gray','dimgray','fire','kb','kg','kr',
40
+ 'bkr','bky','coolwarm','gwv','bjy','bwy','cwr','colorwheel','isolum','rainbow','fire',
41
+ 'cet_fire','gouldian','kbgyw','cwr','CET_CBL1','CET_CBL3','CET_D1A']
42
+
43
+ def gen_cmap(mesh, N: int = 256):
44
+ # build a linear grid of data‐values (not strictly needed for pure colormap)
45
+ vmin, vmax = mesh['values'].min(), mesh['values'].max()
46
+ mapping = np.linspace(vmin, vmax, N)
47
+
48
+ # prepare output
49
+ newcolors = np.empty((N, 4))
50
+
51
+ # normalized positions of control points: start, middle, end
52
+ control_pos = np.array([0.0, 0.25, 0.5, 0.75, 1]) * (vmax - vmin) + vmin
53
+ # stack control colors
54
+ controls = np.vstack([col1, col2, col3, col4, col5])
55
+
56
+ # interp each RGBA channel independently
57
+ for chan in range(4):
58
+ newcolors[:, chan] = np.interp(mapping, control_pos, controls[:, chan])
59
+
60
+ return ListedColormap(newcolors)
61
+
62
+ def setdefault(options: dict, **kwargs) -> dict:
63
+ """Shorthand for overwriting non-existent keyword arguments with defaults
64
+
65
+ Args:
66
+ options (dict): The kwargs dict
67
+
68
+ Returns:
69
+ dict: the kwargs dict
70
+ """
71
+ for key in kwargs.keys():
72
+ if options.get(key,None) is None:
73
+ options[key] = kwargs[key]
74
+ return options
75
+
76
+ def _logscale(dx, dy, dz):
77
+ """
78
+ Logarithmically scales vector magnitudes so that the largest remains unchanged
79
+ and others are scaled down logarithmically.
80
+
81
+ Parameters:
82
+ dx, dy, dz (np.ndarray): Components of vectors.
83
+
84
+ Returns:
85
+ Tuple[np.ndarray, np.ndarray, np.ndarray]: Scaled dx, dy, dz arrays.
86
+ """
87
+ dx = np.asarray(dx)
88
+ dy = np.asarray(dy)
89
+ dz = np.asarray(dz)
90
+
91
+ # Compute original magnitudes
92
+ mags = np.sqrt(dx**2 + dy**2 + dz**2)
93
+ mags_nonzero = np.where(mags == 0, 1e-10, mags) # avoid log(0)
94
+
95
+ # Logarithmic scaling (scaled to max = original max)
96
+ log_mags = np.log10(mags_nonzero)
97
+ log_min = np.min(log_mags)
98
+ log_max = np.max(log_mags)
99
+
100
+ if log_max == log_min:
101
+ # All vectors have the same length
102
+ return dx, dy, dz
103
+
104
+ # Normalize log magnitudes to [0, 1]
105
+ log_scaled = (log_mags - log_min) / (log_max - log_min)
106
+
107
+ # Scale back to original max magnitude
108
+ max_mag = np.max(mags)
109
+ new_mags = log_scaled * max_mag
110
+
111
+ # Compute unit vectors
112
+ unit_dx = dx / mags_nonzero
113
+ unit_dy = dy / mags_nonzero
114
+ unit_dz = dz / mags_nonzero
115
+
116
+ # Apply scaled magnitudes
117
+ scaled_dx = unit_dx * new_mags
118
+ scaled_dy = unit_dy * new_mags
119
+ scaled_dz = unit_dz * new_mags
120
+
121
+ return scaled_dx, scaled_dy, scaled_dz
122
+
123
+ def _min_distance(xs, ys, zs):
124
+ """
125
+ Compute the minimum Euclidean distance between any two points
126
+ defined by the 1D arrays xs, ys, zs.
127
+
128
+ Parameters:
129
+ xs (np.ndarray): x-coordinates of the points
130
+ ys (np.ndarray): y-coordinates of the points
131
+ zs (np.ndarray): z-coordinates of the points
132
+
133
+ Returns:
134
+ float: The minimum Euclidean distance between any two points
135
+ """
136
+ # Stack the coordinates into a (N, 3) array
137
+ points = np.stack((xs, ys, zs), axis=-1)
138
+
139
+ # Compute pairwise squared distances using broadcasting
140
+ diff = points[:, np.newaxis, :] - points[np.newaxis, :, :]
141
+ dists_squared = np.sum(diff ** 2, axis=-1)
142
+
143
+ # Set diagonal to infinity to ignore zero distances to self
144
+ np.fill_diagonal(dists_squared, np.inf)
145
+
146
+ # Get the minimum distance
147
+ min_dist = np.sqrt(np.min(dists_squared))
148
+ return min_dist
149
+
150
+ def _norm(x, y, z):
151
+ return np.sqrt(np.abs(x)**2 + np.abs(y)**2 + np.abs(z)**2)
152
+
153
+ def _select(obj: GeoObject | Selection) -> Selection:
154
+ if isinstance(obj, GeoObject):
155
+ return obj.select
156
+ return obj
157
+
158
+ def _merge(lst: list[GeoObject | Selection]) -> Selection:
159
+ selections = [_select(item) for item in lst]
160
+ dim = selections[0].dim
161
+ all_tags = []
162
+ for item in lst:
163
+ all_tags.extend(_select(item).tags)
164
+
165
+ if dim==1:
166
+ return EdgeSelection(all_tags)
167
+ elif dim==2:
168
+ return FaceSelection(all_tags)
169
+ elif dim==3:
170
+ return DomainSelection(all_tags)
171
+
172
+ class _AnimObject:
173
+ """ A private class containing the required information for plot items in a view
174
+ that can be animated.
175
+ """
176
+ def __init__(self,
177
+ field: np.ndarray,
178
+ T: Callable,
179
+ grid: pv.Grid,
180
+ actor: pv.Actor,
181
+ on_update: Callable):
182
+ self.field: np.ndarray = field
183
+ self.T: Callable = T
184
+ self.grid: pv.Grid = grid
185
+ self.actor: pv.Actor = actor
186
+ self.on_update: Callable = on_update
187
+
188
+ def update(self, phi: complex):
189
+ self.on_update(self, phi)
190
+
191
+ class PVDisplay(BaseDisplay):
192
+
193
+ def __init__(self, mesh: Mesh3D):
194
+ self._mesh: Mesh3D = mesh
195
+ self.set: PVDisplaySettings = PVDisplaySettings()
196
+
197
+ # Animation options
198
+ self._facetags: list[int] = []
199
+ self._stop: bool = False
200
+ self._objs: list[_AnimObject] = []
201
+ self._do_animate: bool = False
202
+ self._Nsteps: int = None
203
+ self._fps: int = 25
204
+ self._ruler: ScreenRuler = ScreenRuler(self, 0.001)
205
+ self._selector: ScreenSelector = ScreenSelector(self)
206
+ self._stop = False
207
+ self._objs = []
208
+
209
+ self._plot = pv.Plotter()
210
+
211
+ self._plot.add_key_event("m", self.activate_ruler)
212
+ self._plot.add_key_event("f", self.activate_object)
213
+
214
+ self._ctr: int = 0
215
+ def activate_ruler(self):
216
+ self._plot.disable_picking()
217
+ self._selector.turn_off()
218
+ self._ruler.toggle()
219
+
220
+ def activate_object(self):
221
+ self._plot.disable_picking()
222
+ self._ruler.turn_off()
223
+ self._selector.toggle()
224
+
225
+ def show(self):
226
+ """ Shows the Pyvista display. """
227
+ self._ruler.min_length = max(1e-3, min(self._mesh.edge_lengths))
228
+ self._add_aux_items()
229
+ if self._do_animate:
230
+ self._plot.show(auto_close=False, interactive_update=True, before_close_callback=self._close_callback)
231
+ self._animate()
232
+ else:
233
+ self._plot.show()
234
+ self._reset()
235
+
236
+ def set_mesh(self, mesh: Mesh3D):
237
+ """Define the mesh to be used
238
+
239
+ Args:
240
+ mesh (Mesh3D): The mesh object
241
+ """
242
+ self._mesh = mesh
243
+
244
+ def _reset(self):
245
+ """ Resets key display parameters."""
246
+ self._plot.close()
247
+ self._plot = pv.Plotter()
248
+ self._stop = False
249
+ self._objs = []
250
+
251
+ def _close_callback(self):
252
+ """The private callback function that stops the animation.
253
+ """
254
+ self._stop = True
255
+
256
+ def _animate(self) -> None:
257
+ """Private function that starts the animation loop.
258
+ """
259
+ self._plot.update()
260
+ while not self._stop:
261
+ for step in range(self._Nsteps):
262
+ if self._stop:
263
+ break
264
+ for aobj in self._objs:
265
+ phi = np.exp(1j*(step/self._Nsteps)*2*np.pi)
266
+ aobj.update(phi)
267
+ self._plot.update()
268
+ time.sleep(1/self._fps)
269
+
270
+ def animate(self, Nsteps: int = 35, fps: int = 25) -> PVDisplay:
271
+ """ Turns on the animation mode with the specified number of steps and FPS.
272
+
273
+ All subsequent plot calls will automatically be animated. This method can be
274
+ method chained.
275
+
276
+ Args:
277
+ Nsteps (int, optional): The number of frames in the loop. Defaults to 35.
278
+ fps (int, optional): The number of frames per seocond, Defaults to 25
279
+
280
+ Returns:
281
+ PVDisplay: The same PVDisplay object
282
+
283
+ Example:
284
+ >>> display.animate().surf(...)
285
+ >>> display.show()
286
+ """
287
+ self._Nsteps = Nsteps
288
+ self._fps = fps
289
+ self._do_animate = True
290
+ return self
291
+
292
+ ## CUSTOM METHODS
293
+ def mesh_volume(self, volume: DomainSelection) -> pv.UnstructuredGrid:
294
+ tets = self._mesh.get_tetrahedra(volume.tags)
295
+ ntets = tets.shape[0]
296
+ cells = np.zeros((ntets,5), dtype=np.int64)
297
+ cells[:,1:] = self._mesh.tets[:,tets].T
298
+ cells[:,0] = 4
299
+ celltypes = np.full(ntets, fill_value=pv.CellType.TETRA, dtype=np.uint8)
300
+ points = self._mesh.nodes.T
301
+ return pv.UnstructuredGrid(cells, celltypes, points)
302
+
303
+ def mesh_surface(self, surface: FaceSelection) -> pv.UnstructuredGrid:
304
+ tris = self._mesh.get_triangles(surface.tags)
305
+ ntris = tris.shape[0]
306
+ cells = np.zeros((ntris,4), dtype=np.int64)
307
+ cells[:,1:] = self._mesh.tris[:,tris].T
308
+ cells[:,0] = 3
309
+ celltypes = np.full(ntris, fill_value=pv.CellType.TRIANGLE, dtype=np.uint8)
310
+ points = self._mesh.nodes.T
311
+ return pv.UnstructuredGrid(cells, celltypes, points)
312
+
313
+ def mesh(self, obj: GeoObject | Selection | Iterable) -> pv.UnstructuredGrid:
314
+ if isinstance(obj, Iterable):
315
+ obj = _merge(obj)
316
+ else:
317
+ obj = _select(obj)
318
+
319
+ if isinstance(obj, DomainSelection):
320
+ return self.mesh_volume(obj)
321
+ elif isinstance(obj, FaceSelection):
322
+ return self.mesh_surface(obj)
323
+
324
+ ## OBLIGATORY METHODS
325
+ def add_object(self, obj: GeoObject | Selection | Iterable, *args, **kwargs):
326
+ kwargs = setdefault(kwargs, color=obj.color_rgb, opacity=obj.opacity, silhouette=True)
327
+ self._plot.add_mesh(self.mesh(obj), pickable=True, *args, **kwargs)
328
+
329
+ def add_scatter(self, xs: np.ndarray, ys: np.ndarray, zs: np.ndarray):
330
+ """Adds a scatter point cloud
331
+
332
+ Args:
333
+ xs (np.ndarray): The X-coordinate
334
+ ys (np.ndarray): The Y-coordinate
335
+ zs (np.ndarray): The Z-coordinate
336
+ """
337
+ cloud = pv.PolyData(np.array([xs,ys,zs]).T)
338
+ self._plot.add_points(cloud)
339
+
340
+ def add_portmode(self, port: PortBC,
341
+ Npoints: int = 10,
342
+ dv=(0,0,0),
343
+ XYZ=None,
344
+ field: Literal['E','H'] = 'E',
345
+ k0: float = None,
346
+ mode_number: int = None) -> pv.UnstructuredGrid:
347
+
348
+ if XYZ:
349
+ X,Y,Z = XYZ
350
+ else:
351
+ tris = self._mesh.get_triangles(port.selection.tags)
352
+ X = self._mesh.tri_centers[0,tris]
353
+ Y = self._mesh.tri_centers[1,tris]
354
+ Z = self._mesh.tri_centers[2,tris]
355
+
356
+ X = X+dv[0]
357
+ Y = Y+dv[1]
358
+ Z = Z+dv[2]
359
+ xf = X.flatten()
360
+ yf = Y.flatten()
361
+ zf = Z.flatten()
362
+
363
+ d = _min_distance(xf, yf, zf)
364
+
365
+ if port.vintline is not None:
366
+ xs, ys, zs = port.vintline.cpoint
367
+ p_line = pv.Line(
368
+ pointa=(xs[0], ys[0], zs[0]),
369
+ pointb=(xs[-1], ys[-1], zs[-1]),
370
+ )
371
+ self._plot.add_mesh(
372
+ p_line,
373
+ color='red',
374
+ pickable=False,
375
+ line_width=3.0,
376
+ )
377
+
378
+ if k0 is None:
379
+ if isinstance(port, ModalPort):
380
+ k0 = port.get_mode(0).k0
381
+ else:
382
+ k0 = 1
383
+ F = port.port_mode_3d_global(xf,yf,zf,k0, which=field)
384
+
385
+ Fx = F[0,:].reshape(X.shape).T
386
+ Fy = F[1,:].reshape(X.shape).T
387
+ Fz = F[2,:].reshape(X.shape).T
388
+
389
+ if field=='H':
390
+ F = np.imag(F.T)
391
+ Fnorm = np.sqrt(Fx.imag**2 + Fy.imag**2 + Fz.imag**2).T
392
+ else:
393
+ F = np.real(F.T)
394
+ Fnorm = np.sqrt(Fx.real**2 + Fy.real**2 + Fz.real**2).T
395
+
396
+ if XYZ is not None:
397
+ grid = pv.StructuredGrid(X,Y,Z)
398
+ self.add_surf(X,Y,Z,Fnorm, _fieldname = 'portfield')
399
+ self._plot.add_mesh(grid, scalars = Fnorm.T, opacity=0.8, pickable=False)
400
+
401
+ Emag = F/np.max(Fnorm.flatten())*d*3
402
+ self._plot.add_arrows(np.array([xf,yf,zf]).T, Emag)
403
+
404
+ def add_surf(self,
405
+ x: np.ndarray,
406
+ y: np.ndarray,
407
+ z: np.ndarray,
408
+ field: np.ndarray,
409
+ scale: Literal['lin','log','symlog'] = 'lin',
410
+ cmap: cmap_names = 'coolwarm',
411
+ clim: tuple[float, float] = None,
412
+ opacity: float = 1.0,
413
+ symmetrize: bool = True,
414
+ _fieldname: str = None,
415
+ **kwargs,):
416
+ """Add a surface plot to the display
417
+ The X,Y,Z coordinates must be a 2D grid of data points. The field must be a real field with the same size.
418
+
419
+ Args:
420
+ x (np.ndarray): The X-grid array
421
+ y (np.ndarray): The Y-grid array
422
+ z (np.ndarray): The Z-grid array
423
+ field (np.ndarray): The scalar field to display
424
+ scale (Literal["lin","log","symlog"], optional): The colormap scaling¹. Defaults to 'lin'.
425
+ cmap (cmap_names, optional): The colormap. Defaults to 'coolwarm'.
426
+ clim (tuple[float, float], optional): Specific color limits (min, max). Defaults to None.
427
+ opacity (float, optional): The opacity of the surface. Defaults to 1.0.
428
+ symmetrize (bool, optional): Wether to force a symmetrical color limit (-A,A). Defaults to True.
429
+
430
+ (¹): lin: f(x)=x, log: f(x)=log₁₀(|x|), symlog: f(x)=sgn(x)·log₁₀(1+|x·ln(10)|)
431
+ """
432
+
433
+ grid = pv.StructuredGrid(x,y,z)
434
+ field_flat = field.flatten(order='F')
435
+
436
+ if scale=='log':
437
+ T = lambda x: np.log10(np.abs(x))
438
+ elif scale=='symlog':
439
+ T = lambda x: np.sign(x) * np.log10(1 + np.abs(x*np.log(10)))
440
+ else:
441
+ T = lambda x: x
442
+
443
+ static_field = T(np.real(field_flat))
444
+ if _fieldname is None:
445
+ name = 'anim'+str(self._ctr)
446
+ else:
447
+ name = _fieldname
448
+ self._ctr += 1
449
+ grid[name] = static_field
450
+
451
+ if clim is None:
452
+ fmin = np.min(static_field)
453
+ fmax = np.max(static_field)
454
+ clim = (fmin, fmax)
455
+
456
+ if symmetrize:
457
+ lim = max(abs(clim[0]),abs(clim[1]))
458
+ clim = (-lim, lim)
459
+
460
+ kwargs = setdefault(kwargs, cmap=cmap, clim=clim, opacity=opacity, pickable=False, multi_colors=True)
461
+ actor = self._plot.add_mesh(grid, scalars=name, **kwargs)
462
+
463
+ if self._animate:
464
+ def on_update(obj: _AnimObject, phi: complex):
465
+ field = obj.T(np.real(obj.field*phi))
466
+ obj.grid['anim'] = field
467
+ self._objs.append(_AnimObject(field_flat, T, grid, actor, on_update))
468
+
469
+
470
+ def add_title(self, title: str) -> None:
471
+ """Adds a title
472
+
473
+ Args:
474
+ title (str): The title name
475
+ """
476
+ self._plot.add_text(
477
+ title,
478
+ position='upper_edge',
479
+ font_size=18)
480
+
481
+ def add_text(self, text: str,
482
+ color: str = 'black',
483
+ position: Literal['lower_left', 'lower_right', 'upper_left', 'upper_right', 'lower_edge', 'upper_edge', 'right_edge', 'left_edge']='upper_right',
484
+ abs_position: tuple[float, float, float] = None):
485
+ viewport = False
486
+ if abs_position is not None:
487
+ position = abs_position
488
+ viewport = True
489
+ self._plot.add_text(
490
+ text,
491
+ position=position,
492
+ color=color,
493
+ font_size=18,
494
+ viewport=viewport)
495
+
496
+ def add_quiver(self, x: np.ndarray, y: np.ndarray, z: np.ndarray,
497
+ dx: np.ndarray, dy: np.ndarray, dz: np.ndarray,
498
+ scale: float = 1,
499
+ color: tuple[float, float, float] = None,
500
+ scalemode: Literal['lin','log'] = 'lin'):
501
+ """Add a quiver plot to the display
502
+
503
+ Args:
504
+ x (np.ndarray): The X-coordinates
505
+ y (np.ndarray): The Y-coordinates
506
+ z (np.ndarray): The Z-coordinates
507
+ dx (np.ndarray): The arrow X-magnitude
508
+ dy (np.ndarray): The arrow Y-magnitude
509
+ dz (np.ndarray): The arrow Z-magnitude
510
+ scale (float, optional): The arrow scale. Defaults to 1.
511
+ scalemode (Literal['lin','log'], optional): Wether to scale lin or log. Defaults to 'lin'.
512
+ """
513
+ x = x.flatten()
514
+ y = y.flatten()
515
+ z = z.flatten()
516
+ dx = dx.flatten().real
517
+ dy = dy.flatten().real
518
+ dz = dz.flatten().real
519
+ dmin = _min_distance(x,y,z)
520
+
521
+ dmax = np.max(_norm(dx,dy,dz))
522
+
523
+ Vec = scale * np.array([dx,dy,dz]).T / dmax * dmin
524
+ Coo = np.array([x,y,z]).T
525
+ if scalemode=='log':
526
+ dx, dy, dz = _logscale(Vec[:,0], Vec[:,1], Vec[:,2])
527
+ Vec[:,0] = dx
528
+ Vec[:,1] = dy
529
+ Vec[:,2] = dz
530
+
531
+ kwargs = dict()
532
+ if color is not None:
533
+ kwargs['color'] = color
534
+ pl = self._plot.add_arrows(Coo, Vec, scalars=None, clim=None, cmap=None, **kwargs)
535
+
536
+ def add_contour(self,
537
+ X: np.ndarray,
538
+ Y: np.ndarray,
539
+ Z: np.ndarray,
540
+ V: np.ndarray,
541
+ Nlevels: int = 5,
542
+ symmetrize: bool = True,
543
+ cmap: str = 'viridis'):
544
+ """Adds a 3D volumetric contourplot based on a 3D grid of X,Y,Z and field values
545
+
546
+
547
+ Args:
548
+ X (np.ndarray): A 3D Grid of X-values
549
+ Y (np.ndarray): A 3D Grid of Y-values
550
+ Z (np.ndarray): A 3D Grid of Z-values
551
+ V (np.ndarray): The scalar quantity to plot ()
552
+ Nlevels (int, optional): The number of contour levels. Defaults to 5.
553
+ symmetrize (bool, optional): Wether to symmetrize the countour levels (-V,V). Defaults to True.
554
+ cmap (str, optional): The color map. Defaults to 'viridis'.
555
+ """
556
+ Vf = V.flatten()
557
+ vmin = np.min(np.real(Vf))
558
+ vmax = np.max(np.real(Vf))
559
+ if symmetrize:
560
+ level = max(np.abs(vmin),np.abs(vmax))
561
+ vmin, vmax = (-level, level)
562
+ grid = pv.StructuredGrid(X,Y,Z)
563
+ field = V.flatten(order='F')
564
+ grid['anim'] = np.real(field)
565
+ levels = np.linspace(vmin, vmax, Nlevels)
566
+ contour = grid.contour(isosurfaces=levels)
567
+ actor = self._plot.add_mesh(contour, opacity=0.25, cmap=cmap, pickable=False)
568
+
569
+ if self._animate:
570
+ def on_update(obj: _AnimObject, phi: complex):
571
+ new_vals = np.real(obj.field * phi)
572
+ obj.grid['anim'] = new_vals
573
+ new_contour = obj.grid.contour(isosurfaces=levels)
574
+ obj.actor.GetMapper().SetInputData(new_contour)
575
+
576
+ self._objs.append(_AnimObject(field, lambda x: x, grid, actor, on_update))
577
+
578
+ def _add_aux_items(self) -> None:
579
+ saved_camera = {
580
+ "position": self._plot.camera.position,
581
+ "focal_point": self._plot.camera.focal_point,
582
+ "view_up": self._plot.camera.up,
583
+ "view_angle": self._plot.camera.view_angle,
584
+ "clipping_range": self._plot.camera.clipping_range
585
+ }
586
+ #self._plot.add_logo_widget('src/_img/logo.jpeg',position=(0.89,0.89), size=(0.1,0.1))
587
+ bounds = self._plot.bounds
588
+ max_size = max([abs(dim) for dim in [bounds.x_max, bounds.x_min, bounds.y_max, bounds.y_min, bounds.z_max, bounds.z_min]])
589
+ length = self.set.plane_ratio*max_size*2
590
+ if self.set.draw_xplane:
591
+ plane = pv.Plane(
592
+ center=(0, 0, 0),
593
+ direction=(1, 0, 0), # normal vector pointing along +X
594
+ i_size=length,
595
+ j_size=length,
596
+ i_resolution=1,
597
+ j_resolution=1
598
+ )
599
+ self._plot.add_mesh(
600
+ plane,
601
+ color='red',
602
+ opacity=self.set.plane_opacity,
603
+ show_edges=False,
604
+ pickable=False,
605
+ )
606
+ self._plot.add_mesh(
607
+ plane,
608
+ edge_opacity=1.0,
609
+ edge_color='red',
610
+ color='red',
611
+ line_width=self.set.plane_edge_width,
612
+ style='wireframe',
613
+ pickable=False,
614
+ )
615
+
616
+ if self.set.draw_yplane:
617
+ plane = pv.Plane(
618
+ center=(0, 0, 0),
619
+ direction=(0, 1, 0), # normal vector pointing along +X
620
+ i_size=length,
621
+ j_size=length,
622
+ i_resolution=1,
623
+ j_resolution=1
624
+ )
625
+ self._plot.add_mesh(
626
+ plane,
627
+ color='green',
628
+ opacity=self.set.plane_opacity,
629
+ show_edges=False,
630
+ pickable=False,
631
+ )
632
+ self._plot.add_mesh(
633
+ plane,
634
+ edge_opacity=1.0,
635
+ edge_color='green',
636
+ color='green',
637
+ line_width=self.set.plane_edge_width,
638
+ style='wireframe',
639
+ pickable=False,
640
+ )
641
+ if self.set.draw_zplane:
642
+ plane = pv.Plane(
643
+ center=(0, 0, 0),
644
+ direction=(0, 0, 1), # normal vector pointing along +X
645
+ i_size=length,
646
+ j_size=length,
647
+ i_resolution=1,
648
+ j_resolution=1
649
+ )
650
+ self._plot.add_mesh(
651
+ plane,
652
+ color='blue',
653
+ opacity=self.set.plane_opacity,
654
+ show_edges=False,
655
+ pickable=False,
656
+ )
657
+ self._plot.add_mesh(
658
+ plane,
659
+ edge_opacity=1.0,
660
+ edge_color='blue',
661
+ color='blue',
662
+ line_width=self.set.plane_edge_width,
663
+ style='wireframe',
664
+ pickable=False,
665
+ )
666
+ # Draw X-axis
667
+ if getattr(self.set, 'draw_xax', False):
668
+ x_line = pv.Line(
669
+ pointa=(-length, 0, 0),
670
+ pointb=(length, 0, 0),
671
+ )
672
+ self._plot.add_mesh(
673
+ x_line,
674
+ color='red',
675
+ line_width=self.set.axis_line_width,
676
+ pickable=False,
677
+ )
678
+
679
+ # Draw Y-axis
680
+ if getattr(self.set, 'draw_yax', False):
681
+ y_line = pv.Line(
682
+ pointa=(0, -length, 0),
683
+ pointb=(0, length, 0),
684
+ )
685
+ self._plot.add_mesh(
686
+ y_line,
687
+ color='green',
688
+ line_width=self.set.axis_line_width,
689
+ pickable=False,
690
+ )
691
+
692
+ # Draw Z-axis
693
+ if getattr(self.set, 'draw_zax', False):
694
+ z_line = pv.Line(
695
+ pointa=(0, 0, -length),
696
+ pointb=(0, 0, length),
697
+ )
698
+ self._plot.add_mesh(
699
+ z_line,
700
+ color='blue',
701
+ line_width=self.set.axis_line_width,
702
+ pickable=False,
703
+ )
704
+
705
+ exponent = np.floor(np.log10(length))
706
+ gs = 10 ** exponent
707
+ N = np.ceil(length/gs)
708
+ if N < 5:
709
+ gs = gs/10
710
+ L = (2*np.ceil(length/(2*gs))+1)*gs
711
+
712
+ # XY grid at Z=0
713
+ if self.set.show_zgrid:
714
+ x_vals = np.arange(-L, L+gs, gs)
715
+ y_vals = np.arange(-L, L+gs, gs)
716
+
717
+ # lines parallel to X
718
+ for y in y_vals:
719
+ line = pv.Line(
720
+ pointa=(-L, y, 0),
721
+ pointb=(L, y, 0)
722
+ )
723
+ self._plot.add_mesh(line, color=self.set.grid_line_color, line_width=self.set.grid_line_width, opacity=0.5, edge_opacity=0.5,pickable=False)
724
+
725
+ # lines parallel to Y
726
+ for x in x_vals:
727
+ line = pv.Line(
728
+ pointa=(x, -L, 0),
729
+ pointb=(x, L, 0)
730
+ )
731
+ self._plot.add_mesh(line, color=self.set.grid_line_color, line_width=self.set.grid_line_width, opacity=0.5, edge_opacity=0.5,pickable=False)
732
+
733
+
734
+ # YZ grid at X=0
735
+ if self.set.show_xgrid:
736
+ y_vals = np.arange(-L, L+gs, gs)
737
+ z_vals = np.arange(-L, L+gs, gs)
738
+
739
+ # lines parallel to Y
740
+ for z in z_vals:
741
+ line = pv.Line(
742
+ pointa=(0, -L, z),
743
+ pointb=(0, L, z)
744
+ )
745
+ self._plot.add_mesh(line, color=self.set.grid_line_color, line_width=self.set.grid_line_width, opacity=0.5, edge_opacity=0.5, pickable=False)
746
+
747
+ # lines parallel to Z
748
+ for y in y_vals:
749
+ line = pv.Line(
750
+ pointa=(0, y, -L),
751
+ pointb=(0, y, L)
752
+ )
753
+ self._plot.add_mesh(line, color=self.set.grid_line_color, line_width=self.set.grid_line_width, opacity=0.5, edge_opacity=0.5, pickable=False)
754
+
755
+
756
+ # XZ grid at Y=0
757
+ if self.set.show_ygrid:
758
+ x_vals = np.arange(-L, L+gs, gs)
759
+ z_vals = np.arange(-L, L+gs, gs)
760
+
761
+ # lines parallel to X
762
+ for z in z_vals:
763
+ line = pv.Line(
764
+ pointa=(-length, 0, z),
765
+ pointb=(length, 0, z)
766
+ )
767
+ self._plot.add_mesh(line, color=self.set.grid_line_color, line_width=self.set.grid_line_width, opacity=0.5, edge_opacity=0.5, pickable=False)
768
+
769
+ # lines parallel to Z
770
+ for x in x_vals:
771
+ line = pv.Line(
772
+ pointa=(x, 0, -length),
773
+ pointb=(x, 0, length)
774
+ )
775
+ self._plot.add_mesh(line, color=self.set.grid_line_color, line_width=self.set.grid_line_width, opacity=0.5, edge_opacity=0.5, pickable=False)
776
+
777
+ if self.set.add_light:
778
+ light = pv.Light()
779
+ light.set_direction_angle(*self.set.light_angle)
780
+ self._plot.add_light(light)
781
+
782
+ self._plot.set_background(self.set.background_bottom, top=self.set.background_top)
783
+ self._plot.add_axes()
784
+
785
+ self._plot.camera.position = saved_camera["position"]
786
+ self._plot.camera.focal_point = saved_camera["focal_point"]
787
+ self._plot.camera.up = saved_camera["view_up"]
788
+ self._plot.camera.view_angle = saved_camera["view_angle"]
789
+ self._plot.camera.clipping_range = saved_camera["clipping_range"]
790
+
791
+
792
+
793
+ def freeze(function):
794
+
795
+ def new_function(self, *args, **kwargs):
796
+ cam = self.disp._plot.camera_position[:]
797
+ self.disp._plot.suppress_rendering = True
798
+ function(self, *args, **kwargs)
799
+ self.disp._plot.camera_position = cam
800
+ self.disp._plot.suppress_rendering = False
801
+ self.disp._plot.render()
802
+ return new_function
803
+
804
+
805
+ class ScreenSelector:
806
+
807
+ def __init__(self, display: PVDisplay):
808
+ self.disp: PVDisplay = display
809
+ self.original_actors: list[pv.Actor] = []
810
+ self.select_actors: list[pv.Actor] = []
811
+ self.grids: list[pv.UnstructuredGrid] = []
812
+ self.surfs: dict[int, np.ndarray] = dict()
813
+ self.state = False
814
+
815
+ def toggle(self):
816
+ if self.state:
817
+ self.turn_off()
818
+ else:
819
+ self.activate()
820
+
821
+ def activate(self):
822
+ self.original_actors = list(self.disp._plot.actors.values())
823
+
824
+ for actor in self.original_actors:
825
+ if isinstance(actor, pv.Text):
826
+ continue
827
+ actor.pickable = False
828
+
829
+ if len(self.grids) == 0:
830
+ for key in self.disp._facetags:
831
+ tris = self.disp._mesh.get_triangles(key)
832
+ ntris = tris.shape[0]
833
+ cells = np.zeros((ntris,4), dtype=np.int64)
834
+ cells[:,1:] = self.disp._mesh.tris[:,tris].T
835
+ cells[:,0] = 3
836
+ nodes = np.unique(self.disp._mesh.tris[:,tris].flatten())
837
+ celltypes = np.full(ntris, fill_value=pv.CellType.TRIANGLE, dtype=np.uint8)
838
+ points = self.disp._mesh.nodes.T
839
+ grid = pv.UnstructuredGrid(cells, celltypes, points)
840
+ grid._tag = key
841
+ self.grids.append(grid)
842
+ self.surfs[key] = points[nodes,:].T
843
+
844
+ self.select_actors = []
845
+ for grid in self.grids:
846
+ actor = self.disp._plot.add_mesh(grid, opacity=0.001, color='red', pickable=True, name=f'FaceTag_{grid._tag}')
847
+ self.select_actors.append(actor)
848
+
849
+ def callback(actor: pv.Actor):
850
+ key = int(actor.name.split('_')[1])
851
+ points = self.surfs[key]
852
+ xs = points[0,:]
853
+ ys = points[1,:]
854
+ zs = points[2,:]
855
+ meanx = np.mean(xs)
856
+ meany = np.mean(ys)
857
+ meanz = np.mean(zs)
858
+ data = (meanx, meany, meanz, min(xs), min(ys), min(zs), max(xs), max(ys), max(zs))
859
+ encoded = encode_data(data)
860
+ print(f'Face code key={key}: ', encoded)
861
+
862
+ self.disp._plot.enable_mesh_picking(callback, style='surface', left_clicking=True, use_actor=True)
863
+
864
+ def turn_off(self) -> None:
865
+ for actor in self.select_actors:
866
+ self.disp._plot.remove_actor(actor)
867
+ self.select_actors = []
868
+ for actor in self.original_actors:
869
+ if isinstance(actor, pv.Text):
870
+ continue
871
+ actor.pickable = True
872
+
873
+
874
+ class ScreenRuler:
875
+
876
+ def __init__(self, display: PVDisplay, min_length: float):
877
+ self.disp: PVDisplay = display
878
+ self.points: list[tuple] = [(0,0,0),(0,0,0)]
879
+ self.text: pv.Text = None
880
+ self.ruler = None
881
+ self.state = False
882
+ self.min_length: float = min_length
883
+
884
+ @freeze
885
+ def toggle(self):
886
+ if not self.state:
887
+ self.state = True
888
+ self.disp._plot.enable_point_picking(self._add_point, left_clicking=True, tolerance=self.min_length)
889
+ else:
890
+ self.state = False
891
+ self.disp._plot.disable_picking()
892
+
893
+ @freeze
894
+ def turn_off(self):
895
+ self.state = False
896
+ self.disp._plot.disable_picking()
897
+
898
+ @property
899
+ def dist(self) -> float:
900
+ p1, p2 = self.points
901
+ return ((p1[0]-p2[0])**2 + (p1[1]-p2[1])**2 + (p1[2]-p2[2])**2)**(0.5)
902
+
903
+ @property
904
+ def middle(self) -> tuple[float, float, float]:
905
+ p1, p2 = self.points
906
+ return ((p1[0]+p2[0])/2, (p1[1]+p2[1])/2, (p1[2]+p2[2])/2)
907
+
908
+ @property
909
+ def measurement_string(self) -> str:
910
+ dist = self.dist
911
+ p1, p2 = self.points
912
+ dx = p2[0]-p1[0]
913
+ dy = p2[1]-p1[1]
914
+ dz = p2[2]-p1[2]
915
+ return f'{dist*1000:.2f}mm (dx={1000.*dx:.4f}mm, dy={1000.*dy:.4f}mm, dz={1000.*dz:.4f}mm)'
916
+
917
+ def set_ruler(self) -> None:
918
+ if self.ruler is None:
919
+ self.ruler = self.disp._plot.add_ruler(self.points[0], self.points[1], title=f'{1000*self.dist:.2f}mm')
920
+ else:
921
+ p1 = self.ruler.GetPositionCoordinate()
922
+ p2 = self.ruler.GetPosition2Coordinate()
923
+ p1.SetValue(*self.points[0])
924
+ p2.SetValue(*self.points[1])
925
+ self.ruler.SetTitle(f'{1000*self.dist:.2f}mm')
926
+
927
+ @freeze
928
+ def _add_point(self, point: tuple[float, float, float]):
929
+ self.points = [point,self.points[0]]
930
+ self.text = self.disp._plot.add_text(self.measurement_string, self.middle, name='RulerText')
931
+ self.set_ruler()