From 03c841380e4f628a704d1ed8a32750aa5b13e539 Mon Sep 17 00:00:00 2001 From: Dariusz L Date: Sun, 27 Jul 2025 01:11:31 +0200 Subject: [PATCH] Optimize mask chunk activation and canvas updates Introduces active chunk management for mask drawing, activating only nearby chunks during drawing for performance. Updates the active mask canvas to show all chunks but optimizes updates to redraw only active chunks during drawing, reducing lag. Adds LRU-style tracking and safety limits for active chunks, and improves chunk activation logic for both drawing and mask application. --- js/MaskTool.js | 243 ++++++++++++++++++++++++++++++---------- src/MaskTool.ts | 287 ++++++++++++++++++++++++++++++++++++++---------- 2 files changed, 413 insertions(+), 117 deletions(-) diff --git a/js/MaskTool.js b/js/MaskTool.js index 8a260df..4c92717 100644 --- a/js/MaskTool.js +++ b/js/MaskTool.js @@ -10,6 +10,10 @@ export class MaskTool { this.maskChunks = new Map(); this.chunkSize = 512; this.activeChunkBounds = null; + // Initialize active chunk management + this.activeChunkRadius = 1; // 3x3 grid of active chunks (radius 1 = 9 chunks total) + this.currentDrawingChunk = null; + this.maxActiveChunks = 25; // Safety limit to prevent memory issues (5x5 grid max) // Create active mask canvas (composite of chunks) this.activeMaskCanvas = document.createElement('canvas'); const activeMaskCtx = this.activeMaskCanvas.getContext('2d', { willReadFrequently: true }); @@ -82,11 +86,11 @@ export class MaskTool { 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 + * Updates the active mask canvas to show ALL chunks but optimize updates during drawing + * Always shows all chunks, but during drawing only updates the active chunks for performance */ - updateActiveMaskCanvas() { - // Find bounds of all non-empty chunks + updateActiveMaskCanvas(forceFullUpdate = false) { + // Always show all chunks - find bounds of all non-empty chunks const chunkBounds = this.getAllChunkBounds(); if (!chunkBounds) { // No chunks with data - create minimal canvas @@ -95,34 +99,44 @@ export class MaskTool { this.x = 0; this.y = 0; this.activeChunkBounds = null; - log.info("No mask chunks found - created minimal active canvas"); + log.debug("No mask chunks found - created minimal active canvas"); return; } - // Calculate canvas size to cover all chunks + // 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); + // Update active mask canvas size and position if needed + if (this.activeMaskCanvas.width !== canvasWidth || + this.activeMaskCanvas.height !== canvasHeight || + this.x !== canvasLeft || + this.y !== canvasTop || + forceFullUpdate) { + this.activeMaskCanvas.width = canvasWidth; + this.activeMaskCanvas.height = canvasHeight; + this.x = canvasLeft; + this.y = canvasTop; + this.activeChunkBounds = chunkBounds; + // Full redraw when canvas size changes + this.activeMaskCtx.clearRect(0, 0, canvasWidth, canvasHeight); + // Draw ALL chunks + for (let chunkY = chunkBounds.minY; chunkY <= chunkBounds.maxY; chunkY++) { + for (let chunkX = chunkBounds.minX; chunkX <= chunkBounds.maxX; chunkX++) { + const chunkKey = `${chunkX},${chunkY}`; + const chunk = this.maskChunks.get(chunkKey); + if (chunk && !chunk.isEmpty) { + const destX = (chunkX - chunkBounds.minX) * this.chunkSize; + const destY = (chunkY - chunkBounds.minY) * this.chunkSize; + this.activeMaskCtx.drawImage(chunk.canvas, destX, destY); + } } } + log.debug(`Full update: rendered ${this.getAllNonEmptyChunkCount()} chunks`); + } + else { + // Canvas size unchanged - this is handled by partial updates during drawing + this.activeChunkBounds = chunkBounds; } } /** @@ -149,6 +163,86 @@ export class MaskTool { } return hasData ? { minX, minY, maxX, maxY } : null; } + /** + * Finds the bounds of only active chunks that contain mask data + * Returns null if no active chunks have data + */ + getActiveChunkBounds() { + 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 && chunk.isActive) { + 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; + } + /** + * Counts all non-empty chunks + */ + getAllNonEmptyChunkCount() { + let count = 0; + for (const chunk of this.maskChunks.values()) { + if (!chunk.isEmpty) + count++; + } + return count; + } + /** + * Counts active non-empty chunks + */ + getActiveChunkCount() { + let count = 0; + for (const chunk of this.maskChunks.values()) { + if (!chunk.isEmpty && chunk.isActive) + count++; + } + return count; + } + /** + * Updates which chunks are active for drawing operations based on current drawing position + * Only activates chunks in a radius around the drawing position for performance + */ + updateActiveChunksForDrawing(worldCoords) { + const currentChunkX = Math.floor(worldCoords.x / this.chunkSize); + const currentChunkY = Math.floor(worldCoords.y / this.chunkSize); + // Update current drawing chunk + this.currentDrawingChunk = { x: currentChunkX, y: currentChunkY }; + // Deactivate all chunks first + for (const chunk of this.maskChunks.values()) { + chunk.isActive = false; + } + // Activate chunks in radius around current drawing position + let activatedCount = 0; + for (let dy = -this.activeChunkRadius; dy <= this.activeChunkRadius; dy++) { + for (let dx = -this.activeChunkRadius; dx <= this.activeChunkRadius; dx++) { + const chunkX = currentChunkX + dx; + const chunkY = currentChunkY + dy; + const chunkKey = `${chunkX},${chunkY}`; + // Get or create chunk if it doesn't exist + const chunk = this.getChunkForPosition(chunkX * this.chunkSize, chunkY * this.chunkSize); + chunk.isActive = true; + chunk.lastAccessTime = Date.now(); + activatedCount++; + // Safety check to prevent too many active chunks + if (activatedCount >= this.maxActiveChunks) { + log.warn(`Reached maximum active chunks limit (${this.maxActiveChunks})`); + return; + } + } + } + log.debug(`Activated ${activatedCount} chunks around drawing position (${currentChunkX}, ${currentChunkY})`); + } /** * Gets or creates a chunk for the given world coordinates */ @@ -180,7 +274,9 @@ export class MaskTool { x: chunkX * this.chunkSize, y: chunkY * this.chunkSize, isDirty: false, - isEmpty: true + isEmpty: true, + isActive: false, + lastAccessTime: Date.now() }; log.debug(`Created chunk at (${chunkX}, ${chunkY}) covering world area (${chunk.x}, ${chunk.y}) to (${chunk.x + this.chunkSize}, ${chunk.y + this.chunkSize})`); return chunk; @@ -217,6 +313,8 @@ export class MaskTool { return; this.isDrawing = true; this.lastPosition = worldCoords; + // Activate chunks around the drawing position for performance + this.updateActiveChunksForDrawing(worldCoords); this.draw(worldCoords); this.clearPreview(); } @@ -226,6 +324,8 @@ export class MaskTool { } if (!this.isActive || !this.isDrawing) return; + // Dynamically update active chunks as user moves while drawing + this.updateActiveChunksForDrawing(worldCoords); this.draw(worldCoords); this.lastPosition = worldCoords; } @@ -242,6 +342,9 @@ export class MaskTool { if (this.isDrawing) { this.isDrawing = false; this.lastPosition = null; + this.currentDrawingChunk = null; + // After drawing is complete, update active canvas to show all chunks + this.updateActiveMaskCanvas(true); // forceShowAll = true this.canvasInstance.canvasState.saveMaskState(); if (this.onStateChange) { this.onStateChange(); @@ -342,7 +445,7 @@ export class MaskTool { } /** * Updates active canvas when drawing affects chunks with throttling to prevent lag - * Uses throttling to limit updates to ~60fps during drawing operations + * During drawing, only updates the affected active chunks for performance */ updateActiveCanvasIfNeeded(startWorld, endWorld) { // Calculate which chunks were affected by this drawing operation @@ -354,27 +457,15 @@ export class MaskTool { 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 - immediate update required - this.updateActiveMaskCanvas(); - log.debug("Drew on new chunks - performed immediate full active canvas update"); - } - else { - // Drawing within existing bounds - use throttled update for performance + // During drawing, only update affected chunks that are active for performance + if (this.isDrawing) { + // Use throttled partial update for active chunks only this.scheduleThrottledActiveMaskUpdate(affectedChunkMinX, affectedChunkMinY, affectedChunkMaxX, affectedChunkMaxY); } + else { + // Not drawing - do full update to show all chunks + this.updateActiveMaskCanvas(true); + } } /** * Schedules a throttled update of the active mask canvas to prevent excessive redraws @@ -399,31 +490,51 @@ export class MaskTool { }, this.ACTIVE_MASK_UPDATE_DELAY); } /** - * 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 + * Partially updates the active canvas by redrawing only specific chunks that are active + * During drawing, only updates active chunks for performance + * Now handles dynamic chunk activation by expanding canvas if needed */ updateActiveCanvasPartial(chunkMinX, chunkMinY, chunkMaxX, chunkMaxY) { + // Check if any active chunks are outside current canvas bounds + const activeChunkBounds = this.getActiveChunkBounds(); + const allChunkBounds = this.getAllChunkBounds(); + if (!allChunkBounds) { + return; // No chunks at all + } + // If active chunks extend beyond current canvas, do full update to resize canvas + if (activeChunkBounds && this.activeChunkBounds && + (activeChunkBounds.minX < this.activeChunkBounds.minX || + activeChunkBounds.maxX > this.activeChunkBounds.maxX || + activeChunkBounds.minY < this.activeChunkBounds.minY || + activeChunkBounds.maxY > this.activeChunkBounds.maxY)) { + log.debug("Active chunks extended beyond canvas bounds - performing full update"); + this.updateActiveMaskCanvas(true); + return; + } if (!this.activeChunkBounds) { // No active bounds - do full update this.updateActiveMaskCanvas(); return; } - // Only redraw the affected chunks that are within the current active canvas bounds + // Only redraw the affected chunks that are active and within the current active canvas bounds for (let chunkY = chunkMinY; chunkY <= chunkMaxY; chunkY++) { for (let chunkX = chunkMinX; chunkX <= chunkMaxX; chunkX++) { - // Check if this chunk is within active bounds (all chunks with data) + // Check if this chunk is within canvas bounds (all chunks with data) if (chunkX >= this.activeChunkBounds.minX && chunkX <= this.activeChunkBounds.maxX && chunkY >= this.activeChunkBounds.minY && chunkY <= this.activeChunkBounds.maxY) { const chunkKey = `${chunkX},${chunkY}`; const chunk = this.maskChunks.get(chunkKey); - if (chunk && !chunk.isEmpty) { + // Update if chunk exists and is currently active (regardless of isEmpty for new chunks) + if (chunk && chunk.isActive) { // Calculate position on active canvas (relative to all chunks bounds) const destX = (chunkX - this.activeChunkBounds.minX) * this.chunkSize; const destY = (chunkY - this.activeChunkBounds.minY) * this.chunkSize; // Clear the area first, then redraw this.activeMaskCtx.clearRect(destX, destY, this.chunkSize, this.chunkSize); - this.activeMaskCtx.drawImage(chunk.canvas, destX, destY); + if (!chunk.isEmpty) { + this.activeMaskCtx.drawImage(chunk.canvas, destX, destY); + } + log.debug(`Partial update: refreshed active chunk (${chunkX}, ${chunkY}) - isEmpty: ${chunk.isEmpty}`); } } } @@ -947,20 +1058,40 @@ export class MaskTool { 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 + // First, deactivate all chunks + for (const chunk of this.maskChunks.values()) { + chunk.isActive = false; + } + // Add mask to all affected chunks and activate them so user can see the mask being applied for (let chunkY = chunkMinY; chunkY <= chunkMaxY; chunkY++) { for (let chunkX = chunkMinX; chunkX <= chunkMaxX; chunkX++) { const chunk = this.getChunkForPosition(chunkX * this.chunkSize, chunkY * this.chunkSize); this.addMaskToChunk(chunk, image, bounds); + // Activate this chunk so user can see the mask being applied + chunk.isActive = true; + chunk.lastAccessTime = Date.now(); } } - // Update active canvas to show the new mask - this.updateActiveMaskCanvas(); + // Also activate surrounding chunks for better visibility (3x3 grid around mask area) + const centerChunkX = Math.floor((maskLeft + maskRight) / 2 / this.chunkSize); + const centerChunkY = Math.floor((maskTop + maskBottom) / 2 / this.chunkSize); + for (let dy = -this.activeChunkRadius; dy <= this.activeChunkRadius; dy++) { + for (let dx = -this.activeChunkRadius; dx <= this.activeChunkRadius; dx++) { + const chunkX = centerChunkX + dx; + const chunkY = centerChunkY + dy; + const chunk = this.getChunkForPosition(chunkX * this.chunkSize, chunkY * this.chunkSize); + chunk.isActive = true; + chunk.lastAccessTime = Date.now(); + } + } + // Update active canvas to show the new mask with activated chunks + this.updateActiveMaskCanvas(true); // Force full update to show all chunks including newly activated ones if (this.onStateChange) { this.onStateChange(); } this.canvasInstance.render(); - log.info(`MaskTool added SAM mask to chunks covering bounds (${bounds.x}, ${bounds.y}) to (${maskRight}, ${maskBottom})`); + const activatedChunks = Array.from(this.maskChunks.values()).filter(chunk => chunk.isActive).length; + log.info(`MaskTool added SAM mask to chunks covering bounds (${bounds.x}, ${bounds.y}) to (${maskRight}, ${maskBottom}) and activated ${activatedChunks} chunks for visibility`); } /** * Adds a mask image to a specific chunk diff --git a/src/MaskTool.ts b/src/MaskTool.ts index 9ecb2e0..874d894 100644 --- a/src/MaskTool.ts +++ b/src/MaskTool.ts @@ -15,6 +15,8 @@ interface MaskChunk { y: number; // World coordinates of chunk isDirty: boolean; // Has been modified isEmpty: boolean; // Contains no mask data + isActive: boolean; // Is currently active for drawing operations + lastAccessTime: number; // For LRU cache management } export class MaskTool { @@ -31,10 +33,15 @@ export class MaskTool { // Chunked mask system private maskChunks: Map; // Key: "x,y" (chunk coordinates) private chunkSize: number; - private activeMaskCanvas: HTMLCanvasElement; // Composite of active chunks + private activeMaskCanvas: HTMLCanvasElement; // Composite of active chunks only private activeMaskCtx: CanvasRenderingContext2D; private activeChunkBounds: { minX: number, minY: number, maxX: number, maxY: number } | null; + // Active chunk management for performance + private activeChunkRadius: number; // Radius of active chunks around drawing position (in chunks) + private currentDrawingChunk: { x: number, y: number } | null; // Current chunk being drawn on + private maxActiveChunks: number; // Maximum number of active chunks to prevent memory issues + private onStateChange: (() => void) | null; private previewCanvas: HTMLCanvasElement; private previewCanvasInitialized: boolean; @@ -64,6 +71,11 @@ export class MaskTool { this.chunkSize = 512; this.activeChunkBounds = null; + // Initialize active chunk management + this.activeChunkRadius = 1; // 3x3 grid of active chunks (radius 1 = 9 chunks total) + this.currentDrawingChunk = null; + this.maxActiveChunks = 25; // Safety limit to prevent memory issues (5x5 grid max) + // Create active mask canvas (composite of chunks) this.activeMaskCanvas = document.createElement('canvas'); const activeMaskCtx = this.activeMaskCanvas.getContext('2d', { willReadFrequently: true }); @@ -150,11 +162,11 @@ export class MaskTool { } /** - * Updates the active mask canvas to show ALL chunks with mask data - * No longer limited to output area - shows all drawn masks everywhere + * Updates the active mask canvas to show ALL chunks but optimize updates during drawing + * Always shows all chunks, but during drawing only updates the active chunks for performance */ - private updateActiveMaskCanvas(): void { - // Find bounds of all non-empty chunks + private updateActiveMaskCanvas(forceFullUpdate: boolean = false): void { + // Always show all chunks - find bounds of all non-empty chunks const chunkBounds = this.getAllChunkBounds(); if (!chunkBounds) { @@ -164,42 +176,51 @@ export class MaskTool { this.x = 0; this.y = 0; this.activeChunkBounds = null; - log.info("No mask chunks found - created minimal active canvas"); + log.debug("No mask chunks found - created minimal active canvas"); return; } - // Calculate canvas size to cover all chunks + // 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; + // Update active mask canvas size and position if needed + if (this.activeMaskCanvas.width !== canvasWidth || + this.activeMaskCanvas.height !== canvasHeight || + this.x !== canvasLeft || + this.y !== canvasTop || + forceFullUpdate) { + + this.activeMaskCanvas.width = canvasWidth; + this.activeMaskCanvas.height = canvasHeight; + this.x = canvasLeft; + this.y = canvasTop; + this.activeChunkBounds = chunkBounds; + + // Full redraw when canvas size changes + this.activeMaskCtx.clearRect(0, 0, canvasWidth, canvasHeight); + + // Draw ALL chunks + for (let chunkY = chunkBounds.minY; chunkY <= chunkBounds.maxY; chunkY++) { + for (let chunkX = chunkBounds.minX; chunkX <= chunkBounds.maxX; chunkX++) { + const chunkKey = `${chunkX},${chunkY}`; + const chunk = this.maskChunks.get(chunkKey); - this.activeMaskCtx.drawImage(chunk.canvas, destX, destY); + if (chunk && !chunk.isEmpty) { + const destX = (chunkX - chunkBounds.minX) * this.chunkSize; + const destY = (chunkY - chunkBounds.minY) * this.chunkSize; + this.activeMaskCtx.drawImage(chunk.canvas, destX, destY); + } } } - } + + log.debug(`Full update: rendered ${this.getAllNonEmptyChunkCount()} chunks`); + } else { + // Canvas size unchanged - this is handled by partial updates during drawing + this.activeChunkBounds = chunkBounds; + } } /** @@ -230,6 +251,97 @@ export class MaskTool { return hasData ? { minX, minY, maxX, maxY } : null; } + /** + * Finds the bounds of only active chunks that contain mask data + * Returns null if no active chunks have data + */ + private getActiveChunkBounds(): { 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 && chunk.isActive) { + 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; + } + + /** + * Counts all non-empty chunks + */ + private getAllNonEmptyChunkCount(): number { + let count = 0; + for (const chunk of this.maskChunks.values()) { + if (!chunk.isEmpty) count++; + } + return count; + } + + /** + * Counts active non-empty chunks + */ + private getActiveChunkCount(): number { + let count = 0; + for (const chunk of this.maskChunks.values()) { + if (!chunk.isEmpty && chunk.isActive) count++; + } + return count; + } + + /** + * Updates which chunks are active for drawing operations based on current drawing position + * Only activates chunks in a radius around the drawing position for performance + */ + private updateActiveChunksForDrawing(worldCoords: Point): void { + const currentChunkX = Math.floor(worldCoords.x / this.chunkSize); + const currentChunkY = Math.floor(worldCoords.y / this.chunkSize); + + // Update current drawing chunk + this.currentDrawingChunk = { x: currentChunkX, y: currentChunkY }; + + // Deactivate all chunks first + for (const chunk of this.maskChunks.values()) { + chunk.isActive = false; + } + + // Activate chunks in radius around current drawing position + let activatedCount = 0; + for (let dy = -this.activeChunkRadius; dy <= this.activeChunkRadius; dy++) { + for (let dx = -this.activeChunkRadius; dx <= this.activeChunkRadius; dx++) { + const chunkX = currentChunkX + dx; + const chunkY = currentChunkY + dy; + const chunkKey = `${chunkX},${chunkY}`; + + // Get or create chunk if it doesn't exist + const chunk = this.getChunkForPosition(chunkX * this.chunkSize, chunkY * this.chunkSize); + chunk.isActive = true; + chunk.lastAccessTime = Date.now(); + activatedCount++; + + // Safety check to prevent too many active chunks + if (activatedCount >= this.maxActiveChunks) { + log.warn(`Reached maximum active chunks limit (${this.maxActiveChunks})`); + return; + } + } + } + + log.debug(`Activated ${activatedCount} chunks around drawing position (${currentChunkX}, ${currentChunkY})`); + } + /** * Gets or creates a chunk for the given world coordinates */ @@ -266,7 +378,9 @@ export class MaskTool { x: chunkX * this.chunkSize, y: chunkY * this.chunkSize, isDirty: false, - isEmpty: true + isEmpty: true, + isActive: false, + lastAccessTime: Date.now() }; log.debug(`Created chunk at (${chunkX}, ${chunkY}) covering world area (${chunk.x}, ${chunk.y}) to (${chunk.x + this.chunkSize}, ${chunk.y + this.chunkSize})`); @@ -310,6 +424,10 @@ export class MaskTool { if (!this.isActive) return; this.isDrawing = true; this.lastPosition = worldCoords; + + // Activate chunks around the drawing position for performance + this.updateActiveChunksForDrawing(worldCoords); + this.draw(worldCoords); this.clearPreview(); } @@ -319,6 +437,10 @@ export class MaskTool { this.drawBrushPreview(viewCoords); } if (!this.isActive || !this.isDrawing) return; + + // Dynamically update active chunks as user moves while drawing + this.updateActiveChunksForDrawing(worldCoords); + this.draw(worldCoords); this.lastPosition = worldCoords; } @@ -337,6 +459,11 @@ export class MaskTool { if (this.isDrawing) { this.isDrawing = false; this.lastPosition = null; + this.currentDrawingChunk = null; + + // After drawing is complete, update active canvas to show all chunks + this.updateActiveMaskCanvas(true); // forceShowAll = true + this.canvasInstance.canvasState.saveMaskState(); if (this.onStateChange) { this.onStateChange(); @@ -457,7 +584,7 @@ export class MaskTool { /** * Updates active canvas when drawing affects chunks with throttling to prevent lag - * Uses throttling to limit updates to ~60fps during drawing operations + * During drawing, only updates the affected active chunks for performance */ private updateActiveCanvasIfNeeded(startWorld: Point, endWorld: Point): void { // Calculate which chunks were affected by this drawing operation @@ -471,25 +598,13 @@ export class MaskTool { 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 - immediate update required - this.updateActiveMaskCanvas(); - log.debug("Drew on new chunks - performed immediate full active canvas update"); - } else { - // Drawing within existing bounds - use throttled update for performance + // During drawing, only update affected chunks that are active for performance + if (this.isDrawing) { + // Use throttled partial update for active chunks only this.scheduleThrottledActiveMaskUpdate(affectedChunkMinX, affectedChunkMinY, affectedChunkMaxX, affectedChunkMaxY); + } else { + // Not drawing - do full update to show all chunks + this.updateActiveMaskCanvas(true); } } @@ -519,35 +634,60 @@ export class MaskTool { } /** - * 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 + * Partially updates the active canvas by redrawing only specific chunks that are active + * During drawing, only updates active chunks for performance + * Now handles dynamic chunk activation by expanding canvas if needed */ private updateActiveCanvasPartial(chunkMinX: number, chunkMinY: number, chunkMaxX: number, chunkMaxY: number): void { + // Check if any active chunks are outside current canvas bounds + const activeChunkBounds = this.getActiveChunkBounds(); + const allChunkBounds = this.getAllChunkBounds(); + + if (!allChunkBounds) { + return; // No chunks at all + } + + // If active chunks extend beyond current canvas, do full update to resize canvas + if (activeChunkBounds && this.activeChunkBounds && + (activeChunkBounds.minX < this.activeChunkBounds.minX || + activeChunkBounds.maxX > this.activeChunkBounds.maxX || + activeChunkBounds.minY < this.activeChunkBounds.minY || + activeChunkBounds.maxY > this.activeChunkBounds.maxY)) { + + log.debug("Active chunks extended beyond canvas bounds - performing full update"); + this.updateActiveMaskCanvas(true); + return; + } + if (!this.activeChunkBounds) { // No active bounds - do full update this.updateActiveMaskCanvas(); return; } - // Only redraw the affected chunks that are within the current active canvas bounds + // Only redraw the affected chunks that are active and within the current active canvas bounds for (let chunkY = chunkMinY; chunkY <= chunkMaxY; chunkY++) { for (let chunkX = chunkMinX; chunkX <= chunkMaxX; chunkX++) { - // Check if this chunk is within active bounds (all chunks with data) + // Check if this chunk is within canvas bounds (all chunks with data) if (chunkX >= this.activeChunkBounds.minX && chunkX <= this.activeChunkBounds.maxX && chunkY >= this.activeChunkBounds.minY && chunkY <= this.activeChunkBounds.maxY) { const chunkKey = `${chunkX},${chunkY}`; const chunk = this.maskChunks.get(chunkKey); - if (chunk && !chunk.isEmpty) { + // Update if chunk exists and is currently active (regardless of isEmpty for new chunks) + if (chunk && chunk.isActive) { // Calculate position on active canvas (relative to all chunks bounds) const destX = (chunkX - this.activeChunkBounds.minX) * this.chunkSize; const destY = (chunkY - this.activeChunkBounds.minY) * this.chunkSize; // Clear the area first, then redraw this.activeMaskCtx.clearRect(destX, destY, this.chunkSize, this.chunkSize); - this.activeMaskCtx.drawImage(chunk.canvas, destX, destY); + if (!chunk.isEmpty) { + this.activeMaskCtx.drawImage(chunk.canvas, destX, destY); + } + + log.debug(`Partial update: refreshed active chunk (${chunkX}, ${chunkY}) - isEmpty: ${chunk.isEmpty}`); } } } @@ -1157,22 +1297,47 @@ export class MaskTool { const chunkMaxX = Math.floor(maskRight / this.chunkSize); const chunkMaxY = Math.floor(maskBottom / this.chunkSize); - // Add mask to all affected chunks + // First, deactivate all chunks + for (const chunk of this.maskChunks.values()) { + chunk.isActive = false; + } + + // Add mask to all affected chunks and activate them so user can see the mask being applied for (let chunkY = chunkMinY; chunkY <= chunkMaxY; chunkY++) { for (let chunkX = chunkMinX; chunkX <= chunkMaxX; chunkX++) { const chunk = this.getChunkForPosition(chunkX * this.chunkSize, chunkY * this.chunkSize); this.addMaskToChunk(chunk, image, bounds); + + // Activate this chunk so user can see the mask being applied + chunk.isActive = true; + chunk.lastAccessTime = Date.now(); } } - // Update active canvas to show the new mask - this.updateActiveMaskCanvas(); + // Also activate surrounding chunks for better visibility (3x3 grid around mask area) + const centerChunkX = Math.floor((maskLeft + maskRight) / 2 / this.chunkSize); + const centerChunkY = Math.floor((maskTop + maskBottom) / 2 / this.chunkSize); + + for (let dy = -this.activeChunkRadius; dy <= this.activeChunkRadius; dy++) { + for (let dx = -this.activeChunkRadius; dx <= this.activeChunkRadius; dx++) { + const chunkX = centerChunkX + dx; + const chunkY = centerChunkY + dy; + const chunk = this.getChunkForPosition(chunkX * this.chunkSize, chunkY * this.chunkSize); + chunk.isActive = true; + chunk.lastAccessTime = Date.now(); + } + } + + // Update active canvas to show the new mask with activated chunks + this.updateActiveMaskCanvas(true); // Force full update to show all chunks including newly activated ones if (this.onStateChange) { this.onStateChange(); } this.canvasInstance.render(); - log.info(`MaskTool added SAM mask to chunks covering bounds (${bounds.x}, ${bounds.y}) to (${maskRight}, ${maskBottom})`); + + const activatedChunks = Array.from(this.maskChunks.values()).filter(chunk => chunk.isActive).length; + log.info(`MaskTool added SAM mask to chunks covering bounds (${bounds.x}, ${bounds.y}) to (${maskRight}, ${maskBottom}) and activated ${activatedChunks} chunks for visibility`); } /**