Metlivi Blog

How to Build a Broad-Edge Pen Stroke by Sweeping a Fixed Shape Along a Path

To build a broad-edge pen stroke, define a narrow rectangular nib, hold its angle constant, and translate it along a path. The stroke is the union of every position occupied by that rectangle. Its thickness changes with the direction of travel, even though the nib itself never changes size.

This guide shows how to construct that geometry for a vector drawing or character accent. You will define the nib, sweep it continuously along a polyline, and generate SVG using a complete Python program. The central construction avoids the gaps that can appear when a brush is simulated with separate stamps.

October 29, 20268 min readBy Metlivi Editorial Team
Section 1

What does sweeping a fixed shape mean?

Separate the drawing into two inputs: a path describing where the nib’s reference point travels, and a footprint describing the area covered by the nib at one position. Place the footprint’s reference point at the origin. At each path position, add that position to every point in the footprint.

Mathematically, the resulting set is S = {p + n | p belongs to P, n belongs to N}, where P is the path and N is the nib. This is a Minkowski sum, following the pointwise definition in the CGAL 2D Minkowski Sums user manual.

For this construction, “fixed” means that both the footprint and its orientation stay constant in drawing coordinates. The nib translates without turning to follow the path. Keeping the reference point at the rectangle’s center makes the path describe the center of the moving nib.

Section 2

How do nib angle and travel direction determine thickness?

Choose three parameters: the rectangle’s long dimension w, its short dimension h, and the angle α of its long edge. A small positive h gives the nib a visible minimum thickness. An ideal line-segment nib instead has zero thickness and can leave a zero-area mark when moved exactly along its own direction.

For a straight path segment with direction angle θ, the rectangular nib’s width measured perpendicular to travel is:

width across the stroke = w × |sin(θ − α)| + h × |cos(θ − α)|

This formula follows by projecting the rectangle’s two perpendicular dimensions onto the normal to the path. It describes a straight segment, or the local footprint projection along a smooth curve; overlapping parts of a curved stroke can make its overall silhouette wider.

For an illustrative nib measuring 24 units by 3 units, travel parallel to the long edge produces a 3-unit-wide strip. Perpendicular travel produces a 24-unit-wide strip. At a 45° difference, the width is approximately (24 + 3) / √2, or 19.09 units. These are calculated dimensions, not measured brush results.

Use those three directions as a calibration test before drawing a complicated curve. They reveal whether the angle and dimensions behave as intended without requiring you to interpret several overlapping bends.

Section 3

How can you sweep continuously without leaving stamping gaps?

For a convex nib moving along one straight segment, take a copy of the nib at each endpoint and compute the convex hull of their combined vertices. That hull is exactly the area swept during translation along the segment.

The reason is straightforward: every intermediate placement is a linear interpolation between the endpoint placements. Because the nib is convex, the hull contains precisely those interpolated positions. This segment construction is a direct consequence of the Minkowski-sum definition, rather than a separate brush feature.

For a rectangle, each segment supplies just eight candidate points: four corners at the start and four at the end. Compute their hull, repeat for every consecutive pair of path points, and combine the resulting polygons. Adjacent sweeps share the full nib placement at their common endpoint, so the geometry has continuous coverage.

Do not compute one convex hull around the entire path. That would fill across bends and erase intended recesses. The hull belongs to each individual segment; the complete stroke is their union.

A concave nib needs a different treatment. Taking its hull would fill its indentations. Decompose it into convex pieces, sweep each piece, and combine the results. CGAL documents the related decomposition-and-union approach for Minkowski sums of nonconvex polygons.

Section 4

How do you implement the sweep in Python?

The following complete program requires Python 3 and its standard library. It defines a five-point sample path, constructs a rectangular nib, and prints an SVG document containing four filled sweep polygons. Save it as broad_edge.py, then run python broad_edge.py > stroke.svg to create the drawing.

Coordinates follow the SVG convention used here: x increases rightward and y increases downward. Consequently, a positive angle in this program tilts the nib clockwise on screen. Angles in other drawing interfaces may use a different convention.

from math import cos, sin, radians


def hull(points):
    pts = sorted(set(points))
    if len(pts) <= 1:
        return pts

    def cross(o, a, b):
        return ((a[0] - o[0]) * (b[1] - o[1])
                - (a[1] - o[1]) * (b[0] - o[0]))

    def half(sequence):
        out = []
        for p in sequence:
            while len(out) >= 2 and cross(out[-2], out[-1], p) <= 0:
                out.pop()
            out.append(p)
        return out

    return half(pts)[:-1] + half(reversed(pts))[:-1]


def sweep(path, width=24, thickness=3, angle=30):
    if not path or width <= 0 or thickness <= 0:
        raise ValueError(
            'Provide a nonempty path and positive nib dimensions'
        )

    a = radians(angle)
    u = (cos(a), sin(a))
    v = (-sin(a), cos(a))
    nib = [
        (s * width * u[0] / 2 + t * thickness * v[0] / 2,
         s * width * u[1] / 2 + t * thickness * v[1] / 2)
        for s, t in [(-1, -1), (1, -1), (1, 1), (-1, 1)]
    ]
    pairs = list(zip(path, path[1:])) or [(path[0], path[0])]
    return [
        hull([(p[0] + q[0], p[1] + q[1])
              for p in (start, end) for q in nib])
        for start, end in pairs
    ]


path = [(40, 45), (85, 125), (140, 150), (195, 110), (245, 45)]
polygons = sweep(path)
all_points = [p for polygon in polygons for p in polygon]
x0, y0 = (min(p[i] for p in all_points) - 5 for i in (0, 1))
x1, y1 = (max(p[i] for p in all_points) + 5 for i in (0, 1))

svg = [
    '<svg xmlns="http://www.w3.org/2000/svg" '
    f'viewBox="{x0} {y0} {x1 - x0} {y1 - y0}">',
    '<g fill="#23364d" stroke="none">'
]
for polygon in polygons:
    coordinates = ' '.join(f'{x:.6f},{y:.6f}' for x, y in polygon)
    svg.append(f'<polygon points="{coordinates}"/>')
svg.extend(['</g>', '</svg>'])
print('\n'.join(svg))

The program handles a single path point by drawing one nib footprint. Repeated consecutive points also produce a footprint rather than requiring a direction calculation. Empty paths and nonpositive nib dimensions raise an error. Supply finite numeric coordinate pairs; this compact example does not validate imported path data.

The construction was run with the sample coordinates: it produced four six-vertex sweep polygons, and the generated SVG parsed successfully as XML. Single-point and repeated-point cases were also checked. These checks verify the construction and document structure; they do not constitute a visual rendering test.

Section 5

How should you handle curves, corners, and endpoints?

The example is exact for its polyline, subject to floating-point arithmetic and coordinate rounding. To use a Bézier curve, first approximate it with straight segments, then apply the same sweep. Add more segments where the curve bends sharply rather than distributing all samples uniformly in the curve parameter.

Choose the approximation tolerance for the intended output size. For example, a quarter-pixel deviation can be an initial display target, followed by inspection at the largest planned scale. That is an illustrative engineering choice, not a universal quality threshold. Importantly, adding subdivisions along an already straight segment does not change its swept area.

Keep separate pen-down paths separate. Passing points from two disconnected strokes into one call would create an unwanted connecting sweep. Generate each stroke independently and collect their polygons in the output.

At an endpoint, the nib retains its full footprint. At a corner, the two neighboring sweeps overlap. There is no additional miter or rounded cap in this model. SVG’s ordinary stroke properties define their own cap and join constructions, so adding them would introduce geometry beyond this fixed-nib sweep. See the SVG 2 definitions of stroke caps and joins.

Section 6

When do you need to merge the polygons?

The program exports overlapping filled polygons. For a solid-color preview, these represent the swept coverage without requiring a polygon-union library. They remain separate editable objects, however; the program does not calculate one consolidated boundary.

If you need a clean silhouette for subsequent outline editing, compute a Boolean union of all segment polygons. Preserve any holes that remain in the result. A looping path may surround an unpainted region, and filling every enclosed contour would change the stroke.

Transparency also needs deliberate handling. Applying partial fill-opacity separately to overlapping polygons can make overlap regions darker. For uniform translucency, apply opacity to the enclosing group, or merge the geometry before applying fill opacity. SVG distinguishes object fill opacity from group opacity in its painting specification.

Intended result Appropriate next step
Solid-color preview Keep the filled sweep polygons
Uniform translucent preview Apply opacity to their enclosing group
One editable silhouette Boolean-union the sweep polygons
Concave custom nib Decompose the nib before sweeping
Smooth curved stroke Approximate the curve, then sweep its segments
Section 7

How do you diagnose an unexpected stroke?

Start with the calibration directions, then inspect the path and output construction. Change one input at a time so that the source of a visible difference remains clear.

  • Thickness stays nearly constant: Check whether the nib is rotating with the path. Its angle must remain fixed in drawing coordinates.
  • Visible spaces appear between marks: Replace isolated stamps with the segment hull construction.
  • A broad wedge fills the inside of a bend: Check whether you accidentally took a hull over several path segments together.
  • Ends look rounded: Check for an added ordinary stroke or a different nib footprint.
  • A smooth source curve looks angular: Refine its polyline approximation around bends.
  • Unexpected connectors appear: Keep disconnected paths in separate sweep calls.

If you are comparing the result with Inkscape’s Calligraphy tool, its documentation provides useful parameter checks: Fixation 100 holds the angle fixed, Thinning 0 removes velocity-based width variation, and pressure and tilt controls can alter width and angle. Disable those varying inputs when examining fixed-nib behavior. These settings describe the tool’s controls; they do not establish that its output is identical to this rectangular construction. See the Inkscape Calligraphy Toolbar documentation.

A dependable broad-edge stroke starts with a simple calibration path and a clearly defined footprint. Once the parallel, perpendicular, and diagonal tests behave correctly, use the same segment sweep on your chosen path, refine curved sections to suit the output scale, and merge the polygons when your next editing step requires a single silhouette.

Section 8

Sources

Related reading

Keep exploring this topic