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.
This commit is contained in:
Dariusz L
2025-07-27 01:11:31 +02:00
parent 46e92f30e8
commit 03c841380e
2 changed files with 413 additions and 117 deletions

View File

@@ -10,6 +10,10 @@ export class MaskTool {
this.maskChunks = new Map(); this.maskChunks = new Map();
this.chunkSize = 512; this.chunkSize = 512;
this.activeChunkBounds = null; 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) // Create active mask canvas (composite of chunks)
this.activeMaskCanvas = document.createElement('canvas'); this.activeMaskCanvas = document.createElement('canvas');
const activeMaskCtx = this.activeMaskCanvas.getContext('2d', { willReadFrequently: true }); 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}`); 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 * Updates the active mask canvas to show ALL chunks but optimize updates during drawing
* No longer limited to output area - shows all drawn masks everywhere * Always shows all chunks, but during drawing only updates the active chunks for performance
*/ */
updateActiveMaskCanvas() { updateActiveMaskCanvas(forceFullUpdate = false) {
// Find bounds of all non-empty chunks // Always show all chunks - find bounds of all non-empty chunks
const chunkBounds = this.getAllChunkBounds(); const chunkBounds = this.getAllChunkBounds();
if (!chunkBounds) { if (!chunkBounds) {
// No chunks with data - create minimal canvas // No chunks with data - create minimal canvas
@@ -95,34 +99,44 @@ export class MaskTool {
this.x = 0; this.x = 0;
this.y = 0; this.y = 0;
this.activeChunkBounds = null; this.activeChunkBounds = null;
log.info("No mask chunks found - created minimal active canvas"); log.debug("No mask chunks found - created minimal active canvas");
return; return;
} }
// Calculate canvas size to cover all chunks // Calculate canvas size to cover ALL chunks
const canvasLeft = chunkBounds.minX * this.chunkSize; const canvasLeft = chunkBounds.minX * this.chunkSize;
const canvasTop = chunkBounds.minY * this.chunkSize; const canvasTop = chunkBounds.minY * this.chunkSize;
const canvasWidth = (chunkBounds.maxX - chunkBounds.minX + 1) * this.chunkSize; const canvasWidth = (chunkBounds.maxX - chunkBounds.minX + 1) * this.chunkSize;
const canvasHeight = (chunkBounds.maxY - chunkBounds.minY + 1) * this.chunkSize; const canvasHeight = (chunkBounds.maxY - chunkBounds.minY + 1) * this.chunkSize;
// Update active mask canvas size and position // Update active mask canvas size and position if needed
this.activeMaskCanvas.width = canvasWidth; if (this.activeMaskCanvas.width !== canvasWidth ||
this.activeMaskCanvas.height = canvasHeight; this.activeMaskCanvas.height !== canvasHeight ||
this.x = canvasLeft; this.x !== canvasLeft ||
this.y = canvasTop; this.y !== canvasTop ||
// Clear active canvas forceFullUpdate) {
this.activeMaskCtx.clearRect(0, 0, canvasWidth, canvasHeight); this.activeMaskCanvas.width = canvasWidth;
this.activeChunkBounds = chunkBounds; this.activeMaskCanvas.height = canvasHeight;
// Composite ALL chunks with data onto active canvas this.x = canvasLeft;
for (let chunkY = chunkBounds.minY; chunkY <= chunkBounds.maxY; chunkY++) { this.y = canvasTop;
for (let chunkX = chunkBounds.minX; chunkX <= chunkBounds.maxX; chunkX++) { this.activeChunkBounds = chunkBounds;
const chunkKey = `${chunkX},${chunkY}`; // Full redraw when canvas size changes
const chunk = this.maskChunks.get(chunkKey); this.activeMaskCtx.clearRect(0, 0, canvasWidth, canvasHeight);
if (chunk && !chunk.isEmpty) { // Draw ALL chunks
// Calculate position on active canvas for (let chunkY = chunkBounds.minY; chunkY <= chunkBounds.maxY; chunkY++) {
const destX = (chunkX - chunkBounds.minX) * this.chunkSize; for (let chunkX = chunkBounds.minX; chunkX <= chunkBounds.maxX; chunkX++) {
const destY = (chunkY - chunkBounds.minY) * this.chunkSize; const chunkKey = `${chunkX},${chunkY}`;
this.activeMaskCtx.drawImage(chunk.canvas, destX, destY); 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; 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 * Gets or creates a chunk for the given world coordinates
*/ */
@@ -180,7 +274,9 @@ export class MaskTool {
x: chunkX * this.chunkSize, x: chunkX * this.chunkSize,
y: chunkY * this.chunkSize, y: chunkY * this.chunkSize,
isDirty: false, 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})`); 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; return chunk;
@@ -217,6 +313,8 @@ export class MaskTool {
return; return;
this.isDrawing = true; this.isDrawing = true;
this.lastPosition = worldCoords; this.lastPosition = worldCoords;
// Activate chunks around the drawing position for performance
this.updateActiveChunksForDrawing(worldCoords);
this.draw(worldCoords); this.draw(worldCoords);
this.clearPreview(); this.clearPreview();
} }
@@ -226,6 +324,8 @@ export class MaskTool {
} }
if (!this.isActive || !this.isDrawing) if (!this.isActive || !this.isDrawing)
return; return;
// Dynamically update active chunks as user moves while drawing
this.updateActiveChunksForDrawing(worldCoords);
this.draw(worldCoords); this.draw(worldCoords);
this.lastPosition = worldCoords; this.lastPosition = worldCoords;
} }
@@ -242,6 +342,9 @@ export class MaskTool {
if (this.isDrawing) { if (this.isDrawing) {
this.isDrawing = false; this.isDrawing = false;
this.lastPosition = null; 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(); this.canvasInstance.canvasState.saveMaskState();
if (this.onStateChange) { if (this.onStateChange) {
this.onStateChange(); this.onStateChange();
@@ -342,7 +445,7 @@ export class MaskTool {
} }
/** /**
* Updates active canvas when drawing affects chunks with throttling to prevent lag * 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) { updateActiveCanvasIfNeeded(startWorld, endWorld) {
// Calculate which chunks were affected by this drawing operation // Calculate which chunks were affected by this drawing operation
@@ -354,27 +457,15 @@ export class MaskTool {
const affectedChunkMinY = Math.floor(minY / this.chunkSize); const affectedChunkMinY = Math.floor(minY / this.chunkSize);
const affectedChunkMaxX = Math.floor(maxX / this.chunkSize); const affectedChunkMaxX = Math.floor(maxX / this.chunkSize);
const affectedChunkMaxY = Math.floor(maxY / this.chunkSize); const affectedChunkMaxY = Math.floor(maxY / this.chunkSize);
// Check if we drew on any new chunks (outside current active bounds) // During drawing, only update affected chunks that are active for performance
let drewOnNewChunks = false; if (this.isDrawing) {
if (!this.activeChunkBounds) { // Use throttled partial update for active chunks only
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
this.scheduleThrottledActiveMaskUpdate(affectedChunkMinX, affectedChunkMinY, affectedChunkMaxX, affectedChunkMaxY); 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 * 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); }, this.ACTIVE_MASK_UPDATE_DELAY);
} }
/** /**
* Partially updates the active canvas by redrawing only specific chunks * Partially updates the active canvas by redrawing only specific chunks that are active
* Much faster than full recomposition during drawing * During drawing, only updates active chunks for performance
* Now works with the new system that shows ALL chunks * Now handles dynamic chunk activation by expanding canvas if needed
*/ */
updateActiveCanvasPartial(chunkMinX, chunkMinY, chunkMaxX, chunkMaxY) { 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) { if (!this.activeChunkBounds) {
// No active bounds - do full update // No active bounds - do full update
this.updateActiveMaskCanvas(); this.updateActiveMaskCanvas();
return; 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 chunkY = chunkMinY; chunkY <= chunkMaxY; chunkY++) {
for (let chunkX = chunkMinX; chunkX <= chunkMaxX; chunkX++) { 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 && if (chunkX >= this.activeChunkBounds.minX && chunkX <= this.activeChunkBounds.maxX &&
chunkY >= this.activeChunkBounds.minY && chunkY <= this.activeChunkBounds.maxY) { chunkY >= this.activeChunkBounds.minY && chunkY <= this.activeChunkBounds.maxY) {
const chunkKey = `${chunkX},${chunkY}`; const chunkKey = `${chunkX},${chunkY}`;
const chunk = this.maskChunks.get(chunkKey); 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) // Calculate position on active canvas (relative to all chunks bounds)
const destX = (chunkX - this.activeChunkBounds.minX) * this.chunkSize; const destX = (chunkX - this.activeChunkBounds.minX) * this.chunkSize;
const destY = (chunkY - this.activeChunkBounds.minY) * this.chunkSize; const destY = (chunkY - this.activeChunkBounds.minY) * this.chunkSize;
// Clear the area first, then redraw // Clear the area first, then redraw
this.activeMaskCtx.clearRect(destX, destY, this.chunkSize, this.chunkSize); 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 chunkMinY = Math.floor(maskTop / this.chunkSize);
const chunkMaxX = Math.floor(maskRight / this.chunkSize); const chunkMaxX = Math.floor(maskRight / this.chunkSize);
const chunkMaxY = Math.floor(maskBottom / 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 chunkY = chunkMinY; chunkY <= chunkMaxY; chunkY++) {
for (let chunkX = chunkMinX; chunkX <= chunkMaxX; chunkX++) { for (let chunkX = chunkMinX; chunkX <= chunkMaxX; chunkX++) {
const chunk = this.getChunkForPosition(chunkX * this.chunkSize, chunkY * this.chunkSize); const chunk = this.getChunkForPosition(chunkX * this.chunkSize, chunkY * this.chunkSize);
this.addMaskToChunk(chunk, image, bounds); 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 // Also activate surrounding chunks for better visibility (3x3 grid around mask area)
this.updateActiveMaskCanvas(); 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) { if (this.onStateChange) {
this.onStateChange(); this.onStateChange();
} }
this.canvasInstance.render(); 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 * Adds a mask image to a specific chunk

View File

@@ -15,6 +15,8 @@ interface MaskChunk {
y: number; // World coordinates of chunk y: number; // World coordinates of chunk
isDirty: boolean; // Has been modified isDirty: boolean; // Has been modified
isEmpty: boolean; // Contains no mask data isEmpty: boolean; // Contains no mask data
isActive: boolean; // Is currently active for drawing operations
lastAccessTime: number; // For LRU cache management
} }
export class MaskTool { export class MaskTool {
@@ -31,10 +33,15 @@ export class MaskTool {
// Chunked mask system // Chunked mask system
private maskChunks: Map<string, MaskChunk>; // Key: "x,y" (chunk coordinates) private maskChunks: Map<string, MaskChunk>; // Key: "x,y" (chunk coordinates)
private chunkSize: number; private chunkSize: number;
private activeMaskCanvas: HTMLCanvasElement; // Composite of active chunks private activeMaskCanvas: HTMLCanvasElement; // Composite of active chunks only
private activeMaskCtx: CanvasRenderingContext2D; private activeMaskCtx: CanvasRenderingContext2D;
private activeChunkBounds: { minX: number, minY: number, maxX: number, maxY: number } | null; 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 onStateChange: (() => void) | null;
private previewCanvas: HTMLCanvasElement; private previewCanvas: HTMLCanvasElement;
private previewCanvasInitialized: boolean; private previewCanvasInitialized: boolean;
@@ -64,6 +71,11 @@ export class MaskTool {
this.chunkSize = 512; this.chunkSize = 512;
this.activeChunkBounds = null; 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) // Create active mask canvas (composite of chunks)
this.activeMaskCanvas = document.createElement('canvas'); this.activeMaskCanvas = document.createElement('canvas');
const activeMaskCtx = this.activeMaskCanvas.getContext('2d', { willReadFrequently: true }); 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 * Updates the active mask canvas to show ALL chunks but optimize updates during drawing
* No longer limited to output area - shows all drawn masks everywhere * Always shows all chunks, but during drawing only updates the active chunks for performance
*/ */
private updateActiveMaskCanvas(): void { private updateActiveMaskCanvas(forceFullUpdate: boolean = false): void {
// Find bounds of all non-empty chunks // Always show all chunks - find bounds of all non-empty chunks
const chunkBounds = this.getAllChunkBounds(); const chunkBounds = this.getAllChunkBounds();
if (!chunkBounds) { if (!chunkBounds) {
@@ -164,42 +176,51 @@ export class MaskTool {
this.x = 0; this.x = 0;
this.y = 0; this.y = 0;
this.activeChunkBounds = null; this.activeChunkBounds = null;
log.info("No mask chunks found - created minimal active canvas"); log.debug("No mask chunks found - created minimal active canvas");
return; return;
} }
// Calculate canvas size to cover all chunks // Calculate canvas size to cover ALL chunks
const canvasLeft = chunkBounds.minX * this.chunkSize; const canvasLeft = chunkBounds.minX * this.chunkSize;
const canvasTop = chunkBounds.minY * this.chunkSize; const canvasTop = chunkBounds.minY * this.chunkSize;
const canvasWidth = (chunkBounds.maxX - chunkBounds.minX + 1) * this.chunkSize; const canvasWidth = (chunkBounds.maxX - chunkBounds.minX + 1) * this.chunkSize;
const canvasHeight = (chunkBounds.maxY - chunkBounds.minY + 1) * this.chunkSize; const canvasHeight = (chunkBounds.maxY - chunkBounds.minY + 1) * this.chunkSize;
// Update active mask canvas size and position // Update active mask canvas size and position if needed
this.activeMaskCanvas.width = canvasWidth; if (this.activeMaskCanvas.width !== canvasWidth ||
this.activeMaskCanvas.height = canvasHeight; this.activeMaskCanvas.height !== canvasHeight ||
this.x = canvasLeft; this.x !== canvasLeft ||
this.y = canvasTop; this.y !== canvasTop ||
forceFullUpdate) {
// Clear active canvas
this.activeMaskCtx.clearRect(0, 0, canvasWidth, canvasHeight); this.activeMaskCanvas.width = canvasWidth;
this.activeMaskCanvas.height = canvasHeight;
this.activeChunkBounds = chunkBounds; this.x = canvasLeft;
this.y = canvasTop;
// Composite ALL chunks with data onto active canvas this.activeChunkBounds = chunkBounds;
for (let chunkY = chunkBounds.minY; chunkY <= chunkBounds.maxY; chunkY++) {
for (let chunkX = chunkBounds.minX; chunkX <= chunkBounds.maxX; chunkX++) { // Full redraw when canvas size changes
const chunkKey = `${chunkX},${chunkY}`; this.activeMaskCtx.clearRect(0, 0, canvasWidth, canvasHeight);
const chunk = this.maskChunks.get(chunkKey);
// Draw ALL chunks
if (chunk && !chunk.isEmpty) { for (let chunkY = chunkBounds.minY; chunkY <= chunkBounds.maxY; chunkY++) {
// Calculate position on active canvas for (let chunkX = chunkBounds.minX; chunkX <= chunkBounds.maxX; chunkX++) {
const destX = (chunkX - chunkBounds.minX) * this.chunkSize; const chunkKey = `${chunkX},${chunkY}`;
const destY = (chunkY - chunkBounds.minY) * this.chunkSize; 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; 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 * Gets or creates a chunk for the given world coordinates
*/ */
@@ -266,7 +378,9 @@ export class MaskTool {
x: chunkX * this.chunkSize, x: chunkX * this.chunkSize,
y: chunkY * this.chunkSize, y: chunkY * this.chunkSize,
isDirty: false, 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})`); 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; if (!this.isActive) return;
this.isDrawing = true; this.isDrawing = true;
this.lastPosition = worldCoords; this.lastPosition = worldCoords;
// Activate chunks around the drawing position for performance
this.updateActiveChunksForDrawing(worldCoords);
this.draw(worldCoords); this.draw(worldCoords);
this.clearPreview(); this.clearPreview();
} }
@@ -319,6 +437,10 @@ export class MaskTool {
this.drawBrushPreview(viewCoords); this.drawBrushPreview(viewCoords);
} }
if (!this.isActive || !this.isDrawing) return; if (!this.isActive || !this.isDrawing) return;
// Dynamically update active chunks as user moves while drawing
this.updateActiveChunksForDrawing(worldCoords);
this.draw(worldCoords); this.draw(worldCoords);
this.lastPosition = worldCoords; this.lastPosition = worldCoords;
} }
@@ -337,6 +459,11 @@ export class MaskTool {
if (this.isDrawing) { if (this.isDrawing) {
this.isDrawing = false; this.isDrawing = false;
this.lastPosition = null; 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(); this.canvasInstance.canvasState.saveMaskState();
if (this.onStateChange) { if (this.onStateChange) {
this.onStateChange(); this.onStateChange();
@@ -457,7 +584,7 @@ export class MaskTool {
/** /**
* Updates active canvas when drawing affects chunks with throttling to prevent lag * 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 { private updateActiveCanvasIfNeeded(startWorld: Point, endWorld: Point): void {
// Calculate which chunks were affected by this drawing operation // Calculate which chunks were affected by this drawing operation
@@ -471,25 +598,13 @@ export class MaskTool {
const affectedChunkMaxX = Math.floor(maxX / this.chunkSize); const affectedChunkMaxX = Math.floor(maxX / this.chunkSize);
const affectedChunkMaxY = Math.floor(maxY / this.chunkSize); const affectedChunkMaxY = Math.floor(maxY / this.chunkSize);
// Check if we drew on any new chunks (outside current active bounds) // During drawing, only update affected chunks that are active for performance
let drewOnNewChunks = false; if (this.isDrawing) {
if (!this.activeChunkBounds) { // Use throttled partial update for active chunks only
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
this.scheduleThrottledActiveMaskUpdate(affectedChunkMinX, affectedChunkMinY, affectedChunkMaxX, affectedChunkMaxY); 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 * Partially updates the active canvas by redrawing only specific chunks that are active
* Much faster than full recomposition during drawing * During drawing, only updates active chunks for performance
* Now works with the new system that shows ALL chunks * Now handles dynamic chunk activation by expanding canvas if needed
*/ */
private updateActiveCanvasPartial(chunkMinX: number, chunkMinY: number, chunkMaxX: number, chunkMaxY: number): void { 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) { if (!this.activeChunkBounds) {
// No active bounds - do full update // No active bounds - do full update
this.updateActiveMaskCanvas(); this.updateActiveMaskCanvas();
return; 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 chunkY = chunkMinY; chunkY <= chunkMaxY; chunkY++) {
for (let chunkX = chunkMinX; chunkX <= chunkMaxX; chunkX++) { 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 && if (chunkX >= this.activeChunkBounds.minX && chunkX <= this.activeChunkBounds.maxX &&
chunkY >= this.activeChunkBounds.minY && chunkY <= this.activeChunkBounds.maxY) { chunkY >= this.activeChunkBounds.minY && chunkY <= this.activeChunkBounds.maxY) {
const chunkKey = `${chunkX},${chunkY}`; const chunkKey = `${chunkX},${chunkY}`;
const chunk = this.maskChunks.get(chunkKey); 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) // Calculate position on active canvas (relative to all chunks bounds)
const destX = (chunkX - this.activeChunkBounds.minX) * this.chunkSize; const destX = (chunkX - this.activeChunkBounds.minX) * this.chunkSize;
const destY = (chunkY - this.activeChunkBounds.minY) * this.chunkSize; const destY = (chunkY - this.activeChunkBounds.minY) * this.chunkSize;
// Clear the area first, then redraw // Clear the area first, then redraw
this.activeMaskCtx.clearRect(destX, destY, this.chunkSize, this.chunkSize); 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 chunkMaxX = Math.floor(maskRight / this.chunkSize);
const chunkMaxY = Math.floor(maskBottom / 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 chunkY = chunkMinY; chunkY <= chunkMaxY; chunkY++) {
for (let chunkX = chunkMinX; chunkX <= chunkMaxX; chunkX++) { for (let chunkX = chunkMinX; chunkX <= chunkMaxX; chunkX++) {
const chunk = this.getChunkForPosition(chunkX * this.chunkSize, chunkY * this.chunkSize); const chunk = this.getChunkForPosition(chunkX * this.chunkSize, chunkY * this.chunkSize);
this.addMaskToChunk(chunk, image, bounds); 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 // Also activate surrounding chunks for better visibility (3x3 grid around mask area)
this.updateActiveMaskCanvas(); 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) { if (this.onStateChange) {
this.onStateChange(); this.onStateChange();
} }
this.canvasInstance.render(); 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`);
} }
/** /**