pythonmatplotlib

Hollowing out a patch / "anticlipping" a patch in Matplotlib (Python)


I want to draw a patch in Matplotlib constructed by hollowing it out with another patch, in a way such that the hollowed out part is completely transparent.

For example, lets say I wanted to draw an ellipse hollowed out by another. I could do the following:

import matplotlib.pyplot as plt
from matplotlib.patches import Ellipse

ellipse_1 = Ellipse((0,0), 4, 3, color='blue')
ellipse_2 = Ellipse((0.5,0.25), 2, 1, angle=30, color='white')

ax = plt.axes()
ax.add_artist(ellipse_1)
ax.add_artist(ellipse_2)

plt.axis('equal')
plt.axis((-3,3,-3,3))
plt.show()

However, if now I wanted to draw something behind, the part behind the hollowed out part would not be visible, for example:

import matplotlib.pyplot as plt
from matplotlib.patches import Ellipse, Rectangle

ellipse_1 = Ellipse((0,0), 4, 3, color='blue')
ellipse_2 = Ellipse((0.5,0.25), 2, 1, angle=30, color='white')
rectangle = Rectangle((-2.5,-2), 5, 2, color='red')

ax = plt.axes()
ax.add_artist(rectangle)
ax.add_artist(ellipse_1)
ax.add_artist(ellipse_2)

plt.axis('equal')
plt.axis((-3,3,-3,3))
plt.show()

where the part of the red rectangle inside the blue shape cannot be seen. Is there an easy way to do this?

Another way to solve this would be with a function to do the opposite of set_clip_path, lets say set_anticlip_path, where the line

ellipse_1.set_anticlip_path(ellipse_2)

would do the trick, but I have not been able to find anything like this.


Solution

  • import matplotlib.pyplot as plt
    from matplotlib.patches import Ellipse, Rectangle, PathPatch
    from matplotlib.path import Path
    from matplotlib.transforms import Affine2D
    
    ellipse_1 = Ellipse((0, 0), 4, 3, color='blue')
    ellipse_2 = Ellipse((0.5, 0.25), 2, 1, angle=30, color='white')
    rectangle = Rectangle((-2.5, -2), 5, 2, color='red')
    
    # Provide a flipping transform to reverse one of the paths
    flip = Affine2D().scale(-1, 1).transform
    
    transform_1 = ellipse_1.get_patch_transform().transform
    transform_2 = ellipse_2.get_patch_transform().transform
    vertices_1 = ellipse_1.get_path().vertices.copy()
    vertices_2 = ellipse_2.get_path().vertices.copy()
    
    # Combine the paths, create a PathPatch from the combined path
    combined_path = Path(
        transform_1(vertices_1).tolist() + transform_2(flip(vertices_2)).tolist(),
        ellipse_1.get_path().codes.tolist() + ellipse_2.get_path().codes.tolist(),
    )
    combined_ellipse = PathPatch(combined_path, color='blue')
    
    ax = plt.axes()
    ax.add_artist(rectangle)
    ax.add_artist(combined_ellipse)
    
    plt.axis('equal')
    plt.axis((-3, 3, -3, 3))
    plt.show()
    

    Produces: hollowed-out ellipse

    Key ideas:

    Alternative approach and caveat: Reversing the direction of one of the paths really is important here. It is not straightforward, however: First, I tried simply reversing the order of vertices of the second ellipse (i.e. I tried writing vertices_2[::-1] rather than flip(vertices_2)). This, however, messed up the ellipse shape quite a bit. Second, I had a look at the values of the vertices that result (a) from flipping and (b) from reversing, and I realized that the latter had an offset compared to the former. In particular, it seems that everything except the last vertex needs to be reversed. Thus, the following code results in the same image as the image above, at least for a reversed ellipse:

    import numpy as np
    ...
    # Writing `vertices[-2::-1]` essentially means `vertices[:-1][::-1]`
    reverse = lambda vertices: np.r_[vertices[-2::-1], vertices[-1:]]
    ...
    combined_path = Path(
        transform_1(vertices_1).tolist() + transform_2(reverse(vertices_2)).tolist(),
        ellipse_1.get_path().codes.tolist() + ellipse_2.get_path().codes.tolist(),
    )
    

    To summarize: