Coverage for wulffpack/core/base_particle.py: 97%

330 statements  

« prev     ^ index     » next       coverage.py v7.8.0, created at 2025-05-06 13:03 +0000

1from typing import Dict, List, Tuple 

2import numpy as np 

3from ase import Atoms 

4from .facet import Facet 

5from .form import Form 

6from scipy.spatial import ConvexHull 

7from .geometry import is_array_in_arrays 

8 

9 

10class BaseParticle(): 

11 """ 

12 Base class for Wulff constructions, useful for systems of 

13 (almost) any symmetry. 

14 

15 Parameters 

16 ---------- 

17 forms 

18 List of forms belonging to the system at hand 

19 standardized_structure 

20 The structural unit implicitly carrying information about 

21 basis vectors etc. 

22 natoms 

23 Used as a target number of atoms if an atomistic structure 

24 is requested. Together with `standardized_structure`, 

25 `natoms` also defines the volume of the particle. 

26 ngrains 

27 If only a part of a complete particle is to be constructed, 

28 for example a fifth of a decahedron, this should be reflected 

29 by this parameter. 

30 volume_scale 

31 Decahedral and icosahedral particle need to be strained to 

32 fill space. With this parameter, the volume is scaled such 

33 that the volume of the particle is correct *after* this 

34 straining. 

35 tolerance 

36 Numerical tolerance 

37 """ 

38 

39 def __init__(self, forms: List[Form], 

40 standardized_structure: Atoms, 

41 natoms: int, 

42 ngrains: int = 1, 

43 volume_scale: int = 1.0, 

44 tol: float = 1e-5): 

45 self._natoms = natoms 

46 self._standardized_structure = standardized_structure 

47 self._forms = forms 

48 self.tol = tol 

49 self._SMALL = 1e-12 

50 

51 facets = list(self._yield_facets()) 

52 if min(f.energy for f in facets) < 0: # interface energy can be negative 

53 self._closest_vertices(facets) 

54 else: 

55 self._dual_wulff_shape(facets) 

56 

57 # Remove vertices that are on the line between two other vertices 

58 # (relevant in the Winterbottom case and some corner cases) 

59 for facet in self._yield_facets(): 

60 facet.remove_redundant_vertices() 

61 

62 # Delete facets with less than three vertices 

63 to_pop = [] 

64 for i, form in enumerate(self.forms): 

65 if len(form.facets[0].vertices) < 3: 

66 to_pop.append(i) 

67 for i in reversed(to_pop): 

68 self.forms.pop(i) 

69 

70 # Scale everyting such that the volume matches the specified one 

71 target_volume = self.natoms * standardized_structure.get_volume() / \ 

72 len(standardized_structure) 

73 scale_factor = (target_volume / (ngrains * self.volume * volume_scale)) ** (1 / 3) 

74 self._scale_size(scale_factor) 

75 

76 def _closest_vertices(self, facets): 

77 """ 

78 The closest vertex technique calculates all possible vertices and eliminates 

79 vertices outside of the Wulff shape. 

80 This works for negative interface energies. 

81 """ 

82 # Calculate intersection point for all possible combinations of three facets 

83 facets.sort(key=lambda x: x.energy) # compare with small energies first in inner loop 

84 for i, fi in enumerate(facets): 

85 ni = fi.normal 

86 for j, fj in enumerate(facets[:i]): 

87 nj = fj.normal 

88 if abs(abs(np.dot(ni, nj)) - 1) < self._SMALL: 

89 continue # skip parallel facets 

90 for fk in facets[:j]: 

91 mat = np.vstack([ni, nj, fk.normal]) 

92 if np.linalg.det(mat) == 0: 

93 continue # skip singular matrices 

94 vertex = np.dot(np.linalg.inv(mat), [fi.energy, fj.energy, fk.energy]) 

95 

96 # check if vertex lies outside of Wulff shape 

97 for fl in facets: 

98 projection = np.dot(vertex, fl.normal) 

99 if projection > fl.energy + self._SMALL: 

100 break 

101 else: # i.e. loop does not break 

102 for f in [fi, fj, fk]: 

103 f.add_vertex(vertex.copy()) 

104 

105 def _dual_wulff_shape(self, facets): 

106 """ 

107 The Wulff construction calculates the vertices of the dual, 

108 calculates the convex hull of those vertices using scipy's 

109 ConvexHull, and then converts the dual back to the Wulff 

110 shape. 

111 """ 

112 # Calculate convex hull of dual vertices 

113 duals = [] 

114 for facet in self._yield_facets(): 

115 facet_points = facet.normal * facet.energy 

116 duals.append(facet_points / np.dot(facet_points, facet_points)) 

117 hull = ConvexHull(duals) 

118 

119 # Calculate vertices of dual again (i.e., back to Wulff shape) 

120 for i, j, k in hull.simplices: 

121 normal = np.cross(duals[j] - duals[i], duals[k] - duals[i]) 

122 if np.linalg.norm(normal) < self._SMALL: 122 ↛ 123line 122 didn't jump to line 123 because the condition on line 122 was never true

123 continue # Rare instances of duals on a line 

124 normal *= facets[i].energy / np.dot(normal, facets[i].normal) 

125 for facet_i in (i, j, k): 

126 facets[facet_i].add_vertex(normal.copy()) 

127 

128 def _scale_size(self, scale_factor: float): 

129 """ 

130 Scale the size of the particle. 

131 

132 Parameters 

133 ---------- 

134 scale_factor 

135 Factor that all coordinates will be scaled with. 

136 """ 

137 for facet in self._yield_facets(): 

138 for i, _ in enumerate(facet.vertices): 

139 facet.vertices[i] *= scale_factor 

140 for i, _ in enumerate(facet.original_vertices): 

141 facet.original_vertices[i] *= scale_factor 

142 

143 @property 

144 def natoms(self) -> List[int]: 

145 """ 

146 The approximate number of atoms in the particle 

147 (implicitly defining the volume). 

148 """ 

149 return self._natoms 

150 

151 @natoms.setter 

152 def natoms(self, natoms: int): 

153 """ 

154 Change the approximate number of atoms and thus the volume. 

155 """ 

156 scale_factor = (natoms / self._natoms) ** (1 / 3.) 

157 self._scale_size(scale_factor) 

158 self._natoms = natoms 

159 

160 @property 

161 def forms(self) -> List[Form]: 

162 """List of inequivalent forms for the particle""" 

163 return self._forms 

164 

165 @property 

166 def standardized_structure(self) -> Atoms: 

167 """ 

168 The standardized atomic structure that defines the geometry 

169 and thus the meaning of the Miller indices. Also forms the building 

170 blocks when `particle.atoms` is called. 

171 """ 

172 return self._standardized_structure 

173 

174 @property 

175 def _twin_form(self) -> Form: 

176 """Returns the twin form if there is one, otherwise None.""" 

177 for form in self.forms: 

178 if form.parent_miller_indices == 'twin': 

179 return form 

180 return None 

181 

182 def get_continuous_color_scheme(self, 

183 base_colors: dict = None, 

184 normalize: bool = False) -> dict: 

185 """ 

186 Returns a dictionary with RGB colors for each form. 

187 The colors smoothly interpolate between three 

188 base colors, corresponding to (1, 1, 1), (1, 1, 0) and 

189 (1, 0, 0). Note that this is sensible primarily for 

190 cubic systems. 

191 

192 Parameters 

193 ---------- 

194 base_colors 

195 User chosen colors for one or several of (1, 1, 1), 

196 (1, 1, 0) and (1, 0, 0). To enforce, say, green 

197 (1, 1, 1), use ``base_colors={(1, 1, 1): 'g'}`` 

198 normalize 

199 If True, the norm of the RGB vectors will be 1. Note 

200 that this may affect the ``base_colors`` too. 

201 """ 

202 from matplotlib.colors import to_rgba 

203 

204 # Adapt base_colors dictionary 

205 new_base_colors = {} 

206 if base_colors: 

207 for key in base_colors.keys(): 

208 adapted_key = tuple(sorted(abs(i) for i in key)) 

209 new_base_colors[adapted_key] = base_colors[key] 

210 

211 # Make sure base_colors is complete 

212 default_colors = {(1, 1, 1): '#2980B9', 

213 (0, 1, 1): '#E92866', 

214 (0, 0, 1): '#FFE82C'} 

215 for key, color in default_colors.items(): 

216 if key not in new_base_colors: 

217 new_base_colors[key] = color 

218 A = [(1, 1, 1), (0, 1, 1), (0, 0, 1)] 

219 base_color_matrix = np.array([to_rgba(new_base_colors[f])[:3] for f in A]).T 

220 

221 # Normalize rows in A 

222 A = np.array([np.array(miller) / np.linalg.norm(miller) for miller in A]).T 

223 

224 # Calculate color for each form 

225 colors = {} 

226 for form in self.forms: 

227 if type(form.parent_miller_indices) is not tuple: 

228 continue 

229 miller = np.array(sorted(abs(i) for i in form.parent_miller_indices)) 

230 color = np.linalg.solve(A, miller) 

231 color /= np.linalg.norm(color) 

232 color = np.dot(base_color_matrix, color) 

233 if normalize: 

234 color /= np.linalg.norm(color) 

235 else: 

236 color = [min(1, c) for c in color] 

237 colors[form.parent_miller_indices] = color 

238 return colors 

239 

240 def make_plot(self, ax, alpha: float = 0.85, linewidth: float = 0.3, colors: dict = None): 

241 """ 

242 Plot a particle in an axis object. This function can be used to 

243 make customized plots of particles. 

244 

245 Parameters 

246 ---------- 

247 ax : matplotlib Axes3DSubplot 

248 An axis object with 3d projection 

249 alpha 

250 Opacity of the faces 

251 linewidth 

252 Thickness of lines between faces 

253 colors 

254 Allows custom colors for facets of all or a subset of forms, 

255 example `{(1, 1, 1): '#FF0000'}` 

256 

257 Example 

258 ------- 

259 In the following example, three different particles are 

260 plotted in the same figure:: 

261 

262 >>> from wulffpack import SingleCrystal, Decahedron, Icosahedron 

263 >>> import matplotlib.pyplot as plt 

264 >>> from mpl_toolkits.mplot3d import Axes3D 

265 >>> 

266 >>> surface_energies = {(1, 1, 1): 1.0, 

267 ... (1, 0, 0): 1.1, 

268 ... (1, 1, 0): 1.15, 

269 ... (3, 2, 1): 1.15} 

270 >>> twin_energy = 0.05 

271 >>> 

272 >>> fig = plt.figure(figsize=(3*4.0, 4.0)) 

273 >>> ax = fig.add_subplot(131, projection='3d') 

274 >>> particle = SingleCrystal(surface_energies) 

275 >>> particle.make_plot(ax) 

276 >>> 

277 >>> ax = fig.add_subplot(132, projection='3d') 

278 >>> particle = Decahedron(surface_energies, 

279 ... twin_energy=0.05) 

280 >>> particle.make_plot(ax) 

281 >>> 

282 >>> ax = fig.add_subplot(133, projection='3d') 

283 >>> particle = Icosahedron(surface_energies, 

284 ... twin_energy=0.05) 

285 >>> particle.make_plot(ax) 

286 >>> 

287 >>> plt.subplots_adjust(top=1, bottom=0, left=0, 

288 ... right=1, wspace=0, hspace=0) 

289 >>> plt.savefig('particles.png') 

290 

291 """ 

292 from mpl_toolkits.mplot3d.art3d import (Poly3DCollection, 

293 Line3DCollection) 

294 

295 # Standardize color dict 

296 if colors is None: 

297 colors = {} 

298 

299 default_colors = ['#D62728', 

300 '#E377C2', 

301 '#8C564B', 

302 '#7F7F7F', 

303 '#9467BD', 

304 '#BCBD22', 

305 '#17BECF', 

306 '#AEC7E8', 

307 '#FFBB78', 

308 '#98DF8A', 

309 '#FF9896', 

310 '#C5B0D5', 

311 '#C49C94', 

312 '#F7B6D2', 

313 '#C7C7C7', 

314 '#DBDB8D', 

315 '#9EDAE5'] 

316 

317 mins = [1e9, 1e9, 1e9] 

318 maxs = [-1e9, -1e9, -1e9] 

319 poly3d = [] 

320 

321 used_forms = [] 

322 color_counter = 0 

323 for form in self.forms: 

324 if form.parent_miller_indices == 'twin': 

325 continue 

326 

327 # Determine color 

328 if form.parent_miller_indices in colors: 

329 color = colors[form.parent_miller_indices] 

330 elif form.parent_miller_indices == (1, 1, 1): 

331 color = '#4f81f1' 

332 colors[form.parent_miller_indices] = color 

333 elif tuple(sorted(form.parent_miller_indices)) == (0, 0, 1): 333 ↛ 336line 333 didn't jump to line 336 because the condition on line 333 was always true

334 color = '#f8c73b' 

335 colors[form.parent_miller_indices] = color 

336 elif tuple(sorted(form.parent_miller_indices)) == (0, 1, 1): 

337 color = '#2CA02C' 

338 colors[form.parent_miller_indices] = color 

339 else: 

340 color = default_colors[color_counter % len(default_colors)] 

341 colors[form.parent_miller_indices] = color 

342 color_counter += 1 

343 

344 # Save the used forms to be able to make a legend 

345 # (the colors dict may contain forms that are not present 

346 # if it was supplied by the user) 

347 if form.parent_miller_indices not in used_forms: 347 ↛ 351line 347 didn't jump to line 351 because the condition on line 347 was always true

348 used_forms.append(form.parent_miller_indices) 

349 

350 # Plot all facets in the form 

351 for facet in form.facets: 

352 poly3d = [facet.ordered_vertices] 

353 

354 # Facets 

355 collection = Poly3DCollection(poly3d, alpha=alpha) 

356 collection.set_facecolor(color) 

357 ax.add_collection3d(collection) 

358 

359 # Lines 

360 ax.add_collection3d(Line3DCollection(poly3d, colors='k', linewidths=linewidth)) 

361 

362 # Find proper ranges for the axes 

363 for vertex in facet.ordered_vertices: 

364 for i, v in enumerate(vertex): 

365 if v > maxs[i]: 

366 maxs[i] = v 

367 if v < mins[i]: 

368 mins[i] = v 

369 

370 # Plot fake lines to make a legend 

371 for form in used_forms: 

372 if isinstance(form, tuple): 

373 label = ('{{' + '{}' * len(form) + '}}').format(*form) 

374 else: 

375 label = form 

376 ax.plot([0, 0], [0, 0], color=colors[form], linewidth=8.0, label=label) 

377 

378 # Fiddle with the layout 

379 extent = max(maxs) - min(mins) 

380 bounds = [min(mins) + 0.15 * extent, max(maxs) - 0.15 * extent] 

381 ax.set_xlim(*bounds) 

382 ax.set_ylim(*bounds) 

383 ax.set_zlim(*bounds) 

384 ax.set_xticks([]) 

385 ax.set_yticks([]) 

386 ax.set_zticks([]) 

387 ax.axis('off') 

388 try: 

389 ax.set_box_aspect((1, 1, 1)) 

390 except AttributeError: 

391 # set_box_aspect only available in recent versions of matplotlib 

392 pass 

393 

394 def view(self, alpha: float = 0.85, linewidth: float = 0.3, colors: dict = None, 

395 legend: bool = True, save_as: str = None): 

396 """ 

397 Use matplotlib to view a rendition of the particle. 

398 

399 Parameters 

400 ---------- 

401 alpha 

402 Opacity of the faces 

403 linewidth 

404 Thickness of lines between faces 

405 colors 

406 Allows custom colors for facets of all or a subset of forms, 

407 example `{(1, 1, 1): '#FF0000'}` 

408 legend 

409 Whether or not to show a legend with facet-color definitions 

410 save_as 

411 Filename to save figure as. If None, show the particle 

412 with the GUI instead. 

413 """ 

414 import matplotlib.pyplot as plt 

415 from mpl_toolkits.mplot3d import Axes3D # NOQA 

416 fig = plt.figure(figsize=(6, 6)) 

417 ax = fig.add_subplot(111, projection='3d') 

418 self.make_plot(ax, alpha=alpha, linewidth=linewidth, colors=colors) 

419 plt.subplots_adjust(left=0, bottom=0, right=1, top=1) 

420 if legend: 420 ↛ 422line 420 didn't jump to line 422 because the condition on line 420 was always true

421 fig.legend(frameon=False) 

422 plt.axis('off') 

423 if save_as: 423 ↛ 424line 423 didn't jump to line 424 because the condition on line 423 was never true

424 plt.savefig(save_as, transparent=True) 

425 else: 

426 plt.show() 

427 

428 def write(self, filename: str): 

429 """ 

430 Write particle to file. The file format is derived from the 

431 filename. Currently supported fileformats are: 

432 

433 * Wavefront .obj 

434 

435 Parameters 

436 --------- 

437 filename 

438 Filename of file to write to 

439 """ 

440 supported_file_formats = ['obj'] 

441 fileformat = filename.split('.')[-1] 

442 if fileformat not in supported_file_formats: 

443 raise ValueError('File format {} not supported, '.format(fileformat) + 

444 'supported formats are: ' + ' '.join(supported_file_formats)) 

445 with open(filename, 'w') as f: 

446 if fileformat == 'obj': 446 ↛ exitline 446 didn't jump to the function exit

447 f.write('# Vertices\n') 

448 for form in self.forms: 

449 for facet in form.facets: 

450 for vertex in facet.ordered_vertices[:-1]: 

451 f.write('v {:15.8f} {:15.8f} {:15.8f}\n'.format(*vertex)) 

452 

453 f.write('# Faces\n') 

454 vertex_counter = 1 

455 for form in self.forms: 

456 # Define a group to be able to color each form separately 

457 f.write('g {}\n'.format(form.parent_miller_indices)) 

458 for facet in form.facets: 

459 # Tie the face to the right vertices. Count starts from 1. 

460 s = ['{:5d}'.format(v + vertex_counter) for v in range(len(facet.vertices))] 

461 vertex_counter += len(facet.vertices) 

462 f.write('f ' + ' '.join(s) + '\n') 

463 

464 def _yield_facets(self, only_original_grains: bool = False) -> Facet: 

465 """Generate all facets that are included in the facets dictionary.""" 

466 for form in self.forms: 

467 for facet in form.facets: 

468 if only_original_grains and not facet.original_grain: 

469 continue 

470 yield facet 

471 

472 @property 

473 def volume(self) -> float: 

474 """Returns the volume of the particle""" 

475 volume = 0 

476 for form in self.forms: 

477 volume += form.volume 

478 return volume 

479 

480 @property 

481 def area(self) -> float: 

482 """ 

483 Returns total area of the surface of the particle (not including 

484 twin boundaries). 

485 """ 

486 area = 0 

487 for form in self.forms: 

488 if form.parent_miller_indices != 'twin': 

489 area += form.area 

490 return area 

491 

492 @property 

493 def edge_length(self) -> float: 

494 """Returns total edge length of the particle.""" 

495 edge_length = 0 

496 for form in self.forms: 

497 if form.parent_miller_indices != 'twin': 

498 edge_length += form.edge_length / 2 # Every edge comes twice 

499 return edge_length 

500 

501 @property 

502 def number_of_corners(self) -> float: 

503 """Returns the number of corners (vertices) on the particle.""" 

504 unique_vertices = [] 

505 for form in self.forms: 

506 if form.parent_miller_indices == 'twin': 

507 continue 

508 for facet in form.facets: 

509 for vertex in facet.vertices: 

510 if not is_array_in_arrays(vertex, unique_vertices): 

511 unique_vertices.append(vertex) 

512 return len(unique_vertices) 

513 

514 @property 

515 def surface_energy(self) -> float: 

516 """ 

517 The total surface energy of the particle (including twin boundaries). 

518 """ 

519 E = 0 

520 for form in self.forms: 

521 E += form.surface_energy 

522 return E 

523 

524 @property 

525 def facet_fractions(self) -> Dict[tuple, float]: 

526 """ 

527 Returns a dict specifying fraction of each form 

528 (not including twin boundaries). 

529 """ 

530 facet_fractions = {} 

531 total_area = 0 

532 for form in self.forms: 

533 if form.parent_miller_indices == 'twin': 

534 continue 

535 area = form.area 

536 total_area += area 

537 facet_fractions[form.parent_miller_indices] = facet_fractions.get( 

538 form.parent_miller_indices, 0) + area 

539 for form in facet_fractions: 

540 facet_fractions[form] = facet_fractions[form] / total_area 

541 return facet_fractions 

542 

543 @property 

544 def average_surface_energy(self) -> float: 

545 """ 

546 Average surface energy for the Wulff construction, i.e., 

547 a weighted average over all the facets, where the weights are 

548 the area fraction of each facet. 

549 """ 

550 fractions = self.facet_fractions 

551 weighted_average = 0 

552 used_miller_indices = [] 

553 for form in self.forms: 

554 if form.parent_miller_indices == 'twin' or \ 

555 form.parent_miller_indices in used_miller_indices: 

556 continue 

557 weighted_average += form.energy * fractions[form.parent_miller_indices] 

558 used_miller_indices.append(form.parent_miller_indices) 

559 return weighted_average 

560 

561 def _duplicate_particle(self, symmetries): 

562 """ 

563 Duplicate the particle by applying symmetry operations. 

564 

565 Useful for making an icosahedron from a single tetrahedron for example. 

566 

567 Parameters 

568 ---------- 

569 symmetries: list of NumPy arrays 

570 Each array a symmetry operation(operating on Cartesian coordinates) 

571 that should create a duplicate. 

572 """ 

573 for form in self.forms: 

574 new_facets = [] 

575 for facet in form.facets: 

576 for R in symmetries: 

577 new_facet = Facet(normal=np.dot(R, facet.normal), 

578 energy=facet.energy, 

579 symmetry=None, 

580 original_grain=False) 

581 for vertex in facet.vertices: 

582 new_facet.add_vertex(np.dot(R, vertex)) 

583 

584 new_facets.append(new_facet) 

585 for facet in new_facets: 

586 form.facets.append(facet) 

587 

588 def translate_particle(self, translation: np.ndarray): 

589 """ 

590 Translate the particle. 

591 

592 Parameters 

593 ---------- 

594 translation: list of 3 floats 

595 Translation vector 

596 """ 

597 for facet in self._yield_facets(): 

598 for i, vertex in enumerate(facet.vertices): 

599 facet.vertices[i] = vertex + translation 

600 

601 def rotate_particle(self, rotation: np.ndarray): 

602 """ 

603 Rotate the particle. 

604 

605 Parameters 

606 ---------- 

607 rotation 

608 Rotation matrix 

609 """ 

610 if abs(abs(np.linalg.det(rotation)) - 1.0) > self.tol: 

611 raise ValueError('Provided matrix is not a rotation matrix ' 

612 '(its determinant does not equal 1 or -1).') 

613 for facet in self._yield_facets(): 

614 for i, vertex in enumerate(facet.vertices): 

615 facet.vertices[i] = np.dot(rotation, vertex) 

616 facet.normal = np.dot(rotation, facet.normal) 

617 facet.original_normal = np.dot(rotation, facet.original_normal) 

618 self.standardized_structure.cell = np.dot(rotation, self.standardized_structure.cell.T).T 

619 for atom in self.standardized_structure: 

620 atom.position = np.dot(rotation, atom.position) 

621 

622 def _get_atoms(self, center_shift: Tuple[float, float, float] = None) -> Atoms: 

623 """ 

624 Returns an ASE Atoms object the atoms of which are all 

625 within the facets. In polycrystalline particles, only 

626 one grain will be returned. 

627 

628 Parameters 

629 ---------- 

630 center_shift 

631 Shift of center in Cartesian coordinates. 

632 """ 

633 # Find max and min in x, y and z to bound the volume 

634 # where there may be atoms 

635 # (using scaled coordinates relative to the origin) 

636 mins = [1e9, 1e9, 1e9] 

637 maxs = [-1e9, -1e9, -1e9] 

638 for facet in self._yield_facets(only_original_grains=True): 

639 if facet.original_vertices: 

640 vertices = facet.original_vertices 

641 else: 

642 vertices = facet.vertices 

643 for vertex in vertices: 

644 scaled_vertex = np.linalg.solve(self.standardized_structure.cell.T, vertex) 

645 for i, v in enumerate(scaled_vertex): 

646 if v < mins[i]: 

647 mins[i] = v 

648 if v > maxs[i]: 

649 maxs[i] = v 

650 

651 # Prepare an unnecessarily large atoms object 

652 mins = [int(i) - 1 for i in mins] 

653 maxs = [int(np.ceil(i)) + 1 for i in maxs] 

654 repeat = [int(np.ceil(i)) - int(j) for i, j in zip(maxs, mins)] 

655 atoms = self.standardized_structure.repeat(repeat) 

656 atoms.translate(np.dot(self.standardized_structure.cell.T, mins)) 

657 

658 if center_shift: 

659 atoms.translate(center_shift) 

660 

661 # Loop over all scaled coordinates and check whether 

662 # they are within all facets 

663 to_delete = [] 

664 for atom in atoms: 

665 for facet in self._yield_facets(only_original_grains=True): 

666 if np.dot(atom.position, facet.original_normal) > \ 

667 facet.distance_from_origin + 1e-5: 

668 to_delete.append(atom.index) 

669 break 

670 del atoms[to_delete] 

671 atoms.set_pbc(False) 

672 atoms.set_cell([0., 0., 0.]) 

673 return atoms