A sticker's white border and its cut line are the same geometry problem: take a drawing, push its outline out by a fixed distance, and keep one closed outer edge. The Vector Editor in the osFoundry desktop app does it with Offset Path followed by Unite. Below is the same pipeline in about 30 lines of Python with Shapely, run on the cat-head SVG used in the app (nine shapes on a 60 mm artboard), with measurements for the parts that tend to go wrong: round versus miter corners, how finely an arc is approximated, and the offset width at which separate pieces become one.
Turn the SVG into polygons
The artwork has paths, ellipses and a mouth that is only a stroked curve. Shapely works on polygons, so every shape is sampled into a polyline, and the stroked mouth is turned into a filled shape by buffering its centre line by half the stroke width. The artboard is 600 units for 60 mm, so 10 units are 1 mm.
import re import numpy as np from shapely.geometry import Polygon, LineString from shapely.ops import unary_union from svgpathtools import parse_path U = 10 # SVG units per mm def sample(d, n=400): p = parse_path(d) return [(z.real, z.imag) for z in (p.point(t) for t in np.linspace(0, 1, n))] def load(svg): shapes = [] for m in re.finditer(r'<(path|ellipse)([^>]*)/>', svg): a = dict(re.findall(r'(\S+)="([^"]*)"', m[2])) if m[1] == 'ellipse': cx, cy, rx, ry = (float(a[k]) for k in ('cx', 'cy', 'rx', 'ry')) t = np.linspace(0, 2 * np.pi, 120) shapes.append(Polygon(zip(cx + rx * np.cos(t), cy + ry * np.sin(t)))) elif a.get('fill') == 'none': # stroked line: give it its stroke width line = unary_union([LineString(sample(s, 80)) for s in re.findall(r'M[^M]+', a['d'])]) shapes.append(line.buffer(float(a['stroke-width']) / 2)) else: shapes.append(Polygon(sample(a['d']))) return shapes The file gives 9 shapes with a combined bounding box of 40.5 × 34.8 mm. The editor shows 40.5 × 34.9 mm for the same selection, the difference being how the curves are flattened.
Offset every shape, then union
buffer is the offset. A positive distance grows a polygon, and quad_segs is the number of line segments used for each quarter of a round corner.
shapes = load(open('cat.svg').read()) offs = [g.buffer(3 * U, quad_segs=16) for g in shapes] # nine overlapping outlines sticker = unary_union(offs) # one outer edge If you cut along the nine outlines, the cutter follows all of them. Their lengths add up to 410.4 mm of line. The union is a single piece, with no holes, whose outline is 164.8 mm. The other 245.6 mm are edges that lie inside the sticker, where nothing should be cut.
print(sum(o.exterior.length for o in offs) / U) # 410.4 print(sticker.exterior.length / U) # 164.8 The union is 46.5 × 40.8 mm, against 46.5 × 40.9 mm in the editor, and 1195.0 mm² in area, against 681.3 mm² for the artwork.
Offsetting the united artwork gives the same result as uniting the offsets. Buffering unary_union(shapes) once and comparing it with the union above leaves a difference of 0.009 mm², which is flattening noise. So when a drawing has many pieces, offset the merged shape once instead of offsetting each shape.
Is the offset really 3 mm everywhere? Sampling 5,000 points along the outline and measuring each one's distance to the artwork gives a minimum of 2.994 mm and a maximum of 3.000 mm. That is the property a sticker needs, and it includes concave places such as the notch between the ears.
Round or miter corners
The corner style decides what happens at sharp points. The star in the drawing is 8.0 × 7.4 mm, with 36 degree tips. Offsetting only the star by 3 mm:
| joins | width x height | farthest point from the star |
|---|---|---|
| round (16 segments) | 14.0 x 13.4 mm | 3.00 mm |
| bevel | 12.3 x 11.5 mm | 3.00 mm |
| miter (limit 4 or 10) | 21.1 x 17.8 mm | 7.16 mm |
With miter joins, each tip of the border is 7.2 mm from the artwork, more than twice the offset you asked for. Round keeps every point at exactly the offset. Bevel is also exact but cuts each corner flat, which leaves a visibly polygonal border. The app's Offset Path dialog has the same choice, Joins, with a Miter Limit field next to it.
star = shapes[-1] star.buffer(3 * U, join_style=1, quad_segs=16) # round star.buffer(3 * U, join_style=3) # bevel star.buffer(3 * U, join_style=2, mitre_limit=4) # miter The measurement for miter used Shapely's mitre_limit, which is a ratio, so the number in another program's Miter Limit field may not mean the same thing. In this case limits 3, 4 and 10 all gave the same 21.1 mm width.
How many segments is enough
A round corner is an arc, and a polygon can only approximate it. I compared the offset of the whole artwork at each quad_segs setting against one made with 256 segments, using the Hausdorff distance, which is the largest gap between the two outlines:
| quad_segs | largest gap | time per offset |
|---|---|---|
| 2 | 0.363 mm | 0.7 ms |
| 4 | 0.095 mm | 1.0 ms |
| 8 | 0.029 mm | 0.7 ms |
| 16 | 0.006 mm | 0.5 ms |
The times are averages of 50 runs on this Mac, and they are all under a millisecond, so the cost of accuracy here is not time but the number of points in the output. At 4 segments the border is already within 0.1 mm of a true curve (I did not check this against any particular cutter). At 16 the gap is 6 microns. If you export to a format with a point limit, 8 is a sensible stopping place.
When the pieces connect
An offset can also make separate pieces join. The cat, the heart and the star do not touch in the drawing, so the border width decides whether the file is one sticker or three:
| offset | pieces | width x height |
|---|---|---|
| 1 mm | 3 | 42.5 x 36.8 mm |
| 2 mm | 1 | 44.5 x 38.8 mm |
| 3 mm | 1 | 46.5 x 40.8 mm |
| 5 mm | 1 | 50.5 x 44.8 mm |
At 0.5 mm there are three pieces as well. Somewhere between 1 and 2 mm the borders overlap and the union becomes one polygon. That is worth checking in code before sending a design to a cutter: len(sticker.geoms) on a MultiPolygon says how many separate cut paths the machine will make.
pieces = getattr(sticker, 'geoms', [sticker]) print(len(pieces)) # 1 at 3 mm, 3 at 1 mm Writing the cut line
To give a print shop the result, write the union as an SVG path with a white fill and a thin black stroke, and put the original artwork on top. The editor does the same with Unite, D (white fill, 1 pt black line) and Send to Back, and its PDF for this drawing is about 8 KB at 46.5 × 40.9 mm.
Doing it in Python makes sense when there are many designs, or the offset has to be applied to a folder of SVGs. For a single sticker, the Vector Editor in osFoundry gets the same shape with two clicks, and the app is free from the download page.
