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 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 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)