diff --git a/lib/matplotlib/patches.py b/lib/matplotlib/patches.py index 42affae91949..abf4b97a6c7d 100644 --- a/lib/matplotlib/patches.py +++ b/lib/matplotlib/patches.py @@ -1592,14 +1592,12 @@ def draw(self, renderer): calculation much easier than doing rotated ellipse intersection directly). - This uses the "line intersecting a circle" algorithm - from: + This uses the "line intersecting a circle" algorithm from: Vince, John. *Geometry for Computer Graphics: Formulae, Examples & Proofs.* London: Springer-Verlag, 2005. - 2. The angles of each of the intersection points are - calculated. + 2. The angles of each of the intersection points are calculated. 3. Proceeding counterclockwise starting in the positive x-direction, each of the visible arc-segments between the @@ -1609,6 +1607,8 @@ def draw(self, renderer): """ if not hasattr(self, 'axes'): raise RuntimeError('Arcs can only be used in Axes instances') + if not self.get_visible(): + return self._recompute_transform() @@ -1621,44 +1621,62 @@ def theta_stretch(theta, scale): theta = np.deg2rad(theta) x = np.cos(theta) y = np.sin(theta) - return np.rad2deg(np.arctan2(scale * y, x)) - theta1 = theta_stretch(self.theta1, width / height) - theta2 = theta_stretch(self.theta2, width / height) - - # Get width and height in pixels - width, height = self.get_transform().transform((width, height)) + stheta = np.rad2deg(np.arctan2(scale * y, x)) + # arctan2 has the range [-pi, pi], we expect [0, 2*pi] + return (stheta + 360) % 360 + + theta1 = self.theta1 + theta2 = self.theta2 + + if ( + # if we need to stretch the angles because we are distorted + width != height + # and we are not doing a full circle. + # + # 0 and 360 do not exactly round-trip through the angle + # stretching (due to both float precision limitations and + # the difference between the range of arctan2 [-pi, pi] and + # this method [0, 360]) so avoid doing it if we don't have to. + and not (theta1 != theta2 and theta1 % 360 == theta2 % 360) + ): + theta1 = theta_stretch(self.theta1, width / height) + theta2 = theta_stretch(self.theta2, width / height) + + # Get width and height in pixels we need to use + # `self.get_data_transform` rather than `self.get_transform` + # because we want the transform from dataspace to the + # screen space to estimate how big the arc will be in physical + # units when rendered (the transform that we get via + # `self.get_transform()` goes from an idealized unit-radius + # space to screen space). + data_to_screen_trans = self.get_data_transform() + pwidth, pheight = (data_to_screen_trans.transform((width, height)) - + data_to_screen_trans.transform((0, 0))) inv_error = (1.0 / 1.89818e-6) * 0.5 - if width < inv_error and height < inv_error: + + if pwidth < inv_error and pheight < inv_error: self._path = Path.arc(theta1, theta2) return Patch.draw(self, renderer) - def iter_circle_intersect_on_line(x0, y0, x1, y1): + def line_circle_intersect(x0, y0, x1, y1): dx = x1 - x0 dy = y1 - y0 dr2 = dx * dx + dy * dy D = x0 * y1 - x1 * y0 D2 = D * D discrim = dr2 - D2 - - # Single (tangential) intersection - if discrim == 0.0: - x = (D * dy) / dr2 - y = (-D * dx) / dr2 - yield x, y - elif discrim > 0.0: - # The definition of "sign" here is different from - # np.sign: we never want to get 0.0 - if dy < 0.0: - sign_dy = -1.0 - else: - sign_dy = 1.0 + if discrim >= 0.0: + sign_dy = np.copysign(1, dy) # +/-1, never 0. sqrt_discrim = np.sqrt(discrim) - for sign in (1., -1.): - x = (D * dy + sign * sign_dy * dx * sqrt_discrim) / dr2 - y = (-D * dx + sign * np.abs(dy) * sqrt_discrim) / dr2 - yield x, y + return np.array( + [[(D * dy + sign_dy * dx * sqrt_discrim) / dr2, + (-D * dx + abs(dy) * sqrt_discrim) / dr2], + [(D * dy - sign_dy * dx * sqrt_discrim) / dr2, + (-D * dx - abs(dy) * sqrt_discrim) / dr2]]) + else: + return np.empty((0, 2)) - def iter_circle_intersect_on_line_seg(x0, y0, x1, y1): + def segment_circle_intersect(x0, y0, x1, y1): epsilon = 1e-9 if x1 < x0: x0e, x1e = x1, x0 @@ -1668,40 +1686,34 @@ def iter_circle_intersect_on_line_seg(x0, y0, x1, y1): y0e, y1e = y1, y0 else: y0e, y1e = y0, y1 - x0e -= epsilon - y0e -= epsilon - x1e += epsilon - y1e += epsilon - for x, y in iter_circle_intersect_on_line(x0, y0, x1, y1): - if x0e <= x <= x1e and y0e <= y <= y1e: - yield x, y + xys = line_circle_intersect(x0, y0, x1, y1) + xs, ys = xys.T + return xys[ + (x0e - epsilon < xs) & (xs < x1e + epsilon) + & (y0e - epsilon < ys) & (ys < y1e + epsilon) + ] # Transforms the axes box_path so that it is relative to the unit # circle in the same way that it is relative to the desired ellipse. - box_path = Path.unit_rectangle() box_path_transform = (transforms.BboxTransformTo(self.axes.bbox) - - self.get_transform()) - box_path = box_path.transformed(box_path_transform) + + self.get_transform().inverted()) + box_path = Path.unit_rectangle().transformed(box_path_transform) thetas = set() # For each of the point pairs, there is a line segment for p0, p1 in zip(box_path.vertices[:-1], box_path.vertices[1:]): - x0, y0 = p0 - x1, y1 = p1 - for x, y in iter_circle_intersect_on_line_seg(x0, y0, x1, y1): - theta = np.arccos(x) - if y < 0: - theta = 2 * np.pi - theta - # Convert radians to angles - theta = np.rad2deg(theta) - if theta1 < theta < theta2: - thetas.add(theta) + xy = segment_circle_intersect(*p0, *p1) + x, y = xy.T + # arctan2 return [-pi, pi), the rest of our angles are in + # [0, 360], adjust as needed. + theta = (np.rad2deg(np.arctan2(y, x)) + 360) % 360 + thetas.update(theta[(theta1 < theta) & (theta < theta2)]) thetas = sorted(thetas) + [theta2] - last_theta = theta1 theta1_rad = np.deg2rad(theta1) - inside = box_path.contains_point((np.cos(theta1_rad), - np.sin(theta1_rad))) + inside = box_path.contains_point( + (np.cos(theta1_rad), np.sin(theta1_rad)) + ) # save original path path_original = self._path diff --git a/lib/matplotlib/tests/baseline_images/test_axes/arc_angles.png b/lib/matplotlib/tests/baseline_images/test_axes/arc_angles.png index 8b81cde703ef..caa050aed900 100644 Binary files a/lib/matplotlib/tests/baseline_images/test_axes/arc_angles.png and b/lib/matplotlib/tests/baseline_images/test_axes/arc_angles.png differ diff --git a/lib/matplotlib/tests/baseline_images/test_patches/all_quadrants_arcs.svg b/lib/matplotlib/tests/baseline_images/test_patches/all_quadrants_arcs.svg new file mode 100644 index 000000000000..a45712972165 --- /dev/null +++ b/lib/matplotlib/tests/baseline_images/test_patches/all_quadrants_arcs.svg @@ -0,0 +1,481 @@ + + + + + + + + + 2020-06-15T18:44:55.456487 + image/svg+xml + + + Matplotlib v3.2.1.post2847.dev0+g36a8896133, https://matplotlib.org/ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/lib/matplotlib/tests/baseline_images/test_patches/large_arc.svg b/lib/matplotlib/tests/baseline_images/test_patches/large_arc.svg new file mode 100644 index 000000000000..d902870aa7b7 --- /dev/null +++ b/lib/matplotlib/tests/baseline_images/test_patches/large_arc.svg @@ -0,0 +1,79 @@ + + + + + + + + + 2020-06-15T19:03:05.186353 + image/svg+xml + + + Matplotlib v3.2.1.post2849.dev0+ge5dca476ed, https://matplotlib.org/ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/lib/matplotlib/tests/test_patches.py b/lib/matplotlib/tests/test_patches.py index 32ce5db1cebe..c0eda5542927 100644 --- a/lib/matplotlib/tests/test_patches.py +++ b/lib/matplotlib/tests/test_patches.py @@ -490,3 +490,60 @@ def test_fancyarrow_units(): fig, ax = plt.subplots() arrow = FancyArrowPatch((0, dtime), (0.01, dtime)) ax.add_patch(arrow) + + +@image_comparison(["large_arc.svg"], style="mpl20") +def test_large_arc(): + fig, (ax1, ax2) = plt.subplots(1, 2) + x = 210 + y = -2115 + diameter = 4261 + for ax in [ax1, ax2]: + a = mpatches.Arc((x, y), diameter, diameter, lw=2, color='k') + ax.add_patch(a) + ax.set_axis_off() + ax.set_aspect('equal') + # force the high accuracy case + ax1.set_xlim(7, 8) + ax1.set_ylim(5, 6) + + # force the low accuracy case + ax2.set_xlim(-25000, 18000) + ax2.set_ylim(-20000, 6600) + + +@image_comparison(["all_quadrants_arcs.svg"], style="mpl20") +def test_rotated_arcs(): + fig, ax_arr = plt.subplots(2, 2, squeeze=False, figsize=(10, 10)) + + scale = 10_000_000 + diag_centers = ((-1, -1), (-1, 1), (1, 1), (1, -1)) + on_axis_centers = ((0, 1), (1, 0), (0, -1), (-1, 0)) + skews = ((2, 2), (2, 1/10), (2, 1/100), (2, 1/1000)) + + for ax, (sx, sy) in zip(ax_arr.ravel(), skews): + k = 0 + for prescale, centers in zip((1 - .0001, (1 - .0001) / np.sqrt(2)), + (on_axis_centers, diag_centers)): + for j, (x_sign, y_sign) in enumerate(centers, start=k): + a = mpatches.Arc( + (x_sign * scale * prescale, + y_sign * scale * prescale), + scale * sx, + scale * sy, + lw=4, + color=f"C{j}", + zorder=1 + j, + angle=np.rad2deg(np.arctan2(y_sign, x_sign)) % 360, + label=f'big {j}', + gid=f'big {j}' + ) + ax.add_patch(a) + + k = j+1 + ax.set_xlim(-scale / 4000, scale / 4000) + ax.set_ylim(-scale / 4000, scale / 4000) + ax.axhline(0, color="k") + ax.axvline(0, color="k") + ax.set_axis_off() + ax.set_aspect("equal")