bsplyne 1.0.0__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.
bsplyne/b_spline.py ADDED
@@ -0,0 +1,2464 @@
1
+ import os
2
+ from typing import Iterable, Literal, Union
3
+ import json, pickle
4
+
5
+ import numpy as np
6
+ import scipy.sparse as sps
7
+ import meshio as io
8
+ import matplotlib as mpl
9
+ from scipy.interpolate import griddata
10
+
11
+ from bsplyne.b_spline_basis import BSplineBasis
12
+ from bsplyne.my_wide_product import my_wide_product
13
+ from bsplyne.save_utils import writePVD
14
+
15
+ _can_visualize_pyvista = False
16
+ try:
17
+ import pyvista as pv
18
+
19
+ _can_visualize_pyvista = True
20
+ except ImportError:
21
+ pass
22
+
23
+
24
+ class BSpline:
25
+ """
26
+ BSpline class for representing and manipulating B-spline curves, surfaces and volumes.
27
+
28
+ A class providing functionality for evaluating, manipulating and visualizing B-splines of arbitrary dimension.
29
+ Supports knot insertion, order elevation, and visualization through Paraview and Matplotlib.
30
+
31
+ Attributes
32
+ ----------
33
+ NPa : int
34
+ Dimension of the isoparametric space.
35
+ bases : np.ndarray[BSplineBasis]
36
+ Array containing `BSplineBasis` instances for each isoparametric dimension.
37
+
38
+ Notes
39
+ -----
40
+ - Supports B-splines of arbitrary dimension (curves, surfaces, volumes, etc.)
41
+ - Provides methods for evaluation, derivatives, refinement and visualization
42
+ - Uses Cox-de Boor recursion formulas for efficient basis function evaluation
43
+ - Visualization available through Paraview (VTK) and Matplotlib
44
+
45
+ See Also
46
+ --------
47
+ `BSplineBasis` : Class representing one-dimensional B-spline basis functions
48
+ `numpy.ndarray` : Array type used for control points and evaluations
49
+ `scipy.sparse` : Sparse matrix formats used for basis function evaluations
50
+ """
51
+
52
+ NPa: int
53
+ bases: np.ndarray[BSplineBasis]
54
+
55
+ def __init__(
56
+ self, degrees: Iterable[int], knots: Iterable[np.ndarray[np.floating]]
57
+ ):
58
+ """
59
+ Initialize a `BSpline` instance with specified degrees and knot vectors.
60
+
61
+ Creates a `BSpline` object by generating basis functions for each isoparametric dimension
62
+ using the provided polynomial degrees and knot vectors.
63
+
64
+ Parameters
65
+ ----------
66
+ degrees : Iterable[int]
67
+ Collection of polynomial degrees for each isoparametric dimension.
68
+ The length determines the dimensionality of the parametric space (`NPa`).
69
+ For example:
70
+ - [p] for a curve
71
+ - [p, q] for a surface
72
+ - [p, q, r] for a volume
73
+ - ...
74
+
75
+ knots : Iterable[np.ndarray[np.floating]]
76
+ Collection of knot vectors for each isoparametric dimension.
77
+ Each knot vector must be a numpy array of `floats`.
78
+ The number of knot vectors must match the number of degrees.
79
+ For a degree `p`, the knot vector must have size `m + 1` where `m>=p`.
80
+
81
+ Notes
82
+ -----
83
+ - The number of control points in each dimension will be `m - p` where `m` is
84
+ the size of the knot vector minus 1 and `p` is the degree
85
+ - Each knot vector must be non-decreasing
86
+ - The multiplicity of each knot must not exceed `p + 1`
87
+
88
+ Examples
89
+ --------
90
+ Create a 2D B-spline surface:
91
+ >>> degrees = [2, 2]
92
+ >>> knots = [np.array([0, 0, 0, 0.5, 1, 1, 1], dtype='float'),
93
+ ... np.array([0, 0, 0, 0.5, 1, 1, 1], dtype='float')]
94
+ >>> spline = BSpline(degrees, knots)
95
+
96
+ Create a 1D B-spline curve:
97
+ >>> degree = [3]
98
+ >>> knot = [np.array([0, 0, 0, 0, 1, 1, 1, 1], dtype='float')]
99
+ >>> curve = BSpline(degree, knot)
100
+ """
101
+ self.NPa = len(degrees)
102
+ self.bases = np.empty(self.NPa, dtype="object")
103
+ for idx in range(self.NPa):
104
+ p = degrees[idx]
105
+ knot = knots[idx]
106
+ self.bases[idx] = BSplineBasis(p, knot)
107
+
108
+ @classmethod
109
+ def from_bases(cls, bases: Iterable[BSplineBasis]) -> "BSpline":
110
+ """
111
+ Create a BSpline instance from an array of `BSplineBasis` objects.
112
+ This is an alternative constructor that allows direct initialization from
113
+ existing basis functions rather than creating new ones from degrees and knot
114
+ vectors.
115
+
116
+ Parameters
117
+ ----------
118
+ bases : Iterable[BSplineBasis]
119
+ An iterable (e.g. list, tuple, array) containing `BSplineBasis` instances.
120
+ Each basis represents one parametric dimension of the resulting B-spline.
121
+ The number of bases determines the dimensionality of the parametric space.
122
+
123
+ Returns
124
+ -------
125
+ BSpline
126
+ A new `BSpline` instance with the provided basis functions.
127
+
128
+ Notes
129
+ -----
130
+ - The method initializes a new `BSpline` instance with empty degrees and knots
131
+ - The bases array is populated with the provided `BSplineBasis` objects
132
+ - The dimensionality (`NPa`) is determined by the number of basis functions
133
+
134
+ Examples
135
+ --------
136
+ >>> basis1 = BSplineBasis(2, np.array([0, 0, 0, 1, 1, 1]))
137
+ >>> basis2 = BSplineBasis(2, np.array([0, 0, 0, 0.5, 1, 1, 1]))
138
+ >>> spline = BSpline.from_bases([basis1, basis2])
139
+ """
140
+ self = cls([], [])
141
+ self.NPa = len(bases)
142
+ self.bases = np.empty(self.NPa, dtype="object")
143
+ self.bases[:] = bases
144
+ return self
145
+
146
+ def getDegrees(self) -> np.ndarray[np.integer]:
147
+ """
148
+ Returns the polynomial degree of each basis function in the isoparametric space.
149
+
150
+ Returns
151
+ -------
152
+ degrees : np.ndarray[np.integer]
153
+ Array containing the polynomial degrees of the B-spline basis functions.
154
+ The array has length `NPa` (dimension of isoparametric space), where each element
155
+ represents the degree of the corresponding isoparametric dimension.
156
+
157
+ Notes
158
+ -----
159
+ - For a curve (1D), returns [degree_xi]
160
+ - For a surface (2D), returns [degree_xi, degree_eta]
161
+ - For a volume (3D), returns [degree_xi, degree_eta, degree_zeta]
162
+ - ...
163
+
164
+ Examples
165
+ --------
166
+ >>> degrees = np.array([2, 2], dtype='int')
167
+ >>> knots = [np.array([0, 0, 0, 0.5, 1, 1, 1], dtype='float'),
168
+ ... np.array([0, 0, 0, 0.5, 1, 1, 1], dtype='float')]
169
+ >>> spline = BSpline(degrees, knots)
170
+ >>> spline.getDegrees()
171
+ array([2, 2])
172
+ """
173
+ degrees = np.array([basis.p for basis in self.bases], dtype="int")
174
+ return degrees
175
+
176
+ def getKnots(self) -> list[np.ndarray[np.floating]]:
177
+ """
178
+ Returns the knot vector of each basis function in the isoparametric space.
179
+
180
+ This method collects all knot vectors from each `BSplineBasis` instance stored
181
+ in the `bases` array. The knot vectors define the isoparametric space partitioning
182
+ and the regularity properties of the B-spline.
183
+
184
+ Returns
185
+ -------
186
+ knots : list[np.ndarray[np.floating]]
187
+ List containing the knot vectors of the B-spline basis functions.
188
+ The list has length `NPa` (dimension of isoparametric space), where each element
189
+ is a `numpy.ndarray` containing the knots for the corresponding isoparametric dimension.
190
+
191
+ Notes
192
+ -----
193
+ - For a curve (1D), returns [`knots_xi`]
194
+ - For a surface (2D), returns [`knots_xi`, `knots_eta`]
195
+ - For a volume (3D), returns [`knots_xi`, `knots_eta`, `knots_zeta`]
196
+ - Each knot vector must be non-decreasing
197
+ - The multiplicity of interior knots determines the continuity at that point
198
+
199
+ Examples
200
+ --------
201
+ >>> degrees = [2, 2]
202
+ >>> knots = [np.array([0, 0, 0, 0.5, 1, 1, 1], dtype='float'),
203
+ ... np.array([0, 0, 0, 0.5, 1, 1, 1], dtype='float')]
204
+ >>> spline = BSpline(degrees, knots)
205
+ >>> spline.getKnots()
206
+ [array([0., 0., 0., 0.5, 1., 1., 1.]),
207
+ array([0., 0., 0., 0.5, 1., 1., 1.])]
208
+ """
209
+ knots = [basis.knot for basis in self.bases]
210
+ return knots
211
+
212
+ def getCtrlShape(self) -> tuple[int]:
213
+ """
214
+ Get the shape of the control grid (number of control points per dimension).
215
+
216
+ This method returns a tuple giving, for each isoparametric direction,
217
+ the number of control points associated with the corresponding B-spline basis.
218
+ In each dimension, this number is equal to `n + 1`, where `n` is the highest
219
+ basis function index.
220
+
221
+ Returns
222
+ -------
223
+ tuple of int
224
+ A tuple giving the number of control points in each dimension.
225
+
226
+ Notes
227
+ -----
228
+ - For a curve (1D), returns a single integer (`n1 + 1`,)
229
+ - For a surface (2D), returns (`n1 + 1`, `n2 + 1`)
230
+ - For a volume (3D), returns (`n1 + 1`, `n2 + 1`, `n3 + 1`)
231
+ - The product of these values gives the total number of control points,
232
+ identical to the number of basis functions (`getNbFunc()`).
233
+
234
+ Examples
235
+ --------
236
+ >>> degrees = [2, 2]
237
+ >>> knots = [np.array([0, 0, 0, 0.5, 1, 1, 1], dtype='float'),
238
+ ... np.array([0, 0, 0, 0.5, 1, 1, 1], dtype='float')]
239
+ >>> spline = BSpline(degrees, knots)
240
+ >>> spline.getCtrlShape()
241
+ (4, 4)
242
+ """
243
+ return tuple(basis.n + 1 for basis in self.bases)
244
+
245
+ def getNbFunc(self) -> int:
246
+ """
247
+ Compute the total number of basis functions in the B-spline.
248
+
249
+ This method calculates the total number of basis functions by multiplying
250
+ the number of basis functions in each isoparametric dimension (`n + 1` for each dimension).
251
+
252
+ Returns
253
+ -------
254
+ int
255
+ Total number of basis functions in the B-spline. This is equal to
256
+ the product of (`n + 1`) for each basis, where `n` is the last index
257
+ of each basis function.
258
+
259
+ Notes
260
+ -----
261
+ - For a curve (1D), returns (`n + 1`)
262
+ - For a surface (2D), returns (`n1 + 1`) × (`n2 + 1`)
263
+ - For a volume (3D), returns (`n1 + 1`) × (`n2 + 1`) × (`n3 + 1`)
264
+ - The number of basis functions equals the number of control points needed
265
+
266
+ Examples
267
+ --------
268
+ >>> degrees = [2, 2]
269
+ >>> knots = [np.array([0, 0, 0, 0.5, 1, 1, 1], dtype='float'),
270
+ ... np.array([0, 0, 0, 0.5, 1, 1, 1], dtype='float')]
271
+ >>> spline = BSpline(degrees, knots)
272
+ >>> spline.getNbFunc()
273
+ 16
274
+ """
275
+ return np.prod(self.getCtrlShape())
276
+
277
+ def getSpans(self) -> list[tuple[float, float]]:
278
+ """
279
+ Returns the span of each basis function in the isoparametric space.
280
+
281
+ This method collects the spans (intervals of definition) from each `BSplineBasis`
282
+ instance stored in the `bases` array.
283
+
284
+ Returns
285
+ -------
286
+ spans : list[tuple[float, float]]
287
+ List containing the spans of the B-spline basis functions.
288
+ The list has length `NPa` (dimension of isoparametric space), where each element
289
+ is a tuple (`a`, `b`) containing the lower and upper bounds of the span
290
+ for the corresponding isoparametric dimension.
291
+
292
+ Notes
293
+ -----
294
+ - For a curve (1D), returns [(`xi_min`, `xi_max`)]
295
+ - For a surface (2D), returns [(`xi_min`, `xi_max`), (`eta_min`, `eta_max`)]
296
+ - For a volume (3D), returns [(`xi_min`, `xi_max`), (`eta_min`, `eta_max`), (`zeta_min`, `zeta_max`)]
297
+ - The span represents the interval where the B-spline is defined
298
+ - Each span is determined by the `p`-th and `(m - p)`-th knots, where `p` is the degree
299
+ and `m` is the last index of the knot vector
300
+
301
+ Examples
302
+ --------
303
+ >>> degrees = [2, 2]
304
+ >>> knots = [np.array([0, 0, 0, 0.5, 1, 1, 1], dtype='float'),
305
+ ... np.array([0, 0, 0, 0.5, 1, 1, 1], dtype='float')]
306
+ >>> spline = BSpline(degrees, knots)
307
+ >>> spline.getSpans()
308
+ [(0.0, 1.0), (0.0, 1.0)]
309
+ """
310
+ spans = [basis.span for basis in self.bases]
311
+ return spans
312
+
313
+ # def get_indices(self, begining=0):
314
+ # """
315
+ # Create an array containing the indices of the control points of
316
+ # the B-spline.
317
+ #
318
+ # Parameters
319
+ # ----------
320
+ # begining : int, optional
321
+ # First index of the arrayof indices, by default 0
322
+ #
323
+ # Returns
324
+ # -------
325
+ # indices : np.array of int
326
+ # Indices of the control points in the same shape as the
327
+ # control points.
328
+ # """
329
+ # indices = np.arange(begining, begining + self.ctrl_pts.size).reshape(self.ctrl_pts.shape)
330
+ # return indices
331
+
332
+ def linspace(
333
+ self, n_eval_per_elem: Union[int, Iterable[int]] = 10
334
+ ) -> tuple[np.ndarray[np.floating], ...]:
335
+ """
336
+ Generate sets of evaluation points over the span of each basis in the isoparametric space.
337
+
338
+ This method creates evenly spaced points for each isoparametric dimension by calling
339
+ `linspace` on each `BSplineBasis` instance stored in the `bases` array.
340
+
341
+ Parameters
342
+ ----------
343
+ n_eval_per_elem : Union[int, Iterable[int]], optional
344
+ Number of evaluation points per element for each isoparametric dimension.
345
+ If an `int` is provided, the same number is used for all dimensions.
346
+ If an `Iterable` is provided, each value corresponds to a different dimension.
347
+ By default, 10.
348
+
349
+ Returns
350
+ -------
351
+ XI : tuple[np.ndarray[np.floating], ...]
352
+ Tuple containing arrays of evaluation points for each isoparametric dimension.
353
+ The tuple has length `NPa` (dimension of isoparametric space).
354
+
355
+ Notes
356
+ -----
357
+ - For a curve (1D), returns (`xi` points, )
358
+ - For a surface (2D), returns (`xi` points, `eta` points)
359
+ - For a volume (3D), returns (`xi` points, `eta` points, `zeta` points)
360
+ - The number of points returned for each dimension depends on the number of
361
+ elements in that dimension times the value of `n_eval_per_elem`
362
+
363
+ Examples
364
+ --------
365
+ >>> degrees = [2, 2]
366
+ >>> knots = [np.array([0, 0, 0, 0.5, 1, 1, 1], dtype='float'),
367
+ ... np.array([0, 0, 0, 1, 1, 1], dtype='float')]
368
+ >>> spline = BSpline(degrees, knots)
369
+ >>> xi, eta = spline.linspace(n_eval_per_elem=2)
370
+ >>> xi
371
+ array([0. , 0.25, 0.5 , 0.75, 1. ])
372
+ >>> eta
373
+ array([0. , 0.5, 1. ])
374
+ """
375
+ if type(n_eval_per_elem) is int:
376
+ n_eval_per_elem = [n_eval_per_elem] * self.NPa # type: ignore
377
+ XI = tuple([basis.linspace(n) for basis, n in zip(self.bases, n_eval_per_elem)]) # type: ignore
378
+ return XI
379
+
380
+ def linspace_for_integration(
381
+ self,
382
+ n_eval_per_elem: Union[int, Iterable[int]] = 10,
383
+ bounding_box: Union[Iterable, None] = None,
384
+ ) -> tuple[
385
+ tuple[np.ndarray[np.floating], ...], tuple[np.ndarray[np.floating], ...]
386
+ ]:
387
+ """
388
+ Generate sets of evaluation points and their integration weights over each basis span.
389
+
390
+ This method creates evenly spaced points and their corresponding integration weights
391
+ for each isoparametric dimension by calling `linspace_for_integration` on each
392
+ `BSplineBasis` instance stored in the `bases` array.
393
+
394
+ Parameters
395
+ ----------
396
+ n_eval_per_elem : Union[int, Iterable[int]], optional
397
+ Number of evaluation points per element for each isoparametric dimension.
398
+ If an `int` is provided, the same number is used for all dimensions.
399
+ If an `Iterable` is provided, each value corresponds to a different dimension.
400
+ By default, 10.
401
+
402
+ bounding_box : Union[Iterable[tuple[float, float]], None], optional
403
+ Lower and upper bounds for each isoparametric dimension.
404
+ If `None`, uses the span of each basis.
405
+ Format: [(`xi_min`, `xi_max`), (`eta_min`, `eta_max`), ...].
406
+ By default, None.
407
+
408
+ Returns
409
+ -------
410
+ XI : tuple[np.ndarray[np.floating], ...]
411
+ Tuple containing arrays of evaluation points for each isoparametric dimension.
412
+ The tuple has length `NPa` (dimension of isoparametric space).
413
+
414
+ dXI : tuple[np.ndarray[np.floating], ...]
415
+ Tuple containing arrays of integration weights for each isoparametric dimension.
416
+ The tuple has length `NPa` (dimension of isoparametric space).
417
+
418
+ Notes
419
+ -----
420
+ - For a curve (1D), returns ((`xi` points), (`xi` weights))
421
+ - For a surface (2D), returns ((`xi` points, `eta` points), (`xi` weights, `eta` weights))
422
+ - For a volume (3D), returns ((`xi` points, `eta` points, `zeta` points),
423
+ (`xi` weights, `eta` weights, `zeta` weights))
424
+ - The points are centered in their integration intervals
425
+ - The weights represent the size of the integration intervals
426
+
427
+ Examples
428
+ --------
429
+ >>> degrees = [2, 2]
430
+ >>> knots = [np.array([0, 0, 0, 0.5, 1, 1, 1], dtype='float'),
431
+ ... np.array([0, 0, 0, 0.5, 1, 1, 1], dtype='float')]
432
+ >>> spline = BSpline(degrees, knots)
433
+ >>> (xi, eta), (dxi, deta) = spline.linspace_for_integration(n_eval_per_elem=2)
434
+ >>> xi # xi points
435
+ array([0.125, 0.375, 0.625, 0.875])
436
+ >>> dxi # xi weights
437
+ array([0.25, 0.25, 0.25, 0.25])
438
+ """
439
+ if type(n_eval_per_elem) is int:
440
+ n_eval_per_elem = [n_eval_per_elem] * self.NPa # type: ignore
441
+ if bounding_box is None:
442
+ bounding_box = [b.span for b in self.bases] # type: ignore
443
+ XI = []
444
+ dXI = []
445
+ for basis, (n, bb) in zip(self.bases, zip(n_eval_per_elem, bounding_box)): # type: ignore
446
+ xi, dxi = basis.linspace_for_integration(n, bb)
447
+ XI.append(xi)
448
+ dXI.append(dxi)
449
+ XI = tuple(XI)
450
+ dXI = tuple(dXI)
451
+ return XI, dXI
452
+
453
+ def gauss_legendre_for_integration(
454
+ self,
455
+ n_eval_per_elem: Union[int, Iterable[int], None] = None,
456
+ bounding_box: Union[Iterable, None] = None,
457
+ ) -> tuple[
458
+ tuple[np.ndarray[np.floating], ...], tuple[np.ndarray[np.floating], ...]
459
+ ]:
460
+ """
461
+ Generate sets of evaluation points and their Gauss-Legendre integration weights over each basis span.
462
+
463
+ This method creates Gauss-Legendre quadrature points and their corresponding integration weights
464
+ for each isoparametric dimension by calling `gauss_legendre_for_integration` on each
465
+ `BSplineBasis` instance stored in the `bases` array.
466
+
467
+ Parameters
468
+ ----------
469
+ n_eval_per_elem : Union[int, Iterable[int], None], optional
470
+ Number of evaluation points per element for each isoparametric dimension.
471
+ If an `int` is provided, the same number is used for all dimensions.
472
+ If an `Iterable` is provided, each value corresponds to a different dimension.
473
+ If `None`, uses `p//2 + 1` points per element where `p` is the degree of each basis.
474
+ This number of points ensures an exact integration of a `p`-th degree polynomial.
475
+ By default, None.
476
+
477
+ bounding_box : Union[Iterable[tuple[float, float]], None], optional
478
+ Lower and upper bounds for each isoparametric dimension.
479
+ If `None`, uses the span of each basis.
480
+ Format: [(`xi_min`, `xi_max`), (`eta_min`, `eta_max`), ...].
481
+ By default, None.
482
+
483
+ Returns
484
+ -------
485
+ XI : tuple[np.ndarray[np.floating], ...]
486
+ Tuple containing arrays of Gauss-Legendre points for each isoparametric dimension.
487
+ The tuple has length `NPa` (dimension of isoparametric space).
488
+
489
+ dXI : tuple[np.ndarray[np.floating], ...]
490
+ Tuple containing arrays of Gauss-Legendre weights for each isoparametric dimension.
491
+ The tuple has length `NPa` (dimension of isoparametric space).
492
+
493
+ Notes
494
+ -----
495
+ - For a curve (1D), returns ((`xi` points), (`xi` weights))
496
+ - For a surface (2D), returns ((`xi` points, `eta` points), (`xi` weights, `eta` weights))
497
+ - For a volume (3D), returns ((`xi` points, `eta` points, `zeta` points),
498
+ (`xi` weights, `eta` weights, `zeta` weights))
499
+ - The points and weights follow the Gauss-Legendre quadrature rule
500
+ - When `n_eval_per_elem` is `None`, uses `p//2 + 1` points per element for exact
501
+ integration of polynomials up to degree `p`
502
+
503
+ Examples
504
+ --------
505
+ >>> degrees = [2, 2]
506
+ >>> knots = [np.array([0, 0, 0, 0.5, 1, 1, 1], dtype='float'),
507
+ ... np.array([0, 0, 0, 0.5, 1, 1, 1], dtype='float')]
508
+ >>> spline = BSpline(degrees, knots)
509
+ >>> (xi, eta), (dxi, deta) = spline.gauss_legendre_for_integration()
510
+ >>> xi # 2 xi points per element => 4 points in total
511
+ array([0.10566243, 0.39433757, 0.60566243, 0.89433757])
512
+ >>> dxi # xi weights
513
+ array([0.25, 0.25, 0.25, 0.25])
514
+ """
515
+ if n_eval_per_elem is None:
516
+ n_eval_per_elem = self.getDegrees() // 2 + 1
517
+ if type(n_eval_per_elem) is int:
518
+ n_eval_per_elem = [n_eval_per_elem] * self.NPa # type: ignore
519
+ if bounding_box is None:
520
+ bounding_box = [None] * self.NPa # type: ignore
521
+ XI = []
522
+ dXI = []
523
+ for basis, (n_eval_per_elem_axis, bb) in zip(self.bases, zip(n_eval_per_elem, bounding_box)): # type: ignore
524
+ xi, dxi = basis.gauss_legendre_for_integration(n_eval_per_elem_axis, bb)
525
+ XI.append(xi)
526
+ dXI.append(dxi)
527
+ XI = tuple(XI)
528
+ dXI = tuple(dXI)
529
+ return XI, dXI
530
+
531
+ def normalize_knots(self):
532
+ """
533
+ Maps all knot vectors to the interval [0, 1] in each isoparametric dimension.
534
+
535
+ This method normalizes the knot vectors of each `BSplineBasis` instance stored
536
+ in the `bases` array by applying an affine transformation that maps the span
537
+ interval to [0, 1].
538
+
539
+ Notes
540
+ -----
541
+ - The transformation preserves the relative spacing between knots
542
+ - The transformation preserves the multiplicity of knots
543
+ - The transformation is applied independently to each isoparametric dimension
544
+ - This operation modifies the knot vectors in place
545
+
546
+ Examples
547
+ --------
548
+ >>> degrees = [2, 2]
549
+ >>> knots = [np.array([-1, -1, -1, 0, 1, 1, 1], dtype='float'),
550
+ ... np.array([0, 0, 0, 2, 4, 4, 4], dtype='float')]
551
+ >>> spline = BSpline(degrees, knots)
552
+ >>> spline.getKnots()
553
+ [array([-1., -1., -1., 0., 1., 1., 1.]),
554
+ array([0., 0., 0., 2., 4., 4., 4.])]
555
+ >>> spline.normalize_knots()
556
+ >>> spline.getKnots()
557
+ [array([0., 0., 0., 0.5, 1., 1., 1.]),
558
+ array([0., 0., 0., 0.5, 1., 1., 1.])]
559
+ """
560
+ for basis in self.bases:
561
+ basis.normalize_knots()
562
+
563
+ def DN(
564
+ self,
565
+ XI: Union[np.ndarray[np.floating], tuple[np.ndarray[np.floating], ...]],
566
+ k: Union[int, Iterable[int]] = 0,
567
+ ) -> Union[sps.spmatrix, np.ndarray[sps.spmatrix]]:
568
+ """
569
+ Compute the `k`-th derivative of the B-spline basis at given points in the isoparametric space.
570
+
571
+ This method evaluates the basis functions or their derivatives at specified points, returning
572
+ a matrix that can be used to evaluate the B-spline through a dot product with the control points.
573
+
574
+ Parameters
575
+ ----------
576
+ XI : Union[np.ndarray[np.floating], tuple[np.ndarray[np.floating], ...]]
577
+ Points in the isoparametric space where to evaluate the basis functions.
578
+ Two input formats are accepted:
579
+ 1. `numpy.ndarray`: Array of coordinates with shape (`NPa`, n_points).
580
+ Each column represents one evaluation point [`xi`, `eta`, ...].
581
+ The resulting matrices will have shape (n_points, number of functions).
582
+ 2. `tuple`: Contains `NPa` arrays of coordinates (`xi`, `eta`, ...).
583
+ The resulting matrices will have (n_xi × n_eta × ...) rows.
584
+
585
+ k : Union[int, Iterable[int]], optional
586
+ Derivative orders to compute. Two formats are accepted:
587
+ 1. `int`: Same derivative order along all axes. Common values:
588
+ - `k=0`: Evaluate basis functions (default)
589
+ - `k=1`: Compute first derivatives (gradient)
590
+ - `k=2`: Compute second derivatives (hessian)
591
+ 2. `list[int]`: Different derivative orders for each axis.
592
+ Example: `[1, 0]` computes first derivative w.r.t `xi`, no derivative w.r.t `eta`.
593
+ By default, 0.
594
+
595
+ Returns
596
+ -------
597
+ DN : Union[sps.spmatrix, np.ndarray[sps.spmatrix]]
598
+ Sparse matrix or array of sparse matrices containing the basis evaluations:
599
+ - If `k` is a `list` or is 0: Returns a single sparse matrix containing the mixed
600
+ derivative specified by the list.
601
+ - If `k` is an `int` > 0: Returns an array of sparse matrices with shape [`NPa`]*`k`.
602
+ For example, if `k=1`, returns `NPa` matrices containing derivatives along each axis.
603
+
604
+ Notes
605
+ -----
606
+ - For evaluating the B-spline with control points in `NPh`-D space:
607
+ `values = DN @ ctrl_pts.reshape((NPh, -1)).T`
608
+ - When using tuple input format for `XI`, points are evaluated at all combinations of coordinates
609
+ - When using array input format for `XI`, each column represents one evaluation point
610
+ - The gradient (`k=1`) returns `NPa` matrices for derivatives along each axis
611
+ - Mixed derivatives can be computed using a list of derivative orders
612
+
613
+ Examples
614
+ --------
615
+ Create a 2D quadratic B-spline:
616
+ >>> degrees = [2, 2]
617
+ >>> knots = [np.array([0, 0, 0, 0.5, 1, 1, 1], dtype='float'),
618
+ ... np.array([0, 0, 0, 0.5, 1, 1, 1], dtype='float')]
619
+ >>> spline = BSpline(degrees, knots)
620
+
621
+ Evaluate basis functions at specific points using array input:
622
+ >>> XI = np.array([[0, 0.5, 1], # xi coordinates
623
+ ... [0, 0.5, 1]]) # eta coordinates
624
+ >>> N = spline.DN(XI, k=0)
625
+ >>> N.A # Convert sparse matrix to dense for display
626
+ array([[1., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0.],
627
+ [0., 0., 0., 0., 0., 0.25, 0.25, 0., 0., 0.25, 0.25, 0., 0., 0., 0., 0.],
628
+ [0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 1.]])
629
+
630
+ Compute first derivatives using tuple input:
631
+ >>> xi = np.array([0, 0.5])
632
+ >>> eta = np.array([0, 1])
633
+ >>> dN = spline.DN((xi, eta), k=1) # Returns [NPa] matrices
634
+ >>> len(dN) # Number of derivative matrices
635
+ 2
636
+ >>> dN[0].A # Derivative w.r.t xi
637
+ array([[-4., 0., 0., 0., 4., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0.],
638
+ [ 0., 0., 0.,-4., 0., 0., 0., 4., 0., 0., 0., 0., 0., 0., 0., 0.],
639
+ [ 0., 0., 0., 0.,-2., 0., 0., 0., 2., 0., 0., 0., 0., 0., 0., 0.],
640
+ [ 0., 0., 0., 0., 0., 0., 0.,-2., 0., 0., 0., 2., 0., 0., 0., 0.]])
641
+ >>> dN[1].A # Derivative w.r.t eta
642
+ array([[-4., 4., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0.],
643
+ [ 0., 0.,-4., 4., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0.],
644
+ [ 0., 0., 0., 0.,-2., 2., 0., 0.,-2., 2., 0., 0., 0., 0., 0., 0.],
645
+ [ 0., 0., 0., 0., 0., 0.,-2., 2., 0., 0.,-2., 2., 0., 0., 0., 0.]])
646
+
647
+ Compute mixed derivatives:
648
+ >>> d2N = spline.DN((xi, eta), k=[1, 1]) # Second derivative: d²/dxi·deta
649
+ >>> d2N.A
650
+ array([[16.,-16., 0., 0.,-16.,16., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0.],
651
+ [ 0., 0., 16.,-16., 0., 0.,-16.,16., 0., 0., 0., 0., 0., 0., 0., 0.],
652
+ [ 0., 0., 0., 0., 8.,-8., 0., 0.,-8., 8., 0., 0., 0., 0., 0., 0.],
653
+ [ 0., 0., 0., 0., 0., 0., 8.,-8., 0.,-0.,-8., 8., 0., 0., 0., 0.]])
654
+ """
655
+
656
+ if isinstance(XI, np.ndarray):
657
+ fct = my_wide_product
658
+ XI = XI.reshape((self.NPa, -1))
659
+ else:
660
+ fct = sps.kron
661
+
662
+ if isinstance(k, int):
663
+ if k == 0:
664
+ k = [0] * self.NPa # type: ignore
665
+
666
+ if isinstance(k, int):
667
+ dkbasis_dxik = np.empty((self.NPa, k + 1), dtype="object")
668
+ for idx in range(self.NPa):
669
+ basis = self.bases[idx]
670
+ xi = XI[idx] - np.finfo("float").eps * (XI[idx] == basis.knot[-1])
671
+ for k_querry in range(k + 1):
672
+ dkbasis_dxik[idx, k_querry] = basis.N(xi, k=k_querry)
673
+ DN = np.empty([self.NPa] * k, dtype="object")
674
+ dic = {}
675
+ for axes in np.ndindex(*DN.shape):
676
+ u, c = np.unique(axes, return_counts=True)
677
+ k_arr = np.zeros(self.NPa, dtype="int")
678
+ k_arr[u] = c
679
+ key = tuple(k_arr)
680
+ if key not in dic:
681
+ for idx in range(self.NPa):
682
+ k_querry = k_arr[idx]
683
+ if idx == 0:
684
+ dic[key] = dkbasis_dxik[idx, k_querry]
685
+ else:
686
+ dic[key] = fct(dic[key], dkbasis_dxik[idx, k_querry])
687
+ DN[axes] = dic[key]
688
+ return DN
689
+ else:
690
+ for idx in range(self.NPa):
691
+ basis = self.bases[idx]
692
+ k_idx = k[idx]
693
+ xi = XI[idx] - np.finfo("float").eps * (XI[idx] == basis.knot[-1])
694
+ DN_elem = basis.N(xi, k=k_idx)
695
+ if idx == 0:
696
+ DN = DN_elem
697
+ else:
698
+ DN = fct(DN, DN_elem)
699
+ return DN
700
+
701
+ def __call__(
702
+ self,
703
+ ctrl_pts: np.ndarray[np.floating],
704
+ XI: Union[np.ndarray[np.floating], tuple[np.ndarray[np.floating], ...]],
705
+ k: Union[int, Iterable[int]] = 0,
706
+ ) -> np.ndarray[np.floating]:
707
+ """
708
+ Evaluate the `k`-th derivative of the B-spline at given points in the isoparametric space.
709
+
710
+ This method evaluates the B-spline or its derivatives at specified points by computing
711
+ the basis functions and performing a dot product with the control points.
712
+
713
+ Parameters
714
+ ----------
715
+ ctrl_pts : np.ndarray[np.floating]
716
+ Control points defining the B-spline geometry.
717
+ Shape: (`NPh`, n1, n2, ...) where:
718
+ - `NPh` is the dimension of the physical space
719
+ - ni is the number of control points in the i-th isoparametric dimension,
720
+ i.e. the number of basis functions on this isoparametric axis
721
+
722
+ XI : Union[np.ndarray[np.floating], tuple[np.ndarray[np.floating], ...]]
723
+ Points in the isoparametric space where to evaluate the B-spline.
724
+ Two input formats are accepted:
725
+ 1. `numpy.ndarray`: Array of coordinates with shape (`NPa`, n_points).
726
+ Each column represents one evaluation point [`xi`, `eta`, ...].
727
+ 2. `tuple`: Contains `NPa` arrays of coordinates (`xi`, `eta`, ...).
728
+
729
+ k : Union[int, Iterable[int]], optional
730
+ Derivative orders to compute. Two formats are accepted:
731
+ 1. `int`: Same derivative order along all axes. Common values:
732
+ - `k=0`: Evaluate the B-spline mapping (default)
733
+ - `k=1`: Compute first derivatives (gradient)
734
+ - `k=2`: Compute second derivatives (hessian)
735
+ 2. `list[int]`: Different derivative orders for each axis.
736
+ Example: `[1, 0]` computes first derivative w.r.t `xi`, no derivative w.r.t `eta`.
737
+ By default, 0.
738
+
739
+ Returns
740
+ -------
741
+ values : np.ndarray[np.floating]
742
+ B-spline evaluation at the specified points.
743
+ Shape depends on input format and derivative order:
744
+ - For array input: (`NPh`, shape of derivative, n_points)
745
+ - For tuple input: (`NPh`, shape of derivative, n_xi, n_eta, ...)
746
+ Where "shape of derivative" depends on `k`:
747
+ - For `k=0` or `k` as list: Empty shape
748
+ - For `k=1`: Shape is (`NPa`,) for gradient
749
+ - For `k>1` as int: Shape is (`NPa`,) repeated `k` times
750
+
751
+ Notes
752
+ -----
753
+ - The method first computes basis functions using `DN` then performs a dot product
754
+ with control points
755
+ - When using tuple input format, points are evaluated at all combinations of coordinates
756
+ - When using array input format, each column represents one evaluation point
757
+ - The gradient (`k=1`) returns derivatives along each isoparametric axis
758
+ - Mixed derivatives can be computed using a list of derivative orders
759
+
760
+ Examples
761
+ --------
762
+ Create a 2D quadratic B-spline:
763
+ >>> degrees = [2, 2]
764
+ >>> knots = [np.array([0, 0, 0, 0.5, 1, 1, 1], dtype='float'),
765
+ ... np.array([0, 0, 0, 0.5, 1, 1, 1], dtype='float')]
766
+ >>> spline = BSpline(degrees, knots)
767
+ >>> ctrl_pts = np.random.rand(3, 4, 4) # 3D control points
768
+
769
+ Evaluate B-spline at specific points using array input:
770
+ >>> XI = np.array([[0, 0.5, 1], # xi coordinates
771
+ ... [0, 0.5, 1]]) # eta coordinates
772
+ >>> values = spline(ctrl_pts, XI, k=0)
773
+ >>> values.shape
774
+ (3, 3) # (NPh, n_points)
775
+
776
+ Evaluate gradient using tuple input:
777
+ >>> xi = np.array([0, 0.5])
778
+ >>> eta = np.array([0, 1])
779
+ >>> derivatives = spline(ctrl_pts, (xi, eta), k=1)
780
+ >>> derivatives.shape
781
+ (2, 3, 2, 2) # (NPa, NPh, n_xi, n_eta)
782
+
783
+ Compute mixed derivatives:
784
+ >>> mixed = spline(ctrl_pts, (xi, eta), k=[1, 1])
785
+ >>> mixed.shape
786
+ (3, 2, 2) # (NPh, n_xi, n_eta)
787
+ """
788
+ if isinstance(XI, np.ndarray):
789
+ XI_shape = XI.shape[1:]
790
+ else:
791
+ XI_shape = [xi.size for xi in XI]
792
+ DN = self.DN(XI, k)
793
+ NPh = ctrl_pts.shape[0]
794
+ if isinstance(DN, np.ndarray):
795
+ values = np.empty((*DN.shape, NPh, *XI_shape), dtype="float")
796
+ for axes in np.ndindex(*DN.shape):
797
+ values[axes] = (DN[axes] @ ctrl_pts.reshape((NPh, -1)).T).T.reshape(
798
+ (NPh, *XI_shape)
799
+ )
800
+ else:
801
+ values = (DN @ ctrl_pts.reshape((NPh, -1)).T).T.reshape((NPh, *XI_shape))
802
+ return values
803
+
804
+ def knotInsertion(
805
+ self,
806
+ ctrl_pts: Union[np.ndarray[np.floating], None],
807
+ knots_to_add: Iterable[Union[np.ndarray[np.float64], int]],
808
+ ) -> np.ndarray[np.floating]:
809
+ """
810
+ Add knots to the B-spline while preserving its geometry.
811
+
812
+ This method performs knot insertion by adding new knots to each isoparametric dimension
813
+ and computing the new control points to maintain the exact same geometry. The method
814
+ modifies the `BSpline` object by updating its basis functions with the new knots.
815
+
816
+ Parameters
817
+ ----------
818
+ ctrl_pts : np.ndarray[np.floating]
819
+ Control points defining the B-spline geometry.
820
+ Shape: (`NPh`, n1, n2, ...) where:
821
+ - `NPh` is the dimension of the physical space
822
+ - ni is the number of control points in the i-th isoparametric dimension
823
+ If None is passed, the knot insertion is performed on the basis functions
824
+ but not on the control points.
825
+
826
+ knots_to_add : Iterable[Union[np.ndarray[np.floating], int]]
827
+ Refinement specification for each isoparametric dimension.
828
+ For each dimension, two formats are accepted:
829
+ 1. `numpy.ndarray`: Array of knots to insert. These knots must lie within
830
+ the span of the existing knot vector.
831
+ 2. `int`: Number of equally spaced knots to insert in each element.
832
+
833
+ Returns
834
+ -------
835
+ new_ctrl_pts : np.ndarray[np.floating]
836
+ New control points after knot insertion.
837
+ Shape: (`NPh`, m1, m2, ...) where mi ≥ ni is the new number of
838
+ control points in the i-th isoparametric dimension.
839
+
840
+ Notes
841
+ -----
842
+ - Knot insertion preserves the geometry and parameterization of the B-spline
843
+ - The number of new control points depends on the number and multiplicity of inserted knots
844
+ - When using integer input, knots are inserted with uniform spacing in each element
845
+ - The method modifies the basis functions but maintains `C^{p-m}` continuity,
846
+ where `p` is the degree and `m` is the multiplicity of the inserted knot
847
+
848
+ Examples
849
+ --------
850
+ Create a 2D quadratic B-spline and insert knots:
851
+ >>> degrees = [2, 2]
852
+ >>> knots = [np.array([0, 0, 0, 0.5, 1, 1, 1], dtype='float'),
853
+ ... np.array([0, 0, 0, 0.5, 1, 1, 1], dtype='float')]
854
+ >>> spline = BSpline(degrees, knots)
855
+ >>> ctrl_pts = np.random.rand(3, 4, 4) # 3D control points
856
+
857
+ Insert specific knots in first dimension only:
858
+ >>> knots_to_add = [np.array([0.25, 0.75], dtype='float'),
859
+ ... np.array([], dtype='float')]
860
+ >>> new_ctrl_pts = spline.knotInsertion(ctrl_pts, knots_to_add)
861
+ >>> new_ctrl_pts.shape
862
+ (3, 6, 4) # Two new control points added in first dimension
863
+ >>> spline.getKnots()[0] # The knot vector is modified
864
+ array([0. , 0. , 0. , 0.25, 0.5 , 0.75, 1. , 1. , 1. ])
865
+
866
+ Insert two knots per element in both dimensions:
867
+ >>> new_ctrl_pts = spline.knotInsertion(new_ctrl_pts, [1, 1])
868
+ >>> new_ctrl_pts.shape
869
+ (3, 10, 6) # Uniform refinement in both dimensions
870
+ >>> spline.getKnots()[0] # The knot vectors are further modified
871
+ array([0. , 0. , 0. , 0.125, 0.25 , 0.375, 0.5 , 0.625, 0.75 ,
872
+ 0.875, 1. , 1. , 1. ])
873
+ """
874
+ true_knots_to_add = []
875
+ for axis, knots_to_add_elem in enumerate(knots_to_add):
876
+ if isinstance(
877
+ knots_to_add_elem, int
878
+ ): # It is a number of knots to add in each element
879
+ u_knot = np.unique(self.bases[axis].knot)
880
+ a, b = u_knot[:-1, None], u_knot[1:, None]
881
+ mu = np.linspace(0, 1, knots_to_add_elem + 1, endpoint=False)[None, 1:]
882
+ true_knots_to_add.append((a + (b - a) * mu).ravel())
883
+ else:
884
+ true_knots_to_add.append(knots_to_add_elem)
885
+ knots_to_add = true_knots_to_add
886
+
887
+ if ctrl_pts is None:
888
+ for basis, knots_to_add_elem in zip(self.bases, knots_to_add):
889
+ basis.knotInsertion(knots_to_add_elem)
890
+ return None
891
+
892
+ pts_shape = np.empty(self.NPa, dtype="int")
893
+ D = None
894
+ for idx in range(self.NPa):
895
+ basis = self.bases[idx]
896
+ knots_to_add_elem = knots_to_add[idx]
897
+ D_elem = basis.knotInsertion(knots_to_add_elem)
898
+ pts_shape[idx] = D_elem.shape[0]
899
+ if D is None:
900
+ D = D_elem
901
+ else:
902
+ D = sps.kron(D, D_elem)
903
+ NPh = ctrl_pts.shape[0]
904
+ pts = (D @ ctrl_pts.reshape((NPh, -1)).T).T
905
+ ctrl_pts = pts.reshape((NPh, *pts_shape))
906
+ return ctrl_pts
907
+
908
+ def orderElevation(
909
+ self, ctrl_pts: Union[np.ndarray[np.floating], None], t: Iterable[int]
910
+ ) -> np.ndarray[np.floating]:
911
+ """
912
+ Elevate the polynomial degree of the B-spline while preserving its geometry.
913
+
914
+ This method performs order elevation by increasing the polynomial degree of each
915
+ isoparametric dimension and computing the new control points to maintain the exact
916
+ same geometry. The method modifies the `BSpline` object by updating its basis
917
+ functions with the new degrees.
918
+
919
+ Parameters
920
+ ----------
921
+ ctrl_pts : Union[np.ndarray[np.floating], None]
922
+ Control points defining the B-spline geometry.
923
+ Shape: (`NPh`, n1, n2, ...) where:
924
+ - `NPh` is the dimension of the physical space
925
+ - ni is the number of control points in the i-th isoparametric dimension
926
+ If None is passed, the order elevation is performed on the basis functions
927
+ but not on the control points.
928
+
929
+ t : Iterable[int]
930
+ Degree elevation for each isoparametric dimension.
931
+ For each dimension i, the new degree will be `p_i + t_i` where `p_i`
932
+ is the current degree.
933
+
934
+ Returns
935
+ -------
936
+ new_ctrl_pts : np.ndarray[np.floating]
937
+ New control points after order elevation.
938
+ Shape: (`NPh`, m1, m2, ...) where mi ≥ ni is the new number of
939
+ control points in the i-th isoparametric dimension.
940
+
941
+ Notes
942
+ -----
943
+ - Order elevation preserves the geometry and parameterization of the B-spline
944
+ - The number of new control points depends on the current degree and number of
945
+ elements
946
+ - The method modifies the `BSpline` object by updating its basis functions
947
+ - This operation is more computationally expensive than knot insertion
948
+
949
+ Examples
950
+ --------
951
+ Create a 2D quadratic B-spline and elevate its order:
952
+ >>> degrees = [2, 2]
953
+ >>> knots = [np.array([0, 0, 0, 0.5, 1, 1, 1], dtype='float'),
954
+ ... np.array([0, 0, 0, 0.5, 1, 1, 1], dtype='float')]
955
+ >>> spline = BSpline(degrees, knots)
956
+ >>> ctrl_pts = np.random.rand(3, 4, 4) # 3D control points
957
+
958
+ Elevate order by 1 in first dimension only:
959
+ >>> t = [1, 0] # Increase degree by 1 in first dimension
960
+ >>> new_ctrl_pts = spline.orderElevation(ctrl_pts, t)
961
+ >>> new_ctrl_pts.shape
962
+ (3, 6, 4) # Two new control points added in first dimension (one per element)
963
+ >>> spline.getDegrees() # The degrees are modified
964
+ array([3, 2])
965
+ """
966
+ if ctrl_pts is None:
967
+ for basis, t_basis in zip(self.bases, t):
968
+ basis.orderElevation(t_basis)
969
+ return None
970
+
971
+ pts_shape = np.empty(self.NPa, dtype="int")
972
+ STD = None
973
+ for idx in range(self.NPa):
974
+ basis = self.bases[idx]
975
+ t_basis = t[idx]
976
+ STD_elem = basis.orderElevation(t_basis)
977
+ pts_shape[idx] = STD_elem.shape[0]
978
+ if STD is None:
979
+ STD = STD_elem
980
+ else:
981
+ STD = sps.kron(STD, STD_elem)
982
+ NPh = ctrl_pts.shape[0]
983
+ pts = (STD @ ctrl_pts.reshape((NPh, -1)).T).T
984
+ ctrl_pts = pts.reshape((NPh, *pts_shape))
985
+ return ctrl_pts
986
+
987
+ def greville_abscissa(self, return_weights: bool = False) -> Union[
988
+ list[np.ndarray[np.floating]],
989
+ tuple[list[np.ndarray[np.floating]], list[np.ndarray[np.floating]]],
990
+ ]:
991
+ """
992
+ Compute the Greville abscissa and optionally their weights for each isoparametric dimension.
993
+
994
+ The Greville abscissa can be interpreted as the "position" of the control points in the
995
+ isoparametric space. They are often used as interpolation points for B-splines.
996
+
997
+ Parameters
998
+ ----------
999
+ return_weights : bool, optional
1000
+ If `True`, also returns the weights (span lengths) of each basis function.
1001
+ By default, False.
1002
+
1003
+ Returns
1004
+ -------
1005
+ greville : list[np.ndarray[np.floating]]
1006
+ List containing the Greville abscissa for each isoparametric dimension.
1007
+ The list has length `NPa`, where each element is an array of size `n + 1`,
1008
+ `n` being the last index of the basis functions in that dimension.
1009
+
1010
+ weights : list[np.ndarray[np.floating]], optional
1011
+ Only returned if `return_weights` is `True`.
1012
+ List containing the weights for each isoparametric dimension.
1013
+ The list has length `NPa`, where each element is an array containing
1014
+ the span length of each basis function.
1015
+
1016
+ Notes
1017
+ -----
1018
+ - For a curve (1D), returns [`xi` abscissa]
1019
+ - For a surface (2D), returns [`xi` abscissa, `eta` abscissa]
1020
+ - For a volume (3D), returns [`xi` abscissa, `eta` abscissa, `zeta` abscissa]
1021
+ - The Greville abscissa are computed as averages of `p` consecutive knots
1022
+ - The weights represent the size of the support of each basis function
1023
+ - The number of abscissa in each dimension equals the number of control points
1024
+
1025
+ Examples
1026
+ --------
1027
+ Compute Greville abscissa for a 2D B-spline:
1028
+ >>> degrees = [2, 2]
1029
+ >>> knots = [np.array([0, 0, 0, 0.5, 1, 1, 1], dtype='float'),
1030
+ ... np.array([0, 0, 0, 1, 1, 1], dtype='float')]
1031
+ >>> spline = BSpline(degrees, knots)
1032
+ >>> greville = spline.greville_abscissa()
1033
+ >>> greville[0] # xi coordinates
1034
+ array([0. , 0.25, 0.75, 1. ])
1035
+ >>> greville[1] # eta coordinates
1036
+ array([0. , 0.5, 1. ])
1037
+
1038
+ Compute both abscissa and weights:
1039
+ >>> greville, weights = spline.greville_abscissa(return_weights=True)
1040
+ >>> weights[0] # weights for xi direction
1041
+ array([0.5, 1. , 1. , 0.5])
1042
+ """
1043
+ res = [
1044
+ basis.greville_abscissa(return_weights=return_weights)
1045
+ for basis in self.bases
1046
+ ]
1047
+ if return_weights:
1048
+ greville, weights = zip(*res)
1049
+ greville = list(greville)
1050
+ weights = list(weights)
1051
+ return greville, weights
1052
+ else:
1053
+ greville = list(res)
1054
+ return greville
1055
+
1056
+ def make_control_poly_meshes(
1057
+ self,
1058
+ ctrl_pts: np.ndarray[np.floating],
1059
+ n_eval_per_elem: Union[int, Iterable[int]] = 10,
1060
+ n_step: int = 1,
1061
+ fields: dict = {},
1062
+ XI: Union[None, tuple[np.ndarray[np.floating], ...]] = None,
1063
+ paraview_sizes: dict = {},
1064
+ ) -> list[io.Mesh]:
1065
+ """
1066
+ Create meshes containing all the data needed to plot the control polygon of the B-spline.
1067
+
1068
+ This method generates a list of `io.Mesh` objects representing the control mesh
1069
+ (polygonal connectivity) of the B-spline, suitable for visualization (e.g. in Paraview).
1070
+ It supports time-dependent fields and arbitrary dimension.
1071
+
1072
+ Parameters
1073
+ ----------
1074
+ ctrl_pts : np.ndarray[np.floating]
1075
+ Array of control points of the B-spline, with shape
1076
+ (`NPh`, number of elements for dim 1, ..., number of elements for dim `NPa`),
1077
+ where `NPh` is the physical space dimension and `NPa` is the dimension of the
1078
+ isoparametric space.
1079
+ n_step : int, optional
1080
+ Number of time steps to plot. By default, 1.
1081
+ n_eval_per_elem : Union[int, Iterable[int]], optional
1082
+ Number of evaluation points per element for each isoparametric dimension.
1083
+ By default, 10.
1084
+ - If an `int` is provided, the same number is used for all dimensions.
1085
+ - If an `Iterable` is provided, each value corresponds to a different dimension.
1086
+ n_step : int, optional
1087
+ Number of time steps to plot. By default, 1.
1088
+ fields : dict, optional
1089
+ Dictionary of fields to plot at each time step. Keys are field names. Values can be:
1090
+ - a `function` taking (`BSpline` spline, `tuple` of `np.ndarray[np.floating]` XI) and
1091
+ returning a `np.ndarray[np.floating]` of shape (`n_step`, number of combinations of XI, field size),
1092
+ - a `np.ndarray[np.floating]` defined **on the control points**, of shape (`n_step`, field size, *`ctrl_pts.shape[1:]`),
1093
+ which is then interpolated using the B-spline basis functions,
1094
+ - a `np.ndarray[np.floating]` defined **on the evaluation grid**, of shape (`n_step`, field size, *grid shape),
1095
+ where `grid shape` matches the discretization provided by XI or `n_eval_per_elem`.
1096
+ In this case, the field is interpolated in physical space using `scipy.interpolate.griddata`.
1097
+ XI : tuple[np.ndarray[np.floating], ...], optional
1098
+ Parametric coordinates at which to evaluate the B-spline and fields.
1099
+ If not `None`, overrides the `n_eval_per_elem` parameter.
1100
+ If `None`, a regular grid is generated according to `n_eval_per_elem`.
1101
+ paraview_sizes: dict, optionnal
1102
+ The fields present in this `dict` are overrided by `np.NaN`s.
1103
+ The keys must be the fields names and the values must be the fields sizes for paraview.
1104
+ By default, {}.
1105
+
1106
+ Returns
1107
+ -------
1108
+ list[io.Mesh]
1109
+ List of `io.Mesh` objects, one for each time step, containing the control mesh geometry
1110
+ and associated fields.
1111
+
1112
+ Notes
1113
+ -----
1114
+ - The control mesh is constructed by connecting control points along each isoparametric direction.
1115
+ - Fields can be provided either as functions evaluated at the Greville abscissae, or as arrays defined on the
1116
+ control points or on a regular parametric grid (in which case they are interpolated at the Greville abscissae).
1117
+ - The first axis of the field array or function output corresponds to the time step, even if there is only one.
1118
+ - The method is compatible with B-splines of arbitrary dimension.
1119
+
1120
+ Examples
1121
+ --------
1122
+ >>> degrees = [2, 2]
1123
+ >>> knots = [np.array([0, 0, 0, 0.5, 1, 1, 1], dtype='float'),
1124
+ ... np.array([0, 0, 0, 1, 1, 1], dtype='float')]
1125
+ >>> spline = BSpline(degrees, knots)
1126
+ >>> ctrl_pts = np.random.rand(3, 4, 3) # 3D control points for a 2D surface
1127
+ >>> meshes = spline.make_control_poly_meshes(ctrl_pts)
1128
+ >>> mesh = meshes[0]
1129
+ """
1130
+ if XI is None:
1131
+ XI = self.linspace(n_eval_per_elem)
1132
+ interp_points = self(ctrl_pts, XI).reshape((3, -1)).T
1133
+ shape = [xi.size for xi in XI]
1134
+ NXI = np.prod(shape)
1135
+ NPh = ctrl_pts.shape[0]
1136
+ lines = np.empty((0, 2), dtype="int")
1137
+ size = np.prod(ctrl_pts.shape[1:])
1138
+ inds = np.arange(size).reshape(ctrl_pts.shape[1:])
1139
+ for idx in range(self.NPa):
1140
+ rng = np.arange(inds.shape[idx])
1141
+ lines = np.append(
1142
+ lines,
1143
+ np.concatenate(
1144
+ (
1145
+ np.expand_dims(np.take(inds, rng[:-1], axis=idx), axis=-1),
1146
+ np.expand_dims(np.take(inds, rng[1:], axis=idx), axis=-1),
1147
+ ),
1148
+ axis=-1,
1149
+ ).reshape((-1, 2)),
1150
+ axis=0,
1151
+ )
1152
+ cells = {"line": lines}
1153
+ points = np.moveaxis(ctrl_pts, 0, -1).reshape((-1, NPh))
1154
+ greville = tuple(self.greville_abscissa())
1155
+ n = self.getNbFunc()
1156
+ point_data = {}
1157
+ for key, value in fields.items():
1158
+ if key in paraview_sizes:
1159
+ point_data[key] = np.full((n_step, n, paraview_sizes[key]), np.NAN)
1160
+ elif callable(value):
1161
+ point_data[key] = value(self, greville)
1162
+ else:
1163
+ value = np.asarray(value)
1164
+ if value.ndim >= 2 and value.shape[-self.NPa :] == tuple(
1165
+ ctrl_pts.shape[1:]
1166
+ ):
1167
+ point_data[key] = value.reshape((n_step, -1, n)).transpose(0, 2, 1)
1168
+ elif value.ndim >= 2 and value.shape[-self.NPa :] == tuple(shape):
1169
+ paraview_size = value.shape[1]
1170
+ interp_field = griddata(
1171
+ interp_points,
1172
+ value.reshape((n_step * paraview_size, NXI)).T,
1173
+ points,
1174
+ method="linear",
1175
+ )
1176
+ point_data[key] = interp_field.reshape(
1177
+ (n, n_step, paraview_size)
1178
+ ).transpose(1, 0, 2)
1179
+ else:
1180
+ raise ValueError(f"Field {key} shape {value.shape} not understood.")
1181
+ # make meshes
1182
+ meshes = []
1183
+ for i in range(n_step):
1184
+ point_data_step = {}
1185
+ for key, value in point_data.items():
1186
+ point_data_step[key] = value[i]
1187
+ mesh = io.Mesh(points, cells, point_data_step) # type: ignore
1188
+ meshes.append(mesh)
1189
+
1190
+ return meshes
1191
+
1192
+ def make_elem_separator_meshes(
1193
+ self,
1194
+ ctrl_pts: np.ndarray[np.floating],
1195
+ n_eval_per_elem: Union[int, Iterable[int]] = 10,
1196
+ n_step: int = 1,
1197
+ fields: dict = {},
1198
+ XI: Union[None, tuple[np.ndarray[np.floating], ...]] = None,
1199
+ paraview_sizes: dict = {},
1200
+ ) -> list[io.Mesh]:
1201
+ """
1202
+ Create meshes representing the boundaries of every element in the B-spline for visualization.
1203
+
1204
+ This method generates a list of `io.Mesh` objects containing the geometry and optional fields
1205
+ needed to plot the limits (borders) of all elements from the isoparametric space of the B-spline.
1206
+ Supports time-dependent fields and arbitrary dimension.
1207
+
1208
+ Parameters
1209
+ ----------
1210
+ ctrl_pts : np.ndarray[np.floating]
1211
+ Array of control points of the B-spline, with shape
1212
+ (`NPh`, number of elements for dim 1, ..., number of elements for dim `NPa`),
1213
+ where `NPh` is the physical space dimension and `NPa` is the dimension of the
1214
+ isoparametric space.
1215
+ n_eval_per_elem : Union[int, Iterable[int]], optional
1216
+ Number of evaluation points per element for each isoparametric dimension.
1217
+ By default, 10.
1218
+ - If an `int` is provided, the same number is used for all dimensions.
1219
+ - If an `Iterable` is provided, each value corresponds to a different dimension.
1220
+ n_step : int, optional
1221
+ Number of time steps to plot. By default, 1.
1222
+ fields : dict, optional
1223
+ Dictionary of fields to plot at each time step. Keys are field names. Values can be:
1224
+ - a `function` taking (`BSpline` spline, `tuple` of `np.ndarray[np.floating]` XI) and
1225
+ returning a `np.ndarray[np.floating]` of shape (`n_step`, number of combinations of XI, field size),
1226
+ - a `np.ndarray[np.floating]` defined **on the control points**, of shape (`n_step`, field size, *`ctrl_pts.shape[1:]`),
1227
+ which is then interpolated using the B-spline basis functions,
1228
+ - a `np.ndarray[np.floating]` defined **on the evaluation grid**, of shape (`n_step`, field size, *grid shape),
1229
+ where `grid shape` matches the discretization provided by XI or `n_eval_per_elem`.
1230
+ In this case, the field is interpolated in physical space using `scipy.interpolate.griddata`.
1231
+ XI : tuple[np.ndarray[np.floating], ...], optional
1232
+ Parametric coordinates at which to evaluate the B-spline and fields.
1233
+ If not `None`, overrides the `n_eval_per_elem` parameter.
1234
+ If `None`, a regular grid is generated according to `n_eval_per_elem`.
1235
+ paraview_sizes: dict, optionnal
1236
+ The fields present in this `dict` are overrided by `np.NaN`s.
1237
+ The keys must be the fields names and the values must be the fields sizes for paraview.
1238
+ By default, {}.
1239
+
1240
+ Returns
1241
+ -------
1242
+ list[io.Mesh]
1243
+ List of `io.Mesh` objects, one for each time step, containing the element boundary geometry
1244
+ and associated fields.
1245
+
1246
+ Notes
1247
+ -----
1248
+ - The element boundary mesh is constructed by connecting points along the unique knot values
1249
+ in each isoparametric direction, outlining the limits of each element.
1250
+ - Fields can be provided either as callable functions, as arrays defined on the control points,
1251
+ or as arrays already defined on a regular evaluation grid.
1252
+ - When fields are defined on a grid, they are interpolated in the physical space using
1253
+ `scipy.interpolate.griddata` with linear interpolation.
1254
+ - The first axis of the field array or function output corresponds to the time step, even if there is only one.
1255
+ - The method supports B-splines of arbitrary dimension.
1256
+
1257
+ Examples
1258
+ --------
1259
+ >>> degrees = [2, 2]
1260
+ >>> knots = [np.array([0, 0, 0, 0.5, 1, 1, 1], dtype='float'),
1261
+ ... np.array([0, 0, 0, 1, 1, 1], dtype='float')]
1262
+ >>> spline = BSpline(degrees, knots)
1263
+ >>> ctrl_pts = np.random.rand(3, 4, 3) # 3D control points for a 2D surface
1264
+ >>> meshes = spline.make_elem_separator_meshes(ctrl_pts)
1265
+ >>> mesh = meshes[0]
1266
+ """
1267
+ if type(n_eval_per_elem) is int:
1268
+ n_eval_per_elem = [n_eval_per_elem] * self.NPa
1269
+ if XI is None:
1270
+ XI = self.linspace(n_eval_per_elem)
1271
+ interp_points = self(ctrl_pts, XI).reshape((3, -1)).T
1272
+ shape = [xi.size for xi in XI]
1273
+ NXI = np.prod(shape)
1274
+ NPh = ctrl_pts.shape[0]
1275
+ knots_uniq = []
1276
+ shape_uniq = []
1277
+ for basis in self.bases:
1278
+ knot = basis.knot
1279
+ knot_uniq = np.unique(knot[np.logical_and(knot >= 0, knot <= 1)])
1280
+ knots_uniq.append(knot_uniq)
1281
+ shape_uniq.append(knot_uniq.size)
1282
+ points = None
1283
+ point_data = {key: None for key in fields}
1284
+ lines = None
1285
+ Size = 0
1286
+ n = self.getNbFunc()
1287
+ for idx in range(self.NPa):
1288
+ basis = self.bases[idx]
1289
+ knot_uniq = knots_uniq[idx]
1290
+ n_eval = n_eval_per_elem[idx]
1291
+ couples = (
1292
+ np.concatenate(
1293
+ (
1294
+ np.expand_dims(knot_uniq[:-1], axis=0),
1295
+ np.expand_dims(knot_uniq[1:], axis=0),
1296
+ ),
1297
+ axis=0,
1298
+ )
1299
+ .reshape((2, -1))
1300
+ .T
1301
+ )
1302
+ for a, b in couples:
1303
+ lin = np.linspace(a, b, n_eval)
1304
+ inner_XI = tuple(knots_uniq[:idx] + [lin] + knots_uniq[(idx + 1) :])
1305
+ inner_shape = shape_uniq[:idx] + [lin.size] + shape_uniq[(idx + 1) :]
1306
+ size = np.prod(inner_shape)
1307
+ N = self.DN(inner_XI, [0] * self.NPa) # type: ignore
1308
+ pts = N @ ctrl_pts.reshape((NPh, -1)).T
1309
+ points = pts if points is None else np.vstack((points, pts))
1310
+ for key, value in fields.items():
1311
+ if key in paraview_sizes:
1312
+ to_store = np.full((n_step, size, paraview_sizes[key]), np.NAN)
1313
+ elif callable(value):
1314
+ to_store = value(self, inner_XI)
1315
+ else:
1316
+ value = np.asarray(value)
1317
+ if value.ndim >= 2 and value.shape[-self.NPa :] == tuple(
1318
+ ctrl_pts.shape[1:]
1319
+ ):
1320
+ paraview_size = value.shape[1]
1321
+ arr = value.reshape((n_step, paraview_size, n)).reshape(
1322
+ (n_step * paraview_size, n)
1323
+ )
1324
+ to_store = (
1325
+ (arr @ N.T)
1326
+ .reshape((n_step, paraview_size, -1))
1327
+ .transpose(0, 2, 1)
1328
+ )
1329
+ elif value.ndim >= 2 and value.shape[-self.NPa :] == tuple(
1330
+ shape
1331
+ ):
1332
+ paraview_size = value.shape[1]
1333
+ interp_field = griddata(
1334
+ interp_points,
1335
+ value.reshape((n_step * paraview_size, NXI)).T,
1336
+ pts,
1337
+ method="linear",
1338
+ )
1339
+ to_store = interp_field.reshape(
1340
+ (pts.shape[0], n_step, paraview_size)
1341
+ ).transpose(1, 0, 2)
1342
+ else:
1343
+ raise ValueError(
1344
+ f"Field {key} shape {value.shape} not understood."
1345
+ )
1346
+ if point_data[key] is None:
1347
+ point_data[key] = to_store
1348
+ else:
1349
+ point_data[key] = np.concatenate((point_data[key], to_store), axis=1) # type: ignore
1350
+ lns = Size + np.arange(size).reshape(inner_shape)
1351
+ lns = np.moveaxis(lns, idx, 0).reshape((inner_shape[idx], -1))
1352
+ lns = np.concatenate(
1353
+ (
1354
+ np.expand_dims(lns[:-1], axis=-1),
1355
+ np.expand_dims(lns[1:], axis=-1),
1356
+ ),
1357
+ axis=-1,
1358
+ ).reshape((-1, 2))
1359
+ lines = lns if lines is None else np.vstack((lines, lns))
1360
+ Size += size
1361
+ cells = {"line": lines}
1362
+ # make meshes
1363
+ meshes = []
1364
+ for i in range(n_step):
1365
+ point_data_step = {}
1366
+ for key, value in point_data.items():
1367
+ point_data_step[key] = value[i] # type: ignore
1368
+ mesh = io.Mesh(points, cells, point_data_step) # type: ignore
1369
+ meshes.append(mesh)
1370
+
1371
+ return meshes
1372
+
1373
+ def make_elements_interior_meshes(
1374
+ self,
1375
+ ctrl_pts: np.ndarray[np.floating],
1376
+ n_eval_per_elem: Union[int, Iterable[int]] = 10,
1377
+ n_step: int = 1,
1378
+ fields: dict = {},
1379
+ XI: Union[None, tuple[np.ndarray[np.floating], ...]] = None,
1380
+ ) -> list[io.Mesh]:
1381
+ """
1382
+ Create meshes representing the interior of each element in the B-spline.
1383
+
1384
+ This method generates a list of `io.Mesh` objects containing the geometry and optional fields
1385
+ for the interior of all elements, suitable for visualization (e.g., in ParaView). Supports
1386
+ time-dependent fields and arbitrary dimension.
1387
+
1388
+ Parameters
1389
+ ----------
1390
+ ctrl_pts : np.ndarray[np.floating]
1391
+ Array of control points of the B-spline, with shape
1392
+ (`NPh`, number of points for dim 1, ..., number of points for dim `NPa`),
1393
+ where `NPh` is the physical space dimension and `NPa` is the dimension of
1394
+ the isoparametric space.
1395
+ n_eval_per_elem : Union[int, Iterable[int]], optional
1396
+ Number of evaluation points per element for each isoparametric dimension.
1397
+ By default, 10.
1398
+ - If an `int` is provided, the same number is used for all dimensions.
1399
+ - If an `Iterable` is provided, each value corresponds to a different dimension.
1400
+ n_step : int, optional
1401
+ Number of time steps to plot. By default, 1.
1402
+ fields : dict, optional
1403
+ Dictionary of fields to plot at each time step. Keys are field names. Values can be:
1404
+ - a `function` taking (`BSpline` spline, `tuple` of `np.ndarray[np.floating]` XI) and
1405
+ returning a `np.ndarray[np.floating]` of shape (`n_step`, number of combinations of XI, field size),
1406
+ - a `np.ndarray[np.floating]` defined **on the control points**, of shape (`n_step`, field size, *`ctrl_pts.shape[1:]`),
1407
+ in which case it is interpolated using the B-spline basis functions,
1408
+ - a `np.ndarray[np.floating]` defined **directly on the evaluation grid**, of shape (`n_step`, field size, *grid shape),
1409
+ where `grid shape` is the shape of the discretization XI (i.e., number of points along each parametric axis).
1410
+ By default, `{}` (no fields).
1411
+ XI : tuple[np.ndarray[np.floating], ...], optional
1412
+ Parametric coordinates at which to evaluate the B-spline and fields.
1413
+ If not `None`, overrides the `n_eval_per_elem` parameter.
1414
+ If `None`, a regular grid is generated according to `n_eval_per_elem`.
1415
+
1416
+ Returns
1417
+ -------
1418
+ list[io.Mesh]
1419
+ List of `io.Mesh` objects, one for each time step, containing the element interior geometry
1420
+ and associated fields.
1421
+
1422
+ Notes
1423
+ -----
1424
+ - The interior mesh is constructed by evaluating the B-spline at a regular grid of points
1425
+ in the isoparametric space, with connectivity corresponding to lines (1D), quads (2D), or
1426
+ hexahedra (3D).
1427
+ - Fields can be provided either as arrays (on control points or on the discretization grid) or as functions.
1428
+ - Arrays given on control points are automatically interpolated using the B-spline basis functions.
1429
+ - Arrays already given on the evaluation grid are used directly without interpolation.
1430
+ - The first axis of the field array or function output must correspond to the time step, even if there is only one.
1431
+ - The method is compatible with B-splines of arbitrary dimension.
1432
+
1433
+ Examples
1434
+ --------
1435
+ >>> degrees = [2, 2]
1436
+ >>> knots = [np.array([0, 0, 0, 0.5, 1, 1, 1], dtype='float'),
1437
+ ... np.array([0, 0, 0, 1, 1, 1], dtype='float')]
1438
+ >>> spline = BSpline(degrees, knots)
1439
+ >>> ctrl_pts = np.random.rand(3, 4, 3) # 3D control points for a 2D surface
1440
+ >>> # Field given on control points (needs interpolation)
1441
+ >>> field_on_ctrl_pts = np.random.rand(1, 1, 4, 3)
1442
+ >>> # Field given directly on the evaluation grid (no interpolation)
1443
+ >>> field_on_grid = np.random.rand(1, 1, 10, 10)
1444
+ >>> meshes = spline.make_elements_interior_meshes(
1445
+ ... ctrl_pts,
1446
+ ... fields={'temperature': field_on_ctrl_pts, 'pressure': field_on_grid}
1447
+ ... XI= # TODO
1448
+ ... )
1449
+ >>> mesh = meshes[0]
1450
+ """
1451
+ if XI is None:
1452
+ XI = self.linspace(n_eval_per_elem)
1453
+ # make points
1454
+ N = self.DN(XI)
1455
+ NPh = ctrl_pts.shape[0]
1456
+ points = N @ ctrl_pts.reshape((NPh, -1)).T
1457
+ # make connectivity
1458
+ elements = {2: "line", 4: "quad", 8: "hexahedron"}
1459
+ shape = [xi.size for xi in XI]
1460
+ NXI = np.prod(shape)
1461
+ inds = np.arange(NXI).reshape(shape)
1462
+ for idx in range(self.NPa):
1463
+ rng = np.arange(inds.shape[idx])
1464
+ inds = np.concatenate(
1465
+ (
1466
+ np.expand_dims(np.take(inds, rng[:-1], axis=idx), axis=-1),
1467
+ np.expand_dims(np.take(inds, rng[1:], axis=idx), axis=-1),
1468
+ ),
1469
+ axis=-1,
1470
+ )
1471
+ if self.NPa >= 2:
1472
+ for i in np.ndindex(*inds.shape[:-2]):
1473
+ inds[i] = [
1474
+ [inds[i + (0, 0)], inds[i + (0, 1)]],
1475
+ [inds[i + (1, 1)], inds[i + (1, 0)]],
1476
+ ]
1477
+ inds = inds.reshape((*inds.shape[: self.NPa], -1))
1478
+ inds = inds.reshape((-1, inds.shape[-1]))
1479
+ cells = {elements[inds.shape[-1]]: inds}
1480
+ # make fields
1481
+ n = self.getNbFunc()
1482
+ point_data = {}
1483
+ for key, value in fields.items():
1484
+ if callable(value):
1485
+ point_data[key] = value(self, XI)
1486
+ else:
1487
+ value = np.asarray(value)
1488
+ if value.ndim >= 2 and value.shape[-self.NPa :] == tuple(
1489
+ ctrl_pts.shape[1:]
1490
+ ):
1491
+ # Field given on control points: interpolate with basis functions
1492
+ paraview_size = value.shape[1]
1493
+ arr = value.reshape((n_step * paraview_size, n))
1494
+ point_data[key] = (
1495
+ (arr @ N.T)
1496
+ .reshape((n_step, paraview_size, NXI))
1497
+ .transpose(0, 2, 1)
1498
+ )
1499
+ elif value.ndim >= 2 and value.shape[-self.NPa :] == tuple(shape):
1500
+ # Field already given on discretization grid
1501
+ paraview_size = value.shape[1]
1502
+ point_data[key] = value.reshape(
1503
+ (n_step, paraview_size, NXI)
1504
+ ).transpose(0, 2, 1)
1505
+ else:
1506
+ raise ValueError(f"Field {key} shape {value.shape} not understood.")
1507
+ # make meshes
1508
+ meshes = []
1509
+ for i in range(n_step):
1510
+ point_data_step = {}
1511
+ for key, value in point_data.items():
1512
+ point_data_step[key] = value[i]
1513
+ mesh = io.Mesh(points, cells, point_data_step) # type: ignore
1514
+ meshes.append(mesh)
1515
+
1516
+ return meshes
1517
+
1518
+ def saveParaview(
1519
+ self,
1520
+ ctrl_pts: np.ndarray[np.floating],
1521
+ path: str,
1522
+ name: str,
1523
+ n_step: int = 1,
1524
+ n_eval_per_elem: Union[int, Iterable[int]] = 10,
1525
+ fields: Union[dict, None] = None,
1526
+ XI: Union[None, tuple[np.ndarray[np.floating], ...]] = None,
1527
+ groups: Union[dict[str, dict[str, Union[str, int]]], None] = None,
1528
+ make_pvd: bool = True,
1529
+ verbose: bool = True,
1530
+ fields_on_interior_only: Union[bool, Literal["auto"], list[str]] = "auto",
1531
+ ) -> dict[str, dict[str, Union[str, int]]]:
1532
+ """
1533
+ Save B-spline visualization data as Paraview files.
1534
+
1535
+ This method creates three types of visualization files:
1536
+ - Interior mesh showing the B-spline surface/volume
1537
+ - Element borders showing the mesh structure
1538
+ - Control points mesh showing the control structure
1539
+
1540
+ All files are saved in VTU format with an optional PVD file to group them.
1541
+
1542
+ Parameters
1543
+ ----------
1544
+ ctrl_pts : np.ndarray[np.floating]
1545
+ Control points defining the B-spline geometry.
1546
+ Shape: (`NPh`, n1, n2, ...) where:
1547
+ - `NPh` is the dimension of the physical space
1548
+ - ni is the number of control points in the i-th isoparametric dimension
1549
+
1550
+ path : str
1551
+ Directory path where the PV files will be saved
1552
+
1553
+ name : str
1554
+ Base name for the output files
1555
+
1556
+ n_step : int, optional
1557
+ Number of time steps to save. By default, 1.
1558
+
1559
+ n_eval_per_elem : Union[int, Iterable[int]], optional
1560
+ Number of evaluation points per element for each isoparametric dimension.
1561
+ By default, 10.
1562
+ - If an `int` is provided, the same number is used for all dimensions.
1563
+ - If an `Iterable` is provided, each value corresponds to a different dimension.
1564
+
1565
+ fields : Union[dict, None], optional
1566
+ Fields to visualize at each time step. Dictionary format:
1567
+ {
1568
+ "field_name": `field_value`
1569
+ }
1570
+ where `field_value` can be either:
1571
+
1572
+ 1. A numpy array with shape (`n_step`, `field_size`, `*ctrl_pts.shape[1:]`) where:
1573
+ - `n_step`: Number of time steps
1574
+ - `field_size`: Size of the field at each point (1 for scalar, 3 for vector)
1575
+ - `*ctrl_pts.shape[1:]`: Same shape as control points (excluding `NPh`)
1576
+
1577
+ 2. A numpy array with shape (`n_step`, `field_size`, `*grid_shape`) where:
1578
+ - `n_step`: Number of time steps
1579
+ - `field_size`: Size of the field at each point (1 for scalar, 3 for vector)
1580
+ - `*grid_shape`: Shape of the evaluation grid (number of points along each isoparametric axis)
1581
+
1582
+ 3. A function that computes field values (`np.ndarray[np.floating]`) at given
1583
+ points from the `BSpline` instance and `XI`, the tuple of arrays containing evaluation
1584
+ points for each dimension (`tuple[np.ndarray[np.floating], ...]`).
1585
+ The result should be an array of shape (`n_step`, `n_points`, `field_size`) where:
1586
+ - `n_step`: Number of time steps
1587
+ - `n_points`: Number of evaluation points (n_xi × n_eta × ...)
1588
+ - `field_size`: Size of the field at each point (1 for scalar, 3 for vector)
1589
+
1590
+ By default, None.
1591
+
1592
+ XI : tuple[np.ndarray[np.floating], ...], optional
1593
+ Parametric coordinates at which to evaluate the B-spline and fields.
1594
+ If not `None`, overrides the `n_eval_per_elem` parameter.
1595
+ If `None`, a regular grid is generated according to `n_eval_per_elem`.
1596
+
1597
+ groups : Union[dict[str, dict[str, Union[str, int]]], None], optional
1598
+ Nested dictionary specifying file groups for PVD organization. Format:
1599
+ {
1600
+ "group_name": {
1601
+ "ext": str, # File extension (e.g., "vtu")
1602
+ "npart": int, # Number of parts in the group
1603
+ "nstep": int # Number of timesteps
1604
+ }
1605
+ }
1606
+ The method automatically creates/updates three groups:
1607
+ - "interior": For the B-spline surface/volume mesh
1608
+ - "elements_borders": For the element boundary mesh
1609
+ - "control_points": For the control point mesh
1610
+
1611
+ If provided, existing groups are updated; if None, these groups are created.
1612
+ By default, None.
1613
+
1614
+ make_pvd : bool, optional
1615
+ Whether to create a PVD file grouping all VTU files. By default, True.
1616
+
1617
+ verbose : bool, optional
1618
+ Whether to print progress information. By default, True.
1619
+
1620
+ fields_on_interior_only: Union[bool, Literal['auto'], list[str]], optionnal
1621
+ Whether to include fields only on the interior mesh (`True`), on all meshes (`False`),
1622
+ or on specified field names.
1623
+ If set to `'auto'`, fields named `'u'`, `'U'`, `'displacement'` or `'displ'`
1624
+ are included on all meshes while others are only included on the interior mesh.
1625
+ By default, 'auto'.
1626
+
1627
+ Returns
1628
+ -------
1629
+ groups : dict[str, dict[str, Union[str, int]]]
1630
+ Updated groups dictionary with information about saved files.
1631
+
1632
+ Notes
1633
+ -----
1634
+ - Creates three types of VTU files for each time step:
1635
+ - {name}_interior_{part}_{step}.vtu
1636
+ - {name}_elements_borders_{part}_{step}.vtu
1637
+ - {name}_control_points_{part}_{step}.vtu
1638
+ - If `make_pvd=True`, creates a PVD file named {name}.pvd
1639
+ - Fields can be visualized as scalars or vectors in Paraview
1640
+ - The method supports time-dependent visualization through `n_step`
1641
+
1642
+ Examples
1643
+ --------
1644
+ Save a 2D B-spline visualization:
1645
+ >>> degrees = [2, 2]
1646
+ >>> knots = [np.array([0, 0, 0, 0.5, 1, 1, 1], dtype='float'),
1647
+ ... np.array([0, 0, 0, 0.5, 1, 1, 1], dtype='float')]
1648
+ >>> spline = BSpline(degrees, knots)
1649
+ >>> ctrl_pts = np.random.rand(3, 4, 4) # 3D control points
1650
+ >>> spline.saveParaview(ctrl_pts, "./output", "bspline")
1651
+
1652
+ Save with a custom field:
1653
+ >>> def displacement(spline, XI):
1654
+ ... # Compute displacement field
1655
+ ... return np.random.rand(1, np.prod([x.size for x in XI]), 3)
1656
+ >>> fields = {"displacement": displacement}
1657
+ >>> spline.saveParaview(ctrl_pts, "./output", "bspline", fields=fields)
1658
+ """
1659
+
1660
+ if type(n_eval_per_elem) is int:
1661
+ n_eval_per_elem = [n_eval_per_elem] * self.NPa
1662
+
1663
+ if fields is None:
1664
+ fields = {}
1665
+
1666
+ if groups is None:
1667
+ groups = {}
1668
+
1669
+ interior = "interior"
1670
+ if interior in groups:
1671
+ groups[interior]["npart"] += 1
1672
+ else:
1673
+ groups[interior] = {"ext": "vtu", "npart": 1, "nstep": n_step}
1674
+ elements_borders = "elements_borders"
1675
+ if elements_borders in groups:
1676
+ groups[elements_borders]["npart"] += 1
1677
+ else:
1678
+ groups[elements_borders] = {"ext": "vtu", "npart": 1, "nstep": n_step}
1679
+ control_points = "control_points"
1680
+ if control_points in groups:
1681
+ groups[control_points]["npart"] += 1
1682
+ else:
1683
+ groups[control_points] = {"ext": "vtu", "npart": 1, "nstep": n_step}
1684
+
1685
+ paraview_sizes = {}
1686
+ if fields_on_interior_only is True:
1687
+ for key, value in fields.items():
1688
+ if callable(value):
1689
+ paraview_sizes[key] = value(self, np.zeros((self.NPa, 1))).shape[2]
1690
+ else:
1691
+ paraview_sizes[key] = value.shape[1]
1692
+ elif fields_on_interior_only is False:
1693
+ pass
1694
+ elif fields_on_interior_only == "auto":
1695
+ for key, value in fields.items():
1696
+ if key not in ["u", "U", "displacement", "displ"]:
1697
+ if callable(value):
1698
+ paraview_sizes[key] = value(
1699
+ self, np.zeros((self.NPa, 1))
1700
+ ).shape[2]
1701
+ else:
1702
+ paraview_sizes[key] = value.shape[1]
1703
+ else:
1704
+ for key in fields_on_interior_only:
1705
+ value = fields[key]
1706
+ if callable(value):
1707
+ paraview_sizes[key] = value(self, np.zeros((self.NPa, 1))).shape[2]
1708
+ else:
1709
+ paraview_sizes[key] = value.shape[1]
1710
+
1711
+ meshes = self.make_elements_interior_meshes(
1712
+ ctrl_pts, n_eval_per_elem, n_step, fields, XI
1713
+ )
1714
+ prefix = os.path.join(
1715
+ path, f"{name}_{interior}_{groups[interior]['npart'] - 1}"
1716
+ )
1717
+ for time_step, mesh in enumerate(meshes):
1718
+ mesh.write(f"{prefix}_{time_step}.vtu")
1719
+ if verbose:
1720
+ print(interior, "done")
1721
+
1722
+ meshes = self.make_elem_separator_meshes(
1723
+ ctrl_pts, n_eval_per_elem, n_step, fields, XI, paraview_sizes
1724
+ )
1725
+ prefix = os.path.join(
1726
+ path, f"{name}_{elements_borders}_{groups[elements_borders]['npart'] - 1}"
1727
+ )
1728
+ for time_step, mesh in enumerate(meshes):
1729
+ mesh.write(f"{prefix}_{time_step}.vtu")
1730
+ if verbose:
1731
+ print(elements_borders, "done")
1732
+
1733
+ meshes = self.make_control_poly_meshes(
1734
+ ctrl_pts, n_eval_per_elem, n_step, fields, XI, paraview_sizes
1735
+ )
1736
+ prefix = os.path.join(
1737
+ path, f"{name}_{control_points}_{groups[control_points]['npart'] - 1}"
1738
+ )
1739
+ for time_step, mesh in enumerate(meshes):
1740
+ mesh.write(f"{prefix}_{time_step}.vtu")
1741
+ if verbose:
1742
+ print(control_points, "done")
1743
+
1744
+ if make_pvd:
1745
+ writePVD(os.path.join(path, name), groups)
1746
+
1747
+ return groups
1748
+
1749
+ def getGeomdl(self, ctrl_pts):
1750
+ try:
1751
+ from geomdl import BSpline as geomdlBS
1752
+ except:
1753
+ raise
1754
+ if self.NPa == 1:
1755
+ curve = geomdlBS.Curve()
1756
+ curve.degree = self.bases[0].p
1757
+ curve.ctrl_pts = ctrl_pts.T.tolist()
1758
+ curve.knotvector = self.bases[0].knot
1759
+ return curve
1760
+ elif self.NPa == 2:
1761
+ surf = geomdlBS.Surface()
1762
+ surf.degree_u = self.bases[0].p
1763
+ surf.degree_v = self.bases[1].p
1764
+ surf.ctrl_pts2d = ctrl_pts.transpose((1, 2, 0)).tolist()
1765
+ surf.knotvector_u = self.bases[0].knot
1766
+ surf.knotvector_v = self.bases[1].knot
1767
+ return surf
1768
+ elif self.NPa == 3:
1769
+ vol = geomdlBS.Volume()
1770
+ vol.degree_u = self.bases[0].p
1771
+ vol.degree_v = self.bases[1].p
1772
+ vol.degree_w = self.bases[2].p
1773
+ vol.cpsize = ctrl_pts.shape[1:]
1774
+ # ctrl_pts format (zeta, xi, eta)
1775
+ vol.ctrl_pts = (
1776
+ ctrl_pts.transpose(3, 1, 2, 0).reshape((-1, ctrl_pts.shape[0])).tolist()
1777
+ )
1778
+ vol.knotvector_u = self.bases[0].knot
1779
+ vol.knotvector_v = self.bases[1].knot
1780
+ vol.knotvector_w = self.bases[2].knot
1781
+ return vol
1782
+ else:
1783
+ raise NotImplementedError("Can only export curves, sufaces or volumes !")
1784
+
1785
+ def to_dict(self) -> dict:
1786
+ """
1787
+ Returns a dictionary representation of the BSpline object.
1788
+ """
1789
+ return {"NPa": self.NPa, "bases": [b.to_dict() for b in self.bases]}
1790
+
1791
+ @classmethod
1792
+ def from_dict(cls, data: dict) -> "BSpline":
1793
+ """
1794
+ Creates a BSpline object from a dictionary representation.
1795
+ """
1796
+ NPa = data["NPa"]
1797
+ bases = np.array([BSplineBasis.from_dict(b) for b in data["bases"]])
1798
+ assert len(bases) == NPa, "The parametric space must be of size 'NPa'."
1799
+ return cls.from_bases(bases)
1800
+
1801
+ def save(self, filepath: str, ctrl_pts: Union[np.ndarray, None] = None) -> None:
1802
+ """
1803
+ Save the BSpline object to a file.
1804
+ Control points are optional.
1805
+ Supported extensions: json, pkl
1806
+ """
1807
+ data = self.to_dict()
1808
+ if ctrl_pts is not None:
1809
+ data["ctrl_pts"] = ctrl_pts.tolist()
1810
+ ext = filepath.split(".")[-1]
1811
+ if ext == "json":
1812
+ with open(filepath, "w") as f:
1813
+ json.dump(data, f, indent=2)
1814
+ elif ext == "pkl":
1815
+ with open(filepath, "wb") as f:
1816
+ pickle.dump(data, f)
1817
+ else:
1818
+ raise ValueError(
1819
+ f"Unknown extension {ext}. Supported extensions: json, pkl."
1820
+ )
1821
+
1822
+ @classmethod
1823
+ def load(cls, filepath: str) -> Union["BSpline", tuple["BSpline", np.ndarray]]:
1824
+ """
1825
+ Load a BSpline object from a file.
1826
+ May return control points if the file contains them.
1827
+ Supported extensions: json, pkl
1828
+ """
1829
+ ext = filepath.split(".")[-1]
1830
+ if ext == "json":
1831
+ with open(filepath, "r") as f:
1832
+ data = json.load(f)
1833
+ elif ext == "pkl":
1834
+ with open(filepath, "rb") as f:
1835
+ data = pickle.load(f)
1836
+ else:
1837
+ raise ValueError(
1838
+ f"Unknown extension {ext}. Supported extensions: json, pkl."
1839
+ )
1840
+ this = cls.from_dict(data)
1841
+ if "ctrl_pts" in data:
1842
+ ctrl_pts = np.array(data["ctrl_pts"])
1843
+ return this, ctrl_pts
1844
+ return this
1845
+
1846
+ def plot(
1847
+ self,
1848
+ ctrl_pts: np.ndarray[np.floating],
1849
+ n_eval_per_elem: Union[int, Iterable[int]] = 10,
1850
+ plotter: Union[mpl.axes.Axes, "pv.Plotter", None] = None,
1851
+ ctrl_color: str = "#d95f02",
1852
+ interior_color: str = "#666666",
1853
+ elem_color: str = "#7570b3",
1854
+ border_color: str = "#1b9e77",
1855
+ language: Union[Literal["english"], Literal["français"]] = "english",
1856
+ show: bool = True,
1857
+ ) -> Union[mpl.axes.Axes, "pv.Plotter", None]:
1858
+ """
1859
+ Plot the B-spline using either Matplotlib or PyVista, depending on availability.
1860
+
1861
+ Automatically selects the appropriate plotting backend (Matplotlib or PyVista)
1862
+ based on which libraries are installed. Supports visualization of B-spline curves,
1863
+ surfaces, and volumes in 2D or 3D space, with control mesh, element borders, and patch borders.
1864
+
1865
+ Parameters
1866
+ ----------
1867
+ ctrl_pts : np.ndarray[np.floating]
1868
+ Control points defining the B-spline geometry.
1869
+ Shape: (NPh, n1, n2, ...) where:
1870
+ - NPh is the dimension of the physical space (2 or 3)
1871
+ - ni is the number of control points in the i-th isoparametric dimension
1872
+ n_eval_per_elem : Union[int, Iterable[int]], optional
1873
+ Number of evaluation points per element for visualizing the B-spline.
1874
+ Can be specified as:
1875
+ - Single integer: Same number for all dimensions
1876
+ - Iterable of integers: Different numbers for each dimension
1877
+ By default, 10.
1878
+ plotter : Union[mpl.axes.Axes, 'pv.Plotter', None], optional
1879
+ Plotter object for the visualization:
1880
+ - If PyVista is available: Can be a `pv.Plotter` instance
1881
+ - If only Matplotlib is available: Can be a `mpl.axes.Axes` instance
1882
+ - If None, creates a new plotter/axes.
1883
+ Default is None.
1884
+ ctrl_color : str, optional
1885
+ Color for the control mesh visualization.
1886
+ Default is '#d95f02' (orange).
1887
+ interior_color : str, optional
1888
+ Color for the B-spline geometry.
1889
+ Default is '#666666' (gray).
1890
+ elem_color : str, optional
1891
+ Color for element boundary visualization.
1892
+ Default is '#7570b3' (purple).
1893
+ border_color : str, optional
1894
+ Color for patch boundary visualization.
1895
+ Default is '#1b9e77' (green).
1896
+ language : str, optional
1897
+ Language for the plot labels and legends in matplotlib. Can be 'english' or 'français'.
1898
+ Default is 'english'.
1899
+ show : bool, optional
1900
+ Whether to display the plot immediately.
1901
+ Default is True.
1902
+
1903
+ Returns
1904
+ -------
1905
+ plotter : Union[mpl.axes.Axes, 'pv.Plotter', None]
1906
+ The plotter object used for visualization (Matplotlib axes or PyVista plotter)
1907
+ if `show` is False. Otherwise, returns None.
1908
+
1909
+ Notes
1910
+ -----
1911
+ - If PyVista is available and the physical space is 3D, uses `plotPV` for 3D visualization.
1912
+ - Otherwise, uses `plotMPL` for 2D/3D visualization with Matplotlib.
1913
+ - For 3D visualization, PyVista is recommended for better interactivity and rendering.
1914
+ - For 2D visualization, Matplotlib is used by default.
1915
+
1916
+ Examples
1917
+ --------
1918
+ Plot a 2D curve in 2D space:
1919
+ >>> degrees = [2]
1920
+ >>> knots = [np.array([0, 0, 0, 1, 1, 1], dtype='float')]
1921
+ >>> spline = BSpline(degrees, knots)
1922
+ >>> ctrl_pts = np.random.rand(2, 3) # 2D control points
1923
+ >>> spline.plot(ctrl_pts)
1924
+
1925
+ Plot a 2D surface in 3D space with PyVista (if available):
1926
+ >>> degrees = [2, 2]
1927
+ >>> knots = [np.array([0, 0, 0, 1, 1, 1], dtype='float'),
1928
+ ... np.array([0, 0, 0, 1, 1, 1], dtype='float')]
1929
+ >>> spline = BSpline(degrees, knots)
1930
+ >>> ctrl_pts = np.random.rand(3, 3, 3) # 3D control points
1931
+ >>> spline.plot(ctrl_pts)
1932
+ """
1933
+ NPh = ctrl_pts.shape[0]
1934
+ if _can_visualize_pyvista and NPh == 3:
1935
+ return self.plotPV(
1936
+ ctrl_pts,
1937
+ n_eval_per_elem,
1938
+ plotter,
1939
+ ctrl_color,
1940
+ interior_color,
1941
+ elem_color,
1942
+ border_color,
1943
+ show,
1944
+ )
1945
+ else:
1946
+ return self.plotMPL(
1947
+ ctrl_pts,
1948
+ n_eval_per_elem,
1949
+ plotter,
1950
+ ctrl_color,
1951
+ interior_color,
1952
+ elem_color,
1953
+ border_color,
1954
+ language,
1955
+ show,
1956
+ )
1957
+
1958
+ def plotMPL(
1959
+ self,
1960
+ ctrl_pts: np.ndarray[np.floating],
1961
+ n_eval_per_elem: Union[int, Iterable[int]] = 10,
1962
+ ax: Union[mpl.axes.Axes, None] = None,
1963
+ ctrl_color: str = "#1b9e77",
1964
+ interior_color: str = "#7570b3",
1965
+ elem_color: str = "#666666",
1966
+ border_color: str = "#d95f02",
1967
+ language: Union[Literal["english"], Literal["français"]] = "english",
1968
+ show: bool = True,
1969
+ ) -> Union[mpl.axes.Axes, None]:
1970
+ """
1971
+ Plot the B-spline using Matplotlib.
1972
+
1973
+ Creates a visualization of the B-spline geometry showing the control mesh,
1974
+ B-spline surface/curve, element borders, and patch borders. Supports plotting
1975
+ 1D curves and 2D surfaces in 2D space, and 2D surfaces and 3D volumes in 3D space.
1976
+
1977
+ Parameters
1978
+ ----------
1979
+ ctrl_pts : np.ndarray[np.floating]
1980
+ Control points defining the B-spline geometry.
1981
+ Shape: (NPh, n1, n2, ...) where:
1982
+ - NPh is the dimension of the physical space (2 or 3)
1983
+ - ni is the number of control points in the i-th isoparametric dimension
1984
+
1985
+ n_eval_per_elem : Union[int, Iterable[int]], optional
1986
+ Number of evaluation points per element for visualizing the B-spline.
1987
+ Can be specified as:
1988
+ - Single integer: Same number for all dimensions
1989
+ - Iterable of integers: Different numbers for each dimension
1990
+ By default, 10.
1991
+
1992
+ ax : Union[mpl.axes.Axes, None], optional
1993
+ Matplotlib axes for plotting. If None, creates a new figure and axes.
1994
+ For 3D visualizations, must be a 3D axes if provided (created with
1995
+ `projection='3d'`).
1996
+ Default is None (creates new axes).
1997
+
1998
+ ctrl_color : str, optional
1999
+ Color for the control mesh visualization:
2000
+ - Applied to control points (markers)
2001
+ - Applied to control mesh lines
2002
+ Default is '#1b9e77' (green).
2003
+
2004
+ interior_color : str, optional
2005
+ Color for the B-spline geometry:
2006
+ - For curves: Line color
2007
+ - For surfaces: Face color (with transparency)
2008
+ - For volumes: Face color of boundary surfaces (with transparency)
2009
+ Default is '#7570b3' (purple).
2010
+
2011
+ elem_color : str, optional
2012
+ Color for element boundary visualization:
2013
+ - Shows internal mesh structure
2014
+ - Helps visualize knot locations
2015
+ Default is '#666666' (gray).
2016
+
2017
+ border_color : str, optional
2018
+ Color for patch boundary visualization:
2019
+ - Outlines the entire B-spline patch
2020
+ - Helps distinguish patch edges
2021
+ Default is '#d95f02' (orange).
2022
+
2023
+ language: str, optional
2024
+ Language for the plot labels. Can be 'english' or 'français'.
2025
+ Default is 'english'.
2026
+
2027
+ show : bool, optional
2028
+ Whether to display the plot immediately. Can be useful to add more stuff to the plot.
2029
+ Default is True.
2030
+
2031
+ Returns
2032
+ -------
2033
+ ax : Union[mpl.axes.Axes, None]
2034
+ Matplotlib axes for the plot if show is deactivated, otherwise None.
2035
+
2036
+ Notes
2037
+ -----
2038
+ Visualization components:
2039
+ - Control mesh: Shows control points and their connections
2040
+ - B-spline: Shows the actual curve/surface/volume
2041
+ - Element borders: Shows the boundaries between elements
2042
+ - Patch borders: Shows the outer boundaries of the B-spline
2043
+
2044
+ Supported configurations:
2045
+ - 1D B-spline in 2D space (curve)
2046
+ - 2D B-spline in 2D space (surface)
2047
+ - 2D B-spline in 3D space (surface)
2048
+ - 3D B-spline in 3D space (volume)
2049
+
2050
+ For 3D visualization:
2051
+ - Surfaces are shown with transparency
2052
+ - Volume visualization shows the faces with transparency
2053
+ - View angle is automatically set for surfaces based on surface normal
2054
+
2055
+ Examples
2056
+ --------
2057
+ Plot a 2D curve in 2D space:
2058
+ >>> degrees = [2]
2059
+ >>> knots = [np.array([0, 0, 0, 1, 1, 1], dtype='float')]
2060
+ >>> spline = BSpline(degrees, knots)
2061
+ >>> ctrl_pts = np.random.rand(2, 3) # 2D control points
2062
+ >>> spline.plotMPL(ctrl_pts)
2063
+
2064
+ Plot a 2D surface in 3D space:
2065
+ >>> degrees = [2, 2]
2066
+ >>> knots = [np.array([0, 0, 0, 1, 1, 1], dtype='float'),
2067
+ ... np.array([0, 0, 0, 1, 1, 1], dtype='float')]
2068
+ >>> spline = BSpline(degrees, knots)
2069
+ >>> ctrl_pts = np.random.rand(3, 3, 3) # 3D control points
2070
+ >>> spline.plotMPL(ctrl_pts)
2071
+
2072
+ Plot on existing axes with custom colors:
2073
+ >>> fig = plt.figure()
2074
+ >>> ax = fig.add_subplot(projection='3d')
2075
+ >>> spline.plotMPL(ctrl_pts, ax=ax, ctrl_color='red', interior_color='blue')
2076
+ """
2077
+ import matplotlib.pyplot as plt
2078
+ from matplotlib.collections import LineCollection
2079
+ from matplotlib.patches import Polygon
2080
+ from mpl_toolkits.mplot3d.art3d import Line3DCollection
2081
+ from matplotlib import lines
2082
+
2083
+ if language == "english":
2084
+ ctrl_mesh = "Control mesh"
2085
+ elems_bord = "Elements borders"
2086
+ b_spline = "B-spline"
2087
+ b_spline_patch = "B-spline patch"
2088
+ patch_bord = "Patch borders"
2089
+ elif language == "français":
2090
+ ctrl_mesh = "Maillage de contrôle"
2091
+ elems_bord = "Frontières inter-éléments"
2092
+ b_spline = "B-spline"
2093
+ b_spline_patch = "Patch B-spline"
2094
+ patch_bord = "Frontières inter-patchs"
2095
+ else:
2096
+ raise NotImplementedError(
2097
+ f"Can't understand language '{language}'. Try 'english' or 'français'."
2098
+ )
2099
+ NPh = ctrl_pts.shape[0]
2100
+ fig = plt.figure() if ax is None else ax.get_figure()
2101
+ if NPh == 2:
2102
+ ax = fig.add_subplot() if ax is None else ax
2103
+ if self.NPa == 1:
2104
+ ax.plot(
2105
+ ctrl_pts[0],
2106
+ ctrl_pts[1],
2107
+ marker="o",
2108
+ c=ctrl_color,
2109
+ label=ctrl_mesh,
2110
+ zorder=0,
2111
+ )
2112
+ (xi,) = self.linspace(n_eval_per_elem=n_eval_per_elem)
2113
+ x, y = self.__call__(ctrl_pts, [xi])
2114
+ ax.plot(x, y, c=interior_color, label=b_spline, zorder=1)
2115
+ (xi_elem,) = self.linspace(n_eval_per_elem=1)
2116
+ x_elem, y_elem = self.__call__(ctrl_pts, [xi_elem])
2117
+ ax.scatter(x_elem, y_elem, marker="*", c=elem_color, label=elems_bord, zorder=2) # type: ignore
2118
+ elif self.NPa == 2:
2119
+ xi, eta = self.linspace(n_eval_per_elem=n_eval_per_elem)
2120
+ xi_elem, eta_elem = self.linspace(n_eval_per_elem=1)
2121
+ x_xi, y_xi = self.__call__(ctrl_pts, [xi_elem, eta])
2122
+ x_eta, y_eta = self.__call__(ctrl_pts, [xi, eta_elem])
2123
+ x_pol = np.hstack(
2124
+ (x_xi[0, ::1], x_eta[::1, -1], x_xi[-1, ::-1], x_eta[::-1, 0])
2125
+ )
2126
+ y_pol = np.hstack(
2127
+ (y_xi[0, ::1], y_eta[::1, -1], y_xi[-1, ::-1], y_eta[::-1, 0])
2128
+ )
2129
+ xy_pol = np.hstack((x_pol[:, None], y_pol[:, None]))
2130
+ ax.add_patch(Polygon(xy_pol, fill=True, edgecolor=None, facecolor=interior_color, alpha=0.5, label=b_spline_patch, zorder=0)) # type: ignore
2131
+ ax.plot(
2132
+ ctrl_pts[0, 0, 0],
2133
+ ctrl_pts[1, 0, 0],
2134
+ marker="o",
2135
+ c=ctrl_color,
2136
+ label=ctrl_mesh,
2137
+ zorder=1,
2138
+ ms=plt.rcParams["lines.markersize"] / np.sqrt(2),
2139
+ )
2140
+ ax.add_collection(LineCollection(ctrl_pts.transpose(1, 2, 0), colors=ctrl_color, zorder=1)) # type: ignore
2141
+ ax.add_collection(LineCollection(ctrl_pts.transpose(2, 1, 0), colors=ctrl_color, zorder=1)) # type: ignore
2142
+ ax.scatter(ctrl_pts[0].ravel(), ctrl_pts[1].ravel(), marker="o", c=ctrl_color, zorder=1, s=0.5 * plt.rcParams["lines.markersize"] ** 2) # type: ignore
2143
+ ax.plot(
2144
+ x_xi[0, 0],
2145
+ y_xi[0, 0],
2146
+ linestyle="-",
2147
+ c=elem_color,
2148
+ label=elems_bord,
2149
+ zorder=2,
2150
+ )
2151
+ ax.add_collection(LineCollection(np.array([x_xi, y_xi]).transpose(1, 2, 0)[1:-1], colors=elem_color, zorder=2)) # type: ignore
2152
+ ax.add_collection(LineCollection(np.array([x_eta, y_eta]).transpose(2, 1, 0)[1:-1], colors=elem_color, zorder=2)) # type: ignore
2153
+ ax.add_patch(Polygon(xy_pol, lw=1.25 * plt.rcParams["lines.linewidth"], fill=False, edgecolor=border_color, label=patch_bord, zorder=2)) # type: ignore
2154
+ else:
2155
+ raise ValueError(f"Can't plot a {self.NPa}D shape in a 2D space.")
2156
+ ax.legend()
2157
+ ax.set_aspect(1)
2158
+ elif NPh == 3:
2159
+ ax = fig.add_subplot(projection="3d") if ax is None else ax
2160
+ if self.NPa == 1:
2161
+ pass
2162
+ elif self.NPa == 2:
2163
+ xi, eta = self.linspace(n_eval_per_elem=n_eval_per_elem)
2164
+ xi_elem, eta_elem = self.linspace(n_eval_per_elem=1)
2165
+ x, y, z = self.__call__(ctrl_pts, [xi, eta])
2166
+ x_xi, y_xi, z_xi = self.__call__(ctrl_pts, [xi_elem, eta])
2167
+ x_eta, y_eta, z_eta = self.__call__(ctrl_pts, [xi, eta_elem])
2168
+ ax.plot_surface(
2169
+ x,
2170
+ y,
2171
+ z,
2172
+ rcount=1,
2173
+ ccount=1,
2174
+ edgecolor=None,
2175
+ facecolor=interior_color,
2176
+ alpha=0.5,
2177
+ )
2178
+ ax.plot_wireframe(
2179
+ ctrl_pts[0], ctrl_pts[1], ctrl_pts[2], color=ctrl_color, zorder=2
2180
+ )
2181
+ ax.scatter(
2182
+ ctrl_pts[0], ctrl_pts[1], ctrl_pts[2], color=ctrl_color, zorder=2
2183
+ )
2184
+ ax.add_collection(Line3DCollection(np.array([x_xi, y_xi, z_xi]).transpose(1, 2, 0), colors=elem_color, zorder=1)) # type: ignore
2185
+ ax.add_collection(Line3DCollection(np.array([x_eta, y_eta, z_eta]).transpose(2, 1, 0), colors=elem_color, zorder=1)) # type: ignore
2186
+ # ax.plot_surface(x, y, z, rcount=1, ccount=1, edgecolor=border_color, facecolor=None, alpha=0)
2187
+ ctrl_handle = lines.Line2D(
2188
+ [], [], color=ctrl_color, marker="o", linestyle="-", label=ctrl_mesh
2189
+ )
2190
+ elem_handle = lines.Line2D(
2191
+ [], [], color=elem_color, linestyle="-", label=elems_bord
2192
+ )
2193
+ border_handle = lines.Line2D(
2194
+ [], [], color=border_color, linestyle="-", label=patch_bord
2195
+ )
2196
+ ax.legend(handles=[ctrl_handle, elem_handle, border_handle])
2197
+ mid_param = [
2198
+ np.array([sum(self.bases[0].span) / 2]),
2199
+ np.array([sum(self.bases[1].span) / 2]),
2200
+ ]
2201
+ dxi, deta = self.__call__(ctrl_pts, mid_param, k=1)
2202
+ nx, ny, nz = np.cross(dxi.ravel(), deta.ravel())
2203
+ azim = np.degrees(np.arctan2(ny, nx))
2204
+ elev = np.degrees(np.arcsin(nz / np.sqrt(nx**2 + ny**2 + nz**2)))
2205
+ ax.view_init(elev=elev + 30, azim=azim)
2206
+ elif self.NPa == 3:
2207
+ XI = self.linspace(n_eval_per_elem=n_eval_per_elem)
2208
+ XI_elem = self.linspace(n_eval_per_elem=1)
2209
+ for face in range(3):
2210
+ for side in [-1, 0]:
2211
+ XI_face = list(XI)
2212
+ XI_face[face] = np.array([XI[face][side]])
2213
+ X = np.squeeze(np.array(self.__call__(ctrl_pts, XI_face)))
2214
+ ax.plot_surface(
2215
+ *X,
2216
+ rcount=1,
2217
+ ccount=1,
2218
+ edgecolor=None,
2219
+ color=interior_color,
2220
+ alpha=0.5,
2221
+ )
2222
+ for axis in range(3):
2223
+ ctrl_mesh_axis = np.rollaxis(ctrl_pts, axis + 1, 1).reshape(
2224
+ (3, ctrl_pts.shape[axis + 1], -1)
2225
+ )
2226
+ ax.add_collection(Line3DCollection(ctrl_mesh_axis.transpose(2, 1, 0), colors=ctrl_color, zorder=2)) # type: ignore
2227
+ ax.scatter(*ctrl_pts.reshape((3, -1)), color=ctrl_color, zorder=2)
2228
+ for face in range(3):
2229
+ for side in [-1, 0]:
2230
+ for face_i, transpose in zip(
2231
+ sorted([(face + 1) % 3, (face + 2) % 3]),
2232
+ ((1, 2, 0), (2, 1, 0)),
2233
+ ):
2234
+ XI_elem_border = list(XI)
2235
+ XI_elem_border[face] = np.array([XI[face][side]])
2236
+ XI_elem_border[face_i] = XI_elem[face_i]
2237
+ X_elem_border = np.squeeze(
2238
+ np.array(self.__call__(ctrl_pts, XI_elem_border))
2239
+ )
2240
+ ax.add_collection(Line3DCollection(X_elem_border.transpose(*transpose), colors=elem_color, zorder=1)) # type: ignore
2241
+ ctrl_handle = lines.Line2D(
2242
+ [], [], color=ctrl_color, marker="o", linestyle="-", label=ctrl_mesh
2243
+ )
2244
+ elem_handle = lines.Line2D(
2245
+ [], [], color=elem_color, linestyle="-", label=elems_bord
2246
+ )
2247
+ border_handle = lines.Line2D(
2248
+ [], [], color=border_color, linestyle="-", label=patch_bord
2249
+ )
2250
+ ax.legend(handles=[ctrl_handle, elem_handle, border_handle])
2251
+ else:
2252
+ raise ValueError(f"Can't plot a {self.NPa}D shape in a 3D space.")
2253
+ else:
2254
+ raise ValueError(f"Can't plot in a {NPh}D space.")
2255
+ if show:
2256
+ plt.show()
2257
+ else:
2258
+ return ax
2259
+
2260
+ def plotPV(
2261
+ self,
2262
+ ctrl_pts: np.ndarray[np.floating],
2263
+ n_eval_per_elem: Union[int, Iterable[int]] = 10,
2264
+ pv_plotter: Union["pv.Plotter", None] = None,
2265
+ ctrl_color: str = "#d95f02",
2266
+ interior_color: str = "#666666",
2267
+ elem_color: str = "#7570b3",
2268
+ border_color: str = "#1b9e77",
2269
+ show: bool = True,
2270
+ ) -> Union["pv.Plotter", None]:
2271
+ """
2272
+ Plot the B-spline using PyVista for 3D visualization.
2273
+
2274
+ Creates an interactive 3D visualization of the B-spline geometry showing the control mesh,
2275
+ B-spline surface/curve/volume, element borders, and patch borders. Supports plotting
2276
+ 1D curves, 2D surfaces, and 3D volumes in 3D space.
2277
+
2278
+ Parameters
2279
+ ----------
2280
+ ctrl_pts : np.ndarray[np.floating]
2281
+ Control points defining the B-spline geometry.
2282
+ Shape: (NPh, n1, n2, ...) where:
2283
+ - NPh is the dimension of the physical space (2 or 3)
2284
+ - ni is the number of control points in the i-th isoparametric dimension
2285
+ If NPh=2, control points are automatically converted to 3D for plotting.
2286
+ n_eval_per_elem : Union[int, Iterable[int]], optional
2287
+ Number of evaluation points per element for visualizing the B-spline.
2288
+ Can be specified as:
2289
+ - Single integer: Same number for all dimensions
2290
+ - Iterable of integers: Different numbers for each dimension
2291
+ By default, 10.
2292
+ pv_plotter : Union['pv.Plotter', None], optional
2293
+ PyVista plotter for visualization. If None, creates a new plotter.
2294
+ Default is None.
2295
+ ctrl_color : str, optional
2296
+ Color for the control mesh visualization.
2297
+ Default is '#d95f02' (orange).
2298
+ interior_color : str, optional
2299
+ Color for the B-spline geometry.
2300
+ Default is '#666666' (gray).
2301
+ elem_color : str, optional
2302
+ Color for element boundary visualization.
2303
+ Default is '#7570b3' (purple).
2304
+ border_color : str, optional
2305
+ Color for patch boundary visualization.
2306
+ Default is '#1b9e77' (green).
2307
+ show : bool, optional
2308
+ Whether to display the plot immediately.
2309
+ Default is True.
2310
+
2311
+ Returns
2312
+ -------
2313
+ pv_plotter : Union['pv.Plotter', None]
2314
+ The PyVista plotter object used for visualization if show is False.
2315
+ Otherwise, returns None.
2316
+
2317
+ Notes
2318
+ -----
2319
+ - For 1D B-splines: Plots the curve and control points.
2320
+ - For 2D B-splines: Plots the surface, control points, element borders, and patch borders.
2321
+ - For 3D B-splines: Plots the volume faces, control points, element borders, and patch borders.
2322
+ - If control points are 2D, they are automatically converted to 3D with z=0 for plotting.
2323
+
2324
+ Examples
2325
+ --------
2326
+ Plot a curved line in 3D space:
2327
+ >>> degrees = [2]
2328
+ >>> knots = [np.array([0, 0, 0, 1, 1, 1], dtype='float')]
2329
+ >>> spline = BSpline(degrees, knots)
2330
+ >>> ctrl_pts = np.random.rand(3, 3) # 3D control points
2331
+ >>> spline.plotPV(ctrl_pts)
2332
+
2333
+ Plot a 2D surface in 3D space:
2334
+ >>> degrees = [2, 2]
2335
+ >>> knots = [np.array([0, 0, 0, 1, 1, 1], dtype='float'),
2336
+ ... np.array([0, 0, 0, 1, 1, 1], dtype='float')]
2337
+ >>> spline = BSpline(degrees, knots)
2338
+ >>> ctrl_pts = np.random.rand(3, 3, 3) # 3D control points
2339
+ >>> spline.plotPV(ctrl_pts)
2340
+ """
2341
+ import pyvista as pv
2342
+
2343
+ NPh = ctrl_pts.shape[0]
2344
+ if NPh == 2:
2345
+ ctrl_pts = np.concatenate(
2346
+ (ctrl_pts, np.zeros((1, *ctrl_pts.shape[1:]))), axis=0
2347
+ )
2348
+ print("Control points converted to 3D for plot.")
2349
+ elif NPh != 3:
2350
+ raise ValueError("Can only plot in a 3D space.")
2351
+ if pv_plotter is None:
2352
+ pv_plotter = pv.Plotter()
2353
+ if self.NPa == 1:
2354
+ lines = self.__call__(
2355
+ ctrl_pts, self.linspace(n_eval_per_elem=n_eval_per_elem)
2356
+ ).T
2357
+ pv_plotter.add_lines(lines, connected=True, color=interior_color, width=2)
2358
+ points = self.__call__(ctrl_pts, self.linspace(n_eval_per_elem=1)).T
2359
+ pv_plotter.add_points(points, color=border_color, point_size=10)
2360
+ pv_plotter.add_points(
2361
+ ctrl_pts.T,
2362
+ color=ctrl_color,
2363
+ point_size=8,
2364
+ render_points_as_spheres=True,
2365
+ )
2366
+ elif self.NPa == 2:
2367
+ xi, eta = self.linspace(n_eval_per_elem=n_eval_per_elem)
2368
+ xi_elem, eta_elem = self.linspace(n_eval_per_elem=1)
2369
+ x, y, z = self.__call__(ctrl_pts, [xi, eta])
2370
+ x_xi, y_xi, z_xi = self.__call__(ctrl_pts, [xi_elem, eta])
2371
+ x_eta, y_eta, z_eta = self.__call__(ctrl_pts, [xi, eta_elem])
2372
+ pv_plotter.add_mesh(
2373
+ pv.StructuredGrid(x, y, z), color=interior_color, opacity=0.5
2374
+ )
2375
+ lines_xi = np.repeat(
2376
+ np.stack((x_xi, y_xi, z_xi)).transpose(1, 2, 0), 2, axis=1
2377
+ )[:, 1:-1].reshape((-1, 3))
2378
+ lines_eta = np.repeat(
2379
+ np.stack((x_eta, y_eta, z_eta)).transpose(2, 1, 0), 2, axis=1
2380
+ )[:, 1:-1].reshape((-1, 3))
2381
+ lines = np.concatenate((lines_xi, lines_eta), axis=0)
2382
+ pv_plotter.add_lines(lines, connected=False, color=elem_color, width=2)
2383
+ pv_plotter.add_points(
2384
+ ctrl_pts.reshape((3, -1)).T,
2385
+ color=ctrl_color,
2386
+ point_size=8,
2387
+ render_points_as_spheres=True,
2388
+ )
2389
+ elif self.NPa == 3:
2390
+ xi, eta, zeta = self.linspace(n_eval_per_elem=n_eval_per_elem)
2391
+ xi_elem, eta_elem, zeta_elem = self.linspace(n_eval_per_elem=1)
2392
+ XI_faces = [
2393
+ [[xi, eta, np.array([zeta[0]])], [xi, eta, np.array([zeta[-1]])]],
2394
+ [[xi, np.array([eta[0]]), zeta], [xi, np.array([eta[-1]]), zeta]],
2395
+ [[np.array([xi[0]]), eta, zeta], [np.array([xi[-1]]), eta, zeta]],
2396
+ ]
2397
+ XI_elem_borders = [
2398
+ [
2399
+ [
2400
+ [xi_elem, eta, np.array([zeta[0]])],
2401
+ [xi, eta_elem, np.array([zeta[0]])],
2402
+ ],
2403
+ [
2404
+ [xi_elem, eta, np.array([zeta[-1]])],
2405
+ [xi, eta_elem, np.array([zeta[-1]])],
2406
+ ],
2407
+ ],
2408
+ [
2409
+ [
2410
+ [xi_elem, np.array([eta[0]]), zeta],
2411
+ [xi, np.array([eta[0]]), zeta_elem],
2412
+ ],
2413
+ [
2414
+ [xi_elem, np.array([eta[-1]]), zeta],
2415
+ [xi, np.array([eta[-1]]), zeta_elem],
2416
+ ],
2417
+ ],
2418
+ [
2419
+ [
2420
+ [np.array([xi[0]]), eta_elem, zeta],
2421
+ [np.array([xi[0]]), eta, zeta_elem],
2422
+ ],
2423
+ [
2424
+ [np.array([xi[-1]]), eta_elem, zeta],
2425
+ [np.array([xi[-1]]), eta, zeta_elem],
2426
+ ],
2427
+ ],
2428
+ ]
2429
+ for XI_face_pair, XI_elem_borders_pair in zip(XI_faces, XI_elem_borders):
2430
+ for XI_face, (XI_elem_border_a, XI_elem_border_b) in zip(
2431
+ XI_face_pair, XI_elem_borders_pair
2432
+ ):
2433
+ x, y, z = np.squeeze(np.array(self.__call__(ctrl_pts, XI_face)))
2434
+ x_a, y_a, z_a = np.squeeze(
2435
+ np.array(self.__call__(ctrl_pts, XI_elem_border_a))
2436
+ )
2437
+ x_b, y_b, z_b = np.squeeze(
2438
+ np.array(self.__call__(ctrl_pts, XI_elem_border_b))
2439
+ )
2440
+ pv_plotter.add_mesh(
2441
+ pv.StructuredGrid(x, y, z), color=interior_color, opacity=0.5
2442
+ )
2443
+ lines_xi = np.repeat(
2444
+ np.stack((x_a, y_a, z_a)).transpose(1, 2, 0), 2, axis=1
2445
+ )[:, 1:-1].reshape((-1, 3))
2446
+ lines_eta = np.repeat(
2447
+ np.stack((x_b, y_b, z_b)).transpose(2, 1, 0), 2, axis=1
2448
+ )[:, 1:-1].reshape((-1, 3))
2449
+ lines = np.concatenate((lines_xi, lines_eta), axis=0)
2450
+ pv_plotter.add_lines(
2451
+ lines, connected=False, color=elem_color, width=2
2452
+ )
2453
+ pv_plotter.add_points(
2454
+ ctrl_pts.reshape((3, -1)).T,
2455
+ color=ctrl_color,
2456
+ point_size=8,
2457
+ render_points_as_spheres=True,
2458
+ )
2459
+ else:
2460
+ raise ValueError(f"Can't plot a {self.NPa}D shape in a 3D space.")
2461
+ if show:
2462
+ pv_plotter.show()
2463
+ else:
2464
+ return pv_plotter