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.
This commit is contained in:
Dariusz L
2025-07-26 19:19:23 +02:00
parent 14c5f291a6
commit ca9e1890c4
4 changed files with 942 additions and 289 deletions

View File

@@ -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) {