0

General aim

I am trying to write some plotting functionality that (at its core) plots arbitrary paths with a constant width given in data coordinates (i.e. unlike lines in matplotlib which have widths given in display coordinates).

Previous solutions

This answer achieves the basic goal. However, this answer converts between display and data coordinates and then uses a matplotlib line with adjusted coordinates. The existing functionality in my code that I would like to replace / extend inherits from matplotlib.patches.Polygon. Since the rest of the code base makes extensive use of matplotlib.patches.Polygon attributes and methods, I would like to continue to inherit from that class.

Problem

My current implementation (code below) seems to come close. However, the patch created by simple_test seems to be subtly thicker towards the centre than it is at the start and end point, and I have no explanation why that may be the case.

I suspect that the problem lies in the computation of the orthogonal vector. As supporting evidence, I would like to point to the start and end points of the patch in the figure created by complicated_test, which do not seem exactly orthogonal to the path. However, the dot product of the orthonormal vector and the tangent vector is always zero, so I am not sure that what is going on here.

Output of simple_test: enter image description here

Output of complicated_test: enter image description here

Code

#!/usr/bin/env python
import numpy as np
import matplotlib.patches
import matplotlib.pyplot as plt

class CurvedPatch(matplotlib.patches.Polygon):

    def __init__(self, path, width, *args, **kwargs):
        vertices = self.get_vertices(path, width)
        matplotlib.patches.Polygon.__init__(self, list(map(tuple, vertices)),
                                            closed=True,
                                            *args, **kwargs)

    def get_vertices(self, path, width):
        left = _get_parallel_path(path, -width/2)
        right = _get_parallel_path(path, width/2)
        full = np.concatenate([left, right[::-1]])
        return full


def _get_parallel_path(path, delta):
    # initialise output
    offset = np.zeros_like(path)

    # use the previous and the following point to
    # determine the tangent at each point in the path;
    for ii in range(1, len(path)-1):
        offset[ii] += _get_shift(path[ii-1], path[ii+1], delta)

    # handle start and end points
    offset[0] = _get_shift(path[0], path[1], delta)
    offset[-1] = _get_shift(path[-2], path[-1], delta)

    return path + offset


def _get_shift(p1, p2, delta):
    # unpack coordinates
    x1, y1 = p1
    x2, y2 = p2

    # get orthogonal unit vector;
    # adapted from https://stackoverflow.com/a/16890776/2912349
    v = np.r_[x2-x1, y2-y1]   # vector between points
    v = v / np.linalg.norm(v) # unit vector

    w = np.r_[-v[1], v[0]]    # orthogonal vector
    w = w / np.linalg.norm(w) # orthogonal unit vector

    # check that vectors are indeed orthogonal
    assert np.isclose(np.dot(v, w), 0.)

    # rescale unit vector
    dx, dy = delta * w

    return dx, dy


def simple_test():

    x = np.linspace(-1, 1, 1000)
    y = np.sqrt(1. - x**2)
    path = np.c_[x, y]

    curve = CurvedPatch(path, 0.1, facecolor='red', alpha=0.5)

    fig, ax = plt.subplots(1,1)
    ax.add_artist(curve)
    ax.plot(x, y) # plot path for reference
    plt.show()


def complicated_test():

    random_points = np.random.rand(10, 2)

    # Adapted from https://stackoverflow.com/a/35007804/2912349
    import scipy.interpolate as si

    def scipy_bspline(cv, n=100, degree=3, periodic=False):
        """ Calculate n samples on a bspline

            cv :      Array ov control vertices
            n  :      Number of samples to return
            degree:   Curve degree
            periodic: True - Curve is closed
        """
        cv = np.asarray(cv)
        count = cv.shape[0]

        # Closed curve
        if periodic:
            kv = np.arange(-degree,count+degree+1)
            factor, fraction = divmod(count+degree+1, count)
            cv = np.roll(np.concatenate((cv,) * factor + (cv[:fraction],)),-1,axis=0)
            degree = np.clip(degree,1,degree)

        # Opened curve
        else:
            degree = np.clip(degree,1,count-1)
            kv = np.clip(np.arange(count+degree+1)-degree,0,count-degree)

        # Return samples
        max_param = count - (degree * (1-periodic))
        spl = si.BSpline(kv, cv, degree)
        return spl(np.linspace(0,max_param,n))

    x, y = scipy_bspline(random_points, n=1000).T
    path = np.c_[x, y]

    curve = CurvedPatch(path, 0.1, facecolor='red', alpha=0.5)

    fig, ax = plt.subplots(1,1)
    ax.add_artist(curve)
    ax.plot(x, y) # plot path for reference
    plt.show()


if __name__ == '__main__':
    plt.ion()
    simple_test()
    complicated_test()
Paul Brodersen
  • 11,221
  • 21
  • 38
  • 1
    Part of the problem is surely the non-equal aspect. Can you add `ax.set_aspect("equal")` to the plotting routine and reformulate the question? Or state what the expected output for non-equal aspect would be? – ImportanceOfBeingErnest Apr 04 '19 at 16:43
  • Evidently, I am having one of my denser days again... Thanks, that was indeed the problem. – Paul Brodersen Apr 04 '19 at 17:19
  • 1
    I took this question as incentive to update [the original answer](https://stackoverflow.com/a/42972469/4124317) with a solution subclassing `Line2D`. Now, here you say you want to use a `Polygon`; what exactly would be the motivation for this? I mean a line is really a line and no polygon, so is there any other reason, maybe calculating areas of overlap or so where a polygon is beneficial? – ImportanceOfBeingErnest Apr 04 '19 at 18:36
  • @ImportanceOfBeingErnest At the end of the day, I want an arrow, not just a line. I guess I could use lines and just add a patch for the arrow head but having a single artist seems cleaner. Also, deriving from `Polygon` seemed natural, as `FancyArrow` also derives from that class, which is what I am currently using to draw my arrows. – Paul Brodersen Apr 05 '19 at 09:28
  • Fair enough. I would think there might be better options with `FancyArrowPatch` and a linewidth in data coordinates; but of course I might misunderstand the final use case. If you want further help with any of this, you can ping me again. – ImportanceOfBeingErnest Apr 05 '19 at 16:03

0 Answers0