Skip to content

Commit 483c01c

Browse files
committed
Fix PathNanRemover for closed loops
If we ever break a path due to a NaN, then it can never be closed, as Agg will try to close it back to the last MOVETO. This would not be the initial point of the loop, but the first point after the break. So we need to emulate a CLOSEPOLY by adding a LINETO the original initial point in the path.
1 parent a216f85 commit 483c01c

File tree

2 files changed

+151
-10
lines changed

2 files changed

+151
-10
lines changed

lib/matplotlib/tests/test_simplification.py

Lines changed: 111 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@
66

77
import pytest
88

9-
from matplotlib.testing.decorators import image_comparison
9+
from matplotlib.testing.decorators import (
10+
check_figures_equal, image_comparison, remove_ticks_and_titles)
1011
import matplotlib.pyplot as plt
1112

1213
from matplotlib import patches, transforms
@@ -230,7 +231,7 @@ def test_sine_plus_noise():
230231
assert simplified.vertices.size == 25240
231232

232233

233-
@image_comparison(['simplify_curve'], remove_text=True)
234+
@image_comparison(['simplify_curve'], remove_text=True, tol=0.017)
234235
def test_simplify_curve():
235236
pp1 = patches.PathPatch(
236237
Path([(0, 0), (1, 0), (1, 1), (np.nan, 1), (0, 0), (2, 0), (2, 2),
@@ -245,6 +246,114 @@ def test_simplify_curve():
245246
ax.set_ylim((0, 2))
246247

247248

249+
@check_figures_equal()
250+
def test_closed_path_nan_removal(fig_test, fig_ref):
251+
ax_test = fig_test.subplots(2, 2).flatten()
252+
ax_ref = fig_ref.subplots(2, 2).flatten()
253+
254+
# NaN on the first point also removes the last point, because it's closed.
255+
path = Path(
256+
[[-3, np.nan], [3, -3], [3, 3], [-3, 3], [-3, -3]],
257+
[Path.MOVETO, Path.LINETO, Path.LINETO, Path.LINETO, Path.CLOSEPOLY])
258+
ax_test[0].add_patch(patches.PathPatch(path, facecolor='none'))
259+
path = Path(
260+
[[-3, np.nan], [3, -3], [3, 3], [-3, 3], [-3, np.nan]],
261+
[Path.MOVETO, Path.LINETO, Path.LINETO, Path.LINETO, Path.LINETO])
262+
ax_ref[0].add_patch(patches.PathPatch(path, facecolor='none'))
263+
264+
# NaN on second-last point should not re-close.
265+
path = Path(
266+
[[-2, -2], [2, -2], [2, 2], [-2, np.nan], [-2, -2]],
267+
[Path.MOVETO, Path.LINETO, Path.LINETO, Path.LINETO, Path.CLOSEPOLY])
268+
ax_test[0].add_patch(patches.PathPatch(path, facecolor='none'))
269+
path = Path(
270+
[[-2, -2], [2, -2], [2, 2], [-2, np.nan], [-2, -2]],
271+
[Path.MOVETO, Path.LINETO, Path.LINETO, Path.LINETO, Path.LINETO])
272+
ax_ref[0].add_patch(patches.PathPatch(path, facecolor='none'))
273+
274+
# Test multiple loops in a single path (with same paths as above).
275+
path = Path(
276+
[[-3, np.nan], [3, -3], [3, 3], [-3, 3], [-3, -3],
277+
[-2, -2], [2, -2], [2, 2], [-2, np.nan], [-2, -2]],
278+
[Path.MOVETO, Path.LINETO, Path.LINETO, Path.LINETO, Path.CLOSEPOLY,
279+
Path.MOVETO, Path.LINETO, Path.LINETO, Path.LINETO, Path.CLOSEPOLY])
280+
ax_test[1].add_patch(patches.PathPatch(path, facecolor='none'))
281+
path = Path(
282+
[[-3, np.nan], [3, -3], [3, 3], [-3, 3], [-3, np.nan],
283+
[-2, -2], [2, -2], [2, 2], [-2, np.nan], [-2, -2]],
284+
[Path.MOVETO, Path.LINETO, Path.LINETO, Path.LINETO, Path.LINETO,
285+
Path.MOVETO, Path.LINETO, Path.LINETO, Path.LINETO, Path.LINETO])
286+
ax_ref[1].add_patch(patches.PathPatch(path, facecolor='none'))
287+
288+
# NaN in first point of CURVE3 should not re-close, and hide entire curve.
289+
path = Path(
290+
[[-1, -1], [1, -1], [1, np.nan], [0, 1], [-1, 1], [-1, -1]],
291+
[Path.MOVETO, Path.LINETO, Path.CURVE3, Path.CURVE3, Path.LINETO,
292+
Path.CLOSEPOLY])
293+
ax_test[2].add_patch(patches.PathPatch(path, facecolor='none'))
294+
path = Path(
295+
[[-1, -1], [1, -1], [1, np.nan], [0, 1], [-1, 1], [-1, -1]],
296+
[Path.MOVETO, Path.LINETO, Path.CURVE3, Path.CURVE3, Path.LINETO,
297+
Path.CLOSEPOLY])
298+
ax_ref[2].add_patch(patches.PathPatch(path, facecolor='none'))
299+
300+
# NaN in second point of CURVE3 should not re-close, and hide entire curve
301+
# plus next line segment.
302+
path = Path(
303+
[[-3, -3], [3, -3], [3, 0], [0, np.nan], [-3, 3], [-3, -3]],
304+
[Path.MOVETO, Path.LINETO, Path.CURVE3, Path.CURVE3, Path.LINETO,
305+
Path.LINETO])
306+
ax_test[2].add_patch(patches.PathPatch(path, facecolor='none'))
307+
path = Path(
308+
[[-3, -3], [3, -3], [3, 0], [0, np.nan], [-3, 3], [-3, -3]],
309+
[Path.MOVETO, Path.LINETO, Path.CURVE3, Path.CURVE3, Path.LINETO,
310+
Path.LINETO])
311+
ax_ref[2].add_patch(patches.PathPatch(path, facecolor='none'))
312+
313+
# NaN in first point of CURVE4 should not re-close, and hide entire curve.
314+
path = Path(
315+
[[-1, -1], [1, -1], [1, np.nan], [0, 0], [0, 1], [-1, 1], [-1, -1]],
316+
[Path.MOVETO, Path.LINETO, Path.CURVE4, Path.CURVE4, Path.CURVE4,
317+
Path.LINETO, Path.CLOSEPOLY])
318+
ax_test[3].add_patch(patches.PathPatch(path, facecolor='none'))
319+
path = Path(
320+
[[-1, -1], [1, -1], [1, np.nan], [0, 0], [0, 1], [-1, 1], [-1, -1]],
321+
[Path.MOVETO, Path.LINETO, Path.CURVE4, Path.CURVE4, Path.CURVE4,
322+
Path.LINETO, Path.CLOSEPOLY])
323+
ax_ref[3].add_patch(patches.PathPatch(path, facecolor='none'))
324+
325+
# NaN in second point of CURVE4 should not re-close, and hide entire curve.
326+
path = Path(
327+
[[-2, -2], [2, -2], [2, 0], [0, np.nan], [0, 2], [-2, 2], [-2, -2]],
328+
[Path.MOVETO, Path.LINETO, Path.CURVE4, Path.CURVE4, Path.CURVE4,
329+
Path.LINETO, Path.LINETO])
330+
ax_test[3].add_patch(patches.PathPatch(path, facecolor='none'))
331+
path = Path(
332+
[[-2, -2], [2, -2], [2, 0], [0, np.nan], [0, 2], [-2, 2], [-2, -2]],
333+
[Path.MOVETO, Path.LINETO, Path.CURVE4, Path.CURVE4, Path.CURVE4,
334+
Path.LINETO, Path.LINETO])
335+
ax_ref[3].add_patch(patches.PathPatch(path, facecolor='none'))
336+
337+
# NaN in third point of CURVE4 should not re-close, and hide entire curve
338+
# plus next line segment.
339+
path = Path(
340+
[[-3, -3], [3, -3], [3, 0], [0, 0], [0, np.nan], [-3, 3], [-3, -3]],
341+
[Path.MOVETO, Path.LINETO, Path.CURVE4, Path.CURVE4, Path.CURVE4,
342+
Path.LINETO, Path.LINETO])
343+
ax_test[3].add_patch(patches.PathPatch(path, facecolor='none'))
344+
path = Path(
345+
[[-3, -3], [3, -3], [3, 0], [0, 0], [0, np.nan], [-3, 3], [-3, -3]],
346+
[Path.MOVETO, Path.LINETO, Path.CURVE4, Path.CURVE4, Path.CURVE4,
347+
Path.LINETO, Path.LINETO])
348+
ax_ref[3].add_patch(patches.PathPatch(path, facecolor='none'))
349+
350+
# Keep everything clean.
351+
for ax in [*ax_test.flat, *ax_ref.flat]:
352+
ax.set(xlim=(-3.5, 3.5), ylim=(-3.5, 3.5))
353+
remove_ticks_and_titles(fig_test)
354+
remove_ticks_and_titles(fig_ref)
355+
356+
248357
@image_comparison(['hatch_simplify'], remove_text=True)
249358
def test_hatch():
250359
fig, ax = plt.subplots()

src/path_converters.h

Lines changed: 40 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,9 @@ class PathNanRemover : protected EmbeddedQueue<4>
165165
bool m_has_codes;
166166
bool valid_segment_exists;
167167
bool m_last_segment_valid;
168+
bool m_was_broken;
169+
double m_initX;
170+
double m_initY;
168171

169172
public:
170173
/* has_codes should be true if the path contains bezier curve segments, or
@@ -173,7 +176,8 @@ class PathNanRemover : protected EmbeddedQueue<4>
173176
*/
174177
PathNanRemover(VertexSource &source, bool remove_nans, bool has_codes)
175178
: m_source(&source), m_remove_nans(remove_nans), m_has_codes(has_codes),
176-
m_last_segment_valid(false)
179+
m_last_segment_valid(false), m_was_broken(false),
180+
m_initX(nan("")), m_initY(nan(""))
177181
{
178182
// ignore all close/end_poly commands until after the first valid
179183
// (nan-free) command is encountered
@@ -208,14 +212,41 @@ class PathNanRemover : protected EmbeddedQueue<4>
208212
are found along the way, the queue is emptied, and
209213
the next curve segment is handled. */
210214
code = m_source->vertex(x, y);
211-
/* The vertices attached to STOP and CLOSEPOLY left are never
212-
used, so we leave them as-is even if NaN. However, CLOSEPOLY
213-
only makes sense if a valid MOVETO command has already been
214-
emitted. */
215-
if (code == agg::path_cmd_stop ||
216-
(code == (agg::path_cmd_end_poly | agg::path_flags_close) &&
217-
valid_segment_exists)) {
215+
/* The vertices attached to STOP and CLOSEPOLY are never used,
216+
* so we leave them as-is even if NaN. */
217+
if (code == agg::path_cmd_stop) {
218218
return code;
219+
} else if (code == (agg::path_cmd_end_poly |
220+
agg::path_flags_close) &&
221+
valid_segment_exists) {
222+
/* However, CLOSEPOLY only makes sense if a valid MOVETO
223+
* command has already been emitted. But if a NaN was
224+
* removed in the path, then we cannot close it as it is no
225+
* longer a loop. We must emulate that by inserting a
226+
* LINETO instead. */
227+
if (m_was_broken) {
228+
if (m_last_segment_valid && (
229+
std::isfinite(m_initX) &&
230+
std::isfinite(m_initY))) {
231+
/* Join to start if both ends are valid. */
232+
queue_push(agg::path_cmd_line_to, m_initX, m_initY);
233+
break;
234+
} else {
235+
/* Skip the close, in case there are additional
236+
* subpaths. */
237+
continue;
238+
}
239+
m_was_broken = false;
240+
break;
241+
} else {
242+
return code;
243+
}
244+
} else if (code == agg::path_cmd_move_to) {
245+
/* Save the initial point in order to produce the last
246+
* segment closing a loop, *if* we broke the loop. */
247+
m_initX = *x;
248+
m_initY = *y;
249+
m_was_broken = false;
219250
}
220251

221252
if (needs_move_to) {
@@ -240,6 +271,7 @@ class PathNanRemover : protected EmbeddedQueue<4>
240271
break;
241272
}
242273

274+
m_was_broken = true;
243275
queue_clear();
244276

245277
/* If the last point is finite, we use that for the

0 commit comments

Comments
 (0)