Engineering Journal
Schema Editor
Schema Editor

Freehand drawing is a pipeline: capture, filter, simplify, spline

2026-07-17

TLDR

A freehand pen tool built on raw mousemove events and naive smoothing will always feel wrong: strokes staircase, die at the canvas edge, and bloat the document. The fix is a four-stage pipeline: pointer capture for input, a min-distance filter for sampling, Ramer-Douglas-Peucker for simplification, and a Catmull-Rom spline for rendering. Each stage is under 40 lines of dependency-free code.

Who this is for

Any developer adding a freehand drawing tool to a web canvas: whiteboards, annotation layers, signature pads, diagram editors. The failure modes here are not specific to one codebase. They fall out of the browser event model, and every first implementation hits them.

The naive version

The obvious implementation looks like this:

container.addEventListener('mousedown', start);
document.addEventListener('mousemove', (e) => {
  points.push(toCanvasCoords(e));
  preview.setAttribute('d', pointsToPath(points));
});
document.addEventListener('mouseup', commit);

And the obvious smoothing looks like a chain of quadratic curves through midpoints:

// looks like a spline, is not one
d +=  Q ${p.x} ${p.y} ${(p.x + next.x) / 2} ${(p.y + next.y) / 2};

This ships, demos fine on a slow deliberate stroke, and falls apart in real use.

Why it fails

Mouse events undersample. mousemove fires at the browser's event loop cadence, not the device's report rate. A 120Hz stylus or gaming mouse delivers far more positions than you receive. Fast strokes come through as sparse, jagged polylines that no amount of after-the-fact smoothing can reconstruct.

Strokes die at the boundary. Without capture, the moment the cursor leaves your container the move events stop arriving. Users drawing a long stroke that clips the edge lose the tail of it, or worse, the stroke commits early.

Shared snapping quantizes the stroke. If your app has a snap-to-grid helper and every tool routes through it, the pen does too. Each sampled point rounds to the nearest grid intersection while the user is still drawing. The output is not a curve, it is a walk on a lattice. This was the single biggest source of the "it draws in boxes" complaint in our diagram editor.

Midpoint quadratics are not smoothing. The Q-through-midpoints trick keeps the path continuous but it passes control through raw sample positions, so every jitter in the input survives into the curve. It also does nothing about point count: a five-second stroke stores thousands of coordinates.

The pipeline

Stage 1: pointer events with capture

container.addEventListener('pointerdown', (e) => {
  container.setPointerCapture(e.pointerId);
  container.style.touchAction = 'none';
  stroke = { pointerId: e.pointerId, points: [toCanvas(e)] };
});

setPointerCapture routes every subsequent event for that pointer to your element, even when the cursor is outside it. The stroke can leave the canvas and come back. touch-action: none stops the browser from hijacking the gesture for scrolling on touch devices.

Stage 2: coalesced events plus a distance filter

container.addEventListener('pointermove', (e) => {
  for (const ev of (e.getCoalescedEvents?.() || [e])) {
    const pt = toCanvas(ev);
    const last = stroke.points[stroke.points.length - 1];
    if (Math.hypot(pt.x - last.x, pt.y - last.y) < minDist) continue;
    stroke.points.push(pt);
  }
});

getCoalescedEvents recovers the full-rate samples the browser batched between frames, so fast strokes are dense again. The distance filter runs the other direction: it drops samples closer than a threshold (we use 0.75 canvas units, divided by zoom), which kills the jitter cloud that accumulates when the pointer hovers in place.

Do not snap here. Raw points only. Snapping belongs to tools that produce structural geometry, not ink.

Stage 3: simplify on commit

Ramer-Douglas-Peucker reduces the point set to the minimum that stays within epsilon of the original polyline. Iterative version, no recursion depth issues:

function rdp(pts, eps) {
  if (pts.length < 3) return pts;
  const keep = new Uint8Array(pts.length);
  keep[0] = keep[pts.length - 1] = 1;
  const stack = [[0, pts.length - 1]];
  while (stack.length) {
    const [a, b] = stack.pop();
    const A = pts[a], B = pts[b];
    const dx = B.x - A.x, dy = B.y - A.y;
    const len = Math.hypot(dx, dy) || 1e-9;
    let maxD = 0, maxI = -1;
    for (let i = a + 1; i < b; i++) {
      const d = Math.abs(dy  (pts[i].x - A.x) - dx  (pts[i].y - A.y)) / len;
      if (d > maxD) { maxD = d; maxI = i; }
    }
    if (maxD > eps) { keep[maxI] = 1; stack.push([a, maxI], [maxI, b]); }
  }
  return pts.filter((_, i) => keep[i]);
}

A multi-second stroke typically drops from thousands of points to a few dozen with no visible change.

Stage 4: render as a real spline

Catmull-Rom interpolates through the points (not near them, through them) and converts exactly to cubic beziers, which SVG and canvas both speak natively:

for (let i = 0; i < pts.length - 1; i++) {
  const p0 = pts[i - 1] || pts[i], p1 = pts[i];
  const p2 = pts[i + 1], p3 = pts[i + 2] || p2;
  d +=  C ${p1.x + (p2.x - p0.x) / 6} ${p1.y + (p2.y - p0.y) / 6}
     +  ${p2.x - (p3.x - p1.x) / 6} ${p2.y - (p3.y - p1.y) / 6}
     +  ${p2.x} ${p2.y};
}

Because RDP already removed the noise, the spline has few control points and reads as a confident hand-drawn line rather than a tremor record.

One more thing: tag ink as ink

If your app analyzes its own canvas (ours classifies shapes into wires and components for netlist extraction), freehand strokes will confuse the classifier: a closed low-linearity scribble scores exactly like a component blob. Tag ink at commit time (data-geo-class="ink") and make the analyzer skip it. Decoration should never enter the semantic model.

Tradeoffs

RDP on commit means the preview and the final stroke differ slightly; users do not notice at sub-pixel epsilon, but a settings slider for smoothing needs the raw points kept around. Catmull-Rom overshoots on sharp corners; if your users draw technical shapes freehand, consider detecting high-curvature points and pinning them. And pointer capture means you own gesture disambiguation: if the same surface supports two-finger pan, disable that recognizer while a pen stroke is active.

None of this needs a library. The full pipeline is about 120 lines and gets you to the interaction floor users now expect from tools like Excalidraw.

Read this post in the full Engineering Journal →