About this technique →
hatched-form/sketch.js
// Hatched Form
//
// A silhouette defined by a 2D SDF, filled with parallel hatching lines.
// The hatch density is uniform within the shape (SVG-exact line spacing),
// optionally cross-hatched at a second angle.
//
// The SVG export contains only the hatch segments — plotter-ready geometry.
// Run through vpype (linemerge + linesort) before plotting for best results.

export const PARAMS = {
  shape:        { value: "Circle",  options: ["Circle", "Blob", "Heart", "Hex"], label: "Shape",              folder: "Form",       rebuildOnChange: true },
  shapeSize:    { value: 0.6,  min: 0.3,  max: 0.95, step: 0.01,  label: "Size (% of canvas)",  folder: "Form" },
  hatchAngle:   { value: 22,   min: 0,    max: 180,  step: 1,     label: "Hatch Angle (°)",     folder: "Hatching" },
  hatchSpacing: { value: 8,    min: 2,    max: 30,   step: 0.5,   label: "Hatch Spacing (px)",  folder: "Hatching" },
  cross:        { value: true,                                     label: "Cross-Hatch",          folder: "Hatching" },
  crossAngle:   { value: 90,   min: 0,    max: 180,  step: 1,     label: "Cross Angle Δ (°)",   folder: "Hatching" },
  pen:          { value: "#1a1a1a", type: "color",                 label: "Pen",                 folder: "Appearance" },
  paper:        { value: "#fafafa", type: "color",                 label: "Paper",               folder: "Appearance" },
  strokeW:      { value: 0.7,  min: 0.2,  max: 2,    step: 0.05,  label: "Pen Width",           folder: "Appearance" },
};

// ── SDF functions ─────────────────────────────────────────────────────────────

// Circle SDF
function sdfCircle(px, py, cx, cy, r) {
  return Math.hypot(px - cx, py - cy) - r;
}

// Smooth minimum for blob
function smin(a, b, k) {
  const h = Math.max(k - Math.abs(a - b), 0) / k;
  return Math.min(a, b) - h * h * k * 0.25;
}

// Blob: smooth union of 3 offset circles
function sdfBlob(px, py, cx, cy, r) {
  const r0 = r * 0.75;
  const d0 = sdfCircle(px, py, cx,        cy - r * 0.18, r);
  const d1 = sdfCircle(px, py, cx - r * 0.3, cy + r * 0.1, r0);
  const d2 = sdfCircle(px, py, cx + r * 0.28, cy + r * 0.15, r0);
  return smin(smin(d0, d1, r * 0.55), d2, r * 0.55);
}

// Heart SDF (parametric)
function sdfHeart(px, py, cx, cy, r) {
  // Normalize to [-1,1] space
  const x = (px - cx) / r;
  const y = (py - cy) / r;
  // Rotate so the heart points downward
  const rx = x;
  const ry = -y + 0.3;
  const d = Math.hypot(rx - Math.sign(rx) * 0.5, ry - 0.5) - 0.5;
  return (d < 0
    ? -(Math.hypot(rx * 0.95, ry - 0.5) - 0.5)
    : d) * r;
}

// Hexagon SDF (axial distance)
function sdfHex(px, py, cx, cy, r) {
  const x = Math.abs(px - cx);
  const y = Math.abs(py - cy);
  // hex: max of two faces
  const sqrt3half = Math.sqrt(3) / 2;
  return Math.max(x * 0.5 + y * sqrt3half - r, x - r);
}

// Dispatcher
function shapeSDF(shape, px, py, cx, cy, r) {
  switch (shape) {
    case 'Blob':   return sdfBlob(px, py, cx, cy, r);
    case 'Heart':  return sdfHeart(px, py, cx, cy, r);
    case 'Hex':    return sdfHex(px, py, cx, cy, r);
    default:       return sdfCircle(px, py, cx, cy, r);
  }
}

// ── Hatch line generation ─────────────────────────────────────────────────────
//
// Strategy: sweep a family of parallel lines at `angleDeg` across the canvas.
// For each line, walk in small steps and collect segments that are inside
// the shape (SDF < 0).
//
// Returns an array of segments: [[x1,y1,x2,y2], ...]

function generateHatchLines(w, h, cx, cy, r, shape, angleDeg, spacingPx) {
  const segs = [];
  const rad  = (angleDeg * Math.PI) / 180;

  // Direction vector along the line, perpendicular vector for the sweep
  const dx = Math.cos(rad);
  const dy = Math.sin(rad);
  const px = -dy; // perpendicular
  const py =  dx;

  // How far the lines need to sweep to cover the whole canvas
  const diagLen = Math.hypot(w, h);
  const halfDiag = diagLen * 0.6;

  // Number of parallel lines needed
  const lineCount = Math.ceil((diagLen * 1.5) / spacingPx);
  const halfLines = Math.floor(lineCount / 2);

  // Walk step: half the spacing for smooth segment detection
  const walkStep = 2; // px

  for (let li = -halfLines; li <= halfLines; li++) {
    // Origin of this line (offset from canvas center along the perp direction)
    const ox = w / 2 + px * li * spacingPx;
    const oy = h / 2 + py * li * spacingPx;

    // Walk along the line direction from -halfDiag to +halfDiag
    let segStart = null;

    for (let t = -halfDiag; t <= halfDiag; t += walkStep) {
      const wx = ox + dx * t;
      const wy = oy + dy * t;

      const inside = shapeSDF(shape, wx, wy, cx, cy, r) < 0;

      if (inside && segStart === null) {
        segStart = [wx, wy];
      } else if (!inside && segStart !== null) {
        segs.push([segStart[0], segStart[1], wx - dx * walkStep, wy - dy * walkStep]);
        segStart = null;
      }
    }
    // Close any open segment at the end of the sweep
    if (segStart !== null) {
      const wx = ox + dx * halfDiag;
      const wy = oy + dy * halfDiag;
      segs.push([segStart[0], segStart[1], wx, wy]);
    }
  }

  return segs;
}

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

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

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

export function sketchDraw(ctx, w, h, params, tng) {
  const cx = w / 2;
  const cy = h / 2;
  const r  = Math.min(w, h) * params.shapeSize * 0.5;

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

  // ── Silhouette fill (visual preview only, not in SVG) ──────────────────
  // Light fill so the shape is visible behind the hatching
  ctx.beginPath();
  switch (params.shape) {
    case 'Blob': {
      // Approximate blob with circle for quick fill
      ctx.arc(cx, cy - r * 0.18, r, 0, Math.PI * 2);
      break;
    }
    case 'Heart': {
      // Rough heart path for preview fill
      const hr = r;
      ctx.moveTo(cx, cy + hr * 0.7);
      ctx.bezierCurveTo(cx - hr, cy + hr * 0.1, cx - hr, cy - hr * 0.5, cx, cy - hr * 0.15);
      ctx.bezierCurveTo(cx + hr, cy - hr * 0.5, cx + hr, cy + hr * 0.1, cx, cy + hr * 0.7);
      break;
    }
    case 'Hex': {
      for (let i = 0; i < 6; i++) {
        const a = (i / 6) * Math.PI * 2 - Math.PI / 6;
        const x = cx + Math.cos(a) * r;
        const y = cy + Math.sin(a) * r;
        i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
      }
      ctx.closePath();
      break;
    }
    default: {
      ctx.arc(cx, cy, r, 0, Math.PI * 2);
    }
  }
  ctx.fillStyle = params.paper;
  ctx.fill();

  // ── Draw hatch lines ──────────────────────────────────────────────────────
  const angle1Segs = generateHatchLines(w, h, cx, cy, r, params.shape, params.hatchAngle, params.hatchSpacing);

  ctx.strokeStyle = params.pen;
  ctx.lineWidth   = params.strokeW;
  ctx.lineCap     = 'butt';

  function drawSegs(segs) {
    for (const [x1, y1, x2, y2] of segs) {
      ctx.beginPath();
      ctx.moveTo(x1, y1);
      ctx.lineTo(x2, y2);
      ctx.stroke();
    }
  }

  drawSegs(angle1Segs);

  if (params.cross) {
    const angle2 = params.hatchAngle + params.crossAngle;
    const crossSegs = generateHatchLines(w, h, cx, cy, r, params.shape, angle2, params.hatchSpacing);
    drawSegs(crossSegs);
  }
}

// ── SVG Export ─────────────────────────────────────────────────────────────────

export function sketchSVG(w, h, params, tng, state) {
  const cx = w / 2;
  const cy = h / 2;
  const r  = Math.min(w, h) * params.shapeSize * 0.5;

  const angle1Segs = generateHatchLines(w, h, cx, cy, r, params.shape, params.hatchAngle, params.hatchSpacing);

  let allSegs = [...angle1Segs];
  if (params.cross) {
    const angle2 = params.hatchAngle + params.crossAngle;
    const crossSegs = generateHatchLines(w, h, cx, cy, r, params.shape, angle2, params.hatchSpacing);
    allSegs = allSegs.concat(crossSegs);
  }

  const pxToMm = 0.264583;
  const wMm = (w * pxToMm).toFixed(2);
  const hMm = (h * pxToMm).toFixed(2);

  const lineElems = allSegs.map(([x1, y1, x2, y2]) =>
    `  <line x1="${x1.toFixed(2)}" y1="${y1.toFixed(2)}" x2="${x2.toFixed(2)}" y2="${y2.toFixed(2)}"/>`
  );

  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}">`,
    `  <!-- Hatched Form — plotter-ready hatch lines -->`,
    `  <!-- Shape: ${params.shape} | Angle: ${params.hatchAngle}° | Spacing: ${params.hatchSpacing}px -->`,
    `  <!-- vpype: linemerge linesort linesimplify --tolerance 0.1mm -->`,
    `  <g id="hatch-layer" inkscape:label="hatch" fill="none" stroke="${params.pen}" stroke-width="${params.strokeW}">`,
    ...lineElems,
    `  </g>`,
    `</svg>`,
  ].join('\n');
}