Skip to content

Visualization

atomli.visualize.view displays structures and trajectories in interactive 3D.

from atomli.build import bulk
from atomli.visualize import view
view(bulk("NaCl", "rocksalt", a=5.64, cubic=True), repeat=(2, 2, 2), cell=True)
Cl32Na32. The 8-atom cubic rocksalt cell, repeated 2x2x2 into 64 atoms, 11.28 A across.

Drag to rotate. Scroll to zoom.

Pass a sequence of Atoms and the viewer becomes a player with a timeline. A list from read, the frames an optimizer collected, or an MD trajectory all work.

from atomli.io import read
from atomli.visualize import view
view(read("relaxation.extxyz", ":"))
Benzene rattled by 0.12 A per atom, then relaxed with GFN2-xTB. 24 frames, one per BFGS step.

That run starts at 20.1 eV/Å of maximum force and takes 23 steps to reach 0.026 eV/Å. Scrub it and the ring flattens and regularizes: the six C-C bonds end within 0.8 mÅ of each other at 1.385 Å, and no carbon sits more than 0.002 Å off the ring’s best-fit plane. Watching the geometry is how you notice a relaxation that converged to something you did not want, which a falling fmax column will not tell you.

In a notebook, atoms on the last line of a cell shows the same viewer. There is nothing to import.

from atomli.build import molecule
atoms = molecule("C6H6")
atoms # renders 3D

This is the one place atomli deviates from ASE’s behaviour, and it is confined to _repr_mimebundle_, the hook only IPython calls. repr(atoms) and print(atoms) return plain text exactly as they did before, so doctests, logging and terminal scripts are untouched.

view returns a Viewer. Its update method pushes the current geometry into the figure that is already on screen, and it takes no required argument, so it attaches directly to any driver.

from atomli.optimize import BFGS
from atomli.visualize import view
v = view(atoms)
opt = BFGS(atoms)
opt.attach(v.update, interval=1)
opt.run(fmax=0.05)

The cell animates while the optimizer runs rather than after it finishes. Frames are appended to the trajectory in place, not re-rendered, so the camera angle and the reader’s position on the scrub bar survive every step. The same works for MD:

dyn = VelocityVerlet(atoms, timestep=1.0 * fs)
dyn.attach(v.update, interval=5)
dyn.run(500)

Three things worth knowing before you rely on it:

  • Only positions are streamed. The species, the count and the cell come from the structure view was given, so a run that adds, removes or retypes atoms needs a fresh view call.
  • update() with no argument reads v.atoms, which is the object you passed in. Drivers mutate that object in place, which is why this works. If your loop builds a new Atoms each step, pass it: v.update(new_atoms).
  • Outside IPython, update does nothing at all. The same script runs unchanged from a terminal, with no viewer and no error.
v = view(trajectory)
v.save("figure.png") # 400 dpi by default
v.save("figure.png", dpi=600)
v.save("run.gif", fps=20, every=2)

The extension chooses the format, and only .png and .gif are written; anything else raises. A .gif needs more than one frame, so asking for one from a single structure raises rather than writing a one-frame animation.

Be clear about where the file goes. The picture is rendered by the renderer, which lives in the browser, because a Python kernel has no WebGL context. So save drives the browser’s download flow: the file lands in the browser’s download directory, not next to the notebook, and nothing at all is written when there is no live viewer, which includes a headless nbconvert run or a reloaded notebook whose kernel is gone. If you need a file on the machine running the kernel, write the structure with atomli.io.write and render it there.

dpi is the capture resolution and pixel count grows with its square, so a long trajectory at 400 dpi can exceed the GIF encoder’s budget. Lower dpi or raise every when it says so.

Look options are keyword arguments, passed to the renderer unchanged.

view(atoms, style="vdw", colorScheme="jmol", background="black", atomScale=60)

atomScale and bondScale are percentages, not multipliers: 100 is the default size, so the call above draws atoms at 60% of it.

OptionDoes
stylebTube (default), flat, skeletal, vdw, bubble
colorSchemevesta-soft, jmol, kessoku
materialmodern-matte, classic-matte, glossy, metallic, 2-5d, 2d
backgroundlight, white, black
atomScale, bondScaleRadii, in percent of the default
bondColorModebicolor splits a bond at its midpoint, unicolor paints it one colour
bonds, edges, cell, axes, labels, polyhedraBooleans: draw bonds, atom outlines, the cell box, orientation axes, element labels, coordination polyhedra
autoRotateSpin the scene continuously
controlsWhich of the options above the reader may adjust in the figure: True for all, a comma-separated string, or a list
title, widthCaption, and width as a number of pixels or a CSS length

A name that is not on that list raises a TypeError naming the valid options. That is deliberate: the widget only logs a console warning for an option it does not recognize, and a notebook user never sees the console, so a typo would otherwise be silently ignored.

Two more arguments shape the frame rather than the scene. height takes a bare number as pixels or any CSS length verbatim, and repeat builds a supercell for display without touching the structure you passed in.

view(atoms, height=600)
view(atoms, height="70vh")
view(crystal, repeat=(3, 3, 3)) # `crystal` itself is unchanged

The signature is ASE’s, in ASE’s order:

view(atoms, data=None, viewer=None, repeat=None, block=False, height=None, **look)

so an unmodified ASE call such as view(atoms, None, "ase", (2, 2, 2)) binds positionally the same way here. data and block are accepted and ignored: ASE uses them to drive an external GUI window, and there is no external GUI here. viewer accepts None, "ase" and "tako", all selecting the built-in viewer. Any other backend name raises instead of quietly showing something you did not ask for.

The output is never a blank box. Before the mount script runs it shows a line naming the structure (formula, atom count, whether it is periodic, how many frames), and if the renderer fails to load, that line is replaced with the reason. Two common causes:

  • The notebook is not trusted. JupyterLab leaves inserted HTML inert until you run jupyter trust notebook.ipynb. The same applies to nbviewer and to GitHub’s .ipynb rendering, which never execute the script at all.
  • tako.atom.li is unreachable. The widget is fetched over the network. A self-hosted or air-gapped deployment can point at its own copy by passing script_url when constructing a Viewer directly.