Coverage for /builds/debichem-team/python-ase/ase/gui/view.py: 63.41%
492 statements
« prev ^ index » next coverage.py v7.5.3, created at 2025-03-06 04:00 +0000
« prev ^ index » next coverage.py v7.5.3, created at 2025-03-06 04:00 +0000
1from math import cos, sin, sqrt
2from os.path import basename
4import numpy as np
6from ase.calculators.calculator import PropertyNotImplementedError
7from ase.data import atomic_numbers
8from ase.data.colors import jmol_colors
9from ase.geometry import complete_cell
10from ase.gui.colors import ColorWindow
11from ase.gui.i18n import ngettext
12from ase.gui.render import Render
13from ase.gui.repeat import Repeat
14from ase.gui.rotate import Rotate
15from ase.gui.utils import get_magmoms
16from ase.utils import rotate
18GREEN = '#74DF00'
19PURPLE = '#AC58FA'
20BLACKISH = '#151515'
23def get_cell_coordinates(cell, shifted=False):
24 """Get start and end points of lines segments used to draw cell."""
25 nn = []
26 for c in range(3):
27 v = cell[c]
28 d = sqrt(np.dot(v, v))
29 if d < 1e-12:
30 n = 0
31 else:
32 n = max(2, int(d / 0.3))
33 nn.append(n)
34 B1 = np.zeros((2, 2, sum(nn), 3))
35 B2 = np.zeros((2, 2, sum(nn), 3))
36 n1 = 0
37 for c, n in enumerate(nn):
38 n2 = n1 + n
39 h = 1.0 / (2 * n - 1)
40 R = np.arange(n) * (2 * h)
42 for i, j in [(0, 0), (0, 1), (1, 0), (1, 1)]:
43 B1[i, j, n1:n2, c] = R
44 B1[i, j, n1:n2, (c + 1) % 3] = i
45 B1[i, j, n1:n2, (c + 2) % 3] = j
46 B2[:, :, n1:n2] = B1[:, :, n1:n2]
47 B2[:, :, n1:n2, c] += h
48 n1 = n2
49 B1.shape = (-1, 3)
50 B2.shape = (-1, 3)
51 if shifted:
52 B1 -= 0.5
53 B2 -= 0.5
54 return B1, B2
57def get_bonds(atoms, covalent_radii):
58 from ase.neighborlist import PrimitiveNeighborList
60 nl = PrimitiveNeighborList(
61 covalent_radii * 1.5,
62 skin=0.0,
63 self_interaction=False,
64 bothways=False,
65 )
66 nl.update(atoms.pbc, atoms.get_cell(complete=True), atoms.positions)
67 number_of_neighbors = sum(indices.size for indices in nl.neighbors)
68 number_of_pbc_neighbors = sum(
69 offsets.any(axis=1).sum() for offsets in nl.displacements
70 ) # sum up all neighbors that have non-zero supercell offsets
71 nbonds = number_of_neighbors + number_of_pbc_neighbors
73 bonds = np.empty((nbonds, 5), int)
74 if nbonds == 0:
75 return bonds
77 n1 = 0
78 for a in range(len(atoms)):
79 indices, offsets = nl.get_neighbors(a)
80 n2 = n1 + len(indices)
81 bonds[n1:n2, 0] = a
82 bonds[n1:n2, 1] = indices
83 bonds[n1:n2, 2:] = offsets
84 n1 = n2
86 i = bonds[:n2, 2:].any(1)
87 pbcbonds = bonds[:n2][i]
88 bonds[n2:, 0] = pbcbonds[:, 1]
89 bonds[n2:, 1] = pbcbonds[:, 0]
90 bonds[n2:, 2:] = -pbcbonds[:, 2:]
91 return bonds
94class View:
95 def __init__(self, rotations):
96 self.colormode = 'jmol' # The default colors
97 self.labels = None
98 self.axes = rotate(rotations)
99 self.configured = False
100 self.frame = None
102 # XXX
103 self.colormode = 'jmol'
104 self.colors = {
105 i: ('#{:02X}{:02X}{:02X}'.format(*(int(x * 255) for x in rgb)))
106 for i, rgb in enumerate(jmol_colors)
107 }
108 # scaling factors for vectors
109 self.force_vector_scale = self.config['force_vector_scale']
110 self.velocity_vector_scale = self.config['velocity_vector_scale']
112 # buttons
113 self.b1 = 1 # left
114 self.b3 = 3 # right
115 if self.config['swap_mouse']:
116 self.b1 = 3
117 self.b3 = 1
119 @property
120 def atoms(self):
121 return self.images[self.frame]
123 def set_frame(self, frame=None, focus=False):
124 if frame is None:
125 frame = self.frame
126 assert frame < len(self.images)
127 self.frame = frame
128 self.set_atoms(self.images[frame])
130 fname = self.images.filenames[frame]
131 if fname is None:
132 header = 'ase.gui'
133 else:
134 # fname is actually not necessarily the filename but may
135 # contain indexing like filename@0
136 header = basename(fname)
138 images_loaded_text = ngettext(
139 'one image loaded',
140 '{} images loaded',
141 len(self.images)
142 ).format(len(self.images))
144 self.window.title = f'{header} — {images_loaded_text}'
146 if focus:
147 self.focus()
148 else:
149 self.draw()
151 def get_bonds(self, atoms):
152 # this method exists rather than just using the standalone function
153 # so that it can be overridden by external libraries
154 return get_bonds(atoms, self.get_covalent_radii(atoms))
156 def set_atoms(self, atoms):
157 natoms = len(atoms)
159 if self.showing_cell():
160 B1, B2 = get_cell_coordinates(atoms.cell,
161 self.config['shift_cell'])
162 else:
163 B1 = B2 = np.zeros((0, 3))
165 if self.showing_bonds():
166 atomscopy = atoms.copy()
167 atomscopy.cell *= self.images.repeat[:, np.newaxis]
168 bonds = self.get_bonds(atomscopy)
169 else:
170 bonds = np.empty((0, 5), int)
172 # X is all atomic coordinates, and starting points of vectors
173 # like bonds and cell segments.
174 # The reason to have them all in one big list is that we like to
175 # eventually rotate/sort it by Z-order when rendering.
177 # Also B are the end points of line segments.
179 self.X = np.empty((natoms + len(B1) + len(bonds), 3))
180 self.X_pos = self.X[:natoms]
181 self.X_pos[:] = atoms.positions
182 self.X_cell = self.X[natoms:natoms + len(B1)]
183 self.X_bonds = self.X[natoms + len(B1):]
185 cell = atoms.cell
186 ncellparts = len(B1)
187 nbonds = len(bonds)
189 self.X_cell[:] = np.dot(B1, cell)
190 self.B = np.empty((ncellparts + nbonds, 3))
191 self.B[:ncellparts] = np.dot(B2, cell)
193 if nbonds > 0:
194 P = atoms.positions
195 Af = self.images.repeat[:, np.newaxis] * cell
196 a = P[bonds[:, 0]]
197 b = P[bonds[:, 1]] + np.dot(bonds[:, 2:], Af) - a
198 d = (b**2).sum(1)**0.5
199 r = 0.65 * self.get_covalent_radii()
200 x0 = (r[bonds[:, 0]] / d).reshape((-1, 1))
201 x1 = (r[bonds[:, 1]] / d).reshape((-1, 1))
202 self.X_bonds[:] = a + b * x0
203 b *= 1.0 - x0 - x1
204 b[bonds[:, 2:].any(1)] *= 0.5
205 self.B[ncellparts:] = self.X_bonds + b
207 def showing_bonds(self):
208 return self.window['toggle-show-bonds']
210 def showing_cell(self):
211 return self.window['toggle-show-unit-cell']
213 def toggle_show_unit_cell(self, key=None):
214 self.set_frame()
216 def update_labels(self):
217 index = self.window['show-labels']
218 if index == 0:
219 self.labels = None
220 elif index == 1:
221 self.labels = list(range(len(self.atoms)))
222 elif index == 2:
223 self.labels = list(get_magmoms(self.atoms))
224 elif index == 4:
225 Q = self.atoms.get_initial_charges()
226 self.labels = [f'{q:.4g}' for q in Q]
227 else:
228 self.labels = self.atoms.get_chemical_symbols()
230 def show_labels(self):
231 self.update_labels()
232 self.draw()
234 def toggle_show_axes(self, key=None):
235 self.draw()
237 def toggle_show_bonds(self, key=None):
238 self.set_frame()
240 def toggle_show_velocities(self, key=None):
241 self.draw()
243 def get_forces(self):
244 if self.atoms.calc is not None:
245 try:
246 return self.atoms.get_forces()
247 except PropertyNotImplementedError:
248 pass
249 return np.zeros((len(self.atoms), 3))
251 def toggle_show_forces(self, key=None):
252 self.draw()
254 def hide_selected(self):
255 self.images.visible[self.images.selected] = False
256 self.draw()
258 def show_selected(self):
259 self.images.visible[self.images.selected] = True
260 self.draw()
262 def repeat_window(self, key=None):
263 return Repeat(self)
265 def rotate_window(self):
266 return Rotate(self)
268 def colors_window(self, key=None):
269 win = ColorWindow(self)
270 self.register_vulnerable(win)
271 return win
273 def focus(self, x=None):
274 cell = (self.window['toggle-show-unit-cell'] and
275 self.images[0].cell.any())
276 if (len(self.atoms) == 0 and not cell):
277 self.scale = 20.0
278 self.center = np.zeros(3)
279 self.draw()
280 return
282 # Get the min and max point of the projected atom positions
283 # including the covalent_radii used for drawing the atoms
284 P = np.dot(self.X, self.axes)
285 n = len(self.atoms)
286 covalent_radii = self.get_covalent_radii()
287 P[:n] -= covalent_radii[:, None]
288 P1 = P.min(0)
289 P[:n] += 2 * covalent_radii[:, None]
290 P2 = P.max(0)
291 self.center = np.dot(self.axes, (P1 + P2) / 2)
292 self.center += self.atoms.get_celldisp().reshape((3,)) / 2
293 # Add 30% of whitespace on each side of the atoms
294 S = 1.3 * (P2 - P1)
295 w, h = self.window.size
296 if S[0] * h < S[1] * w:
297 self.scale = h / S[1]
298 elif S[0] > 0.0001:
299 self.scale = w / S[0]
300 else:
301 self.scale = 1.0
302 self.draw()
304 def reset_view(self, menuitem):
305 self.axes = rotate('0.0x,0.0y,0.0z')
306 self.set_frame()
307 self.focus(self)
309 def set_view(self, key):
310 if key == 'Z':
311 self.axes = rotate('0.0x,0.0y,0.0z')
312 elif key == 'X':
313 self.axes = rotate('-90.0x,-90.0y,0.0z')
314 elif key == 'Y':
315 self.axes = rotate('90.0x,0.0y,90.0z')
316 elif key == 'Alt+Z':
317 self.axes = rotate('180.0x,0.0y,90.0z')
318 elif key == 'Alt+X':
319 self.axes = rotate('0.0x,90.0y,0.0z')
320 elif key == 'Alt+Y':
321 self.axes = rotate('-90.0x,0.0y,0.0z')
322 else:
323 if key == '3':
324 i, j = 0, 1
325 elif key == '1':
326 i, j = 1, 2
327 elif key == '2':
328 i, j = 2, 0
329 elif key == 'Alt+3':
330 i, j = 1, 0
331 elif key == 'Alt+1':
332 i, j = 2, 1
333 elif key == 'Alt+2':
334 i, j = 0, 2
336 A = complete_cell(self.atoms.cell)
337 x1 = A[i]
338 x2 = A[j]
340 norm = np.linalg.norm
342 x1 = x1 / norm(x1)
343 x2 = x2 - x1 * np.dot(x1, x2)
344 x2 /= norm(x2)
345 x3 = np.cross(x1, x2)
347 self.axes = np.array([x1, x2, x3]).T
349 self.set_frame()
351 def get_colors(self, rgb=False):
352 if rgb:
353 return [tuple(int(_rgb[i:i + 2], 16) / 255 for i in range(1, 7, 2))
354 for _rgb in self.get_colors()]
356 if self.colormode == 'jmol':
357 return [self.colors.get(Z, BLACKISH) for Z in self.atoms.numbers]
359 if self.colormode == 'neighbors':
360 return [self.colors.get(Z, BLACKISH)
361 for Z in self.get_color_scalars()]
363 colorscale, cmin, cmax = self.colormode_data
364 N = len(colorscale)
365 colorswhite = colorscale + ['#ffffff']
366 if cmin == cmax:
367 indices = [N // 2] * len(self.atoms)
368 else:
369 scalars = np.ma.array(self.get_color_scalars())
370 indices = np.clip(((scalars - cmin) / (cmax - cmin) * N +
371 0.5).astype(int),
372 0, N - 1).filled(N)
373 return [colorswhite[i] for i in indices]
375 def get_color_scalars(self, frame=None):
376 if self.colormode == 'tag':
377 return self.atoms.get_tags()
378 if self.colormode == 'force':
379 f = (self.get_forces()**2).sum(1)**0.5
380 return f * self.images.get_dynamic(self.atoms)
381 elif self.colormode == 'velocity':
382 return (self.atoms.get_velocities()**2).sum(1)**0.5
383 elif self.colormode == 'initial charge':
384 return self.atoms.get_initial_charges()
385 elif self.colormode == 'magmom':
386 return get_magmoms(self.atoms)
387 elif self.colormode == 'neighbors':
388 from ase.neighborlist import NeighborList
389 n = len(self.atoms)
390 nl = NeighborList(self.get_covalent_radii(self.atoms) * 1.5,
391 skin=0, self_interaction=False, bothways=True)
392 nl.update(self.atoms)
393 return [len(nl.get_neighbors(i)[0]) for i in range(n)]
394 else:
395 scalars = np.array(self.atoms.get_array(self.colormode),
396 dtype=float)
397 return np.ma.array(scalars, mask=np.isnan(scalars))
399 def get_covalent_radii(self, atoms=None):
400 if atoms is None:
401 atoms = self.atoms
402 return self.images.get_radii(atoms)
404 def draw(self, status=True):
405 self.window.clear()
406 axes = self.scale * self.axes * (1, -1, 1)
407 offset = np.dot(self.center, axes)
408 offset[:2] -= 0.5 * self.window.size
409 X = np.dot(self.X, axes) - offset
410 n = len(self.atoms)
412 # The indices enumerate drawable objects in z order:
413 self.indices = X[:, 2].argsort()
414 r = self.get_covalent_radii() * self.scale
415 if self.window['toggle-show-bonds']:
416 r *= 0.65
417 P = self.P = X[:n, :2]
418 A = (P - r[:, None]).round().astype(int)
419 X1 = X[n:, :2].round().astype(int)
420 X2 = (np.dot(self.B, axes) - offset).round().astype(int)
421 disp = (np.dot(self.atoms.get_celldisp().reshape((3,)),
422 axes)).round().astype(int)
423 d = (2 * r).round().astype(int)
425 vector_arrays = []
426 if self.window['toggle-show-velocities']:
427 # Scale ugly?
428 v = self.atoms.get_velocities()
429 if v is not None:
430 vector_arrays.append(v * 10.0 * self.velocity_vector_scale)
431 if self.window['toggle-show-forces']:
432 f = self.get_forces()
433 vector_arrays.append(f * self.force_vector_scale)
435 for array in vector_arrays:
436 array[:] = np.dot(array, axes) + X[:n]
438 colors = self.get_colors()
439 circle = self.window.circle
440 arc = self.window.arc
441 line = self.window.line
442 constrained = ~self.images.get_dynamic(self.atoms)
444 selected = self.images.selected
445 visible = self.images.visible
446 ncell = len(self.X_cell)
447 bond_linewidth = self.scale * 0.15
449 self.update_labels()
451 if self.arrowkey_mode == self.ARROWKEY_MOVE:
452 movecolor = GREEN
453 elif self.arrowkey_mode == self.ARROWKEY_ROTATE:
454 movecolor = PURPLE
456 for a in self.indices:
457 if a < n:
458 ra = d[a]
459 if visible[a]:
460 try:
461 kinds = self.atoms.arrays['spacegroup_kinds']
462 site_occ = self.atoms.info['occupancy'][str(kinds[a])]
463 # first an empty circle if a site is not fully occupied
464 if (np.sum([v for v in site_occ.values()])) < 1.0:
465 fill = '#ffffff'
466 circle(fill, selected[a],
467 A[a, 0], A[a, 1],
468 A[a, 0] + ra, A[a, 1] + ra)
469 start = 0
470 # start with the dominant species
471 for sym, occ in sorted(site_occ.items(),
472 key=lambda x: x[1],
473 reverse=True):
474 if np.round(occ, decimals=4) == 1.0:
475 circle(colors[a], selected[a],
476 A[a, 0], A[a, 1],
477 A[a, 0] + ra, A[a, 1] + ra)
478 else:
479 # jmol colors for the moment
480 extent = 360. * occ
481 arc(self.colors[atomic_numbers[sym]],
482 selected[a],
483 start, extent,
484 A[a, 0], A[a, 1],
485 A[a, 0] + ra, A[a, 1] + ra)
486 start += extent
487 except KeyError:
488 # legacy behavior
489 # Draw the atoms
490 if (self.moving and a < len(self.move_atoms_mask)
491 and self.move_atoms_mask[a]):
492 circle(movecolor, False,
493 A[a, 0] - 4, A[a, 1] - 4,
494 A[a, 0] + ra + 4, A[a, 1] + ra + 4)
496 circle(colors[a], selected[a],
497 A[a, 0], A[a, 1], A[a, 0] + ra, A[a, 1] + ra)
499 # Draw labels on the atoms
500 if self.labels is not None:
501 self.window.text(A[a, 0] + ra / 2,
502 A[a, 1] + ra / 2,
503 str(self.labels[a]))
505 # Draw cross on constrained atoms
506 if constrained[a]:
507 R1 = int(0.14644 * ra)
508 R2 = int(0.85355 * ra)
509 line((A[a, 0] + R1, A[a, 1] + R1,
510 A[a, 0] + R2, A[a, 1] + R2))
511 line((A[a, 0] + R2, A[a, 1] + R1,
512 A[a, 0] + R1, A[a, 1] + R2))
514 # Draw velocities and/or forces
515 for v in vector_arrays:
516 assert not np.isnan(v).any()
517 self.arrow((X[a, 0], X[a, 1], v[a, 0], v[a, 1]),
518 width=2)
519 else:
520 # Draw unit cell and/or bonds:
521 a -= n
522 if a < ncell:
523 line((X1[a, 0] + disp[0], X1[a, 1] + disp[1],
524 X2[a, 0] + disp[0], X2[a, 1] + disp[1]))
525 else:
526 line((X1[a, 0], X1[a, 1],
527 X2[a, 0], X2[a, 1]),
528 width=bond_linewidth)
530 if self.window['toggle-show-axes']:
531 self.draw_axes()
533 if len(self.images) > 1:
534 self.draw_frame_number()
536 self.window.update()
538 if status:
539 self.status(self.atoms)
541 def arrow(self, coords, width):
542 line = self.window.line
543 begin = np.array((coords[0], coords[1]))
544 end = np.array((coords[2], coords[3]))
545 line(coords, width)
547 vec = end - begin
548 length = np.sqrt((vec[:2]**2).sum())
549 length = min(length, 0.3 * self.scale)
551 angle = np.arctan2(end[1] - begin[1], end[0] - begin[0]) + np.pi
552 x1 = (end[0] + length * np.cos(angle - 0.3)).round().astype(int)
553 y1 = (end[1] + length * np.sin(angle - 0.3)).round().astype(int)
554 x2 = (end[0] + length * np.cos(angle + 0.3)).round().astype(int)
555 y2 = (end[1] + length * np.sin(angle + 0.3)).round().astype(int)
556 line((x1, y1, end[0], end[1]), width)
557 line((x2, y2, end[0], end[1]), width)
559 def draw_axes(self):
560 axes_length = 15
562 rgb = ['red', 'green', 'blue']
564 for i in self.axes[:, 2].argsort():
565 a = 20
566 b = self.window.size[1] - 20
567 c = int(self.axes[i][0] * axes_length + a)
568 d = int(-self.axes[i][1] * axes_length + b)
569 self.window.line((a, b, c, d))
570 self.window.text(c, d, 'XYZ'[i], color=rgb[i])
572 def draw_frame_number(self):
573 x, y = self.window.size
574 self.window.text(x, y, '{}'.format(self.frame),
575 anchor='SE')
577 def release(self, event):
578 if event.button in [4, 5]:
579 self.scroll_event(event)
580 return
582 if event.button != self.b1:
583 return
585 selected = self.images.selected
586 selected_ordered = self.images.selected_ordered
588 if event.time < self.t0 + 200: # 200 ms
589 d = self.P - self.xy
590 r = self.get_covalent_radii()
591 hit = np.less((d**2).sum(1), (self.scale * r)**2)
592 for a in self.indices[::-1]:
593 if a < len(self.atoms) and hit[a]:
594 if event.modifier == 'ctrl':
595 selected[a] = not selected[a]
596 if selected[a]:
597 selected_ordered += [a]
598 elif len(selected_ordered) > 0:
599 if selected_ordered[-1] == a:
600 selected_ordered = selected_ordered[:-1]
601 else:
602 selected_ordered = []
603 else:
604 selected[:] = False
605 selected[a] = True
606 selected_ordered = [a]
607 break
608 else:
609 selected[:] = False
610 selected_ordered = []
611 self.draw()
612 else:
613 A = (event.x, event.y)
614 C1 = np.minimum(A, self.xy)
615 C2 = np.maximum(A, self.xy)
616 hit = np.logical_and(self.P > C1, self.P < C2)
617 indices = np.compress(hit.prod(1), np.arange(len(hit)))
618 if event.modifier != 'ctrl':
619 selected[:] = False
620 selected[indices] = True
621 if (len(indices) == 1 and
622 indices[0] not in self.images.selected_ordered):
623 selected_ordered += [indices[0]]
624 elif len(indices) > 1:
625 selected_ordered = []
626 self.draw()
628 # XXX check bounds
629 natoms = len(self.atoms)
630 indices = np.arange(natoms)[self.images.selected[:natoms]]
631 if len(indices) != len(selected_ordered):
632 selected_ordered = []
633 self.images.selected_ordered = selected_ordered
635 def press(self, event):
636 self.button = event.button
637 self.xy = (event.x, event.y)
638 self.t0 = event.time
639 self.axes0 = self.axes
640 self.center0 = self.center
642 def move(self, event):
643 x = event.x
644 y = event.y
645 x0, y0 = self.xy
646 if self.button == self.b1:
647 x0 = int(round(x0))
648 y0 = int(round(y0))
649 self.draw()
650 self.window.canvas.create_rectangle((x, y, x0, y0))
651 return
653 if event.modifier == 'shift':
654 self.center = (self.center0 -
655 np.dot(self.axes, (x - x0, y0 - y, 0)) / self.scale)
656 else:
657 # Snap mode: the a-b angle and t should multipla of 15 degrees ???
658 a = x - x0
659 b = y0 - y
660 t = sqrt(a * a + b * b)
661 if t > 0:
662 a /= t
663 b /= t
664 else:
665 a = 1.0
666 b = 0.0
667 c = cos(0.01 * t)
668 s = -sin(0.01 * t)
669 rotation = np.array([(c * a * a + b * b, (c - 1) * b * a, s * a),
670 ((c - 1) * a * b, c * b * b + a * a, s * b),
671 (-s * a, -s * b, c)])
672 self.axes = np.dot(self.axes0, rotation)
673 if len(self.atoms) > 0:
674 com = self.X_pos.mean(0)
675 else:
676 com = self.atoms.cell.mean(0)
677 self.center = com - np.dot(com - self.center0,
678 np.dot(self.axes0, self.axes.T))
679 self.draw(status=False)
681 def render_window(self):
682 return Render(self)
684 def resize(self, event):
685 w, h = self.window.size
686 self.scale *= (event.width * event.height / (w * h))**0.5
687 self.window.size[:] = [event.width, event.height]
688 self.draw()