55import math
66from typing import Any
77
8- import matplotlib .patches as mpatches
98import matplotlib .path as mpath
109import numpy as np
1110import pandas as pd
1211import shapely
1312from geopandas import GeoDataFrame
14- from matplotlib .collections import PatchCollection
13+ from matplotlib .collections import PathCollection
1514from matplotlib .colors import ColorConverter
1615from scipy .spatial import ConvexHull
1716from shapely .errors import GEOSException
2120from spatialdata_plot .pl .utils import _extract_scalar_value
2221
2322
24- def _get_centroid_of_pathpatch (pathpatch : mpatches .PathPatch ) -> tuple [float , float ]:
25- # Extract the vertices from the PathPatch
26- path = pathpatch .get_path ()
23+ def _get_centroid_of_path (path : mpath .Path ) -> tuple [float , float ]:
2724 vertices = path .vertices
2825 x = vertices [:, 0 ]
2926 y = vertices [:, 1 ]
@@ -37,12 +34,10 @@ def _get_centroid_of_pathpatch(pathpatch: mpatches.PathPatch) -> tuple[float, fl
3734 return centroid_x , centroid_y
3835
3936
40- def _scale_pathpatch_around_centroid ( pathpatch : mpatches . PathPatch , scale_factor : float ) -> None :
37+ def _scale_path_around_centroid ( path : mpath . Path , scale_factor : float ) -> None :
4138 scale_value = _extract_scalar_value (scale_factor , default = 1.0 )
42- centroid = _get_centroid_of_pathpatch (pathpatch )
43- vertices = pathpatch .get_path ().vertices
44- scaled_vertices = np .array ([centroid + (vertex - centroid ) * scale_value for vertex in vertices ])
45- pathpatch .get_path ().vertices = scaled_vertices
39+ centroid = np .asarray (_get_centroid_of_path (path ))
40+ path .vertices = centroid + (path .vertices - centroid ) * scale_value
4641
4742
4843def _normalize_geom (geom : Any ) -> Any :
@@ -67,16 +62,16 @@ def _normalize_geom(geom: Any) -> Any:
6762 return geom
6863
6964
70- def _make_patch_from_multipolygon (mp : shapely .MultiPolygon ) -> list [mpatches . PathPatch ]:
65+ def _make_paths_from_multipolygon (mp : shapely .MultiPolygon ) -> list [mpath . Path ]:
7166 """
72- Create PathPatches from a MultiPolygon, preserving holes robustly.
67+ Create matplotlib ``Path``s from a MultiPolygon, preserving holes robustly.
7368
7469 This follows the same strategy as GeoPandas' internal Polygon plotting:
7570 each (multi)polygon part becomes a compound Path composed of the exterior
7671 ring and all interior rings. Orientation is handled by prior geometry
7772 normalization rather than manual ring reversal.
7873 """
79- patches : list [mpatches . PathPatch ] = []
74+ paths : list [mpath . Path ] = []
8075
8176 for poly in mp .geoms :
8277 if poly .is_empty :
@@ -88,36 +83,38 @@ def _make_patch_from_multipolygon(mp: shapely.MultiPolygon) -> list[mpatches.Pat
8883
8984 if len (interiors ) == 0 :
9085 # Simple polygon without holes
91- patches .append (mpatches . Polygon (exterior , closed = True ))
86+ paths .append (mpath . Path (exterior , closed = True ))
9287 continue
9388
9489 # Build a compound path: exterior + all interior rings
95- compound_path = mpath .Path .make_compound_path (
96- mpath .Path (exterior , closed = True ),
97- * [mpath .Path (ring , closed = True ) for ring in interiors ],
90+ paths .append (
91+ mpath .Path .make_compound_path (
92+ mpath .Path (exterior , closed = True ),
93+ * [mpath .Path (ring , closed = True ) for ring in interiors ],
94+ )
9895 )
99- patches .append (mpatches .PathPatch (compound_path ))
10096
101- return patches
97+ return paths
10298
10399
104- def _build_shape_patches (
100+ def _build_shape_paths (
105101 shapes : GeoDataFrame ,
106102 scale : float ,
107- ) -> tuple [list [mpatches . Patch ], list [int ], int ]:
108- """Build matplotlib patches from shape geometries, once.
103+ ) -> tuple [list [mpath . Path ], list [int ], int ]:
104+ """Build matplotlib ``Path``s from shape geometries, once.
109105
110- Patch geometry is independent of colour/alpha, so it can be built a single time and
111- shared across the fill and outline ``PatchCollection``s in :func:`_render_shapes`
112- instead of being rebuilt per layer (the dominant cost for shape elements).
106+ Geometry is independent of colour/alpha, so it is built a single time and shared across the fill and
107+ outline ``PathCollection``s in :func:`_render_shapes`. Using ``Path`` objects (the form a
108+ ``PatchCollection`` bakes internally anyway) and a ``PathCollection`` avoids constructing one
109+ ``matplotlib.patches.*`` object per shape — the dominant cost for large shape elements.
113110
114111 Returns
115112 -------
116- patches
117- The matplotlib patches (a MultiPolygon expands to several patches ).
118- patch_row_idx
119- For each patch , the index into the empty-filtered, re-indexed shapes — used to
120- look up the per-shape colour.
113+ paths
114+ The matplotlib ``Path``s (a MultiPolygon expands to several paths ).
115+ row_idx
116+ For each path , the index into the empty-filtered, re-indexed shapes — used to look up the
117+ per-shape colour.
121118 n_shapes
122119 Number of shapes after empty filtering (used for the single-colour broadcast rule).
123120 """
@@ -138,28 +135,32 @@ def _build_shape_patches(
138135
139136 # Resolve the scale scalar once instead of per shape.
140137 scale_value = _extract_scalar_value (scale , default = 1.0 )
138+ unit_circle = mpath .Path .unit_circle ()
141139
142- patches : list [mpatches . Patch ] = []
143- patch_row_idx : list [int ] = []
140+ paths : list [mpath . Path ] = []
141+ row_idx : list [int ] = []
144142 for i , geom in enumerate (geoms ):
145143 geom_type = geom .geom_type
146144 if geom_type == "Polygon" :
147145 coords = np .asarray (geom .exterior .coords )
148146 centroid = np .mean (coords , axis = 0 )
149147 scaled = centroid + (coords - centroid ) * scale_value
150- patches .append (mpatches . Polygon (scaled , closed = True ))
151- patch_row_idx .append (i )
148+ paths .append (mpath . Path (scaled , closed = True ))
149+ row_idx .append (i )
152150 elif geom_type == "MultiPolygon" :
153- for m in _make_patch_from_multipolygon (geom ):
154- _scale_pathpatch_around_centroid ( m , scale_value )
155- patches .append (m )
156- patch_row_idx .append (i )
151+ for p in _make_paths_from_multipolygon (geom ):
152+ _scale_path_around_centroid ( p , scale_value )
153+ paths .append (p )
154+ row_idx .append (i )
157155 elif geom_type == "Point" :
158156 radius_value = _extract_scalar_value (radii [i ], default = 0.0 ) if radii is not None else 0.0
159- patches .append (mpatches .Circle ((geom .x , geom .y ), radius = radius_value * scale_value ))
160- patch_row_idx .append (i )
157+ # unit circle scaled by radius and translated to the centre — identical to the path a
158+ # PatchCollection bakes from mpatches.Circle((x, y), radius).
159+ verts = unit_circle .vertices * (radius_value * scale_value ) + (geom .x , geom .y )
160+ paths .append (mpath .Path (verts , unit_circle .codes ))
161+ row_idx .append (i )
161162
162- return patches , patch_row_idx , len (geoms )
163+ return paths , row_idx , len (geoms )
163164
164165
165166def _get_collection_shape (
@@ -171,10 +172,10 @@ def _get_collection_shape(
171172 outline_alpha : None | float = None ,
172173 outline_color : None | str | list [float ] | np .ndarray = "white" ,
173174 linewidth : float = 0.0 ,
174- prebuilt_patches : tuple [list [mpatches . Patch ], list [int ], int ] | None = None ,
175+ prebuilt_paths : tuple [list [mpath . Path ], list [int ], int ] | None = None ,
175176 ** kwargs : Any ,
176- ) -> PatchCollection :
177- """Build a PatchCollection for shapes.
177+ ) -> PathCollection :
178+ """Build a PathCollection for shapes.
178179
179180 ``c`` is the per-row fill: an ``(N, 4)`` RGBA array (from :meth:`ColorSpec.to_rgba`) or a single
180181 color / list of color specs (broadcast). ``outline_color`` may be an ``(N, 4)`` float RGBA array,
@@ -208,26 +209,24 @@ def _get_collection_shape(
208209 else :
209210 outline_c = [None ] * fill_c .shape [0 ]
210211
211- # Build (or reuse) the matplotlib patches . Geometry is colour-independent, so the
212- # caller can build it once via `_build_shape_patches ` and share it across the fill
212+ # Build (or reuse) the matplotlib paths . Geometry is colour-independent, so the
213+ # caller can build it once via `_build_shape_paths ` and share it across the fill
213214 # and outline collections instead of rebuilding it on every call.
214- patches , patch_row_idx , n_shapes = (
215- prebuilt_patches if prebuilt_patches is not None else _build_shape_patches (shapes , s )
216- )
215+ paths , row_idx , n_shapes = prebuilt_paths if prebuilt_paths is not None else _build_shape_paths (shapes , s )
217216
218- if not patches :
219- return PatchCollection ([])
217+ if not paths :
218+ return PathCollection ([])
220219
221- # Expand the per-shape fill colours to per-patch (a MultiPolygon owns several
222- # patches ). Preserve the single-colour broadcast used for multi-shape elements.
220+ # Expand the per-shape fill colours to per-path (a MultiPolygon owns several
221+ # paths ). Preserve the single-colour broadcast used for multi-shape elements.
223222 broadcast_single = n_shapes > 1 and len (fill_c ) == 1
224- patch_fill = np .repeat (fill_c , len (patches ), axis = 0 ) if broadcast_single else fill_c [patch_row_idx ]
223+ path_fill = np .repeat (fill_c , len (paths ), axis = 0 ) if broadcast_single else fill_c [row_idx ]
225224
226- return PatchCollection (
227- patches ,
225+ return PathCollection (
226+ paths ,
228227 snap = False ,
229228 lw = linewidth ,
230- facecolor = patch_fill ,
229+ facecolor = path_fill ,
231230 edgecolor = None if all (o is None for o in outline_c ) else outline_c ,
232231 ** kwargs ,
233232 )
0 commit comments