feat: add auto adjust output area for selected layers

Implements one-click auto adjustment of output area to fit selected layers with intelligent bounding box calculation. Supports rotation, crop mode, flips, and includes automatic padding with complete canvas state updates.
This commit is contained in:
Dariusz L
2025-08-14 12:23:29 +02:00
parent 990853f8c7
commit 7ce7194cbf
4 changed files with 286 additions and 1 deletions

View File

@@ -196,6 +196,117 @@ export class CanvasLayers {
}
}
}
/**
* Automatically adjust output area to fit selected layers
* Calculates precise bounding box for all selected layers including rotation and crop mode support
*/
autoAdjustOutputToSelection() {
const selectedLayers = this.canvas.canvasSelection.selectedLayers;
if (selectedLayers.length === 0) {
return false;
}
// Calculate bounding box of selected layers
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
selectedLayers.forEach((layer) => {
// For crop mode layers, use the visible crop bounds
if (layer.cropMode && layer.cropBounds && layer.originalWidth && layer.originalHeight) {
const layerScaleX = layer.width / layer.originalWidth;
const layerScaleY = layer.height / layer.originalHeight;
const cropWidth = layer.cropBounds.width * layerScaleX;
const cropHeight = layer.cropBounds.height * layerScaleY;
const effectiveCropX = layer.flipH
? layer.originalWidth - (layer.cropBounds.x + layer.cropBounds.width)
: layer.cropBounds.x;
const effectiveCropY = layer.flipV
? layer.originalHeight - (layer.cropBounds.y + layer.cropBounds.height)
: layer.cropBounds.y;
const cropOffsetX = effectiveCropX * layerScaleX;
const cropOffsetY = effectiveCropY * layerScaleY;
const centerX = layer.x + layer.width / 2;
const centerY = layer.y + layer.height / 2;
const rad = layer.rotation * Math.PI / 180;
const cos = Math.cos(rad);
const sin = Math.sin(rad);
// Calculate corners of the crop rectangle
const corners = [
{ x: cropOffsetX, y: cropOffsetY },
{ x: cropOffsetX + cropWidth, y: cropOffsetY },
{ x: cropOffsetX + cropWidth, y: cropOffsetY + cropHeight },
{ x: cropOffsetX, y: cropOffsetY + cropHeight }
];
corners.forEach(p => {
// Transform to layer space (centered)
const localX = p.x - layer.width / 2;
const localY = p.y - layer.height / 2;
// Apply rotation
const worldX = centerX + (localX * cos - localY * sin);
const worldY = centerY + (localX * sin + localY * cos);
minX = Math.min(minX, worldX);
minY = Math.min(minY, worldY);
maxX = Math.max(maxX, worldX);
maxY = Math.max(maxY, worldY);
});
}
else {
// For normal layers, use the full layer bounds
const centerX = layer.x + layer.width / 2;
const centerY = layer.y + layer.height / 2;
const rad = layer.rotation * Math.PI / 180;
const cos = Math.cos(rad);
const sin = Math.sin(rad);
const halfW = layer.width / 2;
const halfH = layer.height / 2;
const corners = [
{ x: -halfW, y: -halfH },
{ x: halfW, y: -halfH },
{ x: halfW, y: halfH },
{ x: -halfW, y: halfH }
];
corners.forEach(p => {
const worldX = centerX + (p.x * cos - p.y * sin);
const worldY = centerY + (p.x * sin + p.y * cos);
minX = Math.min(minX, worldX);
minY = Math.min(minY, worldY);
maxX = Math.max(maxX, worldX);
maxY = Math.max(maxY, worldY);
});
}
});
// Calculate new dimensions without padding for precise fit
const newWidth = Math.ceil(maxX - minX);
const newHeight = Math.ceil(maxY - minY);
if (newWidth <= 0 || newHeight <= 0) {
log.error("Cannot calculate valid output area dimensions");
return false;
}
// Update output area bounds
this.canvas.outputAreaBounds = {
x: minX,
y: minY,
width: newWidth,
height: newHeight
};
// Update canvas dimensions
this.canvas.width = newWidth;
this.canvas.height = newHeight;
this.canvas.maskTool.resize(newWidth, newHeight);
this.canvas.canvas.width = newWidth;
this.canvas.canvas.height = newHeight;
// Reset extensions
this.canvas.outputAreaExtensions = { top: 0, bottom: 0, left: 0, right: 0 };
this.canvas.outputAreaExtensionEnabled = false;
this.canvas.lastOutputAreaExtensions = { top: 0, bottom: 0, left: 0, right: 0 };
// Update original canvas size and position
this.canvas.originalCanvasSize = { width: newWidth, height: newHeight };
this.canvas.originalOutputAreaPosition = { x: minX, y: minY };
// Save state and render
this.canvas.render();
this.canvas.saveState();
log.info(`Auto-adjusted output area to fit ${selectedLayers.length} selected layer(s)`, {
bounds: { x: minX, y: minY, width: newWidth, height: newHeight }
});
return true;
}
pasteLayers() {
if (this.internalClipboard.length === 0)
return;

View File

@@ -8,7 +8,7 @@ import { clearAllCanvasStates } from "./db.js";
import { ImageCache } from "./ImageCache.js";
import { createCanvas } from "./utils/CommonUtils.js";
import { createModuleLogger } from "./utils/LoggerUtils.js";
import { showErrorNotification, showSuccessNotification, showInfoNotification } from "./utils/NotificationUtils.js";
import { showErrorNotification, showSuccessNotification, showInfoNotification, showWarningNotification } from "./utils/NotificationUtils.js";
import { iconLoader, LAYERFORGE_TOOLS } from "./utils/IconLoader.js";
import { setupSAMDetectorHook } from "./SAMDetectorIntegration.js";
const log = createModuleLogger('Canvas_view');
@@ -213,6 +213,25 @@ async function createCanvasWidget(node, widget, app) {
]),
$el("div.painter-separator"),
$el("div.painter-button-group", {}, [
$el("button.painter-button.requires-selection", {
textContent: "Auto Adjust Output",
title: "Automatically adjust output area to fit selected layers",
onclick: () => {
const selectedLayers = canvas.canvasSelection.selectedLayers;
if (selectedLayers.length === 0) {
showWarningNotification("Please select one or more layers first");
return;
}
const success = canvas.canvasLayers.autoAdjustOutputToSelection();
if (success) {
const bounds = canvas.outputAreaBounds;
showSuccessNotification(`Output area adjusted to ${bounds.width}x${bounds.height}px`);
}
else {
showErrorNotification("Cannot calculate valid output area dimensions");
}
}
}),
$el("button.painter-button", {
textContent: "Output Area Size",
title: "Set the size of the output area",

View File

@@ -135,6 +135,142 @@ export class CanvasLayers {
}
}
/**
* Automatically adjust output area to fit selected layers
* Calculates precise bounding box for all selected layers including rotation and crop mode support
*/
autoAdjustOutputToSelection(): boolean {
const selectedLayers = this.canvas.canvasSelection.selectedLayers;
if (selectedLayers.length === 0) {
return false;
}
// Calculate bounding box of selected layers
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
selectedLayers.forEach((layer: Layer) => {
// For crop mode layers, use the visible crop bounds
if (layer.cropMode && layer.cropBounds && layer.originalWidth && layer.originalHeight) {
const layerScaleX = layer.width / layer.originalWidth;
const layerScaleY = layer.height / layer.originalHeight;
const cropWidth = layer.cropBounds.width * layerScaleX;
const cropHeight = layer.cropBounds.height * layerScaleY;
const effectiveCropX = layer.flipH
? layer.originalWidth - (layer.cropBounds.x + layer.cropBounds.width)
: layer.cropBounds.x;
const effectiveCropY = layer.flipV
? layer.originalHeight - (layer.cropBounds.y + layer.cropBounds.height)
: layer.cropBounds.y;
const cropOffsetX = effectiveCropX * layerScaleX;
const cropOffsetY = effectiveCropY * layerScaleY;
const centerX = layer.x + layer.width / 2;
const centerY = layer.y + layer.height / 2;
const rad = layer.rotation * Math.PI / 180;
const cos = Math.cos(rad);
const sin = Math.sin(rad);
// Calculate corners of the crop rectangle
const corners = [
{ x: cropOffsetX, y: cropOffsetY },
{ x: cropOffsetX + cropWidth, y: cropOffsetY },
{ x: cropOffsetX + cropWidth, y: cropOffsetY + cropHeight },
{ x: cropOffsetX, y: cropOffsetY + cropHeight }
];
corners.forEach(p => {
// Transform to layer space (centered)
const localX = p.x - layer.width / 2;
const localY = p.y - layer.height / 2;
// Apply rotation
const worldX = centerX + (localX * cos - localY * sin);
const worldY = centerY + (localX * sin + localY * cos);
minX = Math.min(minX, worldX);
minY = Math.min(minY, worldY);
maxX = Math.max(maxX, worldX);
maxY = Math.max(maxY, worldY);
});
} else {
// For normal layers, use the full layer bounds
const centerX = layer.x + layer.width / 2;
const centerY = layer.y + layer.height / 2;
const rad = layer.rotation * Math.PI / 180;
const cos = Math.cos(rad);
const sin = Math.sin(rad);
const halfW = layer.width / 2;
const halfH = layer.height / 2;
const corners = [
{ x: -halfW, y: -halfH },
{ x: halfW, y: -halfH },
{ x: halfW, y: halfH },
{ x: -halfW, y: halfH }
];
corners.forEach(p => {
const worldX = centerX + (p.x * cos - p.y * sin);
const worldY = centerY + (p.x * sin + p.y * cos);
minX = Math.min(minX, worldX);
minY = Math.min(minY, worldY);
maxX = Math.max(maxX, worldX);
maxY = Math.max(maxY, worldY);
});
}
});
// Calculate new dimensions without padding for precise fit
const newWidth = Math.ceil(maxX - minX);
const newHeight = Math.ceil(maxY - minY);
if (newWidth <= 0 || newHeight <= 0) {
log.error("Cannot calculate valid output area dimensions");
return false;
}
// Update output area bounds
this.canvas.outputAreaBounds = {
x: minX,
y: minY,
width: newWidth,
height: newHeight
};
// Update canvas dimensions
this.canvas.width = newWidth;
this.canvas.height = newHeight;
this.canvas.maskTool.resize(newWidth, newHeight);
this.canvas.canvas.width = newWidth;
this.canvas.canvas.height = newHeight;
// Reset extensions
this.canvas.outputAreaExtensions = { top: 0, bottom: 0, left: 0, right: 0 };
this.canvas.outputAreaExtensionEnabled = false;
this.canvas.lastOutputAreaExtensions = { top: 0, bottom: 0, left: 0, right: 0 };
// Update original canvas size and position
this.canvas.originalCanvasSize = { width: newWidth, height: newHeight };
this.canvas.originalOutputAreaPosition = { x: minX, y: minY };
// Save state and render
this.canvas.render();
this.canvas.saveState();
log.info(`Auto-adjusted output area to fit ${selectedLayers.length} selected layer(s)`, {
bounds: { x: minX, y: minY, width: newWidth, height: newHeight }
});
return true;
}
pasteLayers(): void {
if (this.internalClipboard.length === 0) return;
this.canvas.saveState();

View File

@@ -268,6 +268,25 @@ async function createCanvasWidget(node: ComfyNode, widget: any, app: ComfyApp):
$el("div.painter-separator"),
$el("div.painter-button-group", {}, [
$el("button.painter-button.requires-selection", {
textContent: "Auto Adjust Output",
title: "Automatically adjust output area to fit selected layers",
onclick: () => {
const selectedLayers = canvas.canvasSelection.selectedLayers;
if (selectedLayers.length === 0) {
showWarningNotification("Please select one or more layers first");
return;
}
const success = canvas.canvasLayers.autoAdjustOutputToSelection();
if (success) {
const bounds = canvas.outputAreaBounds;
showSuccessNotification(`Output area adjusted to ${bounds.width}x${bounds.height}px`);
} else {
showErrorNotification("Cannot calculate valid output area dimensions");
}
}
}),
$el("button.painter-button", {
textContent: "Output Area Size",
title: "Set the size of the output area",