export type BrushOptions = { color: string; size: number; opacity: number; hardness: number; // 0–1: 0 = fully soft, 1 = hard edge }; export type BrushStroke = { canvas: HTMLCanvasElement; ctx: CanvasRenderingContext2D; width: number; height: number; }; function hexToRgb(hex: string): { r: number; g: number; b: number } { const clean = hex.replace("#", ""); return { r: parseInt(clean.slice(0, 2), 16), g: parseInt(clean.slice(2, 4), 16), b: parseInt(clean.slice(4, 6), 16), }; } function drawDab( ctx: CanvasRenderingContext2D, x: number, y: number, options: BrushOptions, ) { const r = options.size / 2; const { r: cr, g: cg, b: cb } = hexToRgb(options.color); const gradient = ctx.createRadialGradient(x, y, 0, x, y, r); const innerStop = Math.max(0, Math.min(1, options.hardness)); gradient.addColorStop(0, `rgba(${cr},${cg},${cb},${options.opacity})`); gradient.addColorStop(innerStop, `rgba(${cr},${cg},${cb},${options.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(); } /** Creates a fresh stroke canvas sized to the layer. */ 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 }; } /** Paints a segment of a stroke from (x0,y0) to (x1,y1) using interpolated dabs. */ export function paintSegment( stroke: BrushStroke, x0: number, y0: number, x1: number, y1: number, options: BrushOptions, ) { const dx = x1 - x0; const dy = y1 - y0; const dist = Math.sqrt(dx * dx + dy * dy); const step = Math.max(1, options.size * 0.25); const steps = Math.max(1, Math.ceil(dist / step)); for (let i = 0; i <= steps; i++) { const t = steps === 0 ? 0 : i / steps; drawDab(stroke.ctx, x0 + dx * t, y0 + dy * t, options); } } /** Merges stroke canvas on top of the source image and returns a data URL. */ 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); // Scale stroke canvas to match image natural size ctx.drawImage(stroke.canvas, 0, 0, canvas.width, canvas.height); resolve(canvas.toDataURL("image/png")); }; image.onerror = reject; image.src = sourceUri; }); }