Repository: janosh/pymatviz (language: python)

Issue:
`plot_structure_2d()` show bonds
Had a random thought. As I was looking through some of the images, I notice that it's difficult to get a sense of the depth and by extension the relative distances between things. Perhaps semi-transparent "tubes" that vary in size based on the bond distance would help. Or maybe that's something that's better suited for visualization via VESTA. Thinking about using `plot_structure_2d` extensively for an upcoming manuscript.

No pressure - just a random passing thought.


Discussion / hints:
I'm thinking it might be more useful to go straight for 3d visualization. We could have a function that takes pymatgen structures and converts them into the `xyz` format used by https://github.com/wwwtyro/speck which is available in Plotly's [`dash-bio`](https://dash.plotly.com/dash-bio/speck`) package.

Would look something like this (subject to further customization).

![Screen Shot 2022-05-22 at 09 02 47](https://user-images.githubusercontent.com/30958850/169685031-2c25c7f3-f056-476d-8e77-36a462bab94f.png)

You can't really tell the periodic structure from this though. We'd want to include box edges to indicate the unit cell (which might take some work) and plot the conventional cell to make it look more like https://materialsproject.org/materials/mp-19017.



Fix patch:
diff --git a/examples/generate_assets.py b/examples/generate_assets.py
index c076997a..4edb0927 100644
--- a/examples/generate_assets.py
+++ b/examples/generate_assets.py
@@ -27,7 +27,7 @@
 from pymatviz.ranking import err_decay
 from pymatviz.relevance import precision_recall_curve, roc_curve
 from pymatviz.sankey import sankey_from_2_df_cols
-from pymatviz.struct_vis import plot_structure_2d
+from pymatviz.structure_viz import plot_structure_2d
 from pymatviz.sunburst import spacegroup_sunburst
 from pymatviz.utils import ROOT, df_ptable, save_and_compress_svg
 
@@ -235,9 +235,10 @@
 # %%
 df_phonons = load_dataset("matbench_phonons")
 
-fig, axs = plt.subplots(3, 4, figsize=(12, 12))
+n_rows, n_cols = 3, 4
+fig, axs = plt.subplots(n_rows, n_cols, figsize=(3 * n_rows, 3 * n_cols))
 
-for struct, ax in zip(df_phonons.structure.head(12), axs.flat):
+for struct, ax in zip(df_phonons.structure.head(n_rows * n_cols), axs.flat):
     ax = plot_structure_2d(struct, ax=ax)
     spg_symbol, _ = struct.get_space_group_info()
     formula = struct.composition.reduced_formula
diff --git a/pymatviz/__init__.py b/pymatviz/__init__.py
index a7cc2aca..f77bfce6 100644
--- a/pymatviz/__init__.py
+++ b/pymatviz/__init__.py
@@ -20,6 +20,6 @@
 from .ranking import err_decay
 from .relevance import precision_recall_curve, roc_curve
 from .sankey import sankey_from_2_df_cols
-from .struct_vis import plot_structure_2d
+from .structure_viz import plot_structure_2d
 from .sunburst import spacegroup_sunburst
 from .utils import ROOT, add_mae_r2_box, annotate_bars
diff --git a/pymatviz/elements.py b/pymatviz/elements.py
index e411f126..5a321ccc 100644
--- a/pymatviz/elements.py
+++ b/pymatviz/elements.py
@@ -431,11 +431,11 @@ def ptable_heatmap_plotly(
     precision: str = None,
     hover_props: Sequence[str] | dict[str, str] | None = None,
     hover_data: dict[str, str | int | float] | pd.Series | None = None,
-    font_colors: Sequence[str] = ["black"],
+    font_colors: Sequence[str] = ("black",),
     gap: float = 5,
     font_size: int = None,
     bg_color: str = None,
-    color_bar: dict[str, Any] = {},
+    color_bar: dict[str, Any] = None,
 ) -> Figure:
     """Creates a Plotly figure with an interactive heatmap of the periodic table.
     Supports hover tooltips with custom data or atomic reference data like
@@ -600,6 +600,6 @@ def ptable_heatmap_plotly(
         height=500,
     )
     fig.update_traces(
-        colorbar=dict(lenmode="fraction", len=0.87, thickness=15, **color_bar)
+        colorbar=dict(lenmode="fraction", len=0.87, thickness=15, **(color_bar or {}))
     )
     return fig
diff --git a/pymatviz/struct_vis.py b/pymatviz/structure_viz.py
similarity index 62%
rename from pymatviz/struct_vis.py
rename to pymatviz/structure_viz.py
index eba2204a..b413760b 100644
--- a/pymatviz/struct_vis.py
+++ b/pymatviz/structure_viz.py
@@ -1,6 +1,7 @@
 from __future__ import annotations
 
 import math
+import warnings
 from itertools import product
 from typing import Any
 
@@ -8,42 +9,55 @@
 import numpy as np
 from matplotlib.patches import PathPatch, Wedge
 from matplotlib.path import Path
+from pymatgen.analysis.local_env import CrystalNN, NearNeighbors
 from pymatgen.core import Structure
 
 from pymatviz.utils import NumArray, covalent_radii, jmol_colors
 
 
+class ExperimentalWarning(Warning):
+    """Used for experimental show_bonds feature."""
+
+
+warnings.simplefilter("once", ExperimentalWarning)
+
+
 # plot_structure_2d() and its helpers get_rot_matrix() and unit_cell_to_lines() were
 # inspired by ASE https://wiki.fysik.dtu.dk/ase/ase/visualize/visualize.html#matplotlib
 
 
-def get_rot_matrix(angles: str, rotation: NumArray = np.eye(3)) -> NumArray:
+def _angles_to_rotation_matrix(angles: str, rotation: NumArray = None) -> NumArray:
     """Convert Euler angles to a rotation matrix.
 
     Note the order of angles matters. 50x,40z != 40z,50x.
 
     Args:
         angles (str): Euler angles (in degrees) formatted as '-10y,50x,120z'
-        rotation (NumArray, optional): Initial rotation matrix. Defaults to identity
-            matrix.
+        rotation (np.array, optional): Initial rotation matrix. Use this if you already
+            have a rotation and want to combine it with the rotation defined by angles.
+            Defaults to identity matrix np.eye(3).
 
     Returns:
-        NumArray: 3d rotation matrix.
+        np.array: 3d rotation matrix.
     """
+    if rotation is None:
+        rotation = np.eye(3)
     if angles == "":
         return rotation.copy()  # return initial rotation matrix if no angles
 
-    for i, a in [
-        ("xyz".index(s[-1]), math.radians(float(s[:-1]))) for s in angles.split(",")
-    ]:
-        s = math.sin(a)
-        c = math.cos(a)
-        if i == 0:
-            rotation = np.dot(rotation, [(1, 0, 0), (0, c, s), (0, -s, c)])
-        elif i == 1:
-            rotation = np.dot(rotation, [(c, 0, -s), (0, 1, 0), (s, 0, c)])
+    for angle in angles.split(","):
+        radians = math.radians(float(angle[:-1]))
+        xyz = angle[-1]
+        assert xyz in "xyz"
+        dim = "xyz".index(xyz)
+        sin = math.sin(radians)
+        cos = math.cos(radians)
+        if dim == 0:
+            rotation = np.dot(rotation, [(1, 0, 0), (0, cos, sin), (0, -sin, cos)])
+        elif dim == 1:
+            rotation = np.dot(rotation, [(cos, 0, -sin), (0, 1, 0), (sin, 0, cos)])
         else:
-            rotation = np.dot(rotation, [(c, s, 0), (-s, c, 0), (0, 0, 1)])
+            rotation = np.dot(rotation, [(cos, sin, 0), (-sin, cos, 0), (0, 0, 1)])
     return rotation
 
 
@@ -94,8 +108,10 @@ def plot_structure_2d(
     colors: dict[str, str | list[float]] = None,
     scale: float = 1,
     show_unit_cell: bool = True,
+    show_bonds: bool | NearNeighbors = False,
     site_labels: bool | dict[str, str | float] | list[str | float] = True,
-    label_kwargs: dict[str, Any] = {"fontsize": 14},
+    label_kwargs: dict[str, Any] = None,
+    bond_kwargs: dict[str, Any] = None,
 ) -> plt.Axes:
     """Plot pymatgen structure object in 2d. Uses matplotlib.
 
@@ -135,12 +151,22 @@ def plot_structure_2d(
             Defaults to JMol colors.
         scale (float, optional): Scaling of the plotted atoms and lines. Defaults to 1.
         show_unit_cell (bool, optional): Whether to draw unit cell. Defaults to True.
+        show_bonds (bool | NearNeighbors, optional): Whether to draw bonds. If True, use
+            pymatgen.analysis.local_env.CrystalNN to infer the structure's connectivity.
+            If False, don't draw bonds. If a subclass of
+            pymatgen.analysis.local_env.NearNeighbors, use that to determine
+            connectivity. Options include VoronoiNN, MinimumDistanceNN, OpenBabelNN,
+            CovalentBondNN, dtc. Defaults to True.
         site_labels (bool | dict[str, str | float] | list[str | float]): How to annotate
             lattice sites. If True, labels are element symbols. If a dict, should map
             element symbols to labels. If a list, must be same length as the number of
             sites in the crystal. Defaults to True.
-        label_kwargs (dict, optional): Keyword arguments for matplotlib.text.Text.
-            Defaults to {"fontsize": 14}.
+        label_kwargs (dict, optional): Keyword arguments for matplotlib.text.Text like
+            {"fontsize": 14}. Defaults to None.
+        bond_kwargs (dict, optional): Keyword arguments for the matplotlib.path.Path
+            class used to draw chemical bonds. Allowed are edgecolor, facecolor, color,
+            linewidth, linestyle, antialiased, hatch, fill, capstyle, joinstyle.
+            Defaults to None.
 
     Returns:
         plt.Axes: matplotlib Axes instance with plotted structure.
@@ -148,7 +174,7 @@ def plot_structure_2d(
     if ax is None:
         ax = plt.gca()
 
-    if isinstance(site_labels, list):
+    if isinstance(site_labels, (list, tuple)):
         if len(site_labels) != len(struct):
             raise ValueError(
                 f"If a list, site_labels ({len(site_labels)=}) must have same length as"
@@ -161,27 +187,31 @@ def plot_structure_2d(
     # Get any element at each site, only used for occlusion calculation which won't be
     # perfect for disordered sites. Plotting wedges of different radii for disordered
     # sites is handled later.
-    elems = [str(site.species.elements[0].symbol) for site in struct]
+    elements_at_sites = [str(site.species.elements[0].symbol) for site in struct]
 
     if atomic_radii is None or isinstance(atomic_radii, float):
+        # atomic_radii is a scaling factor for the default set of radii
         atomic_radii = 0.7 * covalent_radii * (atomic_radii or 1)
     else:
+        # atomic_radii is assumed to be a map from element symbols to atomic radii
         assert isinstance(atomic_radii, dict)
         # make sure all present elements are assigned a radius
-        missing = set(elems) - set(atomic_radii)
+        missing = set(elements_at_sites) - set(atomic_radii)
         assert not missing, f"atomic_radii is missing keys: {missing}"
 
-    radii = np.array([atomic_radii[el] for el in elems])  # type: ignore
+    radii_at_sites = np.array(
+        [atomic_radii[el] for el in elements_at_sites]  # type: ignore
+    )
 
     n_atoms = len(struct)
-    rot_matrix = get_rot_matrix(rotation)
+    rotation_matrix = _angles_to_rotation_matrix(rotation)
     unit_cell = struct.lattice.matrix
 
     if show_unit_cell:
         lines, z_indices, unit_cell_lines = unit_cell_to_lines(unit_cell)
         corners = np.array(list(product((0, 1), (0, 1), (0, 1))))
         cell_vertices = np.dot(corners, unit_cell)
-        cell_vertices = np.dot(cell_vertices, rot_matrix)
+        cell_vertices = np.dot(cell_vertices, rotation_matrix)
     else:
         lines = np.empty((0, 3))
         z_indices = None
@@ -198,16 +228,20 @@ def plot_structure_2d(
     # determine which lines should be hidden behind other objects
     for idx in range(n_lines):
         this_layer = unit_cell_lines[z_indices[idx]]
-        occlu_top = ((site_coords - lines[idx] + this_layer) ** 2).sum(1) < radii**2
-        occlu_bot = ((site_coords - lines[idx] - this_layer) ** 2).sum(1) < radii**2
-        if any(occlu_top & occlu_bot):
+        occluded_top = ((site_coords - lines[idx] + this_layer) ** 2).sum(
+            1
+        ) < radii_at_sites**2
+        occluded_bottom = ((site_coords - lines[idx] - this_layer) ** 2).sum(
+            1
+        ) < radii_at_sites**2
+        if any(occluded_top & occluded_bottom):
             z_indices[idx] = -1
 
-    positions = np.dot(positions, rot_matrix)
-    site_coords = positions[:n_atoms]
+    positions = np.dot(positions, rotation_matrix)
+    rotated_site_coords = positions[:n_atoms]
 
-    min_coords = (site_coords - radii[:, None]).min(0)
-    max_coords = (site_coords + radii[:, None]).max(0)
+    min_coords = (rotated_site_coords - radii_at_sites[:, None]).min(0)
+    max_coords = (rotated_site_coords + radii_at_sites[:, None]).max(0)
 
     if show_unit_cell:
         min_coords = np.minimum(min_coords, cell_vertices.min(0))
@@ -221,7 +255,7 @@ def plot_structure_2d(
     positions -= offset
 
     if n_lines > 0:
-        unit_cell_lines = np.dot(unit_cell_lines, rot_matrix)[:, :2] * scale
+        unit_cell_lines = np.dot(unit_cell_lines, rotation_matrix)[:, :2] * scale
 
     # sort so we draw from back to front along out-of-plane (z-)axis
     for idx in positions[:, 2].argsort():
@@ -233,12 +267,13 @@ def plot_structure_2d(
                 # strip oxidation state from element symbol (e.g. Ta5+ to Ta)
                 elem_symbol = elem.symbol
                 radius = atomic_radii[elem_symbol] * scale  # type: ignore
+                facecolor = colors[elem_symbol]
                 wedge = Wedge(
                     xy,
                     radius,
                     360 * start,
                     360 * (start + occupancy),
-                    facecolor=colors[elem_symbol],
+                    facecolor=facecolor,
                     edgecolor="black",
                 )
                 ax.add_patch(wedge)
@@ -260,7 +295,10 @@ def plot_structure_2d(
                         (0.5 * radius) * direction if occupancy < 1 else (0, 0)
                     )
 
-                    txt_kwds = dict(ha="center", va="center", **label_kwargs)
+                    bbox = dict(facecolor=facecolor, edgecolor="none", pad=1)
+                    txt_kwds = dict(
+                        ha="center", va="center", bbox=bbox, **(label_kwargs or {})
+                    )
                     ax.text(*(xy + text_offset), txt, **txt_kwds)
 
                 start += occupancy
@@ -272,6 +310,46 @@ def plot_structure_2d(
                 path = PathPatch(Path((xy + hxy, xy - hxy)))
                 ax.add_patch(path)
 
+    if show_bonds:
+        warnings.warn(
+            "Warning: the show_bonds feature of plot_structure_2d() is experimental. "
+            "Issues and PRs with improvements welcome.",
+            category=ExperimentalWarning,
+        )
+        if show_bonds is True:
+            neighbor_strategy_cls = CrystalNN
+        elif issubclass(show_bonds, NearNeighbors):
+            neighbor_strategy_cls = show_bonds
+        else:
+            raise ValueError(
+                f"Expected boolean or a NearNeighbors subclass for {show_bonds = }"
+            )
+
+        # If structure doesn't have any oxidation states yet, guess them from chemical
+        # composition. Helps CrystalNN and other strategies to estimate better bond
+        # connectivity. Uses getattr on site.specie since it's often a pymatgen Element
+        # which has no oxi_state
+        if not any(
+            hasattr(getattr(site, "specie", None), "oxi_state") for site in struct
+        ):
+            try:
+                struct.add_oxidation_state_by_guess()
+            except ValueError:  # fails for disordered structures
+                "Charge balance analysis requires integer values in Composition"
+        structure_graph = neighbor_strategy_cls().get_bonded_structure(struct)
+
+        bonds = structure_graph.graph.edges(data=True)
+        for bond in bonds:
+            from_idx, to_idx, data = bond
+            if data["to_jimage"] != (0, 0, 0):
+                continue  # skip bonds across periodic boundaries
+            from_xy = positions[from_idx, :2]
+            to_xy = positions[to_idx, :2]
+
+            bond_patch_kwds = dict(facecolor="black", **(bond_kwargs or {}))
+            bond_patch = PathPatch(Path((from_xy, to_xy)), **bond_patch_kwds)
+            ax.add_patch(bond_patch)
+
     width, height, _ = scale * coord_ranges
     ax.set(xlim=[0, width], ylim=[0, height], aspect="equal")
     ax.axis("off")
diff --git a/pymatviz/utils.py b/pymatviz/utils.py
index a90cf798..7d3d8055 100644
--- a/pymatviz/utils.py
+++ b/pymatviz/utils.py
@@ -13,7 +13,7 @@
 from matplotlib.gridspec import GridSpec
 from matplotlib.offsetbox import AnchoredText
 from numpy.typing import NDArray
-from plotly.graph_objs._figure import Figure
+from plotly.graph_objs._figure import Figure as PlotlyFigure
 from sklearn.metrics import r2_score
 
 
@@ -28,9 +28,10 @@
 # http://jmol.sourceforge.net/jscolors
 jmol_colors = df_ptable.jmol_color.dropna().apply(ast.literal_eval)
 
-missing_cov_rad = 0.2
-# covalent_radii = df_ptable.covalent_radius.fillna(missing_cov_rad).to_dict()
-covalent_radii = df_ptable.covalent_radius.fillna(missing_cov_rad)
+# fallback value (in nanometers) for covalent radius of an element
+# see https://wikipedia.org/wiki/Atomic_radii_of_the_elements
+missing_covalent_radius = 0.2
+covalent_radii: pd.Series = df_ptable.covalent_radius.fillna(missing_covalent_radius)
 
 atomic_numbers: dict[str, int] = {}
 element_symbols: dict[int, str] = {}
@@ -216,8 +217,8 @@ def get_crystal_sys(
 
 
 def add_identity_line(
-    fig: Figure, trace_idx: int = 0, line_kwds: dict[str, Any] = None
-) -> Figure:
+    fig: PlotlyFigure, trace_idx: int = 0, line_kwds: dict[str, Any] = None
+) -> PlotlyFigure:
     """Add a line shape to the background layer of a plotly figure spanning from
     smallest to largest x/y values in the trace specified by trace_idx.
 
@@ -261,7 +262,7 @@ def add_identity_line(
     return fig
 
 
-def save_and_compress_svg(filename: str, fig: Figure | None = None) -> None:
+def save_and_compress_svg(filename: str, fig: PlotlyFigure | None = None) -> None:
     """Save Plotly figure as SVG and HTML to assets/ folder. Compresses SVG file with
     svgo CLI if available in PATH.
 
@@ -275,9 +276,9 @@ def save_and_compress_svg(filename: str, fig: Figure | None = None) -> None:
     assert not filename.endswith(".svg"), f"{filename = } should not include .svg"
     filepath = f"{ROOT}/assets/{filename}.svg"
 
-    if isinstance(fig, Figure):
+    if isinstance(fig, PlotlyFigure):
         fig.write_image(filepath)
-    elif fig is None:
+    elif fig is None or isinstance(fig, plt.Figure):
         if len(plt.gcf().axes) == 0:
             raise ValueError(
                 "No figure passed explicitly and plt.gcf() contains no axes. "
diff --git a/readme.md b/readme.md
index efd40b45..aade9a5a 100644
--- a/readme.md
+++ b/readme.md
@@ -52,9 +52,9 @@ See [`pymatviz/sankey.py`](pymatviz/sankey.py).
 
 ## Structure
 
-See [`pymatviz/struct_vis.py`](pymatviz/struct_vis.py). Currently only structure plotting in 2d with `matplotlib` is supported. 3d interactive plots (probably with `plotly`) are on the road map.
+See [`pymatviz/struct_viz.py`](pymatviz/struct_viz.py). Currently structure plotting is only supported with `matplotlib` in 2d. 3d interactive plots (probably with `plotly`) are on the road map.
 
-| [`plot_structure_2d(mp_19017)`](pymatviz/struct_vis.py) | [`plot_structure_2d(mp_12712)`](pymatviz/struct_vis.py) |
+| [`plot_structure_2d(mp_19017)`](pymatviz/struct_viz.py) | [`plot_structure_2d(mp_12712)`](pymatviz/struct_viz.py) |
 | :-----------------------------------------------------: | :-----------------------------------------------------: |
 |      ![struct-2d-mp-19017-Li4Fe3P4CO16-disordered]      |       ![struct-2d-mp-12712-Hf3Zr3Pd8-disordered]        |
 


Test patch:
diff --git a/tests/fixtures/struct_vis/radii=0.5_rot='0x,0y,0z'_labels=False.png b/tests/fixtures/structure_viz/radii=0.5_rot='0x,0y,0z'_labels=False.png
similarity index 100%
rename from tests/fixtures/struct_vis/radii=0.5_rot='0x,0y,0z'_labels=False.png
rename to tests/fixtures/structure_viz/radii=0.5_rot='0x,0y,0z'_labels=False.png
diff --git a/tests/fixtures/struct_vis/radii=0.5_rot='0x,0y,0z'_labels=True.png b/tests/fixtures/structure_viz/radii=0.5_rot='0x,0y,0z'_labels=True.png
similarity index 100%
rename from tests/fixtures/struct_vis/radii=0.5_rot='0x,0y,0z'_labels=True.png
rename to tests/fixtures/structure_viz/radii=0.5_rot='0x,0y,0z'_labels=True.png
diff --git a/tests/fixtures/struct_vis/radii=0.5_rot='0x,0y,0z'_labels={'P': 'Phosphor'}.png b/tests/fixtures/structure_viz/radii=0.5_rot='0x,0y,0z'_labels={'P': 'Phosphor'}.png
similarity index 100%
rename from tests/fixtures/struct_vis/radii=0.5_rot='0x,0y,0z'_labels={'P': 'Phosphor'}.png
rename to tests/fixtures/structure_viz/radii=0.5_rot='0x,0y,0z'_labels={'P': 'Phosphor'}.png
diff --git a/tests/fixtures/struct_vis/radii=0.5_rot='10x,-10y,0z'_labels=False.png b/tests/fixtures/structure_viz/radii=0.5_rot='10x,-10y,0z'_labels=False.png
similarity index 100%
rename from tests/fixtures/struct_vis/radii=0.5_rot='10x,-10y,0z'_labels=False.png
rename to tests/fixtures/structure_viz/radii=0.5_rot='10x,-10y,0z'_labels=False.png
diff --git a/tests/fixtures/struct_vis/radii=0.5_rot='10x,-10y,0z'_labels=True.png b/tests/fixtures/structure_viz/radii=0.5_rot='10x,-10y,0z'_labels=True.png
similarity index 100%
rename from tests/fixtures/struct_vis/radii=0.5_rot='10x,-10y,0z'_labels=True.png
rename to tests/fixtures/structure_viz/radii=0.5_rot='10x,-10y,0z'_labels=True.png
diff --git a/tests/fixtures/struct_vis/radii=0.5_rot='10x,-10y,0z'_labels={'P': 'Phosphor'}.png b/tests/fixtures/structure_viz/radii=0.5_rot='10x,-10y,0z'_labels={'P': 'Phosphor'}.png
similarity index 100%
rename from tests/fixtures/struct_vis/radii=0.5_rot='10x,-10y,0z'_labels={'P': 'Phosphor'}.png
rename to tests/fixtures/structure_viz/radii=0.5_rot='10x,-10y,0z'_labels={'P': 'Phosphor'}.png
diff --git a/tests/fixtures/struct_vis/radii=1.2_rot='0x,0y,0z'_labels=False.png b/tests/fixtures/structure_viz/radii=1.2_rot='0x,0y,0z'_labels=False.png
similarity index 100%
rename from tests/fixtures/struct_vis/radii=1.2_rot='0x,0y,0z'_labels=False.png
rename to tests/fixtures/structure_viz/radii=1.2_rot='0x,0y,0z'_labels=False.png
diff --git a/tests/fixtures/struct_vis/radii=1.2_rot='0x,0y,0z'_labels=True.png b/tests/fixtures/structure_viz/radii=1.2_rot='0x,0y,0z'_labels=True.png
similarity index 100%
rename from tests/fixtures/struct_vis/radii=1.2_rot='0x,0y,0z'_labels=True.png
rename to tests/fixtures/structure_viz/radii=1.2_rot='0x,0y,0z'_labels=True.png
diff --git a/tests/fixtures/struct_vis/radii=1.2_rot='0x,0y,0z'_labels={'P': 'Phosphor'}.png b/tests/fixtures/structure_viz/radii=1.2_rot='0x,0y,0z'_labels={'P': 'Phosphor'}.png
similarity index 100%
rename from tests/fixtures/struct_vis/radii=1.2_rot='0x,0y,0z'_labels={'P': 'Phosphor'}.png
rename to tests/fixtures/structure_viz/radii=1.2_rot='0x,0y,0z'_labels={'P': 'Phosphor'}.png
diff --git a/tests/fixtures/struct_vis/radii=1.2_rot='10x,-10y,0z'_labels=False.png b/tests/fixtures/structure_viz/radii=1.2_rot='10x,-10y,0z'_labels=False.png
similarity index 100%
rename from tests/fixtures/struct_vis/radii=1.2_rot='10x,-10y,0z'_labels=False.png
rename to tests/fixtures/structure_viz/radii=1.2_rot='10x,-10y,0z'_labels=False.png
diff --git a/tests/fixtures/struct_vis/radii=1.2_rot='10x,-10y,0z'_labels=True.png b/tests/fixtures/structure_viz/radii=1.2_rot='10x,-10y,0z'_labels=True.png
similarity index 100%
rename from tests/fixtures/struct_vis/radii=1.2_rot='10x,-10y,0z'_labels=True.png
rename to tests/fixtures/structure_viz/radii=1.2_rot='10x,-10y,0z'_labels=True.png
diff --git a/tests/fixtures/struct_vis/radii=1.2_rot='10x,-10y,0z'_labels={'P': 'Phosphor'}.png b/tests/fixtures/structure_viz/radii=1.2_rot='10x,-10y,0z'_labels={'P': 'Phosphor'}.png
similarity index 100%
rename from tests/fixtures/struct_vis/radii=1.2_rot='10x,-10y,0z'_labels={'P': 'Phosphor'}.png
rename to tests/fixtures/structure_viz/radii=1.2_rot='10x,-10y,0z'_labels={'P': 'Phosphor'}.png
diff --git a/tests/fixtures/structure_viz/radii=None_rot='0x,0y,0z'_labels=False.png b/tests/fixtures/structure_viz/radii=None_rot='0x,0y,0z'_labels=False.png
new file mode 100644
index 00000000..4663d530
Binary files /dev/null and b/tests/fixtures/structure_viz/radii=None_rot='0x,0y,0z'_labels=False.png differ
diff --git a/tests/fixtures/structure_viz/radii=None_rot='0x,0y,0z'_labels=True.png b/tests/fixtures/structure_viz/radii=None_rot='0x,0y,0z'_labels=True.png
new file mode 100644
index 00000000..327b76aa
Binary files /dev/null and b/tests/fixtures/structure_viz/radii=None_rot='0x,0y,0z'_labels=True.png differ
diff --git a/tests/fixtures/structure_viz/radii=None_rot='0x,0y,0z'_labels={'P': 'Phosphor'}.png b/tests/fixtures/structure_viz/radii=None_rot='0x,0y,0z'_labels={'P': 'Phosphor'}.png
new file mode 100644
index 00000000..327b76aa
Binary files /dev/null and b/tests/fixtures/structure_viz/radii=None_rot='0x,0y,0z'_labels={'P': 'Phosphor'}.png differ
diff --git a/tests/fixtures/structure_viz/radii=None_rot='10x,-10y,0z'_labels=False.png b/tests/fixtures/structure_viz/radii=None_rot='10x,-10y,0z'_labels=False.png
new file mode 100644
index 00000000..429abbac
Binary files /dev/null and b/tests/fixtures/structure_viz/radii=None_rot='10x,-10y,0z'_labels=False.png differ
diff --git a/tests/fixtures/structure_viz/radii=None_rot='10x,-10y,0z'_labels=True.png b/tests/fixtures/structure_viz/radii=None_rot='10x,-10y,0z'_labels=True.png
new file mode 100644
index 00000000..12c87014
Binary files /dev/null and b/tests/fixtures/structure_viz/radii=None_rot='10x,-10y,0z'_labels=True.png differ
diff --git a/tests/fixtures/structure_viz/radii=None_rot='10x,-10y,0z'_labels={'P': 'Phosphor'}.png b/tests/fixtures/structure_viz/radii=None_rot='10x,-10y,0z'_labels={'P': 'Phosphor'}.png
new file mode 100644
index 00000000..12c87014
Binary files /dev/null and b/tests/fixtures/structure_viz/radii=None_rot='10x,-10y,0z'_labels={'P': 'Phosphor'}.png differ
diff --git a/tests/test_struct_vis.py b/tests/test_struct_vis.py
index 9ff677fd..f95cbb23 100644
--- a/tests/test_struct_vis.py
+++ b/tests/test_struct_vis.py
@@ -5,34 +5,42 @@
 import matplotlib.pyplot as plt
 import pytest
 from matplotlib.testing.compare import compare_images
+from pymatgen.analysis.local_env import VoronoiNN
 from pymatgen.core import Lattice, Structure
 from pymatgen.transformations.standard_transformations import SubstitutionTransformation
 
-from pymatviz.struct_vis import plot_structure_2d
+from pymatviz.structure_viz import plot_structure_2d
 
 from .conftest import save_reference_img
 
 
-os.makedirs(fixt_dir := "tests/fixtures/struct_vis", exist_ok=True)
+os.makedirs(fixt_dir := "tests/fixtures/structure_viz", exist_ok=True)
 
-latt = Lattice.cubic(5)
-struct = Structure(latt, ["Fe", "O"], [[0, 0, 0], [0.5, 0.5, 0.5]])
+lattice = Lattice.cubic(5)
+struct = Structure(lattice, ["Fe", "O"], [[0, 0, 0], [0.5, 0.5, 0.5]])
 
-disord_struct: Structure = SubstitutionTransformation(
+disordered_struct: Structure = SubstitutionTransformation(
     {"Fe": {"Fe": 0.75, "C": 0.25}}
 ).apply_transformation(struct)
 
 
-@pytest.mark.parametrize("radii", [0.5, 1.2])
+@pytest.mark.parametrize("radii", [None, 0.5])
 @pytest.mark.parametrize("rot", ["0x,0y,0z", "10x,-10y,0z"])
 @pytest.mark.parametrize("labels", [True, False, {"P": "Phosphor"}])
-def test_plot_structure_2d(radii, rot, labels, tmpdir):
+# add True to [False, VoronoiNN] later to test CrystalNN which currently errors on
+# disordered structures https://github.com/materialsproject/pymatgen/issues/2070
+@pytest.mark.parametrize("show_bonds", [False, VoronoiNN])
+def test_plot_structure_2d(radii, rot, labels, tmpdir, show_bonds):
     # set explicit size to avoid ImageComparisonFailure in CI: sizes do not match
     # expected (700, 1350, 3), actual (480, 640, 3)
     plt.figure(figsize=(5, 5))
 
     ax = plot_structure_2d(
-        disord_struct, atomic_radii=radii, rotation=rot, site_labels=labels
+        disordered_struct,
+        atomic_radii=radii,
+        rotation=rot,
+        site_labels=labels,
+        show_bonds=show_bonds,
     )
     assert isinstance(ax, plt.Axes)
     fname = f"{radii=}_{rot=}_{labels=}.png"