Description
Bug summary
If a Path has multiple connected components (separated by MOVETO codes some of them fully horizontal/vertical and some of them not, then none of the chunks are snapped (this is controlled by PathSnapper::should_snap in path_converters.h); this means that drawing that Path via a single PathPatch (or a single Path in a Collection) will render slightly differently than if creating multiple Paths, one per connected component, and drawing them via multiple PathPatches (or a single Collection with multiple Paths).
Code for reproduction
from io import BytesIO
import matplotlib as mpl
from matplotlib import pyplot as plt
from matplotlib.patches import PathPatch
from matplotlib.path import Path
import numpy as np
mpl.use("qtagg")
mpl.rcParams["path.snap"] = True # Default, set to False to remove the problem.
fig = plt.figure(figsize=(6, 2), dpi=200)
ax = fig.add_subplot()
# Define a path with three connected components; the last one has slanted parts.
xys = [
[0.0, 4.5],
[0.740033943422379, 4.5],
[1.259966056577621, 4.5],
[1.774087425970262, 4.5],
[2.2259125740297376, 4.5],
[3.0, 4.5],
[4.0, 4.5],
[4.5, 4.0],
[4.5, 3.0],
[4.5, 2.0],
[4.5, 1.0],
[4.5, 0.0],
]
M = Path.MOVETO; L = Path.LINETO
codes = [M, L, M, L, M, L, L, L, L, L, L, L]
# Draw as single Path, save as png.
ax.clear(); ax.set(xlim=(0, 9), ylim=(0, 9)); ax.set_axis_off()
ax.add_artist(PathPatch(Path(xys, codes), facecolor="none"))
buf0 = BytesIO(); fig.savefig(buf0, format="png"); buf0.seek(0)
# Draw as multiple separate Paths, save as png.
ax.clear(); ax.set(xlim=(0, 9), ylim=(0, 9)); ax.set_axis_off()
for sl in [slice(0, 2), slice(2, 4), slice(4, 12)]:
# One can also explicitly pass the codes (Path(xys[sl], codes[sl])); this makes no
# difference with leaving the codes as None.
ax.add_artist(PathPatch(Path(xys[sl]), facecolor="none"))
buf1 = BytesIO(); fig.savefig(buf1, format="png"); buf1.seek(0)
plt.close("all")
# Summarize the results.
im0 = plt.imread(buf0)[..., 0] # monochrome, so we can just take one channel.
im1 = plt.imread(buf1)[..., 0]
axs = plt.figure(figsize=(8, 8), layout="constrained").subplots(3, sharex=True, sharey=True)
axs[0].imshow(im0, cmap="gray")
axs[1].imshow(im1, cmap="gray")
axs[2].imshow(im0 - im1, cmap="bwr", clim=(-1, 1))
axs[2].set(title=f"diff image; max={abs(im0 - im1).max():.4f}")
plt.show()
Actual outcome
Note that the difference in snapping results in the bottom diff image showing the two lines being slightly offset one from the other.
Expected outcome
First two images should be exactly identical, and the bottom diff should be zero.
Additional information
Even if fixing this is tricky (we probably need to get rid of should_snap (a global value for the entire path) and instead decide for each individual LINETO whether it should be snapped), I'm mostly opening this right now to document one of the reasons why #25247 (which indeed turns multiple separate LineCollections into a single Collection) causes quite a few small image differences.
Operating system
macOS
Matplotlib Version
3.7.0
Matplotlib Backend
qtagg
Python version
3.11
Jupyter version
ENOSUCHLIB
Installation
from source (.tar.gz)