From ef9ae7b39fa28fd7aa4305cafb9d1dd1c5aad4f4 Mon Sep 17 00:00:00 2001 From: Michael Denyer Date: Thu, 30 Jul 2026 08:51:56 +0100 Subject: [PATCH 01/13] Add failing tests for add_patch fill, geometry, and handle ownership add_patch renders every patch as edge-only line segments: no fill, angle dropped, and curved patches measured from raw Bezier control points. The returned handle also has to own the outline so remove() clears the whole patch, and the fill must never touch the axes color cycle. These tests pin that behaviour and fail against the current implementation. --- CHANGELOG.md | 9 +++ python/xy/pyplot/_axes.py | 103 ++++++++++++++++++++------ spec/matplotlib/compat-changelog.md | 17 +++++ spec/matplotlib/compat.md | 6 +- tests/pyplot/test_launch_compat.py | 110 ++++++++++++++++++++++++++++ 5 files changed, 222 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ef1ff3e0..632170f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,15 @@ 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 Bezier control points. Unfilled patches stay + edge-only, the axes color cycle is untouched, and a degenerate patch draws + its edge instead of raising. + ## [0.0.4] - 2026-07-27 ### Added diff --git a/python/xy/pyplot/_axes.py b/python/xy/pyplot/_axes.py index f1e930a5..f163fd34 100644 --- a/python/xy/pyplot/_axes.py +++ b/python/xy/pyplot/_axes.py @@ -1101,6 +1101,42 @@ def _cached_axis(which: str, props: dict) -> Any: return made +def _patch_outline(patch: Any) -> list[np.ndarray]: + """Data-space rings of a patch, curves flattened and its transform applied. + + ``Path.to_polygons`` is what matplotlib's own renderers use, so a rotated + Rectangle, a Circle, and a Wedge all come back as real geometry. Ducks + without it 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) + if get_path is not None and get_transform is not None: + to_polygons = getattr(get_path(), "to_polygons", None) + if to_polygons is not None: + rings = to_polygons(get_transform()) + if len(rings): + return [np.asarray(ring, dtype=np.float64) for ring in 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()) + return [np.asarray([[x0, y0], [x1, y0], [x1, y1], [x0, y1], [x0, y0]])] + if get_path is not None: + return [np.asarray(get_path().vertices, dtype=np.float64)] + raise TypeError(f"unsupported patch {type(patch).__name__}") + + +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 + color = getattr(patch, "get_facecolor", lambda: None)() + 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 + + class Axes(PlotTypeMixin): def __init__(self, figure: Any, *, y2_of: Optional["Axes"] = None) -> None: self.figure = figure @@ -5557,11 +5593,13 @@ 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. + """Add a patch as a filled body plus its outline, or as 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. + 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 draws its edge as line segments. Unsupported patch + types raise. """ if hasattr(patch, "get_data"): data = patch.get_data() @@ -5582,24 +5620,47 @@ 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__}") + from xy import kernels + + outline = _patch_outline(patch) + face = _patch_fill_color(patch) 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) + width = float(getattr(patch, "get_linewidth", lambda: 1.0)()) + entries: list[dict[str, Any]] = [] + if face is not None: + for ring in outline: + xv, yv = ring[:, 0], ring[:, 1] + if len(xv) > 2 and np.allclose((xv[0], yv[0]), (xv[-1], yv[-1])): + xv, yv = xv[:-1], yv[:-1] + try: + topology = kernels.polygon_triangles(xv, yv) + except ValueError: + # A zero-area or self-intersecting ring has no triangulation; + # the outline pass below still draws it. + 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}, + }, + ) + ) + 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": resolve_color(edge), "width": width}, + }, + ) + ) + return Artist(self, entries[0]) 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..683fa743 100644 --- a/spec/matplotlib/compat-changelog.md +++ b/spec/matplotlib/compat-changelog.md @@ -4,6 +4,23 @@ 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(patch.get_patch_transform())`, + the same flattening Matplotlib's own renderers use. `Rectangle(angle=...)` + keeps its rotation, and curved patches use the curve rather than its Bezier + control points: `Circle(radius=1)` covers 3.139 rather than 3.251, and + `Ellipse(width=2, height=1, angle=20)` covers 1.570 rather than 3.251. +- Outlines take the patch's own line width instead of a fixed one point. + Degenerate rings with no triangulation, such as a zero-height `Rectangle`, + draw their edge and skip the fill rather than raising. + ## 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..4c597bb6 100644 --- a/spec/matplotlib/compat.md +++ b/spec/matplotlib/compat.md @@ -113,8 +113,10 @@ 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 the same straight +segments Matplotlib's renderers use rather than an exact analytic curve. ## Sharp edges diff --git a/tests/pyplot/test_launch_compat.py b/tests/pyplot/test_launch_compat.py index 996bad4d..f64b614b 100644 --- a/tests/pyplot/test_launch_compat.py +++ b/tests/pyplot/test_launch_compat.py @@ -113,6 +113,116 @@ 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 _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) + assert len(meshes) == 1 and len(edges) == 1 + 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 Bezier 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")) + 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_without_a_path_or_rectangle_getters_raises() -> None: + _fig, ax = plt.subplots() + with pytest.raises(TypeError, match="unsupported patch"): + ax.add_patch(object()) + + def test_masked_and_nan_lines_break_instead_of_bridging_missing_values() -> None: _fig, ax = plt.subplots() x = np.arange(5.0) From 9701e6dc54e4517038102d4f9e2842cf03b45219 Mon Sep 17 00:00:00 2001 From: Michael Denyer Date: Thu, 30 Jul 2026 08:51:56 +0100 Subject: [PATCH 02/13] Fill patches in add_patch and flatten their real geometry Extract data-space rings with Path.to_polygons(patch.get_patch_transform()), matplotlib's own flattening call, then fill each ring with the patch's own face color using the triangle-mesh recipe Axes.fill already uses. Rotation and curvature now survive: Rectangle(angle=30) keeps its rotation and Ellipse(width=2, height=1) covers pi*a*b rather than its control-point hull. Unfilled patches, a "none" face color, and a fully transparent one stay edge-only, degenerate rings draw their edge without raising, and the patch's own face color means the axes color cycle is never advanced. A patch now emits several marks, so add_patch returns a Patch handle that carries the outline entries as companions and sweeps them in remove() and set_zorder(), following the Wedge precedent in _artists.py. --- python/xy/pyplot/_artists.py | 24 ++++++++++++++++++++++++ python/xy/pyplot/_axes.py | 8 +++++--- spec/matplotlib/compat-changelog.md | 3 +++ 3 files changed, 32 insertions(+), 3 deletions(-) diff --git a/python/xy/pyplot/_artists.py b/python/xy/pyplot/_artists.py index ebd8f5f6..7bcdf396 100644 --- a/python/xy/pyplot/_artists.py +++ b/python/xy/pyplot/_artists.py @@ -940,6 +940,30 @@ def get_data(self) -> tuple[Any, Any, Any]: ) +class Patch(Artist): + """Handle for ``add_patch`` output, owning the outline marks beside the fill.""" + + 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 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 f163fd34..60ae30b4 100644 --- a/python/xy/pyplot/_axes.py +++ b/python/xy/pyplot/_axes.py @@ -33,6 +33,7 @@ BarContainer, Legend, Line2D, + Patch, PathCollection, PolyCollection, Text, @@ -5598,8 +5599,9 @@ def add_patch(self, patch: Any) -> Artist: 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 draws its edge as line segments. Unsupported patch - types raise. + face color and draws its edge as line segments. The returned handle + owns every mark the patch produced, so removing it takes the outline + with the fill. Unsupported patch types raise. """ if hasattr(patch, "get_data"): data = patch.get_data() @@ -5660,7 +5662,7 @@ def add_patch(self, patch: Any) -> Artist: }, ) ) - return Artist(self, entries[0]) + 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 683fa743..c2cc378c 100644 --- a/spec/matplotlib/compat-changelog.md +++ b/spec/matplotlib/compat-changelog.md @@ -20,6 +20,9 @@ which covers user-visible releases across the whole package. - Outlines take the patch's own line width instead of a fixed one point. Degenerate rings with no triangulation, such as a zero-height `Rectangle`, draw their edge and skip the fill rather than raising. +- `add_patch` returns a `Patch` handle rather than a bare `Artist`. It owns the + outline marks alongside the fill, so `remove()` and `set_zorder()` move the + whole patch instead of only its body. ## Box and violin default geometry — 2026-07-26 (Matplotlib 3.11.1 reference) From 7371855e7118f510f6fd4e581bd3779cffd3357e Mon Sep 17 00:00:00 2001 From: Michael Denyer Date: Thu, 30 Jul 2026 09:21:26 +0100 Subject: [PATCH 03/13] Add failing tests for patch stroke DPI, nested rings, and full annulus Three defects surviving the fill work. The outline width goes to the mark in Matplotlib points where it wants pixels, so the stroke ignores figure DPI. A compound path with nested rings paints its hole solid, 116 for a true 84. A full-circle Wedge loses its fill entirely, because Matplotlib repeats a vertex in that path and the triangulator's rejection is swallowed. Two of the six are guards rather than repros: an annular sector and a pair of disjoint rings must keep filling, so they pass before and after. --- tests/pyplot/test_launch_compat.py | 92 ++++++++++++++++++++++++++++++ 1 file changed, 92 insertions(+) diff --git a/tests/pyplot/test_launch_compat.py b/tests/pyplot/test_launch_compat.py index f64b614b..8c6d3017 100644 --- a/tests/pyplot/test_launch_compat.py +++ b/tests/pyplot/test_launch_compat.py @@ -217,6 +217,98 @@ def test_adding_a_filled_patch_does_not_advance_the_color_cycle() -> None: 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; dropping only the + # trailing one left a duplicate that the triangulator rejected in silence. + assert all(len(edge["args"][0]) == 32 for edge in edges) + + def test_patch_without_a_path_or_rectangle_getters_raises() -> None: _fig, ax = plt.subplots() with pytest.raises(TypeError, match="unsupported patch"): From d7b441deb0ede62513da174b0b0e32d2b0242fce Mon Sep 17 00:00:00 2001 From: Michael Denyer Date: Thu, 30 Jul 2026 09:21:26 +0100 Subject: [PATCH 04/13] Convert patch stroke to pixels, dedupe rings, abstain on holes Three fixes that have to land together. Outline width now multiplies by _point_scale(), the shim's points-to-pixels conversion used at 39 other sites. A 5 pt stroke measures 5 px at 72 dpi and 13 px at 200 dpi, matching ax.plot. _patch_outline drops consecutive duplicate vertices, not just a trailing one. Matplotlib repeats a vertex inside its full-circle paths, so a Wedge annulus reached polygon_triangles carrying a duplicate, was rejected, and the fill vanished into the except ValueError. Rings keep their closing vertex, so the outline pass still draws the closing edge. Nested rings mean holes, and polygon_triangles takes one simple polygon, so add_patch now abstains: it draws the outlines and skips the fill rather than painting the hole solid. Containment is tested with kernels.polygon_select, the native even-odd ray cast the lasso already uses. Disjoint rings still fill both, and an annular sector still fills because Matplotlib returns it as one ring. Without the dedupe this abstention would be untested, and without the abstention the dedupe would fill an annulus as two solid discs. --- CHANGELOG.md | 8 +++- python/xy/pyplot/_axes.py | 70 ++++++++++++++++++++++------- spec/matplotlib/compat-changelog.md | 16 +++++-- spec/matplotlib/compat.md | 5 ++- 4 files changed, 79 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 632170f7..8cbb7de3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,7 +26,13 @@ in the README). `Rectangle(angle=...)` keeps its rotation and `Circle`/`Ellipse`/`Wedge` use the curve rather than its Bezier control points. Unfilled patches stay edge-only, the axes color cycle is untouched, and a degenerate patch draws - its edge instead of raising. + 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. +- 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. ## [0.0.4] - 2026-07-27 diff --git a/python/xy/pyplot/_axes.py b/python/xy/pyplot/_axes.py index 60ae30b4..599994f4 100644 --- a/python/xy/pyplot/_axes.py +++ b/python/xy/pyplot/_axes.py @@ -1102,6 +1102,17 @@ 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. + """ + if len(ring) < 2: + return ring + return ring[np.r_[True, ~np.all(np.isclose(ring[1:], ring[:-1]), axis=1)]] + + def _patch_outline(patch: Any) -> list[np.ndarray]: """Data-space rings of a patch, curves flattened and its transform applied. @@ -1111,19 +1122,46 @@ def _patch_outline(patch: Any) -> list[np.ndarray]: """ get_path = getattr(patch, "get_path", None) get_transform = getattr(patch, "get_patch_transform", None) + rings: list[Any] = [] if get_path is not None and get_transform is not None: to_polygons = getattr(get_path(), "to_polygons", None) if to_polygons is not None: - rings = to_polygons(get_transform()) - if len(rings): - return [np.asarray(ring, dtype=np.float64) for ring in 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()) - return [np.asarray([[x0, y0], [x1, y0], [x1, y1], [x0, y1], [x0, y0]])] - if get_path is not None: - return [np.asarray(get_path().vertices, dtype=np.float64)] - raise TypeError(f"unsupported patch {type(patch).__name__}") + rings = list(to_polygons(get_transform())) + 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 inside another, so the patch's path has holes. + + `kernels.polygon_triangles` takes one simple polygon, so a hole would have + to be painted solid. Callers abstain from filling instead. + """ + from xy import kernels + + if len(outline) < 2: + return False + first_row = np.zeros(1, dtype=np.uint32) + for index, ring in enumerate(outline): + if not len(ring): + continue + for other in outline[:index] + outline[index + 1 :]: + if len(other) < 3: + continue + inside = kernels.polygon_select( + ring[:1, 0], ring[:1, 1], first_row, other[:, 0], other[:, 1] + ) + if len(inside): + return True + return False def _patch_fill_color(patch: Any) -> Any: @@ -5599,9 +5637,11 @@ def add_patch(self, patch: Any) -> Artist: 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 draws its edge as line segments. The returned handle - owns every mark the patch produced, so removing it takes the outline - with the fill. Unsupported patch types raise. + face color and draws its edge as line segments. 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 it takes the outline with the fill. Unsupported + patch types raise. """ if hasattr(patch, "get_data"): data = patch.get_data() @@ -5627,9 +5667,9 @@ def add_patch(self, patch: Any) -> Artist: outline = _patch_outline(patch) face = _patch_fill_color(patch) edge = getattr(patch, "get_edgecolor", lambda: "#000000")() - width = float(getattr(patch, "get_linewidth", lambda: 1.0)()) + width = float(getattr(patch, "get_linewidth", lambda: 1.0)()) * self._point_scale() entries: list[dict[str, Any]] = [] - if face is not None: + if face is not None and not _rings_are_nested(outline): for ring in outline: xv, yv = ring[:, 0], ring[:, 1] if len(xv) > 2 and np.allclose((xv[0], yv[0]), (xv[-1], yv[-1])): diff --git a/spec/matplotlib/compat-changelog.md b/spec/matplotlib/compat-changelog.md index c2cc378c..a692b647 100644 --- a/spec/matplotlib/compat-changelog.md +++ b/spec/matplotlib/compat-changelog.md @@ -17,9 +17,19 @@ which covers user-visible releases across the whole package. keeps its rotation, and curved patches use the curve rather than its Bezier control points: `Circle(radius=1)` covers 3.139 rather than 3.251, and `Ellipse(width=2, height=1, angle=20)` covers 1.570 rather than 3.251. -- Outlines take the patch's own line width instead of a fixed one point. - Degenerate rings with no triangulation, such as a zero-height `Rectangle`, - draw their edge and skip the fill rather than raising. +- 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. Degenerate rings with no + triangulation, such as a zero-height `Rectangle`, draw their edge and skip + the fill rather than raising. +- 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. + Rings that merely 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()` and `set_zorder()` move the whole patch instead of only its body. diff --git a/spec/matplotlib/compat.md b/spec/matplotlib/compat.md index 4c597bb6..48fd98f0 100644 --- a/spec/matplotlib/compat.md +++ b/spec/matplotlib/compat.md @@ -116,7 +116,10 @@ against Matplotlib 3.11.1 at 10 pt, `round` is 4.17 px there against 5 px 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 the same straight -segments Matplotlib's renderers use rather than an exact analytic curve. +segments Matplotlib's renderers use rather than an exact analytic curve. A +patch whose path has nested rings is the one case `add_patch` declines rather +than approximates: hole support is not implemented, so it draws its outlines +and skips the fill instead of painting the hole solid. ## Sharp edges From 9e6490e2f26f7026889cb05daf568aa71cff01c5 Mon Sep 17 00:00:00 2001 From: Michael Denyer Date: Thu, 30 Jul 2026 09:44:42 +0100 Subject: [PATCH 05/13] Pin duplicate-vertex handling at scale, drop the tessellation-count assertion The magnitude-relative duplicate test erases a patch drawn at genomic or epoch-millisecond coordinates: a Rectangle at x=1e9 collapses from five vertices to three and fills zero area, because 1e-5 of 1e9 is 10,000 data units. Two new tests pin that and a genuinely tiny 1e-9 edge surviving. A third pins the full-circle Wedge filling. Matplotlib's repeated vertex is not bit-exact, so deduplicating only exact matches leaves it in place and the triangulator rejects the whole disc. That test fails against an exact-equality dedupe and against the state before any dedupe. The full-annulus test no longer asserts a vertex count of 32, which hardcoded Matplotlib's current tessellation. It asserts the invariant instead, that no outline segment is a duplicate-length stub, measured against the ring's own span so it stays scale-free. Comparing against zero would not do: a repeat is floating-point noise at 2e-16, not an exact coincidence. --- tests/pyplot/test_launch_compat.py | 59 ++++++++++++++++++++++++++++-- 1 file changed, 56 insertions(+), 3 deletions(-) diff --git a/tests/pyplot/test_launch_compat.py b/tests/pyplot/test_launch_compat.py index 8c6d3017..9577b995 100644 --- a/tests/pyplot/test_launch_compat.py +++ b/tests/pyplot/test_launch_compat.py @@ -118,6 +118,19 @@ def _mesh_area(entry: dict) -> float: 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"] @@ -304,9 +317,49 @@ def test_full_annulus_draws_both_outlines_without_raising() -> None: meshes, edges = _patch_marks(ax) assert meshes == [] assert len(edges) == 2 - # Matplotlib repeats a vertex in its full-circle path; dropping only the - # trailing one left a duplicate that the triangulator rejected in silence. - assert all(len(edge["args"][0]) == 32 for edge in edges) + # 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")) + 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")) + 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")) + _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 def test_patch_without_a_path_or_rectangle_getters_raises() -> None: From e79b1dab4499e7bde678c94c3154f23622623a42 Mon Sep 17 00:00:00 2001 From: Michael Denyer Date: Thu, 30 Jul 2026 09:44:42 +0100 Subject: [PATCH 06/13] Scale the duplicate-vertex tolerance to the ring, not the coordinate np.isclose is relative to coordinate magnitude, so at x=1e9 it treats two vertices 10,000 data units apart as one and the patch disappears. Deduplicate against an absolute tolerance derived from the ring's own bounding-box diagonal instead. Measured against Matplotlib 3.11.1, repeats sit at 9e-17 of the span while the smallest real edge across the shapes we flatten sits at 2e-3 of it. The threshold takes 1e-12 of the span, ten thousand times above the noise and a billion times below the smallest real edge. Exact equality would not work here: Matplotlib's repeated vertex differs in the last bits, so an exact test catches nothing and the annulus loses its fill again. The comparison is negated so a non-finite gap or span keeps its vertex. The triangulator then rejects the ring on its own terms rather than us silently emptying it. --- python/xy/pyplot/_axes.py | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/python/xy/pyplot/_axes.py b/python/xy/pyplot/_axes.py index 599994f4..be495658 100644 --- a/python/xy/pyplot/_axes.py +++ b/python/xy/pyplot/_axes.py @@ -1106,11 +1106,25 @@ 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. + triangulator rejects any polygon carrying a duplicate. That repeat is not + bit-exact, so the test 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 - return ring[np.r_[True, ~np.all(np.isclose(ring[1:], ring[:-1]), axis=1)]] + span = float(np.hypot(*(ring.max(axis=0) - ring.min(axis=0)))) + gaps = np.hypot(*(ring[1:] - ring[:-1]).T) + # Negated so a non-finite gap or span keeps the vertex. The triangulator + # rejects the ring on its own terms rather than us silently emptying it. + return ring[np.r_[True, ~(gaps <= span * 1e-12)]] def _patch_outline(patch: Any) -> list[np.ndarray]: From 2d6615599458eacca72f84d1aa42e4c96e31c722 Mon Sep 17 00:00:00 2001 From: Michael Denyer Date: Thu, 30 Jul 2026 10:10:00 +0100 Subject: [PATCH 07/13] Keep a ring whole when its span is not finite An infinite coordinate makes the span infinite, so span * 1e-12 is infinite too, every finite gap reads as a duplicate, and the ring collapses to one vertex. NaN took the opposite path and worked by accident, because every comparison against NaN is false. Return the ring untouched when the span is not finite, which covers both and lets the triangulator reject the geometry on its own terms. With that guard in front, a finite span implies finite gaps, so the negated comparison the NaN case needed is gone and the test is a plain greater-than again. np.hypot is overflow-safe, so a ring at 1e200 still deduplicates normally; infinity only reaches here when the caller's geometry already holds it. --- python/xy/pyplot/_axes.py | 11 +++++++---- tests/pyplot/test_launch_compat.py | 14 ++++++++++++++ 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/python/xy/pyplot/_axes.py b/python/xy/pyplot/_axes.py index be495658..d0fb7e76 100644 --- a/python/xy/pyplot/_axes.py +++ b/python/xy/pyplot/_axes.py @@ -1121,10 +1121,13 @@ def _without_repeats(ring: np.ndarray) -> np.ndarray: if len(ring) < 2: return ring span = float(np.hypot(*(ring.max(axis=0) - ring.min(axis=0)))) - gaps = np.hypot(*(ring[1:] - ring[:-1]).T) - # Negated so a non-finite gap or span keeps the vertex. The triangulator - # rejects the ring on its own terms rather than us silently emptying it. - return ring[np.r_[True, ~(gaps <= span * 1e-12)]] + 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 _patch_outline(patch: Any) -> list[np.ndarray]: diff --git a/tests/pyplot/test_launch_compat.py b/tests/pyplot/test_launch_compat.py index 9577b995..e313e98c 100644 --- a/tests/pyplot/test_launch_compat.py +++ b/tests/pyplot/test_launch_compat.py @@ -362,6 +362,20 @@ def test_tightly_spaced_but_distinct_vertices_survive() -> None: 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) + # An infinite span makes every gap read as a duplicate and empties the + # ring; a NaN span makes every comparison false and leaves it alone. The + # two take opposite branches, so both belong here. + 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"): From 8701e420c1d1801d8b49f6acf43befe11831974f Mon Sep 17 00:00:00 2001 From: Michael Denyer Date: Thu, 30 Jul 2026 11:13:45 +0100 Subject: [PATCH 08/13] Say what the non-finite guard does, not what it did before it existed The comment claimed inf and NaN take opposite branches. They did before the guard landed; now both hit the same early return, so it described the bug rather than the fix and implied coverage of two paths where there is one. The three cases still earn their place for a different reason: they are the inputs that yield a span no tolerance can come from, and one predicate has to catch all three. Narrowing it to isnan fails both infinities, narrowing it to isinf fails the NaN. Also disambiguate 'the test needs a tolerance' in _without_repeats, which reads as a unit test rather than the duplicate comparison it means. --- python/xy/pyplot/_axes.py | 4 ++-- tests/pyplot/test_launch_compat.py | 7 ++++--- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/python/xy/pyplot/_axes.py b/python/xy/pyplot/_axes.py index d0fb7e76..0cc397ef 100644 --- a/python/xy/pyplot/_axes.py +++ b/python/xy/pyplot/_axes.py @@ -1107,8 +1107,8 @@ def _without_repeats(ring: np.ndarray) -> np.ndarray: 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 test needs a tolerance, but it has to scale with the - ring rather than with coordinate magnitude. `np.isclose` scales with + 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. diff --git a/tests/pyplot/test_launch_compat.py b/tests/pyplot/test_launch_compat.py index e313e98c..2ef06722 100644 --- a/tests/pyplot/test_launch_compat.py +++ b/tests/pyplot/test_launch_compat.py @@ -370,9 +370,10 @@ def test_patch_with_a_non_finite_coordinate_keeps_its_vertices(bad: float) -> No _fig, ax = plt.subplots() ax.add_patch(Rectangle((0, 0), bad, 1.0, fill=False)) _meshes, edges = _patch_marks(ax) - # An infinite span makes every gap read as a duplicate and empties the - # ring; a NaN span makes every comparison false and leaves it alone. The - # two take opposite branches, so both belong here. + # 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 From 5d9642591e6838e68777792015c3e2f807db8622 Mon Sep 17 00:00:00 2001 From: Alek Petuskey Date: Thu, 30 Jul 2026 12:11:16 -0700 Subject: [PATCH 09/13] Add failing tests for whole-patch mutation, edge emission, and curve scale A hidden patch still draws its outline, because set_visible reaches only the first mark the patch produced; set_alpha, set_color, and set_transform share the gap. A filled patch whose edge paints nothing, matplotlib's default, emits an invisible segments mark per ring anyway. And to_polygons flattens in data units, so Circle(radius=1) arrives as sixteen segments overshooting the true radius by 2.5% while the identical circle at radius=1000 arrives smooth; area assertions cannot see this because the overshoot between on-curve points cancels the chord deficit. Also pinned: a fill dropped for a reason the user should hear about, a ring past the triangulator's 10,000-vertex cap or self-intersecting, warns instead of going quietly hollow; a genuinely degenerate ring stays quiet; a patch whose every mark would be invisible still leaves one entry for the handle to stand on; and the output-scale round trip does not cost a small patch at a large offset its area. Existing tests that used the always-emitted outline as their observation surface now ask for one explicitly with edgecolor=. --- tests/pyplot/test_launch_compat.py | 170 ++++++++++++++++++++++++++++- 1 file changed, 165 insertions(+), 5 deletions(-) diff --git a/tests/pyplot/test_launch_compat.py b/tests/pyplot/test_launch_compat.py index 2ef06722..a4f7ec27 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 @@ -144,7 +145,9 @@ def test_added_rectangle_patch_fills_with_its_own_face_color() -> None: _fig, ax = plt.subplots() ax.add_patch(Rectangle((0, 0), 2, 1, facecolor="tab:blue")) meshes, edges = _patch_marks(ax) - assert len(meshes) == 1 and len(edges) == 1 + # 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) @@ -214,7 +217,7 @@ def test_removing_a_filled_patch_takes_its_outline_with_it() -> None: from matplotlib.patches import Rectangle _fig, ax = plt.subplots() - artist = ax.add_patch(Rectangle((0, 0), 2, 1, facecolor="tab:blue")) + artist = ax.add_patch(Rectangle((0, 0), 2, 1, facecolor="tab:blue", edgecolor="red")) assert len(ax._entries) == 2 artist.remove() assert ax._entries == [] @@ -328,7 +331,7 @@ def test_patch_at_genomic_coordinates_keeps_every_vertex() -> None: from matplotlib.patches import Rectangle _fig, ax = plt.subplots() - ax.add_patch(Rectangle((1e9, 0.0), 5000.0, 10.0, facecolor="tab:blue")) + 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. @@ -341,7 +344,7 @@ def test_full_disc_wedge_fills_despite_its_repeated_vertex() -> None: from matplotlib.patches import Wedge _fig, ax = plt.subplots() - ax.add_patch(Wedge((0, 0), 1.0, 0.0, 360.0, facecolor="tab:blue")) + 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. @@ -355,7 +358,11 @@ def test_tightly_spaced_but_distinct_vertices_survive() -> None: 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")) + 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. @@ -383,6 +390,159 @@ def test_patch_without_a_path_or_rectangle_getters_raises() -> None: 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_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) From b035e8d4012c7a4ac197e1112541be45d8be5538 Mon Sep 17 00:00:00 2001 From: Alek Petuskey Date: Thu, 30 Jul 2026 12:11:36 -0700 Subject: [PATCH 10/13] Own every patch mark, drop unseen outlines, flatten curves at output scale Four review fixes on the add_patch rework. The Artist base gains a _companion_entries hook, and set_visible, set_alpha, set_color, and set_transform now run over it, so a handle standing for several marks moves all of them. Patch feeds its outline entries through the hook; remove and set_zorder already did this by hand. set_color paints face and edge alike, matching matplotlib's Patch.set_color. _patch_edge_color mirrors _patch_fill_color: an edge that paints nothing, which is matplotlib's default on a filled patch, emits no outline mark. That was a fully transparent segments mark per ring, a third of the exported payload for a figure of default rectangles. A patch that drew no body still emits its outline so the handle has an entry to stand on; a width of zero strokes no pixels, as in matplotlib. _refine_at_pixel_scale re-flattens the path as though the patch filled the figure, then undoes the scale. to_polygons subdivides until flat in the coordinates it is handed, so flattening straight through the patch transform took its tessellation from the numeric magnitude of the data: Circle(radius=1) was a visible 16-gon overshooting the true radius by 2.5% while the same circle at radius=1000 was smooth. The scale is anchored at the patch's own corner because the round trip costs relative precision, which a small patch at a large offset has little of to spare. Curved patches now carry ~10x the triangles they did; straight-edged patches are unchanged. Applying the affine to the control points before rebuilding the path is exact, and avoids the matplotlib import the shim forbids. The fill loop filters non-finite vertices first, as Axes.fill does, skips true degenerates silently, and warns with the reason for anything else the triangulator rejects: its 10,000-vertex cap and self-intersection both fell into a bare except ValueError that read as a deliberate hollow style. The closing-vertex test now uses the ring-relative tolerance _without_repeats established rather than magnitude-relative np.allclose. Docs no longer claim the flattening matches matplotlib's renderers; it resolves at the figure's pixel size before the view is known, which is a different mechanism with the same visual result. --- CHANGELOG.md | 16 ++- python/xy/pyplot/_artists.py | 62 ++++++++--- python/xy/pyplot/_axes.py | 164 ++++++++++++++++++++++------ spec/matplotlib/compat-changelog.md | 38 +++++-- spec/matplotlib/compat.md | 15 ++- 5 files changed, 230 insertions(+), 65 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8cbb7de3..7a84ed6a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,15 +24,25 @@ in the README). 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 Bezier control points. Unfilled patches stay + the curve rather than its Bezier 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. + 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. + 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 diff --git a/python/xy/pyplot/_artists.py b/python/xy/pyplot/_artists.py index 7bcdf396..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: @@ -941,7 +966,13 @@ def get_data(self) -> tuple[Any, Any, Any]: class Patch(Artist): - """Handle for ``add_patch`` output, owning the outline marks beside the fill.""" + """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, @@ -952,6 +983,9 @@ def __init__( 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) diff --git a/python/xy/pyplot/_axes.py b/python/xy/pyplot/_axes.py index 0cc397ef..4a1367f5 100644 --- a/python/xy/pyplot/_axes.py +++ b/python/xy/pyplot/_axes.py @@ -56,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, @@ -1130,20 +1130,74 @@ def _without_repeats(ring: np.ndarray) -> np.ndarray: return ring[np.r_[True, np.hypot(*(ring[1:] - ring[:-1]).T) > span * 1e-12]] -def _patch_outline(patch: Any) -> list[np.ndarray]: +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 Bezier until it is flat in the coordinates it + is handed, so flattening straight 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 Bezier'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_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`` is what matplotlib's own renderers use, so a rotated - Rectangle, a Circle, and a Wedge all come back as real geometry. Ducks - without it fall back to raw vertices, which drop curvature and rotation. + ``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 Bezier control points. `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] = [] if get_path is not None and get_transform is not None: - to_polygons = getattr(get_path(), "to_polygons", None) + path = get_path() + to_polygons = getattr(path, "to_polygons", None) if to_polygons is not None: - rings = list(to_polygons(get_transform())) + transform = get_transform() + 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 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()) @@ -1181,11 +1235,8 @@ def _rings_are_nested(outline: list[np.ndarray]) -> bool: return False -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 - color = getattr(patch, "get_facecolor", lambda: None)() +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]): @@ -1193,6 +1244,24 @@ def _patch_fill_color(patch: Any) -> Any: 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 @@ -5654,11 +5723,12 @@ def add_patch(self, patch: Any) -> Artist: 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 draws its edge as line segments. 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 it takes the outline with the fill. Unsupported - patch types raise. + 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() @@ -5681,21 +5751,42 @@ def add_patch(self, patch: Any) -> Artist: ) from xy import kernels - outline = _patch_outline(patch) + canvas = rc_figsize_px(self.figure._figsize, self.figure._dpi) + outline = _patch_outline(patch, pixels=float(max(canvas))) face = _patch_fill_color(patch) - edge = getattr(patch, "get_edgecolor", lambda: "#000000")() + 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] - if len(xv) > 2 and np.allclose((xv[0], yv[0]), (xv[-1], yv[-1])): - xv, yv = xv[:-1], yv[:-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: - # A zero-area or self-intersecting ring has no triangulation; - # the outline pass below still draws it. + 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( @@ -5708,17 +5799,22 @@ def add_patch(self, patch: Any) -> Artist: }, ) ) - 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": resolve_color(edge), "width": width}, - }, + # 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: diff --git a/spec/matplotlib/compat-changelog.md b/spec/matplotlib/compat-changelog.md index a692b647..616c099f 100644 --- a/spec/matplotlib/compat-changelog.md +++ b/spec/matplotlib/compat-changelog.md @@ -12,16 +12,34 @@ which covers user-visible releases across the whole package. 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(patch.get_patch_transform())`, - the same flattening Matplotlib's own renderers use. `Rectangle(angle=...)` - keeps its rotation, and curved patches use the curve rather than its Bezier - control points: `Circle(radius=1)` covers 3.139 rather than 3.251, and +- 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 Bezier 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. Degenerate rings with no - triangulation, such as a zero-height `Rectangle`, draw their edge and skip - the fill rather than raising. + 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. - 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 @@ -31,8 +49,10 @@ which covers user-visible releases across the whole package. 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()` and `set_zorder()` move the - whole patch instead of only its body. + 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) diff --git a/spec/matplotlib/compat.md b/spec/matplotlib/compat.md index 48fd98f0..4e2f4cc5 100644 --- a/spec/matplotlib/compat.md +++ b/spec/matplotlib/compat.md @@ -115,11 +115,16 @@ width with a fixed corner radius per box style (5 px for `round`, 8 px for against Matplotlib 3.11.1 at 10 pt, `round` is 4.17 px there against 5 px 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 the same straight -segments Matplotlib's renderers use rather than an exact analytic curve. A -patch whose path has nested rings is the one case `add_patch` declines rather -than approximates: hole support is not implemented, so it draws its outlines -and skips the fill instead of painting the hole solid. +`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. 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 From 9a1036c18efb32c9d6f6903b9e7c8653ad9a5793 Mon Sep 17 00:00:00 2001 From: Alek Petuskey Date: Thu, 30 Jul 2026 12:50:52 -0700 Subject: [PATCH 11/13] =?UTF-8?q?Say=20cubic=20B=C3=A9zier=20where=20the?= =?UTF-8?q?=20comments=20mean=20it?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The curves these comments describe — Circle, Ellipse, Wedge, Arc paths — are CURVE4 segments, and the repo writes Bézier with its accent everywhere else. --- CHANGELOG.md | 2 +- python/xy/pyplot/_axes.py | 12 ++++++------ spec/matplotlib/compat-changelog.md | 2 +- tests/pyplot/test_launch_compat.py | 2 +- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a84ed6a..eb4bffca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,7 +24,7 @@ in the README). 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 Bezier control points. The curve is flattened at + 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 diff --git a/python/xy/pyplot/_axes.py b/python/xy/pyplot/_axes.py index 4a1367f5..91245e12 100644 --- a/python/xy/pyplot/_axes.py +++ b/python/xy/pyplot/_axes.py @@ -1133,9 +1133,9 @@ def _without_repeats(ring: np.ndarray) -> np.ndarray: 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 Bezier until it is flat in the coordinates it - is handed, so flattening straight through the patch transform takes its - tessellation from the *numeric magnitude* of the data. `Circle(radius=1)` + ``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 @@ -1149,8 +1149,8 @@ def _refine_at_pixel_scale(path: Any, transform: Any, rings: list[Any], pixels: 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 Bezier's control points to the control points of - the mapped curve, and patch transforms are affine. + 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 @@ -1178,7 +1178,7 @@ def _patch_outline(patch: Any, pixels: float = 1024.0) -> list[np.ndarray]: ``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 Bezier control points. `pixels` is + back as real geometry rather than as cubic-Bézier control points. `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 diff --git a/spec/matplotlib/compat-changelog.md b/spec/matplotlib/compat-changelog.md index 616c099f..f1455d08 100644 --- a/spec/matplotlib/compat-changelog.md +++ b/spec/matplotlib/compat-changelog.md @@ -14,7 +14,7 @@ which covers user-visible releases across the whole package. 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 Bezier control points: at the default figure size + 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 diff --git a/tests/pyplot/test_launch_compat.py b/tests/pyplot/test_launch_compat.py index a4f7ec27..f6c3bfa1 100644 --- a/tests/pyplot/test_launch_compat.py +++ b/tests/pyplot/test_launch_compat.py @@ -174,7 +174,7 @@ def test_added_ellipse_flattens_its_curve_under_the_patch_transform() -> None: _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 Bezier control points without the transform give 3.2509. + # 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) From 18a592f1801e27f1253c43455ac69750e6d5ae67 Mon Sep 17 00:00:00 2001 From: Alek Petuskey Date: Thu, 30 Jul 2026 13:00:39 -0700 Subject: [PATCH 12/13] Carry the artist transform, require whole-ring containment for holes Two cubic review findings confirmed and fixed, one documented, one already settled in the thread. Rectangle(..., transform=Affine2D().rotate_deg(45)) silently dropped its rotation, because flattening went through get_patch_transform alone. matplotlib's Patch.get_transform composes the artist transform in, so when one is set and composes, that is what the flattening uses. xy's own transform objects will not compose inside matplotlib: transData is the identity and is accepted as the no-op it is, a data-space affine applies to the flattened rings afterwards, and transAxes/transFigure raise NotImplementedError with the same message _transform_points gives every other data artist, since baked fractions go silently stale on the next limit change. _rings_are_nested tested one vertex, so rings that merely overlap read as nested and the whole patch went hollow. Nested now means every vertex of one ring inside another. Overlapping rings fill ring-by-ring, their union, where matplotlib's even-odd rule would leave the intersection unpainted; painting what each ring alone would have painted errs on the visible side. The Wedge annulus still abstains, since its inner ring is wholly inside its outer. The tessellation-staleness finding is real and documented instead of fixed: geometry is built when the patch is added, so a later figure resize, DPI change, or deep zoom reuses the tessellation chosen then. Re-flattening at materialization is an architecture change, not a review fix. The hole-fill finding was already deliberately deferred in the review thread, with the abstention documented in compat.md. --- python/xy/pyplot/_axes.py | 59 +++++++++++++++++++++++++---- spec/matplotlib/compat-changelog.md | 15 ++++++-- spec/matplotlib/compat.md | 7 +++- tests/pyplot/test_launch_compat.py | 50 ++++++++++++++++++++++++ 4 files changed, 119 insertions(+), 12 deletions(-) diff --git a/python/xy/pyplot/_axes.py b/python/xy/pyplot/_axes.py index 91245e12..1a3e38c3 100644 --- a/python/xy/pyplot/_axes.py +++ b/python/xy/pyplot/_axes.py @@ -1173,12 +1173,47 @@ def _refine_at_pixel_scale(path: Any, transform: Any, rings: list[Any], pixels: 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. `pixels` is + 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 @@ -1187,17 +1222,20 @@ def _patch_outline(patch: Any, pixels: float = 1024.0) -> list[np.ndarray]: 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 = get_transform() + 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()) @@ -1211,26 +1249,31 @@ def _patch_outline(patch: Any, pixels: float = 1024.0) -> list[np.ndarray]: def _rings_are_nested(outline: list[np.ndarray]) -> bool: - """True when one ring sits inside another, so the patch's path has holes. + """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 - first_row = np.zeros(1, dtype=np.uint32) 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[:1, 0], ring[:1, 1], first_row, other[:, 0], other[:, 1] - ) - if len(inside): + inside = kernels.polygon_select(ring[:, 0], ring[:, 1], rows, other[:, 0], other[:, 1]) + if len(inside) == len(ring): return True return False diff --git a/spec/matplotlib/compat-changelog.md b/spec/matplotlib/compat-changelog.md index f1455d08..b6485de9 100644 --- a/spec/matplotlib/compat-changelog.md +++ b/spec/matplotlib/compat-changelog.md @@ -40,14 +40,23 @@ which covers user-visible releases across the whole package. `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. - Rings that merely 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. + 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 diff --git a/spec/matplotlib/compat.md b/spec/matplotlib/compat.md index 4e2f4cc5..027e2d3c 100644 --- a/spec/matplotlib/compat.md +++ b/spec/matplotlib/compat.md @@ -120,7 +120,12 @@ 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. Two cases `add_patch` declines +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 diff --git a/tests/pyplot/test_launch_compat.py b/tests/pyplot/test_launch_compat.py index f6c3bfa1..fc1beb51 100644 --- a/tests/pyplot/test_launch_compat.py +++ b/tests/pyplot/test_launch_compat.py @@ -503,6 +503,56 @@ def test_transforming_a_filled_patch_moves_its_outline_too() -> None: 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 From cf4f9a6ffe453f36070b19040a18b964fd454611 Mon Sep 17 00:00:00 2001 From: Alek Petuskey Date: Thu, 30 Jul 2026 13:08:05 -0700 Subject: [PATCH 13/13] =?UTF-8?q?Unhyphenate=20cubic=20B=C3=A9zier?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hyphen crept in as a compound modifier; the term is written open in the graphics literature and in this repo's own prior usage. --- CHANGELOG.md | 2 +- python/xy/pyplot/_axes.py | 2 +- spec/matplotlib/compat-changelog.md | 2 +- tests/pyplot/test_launch_compat.py | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index eb4bffca..0de20213 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,7 +24,7 @@ in the README). 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 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 diff --git a/python/xy/pyplot/_axes.py b/python/xy/pyplot/_axes.py index 1a3e38c3..189e5aa9 100644 --- a/python/xy/pyplot/_axes.py +++ b/python/xy/pyplot/_axes.py @@ -1212,7 +1212,7 @@ def _patch_outline(patch: Any, pixels: float = 1024.0) -> list[np.ndarray]: ``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 + 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. diff --git a/spec/matplotlib/compat-changelog.md b/spec/matplotlib/compat-changelog.md index b6485de9..58a8a520 100644 --- a/spec/matplotlib/compat-changelog.md +++ b/spec/matplotlib/compat-changelog.md @@ -14,7 +14,7 @@ which covers user-visible releases across the whole package. 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 + 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 diff --git a/tests/pyplot/test_launch_compat.py b/tests/pyplot/test_launch_compat.py index fc1beb51..538243be 100644 --- a/tests/pyplot/test_launch_compat.py +++ b/tests/pyplot/test_launch_compat.py @@ -174,7 +174,7 @@ def test_added_ellipse_flattens_its_curve_under_the_patch_transform() -> None: _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. + # 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)