I've recently been learning about how to draw smoother curves that look more appealing.
A common technique in professional CAD and font design software is to use a visualisation called a curvature comb to see how sharp or flat a curve is along its path.
Here's an example of a curvature comb being used in Fontlab VI to smooth out bumpy spots in a font:
<a target="_blank" href="https://www.fontlab.com/font-editor/fontlab-vi/"><img src="https://www.fontlab.com/images/vi-smoothcurves-720x540.gif" width=400 /></a>
To explain how this works, let's explore how Bézier curves work.
Most vector graphics software uses cubic Bézier curves to display curved lines.
A cubic Bézier curve is defined by four points: P₀, P₁, P₂ and P₃. The curve starts at P₀ moving toward P₁ and arrives at P₃ coming from the direction of P₂.
curve = { let p0 = new Point(40, 40); let p1 = new Point(100, 260); let p2 = new Point(370, 220); let p3 = new Point(250, 60); return new Bezier(p0, p1, p2, p3); }
{ const context = DOM.context2d(400, 300); context.beginPath(); context.moveTo(curve.p0.x, curve.p0.y); context.lineTo(curve.p1.x, curve.p1.y); context.lineTo(curve.p2.x, curve.p2.y); context.lineTo(curve.p3.x, curve.p3.y); context.lineJoin = context.lineCap = "round"; context.strokeStyle = '#AAA'; context.setLineDash([7, 7]); context.stroke(); context.beginPath(); context.moveTo(curve.p0.x, curve.p0.y); context.bezierCurveTo(curve.p1.x, curve.p1.y, curve.p2.x, curve.p2.y, curve.p3.x, curve.p3.y); context.lineJoin = context.lineCap = "round"; context.strokeStyle = '#000'; context.lineWidth = 3; context.setLineDash([]); context.stroke(); context.font = '18px Georgia, Sans-Serif'; context.fillText('P₀', curve.p0.x - 25, curve.p0.y); context.fillText('P₁', curve.p1.x - 25, curve.p1.y + 15); context.fillText('P₂', curve.p2.x + 5, curve.p2.y + 15); context.fillText('P₃', curve.p3.x + 5, curve.p3.y); context.canvas.style.maxWidth = '100%'; return context.canvas; }
The position along the curve can be calculated with the formula:
tex`B(t)=(1-t)^3P_0+3(1-t)^2tP_1+3(1-t)t^2P_2+t^3P_3 \text{ , } 0 \leq t \leq 1`
.. it might look a bit complicated—but don't worry—I'll try to explain.
The idea is that its kind of "blending" between the control points P₀, P₁, P₂ and P₃ as 𝑡 goes from 0 to 1
{ const lerp = (p1, p2, t) => p1.multiply(1 - t).add(p2.multiply(t)); const a = lerp(curve.p0, curve.p1, t); const b = lerp(curve.p1, curve.p2, t); const c = lerp(curve.p2, curve.p3, t); const d = lerp(a, b, t); const e = lerp(b, c, t); const context = DOM.context2d(400, 300); context.beginPath(); context.moveTo(curve.p0.x, curve.p0.y); context.lineTo(curve.p1.x, curve.p1.y); context.lineTo(curve.p2.x, curve.p2.y); context.lineTo(curve.p3.x, curve.p3.y); context.lineJoin = context.lineCap = "round"; context.strokeStyle = '#AAA'; context.setLineDash([7, 7]); context.stroke(); context.lineWidth = 1.5; context.beginPath(); context.moveTo(a.x, a.y); context.lineTo(b.x, b.y); context.lineJoin = context.lineCap = "round"; context.strokeStyle = '#0f0'; context.setLineDash([]); context.stroke(); context.beginPath(); context.moveTo(b.x, b.y); context.lineTo(c.x, c.y); context.lineJoin = context.lineCap = "round"; context.strokeStyle = '#0f0'; context.setLineDash([]); context.stroke(); context.beginPath(); context.moveTo(d.x, d.y); context.lineTo(e.x, e.y); context.lineJoin = context.lineCap = "round"; context.strokeStyle = '#00f'; context.setLineDash([]); context.stroke(); context.beginPath(); context.moveTo(curve.p0.x, curve.p0.y); context.bezierCurveTo(curve.p1.x, curve.p1.y, curve.p2.x, curve.p2.y, curve.p3.x, curve.p3.y); context.lineJoin = context.lineCap = "round"; context.strokeStyle = '#f00'; context.lineWidth = 3; context.setLineDash([]); context.stroke(); context.beginPath(); context.arc(curve.position(t).x, curve.position(t).y, 3, 0, 2 * Math.PI); context.fill(); context.font = '18px Georgia, Sans-Serif'; context.fillText('P₀', curve.p0.x - 25, curve.p0.y); context.fillText('P₁', curve.p1.x - 25, curve.p1.y + 15); context.fillText('P₂', curve.p2.x + 5, curve.p2.y + 15); context.fillText('P₃', curve.p3.x + 5, curve.p3.y); context.font = 'italic 14px Georgia, Sans-Serif'; context.fillText('t='+t.toPrecision(2), 200, 280); context.canvas.style.maxWidth = '100%' return context.canvas; }
viewof t = html`<input type=range min=0 max=1 step=any style="max-width:100%">`
A few extra formulas we'll need...
The first derivative of the curve is:
tex`B'(t) = 3(1-t)^2(P_1 - P_0) + 6(1-t)t(P_2 - P_1) + 3t^2(P_3 - P_2)`
... this describes the tangent along the curve. It is also used to calculate the line that is 90º to the curve—sometimes called the normal.
{ const t = t1; const context = DOM.context2d(400, 300); context.beginPath(); context.moveTo(curve.p0.x, curve.p0.y); context.lineTo(curve.p1.x, curve.p1.y); context.lineTo(curve.p2.x, curve.p2.y); context.lineTo(curve.p3.x, curve.p3.y); context.lineJoin = context.lineCap = "round"; context.strokeStyle = '#AAA'; context.setLineDash([7, 7]); context.stroke(); context.beginPath(); context.moveTo(curve.p0.x, curve.p0.y); context.bezierCurveTo(curve.p1.x, curve.p1.y, curve.p2.x, curve.p2.y, curve.p3.x, curve.p3.y); context.lineJoin = context.lineCap = "round"; context.strokeStyle = '#000'; context.lineWidth = 3; context.setLineDash([]); context.stroke(); context.font = '18px Georgia, Sans-Serif'; context.fillText('P₀', curve.p0.x - 25, curve.p0.y); context.fillText('P₁', curve.p1.x - 25, curve.p1.y + 15); context.fillText('P₂', curve.p2.x + 5, curve.p2.y + 15); context.fillText('P₃', curve.p3.x + 5, curve.p3.y); context.beginPath(); context.moveTo( curve.position(t).x - curve.d(t).x, curve.position(t).y - curve.d(t).y); context.lineTo( curve.position(t).x + curve.d(t).x, curve.position(t).y + curve.d(t).y); context.strokeStyle = '#00f'; context.lineWidth = 3; context.setLineDash([]); context.stroke(); context.beginPath(); context.moveTo( curve.position(t).x, curve.position(t).y); context.lineTo( curve.position(t).x - curve.d(t).y, curve.position(t).y + curve.d(t).x); context.strokeStyle = '#0f0'; context.lineWidth = 3; context.setLineDash([]); context.stroke(); context.fillStyle = '#00f'; context.fillText('Tangent', curve.position(t).x + 15, curve.position(t).y - 25); context.fillStyle = '#0f0'; context.fillText('Normal', curve.position(t).x - 75, curve.position(t).y + 25); context.canvas.style.maxWidth = '100%'; return context.canvas; }
viewof t1 = html`<input type=range min=0 max=1 step=any style="max-width:100%">`
The second derivative of the curve is:
tex`B''(t) = 6(1-t)(P_2 - 2 P_1 + P_0) + 6t(P_3 - 2 P_2 + P_1)`
This is starting to get a bit more abstract… it describes how quickly the tangent is changing. It will come in handy later when we want to calculate the curvature.
Curvature is a measure of how "sharply" a curve is changing.
Flat spots have a low curvature—for example, a straight line has a curvature of 0. Sharper areas have a higher curvature. Usually the curvature will change as you go along a Bézier curve.
To measure the curvature of a cubic Bézier curve we'll use a technique I found described in Computer Aided Geometric Design course notes. The idea is to try fit a circle inside the curve at each point, so that the circle just barely touches.
{ const t = t2; const lerp = (p1, p2, t) => p1.multiply(1 - t).add(p2.multiply(t)); const a = lerp(curve.p0, curve.p1, t); const b = lerp(curve.p1, curve.p2, t); const c = lerp(curve.p2, curve.p3, t); const d = lerp(a, b, t); const e = lerp(b, c, t); const context = DOM.context2d(400, 300); context.beginPath(); context.moveTo(curve.p0.x, curve.p0.y); context.lineTo(curve.p1.x, curve.p1.y); context.lineTo(curve.p2.x, curve.p2.y); context.lineTo(curve.p3.x, curve.p3.y); context.lineJoin = context.lineCap = "round"; context.strokeStyle = '#AAA'; context.setLineDash([7, 7]); context.stroke(); context.beginPath(); context.moveTo(curve.p0.x, curve.p0.y); context.bezierCurveTo(curve.p1.x, curve.p1.y, curve.p2.x, curve.p2.y, curve.p3.x, curve.p3.y); context.lineJoin = context.lineCap = "round"; context.strokeStyle = '#000'; context.lineWidth = 3; context.setLineDash([]); context.stroke(); context.beginPath(); context.moveTo( curve.position(t).x - curve.d(t).x, curve.position(t).y - curve.d(t).y); context.lineTo( curve.position(t).x + curve.d(t).x, curve.position(t).y + curve.d(t).y); context.strokeStyle = '#0f0'; context.lineWidth = 1; context.setLineDash([]); context.stroke(); const r = 1 / curve.curvature(t); const p = curve.position(t); const n = curve.d(t).normal().normalise().multiply(r); context.beginPath(); context.moveTo(p.x, p.y); context.lineTo(p.add(n).x, p.add(n).y); context.strokeStyle = '#f00'; context.lineWidth = 1; context.stroke(); context.beginPath(); context.arc(p.add(n).x, p.add(n).y, Math.abs(r), 0, 2 * Math.PI); context.stroke(); context.font = '18px Georgia, Sans-Serif'; context.fillText('P₀', curve.p0.x - 25, curve.p0.y); context.fillText('P₁', curve.p1.x - 25, curve.p1.y + 15); context.fillText('P₂', curve.p2.x + 5, curve.p2.y + 15); context.fillText('P₃', curve.p3.x + 5, curve.p3.y); context.canvas.style.maxWidth = '100%'; return context.canvas; }
viewof t2 = html`<input type=range min=0 max=1 step=any style="max-width:100%">`
The curvature can be calculated as the inverse of the radius:
tex`c = \frac{1}{r}`
And so now, by drawing perpendicular lines at a regular interval with a length based on the curvature, we now have our curvature comb:
{ const context = DOM.context2d(400, 300); context.beginPath(); context.moveTo(curve.p0.x, curve.p0.y); context.lineTo(curve.p1.x, curve.p1.y); context.lineTo(curve.p2.x, curve.p2.y); context.lineTo(curve.p3.x, curve.p3.y); context.lineJoin = context.lineCap = "round"; context.strokeStyle = '#AAA'; context.setLineDash([7, 7]); context.stroke(); context.beginPath(); context.moveTo(curve.p0.x, curve.p0.y); context.bezierCurveTo(curve.p1.x, curve.p1.y, curve.p2.x, curve.p2.y, curve.p3.x, curve.p3.y); context.lineJoin = context.lineCap = "round"; context.strokeStyle = '#000'; context.lineWidth = 3; context.setLineDash([]); context.stroke(); const steps = 100; for (let i=0; i<steps; i++) { let t = i * (1/steps); const c = curve.curvature(t); const p = curve.position(t); const n = curve.d(t).normal().normalise().multiply(c * -2500); context.beginPath(); context.moveTo(p.x, p.y); context.lineTo(p.add(n).x, p.add(n).y); context.strokeStyle = '#f00'; context.lineWidth = 0.5; context.stroke(); } context.font = '18px Georgia, Sans-Serif'; context.fillText('P₀', curve.p0.x - 25, curve.p0.y); context.fillText('P₁', curve.p1.x - 25, curve.p1.y + 15); context.fillText('P₂', curve.p2.x + 5, curve.p2.y + 15); context.fillText('P₃', curve.p3.x + 5, curve.p3.y); context.canvas.style.maxWidth = '100%'; return context.canvas; }
This curvature comb is a useful tool to see which parts of the curve are "sharper" or "flatter".
It starts to become even more useful when joining Bézier curves together:
{ const context = DOM.context2d(500, 300); let _y = 220; let _a = new Point(20, 20); let _b = new Point(20, 60); let _c = new Point(m-80, _y); let _d = new Point(m, _y); let _e = new Point(m+(m/3), _y); let _f = new Point(320, 150); let _g = new Point(380, 20); let c1 = new Bezier(_a, _b, _c, _d); let c2 = new Bezier(_d, _e, _f, _g); let c; c = c1; context.beginPath(); context.moveTo(c.p0.x, c.p0.y); context.bezierCurveTo(c.p1.x, c.p1.y, c.p2.x, c.p2.y, c.p3.x, c.p3.y); context.lineJoin = context.lineCap = "round"; context.strokeStyle = '#000'; context.lineWidth = 2; context.setLineDash([]); context.stroke(); c = c2; context.beginPath(); context.moveTo(c.p0.x, c.p0.y); context.bezierCurveTo(c.p1.x, c.p1.y, c.p2.x, c.p2.y, c.p3.x, c.p3.y); context.lineJoin = context.lineCap = "round"; context.strokeStyle = '#000'; context.lineWidth = 2; context.setLineDash([]); context.stroke(); const steps = 80; for (let i=0; i<steps; i++) { let t = i * (1/steps); const c = c1.curvature(t); const p = c1.position(t); const n = c1.d(t).normal().normalise().multiply(c * -2500); context.beginPath(); context.moveTo(p.x, p.y); context.lineTo(p.add(n).x, p.add(n).y); context.strokeStyle = '#00f'; context.lineWidth = 0.5; context.stroke(); } for (let i=0; i<steps; i++) { let t = i * (1/steps); const c = c2.curvature(t); const p = c2.position(t); const n = c2.d(t).normal().normalise().multiply(c * -2500); context.beginPath(); context.moveTo(p.x, p.y); context.lineTo(p.add(n).x, p.add(n).y); context.strokeStyle = '#f00'; context.lineWidth = 0.5; context.stroke(); } context.canvas.style.maxWidth = '100%'; return context.canvas; }
viewof m = html`<input type=range min=50 max=350 style="max-width:100%">`
Try dragging the slider above—it moves the point that connects the two Bézier curves. It's set up so that both curves have the same tangent at the joining point.
When the amount of curvature at the joining point doesn't match, you might notice it can look kind of "lumpy". This is because the curve is suddenly jumping from one curvature to another. These sudden jumps in curvature are called continuity breaks.
Try moving the slider until the amount of curvature lines up on both sides.
Ahh, much better! 😌
class Bezier { constructor(p0, p1, p2, p3) { this.p0 = p0; this.p1 = p1; this.p2 = p2; this.p3 = p3; } position(t) { return ( this.p0.multiply((1-t)**3) .add(this.p1.multiply(3 * (1-t)**2 * t)) .add(this.p2.multiply(3 * (1-t) * t**2)) .add(this.p3.multiply(t**3)) ); } // First derivitive d(t) { return ( this.p1.sub(this.p0).multiply(3 * (1-t)**2) .add(this.p2.sub(this.p1).multiply(6 * (1-t)*t)) .add(this.p3.sub(this.p2).multiply(3 * t**2)) ); } // Second derivitive dd(t) { return ( this.p2.sub(this.p1.multiply(2)).add(this.p0).multiply(6 * (1-t)) .add(this.p3.sub(this.p2.multiply(2)).add(this.p1).multiply(6 * t )) ); } curvature(t) { const d = this.d(t); const dd = this.dd(t); return ( (d.x * dd.y - d.y * dd.x) / (d.x**2 + d.y**2)**(3/2) ); } }
class Point { constructor(x, y) { this.x = x; this.y = y } multiply(n) { return new Point(this.x * n, this.y * n) } add(p) { return new Point(this.x + p.x, this.y + p.y) } sub(p) { return new Point(this.x - p.x, this.y - p.y) } mag() { return Math.sqrt(this.x**2 + this.y**2) } normal() { return new Point(-this.y, this.x) } normalise() { return this.multiply(1/this.mag()) } }