mirror of
https://github.com/Azornes/Comfyui-LayerForge.git
synced 2026-03-21 20:52:12 -03:00
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.
1339 lines
62 KiB
JavaScript
1339 lines
62 KiB
JavaScript
import { createModuleLogger } from "./utils/LoggerUtils.js";
|
|
const log = createModuleLogger('Mask_tool');
|
|
export class MaskTool {
|
|
constructor(canvasInstance, callbacks = {}) {
|
|
this.canvasInstance = canvasInstance;
|
|
this.mainCanvas = canvasInstance.canvas;
|
|
this.onStateChange = callbacks.onStateChange || null;
|
|
// 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.activeMaskCtx = activeMaskCtx;
|
|
this.x = 0;
|
|
this.y = 0;
|
|
this.isOverlayVisible = true;
|
|
this.isActive = false;
|
|
this.brushSize = 20;
|
|
this.brushStrength = 0.5;
|
|
this.brushHardness = 0.5;
|
|
this.isDrawing = false;
|
|
this.lastPosition = null;
|
|
this.previewCanvas = document.createElement('canvas');
|
|
const previewCtx = this.previewCanvas.getContext('2d', { willReadFrequently: true });
|
|
if (!previewCtx) {
|
|
throw new Error("Failed to get 2D context for preview canvas");
|
|
}
|
|
this.previewCtx = previewCtx;
|
|
this.previewVisible = false;
|
|
this.previewCanvasInitialized = false;
|
|
// Initialize shape preview system
|
|
this.shapePreviewCanvas = document.createElement('canvas');
|
|
const shapePreviewCtx = this.shapePreviewCanvas.getContext('2d', { willReadFrequently: true });
|
|
if (!shapePreviewCtx) {
|
|
throw new Error("Failed to get 2D context for shape preview canvas");
|
|
}
|
|
this.shapePreviewCtx = shapePreviewCtx;
|
|
this.shapePreviewVisible = false;
|
|
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);
|
|
}
|
|
this.previewCanvas.width = this.canvasInstance.canvas.width;
|
|
this.previewCanvas.height = this.canvasInstance.canvas.height;
|
|
this.previewCanvas.style.position = 'absolute';
|
|
this.previewCanvas.style.left = `${this.canvasInstance.canvas.offsetLeft}px`;
|
|
this.previewCanvas.style.top = `${this.canvasInstance.canvas.offsetTop}px`;
|
|
this.previewCanvas.style.pointerEvents = 'none';
|
|
this.previewCanvas.style.zIndex = '10';
|
|
if (this.canvasInstance.canvas.parentElement) {
|
|
this.canvasInstance.canvas.parentElement.appendChild(this.previewCanvas);
|
|
}
|
|
}
|
|
setBrushHardness(hardness) {
|
|
this.brushHardness = Math.max(0, Math.min(1, hardness));
|
|
}
|
|
initMaskCanvas() {
|
|
// 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) {
|
|
this.initPreviewCanvas();
|
|
this.previewCanvasInitialized = true;
|
|
}
|
|
this.isActive = true;
|
|
this.previewCanvas.style.display = 'block';
|
|
this.canvasInstance.interaction.mode = 'drawingMask';
|
|
if (this.canvasInstance.canvasState.maskUndoStack.length === 0) {
|
|
this.canvasInstance.canvasState.saveMaskState();
|
|
}
|
|
this.canvasInstance.updateHistoryButtons();
|
|
log.info("Mask tool activated");
|
|
}
|
|
deactivate() {
|
|
this.isActive = false;
|
|
this.previewCanvas.style.display = 'none';
|
|
this.canvasInstance.interaction.mode = 'none';
|
|
this.canvasInstance.updateHistoryButtons();
|
|
log.info("Mask tool deactivated");
|
|
}
|
|
setBrushSize(size) {
|
|
this.brushSize = Math.max(1, size);
|
|
}
|
|
setBrushStrength(strength) {
|
|
this.brushStrength = Math.max(0, Math.min(1, strength));
|
|
}
|
|
handleMouseDown(worldCoords, viewCoords) {
|
|
if (!this.isActive)
|
|
return;
|
|
this.isDrawing = true;
|
|
this.lastPosition = worldCoords;
|
|
this.draw(worldCoords);
|
|
this.clearPreview();
|
|
}
|
|
handleMouseMove(worldCoords, viewCoords) {
|
|
if (this.isActive) {
|
|
this.drawBrushPreview(viewCoords);
|
|
}
|
|
if (!this.isActive || !this.isDrawing)
|
|
return;
|
|
this.draw(worldCoords);
|
|
this.lastPosition = worldCoords;
|
|
}
|
|
handleMouseLeave() {
|
|
this.previewVisible = false;
|
|
this.clearPreview();
|
|
}
|
|
handleMouseEnter() {
|
|
this.previewVisible = true;
|
|
}
|
|
handleMouseUp(viewCoords) {
|
|
if (!this.isActive)
|
|
return;
|
|
if (this.isDrawing) {
|
|
this.isDrawing = false;
|
|
this.lastPosition = null;
|
|
this.canvasInstance.canvasState.saveMaskState();
|
|
if (this.onStateChange) {
|
|
this.onStateChange();
|
|
}
|
|
this.drawBrushPreview(viewCoords);
|
|
}
|
|
}
|
|
draw(worldCoords) {
|
|
if (!this.lastPosition) {
|
|
this.lastPosition = worldCoords;
|
|
}
|
|
// Draw on chunks instead of single canvas
|
|
this.drawOnChunks(this.lastPosition, worldCoords);
|
|
// Only update active canvas if we drew on chunks that are currently visible
|
|
// This prevents unnecessary recomposition during drawing
|
|
this.updateActiveCanvasIfNeeded(this.lastPosition, worldCoords);
|
|
}
|
|
/**
|
|
* 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);
|
|
}
|
|
}
|
|
}
|
|
/**
|
|
* 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 {
|
|
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) {
|
|
if (!this.previewVisible || this.isDrawing) {
|
|
this.clearPreview();
|
|
return;
|
|
}
|
|
this.clearPreview();
|
|
const zoom = this.canvasInstance.viewport.zoom;
|
|
const radius = (this.brushSize / 2) * zoom;
|
|
this.previewCtx.beginPath();
|
|
this.previewCtx.arc(viewCoords.x, viewCoords.y, radius, 0, 2 * Math.PI);
|
|
this.previewCtx.strokeStyle = 'rgba(255, 255, 255, 0.8)';
|
|
this.previewCtx.lineWidth = 1;
|
|
this.previewCtx.setLineDash([2, 4]);
|
|
this.previewCtx.stroke();
|
|
}
|
|
clearPreview() {
|
|
this.previewCtx.clearRect(0, 0, this.previewCanvas.width, this.previewCanvas.height);
|
|
this.clearShapePreview();
|
|
}
|
|
/**
|
|
* Initialize shape preview canvas for showing blue outline during slider adjustments
|
|
* Canvas is pinned to viewport and covers the entire visible area
|
|
*/
|
|
initShapePreviewCanvas() {
|
|
if (this.shapePreviewCanvas.parentElement) {
|
|
this.shapePreviewCanvas.parentElement.removeChild(this.shapePreviewCanvas);
|
|
}
|
|
// Canvas covers entire viewport - pinned to screen, not world
|
|
this.shapePreviewCanvas.width = this.canvasInstance.canvas.width;
|
|
this.shapePreviewCanvas.height = this.canvasInstance.canvas.height;
|
|
// Pin canvas to viewport - no world coordinate positioning
|
|
this.shapePreviewCanvas.style.position = 'absolute';
|
|
this.shapePreviewCanvas.style.left = '0px';
|
|
this.shapePreviewCanvas.style.top = '0px';
|
|
this.shapePreviewCanvas.style.width = '100%';
|
|
this.shapePreviewCanvas.style.height = '100%';
|
|
this.shapePreviewCanvas.style.pointerEvents = 'none';
|
|
this.shapePreviewCanvas.style.zIndex = '15'; // Above regular preview
|
|
this.shapePreviewCanvas.style.imageRendering = 'pixelated'; // Sharp rendering
|
|
if (this.canvasInstance.canvas.parentElement) {
|
|
this.canvasInstance.canvas.parentElement.appendChild(this.shapePreviewCanvas);
|
|
}
|
|
}
|
|
/**
|
|
* Show blue outline preview of expansion/contraction during slider adjustment
|
|
*/
|
|
showShapePreview(expansionValue, featherValue = 0) {
|
|
if (!this.canvasInstance.outputAreaShape?.points || this.canvasInstance.outputAreaShape.points.length < 3) {
|
|
return;
|
|
}
|
|
if (!this.shapePreviewCanvas.parentElement)
|
|
this.initShapePreviewCanvas();
|
|
this.isPreviewMode = true;
|
|
this.shapePreviewVisible = true;
|
|
this.shapePreviewCanvas.style.display = 'block';
|
|
this.clearShapePreview();
|
|
const shape = this.canvasInstance.outputAreaShape;
|
|
const viewport = this.canvasInstance.viewport;
|
|
const screenPoints = shape.points.map(p => ({
|
|
x: (p.x - viewport.x) * viewport.zoom,
|
|
y: (p.y - viewport.y) * viewport.zoom
|
|
}));
|
|
// This function now returns Point[][] to handle islands.
|
|
const allContours = this._calculatePreviewPointsScreen([screenPoints], expansionValue, viewport.zoom);
|
|
// Draw main expansion/contraction preview
|
|
this.shapePreviewCtx.strokeStyle = '#4A9EFF';
|
|
this.shapePreviewCtx.lineWidth = 2;
|
|
this.shapePreviewCtx.setLineDash([4, 4]);
|
|
this.shapePreviewCtx.globalAlpha = 0.8;
|
|
for (const contour of allContours) {
|
|
if (contour.length < 2)
|
|
continue;
|
|
this.shapePreviewCtx.beginPath();
|
|
this.shapePreviewCtx.moveTo(contour[0].x, contour[0].y);
|
|
for (let i = 1; i < contour.length; i++) {
|
|
this.shapePreviewCtx.lineTo(contour[i].x, contour[i].y);
|
|
}
|
|
this.shapePreviewCtx.closePath();
|
|
this.shapePreviewCtx.stroke();
|
|
}
|
|
// Draw feather preview
|
|
if (featherValue > 0) {
|
|
const allFeatherContours = this._calculatePreviewPointsScreen(allContours, -featherValue, viewport.zoom);
|
|
this.shapePreviewCtx.strokeStyle = '#4A9EFF';
|
|
this.shapePreviewCtx.lineWidth = 1;
|
|
this.shapePreviewCtx.setLineDash([3, 5]);
|
|
this.shapePreviewCtx.globalAlpha = 0.6;
|
|
for (const contour of allFeatherContours) {
|
|
if (contour.length < 2)
|
|
continue;
|
|
this.shapePreviewCtx.beginPath();
|
|
this.shapePreviewCtx.moveTo(contour[0].x, contour[0].y);
|
|
for (let i = 1; i < contour.length; i++) {
|
|
this.shapePreviewCtx.lineTo(contour[i].x, contour[i].y);
|
|
}
|
|
this.shapePreviewCtx.closePath();
|
|
this.shapePreviewCtx.stroke();
|
|
}
|
|
}
|
|
log.debug(`Shape preview shown with expansion: ${expansionValue}px, feather: ${featherValue}px`);
|
|
}
|
|
/**
|
|
* Hide shape preview and switch back to normal mode
|
|
*/
|
|
hideShapePreview() {
|
|
this.isPreviewMode = false;
|
|
this.shapePreviewVisible = false;
|
|
this.clearShapePreview();
|
|
this.shapePreviewCanvas.style.display = 'none';
|
|
log.debug("Shape preview hidden");
|
|
}
|
|
/**
|
|
* Clear shape preview canvas
|
|
*/
|
|
clearShapePreview() {
|
|
if (this.shapePreviewCtx) {
|
|
this.shapePreviewCtx.clearRect(0, 0, this.shapePreviewCanvas.width, this.shapePreviewCanvas.height);
|
|
}
|
|
}
|
|
/**
|
|
* Update shape preview canvas position and scale when viewport changes
|
|
* This ensures the preview stays synchronized with the world coordinates
|
|
*/
|
|
updateShapePreviewPosition() {
|
|
if (!this.shapePreviewCanvas.parentElement || !this.shapePreviewVisible) {
|
|
return;
|
|
}
|
|
const viewport = this.canvasInstance.viewport;
|
|
const bufferSize = 300;
|
|
// Calculate world position (output area + buffer)
|
|
const previewX = -bufferSize; // World coordinates
|
|
const previewY = -bufferSize;
|
|
// Convert to screen coordinates
|
|
const screenX = (previewX - viewport.x) * viewport.zoom;
|
|
const screenY = (previewY - viewport.y) * viewport.zoom;
|
|
// Update position and scale
|
|
this.shapePreviewCanvas.style.left = `${screenX}px`;
|
|
this.shapePreviewCanvas.style.top = `${screenY}px`;
|
|
const previewWidth = this.canvasInstance.width + (bufferSize * 2);
|
|
const previewHeight = this.canvasInstance.height + (bufferSize * 2);
|
|
this.shapePreviewCanvas.style.width = `${previewWidth * viewport.zoom}px`;
|
|
this.shapePreviewCanvas.style.height = `${previewHeight * viewport.zoom}px`;
|
|
}
|
|
/**
|
|
* Ultra-fast dilation using Distance Transform + thresholding (Manhattan distance for speed)
|
|
*/
|
|
_fastDilateDT(mask, width, height, radius) {
|
|
const INF = 1e9;
|
|
const dist = new Float32Array(width * height);
|
|
// 1. Initialize: 0 for foreground, INF for background
|
|
for (let i = 0; i < width * height; ++i) {
|
|
dist[i] = mask[i] ? 0 : INF;
|
|
}
|
|
// 2. Forward pass: top-left -> bottom-right
|
|
for (let y = 0; y < height; ++y) {
|
|
for (let x = 0; x < width; ++x) {
|
|
const i = y * width + x;
|
|
if (mask[i])
|
|
continue;
|
|
if (x > 0)
|
|
dist[i] = Math.min(dist[i], dist[y * width + (x - 1)] + 1);
|
|
if (y > 0)
|
|
dist[i] = Math.min(dist[i], dist[(y - 1) * width + x] + 1);
|
|
}
|
|
}
|
|
// 3. Backward pass: bottom-right -> top-left
|
|
for (let y = height - 1; y >= 0; --y) {
|
|
for (let x = width - 1; x >= 0; --x) {
|
|
const i = y * width + x;
|
|
if (mask[i])
|
|
continue;
|
|
if (x < width - 1)
|
|
dist[i] = Math.min(dist[i], dist[y * width + (x + 1)] + 1);
|
|
if (y < height - 1)
|
|
dist[i] = Math.min(dist[i], dist[(y + 1) * width + x] + 1);
|
|
}
|
|
}
|
|
// 4. Thresholding: if distance <= radius, it's part of the expanded mask
|
|
const expanded = new Uint8Array(width * height);
|
|
for (let i = 0; i < width * height; ++i) {
|
|
expanded[i] = dist[i] <= radius ? 1 : 0;
|
|
}
|
|
return expanded;
|
|
}
|
|
/**
|
|
* Ultra-fast erosion using Distance Transform + thresholding
|
|
*/
|
|
_fastErodeDT(mask, width, height, radius) {
|
|
const INF = 1e9;
|
|
const dist = new Float32Array(width * height);
|
|
// 1. Initialize: 0 for background, INF for foreground (inverse of dilation)
|
|
for (let i = 0; i < width * height; ++i) {
|
|
dist[i] = mask[i] ? INF : 0;
|
|
}
|
|
// 2. Forward pass: top-left -> bottom-right
|
|
for (let y = 0; y < height; ++y) {
|
|
for (let x = 0; x < width; ++x) {
|
|
const i = y * width + x;
|
|
if (!mask[i])
|
|
continue;
|
|
if (x > 0)
|
|
dist[i] = Math.min(dist[i], dist[y * width + (x - 1)] + 1);
|
|
if (y > 0)
|
|
dist[i] = Math.min(dist[i], dist[(y - 1) * width + x] + 1);
|
|
}
|
|
}
|
|
// 3. Backward pass: bottom-right -> top-left
|
|
for (let y = height - 1; y >= 0; --y) {
|
|
for (let x = width - 1; x >= 0; --x) {
|
|
const i = y * width + x;
|
|
if (!mask[i])
|
|
continue;
|
|
if (x < width - 1)
|
|
dist[i] = Math.min(dist[i], dist[y * width + (x + 1)] + 1);
|
|
if (y < height - 1)
|
|
dist[i] = Math.min(dist[i], dist[(y + 1) * width + x] + 1);
|
|
}
|
|
}
|
|
// 4. Thresholding: if distance > radius, it's part of the eroded mask
|
|
const eroded = new Uint8Array(width * height);
|
|
for (let i = 0; i < width * height; ++i) {
|
|
eroded[i] = dist[i] > radius ? 1 : 0;
|
|
}
|
|
return eroded;
|
|
}
|
|
/**
|
|
* Calculate preview points using screen coordinates for pinned canvas.
|
|
* This version now accepts multiple contours and returns multiple contours.
|
|
*/
|
|
_calculatePreviewPointsScreen(contours, expansionValue, zoom) {
|
|
if (contours.length === 0 || expansionValue === 0)
|
|
return contours;
|
|
const width = this.canvasInstance.canvas.width;
|
|
const height = this.canvasInstance.canvas.height;
|
|
const tempCanvas = document.createElement('canvas');
|
|
tempCanvas.width = width;
|
|
tempCanvas.height = height;
|
|
const tempCtx = tempCanvas.getContext('2d', { willReadFrequently: true });
|
|
// Draw all contours to create the initial mask
|
|
tempCtx.fillStyle = 'white';
|
|
for (const points of contours) {
|
|
if (points.length < 3)
|
|
continue;
|
|
tempCtx.beginPath();
|
|
tempCtx.moveTo(points[0].x, points[0].y);
|
|
for (let i = 1; i < points.length; i++) {
|
|
tempCtx.lineTo(points[i].x, points[i].y);
|
|
}
|
|
tempCtx.closePath();
|
|
tempCtx.fill('evenodd'); // Use evenodd to handle holes correctly
|
|
}
|
|
const maskImage = tempCtx.getImageData(0, 0, width, height);
|
|
const binaryData = new Uint8Array(width * height);
|
|
for (let i = 0; i < binaryData.length; i++) {
|
|
binaryData[i] = maskImage.data[i * 4] > 0 ? 1 : 0;
|
|
}
|
|
let resultMask;
|
|
const scaledExpansionValue = Math.round(Math.abs(expansionValue * zoom));
|
|
if (expansionValue >= 0) {
|
|
resultMask = this._fastDilateDT(binaryData, width, height, scaledExpansionValue);
|
|
}
|
|
else {
|
|
resultMask = this._fastErodeDT(binaryData, width, height, scaledExpansionValue);
|
|
}
|
|
// Extract all contours (outer and inner) from the resulting mask
|
|
const allResultContours = this._traceAllContours(resultMask, width, height);
|
|
return allResultContours.length > 0 ? allResultContours : contours;
|
|
}
|
|
/**
|
|
* Calculate preview points in world coordinates using morphological operations
|
|
* This version works directly with mask canvas coordinates
|
|
*/
|
|
/**
|
|
* Traces all contours (outer and inner islands) from a binary mask.
|
|
* @returns An array of contours, where each contour is an array of points.
|
|
*/
|
|
_traceAllContours(mask, width, height) {
|
|
const contours = [];
|
|
const visited = new Uint8Array(mask.length); // Keep track of visited pixels
|
|
for (let y = 1; y < height - 1; y++) {
|
|
for (let x = 1; x < width - 1; x++) {
|
|
const idx = y * width + x;
|
|
// Check for a potential starting point: a foreground pixel that hasn't been visited
|
|
// and is on a boundary (next to a background pixel).
|
|
if (mask[idx] === 1 && visited[idx] === 0) {
|
|
// Check if it's a boundary pixel
|
|
const isBoundary = mask[idx - 1] === 0 ||
|
|
mask[idx + 1] === 0 ||
|
|
mask[idx - width] === 0 ||
|
|
mask[idx + width] === 0;
|
|
if (isBoundary) {
|
|
// Found a new contour, let's trace it.
|
|
const contour = this._traceSingleContour({ x, y }, mask, width, height, visited);
|
|
if (contour.length > 2) {
|
|
// --- Path Simplification ---
|
|
const simplifiedContour = [];
|
|
const simplificationFactor = Math.max(1, Math.floor(contour.length / 200));
|
|
for (let i = 0; i < contour.length; i += simplificationFactor) {
|
|
simplifiedContour.push(contour[i]);
|
|
}
|
|
contours.push(simplifiedContour);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return contours;
|
|
}
|
|
/**
|
|
* Traces a single contour from a starting point using Moore-Neighbor algorithm.
|
|
*/
|
|
_traceSingleContour(startPoint, mask, width, height, visited) {
|
|
const contour = [];
|
|
let { x, y } = startPoint;
|
|
// Neighbor checking order (clockwise)
|
|
const neighbors = [
|
|
{ dx: 0, dy: -1 }, // N
|
|
{ dx: 1, dy: -1 }, // NE
|
|
{ dx: 1, dy: 0 }, // E
|
|
{ dx: 1, dy: 1 }, // SE
|
|
{ dx: 0, dy: 1 }, // S
|
|
{ dx: -1, dy: 1 }, // SW
|
|
{ dx: -1, dy: 0 }, // W
|
|
{ dx: -1, dy: -1 } // NW
|
|
];
|
|
let initialNeighborIndex = 0;
|
|
do {
|
|
let foundNext = false;
|
|
for (let i = 0; i < 8; i++) {
|
|
const neighborIndex = (initialNeighborIndex + i) % 8;
|
|
const nx = x + neighbors[neighborIndex].dx;
|
|
const ny = y + neighbors[neighborIndex].dy;
|
|
const nIdx = ny * width + nx;
|
|
if (nx >= 0 && nx < width && ny >= 0 && ny < height && mask[nIdx] === 1) {
|
|
contour.push({ x, y });
|
|
visited[y * width + x] = 1; // Mark current point as visited
|
|
x = nx;
|
|
y = ny;
|
|
initialNeighborIndex = (neighborIndex + 5) % 8;
|
|
foundNext = true;
|
|
break;
|
|
}
|
|
}
|
|
if (!foundNext)
|
|
break; // End if no next point found
|
|
} while (x !== startPoint.x || y !== startPoint.y);
|
|
return contour;
|
|
}
|
|
clear() {
|
|
this.maskCtx.clearRect(0, 0, this.maskCanvas.width, this.maskCanvas.height);
|
|
if (this.isActive) {
|
|
this.canvasInstance.canvasState.saveMaskState();
|
|
}
|
|
}
|
|
getMask() {
|
|
// 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');
|
|
tempCanvas.width = this.maskCanvas.width;
|
|
tempCanvas.height = this.maskCanvas.height;
|
|
const tempCtx = tempCanvas.getContext('2d', { willReadFrequently: true });
|
|
if (!tempCtx) {
|
|
throw new Error("Failed to get 2D context for temporary canvas");
|
|
}
|
|
tempCtx.drawImage(this.maskCanvas, 0, 0);
|
|
const imageData = tempCtx.getImageData(0, 0, tempCanvas.width, tempCanvas.height);
|
|
const data = imageData.data;
|
|
for (let i = 0; i < data.length; i += 4) {
|
|
const alpha = data[i];
|
|
data[i] = 255;
|
|
data[i + 1] = 255;
|
|
data[i + 2] = 255;
|
|
data[i + 3] = alpha;
|
|
}
|
|
tempCtx.putImageData(imageData, 0, 0);
|
|
const maskImage = new Image();
|
|
maskImage.src = tempCanvas.toDataURL();
|
|
return maskImage;
|
|
}
|
|
resize(width, height) {
|
|
this.initPreviewCanvas();
|
|
const oldMask = this.maskCanvas;
|
|
const oldX = this.x;
|
|
const oldY = this.y;
|
|
const oldWidth = oldMask.width;
|
|
const oldHeight = oldMask.height;
|
|
const isIncreasingWidth = width > this.canvasInstance.width;
|
|
const isIncreasingHeight = height > this.canvasInstance.height;
|
|
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.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.activeMaskCtx = newMaskCtx;
|
|
if (oldMask.width > 0 && oldMask.height > 0) {
|
|
const offsetX = this.x - oldX;
|
|
const offsetY = this.y - oldY;
|
|
this.activeMaskCtx.drawImage(oldMask, offsetX, offsetY);
|
|
log.debug(`Preserved mask content with offset (${offsetX}, ${offsetY})`);
|
|
}
|
|
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) {
|
|
this.x += dx;
|
|
this.y += dy;
|
|
log.info(`Mask position updated to (${this.x}, ${this.y})`);
|
|
}
|
|
/**
|
|
* 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() {
|
|
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) {
|
|
// Clear existing mask chunks in the output area first
|
|
const bounds = this.canvasInstance.outputAreaBounds;
|
|
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);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
/**
|
|
* 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) {
|
|
// Add mask to chunks system instead of directly to active canvas
|
|
const bounds = this.canvasInstance.outputAreaBounds;
|
|
// 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 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) {
|
|
log.warn("Cannot apply shape mask: shape is not defined or has too few points.");
|
|
return;
|
|
}
|
|
if (saveState) {
|
|
this.canvasInstance.canvasState.saveMaskState();
|
|
}
|
|
const shape = this.canvasInstance.outputAreaShape;
|
|
const destX = -this.x;
|
|
const destY = -this.y;
|
|
const maskPoints = shape.points.map(p => ({ x: p.x + destX, y: p.y + destY }));
|
|
// --- Clear Previous State ---
|
|
// To prevent artifacts from previous slider values, we first clear the maximum
|
|
// possible area the shape could have occupied.
|
|
const maxExpansion = 300; // The maximum value of the expansion slider
|
|
const clearingMaskCanvas = this._createExpandedMaskCanvas(maskPoints, maxExpansion, this.maskCanvas.width, this.maskCanvas.height);
|
|
this.maskCtx.globalCompositeOperation = 'destination-out';
|
|
this.maskCtx.drawImage(clearingMaskCanvas, 0, 0);
|
|
// --- Apply Current State ---
|
|
// Now, apply the new, correct mask additively.
|
|
this.maskCtx.globalCompositeOperation = 'source-over';
|
|
// Check if we need expansion or feathering
|
|
const needsExpansion = this.canvasInstance.shapeMaskExpansion && this.canvasInstance.shapeMaskExpansionValue !== 0;
|
|
const needsFeather = this.canvasInstance.shapeMaskFeather && this.canvasInstance.shapeMaskFeatherValue > 0;
|
|
if (!needsExpansion && !needsFeather) {
|
|
// Simple case: just draw the original shape
|
|
this.maskCtx.fillStyle = 'white';
|
|
this.maskCtx.beginPath();
|
|
this.maskCtx.moveTo(maskPoints[0].x, maskPoints[0].y);
|
|
for (let i = 1; i < maskPoints.length; i++) {
|
|
this.maskCtx.lineTo(maskPoints[i].x, maskPoints[i].y);
|
|
}
|
|
this.maskCtx.closePath();
|
|
this.maskCtx.fill('evenodd'); // Use evenodd to handle holes correctly
|
|
}
|
|
else if (needsExpansion && !needsFeather) {
|
|
// Expansion only: use the new distance transform expansion
|
|
const expandedMaskCanvas = this._createExpandedMaskCanvas(maskPoints, this.canvasInstance.shapeMaskExpansionValue, this.maskCanvas.width, this.maskCanvas.height);
|
|
this.maskCtx.drawImage(expandedMaskCanvas, 0, 0);
|
|
}
|
|
else if (!needsExpansion && needsFeather) {
|
|
// Feather only: apply feathering to the original shape
|
|
const featheredMaskCanvas = this._createFeatheredMaskCanvas(maskPoints, this.canvasInstance.shapeMaskFeatherValue, this.maskCanvas.width, this.maskCanvas.height);
|
|
this.maskCtx.drawImage(featheredMaskCanvas, 0, 0);
|
|
}
|
|
else {
|
|
// Both expansion and feather: first expand, then apply feather to the expanded shape
|
|
// Step 1: Create expanded shape
|
|
const expandedMaskCanvas = this._createExpandedMaskCanvas(maskPoints, this.canvasInstance.shapeMaskExpansionValue, this.maskCanvas.width, this.maskCanvas.height);
|
|
// Step 2: Extract points from the expanded canvas and apply feathering
|
|
// For now, we'll apply feathering to the expanded canvas directly
|
|
// This is a simplified approach - we could extract the outline points for more precision
|
|
const tempCtx = expandedMaskCanvas.getContext('2d', { willReadFrequently: true });
|
|
const expandedImageData = tempCtx.getImageData(0, 0, expandedMaskCanvas.width, expandedMaskCanvas.height);
|
|
// Apply feathering to the expanded shape
|
|
const featheredMaskCanvas = this._createFeatheredMaskFromImageData(expandedImageData, this.canvasInstance.shapeMaskFeatherValue, this.maskCanvas.width, this.maskCanvas.height);
|
|
this.maskCtx.drawImage(featheredMaskCanvas, 0, 0);
|
|
}
|
|
if (this.onStateChange) {
|
|
this.onStateChange();
|
|
}
|
|
this.canvasInstance.render();
|
|
log.info(`Applied shape mask with expansion: ${needsExpansion}, feather: ${needsFeather}.`);
|
|
}
|
|
/**
|
|
* Removes mask in the area of the custom output area shape. This must use a hard-edged
|
|
* shape to correctly erase any feathered "glow" that might have been applied.
|
|
*/
|
|
removeShapeMask() {
|
|
if (!this.canvasInstance.outputAreaShape?.points || this.canvasInstance.outputAreaShape.points.length < 3) {
|
|
log.warn("Shape has insufficient points for mask removal");
|
|
return;
|
|
}
|
|
this.canvasInstance.canvasState.saveMaskState();
|
|
const shape = this.canvasInstance.outputAreaShape;
|
|
const destX = -this.x;
|
|
const destY = -this.y;
|
|
// Use 'destination-out' to erase the shape area
|
|
this.maskCtx.globalCompositeOperation = 'destination-out';
|
|
const maskPoints = shape.points.map(p => ({ x: p.x + destX, y: p.y + destY }));
|
|
const needsExpansion = this.canvasInstance.shapeMaskExpansion && this.canvasInstance.shapeMaskExpansionValue !== 0;
|
|
// IMPORTANT: Removal should always be hard-edged, even if feather was on.
|
|
// This ensures the feathered "glow" is completely removed. We only care about expansion.
|
|
if (needsExpansion) {
|
|
// If expansion was active, remove the expanded area with a hard edge.
|
|
const expandedMaskCanvas = this._createExpandedMaskCanvas(maskPoints, this.canvasInstance.shapeMaskExpansionValue, this.maskCanvas.width, this.maskCanvas.height);
|
|
this.maskCtx.drawImage(expandedMaskCanvas, 0, 0);
|
|
}
|
|
else {
|
|
// If no expansion, just remove the base shape with a hard edge.
|
|
this.maskCtx.beginPath();
|
|
this.maskCtx.moveTo(maskPoints[0].x, maskPoints[0].y);
|
|
for (let i = 1; i < maskPoints.length; i++) {
|
|
this.maskCtx.lineTo(maskPoints[i].x, maskPoints[i].y);
|
|
}
|
|
this.maskCtx.closePath();
|
|
this.maskCtx.fill('evenodd');
|
|
}
|
|
// Restore default composite operation
|
|
this.maskCtx.globalCompositeOperation = 'source-over';
|
|
if (this.onStateChange) {
|
|
this.onStateChange();
|
|
}
|
|
this.canvasInstance.render();
|
|
log.info(`Removed shape mask area (hard-edged) with expansion: ${needsExpansion}.`);
|
|
}
|
|
_createFeatheredMaskCanvas(points, featherRadius, width, height) {
|
|
// 1. Create a binary mask on a temporary canvas.
|
|
const binaryCanvas = document.createElement('canvas');
|
|
binaryCanvas.width = width;
|
|
binaryCanvas.height = height;
|
|
const binaryCtx = binaryCanvas.getContext('2d', { willReadFrequently: true });
|
|
binaryCtx.fillStyle = 'white';
|
|
binaryCtx.beginPath();
|
|
binaryCtx.moveTo(points[0].x, points[0].y);
|
|
for (let i = 1; i < points.length; i++) {
|
|
binaryCtx.lineTo(points[i].x, points[i].y);
|
|
}
|
|
binaryCtx.closePath();
|
|
binaryCtx.fill();
|
|
const maskImage = binaryCtx.getImageData(0, 0, width, height);
|
|
const binaryData = new Uint8Array(width * height);
|
|
for (let i = 0; i < binaryData.length; i++) {
|
|
binaryData[i] = maskImage.data[i * 4] > 0 ? 1 : 0; // 1 = inside, 0 = outside
|
|
}
|
|
// 2. Calculate the fast distance transform (from ImageAnalysis.ts approach).
|
|
const distanceMap = this._fastDistanceTransform(binaryData, width, height);
|
|
// Find the maximum distance to normalize
|
|
let maxDistance = 0;
|
|
for (let i = 0; i < distanceMap.length; i++) {
|
|
if (distanceMap[i] > maxDistance) {
|
|
maxDistance = distanceMap[i];
|
|
}
|
|
}
|
|
// 3. Create the final output canvas with the complete mask (solid + feather).
|
|
const outputCanvas = document.createElement('canvas');
|
|
outputCanvas.width = width;
|
|
outputCanvas.height = height;
|
|
const outputCtx = outputCanvas.getContext('2d', { willReadFrequently: true });
|
|
const outputData = outputCtx.createImageData(width, height);
|
|
// Use featherRadius as the threshold for the gradient
|
|
const threshold = Math.min(featherRadius, maxDistance);
|
|
for (let i = 0; i < distanceMap.length; i++) {
|
|
const distance = distanceMap[i];
|
|
const originalAlpha = maskImage.data[i * 4 + 3];
|
|
if (originalAlpha === 0) {
|
|
// Transparent pixels remain transparent
|
|
outputData.data[i * 4] = 255;
|
|
outputData.data[i * 4 + 1] = 255;
|
|
outputData.data[i * 4 + 2] = 255;
|
|
outputData.data[i * 4 + 3] = 0;
|
|
}
|
|
else if (distance <= threshold) {
|
|
// Edge area - apply gradient alpha (from edge inward)
|
|
const gradientValue = distance / threshold;
|
|
const alphaValue = Math.floor(gradientValue * 255);
|
|
outputData.data[i * 4] = 255;
|
|
outputData.data[i * 4 + 1] = 255;
|
|
outputData.data[i * 4 + 2] = 255;
|
|
outputData.data[i * 4 + 3] = alphaValue;
|
|
}
|
|
else {
|
|
// Inner area - full alpha (no blending effect)
|
|
outputData.data[i * 4] = 255;
|
|
outputData.data[i * 4 + 1] = 255;
|
|
outputData.data[i * 4 + 2] = 255;
|
|
outputData.data[i * 4 + 3] = 255;
|
|
}
|
|
}
|
|
outputCtx.putImageData(outputData, 0, 0);
|
|
return outputCanvas;
|
|
}
|
|
/**
|
|
* Fast distance transform using the simple two-pass algorithm from ImageAnalysis.ts
|
|
* Much faster than the complex Felzenszwalb algorithm
|
|
*/
|
|
_fastDistanceTransform(binaryMask, width, height) {
|
|
const distances = new Float32Array(width * height);
|
|
const infinity = width + height; // A value larger than any possible distance
|
|
// Initialize distances
|
|
for (let i = 0; i < width * height; i++) {
|
|
distances[i] = binaryMask[i] === 1 ? infinity : 0;
|
|
}
|
|
// Forward pass (top-left to bottom-right)
|
|
for (let y = 0; y < height; y++) {
|
|
for (let x = 0; x < width; x++) {
|
|
const idx = y * width + x;
|
|
if (distances[idx] > 0) {
|
|
let minDist = distances[idx];
|
|
// Check top neighbor
|
|
if (y > 0) {
|
|
minDist = Math.min(minDist, distances[(y - 1) * width + x] + 1);
|
|
}
|
|
// Check left neighbor
|
|
if (x > 0) {
|
|
minDist = Math.min(minDist, distances[y * width + (x - 1)] + 1);
|
|
}
|
|
// Check top-left diagonal
|
|
if (x > 0 && y > 0) {
|
|
minDist = Math.min(minDist, distances[(y - 1) * width + (x - 1)] + Math.sqrt(2));
|
|
}
|
|
// Check top-right diagonal
|
|
if (x < width - 1 && y > 0) {
|
|
minDist = Math.min(minDist, distances[(y - 1) * width + (x + 1)] + Math.sqrt(2));
|
|
}
|
|
distances[idx] = minDist;
|
|
}
|
|
}
|
|
}
|
|
// Backward pass (bottom-right to top-left)
|
|
for (let y = height - 1; y >= 0; y--) {
|
|
for (let x = width - 1; x >= 0; x--) {
|
|
const idx = y * width + x;
|
|
if (distances[idx] > 0) {
|
|
let minDist = distances[idx];
|
|
// Check bottom neighbor
|
|
if (y < height - 1) {
|
|
minDist = Math.min(minDist, distances[(y + 1) * width + x] + 1);
|
|
}
|
|
// Check right neighbor
|
|
if (x < width - 1) {
|
|
minDist = Math.min(minDist, distances[y * width + (x + 1)] + 1);
|
|
}
|
|
// Check bottom-right diagonal
|
|
if (x < width - 1 && y < height - 1) {
|
|
minDist = Math.min(minDist, distances[(y + 1) * width + (x + 1)] + Math.sqrt(2));
|
|
}
|
|
// Check bottom-left diagonal
|
|
if (x > 0 && y < height - 1) {
|
|
minDist = Math.min(minDist, distances[(y + 1) * width + (x - 1)] + Math.sqrt(2));
|
|
}
|
|
distances[idx] = minDist;
|
|
}
|
|
}
|
|
}
|
|
return distances;
|
|
}
|
|
/**
|
|
* Creates an expanded mask using distance transform - much better for complex shapes
|
|
* than the centroid-based approach. This version only does expansion without transparency calculations.
|
|
*/
|
|
_calculateExpandedPoints(points, expansionValue) {
|
|
if (points.length < 3 || expansionValue === 0)
|
|
return points;
|
|
// For expansion, we need to create a temporary canvas to use the distance transform approach
|
|
// This will give us much better results for complex shapes than the centroid method
|
|
const tempCanvas = this._createExpandedMaskCanvas(points, expansionValue, this.maskCanvas.width, this.maskCanvas.height);
|
|
// Extract the expanded shape outline from the canvas
|
|
// For now, return the original points as a fallback - the real expansion happens in the canvas
|
|
// The calling code will use the canvas directly instead of these points
|
|
return points;
|
|
}
|
|
/**
|
|
* Creates an expanded/contracted mask canvas using simple morphological operations
|
|
* This gives SHARP edges without smoothing, unlike distance transform
|
|
*/
|
|
_createExpandedMaskCanvas(points, expansionValue, width, height) {
|
|
// 1. Create a binary mask on a temporary canvas.
|
|
const binaryCanvas = document.createElement('canvas');
|
|
binaryCanvas.width = width;
|
|
binaryCanvas.height = height;
|
|
const binaryCtx = binaryCanvas.getContext('2d', { willReadFrequently: true });
|
|
binaryCtx.fillStyle = 'white';
|
|
binaryCtx.beginPath();
|
|
binaryCtx.moveTo(points[0].x, points[0].y);
|
|
for (let i = 1; i < points.length; i++) {
|
|
binaryCtx.lineTo(points[i].x, points[i].y);
|
|
}
|
|
binaryCtx.closePath();
|
|
binaryCtx.fill('evenodd'); // Use evenodd to handle holes correctly
|
|
const maskImage = binaryCtx.getImageData(0, 0, width, height);
|
|
const binaryData = new Uint8Array(width * height);
|
|
for (let i = 0; i < binaryData.length; i++) {
|
|
binaryData[i] = maskImage.data[i * 4] > 0 ? 1 : 0; // 1 = inside, 0 = outside
|
|
}
|
|
// 2. Apply fast morphological operations for sharp edges
|
|
let resultMask;
|
|
const absExpansionValue = Math.abs(expansionValue);
|
|
if (expansionValue >= 0) {
|
|
// EXPANSION: Use new fast dilation algorithm
|
|
resultMask = this._fastDilateDT(binaryData, width, height, absExpansionValue);
|
|
}
|
|
else {
|
|
// CONTRACTION: Use new fast erosion algorithm
|
|
resultMask = this._fastErodeDT(binaryData, width, height, absExpansionValue);
|
|
}
|
|
// 3. Create the final output canvas with sharp edges
|
|
const outputCanvas = document.createElement('canvas');
|
|
outputCanvas.width = width;
|
|
outputCanvas.height = height;
|
|
const outputCtx = outputCanvas.getContext('2d', { willReadFrequently: true });
|
|
const outputData = outputCtx.createImageData(width, height);
|
|
for (let i = 0; i < resultMask.length; i++) {
|
|
const alpha = resultMask[i] === 1 ? 255 : 0; // Sharp binary mask - no smoothing
|
|
outputData.data[i * 4] = 255; // R
|
|
outputData.data[i * 4 + 1] = 255; // G
|
|
outputData.data[i * 4 + 2] = 255; // B
|
|
outputData.data[i * 4 + 3] = alpha; // A - sharp edges
|
|
}
|
|
outputCtx.putImageData(outputData, 0, 0);
|
|
return outputCanvas;
|
|
}
|
|
/**
|
|
* Creates a feathered mask from existing ImageData (used when combining expansion + feather)
|
|
*/
|
|
_createFeatheredMaskFromImageData(imageData, featherRadius, width, height) {
|
|
const data = imageData.data;
|
|
const binaryData = new Uint8Array(width * height);
|
|
// Convert ImageData to binary mask
|
|
for (let i = 0; i < width * height; i++) {
|
|
binaryData[i] = data[i * 4 + 3] > 0 ? 1 : 0; // 1 = inside, 0 = outside
|
|
}
|
|
// Calculate the fast distance transform
|
|
const distanceMap = this._fastDistanceTransform(binaryData, width, height);
|
|
// Find the maximum distance to normalize
|
|
let maxDistance = 0;
|
|
for (let i = 0; i < distanceMap.length; i++) {
|
|
if (distanceMap[i] > maxDistance) {
|
|
maxDistance = distanceMap[i];
|
|
}
|
|
}
|
|
// Create the final output canvas with feathering applied
|
|
const outputCanvas = document.createElement('canvas');
|
|
outputCanvas.width = width;
|
|
outputCanvas.height = height;
|
|
const outputCtx = outputCanvas.getContext('2d', { willReadFrequently: true });
|
|
const outputData = outputCtx.createImageData(width, height);
|
|
// Use featherRadius as the threshold for the gradient
|
|
const threshold = Math.min(featherRadius, maxDistance);
|
|
for (let i = 0; i < distanceMap.length; i++) {
|
|
const distance = distanceMap[i];
|
|
const originalAlpha = data[i * 4 + 3];
|
|
if (originalAlpha === 0) {
|
|
// Transparent pixels remain transparent
|
|
outputData.data[i * 4] = 255;
|
|
outputData.data[i * 4 + 1] = 255;
|
|
outputData.data[i * 4 + 2] = 255;
|
|
outputData.data[i * 4 + 3] = 0;
|
|
}
|
|
else if (distance <= threshold) {
|
|
// Edge area - apply gradient alpha (from edge inward)
|
|
const gradientValue = distance / threshold;
|
|
const alphaValue = Math.floor(gradientValue * 255);
|
|
outputData.data[i * 4] = 255;
|
|
outputData.data[i * 4 + 1] = 255;
|
|
outputData.data[i * 4 + 2] = 255;
|
|
outputData.data[i * 4 + 3] = alphaValue;
|
|
}
|
|
else {
|
|
// Inner area - full alpha (no blending effect)
|
|
outputData.data[i * 4] = 255;
|
|
outputData.data[i * 4 + 1] = 255;
|
|
outputData.data[i * 4 + 2] = 255;
|
|
outputData.data[i * 4 + 3] = 255;
|
|
}
|
|
}
|
|
outputCtx.putImageData(outputData, 0, 0);
|
|
return outputCanvas;
|
|
}
|
|
}
|