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