polytope.py 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646
  1. """
  2. Polytope class and subclasses for n-polytopes. Likewise, generalized NFace class and subclasses for n-faces, which are stored inside Polytopes.
  3. """
  4. # Standard library imports
  5. from __future__ import annotations
  6. from functools import cached_property, reduce
  7. from itertools import combinations, cycle, islice
  8. from math import isclose
  9. from typing import List, Set, Tuple
  10. import weakref
  11. # Third-party imports
  12. import numpy as np
  13. import sympy
  14. from sympy import Rational
  15. # Local imports
  16. #from exceptions import SubfaceError
  17. class Polytope:
  18. """
  19. Superclass for n-polytopes. Such as polyhedra, polygons, line segments, and points.
  20. Each positional argument is a list. Vertices are a list of 3-tuples.
  21. And the rest are lists of integers to represent the indicies of the subfaces that form that n-face.
  22. Example for constructing a polyhedron:
  23. Polytope([vertices, edges, faces])
  24. """
  25. def __init__(self, subfaces: List[Polytope] = None, superfaces: List[Polytope] = None):
  26. if subfaces is None:
  27. self.subfaces = []
  28. else:
  29. self.subfaces = subfaces
  30. if superfaces is None:
  31. self.superfaces = []
  32. else:
  33. self.superfaces = weakref.proxy(superfaces)
  34. # def __eq__(self, other) -> bool:
  35. # if isinstance(other, Polytope):
  36. # return self.subfaces == self.subfaces
  37. # def __hash__(self):
  38. # return hash(self.subfaces)
  39. def __getitem__(self, idx) -> Polytope:
  40. """Index by subfaces."""
  41. return self.subfaces[idx]
  42. def __len__(self):
  43. """Return count of subfaces."""
  44. return len(self.subfaces)
  45. @property
  46. def rank(self) -> int:
  47. """The dimension of self."""
  48. if self.subfaces == []:
  49. return 0
  50. else:
  51. return next(iter(self.subfaces)).rank + 1
  52. @property
  53. def parents(self) -> List[Polytope]:
  54. """Alias for superfaces; that is, (n+1)-faces that contain self."""
  55. return self.superfaces
  56. @cached_property
  57. def siblings(self) -> List[Polytope]:
  58. """Return n-faces that share an (n+1)-face with self."""
  59. neighbours = set()
  60. for superface in self.superfaces:
  61. neighbours = neighbours.union(superface.subfaces)
  62. neighbours.remove(self)
  63. return list(neighbours)
  64. @cached_property
  65. def neighbours(self) -> List[Polytope]:
  66. """Return n-faces that share an (n-1)-face with self."""
  67. neighbours = set()
  68. for subface in self.subfaces:
  69. neighbours = neighbours.union(subface.superfaces)
  70. neighbours.remove(self)
  71. return list(neighbours)
  72. @property
  73. def children(self) -> List[Polytope]:
  74. """Alias for subfaces; that is, (n-1)-faces that self contains."""
  75. return self.subfaces
  76. @property
  77. def facets(self) -> List[Polytope]:
  78. """(n-1)-faces."""
  79. return self.subfaces
  80. @property
  81. def ridges(self) -> List[Polytope]:
  82. """(n-2)-faces."""
  83. return self.nfaces(self.rank - 2)
  84. @property
  85. def peaks(self) -> List[Polytope]:
  86. """(n-3)-faces."""
  87. return self.nfaces(self.rank - 3)
  88. def nfaces(self, rank) -> List[Polytope]:
  89. """Return n-faces, where n is the "rank" argument, that are within self or that self is within."""
  90. if rank > self.rank:
  91. faces = self.superfaces
  92. superfaces = set()
  93. while True:
  94. if next(iter(faces)).rank == rank:
  95. return list(faces)
  96. for superface in faces:
  97. superfaces = superfaces.union(superface.superfaces)
  98. faces = superfaces
  99. superfaces = set()
  100. elif rank == self.rank:
  101. return self
  102. else:
  103. faces = self.subfaces
  104. subfaces = set()
  105. while True:
  106. if next(iter(faces)).rank == rank:
  107. return list(faces)
  108. for subface in faces:
  109. subfaces = subfaces.union(subface.subfaces)
  110. faces = subfaces
  111. subfaces = set()
  112. @property
  113. def cells(self) -> List[Polytope]:
  114. """3-faces."""
  115. return self.nfaces(3)
  116. @property
  117. def faces(self) -> List[Polygon]:
  118. """2-faces."""
  119. return self.nfaces(2)
  120. @property
  121. def edges(self) -> List[LineSegment]:
  122. """1-faces."""
  123. return self.nfaces(1)
  124. @property
  125. def vertices(self) -> List[Point]:
  126. """0-faces."""
  127. if next(iter(self.subfaces)) == []:
  128. return self.subfaces
  129. return self.nfaces(0)
  130. @property
  131. def ambient_dimension(self) -> int:
  132. """The number of dimensions in which the polytope resides in.
  133. Note that this is not always the dimensionality of the shape itself,
  134. such as a square in a 3D space."""
  135. return self.vertices[0].ambient_dimension
  136. @property
  137. def centroid(self) -> Point:
  138. """Average of positions in element."""
  139. positions = [vertex.position for vertex in self.vertices]
  140. centroid = []
  141. for axis in range(len(positions[0])):
  142. centroid.append(reduce(lambda a, b: a + b[axis], positions, 0)*Rational(1, len(positions)))
  143. return Point(centroid)
  144. def stats(self) -> None:
  145. """Print number of vertices, edges, faces, etc."""
  146. nfaces = ["Vertices", "Edges", "Faces", "Cells"]
  147. for rank in range(0, self.rank):
  148. if rank <= 3:
  149. nface = nfaces[rank]
  150. else:
  151. nface = f"{rank}-face"
  152. count = len(self.nfaces(rank))
  153. print(f"{nface}: {count}")
  154. class Polyhedron(Polytope):
  155. """The 3-polytope."""
  156. def __init__(self, *args, **kwargs):
  157. super().__init__(*args, **kwargs)
  158. def face_types(self) -> None:
  159. """Print counts of each n-gon."""
  160. polygon_names = {3:"triangles", 4:"quadrilaterals", 5:"pentagons", 6:"hexagons", 7:"heptagons",
  161. 8:"octagons", 9:"nonagons", 10:"decagons", 11:"undecagons", 12:"dodecagons"}
  162. polygon_counts = {}
  163. for face in self.faces:
  164. if len(face.vertices) in polygon_counts:
  165. polygon_counts[len(face.vertices)] += 1
  166. else:
  167. polygon_counts[len(face.vertices)] = 1
  168. for key, value in polygon_counts.items():
  169. if key in polygon_names:
  170. print(f"{polygon_names[key]}: {value}")
  171. else:
  172. print(f"{key}-gon: {value}")
  173. def is_canonical(self) -> bool:
  174. """Return whether Polyhedron has midsphere. That is to say whether all edges form lines tangent to the same sphere."""
  175. midradius = None
  176. for edge in self.edges:
  177. vertex1 = list(edge.vertices)[0]
  178. vertex2 = list(edge.vertices)[1]
  179. line = sympy.Line3D(sympy.Point3D(vertex1.coordinates), sympy.Point3D(vertex2.coordinates))
  180. distance = line.distance(sympy.Point3D(0, 0, 0))
  181. if midradius is None:
  182. midradius = distance
  183. elif not isclose(float(midradius), float(distance)):
  184. return False
  185. return True
  186. ### Operations and closely associated functions ###
  187. @staticmethod
  188. def __add_to_list(point, new_vertices, new_face):
  189. """Test if point already in new_vertices, and if not, add it."""
  190. try:
  191. index = new_vertices.index(point)
  192. new_face.append(index)
  193. except ValueError:
  194. new_vertices.append(point)
  195. new_face.append(len(new_vertices) - 1)
  196. @staticmethod
  197. def diminish(func, vertex, new_vertices):
  198. """Remove pyramid off polyhedron where apex is a vertex on the polyhedron.
  199. Takes a func to determine how base of pyramid is formed.
  200. Used in truncate(), rectify(), and facet().
  201. """
  202. # Create new faces (new faces derived from previous vertices)
  203. unordered_face = []
  204. for neighbour in vertex.neighbours:
  205. midpoint = func(LineSegment([Point(vertex.coordinates), Point(neighbour.coordinates)]))
  206. unordered_face.append((midpoint, neighbour))
  207. # Find edges by comparing endpoint faces
  208. edges = []
  209. for midpoint1, endpoint1 in unordered_face:
  210. for midpoint2, endpoint2 in unordered_face:
  211. if midpoint1 != midpoint2:
  212. for end_face in endpoint1.faces:
  213. if end_face in endpoint2.faces:
  214. edges.append((midpoint1, midpoint2))
  215. # Order vertices in face
  216. face_vertices = [edges[0][0]]
  217. for _ in edges:
  218. for edge in edges:
  219. if edge[0] == face_vertices[-1] and edge[1] not in face_vertices:
  220. face_vertices.append(edge[1])
  221. break
  222. # Create new faces and vertices
  223. new_face = []
  224. for vertex in face_vertices:
  225. index = new_vertices.index(vertex)
  226. new_face.append(index)
  227. return new_face
  228. def truncate(self):
  229. """Perform truncation operation. Cleave vertices by marking 1/3rd and 2/3rds of each edge as new vertices."""
  230. print("Truncating")
  231. new_vertices = []
  232. new_faces = []
  233. # Create truncated faces (new faces derived from previous faces)
  234. for face in self.faces:
  235. new_face = []
  236. # Create new vertices and faces
  237. for x in range(len(face.vertices)):
  238. coordinates1 = face.vertices[x - 1].coordinates
  239. coordinates2 = face.vertices[x].coordinates
  240. third_forward = LineSegment([Point(coordinates1), Point(coordinates2)]).find_third()
  241. third_backward = LineSegment([Point(coordinates2), Point(coordinates1)]).find_third()
  242. self.__add_to_list(third_forward, new_vertices, new_face)
  243. self.__add_to_list(third_backward, new_vertices, new_face)
  244. new_faces.append(new_face)
  245. # Create new faces (new faces derived from previous vertices)
  246. for vertex in self.vertices:
  247. new_face = self.diminish(LineSegment.find_third, vertex, new_vertices)
  248. new_faces.append(new_face)
  249. return create_polytope(new_vertices, new_faces)
  250. def rectify(self, method="by_midsphere"):
  251. """Perform rectification operation. This diminishes the polyhedron at vertices such edges are converted into new vertices.
  252. The default implementation uses the intersections of the edges to the polyhedron's midsphere to place new vertices.
  253. And thus, this only works on polyhedra that have a midsphere.
  254. An alternate method can be used by setting the parameter method to "by_midpoint".
  255. This method doesn't require a midsphere and instead cleaves vertices by marking midpoints of edges as new vertices.
  256. For uniform polyhedra, this produces the same results as the midsphere method; however, for nonuniform polyhedra,
  257. this can result in nonplanar faces."""
  258. print("Rectifying")
  259. # Decide on method of rectification
  260. if method == "by_midsphere":
  261. if not self.is_canonical():
  262. print("Polyhedron is not canonical; must have a midsphere to rectify.")
  263. return self
  264. create_new_vertices = LineSegment.find_midsphere_intersection
  265. elif method == "by_midpoint":
  266. create_new_vertices = LineSegment.find_midpoint
  267. else:
  268. print("Not a valid option for parameter alt_method.")
  269. # Arrays for new polyhedron's vertices, edges, and faces
  270. new_vertices = []
  271. new_faces = []
  272. # Create rectified faces (new faces derived from previous faces)
  273. for face in self.faces:
  274. new_face = []
  275. # Find new vertices
  276. offset_cycle = islice(cycle(face.vertices), 1, None)
  277. for vertex, neighbour in zip(face.vertices, offset_cycle):
  278. midpoint = create_new_vertices(LineSegment([Point(vertex.coordinates), Point(neighbour.coordinates)]))
  279. # Test if midpoint is already in new_vertices, and if not, add it
  280. self.__add_to_list(midpoint, new_vertices, new_face)
  281. new_faces.append(new_face)
  282. # Create new faces (new faces derived from previous vertices)
  283. for vertex in self.vertices:
  284. new_face = self.diminish(create_new_vertices, vertex, new_vertices)
  285. new_faces.append(new_face)
  286. return create_polytope(new_vertices, new_faces)
  287. def facet(self):
  288. """Perform facet operation. Maintain all previous vertices, but connect them differently to form new faces on a nonconvex figure."""
  289. print("Faceting")
  290. def keep_only_neighbour(vertex1, vertex2):
  291. return vertex2
  292. new_faces = []
  293. new_vertices = [vertex.coordinates for vertex in self.vertices]
  294. # Create new faces (new faces derived from previous vertices)
  295. for vertex in self.vertices:
  296. new_face = self.diminish(keep_only_neighbour, vertex, new_vertices)
  297. new_faces.append(new_face)
  298. return create_polytope(new_vertices, new_faces)
  299. def reciprocate(self):
  300. """Perform reciprocation operation. Convert centroid of each face into a vertex and connect each adjacent centroid.
  301. This implementation creates skew faces on some polyhedra."""
  302. print("Reciprocating")
  303. def find_centroid(vertices_coordinates):
  304. return tuple(sum(np.array(vertices_coordinates))/len(vertices_coordinates))
  305. new_vertices = []
  306. new_faces = []
  307. # Create new vertices and faces
  308. for vertex in self.vertices:
  309. new_face = []
  310. for face in vertex.faces:
  311. centroid = find_centroid([vertex.coordinates for vertex in face.vertices])
  312. # Test if centroid is already in new_vertices, and if not, add it
  313. self.__add_to_list(centroid, new_vertices, new_face)
  314. new_faces.append(new_face)
  315. return create_polytope(new_vertices, new_faces)
  316. @staticmethod
  317. def augment(func, face, new_vertices):
  318. print("Under development")
  319. pass
  320. def cap(self):
  321. print("Under development")
  322. return self
  323. def bridge(self):
  324. print("Under development")
  325. return self
  326. def stellate(self, nth_stellation: int = 2):
  327. """Extends edges until meeting other edges, creating new vertices and changing shape of faces.
  328. The base polyhedron is designated as the first stellation, or nth_stellation=1."""
  329. print("Under development")
  330. # Edge needs to be of form [(1.0, 1.0, 1.0), (1.5, 2.0, 1.5)]
  331. # Create lines from edges
  332. lines = [sympy.Line(*edge) for edge in self.edges]
  333. midpoints = [Point(edge.midpoint) for edge in self.edges]
  334. new_edges = []
  335. for idx, line1 in enumerate(lines):
  336. intersections = []
  337. for line2 in lines:
  338. if line1 != line2:
  339. intersection = line1.intersection(line2)
  340. if intersection:
  341. intersections.append(intersection[0])
  342. distances = [intersection.distance(midpoints[idx]) for intersection in intersections]
  343. #print("Distances", distances)
  344. try:
  345. mins = sorted(list(set(distances)))
  346. endpoints = []
  347. for idx, distance in enumerate(distances):
  348. if distance == mins[nth_stellation]:
  349. endpoints.append(idx)
  350. new_edges.append(tuple(endpoints))
  351. except:
  352. print("Stellation of order", nth_stellation, "does not exist.")
  353. return self
  354. def greaten(self):
  355. """Extend faces to form new larger faces."""
  356. print("Under development")
  357. def intersection(face1):
  358. """The line that two face meet along."""
  359. pass
  360. # Find intersections of neighbours of each face.
  361. for face in self.faces:
  362. for face1, face2 in combinations([neighbour for neighbour in face.neighbours], 2):
  363. edge = intersection(face1, face2)
  364. return self
  365. def decompose(self):
  366. """Slice polyhedron into two or more parts. If not decomposable, return self."""
  367. print("Under development")
  368. return self
  369. def uncouple(self):
  370. """Separate compound polyhedra."""
  371. print("Under development")
  372. return self
  373. composititions = []
  374. connected_faces = []
  375. def neighbour_recursion(connected_faces, face):
  376. for neighbour in face.neighbours:
  377. if neighbour not in connected_faces:
  378. connected_faces.append(neighbour)
  379. neighbour_recursion(neighbour)
  380. neighbour_recursion(self.faces[0].neighbours)
  381. if len(connected_faces) == len(self.faces):
  382. return polyhedron
  383. else:
  384. return Polyhedron(self.vertices, connected_faces) # change new vertices
  385. class Polygon(Polytope):
  386. """The 2-polytope."""
  387. def __init__(self, *args, **kwargs):
  388. super().__init__(*args, **kwargs)
  389. @cached_property
  390. def vertices(self):
  391. """Return ordered vertices."""
  392. edges = {*self.edges}
  393. first_edge = edges.pop()
  394. vertices = [first_edge.vertices[0], first_edge.vertices[1]]
  395. while len(edges) > 1:
  396. for edge in edges:
  397. try:
  398. vertex_idx = edge.vertices.index(vertices[-1])
  399. if vertex_idx == 0:
  400. neighbour_idx = 1
  401. else:
  402. neighbour_idx = 0
  403. vertices.append(edge.vertices[neighbour_idx])
  404. edges.remove(edge)
  405. break
  406. except:
  407. pass
  408. return vertices
  409. class LineSegment(Polytope):
  410. """The 1-polytope."""
  411. def __init__(self, *args, **kwargs):
  412. super().__init__(*args, **kwargs)
  413. @property
  414. def midpoint(self):
  415. """Alias for centroid."""
  416. return self.centroid
  417. def find_third(self):
  418. return ((self[0][0]*Rational(2, 3) + self[1][0]*Rational(1, 3)), # x coordinate
  419. (self[0][1]*Rational(2, 3) + self[1][1]*Rational(1, 3)), # y coordinate
  420. (self[0][2]*Rational(2, 3) + self[1][2]*Rational(1, 3))) # z coordinate
  421. def find_midsphere_intersection(self):
  422. """"Use sympy to find point on line closest to origin."""
  423. line = sympy.Line3D(sympy.Point3D(self[0].coords), sympy.Point3D(self[1].coords))
  424. point = line.projection(sympy.Point3D(0, 0, 0))
  425. return point.coordinates
  426. class Point(Polytope):
  427. """The 0-polytope."""
  428. def __init__(self, coords: Tuple[float]):
  429. super().__init__()
  430. self.coordinates = coords
  431. # def __eq__(self, other) -> bool:
  432. # """Overide class Polytope implementation."""
  433. # return isinstance(other, Point) and self.coords == self.coords
  434. # def __hash__(self):
  435. # return hash(self.coords)
  436. def __str__(self):
  437. return "Point: " + str(self.coordinates)
  438. def __len__(self):
  439. return len(self.coordinates)
  440. def __getitem__(self, idx) -> Polytope:
  441. """Override class Polytope implementation. Index by coordinate."""
  442. return self.coordinates[idx]
  443. @property
  444. def children(self):
  445. # Overides the corresponding method for superclass NFace since vertices don't have subfaces.
  446. #raise SubfaceError("Vertices don't have subfaces.")
  447. pass
  448. @cached_property
  449. def neighbours(self) -> List[Point]:
  450. """Overide class Polytope implementation. Because vertices can't share a subface (for not having any),
  451. the closest idea of neighbours are those that share an edge.
  452. This usage of the term "neighbours" is used in graph theory and so applied here."""
  453. return self.siblings
  454. @property
  455. def coords(self):
  456. """Alias for coordinates."""
  457. return self.coordinates
  458. @property
  459. def position(self):
  460. """Alias for coordinates."""
  461. return self.coordinates
  462. @property
  463. def ambient_dimension(self):
  464. return len(self.position)
  465. def create_polytope(*elements, with_edges=False) -> Polytope:
  466. """Construct instance of class Polytope."""
  467. def create_with_edges(input_faces):
  468. """Take faces referencing vertices and convert to faces referencing edges and edges referencing vertices."""
  469. # Convert input element tuples into type Polytope or one of its subclasses
  470. new_edges = []
  471. new_faces = []
  472. # Find new edges and faces
  473. for face_by_indices in input_faces:
  474. offset_cycle = islice(cycle(face_by_indices), 1, None)
  475. new_face = []
  476. for vertex_idx, neighbour_idx in zip(face_by_indices, offset_cycle):
  477. directed_edge = (vertex_idx, neighbour_idx)
  478. if directed_edge in new_edges:
  479. idx = new_edges.index(directed_edge)
  480. elif tuple(reversed(directed_edge)) in new_edges:
  481. idx = new_edges.index(tuple(reversed(directed_edge)))
  482. else:
  483. idx = len(new_edges)
  484. new_edges.append(directed_edge)
  485. new_face.append(idx)
  486. new_faces.append(new_face)
  487. return new_edges, new_faces
  488. elements_by_indices = list(elements)
  489. if not with_edges:
  490. new_edges, new_faces = create_with_edges(elements[1])
  491. # Assign new edges and faces to elements
  492. elements_by_indices.insert(1, new_edges)
  493. elements_by_indices[2] = new_faces
  494. else:
  495. elements_by_indices = elements
  496. nfaces = [[] for rank in elements_by_indices]
  497. for rank, elements in enumerate(elements_by_indices):
  498. for nface in elements:
  499. # Set class to instantiate as
  500. if rank == 0:
  501. NFace = Point
  502. elif rank == 1:
  503. NFace = LineSegment
  504. elif rank == 2:
  505. NFace = Polygon
  506. elif rank == 3:
  507. NFace = Polyhedron
  508. else:
  509. NFace = Polytope
  510. # Build instance
  511. if rank == 0:
  512. nfaces[rank].append(NFace(nface))
  513. else:
  514. subfaces = []
  515. # Add subfaces for n-face
  516. for subface_idx in nface:
  517. subfaces.append(nfaces[rank - 1][subface_idx])
  518. cur_face = NFace(subfaces=subfaces)
  519. nfaces[rank].append(cur_face)
  520. # Add superface to (n-1)-faces (except for facet)
  521. for subface in subfaces:
  522. subface.superfaces.append(cur_face)
  523. # Create and return outermost container for all n-faces
  524. if len(elements_by_indices) == 2:
  525. polytope = Polygon(subfaces=nfaces[-1])
  526. elif len(elements_by_indices) == 3:
  527. polytope = Polyhedron(subfaces=nfaces[-1])
  528. elif len(elements_by_indices) > 3:
  529. polytope = Polytope(subfaces=nfaces[-1])
  530. # Add polytope as superface to facets
  531. for facet in nfaces[-1]:
  532. facet.superfaces.append(polytope)
  533. # Create and return outermost container for all n-faces
  534. return polytope