Plot 3D convex closed regions in matplotlib
Pretty sure there is nothing native in matplotlib. Finding the faces that belong together is not particularly hard, though. The basic idea implemented below is that you create a graph, where each node is a triangle. You then connect triangles that are co-planar and adjacent. Finally, you find the connected components of the graph to determine which triangles form a face.
import numpy as npfrom sympy import Plane, Point3Dimport networkx as nxdef simplify(triangles): """ Simplify an iterable of triangles such that adjacent and coplanar triangles form a single face. Each triangle is a set of 3 points in 3D space. """ # create a graph in which nodes represent triangles; # nodes are connected if the corresponding triangles are adjacent and coplanar G = nx.Graph() G.add_nodes_from(range(len(triangles))) for ii, a in enumerate(triangles): for jj, b in enumerate(triangles): if (ii < jj): # test relationships only in one way as adjacency and co-planarity are bijective if is_adjacent(a, b): if is_coplanar(a, b, np.pi / 180.): G.add_edge(ii,jj) # triangles that belong to a connected component can be combined components = list(nx.connected_components(G)) simplified = [set(flatten(triangles[index] for index in component)) for component in components] # need to reorder nodes so that patches are plotted correctly reordered = [reorder(face) for face in simplified] return reordereddef is_adjacent(a, b): return len(set(a) & set(b)) == 2 # i.e. triangles share 2 points and hence a sidedef is_coplanar(a, b, tolerance_in_radians=0): a1, a2, a3 = a b1, b2, b3 = b plane_a = Plane(Point3D(a1), Point3D(a2), Point3D(a3)) plane_b = Plane(Point3D(b1), Point3D(b2), Point3D(b3)) if not tolerance_in_radians: # only accept exact results return plane_a.is_coplanar(plane_b) else: angle = plane_a.angle_between(plane_b).evalf() angle %= np.pi # make sure that angle is between 0 and np.pi return (angle - tolerance_in_radians <= 0.) or \ ((np.pi - angle) - tolerance_in_radians <= 0.)flatten = lambda l: [item for sublist in l for item in sublist]def reorder(vertices): """ Reorder nodes such that the resulting path corresponds to the "hull" of the set of points. Note: ----- Not tested on edge cases, and likely to break. Probably only works for convex shapes. """ if len(vertices) <= 3: # just a triangle return vertices else: # take random vertex (here simply the first) reordered = [vertices.pop()] # get next closest vertex that is not yet reordered # repeat until only one vertex remains in original list vertices = list(vertices) while len(vertices) > 1: idx = np.argmin(get_distance(reordered[-1], vertices)) v = vertices.pop(idx) reordered.append(v) # add remaining vertex to output reordered += vertices return reordereddef get_distance(v1, v2): v2 = np.array(list(v2)) difference = v2 - v1 ssd = np.sum(difference**2, axis=1) return np.sqrt(ssd)
Applied to your example:
from scipy.spatial import HalfspaceIntersectionfrom scipy.spatial import ConvexHullimport scipy as spimport numpy as npimport matplotlib.pyplot as pltimport mpl_toolkits.mplot3d as a3import matplotlib.colors as colorsw = np.array([1., 1., 1.])# ∑ᵢ hᵢ wᵢ qᵢ - ∑ᵢ gᵢ wᵢ <= 0# qᵢ - ubᵢ <= 0# -qᵢ + lbᵢ <= 0halfspaces = np.array([ [1.*w[0], 1.*w[1], 1.*w[2], -10 ], [ 1., 0., 0., -4], [ 0., 1., 0., -4], [ 0., 0., 1., -4], [-1., 0., 0., 0], [ 0., -1., 0., 0], [ 0., 0., -1., 0] ])feasible_point = np.array([0.1, 0.1, 0.1])hs = HalfspaceIntersection(halfspaces, feasible_point)verts = hs.intersectionshull = ConvexHull(verts)faces = hull.simplicesax = a3.Axes3D(plt.figure())ax.dist=10ax.azim=30ax.elev=10ax.set_xlim([0,5])ax.set_ylim([0,5])ax.set_zlim([0,5])triangles = []for s in faces: sq = [ (verts[s[0], 0], verts[s[0], 1], verts[s[0], 2]), (verts[s[1], 0], verts[s[1], 1], verts[s[1], 2]), (verts[s[2], 0], verts[s[2], 1], verts[s[2], 2]) ] triangles.append(sq)new_faces = simplify(triangles)for sq in new_faces: f = a3.art3d.Poly3DCollection([sq]) f.set_color(colors.rgb2hex(sp.rand(3))) f.set_edgecolor('k') f.set_alpha(0.1) ax.add_collection3d(f)# # rotate the axes and update# for angle in range(0, 360):# ax.view_init(30, angle)# plt.draw()# plt.pause(.001)
Note
Upon reflection, the function reordered
probably needs some more work. Pretty sure this will break for weird / non-convex shapes, and I am not even 100% sure that it will always work for convex shapes. Rest should be fine though.
The following would be my version of a solution. It is similar to @Paul's solution in that it takes the triangles, groups them by face they belong to and joins them to a single face.
The difference would mainly be that this solution does not use nx
or simpy
. Many of the necessary operations are performed by reindexing, extensive use of unique
and some linear algebra.
The order of the vertices of the final faces is determined by ConvexHull
. I think this should not be a limitation, as (I think that) any half space intersection should result in convex shapes only. However, I also added another method which can be used if the shapes are not convex (based on the idea from this question).
from scipy.spatial import HalfspaceIntersectionfrom scipy.spatial import ConvexHullimport numpy as npimport matplotlib.pyplot as pltimport mpl_toolkits.mplot3d as a3w = np.array([1., 1., 1.])# ∑ᵢ hᵢ wᵢ qᵢ - ∑ᵢ gᵢ wᵢ <= 0 # qᵢ - ubᵢ <= 0# -qᵢ + lbᵢ <= 0 halfspaces = np.array([ [1.*w[0], 1.*w[1], 1.*w[2], -10 ], [ 1., 0., 0., -4], [ 0., 1., 0., -4], [ 0., 0., 1., -4], [-1., 0., 0., 0], [ 0., -1., 0., 0], [ 0., 0., -1., 0] ])feasible_point = np.array([0.1, 0.1, 0.1])hs = HalfspaceIntersection(halfspaces, feasible_point)verts = hs.intersectionshull = ConvexHull(verts)simplices = hull.simplicesorg_triangles = [verts[s] for s in simplices]class Faces(): def __init__(self,tri, sig_dig=12, method="convexhull"): self.method=method self.tri = np.around(np.array(tri), sig_dig) self.grpinx = list(range(len(tri))) norms = np.around([self.norm(s) for s in self.tri], sig_dig) _, self.inv = np.unique(norms,return_inverse=True, axis=0) def norm(self,sq): cr = np.cross(sq[2]-sq[0],sq[1]-sq[0]) return np.abs(cr/np.linalg.norm(cr)) def isneighbor(self, tr1,tr2): a = np.concatenate((tr1,tr2), axis=0) return len(a) == len(np.unique(a, axis=0))+2 def order(self, v): if len(v) <= 3: return v v = np.unique(v, axis=0) n = self.norm(v[:3]) y = np.cross(n,v[1]-v[0]) y = y/np.linalg.norm(y) c = np.dot(v, np.c_[v[1]-v[0],y]) if self.method == "convexhull": h = ConvexHull(c) return v[h.vertices] else: mean = np.mean(c,axis=0) d = c-mean s = np.arctan2(d[:,0], d[:,1]) return v[np.argsort(s)] def simplify(self): for i, tri1 in enumerate(self.tri): for j,tri2 in enumerate(self.tri): if j > i: if self.isneighbor(tri1,tri2) and \ self.inv[i]==self.inv[j]: self.grpinx[j] = self.grpinx[i] groups = [] for i in np.unique(self.grpinx): u = self.tri[self.grpinx == i] u = np.concatenate([d for d in u]) u = self.order(u) groups.append(u) return groupsf = Faces(org_triangles)g = f.simplify()ax = a3.Axes3D(plt.figure())colors = list(map("C{}".format, range(len(g))))pc = a3.art3d.Poly3DCollection(g, facecolor=colors, edgecolor="k", alpha=0.9)ax.add_collection3d(pc)ax.dist=10ax.azim=30ax.elev=10ax.set_xlim([0,5])ax.set_ylim([0,5])ax.set_zlim([0,5])plt.show()