mirror of
https://github.com/Azornes/Comfyui-LayerForge.git
synced 2026-03-21 12:52:10 -03:00
1869 lines
86 KiB
JavaScript
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);
|
|
}
|
|
}
|