Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,31 @@ in the README).
structural invariants remain hard gates, while the paired CodSpeed rows
continue to track shim overhead.

### Fixed
- `Axes.add_patch` rendered every patch as a hollow outline and dropped both
rotation and curvature. Patches now fill in their own face color, and their
geometry comes from `Path.to_polygons` with the patch transform applied, so
`Rectangle(angle=...)` keeps its rotation and `Circle`/`Ellipse`/`Wedge` use
the curve rather than its cubic Bézier control points. The curve is flattened at
the figure's pixel size rather than in data units, so a `Circle(radius=1)`
is as round as the same circle drawn as `radius=1000`. Unfilled patches stay
edge-only, the axes color cycle is untouched, and a degenerate patch draws
its edge instead of raising. A patch whose path has nested rings draws its
outlines and skips the fill, since hole triangulation is not implemented and
filling every ring would paint the hole solid. A ring that has a body but no
triangulation, self-intersecting or past the triangulator's vertex cap,
draws its outline and warns rather than going quietly hollow.
- Patch outlines were stroked at a fixed one pixel that ignored both the
patch's line width and the figure DPI. They now use the patch's own
`linewidth`, converted from Matplotlib points into output pixels like every
other stroke in the shim, and a patch whose edge paints nothing — the
Matplotlib default on a filled patch — no longer emits an invisible outline
mark per ring.
- The handle `add_patch` returns now owns every mark the patch produced, so
`remove`, `set_zorder`, `set_visible`, `set_alpha`, `set_color` and
`set_transform` move the whole patch. Previously they reached only the fill,
and a hidden patch still drew its outline.

## [0.0.4] - 2026-07-27

### Added
Expand Down
84 changes: 71 additions & 13 deletions python/xy/pyplot/_artists.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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:
Expand All @@ -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:
Expand Down Expand Up @@ -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)),
Expand All @@ -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"])
Expand All @@ -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:
Expand Down Expand Up @@ -940,6 +965,39 @@ def get_data(self) -> tuple[Any, Any, Any]:
)


class Patch(Artist):
"""Handle for ``add_patch`` output, owning the outline marks beside the fill.

A patch can reach the spec as several marks — one fill per ring and one
outline per ring — so every mutation that means "the whole patch" runs
over `_companion_entries` rather than over `_entry` alone. Without that,
`set_visible(False)` would hide the body and leave the outline drawn.
"""

def __init__(
self,
axes: Any,
entry: dict[str, Any],
outline_entries: list[dict[str, Any]] | None = None,
) -> None:
super().__init__(axes, entry)
self._outline_entries = list(outline_entries or [])

def _companion_entries(self) -> list[dict[str, Any]]:
return self._outline_entries

def remove(self) -> None:
for entry in self._outline_entries:
self._axes._remove_entry(entry)
self._outline_entries.clear()
super().remove()

def set_zorder(self, level: float) -> None:
for entry in self._outline_entries:
entry["_zorder"] = float(level)
super().set_zorder(level)


class StemContainer:
"""Small tuple-compatible analogue of matplotlib's StemContainer."""

Expand Down
Loading
Loading