Freehand Ink Capture Is a Four-Stage Pipeline: Capture, Filter, Simplify, Spline
TLDR
Naive freehand drawing tools built directly on un-filteredmousemove 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 Stage | Algorithmic Mechanism | Performance / UX Role |
|---|---|---|
| 1. Pointer Capture | setPointerCapture(pointerId) | Prevents lost strokes when cursor leaves canvas |
| 2. Coalesced Filter | getCoalescedEvents() + Math.hypot | Captures 120Hz inputs while dropping hover jitter |
| 3. RDP Simplification | Ramer-Douglas-Peucker ($\epsilon$ threshold) | Reduces thousands of points to dozens on commit |
| 4. Spline Interpolation | Catmull-Rom to Cubic Beziers | Renders 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:
- Event Undersampling:
mousemovefires at browser event-loop cadence rather than stylus hardware report rates (120Hz+), making fast strokes look sparse and jagged. - Boundary Truncation: When the cursor leaves the canvas container, move events stop arriving and strokes freeze or commit prematurely.
- Grid Quantization Interference: Global grid-snap helpers round points during dragging, turning smooth curves into lattice walks.
- 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 usinggetCoalescedEvents() 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
Onpointerup, 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.