Coverage for wulffpack/core/facet.py: 98%

147 statements  

« prev     ^ index     » next       coverage.py v7.9.1, created at 2025-06-29 11:40 +0000

1from typing import List, Tuple, Union 

2import numpy as np 

3from .geometry import get_angle, get_tetrahedral_volume 

4 

5 

6class Facet(object): 

7 """ 

8 A `Facet` carries information about a particular facet, i.e., 

9 only one facet out of potentially several that are equivalent 

10 by symmetry. 

11 

12 Parameters 

13 ---------- 

14 normal 

15 The normal to the surface in Cartesian coordintes 

16 energy 

17 Surface energy (in units of energy per surface area) 

18 rotation 

19 Symmetry operation (acting on scaled coordinates) that 

20 carried an arbtriraily defined "parent facet" to this facet 

21 original_grain 

22 If True, this facet is part of an "original grain", i.e., 

23 in the case of decahedron/icosahedron, it is part of 

24 the first created tetrahedron 

25 tol 

26 Numerical tolerance 

27 """ 

28 

29 def __init__( 

30 self, 

31 normal: Union[List[float], Tuple[float]], 

32 energy: float, 

33 symmetry: np.ndarray, 

34 original_grain: bool = True, 

35 tol: float = 1e-5 

36 ): 

37 self.normal = np.array(normal) / np.linalg.norm(normal) 

38 self.original_normal = self.normal.copy() 

39 self.energy = energy 

40 self.symmetries = [symmetry] 

41 self.vertices = [] 

42 self.original_grain = original_grain 

43 self.original_vertices = [] 

44 self.tol = tol 

45 

46 def __str__(self) -> str: 

47 width = 42 

48 s = [] 

49 s += ['{s:-^{n}}'.format(s=' Facet ', n=width)] 

50 flds = dict(normal='', original_normal='', energy='12.4f', original_grain='') 

51 for fld, fmt in flds.items(): 

52 s += ['{fld:16} : {value:{fmt}}'.format(fld=fld, value=getattr(self, fld), fmt=fmt)] 

53 s += [f'{"symmetries":16} :'] 

54 for k, v in enumerate(self.symmetries): 

55 s += [f' {k:4} : {v[0]}'] 

56 s += [f' {"":4} {v[1]}'] 

57 s += [f' {"":4} {v[2]}'] 

58 return '\n'.join(s) 

59 

60 def _repr_html_(self) -> str: 

61 s = [] 

62 s += ['<table border="1" class="dataframe"'] 

63 s += [ 

64 '<thead><tr><th style="text-align: left;">Property</th><th>Value</th></tr></thead>' 

65 ] 

66 s += ['<tbody>'] 

67 flds = dict(normal='', original_normal='', energy='12.4f', original_grain='') 

68 for fld, fmt in flds.items(): 

69 s += [ 

70 f'<tr><td style="text-align: left">{fld:22}</td>' 

71 '<td>{value:{fmt}}</td><tr>'.format(value=getattr(self, fld), fmt=fmt) 

72 ] 

73 s += '<tr><td style="text-align: left">symmetries</td><tr>' 

74 for k, v in enumerate(self.symmetries): 

75 s += [f'<tr><td>{k}</td><td>{v[0]}<br>{v[1]}<br>{v[2]}</td></tr>'] 

76 s += ['</tbody>'] 

77 s += ['</table>'] 

78 return ''.join(s) 

79 

80 def add_vertex(self, vertex: Union[List, tuple]): 

81 """ 

82 Add a vertex to the list of vertices. This function checks 

83 that there is not already such a vertex, to ensure that 

84 no vertex is duplicated. It is also checked that the vertex 

85 lies in the expected plane (as defined by the first vertex and 

86 the surface normal). 

87 

88 Parameters 

89 ---------- 

90 vertex 

91 The vertex in Cartesian coordinates 

92 """ 

93 # Need to check that the vertex is not already added 

94 # (relevant if more than three planes cross at the 

95 # same point, such as in a non-truncated octahedron) 

96 for comp_vertex in self.vertices: 

97 if np.linalg.norm(vertex - comp_vertex) < self.tol: 

98 return None 

99 # Check that it is in the right plane 

100 if self.vertices: 

101 if abs(np.dot(vertex, self.normal) - self.distance_from_origin) > self.tol: 

102 raise ValueError('Vertex {} corresponding to facet with normal {} ' 

103 'does not lie in the same plane ' 

104 'as previously added vertices.'.format(vertex, self.normal)) 

105 self.vertices.append(vertex) 

106 

107 @property 

108 def distance_from_origin(self) -> float: 

109 """Returns the distance from the origin to the facet.""" 

110 return np.dot(self.vertices[0], self.normal) 

111 

112 def remove_redundant_vertices(self): 

113 """ 

114 Remove any vertex which is midway between two other vertices. 

115 This would not work if the polygon were concave, but that is 

116 not allowed anyway. 

117 """ 

118 to_pop = [] 

119 for i, vertex_i in enumerate(self.vertices): 

120 for j, vertex_j in enumerate(self.vertices): 

121 if j <= i: 

122 continue 

123 v1 = vertex_j - vertex_i 

124 for k, vertex_k in enumerate(self.vertices): 

125 if k <= j: 

126 continue 

127 v2 = vertex_k - vertex_i 

128 angleish = abs(get_angle(v2, v1)) / np.pi 

129 if abs(angleish - round(angleish)) > self.tol / 100: 

130 continue 

131 # Then we have found three vertices on a line 

132 # Now we need to remove the middle one 

133 v3 = vertex_k - vertex_j 

134 d1 = np.linalg.norm(v1) 

135 d2 = np.linalg.norm(v2) 

136 d3 = np.linalg.norm(v3) 

137 _, indices = zip( 

138 *sorted(zip([d1, d2, d3], list(range(3))))) 

139 to_pop.append([k, j, i][indices[-1]]) 

140 for i in reversed(sorted(set(to_pop))): 

141 self.vertices.pop(i) 

142 

143 @property 

144 def ordered_vertices(self) -> List[np.ndarray]: 

145 """ 

146 Returns the vertices ordered such that they enclose a polygon. 

147 The first/last vertex occurs twice. 

148 

149 It is assumed (but not checked) that the coordinates lie in 

150 a plane and that they form a convex polygon. 

151 """ 

152 # Find closest pairs by comparing angles between each triplet of 

153 # vertices 

154 best_pairs = [] 

155 for i, vertex_i in enumerate(self.vertices): 

156 # Its closest neighbors are those with which the largest angle is 

157 # formed 

158 best_angle = 0 

159 for j, vertex_j in enumerate(self.vertices): 

160 if i == j: 

161 continue 

162 for k, vertex_k in enumerate(self.vertices): 

163 if k <= j or i == k: 

164 continue 

165 angle = get_angle(vertex_j - vertex_i, vertex_k - vertex_i) 

166 if angle > best_angle: 

167 best_angle = angle 

168 neighbors = (j, k) 

169 for neighbor in neighbors: 

170 pair = tuple(sorted([i, neighbor])) 

171 if pair not in best_pairs: 

172 best_pairs.append(pair) 

173 

174 # Make a new list of vertices, now in order (as implicitly 

175 # defined by the pairs of nearest neighbors) 

176 ordered_vertices = [self.vertices[0]] 

177 current_vertex_index = 0 

178 while len(ordered_vertices) < len(self.vertices): 

179 for pair_index, pair in enumerate(best_pairs): 179 ↛ 178line 179 didn't jump to line 178 because the loop on line 179 didn't complete

180 if current_vertex_index in pair: 

181 for new_vertex_index in pair: 181 ↛ 186line 181 didn't jump to line 186 because the loop on line 181 didn't complete

182 if new_vertex_index != current_vertex_index: 

183 ordered_vertices.append( 

184 self.vertices[new_vertex_index]) 

185 break 

186 best_pairs.pop(pair_index) 

187 current_vertex_index = new_vertex_index 

188 break 

189 ordered_vertices.append(self.vertices[0]) 

190 return ordered_vertices 

191 

192 @property 

193 def face_as_triangles(self) -> List[List[np.ndarray]]: 

194 """ 

195 Given N 3D coordinates, ABCD...N, split them into triangles 

196 that do not overlap. Two of the vertices of each triangle 

197 will be vertices of the face, the third vertex will be 

198 the origin. 

199 """ 

200 vertices = self.ordered_vertices[:-1] 

201 nvertices = len(vertices) 

202 center = np.mean(vertices, axis=0) # Mid point 

203 

204 # Standardize order because some applications 

205 # (such as threejs) expect that 

206 normal = np.cross(vertices[1] - center, 

207 vertices[0] - center) 

208 reverse = (np.dot(normal, self.normal) > 0) 

209 

210 # Construct all triangles 

211 triangles = [] 

212 for i in range(0, len(vertices)): 

213 if reverse: 

214 triangle = [center, 

215 vertices[(i + 1) % nvertices], 

216 vertices[i]] 

217 else: 

218 triangle = [center, 

219 vertices[i], 

220 vertices[(i + 1) % nvertices]] 

221 triangles.append(triangle) 

222 assert len(triangles) == len(vertices) 

223 return triangles 

224 

225 @property 

226 def area(self) -> float: 

227 """Returns the total area of the facet.""" 

228 area = 0 

229 for triangle in self.face_as_triangles: 

230 v1 = triangle[1] - triangle[0] 

231 v2 = triangle[2] - triangle[0] 

232 area += np.linalg.norm(np.cross(v1, v2)) 

233 return area / 2 

234 

235 @property 

236 def surface_energy(self) -> float: 

237 """Returns the total surface energy of the facet.""" 

238 return self.area * self.energy 

239 

240 def get_volume(self, origin: Union[List, tuple] = None) -> float: 

241 """ 

242 Calculate the volume formed by the pyramid having the face as 

243 its base and the specified origin as its tip. 

244 

245 Parameters 

246 ---------- 

247 origin : list of 3 ints 

248 The origin 

249 

250 Returns 

251 ------- 

252 The total volume 

253 """ 

254 if origin is None: 254 ↛ 257line 254 didn't jump to line 257 because the condition on line 254 was always true

255 origin = np.array([0, 0, 0]) 

256 else: 

257 origin = np.array(origin) 

258 

259 volume = 0 

260 for triangle in self.face_as_triangles: 

261 volume += get_tetrahedral_volume(triangle, origin) 

262 return volume 

263 

264 @property 

265 def perimeter_length(self) -> float: 

266 """Returns the length of the perimeter""" 

267 length = 0.0 

268 ordered_vertices = self.ordered_vertices 

269 for i, vertex in enumerate(ordered_vertices[1:]): 

270 length += np.linalg.norm(vertex - ordered_vertices[i]) 

271 return length