InterpolatePy 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.
@@ -0,0 +1,737 @@
1
+ import matplotlib.pyplot as plt
2
+ import numpy as np
3
+
4
+ from mpl_toolkits.mplot3d import Axes3D # noqa: F401 - Imported for type hinting
5
+
6
+
7
+ class BSpline:
8
+ """
9
+ A class for representing and evaluating B-spline curves.
10
+
11
+ Parameters
12
+ ----------
13
+ degree : int
14
+ The degree of the B-spline.
15
+ knots : array_like
16
+ The knot vector.
17
+ control_points : array_like
18
+ The control points defining the B-spline.
19
+
20
+ Attributes
21
+ ----------
22
+ degree : int
23
+ The degree of the B-spline.
24
+ knots : ndarray
25
+ The knot vector.
26
+ control_points : ndarray
27
+ The control points defining the B-spline.
28
+ u_min : float
29
+ Minimum valid parameter value.
30
+ u_max : float
31
+ Maximum valid parameter value.
32
+ dimension : int
33
+ The dimension of the control points (2D, 3D, etc.).
34
+ """
35
+
36
+ # Constants for dimension comparisons
37
+ DIM_2 = 2
38
+ DIM_3 = 3
39
+
40
+ def __init__(
41
+ self, degree: int, knots: list | np.ndarray, control_points: list | np.ndarray
42
+ ) -> None:
43
+ """
44
+ Initialize a B-spline curve.
45
+
46
+ Parameters
47
+ ----------
48
+ degree : int
49
+ The degree of the B-spline (must be positive).
50
+ knots : array_like
51
+ The knot vector (must be non-decreasing).
52
+ control_points : array_like
53
+ The control points defining the B-spline.
54
+ Can be multidimensional (e.g., 2D or 3D points).
55
+
56
+ Raises
57
+ ------
58
+ ValueError
59
+ If the inputs do not satisfy B-spline requirements.
60
+ """
61
+ # Convert inputs to numpy arrays if they're not already
62
+ if not isinstance(knots, np.ndarray):
63
+ knots = np.array(knots, dtype=np.float64)
64
+ else:
65
+ # Ensure knots are float64 for precision
66
+ knots = knots.astype(np.float64)
67
+
68
+ if not isinstance(control_points, np.ndarray):
69
+ control_points = np.array(control_points, dtype=np.float64)
70
+ else:
71
+ # Ensure control points are float64 for precision
72
+ control_points = control_points.astype(np.float64)
73
+
74
+ # Validate inputs
75
+ if degree < 0:
76
+ raise ValueError("Degree must be non-negative")
77
+
78
+ if not np.all(np.diff(knots) >= 0):
79
+ raise ValueError("Knot vector must be non-decreasing")
80
+
81
+ n_control_points = len(control_points)
82
+ n_knots = len(knots)
83
+
84
+ # Check relationship between degree, control points, and knots
85
+ if n_knots != n_control_points + degree + 1:
86
+ raise ValueError(
87
+ f"Invalid knot vector length for the given degree and number of control points. "
88
+ f"Expected {n_control_points + degree + 1}, got {n_knots}. "
89
+ f"The relationship must satisfy: n_knots = n_control_points + degree + 1"
90
+ )
91
+
92
+ self.degree = degree
93
+ self.knots = knots
94
+ self.control_points = control_points
95
+
96
+ # Store min and max parameter values for convenience
97
+ self.u_min = knots[degree]
98
+ self.u_max = knots[-(degree + 1)]
99
+
100
+ # Store the dimension of the control points
101
+ self.dimension = 1 if control_points.ndim == 1 else control_points.shape[1]
102
+
103
+ # Precompute knot spans for common parameter values
104
+ self._cached_spans: dict[float, int] = {}
105
+
106
+ # Tolerance for floating-point comparisons
107
+ self.eps = 1e-10
108
+
109
+ def find_knot_span(self, u: float) -> int:
110
+ """
111
+ Find the knot span index for a given parameter value u.
112
+
113
+ Parameters
114
+ ----------
115
+ u : float
116
+ The parameter value.
117
+
118
+ Returns
119
+ -------
120
+ int
121
+ The index of the knot span containing u.
122
+
123
+ Raises
124
+ ------
125
+ ValueError
126
+ If u is outside the valid parameter range.
127
+ """
128
+ # Check cache first for frequently used values
129
+ if u in self._cached_spans:
130
+ return self._cached_spans[u]
131
+
132
+ # Validate parameter range
133
+ if u < self.u_min - self.eps or u > self.u_max + self.eps:
134
+ raise ValueError(f"Parameter u={u} outside valid range [{self.u_min}, {self.u_max}]")
135
+
136
+ # Clamp parameter to valid range to handle numerical issues
137
+ u = np.clip(u, self.u_min, self.u_max)
138
+
139
+ # Handle endpoint case more efficiently
140
+ if abs(u - self.u_max) <= self.eps:
141
+ # For maximum parameter value, return the last valid span
142
+ span = len(self.knots) - self.degree - 2
143
+ self._cached_spans[u] = span
144
+ return span
145
+
146
+ # More efficient binary search implementation
147
+ n = len(self.control_points) - 1
148
+ low = self.degree
149
+ high = n + 1
150
+
151
+ # Binary search with simplified condition
152
+ while low < high - 1:
153
+ mid = (low + high) // 2
154
+ if u >= self.knots[mid]:
155
+ low = mid
156
+ else:
157
+ high = mid
158
+
159
+ # Cache the result for future use
160
+ self._cached_spans[u] = low
161
+ return low
162
+
163
+ def basis_functions(self, u: float, span_index: int) -> np.ndarray:
164
+ """
165
+ Calculate all non-zero basis functions at parameter value u.
166
+
167
+ Parameters
168
+ ----------
169
+ u : float
170
+ The parameter value.
171
+ span_index : int
172
+ The knot span index containing u.
173
+
174
+ Returns
175
+ -------
176
+ ndarray
177
+ Array of basis function values (p+1 values).
178
+ """
179
+ # Initialize the basis functions array
180
+ n = np.zeros(self.degree + 1, dtype=np.float64)
181
+
182
+ # Initialize left and right arrays
183
+ left = np.zeros(self.degree + 1, dtype=np.float64)
184
+ right = np.zeros(self.degree + 1, dtype=np.float64)
185
+
186
+ # First basis function is always 1
187
+ n[0] = 1.0
188
+
189
+ # For each degree, update the basis functions
190
+ for d in range(1, self.degree + 1):
191
+ left[d] = u - self.knots[span_index + 1 - d]
192
+ right[d] = self.knots[span_index + d] - u
193
+
194
+ saved = 0.0
195
+
196
+ for r in range(d):
197
+ # Avoid division by zero more robustly
198
+ denominator = right[r + 1] + left[d - r]
199
+
200
+ # Use small epsilon for numerical stability - using ternary operator
201
+ temp = 0.0 if abs(denominator) < self.eps else n[r] / denominator
202
+
203
+ # Update the basis function using the recurrence relation
204
+ n[r] = saved + right[r + 1] * temp
205
+ saved = left[d - r] * temp
206
+
207
+ n[d] = saved
208
+
209
+ return n
210
+
211
+ def evaluate(self, u: float) -> np.ndarray:
212
+ """
213
+ Evaluate the B-spline curve at parameter value u.
214
+
215
+ Parameters
216
+ ----------
217
+ u : float
218
+ The parameter value.
219
+
220
+ Returns
221
+ -------
222
+ ndarray
223
+ The point on the B-spline curve at parameter u.
224
+ """
225
+ # Handle edge cases exactly at endpoints to avoid numerical issues
226
+ if abs(u - self.u_min) <= self.eps:
227
+ return self.control_points[0].copy()
228
+ if abs(u - self.u_max) <= self.eps:
229
+ return self.control_points[-1].copy()
230
+
231
+ # Clamp parameter to valid range using numpy's clip function
232
+ u = np.clip(u, self.u_min, self.u_max)
233
+
234
+ # Find the knot span
235
+ span = self.find_knot_span(u)
236
+
237
+ # Calculate the basis functions
238
+ n = self.basis_functions(u, span)
239
+
240
+ # Calculate the point by multiplying basis functions with control points
241
+ # Use advanced indexing for efficiency
242
+ relevant_controls = self.control_points[span - self.degree : span + 1]
243
+
244
+ # Handle 1D control points differently
245
+ if self.dimension == 1:
246
+ point = np.dot(n, relevant_controls)
247
+ else:
248
+ # For multi-dimensional control points
249
+ point = np.zeros(self.dimension, dtype=np.float64)
250
+ for i in range(self.degree + 1):
251
+ point += n[i] * relevant_controls[i]
252
+
253
+ return point
254
+
255
+ def basis_function_derivatives(self, u: float, span_index: int, order: int) -> np.ndarray:
256
+ """
257
+ Calculate derivatives of basis functions up to the specified order.
258
+
259
+ Parameters
260
+ ----------
261
+ u : float
262
+ The parameter value.
263
+ span_index : int
264
+ The knot span index containing u.
265
+ order : int
266
+ The maximum order of derivatives to calculate.
267
+
268
+ Returns
269
+ -------
270
+ ndarray
271
+ 2D array where ders[k][j] is the k-th derivative of the j-th basis function.
272
+ """
273
+ # Ensure order doesn't exceed degree
274
+ order = min(order, self.degree)
275
+
276
+ # Initialize the result array
277
+ ders = np.zeros((order + 1, self.degree + 1), dtype=np.float64)
278
+
279
+ # Initialize temporary arrays
280
+ left = np.zeros(self.degree + 1, dtype=np.float64)
281
+ right = np.zeros(self.degree + 1, dtype=np.float64)
282
+ a = np.zeros((2, self.degree + 1), dtype=np.float64)
283
+
284
+ # Initialize the basis function table (ndu)
285
+ ndu = np.zeros((self.degree + 1, self.degree + 1), dtype=np.float64)
286
+ ndu[0, 0] = 1.0
287
+
288
+ for j in range(1, self.degree + 1):
289
+ left[j] = u - self.knots[span_index + 1 - j]
290
+ right[j] = self.knots[span_index + j] - u
291
+ saved = 0.0
292
+
293
+ for r in range(j):
294
+ # Lower triangle
295
+ ndu[j, r] = right[r + 1] + left[j - r]
296
+
297
+ # Avoid division by zero - using ternary operator
298
+ temp = 0.0 if abs(ndu[j, r]) < self.eps else ndu[r, j - 1] / ndu[j, r]
299
+
300
+ # Upper triangle
301
+ ndu[r, j] = saved + right[r + 1] * temp
302
+ saved = left[j - r] * temp
303
+
304
+ ndu[j, j] = saved
305
+
306
+ # Load the basis functions
307
+ for j in range(self.degree + 1):
308
+ ders[0, j] = ndu[j, self.degree]
309
+
310
+ # Calculate the derivatives
311
+ for r in range(self.degree + 1):
312
+ # Index of current column in the table
313
+ s1 = 0
314
+ s2 = 1
315
+
316
+ # Initialize a array
317
+ a[0, 0] = 1.0
318
+
319
+ # Loop to compute k-th derivative
320
+ for k in range(1, order + 1):
321
+ d = 0.0
322
+ rk = r - k
323
+ pk = self.degree - k
324
+
325
+ if r >= k:
326
+ a[s2, 0] = a[s1, 0] / ndu[pk + 1, rk]
327
+ d = a[s2, 0] * ndu[rk, pk]
328
+
329
+ j1 = 1 if rk >= -1 else -rk
330
+ j2 = k - 1 if r - 1 <= pk else self.degree - r
331
+
332
+ for j in range(j1, j2 + 1):
333
+ a[s2, j] = (a[s1, j] - a[s1, j - 1]) / ndu[pk + 1, rk + j]
334
+ d += a[s2, j] * ndu[rk + j, pk]
335
+
336
+ if r <= pk:
337
+ a[s2, k] = -a[s1, k - 1] / ndu[pk + 1, r]
338
+ d += a[s2, k] * ndu[r, pk]
339
+
340
+ ders[k, r] = d
341
+
342
+ # Switch row indices
343
+ j = s1
344
+ s1 = s2
345
+ s2 = j
346
+
347
+ # Multiply by the correct factors (p!/(p-k)!)
348
+ r = self.degree
349
+ for k in range(1, order + 1):
350
+ for j in range(self.degree + 1):
351
+ ders[k, j] *= r
352
+ r *= self.degree - k
353
+
354
+ return ders
355
+
356
+ def evaluate_derivative(self, u: float, order: int = 1) -> np.ndarray:
357
+ """
358
+ Evaluate the derivative of the B-spline curve at parameter value u.
359
+
360
+ Parameters
361
+ ----------
362
+ u : float
363
+ The parameter value.
364
+ order : int, optional
365
+ The order of the derivative. Default is 1.
366
+
367
+ Returns
368
+ -------
369
+ ndarray
370
+ The derivative of the B-spline curve at parameter u.
371
+
372
+ Raises
373
+ ------
374
+ ValueError
375
+ If the order is greater than the degree of the B-spline.
376
+ """
377
+ if order > self.degree:
378
+ raise ValueError(f"Derivative order {order} exceeds B-spline degree {self.degree}")
379
+
380
+ if order == 0:
381
+ return self.evaluate(u)
382
+
383
+ # Clamp parameter to valid range
384
+ u = np.clip(u, self.u_min + self.eps, self.u_max - self.eps)
385
+
386
+ # Find the knot span
387
+ span = self.find_knot_span(u)
388
+
389
+ # Calculate derivatives of basis functions
390
+ ders = self.basis_function_derivatives(u, span, order)
391
+
392
+ # Calculate the derivative of the curve more efficiently
393
+ relevant_controls = self.control_points[span - self.degree : span + 1]
394
+
395
+ # Handle 1D control points differently
396
+ if self.dimension == 1:
397
+ derivative = np.dot(ders[order], relevant_controls)
398
+ else:
399
+ derivative = np.zeros(self.dimension, dtype=np.float64)
400
+ for j in range(self.degree + 1):
401
+ derivative += ders[order, j] * relevant_controls[j]
402
+
403
+ return derivative
404
+
405
+ def generate_curve_points(self, num_points: int = 100) -> tuple[np.ndarray, np.ndarray]:
406
+ """
407
+ Generate points along the B-spline curve for visualization.
408
+
409
+ Parameters
410
+ ----------
411
+ num_points : int, optional
412
+ Number of points to generate. Default is 100.
413
+
414
+ Returns
415
+ -------
416
+ u_values : ndarray
417
+ Parameter values.
418
+ curve_points : ndarray
419
+ Corresponding points on the curve.
420
+ """
421
+ # Generate parameter values evenly spaced in the valid range
422
+ u_values = np.linspace(self.u_min, self.u_max, num_points)
423
+
424
+ # Vectorize for better performance if curve is 1D or 2D
425
+ if self.dimension <= self.DIM_2:
426
+ # Pre-allocate array for curve points
427
+ curve_points = np.zeros((num_points, self.dimension), dtype=np.float64)
428
+
429
+ # Evaluate the curve at each parameter value
430
+ for i, u in enumerate(u_values):
431
+ curve_points[i] = self.evaluate(u)
432
+ else:
433
+ # For higher dimensions, use list comprehension
434
+ curve_points = np.array([self.evaluate(u) for u in u_values])
435
+
436
+ return u_values, curve_points
437
+
438
+ def plot_2d(
439
+ self,
440
+ num_points: int = 100,
441
+ show_control_polygon: bool = True,
442
+ show_knots: bool = False,
443
+ ax: plt.Axes | None = None,
444
+ ) -> plt.Axes:
445
+ """
446
+ Plot a 2D B-spline curve with customizable styling.
447
+
448
+ Parameters
449
+ ----------
450
+ num_points : int, optional
451
+ Number of points to generate for the curve. Default is 100.
452
+ show_control_polygon : bool, optional
453
+ Whether to show the control polygon. Default is True.
454
+ show_knots : bool, optional
455
+ Whether to show the knot points on the curve. Default is False.
456
+ ax : matplotlib.axes.Axes, optional
457
+ Matplotlib axis to use for plotting. If None, a new figure is created.
458
+ curve_style : dict, optional
459
+ Style parameters for the curve.
460
+ control_style : dict, optional
461
+ Style parameters for the control polygon.
462
+ knot_style : dict, optional
463
+ Style parameters for the knot points.
464
+
465
+ Returns
466
+ -------
467
+ matplotlib.axes.Axes
468
+ The matplotlib axis object.
469
+
470
+ Raises
471
+ ------
472
+ ValueError
473
+ If the dimension of control points is not 2.
474
+ """
475
+ if self.dimension != self.DIM_2:
476
+ raise ValueError(
477
+ f"Control points must be 2D for this plot function, got {self.dimension}D"
478
+ )
479
+
480
+ # Default styles
481
+ default_curve_style = {"color": "blue", "linewidth": 2, "label": "B-spline curve"}
482
+ default_control_style = {
483
+ "color": "red",
484
+ "linestyle": "--",
485
+ "marker": "o",
486
+ "linewidth": 1,
487
+ "markersize": 8,
488
+ "label": "Control polygon",
489
+ }
490
+ default_knot_style = {
491
+ "color": "green",
492
+ "marker": "x",
493
+ "markersize": 10,
494
+ "label": "Knot points",
495
+ }
496
+
497
+ # Create a new figure if needed
498
+ if ax is None:
499
+ _, ax = plt.subplots(figsize=(10, 6))
500
+
501
+ # Generate points along the curve
502
+ _, curve_points = self.generate_curve_points(num_points)
503
+
504
+ # Plot the curve
505
+ ax.plot(curve_points[:, 0], curve_points[:, 1], **default_curve_style)
506
+
507
+ # Plot the control points and polygon if requested
508
+ if show_control_polygon:
509
+ ax.plot(self.control_points[:, 0], self.control_points[:, 1], **default_control_style)
510
+
511
+ # Plot the knot points if requested
512
+ if show_knots:
513
+ # Only plot the knots within the valid parameter range
514
+ valid_knots = [k for k in self.knots if self.u_min <= k <= self.u_max]
515
+ unique_knots = np.unique(valid_knots)
516
+
517
+ knot_points = np.array([self.evaluate(k) for k in unique_knots])
518
+ ax.plot(knot_points[:, 0], knot_points[:, 1], **default_knot_style)
519
+
520
+ # Set labels and title
521
+ ax.set_xlabel("X")
522
+ ax.set_ylabel("Y")
523
+ ax.set_title(f"B-spline curve of degree {self.degree}")
524
+ ax.legend()
525
+ ax.grid(True)
526
+
527
+ return ax
528
+
529
+ def plot_3d(
530
+ self,
531
+ num_points: int = 100,
532
+ show_control_polygon: bool = True,
533
+ ax: plt.Axes | None = None,
534
+ ) -> plt.Axes:
535
+ """
536
+ Plot a 3D B-spline curve.
537
+
538
+ Parameters
539
+ ----------
540
+ num_points : int, optional
541
+ Number of points to generate for the curve. Default is 100.
542
+ show_control_polygon : bool, optional
543
+ Whether to show the control polygon. Default is True.
544
+ ax : matplotlib.axes.Axes, optional
545
+ Matplotlib 3D axis to use for plotting. If None, a new figure is created.
546
+ curve_style : dict, optional
547
+ Style parameters for the curve.
548
+ control_style : dict, optional
549
+ Style parameters for the control polygon.
550
+
551
+ Returns
552
+ -------
553
+ matplotlib.axes.Axes
554
+ The matplotlib 3D axis object.
555
+
556
+ Raises
557
+ ------
558
+ ValueError
559
+ If the dimension of control points is not 3.
560
+ """
561
+ if self.dimension != self.DIM_3:
562
+ raise ValueError(
563
+ f"Control points must be 3D for this plot function, got {self.dimension}D"
564
+ )
565
+
566
+ # Default styles
567
+ default_curve_style = {"color": "blue", "linewidth": 2, "label": "B-spline curve"}
568
+ default_control_style = {
569
+ "color": "red",
570
+ "linestyle": "--",
571
+ "marker": "o",
572
+ "linewidth": 1,
573
+ "markersize": 8,
574
+ "label": "Control polygon",
575
+ }
576
+
577
+ # Create a new figure if needed
578
+ if ax is None:
579
+ fig = plt.figure(figsize=(10, 8))
580
+ ax = fig.add_subplot(111, projection="3d")
581
+
582
+ # Generate points along the curve
583
+ _, curve_points = self.generate_curve_points(num_points)
584
+
585
+ # Plot the curve
586
+ ax.plot(curve_points[:, 0], curve_points[:, 1], curve_points[:, 2], **default_curve_style)
587
+
588
+ # Plot the control points and polygon if requested
589
+ if show_control_polygon:
590
+ ax.plot(
591
+ self.control_points[:, 0],
592
+ self.control_points[:, 1],
593
+ self.control_points[:, 2],
594
+ **default_control_style,
595
+ )
596
+
597
+ # Set labels and title
598
+ ax.set_xlabel("X")
599
+ ax.set_ylabel("Y")
600
+ ax.set_zlabel("Z")
601
+ ax.set_title(f"3D B-spline curve of degree {self.degree}")
602
+ ax.legend()
603
+
604
+ return ax
605
+
606
+ @staticmethod
607
+ def create_uniform_knots(
608
+ degree: int, num_control_points: int, domain_min: float = 0.0, domain_max: float = 1.0
609
+ ) -> np.ndarray:
610
+ """
611
+ Create a uniform knot vector for a B-spline with appropriate
612
+ multiplicity at endpoints to ensure interpolation.
613
+
614
+ Parameters
615
+ ----------
616
+ degree : int
617
+ The degree of the B-spline.
618
+ num_control_points : int
619
+ The number of control points.
620
+ domain_min : float, optional
621
+ Minimum value of the parameter domain. Default is 0.0.
622
+ domain_max : float, optional
623
+ Maximum value of the parameter domain. Default is 1.0.
624
+
625
+ Returns
626
+ -------
627
+ ndarray
628
+ The uniform knot vector with knots in the specified domain.
629
+
630
+ Raises
631
+ ------
632
+ ValueError
633
+ If degree is negative or num_control_points is less than or equal to degree.
634
+ """
635
+ # Input validation
636
+ if degree < 0:
637
+ raise ValueError("Degree must be non-negative")
638
+ if num_control_points <= degree:
639
+ raise ValueError("Number of control points must be greater than the degree")
640
+
641
+ # Calculate total number of knots required by the formula: n = m + p + 1
642
+ # where n is number of knots, m is number of control points, p is degree
643
+ num_knots = num_control_points + degree + 1
644
+
645
+ # Initialize the knot vector with domain_min values (for the first p+1 knots)
646
+ knots = np.zeros(num_knots, dtype=np.float64)
647
+
648
+ # Calculate the number of internal knots needed
649
+ n_internal = num_knots - 2 * (degree + 1)
650
+
651
+ # Set the first degree+1 knots to domain_min
652
+ knots[: degree + 1] = domain_min
653
+
654
+ # Create internal knots uniformly distributed (if any)
655
+ if n_internal >= 0:
656
+ # Generate values between domain_min and domain_max, excluding endpoints
657
+ internal_values = np.linspace(domain_min, domain_max, n_internal + 2)[1:-1]
658
+ knots[degree + 1 : degree + 1 + n_internal] = internal_values
659
+
660
+ # Set end knots to domain_max with multiplicity p+1
661
+ knots[-(degree + 1) :] = domain_max
662
+
663
+ return knots
664
+
665
+ @staticmethod
666
+ def create_periodic_knots(
667
+ degree: int, num_control_points: int, domain_min: float = 0.0, domain_max: float = 1.0
668
+ ) -> np.ndarray:
669
+ """
670
+ Create a periodic (uniform) knot vector for a B-spline.
671
+
672
+ Parameters
673
+ ----------
674
+ degree : int
675
+ The degree of the B-spline.
676
+ num_control_points : int
677
+ The number of control points.
678
+ domain_min : float, optional
679
+ Minimum value of the parameter domain. Default is 0.0.
680
+ domain_max : float, optional
681
+ Maximum value of the parameter domain. Default is 1.0.
682
+
683
+ Returns
684
+ -------
685
+ ndarray
686
+ The periodic knot vector.
687
+
688
+ Raises
689
+ ------
690
+ ValueError
691
+ If degree is negative or num_control_points is less than degree+1.
692
+ """
693
+ # Input validation
694
+ if degree < 0:
695
+ raise ValueError("Degree must be non-negative")
696
+ if num_control_points < degree + 1:
697
+ raise ValueError(
698
+ "For a periodic B-spline, number of control points must be at least degree+1"
699
+ )
700
+
701
+ # Calculate total number of knots required
702
+ num_knots = num_control_points + degree + 1
703
+
704
+ # Create uniformly spaced knots
705
+ knots = np.linspace(domain_min, domain_max, num_knots - 2 * degree)
706
+
707
+ # Extend the knot vector for periodicity
708
+ extended_knots = np.zeros(num_knots, dtype=np.float64)
709
+
710
+ # Add degree knots at the beginning (below domain_min)
711
+ step = (domain_max - domain_min) / (num_knots - 2 * degree - 1)
712
+ for i in range(degree):
713
+ extended_knots[i] = domain_min - (degree - i) * step
714
+
715
+ # Add the regular knots
716
+ extended_knots[degree : degree + len(knots)] = knots
717
+
718
+ # Add degree knots at the end (above domain_max)
719
+ for i in range(degree):
720
+ extended_knots[degree + len(knots) + i] = domain_max + (i + 1) * step
721
+
722
+ return extended_knots
723
+
724
+ def __repr__(self) -> str:
725
+ """
726
+ Return a string representation of the B-spline.
727
+
728
+ Returns
729
+ -------
730
+ str
731
+ String representation.
732
+ """
733
+ return (
734
+ f"BSpline(degree={self.degree}, "
735
+ f"control_points={len(self.control_points)}, "
736
+ f"dimension={self.dimension})"
737
+ )