7

de Casteljau: Bezier Construction

drag a control point

A Bezier curve of degree is determined by control points . The de Casteljau algorithm evaluates it at parameter by repeated linear interpolation: set , then for each level compute

After reductions, a single point remains — that is the Bezier curve at . Equivalently, , the Bernstein form, but de Casteljau is the numerically stable, geometric way to see it: every interior point is just a weighted average. Here (six control points). The parameter sweeps from to over four seconds, holds, and restarts. Gray segments fanning down the levels show the intermediate interpolants; the orange dot at the bottom of the fan is , and its trajectory traces the blue curve. Drag a control point to reshape the curve. Inspired by Gabriel Peyré's classical-geometry visualizations.

idle
238 lines · vanilla
view source
// de Casteljau's algorithm for a degree-5 Bezier curve.
// Given control points P_0..P_5, level-k points are obtained by
// linear interpolation between adjacent level-(k-1) points:
//   Q^{k}_i(t) = (1 - t) * Q^{k-1}_i + t * Q^{k-1}_{i+1}
// After 5 reductions, a single point remains — the Bezier value B(t).
// Parameter t sweeps 0 -> 1 over 4 s, holds 1 s, restarts.

const DEGREE = 5;             // 6 control points => degree 5
const N_CTRL = DEGREE + 1;
const SWEEP_DURATION = 4.0;   // seconds for t: 0 -> 1
const HOLD_DURATION = 1.0;    // seconds at t = 1
const TRAIL_SAMPLES = 240;    // resolution of the traced curve
const PICK_RADIUS = 26;       // px hit radius for dragging a control point

// Aesthetic — desaturated paper background, gray polygon, warm-gray
// intermediate levels, blue curve trace, orange final point.
const BG = '#0f1014';
const POLY_COLOR = 'rgba(170, 172, 180, 0.55)';
const POLY_DOT = 'rgba(210, 212, 220, 0.85)';
const CURVE_COLOR = 'rgba(96, 165, 250, 0.95)';     // blue
const FINAL_DOT = 'rgba(251, 146, 60, 1.0)';        // orange
const FINAL_DOT_GLOW = 'rgba(251, 146, 60, 0.30)';
const HOVER_RING = 'rgba(251, 146, 60, 0.55)';
const DRAG_RING = 'rgba(251, 146, 60, 0.9)';

// Intermediate level colors — warmer / lighter as we go deeper.
// Levels 1..4 (0-indexed: idx 0..3). Level 5 collapses to the final dot.
const LEVEL_COLORS = [
  'rgba(150, 150, 158, 0.55)', // level 1 — dim cool gray
  'rgba(180, 168, 158, 0.65)', // level 2 — warmer gray
  'rgba(210, 184, 152, 0.78)', // level 3 — warmer still
  'rgba(238, 198, 140, 0.92)', // level 4 — pale amber
];
const LEVEL_DOT_RADIUS = [4.5, 3.8, 3.2, 2.6];
const LEVEL_LINE_W = [1.6, 1.4, 1.2, 1.05];

let W, H;
let ctrl;                  // Float64Array length 2*N_CTRL
let phase;                 // 'sweep' or 'hold'
let phaseTime;             // seconds elapsed in current phase
let trail;                 // Float64Array length 2*TRAIL_SAMPLES — traced curve points
let trailCount;            // how many valid samples so far this sweep
let dragIndex;             // -1 if not dragging, else 0..5
let hoverIndex;            // -1 if no control point hovered
let prevMouseDown;
let firstFrame;

function defaultControlPoints() {
  // S-curve layout that's pleasant at any aspect ratio.
  // Normalized in [0.1, 0.9] x [0.2, 0.8].
  const xs = [0.08, 0.22, 0.40, 0.60, 0.78, 0.92];
  const ys = [0.78, 0.20, 0.78, 0.22, 0.80, 0.30];
  for (let i = 0; i < N_CTRL; i++) {
    ctrl[i * 2]     = xs[i] * W;
    ctrl[i * 2 + 1] = ys[i] * H;
  }
}

function init({ ctx, width, height }) {
  W = width;
  H = height;
  ctrl = new Float64Array(N_CTRL * 2);
  defaultControlPoints();
  trail = new Float64Array(TRAIL_SAMPLES * 2);
  trailCount = 0;
  phase = 'sweep';
  phaseTime = 0;
  dragIndex = -1;
  hoverIndex = -1;
  prevMouseDown = false;
  firstFrame = true;

  ctx.fillStyle = BG;
  ctx.fillRect(0, 0, W, H);
}

// Run de Casteljau at parameter t, writing each level into out[level][i*2..].
// out is an array of Float64Arrays sized [N_CTRL, N_CTRL-1, ..., 1].
function deCasteljau(t, out) {
  // Level 0 = control points.
  for (let i = 0; i < N_CTRL; i++) {
    out[0][i * 2]     = ctrl[i * 2];
    out[0][i * 2 + 1] = ctrl[i * 2 + 1];
  }
  for (let lvl = 1; lvl <= DEGREE; lvl++) {
    const prev = out[lvl - 1];
    const cur  = out[lvl];
    const m = N_CTRL - lvl; // number of points at this level
    const omt = 1 - t;
    for (let i = 0; i < m; i++) {
      const ax = prev[i * 2];
      const ay = prev[i * 2 + 1];
      const bx = prev[(i + 1) * 2];
      const by = prev[(i + 1) * 2 + 1];
      cur[i * 2]     = omt * ax + t * bx;
      cur[i * 2 + 1] = omt * ay + t * by;
    }
  }
}

function bezierAt(t, scratch) {
  deCasteljau(t, scratch);
  return [scratch[DEGREE][0], scratch[DEGREE][1]];
}

// Pre-allocate the level scratch once and reuse.
let levelScratch = null;
function getLevelScratch() {
  if (levelScratch) return levelScratch;
  const arr = [];
  for (let lvl = 0; lvl <= DEGREE; lvl++) {
    arr.push(new Float64Array((N_CTRL - lvl) * 2));
  }
  levelScratch = arr;
  return arr;
}

function nearestControlPoint(mx, my) {
  let best = -1;
  let bestD2 = PICK_RADIUS * PICK_RADIUS;
  for (let i = 0; i < N_CTRL; i++) {
    const dx = ctrl[i * 2] - mx;
    const dy = ctrl[i * 2 + 1] - my;
    const d2 = dx * dx + dy * dy;
    if (d2 < bestD2) {
      bestD2 = d2;
      best = i;
    }
  }
  return best;
}

function rebuildTrailUpTo(tCap, scratch) {
  // Recompute the full traced curve from 0..tCap so it shows the entire
  // current sweep up to the present parameter value.
  const n = Math.max(2, Math.floor(TRAIL_SAMPLES * Math.max(0, Math.min(1, tCap))));
  trailCount = n;
  if (n < 2) return;
  for (let i = 0; i < n; i++) {
    const tt = (i / (n - 1)) * tCap;
    const p = bezierAt(tt, scratch);
    trail[i * 2]     = p[0];
    trail[i * 2 + 1] = p[1];
  }
}

function tick({ ctx, dt, width, height, input }) {
  if (width !== W || height !== H) {
    // Rescale control points proportionally on resize.
    const sx = width / W;
    const sy = height / H;
    for (let i = 0; i < N_CTRL; i++) {
      ctrl[i * 2]     *= sx;
      ctrl[i * 2 + 1] *= sy;
    }
    W = width;
    H = height;
  }

  // --- Input ---
  const mx = input.mouseX;
  const my = input.mouseY;
  const md = input.mouseDown;

  // Hover detection (only when not actively dragging).
  if (dragIndex < 0) {
    hoverIndex = nearestControlPoint(mx, my);
  }

  // Drag pickup: mousedown edge over a control point.
  if (md && !prevMouseDown) {
    const pick = nearestControlPoint(mx, my);
    if (pick >= 0) dragIndex = pick;
  }
  if (!md) dragIndex = -1;

  if (dragIndex >= 0) {
    ctrl[dragIndex * 2]     = mx;
    ctrl[dragIndex * 2 + 1] = my;
  }

  prevMouseDown = md;

  // --- Animate t ---
  phaseTime += dt;
  let t;
  if (phase === 'sweep') {
    t = phaseTime / SWEEP_DURATION;
    if (t >= 1) {
      t = 1;
      phase = 'hold';
      phaseTime = 0;
    }
  } else {
    t = 1;
    if (phaseTime >= HOLD_DURATION) {
      phase = 'sweep';
      phaseTime = 0;
      t = 0;
    }
  }

  const scratch = getLevelScratch();

  // Rebuild trace each frame (cheap at this size) so dragging while paused
  // also keeps the curve consistent.
  rebuildTrailUpTo(t, scratch);

  // Compute the levels at the current t for drawing.
  deCasteljau(t, scratch);

  // --- Render ---
  // Solid clear — clean, no motion smear (Peyré-style).
  ctx.fillStyle = BG;
  ctx.fillRect(0, 0, W, H);

  // 1. Control polygon (straight segments).
  ctx.lineWidth = 1.2;
  ctx.strokeStyle = POLY_COLOR;
  ctx.beginPath();
  ctx.moveTo(ctrl[0], ctrl[1]);
  for (let i = 1; i < N_CTRL; i++) {
    ctx.lineTo(ctrl[i * 2], ctrl[i * 2 + 1]);
  }
  ctx.stroke();

  // 2. Bezier trace so far.
  if (trailCount >= 2) {
    ctx.lineWidth = 2.2;
    ctx.strokeStyle = CURVE_COLOR;
    ctx.lineCap = 'round';
    ctx.lineJoin = 'round';
    ctx.beginPath();
    ctx.moveTo(trail[0], trail[1]);
    for (let i = 1; i < trailCount; i++) {
      ctx.lineTo(trail[i * 2], trail[i * 2 + 1]);
    }
    ctx.stroke();
  }

  // 3. Intermediate-level segments + dots (levels 1..DEGREE-1).
  // Level idx in LEVEL_COLORS = lvl - 1 (level 1 -> idx 0, ..., level 4 -> idx 3).
  for (let lvl = 1; lvl < DEGREE; lvl++) {
    const layer = scratch[lvl];
    const m = N_CTRL - lvl; // points at this level
    const colorIdx = lvl - 1;
    ctx.strokeStyle = LEVEL_COLORS[colorIdx];
    ctx.lineWidth = LEVEL_LINE_W[colorIdx];
    ctx.beginPath();
    ctx.moveTo(layer[0], layer[1]);
    for (let i = 1; i < m; i++) {
      ctx.lineTo(layer[i * 2], layer[i * 2 + 1]);
    }
    ctx.stroke();

    // Dots at level vertices.
    ctx.fillStyle = LEVEL_COLORS[colorIdx];
    const r = LEVEL_DOT_RADIUS[colorIdx];
    for (let i = 0; i < m; i++) {
      ctx.beginPath();
      ctx.arc(layer[i * 2], layer[i * 2 + 1], r, 0, Math.PI * 2);
      ctx.fill();
    }
  }

  // 4. Control points (level 0) on top of polygon.
  for (let i = 0; i < N_CTRL; i++) {
    const x = ctrl[i * 2];
    const y = ctrl[i * 2 + 1];

    // Hover / drag ring.
    if (i === dragIndex) {
      ctx.strokeStyle = DRAG_RING;
      ctx.lineWidth = 2;
      ctx.beginPath();
      ctx.arc(x, y, 11, 0, Math.PI * 2);
      ctx.stroke();
    } else if (i === hoverIndex && dragIndex < 0) {
      ctx.strokeStyle = HOVER_RING;
      ctx.lineWidth = 1.5;
      ctx.beginPath();
      ctx.arc(x, y, 10, 0, Math.PI * 2);
      ctx.stroke();
    }

    ctx.fillStyle = POLY_DOT;
    ctx.beginPath();
    ctx.arc(x, y, 5, 0, Math.PI * 2);
    ctx.fill();
  }

  // 5. Final moving point (level DEGREE = a single point).
  const fx = scratch[DEGREE][0];
  const fy = scratch[DEGREE][1];

  // Soft glow.
  ctx.fillStyle = FINAL_DOT_GLOW;
  ctx.beginPath();
  ctx.arc(fx, fy, 12, 0, Math.PI * 2);
  ctx.fill();

  // Solid dot.
  ctx.fillStyle = FINAL_DOT;
  ctx.beginPath();
  ctx.arc(fx, fy, 5.5, 0, Math.PI * 2);
  ctx.fill();

  // Drain any click events so they don't accumulate.
  if (input.consumeClicks) input.consumeClicks();

  firstFrame = false;
}

Comments (0)

Log in to comment.