Skip to content

Handle MOVETO's, CLOSEPOLY's and empty paths in Path.interpolated #29919

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
Apr 18, 2025

Conversation

rcomer
Copy link
Member

@rcomer rcomer commented Apr 15, 2025

PR summary

Fixes #29917. The geo projection's path transform relies on Path.interpolated

def transform_path_non_affine(self, path):
# docstring inherited
ipath = path.interpolated(self._resolution)
return Path(self.transform(ipath.vertices), ipath.codes)

At v3.8 ContourSet changed from having one path per contour to one path per level, and so its paths can now contain internal MOVETO's. We can make the interpolated method handle those by just splitting the path up, applying itself to each sub-path, and re-joining the result. The example code provided in #29917 now gives

Figure_1

For contour the given example also had one or more empty paths which caused an exception in the interpolation. We can just pass those straight out again.

Figure_1

I welcome better wording for the docstring, though I do not think the original was correct since a unclosed path with N vertices would have become a path with (N-1) * steps + 1 vertices.

PR checklist

@@ -667,13 +667,27 @@ def intersects_bbox(self, bbox, filled=True):

def interpolated(self, steps):
"""
Return a new path resampled to length N x *steps*.
Return a new path with each `LINETO` resampled to *steps* `LINETO`'s.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
Return a new path with each `LINETO` resampled to *steps* `LINETO`'s.
Return a new path with each segment is divided into *steps* parts.

Note that "segment" is the term for one "step" (c.f. https://matplotlib.org/devdocs/api/path_api.html#matplotlib.path.Path.iter_segments)

Edit: I've just read one maybe "each LINETO step ..."


Codes other than `LINETO` are not handled correctly.
Codes other than `LINETO` and `MOVETO` are not handled correctly.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not checked, but what about CLOSEPOLY? That should also be sub sampled.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It can go wrong. Adapting the example from https://matplotlib.org/stable/gallery/shapes_and_collections/compound_path.html

import matplotlib.pyplot as plt

from matplotlib.patches import PathPatch
from matplotlib.path import Path

codes = [Path.MOVETO] + [Path.LINETO]*2 + [Path.CLOSEPOLY]
vertices = [(4, 4), (5, 5), (5, 4), (0, 0)]

path = Path(vertices, codes)

pathpatch = PathPatch(path, facecolor='none', edgecolor='blue', linewidth=3,
                      label='original')
pathpatch2 = PathPatch(path.interpolated(2), facecolor='none', edgecolor='red',
                       linewidth=3, linestyle='dashed', label='interpolated')

fig, ax = plt.subplots()
ax.add_patch(pathpatch)
ax.add_patch(pathpatch2)

ax.autoscale_view()
ax.legend()

plt.show()

image

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The vertex corresponding to CLOSEPOLY is not correctly ignored here; it should be interpolated from the previous point to the first point in the path (i.e., the most recent MOVETO or the first point in the whole thing.)

@rcomer rcomer changed the title Handle MOVETO's in Path.interpolated Handle MOVETO's and CLOSEPOLY's in Path.interpolated Apr 16, 2025
Comment on lines 691 to 692
if self.codes[-1] == self.CLOSEPOLY and not np.all(self.vertices[-1] ==
self.vertices[0]):
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am assuming that after splitting by MOVETO, any CLOSEPOLY will be at the end of the path. Is that correct?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It looks like _iter_connected_components only splits on MOVETO, so it wouldn't notice CLOSEPOLY. I'm not sure what happens to a Path with CLOSEPOLY stuck in the middle.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A CLOSEPOLY the middle can only happen if it was not followed by a MOVETO in the original path. But that means you closed a shape, and continue to draw from that position. People would likely just use LINETOs in such a case, But AFAIK there's nothing in the rules prohibiting LINETO, CLOSEPOLY, LINETO, and there could be rare cases where it makes sense/could happen.

That said, I haven't checked what we actually do here and whether that pattern renders reasonably.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmmmm

import matplotlib.pyplot as plt

from matplotlib.patches import PathPatch
from matplotlib.path import Path

codes = [Path.MOVETO] + ([Path.LINETO]*2 + [Path.CLOSEPOLY]) * 2
vertices = [(4, 4), (5, 5), (5, 4), (0, 0), (3, 3), (4, 3), (0, 0)]

path = Path(vertices, codes)

pathpatch = PathPatch(path, facecolor='none', edgecolor='blue', linewidth=3,
                      label='original')
pathpatch2 = PathPatch(path.interpolated(2), facecolor='none', edgecolor='red',
                       linewidth=3, linestyle='dashed', label='interpolated')

fig, ax = plt.subplots()
ax.add_patch(pathpatch)
ax.add_patch(pathpatch2)

ax.autoscale_view()
ax.legend()

plt.show()

Figure_1

It seems that the rendering at least handles internal CLOSEPOLY's. So probably we should handle them here too.

Copy link
Member Author

@rcomer rcomer Apr 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With latest update

Figure_1

Edit: given the axes limits here go down beyond (0,0), I guess that whatever tells autoscale the extent of the path also does not know to ignore CLOSEPOLY.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Edit: given the axes limits here go down beyond (0,0), I guess that whatever tells autoscale the extent of the path also does not know to ignore CLOSEPOLY.

I'd say this is a separate topic. Without checking: I suspect that autoscale just takes all the points, no matter what the associated code is (likely also the control points for curve).

At least for CLOSEPOLY people can work around this by using existing coordinates.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd say this is a separate topic.

Sure, that was very much an aside - not proposing to investigate it here.

@rcomer rcomer added this to the v3.11.0 milestone Apr 16, 2025
Copy link
Contributor

@greglucas greglucas left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems like it could be considered a bug-fix and backported?



def test_interpolated_closepoly():
codes = [Path.MOVETO] + [Path.LINETO]*2 + [Path.CLOSEPOLY]
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test a compound path with multiple closepolys by doubling this again similar to your above test to make sure the implementation handles multiple MOVETOs and CLOSEPOLYS together?

@rcomer
Copy link
Member Author

rcomer commented Apr 17, 2025

This seems like it could be considered a bug-fix and backported?

I could go either way. I figured it's a New Feature since we are making a public method do something it didn't before (and the docstring stated it didn't). OTOH this clearly fixes a bug for the geo projections, and I'll be astonished if anyone is relying on these codes not being supported.

@rcomer
Copy link
Member Author

rcomer commented Apr 17, 2025

I just realized that this does not in fact close #29917. I had not checked unfilled contours, but they are even more broken than filled contours.

@rcomer rcomer marked this pull request as draft April 17, 2025 14:00
@rcomer
Copy link
Member Author

rcomer commented Apr 17, 2025

OK the problem with the unfilled contours is just that it has an empty path, so ended up at

  File "/[path-to-git-repo]/matplotlib/lib/matplotlib/cbook.py", line 914, in simple_linear_interpolation
    fps = a.reshape((len(a), -1))
          ^^^^^^^^^^^^^^^^^^^^^^^
ValueError: cannot reshape array of size 0 into shape (0,newaxis)

This is an easy fix, though I am slightly worried that different input data could turn up different bugs.

@rcomer rcomer changed the title Handle MOVETO's and CLOSEPOLY's in Path.interpolated Handle MOVETO's and CLOSEPOLY's and empty paths in Path.interpolated Apr 17, 2025
@rcomer rcomer marked this pull request as ready for review April 17, 2025 14:41
if self.codes is not None and self.CLOSEPOLY in self.codes and not np.all(
self.vertices[self.codes == self.CLOSEPOLY] == self.vertices[0]):
vertices = self.vertices.copy()
vertices[self.codes == self.CLOSEPOLY] = vertices[0]
Copy link
Member

@timhoffm timhoffm Apr 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this patch is not sufficient if you have a path with mixtures of CLOSEPOLY AND MOVETO. The MOVETO creates a new separate separate component with a starting point that is generally different from vertices[0].

Maybe a variation of _iter_connected_components is the way to go?

Copy link
Member Author

@rcomer rcomer Apr 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But if we got to here, we have no internal MOVETO's because we already split on them and called the function again on the subpath. I think this case is covered by the new test_interpolated_moveto_closepoly.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, you are right.

@timhoffm
Copy link
Member

I don't have a strong opinion on 3.10.2 vs 3.11. Either is ok, and their releases will likely not be far apart.

@rcomer rcomer modified the milestones: v3.11.0, v3.10.2 Apr 18, 2025
@rcomer
Copy link
Member Author

rcomer commented Apr 18, 2025

OK, I think there are two votes for v3.10.2 and none against, so I removed the whatsnew.

@rcomer rcomer changed the title Handle MOVETO's and CLOSEPOLY's and empty paths in Path.interpolated Handle MOVETO's, CLOSEPOLY's and empty paths in Path.interpolated Apr 18, 2025
@rcomer rcomer added the PR: bugfix Pull requests that fix identified bugs label Apr 18, 2025
@timhoffm timhoffm merged commit 5ff6a93 into matplotlib:main Apr 18, 2025
45 checks passed
meeseeksmachine pushed a commit to meeseeksmachine/matplotlib that referenced this pull request Apr 18, 2025
@rcomer rcomer deleted the geo-contour branch April 18, 2025 11:58
oscargus added a commit that referenced this pull request Apr 18, 2025
…919-on-v3.10.x

Backport PR #29919 on branch v3.10.x (Handle MOVETO's, CLOSEPOLY's and empty paths in Path.interpolated)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
PR: bugfix Pull requests that fix identified bugs topic: path handling
Projects
None yet
Development

Successfully merging this pull request may close these issues.

[Bug]: Contour plots using mollweide-projection
5 participants