7

What's the right tool for the job if I want to write a Python script that produces vector graphics in PDF format? In particular, I need to draw filled polygons with rounded corners (i.e., plane figures that are composed of straight lines and circular arcs).

It seems that matplotlib makes it fairly easy to draw rectangles with rounded corners and general polygons with sharp corners. However, to draw polygons with rounded corners, it seems that I have to first compute a Bézier curve that approximates the shape.

Is there anything more straightforward available? Or is there another library that I can use to compute the Bézier curve that approximates the shape that I want to produce? Ideally, I would simply specify the (location, corner radius) pair for each vertex.

Here is an example: I would like to specify the red polygon (+ the radius of each corner) and the library would output the grey figure:

example

(For convex polygons I could cheat and use a thick pen to draw the outline of the polygon. However, this does not work in the non-convex case.)

Lightness Races in Orbit
  • 378,754
  • 76
  • 643
  • 1,055
Jukka Suomela
  • 12,070
  • 6
  • 40
  • 46

2 Answers2

14

Here's a somewhat hacky matplotlib solution. The main complications are related to using matplotlib Path objects to build a composite Path.

#!/usr/bin/env python

import numpy as np
from matplotlib.path import Path
from matplotlib.patches import PathPatch, Polygon
from matplotlib.transforms import Bbox, BboxTransformTo

def side(a, b, c):
    "On which side of line a-b is point c? Returns -1, 0, or 1."
    return np.sign(np.linalg.det(np.c_[[a,b,c],[1,1,1]]))

def center((prev, curr, next), radius):
    "Find center of arc approximating corner at curr."
    p0, p1 = prev
    c0, c1 = curr
    n0, n1 = next
    dp = radius * np.hypot(c1 - p1, c0 - p0)
    dn = radius * np.hypot(c1 - n1, c0 - n0)
    p = p1 * c0 - p0 * c1
    n = n1 * c0 - n0 * c1
    results = \
        np.linalg.solve([[p1 - c1, c0 - p0],
                         [n1 - c1, c0 - n0]],
                        [[p - dp, p - dp, p + dp, p + dp],
                         [n - dn, n + dn, n - dn, n + dn]])
    side_n = side(prev, curr, next)
    side_p = side(next, curr, prev)
    for r in results.T:
        if (side(prev, curr, r), side(next, curr, r)) == (side_n, side_p):
            return r
    raise ValueError, "Cannot find solution"

def proj((prev, curr, next), center):
    "Project center onto lines prev-curr and next-curr."
    p0, p1 = prev = np.asarray(prev)
    c0, c1 = curr = np.asarray(curr)
    n0, n1 = next = np.asarray(next)
    pc = curr - prev
    nc = curr - next
    pc2 = np.dot(pc, pc)
    nc2 = np.dot(nc, nc)
    return (prev + np.dot(center - prev, pc)/pc2 * pc,
            next + np.dot(center - next, nc)/nc2 * nc)

def rad2deg(angle):
    return angle * 180.0 / np.pi

def angle(center, point):
    x, y = np.asarray(point) - np.asarray(center)
    return np.arctan2(y, x)

def arc_path(center, start, end):
    "Return a Path for an arc from start to end around center."
    # matplotlib arcs always go ccw so we may need to mirror
    mirror = side(center, start, end) < 0
    if mirror: 
        start *= [1, -1]
        center *= [1, -1]
        end *= [1, -1]
    return Path.arc(rad2deg(angle(center, start)),
                    rad2deg(angle(center, end))), \
           mirror

def path(vertices, radii):
    "Return a Path for a closed rounded polygon."
    if np.isscalar(radii):
        radii = np.repeat(radii, len(vertices))
    else:
        radii = np.asarray(radii)
    pv = []
    pc = []
    first = True
    for i in range(len(vertices)):
        if i == 0:
            seg = (vertices[-1], vertices[0], vertices[1])
        elif i == len(vertices) - 1:
            seg = (vertices[-2], vertices[-1], vertices[0])
        else:
            seg = vertices[i-1:i+2]
        r = radii[i]
        c = center(seg, r)
        a, b = proj(seg, c)
        arc, mirror = arc_path(c, a, b)
        m = [1,1] if not mirror else [1,-1]
        bb = Bbox([c, c + (r, r)])
        iter = arc.iter_segments(BboxTransformTo(bb))
        for v, c in iter:
            if c == Path.CURVE4:
                pv.extend([m * v[0:2], m * v[2:4], m * v[4:6]])
                pc.extend([c, c, c])
            elif c == Path.MOVETO:
                pv.append(m * v)
                if first:
                    pc.append(Path.MOVETO)
                    first = False
                else:
                    pc.append(Path.LINETO)
    pv.append([0,0])
    pc.append(Path.CLOSEPOLY)

    return Path(pv, pc)

if __name__ == '__main__':
    from matplotlib import pyplot
    fig = pyplot.figure()
    ax = fig.add_subplot(111)
    vertices = [[3,0], [5,2], [10,0], [6,9], [6,5], [3, 5], [0,2]]

    patch = Polygon(vertices, edgecolor='red', facecolor='None',
                    linewidth=1)
    ax.add_patch(patch)

    patch = PathPatch(path(vertices, 0.5), 
                      edgecolor='black', facecolor='blue', alpha=0.4,
                      linewidth=2)
    ax.add_patch(patch)

    ax.set_xlim(-1, 11)
    ax.set_ylim(-1, 9)
    fig.savefig('foo.pdf')

output of script above

Jouni K. Seppänen
  • 43,139
  • 5
  • 71
  • 100
  • Thanks, this is great! I accepted the other answer, as it seems that Cairo is the "right" tool for this kind of task, but it is really nice to see that one can do it in matplotlib, too. In particular, the code fragment that constructs a composite path from path fragments is very useful. – Jukka Suomela Oct 10 '11 at 16:57
5

As for producing PDF files, I would suggest to have a look at the cairo library, a vector graphics libaray which support "drawing" into PDF surfaces. It has Python bindings too.

As for drawing polygons with rounded corners, I'm not aware of any graphics library which supports this out of the box.

But it shouldn't be too complicated to compute the arc coordinates at polygon corners given the corner radius. Basically you have to find the point on the angle bisector of two adjacent edges which has the distance r (i.e. the desired radius) from both edges. This is the center of the arc, for finding the starting and ending point, you'll project from this point to the two edges.

There might be non-trivial cases, e.g. what to do, if polygon edges are too short to fit two arcs (I guess you'll have to select a smaller radius in this case), and maybe others, I'm currently not aware of ...

HTH

MartinStettner
  • 28,719
  • 15
  • 79
  • 106
  • Thanks for the answer! Sure, computing the arc coordinates and line segment coordinates is not that difficult; that is, I could fairly easily draw the *outline* of the shape using separate arcs and line segments. However, how do I get a *filled* plane figure? Can I use the Cairo library to compose a closed path from arcs and line segments and then tell the library to fill the interior of the path? – Jukka Suomela Oct 09 '11 at 11:04