mirror of
https://github.com/Azornes/Comfyui-LayerForge.git
synced 2026-03-24 22:12:17 -03:00
Fix custom shape output area extension and mask logic
Refactors how custom output area shapes interact with extensions, ensuring the shape's position and mask application remain consistent when extensions are toggled. Moves output area shape logic to CanvasInteractions, tracks original shape position, and updates all rendering, IO, and mask operations to use the correct coordinates. Improves mask chunk clearing and adds chunked mask application/removal for shape masks, ensuring correct behavior with expansion and feathering.
This commit is contained in:
34
js/Canvas.js
34
js/Canvas.js
@@ -72,7 +72,9 @@ export class Canvas {
|
|||||||
this.outputAreaExtensions = { top: 0, bottom: 0, left: 0, right: 0 };
|
this.outputAreaExtensions = { top: 0, bottom: 0, left: 0, right: 0 };
|
||||||
this.outputAreaExtensionEnabled = false;
|
this.outputAreaExtensionEnabled = false;
|
||||||
this.outputAreaExtensionPreview = null;
|
this.outputAreaExtensionPreview = null;
|
||||||
|
this.lastOutputAreaExtensions = { top: 0, bottom: 0, left: 0, right: 0 };
|
||||||
this.originalCanvasSize = { width: this.width, height: this.height };
|
this.originalCanvasSize = { width: this.width, height: this.height };
|
||||||
|
this.originalOutputAreaPosition = { x: -(this.width / 4), y: -(this.height / 4) };
|
||||||
// Initialize outputAreaBounds centered in viewport, similar to how canvas resize/move work
|
// Initialize outputAreaBounds centered in viewport, similar to how canvas resize/move work
|
||||||
this.outputAreaBounds = {
|
this.outputAreaBounds = {
|
||||||
x: -(this.width / 4),
|
x: -(this.width / 4),
|
||||||
@@ -317,37 +319,7 @@ export class Canvas {
|
|||||||
return this.canvasSelection.updateSelectionLogic(layer, isCtrlPressed, isShiftPressed, index);
|
return this.canvasSelection.updateSelectionLogic(layer, isCtrlPressed, isShiftPressed, index);
|
||||||
}
|
}
|
||||||
defineOutputAreaWithShape(shape) {
|
defineOutputAreaWithShape(shape) {
|
||||||
const boundingBox = this.shapeTool.getBoundingBox();
|
this.canvasInteractions.defineOutputAreaWithShape(shape);
|
||||||
if (boundingBox && boundingBox.width > 1 && boundingBox.height > 1) {
|
|
||||||
this.saveState();
|
|
||||||
this.outputAreaShape = {
|
|
||||||
...shape,
|
|
||||||
points: shape.points.map(p => ({
|
|
||||||
x: p.x - boundingBox.x,
|
|
||||||
y: p.y - boundingBox.y
|
|
||||||
}))
|
|
||||||
};
|
|
||||||
const newWidth = Math.round(boundingBox.width);
|
|
||||||
const newHeight = Math.round(boundingBox.height);
|
|
||||||
const newX = Math.round(boundingBox.x);
|
|
||||||
const newY = Math.round(boundingBox.y);
|
|
||||||
// Store the original canvas size for extension calculations
|
|
||||||
this.originalCanvasSize = { width: newWidth, height: newHeight };
|
|
||||||
// Update canvas size but don't change outputAreaBounds yet
|
|
||||||
this.updateOutputAreaSize(newWidth, newHeight, false);
|
|
||||||
// Set outputAreaBounds to where the custom shape was drawn in the world
|
|
||||||
// Similar to finalizeCanvasMove - just update outputAreaBounds position
|
|
||||||
this.outputAreaBounds = {
|
|
||||||
x: newX,
|
|
||||||
y: newY,
|
|
||||||
width: newWidth,
|
|
||||||
height: newHeight
|
|
||||||
};
|
|
||||||
// Update mask canvas to ensure it covers the new output area position
|
|
||||||
this.maskTool.updateMaskCanvasForOutputArea();
|
|
||||||
this.saveState();
|
|
||||||
this.render();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* Zmienia rozmiar obszaru wyjściowego
|
* Zmienia rozmiar obszaru wyjściowego
|
||||||
|
|||||||
@@ -661,12 +661,17 @@ export class CanvasIO {
|
|||||||
}
|
}
|
||||||
// Draw the image first
|
// Draw the image first
|
||||||
ctx.drawImage(image, 0, 0);
|
ctx.drawImage(image, 0, 0);
|
||||||
// Create a clipping mask using the shape
|
// Calculate custom shape position accounting for extensions
|
||||||
|
// Custom shape should maintain its relative position within the original canvas area
|
||||||
|
const ext = this.canvas.outputAreaExtensionEnabled ? this.canvas.outputAreaExtensions : { top: 0, bottom: 0, left: 0, right: 0 };
|
||||||
|
const shapeOffsetX = ext.left; // Add left extension to maintain relative position
|
||||||
|
const shapeOffsetY = ext.top; // Add top extension to maintain relative position
|
||||||
|
// Create a clipping mask using the shape with extension offset
|
||||||
ctx.globalCompositeOperation = 'destination-in';
|
ctx.globalCompositeOperation = 'destination-in';
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.moveTo(shape.points[0].x, shape.points[0].y);
|
ctx.moveTo(shape.points[0].x + shapeOffsetX, shape.points[0].y + shapeOffsetY);
|
||||||
for (let i = 1; i < shape.points.length; i++) {
|
for (let i = 1; i < shape.points.length; i++) {
|
||||||
ctx.lineTo(shape.points[i].x, shape.points[i].y);
|
ctx.lineTo(shape.points[i].x + shapeOffsetX, shape.points[i].y + shapeOffsetY);
|
||||||
}
|
}
|
||||||
ctx.closePath();
|
ctx.closePath();
|
||||||
ctx.fill();
|
ctx.fill();
|
||||||
|
|||||||
@@ -794,6 +794,58 @@ export class CanvasInteractions {
|
|||||||
};
|
};
|
||||||
reader.readAsDataURL(file);
|
reader.readAsDataURL(file);
|
||||||
}
|
}
|
||||||
|
defineOutputAreaWithShape(shape) {
|
||||||
|
const boundingBox = this.canvas.shapeTool.getBoundingBox();
|
||||||
|
if (boundingBox && boundingBox.width > 1 && boundingBox.height > 1) {
|
||||||
|
this.canvas.saveState();
|
||||||
|
this.canvas.outputAreaShape = {
|
||||||
|
...shape,
|
||||||
|
points: shape.points.map((p) => ({
|
||||||
|
x: p.x - boundingBox.x,
|
||||||
|
y: p.y - boundingBox.y
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
const newWidth = Math.round(boundingBox.width);
|
||||||
|
const newHeight = Math.round(boundingBox.height);
|
||||||
|
const newX = Math.round(boundingBox.x);
|
||||||
|
const newY = Math.round(boundingBox.y);
|
||||||
|
// Store the original canvas size for extension calculations
|
||||||
|
this.canvas.originalCanvasSize = { width: newWidth, height: newHeight };
|
||||||
|
// Store the original position where custom shape was drawn for extension calculations
|
||||||
|
this.canvas.originalOutputAreaPosition = { x: newX, y: newY };
|
||||||
|
// If extensions are enabled, we need to recalculate outputAreaBounds with current extensions
|
||||||
|
if (this.canvas.outputAreaExtensionEnabled) {
|
||||||
|
const ext = this.canvas.outputAreaExtensions;
|
||||||
|
const extendedWidth = newWidth + ext.left + ext.right;
|
||||||
|
const extendedHeight = newHeight + ext.top + ext.bottom;
|
||||||
|
// Update canvas size with extensions
|
||||||
|
this.canvas.updateOutputAreaSize(extendedWidth, extendedHeight, false);
|
||||||
|
// Set outputAreaBounds accounting for extensions
|
||||||
|
this.canvas.outputAreaBounds = {
|
||||||
|
x: newX - ext.left, // Adjust position by left extension
|
||||||
|
y: newY - ext.top, // Adjust position by top extension
|
||||||
|
width: extendedWidth,
|
||||||
|
height: extendedHeight
|
||||||
|
};
|
||||||
|
log.info(`New custom shape with extensions: original(${newX}, ${newY}) extended(${newX - ext.left}, ${newY - ext.top}) size(${extendedWidth}x${extendedHeight})`);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
// No extensions - use original size and position
|
||||||
|
this.canvas.updateOutputAreaSize(newWidth, newHeight, false);
|
||||||
|
this.canvas.outputAreaBounds = {
|
||||||
|
x: newX,
|
||||||
|
y: newY,
|
||||||
|
width: newWidth,
|
||||||
|
height: newHeight
|
||||||
|
};
|
||||||
|
log.info(`New custom shape without extensions: position(${newX}, ${newY}) size(${newWidth}x${newHeight})`);
|
||||||
|
}
|
||||||
|
// Update mask canvas to ensure it covers the new output area position
|
||||||
|
this.canvas.maskTool.updateMaskCanvasForOutputArea();
|
||||||
|
this.canvas.saveState();
|
||||||
|
this.canvas.render();
|
||||||
|
}
|
||||||
|
}
|
||||||
async handlePasteEvent(e) {
|
async handlePasteEvent(e) {
|
||||||
const shouldHandle = this.canvas.isMouseOver ||
|
const shouldHandle = this.canvas.isMouseOver ||
|
||||||
this.canvas.canvas.contains(document.activeElement) ||
|
this.canvas.canvas.contains(document.activeElement) ||
|
||||||
|
|||||||
@@ -273,11 +273,16 @@ export class CanvasRenderer {
|
|||||||
ctx.setLineDash([]);
|
ctx.setLineDash([]);
|
||||||
const shape = this.canvas.outputAreaShape;
|
const shape = this.canvas.outputAreaShape;
|
||||||
const bounds = this.canvas.outputAreaBounds;
|
const bounds = this.canvas.outputAreaBounds;
|
||||||
|
// Calculate custom shape position accounting for extensions
|
||||||
|
// Custom shape should maintain its relative position within the original canvas area
|
||||||
|
const ext = this.canvas.outputAreaExtensionEnabled ? this.canvas.outputAreaExtensions : { top: 0, bottom: 0, left: 0, right: 0 };
|
||||||
|
const shapeOffsetX = bounds.x + ext.left; // Add left extension to maintain relative position
|
||||||
|
const shapeOffsetY = bounds.y + ext.top; // Add top extension to maintain relative position
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
// Render custom shape relative to outputAreaBounds, not (0,0)
|
// Render custom shape with extension offset to maintain relative position
|
||||||
ctx.moveTo(bounds.x + shape.points[0].x, bounds.y + shape.points[0].y);
|
ctx.moveTo(shapeOffsetX + shape.points[0].x, shapeOffsetY + shape.points[0].y);
|
||||||
for (let i = 1; i < shape.points.length; i++) {
|
for (let i = 1; i < shape.points.length; i++) {
|
||||||
ctx.lineTo(bounds.x + shape.points[i].x, bounds.y + shape.points[i].y);
|
ctx.lineTo(shapeOffsetX + shape.points[i].x, shapeOffsetY + shape.points[i].y);
|
||||||
}
|
}
|
||||||
ctx.closePath();
|
ctx.closePath();
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
@@ -321,10 +326,11 @@ export class CanvasRenderer {
|
|||||||
const baseWidth = this.canvas.originalCanvasSize ? this.canvas.originalCanvasSize.width : this.canvas.width;
|
const baseWidth = this.canvas.originalCanvasSize ? this.canvas.originalCanvasSize.width : this.canvas.width;
|
||||||
const baseHeight = this.canvas.originalCanvasSize ? this.canvas.originalCanvasSize.height : this.canvas.height;
|
const baseHeight = this.canvas.originalCanvasSize ? this.canvas.originalCanvasSize.height : this.canvas.height;
|
||||||
const ext = this.canvas.outputAreaExtensionPreview;
|
const ext = this.canvas.outputAreaExtensionPreview;
|
||||||
// Podgląd pokazuje jak będą wyglądać nowe outputAreaBounds
|
// Calculate preview bounds relative to original custom shape position, not (0,0)
|
||||||
|
const originalPos = this.canvas.originalOutputAreaPosition;
|
||||||
const previewBounds = {
|
const previewBounds = {
|
||||||
x: -ext.left, // Może być ujemne - wycinamy fragment świata
|
x: originalPos.x - ext.left, // ✅ Względem oryginalnej pozycji custom shape
|
||||||
y: -ext.top, // Może być ujemne - wycinamy fragment świata
|
y: originalPos.y - ext.top, // ✅ Względem oryginalnej pozycji custom shape
|
||||||
width: baseWidth + ext.left + ext.right,
|
width: baseWidth + ext.left + ext.right,
|
||||||
height: baseHeight + ext.top + ext.bottom
|
height: baseHeight + ext.top + ext.bottom
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -281,11 +281,17 @@ export class CustomShapeMenu {
|
|||||||
width: this.canvas.width,
|
width: this.canvas.width,
|
||||||
height: this.canvas.height
|
height: this.canvas.height
|
||||||
};
|
};
|
||||||
|
// Restore last saved extensions instead of starting from zero
|
||||||
|
this.canvas.outputAreaExtensions = { ...this.canvas.lastOutputAreaExtensions };
|
||||||
log.info(`Captured current canvas size as baseline: ${this.canvas.width}x${this.canvas.height}`);
|
log.info(`Captured current canvas size as baseline: ${this.canvas.width}x${this.canvas.height}`);
|
||||||
|
log.info(`Restored last extensions:`, this.canvas.outputAreaExtensions);
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
// Reset all extensions when disabled
|
// Save current extensions before disabling
|
||||||
|
this.canvas.lastOutputAreaExtensions = { ...this.canvas.outputAreaExtensions };
|
||||||
|
// Reset current extensions when disabled (but keep the saved ones)
|
||||||
this.canvas.outputAreaExtensions = { top: 0, bottom: 0, left: 0, right: 0 };
|
this.canvas.outputAreaExtensions = { top: 0, bottom: 0, left: 0, right: 0 };
|
||||||
|
log.info(`Saved extensions for later:`, this.canvas.lastOutputAreaExtensions);
|
||||||
}
|
}
|
||||||
this._updateExtensionUI();
|
this._updateExtensionUI();
|
||||||
this._updateCanvasSize(); // Update canvas size when toggling
|
this._updateCanvasSize(); // Update canvas size when toggling
|
||||||
@@ -610,12 +616,12 @@ export class CustomShapeMenu {
|
|||||||
}
|
}
|
||||||
_updateCanvasSize() {
|
_updateCanvasSize() {
|
||||||
if (!this.canvas.outputAreaExtensionEnabled) {
|
if (!this.canvas.outputAreaExtensionEnabled) {
|
||||||
// When extensions are disabled, preserve the current outputAreaBounds position
|
// When extensions are disabled, return to original custom shape position
|
||||||
// Only update the size to match originalCanvasSize
|
// Use originalOutputAreaPosition instead of current bounds position
|
||||||
const currentBounds = this.canvas.outputAreaBounds;
|
const originalPos = this.canvas.originalOutputAreaPosition;
|
||||||
this.canvas.outputAreaBounds = {
|
this.canvas.outputAreaBounds = {
|
||||||
x: currentBounds.x, // ✅ Preserve current position
|
x: originalPos.x, // ✅ Return to original custom shape position
|
||||||
y: currentBounds.y, // ✅ Preserve current position
|
y: originalPos.y, // ✅ Return to original custom shape position
|
||||||
width: this.canvas.originalCanvasSize.width,
|
width: this.canvas.originalCanvasSize.width,
|
||||||
height: this.canvas.originalCanvasSize.height
|
height: this.canvas.originalCanvasSize.height
|
||||||
};
|
};
|
||||||
@@ -625,11 +631,11 @@ export class CustomShapeMenu {
|
|||||||
const ext = this.canvas.outputAreaExtensions;
|
const ext = this.canvas.outputAreaExtensions;
|
||||||
const newWidth = this.canvas.originalCanvasSize.width + ext.left + ext.right;
|
const newWidth = this.canvas.originalCanvasSize.width + ext.left + ext.right;
|
||||||
const newHeight = this.canvas.originalCanvasSize.height + ext.top + ext.bottom;
|
const newHeight = this.canvas.originalCanvasSize.height + ext.top + ext.bottom;
|
||||||
// When extensions are enabled, calculate new bounds relative to current position
|
// When extensions are enabled, calculate new bounds relative to original custom shape position
|
||||||
const currentBounds = this.canvas.outputAreaBounds;
|
const originalPos = this.canvas.originalOutputAreaPosition;
|
||||||
this.canvas.outputAreaBounds = {
|
this.canvas.outputAreaBounds = {
|
||||||
x: currentBounds.x - ext.left, // Adjust position by left extension
|
x: originalPos.x - ext.left, // Adjust position by left extension from original position
|
||||||
y: currentBounds.y - ext.top, // Adjust position by top extension
|
y: originalPos.y - ext.top, // Adjust position by top extension from original position
|
||||||
width: newWidth,
|
width: newWidth,
|
||||||
height: newHeight
|
height: newHeight
|
||||||
};
|
};
|
||||||
|
|||||||
310
js/MaskTool.js
310
js/MaskTool.js
@@ -463,7 +463,14 @@ export class MaskTool {
|
|||||||
this.clearShapePreview();
|
this.clearShapePreview();
|
||||||
const shape = this.canvasInstance.outputAreaShape;
|
const shape = this.canvasInstance.outputAreaShape;
|
||||||
const viewport = this.canvasInstance.viewport;
|
const viewport = this.canvasInstance.viewport;
|
||||||
const screenPoints = shape.points.map(p => ({
|
const bounds = this.canvasInstance.outputAreaBounds;
|
||||||
|
// Convert shape points to world coordinates first (relative to output area bounds)
|
||||||
|
const worldShapePoints = shape.points.map(p => ({
|
||||||
|
x: bounds.x + p.x,
|
||||||
|
y: bounds.y + p.y
|
||||||
|
}));
|
||||||
|
// Then convert world coordinates to screen coordinates
|
||||||
|
const screenPoints = worldShapePoints.map(p => ({
|
||||||
x: (p.x - viewport.x) * viewport.zoom,
|
x: (p.x - viewport.x) * viewport.zoom,
|
||||||
y: (p.y - viewport.y) * viewport.zoom
|
y: (p.y - viewport.y) * viewport.zoom
|
||||||
}));
|
}));
|
||||||
@@ -504,7 +511,7 @@ export class MaskTool {
|
|||||||
this.shapePreviewCtx.stroke();
|
this.shapePreviewCtx.stroke();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
log.debug(`Shape preview shown with expansion: ${expansionValue}px, feather: ${featherValue}px`);
|
log.debug(`Shape preview shown with expansion: ${expansionValue}px, feather: ${featherValue}px at bounds (${bounds.x}, ${bounds.y})`);
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* Hide shape preview and switch back to normal mode
|
* Hide shape preview and switch back to normal mode
|
||||||
@@ -754,10 +761,16 @@ export class MaskTool {
|
|||||||
return contour;
|
return contour;
|
||||||
}
|
}
|
||||||
clear() {
|
clear() {
|
||||||
this.maskCtx.clearRect(0, 0, this.maskCanvas.width, this.maskCanvas.height);
|
// Clear all mask chunks instead of just the active canvas
|
||||||
|
this.clearAllMaskChunks();
|
||||||
|
// Update active mask canvas to reflect the cleared state
|
||||||
|
this.updateActiveMaskCanvas();
|
||||||
if (this.isActive) {
|
if (this.isActive) {
|
||||||
this.canvasInstance.canvasState.saveMaskState();
|
this.canvasInstance.canvasState.saveMaskState();
|
||||||
}
|
}
|
||||||
|
// Trigger render to show the cleared mask
|
||||||
|
this.canvasInstance.render();
|
||||||
|
log.info("Cleared all mask data from all chunks");
|
||||||
}
|
}
|
||||||
getMask() {
|
getMask() {
|
||||||
// Always return the current active mask canvas which shows all chunks
|
// Always return the current active mask canvas which shows all chunks
|
||||||
@@ -908,6 +921,21 @@ export class MaskTool {
|
|||||||
chunk.isDirty = true;
|
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})`);
|
log.debug(`Cleared area from chunk (${Math.floor(chunk.x / this.chunkSize)}, ${Math.floor(chunk.y / this.chunkSize)}) at local position (${destX}, ${destY})`);
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* Clears all mask chunks - used by the clear() function
|
||||||
|
*/
|
||||||
|
clearAllMaskChunks() {
|
||||||
|
// Clear all existing chunks
|
||||||
|
for (const [chunkKey, chunk] of this.maskChunks) {
|
||||||
|
chunk.ctx.clearRect(0, 0, this.chunkSize, this.chunkSize);
|
||||||
|
chunk.isEmpty = true;
|
||||||
|
chunk.isDirty = true;
|
||||||
|
}
|
||||||
|
// Optionally remove all chunks from memory to free up resources
|
||||||
|
this.maskChunks.clear();
|
||||||
|
this.activeChunkBounds = null;
|
||||||
|
log.info(`Cleared all ${this.maskChunks.size} mask chunks`);
|
||||||
|
}
|
||||||
addMask(image) {
|
addMask(image) {
|
||||||
// Add mask to chunks system instead of directly to active canvas
|
// Add mask to chunks system instead of directly to active canvas
|
||||||
const bounds = this.canvasInstance.outputAreaBounds;
|
const bounds = this.canvasInstance.outputAreaBounds;
|
||||||
@@ -975,6 +1003,143 @@ export class MaskTool {
|
|||||||
chunk.isEmpty = false;
|
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})`);
|
log.debug(`Added mask to chunk (${Math.floor(chunk.x / this.chunkSize)}, ${Math.floor(chunk.y / this.chunkSize)}) at local position (${destX}, ${destY})`);
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* Applies a mask canvas to the chunked system at a specific world position
|
||||||
|
*/
|
||||||
|
applyMaskCanvasToChunks(maskCanvas, worldX, worldY) {
|
||||||
|
// Calculate which chunks this mask will affect
|
||||||
|
const maskLeft = worldX;
|
||||||
|
const maskTop = worldY;
|
||||||
|
const maskRight = worldX + maskCanvas.width;
|
||||||
|
const maskBottom = worldY + maskCanvas.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);
|
||||||
|
// First, clear the area where the mask will be applied
|
||||||
|
this.clearMaskInArea(maskLeft, maskTop, maskCanvas.width, maskCanvas.height);
|
||||||
|
// Apply 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.applyMaskCanvasToChunk(chunk, maskCanvas, worldX, worldY);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log.info(`Applied mask canvas to chunks covering area (${maskLeft}, ${maskTop}) to (${maskRight}, ${maskBottom})`);
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Removes a mask canvas from the chunked system at a specific world position
|
||||||
|
*/
|
||||||
|
removeMaskCanvasFromChunks(maskCanvas, worldX, worldY) {
|
||||||
|
// Calculate which chunks this mask will affect
|
||||||
|
const maskLeft = worldX;
|
||||||
|
const maskTop = worldY;
|
||||||
|
const maskRight = worldX + maskCanvas.width;
|
||||||
|
const maskBottom = worldY + maskCanvas.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);
|
||||||
|
// Remove mask from 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.removeMaskCanvasFromChunk(chunk, maskCanvas, worldX, worldY);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log.info(`Removed mask canvas from chunks covering area (${maskLeft}, ${maskTop}) to (${maskRight}, ${maskBottom})`);
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Removes a mask canvas from a specific chunk using destination-out composition
|
||||||
|
*/
|
||||||
|
removeMaskCanvasFromChunk(chunk, maskCanvas, maskWorldX, maskWorldY) {
|
||||||
|
// 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 = maskWorldX;
|
||||||
|
const maskTop = maskWorldY;
|
||||||
|
const maskRight = maskWorldX + maskCanvas.width;
|
||||||
|
const maskBottom = maskWorldY + maskCanvas.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 canvas
|
||||||
|
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;
|
||||||
|
// Use destination-out to remove the mask portion from this chunk
|
||||||
|
chunk.ctx.globalCompositeOperation = 'destination-out';
|
||||||
|
chunk.ctx.drawImage(maskCanvas, srcX, srcY, srcWidth, srcHeight, // Source rectangle
|
||||||
|
destX, destY, srcWidth, srcHeight // Destination rectangle
|
||||||
|
);
|
||||||
|
// Restore normal composition mode
|
||||||
|
chunk.ctx.globalCompositeOperation = 'source-over';
|
||||||
|
// Check if the 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(`Removed mask canvas from chunk (${Math.floor(chunk.x / this.chunkSize)}, ${Math.floor(chunk.y / this.chunkSize)}) at local position (${destX}, ${destY})`);
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Applies a mask canvas to a specific chunk
|
||||||
|
*/
|
||||||
|
applyMaskCanvasToChunk(chunk, maskCanvas, maskWorldX, maskWorldY) {
|
||||||
|
// 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 = maskWorldX;
|
||||||
|
const maskTop = maskWorldY;
|
||||||
|
const maskRight = maskWorldX + maskCanvas.width;
|
||||||
|
const maskBottom = maskWorldY + maskCanvas.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 canvas
|
||||||
|
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(maskCanvas, 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(`Applied mask canvas to chunk (${Math.floor(chunk.x / this.chunkSize)}, ${Math.floor(chunk.y / this.chunkSize)}) at local position (${destX}, ${destY})`);
|
||||||
|
}
|
||||||
applyShapeMask(saveState = true) {
|
applyShapeMask(saveState = true) {
|
||||||
if (!this.canvasInstance.outputAreaShape?.points || this.canvasInstance.outputAreaShape.points.length < 3) {
|
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.");
|
log.warn("Cannot apply shape mask: shape is not defined or has too few points.");
|
||||||
@@ -984,65 +1149,73 @@ export class MaskTool {
|
|||||||
this.canvasInstance.canvasState.saveMaskState();
|
this.canvasInstance.canvasState.saveMaskState();
|
||||||
}
|
}
|
||||||
const shape = this.canvasInstance.outputAreaShape;
|
const shape = this.canvasInstance.outputAreaShape;
|
||||||
const destX = -this.x;
|
const bounds = this.canvasInstance.outputAreaBounds;
|
||||||
const destY = -this.y;
|
// Calculate shape points in world coordinates
|
||||||
const maskPoints = shape.points.map(p => ({ x: p.x + destX, y: p.y + destY }));
|
// Shape points are relative to the output area bounds
|
||||||
// --- Clear Previous State ---
|
const worldShapePoints = shape.points.map(p => ({
|
||||||
// To prevent artifacts from previous slider values, we first clear the maximum
|
x: bounds.x + p.x,
|
||||||
// possible area the shape could have occupied.
|
y: bounds.y + p.y
|
||||||
const maxExpansion = 300; // The maximum value of the expansion slider
|
}));
|
||||||
const clearingMaskCanvas = this._createExpandedMaskCanvas(maskPoints, maxExpansion, this.maskCanvas.width, this.maskCanvas.height);
|
// Create the shape mask canvas
|
||||||
this.maskCtx.globalCompositeOperation = 'destination-out';
|
let shapeMaskCanvas;
|
||||||
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
|
// Check if we need expansion or feathering
|
||||||
const needsExpansion = this.canvasInstance.shapeMaskExpansion && this.canvasInstance.shapeMaskExpansionValue !== 0;
|
const needsExpansion = this.canvasInstance.shapeMaskExpansion && this.canvasInstance.shapeMaskExpansionValue !== 0;
|
||||||
const needsFeather = this.canvasInstance.shapeMaskFeather && this.canvasInstance.shapeMaskFeatherValue > 0;
|
const needsFeather = this.canvasInstance.shapeMaskFeather && this.canvasInstance.shapeMaskFeatherValue > 0;
|
||||||
|
// Create a temporary canvas large enough to contain the shape and any expansion
|
||||||
|
const maxExpansion = Math.max(300, Math.abs(this.canvasInstance.shapeMaskExpansionValue || 0));
|
||||||
|
const tempCanvasWidth = bounds.width + (maxExpansion * 2);
|
||||||
|
const tempCanvasHeight = bounds.height + (maxExpansion * 2);
|
||||||
|
const tempOffsetX = maxExpansion;
|
||||||
|
const tempOffsetY = maxExpansion;
|
||||||
|
// Adjust shape points for the temporary canvas
|
||||||
|
const tempShapePoints = worldShapePoints.map(p => ({
|
||||||
|
x: p.x - bounds.x + tempOffsetX,
|
||||||
|
y: p.y - bounds.y + tempOffsetY
|
||||||
|
}));
|
||||||
if (!needsExpansion && !needsFeather) {
|
if (!needsExpansion && !needsFeather) {
|
||||||
// Simple case: just draw the original shape
|
// Simple case: just draw the original shape
|
||||||
this.maskCtx.fillStyle = 'white';
|
shapeMaskCanvas = document.createElement('canvas');
|
||||||
this.maskCtx.beginPath();
|
shapeMaskCanvas.width = tempCanvasWidth;
|
||||||
this.maskCtx.moveTo(maskPoints[0].x, maskPoints[0].y);
|
shapeMaskCanvas.height = tempCanvasHeight;
|
||||||
for (let i = 1; i < maskPoints.length; i++) {
|
const ctx = shapeMaskCanvas.getContext('2d', { willReadFrequently: true });
|
||||||
this.maskCtx.lineTo(maskPoints[i].x, maskPoints[i].y);
|
ctx.fillStyle = 'white';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(tempShapePoints[0].x, tempShapePoints[0].y);
|
||||||
|
for (let i = 1; i < tempShapePoints.length; i++) {
|
||||||
|
ctx.lineTo(tempShapePoints[i].x, tempShapePoints[i].y);
|
||||||
}
|
}
|
||||||
this.maskCtx.closePath();
|
ctx.closePath();
|
||||||
this.maskCtx.fill('evenodd'); // Use evenodd to handle holes correctly
|
ctx.fill('evenodd');
|
||||||
}
|
}
|
||||||
else if (needsExpansion && !needsFeather) {
|
else if (needsExpansion && !needsFeather) {
|
||||||
// Expansion only: use the new distance transform expansion
|
// Expansion only
|
||||||
const expandedMaskCanvas = this._createExpandedMaskCanvas(maskPoints, this.canvasInstance.shapeMaskExpansionValue, this.maskCanvas.width, this.maskCanvas.height);
|
shapeMaskCanvas = this._createExpandedMaskCanvas(tempShapePoints, this.canvasInstance.shapeMaskExpansionValue, tempCanvasWidth, tempCanvasHeight);
|
||||||
this.maskCtx.drawImage(expandedMaskCanvas, 0, 0);
|
|
||||||
}
|
}
|
||||||
else if (!needsExpansion && needsFeather) {
|
else if (!needsExpansion && needsFeather) {
|
||||||
// Feather only: apply feathering to the original shape
|
// Feather only
|
||||||
const featheredMaskCanvas = this._createFeatheredMaskCanvas(maskPoints, this.canvasInstance.shapeMaskFeatherValue, this.maskCanvas.width, this.maskCanvas.height);
|
shapeMaskCanvas = this._createFeatheredMaskCanvas(tempShapePoints, this.canvasInstance.shapeMaskFeatherValue, tempCanvasWidth, tempCanvasHeight);
|
||||||
this.maskCtx.drawImage(featheredMaskCanvas, 0, 0);
|
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
// Both expansion and feather: first expand, then apply feather to the expanded shape
|
// Both expansion and feather
|
||||||
// Step 1: Create expanded shape
|
const expandedMaskCanvas = this._createExpandedMaskCanvas(tempShapePoints, this.canvasInstance.shapeMaskExpansionValue, tempCanvasWidth, tempCanvasHeight);
|
||||||
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 tempCtx = expandedMaskCanvas.getContext('2d', { willReadFrequently: true });
|
||||||
const expandedImageData = tempCtx.getImageData(0, 0, expandedMaskCanvas.width, expandedMaskCanvas.height);
|
const expandedImageData = tempCtx.getImageData(0, 0, expandedMaskCanvas.width, expandedMaskCanvas.height);
|
||||||
// Apply feathering to the expanded shape
|
shapeMaskCanvas = this._createFeatheredMaskFromImageData(expandedImageData, this.canvasInstance.shapeMaskFeatherValue, tempCanvasWidth, tempCanvasHeight);
|
||||||
const featheredMaskCanvas = this._createFeatheredMaskFromImageData(expandedImageData, this.canvasInstance.shapeMaskFeatherValue, this.maskCanvas.width, this.maskCanvas.height);
|
|
||||||
this.maskCtx.drawImage(featheredMaskCanvas, 0, 0);
|
|
||||||
}
|
}
|
||||||
|
// Now apply the shape mask to the chunked system
|
||||||
|
this.applyMaskCanvasToChunks(shapeMaskCanvas, bounds.x - tempOffsetX, bounds.y - tempOffsetY);
|
||||||
|
// Update the active mask canvas to show the changes
|
||||||
|
this.updateActiveMaskCanvas();
|
||||||
if (this.onStateChange) {
|
if (this.onStateChange) {
|
||||||
this.onStateChange();
|
this.onStateChange();
|
||||||
}
|
}
|
||||||
this.canvasInstance.render();
|
this.canvasInstance.render();
|
||||||
log.info(`Applied shape mask with expansion: ${needsExpansion}, feather: ${needsFeather}.`);
|
log.info(`Applied shape mask to chunks with expansion: ${needsExpansion}, feather: ${needsFeather}.`);
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* Removes mask in the area of the custom output area shape. This must use a hard-edged
|
* 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.
|
* shape to correctly erase any feathered "glow" that might have been applied.
|
||||||
|
* Now works with the chunked mask system.
|
||||||
*/
|
*/
|
||||||
removeShapeMask() {
|
removeShapeMask() {
|
||||||
if (!this.canvasInstance.outputAreaShape?.points || this.canvasInstance.outputAreaShape.points.length < 3) {
|
if (!this.canvasInstance.outputAreaShape?.points || this.canvasInstance.outputAreaShape.points.length < 3) {
|
||||||
@@ -1051,36 +1224,55 @@ export class MaskTool {
|
|||||||
}
|
}
|
||||||
this.canvasInstance.canvasState.saveMaskState();
|
this.canvasInstance.canvasState.saveMaskState();
|
||||||
const shape = this.canvasInstance.outputAreaShape;
|
const shape = this.canvasInstance.outputAreaShape;
|
||||||
const destX = -this.x;
|
const bounds = this.canvasInstance.outputAreaBounds;
|
||||||
const destY = -this.y;
|
// Calculate shape points in world coordinates (same as applyShapeMask)
|
||||||
// Use 'destination-out' to erase the shape area
|
const worldShapePoints = shape.points.map(p => ({
|
||||||
this.maskCtx.globalCompositeOperation = 'destination-out';
|
x: bounds.x + p.x,
|
||||||
const maskPoints = shape.points.map(p => ({ x: p.x + destX, y: p.y + destY }));
|
y: bounds.y + p.y
|
||||||
|
}));
|
||||||
|
// Check if we need to account for expansion when removing
|
||||||
const needsExpansion = this.canvasInstance.shapeMaskExpansion && this.canvasInstance.shapeMaskExpansionValue !== 0;
|
const needsExpansion = this.canvasInstance.shapeMaskExpansion && this.canvasInstance.shapeMaskExpansionValue !== 0;
|
||||||
// IMPORTANT: Removal should always be hard-edged, even if feather was on.
|
// Create a removal mask canvas - always hard-edged to ensure complete removal
|
||||||
// This ensures the feathered "glow" is completely removed. We only care about expansion.
|
let removalMaskCanvas;
|
||||||
|
// Create a temporary canvas large enough to contain the shape and any expansion
|
||||||
|
const maxExpansion = Math.max(300, Math.abs(this.canvasInstance.shapeMaskExpansionValue || 0));
|
||||||
|
const tempCanvasWidth = bounds.width + (maxExpansion * 2);
|
||||||
|
const tempCanvasHeight = bounds.height + (maxExpansion * 2);
|
||||||
|
const tempOffsetX = maxExpansion;
|
||||||
|
const tempOffsetY = maxExpansion;
|
||||||
|
// Adjust shape points for the temporary canvas
|
||||||
|
const tempShapePoints = worldShapePoints.map(p => ({
|
||||||
|
x: p.x - bounds.x + tempOffsetX,
|
||||||
|
y: p.y - bounds.y + tempOffsetY
|
||||||
|
}));
|
||||||
if (needsExpansion) {
|
if (needsExpansion) {
|
||||||
// If expansion was active, remove the expanded area with a hard edge.
|
// 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);
|
removalMaskCanvas = this._createExpandedMaskCanvas(tempShapePoints, this.canvasInstance.shapeMaskExpansionValue, tempCanvasWidth, tempCanvasHeight);
|
||||||
this.maskCtx.drawImage(expandedMaskCanvas, 0, 0);
|
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
// If no expansion, just remove the base shape with a hard edge.
|
// If no expansion, just remove the base shape with a hard edge
|
||||||
this.maskCtx.beginPath();
|
removalMaskCanvas = document.createElement('canvas');
|
||||||
this.maskCtx.moveTo(maskPoints[0].x, maskPoints[0].y);
|
removalMaskCanvas.width = tempCanvasWidth;
|
||||||
for (let i = 1; i < maskPoints.length; i++) {
|
removalMaskCanvas.height = tempCanvasHeight;
|
||||||
this.maskCtx.lineTo(maskPoints[i].x, maskPoints[i].y);
|
const ctx = removalMaskCanvas.getContext('2d', { willReadFrequently: true });
|
||||||
|
ctx.fillStyle = 'white';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(tempShapePoints[0].x, tempShapePoints[0].y);
|
||||||
|
for (let i = 1; i < tempShapePoints.length; i++) {
|
||||||
|
ctx.lineTo(tempShapePoints[i].x, tempShapePoints[i].y);
|
||||||
}
|
}
|
||||||
this.maskCtx.closePath();
|
ctx.closePath();
|
||||||
this.maskCtx.fill('evenodd');
|
ctx.fill('evenodd');
|
||||||
}
|
}
|
||||||
// Restore default composite operation
|
// Now remove the shape mask from the chunked system
|
||||||
this.maskCtx.globalCompositeOperation = 'source-over';
|
this.removeMaskCanvasFromChunks(removalMaskCanvas, bounds.x - tempOffsetX, bounds.y - tempOffsetY);
|
||||||
|
// Update the active mask canvas to show the changes
|
||||||
|
this.updateActiveMaskCanvas();
|
||||||
if (this.onStateChange) {
|
if (this.onStateChange) {
|
||||||
this.onStateChange();
|
this.onStateChange();
|
||||||
}
|
}
|
||||||
this.canvasInstance.render();
|
this.canvasInstance.render();
|
||||||
log.info(`Removed shape mask area (hard-edged) with expansion: ${needsExpansion}.`);
|
log.info(`Removed shape mask from chunks with expansion: ${needsExpansion}.`);
|
||||||
}
|
}
|
||||||
_createFeatheredMaskCanvas(points, featherRadius, width, height) {
|
_createFeatheredMaskCanvas(points, featherRadius, width, height) {
|
||||||
// 1. Create a binary mask on a temporary canvas.
|
// 1. Create a binary mask on a temporary canvas.
|
||||||
|
|||||||
@@ -76,7 +76,9 @@ export class Canvas {
|
|||||||
outputAreaExtensions: { top: number, bottom: number, left: number, right: number };
|
outputAreaExtensions: { top: number, bottom: number, left: number, right: number };
|
||||||
outputAreaExtensionEnabled: boolean;
|
outputAreaExtensionEnabled: boolean;
|
||||||
outputAreaExtensionPreview: { top: number, bottom: number, left: number, right: number } | null;
|
outputAreaExtensionPreview: { top: number, bottom: number, left: number, right: number } | null;
|
||||||
|
lastOutputAreaExtensions: { top: number, bottom: number, left: number, right: number };
|
||||||
originalCanvasSize: { width: number, height: number };
|
originalCanvasSize: { width: number, height: number };
|
||||||
|
originalOutputAreaPosition: { x: number, y: number };
|
||||||
outputAreaBounds: OutputAreaBounds;
|
outputAreaBounds: OutputAreaBounds;
|
||||||
node: ComfyNode;
|
node: ComfyNode;
|
||||||
offscreenCanvas: HTMLCanvasElement;
|
offscreenCanvas: HTMLCanvasElement;
|
||||||
@@ -131,7 +133,9 @@ export class Canvas {
|
|||||||
this.outputAreaExtensions = { top: 0, bottom: 0, left: 0, right: 0 };
|
this.outputAreaExtensions = { top: 0, bottom: 0, left: 0, right: 0 };
|
||||||
this.outputAreaExtensionEnabled = false;
|
this.outputAreaExtensionEnabled = false;
|
||||||
this.outputAreaExtensionPreview = null;
|
this.outputAreaExtensionPreview = null;
|
||||||
|
this.lastOutputAreaExtensions = { top: 0, bottom: 0, left: 0, right: 0 };
|
||||||
this.originalCanvasSize = { width: this.width, height: this.height };
|
this.originalCanvasSize = { width: this.width, height: this.height };
|
||||||
|
this.originalOutputAreaPosition = { x: -(this.width / 4), y: -(this.height / 4) };
|
||||||
// Initialize outputAreaBounds centered in viewport, similar to how canvas resize/move work
|
// Initialize outputAreaBounds centered in viewport, similar to how canvas resize/move work
|
||||||
this.outputAreaBounds = {
|
this.outputAreaBounds = {
|
||||||
x: -(this.width / 4),
|
x: -(this.width / 4),
|
||||||
@@ -416,44 +420,7 @@ export class Canvas {
|
|||||||
}
|
}
|
||||||
|
|
||||||
defineOutputAreaWithShape(shape: Shape): void {
|
defineOutputAreaWithShape(shape: Shape): void {
|
||||||
const boundingBox = this.shapeTool.getBoundingBox();
|
this.canvasInteractions.defineOutputAreaWithShape(shape);
|
||||||
if (boundingBox && boundingBox.width > 1 && boundingBox.height > 1) {
|
|
||||||
this.saveState();
|
|
||||||
|
|
||||||
this.outputAreaShape = {
|
|
||||||
...shape,
|
|
||||||
points: shape.points.map(p => ({
|
|
||||||
x: p.x - boundingBox.x,
|
|
||||||
y: p.y - boundingBox.y
|
|
||||||
}))
|
|
||||||
};
|
|
||||||
|
|
||||||
const newWidth = Math.round(boundingBox.width);
|
|
||||||
const newHeight = Math.round(boundingBox.height);
|
|
||||||
const newX = Math.round(boundingBox.x);
|
|
||||||
const newY = Math.round(boundingBox.y);
|
|
||||||
|
|
||||||
// Store the original canvas size for extension calculations
|
|
||||||
this.originalCanvasSize = { width: newWidth, height: newHeight };
|
|
||||||
|
|
||||||
// Update canvas size but don't change outputAreaBounds yet
|
|
||||||
this.updateOutputAreaSize(newWidth, newHeight, false);
|
|
||||||
|
|
||||||
// Set outputAreaBounds to where the custom shape was drawn in the world
|
|
||||||
// Similar to finalizeCanvasMove - just update outputAreaBounds position
|
|
||||||
this.outputAreaBounds = {
|
|
||||||
x: newX,
|
|
||||||
y: newY,
|
|
||||||
width: newWidth,
|
|
||||||
height: newHeight
|
|
||||||
};
|
|
||||||
|
|
||||||
// Update mask canvas to ensure it covers the new output area position
|
|
||||||
this.maskTool.updateMaskCanvasForOutputArea();
|
|
||||||
|
|
||||||
this.saveState();
|
|
||||||
this.render();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -775,12 +775,18 @@ export class CanvasIO {
|
|||||||
// Draw the image first
|
// Draw the image first
|
||||||
ctx.drawImage(image, 0, 0);
|
ctx.drawImage(image, 0, 0);
|
||||||
|
|
||||||
// Create a clipping mask using the shape
|
// Calculate custom shape position accounting for extensions
|
||||||
|
// Custom shape should maintain its relative position within the original canvas area
|
||||||
|
const ext = this.canvas.outputAreaExtensionEnabled ? this.canvas.outputAreaExtensions : { top: 0, bottom: 0, left: 0, right: 0 };
|
||||||
|
const shapeOffsetX = ext.left; // Add left extension to maintain relative position
|
||||||
|
const shapeOffsetY = ext.top; // Add top extension to maintain relative position
|
||||||
|
|
||||||
|
// Create a clipping mask using the shape with extension offset
|
||||||
ctx.globalCompositeOperation = 'destination-in';
|
ctx.globalCompositeOperation = 'destination-in';
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.moveTo(shape.points[0].x, shape.points[0].y);
|
ctx.moveTo(shape.points[0].x + shapeOffsetX, shape.points[0].y + shapeOffsetY);
|
||||||
for (let i = 1; i < shape.points.length; i++) {
|
for (let i = 1; i < shape.points.length; i++) {
|
||||||
ctx.lineTo(shape.points[i].x, shape.points[i].y);
|
ctx.lineTo(shape.points[i].x + shapeOffsetX, shape.points[i].y + shapeOffsetY);
|
||||||
}
|
}
|
||||||
ctx.closePath();
|
ctx.closePath();
|
||||||
ctx.fill();
|
ctx.fill();
|
||||||
|
|||||||
@@ -902,6 +902,70 @@ export class CanvasInteractions {
|
|||||||
reader.readAsDataURL(file);
|
reader.readAsDataURL(file);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
defineOutputAreaWithShape(shape: any): void {
|
||||||
|
const boundingBox = this.canvas.shapeTool.getBoundingBox();
|
||||||
|
if (boundingBox && boundingBox.width > 1 && boundingBox.height > 1) {
|
||||||
|
this.canvas.saveState();
|
||||||
|
|
||||||
|
this.canvas.outputAreaShape = {
|
||||||
|
...shape,
|
||||||
|
points: shape.points.map((p: any) => ({
|
||||||
|
x: p.x - boundingBox.x,
|
||||||
|
y: p.y - boundingBox.y
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
|
||||||
|
const newWidth = Math.round(boundingBox.width);
|
||||||
|
const newHeight = Math.round(boundingBox.height);
|
||||||
|
const newX = Math.round(boundingBox.x);
|
||||||
|
const newY = Math.round(boundingBox.y);
|
||||||
|
|
||||||
|
// Store the original canvas size for extension calculations
|
||||||
|
this.canvas.originalCanvasSize = { width: newWidth, height: newHeight };
|
||||||
|
|
||||||
|
// Store the original position where custom shape was drawn for extension calculations
|
||||||
|
this.canvas.originalOutputAreaPosition = { x: newX, y: newY };
|
||||||
|
|
||||||
|
// If extensions are enabled, we need to recalculate outputAreaBounds with current extensions
|
||||||
|
if (this.canvas.outputAreaExtensionEnabled) {
|
||||||
|
const ext = this.canvas.outputAreaExtensions;
|
||||||
|
const extendedWidth = newWidth + ext.left + ext.right;
|
||||||
|
const extendedHeight = newHeight + ext.top + ext.bottom;
|
||||||
|
|
||||||
|
// Update canvas size with extensions
|
||||||
|
this.canvas.updateOutputAreaSize(extendedWidth, extendedHeight, false);
|
||||||
|
|
||||||
|
// Set outputAreaBounds accounting for extensions
|
||||||
|
this.canvas.outputAreaBounds = {
|
||||||
|
x: newX - ext.left, // Adjust position by left extension
|
||||||
|
y: newY - ext.top, // Adjust position by top extension
|
||||||
|
width: extendedWidth,
|
||||||
|
height: extendedHeight
|
||||||
|
};
|
||||||
|
|
||||||
|
log.info(`New custom shape with extensions: original(${newX}, ${newY}) extended(${newX - ext.left}, ${newY - ext.top}) size(${extendedWidth}x${extendedHeight})`);
|
||||||
|
} else {
|
||||||
|
// No extensions - use original size and position
|
||||||
|
this.canvas.updateOutputAreaSize(newWidth, newHeight, false);
|
||||||
|
|
||||||
|
this.canvas.outputAreaBounds = {
|
||||||
|
x: newX,
|
||||||
|
y: newY,
|
||||||
|
width: newWidth,
|
||||||
|
height: newHeight
|
||||||
|
};
|
||||||
|
|
||||||
|
log.info(`New custom shape without extensions: position(${newX}, ${newY}) size(${newWidth}x${newHeight})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update mask canvas to ensure it covers the new output area position
|
||||||
|
this.canvas.maskTool.updateMaskCanvasForOutputArea();
|
||||||
|
|
||||||
|
this.canvas.saveState();
|
||||||
|
this.canvas.render();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async handlePasteEvent(e: ClipboardEvent): Promise<void> {
|
async handlePasteEvent(e: ClipboardEvent): Promise<void> {
|
||||||
|
|
||||||
const shouldHandle = this.canvas.isMouseOver ||
|
const shouldHandle = this.canvas.isMouseOver ||
|
||||||
|
|||||||
@@ -323,11 +323,17 @@ export class CanvasRenderer {
|
|||||||
const shape = this.canvas.outputAreaShape;
|
const shape = this.canvas.outputAreaShape;
|
||||||
const bounds = this.canvas.outputAreaBounds;
|
const bounds = this.canvas.outputAreaBounds;
|
||||||
|
|
||||||
|
// Calculate custom shape position accounting for extensions
|
||||||
|
// Custom shape should maintain its relative position within the original canvas area
|
||||||
|
const ext = this.canvas.outputAreaExtensionEnabled ? this.canvas.outputAreaExtensions : { top: 0, bottom: 0, left: 0, right: 0 };
|
||||||
|
const shapeOffsetX = bounds.x + ext.left; // Add left extension to maintain relative position
|
||||||
|
const shapeOffsetY = bounds.y + ext.top; // Add top extension to maintain relative position
|
||||||
|
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
// Render custom shape relative to outputAreaBounds, not (0,0)
|
// Render custom shape with extension offset to maintain relative position
|
||||||
ctx.moveTo(bounds.x + shape.points[0].x, bounds.y + shape.points[0].y);
|
ctx.moveTo(shapeOffsetX + shape.points[0].x, shapeOffsetY + shape.points[0].y);
|
||||||
for (let i = 1; i < shape.points.length; i++) {
|
for (let i = 1; i < shape.points.length; i++) {
|
||||||
ctx.lineTo(bounds.x + shape.points[i].x, bounds.y + shape.points[i].y);
|
ctx.lineTo(shapeOffsetX + shape.points[i].x, shapeOffsetY + shape.points[i].y);
|
||||||
}
|
}
|
||||||
ctx.closePath();
|
ctx.closePath();
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
@@ -379,10 +385,11 @@ export class CanvasRenderer {
|
|||||||
|
|
||||||
const ext = this.canvas.outputAreaExtensionPreview;
|
const ext = this.canvas.outputAreaExtensionPreview;
|
||||||
|
|
||||||
// Podgląd pokazuje jak będą wyglądać nowe outputAreaBounds
|
// Calculate preview bounds relative to original custom shape position, not (0,0)
|
||||||
|
const originalPos = this.canvas.originalOutputAreaPosition;
|
||||||
const previewBounds = {
|
const previewBounds = {
|
||||||
x: -ext.left, // Może być ujemne - wycinamy fragment świata
|
x: originalPos.x - ext.left, // ✅ Względem oryginalnej pozycji custom shape
|
||||||
y: -ext.top, // Może być ujemne - wycinamy fragment świata
|
y: originalPos.y - ext.top, // ✅ Względem oryginalnej pozycji custom shape
|
||||||
width: baseWidth + ext.left + ext.right,
|
width: baseWidth + ext.left + ext.right,
|
||||||
height: baseHeight + ext.top + ext.bottom
|
height: baseHeight + ext.top + ext.bottom
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -347,10 +347,16 @@ export class CustomShapeMenu {
|
|||||||
width: this.canvas.width,
|
width: this.canvas.width,
|
||||||
height: this.canvas.height
|
height: this.canvas.height
|
||||||
};
|
};
|
||||||
|
// Restore last saved extensions instead of starting from zero
|
||||||
|
this.canvas.outputAreaExtensions = { ...this.canvas.lastOutputAreaExtensions };
|
||||||
log.info(`Captured current canvas size as baseline: ${this.canvas.width}x${this.canvas.height}`);
|
log.info(`Captured current canvas size as baseline: ${this.canvas.width}x${this.canvas.height}`);
|
||||||
|
log.info(`Restored last extensions:`, this.canvas.outputAreaExtensions);
|
||||||
} else {
|
} else {
|
||||||
// Reset all extensions when disabled
|
// Save current extensions before disabling
|
||||||
|
this.canvas.lastOutputAreaExtensions = { ...this.canvas.outputAreaExtensions };
|
||||||
|
// Reset current extensions when disabled (but keep the saved ones)
|
||||||
this.canvas.outputAreaExtensions = { top: 0, bottom: 0, left: 0, right: 0 };
|
this.canvas.outputAreaExtensions = { top: 0, bottom: 0, left: 0, right: 0 };
|
||||||
|
log.info(`Saved extensions for later:`, this.canvas.lastOutputAreaExtensions);
|
||||||
}
|
}
|
||||||
|
|
||||||
this._updateExtensionUI();
|
this._updateExtensionUI();
|
||||||
@@ -729,12 +735,12 @@ export class CustomShapeMenu {
|
|||||||
|
|
||||||
public _updateCanvasSize(): void {
|
public _updateCanvasSize(): void {
|
||||||
if (!this.canvas.outputAreaExtensionEnabled) {
|
if (!this.canvas.outputAreaExtensionEnabled) {
|
||||||
// When extensions are disabled, preserve the current outputAreaBounds position
|
// When extensions are disabled, return to original custom shape position
|
||||||
// Only update the size to match originalCanvasSize
|
// Use originalOutputAreaPosition instead of current bounds position
|
||||||
const currentBounds = this.canvas.outputAreaBounds;
|
const originalPos = this.canvas.originalOutputAreaPosition;
|
||||||
this.canvas.outputAreaBounds = {
|
this.canvas.outputAreaBounds = {
|
||||||
x: currentBounds.x, // ✅ Preserve current position
|
x: originalPos.x, // ✅ Return to original custom shape position
|
||||||
y: currentBounds.y, // ✅ Preserve current position
|
y: originalPos.y, // ✅ Return to original custom shape position
|
||||||
width: this.canvas.originalCanvasSize.width,
|
width: this.canvas.originalCanvasSize.width,
|
||||||
height: this.canvas.originalCanvasSize.height
|
height: this.canvas.originalCanvasSize.height
|
||||||
};
|
};
|
||||||
@@ -750,11 +756,11 @@ export class CustomShapeMenu {
|
|||||||
const newWidth = this.canvas.originalCanvasSize.width + ext.left + ext.right;
|
const newWidth = this.canvas.originalCanvasSize.width + ext.left + ext.right;
|
||||||
const newHeight = this.canvas.originalCanvasSize.height + ext.top + ext.bottom;
|
const newHeight = this.canvas.originalCanvasSize.height + ext.top + ext.bottom;
|
||||||
|
|
||||||
// When extensions are enabled, calculate new bounds relative to current position
|
// When extensions are enabled, calculate new bounds relative to original custom shape position
|
||||||
const currentBounds = this.canvas.outputAreaBounds;
|
const originalPos = this.canvas.originalOutputAreaPosition;
|
||||||
this.canvas.outputAreaBounds = {
|
this.canvas.outputAreaBounds = {
|
||||||
x: currentBounds.x - ext.left, // Adjust position by left extension
|
x: originalPos.x - ext.left, // Adjust position by left extension from original position
|
||||||
y: currentBounds.y - ext.top, // Adjust position by top extension
|
y: originalPos.y - ext.top, // Adjust position by top extension from original position
|
||||||
width: newWidth,
|
width: newWidth,
|
||||||
height: newHeight
|
height: newHeight
|
||||||
};
|
};
|
||||||
|
|||||||
377
src/MaskTool.ts
377
src/MaskTool.ts
@@ -591,8 +591,16 @@ export class MaskTool {
|
|||||||
|
|
||||||
const shape = this.canvasInstance.outputAreaShape;
|
const shape = this.canvasInstance.outputAreaShape;
|
||||||
const viewport = this.canvasInstance.viewport;
|
const viewport = this.canvasInstance.viewport;
|
||||||
|
const bounds = this.canvasInstance.outputAreaBounds;
|
||||||
|
|
||||||
const screenPoints = shape.points.map(p => ({
|
// Convert shape points to world coordinates first (relative to output area bounds)
|
||||||
|
const worldShapePoints = shape.points.map(p => ({
|
||||||
|
x: bounds.x + p.x,
|
||||||
|
y: bounds.y + p.y
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Then convert world coordinates to screen coordinates
|
||||||
|
const screenPoints = worldShapePoints.map(p => ({
|
||||||
x: (p.x - viewport.x) * viewport.zoom,
|
x: (p.x - viewport.x) * viewport.zoom,
|
||||||
y: (p.y - viewport.y) * viewport.zoom
|
y: (p.y - viewport.y) * viewport.zoom
|
||||||
}));
|
}));
|
||||||
@@ -638,7 +646,7 @@ export class MaskTool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
log.debug(`Shape preview shown with expansion: ${expansionValue}px, feather: ${featherValue}px`);
|
log.debug(`Shape preview shown with expansion: ${expansionValue}px, feather: ${featherValue}px at bounds (${bounds.x}, ${bounds.y})`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -915,10 +923,20 @@ export class MaskTool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
clear(): void {
|
clear(): void {
|
||||||
this.maskCtx.clearRect(0, 0, this.maskCanvas.width, this.maskCanvas.height);
|
// Clear all mask chunks instead of just the active canvas
|
||||||
|
this.clearAllMaskChunks();
|
||||||
|
|
||||||
|
// Update active mask canvas to reflect the cleared state
|
||||||
|
this.updateActiveMaskCanvas();
|
||||||
|
|
||||||
if (this.isActive) {
|
if (this.isActive) {
|
||||||
this.canvasInstance.canvasState.saveMaskState();
|
this.canvasInstance.canvasState.saveMaskState();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Trigger render to show the cleared mask
|
||||||
|
this.canvasInstance.render();
|
||||||
|
|
||||||
|
log.info("Cleared all mask data from all chunks");
|
||||||
}
|
}
|
||||||
|
|
||||||
getMask(): HTMLCanvasElement {
|
getMask(): HTMLCanvasElement {
|
||||||
@@ -1102,6 +1120,24 @@ export class MaskTool {
|
|||||||
log.debug(`Cleared area from chunk (${Math.floor(chunk.x / this.chunkSize)}, ${Math.floor(chunk.y / this.chunkSize)}) at local position (${destX}, ${destY})`);
|
log.debug(`Cleared area from chunk (${Math.floor(chunk.x / this.chunkSize)}, ${Math.floor(chunk.y / this.chunkSize)}) at local position (${destX}, ${destY})`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clears all mask chunks - used by the clear() function
|
||||||
|
*/
|
||||||
|
private clearAllMaskChunks(): void {
|
||||||
|
// Clear all existing chunks
|
||||||
|
for (const [chunkKey, chunk] of this.maskChunks) {
|
||||||
|
chunk.ctx.clearRect(0, 0, this.chunkSize, this.chunkSize);
|
||||||
|
chunk.isEmpty = true;
|
||||||
|
chunk.isDirty = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optionally remove all chunks from memory to free up resources
|
||||||
|
this.maskChunks.clear();
|
||||||
|
this.activeChunkBounds = null;
|
||||||
|
|
||||||
|
log.info(`Cleared all ${this.maskChunks.size} mask chunks`);
|
||||||
|
}
|
||||||
|
|
||||||
addMask(image: HTMLImageElement): void {
|
addMask(image: HTMLImageElement): void {
|
||||||
// Add mask to chunks system instead of directly to active canvas
|
// Add mask to chunks system instead of directly to active canvas
|
||||||
const bounds = this.canvasInstance.outputAreaBounds;
|
const bounds = this.canvasInstance.outputAreaBounds;
|
||||||
@@ -1186,6 +1222,176 @@ export class MaskTool {
|
|||||||
log.debug(`Added mask to chunk (${Math.floor(chunk.x / this.chunkSize)}, ${Math.floor(chunk.y / this.chunkSize)}) at local position (${destX}, ${destY})`);
|
log.debug(`Added mask to chunk (${Math.floor(chunk.x / this.chunkSize)}, ${Math.floor(chunk.y / this.chunkSize)}) at local position (${destX}, ${destY})`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies a mask canvas to the chunked system at a specific world position
|
||||||
|
*/
|
||||||
|
private applyMaskCanvasToChunks(maskCanvas: HTMLCanvasElement, worldX: number, worldY: number): void {
|
||||||
|
// Calculate which chunks this mask will affect
|
||||||
|
const maskLeft = worldX;
|
||||||
|
const maskTop = worldY;
|
||||||
|
const maskRight = worldX + maskCanvas.width;
|
||||||
|
const maskBottom = worldY + maskCanvas.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);
|
||||||
|
|
||||||
|
// First, clear the area where the mask will be applied
|
||||||
|
this.clearMaskInArea(maskLeft, maskTop, maskCanvas.width, maskCanvas.height);
|
||||||
|
|
||||||
|
// Apply 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.applyMaskCanvasToChunk(chunk, maskCanvas, worldX, worldY);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info(`Applied mask canvas to chunks covering area (${maskLeft}, ${maskTop}) to (${maskRight}, ${maskBottom})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes a mask canvas from the chunked system at a specific world position
|
||||||
|
*/
|
||||||
|
private removeMaskCanvasFromChunks(maskCanvas: HTMLCanvasElement, worldX: number, worldY: number): void {
|
||||||
|
// Calculate which chunks this mask will affect
|
||||||
|
const maskLeft = worldX;
|
||||||
|
const maskTop = worldY;
|
||||||
|
const maskRight = worldX + maskCanvas.width;
|
||||||
|
const maskBottom = worldY + maskCanvas.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);
|
||||||
|
|
||||||
|
// Remove mask from 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.removeMaskCanvasFromChunk(chunk, maskCanvas, worldX, worldY);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info(`Removed mask canvas from chunks covering area (${maskLeft}, ${maskTop}) to (${maskRight}, ${maskBottom})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes a mask canvas from a specific chunk using destination-out composition
|
||||||
|
*/
|
||||||
|
private removeMaskCanvasFromChunk(chunk: MaskChunk, maskCanvas: HTMLCanvasElement, maskWorldX: number, maskWorldY: number): void {
|
||||||
|
// Calculate the intersection of the mask with this chunk
|
||||||
|
const chunkLeft = chunk.x;
|
||||||
|
const chunkTop = chunk.y;
|
||||||
|
const chunkRight = chunk.x + this.chunkSize;
|
||||||
|
const chunkBottom = chunk.y + this.chunkSize;
|
||||||
|
|
||||||
|
const maskLeft = maskWorldX;
|
||||||
|
const maskTop = maskWorldY;
|
||||||
|
const maskRight = maskWorldX + maskCanvas.width;
|
||||||
|
const maskBottom = maskWorldY + maskCanvas.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 canvas
|
||||||
|
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;
|
||||||
|
|
||||||
|
// Use destination-out to remove the mask portion from this chunk
|
||||||
|
chunk.ctx.globalCompositeOperation = 'destination-out';
|
||||||
|
chunk.ctx.drawImage(
|
||||||
|
maskCanvas,
|
||||||
|
srcX, srcY, srcWidth, srcHeight, // Source rectangle
|
||||||
|
destX, destY, srcWidth, srcHeight // Destination rectangle
|
||||||
|
);
|
||||||
|
|
||||||
|
// Restore normal composition mode
|
||||||
|
chunk.ctx.globalCompositeOperation = 'source-over';
|
||||||
|
|
||||||
|
// Check if the 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(`Removed mask canvas from chunk (${Math.floor(chunk.x / this.chunkSize)}, ${Math.floor(chunk.y / this.chunkSize)}) at local position (${destX}, ${destY})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies a mask canvas to a specific chunk
|
||||||
|
*/
|
||||||
|
private applyMaskCanvasToChunk(chunk: MaskChunk, maskCanvas: HTMLCanvasElement, maskWorldX: number, maskWorldY: number): void {
|
||||||
|
// Calculate the intersection of the mask with this chunk
|
||||||
|
const chunkLeft = chunk.x;
|
||||||
|
const chunkTop = chunk.y;
|
||||||
|
const chunkRight = chunk.x + this.chunkSize;
|
||||||
|
const chunkBottom = chunk.y + this.chunkSize;
|
||||||
|
|
||||||
|
const maskLeft = maskWorldX;
|
||||||
|
const maskTop = maskWorldY;
|
||||||
|
const maskRight = maskWorldX + maskCanvas.width;
|
||||||
|
const maskBottom = maskWorldY + maskCanvas.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 canvas
|
||||||
|
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(
|
||||||
|
maskCanvas,
|
||||||
|
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(`Applied mask canvas to chunk (${Math.floor(chunk.x / this.chunkSize)}, ${Math.floor(chunk.y / this.chunkSize)}) at local position (${destX}, ${destY})`);
|
||||||
|
}
|
||||||
|
|
||||||
applyShapeMask(saveState: boolean = true): void {
|
applyShapeMask(saveState: boolean = true): void {
|
||||||
if (!this.canvasInstance.outputAreaShape?.points || this.canvasInstance.outputAreaShape.points.length < 3) {
|
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.");
|
log.warn("Cannot apply shape mask: shape is not defined or has too few points.");
|
||||||
@@ -1196,72 +1402,81 @@ export class MaskTool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const shape = this.canvasInstance.outputAreaShape;
|
const shape = this.canvasInstance.outputAreaShape;
|
||||||
const destX = -this.x;
|
const bounds = this.canvasInstance.outputAreaBounds;
|
||||||
const destY = -this.y;
|
|
||||||
|
|
||||||
const maskPoints = shape.points.map(p => ({ x: p.x + destX, y: p.y + destY }));
|
// Calculate shape points in world coordinates
|
||||||
|
// Shape points are relative to the output area bounds
|
||||||
|
const worldShapePoints = shape.points.map(p => ({
|
||||||
|
x: bounds.x + p.x,
|
||||||
|
y: bounds.y + p.y
|
||||||
|
}));
|
||||||
|
|
||||||
// --- Clear Previous State ---
|
// Create the shape mask canvas
|
||||||
// To prevent artifacts from previous slider values, we first clear the maximum
|
let shapeMaskCanvas: HTMLCanvasElement;
|
||||||
// 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
|
// Check if we need expansion or feathering
|
||||||
const needsExpansion = this.canvasInstance.shapeMaskExpansion && this.canvasInstance.shapeMaskExpansionValue !== 0;
|
const needsExpansion = this.canvasInstance.shapeMaskExpansion && this.canvasInstance.shapeMaskExpansionValue !== 0;
|
||||||
const needsFeather = this.canvasInstance.shapeMaskFeather && this.canvasInstance.shapeMaskFeatherValue > 0;
|
const needsFeather = this.canvasInstance.shapeMaskFeather && this.canvasInstance.shapeMaskFeatherValue > 0;
|
||||||
|
|
||||||
|
// Create a temporary canvas large enough to contain the shape and any expansion
|
||||||
|
const maxExpansion = Math.max(300, Math.abs(this.canvasInstance.shapeMaskExpansionValue || 0));
|
||||||
|
const tempCanvasWidth = bounds.width + (maxExpansion * 2);
|
||||||
|
const tempCanvasHeight = bounds.height + (maxExpansion * 2);
|
||||||
|
const tempOffsetX = maxExpansion;
|
||||||
|
const tempOffsetY = maxExpansion;
|
||||||
|
|
||||||
|
// Adjust shape points for the temporary canvas
|
||||||
|
const tempShapePoints = worldShapePoints.map(p => ({
|
||||||
|
x: p.x - bounds.x + tempOffsetX,
|
||||||
|
y: p.y - bounds.y + tempOffsetY
|
||||||
|
}));
|
||||||
|
|
||||||
if (!needsExpansion && !needsFeather) {
|
if (!needsExpansion && !needsFeather) {
|
||||||
// Simple case: just draw the original shape
|
// Simple case: just draw the original shape
|
||||||
this.maskCtx.fillStyle = 'white';
|
shapeMaskCanvas = document.createElement('canvas');
|
||||||
this.maskCtx.beginPath();
|
shapeMaskCanvas.width = tempCanvasWidth;
|
||||||
this.maskCtx.moveTo(maskPoints[0].x, maskPoints[0].y);
|
shapeMaskCanvas.height = tempCanvasHeight;
|
||||||
for (let i = 1; i < maskPoints.length; i++) {
|
const ctx = shapeMaskCanvas.getContext('2d', { willReadFrequently: true })!;
|
||||||
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
|
ctx.fillStyle = 'white';
|
||||||
// For now, we'll apply feathering to the expanded canvas directly
|
ctx.beginPath();
|
||||||
// This is a simplified approach - we could extract the outline points for more precision
|
ctx.moveTo(tempShapePoints[0].x, tempShapePoints[0].y);
|
||||||
|
for (let i = 1; i < tempShapePoints.length; i++) {
|
||||||
|
ctx.lineTo(tempShapePoints[i].x, tempShapePoints[i].y);
|
||||||
|
}
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fill('evenodd');
|
||||||
|
} else if (needsExpansion && !needsFeather) {
|
||||||
|
// Expansion only
|
||||||
|
shapeMaskCanvas = this._createExpandedMaskCanvas(tempShapePoints, this.canvasInstance.shapeMaskExpansionValue, tempCanvasWidth, tempCanvasHeight);
|
||||||
|
} else if (!needsExpansion && needsFeather) {
|
||||||
|
// Feather only
|
||||||
|
shapeMaskCanvas = this._createFeatheredMaskCanvas(tempShapePoints, this.canvasInstance.shapeMaskFeatherValue, tempCanvasWidth, tempCanvasHeight);
|
||||||
|
} else {
|
||||||
|
// Both expansion and feather
|
||||||
|
const expandedMaskCanvas = this._createExpandedMaskCanvas(tempShapePoints, this.canvasInstance.shapeMaskExpansionValue, tempCanvasWidth, tempCanvasHeight);
|
||||||
const tempCtx = expandedMaskCanvas.getContext('2d', { willReadFrequently: true })!;
|
const tempCtx = expandedMaskCanvas.getContext('2d', { willReadFrequently: true })!;
|
||||||
const expandedImageData = tempCtx.getImageData(0, 0, expandedMaskCanvas.width, expandedMaskCanvas.height);
|
const expandedImageData = tempCtx.getImageData(0, 0, expandedMaskCanvas.width, expandedMaskCanvas.height);
|
||||||
|
shapeMaskCanvas = this._createFeatheredMaskFromImageData(expandedImageData, this.canvasInstance.shapeMaskFeatherValue, tempCanvasWidth, tempCanvasHeight);
|
||||||
// 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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Now apply the shape mask to the chunked system
|
||||||
|
this.applyMaskCanvasToChunks(shapeMaskCanvas, bounds.x - tempOffsetX, bounds.y - tempOffsetY);
|
||||||
|
|
||||||
|
// Update the active mask canvas to show the changes
|
||||||
|
this.updateActiveMaskCanvas();
|
||||||
|
|
||||||
if (this.onStateChange) {
|
if (this.onStateChange) {
|
||||||
this.onStateChange();
|
this.onStateChange();
|
||||||
}
|
}
|
||||||
this.canvasInstance.render();
|
this.canvasInstance.render();
|
||||||
log.info(`Applied shape mask with expansion: ${needsExpansion}, feather: ${needsFeather}.`);
|
log.info(`Applied shape mask to chunks with expansion: ${needsExpansion}, feather: ${needsFeather}.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Removes mask in the area of the custom output area shape. This must use a hard-edged
|
* 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.
|
* shape to correctly erase any feathered "glow" that might have been applied.
|
||||||
|
* Now works with the chunked mask system.
|
||||||
*/
|
*/
|
||||||
removeShapeMask(): void {
|
removeShapeMask(): void {
|
||||||
if (!this.canvasInstance.outputAreaShape?.points || this.canvasInstance.outputAreaShape.points.length < 3) {
|
if (!this.canvasInstance.outputAreaShape?.points || this.canvasInstance.outputAreaShape.points.length < 3) {
|
||||||
@@ -1272,45 +1487,69 @@ export class MaskTool {
|
|||||||
this.canvasInstance.canvasState.saveMaskState();
|
this.canvasInstance.canvasState.saveMaskState();
|
||||||
|
|
||||||
const shape = this.canvasInstance.outputAreaShape;
|
const shape = this.canvasInstance.outputAreaShape;
|
||||||
const destX = -this.x;
|
const bounds = this.canvasInstance.outputAreaBounds;
|
||||||
const destY = -this.y;
|
|
||||||
|
|
||||||
// Use 'destination-out' to erase the shape area
|
// Calculate shape points in world coordinates (same as applyShapeMask)
|
||||||
this.maskCtx.globalCompositeOperation = 'destination-out';
|
const worldShapePoints = shape.points.map(p => ({
|
||||||
|
x: bounds.x + p.x,
|
||||||
|
y: bounds.y + p.y
|
||||||
|
}));
|
||||||
|
|
||||||
const maskPoints = shape.points.map(p => ({ x: p.x + destX, y: p.y + destY }));
|
// Check if we need to account for expansion when removing
|
||||||
const needsExpansion = this.canvasInstance.shapeMaskExpansion && this.canvasInstance.shapeMaskExpansionValue !== 0;
|
const needsExpansion = this.canvasInstance.shapeMaskExpansion && this.canvasInstance.shapeMaskExpansionValue !== 0;
|
||||||
|
|
||||||
// IMPORTANT: Removal should always be hard-edged, even if feather was on.
|
// Create a removal mask canvas - always hard-edged to ensure complete removal
|
||||||
// This ensures the feathered "glow" is completely removed. We only care about expansion.
|
let removalMaskCanvas: HTMLCanvasElement;
|
||||||
|
|
||||||
|
// Create a temporary canvas large enough to contain the shape and any expansion
|
||||||
|
const maxExpansion = Math.max(300, Math.abs(this.canvasInstance.shapeMaskExpansionValue || 0));
|
||||||
|
const tempCanvasWidth = bounds.width + (maxExpansion * 2);
|
||||||
|
const tempCanvasHeight = bounds.height + (maxExpansion * 2);
|
||||||
|
const tempOffsetX = maxExpansion;
|
||||||
|
const tempOffsetY = maxExpansion;
|
||||||
|
|
||||||
|
// Adjust shape points for the temporary canvas
|
||||||
|
const tempShapePoints = worldShapePoints.map(p => ({
|
||||||
|
x: p.x - bounds.x + tempOffsetX,
|
||||||
|
y: p.y - bounds.y + tempOffsetY
|
||||||
|
}));
|
||||||
|
|
||||||
if (needsExpansion) {
|
if (needsExpansion) {
|
||||||
// If expansion was active, remove the expanded area with a hard edge.
|
// If expansion was active, remove the expanded area with a hard edge
|
||||||
const expandedMaskCanvas = this._createExpandedMaskCanvas(
|
removalMaskCanvas = this._createExpandedMaskCanvas(
|
||||||
maskPoints,
|
tempShapePoints,
|
||||||
this.canvasInstance.shapeMaskExpansionValue,
|
this.canvasInstance.shapeMaskExpansionValue,
|
||||||
this.maskCanvas.width,
|
tempCanvasWidth,
|
||||||
this.maskCanvas.height
|
tempCanvasHeight
|
||||||
);
|
);
|
||||||
this.maskCtx.drawImage(expandedMaskCanvas, 0, 0);
|
|
||||||
} else {
|
} else {
|
||||||
// If no expansion, just remove the base shape with a hard edge.
|
// If no expansion, just remove the base shape with a hard edge
|
||||||
this.maskCtx.beginPath();
|
removalMaskCanvas = document.createElement('canvas');
|
||||||
this.maskCtx.moveTo(maskPoints[0].x, maskPoints[0].y);
|
removalMaskCanvas.width = tempCanvasWidth;
|
||||||
for (let i = 1; i < maskPoints.length; i++) {
|
removalMaskCanvas.height = tempCanvasHeight;
|
||||||
this.maskCtx.lineTo(maskPoints[i].x, maskPoints[i].y);
|
const ctx = removalMaskCanvas.getContext('2d', { willReadFrequently: true })!;
|
||||||
|
|
||||||
|
ctx.fillStyle = 'white';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(tempShapePoints[0].x, tempShapePoints[0].y);
|
||||||
|
for (let i = 1; i < tempShapePoints.length; i++) {
|
||||||
|
ctx.lineTo(tempShapePoints[i].x, tempShapePoints[i].y);
|
||||||
}
|
}
|
||||||
this.maskCtx.closePath();
|
ctx.closePath();
|
||||||
this.maskCtx.fill('evenodd');
|
ctx.fill('evenodd');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Restore default composite operation
|
// Now remove the shape mask from the chunked system
|
||||||
this.maskCtx.globalCompositeOperation = 'source-over';
|
this.removeMaskCanvasFromChunks(removalMaskCanvas, bounds.x - tempOffsetX, bounds.y - tempOffsetY);
|
||||||
|
|
||||||
|
// Update the active mask canvas to show the changes
|
||||||
|
this.updateActiveMaskCanvas();
|
||||||
|
|
||||||
if (this.onStateChange) {
|
if (this.onStateChange) {
|
||||||
this.onStateChange();
|
this.onStateChange();
|
||||||
}
|
}
|
||||||
this.canvasInstance.render();
|
this.canvasInstance.render();
|
||||||
log.info(`Removed shape mask area (hard-edged) with expansion: ${needsExpansion}.`);
|
log.info(`Removed shape mask from chunks with expansion: ${needsExpansion}.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
private _createFeatheredMaskCanvas(points: Point[], featherRadius: number, width: number, height: number): HTMLCanvasElement {
|
private _createFeatheredMaskCanvas(points: Point[], featherRadius: number, width: number, height: number): HTMLCanvasElement {
|
||||||
|
|||||||
Reference in New Issue
Block a user