import type { Selection, SelectionMask, SelectionBounds, LassoPoint, SelectionMode, } from '@/types/selection'; import { v4 as uuidv4 } from 'uuid'; /** * Create an empty selection mask */ export function createEmptyMask(width: number, height: number): SelectionMask { return { width, height, data: new Uint8Array(width * height), bounds: { x: 0, y: 0, width: 0, height: 0 }, }; } /** * Create a rectangular selection mask */ export function createRectangularMask( x: number, y: number, width: number, height: number, canvasWidth: number, canvasHeight: number ): SelectionMask { const mask = createEmptyMask(canvasWidth, canvasHeight); const startX = Math.max(0, Math.floor(Math.min(x, x + width))); const startY = Math.max(0, Math.floor(Math.min(y, y + height))); const endX = Math.min(canvasWidth, Math.ceil(Math.max(x, x + width))); const endY = Math.min(canvasHeight, Math.ceil(Math.max(y, y + height))); for (let py = startY; py < endY; py++) { for (let px = startX; px < endX; px++) { mask.data[py * canvasWidth + px] = 255; } } mask.bounds = { x: startX, y: startY, width: endX - startX, height: endY - startY, }; return mask; } /** * Create an elliptical selection mask */ export function createEllipticalMask( cx: number, cy: number, rx: number, ry: number, canvasWidth: number, canvasHeight: number ): SelectionMask { const mask = createEmptyMask(canvasWidth, canvasHeight); const startX = Math.max(0, Math.floor(cx - rx)); const startY = Math.max(0, Math.floor(cy - ry)); const endX = Math.min(canvasWidth, Math.ceil(cx + rx)); const endY = Math.min(canvasHeight, Math.ceil(cy + ry)); for (let y = startY; y < endY; y++) { for (let x = startX; x < endX; x++) { const dx = (x - cx) / rx; const dy = (y - cy) / ry; if (dx * dx + dy * dy <= 1) { mask.data[y * canvasWidth + x] = 255; } } } mask.bounds = { x: startX, y: startY, width: endX - startX, height: endY - startY, }; return mask; } /** * Create a lasso (polygon) selection mask using scanline fill */ export function createLassoMask( points: LassoPoint[], canvasWidth: number, canvasHeight: number ): SelectionMask { const mask = createEmptyMask(canvasWidth, canvasHeight); if (points.length < 3) return mask; // Find bounds let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; for (const point of points) { minX = Math.min(minX, point.x); minY = Math.min(minY, point.y); maxX = Math.max(maxX, point.x); maxY = Math.max(maxY, point.y); } minX = Math.max(0, Math.floor(minX)); minY = Math.max(0, Math.floor(minY)); maxX = Math.min(canvasWidth, Math.ceil(maxX)); maxY = Math.min(canvasHeight, Math.ceil(maxY)); // Scanline fill algorithm for (let y = minY; y < maxY; y++) { const intersections: number[] = []; // Find intersections with polygon edges for (let i = 0; i < points.length; i++) { const p1 = points[i]; const p2 = points[(i + 1) % points.length]; if ( (p1.y <= y && p2.y > y) || (p2.y <= y && p1.y > y) ) { const x = p1.x + ((y - p1.y) / (p2.y - p1.y)) * (p2.x - p1.x); intersections.push(x); } } // Sort intersections intersections.sort((a, b) => a - b); // Fill between pairs of intersections for (let i = 0; i < intersections.length; i += 2) { if (i + 1 < intersections.length) { const startX = Math.max(minX, Math.floor(intersections[i])); const endX = Math.min(maxX, Math.ceil(intersections[i + 1])); for (let x = startX; x < endX; x++) { mask.data[y * canvasWidth + x] = 255; } } } } mask.bounds = { x: minX, y: minY, width: maxX - minX, height: maxY - minY, }; return mask; } /** * Create a magic wand selection mask using flood fill */ export function createMagicWandMask( startX: number, startY: number, imageData: ImageData, tolerance: number ): SelectionMask { const { width, height, data } = imageData; const mask = createEmptyMask(width, height); if (startX < 0 || startX >= width || startY < 0 || startY >= height) { return mask; } const startIdx = (startY * width + startX) * 4; const targetR = data[startIdx]; const targetG = data[startIdx + 1]; const targetB = data[startIdx + 2]; const targetA = data[startIdx + 3]; const visited = new Set(); const stack: [number, number][] = [[startX, startY]]; const isColorMatch = (x: number, y: number): boolean => { const idx = (y * width + x) * 4; const r = data[idx]; const g = data[idx + 1]; const b = data[idx + 2]; const a = data[idx + 3]; const diff = Math.sqrt( Math.pow(r - targetR, 2) + Math.pow(g - targetG, 2) + Math.pow(b - targetB, 2) + Math.pow(a - targetA, 2) ); return diff <= tolerance; }; let minX = startX, minY = startY, maxX = startX, maxY = startY; while (stack.length > 0) { const [x, y] = stack.pop()!; const key = `${x},${y}`; if (visited.has(key)) continue; if (x < 0 || x >= width || y < 0 || y >= height) continue; if (!isColorMatch(x, y)) continue; visited.add(key); mask.data[y * width + x] = 255; minX = Math.min(minX, x); minY = Math.min(minY, y); maxX = Math.max(maxX, x); maxY = Math.max(maxY, y); stack.push([x + 1, y]); stack.push([x - 1, y]); stack.push([x, y + 1]); stack.push([x, y - 1]); } mask.bounds = { x: minX, y: minY, width: maxX - minX + 1, height: maxY - minY + 1, }; return mask; } /** * Combine two selection masks based on mode */ export function combineMasks( mask1: SelectionMask, mask2: SelectionMask, mode: SelectionMode ): SelectionMask { const width = Math.max(mask1.width, mask2.width); const height = Math.max(mask1.height, mask2.height); const result = createEmptyMask(width, height); for (let y = 0; y < height; y++) { for (let x = 0; x < width; x++) { const idx = y * width + x; const val1 = x < mask1.width && y < mask1.height ? mask1.data[idx] : 0; const val2 = x < mask2.width && y < mask2.height ? mask2.data[idx] : 0; switch (mode) { case 'new': result.data[idx] = val2; break; case 'add': result.data[idx] = Math.max(val1, val2); break; case 'subtract': result.data[idx] = val1 > 0 && val2 === 0 ? val1 : 0; break; case 'intersect': result.data[idx] = val1 > 0 && val2 > 0 ? 255 : 0; break; } } } // Recalculate bounds let minX = width, minY = height, maxX = 0, maxY = 0; let hasSelection = false; for (let y = 0; y < height; y++) { for (let x = 0; x < width; x++) { if (result.data[y * width + x] > 0) { hasSelection = true; minX = Math.min(minX, x); minY = Math.min(minY, y); maxX = Math.max(maxX, x); maxY = Math.max(maxY, y); } } } if (hasSelection) { result.bounds = { x: minX, y: minY, width: maxX - minX + 1, height: maxY - minY + 1, }; } return result; } /** * Apply feathering to a selection mask */ export function featherMask(mask: SelectionMask, radius: number): SelectionMask { if (radius <= 0) return mask; const { width, height, data } = mask; const result = createEmptyMask(width, height); result.bounds = { ...mask.bounds }; // Simple box blur for feathering const kernelSize = Math.ceil(radius) * 2 + 1; const tempData = new Uint8Array(width * height); // Horizontal pass for (let y = 0; y < height; y++) { for (let x = 0; x < width; x++) { let sum = 0; let count = 0; for (let k = -Math.ceil(radius); k <= Math.ceil(radius); k++) { const nx = x + k; if (nx >= 0 && nx < width) { sum += data[y * width + nx]; count++; } } tempData[y * width + x] = sum / count; } } // Vertical pass for (let y = 0; y < height; y++) { for (let x = 0; x < width; x++) { let sum = 0; let count = 0; for (let k = -Math.ceil(radius); k <= Math.ceil(radius); k++) { const ny = y + k; if (ny >= 0 && ny < height) { sum += tempData[ny * width + x]; count++; } } result.data[y * width + x] = sum / count; } } return result; } /** * Invert a selection mask */ export function invertMask(mask: SelectionMask): SelectionMask { const result = createEmptyMask(mask.width, mask.height); for (let i = 0; i < mask.data.length; i++) { result.data[i] = mask.data[i] > 0 ? 0 : 255; } // Recalculate bounds let minX = mask.width, minY = mask.height, maxX = 0, maxY = 0; let hasSelection = false; for (let y = 0; y < mask.height; y++) { for (let x = 0; x < mask.width; x++) { if (result.data[y * mask.width + x] > 0) { hasSelection = true; minX = Math.min(minX, x); minY = Math.min(minY, y); maxX = Math.max(maxX, x); maxY = Math.max(maxY, y); } } } if (hasSelection) { result.bounds = { x: minX, y: minY, width: maxX - minX + 1, height: maxY - minY + 1, }; } else { result.bounds = { x: 0, y: 0, width: mask.width, height: mask.height }; } return result; } /** * Check if a point is inside the selection */ export function isPointInSelection( x: number, y: number, selection: Selection ): boolean { const { mask } = selection; if (x < 0 || x >= mask.width || y < 0 || y >= mask.height) { return false; } const value = mask.data[Math.floor(y) * mask.width + Math.floor(x)]; return selection.inverted ? value === 0 : value > 0; } /** * Create a selection object */ export function createSelection( layerId: string, mask: SelectionMask, feather: number = 0 ): Selection { const featheredMask = feather > 0 ? featherMask(mask, feather) : mask; return { id: uuidv4(), layerId, mask: featheredMask, inverted: false, feather, createdAt: Date.now(), }; } /** * Draw marching ants around selection */ export function drawMarchingAnts( ctx: CanvasRenderingContext2D, mask: SelectionMask, offset: number = 0 ): void { const { width, height, data } = mask; ctx.save(); ctx.strokeStyle = '#000'; ctx.lineWidth = 1; ctx.setLineDash([4, 4]); ctx.lineDashOffset = -offset; // Find edges and draw them ctx.beginPath(); for (let y = 0; y < height; y++) { for (let x = 0; x < width; x++) { const idx = y * width + x; if (data[idx] === 0) continue; // Check if this pixel is on the edge const isEdge = x === 0 || x === width - 1 || y === 0 || y === height - 1 || data[idx - 1] === 0 || // left data[idx + 1] === 0 || // right data[idx - width] === 0 || // top data[idx + width] === 0; // bottom if (isEdge) { // Draw pixel outline ctx.rect(x, y, 1, 1); } } } ctx.stroke(); // Draw white dashes on top ctx.strokeStyle = '#fff'; ctx.lineDashOffset = -offset + 4; ctx.stroke(); ctx.restore(); }