About this technique →
watercolor-with-plotter/sketch.js
// Watercolor with Plotter
//
// Simulates the hybrid analog-digital plotter aesthetic:
// soft organic watercolor washes (procedural bezier blobs) layered many times
// at low opacity, then overdrawn with noise-perturbed pen lines.
//
// The SVG export produces ONLY the pen linework so you can plot it over
// a real physical watercolor wash on paper.

export const PARAMS = {
  blobCount:  { value: 5,    min: 1,    max: 12,   step: 1,     label: "Watercolor Blobs",  folder: "Wash" },
  blobLayers: { value: 8,    min: 1,    max: 20,   step: 1,     label: "Layers per Blob",   folder: "Wash" },
  blobAlpha:  { value: 0.06, min: 0.01, max: 0.2,  step: 0.005, label: "Layer Opacity",     folder: "Wash" },
  hueA:       { value: 200,  min: 0,    max: 360,  step: 1,     label: "Wash Hue A",        folder: "Wash" },
  hueB:       { value: 280,  min: 0,    max: 360,  step: 1,     label: "Wash Hue B",        folder: "Wash" },
  penLines:   { value: 80,   min: 0,    max: 300,  step: 5,     label: "Pen Lines",         folder: "Pen" },
  penChaos:   { value: 0.4,  min: 0,    max: 1,    step: 0.01,  label: "Line Chaos",        folder: "Pen" },
  penColor:   { value: "#1a1a1a", type: "color",               label: "Pen Color",         folder: "Pen" },
  paperColor: { value: "#f4ede0", type: "color",               label: "Paper",             folder: "Paper" },
  strokeW:    { value: 0.6,  min: 0.2,  max: 2,    step: 0.05,  label: "Pen Width",         folder: "Pen" },
};

// ── Helpers ──────────────────────────────────────────────────────────────────

function hexToRgb(hex) {
  const h = hex.replace('#', '');
  return [
    parseInt(h.substring(0, 2), 16),
    parseInt(h.substring(2, 4), 16),
    parseInt(h.substring(4, 6), 16),
  ];
}

// Convert Oklch to CSS rgba string
function oklchToCss(L, C, hDeg, alpha, tng) {
  const [r, g, b] = tng.color.oklchToRgb(L, C, hDeg);
  const ri = Math.round(Math.max(0, Math.min(255, r * 255)));
  const gi = Math.round(Math.max(0, Math.min(255, g * 255)));
  const bi = Math.round(Math.max(0, Math.min(255, b * 255)));
  return `rgba(${ri},${gi},${bi},${alpha})`;
}

// Generate a closed bezier blob around a center point with a given radius.
// The blob is built from `points` control points, each perturbed by `jitter`.
function drawBlob(ctx, cx, cy, radius, points, jitter, rng) {
  const pts = [];
  for (let i = 0; i < points; i++) {
    const angle = (i / points) * Math.PI * 2;
    const r = radius * (1 + (rng() - 0.5) * jitter);
    pts.push([cx + Math.cos(angle) * r, cy + Math.sin(angle) * r]);
  }

  ctx.beginPath();
  // Use cubic bezier through the polygon — each segment has two control points
  // computed by mirroring around the current vertex
  const n = pts.length;
  ctx.moveTo((pts[0][0] + pts[1][0]) / 2, (pts[0][1] + pts[1][1]) / 2);
  for (let i = 0; i < n; i++) {
    const p0 = pts[i];
    const p1 = pts[(i + 1) % n];
    const mid = [(p0[0] + p1[0]) / 2, (p0[1] + p1[1]) / 2];
    ctx.quadraticCurveTo(p0[0], p0[1], mid[0], mid[1]);
  }
  ctx.closePath();
}

// ── Setup ─────────────────────────────────────────────────────────────────────

export function sketchSetup(ctx, w, h, tng) {
  const rng = tng.random;

  // Pre-generate all blob data using the seeded RNG so the layout is stable.
  const blobs = [];
  const maxBlobs = PARAMS.blobCount.max;
  for (let i = 0; i < maxBlobs; i++) {
    const cx = tng.randomRange(w * 0.15, w * 0.85);
    const cy = tng.randomRange(h * 0.15, h * 0.85);
    const radius = tng.randomRange(Math.min(w, h) * 0.08, Math.min(w, h) * 0.22);
    const t = rng(); // hue interpolation factor
    const points = Math.floor(rng() * 5) + 6; // 6–10 control points

    // Pre-generate per-layer perturbation seeds
    const layerSeeds = [];
    const maxLayers = PARAMS.blobLayers.max;
    for (let l = 0; l < maxLayers; l++) {
      layerSeeds.push(rng());
    }

    blobs.push({ cx, cy, radius, t, points, layerSeeds });
  }

  // Pre-generate pen line paths
  const penPaths = [];
  const maxLines = PARAMS.penLines.max;
  const noiseScale = 0.004;
  for (let i = 0; i < maxLines; i++) {
    // Each line starts from a random edge or point
    const side = Math.floor(rng() * 4);
    let sx, sy;
    if (side === 0) { sx = rng() * w; sy = 0; }
    else if (side === 1) { sx = rng() * w; sy = h; }
    else if (side === 2) { sx = 0; sy = rng() * h; }
    else { sx = w; sy = rng() * h; }

    // Store a unique offset so each line gets its own noise slice
    const noiseOffset = rng() * 1000;
    penPaths.push({ sx, sy, noiseOffset });
  }

  return { blobs, penPaths, w, h };
}

// ── Draw ──────────────────────────────────────────────────────────────────────

export function sketchDraw(ctx, w, h, params, tng, state) {
  const rng = tng.random;

  // Paper background
  ctx.fillStyle = params.paperColor;
  ctx.fillRect(0, 0, w, h);

  // ── Watercolor wash ──────────────────────────────────────────────────────
  const blobCount  = Math.min(params.blobCount, state.blobs.length);
  const layerCount = params.blobLayers;

  for (let i = 0; i < blobCount; i++) {
    const blob = state.blobs[i];
    const hue = params.hueA + blob.t * (params.hueB - params.hueA);

    for (let l = 0; l < layerCount; l++) {
      // Vary lightness and chroma slightly per layer for organic depth
      const L = 0.75 + 0.1 * (l / layerCount);
      const C = 0.08 + 0.04 * blob.t;
      const color = oklchToCss(L, C, hue, params.blobAlpha, tng);

      // Perturb radius and center slightly each layer using the pre-seeded values
      const seed = blob.layerSeeds[l % blob.layerSeeds.length];
      const jitter = 0.35 + 0.25 * seed;
      const dr = blob.radius * 0.08 * (seed - 0.5);
      const dcx = blob.radius * 0.04 * (seed * 2 - 1);
      const dcy = blob.radius * 0.04 * (1 - seed * 2);

      ctx.fillStyle = color;
      drawBlob(ctx, blob.cx + dcx, blob.cy + dcy, blob.radius + dr, blob.points, jitter, tng.random);
      ctx.fill();
    }
  }

  // ── Pen linework ─────────────────────────────────────────────────────────
  const lineCount = Math.min(params.penLines, state.penPaths.length);
  const chaos     = params.penChaos;
  const stepPx    = 4;
  const noiseScale = 0.003;
  const maxSteps  = Math.ceil(Math.max(w, h) * 1.5 / stepPx);

  ctx.strokeStyle = params.penColor;
  ctx.lineWidth   = params.strokeW;
  ctx.lineCap     = 'round';

  for (let i = 0; i < lineCount; i++) {
    const { sx, sy, noiseOffset } = state.penPaths[i];
    ctx.beginPath();
    ctx.moveTo(sx, sy);

    let x = sx, y = sy;
    for (let s = 0; s < maxSteps; s++) {
      // Base direction: slight drift downward (like gravity)
      const nx = tng.noise.fbm2(x * noiseScale + noiseOffset, y * noiseScale, 2);
      const ny = tng.noise.fbm2(x * noiseScale, y * noiseScale + noiseOffset, 2);
      const angle = (nx * 2 * Math.PI) * chaos + (1 - chaos) * (Math.PI * 0.5);
      x += Math.cos(angle) * stepPx;
      y += Math.sin(angle) * stepPx + stepPx * 0.2; // slight downward bias

      ctx.lineTo(x, y);

      // Stop if we've left the canvas
      if (x < -20 || x > w + 20 || y < -20 || y > h + 20) break;
    }
    ctx.stroke();
  }
}

// ── SVG Export (pen linework only — for physical plotter use) ─────────────────
//
// The watercolor blobs are NOT included in the SVG. The user provides their
// own physical watercolor wash and then plots the linework on top.

export function sketchSVG(w, h, params, tng, state) {
  const lineCount = Math.min(params.penLines, state.penPaths.length);
  const chaos     = params.penChaos;
  const stepPx    = 4;
  const noiseScale = 0.003;
  const maxSteps  = Math.ceil(Math.max(w, h) * 1.5 / stepPx);

  // Convert canvas pixels to mm at 96dpi (1px = 0.264583mm)
  const pxToMm = 0.264583;
  const wMm = (w * pxToMm).toFixed(2);
  const hMm = (h * pxToMm).toFixed(2);

  const lines = [];
  for (let i = 0; i < lineCount; i++) {
    const { sx, sy, noiseOffset } = state.penPaths[i];
    const pts = [[sx, sy]];

    let x = sx, y = sy;
    for (let s = 0; s < maxSteps; s++) {
      const nx = tng.noise.fbm2(x * noiseScale + noiseOffset, y * noiseScale, 2);
      const angle = (nx * 2 * Math.PI) * chaos + (1 - chaos) * (Math.PI * 0.5);
      x += Math.cos(angle) * stepPx;
      y += Math.sin(angle) * stepPx + stepPx * 0.2;
      pts.push([x, y]);
      if (x < -20 || x > w + 20 || y < -20 || y > h + 20) break;
    }

    const pointStr = pts.map(([px, py]) => `${px.toFixed(1)},${py.toFixed(1)}`).join(' ');
    lines.push(
      `  <polyline points="${pointStr}" fill="none" stroke="${params.penColor}" stroke-width="${params.strokeW}" stroke-linecap="round"/>`
    );
  }

  return [
    `<?xml version="1.0" encoding="UTF-8"?>`,
    `<svg xmlns="http://www.w3.org/2000/svg" width="${wMm}mm" height="${hMm}mm" viewBox="0 0 ${w} ${h}">`,
    `  <!-- Pen linework only. Apply your own watercolor wash before plotting. -->`,
    `  <!-- vpype: linemerge linesort linesimplify --tolerance 0.1mm -->`,
    `  <g id="pen-layer" inkscape:label="pen">`,
    ...lines,
    `  </g>`,
    `</svg>`,
  ].join('\n');
}