Custom Bezier Curve Tool for HTML Canvas, pt. 1

Tom Cantwell
4 min readJan 15, 2021

First of all, what is a bezier curve? You could look it up, but basically it’s what all vector art tools use to generate curved lines. The curve is defined by a series of ‘control points’, which can be moved to adjust the curve intuitively.

HTML Canvas has a built-in function to draw bezier curves, bezierCurveTo(), but for my purposes, I want to build a bezier curve tool for pixel art. So, I’m building the tool from scratch. This is incomplete, but I’ll write about the refined version in the next blogpost. I’m also just starting with a quadratic bezier curve, and I’ll worry about higher order curves later.

The formula for a bezier curve is a parametric equation, which just means we’re going to use the same equation twice, once for the x component, and once for the y component. The equation:

For my tool, it’s not a true vector tool that allows you to readjust the control points. Once the curve is made, it will be fully raster. So, the main control point which will be adjusted while making a curve will be P1.

Quadratic Bezier Curve

t is a number between 0 and 1, and represents the progress along the curve.

Since x and y will be calculated using the same equation, I’ll just write a generalized function that I can plug values into:

function pt(p1, p2, p3, t) {
return Math.round(p3 + Math.pow((1 - t), 2) * (p1 - p3) + Math.pow(t, 2) * (p2 - p3));
}

The result is rounded to keep the pixels locked to the grid.

The controller function for the curve tool:

function curveSteps() {
switch (state.event) {
case "mousedown":
clickCounter += 1;
if (clickCounter > 3) clickCounter = 1;
switch (clickCounter) {
case 1:
px1 = state.mouseX;
py1 = state.mouseY;
break;
case 2:
px2 = state.mouseX;
py2 = state.mouseY;
break;
case 3:
px3 = state.mouseX;
py3 = state.mouseY;
break;
default:
//do nothing
}
break;
case "mousemove":
//only draw when necessary
if (state.onX !== state.lastOnX || state.onY !== state.lastOnY) {
onScreenCTX.clearRect(0, 0, ocWidth / zoom, ocHeight / zoom);
drawCanvas();
//onscreen preview
actionCurve(
px1 + (state.xOffset / state.ratio) * zoom,
py1 + (state.yOffset / state.ratio) * zoom,
px2 + (state.xOffset / state.ratio) * zoom,
py2 + (state.yOffset / state.ratio) * zoom,
px3 + (state.xOffset / state.ratio) * zoom,
py3 + (state.yOffset / state.ratio) * zoom,
clickCounter,
state.brushColor,
onScreenCTX,
state.mode,
state.ratio / zoom
);
state.lastOnX = state.onX;
state.lastOnY = state.onY;
}
break;
case "mouseup" || "mouseout":
if (clickCounter === 3) {
actionCurve(
px1,
py1,
px2,
py2,
px3,
py3,
clickCounter,
state.brushColor,
offScreenCTX,
state.mode
);
clickCounter = 0;
renderImage();
}
break;
default:
//do nothing
}
}

Each click adds a control point, and a preview is drawn onscreen until the third control point is decided, which then draws it permanently onto the offscreen canvas.

function actionCurve(x1,y1,x2,y2,x3,y3,stepNum,currentColor,ctx, currentMode,scale = 1) {
ctx.fillStyle = currentColor.color;
function pt(p1, p2, p3, t) {
return Math.round(
p3 + Math.pow(1 - t, 2) * (p1 - p3) + Math.pow(t, 2) * (p2 - p3)
);
}
let tNum = 32;
let lastXt = x1;
let lastYt = y1;
if (stepNum === 1) {
//after defining x1y1
actionLine(
x1,
y1,
state.mox,
state.moy,
currentColor,
onScreenCTX,
currentMode,
scale
);
} else if (stepNum === 2) {
// after defining x2y2
//onscreen preview curve
for (let i = 0; i < tNum; i++) {
let xt = pt(x1, x2, state.mox, i / tNum);
let yt = pt(y1, y2, state.moy, i / tNum);
actionLine(
lastXt,
lastYt,
xt,
yt,
currentColor,
onScreenCTX,
currentMode,
scale
);
lastXt = xt;
lastYt = yt;
onScreenCTX.fillRect(
(xt * state.ratio) / zoom,
(yt * state.ratio) / zoom,
scale,
scale
);
}
actionLine(
lastXt,
lastYt,
x2,
y2,
currentColor,
onScreenCTX,
currentMode,
scale
);
} else if (stepNum === 3) {
//curve after defining x3y3
for (let i = 0; i < tNum; i++) {
let xt = pt(x1, x2, x3, i / tNum);
let yt = pt(y1, y2, y3, i / tNum);
ctx.fillRect(xt, yt, scale, scale);
}
//store control points for timeline
source = offScreenCVS.toDataURL();
renderImage();
}
}

With each control point added, the tool does different things, showing a preview line from the first control point to the mouse, then a preview curve after the second control point is chosen. Right now I’m drawing a straight line between points (tNum). This is the problem I’ll be seeking to solve in my next post. Connecting the dots with straight lines is problematic for a number of reasons, but mainly it just looks bad, causing some zigzagging and at a larger scale, it no longer looks like a curve.

--

--