Reputation: 35
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
Reputation: 5531
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()
Key ideas:
vertices
and codes
of the two ellipses and concatenating them within a new matplotlib.path.Path
instance.matplotlib.transforms.Affine2D
, which we use for flipping one of the given ellipses.get_patch_transform()
result before concatenating the vertices.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
codes
(which describe how the vertices are connected) into connected segments,codes
need to be reversed a bit differently from the vertices
),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()
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:
[move_to V1, line_to V2, curve_to C, curve_to V3]
.[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):
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