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

  • Approach for ellipses

    The following is a simple approach that works for the ellipse example (and, generally, for symmetric objects):

    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:

    Generalized approach

    Reversing the direction of one of the paths really is important here. It is not straightforward, however. Flipping is a bit of a "cheat" above, as it only works because an ellipse has a symmetric shape.

    For a generic path, we need to

    import matplotlib.pyplot as plt
    from matplotlib.patches import Ellipse, Rectangle, PathPatch, Annulus
    from matplotlib.path import Path
    
    ellipse = Ellipse((0, 0), 4, 3, color='blue')
    annulus = Annulus((0.5, 0.25), (2/2, 1/2), 0.9/2, angle=30, color='white')
    rectangle = Rectangle((-2.5, -2), 5, 2, color='red')
    
    def reverse(vertices, codes):
        # Codes (https://matplotlib.org/stable/api/path_api.html#matplotlib.path.Path):
        MOVETO = 1  # "Pick up the pen and move to the given vertex."
        CLOSE = 79  # "Draw a line segment to the start point of the current polyline."
        # LINETO = 2: "Draw a line from the current position to the given vertex."
        # CURVE3 = 3: "Draw a quadratic Bézier curve from the current position,
        #              with the given control point, to the given end point."
        # CURVE4 = 4: "Draw a cubic Bézier curve from the current position,
        #              with the given control points, to the given end point."
        assert len(vertices) == len(codes), f"Length mismatch: {len(vertices)=} vs. {len(codes)=}"
        vertices, codes = list(vertices), list(codes)
        assert codes[0] == MOVETO, "Path should start with MOVETO"
        if CLOSE in codes:  # Check if the path is closed
            assert codes.count(CLOSE) == 1, "CLOSEPOLY should not occur more than once"
            assert codes[-1] == CLOSE, "CLOSEPOLY should only appear at the last index"
            vertices, codes = vertices[:-1], codes[:-1]  # Ignore CLOSEPOLY for now
            is_closed = True
        else:
            is_closed = False
        # Split the path into segments, where segments start at MOVETO
        segmented_vertices, segmented_codes = [], []
        for vertex, code in zip(vertices, codes):
            if code == MOVETO:  # Start a new segment
                segmented_vertices.append([vertex])
                segmented_codes.append([code])
            else:  # Append to current segment
                segmented_vertices[-1].append(vertex)
                segmented_codes[-1].append(code)
        # Reverse and concatenate
        rev_vertices = [val for seg in segmented_vertices for val in reversed(seg)]
        rev_codes = [val for seg in segmented_codes for val in [seg[0]] + seg[1:][::-1]]
        if is_closed:  # Close again if necessary, by appending CLOSEPOLY
            rev_codes.append(CLOSE)
            rev_vertices.append([0., 0.])
        return rev_vertices, rev_codes
        
    transform_1 = ellipse.get_patch_transform().transform
    transform_2 = annulus.get_patch_transform().transform
    vertices_1 = ellipse.get_path().vertices.copy()
    vertices_2 = annulus.get_path().vertices.copy()
    codes_1 = ellipse.get_path().codes.tolist()
    codes_2 = annulus.get_path().codes.tolist()
    
    vertices_2, codes_2 = reverse(vertices_2, codes_2)  # Reverse one path
    
    # Combine the paths, create a PathPatch from the combined path
    combined_path = Path(
        transform_1(vertices_1).tolist() + transform_2(vertices_2).tolist(),
        codes_1 + codes_2,
    )
    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: result from generalized approach

    Key ideas: