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