Skip to content

Visualization

The voids.visualization sub-package provides network rendering via Plotly and PyVista.

PyVista dependency

PyVista is installed as a core dependency of voids and is available by default when you install the package.


Plotly

voids.visualization.plotly

plot_network_plotly

plot_network_plotly(
    net,
    *,
    point_scalars=None,
    cell_scalars=None,
    point_sizes=None,
    throat_sizes=None,
    point_size=None,
    line_width=None,
    line_opacity=0.4,
    size_scale=1.0,
    point_size_limits=None,
    throat_size_limits=None,
    max_throats=1000,
    title=None,
    show_colorbar=True,
    layout_kwargs=None,
)

Create an interactive Plotly visualization of a pore-throat network.

Parameters:

Name Type Description Default
net Network

Network to render.

required
point_scalars str | ndarray | None

Pore field name or explicit pore-valued array with shape (Np,).

None
cell_scalars str | ndarray | None

Throat field name or explicit throat-valued array with shape (Nt,).

None
point_sizes str | ndarray | bool | None

Pore/throat characteristic size field name, explicit size array, None for automatic size-field detection, or False to disable size-driven rendering. Automatically detected size fields follow the priority diameter_equivalent -> diameter_inscribed -> radius_inscribed -> area.

None
throat_sizes str | ndarray | bool | None

Pore/throat characteristic size field name, explicit size array, None for automatic size-field detection, or False to disable size-driven rendering. Automatically detected size fields follow the priority diameter_equivalent -> diameter_inscribed -> radius_inscribed -> area.

None
point_size float | None

Constant marker size for pores when explicit size rendering is disabled. When size-driven rendering is active, this acts as the reference marker size for median-sized pores.

None
line_width float | None

Constant line width for throats when explicit size rendering is disabled. When size-driven rendering is active, this acts as the reference width for median-sized throats.

None
line_opacity float

Opacity applied to throat lines.

0.4
size_scale float

Multiplicative factor applied to size-driven pore markers and throat widths.

1.0
point_size_limits tuple[float | None, float | None] | None

Optional (min_px, max_px) limits for size-driven rendering in screen-space pixels. When omitted, conservative default clipping is applied for readability. Set to (None, None) to disable clipping and preserve the full relative dynamic range.

None
throat_size_limits tuple[float | None, float | None] | None

Optional (min_px, max_px) limits for size-driven rendering in screen-space pixels. When omitted, conservative default clipping is applied for readability. Set to (None, None) to disable clipping and preserve the full relative dynamic range.

None
max_throats int | None

Maximum number of throats to draw. Large networks are downsampled for responsiveness.

1000
title str | None

Figure title.

None
show_colorbar bool

If True, display a colorbar for pore scalars.

True
layout_kwargs dict[str, Any] | None

Optional Plotly layout overrides.

None

Returns:

Type Description
Figure

Interactive 3D figure.

Notes

If only pore scalars are given, each throat is colored by the arithmetic mean of its endpoint pore values:

s_throat = 0.5 * (s_i + s_j)

The throat colormap is normalized with the same scalar bounds as the pore markers so that equal numerical values map to equal colors across pores and throats.

Source code in src/voids/visualization/plotly.py
def plot_network_plotly(
    net: Network,
    *,
    point_scalars: str | np.ndarray | None = None,
    cell_scalars: str | np.ndarray | None = None,
    point_sizes: str | np.ndarray | bool | None = None,
    throat_sizes: str | np.ndarray | bool | None = None,
    point_size: float | None = None,
    line_width: float | None = None,
    line_opacity: float = 0.4,
    size_scale: float = 1.0,
    point_size_limits: tuple[float | None, float | None] | None = None,
    throat_size_limits: tuple[float | None, float | None] | None = None,
    max_throats: int | None = 1000,
    title: str | None = None,
    show_colorbar: bool = True,
    layout_kwargs: dict[str, Any] | None = None,
) -> go.Figure:
    """Create an interactive Plotly visualization of a pore-throat network.

    Parameters
    ----------
    net :
        Network to render.
    point_scalars :
        Pore field name or explicit pore-valued array with shape ``(Np,)``.
    cell_scalars :
        Throat field name or explicit throat-valued array with shape ``(Nt,)``.
    point_sizes, throat_sizes :
        Pore/throat characteristic size field name, explicit size array, ``None`` for
        automatic size-field detection, or ``False`` to disable size-driven rendering.
        Automatically detected size fields follow the priority
        ``diameter_equivalent -> diameter_inscribed -> radius_inscribed -> area``.
    point_size :
        Constant marker size for pores when explicit size rendering is disabled. When
        size-driven rendering is active, this acts as the reference marker size for
        median-sized pores.
    line_width :
        Constant line width for throats when explicit size rendering is disabled. When
        size-driven rendering is active, this acts as the reference width for
        median-sized throats.
    line_opacity :
        Opacity applied to throat lines.
    size_scale :
        Multiplicative factor applied to size-driven pore markers and throat widths.
    point_size_limits, throat_size_limits :
        Optional ``(min_px, max_px)`` limits for size-driven rendering in screen-space
        pixels. When omitted, conservative default clipping is applied for readability.
        Set to ``(None, None)`` to disable clipping and preserve the full relative
        dynamic range.
    max_throats :
        Maximum number of throats to draw. Large networks are downsampled for responsiveness.
    title :
        Figure title.
    show_colorbar :
        If ``True``, display a colorbar for pore scalars.
    layout_kwargs :
        Optional Plotly layout overrides.

    Returns
    -------
    plotly.graph_objects.Figure
        Interactive 3D figure.

    Notes
    -----
    If only pore scalars are given, each throat is colored by the arithmetic mean of
    its endpoint pore values:

    ``s_throat = 0.5 * (s_i + s_j)``

    The throat colormap is normalized with the same scalar bounds as the pore markers
    so that equal numerical values map to equal colors across pores and throats.
    """

    point_values, point_label = _resolve_scalars(
        point_scalars, store=net.pore, expected_shape=(net.Np,), prefix="pore"
    )
    cell_values, cell_label = _resolve_scalars(
        cell_scalars, store=net.throat, expected_shape=(net.Nt,), prefix="throat"
    )
    point_size_values, point_size_label = resolve_size_values(
        point_sizes, store=net.pore, expected_shape=(net.Np,), prefix="pore"
    )
    throat_size_values, throat_size_label = resolve_size_values(
        throat_sizes, store=net.throat, expected_shape=(net.Nt,), prefix="throat"
    )

    coords = np.asarray(net.pore_coords, dtype=float)
    x, y, z = coords[:, 0], coords[:, 1], coords[:, 2]
    sampled = _sample_indices(net.Nt, max_throats)
    point_vmin, point_vmax = _scalar_bounds(point_values)
    cell_vmin, cell_vmax = _scalar_bounds(cell_values)
    point_size_ref = float(
        point_size if point_size is not None else (6.0 if net.Np <= 2000 else 4.0)
    )
    line_width_ref = float(2.0 if line_width is None else line_width)
    use_variable_point_sizes = point_size_values is not None
    use_variable_throat_sizes = throat_size_values is not None
    point_min_size: float | None
    point_max_size: float | None
    if point_size_limits is None:
        point_min_size = max(2.0, 0.5 * point_size_ref)
        point_max_size = max(18.0, 4.0 * point_size_ref)
    else:
        point_min_size, point_max_size = point_size_limits
    throat_min_size: float | None
    throat_max_size: float | None
    if throat_size_limits is None:
        throat_min_size = 0.75
        throat_max_size = max(10.0, 4.0 * line_width_ref)
    else:
        throat_min_size, throat_max_size = throat_size_limits

    if use_variable_point_sizes:
        assert point_size_values is not None
        marker_size: float | np.ndarray = scale_sizes_to_pixels(
            point_size_values,
            reference=point_size_ref,
            scale=size_scale,
            min_size=point_min_size,
            max_size=point_max_size,
        )
    else:
        marker_size = point_size_ref

    if use_variable_throat_sizes:
        assert throat_size_values is not None
        sampled_line_widths = scale_sizes_to_pixels(
            throat_size_values[sampled],
            reference=line_width_ref,
            scale=size_scale,
            min_size=throat_min_size,
            max_size=throat_max_size,
        )
    else:
        sampled_line_widths = np.full(sampled.shape, line_width_ref, dtype=float)

    if point_values is not None:
        marker: dict[str, Any] = {
            "size": marker_size,
            "color": point_values,
            "colorscale": "Viridis",
            "showscale": show_colorbar,
        }
        if use_variable_point_sizes:
            marker["sizemode"] = "diameter"
        if point_vmin is not None and point_vmax is not None:
            marker["cmin"] = point_vmin
            marker["cmax"] = point_vmax
        if show_colorbar:
            marker["colorbar"] = {"title": point_label or "pore scalar"}
    else:
        marker = {
            "size": marker_size,
            "color": "royalblue",
            "showscale": False,
        }
        if use_variable_point_sizes:
            marker["sizemode"] = "diameter"

    pore_text = []
    for idx in range(net.Np):
        hover_lines = [f"Pore {idx}"]
        if point_values is not None:
            hover_lines.append(f"{point_label or 'value'}={point_values[idx]:.3e}")
        if use_variable_point_sizes and point_size_label is not None:
            assert point_size_values is not None
            hover_lines.append(f"{point_size_label}={point_size_values[idx]:.3e}")
        pore_text.append("<br>".join(hover_lines))

    traces: list[Any] = [
        go.Scatter3d(
            x=x,
            y=y,
            z=z,
            mode="markers",
            marker=marker,
            name="Pores",
            text=pore_text,
            hoverinfo="text",
        )
    ]

    if cell_values is not None:
        throat_values = np.asarray(cell_values[sampled], dtype=float)
        throat_label = cell_label or "throat scalar"
        color_vmin, color_vmax = cell_vmin, cell_vmax
    elif point_values is not None:
        conns = net.throat_conns[sampled]
        throat_values = 0.5 * (point_values[conns[:, 0]] + point_values[conns[:, 1]])
        throat_label = f"avg({point_label or 'pore scalar'})"
        color_vmin, color_vmax = point_vmin, point_vmax
    else:
        throat_values = None
        throat_label = None
        color_vmin, color_vmax = None, None

    for local_idx, throat_idx in enumerate(sampled):
        i, j = net.throat_conns[throat_idx]
        hover_lines = [f"Throat {int(throat_idx)}"]
        if throat_values is None:
            color = _rgb_with_opacity("rgb(100,100,100)", line_opacity)
        else:
            if color_vmin is not None and color_vmax is not None and color_vmax > color_vmin:
                norm = float((throat_values[local_idx] - color_vmin) / (color_vmax - color_vmin))
            else:
                norm = 0.5
            color = _rgb_with_opacity(sample_colorscale("Viridis", [norm])[0], line_opacity)
            hover_lines.append(f"{throat_label}={float(throat_values[local_idx]):.3e}")
        if use_variable_throat_sizes and throat_size_label is not None:
            assert throat_size_values is not None
            hover_lines.append(f"{throat_size_label}={float(throat_size_values[throat_idx]):.3e}")
        traces.append(
            go.Scatter3d(
                x=[x[i], x[j]],
                y=[y[i], y[j]],
                z=[z[i], z[j]],
                mode="lines",
                line={"color": color, "width": float(sampled_line_widths[local_idx])},
                name="Throats",
                showlegend=False,
                text="<br>".join(hover_lines),
                hoverinfo="text",
            )
        )

    if title is None:
        title = "Pore network"
        if sampled.size != net.Nt:
            title += f" (showing {sampled.size} of {net.Nt} throats)"

    figure = go.Figure(data=traces)
    layout: dict[str, Any] = {
        "title": title,
        "scene": {
            "xaxis_title": "X",
            "yaxis_title": "Y",
            "zaxis_title": "Z",
            "aspectmode": "data",
        },
        "width": 900,
        "height": 700,
        "hovermode": "closest",
    }
    if layout_kwargs:
        layout.update(layout_kwargs)
    figure.update_layout(**layout)
    return figure

PyVista

voids.visualization.pyvista

network_to_pyvista_polydata

network_to_pyvista_polydata(
    net,
    *,
    point_scalars=None,
    cell_scalars=None,
    include_all_numeric_fields=False,
)

Convert a network to pyvista.PolyData.

Parameters:

Name Type Description Default
net Network

Network to convert.

required
point_scalars str | ndarray | None

Pore/throat scalar field name or explicit array.

None
cell_scalars str | ndarray | None

Pore/throat scalar field name or explicit array.

None
include_all_numeric_fields bool

If True, attach every 1D numeric pore/throat array whose length matches Np or Nt.

False

Returns:

Type Description
PolyData

PolyData with pores as points and throats as line cells.

Raises:

Type Description
KeyError

If a requested scalar field name is missing.

ValueError

If an explicit scalar array has the wrong shape.

Source code in src/voids/visualization/pyvista.py
def network_to_pyvista_polydata(
    net: Network,
    *,
    point_scalars: str | np.ndarray | None = None,
    cell_scalars: str | np.ndarray | None = None,
    include_all_numeric_fields: bool = False,
) -> pv.PolyData:
    """Convert a network to ``pyvista.PolyData``.

    Parameters
    ----------
    net :
        Network to convert.
    point_scalars, cell_scalars :
        Pore/throat scalar field name or explicit array.
    include_all_numeric_fields :
        If ``True``, attach every 1D numeric pore/throat array whose length matches
        ``Np`` or ``Nt``.

    Returns
    -------
    pyvista.PolyData
        PolyData with pores as points and throats as line cells.

    Raises
    ------
    KeyError
        If a requested scalar field name is missing.
    ValueError
        If an explicit scalar array has the wrong shape.
    """

    points = np.asarray(net.pore_coords, dtype=float)
    line_cells = _line_cells_from_conns(net.throat_conns)
    poly: pv.PolyData = pv.PolyData(points, lines=line_cells)

    poly.point_data["pore.id"] = np.arange(net.Np, dtype=np.int64)
    poly.cell_data["throat.id"] = np.arange(net.Nt, dtype=np.int64)

    if include_all_numeric_fields:
        for k, v in net.pore.items():
            a = np.asarray(v)
            if a.ndim == 1 and a.shape[0] == net.Np and np.issubdtype(a.dtype, np.number):
                poly.point_data[f"pore.{k}"] = a
        for k, v in net.throat.items():
            a = np.asarray(v)
            if a.ndim == 1 and a.shape[0] == net.Nt and np.issubdtype(a.dtype, np.number):
                poly.cell_data[f"throat.{k}"] = a

    if isinstance(point_scalars, str):
        if point_scalars not in net.pore:
            raise KeyError(f"Missing pore field '{point_scalars}'")
        poly.point_data["pore.scalar"] = np.asarray(net.pore[point_scalars])
        poly.set_active_scalars("pore.scalar", preference="point")
    elif point_scalars is not None:
        arr = np.asarray(point_scalars)
        if arr.shape != (net.Np,):
            raise ValueError("point_scalars array must have shape (Np,)")
        poly.point_data["pore.scalar"] = arr
        poly.set_active_scalars("pore.scalar", preference="point")

    if isinstance(cell_scalars, str):
        if cell_scalars not in net.throat:
            raise KeyError(f"Missing throat field '{cell_scalars}'")
        poly.cell_data["throat.scalar"] = np.asarray(net.throat[cell_scalars])
    elif cell_scalars is not None:
        arr = np.asarray(cell_scalars)
        if arr.shape != (net.Nt,):
            raise ValueError("cell_scalars array must have shape (Nt,)")
        poly.cell_data["throat.scalar"] = arr

    return poly

plot_network_pyvista

plot_network_pyvista(
    net,
    *,
    point_scalars=None,
    cell_scalars=None,
    point_sizes=None,
    throat_sizes=None,
    show_points=True,
    show_lines=True,
    line_width=None,
    point_size=None,
    render_tubes=False,
    tube_radius=None,
    size_scale=1.0,
    off_screen=False,
    screenshot=None,
    show_axes=True,
    notebook=None,
    **add_mesh_kwargs,
)

Render a pore network with PyVista.

Parameters:

Name Type Description Default
net Network

Network to render.

required
point_scalars str | ndarray | None

Pore/throat scalar field name or explicit array.

None
cell_scalars str | ndarray | None

Pore/throat scalar field name or explicit array.

None
point_sizes str | ndarray | bool | None

Pore/throat characteristic size field name, explicit size array, None for automatic size-field detection, or False to disable size-driven rendering. Automatically detected size fields follow the priority diameter_equivalent -> diameter_inscribed -> radius_inscribed -> area.

None
throat_sizes str | ndarray | bool | None

Pore/throat characteristic size field name, explicit size array, None for automatic size-field detection, or False to disable size-driven rendering. Automatically detected size fields follow the priority diameter_equivalent -> diameter_inscribed -> radius_inscribed -> area.

None
show_points bool

Toggle pore and throat rendering.

True
show_lines bool

Toggle pore and throat rendering.

True
line_width float | None

Width used for line rendering when throat sizes are not rendered with tubes.

None
point_size float | None

Marker size used for pores when size-driven sphere rendering is disabled.

None
render_tubes bool

If True, convert throats from lines to tubes.

False
tube_radius float | None

Optional tube radius when render_tubes is enabled.

None
size_scale float

Multiplicative factor applied to point diameters and throat radii in world units.

1.0
off_screen bool

If True, create an off-screen plotter for headless rendering.

False
screenshot str | None

Optional screenshot output path. When provided, the plot is rendered and saved.

None
show_axes bool

If True, display orientation axes.

True
notebook bool | None

Optional PyVista notebook flag. Defaults to False when omitted.

None
**add_mesh_kwargs Any

Additional keyword arguments forwarded to :meth:pyvista.Plotter.add_mesh.

{}

Returns:

Type Description
tuple

Pair (plotter, polydata).

Notes

For throat rendering, scalar selection follows this priority:

  1. explicit throat/cell scalar data
  2. pore/point scalar data, reused on the line representation

This allows pore-defined pressure fields to color both pores and throats in a consistent network visualization.

When tube rendering is requested (via render_tubes, tube_radius, or automatically detected variable throat sizes) but the PyVista tube filter is unavailable or raises an exception, rendering falls back to line geometry with render_lines_as_tubes=True so that a tube-like appearance is preserved. Variable throat radii cannot be accurately represented in this fallback mode; a :class:UserWarning is emitted to notify callers.

Source code in src/voids/visualization/pyvista.py
def plot_network_pyvista(
    net: Network,
    *,
    point_scalars: str | np.ndarray | None = None,
    cell_scalars: str | np.ndarray | None = None,
    point_sizes: str | np.ndarray | bool | None = None,
    throat_sizes: str | np.ndarray | bool | None = None,
    show_points: bool = True,
    show_lines: bool = True,
    line_width: float | None = None,
    point_size: float | None = None,
    render_tubes: bool = False,
    tube_radius: float | None = None,
    size_scale: float = 1.0,
    off_screen: bool = False,
    screenshot: str | None = None,
    show_axes: bool = True,
    notebook: bool | None = None,
    **add_mesh_kwargs: Any,
) -> tuple[pv.Plotter, pv.PolyData]:
    """Render a pore network with PyVista.

    Parameters
    ----------
    net :
        Network to render.
    point_scalars, cell_scalars :
        Pore/throat scalar field name or explicit array.
    point_sizes, throat_sizes :
        Pore/throat characteristic size field name, explicit size array, ``None`` for
        automatic size-field detection, or ``False`` to disable size-driven rendering.
        Automatically detected size fields follow the priority
        ``diameter_equivalent -> diameter_inscribed -> radius_inscribed -> area``.
    show_points, show_lines :
        Toggle pore and throat rendering.
    line_width :
        Width used for line rendering when throat sizes are not rendered with tubes.
    point_size :
        Marker size used for pores when size-driven sphere rendering is disabled.
    render_tubes :
        If ``True``, convert throats from lines to tubes.
    tube_radius :
        Optional tube radius when ``render_tubes`` is enabled.
    size_scale :
        Multiplicative factor applied to point diameters and throat radii in world units.
    off_screen :
        If ``True``, create an off-screen plotter for headless rendering.
    screenshot :
        Optional screenshot output path. When provided, the plot is rendered and saved.
    show_axes :
        If ``True``, display orientation axes.
    notebook :
        Optional PyVista notebook flag. Defaults to ``False`` when omitted.
    **add_mesh_kwargs :
        Additional keyword arguments forwarded to :meth:`pyvista.Plotter.add_mesh`.

    Returns
    -------
    tuple
        Pair ``(plotter, polydata)``.

    Notes
    -----
    For throat rendering, scalar selection follows this priority:

    1. explicit throat/cell scalar data
    2. pore/point scalar data, reused on the line representation

    This allows pore-defined pressure fields to color both pores and throats in a
    consistent network visualization.

    When tube rendering is requested (via ``render_tubes``, ``tube_radius``, or
    automatically detected variable throat sizes) but the PyVista tube filter is
    unavailable or raises an exception, rendering falls back to line geometry with
    ``render_lines_as_tubes=True`` so that a tube-like appearance is preserved.
    Variable throat radii **cannot** be accurately represented in this fallback mode;
    a :class:`UserWarning` is emitted to notify callers.
    """

    poly = network_to_pyvista_polydata(
        net,
        point_scalars=point_scalars,
        cell_scalars=cell_scalars,
        include_all_numeric_fields=True,
    )

    if notebook is None:
        notebook = False
    pl = pv.Plotter(off_screen=off_screen, notebook=notebook)

    line_scalars_name = "throat.scalar" if "throat.scalar" in poly.cell_data else None
    point_scalars_name = "pore.scalar" if "pore.scalar" in poly.point_data else None
    if line_scalars_name is None and point_scalars_name is not None:
        line_scalars_name = point_scalars_name
    point_size_values, _ = resolve_size_values(
        point_sizes, store=net.pore, expected_shape=(net.Np,), prefix="pore"
    )
    throat_size_values, _ = resolve_size_values(
        throat_sizes, store=net.throat, expected_shape=(net.Nt,), prefix="throat"
    )
    use_variable_point_sizes = point_size_values is not None
    use_variable_throat_sizes = throat_size_values is not None
    point_size_value = float(point_size if point_size is not None else 9.0)
    line_width_value = float(3.0 if line_width is None else line_width)
    if use_variable_point_sizes:
        poly.point_data["pore.render_diameter"] = float(size_scale) * np.asarray(
            point_size_values, dtype=float
        )
    if use_variable_throat_sizes:
        poly.cell_data["throat.render_radius"] = (
            0.5 * float(size_scale) * np.asarray(throat_size_values, dtype=float)
        )

    if show_lines and net.Nt > 0:
        line_mesh = poly
        render_tubes_effective = (
            render_tubes or use_variable_throat_sizes or tube_radius is not None
        )
        if render_tubes_effective:
            kwargs: dict[str, Any] = {}
            if use_variable_throat_sizes:
                kwargs["scalars"] = "throat.render_radius"
                kwargs["absolute"] = True
                kwargs["preference"] = "cell"
            elif tube_radius is not None:
                kwargs["radius"] = float(tube_radius)
            try:
                line_mesh = poly.tube(**kwargs)
            except Exception as exc:
                line_mesh = poly
                msg = (
                    f"PyVista tube filter failed ({type(exc).__name__}: {exc}); "
                    "falling back to line rendering with render_lines_as_tubes=True."
                )
                if use_variable_throat_sizes:
                    msg += (
                        " Variable throat radii cannot be represented accurately "
                        "without the tube filter."
                    )
                warnings.warn(msg, stacklevel=2)
        line_kwargs: dict[str, Any] = {
            "scalars": line_scalars_name,
            "show_scalar_bar": (line_scalars_name is not None),
            **add_mesh_kwargs,
        }
        if line_mesh is poly:
            line_kwargs["line_width"] = line_width_value
            # Use line-tube approximation when tubes were requested but unavailable.
            line_kwargs["render_lines_as_tubes"] = render_tubes_effective
        pl.add_mesh(line_mesh, **line_kwargs)

    if show_points and net.Np > 0:
        if use_variable_point_sizes:
            point_mesh = pv.PolyData(np.asarray(net.pore_coords, dtype=float))
            point_mesh.point_data["pore.render_diameter"] = poly.point_data["pore.render_diameter"]
            if point_scalars_name is not None:
                point_mesh.point_data[point_scalars_name] = poly.point_data[point_scalars_name]
            sphere = pv.Sphere(radius=0.5)
            point_mesh = point_mesh.glyph(
                scale="pore.render_diameter",
                orient=False,
                factor=1.0,
                geom=sphere,
            )
            pl.add_mesh(
                point_mesh,
                scalars=point_scalars_name,
                show_scalar_bar=(point_scalars_name is not None and not show_lines),
            )
        else:
            pl.add_mesh(
                poly,
                style="points",
                point_size=point_size_value,
                render_points_as_spheres=True,
                scalars=point_scalars_name,
                show_scalar_bar=(point_scalars_name is not None and not show_lines),
            )

    if show_axes:
        pl.add_axes()  # type: ignore[call-arg]

    if screenshot is not None:
        pl.show(auto_close=False)
        pl.screenshot(screenshot)
    return pl, poly