diff --git a/CHANGELOG.md b/CHANGELOG.md index ef1ff3e0..0de20213 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,31 @@ in the README). structural invariants remain hard gates, while the paired CodSpeed rows continue to track shim overhead. +### Fixed +- `Axes.add_patch` rendered every patch as a hollow outline and dropped both + rotation and curvature. Patches now fill in their own face color, and their + geometry comes from `Path.to_polygons` with the patch transform applied, so + `Rectangle(angle=...)` keeps its rotation and `Circle`/`Ellipse`/`Wedge` use + the curve rather than its cubic Bézier control points. The curve is flattened at + the figure's pixel size rather than in data units, so a `Circle(radius=1)` + is as round as the same circle drawn as `radius=1000`. Unfilled patches stay + edge-only, the axes color cycle is untouched, and a degenerate patch draws + its edge instead of raising. A patch whose path has nested rings draws its + outlines and skips the fill, since hole triangulation is not implemented and + filling every ring would paint the hole solid. A ring that has a body but no + triangulation, self-intersecting or past the triangulator's vertex cap, + draws its outline and warns rather than going quietly hollow. +- Patch outlines were stroked at a fixed one pixel that ignored both the + patch's line width and the figure DPI. They now use the patch's own + `linewidth`, converted from Matplotlib points into output pixels like every + other stroke in the shim, and a patch whose edge paints nothing — the + Matplotlib default on a filled patch — no longer emits an invisible outline + mark per ring. +- The handle `add_patch` returns now owns every mark the patch produced, so + `remove`, `set_zorder`, `set_visible`, `set_alpha`, `set_color` and + `set_transform` move the whole patch. Previously they reached only the fill, + and a hidden patch still drew its outline. + ## [0.0.4] - 2026-07-27 ### Added diff --git a/python/xy/pyplot/_artists.py b/python/xy/pyplot/_artists.py index ebd8f5f6..991e16a5 100644 --- a/python/xy/pyplot/_artists.py +++ b/python/xy/pyplot/_artists.py @@ -93,6 +93,20 @@ def __init__(self, axes: Any, entry: dict[str, Any]) -> None: def _touch(self) -> None: self._axes._invalidate() + def _companion_entries(self) -> list[dict[str, Any]]: + """Extra spec entries this one handle stands for, beside ``_entry``. + + Matplotlib artists that xy has to emit as several marks (a patch's + fill and its outline) hang the rest here, so the mutations that mean + "the whole artist" — visibility, alpha, color, transform, zorder, + removal — move all of them rather than only the first. + """ + return [] + + def _owned_entries(self) -> list[dict[str, Any]]: + companions = [e for e in self._companion_entries() if e is not self._entry] + return [self._entry, *companions] + def remove(self) -> None: self._axes._remove_entry(self._entry) self._axes._unregister_artist(self) @@ -107,7 +121,8 @@ def get_label(self) -> Optional[str]: def set_alpha(self, alpha: float) -> None: self._visible_opacity = float(alpha) if self._visible: - self._entry["kwargs"]["opacity"] = float(alpha) + for entry in self._owned_entries(): + entry["kwargs"]["opacity"] = float(alpha) self._touch() def get_alpha(self) -> Any: @@ -122,7 +137,8 @@ def set_visible(self, visible: bool) -> None: if not visible: self._visible_opacity = float(self._entry["kwargs"].get("opacity", 1.0)) self._visible = visible - self._entry["kwargs"]["opacity"] = self._visible_opacity if visible else 0.0 + for entry in self._owned_entries(): + entry["kwargs"]["opacity"] = self._visible_opacity if visible else 0.0 self._touch() def get_visible(self) -> bool: @@ -178,10 +194,15 @@ def convert(x: Any, y: Any) -> tuple[np.ndarray, np.ndarray]: made = np.asarray(transform.transform(old_inverse.transform(points)), dtype=float) return made[:, 0].reshape(xa.shape), made[:, 1].reshape(ya.shape) - if "x" in self._entry and "y" in self._entry: - self._entry["x"], self._entry["y"] = convert(self._entry["x"], self._entry["y"]) - elif self._entry.get("kind") == "@mark": - factory = self._entry.get("factory") + def move(entry: dict[str, Any]) -> None: + if "x" in entry and "y" in entry: + entry["x"], entry["y"] = convert(entry["x"], entry["y"]) + return + if entry.get("kind") != "@mark": + raise NotImplementedError( + f"{type(self).__name__} transform is not supported for this geometry" + ) + factory = entry.get("factory") pairs = { "segments": ((0, 1), (2, 3)), "triangle_mesh": ((0, 1), (2, 3), (4, 5)), @@ -193,14 +214,15 @@ def convert(x: Any, y: Any) -> tuple[np.ndarray, np.ndarray]: raise NotImplementedError( f"{type(self).__name__} transform is not supported for {factory!r} geometry" ) - args = list(self._entry["args"]) + args = list(entry["args"]) for x_index, y_index in pairs: args[x_index], args[y_index] = convert(args[x_index], args[y_index]) - self._entry["args"] = tuple(args) - else: - raise NotImplementedError( - f"{type(self).__name__} transform is not supported for this geometry" - ) + entry["args"] = tuple(args) + + move(self._entry) + for companion in self._companion_entries(): + if companion is not self._entry: + move(companion) for marker_entry in self._marker_entries(): if marker_entry is not self._entry: marker_entry["x"], marker_entry["y"] = convert(marker_entry["x"], marker_entry["y"]) @@ -222,7 +244,10 @@ def get_rasterized(self) -> bool: return self._rasterized def set_color(self, color: Any) -> None: - self._entry["kwargs"]["color"] = resolve_color(color) + # Matplotlib's Patch.set_color paints face and edge alike, so a handle + # standing for both moves both. + for entry in self._owned_entries(): + entry["kwargs"]["color"] = resolve_color(color) self._touch() def get_color(self) -> Any: @@ -940,6 +965,39 @@ def get_data(self) -> tuple[Any, Any, Any]: ) +class Patch(Artist): + """Handle for ``add_patch`` output, owning the outline marks beside the fill. + + A patch can reach the spec as several marks — one fill per ring and one + outline per ring — so every mutation that means "the whole patch" runs + over `_companion_entries` rather than over `_entry` alone. Without that, + `set_visible(False)` would hide the body and leave the outline drawn. + """ + + def __init__( + self, + axes: Any, + entry: dict[str, Any], + outline_entries: list[dict[str, Any]] | None = None, + ) -> None: + super().__init__(axes, entry) + self._outline_entries = list(outline_entries or []) + + def _companion_entries(self) -> list[dict[str, Any]]: + return self._outline_entries + + def remove(self) -> None: + for entry in self._outline_entries: + self._axes._remove_entry(entry) + self._outline_entries.clear() + super().remove() + + def set_zorder(self, level: float) -> None: + for entry in self._outline_entries: + entry["_zorder"] = float(level) + super().set_zorder(level) + + class StemContainer: """Small tuple-compatible analogue of matplotlib's StemContainer.""" diff --git a/python/xy/pyplot/_axes.py b/python/xy/pyplot/_axes.py index f1e930a5..189e5aa9 100644 --- a/python/xy/pyplot/_axes.py +++ b/python/xy/pyplot/_axes.py @@ -33,6 +33,7 @@ BarContainer, Legend, Line2D, + Patch, PathCollection, PolyCollection, Text, @@ -55,7 +56,7 @@ from ._markers import marker_render_spec from ._mathtext import mathtext_italic_ranges, mathtext_to_unicode from ._plot_types import PlotTypeMixin -from ._rc import RcParams, rcParams +from ._rc import RcParams, rc_figsize_px, rcParams from ._ticker import ( AsinhLocator, AutoLocator, @@ -1101,6 +1102,209 @@ def _cached_axis(which: str, props: dict) -> Any: return made +def _without_repeats(ring: np.ndarray) -> np.ndarray: + """A ring with consecutive duplicate vertices dropped, closing vertex kept. + + Matplotlib repeats a vertex inside its full-circle paths, and the + triangulator rejects any polygon carrying a duplicate. That repeat is not + bit-exact, so the comparison needs a tolerance, but it has to scale with + the ring rather than with coordinate magnitude. `np.isclose` scales with + magnitude and so treats vertices 10,000 units apart as one at x = 1e9, + erasing a patch drawn on genomic or epoch-millisecond axes. + + Measured against Matplotlib 3.11.1 as a fraction of the ring's + bounding-box diagonal, repeats sit at 9e-17 while the smallest real edge + across the shapes we flatten sits at 2e-3. A threshold of 1e-12 of the + span is ten thousand times above the noise and a billion times below the + smallest real edge. + """ + if len(ring) < 2: + return ring + span = float(np.hypot(*(ring.max(axis=0) - ring.min(axis=0)))) + if not np.isfinite(span): + # One non-finite coordinate makes every tolerance derived from the span + # meaningless: against an infinite one, each gap reads as a duplicate + # and the ring empties. Keep it whole and let the triangulator reject + # it on its own terms. + return ring + return ring[np.r_[True, np.hypot(*(ring[1:] - ring[:-1]).T) > span * 1e-12]] + + +def _refine_at_pixel_scale(path: Any, transform: Any, rings: list[Any], pixels: float) -> Any: + """Re-flatten `path` as though the patch spanned `pixels` output pixels. + + ``to_polygons`` subdivides a cubic Bézier until it is flat in the + coordinates it is handed, so flattening through the patch transform takes + its tessellation from the *numeric magnitude* of the data. `Circle(radius=1)` + comes back as sixteen segments whose alternate vertices overshoot the true + radius by 2.5%, while the identical circle drawn as `radius=1000` comes + back smooth. Matplotlib never shows this, because its renderers flatten in + display space, after the full data-to-pixel transform. + + xy builds geometry when the patch is added, before the view is known, so + there is no true pixel transform to use here. Flattening as though the + patch filled the figure is the finest resolution it could ever need, and + undoing the scale afterwards leaves data-space rings whose accuracy no + longer depends on the units the caller happened to plot in. + + The patch transform is applied to the control points rather than composed + onto a scale transform, because the shim never imports matplotlib. That is + exact: an affine maps a cubic Bézier's control points to the control + points of the mapped curve, and patch transforms are affine. + + Scaling is done about the patch's own corner rather than the origin. A + round trip through `* scale` and `/ scale` costs relative precision, and + a small patch at a large offset has little to spare: a 1e-4 rectangle at + x = 1e9 loses eight times more area to the round trip when the offset + rides along than when only the patch's own extent is scaled. + """ + finite = [np.asarray(ring, dtype=np.float64) for ring in rings] + finite = [ring for ring in finite if ring.ndim == 2 and len(ring) and np.isfinite(ring).all()] + if not finite: + return rings + stacked = np.concatenate(finite) + corner = stacked.min(axis=0) + span = float(np.hypot(*(stacked.max(axis=0) - corner))) + scale = pixels / span if span > 0.0 else 0.0 + if not np.isfinite(scale) or scale <= 0.0: + return rings + placed = np.asarray(transform.transform(path.vertices), dtype=np.float64) + enlarged = type(path)((placed - corner) * scale, path.codes) + return [np.asarray(ring, dtype=np.float64) / scale + corner for ring in enlarged.to_polygons()] + + +def _patch_placement(patch: Any) -> tuple[Any, Any]: + """(transform to flatten through, xy transform to apply after) for a patch. + + Matplotlib's `Patch.get_transform` composes `get_patch_transform` with the + artist-level transform, so `Rectangle(..., transform=Affine2D().rotate_deg(45))` + carries its rotation there and flattening through `get_patch_transform` + alone would silently drop it. When an artist transform has been set and + matplotlib can compose it, the composite is the transform to flatten + through. xy's own transform objects will not compose inside matplotlib + (`TypeError`); of those, `ax.transData` is the identity so the patch + transform alone is already right, a data-space affine comes back as the + second element to apply to the flattened rings — exact, since an affine + maps polygons to polygons — and axes/figure fractions are rejected the + way `_transform_points` rejects them for every other data artist: baked + fractions go silently stale on the next limit change. + """ + patch_transform = patch.get_patch_transform() + if not getattr(patch, "is_transform_set", lambda: False)(): + return patch_transform, None + try: + return patch.get_transform(), None + except TypeError: + artist_transform = getattr(patch, "_transform", None) + if getattr(artist_transform, "coordinate_space", "data") in { + "axes_fraction", + "figure_fraction", + }: + raise not_implemented( + "data artists with transform=transAxes/transFigure", + "affine data transforms composed with ax.transData", + ) + return patch_transform, artist_transform + + +def _patch_outline(patch: Any, pixels: float = 1024.0) -> list[np.ndarray]: + """Data-space rings of a patch, curves flattened and its transform applied. + + ``Path.to_polygons`` applies the patch transform and resolves curves into + straight segments, so a rotated Rectangle, a Circle, and a Wedge all come + back as real geometry rather than as cubic Bézier control points. An + artist-level `transform=` rides along per `_patch_placement`. `pixels` is + the output size the flattening is resolved for — see + `_refine_at_pixel_scale`, which is why it is not resolved in data units. + Ducks without a path fall back to raw vertices, which drop curvature and + rotation. + """ + get_path = getattr(patch, "get_path", None) + get_transform = getattr(patch, "get_patch_transform", None) + rings: list[Any] = [] + after = None + if get_path is not None and get_transform is not None: + path = get_path() + to_polygons = getattr(path, "to_polygons", None) + if to_polygons is not None: + transform, after = _patch_placement(patch) + rings = list(to_polygons(transform)) + if rings: + # A duck path that cannot be rebuilt from vertices and codes + # keeps its data-space flattening rather than losing geometry. + with suppress(AttributeError, TypeError, ValueError): + rings = list(_refine_at_pixel_scale(path, transform, rings, pixels)) + if after is not None: + rings = [np.asarray(after.transform(ring), dtype=np.float64) for ring in rings] + if not rings: + if all(hasattr(patch, name) for name in ("get_x", "get_y", "get_width", "get_height")): + x0, y0 = float(patch.get_x()), float(patch.get_y()) + x1, y1 = x0 + float(patch.get_width()), y0 + float(patch.get_height()) + rings = [[[x0, y0], [x1, y0], [x1, y1], [x0, y1], [x0, y0]]] + elif get_path is not None: + rings = [get_path().vertices] + else: + raise TypeError(f"unsupported patch {type(patch).__name__}") + return [_without_repeats(np.asarray(ring, dtype=np.float64)) for ring in rings] + + +def _rings_are_nested(outline: list[np.ndarray]) -> bool: + """True when one ring sits wholly inside another, so the path has holes. + + `kernels.polygon_triangles` takes one simple polygon, so a hole would have + to be painted solid. Callers abstain from filling instead. + + Containment is every vertex inside, not the first one: rings that merely + overlap put some vertices in and some out, and a first-vertex test would + call them nested and hollow the whole patch. Overlapping rings fill + ring-by-ring instead — their union, where the even-odd rule would leave + the intersection unpainted, which errs on the side of painting what each + ring alone would have painted. + """ + from xy import kernels + + if len(outline) < 2: + return False + for index, ring in enumerate(outline): + if not len(ring): + continue + rows = np.arange(len(ring), dtype=np.uint32) + for other in outline[:index] + outline[index + 1 :]: + if len(other) < 3: + continue + inside = kernels.polygon_select(ring[:, 0], ring[:, 1], rows, other[:, 0], other[:, 1]) + if len(inside) == len(ring): + return True + return False + + +def _opaque_or_none(color: Any) -> Any: + """`color` unless it paints nothing — a "none" name or a zero alpha.""" + if color is None or str(color).lower() == "none": + return None + if isinstance(color, (tuple, list, np.ndarray)) and len(color) == 4 and not float(color[3]): + return None + return color + + +def _patch_fill_color(patch: Any) -> Any: + """The patch's own face color, or None when it asks not to be filled.""" + if not getattr(patch, "get_fill", lambda: True)(): + return None + return _opaque_or_none(getattr(patch, "get_facecolor", lambda: None)()) + + +def _patch_edge_color(patch: Any) -> Any: + """The patch's own edge color, or None when its outline paints nothing. + + Matplotlib leaves `edgecolor` as `"none"` on a filled patch unless the + caller asks for one, so the common `Rectangle(facecolor=...)` has no + visible outline at all. Emitting one anyway costs a mark per ring that + can never be seen. + """ + return _opaque_or_none(getattr(patch, "get_edgecolor", lambda: "#000000")()) + + class Axes(PlotTypeMixin): def __init__(self, figure: Any, *, y2_of: Optional["Axes"] = None) -> None: self.figure = figure @@ -5557,11 +5761,17 @@ def add_collection(self, collection: Any) -> Artist: return Artist(self, entry) def add_patch(self, patch: Any) -> Artist: - """Add a patch, approximated as its outline or a stairs fill. - - StepPatch-likes (with ``get_data()``) route to `stairs`; Rectangle- - and Path-based patches draw their edge as line segments. Unsupported - patch types raise. + """Add a patch as a filled body plus its outline, or as a stairs fill. + + StepPatch-likes (with ``get_data()``) route to `stairs`. Every other + patch is flattened to data-space rings via ``Path.to_polygons``, so + rotation and curvature survive; each ring fills with the patch's own + face color, and rings draw their edge as line segments when the patch + has a visible one. A path whose rings nest, meaning holes, draws its + outline and skips the fill rather than painting the hole solid. The + returned handle owns every mark the patch produced, so removing or + hiding it moves the outline with the fill. Unsupported patch types + raise. """ if hasattr(patch, "get_data"): data = patch.get_data() @@ -5582,24 +5792,73 @@ def add_patch(self, patch: Any) -> Artist: label=getattr(patch, "get_label", lambda: None)(), **({"color": color} if color is not None else {}), ) - if all(hasattr(patch, name) for name in ("get_x", "get_y", "get_width", "get_height")): - x0, y0 = float(patch.get_x()), float(patch.get_y()) - x1, y1 = x0 + float(patch.get_width()), y0 + float(patch.get_height()) - vertices = np.asarray([[x0, y0], [x1, y0], [x1, y1], [x0, y1], [x0, y0]]) - elif hasattr(patch, "get_path"): - vertices = np.asarray(patch.get_path().vertices, dtype=np.float64) - else: - raise TypeError(f"unsupported patch {type(patch).__name__}") - edge = getattr(patch, "get_edgecolor", lambda: "#000000")() - entry = self._add( - "@mark", - { - "factory": "segments", - "args": (vertices[:-1, 0], vertices[:-1, 1], vertices[1:, 0], vertices[1:, 1]), - "kwargs": {"color": resolve_color(edge), "width": 1.0}, - }, - ) - return Artist(self, entry) + from xy import kernels + + canvas = rc_figsize_px(self.figure._figsize, self.figure._dpi) + outline = _patch_outline(patch, pixels=float(max(canvas))) + face = _patch_fill_color(patch) + edge = _patch_edge_color(patch) + width = float(getattr(patch, "get_linewidth", lambda: 1.0)()) * self._point_scale() + entries: list[dict[str, Any]] = [] + if face is not None and not _rings_are_nested(outline): + for ring in outline: + xv, yv = ring[:, 0], ring[:, 1] + # Non-finite vertices have no triangulation, as in `Axes.fill`. + finite = np.isfinite(xv) & np.isfinite(yv) + xv, yv = xv[finite], yv[finite] + if len(xv) > 2: + # Closing-vertex test at the ring's own scale, like + # `_without_repeats`: `np.allclose` scales with coordinate + # magnitude and would swallow a real 5,000-unit closing + # edge on a duck-path ring drawn at x = 1e9. + span = float(np.hypot(np.ptp(xv), np.ptp(yv))) + if np.hypot(xv[0] - xv[-1], yv[0] - yv[-1]) <= span * 1e-12: + xv, yv = xv[:-1], yv[:-1] + if len(xv) < 3: + # Degenerate, such as a zero-height Rectangle. It has no + # body to fill; the outline pass below still draws it. + continue + try: + topology = kernels.polygon_triangles(xv, yv) + except ValueError as error: + # Self-intersecting, or past the triangulator's vertex cap. + # Matplotlib fills both, so say so rather than shipping a + # hollow patch that looks deliberate. + warnings.warn( + f"add_patch could not fill a {len(xv)}-vertex ring ({error}); " + "drawing its outline only", + RuntimeWarning, + stacklevel=2, + ) + continue + x0, y0, x1, y1, x2, y2, _ = kernels.indexed_triangles(xv, yv, topology) + entries.append( + self._add( + "@mark", + { + "factory": "triangle_mesh", + "args": (x0, y0, x1, y1, x2, y2), + "kwargs": {"color": resolve_color(face), "_joined_fill": True}, + }, + ) + ) + # An invisible outline is worth emitting only when nothing else was: + # the handle needs one entry to stand on, and a patch that drew no + # body should still occupy its place in the spec. + if (edge is not None and width > 0.0) or not entries: + stroke = resolve_color(edge if edge is not None else "none") + for ring in outline: + entries.append( + self._add( + "@mark", + { + "factory": "segments", + "args": (ring[:-1, 0], ring[:-1, 1], ring[1:, 0], ring[1:, 1]), + "kwargs": {"color": stroke, "width": width}, + }, + ) + ) + return Patch(self, entries[0], entries[1:]) def add_image(self, image: Any) -> AxesImage: """Add an AxesImage-like artist by resampling it through `imshow`. diff --git a/spec/matplotlib/compat-changelog.md b/spec/matplotlib/compat-changelog.md index 94cb1230..58a8a520 100644 --- a/spec/matplotlib/compat-changelog.md +++ b/spec/matplotlib/compat-changelog.md @@ -4,6 +4,65 @@ This changelog records changes to the upstream compatibility target and to the meaning of xy's compatibility levels. It complements the project changelog, which covers user-visible releases across the whole package. +## Patch bodies and geometry — 2026-07-30 (Matplotlib 3.11.1 reference) + +- `xy.pyplot.Axes.add_patch` now fills a patch instead of drawing only its + outline. Each ring gets a triangle mesh in the patch's own face color, with + triangle joins marked as a single fill so browser, PNG, and SVG output + suppress internal seams. Patches that report `fill=False`, a `"none"` face + color, or a fully transparent one stay edge-only, and the patch never + advances the axes color cycle. +- Patch geometry now comes from `Path.to_polygons`, with the patch transform + applied. `Rectangle(angle=...)` keeps its rotation, and curved patches use + the curve rather than its cubic Bézier control points: at the default figure size + `Circle(radius=1)` covers 3.141 rather than 3.251, and + `Ellipse(width=2, height=1, angle=20)` covers 1.570 rather than 3.251. +- The flattening is resolved at the figure's pixel size rather than in data + units. `to_polygons` subdivides until the curve is flat in whatever + coordinates it is handed, so flattening straight through the patch transform + would take its tessellation from the numeric magnitude of the data: + `Circle(radius=1)` came back as sixteen segments overshooting the true + radius by 2.5%, while the same circle drawn as `radius=1000` came back + smooth. Matplotlib does not have this problem, because its renderers flatten + in display space, after the full data-to-pixel transform. xy builds geometry + when the patch is added, before the view is known, so it flattens as though + the patch filled the figure — the finest resolution it could need — which + holds the radial error near 1e-4 at every coordinate scale. +- Outlines take the patch's own line width, converted from Matplotlib points + into output pixels like every other stroke in the shim, instead of a fixed + one pixel that did not move with figure DPI. A patch whose edge paints + nothing — `edgecolor="none"`, which is Matplotlib's default on a filled + patch, a fully transparent one, or `linewidth=0` — emits no outline mark at + all, unless it drew no body either. Degenerate rings with no triangulation, + such as a zero-height `Rectangle`, draw their edge and skip the fill rather + than raising. +- A ring that has a body but no triangulation — self-intersecting, or past + `polygon_triangles`' 10,000-vertex cap — draws its outline and raises a + `RuntimeWarning` naming the reason. Matplotlib fills both, so abstaining + silently would leave a hollow patch that looks like a deliberate style. +- An artist-level `transform=` on the patch rides along when it is a + data-space affine — `Rectangle(..., transform=Affine2D().rotate_deg(45))` + keeps its rotation, and `transform=ax.transData` is accepted as the no-op + it is. `transform=ax.transAxes`/`transFigure` raise `NotImplementedError` + like every other data artist, since baked fractions go silently stale on + the next limit change. +- Holes are not implemented. A patch whose path has nested rings, such as a + compound `PathPatch` of a square inside a square or a full-circle `Wedge` + with a `width`, draws its outlines and skips the fill rather than painting + the hole solid. `polygon_triangles` takes one simple polygon, so filling + every ring would paint 116 for a square-with-hole whose true area is 84. + Nesting means every vertex of one ring inside another: rings that merely + overlap fill ring-by-ring (their union, where Matplotlib's even-odd rule + leaves the intersection unpainted), rings that sit beside each other still + fill, and an annular *sector* fills correctly because Matplotlib returns it + as one ring that traces out along the outer arc and back along the inner + one. +- `add_patch` returns a `Patch` handle rather than a bare `Artist`. It owns the + outline marks alongside the fill, so `remove()`, `set_zorder()`, + `set_visible()`, `set_alpha()`, `set_color()` and `set_transform()` move the + whole patch instead of only its body. `set_color` paints edge and face + alike, as `matplotlib.patches.Patch.set_color` does. + ## Box and violin default geometry — 2026-07-26 (Matplotlib 3.11.1 reference) - `xy.pyplot.boxplot` no longer routes its default call through the native diff --git a/spec/matplotlib/compat.md b/spec/matplotlib/compat.md index 77b3170b..027e2d3c 100644 --- a/spec/matplotlib/compat.md +++ b/spec/matplotlib/compat.md @@ -113,8 +113,23 @@ Matplotlib's exact patch paths, `bbox=` boxes sized from an estimated text width with a fixed corner radius per box style (5 px for `round`, 8 px for `round4`) rather than Matplotlib's `pad × fontsize` box path — measured against Matplotlib 3.11.1 at 10 pt, `round` is 4.17 px there against 5 px -here — and errorbar limit flags rendered as one-sided bars without -Matplotlib's caret arrows. +here — errorbar limit flags rendered as one-sided bars without +Matplotlib's caret arrows, and `add_patch` geometry flattened through +`Path.to_polygons`, which resolves a curved patch into straight segments +rather than an exact analytic curve. Matplotlib's renderers flatten in display +space at draw time; xy builds patch geometry when the patch is added, before +the view is known, so it flattens as though the patch filled the figure — the +finest resolution the patch could need, which holds the error near 1e-4 of the +patch's own size whatever units it is drawn in. That resolution is fixed at +the figure size in effect when the patch is added: enlarging the figure or +its DPI afterwards, or zooming deep into a curve, reuses the tessellation +rather than re-flattening. Rings of one compound path that overlap without +nesting fill ring-by-ring — their union, where Matplotlib's even-odd rule +leaves the intersection unpainted. Two cases `add_patch` declines +rather than approximates: a patch whose path has nested rings draws its +outlines and skips the fill, since hole support is not implemented and filling +every ring would paint the hole solid; and a ring that is self-intersecting or +past the triangulator's 10,000-vertex cap draws its outline and warns. ## Sharp edges diff --git a/tests/pyplot/test_launch_compat.py b/tests/pyplot/test_launch_compat.py index 996bad4d..538243be 100644 --- a/tests/pyplot/test_launch_compat.py +++ b/tests/pyplot/test_launch_compat.py @@ -1,5 +1,6 @@ from __future__ import annotations +import warnings from io import BytesIO import numpy as np @@ -113,6 +114,485 @@ def test_adding_external_step_patch_does_not_advance_color_cycle() -> None: assert filled[2]["kwargs"]["color"] == "#1f77b4" +def _mesh_area(entry: dict) -> float: + x0, y0, x1, y1, x2, y2 = (np.asarray(values, dtype=np.float64) for values in entry["args"]) + return float(np.sum(np.abs((x1 - x0) * (y2 - y0) - (x2 - x0) * (y1 - y0))) / 2.0) + + +def _shortest_relative_edge(entry: dict) -> float: + """The outline's shortest segment as a fraction of its bounding diagonal. + + Scale-free, so it pins "no duplicate vertices survived" without pinning + how many vertices Matplotlib's tessellation happens to emit. A duplicate + left in place shows up here around 1e-16, real geometry above 1e-3. + """ + x0, y0, x1, y1 = (np.asarray(values, dtype=np.float64) for values in entry["args"]) + xs, ys = np.concatenate((x0, x1)), np.concatenate((y0, y1)) + span = float(np.hypot(np.ptp(xs), np.ptp(ys))) + return float(np.min(np.hypot(x1 - x0, y1 - y0)) / span) + + +def _patch_marks(ax: plt.Axes) -> tuple[list[dict], list[dict]]: + meshes = [entry for entry in ax._entries if entry.get("factory") == "triangle_mesh"] + edges = [entry for entry in ax._entries if entry.get("factory") == "segments"] + return meshes, edges + + +def test_added_rectangle_patch_fills_with_its_own_face_color() -> None: + pytest.importorskip("matplotlib") + from matplotlib.patches import Rectangle + + _fig, ax = plt.subplots() + ax.add_patch(Rectangle((0, 0), 2, 1, facecolor="tab:blue")) + meshes, edges = _patch_marks(ax) + # Matplotlib leaves edgecolor "none" on a filled patch, so there is no + # outline to draw and none is emitted. + assert len(meshes) == 1 and edges == [] + assert meshes[0]["kwargs"]["color"] == "rgba(31,119,180,1)" + assert meshes[0]["kwargs"]["_joined_fill"] is True + assert _mesh_area(meshes[0]) == pytest.approx(2.0) + + +def test_added_rotated_rectangle_keeps_its_rotation() -> None: + pytest.importorskip("matplotlib") + from matplotlib.patches import Rectangle + + _fig, ax = plt.subplots() + ax.add_patch(Rectangle((1, 2), 3, 4, angle=30, facecolor="tab:blue")) + meshes, _edges = _patch_marks(ax) + xs = np.concatenate([np.asarray(meshes[0]["args"][index]) for index in (0, 2, 4)]) + # Ignoring `angle` leaves the axis-aligned span 1.000..4.000 instead. + assert xs.min() == pytest.approx(-1.000, abs=1e-3) + assert xs.max() == pytest.approx(3.598, abs=1e-3) + assert _mesh_area(meshes[0]) == pytest.approx(12.0) + + +def test_added_ellipse_flattens_its_curve_under_the_patch_transform() -> None: + pytest.importorskip("matplotlib") + from matplotlib.patches import Ellipse + + _fig, ax = plt.subplots() + ax.add_patch(Ellipse((0, 0), width=2, height=1, angle=20, facecolor="green")) + meshes, _edges = _patch_marks(ax) + # pi*a*b. Raw cubic Bézier control points without the transform give 3.2509. + assert _mesh_area(meshes[0]) == pytest.approx(np.pi * 1.0 * 0.5, rel=1e-2) + + +def test_added_concave_polygon_patch_triangulates_its_true_area() -> None: + pytest.importorskip("matplotlib") + from matplotlib.patches import Polygon + + _fig, ax = plt.subplots() + ax.add_patch(Polygon([[0, 0], [4, 0], [4, 4], [2, 1], [0, 4]], facecolor="green")) + meshes, _edges = _patch_marks(ax) + assert _mesh_area(meshes[0]) == pytest.approx(10.0) + + +def test_unfilled_patch_stays_edge_only() -> None: + pytest.importorskip("matplotlib") + from matplotlib.patches import Rectangle + + _fig, ax = plt.subplots() + ax.add_patch(Rectangle((0, 0), 2, 1, fill=False)) + meshes, edges = _patch_marks(ax) + assert meshes == [] + assert len(edges) == 1 + + +def test_degenerate_patch_draws_its_edge_without_raising() -> None: + pytest.importorskip("matplotlib") + from matplotlib.patches import Rectangle + + _fig, ax = plt.subplots() + ax.add_patch(Rectangle((0, 0), 2, 0, facecolor="tab:blue")) + meshes, edges = _patch_marks(ax) + assert meshes == [] + assert len(edges) == 1 + edge_x = np.concatenate((edges[0]["args"][0], edges[0]["args"][2])) + assert np.ptp(edge_x) == pytest.approx(2.0) + + +def test_removing_a_filled_patch_takes_its_outline_with_it() -> None: + pytest.importorskip("matplotlib") + from matplotlib.patches import Rectangle + + _fig, ax = plt.subplots() + artist = ax.add_patch(Rectangle((0, 0), 2, 1, facecolor="tab:blue", edgecolor="red")) + assert len(ax._entries) == 2 + artist.remove() + assert ax._entries == [] + + +def test_adding_a_filled_patch_does_not_advance_the_color_cycle() -> None: + pytest.importorskip("matplotlib") + from matplotlib.patches import Rectangle + + _fig, ax = plt.subplots() + ax.add_patch(Rectangle((0, 0), 2, 1, facecolor="tab:orange")) + polygon = ax.fill([0, 1, 1], [0, 0, 1])[0] + assert polygon._entry["kwargs"]["color"] == "#1f77b4" + + +def test_patch_outline_width_converts_points_to_pixels() -> None: + pytest.importorskip("matplotlib") + from matplotlib.patches import Rectangle + + widths = [] + for dpi in (72, 200): + _fig, ax = plt.subplots(dpi=dpi) + ax.add_patch(Rectangle((0, 0), 2, 1, fill=False, linewidth=5)) + widths.append(ax._entries[-1]["kwargs"]["width"]) + assert widths == pytest.approx([5.0, 5.0 * 200 / 72]) + + +def test_patch_outline_stroke_thickens_with_dpi_in_the_png(tmp_path) -> None: + pytest.importorskip("matplotlib") + image_module = pytest.importorskip("PIL.Image") + from matplotlib.patches import Rectangle + + def stroke_runs(dpi: int) -> list[int]: + fig, ax = plt.subplots(figsize=(4, 4), dpi=dpi) + ax.add_patch(Rectangle((2, 4), 6, 2, fill=False, edgecolor="#ff0000", linewidth=5)) + ax.set_xlim(0, 10) + ax.set_ylim(0, 10) + path = tmp_path / f"stroke_{dpi}.png" + fig.savefig(str(path)) + pixels = np.asarray(image_module.open(path).convert("RGB")) + red = (pixels[:, :, 0] > 150) & (pixels[:, :, 1] < 100) & (pixels[:, :, 2] < 100) + column = red[:, pixels.shape[1] // 2] + edges = np.flatnonzero(np.diff(np.r_[False, column, False].astype(np.int8))) + return (edges[1::2] - edges[0::2]).tolist() + + assert stroke_runs(72) == [5, 5] + assert stroke_runs(200) == [13, 13] + + +def test_patch_with_nested_rings_abstains_from_filling_the_hole() -> None: + pytest.importorskip("matplotlib") + from matplotlib.patches import PathPatch + from matplotlib.path import Path + + square = [(0, 0), (10, 0), (10, 10), (0, 10), (0, 0)] + hole = [(3, 3), (3, 7), (7, 7), (7, 3), (3, 3)] + codes = ([Path.MOVETO] + [Path.LINETO] * 3 + [Path.CLOSEPOLY]) * 2 + _fig, ax = plt.subplots() + ax.add_patch(PathPatch(Path(square + hole, codes), facecolor="tab:blue")) + meshes, edges = _patch_marks(ax) + # Filling both rings would paint 116 for a true area of 84. + assert meshes == [] + assert len(edges) == 2 + + +def test_patch_with_disjoint_rings_still_fills_both() -> None: + pytest.importorskip("matplotlib") + from matplotlib.patches import PathPatch + from matplotlib.path import Path + + left = [(0, 0), (1, 0), (1, 1), (0, 1), (0, 0)] + right = [(3, 0), (4, 0), (4, 1), (3, 1), (3, 0)] + codes = ([Path.MOVETO] + [Path.LINETO] * 3 + [Path.CLOSEPOLY]) * 2 + _fig, ax = plt.subplots() + ax.add_patch(PathPatch(Path(left + right, codes), facecolor="tab:blue")) + meshes, _edges = _patch_marks(ax) + assert len(meshes) == 2 + assert sum(_mesh_area(mesh) for mesh in meshes) == pytest.approx(2.0) + + +def test_annular_sector_fills_as_one_ring() -> None: + pytest.importorskip("matplotlib") + from matplotlib.patches import Wedge + + _fig, ax = plt.subplots() + ax.add_patch(Wedge((0, 0), 1.0, 0.0, 90.0, width=0.4, facecolor="tab:blue")) + meshes, _edges = _patch_marks(ax) + # An annular sector traces out along one arc and back along the other, so + # it is a single simple ring rather than a hole. + assert len(meshes) == 1 + assert _mesh_area(meshes[0]) == pytest.approx(np.pi * (1.0**2 - 0.6**2) / 4.0, rel=1e-2) + + +def test_full_annulus_draws_both_outlines_without_raising() -> None: + pytest.importorskip("matplotlib") + from matplotlib.patches import Wedge + + _fig, ax = plt.subplots() + ax.add_patch(Wedge((0, 0), 1.0, 0.0, 360.0, width=0.4, facecolor="tab:blue")) + meshes, edges = _patch_marks(ax) + assert meshes == [] + assert len(edges) == 2 + # Matplotlib repeats a vertex in its full-circle path. Leaving it in place + # made the triangulator reject the ring, and the failure was swallowed, so + # this asserts the repeat is gone rather than that we drew nothing. + assert all(_shortest_relative_edge(edge) > 1e-6 for edge in edges) + + +def test_patch_at_genomic_coordinates_keeps_every_vertex() -> None: + pytest.importorskip("matplotlib") + from matplotlib.patches import Rectangle + + _fig, ax = plt.subplots() + ax.add_patch(Rectangle((1e9, 0.0), 5000.0, 10.0, facecolor="tab:blue", edgecolor="black")) + meshes, edges = _patch_marks(ax) + # A magnitude-relative duplicate test collapses this to three vertices and + # zero area, because 1e-5 of 1e9 is 10,000 data units. + assert len(edges[0]["args"][0]) == 4 + assert _mesh_area(meshes[0]) == pytest.approx(50000.0) + + +def test_full_disc_wedge_fills_despite_its_repeated_vertex() -> None: + pytest.importorskip("matplotlib") + from matplotlib.patches import Wedge + + _fig, ax = plt.subplots() + ax.add_patch(Wedge((0, 0), 1.0, 0.0, 360.0, facecolor="tab:blue", edgecolor="black")) + meshes, edges = _patch_marks(ax) + # The repeat is not bit-exact, so an exact-equality dedupe leaves it in + # place and the triangulator rejects the whole disc. + assert len(meshes) == 1 + assert _mesh_area(meshes[0]) == pytest.approx(np.pi, rel=1e-3) + assert _shortest_relative_edge(edges[0]) > 1e-6 + + +def test_tightly_spaced_but_distinct_vertices_survive() -> None: + pytest.importorskip("matplotlib") + from matplotlib.patches import Polygon + + _fig, ax = plt.subplots() + ax.add_patch( + Polygon( + [[0, 0], [1e-9, 0], [1, 0], [1, 1], [0, 1]], facecolor="tab:blue", edgecolor="black" + ) + ) + _meshes, edges = _patch_marks(ax) + # A 1e-9 edge on a unit-span ring is real geometry, not floating-point + # noise, and np.isclose's 1e-8 absolute floor would swallow it. + assert len(edges[0]["args"][0]) == 5 + + +@pytest.mark.parametrize("bad", [np.inf, -np.inf, np.nan]) +def test_patch_with_a_non_finite_coordinate_keeps_its_vertices(bad: float) -> None: + pytest.importorskip("matplotlib") + from matplotlib.patches import Rectangle + + _fig, ax = plt.subplots() + ax.add_patch(Rectangle((0, 0), bad, 1.0, fill=False)) + _meshes, edges = _patch_marks(ax) + # The tolerance is a fraction of the ring's span, and none of these three + # yield a usable one. They share a single guard, so what this pins is that + # its predicate catches all three: an isnan check would let the infinities + # through, an isinf check would let the NaN through. + assert len(edges[0]["args"][0]) == 4 + + +def test_patch_without_a_path_or_rectangle_getters_raises() -> None: + _fig, ax = plt.subplots() + with pytest.raises(TypeError, match="unsupported patch"): + ax.add_patch(object()) + + +def _mesh_radii(entry: dict, radius: float) -> np.ndarray: + """Every mesh vertex's distance from the origin, as a fraction of `radius`.""" + values = [np.asarray(entry["args"][index], dtype=np.float64) for index in range(6)] + xs = np.concatenate(values[0::2]) + ys = np.concatenate(values[1::2]) + return np.hypot(xs, ys) / radius + + +@pytest.mark.parametrize("radius", [1e-3, 1.0, 1e3]) +def test_patch_curves_flatten_at_output_scale_not_in_data_units(radius: float) -> None: + pytest.importorskip("matplotlib") + from matplotlib.patches import Circle + + _fig, ax = plt.subplots() + ax.add_patch(Circle((0, 0), radius, facecolor="tab:blue")) + meshes, _edges = _patch_marks(ax) + # `to_polygons` subdivides until flat in the units it is handed, so + # flattening in data space made this 2.5% out at radius 1 and exact at + # radius 1000 — the same circle on screen, drawn differently because of + # the numbers behind it. Area hides this: the overshoot between the + # on-curve points cancels the chord deficit, leaving 0.08% either way. + assert _mesh_radii(meshes[0], radius).max() == pytest.approx(1.0, abs=1e-3) + + +def test_small_patch_at_a_large_offset_keeps_its_area() -> None: + pytest.importorskip("matplotlib") + from matplotlib.patches import Rectangle + + _fig, ax = plt.subplots() + ax.add_patch(Rectangle((1e9, 0.0), 1e-4, 1e-4, facecolor="tab:blue")) + meshes, _edges = _patch_marks(ax) + # Flattening at output scale means a round trip through `* scale` and + # `/ scale`. Carrying the 1e9 offset through it costs eight times more + # area than scaling about the patch's own corner does. + assert _mesh_area(meshes[0]) == pytest.approx(1e-8, rel=1e-3) + + +def test_filled_patch_with_no_edge_color_emits_no_outline_mark() -> None: + pytest.importorskip("matplotlib") + from matplotlib.patches import Rectangle + + _fig, ax = plt.subplots() + ax.add_patch(Rectangle((0, 0), 2, 1, facecolor="tab:blue", edgecolor="none")) + ax.add_patch(Rectangle((0, 0), 2, 1, facecolor="tab:blue", edgecolor="red", linewidth=0)) + ax.add_patch(Rectangle((0, 0), 2, 1, facecolor="tab:blue", edgecolor="red")) + meshes, edges = _patch_marks(ax) + # Three fills, and only the patch that actually asked for a stroke pays + # for one. An invisible outline per ring is pure payload. + assert len(meshes) == 3 + assert len(edges) == 1 + assert edges[0]["kwargs"]["color"] == "rgba(255,0,0,1)" + + +def test_invisible_patch_still_leaves_one_entry_to_hold() -> None: + pytest.importorskip("matplotlib") + from matplotlib.patches import PathPatch + from matplotlib.path import Path + + square = [(0, 0), (10, 0), (10, 10), (0, 10), (0, 0)] + hole = [(3, 3), (3, 7), (7, 7), (7, 3), (3, 3)] + codes = ([Path.MOVETO] + [Path.LINETO] * 3 + [Path.CLOSEPOLY]) * 2 + _fig, ax = plt.subplots() + # Nested rings abstain from filling and the edge paints nothing, so the + # outline is emitted anyway rather than leaving the handle with no entry. + handle = ax.add_patch(Path and PathPatch(Path(square + hole, codes), edgecolor="none")) + assert len(ax._entries) == 2 + handle.remove() + assert ax._entries == [] + + +def test_hiding_a_filled_patch_hides_its_outline_too() -> None: + pytest.importorskip("matplotlib") + from matplotlib.patches import Rectangle + + _fig, ax = plt.subplots() + handle = ax.add_patch(Rectangle((0, 0), 2, 1, facecolor="tab:blue", edgecolor="red")) + handle.set_visible(False) + # Hiding only the body left the outline drawn, so a "hidden" patch was + # still a red rectangle on screen. + assert [entry["kwargs"]["opacity"] for entry in ax._entries] == [0.0, 0.0] + handle.set_visible(True) + assert [entry["kwargs"]["opacity"] for entry in ax._entries] == [1.0, 1.0] + + +def test_alpha_and_color_on_a_filled_patch_reach_its_outline() -> None: + pytest.importorskip("matplotlib") + from matplotlib.patches import Rectangle + + _fig, ax = plt.subplots() + handle = ax.add_patch(Rectangle((0, 0), 2, 1, facecolor="tab:blue", edgecolor="red")) + handle.set_alpha(0.25) + assert [entry["kwargs"]["opacity"] for entry in ax._entries] == [0.25, 0.25] + handle.set_color("green") + # Matplotlib's Patch.set_color paints face and edge alike. + assert {entry["kwargs"]["color"] for entry in ax._entries} == {"green"} + + +def test_transforming_a_filled_patch_moves_its_outline_too() -> None: + pytest.importorskip("matplotlib") + from matplotlib.patches import Rectangle + + from xy.pyplot._transforms import Affine2D + + _fig, ax = plt.subplots() + handle = ax.add_patch(Rectangle((0, 0), 2, 1, facecolor="tab:blue", edgecolor="red")) + handle.set_transform(Affine2D().translate(10, 0)) + meshes, edges = _patch_marks(ax) + # A transform that moved the body and left the outline behind would tear + # the patch in two. + assert np.asarray(meshes[0]["args"][0]).min() == pytest.approx(10.0) + assert np.asarray(edges[0]["args"][0]).min() == pytest.approx(10.0) + + +def test_patch_artist_transform_rides_along() -> None: + pytest.importorskip("matplotlib") + from matplotlib.patches import Rectangle + from matplotlib.transforms import Affine2D + + _fig, ax = plt.subplots() + ax.add_patch(Rectangle((0, 0), 2, 1, facecolor="tab:blue", transform=Affine2D().rotate_deg(90))) + meshes, _edges = _patch_marks(ax) + xs = np.concatenate([np.asarray(meshes[0]["args"][index]) for index in (0, 2, 4)]) + ys = np.concatenate([np.asarray(meshes[0]["args"][index]) for index in (1, 3, 5)]) + # Flattening through get_patch_transform alone drops the artist-level + # transform and leaves the rectangle axis-aligned at x in 0..2. + assert xs.min() == pytest.approx(-1.0) + assert xs.max() == pytest.approx(0.0, abs=1e-9) + assert ys.max() == pytest.approx(2.0) + assert _mesh_area(meshes[0]) == pytest.approx(2.0) + + +def test_patch_transform_transdata_is_accepted_and_transaxes_rejected() -> None: + pytest.importorskip("matplotlib") + from matplotlib.patches import Rectangle + + _fig, ax = plt.subplots() + ax.add_patch(Rectangle((0, 0), 2, 1, facecolor="tab:blue", transform=ax.transData)) + meshes, _edges = _patch_marks(ax) + assert _mesh_area(meshes[0]) == pytest.approx(2.0) + # Baked axes fractions go silently stale on the next limit change, so + # this rejects like _transform_points does for every other data artist. + with pytest.raises(NotImplementedError, match="transAxes"): + ax.add_patch(Rectangle((0.1, 0.1), 0.5, 0.5, transform=ax.transAxes)) + + +def test_partially_overlapping_rings_fill_instead_of_hollowing() -> None: + pytest.importorskip("matplotlib") + from matplotlib.patches import PathPatch + from matplotlib.path import Path + + left = [(0, 0), (2, 0), (2, 2), (0, 2), (0, 0)] + right = [(1, 1), (3, 1), (3, 3), (1, 3), (1, 1)] + codes = ([Path.MOVETO] + [Path.LINETO] * 3 + [Path.CLOSEPOLY]) * 2 + _fig, ax = plt.subplots() + ax.add_patch(PathPatch(Path(left + right, codes), facecolor="tab:blue")) + meshes, _edges = _patch_marks(ax) + # The right ring's first vertex sits inside the left ring, but the rings + # only overlap. A first-vertex containment test called them nested and + # hollowed the whole patch; overlap fills ring-by-ring instead. + assert len(meshes) == 2 + assert sum(_mesh_area(mesh) for mesh in meshes) == pytest.approx(8.0) + + +def test_ring_past_the_triangulator_cap_says_so_instead_of_dropping_the_fill() -> None: + pytest.importorskip("matplotlib") + from matplotlib.patches import Polygon + + angles = np.linspace(0.0, 2.0 * np.pi, 12000, endpoint=False) + _fig, ax = plt.subplots() + with pytest.warns(RuntimeWarning, match="could not fill a 12000-vertex ring"): + ax.add_patch(Polygon(np.c_[np.cos(angles), np.sin(angles)], facecolor="tab:blue")) + meshes, edges = _patch_marks(ax) + # Matplotlib fills this. Abstaining is defensible, doing it silently is + # not: a hollow patch looks like a deliberate style choice. + assert meshes == [] + assert len(edges) == 1 + + +def test_self_intersecting_patch_says_why_it_could_not_fill() -> None: + pytest.importorskip("matplotlib") + from matplotlib.patches import Polygon + + _fig, ax = plt.subplots() + with pytest.warns(RuntimeWarning, match="could not fill a 4-vertex ring"): + ax.add_patch(Polygon([[0, 0], [2, 2], [2, 0], [0, 2]], facecolor="tab:blue")) + assert _patch_marks(ax)[0] == [] + + +def test_degenerate_ring_skips_its_fill_without_warning() -> None: + pytest.importorskip("matplotlib") + from matplotlib.patches import Rectangle + + _fig, ax = plt.subplots() + with warnings.catch_warnings(): + # A zero-height Rectangle has no body by construction, so warning + # about it would cry wolf on every axvline-style spacer. + warnings.simplefilter("error") + ax.add_patch(Rectangle((0, 0), 2, 0, facecolor="tab:blue")) + meshes, edges = _patch_marks(ax) + assert meshes == [] + assert len(edges) == 1 + + def test_masked_and_nan_lines_break_instead_of_bridging_missing_values() -> None: _fig, ax = plt.subplots() x = np.arange(5.0)