From 90dce867c34243dfbf87f84d4bfc488e2706463a Mon Sep 17 00:00:00 2001 From: Bruno Beltran Date: Fri, 28 Feb 2020 16:30:31 -0800 Subject: [PATCH 01/33] fix tightbbox to account for markeredgewidth --- lib/matplotlib/lines.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/matplotlib/lines.py b/lib/matplotlib/lines.py index 76c256dfa185..dd9baae803a0 100644 --- a/lib/matplotlib/lines.py +++ b/lib/matplotlib/lines.py @@ -617,7 +617,8 @@ def get_window_extent(self, renderer): ignore=True) # correct for marker size, if any if self._marker: - ms = (self._markersize / 72.0 * self.figure.dpi) * 0.5 + extra_pts = self._markersize + self._markeredgewidth + ms = (extra_pts / 72.0 * self.figure.dpi) * 0.5 bbox = bbox.padded(ms) return bbox From 5ed49fd48da1af544e034e01688a83dd250a4b94 Mon Sep 17 00:00:00 2001 From: Bruno Beltran Date: Sun, 1 Mar 2020 13:52:02 -0800 Subject: [PATCH 02/33] cleanup definition of "point" marker --- lib/matplotlib/markers.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/lib/matplotlib/markers.py b/lib/matplotlib/markers.py index bab0d4a600b8..8878641d62b9 100644 --- a/lib/matplotlib/markers.py +++ b/lib/matplotlib/markers.py @@ -198,9 +198,6 @@ class MarkerStyle: fillstyles = ('full', 'left', 'right', 'bottom', 'top', 'none') _half_fillstyles = ('left', 'right', 'bottom', 'top') - # TODO: Is this ever used as a non-constant? - _point_size_reduction = 0.5 - def __init__(self, marker=None, fillstyle=None): """ Attributes @@ -408,7 +405,8 @@ def _set_pixel(self): self._snap_threshold = None def _set_point(self): - self._set_circle(reduction=self._point_size_reduction) + # a "point" is defined to a circle with half the requested markersize + self._set_circle(reduction=0.5) _triangle_path = Path( [[0.0, 1.0], [-1.0, -1.0], [1.0, -1.0], [0.0, 1.0]], From 9088cf4fa38940fe8a1769bd6cccb80153f62b51 Mon Sep 17 00:00:00 2001 From: Bruno Beltran Date: Sun, 1 Mar 2020 15:05:46 -0800 Subject: [PATCH 03/33] document unit_regular_polygon --- lib/matplotlib/path.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/matplotlib/path.py b/lib/matplotlib/path.py index 73bc8f25ff2e..acb040594784 100644 --- a/lib/matplotlib/path.py +++ b/lib/matplotlib/path.py @@ -649,7 +649,8 @@ def unit_rectangle(cls): def unit_regular_polygon(cls, numVertices): """ Return a :class:`Path` instance for a unit regular polygon with the - given *numVertices* and radius of 1.0, centered at (0, 0). + given *numVertices* such that the circumscribing circle has radius 1.0, + centered at (0, 0). """ if numVertices <= 16: path = cls._unit_regular_polygons.get(numVertices) From fd6df9b5b2d5816d44c1b3b9444a0391fc6576c4 Mon Sep 17 00:00:00 2001 From: Bruno Beltran Date: Tue, 3 Mar 2020 08:41:34 -0800 Subject: [PATCH 04/33] untested version of new code to get marker bbox --- lib/matplotlib/lines.py | 9 +- lib/matplotlib/markers.py | 263 +++++++++++++++++++++++++++++++++++++- 2 files changed, 267 insertions(+), 5 deletions(-) diff --git a/lib/matplotlib/lines.py b/lib/matplotlib/lines.py index dd9baae803a0..8a1777f25f92 100644 --- a/lib/matplotlib/lines.py +++ b/lib/matplotlib/lines.py @@ -14,7 +14,7 @@ from .artist import Artist, allow_rasterization from .cbook import ( _to_unmasked_float_array, ls_mapper, ls_mapper_r, STEP_LOOKUP_MAP) -from .markers import MarkerStyle +from .markers import MarkerStyle, marker_bbox from .path import Path from .transforms import ( Affine2D, Bbox, BboxTransformFrom, BboxTransformTo, TransformedPath) @@ -617,9 +617,10 @@ def get_window_extent(self, renderer): ignore=True) # correct for marker size, if any if self._marker: - extra_pts = self._markersize + self._markeredgewidth - ms = (extra_pts / 72.0 * self.figure.dpi) * 0.5 - bbox = bbox.padded(ms) + m_bbox = marker_bbox(self._marker, self._markersize, + self._markeredgewidth) + # add correct padding to each side of bbox + bbox = Bbox(bbox.get_points() + m_bbox.get_points()) return bbox @Artist.axes.setter diff --git a/lib/matplotlib/markers.py b/lib/matplotlib/markers.py index 8878641d62b9..934f782fe483 100644 --- a/lib/matplotlib/markers.py +++ b/lib/matplotlib/markers.py @@ -128,12 +128,13 @@ """ from collections.abc import Sized +from collections import namedtuple import numpy as np from . import cbook, rcParams from .path import Path -from .transforms import IdentityTransform, Affine2D +from .transforms import IdentityTransform, Affine2D, Bbox # special-purpose marker identifiers: (TICKLEFT, TICKRIGHT, TICKUP, TICKDOWN, @@ -142,6 +143,266 @@ _empty_path = Path(np.empty((0, 2))) +# hold info to track how marker size scales with increased "edge" thickness +PathEndAngle = namedtuple('PathEndAngle', 'incidence_angle corner_angle') +r"""Used to have a universal way to account for how much the bounding box of a +shape will grow as we increase its `markeredgewidth`. + +Attributes +---------- + `incidence_angle` : float + the angle that the corner bisector makes with the box edge (where + top/bottom box edges are horizontal, left/right box edges are + vertical). + `corner_angle` : float + the internal angle of the corner, where np.pi is a straight line, and 0 + is retracing exactly the way you came. None can be used to signify that + the line ends there (i.e. no corner). + +Notes +----- +$\pi$ and 0 are equivalent for `corner_angle`. Both $\theta$ and $\pi - \theta$ +are equivalent for `incidence_angle` by symmetry.""" + +BoxSides = namedtuple('BoxSides', 'top bottom left right') +"""Easily keep track of same parameter for each of four sides.""" + +# some angles are heavily repeated throughout various markers +_tri_side_angle = np.arctan(2) +_tri_tip_angle = np.arctan(1/2) +# half the edge length of the smaller pentagon over the difference between the +# larger pentagon's circumcribing radius and the smaller pentagon's inscribed +# radius +_star_tip_angle = 2*np.arctan((1/4)*np.sqrt((5 - np.sqrt(5))/2), + 1 - np.sqrt((3 + np.sqrt(5))/32)) +# reusable corner types +_flat_side = PathEndAngle(0, 0) +_normal_line = PathEndAngle(np.pi/2, None) +_normal_right_angle = PathEndAngle(np.pi/2, np.pi/2) +_triangle_side_corner = PathEndAngle(np.pi/2 - tri_side_angle/2, tri_side_angle) +_triangle_tip = PathEndAngle(np.pi/2, tri_tip_angle) +# and some entire box side behaviors are repeated among markers +_effective_square = BoxSides(_flat_side, _flat_side, _flat_side, _flat_side) +_effective_diamond = BoxSides(_normal_right_angle, _normal_right_angle, + _normal_right_angle, _normal_right_angle) + +# precomputed information required for marker_bbox (besides _joinstyle) +_edge_angles = { + '.': _effective_square, + ',': _effective_square, + 'o': _effective_square, + # hit two corners and tip bisects one side of unit square + 'v': BoxSides(_flat_side, _triangle_tip, _triangle_side_corner, _triangle_side_corner), + '^': BoxSides(_triangle_tip, _flat_side, _triangle_side_corner, _triangle_side_corner), + '<': BoxSides(_triangle_side_corner, _triangle_side_corner, _triangle_tip, _flat_side), + '>': BoxSides(_triangle_side_corner, _triangle_side_corner, _flat_side, _triangle_tip), + # angle bisectors of an equilateral triangle. lines of length 1/2 + '1': BoxSides(PathEndAngle(np.pi/6, None), PathEndAngle(np.pi/2, None), + PathEndAngle(np.pi/3, None), PathEndAngle(np.pi/3, None)), + '2': BoxSides(PathEndAngle(np.pi/2, None), PathEndAngle(np.pi/6, None), + PathEndAngle(np.pi/3, None), PathEndAngle(np.pi/3, None)), + '3': BoxSides(PathEndAngle(np.pi/3, None), PathEndAngle(np.pi/3, None), + PathEndAngle(np.pi/2, None), PathEndAngle(np.pi/6, None)), + '4': BoxSides(PathEndAngle(np.pi/3, None), PathEndAngle(np.pi/3, None), + PathEndAngle(np.pi/6, None), PathEndAngle(np.pi/2, None)), + # regular polygons, circumscribed in circle of radius 1. + '8': _effective_square, + 's': _effective_square, + 'p': BoxSides(PathEndAngle(np.pi/2, 3*np.pi/5), _flat_side, + PathEndAngle(2*np.pi/5, 3*np.pi/5), + PathEndAngle(2*np.pi/5, 3*np.pi/5)), + # tips are corners of regular pentagon circuscribed in circle of radius 1. + # so incidence angles are same as pentagon + # interior points are corners of another regular pentagon, whose + # circumscribing circle has radius 0.5, so all tip angles are same + '*': BoxSides(PathEndAngle(np.pi/2, _star_tip_angle), + PathEndAngle(3*np.pi/10, _star_tip_angle), + PathEndAngle(2*np.pi/5, _star_tip_angle), + PathEndAngle(2*np.pi/5, _star_tip_angle)), + 'h': BoxSides(PathEndAngle(np.pi/2, 2*np.pi/3), + PathEndAngle(np.pi/2, 2*np.pi/3), + _flat_side, _flat_side), + 'H': BoxSides(_flat_side, _flat_side, + PathEndAngle(np.pi/2, 2*np.pi/3), + PathEndAngle(np.pi/2, 2*np.pi/3)), + '+': BoxSides(_normal_line, _normal_line, _normal_line, _normal_line), + 'x': BoxSides(PathEndAngle(np.pi/4, None), PathEndAngle(np.pi/4, None), + PathEndAngle(np.pi/4, None), PathEndAngle(np.pi/4, None)), + # unit square rotated pi/2 + 'D': _effective_diamond, + # D scaled by 0.6 in horizontal direction + 'd': BoxSides(PathEndAngle(np.pi/2, 2*np.arctan(3/5)), + PathEndAngle(np.pi/2, 2*np.arctan(3/5)), + PathEndAngle(np.pi/2, 2*np.arctan(5/3)), + PathEndAngle(np.pi/2, 2*np.arctan(5/3))), + '|': BoxSides(_normal_line, _normal_line, _flat_side, _flat_side), + '_': BoxSides(_flat_side, _flat_side, _normal_line, _normal_line), + 'P': _effective_square, + 'X': _effective_diamond, + TICKLEFT: BoxSides(_normal_line, _normal_line, _flat_side, _flat_side), + TICKRIGHT: BoxSides(_normal_line, _normal_line, _flat_side, _flat_side), + TICKUP: BoxSides(_flat_side, _flat_side, _normal_line, _normal_line), + TICKDOWN: BoxSides(_flat_side, _flat_side, _normal_line, _normal_line), + # carets same size as "triangles" but missing the edge opposite their "tip" + CARETLEFT: BoxSides(PathEndAngle(np.pi/2 - tri_side_angle/2, None), + PathEndAngle(np.pi/2 - tri_side_angle/2, None), + _triangle_tip, PathEndAngle(tri_side_angle, None)), + CARETRIGHT: BoxSides(PathEndAngle(np.pi/2 - tri_side_angle/2, None), + PathEndAngle(np.pi/2 - tri_side_angle/2, None), + PathEndAngle(tri_side_angle, None), _triangle_tip), + CARETUP: BoxSides(_triangle_tip, PathEndAngle(tri_side_angle, None), + PathEndAngle(np.pi/2 - tri_side_angle/2, None), + PathEndAngle(np.pi/2 - tri_side_angle/2, None)), + CARETDOWN: BoxSides(PathEndAngle(tri_side_angle, None), _triangle_tip, + PathEndAngle(np.pi/2 - tri_side_angle/2, None), + PathEndAngle(np.pi/2 - tri_side_angle/2, None)), + CARETLEFTBASE: BoxSides(PathEndAngle(np.pi/2 - tri_side_angle/2, None), + PathEndAngle(np.pi/2 - tri_side_angle/2, None), + _triangle_tip, PathEndAngle(tri_side_angle, None)), + CARETRIGHTBASE: BoxSides(PathEndAngle(np.pi/2 - tri_side_angle/2, None), + PathEndAngle(np.pi/2 - tri_side_angle/2, None), + PathEndAngle(tri_side_angle, None), _triangle_tip), + CARETUPBASE: BoxSides(_triangle_tip, PathEndAngle(tri_side_angle, None), + PathEndAngle(np.pi/2 - tri_side_angle/2, None), + PathEndAngle(np.pi/2 - tri_side_angle/2, None)), + CARETDOWNBASE: BoxSides(PathEndAngle(tri_side_angle, None), _triangle_tip, + PathEndAngle(np.pi/2 - tri_side_angle/2, None), + PathEndAngle(np.pi/2 - tri_side_angle/2, None)), +} + +def marker_bbox(marker=None, markerwidth=0, markeredgewidth=0): + """For a given marker style and size parameters, compute the actual extents + of the marker. + + For markers with no edge, this is just the same bbox as that of the + transformed marker path, but how much extra extent is added by an edge is a + function of the angle of the path at its own (the path's own) boundary. + + Parameters + ---------- + marker : matplotlib.markers.MarkerStyle + The marker type, or object that can be used to construct the marker + type. + + markerwidth : float, optional, default: None + "Size" of the marker, in pixels. + + markeredgewidth : float, optional, default: None + Width, in pixels, of the stroke used to create the marker's edge. + + Returns + ------- + + bbox : matplotlib.transforms.Bbox + The extents of the marker including its edge (in pixels) if it were + centered at (0,0). + + Notes + ----- + """ + if type(marker) != MarkerStyle: + marker = MarkerStyle(marker) + marker_symbol = marker.get_marker() + joinstyle = marker.get_joinstyle() + capstyle = marker.get_capstyle() + unit_bbox = marker.get_path().get_extents() + scale = mpl.transforms.Affine2D().scale(markerwidth) + [[left, bottom], [right, top]] = scale.transform(unit_bbox) + left -= _get_padding_due_to_angle(markeredgewidth, + _edge_angles[marker_symbol].left, joinstyle, capstyle) + bottom -= _get_padding_due_to_angle(markeredgewidth, + _edge_angles[marker_symbol].bottom, joinstyle, capstyle) + right += _get_padding_due_to_angle(markeredgewidth, + _edge_angles[marker_symbol].right, joinstyle, capstyle) + top -= _get_padding_due_to_angle(markeredgewidth, + _edge_angles[marker_symbol].top, joinstyle, capstyle) + return Bbox.from_extents(left, bottom, right, top) + +def _get_padding_due_to_angle(width, path_end_angle, joinstyle='miter', + capstyle='butt'): + """How much does adding a stroke with `width` overflow the naive bbox at a + corner described by `path_end_angle`? + + Parameters + ---------- + width : float + `markeredgewidth` used to draw the stroke that we're computing the + overflow of + path_end_angle : PathEndAngle + precomputed property of a corner that allows us to compute the overflow + + Returns + ------- + pad : float + amount of overflow + + """ + phi, theta = path_end_angle.incidence_angle, path_end_angle.corner_angle + # if there's no corner (i.e. the path just ends, as in the "sides" of the + # carets or in the non-fillable markers, we can compute how far the outside + # edge of the markeredge stroke extends outside of the bounding box of its + # path using the law of sines: $\sin(\phi)/(w/2) = \sin(\pi/2 - \phi)/l$ + # for $w$ the `markeredgewidth`, $\phi$ the incidence angle of the line, + # then $l$ is the length along the outer edge of the stroke that extends + # beyond the bouding box. We can translate this to a distance perpendicular + # to the bounding box E(w, \phi) = l \sin(\phi)$, for $l$ as above. + if theta is None: + # also note that in this case, we shouldn't check _joinstyle because + # it's going to be "round" by default but not actually be in use. what + # we care about is _capstyle, which is currently always its default + # "butt" for all markers. if we find otherwise, we should change this + # code to check for the "projecting" and "round" cases + if capstyle != 'butt': + raise NotImplementedError("Only capstyle='butt' currently needed") + pad = (w/2) * np.cos(phi) + # to calculate the offset for _joinstyle == 'miter', imagine aligning the + # corner so that on line comes in along the negative x-axis, and another + # from above, makes an angle $\theta$ with the negative x-axis. + # the tip of the new corner created by the markeredge stroke will be at the + # point where the two outer edge of the markeredge stroke intersect. + # in the orientation described above, the outer edge of the stroke aligned + # with the x axis will obviously have equation $y = -w/2$ where $w$ is the + # markeredgewidth. WLOG, the stroke coming in from above at an angle + # $\theta$ from the negative x-axis will have equation + # $-(\tan(\theta) x + \frac{w}{2\cos(\theta)}$. + # the intersection of these two lines is at $y = w/2$, and we can solve for + # $x = \cot(\theta) (\frac{w}{2} + \frac{w}{2\cos(\theta)})$. + # this puts the "edge" tip a distance $M = (w/2)\sqrt{\csc^2(\theta/2) + 1}$ + # from the tip of the corner itself, on the line defined by the bisector of + # the corner angle. So the extra padding required is $M\sin(\phi)$, where + # $\phi$ is the incidence angle of the corner's bisector + elif joinstyle == 'miter': + pad = (w/2)*np.sin(phi)*np.sqrt(np.csc(theta/2)**2 + 1) + # to calculate the offset for _joinstyle = "bevel", we can start with the + # analogous "miter" corner. the rules for how the "bevel" is + # created in SVG is that the outer edges of the stroke continue up until + # the stroke hits the corner point (similar to _capstyle='butt'). A line is + # then drawn joining these two outer points and the interior is filled. in + # other words, it is the same as a "miter" corner, but with some amount of + # the tip removed (an isoceles triangle with base given by the distance + # described above). This base length (the bevel "size") is given by the law + # of sines $b = (w/2)\frac{\sin(\pi - \theta)}{\sin(\theta/2)}$. + # We can then subtract the height of the isoceles rectangle with this base + # height and tip angle $\theta$ from our result $M$ above to get how far + # the midpoint of the bevel extends beyond the outside.... + + # but that's not what we're interested in. + # a beveled edge is exactly the convex hull of its two composite lines with + # capstyle='butt'. So we just compute the individual lines' incidence + # angles and take the maximum of the two padding values + elif joinstyle == 'bevel': + phi1 = phi + theta/2 + phi2 = phi - theta/2 + pad = (w/2) * max(np.cos(phi1), np.cos(phi2)) + # finally, _joinstyle = "round" is just _joinstyle = "bevel" but with + # a hemispherical cap. we could calculate this but for now no markers use + # it.... + elif joinstyle == 'round': + raise NotImplementedError("Only 'miter' and 'bevel' joinstyles needed for now") + else: + raise ValueError(f"Unknown joinstyle: {joinstyle}") + return pad + class MarkerStyle: From ef2fefd3d0897735980c21a233c8b2e6e55add65 Mon Sep 17 00:00:00 2001 From: Bruno Beltran Date: Tue, 3 Mar 2020 09:53:29 -0800 Subject: [PATCH 05/33] fix for marker bbox now works except for on miter --- lib/matplotlib/markers.py | 97 ++++++++++++++++++++++----------------- 1 file changed, 54 insertions(+), 43 deletions(-) diff --git a/lib/matplotlib/markers.py b/lib/matplotlib/markers.py index 934f782fe483..9a66b50eafe6 100644 --- a/lib/matplotlib/markers.py +++ b/lib/matplotlib/markers.py @@ -173,14 +173,14 @@ # half the edge length of the smaller pentagon over the difference between the # larger pentagon's circumcribing radius and the smaller pentagon's inscribed # radius -_star_tip_angle = 2*np.arctan((1/4)*np.sqrt((5 - np.sqrt(5))/2), - 1 - np.sqrt((3 + np.sqrt(5))/32)) +_star_tip_angle = 2*np.arctan2((1/4)*np.sqrt((5 - np.sqrt(5))/2), + 1 - np.sqrt((3 + np.sqrt(5))/32)) # reusable corner types _flat_side = PathEndAngle(0, 0) _normal_line = PathEndAngle(np.pi/2, None) _normal_right_angle = PathEndAngle(np.pi/2, np.pi/2) -_triangle_side_corner = PathEndAngle(np.pi/2 - tri_side_angle/2, tri_side_angle) -_triangle_tip = PathEndAngle(np.pi/2, tri_tip_angle) +_triangle_side_corner = PathEndAngle(np.pi/2 - _tri_side_angle/2, _tri_side_angle) +_triangle_tip = PathEndAngle(np.pi/2, _tri_tip_angle) # and some entire box side behaviors are repeated among markers _effective_square = BoxSides(_flat_side, _flat_side, _flat_side, _flat_side) _effective_diamond = BoxSides(_normal_right_angle, _normal_right_angle, @@ -244,30 +244,34 @@ TICKUP: BoxSides(_flat_side, _flat_side, _normal_line, _normal_line), TICKDOWN: BoxSides(_flat_side, _flat_side, _normal_line, _normal_line), # carets same size as "triangles" but missing the edge opposite their "tip" - CARETLEFT: BoxSides(PathEndAngle(np.pi/2 - tri_side_angle/2, None), - PathEndAngle(np.pi/2 - tri_side_angle/2, None), - _triangle_tip, PathEndAngle(tri_side_angle, None)), - CARETRIGHT: BoxSides(PathEndAngle(np.pi/2 - tri_side_angle/2, None), - PathEndAngle(np.pi/2 - tri_side_angle/2, None), - PathEndAngle(tri_side_angle, None), _triangle_tip), - CARETUP: BoxSides(_triangle_tip, PathEndAngle(tri_side_angle, None), - PathEndAngle(np.pi/2 - tri_side_angle/2, None), - PathEndAngle(np.pi/2 - tri_side_angle/2, None)), - CARETDOWN: BoxSides(PathEndAngle(tri_side_angle, None), _triangle_tip, - PathEndAngle(np.pi/2 - tri_side_angle/2, None), - PathEndAngle(np.pi/2 - tri_side_angle/2, None)), - CARETLEFTBASE: BoxSides(PathEndAngle(np.pi/2 - tri_side_angle/2, None), - PathEndAngle(np.pi/2 - tri_side_angle/2, None), - _triangle_tip, PathEndAngle(tri_side_angle, None)), - CARETRIGHTBASE: BoxSides(PathEndAngle(np.pi/2 - tri_side_angle/2, None), - PathEndAngle(np.pi/2 - tri_side_angle/2, None), - PathEndAngle(tri_side_angle, None), _triangle_tip), - CARETUPBASE: BoxSides(_triangle_tip, PathEndAngle(tri_side_angle, None), - PathEndAngle(np.pi/2 - tri_side_angle/2, None), - PathEndAngle(np.pi/2 - tri_side_angle/2, None)), - CARETDOWNBASE: BoxSides(PathEndAngle(tri_side_angle, None), _triangle_tip, - PathEndAngle(np.pi/2 - tri_side_angle/2, None), - PathEndAngle(np.pi/2 - tri_side_angle/2, None)), + CARETLEFT: BoxSides(PathEndAngle(np.pi/2 - _tri_side_angle/2, None), + PathEndAngle(np.pi/2 - _tri_side_angle/2, None), + _triangle_tip, PathEndAngle(_tri_side_angle, None)), + CARETRIGHT: BoxSides(PathEndAngle(np.pi/2 - _tri_side_angle/2, None), + PathEndAngle(np.pi/2 - _tri_side_angle/2, None), + PathEndAngle(_tri_side_angle, None), _triangle_tip), + CARETUP: BoxSides(_triangle_tip, PathEndAngle(_tri_side_angle, None), + PathEndAngle(np.pi/2 - _tri_side_angle/2, None), + PathEndAngle(np.pi/2 - _tri_side_angle/2, None)), + CARETDOWN: BoxSides(PathEndAngle(_tri_side_angle, None), _triangle_tip, + PathEndAngle(np.pi/2 - _tri_side_angle/2, None), + PathEndAngle(np.pi/2 - _tri_side_angle/2, None)), + CARETLEFTBASE: BoxSides(PathEndAngle(np.pi/2 - _tri_side_angle/2, None), + PathEndAngle(np.pi/2 - _tri_side_angle/2, None), + _triangle_tip, PathEndAngle(_tri_side_angle, None)), + CARETRIGHTBASE: BoxSides(PathEndAngle(np.pi/2 - _tri_side_angle/2, None), + PathEndAngle(np.pi/2 - _tri_side_angle/2, None), + PathEndAngle(_tri_side_angle, None), _triangle_tip), + CARETUPBASE: BoxSides(_triangle_tip, PathEndAngle(_tri_side_angle, None), + PathEndAngle(np.pi/2 - _tri_side_angle/2, None), + PathEndAngle(np.pi/2 - _tri_side_angle/2, None)), + CARETDOWNBASE: BoxSides(PathEndAngle(_tri_side_angle, None), _triangle_tip, + PathEndAngle(np.pi/2 - _tri_side_angle/2, None), + PathEndAngle(np.pi/2 - _tri_side_angle/2, None)), + '': BoxSides(None, None, None, None), + ' ': BoxSides(None, None, None, None), + 'None': BoxSides(None, None, None, None), + None: BoxSides(None, None, None, None), } def marker_bbox(marker=None, markerwidth=0, markeredgewidth=0): @@ -305,17 +309,16 @@ def marker_bbox(marker=None, markerwidth=0, markeredgewidth=0): marker_symbol = marker.get_marker() joinstyle = marker.get_joinstyle() capstyle = marker.get_capstyle() - unit_bbox = marker.get_path().get_extents() - scale = mpl.transforms.Affine2D().scale(markerwidth) + unit_path = marker.get_transform().transform_path(marker.get_path()) + unit_bbox = unit_path.get_extents() + scale = Affine2D().scale(markerwidth) [[left, bottom], [right, top]] = scale.transform(unit_bbox) - left -= _get_padding_due_to_angle(markeredgewidth, - _edge_angles[marker_symbol].left, joinstyle, capstyle) - bottom -= _get_padding_due_to_angle(markeredgewidth, - _edge_angles[marker_symbol].bottom, joinstyle, capstyle) - right += _get_padding_due_to_angle(markeredgewidth, - _edge_angles[marker_symbol].right, joinstyle, capstyle) - top -= _get_padding_due_to_angle(markeredgewidth, - _edge_angles[marker_symbol].top, joinstyle, capstyle) + angles = _edge_angles[marker_symbol] + ew = markeredgewidth + left -= _get_padding_due_to_angle(ew, angles.left, joinstyle, capstyle) + bottom -= _get_padding_due_to_angle(ew, angles.bottom, joinstyle, capstyle) + right += _get_padding_due_to_angle(ew, angles.right, joinstyle, capstyle) + top += _get_padding_due_to_angle(ew, angles.top, joinstyle, capstyle) return Bbox.from_extents(left, bottom, right, top) def _get_padding_due_to_angle(width, path_end_angle, joinstyle='miter', @@ -337,6 +340,8 @@ def _get_padding_due_to_angle(width, path_end_angle, joinstyle='miter', amount of overflow """ + if path_end_angle is None or width == 0: + return 0 phi, theta = path_end_angle.incidence_angle, path_end_angle.corner_angle # if there's no corner (i.e. the path just ends, as in the "sides" of the # carets or in the non-fillable markers, we can compute how far the outside @@ -354,7 +359,7 @@ def _get_padding_due_to_angle(width, path_end_angle, joinstyle='miter', # code to check for the "projecting" and "round" cases if capstyle != 'butt': raise NotImplementedError("Only capstyle='butt' currently needed") - pad = (w/2) * np.cos(phi) + pad = (width/2) * np.cos(phi) # to calculate the offset for _joinstyle == 'miter', imagine aligning the # corner so that on line comes in along the negative x-axis, and another # from above, makes an angle $\theta$ with the negative x-axis. @@ -372,7 +377,7 @@ def _get_padding_due_to_angle(width, path_end_angle, joinstyle='miter', # the corner angle. So the extra padding required is $M\sin(\phi)$, where # $\phi$ is the incidence angle of the corner's bisector elif joinstyle == 'miter': - pad = (w/2)*np.sin(phi)*np.sqrt(np.csc(theta/2)**2 + 1) + pad = (width/2)*np.sin(phi)*np.sqrt(np.sin(theta/2)**-2 + 1) # to calculate the offset for _joinstyle = "bevel", we can start with the # analogous "miter" corner. the rules for how the "bevel" is # created in SVG is that the outer edges of the stroke continue up until @@ -393,12 +398,18 @@ def _get_padding_due_to_angle(width, path_end_angle, joinstyle='miter', elif joinstyle == 'bevel': phi1 = phi + theta/2 phi2 = phi - theta/2 - pad = (w/2) * max(np.cos(phi1), np.cos(phi2)) + pad = (width/2) * max(np.cos(phi1), np.cos(phi2)) # finally, _joinstyle = "round" is just _joinstyle = "bevel" but with # a hemispherical cap. we could calculate this but for now no markers use - # it.... + # it....except those with "no corner", in which case we can treat them the + # same as squares... elif joinstyle == 'round': - raise NotImplementedError("Only 'miter' and 'bevel' joinstyles needed for now") + # the two "same as straight line" cases + if np.isclose(theta, 0) and np.isclose(phi, 0) \ + or np.isclose(theta, np.pi) and np.isclose(phi, np.pi/2): + pad = width/2 + else: + raise NotImplementedError("Only 'miter' and 'bevel' joinstyles needed for now") else: raise ValueError(f"Unknown joinstyle: {joinstyle}") return pad From db033e4fcea284a849563310adbe8d73ab701efd Mon Sep 17 00:00:00 2001 From: Bruno Beltran Date: Tue, 3 Mar 2020 10:15:01 -0800 Subject: [PATCH 06/33] fixed mis-ordered PathEndAngles for ticks --- lib/matplotlib/markers.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/lib/matplotlib/markers.py b/lib/matplotlib/markers.py index 9a66b50eafe6..283c5b9a6fc1 100644 --- a/lib/matplotlib/markers.py +++ b/lib/matplotlib/markers.py @@ -239,10 +239,10 @@ '_': BoxSides(_flat_side, _flat_side, _normal_line, _normal_line), 'P': _effective_square, 'X': _effective_diamond, - TICKLEFT: BoxSides(_normal_line, _normal_line, _flat_side, _flat_side), - TICKRIGHT: BoxSides(_normal_line, _normal_line, _flat_side, _flat_side), - TICKUP: BoxSides(_flat_side, _flat_side, _normal_line, _normal_line), - TICKDOWN: BoxSides(_flat_side, _flat_side, _normal_line, _normal_line), + TICKLEFT: BoxSides(_flat_side, _flat_side, _normal_line, _normal_line), + TICKRIGHT: BoxSides(_flat_side, _flat_side, _normal_line, _normal_line), + TICKUP: BoxSides(_normal_line, _normal_line, _flat_side, _flat_side), + TICKDOWN: BoxSides(_normal_line, _normal_line, _flat_side, _flat_side), # carets same size as "triangles" but missing the edge opposite their "tip" CARETLEFT: BoxSides(PathEndAngle(np.pi/2 - _tri_side_angle/2, None), PathEndAngle(np.pi/2 - _tri_side_angle/2, None), @@ -377,7 +377,7 @@ def _get_padding_due_to_angle(width, path_end_angle, joinstyle='miter', # the corner angle. So the extra padding required is $M\sin(\phi)$, where # $\phi$ is the incidence angle of the corner's bisector elif joinstyle == 'miter': - pad = (width/2)*np.sin(phi)*np.sqrt(np.sin(theta/2)**-2 + 1) + pad = (width/2)*np.sin(phi)*np.sqrt(np.power(np.sin(theta/2), -2) + 1) # to calculate the offset for _joinstyle = "bevel", we can start with the # analogous "miter" corner. the rules for how the "bevel" is # created in SVG is that the outer edges of the stroke continue up until From 7e41bf5e2ef83aa2314c626a08aed45aa5c53399 Mon Sep 17 00:00:00 2001 From: Bruno Beltran Date: Tue, 3 Mar 2020 10:22:05 -0800 Subject: [PATCH 07/33] flake8 for new markers code --- lib/matplotlib/markers.py | 63 ++++++++++++++++++++------------------- 1 file changed, 33 insertions(+), 30 deletions(-) diff --git a/lib/matplotlib/markers.py b/lib/matplotlib/markers.py index 283c5b9a6fc1..db41ff876c11 100644 --- a/lib/matplotlib/markers.py +++ b/lib/matplotlib/markers.py @@ -179,8 +179,8 @@ _flat_side = PathEndAngle(0, 0) _normal_line = PathEndAngle(np.pi/2, None) _normal_right_angle = PathEndAngle(np.pi/2, np.pi/2) -_triangle_side_corner = PathEndAngle(np.pi/2 - _tri_side_angle/2, _tri_side_angle) -_triangle_tip = PathEndAngle(np.pi/2, _tri_tip_angle) +_tri_side_corner = PathEndAngle(np.pi/2 - _tri_side_angle/2, _tri_side_angle) +_tri_tip = PathEndAngle(np.pi/2, _tri_tip_angle) # and some entire box side behaviors are repeated among markers _effective_square = BoxSides(_flat_side, _flat_side, _flat_side, _flat_side) _effective_diamond = BoxSides(_normal_right_angle, _normal_right_angle, @@ -192,10 +192,10 @@ ',': _effective_square, 'o': _effective_square, # hit two corners and tip bisects one side of unit square - 'v': BoxSides(_flat_side, _triangle_tip, _triangle_side_corner, _triangle_side_corner), - '^': BoxSides(_triangle_tip, _flat_side, _triangle_side_corner, _triangle_side_corner), - '<': BoxSides(_triangle_side_corner, _triangle_side_corner, _triangle_tip, _flat_side), - '>': BoxSides(_triangle_side_corner, _triangle_side_corner, _flat_side, _triangle_tip), + 'v': BoxSides(_flat_side, _tri_tip, _tri_side_corner, _tri_side_corner), + '^': BoxSides(_tri_tip, _flat_side, _tri_side_corner, _tri_side_corner), + '<': BoxSides(_tri_side_corner, _tri_side_corner, _tri_tip, _flat_side), + '>': BoxSides(_tri_side_corner, _tri_side_corner, _flat_side, _tri_tip), # angle bisectors of an equilateral triangle. lines of length 1/2 '1': BoxSides(PathEndAngle(np.pi/6, None), PathEndAngle(np.pi/2, None), PathEndAngle(np.pi/3, None), PathEndAngle(np.pi/3, None)), @@ -246,34 +246,35 @@ # carets same size as "triangles" but missing the edge opposite their "tip" CARETLEFT: BoxSides(PathEndAngle(np.pi/2 - _tri_side_angle/2, None), PathEndAngle(np.pi/2 - _tri_side_angle/2, None), - _triangle_tip, PathEndAngle(_tri_side_angle, None)), + _tri_tip, PathEndAngle(_tri_side_angle, None)), CARETRIGHT: BoxSides(PathEndAngle(np.pi/2 - _tri_side_angle/2, None), PathEndAngle(np.pi/2 - _tri_side_angle/2, None), - PathEndAngle(_tri_side_angle, None), _triangle_tip), - CARETUP: BoxSides(_triangle_tip, PathEndAngle(_tri_side_angle, None), + PathEndAngle(_tri_side_angle, None), _tri_tip), + CARETUP: BoxSides(_tri_tip, PathEndAngle(_tri_side_angle, None), PathEndAngle(np.pi/2 - _tri_side_angle/2, None), PathEndAngle(np.pi/2 - _tri_side_angle/2, None)), - CARETDOWN: BoxSides(PathEndAngle(_tri_side_angle, None), _triangle_tip, - PathEndAngle(np.pi/2 - _tri_side_angle/2, None), - PathEndAngle(np.pi/2 - _tri_side_angle/2, None)), - CARETLEFTBASE: BoxSides(PathEndAngle(np.pi/2 - _tri_side_angle/2, None), + CARETDOWN: BoxSides(PathEndAngle(_tri_side_angle, None), _tri_tip, PathEndAngle(np.pi/2 - _tri_side_angle/2, None), - _triangle_tip, PathEndAngle(_tri_side_angle, None)), + PathEndAngle(np.pi/2 - _tri_side_angle/2, None)), + CARETLEFTBASE: BoxSides(PathEndAngle(np.pi/2 - _tri_side_angle/2, None), + PathEndAngle(np.pi/2 - _tri_side_angle/2, None), + _tri_tip, PathEndAngle(_tri_side_angle, None)), CARETRIGHTBASE: BoxSides(PathEndAngle(np.pi/2 - _tri_side_angle/2, None), - PathEndAngle(np.pi/2 - _tri_side_angle/2, None), - PathEndAngle(_tri_side_angle, None), _triangle_tip), - CARETUPBASE: BoxSides(_triangle_tip, PathEndAngle(_tri_side_angle, None), - PathEndAngle(np.pi/2 - _tri_side_angle/2, None), - PathEndAngle(np.pi/2 - _tri_side_angle/2, None)), - CARETDOWNBASE: BoxSides(PathEndAngle(_tri_side_angle, None), _triangle_tip, - PathEndAngle(np.pi/2 - _tri_side_angle/2, None), - PathEndAngle(np.pi/2 - _tri_side_angle/2, None)), + PathEndAngle(np.pi/2 - _tri_side_angle/2, None), + PathEndAngle(_tri_side_angle, None), _tri_tip), + CARETUPBASE: BoxSides(_tri_tip, PathEndAngle(_tri_side_angle, None), + PathEndAngle(np.pi/2 - _tri_side_angle/2, None), + PathEndAngle(np.pi/2 - _tri_side_angle/2, None)), + CARETDOWNBASE: BoxSides(PathEndAngle(_tri_side_angle, None), _tri_tip, + PathEndAngle(np.pi/2 - _tri_side_angle/2, None), + PathEndAngle(np.pi/2 - _tri_side_angle/2, None)), '': BoxSides(None, None, None, None), ' ': BoxSides(None, None, None, None), 'None': BoxSides(None, None, None, None), None: BoxSides(None, None, None, None), } + def marker_bbox(marker=None, markerwidth=0, markeredgewidth=0): """For a given marker style and size parameters, compute the actual extents of the marker. @@ -321,6 +322,7 @@ def marker_bbox(marker=None, markerwidth=0, markeredgewidth=0): top += _get_padding_due_to_angle(ew, angles.top, joinstyle, capstyle) return Bbox.from_extents(left, bottom, right, top) + def _get_padding_due_to_angle(width, path_end_angle, joinstyle='miter', capstyle='butt'): """How much does adding a stroke with `width` overflow the naive bbox at a @@ -362,17 +364,17 @@ def _get_padding_due_to_angle(width, path_end_angle, joinstyle='miter', pad = (width/2) * np.cos(phi) # to calculate the offset for _joinstyle == 'miter', imagine aligning the # corner so that on line comes in along the negative x-axis, and another - # from above, makes an angle $\theta$ with the negative x-axis. - # the tip of the new corner created by the markeredge stroke will be at the - # point where the two outer edge of the markeredge stroke intersect. - # in the orientation described above, the outer edge of the stroke aligned - # with the x axis will obviously have equation $y = -w/2$ where $w$ is the + # from above, makes an angle $\theta$ with the negative x-axis. the tip of + # the new corner created by the markeredge stroke will be at the point + # where the two outer edge of the markeredge stroke intersect. in the + # orientation described above, the outer edge of the stroke aligned with + # the x axis will obviously have equation $y = -w/2$ where $w$ is the # markeredgewidth. WLOG, the stroke coming in from above at an angle # $\theta$ from the negative x-axis will have equation - # $-(\tan(\theta) x + \frac{w}{2\cos(\theta)}$. + # $$-(\tan(\theta) x + \frac{w}{2\cos(\theta)}.$$ # the intersection of these two lines is at $y = w/2$, and we can solve for # $x = \cot(\theta) (\frac{w}{2} + \frac{w}{2\cos(\theta)})$. - # this puts the "edge" tip a distance $M = (w/2)\sqrt{\csc^2(\theta/2) + 1}$ + # this puts the "edge" tip a distance $M = (w/2)\sqrt{\csc^2(\theta/2)+1}$ # from the tip of the corner itself, on the line defined by the bisector of # the corner angle. So the extra padding required is $M\sin(\phi)$, where # $\phi$ is the incidence angle of the corner's bisector @@ -409,7 +411,8 @@ def _get_padding_due_to_angle(width, path_end_angle, joinstyle='miter', or np.isclose(theta, np.pi) and np.isclose(phi, np.pi/2): pad = width/2 else: - raise NotImplementedError("Only 'miter' and 'bevel' joinstyles needed for now") + raise NotImplementedError("Only 'miter' and 'bevel' joinstyles " + "needed for now") else: raise ValueError(f"Unknown joinstyle: {joinstyle}") return pad From f1014b5eb198411ec20ef903e35454e72b7c2415 Mon Sep 17 00:00:00 2001 From: Bruno Beltran Date: Tue, 3 Mar 2020 10:53:49 -0800 Subject: [PATCH 08/33] factor marker bbox code to be within MarkerStyles --- lib/matplotlib/lines.py | 6 +-- lib/matplotlib/markers.py | 94 +++++++++++++++++++-------------------- 2 files changed, 49 insertions(+), 51 deletions(-) diff --git a/lib/matplotlib/lines.py b/lib/matplotlib/lines.py index 8a1777f25f92..52e3117c2bb1 100644 --- a/lib/matplotlib/lines.py +++ b/lib/matplotlib/lines.py @@ -14,7 +14,7 @@ from .artist import Artist, allow_rasterization from .cbook import ( _to_unmasked_float_array, ls_mapper, ls_mapper_r, STEP_LOOKUP_MAP) -from .markers import MarkerStyle, marker_bbox +from .markers import MarkerStyle from .path import Path from .transforms import ( Affine2D, Bbox, BboxTransformFrom, BboxTransformTo, TransformedPath) @@ -617,8 +617,8 @@ def get_window_extent(self, renderer): ignore=True) # correct for marker size, if any if self._marker: - m_bbox = marker_bbox(self._marker, self._markersize, - self._markeredgewidth) + m_bbox = self._marker.get_centered_bbox( + self._markersize, self._markeredgewidth) # add correct padding to each side of bbox bbox = Bbox(bbox.get_points() + m_bbox.get_points()) return bbox diff --git a/lib/matplotlib/markers.py b/lib/matplotlib/markers.py index db41ff876c11..c4109db01a4b 100644 --- a/lib/matplotlib/markers.py +++ b/lib/matplotlib/markers.py @@ -275,54 +275,6 @@ } -def marker_bbox(marker=None, markerwidth=0, markeredgewidth=0): - """For a given marker style and size parameters, compute the actual extents - of the marker. - - For markers with no edge, this is just the same bbox as that of the - transformed marker path, but how much extra extent is added by an edge is a - function of the angle of the path at its own (the path's own) boundary. - - Parameters - ---------- - marker : matplotlib.markers.MarkerStyle - The marker type, or object that can be used to construct the marker - type. - - markerwidth : float, optional, default: None - "Size" of the marker, in pixels. - - markeredgewidth : float, optional, default: None - Width, in pixels, of the stroke used to create the marker's edge. - - Returns - ------- - - bbox : matplotlib.transforms.Bbox - The extents of the marker including its edge (in pixels) if it were - centered at (0,0). - - Notes - ----- - """ - if type(marker) != MarkerStyle: - marker = MarkerStyle(marker) - marker_symbol = marker.get_marker() - joinstyle = marker.get_joinstyle() - capstyle = marker.get_capstyle() - unit_path = marker.get_transform().transform_path(marker.get_path()) - unit_bbox = unit_path.get_extents() - scale = Affine2D().scale(markerwidth) - [[left, bottom], [right, top]] = scale.transform(unit_bbox) - angles = _edge_angles[marker_symbol] - ew = markeredgewidth - left -= _get_padding_due_to_angle(ew, angles.left, joinstyle, capstyle) - bottom -= _get_padding_due_to_angle(ew, angles.bottom, joinstyle, capstyle) - right += _get_padding_due_to_angle(ew, angles.right, joinstyle, capstyle) - top += _get_padding_due_to_angle(ew, angles.top, joinstyle, capstyle) - return Bbox.from_extents(left, bottom, right, top) - - def _get_padding_due_to_angle(width, path_end_angle, joinstyle='miter', capstyle='butt'): """How much does adding a stroke with `width` overflow the naive bbox at a @@ -1171,3 +1123,49 @@ def _set_x_filled(self): self._alt_transform = Affine2D().translate(-0.5, -0.5) self._transform.rotate_deg(rotate) self._alt_transform.rotate_deg(rotate_alt) + + + def get_centered_bbox(markerwidth=0, markeredgewidth=0): + """Get size of bbox if marker is centered at origin. + + For markers with no edge, this is just the same bbox as that of the + transformed marker path, but how much extra extent is added by an edge + is a function of the angle of the path at its own (the path's own) + boundary. + + Parameters + ---------- + markerwidth : float, optional, default: None + "Size" of the marker, in pixels. + + markeredgewidth : float, optional, default: None + Width, in pixels, of the stroke used to create the marker's edge. + + Returns + ------- + + bbox : matplotlib.transforms.Bbox + The extents of the marker including its edge (in pixels) if it were + centered at (0,0). + + Notes + ----- + """ + if type(marker) != MarkerStyle: + marker = MarkerStyle(marker) + unit_path = self._transform.transform_path(self._path) + unit_bbox = unit_path.get_extents() + scale = Affine2D().scale(markerwidth) + [[left, bottom], [right, top]] = scale.transform(unit_bbox) + angles = _edge_angles[marker._marker] + left -= _get_padding_due_to_angle(markeredgewidth, angles.left, + self._joinstyle, self._capstyle) + bottom -= _get_padding_due_to_angle(markeredgewidth, angles.bottom, + self._joinstyle, self._capstyle) + right += _get_padding_due_to_angle(markeredgewidth, angles.right, + self._joinstyle, self._capstyle) + top += _get_padding_due_to_angle(markeredgewidth, angles.top, + self._joinstyle, self._capstyle) + return Bbox.from_extents(left, bottom, right, top) + + From 42cc5db99297da2fb8c57604b26e4d6789bb9b8d Mon Sep 17 00:00:00 2001 From: Bruno Beltran Date: Tue, 3 Mar 2020 10:59:07 -0800 Subject: [PATCH 09/33] bugfix, forgot self in MarkerStyle.get_centered_bbox --- lib/matplotlib/markers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/matplotlib/markers.py b/lib/matplotlib/markers.py index c4109db01a4b..8b05d19ea916 100644 --- a/lib/matplotlib/markers.py +++ b/lib/matplotlib/markers.py @@ -1125,7 +1125,7 @@ def _set_x_filled(self): self._alt_transform.rotate_deg(rotate_alt) - def get_centered_bbox(markerwidth=0, markeredgewidth=0): + def get_centered_bbox(self, markerwidth=0, markeredgewidth=0): """Get size of bbox if marker is centered at origin. For markers with no edge, this is just the same bbox as that of the From 8fcb2238a39de116a05857476aad80e832ad24ac Mon Sep 17 00:00:00 2001 From: Bruno Beltran Date: Tue, 3 Mar 2020 11:07:22 -0800 Subject: [PATCH 10/33] misc bugfixes after factoring get_centered_bbox --- lib/matplotlib/markers.py | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/lib/matplotlib/markers.py b/lib/matplotlib/markers.py index 8b05d19ea916..89e197f3a299 100644 --- a/lib/matplotlib/markers.py +++ b/lib/matplotlib/markers.py @@ -143,7 +143,8 @@ _empty_path = Path(np.empty((0, 2))) -# hold info to track how marker size scales with increased "edge" thickness +# we store some geometrical information about each marker to track how its +# size scales with increased "edge" thickness PathEndAngle = namedtuple('PathEndAngle', 'incidence_angle corner_angle') r"""Used to have a universal way to account for how much the bounding box of a shape will grow as we increase its `markeredgewidth`. @@ -277,8 +278,8 @@ def _get_padding_due_to_angle(width, path_end_angle, joinstyle='miter', capstyle='butt'): - """How much does adding a stroke with `width` overflow the naive bbox at a - corner described by `path_end_angle`? + """Computes how much a stroke of *width* overflows the naive bbox at a + corner described by *path_end_angle*. Parameters ---------- @@ -1125,7 +1126,7 @@ def _set_x_filled(self): self._alt_transform.rotate_deg(rotate_alt) - def get_centered_bbox(self, markerwidth=0, markeredgewidth=0): + def get_centered_bbox(self, markerwidth, markeredgewidth=0): """Get size of bbox if marker is centered at origin. For markers with no edge, this is just the same bbox as that of the @@ -1135,10 +1136,10 @@ def get_centered_bbox(self, markerwidth=0, markeredgewidth=0): Parameters ---------- - markerwidth : float, optional, default: None + markerwidth : float "Size" of the marker, in pixels. - markeredgewidth : float, optional, default: None + markeredgewidth : float, optional, default: 0 Width, in pixels, of the stroke used to create the marker's edge. Returns @@ -1151,13 +1152,15 @@ def get_centered_bbox(self, markerwidth=0, markeredgewidth=0): Notes ----- """ - if type(marker) != MarkerStyle: - marker = MarkerStyle(marker) + # if the marker is of size zero, the stroke's width doesn't matter, + # there is no stroke so the bbox is trivial + if np.isclose(markerwidth, 0): + return Bbox([[0,0],[0,0]]) unit_path = self._transform.transform_path(self._path) unit_bbox = unit_path.get_extents() scale = Affine2D().scale(markerwidth) [[left, bottom], [right, top]] = scale.transform(unit_bbox) - angles = _edge_angles[marker._marker] + angles = _edge_angles[self._marker] left -= _get_padding_due_to_angle(markeredgewidth, angles.left, self._joinstyle, self._capstyle) bottom -= _get_padding_due_to_angle(markeredgewidth, angles.bottom, From d95e4d899851ad5db8ce73149af502262a66840f Mon Sep 17 00:00:00 2001 From: Bruno Beltran Date: Tue, 3 Mar 2020 11:36:09 -0800 Subject: [PATCH 11/33] markers bbox code visually tested, now works --- lib/matplotlib/markers.py | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/lib/matplotlib/markers.py b/lib/matplotlib/markers.py index 89e197f3a299..1100aabd4be2 100644 --- a/lib/matplotlib/markers.py +++ b/lib/matplotlib/markers.py @@ -170,7 +170,7 @@ # some angles are heavily repeated throughout various markers _tri_side_angle = np.arctan(2) -_tri_tip_angle = np.arctan(1/2) +_tri_tip_angle = 2*np.arctan(1/2) # half the edge length of the smaller pentagon over the difference between the # larger pentagon's circumcribing radius and the smaller pentagon's inscribed # radius @@ -296,7 +296,7 @@ def _get_padding_due_to_angle(width, path_end_angle, joinstyle='miter', """ if path_end_angle is None or width == 0: - return 0 + return np.nan phi, theta = path_end_angle.incidence_angle, path_end_angle.corner_angle # if there's no corner (i.e. the path just ends, as in the "sides" of the # carets or in the non-fillable markers, we can compute how far the outside @@ -315,6 +315,10 @@ def _get_padding_due_to_angle(width, path_end_angle, joinstyle='miter', if capstyle != 'butt': raise NotImplementedError("Only capstyle='butt' currently needed") pad = (width/2) * np.cos(phi) + # the two "same as straight line" cases are NaN limits in the miter formula + elif np.isclose(theta, 0) and np.isclose(phi, 0) \ + or np.isclose(theta, np.pi) and np.isclose(phi, np.pi/2): + pad = width/2 # to calculate the offset for _joinstyle == 'miter', imagine aligning the # corner so that on line comes in along the negative x-axis, and another # from above, makes an angle $\theta$ with the negative x-axis. the tip of @@ -359,13 +363,8 @@ def _get_padding_due_to_angle(width, path_end_angle, joinstyle='miter', # it....except those with "no corner", in which case we can treat them the # same as squares... elif joinstyle == 'round': - # the two "same as straight line" cases - if np.isclose(theta, 0) and np.isclose(phi, 0) \ - or np.isclose(theta, np.pi) and np.isclose(phi, np.pi/2): - pad = width/2 - else: - raise NotImplementedError("Only 'miter' and 'bevel' joinstyles " - "needed for now") + raise NotImplementedError("Only 'miter' and 'bevel' joinstyles needed " + "for now") else: raise ValueError(f"Unknown joinstyle: {joinstyle}") return pad From 4592cda2bd013191efedbe475c8e3023d5a96f80 Mon Sep 17 00:00:00 2001 From: Bruno Beltran Date: Tue, 3 Mar 2020 12:02:17 -0800 Subject: [PATCH 12/33] flake8 for new markers bbox code --- lib/matplotlib/markers.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/lib/matplotlib/markers.py b/lib/matplotlib/markers.py index 1100aabd4be2..2feda41ed24e 100644 --- a/lib/matplotlib/markers.py +++ b/lib/matplotlib/markers.py @@ -1124,7 +1124,6 @@ def _set_x_filled(self): self._transform.rotate_deg(rotate) self._alt_transform.rotate_deg(rotate_alt) - def get_centered_bbox(self, markerwidth, markeredgewidth=0): """Get size of bbox if marker is centered at origin. @@ -1154,7 +1153,7 @@ def get_centered_bbox(self, markerwidth, markeredgewidth=0): # if the marker is of size zero, the stroke's width doesn't matter, # there is no stroke so the bbox is trivial if np.isclose(markerwidth, 0): - return Bbox([[0,0],[0,0]]) + return Bbox([[0, 0], [0, 0]]) unit_path = self._transform.transform_path(self._path) unit_bbox = unit_path.get_extents() scale = Affine2D().scale(markerwidth) @@ -1169,5 +1168,3 @@ def get_centered_bbox(self, markerwidth, markeredgewidth=0): top += _get_padding_due_to_angle(markeredgewidth, angles.top, self._joinstyle, self._capstyle) return Bbox.from_extents(left, bottom, right, top) - - From c08826ec393126de172766f7728d6ad2c472fcaf Mon Sep 17 00:00:00 2001 From: Bruno Beltran Date: Tue, 3 Mar 2020 20:38:52 -0800 Subject: [PATCH 13/33] fixed formula for miter marker bbox, bevel broke --- lib/matplotlib/markers.py | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/lib/matplotlib/markers.py b/lib/matplotlib/markers.py index 2feda41ed24e..2f36d3970ffd 100644 --- a/lib/matplotlib/markers.py +++ b/lib/matplotlib/markers.py @@ -295,9 +295,15 @@ def _get_padding_due_to_angle(width, path_end_angle, joinstyle='miter', amount of overflow """ - if path_end_angle is None or width == 0: + if path_end_angle is None or width < 0: return np.nan phi, theta = path_end_angle.incidence_angle, path_end_angle.corner_angle + if theta is not None and (theta < 0 or theta > np.pi) \ + or phi < 0 or phi > np.pi: + raise ValueError("Corner angles should be in [0, pi].") + if phi > np.pi/2: + # equivalent by symmetry, but keeps math simpler + phi = np.pi - phi # if there's no corner (i.e. the path just ends, as in the "sides" of the # carets or in the non-fillable markers, we can compute how far the outside # edge of the markeredge stroke extends outside of the bounding box of its @@ -331,12 +337,14 @@ def _get_padding_due_to_angle(width, path_end_angle, joinstyle='miter', # $$-(\tan(\theta) x + \frac{w}{2\cos(\theta)}.$$ # the intersection of these two lines is at $y = w/2$, and we can solve for # $x = \cot(\theta) (\frac{w}{2} + \frac{w}{2\cos(\theta)})$. - # this puts the "edge" tip a distance $M = (w/2)\sqrt{\csc^2(\theta/2)+1}$ + # this puts the "edge" tip a distance $M = (w/2)\csc(\theta/2)$ # from the tip of the corner itself, on the line defined by the bisector of # the corner angle. So the extra padding required is $M\sin(\phi)$, where - # $\phi$ is the incidence angle of the corner's bisector + # $\phi$ is the incidence angle of the corner's bisector. Notice that in + # the obvious limit ($\phi = \theta/2$) where the corner is flush with the + # bbox, this correctly simplifies to just $w/2$. elif joinstyle == 'miter': - pad = (width/2)*np.sin(phi)*np.sqrt(np.power(np.sin(theta/2), -2) + 1) + pad = (width/2)*np.sin(phi)/np.sin(theta/2) # to calculate the offset for _joinstyle = "bevel", we can start with the # analogous "miter" corner. the rules for how the "bevel" is # created in SVG is that the outer edges of the stroke continue up until @@ -357,7 +365,7 @@ def _get_padding_due_to_angle(width, path_end_angle, joinstyle='miter', elif joinstyle == 'bevel': phi1 = phi + theta/2 phi2 = phi - theta/2 - pad = (width/2) * max(np.cos(phi1), np.cos(phi2)) + pad = (width/2) * max(np.abs(np.cos(phi1)), np.abs(np.cos(phi2))) # finally, _joinstyle = "round" is just _joinstyle = "bevel" but with # a hemispherical cap. we could calculate this but for now no markers use # it....except those with "no corner", in which case we can treat them the From 7f9db168c3bc1d47a1bee4f28365d828b8b0a8e4 Mon Sep 17 00:00:00 2001 From: Bruno Beltran Date: Tue, 3 Mar 2020 21:16:09 -0800 Subject: [PATCH 14/33] bugfix caret bbox calculation, incorrect angles --- lib/matplotlib/markers.py | 49 +++++++++++++++------------------------ 1 file changed, 19 insertions(+), 30 deletions(-) diff --git a/lib/matplotlib/markers.py b/lib/matplotlib/markers.py index 2f36d3970ffd..abb6020c940b 100644 --- a/lib/matplotlib/markers.py +++ b/lib/matplotlib/markers.py @@ -171,6 +171,8 @@ # some angles are heavily repeated throughout various markers _tri_side_angle = np.arctan(2) _tri_tip_angle = 2*np.arctan(1/2) +_caret_side_angle = np.arctan(3/2) +_caret_tip_angle = 2*np.arctan(2/3) # half the edge length of the smaller pentagon over the difference between the # larger pentagon's circumcribing radius and the smaller pentagon's inscribed # radius @@ -180,8 +182,11 @@ _flat_side = PathEndAngle(0, 0) _normal_line = PathEndAngle(np.pi/2, None) _normal_right_angle = PathEndAngle(np.pi/2, np.pi/2) -_tri_side_corner = PathEndAngle(np.pi/2 - _tri_side_angle/2, _tri_side_angle) +_tri_side = PathEndAngle(np.pi/2 - _tri_side_angle/2, _tri_side_angle) _tri_tip = PathEndAngle(np.pi/2, _tri_tip_angle) +_caret_bottom = PathEndAngle(_caret_side_angle, None) +_caret_side = PathEndAngle(np.pi/2 - _caret_side_angle, None) +_caret_tip = PathEndAngle(np.pi/2, _caret_tip_angle) # and some entire box side behaviors are repeated among markers _effective_square = BoxSides(_flat_side, _flat_side, _flat_side, _flat_side) _effective_diamond = BoxSides(_normal_right_angle, _normal_right_angle, @@ -193,10 +198,10 @@ ',': _effective_square, 'o': _effective_square, # hit two corners and tip bisects one side of unit square - 'v': BoxSides(_flat_side, _tri_tip, _tri_side_corner, _tri_side_corner), - '^': BoxSides(_tri_tip, _flat_side, _tri_side_corner, _tri_side_corner), - '<': BoxSides(_tri_side_corner, _tri_side_corner, _tri_tip, _flat_side), - '>': BoxSides(_tri_side_corner, _tri_side_corner, _flat_side, _tri_tip), + 'v': BoxSides(_flat_side, _tri_tip, _tri_side, _tri_side), + '^': BoxSides(_tri_tip, _flat_side, _tri_side, _tri_side), + '<': BoxSides(_tri_side, _tri_side, _tri_tip, _flat_side), + '>': BoxSides(_tri_side, _tri_side, _flat_side, _tri_tip), # angle bisectors of an equilateral triangle. lines of length 1/2 '1': BoxSides(PathEndAngle(np.pi/6, None), PathEndAngle(np.pi/2, None), PathEndAngle(np.pi/3, None), PathEndAngle(np.pi/3, None)), @@ -244,31 +249,15 @@ TICKRIGHT: BoxSides(_flat_side, _flat_side, _normal_line, _normal_line), TICKUP: BoxSides(_normal_line, _normal_line, _flat_side, _flat_side), TICKDOWN: BoxSides(_normal_line, _normal_line, _flat_side, _flat_side), - # carets same size as "triangles" but missing the edge opposite their "tip" - CARETLEFT: BoxSides(PathEndAngle(np.pi/2 - _tri_side_angle/2, None), - PathEndAngle(np.pi/2 - _tri_side_angle/2, None), - _tri_tip, PathEndAngle(_tri_side_angle, None)), - CARETRIGHT: BoxSides(PathEndAngle(np.pi/2 - _tri_side_angle/2, None), - PathEndAngle(np.pi/2 - _tri_side_angle/2, None), - PathEndAngle(_tri_side_angle, None), _tri_tip), - CARETUP: BoxSides(_tri_tip, PathEndAngle(_tri_side_angle, None), - PathEndAngle(np.pi/2 - _tri_side_angle/2, None), - PathEndAngle(np.pi/2 - _tri_side_angle/2, None)), - CARETDOWN: BoxSides(PathEndAngle(_tri_side_angle, None), _tri_tip, - PathEndAngle(np.pi/2 - _tri_side_angle/2, None), - PathEndAngle(np.pi/2 - _tri_side_angle/2, None)), - CARETLEFTBASE: BoxSides(PathEndAngle(np.pi/2 - _tri_side_angle/2, None), - PathEndAngle(np.pi/2 - _tri_side_angle/2, None), - _tri_tip, PathEndAngle(_tri_side_angle, None)), - CARETRIGHTBASE: BoxSides(PathEndAngle(np.pi/2 - _tri_side_angle/2, None), - PathEndAngle(np.pi/2 - _tri_side_angle/2, None), - PathEndAngle(_tri_side_angle, None), _tri_tip), - CARETUPBASE: BoxSides(_tri_tip, PathEndAngle(_tri_side_angle, None), - PathEndAngle(np.pi/2 - _tri_side_angle/2, None), - PathEndAngle(np.pi/2 - _tri_side_angle/2, None)), - CARETDOWNBASE: BoxSides(PathEndAngle(_tri_side_angle, None), _tri_tip, - PathEndAngle(np.pi/2 - _tri_side_angle/2, None), - PathEndAngle(np.pi/2 - _tri_side_angle/2, None)), + # carets missing the edge opposite their "tip", different size than tri's + CARETLEFT: BoxSides(_caret_side, _caret_side, _caret_tip, _caret_bottom), + CARETRIGHT: BoxSides(_caret_side, _caret_side, _caret_bottom, _caret_tip), + CARETUP: BoxSides(_caret_tip, _caret_bottom, _caret_side, _caret_side), + CARETDOWN: BoxSides(_caret_bottom, _caret_tip, _caret_side, _caret_side), + CARETLEFTBASE: BoxSides(_caret_side, _caret_side, _caret_tip, _caret_bottom), + CARETRIGHTBASE: BoxSides(_caret_side, _caret_side, _caret_bottom, _caret_tip), + CARETUPBASE: BoxSides(_caret_tip, _caret_bottom, _caret_side, _caret_side), + CARETDOWNBASE: BoxSides(_caret_bottom, _caret_tip, _caret_side, _caret_side), '': BoxSides(None, None, None, None), ' ': BoxSides(None, None, None, None), 'None': BoxSides(None, None, None, None), From 1805dc7111e69fe78f386346c3b6fb1ee4e8944f Mon Sep 17 00:00:00 2001 From: Bruno Beltran Date: Wed, 4 Mar 2020 07:38:12 -0800 Subject: [PATCH 15/33] fixed star tip angle in marker bbox calculation --- lib/matplotlib/markers.py | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/lib/matplotlib/markers.py b/lib/matplotlib/markers.py index abb6020c940b..5ae5962116be 100644 --- a/lib/matplotlib/markers.py +++ b/lib/matplotlib/markers.py @@ -175,9 +175,10 @@ _caret_tip_angle = 2*np.arctan(2/3) # half the edge length of the smaller pentagon over the difference between the # larger pentagon's circumcribing radius and the smaller pentagon's inscribed -# radius -_star_tip_angle = 2*np.arctan2((1/4)*np.sqrt((5 - np.sqrt(5))/2), - 1 - np.sqrt((3 + np.sqrt(5))/32)) +# radius #TODO this formula has typo somewhere.... +# _star_tip_angle = 2*np.arctan2((1/4)*np.sqrt((5 - np.sqrt(5))/2), +# 1 - np.sqrt((3 + np.sqrt(5))/32)) +_star_tip_angle = 0.6283185056636065 # reusable corner types _flat_side = PathEndAngle(0, 0) _normal_line = PathEndAngle(np.pi/2, None) @@ -254,10 +255,13 @@ CARETRIGHT: BoxSides(_caret_side, _caret_side, _caret_bottom, _caret_tip), CARETUP: BoxSides(_caret_tip, _caret_bottom, _caret_side, _caret_side), CARETDOWN: BoxSides(_caret_bottom, _caret_tip, _caret_side, _caret_side), - CARETLEFTBASE: BoxSides(_caret_side, _caret_side, _caret_tip, _caret_bottom), - CARETRIGHTBASE: BoxSides(_caret_side, _caret_side, _caret_bottom, _caret_tip), + CARETLEFTBASE: BoxSides(_caret_side, _caret_side, _caret_tip, + _caret_bottom), + CARETRIGHTBASE: BoxSides(_caret_side, _caret_side, _caret_bottom, + _caret_tip), CARETUPBASE: BoxSides(_caret_tip, _caret_bottom, _caret_side, _caret_side), - CARETDOWNBASE: BoxSides(_caret_bottom, _caret_tip, _caret_side, _caret_side), + CARETDOWNBASE: BoxSides(_caret_bottom, _caret_tip, _caret_side, + _caret_side), '': BoxSides(None, None, None, None), ' ': BoxSides(None, None, None, None), 'None': BoxSides(None, None, None, None), From a13598d70f589eefe34bc36b64da6729100129d8 Mon Sep 17 00:00:00 2001 From: Bruno Beltran Date: Wed, 4 Mar 2020 08:32:56 -0800 Subject: [PATCH 16/33] test marker bbox. failing here, pass in jupyter --- .../test_marker/marker_bbox_pentagon.png | Bin 0 -> 5286 bytes .../test_marker/marker_bbox_star.png | Bin 0 -> 6111 bytes lib/matplotlib/tests/test_marker.py | 39 ++++++++++++++++++ 3 files changed, 39 insertions(+) create mode 100644 lib/matplotlib/tests/baseline_images/test_marker/marker_bbox_pentagon.png create mode 100644 lib/matplotlib/tests/baseline_images/test_marker/marker_bbox_star.png diff --git a/lib/matplotlib/tests/baseline_images/test_marker/marker_bbox_pentagon.png b/lib/matplotlib/tests/baseline_images/test_marker/marker_bbox_pentagon.png new file mode 100644 index 0000000000000000000000000000000000000000..133aa7894ac30cfe15b629584340068d62b4b9d6 GIT binary patch literal 5286 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 DMeVA~ literal 0 HcmV?d00001 diff --git a/lib/matplotlib/tests/baseline_images/test_marker/marker_bbox_star.png b/lib/matplotlib/tests/baseline_images/test_marker/marker_bbox_star.png new file mode 100644 index 0000000000000000000000000000000000000000..e6f7a7468593a3a5df3d062d99df42bc2a50c98c GIT binary patch literal 6111 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 MPP(2bKOS)HpQn6>`2YX_ literal 0 HcmV?d00001 diff --git a/lib/matplotlib/tests/test_marker.py b/lib/matplotlib/tests/test_marker.py index 1ef9c18c47fb..56486fb9de38 100644 --- a/lib/matplotlib/tests/test_marker.py +++ b/lib/matplotlib/tests/test_marker.py @@ -1,6 +1,8 @@ import numpy as np +import matplotlib.pyplot as plt from matplotlib import markers from matplotlib.path import Path +from matplotlib.testing.decorators import image_comparison import pytest @@ -26,3 +28,40 @@ def test_marker_path(): path = Path([[0, 0], [1, 0]], [Path.MOVETO, Path.LINETO]) # Checking this doesn't fail. marker_style.set_marker(path) + +def _draw_marker_outlined(marker, markeredgewidth=0, markersize=100): + # keep marker vaguely inside the figure by scaling + fig_d = 4*(markeredgewidth + markersize)/100 + fig, ax = plt.subplots(figsize=(fig_d,fig_d)) + ax.axis('off') + # and fix limits so pix size doesn't change later + ax_lim = 2 + plt.xlim([-ax_lim, ax_lim]) + plt.ylim([-ax_lim, ax_lim]) + # draw a single marker centered at the origin + lines = ax.plot(0, 0, 'b', markersize=markersize, marker=marker, + clip_on=False, markeredgewidth=markeredgewidth, + markeredgecolor='k') + # now get theoretical bbox from markers interface + origin_px = ax.transData.transform((0,0)) + m_bbox = markers.MarkerStyle(marker).get_centered_bbox(markersize, markeredgewidth) + m_bottom_left, m_top_right = m_bbox.get_points() + top_right_px = origin_px + m_top_right + bottom_left_px = origin_px + m_bottom_left + right, top = ax.transData.inverted().transform(top_right_px) + left, bottom = ax.transData.inverted().transform(bottom_left_px) + # now draw that bbox in green + ax.plot([left, right], [top, top], 'g') + ax.plot([left, right], [bottom, bottom], 'g') + ax.plot([right, right], [bottom, top], 'g') + ax.plot([left, left], [bottom, top], 'g') + +@image_comparison(baseline_images=['marker_bbox_star'], + extensions=['png']) +def test_marker_bbox_star(): + _draw_marker_outlined('*', markeredgewidth=20) + +@image_comparison(baseline_images=['marker_bbox_pentagon'], + extensions=['png']) +def test_marker_bbox_pentagon(): + _draw_marker_outlined('p', markeredgewidth=20) From 0f7300a30cc4e89a56314f8fb38faa6713490645 Mon Sep 17 00:00:00 2001 From: Bruno Beltran Date: Wed, 4 Mar 2020 13:46:12 -0800 Subject: [PATCH 17/33] bugfix so markers bbox api stays in pts units --- lib/matplotlib/lines.py | 6 ++++-- lib/matplotlib/markers.py | 8 ++++---- lib/matplotlib/tests/test_marker.py | 10 ++++++---- 3 files changed, 14 insertions(+), 10 deletions(-) diff --git a/lib/matplotlib/lines.py b/lib/matplotlib/lines.py index 52e3117c2bb1..87e20b2a5aa9 100644 --- a/lib/matplotlib/lines.py +++ b/lib/matplotlib/lines.py @@ -617,10 +617,12 @@ def get_window_extent(self, renderer): ignore=True) # correct for marker size, if any if self._marker: - m_bbox = self._marker.get_centered_bbox( + m_bbox = self._marker.get_bbox( self._markersize, self._markeredgewidth) + # markers use units of pts, not pixels + box_points_px = m_bbox.get_points() / 72.0 * self.figure.dpi # add correct padding to each side of bbox - bbox = Bbox(bbox.get_points() + m_bbox.get_points()) + bbox = Bbox(bbox.get_points() + box_points_px) return bbox @Artist.axes.setter diff --git a/lib/matplotlib/markers.py b/lib/matplotlib/markers.py index 5ae5962116be..d08746f0661f 100644 --- a/lib/matplotlib/markers.py +++ b/lib/matplotlib/markers.py @@ -1125,7 +1125,7 @@ def _set_x_filled(self): self._transform.rotate_deg(rotate) self._alt_transform.rotate_deg(rotate_alt) - def get_centered_bbox(self, markerwidth, markeredgewidth=0): + def get_bbox(self, markerwidth, markeredgewidth=0): """Get size of bbox if marker is centered at origin. For markers with no edge, this is just the same bbox as that of the @@ -1136,16 +1136,16 @@ def get_centered_bbox(self, markerwidth, markeredgewidth=0): Parameters ---------- markerwidth : float - "Size" of the marker, in pixels. + "Size" of the marker, in points. markeredgewidth : float, optional, default: 0 - Width, in pixels, of the stroke used to create the marker's edge. + Width, in points, of the stroke used to create the marker's edge. Returns ------- bbox : matplotlib.transforms.Bbox - The extents of the marker including its edge (in pixels) if it were + The extents of the marker including its edge (in points) if it were centered at (0,0). Notes diff --git a/lib/matplotlib/tests/test_marker.py b/lib/matplotlib/tests/test_marker.py index 56486fb9de38..06159cbd5064 100644 --- a/lib/matplotlib/tests/test_marker.py +++ b/lib/matplotlib/tests/test_marker.py @@ -29,10 +29,11 @@ def test_marker_path(): # Checking this doesn't fail. marker_style.set_marker(path) + def _draw_marker_outlined(marker, markeredgewidth=0, markersize=100): # keep marker vaguely inside the figure by scaling fig_d = 4*(markeredgewidth + markersize)/100 - fig, ax = plt.subplots(figsize=(fig_d,fig_d)) + fig, ax = plt.subplots(figsize=(fig_d, fig_d)) ax.axis('off') # and fix limits so pix size doesn't change later ax_lim = 2 @@ -43,9 +44,10 @@ def _draw_marker_outlined(marker, markeredgewidth=0, markersize=100): clip_on=False, markeredgewidth=markeredgewidth, markeredgecolor='k') # now get theoretical bbox from markers interface - origin_px = ax.transData.transform((0,0)) - m_bbox = markers.MarkerStyle(marker).get_centered_bbox(markersize, markeredgewidth) - m_bottom_left, m_top_right = m_bbox.get_points() + origin_px = ax.transData.transform((0, 0)) + m_bbox = markers.MarkerStyle(marker).get_bbox(markersize, markeredgewidth) + # convert from pt to pixel, and rename + m_bottom_left, m_top_right = m_bbox.get_points() / 72.0 * fig.dpi top_right_px = origin_px + m_top_right bottom_left_px = origin_px + m_bottom_left right, top = ax.transData.inverted().transform(top_right_px) From d6f1571f7afb2b1b9cf8a41c5c66601ebc82dd9c Mon Sep 17 00:00:00 2001 From: Bruno Beltran Date: Wed, 4 Mar 2020 13:50:14 -0800 Subject: [PATCH 18/33] forgot to push new test references images up --- .../test_marker/marker_bbox_pentagon.png | Bin 5286 -> 5358 bytes .../test_marker/marker_bbox_star.png | Bin 6111 -> 6200 bytes 2 files changed, 0 insertions(+), 0 deletions(-) diff --git a/lib/matplotlib/tests/baseline_images/test_marker/marker_bbox_pentagon.png b/lib/matplotlib/tests/baseline_images/test_marker/marker_bbox_pentagon.png index 133aa7894ac30cfe15b629584340068d62b4b9d6..152f6656ebac309f1f48387a7672c847d04f5520 100644 GIT binary patch literal 5358 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 MwvIOJWB6PD2Lx~0*8l(j literal 5286 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 DMeVA~ diff --git a/lib/matplotlib/tests/baseline_images/test_marker/marker_bbox_star.png b/lib/matplotlib/tests/baseline_images/test_marker/marker_bbox_star.png index e6f7a7468593a3a5df3d062d99df42bc2a50c98c..bf30f4621a795f5f899c2f3963838858b5838b78 100644 GIT binary patch literal 6200 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 MPP(2bKOS)HpQn6>`2YX_ From 2bee048d3b4ebee10aa7be8f1fe3caca086ae80a Mon Sep 17 00:00:00 2001 From: Bruno Beltran Date: Fri, 6 Mar 2020 14:58:59 -0800 Subject: [PATCH 19/33] cleanup variable name consistency --- lib/matplotlib/markers.py | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/lib/matplotlib/markers.py b/lib/matplotlib/markers.py index d08746f0661f..333c1bb3ef4c 100644 --- a/lib/matplotlib/markers.py +++ b/lib/matplotlib/markers.py @@ -1125,7 +1125,7 @@ def _set_x_filled(self): self._transform.rotate_deg(rotate) self._alt_transform.rotate_deg(rotate_alt) - def get_bbox(self, markerwidth, markeredgewidth=0): + def get_bbox(self, markersize, markeredgewidth=0): """Get size of bbox if marker is centered at origin. For markers with no edge, this is just the same bbox as that of the @@ -1135,7 +1135,7 @@ def get_bbox(self, markerwidth, markeredgewidth=0): Parameters ---------- - markerwidth : float + markersize : float "Size" of the marker, in points. markeredgewidth : float, optional, default: 0 @@ -1143,21 +1143,18 @@ def get_bbox(self, markerwidth, markeredgewidth=0): Returns ------- - bbox : matplotlib.transforms.Bbox The extents of the marker including its edge (in points) if it were centered at (0,0). - Notes - ----- """ # if the marker is of size zero, the stroke's width doesn't matter, # there is no stroke so the bbox is trivial - if np.isclose(markerwidth, 0): + if np.isclose(markersize, 0): return Bbox([[0, 0], [0, 0]]) unit_path = self._transform.transform_path(self._path) unit_bbox = unit_path.get_extents() - scale = Affine2D().scale(markerwidth) + scale = Affine2D().scale(markersize) [[left, bottom], [right, top]] = scale.transform(unit_bbox) angles = _edge_angles[self._marker] left -= _get_padding_due_to_angle(markeredgewidth, angles.left, From 66694a0509ea7e7b767764d88509cdccca09bd70 Mon Sep 17 00:00:00 2001 From: Bruno Beltran Date: Mon, 9 Mar 2020 05:33:35 -0700 Subject: [PATCH 20/33] use conversion not magic nums for line get_extents --- lib/matplotlib/lines.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/lib/matplotlib/lines.py b/lib/matplotlib/lines.py index 87e20b2a5aa9..7d111d5b2a40 100644 --- a/lib/matplotlib/lines.py +++ b/lib/matplotlib/lines.py @@ -620,8 +620,9 @@ def get_window_extent(self, renderer): m_bbox = self._marker.get_bbox( self._markersize, self._markeredgewidth) # markers use units of pts, not pixels - box_points_px = m_bbox.get_points() / 72.0 * self.figure.dpi - # add correct padding to each side of bbox + box_points_px = renderer.points_to_pixels(m_bbox.get_points()) + # add correct padding to each side of bbox (note: get_points means + # the four points of the bbox, not units of "pt". bbox = Bbox(bbox.get_points() + box_points_px) return bbox From c4a45de12b8bd68c1cabf20c19492e06063da4c3 Mon Sep 17 00:00:00 2001 From: Bruno Beltran Date: Mon, 9 Mar 2020 05:34:23 -0700 Subject: [PATCH 21/33] iter_curves: iterate over path more conveniently --- lib/matplotlib/path.py | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/lib/matplotlib/path.py b/lib/matplotlib/path.py index acb040594784..a69ae4a261f4 100644 --- a/lib/matplotlib/path.py +++ b/lib/matplotlib/path.py @@ -417,6 +417,35 @@ def iter_segments(self, transform=None, remove_nans=True, clip=None, curr_vertices = np.append(curr_vertices, next(vertices)) yield curr_vertices, code + def iter_curves(self, **kwargs): + """Iterate over segments of path as bezier segments. + + For each curve in the path, yields the relevant code along with an + np.array of size (N, 2), where N is the number of control points in the + segment, and d=2 is the dimension of the Path. + + kwargs get forwarded to :method:`Path.iter_segments`. + """ + first_vertex = None + for vertices, code in self.iter_segments(**kwargs): + if first_vertex is None: + if code != Path.MOVETO: + raise ValueError("Malformed path, must start with MOVETO.") + if code == Path.MOVETO: # a point is like "CURVE1" + first_vertex = vertices + yield np.array([first_vertex]), code + elif code == Path.LINETO: # "CURVE2" + yield np.array([prev_vertex, vertices]), code + elif code == Path.CURVE3: + yield np.array([prev_vertex, vertices[:2], vertices[2:]]), code + elif code == Path.CURVE4: + yield np.array([prev_vertex, vertices[:2], vertices[2:4], + vertices[4:]]), code + elif code == Path.CLOSEPOLY: + yield np.array([prev_vertex, first_vertex]), code + prev_vertex = vertices[-2:] + + @cbook._delete_parameter("3.3", "quantize") def cleaned(self, transform=None, remove_nans=False, clip=None, quantize=False, simplify=False, curves=False, From c5bdd8d592f0795403ee7776b86e84a5de3d46d3 Mon Sep 17 00:00:00 2001 From: Bruno Beltran Date: Tue, 10 Mar 2020 00:39:55 -0700 Subject: [PATCH 22/33] helper functions for bezier curve zeros/tangents --- lib/matplotlib/bezier.py | 67 ++++++++++++++++++++++++++++++++++++---- 1 file changed, 61 insertions(+), 6 deletions(-) diff --git a/lib/matplotlib/bezier.py b/lib/matplotlib/bezier.py index 9e347ce87f29..5e2dc474bb46 100644 --- a/lib/matplotlib/bezier.py +++ b/lib/matplotlib/bezier.py @@ -177,18 +177,73 @@ class BezierSegment: """ def __init__(self, control_points): - n = len(control_points) - self._orders = np.arange(n) - coeff = [math.factorial(n - 1) - // (math.factorial(i) * math.factorial(n - 1 - i)) - for i in range(n)] - self._px = np.asarray(control_points).T * coeff + self.cpoints = np.asarray(control_points) + self.n, self.d = self.cpoints.shape + self._orders = np.arange(self.n) + coeff = [math.factorial(self.n - 1) + // (math.factorial(i) * math.factorial(self.n - 1 - i)) + for i in range(self.n)] + self._px = self.cpoints.T * coeff def point_at_t(self, t): """Return the point on the Bezier curve for parameter *t*.""" return tuple( self._px @ (((1 - t) ** self._orders)[::-1] * t ** self._orders)) + @property + def tan_in(self): + if self.n < 2: + raise ValueError("Need at least two control points to get tangent " + "vector!") + return self.cpoints[1] - self.cpoints[0] + + @property + def tan_out(self): + if self.n < 2: + raise ValueError("Need at least two control points to get tangent " + "vector!") + return self.cpoints[-1] - self.cpoints[-2] + + @property + def interior_extrema(self): + if self.n <= 2: # a line's extrema are always its tips + return np.array([]), np.array([]) + elif self.n == 3: # quadratic curve + # the bezier curve in standard form is + # cp[0] * (1 - t)^2 + cp[1] * 2t(1-t) + cp[2] * t^2 + # can be re-written as + # cp[0] + 2 (cp[1] - cp[0]) t + (cp[2] - 2 cp[1] + cp[0]) t^2 + # which has simple derivative + # 2*(cp[2] - 2*cp[1] + cp[0]) t + 2*(cp[1] - cp[0]) + num = 2*(self.cpoints[2] - 2*self.cpoints[1] + self.cpoints[0]) + denom = self.cpoints[1] - self.cpoints[0] + mask = ~np.isclose(denom, 0) + zeros = num[mask]/denom[mask] + dims = np.arange(self.d)[mask] + in_range = (0 <= zeros) & (zeros <= 1) + return dims[in_range], zeros[in_range] + elif self.n == 4: # cubic curve + # derivative of cubic bezier curve has coefficients + a = 3*(points[3] - 3*points[2] + 3*points[1] - points[0]) + b = 6*(points[2] - 2*points[1] + points[0]) + c = 3*(points[1] - points[0]) + under_sqrt = b**2 - 4*a*c + dims = [] + zeros = [] + for i in range(d): + if under_sqrt[i] < 0: + continue + roots = [(-b + np.sqrt(under_sqrt))/2/a, + (-b - np.sqrt(under_sqrt))/2/a] + for root in roots: + if 0 <= root <= 1: + dims.append(i) + zeros.append(root) + return np.asarray(dims), np.asarray(zeros) + else: # self.n > 4: + raise NotImplementedError("Zero finding only implemented up to " + "cubic curves.") + def split_bezier_intersecting_with_closedpath( bezier, inside_closedpath, tolerance=0.01): From 41f268e07773827fd9f0636efe19ab007e8c0219 Mon Sep 17 00:00:00 2001 From: Bruno Beltran Date: Tue, 10 Mar 2020 00:40:45 -0700 Subject: [PATCH 23/33] CornerInfo in bezier.py, should be path.py --- lib/matplotlib/bezier.py | 129 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 129 insertions(+) diff --git a/lib/matplotlib/bezier.py b/lib/matplotlib/bezier.py index 5e2dc474bb46..9ef08a22e181 100644 --- a/lib/matplotlib/bezier.py +++ b/lib/matplotlib/bezier.py @@ -359,6 +359,135 @@ def _f(xy): return _f +CornerInfo = namedtuple('CornerInfo', 'apex incidence_angle corner_angle') +r"""Used to have a universal way to account for how much the bounding box of a +shape will grow as we increase its `markeredgewidth`. + +Attributes +---------- + `apex` : float + the vertex that marks the "tip" of the corner + `incidence_angle` : float + the angle that the corner bisector makes with the box edge (where + top/bottom box edges are horizontal, left/right box edges are + vertical). + `corner_angle` : float + the internal angle of the corner, where np.pi is a straight line, and 0 + is retracing exactly the way you came. None can be used to signify that + the line ends there (i.e. no corner). + +Notes +----- +$\pi$ and 0 are equivalent for `corner_angle`. Both $\theta$ and $\pi - \theta$ +are equivalent for `incidence_angle` by symmetry.""" + + +def _incidence_corner_from_angles(angle_1, angle_2): + """Gets CornerInfo from direction of lines making up corner. + + This function expects angle_1 and angle_2 (in radians) to be + the orientation of lines 1 and 2 (arbitrarily chosen to point + towards the corner where they meet) relative to the coordinate + system. + + Helper function for iter_corners. + + Returns + ------- + incidence_angle : float in [0, 2*pi] + as described in CornerInfo docs + corner_angle : float in [0, pi] + as described in CornerInfo docs + """ + # get "interior" angle between tangents to joined curves' tips + corner_angle = np.abs(angle_1 - angle_2) + if corner_angle > np.pi: + corner_angle = 2*np.pi - corner_angle + # since [-pi, pi], we need to sort to avoid modulo + smaller_angle = min(angle_1, angle_2) + larger_angle = max(angle_1, angle_2) + if np.isclose(smaller_angle + corner_angle, larger_angle): + incident_angle = smaller_angle + corner_angle/2 + else: + incident_angle = smaller_angle - corner_angle/2 + return incident_angle, corner_angle + + +def iter_corners(path, **kwargs): + """Iterate over a mpl.path.Path object and return information about every + cap and corner. + + Parameters + ---------- + path : mpl.path.Path + the path to extract corners from + kwargs : Dict[str, object] + passed onto Path.iter_curves + + Yields + ------ + corner : CornerInfo + Measure of the corner's position, orientation, and angle. Useful in + order to determine how the corner affects the bbox of the curve. + """ + first_tan_angle = None + first_vertex = None + prev_tan_angle = None + prev_vertex = None + for bcurve, code in test_path.iter_curves(**kwargs): + bcurve = BezierSegment(bcurve) + if code == Path.MOVETO: + # deal with capping ends of previous polyline, if it exists + if prev_tan_angle is not None and is_capped: + for cap_angle, cap_vertex in [(first_tan_angle, first_vertex), + (prev_tan_angle, prev_vertex)]: + yield CornerInfo(cap_vertex, cap_angle, None) + first_tan_angle = None + prev_tan_angle = None + first_vertex = bcurve.cpoints[0] + prev_vertex = first_vertex + # lines will end in a cap by default unless a CLOSEPOLY is observed + is_capped = True + continue + if code == Path.CLOSEPOLY: + is_capped = False + if prev_tan_angle is None: + raise ValueError("Misformed path, cannot close poly with single vertex!") + tan_in = prev_vertex - first_vertex + # often CLOSEPOLY is used when the curve has already reached it's initial point + # in order to prevent there from being a stray straight line segment + # if it's used this way, then we more or less ignore the current bcurve + if np.isclose(np.linalg.norm(tan_in), 0): + incident_angle, corner_angle = _incidence_corner_from_angles( + prev_tan_angle, first_tan_angle) + yield CornerInfo(prev_vertex, incident_angle, corner_angle) + continue + # otherwise, we have to calculate both the corner from the + # previous line segment to the current straight line, and from the current straight + # line to the original starting line. The former is taken care of by the + # non-special-case code below. the latter looks like: + tan_out = bcurve.tan_out + angle_end = np.arctan2(tan_out[1], tan_out[0]) + incident_angle, corner_angle = _incidence_corner_from_angles( + angle_end, first_tan_angle) + yield CornerInfo(first_vertex, incident_angle, corner_angle) + # finally, usual case is when two curves meet at an angle + tan_in = -bcurve.tan_in + angle_in = np.arctan2(tan_in[1], tan_in[0]) + if first_tan_angle is None: + first_tan_angle = angle_in + if prev_tan_angle is not None: + incident_angle, corner_angle = _incidence_corner_from_angles( + angle_in, prev_tan_angle) + yield CornerInfo(prev_vertex, incident_angle, corner_angle) + tan_out = bcurve.tan_out + prev_tan_angle = np.arctan2(tan_out[1], tan_out[0]) + prev_vertex = bcurve.cpoints[-1] + if prev_tan_angle is not None and is_capped: + for cap_angle, cap_vertex in [(first_tan_angle, first_vertex), + (prev_tan_angle, prev_vertex)]: + yield CornerInfo(cap_vertex, cap_angle, None) + # quadratic Bezier lines def get_cos_sin(x0, y0, x1, y1): From 637a7f289a96dce4111dfcf7428b195dac6974a5 Mon Sep 17 00:00:00 2001 From: Bruno Beltran Date: Tue, 10 Mar 2020 02:12:06 -0700 Subject: [PATCH 24/33] update marker bbox to work for arbitrary paths --- lib/matplotlib/bezier.py | 1 + lib/matplotlib/lines.py | 2 +- lib/matplotlib/markers.py | 272 ++++++++++------------------ lib/matplotlib/tests/test_marker.py | 167 +++++++++++++++++ 4 files changed, 268 insertions(+), 174 deletions(-) diff --git a/lib/matplotlib/bezier.py b/lib/matplotlib/bezier.py index 9ef08a22e181..715385017de5 100644 --- a/lib/matplotlib/bezier.py +++ b/lib/matplotlib/bezier.py @@ -3,6 +3,7 @@ """ import math +from collections import namedtuple import numpy as np diff --git a/lib/matplotlib/lines.py b/lib/matplotlib/lines.py index 7d111d5b2a40..7b9d6120a049 100644 --- a/lib/matplotlib/lines.py +++ b/lib/matplotlib/lines.py @@ -617,7 +617,7 @@ def get_window_extent(self, renderer): ignore=True) # correct for marker size, if any if self._marker: - m_bbox = self._marker.get_bbox( + m_bbox = self._marker.get_stroked_bbox( self._markersize, self._markeredgewidth) # markers use units of pts, not pixels box_points_px = renderer.points_to_pixels(m_bbox.get_points()) diff --git a/lib/matplotlib/markers.py b/lib/matplotlib/markers.py index 333c1bb3ef4c..2175c06f71c3 100644 --- a/lib/matplotlib/markers.py +++ b/lib/matplotlib/markers.py @@ -135,6 +135,7 @@ from . import cbook, rcParams from .path import Path from .transforms import IdentityTransform, Affine2D, Bbox +from .bezier import BezierSegment, iter_corners # special-purpose marker identifiers: (TICKLEFT, TICKRIGHT, TICKUP, TICKDOWN, @@ -143,154 +144,32 @@ _empty_path = Path(np.empty((0, 2))) -# we store some geometrical information about each marker to track how its -# size scales with increased "edge" thickness -PathEndAngle = namedtuple('PathEndAngle', 'incidence_angle corner_angle') -r"""Used to have a universal way to account for how much the bounding box of a -shape will grow as we increase its `markeredgewidth`. - -Attributes ----------- - `incidence_angle` : float - the angle that the corner bisector makes with the box edge (where - top/bottom box edges are horizontal, left/right box edges are - vertical). - `corner_angle` : float - the internal angle of the corner, where np.pi is a straight line, and 0 - is retracing exactly the way you came. None can be used to signify that - the line ends there (i.e. no corner). - -Notes ------ -$\pi$ and 0 are equivalent for `corner_angle`. Both $\theta$ and $\pi - \theta$ -are equivalent for `incidence_angle` by symmetry.""" - -BoxSides = namedtuple('BoxSides', 'top bottom left right') -"""Easily keep track of same parameter for each of four sides.""" - -# some angles are heavily repeated throughout various markers -_tri_side_angle = np.arctan(2) -_tri_tip_angle = 2*np.arctan(1/2) -_caret_side_angle = np.arctan(3/2) -_caret_tip_angle = 2*np.arctan(2/3) -# half the edge length of the smaller pentagon over the difference between the -# larger pentagon's circumcribing radius and the smaller pentagon's inscribed -# radius #TODO this formula has typo somewhere.... -# _star_tip_angle = 2*np.arctan2((1/4)*np.sqrt((5 - np.sqrt(5))/2), -# 1 - np.sqrt((3 + np.sqrt(5))/32)) -_star_tip_angle = 0.6283185056636065 -# reusable corner types -_flat_side = PathEndAngle(0, 0) -_normal_line = PathEndAngle(np.pi/2, None) -_normal_right_angle = PathEndAngle(np.pi/2, np.pi/2) -_tri_side = PathEndAngle(np.pi/2 - _tri_side_angle/2, _tri_side_angle) -_tri_tip = PathEndAngle(np.pi/2, _tri_tip_angle) -_caret_bottom = PathEndAngle(_caret_side_angle, None) -_caret_side = PathEndAngle(np.pi/2 - _caret_side_angle, None) -_caret_tip = PathEndAngle(np.pi/2, _caret_tip_angle) -# and some entire box side behaviors are repeated among markers -_effective_square = BoxSides(_flat_side, _flat_side, _flat_side, _flat_side) -_effective_diamond = BoxSides(_normal_right_angle, _normal_right_angle, - _normal_right_angle, _normal_right_angle) - -# precomputed information required for marker_bbox (besides _joinstyle) -_edge_angles = { - '.': _effective_square, - ',': _effective_square, - 'o': _effective_square, - # hit two corners and tip bisects one side of unit square - 'v': BoxSides(_flat_side, _tri_tip, _tri_side, _tri_side), - '^': BoxSides(_tri_tip, _flat_side, _tri_side, _tri_side), - '<': BoxSides(_tri_side, _tri_side, _tri_tip, _flat_side), - '>': BoxSides(_tri_side, _tri_side, _flat_side, _tri_tip), - # angle bisectors of an equilateral triangle. lines of length 1/2 - '1': BoxSides(PathEndAngle(np.pi/6, None), PathEndAngle(np.pi/2, None), - PathEndAngle(np.pi/3, None), PathEndAngle(np.pi/3, None)), - '2': BoxSides(PathEndAngle(np.pi/2, None), PathEndAngle(np.pi/6, None), - PathEndAngle(np.pi/3, None), PathEndAngle(np.pi/3, None)), - '3': BoxSides(PathEndAngle(np.pi/3, None), PathEndAngle(np.pi/3, None), - PathEndAngle(np.pi/2, None), PathEndAngle(np.pi/6, None)), - '4': BoxSides(PathEndAngle(np.pi/3, None), PathEndAngle(np.pi/3, None), - PathEndAngle(np.pi/6, None), PathEndAngle(np.pi/2, None)), - # regular polygons, circumscribed in circle of radius 1. - '8': _effective_square, - 's': _effective_square, - 'p': BoxSides(PathEndAngle(np.pi/2, 3*np.pi/5), _flat_side, - PathEndAngle(2*np.pi/5, 3*np.pi/5), - PathEndAngle(2*np.pi/5, 3*np.pi/5)), - # tips are corners of regular pentagon circuscribed in circle of radius 1. - # so incidence angles are same as pentagon - # interior points are corners of another regular pentagon, whose - # circumscribing circle has radius 0.5, so all tip angles are same - '*': BoxSides(PathEndAngle(np.pi/2, _star_tip_angle), - PathEndAngle(3*np.pi/10, _star_tip_angle), - PathEndAngle(2*np.pi/5, _star_tip_angle), - PathEndAngle(2*np.pi/5, _star_tip_angle)), - 'h': BoxSides(PathEndAngle(np.pi/2, 2*np.pi/3), - PathEndAngle(np.pi/2, 2*np.pi/3), - _flat_side, _flat_side), - 'H': BoxSides(_flat_side, _flat_side, - PathEndAngle(np.pi/2, 2*np.pi/3), - PathEndAngle(np.pi/2, 2*np.pi/3)), - '+': BoxSides(_normal_line, _normal_line, _normal_line, _normal_line), - 'x': BoxSides(PathEndAngle(np.pi/4, None), PathEndAngle(np.pi/4, None), - PathEndAngle(np.pi/4, None), PathEndAngle(np.pi/4, None)), - # unit square rotated pi/2 - 'D': _effective_diamond, - # D scaled by 0.6 in horizontal direction - 'd': BoxSides(PathEndAngle(np.pi/2, 2*np.arctan(3/5)), - PathEndAngle(np.pi/2, 2*np.arctan(3/5)), - PathEndAngle(np.pi/2, 2*np.arctan(5/3)), - PathEndAngle(np.pi/2, 2*np.arctan(5/3))), - '|': BoxSides(_normal_line, _normal_line, _flat_side, _flat_side), - '_': BoxSides(_flat_side, _flat_side, _normal_line, _normal_line), - 'P': _effective_square, - 'X': _effective_diamond, - TICKLEFT: BoxSides(_flat_side, _flat_side, _normal_line, _normal_line), - TICKRIGHT: BoxSides(_flat_side, _flat_side, _normal_line, _normal_line), - TICKUP: BoxSides(_normal_line, _normal_line, _flat_side, _flat_side), - TICKDOWN: BoxSides(_normal_line, _normal_line, _flat_side, _flat_side), - # carets missing the edge opposite their "tip", different size than tri's - CARETLEFT: BoxSides(_caret_side, _caret_side, _caret_tip, _caret_bottom), - CARETRIGHT: BoxSides(_caret_side, _caret_side, _caret_bottom, _caret_tip), - CARETUP: BoxSides(_caret_tip, _caret_bottom, _caret_side, _caret_side), - CARETDOWN: BoxSides(_caret_bottom, _caret_tip, _caret_side, _caret_side), - CARETLEFTBASE: BoxSides(_caret_side, _caret_side, _caret_tip, - _caret_bottom), - CARETRIGHTBASE: BoxSides(_caret_side, _caret_side, _caret_bottom, - _caret_tip), - CARETUPBASE: BoxSides(_caret_tip, _caret_bottom, _caret_side, _caret_side), - CARETDOWNBASE: BoxSides(_caret_bottom, _caret_tip, _caret_side, - _caret_side), - '': BoxSides(None, None, None, None), - ' ': BoxSides(None, None, None, None), - 'None': BoxSides(None, None, None, None), - None: BoxSides(None, None, None, None), -} - - -def _get_padding_due_to_angle(width, path_end_angle, joinstyle='miter', +def _get_padding_due_to_angle(width, phi, theta, joinstyle='miter', capstyle='butt'): """Computes how much a stroke of *width* overflows the naive bbox at a - corner described by *path_end_angle*. + corner described by *corner_info*. Parameters ---------- width : float `markeredgewidth` used to draw the stroke that we're computing the - overflow of - path_end_angle : PathEndAngle - precomputed property of a corner that allows us to compute the overflow + overflow for + phi : float + incidence angle of bisector of corner relative to side of bounding box + we're calculating the padding for + theta : float or None + angle swept out by the two lines that form the corner. if None, the + padding due to a "cap" is used instead of a corner + joinstyle : 'miter' (default), 'round', or 'bevel' + how the corner is to be drawn + capstyle : 'butt', 'round', 'projecting' + Returns ------- pad : float amount of overflow - """ - if path_end_angle is None or width < 0: - return np.nan - phi, theta = path_end_angle.incidence_angle, path_end_angle.corner_angle if theta is not None and (theta < 0 or theta > np.pi) \ or phi < 0 or phi > np.pi: raise ValueError("Corner angles should be in [0, pi].") @@ -298,22 +177,27 @@ def _get_padding_due_to_angle(width, path_end_angle, joinstyle='miter', # equivalent by symmetry, but keeps math simpler phi = np.pi - phi # if there's no corner (i.e. the path just ends, as in the "sides" of the - # carets or in the non-fillable markers, we can compute how far the outside - # edge of the markeredge stroke extends outside of the bounding box of its - # path using the law of sines: $\sin(\phi)/(w/2) = \sin(\pi/2 - \phi)/l$ - # for $w$ the `markeredgewidth`, $\phi$ the incidence angle of the line, - # then $l$ is the length along the outer edge of the stroke that extends - # beyond the bouding box. We can translate this to a distance perpendicular - # to the bounding box E(w, \phi) = l \sin(\phi)$, for $l$ as above. + # caret marker (and other non-fillable markers), we still need to compute + # how much the "cap" extends past the endpoint of the path if theta is None: - # also note that in this case, we shouldn't check _joinstyle because - # it's going to be "round" by default but not actually be in use. what - # we care about is _capstyle, which is currently always its default - # "butt" for all markers. if we find otherwise, we should change this - # code to check for the "projecting" and "round" cases - if capstyle != 'butt': - raise NotImplementedError("Only capstyle='butt' currently needed") - pad = (width/2) * np.cos(phi) + # for "butt" caps we can compute how far the + # outside edge of the markeredge stroke extends outside of the bounding box + # of its path using the law of sines: $\sin(\phi)/(w/2) = \sin(\pi/2 - + # \phi)/l$ for $w$ the `markeredgewidth`, $\phi$ the incidence angle of the + # line, then $l$ is the length along the outer edge of the stroke that + # extends beyond the bouding box. We can translate this to a distance + # perpendicular to the bounding box E(w, \phi) = l \sin(\phi)$, for $l$ as + # above. + if capstyle == 'butt': + pad = (width/2) * np.cos(phi) + # "round" caps are hemispherical, so regardless of angle + elif capstyle == 'round': + pad = width/2 + # finally, projecting caps are just bevel caps with an extra + # width/2 distance along the direction of the line, so "butt" plus some + # extra + elif capstyle == 'projecting': + pad = (width/2) * np.cos(phi) + (width/2)*np.sin(phi) # the two "same as straight line" cases are NaN limits in the miter formula elif np.isclose(theta, 0) and np.isclose(phi, 0) \ or np.isclose(theta, np.pi) and np.isclose(phi, np.pi/2): @@ -338,6 +222,9 @@ def _get_padding_due_to_angle(width, path_end_angle, joinstyle='miter', # bbox, this correctly simplifies to just $w/2$. elif joinstyle == 'miter': pad = (width/2)*np.sin(phi)/np.sin(theta/2) + # # matplotlib currently doesn't set the miterlimit... + # if pad/width > miterlimit: + # pad = _get_padding_due_to_angle(width, phi, theta, 'bevel', capstyle) # to calculate the offset for _joinstyle = "bevel", we can start with the # analogous "miter" corner. the rules for how the "bevel" is # created in SVG is that the outer edges of the stroke continue up until @@ -364,8 +251,7 @@ def _get_padding_due_to_angle(width, path_end_angle, joinstyle='miter', # it....except those with "no corner", in which case we can treat them the # same as squares... elif joinstyle == 'round': - raise NotImplementedError("Only 'miter' and 'bevel' joinstyles needed " - "for now") + return width/2 # hemispherical cap, so always same padding else: raise ValueError(f"Unknown joinstyle: {joinstyle}") return pad @@ -1125,13 +1011,8 @@ def _set_x_filled(self): self._transform.rotate_deg(rotate) self._alt_transform.rotate_deg(rotate_alt) - def get_bbox(self, markersize, markeredgewidth=0): - """Get size of bbox if marker is centered at origin. - - For markers with no edge, this is just the same bbox as that of the - transformed marker path, but how much extra extent is added by an edge - is a function of the angle of the path at its own (the path's own) - boundary. + def get_stroked_bbox(self, markersize, markeredgewidth=0, **kwargs): + """Get size of bbox of marker directly from its path. Parameters ---------- @@ -1147,22 +1028,67 @@ def get_bbox(self, markersize, markeredgewidth=0): The extents of the marker including its edge (in points) if it were centered at (0,0). + Note + ---- + The approach used is simply to notice that the bbox with no marker edge + must be defined by a corner (control point of the linear parts of path) + or a an extremal point on one of the curved parts of the path. + + For a nonzero marker edge width, because the interior extrema will by + definition be parallel to the bounding box, we need only check if the + path location + width/2 extends the bbox at each interior extrema. + Then, for each join and cap, we check if that join extends the bbox. """ - # if the marker is of size zero, the stroke's width doesn't matter, - # there is no stroke so the bbox is trivial + xmin = 0; ymin = 1; xmax = 2; ymax = 3; maxi = 2 if np.isclose(markersize, 0): return Bbox([[0, 0], [0, 0]]) unit_path = self._transform.transform_path(self._path) - unit_bbox = unit_path.get_extents() + # get_extents returns a bbox, so Bbox.extents + unit_extents = unit_path.get_extents().extents + for curve, code in unit_path.iter_curves(**kwargs): + curve = BezierSegment(curve) + for dim, zero in zip(curve.interior_extrema): + potential_extrema = curve.point_at_t(zero)[dim] + if potential_extrema < unit_extents[dim]: + unit_extents[dim] = potential_extrema + if potential_extrema > unit_extents[maxi+dim]: + unit_extents[maxi+dim] = potential_extrema + for corner in iter_corners(unit_path, **kwargs): + x, y = corner.apex + # now for each of up/down/left/right, convert the absolute + # incidence angle into the incidence angle relative to that + # respective side of the bbox, and see if the corner expands the + # extents... + if np.cos(corner.incidence_angle) > 0: + incidence_angle = corner.incidence_angle + np.pi/2 + x += _get_padding_due_to_angle(width, incidence_angle, + corner.corner_angle, joinstyle=self._joinstyle, + capstyle=self._capstyle) + if x > unit_extents[xmax]: + unit_extents[xmax] = x + else: + if corner.incidence_angle < 0: # [-pi, -pi/2] + incidence_angle = 2*np.pi + corner.incidence_angle - pi/2 + else: + incidence_angle = corner.incidence_angle - pi/2 + x -= _get_padding_due_to_angle(width, incidence_angle, + corner.corner_angle, joinstyle=self._joinstyle, + capstyle=self._capstyle) + if x < unit_extents[xmin]: + unit_extents[xmin] = x + if np.sin(corner.incidence_angle) > 0: + incidence_angle = corner.incidence_angle + y += _get_padding_due_to_angle(width, incidence_angle, + corner.corner_angle, joinstyle=self._joinstyle, + capstyle=self._capstyle) + if y > unit_extents[ymax]: + unit_extents[ymax] = y + else: + incidence_angle = corner.incidence_angle + np.pi + y -= _get_padding_due_to_angle(width, incidence_angle, + corner.corner_angle, joinstyle=self._joinstyle, + capstyle=self._capstyle) + if y < unit_extents[ymin]: + unit_extents[ymin] = y scale = Affine2D().scale(markersize) - [[left, bottom], [right, top]] = scale.transform(unit_bbox) - angles = _edge_angles[self._marker] - left -= _get_padding_due_to_angle(markeredgewidth, angles.left, - self._joinstyle, self._capstyle) - bottom -= _get_padding_due_to_angle(markeredgewidth, angles.bottom, - self._joinstyle, self._capstyle) - right += _get_padding_due_to_angle(markeredgewidth, angles.right, - self._joinstyle, self._capstyle) - top += _get_padding_due_to_angle(markeredgewidth, angles.top, - self._joinstyle, self._capstyle) - return Bbox.from_extents(left, bottom, right, top) + return Bbox(scale.transform(Bbox.from_extents(unit_extents))) diff --git a/lib/matplotlib/tests/test_marker.py b/lib/matplotlib/tests/test_marker.py index 06159cbd5064..d1e1e8df568c 100644 --- a/lib/matplotlib/tests/test_marker.py +++ b/lib/matplotlib/tests/test_marker.py @@ -67,3 +67,170 @@ def test_marker_bbox_star(): extensions=['png']) def test_marker_bbox_pentagon(): _draw_marker_outlined('p', markeredgewidth=20) + +# we store some geometrical information about each marker to track how its +# size scales with increased "edge" thickness +PathEndAngle = namedtuple('PathEndAngle', 'incidence_angle corner_angle') +r"""Used to have a universal way to account for how much the bounding box of a +shape will grow as we increase its `markeredgewidth`. + +Attributes +---------- + `incidence_angle` : float + the angle that the corner bisector makes with the box edge (where + top/bottom box edges are horizontal, left/right box edges are + vertical). + `corner_angle` : float + the internal angle of the corner, where np.pi is a straight line, and 0 + is retracing exactly the way you came. None can be used to signify that + the line ends there (i.e. no corner). + +Notes +----- +$\pi$ and 0 are equivalent for `corner_angle`. Both $\theta$ and $\pi - \theta$ +are equivalent for `incidence_angle` by symmetry.""" + +BoxSides = namedtuple('BoxSides', 'top bottom left right') +"""Easily keep track of same parameter for each of four sides.""" + +# some angles are heavily repeated throughout various markers +_tri_side_angle = np.arctan(2) +_tri_tip_angle = 2*np.arctan(1/2) +_caret_side_angle = np.arctan(3/2) +_caret_tip_angle = 2*np.arctan(2/3) +# half the edge length of the smaller pentagon over the difference between the +# larger pentagon's circumcribing radius and the smaller pentagon's inscribed +# radius #TODO this formula has typo somewhere.... +# _star_tip_angle = 2*np.arctan2((1/4)*np.sqrt((5 - np.sqrt(5))/2), +# 1 - np.sqrt((3 + np.sqrt(5))/32)) +_star_tip_angle = 0.6283185056636065 +# reusable corner types +_flat_side = PathEndAngle(0, 0) +_normal_line = PathEndAngle(np.pi/2, None) +_normal_right_angle = PathEndAngle(np.pi/2, np.pi/2) +_tri_side = PathEndAngle(np.pi/2 - _tri_side_angle/2, _tri_side_angle) +_tri_tip = PathEndAngle(np.pi/2, _tri_tip_angle) +_caret_bottom = PathEndAngle(_caret_side_angle, None) +_caret_side = PathEndAngle(np.pi/2 - _caret_side_angle, None) +_caret_tip = PathEndAngle(np.pi/2, _caret_tip_angle) +# and some entire box side behaviors are repeated among markers +_effective_square = BoxSides(_flat_side, _flat_side, _flat_side, _flat_side) +_effective_diamond = BoxSides(_normal_right_angle, _normal_right_angle, + _normal_right_angle, _normal_right_angle) + +# precomputed information required for marker_bbox (besides _joinstyle) +_edge_angles = { + '.': _effective_square, + ',': _effective_square, + 'o': _effective_square, + # hit two corners and tip bisects one side of unit square + 'v': BoxSides(_flat_side, _tri_tip, _tri_side, _tri_side), + '^': BoxSides(_tri_tip, _flat_side, _tri_side, _tri_side), + '<': BoxSides(_tri_side, _tri_side, _tri_tip, _flat_side), + '>': BoxSides(_tri_side, _tri_side, _flat_side, _tri_tip), + # angle bisectors of an equilateral triangle. lines of length 1/2 + '1': BoxSides(PathEndAngle(np.pi/6, None), PathEndAngle(np.pi/2, None), + PathEndAngle(np.pi/3, None), PathEndAngle(np.pi/3, None)), + '2': BoxSides(PathEndAngle(np.pi/2, None), PathEndAngle(np.pi/6, None), + PathEndAngle(np.pi/3, None), PathEndAngle(np.pi/3, None)), + '3': BoxSides(PathEndAngle(np.pi/3, None), PathEndAngle(np.pi/3, None), + PathEndAngle(np.pi/2, None), PathEndAngle(np.pi/6, None)), + '4': BoxSides(PathEndAngle(np.pi/3, None), PathEndAngle(np.pi/3, None), + PathEndAngle(np.pi/6, None), PathEndAngle(np.pi/2, None)), + # regular polygons, circumscribed in circle of radius 1. + '8': _effective_square, + 's': _effective_square, + 'p': BoxSides(PathEndAngle(np.pi/2, 3*np.pi/5), _flat_side, + PathEndAngle(2*np.pi/5, 3*np.pi/5), + PathEndAngle(2*np.pi/5, 3*np.pi/5)), + # tips are corners of regular pentagon circuscribed in circle of radius 1. + # so incidence angles are same as pentagon + # interior points are corners of another regular pentagon, whose + # circumscribing circle has radius 0.5, so all tip angles are same + '*': BoxSides(PathEndAngle(np.pi/2, _star_tip_angle), + PathEndAngle(3*np.pi/10, _star_tip_angle), + PathEndAngle(2*np.pi/5, _star_tip_angle), + PathEndAngle(2*np.pi/5, _star_tip_angle)), + 'h': BoxSides(PathEndAngle(np.pi/2, 2*np.pi/3), + PathEndAngle(np.pi/2, 2*np.pi/3), + _flat_side, _flat_side), + 'H': BoxSides(_flat_side, _flat_side, + PathEndAngle(np.pi/2, 2*np.pi/3), + PathEndAngle(np.pi/2, 2*np.pi/3)), + '+': BoxSides(_normal_line, _normal_line, _normal_line, _normal_line), + 'x': BoxSides(PathEndAngle(np.pi/4, None), PathEndAngle(np.pi/4, None), + PathEndAngle(np.pi/4, None), PathEndAngle(np.pi/4, None)), + # unit square rotated pi/2 + 'D': _effective_diamond, + # D scaled by 0.6 in horizontal direction + 'd': BoxSides(PathEndAngle(np.pi/2, 2*np.arctan(3/5)), + PathEndAngle(np.pi/2, 2*np.arctan(3/5)), + PathEndAngle(np.pi/2, 2*np.arctan(5/3)), + PathEndAngle(np.pi/2, 2*np.arctan(5/3))), + '|': BoxSides(_normal_line, _normal_line, _flat_side, _flat_side), + '_': BoxSides(_flat_side, _flat_side, _normal_line, _normal_line), + 'P': _effective_square, + 'X': _effective_diamond, + TICKLEFT: BoxSides(_flat_side, _flat_side, _normal_line, _normal_line), + TICKRIGHT: BoxSides(_flat_side, _flat_side, _normal_line, _normal_line), + TICKUP: BoxSides(_normal_line, _normal_line, _flat_side, _flat_side), + TICKDOWN: BoxSides(_normal_line, _normal_line, _flat_side, _flat_side), + # carets missing the edge opposite their "tip", different size than tri's + CARETLEFT: BoxSides(_caret_side, _caret_side, _caret_tip, _caret_bottom), + CARETRIGHT: BoxSides(_caret_side, _caret_side, _caret_bottom, _caret_tip), + CARETUP: BoxSides(_caret_tip, _caret_bottom, _caret_side, _caret_side), + CARETDOWN: BoxSides(_caret_bottom, _caret_tip, _caret_side, _caret_side), + CARETLEFTBASE: BoxSides(_caret_side, _caret_side, _caret_tip, + _caret_bottom), + CARETRIGHTBASE: BoxSides(_caret_side, _caret_side, _caret_bottom, + _caret_tip), + CARETUPBASE: BoxSides(_caret_tip, _caret_bottom, _caret_side, _caret_side), + CARETDOWNBASE: BoxSides(_caret_bottom, _caret_tip, _caret_side, + _caret_side), + '': BoxSides(None, None, None, None), + ' ': BoxSides(None, None, None, None), + 'None': BoxSides(None, None, None, None), + None: BoxSides(None, None, None, None), +} + +def _get_bbox_path_end_angle(, markersize, markeredgewidth=0): + """Get size of bbox if marker is centered at origin. + + For markers with no edge, this is just the same bbox as that of the + transformed marker path, but how much extra extent is added by an edge + is a function of the angle of the path at its own (the path's own) + boundary. + + Parameters + ---------- + markersize : float + "Size" of the marker, in points. + + markeredgewidth : float, optional, default: 0 + Width, in points, of the stroke used to create the marker's edge. + + Returns + ------- + bbox : matplotlib.transforms.Bbox + The extents of the marker including its edge (in points) if it were + centered at (0,0). + + """ + # if the marker is of size zero, the stroke's width doesn't matter, + # there is no stroke so the bbox is trivial + if np.isclose(markersize, 0): + return Bbox([[0, 0], [0, 0]]) + unit_path = self._transform.transform_path(self._path) + unit_bbox = unit_path.get_extents() + scale = Affine2D().scale(markersize) + [[left, bottom], [right, top]] = scale.transform(unit_bbox) + angles = _edge_angles[self._marker] + left -= _get_padding_due_to_angle(markeredgewidth, angles.left, + self._joinstyle, self._capstyle) + bottom -= _get_padding_due_to_angle(markeredgewidth, angles.bottom, + self._joinstyle, self._capstyle) + right += _get_padding_due_to_angle(markeredgewidth, angles.right, + self._joinstyle, self._capstyle) + top += _get_padding_due_to_angle(markeredgewidth, angles.top, + self._joinstyle, self._capstyle) + return Bbox.from_extents(left, bottom, right, top) From 5ac91140896ae9b8358a2cbfe9cdc8f554ab9b41 Mon Sep 17 00:00:00 2001 From: Bruno Beltran Date: Tue, 10 Mar 2020 02:31:18 -0700 Subject: [PATCH 25/33] bugfix, new marker bbox code now runs, untested --- lib/matplotlib/bezier.py | 2 +- lib/matplotlib/markers.py | 31 +++++++++++++++++-------------- 2 files changed, 18 insertions(+), 15 deletions(-) diff --git a/lib/matplotlib/bezier.py b/lib/matplotlib/bezier.py index 715385017de5..e2ab2f375d6c 100644 --- a/lib/matplotlib/bezier.py +++ b/lib/matplotlib/bezier.py @@ -435,7 +435,7 @@ def iter_corners(path, **kwargs): first_vertex = None prev_tan_angle = None prev_vertex = None - for bcurve, code in test_path.iter_curves(**kwargs): + for bcurve, code in path.iter_curves(**kwargs): bcurve = BezierSegment(bcurve) if code == Path.MOVETO: # deal with capping ends of previous polyline, if it exists diff --git a/lib/matplotlib/markers.py b/lib/matplotlib/markers.py index 2175c06f71c3..040e0304ed03 100644 --- a/lib/matplotlib/markers.py +++ b/lib/matplotlib/markers.py @@ -1047,7 +1047,10 @@ def get_stroked_bbox(self, markersize, markeredgewidth=0, **kwargs): unit_extents = unit_path.get_extents().extents for curve, code in unit_path.iter_curves(**kwargs): curve = BezierSegment(curve) - for dim, zero in zip(curve.interior_extrema): + dims, zeros = curve.interior_extrema + if len(zeros) == 0: + continue + for dim, zero in zip(dims, zeros): potential_extrema = curve.point_at_t(zero)[dim] if potential_extrema < unit_extents[dim]: unit_extents[dim] = potential_extrema @@ -1061,33 +1064,33 @@ def get_stroked_bbox(self, markersize, markeredgewidth=0, **kwargs): # extents... if np.cos(corner.incidence_angle) > 0: incidence_angle = corner.incidence_angle + np.pi/2 - x += _get_padding_due_to_angle(width, incidence_angle, - corner.corner_angle, joinstyle=self._joinstyle, - capstyle=self._capstyle) + x += _get_padding_due_to_angle(markeredgewidth, + incidence_angle, corner.corner_angle, + joinstyle=self._joinstyle, capstyle=self._capstyle) if x > unit_extents[xmax]: unit_extents[xmax] = x else: if corner.incidence_angle < 0: # [-pi, -pi/2] - incidence_angle = 2*np.pi + corner.incidence_angle - pi/2 + incidence_angle = 2*np.pi + corner.incidence_angle - np.pi/2 else: incidence_angle = corner.incidence_angle - pi/2 - x -= _get_padding_due_to_angle(width, incidence_angle, - corner.corner_angle, joinstyle=self._joinstyle, - capstyle=self._capstyle) + x -= _get_padding_due_to_angle(markeredgewidth, + incidence_angle, corner.corner_angle, + joinstyle=self._joinstyle, capstyle=self._capstyle) if x < unit_extents[xmin]: unit_extents[xmin] = x if np.sin(corner.incidence_angle) > 0: incidence_angle = corner.incidence_angle - y += _get_padding_due_to_angle(width, incidence_angle, - corner.corner_angle, joinstyle=self._joinstyle, - capstyle=self._capstyle) + y += _get_padding_due_to_angle(markeredgewidth, + incidence_angle, corner.corner_angle, + joinstyle=self._joinstyle, capstyle=self._capstyle) if y > unit_extents[ymax]: unit_extents[ymax] = y else: incidence_angle = corner.incidence_angle + np.pi - y -= _get_padding_due_to_angle(width, incidence_angle, - corner.corner_angle, joinstyle=self._joinstyle, - capstyle=self._capstyle) + y -= _get_padding_due_to_angle(markeredgewidth, + incidence_angle, corner.corner_angle, + joinstyle=self._joinstyle, capstyle=self._capstyle) if y < unit_extents[ymin]: unit_extents[ymin] = y scale = Affine2D().scale(markersize) From ef36ec243a265195ecf0c6ba6a88a6f699468992 Mon Sep 17 00:00:00 2001 From: Bruno Beltran Date: Tue, 10 Mar 2020 02:44:51 -0700 Subject: [PATCH 26/33] pyflake fixes for marker bbox code --- lib/matplotlib/bezier.py | 42 ++++++++++++++++------------- lib/matplotlib/markers.py | 35 +++++++++++++----------- lib/matplotlib/path.py | 6 ++--- lib/matplotlib/tests/test_marker.py | 15 ++++++----- 4 files changed, 55 insertions(+), 43 deletions(-) diff --git a/lib/matplotlib/bezier.py b/lib/matplotlib/bezier.py index e2ab2f375d6c..46e19f970968 100644 --- a/lib/matplotlib/bezier.py +++ b/lib/matplotlib/bezier.py @@ -207,9 +207,9 @@ def tan_out(self): @property def interior_extrema(self): - if self.n <= 2: # a line's extrema are always its tips + if self.n <= 2: # a line's extrema are always its tips return np.array([]), np.array([]) - elif self.n == 3: # quadratic curve + elif self.n == 3: # quadratic curve # the bezier curve in standard form is # cp[0] * (1 - t)^2 + cp[1] * 2t(1-t) + cp[2] * t^2 # can be re-written as @@ -223,25 +223,26 @@ def interior_extrema(self): dims = np.arange(self.d)[mask] in_range = (0 <= zeros) & (zeros <= 1) return dims[in_range], zeros[in_range] - elif self.n == 4: # cubic curve + elif self.n == 4: # cubic curve + P = self.cpoints # derivative of cubic bezier curve has coefficients - a = 3*(points[3] - 3*points[2] + 3*points[1] - points[0]) - b = 6*(points[2] - 2*points[1] + points[0]) - c = 3*(points[1] - points[0]) - under_sqrt = b**2 - 4*a*c + a = 3*(P[3] - 3*P[2] + 3*P[1] - P[0]) + b = 6*(P[2] - 2*P[1] + P[0]) + c = 3*(P[1] - P[0]) + discriminant = b**2 - 4*a*c dims = [] zeros = [] - for i in range(d): - if under_sqrt[i] < 0: + for i in range(self.d): + if discriminant[i] < 0: continue - roots = [(-b + np.sqrt(under_sqrt))/2/a, - (-b - np.sqrt(under_sqrt))/2/a] + roots = [(-b + np.sqrt(discriminant))/2/a, + (-b - np.sqrt(discriminant))/2/a] for root in roots: if 0 <= root <= 1: dims.append(i) zeros.append(root) return np.asarray(dims), np.asarray(zeros) - else: # self.n > 4: + else: # self.n > 4: raise NotImplementedError("Zero finding only implemented up to " "cubic curves.") @@ -435,6 +436,7 @@ def iter_corners(path, **kwargs): first_vertex = None prev_tan_angle = None prev_vertex = None + is_capped = False for bcurve, code in path.iter_curves(**kwargs): bcurve = BezierSegment(bcurve) if code == Path.MOVETO: @@ -453,19 +455,22 @@ def iter_corners(path, **kwargs): if code == Path.CLOSEPOLY: is_capped = False if prev_tan_angle is None: - raise ValueError("Misformed path, cannot close poly with single vertex!") + raise ValueError("Misformed path, cannot close poly with " + "single vertex!") tan_in = prev_vertex - first_vertex - # often CLOSEPOLY is used when the curve has already reached it's initial point - # in order to prevent there from being a stray straight line segment - # if it's used this way, then we more or less ignore the current bcurve + # often CLOSEPOLY is used when the curve has already reached it's + # initial point in order to prevent there from being a stray + # straight line segment if it's used this way, then we more or less + # ignore the current bcurve if np.isclose(np.linalg.norm(tan_in), 0): incident_angle, corner_angle = _incidence_corner_from_angles( prev_tan_angle, first_tan_angle) yield CornerInfo(prev_vertex, incident_angle, corner_angle) continue # otherwise, we have to calculate both the corner from the - # previous line segment to the current straight line, and from the current straight - # line to the original starting line. The former is taken care of by the + # previous line segment to the current straight line, and from the + # current straight line to the original starting line. The former + # is taken care of by the # non-special-case code below. the latter looks like: tan_out = bcurve.tan_out angle_end = np.arctan2(tan_out[1], tan_out[0]) @@ -491,6 +496,7 @@ def iter_corners(path, **kwargs): # quadratic Bezier lines + def get_cos_sin(x0, y0, x1, y1): dx, dy = x1 - x0, y1 - y0 d = (dx * dx + dy * dy) ** .5 diff --git a/lib/matplotlib/markers.py b/lib/matplotlib/markers.py index 040e0304ed03..43d2c33f4529 100644 --- a/lib/matplotlib/markers.py +++ b/lib/matplotlib/markers.py @@ -128,7 +128,6 @@ """ from collections.abc import Sized -from collections import namedtuple import numpy as np @@ -144,6 +143,7 @@ _empty_path = Path(np.empty((0, 2))) + def _get_padding_due_to_angle(width, phi, theta, joinstyle='miter', capstyle='butt'): """Computes how much a stroke of *width* overflows the naive bbox at a @@ -181,13 +181,13 @@ def _get_padding_due_to_angle(width, phi, theta, joinstyle='miter', # how much the "cap" extends past the endpoint of the path if theta is None: # for "butt" caps we can compute how far the - # outside edge of the markeredge stroke extends outside of the bounding box - # of its path using the law of sines: $\sin(\phi)/(w/2) = \sin(\pi/2 - - # \phi)/l$ for $w$ the `markeredgewidth`, $\phi$ the incidence angle of the - # line, then $l$ is the length along the outer edge of the stroke that - # extends beyond the bouding box. We can translate this to a distance - # perpendicular to the bounding box E(w, \phi) = l \sin(\phi)$, for $l$ as - # above. + # outside edge of the markeredge stroke extends outside of the bounding + # box of its path using the law of sines: $\sin(\phi)/(w/2) = + # \sin(\pi/2 - \phi)/l$ for $w$ the `markeredgewidth`, $\phi$ the + # incidence angle of the line, then $l$ is the length along the outer + # edge of the stroke that extends beyond the bouding box. We can + # translate this to a distance perpendicular to the bounding box E(w, + # \phi) = l \sin(\phi)$, for $l$ as above. if capstyle == 'butt': pad = (width/2) * np.cos(phi) # "round" caps are hemispherical, so regardless of angle @@ -224,7 +224,8 @@ def _get_padding_due_to_angle(width, phi, theta, joinstyle='miter', pad = (width/2)*np.sin(phi)/np.sin(theta/2) # # matplotlib currently doesn't set the miterlimit... # if pad/width > miterlimit: - # pad = _get_padding_due_to_angle(width, phi, theta, 'bevel', capstyle) + # pad = _get_padding_due_to_angle(width, phi, theta, 'bevel', + # capstyle) # to calculate the offset for _joinstyle = "bevel", we can start with the # analogous "miter" corner. the rules for how the "bevel" is # created in SVG is that the outer edges of the stroke continue up until @@ -251,7 +252,7 @@ def _get_padding_due_to_angle(width, phi, theta, joinstyle='miter', # it....except those with "no corner", in which case we can treat them the # same as squares... elif joinstyle == 'round': - return width/2 # hemispherical cap, so always same padding + return width/2 # hemispherical cap, so always same padding else: raise ValueError(f"Unknown joinstyle: {joinstyle}") return pad @@ -1039,7 +1040,11 @@ def get_stroked_bbox(self, markersize, markeredgewidth=0, **kwargs): path location + width/2 extends the bbox at each interior extrema. Then, for each join and cap, we check if that join extends the bbox. """ - xmin = 0; ymin = 1; xmax = 2; ymax = 3; maxi = 2 + xmin = 0 + ymin = 1 + xmax = 2 + ymax = 3 + maxi = 2 if np.isclose(markersize, 0): return Bbox([[0, 0], [0, 0]]) unit_path = self._transform.transform_path(self._path) @@ -1064,16 +1069,16 @@ def get_stroked_bbox(self, markersize, markeredgewidth=0, **kwargs): # extents... if np.cos(corner.incidence_angle) > 0: incidence_angle = corner.incidence_angle + np.pi/2 - x += _get_padding_due_to_angle(markeredgewidth, + x += _get_padding_due_to_angle(markeredgewidth, incidence_angle, corner.corner_angle, joinstyle=self._joinstyle, capstyle=self._capstyle) if x > unit_extents[xmax]: unit_extents[xmax] = x else: - if corner.incidence_angle < 0: # [-pi, -pi/2] - incidence_angle = 2*np.pi + corner.incidence_angle - np.pi/2 + if corner.incidence_angle < 0: # [-pi, -pi/2] + incidence_angle = 3*np.pi/2 + corner.incidence_angle else: - incidence_angle = corner.incidence_angle - pi/2 + incidence_angle = corner.incidence_angle - np.pi/2 x -= _get_padding_due_to_angle(markeredgewidth, incidence_angle, corner.corner_angle, joinstyle=self._joinstyle, capstyle=self._capstyle) diff --git a/lib/matplotlib/path.py b/lib/matplotlib/path.py index a69ae4a261f4..96883cb8a20b 100644 --- a/lib/matplotlib/path.py +++ b/lib/matplotlib/path.py @@ -427,14 +427,15 @@ def iter_curves(self, **kwargs): kwargs get forwarded to :method:`Path.iter_segments`. """ first_vertex = None + prev_vertex = None for vertices, code in self.iter_segments(**kwargs): if first_vertex is None: if code != Path.MOVETO: raise ValueError("Malformed path, must start with MOVETO.") - if code == Path.MOVETO: # a point is like "CURVE1" + if code == Path.MOVETO: # a point is like "CURVE1" first_vertex = vertices yield np.array([first_vertex]), code - elif code == Path.LINETO: # "CURVE2" + elif code == Path.LINETO: # "CURVE2" yield np.array([prev_vertex, vertices]), code elif code == Path.CURVE3: yield np.array([prev_vertex, vertices[:2], vertices[2:]]), code @@ -445,7 +446,6 @@ def iter_curves(self, **kwargs): yield np.array([prev_vertex, first_vertex]), code prev_vertex = vertices[-2:] - @cbook._delete_parameter("3.3", "quantize") def cleaned(self, transform=None, remove_nans=False, clip=None, quantize=False, simplify=False, curves=False, diff --git a/lib/matplotlib/tests/test_marker.py b/lib/matplotlib/tests/test_marker.py index d1e1e8df568c..33fb8f5a9c8f 100644 --- a/lib/matplotlib/tests/test_marker.py +++ b/lib/matplotlib/tests/test_marker.py @@ -4,6 +4,7 @@ from matplotlib.path import Path from matplotlib.testing.decorators import image_comparison +from collections import namedtuple import pytest @@ -193,7 +194,7 @@ def test_marker_bbox_pentagon(): None: BoxSides(None, None, None, None), } -def _get_bbox_path_end_angle(, markersize, markeredgewidth=0): +def _get_bbox_path_end_angle(marker, markersize, markeredgewidth=0): """Get size of bbox if marker is centered at origin. For markers with no edge, this is just the same bbox as that of the @@ -220,17 +221,17 @@ def _get_bbox_path_end_angle(, markersize, markeredgewidth=0): # there is no stroke so the bbox is trivial if np.isclose(markersize, 0): return Bbox([[0, 0], [0, 0]]) - unit_path = self._transform.transform_path(self._path) + unit_path = marker._transform.transform_path(marker._path) unit_bbox = unit_path.get_extents() scale = Affine2D().scale(markersize) [[left, bottom], [right, top]] = scale.transform(unit_bbox) - angles = _edge_angles[self._marker] + angles = _edge_angles[marker._marker] left -= _get_padding_due_to_angle(markeredgewidth, angles.left, - self._joinstyle, self._capstyle) + marker._joinstyle, marker._capstyle) bottom -= _get_padding_due_to_angle(markeredgewidth, angles.bottom, - self._joinstyle, self._capstyle) + marker._joinstyle, marker._capstyle) right += _get_padding_due_to_angle(markeredgewidth, angles.right, - self._joinstyle, self._capstyle) + marker._joinstyle, marker._capstyle) top += _get_padding_due_to_angle(markeredgewidth, angles.top, - self._joinstyle, self._capstyle) + marker._joinstyle, marker._capstyle) return Bbox.from_extents(left, bottom, right, top) From 82e3c123f1bfe7f86090f6ebef9e99578ecea6c4 Mon Sep 17 00:00:00 2001 From: Bruno Beltran Date: Wed, 11 Mar 2020 18:22:53 -0700 Subject: [PATCH 27/33] cleanup path/bezier to prevent import triangle --- lib/matplotlib/bezier.py | 219 -------------------------------------- lib/matplotlib/markers.py | 23 ++-- lib/matplotlib/patches.py | 11 +- lib/matplotlib/path.py | 219 +++++++++++++++++++++++++++++++++++++- 4 files changed, 232 insertions(+), 240 deletions(-) diff --git a/lib/matplotlib/bezier.py b/lib/matplotlib/bezier.py index 46e19f970968..f565d05103a1 100644 --- a/lib/matplotlib/bezier.py +++ b/lib/matplotlib/bezier.py @@ -3,12 +3,10 @@ """ import math -from collections import namedtuple import numpy as np import matplotlib.cbook as cbook -from matplotlib.path import Path class NonIntersectingPathException(ValueError): @@ -282,68 +280,6 @@ def split_bezier_intersecting_with_closedpath( # matplotlib specific -def split_path_inout(path, inside, tolerance=0.01, reorder_inout=False): - """ - Divide a path into two segments at the point where ``inside(x, y)`` becomes - False. - """ - path_iter = path.iter_segments() - - ctl_points, command = next(path_iter) - begin_inside = inside(ctl_points[-2:]) # true if begin point is inside - - ctl_points_old = ctl_points - - concat = np.concatenate - - iold = 0 - i = 1 - - for ctl_points, command in path_iter: - iold = i - i += len(ctl_points) // 2 - if inside(ctl_points[-2:]) != begin_inside: - bezier_path = concat([ctl_points_old[-2:], ctl_points]) - break - ctl_points_old = ctl_points - else: - raise ValueError("The path does not intersect with the patch") - - bp = bezier_path.reshape((-1, 2)) - left, right = split_bezier_intersecting_with_closedpath( - bp, inside, tolerance) - if len(left) == 2: - codes_left = [Path.LINETO] - codes_right = [Path.MOVETO, Path.LINETO] - elif len(left) == 3: - codes_left = [Path.CURVE3, Path.CURVE3] - codes_right = [Path.MOVETO, Path.CURVE3, Path.CURVE3] - elif len(left) == 4: - codes_left = [Path.CURVE4, Path.CURVE4, Path.CURVE4] - codes_right = [Path.MOVETO, Path.CURVE4, Path.CURVE4, Path.CURVE4] - else: - raise AssertionError("This should never be reached") - - verts_left = left[1:] - verts_right = right[:] - - if path.codes is None: - path_in = Path(concat([path.vertices[:i], verts_left])) - path_out = Path(concat([verts_right, path.vertices[i:]])) - - else: - path_in = Path(concat([path.vertices[:iold], verts_left]), - concat([path.codes[:iold], codes_left])) - - path_out = Path(concat([verts_right, path.vertices[i:]]), - concat([codes_right, path.codes[i:]])) - - if reorder_inout and not begin_inside: - path_in, path_out = path_out, path_in - - return path_in, path_out - - def inside_circle(cx, cy, r): """ Return a function that checks whether a point is in a circle with center @@ -361,139 +297,6 @@ def _f(xy): return _f -CornerInfo = namedtuple('CornerInfo', 'apex incidence_angle corner_angle') -r"""Used to have a universal way to account for how much the bounding box of a -shape will grow as we increase its `markeredgewidth`. - -Attributes ----------- - `apex` : float - the vertex that marks the "tip" of the corner - `incidence_angle` : float - the angle that the corner bisector makes with the box edge (where - top/bottom box edges are horizontal, left/right box edges are - vertical). - `corner_angle` : float - the internal angle of the corner, where np.pi is a straight line, and 0 - is retracing exactly the way you came. None can be used to signify that - the line ends there (i.e. no corner). - -Notes ------ -$\pi$ and 0 are equivalent for `corner_angle`. Both $\theta$ and $\pi - \theta$ -are equivalent for `incidence_angle` by symmetry.""" - - -def _incidence_corner_from_angles(angle_1, angle_2): - """Gets CornerInfo from direction of lines making up corner. - - This function expects angle_1 and angle_2 (in radians) to be - the orientation of lines 1 and 2 (arbitrarily chosen to point - towards the corner where they meet) relative to the coordinate - system. - - Helper function for iter_corners. - - Returns - ------- - incidence_angle : float in [0, 2*pi] - as described in CornerInfo docs - corner_angle : float in [0, pi] - as described in CornerInfo docs - """ - # get "interior" angle between tangents to joined curves' tips - corner_angle = np.abs(angle_1 - angle_2) - if corner_angle > np.pi: - corner_angle = 2*np.pi - corner_angle - # since [-pi, pi], we need to sort to avoid modulo - smaller_angle = min(angle_1, angle_2) - larger_angle = max(angle_1, angle_2) - if np.isclose(smaller_angle + corner_angle, larger_angle): - incident_angle = smaller_angle + corner_angle/2 - else: - incident_angle = smaller_angle - corner_angle/2 - return incident_angle, corner_angle - - -def iter_corners(path, **kwargs): - """Iterate over a mpl.path.Path object and return information about every - cap and corner. - - Parameters - ---------- - path : mpl.path.Path - the path to extract corners from - kwargs : Dict[str, object] - passed onto Path.iter_curves - - Yields - ------ - corner : CornerInfo - Measure of the corner's position, orientation, and angle. Useful in - order to determine how the corner affects the bbox of the curve. - """ - first_tan_angle = None - first_vertex = None - prev_tan_angle = None - prev_vertex = None - is_capped = False - for bcurve, code in path.iter_curves(**kwargs): - bcurve = BezierSegment(bcurve) - if code == Path.MOVETO: - # deal with capping ends of previous polyline, if it exists - if prev_tan_angle is not None and is_capped: - for cap_angle, cap_vertex in [(first_tan_angle, first_vertex), - (prev_tan_angle, prev_vertex)]: - yield CornerInfo(cap_vertex, cap_angle, None) - first_tan_angle = None - prev_tan_angle = None - first_vertex = bcurve.cpoints[0] - prev_vertex = first_vertex - # lines will end in a cap by default unless a CLOSEPOLY is observed - is_capped = True - continue - if code == Path.CLOSEPOLY: - is_capped = False - if prev_tan_angle is None: - raise ValueError("Misformed path, cannot close poly with " - "single vertex!") - tan_in = prev_vertex - first_vertex - # often CLOSEPOLY is used when the curve has already reached it's - # initial point in order to prevent there from being a stray - # straight line segment if it's used this way, then we more or less - # ignore the current bcurve - if np.isclose(np.linalg.norm(tan_in), 0): - incident_angle, corner_angle = _incidence_corner_from_angles( - prev_tan_angle, first_tan_angle) - yield CornerInfo(prev_vertex, incident_angle, corner_angle) - continue - # otherwise, we have to calculate both the corner from the - # previous line segment to the current straight line, and from the - # current straight line to the original starting line. The former - # is taken care of by the - # non-special-case code below. the latter looks like: - tan_out = bcurve.tan_out - angle_end = np.arctan2(tan_out[1], tan_out[0]) - incident_angle, corner_angle = _incidence_corner_from_angles( - angle_end, first_tan_angle) - yield CornerInfo(first_vertex, incident_angle, corner_angle) - # finally, usual case is when two curves meet at an angle - tan_in = -bcurve.tan_in - angle_in = np.arctan2(tan_in[1], tan_in[0]) - if first_tan_angle is None: - first_tan_angle = angle_in - if prev_tan_angle is not None: - incident_angle, corner_angle = _incidence_corner_from_angles( - angle_in, prev_tan_angle) - yield CornerInfo(prev_vertex, incident_angle, corner_angle) - tan_out = bcurve.tan_out - prev_tan_angle = np.arctan2(tan_out[1], tan_out[0]) - prev_vertex = bcurve.cpoints[-1] - if prev_tan_angle is not None and is_capped: - for cap_angle, cap_vertex in [(first_tan_angle, first_vertex), - (prev_tan_angle, prev_vertex)]: - yield CornerInfo(cap_vertex, cap_angle, None) - # quadratic Bezier lines @@ -669,25 +472,3 @@ def make_wedged_bezier2(bezier2, width, w1=1., wm=0.5, w2=0.): c3x_right, c3y_right) return path_left, path_right - - -def make_path_regular(p): - """ - If the ``codes`` attribute of `.Path` *p* is None, return a copy of *p* - with ``codes`` set to (MOVETO, LINETO, LINETO, ..., LINETO); otherwise - return *p* itself. - """ - c = p.codes - if c is None: - c = np.full(len(p.vertices), Path.LINETO, dtype=Path.code_type) - c[0] = Path.MOVETO - return Path(p.vertices, c) - else: - return p - - -def concatenate_paths(paths): - """Concatenate a list of paths into a single path.""" - vertices = np.concatenate([p.vertices for p in paths]) - codes = np.concatenate([make_path_regular(p).codes for p in paths]) - return Path(vertices, codes) diff --git a/lib/matplotlib/markers.py b/lib/matplotlib/markers.py index 43d2c33f4529..ec8b137ea2cf 100644 --- a/lib/matplotlib/markers.py +++ b/lib/matplotlib/markers.py @@ -134,7 +134,7 @@ from . import cbook, rcParams from .path import Path from .transforms import IdentityTransform, Affine2D, Bbox -from .bezier import BezierSegment, iter_corners +from .bezier import BezierSegment # special-purpose marker identifiers: (TICKLEFT, TICKRIGHT, TICKUP, TICKDOWN, @@ -1023,6 +1023,9 @@ def get_stroked_bbox(self, markersize, markeredgewidth=0, **kwargs): markeredgewidth : float, optional, default: 0 Width, in points, of the stroke used to create the marker's edge. + kwargs : Dict[str, object] + forwarded to iter_curves and iter_corners + Returns ------- bbox : matplotlib.transforms.Bbox @@ -1061,7 +1064,7 @@ def get_stroked_bbox(self, markersize, markeredgewidth=0, **kwargs): unit_extents[dim] = potential_extrema if potential_extrema > unit_extents[maxi+dim]: unit_extents[maxi+dim] = potential_extrema - for corner in iter_corners(unit_path, **kwargs): + for corner in unit_path.iter_corners(**kwargs): x, y = corner.apex # now for each of up/down/left/right, convert the absolute # incidence angle into the incidence angle relative to that @@ -1069,8 +1072,8 @@ def get_stroked_bbox(self, markersize, markeredgewidth=0, **kwargs): # extents... if np.cos(corner.incidence_angle) > 0: incidence_angle = corner.incidence_angle + np.pi/2 - x += _get_padding_due_to_angle(markeredgewidth, - incidence_angle, corner.corner_angle, + x += _get_padding_due_to_angle( + markeredgewidth, incidence_angle, corner.corner_angle, joinstyle=self._joinstyle, capstyle=self._capstyle) if x > unit_extents[xmax]: unit_extents[xmax] = x @@ -1079,22 +1082,22 @@ def get_stroked_bbox(self, markersize, markeredgewidth=0, **kwargs): incidence_angle = 3*np.pi/2 + corner.incidence_angle else: incidence_angle = corner.incidence_angle - np.pi/2 - x -= _get_padding_due_to_angle(markeredgewidth, - incidence_angle, corner.corner_angle, + x -= _get_padding_due_to_angle( + markeredgewidth, incidence_angle, corner.corner_angle, joinstyle=self._joinstyle, capstyle=self._capstyle) if x < unit_extents[xmin]: unit_extents[xmin] = x if np.sin(corner.incidence_angle) > 0: incidence_angle = corner.incidence_angle - y += _get_padding_due_to_angle(markeredgewidth, - incidence_angle, corner.corner_angle, + y += _get_padding_due_to_angle( + markeredgewidth, incidence_angle, corner.corner_angle, joinstyle=self._joinstyle, capstyle=self._capstyle) if y > unit_extents[ymax]: unit_extents[ymax] = y else: incidence_angle = corner.incidence_angle + np.pi - y -= _get_padding_due_to_angle(markeredgewidth, - incidence_angle, corner.corner_angle, + y -= _get_padding_due_to_angle( + markeredgewidth, incidence_angle, corner.corner_angle, joinstyle=self._joinstyle, capstyle=self._capstyle) if y < unit_extents[ymin]: unit_extents[ymin] = y diff --git a/lib/matplotlib/patches.py b/lib/matplotlib/patches.py index d7dcda251310..a61348e9eff0 100644 --- a/lib/matplotlib/patches.py +++ b/lib/matplotlib/patches.py @@ -10,10 +10,9 @@ import matplotlib as mpl from . import artist, cbook, colors, docstring, lines as mlines, transforms from .bezier import ( - NonIntersectingPathException, concatenate_paths, get_cos_sin, - get_intersection, get_parallels, inside_circle, make_path_regular, - make_wedged_bezier2, split_bezier_intersecting_with_closedpath, - split_path_inout) + NonIntersectingPathException, get_cos_sin, get_intersection, get_parallels, + inside_circle, make_wedged_bezier2, + split_bezier_intersecting_with_closedpath, split_path_inout) from .path import Path @@ -3187,7 +3186,7 @@ def __call__(self, path, mutation_size, linewidth, and takes care of the aspect ratio. """ - path = make_path_regular(path) + path = path.make_path_regular() if aspect_ratio is not None: # Squeeze the given height by the aspect_ratio @@ -4174,7 +4173,7 @@ def get_path(self): """ _path, fillable = self.get_path_in_displaycoord() if np.iterable(fillable): - _path = concatenate_paths(_path) + _path = Path.make_compound_path(_path) return self.get_transform().inverted().transform_path(_path) def get_path_in_displaycoord(self): diff --git a/lib/matplotlib/path.py b/lib/matplotlib/path.py index 96883cb8a20b..cc09416a93f2 100644 --- a/lib/matplotlib/path.py +++ b/lib/matplotlib/path.py @@ -11,12 +11,68 @@ from functools import lru_cache from weakref import WeakValueDictionary +from collections import namedtuple import numpy as np import matplotlib as mpl from . import _path, cbook from .cbook import _to_unmasked_float_array, simple_linear_interpolation +from .bezier import BezierSegment, split_bezier_intersecting_with_closedpath + + +CornerInfo = namedtuple('CornerInfo', 'apex incidence_angle corner_angle') +r"""Used to have a universal way to account for how much the bounding box of a +shape will grow as we increase its `markeredgewidth`. + +Attributes +---------- + `apex` : float + the vertex that marks the "tip" of the corner + `incidence_angle` : float + the angle that the corner bisector makes with the box edge (where + top/bottom box edges are horizontal, left/right box edges are + vertical). + `corner_angle` : float + the internal angle of the corner, where np.pi is a straight line, and 0 + is retracing exactly the way you came. None can be used to signify that + the line ends there (i.e. no corner). + +Notes +----- +$\pi$ and 0 are equivalent for `corner_angle`. Both $\theta$ and $\pi - \theta$ +are equivalent for `incidence_angle` by symmetry.""" + + +def _incidence_corner_from_angles(angle_1, angle_2): + """Gets CornerInfo from direction of lines making up corner. + + This function expects angle_1 and angle_2 (in radians) to be + the orientation of lines 1 and 2 (arbitrarily chosen to point + towards the corner where they meet) relative to the coordinate + system. + + Helper function for Path.iter_corners. + + Returns + ------- + incidence_angle : float in [0, 2*pi] + as described in CornerInfo docs + corner_angle : float in [0, pi] + as described in CornerInfo docs + """ + # get "interior" angle between tangents to joined curves' tips + corner_angle = np.abs(angle_1 - angle_2) + if corner_angle > np.pi: + corner_angle = 2*np.pi - corner_angle + # since [-pi, pi], we need to sort to avoid modulo + smaller_angle = min(angle_1, angle_2) + larger_angle = max(angle_1, angle_2) + if np.isclose(smaller_angle + corner_angle, larger_angle): + incident_angle = smaller_angle + corner_angle/2 + else: + incident_angle = smaller_angle - corner_angle/2 + return incident_angle, corner_angle class Path: @@ -337,11 +393,8 @@ def make_compound_path(cls, *args): codes = np.empty(total_length, dtype=cls.code_type) i = 0 for path in args: - if path.codes is None: - codes[i] = cls.MOVETO - codes[i + 1:i + len(path.vertices)] = cls.LINETO - else: - codes[i:i + len(path.codes)] = path.codes + path = path.make_path_regular() + codes[i:i + len(path.codes)] = path.codes i += len(path.vertices) return cls(vertices, codes) @@ -446,6 +499,87 @@ def iter_curves(self, **kwargs): yield np.array([prev_vertex, first_vertex]), code prev_vertex = vertices[-2:] + def iter_corners(self, **kwargs): + """Iterate over a mpl.path.Path object and return information about every + cap and corner. + + Parameters + ---------- + path : mpl.path.Path + the path to extract corners from + kwargs : Dict[str, object] + passed onto Path.iter_curves + + Yields + ------ + corner : CornerInfo + Measure of the corner's position, orientation, and angle. Useful in + order to determine how the corner affects the bbox of the curve. + """ + first_tan_angle = None + first_vertex = None + prev_tan_angle = None + prev_vertex = None + is_capped = False + for bcurve, code in self.iter_curves(**kwargs): + bcurve = BezierSegment(bcurve) + if code == Path.MOVETO: + # deal with capping ends of previous polyline, if it exists + if prev_tan_angle is not None and is_capped: + cap_angles = [first_tan_angle, prev_tan_angle] + cap_vertices = [first_vertex, prev_vertex] + for cap_angle, cap_vertex in zip(cap_angles, cap_vertices): + yield CornerInfo(cap_vertex, cap_angle, None) + first_tan_angle = None + prev_tan_angle = None + first_vertex = bcurve.cpoints[0] + prev_vertex = first_vertex + # lines end in a cap by default unless a CLOSEPOLY is observed + is_capped = True + continue + if code == Path.CLOSEPOLY: + is_capped = False + if prev_tan_angle is None: + raise ValueError("Misformed path, cannot close poly with " + "single vertex!") + tan_in = prev_vertex - first_vertex + # often CLOSEPOLY is used when the curve has already reached + # it's initial point in order to prevent there from being a + # stray straight line segment. + # if it's used this way, then we more or less ignore the + # current bcurve. + if np.isclose(np.linalg.norm(tan_in), 0): + incident_a, corner_a = _incidence_corner_from_angles( + prev_tan_angle, first_tan_angle) + yield CornerInfo(prev_vertex, incident_a, corner_a) + continue + # otherwise, we have to calculate both the corner from the + # previous line segment to the current straight line, and from + # the current straight line to the original starting line. The + # former is taken care of by the non-special-case code below. + # the latter looks like: + tan_out = bcurve.tan_out + angle_end = np.arctan2(tan_out[1], tan_out[0]) + incident_a, corner_a = _incidence_corner_from_angles( + angle_end, first_tan_angle) + yield CornerInfo(first_vertex, incident_a, corner_a) + # finally, usual case is when two curves meet at an angle + tan_in = -bcurve.tan_in + angle_in = np.arctan2(tan_in[1], tan_in[0]) + if first_tan_angle is None: + first_tan_angle = angle_in + if prev_tan_angle is not None: + incident_a, corner_a = _incidence_corner_from_angles( + angle_in, prev_tan_angle) + yield CornerInfo(prev_vertex, incident_a, corner_a) + tan_out = bcurve.tan_out + prev_tan_angle = np.arctan2(tan_out[1], tan_out[0]) + prev_vertex = bcurve.cpoints[-1] + if prev_tan_angle is not None and is_capped: + for cap_angle, cap_vertex in [(first_tan_angle, first_vertex), + (prev_tan_angle, prev_vertex)]: + yield CornerInfo(cap_vertex, cap_angle, None) + @cbook._delete_parameter("3.3", "quantize") def cleaned(self, transform=None, remove_nans=False, clip=None, quantize=False, simplify=False, curves=False, @@ -479,6 +613,20 @@ def transformed(self, transform): return Path(transform.transform(self.vertices), self.codes, self._interpolation_steps) + def make_path_regular(self): + """ + If the ``codes`` attribute of `.Path` *p* is None, return a copy of *p* + with ``codes`` set to (MOVETO, LINETO, LINETO, ..., LINETO); otherwise + return *p* itself. + """ + c = self.codes + if c is None: + c = np.full(len(self.vertices), Path.LINETO, dtype=Path.code_type) + c[0] = Path.MOVETO + return Path(self.vertices, c) + else: + return self + def contains_point(self, point, transform=None, radius=0.0): """ Return whether the (closed) path contains the given point. @@ -612,6 +760,67 @@ def interpolated(self, steps): new_codes = None return Path(vertices, new_codes) + def split_path_inout(self, inside, tolerance=0.01, reorder_inout=False): + """ + Divide a path into two segments at the point where ``inside(x, y)`` + becomes False. + """ + path_iter = self.iter_segments() + + ctl_points, command = next(path_iter) + begin_inside = inside(ctl_points[-2:]) # true if begin point is inside + + ctl_points_old = ctl_points + + concat = np.concatenate + + iold = 0 + i = 1 + + for ctl_points, command in path_iter: + iold = i + i += len(ctl_points) // 2 + if inside(ctl_points[-2:]) != begin_inside: + bezier_path = concat([ctl_points_old[-2:], ctl_points]) + break + ctl_points_old = ctl_points + else: + raise ValueError("The path does not intersect with the patch") + + bp = bezier_path.reshape((-1, 2)) + left, right = split_bezier_intersecting_with_closedpath( + bp, inside, tolerance) + if len(left) == 2: + codes_left = [Path.LINETO] + codes_right = [Path.MOVETO, Path.LINETO] + elif len(left) == 3: + codes_left = [Path.CURVE3, Path.CURVE3] + codes_right = [Path.MOVETO, Path.CURVE3, Path.CURVE3] + elif len(left) == 4: + codes_left = [Path.CURVE4, Path.CURVE4, Path.CURVE4] + codes_right = [Path.MOVETO, Path.CURVE4, Path.CURVE4, Path.CURVE4] + else: + raise AssertionError("This should never be reached") + + verts_left = left[1:] + verts_right = right[:] + + if self.codes is None: + path_in = Path(concat([self.vertices[:i], verts_left])) + path_out = Path(concat([verts_right, self.vertices[i:]])) + + else: + path_in = Path(concat([self.vertices[:iold], verts_left]), + concat([self.codes[:iold], codes_left])) + + path_out = Path(concat([verts_right, self.vertices[i:]]), + concat([codes_right, self.codes[i:]])) + + if reorder_inout and not begin_inside: + path_in, path_out = path_out, path_in + + return path_in, path_out + def to_polygons(self, transform=None, width=0, height=0, closed_only=True): """ Convert this path to a list of polygons or polylines. Each From 8585ecaf5d2e8c33d1674df886275fea1e6533cb Mon Sep 17 00:00:00 2001 From: Bruno Beltran Date: Wed, 11 Mar 2020 18:30:55 -0700 Subject: [PATCH 28/33] fix prev commit, make split_in_out method of Path --- lib/matplotlib/patches.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/lib/matplotlib/patches.py b/lib/matplotlib/patches.py index a61348e9eff0..fdcdc57cead7 100644 --- a/lib/matplotlib/patches.py +++ b/lib/matplotlib/patches.py @@ -12,7 +12,7 @@ from .bezier import ( NonIntersectingPathException, get_cos_sin, get_intersection, get_parallels, inside_circle, make_wedged_bezier2, - split_bezier_intersecting_with_closedpath, split_path_inout) + split_bezier_intersecting_with_closedpath) from .path import Path @@ -2723,7 +2723,7 @@ def insideA(xy_display): return patchA.contains(xy_event)[0] try: - left, right = split_path_inout(path, insideA) + left, right = path.split_path_inout(insideA) except ValueError: right = path @@ -2735,7 +2735,7 @@ def insideB(xy_display): return patchB.contains(xy_event)[0] try: - left, right = split_path_inout(path, insideB) + left, right = path.split_path_inout(insideB) except ValueError: left = path @@ -2750,13 +2750,13 @@ def _shrink(self, path, shrinkA, shrinkB): if shrinkA: insideA = inside_circle(*path.vertices[0], shrinkA) try: - left, path = split_path_inout(path, insideA) + left, path = path.split_path_inout(insideA) except ValueError: pass if shrinkB: insideB = inside_circle(*path.vertices[-1], shrinkB) try: - path, right = split_path_inout(path, insideB) + path, right = path.split_path_inout(insideB) except ValueError: pass return path From 54e3a3e52e48b726a16eba4844b9afb123151532 Mon Sep 17 00:00:00 2001 From: Bruno Beltran Date: Thu, 12 Mar 2020 03:53:16 +0100 Subject: [PATCH 29/33] generalized path bbox code tested on some markers --- lib/matplotlib/bezier.py | 4 +- lib/matplotlib/lines.py | 2 +- lib/matplotlib/markers.py | 179 +------------------- lib/matplotlib/path.py | 342 +++++++++++++++++++++++++++++++------- 4 files changed, 291 insertions(+), 236 deletions(-) diff --git a/lib/matplotlib/bezier.py b/lib/matplotlib/bezier.py index f565d05103a1..c3a64f69dec1 100644 --- a/lib/matplotlib/bezier.py +++ b/lib/matplotlib/bezier.py @@ -233,8 +233,8 @@ def interior_extrema(self): for i in range(self.d): if discriminant[i] < 0: continue - roots = [(-b + np.sqrt(discriminant))/2/a, - (-b - np.sqrt(discriminant))/2/a] + roots = [(-b[i] + np.sqrt(discriminant[i]))/2/a[i], + (-b[i] - np.sqrt(discriminant[i]))/2/a[i]] for root in roots: if 0 <= root <= 1: dims.append(i) diff --git a/lib/matplotlib/lines.py b/lib/matplotlib/lines.py index 7b9d6120a049..7d111d5b2a40 100644 --- a/lib/matplotlib/lines.py +++ b/lib/matplotlib/lines.py @@ -617,7 +617,7 @@ def get_window_extent(self, renderer): ignore=True) # correct for marker size, if any if self._marker: - m_bbox = self._marker.get_stroked_bbox( + m_bbox = self._marker.get_bbox( self._markersize, self._markeredgewidth) # markers use units of pts, not pixels box_points_px = renderer.points_to_pixels(m_bbox.get_points()) diff --git a/lib/matplotlib/markers.py b/lib/matplotlib/markers.py index ec8b137ea2cf..94e52059f8eb 100644 --- a/lib/matplotlib/markers.py +++ b/lib/matplotlib/markers.py @@ -134,7 +134,6 @@ from . import cbook, rcParams from .path import Path from .transforms import IdentityTransform, Affine2D, Bbox -from .bezier import BezierSegment # special-purpose marker identifiers: (TICKLEFT, TICKRIGHT, TICKUP, TICKDOWN, @@ -144,120 +143,6 @@ _empty_path = Path(np.empty((0, 2))) -def _get_padding_due_to_angle(width, phi, theta, joinstyle='miter', - capstyle='butt'): - """Computes how much a stroke of *width* overflows the naive bbox at a - corner described by *corner_info*. - - Parameters - ---------- - width : float - `markeredgewidth` used to draw the stroke that we're computing the - overflow for - phi : float - incidence angle of bisector of corner relative to side of bounding box - we're calculating the padding for - theta : float or None - angle swept out by the two lines that form the corner. if None, the - padding due to a "cap" is used instead of a corner - joinstyle : 'miter' (default), 'round', or 'bevel' - how the corner is to be drawn - capstyle : 'butt', 'round', 'projecting' - - - Returns - ------- - pad : float - amount of overflow - """ - if theta is not None and (theta < 0 or theta > np.pi) \ - or phi < 0 or phi > np.pi: - raise ValueError("Corner angles should be in [0, pi].") - if phi > np.pi/2: - # equivalent by symmetry, but keeps math simpler - phi = np.pi - phi - # if there's no corner (i.e. the path just ends, as in the "sides" of the - # caret marker (and other non-fillable markers), we still need to compute - # how much the "cap" extends past the endpoint of the path - if theta is None: - # for "butt" caps we can compute how far the - # outside edge of the markeredge stroke extends outside of the bounding - # box of its path using the law of sines: $\sin(\phi)/(w/2) = - # \sin(\pi/2 - \phi)/l$ for $w$ the `markeredgewidth`, $\phi$ the - # incidence angle of the line, then $l$ is the length along the outer - # edge of the stroke that extends beyond the bouding box. We can - # translate this to a distance perpendicular to the bounding box E(w, - # \phi) = l \sin(\phi)$, for $l$ as above. - if capstyle == 'butt': - pad = (width/2) * np.cos(phi) - # "round" caps are hemispherical, so regardless of angle - elif capstyle == 'round': - pad = width/2 - # finally, projecting caps are just bevel caps with an extra - # width/2 distance along the direction of the line, so "butt" plus some - # extra - elif capstyle == 'projecting': - pad = (width/2) * np.cos(phi) + (width/2)*np.sin(phi) - # the two "same as straight line" cases are NaN limits in the miter formula - elif np.isclose(theta, 0) and np.isclose(phi, 0) \ - or np.isclose(theta, np.pi) and np.isclose(phi, np.pi/2): - pad = width/2 - # to calculate the offset for _joinstyle == 'miter', imagine aligning the - # corner so that on line comes in along the negative x-axis, and another - # from above, makes an angle $\theta$ with the negative x-axis. the tip of - # the new corner created by the markeredge stroke will be at the point - # where the two outer edge of the markeredge stroke intersect. in the - # orientation described above, the outer edge of the stroke aligned with - # the x axis will obviously have equation $y = -w/2$ where $w$ is the - # markeredgewidth. WLOG, the stroke coming in from above at an angle - # $\theta$ from the negative x-axis will have equation - # $$-(\tan(\theta) x + \frac{w}{2\cos(\theta)}.$$ - # the intersection of these two lines is at $y = w/2$, and we can solve for - # $x = \cot(\theta) (\frac{w}{2} + \frac{w}{2\cos(\theta)})$. - # this puts the "edge" tip a distance $M = (w/2)\csc(\theta/2)$ - # from the tip of the corner itself, on the line defined by the bisector of - # the corner angle. So the extra padding required is $M\sin(\phi)$, where - # $\phi$ is the incidence angle of the corner's bisector. Notice that in - # the obvious limit ($\phi = \theta/2$) where the corner is flush with the - # bbox, this correctly simplifies to just $w/2$. - elif joinstyle == 'miter': - pad = (width/2)*np.sin(phi)/np.sin(theta/2) - # # matplotlib currently doesn't set the miterlimit... - # if pad/width > miterlimit: - # pad = _get_padding_due_to_angle(width, phi, theta, 'bevel', - # capstyle) - # to calculate the offset for _joinstyle = "bevel", we can start with the - # analogous "miter" corner. the rules for how the "bevel" is - # created in SVG is that the outer edges of the stroke continue up until - # the stroke hits the corner point (similar to _capstyle='butt'). A line is - # then drawn joining these two outer points and the interior is filled. in - # other words, it is the same as a "miter" corner, but with some amount of - # the tip removed (an isoceles triangle with base given by the distance - # described above). This base length (the bevel "size") is given by the law - # of sines $b = (w/2)\frac{\sin(\pi - \theta)}{\sin(\theta/2)}$. - # We can then subtract the height of the isoceles rectangle with this base - # height and tip angle $\theta$ from our result $M$ above to get how far - # the midpoint of the bevel extends beyond the outside.... - - # but that's not what we're interested in. - # a beveled edge is exactly the convex hull of its two composite lines with - # capstyle='butt'. So we just compute the individual lines' incidence - # angles and take the maximum of the two padding values - elif joinstyle == 'bevel': - phi1 = phi + theta/2 - phi2 = phi - theta/2 - pad = (width/2) * max(np.abs(np.cos(phi1)), np.abs(np.cos(phi2))) - # finally, _joinstyle = "round" is just _joinstyle = "bevel" but with - # a hemispherical cap. we could calculate this but for now no markers use - # it....except those with "no corner", in which case we can treat them the - # same as squares... - elif joinstyle == 'round': - return width/2 # hemispherical cap, so always same padding - else: - raise ValueError(f"Unknown joinstyle: {joinstyle}") - return pad - - class MarkerStyle: markers = { @@ -1012,7 +897,7 @@ def _set_x_filled(self): self._transform.rotate_deg(rotate) self._alt_transform.rotate_deg(rotate_alt) - def get_stroked_bbox(self, markersize, markeredgewidth=0, **kwargs): + def get_bbox(self, markersize, markeredgewidth=0, **kwargs): """Get size of bbox of marker directly from its path. Parameters @@ -1024,7 +909,7 @@ def get_stroked_bbox(self, markersize, markeredgewidth=0, **kwargs): Width, in points, of the stroke used to create the marker's edge. kwargs : Dict[str, object] - forwarded to iter_curves and iter_corners + forwarded to path's iter_curves and iter_corners Returns ------- @@ -1043,63 +928,11 @@ def get_stroked_bbox(self, markersize, markeredgewidth=0, **kwargs): path location + width/2 extends the bbox at each interior extrema. Then, for each join and cap, we check if that join extends the bbox. """ - xmin = 0 - ymin = 1 - xmax = 2 - ymax = 3 - maxi = 2 if np.isclose(markersize, 0): return Bbox([[0, 0], [0, 0]]) unit_path = self._transform.transform_path(self._path) - # get_extents returns a bbox, so Bbox.extents - unit_extents = unit_path.get_extents().extents - for curve, code in unit_path.iter_curves(**kwargs): - curve = BezierSegment(curve) - dims, zeros = curve.interior_extrema - if len(zeros) == 0: - continue - for dim, zero in zip(dims, zeros): - potential_extrema = curve.point_at_t(zero)[dim] - if potential_extrema < unit_extents[dim]: - unit_extents[dim] = potential_extrema - if potential_extrema > unit_extents[maxi+dim]: - unit_extents[maxi+dim] = potential_extrema - for corner in unit_path.iter_corners(**kwargs): - x, y = corner.apex - # now for each of up/down/left/right, convert the absolute - # incidence angle into the incidence angle relative to that - # respective side of the bbox, and see if the corner expands the - # extents... - if np.cos(corner.incidence_angle) > 0: - incidence_angle = corner.incidence_angle + np.pi/2 - x += _get_padding_due_to_angle( - markeredgewidth, incidence_angle, corner.corner_angle, - joinstyle=self._joinstyle, capstyle=self._capstyle) - if x > unit_extents[xmax]: - unit_extents[xmax] = x - else: - if corner.incidence_angle < 0: # [-pi, -pi/2] - incidence_angle = 3*np.pi/2 + corner.incidence_angle - else: - incidence_angle = corner.incidence_angle - np.pi/2 - x -= _get_padding_due_to_angle( - markeredgewidth, incidence_angle, corner.corner_angle, - joinstyle=self._joinstyle, capstyle=self._capstyle) - if x < unit_extents[xmin]: - unit_extents[xmin] = x - if np.sin(corner.incidence_angle) > 0: - incidence_angle = corner.incidence_angle - y += _get_padding_due_to_angle( - markeredgewidth, incidence_angle, corner.corner_angle, - joinstyle=self._joinstyle, capstyle=self._capstyle) - if y > unit_extents[ymax]: - unit_extents[ymax] = y - else: - incidence_angle = corner.incidence_angle + np.pi - y -= _get_padding_due_to_angle( - markeredgewidth, incidence_angle, corner.corner_angle, - joinstyle=self._joinstyle, capstyle=self._capstyle) - if y < unit_extents[ymin]: - unit_extents[ymin] = y scale = Affine2D().scale(markersize) - return Bbox(scale.transform(Bbox.from_extents(unit_extents))) + path = scale.transform_path(unit_path) + return Bbox.from_extents(path.get_stroked_extents(markeredgewidth, + self._joinstyle, + self._capstyle)) diff --git a/lib/matplotlib/path.py b/lib/matplotlib/path.py index cc09416a93f2..7cd4e8d0af0b 100644 --- a/lib/matplotlib/path.py +++ b/lib/matplotlib/path.py @@ -21,60 +21,6 @@ from .bezier import BezierSegment, split_bezier_intersecting_with_closedpath -CornerInfo = namedtuple('CornerInfo', 'apex incidence_angle corner_angle') -r"""Used to have a universal way to account for how much the bounding box of a -shape will grow as we increase its `markeredgewidth`. - -Attributes ----------- - `apex` : float - the vertex that marks the "tip" of the corner - `incidence_angle` : float - the angle that the corner bisector makes with the box edge (where - top/bottom box edges are horizontal, left/right box edges are - vertical). - `corner_angle` : float - the internal angle of the corner, where np.pi is a straight line, and 0 - is retracing exactly the way you came. None can be used to signify that - the line ends there (i.e. no corner). - -Notes ------ -$\pi$ and 0 are equivalent for `corner_angle`. Both $\theta$ and $\pi - \theta$ -are equivalent for `incidence_angle` by symmetry.""" - - -def _incidence_corner_from_angles(angle_1, angle_2): - """Gets CornerInfo from direction of lines making up corner. - - This function expects angle_1 and angle_2 (in radians) to be - the orientation of lines 1 and 2 (arbitrarily chosen to point - towards the corner where they meet) relative to the coordinate - system. - - Helper function for Path.iter_corners. - - Returns - ------- - incidence_angle : float in [0, 2*pi] - as described in CornerInfo docs - corner_angle : float in [0, pi] - as described in CornerInfo docs - """ - # get "interior" angle between tangents to joined curves' tips - corner_angle = np.abs(angle_1 - angle_2) - if corner_angle > np.pi: - corner_angle = 2*np.pi - corner_angle - # since [-pi, pi], we need to sort to avoid modulo - smaller_angle = min(angle_1, angle_2) - larger_angle = max(angle_1, angle_2) - if np.isclose(smaller_angle + corner_angle, larger_angle): - incident_angle = smaller_angle + corner_angle/2 - else: - incident_angle = smaller_angle - corner_angle/2 - return incident_angle, corner_angle - - class Path: """ A series of possibly disconnected, possibly closed, line and curve @@ -549,9 +495,9 @@ def iter_corners(self, **kwargs): # if it's used this way, then we more or less ignore the # current bcurve. if np.isclose(np.linalg.norm(tan_in), 0): - incident_a, corner_a = _incidence_corner_from_angles( + incidence_a, corner_a = _incidence_corner_from_angles( prev_tan_angle, first_tan_angle) - yield CornerInfo(prev_vertex, incident_a, corner_a) + yield CornerInfo(prev_vertex, incidence_a, corner_a) continue # otherwise, we have to calculate both the corner from the # previous line segment to the current straight line, and from @@ -560,18 +506,18 @@ def iter_corners(self, **kwargs): # the latter looks like: tan_out = bcurve.tan_out angle_end = np.arctan2(tan_out[1], tan_out[0]) - incident_a, corner_a = _incidence_corner_from_angles( + incidence_a, corner_a = _incidence_corner_from_angles( angle_end, first_tan_angle) - yield CornerInfo(first_vertex, incident_a, corner_a) + yield CornerInfo(first_vertex, incidence_a, corner_a) # finally, usual case is when two curves meet at an angle tan_in = -bcurve.tan_in angle_in = np.arctan2(tan_in[1], tan_in[0]) if first_tan_angle is None: first_tan_angle = angle_in if prev_tan_angle is not None: - incident_a, corner_a = _incidence_corner_from_angles( + incidence_a, corner_a = _incidence_corner_from_angles( angle_in, prev_tan_angle) - yield CornerInfo(prev_vertex, incident_a, corner_a) + yield CornerInfo(prev_vertex, incidence_a, corner_a) tan_out = bcurve.tan_out prev_tan_angle = np.arctan2(tan_out[1], tan_out[0]) prev_vertex = bcurve.cpoints[-1] @@ -719,6 +665,66 @@ def get_extents(self, transform=None): transform = None return Bbox(_path.get_path_extents(path, transform)) + def get_stroked_extents(self, markeredgewidth=0, joinstyle='round', + capstyle='butt', **kwargs): + """Get size of bbox of marker directly from its path. + + Parameters + ---------- + markersize : float + "Size" of the marker, in points. + + markeredgewidth : float, optional, default: 0 + Width, in points, of the stroke used to create the marker's edge. + + kwargs : Dict[str, object] + forwarded to iter_curves and iter_corners + + Returns + ------- + bbox : (4,) float, array_like + The extents of the path including an edge of width markeredgewidth. + + Note + ---- + The approach used is simply to notice that the bbox with no marker edge + must be defined by a corner (control point of the linear parts of path) + or a an extremal point on one of the curved parts of the path. + + For a nonzero marker edge width, because the interior extrema will by + definition be parallel to the bounding box, we need only check if the + path location + width/2 extends the bbox at each interior extrema. + Then, for each join and cap, we check if that join extends the bbox. + """ + maxi = 2 # [xmin, ymin, *xmax, ymax] + # get_extents returns a bbox, so Bbox.extents + extents = self.get_extents().extents + for curve, code in self.iter_curves(**kwargs): + curve = BezierSegment(curve) + dims, zeros = curve.interior_extrema + if len(zeros) == 0: + continue + for dim, zero in zip(dims, zeros): + potential_extrema = curve.point_at_t(zero)[dim] + if potential_extrema < extents[dim]: + extents[dim] = potential_extrema + if potential_extrema > extents[maxi+dim]: + extents[maxi+dim] = potential_extrema + for corner in self.iter_corners(**kwargs): + _pad_extents_with_corner(extents, corner, markeredgewidth, + joinstyle, capstyle) + # account for corner_angle = pi ambiguity + corner_a = corner.corner_angle + if corner_a is not None and np.isclose(corner.corner_angle, np.pi): + # rotate by pi, this is the "same" corner, but padding in + # opposite direction + x = np.cos(corner.incidence_angle) + y = np.sin(corner.incidence_angle) + sym_corner = CornerInfo(corner.apex, np.arctan2(-y, -x), np.pi) + _pad_extents_with_corner(extents, sym_corner, markeredgewidth, + joinstyle, capstyle) + return extents + def intersects_path(self, other, filled=True): """ Returns *True* if this path intersects another given path. @@ -1230,3 +1236,219 @@ def get_path_collection_extents( return Bbox.from_extents(*_path.get_path_collection_extents( master_transform, paths, np.atleast_3d(transforms), offsets, offset_transform)) + + +def _get_padding_due_to_angle(width, phi, theta, joinstyle='miter', + capstyle='butt'): + """Computes how much a stroke of *width* overflows the naive bbox at a + corner described by *corner_info*. + + Parameters + ---------- + width : float + `markeredgewidth` used to draw the stroke that we're computing the + overflow for + phi : float + incidence angle of bisector of corner relative to side of bounding box + we're calculating the padding for + theta : float or None + angle swept out by the two lines that form the corner. if None, the + padding due to a "cap" is used instead of a corner + joinstyle : 'miter' (default), 'round', or 'bevel' + how the corner is to be drawn + capstyle : 'butt', 'round', 'projecting' + + + Returns + ------- + pad : float + amount of overflow + """ + if theta is not None and (theta < 0 or theta > np.pi) \ + or phi < 0 or phi > np.pi: + raise ValueError("Corner angles should be in [0, pi].") + if phi > np.pi/2: + # equivalent by symmetry, but keeps math simpler + phi = np.pi - phi + # if there's no corner (i.e. the path just ends, as in the "sides" of the + # caret marker (and other non-fillable markers), we still need to compute + # how much the "cap" extends past the endpoint of the path + if theta is None: + # for "butt" caps we can compute how far the + # outside edge of the markeredge stroke extends outside of the bounding + # box of its path using the law of sines: $\sin(\phi)/(w/2) = + # \sin(\pi/2 - \phi)/l$ for $w$ the `markeredgewidth`, $\phi$ the + # incidence angle of the line, then $l$ is the length along the outer + # edge of the stroke that extends beyond the bouding box. We can + # translate this to a distance perpendicular to the bounding box E(w, + # \phi) = l \sin(\phi)$, for $l$ as above. + if capstyle == 'butt': + pad = (width/2) * np.cos(phi) + # "round" caps are hemispherical, so regardless of angle + elif capstyle == 'round': + pad = width/2 + # finally, projecting caps are just bevel caps with an extra + # width/2 distance along the direction of the line, so "butt" plus some + # extra + elif capstyle == 'projecting': + pad = (width/2) * np.cos(phi) + (width/2)*np.sin(phi) + # the two "same as straight line" cases are NaN limits in the miter formula + elif np.isclose(theta, 0) and np.isclose(phi, 0) \ + or np.isclose(theta, np.pi) and np.isclose(phi, np.pi/2): + pad = width/2 + # to calculate the offset for _joinstyle == 'miter', imagine aligning the + # corner so that on line comes in along the negative x-axis, and another + # from above, makes an angle $\theta$ with the negative x-axis. the tip of + # the new corner created by the markeredge stroke will be at the point + # where the two outer edge of the markeredge stroke intersect. in the + # orientation described above, the outer edge of the stroke aligned with + # the x axis will obviously have equation $y = -w/2$ where $w$ is the + # markeredgewidth. WLOG, the stroke coming in from above at an angle + # $\theta$ from the negative x-axis will have equation + # $$-(\tan(\theta) x + \frac{w}{2\cos(\theta)}.$$ + # the intersection of these two lines is at $y = w/2$, and we can solve for + # $x = \cot(\theta) (\frac{w}{2} + \frac{w}{2\cos(\theta)})$. + # this puts the "edge" tip a distance $M = (w/2)\csc(\theta/2)$ + # from the tip of the corner itself, on the line defined by the bisector of + # the corner angle. So the extra padding required is $M\sin(\phi)$, where + # $\phi$ is the incidence angle of the corner's bisector. Notice that in + # the obvious limit ($\phi = \theta/2$) where the corner is flush with the + # bbox, this correctly simplifies to just $w/2$. + elif joinstyle == 'miter': + pad = (width/2)*np.sin(phi)/np.sin(theta/2) + # # matplotlib currently doesn't set the miterlimit... + # if pad/width > miterlimit: + # pad = _get_padding_due_to_angle(width, phi, theta, 'bevel', + # capstyle) + # to calculate the offset for _joinstyle = "bevel", we can start with the + # analogous "miter" corner. the rules for how the "bevel" is + # created in SVG is that the outer edges of the stroke continue up until + # the stroke hits the corner point (similar to _capstyle='butt'). A line is + # then drawn joining these two outer points and the interior is filled. in + # other words, it is the same as a "miter" corner, but with some amount of + # the tip removed (an isoceles triangle with base given by the distance + # described above). This base length (the bevel "size") is given by the law + # of sines $b = (w/2)\frac{\sin(\pi - \theta)}{\sin(\theta/2)}$. + # We can then subtract the height of the isoceles rectangle with this base + # height and tip angle $\theta$ from our result $M$ above to get how far + # the midpoint of the bevel extends beyond the outside.... + + # but that's not what we're interested in. + # a beveled edge is exactly the convex hull of its two composite lines with + # capstyle='butt'. So we just compute the individual lines' incidence + # angles and take the maximum of the two padding values + elif joinstyle == 'bevel': + phi1 = phi + theta/2 + phi2 = phi - theta/2 + pad = (width/2) * max(np.abs(np.cos(phi1)), np.abs(np.cos(phi2))) + # finally, _joinstyle = "round" is just _joinstyle = "bevel" but with + # a hemispherical cap. we could calculate this but for now no markers use + # it....except those with "no corner", in which case we can treat them the + # same as squares... + elif joinstyle == 'round': + return width/2 # hemispherical cap, so always same padding + else: + raise ValueError(f"Unknown joinstyle: {joinstyle}") + return pad + + +CornerInfo = namedtuple('CornerInfo', 'apex incidence_angle corner_angle') +r"""Used to have a universal way to account for how much the bounding box of a +shape will grow as we increase its `markeredgewidth`. + +Attributes +---------- + `apex` : float + the vertex that marks the "tip" of the corner + `incidence_angle` : float + the angle that the corner bisector makes with the box edge (where + top/bottom box edges are horizontal, left/right box edges are + vertical). + `corner_angle` : float + the internal angle of the corner, where np.pi is a straight line, and 0 + is retracing exactly the way you came. None can be used to signify that + the line ends there (i.e. no corner). + +Notes +----- +$\pi$ and 0 are equivalent for `corner_angle`. Both $\theta$ and $\pi - \theta$ +are equivalent for `incidence_angle` by symmetry.""" + + +def _incidence_corner_from_angles(angle_1, angle_2): + """Gets CornerInfo from direction of lines making up corner. + + This function expects angle_1 and angle_2 (in radians) to be + the orientation of lines 1 and 2 (arbitrarily chosen to point + towards the corner where they meet) relative to the coordinate + system. + + Helper function for Path.iter_corners. + + Returns + ------- + incidence_angle : float in [0, 2*pi] + as described in CornerInfo docs + corner_angle : float in [0, pi] + as described in CornerInfo docs + + Notes + ----- + Is necessarily ambiguous if corner_angle is pi. + """ + # get "interior" angle between tangents to joined curves' tips + corner_angle = np.abs(angle_1 - angle_2) + if corner_angle > np.pi: + corner_angle = 2*np.pi - corner_angle + # since [-pi, pi], we need to sort to avoid modulo + smaller_angle = min(angle_1, angle_2) + larger_angle = max(angle_1, angle_2) + if np.isclose(smaller_angle + corner_angle, larger_angle): + incidence_angle = smaller_angle + corner_angle/2 + else: + incidence_angle = smaller_angle - corner_angle/2 + return incidence_angle, corner_angle + + +def _pad_extents_with_corner(extents, corner, markeredgewidth, joinstyle, + capstyle): + xmin = 0 + ymin = 1 + xmax = 2 + ymax = 3 + # now for each of up/down/left/right, convert the absolute + # incidence angle into the incidence angle relative to that + # respective side of the bbox, and see if the corner expands the + # extents... + x, y = corner.apex + if np.cos(corner.incidence_angle) > 0: + incidence_angle = corner.incidence_angle + np.pi/2 + x += _get_padding_due_to_angle( + markeredgewidth, incidence_angle, corner.corner_angle, + joinstyle=joinstyle, capstyle=capstyle) + if x > extents[xmax]: + extents[xmax] = x + else: + if corner.incidence_angle < 0: # [-pi, -pi/2] + incidence_angle = 3*np.pi/2 + corner.incidence_angle + else: + incidence_angle = corner.incidence_angle - np.pi/2 + x -= _get_padding_due_to_angle( + markeredgewidth, incidence_angle, corner.corner_angle, + joinstyle=joinstyle, capstyle=capstyle) + if x < extents[xmin]: + extents[xmin] = x + if np.sin(corner.incidence_angle) > 0: + incidence_angle = corner.incidence_angle + y += _get_padding_due_to_angle( + markeredgewidth, incidence_angle, corner.corner_angle, + joinstyle=joinstyle, capstyle=capstyle) + if y > extents[ymax]: + extents[ymax] = y + else: + incidence_angle = corner.incidence_angle + np.pi + y -= _get_padding_due_to_angle( + markeredgewidth, incidence_angle, corner.corner_angle, + joinstyle=joinstyle, capstyle=capstyle) + if y < extents[ymin]: + extents[ymin] = y From b3906538db66833ae9cbe35a1ce276fc4ecf42f0 Mon Sep 17 00:00:00 2001 From: Bruno Beltran Date: Thu, 12 Mar 2020 04:23:00 +0100 Subject: [PATCH 30/33] path bbox now works for all markers but "pixel" --- lib/matplotlib/path.py | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/lib/matplotlib/path.py b/lib/matplotlib/path.py index 7cd4e8d0af0b..f762d175a354 100644 --- a/lib/matplotlib/path.py +++ b/lib/matplotlib/path.py @@ -1387,7 +1387,7 @@ def _incidence_corner_from_angles(angle_1, angle_2): Returns ------- - incidence_angle : float in [0, 2*pi] + incidence_angle : float in [-pi, pi] as described in CornerInfo docs corner_angle : float in [0, pi] as described in CornerInfo docs @@ -1407,6 +1407,9 @@ def _incidence_corner_from_angles(angle_1, angle_2): incidence_angle = smaller_angle + corner_angle/2 else: incidence_angle = smaller_angle - corner_angle/2 + # stay in [-pi, pi] + if incidence_angle < -np.pi: + incidence_angle = 2*np.pi + incidence_angle return incidence_angle, corner_angle @@ -1452,3 +1455,19 @@ def _pad_extents_with_corner(extents, corner, markeredgewidth, joinstyle, joinstyle=joinstyle, capstyle=capstyle) if y < extents[ymin]: extents[ymin] = y + # also catch extra extent due to caps growing sideways + if corner.corner_angle is None: + for perp_dir in [np.pi/2, 3*np.pi/2]: + x, y = corner.apex + if corner.corner_angle is None: + cap_perp = corner.incidence_angle + perp_dir + x += (markeredgewidth/2) * np.cos(cap_perp) + if x < extents[xmin]: + extents[xmin] = x + if x > extents[xmax]: + extents[xmax] = x + y += (markeredgewidth/2) * np.sin(cap_perp) + if y < extents[ymin]: + extents[ymin] = y + if y > extents[ymax]: + extents[ymax] = y From 85a305011dd05870157092ef3ea01e7d9300b6d4 Mon Sep 17 00:00:00 2001 From: Bruno Beltran Date: Thu, 12 Mar 2020 04:43:28 +0100 Subject: [PATCH 31/33] reorg'd path/bezier code now builds docs no errors --- lib/matplotlib/patches.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/matplotlib/patches.py b/lib/matplotlib/patches.py index fdcdc57cead7..f63a0cb53a3f 100644 --- a/lib/matplotlib/patches.py +++ b/lib/matplotlib/patches.py @@ -4173,7 +4173,7 @@ def get_path(self): """ _path, fillable = self.get_path_in_displaycoord() if np.iterable(fillable): - _path = Path.make_compound_path(_path) + _path = Path.make_compound_path(*_path) return self.get_transform().inverted().transform_path(_path) def get_path_in_displaycoord(self): From 79aa3b76b0eae7a8318f86b6f8a94695514dcf44 Mon Sep 17 00:00:00 2001 From: Bruno Beltran Date: Thu, 12 Mar 2020 04:43:45 +0100 Subject: [PATCH 32/33] cleanup docstrings of stroked path bbox code --- lib/matplotlib/path.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/lib/matplotlib/path.py b/lib/matplotlib/path.py index f762d175a354..1377e13dc63e 100644 --- a/lib/matplotlib/path.py +++ b/lib/matplotlib/path.py @@ -1354,7 +1354,7 @@ def _get_padding_due_to_angle(width, phi, theta, joinstyle='miter', CornerInfo = namedtuple('CornerInfo', 'apex incidence_angle corner_angle') r"""Used to have a universal way to account for how much the bounding box of a -shape will grow as we increase its `markeredgewidth`. +shape will grow as we increase its *markeredgewidth*. Attributes ---------- @@ -1372,7 +1372,8 @@ def _get_padding_due_to_angle(width, phi, theta, joinstyle='miter', Notes ----- $\pi$ and 0 are equivalent for `corner_angle`. Both $\theta$ and $\pi - \theta$ -are equivalent for `incidence_angle` by symmetry.""" +are equivalent for `incidence_angle` by symmetry. +""" def _incidence_corner_from_angles(angle_1, angle_2): From 1ea37be110a0e4f2d8739f54917045d5889aba81 Mon Sep 17 00:00:00 2001 From: Bruno Beltran Date: Thu, 12 Mar 2020 05:12:18 +0100 Subject: [PATCH 33/33] fixed sphinx warnings in path.py's docstrings --- lib/matplotlib/path.py | 25 +++++++++++++------------ 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/lib/matplotlib/path.py b/lib/matplotlib/path.py index 1377e13dc63e..53de5acd8a16 100644 --- a/lib/matplotlib/path.py +++ b/lib/matplotlib/path.py @@ -423,7 +423,7 @@ def iter_curves(self, **kwargs): np.array of size (N, 2), where N is the number of control points in the segment, and d=2 is the dimension of the Path. - kwargs get forwarded to :method:`Path.iter_segments`. + kwargs get forwarded to `Path.iter_segments`. """ first_vertex = None prev_vertex = None @@ -1353,21 +1353,22 @@ def _get_padding_due_to_angle(width, phi, theta, joinstyle='miter', CornerInfo = namedtuple('CornerInfo', 'apex incidence_angle corner_angle') -r"""Used to have a universal way to account for how much the bounding box of a +CornerInfo.__doc__ = r""" +Used to have a universal way to account for how much the bounding box of a shape will grow as we increase its *markeredgewidth*. Attributes ---------- - `apex` : float - the vertex that marks the "tip" of the corner - `incidence_angle` : float - the angle that the corner bisector makes with the box edge (where - top/bottom box edges are horizontal, left/right box edges are - vertical). - `corner_angle` : float - the internal angle of the corner, where np.pi is a straight line, and 0 - is retracing exactly the way you came. None can be used to signify that - the line ends there (i.e. no corner). +apex : float + the vertex that marks the "tip" of the corner +incidence_angle : float + the angle that the corner bisector makes with the box edge (where + top/bottom box edges are horizontal, left/right box edges are + vertical). +corner_angle : float + the internal angle of the corner, where np.pi is a straight line, and 0 + is retracing exactly the way you came. None can be used to signify that + the line ends there (i.e. no corner). Notes -----