// 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');
}