From ca9e1890c46c1e4477184d7760f03de86edbb2a6 Mon Sep 17 00:00:00 2001 From: Dariusz L Date: Sat, 26 Jul 2025 19:19:23 +0200 Subject: [PATCH] Refactor mask system to use chunked canvas storage Replaces the single large mask canvas with a chunked system, where mask data is stored in 512x512 pixel chunks. Updates all mask drawing, compositing, and manipulation logic to operate on these chunks, improving performance and scalability for large or sparse masks. The active mask canvas is now a composite of all non-empty chunks, and all mask operations (drawing, setting, clearing) are adapted to the new chunked architecture. --- js/CanvasMask.js | 16 +- js/MaskTool.js | 540 +++++++++++++++++++++++++++++--------- src/CanvasMask.ts | 19 +- src/MaskTool.ts | 656 ++++++++++++++++++++++++++++++++++++---------- 4 files changed, 942 insertions(+), 289 deletions(-) diff --git a/js/CanvasMask.js b/js/CanvasMask.js index c3c23b7..7d94f03 100644 --- a/js/CanvasMask.js +++ b/js/CanvasMask.js @@ -442,22 +442,12 @@ export class CanvasMask { const maskAsImage = new Image(); maskAsImage.src = tempCanvas.toDataURL(); await new Promise(resolve => maskAsImage.onload = resolve); - const maskCtx = this.maskTool.maskCtx; - // Pozycja gdzie ma być aplikowana maska na canvas MaskTool - // MaskTool canvas ma pozycję (maskTool.x, maskTool.y) w świecie - // Maska z edytora reprezentuje output bounds, więc musimy ją umieścić - // w pozycji bounds względem pozycji MaskTool - const destX = bounds.x - this.maskTool.x; - const destY = bounds.y - this.maskTool.y; - log.debug("Applying mask to canvas", { - maskToolPos: { x: this.maskTool.x, y: this.maskTool.y }, + log.debug("Applying mask using chunk system", { boundsPos: { x: bounds.x, y: bounds.y }, - destPos: { x: destX, y: destY }, maskSize: { width: bounds.width, height: bounds.height } }); - maskCtx.globalCompositeOperation = 'source-over'; - maskCtx.clearRect(destX, destY, bounds.width, bounds.height); - maskCtx.drawImage(maskAsImage, destX, destY); + // Use the chunk system instead of direct canvas manipulation + this.maskTool.setMask(maskAsImage); this.canvas.render(); this.canvas.saveState(); log.debug("Creating new preview image"); diff --git a/js/MaskTool.js b/js/MaskTool.js index 57d9559..df9be1d 100644 --- a/js/MaskTool.js +++ b/js/MaskTool.js @@ -5,12 +5,17 @@ export class MaskTool { this.canvasInstance = canvasInstance; this.mainCanvas = canvasInstance.canvas; this.onStateChange = callbacks.onStateChange || null; - this.maskCanvas = document.createElement('canvas'); - const maskCtx = this.maskCanvas.getContext('2d', { willReadFrequently: true }); - if (!maskCtx) { - throw new Error("Failed to get 2D context for mask canvas"); + // Initialize chunked mask system + this.maskChunks = new Map(); + this.chunkSize = 512; + this.activeChunkBounds = null; + // Create active mask canvas (composite of chunks) + this.activeMaskCanvas = document.createElement('canvas'); + const activeMaskCtx = this.activeMaskCanvas.getContext('2d', { willReadFrequently: true }); + if (!activeMaskCtx) { + throw new Error("Failed to get 2D context for active mask canvas"); } - this.maskCtx = maskCtx; + this.activeMaskCtx = activeMaskCtx; this.x = 0; this.y = 0; this.isOverlayVisible = true; @@ -39,6 +44,13 @@ export class MaskTool { this.isPreviewMode = false; this.initMaskCanvas(); } + // Temporary compatibility getters - will be replaced with chunked system + get maskCanvas() { + return this.activeMaskCanvas; + } + get maskCtx() { + return this.activeMaskCtx; + } initPreviewCanvas() { if (this.previewCanvas.parentElement) { this.previewCanvas.parentElement.removeChild(this.previewCanvas); @@ -58,19 +70,117 @@ export class MaskTool { this.brushHardness = Math.max(0, Math.min(1, hardness)); } initMaskCanvas() { - const extraSpace = 2000; // Allow for a generous drawing area outside the output area - const bounds = this.canvasInstance.outputAreaBounds; - // Mask canvas should cover output area + extra space around it - const maskLeft = bounds.x - extraSpace / 2; - const maskTop = bounds.y - extraSpace / 2; - const maskWidth = bounds.width + extraSpace; - const maskHeight = bounds.height + extraSpace; - this.maskCanvas.width = maskWidth; - this.maskCanvas.height = maskHeight; - this.x = maskLeft; - this.y = maskTop; - this.maskCtx.clearRect(0, 0, this.maskCanvas.width, this.maskCanvas.height); - log.info(`Initialized mask canvas with size: ${this.maskCanvas.width}x${this.maskCanvas.height}, positioned at (${this.x}, ${this.y}) to cover output area at (${bounds.x}, ${bounds.y})`); + // Initialize chunked system + this.chunkSize = 512; + this.maskChunks = new Map(); + // Create initial active mask canvas + this.updateActiveMaskCanvas(); + log.info(`Initialized chunked mask system with chunk size: ${this.chunkSize}x${this.chunkSize}`); + } + /** + * Updates the active mask canvas to show ALL chunks with mask data + * No longer limited to output area - shows all drawn masks everywhere + */ + updateActiveMaskCanvas() { + // Find bounds of all non-empty chunks + const chunkBounds = this.getAllChunkBounds(); + if (!chunkBounds) { + // No chunks with data - create minimal canvas + this.activeMaskCanvas.width = 1; + this.activeMaskCanvas.height = 1; + this.x = 0; + this.y = 0; + this.activeChunkBounds = null; + log.info("No mask chunks found - created minimal active canvas"); + return; + } + // Calculate canvas size to cover all chunks + const canvasLeft = chunkBounds.minX * this.chunkSize; + const canvasTop = chunkBounds.minY * this.chunkSize; + const canvasWidth = (chunkBounds.maxX - chunkBounds.minX + 1) * this.chunkSize; + const canvasHeight = (chunkBounds.maxY - chunkBounds.minY + 1) * this.chunkSize; + // Update active mask canvas size and position + this.activeMaskCanvas.width = canvasWidth; + this.activeMaskCanvas.height = canvasHeight; + this.x = canvasLeft; + this.y = canvasTop; + // Clear active canvas + this.activeMaskCtx.clearRect(0, 0, canvasWidth, canvasHeight); + this.activeChunkBounds = chunkBounds; + // Composite ALL chunks with data onto active canvas + for (let chunkY = chunkBounds.minY; chunkY <= chunkBounds.maxY; chunkY++) { + for (let chunkX = chunkBounds.minX; chunkX <= chunkBounds.maxX; chunkX++) { + const chunkKey = `${chunkX},${chunkY}`; + const chunk = this.maskChunks.get(chunkKey); + if (chunk && !chunk.isEmpty) { + // Calculate position on active canvas + const destX = (chunkX - chunkBounds.minX) * this.chunkSize; + const destY = (chunkY - chunkBounds.minY) * this.chunkSize; + this.activeMaskCtx.drawImage(chunk.canvas, destX, destY); + } + } + } + log.info(`Updated active mask canvas to show ALL chunks: ${canvasWidth}x${canvasHeight} at (${canvasLeft}, ${canvasTop}), chunks: ${chunkBounds.minX},${chunkBounds.minY} to ${chunkBounds.maxX},${chunkBounds.maxY}`); + } + /** + * Finds the bounds of all chunks that contain mask data + * Returns null if no chunks have data + */ + getAllChunkBounds() { + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + let hasData = false; + for (const [chunkKey, chunk] of this.maskChunks) { + if (!chunk.isEmpty) { + const [chunkXStr, chunkYStr] = chunkKey.split(','); + const chunkX = parseInt(chunkXStr); + const chunkY = parseInt(chunkYStr); + minX = Math.min(minX, chunkX); + minY = Math.min(minY, chunkY); + maxX = Math.max(maxX, chunkX); + maxY = Math.max(maxY, chunkY); + hasData = true; + } + } + return hasData ? { minX, minY, maxX, maxY } : null; + } + /** + * Gets or creates a chunk for the given world coordinates + */ + getChunkForPosition(worldX, worldY) { + const chunkX = Math.floor(worldX / this.chunkSize); + const chunkY = Math.floor(worldY / this.chunkSize); + const chunkKey = `${chunkX},${chunkY}`; + let chunk = this.maskChunks.get(chunkKey); + if (!chunk) { + chunk = this.createChunk(chunkX, chunkY); + this.maskChunks.set(chunkKey, chunk); + } + return chunk; + } + /** + * Creates a new chunk at the given chunk coordinates + */ + createChunk(chunkX, chunkY) { + const canvas = document.createElement('canvas'); + canvas.width = this.chunkSize; + canvas.height = this.chunkSize; + const ctx = canvas.getContext('2d', { willReadFrequently: true }); + if (!ctx) { + throw new Error("Failed to get 2D context for chunk canvas"); + } + const chunk = { + canvas, + ctx, + x: chunkX * this.chunkSize, + y: chunkY * this.chunkSize, + isDirty: false, + isEmpty: true + }; + log.debug(`Created chunk at (${chunkX}, ${chunkY}) covering world area (${chunk.x}, ${chunk.y}) to (${chunk.x + this.chunkSize}, ${chunk.y + this.chunkSize})`); + return chunk; } activate() { if (!this.previewCanvasInitialized) { @@ -140,38 +250,159 @@ export class MaskTool { if (!this.lastPosition) { this.lastPosition = worldCoords; } - const canvasLastX = this.lastPosition.x - this.x; - const canvasLastY = this.lastPosition.y - this.y; - const canvasX = worldCoords.x - this.x; - const canvasY = worldCoords.y - this.y; - const canvasWidth = this.maskCanvas.width; - const canvasHeight = this.maskCanvas.height; - if (canvasX >= 0 && canvasX < canvasWidth && - canvasY >= 0 && canvasY < canvasHeight && - canvasLastX >= 0 && canvasLastX < canvasWidth && - canvasLastY >= 0 && canvasLastY < canvasHeight) { - this.maskCtx.beginPath(); - this.maskCtx.moveTo(canvasLastX, canvasLastY); - this.maskCtx.lineTo(canvasX, canvasY); - const gradientRadius = this.brushSize / 2; - if (this.brushHardness === 1) { - this.maskCtx.strokeStyle = `rgba(255, 255, 255, ${this.brushStrength})`; + // Draw on chunks instead of single canvas + this.drawOnChunks(this.lastPosition, worldCoords); + // Only update active canvas if we drew on chunks that are currently visible + // This prevents unnecessary recomposition during drawing + this.updateActiveCanvasIfNeeded(this.lastPosition, worldCoords); + } + /** + * Draws a line between two world coordinates on the appropriate chunks + */ + drawOnChunks(startWorld, endWorld) { + // Calculate all chunks that this line might touch + const minX = Math.min(startWorld.x, endWorld.x) - this.brushSize; + const maxX = Math.max(startWorld.x, endWorld.x) + this.brushSize; + const minY = Math.min(startWorld.y, endWorld.y) - this.brushSize; + const maxY = Math.max(startWorld.y, endWorld.y) + this.brushSize; + const chunkMinX = Math.floor(minX / this.chunkSize); + const chunkMinY = Math.floor(minY / this.chunkSize); + const chunkMaxX = Math.floor(maxX / this.chunkSize); + const chunkMaxY = Math.floor(maxY / this.chunkSize); + // Draw on all affected chunks + for (let chunkY = chunkMinY; chunkY <= chunkMaxY; chunkY++) { + for (let chunkX = chunkMinX; chunkX <= chunkMaxX; chunkX++) { + const chunk = this.getChunkForPosition(chunkX * this.chunkSize, chunkY * this.chunkSize); + this.drawLineOnChunk(chunk, startWorld, endWorld); } - else { - const innerRadius = gradientRadius * this.brushHardness; - const gradient = this.maskCtx.createRadialGradient(canvasX, canvasY, innerRadius, canvasX, canvasY, gradientRadius); - gradient.addColorStop(0, `rgba(255, 255, 255, ${this.brushStrength})`); - gradient.addColorStop(1, `rgba(255, 255, 255, 0)`); - this.maskCtx.strokeStyle = gradient; - } - this.maskCtx.lineWidth = this.brushSize; - this.maskCtx.lineCap = 'round'; - this.maskCtx.lineJoin = 'round'; - this.maskCtx.globalCompositeOperation = 'source-over'; - this.maskCtx.stroke(); + } + } + /** + * Draws a line on a specific chunk + */ + drawLineOnChunk(chunk, startWorld, endWorld) { + // Convert world coordinates to chunk-local coordinates + const startLocal = { + x: startWorld.x - chunk.x, + y: startWorld.y - chunk.y + }; + const endLocal = { + x: endWorld.x - chunk.x, + y: endWorld.y - chunk.y + }; + // Check if the line intersects this chunk + if (!this.lineIntersectsChunk(startLocal, endLocal, this.chunkSize)) { + return; + } + // Draw the line on this chunk + chunk.ctx.beginPath(); + chunk.ctx.moveTo(startLocal.x, startLocal.y); + chunk.ctx.lineTo(endLocal.x, endLocal.y); + const gradientRadius = this.brushSize / 2; + if (this.brushHardness === 1) { + chunk.ctx.strokeStyle = `rgba(255, 255, 255, ${this.brushStrength})`; } else { - log.debug(`Drawing outside mask canvas bounds: (${canvasX}, ${canvasY})`); + const innerRadius = gradientRadius * this.brushHardness; + const gradient = chunk.ctx.createRadialGradient(endLocal.x, endLocal.y, innerRadius, endLocal.x, endLocal.y, gradientRadius); + gradient.addColorStop(0, `rgba(255, 255, 255, ${this.brushStrength})`); + gradient.addColorStop(1, `rgba(255, 255, 255, 0)`); + chunk.ctx.strokeStyle = gradient; + } + chunk.ctx.lineWidth = this.brushSize; + chunk.ctx.lineCap = 'round'; + chunk.ctx.lineJoin = 'round'; + chunk.ctx.globalCompositeOperation = 'source-over'; + chunk.ctx.stroke(); + // Mark chunk as dirty and not empty + chunk.isDirty = true; + chunk.isEmpty = false; + log.debug(`Drew on chunk (${Math.floor(chunk.x / this.chunkSize)}, ${Math.floor(chunk.y / this.chunkSize)})`); + } + /** + * Checks if a line intersects with a chunk bounds + */ + lineIntersectsChunk(startLocal, endLocal, chunkSize) { + // Expand bounds by brush size to catch partial intersections + const margin = this.brushSize / 2; + const left = -margin; + const top = -margin; + const right = chunkSize + margin; + const bottom = chunkSize + margin; + // Check if either point is inside the expanded bounds + if ((startLocal.x >= left && startLocal.x <= right && startLocal.y >= top && startLocal.y <= bottom) || + (endLocal.x >= left && endLocal.x <= right && endLocal.y >= top && endLocal.y <= bottom)) { + return true; + } + // Check if line crosses chunk bounds (simplified check) + return true; // For now, always draw - more precise intersection can be added later + } + /** + * Updates active canvas when drawing affects chunks + * Now always updates when new chunks are created to ensure immediate visibility + */ + updateActiveCanvasIfNeeded(startWorld, endWorld) { + // Calculate which chunks were affected by this drawing operation + const minX = Math.min(startWorld.x, endWorld.x) - this.brushSize; + const maxX = Math.max(startWorld.x, endWorld.x) + this.brushSize; + const minY = Math.min(startWorld.y, endWorld.y) - this.brushSize; + const maxY = Math.max(startWorld.y, endWorld.y) + this.brushSize; + const affectedChunkMinX = Math.floor(minX / this.chunkSize); + const affectedChunkMinY = Math.floor(minY / this.chunkSize); + const affectedChunkMaxX = Math.floor(maxX / this.chunkSize); + const affectedChunkMaxY = Math.floor(maxY / this.chunkSize); + // Check if we drew on any new chunks (outside current active bounds) + let drewOnNewChunks = false; + if (!this.activeChunkBounds) { + drewOnNewChunks = true; + } + else { + drewOnNewChunks = + affectedChunkMinX < this.activeChunkBounds.minX || + affectedChunkMaxX > this.activeChunkBounds.maxX || + affectedChunkMinY < this.activeChunkBounds.minY || + affectedChunkMaxY > this.activeChunkBounds.maxY; + } + if (drewOnNewChunks) { + // Drawing extended beyond current active bounds - do full update to include new chunks + this.updateActiveMaskCanvas(); + log.debug("Drew on new chunks - performed full active canvas update"); + } + else { + // Drawing within existing bounds - do partial update for performance + this.updateActiveCanvasPartial(affectedChunkMinX, affectedChunkMinY, affectedChunkMaxX, affectedChunkMaxY); + log.debug("Drew within existing bounds - performed partial update"); + } + } + /** + * Partially updates the active canvas by redrawing only specific chunks + * Much faster than full recomposition during drawing + * Now works with the new system that shows ALL chunks + */ + updateActiveCanvasPartial(chunkMinX, chunkMinY, chunkMaxX, chunkMaxY) { + if (!this.activeChunkBounds) { + // No active bounds - do full update + this.updateActiveMaskCanvas(); + return; + } + // Only redraw the affected chunks that are within the current active canvas bounds + for (let chunkY = chunkMinY; chunkY <= chunkMaxY; chunkY++) { + for (let chunkX = chunkMinX; chunkX <= chunkMaxX; chunkX++) { + // Check if this chunk is within active bounds (all chunks with data) + if (chunkX >= this.activeChunkBounds.minX && chunkX <= this.activeChunkBounds.maxX && + chunkY >= this.activeChunkBounds.minY && chunkY <= this.activeChunkBounds.maxY) { + const chunkKey = `${chunkX},${chunkY}`; + const chunk = this.maskChunks.get(chunkKey); + if (chunk && !chunk.isEmpty) { + // Calculate position on active canvas (relative to all chunks bounds) + const destX = (chunkX - this.activeChunkBounds.minX) * this.chunkSize; + const destY = (chunkY - this.activeChunkBounds.minY) * this.chunkSize; + // Clear the area first, then redraw + this.activeMaskCtx.clearRect(destX, destY, this.chunkSize, this.chunkSize); + this.activeMaskCtx.drawImage(chunk.canvas, destX, destY); + } + } + } } } drawBrushPreview(viewCoords) { @@ -529,7 +760,10 @@ export class MaskTool { } } getMask() { - return this.maskCanvas; + // Always return the current active mask canvas which shows all chunks + // Make sure it's up to date before returning + this.updateActiveMaskCanvas(); + return this.activeMaskCanvas; } getMaskImageWithAlpha() { const tempCanvas = document.createElement('canvas'); @@ -563,24 +797,24 @@ export class MaskTool { const oldHeight = oldMask.height; const isIncreasingWidth = width > this.canvasInstance.width; const isIncreasingHeight = height > this.canvasInstance.height; - this.maskCanvas = document.createElement('canvas'); + this.activeMaskCanvas = document.createElement('canvas'); const extraSpace = 2000; const newWidth = isIncreasingWidth ? width + extraSpace : Math.max(oldWidth, width + extraSpace); const newHeight = isIncreasingHeight ? height + extraSpace : Math.max(oldHeight, height + extraSpace); - this.maskCanvas.width = newWidth; - this.maskCanvas.height = newHeight; - const newMaskCtx = this.maskCanvas.getContext('2d', { willReadFrequently: true }); + this.activeMaskCanvas.width = newWidth; + this.activeMaskCanvas.height = newHeight; + const newMaskCtx = this.activeMaskCanvas.getContext('2d', { willReadFrequently: true }); if (!newMaskCtx) { throw new Error("Failed to get 2D context for new mask canvas"); } - this.maskCtx = newMaskCtx; + this.activeMaskCtx = newMaskCtx; if (oldMask.width > 0 && oldMask.height > 0) { const offsetX = this.x - oldX; const offsetY = this.y - oldY; - this.maskCtx.drawImage(oldMask, offsetX, offsetY); + this.activeMaskCtx.drawImage(oldMask, offsetX, offsetY); log.debug(`Preserved mask content with offset (${offsetX}, ${offsetY})`); } - log.info(`Mask canvas resized to ${this.maskCanvas.width}x${this.maskCanvas.height}, position (${this.x}, ${this.y})`); + log.info(`Mask canvas resized to ${this.activeMaskCanvas.width}x${this.activeMaskCanvas.height}, position (${this.x}, ${this.y})`); log.info(`Canvas size change: width ${isIncreasingWidth ? 'increased' : 'decreased'}, height ${isIncreasingHeight ? 'increased' : 'decreased'}`); } updatePosition(dx, dy) { @@ -591,87 +825,155 @@ export class MaskTool { /** * Updates mask canvas to ensure it covers the current output area * This should be called when output area position or size changes + * Now uses chunked system - just updates the active mask canvas */ updateMaskCanvasForOutputArea() { - const extraSpace = 2000; - const bounds = this.canvasInstance.outputAreaBounds; - // Calculate required mask canvas bounds - const requiredLeft = bounds.x - extraSpace / 2; - const requiredTop = bounds.y - extraSpace / 2; - const requiredWidth = bounds.width + extraSpace; - const requiredHeight = bounds.height + extraSpace; - // Check if current mask canvas covers the required area - const currentRight = this.x + this.maskCanvas.width; - const currentBottom = this.y + this.maskCanvas.height; - const requiredRight = requiredLeft + requiredWidth; - const requiredBottom = requiredTop + requiredHeight; - const needsResize = requiredLeft < this.x || - requiredTop < this.y || - requiredRight > currentRight || - requiredBottom > currentBottom; - if (needsResize) { - log.info(`Updating mask canvas to cover output area at (${bounds.x}, ${bounds.y})`); - // Save current mask content - const oldMask = this.maskCanvas; - const oldX = this.x; - const oldY = this.y; - // Create new mask canvas with proper size and position - this.maskCanvas = document.createElement('canvas'); - this.maskCanvas.width = requiredWidth; - this.maskCanvas.height = requiredHeight; - this.x = requiredLeft; - this.y = requiredTop; - const newMaskCtx = this.maskCanvas.getContext('2d', { willReadFrequently: true }); - if (!newMaskCtx) { - throw new Error("Failed to get 2D context for new mask canvas"); - } - this.maskCtx = newMaskCtx; - // Copy old mask content to new position - if (oldMask.width > 0 && oldMask.height > 0) { - const offsetX = oldX - this.x; - const offsetY = oldY - this.y; - this.maskCtx.drawImage(oldMask, offsetX, offsetY); - log.debug(`Preserved mask content with offset (${offsetX}, ${offsetY})`); - } - log.info(`Mask canvas updated to ${this.maskCanvas.width}x${this.maskCanvas.height} at (${this.x}, ${this.y})`); - } + log.info(`Updating chunked mask system for output area at (${this.canvasInstance.outputAreaBounds.x}, ${this.canvasInstance.outputAreaBounds.y})`); + // Simply update the active mask canvas to cover the new output area + // All existing chunks are preserved in the maskChunks Map + this.updateActiveMaskCanvas(); + log.info(`Chunked mask system updated - ${this.maskChunks.size} chunks preserved`); } toggleOverlayVisibility() { this.isOverlayVisible = !this.isOverlayVisible; log.info(`Mask overlay visibility toggled to: ${this.isOverlayVisible}`); } setMask(image) { - // Pozycja gdzie ma być aplikowana maska na canvas MaskTool - // MaskTool canvas ma pozycję (this.x, this.y) w świecie - // Maska reprezentuje output bounds, więc musimy ją umieścić - // w pozycji bounds względem pozycji MaskTool + // Clear existing mask chunks in the output area first const bounds = this.canvasInstance.outputAreaBounds; - const destX = bounds.x - this.x; - const destY = bounds.y - this.y; - this.maskCtx.clearRect(destX, destY, this.canvasInstance.width, this.canvasInstance.height); - this.maskCtx.drawImage(image, destX, destY); - if (this.onStateChange) { - this.onStateChange(); + this.clearMaskInArea(bounds.x, bounds.y, image.width, image.height); + // Add the new mask using the chunk system + this.addMask(image); + log.info(`MaskTool set new mask using chunk system at bounds (${bounds.x}, ${bounds.y})`); + } + /** + * Clears mask data in a specific area by clearing affected chunks + */ + clearMaskInArea(x, y, width, height) { + const chunkMinX = Math.floor(x / this.chunkSize); + const chunkMinY = Math.floor(y / this.chunkSize); + const chunkMaxX = Math.floor((x + width) / this.chunkSize); + const chunkMaxY = Math.floor((y + height) / this.chunkSize); + // Clear all affected chunks + for (let chunkY = chunkMinY; chunkY <= chunkMaxY; chunkY++) { + for (let chunkX = chunkMinX; chunkX <= chunkMaxX; chunkX++) { + const chunkKey = `${chunkX},${chunkY}`; + const chunk = this.maskChunks.get(chunkKey); + if (chunk && !chunk.isEmpty) { + this.clearMaskFromChunk(chunk, x, y, width, height); + } + } } - this.canvasInstance.render(); - log.info(`MaskTool updated with a new mask image at position (${destX}, ${destY}) relative to bounds (${bounds.x}, ${bounds.y}).`); + } + /** + * 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) { + 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})`); } addMask(image) { - // Pozycja gdzie ma być aplikowana maska na canvas MaskTool - // MaskTool canvas ma pozycję (this.x, this.y) w świecie - // Maska z SAM reprezentuje output bounds, więc musimy ją umieścić - // w pozycji bounds względem pozycji MaskTool + // Add mask to chunks system instead of directly to active canvas const bounds = this.canvasInstance.outputAreaBounds; - const destX = bounds.x - this.x; - const destY = bounds.y - this.y; - // Don't clear existing mask - just add to it - this.maskCtx.globalCompositeOperation = 'source-over'; - this.maskCtx.drawImage(image, destX, destY); + // Calculate which chunks this mask will affect + const maskLeft = bounds.x; + const maskTop = bounds.y; + const maskRight = bounds.x + image.width; + const maskBottom = bounds.y + image.height; + const 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); + // Add 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.addMaskToChunk(chunk, image, bounds); + } + } + // Update active canvas to show the new mask + this.updateActiveMaskCanvas(); if (this.onStateChange) { this.onStateChange(); } this.canvasInstance.render(); - log.info(`MaskTool added SAM mask overlay at position (${destX}, ${destY}) relative to bounds (${bounds.x}, ${bounds.y}) without clearing existing mask.`); + log.info(`MaskTool added SAM mask to chunks covering bounds (${bounds.x}, ${bounds.y}) to (${maskRight}, ${maskBottom})`); + } + /** + * 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) { + 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 + ); + // 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})`); } applyShapeMask(saveState = true) { if (!this.canvasInstance.outputAreaShape?.points || this.canvasInstance.outputAreaShape.points.length < 3) { diff --git a/src/CanvasMask.ts b/src/CanvasMask.ts index 7057a5e..8c91002 100644 --- a/src/CanvasMask.ts +++ b/src/CanvasMask.ts @@ -532,26 +532,13 @@ export class CanvasMask { maskAsImage.src = tempCanvas.toDataURL(); await new Promise(resolve => maskAsImage.onload = resolve); - const maskCtx = this.maskTool.maskCtx; - - // Pozycja gdzie ma być aplikowana maska na canvas MaskTool - // MaskTool canvas ma pozycję (maskTool.x, maskTool.y) w świecie - // Maska z edytora reprezentuje output bounds, więc musimy ją umieścić - // w pozycji bounds względem pozycji MaskTool - const destX = bounds.x - this.maskTool.x; - const destY = bounds.y - this.maskTool.y; - - log.debug("Applying mask to canvas", { - maskToolPos: {x: this.maskTool.x, y: this.maskTool.y}, + log.debug("Applying mask using chunk system", { boundsPos: {x: bounds.x, y: bounds.y}, - destPos: {x: destX, y: destY}, maskSize: {width: bounds.width, height: bounds.height} }); - maskCtx.globalCompositeOperation = 'source-over'; - maskCtx.clearRect(destX, destY, bounds.width, bounds.height); - - maskCtx.drawImage(maskAsImage, destX, destY); + // Use the chunk system instead of direct canvas manipulation + this.maskTool.setMask(maskAsImage); this.canvas.render(); this.canvas.saveState(); diff --git a/src/MaskTool.ts b/src/MaskTool.ts index cf78b28..9b7f55a 100644 --- a/src/MaskTool.ts +++ b/src/MaskTool.ts @@ -8,6 +8,15 @@ interface MaskToolCallbacks { onStateChange?: () => void; } +interface MaskChunk { + canvas: HTMLCanvasElement; + ctx: CanvasRenderingContext2D; + x: number; // World coordinates of chunk + y: number; // World coordinates of chunk + isDirty: boolean; // Has been modified + isEmpty: boolean; // Contains no mask data +} + export class MaskTool { private brushHardness: number; private brushSize: number; @@ -18,15 +27,21 @@ export class MaskTool { public isOverlayVisible: boolean; private lastPosition: Point | null; private mainCanvas: HTMLCanvasElement; - private maskCanvas: HTMLCanvasElement; - private maskCtx: CanvasRenderingContext2D; + + // Chunked mask system + private maskChunks: Map; // Key: "x,y" (chunk coordinates) + private chunkSize: number; + private activeMaskCanvas: HTMLCanvasElement; // Composite of active chunks + private activeMaskCtx: CanvasRenderingContext2D; + private activeChunkBounds: { minX: number, minY: number, maxX: number, maxY: number } | null; + private onStateChange: (() => void) | null; private previewCanvas: HTMLCanvasElement; private previewCanvasInitialized: boolean; private previewCtx: CanvasRenderingContext2D; private previewVisible: boolean; - public x: number; - public y: number; + public x: number; // Active mask canvas position + public y: number; // Active mask canvas position // Shape mask preview system private shapePreviewCanvas: HTMLCanvasElement; @@ -38,12 +53,19 @@ export class MaskTool { this.canvasInstance = canvasInstance; this.mainCanvas = canvasInstance.canvas; this.onStateChange = callbacks.onStateChange || null; - this.maskCanvas = document.createElement('canvas'); - const maskCtx = this.maskCanvas.getContext('2d', { willReadFrequently: true }); - if (!maskCtx) { - throw new Error("Failed to get 2D context for mask canvas"); + + // Initialize chunked mask system + this.maskChunks = new Map(); + this.chunkSize = 512; + this.activeChunkBounds = null; + + // Create active mask canvas (composite of chunks) + this.activeMaskCanvas = document.createElement('canvas'); + const activeMaskCtx = this.activeMaskCanvas.getContext('2d', { willReadFrequently: true }); + if (!activeMaskCtx) { + throw new Error("Failed to get 2D context for active mask canvas"); } - this.maskCtx = maskCtx; + this.activeMaskCtx = activeMaskCtx; this.x = 0; this.y = 0; @@ -78,6 +100,15 @@ export class MaskTool { this.initMaskCanvas(); } + // Temporary compatibility getters - will be replaced with chunked system + get maskCanvas(): HTMLCanvasElement { + return this.activeMaskCanvas; + } + + get maskCtx(): CanvasRenderingContext2D { + return this.activeMaskCtx; + } + initPreviewCanvas(): void { if (this.previewCanvas.parentElement) { this.previewCanvas.parentElement.removeChild(this.previewCanvas); @@ -99,22 +130,140 @@ export class MaskTool { } initMaskCanvas(): void { - const extraSpace = 2000; // Allow for a generous drawing area outside the output area - const bounds = this.canvasInstance.outputAreaBounds; + // Initialize chunked system + this.chunkSize = 512; + this.maskChunks = new Map(); - // Mask canvas should cover output area + extra space around it - const maskLeft = bounds.x - extraSpace / 2; - const maskTop = bounds.y - extraSpace / 2; - const maskWidth = bounds.width + extraSpace; - const maskHeight = bounds.height + extraSpace; + // Create initial active mask canvas + this.updateActiveMaskCanvas(); - this.maskCanvas.width = maskWidth; - this.maskCanvas.height = maskHeight; - this.x = maskLeft; - this.y = maskTop; + log.info(`Initialized chunked mask system with chunk size: ${this.chunkSize}x${this.chunkSize}`); + } - this.maskCtx.clearRect(0, 0, this.maskCanvas.width, this.maskCanvas.height); - log.info(`Initialized mask canvas with size: ${this.maskCanvas.width}x${this.maskCanvas.height}, positioned at (${this.x}, ${this.y}) to cover output area at (${bounds.x}, ${bounds.y})`); + /** + * Updates the active mask canvas to show ALL chunks with mask data + * No longer limited to output area - shows all drawn masks everywhere + */ + private updateActiveMaskCanvas(): void { + // Find bounds of all non-empty chunks + const chunkBounds = this.getAllChunkBounds(); + + if (!chunkBounds) { + // No chunks with data - create minimal canvas + this.activeMaskCanvas.width = 1; + this.activeMaskCanvas.height = 1; + this.x = 0; + this.y = 0; + this.activeChunkBounds = null; + log.info("No mask chunks found - created minimal active canvas"); + return; + } + + // Calculate canvas size to cover all chunks + const canvasLeft = chunkBounds.minX * this.chunkSize; + const canvasTop = chunkBounds.minY * this.chunkSize; + const canvasWidth = (chunkBounds.maxX - chunkBounds.minX + 1) * this.chunkSize; + const canvasHeight = (chunkBounds.maxY - chunkBounds.minY + 1) * this.chunkSize; + + // Update active mask canvas size and position + this.activeMaskCanvas.width = canvasWidth; + this.activeMaskCanvas.height = canvasHeight; + this.x = canvasLeft; + this.y = canvasTop; + + // Clear active canvas + this.activeMaskCtx.clearRect(0, 0, canvasWidth, canvasHeight); + + this.activeChunkBounds = chunkBounds; + + // Composite ALL chunks with data onto active canvas + for (let chunkY = chunkBounds.minY; chunkY <= chunkBounds.maxY; chunkY++) { + for (let chunkX = chunkBounds.minX; chunkX <= chunkBounds.maxX; chunkX++) { + const chunkKey = `${chunkX},${chunkY}`; + const chunk = this.maskChunks.get(chunkKey); + + if (chunk && !chunk.isEmpty) { + // Calculate position on active canvas + const destX = (chunkX - chunkBounds.minX) * this.chunkSize; + const destY = (chunkY - chunkBounds.minY) * this.chunkSize; + + this.activeMaskCtx.drawImage(chunk.canvas, destX, destY); + } + } + } + + log.info(`Updated active mask canvas to show ALL chunks: ${canvasWidth}x${canvasHeight} at (${canvasLeft}, ${canvasTop}), chunks: ${chunkBounds.minX},${chunkBounds.minY} to ${chunkBounds.maxX},${chunkBounds.maxY}`); + } + + /** + * Finds the bounds of all chunks that contain mask data + * Returns null if no chunks have data + */ + private getAllChunkBounds(): { minX: number, minY: number, maxX: number, maxY: number } | null { + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + let hasData = false; + + for (const [chunkKey, chunk] of this.maskChunks) { + if (!chunk.isEmpty) { + const [chunkXStr, chunkYStr] = chunkKey.split(','); + const chunkX = parseInt(chunkXStr); + const chunkY = parseInt(chunkYStr); + + minX = Math.min(minX, chunkX); + minY = Math.min(minY, chunkY); + maxX = Math.max(maxX, chunkX); + maxY = Math.max(maxY, chunkY); + hasData = true; + } + } + + return hasData ? { minX, minY, maxX, maxY } : null; + } + + /** + * Gets or creates a chunk for the given world coordinates + */ + private getChunkForPosition(worldX: number, worldY: number): MaskChunk { + const chunkX = Math.floor(worldX / this.chunkSize); + const chunkY = Math.floor(worldY / this.chunkSize); + const chunkKey = `${chunkX},${chunkY}`; + + let chunk = this.maskChunks.get(chunkKey); + if (!chunk) { + chunk = this.createChunk(chunkX, chunkY); + this.maskChunks.set(chunkKey, chunk); + } + + return chunk; + } + + /** + * Creates a new chunk at the given chunk coordinates + */ + private createChunk(chunkX: number, chunkY: number): MaskChunk { + const canvas = document.createElement('canvas'); + canvas.width = this.chunkSize; + canvas.height = this.chunkSize; + + const ctx = canvas.getContext('2d', { willReadFrequently: true }); + if (!ctx) { + throw new Error("Failed to get 2D context for chunk canvas"); + } + + const chunk: MaskChunk = { + canvas, + ctx, + x: chunkX * this.chunkSize, + y: chunkY * this.chunkSize, + isDirty: false, + isEmpty: true + }; + + log.debug(`Created chunk at (${chunkX}, ${chunkY}) covering world area (${chunk.x}, ${chunk.y}) to (${chunk.x + this.chunkSize}, ${chunk.y + this.chunkSize})`); + return chunk; } activate(): void { @@ -194,46 +343,183 @@ export class MaskTool { this.lastPosition = worldCoords; } + // Draw on chunks instead of single canvas + this.drawOnChunks(this.lastPosition, worldCoords); + + // Only update active canvas if we drew on chunks that are currently visible + // This prevents unnecessary recomposition during drawing + this.updateActiveCanvasIfNeeded(this.lastPosition, worldCoords); + } - const canvasLastX = this.lastPosition.x - this.x; - const canvasLastY = this.lastPosition.y - this.y; - const canvasX = worldCoords.x - this.x; - const canvasY = worldCoords.y - this.y; - - - const canvasWidth = this.maskCanvas.width; - const canvasHeight = this.maskCanvas.height; - - if (canvasX >= 0 && canvasX < canvasWidth && - canvasY >= 0 && canvasY < canvasHeight && - canvasLastX >= 0 && canvasLastX < canvasWidth && - canvasLastY >= 0 && canvasLastY < canvasHeight) { - - this.maskCtx.beginPath(); - this.maskCtx.moveTo(canvasLastX, canvasLastY); - this.maskCtx.lineTo(canvasX, canvasY); - const gradientRadius = this.brushSize / 2; - - if (this.brushHardness === 1) { - this.maskCtx.strokeStyle = `rgba(255, 255, 255, ${this.brushStrength})`; - } else { - const innerRadius = gradientRadius * this.brushHardness; - const gradient = this.maskCtx.createRadialGradient( - canvasX, canvasY, innerRadius, - canvasX, canvasY, gradientRadius - ); - gradient.addColorStop(0, `rgba(255, 255, 255, ${this.brushStrength})`); - gradient.addColorStop(1, `rgba(255, 255, 255, 0)`); - this.maskCtx.strokeStyle = gradient; + /** + * Draws a line between two world coordinates on the appropriate chunks + */ + private drawOnChunks(startWorld: Point, endWorld: Point): void { + // Calculate all chunks that this line might touch + const minX = Math.min(startWorld.x, endWorld.x) - this.brushSize; + const maxX = Math.max(startWorld.x, endWorld.x) + this.brushSize; + const minY = Math.min(startWorld.y, endWorld.y) - this.brushSize; + const maxY = Math.max(startWorld.y, endWorld.y) + this.brushSize; + + const chunkMinX = Math.floor(minX / this.chunkSize); + const chunkMinY = Math.floor(minY / this.chunkSize); + const chunkMaxX = Math.floor(maxX / this.chunkSize); + const chunkMaxY = Math.floor(maxY / this.chunkSize); + + // Draw on all affected chunks + for (let chunkY = chunkMinY; chunkY <= chunkMaxY; chunkY++) { + for (let chunkX = chunkMinX; chunkX <= chunkMaxX; chunkX++) { + const chunk = this.getChunkForPosition(chunkX * this.chunkSize, chunkY * this.chunkSize); + this.drawLineOnChunk(chunk, startWorld, endWorld); } + } + } - this.maskCtx.lineWidth = this.brushSize; - this.maskCtx.lineCap = 'round'; - this.maskCtx.lineJoin = 'round'; - this.maskCtx.globalCompositeOperation = 'source-over'; - this.maskCtx.stroke(); + /** + * Draws a line on a specific chunk + */ + private drawLineOnChunk(chunk: MaskChunk, startWorld: Point, endWorld: Point): void { + // Convert world coordinates to chunk-local coordinates + const startLocal = { + x: startWorld.x - chunk.x, + y: startWorld.y - chunk.y + }; + const endLocal = { + x: endWorld.x - chunk.x, + y: endWorld.y - chunk.y + }; + + // Check if the line intersects this chunk + if (!this.lineIntersectsChunk(startLocal, endLocal, this.chunkSize)) { + return; + } + + // Draw the line on this chunk + chunk.ctx.beginPath(); + chunk.ctx.moveTo(startLocal.x, startLocal.y); + chunk.ctx.lineTo(endLocal.x, endLocal.y); + + const gradientRadius = this.brushSize / 2; + + if (this.brushHardness === 1) { + chunk.ctx.strokeStyle = `rgba(255, 255, 255, ${this.brushStrength})`; } else { - log.debug(`Drawing outside mask canvas bounds: (${canvasX}, ${canvasY})`); + const innerRadius = gradientRadius * this.brushHardness; + const gradient = chunk.ctx.createRadialGradient( + endLocal.x, endLocal.y, innerRadius, + endLocal.x, endLocal.y, gradientRadius + ); + gradient.addColorStop(0, `rgba(255, 255, 255, ${this.brushStrength})`); + gradient.addColorStop(1, `rgba(255, 255, 255, 0)`); + chunk.ctx.strokeStyle = gradient; + } + + chunk.ctx.lineWidth = this.brushSize; + chunk.ctx.lineCap = 'round'; + chunk.ctx.lineJoin = 'round'; + chunk.ctx.globalCompositeOperation = 'source-over'; + chunk.ctx.stroke(); + + // Mark chunk as dirty and not empty + chunk.isDirty = true; + chunk.isEmpty = false; + + log.debug(`Drew on chunk (${Math.floor(chunk.x / this.chunkSize)}, ${Math.floor(chunk.y / this.chunkSize)})`); + } + + /** + * Checks if a line intersects with a chunk bounds + */ + private lineIntersectsChunk(startLocal: Point, endLocal: Point, chunkSize: number): boolean { + // Expand bounds by brush size to catch partial intersections + const margin = this.brushSize / 2; + const left = -margin; + const top = -margin; + const right = chunkSize + margin; + const bottom = chunkSize + margin; + + // Check if either point is inside the expanded bounds + if ((startLocal.x >= left && startLocal.x <= right && startLocal.y >= top && startLocal.y <= bottom) || + (endLocal.x >= left && endLocal.x <= right && endLocal.y >= top && endLocal.y <= bottom)) { + return true; + } + + // Check if line crosses chunk bounds (simplified check) + return true; // For now, always draw - more precise intersection can be added later + } + + /** + * Updates active canvas when drawing affects chunks + * Now always updates when new chunks are created to ensure immediate visibility + */ + private updateActiveCanvasIfNeeded(startWorld: Point, endWorld: Point): void { + // Calculate which chunks were affected by this drawing operation + const minX = Math.min(startWorld.x, endWorld.x) - this.brushSize; + const maxX = Math.max(startWorld.x, endWorld.x) + this.brushSize; + const minY = Math.min(startWorld.y, endWorld.y) - this.brushSize; + const maxY = Math.max(startWorld.y, endWorld.y) + this.brushSize; + + const affectedChunkMinX = Math.floor(minX / this.chunkSize); + const affectedChunkMinY = Math.floor(minY / this.chunkSize); + const affectedChunkMaxX = Math.floor(maxX / this.chunkSize); + const affectedChunkMaxY = Math.floor(maxY / this.chunkSize); + + // Check if we drew on any new chunks (outside current active bounds) + let drewOnNewChunks = false; + if (!this.activeChunkBounds) { + drewOnNewChunks = true; + } else { + drewOnNewChunks = + affectedChunkMinX < this.activeChunkBounds.minX || + affectedChunkMaxX > this.activeChunkBounds.maxX || + affectedChunkMinY < this.activeChunkBounds.minY || + affectedChunkMaxY > this.activeChunkBounds.maxY; + } + + if (drewOnNewChunks) { + // Drawing extended beyond current active bounds - do full update to include new chunks + this.updateActiveMaskCanvas(); + log.debug("Drew on new chunks - performed full active canvas update"); + } else { + // Drawing within existing bounds - do partial update for performance + this.updateActiveCanvasPartial(affectedChunkMinX, affectedChunkMinY, affectedChunkMaxX, affectedChunkMaxY); + log.debug("Drew within existing bounds - performed partial update"); + } + } + + /** + * Partially updates the active canvas by redrawing only specific chunks + * Much faster than full recomposition during drawing + * Now works with the new system that shows ALL chunks + */ + private updateActiveCanvasPartial(chunkMinX: number, chunkMinY: number, chunkMaxX: number, chunkMaxY: number): void { + if (!this.activeChunkBounds) { + // No active bounds - do full update + this.updateActiveMaskCanvas(); + return; + } + + // Only redraw the affected chunks that are within the current active canvas bounds + for (let chunkY = chunkMinY; chunkY <= chunkMaxY; chunkY++) { + for (let chunkX = chunkMinX; chunkX <= chunkMaxX; chunkX++) { + // Check if this chunk is within active bounds (all chunks with data) + if (chunkX >= this.activeChunkBounds.minX && chunkX <= this.activeChunkBounds.maxX && + chunkY >= this.activeChunkBounds.minY && chunkY <= this.activeChunkBounds.maxY) { + + const chunkKey = `${chunkX},${chunkY}`; + const chunk = this.maskChunks.get(chunkKey); + + if (chunk && !chunk.isEmpty) { + // Calculate position on active canvas (relative to all chunks bounds) + const destX = (chunkX - this.activeChunkBounds.minX) * this.chunkSize; + const destY = (chunkY - this.activeChunkBounds.minY) * this.chunkSize; + + // Clear the area first, then redraw + this.activeMaskCtx.clearRect(destX, destY, this.chunkSize, this.chunkSize); + this.activeMaskCtx.drawImage(chunk.canvas, destX, destY); + } + } + } } } @@ -636,7 +922,10 @@ export class MaskTool { } getMask(): HTMLCanvasElement { - return this.maskCanvas; + // Always return the current active mask canvas which shows all chunks + // Make sure it's up to date before returning + this.updateActiveMaskCanvas(); + return this.activeMaskCanvas; } getMaskImageWithAlpha(): HTMLImageElement { @@ -674,31 +963,31 @@ export class MaskTool { const isIncreasingWidth = width > this.canvasInstance.width; const isIncreasingHeight = height > this.canvasInstance.height; - this.maskCanvas = document.createElement('canvas'); + this.activeMaskCanvas = document.createElement('canvas'); const extraSpace = 2000; const newWidth = isIncreasingWidth ? width + extraSpace : Math.max(oldWidth, width + extraSpace); const newHeight = isIncreasingHeight ? height + extraSpace : Math.max(oldHeight, height + extraSpace); - this.maskCanvas.width = newWidth; - this.maskCanvas.height = newHeight; - const newMaskCtx = this.maskCanvas.getContext('2d', { willReadFrequently: true }); + this.activeMaskCanvas.width = newWidth; + this.activeMaskCanvas.height = newHeight; + const newMaskCtx = this.activeMaskCanvas.getContext('2d', { willReadFrequently: true }); if (!newMaskCtx) { throw new Error("Failed to get 2D context for new mask canvas"); } - this.maskCtx = newMaskCtx; + this.activeMaskCtx = newMaskCtx; if (oldMask.width > 0 && oldMask.height > 0) { const offsetX = this.x - oldX; const offsetY = this.y - oldY; - this.maskCtx.drawImage(oldMask, offsetX, offsetY); + this.activeMaskCtx.drawImage(oldMask, offsetX, offsetY); log.debug(`Preserved mask content with offset (${offsetX}, ${offsetY})`); } - log.info(`Mask canvas resized to ${this.maskCanvas.width}x${this.maskCanvas.height}, position (${this.x}, ${this.y})`); + log.info(`Mask canvas resized to ${this.activeMaskCanvas.width}x${this.activeMaskCanvas.height}, position (${this.x}, ${this.y})`); log.info(`Canvas size change: width ${isIncreasingWidth ? 'increased' : 'decreased'}, height ${isIncreasingHeight ? 'increased' : 'decreased'}`); } @@ -711,60 +1000,16 @@ export class MaskTool { /** * Updates mask canvas to ensure it covers the current output area * This should be called when output area position or size changes + * Now uses chunked system - just updates the active mask canvas */ updateMaskCanvasForOutputArea(): void { - const extraSpace = 2000; - const bounds = this.canvasInstance.outputAreaBounds; + log.info(`Updating chunked mask system for output area at (${this.canvasInstance.outputAreaBounds.x}, ${this.canvasInstance.outputAreaBounds.y})`); - // Calculate required mask canvas bounds - const requiredLeft = bounds.x - extraSpace / 2; - const requiredTop = bounds.y - extraSpace / 2; - const requiredWidth = bounds.width + extraSpace; - const requiredHeight = bounds.height + extraSpace; + // Simply update the active mask canvas to cover the new output area + // All existing chunks are preserved in the maskChunks Map + this.updateActiveMaskCanvas(); - // Check if current mask canvas covers the required area - const currentRight = this.x + this.maskCanvas.width; - const currentBottom = this.y + this.maskCanvas.height; - const requiredRight = requiredLeft + requiredWidth; - const requiredBottom = requiredTop + requiredHeight; - - const needsResize = - requiredLeft < this.x || - requiredTop < this.y || - requiredRight > currentRight || - requiredBottom > currentBottom; - - if (needsResize) { - log.info(`Updating mask canvas to cover output area at (${bounds.x}, ${bounds.y})`); - - // Save current mask content - const oldMask = this.maskCanvas; - const oldX = this.x; - const oldY = this.y; - - // Create new mask canvas with proper size and position - this.maskCanvas = document.createElement('canvas'); - this.maskCanvas.width = requiredWidth; - this.maskCanvas.height = requiredHeight; - this.x = requiredLeft; - this.y = requiredTop; - - const newMaskCtx = this.maskCanvas.getContext('2d', { willReadFrequently: true }); - if (!newMaskCtx) { - throw new Error("Failed to get 2D context for new mask canvas"); - } - this.maskCtx = newMaskCtx; - - // Copy old mask content to new position - if (oldMask.width > 0 && oldMask.height > 0) { - const offsetX = oldX - this.x; - const offsetY = oldY - this.y; - this.maskCtx.drawImage(oldMask, offsetX, offsetY); - log.debug(`Preserved mask content with offset (${offsetX}, ${offsetY})`); - } - - log.info(`Mask canvas updated to ${this.maskCanvas.width}x${this.maskCanvas.height} at (${this.x}, ${this.y})`); - } + log.info(`Chunked mask system updated - ${this.maskChunks.size} chunks preserved`); } toggleOverlayVisibility(): void { @@ -773,43 +1018,172 @@ export class MaskTool { } setMask(image: HTMLImageElement): void { - // Pozycja gdzie ma być aplikowana maska na canvas MaskTool - // MaskTool canvas ma pozycję (this.x, this.y) w świecie - // Maska reprezentuje output bounds, więc musimy ją umieścić - // w pozycji bounds względem pozycji MaskTool + // Clear existing mask chunks in the output area first const bounds = this.canvasInstance.outputAreaBounds; - const destX = bounds.x - this.x; - const destY = bounds.y - this.y; + this.clearMaskInArea(bounds.x, bounds.y, image.width, image.height); + + // Add the new mask using the chunk system + this.addMask(image); + + log.info(`MaskTool set new mask using chunk system at bounds (${bounds.x}, ${bounds.y})`); + } - this.maskCtx.clearRect(destX, destY, this.canvasInstance.width, this.canvasInstance.height); - - this.maskCtx.drawImage(image, destX, destY); - - if (this.onStateChange) { - this.onStateChange(); + /** + * Clears mask data in a specific area by clearing affected chunks + */ + private clearMaskInArea(x: number, y: number, width: number, height: number): void { + const chunkMinX = Math.floor(x / this.chunkSize); + const chunkMinY = Math.floor(y / this.chunkSize); + const chunkMaxX = Math.floor((x + width) / this.chunkSize); + const chunkMaxY = Math.floor((y + height) / this.chunkSize); + + // Clear all affected chunks + for (let chunkY = chunkMinY; chunkY <= chunkMaxY; chunkY++) { + for (let chunkX = chunkMinX; chunkX <= chunkMaxX; chunkX++) { + const chunkKey = `${chunkX},${chunkY}`; + const chunk = this.maskChunks.get(chunkKey); + + if (chunk && !chunk.isEmpty) { + this.clearMaskFromChunk(chunk, x, y, width, height); + } + } } - this.canvasInstance.render(); - log.info(`MaskTool updated with a new mask image at position (${destX}, ${destY}) relative to bounds (${bounds.x}, ${bounds.y}).`); + } + + /** + * 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) { + 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})`); } addMask(image: HTMLImageElement): void { - // Pozycja gdzie ma być aplikowana maska na canvas MaskTool - // MaskTool canvas ma pozycję (this.x, this.y) w świecie - // Maska z SAM reprezentuje output bounds, więc musimy ją umieścić - // w pozycji bounds względem pozycji MaskTool + // Add mask to chunks system instead of directly to active canvas const bounds = this.canvasInstance.outputAreaBounds; - const destX = bounds.x - this.x; - const destY = bounds.y - this.y; - - // Don't clear existing mask - just add to it - this.maskCtx.globalCompositeOperation = 'source-over'; - this.maskCtx.drawImage(image, destX, destY); + + // Calculate which chunks this mask will affect + const maskLeft = bounds.x; + const maskTop = bounds.y; + const maskRight = bounds.x + image.width; + const maskBottom = bounds.y + image.height; + + const 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); + + // Add 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.addMaskToChunk(chunk, image, bounds); + } + } + + // Update active canvas to show the new mask + this.updateActiveMaskCanvas(); if (this.onStateChange) { this.onStateChange(); } this.canvasInstance.render(); - log.info(`MaskTool added SAM mask overlay at position (${destX}, ${destY}) relative to bounds (${bounds.x}, ${bounds.y}) without clearing existing mask.`); + log.info(`MaskTool added SAM mask to chunks covering bounds (${bounds.x}, ${bounds.y}) to (${maskRight}, ${maskBottom})`); + } + + /** + * 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) { + 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 + ); + + // 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})`); } applyShapeMask(saveState: boolean = true): void {