export type BrushOptions = { color: string; size: number; opacity: number; hardness: number; // 0 to 1: 0 is fully soft, 1 is a hard edge. }; export type BrushStroke = { canvas: HTMLCanvasElement; ctx: CanvasRenderingContext2D; width: number; height: number; }; export type BrushDab = { x: number; y: number; }; export function hexToRgb(hex: string): { r: number; g: number; b: number } { const clean = hex.replace("#", "").trim(); if (!/^[0-9a-fA-F]{6}$/.test(clean)) return { r: 0, g: 0, b: 0 }; return { r: parseInt(clean.slice(0, 2), 16), g: parseInt(clean.slice(2, 4), 16), b: parseInt(clean.slice(4, 6), 16), }; } export function clampBrushOptions(options: BrushOptions): BrushOptions { return { color: options.color, size: Math.max(1, Number.isFinite(options.size) ? options.size : 1), opacity: Math.max( 0, Math.min(1, Number.isFinite(options.opacity) ? options.opacity : 1), ), hardness: Math.max( 0, Math.min(1, Number.isFinite(options.hardness) ? options.hardness : 1), ), }; } export function buildBrushDabs( x0: number, y0: number, x1: number, y1: number, size: number, ): BrushDab[] { const dx = x1 - x0; const dy = y1 - y0; const dist = Math.sqrt(dx * dx + dy * dy); const step = Math.max(1, size * 0.25); const steps = Math.max(1, Math.ceil(dist / step)); const dabs: BrushDab[] = []; for (let i = 0; i <= steps; i++) { const t = i / steps; dabs.push({ x: x0 + dx * t, y: y0 + dy * t }); } return dabs; } function drawDab( ctx: CanvasRenderingContext2D, x: number, y: number, options: BrushOptions, ) { const normalized = clampBrushOptions(options); const r = normalized.size / 2; const { r: cr, g: cg, b: cb } = hexToRgb(normalized.color); const gradient = ctx.createRadialGradient(x, y, 0, x, y, r); gradient.addColorStop(0, `rgba(${cr},${cg},${cb},${normalized.opacity})`); gradient.addColorStop( normalized.hardness, `rgba(${cr},${cg},${cb},${normalized.opacity})`, ); gradient.addColorStop(1, `rgba(${cr},${cg},${cb},0)`); ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI * 2); ctx.fillStyle = gradient; ctx.fill(); } export function createStroke(width: number, height: number): BrushStroke { const canvas = document.createElement("canvas"); canvas.width = Math.max(1, Math.round(width)); canvas.height = Math.max(1, Math.round(height)); const ctx = canvas.getContext("2d"); if (!ctx) throw new Error("Cannot create brush canvas context"); return { canvas, ctx, width: canvas.width, height: canvas.height }; } export function paintSegment( stroke: BrushStroke, x0: number, y0: number, x1: number, y1: number, options: BrushOptions, ) { const normalized = clampBrushOptions(options); for (const dab of buildBrushDabs(x0, y0, x1, y1, normalized.size)) { drawDab(stroke.ctx, dab.x, dab.y, normalized); } } export function commitStroke( sourceUri: string, stroke: BrushStroke, ): Promise { return new Promise((resolve, reject) => { const image = new Image(); image.crossOrigin = "anonymous"; image.onload = () => { const canvas = document.createElement("canvas"); canvas.width = image.naturalWidth; canvas.height = image.naturalHeight; const ctx = canvas.getContext("2d"); if (!ctx) { reject(new Error("Cannot create merge canvas context")); return; } ctx.drawImage(image, 0, 0); ctx.drawImage(stroke.canvas, 0, 0, canvas.width, canvas.height); resolve(canvas.toDataURL("image/png")); }; image.onerror = reject; image.src = sourceUri; }); }