diff --git a/lib/matplotlib/bezier.py b/lib/matplotlib/bezier.py index 9e347ce87f29..c3a64f69dec1 100644 --- a/lib/matplotlib/bezier.py +++ b/lib/matplotlib/bezier.py @@ -7,7 +7,6 @@ import numpy as np import matplotlib.cbook as cbook -from matplotlib.path import Path class NonIntersectingPathException(ValueError): @@ -177,18 +176,74 @@ 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 + P = self.cpoints + # derivative of cubic bezier curve has coefficients + 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(self.d): + if discriminant[i] < 0: + continue + 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) + 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): @@ -225,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 @@ -306,6 +299,7 @@ def _f(xy): # quadratic Bezier lines + def get_cos_sin(x0, y0, x1, y1): dx, dy = x1 - x0, y1 - y0 d = (dx * dx + dy * dy) ** .5 @@ -478,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/lines.py b/lib/matplotlib/lines.py index 76c256dfa185..7d111d5b2a40 100644 --- a/lib/matplotlib/lines.py +++ b/lib/matplotlib/lines.py @@ -617,8 +617,13 @@ 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 - bbox = bbox.padded(ms) + 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()) + # 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 @Artist.axes.setter diff --git a/lib/matplotlib/markers.py b/lib/matplotlib/markers.py index bab0d4a600b8..94e52059f8eb 100644 --- a/lib/matplotlib/markers.py +++ b/lib/matplotlib/markers.py @@ -133,7 +133,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, @@ -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]], @@ -898,3 +896,43 @@ 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_bbox(self, markersize, markeredgewidth=0, **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 path's iter_curves and iter_corners + + Returns + ------- + bbox : matplotlib.transforms.Bbox + 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 np.isclose(markersize, 0): + return Bbox([[0, 0], [0, 0]]) + unit_path = self._transform.transform_path(self._path) + scale = Affine2D().scale(markersize) + path = scale.transform_path(unit_path) + return Bbox.from_extents(path.get_stroked_extents(markeredgewidth, + self._joinstyle, + self._capstyle)) diff --git a/lib/matplotlib/patches.py b/lib/matplotlib/patches.py index d7dcda251310..f63a0cb53a3f 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) from .path import Path @@ -2724,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 @@ -2736,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 @@ -2751,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 @@ -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 73bc8f25ff2e..53de5acd8a16 100644 --- a/lib/matplotlib/path.py +++ b/lib/matplotlib/path.py @@ -11,12 +11,14 @@ 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 class Path: @@ -337,11 +339,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) @@ -417,6 +416,116 @@ 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 `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" + 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:] + + 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): + incidence_a, corner_a = _incidence_corner_from_angles( + prev_tan_angle, first_tan_angle) + 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 + # 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]) + incidence_a, corner_a = _incidence_corner_from_angles( + angle_end, first_tan_angle) + 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: + incidence_a, corner_a = _incidence_corner_from_angles( + angle_in, prev_tan_angle) + 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] + 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, @@ -450,6 +559,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. @@ -542,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. @@ -583,6 +766,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 @@ -649,7 +893,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) @@ -991,3 +1236,240 @@ 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') +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). + +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 [-pi, 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 + # stay in [-pi, pi] + if incidence_angle < -np.pi: + incidence_angle = 2*np.pi + incidence_angle + 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 + # 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 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 000000000000..152f6656ebac Binary files /dev/null and b/lib/matplotlib/tests/baseline_images/test_marker/marker_bbox_pentagon.png differ 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 000000000000..bf30f4621a79 Binary files /dev/null and b/lib/matplotlib/tests/baseline_images/test_marker/marker_bbox_star.png differ diff --git a/lib/matplotlib/tests/test_marker.py b/lib/matplotlib/tests/test_marker.py index 1ef9c18c47fb..33fb8f5a9c8f 100644 --- a/lib/matplotlib/tests/test_marker.py +++ b/lib/matplotlib/tests/test_marker.py @@ -1,7 +1,10 @@ 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 +from collections import namedtuple import pytest @@ -26,3 +29,209 @@ 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_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) + 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) + +# 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(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 + 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 = 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[marker._marker] + left -= _get_padding_due_to_angle(markeredgewidth, angles.left, + marker._joinstyle, marker._capstyle) + bottom -= _get_padding_due_to_angle(markeredgewidth, angles.bottom, + marker._joinstyle, marker._capstyle) + right += _get_padding_due_to_angle(markeredgewidth, angles.right, + marker._joinstyle, marker._capstyle) + top += _get_padding_due_to_angle(markeredgewidth, angles.top, + marker._joinstyle, marker._capstyle) + return Bbox.from_extents(left, bottom, right, top)