adaptive/learner/triangulation.py
3f617442
 from collections import Counter
176c745c
 from collections.abc import Iterable, Sized
 from itertools import chain, combinations
 from math import factorial
8ab9b762
 import scipy.spatial
3a985b30
 
571850ce
 from numpy import square, zeros, subtract, array, ones, dot, asarray, concatenate, average, eye, mean, abs, sqrt
 from numpy import sum as nsum
 from numpy.linalg import det as ndet
 from numpy.linalg import slogdet, solve, matrix_rank, norm
 
0cb79900
 
ae0f30db
 def fast_norm(v):
67f564e3
     """ Manually take the vector norm for len 2, 3 vectors. Defaults to a square root of the dot product
     for larger vectors. 
     
     Note that for large vectors, it is possible for integer overflow to occur. 
     For instance:
     vec = [49024, 59454, 12599, -63721, 18517, 27961]
     dot(vec, vec) = -1602973744
     
     """
     len_v = len(v)
ae0f30db
     # notice this method can be even more optimised
67f564e3
     if len_v == 2:
571850ce
         return sqrt(v[0] * v[0] + v[1] * v[1])
67f564e3
     if len_v == 3:
571850ce
         return sqrt(v[0] * v[0] + v[1] * v[1] + v[2] * v[2])
     return sqrt(dot(v, v))
0cb79900
 
ae0f30db
 
12816a92
 def fast_2d_point_in_simplex(point, simplex, eps=1e-8):
02e8d766
     (p0x, p0y), (p1x, p1y), (p2x, p2y) = simplex
12816a92
     px, py = point
 
716dbce8
     area = 0.5 * (-p1y * p2x + p0y * (p2x - p1x) + p1x * p2y + p0x * (p1y - p2y))
12816a92
 
716dbce8
     s = 1 / (2 * area) * (+p0y * p2x + (p2y - p0y) * px - p0x * p2y + (p0x - p2x) * py)
98f2f23b
     if s < -eps or s > 1 + eps:
22ae0755
         return False
716dbce8
     t = 1 / (2 * area) * (+p0x * p1y + (p0y - p1y) * px - p0y * p1x + (p1x - p0x) * py)
12816a92
 
98f2f23b
     return (t >= -eps) and (s + t <= 1 + eps)
12816a92
 
 
d1df8944
 def point_in_simplex(point, simplex, eps=1e-8):
571850ce
     # simplex is list
d1df8944
     if len(point) == 2:
         return fast_2d_point_in_simplex(point, simplex, eps)
 
571850ce
     x0 = array(simplex[0], dtype=float)
     vectors = array(simplex[1:], dtype=float) - x0
     alpha = solve(vectors.T, point - x0)
d1df8944
 
     return all(alpha > -eps) and sum(alpha) < 1 + eps
 
 
906058ff
 def fast_2d_circumcircle(points):
98f2f23b
     """Compute the center and radius of the circumscribed circle of a triangle
d8e4af90
 
     Parameters
     ----------
9631afec
     points: 2D array-like
         the points of the triangle to investigate
d8e4af90
 
     Returns
     -------
9631afec
     tuple
98f2f23b
         (center point : tuple(int), radius: int)
906058ff
     """
571850ce
     points = array(points)
3a985b30
     # transform to relative coordinates
906058ff
     pts = points[1:] - points[0]
02e8d766
 
     (x1, y1), (x2, y2) = pts
3a985b30
     # compute the length squared
98f2f23b
     l1 = x1 * x1 + y1 * y1
     l2 = x2 * x2 + y2 * y2
906058ff
 
3a985b30
     # compute some determinants
716dbce8
     dx = +l1 * y2 - l2 * y1
     dy = -l1 * x2 + l2 * x1
     aa = +x1 * y2 - x2 * y1
906058ff
     a = 2 * aa
 
3a985b30
     # compute center
98f2f23b
     x = dx / a
     y = dy / a
571850ce
     radius = sqrt(x * x + y * y)  # radius = norm([x, y])
906058ff
 
3a985b30
     return (x + points[0][0], y + points[0][1]), radius
906058ff
 
 
 def fast_3d_circumcircle(points):
98f2f23b
     """Compute the center and radius of the circumscribed shpere of a simplex.
fd3784c0
 
     Parameters
     ----------
9631afec
     points: 2D array-like
         the points of the triangle to investigate
fd3784c0
 
     Returns
     -------
9631afec
     tuple
98f2f23b
         (center point : tuple(int), radius: int)
906058ff
     """
571850ce
     points = array(points)
906058ff
     pts = points[1:] - points[0]
02e8d766
 
     (x1, y1, z1), (x2, y2, z2), (x3, y3, z3) = pts
906058ff
 
bf4d6422
     l1 = x1 * x1 + y1 * y1 + z1 * z1
     l2 = x2 * x2 + y2 * y2 + z2 * z2
     l3 = x3 * x3 + y3 * y3 + z3 * z3
 
906058ff
     # Compute some determinants:
716dbce8
     dx = +l1 * (y2 * z3 - z2 * y3) - l2 * (y1 * z3 - z1 * y3) + l3 * (y1 * z2 - z1 * y2)
     dy = +l1 * (x2 * z3 - z2 * x3) - l2 * (x1 * z3 - z1 * x3) + l3 * (x1 * z2 - z1 * x2)
     dz = +l1 * (x2 * y3 - y2 * x3) - l2 * (x1 * y3 - y1 * x3) + l3 * (x1 * y2 - y1 * x2)
     aa = +x1 * (y2 * z3 - z2 * y3) - x2 * (y1 * z3 - z1 * y3) + x3 * (y1 * z2 - z1 * y2)
98f2f23b
     a = 2 * aa
906058ff
 
bf4d6422
     center = (dx / a, -dy / a, dz / a)
ae0f30db
     radius = fast_norm(center)
716dbce8
     center = (
         center[0] + points[0][0],
         center[1] + points[0][1],
         center[2] + points[0][2],
     )
906058ff
 
bf4d6422
     return center, radius
906058ff
 
 
ae6af683
 def fast_det(matrix):
571850ce
     matrix = asarray(matrix, dtype=float)
ae6af683
     if matrix.shape == (2, 2):
         return matrix[0][0] * matrix[1][1] - matrix[1][0] * matrix[0][1]
     elif matrix.shape == (3, 3):
         a, b, c, d, e, f, g, h, i = matrix.ravel()
716dbce8
         return a * (e * i - f * h) - b * (d * i - f * g) + c * (d * h - e * g)
ae6af683
     else:
571850ce
         return ndet(matrix)
ae6af683
 
 
d1df8944
 def circumsphere(pts):
     dim = len(pts) - 1
     if dim == 2:
         return fast_2d_circumcircle(pts)
     if dim == 3:
         return fast_3d_circumcircle(pts)
 
     # Modified method from http://mathworld.wolfram.com/Circumsphere.html
571850ce
     mat = array([[nsum(square(pt)), *pt, 1] for pt in pts])
     center = zeros(dim)
     a = 1 / (2 * ndet(mat[:, 1:]))
     factor = a
     ind = ones((dim + 2,), bool)
d1df8944
     for i in range(1, len(pts)):
571850ce
         ind[i - 1] = True
         ind[i] = False
         center[i - 1] = factor * ndet(mat[:, ind])
         factor *= -1
d1df8944
 
     x0 = pts[0]
571850ce
     vec = subtract(center, x0)
     radius = sqrt(dot(vec, vec))
d1df8944
 
     return tuple(center), radius
 
 
95e09758
 def orientation(face, origin):
98f2f23b
     """Compute the orientation of the face with respect to a point, origin.
95e09758
 
     Parameters
     ----------
0cb79900
     face : array-like, of shape (N-dim, N-dim)
95e09758
         The hyperplane we want to know the orientation of
         Do notice that the order in which you provide the points is critical
0cb79900
     origin : array-like, point of shape (N-dim)
95e09758
         The point to compute the orientation from
 
     Returns
     -------
9631afec
     0 if the origin lies in the same hyperplane as face,
     -1 or 1 to indicate left or right orientation
95e09758
 
9631afec
     If two points lie on the same side of the face, the orientation will
     be equal, if they lie on the other side of the face, it will be negated.
95e09758
     """
571850ce
     vectors = array(face)
     sign, logdet = slogdet(vectors - origin)
95e09758
     if logdet < -50:  # assume it to be zero when it's close to zero
         return 0
     return sign
0d651f85
 
 
b02c5eb5
 def is_iterable_and_sized(obj):
     return isinstance(obj, Iterable) and isinstance(obj, Sized)
d5c3cbdd
 
 
e5f3deb7
 def simplex_volume_in_embedding(vertices) -> float:
     """Calculate the volume of a simplex in a higher dimensional embedding.
2bbad875
     That is: dim > len(vertices) - 1. For example if you would like to know the
e5f3deb7
     surface area of a triangle in a 3d space.
 
50295475
     This algorithm has not been tested for numerical stability.
 
e5f3deb7
     Parameters
     ----------
50295475
     vertices : 2D arraylike of floats
e5f3deb7
 
     Returns
     -------
50295475
     volume : int
e5f3deb7
         the volume of the simplex with given vertices.
 
     Raises
     ------
50295475
     ValueError
e5f3deb7
         if the vertices do not form a simplex (for example,
         because they are coplanar, colinear or coincident).
     """
     # Implements http://mathworld.wolfram.com/Cayley-MengerDeterminant.html
     # Modified from https://codereview.stackexchange.com/questions/77593/calculating-the-volume-of-a-tetrahedron
 
571850ce
     vertices = asarray(vertices, dtype=float)
ca15e808
     dim = len(vertices[0])
     if dim == 2:
         # Heron's formula
716dbce8
         a, b, c = scipy.spatial.distance.pdist(vertices, metric="euclidean")
ca15e808
         s = 0.5 * (a + b + c)
571850ce
         return sqrt(s * (s - a) * (s - b) * (s - c))
ca15e808
 
     # β_ij = |v_i - v_k|²
716dbce8
     sq_dists = scipy.spatial.distance.pdist(vertices, metric="sqeuclidean")
e5f3deb7
 
     # Add border while compressed
     num_verts = scipy.spatial.distance.num_obs_y(sq_dists)
571850ce
     bordered = concatenate((ones(num_verts), sq_dists))
e5f3deb7
 
     # Make matrix and find volume
     sq_dists_mat = scipy.spatial.distance.squareform(bordered)
 
c60d8ca7
     coeff = -((-2) ** (num_verts - 1)) * factorial(num_verts - 1) ** 2
ae6af683
     vol_square = fast_det(sq_dists_mat) / coeff
e5f3deb7
 
0360e795
     if vol_square < 0:
         if abs(vol_square) < 1e-15:
             return 0
716dbce8
         raise ValueError("Provided vertices do not form a simplex")
e5f3deb7
 
571850ce
     return sqrt(vol_square)
e5f3deb7
 
 
0d651f85
 class Triangulation:
0cb79900
     """A triangulation object.
0d651f85
 
0cb79900
     Parameters
     ----------
     coords : 2d array-like of floats
8ab9b762
         Coordinates of vertices.
0d651f85
 
0cb79900
     Attributes
     ----------
     vertices : list of float tuples
         Coordinates of the triangulation vertices.
     simplices : set of integer tuples
         List with indices of vertices forming individual simplices
f7fd43ad
     vertex_to_simplices : list of sets
         Set of simplices connected to a vertex, the index of the vertex is the
         index of the list.
0cb79900
     hull : set of int
         Exterior vertices
8ab9b762
 
     Raises
     ------
     ValueError
2bbad875
         if the list of coordinates is incorrect or the points do not form one
         or more simplices in the
0cb79900
     """
 
d5c3cbdd
     def __init__(self, coords):
b02c5eb5
         if not is_iterable_and_sized(coords):
c2b2487d
             raise TypeError("Please provide a 2-dimensional list of points")
d5c3cbdd
         coords = list(coords)
b02c5eb5
         if not all(is_iterable_and_sized(coord) for coord in coords):
c2b2487d
             raise TypeError("Please provide a 2-dimensional list of points")
e83a8986
         if len(coords) == 0:
2bbad875
             raise ValueError("Please provide at least one simplex")
e83a8986
             # raise now because otherwise the next line will raise a less
237409b9
 
0d651f85
         dim = len(coords[0])
         if any(len(coord) != dim for coord in coords):
             raise ValueError("Coordinates dimension mismatch")
 
237409b9
         if dim == 1:
             raise ValueError("Triangulation class only supports dim >= 2")
 
b8ff5c97
         if len(coords) < dim + 1:
e83a8986
             raise ValueError("Please provide at least one simplex")
0af50676
 
d5c3cbdd
         coords = list(map(tuple, coords))
571850ce
         vectors = subtract(coords[1:], coords[0])
         if matrix_rank(vectors) < dim:
716dbce8
             raise ValueError(
                 "Initial simplex has zero volumes "
                 "(the points are linearly dependent)"
             )
ce9c74a4
 
0d651f85
         self.vertices = list(coords)
         self.simplices = set()
f7fd43ad
         # initialise empty set for each vertex
         self.vertex_to_simplices = [set() for _ in coords]
8ab9b762
 
2bbad875
         # find a Delaunay triangulation to start with, then we will throw it
8ab9b762
         # away and continue with our own algorithm
         initial_tri = scipy.spatial.Delaunay(coords)
         for simplex in initial_tri.simplices:
             self.add_simplex(simplex)
0d651f85
 
     def delete_simplex(self, simplex):
         simplex = tuple(sorted(simplex))
         self.simplices.remove(simplex)
         for vertex in simplex:
             self.vertex_to_simplices[vertex].remove(simplex)
 
     def add_simplex(self, simplex):
         simplex = tuple(sorted(simplex))
         self.simplices.add(simplex)
         for vertex in simplex:
             self.vertex_to_simplices[vertex].add(simplex)
 
777b2459
     def get_vertices(self, indices):
01fb1201
         return [self.get_vertex(i) for i in indices]
 
     def get_vertex(self, index):
         if index is None:
             return None
         return self.vertices[index]
7b329bc9
 
f7fd43ad
     def get_reduced_simplex(self, point, simplex, eps=1e-8) -> list:
0d651f85
         """Check whether vertex lies within a simplex.
 
         Returns
         -------
         vertices : list of ints
07ce0505
             Indices of vertices of the simplex to which the vertex belongs.
             An empty list indicates that the vertex is outside the simplex.
0d651f85
         """
98f2f23b
         # XXX: in the end we want to lose this method
0d651f85
         if len(simplex) != self.dim + 1:
             # We are checking whether point belongs to a face.
             simplex = self.containing(simplex).pop()
571850ce
         x0 = array(self.vertices[simplex[0]])
         vectors = array(self.get_vertices(simplex[1:])) - x0
         alpha = solve(vectors.T, point - x0)
0d651f85
         if any(alpha < -eps) or sum(alpha) > 1 + eps:
             return []
ae0f30db
 
         result = [i for i, a in enumerate(alpha, 1) if a > eps]
         if sum(alpha) < 1 - eps:
             result.insert(0, 0)
 
0d651f85
         return [simplex[i] for i in result]
 
beee622f
     def point_in_simplex(self, point, simplex, eps=1e-8):
d1df8944
         vertices = self.get_vertices(simplex)
         return point_in_simplex(point, vertices, eps)
12816a92
 
0d651f85
     def locate_point(self, point):
         """Find to which simplex the point belongs.
 
         Return indices of the simplex containing the point.
         Empty tuple means the point is outside the triangulation
         """
         for simplex in self.simplices:
beee622f
             if self.point_in_simplex(point, simplex):
12816a92
                 return simplex
0d651f85
         return ()
 
     @property
     def dim(self):
         return len(self.vertices[0])
 
     def faces(self, dim=None, simplices=None, vertices=None):
         """Iterator over faces of a simplex or vertex sequence."""
         if dim is None:
             dim = self.dim
 
         if simplices is not None and vertices is not None:
             raise ValueError("Only one of simplices and vertices is allowed.")
         if vertices is not None:
             vertices = set(vertices)
             simplices = chain(*(self.vertex_to_simplices[i] for i in vertices))
             simplices = set(simplices)
         elif simplices is None:
             simplices = self.simplices
 
716dbce8
         faces = (face for tri in simplices for face in combinations(tri, dim))
0d651f85
 
         if vertices is not None:
             return (face for face in faces if all(i in vertices for i in face))
         else:
             return faces
 
     def containing(self, face):
         """Simplices containing a face."""
         return set.intersection(*(self.vertex_to_simplices[i] for i in face))
 
725886b8
     def _extend_hull(self, new_vertex, eps=1e-8):
50a53e4f
         # count multiplicities in order to get all hull faces
         multiplicities = Counter(face for face in self.faces())
         hull_faces = [face for face, count in multiplicities.items() if count == 1]
7d8a8523
 
c3b85d7d
         # compute the center of the convex hull, this center lies in the hull
         # we do not really need the center, we only need a point that is
         # guaranteed to lie strictly within the hull
7b329bc9
         hull_points = self.get_vertices(self.hull)
571850ce
         pt_center = average(hull_points, axis=0)
c3b85d7d
 
0d651f85
         pt_index = len(self.vertices)
         self.vertices.append(new_vertex)
de643c2c
 
         new_simplices = set()
0d651f85
         for face in hull_faces:
92eca331
             # do orientation check, if orientation is the same, it lies on
             # the same side of the face, otherwise, it lies on the other
             # side of the face
7b329bc9
             pts_face = tuple(self.get_vertices(face))
92eca331
             orientation_inside = orientation(pts_face, pt_center)
             orientation_new_point = orientation(pts_face, new_vertex)
             if orientation_inside == -orientation_new_point:
                 # if the orientation of the new vertex is zero or directed
                 # towards the center, do not add the simplex
381cedb9
                 simplex = (*face, pt_index)
                 if not self._simplex_is_almost_flat(simplex):
                     self.add_simplex(simplex)
                     new_simplices.add(simplex)
0d651f85
 
de643c2c
         if len(new_simplices) == 0:
0d651f85
             # We tried to add an internal point, revert and raise.
             for tri in self.vertex_to_simplices[pt_index]:
                 self.simplices.remove(tri)
             del self.vertex_to_simplices[pt_index]
             del self.vertices[pt_index]
             raise ValueError("Candidate vertex is inside the hull.")
 
de643c2c
         return new_simplices
e9476950
 
ae0f30db
     def circumscribed_circle(self, simplex, transform):
98f2f23b
         """Compute the center and radius of the circumscribed circle of a simplex.
9631afec
 
         Parameters
         ----------
         simplex : tuple of ints
             the simplex to investigate
 
         Returns
         -------
98f2f23b
         tuple (center point, radius)
9631afec
             The center and radius of the circumscribed circle
692f8b4e
         """
571850ce
         pts = dot(self.get_vertices(simplex), transform)
d1df8944
         return circumsphere(pts)
692f8b4e
 
ae0f30db
     def point_in_cicumcircle(self, pt_index, simplex, transform):
         # return self.fast_point_in_circumcircle(pt_index, simplex, transform)
725886b8
         eps = 1e-8
 
ae0f30db
         center, radius = self.circumscribed_circle(simplex, transform)
571850ce
         pt = dot(self.get_vertices([pt_index]), transform)[0]
777b2459
 
571850ce
         return norm(center - pt) < (radius * (1 + eps))
725886b8
 
     @property
ae0f30db
     def default_transform(self):
571850ce
         return eye(self.dim)
725886b8
 
ae0f30db
     def bowyer_watson(self, pt_index, containing_simplex=None, transform=None):
98f2f23b
         """Modified Bowyer-Watson point adding algorithm.
3976816c
 
98f2f23b
         Create a hole in the triangulation around the new point,
         then retriangulate this hole.
3976816c
 
0ccade4a
         Parameters
         ----------
9631afec
         pt_index: number
             the index of the point to inspect
0ccade4a
 
         Returns
         -------
9631afec
         deleted_simplices : set of tuples
             Simplices that have been deleted
         new_simplices : set of tuples
             Simplices that have been added
692f8b4e
         """
3976816c
         queue = set()
         done_simplices = set()
692f8b4e
 
ae0f30db
         transform = self.default_transform if transform is None else transform
725886b8
 
0af50676
         if containing_simplex is None:
             queue.update(self.vertex_to_simplices[pt_index])
         else:
             queue.add(containing_simplex)
 
3976816c
         bad_triangles = set()
 
         while len(queue):
             simplex = queue.pop()
             done_simplices.add(simplex)
 
ae0f30db
             if self.point_in_cicumcircle(pt_index, simplex, transform):
3976816c
                 self.delete_simplex(simplex)
695d4e09
                 todo_points = set(simplex)
3976816c
                 bad_triangles.add(simplex)
 
1e33f914
                 # Get all simplices that share at least a point with the simplex
01fb1201
                 neighbors = self.get_neighbors_from_vertices(todo_points)
1e33f914
                 # Filter out the already evaluated simplices
01fb1201
                 neighbors = neighbors - done_simplices
                 neighbors = self.get_face_sharing_neighbors(neighbors, simplex)
                 queue.update(neighbors)
3976816c
 
         faces = list(self.faces(simplices=bad_triangles))
 
         multiplicities = Counter(face for face in faces)
22ae0755
         hole_faces = [face for face in faces if multiplicities[face] < 2]
3976816c
 
         for face in hole_faces:
             if pt_index not in face:
381cedb9
                 simplex = (*face, pt_index)
                 if not self._simplex_is_almost_flat(simplex):
                     self.add_simplex(simplex)
692f8b4e
 
4c7dbeeb
         new_triangles = self.vertex_to_simplices[pt_index]
         return bad_triangles - new_triangles, new_triangles - bad_triangles
692f8b4e
 
381cedb9
     def _simplex_is_almost_flat(self, simplex):
         return self._relative_volume(simplex) < 1e-8
 
     def _relative_volume(self, simplex):
         """Compute the volume of a simplex divided by the average (Manhattan)
         distance of its vertices. The advantage of this is that the relative
         volume is only dependent on the shape of the simplex and not on the
         absolute size. Due to the weird scaling, the only use of this method
         is to check that a simplex is almost flat."""
571850ce
         vertices = array(self.get_vertices(simplex))
381cedb9
         vectors = vertices[1:] - vertices[0]
571850ce
         average_edge_length = mean(abs(vectors))
381cedb9
         return self.volume(simplex) / (average_edge_length ** self.dim)
 
ae0f30db
     def add_point(self, point, simplex=None, transform=None):
0d651f85
         """Add a new vertex and create simplices as appropriate.
 
         Parameters
         ----------
         point : float vector
             Coordinates of the point to be added.
ae0f30db
         transform : N*N matrix of floats
725886b8
             Multiplication matrix to apply to the point (and neighbouring
             simplices) when running the Bowyer Watson method.
0d651f85
         simplex : tuple of ints, optional
             Simplex containing the point. Empty tuple indicates points outside
             the hull. If not provided, the algorithm costs O(N), so this should
             be used whenever possible.
         """
12816a92
         point = tuple(point)
0d651f85
         if simplex is None:
             simplex = self.locate_point(point)
 
0af50676
         actual_simplex = simplex
f7fd43ad
         self.vertex_to_simplices.append(set())
0af50676
 
0d651f85
         if not simplex:
4c7dbeeb
             temporary_simplices = self._extend_hull(point)
3976816c
 
             pt_index = len(self.vertices) - 1
716dbce8
             deleted_simplices, added_simplices = self.bowyer_watson(
                 pt_index, transform=transform
             )
4c7dbeeb
 
             deleted = deleted_simplices - temporary_simplices
             added = added_simplices | (temporary_simplices - deleted_simplices)
             return deleted, added
0d651f85
         else:
d84eed2d
             reduced_simplex = self.get_reduced_simplex(point, simplex)
0d651f85
             if not reduced_simplex:
c8120cad
                 self.vertex_to_simplices.pop()  # revert adding vertex
716dbce8
                 raise ValueError("Point lies outside of the specified simplex.")
0d651f85
             else:
                 simplex = reduced_simplex
 
         if len(simplex) == 1:
f7fd43ad
             self.vertex_to_simplices.pop()  # revert adding vertex
0d651f85
             raise ValueError("Point already in triangulation.")
         else:
0af50676
             pt_index = len(self.vertices)
             self.vertices.append(point)
ae0f30db
             return self.bowyer_watson(pt_index, actual_simplex, transform)
0d651f85
 
     def volume(self, simplex):
571850ce
         prefactor = factorial(self.dim)
         vertices = array(self.get_vertices(simplex))
7b329bc9
         vectors = vertices[1:] - vertices[0]
ae6af683
         return float(abs(fast_det(vectors)) / prefactor)
0d651f85
 
98f2f23b
     def volumes(self):
         return [self.volume(sim) for sim in self.simplices]
 
0d651f85
     def reference_invariant(self):
         """vertex_to_simplices and simplices are compatible."""
         for vertex in range(len(self.vertices)):
716dbce8
             if any(vertex not in tri for tri in self.vertex_to_simplices[vertex]):
0d651f85
                 return False
         for simplex in self.simplices:
716dbce8
             if any(simplex not in self.vertex_to_simplices[pt] for pt in simplex):
0d651f85
                 return False
         return True
 
     def vertex_invariant(self, vertex):
         """Simplices originating from a vertex don't overlap."""
         raise NotImplementedError
 
01fb1201
     def get_neighbors_from_vertices(self, simplex):
716dbce8
         return set.union(*[self.vertex_to_simplices[p] for p in simplex])
01fb1201
 
     def get_face_sharing_neighbors(self, neighbors, simplex):
         """Keep only the simplices sharing a whole face with simplex."""
716dbce8
         return {
             simpl for simpl in neighbors if len(set(simpl) & set(simplex)) == self.dim
         }  # they share a face
01fb1201
 
     def get_simplices_attached_to_points(self, indices):
         # Get all simplices that share at least a point with the simplex
         neighbors = self.get_neighbors_from_vertices(indices)
         return self.get_face_sharing_neighbors(neighbors, indices)
 
     def get_opposing_vertices(self, simplex):
         if simplex not in self.simplices:
             raise ValueError("Provided simplex is not part of the triangulation")
         neighbors = self.get_simplices_attached_to_points(simplex)
716dbce8
 
01fb1201
         def find_opposing_vertex(vertex):
             # find the simplex:
             simp = next((x for x in neighbors if vertex not in x), None)
             if simp is None:
                 return None
             opposing = set(simp) - set(simplex)
             assert len(opposing) == 1
             return opposing.pop()
716dbce8
 
01fb1201
         result = tuple(find_opposing_vertex(v) for v in simplex)
         return result
 
50a53e4f
     @property
     def hull(self):
         """Compute hull from triangulation.
0d651f85
 
         Parameters
         ----------
eef11f0b
         check : bool, default: True
0d651f85
             Whether to raise an error if the computed hull is different from
             stored.
 
         Returns
         -------
         hull : set of int
             Vertices in the hull.
         """
         counts = Counter(self.faces())
         if any(i > 2 for i in counts.values()):
716dbce8
             raise RuntimeError(
                 "Broken triangulation, a (N-1)-dimensional"
                 " appears in more than 2 simplices."
             )
0d651f85
 
716dbce8
         hull = {point for face, count in counts.items() if count == 1 for point in face}
0d651f85
         return hull
 
     def convex_invariant(self, vertex):
         """Hull is convex."""
         raise NotImplementedError