3 portraits placed along a cubic Bézier curve, sliced

An advanced version of the Bézier placement demo that slices each image into vertical strips and warps them individually. This allows the images to bend and follow the curve's path more fluidly.

INPUT
INPUT — 3 portraits placed along a cubic Bézier curve, sliced
OUTPUT
OUTPUT — 3 portraits placed along a cubic Bézier curve, sliced
JavaScript
// 3 portraits placed along a cubic Bézier curve, sliced
// demo_bezier_placer.js
//!OUTPUT: OUTPUT
// demo_bezier_placer_sliced_final.js
// Crops strips from the image, then warps each strip individually to follow the curve.

const CANVAS_W = 1200;
const CANVAS_H = 600;
const NUM_IMAGES = 3;  // How many complete images to place
const SLICES_PER_IMAGE = 15;  // Cut each image into this many strips
const TILE_W = 250; // Image width on canvas
const TILE_H = 166; // Image height on canvas
const BLEND_OVER = BlendMode.Over;

// Cubic Bézier control points
const P0 = { x: 80,   y: 300 };
const P1 = { x: 380,  y: 30  };
const P2 = { x: 820,  y: 570 };
const P3 = { x: 1120, y: 300 };

// ── Bézier math ──────────────────────────────────────────────────────────────

function bezierPoint(t) {
  const mt = 1 - t, mt2 = mt * mt, mt3 = mt2 * mt;
  const t2 = t * t,  t3  = t2 * t;
  return {
    x: mt3*P0.x + 3*mt2*t*P1.x + 3*mt*t2*P2.x + t3*P3.x,
    y: mt3*P0.y + 3*mt2*t*P1.y + 3*mt*t2*P2.y + t3*P3.y,
  };
}

function bezierTangent(t) {
  const mt = 1 - t;
  const dx = 3*mt*mt*(P1.x-P0.x) + 6*mt*t*(P2.x-P1.x) + 3*t*t*(P3.x-P2.x);
  const dy = 3*mt*mt*(P1.y-P0.y) + 6*mt*t*(P2.y-P1.y) + 3*t*t*(P3.y-P2.y);
  const len = Math.sqrt(dx*dx + dy*dy);
  return len < 1e-6 ? { x: 1, y: 0 } : { x: dx/len, y: dy/len };
}

// ── Arc-length LUT ───────────────────────────────────────────────────────────

const LUT_STEPS = 400;
const arcLUT = [{ t: 0, dist: 0 }];
let totalLen = 0;
{
  let prev = bezierPoint(0);
  for (let i = 1; i <= LUT_STEPS; i++) {
    const t  = i / LUT_STEPS;
    const pt = bezierPoint(t);
    const dx = pt.x - prev.x, dy = pt.y - prev.y;
    totalLen += Math.sqrt(dx*dx + dy*dy);
    arcLUT.push({ t, dist: totalLen });
    prev = pt;
  }
}

function getT(d) {
  if (d <= 0) { return 0; }
  if (d >= totalLen) { return 1; }
  let lo = 0, hi = arcLUT.length - 1;
  while (hi - lo > 1) {
    const mid = (lo + hi) >> 1;
    if (arcLUT[mid].dist < d) { lo = mid; } else { hi = mid; }
  }
  const s = (d - arcLUT[lo].dist) / (arcLUT[hi].dist - arcLUT[lo].dist);
  return arcLUT[lo].t + s * (arcLUT[hi].t - arcLUT[lo].t);
}

// ── Homography math ──────────────────────────────────────────────────────────

function gaussElim(A, b) {
  const n = 8;
  const M = A.map((row, i) => [...row, b[i]]);
  for (let col = 0; col < n; col++) {
    let maxRow = col;
    for (let row = col + 1; row < n; row++) {
      if (Math.abs(M[row][col]) > Math.abs(M[maxRow][col])) { maxRow = row; }
    }
    const tmp = M[col]; M[col] = M[maxRow]; M[maxRow] = tmp;
    for (let row = 0; row < n; row++) {
      if (row === col) { continue; }
      const f = M[row][col] / M[col][col];
      for (let j = col; j <= n; j++) { M[row][j] -= f * M[col][j]; }
    }
  }
  return M.map((row, i) => row[n] / row[i]);
}

function mat3mul(A, B) {
  const [a0,a1,a2, a3,a4,a5, a6,a7,a8] = A;
  const [b0,b1,b2, b3,b4,b5, b6,b7,b8] = B;
  return [
    a0*b0+a1*b3+a2*b6,  a0*b1+a1*b4+a2*b7,  a0*b2+a1*b5+a2*b8,
    a3*b0+a4*b3+a5*b6,  a3*b1+a4*b4+a5*b7,  a3*b2+a4*b5+a5*b8,
    a6*b0+a7*b3+a8*b6,  a6*b1+a7*b4+a8*b7,  a6*b2+a7*b5+a8*b8,
  ];
}

function computeH_fwd(srcPts, dstPts, canvasW, canvasH) {
  const sw = srcPts[1].x;
  const sh = srcPts[2].y;
  const sn = srcPts.map(p => ({ x: p.x / sw, y: p.y / sh }));
  const dn = dstPts.map(p => ({ x: p.x / canvasW, y: p.y / canvasH }));

  const A = [], b = [];
  for (let i = 0; i < 4; i++) {
    const { x: xs, y: ys } = sn[i];
    const { x: xd, y: yd } = dn[i];
    A.push([xs, ys, 1, 0,  0,  0, -xd*xs, -xd*ys]); b.push(xd);
    A.push([0,  0,  0, xs, ys, 1, -yd*xs, -yd*ys]); b.push(yd);
  }
  const h = gaussElim(A, b);
  const H_n = [h[0],h[1],h[2], h[3],h[4],h[5], h[6],h[7], 1.0];
  const normSrc   = [1/sw, 0, 0, 0, 1/sh, 0, 0, 0, 1];
  const denormDst = [canvasW, 0, 0, 0, canvasH, 0, 0, 0, 1];
  return mat3mul(denormDst, mat3mul(H_n, normSrc));
}

// ── Render strips along curve ────────────────────────────────────────────────

const src = Engine.loadImage(INPUT);
const srcW = src.width;
const srcH = src.height;
const sliceWidthPx = srcW / SLICES_PER_IMAGE;  // Width of each strip in source
const sliceWidthCanvas = TILE_W / SLICES_PER_IMAGE;  // Width on canvas

// White canvas background
const canvas = Engine.createImage(1, 1);
canvas.setPixel(px(0, 0), new Pixel(1, 1, 1, 1));
canvas.resize(CANVAS_W, CANVAS_H);

// Curve range where we place images
const startDist = 0.10 * totalLen;
const endDist = 0.90 * totalLen;
const curveLengthPerImage = (endDist - startDist) / NUM_IMAGES;
const stepSize = curveLengthPerImage / SLICES_PER_IMAGE;  // Arc-length per strip

for (let imgIdx = 0; imgIdx < NUM_IMAGES; imgIdx++) {
  const imageStartDist = startDist + (imgIdx * curveLengthPerImage);

  for (let slice = 0; slice < SLICES_PER_IMAGE; slice++) {
    // Arc-length distance for this strip's position on the curve
    const sliceDist = imageStartDist + (slice * stepSize);

    // Get curve position and tangent at this point
    const t = getT(sliceDist);
    const pos = bezierPoint(t);
    const tang = bezierTangent(t);
    const N = { x: tang.y, y: -tang.x };  // Normal (perpendicular)

    // Crop the strip from the original image
    const stripX = slice * sliceWidthPx;
    const strip = src.clone().crop(stripX, 0, sliceWidthPx, srcH);

    // Source corners for this strip (0 to strip width, 0 to strip height)
    const stripW = strip.width;
    const stripH = strip.height;
    const srcPts = [
      { x: 0,        y: 0       },  // TL
      { x: stripW-1, y: 0       },  // TR
      { x: stripW-1, y: stripH-1},  // BR
      { x: 0,        y: stripH-1},  // BL
    ];

    // Destination quad on canvas for this strip
    // The strip is centered on the curve point
    const halfSliceWidth = sliceWidthCanvas / 2;
    const halfHeight = TILE_H / 2;

    const dstPts = [
      { x: pos.x - halfSliceWidth * tang.x + halfHeight * N.x,  // TL
        y: pos.y - halfSliceWidth * tang.y + halfHeight * N.y },
      { x: pos.x + halfSliceWidth * tang.x + halfHeight * N.x,  // TR
        y: pos.y + halfSliceWidth * tang.y + halfHeight * N.y },
      { x: pos.x + halfSliceWidth * tang.x - halfHeight * N.x,  // BR
        y: pos.y + halfSliceWidth * tang.y - halfHeight * N.y },
      { x: pos.x - halfSliceWidth * tang.x - halfHeight * N.x,  // BL
        y: pos.y - halfSliceWidth * tang.y - halfHeight * N.y },
    ];

    // Compute homography for this strip (strip source → canvas destination)
    const H_fwd = computeH_fwd(srcPts, dstPts, CANVAS_W, CANVAS_H);

    // Warp the strip onto the canvas
    const warpedStrip = strip.warpPerspective(CANVAS_W, CANVAS_H, H_fwd);
    canvas.blendAt(warpedStrip, px(0, 0), 1.0, BLEND_OVER);

    // Cleanup
    warpedStrip.free();
    strip.free();
  }
}

// ── Overlay: Bézier curve ────────────────────────────────────────────────────

const ov = Engine.createCanvas(CANVAS_W, CANVAS_H);

function circleMarker(cv, cx, cy, r, color, strokeW) {
  const p = Engine.createPath().circle(cx, cy, r);
  cv.fill(color).drawPath(p);
  if (strokeW > 0) { cv.pen("#ffffffff", strokeW).drawPath(p, true); }
  p.free();
}

// The curve
ov.pen(new Pixel(0.10, 0.10, 0.10, 0.85), 3.0);
ov.drawPathStr(`M ${P0.x},${P0.y} C ${P1.x},${P1.y} ${P2.x},${P2.y} ${P3.x},${P3.y}`, true);

// Control lines
ov.pen(new Pixel(0.92, 0.42, 0.12, 0.75), 1.5);
ov.drawPathStr(`M ${P0.x},${P0.y} L ${P1.x},${P1.y}`, true);
ov.drawPathStr(`M ${P3.x},${P3.y} L ${P2.x},${P2.y}`, true);

// Control points
for (const p of [P0, P3]) { circleMarker(ov, p.x, p.y, 7, new Pixel(0.18, 0.40, 0.92, 1.0), 1.5); }
for (const p of [P1, P2]) { circleMarker(ov, p.x, p.y, 5, new Pixel(0.92, 0.42, 0.12, 0.90), 1.5); }

const ovImg = ov.toImage();
canvas.blendAt(ovImg, px(0, 0), 1.0, BLEND_OVER);
ovImg.free();
ov.free();

canvas.save(OUTPUT);

// © 2026 Michael Lechner · mlc OpticScript · https://mlcgo.eu · Elastic License 2.0