From 265caec520ed82f1126655da214273766d50a073 Mon Sep 17 00:00:00 2001 From: Bruno Beltran Date: Mon, 20 Apr 2020 02:04:00 -0700 Subject: [PATCH 1/2] FEATURE: Path.get_stroked_extents --- .../2020-06-17-path-extents.rst | 10 + lib/matplotlib/bezier.py | 14 + lib/matplotlib/path.py | 389 ++++++++++++++++++ .../test_path/stroked_bbox.pdf | Bin 0 -> 2031 bytes .../test_path/stroked_bbox.png | Bin 0 -> 26337 bytes .../test_path/stroked_bbox.svg | 251 +++++++++++ lib/matplotlib/tests/test_path.py | 52 ++- 7 files changed, 712 insertions(+), 4 deletions(-) create mode 100644 doc/users/next_whats_new/2020-06-17-path-extents.rst create mode 100644 lib/matplotlib/tests/baseline_images/test_path/stroked_bbox.pdf create mode 100644 lib/matplotlib/tests/baseline_images/test_path/stroked_bbox.png create mode 100644 lib/matplotlib/tests/baseline_images/test_path/stroked_bbox.svg diff --git a/doc/users/next_whats_new/2020-06-17-path-extents.rst b/doc/users/next_whats_new/2020-06-17-path-extents.rst new file mode 100644 index 000000000000..125158af76cc --- /dev/null +++ b/doc/users/next_whats_new/2020-06-17-path-extents.rst @@ -0,0 +1,10 @@ +New function to get Path's *stroked* Bbox +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +A Path is typically drawn by stroking it (with some ``markeredgewidth``), an +operation which changes its bounding box in a nontrivial way, depending on the +Path's joinstyle, capstyle, miterlimit, and shape. + +`~.path.Path.get_stroked_extents` was added to allow computation of the final +bounding box in pixel/points coordinates of the line, after it has been drawn +(and accounting for the joinstyle, capstyle, and miterlimit). diff --git a/lib/matplotlib/bezier.py b/lib/matplotlib/bezier.py index 3fcd31d7dea3..a6e0de9a8efe 100644 --- a/lib/matplotlib/bezier.py +++ b/lib/matplotlib/bezier.py @@ -234,6 +234,20 @@ def degree(self): """Degree of the polynomial. One less the number of control points.""" return self._N - 1 + @property + def tan_in(self): + if self._N < 2: + raise ValueError("Need at least two control points to get tangent " + "vector!") + return self.control_points[1] - self.control_points[0] + + @property + def tan_out(self): + if self._N < 2: + raise ValueError("Need at least two control points to get tangent " + "vector!") + return self.control_points[-1] - self.control_points[-2] + @property def polynomial_coefficients(self): r""" diff --git a/lib/matplotlib/path.py b/lib/matplotlib/path.py index f89e86a72dc3..0e8615a24674 100644 --- a/lib/matplotlib/path.py +++ b/lib/matplotlib/path.py @@ -9,6 +9,7 @@ visualisation. """ +from collections import namedtuple from functools import lru_cache from weakref import WeakValueDictionary @@ -20,6 +21,31 @@ from .bezier import BezierSegment +VertexInfo = namedtuple('VertexInfo', 'apex incidence_angle corner_angle') +VertexInfo.__doc__ = r""" +Holds information necessary to ascertain the bounding box of a vertex once it's +been stroked at a given ``markeredgewidth``. + +Attributes +---------- +apex : Tuple[float,float] + The position of the vertex. +incidence_angle : float, in + For vertices with one incoming line, set to ``None``. For vertices that + form a corner, the angle swept out by the two lines that meet at the + vertex. +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. +""" + + class Path: """ A series of possibly disconnected, possibly closed, line and curve @@ -461,6 +487,85 @@ def iter_bezier(self, **kwargs): raise ValueError("Invalid Path.code_type: " + str(code)) prev_vert = verts[-2:] + def iter_angles(self, **kwargs): + """ + Iterate over `.VertexInfo` for each vertex in the path. + + Parameters + ---------- + **kwargs + Forwarded to `.iter_segments` + + Yields + ------ + vinfo : `.VertexInfo` + Measure of the vertex's position, orientation, and angle (if it's + the apex of a corner). 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 B, code in self.iter_bezier(**kwargs): + 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 VertexInfo(cap_vertex, cap_angle, None) + first_tan_angle = None + prev_tan_angle = None + first_vertex = B.control_points[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 (like closing a circle). + # If it's used this way, then we more or less ignore the + # current bcurve. + if np.isclose(np.linalg.norm(tan_in), 0): + incidence_a, corner_a = _vertex_info_from_angles( + prev_tan_angle, first_tan_angle) + yield VertexInfo(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 + # 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 = B.tan_out + angle_end = np.arctan2(tan_out[1], tan_out[0]) + incidence_a, corner_a = _vertex_info_from_angles( + angle_end, first_tan_angle) + yield VertexInfo(first_vertex, incidence_a, corner_a) + # finally, usual case is when two curves meet at an angle + tan_in = -B.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: + incidence_a, corner_a = _vertex_info_from_angles( + angle_in, prev_tan_angle) + yield VertexInfo(prev_vertex, incidence_a, corner_a) + tan_out = B.tan_out + prev_tan_angle = np.arctan2(tan_out[1], tan_out[0]) + prev_vertex = B.control_points[-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 VertexInfo(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, @@ -596,6 +701,65 @@ def get_extents(self, transform=None, **kwargs): bbox.update_from_data_xy(curve([0, *dzeros, 1]), ignore=False) return bbox + def get_stroked_extents(self, markeredgewidth, transform, joinstyle, + capstyle, **kwargs): + """ + Get Bbox of path stroked with given *markeredgewidth*. + + Parameters + ---------- + markeredgewidth : float + Width, in points, of the stroke used to create the marker's edge. + For ``markeredgewidth = 0``, same as `.get_extents`. + transform : `~.transforms.Transform` + Transform from the coordinates of the path's vertices to the units + in which the marker edge is specified. The *markeredgewidth* is + typically defined in points, so it doesn't usually make sense to + request the stroked extents of a path without transforming it. + joinstyle : {'miter', 'bevel', 'round'} + How the corner is to be drawn. + capstyle : {'butt', 'round', 'projecting'} + How line ends are to be drawn. + **kwargs + Forwarded to `.iter_angles`. + + 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. + """ + from .transforms import Bbox + maxi = 2 # [xmin, ymin, *xmax, ymax] + # get_extents returns a bbox, Bbox.extents returns a copy of a np.array + extents = self.get_extents(transform=transform).extents + for vinfo in self.iter_angles(transform=transform, **kwargs): + _pad_extents_stroked_vertex(extents, vinfo, markeredgewidth, + joinstyle, capstyle) + # account for 2-fold ambiguity in orientation of corner's bisector + # angle when the line is approximately straight (corner_angle = pi) + corner_a = vinfo.corner_angle + if corner_a is not None and np.isclose(vinfo.corner_angle, np.pi): + # rotate by pi, this is the "same" corner, but padding in + # opposite direction + x = np.cos(vinfo.incidence_angle) + y = np.sin(vinfo.incidence_angle) + vinfo = VertexInfo(vinfo.apex, np.arctan2(-y, -x), np.pi) + _pad_extents_stroked_vertex(extents, vinfo, markeredgewidth, + joinstyle, capstyle) + return Bbox.from_extents(extents) + def intersects_path(self, other, filled=True): """ Return whether if this path intersects another given path. @@ -1033,3 +1197,228 @@ 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 _vertex_info_from_angles(angle_1, angle_2): + """ + Gets VertexInfo from direction of lines making up a 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_angles`. + + Returns + ------- + incidence_angle : float in [-pi, pi] + as described in VertexInfo docs + corner_angle : float in [0, pi] + as described in VertexInfo 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 input in [-pi, pi], we need to sort to avoid a modulo op + 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 + # stay in [-pi, pi] + if incidence_angle < -np.pi: + incidence_angle = 2*np.pi + incidence_angle + return incidence_angle, corner_angle + + +def _stroke_x_overflow(width, phi, theta, joinstyle='miter', capstyle='butt'): + """ + Computes how far right a stroke of *width* extends past x coordinate of + vertex. + + Assumes the incident lines are both coming from the left. + + Parameters + ---------- + width : float + `markeredgewidth` used to draw the stroke that we're computing the + overflow for. + phi : float + For vertices with one incoming line, *phi* is the incidence angle that + line forms with the positive y-axis. For corners (vertices with two + incoming lines) the incidence angle of the corner's bisector is used + instead. + theta : float or None + For vertices with one incoming line, set to ``None``. For vertices that + form a corner, the interior angle swept out by the two lines that meet + at the vertex. + joinstyle : {'miter', 'bevel', 'round'} + How the corner is to be drawn. + capstyle : {'butt', 'round', 'projecting'} + How line ends are to be drawn. + + Returns + ------- + pad : float + Amount of bbox 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': + return (width/2) * np.cos(phi) + # "round" caps are hemispherical, so regardless of angle + elif capstyle == 'round': + return 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': + return (width/2) * np.cos(phi) + (width/2)*np.sin(phi) + else: + raise ValueError(f"Unknown capstyle: {capstyle}.") + # 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): + return width/2 + # to calculate the offset for _joinstyle == 'miter', imagine aligning the + # corner so that one line comes in along the negative x-axis, and another + # from above, making 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 limit ($\phi = \theta/2$) where the "corner" is flush with the bbox, + # this correctly simplifies to just $w/2$. + elif joinstyle == 'miter': + # matplotlib currently doesn't set the miterlimit... + _nominal_miter_limit = 10 # pdf and agg do this + if 1/np.sin(theta/2) > _nominal_miter_limit: + return _stroke_x_overflow(width, phi, theta, 'bevel', capstyle) + else: + return (width/2)*np.sin(phi)/np.sin(theta/2) + # 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 + return (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}") + + +def _pad_extents_stroked_vertex(extents, vinfo, markeredgewidth, joinstyle, + capstyle): + """ + Accumulator for building true extents from `.VertexInfo`s. + + Parameters + ---------- + extents : 4*[float] + The extents (xmin, ymin, xmax, ymax) of the `~.transforms.Bbox` of the + vertices. Modified in place so that the corner described by *vinfo* + fits into the extents when stroked with a width of *markeredgewidth*. + vinfo : `.VertexInfo` + Information about the corner or cap at one vertex. + markeredgewidth : `float` + The width of the stroke being drawn. + joinstyle : {'miter', 'bevel', 'round'} + How the corner is to be drawn. + capstyle : {'butt', 'round', 'projecting'} + How line ends are to be drawn. + + Notes + ----- + Implementing by wrapping `._stroke_x_overflow`. This function checks which + direction the corner (or cap) is pointing, then for each side of *extents* + that might need padding, it rotates the corner to point in the positive x + direction and calls `._stroke_x_overflow` to get the padding. + """ + xmin = 0 + ymin = 1 + xmax = 2 + ymax = 3 + # now for each direction (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 stroked vertex expands the extents... + x, y = vinfo.apex + if np.cos(vinfo.incidence_angle) > 0: + incidence_angle = vinfo.incidence_angle + np.pi/2 + x += _stroke_x_overflow(markeredgewidth, incidence_angle, + vinfo.corner_angle, joinstyle, capstyle) + if x > extents[xmax]: + extents[xmax] = x + else: + if vinfo.incidence_angle < 0: # [-pi, -pi/2] + incidence_angle = 3*np.pi/2 + vinfo.incidence_angle + else: + incidence_angle = vinfo.incidence_angle - np.pi/2 + x -= _stroke_x_overflow(markeredgewidth, incidence_angle, + vinfo.corner_angle, joinstyle, capstyle) + if x < extents[xmin]: + extents[xmin] = x + if np.sin(vinfo.incidence_angle) > 0: + incidence_angle = vinfo.incidence_angle + y += _stroke_x_overflow(markeredgewidth, incidence_angle, + vinfo.corner_angle, joinstyle, capstyle) + if y > extents[ymax]: + extents[ymax] = y + else: + incidence_angle = vinfo.incidence_angle + np.pi + y -= _stroke_x_overflow(markeredgewidth, incidence_angle, + vinfo.corner_angle, joinstyle, capstyle) + if y < extents[ymin]: + extents[ymin] = y + # also catch extra extent due to caps growing sideways + if vinfo.corner_angle is None: + for perp_dir in [np.pi/2, 3*np.pi/2]: + x, y = vinfo.apex + cap_perp = vinfo.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 diff --git a/lib/matplotlib/tests/baseline_images/test_path/stroked_bbox.pdf b/lib/matplotlib/tests/baseline_images/test_path/stroked_bbox.pdf new file mode 100644 index 0000000000000000000000000000000000000000..0e0d8cd2c3ebcd470108745110d146dc19196569 GIT binary patch literal 2031 zcmZXVcT`hn7{*&rk%+OfL|h*uAgCmPB!C({LP!7^Aq+tQ8^Q$=!c7d98m!tXxG7cz zff5J9L7C##2uQ32X*)71@S@BNRLV z`L}RnKmwvL0dDPDKxA_xp;%yxDjY!cix)$H=*&g9LQy1Gw+;uv;n+oo&jMVe2+J4Y zA`qZV1TjT00_-Nf*<1vHBru?2M;wtY5%GK>1Z+X;Fqmk5NQwZWTMSy$Y4Ytn`E~;# zfan{=4d=s=fHn~x=p812cnGi`PyVSLG6@x?2G{_@A|w_qfyO(dtw1n(a-2xOl8EBO z02|ZU7Z4fH4}2cPVWFkblb?hO$BMZU26U!b2^@Jo;!M=IUquVeL%y%j64DivW=+L_K6cJW)7mxd#MCB2j>h z{TU7x7>h_CZVXN;yR%6NchJ6*zgsah)1!RomW_r{?W#;?{06T@HFZvUU=bJ{(I;%o z7S1<1+EroXTC|Xwe_I`*|DiZ}B*Ajb;0&u_6n{pGHPq7<=5@pC-F-hX%e!U0?8DQM zuFFy@M{$Zk<><0C7WgV%bp0C?CPC)|4@dPq?;dFwB|hJKd2Hb4;!EbkYp$E(k2$$f zO^*xk)OP+XJwu$2Lv&YfU8=5e?T*B!mV-IvsY`PVJC{8rhVD{?^p)>@Cb|(#S^s>v zr>uNzuz@&urf1zSaqNbs)XvKzv&wJy&uQUb%4;_d zx=3hQ9%=^PEc`{m(38qn-U`$a9O^Z@r*He9`-;@AImmGEzv5Jv+*uacOqQfkWknrW zB6Ixvno!W`izl-Tmb7n4f3^0##Sz`tS0fbmuiNnS(l^<8`#TKeDfvMz9Am0ccIwU) z29Md9ef(u^m^HOJ@{$R1s z`DS&dsl{Ubdsl@O+XED5U4sPbKZ747?3eG5r&V!utSodLQ(triZ(VVOfJ>+Ar(Mbq z^n5F;NFLM(yhd;RwyY*&R->D__IPeXK8B)HfDt@?EgoXK(-K< z$~IeBC8rQG?faV8hSbEcT25nC=yEL|r{bi7jsR{bkHA>7END0wFMZQP$aGK+uR~Loe7f|a#vGld|F8#M>+3pqbvVk%>-buDKp!nG^X+;bBNu>Fi!`h@F49^<=L-<=EH;yz?TF z;?<#sa|hDewp;XSsF%s=+xt&*jf<3w9T$ErdflzE5_fve4CwE?ld~D{>-$wUjRwDQ zawR24@^8{J6iVycd))qUFgBx|uX$LM;=~^~I2R(w4BZRk2Q>P`RwaohWvmP@-n_+a z)sJrbZdL!gzFvE0*qmKmgzR}b?SHis5;nIgYmGx!RNnVmb!+ZhqaUjmCHeP`lGYTM zcigQj$*OOnE0Cq}9W#2YD&Gb)^^DYi^ZtEwHQUaDG_&AsYK1XDgxu(W1(u}1x46U%!JKJ641 zdBtP#&`;?)`_tHY>bZprTHVvF>|Pk`J7D4QAfU`uTk~XTQX{<*G2ZS|F`~7)rb!nA z@FWT*Q9O9D(5C&BXl= Dud)H2 literal 0 HcmV?d00001 diff --git a/lib/matplotlib/tests/baseline_images/test_path/stroked_bbox.png b/lib/matplotlib/tests/baseline_images/test_path/stroked_bbox.png new file mode 100644 index 0000000000000000000000000000000000000000..cd6fd41e0486f4ecabcec888a985f55391f43a77 GIT binary patch literal 26337 zcmeFZWmHye*EM_s5`uyOs3@TV5(0vRG>S-fH;RCCcbX_jm(tzc9U>v!UDDm1-`d{y zJD%~5@%{aNea|(9g9|7h(t+gO;G+n5->p|I1lvNkj~d&-r3k#a4|9c&oPYVRt8M0^8`t7-pdvus@5nJ!E59XS}K2v zAqu6>E&B4gf`y0JQ4KBsws!Nq7{6u*Keng?e&sz3N*O1?f?|UFWa<8IYf*&5H zI26dQCW0vBD4o0LILNP~*94FsLtp;)(EnYg|2rg-gG=xrKpL9FuaZ2Q2>VHEI{ctN z~e$Tu04>nqhO&j+Zn^z9V8sy~yg98IEHe{v#{*BJTYY3ONv$HEM@rG~w-E^1Z zEjEFeVun^3KZ9htimY1x%jLlhukod&rQ-}Cyw8nI+D*RnNpW^-do<^TWjyn8I z?Ha9*(=&?C<#fuda0v(oiwq5oDhz|vicM&>CtIUUqMqD8;Aji;{?3|h|GuVXM^{kL zNB1s!rPU;?n9od4l3WmVMl zPSR(oH5qL1>A_Ul1Y<_5kj+gCww9HHxerO=Ijd;T^TMK|v&r30W!=w@QsnT-`nr=P zi{`)F-96c;(Y9Q*6b(DDH*wk@*2od|yO+i|sBP}zc6{WRZ9#Y!vP$!xPNvQ#Jh#KY z*qSb=`99jS?N+89rW%nT>NJ-lF{AGIg!QVDATIm$OlryS<`lE>%A8w-v&S=6(9k;k zB&BP%ElCKau<=Etd8uqy9rRFTs|RzP{1WOMB(MC`eqBtNoa^oGJ@c{V8C8qRAG^?EQEsPO!YLxG9cVGAEf_#_?_TEAlum1h{p>H@jNRBeA-HWS@tHOK~A4Bx&+L7hL@W(`I1T&S*un#xnQU&D5O%@ zZkUjEM?_Mgnn_)m4xHQ&7x|NQBbiblI0o#OP}=J@#M7`ppU4dkE4ghfTIy+V~e z34QxMaqRhQItq zTwLR-N?aUu_Epr;Ll2Y(o-ZkP^3}WSKTO6|bsI&_PQ+8BRMm*Rn0Knk2O}ptf^BFz zANKb4vgtNn`TY5_;rduvGCA`lj}H{c!2_A937MIXZ!)-2jZ=yqZwjWlI_u^cQ}D0* zT!;j-Hq8;!($bRfxs-;po2KivhBC`kmEv9Q4ybmu9U8Tb{rvzrNt^G5znXZu!&ZN? zq}<9t;GdK-nGdCd7%a~laq%&4l9T6fua^qmwPy7FHl1Ro8RE1+x>m4gqbMi;vlNB- z0)-}%Z!%8TpRQ+uSG#&hkk(7Ik(eklUM`wb)mVD%nE{{2y`bIfrIiV{lQpC9@}i}E z8j=qmJ}8t~>4|qJQa=<_D-z>!8w=ctJA@UXQB79}z`|!OX5?}?%q=z+`J)_CWLU?ym)5U1cez8OX$?Xle-ygE&e5)APjw!6e(S z-B8CNGyIX94l>JQs!ltSjh_jf8F!Va9z9xV`}vfen9Aoahg5%ta>-u!CSwMA^S4?7 z!`*k^CdrYlgK`v+k{T#B9hE4y<2kSYX}ypbC+NczV_JRi^1kl*7QJ~#8E)Es%h?+_kxty4$=?cU4lh7g&iatVHoR|2?A~e?$ z@>J^*mYC@%32Z4*Q8|U+W$nX@d%1UzOV{D^C=5~<*tH$Z348D$(RL*f&=`YyvBbky z-=Dbh5i;2Ad$5{MXu!_fOyVG6V{zLeVaw0L8p>3pr)7}rci!E!qoRp}JtP+T`{j)4 z-0zoR_$9iho=@E`7;G1F)fv>r(vg^hq)=XcG=I0G)B9Iv2a3gb;PDZ&v)VU>xEGkP zn;j~y1PdS;sF*9Wh^ z`|BS}2#@^LDlUa)g`FZnH6~N{53gGj0sssjwehIYJbxA2m<9;}at zLoyAK{J~YU`1M0v-Nv!+Qk8R}M;D$cl(F2r6)W&>nagc`@^09!%sUQ6^EwxH?#@VH z-nrPyN+b@94r5-gXHzeBNrF-|-E&tWO3h?w>##panlEjn$e4TLXDD@+hM|cpkKWtc zH!a`3eS&pK>p83zcH4oQLaO39+ znxA5czI(A%kf;>cD8^;DeOd<3$aq1@nwN$qlIM;93G2Jqx{ufkt};tKa#?DP#0^?U z+jELZVnpU)?{6S&K-h4euIKsriFr^Ksn5lsx}3WxDV6C{^-#e(ckYm|oBYTxC}7}a zs%#2En{BwI9b8lM^UjD&XE*_QZcUgAlvQ+T0f_`-46SMAHD^jO zF5BOlItZNCh6|b=E|?BxM;-64tpr#nd^H@F(d( zaaoG3YOoVqx)UTcJkX&DqEQVDOiZQGT@g@H3XMRU^-pY~ED9=Yn03^+NkWniTTHDr zltF%6EaVU?|F(#jSkG{Q0R#Vrru}$QbF)Zmr}y-G;anXK1U|N|KN7z<>evS z`A<+&tHYR4-k<$!TKVMf)KZbDLqjTi%l)kmK}Y~(srx*iP%qKSYrQ$ce|mO?6fxw4 z(5h8j7eG;9@67O1E)j{Q5i3rst*j8#mr8amt1{slhaIw z^D)hba^nEn=Lu~or1%zE#O3k&q0qURv7q1u2HYp*p;64x{~pm?HwvqMddh0lMT^h=NfY$MAE|UK(C>_vJfw*`KVD&q;N_F1u5DkyLjwG%GS2vVvS-M zwE}%*7kZcO`S!+DhW-rhi5mNyVC%C_M*WeUEkVJQNr@uu5!@5*$AyKi&g%!i>Pg9+ z|1P8|OQ$RR*<#+>YRqK31^vrGXFxO~r}Hiq1H*wD-uZclJ3ir^nv3%asc0U<6Wa{s zoNS2JmG{_W7UmvD^X+n;_&v-0QiIQ)-w*gf)Bf|J4_Va8)1LG@xqzM_zjCWvF$Uj* z0DU?>a52UStrPCm5GyGH{AbTmwD$p&WIN=%uq(!od={`$WsHHb7ZzmRe#4J^@H>m% z?w25sZNTAV9eE!%G~C^N?6*(13Jf|H;|dJ=VI4hBa@yPa+@+&h&YnIyQab$Axg|54 zueSace_Jm_DotE}K51oP$C|aHO+oq5N4JVd?i9TgX>AwR+NOo+&VWX`KB3U@8GaX6 zS2mkP8OdlK)w;U6^%#F-BYeb83eyffKIkM8$h>{=cV#6*wLrhjl`NQNeb+;Q+hynw z-FoKLf;KTa=QRPbuQQxAXZ!%dTKTDONJaMz)BCd8_y^m}fA@{yoUypT3)E`gI-VLX z;Bp(-8rBNhjSDKLG|c!o5=jaVVJIj%duC|~-_KD)uH(>(A|@umglzESJb$D^-+-H! zSjaPKhX29ChkNr8=IKPdM9pEVs6r1Tt-`N|P>Ao!WL7UN!b#?zqK*5)onOj0F zdn;+1MpeVAIyysL_BXgMI*Ht4E*2!pHFDZRVsw@HPOMut44$q3+LQTOWI{{IO+B~x zS4>)Z5c1RVmbh}7oX}H)j=qO}Jim61NIncCm}9mK?iCOx%*YMuBk~FdY1tJ-Md4&evZ^GPXJ3^zwzMe zZ5k24QH`(FEG(`G%;Dyhrui5)1@?ReqCd?9imNn^iB57W=7fPp`y_&b85y{p6r<9KHf$fTzH}Rd( zdt-M#x%De58@BymURTn`!3}iUeNs#l?MoKIcl3K`UdQgi6?7b2FJST#^WI+hRpGMF zNaz%O`$9HNj;cL^LwaQ}XK)W1)1xEV%1CZ|sgr{Zj&Zl1L{U0(#luX+)*nf&!;inv z6t{JJYIvALL(}MiA|9sQ!jZUcvpp+%wm)hf8okpkOx_O|Q?bZMQ5@3oO6_}$mNsU2 z?dMRVRs4AOe>4!qQ7(@ZWe`7BiFFD*opm^U+u%jipKGMZhL0oj8A>J~u<@}3&-Re` zQ~>S7oV3h1IXK5~Wwrx$uo@$hkhe!E)vZHC85#d%iJ$TJ1;1E4#F1~q@`oq7JfLn~RD7Gzoy|x@R#v%^l$baf zN=BMG2kldNMG{tvv1(&w-F82`$jl?mXZ6}*(Ar9TC%kTHS@+l;JC=X57Q@Tew^)Dw zez1x@M)T@$fh55_tVrCe?auB=VvYQ@l&`LaT)KQ&?ZKb1E|%CIki^wWWzyw^p=0d@ zZbc$0Su}|Hc&mwg4|3}*-_5$dG$CQDeCB3u&ZB8{ef?UXF0cu&Uw^Hqhtk1A7ZJ7C zO+6OH-M1qZcrBCU#cC0&kmU8BmM#;Wjo``^1sj_}cJql$xH+ffS5FZHDk8>0<}NB$ zeyH@^S)e7DHUVmas`?-%{xSxNehY`#<3>j84FB=oN(!g-e5%sx$mrc)eQ7kOYDIxP zJyunUT__e{2l}f_NUyh2Qo;3#LPy^~p@+&vM@zhvcI#R!ik#fEJrCVR>U?-o z>Y>3KodHuT88cEw1g$M{I=&;@C!D90Yj*kk&$!iX3-T2n(YHEW(qEEG6TRu#6mMZ^ zdn)`9>nf}Lx`yq>#CSbKATTMBH*Vh49M|D2%2)?wCGJ3K*D{6Rt zN?+|*LBlMYuE~Ai+~mvYo><@1 zD|gK+cgtUVK6UpsuVy9_wWMT`Q>!`eSvhs{Eo4pL6Sk5PE;Wsq-PkyMMo8yLKt2%4 zpuWvRO+g_57`ZaqX_+>JR=zR0MW%<6nwt3CRet~zG|!$5S!1GkcpkkqSU4oP-Jouj z-(5739x`(VJd26R`hi+;aO5s44Rzz~F7+g@nX}86d%6>VNlg7)$<5E)UHZpwbAOE?E{elJxALT`%UxzM} zPf00qc5Jx7F*)m00i9y_Gt2nt>1iDD+>i{6uF*on1h=ze$Ku;{BJma752toCvNRtosKa5<&wklER3>b(8~HfeuNFJ0< zN}EJN3H=ipz;XrUHcQjHU9jheYnB~-JrXx9oO7Q_)h zpP&o!2!rg`^!KE@yL;Q3{XQCs&=NXQRON430uhL`{wint6K^zB#1lMX6iUlKARsPZ zua#~g<~4N1rW2K_3~Gg&Z}d=8s=NPg;K%JhkS6Gr7PmK0aB-ms?UO1IsfeokGpZ-| zwcu3}*5-165=^CwJ83VksO#Z5&?lORp585ETT!1N{`D)|~?At$s%IRus8{{DO#bYB^(4Tsr z&G5lh63Qr^rPI={U&k(P+=wK1Ze{2>J?c2<{26qjA)ow=e}8WB=K^d>21%nXij5dFlmxJZ@L%p#KOICo`(H`zf91pf4^&n$#|jkT zeM7^ZY|SvOlM``h5Bc_Y4GmB1(q0Ruxi~g8nT%2=rD!{8=0q!`1k&6M_voll>{pdCx#i)V>DCz{`sF_r z>w%P?Jv}pYn|zIW62Ij=W$Y-e)2>kR`flS=4WR#;Rb3#JT#cKr-&(gIE^c5bqiv;o z_XESOSn-_E;*f!?){}*y8trt&)-7iEnoS&Z9F4HhM3kWDS3G{OD3F*tN#iH7Mo9Idy+Ycyj6$O zY52nF9s?H(6wML2!^A?2dp4DLs6*}p_(wt^o)4H|HY)>?ldcDv???Ke;E$G=EB|EC z<538~j(m3Cblj+Iea>VR@fAoUU|%{K%@cg;cCLCAZ^)!Gn%8N@hl58huql|yylq72 z5I%(5`nGa$=K1EHqSLvs)@xv1#3Ut)Dk+igle~0oHpfg;GF?;sWSf5*H!Up!00CJc z)O+!84)g4ajp|XG6F(G8o%FxtPKqjv{#?qEr0#Dg@cnm7Dl~q zeIY2w2Klyk4fH<)pjecNm1fbNKbP|(YlJ$63e~;;VwQ_+n={>g1?@8^wmBGR8 z1YyajvkN9nbF}uXm|-{R%4Vtwj){pu$H3?>HdV(gG+sE=`1!LZltHe_2~do$-#5Q7 zt1qMBY>?4O$t!?riDqOonssnj3kT?xGYrGQt}y~ zREU?LrF{&nD>^(PmiDRyB0y7WIsCR!$2kgJgJr6hyaZo+Yal=pZs zfsuSNQS@fjR^#2}p}d3zFP4>EL)!b#fdN8CuSB+%5Z1@8zo9>$8ng@!j^)V0g#er| zwiGfG6APr4WGT*7FfuZFe{nHUY#IS9cjEck$;w8}#lDOlwdo`yo3TDvQaoBAc*TL4 zV6~VM=v`CJREgWDKI!=7C8L4_9GA_ae_AoQ%jSC$Hlsv@pBDL0Ee5(1!tq zvH)?$tYSGmW6UYo2t-jQ(E1rNo3GZ@^=R7PQU4M4<6RqHi0(kD>&d>V)iMY+sUU$k zt>!gJ9Hc@_Zrq3H00hvJ)Hv`hCE7`hZR~d8+38N_%I;Ec@~ApBYu}-&pkS4zXt&*F z?Cy4J<9;PHn(9v98oY010N!+3$tnr{`O|Qz4Ax=cnt*=|uXAF>279s8B`mD+qTDH1 zu)XDsoNzP0U!6A#qLPx6m%lxVmKiNJ-2(+)-OUZqE^qQ*p>vj}NkNa2Y6U=X_p4{= z&({-gXwa-Kj5G#{4kDUSF-xuAB#Gwnn>UGHaSUf((eh{n+8pm1!tR#&MyA%*2|Ulm zf%`?uQg2EIG>>FuSg=0ePiTg1L`WEHaQFm&r#DlzKth|Efk9F}S%P_-?6b&!1Q%W= zX!M@yw`~ql6%-V(xu3fLBP4Qhy1@T)2&_aeyvVMp&~bCC!{YV-6PI1nol zb=6L{$f#8Aj~F8liRY~Q=AAof9Fs2cS!zYwZJ5QTLC1Sf8?Ue#PX9E91aYMs16};+ z@Q{X!YsBWndZA-!*dV4T_W>b0u)|%qv|Zvda;irX231SUq8>bWfav<9+;(lpc!?=@ z?^E)QA6sLfWWHY)&?y6>!`{Z^q|J#m|5=~&3J(adKsTv7wDF&B5u5=f;k2BVGCLD= zkl?!B+|tssl2eoY%7>|pZ1AqSi;&E#m8$e3-H6_C!>=7d?d(P2T~a+1%mi$WLA zFC97*;C{2HCE3N7r@D<1k+Ke(Tp>j&Nb)>9f65PqeObrdAmJUu-iGKC z`ukVTZolDY#FQg80ejGHP~ril;=y7OLaqb5EvDP}y?bk>X`CAb|DHf9BoCKh6!;X8p5uT9APNQ^0 zb3{id(y|`WRV+d^>ft>YOIRPTFa~btzvn@r`#GI%H4zaL>zAL+{t2>$i(A3{!22J0 zCBl;)qZxBh^jNWHG%#I7b(pC7WSyH0RM43wzd>lac${F10>$nb!l*ft)v$n1&X);K zxErp-TPZ8CkX1JVdjahw3tu!@P@!Otj%c*A#4>bY;mo>UNI1+g=UT&_s>2PDy+l1f zC*cTvqwAA(8L6H?#Y$?QhbU`x^XVtig>K_h+)@bL4NaV+6mrPFhad~s@BK&|tW8vn zCJdsZViO<0PDewN>rHMPxCAX*do=Ip?p!DIH2@bTY);Vsw6Ru~b+#2QI8j9!qs@L; z(fj1@7)a+BAL)I*6LXOY4QM#4VPKEyJ7{jinem8CtQVr}mkT=io{qcCH2UBk?CzFQ z8=QXN9Monq9nwjjyDvIJ$xvKOfekfy4?IKAIcb&I|85em{~~I=Aa<3+p%P!^-G&zJ z!m3Mux@eIRCm%cQAC(`4j@C^l(lMdM=ACEJ1)aWa5{9kBy36xoQZH;=Dub=P7Llqa zm7FqBp)4jHM#5%QN&a~-PgkzN%lBZ5+w%s<-QcNe$^fbh;giz#LAV&OpLAUqrj@@; zQJgMEb>ON~cSdKJn#eFf80^XCdb|jWDGMR&!*SzAqUB8D&!&ee$;T>R;Wj{l?@UuhINxtWjsG(A1SK9Vm z>k62G_E$G3#!CJVyshT55D@%wX|`jdQ_TxTxLN* zx~^13!G*$&UL3xkL|g7?<45K&rL1IENCEw%)H1JqdvZE@ZXl z@B&29Ki?jKKsaxDZa>r;pYKAq6F|Cq^Y-rL-l){od>#fKt&y6GmUUA?36o5P zisC%mcpZ!`O)VRM-uD)2W*kA?8oIvUGv={2bDFFH_U1R0_fxSK?Y+^-`R}XFK z@R%wQFPEBJE0I_jyPMLZ$&q@l0c?LfE-^KxKh}PtP-MRImwz7fBzH@#U%&c)I5p%9QIaU-{}1GC>`t#=SQ9H;e4F|4~2JkMv59&Ri&tv zQH;Wn8jbp%=+C9f*5}LbLqW{C!wNeo2_(bOd?y{iBV)YvmZI7sBoET66JG_sYQ`ap zzF=o$WRx=Kh}=4~)#(6?QlU-k7xc57;W zng(65&*{Lb(A42K{SAel6T#z{kB*KmQ9}My|9gJ^<8s-(S`X*hP+&Bfst;uw6BM#2 zH5q2EXZY#ySSgks9UU#Ll}sunsTCy_n*u;J54-dzJ|SUFTf)4JUNJopTJ6+6+d<~T zl2lQt(D^pzVlw}zs4N2Ttqy8AXKA_aDXyIJadiQKnoQ|Q3+*uzvn&bk$dHpjdpO%( zpS<)>vl`2pS7#@a7sbgxmiv8TikxhXJFrC?ZT!STAIok`RDtavej#S#vF?*2V)HO1 z3XqA_^51?#oStArHXP^D^?pIi!=r)NI-c26vTM$rkV0i!#p6RSAtuIBy(pG{ zp4S6-Dh(3X@?=d-lYv*|dihG>+dnrR&c^UDG&I~%uf^mWew^TS`un%Y_3Pl~m2)T+ zX0$v#v_wD|ct4Xz1_AQ9pKf`!gfbXg>RAl7gwV4AN?jW(>m5z{F;HkI&wILAPr~Dn z3&_1v6MsACCv6mu;jR=cBy<%BpHQgZPGUM3&;Q_$2vbb z%%j0E@}}GsdTJmAbYBZSSv%cm%gL!qmXhX8Dln+x)>VA2n5lx)d3irF-o6B3g8ppH z(NU_pHw1h%RTtF-Rl|V;Vor)Z2HSS1B@jaaFDI#+l{M@plF~$j4QTJrqnJhb1C<}! zq--9rT8@j+-`|%FSCnRCYq#f2(=deYqFp*Fp!EPvKnZw(F354O(_I0LB6L5jmAb|q z+{aWxwBE#NbHBeG(?6^q?JtEAKd?Mpkm9tzn#OKAvU$~-nZv->W-ch^4ol;MDmuf2 zHMTjzYu942$z5ciQIP3PmaJ04!%cy`W;B%h`WQrB(~5a8ZxP+OV>r4ef!G?1AiS8*C#gzoj7Cjf2+8uV+#t9%5 zz~B87aUrT_drte2T_dC$9v((IZ>1b9)ATBE)gZ(b(m=u@Jb3z49yH&+KS7VGG+Tt+ z+}z5cPiNYRPB3ExESTve#xEg3B`eFJOSAG2s8KnRaGBy z{s|W}=L0beY^`b;Ok5f^wj!BNez1Bg7v~!nyU>6yL;s;x9>Dp@XGr1>C!kb z7R$XUex`pD6w>4%>qUdcE^z2Ew}|#U&IV|?GPa_1SQPN$MttCSOHClYI#wp{Q~89~ z6j(YNa8qlq&Xq!gIX5#i^V55)LT>gRpNk5#U48J)Q~g{ZA|*eHc6b5GU1>I$u@9sg zrzYbcJrv|}kV#jPq|@Z;6h!>Q*7FkWKbQrf?B%5iq=7x=cs5A7uiDjlH2YiNXVixm zhQRlg!xu^NPmaJ}L;5J6;)oejP@9I0i|}1Ny}i6{S+FZKuiPW$vXLpXn&WYk6vZt6 z=Fxud)IJh&=>FR1%E6@jftu=|rhU{a-+O7!8&zTE!Qw2pE;=$*@?O+?5)49{W%{^7 z#b6%V2c%IpsbUl(CaZf9r;ww?3)bj1n-hQ@LNV^=Wk7>aK)Obj!FhHJ+53Q* zqNH4eJMKc*S8zCv+~T8rXw#Q2bV&1kxi5g2k+pb%_hhv|>oPeM>++or?jol>Bh#@` zSpbet{$e`mPRHE+BgT67-OT)rdAN`qPcpd^ZTEgMJgGr=9Nty;0s;c)+9mmM zDS&LafB$~*<1V}BkOi==l7Ma3Gye^zm30qBAS@W-5e7D*+b}|z@L*@B7fEyg|9(1~ zgrs202a>JpgY~hp zk%FeNgGG?tkg5V%9}-LUZC4c=p>>SEa4Ft^(MDBX!(BqiUTEeSb@r`0-%TU zDM^*nUJtUbA@P_^`$n9h!j`&*3V zeL`vFU-hO)54s+V8>FAXm9Rp51K@9;Zkh68(Q8>O8q|`H;d2FBGEc+V=0`Dd&Un-F za}b*M8}4M3+pQTrE)Na352jT0Vht|Q*3iI$CpT{D@+@foN2!jNfU<{fSTvOt{XVQK_iCA3miRUIDGc6TkNl)r#-8d2qb;bwkuo2pgY zUC%%ovCA|^@^W)$xE!qlYumK8ztPzdYIOm8(2*?|us`EPZPct}k1d2h>psep=KC2n zp7?F&%y@8~Px&)$ho4YfzQfj=-WYP8Gx8T|!ifoovY( zoC|T-l#iSQrjlWEq(e-ZG5HRs$v^VB$0>Jl*u;<9`S8P)bA~{KpFwQt`zuP>8mdqa z^N__qDS97?LqWshbg-TUxa*gKIwB;G6p8{#Sy)Pk`YiS01FKmit93FV37sf1&@kT- zr9LGOWYsGIo=p!Vvk)_=sc5F1;7|yGv$x1*suoQ>XBccq4BFKidkzw~pHT%mam_zC zt&^9`NfBZX<}pqQDxXa8(f;YPMG>M4`-#Q41ZEtpfUj2pT;v*j4gJd6S-{;GU7Vje zovfAM85{}(1c!$kIUklTL^~G(r2K`AdlenW>$`DIl@i!jSjOF;bgF@|Y62TPjtB>b zYIvoi6I*?LY&>q&%HT*gm@IkM_3*FfF^nYajahf5L1)OW`0mC>ckf6j5Tn_U*%P2b zFDLl%$-$APgP7a(4^^7AUwyIJj0K^3P}x;VN=gYHl2qg~fD<6z6W+Wj?+`tSrBl^P zE?#c(xN8H(V8G@DRV)k~G(=zl!6)TP1>dldd_l6U?t8}XhPZe^k?p^WbUlNuO~l-E z!+~?fx*B=DNOS7zau7vlzmjYr8o}cb1p`3{8dU<2#N$9zdk@ijnxmPcxop$b@dz2U zDpmY=w!hk9qS0so6?U|fRQJ(G6dQO>vOncuzL}8~7Y5EjDLXh)(x==k%4WtVOiMX+ z$|ZdJ_scXp)1?S=N&}Af0RcV7dtq8k8Z=k$EglZJoqadX(pYlf_xch{$UB}JN1T$p zoa@Qh4TutXps!!QCM*A?H$4N*ablVE&!3b*dM{0pa>6K0cn+u!qtw^2C>Wy$f3L1Y9CIhuKqn&L4+p%`oYKoabdA(qbCb zdY~nbN=I=icJdxXekMXgO@%|6LEM+2zSy9ZbO+PQv$~x+0Ew$&r&W9N_9x#5OMgAd zm4JT-$p?ZBpN!`nBKede_91PTtz|&rbmlRLB zjy_muZMK8IhC4x03voSjOgPm4@&fvL8~_dBjT_SgoY__TYRD=;OP`-gSGxn);r#UU z`$8C=?4f@m8Lv}mQTWQ~(M}QQn`7qDe5P0UcwI-@mx1P0htZBbuzG}>xkEs&fcfj8 zOKkIfkJ#qztu0wV%S-F?Fb&4L)AqD%a;#d@XrOHf9X0j(V9eSt%?`3Ug6cj*N?M=@ z-(0#zXb4-)YGpJ5O0hB24PvEBC;_q?H@?$`bP@<`ARrc|)~t4bA~FV}J>nK}aV_%^ z?EY=e=v9eWQNYfeJPPez?;9FIR4uWvAIVBjWDLnjXQwoxl8F1s0;?`ibG9D=xnXUX z1donN2n4nvbVdre<_s_jAx#1(dM9ff44UPA|5i#9YOCADnY|CVIo@Ni5 zX60vquE&-|(NGlHqM~5J=$Wc3!}#Rq2gYr%{LR>%9pCXY(|lqXPe?R(IMkhx6hiN# zN=m9)A_W*&JcnN43A*>gZ{OtBMtV3VMR=W_@lhjwwFw+H7)|BFYJLyjn3Q;X(rkC> zp<>$fno%T|^L{2W@pX9UAiqYhoSlh*jbF+Z4YH+t$AQPQGwuj~83$(pyVWNh&ksGk zA@eHzA17JzC=-as;B(K*rT|`l_7{Z>Aw3YtijWW`N-qA9`kN;H@ze{p1EkM7n+85&HvSvSA1SZ!FtdUm?Q zqr z)gIKakJ>6wN}sK3fe(r?LSnMtP0c%$y?!^A6ebx#qSOG-d-K5%@}%%?F&4(rfqoMR zPv}jSrF>eFp;VhUKK+~>6Bw>%y&-mm+?+%lXX_0JU(rWM8z zanX|Lb5|S<&DU?ZToG0oEQ3n=4C+eYCF=4bqTdC4V{~@BhgjtyN-~w$j8cUQ^wU)f zk_(+n6yne`O5uJTZqvs-bpJo0Am>9;cCfc%AfaAil{8Z<9wr8~HUBmF^w)meNuftJWvRk2(>Dj`%f! zsptn?81dz{q#M#w9i;kT>j$a+*?&izpWu+fr~Y>){{J8I{}FN%jHu5;G45i68Z!=) z|JPjlaIvJAbj@zL7BAS#o0?#V7Vzr3)R{9{+Ma6HFfD8>F&`38Vi+Vxio}fuBtj0uNJcnuRzJ9&cWT|{~i zl^PmAFnGS0%+oHP3OFz=y)7*o5FekINV$RwSsik-)auY{iMk41&k}lCfBuV8`JAf#@TGlVVB{yO zU45pQ4R4@9(yRqI@Jy*Vv#&5MkKqMk+R=Oa_Jxowh= zccrG#dy}Eu=j3jOaZCI5(5Zru$&iV>mm>_hl@~Deh%k5M2velr_QP_?XG&)&yW%^@ zsHn$+a9f}MF~t|B1xx#&G|8%{3?*^A5KKxEXGlf7U!2TWnY=22B}{YNnLh@BN}?!7 zB`+Rf-u(TqdD9~`YYrZ~(8Q>DjVZEdFw*sJ(b(g#FA0r+D%~IZ90R282qWzjDblr} zJV8D0Aroylw-6C8g~j9l4CoQRfw^^dQa(zxxWEj(=>W}XKw z&JXy3@16c_4jv~^`E_Dv7Yvre;hRctAj{6Cs8-%LM2wEOHjrh9iHU4a0@0EG+J%vQ z3ZYnR;Ae9%-Z;<-*MW>Gg_}+0=?ag7M+E_?C{%&U*K5B2Mve4&KFsQY!zmMJ6oc|J z;Blb?1I8FW^u!y5-gJ7J7)xQU90z`zWpLGlAPOD4%*&UbkbVGFOM2yna9jwG{B~<2 zhQKB#kAy*2%5AqQ1Ha%+Jo_K!ZKQ7x4zQqd5|DZ4x<71yY&oCX%)c}a{l9m~W`r&o zBQePg|4ajW2NBtk{w-}EA#i{sgSRu7n7)RqvcQ8*RwkU^y?O~`KmfKBF7UU6FA$1~ z({A+#LQw$+5n)9BU+X`sE(d%7mlWD_6^ZY&GrA_G`_tY`;mEUqkxb-S2;3J!J&(%= z&Y^0%g+6(-#IW}jAen-HC{P8I#20f%3o*(t|F*Or%D`3XSdfR`GT*))1KttTt?O_{ zCeW@Un%&Yq0vAvA*Rp7nUtOi8Yxz&=yz;_RH$__f@@1$mgx99FTJ;PJGC(*#hFKZO zA|}Oj5VxoMfKx*PCiat;UzjgBR3?6=RHC%Bay#?B2OvzCPDYFWbo zBeJ(F8bVj5q~0D5qP(x)`2W}diUFd^sN}u*3_XZm`57pvY%pAc#5xmQ8;7x>p)2kr`0gqz+Y>J&_a=U3r=dZ# zJ$%lsbC}`X|M9-fnrImKk@ci2hH2I82C_O8&gf#}vu1BJPB-Qfqg~Pp`}s2sI1yTQ z_5p175HC+N6mtcQ@IXm z)c}llK`#ovON7dMEkt9veWbq*VSWrTfNcLP^&&CDK>Vt$FX{MqrVm}lR_iqJ%?4Q1 zi$Ra#O+s91feIN)49JLoR!&9s=AOsU>20P}%lWoDpaju&*BQt}u$%4znXv>jSRpw~ zg5LvRV7PJ_1W}|J4-5>1{zNAcf<%EO|~IE1ikkY~kN^w;0f z{z=i|b=_OZN&ovU9^GtTC$F%ObsRA9ai{xv(8?K*dGG2zVEz;R6n@@hW|n*Tsn9ZE z$Gl8-wh|`JNVsg?MK|?9xkI>R;842WAF!)IsTB5E2G?BtP>cik_b!Q9^20ZZVWNipf44T-iU5wZR9UrSJN0tSy2 zDkyr%4iFX)AL&3Oq=T7ZAhUlCzdDze&_RU27mNZDMJ2WwO^nxxi1Z-8%K;f+@XM2u zl<#rXS#h3F9*0gX>hqT`MgSHtamfRLb4pM*uj-SAGEHJo|@E7M+yeO#?WDQcK-vKeagnfFR4`6(v$Je5S7zX)HV3 z*Yy>%Gl5f)0=+esg@pyz#tR0(f#kIV;N{1oKq>b9pXrO1QxyR*RNDE!8yXBZr|RG% z#mJ2JS|?{cu^1sb&b^&Yu_-Vz$-^9YS}~Z(kjcQ(2erKLN{f#m&nQpbeKi$N!e(Q% zsxr=Nl?X3(Fgs>Vk&YRFx}K8~f?*}l!sqOI+IRz&77g=TiFBL)=|;Akvj0Sw#$PnSBvro$IL<|G&U@y}x>p&fp`Jp z$Ef4#E!;)Oz>ROh*vrA9y8HX4^gCqX*G-PC2pvytKV|WqAL{mko&A*`ndG5I5vT>~ z%9?trKxnz2t!N{!UU2mzj8)kLvv2cE(&fvSjYf;JLK!uUj>@7&iY1Lk`iA10!+~*I z8$5-r!Dc!A62edz$Vr?aIzstN*M?xG=c!qiiWac5KBU|OKoxZ&LNQ|UmB#t2pEI;L zR#t!%iz7#%{F1iY4{=d~j88poK$M<}a2c2^0>5610dpoP#ql2FlK1y>+& zgJ7dA`u8NDT2C-urUo<1(0^W~qK+p4{%g<&*v8$EQ7D5J9zt0NtU|={2Jg!F#n`m4 z4%uBXTZ7Y26QW4--_u}FpZ|&1m+^=}^^J)vE4(ly4&VM8+v9i8sk ziImPc-fxT{nw;R4c&)9>_PwHjk9!B6f3m7ZaOl`Hk=nnT{{1a`va$^PeaP^(cRVe- zQo82oP_!~Yc~_V_Rs&%0>({SJl`$${$UthW>Vv$}9>vXZfqApUJS?2;Y4IirIu4H+ zyt~P)eFd)51YvpMD&*vlHv=(vmHd9mYdht!`a^v!uZe7@P2%O)Dv>y{N({aJ@P-0a zpwYVXbenkG7zhcF`-A}kHplI^Aq?sRqjMsGNHzRh?}>Q*5dH~bW#+K<%hjYJV;_b} zco|94f1FlXch7-;N8Yyu79(h^WT7~XrPmP>)6sFlJIp@P(&pX~D!vd?$%ynUv5$c0$OOUDmS2C{t6UjvS&QTU55} zdmCDrj%^Z3Df@bkWWUe1?)~9=|AzZhJst_4&*%MlEzj*}3lZDq03-d1j_F~2zmbmp zq<$4YDL#1cAd#f=w5%4d?3p{2y5wnLD$jxAY`(-N+L18KFf=c8BE0HtnXpNgK;?fpJS(55TS%}<(o#)Yfp5F%a9mG0g~rJ`b%d?aBcW~$O0 z>eSbCM!sELD`!ckeK`pE$aivHCacG06ym64yC{@bzO&_igG43LD!_48Td1V* zG$MQahTB^@bh0@;SXv})0oO_bdyT-@|iVr8tYNbUn!3H=6|Up^>eLE9}C zy(K1LUu>~E(`C0fIN+<|JE=b3Hhx&HaQagikr+0`MP0h|H#l$C@+t?j%oWo8+njHT zg_blHV!!e0XQ*pA3fA;E=AM1g>oY~t5OMt6q+b!G)~Tcr6T)@i_3e+{q*qAHSY%&T znHxLQ0$JfMxkxaYM~}%r)d(y%bqRwDrCvLSL*?XCj@$mJZ8ms`CnmqPty9?=TQT)NfkObzHa8=%mP7BqV@S4>o z`~}E5U+B|I8Y6bpMKmJZhteOh!749XOrzMN^FHd(AV7~vmn~y?#G70HkGYhDgeGR- zN4B=MLar6bCsBbf(7YJ-DCF?(A*0ks~J3UP-(}JcPrQ7x&pXc53$VQwsE57@5 zjffVik^!oy@8{<$sSOuDJ3zu{Wi2WxCr1Q}&1g|&S-W!nEyfVRF2zFxX`_|QtJrS_ z@&`2;z~4jx6=hi|JHK3qCUA1gTC>miHH~rnbxO8EL)L|!f&I<0FrYy9@Zro56M8X! zsn_MSYp=V4j2RL<+%d$K1NnY}q^MT}=HEV)%W;1pTH5SL#WC=t2GFQfp5VTm2SiMH zagIs!HyUcg7}j~bsZ+A+Vt73 zr}fkB_~d3XF)gG+cPvmI8_|6N`lic~kzC{*N87Bky1~ z`5^7BEA!LWpc zB4^-*88U<-CO~V(2lj}h+_Y)hNI{PqNo2vl9$F!pgBP&MGWAH*-m_yi@-j-Av?6N` z_x3Wwo3JX3bFP>A8~pq{o&XeMITVwJeFllRQYREDk(^bI@Y=LvkoScKvT69#+XUmx z>ay43vQ6>PsMZe{aQ46Elt9eJ8uFo7M4rHBgng&6(H5doOHQidPW&p!OL2C$6Xk)Fca|VQkZWn0w0wOWZ8P>!&@9=EoeFpg-|PzSnAc0-{vgZ`L3Gnu_ob}S`TBmJ1l zZ^aP+TUsU?4vaeCPvTB^W`{>2-AiTvF|BnFht3K|OtMjlsohmD(L=I!kRMPN{(FP~ zk3g(&e!h88NJT=xoSK8!?wIB$v;q=v=vg`br+Ba0J9&guL2KL;C98eS$ehtetGlW4 z8fiSusTYH`|8lxkN4_%+U+;q3O0lkhyP|j(@fUEg~{B|GK=jtQSlokcc^vKxEBiZ3jBVb@U}EC)oOj?)j0RPR#POY zBIhhBYlpC@ygDP|_-a);D>JQ0x}+Mr8Vj*G0wXeUpURm@QSGJr+`Kj3p=xR)UDr3s zMTTA#`*l;a-2Ybw9nCfec_R93<4yrVN0z-NX0sY>;u}Not;oc3r+VK1OHDe@K=VoE z1hqfe!k52r6D-#O&;q>MeV}3!%szJ@KX4o zr?l^pl_fDci}Q^`qs9SR6=KG!i-A{`v(NVRyi{enV_Klj<7dBdF}sD#7iKT@JlBCW zf`oT=BD}Nj^p8TGpBjzNpM^)Q|BHC8vtRu&*e>$xi<4mZ$=ES>!1tPW*FCA9T8-c_ zZX_1j_cPMVQDsr6ASrkR zGQn2gnYpZ12Q3ofQc6tryjU%TGGJac7T)tk6lxJ&d_@yD`y7RK0O$`)vJH@L>^-VU zm91{M8tHp3W<~OI+qH_8@I)w)g_StVAOqT}s(PEnl;u--sAnkW5}ccy9L_6}V^NYr zCguy#g?qn@|8go?D*3RNCoNjL$Y~sTd3%A8;I15%sbbN(f<~3G8SHC&2R@;C4*>R@ z69$cU;E0pPYel^(qI@6f*hG)5n9<=W?|6DSEF~LpuRG`%*!$5*te`ydDRYMk36kmr zs^M+#f{Vf!sJj`8D;wrULz9T4(X{eoO=u`N4H$gC*S4V$dI!@S-=@va!b56_ts_nw zS#=s$g@5Zjhl)u#QC=7!5U`O);0gQDI67?R?Rf{y3y$Eu&oYv|tWV$%+gvKN)_W-Hm=j7ms_fW-=cW^lIUS=Z>DW0kmb~p1pn?IO$=!|qo=|Sm` z9}LPQa$zcd<1jGXC639ExGqp`3iA{;3#&>)r|7l}dSplKwFPH#StAv7Z|HHZP$)u4 zcC+^~hMJOAEMI94a>^-FYp@kw6i%ubfdG>ZNWr~)9#&mqg;(e>MajdW*Onc|o@|ff zl&|O)={$(kFwEZfFmA@Hv|wDhxj{r6f_FZJ@58c=_bQd-Ek|(I+Ry>Nj@9J=N{~u-~Vodd&#Sqz=-FS}eG`Xs!INRpBs-Q_YCgWA!-agZnwb`d( zcs0LIb#+~;v1{wbXme%OHKUBk!QlhIZ=j{=4Ybd9ZOfSfZxR3eZs~!=GS?R0yVy4_ zdlyTe=OIue@iEhzmoL#=w_Y_gnBb7Iup=h8Si|5%jFJ!~ zT4%+XioGf;*(}w0SKKl$^G+w!10uq8<5N5m{cG?I4!;2>Xz$ujJqq|z$FT4e zvU_~0p>U{QXcFN>WI9p7gz=P1n>jVr1}H|SsR{`QvBlPCEf^8KM0bpb*^wmB$>_ z^hQ{2YKqRy2X^{4>F6dxNFO`hy(~FdIC#XIOfukA$yknn@-TwCg%8P=iu+ zt_@^}=3cXgjweBMSU_Z{aP;P%%P5-on*Ku5j)yHy^6T(&Jq`zZO-yQL@pI?H_u9W$ z3$wCyQpoq4$TPAPF0i|ZaJuw;egg{DmbNAr?-;Io$*}~%AMo;^@7F3BxV{_<;)m&f_bIWq&tT0 zo<(RpWQiu(!lN}~?{67G*_-sSHjYqgkfPrMpZ;J0Zc!`hAL3#?BT-rU&wli3+NRu~ zf14{1V)M4yuBJf0A@(-O^Nmd4^wwMdkt{-FCtMO8d80MAh85VYma6gOIwXs#mb*+9 zyJ~#1ZupGZ$NYLk;k13HVt?J{DAlJriHa&2CcFC||3S@V&g|ElE6ZPd5<|6`nwpZn zz|oX6G3Tco&ae3E-^!(YjmNso=cTr8N5IVN3oN8w#*wJ7kw zr0xcl#AVyK(0rvecrP`nx~W%jafov*T zt`c+uKggzML}H%)_emQd`~UZWB>(l{E8iBEjp;KT)XiuLmJTgvkCtf5`Sun literal 0 HcmV?d00001 diff --git a/lib/matplotlib/tests/baseline_images/test_path/stroked_bbox.svg b/lib/matplotlib/tests/baseline_images/test_path/stroked_bbox.svg new file mode 100644 index 000000000000..f419b52c73f7 --- /dev/null +++ b/lib/matplotlib/tests/baseline_images/test_path/stroked_bbox.svg @@ -0,0 +1,251 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/lib/matplotlib/tests/test_path.py b/lib/matplotlib/tests/test_path.py index 3ff83da499ff..23113af69366 100644 --- a/lib/matplotlib/tests/test_path.py +++ b/lib/matplotlib/tests/test_path.py @@ -8,10 +8,10 @@ from matplotlib import patches from matplotlib.path import Path -from matplotlib.patches import Polygon +from matplotlib.patches import Polygon, PathPatch from matplotlib.testing.decorators import image_comparison import matplotlib.pyplot as plt -from matplotlib import transforms +from matplotlib.transforms import Bbox, Affine2D from matplotlib.backend_bases import MouseEvent @@ -100,6 +100,50 @@ def test_exact_extents(path, extents): assert np.all(path.get_extents().extents == extents) +@image_comparison(['stroked_bbox'], remove_text=True, + extensions=['pdf', 'svg', 'png']) +def test_stroked_extents(): + markeredgewidth = 10 + leg_length = 1 + joinstyles = ['miter', 'round', 'bevel'] + capstyles = ['butt', 'round', 'projecting'] + # The common miterlimit defaults are 0, :math:`\sqrt{2}`, 4, and 10. These + # angles are chosen so that each successive one will trigger one of these + # default miter limits. + angles = [np.pi, np.pi/4, np.pi/8, np.pi/24] + # Each column tests one join style and one butt style, each row one angle + # and iterate through orientations + fig, axs = plt.subplots(len(joinstyles), len(angles), sharex=True, + sharey=True) + # technically it *can* extend beyond this depending on miterlimit.... + axs[0, 0].set_xlim([-1.5*leg_length, 1.5*leg_length]) + axs[0, 0].set_ylim([-1.5*leg_length, 1.5*leg_length]) + for i, (joinstyle, capstyle) in enumerate(zip(joinstyles, capstyles)): + for j, corner_angle in enumerate(angles): + rot_angle = (i*len(angles) + j) * 2*np.pi/12 + # A path with two caps and one corner. the corner has: + # path.VertexInfo(apex=(0,0), np.pi + rot_angle + corner_angle/2, + # corner_angle) + vertices = leg_length*np.array( + [[1, 0], [0, 0], [np.cos(corner_angle), np.sin(corner_angle)]]) + path = Path(vertices, [Path.MOVETO, Path.LINETO, Path.LINETO]) + path = path.transformed(Affine2D().rotate(rot_angle)) + patch = PathPatch(path, linewidth=markeredgewidth, + joinstyle=joinstyle, capstyle=capstyle) + axs[i, j].add_patch(patch) + # plot the extents + data_to_pts = (Affine2D().scale(72) + + fig.dpi_scale_trans.inverted() + + axs[i, j].transData) + bbox = path.get_stroked_extents(markeredgewidth, data_to_pts, + joinstyle, capstyle) + bbox = bbox.transformed(data_to_pts.inverted()) + axs[i, j].plot([bbox.x0, bbox.x0, bbox.x1, bbox.x1, bbox.x0], + [bbox.y0, bbox.y1, bbox.y1, bbox.y0, bbox.y0], + 'r-.') + axs[i, j].axis('off') + + def test_point_in_path_nan(): box = np.array([[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]]) p = Path(box) @@ -287,7 +331,7 @@ def test_path_no_doubled_point_in_to_polygon(): (r0, c0, r1, c1) = (1.0, 1.5, 2.1, 2.5) poly = Path(np.vstack((hand[:, 1], hand[:, 0])).T, closed=True) - clip_rect = transforms.Bbox([[r0, c0], [r1, c1]]) + clip_rect = Bbox([[r0, c0], [r1, c1]]) poly_clipped = poly.clip_to_bbox(clip_rect).to_polygons()[0] assert np.all(poly_clipped[-2] != poly_clipped[-1]) @@ -332,7 +376,7 @@ def test_path_intersect_path(phi): # test for the range of intersection angles eps_array = [1e-5, 1e-8, 1e-10, 1e-12] - transform = transforms.Affine2D().rotate(np.deg2rad(phi)) + transform = Affine2D().rotate(np.deg2rad(phi)) # a and b intersect at angle phi a = Path([(-2, 0), (2, 0)]) From 543929bad561add3f6ac9957500cc9469deb1aac Mon Sep 17 00:00:00 2001 From: Bruno Beltran Date: Mon, 20 Apr 2020 06:31:18 -0700 Subject: [PATCH 2/2] BUGFIX: Line2D.get_window_extents more accurate --- lib/matplotlib/lines.py | 47 ++++++++++++++++++++++++++++++++------- lib/matplotlib/markers.py | 28 ++++++++++++++++++++++- 2 files changed, 66 insertions(+), 9 deletions(-) diff --git a/lib/matplotlib/lines.py b/lib/matplotlib/lines.py index 9d86826d075c..88a2fb07708f 100644 --- a/lib/matplotlib/lines.py +++ b/lib/matplotlib/lines.py @@ -605,15 +605,46 @@ def set_picker(self, p): self._picker = p def get_window_extent(self, renderer): - bbox = Bbox([[0, 0], [0, 0]]) - trans_data_to_xy = self.get_transform().transform - bbox.update_from_data_xy(trans_data_to_xy(self.get_xydata()), - ignore=True) - # correct for marker size, if any + """ + Get bbox of Line2D in pixel space. + + Notes + ----- + Both the (stroked) line itself or any markers that have been placed + every ``markevery`` vertices along the line could be responsible for a + `Line2D`'s extents. + """ + # marker contribution if self._marker: - ms = (self._markersize / 72.0 * self.figure.dpi) * 0.5 - bbox = bbox.padded(ms) - return bbox + pts_box = self._marker.get_drawn_bbox( + self._markersize, self._markeredgewidth) + pix_box = pts_box.transformed( + Affine2D().scale(self.figure.dpi / 72.0)) + else: + pix_box = Bbox([[0, 0], [0, 0]]) + marker_bbox = Bbox.null() + trans_data_to_xy = self.get_transform().transform + xy = trans_data_to_xy(self.get_xydata()) + if self._markevery: + xy = xy[::self._markevery] + bottom_left = xy + np.array([pix_box.x0, pix_box.y0]) + marker_bbox.update_from_data_xy(bottom_left, ignore=True) + top_right = xy + np.array([pix_box.x1, pix_box.y1]) + marker_bbox.update_from_data_xy(top_right, ignore=False) + + # line's contribution + if self.is_dashed(): + cap = self._dashcapstyle + join = self._dashjoinstyle + else: + cap = self._solidcapstyle + join = self._solidjoinstyle + line_bbox = Bbox.null() + path, affine = (self._get_transformed_path() + .get_transformed_path_and_affine()) + lw = self.get_linewidth() / 72.0 * self.figure.dpi + path_bbox = path.get_stroked_extents(lw, affine, join, cap) + return Bbox.union([path_bbox, marker_bbox]) @Artist.axes.setter def axes(self, ax): diff --git a/lib/matplotlib/markers.py b/lib/matplotlib/markers.py index ed3d3b18583a..001fe9f04b07 100644 --- a/lib/matplotlib/markers.py +++ b/lib/matplotlib/markers.py @@ -132,7 +132,7 @@ 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, @@ -908,3 +908,29 @@ 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_drawn_bbox(self, markersize, markeredgewidth, **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 + Forwarded to `~.path.Path.iter_angles`. + + Returns + ------- + bbox : matplotlib.transforms.Bbox + The extents of the marker including its edge (in points) if it were + centered at (0,0). + """ + if np.isclose(markersize, 0): + return Bbox([[0, 0], [0, 0]]) + scale = Affine2D().scale(markersize) + transform = scale + self._transform + return self._path.get_stroked_extents(markeredgewidth, transform, + self._joinstyle, self._capstyle) pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy