From 7ce7194cbf89c794e5a1372038a261dcaa601c14 Mon Sep 17 00:00:00 2001 From: Dariusz L Date: Thu, 14 Aug 2025 12:23:29 +0200 Subject: [PATCH] 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. --- js/CanvasLayers.js | 111 ++++++++++++++++++++++++++++++++++++ js/CanvasView.js | 21 ++++++- src/CanvasLayers.ts | 136 ++++++++++++++++++++++++++++++++++++++++++++ src/CanvasView.ts | 19 +++++++ 4 files changed, 286 insertions(+), 1 deletion(-) diff --git a/js/CanvasLayers.js b/js/CanvasLayers.js index 8a3f2d8..2b1dafd 100644 --- a/js/CanvasLayers.js +++ b/js/CanvasLayers.js @@ -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; diff --git a/js/CanvasView.js b/js/CanvasView.js index 947cf2b..92171e9 100644 --- a/js/CanvasView.js +++ b/js/CanvasView.js @@ -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", diff --git a/src/CanvasLayers.ts b/src/CanvasLayers.ts index fc91633..62330c0 100644 --- a/src/CanvasLayers.ts +++ b/src/CanvasLayers.ts @@ -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(); diff --git a/src/CanvasView.ts b/src/CanvasView.ts index 7c4a665..c3f79fa 100644 --- a/src/CanvasView.ts +++ b/src/CanvasView.ts @@ -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",