bspy 3.0.1__py3-none-any.whl → 4.1__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.
bspy/solid.py ADDED
@@ -0,0 +1,839 @@
1
+ import numpy as np
2
+ import json
3
+ import scipy.integrate as integrate
4
+ from bspy.manifold import Manifold
5
+
6
+ class Boundary:
7
+ """
8
+ A portion of the boundary of a solid.
9
+
10
+ Parameters
11
+ ----------
12
+ manifold : `Manifold`
13
+ The differentiable function whose range is one dimension higher than its domain that defines the range of the boundary.
14
+
15
+ domain : `Solid`, optional
16
+ The region of the domain of the manifold that's within the boundary. The default is the full domain of the manifold.
17
+
18
+ See also
19
+ --------
20
+ `Solid` : A region that separates space into an inside and outside, defined by a collection of boundaries.
21
+ `Manifold.full_domain` : Return a solid that represents the full domain of the manifold.
22
+ """
23
+ def __init__(self, manifold, domain = None):
24
+ self.domain = manifold.full_domain() if domain is None else domain
25
+ if manifold.domain_dimension() != self.domain.dimension: raise ValueError("Domain dimensions don't match")
26
+ if manifold.domain_dimension() + 1 != manifold.range_dimension(): raise ValueError("Manifold range is not one dimension higher than domain")
27
+ self.manifold, self.bounds = manifold.trimmed_range_bounds(self.domain.bounds)
28
+
29
+ def __repr__(self):
30
+ return "Boundary({0}, {1})".format(self.manifold.__repr__(), self.domain.__repr__())
31
+
32
+ def any_point(self):
33
+ """
34
+ Return an arbitrary point on the boundary.
35
+
36
+ Returns
37
+ -------
38
+ point : `numpy.array`
39
+ A point on the boundary.
40
+
41
+ See Also
42
+ --------
43
+ `Solid.any_point` : Return an arbitrary point on the solid.
44
+
45
+ Notes
46
+ -----
47
+ The point is computed by evaluating the boundary manifold by an arbitrary point in the domain of the boundary.
48
+ """
49
+ return self.manifold.evaluate(self.domain.any_point())
50
+
51
+ class Solid:
52
+ """
53
+ A region that separates space into an inside and outside, defined by a collection of boundaries.
54
+
55
+ Parameters
56
+ ----------
57
+ dimension : `int`
58
+ The dimension of the solid (non-negative).
59
+
60
+ containsInfinity : `bool`
61
+ Indicates whether or not the solid contains infinity.
62
+
63
+ See also
64
+ --------
65
+ `Boundary` : A portion of the boundary of a solid.
66
+
67
+ Notes
68
+ -----
69
+ Solids also contain a `list` of `boundaries`. That list may be empty.
70
+
71
+ Solids can be of zero dimension, typically acting as the domain of boundary endpoints. Zero-dimension solids have no boundaries, they only contain infinity or not.
72
+ """
73
+ def __init__(self, dimension, containsInfinity):
74
+ assert dimension >= 0
75
+ self.dimension = dimension
76
+ self.containsInfinity = containsInfinity
77
+ self.boundaries = []
78
+ self.bounds = None
79
+
80
+ def __add__(self, other):
81
+ return self.union(other)
82
+
83
+ def __bool__(self):
84
+ return self.containsInfinity or len(self.boundaries) > 0
85
+
86
+ def __mul__(self, other):
87
+ return self.intersection(other)
88
+
89
+ def __neg__(self):
90
+ return self.complement()
91
+
92
+ def __repr__(self):
93
+ return "Solid({0}, {1})".format(self.dimension, self.containsInfinity)
94
+
95
+ def __sub__(self, other):
96
+ return self.difference(other)
97
+
98
+ def add_boundary(self, boundary):
99
+ """
100
+ Adds a boundary to a solid, recomputing the solid's bounds.
101
+
102
+ Parameters
103
+ ----------
104
+ boundary : `Boundary`
105
+ A boundary to add to the solid.
106
+
107
+ Notes
108
+ -----
109
+ While you could just append the boundary to the solid's collection of boundaries,
110
+ this method also recomputes the solid's bounds for faster intersection and containment operations.
111
+ Adding the boundary directly to the solid's boundaries collection may result in faulty operations.
112
+ """
113
+ if boundary.manifold.range_dimension() != self.dimension: raise ValueError("Dimensions don't match")
114
+ self.boundaries.append(boundary)
115
+ if self.bounds is None:
116
+ self.bounds = boundary.bounds
117
+ elif boundary.bounds is None:
118
+ raise ValueError("Mix of infinite and bounded boundaries")
119
+ else:
120
+ self.bounds[:, 0] = np.minimum(self.bounds[:, 0], boundary.bounds[:, 0])
121
+ self.bounds[:, 1] = np.maximum(self.bounds[:, 1], boundary.bounds[:, 1])
122
+
123
+ def any_point(self):
124
+ """
125
+ Return an arbitrary point on the solid.
126
+
127
+ Returns
128
+ -------
129
+ point : `numpy.array`
130
+ A point on the solid.
131
+
132
+ See Also
133
+ --------
134
+ `Boundary.any_point` : Return an arbitrary point on the boundary.
135
+
136
+ Notes
137
+ -----
138
+ The point is computed by calling `Boundary.any_point` on the solid's first boundary.
139
+ If the solid has no boundaries but contains infinity, `any_point` returns the origin.
140
+ If the solid has no boundaries and doesn't contain infinity, `any_point` returns `None`.
141
+ """
142
+ point = None
143
+ if self.boundaries:
144
+ point = self.boundaries[0].any_point()
145
+ elif self.containsInfinity:
146
+ if self.dimension > 0:
147
+ point = np.full((self.dimension), 0.0)
148
+ else:
149
+ point = 0.0
150
+
151
+ return point
152
+
153
+ def complement(self):
154
+ """
155
+ Return the complement of the solid: whatever was inside is outside and vice-versa.
156
+
157
+ Returns
158
+ -------
159
+ solid : `Solid`
160
+ The complement of the solid.
161
+
162
+ See Also
163
+ --------
164
+ `intersection` : Intersect two solids.
165
+ `union` : Union two solids.
166
+ `difference` : Subtract one solid from another.
167
+ """
168
+ solid = Solid(self.dimension, not self.containsInfinity)
169
+ for boundary in self.boundaries:
170
+ solid.add_boundary(Boundary(boundary.manifold.flip_normal(), boundary.domain))
171
+ return solid
172
+
173
+ def contains_point(self, point):
174
+ """
175
+ Test if a point lies within the solid.
176
+
177
+ Parameters
178
+ ----------
179
+ point : array-like
180
+ A point that may lie within the solid.
181
+
182
+ Returns
183
+ -------
184
+ containment : `bool`
185
+ `True` if `point` lies within the solid. `False` otherwise.
186
+
187
+ See Also
188
+ --------
189
+ `winding_number` : Compute the winding number for a point relative to the solid.
190
+
191
+ Notes
192
+ -----
193
+ A point is considered contained if it's on the boundary of the solid or it's winding number is greater than 0.5.
194
+ """
195
+ windingNumber, onBoundaryNormal = self.winding_number(point)
196
+ # The default is to include points on the boundary (onBoundaryNormal is not None).
197
+ containment = True
198
+ if onBoundaryNormal is None:
199
+ # windingNumber > 0.5 returns a np.bool_, not a bool, so we need to cast it.
200
+ containment = bool(windingNumber > 0.5)
201
+ return containment
202
+
203
+ def difference(self, other):
204
+ """
205
+ Subtract one solid from another.
206
+
207
+ Parameters
208
+ ----------
209
+ other : `Solid`
210
+ The `Solid` subtracted from self.
211
+
212
+ Returns
213
+ -------
214
+ combinedSolid : `Solid`
215
+ A `Solid` that represents the subtraction of other from self.
216
+
217
+ See Also
218
+ --------
219
+ `intersection` : Intersect two solids.
220
+ `union` : Union two solids.
221
+ """
222
+ return self.intersection(other.complement())
223
+
224
+ @staticmethod
225
+ def disjoint_bounds(bounds1, bounds2):
226
+ """
227
+ Returns whether or not bounds1 and bounds2 are disjoint.
228
+
229
+ Parameters
230
+ ----------
231
+ bounds1 : array-like or `None`
232
+ An array with shape (dimension, 2) of lower and upper and lower bounds on each dimension.
233
+ If bounds1 is `None` then then there are no bounds.
234
+
235
+ bounds2 : array-like or `None`
236
+ An array with shape (dimension, 2) of lower and upper and lower bounds on each dimension.
237
+ If bounds2 is `None` then then there are no bounds.
238
+
239
+ Returns
240
+ -------
241
+ disjoint : `bool`
242
+ Value is true if the bounds are disjoint. Value is false if either bounds is `None`.
243
+ """
244
+ if bounds1 is None or bounds2 is None:
245
+ return False
246
+ else:
247
+ return np.min(np.diff(bounds1).reshape(-1) + np.diff(bounds2).reshape(-1) -
248
+ np.abs(np.sum(bounds1, axis=1) - np.sum(bounds2, axis=1))) < -Manifold.minSeparation
249
+
250
+ def intersection(self, other, cache = None):
251
+ """
252
+ Intersect two solids.
253
+
254
+ Parameters
255
+ ----------
256
+ other : `Solid`
257
+ The `Solid` intersecting self.
258
+
259
+ cache : `dict`, optional
260
+ A dictionary to cache `Manifold` intersections, speeding computation. If no dictionary is passed, one is created.
261
+
262
+ Returns
263
+ -------
264
+ combinedSolid : `Solid`
265
+ A `Solid` that represents the intersection between self and other.
266
+
267
+ See Also
268
+ --------
269
+ `slice` : Slice a solid by a manifold.
270
+ `union` : Union two solids.
271
+ `difference` : Subtract one solid from another.
272
+
273
+ Notes
274
+ -----
275
+ To intersect two solids, we slice each solid with the boundaries of the other solid. The slices are the region
276
+ of the domain that intersect the solid. We then intersect the domain of each boundary with its slice of the other solid. Thus,
277
+ the intersection of two solids becomes a set of intersections within the domains of their boundaries. This recursion continues
278
+ until we are intersecting points whose domains have no boundaries.
279
+
280
+ The only subtlety is when two boundaries are coincident. To avoid overlapping the coincident region, we keep that region
281
+ for one slice and trim it away for the other. We use a manifold intersection cache to keep track of these pairs, as well as to reduce computation.
282
+ """
283
+ assert self.dimension == other.dimension
284
+
285
+ # Manifold intersections are expensive and come in symmetric pairs (m1 intersect m2, m2 intersect m1).
286
+ # So, we create a manifold intersections cache (dictionary) to store and reuse intersection pairs.
287
+ # The cache is also used to avoid overlapping coincident regions by identifying twins that could be trimmed.
288
+ if cache is None:
289
+ cache = {}
290
+
291
+ # Start with a solid without boundaries.
292
+ combinedSolid = Solid(self.dimension, self.containsInfinity and other.containsInfinity)
293
+
294
+ # If the solids are disjoint, add the boundaries contained by infinity and return.
295
+ if Solid.disjoint_bounds(self.bounds, other.bounds):
296
+ if other.containsInfinity:
297
+ for boundary in self.boundaries:
298
+ combinedSolid.add_boundary(boundary)
299
+ if self.containsInfinity:
300
+ for boundary in other.boundaries:
301
+ combinedSolid.add_boundary(boundary)
302
+ return combinedSolid
303
+
304
+ for boundary in self.boundaries:
305
+ # Slice self boundary manifold by other.
306
+ slice = other.slice(boundary.manifold, cache, True)
307
+ # Intersect slice with the boundary's domain.
308
+ newDomain = boundary.domain.intersection(slice, cache)
309
+ if newDomain:
310
+ # Self boundary intersects other, so create a new boundary with the intersected domain.
311
+ combinedSolid.add_boundary(Boundary(boundary.manifold, newDomain))
312
+
313
+ for boundary in other.boundaries:
314
+ # Slice other boundary manifold by self.
315
+ slice = self.slice(boundary.manifold, cache, True)
316
+ # Intersect slice with the boundary's domain.
317
+ newDomain = boundary.domain.intersection(slice, cache)
318
+ if newDomain:
319
+ # Other boundary intersects self, so create a new boundary with the intersected domain.
320
+ combinedSolid.add_boundary(Boundary(boundary.manifold, newDomain))
321
+
322
+ return combinedSolid
323
+
324
+ def is_empty(self):
325
+ """
326
+ Test if the solid is empty.
327
+
328
+ Returns
329
+ -------
330
+ isEmpty : `bool`
331
+ `True` if the solid is empty, `False` otherwise.
332
+
333
+ Notes
334
+ -----
335
+ Casting the solid to `bool` returns not `is_empty`.
336
+ """
337
+ return not self
338
+
339
+ @staticmethod
340
+ def load(fileName):
341
+ """
342
+ Load solids and/or manifolds in json format from the specified filename (full path).
343
+
344
+ Parameters
345
+ ----------
346
+ fileName : `string`
347
+ The full path to the file containing the solids and/or manifolds. Can be a relative path.
348
+
349
+ Returns
350
+ -------
351
+ solidsAndManifolds : list of `Solid` and/or `Manifold`
352
+ The loaded solids and/or manifolds.
353
+
354
+ See Also
355
+ --------
356
+ `save` : Save a solids and/or manifolds in json format to the specified filename (full path).
357
+ """
358
+ def from_dict(dictionary):
359
+ solid = Solid(dictionary["dimension"], dictionary["containsInfinity"])
360
+ for boundary in dictionary["boundaries"]:
361
+ manifold = boundary["manifold"]
362
+ solid.add_boundary(Boundary(Manifold.factory[manifold.get("type", "Spline")].from_dict(manifold), from_dict(boundary["domain"])))
363
+ return solid
364
+
365
+ # Load json file.
366
+ with open(fileName, 'r', encoding='utf-8') as file:
367
+ data = json.load(file)
368
+
369
+ # Convert json data to solids and manifolds.
370
+ solidsAndManifolds = []
371
+ if isinstance(data, dict):
372
+ data = [data]
373
+ for dictionary in data:
374
+ className = dictionary.get("type", "Spline")
375
+ if className == "Solid":
376
+ solidsAndManifolds.append(from_dict(dictionary))
377
+ else:
378
+ solidsAndManifolds.append(Manifold.factory[className].from_dict(dictionary))
379
+ return solidsAndManifolds
380
+
381
+ @staticmethod
382
+ def point_outside_bounds(point, bounds):
383
+ """
384
+ Returns whether or not point is outside bounds.
385
+
386
+ Parameters
387
+ ----------
388
+ point : array-like
389
+ A point whose dimension matches the bounds.
390
+
391
+ bounds : array-like or `None`
392
+ An array with shape (dimension, 2) of lower and upper and lower bounds on each dimension.
393
+ If bounds is `None` then then there are no bounds.
394
+
395
+ Returns
396
+ -------
397
+ within : `bool`
398
+ Value is true if the point is outside bounds or bounds is `None`.
399
+ """
400
+ if bounds is None:
401
+ return True
402
+ else:
403
+ return np.min(np.diff(bounds).reshape(-1) - np.abs(np.sum(bounds, axis=1) + -2.0 * point)) < -Manifold.minSeparation
404
+
405
+ @staticmethod
406
+ def save(fileName, *solids_or_manifolds):
407
+ """
408
+ Save a solids and/or manifolds in json format to the specified filename (full path).
409
+
410
+ Parameters
411
+ ----------
412
+ fileName : `string`
413
+ The full path to the file containing the solids and/or manifolds. Can be a relative path.
414
+
415
+ *solids_or_manifolds : `Solid` or `Manifold`
416
+ Solids and/or manifolds to save in the same file.
417
+
418
+ See Also
419
+ --------
420
+ `load` : Load solids and/or manifolds in json format from the specified filename (full path).
421
+ """
422
+ class Encoder(json.JSONEncoder):
423
+ def default(self, obj):
424
+ if isinstance(obj, np.ndarray):
425
+ return obj.tolist()
426
+ if isinstance(obj, Manifold):
427
+ return obj.to_dict()
428
+ if isinstance(obj, Boundary):
429
+ return {"type" : "Boundary", "manifold" : obj.manifold, "domain" : obj.domain}
430
+ if isinstance(obj, Solid):
431
+ return {"type" : "Solid", "dimension" : obj.dimension, "containsInfinity" : obj.containsInfinity, "boundaries" : obj.boundaries}
432
+ return super().default(obj)
433
+
434
+ with open(fileName, 'w', encoding='utf-8') as file:
435
+ json.dump(solids_or_manifolds, file, indent=4, cls=Encoder)
436
+
437
+ def slice(self, manifold, cache = None, trimTwin = False):
438
+ """
439
+ Slice the solid by a manifold.
440
+
441
+ Parameters
442
+ ----------
443
+ manifold : `Manifold`
444
+ The `Manifold` used to slice the solid.
445
+
446
+ cache : `dict`, optional
447
+ A dictionary to cache `Manifold` intersections, speeding computation.
448
+
449
+ trimTwin : `bool`, default: False
450
+ Trim coincident boundary twins on subsequent calls to slice (avoids duplication of overlapping regions).
451
+ Trimming twins is typically only used in conjunction with `intersection`.
452
+
453
+ Returns
454
+ -------
455
+ slice : `Solid`
456
+ A region in the domain of `manifold` that intersects with the solid. The region may contain infinity.
457
+
458
+ See Also
459
+ --------
460
+ `intersection` : Intersect two solids.
461
+ `Manifold.intersect` : Intersect two manifolds.
462
+ `Manifold.cached_intersect` : Intersect two manifolds, caching the result for twins.
463
+ `Manifold.complete_slice` : Add any missing inherent (implicit) boundaries of this manifold's domain to the given slice.
464
+
465
+ Notes
466
+ -----
467
+ The dimension of the slice is always one less than the dimension of the solid, since the slice is a region in the domain of the manifold slicing the solid.
468
+
469
+ To compute the slice of a manifold intersecting the solid, we intersect the manifold with each boundary of the solid. There may be multiple intersections
470
+ between the manifold and the boundary. Each is either a crossing or a coincident region.
471
+
472
+ Crossings result in two intersection manifolds: one in the domain of the manifold and one in the domain of the boundary. By construction, both intersection manifolds have the
473
+ same domain and the same range of the manifold and boundary (the crossing itself). The intersection manifold in the domain of the manifold becomes a boundary of the slice,
474
+ but we must determine the intersection's domain. For that, we slice the boundary's intersection manifold with the boundary's domain. This recursion continues
475
+ until the slice is just a point with no domain.
476
+
477
+ Coincident regions appear in the domains of the manifold and the boundary. We intersect the boundary's coincident region with the domain of the boundary and then map
478
+ it to the domain of the manifold. If the coincident regions have normals in opposite directions, they cancel each other out, so we subtract them from the slice by
479
+ inverting the region and intersecting it with the slice. We use this same technique for removing overlapping coincident regions. If the coincident regions have normals
480
+ in the same direction, we union them with the slice.
481
+ """
482
+ assert manifold.range_dimension() == self.dimension
483
+
484
+ # Start with an empty slice and no domain coincidences.
485
+ slice = Solid(self.dimension-1, self.containsInfinity)
486
+ bounds = manifold.range_bounds()
487
+ if Solid.disjoint_bounds(bounds, self.bounds):
488
+ manifold.complete_slice(slice, self)
489
+ return slice
490
+ coincidences = []
491
+
492
+ # Intersect each of this solid's boundaries with the manifold.
493
+ for boundary in self.boundaries:
494
+ if Solid.disjoint_bounds(boundary.bounds, bounds):
495
+ continue
496
+
497
+ # Intersect manifolds, checking if the intersection is already in the cache.
498
+ intersections, isTwin = boundary.manifold.cached_intersect(manifold, cache)
499
+ if intersections is NotImplemented:
500
+ raise NotImplementedError()
501
+
502
+ # Each intersection is either a crossing (domain manifold) or a coincidence (solid within the domain).
503
+ for intersection in intersections:
504
+ (left, right) = (intersection.right, intersection.left) if isTwin else (intersection.left, intersection.right)
505
+
506
+ if isinstance(intersection, Manifold.Crossing):
507
+ domainSlice = boundary.domain.slice(left, cache)
508
+ if domainSlice:
509
+ slice.add_boundary(Boundary(right, domainSlice))
510
+
511
+ elif isinstance(intersection, Manifold.Coincidence):
512
+ # First, intersect domain coincidence with the domain boundary.
513
+ coincidence = left.intersection(boundary.domain)
514
+ # Next, invert the domain coincidence (which will remove it) if this is a twin or if the normals point in opposite directions.
515
+ invertCoincidence = (trimTwin and isTwin) or intersection.alignment < 0.0
516
+ if invertCoincidence:
517
+ coincidence.containsInfinity = not coincidence.containsInfinity
518
+ # Next, transform the domain coincidence from the boundary to the given manifold.
519
+ # Create copies of the manifolds and boundaries, since we are changing them.
520
+ for i in range(len(coincidence.boundaries)):
521
+ domainManifold = coincidence.boundaries[i].manifold
522
+ if invertCoincidence:
523
+ domainManifold = domainManifold.flip_normal()
524
+ if isTwin:
525
+ domainManifold = domainManifold.translate(-intersection.translation)
526
+ domainManifold = domainManifold.transform(intersection.inverse, intersection.transform.T)
527
+ else:
528
+ domainManifold = domainManifold.transform(intersection.transform, intersection.inverse.T)
529
+ domainManifold = domainManifold.translate(intersection.translation)
530
+ coincidence.boundaries[i] = Boundary(domainManifold, coincidence.boundaries[i].domain)
531
+ # Finally, add the domain coincidence to the list of coincidences.
532
+ coincidences.append((invertCoincidence, coincidence))
533
+
534
+ # Ensure the slice includes the manifold's inherent (implicit) boundaries, making it valid and complete.
535
+ manifold.complete_slice(slice, self)
536
+
537
+ # Now that we have a complete manifold domain, join it with each domain coincidence.
538
+ for coincidence in coincidences:
539
+ if coincidence[0]:
540
+ # If the domain coincidence is inverted (coincidence[0]), intersect it with the slice, thus removing it.
541
+ slice = slice.intersection(coincidence[1], cache)
542
+ else:
543
+ # Otherwise, union the domain coincidence with the slice, thus adding it.
544
+ slice = slice.union(coincidence[1])
545
+
546
+ return slice
547
+
548
+ def surface_integral(self, f, args=(), epsabs=None, epsrel=None, *quadArgs):
549
+ """
550
+ Compute the surface integral of a vector field on the boundary of the solid.
551
+
552
+ Parameters
553
+ ----------
554
+ f : python function `f(point: numpy.array, normal: numpy.array, args : user-defined) -> numpy.array`
555
+ The vector field to be integrated on the boundary of the solid.
556
+ It's passed a point on the boundary and its corresponding outward-pointing unit normal, as well as any optional user-defined arguments.
557
+
558
+ args : tuple, optional
559
+ Extra arguments to pass to `f`.
560
+
561
+ *quadArgs : Quadrature arguments passed to `scipy.integrate.quad`.
562
+
563
+ Returns
564
+ -------
565
+ sum : scalar value
566
+ The value of the surface integral.
567
+
568
+ See Also
569
+ --------
570
+ `volume_integral` : Compute the volume integral of a function within the solid.
571
+ `scipy.integrate.quad` : Integrate func from a to b (possibly infinite interval) using a technique from the Fortran library QUADPACK.
572
+
573
+ Notes
574
+ -----
575
+ To compute the surface integral of a scalar function on the boundary, have `f` return the product of the `normal` times the scalar function for the `point`.
576
+
577
+ `surface_integral` sums the `volume_integral` over the domain of the solid's boundaries, using the integrand: `numpy.dot(f(point, normal), normal)`,
578
+ where `normal` is the cross-product of the boundary tangents (the normal before normalization).
579
+ """
580
+ if not isinstance(args, tuple):
581
+ args = (args,)
582
+ if epsabs is None:
583
+ epsabs = Manifold.minSeparation
584
+ if epsrel is None:
585
+ epsrel = Manifold.minSeparation
586
+
587
+ # Initialize the return value for the integral
588
+ sum = 0.0
589
+
590
+ for boundary in self.boundaries:
591
+ def integrand(domainPoint):
592
+ evalPoint = np.atleast_1d(domainPoint)
593
+ point = boundary.manifold.evaluate(evalPoint)
594
+ cofactorNormal = boundary.manifold.normal(evalPoint, False)
595
+ normal = cofactorNormal / np.linalg.norm(cofactorNormal)
596
+ fValue = f(point, normal, *args)
597
+ return np.dot(fValue, cofactorNormal)
598
+
599
+ if boundary.domain.dimension > 0:
600
+ # Add the contribution to the Volume integral from this boundary.
601
+ sum += boundary.domain.volume_integral(integrand)
602
+ else:
603
+ # This is a 1-D boundary (line interval, no domain), so just add the integrand.
604
+ sum += integrand(0.0)
605
+
606
+ return sum
607
+
608
+ def transform(self, matrix, matrixInverseTranspose = None):
609
+ """
610
+ Transform the range of the solid.
611
+
612
+ Parameters
613
+ ----------
614
+ matrix : `numpy.array`
615
+ A square matrix transformation.
616
+
617
+ matrixInverseTranspose : `numpy.array`, optional
618
+ The inverse transpose of matrix (computed if not provided).
619
+
620
+ Returns
621
+ -------
622
+ solid : `Solid`
623
+ The transformed solid.
624
+ """
625
+ assert np.shape(matrix) == (self.dimension, self.dimension)
626
+
627
+ if matrixInverseTranspose is None:
628
+ matrixInverseTranspose = np.transpose(np.linalg.inv(matrix))
629
+
630
+ solid = Solid(self.dimension, self.containsInfinity)
631
+ for boundary in self.boundaries:
632
+ solid.add_boundary(Boundary(boundary.manifold.transform(matrix, matrixInverseTranspose), boundary.domain))
633
+ return solid
634
+
635
+ def translate(self, delta):
636
+ """
637
+ Translate the range of the solid.
638
+
639
+ Parameters
640
+ ----------
641
+ delta : `numpy.array`
642
+ A 1D array translation.
643
+
644
+ Returns
645
+ -------
646
+ solid : `Solid`
647
+ The translated solid.
648
+ """
649
+ assert len(delta) == self.dimension
650
+
651
+ solid = Solid(self.dimension, self.containsInfinity)
652
+ for boundary in self.boundaries:
653
+ solid.add_boundary(Boundary(boundary.manifold.translate(delta), boundary.domain))
654
+ return solid
655
+
656
+ def union(self, other):
657
+ """
658
+ Union two solids.
659
+
660
+ Parameters
661
+ ----------
662
+ other : `Solid`
663
+ The `Solid` unioning self.
664
+
665
+ Returns
666
+ -------
667
+ combinedSolid : `Solid`
668
+ A `Solid` that represents the union between self and other.
669
+
670
+ See Also
671
+ --------
672
+ `intersection` : Intersect two solids.
673
+ `difference` : Subtract one solid from another.
674
+ """
675
+ return self.complement().intersection(other.complement()).complement()
676
+
677
+ def volume_integral(self, f, args=(), epsabs=None, epsrel=None, *quadArgs):
678
+ """
679
+ Compute the volume integral of a function within the solid.
680
+
681
+ Parameters
682
+ ----------
683
+ f : python function `f(point: numpy.array, args : user-defined) -> scalar value`
684
+ The function to be integrated within the solid.
685
+ It's passed a point within the solid, as well as any optional user-defined arguments.
686
+
687
+ args : tuple, optional
688
+ Extra arguments to pass to `f`.
689
+
690
+ *quadArgs : Quadrature arguments passed to `scipy.integrate.quad`.
691
+
692
+ Returns
693
+ -------
694
+ sum : scalar value
695
+ The value of the volume integral.
696
+
697
+ See Also
698
+ --------
699
+ `surface_integral` : Compute the surface integral of a vector field on the boundary of the solid.
700
+ `scipy.integrate.quad` : Integrate func from a to b (possibly infinite interval) using a technique from the Fortran library QUADPACK.
701
+
702
+ Notes
703
+ -----
704
+ The volume integral is computed by recursive application of the divergence theorem: `volume_integral(divergence(F)) = surface_integral(dot(F, n))`,
705
+ where `F` is a vector field and `n` is the outward boundary unit normal.
706
+
707
+ Let `F = [Integral(f) from x0 to x holding other coordinates fixed, 0..0]`. `divergence(F) = f` by construction, and `dot(F, n) = Integral(f) * n[0]`.
708
+ Note that the choice of `x0` is arbitrary as long as it's in the domain of f and doesn't change across all surface integral boundaries.
709
+
710
+ Thus, we have `volume_integral(f) = surface_integral(Integral(f) * n[0])`.
711
+ The outward boundary unit normal, `n`, is the cross product of the boundary manifold's tangent space divided by its length.
712
+ The surface differential, `dS`, is the length of cross product of the boundary manifold's tangent space times the differentials of the manifold's domain variables.
713
+ The length of the cross product appears in the numerator and denominator of the surface integral and cancels.
714
+ What's left multiplying `Integral(f)` is the first coordinate of the cross product plus the domain differentials (volume integral).
715
+ The first coordinate of the cross product of the boundary manifold's tangent space is the first cofactor of the tangent space.
716
+ And so, `surface_integral(Integral(f) * n[0]) = volume_integral(Integral(f) * first cofactor)` over each boundary manifold's domain.
717
+
718
+ So, we have `volume_integral(f) = volume_integral(Integral(f) * first cofactor)` over each boundary manifold's domain.
719
+ To compute the volume integral we sum `volume_integral` over the domain of the solid's boundaries, using the integrand:
720
+ `scipy.integrate.quad(f, x0, x [other coordinates fixed]) * first cofactor`.
721
+ This recursion continues until the boundaries are only points, where we can just sum the integrand.
722
+ """
723
+ if not isinstance(args, tuple):
724
+ args = (args,)
725
+ if epsabs is None:
726
+ epsabs = Manifold.minSeparation
727
+ if epsrel is None:
728
+ epsrel = Manifold.minSeparation
729
+
730
+ # Initialize the return value for the integral
731
+ sum = 0.0
732
+
733
+ # Select the first coordinate of an arbitrary point within the volume boundary (the domain of f)
734
+ x0 = self.any_point()[0]
735
+
736
+ for boundary in self.boundaries:
737
+ def domainF(domainPoint):
738
+ evalPoint = np.atleast_1d(domainPoint)
739
+ point = boundary.manifold.evaluate(evalPoint)
740
+
741
+ # fHat passes the scalar given by integrate.quad into the first coordinate of the vector for f.
742
+ def fHat(x):
743
+ evalPoint = np.array(point)
744
+ evalPoint[0] = x
745
+ return f(evalPoint, *args)
746
+
747
+ # Calculate Integral(f) * first cofactor. Note that quad returns a tuple: (integral, error bound).
748
+ returnValue = 0.0
749
+ firstCofactor = boundary.manifold.normal(evalPoint, False, (0,))
750
+ if abs(x0 - point[0]) > epsabs and abs(firstCofactor) > epsabs:
751
+ returnValue = integrate.quad(fHat, x0, point[0], epsabs=epsabs, epsrel=epsrel, *quadArgs)[0] * firstCofactor
752
+ return returnValue
753
+
754
+ if boundary.domain.dimension > 0:
755
+ # Add the contribution to the Volume integral from this boundary.
756
+ sum += boundary.domain.volume_integral(domainF)
757
+ else:
758
+ # This is a 1-D boundary (line interval, no domain), so just add the integrand.
759
+ sum += domainF(0.0)
760
+
761
+ return sum
762
+
763
+ def winding_number(self, point):
764
+ """
765
+ Compute the winding number for a point relative to the solid.
766
+
767
+ Parameters
768
+ ----------
769
+ point : array-like
770
+ A point that may lie within the solid.
771
+
772
+ Returns
773
+ -------
774
+ windingNumber : scalar value
775
+ The `windingNumber` is 0 if the point is outside the solid, 1 if it's inside.
776
+ Other values indicate issues:
777
+ * A point on the boundary leads to an undefined (random) winding number;
778
+ * Boundaries with gaps or overlaps lead to fractional winding numbers;
779
+ * Interior-pointing normals lead to negative winding numbers;
780
+ * Nested shells lead to winding numbers with absolute value 2 or greater.
781
+
782
+ onBoundaryNormal : `numpy.array`
783
+ The boundary normal if the point lies on a boundary, `None` otherwise.
784
+
785
+ See Also
786
+ --------
787
+ `contains_point` : Test if a point lies within the solid.
788
+
789
+ Notes
790
+ -----
791
+ If `onBoundaryNormal` is not `None`, `windingNumber` is undefined and should be ignored.
792
+
793
+ `winding_number` uses two different implementations:
794
+ * A simple fast implementation if the solid is a number line (dimension <= 1). This is the default for dimension <= 1.
795
+ * A surface integral with integrand: `(x - point) / norm(x - point)**dimension`.
796
+ """
797
+ point = np.atleast_1d(point)
798
+ windingNumber = 0.0
799
+ onBoundaryNormal = None
800
+ if self.containsInfinity:
801
+ # If the solid contains infinity, then the winding number starts as 1 to account for the boundary at infinity.
802
+ windingNumber = 1.0
803
+
804
+ if Solid.point_outside_bounds(point, self.bounds):
805
+ return windingNumber, onBoundaryNormal
806
+
807
+ if self.dimension <= 1:
808
+ # Fast winding number calculation for a number line specialized to catch boundary edges.
809
+ for boundary in self.boundaries:
810
+ normal = boundary.manifold.normal(0.0)
811
+ separation = np.dot(normal, boundary.manifold.evaluate(0.0) - point)
812
+ if -2.0 * Manifold.minSeparation < separation < Manifold.minSeparation:
813
+ onBoundaryNormal = normal
814
+ break
815
+ else:
816
+ windingNumber += np.sign(separation) / 2.0
817
+ else:
818
+ # Compute the winding number via a surface integral, normalizing by the hypersphere surface area.
819
+ nSphereArea = 2.0
820
+ if self.dimension % 2 == 0:
821
+ nSphereArea *= np.pi
822
+ dimension = self.dimension
823
+ while dimension > 2:
824
+ nSphereArea *= 2.0 * np.pi / (dimension - 2.0)
825
+ dimension -= 2
826
+
827
+ def windingIntegrand(boundaryPoint, boundaryNormal, onBoundaryNormalList):
828
+ vector = boundaryPoint - point
829
+ vectorLength = np.linalg.norm(vector)
830
+ if vectorLength < Manifold.minSeparation:
831
+ onBoundaryNormalList[0] = boundaryNormal
832
+ vectorLength = 1.0
833
+ return vector / (vectorLength**self.dimension)
834
+
835
+ onBoundaryNormalList = [onBoundaryNormal]
836
+ windingNumber += self.surface_integral(windingIntegrand, onBoundaryNormalList) / nSphereArea
837
+ onBoundaryNormal = onBoundaryNormalList[0]
838
+
839
+ return windingNumber, onBoundaryNormal