Coverage for ase / io / pov.py: 79.78%

356 statements  

« prev     ^ index     » next       coverage.py v7.13.3, created at 2026-02-04 10:20 +0000

1# fmt: off 

2 

3""" 

4Module for povray file format support. 

5 

6See http://www.povray.org/ for details on the format. 

7""" 

8from collections.abc import Sequence 

9from os import unlink 

10from pathlib import Path 

11from subprocess import DEVNULL, check_call 

12 

13import numpy as np 

14 

15from ase import Atoms 

16from ase.constraints import FixAtoms 

17from ase.io.utils import PlottingVariables 

18 

19 

20def pa(array): 

21 """Povray array syntax""" 

22 return '<' + ', '.join(f"{x:>6.2f}" for x in tuple(array)) + '>' 

23 

24 

25def pc(array): 

26 """Povray color syntax""" 

27 if isinstance(array, str): 

28 return 'color ' + array 

29 if isinstance(array, float): 

30 return f'rgb <{array:.2f}>*3'.format(array) 

31 L = len(array) 

32 if L > 2 and L < 6: 

33 return f"rgb{'' if L == 3 else 't' if L == 4 else 'ft'} <" +\ 

34 ', '.join(f"{x:.2f}" for x in tuple(array)) + '>' 

35 

36 

37def get_bondpairs(atoms, radius=1.1): 

38 """Get all pairs of bonding atoms 

39 

40 Return all pairs of atoms which are closer than radius times the 

41 sum of their respective covalent radii. The pairs are returned as 

42 tuples:: 

43 

44 (a, b, (i1, i2, i3)) 

45 

46 so that atoms a bonds to atom b displaced by the vector:: 

47 

48 _ _ _ 

49 i c + i c + i c , 

50 1 1 2 2 3 3 

51 

52 where c1, c2 and c3 are the unit cell vectors and i1, i2, i3 are 

53 integers.""" 

54 

55 from ase.data import covalent_radii 

56 from ase.neighborlist import NeighborList 

57 cutoffs = radius * covalent_radii[atoms.numbers] 

58 nl = NeighborList(cutoffs=cutoffs, self_interaction=False) 

59 nl.update(atoms) 

60 bondpairs = [] 

61 for a in range(len(atoms)): 

62 indices, offsets = nl.get_neighbors(a) 

63 bondpairs.extend([(a, a2, offset) 

64 for a2, offset in zip(indices, offsets)]) 

65 return bondpairs 

66 

67 

68def set_high_bondorder_pairs(bondpairs, high_bondorder_pairs=None): 

69 """Set high bondorder pairs 

70 

71 Modify bondpairs list (from get_bondpairs((atoms)) to include high 

72 bondorder pairs. 

73 

74 Parameters: 

75 ----------- 

76 bondpairs: List of pairs, generated from get_bondpairs(atoms) 

77 high_bondorder_pairs: Dictionary of pairs with high bond orders 

78 using the following format: 

79 { ( a1, b1 ): ( offset1, bond_order1, bond_offset1), 

80 ( a2, b2 ): ( offset2, bond_order2, bond_offset2), 

81 ... 

82 } 

83 offset, bond_order, bond_offset are optional. 

84 However, if they are provided, the 1st value is 

85 offset, 2nd value is bond_order, 

86 3rd value is bond_offset """ 

87 

88 if high_bondorder_pairs is None: 

89 high_bondorder_pairs = {} 

90 bondpairs_ = [] 

91 for pair in bondpairs: 

92 (a, b) = (pair[0], pair[1]) 

93 if (a, b) in high_bondorder_pairs.keys(): 

94 bondpair = [a, b] + [item for item in high_bondorder_pairs[(a, b)]] 

95 bondpairs_.append(bondpair) 

96 elif (b, a) in high_bondorder_pairs.keys(): 

97 bondpair = [a, b] + [item for item in high_bondorder_pairs[(b, a)]] 

98 bondpairs_.append(bondpair) 

99 else: 

100 bondpairs_.append(pair) 

101 return bondpairs_ 

102 

103 

104class POVRAY: 

105 # These new styles were an attempt to port the old styles o the correct 

106 # gamma, many had or still have unphysical light properties inorder to 

107 # acheive a certain look. 

108 material_styles_dict = dict( 

109 simple='finish {phong 0.7 ambient 0.4 diffuse 0.55}', 

110 # In general, 'pale' doesn't conserve energy and can look 

111 # strange in many cases. 

112 pale=('finish {ambient 0.9 diffuse 0.30 roughness 0.001 ' 

113 'specular 0.2 }'), 

114 intermediate=('finish {ambient 0.4 diffuse 0.6 specular 0.1 ' 

115 'roughness 0.04}'), 

116 vmd=( 

117 'finish {ambient 0.2 diffuse 0.80 phong 0.25 phong_size 10.0 ' 

118 'specular 0.2 roughness 0.1}'), 

119 jmol=('finish {ambient 0.4 diffuse 0.6 specular 1 roughness 0.001 ' 

120 'metallic}'), 

121 ase2=('finish {ambient 0.2 brilliance 3 diffuse 0.6 metallic ' 

122 'specular 0.7 roughness 0.04 reflection 0.15}'), 

123 ase3=('finish {ambient 0.4 brilliance 2 diffuse 0.6 metallic ' 

124 'specular 1.0 roughness 0.001 reflection 0.0}'), 

125 glass=('finish {ambient 0.4 diffuse 0.35 specular 1.0 ' 

126 'roughness 0.001}'), 

127 glass2=('finish {ambient 0.3 diffuse 0.3 specular 1.0 ' 

128 'reflection 0.25 roughness 0.001}'), 

129 ) 

130 

131 # These styles were made when assumed_gamma was 1.0 which gives poor color 

132 # reproduction, the correct gamma is 2.2 for the sRGB standard. 

133 material_styles_dict_old = dict( 

134 simple='finish {phong 0.7}', 

135 pale=('finish {ambient 0.5 diffuse 0.85 roughness 0.001 ' 

136 'specular 0.200 }'), 

137 intermediate=('finish {ambient 0.3 diffuse 0.6 specular 0.1 ' 

138 'roughness 0.04}'), 

139 vmd=('finish {ambient 0.0 diffuse 0.65 phong 0.1 phong_size 40.0 ' 

140 'specular 0.5 }'), 

141 jmol=('finish {ambient 0.2 diffuse 0.6 specular 1 roughness 0.001 ' 

142 'metallic}'), 

143 ase2=('finish {ambient 0.05 brilliance 3 diffuse 0.6 metallic ' 

144 'specular 0.7 roughness 0.04 reflection 0.15}'), 

145 ase3=('finish {ambient 0.15 brilliance 2 diffuse 0.6 metallic ' 

146 'specular 1.0 roughness 0.001 reflection 0.0}'), 

147 glass=('finish {ambient 0.05 diffuse 0.3 specular 1.0 ' 

148 'roughness 0.001}'), 

149 glass2=('finish {ambient 0.01 diffuse 0.3 specular 1.0 ' 

150 'reflection 0.25 roughness 0.001}'), 

151 ) 

152 

153 def __init__(self, cell, cell_vertices, positions, diameters, colors, 

154 image_width, image_height, constraints=(), isosurfaces=[], 

155 display=False, pause=True, transparent=True, canvas_width=None, 

156 canvas_height=None, camera_dist=50., image_plane=None, 

157 camera_type='orthographic', point_lights=[], 

158 area_light=[(2., 3., 40.), 'White', .7, .7, 3, 3], 

159 background='White', textures=None, transmittances=None, 

160 depth_cueing=False, cue_density=5e-3, 

161 celllinewidth=0.05, bondlinewidth=0.10, bondatoms=[], 

162 exportconstraints=False): 

163 """ 

164 # x, y is the image plane, z is *out* of the screen 

165 cell: ase.cell 

166 cell object 

167 cell_vertices: 2-d numpy array 

168 contains the 8 vertices of the cell, each with three coordinates 

169 positions: 2-d numpy array 

170 number of atoms length array with three coordinates for positions 

171 diameters: 1-d numpy array 

172 diameter of atoms (in order with positions) 

173 colors: list of str 

174 colors of atoms (in order with positions) 

175 image_width: float 

176 image width in pixels 

177 image_height: float 

178 image height in pixels 

179 constraints: Atoms.constraints 

180 constraints to be visualized 

181 isosurfaces: list of POVRAYIsosurface 

182 composite object to write/render POVRAY isosurfaces 

183 display: bool 

184 display while rendering 

185 pause: bool 

186 pause when done rendering (only if display) 

187 transparent: bool 

188 make background transparent 

189 canvas_width: int 

190 width of canvas in pixels 

191 canvas_height: int 

192 height of canvas in pixels 

193 camera_dist: float 

194 distance from camera to front atom 

195 image_plane: float 

196 distance from front atom to image plane 

197 camera_type: str 

198 if 'orthographic' perspective, ultra_wide_angle 

199 point_lights: list of 2-element sequences 

200 like [[loc1, color1], [loc2, color2],...] 

201 area_light: 3-element sequence of location (3-tuple), color (str), 

202 width (float), height (float), 

203 Nlamps_x (int), Nlamps_y (int) 

204 example [(2., 3., 40.), 'White', .7, .7, 3, 3] 

205 background: str 

206 color specification, e.g., 'White' 

207 textures: list of str 

208 length of atoms list of texture names 

209 transmittances: list of floats 

210 length of atoms list of transmittances of the atoms 

211 depth_cueing: bool 

212 whether or not to use depth cueing a.k.a. fog 

213 use with care - adjust the camera_distance to be closer 

214 cue_density: float 

215 if there is depth_cueing, how dense is it (how dense is the fog) 

216 celllinewidth: float 

217 radius of the cylinders representing the cell (Ang.) 

218 bondlinewidth: float 

219 radius of the cylinders representing bonds (Ang.) 

220 bondatoms: list of lists (polymorphic) 

221 [[atom1, atom2], ... ] pairs of bonding atoms 

222 For bond order > 1 = [[atom1, atom2, offset, 

223 bond_order, bond_offset], 

224 ... ] 

225 bond_order: 1, 2, 3 for single, double, 

226 and triple bond 

227 bond_offset: vector for shifting bonds from 

228 original position. Coordinates are 

229 in Angstrom unit. 

230 exportconstraints: bool 

231 honour FixAtoms and mark?""" 

232 

233 # attributes from initialization 

234 self.area_light = area_light 

235 self.background = background 

236 self.bondatoms = bondatoms 

237 self.bondlinewidth = bondlinewidth 

238 self.camera_dist = camera_dist 

239 self.camera_type = camera_type 

240 self.celllinewidth = celllinewidth 

241 self.cue_density = cue_density 

242 self.depth_cueing = depth_cueing 

243 self.display = display 

244 self.exportconstraints = exportconstraints 

245 self.isosurfaces = isosurfaces 

246 self.pause = pause 

247 self.point_lights = point_lights 

248 self.textures = textures 

249 self.transmittances = transmittances 

250 self.transparent = transparent 

251 

252 self.image_width = image_width 

253 self.image_height = image_height 

254 self.colors = colors 

255 self.cell = cell 

256 self.diameters = diameters 

257 

258 # calculations based on passed inputs 

259 

260 z0 = positions[:, 2].max() 

261 self.offset = (image_width / 2, image_height / 2, z0) 

262 self.positions = positions - self.offset 

263 

264 if cell_vertices is not None: 

265 self.cell_vertices = cell_vertices - self.offset 

266 self.cell_vertices.shape = (2, 2, 2, 3) 

267 else: 

268 self.cell_vertices = None 

269 

270 ratio = float(self.image_width) / self.image_height 

271 if canvas_width is None: 

272 if canvas_height is None: 

273 self.canvas_width = min(self.image_width * 15, 640) 

274 # a guess should respect the aspect ratio 

275 self.canvas_height = self.canvas_width / ratio 

276 else: 

277 self.canvas_width = canvas_height * ratio 

278 self.canvas_height = canvas_height 

279 elif canvas_height is None: 

280 self.canvas_width = canvas_width 

281 self.canvas_height = self.canvas_width / ratio 

282 else: 

283 raise RuntimeError("Can't set *both* width and height!") 

284 

285 # Distance to image plane from camera 

286 if image_plane is None: 

287 if self.camera_type == 'orthographic': 

288 self.image_plane = 1 - self.camera_dist 

289 else: 

290 self.image_plane = 0 

291 self.image_plane += self.camera_dist 

292 

293 self.constrainatoms = [] 

294 for c in constraints: 

295 if isinstance(c, FixAtoms): 

296 # self.constrainatoms.extend(c.index) # is this list-like? 

297 for n, i in enumerate(c.index): 

298 self.constrainatoms += [i] 

299 

300 @classmethod 

301 def from_PlottingVariables(cls, pvars, **kwargs): 

302 cell = pvars.cell 

303 cell_vertices = pvars.cell_vertices 

304 if 'colors' in kwargs: 

305 colors = kwargs.pop('colors') 

306 else: 

307 colors = pvars.colors 

308 diameters = pvars.d 

309 image_height = pvars.h 

310 image_width = pvars.w 

311 positions = pvars.positions 

312 constraints = pvars.constraints 

313 return cls(cell=cell, cell_vertices=cell_vertices, colors=colors, 

314 constraints=constraints, diameters=diameters, 

315 image_height=image_height, image_width=image_width, 

316 positions=positions, **kwargs) 

317 

318 @classmethod 

319 def from_atoms(cls, atoms, **kwargs): 

320 return cls.from_plotting_variables( 

321 PlottingVariables(atoms, scale=1.0), **kwargs) 

322 

323 def write_ini(self, path): 

324 """Write ini file.""" 

325 

326 ini_str = f"""\ 

327Input_File_Name={path.with_suffix('.pov').name} 

328Output_to_File=True 

329Output_File_Type=N 

330Output_Alpha={'on' if self.transparent else 'off'} 

331; if you adjust Height, and width, you must preserve the ratio 

332; Width / Height = {self.canvas_width / self.canvas_height:f} 

333Width={self.canvas_width} 

334Height={self.canvas_height} 

335Antialias=True 

336Antialias_Threshold=0.1 

337Display={self.display} 

338Display_Gamma=2.2 

339Pause_When_Done={self.pause} 

340Verbose=False 

341""" 

342 with open(path, 'w') as fd: 

343 fd.write(ini_str) 

344 return path 

345 

346 def write_pov(self, path): 

347 """Write pov file.""" 

348 

349 point_lights = '\n'.join(f"light_source {{{pa(loc)} {pc(rgb)}}}" 

350 for loc, rgb in self.point_lights) 

351 

352 area_light = '' 

353 if self.area_light is not None: 

354 loc, color, width, height, nx, ny = self.area_light 

355 area_light += f"""\nlight_source {{{pa(loc)} {pc(color)} 

356 area_light <{width:.2f}, 0, 0>, <0, {height:.2f}, 0>, {nx:n}, {ny:n} 

357 adaptive 1 jitter}}""" 

358 

359 fog = '' 

360 if self.depth_cueing and (self.cue_density >= 1e-4): 

361 # same way vmd does it 

362 if self.cue_density > 1e4: 

363 # larger does not make any sense 

364 dist = 1e-4 

365 else: 

366 dist = 1. / self.cue_density 

367 fog += f'fog {{fog_type 1 distance {dist:.4f} '\ 

368 f'color {pc(self.background)}}}' 

369 

370 mat_style_keys = (f'#declare {k} = {v}' 

371 for k, v in self.material_styles_dict.items()) 

372 mat_style_keys = '\n'.join(mat_style_keys) 

373 

374 # Draw unit cell 

375 cell_vertices = '' 

376 if self.cell_vertices is not None: 

377 for c in range(3): 

378 for j in ([0, 0], [1, 0], [1, 1], [0, 1]): 

379 p1 = self.cell_vertices[tuple(j[:c]) + (0,) + tuple(j[c:])] 

380 p2 = self.cell_vertices[tuple(j[:c]) + (1,) + tuple(j[c:])] 

381 

382 distance = np.linalg.norm(p2 - p1) 

383 if distance < 1e-12: 

384 continue 

385 

386 cell_vertices += f'cylinder {{{pa(p1)}, {pa(p2)}, '\ 

387 'Rcell pigment {Black}}\n' 

388 # all strings are f-strings for consistency 

389 cell_vertices = cell_vertices.strip('\n') 

390 

391 # Draw atoms 

392 a = 0 

393 atoms = '' 

394 for loc, dia, col in zip(self.positions, self.diameters, self.colors): 

395 tex = 'ase3' 

396 trans = 0. 

397 if self.textures is not None: 

398 tex = self.textures[a] 

399 if self.transmittances is not None: 

400 trans = self.transmittances[a] 

401 atoms += f'atom({pa(loc)}, {dia / 2.:.2f}, {pc(col)}, '\ 

402 f'{trans}, {tex}) // #{a:n}\n' 

403 a += 1 

404 atoms = atoms.strip('\n') 

405 

406 # Draw atom bonds 

407 bondatoms = '' 

408 for pair in self.bondatoms: 

409 # Make sure that each pair has 4 componets: a, b, offset, 

410 # bond_order, bond_offset 

411 # a, b: atom index to draw bond 

412 # offset: original meaning to make offset for mid-point. 

413 # bond_oder: if not supplied, set it to 1 (single bond). 

414 # It can be 1, 2, 3, corresponding to single, 

415 # double, triple bond 

416 # bond_offset: displacement from original bond position. 

417 # Default is (bondlinewidth, bondlinewidth, 0) 

418 # for bond_order > 1. 

419 if len(pair) == 2: 

420 a, b = pair 

421 offset = (0, 0, 0) 

422 bond_order = 1 

423 bond_offset = (0, 0, 0) 

424 elif len(pair) == 3: 

425 a, b, offset = pair 

426 bond_order = 1 

427 bond_offset = (0, 0, 0) 

428 elif len(pair) == 4: 

429 a, b, offset, bond_order = pair 

430 bond_offset = (self.bondlinewidth, self.bondlinewidth, 0) 

431 elif len(pair) > 4: 

432 a, b, offset, bond_order, bond_offset = pair 

433 else: 

434 raise RuntimeError('Each list in bondatom must have at least ' 

435 '2 entries. Error at %s' % pair) 

436 

437 if len(offset) != 3: 

438 raise ValueError('offset must have 3 elements. ' 

439 'Error at %s' % pair) 

440 if len(bond_offset) != 3: 

441 raise ValueError('bond_offset must have 3 elements. ' 

442 'Error at %s' % pair) 

443 if bond_order not in [0, 1, 2, 3]: 

444 raise ValueError('bond_order must be either 0, 1, 2, or 3. ' 

445 'Error at %s' % pair) 

446 

447 # Up to here, we should have all a, b, offset, bond_order, 

448 # bond_offset for all bonds. 

449 

450 # Rotate bond_offset so that its direction is 90 deg. off the bond 

451 # Utilize Atoms object to rotate 

452 if bond_order > 1 and np.linalg.norm(bond_offset) > 1.e-9: 

453 tmp_atoms = Atoms('H3') 

454 tmp_atoms.set_cell(self.cell) 

455 tmp_atoms.set_positions([ 

456 self.positions[a], 

457 self.positions[b], 

458 self.positions[b] + np.array(bond_offset), 

459 ]) 

460 tmp_atoms.center() 

461 tmp_atoms.set_angle(0, 1, 2, 90) 

462 bond_offset = tmp_atoms[2].position - tmp_atoms[1].position 

463 

464 R = np.dot(offset, self.cell) 

465 mida = 0.5 * (self.positions[a] + self.positions[b] + R) 

466 midb = 0.5 * (self.positions[a] + self.positions[b] - R) 

467 if self.textures is not None: 

468 texa = self.textures[a] 

469 texb = self.textures[b] 

470 else: 

471 texa = texb = 'ase3' 

472 

473 if self.transmittances is not None: 

474 transa = self.transmittances[a] 

475 transb = self.transmittances[b] 

476 else: 

477 transa = transb = 0. 

478 

479 # draw bond, according to its bond_order. 

480 # bond_order == 0: No bond is plotted 

481 # bond_order == 1: use original code 

482 # bond_order == 2: draw two bonds, one is shifted by bond_offset/2, 

483 # and another is shifted by -bond_offset/2. 

484 # bond_order == 3: draw two bonds, one is shifted by bond_offset, 

485 # and one is shifted by -bond_offset, and the 

486 # other has no shift. 

487 # To shift the bond, add the shift to the first two coordinate in 

488 # write statement. 

489 

490 posa = self.positions[a] 

491 posb = self.positions[b] 

492 cola = self.colors[a] 

493 colb = self.colors[b] 

494 

495 if bond_order == 1: 

496 draw_tuples = ( 

497 (posa, mida, cola, transa, texa), 

498 (posb, midb, colb, transb, texb)) 

499 

500 elif bond_order == 2: 

501 bs = [x / 2 for x in bond_offset] 

502 draw_tuples = ( 

503 (posa - bs, mida - bs, cola, transa, texa), 

504 (posb - bs, midb - bs, colb, transb, texb), 

505 (posa + bs, mida + bs, cola, transa, texa), 

506 (posb + bs, midb + bs, colb, transb, texb)) 

507 

508 elif bond_order == 3: 

509 bs = bond_offset 

510 draw_tuples = ( 

511 (posa, mida, cola, transa, texa), 

512 (posb, midb, colb, transb, texb), 

513 (posa + bs, mida + bs, cola, transa, texa), 

514 (posb + bs, midb + bs, colb, transb, texb), 

515 (posa - bs, mida - bs, cola, transa, texa), 

516 (posb - bs, midb - bs, colb, transb, texb)) 

517 

518 bondatoms += ''.join(f'cylinder {{{pa(p)}, ' 

519 f'{pa(m)}, Rbond texture{{pigment ' 

520 f'{{color {pc(c)} ' 

521 f'transmit {tr}}} finish{{{tx}}}}}}}\n' 

522 for p, m, c, tr, tx in 

523 draw_tuples) 

524 

525 bondatoms = bondatoms.strip('\n') 

526 

527 # Draw constraints if requested 

528 constraints = '' 

529 if self.exportconstraints: 

530 for a in self.constrainatoms: 

531 dia = self.diameters[a] 

532 loc = self.positions[a] 

533 trans = 0.0 

534 if self.transmittances is not None: 

535 trans = self.transmittances[a] 

536 constraints += f'constrain({pa(loc)}, {dia / 2.:.2f}, Black, '\ 

537 f'{trans}, {tex}) // #{a:n} \n' 

538 constraints = constraints.strip('\n') 

539 

540 pov = f"""#version 3.6; 

541#include "colors.inc" 

542#include "finish.inc" 

543 

544global_settings {{assumed_gamma 2.2 max_trace_level 6}} 

545background {{{pc(self.background)}{' transmit 1.0' if self.transparent else ''}}} 

546camera {{{self.camera_type} 

547 right -{self.image_width:.2f}*x up {self.image_height:.2f}*y 

548 direction {self.image_plane:.2f}*z 

549 location <0,0,{self.camera_dist:.2f}> look_at <0,0,0>}} 

550{point_lights} 

551{area_light if area_light != '' else '// no area light'} 

552{fog if fog != '' else '// no fog'} 

553{mat_style_keys} 

554#declare Rcell = {self.celllinewidth:.3f}; 

555#declare Rbond = {self.bondlinewidth:.3f}; 

556 

557#macro atom(LOC, R, COL, TRANS, FIN) 

558 sphere{{LOC, R texture{{pigment{{color COL transmit TRANS}} finish{{FIN}}}}}} 

559#end 

560#macro constrain(LOC, R, COL, TRANS FIN) 

561union{{torus{{R, Rcell rotate 45*z texture{{pigment{{color COL transmit TRANS}} finish{{FIN}}}}}} 

562 torus{{R, Rcell rotate -45*z texture{{pigment{{color COL transmit TRANS}} finish{{FIN}}}}}} 

563 translate LOC}} 

564#end 

565 

566{cell_vertices if cell_vertices != '' else '// no cell vertices'} 

567{atoms} 

568{bondatoms} 

569{constraints if constraints != '' else '// no constraints'} 

570""" # noqa: E501 

571 

572 with open(path, 'w') as fd: 

573 fd.write(pov) 

574 

575 return path 

576 

577 def write(self, pov_path): 

578 pov_path = require_pov(pov_path) 

579 ini_path = pov_path.with_suffix('.ini') 

580 self.write_ini(ini_path) 

581 self.write_pov(pov_path) 

582 if self.isosurfaces is not None: 

583 with open(pov_path, 'a') as fd: 

584 for iso in self.isosurfaces: 

585 fd.write(iso.format_mesh()) 

586 return POVRAYInputs(ini_path) 

587 

588 

589def require_pov(path): 

590 path = Path(path) 

591 if path.suffix != '.pov': 

592 raise ValueError(f'Expected .pov path, got {path}') 

593 return path 

594 

595 

596class POVRAYInputs: 

597 def __init__(self, path): 

598 self.path = path 

599 

600 def render(self, povray_executable='povray', stderr=DEVNULL, 

601 clean_up=False): 

602 cmd = [povray_executable, str(self.path)] 

603 

604 check_call(cmd, stderr=stderr) 

605 png_path = self.path.with_suffix('.png').absolute() 

606 if not png_path.is_file(): 

607 raise RuntimeError(f'Povray left no output PNG file "{png_path}"') 

608 

609 if clean_up: 

610 unlink(self.path) 

611 unlink(self.path.with_suffix('.pov')) 

612 

613 return png_path 

614 

615 

616class POVRAYIsosurface: 

617 def __init__(self, density_grid, cut_off, cell, cell_origin, 

618 closed_edges=False, gradient_ascending=False, 

619 color=(0.85, 0.80, 0.25, 0.2), material='ase3'): 

620 """ 

621 density_grid: 3D float ndarray 

622 A regular grid on that spans the cell. The first dimension 

623 corresponds to the first cell vector and so on. 

624 cut_off: float 

625 The density value of the isosurface. 

626 cell: 2D float ndarray or ASE cell object 

627 The 3 vectors which give the cell's repetition 

628 cell_origin: 4 float tuple 

629 The cell origin as used by POVRAY object 

630 closed_edges: bool 

631 Setting this will fill in isosurface edges at the cell boundaries. 

632 Filling in the edges can help with visualizing 

633 highly porous structures. 

634 gradient_ascending: bool 

635 Lets you pick the area you want to enclose, i.e., should the denser 

636 or less dense area be filled in. 

637 color: povray color string, float, or float tuple 

638 1 float is interpreted as grey scale, a 3 float tuple is rgb, 

639 4 float tuple is rgbt, and 5 float tuple is rgbft, where 

640 t is transmission fraction and f is filter fraction. 

641 Named Povray colors are set in colors.inc 

642 (http://wiki.povray.org/content/Reference:Colors.inc) 

643 material: string 

644 Can be a finish macro defined by POVRAY.material_styles 

645 or a full Povray material {...} specification. Using a 

646 full material specification willoverride the color parameter. 

647 """ 

648 

649 self.gradient_direction = 'ascent' if gradient_ascending else 'descent' 

650 self.color = color 

651 self.material = material 

652 self.closed_edges = closed_edges 

653 self._cut_off = cut_off 

654 

655 if self.gradient_direction == 'ascent': 

656 cv = 2 * cut_off 

657 else: 

658 cv = 0 

659 

660 if closed_edges: 

661 shape_old = density_grid.shape 

662 # since well be padding, we need to keep the data at origin 

663 cell_origin += -(1.0 / np.array(shape_old)) @ cell 

664 density_grid = np.pad( 

665 density_grid, pad_width=( 

666 1,), mode='constant', constant_values=cv) 

667 shape_new = density_grid.shape 

668 s = np.array(shape_new) / np.array(shape_old) 

669 cell = cell @ np.diag(s) 

670 

671 self.cell = cell 

672 self.cell_origin = cell_origin 

673 self.density_grid = density_grid 

674 self.spacing = tuple(1.0 / np.array(self.density_grid.shape)) 

675 

676 scaled_verts, faces, _normals, _values = self.compute_mesh( 

677 self.density_grid, 

678 self.cut_off, 

679 self.spacing, 

680 self.gradient_direction) 

681 

682 # The verts are scaled by default, this is the super easy way of 

683 # distributing them in real space but it's easier to do affine 

684 # transformations/rotations on a unit cube so I leave it like that 

685 # verts = scaled_verts.dot(self.cell) 

686 self.verts = scaled_verts 

687 self.faces = faces 

688 

689 @property 

690 def cut_off(self): 

691 return self._cut_off 

692 

693 @cut_off.setter 

694 def cut_off(self, value): 

695 raise Exception("Use the set_cut_off method") 

696 

697 def set_cut_off(self, value): 

698 self._cut_off = value 

699 

700 if self.gradient_direction == 'ascent': 

701 cv = 2 * self.cut_off 

702 else: 

703 cv = 0 

704 

705 if self.closed_edges: 

706 shape_old = self.density_grid.shape 

707 # since well be padding, we need to keep the data at origin 

708 self.cell_origin += -(1.0 / np.array(shape_old)) @ self.cell 

709 self.density_grid = np.pad( 

710 self.density_grid, pad_width=( 

711 1,), mode='constant', constant_values=cv) 

712 shape_new = self.density_grid.shape 

713 s = np.array(shape_new) / np.array(shape_old) 

714 self.cell = self.cell @ np.diag(s) 

715 

716 self.spacing = tuple(1.0 / np.array(self.density_grid.shape)) 

717 

718 scaled_verts, faces, _, _ = self.compute_mesh( 

719 self.density_grid, 

720 self.cut_off, 

721 self.spacing, 

722 self.gradient_direction) 

723 

724 self.verts = scaled_verts 

725 self.faces = faces 

726 

727 @classmethod 

728 def from_POVRAY(cls, povray, density_grid, cut_off, **kwargs): 

729 return cls(cell=povray.cell, 

730 cell_origin=povray.cell_vertices[0, 0, 0], 

731 density_grid=density_grid, 

732 cut_off=cut_off, **kwargs) 

733 

734 @staticmethod 

735 def wrapped_triples_section(triple_list, 

736 triple_format="<{:f}, {:f}, {:f}>".format, 

737 triples_per_line=4): 

738 

739 triples = [triple_format(*x) for x in triple_list] 

740 n = len(triples) 

741 s = '' 

742 tpl = triples_per_line 

743 c = 0 

744 

745 while c < n - tpl: 

746 c += tpl 

747 s += '\n ' 

748 s += ', '.join(triples[c - tpl:c]) 

749 s += '\n ' 

750 s += ', '.join(triples[c:]) 

751 return s 

752 

753 @staticmethod 

754 def compute_mesh(density_grid, cut_off, spacing, gradient_direction): 

755 """ 

756 

757 Import statement is in this method and not file header 

758 since few users will use isosurface rendering. 

759 

760 Returns scaled_verts, faces, normals, values. See skimage docs. 

761 

762 """ 

763 

764 # marching_cubes name was changed in skimage v0.19 

765 try: 

766 # New skimage 

767 from skimage.measure import marching_cubes 

768 except ImportError: 

769 # Old skimage (remove at some point) 

770 from skimage.measure import marching_cubes_lewiner as marching_cubes 

771 

772 return marching_cubes( 

773 density_grid, 

774 level=cut_off, 

775 spacing=spacing, 

776 gradient_direction=gradient_direction, 

777 allow_degenerate=False) 

778 

779 def format_mesh(self): 

780 """Returns a formatted data output for POVRAY files 

781 

782 Example: 

783 material = ''' 

784 material { // This material looks like pink jelly 

785 texture { 

786 pigment { rgbt <0.8, 0.25, 0.25, 0.5> } 

787 finish{ diffuse 0.85 ambient 0.99 brilliance 3 specular 0.5 roughness 0.001 

788 reflection { 0.05, 0.98 fresnel on exponent 1.5 } 

789 conserve_energy 

790 } 

791 } 

792 interior { ior 1.3 } 

793 } 

794 photons { 

795 target 

796 refraction on 

797 reflection on 

798 collect on 

799 }''' 

800 """ # noqa: E501 

801 

802 if self.material in POVRAY.material_styles_dict: 

803 material = f"""material {{ 

804 texture {{ 

805 pigment {{ {pc(self.color)} }} 

806 finish {{ {self.material} }} 

807 }} 

808 }}""" 

809 else: 

810 material = self.material 

811 

812 # Start writing the mesh2 

813 vertex_vectors = self.wrapped_triples_section( 

814 triple_list=self.verts, 

815 triple_format="<{:f}, {:f}, {:f}>".format, 

816 triples_per_line=4) 

817 

818 face_indices = self.wrapped_triples_section( 

819 triple_list=self.faces, 

820 triple_format="<{:n}, {:n}, {:n}>".format, 

821 triples_per_line=5) 

822 

823 cell = self.cell 

824 cell_or = self.cell_origin 

825 mesh2 = f"""\n\nmesh2 {{ 

826 vertex_vectors {{ {len(self.verts):n}, 

827 {vertex_vectors} 

828 }} 

829 face_indices {{ {len(self.faces):n}, 

830 {face_indices} 

831 }} 

832{material if material != '' else '// no material'} 

833 matrix < {cell[0][0]:f}, {cell[0][1]:f}, {cell[0][2]:f}, 

834 {cell[1][0]:f}, {cell[1][1]:f}, {cell[1][2]:f}, 

835 {cell[2][0]:f}, {cell[2][1]:f}, {cell[2][2]:f}, 

836 {cell_or[0]:f}, {cell_or[1]:f}, {cell_or[2]:f}> 

837 }} 

838 """ 

839 return mesh2 

840 

841 

842def pop_deprecated(dct, name): 

843 import warnings 

844 if name in dct: 

845 del dct[name] 

846 warnings.warn(f'The "{name}" keyword of write_pov() is deprecated ' 

847 'and has no effect; this will raise an error in the ' 

848 'future.', FutureWarning) 

849 

850 

851def write_pov(filename, atoms, *, 

852 povray_settings=None, isosurface_data=None, 

853 **generic_projection_settings): 

854 

855 for name in ['run_povray', 'povray_path', 'stderr', 'extras']: 

856 pop_deprecated(generic_projection_settings, name) 

857 

858 if povray_settings is None: 

859 povray_settings = {} 

860 

861 pvars = PlottingVariables(atoms, scale=1.0, **generic_projection_settings) 

862 pov_obj = POVRAY.from_PlottingVariables(pvars, **povray_settings) 

863 

864 if isosurface_data is None: 

865 isosurface_data = [] 

866 elif not isinstance(isosurface_data, Sequence): 

867 isosurface_data = [isosurface_data] 

868 

869 isosurfaces = [] 

870 for isodata in isosurface_data: 

871 if isinstance(isodata, POVRAYIsosurface): 

872 iso = isodata 

873 else: 

874 iso = POVRAYIsosurface.from_POVRAY(pov_obj, **isodata) 

875 isosurfaces.append(iso) 

876 pov_obj.isosurfaces = isosurfaces 

877 

878 return pov_obj.write(filename)