export type BrushSurface = { readonly width: number; readonly height: number; readonly ready: Promise; readonly resource: object; }; type InternalSurface = BrushSurface & { canvas: HTMLCanvasElement; context: CanvasRenderingContext2D; featherSource: HTMLCanvasElement; featherBlur: HTMLCanvasElement; }; export function createBrushSurface(width: number, height: number, source: string): BrushSurface | undefined { const canvas = document.createElement("canvas"); canvas.width = Math.max(1, Math.round(width)); canvas.height = Math.max(1, Math.round(height)); const context = canvas.getContext("2d"); if (!context) return undefined; const surface = { width: canvas.width, height: canvas.height, canvas, context, featherSource: document.createElement("canvas"), featherBlur: document.createElement("canvas"), resource: {}, ready: Promise.resolve(false), } as InternalSurface; (surface as { ready: Promise }).ready = loadImage(source).then((image) => { context.clearRect(0, 0, canvas.width, canvas.height); context.drawImage(image, 0, 0, canvas.width, canvas.height); return true; }).catch(() => false); return surface; } export function drawBrushSegment(surface: BrushSurface, options: { from: { x: number; y: number }; to: { x: number; y: number }; color: string; size: number; hardness: number; opacity: number; flow: number; mode: "brush" | "eraser" }) { const context = internal(surface).context; const hardness = Math.max(0, Math.min(100, options.hardness)) / 100; context.save(); context.globalAlpha = Math.max(0, Math.min(1, options.opacity / 100)) * Math.max(0.01, Math.min(1, options.flow / 100)); context.globalCompositeOperation = options.mode === "eraser" ? "destination-out" : "source-over"; context.strokeStyle = options.color; context.shadowColor = options.mode === "eraser" ? "rgba(0,0,0,1)" : options.color; context.shadowBlur = (1 - hardness) * options.size; context.lineWidth = options.size; context.lineCap = "round"; context.lineJoin = "round"; context.beginPath(); context.moveTo(options.from.x, options.from.y); context.lineTo(options.to.x, options.to.y); context.stroke(); context.restore(); } export function drawFeatherSegment(surface: BrushSurface, options: { from: { x: number; y: number }; to: { x: number; y: number }; size: number; radius: number; strength: number; }) { const target = internal(surface); const brushRadius = Math.max(0.5, options.size / 2); const blurRadius = Math.max(1, Math.round(options.radius)); const padding = Math.ceil(blurRadius * 2.5); const x1 = Math.max(0, Math.floor(Math.min(options.from.x, options.to.x) - brushRadius - padding)); const y1 = Math.max(0, Math.floor(Math.min(options.from.y, options.to.y) - brushRadius - padding)); const x2 = Math.min(target.width, Math.ceil(Math.max(options.from.x, options.to.x) + brushRadius + padding)); const y2 = Math.min(target.height, Math.ceil(Math.max(options.from.y, options.to.y) + brushRadius + padding)); const width = x2 - x1; const height = y2 - y1; if (width <= 0 || height <= 0) return; const scratchWidth = width + padding * 2; const scratchHeight = height + padding * 2; resizeCanvas(target.featherSource, scratchWidth, scratchHeight); resizeCanvas(target.featherBlur, scratchWidth, scratchHeight); const sourceContext = target.featherSource.getContext("2d"); const blurContext = target.featherBlur.getContext("2d"); if (!sourceContext || !blurContext) return; sourceContext.clearRect(0, 0, scratchWidth, scratchHeight); sourceContext.drawImage(target.canvas, x1, y1, width, height, padding, padding, width, height); blurContext.clearRect(0, 0, scratchWidth, scratchHeight); blurContext.save(); blurContext.filter = `blur(${blurRadius}px)`; blurContext.drawImage(target.featherSource, 0, 0); blurContext.restore(); const original = target.context.getImageData(x1, y1, width, height); const blurred = blurContext.getImageData(padding, padding, width, height); blendFeatherPatch(original.data, blurred.data, width, height, { x: x1, y: y1 }, options); target.context.putImageData(original, x1, y1); } export function blendFeatherPatch( original: Uint8ClampedArray, blurred: Uint8ClampedArray, width: number, height: number, origin: { x: number; y: number }, options: { from: { x: number; y: number }; to: { x: number; y: number }; size: number; strength: number }, ) { const brushRadius = Math.max(0.5, options.size / 2); const strength = Math.max(0.01, Math.min(1, options.strength / 100)); for (let y = 0; y < height; y += 1) { for (let x = 0; x < width; x += 1) { const distance = distanceToSegment(origin.x + x + 0.5, origin.y + y + 0.5, options.from, options.to); if (distance >= brushRadius) continue; const normalized = distance / brushRadius; const coverage = 1 - smoothstep(0.72, 1, normalized); const mix = coverage * strength; const index = (y * width + x) * 4; for (let channel = 0; channel < 4; channel += 1) { const current = original[index + channel] ?? 0; const feathered = blurred[index + channel] ?? 0; original[index + channel] = Math.round(current + (feathered - current) * mix); } } } } export function brushSurfaceDataUrl(surface: BrushSurface) { try { return internal(surface).canvas.toDataURL("image/png"); } catch { return undefined; } } export function brushSurfaceObjectUrl(surface: BrushSurface) { return new Promise((resolve) => internal(surface).canvas.toBlob((blob) => resolve(blob ? URL.createObjectURL(blob) : undefined), "image/png")); } export function releaseObjectUrl(source?: string) { if (source) URL.revokeObjectURL(source); } export function scheduleFrame(callback: () => void) { return requestAnimationFrame(callback); } export function cancelFrame(id: number) { cancelAnimationFrame(id); } function internal(surface: BrushSurface) { return surface as InternalSurface; } function loadImage(source: string) { return new Promise((resolve, reject) => { const image = new Image(); image.onload = () => resolve(image); image.onerror = () => reject(new Error("Failed to load raster layer")); image.src = source; }); } function resizeCanvas(canvas: HTMLCanvasElement, width: number, height: number) { if (canvas.width !== width) canvas.width = width; if (canvas.height !== height) canvas.height = height; } function distanceToSegment(x: number, y: number, from: { x: number; y: number }, to: { x: number; y: number }) { const dx = to.x - from.x; const dy = to.y - from.y; const lengthSquared = dx * dx + dy * dy; if (lengthSquared <= 0.0001) return Math.hypot(x - from.x, y - from.y); const amount = Math.max(0, Math.min(1, ((x - from.x) * dx + (y - from.y) * dy) / lengthSquared)); return Math.hypot(x - (from.x + dx * amount), y - (from.y + dy * amount)); } function smoothstep(edge0: number, edge1: number, value: number) { const amount = Math.max(0, Math.min(1, (value - edge0) / Math.max(0.0001, edge1 - edge0))); return amount * amount * (3 - 2 * amount); }