Adrián T.
Adrián T.

Reputation: 35

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.

Upvotes: 2

Views: 110

Answers (1)

simon
simon

Reputation: 5531

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:

  • We can combine their paths by getting the vertices and codes of the two ellipses and concatenating them within a new matplotlib.path.Path instance.
  • We need to reverse one of the paths before combining, or otherwise the intersection will not be transparent. We do so with an appropriate matplotlib.transforms.Affine2D, which we use for flipping one of the given ellipses.
  • We need to get the paths into the correct coordinate system before plotting. We do so by applying each ellipse's get_patch_transform() result before concatenating the vertices.

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

  • disassemble it according to its codes (which describe how the vertices are connected) into connected segments,
  • reverse the resulting segments (where we need to take extra care that the codes need to be reversed a bit differently from the vertices),
  • and then reassemble it:
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:

  • The reverse() function splits the path at points where the drawing pen is moved, and only reverses the segments in-between.

  • Consider the pseudo-code example of a path segment with three vertices V1, V2, V3, connected by a line between V1–V2 and a curve between V2–V3 with control point C:

    • The (forward) path would be [move_to V1, line_to V2, curve_to C, curve_to V3].
    • The reversed path would be [move_to V3, curve_to C, curve_to V2, line_to V1].

    We can see here, that we need to reverse the codes (move_to, curve_to, line_to) different than the points (vertices V1, V2, V3; control point C):

    • The points just need to be reversed completely.
    • The first code always needs to be move_to, then the remaining codes need to be reversed.
  • For a closed path, the value 79 (CLOSEPOLY which I shortened to CLOSE above) always seems to be the last code. We take care of it by checking for it, removing it if present, and appending it again at the end, if necessary. The values of its associated vertex do not matter.

Upvotes: 4

Related Questions