Engineering Journal
Schema Editor
Schema Editor

Freehand Ink Capture Is a Four-Stage Pipeline: Capture, Filter, Simplify, Spline

2026-07-17

TLDR

Naive freehand drawing tools built directly on un-filtered mousemove events feel jagged, drop strokes at canvas boundaries, and bloat documents with thousands of redundant points. The fix is a four-stage architecture: Pointer Capture (captures out-of-bounds gestures), Distance-Filtered Coalesced Sampling (recovers high-rate stylus hardware inputs while filtering jitter), Ramer-Douglas-Peucker (RDP) Simplification (prunes 90% of redundant points on commit), and Catmull-Rom Spline Rendering (converts points into smooth cubic Beziers).
Pipeline StageAlgorithmic MechanismPerformance / UX Role
1. Pointer CapturesetPointerCapture(pointerId)Prevents lost strokes when cursor leaves canvas
2. Coalesced FiltergetCoalescedEvents() + Math.hypotCaptures 120Hz inputs while dropping hover jitter
3. RDP SimplificationRamer-Douglas-Peucker ($\epsilon$ threshold)Reduces thousands of points to dozens on commit
4. Spline InterpolationCatmull-Rom to Cubic BeziersRenders confident, smooth hand-drawn curves

Problem statement: the flaws of naive mousemove drawing

Building a freehand pen tool by simply pushing raw mousemove coordinates into an SVG path (M x y L x y...) produces severe UX defects:

  1. Event Undersampling: mousemove fires at browser event-loop cadence rather than stylus hardware report rates (120Hz+), making fast strokes look sparse and jagged.
  2. Boundary Truncation: When the cursor leaves the canvas container, move events stop arriving and strokes freeze or commit prematurely.
  3. Grid Quantization Interference: Global grid-snap helpers round points during dragging, turning smooth curves into lattice walks.
  4. Document Bloat: A 5-second stroke stores thousands of coordinates, degrading application performance over time.

The fix & architecture: the four-stage drawing pipeline

Stage 1: pointer capture initialization

Use Pointer Events with explicit pointer capture and disable default touch behaviors:
container.addEventListener('pointerdown', (e) => {
  container.setPointerCapture(e.pointerId);
  container.style.touchAction = 'none'; // Prevents touch scrolling on mobile
  stroke = { pointerId: e.pointerId, points: [toCanvas(e)] };
});

Stage 2: coalesced events & distance filtering

Recover batched hardware events using getCoalescedEvents() and filter out sub-pixel jitter:
container.addEventListener('pointermove', (e) => {
  if (!stroke || e.pointerId !== stroke.pointerId) return;
  const events = e.getCoalescedEvents ? e.getCoalescedEvents() : [e];
  
  for (const ev of events) {
    const pt = toCanvas(ev);
    const last = stroke.points[stroke.points.length - 1];
    // Filter jitter: skip points closer than minDist (e.g., 0.75 units)
    if (Math.hypot(pt.x - last.x, pt.y - last.y) < minDist) continue;
    stroke.points.push(pt);
  }
  renderPreview(stroke.points);
});

Stage 3: ramer-douglas-peucker (RDP) simplification

On pointerup, run an iterative RDP simplification algorithm to strip redundant collinear points:
function rdpSimplify(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 > 0) { 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]); }

Stage 4: catmull-rom spline to cubic Beziers

Convert simplified points into smooth cubic Bezier path segments:
function pointsToCatmullRomSvgD(pts) {
  if (pts.length < 2) return '';
  let d = M ${pts[0].x} ${pts[0].y};

for (let i = 0; i < pts.length - 1; i++) { const p0 = pts[i - 1] || pts[i]; const p1 = pts[i]; const p2 = pts[i + 1]; const p3 = pts[i + 2] || p2;

const cp1x = p1.x + (p2.x - p0.x) / 6; const cp1y = p1.y + (p2.y - p0.y) / 6; const cp2x = p2.x - (p3.x - p1.x) / 6; const cp2y = p2.y - (p3.y - p1.y) / 6;

d += C ${cp1x} ${cp1y} ${cp2x} ${cp2y} ${p2.x} ${p2.y}; } return d; }

Rule of thumb: Never save raw mouse move coordinate streams directly to document storage. Pass ink inputs through RDP simplification and render smoothed paths using Catmull-Rom splines.
Read this post in the full Engineering Journal →