Skip to content

Rough curves

roughCurve(points, options) draws a smooth outline by hand. Use it for an ellipse or an arrow’s arc. Roughening a 64-point ellipse edge by edge reads as a polygon, so a smooth outline gets a smooth curve through a few points instead. Straight-edged outlines belong to roughEdges.

type RoughCurveOptions = {
readonly closed: boolean;
readonly roughness: number;
readonly seed: Seed;
readonly passes?: 1 | 2;
};
type RoughCurve = {
readonly path: Path;
readonly base: readonly LocalPoint[];
};

closed says the last point joins back to the first. passes is 2 by default. passes: 1 draws only the first pass, for dashed and dotted strokes, and returns the same base. Pass each point once. Repeating the first point at the end is harmless.

How it draws

  1. Resample. The outline is resampled at even arc length to a few points. A closed outline gets one point per 40 units of perimeter, and never fewer than 9. An open one gets one per 40 units, and never fewer than 4. Even spacing keeps the sides from going lumpy, because the jitter is the same size at every point.
  2. Jitter. Every point moves by a random amount of up to 1 unit on the first pass and 1.5 on the second, times roughness. Open curves add a share of roughness to those amounts, so an arrow’s arc wobbles a little more than an ellipse.
  3. Two passes. Each pass is a Catmull-Rom curve through its jittered points, converted to cubic curve commands. The result holds both passes, so the stroke looks drawn twice.

Closed outlines

A closed outline starts near its top, like a rough.js ellipse. The start is the topmost input point, moved along the outline by a random turn of up to 0.5 radians times roughness, so the join between the end of the stroke and its start sits near the top but differs per seed.

The stroke does not stop where it started. It runs on past the start by a random fraction of one resample step, as rough.js does: roughness × (0.1 + random × (inner − 0.1)) steps, where inner is roughness × (0.4 + 0.6 × random). It passes the halfway point of the overlap on the outline and ends at 98 percent of the radius, so the overlap hugs the curve and turns in only slightly. On a circle of radius 50 at roughness 1, the overlap is shorter than one 35-unit step. Only the first pass overlaps. The second pass draws its own random start near the top and closes on itself with no overlap, so the two seams land in different places, as rough.js does. The outline is also scaled about its centre by up to 5 percent, so two shapes with different seeds differ in size as well as in wobble.

The base points

base holds the jittered points of the first pass. A closed curve returns its ring without the tail, and the polygon closes implicitly. A fill hatches this polygon, so the hatching stays inside the outline the stroke drew. ADR 0005 (docs/decisions/0005-hand-drawn-canvas-renderer.md) calls this following the stroke’s base points.

Point limit

A curve resamples to at most 512 points per pass, closed or open. A huge outline gets a wider spacing instead of more points, so painting it stays fast.

Roughness 0

A roughness of 0 or less draws one pass with no jitter, no scale change and no tail. A closed curve starts at the first input point and ends where it started. The seed has no effect. The curve passes through evenly spaced points on the input outline, so it is a smoothed version of it and not the input itself.

Degenerate input

Fewer than two points return an empty path and the input as base. Equal points, such as a zero-size ellipse, give a path of finite numbers.

docs/examples/rough-ellipse.ts
import { localPoint, seed } from "@draw/shapes";
import type { LocalPoint } from "@draw/shapes";
import { roughCurve } from "@draw/sketch";
import type { RoughCurve } from "@draw/sketch";
const outline: readonly LocalPoint[] = Array.from({ length: 64 }, (_, index) => {
const angle = (index / 64) * Math.PI * 2;
return localPoint(80 + 80 * Math.cos(angle), 55 + 55 * Math.sin(angle));
});
export const ellipse: RoughCurve = roughCurve(outline, {
closed: true,
roughness: 1,
seed: seed(1234),
});

The ellipse returns a path with two passes and 11 base points. The base ring is what a hachure fill would be clipped to.