7
de Casteljau: Bezier Construction
drag a control point
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.