About this technique →
l-system-tree/sketch.js
// L-System Tree
//
// A Lindenmayer system: start from an axiom string, iteratively rewrite via
// production rules, then interpret the final string as turtle-graphics commands.
// F/G = draw forward, +/- = turn, [ ] = push/pop state.

export const PARAMS = {
  preset:     { value: "Tree",   options: ["Tree", "Bush", "Plant", "Dragon", "Sierpinski"], label: "Grammar Preset", folder: "Structure", rebuildOnChange: true },
  iterations: { value: 5,  min: 1, max: 8,  step: 1,   label: "Iterations",        folder: "Structure", rebuildOnChange: true },
  angle:      { value: 25, min: 5, max: 90, step: 0.5,  label: "Branch Angle (°)", folder: "Structure" },
  segLength:  { value: 8,  min: 1, max: 30, step: 0.5,  label: "Segment Length",   folder: "Structure" },
  hue:        { value: 120, min: 0, max: 360, step: 1,  label: "Trunk Hue",        folder: "Appearance" },
  hueShift:   { value: 0.15, min: 0, max: 0.5, step: 0.01, label: "Hue Shift Per Level", folder: "Appearance" },
  strokeW:    { value: 1.5, min: 0.5, max: 5, step: 0.1, label: "Stroke Width",    folder: "Appearance" },
  bg:         { value: "#0a0a0a", type: "color", label: "Background", folder: "Appearance" },
};

// ── Grammar definitions ──────────────────────────────────────────────────────

const GRAMMARS = {
  Tree: {
    axiom: "F",
    rules: { F: "FF+[+F-F-F]-[-F+F+F]" },
    startAngle: -90, // grows upward
    defaultAngle: 25,
    defaultIter: 5,
  },
  Bush: {
    axiom: "Y",
    rules: { X: "X[-FFF][+FFF]FX", Y: "YFX[+Y][-Y]" },
    startAngle: -90,
    defaultAngle: 25,
    defaultIter: 5,
  },
  Plant: {
    axiom: "X",
    rules: { X: "F+[[X]-X]-F[-FX]+X", F: "FF" },
    startAngle: -90,
    defaultAngle: 25,
    defaultIter: 5,
  },
  Dragon: {
    axiom: "FX",
    rules: { X: "X+YF+", Y: "-FX-Y" },
    startAngle: 0,
    defaultAngle: 90,
    defaultIter: 10,
  },
  Sierpinski: {
    axiom: "F-G-G",
    rules: { F: "F-G+F+G-F", G: "GG" },
    startAngle: 0,
    defaultAngle: 120,
    defaultIter: 6,
  },
};

// ── L-system expansion ───────────────────────────────────────────────────────

function expand(axiom, rules, iters) {
  let s = axiom;
  for (let i = 0; i < iters; i++) {
    let next = "";
    for (const ch of s) {
      next += rules[ch] ?? ch;
    }
    s = next;
    // Safety: cap string length to avoid freezing
    if (s.length > 300000) break;
  }
  return s;
}

// ── Bounding box pass (no drawing) ──────────────────────────────────────────

function measureBounds(str, startX, startY, startAngle, angleDeg, segLen) {
  let x = startX, y = startY;
  let angle = startAngle * Math.PI / 180;
  const stack = [];
  let minX = x, maxX = x, minY = y, maxY = y;
  const ang = angleDeg * Math.PI / 180;

  for (const ch of str) {
    switch (ch) {
      case "F": case "G": case "f": case "g": {
        x += Math.cos(angle) * segLen;
        y += Math.sin(angle) * segLen;
        if (x < minX) minX = x; if (x > maxX) maxX = x;
        if (y < minY) minY = y; if (y > maxY) maxY = y;
        break;
      }
      case "+": angle += ang; break;
      case "-": angle -= ang; break;
      case "[": stack.push([x, y, angle]); break;
      case "]": { const s = stack.pop(); if (s) { [x, y, angle] = s; } break; }
    }
  }
  return { minX, maxX, minY, maxY };
}

// ── HSL helper ───────────────────────────────────────────────────────────────

function hslStr(h, s, l) {
  return `hsl(${((h % 360) + 360) % 360},${Math.round(s * 100)}%,${Math.round(l * 100)}%)`;
}

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

export function sketchSetup(ctx, w, h, tng) {
  return {};
}

export function sketchDraw(ctx, w, h, params, tng) {
  // Background
  ctx.fillStyle = params.bg;
  ctx.fillRect(0, 0, w, h);

  const grammar = GRAMMARS[params.preset] ?? GRAMMARS["Tree"];
  const angleDeg = params.angle;
  const segLen = params.segLength;

  // Expand
  const iters = params.iterations | 0;
  const str = expand(grammar.axiom, grammar.rules, iters);

  // Measure bounds using a dummy origin
  const bounds = measureBounds(str, 0, 0, grammar.startAngle, angleDeg, segLen);
  const bw = bounds.maxX - bounds.minX;
  const bh = bounds.maxY - bounds.minY;

  // Fit to canvas with padding
  const pad = 0.05;
  const fitScale = Math.min(
    (w * (1 - pad * 2)) / (bw || 1),
    (h * (1 - pad * 2)) / (bh || 1),
  );

  // Start position: translate so the bounding box is centered
  const startX = w / 2 - (bounds.minX + bw / 2) * fitScale;
  const startY = h / 2 - (bounds.minY + bh / 2) * fitScale;

  // Draw pass
  let x = startX, y = startY;
  let angle = grammar.startAngle * Math.PI / 180;
  const ang = angleDeg * Math.PI / 180;
  const stack = [];
  let depth = 0;

  ctx.lineCap = "round";

  for (const ch of str) {
    switch (ch) {
      case "F": case "G": {
        const nx = x + Math.cos(angle) * segLen * fitScale;
        const ny = y + Math.sin(angle) * segLen * fitScale;
        const depthHue = params.hue + depth * params.hueShift * 360;
        const lightness = 0.35 + depth * 0.02;
        ctx.strokeStyle = hslStr(depthHue, 0.6, Math.min(0.7, lightness));
        ctx.lineWidth = Math.max(0.5, params.strokeW * Math.max(0.3, 1 - depth * 0.05));
        ctx.beginPath();
        ctx.moveTo(x, y);
        ctx.lineTo(nx, ny);
        ctx.stroke();
        x = nx; y = ny;
        break;
      }
      case "f": case "g": {
        // move without drawing
        x += Math.cos(angle) * segLen * fitScale;
        y += Math.sin(angle) * segLen * fitScale;
        break;
      }
      case "+": angle += ang; break;
      case "-": angle -= ang; break;
      case "[": stack.push([x, y, angle, depth]); depth++; break;
      case "]": {
        const s = stack.pop();
        if (s) { [x, y, angle, depth] = s; }
        break;
      }
    }
  }
}