Skip to content

Bezier Curves and Following a Path

A Bezier curve is a weighted blend of a few control points, where the weights depend on one parameter. Two points give a straight line, three give an arc, four give the cubic that every vector drawing tool and every animation editor is built on.

Then there is the thing that catches everybody, and it is the reason this Section exists rather than being a footnote: the parameter is not distance. It runs from 0 to 1 and it looks like “how far along”. Walk it at a constant rate and your sprite speeds up and slows down depending on where the handles happen to sit. On one of the curves below, t=0.5t = 0.5 has covered only 32.94%32.94\% of the ground.

B(t)=(1−t)2P0+2(1−t)t P1+t2P2B(t) = (1-t)^2 P_0 + 2(1-t)t\,P_1 + t^2 P_2 B(t)=(1−t)3P0+3(1−t)2t P1+3(1−t)t2P2+t3P3B(t) = (1-t)^3 P_0 + 3(1-t)^2t\,P_1 + 3(1-t)t^2 P_2 + t^3 P_3

Quadratic and cubic. The coefficients are 1:2:11{:}2{:}1 and 1:3:3:11{:}3{:}3{:}1 — Pascal’s triangle, which is where the pattern for any degree comes from.

The property worth holding on to is that the weights always sum to 1, at every tt, and the build sweeps a thousand values of tt to confirm it. That single fact explains most of a Bezier’s behaviour: the curve can never leave the box its control points sit in, it cannot loop off somewhere surprising, and moving one control point moves the curve in a way you can predict.

It also explains the part beginners trip on. At t=0.5t = 0.5 a quadratic’s weights are 0.250.25, 0.50.5, 0.250.25 — so the middle point gets half the vote and the two ends split the other half. The curve therefore lands exactly halfway between the handle and the midpoint of the chord, which on the demo’s quadratic is 2.62.6 units short of the handle itself. A handle is something you steer with, not a waypoint you route through.

De Casteljau: The Same Answer, With Only lerp

Section titled “De Casteljau: The Same Answer, With Only lerp”

There is a second way to evaluate a Bezier, and it needs no formula at all. Take each neighbouring pair of control points and find the point tt of the way between them. You now have one fewer point. Repeat until one is left. That is the point on the curve.

Nothing but lerp, all the way down — the function from Section 4.2, applied three times, then twice, then once. It is worth knowing even though the polynomial is faster, for three reasons: it works at any degree without a new formula, it is better behaved numerically, and it hands you two other operations for free (below).

A Bezier with draggable handles, built by repeated lerping
Drag the points, or pick a preset, then sweep t with the construction lines showing.
The code that draws it src/lib/gamedev/demos/2d/path.scene.ts
/** A Bezier with draggable handles, and de Casteljau's repeated lerping built in front of you. */
import {
  makeCanvas2D,
  addDragTargets,
  dot as fillDot,
  label,
  line,
} from "../canvas2d.ts";
// From `controls.ts`, not `ui.ts`: the latter imports Three.js and this track must not.
import {
  addButtonRow,
  addCheckbox,
  addReadout,
  addSlider,
} from "../controls.ts";
import { deCasteljau } from "../../../gamedev2d/bezier2d.ts";
import {
  PRESETS,
  VIEW,
  clampToBounds,
  outline,
  screenOf,
  worldOf,
} from "./path-shared.ts";
import type { MountFn } from "../runner.ts";

const CURVE = "#58a6ff";
const HULL = "#3b4552";
const ANCHOR = "#7ee787";
const HANDLE = "#d2a8ff";
const BUILD = "#f0883e";
const HERE = "#ffd866";
const TEXT = "#9198a1";
const DIM = "#636c76";

const mount: MountFn = (el) => {
  const { ctx, canvas, clear } = makeCanvas2D(el, VIEW.height);

  let points = PRESETS[0].points.map((p) => ({ ...p }));

  const show = addReadout(el);
  const note = addReadout(el);
  const time = addSlider(el, "t", 0, 1, 0.35, draw, "", 0.01);
  const building = addCheckbox(
    el,
    "show de Casteljau's construction (the lerps that find the point)",
    true,
    draw,
  );
  const setActive = addButtonRow(
    el,
    PRESETS.map((preset, index) => ({
      label: preset.name,
      apply: () => {
        points = preset.points.map((p) => ({ ...p }));
        setActive(index);
        draw();
      },
    })),
  );
  setActive(0);

  // Dragging is the natural way in; the preset buttons above are the keyboard path to the same shapes.
  const stopDragging = addDragTargets(
    canvas,
    () => points.map(screenOf),
    (index, x, y) => {
      points[index] = clampToBounds(worldOf(x, y));
      draw();
    },
  );

  function draw() {
    clear();
    const t = time();
    const at = (p: { x: number; y: number }) => screenOf(p);

    // The control polygon. The curve leans toward it and stays inside it, never crossing out.
    for (let i = 1; i < points.length; i += 1) {
      line(ctx, at(points[i - 1]), at(points[i]), HULL, {
        dashed: true,
        width: 1,
      });
    }

    // The curve itself.
    ctx.save();
    ctx.strokeStyle = CURVE;
    ctx.lineWidth = 2.4;
    ctx.beginPath();
    outline(points).forEach((p, i) => {
      const q = at(p);
      if (i === 0) ctx.moveTo(q.x, q.y);
      else ctx.lineTo(q.x, q.y);
    });
    ctx.stroke();
    ctx.restore();

    /* Every intermediate level of the construction. The last pair before the answer is the tangent
       line, which is why the facing direction in the next scene is not a separate idea. */
    const { levels, point } = deCasteljau(points, t);
    if (building()) {
      levels.slice(1, -1).forEach((level) => {
        for (let i = 1; i < level.length; i += 1) {
          line(ctx, at(level[i - 1]), at(level[i]), BUILD, { width: 1.2 });
        }
        for (const p of level) {
          const q = at(p);
          fillDot(ctx, q.x, q.y, 3, BUILD);
        }
      });
    }

    // The control points. Ends the curve passes through, handles it only leans toward.
    points.forEach((p, i) => {
      const q = at(p);
      const isEnd = i === 0 || i === points.length - 1;
      fillDot(ctx, q.x, q.y, isEnd ? 6 : 5, isEnd ? ANCHOR : HANDLE);
      label(ctx, `P${i}`, q.x + 9, q.y - 8, isEnd ? ANCHOR : HANDLE);
    });

    const here = at(point);
    fillDot(ctx, here.x, here.y, 5.5, HERE);
    label(ctx, `t = ${t.toFixed(2)}`, here.x + 10, here.y + 16, HERE);

    label(
      ctx,
      "drag any point \u00b7 green ends are on the curve, purple handles are not",
      12,
      VIEW.height - 10,
      DIM,
    );

    const degree = points.length - 1;
    show(
      `${degree === 2 ? "quadratic" : "cubic"}, ${points.length} control points \u00b7 ` +
        `${levels.length - 1} rounds of lerping, ${levels.slice(1).reduce((n, level) => n + level.length, 0)} lerps in all`,
    );
    note(
      building()
        ? "each orange level is the one above it, lerped by t \u2014 the last orange segment is the curve's tangent"
        : `the curve touches P0 and P${degree} and leans toward the rest, which is why the handles are a shape you steer with rather than points you route through`,
    );
  }

  draw();

  return stopDragging;
};

export default mount;

The two are genuinely different arithmetic — a loop of lerps against a weighted sum of four terms — so the build sweeping ten thousand values of tt and finding them agree to 10−1310^{-13} means both are right, rather than that one was copied from the other.

The derivative of a degree-nn Bezier is nn times a degree-(n−1)(n-1) Bezier built on the differences between neighbouring control points. So the derivative of a cubic is a quadratic on three vectors, which is a pleasant thing rather than a chore. The build checks it against a finite difference of the curve itself at two hundred points on five different curves, so it is the derivative rather than a plausible rearrangement.

One atan2 on that gives the facing angle, and a car follows a road instead of sliding along it sideways. Two consequences worth knowing:

The tangent at an endpoint points straight at the neighbouring handle. That is what makes handles feel steerable — you are aiming the departure, not just bending the middle.

The tangent’s length is the speed, in parameter terms, and it is not constant. Which brings us to the problem.

The same curve travelled by t, and travelled by distance
Watch where the blue marks bunch up, then tick the box and watch them even out.
The code that draws it src/lib/gamedev/demos/2d/travel.scene.ts
/** A sprite travelling the same curve, facing along the tangent, stepping by t or by distance. */
import {
  makeCanvas2D,
  addDragTargets,
  arrow,
  dot as fillDot,
  label,
  line,
} from "../canvas2d.ts";
// From `controls.ts`, not `ui.ts`: the latter imports Three.js and this track must not.
import {
  addButtonRow,
  addCheckbox,
  addReadout,
  addSlider,
} from "../controls.ts";
import {
  arcTable,
  facingAt,
  fractionAtT,
  pointAt,
  tAtFraction,
} from "../../../gamedev2d/bezier2d.ts";
// The facing arrow is drawn from the angle rather than from the tangent, so a wrong angle shows.
import { directionFromAngle } from "../../../gamedev2d/angles2d.ts";
import {
  MARKS,
  PRESETS,
  VIEW,
  clampToBounds,
  markPoints,
  outline,
  screenOf,
  travelReport,
  worldOf,
} from "./path-shared.ts";
import type { MountFn } from "../runner.ts";

const CURVE = "#3b4552";
const MARK = "#58a6ff";
const SPRITE = "#7ee787";
const FACING = "#ffd866";
const BROKEN = "#ff7b72";
const HANDLE = "#636c76";
const DIM = "#636c76";

const mount: MountFn = (el) => {
  const { ctx, canvas, clear } = makeCanvas2D(el, VIEW.height);

  let points = PRESETS[1].points.map((p) => ({ ...p }));

  const show = addReadout(el);
  const note = addReadout(el);
  const progress = addSlider(el, "progress", 0, 1, 0.5, draw, "", 0.01);
  const byDistance = addCheckbox(
    el,
    "step by distance instead of by t (constant speed)",
    false,
    draw,
  );
  const setActive = addButtonRow(
    el,
    PRESETS.map((preset, index) => ({
      label: preset.name,
      apply: () => {
        points = preset.points.map((p) => ({ ...p }));
        setActive(index);
        draw();
      },
    })),
  );
  setActive(1);

  // Dragging explores; the presets are the keyboard path to the configurations worth seeing.
  const stopDragging = addDragTargets(
    canvas,
    () => points.map(screenOf),
    (index, x, y) => {
      points[index] = clampToBounds(worldOf(x, y));
      draw();
    },
  );

  function draw() {
    clear();
    const s = progress();
    const even = byDistance();
    const at = (p: { x: number; y: number }) => screenOf(p);
    const table = arcTable(points);
    // The one line that separates the two behaviours, and the whole Section is about it.
    const t = even ? tAtFraction(table, s) : s;

    // The path, drawn dim: it is the stage here rather than the subject.
    ctx.save();
    ctx.strokeStyle = CURVE;
    ctx.lineWidth = 2;
    ctx.beginPath();
    outline(points).forEach((p, i) => {
      const q = at(p);
      if (i === 0) ctx.moveTo(q.x, q.y);
      else ctx.lineTo(q.x, q.y);
    });
    ctx.stroke();
    ctx.restore();

    for (const p of points) {
      const q = at(p);
      fillDot(ctx, q.x, q.y, 3, HANDLE);
    }

    /* Eleven marks at equal steps of whichever quantity is selected. Bunched marks are slow going and
       spread marks are fast, so the picture is the speed profile rather than a claim about it. */
    for (const p of markPoints(points, even)) {
      const q = at(p);
      fillDot(ctx, q.x, q.y, 2.6, MARK);
    }

    const here = at(pointAt(points, t));
    const angle = facingAt(points, t);
    if (angle === null) {
      // No tangent means no facing. Drawn as an absence rather than as an arrow pointing east.
      fillDot(ctx, here.x, here.y, 7, BROKEN);
      label(ctx, "no facing here", here.x + 12, here.y - 10, BROKEN);
    } else {
      const direction = directionFromAngle(angle);
      arrow(
        ctx,
        here,
        { x: here.x + direction.x * 38, y: here.y - direction.y * 38 },
        FACING,
        2.4,
      );
      fillDot(ctx, here.x, here.y, 6, SPRITE);
    }

    label(
      ctx,
      `${MARKS} marks at equal steps of ${even ? "distance" : "t"} \u00b7 drag any grey point`,
      12,
      VIEW.height - 10,
      DIM,
    );

    const r = travelReport(points, even);
    show(
      `length ${r.length.toFixed(2)} \u00b7 ` +
        `${
          even
            ? `${(s * 100).toFixed(0)}% of the distance is at t = ${t.toFixed(3)}`
            : `t = ${t.toFixed(2)} is only at ${(fractionAtT(points, t, table) * 100).toFixed(1)}% of the distance`
        } \u00b7 widest gap over narrowest ${r.evenness.toFixed(3)}`,
    );
    note(
      even
        ? "the marks are evenly spaced, so equal time now means equal ground covered"
        : `the marks bunch where the curve is slow and spread where it is fast \u2014 the fastest part of this ` +
            `curve moves ${r.spread.toFixed(2)} times as fast as the slowest`,
    );
  }

  draw();

  return stopDragging;
};

export default mount;

Eleven marks at equal steps of tt. They bunch where the curve is slow and spread where it is fast, so the picture is the speed profile rather than a claim about one. Then tick the box, and the same eleven marks are placed at equal steps of distance.

On the “slow start, fast finish” curve:

ParameterDistance covered
t=0.25t=0.2516.49%16.49\%
t=0.5t=0.532.94%32.94\%
t=0.75t=0.7558.12%58.12\%

Half the length is not reached until t=0.6829t = 0.6829. The fastest point on that curve moves 3.409 times as fast as the slowest, and the widest gap between marks is 3.1183.118 times the narrowest. Step by distance instead and that ratio drops to 1.0091.009.

There is no closed form for the length of a cubic Bezier. This is not a gap in the library — it is a fact about the integral, and every engine samples instead. Walk the curve in small straight steps, accumulate the distances, and keep the running total against each tt. Then to travel at a constant speed, ask for distance = speed * elapsed and binary-search the table for the tt that sits there.

Sampling is a choice with a price, so the price is measured rather than waved at:

SamplesLength error, in pixels
81.15541.1554
160.28870.2887
640.01800.0180
2560.00110.0011

Quartering with every doubling, which is what a second-order method should do. The default of 256256 is accurate to a thousandth of a pixel at this scale — built once when the path changes, not per frame.

The table is read in both directions, and the build checks that the round trip is exact to 10−1210^{-12} across all four curves. That is a real check rather than an identity: one direction is a binary search and the other a forward interpolation, so they could disagree.

Two Things The Construction Gives You Free

Section titled “Two Things The Construction Gives You Free”

Splitting. The first point of every level of de Casteljau’s construction is the left half’s control points; the last point of every level, reversed, is the right half’s. Two curves of the same degree that together retrace the original exactly — checked at three split points on five curves, to 10−1210^{-12}. Useful for trimming a path, for drawing only the part already travelled, and for subdividing until a curve is flat enough to draw as line segments.

Degree elevation. Add a control point without changing the shape at all:

Qi=in+1Pi−1+(1−in+1)PiQ_i = \frac{i}{n+1}P_{i-1} + \left(1 - \frac{i}{n+1}\right)P_i

So every quadratic is also a cubic. The demo’s quadratic becomes a cubic whose handles sit exactly a third of the way along each side. That is practically useful rather than trivia: a tool that only speaks cubics can accept a quadratic, and a chain of mixed degrees can be made uniform before anything else touches it.

Chaining: A Shared Endpoint Is Not A Smooth Join

Section titled “Chaining: A Shared Endpoint Is Not A Smooth Join”

One cubic is rarely enough for a route. Chaining them is where a subtle bug lives, because the obvious check passes.

Two curves sharing an endpoint, and still cornering
The code src/lib/gamedev/demos/2d/joins.ts
/** Sharing an endpoint is not smoothness, and the one line that turns the first into the second. */
import {
  elevate,
  joinsSmoothly,
  meetsAt,
  seamAngle,
  smoothedNext,
} from "../../../gamedev2d/bezier2d.ts";
import { JOIN_A, JOIN_NAIVE, PRESETS } from "./path-shared.ts";
import type { Demo } from "../runner.ts";

const demo: Demo = (log) => {
  log(
    "meetsAt(first, second)",
    meetsAt(JOIN_A, JOIN_NAIVE),
    "they share an endpoint, which is all most code checks",
  );
  log(
    "joinsSmoothly(first, second)",
    joinsSmoothly(JOIN_A, JOIN_NAIVE),
    "and yet",
  );
  log(
    "seamAngle(first, second)",
    `${seamAngle(JOIN_A, JOIN_NAIVE).toFixed(2)}\u00B0`,
    "the corner a sprite would swing its facing through in one frame",
  );

  // The fix: reflect the previous handle through the shared point. P1' = 2S - P(n-1).
  const fixed = smoothedNext(JOIN_A, JOIN_NAIVE);
  log(
    "smoothedNext(first, second)[1]",
    `(${fixed[1].x}, ${fixed[1].y})`,
    `was (${JOIN_NAIVE[1].x}, ${JOIN_NAIVE[1].y}) - only the one handle moved`,
  );
  log(
    "seamAngle after mirroring",
    `${seamAngle(JOIN_A, fixed).toFixed(10)}\u00B0`,
    "smooth to the last decimal, and nothing else about the curve changed",
  );

  // And the degree elevation that lets a chain of mixed degrees be made uniform.
  log(
    "elevate(quadratic)",
    elevate(PRESETS[2].points)
      .map((p) => `(${p.x.toFixed(3)}, ${p.y.toFixed(3)})`)
      .join(" "),
    "four control points now, and the identical curve",
  );
};

export default demo;
meetsAt(first, second) → true // they share an endpoint, which is all most code checks
joinsSmoothly(first, second) → false // and yet
seamAngle(first, second) → 90.00° // the corner a sprite would swing its facing through in one frame
smoothedNext(first, second)[1] → (3.5, -1.5) // was (3.25, 2.25) - only the one handle moved
seamAngle after mirroring → 0.0000000000° // smooth to the last decimal, and nothing else about the curve changed
elevate(quadratic) → (-5.000, -2.200) (-1.667, 1.267) (1.667, 1.267) (5.000, -2.200) // four control points now, and the identical curve

Those two curves share an endpoint exactly. meetsAt returns true. And the seam still turns through a right angle — the first curve arrives at −45°-45° and the second leaves at +45°+45°, so a sprite crossing it swings its facing 90°90° in a single frame.

Sharing a point is C0C^0 continuity. Smooth means the tangents on either side point the same way, and the fix is one line:

P1′=2S−Pn−1P_1' = 2S - P_{n-1}

Reflect the previous curve’s last handle through the shared point. That is the whole trick behind every smooth path editor — dragging a handle on one side of a node and watching its twin swing round on the other side is this arithmetic, and nothing else. It moves exactly one point, leaves the shared point and the rest of the second curve untouched, and running it on an already-smooth seam changes nothing.

Put a handle exactly on its own endpoint — which is what happens when somebody wants a “straight” start — and the tangent there is the zero vector. The build confirms it is exactly zero, not merely small.

atan2(0, 0) returns 00. No error, no NaN, no warning. So the sprite snaps to facing east for one sample and then jumps back to its real heading of about 33°33°. That is why facingAt returns null rather than an angle:

  • It is a hole of exactly one point. By t=10−5t = 10^{-5} the facing is defined and perfectly ordinary.
  • Which is precisely what makes it hard to find: a one-frame flick at the start of a movement, with nothing in the console.
  • The build also confirms every well-formed curve has a facing at all 501501 sampled values of tt, so the null is a real case rather than the normal one.

Handle it by carrying the previous frame’s facing forward, or by reading the tangent slightly ahead. Do not let atan2 answer a question that has no answer.

source The blend, de Casteljau, the tangent, and the arc-length table src/lib/gamedev2d/bezier2d.ts 398 lines
/**
 * Bezier curves, and the thing about them that catches everybody: **`t` is not distance**.
 *
 * A Bezier is a weighted blend of a handful of control points, where the weights depend on one
 * parameter. Two control points give a straight line; three give a parabola-like arc; four give the
 * cubic that every vector drawing program and every animation tool is built on.
 *
 * The parameter runs from 0 to 1 and it is tempting to read it as "how far along". It is not. Walk `t`
 * at a constant rate and the sprite speeds up and slows down depending on where the control points
 * sit - which is fine if you wanted that and a bug if you wanted a patrol route. The fix is an
 * arc-length table, and it is the second half of this file.
 */
import { displacement, movedBy, scaled, type Point } from "./vectors2d.ts";
import { length } from "./length2d.ts";
import { angleOf } from "./angles2d.ts";

export type { Point };

/**
 * A quadratic, written out in Bernstein form so the weights are visible.
 *
 * $$B(t) = (1-t)^2 P_0 + 2(1-t)t\,P_1 + t^2 P_2$$
 *
 * The three weights always sum to 1, which is the reason the curve is trapped inside the triangle its
 * control points make and can never wander off somewhere surprising. It is also why `P_1` pulls the
 * curve without the curve ever reaching it: at $t = 0.5$ the weights are $0.25$, $0.5$, $0.25$, so the
 * middle point gets half the vote and the ends split the rest.
 */
export function quadraticAt(p0: Point, p1: Point, p2: Point, t: number): Point {
  const u = 1 - t;
  return {
    x: u * u * p0.x + 2 * u * t * p1.x + t * t * p2.x,
    y: u * u * p0.y + 2 * u * t * p1.y + t * t * p2.y,
  };
}

/**
 * A cubic, which is the one you will actually use.
 *
 * $$B(t) = (1-t)^3 P_0 + 3(1-t)^2t\,P_1 + 3(1-t)t^2 P_2 + t^3 P_3$$
 *
 * Four points is the sweet spot: enough freedom to make an S-shape, few enough that a human can place
 * them. Two of them are the ends the curve passes through and two are handles it only leans toward.
 */
export function cubicAt(
  p0: Point,
  p1: Point,
  p2: Point,
  p3: Point,
  t: number,
): Point {
  const u = 1 - t;
  return {
    x:
      u * u * u * p0.x +
      3 * u * u * t * p1.x +
      3 * u * t * t * p2.x +
      t * t * t * p3.x,
    y:
      u * u * u * p0.y +
      3 * u * u * t * p1.y +
      3 * u * t * t * p2.y +
      t * t * t * p3.y,
  };
}

/**
 * De Casteljau's algorithm: the same answer, built by **repeated lerping**, at any degree.
 *
 * Take each neighbouring pair of control points, find the point `t` of the way between them, and you
 * have one fewer point. Repeat until one is left; that is the point on the curve. Nothing but `lerp`,
 * which is why it is worth knowing even though the polynomial above is faster - it needs no formula
 * per degree, it is numerically better behaved, and it hands you the split for free.
 *
 * Returns every level, because the intermediate points **are** the picture: the last pair before the
 * answer is the curve's tangent line, which is the fact the facing direction below rests on.
 */
export function deCasteljau(
  points: readonly Point[],
  t: number,
): { levels: Point[][]; point: Point } {
  const levels: Point[][] = [points.slice()];
  let current = points.slice();
  while (current.length > 1) {
    const next: Point[] = [];
    for (let i = 0; i < current.length - 1; i += 1) {
      next.push(
        movedBy(
          current[i],
          scaled(displacement(current[i], current[i + 1]), t),
        ),
      );
    }
    levels.push(next);
    current = next;
  }
  return { levels, point: current[0] };
}

/** The point on a curve of any degree. De Casteljau, so one function covers quadratic and cubic. */
export function pointAt(points: readonly Point[], t: number): Point {
  return deCasteljau(points, t).point;
}

/**
 * The tangent: the direction the curve is heading, and how fast.
 *
 * The derivative of a degree-$n$ Bezier is $n$ times a degree-$(n-1)$ Bezier built on the **differences
 * between neighbouring control points**. So the derivative of a cubic is a quadratic on three vectors,
 * which is a pleasant thing rather than a chore.
 *
 * Its **length is the speed** in parameter terms, and that length is not constant. That is the whole
 * problem this file's second half solves.
 */
export function tangentAt(points: readonly Point[], t: number): Point {
  const n = points.length - 1;
  const differences: Point[] = [];
  for (let i = 0; i < n; i += 1) {
    differences.push(displacement(points[i], points[i + 1]));
  }
  return scaled(pointAt(differences, t), n);
}

/** How fast the curve moves per unit of `t`. Varies along the curve, which is the point. */
export function speedAt(points: readonly Point[], t: number): number {
  return length(tangentAt(points, t));
}

/**
 * The angle to face while travelling the curve, or `null` where there is no answer.
 *
 * Facing along the tangent is what makes a car follow a road rather than slide along it sideways, and
 * it is one `atan2` away once you have the derivative.
 *
 * **The `null` is not defensive.** Put two control points in the same place - a handle dropped exactly
 * on its endpoint, which is what happens when someone wants a "straight" start - and the tangent there
 * is the zero vector. `atan2(0, 0)` returns 0 without complaining, so the sprite snaps to facing east
 * for one frame and then jumps back. No error, no NaN, just a flick that is very hard to find.
 */
export function facingAt(points: readonly Point[], t: number): number | null {
  const tangent = tangentAt(points, t);
  return length(tangent) < 1e-9 ? null : angleOf(tangent);
}

/**
 * Split a curve in two at `t`, giving two curves that together are the original.
 *
 * Free from de Casteljau: the first point of every level is the left half's control points, and the
 * last point of every level is the right half's. Useful for trimming a path, for drawing only the part
 * already travelled, and for subdividing until a curve is flat enough to draw as lines.
 */
export function splitAt(
  points: readonly Point[],
  t: number,
): { left: Point[]; right: Point[] } {
  const { levels } = deCasteljau(points, t);
  return {
    left: levels.map((level) => level[0]),
    right: levels.map((level) => level[level.length - 1]).reverse(),
  };
}

/**
 * Raise a curve's degree by one without changing its shape at all.
 *
 * $$Q_i = \frac{i}{n+1}P_{i-1} + \left(1 - \frac{i}{n+1}\right)P_i$$
 *
 * Which means **every quadratic is also a cubic**, and that is practically useful rather than trivia:
 * a tool that only speaks cubics can accept a quadratic, and a chain of mixed degrees can be made
 * uniform before anything else touches it.
 */
export function elevate(points: readonly Point[]): Point[] {
  const n = points.length - 1;
  const raised: Point[] = [points[0]];
  for (let i = 1; i <= n; i += 1) {
    const k = i / (n + 1);
    raised.push({
      x: k * points[i - 1].x + (1 - k) * points[i].x,
      y: k * points[i - 1].y + (1 - k) * points[i].y,
    });
  }
  raised.push(points[n]);
  return raised;
}

// ---- Arc length: turning `t` into distance -------------------------------------------------

/**
 * A table of cumulative distance against `t`, built by walking the curve in small straight steps.
 *
 * There is no closed form for the length of a cubic Bezier, so this is not laziness - sampling is what
 * everyone does, including the engines. More samples is more accurate and the error falls off fast;
 * `LENGTH_SAMPLES` below is the default and the build prices what it costs.
 */
export type ArcTable = {
  /** `t` at each sample. */
  ts: number[];
  /** Distance from the start to that sample. */
  distances: number[];
  /** Total length, which is the last entry. */
  total: number;
};

/** Enough samples that the length is accurate to well under a pixel on any curve this size. */
export const LENGTH_SAMPLES = 256;

export function arcTable(
  points: readonly Point[],
  samples = LENGTH_SAMPLES,
): ArcTable {
  const ts: number[] = [0];
  const distances: number[] = [0];
  let previous = pointAt(points, 0);
  let total = 0;
  for (let i = 1; i <= samples; i += 1) {
    const t = i / samples;
    const current = pointAt(points, t);
    total += length(displacement(previous, current));
    ts.push(t);
    distances.push(total);
    previous = current;
  }
  return { ts, distances, total };
}

/** The curve's length, sampled. Shorthand for the total in the table. */
export function curveLength(
  points: readonly Point[],
  samples = LENGTH_SAMPLES,
): number {
  return arcTable(points, samples).total;
}

/**
 * The `t` that sits a given **distance** along the curve. The inverse of the table, by binary search.
 *
 * This is the function that makes a sprite travel at a constant speed. Ask for distance
 * `speed * elapsed` rather than for `t = elapsed / duration` and the motion stops depending on where
 * the control points happen to be.
 */
export function tAtDistance(table: ArcTable, distance: number): number {
  const target = Math.min(Math.max(distance, 0), table.total);
  let lo = 0;
  let hi = table.distances.length - 1;
  while (hi - lo > 1) {
    const mid = (lo + hi) >> 1;
    if (table.distances[mid] <= target) lo = mid;
    else hi = mid;
  }
  const span = table.distances[hi] - table.distances[lo];
  // A zero-length segment means the curve stood still here, so either end of it will do.
  const within = span < 1e-12 ? 0 : (target - table.distances[lo]) / span;
  return table.ts[lo] + within * (table.ts[hi] - table.ts[lo]);
}

/** The `t` a given fraction of the **length** along, which is what "halfway" ought to mean. */
export function tAtFraction(table: ArcTable, fraction: number): number {
  return tAtDistance(table, fraction * table.total);
}

/** The point a given fraction of the length along. Constant speed, in one call. */
export function pointAtFraction(
  points: readonly Point[],
  table: ArcTable,
  fraction: number,
): Point {
  return pointAt(points, tAtFraction(table, fraction));
}

/**
 * The distance from the start to a given `t`, read off the table. The forward direction.
 *
 * Note this is not the inverse of `tAtDistance` by construction - it is the same table read the other
 * way - so the two agreeing on a round trip is a real check rather than an identity, and the build
 * runs it.
 */
export function distanceAtT(table: ArcTable, t: number): number {
  const clamped = Math.min(Math.max(t, 0), 1);
  const position = clamped * (table.ts.length - 1);
  const lo = Math.floor(position);
  const hi = Math.min(lo + 1, table.distances.length - 1);
  return (
    table.distances[lo] +
    (position - lo) * (table.distances[hi] - table.distances[lo])
  );
}

/**
 * What fraction of the **length** has been covered at a given `t`.
 *
 * Here so the mismatch is a number the build can assert rather than a claim in a caption. At $t = 0.5$
 * on a curve with evenly spread control points this is close to a half; pull one handle out and it is
 * not, and that difference is the Section.
 */
export function fractionAtT(
  points: readonly Point[],
  t: number,
  table?: ArcTable,
): number {
  const built = table ?? arcTable(points);
  return built.total < 1e-12 ? 0 : distanceAtT(built, t) / built.total;
}

/** The ratio of the fastest point on the curve to the slowest. 1 would mean `t` already was distance. */
export function speedSpread(points: readonly Point[], samples = 2000): number {
  let fastest = 0;
  let slowest = Infinity;
  for (let i = 0; i <= samples; i += 1) {
    const speed = speedAt(points, i / samples);
    fastest = Math.max(fastest, speed);
    slowest = Math.min(slowest, speed);
  }
  return slowest < 1e-9 ? Infinity : fastest / slowest;
}

// ---- Chaining: joining curves without a visible corner -------------------------------------

/** Two curves meet at a point if one ends where the next begins. The easy half, and not enough. */
export function meetsAt(a: readonly Point[], b: readonly Point[]): boolean {
  const end = a[a.length - 1];
  return Math.abs(end.x - b[0].x) < 1e-9 && Math.abs(end.y - b[0].y) < 1e-9;
}

/**
 * Do two curves join **smoothly**, or is there a corner at the seam?
 *
 * Sharing an endpoint is $C^0$ continuity and it is what everybody checks. It is not enough: the
 * tangents on either side of the seam can point in completely different directions, and the result is
 * a visible kink and a sprite that snaps its facing in one frame.
 *
 * Smooth means the incoming and outgoing tangents point the **same way**. Equal length as well as
 * direction is $C^1$; direction alone is $G^1$, which looks smooth and is what a path usually needs.
 */
export function joinsSmoothly(
  a: readonly Point[],
  b: readonly Point[],
  tolerance = 1e-6,
): boolean {
  if (!meetsAt(a, b)) return false;
  const incoming = tangentAt(a, 1);
  const outgoing = tangentAt(b, 0);
  const la = length(incoming);
  const lb = length(outgoing);
  if (la < 1e-9 || lb < 1e-9) return false;
  const cross = (incoming.x * outgoing.y - incoming.y * outgoing.x) / (la * lb);
  const dot = (incoming.x * outgoing.x + incoming.y * outgoing.y) / (la * lb);
  return Math.abs(cross) < tolerance && dot > 0;
}

/**
 * The corner in degrees at a seam, so "there is a kink" becomes a measurement.
 *
 * Zero is smooth. Anything else is the angle a sprite's facing would jump through in a single frame.
 */
export function seamAngle(a: readonly Point[], b: readonly Point[]): number {
  const incoming = tangentAt(a, 1);
  const outgoing = tangentAt(b, 0);
  const difference = angleOf(outgoing) - angleOf(incoming);
  const wrapped = Math.atan2(Math.sin(difference), Math.cos(difference));
  return (wrapped * 180) / Math.PI;
}

/**
 * Move the next curve's first handle so the seam is smooth: **reflect the previous one through the
 * shared point**.
 *
 * $$P_1' = 2S - P_{n-1}$$
 *
 * One line, and it is the whole trick behind every smooth path editor. Dragging a handle on one side
 * of a node moves its twin on the other side, and this is the arithmetic doing it. Everything else
 * about the second curve is left alone.
 */
export function smoothedNext(
  a: readonly Point[],
  b: readonly Point[],
): Point[] {
  const shared = a[a.length - 1];
  const previousHandle = a[a.length - 2];
  const mirrored = {
    x: 2 * shared.x - previousHandle.x,
    y: 2 * shared.y - previousHandle.y,
  };
  return [shared, mirrored, ...b.slice(2)];
}

/** Every point along a chain of curves, for drawing it as one path. */
export function chainPoints(
  curves: ReadonlyArray<readonly Point[]>,
  perCurve = 48,
): Point[] {
  const out: Point[] = [];
  curves.forEach((curve, index) => {
    for (let i = index === 0 ? 0 : 1; i <= perCurve; i += 1) {
      out.push(pointAt(curve, i / perCurve));
    }
  });
  return out;
}
  • Patrol routes and scripted camera moves, which is where the arc-length table earns its keep.
  • Section 4.2’s easing, composed with this: ease the distance along the path, not the parameter, or you get two speed curves multiplied together and neither one is what you asked for.
  • Section 2.2’s atan2, doing the facing, with the degenerate case above as its edge.
  • Section 3.2’s local space, since a path defined in a parent’s frame moves with the parent.
  • The capstone, where a moving platform follows a path and has to arrive on time.