From 6121403460b40889bd01a323f184d31968434946 Mon Sep 17 00:00:00 2001 From: Dariusz L Date: Sun, 27 Jul 2025 13:35:30 +0200 Subject: [PATCH] Refactor MaskTool chunk operations and shape offset handling Introduces utility methods for chunk bounds calculation, intersection, and activation for better code reuse and clarity. Refactors shape mask application and removal to consistently account for output area extensions, and centralizes chunk empty status updates. Improves chunk activation logic for mask and shape operations to enhance visibility and maintainability. --- js/MaskTool.js | 319 +++++++++++++++++++++-------------------- src/MaskTool.ts | 368 +++++++++++++++++++++++++----------------------- 2 files changed, 359 insertions(+), 328 deletions(-) diff --git a/js/MaskTool.js b/js/MaskTool.js index 4c92717..3efb3cf 100644 --- a/js/MaskTool.js +++ b/js/MaskTool.js @@ -209,6 +209,109 @@ export class MaskTool { } return count; } + /** + * 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; + } /** * 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 @@ -599,10 +702,13 @@ export class MaskTool { const shape = this.canvasInstance.outputAreaShape; const viewport = this.canvasInstance.viewport; const bounds = this.canvasInstance.outputAreaBounds; - // Convert shape points to world coordinates first (relative to output area bounds) + // Convert shape points to world coordinates first accounting for extensions + const ext = this.canvasInstance.outputAreaExtensionEnabled ? this.canvasInstance.outputAreaExtensions : { top: 0, bottom: 0, left: 0, right: 0 }; + const shapeOffsetX = ext.left; // Add left extension to maintain relative position + const shapeOffsetY = ext.top; // Add top extension to maintain relative position const worldShapePoints = shape.points.map(p => ({ - x: bounds.x + p.x, - y: bounds.y + p.y + x: bounds.x + shapeOffsetX + p.x, + y: bounds.y + shapeOffsetY + p.y })); // Then convert world coordinates to screen coordinates const screenPoints = worldShapePoints.map(p => ({ @@ -992,44 +1098,19 @@ export class MaskTool { * Clears mask data from a specific chunk in a given area */ clearMaskFromChunk(chunk, clearX, clearY, clearWidth, clearHeight) { - // Calculate the intersection of the clear area with this chunk - const chunkLeft = chunk.x; - const chunkTop = chunk.y; - const chunkRight = chunk.x + this.chunkSize; - const chunkBottom = chunk.y + this.chunkSize; const clearLeft = clearX; const clearTop = clearY; const clearRight = clearX + clearWidth; const clearBottom = clearY + clearHeight; - // Find intersection - const intersectLeft = Math.max(chunkLeft, clearLeft); - const intersectTop = Math.max(chunkTop, clearTop); - const intersectRight = Math.min(chunkRight, clearRight); - const intersectBottom = Math.min(chunkBottom, clearBottom); - // Check if there's actually an intersection - if (intersectLeft >= intersectRight || intersectTop >= intersectBottom) { + const intersection = this.calculateChunkIntersection(chunk, clearLeft, clearTop, clearRight, clearBottom); + if (!intersection) { return; // No intersection } - // Calculate destination coordinates on the chunk - const destX = intersectLeft - chunkLeft; - const destY = intersectTop - chunkTop; - const destWidth = intersectRight - intersectLeft; - const destHeight = intersectBottom - intersectTop; // Clear the area on this chunk - chunk.ctx.clearRect(destX, destY, destWidth, destHeight); - // Check if the entire chunk is now empty - const imageData = chunk.ctx.getImageData(0, 0, this.chunkSize, this.chunkSize); - const data = imageData.data; - let hasData = false; - for (let i = 3; i < data.length; i += 4) { // Check alpha channel - if (data[i] > 0) { - hasData = true; - break; - } - } - chunk.isEmpty = !hasData; - chunk.isDirty = true; - log.debug(`Cleared area from chunk (${Math.floor(chunk.x / this.chunkSize)}, ${Math.floor(chunk.y / this.chunkSize)}) at local position (${destX}, ${destY})`); + 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 @@ -1054,84 +1135,45 @@ export class MaskTool { const maskTop = bounds.y; const maskRight = bounds.x + image.width; const maskBottom = bounds.y + image.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, deactivate all chunks - for (const chunk of this.maskChunks.values()) { - chunk.isActive = false; - } - // Add mask to all affected chunks and activate them so user can see the mask being applied - for (let chunkY = chunkMinY; chunkY <= chunkMaxY; chunkY++) { - for (let chunkX = chunkMinX; chunkX <= chunkMaxX; chunkX++) { + 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 this chunk so user can see the mask being applied - chunk.isActive = true; - chunk.lastAccessTime = Date.now(); - } - } - // Also activate surrounding chunks for better visibility (3x3 grid around mask area) - const centerChunkX = Math.floor((maskLeft + maskRight) / 2 / this.chunkSize); - const centerChunkY = Math.floor((maskTop + maskBottom) / 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(); } } + // 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 if (this.onStateChange) { this.onStateChange(); } this.canvasInstance.render(); - const activatedChunks = Array.from(this.maskChunks.values()).filter(chunk => chunk.isActive).length; 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) { - // Calculate the intersection of the mask with this chunk - const chunkLeft = chunk.x; - const chunkTop = chunk.y; - const chunkRight = chunk.x + this.chunkSize; - const chunkBottom = chunk.y + this.chunkSize; const maskLeft = bounds.x; const maskTop = bounds.y; const maskRight = bounds.x + maskImage.width; const maskBottom = bounds.y + maskImage.height; - // Find intersection - const intersectLeft = Math.max(chunkLeft, maskLeft); - const intersectTop = Math.max(chunkTop, maskTop); - const intersectRight = Math.min(chunkRight, maskRight); - const intersectBottom = Math.min(chunkBottom, maskBottom); - // Check if there's actually an intersection - if (intersectLeft >= intersectRight || intersectTop >= intersectBottom) { + const intersection = this.calculateChunkIntersection(chunk, maskLeft, maskTop, maskRight, maskBottom); + if (!intersection) { return; // No intersection } - // Calculate source coordinates on the mask image - const srcX = intersectLeft - maskLeft; - const srcY = intersectTop - maskTop; - const srcWidth = intersectRight - intersectLeft; - const srcHeight = intersectBottom - intersectTop; - // Calculate destination coordinates on the chunk - const destX = intersectLeft - chunkLeft; - const destY = intersectTop - chunkTop; // Draw the mask portion onto this chunk chunk.ctx.globalCompositeOperation = 'source-over'; - chunk.ctx.drawImage(maskImage, srcX, srcY, srcWidth, srcHeight, // Source rectangle - destX, destY, srcWidth, srcHeight // Destination rectangle + chunk.ctx.drawImage(maskImage, intersection.srcX, intersection.srcY, intersection.srcWidth, intersection.srcHeight, // Source rectangle + intersection.destX, intersection.destY, intersection.destWidth, intersection.destHeight // Destination rectangle ); // Mark chunk as dirty and not empty chunk.isDirty = true; chunk.isEmpty = false; - log.debug(`Added mask to chunk (${Math.floor(chunk.x / this.chunkSize)}, ${Math.floor(chunk.y / this.chunkSize)}) at local position (${destX}, ${destY})`); + log.debug(`Added mask to chunk (${Math.floor(chunk.x / this.chunkSize)}, ${Math.floor(chunk.y / this.chunkSize)}) at local position (${intersection.destX}, ${intersection.destY})`); } /** * Applies a mask canvas to the chunked system at a specific world position @@ -1183,92 +1225,46 @@ export class MaskTool { * Removes a mask canvas from a specific chunk using destination-out composition */ removeMaskCanvasFromChunk(chunk, maskCanvas, maskWorldX, maskWorldY) { - // Calculate the intersection of the mask with this chunk - const chunkLeft = chunk.x; - const chunkTop = chunk.y; - const chunkRight = chunk.x + this.chunkSize; - const chunkBottom = chunk.y + this.chunkSize; const maskLeft = maskWorldX; const maskTop = maskWorldY; const maskRight = maskWorldX + maskCanvas.width; const maskBottom = maskWorldY + maskCanvas.height; - // Find intersection - const intersectLeft = Math.max(chunkLeft, maskLeft); - const intersectTop = Math.max(chunkTop, maskTop); - const intersectRight = Math.min(chunkRight, maskRight); - const intersectBottom = Math.min(chunkBottom, maskBottom); - // Check if there's actually an intersection - if (intersectLeft >= intersectRight || intersectTop >= intersectBottom) { + const intersection = this.calculateChunkIntersection(chunk, maskLeft, maskTop, maskRight, maskBottom); + if (!intersection) { return; // No intersection } - // Calculate source coordinates on the mask canvas - const srcX = intersectLeft - maskLeft; - const srcY = intersectTop - maskTop; - const srcWidth = intersectRight - intersectLeft; - const srcHeight = intersectBottom - intersectTop; - // Calculate destination coordinates on the chunk - const destX = intersectLeft - chunkLeft; - const destY = intersectTop - chunkTop; // Use destination-out to remove the mask portion from this chunk chunk.ctx.globalCompositeOperation = 'destination-out'; - chunk.ctx.drawImage(maskCanvas, srcX, srcY, srcWidth, srcHeight, // Source rectangle - destX, destY, srcWidth, srcHeight // Destination rectangle + chunk.ctx.drawImage(maskCanvas, intersection.srcX, intersection.srcY, intersection.srcWidth, intersection.srcHeight, // Source rectangle + intersection.destX, intersection.destY, intersection.destWidth, intersection.destHeight // Destination rectangle ); // Restore normal composition mode chunk.ctx.globalCompositeOperation = 'source-over'; - // Check if the chunk is now empty - const imageData = chunk.ctx.getImageData(0, 0, this.chunkSize, this.chunkSize); - const data = imageData.data; - let hasData = false; - for (let i = 3; i < data.length; i += 4) { // Check alpha channel - if (data[i] > 0) { - hasData = true; - break; - } - } - chunk.isEmpty = !hasData; - chunk.isDirty = true; - log.debug(`Removed mask canvas from chunk (${Math.floor(chunk.x / this.chunkSize)}, ${Math.floor(chunk.y / this.chunkSize)}) at local position (${destX}, ${destY})`); + // Update chunk empty status + this.updateChunkEmptyStatus(chunk); + log.debug(`Removed mask canvas from chunk (${Math.floor(chunk.x / this.chunkSize)}, ${Math.floor(chunk.y / this.chunkSize)}) at local position (${intersection.destX}, ${intersection.destY})`); } /** * Applies a mask canvas to a specific chunk */ applyMaskCanvasToChunk(chunk, maskCanvas, maskWorldX, maskWorldY) { - // Calculate the intersection of the mask with this chunk - const chunkLeft = chunk.x; - const chunkTop = chunk.y; - const chunkRight = chunk.x + this.chunkSize; - const chunkBottom = chunk.y + this.chunkSize; const maskLeft = maskWorldX; const maskTop = maskWorldY; const maskRight = maskWorldX + maskCanvas.width; const maskBottom = maskWorldY + maskCanvas.height; - // Find intersection - const intersectLeft = Math.max(chunkLeft, maskLeft); - const intersectTop = Math.max(chunkTop, maskTop); - const intersectRight = Math.min(chunkRight, maskRight); - const intersectBottom = Math.min(chunkBottom, maskBottom); - // Check if there's actually an intersection - if (intersectLeft >= intersectRight || intersectTop >= intersectBottom) { + const intersection = this.calculateChunkIntersection(chunk, maskLeft, maskTop, maskRight, maskBottom); + if (!intersection) { return; // No intersection } - // Calculate source coordinates on the mask canvas - const srcX = intersectLeft - maskLeft; - const srcY = intersectTop - maskTop; - const srcWidth = intersectRight - intersectLeft; - const srcHeight = intersectBottom - intersectTop; - // Calculate destination coordinates on the chunk - const destX = intersectLeft - chunkLeft; - const destY = intersectTop - chunkTop; // Draw the mask portion onto this chunk chunk.ctx.globalCompositeOperation = 'source-over'; - chunk.ctx.drawImage(maskCanvas, srcX, srcY, srcWidth, srcHeight, // Source rectangle - destX, destY, srcWidth, srcHeight // Destination rectangle + chunk.ctx.drawImage(maskCanvas, intersection.srcX, intersection.srcY, intersection.srcWidth, intersection.srcHeight, // Source rectangle + intersection.destX, intersection.destY, intersection.destWidth, intersection.destHeight // Destination rectangle ); // Mark chunk as dirty and not empty chunk.isDirty = true; chunk.isEmpty = false; - log.debug(`Applied mask canvas to chunk (${Math.floor(chunk.x / this.chunkSize)}, ${Math.floor(chunk.y / this.chunkSize)}) at local position (${destX}, ${destY})`); + log.debug(`Applied mask canvas to chunk (${Math.floor(chunk.x / this.chunkSize)}, ${Math.floor(chunk.y / this.chunkSize)}) at local position (${intersection.destX}, ${intersection.destY})`); } applyShapeMask(saveState = true) { if (!this.canvasInstance.outputAreaShape?.points || this.canvasInstance.outputAreaShape.points.length < 3) { @@ -1280,11 +1276,14 @@ export class MaskTool { } const shape = this.canvasInstance.outputAreaShape; const bounds = this.canvasInstance.outputAreaBounds; - // Calculate shape points in world coordinates - // Shape points are relative to the output area bounds + // Calculate shape points in world coordinates accounting for extensions + // Shape points are relative to the output area bounds, but need extension offset + const ext = this.canvasInstance.outputAreaExtensionEnabled ? this.canvasInstance.outputAreaExtensions : { top: 0, bottom: 0, left: 0, right: 0 }; + const shapeOffsetX = ext.left; // Add left extension to maintain relative position + const shapeOffsetY = ext.top; // Add top extension to maintain relative position const worldShapePoints = shape.points.map(p => ({ - x: bounds.x + p.x, - y: bounds.y + p.y + x: bounds.x + shapeOffsetX + p.x, + y: bounds.y + shapeOffsetY + p.y })); // Create the shape mask canvas let shapeMaskCanvas; @@ -1332,15 +1331,24 @@ export class MaskTool { const expandedImageData = tempCtx.getImageData(0, 0, expandedMaskCanvas.width, expandedMaskCanvas.height); shapeMaskCanvas = this._createFeatheredMaskFromImageData(expandedImageData, this.canvasInstance.shapeMaskFeatherValue, tempCanvasWidth, tempCanvasHeight); } - // Now apply the shape mask to the chunked system - this.applyMaskCanvasToChunks(shapeMaskCanvas, bounds.x - tempOffsetX, bounds.y - tempOffsetY); - // Update the active mask canvas to show the changes - this.updateActiveMaskCanvas(); + // Calculate which chunks will be affected by the shape mask + const maskWorldX = bounds.x - tempOffsetX; + const maskWorldY = bounds.y - 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}.`); + 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 @@ -1355,10 +1363,13 @@ export class MaskTool { this.canvasInstance.canvasState.saveMaskState(); const shape = this.canvasInstance.outputAreaShape; const bounds = this.canvasInstance.outputAreaBounds; - // Calculate shape points in world coordinates (same as applyShapeMask) + // Calculate shape points in world coordinates accounting for extensions (same as applyShapeMask) + const ext = this.canvasInstance.outputAreaExtensionEnabled ? this.canvasInstance.outputAreaExtensions : { top: 0, bottom: 0, left: 0, right: 0 }; + const shapeOffsetX = ext.left; // Add left extension to maintain relative position + const shapeOffsetY = ext.top; // Add top extension to maintain relative position const worldShapePoints = shape.points.map(p => ({ - x: bounds.x + p.x, - y: bounds.y + p.y + x: bounds.x + shapeOffsetX + p.x, + y: bounds.y + shapeOffsetY + p.y })); // Check if we need to account for expansion when removing const needsExpansion = this.canvasInstance.shapeMaskExpansion && this.canvasInstance.shapeMaskExpansionValue !== 0; diff --git a/src/MaskTool.ts b/src/MaskTool.ts index 874d894..9f4b081 100644 --- a/src/MaskTool.ts +++ b/src/MaskTool.ts @@ -301,6 +301,132 @@ export class MaskTool { return count; } + /** + * Gets extension offset for shape positioning + */ + private getExtensionOffset(): { x: number, y: number } { + 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 + */ + private calculateChunkBounds(left: number, top: number, right: number, bottom: number): { + minX: number, minY: number, maxX: number, maxY: number + } { + 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 + */ + private activateChunksInArea(left: number, top: number, right: number, bottom: number): number { + // 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 + */ + private calculateChunkIntersection(chunk: MaskChunk, areaLeft: number, areaTop: number, areaRight: number, areaBottom: number): { + intersectLeft: number, intersectTop: number, intersectRight: number, intersectBottom: number, + srcX: number, srcY: number, srcWidth: number, srcHeight: number, + destX: number, destY: number, destWidth: number, destHeight: number + } | null { + 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 + */ + private updateChunkEmptyStatus(chunk: MaskChunk): void { + 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; + } + /** * 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 @@ -764,10 +890,14 @@ export class MaskTool { const viewport = this.canvasInstance.viewport; const bounds = this.canvasInstance.outputAreaBounds; - // Convert shape points to world coordinates first (relative to output area bounds) + // Convert shape points to world coordinates first accounting for extensions + const ext = this.canvasInstance.outputAreaExtensionEnabled ? this.canvasInstance.outputAreaExtensions : { top: 0, bottom: 0, left: 0, right: 0 }; + const shapeOffsetX = ext.left; // Add left extension to maintain relative position + const shapeOffsetY = ext.top; // Add top extension to maintain relative position + const worldShapePoints = shape.points.map(p => ({ - x: bounds.x + p.x, - y: bounds.y + p.y + x: bounds.x + shapeOffsetX + p.x, + y: bounds.y + shapeOffsetY + p.y })); // Then convert world coordinates to screen coordinates @@ -1216,52 +1346,23 @@ export class MaskTool { * Clears mask data from a specific chunk in a given area */ private clearMaskFromChunk(chunk: MaskChunk, clearX: number, clearY: number, clearWidth: number, clearHeight: number): void { - // Calculate the intersection of the clear area with this chunk - const chunkLeft = chunk.x; - const chunkTop = chunk.y; - const chunkRight = chunk.x + this.chunkSize; - const chunkBottom = chunk.y + this.chunkSize; - const clearLeft = clearX; const clearTop = clearY; const clearRight = clearX + clearWidth; const clearBottom = clearY + clearHeight; - // Find intersection - const intersectLeft = Math.max(chunkLeft, clearLeft); - const intersectTop = Math.max(chunkTop, clearTop); - const intersectRight = Math.min(chunkRight, clearRight); - const intersectBottom = Math.min(chunkBottom, clearBottom); - - // Check if there's actually an intersection - if (intersectLeft >= intersectRight || intersectTop >= intersectBottom) { + const intersection = this.calculateChunkIntersection(chunk, clearLeft, clearTop, clearRight, clearBottom); + if (!intersection) { return; // No intersection } - // Calculate destination coordinates on the chunk - const destX = intersectLeft - chunkLeft; - const destY = intersectTop - chunkTop; - const destWidth = intersectRight - intersectLeft; - const destHeight = intersectBottom - intersectTop; - // Clear the area on this chunk - chunk.ctx.clearRect(destX, destY, destWidth, destHeight); + chunk.ctx.clearRect(intersection.destX, intersection.destY, intersection.destWidth, intersection.destHeight); - // Check if the entire chunk is now empty - const imageData = chunk.ctx.getImageData(0, 0, this.chunkSize, this.chunkSize); - const data = imageData.data; - let hasData = false; - for (let i = 3; i < data.length; i += 4) { // Check alpha channel - if (data[i] > 0) { - hasData = true; - break; - } - } + // Update chunk empty status + this.updateChunkEmptyStatus(chunk); - chunk.isEmpty = !hasData; - chunk.isDirty = true; - - log.debug(`Cleared area from chunk (${Math.floor(chunk.x / this.chunkSize)}, ${Math.floor(chunk.y / this.chunkSize)}) at local position (${destX}, ${destY})`); + 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})`); } /** @@ -1292,41 +1393,18 @@ export class MaskTool { const maskRight = bounds.x + image.width; const maskBottom = bounds.y + image.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); + const chunkBounds = this.calculateChunkBounds(maskLeft, maskTop, maskRight, maskBottom); - // First, deactivate all chunks - for (const chunk of this.maskChunks.values()) { - chunk.isActive = false; - } - - // Add mask to all affected chunks and activate them so user can see the mask being applied - for (let chunkY = chunkMinY; chunkY <= chunkMaxY; chunkY++) { - for (let chunkX = chunkMinX; chunkX <= chunkMaxX; chunkX++) { + // 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 this chunk so user can see the mask being applied - chunk.isActive = true; - chunk.lastAccessTime = Date.now(); } } - // Also activate surrounding chunks for better visibility (3x3 grid around mask area) - const centerChunkX = Math.floor((maskLeft + maskRight) / 2 / this.chunkSize); - const centerChunkY = Math.floor((maskTop + maskBottom) / 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(); - } - } + // 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 @@ -1336,7 +1414,6 @@ export class MaskTool { } this.canvasInstance.render(); - const activatedChunks = Array.from(this.maskChunks.values()).filter(chunk => chunk.isActive).length; log.info(`MaskTool added SAM mask to chunks covering bounds (${bounds.x}, ${bounds.y}) to (${maskRight}, ${maskBottom}) and activated ${activatedChunks} chunks for visibility`); } @@ -1344,51 +1421,29 @@ export class MaskTool { * Adds a mask image to a specific chunk */ private addMaskToChunk(chunk: MaskChunk, maskImage: HTMLImageElement, bounds: { x: number, y: number, width: number, height: number }): void { - // Calculate the intersection of the mask with this chunk - const chunkLeft = chunk.x; - const chunkTop = chunk.y; - const chunkRight = chunk.x + this.chunkSize; - const chunkBottom = chunk.y + this.chunkSize; - const maskLeft = bounds.x; const maskTop = bounds.y; const maskRight = bounds.x + maskImage.width; const maskBottom = bounds.y + maskImage.height; - // Find intersection - const intersectLeft = Math.max(chunkLeft, maskLeft); - const intersectTop = Math.max(chunkTop, maskTop); - const intersectRight = Math.min(chunkRight, maskRight); - const intersectBottom = Math.min(chunkBottom, maskBottom); - - // Check if there's actually an intersection - if (intersectLeft >= intersectRight || intersectTop >= intersectBottom) { + const intersection = this.calculateChunkIntersection(chunk, maskLeft, maskTop, maskRight, maskBottom); + if (!intersection) { return; // No intersection } - // Calculate source coordinates on the mask image - const srcX = intersectLeft - maskLeft; - const srcY = intersectTop - maskTop; - const srcWidth = intersectRight - intersectLeft; - const srcHeight = intersectBottom - intersectTop; - - // Calculate destination coordinates on the chunk - const destX = intersectLeft - chunkLeft; - const destY = intersectTop - chunkTop; - // Draw the mask portion onto this chunk chunk.ctx.globalCompositeOperation = 'source-over'; chunk.ctx.drawImage( maskImage, - srcX, srcY, srcWidth, srcHeight, // Source rectangle - destX, destY, srcWidth, srcHeight // Destination rectangle + intersection.srcX, intersection.srcY, intersection.srcWidth, intersection.srcHeight, // Source rectangle + intersection.destX, intersection.destY, intersection.destWidth, intersection.destHeight // Destination rectangle ); // Mark chunk as dirty and not empty chunk.isDirty = true; chunk.isEmpty = false; - log.debug(`Added mask to chunk (${Math.floor(chunk.x / this.chunkSize)}, ${Math.floor(chunk.y / this.chunkSize)}) at local position (${destX}, ${destY})`); + log.debug(`Added mask to chunk (${Math.floor(chunk.x / this.chunkSize)}, ${Math.floor(chunk.y / this.chunkSize)}) at local position (${intersection.destX}, ${intersection.destY})`); } /** @@ -1450,115 +1505,60 @@ export class MaskTool { * Removes a mask canvas from a specific chunk using destination-out composition */ private removeMaskCanvasFromChunk(chunk: MaskChunk, maskCanvas: HTMLCanvasElement, maskWorldX: number, maskWorldY: number): void { - // Calculate the intersection of the mask with this chunk - const chunkLeft = chunk.x; - const chunkTop = chunk.y; - const chunkRight = chunk.x + this.chunkSize; - const chunkBottom = chunk.y + this.chunkSize; - const maskLeft = maskWorldX; const maskTop = maskWorldY; const maskRight = maskWorldX + maskCanvas.width; const maskBottom = maskWorldY + maskCanvas.height; - // Find intersection - const intersectLeft = Math.max(chunkLeft, maskLeft); - const intersectTop = Math.max(chunkTop, maskTop); - const intersectRight = Math.min(chunkRight, maskRight); - const intersectBottom = Math.min(chunkBottom, maskBottom); - - // Check if there's actually an intersection - if (intersectLeft >= intersectRight || intersectTop >= intersectBottom) { + const intersection = this.calculateChunkIntersection(chunk, maskLeft, maskTop, maskRight, maskBottom); + if (!intersection) { return; // No intersection } - // Calculate source coordinates on the mask canvas - const srcX = intersectLeft - maskLeft; - const srcY = intersectTop - maskTop; - const srcWidth = intersectRight - intersectLeft; - const srcHeight = intersectBottom - intersectTop; - - // Calculate destination coordinates on the chunk - const destX = intersectLeft - chunkLeft; - const destY = intersectTop - chunkTop; - // Use destination-out to remove the mask portion from this chunk chunk.ctx.globalCompositeOperation = 'destination-out'; chunk.ctx.drawImage( maskCanvas, - srcX, srcY, srcWidth, srcHeight, // Source rectangle - destX, destY, srcWidth, srcHeight // Destination rectangle + intersection.srcX, intersection.srcY, intersection.srcWidth, intersection.srcHeight, // Source rectangle + intersection.destX, intersection.destY, intersection.destWidth, intersection.destHeight // Destination rectangle ); // Restore normal composition mode chunk.ctx.globalCompositeOperation = 'source-over'; - // Check if the chunk is now empty - const imageData = chunk.ctx.getImageData(0, 0, this.chunkSize, this.chunkSize); - const data = imageData.data; - let hasData = false; - for (let i = 3; i < data.length; i += 4) { // Check alpha channel - if (data[i] > 0) { - hasData = true; - break; - } - } + // Update chunk empty status + this.updateChunkEmptyStatus(chunk); - chunk.isEmpty = !hasData; - chunk.isDirty = true; - - log.debug(`Removed mask canvas from chunk (${Math.floor(chunk.x / this.chunkSize)}, ${Math.floor(chunk.y / this.chunkSize)}) at local position (${destX}, ${destY})`); + log.debug(`Removed mask canvas from chunk (${Math.floor(chunk.x / this.chunkSize)}, ${Math.floor(chunk.y / this.chunkSize)}) at local position (${intersection.destX}, ${intersection.destY})`); } /** * Applies a mask canvas to a specific chunk */ private applyMaskCanvasToChunk(chunk: MaskChunk, maskCanvas: HTMLCanvasElement, maskWorldX: number, maskWorldY: number): void { - // Calculate the intersection of the mask with this chunk - const chunkLeft = chunk.x; - const chunkTop = chunk.y; - const chunkRight = chunk.x + this.chunkSize; - const chunkBottom = chunk.y + this.chunkSize; - const maskLeft = maskWorldX; const maskTop = maskWorldY; const maskRight = maskWorldX + maskCanvas.width; const maskBottom = maskWorldY + maskCanvas.height; - // Find intersection - const intersectLeft = Math.max(chunkLeft, maskLeft); - const intersectTop = Math.max(chunkTop, maskTop); - const intersectRight = Math.min(chunkRight, maskRight); - const intersectBottom = Math.min(chunkBottom, maskBottom); - - // Check if there's actually an intersection - if (intersectLeft >= intersectRight || intersectTop >= intersectBottom) { + const intersection = this.calculateChunkIntersection(chunk, maskLeft, maskTop, maskRight, maskBottom); + if (!intersection) { return; // No intersection } - // Calculate source coordinates on the mask canvas - const srcX = intersectLeft - maskLeft; - const srcY = intersectTop - maskTop; - const srcWidth = intersectRight - intersectLeft; - const srcHeight = intersectBottom - intersectTop; - - // Calculate destination coordinates on the chunk - const destX = intersectLeft - chunkLeft; - const destY = intersectTop - chunkTop; - // Draw the mask portion onto this chunk chunk.ctx.globalCompositeOperation = 'source-over'; chunk.ctx.drawImage( maskCanvas, - srcX, srcY, srcWidth, srcHeight, // Source rectangle - destX, destY, srcWidth, srcHeight // Destination rectangle + intersection.srcX, intersection.srcY, intersection.srcWidth, intersection.srcHeight, // Source rectangle + intersection.destX, intersection.destY, intersection.destWidth, intersection.destHeight // Destination rectangle ); // Mark chunk as dirty and not empty chunk.isDirty = true; chunk.isEmpty = false; - log.debug(`Applied mask canvas to chunk (${Math.floor(chunk.x / this.chunkSize)}, ${Math.floor(chunk.y / this.chunkSize)}) at local position (${destX}, ${destY})`); + log.debug(`Applied mask canvas to chunk (${Math.floor(chunk.x / this.chunkSize)}, ${Math.floor(chunk.y / this.chunkSize)}) at local position (${intersection.destX}, ${intersection.destY})`); } applyShapeMask(saveState: boolean = true): void { @@ -1573,11 +1573,15 @@ export class MaskTool { const shape = this.canvasInstance.outputAreaShape; const bounds = this.canvasInstance.outputAreaBounds; - // Calculate shape points in world coordinates - // Shape points are relative to the output area bounds + // Calculate shape points in world coordinates accounting for extensions + // Shape points are relative to the output area bounds, but need extension offset + const ext = this.canvasInstance.outputAreaExtensionEnabled ? this.canvasInstance.outputAreaExtensions : { top: 0, bottom: 0, left: 0, right: 0 }; + const shapeOffsetX = ext.left; // Add left extension to maintain relative position + const shapeOffsetY = ext.top; // Add top extension to maintain relative position + const worldShapePoints = shape.points.map(p => ({ - x: bounds.x + p.x, - y: bounds.y + p.y + x: bounds.x + shapeOffsetX + p.x, + y: bounds.y + shapeOffsetY + p.y })); // Create the shape mask canvas @@ -1629,17 +1633,29 @@ export class MaskTool { shapeMaskCanvas = this._createFeatheredMaskFromImageData(expandedImageData, this.canvasInstance.shapeMaskFeatherValue, tempCanvasWidth, tempCanvasHeight); } - // Now apply the shape mask to the chunked system - this.applyMaskCanvasToChunks(shapeMaskCanvas, bounds.x - tempOffsetX, bounds.y - tempOffsetY); + // Calculate which chunks will be affected by the shape mask + const maskWorldX = bounds.x - tempOffsetX; + const maskWorldY = bounds.y - tempOffsetY; + const maskLeft = maskWorldX; + const maskTop = maskWorldY; + const maskRight = maskWorldX + shapeMaskCanvas.width; + const maskBottom = maskWorldY + shapeMaskCanvas.height; - // Update the active mask canvas to show the changes - this.updateActiveMaskCanvas(); + // 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}.`); + + log.info(`Applied shape mask to chunks with expansion: ${needsExpansion}, feather: ${needsFeather} and activated ${activatedChunks} chunks for visibility`); } /** @@ -1658,10 +1674,14 @@ export class MaskTool { const shape = this.canvasInstance.outputAreaShape; const bounds = this.canvasInstance.outputAreaBounds; - // Calculate shape points in world coordinates (same as applyShapeMask) + // Calculate shape points in world coordinates accounting for extensions (same as applyShapeMask) + const ext = this.canvasInstance.outputAreaExtensionEnabled ? this.canvasInstance.outputAreaExtensions : { top: 0, bottom: 0, left: 0, right: 0 }; + const shapeOffsetX = ext.left; // Add left extension to maintain relative position + const shapeOffsetY = ext.top; // Add top extension to maintain relative position + const worldShapePoints = shape.points.map(p => ({ - x: bounds.x + p.x, - y: bounds.y + p.y + x: bounds.x + shapeOffsetX + p.x, + y: bounds.y + shapeOffsetY + p.y })); // Check if we need to account for expansion when removing