diff --git a/js/Canvas.js b/js/Canvas.js index e89f526..cb40937 100644 --- a/js/Canvas.js +++ b/js/Canvas.js @@ -72,7 +72,9 @@ export class Canvas { this.outputAreaExtensions = { top: 0, bottom: 0, left: 0, right: 0 }; this.outputAreaExtensionEnabled = false; this.outputAreaExtensionPreview = null; + this.lastOutputAreaExtensions = { top: 0, bottom: 0, left: 0, right: 0 }; this.originalCanvasSize = { width: this.width, height: this.height }; + this.originalOutputAreaPosition = { x: -(this.width / 4), y: -(this.height / 4) }; // Initialize outputAreaBounds centered in viewport, similar to how canvas resize/move work this.outputAreaBounds = { x: -(this.width / 4), @@ -317,37 +319,7 @@ export class Canvas { return this.canvasSelection.updateSelectionLogic(layer, isCtrlPressed, isShiftPressed, index); } defineOutputAreaWithShape(shape) { - const boundingBox = this.shapeTool.getBoundingBox(); - if (boundingBox && boundingBox.width > 1 && boundingBox.height > 1) { - this.saveState(); - this.outputAreaShape = { - ...shape, - points: shape.points.map(p => ({ - x: p.x - boundingBox.x, - y: p.y - boundingBox.y - })) - }; - const newWidth = Math.round(boundingBox.width); - const newHeight = Math.round(boundingBox.height); - const newX = Math.round(boundingBox.x); - const newY = Math.round(boundingBox.y); - // Store the original canvas size for extension calculations - this.originalCanvasSize = { width: newWidth, height: newHeight }; - // Update canvas size but don't change outputAreaBounds yet - this.updateOutputAreaSize(newWidth, newHeight, false); - // Set outputAreaBounds to where the custom shape was drawn in the world - // Similar to finalizeCanvasMove - just update outputAreaBounds position - this.outputAreaBounds = { - x: newX, - y: newY, - width: newWidth, - height: newHeight - }; - // Update mask canvas to ensure it covers the new output area position - this.maskTool.updateMaskCanvasForOutputArea(); - this.saveState(); - this.render(); - } + this.canvasInteractions.defineOutputAreaWithShape(shape); } /** * Zmienia rozmiar obszaru wyjściowego diff --git a/js/CanvasIO.js b/js/CanvasIO.js index b2dea70..7f212d3 100644 --- a/js/CanvasIO.js +++ b/js/CanvasIO.js @@ -661,12 +661,17 @@ export class CanvasIO { } // Draw the image first ctx.drawImage(image, 0, 0); - // Create a clipping mask using the shape + // Calculate custom shape position accounting for extensions + // Custom shape should maintain its relative position within the original canvas area + const ext = this.canvas.outputAreaExtensionEnabled ? this.canvas.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 + // Create a clipping mask using the shape with extension offset ctx.globalCompositeOperation = 'destination-in'; ctx.beginPath(); - ctx.moveTo(shape.points[0].x, shape.points[0].y); + ctx.moveTo(shape.points[0].x + shapeOffsetX, shape.points[0].y + shapeOffsetY); for (let i = 1; i < shape.points.length; i++) { - ctx.lineTo(shape.points[i].x, shape.points[i].y); + ctx.lineTo(shape.points[i].x + shapeOffsetX, shape.points[i].y + shapeOffsetY); } ctx.closePath(); ctx.fill(); diff --git a/js/CanvasInteractions.js b/js/CanvasInteractions.js index b253a20..3029ea4 100644 --- a/js/CanvasInteractions.js +++ b/js/CanvasInteractions.js @@ -794,6 +794,58 @@ export class CanvasInteractions { }; reader.readAsDataURL(file); } + defineOutputAreaWithShape(shape) { + const boundingBox = this.canvas.shapeTool.getBoundingBox(); + if (boundingBox && boundingBox.width > 1 && boundingBox.height > 1) { + this.canvas.saveState(); + this.canvas.outputAreaShape = { + ...shape, + points: shape.points.map((p) => ({ + x: p.x - boundingBox.x, + y: p.y - boundingBox.y + })) + }; + const newWidth = Math.round(boundingBox.width); + const newHeight = Math.round(boundingBox.height); + const newX = Math.round(boundingBox.x); + const newY = Math.round(boundingBox.y); + // Store the original canvas size for extension calculations + this.canvas.originalCanvasSize = { width: newWidth, height: newHeight }; + // Store the original position where custom shape was drawn for extension calculations + this.canvas.originalOutputAreaPosition = { x: newX, y: newY }; + // If extensions are enabled, we need to recalculate outputAreaBounds with current extensions + if (this.canvas.outputAreaExtensionEnabled) { + const ext = this.canvas.outputAreaExtensions; + const extendedWidth = newWidth + ext.left + ext.right; + const extendedHeight = newHeight + ext.top + ext.bottom; + // Update canvas size with extensions + this.canvas.updateOutputAreaSize(extendedWidth, extendedHeight, false); + // Set outputAreaBounds accounting for extensions + this.canvas.outputAreaBounds = { + x: newX - ext.left, // Adjust position by left extension + y: newY - ext.top, // Adjust position by top extension + width: extendedWidth, + height: extendedHeight + }; + log.info(`New custom shape with extensions: original(${newX}, ${newY}) extended(${newX - ext.left}, ${newY - ext.top}) size(${extendedWidth}x${extendedHeight})`); + } + else { + // No extensions - use original size and position + this.canvas.updateOutputAreaSize(newWidth, newHeight, false); + this.canvas.outputAreaBounds = { + x: newX, + y: newY, + width: newWidth, + height: newHeight + }; + log.info(`New custom shape without extensions: position(${newX}, ${newY}) size(${newWidth}x${newHeight})`); + } + // Update mask canvas to ensure it covers the new output area position + this.canvas.maskTool.updateMaskCanvasForOutputArea(); + this.canvas.saveState(); + this.canvas.render(); + } + } async handlePasteEvent(e) { const shouldHandle = this.canvas.isMouseOver || this.canvas.canvas.contains(document.activeElement) || diff --git a/js/CanvasRenderer.js b/js/CanvasRenderer.js index 33d6b69..a827ec3 100644 --- a/js/CanvasRenderer.js +++ b/js/CanvasRenderer.js @@ -273,11 +273,16 @@ export class CanvasRenderer { ctx.setLineDash([]); const shape = this.canvas.outputAreaShape; const bounds = this.canvas.outputAreaBounds; + // Calculate custom shape position accounting for extensions + // Custom shape should maintain its relative position within the original canvas area + const ext = this.canvas.outputAreaExtensionEnabled ? this.canvas.outputAreaExtensions : { top: 0, bottom: 0, left: 0, right: 0 }; + const shapeOffsetX = bounds.x + ext.left; // Add left extension to maintain relative position + const shapeOffsetY = bounds.y + ext.top; // Add top extension to maintain relative position ctx.beginPath(); - // Render custom shape relative to outputAreaBounds, not (0,0) - ctx.moveTo(bounds.x + shape.points[0].x, bounds.y + shape.points[0].y); + // Render custom shape with extension offset to maintain relative position + ctx.moveTo(shapeOffsetX + shape.points[0].x, shapeOffsetY + shape.points[0].y); for (let i = 1; i < shape.points.length; i++) { - ctx.lineTo(bounds.x + shape.points[i].x, bounds.y + shape.points[i].y); + ctx.lineTo(shapeOffsetX + shape.points[i].x, shapeOffsetY + shape.points[i].y); } ctx.closePath(); ctx.stroke(); @@ -321,10 +326,11 @@ export class CanvasRenderer { const baseWidth = this.canvas.originalCanvasSize ? this.canvas.originalCanvasSize.width : this.canvas.width; const baseHeight = this.canvas.originalCanvasSize ? this.canvas.originalCanvasSize.height : this.canvas.height; const ext = this.canvas.outputAreaExtensionPreview; - // Podgląd pokazuje jak będą wyglądać nowe outputAreaBounds + // Calculate preview bounds relative to original custom shape position, not (0,0) + const originalPos = this.canvas.originalOutputAreaPosition; const previewBounds = { - x: -ext.left, // Może być ujemne - wycinamy fragment świata - y: -ext.top, // Może być ujemne - wycinamy fragment świata + x: originalPos.x - ext.left, // ✅ Względem oryginalnej pozycji custom shape + y: originalPos.y - ext.top, // ✅ Względem oryginalnej pozycji custom shape width: baseWidth + ext.left + ext.right, height: baseHeight + ext.top + ext.bottom }; diff --git a/js/CustomShapeMenu.js b/js/CustomShapeMenu.js index 3ae90a3..8d7304e 100644 --- a/js/CustomShapeMenu.js +++ b/js/CustomShapeMenu.js @@ -281,11 +281,17 @@ export class CustomShapeMenu { width: this.canvas.width, height: this.canvas.height }; + // Restore last saved extensions instead of starting from zero + this.canvas.outputAreaExtensions = { ...this.canvas.lastOutputAreaExtensions }; log.info(`Captured current canvas size as baseline: ${this.canvas.width}x${this.canvas.height}`); + log.info(`Restored last extensions:`, this.canvas.outputAreaExtensions); } else { - // Reset all extensions when disabled + // Save current extensions before disabling + this.canvas.lastOutputAreaExtensions = { ...this.canvas.outputAreaExtensions }; + // Reset current extensions when disabled (but keep the saved ones) this.canvas.outputAreaExtensions = { top: 0, bottom: 0, left: 0, right: 0 }; + log.info(`Saved extensions for later:`, this.canvas.lastOutputAreaExtensions); } this._updateExtensionUI(); this._updateCanvasSize(); // Update canvas size when toggling @@ -610,12 +616,12 @@ export class CustomShapeMenu { } _updateCanvasSize() { if (!this.canvas.outputAreaExtensionEnabled) { - // When extensions are disabled, preserve the current outputAreaBounds position - // Only update the size to match originalCanvasSize - const currentBounds = this.canvas.outputAreaBounds; + // When extensions are disabled, return to original custom shape position + // Use originalOutputAreaPosition instead of current bounds position + const originalPos = this.canvas.originalOutputAreaPosition; this.canvas.outputAreaBounds = { - x: currentBounds.x, // ✅ Preserve current position - y: currentBounds.y, // ✅ Preserve current position + x: originalPos.x, // ✅ Return to original custom shape position + y: originalPos.y, // ✅ Return to original custom shape position width: this.canvas.originalCanvasSize.width, height: this.canvas.originalCanvasSize.height }; @@ -625,11 +631,11 @@ export class CustomShapeMenu { const ext = this.canvas.outputAreaExtensions; const newWidth = this.canvas.originalCanvasSize.width + ext.left + ext.right; const newHeight = this.canvas.originalCanvasSize.height + ext.top + ext.bottom; - // When extensions are enabled, calculate new bounds relative to current position - const currentBounds = this.canvas.outputAreaBounds; + // When extensions are enabled, calculate new bounds relative to original custom shape position + const originalPos = this.canvas.originalOutputAreaPosition; this.canvas.outputAreaBounds = { - x: currentBounds.x - ext.left, // Adjust position by left extension - y: currentBounds.y - ext.top, // Adjust position by top extension + x: originalPos.x - ext.left, // Adjust position by left extension from original position + y: originalPos.y - ext.top, // Adjust position by top extension from original position width: newWidth, height: newHeight }; diff --git a/js/MaskTool.js b/js/MaskTool.js index df9be1d..9b2fcdf 100644 --- a/js/MaskTool.js +++ b/js/MaskTool.js @@ -463,7 +463,14 @@ export class MaskTool { this.clearShapePreview(); const shape = this.canvasInstance.outputAreaShape; const viewport = this.canvasInstance.viewport; - const screenPoints = shape.points.map(p => ({ + const bounds = this.canvasInstance.outputAreaBounds; + // Convert shape points to world coordinates first (relative to output area bounds) + const worldShapePoints = shape.points.map(p => ({ + x: bounds.x + p.x, + y: bounds.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 })); @@ -504,7 +511,7 @@ export class MaskTool { this.shapePreviewCtx.stroke(); } } - log.debug(`Shape preview shown with expansion: ${expansionValue}px, feather: ${featherValue}px`); + log.debug(`Shape preview shown with expansion: ${expansionValue}px, feather: ${featherValue}px at bounds (${bounds.x}, ${bounds.y})`); } /** * Hide shape preview and switch back to normal mode @@ -754,10 +761,16 @@ export class MaskTool { return contour; } clear() { - this.maskCtx.clearRect(0, 0, this.maskCanvas.width, this.maskCanvas.height); + // 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"); } getMask() { // Always return the current active mask canvas which shows all chunks @@ -908,6 +921,21 @@ export class MaskTool { 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})`); } + /** + * 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; @@ -975,6 +1003,143 @@ export class MaskTool { 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})`); } + /** + * 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) { + // 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) { + 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 + ); + // 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})`); + } + /** + * 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) { + 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 + ); + // 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})`); + } applyShapeMask(saveState = true) { if (!this.canvasInstance.outputAreaShape?.points || this.canvasInstance.outputAreaShape.points.length < 3) { log.warn("Cannot apply shape mask: shape is not defined or has too few points."); @@ -984,65 +1149,73 @@ export class MaskTool { this.canvasInstance.canvasState.saveMaskState(); } const shape = this.canvasInstance.outputAreaShape; - const destX = -this.x; - const destY = -this.y; - const maskPoints = shape.points.map(p => ({ x: p.x + destX, y: p.y + destY })); - // --- Clear Previous State --- - // To prevent artifacts from previous slider values, we first clear the maximum - // possible area the shape could have occupied. - const maxExpansion = 300; // The maximum value of the expansion slider - const clearingMaskCanvas = this._createExpandedMaskCanvas(maskPoints, maxExpansion, this.maskCanvas.width, this.maskCanvas.height); - this.maskCtx.globalCompositeOperation = 'destination-out'; - this.maskCtx.drawImage(clearingMaskCanvas, 0, 0); - // --- Apply Current State --- - // Now, apply the new, correct mask additively. - this.maskCtx.globalCompositeOperation = 'source-over'; + const bounds = this.canvasInstance.outputAreaBounds; + // Calculate shape points in world coordinates + // Shape points are relative to the output area bounds + const worldShapePoints = shape.points.map(p => ({ + x: bounds.x + p.x, + y: bounds.y + p.y + })); + // 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; + // Create a temporary canvas large enough to contain the shape and any expansion + const maxExpansion = Math.max(300, Math.abs(this.canvasInstance.shapeMaskExpansionValue || 0)); + const tempCanvasWidth = bounds.width + (maxExpansion * 2); + const tempCanvasHeight = bounds.height + (maxExpansion * 2); + const tempOffsetX = maxExpansion; + const tempOffsetY = maxExpansion; + // Adjust shape points for the temporary canvas + const tempShapePoints = worldShapePoints.map(p => ({ + x: p.x - bounds.x + tempOffsetX, + y: p.y - bounds.y + tempOffsetY + })); if (!needsExpansion && !needsFeather) { // Simple case: just draw the original shape - this.maskCtx.fillStyle = 'white'; - this.maskCtx.beginPath(); - this.maskCtx.moveTo(maskPoints[0].x, maskPoints[0].y); - for (let i = 1; i < maskPoints.length; i++) { - this.maskCtx.lineTo(maskPoints[i].x, maskPoints[i].y); + shapeMaskCanvas = document.createElement('canvas'); + shapeMaskCanvas.width = tempCanvasWidth; + shapeMaskCanvas.height = tempCanvasHeight; + const ctx = shapeMaskCanvas.getContext('2d', { willReadFrequently: true }); + ctx.fillStyle = 'white'; + ctx.beginPath(); + ctx.moveTo(tempShapePoints[0].x, tempShapePoints[0].y); + for (let i = 1; i < tempShapePoints.length; i++) { + ctx.lineTo(tempShapePoints[i].x, tempShapePoints[i].y); } - this.maskCtx.closePath(); - this.maskCtx.fill('evenodd'); // Use evenodd to handle holes correctly + ctx.closePath(); + ctx.fill('evenodd'); } else if (needsExpansion && !needsFeather) { - // Expansion only: use the new distance transform expansion - const expandedMaskCanvas = this._createExpandedMaskCanvas(maskPoints, this.canvasInstance.shapeMaskExpansionValue, this.maskCanvas.width, this.maskCanvas.height); - this.maskCtx.drawImage(expandedMaskCanvas, 0, 0); + // Expansion only + shapeMaskCanvas = this._createExpandedMaskCanvas(tempShapePoints, this.canvasInstance.shapeMaskExpansionValue, tempCanvasWidth, tempCanvasHeight); } else if (!needsExpansion && needsFeather) { - // Feather only: apply feathering to the original shape - const featheredMaskCanvas = this._createFeatheredMaskCanvas(maskPoints, this.canvasInstance.shapeMaskFeatherValue, this.maskCanvas.width, this.maskCanvas.height); - this.maskCtx.drawImage(featheredMaskCanvas, 0, 0); + // Feather only + shapeMaskCanvas = this._createFeatheredMaskCanvas(tempShapePoints, this.canvasInstance.shapeMaskFeatherValue, tempCanvasWidth, tempCanvasHeight); } else { - // Both expansion and feather: first expand, then apply feather to the expanded shape - // Step 1: Create expanded shape - const expandedMaskCanvas = this._createExpandedMaskCanvas(maskPoints, this.canvasInstance.shapeMaskExpansionValue, this.maskCanvas.width, this.maskCanvas.height); - // Step 2: Extract points from the expanded canvas and apply feathering - // For now, we'll apply feathering to the expanded canvas directly - // This is a simplified approach - we could extract the outline points for more precision + // Both expansion and feather + const expandedMaskCanvas = this._createExpandedMaskCanvas(tempShapePoints, this.canvasInstance.shapeMaskExpansionValue, tempCanvasWidth, tempCanvasHeight); const tempCtx = expandedMaskCanvas.getContext('2d', { willReadFrequently: true }); const expandedImageData = tempCtx.getImageData(0, 0, expandedMaskCanvas.width, expandedMaskCanvas.height); - // Apply feathering to the expanded shape - const featheredMaskCanvas = this._createFeatheredMaskFromImageData(expandedImageData, this.canvasInstance.shapeMaskFeatherValue, this.maskCanvas.width, this.maskCanvas.height); - this.maskCtx.drawImage(featheredMaskCanvas, 0, 0); + 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(); if (this.onStateChange) { this.onStateChange(); } this.canvasInstance.render(); - log.info(`Applied shape mask with expansion: ${needsExpansion}, feather: ${needsFeather}.`); + log.info(`Applied shape mask to chunks with expansion: ${needsExpansion}, feather: ${needsFeather}.`); } /** * 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() { if (!this.canvasInstance.outputAreaShape?.points || this.canvasInstance.outputAreaShape.points.length < 3) { @@ -1051,36 +1224,55 @@ export class MaskTool { } this.canvasInstance.canvasState.saveMaskState(); const shape = this.canvasInstance.outputAreaShape; - const destX = -this.x; - const destY = -this.y; - // Use 'destination-out' to erase the shape area - this.maskCtx.globalCompositeOperation = 'destination-out'; - const maskPoints = shape.points.map(p => ({ x: p.x + destX, y: p.y + destY })); + const bounds = this.canvasInstance.outputAreaBounds; + // Calculate shape points in world coordinates (same as applyShapeMask) + const worldShapePoints = shape.points.map(p => ({ + x: bounds.x + p.x, + y: bounds.y + p.y + })); + // Check if we need to account for expansion when removing const needsExpansion = this.canvasInstance.shapeMaskExpansion && this.canvasInstance.shapeMaskExpansionValue !== 0; - // IMPORTANT: Removal should always be hard-edged, even if feather was on. - // This ensures the feathered "glow" is completely removed. We only care about expansion. + // Create a removal mask canvas - always hard-edged to ensure complete removal + let removalMaskCanvas; + // Create a temporary canvas large enough to contain the shape and any expansion + const maxExpansion = Math.max(300, Math.abs(this.canvasInstance.shapeMaskExpansionValue || 0)); + const tempCanvasWidth = bounds.width + (maxExpansion * 2); + const tempCanvasHeight = bounds.height + (maxExpansion * 2); + const tempOffsetX = maxExpansion; + const tempOffsetY = maxExpansion; + // Adjust shape points for the temporary canvas + const tempShapePoints = worldShapePoints.map(p => ({ + x: p.x - bounds.x + tempOffsetX, + y: p.y - bounds.y + tempOffsetY + })); if (needsExpansion) { - // If expansion was active, remove the expanded area with a hard edge. - const expandedMaskCanvas = this._createExpandedMaskCanvas(maskPoints, this.canvasInstance.shapeMaskExpansionValue, this.maskCanvas.width, this.maskCanvas.height); - this.maskCtx.drawImage(expandedMaskCanvas, 0, 0); + // If expansion was active, remove the expanded area with a hard edge + removalMaskCanvas = this._createExpandedMaskCanvas(tempShapePoints, this.canvasInstance.shapeMaskExpansionValue, tempCanvasWidth, tempCanvasHeight); } else { - // If no expansion, just remove the base shape with a hard edge. - this.maskCtx.beginPath(); - this.maskCtx.moveTo(maskPoints[0].x, maskPoints[0].y); - for (let i = 1; i < maskPoints.length; i++) { - this.maskCtx.lineTo(maskPoints[i].x, maskPoints[i].y); + // If no expansion, just remove the base shape with a hard edge + removalMaskCanvas = document.createElement('canvas'); + removalMaskCanvas.width = tempCanvasWidth; + removalMaskCanvas.height = tempCanvasHeight; + const ctx = removalMaskCanvas.getContext('2d', { willReadFrequently: true }); + ctx.fillStyle = 'white'; + ctx.beginPath(); + ctx.moveTo(tempShapePoints[0].x, tempShapePoints[0].y); + for (let i = 1; i < tempShapePoints.length; i++) { + ctx.lineTo(tempShapePoints[i].x, tempShapePoints[i].y); } - this.maskCtx.closePath(); - this.maskCtx.fill('evenodd'); + ctx.closePath(); + ctx.fill('evenodd'); } - // Restore default composite operation - this.maskCtx.globalCompositeOperation = 'source-over'; + // Now remove the shape mask from the chunked system + this.removeMaskCanvasFromChunks(removalMaskCanvas, bounds.x - tempOffsetX, bounds.y - tempOffsetY); + // Update the active mask canvas to show the changes + this.updateActiveMaskCanvas(); if (this.onStateChange) { this.onStateChange(); } this.canvasInstance.render(); - log.info(`Removed shape mask area (hard-edged) with expansion: ${needsExpansion}.`); + log.info(`Removed shape mask from chunks with expansion: ${needsExpansion}.`); } _createFeatheredMaskCanvas(points, featherRadius, width, height) { // 1. Create a binary mask on a temporary canvas. diff --git a/src/Canvas.ts b/src/Canvas.ts index 4e561f3..066f51f 100644 --- a/src/Canvas.ts +++ b/src/Canvas.ts @@ -76,7 +76,9 @@ export class Canvas { outputAreaExtensions: { top: number, bottom: number, left: number, right: number }; outputAreaExtensionEnabled: boolean; outputAreaExtensionPreview: { top: number, bottom: number, left: number, right: number } | null; + lastOutputAreaExtensions: { top: number, bottom: number, left: number, right: number }; originalCanvasSize: { width: number, height: number }; + originalOutputAreaPosition: { x: number, y: number }; outputAreaBounds: OutputAreaBounds; node: ComfyNode; offscreenCanvas: HTMLCanvasElement; @@ -131,7 +133,9 @@ export class Canvas { this.outputAreaExtensions = { top: 0, bottom: 0, left: 0, right: 0 }; this.outputAreaExtensionEnabled = false; this.outputAreaExtensionPreview = null; + this.lastOutputAreaExtensions = { top: 0, bottom: 0, left: 0, right: 0 }; this.originalCanvasSize = { width: this.width, height: this.height }; + this.originalOutputAreaPosition = { x: -(this.width / 4), y: -(this.height / 4) }; // Initialize outputAreaBounds centered in viewport, similar to how canvas resize/move work this.outputAreaBounds = { x: -(this.width / 4), @@ -416,44 +420,7 @@ export class Canvas { } defineOutputAreaWithShape(shape: Shape): void { - const boundingBox = this.shapeTool.getBoundingBox(); - if (boundingBox && boundingBox.width > 1 && boundingBox.height > 1) { - this.saveState(); - - this.outputAreaShape = { - ...shape, - points: shape.points.map(p => ({ - x: p.x - boundingBox.x, - y: p.y - boundingBox.y - })) - }; - - const newWidth = Math.round(boundingBox.width); - const newHeight = Math.round(boundingBox.height); - const newX = Math.round(boundingBox.x); - const newY = Math.round(boundingBox.y); - - // Store the original canvas size for extension calculations - this.originalCanvasSize = { width: newWidth, height: newHeight }; - - // Update canvas size but don't change outputAreaBounds yet - this.updateOutputAreaSize(newWidth, newHeight, false); - - // Set outputAreaBounds to where the custom shape was drawn in the world - // Similar to finalizeCanvasMove - just update outputAreaBounds position - this.outputAreaBounds = { - x: newX, - y: newY, - width: newWidth, - height: newHeight - }; - - // Update mask canvas to ensure it covers the new output area position - this.maskTool.updateMaskCanvasForOutputArea(); - - this.saveState(); - this.render(); - } + this.canvasInteractions.defineOutputAreaWithShape(shape); } /** diff --git a/src/CanvasIO.ts b/src/CanvasIO.ts index 8e7b339..f5b8f73 100644 --- a/src/CanvasIO.ts +++ b/src/CanvasIO.ts @@ -775,12 +775,18 @@ export class CanvasIO { // Draw the image first ctx.drawImage(image, 0, 0); - // Create a clipping mask using the shape + // Calculate custom shape position accounting for extensions + // Custom shape should maintain its relative position within the original canvas area + const ext = this.canvas.outputAreaExtensionEnabled ? this.canvas.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 + + // Create a clipping mask using the shape with extension offset ctx.globalCompositeOperation = 'destination-in'; ctx.beginPath(); - ctx.moveTo(shape.points[0].x, shape.points[0].y); + ctx.moveTo(shape.points[0].x + shapeOffsetX, shape.points[0].y + shapeOffsetY); for (let i = 1; i < shape.points.length; i++) { - ctx.lineTo(shape.points[i].x, shape.points[i].y); + ctx.lineTo(shape.points[i].x + shapeOffsetX, shape.points[i].y + shapeOffsetY); } ctx.closePath(); ctx.fill(); diff --git a/src/CanvasInteractions.ts b/src/CanvasInteractions.ts index 6b6fd62..8ae2201 100644 --- a/src/CanvasInteractions.ts +++ b/src/CanvasInteractions.ts @@ -902,6 +902,70 @@ export class CanvasInteractions { reader.readAsDataURL(file); } + defineOutputAreaWithShape(shape: any): void { + const boundingBox = this.canvas.shapeTool.getBoundingBox(); + if (boundingBox && boundingBox.width > 1 && boundingBox.height > 1) { + this.canvas.saveState(); + + this.canvas.outputAreaShape = { + ...shape, + points: shape.points.map((p: any) => ({ + x: p.x - boundingBox.x, + y: p.y - boundingBox.y + })) + }; + + const newWidth = Math.round(boundingBox.width); + const newHeight = Math.round(boundingBox.height); + const newX = Math.round(boundingBox.x); + const newY = Math.round(boundingBox.y); + + // Store the original canvas size for extension calculations + this.canvas.originalCanvasSize = { width: newWidth, height: newHeight }; + + // Store the original position where custom shape was drawn for extension calculations + this.canvas.originalOutputAreaPosition = { x: newX, y: newY }; + + // If extensions are enabled, we need to recalculate outputAreaBounds with current extensions + if (this.canvas.outputAreaExtensionEnabled) { + const ext = this.canvas.outputAreaExtensions; + const extendedWidth = newWidth + ext.left + ext.right; + const extendedHeight = newHeight + ext.top + ext.bottom; + + // Update canvas size with extensions + this.canvas.updateOutputAreaSize(extendedWidth, extendedHeight, false); + + // Set outputAreaBounds accounting for extensions + this.canvas.outputAreaBounds = { + x: newX - ext.left, // Adjust position by left extension + y: newY - ext.top, // Adjust position by top extension + width: extendedWidth, + height: extendedHeight + }; + + log.info(`New custom shape with extensions: original(${newX}, ${newY}) extended(${newX - ext.left}, ${newY - ext.top}) size(${extendedWidth}x${extendedHeight})`); + } else { + // No extensions - use original size and position + this.canvas.updateOutputAreaSize(newWidth, newHeight, false); + + this.canvas.outputAreaBounds = { + x: newX, + y: newY, + width: newWidth, + height: newHeight + }; + + log.info(`New custom shape without extensions: position(${newX}, ${newY}) size(${newWidth}x${newHeight})`); + } + + // Update mask canvas to ensure it covers the new output area position + this.canvas.maskTool.updateMaskCanvasForOutputArea(); + + this.canvas.saveState(); + this.canvas.render(); + } + } + async handlePasteEvent(e: ClipboardEvent): Promise { const shouldHandle = this.canvas.isMouseOver || diff --git a/src/CanvasRenderer.ts b/src/CanvasRenderer.ts index c46d9de..0501c13 100644 --- a/src/CanvasRenderer.ts +++ b/src/CanvasRenderer.ts @@ -323,11 +323,17 @@ export class CanvasRenderer { const shape = this.canvas.outputAreaShape; const bounds = this.canvas.outputAreaBounds; + // Calculate custom shape position accounting for extensions + // Custom shape should maintain its relative position within the original canvas area + const ext = this.canvas.outputAreaExtensionEnabled ? this.canvas.outputAreaExtensions : { top: 0, bottom: 0, left: 0, right: 0 }; + const shapeOffsetX = bounds.x + ext.left; // Add left extension to maintain relative position + const shapeOffsetY = bounds.y + ext.top; // Add top extension to maintain relative position + ctx.beginPath(); - // Render custom shape relative to outputAreaBounds, not (0,0) - ctx.moveTo(bounds.x + shape.points[0].x, bounds.y + shape.points[0].y); + // Render custom shape with extension offset to maintain relative position + ctx.moveTo(shapeOffsetX + shape.points[0].x, shapeOffsetY + shape.points[0].y); for (let i = 1; i < shape.points.length; i++) { - ctx.lineTo(bounds.x + shape.points[i].x, bounds.y + shape.points[i].y); + ctx.lineTo(shapeOffsetX + shape.points[i].x, shapeOffsetY + shape.points[i].y); } ctx.closePath(); ctx.stroke(); @@ -379,10 +385,11 @@ export class CanvasRenderer { const ext = this.canvas.outputAreaExtensionPreview; - // Podgląd pokazuje jak będą wyglądać nowe outputAreaBounds + // Calculate preview bounds relative to original custom shape position, not (0,0) + const originalPos = this.canvas.originalOutputAreaPosition; const previewBounds = { - x: -ext.left, // Może być ujemne - wycinamy fragment świata - y: -ext.top, // Może być ujemne - wycinamy fragment świata + x: originalPos.x - ext.left, // ✅ Względem oryginalnej pozycji custom shape + y: originalPos.y - ext.top, // ✅ Względem oryginalnej pozycji custom shape width: baseWidth + ext.left + ext.right, height: baseHeight + ext.top + ext.bottom }; diff --git a/src/CustomShapeMenu.ts b/src/CustomShapeMenu.ts index f01bc90..07310b8 100644 --- a/src/CustomShapeMenu.ts +++ b/src/CustomShapeMenu.ts @@ -347,10 +347,16 @@ export class CustomShapeMenu { width: this.canvas.width, height: this.canvas.height }; + // Restore last saved extensions instead of starting from zero + this.canvas.outputAreaExtensions = { ...this.canvas.lastOutputAreaExtensions }; log.info(`Captured current canvas size as baseline: ${this.canvas.width}x${this.canvas.height}`); + log.info(`Restored last extensions:`, this.canvas.outputAreaExtensions); } else { - // Reset all extensions when disabled + // Save current extensions before disabling + this.canvas.lastOutputAreaExtensions = { ...this.canvas.outputAreaExtensions }; + // Reset current extensions when disabled (but keep the saved ones) this.canvas.outputAreaExtensions = { top: 0, bottom: 0, left: 0, right: 0 }; + log.info(`Saved extensions for later:`, this.canvas.lastOutputAreaExtensions); } this._updateExtensionUI(); @@ -729,12 +735,12 @@ export class CustomShapeMenu { public _updateCanvasSize(): void { if (!this.canvas.outputAreaExtensionEnabled) { - // When extensions are disabled, preserve the current outputAreaBounds position - // Only update the size to match originalCanvasSize - const currentBounds = this.canvas.outputAreaBounds; + // When extensions are disabled, return to original custom shape position + // Use originalOutputAreaPosition instead of current bounds position + const originalPos = this.canvas.originalOutputAreaPosition; this.canvas.outputAreaBounds = { - x: currentBounds.x, // ✅ Preserve current position - y: currentBounds.y, // ✅ Preserve current position + x: originalPos.x, // ✅ Return to original custom shape position + y: originalPos.y, // ✅ Return to original custom shape position width: this.canvas.originalCanvasSize.width, height: this.canvas.originalCanvasSize.height }; @@ -750,11 +756,11 @@ export class CustomShapeMenu { const newWidth = this.canvas.originalCanvasSize.width + ext.left + ext.right; const newHeight = this.canvas.originalCanvasSize.height + ext.top + ext.bottom; - // When extensions are enabled, calculate new bounds relative to current position - const currentBounds = this.canvas.outputAreaBounds; + // When extensions are enabled, calculate new bounds relative to original custom shape position + const originalPos = this.canvas.originalOutputAreaPosition; this.canvas.outputAreaBounds = { - x: currentBounds.x - ext.left, // Adjust position by left extension - y: currentBounds.y - ext.top, // Adjust position by top extension + x: originalPos.x - ext.left, // Adjust position by left extension from original position + y: originalPos.y - ext.top, // Adjust position by top extension from original position width: newWidth, height: newHeight }; diff --git a/src/MaskTool.ts b/src/MaskTool.ts index 9b7f55a..e6eb84e 100644 --- a/src/MaskTool.ts +++ b/src/MaskTool.ts @@ -591,8 +591,16 @@ export class MaskTool { const shape = this.canvasInstance.outputAreaShape; const viewport = this.canvasInstance.viewport; + const bounds = this.canvasInstance.outputAreaBounds; - const screenPoints = shape.points.map(p => ({ + // Convert shape points to world coordinates first (relative to output area bounds) + const worldShapePoints = shape.points.map(p => ({ + x: bounds.x + p.x, + y: bounds.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 })); @@ -638,7 +646,7 @@ export class MaskTool { } } - log.debug(`Shape preview shown with expansion: ${expansionValue}px, feather: ${featherValue}px`); + log.debug(`Shape preview shown with expansion: ${expansionValue}px, feather: ${featherValue}px at bounds (${bounds.x}, ${bounds.y})`); } /** @@ -915,10 +923,20 @@ export class MaskTool { } clear(): void { - this.maskCtx.clearRect(0, 0, this.maskCanvas.width, this.maskCanvas.height); + // 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"); } getMask(): HTMLCanvasElement { @@ -1102,6 +1120,24 @@ export class MaskTool { log.debug(`Cleared area from chunk (${Math.floor(chunk.x / this.chunkSize)}, ${Math.floor(chunk.y / this.chunkSize)}) at local position (${destX}, ${destY})`); } + /** + * Clears all mask chunks - used by the clear() function + */ + private clearAllMaskChunks(): void { + // 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: HTMLImageElement): void { // Add mask to chunks system instead of directly to active canvas const bounds = this.canvasInstance.outputAreaBounds; @@ -1186,6 +1222,176 @@ export class MaskTool { log.debug(`Added mask to chunk (${Math.floor(chunk.x / this.chunkSize)}, ${Math.floor(chunk.y / this.chunkSize)}) at local position (${destX}, ${destY})`); } + /** + * Applies a mask canvas to the chunked system at a specific world position + */ + private applyMaskCanvasToChunks(maskCanvas: HTMLCanvasElement, worldX: number, worldY: number): void { + // 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 + */ + private removeMaskCanvasFromChunks(maskCanvas: HTMLCanvasElement, worldX: number, worldY: number): void { + // 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 + */ + 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) { + 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 + ); + + // 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})`); + } + + /** + * 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) { + 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 + ); + + // 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})`); + } + applyShapeMask(saveState: boolean = true): void { if (!this.canvasInstance.outputAreaShape?.points || this.canvasInstance.outputAreaShape.points.length < 3) { log.warn("Cannot apply shape mask: shape is not defined or has too few points."); @@ -1196,72 +1402,81 @@ export class MaskTool { } const shape = this.canvasInstance.outputAreaShape; - const destX = -this.x; - const destY = -this.y; + const bounds = this.canvasInstance.outputAreaBounds; - const maskPoints = shape.points.map(p => ({ x: p.x + destX, y: p.y + destY })); + // Calculate shape points in world coordinates + // Shape points are relative to the output area bounds + const worldShapePoints = shape.points.map(p => ({ + x: bounds.x + p.x, + y: bounds.y + p.y + })); - // --- Clear Previous State --- - // To prevent artifacts from previous slider values, we first clear the maximum - // possible area the shape could have occupied. - const maxExpansion = 300; // The maximum value of the expansion slider - const clearingMaskCanvas = this._createExpandedMaskCanvas(maskPoints, maxExpansion, this.maskCanvas.width, this.maskCanvas.height); + // Create the shape mask canvas + let shapeMaskCanvas: HTMLCanvasElement; - this.maskCtx.globalCompositeOperation = 'destination-out'; - this.maskCtx.drawImage(clearingMaskCanvas, 0, 0); - - // --- Apply Current State --- - // Now, apply the new, correct mask additively. - this.maskCtx.globalCompositeOperation = 'source-over'; - // 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; + // Create a temporary canvas large enough to contain the shape and any expansion + const maxExpansion = Math.max(300, Math.abs(this.canvasInstance.shapeMaskExpansionValue || 0)); + const tempCanvasWidth = bounds.width + (maxExpansion * 2); + const tempCanvasHeight = bounds.height + (maxExpansion * 2); + const tempOffsetX = maxExpansion; + const tempOffsetY = maxExpansion; + + // Adjust shape points for the temporary canvas + const tempShapePoints = worldShapePoints.map(p => ({ + x: p.x - bounds.x + tempOffsetX, + y: p.y - bounds.y + tempOffsetY + })); + if (!needsExpansion && !needsFeather) { // Simple case: just draw the original shape - this.maskCtx.fillStyle = 'white'; - this.maskCtx.beginPath(); - this.maskCtx.moveTo(maskPoints[0].x, maskPoints[0].y); - for (let i = 1; i < maskPoints.length; i++) { - this.maskCtx.lineTo(maskPoints[i].x, maskPoints[i].y); - } - this.maskCtx.closePath(); - this.maskCtx.fill('evenodd'); // Use evenodd to handle holes correctly - } else if (needsExpansion && !needsFeather) { - // Expansion only: use the new distance transform expansion - const expandedMaskCanvas = this._createExpandedMaskCanvas(maskPoints, this.canvasInstance.shapeMaskExpansionValue, this.maskCanvas.width, this.maskCanvas.height); - this.maskCtx.drawImage(expandedMaskCanvas, 0, 0); - } else if (!needsExpansion && needsFeather) { - // Feather only: apply feathering to the original shape - const featheredMaskCanvas = this._createFeatheredMaskCanvas(maskPoints, this.canvasInstance.shapeMaskFeatherValue, this.maskCanvas.width, this.maskCanvas.height); - this.maskCtx.drawImage(featheredMaskCanvas, 0, 0); - } else { - // Both expansion and feather: first expand, then apply feather to the expanded shape - // Step 1: Create expanded shape - const expandedMaskCanvas = this._createExpandedMaskCanvas(maskPoints, this.canvasInstance.shapeMaskExpansionValue, this.maskCanvas.width, this.maskCanvas.height); + shapeMaskCanvas = document.createElement('canvas'); + shapeMaskCanvas.width = tempCanvasWidth; + shapeMaskCanvas.height = tempCanvasHeight; + const ctx = shapeMaskCanvas.getContext('2d', { willReadFrequently: true })!; - // Step 2: Extract points from the expanded canvas and apply feathering - // For now, we'll apply feathering to the expanded canvas directly - // This is a simplified approach - we could extract the outline points for more precision + ctx.fillStyle = 'white'; + ctx.beginPath(); + ctx.moveTo(tempShapePoints[0].x, tempShapePoints[0].y); + for (let i = 1; i < tempShapePoints.length; i++) { + ctx.lineTo(tempShapePoints[i].x, tempShapePoints[i].y); + } + ctx.closePath(); + ctx.fill('evenodd'); + } else if (needsExpansion && !needsFeather) { + // Expansion only + shapeMaskCanvas = this._createExpandedMaskCanvas(tempShapePoints, this.canvasInstance.shapeMaskExpansionValue, tempCanvasWidth, tempCanvasHeight); + } else if (!needsExpansion && needsFeather) { + // Feather only + shapeMaskCanvas = this._createFeatheredMaskCanvas(tempShapePoints, this.canvasInstance.shapeMaskFeatherValue, tempCanvasWidth, tempCanvasHeight); + } else { + // Both expansion and feather + const expandedMaskCanvas = this._createExpandedMaskCanvas(tempShapePoints, this.canvasInstance.shapeMaskExpansionValue, tempCanvasWidth, tempCanvasHeight); const tempCtx = expandedMaskCanvas.getContext('2d', { willReadFrequently: true })!; const expandedImageData = tempCtx.getImageData(0, 0, expandedMaskCanvas.width, expandedMaskCanvas.height); - - // Apply feathering to the expanded shape - const featheredMaskCanvas = this._createFeatheredMaskFromImageData(expandedImageData, this.canvasInstance.shapeMaskFeatherValue, this.maskCanvas.width, this.maskCanvas.height); - this.maskCtx.drawImage(featheredMaskCanvas, 0, 0); + 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(); + if (this.onStateChange) { this.onStateChange(); } this.canvasInstance.render(); - log.info(`Applied shape mask with expansion: ${needsExpansion}, feather: ${needsFeather}.`); + log.info(`Applied shape mask to chunks with expansion: ${needsExpansion}, feather: ${needsFeather}.`); } /** * 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(): void { if (!this.canvasInstance.outputAreaShape?.points || this.canvasInstance.outputAreaShape.points.length < 3) { @@ -1272,45 +1487,69 @@ export class MaskTool { this.canvasInstance.canvasState.saveMaskState(); const shape = this.canvasInstance.outputAreaShape; - const destX = -this.x; - const destY = -this.y; + const bounds = this.canvasInstance.outputAreaBounds; - // Use 'destination-out' to erase the shape area - this.maskCtx.globalCompositeOperation = 'destination-out'; + // Calculate shape points in world coordinates (same as applyShapeMask) + const worldShapePoints = shape.points.map(p => ({ + x: bounds.x + p.x, + y: bounds.y + p.y + })); - const maskPoints = shape.points.map(p => ({ x: p.x + destX, y: p.y + destY })); + // Check if we need to account for expansion when removing const needsExpansion = this.canvasInstance.shapeMaskExpansion && this.canvasInstance.shapeMaskExpansionValue !== 0; - // IMPORTANT: Removal should always be hard-edged, even if feather was on. - // This ensures the feathered "glow" is completely removed. We only care about expansion. + // Create a removal mask canvas - always hard-edged to ensure complete removal + let removalMaskCanvas: HTMLCanvasElement; + + // Create a temporary canvas large enough to contain the shape and any expansion + const maxExpansion = Math.max(300, Math.abs(this.canvasInstance.shapeMaskExpansionValue || 0)); + const tempCanvasWidth = bounds.width + (maxExpansion * 2); + const tempCanvasHeight = bounds.height + (maxExpansion * 2); + const tempOffsetX = maxExpansion; + const tempOffsetY = maxExpansion; + + // Adjust shape points for the temporary canvas + const tempShapePoints = worldShapePoints.map(p => ({ + x: p.x - bounds.x + tempOffsetX, + y: p.y - bounds.y + tempOffsetY + })); + if (needsExpansion) { - // If expansion was active, remove the expanded area with a hard edge. - const expandedMaskCanvas = this._createExpandedMaskCanvas( - maskPoints, + // If expansion was active, remove the expanded area with a hard edge + removalMaskCanvas = this._createExpandedMaskCanvas( + tempShapePoints, this.canvasInstance.shapeMaskExpansionValue, - this.maskCanvas.width, - this.maskCanvas.height + tempCanvasWidth, + tempCanvasHeight ); - this.maskCtx.drawImage(expandedMaskCanvas, 0, 0); } else { - // If no expansion, just remove the base shape with a hard edge. - this.maskCtx.beginPath(); - this.maskCtx.moveTo(maskPoints[0].x, maskPoints[0].y); - for (let i = 1; i < maskPoints.length; i++) { - this.maskCtx.lineTo(maskPoints[i].x, maskPoints[i].y); + // If no expansion, just remove the base shape with a hard edge + removalMaskCanvas = document.createElement('canvas'); + removalMaskCanvas.width = tempCanvasWidth; + removalMaskCanvas.height = tempCanvasHeight; + const ctx = removalMaskCanvas.getContext('2d', { willReadFrequently: true })!; + + ctx.fillStyle = 'white'; + ctx.beginPath(); + ctx.moveTo(tempShapePoints[0].x, tempShapePoints[0].y); + for (let i = 1; i < tempShapePoints.length; i++) { + ctx.lineTo(tempShapePoints[i].x, tempShapePoints[i].y); } - this.maskCtx.closePath(); - this.maskCtx.fill('evenodd'); + ctx.closePath(); + ctx.fill('evenodd'); } - // Restore default composite operation - this.maskCtx.globalCompositeOperation = 'source-over'; + // Now remove the shape mask from the chunked system + this.removeMaskCanvasFromChunks(removalMaskCanvas, bounds.x - tempOffsetX, bounds.y - tempOffsetY); + + // Update the active mask canvas to show the changes + this.updateActiveMaskCanvas(); if (this.onStateChange) { this.onStateChange(); } this.canvasInstance.render(); - log.info(`Removed shape mask area (hard-edged) with expansion: ${needsExpansion}.`); + log.info(`Removed shape mask from chunks with expansion: ${needsExpansion}.`); } private _createFeatheredMaskCanvas(points: Point[], featherRadius: number, width: number, height: number): HTMLCanvasElement {