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/hyperplane.py ADDED
@@ -0,0 +1,540 @@
1
+ import numpy as np
2
+ from collections import namedtuple
3
+ from bspy.manifold import Manifold
4
+ from bspy.solid import Solid, Boundary
5
+
6
+ @Manifold.register
7
+ class Hyperplane(Manifold):
8
+ """
9
+ A hyperplane is a `Manifold` defined by a unit normal, a point on the hyperplane, and a tangent space orthogonal to the normal.
10
+
11
+ Parameters
12
+ ----------
13
+ normal : array-like
14
+ The unit normal.
15
+
16
+ point : array-like
17
+ A point on the hyperplane.
18
+
19
+ tangentSpace : array-like
20
+ A array of tangents that are linearly independent and orthogonal to the normal.
21
+
22
+ Notes
23
+ -----
24
+ The number of coordinates in the normal defines the dimension of the range of the hyperplane. The point must have the same dimension. The tangent space must be shaped: (dimension, dimension-1).
25
+ Thus the dimension of the domain is one less than that of the range.
26
+ """
27
+
28
+ maxAlignment = 0.99 # 1 - 1/10^2
29
+ """ If a shift of 1 in the normal direction of one manifold yields a shift of 10 in the tangent plane intersection, the manifolds are parallel."""
30
+
31
+ def __init__(self, normal, point, tangentSpace):
32
+ self._normal = np.atleast_1d(np.array(normal))
33
+ self._point = np.atleast_1d(np.array(point))
34
+ self._tangentSpace = np.atleast_1d(np.array(tangentSpace))
35
+ if not np.allclose(self._tangentSpace.T @ self._normal, 0.0): raise ValueError("normal must be orthogonal to tangent space")
36
+
37
+ def __repr__(self):
38
+ return "Hyperplane({0}, {1}, {2})".format(self._normal, self._point, self._tangentSpace)
39
+
40
+ def complete_slice(self, slice, solid):
41
+ """
42
+ Add any missing inherent (implicit) boundaries of this manifold's domain to the given slice of the
43
+ given solid that are needed to make the slice valid and complete.
44
+
45
+ Parameters
46
+ ----------
47
+ slice : `Solid`
48
+ The slice of the given solid formed by the manifold. The slice may be incomplete, missing some of the
49
+ manifold's inherent domain boundaries. Its dimension must match `self.domain_dimension()`.
50
+
51
+ solid : `Solid`
52
+ The solid being sliced by the manifold. Its dimension must match `self.range_dimension()`.
53
+
54
+ Parameters
55
+ ----------
56
+ domain : `Solid`
57
+ A domain for this manifold that may be incomplete, missing some of the manifold's inherent domain boundaries.
58
+ Its dimension must match `self.domain_dimension()`.
59
+
60
+ See Also
61
+ --------
62
+ `Solid.slice` : Slice the solid by a manifold.
63
+
64
+ Notes
65
+ -----
66
+ Since hyperplanes have no inherent domain boundaries, this operation only tests for
67
+ point containment for zero-dimension hyperplanes (points).
68
+ """
69
+ assert self.domain_dimension() == slice.dimension
70
+ assert self.range_dimension() == solid.dimension
71
+ if slice.dimension == 0:
72
+ slice.containsInfinity = solid.contains_point(self._point)
73
+
74
+ def copy(self):
75
+ """
76
+ Copy the hyperplane.
77
+
78
+ Returns
79
+ -------
80
+ hyperplane : `Hyperplane`
81
+ """
82
+ return Hyperplane(self._normal, self._point, self._tangentSpace)
83
+
84
+ @staticmethod
85
+ def create_axis_aligned(dimension, axis, offset, flipNormal=False):
86
+ """
87
+ Create an axis-aligned hyperplane.
88
+
89
+ Parameters
90
+ ----------
91
+ dimension : `int`
92
+ The dimension of the hyperplane.
93
+
94
+ axis : `int`
95
+ The number of the axis (0 for x, 1 for y, ...).
96
+
97
+ offset : `float`
98
+ The offset from zero along the axis of a point on the hyperplane.
99
+
100
+ flipNormal : `bool`, optional
101
+ A Boolean indicating that the normal should point toward in the negative direction along the axis.
102
+ Default is False, meaning the normal points in the positive direction along the axis.
103
+
104
+ Returns
105
+ -------
106
+ hyperplane : `Hyperplane`
107
+ The axis-aligned hyperplane.
108
+ """
109
+ assert dimension > 0
110
+ diagonal = np.identity(dimension)
111
+ sign = -1.0 if flipNormal else 1.0
112
+ normal = sign * diagonal[:,axis]
113
+ point = offset * normal
114
+ if dimension > 1:
115
+ tangentSpace = np.delete(diagonal, axis, axis=1)
116
+ else:
117
+ tangentSpace = np.array([0.0])
118
+
119
+ return Hyperplane(normal, point, tangentSpace)
120
+
121
+ @staticmethod
122
+ def create_hypercube(bounds):
123
+ """
124
+ Create a solid hypercube.
125
+
126
+ Parameters
127
+ ----------
128
+ bounds : array-like
129
+ An array with shape (dimension, 2) of lower and upper and lower bounds for the hypercube.
130
+
131
+ Returns
132
+ -------
133
+ hypercube : `Solid`
134
+ The hypercube.
135
+ """
136
+ bounds = np.array(bounds)
137
+ if len(bounds.shape) != 2 or bounds.shape[1] != 2: raise ValueError("bounds must have shape (dimension, 2)")
138
+ dimension = bounds.shape[0]
139
+ solid = Solid(dimension, False)
140
+ for i in range(dimension):
141
+ if dimension > 1:
142
+ domain = Hyperplane.create_hypercube(np.delete(bounds, i, axis=0))
143
+ else:
144
+ domain = Solid(0, True)
145
+ hyperplane = Hyperplane.create_axis_aligned(dimension, i, -bounds[i][0], True)
146
+ solid.add_boundary(Boundary(hyperplane, domain))
147
+ hyperplane = Hyperplane.create_axis_aligned(dimension, i, bounds[i][1], False)
148
+ solid.add_boundary(Boundary(hyperplane, domain))
149
+
150
+ return solid
151
+
152
+ def domain_dimension(self):
153
+ """
154
+ Return the domain dimension.
155
+
156
+ Returns
157
+ -------
158
+ dimension : `int`
159
+ """
160
+ return len(self._normal) - 1
161
+
162
+ def evaluate(self, domainPoint):
163
+ """
164
+ Return the value of the manifold (a point on the manifold).
165
+
166
+ Parameters
167
+ ----------
168
+ domainPoint : `numpy.array`
169
+ The 1D array at which to evaluate the point.
170
+
171
+ Returns
172
+ -------
173
+ point : `numpy.array`
174
+ """
175
+ return np.dot(self._tangentSpace, domainPoint) + self._point
176
+
177
+ def flip_normal(self):
178
+ """
179
+ Flip the direction of the normal.
180
+
181
+ Returns
182
+ -------
183
+ hyperplane : `Hyperplane`
184
+ The hyperplane with flipped normal. The hyperplane retains the same tangent space.
185
+
186
+ See Also
187
+ --------
188
+ `Solid.complement` : Return the complement of the solid: whatever was inside is outside and vice-versa.
189
+ """
190
+ return Hyperplane(-self._normal, self._point, self._tangentSpace)
191
+
192
+ @staticmethod
193
+ def from_dict(dictionary):
194
+ """
195
+ Create a `Hyperplane` from a data in a `dict`.
196
+
197
+ Parameters
198
+ ----------
199
+ dictionary : `dict`
200
+ The `dict` containing `Hyperplane` data.
201
+
202
+ Returns
203
+ -------
204
+ hyperplane : `hyperplane`
205
+
206
+ See Also
207
+ --------
208
+ `to_dict` : Return a `dict` with `Hyperplane` data.
209
+ """
210
+ return Hyperplane(dictionary["normal"], dictionary["point"], dictionary["tangentSpace"])
211
+
212
+ def full_domain(self):
213
+ """
214
+ Return a solid that represents the full domain of the hyperplane.
215
+
216
+ Returns
217
+ -------
218
+ domain : `Solid`
219
+ The full (untrimmed) domain of the hyperplane.
220
+
221
+ See Also
222
+ --------
223
+ `Boundary` : A portion of the boundary of a solid.
224
+ """
225
+ return Solid(self.domain_dimension(), True)
226
+
227
+ def intersect(self, other):
228
+ """
229
+ Intersect two hyperplanes.
230
+
231
+ Parameters
232
+ ----------
233
+ other : `Hyperplane`
234
+ The `Hyperplane` intersecting the hyperplane.
235
+
236
+ Returns
237
+ -------
238
+ intersections : `list` (or `NotImplemented` if other is not a `Hyperplane`)
239
+ A list of intersections between the two hyperplanes.
240
+ (Hyperplanes will have at most one intersection, but other types of manifolds can have several.)
241
+ Each intersection records either a crossing or a coincident region.
242
+
243
+ For a crossing, intersection is a `Manifold.Crossing`: (left, right)
244
+ * left : `Manifold` in the manifold's domain where the manifold and the other cross.
245
+ * right : `Manifold` in the other's domain where the manifold and the other cross.
246
+ * Both intersection manifolds have the same domain and range (the crossing between the manifold and the other).
247
+
248
+ For a coincident region, intersection is a `Manifold.Coincidence`: (left, right, alignment, transform, inverse, translation)
249
+ * left : `Solid` in the manifold's domain within which the manifold and the other are coincident.
250
+ * right : `Solid` in the other's domain within which the manifold and the other are coincident.
251
+ * alignment : scalar value holding the normal alignment between the manifold and the other (the dot product of their unit normals).
252
+ * transform : `numpy.array` holding the transform matrix from the manifold's domain to the other's domain.
253
+ * inverse : `numpy.array` holding the inverse transform matrix from the other's domain to the boundary's domain.
254
+ * translation : `numpy.array` holding the translation vector from the manifold's domain to the other's domain.
255
+ * Together transform, inverse, and translation form the mapping from the manifold's domain to the other's domain and vice-versa.
256
+
257
+ See Also
258
+ --------
259
+ `Solid.slice` : slice the solid by a manifold.
260
+ `numpy.linalg.svd` : Compute the singular value decomposition of a matrix array.
261
+
262
+ Notes
263
+ -----
264
+ Hyperplanes are parallel when their unit normals are aligned (dot product is nearly 1 or -1). Otherwise, they cross each other.
265
+
266
+ To solve the crossing, we find the intersection by solving the underdetermined system of equations formed by assigning points
267
+ in one hyperplane (`self`) to points in the other (`other`). That is:
268
+ `self._tangentSpace * selfDomainPoint + self._point = other._tangentSpace * otherDomainPoint + other._point`. This system is `dimension` equations
269
+ with `2*(dimension-1)` unknowns (the two domain points).
270
+
271
+ There are more unknowns than equations, so it's underdetermined. The number of free variables is `2*(dimension-1) - dimension = dimension-2`.
272
+ To solve the system, we rephrase it as `Ax = b`, where `A = (self._tangentSpace -other._tangentSpace)`, `x = (selfDomainPoint otherDomainPoint)`,
273
+ and `b = other._point - self._point`. Then we take the singular value decomposition of `A = U * Sigma * VTranspose`, using `numpy.linalg.svd`.
274
+ The particular solution for x is given by `x = V * SigmaInverse * UTranspose * b`,
275
+ where we only consider the first `dimension` number of vectors in `V` (the rest are zeroed out, i.e. the null space of `A`).
276
+ The null space of `A` (the last `dimension-2` vectors in `V`) spans the free variable space, so those vectors form the tangent space of the intersection.
277
+ Remember, we're solving for `x = (selfDomainPoint otherDomainPoint)`. So, the selfDomainPoint is the first `dimension-1` coordinates of `x`,
278
+ and the otherDomainPoint is the last `dimension-1` coordinates of `x`. Likewise for the two tangent spaces.
279
+
280
+ For coincident regions, we need the domains, normal alignment, and mapping from the hyperplane's domain to the other's domain. (The mapping is irrelevant and excluded for dimensions less than 2.)
281
+ We can tell if the two hyperplanes are coincident if their normal alignment (dot product of their unit normals) is nearly 1
282
+ in absolute value (`alignment**2 < Hyperplane.maxAlignment`) and their points are barely separated:
283
+ `-2 * Manifold.minSeparation < dot(hyperplane._normal, hyperplane._point - other._point) < Manifold.minSeparation`. (We give more room
284
+ to the outside than the inside to avoid compounding issues from minute gaps.)
285
+
286
+ Since hyperplanes are flat, the domains of their coincident regions are the entire domain: `Solid(domain dimension, True)`.
287
+ The normal alignment is the dot product of the unit normals. The mapping from the hyperplane's domain to the other's domain is derived
288
+ from setting the hyperplanes to each other:
289
+ `hyperplane._tangentSpace * selfDomainPoint + hyperplane._point = other._tangentSpace * otherDomainPoint + other._point`. Then solve for
290
+ `otherDomainPoint = inverse(transpose(other._tangentSpace) * other._tangentSpace)) * transpose(other._tangentSpace) * (hyperplane._tangentSpace * selfDomainPoint + hyperplane._point - other._point)`.
291
+ You get the transform is `inverse(transpose(other._tangentSpace) * other._tangentSpace)) * transpose(other._tangentSpace) * hyperplane._tangentSpace`,
292
+ and the translation is `inverse(transpose(other._tangentSpace) * other._tangentSpace)) * transpose(other._tangentSpace) * (hyperplane._point - other._point)`.
293
+
294
+ Note that to invert the mapping to go from the other's domain to the hyperplane's domain, you first subtract the translation and then multiply by the inverse of the transform.
295
+ """
296
+ if not isinstance(other, Hyperplane):
297
+ return NotImplemented
298
+ assert self.range_dimension() == other.range_dimension()
299
+
300
+ # Initialize list of intersections. Planar manifolds will have at most one intersection, but curved manifolds could have multiple.
301
+ intersections = []
302
+ dimension = self.range_dimension()
303
+
304
+ # Check if manifolds intersect (are not parallel)
305
+ alignment = np.dot(self._normal, other._normal)
306
+ if alignment * alignment < Hyperplane.maxAlignment:
307
+ # We're solving the system Ax = b using singular value decomposition,
308
+ # where A = (self._tangentSpace -other._tangentSpace), x = (selfDomainPoint otherDomainPoint), and b = other._point - self._point.
309
+ # Construct A.
310
+ A = np.concatenate((self._tangentSpace, -other._tangentSpace),axis=1)
311
+ # Compute the singular value decomposition of A.
312
+ U, sigma, VTranspose = np.linalg.svd(A)
313
+ # Compute the inverse of Sigma and transpose of V.
314
+ SigmaInverse = np.diag(np.reciprocal(sigma))
315
+ V = np.transpose(VTranspose)
316
+ # Compute x = V * SigmaInverse * UTranspose * (other._point - self._point)
317
+ x = V[:, 0:dimension] @ SigmaInverse @ np.transpose(U) @ (other._point - self._point)
318
+
319
+ # The self intersection normal is just the dot product of other normal with the self tangent space.
320
+ selfDomainNormal = np.dot(other._normal, self._tangentSpace)
321
+ selfDomainNormal = selfDomainNormal / np.linalg.norm(selfDomainNormal)
322
+ # The other intersection normal is just the dot product of self normal with the other tangent space.
323
+ otherDomainNormal = np.dot(self._normal, other._tangentSpace)
324
+ otherDomainNormal = otherDomainNormal / np.linalg.norm(otherDomainNormal)
325
+ # The self intersection point is the first dimension-1 coordinates of x.
326
+ selfDomainPoint = x[0:dimension-1]
327
+ # The other intersection point is the last dimension-1 coordinates of x.
328
+ otherDomainPoint = x[dimension-1:]
329
+ if dimension > 2:
330
+ # The self intersection tangent space is the first dimension-1 coordinates of the null space (the last dimension-2 vectors in V).
331
+ selfDomainTangentSpace = V[0:dimension-1, dimension:]
332
+ # The other intersection tangent space is the last dimension-1 coordinates of the null space (the last dimension-2 vectors in V).
333
+ otherDomainTangentSpace = V[dimension-1:, dimension:]
334
+ else:
335
+ # There is no null space (dimension-2 <= 0)
336
+ selfDomainTangentSpace = np.array([0.0])
337
+ otherDomainTangentSpace = np.array([0.0])
338
+ intersections.append(Manifold.Crossing(Hyperplane(selfDomainNormal, selfDomainPoint, selfDomainTangentSpace), Hyperplane(otherDomainNormal, otherDomainPoint, otherDomainTangentSpace)))
339
+
340
+ # Otherwise, manifolds are parallel. Now, check if they are coincident.
341
+ else:
342
+ insideSeparation = np.dot(self._normal, self._point - other._point)
343
+ # Allow for extra outside separation to avoid issues with minute gaps.
344
+ if -2.0 * Hyperplane.minSeparation < insideSeparation < Hyperplane.minSeparation:
345
+ # These hyperplanes are coincident. Return the domains in which they coincide (entire domain for hyperplanes) and the normal alignment.
346
+ domainCoincidence = Solid(dimension-1, True)
347
+ if dimension > 1:
348
+ # For non-zero domains, also return the mapping from the self domain to the other domain.
349
+ tangentSpaceTranspose = np.transpose(other._tangentSpace)
350
+ map = np.linalg.inv(tangentSpaceTranspose @ other._tangentSpace) @ tangentSpaceTranspose
351
+ transform = map @ self._tangentSpace
352
+ inverseTransform = np.linalg.inv(transform)
353
+ translation = map @ (self._point - other._point)
354
+ intersections.append(Manifold.Coincidence(domainCoincidence, domainCoincidence, alignment, transform, inverseTransform, translation))
355
+ else:
356
+ intersections.append(Manifold.Coincidence(domainCoincidence, domainCoincidence, alignment, None, None, None))
357
+
358
+ return intersections
359
+
360
+ def normal(self, domainPoint, normalize=True, indices=None):
361
+ """
362
+ Return the normal.
363
+
364
+ Parameters
365
+ ----------
366
+ domainPoint : `numpy.array`
367
+ The 1D array at which to evaluate the normal.
368
+
369
+ normalize : `boolean`, optional
370
+ If True the returned normal will have unit length (the default). Otherwise, the normal's length will
371
+ be the area of the tangent space (for two independent variables, its the length of the cross product of tangent vectors).
372
+
373
+ indices : `iterable`, optional
374
+ An iterable of normal indices to calculate. For example, `indices=(0, 3)` will return a vector of length 2
375
+ with the first and fourth values of the normal. If `None`, all normal values are returned (the default).
376
+
377
+ Returns
378
+ -------
379
+ normal : `numpy.array`
380
+ """
381
+ if normalize:
382
+ normal = self._normal
383
+ else:
384
+ # Compute and cache cofactor normal on demand.
385
+ if not hasattr(self, '_cofactorNormal'):
386
+ dimension = self.range_dimension()
387
+ if dimension > 1:
388
+ minor = np.zeros((dimension-1, dimension-1))
389
+ self._cofactorNormal = np.array(self._normal) # We change it, so make a copy.
390
+ sign = 1.0
391
+ for i in range(dimension):
392
+ if i > 0:
393
+ minor[0:i, :] = self._tangentSpace[0:i, :]
394
+ if i < dimension - 1:
395
+ minor[i:, :] = self._tangentSpace[i+1:, :]
396
+ self._cofactorNormal[i] = sign * np.linalg.det(minor)
397
+ sign *= -1.0
398
+
399
+ # Ensure cofactorNormal points in the same direction as normal.
400
+ if np.dot(self._cofactorNormal, self._normal) < 0.0:
401
+ self._cofactorNormal = -self._cofactorNormal
402
+ else:
403
+ self._cofactorNormal = self._normal
404
+
405
+ normal = self._cofactorNormal
406
+
407
+ return normal if indices is None else normal[(indices,)]
408
+
409
+ def range_bounds(self):
410
+ """
411
+ Return the range bounds for the hyperplane.
412
+
413
+ Returns
414
+ -------
415
+ rangeBounds : `np.array` or `None`
416
+ The range of the hyperplane given as lower and upper bounds on each dependent variable.
417
+ If the hyperplane has an unbounded range (domain dimension > 0), `None` is returned.
418
+ """
419
+ return None if self.domain_dimension() > 0 else np.array(((self._point[0], self._point[0]),))
420
+
421
+ def range_dimension(self):
422
+ """
423
+ Return the range dimension.
424
+
425
+ Returns
426
+ -------
427
+ dimension : `int`
428
+ """
429
+ return len(self._normal)
430
+
431
+ def tangent_space(self, domainPoint):
432
+ """
433
+ Return the tangent space.
434
+
435
+ Parameters
436
+ ----------
437
+ domainPoint : `numpy.array`
438
+ The 1D array at which to evaluate the tangent space.
439
+
440
+ Returns
441
+ -------
442
+ tangentSpace : `numpy.array`
443
+ """
444
+ return self._tangentSpace
445
+
446
+ def to_dict(self):
447
+ """
448
+ Return a `dict` with `Hyperplane` data.
449
+
450
+ Returns
451
+ -------
452
+ dictionary : `dict`
453
+
454
+ See Also
455
+ --------
456
+ `from_dict` : Create a `Hyperplane` from a data in a `dict`.
457
+ """
458
+ return {"type" : "Hyperplane", "normal" : self._normal, "point" : self._point, "tangentSpace" : self._tangentSpace}
459
+
460
+ def transform(self, matrix, matrixInverseTranspose = None):
461
+ """
462
+ Transform the range of the hyperplane.
463
+
464
+ Parameters
465
+ ----------
466
+ matrix : `numpy.array`
467
+ A square matrix transformation.
468
+
469
+ matrixInverseTranspose : `numpy.array`, optional
470
+ The inverse transpose of matrix (computed if not provided).
471
+
472
+ Returns
473
+ -------
474
+ hyperplane : `Hyperplane`
475
+ The transformed hyperplane.
476
+
477
+ See Also
478
+ --------
479
+ `Solid.transform` : Transform the range of the solid.
480
+ """
481
+ if self.range_dimension() > 1:
482
+ if matrixInverseTranspose is None:
483
+ matrixInverseTranspose = np.transpose(np.linalg.inv(matrix))
484
+
485
+ normal = matrixInverseTranspose @ self._normal
486
+ normal = normal / np.linalg.norm(normal)
487
+ hyperplane = Hyperplane(normal, matrix @ self._point, matrix @ self._tangentSpace)
488
+ else:
489
+ hyperplane = Hyperplane(self._normal, matrix @ self._point, self._tangentSpace)
490
+
491
+ return hyperplane
492
+
493
+ def translate(self, delta):
494
+ """
495
+ translate the range of the hyperplane.
496
+
497
+ Parameters
498
+ ----------
499
+ delta : `numpy.array`
500
+ A 1D array translation.
501
+
502
+ Returns
503
+ -------
504
+ hyperplane : `Hyperplane`
505
+ The translated hyperplane.
506
+
507
+ See Also
508
+ --------
509
+ `Solid.translate` : translate the range of the solid.
510
+ """
511
+ return Hyperplane(self._normal, self._point + delta, self._tangentSpace)
512
+
513
+ def trimmed_range_bounds(self, domainBounds):
514
+ """
515
+ Return the trimmed range bounds for the hyperplane.
516
+
517
+ Parameters
518
+ ----------
519
+ domainBounds : array-like or `None`
520
+ An array with shape (domain_dimension, 2) of lower and upper and lower bounds on each hyperplane parameter.
521
+ If domainBounds is `None` then the hyperplane is unbounded.
522
+
523
+ Returns
524
+ -------
525
+ trimmedManifold, rangeBounds : `Hyperplane`, `np.array` (or None)
526
+ A manifold trimmed to the given domain bounds, and the range of the trimmed hyperplane given as
527
+ lower and upper bounds on each dependent variable. If the domain bounds are `None` (meaning unbounded)
528
+ then rangeBounds is `None`.
529
+
530
+ Notes
531
+ -----
532
+ The returned trimmed manifold is the original hyperplane (no changes).
533
+ """
534
+ if domainBounds is None:
535
+ return self, self.range_bounds()
536
+ else:
537
+ domainBounds = np.atleast_1d(domainBounds)
538
+ rangeBounds = [[value.min(), value.max()] for value in
539
+ self._tangentSpace @ domainBounds + self._point.reshape(self._point.shape[0], 1)]
540
+ return self, np.array(rangeBounds, self._normal.dtype)