bspy 4.1__py3-none-any.whl → 4.2__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.
@@ -4,6 +4,7 @@ import numpy as np
4
4
  from bspy.manifold import Manifold
5
5
  from bspy.hyperplane import Hyperplane
6
6
  import bspy.spline
7
+ import bspy.spline_block
7
8
  from bspy.solid import Solid, Boundary
8
9
  from collections import namedtuple
9
10
  from multiprocessing import Pool
@@ -75,14 +76,22 @@ def zeros_using_interval_newton(self):
75
76
  if derivativeBounds[0] * derivativeBounds[1] >= 0.0: # Refine interval
76
77
  projectedLeftStep = max(0.0, adjustedLeftStep)
77
78
  projectedRightStep = min(1.0, adjustedRightStep)
79
+ provisionalZero = [0.5 * (projectedLeftStep + projectedRightStep)]
78
80
  if projectedLeftStep <= projectedRightStep:
79
81
  if projectedRightStep - projectedLeftStep <= epsilon:
80
- myZeros = [0.5 * (projectedLeftStep + projectedRightStep)]
82
+ myZeros = provisionalZero
81
83
  else:
82
84
  trimmedSpline = mySpline.trim(((projectedLeftStep, projectedRightStep),))
83
85
  myZeros = refine(trimmedSpline, intervalSize, functionMax)
86
+ if len(myZeros) == 0 and mySpline.order[0] == mySpline.nCoef[0] and \
87
+ mySpline.coefs[0][0] * mySpline.coefs[0][-1] < 0.0:
88
+ myZeros = provisionalZero
84
89
  else:
85
- return []
90
+ if mySpline.order[0] == mySpline.nCoef[0] and \
91
+ mySpline.coefs[0][0] * mySpline.coefs[0][-1] < 0.0:
92
+ myZeros = provisionalZero
93
+ else:
94
+ return []
86
95
  else: # . . . or split as needed
87
96
  myZeros = []
88
97
  if adjustedLeftStep > 0.0:
@@ -117,18 +126,19 @@ def zeros_using_interval_newton(self):
117
126
  return mySolution
118
127
  return refine(spline, 1.0, 1.0)
119
128
 
120
- def _convex_hull_2D(xData, yData, yBounds, epsilon = 1.0e-8):
129
+ def _convex_hull_2D(xData, yData, yBounds, yOtherBounds, epsilon = 1.0e-8):
121
130
  # Allow xData to be repeated for longer yData, but only if yData is a multiple.
122
131
  if not(yData.shape[0] % xData.shape[0] == 0): raise ValueError("Size of xData does not divide evenly in size of yData")
123
132
 
124
133
  # Assign (x0, y0) to the lowest point.
125
134
  yMinIndex = np.argmin(yData)
126
135
  x0 = xData[yMinIndex % xData.shape[0]]
127
- y0 = yData[yMinIndex]
136
+ y0 = yOtherBounds[0] + yData[yMinIndex]
128
137
 
129
138
  # Calculate y adjustment as needed for values close to zero
130
139
  yAdjustment = -yBounds[0] if yBounds[0] > 0.0 else -yBounds[1] if yBounds[1] < 0.0 else 0.0
131
140
  y0 += yAdjustment
141
+ additionalPoint = yOtherBounds[1] > yOtherBounds[0] + epsilon
132
142
 
133
143
  # Sort points by angle around p0.
134
144
  sortedPoints = []
@@ -139,7 +149,9 @@ def _convex_hull_2D(xData, yData, yBounds, epsilon = 1.0e-8):
139
149
  if x is None:
140
150
  xIter = iter(xData)
141
151
  x = next(xIter)
142
- sortedPoints.append((math.atan2(y - y0, x - x0), x, y))
152
+ sortedPoints.append((math.atan2(yOtherBounds[0] + y - y0, x - x0), x, yOtherBounds[0] + y))
153
+ if additionalPoint:
154
+ sortedPoints.append((math.atan2(yOtherBounds[1] + y - y0, x - x0), x, yOtherBounds[1] + y))
143
155
  sortedPoints.sort()
144
156
 
145
157
  # Trim away points with the same angle (keep furthest point from p0), removing the angle from the list.
@@ -195,74 +207,113 @@ def _intersect_convex_hull_with_x_interval(hullPoints, epsilon, xInterval):
195
207
  else:
196
208
  return (min(max(xMin, xInterval[0]), xInterval[1]), max(min(xMax, xInterval[1]), xInterval[0]))
197
209
 
198
- Interval = namedtuple('Interval', ('spline', 'unknowns', 'scale', 'slope', 'intercept', 'epsilon', 'atMachineEpsilon'))
210
+ Interval = namedtuple('Interval', ('block', 'unknowns', 'scale', 'bounds', 'slope', 'intercept', 'epsilon', 'atMachineEpsilon'))
211
+
212
+ def _create_interval(domain, block, unknowns, scale, slope, intercept, epsilon):
213
+ nDep = 0
214
+ bounds = np.zeros((len(scale), 2), scale.dtype)
215
+ newScale = np.empty_like(scale)
216
+ newBlock = []
217
+ for row in block:
218
+ newRow = []
219
+ nInd = 0
220
+ keepDep = []
221
+ # Trim and reparametrize splines, and sum bounds.
222
+ for spline in row:
223
+ spline = spline.trim(domain[nInd:nInd + spline.nInd]).reparametrize(((0.0, 1.0),) * spline.nInd)
224
+ bounds[nDep:nDep + spline.nDep] += spline.range_bounds()
225
+ nInd += spline.nInd
226
+ newRow.append(spline)
227
+
228
+ # Check row bounds for potential roots.
229
+ for dep in range(spline.nDep):
230
+ coefsMin = bounds[nDep, 0] * scale[nDep]
231
+ coefsMax = bounds[nDep, 1] * scale[nDep]
232
+ if coefsMax < -epsilon or coefsMin > epsilon:
233
+ # No roots in this interval.
234
+ return None
235
+ if coefsMin < -epsilon or coefsMax > epsilon:
236
+ # Dependent variable not near zero for entire interval.
237
+ keepDep.append(dep)
238
+ newScale[nDep] = max(-coefsMin, coefsMax)
239
+ # Rescale spline coefficients to max 1.0.
240
+ rescale = 1.0 / max(-bounds[nDep, 0], bounds[nDep, 1])
241
+ for spline in newRow:
242
+ spline.coefs[dep] *= rescale
243
+ bounds[nDep] *= rescale
244
+ nDep += 1
245
+ else:
246
+ # Dependent variable near zero for entire interval.
247
+ bounds = np.delete(bounds, nDep, 0)
248
+ scale = np.delete(scale, nDep, 0)
249
+
250
+ if keepDep:
251
+ # Remove dependent variables that are zero over the domain
252
+ for spline in newRow:
253
+ spline.nDep = len(keepDep)
254
+ spline.coefs = spline.coefs[keepDep]
255
+
256
+ newBlock.append(newRow)
257
+
258
+ return Interval(newBlock, unknowns, newScale[:nDep], bounds, slope, intercept, epsilon, np.dot(slope, slope) < np.finfo(slope.dtype).eps)
199
259
 
200
260
  # We use multiprocessing.Pool to call this function in parallel, so it cannot be nested and must take a single argument.
201
261
  def _refine_projected_polyhedron(interval):
202
262
  Crit = 0.85 # Required percentage decrease in domain per iteration.
203
263
  epsilon = interval.epsilon
204
- evaluationEpsilon = np.sqrt(epsilon)
205
- machineEpsilon = np.finfo(interval.spline.coefs.dtype).eps
206
264
  roots = []
207
265
  intervals = []
208
-
209
- # Remove dependent variables that are near zero and compute newScale.
210
- spline = interval.spline.copy()
211
- bounds = spline.range_bounds()
212
- keepDep = []
213
- for nDep, (coefsMin, coefsMax) in enumerate(bounds * interval.scale):
214
- if coefsMax < -epsilon or coefsMin > epsilon:
215
- # No roots in this interval.
216
- return roots, intervals
217
- if coefsMin < -epsilon or coefsMax > epsilon:
218
- # Dependent variable not near zero for entire interval.
219
- keepDep.append(nDep)
220
-
221
- spline.nDep = len(keepDep)
222
- if spline.nDep == 0:
223
- # Return the interval center and radius.
224
- roots.append((interval.intercept + 0.5 * interval.slope, 0.5 * np.linalg.norm(interval.slope)))
225
- return roots, intervals
226
-
227
- # Rescale remaining spline coefficients to max 1.0.
228
- bounds = bounds[keepDep]
229
- newScale = np.abs(bounds).max()
230
- spline.coefs = spline.coefs[keepDep]
231
- spline.coefs *= 1.0 / newScale
232
- bounds *= 1.0 / newScale
233
- newScale *= interval.scale
234
266
 
235
267
  # Loop through each independent variable to determine a tighter domain around roots.
268
+ # The interval block's remaining number of independent variables (nInd) is len(interval.unknowns).
269
+ # The interval block's remaining number of dependent variables (nDep) is len(interval.scale).
236
270
  domain = []
237
- coefs = spline.coefs
238
- for nInd, order, knots, nCoef, s in zip(range(spline.nInd), spline.order, spline.knots, spline.nCoef, interval.slope):
239
- # Move independent variable to the last (fastest) axis, adding 1 to account for the dependent variables.
240
- coefs = np.moveaxis(spline.coefs, nInd + 1, -1)
241
-
242
- # Compute the coefficients for f(x) = x for the independent variable and its knots.
243
- degree = order - 1
244
- xData = np.empty((nCoef,), knots.dtype)
245
- xData[0] = knots[1]
246
- for i in range(1, nCoef):
247
- xData[i] = xData[i - 1] + (knots[i + degree] - knots[i])/degree
248
-
271
+ for nInd in range(len(interval.unknowns)):
249
272
  # Loop through each dependent variable to compute the interval containing the root for this independent variable.
250
273
  xInterval = (0.0, 1.0)
251
- for yData, yBounds in zip(coefs, bounds):
252
- # Compute the 2D convex hull of the knot coefficients and the spline's coefficients
253
- hull = _convex_hull_2D(xData, yData.ravel(), yBounds, epsilon)
254
- if hull is None:
255
- return roots, intervals
274
+ nDep = 0
275
+ for row in interval.block:
276
+ rowInd = 0
277
+ order = 0
278
+ for spline in row:
279
+ if rowInd <= nInd < rowInd + spline.nInd:
280
+ order = spline.order[nInd - rowInd]
281
+ nCoef = spline.nCoef[nInd - rowInd]
282
+ knots = spline.knots[nInd - rowInd]
283
+ # Move independent variable to the last (fastest) axis, adding 1 to account for the dependent variables.
284
+ coefs = np.moveaxis(spline.coefs, nInd - rowInd + 1, -1)
285
+ break
286
+ rowInd += spline.nInd
287
+
288
+ # Skip this row if it doesn't contains this independent variable.
289
+ if order < 1:
290
+ continue
291
+
292
+ # Compute the coefficients for f(x) = x for the independent variable and its knots.
293
+ degree = order - 1
294
+ xData = np.empty((nCoef,), knots.dtype)
295
+ xData[0] = knots[1]
296
+ for i in range(1, nCoef):
297
+ xData[i] = xData[i - 1] + (knots[i + degree] - knots[i])/degree
298
+
299
+ # Loop through each dependent variable in this row to refine the interval containing the root for this independent variable.
300
+ for yData, ySplineBounds, yBounds in zip(coefs, spline.range_bounds(), interval.bounds[nDep:nDep + spline.nDep]):
301
+ # Compute the 2D convex hull of the knot coefficients and the spline's coefficients
302
+ hull = _convex_hull_2D(xData, yData.ravel(), yBounds, yBounds - ySplineBounds, epsilon)
303
+ if hull is None:
304
+ return roots, intervals
305
+
306
+ # Intersect the convex hull with the xInterval along the x axis (the knot coefficients axis).
307
+ xInterval = _intersect_convex_hull_with_x_interval(hull, epsilon, xInterval)
308
+ if xInterval is None:
309
+ return roots, intervals
256
310
 
257
- # Intersect the convex hull with the xInterval along the x axis (the knot coefficients axis).
258
- xInterval = _intersect_convex_hull_with_x_interval(hull, epsilon, xInterval)
259
- if xInterval is None:
260
- return roots, intervals
311
+ nDep += spline.nDep
261
312
 
262
313
  domain.append(xInterval)
263
314
 
264
315
  # Compute new slope, intercept, and unknowns.
265
- domain = np.array(domain, spline.knots[0].dtype).T
316
+ domain = np.array(domain, interval.slope.dtype).T
266
317
  width = domain[1] - domain[0]
267
318
  newSlope = interval.slope.copy()
268
319
  newIntercept = interval.intercept.copy()
@@ -282,17 +333,23 @@ def _refine_projected_polyhedron(interval):
282
333
  nInd += 1
283
334
 
284
335
  # Iteration is complete if the interval actual width (slope) is either
285
- # one iteration past being less than sqrt(machineEpsilon) or there are no remaining unknowns.
286
- if interval.atMachineEpsilon or len(newUnknowns) == 0:
336
+ # one iteration past being less than sqrt(machineEpsilon) or there are no remaining independent variables.
337
+ if interval.atMachineEpsilon or nInd == 0:
287
338
  # Return the interval center and radius.
288
339
  roots.append((newIntercept + 0.5 * newSlope, epsilon))
289
340
  return roots, intervals
290
341
 
291
- # Contract spline as needed.
292
- spline = spline.contract(uvw)
293
-
294
- # Use interval newton for one-dimensional splines.
295
- if spline.nInd == 1 and spline.nDep == 1:
342
+ # Contract spline matrix as needed.
343
+ if newDomain.shape[1] < domain.shape[1]:
344
+ for row in interval.block:
345
+ rowInd = 0
346
+ for i, spline in enumerate(row):
347
+ row[i] = spline.contract(uvw[rowInd:rowInd + spline.nInd])
348
+ rowInd += spline.nInd
349
+
350
+ # Special case optimization: Use interval newton for one-dimensional splines.
351
+ if nInd == 1 and nDep == 1 and len(interval.block[0]) == 1:
352
+ spline = interval.block[0][0]
296
353
  i = newUnknowns[0]
297
354
  for root in zeros_using_interval_newton(spline):
298
355
  if not isinstance(root, tuple):
@@ -310,7 +367,7 @@ def _refine_projected_polyhedron(interval):
310
367
  # Split domain in dimensions that aren't decreasing in width sufficiently.
311
368
  width = newDomain[1] - newDomain[0]
312
369
  domains = [newDomain]
313
- for nInd, w in zip(range(spline.nInd), width):
370
+ for nInd, w in enumerate(width):
314
371
  if w > Crit:
315
372
  # Didn't get the required decrease in width, so split the domain.
316
373
  domainCount = len(domains) # Cache the domain list size, since we're increasing it mid loop
@@ -330,7 +387,12 @@ def _refine_projected_polyhedron(interval):
330
387
  for i, w, d in zip(newUnknowns, width, domain.T):
331
388
  splitSlope[i] = w * interval.slope[i]
332
389
  splitIntercept[i] = d[0] * interval.slope[i] + interval.intercept[i]
333
- intervals.append(Interval(spline.trim(domain.T).reparametrize(((0.0, 1.0),) * spline.nInd), newUnknowns, newScale, splitSlope, splitIntercept, epsilon, np.dot(splitSlope, splitSlope) < machineEpsilon))
390
+ newInterval = _create_interval(domain.T, interval.block, newUnknowns, interval.scale, splitSlope, splitIntercept, epsilon)
391
+ if newInterval:
392
+ if newInterval.block:
393
+ intervals.append(newInterval)
394
+ else:
395
+ roots.append((newInterval.intercept + 0.5 * newInterval.slope, 0.5 * np.linalg.norm(newInterval.slope)))
334
396
 
335
397
  return roots, intervals
336
398
 
@@ -340,18 +402,27 @@ class _Region:
340
402
  self.radius = radius
341
403
  self.count = count
342
404
 
343
- def zeros_using_projected_polyhedron(self, epsilon=None):
344
- if not(self.nInd == self.nDep): raise ValueError("The number of independent variables (nInd) must match the number of dependent variables (nDep).")
345
- machineEpsilon = np.finfo(self.knots[0].dtype).eps
405
+ def zeros_using_projected_polyhedron(self, epsilon=None, initialScale=None):
406
+ if self.nInd != self.nDep: raise ValueError("The number of independent variables (nInd) must match the number of dependent variables (nDep).")
407
+
408
+ # Determine epsilon and initialize roots.
409
+ machineEpsilon = np.finfo(self.knotsDtype).eps
346
410
  if epsilon is None:
347
411
  epsilon = 0.0
348
- epsilon = max(epsilon, np.sqrt(machineEpsilon))
349
- evaluationEpsilon = np.sqrt(epsilon)
412
+ epsilon = max(epsilon, np.sqrt(machineEpsilon)) if epsilon else np.sqrt(machineEpsilon)
413
+ evaluationEpsilon = max(np.sqrt(epsilon), np.finfo(self.coefsDtype).eps ** 0.25)
414
+ intervals = []
350
415
  roots = []
351
416
 
352
- # Set initial spline, domain, and interval.
417
+ # Set initial interval.
353
418
  domain = self.domain().T
354
- intervals = [Interval(self.trim(domain.T).reparametrize(((0.0, 1.0),) * self.nInd), [*range(self.nInd)], 1.0, domain[1] - domain[0], domain[0], epsilon, False)]
419
+ initialScale = np.full(self.nDep, 1.0, self.coefsDtype) if initialScale is None else np.array(initialScale, self.coefsDtype)
420
+ newInterval = _create_interval(domain.T, self.block, [*range(self.nInd)], initialScale, domain[1] - domain[0], domain[0], epsilon)
421
+ if newInterval:
422
+ if newInterval.block:
423
+ intervals.append(newInterval)
424
+ else:
425
+ roots.append((newInterval.intercept + 0.5 * newInterval.slope, 0.5 * np.linalg.norm(newInterval.slope)))
355
426
  chunkSize = 8
356
427
  #pool = Pool() # Pool size matches CPU count
357
428
 
@@ -376,7 +447,8 @@ def zeros_using_projected_polyhedron(self, epsilon=None):
376
447
  rootRadius = root[1]
377
448
 
378
449
  # Ensure we have a real root (not a boundary special case).
379
- if np.linalg.norm(self(rootCenter)) >= evaluationEpsilon:
450
+ value = self.evaluate(rootCenter)
451
+ if np.linalg.norm(value) >= evaluationEpsilon:
380
452
  continue
381
453
 
382
454
  # Expand the radius of the root based on the approximate distance from the center needed
@@ -418,48 +490,47 @@ def zeros_using_projected_polyhedron(self, epsilon=None):
418
490
 
419
491
  return roots
420
492
 
421
- def contours(self):
422
- if self.nInd - self.nDep != 1: raise ValueError("The number of free variables (self.nInd - self.nDep) must be one.")
423
-
493
+ def _contours_of_C1_spline_block(self, epsilon, evaluationEpsilon):
424
494
  Point = namedtuple('Point', ('d', 'det', 'onUVBoundary', 'turningPoint', 'uvw'))
425
- epsilon = np.sqrt(np.finfo(self.coefs.dtype).eps)
426
- evaluationEpsilon = np.sqrt(epsilon)
427
-
428
- # Go through each nDep of the spline, checking bounds.
429
- for coefs in self.coefs:
430
- coefsMin = coefs.min()
431
- coefsMax = coefs.max()
432
- if coefsMax < -evaluationEpsilon or coefsMin > evaluationEpsilon:
495
+
496
+ # Go through each nDep of the spline block, checking bounds.
497
+ bounds = self.range_bounds()
498
+ for bound in bounds:
499
+ if bound[1] < -evaluationEpsilon or bound[0] > evaluationEpsilon:
433
500
  # No contours for this spline.
434
501
  return []
435
502
 
436
503
  # Record self's original domain and then reparametrize self's domain to [0, 1]^nInd.
437
504
  domain = self.domain().T
438
505
  self = self.reparametrize(((0.0, 1.0),) * self.nInd)
506
+
507
+ # Rescale self in all dimensions.
508
+ nDep = 0
509
+ initialScale = np.max(np.abs(bounds), axis=1)
510
+ rescale = np.reciprocal(initialScale)
511
+ for row in self.block:
512
+ nInd = 0
513
+ for spline in row:
514
+ for coefs, scale in zip(spline.coefs, rescale):
515
+ coefs *= scale
516
+ nDep += spline.nDep
439
517
 
440
- # Construct self's tangents and normal.
441
- tangents = []
442
- for nInd in range(self.nInd):
443
- tangents.append(self.differentiate(nInd))
518
+ # Construct self's normal.
444
519
  normal = self.normal_spline((0, 1)) # We only need the first two indices
445
520
 
446
- theta = np.sqrt(2) # Arbitrary starting value for theta (picked one in [0, pi/2] unlikely to be a stationary point)
447
- # Try different theta values until no border or turning points are degenerate or we run out of attempts.
448
- attempts = 3
449
- while attempts > 0:
521
+ # Try arbitrary values for theta between [0, pi/2] that are unlikely to be a stationary points.
522
+ for theta in (1.0 / np.sqrt(2), np.pi / 6.0, 1.0/ np.e):
450
523
  points = []
451
- theta *= 0.607
452
524
  cosTheta = np.cos(theta)
453
525
  sinTheta = np.sin(theta)
454
526
  abort = False
455
- attempts -=1
456
527
 
457
528
  # Construct the turning point determinant.
458
529
  turningPointDeterminant = normal.dot((cosTheta, sinTheta))
459
530
 
460
531
  # Find intersections with u and v boundaries.
461
532
  def uvIntersections(nInd, boundary):
462
- zeros = self.contract([None] * nInd + [boundary] + [None] * (self.nInd - nInd - 1)).zeros()
533
+ zeros = self.contract([None] * nInd + [boundary] + [None] * (self.nInd - nInd - 1)).zeros(epsilon, initialScale)
463
534
  abort = False
464
535
  for zero in zeros:
465
536
  if isinstance(zero, tuple):
@@ -467,10 +538,24 @@ def contours(self):
467
538
  break
468
539
  uvw = np.insert(np.array(zero), nInd, boundary)
469
540
  d = uvw[0] * cosTheta + uvw[1] * sinTheta
470
- det = (0.5 - boundary) * normal(uvw)[nInd] * turningPointDeterminant(uvw)
541
+ n = normal(uvw)
542
+ tpd = turningPointDeterminant(uvw)
543
+ det = (0.5 - boundary) * n[nInd] * tpd
471
544
  if abs(det) < epsilon:
472
545
  abort = True
473
546
  break
547
+ # Check for literal corner case.
548
+ otherInd = 1 - nInd
549
+ otherValue = uvw[otherInd]
550
+ if otherValue < epsilon or otherValue + epsilon > 1.0:
551
+ otherDet = (0.5 - otherValue) * n[otherInd] * tpd
552
+ if det * otherDet < 0.0:
553
+ continue # Corner that starts and ends, ignore it
554
+ elif max(otherValue, boundary) < epsilon and det < 0.0:
555
+ continue # End point at (0, 0), ignore it
556
+ elif min(otherValue, boundary) + epsilon > 1.0 and det > 0.0:
557
+ continue # Start point at (1, 1), ignore it
558
+ # Append boundary point.
474
559
  points.append(Point(d, det, True, False, uvw))
475
560
  return abort
476
561
  for nInd in range(2):
@@ -485,7 +570,7 @@ def contours(self):
485
570
 
486
571
  # Find intersections with other boundaries.
487
572
  def otherIntersections(nInd, boundary):
488
- zeros = self.contract([None] * nInd + [boundary] + [None] * (self.nInd - nInd - 1)).zeros()
573
+ zeros = self.contract([None] * nInd + [boundary] + [None] * (self.nInd - nInd - 1)).zeros(epsilon, initialScale)
489
574
  abort = False
490
575
  for zero in zeros:
491
576
  if isinstance(zero, tuple):
@@ -494,12 +579,13 @@ def contours(self):
494
579
  uvw = np.insert(np.array(zero), nInd, boundary)
495
580
  d = uvw[0] * cosTheta + uvw[1] * sinTheta
496
581
  columns = np.empty((self.nDep, self.nInd - 1))
582
+ tangents = self.jacobian(uvw).T
497
583
  i = 0
498
584
  for j in range(self.nInd):
499
585
  if j != nInd:
500
- columns[:, i] = tangents[j](uvw)
586
+ columns[:, i] = tangents[j]
501
587
  i += 1
502
- duv = np.linalg.solve(columns, -tangents[nInd](uvw))
588
+ duv = np.linalg.solve(columns, -tangents[nInd])
503
589
  det = np.arctan2((0.5 - boundary) * (duv[0] * cosTheta + duv[1] * sinTheta), (0.5 - boundary) * (duv[0] * cosTheta - duv[1] * sinTheta))
504
590
  if abs(det) < epsilon:
505
591
  abort = True
@@ -517,10 +603,9 @@ def contours(self):
517
603
  continue # Try a different theta
518
604
 
519
605
  # Find turning points by combining self and turningPointDeterminant into a system and processing its zeros.
520
- systemSelf, systemTurningPointDeterminant = bspy.Spline.common_basis((self, turningPointDeterminant))
521
- system = type(systemSelf)(self.nInd, self.nInd, systemSelf.order, systemSelf.nCoef, systemSelf.knots, \
522
- np.concatenate((systemSelf.coefs, systemTurningPointDeterminant.coefs)), systemSelf.metadata)
523
- zeros = system.zeros()
606
+ turningPointBlock = self.block.copy()
607
+ turningPointBlock.append([turningPointDeterminant])
608
+ zeros = bspy.spline_block.SplineBlock(turningPointBlock).zeros(epsilon, np.append(initialScale, 1.0))
524
609
  for uvw in zeros:
525
610
  if isinstance(uvw, tuple):
526
611
  abort = True
@@ -540,7 +625,7 @@ def contours(self):
540
625
  if not abort:
541
626
  break # We're done!
542
627
 
543
- if attempts <= 0: raise ValueError("No contours. Degenerate equations.")
628
+ if abort: raise ValueError("No contours. Degenerate equations.")
544
629
 
545
630
  if not points:
546
631
  return [] # No contours
@@ -553,17 +638,14 @@ def contours(self):
553
638
  # a panel boundary: u * cosTheta + v * sinTheta = d. Basically, we add this panel boundary plane
554
639
  # to the contour condition. We'll define it for d = 0, and add the actual d later.
555
640
  # We didn't construct the panel system earlier, because we didn't have theta.
556
- panelCoefs = np.empty((self.nDep + 1, *self.coefs.shape[1:]), self.coefs.dtype) # Note that self.nDep + 1 == self.nInd
557
- panelCoefs[:self.nDep] = self.coefs
558
- # The following value should be -d. We're setting it for d = 0 to start.
559
- panelCoefs[self.nDep, 0, 0] = 0.0
560
- degree = self.order[0] - 1
561
- for i in range(1, self.nCoef[0]):
562
- panelCoefs[self.nDep, i, 0] = panelCoefs[self.nDep, i - 1, 0] + ((self.knots[0][degree + i] - self.knots[0][i]) / degree) * cosTheta
563
- degree = self.order[1] - 1
564
- for i in range(1, self.nCoef[1]):
565
- panelCoefs[self.nDep, :, i] = panelCoefs[self.nDep, :, i - 1] + ((self.knots[1][degree + i] - self.knots[1][i]) / degree) * sinTheta
566
- panel = type(self)(self.nInd, self.nInd, self.order, self.nCoef, self.knots, panelCoefs, self.metadata)
641
+ panelCoefs = np.array((((0.0, sinTheta), (cosTheta, cosTheta + sinTheta)),), self.coefsDtype)
642
+ panelSpline = bspy.spline.Spline(2, 1, (2, 2), (2, 2),
643
+ (np.array((0.0, 0.0, 1.0, 1.0), self.knotsDtype), np.array((0.0, 0.0, 1.0, 1.0), self.knotsDtype)),
644
+ panelCoefs)
645
+ panelBlock = self.block.copy()
646
+ panelBlock.append([panelSpline])
647
+ panel = bspy.spline_block.SplineBlock(panelBlock)
648
+ panelInitialScale = np.append(initialScale, 1.0)
567
649
 
568
650
  # Okay, we have everything we need to determine the contour topology and points along each contour.
569
651
  # We've done the first two steps of Grandine and Klein's algorithm:
@@ -574,10 +656,26 @@ def contours(self):
574
656
  # (3) Take all the points found in Step (1) and Step (2) and order them by distance in the theta direction from the origin.
575
657
  points.sort()
576
658
 
659
+ # Extra step not in paper.
660
+ # Remove duplicate points (typically appear at corners).
661
+ i = 0
662
+ while i < len(points):
663
+ previousPoint = points[i]
664
+ j = i + 1
665
+ while j < len(points):
666
+ point = points[j]
667
+ if point.d < previousPoint.d + epsilon:
668
+ if np.linalg.norm(point.uvw - previousPoint.uvw) < epsilon:
669
+ del points[j]
670
+ else:
671
+ j += 1
672
+ else:
673
+ break
674
+ i += 1
675
+
577
676
  # Extra step not in paper.
578
677
  # Run a checksum on the points, ensuring starting and ending points balance.
579
678
  # Start by flipping endpoints as needed, since we can miss turning points near endpoints.
580
-
581
679
  if points[0].det < 0.0:
582
680
  point = points[0]
583
681
  points[0] = Point(point.d, -point.det, point.onUVBoundary, point.turningPoint, point.uvw)
@@ -651,11 +749,11 @@ def contours(self):
651
749
  # points. Either insert two new contours in the list or delete two existing ones from
652
750
  # the list. Go back to Step (5).
653
751
  # First, construct panel, whose zeros lie along the panel boundary, u * cosTheta + v * sinTheta - d = 0.
654
- panel.coefs[self.nDep] -= point.d
752
+ panelSpline.coefs = panelCoefs - point.d
655
753
 
656
754
  if point.turningPoint and point.uvw is None:
657
755
  # For an inserted panel between two consecutive turning points, just find zeros along the panel.
658
- panelPoints = panel.zeros()
756
+ panelPoints = panel.zeros(epsilon, panelInitialScale)
659
757
  elif point.turningPoint:
660
758
  # Split panel below and above the known zero point.
661
759
  # This avoids extra computation and the high-zero at the known zero point, while ensuring we match the turning point.
@@ -677,15 +775,15 @@ def contours(self):
677
775
  np.linalg.norm(selfUU * sinTheta * sinTheta - 2.0 * selfUV * sinTheta * cosTheta + selfVV * cosTheta * cosTheta))
678
776
  # Now, we can find the zeros of the split panel, checking to ensure each panel is within bounds first.
679
777
  if point.uvw[0] + sinTheta * offset < 1.0 - epsilon and epsilon < point.uvw[1] - cosTheta * offset:
680
- panelPoints += panel.trim(((point.uvw[0] + sinTheta * offset, 1.0), (0.0, point.uvw[1] - cosTheta * offset)) + ((None, None),) * (self.nInd - 2)).zeros()
778
+ panelPoints += panel.trim(((point.uvw[0] + sinTheta * offset, 1.0), (0.0, point.uvw[1] - cosTheta * offset)) + ((None, None),) * (self.nInd - 2)).zeros(epsilon, panelInitialScale)
681
779
  expectedPanelPoints -= len(panelPoints) - 1 # Discount the turning point itself
682
780
  if expectedPanelPoints > 0 and epsilon < point.uvw[0] - sinTheta * offset and point.uvw[1] + cosTheta * offset < 1.0 - epsilon:
683
- panelPoints += panel.trim(((0.0, point.uvw[0] - sinTheta * offset), (point.uvw[1] + cosTheta * offset, 1.0)) + ((None, None),) * (self.nInd - 2)).zeros()
781
+ panelPoints += panel.trim(((0.0, point.uvw[0] - sinTheta * offset), (point.uvw[1] + cosTheta * offset, 1.0)) + ((None, None),) * (self.nInd - 2)).zeros(epsilon, panelInitialScale)
684
782
  else: # It's an other-boundary point.
685
783
  # Only find extra zeros along the panel if any are expected (> 0 for starting point, > 1 for ending one).
686
784
  expectedPanelPoints = len(currentContourPoints) - (0 if point.det > 0.0 else 1)
687
785
  if expectedPanelPoints > 0:
688
- panelPoints = panel.zeros()
786
+ panelPoints = panel.zeros(epsilon, panelInitialScale)
689
787
  panelPoints.sort(key=lambda uvw: np.linalg.norm(point.uvw - uvw)) # Sort by distance from boundary point
690
788
  while len(panelPoints) > expectedPanelPoints:
691
789
  panelPoints.pop(0) # Drop points closest to the boundary point
@@ -693,8 +791,6 @@ def contours(self):
693
791
  else:
694
792
  panelPoints = [point.uvw]
695
793
 
696
- # Add d back to prepare for next turning point.
697
- panel.coefs[self.nDep] += point.d
698
794
  # Sort zero points by their position along the panel boundary (using vector orthogonal to its normal).
699
795
  panelPoints.sort(key=lambda uvw: uvw[1] * cosTheta - uvw[0] * sinTheta)
700
796
  # Go through panel points, adding them to existing contours, creating new ones, or closing old ones.
@@ -781,6 +877,58 @@ def contours(self):
781
877
 
782
878
  return splineContours
783
879
 
880
+ def contours(self):
881
+ if self.nInd - self.nDep != 1: raise ValueError("The number of free variables (self.nInd - self.nDep) must be one.")
882
+ epsilon = np.sqrt(np.finfo(self.knotsDtype).eps)
883
+ evaluationEpsilon = max(np.sqrt(epsilon), np.finfo(self.coefsDtype).eps ** 0.25)
884
+
885
+ # Split the splines in the block to ensure C1 continuity within each block
886
+ blocks = [self]
887
+ for i, row in enumerate(self.block):
888
+ for j, spline in enumerate(row):
889
+ splines = spline.split(minContinuity = 1)
890
+ if splines.size == 1 and self.size == 1:
891
+ break # Special case of a block with one C1 spline
892
+ newBlocks = []
893
+ for spline in splines.ravel():
894
+ for block in blocks:
895
+ newBlock = block.block.copy()
896
+ newRow = newBlock[i].copy()
897
+ newBlock[i] = newRow
898
+ newRow[j] = spline
899
+ newBlocks.append(bspy.spline_block.SplineBlock(newBlock))
900
+ blocks = newBlocks
901
+
902
+ contours = []
903
+ for block in blocks:
904
+ splineContours = _contours_of_C1_spline_block(block, epsilon, evaluationEpsilon)
905
+ for newContour in splineContours:
906
+ newStart = newContour(0.0)
907
+ newFinish = newContour(1.0)
908
+ joined = False
909
+ for i, oldContour in enumerate(contours):
910
+ oldStart = oldContour(0.0)
911
+ oldFinish = oldContour(1.0)
912
+ if np.linalg.norm(newStart - oldFinish) < evaluationEpsilon:
913
+ contours[i] = bspy.Spline.join((oldContour, newContour))
914
+ joined = True
915
+ break
916
+ if np.linalg.norm(newStart - oldStart) < evaluationEpsilon:
917
+ contours[i] = bspy.Spline.join((oldContour, newContour.reverse()))
918
+ joined = True
919
+ break
920
+ if np.linalg.norm(newFinish - oldStart) < evaluationEpsilon:
921
+ contours[i] = bspy.Spline.join((newContour, oldContour))
922
+ joined = True
923
+ break
924
+ if np.linalg.norm(newFinish - oldFinish) < evaluationEpsilon:
925
+ contours[i] = bspy.Spline.join((newContour, oldContour.reverse()))
926
+ joined = True
927
+ break
928
+ if not joined:
929
+ contours.append(newContour)
930
+ return contours
931
+
784
932
  def intersect(self, other):
785
933
  intersections = []
786
934
  nDep = self.nInd # The dimension of the intersection's range
@@ -850,13 +998,13 @@ def intersect(self, other):
850
998
 
851
999
  # Spline-Spline intersection.
852
1000
  elif isinstance(other, bspy.Spline):
853
- # Construct a new spline that represents the intersection.
854
- spline = self.subtract(other)
1001
+ # Construct a spline block that represents the intersection.
1002
+ block = bspy.spline_block.SplineBlock([[self, -other]])
855
1003
 
856
1004
  # Curve-Curve intersection.
857
1005
  if nDep == 1:
858
1006
  # Find the intersection points and intervals.
859
- zeros = spline.zeros()
1007
+ zeros = block.zeros()
860
1008
  # Convert each intersection point into a Manifold.Crossing and each intersection interval into a Manifold.Coincidence.
861
1009
  for zero in zeros:
862
1010
  if isinstance(zero, tuple):
@@ -895,28 +1043,27 @@ def intersect(self, other):
895
1043
  # Surface-Surface intersection.
896
1044
  elif nDep == 2:
897
1045
  if "Name" in self.metadata and "Name" in other.metadata:
898
- logging.info(f"intersect({self.metadata['Name']}, {other.metadata['Name']})")
1046
+ logging.info(f"intersect:{self.metadata['Name']}:{other.metadata['Name']}")
899
1047
  # Find the intersection contours, which are returned as splines.
900
1048
  swap = False
901
1049
  try:
902
1050
  # First try the intersection as is.
903
- contours = spline.contours()
904
- except ValueError:
1051
+ contours = block.contours()
1052
+ except ValueError as e:
1053
+ logging.info(e)
905
1054
  # If that fails, swap the manifolds. Worth a shot since intersections are touchy.
906
- swap = True
907
-
908
- # Convert each contour into a Manifold.Crossing.
909
- if swap:
910
- spline = other.subtract(self)
1055
+ block = bspy.spline_block.SplineBlock([[other, -self]])
911
1056
  if "Name" in self.metadata and "Name" in other.metadata:
912
- logging.info(f"intersect({other.metadata['Name']}, {self.metadata['Name']})")
913
- contours = spline.contours()
1057
+ logging.info(f"intersect:{other.metadata['Name']}:{self.metadata['Name']}")
1058
+ contours = block.contours()
1059
+ # Convert each contour into a Manifold.Crossing, swapping the manifolds back.
914
1060
  for contour in contours:
915
1061
  # Swap left and right, compared to not swapped.
916
1062
  left = bspy.Spline(contour.nInd, nDep, contour.order, contour.nCoef, contour.knots, contour.coefs[nDep:], contour.metadata)
917
1063
  right = bspy.Spline(contour.nInd, nDep, contour.order, contour.nCoef, contour.knots, contour.coefs[:nDep], contour.metadata)
918
1064
  intersections.append(Manifold.Crossing(left, right))
919
1065
  else:
1066
+ # Convert each contour into a Manifold.Crossing.
920
1067
  for contour in contours:
921
1068
  left = bspy.Spline(contour.nInd, nDep, contour.order, contour.nCoef, contour.knots, contour.coefs[:nDep], contour.metadata)
922
1069
  right = bspy.Spline(contour.nInd, nDep, contour.order, contour.nCoef, contour.knots, contour.coefs[nDep:], contour.metadata)
@@ -950,7 +1097,7 @@ def complete_slice(self, slice, solid):
950
1097
  if not slice.boundaries:
951
1098
  if slice.dimension == 2:
952
1099
  if "Name" in self.metadata:
953
- logging.info(f"check containment: {self.metadata['Name']}")
1100
+ logging.info(f"check containment:{self.metadata['Name']}")
954
1101
  domain = bounds.T
955
1102
  if solid.contains_point(self(0.5 * (domain[0] + domain[1]))):
956
1103
  for boundary in Hyperplane.create_hypercube(bounds).boundaries: