Files
Comfyui-LayerForge/js/MaskTool.js
2025-08-08 22:41:19 +02:00

1869 lines
86 KiB
JavaScript

import { createModuleLogger } from "./utils/LoggerUtils.js";
import { createCanvas } from "./utils/CommonUtils.js";
const log = createModuleLogger('Mask_tool');
export class MaskTool {
constructor(canvasInstance, callbacks = {}) {
// Track strokes during drawing for efficient overlay updates
this.currentStrokePoints = [];
this.ACTIVE_MASK_UPDATE_DELAY = 16; // ~60fps throttling
this.SHAPE_PREVIEW_THROTTLE_DELAY = 16; // ~60fps throttling for preview
this.canvasInstance = canvasInstance;
this.mainCanvas = canvasInstance.canvas;
this.onStateChange = callbacks.onStateChange || null;
// Initialize stroke tracking for overlay drawing
this.currentStrokePoints = [];
// Initialize chunked mask system
this.maskChunks = new Map();
this.chunkSize = 512;
this.activeChunkBounds = null;
// Initialize active chunk management
this.activeChunkRadius = 1; // 3x3 grid of active chunks (radius 1 = 9 chunks total)
this.currentDrawingChunk = null;
this.maxActiveChunks = 25; // Safety limit to prevent memory issues (5x5 grid max)
// Create active mask canvas (composite of chunks)
const { canvas: activeMaskCanvas, ctx: activeMaskCtx } = createCanvas(1, 1, '2d', { willReadFrequently: true });
if (!activeMaskCtx) {
throw new Error("Failed to get 2D context for active mask canvas");
}
this.activeMaskCanvas = activeMaskCanvas;
this.activeMaskCtx = activeMaskCtx;
this.x = 0;
this.y = 0;
this.isOverlayVisible = true;
this.isActive = false;
this.brushSize = 20;
this._brushStrength = 0.5;
this._brushHardness = 0.5;
this._previewOpacity = 0.5; // Default 50% opacity for preview
this.isDrawing = false;
this.lastPosition = null;
const { canvas: previewCanvas, ctx: previewCtx } = createCanvas(1, 1, '2d', { willReadFrequently: true });
if (!previewCtx) {
throw new Error("Failed to get 2D context for preview canvas");
}
this.previewCanvas = previewCanvas;
this.previewCtx = previewCtx;
this.previewVisible = false;
this.previewCanvasInitialized = false;
// Initialize shape preview system
const { canvas: shapePreviewCanvas, ctx: shapePreviewCtx } = createCanvas(1, 1, '2d', { willReadFrequently: true });
if (!shapePreviewCtx) {
throw new Error("Failed to get 2D context for shape preview canvas");
}
this.shapePreviewCanvas = shapePreviewCanvas;
this.shapePreviewCtx = shapePreviewCtx;
this.shapePreviewVisible = false;
this.isPreviewMode = false;
// Initialize performance optimization flags
this.activeMaskNeedsUpdate = false;
this.activeMaskUpdateTimeout = null;
// Initialize shape preview throttling
this.shapePreviewThrottleTimeout = null;
this.pendingPreviewParams = null;
this.initMaskCanvas();
}
// Temporary compatibility getters - will be replaced with chunked system
get maskCanvas() {
return this.activeMaskCanvas;
}
get maskCtx() {
return this.activeMaskCtx;
}
initPreviewCanvas() {
if (this.previewCanvas.parentElement) {
this.previewCanvas.parentElement.removeChild(this.previewCanvas);
}
this.previewCanvas.width = this.canvasInstance.canvas.width;
this.previewCanvas.height = this.canvasInstance.canvas.height;
this.previewCanvas.style.position = 'absolute';
this.previewCanvas.style.left = `${this.canvasInstance.canvas.offsetLeft}px`;
this.previewCanvas.style.top = `${this.canvasInstance.canvas.offsetTop}px`;
this.previewCanvas.style.pointerEvents = 'none';
this.previewCanvas.style.zIndex = '10';
if (this.canvasInstance.canvas.parentElement) {
this.canvasInstance.canvas.parentElement.appendChild(this.previewCanvas);
}
}
// Getters for brush properties
get brushStrength() {
return this._brushStrength;
}
get brushHardness() {
return this._brushHardness;
}
get previewOpacity() {
return this._previewOpacity;
}
setBrushHardness(hardness) {
this._brushHardness = Math.max(0, Math.min(1, hardness));
}
setPreviewOpacity(opacity) {
this._previewOpacity = Math.max(0, Math.min(1, opacity));
// Update the stroke overlay canvas opacity when preview opacity changes
if (this.canvasInstance.canvasRenderer && this.canvasInstance.canvasRenderer.strokeOverlayCanvas) {
this.canvasInstance.canvasRenderer.strokeOverlayCanvas.style.opacity = String(this._previewOpacity);
}
// Trigger canvas render to update mask display opacity
this.canvasInstance.render();
}
initMaskCanvas() {
// Initialize chunked system
this.chunkSize = 512;
this.maskChunks = new Map();
// Create initial active mask canvas
this.updateActiveMaskCanvas();
log.info(`Initialized chunked mask system with chunk size: ${this.chunkSize}x${this.chunkSize}`);
}
/**
* Updates the active mask canvas to show ALL chunks but optimize updates during drawing
* Always shows all chunks, but during drawing only updates the active chunks for performance
*/
updateActiveMaskCanvas(forceFullUpdate = false) {
// Always show all chunks - find bounds of all non-empty chunks
const chunkBounds = this.getAllChunkBounds();
if (!chunkBounds) {
// No chunks with data - create minimal canvas
this.activeMaskCanvas.width = 1;
this.activeMaskCanvas.height = 1;
this.x = 0;
this.y = 0;
this.activeChunkBounds = null;
log.debug("No mask chunks found - created minimal active canvas");
return;
}
// Calculate canvas size to cover ALL chunks
const canvasLeft = chunkBounds.minX * this.chunkSize;
const canvasTop = chunkBounds.minY * this.chunkSize;
const canvasWidth = (chunkBounds.maxX - chunkBounds.minX + 1) * this.chunkSize;
const canvasHeight = (chunkBounds.maxY - chunkBounds.minY + 1) * this.chunkSize;
// Update active mask canvas size and position if needed
if (this.activeMaskCanvas.width !== canvasWidth ||
this.activeMaskCanvas.height !== canvasHeight ||
this.x !== canvasLeft ||
this.y !== canvasTop ||
forceFullUpdate) {
this.activeMaskCanvas.width = canvasWidth;
this.activeMaskCanvas.height = canvasHeight;
this.x = canvasLeft;
this.y = canvasTop;
this.activeChunkBounds = chunkBounds;
// Full redraw when canvas size changes
this.activeMaskCtx.clearRect(0, 0, canvasWidth, canvasHeight);
// Draw ALL chunks
for (let chunkY = chunkBounds.minY; chunkY <= chunkBounds.maxY; chunkY++) {
for (let chunkX = chunkBounds.minX; chunkX <= chunkBounds.maxX; chunkX++) {
const chunkKey = `${chunkX},${chunkY}`;
const chunk = this.maskChunks.get(chunkKey);
if (chunk && !chunk.isEmpty) {
const destX = (chunkX - chunkBounds.minX) * this.chunkSize;
const destY = (chunkY - chunkBounds.minY) * this.chunkSize;
this.activeMaskCtx.drawImage(chunk.canvas, destX, destY);
}
}
}
log.debug(`Full update: rendered ${this.getAllNonEmptyChunkCount()} chunks`);
}
else {
// Canvas size unchanged - this is handled by partial updates during drawing
this.activeChunkBounds = chunkBounds;
}
}
/**
* Universal chunk data processing method - eliminates duplication between chunk bounds and counting operations
* Processes chunks based on filter criteria and accumulates results using provided processor function
*/
_processChunks(processor, initialValue, filter = () => true) {
let result = initialValue;
for (const [chunkKey, chunk] of this.maskChunks) {
if (filter(chunk)) {
result = processor(chunk, chunkKey, result);
}
}
return result;
}
/**
* Finds the bounds of all chunks that contain mask data
* Returns null if no chunks have data
*/
getAllChunkBounds() {
const filter = (chunk) => !chunk.isEmpty;
const processor = (chunk, chunkKey, bounds) => {
const [chunkXStr, chunkYStr] = chunkKey.split(',');
const chunkX = parseInt(chunkXStr);
const chunkY = parseInt(chunkYStr);
return {
minX: Math.min(bounds.minX, chunkX),
minY: Math.min(bounds.minY, chunkY),
maxX: Math.max(bounds.maxX, chunkX),
maxY: Math.max(bounds.maxY, chunkY),
hasData: true
};
};
const result = this._processChunks(processor, { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity, hasData: false }, filter);
return result.hasData ? { minX: result.minX, minY: result.minY, maxX: result.maxX, maxY: result.maxY } : null;
}
/**
* Finds the bounds of only active chunks that contain mask data
* Returns null if no active chunks have data
*/
getActiveChunkBounds() {
const filter = (chunk) => !chunk.isEmpty && chunk.isActive;
const processor = (chunk, chunkKey, bounds) => {
const [chunkXStr, chunkYStr] = chunkKey.split(',');
const chunkX = parseInt(chunkXStr);
const chunkY = parseInt(chunkYStr);
return {
minX: Math.min(bounds.minX, chunkX),
minY: Math.min(bounds.minY, chunkY),
maxX: Math.max(bounds.maxX, chunkX),
maxY: Math.max(bounds.maxY, chunkY),
hasData: true
};
};
const result = this._processChunks(processor, { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity, hasData: false }, filter);
return result.hasData ? { minX: result.minX, minY: result.minY, maxX: result.maxX, maxY: result.maxY } : null;
}
/**
* Counts all non-empty chunks
*/
getAllNonEmptyChunkCount() {
const filter = (chunk) => !chunk.isEmpty;
const processor = (chunk, chunkKey, count) => count + 1;
return this._processChunks(processor, 0, filter);
}
/**
* Counts active non-empty chunks
*/
getActiveChunkCount() {
const filter = (chunk) => !chunk.isEmpty && chunk.isActive;
const processor = (chunk, chunkKey, count) => count + 1;
return this._processChunks(processor, 0, filter);
}
/**
* Gets extension offset for shape positioning
*/
getExtensionOffset() {
const ext = this.canvasInstance.outputAreaExtensionEnabled ?
this.canvasInstance.outputAreaExtensions :
{ top: 0, bottom: 0, left: 0, right: 0 };
return { x: ext.left, y: ext.top };
}
/**
* Calculates chunk bounds for a given area
*/
calculateChunkBounds(left, top, right, bottom) {
return {
minX: Math.floor(left / this.chunkSize),
minY: Math.floor(top / this.chunkSize),
maxX: Math.floor(right / this.chunkSize),
maxY: Math.floor(bottom / this.chunkSize)
};
}
/**
* Activates chunks in a specific area and surrounding chunks for visibility
*/
activateChunksInArea(left, top, right, bottom) {
// First, deactivate all chunks
for (const chunk of this.maskChunks.values()) {
chunk.isActive = false;
}
const chunkBounds = this.calculateChunkBounds(left, top, right, bottom);
// Activate chunks in the area
for (let chunkY = chunkBounds.minY; chunkY <= chunkBounds.maxY; chunkY++) {
for (let chunkX = chunkBounds.minX; chunkX <= chunkBounds.maxX; chunkX++) {
const chunk = this.getChunkForPosition(chunkX * this.chunkSize, chunkY * this.chunkSize);
chunk.isActive = true;
chunk.lastAccessTime = Date.now();
}
}
// Also activate surrounding chunks for better visibility (3x3 grid around area)
const centerChunkX = Math.floor((left + right) / 2 / this.chunkSize);
const centerChunkY = Math.floor((top + bottom) / 2 / this.chunkSize);
for (let dy = -this.activeChunkRadius; dy <= this.activeChunkRadius; dy++) {
for (let dx = -this.activeChunkRadius; dx <= this.activeChunkRadius; dx++) {
const chunkX = centerChunkX + dx;
const chunkY = centerChunkY + dy;
const chunk = this.getChunkForPosition(chunkX * this.chunkSize, chunkY * this.chunkSize);
chunk.isActive = true;
chunk.lastAccessTime = Date.now();
}
}
return Array.from(this.maskChunks.values()).filter(chunk => chunk.isActive).length;
}
/**
* Calculates intersection between a chunk and a rectangular area
* Returns null if no intersection exists
*/
calculateChunkIntersection(chunk, areaLeft, areaTop, areaRight, areaBottom) {
const chunkLeft = chunk.x;
const chunkTop = chunk.y;
const chunkRight = chunk.x + this.chunkSize;
const chunkBottom = chunk.y + this.chunkSize;
// Find intersection
const intersectLeft = Math.max(chunkLeft, areaLeft);
const intersectTop = Math.max(chunkTop, areaTop);
const intersectRight = Math.min(chunkRight, areaRight);
const intersectBottom = Math.min(chunkBottom, areaBottom);
// Check if there's actually an intersection
if (intersectLeft >= intersectRight || intersectTop >= intersectBottom) {
return null; // No intersection
}
// Calculate source coordinates (relative to area)
const srcX = intersectLeft - areaLeft;
const srcY = intersectTop - areaTop;
const srcWidth = intersectRight - intersectLeft;
const srcHeight = intersectBottom - intersectTop;
// Calculate destination coordinates (relative to chunk)
const destX = intersectLeft - chunkLeft;
const destY = intersectTop - chunkTop;
const destWidth = srcWidth;
const destHeight = srcHeight;
return {
intersectLeft, intersectTop, intersectRight, intersectBottom,
srcX, srcY, srcWidth, srcHeight,
destX, destY, destWidth, destHeight
};
}
/**
* Checks if a chunk is empty by examining its pixel data
* Updates the chunk's isEmpty flag
*/
updateChunkEmptyStatus(chunk) {
const imageData = chunk.ctx.getImageData(0, 0, this.chunkSize, this.chunkSize);
const data = imageData.data;
let hasData = false;
// Check alpha channel for any non-zero values
for (let i = 3; i < data.length; i += 4) {
if (data[i] > 0) {
hasData = true;
break;
}
}
chunk.isEmpty = !hasData;
chunk.isDirty = true;
}
/**
* Marks chunk as dirty and not empty after drawing operations
*/
markChunkAsModified(chunk) {
chunk.isDirty = true;
chunk.isEmpty = false;
}
/**
* Logs chunk operation with standardized format
*/
logChunkOperation(operation, chunk, intersection) {
const chunkCoordX = Math.floor(chunk.x / this.chunkSize);
const chunkCoordY = Math.floor(chunk.y / this.chunkSize);
log.debug(`${operation} chunk (${chunkCoordX}, ${chunkCoordY}) at local position (${intersection.destX}, ${intersection.destY})`);
}
/**
* Universal chunk operation method - eliminates duplication between chunk operations
* Handles intersection calculation, drawing, and post-processing for all chunk operations
*/
performChunkOperation(chunk, source, sourceArea, operation, operationName) {
const intersection = this.calculateChunkIntersection(chunk, sourceArea.left, sourceArea.top, sourceArea.right, sourceArea.bottom);
if (!intersection) {
return; // No intersection
}
// Set composition mode based on operation
if (operation === 'remove') {
chunk.ctx.globalCompositeOperation = 'destination-out';
}
else {
chunk.ctx.globalCompositeOperation = 'source-over';
}
// Draw the source portion onto this chunk
chunk.ctx.drawImage(source, intersection.srcX, intersection.srcY, intersection.srcWidth, intersection.srcHeight, // Source rectangle
intersection.destX, intersection.destY, intersection.destWidth, intersection.destHeight // Destination rectangle
);
// Restore normal composition mode if it was changed
if (operation === 'remove') {
chunk.ctx.globalCompositeOperation = 'source-over';
}
// Update chunk status based on operation
if (operation === 'remove') {
this.updateChunkEmptyStatus(chunk);
}
else {
this.markChunkAsModified(chunk);
}
// Log the operation
this.logChunkOperation(operationName, chunk, intersection);
}
/**
* Triggers state change callback and renders canvas
*/
triggerStateChangeAndRender() {
if (this.onStateChange) {
this.onStateChange();
}
this.canvasInstance.render();
}
/**
* Saves mask state if tool is active
*/
saveMaskStateIfActive() {
if (this.isActive) {
this.canvasInstance.canvasState.saveMaskState();
}
}
/**
* Saves mask state, triggers state change and renders
*/
completeMaskOperation(saveState = true) {
if (saveState) {
this.canvasInstance.canvasState.saveMaskState();
}
this.triggerStateChangeAndRender();
}
/**
* Creates a canvas with specified dimensions and returns both canvas and context
*/
createCanvas(width, height) {
const { canvas, ctx } = createCanvas(width, height, '2d', { willReadFrequently: true });
if (!ctx) {
throw new Error("Failed to get 2D context for canvas");
}
return { canvas, ctx };
}
/**
* Draws shape points on a canvas context
*/
drawShapeOnCanvas(ctx, points, fillRule = 'evenodd') {
ctx.fillStyle = 'white';
ctx.beginPath();
ctx.moveTo(points[0].x, points[0].y);
for (let i = 1; i < points.length; i++) {
ctx.lineTo(points[i].x, points[i].y);
}
ctx.closePath();
ctx.fill(fillRule);
}
/**
* Creates binary mask data from shape points
*/
createBinaryMaskFromShape(points, width, height) {
const { canvas, ctx } = this.createCanvas(width, height);
this.drawShapeOnCanvas(ctx, points);
const maskImage = ctx.getImageData(0, 0, width, height);
const binaryData = new Uint8Array(width * height);
for (let i = 0; i < binaryData.length; i++) {
binaryData[i] = maskImage.data[i * 4] > 0 ? 1 : 0;
}
return binaryData;
}
/**
* Creates output canvas with image data
*/
createOutputCanvasFromImageData(imageData, width, height) {
const { canvas, ctx } = this.createCanvas(width, height);
ctx.putImageData(imageData, 0, 0);
return canvas;
}
/**
* Creates output canvas from processed pixel data
*/
createOutputCanvasFromPixelData(pixelProcessor, width, height) {
const { canvas, ctx } = this.createCanvas(width, height);
const outputData = ctx.createImageData(width, height);
pixelProcessor(outputData);
ctx.putImageData(outputData, 0, 0);
return canvas;
}
/**
* Draws contour points on a canvas context with stroke
*/
drawContourOnCanvas(ctx, points) {
if (points.length < 2)
return;
ctx.beginPath();
ctx.moveTo(points[0].x, points[0].y);
for (let i = 1; i < points.length; i++) {
ctx.lineTo(points[i].x, points[i].y);
}
ctx.closePath();
ctx.stroke();
}
/**
* Draws multiple contours on a canvas context for preview
*/
drawContoursForPreview(ctx, contours, strokeStyle, lineWidth, lineDash, globalAlpha) {
ctx.strokeStyle = strokeStyle;
ctx.lineWidth = lineWidth;
ctx.setLineDash(lineDash);
ctx.globalAlpha = globalAlpha;
for (const contour of contours) {
this.drawContourOnCanvas(ctx, contour);
}
}
/**
* Applies feather effect to distance map and creates ImageData
*/
applyFeatherToDistanceMap(distanceMap, binaryData, featherRadius, width, height) {
// Find the maximum distance to normalize
let maxDistance = 0;
for (let i = 0; i < distanceMap.length; i++) {
if (distanceMap[i] > maxDistance) {
maxDistance = distanceMap[i];
}
}
// Create ImageData with feather effect
const { canvas: tempCanvas, ctx: tempCtx } = this.createCanvas(width, height);
const outputData = tempCtx.createImageData(width, height);
// Use featherRadius as the threshold for the gradient
const threshold = Math.min(featherRadius, maxDistance);
for (let i = 0; i < distanceMap.length; i++) {
const distance = distanceMap[i];
const isInside = binaryData[i] === 1;
if (!isInside) {
// Transparent pixels remain transparent
outputData.data[i * 4] = 255;
outputData.data[i * 4 + 1] = 255;
outputData.data[i * 4 + 2] = 255;
outputData.data[i * 4 + 3] = 0;
}
else if (distance <= threshold) {
// Edge area - apply gradient alpha (from edge inward)
const gradientValue = distance / threshold;
const alphaValue = Math.floor(gradientValue * 255);
outputData.data[i * 4] = 255;
outputData.data[i * 4 + 1] = 255;
outputData.data[i * 4 + 2] = 255;
outputData.data[i * 4 + 3] = alphaValue;
}
else {
// Inner area - full alpha (no blending effect)
outputData.data[i * 4] = 255;
outputData.data[i * 4 + 1] = 255;
outputData.data[i * 4 + 2] = 255;
outputData.data[i * 4 + 3] = 255;
}
}
return outputData;
}
/**
* Creates feathered mask canvas from binary data - unified logic for feathering
* This eliminates duplication between _createFeatheredMaskCanvas and _createFeatheredMaskFromImageData
*/
createFeatheredMaskFromBinaryData(binaryData, featherRadius, width, height) {
// Calculate the fast distance transform
const distanceMap = this._fastDistanceTransform(binaryData, width, height);
// Find the maximum distance to normalize
let maxDistance = 0;
for (let i = 0; i < distanceMap.length; i++) {
if (distanceMap[i] > maxDistance) {
maxDistance = distanceMap[i];
}
}
// Create the final output canvas with feather effect
const featherImageData = this.applyFeatherToDistanceMap(distanceMap, binaryData, featherRadius, width, height);
return this.createOutputCanvasFromImageData(featherImageData, width, height);
}
/**
* Prepares shape mask configuration data - eliminates duplication between applyShapeMask and removeShapeMask
* Returns all necessary data for shape mask operations including world coordinates and temporary canvas setup
* Now uses precise expansion calculation based on actual user values
*/
prepareShapeMaskConfiguration() {
// Validate shape
if (!this.canvasInstance.outputAreaShape?.points || this.canvasInstance.outputAreaShape.points.length < 3) {
return null;
}
const shape = this.canvasInstance.outputAreaShape;
const bounds = this.canvasInstance.outputAreaBounds;
// Calculate shape points in world coordinates accounting for extensions
const extensionOffset = this.getExtensionOffset();
const worldShapePoints = shape.points.map(p => ({
x: bounds.x + extensionOffset.x + p.x,
y: bounds.y + extensionOffset.y + p.y
}));
// Use precise expansion calculation - only actual user value + small safety margin
const userExpansionValue = Math.abs(this.canvasInstance.shapeMaskExpansionValue || 0);
const safetyMargin = 10; // Small safety margin for precise operations
const preciseExpansion = userExpansionValue + safetyMargin;
const tempCanvasWidth = bounds.width + (preciseExpansion * 2);
const tempCanvasHeight = bounds.height + (preciseExpansion * 2);
const tempOffsetX = preciseExpansion;
const tempOffsetY = preciseExpansion;
// Adjust shape points for the temporary canvas
const tempShapePoints = worldShapePoints.map(p => ({
x: p.x - bounds.x + tempOffsetX,
y: p.y - bounds.y + tempOffsetY
}));
return {
shape,
bounds,
extensionOffset,
worldShapePoints,
maxExpansion: preciseExpansion,
tempCanvasWidth,
tempCanvasHeight,
tempOffsetX,
tempOffsetY,
tempShapePoints
};
}
/**
* Updates which chunks are active for drawing operations based on current drawing position
* Only activates chunks in a radius around the drawing position for performance
*/
updateActiveChunksForDrawing(worldCoords) {
const currentChunkX = Math.floor(worldCoords.x / this.chunkSize);
const currentChunkY = Math.floor(worldCoords.y / this.chunkSize);
// Update current drawing chunk
this.currentDrawingChunk = { x: currentChunkX, y: currentChunkY };
// Deactivate all chunks first
for (const chunk of this.maskChunks.values()) {
chunk.isActive = false;
}
// Activate chunks in radius around current drawing position
let activatedCount = 0;
for (let dy = -this.activeChunkRadius; dy <= this.activeChunkRadius; dy++) {
for (let dx = -this.activeChunkRadius; dx <= this.activeChunkRadius; dx++) {
const chunkX = currentChunkX + dx;
const chunkY = currentChunkY + dy;
const chunkKey = `${chunkX},${chunkY}`;
// Get or create chunk if it doesn't exist
const chunk = this.getChunkForPosition(chunkX * this.chunkSize, chunkY * this.chunkSize);
chunk.isActive = true;
chunk.lastAccessTime = Date.now();
activatedCount++;
// Safety check to prevent too many active chunks
if (activatedCount >= this.maxActiveChunks) {
log.warn(`Reached maximum active chunks limit (${this.maxActiveChunks})`);
return;
}
}
}
log.debug(`Activated ${activatedCount} chunks around drawing position (${currentChunkX}, ${currentChunkY})`);
}
/**
* Gets or creates a chunk for the given world coordinates
*/
getChunkForPosition(worldX, worldY) {
const chunkX = Math.floor(worldX / this.chunkSize);
const chunkY = Math.floor(worldY / this.chunkSize);
const chunkKey = `${chunkX},${chunkY}`;
let chunk = this.maskChunks.get(chunkKey);
if (!chunk) {
chunk = this.createChunk(chunkX, chunkY);
this.maskChunks.set(chunkKey, chunk);
}
return chunk;
}
/**
* Creates a new chunk at the given chunk coordinates
*/
createChunk(chunkX, chunkY) {
const { canvas, ctx } = this.createCanvas(this.chunkSize, this.chunkSize);
const chunk = {
canvas,
ctx,
x: chunkX * this.chunkSize,
y: chunkY * this.chunkSize,
isDirty: false,
isEmpty: true,
isActive: false,
lastAccessTime: Date.now()
};
log.debug(`Created chunk at (${chunkX}, ${chunkY}) covering world area (${chunk.x}, ${chunk.y}) to (${chunk.x + this.chunkSize}, ${chunk.y + this.chunkSize})`);
return chunk;
}
activate() {
if (!this.previewCanvasInitialized) {
this.initPreviewCanvas();
this.previewCanvasInitialized = true;
}
this.isActive = true;
this.previewCanvas.style.display = 'block';
this.canvasInstance.interaction.mode = 'drawingMask';
if (this.canvasInstance.canvasState.maskUndoStack.length === 0) {
this.canvasInstance.canvasState.saveMaskState();
}
this.canvasInstance.updateHistoryButtons();
log.info("Mask tool activated");
}
deactivate() {
this.isActive = false;
this.previewCanvas.style.display = 'none';
this.canvasInstance.interaction.mode = 'none';
this.canvasInstance.updateHistoryButtons();
log.info("Mask tool deactivated");
}
setBrushSize(size) {
this.brushSize = Math.max(1, size);
}
setBrushStrength(strength) {
this._brushStrength = Math.max(0, Math.min(1, strength));
}
handleMouseDown(worldCoords, viewCoords) {
if (!this.isActive)
return;
this.isDrawing = true;
this.lastPosition = worldCoords;
// Initialize stroke tracking for live preview
this.currentStrokePoints = [worldCoords];
// Clear any previous stroke overlay
this.canvasInstance.canvasRenderer.clearMaskStrokeOverlay();
this.clearPreview();
}
handleMouseMove(worldCoords, viewCoords) {
if (this.isActive) {
this.drawBrushPreview(viewCoords);
}
if (!this.isActive || !this.isDrawing)
return;
// Add point to stroke tracking
this.currentStrokePoints.push(worldCoords);
// Draw interpolated segments for smooth strokes without gaps
if (this.lastPosition) {
// Calculate distance between last and current position
const dx = worldCoords.x - this.lastPosition.x;
const dy = worldCoords.y - this.lastPosition.y;
const distance = Math.sqrt(dx * dx + dy * dy);
// If distance is small, just draw a single segment
if (distance < this.brushSize / 4) {
this.canvasInstance.canvasRenderer.drawMaskStrokeSegment(this.lastPosition, worldCoords);
}
else {
// Interpolate points for smooth drawing without gaps
const interpolatedPoints = this.interpolatePoints(this.lastPosition, worldCoords, distance);
// Draw all interpolated segments
for (let i = 0; i < interpolatedPoints.length - 1; i++) {
this.canvasInstance.canvasRenderer.drawMaskStrokeSegment(interpolatedPoints[i], interpolatedPoints[i + 1]);
}
}
}
this.lastPosition = worldCoords;
}
/**
* Interpolates points between two positions to create smooth strokes without gaps
* Based on the BrushTool's approach for eliminating dotted lines during fast drawing
*/
interpolatePoints(start, end, distance) {
const points = [];
// Calculate number of interpolated points based on brush size
// More points = smoother line
const stepSize = Math.max(1, this.brushSize / 6); // Adjust divisor for smoothness
const numSteps = Math.ceil(distance / stepSize);
// Always include start point
points.push(start);
// Interpolate intermediate points
for (let i = 1; i < numSteps; i++) {
const t = i / numSteps;
points.push({
x: start.x + (end.x - start.x) * t,
y: start.y + (end.y - start.y) * t
});
}
// Always include end point
points.push(end);
return points;
}
/**
* Called when viewport changes during drawing to update stroke overlay
* This ensures the stroke preview scales correctly with zoom changes
*/
handleViewportChange() {
if (this.isDrawing && this.currentStrokePoints.length > 1) {
// Redraw the entire stroke overlay with new viewport settings
this.canvasInstance.canvasRenderer.redrawMaskStrokeOverlay(this.currentStrokePoints);
}
}
handleMouseLeave() {
this.previewVisible = false;
this.clearPreview();
// Clear overlay canvases when mouse leaves
this.canvasInstance.canvasRenderer.clearOverlay();
this.canvasInstance.canvasRenderer.clearMaskStrokeOverlay();
}
handleMouseEnter() {
this.previewVisible = true;
}
handleMouseUp(viewCoords) {
if (!this.isActive)
return;
if (this.isDrawing) {
this.isDrawing = false;
// Commit the stroke from overlay to actual mask chunks
this.commitStrokeToChunks();
// Clear stroke overlay and reset state
this.canvasInstance.canvasRenderer.clearMaskStrokeOverlay();
this.currentStrokePoints = [];
this.lastPosition = null;
this.currentDrawingChunk = null;
// After drawing is complete, update active canvas to show all chunks
this.updateActiveMaskCanvas(true); // Force full update
this.completeMaskOperation();
this.drawBrushPreview(viewCoords);
}
}
draw(worldCoords) {
if (!this.lastPosition) {
this.lastPosition = worldCoords;
}
// Draw on chunks instead of single canvas
this.drawOnChunks(this.lastPosition, worldCoords);
// Only update active canvas if we drew on chunks that are currently visible
// This prevents unnecessary recomposition during drawing
this.updateActiveCanvasIfNeeded(this.lastPosition, worldCoords);
}
/**
* Commits the current stroke from overlay to actual mask chunks
* This replays the entire stroke path with interpolation to ensure pixel-perfect accuracy
*/
commitStrokeToChunks() {
if (this.currentStrokePoints.length < 2) {
return; // Need at least 2 points for a stroke
}
log.debug(`Committing stroke with ${this.currentStrokePoints.length} points to chunks`);
// Replay the entire stroke path with interpolation for smooth, accurate lines
for (let i = 1; i < this.currentStrokePoints.length; i++) {
const startPoint = this.currentStrokePoints[i - 1];
const endPoint = this.currentStrokePoints[i];
// Calculate distance between points
const dx = endPoint.x - startPoint.x;
const dy = endPoint.y - startPoint.y;
const distance = Math.sqrt(dx * dx + dy * dy);
if (distance < this.brushSize / 4) {
// Small distance - draw single segment
this.drawOnChunks(startPoint, endPoint);
}
else {
// Large distance - interpolate for smooth line without gaps
const interpolatedPoints = this.interpolatePoints(startPoint, endPoint, distance);
// Draw all interpolated segments
for (let j = 0; j < interpolatedPoints.length - 1; j++) {
this.drawOnChunks(interpolatedPoints[j], interpolatedPoints[j + 1]);
}
}
}
log.debug("Stroke committed to chunks successfully with interpolation");
}
/**
* Draws a line between two world coordinates on the appropriate chunks
*/
drawOnChunks(startWorld, endWorld) {
// Calculate all chunks that this line might touch
const minX = Math.min(startWorld.x, endWorld.x) - this.brushSize;
const maxX = Math.max(startWorld.x, endWorld.x) + this.brushSize;
const minY = Math.min(startWorld.y, endWorld.y) - this.brushSize;
const maxY = Math.max(startWorld.y, endWorld.y) + this.brushSize;
const chunkMinX = Math.floor(minX / this.chunkSize);
const chunkMinY = Math.floor(minY / this.chunkSize);
const chunkMaxX = Math.floor(maxX / this.chunkSize);
const chunkMaxY = Math.floor(maxY / this.chunkSize);
// Draw on all affected chunks
for (let chunkY = chunkMinY; chunkY <= chunkMaxY; chunkY++) {
for (let chunkX = chunkMinX; chunkX <= chunkMaxX; chunkX++) {
const chunk = this.getChunkForPosition(chunkX * this.chunkSize, chunkY * this.chunkSize);
this.drawLineOnChunk(chunk, startWorld, endWorld);
}
}
}
/**
* Draws a line on a specific chunk
*/
drawLineOnChunk(chunk, startWorld, endWorld) {
// Convert world coordinates to chunk-local coordinates
const startLocal = {
x: startWorld.x - chunk.x,
y: startWorld.y - chunk.y
};
const endLocal = {
x: endWorld.x - chunk.x,
y: endWorld.y - chunk.y
};
// Check if the line intersects this chunk
if (!this.lineIntersectsChunk(startLocal, endLocal, this.chunkSize)) {
return;
}
// Draw the line on this chunk
chunk.ctx.beginPath();
chunk.ctx.moveTo(startLocal.x, startLocal.y);
chunk.ctx.lineTo(endLocal.x, endLocal.y);
const gradientRadius = this.brushSize / 2;
if (this._brushHardness === 1) {
chunk.ctx.strokeStyle = `rgba(255, 255, 255, ${this._brushStrength})`;
}
else {
const innerRadius = gradientRadius * this._brushHardness;
const gradient = chunk.ctx.createRadialGradient(endLocal.x, endLocal.y, innerRadius, endLocal.x, endLocal.y, gradientRadius);
gradient.addColorStop(0, `rgba(255, 255, 255, ${this._brushStrength})`);
gradient.addColorStop(1, `rgba(255, 255, 255, 0)`);
chunk.ctx.strokeStyle = gradient;
}
chunk.ctx.lineWidth = this.brushSize;
chunk.ctx.lineCap = 'round';
chunk.ctx.lineJoin = 'round';
chunk.ctx.globalCompositeOperation = 'source-over';
chunk.ctx.stroke();
// Mark chunk as dirty and not empty
this.markChunkAsModified(chunk);
log.debug(`Drew on chunk (${Math.floor(chunk.x / this.chunkSize)}, ${Math.floor(chunk.y / this.chunkSize)})`);
}
/**
* Checks if a line intersects with a chunk bounds
*/
lineIntersectsChunk(startLocal, endLocal, chunkSize) {
// Expand bounds by brush size to catch partial intersections
const margin = this.brushSize / 2;
const left = -margin;
const top = -margin;
const right = chunkSize + margin;
const bottom = chunkSize + margin;
// Check if either point is inside the expanded bounds
if ((startLocal.x >= left && startLocal.x <= right && startLocal.y >= top && startLocal.y <= bottom) ||
(endLocal.x >= left && endLocal.x <= right && endLocal.y >= top && endLocal.y <= bottom)) {
return true;
}
// Check if line crosses chunk bounds (simplified check)
return true; // For now, always draw - more precise intersection can be added later
}
/**
* Updates active canvas when drawing affects chunks
* Since we now use overlay during drawing, this is only called after drawing is complete
*/
updateActiveCanvasIfNeeded(startWorld, endWorld) {
// This method is now simplified - we only update after drawing is complete
// The overlay handles all live preview, so we don't need complex chunk activation
if (!this.isDrawing) {
// Not drawing - do full update to show all chunks
this.updateActiveMaskCanvas(true);
}
// During drawing, we don't update chunks at all - overlay handles preview
}
/**
* Schedules a throttled update of the active mask canvas to prevent excessive redraws
* Only updates at most once per ACTIVE_MASK_UPDATE_DELAY milliseconds
*/
scheduleThrottledActiveMaskUpdate(chunkMinX, chunkMinY, chunkMaxX, chunkMaxY) {
// Mark that an update is needed
this.activeMaskNeedsUpdate = true;
// If there's already a pending update, don't schedule another one
if (this.activeMaskUpdateTimeout !== null) {
return;
}
// Schedule the update with throttling
this.activeMaskUpdateTimeout = window.setTimeout(() => {
if (this.activeMaskNeedsUpdate) {
// Perform partial update for the affected chunks
this.updateActiveCanvasPartial(chunkMinX, chunkMinY, chunkMaxX, chunkMaxY);
this.activeMaskNeedsUpdate = false;
log.debug("Performed throttled partial active canvas update");
}
this.activeMaskUpdateTimeout = null;
}, this.ACTIVE_MASK_UPDATE_DELAY);
}
/**
* Partially updates the active canvas by redrawing only specific chunks that are active
* During drawing, only updates active chunks for performance
* Now handles dynamic chunk activation by expanding canvas if needed
*/
updateActiveCanvasPartial(chunkMinX, chunkMinY, chunkMaxX, chunkMaxY) {
// Check if any active chunks are outside current canvas bounds
const activeChunkBounds = this.getActiveChunkBounds();
const allChunkBounds = this.getAllChunkBounds();
if (!allChunkBounds) {
return; // No chunks at all
}
// If active chunks extend beyond current canvas, do full update to resize canvas
if (activeChunkBounds && this.activeChunkBounds &&
(activeChunkBounds.minX < this.activeChunkBounds.minX ||
activeChunkBounds.maxX > this.activeChunkBounds.maxX ||
activeChunkBounds.minY < this.activeChunkBounds.minY ||
activeChunkBounds.maxY > this.activeChunkBounds.maxY)) {
log.debug("Active chunks extended beyond canvas bounds - performing full update");
this.updateActiveMaskCanvas(true);
return;
}
if (!this.activeChunkBounds) {
// No active bounds - do full update
this.updateActiveMaskCanvas();
return;
}
// Only redraw the affected chunks that are active and within the current active canvas bounds
for (let chunkY = chunkMinY; chunkY <= chunkMaxY; chunkY++) {
for (let chunkX = chunkMinX; chunkX <= chunkMaxX; chunkX++) {
// Check if this chunk is within canvas bounds (all chunks with data)
if (chunkX >= this.activeChunkBounds.minX && chunkX <= this.activeChunkBounds.maxX &&
chunkY >= this.activeChunkBounds.minY && chunkY <= this.activeChunkBounds.maxY) {
const chunkKey = `${chunkX},${chunkY}`;
const chunk = this.maskChunks.get(chunkKey);
// Update if chunk exists and is currently active (regardless of isEmpty for new chunks)
if (chunk && chunk.isActive) {
// Calculate position on active canvas (relative to all chunks bounds)
const destX = (chunkX - this.activeChunkBounds.minX) * this.chunkSize;
const destY = (chunkY - this.activeChunkBounds.minY) * this.chunkSize;
// Clear the area first, then redraw
this.activeMaskCtx.clearRect(destX, destY, this.chunkSize, this.chunkSize);
if (!chunk.isEmpty) {
this.activeMaskCtx.drawImage(chunk.canvas, destX, destY);
}
log.debug(`Partial update: refreshed active chunk (${chunkX}, ${chunkY}) - isEmpty: ${chunk.isEmpty}`);
}
}
}
}
}
drawBrushPreview(viewCoords) {
if (!this.previewVisible || this.isDrawing) {
this.canvasInstance.canvasRenderer.clearOverlay();
return;
}
// Use overlay canvas instead of preview canvas for brush cursor
const worldCoords = this.canvasInstance.lastMousePosition;
this.canvasInstance.canvasRenderer.drawMaskBrushCursor(worldCoords);
}
clearPreview() {
this.previewCtx.clearRect(0, 0, this.previewCanvas.width, this.previewCanvas.height);
this.clearShapePreview();
}
/**
* Initialize shape preview canvas for showing blue outline during slider adjustments
* Canvas is pinned to viewport and covers the entire visible area
*/
initShapePreviewCanvas() {
if (this.shapePreviewCanvas.parentElement) {
this.shapePreviewCanvas.parentElement.removeChild(this.shapePreviewCanvas);
}
// Canvas covers entire viewport - pinned to screen, not world
this.shapePreviewCanvas.width = this.canvasInstance.canvas.width;
this.shapePreviewCanvas.height = this.canvasInstance.canvas.height;
// Pin canvas to viewport - no world coordinate positioning
this.shapePreviewCanvas.style.position = 'absolute';
this.shapePreviewCanvas.style.left = '0px';
this.shapePreviewCanvas.style.top = '0px';
this.shapePreviewCanvas.style.width = '100%';
this.shapePreviewCanvas.style.height = '100%';
this.shapePreviewCanvas.style.pointerEvents = 'none';
this.shapePreviewCanvas.style.zIndex = '15'; // Above regular preview
this.shapePreviewCanvas.style.imageRendering = 'pixelated'; // Sharp rendering
if (this.canvasInstance.canvas.parentElement) {
this.canvasInstance.canvas.parentElement.appendChild(this.shapePreviewCanvas);
}
}
/**
* Show blue outline preview of expansion/contraction during slider adjustment
*/
showShapePreview(expansionValue, featherValue = 0) {
// Store the parameters for throttled execution
this.pendingPreviewParams = { expansionValue, featherValue };
// If there's already a pending preview update, don't schedule another one
if (this.shapePreviewThrottleTimeout !== null) {
return;
}
// Schedule the preview update with throttling
this.shapePreviewThrottleTimeout = window.setTimeout(() => {
if (this.pendingPreviewParams) {
this.executeShapePreview(this.pendingPreviewParams.expansionValue, this.pendingPreviewParams.featherValue);
this.pendingPreviewParams = null;
}
this.shapePreviewThrottleTimeout = null;
}, this.SHAPE_PREVIEW_THROTTLE_DELAY);
}
/**
* Executes the actual shape preview rendering - separated from showShapePreview for throttling
*/
executeShapePreview(expansionValue, featherValue = 0) {
if (!this.canvasInstance.outputAreaShape?.points || this.canvasInstance.outputAreaShape.points.length < 3) {
return;
}
if (!this.shapePreviewCanvas.parentElement)
this.initShapePreviewCanvas();
this.isPreviewMode = true;
this.shapePreviewVisible = true;
this.shapePreviewCanvas.style.display = 'block';
this.clearShapePreview();
const shape = this.canvasInstance.outputAreaShape;
const viewport = this.canvasInstance.viewport;
const bounds = this.canvasInstance.outputAreaBounds;
// Convert shape points to world coordinates first accounting for extensions
const extensionOffset = this.getExtensionOffset();
const worldShapePoints = shape.points.map(p => ({
x: bounds.x + extensionOffset.x + p.x,
y: bounds.y + extensionOffset.y + p.y
}));
// Then convert world coordinates to screen coordinates
const screenPoints = worldShapePoints.map(p => ({
x: (p.x - viewport.x) * viewport.zoom,
y: (p.y - viewport.y) * viewport.zoom
}));
// This function now returns Point[][] to handle islands.
const allContours = this._calculatePreviewPointsScreen([screenPoints], expansionValue, viewport.zoom);
// Draw main expansion/contraction preview
this.drawContoursForPreview(this.shapePreviewCtx, allContours, '#4A9EFF', 2, [4, 4], 0.8);
// Draw feather preview
if (featherValue > 0) {
const allFeatherContours = this._calculatePreviewPointsScreen(allContours, -featherValue, viewport.zoom);
this.drawContoursForPreview(this.shapePreviewCtx, allFeatherContours, '#4A9EFF', 1, [3, 5], 0.6);
}
log.debug(`Shape preview executed with expansion: ${expansionValue}px, feather: ${featherValue}px at bounds (${bounds.x}, ${bounds.y})`);
}
/**
* Hide shape preview and switch back to normal mode
*/
hideShapePreview() {
// Cancel any pending throttled preview updates to prevent race conditions
if (this.shapePreviewThrottleTimeout !== null) {
clearTimeout(this.shapePreviewThrottleTimeout);
this.shapePreviewThrottleTimeout = null;
}
// Clear any pending preview parameters
this.pendingPreviewParams = null;
this.isPreviewMode = false;
this.shapePreviewVisible = false;
this.clearShapePreview();
this.shapePreviewCanvas.style.display = 'none';
log.debug("Shape preview hidden and all pending operations cancelled");
}
/**
* Clear shape preview canvas
*/
clearShapePreview() {
if (this.shapePreviewCtx) {
this.shapePreviewCtx.clearRect(0, 0, this.shapePreviewCanvas.width, this.shapePreviewCanvas.height);
}
}
/**
* Update shape preview canvas position and scale when viewport changes
* This ensures the preview stays synchronized with the world coordinates
*/
updateShapePreviewPosition() {
if (!this.shapePreviewCanvas.parentElement || !this.shapePreviewVisible) {
return;
}
const viewport = this.canvasInstance.viewport;
const bufferSize = 300;
// Calculate world position (output area + buffer)
const previewX = -bufferSize; // World coordinates
const previewY = -bufferSize;
// Convert to screen coordinates
const screenX = (previewX - viewport.x) * viewport.zoom;
const screenY = (previewY - viewport.y) * viewport.zoom;
// Update position and scale
this.shapePreviewCanvas.style.left = `${screenX}px`;
this.shapePreviewCanvas.style.top = `${screenY}px`;
const previewWidth = this.canvasInstance.width + (bufferSize * 2);
const previewHeight = this.canvasInstance.height + (bufferSize * 2);
this.shapePreviewCanvas.style.width = `${previewWidth * viewport.zoom}px`;
this.shapePreviewCanvas.style.height = `${previewHeight * viewport.zoom}px`;
}
/**
* Universal morphological operation using Distance Transform + thresholding
* Combines dilation and erosion into one optimized function
*/
_fastMorphologyDT(mask, width, height, radius, isDilation) {
const INF = 1e9;
const dist = new Float32Array(width * height);
// 1. Initialize based on operation type
for (let i = 0; i < width * height; ++i) {
if (isDilation) {
// Dilation: 0 for foreground, INF for background
dist[i] = mask[i] ? 0 : INF;
}
else {
// Erosion: 0 for background, INF for foreground
dist[i] = mask[i] ? INF : 0;
}
}
// 2. Forward pass: top-left -> bottom-right
for (let y = 0; y < height; ++y) {
for (let x = 0; x < width; ++x) {
const i = y * width + x;
// Skip condition based on operation type
if (isDilation ? mask[i] : !mask[i])
continue;
if (x > 0)
dist[i] = Math.min(dist[i], dist[y * width + (x - 1)] + 1);
if (y > 0)
dist[i] = Math.min(dist[i], dist[(y - 1) * width + x] + 1);
}
}
// 3. Backward pass: bottom-right -> top-left
for (let y = height - 1; y >= 0; --y) {
for (let x = width - 1; x >= 0; --x) {
const i = y * width + x;
// Skip condition based on operation type
if (isDilation ? mask[i] : !mask[i])
continue;
if (x < width - 1)
dist[i] = Math.min(dist[i], dist[y * width + (x + 1)] + 1);
if (y < height - 1)
dist[i] = Math.min(dist[i], dist[(y + 1) * width + x] + 1);
}
}
// 4. Thresholding based on operation type
const result = new Uint8Array(width * height);
for (let i = 0; i < width * height; ++i) {
if (isDilation) {
// Dilation: if distance <= radius, it's part of the expanded mask
result[i] = dist[i] <= radius ? 1 : 0;
}
else {
// Erosion: if distance > radius, it's part of the eroded mask
result[i] = dist[i] > radius ? 1 : 0;
}
}
return result;
}
/**
* Fast dilation using unified morphology function
*/
_fastDilateDT(mask, width, height, radius) {
return this._fastMorphologyDT(mask, width, height, radius, true);
}
/**
* Fast erosion using unified morphology function
*/
_fastErodeDT(mask, width, height, radius) {
return this._fastMorphologyDT(mask, width, height, radius, false);
}
/**
* Calculate preview points using screen coordinates for pinned canvas.
* This version now accepts multiple contours and returns multiple contours.
*/
_calculatePreviewPointsScreen(contours, expansionValue, zoom) {
if (contours.length === 0 || expansionValue === 0)
return contours;
const width = this.canvasInstance.canvas.width;
const height = this.canvasInstance.canvas.height;
const { canvas: tempCanvas, ctx: tempCtx } = this.createCanvas(width, height);
// Draw all contours to create the initial mask
tempCtx.fillStyle = 'white';
for (const points of contours) {
if (points.length < 3)
continue;
tempCtx.beginPath();
tempCtx.moveTo(points[0].x, points[0].y);
for (let i = 1; i < points.length; i++) {
tempCtx.lineTo(points[i].x, points[i].y);
}
tempCtx.closePath();
tempCtx.fill('evenodd'); // Use evenodd to handle holes correctly
}
const maskImage = tempCtx.getImageData(0, 0, width, height);
const binaryData = new Uint8Array(width * height);
for (let i = 0; i < binaryData.length; i++) {
binaryData[i] = maskImage.data[i * 4] > 0 ? 1 : 0;
}
let resultMask;
const scaledExpansionValue = Math.round(Math.abs(expansionValue * zoom));
if (expansionValue >= 0) {
resultMask = this._fastDilateDT(binaryData, width, height, scaledExpansionValue);
}
else {
resultMask = this._fastErodeDT(binaryData, width, height, scaledExpansionValue);
}
// Extract all contours (outer and inner) from the resulting mask
const allResultContours = this._traceAllContours(resultMask, width, height);
return allResultContours.length > 0 ? allResultContours : contours;
}
/**
* Calculate preview points in world coordinates using morphological operations
* This version works directly with mask canvas coordinates
*/
/**
* Traces all contours (outer and inner islands) from a binary mask.
* @returns An array of contours, where each contour is an array of points.
*/
_traceAllContours(mask, width, height) {
const contours = [];
const visited = new Uint8Array(mask.length); // Keep track of visited pixels
for (let y = 1; y < height - 1; y++) {
for (let x = 1; x < width - 1; x++) {
const idx = y * width + x;
// Check for a potential starting point: a foreground pixel that hasn't been visited
// and is on a boundary (next to a background pixel).
if (mask[idx] === 1 && visited[idx] === 0) {
// Check if it's a boundary pixel
const isBoundary = mask[idx - 1] === 0 ||
mask[idx + 1] === 0 ||
mask[idx - width] === 0 ||
mask[idx + width] === 0;
if (isBoundary) {
// Found a new contour, let's trace it.
const contour = this._traceSingleContour({ x, y }, mask, width, height, visited);
if (contour.length > 2) {
// --- Path Simplification ---
const simplifiedContour = [];
const simplificationFactor = Math.max(1, Math.floor(contour.length / 200));
for (let i = 0; i < contour.length; i += simplificationFactor) {
simplifiedContour.push(contour[i]);
}
contours.push(simplifiedContour);
}
}
}
}
}
return contours;
}
/**
* Traces a single contour from a starting point using Moore-Neighbor algorithm.
*/
_traceSingleContour(startPoint, mask, width, height, visited) {
const contour = [];
let { x, y } = startPoint;
// Neighbor checking order (clockwise)
const neighbors = [
{ dx: 0, dy: -1 }, // N
{ dx: 1, dy: -1 }, // NE
{ dx: 1, dy: 0 }, // E
{ dx: 1, dy: 1 }, // SE
{ dx: 0, dy: 1 }, // S
{ dx: -1, dy: 1 }, // SW
{ dx: -1, dy: 0 }, // W
{ dx: -1, dy: -1 } // NW
];
let initialNeighborIndex = 0;
do {
let foundNext = false;
for (let i = 0; i < 8; i++) {
const neighborIndex = (initialNeighborIndex + i) % 8;
const nx = x + neighbors[neighborIndex].dx;
const ny = y + neighbors[neighborIndex].dy;
const nIdx = ny * width + nx;
if (nx >= 0 && nx < width && ny >= 0 && ny < height && mask[nIdx] === 1) {
contour.push({ x, y });
visited[y * width + x] = 1; // Mark current point as visited
x = nx;
y = ny;
initialNeighborIndex = (neighborIndex + 5) % 8;
foundNext = true;
break;
}
}
if (!foundNext)
break; // End if no next point found
} while (x !== startPoint.x || y !== startPoint.y);
return contour;
}
clear() {
// Clear all mask chunks instead of just the active canvas
this.clearAllMaskChunks();
// Update active mask canvas to reflect the cleared state
this.updateActiveMaskCanvas();
if (this.isActive) {
this.canvasInstance.canvasState.saveMaskState();
}
// Trigger render to show the cleared mask
this.canvasInstance.render();
log.info("Cleared all mask data from all chunks");
}
/**
* Clears all chunks and restores mask from saved state
* This is used during undo/redo operations to ensure clean state restoration
*/
restoreMaskFromSavedState(savedMaskCanvas) {
// First, clear ALL chunks to ensure no leftover data
this.clearAllMaskChunks();
// Now apply the saved mask state to chunks
if (savedMaskCanvas.width > 0 && savedMaskCanvas.height > 0) {
// Apply the saved mask to the chunk system at the correct position
const bounds = this.canvasInstance.outputAreaBounds;
this.applyMaskCanvasToChunks(savedMaskCanvas, this.x, this.y);
}
// Update the active mask canvas to show the restored state
this.updateActiveMaskCanvas(true);
log.debug("Restored mask from saved state with clean chunk system");
}
getMask() {
// Return the current active mask canvas which shows all chunks
// Only update if there are pending changes to avoid unnecessary redraws
if (this.activeMaskNeedsUpdate) {
this.updateActiveMaskCanvas();
this.activeMaskNeedsUpdate = false;
}
return this.activeMaskCanvas;
}
/**
* Gets mask only for the output area - optimized for performance
* Returns only the portion of the mask that overlaps with the output area
* This is much more efficient than returning the entire mask when there are many chunks
*/
getMaskForOutputArea() {
const bounds = this.canvasInstance.outputAreaBounds;
// Create canvas sized to output area
const { canvas: outputMaskCanvas, ctx: outputMaskCtx } = createCanvas(bounds.width, bounds.height, '2d', { willReadFrequently: true });
if (!outputMaskCtx) {
throw new Error("Failed to get 2D context for output area mask canvas");
}
// Calculate which chunks overlap with the output area
const outputLeft = bounds.x;
const outputTop = bounds.y;
const outputRight = bounds.x + bounds.width;
const outputBottom = bounds.y + bounds.height;
const chunkBounds = this.calculateChunkBounds(outputLeft, outputTop, outputRight, outputBottom);
// Only process chunks that overlap with output area
for (let chunkY = chunkBounds.minY; chunkY <= chunkBounds.maxY; chunkY++) {
for (let chunkX = chunkBounds.minX; chunkX <= chunkBounds.maxX; chunkX++) {
const chunkKey = `${chunkX},${chunkY}`;
const chunk = this.maskChunks.get(chunkKey);
if (chunk && !chunk.isEmpty) {
// Calculate intersection between chunk and output area
const intersection = this.calculateChunkIntersection(chunk, outputLeft, outputTop, outputRight, outputBottom);
if (intersection) {
// Draw only the intersecting portion
outputMaskCtx.drawImage(chunk.canvas, intersection.destX, intersection.destY, intersection.destWidth, intersection.destHeight, // Source from chunk
intersection.srcX, intersection.srcY, intersection.srcWidth, intersection.srcHeight // Destination on output canvas
);
}
}
}
}
log.debug(`Generated output area mask (${bounds.width}x${bounds.height}) from ${chunkBounds.maxX - chunkBounds.minX + 1}x${chunkBounds.maxY - chunkBounds.minY + 1} chunks`);
return outputMaskCanvas;
}
resize(width, height) {
this.initPreviewCanvas();
const oldMask = this.maskCanvas;
const oldX = this.x;
const oldY = this.y;
const oldWidth = oldMask.width;
const oldHeight = oldMask.height;
const isIncreasingWidth = width > this.canvasInstance.width;
const isIncreasingHeight = height > this.canvasInstance.height;
const { canvas: activeMaskCanvas } = createCanvas(1, 1, '2d', { willReadFrequently: true });
this.activeMaskCanvas = activeMaskCanvas;
const extraSpace = 2000;
const newWidth = isIncreasingWidth ? width + extraSpace : Math.max(oldWidth, width + extraSpace);
const newHeight = isIncreasingHeight ? height + extraSpace : Math.max(oldHeight, height + extraSpace);
this.activeMaskCanvas.width = newWidth;
this.activeMaskCanvas.height = newHeight;
const newMaskCtx = this.activeMaskCanvas.getContext('2d', { willReadFrequently: true });
if (!newMaskCtx) {
throw new Error("Failed to get 2D context for new mask canvas");
}
this.activeMaskCtx = newMaskCtx;
if (oldMask.width > 0 && oldMask.height > 0) {
const offsetX = this.x - oldX;
const offsetY = this.y - oldY;
this.activeMaskCtx.drawImage(oldMask, offsetX, offsetY);
log.debug(`Preserved mask content with offset (${offsetX}, ${offsetY})`);
}
log.info(`Mask canvas resized to ${this.activeMaskCanvas.width}x${this.activeMaskCanvas.height}, position (${this.x}, ${this.y})`);
log.info(`Canvas size change: width ${isIncreasingWidth ? 'increased' : 'decreased'}, height ${isIncreasingHeight ? 'increased' : 'decreased'}`);
}
/**
* Updates mask canvas to ensure it covers the current output area
* This should be called when output area position or size changes
* Now uses chunked system - just updates the active mask canvas
*/
updateMaskCanvasForOutputArea() {
log.info(`Updating chunked mask system for output area at (${this.canvasInstance.outputAreaBounds.x}, ${this.canvasInstance.outputAreaBounds.y})`);
// Simply update the active mask canvas to cover the new output area
// All existing chunks are preserved in the maskChunks Map
this.updateActiveMaskCanvas();
log.info(`Chunked mask system updated - ${this.maskChunks.size} chunks preserved`);
}
toggleOverlayVisibility() {
this.isOverlayVisible = !this.isOverlayVisible;
log.info(`Mask overlay visibility toggled to: ${this.isOverlayVisible}`);
}
setMask(image, isFromInputMask = false) {
const bounds = this.canvasInstance.outputAreaBounds;
if (isFromInputMask) {
// For INPUT MASK - process black background to transparent using luminance
// Center like input images
const centerX = bounds.x + (bounds.width - image.width) / 2;
const centerY = bounds.y + (bounds.height - image.height) / 2;
// Prepare mask where alpha = luminance (white = applied, black = transparent)
const { canvas: maskCanvas, ctx } = createCanvas(image.width, image.height, '2d', { willReadFrequently: true });
if (!ctx)
throw new Error("Could not create mask processing context");
ctx.drawImage(image, 0, 0);
const imgData = ctx.getImageData(0, 0, image.width, image.height);
const data = imgData.data;
for (let i = 0; i < data.length; i += 4) {
const r = data[i], g = data[i + 1], b = data[i + 2];
const lum = Math.round(0.299 * r + 0.587 * g + 0.114 * b);
data[i] = 255; // force white color (color channels ignored downstream)
data[i + 1] = 255;
data[i + 2] = 255;
data[i + 3] = lum; // alpha encodes mask strength: white -> strong, black -> 0
}
ctx.putImageData(imgData, 0, 0);
// Clear target area and apply to chunked system at centered position
this.clearMaskInArea(centerX, centerY, image.width, image.height);
this.applyMaskCanvasToChunks(maskCanvas, centerX, centerY);
// Refresh state and UI
this.updateActiveMaskCanvas(true);
this.canvasInstance.canvasState.saveMaskState();
this.canvasInstance.render();
log.info(`MaskTool set INPUT MASK at centered position (${centerX}, ${centerY}) using luminance as alpha`);
}
else {
// For SAM Detector and other sources - just clear and add without processing
this.clearMaskInArea(bounds.x, bounds.y, bounds.width, bounds.height);
this.addMask(image);
log.info(`MaskTool set mask using chunk system at bounds (${bounds.x}, ${bounds.y})`);
}
}
/**
* Clears mask data in a specific area by clearing affected chunks
*/
clearMaskInArea(x, y, width, height) {
const chunkMinX = Math.floor(x / this.chunkSize);
const chunkMinY = Math.floor(y / this.chunkSize);
const chunkMaxX = Math.floor((x + width) / this.chunkSize);
const chunkMaxY = Math.floor((y + height) / this.chunkSize);
// Clear all affected chunks
for (let chunkY = chunkMinY; chunkY <= chunkMaxY; chunkY++) {
for (let chunkX = chunkMinX; chunkX <= chunkMaxX; chunkX++) {
const chunkKey = `${chunkX},${chunkY}`;
const chunk = this.maskChunks.get(chunkKey);
if (chunk && !chunk.isEmpty) {
this.clearMaskFromChunk(chunk, x, y, width, height);
}
}
}
}
/**
* Clears mask data from a specific chunk in a given area
*/
clearMaskFromChunk(chunk, clearX, clearY, clearWidth, clearHeight) {
const clearLeft = clearX;
const clearTop = clearY;
const clearRight = clearX + clearWidth;
const clearBottom = clearY + clearHeight;
const intersection = this.calculateChunkIntersection(chunk, clearLeft, clearTop, clearRight, clearBottom);
if (!intersection) {
return; // No intersection
}
// Clear the area on this chunk
chunk.ctx.clearRect(intersection.destX, intersection.destY, intersection.destWidth, intersection.destHeight);
// Update chunk empty status
this.updateChunkEmptyStatus(chunk);
log.debug(`Cleared area from chunk (${Math.floor(chunk.x / this.chunkSize)}, ${Math.floor(chunk.y / this.chunkSize)}) at local position (${intersection.destX}, ${intersection.destY})`);
}
/**
* Clears all mask chunks - used by the clear() function
*/
clearAllMaskChunks() {
// Clear all existing chunks
for (const [chunkKey, chunk] of this.maskChunks) {
chunk.ctx.clearRect(0, 0, this.chunkSize, this.chunkSize);
chunk.isEmpty = true;
chunk.isDirty = true;
}
// Optionally remove all chunks from memory to free up resources
this.maskChunks.clear();
this.activeChunkBounds = null;
log.info(`Cleared all ${this.maskChunks.size} mask chunks`);
}
addMask(image) {
// Add mask to chunks system instead of directly to active canvas
const bounds = this.canvasInstance.outputAreaBounds;
// Calculate which chunks this mask will affect
const maskLeft = bounds.x;
const maskTop = bounds.y;
const maskRight = bounds.x + image.width;
const maskBottom = bounds.y + image.height;
const chunkBounds = this.calculateChunkBounds(maskLeft, maskTop, maskRight, maskBottom);
// Add mask to all affected chunks
for (let chunkY = chunkBounds.minY; chunkY <= chunkBounds.maxY; chunkY++) {
for (let chunkX = chunkBounds.minX; chunkX <= chunkBounds.maxX; chunkX++) {
const chunk = this.getChunkForPosition(chunkX * this.chunkSize, chunkY * this.chunkSize);
this.addMaskToChunk(chunk, image, bounds);
}
}
// Activate chunks in the area for visibility
const activatedChunks = this.activateChunksInArea(maskLeft, maskTop, maskRight, maskBottom);
// Update active canvas to show the new mask with activated chunks
this.updateActiveMaskCanvas(true); // Force full update to show all chunks including newly activated ones
this.triggerStateChangeAndRender();
log.info(`MaskTool added SAM mask to chunks covering bounds (${bounds.x}, ${bounds.y}) to (${maskRight}, ${maskBottom}) and activated ${activatedChunks} chunks for visibility`);
}
/**
* Adds a mask image to a specific chunk
*/
addMaskToChunk(chunk, maskImage, bounds) {
const sourceArea = {
left: bounds.x,
top: bounds.y,
right: bounds.x + maskImage.width,
bottom: bounds.y + maskImage.height
};
this.performChunkOperation(chunk, maskImage, sourceArea, 'add', "Added mask to");
}
/**
* Applies a mask canvas to the chunked system at a specific world position
*/
applyMaskCanvasToChunks(maskCanvas, worldX, worldY) {
// Calculate which chunks this mask will affect
const maskLeft = worldX;
const maskTop = worldY;
const maskRight = worldX + maskCanvas.width;
const maskBottom = worldY + maskCanvas.height;
const chunkMinX = Math.floor(maskLeft / this.chunkSize);
const chunkMinY = Math.floor(maskTop / this.chunkSize);
const chunkMaxX = Math.floor(maskRight / this.chunkSize);
const chunkMaxY = Math.floor(maskBottom / this.chunkSize);
// First, clear the area where the mask will be applied
this.clearMaskInArea(maskLeft, maskTop, maskCanvas.width, maskCanvas.height);
// Apply mask to all affected chunks
for (let chunkY = chunkMinY; chunkY <= chunkMaxY; chunkY++) {
for (let chunkX = chunkMinX; chunkX <= chunkMaxX; chunkX++) {
const chunk = this.getChunkForPosition(chunkX * this.chunkSize, chunkY * this.chunkSize);
this.applyMaskCanvasToChunk(chunk, maskCanvas, worldX, worldY);
}
}
log.info(`Applied mask canvas to chunks covering area (${maskLeft}, ${maskTop}) to (${maskRight}, ${maskBottom})`);
}
/**
* Removes a mask canvas from the chunked system at a specific world position
*/
removeMaskCanvasFromChunks(maskCanvas, worldX, worldY) {
// Calculate which chunks this mask will affect
const maskLeft = worldX;
const maskTop = worldY;
const maskRight = worldX + maskCanvas.width;
const maskBottom = worldY + maskCanvas.height;
const chunkMinX = Math.floor(maskLeft / this.chunkSize);
const chunkMinY = Math.floor(maskTop / this.chunkSize);
const chunkMaxX = Math.floor(maskRight / this.chunkSize);
const chunkMaxY = Math.floor(maskBottom / this.chunkSize);
// Remove mask from all affected chunks
for (let chunkY = chunkMinY; chunkY <= chunkMaxY; chunkY++) {
for (let chunkX = chunkMinX; chunkX <= chunkMaxX; chunkX++) {
const chunk = this.getChunkForPosition(chunkX * this.chunkSize, chunkY * this.chunkSize);
this.removeMaskCanvasFromChunk(chunk, maskCanvas, worldX, worldY);
}
}
log.info(`Removed mask canvas from chunks covering area (${maskLeft}, ${maskTop}) to (${maskRight}, ${maskBottom})`);
}
/**
* Removes a mask canvas from a specific chunk using destination-out composition
*/
removeMaskCanvasFromChunk(chunk, maskCanvas, maskWorldX, maskWorldY) {
const sourceArea = {
left: maskWorldX,
top: maskWorldY,
right: maskWorldX + maskCanvas.width,
bottom: maskWorldY + maskCanvas.height
};
this.performChunkOperation(chunk, maskCanvas, sourceArea, 'remove', "Removed mask canvas from");
}
/**
* Applies a mask canvas to a specific chunk
*/
applyMaskCanvasToChunk(chunk, maskCanvas, maskWorldX, maskWorldY) {
const sourceArea = {
left: maskWorldX,
top: maskWorldY,
right: maskWorldX + maskCanvas.width,
bottom: maskWorldY + maskCanvas.height
};
this.performChunkOperation(chunk, maskCanvas, sourceArea, 'apply', "Applied mask canvas to");
}
applyShapeMask(saveState = true) {
// Use unified configuration preparation
const config = this.prepareShapeMaskConfiguration();
if (!config) {
log.warn("Cannot apply shape mask: shape is not defined or has too few points.");
return;
}
if (saveState) {
this.canvasInstance.canvasState.saveMaskState();
}
// Create the shape mask canvas
let shapeMaskCanvas;
// Check if we need expansion or feathering
const needsExpansion = this.canvasInstance.shapeMaskExpansion && this.canvasInstance.shapeMaskExpansionValue !== 0;
const needsFeather = this.canvasInstance.shapeMaskFeather && this.canvasInstance.shapeMaskFeatherValue > 0;
if (!needsExpansion && !needsFeather) {
// Simple case: just draw the original shape
const { canvas, ctx } = this.createCanvas(config.tempCanvasWidth, config.tempCanvasHeight);
shapeMaskCanvas = canvas;
this.drawShapeOnCanvas(ctx, config.tempShapePoints, 'evenodd');
}
else if (needsExpansion && !needsFeather) {
// Expansion only
shapeMaskCanvas = this._createExpandedMaskCanvas(config.tempShapePoints, this.canvasInstance.shapeMaskExpansionValue, config.tempCanvasWidth, config.tempCanvasHeight);
}
else if (!needsExpansion && needsFeather) {
// Feather only
shapeMaskCanvas = this._createFeatheredMaskCanvas(config.tempShapePoints, this.canvasInstance.shapeMaskFeatherValue, config.tempCanvasWidth, config.tempCanvasHeight);
}
else {
// Both expansion and feather
const expandedMaskCanvas = this._createExpandedMaskCanvas(config.tempShapePoints, this.canvasInstance.shapeMaskExpansionValue, config.tempCanvasWidth, config.tempCanvasHeight);
const tempCtx = expandedMaskCanvas.getContext('2d', { willReadFrequently: true });
const expandedImageData = tempCtx.getImageData(0, 0, expandedMaskCanvas.width, expandedMaskCanvas.height);
shapeMaskCanvas = this._createFeatheredMaskFromImageData(expandedImageData, this.canvasInstance.shapeMaskFeatherValue, config.tempCanvasWidth, config.tempCanvasHeight);
}
// Calculate which chunks will be affected by the shape mask
const maskWorldX = config.bounds.x - config.tempOffsetX;
const maskWorldY = config.bounds.y - config.tempOffsetY;
const maskLeft = maskWorldX;
const maskTop = maskWorldY;
const maskRight = maskWorldX + shapeMaskCanvas.width;
const maskBottom = maskWorldY + shapeMaskCanvas.height;
// Apply the shape mask to the chunked system
this.applyMaskCanvasToChunks(shapeMaskCanvas, maskWorldX, maskWorldY);
// Activate chunks in the area for visibility
const activatedChunks = this.activateChunksInArea(maskLeft, maskTop, maskRight, maskBottom);
// Update the active mask canvas to show the changes with activated chunks
this.updateActiveMaskCanvas(true); // Force full update to show all chunks including newly activated ones
if (this.onStateChange) {
this.onStateChange();
}
this.canvasInstance.render();
log.info(`Applied shape mask to chunks with expansion: ${needsExpansion}, feather: ${needsFeather} and activated ${activatedChunks} chunks for visibility`);
}
/**
* Removes mask in the area of the custom output area shape. This must use a hard-edged
* shape to correctly erase any feathered "glow" that might have been applied.
* Now works with the chunked mask system.
*/
removeShapeMask() {
// Use unified configuration preparation
const config = this.prepareShapeMaskConfiguration();
if (!config) {
log.warn("Shape has insufficient points for mask removal");
return;
}
this.canvasInstance.canvasState.saveMaskState();
// Check if we need to account for expansion when removing
const needsExpansion = this.canvasInstance.shapeMaskExpansion && this.canvasInstance.shapeMaskExpansionValue !== 0;
// Create a removal mask canvas - always hard-edged to ensure complete removal
let removalMaskCanvas;
// Add safety margin to ensure complete removal of antialiasing artifacts
const safetyMargin = 2; // 2px margin to remove any antialiasing remnants
if (needsExpansion) {
// If expansion was active, remove exactly the user's expansion value + safety margin
const userExpansionValue = this.canvasInstance.shapeMaskExpansionValue;
const expandedValue = Math.abs(userExpansionValue) + safetyMargin;
removalMaskCanvas = this._createExpandedMaskCanvas(config.tempShapePoints, expandedValue, config.tempCanvasWidth, config.tempCanvasHeight);
}
else {
// If no expansion, remove the base shape with safety margin only
removalMaskCanvas = this._createExpandedMaskCanvas(config.tempShapePoints, safetyMargin, config.tempCanvasWidth, config.tempCanvasHeight);
}
// Now remove the shape mask from the chunked system
this.removeMaskCanvasFromChunks(removalMaskCanvas, config.bounds.x - config.tempOffsetX, config.bounds.y - config.tempOffsetY);
// Update the active mask canvas to show the changes
this.updateActiveMaskCanvas(true); // Force full update to ensure all chunks are properly updated
if (this.onStateChange) {
this.onStateChange();
}
this.canvasInstance.render();
log.info(`Removed shape mask from chunks with expansion: ${needsExpansion}.`);
}
_createFeatheredMaskCanvas(points, featherRadius, width, height) {
// 1. Create binary mask data from shape points
const binaryData = this.createBinaryMaskFromShape(points, width, height);
// 2. Use unified feathering logic
return this.createFeatheredMaskFromBinaryData(binaryData, featherRadius, width, height);
}
/**
* Fast distance transform using the simple two-pass algorithm from ImageAnalysis.ts
* Much faster than the complex Felzenszwalb algorithm
*/
_fastDistanceTransform(binaryMask, width, height) {
const distances = new Float32Array(width * height);
const infinity = width + height; // A value larger than any possible distance
// Initialize distances
for (let i = 0; i < width * height; i++) {
distances[i] = binaryMask[i] === 1 ? infinity : 0;
}
// Forward pass (top-left to bottom-right)
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const idx = y * width + x;
if (distances[idx] > 0) {
let minDist = distances[idx];
// Check top neighbor
if (y > 0) {
minDist = Math.min(minDist, distances[(y - 1) * width + x] + 1);
}
// Check left neighbor
if (x > 0) {
minDist = Math.min(minDist, distances[y * width + (x - 1)] + 1);
}
// Check top-left diagonal
if (x > 0 && y > 0) {
minDist = Math.min(minDist, distances[(y - 1) * width + (x - 1)] + Math.sqrt(2));
}
// Check top-right diagonal
if (x < width - 1 && y > 0) {
minDist = Math.min(minDist, distances[(y - 1) * width + (x + 1)] + Math.sqrt(2));
}
distances[idx] = minDist;
}
}
}
// Backward pass (bottom-right to top-left)
for (let y = height - 1; y >= 0; y--) {
for (let x = width - 1; x >= 0; x--) {
const idx = y * width + x;
if (distances[idx] > 0) {
let minDist = distances[idx];
// Check bottom neighbor
if (y < height - 1) {
minDist = Math.min(minDist, distances[(y + 1) * width + x] + 1);
}
// Check right neighbor
if (x < width - 1) {
minDist = Math.min(minDist, distances[y * width + (x + 1)] + 1);
}
// Check bottom-right diagonal
if (x < width - 1 && y < height - 1) {
minDist = Math.min(minDist, distances[(y + 1) * width + (x + 1)] + Math.sqrt(2));
}
// Check bottom-left diagonal
if (x > 0 && y < height - 1) {
minDist = Math.min(minDist, distances[(y + 1) * width + (x - 1)] + Math.sqrt(2));
}
distances[idx] = minDist;
}
}
}
return distances;
}
/**
* Creates an expanded/contracted mask canvas using simple morphological operations
* This gives SHARP edges without smoothing, unlike distance transform
*/
_createExpandedMaskCanvas(points, expansionValue, width, height) {
// 1. Create binary mask data from shape points
const binaryData = this.createBinaryMaskFromShape(points, width, height);
// 2. Apply fast morphological operations for sharp edges
let resultMask;
const absExpansionValue = Math.abs(expansionValue);
if (expansionValue >= 0) {
// EXPANSION: Use new fast dilation algorithm
resultMask = this._fastDilateDT(binaryData, width, height, absExpansionValue);
}
else {
// CONTRACTION: Use new fast erosion algorithm
resultMask = this._fastErodeDT(binaryData, width, height, absExpansionValue);
}
// 3. Create the final output canvas with sharp edges
return this.createOutputCanvasFromPixelData((outputData) => {
for (let i = 0; i < resultMask.length; i++) {
const alpha = resultMask[i] === 1 ? 255 : 0; // Sharp binary mask - no smoothing
outputData.data[i * 4] = 255; // R
outputData.data[i * 4 + 1] = 255; // G
outputData.data[i * 4 + 2] = 255; // B
outputData.data[i * 4 + 3] = alpha; // A - sharp edges
}
}, width, height);
}
/**
* Creates a feathered mask from existing ImageData (used when combining expansion + feather)
*/
_createFeatheredMaskFromImageData(imageData, featherRadius, width, height) {
const data = imageData.data;
const binaryData = new Uint8Array(width * height);
// Convert ImageData to binary mask
for (let i = 0; i < width * height; i++) {
binaryData[i] = data[i * 4 + 3] > 0 ? 1 : 0; // 1 = inside, 0 = outside
}
// Use unified feathering logic
return this.createFeatheredMaskFromBinaryData(binaryData, featherRadius, width, height);
}
}