/** * Layer Effects Rendering Engine * Applies non-destructive visual effects to layer canvases */ import type { LayerEffect, LayerEffectsConfig, DropShadowEffect, OuterGlowEffect, InnerShadowEffect, InnerGlowEffect, StrokeEffect, ColorOverlayEffect, BevelEffect, } from '@/types/layer-effects'; /** * Apply all enabled effects to a layer canvas * Returns a new canvas with effects applied (non-destructive) */ export function applyLayerEffects( sourceCanvas: HTMLCanvasElement, effectsConfig: LayerEffectsConfig | undefined ): HTMLCanvasElement { if (!effectsConfig || !effectsConfig.enabled || effectsConfig.effects.length === 0) { return sourceCanvas; } // Filter to only enabled effects const activeEffects = effectsConfig.effects.filter((e) => e.enabled); if (activeEffects.length === 0) { return sourceCanvas; } // Create output canvas with extra padding for effects like shadows and glows const padding = calculateRequiredPadding(activeEffects); const outputCanvas = document.createElement('canvas'); outputCanvas.width = sourceCanvas.width + padding.left + padding.right; outputCanvas.height = sourceCanvas.height + padding.top + padding.bottom; const ctx = outputCanvas.getContext('2d', { willReadFrequently: true }); if (!ctx) return sourceCanvas; // Effects are applied in a specific order for best visual results // Order: Background effects → Layer content → Overlay effects ctx.save(); ctx.translate(padding.left, padding.top); // Step 1: Apply effects that go behind the layer (Drop Shadow, Outer Glow) applyBackgroundEffects(ctx, sourceCanvas, activeEffects, effectsConfig); // Step 2: Draw the original layer content ctx.globalCompositeOperation = 'source-over'; ctx.drawImage(sourceCanvas, 0, 0); // Step 3: Apply effects that modify the layer (Inner Shadow, Inner Glow, Stroke) applyModifyingEffects(ctx, sourceCanvas, activeEffects, effectsConfig); // Step 4: Apply overlay effects (Color Overlay, Gradient Overlay, Pattern Overlay) applyOverlayEffects(ctx, sourceCanvas, activeEffects); ctx.restore(); return outputCanvas; } /** * Calculate required padding for effects */ function calculateRequiredPadding(effects: LayerEffect[]): { top: number; right: number; bottom: number; left: number; } { let maxPadding = 0; effects.forEach((effect) => { if (!effect.enabled) return; switch (effect.type) { case 'dropShadow': { const shadow = effect as DropShadowEffect; const maxBlur = shadow.size + shadow.distance; maxPadding = Math.max(maxPadding, maxBlur); break; } case 'outerGlow': { const glow = effect as OuterGlowEffect; maxPadding = Math.max(maxPadding, glow.size); break; } case 'stroke': { const stroke = effect as StrokeEffect; if (stroke.position === 'outside') { maxPadding = Math.max(maxPadding, stroke.size); } else if (stroke.position === 'center') { maxPadding = Math.max(maxPadding, stroke.size / 2); } break; } } }); return { top: Math.ceil(maxPadding), right: Math.ceil(maxPadding), bottom: Math.ceil(maxPadding), left: Math.ceil(maxPadding), }; } /** * Apply background effects (Drop Shadow, Outer Glow) */ function applyBackgroundEffects( ctx: CanvasRenderingContext2D, sourceCanvas: HTMLCanvasElement, effects: LayerEffect[], config: LayerEffectsConfig ): void { effects.forEach((effect) => { switch (effect.type) { case 'dropShadow': applyDropShadow(ctx, sourceCanvas, effect as DropShadowEffect, config); break; case 'outerGlow': applyOuterGlow(ctx, sourceCanvas, effect as OuterGlowEffect); break; } }); } /** * Apply modifying effects (Inner Shadow, Inner Glow, Stroke) */ function applyModifyingEffects( ctx: CanvasRenderingContext2D, sourceCanvas: HTMLCanvasElement, effects: LayerEffect[], config: LayerEffectsConfig ): void { effects.forEach((effect) => { switch (effect.type) { case 'innerShadow': applyInnerShadow(ctx, sourceCanvas, effect as InnerShadowEffect, config); break; case 'innerGlow': applyInnerGlow(ctx, sourceCanvas, effect as InnerGlowEffect); break; case 'stroke': applyStroke(ctx, sourceCanvas, effect as StrokeEffect); break; } }); } /** * Apply overlay effects (Color Overlay, etc.) */ function applyOverlayEffects( ctx: CanvasRenderingContext2D, sourceCanvas: HTMLCanvasElement, effects: LayerEffect[] ): void { effects.forEach((effect) => { switch (effect.type) { case 'colorOverlay': applyColorOverlay(ctx, sourceCanvas, effect as ColorOverlayEffect); break; // TODO: Gradient Overlay, Pattern Overlay } }); } /** * Apply Drop Shadow Effect */ function applyDropShadow( ctx: CanvasRenderingContext2D, sourceCanvas: HTMLCanvasElement, effect: DropShadowEffect, config: LayerEffectsConfig ): void { const angle = effect.useGlobalLight ? config.globalLightAngle : effect.angle; const angleRad = (angle * Math.PI) / 180; const offsetX = Math.cos(angleRad) * effect.distance; const offsetY = Math.sin(angleRad) * effect.distance; ctx.save(); ctx.globalAlpha = effect.opacity; ctx.globalCompositeOperation = effect.blendMode as GlobalCompositeOperation; // Apply shadow ctx.shadowColor = effect.color; ctx.shadowBlur = effect.size; ctx.shadowOffsetX = offsetX; ctx.shadowOffsetY = offsetY; // Draw source as shadow ctx.drawImage(sourceCanvas, 0, 0); ctx.restore(); } /** * Apply Outer Glow Effect */ function applyOuterGlow( ctx: CanvasRenderingContext2D, sourceCanvas: HTMLCanvasElement, effect: OuterGlowEffect ): void { ctx.save(); ctx.globalAlpha = effect.opacity; ctx.globalCompositeOperation = effect.blendMode as GlobalCompositeOperation; // Create glow using shadow ctx.shadowColor = effect.color; ctx.shadowBlur = effect.size; ctx.shadowOffsetX = 0; ctx.shadowOffsetY = 0; // Draw multiple times for stronger glow const iterations = Math.ceil(effect.spread / 20) + 1; for (let i = 0; i < iterations; i++) { ctx.drawImage(sourceCanvas, 0, 0); } ctx.restore(); } /** * Apply Inner Shadow Effect */ function applyInnerShadow( ctx: CanvasRenderingContext2D, sourceCanvas: HTMLCanvasElement, effect: InnerShadowEffect, config: LayerEffectsConfig ): void { const angle = effect.useGlobalLight ? config.globalLightAngle : effect.angle; const angleRad = (angle * Math.PI) / 180; const offsetX = Math.cos(angleRad) * effect.distance; const offsetY = Math.sin(angleRad) * effect.distance; // Inner shadow is complex - we need to invert the layer, apply shadow, then comp it back const tempCanvas = document.createElement('canvas'); tempCanvas.width = sourceCanvas.width; tempCanvas.height = sourceCanvas.height; const tempCtx = tempCanvas.getContext('2d'); if (!tempCtx) return; // Step 1: Create inverted mask tempCtx.fillStyle = 'white'; tempCtx.fillRect(0, 0, tempCanvas.width, tempCanvas.height); tempCtx.globalCompositeOperation = 'destination-out'; tempCtx.drawImage(sourceCanvas, 0, 0); // Step 2: Apply shadow to inverted mask tempCtx.globalCompositeOperation = 'source-over'; tempCtx.shadowColor = effect.color; tempCtx.shadowBlur = effect.size; tempCtx.shadowOffsetX = -offsetX; tempCtx.shadowOffsetY = -offsetY; tempCtx.drawImage(tempCanvas, 0, 0); // Step 3: Clip to original layer shape ctx.save(); ctx.globalAlpha = effect.opacity; ctx.globalCompositeOperation = 'source-atop'; ctx.drawImage(tempCanvas, 0, 0); ctx.restore(); } /** * Apply Inner Glow Effect */ function applyInnerGlow( ctx: CanvasRenderingContext2D, sourceCanvas: HTMLCanvasElement, effect: InnerGlowEffect ): void { // Similar to inner shadow but without offset const tempCanvas = document.createElement('canvas'); tempCanvas.width = sourceCanvas.width; tempCanvas.height = sourceCanvas.height; const tempCtx = tempCanvas.getContext('2d'); if (!tempCtx) return; // Create inverted mask tempCtx.fillStyle = 'white'; tempCtx.fillRect(0, 0, tempCanvas.width, tempCanvas.height); tempCtx.globalCompositeOperation = 'destination-out'; tempCtx.drawImage(sourceCanvas, 0, 0); // Apply glow tempCtx.globalCompositeOperation = 'source-over'; tempCtx.shadowColor = effect.color; tempCtx.shadowBlur = effect.size; tempCtx.shadowOffsetX = 0; tempCtx.shadowOffsetY = 0; const iterations = Math.ceil(effect.choke / 20) + 1; for (let i = 0; i < iterations; i++) { tempCtx.drawImage(tempCanvas, 0, 0); } // Clip to original layer ctx.save(); ctx.globalAlpha = effect.opacity; ctx.globalCompositeOperation = 'source-atop'; ctx.drawImage(tempCanvas, 0, 0); ctx.restore(); } /** * Apply Stroke Effect */ function applyStroke( ctx: CanvasRenderingContext2D, sourceCanvas: HTMLCanvasElement, effect: StrokeEffect ): void { ctx.save(); ctx.globalAlpha = effect.opacity; ctx.globalCompositeOperation = effect.blendMode as GlobalCompositeOperation; // For now, only implement solid color stroke if (effect.fillType === 'color') { ctx.strokeStyle = effect.color; ctx.lineWidth = effect.size; // Get the outline of the layer const imageData = ctx.getImageData(0, 0, sourceCanvas.width, sourceCanvas.height); const outline = getLayerOutline(imageData); // Draw the stroke ctx.beginPath(); outline.forEach((point, index) => { if (index === 0) { ctx.moveTo(point.x, point.y); } else { ctx.lineTo(point.x, point.y); } }); ctx.closePath(); ctx.stroke(); } ctx.restore(); } /** * Apply Color Overlay Effect */ function applyColorOverlay( ctx: CanvasRenderingContext2D, sourceCanvas: HTMLCanvasElement, effect: ColorOverlayEffect ): void { ctx.save(); ctx.globalAlpha = effect.opacity; ctx.globalCompositeOperation = effect.blendMode as GlobalCompositeOperation; // Fill with color, clipped to layer shape ctx.fillStyle = effect.color; ctx.fillRect(0, 0, sourceCanvas.width, sourceCanvas.height); ctx.restore(); } /** * Get outline points of a layer (for stroke effect) * This is a simplified version - proper implementation would use edge detection */ function getLayerOutline(imageData: ImageData): Array<{ x: number; y: number }> { const points: Array<{ x: number; y: number }> = []; const width = imageData.width; const height = imageData.height; const data = imageData.data; // Simple edge detection - find pixels with alpha > 0 next to alpha === 0 for (let y = 0; y < height; y++) { for (let x = 0; x < width; x++) { const idx = (y * width + x) * 4; const alpha = data[idx + 3]; if (alpha > 0) { // Check if this is an edge pixel const isEdge = x === 0 || x === width - 1 || y === 0 || y === height - 1 || data[((y - 1) * width + x) * 4 + 3] === 0 || data[((y + 1) * width + x) * 4 + 3] === 0 || data[(y * width + (x - 1)) * 4 + 3] === 0 || data[(y * width + (x + 1)) * 4 + 3] === 0; if (isEdge) { points.push({ x, y }); } } } } return points; } /** * Check if a layer has any active effects */ export function hasActiveEffects(effectsConfig: LayerEffectsConfig | undefined): boolean { if (!effectsConfig || !effectsConfig.enabled) return false; return effectsConfig.effects.some((e) => e.enabled); } /** * Get the effective bounds of a layer with effects applied * This is used to know how much larger the layer will be with effects */ export function getEffectBounds( layerWidth: number, layerHeight: number, effectsConfig: LayerEffectsConfig | undefined ): { width: number; height: number; offsetX: number; offsetY: number } { if (!effectsConfig || !effectsConfig.enabled) { return { width: layerWidth, height: layerHeight, offsetX: 0, offsetY: 0 }; } const padding = calculateRequiredPadding(effectsConfig.effects); return { width: layerWidth + padding.left + padding.right, height: layerHeight + padding.top + padding.bottom, offsetX: padding.left, offsetY: padding.top, }; }