Bitspace: Building a Cubic Bézier Curve editor from scratch

emilwidlund

emilwidlund

image.png

I've come across Bézier Curves many times in my creative life – most often in concepts like animations & easing configurations, where Bézier Curves comes up frequently. They are quite similar to Photoshop's & Lightroom's curve adjustment editors too, although they use a variant called Catmull-Rom Splines.

I thought it would be quite fun to build a small cubic bézier editor for Bitspace. It's a neat way of constructing custom easing functions which then can be used in combination with linear interpolation.

Introduction to Cubic Bézier Curves

There are a couple of different variants of Bézier Curves. The two most common are Cubic & Quadratic. The cubic version uses four control points P0, P1, P2, P3 to shape the curve, while the quadratic version only uses three - P0, P1, P2.

The cubic variant will therefore give us a smoother curvature.

image.png

By changing the positional relationships between P1 and P2 you can shape the curvature between P0 & P3. Cubic Bézier Curves have the following format: .70, 0, .30, 1 which specifies P1 & P2 coordinates. The first two numbers are X & Y coordinates for P1 and the last two numbers are coordinates for P2. The bounded coordinate limits are clamped 0 -> 1.

ezgif-7-97ba3a1e60.gif

The implementation

I've divided up the implementation into three steps;

  1. A basic UI with segments
  2. Draggable handles for control points P1 & P2
  3. Rendering the curve

A UI with segments

I decided to use SVG elements to construct the editor, as it has very handy features for rendering the curve later on. As we want to create some segments in out editor UI, we can use simple SVG paths for that as well.

Let's first define our React component. I'll be using Tailwind in this example to make the styling easier.

export interface CubicBezierProps {
    size: number;
}

export const CubicBezier = ({ size }: CubicBezierProps) => {
    const subdivision = Math.round(size / 4);
    const subdivisionColor = `#f1f5f9`;
    const subdivisionWidth = 2;

    return (
        <div className="relative h-[226px] w-full">
            <svg
                className="rounded-3xl"
                viewBox={`0 0 ${size} ${size}`}
                height={size}
                width={size}
                xmlns="http://www.w3.org/2000/svg"
            >
                {/** Subdivision Lines */}
                {Array.from({ length: 3 }).map((_, i) => (
                    <line
                        key={i}
                        x1={subdivision * (i + 1)}
                        x2={subdivision * (i + 1)}
                        y1={0}
                        y2={size}
                        stroke={subdivisionColor}
                        strokeWidth={subdivisionWidth}
                    />
                ))}
                {Array.from({ length: 3 }).map((_, i) => (
                    <line
                        key={i}
                        x1={0}
                        x2={size}
                        y1={subdivision * (i + 1)}
                        y2={subdivision * (i + 1)}
                        stroke={subdivisionColor}
                        strokeWidth={subdivisionWidth}
                    />
                ))}
            </svg>
        </div>
    );
};

That should yield a simple square with vertical & horizontal subdivision segments. These segments will be helpful when the user is arranging the control points.

image.png

Draggable handles for control points P1 & P2

Let's build some interactivity into this editor. As you might know from my previous posts, I'm a really big fan of the react-draggable package when dealing with draggable elements.

The idea here is that we create two draggable elements for each of the the "inner" control points P1 & P2. We won't bother with P0 & P3 as they will be fixed in the lower-left & upper-right corners respectively.

We can start by extending the props model of our editor.

export interface CubicBezierProps {
    points: [number, number, number, number];
    size: number;
    onChange: (points: [number, number, number, number]) => void;
}

points refers to the P1 & P2 control point's X & Y coordinates. We expect these to be normalized between 0 -> 1. When we drag the handles, we'll fire the onChange function with updated coordinates, which we expect will trickle down as points again from the parent.

As the control points are normalized, we need to scale them up and map to the size we're defining in our props.

export const CubicBezier = ({ points: controls, onChange, size = 222 }: CubicBezierProps) => {
    const [points, setPoints] = useState<[number, number, number, number]>(controls);

    const [xy1, xy2] = useMemo(() => {
        const [x1, y1, x2, y2] = points;

        const xy1 = { x: x1 * size, y: size - y1 * size };
        const xy2 = { x: x2 * size, y: size - y2 * size };

        return [xy1, xy2];
    }, [points]);

    // Call the onChange-handler whenever we have new points
    useEffect(() => {
        onChange(points);
    }, [points]);

    // Our subdivision logic
    ...
}

We should now have what we need to render handles for the P1 & P2 control points. But first, let's create a simple ControlPoint-component which uses a Draggable component from react-draggable.

const ControlPoint = ({ editorSize, ...props }: Partial<DraggableProps> & { editorSize: number }) => {
    return (
        <Draggable
            {...props}
            // Needed to cancel the Draggable nature of Bitspace nodes
            onMouseDown={e => e.stopPropagation()}
            bounds={{
                left: 0,
                right: editorSize,
                top: 0,
                bottom: editorSize
            }}
        >
            <div className="w-2 h-2 rounded-full bg-[#94a3b8] absolute -top-1 -left-1" />
        </Draggable>
    );
};

This <ControlPoint /> component will take DraggableProps as base props, along with the editor's size. We also make sure to set the bounds property to restrict the draggable control point within the editors bounds.

Now it's time to use this component in our editor. Put two <ControlPoint /> components under the SVG element which we use for subdivision lines.

...
            </svg>
            <ControlPoint
                position={xy1}
                onDrag={(e, data) => {
                    setPoints(points => [data.x / size, 1 - data.y / size, points[2], points[3]]);
                }}
                editorSize={size}
            />
            <ControlPoint
                position={xy2}
                onDrag={(e, data) => {
                    setPoints(points => [points[0], points[1], data.x / size, 1 - data.y / size]);
                }}
                editorSize={size}
            />
        </div>
    )
}

Let's go ahead and draw two paths from P0 & P3 to P1 & P2 respectively.

...
const subdivision = Math.round(size / 4);
const subdivisionColor = `#f1f5f9`;
const subdivisionWidth = 2;
// Define a color for the handles
const handleColor = `#94a3b8`;
...

{/** Put these lines inside the SVG element */}
<line x1={0} x2={xy1.x} y1={size} y2={xy1.y} stroke={handleColor} strokeWidth="2" />
<line x1={size} x2={xy2.x} y1={0} y2={xy2.y} stroke={handleColor} strokeWidth="2" />

image.png

Rendering the curve

Great work! We've arrived at the last step of this Editor implementation. And it's a very trivial step actually. We have everything we need to render our Cubic Bézier Curve.

And we're quite lucky – the HTML SVG implementation has a very handy way of rendering these curves for us using the C command.

<path
    d={`M 0 ${size} C ${xy1.x} ${xy1.y}, ${xy2.x} ${xy2.y}, ${size} 0`}
    stroke="#0000ff"
    strokeWidth={2}
    fill="none"
/>

Yes, it's actually THAT easy. You should now have a fully functioning Cubic Bézier Curve editor which emits updated control point values whenever you drag around the handles.

ezgif-7-97ba3a1e60.gif

The full source code can be found below if you didn't follow along.

import { useEffect, useMemo, useState } from 'react';
import Draggable, { DraggableProps } from 'react-draggable';

export interface CubicBezierProps {
    points: [number, number, number, number];
    size: number;
    onChange: (points: [number, number, number, number]) => void;
}

export const CubicBezier = ({ points: controls, onChange, size = 222 }: CubicBezierProps) => {
    const [points, setPoints] = useState<[number, number, number, number]>(controls);

    const [xy1, xy2] = useMemo(() => {
        const [x1, y1, x2, y2] = points;

        const xy1 = { x: x1 * size, y: size - y1 * size };
        const xy2 = { x: x2 * size, y: size - y2 * size };

        return [xy1, xy2];
    }, [points]);

    useEffect(() => {
        onChange(points);
    }, [points]);

    const subdivision = Math.round(size / 4);
    const subdivisionColor = `#f1f5f9`;
    const subdivisionWidth = 2;
    const handleColor = `#94a3b8`;

    return (
        <div className="relative h-[226px] w-full">
            <svg
                className="rounded-3xl"
                viewBox={`0 0 ${size} ${size}`}
                height={size}
                width={size}
                xmlns="http://www.w3.org/2000/svg"
            >
                {/** Subdivision Lines */}
                {Array.from({ length: 3 }).map((_, i) => (
                    <line
                        key={i}
                        x1={subdivision * (i + 1)}
                        x2={subdivision * (i + 1)}
                        y1={0}
                        y2={size}
                        stroke={subdivisionColor}
                        strokeWidth={subdivisionWidth}
                    />
                ))}
                {Array.from({ length: 3 }).map((_, i) => (
                    <line
                        key={i}
                        x1={0}
                        x2={size}
                        y1={subdivision * (i + 1)}
                        y2={subdivision * (i + 1)}
                        stroke={subdivisionColor}
                        strokeWidth={subdivisionWidth}
                    />
                ))}

                {/** Handle Lines */}
                <line x1={0} x2={xy1.x} y1={size} y2={xy1.y} stroke={handleColor} strokeWidth="2" />
                <line x1={size} x2={xy2.x} y1={0} y2={xy2.y} stroke={handleColor} strokeWidth="2" />

                {/** Curve */}
                <path
                    d={`M 0 ${size} C ${xy1.x} ${xy1.y}, ${xy2.x} ${xy2.y}, ${size} 0`}
                    stroke="#0000ff"
                    strokeWidth={2}
                    fill="none"
                />
            </svg>
            <ControlPoint
                position={xy1}
                onDrag={(e, data) => {
                    setPoints(points => [data.x / size, 1 - data.y / size, points[2], points[3]]);
                }}
                editorSize={size}
            />
            <ControlPoint
                position={xy2}
                onDrag={(e, data) => {
                    setPoints(points => [points[0], points[1], data.x / size, 1 - data.y / size]);
                }}
                editorSize={size}
            />
        </div>
    );
};

const ControlPoint = ({ editorSize, ...props }: Partial<DraggableProps> & { editorSize: number }) => {
    return (
        <Draggable
            {...props}
            onMouseDown={e => e.stopPropagation()}
            bounds={{
                left: 0,
                right: editorSize,
                top: 0,
                bottom: editorSize
            }}
        >
            <div className="w-2 h-2 rounded-full bg-[#94a3b8] absolute -top-1 -left-1" />
        </Draggable>
    );
};

Thank you so much for reading this small piece on Bitspace's Cubic Bézier Curve editor implementation. I can't wait to let you all play around with it when the Early Alpha opens up.

Until next time!