15

Mean Curvature Flow

click to restart

Mean curvature flow evolves a closed planar curve by pushing each point inward along the inward unit normal at speed equal to the local curvature:

Discretely, for an arc-length parameterized polyline the curvature vector is the second difference , so one explicit step is โ€” a curve Laplacian. The Gage-Hamilton-Grayson theorem says any embedded simple closed curve first becomes convex under this flow, then shrinks self-similarly to a round point in finite time. Here a circle is perturbed by random Fourier modes 3 through 9, sampled at 400 vertices, and resampled by arc length each frame. Inspired by Gabriel Peyrรฉ's curve-evolution visualizations.

idle
183 lines ยท vanilla
view source
// Mean curvature flow on a closed planar curve.
// Each point moves inward along the inward normal at speed equal to
// the local curvature: d/dt gamma = kappa * N.
// Gage-Hamilton-Grayson: any embedded simple closed curve flows to a
// round point.

const N_POINTS = 400;
const STEP_H = 0.35;       // per-substep time scale (pixels^2-ish)
const SUBSTEPS = 2;        // explicit Euler substeps per frame
const RESET_AREA_FRAC = 0.0015; // reset when area shrinks below this fraction of W*H
const TRAIL_COUNT = 16;    // how many faded past outlines to draw
const TRAIL_INTERVAL = 6;  // store an outline every N frames

let W, H, cx, cy, R0;
let pts;                   // Float64Array length 2*N_POINTS, [x0,y0,x1,y1,...]
let work;                  // scratch buffer for new positions
let trails;                // ring buffer of Float64Array snapshots
let trailHead;
let trailFilled;
let frameCounter;
let resetCooldown;         // frames before reset-check is allowed
let prevMouseDown;

function rand() {
  return Math.random();
}

function buildNoisyCurve() {
  // Circle radius R0 perturbed by sum of cos modes 3..9 with random phases & amps.
  const modes = [3, 4, 5, 6, 7, 8, 9];
  const phases = modes.map(() => rand() * Math.PI * 2);
  const amps = modes.map(() => (rand() * 0.5 - 0.25) * R0 * 0.18); // up to ~18% of R0 per mode
  // also a small global wobble
  const baseScale = 0.85 + rand() * 0.2;

  for (let i = 0; i < N_POINTS; i++) {
    const theta = (i / N_POINTS) * Math.PI * 2;
    let r = R0 * baseScale;
    for (let m = 0; m < modes.length; m++) {
      r += amps[m] * Math.cos(modes[m] * theta + phases[m]);
    }
    // safety: keep radius positive
    if (r < R0 * 0.15) r = R0 * 0.15;
    pts[i * 2] = cx + r * Math.cos(theta);
    pts[i * 2 + 1] = cy + r * Math.sin(theta);
  }
}

function curveArea() {
  // Shoelace
  let a = 0;
  for (let i = 0; i < N_POINTS; i++) {
    const j = (i + 1) % N_POINTS;
    const x1 = pts[i * 2];
    const y1 = pts[i * 2 + 1];
    const x2 = pts[j * 2];
    const y2 = pts[j * 2 + 1];
    a += x1 * y2 - x2 * y1;
  }
  return Math.abs(a) * 0.5;
}

function curvePerimeter() {
  let s = 0;
  for (let i = 0; i < N_POINTS; i++) {
    const j = (i + 1) % N_POINTS;
    const dx = pts[j * 2] - pts[i * 2];
    const dy = pts[j * 2 + 1] - pts[i * 2 + 1];
    s += Math.sqrt(dx * dx + dy * dy);
  }
  return s;
}

function stepMCF(h) {
  // Discrete Laplacian: Delta p_i ~= p_{i-1} - 2 p_i + p_{i+1}.
  // For an arc-length parameterized curve this equals kappa * N (inward).
  // So move p_i <- p_i + h * Delta p_i  (this is the implicit "inward kappa N" direction).
  for (let i = 0; i < N_POINTS; i++) {
    const im = (i - 1 + N_POINTS) % N_POINTS;
    const ip = (i + 1) % N_POINTS;
    const lx = pts[im * 2] - 2 * pts[i * 2] + pts[ip * 2];
    const ly = pts[im * 2 + 1] - 2 * pts[i * 2 + 1] + pts[ip * 2 + 1];
    work[i * 2] = pts[i * 2] + h * lx;
    work[i * 2 + 1] = pts[i * 2 + 1] + h * ly;
  }
  // swap
  const tmp = pts;
  pts = work;
  work = tmp;
}

function resample() {
  // Resample N_POINTS evenly along arc length to keep numerical Laplacian stable.
  const perim = curvePerimeter();
  if (!isFinite(perim) || perim < 1e-3) return;
  const seg = perim / N_POINTS;

  // build cumulative arc lengths
  const cum = new Float64Array(N_POINTS + 1);
  cum[0] = 0;
  for (let i = 0; i < N_POINTS; i++) {
    const j = (i + 1) % N_POINTS;
    const dx = pts[j * 2] - pts[i * 2];
    const dy = pts[j * 2 + 1] - pts[i * 2 + 1];
    cum[i + 1] = cum[i] + Math.sqrt(dx * dx + dy * dy);
  }

  let k = 0;
  for (let i = 0; i < N_POINTS; i++) {
    const target = i * seg;
    while (k < N_POINTS && cum[k + 1] < target) k++;
    const t = (target - cum[k]) / Math.max(1e-9, cum[k + 1] - cum[k]);
    const a = k % N_POINTS;
    const b = (k + 1) % N_POINTS;
    work[i * 2] = pts[a * 2] + t * (pts[b * 2] - pts[a * 2]);
    work[i * 2 + 1] = pts[a * 2 + 1] + t * (pts[b * 2 + 1] - pts[a * 2 + 1]);
  }
  const tmp = pts;
  pts = work;
  work = tmp;
}

function snapshotTrail() {
  const snap = new Float64Array(N_POINTS * 2);
  snap.set(pts);
  trails[trailHead] = snap;
  trailHead = (trailHead + 1) % TRAIL_COUNT;
  if (trailHead === 0) trailFilled = true;
}

function clearTrails() {
  for (let i = 0; i < TRAIL_COUNT; i++) trails[i] = null;
  trailHead = 0;
  trailFilled = false;
}

function reset() {
  buildNoisyCurve();
  clearTrails();
  frameCounter = 0;
  resetCooldown = 30;
}

function init({ ctx, width, height }) {
  W = width;
  H = height;
  cx = W * 0.5;
  cy = H * 0.5;
  R0 = Math.min(W, H) * 0.36;

  pts = new Float64Array(N_POINTS * 2);
  work = new Float64Array(N_POINTS * 2);
  trails = new Array(TRAIL_COUNT).fill(null);
  trailHead = 0;
  trailFilled = false;
  frameCounter = 0;
  resetCooldown = 30;
  prevMouseDown = false;

  buildNoisyCurve();

  ctx.fillStyle = '#08090c';
  ctx.fillRect(0, 0, W, H);
}

function drawCurve(ctx, arr, strokeStyle, lineWidth) {
  ctx.beginPath();
  ctx.moveTo(arr[0], arr[1]);
  for (let i = 1; i < N_POINTS; i++) {
    ctx.lineTo(arr[i * 2], arr[i * 2 + 1]);
  }
  ctx.closePath();
  ctx.strokeStyle = strokeStyle;
  ctx.lineWidth = lineWidth;
  ctx.stroke();
}

function tick({ ctx, width, height, input }) {
  if (width !== W || height !== H) {
    W = width;
    H = height;
    cx = W * 0.5;
    cy = H * 0.5;
    R0 = Math.min(W, H) * 0.36;
  }

  // Click to reset
  if (input && typeof input.consumeClicks === 'function') {
    if (input.consumeClicks() > 0) {
      reset();
    }
  }

  // Fade background slightly each frame for a soft afterimage
  ctx.globalCompositeOperation = 'source-over';
  ctx.fillStyle = '#08090c';
  ctx.fillRect(0, 0, W, H);

  // Advance flow
  for (let s = 0; s < SUBSTEPS; s++) {
    stepMCF(STEP_H);
  }
  // Resample occasionally to keep arc length uniform
  if (frameCounter % 3 === 0) resample();

  // Periodically store a trail snapshot
  if (frameCounter % TRAIL_INTERVAL === 0) snapshotTrail();

  // Draw trails (oldest -> newest, faint)
  const count = trailFilled ? TRAIL_COUNT : trailHead;
  for (let i = 0; i < count; i++) {
    const idx = (trailHead - 1 - i + TRAIL_COUNT) % TRAIL_COUNT;
    const arr = trails[idx];
    if (!arr) continue;
    const age = i / TRAIL_COUNT; // 0 = freshest trail, 1 = oldest
    const alpha = (1 - age) * 0.18 + 0.02;
    drawCurve(ctx, arr, `rgba(220, 225, 235, ${alpha.toFixed(3)})`, 1);
  }

  // Draw current curve crisp white
  drawCurve(ctx, pts, 'rgba(245, 247, 252, 0.95)', 1.6);

  // Reset condition: area below threshold
  if (resetCooldown > 0) {
    resetCooldown--;
  } else {
    const area = curveArea();
    if (area < RESET_AREA_FRAC * W * H || !isFinite(area)) {
      reset();
    }
  }

  frameCounter++;
}

Comments (0)

Log in to comment.