Behavior of `contains()` inconsistent with fill in overlapping paths · Issue #32329 · matplotlib/matplotlib · GitHub
Skip to content

Behavior of contains() inconsistent with fill in overlapping paths #32329

Open

Description

@greglucas

How do the two cases behave with contains() and mouse checking on the various artists?

I've investigated this, and amusingly enough, contains() is not fully consistent with either fill rule. The current logic (in this function) is essentially (and possibly unintentionally) consistent with the even-odd rule on any individual subpath (and if the subpath does not self-intersect, the non-zero winding rule is the same). However, if there are multiple subpaths, a point is considered "inside" the combined path if it is inside at least one subpath, even if the combined effect of all subpaths mean that the point should actually be "outside". This means:

  • If all subpaths do not self-intersect and do not intersect with each other, contains() is consistent with both the non-zero winding rule and the even-odd rule (that is, both rules have the same result)
  • If there is a single subpath that self-intersects, contains() is consistent with the even-odd rule
  • If there are multiple subpaths that do not self-intersect but do intersect with each other, and all subpaths are the same orientation, contains() is consistent with the non-zero winding rule
  • If none of the above, contains() gives a "wrong" result: it is not consistent with either rule

My inclination is that fixing contains() should be deferred to a future PR.

Originally posted by @ayshih in #32253 (comment)

Activity

  1. ayshih commented on Sep 10, 2026

    @ayshih
    Contributor

    Here is an interactive example that shows how the existing contains() code sometimes matches one fill rule and not the other, depending on the path used for the PathPatch.

    import numpy as np
    
    import matplotlib.pyplot as plt
    from matplotlib.patches import PathPatch
    from matplotlib.path import Path
    
    twisted_vertices = np.array([[0, 0, 6, 6, 1, 1, 5, 5, 2, 2, 4, 4, 3, 3, 5, 5, 0],
                                 [2, 5, 5, 0, 0, 7, 7, 3, 3, 4, 4, 6, 6, 1, 1, 2, 2]]).T
    twisted_codes = [Path.MOVETO] + [Path.LINETO] * (len(twisted_vertices) - 1)
    
    square_vertices = np.array([[-1, -1], [-1, 1], [1, 1], [1, -1], [-1, -1]])
    square_codes = [Path.MOVETO, Path.LINETO, Path.LINETO, Path.LINETO, Path.LINETO]
    
    fig, axs = plt.subplots(2, 3, figsize=(7, 4), layout='constrained')
    
    for i, fill_rule in enumerate(['nonzero', 'evenodd']):
        axs[i, 0].text(-1.2, 0, f'fill_rule={fill_rule}', rotation='vertical', va='center')
    
        donut1 = Path(np.vstack([square_vertices, square_vertices / 2]), square_codes * 2)
        axs[i, 0].add_patch(PathPatch(donut1, fc='g', ec='m', fill_rule=fill_rule))
    
        twisted = Path(twisted_vertices, twisted_codes)
        axs[i, 1].add_patch(PathPatch(twisted, fc='g', ec='m', fill_rule=fill_rule))
    
        donut2 = Path(np.vstack([square_vertices, square_vertices[::-1, :] / 2]), square_codes * 2)
        axs[i, 2].add_patch(PathPatch(donut2, fc='g', ec='m', fill_rule=fill_rule))
    
        for j in range(axs.shape[1]):
            axs[i, j].autoscale_view()
            axs[i, j].set_axis_off()
    
    axs[0, 0].set_title('contains() == nonzero')
    axs[0, 1].set_title('contains() == even-odd')
    axs[0, 2].set_title('contains() != either')
    
    def on_mouse_move(event):
        if event.inaxes is None:
            return
    
        for ax in axs.flatten():
            patch = ax.patches[0]
            patch.set_facecolor('r' if patch.contains(event)[0] else 'g')
    
        fig.canvas.draw_idle()
    
    fig.canvas.mpl_connect('motion_notify_event', on_mouse_move)
    
    plt.show()
  2. ayshih commented on Sep 10, 2026

    @ayshih
    Contributor

    I've already verified how to modify point_in_path_impl() in _path.h to be fully consistent with one fill rule or the other. The thought needed is how to set up the (internal) API to carry the current fill rule down to that level to switch which one is being calculated.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions