diff --git a/examples/images_contours_and_fields/contours_in_optimization_demo.py b/examples/images_contours_and_fields/contours_in_optimization_demo.py new file mode 100644 index 000000000000..ab7fa019602f --- /dev/null +++ b/examples/images_contours_and_fields/contours_in_optimization_demo.py @@ -0,0 +1,64 @@ +""" +============================================== +Contouring the solution space of optimizations +============================================== + +Contour plotting is particularly handy when illustrating the solution +space of optimization problems. Not only can `.axes.Axes.contour` be +used to represent the topography of the objective function, it can be +used to generate boundary curves of the constraint functions. The +constraint lines can be drawn with +`~matplotlib.patheffects.TickedStroke` to distinguish the valid and +invalid sides of the constraint boundaries. + +`.axes.Axes.contour` generates curves with larger values to the left +of the contour. The angle parameter is measured zero ahead with +increasing values to the left. Consequently, when using +`~matplotlib.patheffects.TickedStroke` to illustrate a constraint in +a typical optimization problem, the angle should be set between +zero and 180 degrees. + +""" + +import numpy as np +import matplotlib.pyplot as plt +import matplotlib.patheffects as patheffects + +fig, ax = plt.subplots(figsize=(6, 6)) + +nx = 101 +ny = 105 + +# Set up survey vectors +xvec = np.linspace(0.001, 4.0, nx) +yvec = np.linspace(0.001, 4.0, ny) + +# Set up survey matrices. Design disk loading and gear ratio. +x1, x2 = np.meshgrid(xvec, yvec) + +# Evaluate some stuff to plot +obj = x1**2 + x2**2 - 2*x1 - 2*x2 + 2 +g1 = -(3*x1 + x2 - 5.5) +g2 = -(x1 + 2*x2 - 4) +g3 = 0.8 + x1**-3 - x2 + +cntr = ax.contour(x1, x2, obj, [0.01, 0.1, 0.5, 1, 2, 4, 8, 16], + colors=('k',)) +ax.clabel(cntr, fmt="%2.1f", use_clabeltext=True) + +cg1 = ax.contour(x1, x2, g1, [0], colors=('k',)) +plt.setp(cg1.collections, + path_effects=[patheffects.withTickedStroke(angle=135)]) + +cg2 = ax.contour(x1, x2, g2, [0], colors=('r',)) +plt.setp(cg2.collections, + path_effects=[patheffects.withTickedStroke(angle=60, length=2)]) + +cg3 = ax.contour(x1, x2, g3, [0], colors=('b',)) +plt.setp(cg3.collections, + path_effects=[patheffects.withTickedStroke(spacing=7)]) + +ax.set_xlim(0, 4) +ax.set_ylim(0, 4) + +plt.show() diff --git a/examples/lines_bars_and_markers/lines_with_ticks_demo.py b/examples/lines_bars_and_markers/lines_with_ticks_demo.py new file mode 100644 index 000000000000..c7b902f09ddf --- /dev/null +++ b/examples/lines_bars_and_markers/lines_with_ticks_demo.py @@ -0,0 +1,29 @@ +""" +============================== +Lines with a ticked patheffect +============================== + +Ticks can be added along a line to mark one side as a barrier using +`~matplotlib.patheffects.TickedStroke`. You can control the angle, +spacing, and length of the ticks. + +The ticks will also appear appropriately in the legend. + +""" + +import numpy as np +import matplotlib.pyplot as plt +from matplotlib import patheffects + +fig, ax = plt.subplots(figsize=(6, 6)) +ax.plot([0, 1], [0, 1], label="Line", + path_effects=[patheffects.withTickedStroke(spacing=7, angle=135)]) + +nx = 101 +x = np.linspace(0.0, 1.0, nx) +y = 0.3*np.sin(x*8) + 0.4 +ax.plot(x, y, label="Curve", path_effects=[patheffects.withTickedStroke()]) + +ax.legend() + +plt.show() diff --git a/examples/misc/tickedstroke_demo.py b/examples/misc/tickedstroke_demo.py new file mode 100644 index 000000000000..2079e40285a4 --- /dev/null +++ b/examples/misc/tickedstroke_demo.py @@ -0,0 +1,99 @@ +""" +======================= +TickedStroke patheffect +======================= + +Matplotlib's :mod:`.patheffects` can be used to alter the way paths +are drawn at a low enough level that they can affect almost anything. + +The :doc:`patheffects guide` +details the use of patheffects. + +The `~matplotlib.patheffects.TickedStroke` patheffect illustrated here +draws a path with a ticked style. The spacing, length, and angle of +ticks can be controlled. + +See also the :doc:`contour demo example +`. + +See also the :doc:`contours in optimization example +`. +""" + +import matplotlib.patches as patches +from matplotlib.path import Path +import numpy as np +import matplotlib.pyplot as plt +import matplotlib.patheffects as patheffects + +# Direct to path +fig, ax = plt.subplots(figsize=(6, 6)) +path = Path.unit_circle() +patch = patches.PathPatch(path, facecolor='none', lw=2, path_effects=[ + patheffects.withTickedStroke(angle=-90, spacing=10, length=1)]) + +ax.add_patch(patch) +ax.axis('equal') +ax.set_xlim(-2, 2) +ax.set_ylim(-2, 2) + +plt.show() + +############################################################################### +# Lines and curves with plot and legend +fig, ax = plt.subplots(figsize=(6, 6)) +ax.plot([0, 1], [0, 1], label="Line", + path_effects=[patheffects.withTickedStroke(spacing=7, angle=135)]) + +nx = 101 +x = np.linspace(0.0, 1.0, nx) +y = 0.3*np.sin(x*8) + 0.4 +ax.plot(x, y, label="Curve", path_effects=[patheffects.withTickedStroke()]) + +ax.legend() + +plt.show() + +############################################################################### +# Contour plot with objective and constraints. +# Curves generated by contour to represent a typical constraint in an +# optimization problem should be plotted with angles between zero and +# 180 degrees. +fig, ax = plt.subplots(figsize=(6, 6)) + +nx = 101 +ny = 105 + +# Set up survey vectors +xvec = np.linspace(0.001, 4.0, nx) +yvec = np.linspace(0.001, 4.0, ny) + +# Set up survey matrices. Design disk loading and gear ratio. +x1, x2 = np.meshgrid(xvec, yvec) + +# Evaluate some stuff to plot +obj = x1**2 + x2**2 - 2*x1 - 2*x2 + 2 +g1 = -(3*x1 + x2 - 5.5) +g2 = -(x1 + 2*x2 - 4) +g3 = 0.8 + x1**-3 - x2 + +cntr = ax.contour(x1, x2, obj, [0.01, 0.1, 0.5, 1, 2, 4, 8, 16], + colors=('k',)) +ax.clabel(cntr, fmt="%2.1f", use_clabeltext=True) + +cg1 = ax.contour(x1, x2, g1, [0], colors='black') +plt.setp(cg1.collections, + path_effects=[patheffects.withTickedStroke(angle=135)]) + +cg2 = ax.contour(x1, x2, g2, [0], colors='red') +plt.setp(cg2.collections, + path_effects=[patheffects.withTickedStroke(angle=60, length=2)]) + +cg3 = ax.contour(x1, x2, g3, [0], colors='blue') +plt.setp(cg3.collections, + path_effects=[patheffects.withTickedStroke(spacing=7)]) + +ax.set_xlim(0, 4) +ax.set_ylim(0, 4) + +plt.show() diff --git a/lib/matplotlib/patheffects.py b/lib/matplotlib/patheffects.py index 5b573c6fe8fc..76544f561bed 100644 --- a/lib/matplotlib/patheffects.py +++ b/lib/matplotlib/patheffects.py @@ -10,6 +10,8 @@ from matplotlib import colors as mcolors from matplotlib import patches as mpatches from matplotlib import transforms as mtransforms +from matplotlib.path import Path +import numpy as np class AbstractPathEffect: @@ -371,3 +373,148 @@ def draw_path(self, renderer, gc, tpath, affine, rgbFace): if clip_path: self.patch.set_clip_path(*clip_path) self.patch.draw(renderer) + + +class TickedStroke(AbstractPathEffect): + """ + A line-based PathEffect which draws a path with a ticked style. + + This line style is frequently used to represent constraints in + optimization. The ticks may be used to indicate that one side + of the line is invalid or to represent a closed boundary of a + domain (i.e. a wall or the edge of a pipe). + + The spacing, length, and angle of ticks can be controlled. + + This line style is sometimes referred to as a hatched line. + + See also the :doc:`contour demo example + `. + + See also the :doc:`contours in optimization example + `. + """ + + def __init__(self, offset=(0, 0), + spacing=10.0, angle=45.0, length=np.sqrt(2), + **kwargs): + """ + Parameters + ---------- + offset : pair of floats, default: (0, 0) + The offset to apply to the path, in points. + spacing : float, default: 10.0 + The spacing between ticks in points. + angle : float, default: 45.0 + The angle between the path and the tick in degrees. The angle + is measured as if you were an ant walking along the curve, with + zero degrees pointing directly ahead, 90 to your left, -90 + to your right, and 180 behind you. + length : float, default: 1.414 + The length of the tick relative to spacing. + Recommended length = 1.414 (sqrt(2)) when angle=45, length=1.0 + when angle=90 and length=2.0 when angle=60. + **kwargs + Extra keywords are stored and passed through to + :meth:`AbstractPathEffect._update_gc`. + + Examples + -------- + See :doc:`/gallery/misc/tickedstroke_demo`. + """ + super().__init__(offset) + + self._spacing = spacing + self._angle = angle + self._length = length + self._gc = kwargs + + def draw_path(self, renderer, gc, tpath, affine, rgbFace): + """ + Draw the path with updated gc. + """ + # Do not modify the input! Use copy instead. + gc0 = renderer.new_gc() + gc0.copy_properties(gc) + + gc0 = self._update_gc(gc0, self._gc) + trans = affine + self._offset_transform(renderer) + + theta = -np.radians(self._angle) + trans_matrix = np.array([[np.cos(theta), -np.sin(theta)], + [np.sin(theta), np.cos(theta)]]) + + # Convert spacing parameter to pixels. + spcpx = renderer.points_to_pixels(self._spacing) + + # Transform before evaluation because to_polygons works at resolution + # of one -- assuming it is working in pixel space. + transpath = affine.transform_path(tpath) + + # Evaluate path to straight line segments that can be used to + # construct line ticks. + polys = transpath.to_polygons(closed_only=False) + + for p in polys: + x = p[:, 0] + y = p[:, 1] + + # Can not interpolate points or draw line if only one point in + # polyline. + if x.size < 2: + continue + + # Find distance between points on the line + ds = np.hypot(x[1:] - x[:-1], y[1:] - y[:-1]) + + # Build parametric coordinate along curve + s = np.concatenate(([0.0], np.cumsum(ds))) + stot = s[-1] + + num = int(np.ceil(stot / spcpx))-1 + # Pick parameter values for ticks. + s_tick = np.linspace(spcpx/2, stot-spcpx/2, num) + + # Find points along the parameterized curve + x_tick = np.interp(s_tick, s, x) + y_tick = np.interp(s_tick, s, y) + + # Find unit vectors in local direction of curve + delta_s = self._spacing * .001 + u = (np.interp(s_tick + delta_s, s, x) - x_tick) / delta_s + v = (np.interp(s_tick + delta_s, s, y) - y_tick) / delta_s + + # Normalize slope into unit slope vector. + n = np.hypot(u, v) + mask = n == 0 + n[mask] = 1.0 + + uv = np.array([u / n, v / n]).T + uv[mask] = np.array([0, 0]).T + + # Rotate and scale unit vector into tick vector + dxy = np.dot(uv, trans_matrix) * self._length * spcpx + + # Build tick endpoints + x_end = x_tick + dxy[:, 0] + y_end = y_tick + dxy[:, 1] + + # Interleave ticks to form Path vertices + xyt = np.empty((2 * num, 2), dtype=x_tick.dtype) + xyt[0::2, 0] = x_tick + xyt[1::2, 0] = x_end + xyt[0::2, 1] = y_tick + xyt[1::2, 1] = y_end + + # Build up vector of Path codes + codes = np.tile([Path.MOVETO, Path.LINETO], num) + + # Construct and draw resulting path + h = Path(xyt, codes) + # Transform back to data space during render + renderer.draw_path(gc0, h, affine.inverted() + trans, rgbFace) + + gc0.restore() + + +withTickedStroke = _subclass_with_normal(effect_class=TickedStroke) diff --git a/lib/matplotlib/tests/baseline_images/test_patheffects/tickedstroke.png b/lib/matplotlib/tests/baseline_images/test_patheffects/tickedstroke.png new file mode 100644 index 000000000000..60af86eaadcb Binary files /dev/null and b/lib/matplotlib/tests/baseline_images/test_patheffects/tickedstroke.png differ diff --git a/lib/matplotlib/tests/test_patheffects.py b/lib/matplotlib/tests/test_patheffects.py index 77b6ae3145be..2592796b33af 100644 --- a/lib/matplotlib/tests/test_patheffects.py +++ b/lib/matplotlib/tests/test_patheffects.py @@ -3,6 +3,8 @@ from matplotlib.testing.decorators import image_comparison import matplotlib.pyplot as plt import matplotlib.patheffects as path_effects +from matplotlib.path import Path +import matplotlib.patches as patches @image_comparison(['patheffect1'], remove_text=True) @@ -132,3 +134,57 @@ def test_collection(): linewidth=3)]) text.set_bbox({'boxstyle': 'sawtooth', 'facecolor': 'none', 'edgecolor': 'blue'}) + + +@image_comparison(['tickedstroke'], remove_text=True, extensions=['png']) +def test_tickedstroke(): + fig, (ax1, ax2, ax3) = plt.subplots(1, 3, figsize=(12, 4)) + path = Path.unit_circle() + patch = patches.PathPatch(path, facecolor='none', lw=2, path_effects=[ + path_effects.withTickedStroke(angle=-90, spacing=10, + length=1)]) + + ax1.add_patch(patch) + ax1.axis('equal') + ax1.set_xlim(-2, 2) + ax1.set_ylim(-2, 2) + + ax2.plot([0, 1], [0, 1], label=' ', + path_effects=[path_effects.withTickedStroke(spacing=7, + angle=135)]) + nx = 101 + x = np.linspace(0.0, 1.0, nx) + y = 0.3 * np.sin(x * 8) + 0.4 + ax2.plot(x, y, label=' ', path_effects=[path_effects.withTickedStroke()]) + + ax2.legend() + + nx = 101 + ny = 105 + + # Set up survey vectors + xvec = np.linspace(0.001, 4.0, nx) + yvec = np.linspace(0.001, 4.0, ny) + + # Set up survey matrices. Design disk loading and gear ratio. + x1, x2 = np.meshgrid(xvec, yvec) + + # Evaluate some stuff to plot + g1 = -(3 * x1 + x2 - 5.5) + g2 = -(x1 + 2 * x2 - 4) + g3 = .8 + x1 ** -3 - x2 + + cg1 = ax3.contour(x1, x2, g1, [0], colors=('k',)) + plt.setp(cg1.collections, + path_effects=[path_effects.withTickedStroke(angle=135)]) + + cg2 = ax3.contour(x1, x2, g2, [0], colors=('r',)) + plt.setp(cg2.collections, + path_effects=[path_effects.withTickedStroke(angle=60, length=2)]) + + cg3 = ax3.contour(x1, x2, g3, [0], colors=('b',)) + plt.setp(cg3.collections, + path_effects=[path_effects.withTickedStroke(spacing=7)]) + + ax3.set_xlim(0, 4) + ax3.set_ylim(0, 4)