import { BaseTool } from './base-tool'; import type { PointerState, ToolSettings } from '@/types'; import { distance } from '@/lib/utils'; /** * Smudge Tool - Smears and blends colors for paint-like effects */ export class SmudgeTool extends BaseTool { private lastX = 0; private lastY = 0; private smudgeBuffer: ImageData | null = null; private isFirstStroke = true; constructor() { super('Smudge'); } onPointerDown( pointer: PointerState, ctx: CanvasRenderingContext2D, settings: ToolSettings ): void { this.isDrawing = true; this.lastX = pointer.x; this.lastY = pointer.y; this.isFirstStroke = true; // Sample initial area this.sampleArea(pointer.x, pointer.y, ctx, settings); } onPointerMove( pointer: PointerState, ctx: CanvasRenderingContext2D, settings: ToolSettings ): void { if (!this.isDrawing) return; // Calculate distance from last point const dist = distance(this.lastX, this.lastY, pointer.x, pointer.y); const spacing = settings.size * settings.spacing * 0.5; // Closer spacing for smoother smudge if (dist >= spacing) { // Interpolate between points for smooth stroke const steps = Math.ceil(dist / spacing); for (let i = 1; i <= steps; i++) { const t = i / steps; const x = this.lastX + (pointer.x - this.lastX) * t; const y = this.lastY + (pointer.y - this.lastY) * t; this.smudge(x, y, ctx, settings); } this.lastX = pointer.x; this.lastY = pointer.y; } } onPointerUp( pointer: PointerState, ctx: CanvasRenderingContext2D, settings: ToolSettings ): void { this.isDrawing = false; this.smudgeBuffer = null; this.isFirstStroke = true; } /** * Sample the area under the brush */ private sampleArea( x: number, y: number, ctx: CanvasRenderingContext2D, settings: ToolSettings ): void { const size = Math.ceil(settings.size); const halfSize = Math.floor(size / 2); // Clamp to canvas bounds const sx = Math.max(0, Math.floor(x - halfSize)); const sy = Math.max(0, Math.floor(y - halfSize)); const sw = Math.min(size, ctx.canvas.width - sx); const sh = Math.min(size, ctx.canvas.height - sy); if (sw > 0 && sh > 0) { this.smudgeBuffer = ctx.getImageData(sx, sy, sw, sh); } } /** * Apply smudge effect */ private smudge( x: number, y: number, ctx: CanvasRenderingContext2D, settings: ToolSettings ): void { if (!this.smudgeBuffer) { this.sampleArea(x, y, ctx, settings); return; } const size = Math.ceil(settings.size); const halfSize = Math.floor(size / 2); // Get current area const sx = Math.max(0, Math.floor(x - halfSize)); const sy = Math.max(0, Math.floor(y - halfSize)); const sw = Math.min(size, ctx.canvas.width - sx); const sh = Math.min(size, ctx.canvas.height - sy); if (sw <= 0 || sh <= 0) return; const currentArea = ctx.getImageData(sx, sy, sw, sh); // Blend smudge buffer with current area const strength = settings.flow; // Use flow as smudge strength const blendedData = this.blendImageData( this.smudgeBuffer, currentArea, strength, settings ); // Draw blended result ctx.putImageData(blendedData, sx, sy); // Update smudge buffer for next stroke (smear effect) // Mix more of the current area into the buffer for progressive smudging if (!this.isFirstStroke) { this.smudgeBuffer = this.blendImageData(currentArea, this.smudgeBuffer, 0.3, settings); } else { this.smudgeBuffer = currentArea; this.isFirstStroke = false; } } /** * Blend two ImageData objects */ private blendImageData( source: ImageData, target: ImageData, strength: number, settings: ToolSettings ): ImageData { const result = new ImageData(target.width, target.height); const srcData = source.data; const tgtData = target.data; const resData = result.data; const hardness = settings.hardness; const centerX = target.width / 2; const centerY = target.height / 2; const maxRadius = Math.min(centerX, centerY); for (let i = 0; i < tgtData.length; i += 4) { const pixelIndex = i / 4; const px = pixelIndex % target.width; const py = Math.floor(pixelIndex / target.width); // Calculate distance from center for soft brush effect const dx = px - centerX; const dy = py - centerY; const distFromCenter = Math.sqrt(dx * dx + dy * dy); const normalizedDist = Math.min(distFromCenter / maxRadius, 1); // Apply hardness to create soft/hard brush let falloff = 1; if (normalizedDist > hardness) { falloff = 1 - (normalizedDist - hardness) / (1 - hardness); falloff = Math.max(0, Math.min(1, falloff)); } const effectiveStrength = strength * falloff; // Ensure we don't go out of bounds if (i < srcData.length) { // Blend RGB channels resData[i] = tgtData[i] * (1 - effectiveStrength) + srcData[i] * effectiveStrength; resData[i + 1] = tgtData[i + 1] * (1 - effectiveStrength) + srcData[i + 1] * effectiveStrength; resData[i + 2] = tgtData[i + 2] * (1 - effectiveStrength) + srcData[i + 2] * effectiveStrength; resData[i + 3] = tgtData[i + 3]; // Preserve alpha } else { // If source is smaller, use target values resData[i] = tgtData[i]; resData[i + 1] = tgtData[i + 1]; resData[i + 2] = tgtData[i + 2]; resData[i + 3] = tgtData[i + 3]; } } return result; } /** * Reset when tool is deactivated */ onDeactivate(): void { super.onDeactivate(); this.smudgeBuffer = null; this.isFirstStroke = true; } getCursor(settings: ToolSettings): string { return 'crosshair'; } }