From a309d04f5991b337686b399c7855521d333b87ad Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 1 Jul 2025 13:29:04 +0000 Subject: [PATCH 01/26] Add invert mask functionality to inpainting control layer Co-authored-by: kent --- invokeai/frontend/web/public/locales/en.json | 1 + .../InpaintMask/InpaintMaskMenuItems.tsx | 2 ++ .../InpaintMaskMenuItemsInvert.tsx | 36 +++++++++++++++++++ .../controlLayers/store/canvasSlice.ts | 31 ++++++++++++++++ 4 files changed, 70 insertions(+) create mode 100644 invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskMenuItemsInvert.tsx diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json index 44cba5629d2..41229590c4a 100644 --- a/invokeai/frontend/web/public/locales/en.json +++ b/invokeai/frontend/web/public/locales/en.json @@ -2074,6 +2074,7 @@ "uploadOrDragAnImage": "Drag an image from the gallery or upload an image.", "imageNoise": "Image Noise", "denoiseLimit": "Denoise Limit", + "invertMask": "Invert Mask", "warnings": { "problemsFound": "Problems found", "unsupportedModel": "layer not supported for selected base model", diff --git a/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskMenuItems.tsx b/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskMenuItems.tsx index 0d0289adf87..2b3ba7fcb65 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskMenuItems.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskMenuItems.tsx @@ -10,6 +10,7 @@ import { CanvasEntityMenuItemsTransform } from 'features/controlLayers/component import { InpaintMaskMenuItemsAddModifiers } from 'features/controlLayers/components/InpaintMask/InpaintMaskMenuItemsAddModifiers'; import { InpaintMaskMenuItemsConvertToSubMenu } from 'features/controlLayers/components/InpaintMask/InpaintMaskMenuItemsConvertToSubMenu'; import { InpaintMaskMenuItemsCopyToSubMenu } from 'features/controlLayers/components/InpaintMask/InpaintMaskMenuItemsCopyToSubMenu'; +import { InpaintMaskMenuItemsInvert } from 'features/controlLayers/components/InpaintMask/InpaintMaskMenuItemsInvert'; import { memo } from 'react'; export const InpaintMaskMenuItems = memo(() => { @@ -21,6 +22,7 @@ export const InpaintMaskMenuItems = memo(() => { + diff --git a/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskMenuItemsInvert.tsx b/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskMenuItemsInvert.tsx new file mode 100644 index 00000000000..3eefdf7ce37 --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskMenuItemsInvert.tsx @@ -0,0 +1,36 @@ +import { MenuItem } from '@invoke-ai/ui-library'; +import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; +import { useEntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext'; +import { inpaintMaskInverted } from 'features/controlLayers/store/canvasSlice'; +import { selectCanvasSlice } from 'features/controlLayers/store/selectors'; +import { memo, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { PiSelectionInverseBold } from 'react-icons/pi'; + +export const InpaintMaskMenuItemsInvert = memo(() => { + const { t } = useTranslation(); + const dispatch = useAppDispatch(); + const entityIdentifier = useEntityIdentifierContext('inpaint_mask'); + const canvasSlice = useAppSelector(selectCanvasSlice); + + const handleInvertMask = useCallback(() => { + dispatch(inpaintMaskInverted({ entityIdentifier })); + }, [dispatch, entityIdentifier]); + + // Only show if there are objects to invert and we have a valid bounding box + const entity = canvasSlice.inpaintMasks.entities.find((entity) => entity.id === entityIdentifier.id); + const hasObjects = entity?.objects.length > 0; + const hasBbox = canvasSlice.bbox.rect.width > 0 && canvasSlice.bbox.rect.height > 0; + + if (!hasObjects || !hasBbox) { + return null; + } + + return ( + }> + {t('controlLayers.invertMask')} + + ); +}); + +InpaintMaskMenuItemsInvert.displayName = 'InpaintMaskMenuItemsInvert'; diff --git a/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts b/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts index 952eaf401f1..ddde265b2ce 100644 --- a/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts +++ b/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts @@ -993,6 +993,36 @@ export const canvasSlice = createSlice({ entity.denoiseLimit = undefined; } }, + inpaintMaskInverted: (state, action: PayloadAction>) => { + const { entityIdentifier } = action.payload; + const entity = selectEntity(state, entityIdentifier); + if (!entity || entity.type !== 'inpaint_mask') { + return; + } + + // Create a rectangle covering the current bounding box + const bboxRect = state.bbox.rect; + const fillRectObject = { + id: getPrefixedId('rect'), + type: 'rect' as const, + rect: { + x: bboxRect.x - entity.position.x, + y: bboxRect.y - entity.position.y, + width: bboxRect.width, + height: bboxRect.height, + }, + color: { r: 255, g: 255, b: 255, a: 1 }, + }; + + // Convert existing objects to eraser effect by creating a composite inverted mask + // The strategy is to replace all existing objects with: + // 1. A full rectangle covering the bbox + // 2. The original objects as "erasers" to punch holes through the rectangle + const originalObjects = [...entity.objects]; + + // Start with the full rectangle, then "erase" the original painted areas + entity.objects = [fillRectObject, ...originalObjects]; + }, //#region BBox bboxScaledWidthChanged: (state, action: PayloadAction) => { const gridSize = getGridSize(state.bbox.modelBase); @@ -1713,6 +1743,7 @@ export const { inpaintMaskDenoiseLimitAdded, inpaintMaskDenoiseLimitChanged, inpaintMaskDenoiseLimitDeleted, + inpaintMaskInverted, // inpaintMaskRecalled, } = canvasSlice.actions; From 220d77e75ff86192c9309bcb08731f16bc76cae4 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 1 Jul 2025 13:30:40 +0000 Subject: [PATCH 02/26] Refactor mask inversion logic with improved object type conversion Co-authored-by: kent --- .../controlLayers/store/canvasSlice.ts | 59 ++++++++++++++++--- 1 file changed, 51 insertions(+), 8 deletions(-) diff --git a/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts b/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts index ddde265b2ce..bbc1b415a68 100644 --- a/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts +++ b/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts @@ -1000,7 +1000,12 @@ export const canvasSlice = createSlice({ return; } - // Create a rectangle covering the current bounding box + // If there are no objects to invert, do nothing + if (entity.objects.length === 0) { + return; + } + + // Create a rectangle covering the current bounding box relative to the entity position const bboxRect = state.bbox.rect; const fillRectObject = { id: getPrefixedId('rect'), @@ -1014,14 +1019,52 @@ export const canvasSlice = createSlice({ color: { r: 255, g: 255, b: 255, a: 1 }, }; - // Convert existing objects to eraser effect by creating a composite inverted mask - // The strategy is to replace all existing objects with: - // 1. A full rectangle covering the bbox - // 2. The original objects as "erasers" to punch holes through the rectangle - const originalObjects = [...entity.objects]; + // To invert a mask, we need to: + // 1. Start with a full rectangle covering the bbox (this becomes the "base mask") + // 2. Convert existing brush/rect objects to eraser lines to "punch holes" in the base mask + const convertedObjects = entity.objects.map((obj) => { + if (obj.type === 'brush_line') { + // Convert brush lines to eraser lines + return { + ...obj, + id: getPrefixedId('eraser_line'), + type: 'eraser_line' as const, + }; + } else if (obj.type === 'brush_line_with_pressure') { + // Convert brush lines with pressure to eraser lines with pressure + return { + ...obj, + id: getPrefixedId('eraser_line'), + type: 'eraser_line_with_pressure' as const, + }; + } else if (obj.type === 'rect') { + // Convert rectangles to eraser "rectangles" by making them transparent + return { + ...obj, + id: getPrefixedId('rect'), + color: { ...obj.color, a: 0 }, // Make transparent to act as eraser + }; + } else if (obj.type === 'eraser_line') { + // Convert eraser lines to brush lines + return { + ...obj, + id: getPrefixedId('brush_line'), + type: 'brush_line' as const, + }; + } else if (obj.type === 'eraser_line_with_pressure') { + // Convert eraser lines with pressure to brush lines with pressure + return { + ...obj, + id: getPrefixedId('brush_line'), + type: 'brush_line_with_pressure' as const, + }; + } + // Keep images and other objects as is + return obj; + }); - // Start with the full rectangle, then "erase" the original painted areas - entity.objects = [fillRectObject, ...originalObjects]; + // Replace all objects with the base rectangle followed by converted objects + entity.objects = [fillRectObject, ...convertedObjects]; }, //#region BBox bboxScaledWidthChanged: (state, action: PayloadAction) => { From 425119d081b906cfdaed55506e85d7ac5b29e737 Mon Sep 17 00:00:00 2001 From: Kent Keirsey <31807370+hipsterusername@users.noreply.github.com> Date: Tue, 1 Jul 2025 10:04:26 -0400 Subject: [PATCH 03/26] Fix Invert Mask option. --- .../controlLayers/store/canvasSlice.ts | 113 +++++--- .../controlLayers/util/bitmapToMaskObjects.ts | 243 ++++++++++++++++++ 2 files changed, 323 insertions(+), 33 deletions(-) create mode 100644 invokeai/frontend/web/src/features/controlLayers/util/bitmapToMaskObjects.ts diff --git a/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts b/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts index bbc1b415a68..869cfd0473f 100644 --- a/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts +++ b/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts @@ -15,10 +15,15 @@ import { selectRegionalGuidanceReferenceImage, } from 'features/controlLayers/store/selectors'; import type { + CanvasBrushLineState, + CanvasBrushLineWithPressureState, CanvasEntityStateFromType, CanvasEntityType, + CanvasEraserLineState, + CanvasEraserLineWithPressureState, CanvasInpaintMaskState, CanvasMetadata, + CanvasRectState, ControlLoRAConfig, EntityMovedByPayload, FillStyle, @@ -1005,11 +1010,16 @@ export const canvasSlice = createSlice({ return; } - // Create a rectangle covering the current bounding box relative to the entity position + // For now, we'll use a simple approach: create a full rectangle and add eraser lines + // This is a temporary solution until we can properly handle the bitmap conversion + + // Get the bbox dimensions for the mask const bboxRect = state.bbox.rect; - const fillRectObject = { + + // Create a full rectangle covering the bbox + const fillRect: CanvasRectState = { id: getPrefixedId('rect'), - type: 'rect' as const, + type: 'rect', rect: { x: bboxRect.x - entity.position.x, y: bboxRect.y - entity.position.y, @@ -1019,52 +1029,89 @@ export const canvasSlice = createSlice({ color: { r: 255, g: 255, b: 255, a: 1 }, }; - // To invert a mask, we need to: - // 1. Start with a full rectangle covering the bbox (this becomes the "base mask") - // 2. Convert existing brush/rect objects to eraser lines to "punch holes" in the base mask - const convertedObjects = entity.objects.map((obj) => { + // Convert existing brush lines to eraser lines to "punch holes" in the full rectangle + const invertedObjects: ( + | CanvasRectState + | CanvasEraserLineState + | CanvasEraserLineWithPressureState + | CanvasBrushLineState + | CanvasBrushLineWithPressureState + )[] = [fillRect]; + + for (const obj of entity.objects) { if (obj.type === 'brush_line') { - // Convert brush lines to eraser lines - return { - ...obj, + // Convert brush line to eraser line + const eraserLine: CanvasEraserLineState = { id: getPrefixedId('eraser_line'), - type: 'eraser_line' as const, + type: 'eraser_line', + strokeWidth: obj.strokeWidth, + points: obj.points, + clip: obj.clip, }; + invertedObjects.push(eraserLine); } else if (obj.type === 'brush_line_with_pressure') { - // Convert brush lines with pressure to eraser lines with pressure - return { - ...obj, + // Convert brush line with pressure to eraser line with pressure + const eraserLine: CanvasEraserLineWithPressureState = { id: getPrefixedId('eraser_line'), - type: 'eraser_line_with_pressure' as const, + type: 'eraser_line_with_pressure', + strokeWidth: obj.strokeWidth, + points: obj.points, + clip: obj.clip, }; + invertedObjects.push(eraserLine); } else if (obj.type === 'rect') { - // Convert rectangles to eraser "rectangles" by making them transparent - return { - ...obj, - id: getPrefixedId('rect'), - color: { ...obj.color, a: 0 }, // Make transparent to act as eraser + // Convert rectangle to eraser rectangle (we'll use eraser lines to trace the rectangle) + const { x, y, width, height } = obj.rect; + const points = [ + x, + y, + x + width, + y, + x + width, + y + height, + x, + y + height, + x, + y, // Close the rectangle + ]; + + const eraserLine: CanvasEraserLineState = { + id: getPrefixedId('eraser_line'), + type: 'eraser_line', + points, + strokeWidth: Math.max(width, height) / 2, // Use a stroke width that covers the rectangle + clip: null, }; + invertedObjects.push(eraserLine); } else if (obj.type === 'eraser_line') { - // Convert eraser lines to brush lines - return { - ...obj, + // Convert eraser line to brush line + const brushLine: CanvasBrushLineState = { id: getPrefixedId('brush_line'), - type: 'brush_line' as const, + type: 'brush_line', + strokeWidth: obj.strokeWidth, + points: obj.points, + clip: obj.clip, + color: { r: 255, g: 255, b: 255, a: 1 }, }; + invertedObjects.push(brushLine); } else if (obj.type === 'eraser_line_with_pressure') { - // Convert eraser lines with pressure to brush lines with pressure - return { - ...obj, + // Convert eraser line with pressure to brush line with pressure + const brushLine: CanvasBrushLineWithPressureState = { id: getPrefixedId('brush_line'), - type: 'brush_line_with_pressure' as const, + type: 'brush_line_with_pressure', + strokeWidth: obj.strokeWidth, + points: obj.points, + clip: obj.clip, + color: { r: 255, g: 255, b: 255, a: 1 }, }; + invertedObjects.push(brushLine); } - // Keep images and other objects as is - return obj; - }); + // Note: Image objects are not handled in this simple approach + // They would need to be processed through the compositor + } - // Replace all objects with the base rectangle followed by converted objects - entity.objects = [fillRectObject, ...convertedObjects]; + // Replace the entity's objects with the inverted mask objects + entity.objects = invertedObjects; }, //#region BBox bboxScaledWidthChanged: (state, action: PayloadAction) => { diff --git a/invokeai/frontend/web/src/features/controlLayers/util/bitmapToMaskObjects.ts b/invokeai/frontend/web/src/features/controlLayers/util/bitmapToMaskObjects.ts new file mode 100644 index 00000000000..aee22f521e8 --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/util/bitmapToMaskObjects.ts @@ -0,0 +1,243 @@ +import { getPrefixedId } from 'features/controlLayers/konva/util'; +import type { + CanvasBrushLineState, + CanvasBrushLineWithPressureState, + CanvasEraserLineState, + CanvasEraserLineWithPressureState, + CanvasImageState, + CanvasRectState, + RgbaColor, +} from 'features/controlLayers/store/types'; + +/** + * Options for converting bitmap to mask objects + */ +export interface BitmapToMaskOptions { + /** + * The threshold for considering a pixel as masked (0-255) + * Pixels with alpha >= threshold are considered masked + */ + threshold?: number; + /** + * The color to use for brush lines + */ + brushColor?: RgbaColor; + /** + * The stroke width for brush lines + */ + strokeWidth?: number; + /** + * Whether to use pressure-sensitive lines + */ + usePressure?: boolean; + /** + * The pressure value to use for pressure-sensitive lines (0-1) + */ + pressure?: number; +} + +/** + * Default options for bitmap to mask conversion + */ +const DEFAULT_OPTIONS: Required = { + threshold: 128, + brushColor: { r: 255, g: 255, b: 255, a: 1 }, + strokeWidth: 50, + usePressure: false, + pressure: 1.0, +}; + +/** + * Converts a bitmap (ImageData) to mask objects (brush lines, eraser lines, rectangles) + * + * @param imageData - The bitmap data to convert + * @param options - Conversion options + * @returns Array of mask objects + */ +export function bitmapToMaskObjects( + imageData: ImageData, + options: BitmapToMaskOptions = {} +): ( + | CanvasBrushLineState + | CanvasBrushLineWithPressureState + | CanvasEraserLineState + | CanvasEraserLineWithPressureState + | CanvasRectState + | CanvasImageState +)[] { + const opts = { ...DEFAULT_OPTIONS, ...options }; + const { width, height, data } = imageData; + const objects: ( + | CanvasBrushLineState + | CanvasBrushLineWithPressureState + | CanvasEraserLineState + | CanvasEraserLineWithPressureState + | CanvasRectState + | CanvasImageState + )[] = []; + + // For now, we'll create a simple approach that creates rectangles for masked areas + // This can be enhanced later to create more sophisticated brush/eraser line patterns + + // Scan the image data to find masked areas + for (let y = 0; y < height; y += opts.strokeWidth) { + for (let x = 0; x < width; x += opts.strokeWidth) { + // Check if this pixel is masked + const pixelIndex = (y * width + x) * 4; + const alpha = data[pixelIndex + 3] ?? 0; + + if (alpha >= opts.threshold) { + // Create a rectangle for this masked area + const rect: CanvasRectState = { + id: getPrefixedId('rect'), + type: 'rect', + rect: { + x, + y, + width: Math.min(opts.strokeWidth, width - x), + height: Math.min(opts.strokeWidth, height - y), + }, + color: opts.brushColor, + }; + objects.push(rect); + } + } + } + + return objects; +} + +/** + * Inverts a bitmap by flipping the alpha channel + * + * @param imageData - The bitmap data to invert + * @returns New ImageData with inverted alpha channel + */ +export function invertBitmap(imageData: ImageData): ImageData { + const { width, height, data } = imageData; + const newImageData = new ImageData(width, height); + const newData = newImageData.data; + + for (let i = 0; i < data.length; i += 4) { + // Copy RGB values + newData[i] = data[i] ?? 0; // R + newData[i + 1] = data[i + 1] ?? 0; // G + newData[i + 2] = data[i + 2] ?? 0; // B + // Invert alpha + newData[i + 3] = 255 - (data[i + 3] ?? 0); // A + } + + return newImageData; +} + +/** + * Converts mask objects to a bitmap (ImageData) + * This is a simplified version that creates a basic bitmap representation + * + * @param objects - Array of mask objects + * @param width - Width of the output bitmap + * @param height - Height of the output bitmap + * @returns ImageData representing the mask + */ +export function maskObjectsToBitmap( + objects: ( + | CanvasBrushLineState + | CanvasBrushLineWithPressureState + | CanvasEraserLineState + | CanvasEraserLineWithPressureState + | CanvasRectState + | CanvasImageState + )[], + width: number, + height: number +): ImageData { + const canvas = document.createElement('canvas'); + canvas.width = width; + canvas.height = height; + const ctx = canvas.getContext('2d'); + + if (!ctx) { + throw new Error('Failed to get canvas context'); + } + + // Clear canvas with transparent background + ctx.clearRect(0, 0, width, height); + + // Draw each object + for (const obj of objects) { + if (obj.type === 'rect') { + ctx.fillStyle = `rgba(${obj.color.r}, ${obj.color.g}, ${obj.color.b}, ${obj.color.a})`; + ctx.fillRect(obj.rect.x, obj.rect.y, obj.rect.width, obj.rect.height); + } else if (obj.type === 'brush_line' || obj.type === 'brush_line_with_pressure') { + ctx.strokeStyle = `rgba(${obj.color.r}, ${obj.color.g}, ${obj.color.b}, ${obj.color.a})`; + ctx.lineWidth = obj.strokeWidth; + ctx.lineCap = 'round'; + ctx.lineJoin = 'round'; + + // Draw the line + ctx.beginPath(); + for (let i = 0; i < obj.points.length; i += 2) { + const x = obj.points[i] ?? 0; + const y = obj.points[i + 1] ?? 0; + if (i === 0) { + ctx.moveTo(x, y); + } else { + ctx.lineTo(x, y); + } + } + ctx.stroke(); + } else if (obj.type === 'eraser_line' || obj.type === 'eraser_line_with_pressure') { + // Eraser lines use destination-out composite operation + ctx.globalCompositeOperation = 'destination-out'; + ctx.strokeStyle = 'rgba(0, 0, 0, 1)'; + ctx.lineWidth = obj.strokeWidth; + ctx.lineCap = 'round'; + ctx.lineJoin = 'round'; + + // Draw the line + ctx.beginPath(); + for (let i = 0; i < obj.points.length; i += 2) { + const x = obj.points[i] ?? 0; + const y = obj.points[i + 1] ?? 0; + if (i === 0) { + ctx.moveTo(x, y); + } else { + ctx.lineTo(x, y); + } + } + ctx.stroke(); + + // Reset composite operation + ctx.globalCompositeOperation = 'source-over'; + } else if (obj.type === 'image') { + // For image objects, we need to load the image and draw it + // This is a simplified approach - in a real implementation, you'd want to handle image loading properly + const img = new Image(); + img.crossOrigin = 'anonymous'; + + // Create a temporary canvas to draw the image + const tempCanvas = document.createElement('canvas'); + const tempCtx = tempCanvas.getContext('2d'); + if (tempCtx) { + tempCanvas.width = obj.image.width; + tempCanvas.height = obj.image.height; + + // Draw the image to the temp canvas + if ('image_name' in obj.image) { + // This would need proper image loading from the server + // For now, we'll skip image objects in the mask conversion + console.warn('Image objects with image_name are not supported in mask conversion'); + } else { + // Data URL image + img.src = obj.image.dataURL; + tempCtx.drawImage(img, 0, 0); + + // Draw the temp canvas to the main canvas + ctx.drawImage(tempCanvas, 0, 0); + } + } + } + } + + return ctx.getImageData(0, 0, width, height); +} From f4a73ca3e02defda94db3d754987a07535633a9f Mon Sep 17 00:00:00 2001 From: Kent Keirsey <31807370+hipsterusername@users.noreply.github.com> Date: Tue, 1 Jul 2025 10:07:18 -0400 Subject: [PATCH 04/26] remove erroneous console warning --- .../web/src/features/controlLayers/util/bitmapToMaskObjects.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/invokeai/frontend/web/src/features/controlLayers/util/bitmapToMaskObjects.ts b/invokeai/frontend/web/src/features/controlLayers/util/bitmapToMaskObjects.ts index aee22f521e8..a58bb20ef6a 100644 --- a/invokeai/frontend/web/src/features/controlLayers/util/bitmapToMaskObjects.ts +++ b/invokeai/frontend/web/src/features/controlLayers/util/bitmapToMaskObjects.ts @@ -226,7 +226,7 @@ export function maskObjectsToBitmap( if ('image_name' in obj.image) { // This would need proper image loading from the server // For now, we'll skip image objects in the mask conversion - console.warn('Image objects with image_name are not supported in mask conversion'); + // Image objects are not supported in this simple mask conversion } else { // Data URL image img.src = obj.image.dataURL; From cd473954306d57e7cdf860b4b13c2cef5eb6a3b6 Mon Sep 17 00:00:00 2001 From: Kent Keirsey <31807370+hipsterusername@users.noreply.github.com> Date: Tue, 1 Jul 2025 10:44:56 -0400 Subject: [PATCH 05/26] Add AdjustBBox --- invokeai/frontend/web/public/locales/en.json | 1 + .../CanvasEntityGroupList.tsx | 2 + .../InpaintMaskAdjustBboxButton.tsx | 116 ++++++++++++++ .../InpaintMask/InpaintMaskBboxAdjuster.tsx | 150 ++++++++++++++++++ .../InpaintMask/InpaintMaskList.tsx | 1 + .../InpaintMask/InpaintMaskMenuItems.tsx | 2 + .../InpaintMaskMenuItemsAdjustBbox.tsx | 110 +++++++++++++ 7 files changed, 382 insertions(+) create mode 100644 invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskAdjustBboxButton.tsx create mode 100644 invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskBboxAdjuster.tsx create mode 100644 invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskMenuItemsAdjustBbox.tsx diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json index 41229590c4a..9ed9c09463c 100644 --- a/invokeai/frontend/web/public/locales/en.json +++ b/invokeai/frontend/web/public/locales/en.json @@ -2074,6 +2074,7 @@ "uploadOrDragAnImage": "Drag an image from the gallery or upload an image.", "imageNoise": "Image Noise", "denoiseLimit": "Denoise Limit", + "adjustBboxToMasks": "Adjust Bbox to Masks", "invertMask": "Invert Mask", "warnings": { "problemsFound": "Problems found", diff --git a/invokeai/frontend/web/src/features/controlLayers/components/CanvasEntityList/CanvasEntityGroupList.tsx b/invokeai/frontend/web/src/features/controlLayers/components/CanvasEntityList/CanvasEntityGroupList.tsx index 4e1f5c1dc4a..3410f0cce70 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/CanvasEntityList/CanvasEntityGroupList.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/CanvasEntityList/CanvasEntityGroupList.tsx @@ -10,6 +10,7 @@ import { fixTooltipCloseOnScrollStyles } from 'common/util/fixTooltipCloseOnScro import { CanvasEntityAddOfTypeButton } from 'features/controlLayers/components/common/CanvasEntityAddOfTypeButton'; import { CanvasEntityMergeVisibleButton } from 'features/controlLayers/components/common/CanvasEntityMergeVisibleButton'; import { CanvasEntityTypeIsHiddenToggle } from 'features/controlLayers/components/common/CanvasEntityTypeIsHiddenToggle'; +import { InpaintMaskAdjustBboxButton } from 'features/controlLayers/components/InpaintMask/InpaintMaskAdjustBboxButton'; import { RasterLayerExportPSDButton } from 'features/controlLayers/components/RasterLayer/RasterLayerExportPSDButton'; import { useEntityTypeInformationalPopover } from 'features/controlLayers/hooks/useEntityTypeInformationalPopover'; import { useEntityTypeTitle } from 'features/controlLayers/hooks/useEntityTypeTitle'; @@ -165,6 +166,7 @@ export const CanvasEntityGroupList = memo(({ isSelected, type, children, entityI + {type === 'inpaint_mask' && } {type === 'raster_layer' && } diff --git a/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskAdjustBboxButton.tsx b/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskAdjustBboxButton.tsx new file mode 100644 index 00000000000..05f4df1adc4 --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskAdjustBboxButton.tsx @@ -0,0 +1,116 @@ +import { IconButton, Tooltip } from '@invoke-ai/ui-library'; +import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; +import { bboxChangedFromCanvas } from 'features/controlLayers/store/canvasSlice'; +import { selectMaskBlur } from 'features/controlLayers/store/paramsSlice'; +import { selectCanvasSlice } from 'features/controlLayers/store/selectors'; +import type { Rect } from 'features/controlLayers/store/types'; +import { memo, useCallback, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { PiCropBold } from 'react-icons/pi'; + +export const InpaintMaskAdjustBboxButton = memo(() => { + const { t } = useTranslation(); + const dispatch = useAppDispatch(); + const canvasSlice = useAppSelector(selectCanvasSlice); + const maskBlur = useAppSelector(selectMaskBlur); + const inpaintMasks = canvasSlice.inpaintMasks.entities; + + // Calculate the bounding box that contains all inpaint masks + const calculateMaskBbox = useCallback((): Rect | null => { + if (inpaintMasks.length === 0) { + return null; + } + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + for (const mask of inpaintMasks) { + if (!mask.isEnabled || mask.objects.length === 0) { + continue; + } + for (const obj of mask.objects) { + let objMinX = 0; + let objMinY = 0; + let objMaxX = 0; + let objMaxY = 0; + if (obj.type === 'rect') { + objMinX = mask.position.x + obj.rect.x; + objMinY = mask.position.y + obj.rect.y; + objMaxX = objMinX + obj.rect.width; + objMaxY = objMinY + obj.rect.height; + } else if ( + obj.type === 'brush_line' || + obj.type === 'brush_line_with_pressure' || + obj.type === 'eraser_line' || + obj.type === 'eraser_line_with_pressure' + ) { + for (let i = 0; i < obj.points.length; i += 2) { + const x = mask.position.x + (obj.points[i] ?? 0); + const y = mask.position.y + (obj.points[i + 1] ?? 0); + if (i === 0) { + objMinX = objMaxX = x; + objMinY = objMaxY = y; + } else { + objMinX = Math.min(objMinX, x); + objMinY = Math.min(objMinY, y); + objMaxX = Math.max(objMaxX, x); + objMaxY = Math.max(objMaxY, y); + } + } + const strokeRadius = (obj.strokeWidth ?? 50) / 2; + objMinX -= strokeRadius; + objMinY -= strokeRadius; + objMaxX += strokeRadius; + objMaxY += strokeRadius; + } else if (obj.type === 'image') { + objMinX = mask.position.x; + objMinY = mask.position.y; + objMaxX = objMinX + obj.image.width; + objMaxY = objMinY + obj.image.height; + } + minX = Math.min(minX, objMinX); + minY = Math.min(minY, objMinY); + maxX = Math.max(maxX, objMaxX); + maxY = Math.max(maxY, objMaxY); + } + } + if (minX === Infinity || minY === Infinity || maxX === -Infinity || maxY === -Infinity) { + return null; + } + return { x: minX, y: minY, width: maxX - minX, height: maxY - minY }; + }, [inpaintMasks]); + + const maskBbox = useMemo(() => calculateMaskBbox(), [calculateMaskBbox]); + const handleAdjustBbox = useCallback(() => { + if (!maskBbox) { + return; + } + const padding = maskBlur + 8; + const adjustedBbox: Rect = { + x: maskBbox.x - padding, + y: maskBbox.y - padding, + width: maskBbox.width + padding * 2, + height: maskBbox.height + padding * 2, + }; + dispatch(bboxChangedFromCanvas(adjustedBbox)); + }, [dispatch, maskBbox, maskBlur]); + + const hasValidMasks = inpaintMasks.some((mask) => mask.isEnabled && mask.objects.length > 0); + if (!hasValidMasks) { + return null; + } + + return ( + + } + size="sm" + variant="ghost" + onClick={handleAdjustBbox} + /> + + ); +}); + +InpaintMaskAdjustBboxButton.displayName = 'InpaintMaskAdjustBboxButton'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskBboxAdjuster.tsx b/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskBboxAdjuster.tsx new file mode 100644 index 00000000000..f5c39088bd7 --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskBboxAdjuster.tsx @@ -0,0 +1,150 @@ +import { Button, Flex, Icon, Text } from '@invoke-ai/ui-library'; +import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; +import { bboxChangedFromCanvas } from 'features/controlLayers/store/canvasSlice'; +import { selectMaskBlur } from 'features/controlLayers/store/paramsSlice'; +import { selectCanvasSlice } from 'features/controlLayers/store/selectors'; +import type { Rect } from 'features/controlLayers/store/types'; +import { memo, useCallback, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { PiCropBold } from 'react-icons/pi'; + +export const InpaintMaskBboxAdjuster = memo(() => { + const { t } = useTranslation(); + const dispatch = useAppDispatch(); + const canvasSlice = useAppSelector(selectCanvasSlice); + const maskBlur = useAppSelector(selectMaskBlur); + + // Get all inpaint mask entities + const inpaintMasks = canvasSlice.inpaintMasks.entities; + + // Calculate the bounding box that contains all inpaint masks + const calculateMaskBbox = useCallback((): Rect | null => { + if (inpaintMasks.length === 0) { + return null; + } + + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + + // Iterate through all inpaint masks to find the overall bounds + for (const mask of inpaintMasks) { + if (!mask.isEnabled || mask.objects.length === 0) { + continue; + } + + // Calculate bounds for this mask's objects + for (const obj of mask.objects) { + let objMinX = 0; + let objMinY = 0; + let objMaxX = 0; + let objMaxY = 0; + + if (obj.type === 'rect') { + objMinX = mask.position.x + obj.rect.x; + objMinY = mask.position.y + obj.rect.y; + objMaxX = objMinX + obj.rect.width; + objMaxY = objMinY + obj.rect.height; + } else if ( + obj.type === 'brush_line' || + obj.type === 'brush_line_with_pressure' || + obj.type === 'eraser_line' || + obj.type === 'eraser_line_with_pressure' + ) { + // For lines, find the min/max points + for (let i = 0; i < obj.points.length; i += 2) { + const x = mask.position.x + (obj.points[i] ?? 0); + const y = mask.position.y + (obj.points[i + 1] ?? 0); + + if (i === 0) { + objMinX = objMaxX = x; + objMinY = objMaxY = y; + } else { + objMinX = Math.min(objMinX, x); + objMinY = Math.min(objMinY, y); + objMaxX = Math.max(objMaxX, x); + objMaxY = Math.max(objMaxY, y); + } + } + // Add stroke width to account for line thickness + const strokeRadius = (obj.strokeWidth ?? 50) / 2; + objMinX -= strokeRadius; + objMinY -= strokeRadius; + objMaxX += strokeRadius; + objMaxY += strokeRadius; + } else if (obj.type === 'image') { + // Image objects are positioned at the entity's position + objMinX = mask.position.x; + objMinY = mask.position.y; + objMaxX = objMinX + obj.image.width; + objMaxY = objMinY + obj.image.height; + } + + // Update overall bounds + minX = Math.min(minX, objMinX); + minY = Math.min(minY, objMinY); + maxX = Math.max(maxX, objMaxX); + maxY = Math.max(maxY, objMaxY); + } + } + + // If no valid bounds found, return null + if (minX === Infinity || minY === Infinity || maxX === -Infinity || maxY === -Infinity) { + return null; + } + + return { + x: minX, + y: minY, + width: maxX - minX, + height: maxY - minY, + }; + }, [inpaintMasks]); + + const maskBbox = useMemo(() => calculateMaskBbox(), [calculateMaskBbox]); + + const handleAdjustBbox = useCallback(() => { + if (!maskBbox) { + return; + } + + // Add padding based on maskblur setting + 8px + const padding = maskBlur + 8; + const adjustedBbox: Rect = { + x: maskBbox.x - padding, + y: maskBbox.y - padding, + width: maskBbox.width + padding * 2, + height: maskBbox.height + padding * 2, + }; + + dispatch(bboxChangedFromCanvas(adjustedBbox)); + }, [dispatch, maskBbox, maskBlur]); + + // Only show if there are enabled inpaint masks with objects + const hasValidMasks = inpaintMasks.some((mask) => mask.isEnabled && mask.objects.length > 0); + if (!hasValidMasks) { + return null; + } + + return ( + + + + ); +}); + +InpaintMaskBboxAdjuster.displayName = 'InpaintMaskBboxAdjuster'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskList.tsx b/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskList.tsx index 8bbb49a9865..cb8cc4eeca6 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskList.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskList.tsx @@ -3,6 +3,7 @@ import { createMemoizedSelector } from 'app/store/createMemoizedSelector'; import { useAppSelector } from 'app/store/storeHooks'; import { CanvasEntityGroupList } from 'features/controlLayers/components/CanvasEntityList/CanvasEntityGroupList'; import { InpaintMask } from 'features/controlLayers/components/InpaintMask/InpaintMask'; +// import { InpaintMaskBboxAdjuster } from 'features/controlLayers/components/InpaintMask/InpaintMaskBboxAdjuster'; import { selectCanvasSlice, selectSelectedEntityIdentifier } from 'features/controlLayers/store/selectors'; import { getEntityIdentifier } from 'features/controlLayers/store/types'; import { memo } from 'react'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskMenuItems.tsx b/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskMenuItems.tsx index 2b3ba7fcb65..60aba9819e8 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskMenuItems.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskMenuItems.tsx @@ -8,6 +8,7 @@ import { CanvasEntityMenuItemsMergeDown } from 'features/controlLayers/component import { CanvasEntityMenuItemsSave } from 'features/controlLayers/components/common/CanvasEntityMenuItemsSave'; import { CanvasEntityMenuItemsTransform } from 'features/controlLayers/components/common/CanvasEntityMenuItemsTransform'; import { InpaintMaskMenuItemsAddModifiers } from 'features/controlLayers/components/InpaintMask/InpaintMaskMenuItemsAddModifiers'; +import { InpaintMaskMenuItemsAdjustBbox } from 'features/controlLayers/components/InpaintMask/InpaintMaskMenuItemsAdjustBbox'; import { InpaintMaskMenuItemsConvertToSubMenu } from 'features/controlLayers/components/InpaintMask/InpaintMaskMenuItemsConvertToSubMenu'; import { InpaintMaskMenuItemsCopyToSubMenu } from 'features/controlLayers/components/InpaintMask/InpaintMaskMenuItemsCopyToSubMenu'; import { InpaintMaskMenuItemsInvert } from 'features/controlLayers/components/InpaintMask/InpaintMaskMenuItemsInvert'; @@ -23,6 +24,7 @@ export const InpaintMaskMenuItems = memo(() => { + diff --git a/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskMenuItemsAdjustBbox.tsx b/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskMenuItemsAdjustBbox.tsx new file mode 100644 index 00000000000..2750e209c4a --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskMenuItemsAdjustBbox.tsx @@ -0,0 +1,110 @@ +import { MenuItem } from '@invoke-ai/ui-library'; +import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; +import { bboxChangedFromCanvas } from 'features/controlLayers/store/canvasSlice'; +import { selectMaskBlur } from 'features/controlLayers/store/paramsSlice'; +import { selectCanvasSlice } from 'features/controlLayers/store/selectors'; +import type { Rect } from 'features/controlLayers/store/types'; +import { memo, useCallback, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { PiCropBold } from 'react-icons/pi'; + +export const InpaintMaskMenuItemsAdjustBbox = memo(() => { + const { t } = useTranslation(); + const dispatch = useAppDispatch(); + const canvasSlice = useAppSelector(selectCanvasSlice); + const maskBlur = useAppSelector(selectMaskBlur); + const inpaintMasks = canvasSlice.inpaintMasks.entities; + + // Calculate the bounding box that contains all inpaint masks + const calculateMaskBbox = useCallback((): Rect | null => { + if (inpaintMasks.length === 0) { + return null; + } + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + for (const mask of inpaintMasks) { + if (!mask.isEnabled || mask.objects.length === 0) { + continue; + } + for (const obj of mask.objects) { + let objMinX = 0; + let objMinY = 0; + let objMaxX = 0; + let objMaxY = 0; + if (obj.type === 'rect') { + objMinX = mask.position.x + obj.rect.x; + objMinY = mask.position.y + obj.rect.y; + objMaxX = objMinX + obj.rect.width; + objMaxY = objMinY + obj.rect.height; + } else if ( + obj.type === 'brush_line' || + obj.type === 'brush_line_with_pressure' || + obj.type === 'eraser_line' || + obj.type === 'eraser_line_with_pressure' + ) { + for (let i = 0; i < obj.points.length; i += 2) { + const x = mask.position.x + (obj.points[i] ?? 0); + const y = mask.position.y + (obj.points[i + 1] ?? 0); + if (i === 0) { + objMinX = objMaxX = x; + objMinY = objMaxY = y; + } else { + objMinX = Math.min(objMinX, x); + objMinY = Math.min(objMinY, y); + objMaxX = Math.max(objMaxX, x); + objMaxY = Math.max(objMaxY, y); + } + } + const strokeRadius = (obj.strokeWidth ?? 50) / 2; + objMinX -= strokeRadius; + objMinY -= strokeRadius; + objMaxX += strokeRadius; + objMaxY += strokeRadius; + } else if (obj.type === 'image') { + objMinX = mask.position.x; + objMinY = mask.position.y; + objMaxX = objMinX + obj.image.width; + objMaxY = objMinY + obj.image.height; + } + minX = Math.min(minX, objMinX); + minY = Math.min(minY, objMinY); + maxX = Math.max(maxX, objMaxX); + maxY = Math.max(maxY, objMaxY); + } + } + if (minX === Infinity || minY === Infinity || maxX === -Infinity || maxY === -Infinity) { + return null; + } + return { x: minX, y: minY, width: maxX - minX, height: maxY - minY }; + }, [inpaintMasks]); + + const maskBbox = useMemo(() => calculateMaskBbox(), [calculateMaskBbox]); + const handleAdjustBbox = useCallback(() => { + if (!maskBbox) { + return; + } + const padding = maskBlur + 8; + const adjustedBbox: Rect = { + x: maskBbox.x - padding, + y: maskBbox.y - padding, + width: maskBbox.width + padding * 2, + height: maskBbox.height + padding * 2, + }; + dispatch(bboxChangedFromCanvas(adjustedBbox)); + }, [dispatch, maskBbox, maskBlur]); + + const hasValidMasks = inpaintMasks.some((mask) => mask.isEnabled && mask.objects.length > 0); + if (!hasValidMasks) { + return null; + } + + return ( + }> + {t('controlLayers.adjustBboxToMasks')} + + ); +}); + +InpaintMaskMenuItemsAdjustBbox.displayName = 'InpaintMaskMenuItemsAdjustBbox'; From 9155a53963ce91839234a1f185fa30d6517ba004 Mon Sep 17 00:00:00 2001 From: Kent Keirsey <31807370+hipsterusername@users.noreply.github.com> Date: Tue, 1 Jul 2025 10:51:46 -0400 Subject: [PATCH 06/26] Add Hotkeys --- invokeai/frontend/web/public/locales/en.json | 8 ++ .../components/Toolbar/CanvasToolbar.tsx | 4 + .../hooks/useCanvasAdjustBboxHotkey.ts | 113 ++++++++++++++++++ .../hooks/useCanvasInvertMaskHotkey.ts | 52 ++++++++ .../components/HotkeysModal/useHotkeyData.ts | 2 + 5 files changed, 179 insertions(+) create mode 100644 invokeai/frontend/web/src/features/controlLayers/hooks/useCanvasAdjustBboxHotkey.ts create mode 100644 invokeai/frontend/web/src/features/controlLayers/hooks/useCanvasInvertMaskHotkey.ts diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json index 9ed9c09463c..3e42ff5ab61 100644 --- a/invokeai/frontend/web/public/locales/en.json +++ b/invokeai/frontend/web/public/locales/en.json @@ -589,6 +589,14 @@ "toggleNonRasterLayers": { "title": "Toggle Non-Raster Layers", "desc": "Show or hide all non-raster layer categories (Control Layers, Inpaint Masks, Regional Guidance)." + }, + "invertMask": { + "title": "Invert Mask", + "desc": "Invert the selected inpaint mask. Only works when an inpaint mask is selected and has objects." + }, + "adjustBbox": { + "title": "Adjust Bbox to Masks", + "desc": "Adjust the bounding box to fit all visible inpaint masks with padding." } }, "workflows": { diff --git a/invokeai/frontend/web/src/features/controlLayers/components/Toolbar/CanvasToolbar.tsx b/invokeai/frontend/web/src/features/controlLayers/components/Toolbar/CanvasToolbar.tsx index 453d13b3c50..86e57977e99 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/Toolbar/CanvasToolbar.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/Toolbar/CanvasToolbar.tsx @@ -16,6 +16,8 @@ import { useCanvasResetLayerHotkey } from 'features/controlLayers/hooks/useCanva import { useCanvasToggleNonRasterLayersHotkey } from 'features/controlLayers/hooks/useCanvasToggleNonRasterLayersHotkey'; import { useCanvasTransformHotkey } from 'features/controlLayers/hooks/useCanvasTransformHotkey'; import { useCanvasUndoRedoHotkeys } from 'features/controlLayers/hooks/useCanvasUndoRedoHotkeys'; +import { useCanvasInvertMaskHotkey } from 'features/controlLayers/hooks/useCanvasInvertMaskHotkey'; +import { useCanvasAdjustBboxHotkey } from 'features/controlLayers/hooks/useCanvasAdjustBboxHotkey'; import { useNextPrevEntityHotkeys } from 'features/controlLayers/hooks/useNextPrevEntity'; import { memo } from 'react'; @@ -28,6 +30,8 @@ export const CanvasToolbar = memo(() => { useCanvasTransformHotkey(); useCanvasFilterHotkey(); useCanvasToggleNonRasterLayersHotkey(); + useCanvasInvertMaskHotkey(); + useCanvasAdjustBboxHotkey(); return ( diff --git a/invokeai/frontend/web/src/features/controlLayers/hooks/useCanvasAdjustBboxHotkey.ts b/invokeai/frontend/web/src/features/controlLayers/hooks/useCanvasAdjustBboxHotkey.ts new file mode 100644 index 00000000000..d3a87b11319 --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/hooks/useCanvasAdjustBboxHotkey.ts @@ -0,0 +1,113 @@ +import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; +import { useAssertSingleton } from 'common/hooks/useAssertSingleton'; +import { useCanvasIsBusy } from 'features/controlLayers/hooks/useCanvasIsBusy'; +import { bboxChangedFromCanvas } from 'features/controlLayers/store/canvasSlice'; +import { selectMaskBlur } from 'features/controlLayers/store/paramsSlice'; +import { selectCanvasSlice } from 'features/controlLayers/store/selectors'; +import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData'; +import type { Rect } from 'features/controlLayers/store/types'; +import { useCallback, useMemo } from 'react'; + +export const useCanvasAdjustBboxHotkey = () => { + useAssertSingleton('useCanvasAdjustBboxHotkey'); + const dispatch = useAppDispatch(); + const canvasSlice = useAppSelector(selectCanvasSlice); + const maskBlur = useAppSelector(selectMaskBlur); + const isBusy = useCanvasIsBusy(); + const inpaintMasks = canvasSlice.inpaintMasks.entities; + + // Calculate the bounding box that contains all inpaint masks + const calculateMaskBbox = useCallback((): Rect | null => { + if (inpaintMasks.length === 0) { + return null; + } + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + for (const mask of inpaintMasks) { + if (!mask.isEnabled || !mask.objects || mask.objects.length === 0) { + continue; + } + for (const obj of mask.objects) { + let objMinX = 0; + let objMinY = 0; + let objMaxX = 0; + let objMaxY = 0; + if (obj.type === 'rect') { + objMinX = mask.position.x + obj.rect.x; + objMinY = mask.position.y + obj.rect.y; + objMaxX = objMinX + obj.rect.width; + objMaxY = objMinY + obj.rect.height; + } else if ( + obj.type === 'brush_line' || + obj.type === 'brush_line_with_pressure' || + obj.type === 'eraser_line' || + obj.type === 'eraser_line_with_pressure' + ) { + for (let i = 0; i < obj.points.length; i += 2) { + const x = mask.position.x + (obj.points[i] ?? 0); + const y = mask.position.y + (obj.points[i + 1] ?? 0); + if (i === 0) { + objMinX = objMaxX = x; + objMinY = objMaxY = y; + } else { + objMinX = Math.min(objMinX, x); + objMinY = Math.min(objMinY, y); + objMaxX = Math.max(objMaxX, x); + objMaxY = Math.max(objMaxY, y); + } + } + const strokeRadius = (obj.strokeWidth ?? 50) / 2; + objMinX -= strokeRadius; + objMinY -= strokeRadius; + objMaxX += strokeRadius; + objMaxY += strokeRadius; + } else if (obj.type === 'image') { + objMinX = mask.position.x; + objMinY = mask.position.y; + objMaxX = objMinX + obj.image.width; + objMaxY = objMinY + obj.image.height; + } + minX = Math.min(minX, objMinX); + minY = Math.min(minY, objMinY); + maxX = Math.max(maxX, objMaxX); + maxY = Math.max(maxY, objMaxY); + } + } + if (minX === Infinity || minY === Infinity || maxX === -Infinity || maxY === -Infinity) { + return null; + } + return { x: minX, y: minY, width: maxX - minX, height: maxY - minY }; + }, [inpaintMasks]); + + const handleAdjustBbox = useCallback(() => { + const maskBbox = calculateMaskBbox(); + if (!maskBbox) { + return; + } + + const padding = maskBlur + 8; + const adjustedBbox: Rect = { + x: maskBbox.x - padding, + y: maskBbox.y - padding, + width: maskBbox.width + padding * 2, + height: maskBbox.height + padding * 2, + }; + + dispatch(bboxChangedFromCanvas(adjustedBbox)); + }, [dispatch, calculateMaskBbox, maskBlur]); + + const isAdjustBboxAllowed = useMemo(() => { + const hasValidMasks = inpaintMasks.some((mask) => mask.isEnabled && mask.objects && mask.objects.length > 0); + return hasValidMasks; + }, [inpaintMasks]); + + useRegisteredHotkeys({ + id: 'adjustBbox', + category: 'canvas', + callback: handleAdjustBbox, + options: { enabled: isAdjustBboxAllowed && !isBusy, preventDefault: true }, + dependencies: [isAdjustBboxAllowed, isBusy, handleAdjustBbox], + }); +}; \ No newline at end of file diff --git a/invokeai/frontend/web/src/features/controlLayers/hooks/useCanvasInvertMaskHotkey.ts b/invokeai/frontend/web/src/features/controlLayers/hooks/useCanvasInvertMaskHotkey.ts new file mode 100644 index 00000000000..699c63354d4 --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/hooks/useCanvasInvertMaskHotkey.ts @@ -0,0 +1,52 @@ +import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; +import { useAssertSingleton } from 'common/hooks/useAssertSingleton'; +import { useCanvasIsBusy } from 'features/controlLayers/hooks/useCanvasIsBusy'; +import { inpaintMaskInverted } from 'features/controlLayers/store/canvasSlice'; +import { selectCanvasSlice, selectSelectedEntityIdentifier } from 'features/controlLayers/store/selectors'; +import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData'; +import { useCallback, useMemo } from 'react'; + +export const useCanvasInvertMaskHotkey = () => { + useAssertSingleton('useCanvasInvertMaskHotkey'); + const dispatch = useAppDispatch(); + const selectedEntityIdentifier = useAppSelector(selectSelectedEntityIdentifier); + const canvasSlice = useAppSelector(selectCanvasSlice); + const isBusy = useCanvasIsBusy(); + + const handleInvertMask = useCallback(() => { + if (!selectedEntityIdentifier || selectedEntityIdentifier.type !== 'inpaint_mask') { + return; + } + + // Check if the selected entity has objects and there's a valid bounding box + const entity = canvasSlice.inpaintMasks.entities.find((entity) => entity.id === selectedEntityIdentifier.id); + const hasObjects = entity?.objects && entity.objects.length > 0; + const hasBbox = canvasSlice.bbox.rect.width > 0 && canvasSlice.bbox.rect.height > 0; + + if (!hasObjects || !hasBbox) { + return; + } + + dispatch(inpaintMaskInverted({ entityIdentifier: selectedEntityIdentifier as any })); + }, [dispatch, selectedEntityIdentifier, canvasSlice]); + + const isInvertMaskAllowed = useMemo(() => { + if (!selectedEntityIdentifier || selectedEntityIdentifier.type !== 'inpaint_mask') { + return false; + } + + const entity = canvasSlice.inpaintMasks.entities.find((entity) => entity.id === selectedEntityIdentifier.id); + const hasObjects = entity?.objects && entity.objects.length > 0; + const hasBbox = canvasSlice.bbox.rect.width > 0 && canvasSlice.bbox.rect.height > 0; + + return hasObjects && hasBbox; + }, [selectedEntityIdentifier, canvasSlice]); + + useRegisteredHotkeys({ + id: 'invertMask', + category: 'canvas', + callback: handleInvertMask, + options: { enabled: isInvertMaskAllowed && !isBusy, preventDefault: true }, + dependencies: [isInvertMaskAllowed, isBusy, handleInvertMask], + }); +}; \ No newline at end of file diff --git a/invokeai/frontend/web/src/features/system/components/HotkeysModal/useHotkeyData.ts b/invokeai/frontend/web/src/features/system/components/HotkeysModal/useHotkeyData.ts index 0241f45cecd..32fb49fa33b 100644 --- a/invokeai/frontend/web/src/features/system/components/HotkeysModal/useHotkeyData.ts +++ b/invokeai/frontend/web/src/features/system/components/HotkeysModal/useHotkeyData.ts @@ -123,6 +123,8 @@ export const useHotkeyData = (): HotkeysData => { addHotkey('canvas', 'applySegmentAnything', ['enter']); addHotkey('canvas', 'cancelSegmentAnything', ['esc']); addHotkey('canvas', 'toggleNonRasterLayers', ['shift+h']); + addHotkey('canvas', 'invertMask', ['shift+v']); + addHotkey('canvas', 'adjustBbox', ['shift+b']); // Workflows addHotkey('workflows', 'addNode', ['shift+a', 'space']); From f9a49e21c9a8ee81009c3328c9af96cdb5fc39ee Mon Sep 17 00:00:00 2001 From: Kent Keirsey <31807370+hipsterusername@users.noreply.github.com> Date: Tue, 1 Jul 2025 11:53:27 -0400 Subject: [PATCH 07/26] Fix bugs. Extract into transform utilities --- .../InpaintMask/InpaintMaskBboxAdjuster.tsx | 110 +++--- .../components/Toolbar/CanvasToolbar.tsx | 4 +- .../hooks/useCanvasAdjustBboxHotkey.ts | 100 +++--- .../controlLayers/store/canvasSlice.ts | 25 +- .../controlLayers/util/coordinateTransform.ts | 340 ++++++++++++++++++ .../controlLayers/util/maskObjectTransform.ts | 241 +++++++++++++ 6 files changed, 687 insertions(+), 133 deletions(-) create mode 100644 invokeai/frontend/web/src/features/controlLayers/util/coordinateTransform.ts create mode 100644 invokeai/frontend/web/src/features/controlLayers/util/maskObjectTransform.ts diff --git a/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskBboxAdjuster.tsx b/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskBboxAdjuster.tsx index f5c39088bd7..74390df5145 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskBboxAdjuster.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskBboxAdjuster.tsx @@ -3,7 +3,17 @@ import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { bboxChangedFromCanvas } from 'features/controlLayers/store/canvasSlice'; import { selectMaskBlur } from 'features/controlLayers/store/paramsSlice'; import { selectCanvasSlice } from 'features/controlLayers/store/selectors'; -import type { Rect } from 'features/controlLayers/store/types'; +import type { + Rect, + CanvasBrushLineState, + CanvasBrushLineWithPressureState, + CanvasEraserLineState, + CanvasEraserLineWithPressureState, + CanvasRectState, + CanvasImageState, +} from 'features/controlLayers/store/types'; +import { transformMaskObjectsRelativeToBbox, calculateMaskBoundsFromBitmap } from 'features/controlLayers/util/maskObjectTransform'; +import { convertTransformedToOriginal } from 'features/controlLayers/util/coordinateTransform'; import { memo, useCallback, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { PiCropBold } from 'react-icons/pi'; @@ -14,8 +24,9 @@ export const InpaintMaskBboxAdjuster = memo(() => { const canvasSlice = useAppSelector(selectCanvasSlice); const maskBlur = useAppSelector(selectMaskBlur); - // Get all inpaint mask entities + // Get all inpaint mask entities and bbox const inpaintMasks = canvasSlice.inpaintMasks.entities; + const bboxRect = canvasSlice.bbox.rect; // Calculate the bounding box that contains all inpaint masks const calculateMaskBbox = useCallback((): Rect | null => { @@ -23,84 +34,47 @@ export const InpaintMaskBboxAdjuster = memo(() => { return null; } - let minX = Infinity; - let minY = Infinity; - let maxX = -Infinity; - let maxY = -Infinity; - - // Iterate through all inpaint masks to find the overall bounds + // Collect all mask objects from enabled masks + const allObjects: ( + | CanvasBrushLineState + | CanvasBrushLineWithPressureState + | CanvasEraserLineState + | CanvasEraserLineWithPressureState + | CanvasRectState + | CanvasImageState + )[] = []; + for (const mask of inpaintMasks) { - if (!mask.isEnabled || mask.objects.length === 0) { + if (!mask.isEnabled || !mask.objects || mask.objects.length === 0) { continue; } - // Calculate bounds for this mask's objects - for (const obj of mask.objects) { - let objMinX = 0; - let objMinY = 0; - let objMaxX = 0; - let objMaxY = 0; - - if (obj.type === 'rect') { - objMinX = mask.position.x + obj.rect.x; - objMinY = mask.position.y + obj.rect.y; - objMaxX = objMinX + obj.rect.width; - objMaxY = objMinY + obj.rect.height; - } else if ( - obj.type === 'brush_line' || - obj.type === 'brush_line_with_pressure' || - obj.type === 'eraser_line' || - obj.type === 'eraser_line_with_pressure' - ) { - // For lines, find the min/max points - for (let i = 0; i < obj.points.length; i += 2) { - const x = mask.position.x + (obj.points[i] ?? 0); - const y = mask.position.y + (obj.points[i + 1] ?? 0); - - if (i === 0) { - objMinX = objMaxX = x; - objMinY = objMaxY = y; - } else { - objMinX = Math.min(objMinX, x); - objMinY = Math.min(objMinY, y); - objMaxX = Math.max(objMaxX, x); - objMaxY = Math.max(objMaxY, y); - } - } - // Add stroke width to account for line thickness - const strokeRadius = (obj.strokeWidth ?? 50) / 2; - objMinX -= strokeRadius; - objMinY -= strokeRadius; - objMaxX += strokeRadius; - objMaxY += strokeRadius; - } else if (obj.type === 'image') { - // Image objects are positioned at the entity's position - objMinX = mask.position.x; - objMinY = mask.position.y; - objMaxX = objMinX + obj.image.width; - objMaxY = objMinY + obj.image.height; - } + // Transform objects to be relative to the bbox + const transformedObjects = transformMaskObjectsRelativeToBbox(mask.objects, bboxRect); + // Convert back to original types for compatibility + const originalObjects = transformedObjects.map(convertTransformedToOriginal); + allObjects.push(...originalObjects); + } - // Update overall bounds - minX = Math.min(minX, objMinX); - minY = Math.min(minY, objMinY); - maxX = Math.max(maxX, objMaxX); - maxY = Math.max(maxY, objMaxY); - } + if (allObjects.length === 0) { + return null; } - // If no valid bounds found, return null - if (minX === Infinity || minY === Infinity || maxX === -Infinity || maxY === -Infinity) { + // Calculate bounds from the rendered bitmap for accurate results + const maskBounds = calculateMaskBoundsFromBitmap(allObjects, bboxRect.width, bboxRect.height); + + if (!maskBounds) { return null; } + // Convert back to world coordinates relative to the bbox return { - x: minX, - y: minY, - width: maxX - minX, - height: maxY - minY, + x: bboxRect.x + maskBounds.x, + y: bboxRect.y + maskBounds.y, + width: maskBounds.width, + height: maskBounds.height, }; - }, [inpaintMasks]); + }, [inpaintMasks, bboxRect]); const maskBbox = useMemo(() => calculateMaskBbox(), [calculateMaskBbox]); diff --git a/invokeai/frontend/web/src/features/controlLayers/components/Toolbar/CanvasToolbar.tsx b/invokeai/frontend/web/src/features/controlLayers/components/Toolbar/CanvasToolbar.tsx index 86e57977e99..062428343b4 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/Toolbar/CanvasToolbar.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/Toolbar/CanvasToolbar.tsx @@ -9,15 +9,15 @@ import { CanvasToolbarResetViewButton } from 'features/controlLayers/components/ import { CanvasToolbarSaveToGalleryButton } from 'features/controlLayers/components/Toolbar/CanvasToolbarSaveToGalleryButton'; import { CanvasToolbarScale } from 'features/controlLayers/components/Toolbar/CanvasToolbarScale'; import { CanvasToolbarUndoButton } from 'features/controlLayers/components/Toolbar/CanvasToolbarUndoButton'; +import { useCanvasAdjustBboxHotkey } from 'features/controlLayers/hooks/useCanvasAdjustBboxHotkey'; import { useCanvasDeleteLayerHotkey } from 'features/controlLayers/hooks/useCanvasDeleteLayerHotkey'; import { useCanvasEntityQuickSwitchHotkey } from 'features/controlLayers/hooks/useCanvasEntityQuickSwitchHotkey'; import { useCanvasFilterHotkey } from 'features/controlLayers/hooks/useCanvasFilterHotkey'; +import { useCanvasInvertMaskHotkey } from 'features/controlLayers/hooks/useCanvasInvertMaskHotkey'; import { useCanvasResetLayerHotkey } from 'features/controlLayers/hooks/useCanvasResetLayerHotkey'; import { useCanvasToggleNonRasterLayersHotkey } from 'features/controlLayers/hooks/useCanvasToggleNonRasterLayersHotkey'; import { useCanvasTransformHotkey } from 'features/controlLayers/hooks/useCanvasTransformHotkey'; import { useCanvasUndoRedoHotkeys } from 'features/controlLayers/hooks/useCanvasUndoRedoHotkeys'; -import { useCanvasInvertMaskHotkey } from 'features/controlLayers/hooks/useCanvasInvertMaskHotkey'; -import { useCanvasAdjustBboxHotkey } from 'features/controlLayers/hooks/useCanvasAdjustBboxHotkey'; import { useNextPrevEntityHotkeys } from 'features/controlLayers/hooks/useNextPrevEntity'; import { memo } from 'react'; diff --git a/invokeai/frontend/web/src/features/controlLayers/hooks/useCanvasAdjustBboxHotkey.ts b/invokeai/frontend/web/src/features/controlLayers/hooks/useCanvasAdjustBboxHotkey.ts index d3a87b11319..9e07d1f639e 100644 --- a/invokeai/frontend/web/src/features/controlLayers/hooks/useCanvasAdjustBboxHotkey.ts +++ b/invokeai/frontend/web/src/features/controlLayers/hooks/useCanvasAdjustBboxHotkey.ts @@ -4,8 +4,18 @@ import { useCanvasIsBusy } from 'features/controlLayers/hooks/useCanvasIsBusy'; import { bboxChangedFromCanvas } from 'features/controlLayers/store/canvasSlice'; import { selectMaskBlur } from 'features/controlLayers/store/paramsSlice'; import { selectCanvasSlice } from 'features/controlLayers/store/selectors'; +import type { + Rect, + CanvasBrushLineState, + CanvasBrushLineWithPressureState, + CanvasEraserLineState, + CanvasEraserLineWithPressureState, + CanvasRectState, + CanvasImageState, +} from 'features/controlLayers/store/types'; +import { transformMaskObjectsRelativeToBbox, calculateMaskBoundsFromBitmap } from 'features/controlLayers/util/maskObjectTransform'; +import { convertTransformedToOriginal } from 'features/controlLayers/util/coordinateTransform'; import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData'; -import type { Rect } from 'features/controlLayers/store/types'; import { useCallback, useMemo } from 'react'; export const useCanvasAdjustBboxHotkey = () => { @@ -15,71 +25,55 @@ export const useCanvasAdjustBboxHotkey = () => { const maskBlur = useAppSelector(selectMaskBlur); const isBusy = useCanvasIsBusy(); const inpaintMasks = canvasSlice.inpaintMasks.entities; + const bboxRect = canvasSlice.bbox.rect; // Calculate the bounding box that contains all inpaint masks const calculateMaskBbox = useCallback((): Rect | null => { if (inpaintMasks.length === 0) { return null; } - let minX = Infinity; - let minY = Infinity; - let maxX = -Infinity; - let maxY = -Infinity; + + // Collect all mask objects from enabled masks + const allObjects: ( + | CanvasBrushLineState + | CanvasBrushLineWithPressureState + | CanvasEraserLineState + | CanvasEraserLineWithPressureState + | CanvasRectState + | CanvasImageState + )[] = []; + for (const mask of inpaintMasks) { if (!mask.isEnabled || !mask.objects || mask.objects.length === 0) { continue; } - for (const obj of mask.objects) { - let objMinX = 0; - let objMinY = 0; - let objMaxX = 0; - let objMaxY = 0; - if (obj.type === 'rect') { - objMinX = mask.position.x + obj.rect.x; - objMinY = mask.position.y + obj.rect.y; - objMaxX = objMinX + obj.rect.width; - objMaxY = objMinY + obj.rect.height; - } else if ( - obj.type === 'brush_line' || - obj.type === 'brush_line_with_pressure' || - obj.type === 'eraser_line' || - obj.type === 'eraser_line_with_pressure' - ) { - for (let i = 0; i < obj.points.length; i += 2) { - const x = mask.position.x + (obj.points[i] ?? 0); - const y = mask.position.y + (obj.points[i + 1] ?? 0); - if (i === 0) { - objMinX = objMaxX = x; - objMinY = objMaxY = y; - } else { - objMinX = Math.min(objMinX, x); - objMinY = Math.min(objMinY, y); - objMaxX = Math.max(objMaxX, x); - objMaxY = Math.max(objMaxY, y); - } - } - const strokeRadius = (obj.strokeWidth ?? 50) / 2; - objMinX -= strokeRadius; - objMinY -= strokeRadius; - objMaxX += strokeRadius; - objMaxY += strokeRadius; - } else if (obj.type === 'image') { - objMinX = mask.position.x; - objMinY = mask.position.y; - objMaxX = objMinX + obj.image.width; - objMaxY = objMinY + obj.image.height; - } - minX = Math.min(minX, objMinX); - minY = Math.min(minY, objMinY); - maxX = Math.max(maxX, objMaxX); - maxY = Math.max(maxY, objMaxY); - } + + // Transform objects to be relative to the bbox + const transformedObjects = transformMaskObjectsRelativeToBbox(mask.objects, bboxRect); + // Convert back to original types for compatibility + const originalObjects = transformedObjects.map(convertTransformedToOriginal); + allObjects.push(...originalObjects); } - if (minX === Infinity || minY === Infinity || maxX === -Infinity || maxY === -Infinity) { + + if (allObjects.length === 0) { return null; } - return { x: minX, y: minY, width: maxX - minX, height: maxY - minY }; - }, [inpaintMasks]); + + // Calculate bounds from the rendered bitmap for accurate results + const maskBounds = calculateMaskBoundsFromBitmap(allObjects, bboxRect.width, bboxRect.height); + + if (!maskBounds) { + return null; + } + + // Convert back to world coordinates relative to the bbox + return { + x: bboxRect.x + maskBounds.x, + y: bboxRect.y + maskBounds.y, + width: maskBounds.width, + height: maskBounds.height, + }; + }, [inpaintMasks, bboxRect]); const handleAdjustBbox = useCallback(() => { const maskBbox = calculateMaskBbox(); diff --git a/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts b/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts index 869cfd0473f..341f2f0e428 100644 --- a/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts +++ b/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts @@ -1010,10 +1010,7 @@ export const canvasSlice = createSlice({ return; } - // For now, we'll use a simple approach: create a full rectangle and add eraser lines - // This is a temporary solution until we can properly handle the bitmap conversion - - // Get the bbox dimensions for the mask + // Get the current bbox dimensions for the mask const bboxRect = state.bbox.rect; // Create a full rectangle covering the bbox @@ -1038,15 +1035,23 @@ export const canvasSlice = createSlice({ | CanvasBrushLineWithPressureState )[] = [fillRect]; + // Create a clip region that constrains all objects to the bbox + const bboxClip = { + x: bboxRect.x - entity.position.x, + y: bboxRect.y - entity.position.y, + width: bboxRect.width, + height: bboxRect.height, + }; + for (const obj of entity.objects) { if (obj.type === 'brush_line') { - // Convert brush line to eraser line + // Convert brush line to eraser line, ensuring it's clipped to the bbox const eraserLine: CanvasEraserLineState = { id: getPrefixedId('eraser_line'), type: 'eraser_line', strokeWidth: obj.strokeWidth, points: obj.points, - clip: obj.clip, + clip: bboxClip, // Always clip to the current bbox }; invertedObjects.push(eraserLine); } else if (obj.type === 'brush_line_with_pressure') { @@ -1056,7 +1061,7 @@ export const canvasSlice = createSlice({ type: 'eraser_line_with_pressure', strokeWidth: obj.strokeWidth, points: obj.points, - clip: obj.clip, + clip: bboxClip, // Always clip to the current bbox }; invertedObjects.push(eraserLine); } else if (obj.type === 'rect') { @@ -1080,7 +1085,7 @@ export const canvasSlice = createSlice({ type: 'eraser_line', points, strokeWidth: Math.max(width, height) / 2, // Use a stroke width that covers the rectangle - clip: null, + clip: bboxClip, // Always clip to the current bbox }; invertedObjects.push(eraserLine); } else if (obj.type === 'eraser_line') { @@ -1090,7 +1095,7 @@ export const canvasSlice = createSlice({ type: 'brush_line', strokeWidth: obj.strokeWidth, points: obj.points, - clip: obj.clip, + clip: bboxClip, // Always clip to the current bbox color: { r: 255, g: 255, b: 255, a: 1 }, }; invertedObjects.push(brushLine); @@ -1101,7 +1106,7 @@ export const canvasSlice = createSlice({ type: 'brush_line_with_pressure', strokeWidth: obj.strokeWidth, points: obj.points, - clip: obj.clip, + clip: bboxClip, // Always clip to the current bbox color: { r: 255, g: 255, b: 255, a: 1 }, }; invertedObjects.push(brushLine); diff --git a/invokeai/frontend/web/src/features/controlLayers/util/coordinateTransform.ts b/invokeai/frontend/web/src/features/controlLayers/util/coordinateTransform.ts new file mode 100644 index 00000000000..db7f9c1adab --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/util/coordinateTransform.ts @@ -0,0 +1,340 @@ +import type { + CanvasBrushLineState, + CanvasBrushLineWithPressureState, + CanvasEraserLineState, + CanvasEraserLineWithPressureState, + CanvasRectState, + CanvasImageState, + Coordinate, + Rect, +} from 'features/controlLayers/store/types'; + +/** + * Type for mask objects with transformed coordinates. + * This preserves the discriminated union structure while allowing coordinate transformations. + */ +export type TransformedMaskObject = + | TransformedBrushLine + | TransformedBrushLineWithPressure + | TransformedEraserLine + | TransformedEraserLineWithPressure + | TransformedRect + | TransformedImage; + +export interface TransformedBrushLine { + id: string; + type: 'brush_line'; + points: number[]; + strokeWidth: number; + color: { r: number; g: number; b: number; a: number }; + clip?: Rect | null; +} + +export interface TransformedBrushLineWithPressure { + id: string; + type: 'brush_line_with_pressure'; + points: number[]; + strokeWidth: number; + color: { r: number; g: number; b: number; a: number }; + clip?: Rect | null; +} + +export interface TransformedEraserLine { + id: string; + type: 'eraser_line'; + points: number[]; + strokeWidth: number; + clip?: Rect | null; +} + +export interface TransformedEraserLineWithPressure { + id: string; + type: 'eraser_line_with_pressure'; + points: number[]; + strokeWidth: number; + clip?: Rect | null; +} + +export interface TransformedRect { + id: string; + type: 'rect'; + rect: Rect; + color: { r: number; g: number; b: number; a: number }; +} + +export interface TransformedImage { + id: string; + type: 'image'; + image: { width: number; height: number; dataURL: string } | { width: number; height: number; image_name: string }; +} + +/** + * Transforms a mask object by applying a coordinate offset. + * @param obj The mask object to transform + * @param offset The offset to apply to coordinates + * @returns A new mask object with transformed coordinates + */ +export function transformMaskObject( + obj: CanvasBrushLineState | CanvasBrushLineWithPressureState | CanvasEraserLineState | CanvasEraserLineWithPressureState | CanvasRectState | CanvasImageState, + offset: Coordinate +): TransformedMaskObject { + switch (obj.type) { + case 'brush_line': + return { + ...obj, + points: transformPoints(obj.points, offset), + clip: obj.clip ? transformRect(obj.clip, offset) : null, + }; + case 'brush_line_with_pressure': + return { + ...obj, + points: transformPoints(obj.points, offset), + clip: obj.clip ? transformRect(obj.clip, offset) : null, + }; + case 'eraser_line': + return { + ...obj, + points: transformPoints(obj.points, offset), + clip: obj.clip ? transformRect(obj.clip, offset) : null, + }; + case 'eraser_line_with_pressure': + return { + ...obj, + points: transformPoints(obj.points, offset), + clip: obj.clip ? transformRect(obj.clip, offset) : null, + }; + case 'rect': + return { + ...obj, + rect: transformRect(obj.rect, offset), + }; + case 'image': + return { + ...obj, + }; + } +} + +/** + * Transforms an array of points by applying a coordinate offset. + * @param points Array of numbers representing [x1, y1, x2, y2, ...] + * @param offset The offset to apply + * @returns New array with transformed coordinates + */ +export function transformPoints(points: number[], offset: Coordinate): number[] { + const transformed: number[] = []; + for (let i = 0; i < points.length; i += 2) { + transformed.push((points[i] ?? 0) + offset.x); + transformed.push((points[i + 1] ?? 0) + offset.y); + } + return transformed; +} + +/** + * Transforms a rectangle by applying a coordinate offset. + * @param rect The rectangle to transform + * @param offset The offset to apply + * @returns New rectangle with transformed coordinates + */ +export function transformRect(rect: Rect, offset: Coordinate): Rect { + return { + x: rect.x + offset.x, + y: rect.y + offset.y, + width: rect.width, + height: rect.height, + }; +} + +/** + * Clips a mask object to the boundaries of a container rectangle. + * @param obj The mask object to clip + * @param container The container rectangle to clip to + * @returns A new mask object clipped to the container boundaries, or null if completely outside + */ +export function clipMaskObjectToContainer( + obj: TransformedMaskObject, + container: Rect +): TransformedMaskObject | null { + switch (obj.type) { + case 'brush_line': + case 'brush_line_with_pressure': + case 'eraser_line': + case 'eraser_line_with_pressure': + return clipLineToContainer(obj, container); + case 'rect': + return clipRectToContainer(obj, container); + case 'image': + return clipImageToContainer(obj, container); + } +} + +/** + * Clips a line object to container boundaries. + */ +function clipLineToContainer( + obj: TransformedBrushLine | TransformedBrushLineWithPressure | TransformedEraserLine | TransformedEraserLineWithPressure, + container: Rect +): typeof obj | null { + // For lines, we clip the points to the container boundaries + const clippedPoints: number[] = []; + + for (let i = 0; i < obj.points.length; i += 2) { + const x = obj.points[i] ?? 0; + const y = obj.points[i + 1] ?? 0; + + // Clip coordinates to container boundaries + const clippedX = Math.max(container.x, Math.min(container.x + container.width, x)); + const clippedY = Math.max(container.y, Math.min(container.y + container.height, y)); + + clippedPoints.push(clippedX, clippedY); + } + + // If no points remain, return null + if (clippedPoints.length === 0) { + return null; + } + + return { + ...obj, + points: clippedPoints, + clip: container, + }; +} + +/** + * Clips a rectangle object to container boundaries. + */ +function clipRectToContainer(obj: TransformedRect, container: Rect): TransformedRect | null { + const rect = obj.rect; + + // Calculate intersection + const left = Math.max(rect.x, container.x); + const top = Math.max(rect.y, container.y); + const right = Math.min(rect.x + rect.width, container.x + container.width); + const bottom = Math.min(rect.y + rect.height, container.y + container.height); + + // If no intersection, return null + if (left >= right || top >= bottom) { + return null; + } + + return { + ...obj, + rect: { + x: left, + y: top, + width: right - left, + height: bottom - top, + }, + }; +} + +/** + * Clips an image object to container boundaries. + */ +function clipImageToContainer(obj: TransformedImage, container: Rect): TransformedImage | null { + // For images, we don't clip them - they remain as-is + return obj; +} + +/** + * Calculates the effective bounds of a mask object. + * @param obj The mask object to calculate bounds for + * @returns The bounding rectangle, or null if the object has no effective bounds + */ +export function calculateMaskObjectBounds(obj: TransformedMaskObject): Rect | null { + switch (obj.type) { + case 'brush_line': + case 'brush_line_with_pressure': + case 'eraser_line': + case 'eraser_line_with_pressure': + return calculateLineBounds(obj); + case 'rect': + return obj.rect; + case 'image': + return calculateImageBounds(obj); + } +} + +/** + * Calculates bounds for a line object. + */ +function calculateLineBounds( + obj: TransformedBrushLine | TransformedBrushLineWithPressure | TransformedEraserLine | TransformedEraserLineWithPressure +): Rect | null { + if (obj.points.length < 2) { + return null; + } + + let minX = obj.points[0] ?? 0; + let minY = obj.points[1] ?? 0; + let maxX = minX; + let maxY = minY; + + for (let i = 2; i < obj.points.length; i += 2) { + const x = obj.points[i] ?? 0; + const y = obj.points[i + 1] ?? 0; + minX = Math.min(minX, x); + minY = Math.min(minY, y); + maxX = Math.max(maxX, x); + maxY = Math.max(maxY, y); + } + + // Add stroke width to bounds + const strokeRadius = obj.strokeWidth / 2; + return { + x: minX - strokeRadius, + y: minY - strokeRadius, + width: maxX - minX + obj.strokeWidth, + height: maxY - minY + obj.strokeWidth, + }; +} + +/** + * Calculates bounds for an image object. + */ +function calculateImageBounds(obj: TransformedImage): Rect | null { + return { + x: 0, + y: 0, + width: obj.image.width, + height: obj.image.height, + }; +} + +/** + * Converts a TransformedMaskObject back to its original mask object type. + * This is needed for compatibility with functions that expect the original types. + * @param obj The transformed mask object to convert back + * @returns The original mask object type + */ +export function convertTransformedToOriginal( + obj: TransformedMaskObject +): CanvasBrushLineState | CanvasBrushLineWithPressureState | CanvasEraserLineState | CanvasEraserLineWithPressureState | CanvasRectState | CanvasImageState { + switch (obj.type) { + case 'brush_line': + return { + ...obj, + clip: obj.clip ?? null, + }; + case 'brush_line_with_pressure': + return { + ...obj, + clip: obj.clip ?? null, + }; + case 'eraser_line': + return { + ...obj, + clip: obj.clip ?? null, + }; + case 'eraser_line_with_pressure': + return { + ...obj, + clip: obj.clip ?? null, + }; + case 'rect': + return obj; + case 'image': + return obj; + } +} \ No newline at end of file diff --git a/invokeai/frontend/web/src/features/controlLayers/util/maskObjectTransform.ts b/invokeai/frontend/web/src/features/controlLayers/util/maskObjectTransform.ts new file mode 100644 index 00000000000..bc8ab7b038b --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/util/maskObjectTransform.ts @@ -0,0 +1,241 @@ +import type { + CanvasBrushLineState, + CanvasBrushLineWithPressureState, + CanvasEraserLineState, + CanvasEraserLineWithPressureState, + CanvasRectState, + CanvasImageState, + Coordinate, + Rect, +} from 'features/controlLayers/store/types'; +import { + transformMaskObject, + clipMaskObjectToContainer, + calculateMaskObjectBounds, + convertTransformedToOriginal, + type TransformedMaskObject, +} from './coordinateTransform'; +import { maskObjectsToBitmap } from './bitmapToMaskObjects'; + +/** + * Transforms mask objects relative to a bounding box container. + * This adjusts all object coordinates to be relative to the bbox origin. + * @param objects Array of mask objects to transform + * @param bboxRect The bounding box to use as the container reference + * @returns Array of transformed mask objects + */ +export function transformMaskObjectsRelativeToBbox( + objects: ( + | CanvasBrushLineState + | CanvasBrushLineWithPressureState + | CanvasEraserLineState + | CanvasEraserLineWithPressureState + | CanvasRectState + | CanvasImageState + )[], + bboxRect: Rect +): TransformedMaskObject[] { + const transformedObjects: TransformedMaskObject[] = []; + + for (const obj of objects) { + // Calculate the offset to make coordinates relative to the bbox + const offset: Coordinate = { + x: -bboxRect.x, + y: -bboxRect.y, + }; + + const transformed = transformMaskObject(obj, offset); + transformedObjects.push(transformed); + } + + return transformedObjects; +} + +/** + * Clips all mask objects to the boundaries of a container rectangle. + * @param objects Array of mask objects to clip + * @param container The container rectangle to clip to + * @returns Array of clipped mask objects (null values are filtered out) + */ +export function clipMaskObjectsToContainer( + objects: TransformedMaskObject[], + container: Rect +): TransformedMaskObject[] { + return objects + .map((obj) => clipMaskObjectToContainer(obj, container)) + .filter((obj): obj is TransformedMaskObject => obj !== null); +} + +/** + * Calculates the effective bounds of all mask objects. + * @param objects Array of mask objects to calculate bounds for + * @returns The bounding rectangle containing all objects, or null if no objects + */ +export function calculateMaskObjectsBounds(objects: TransformedMaskObject[]): Rect | null { + if (objects.length === 0) { + return null; + } + + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + + for (const obj of objects) { + const bounds = calculateMaskObjectBounds(obj); + if (bounds) { + minX = Math.min(minX, bounds.x); + minY = Math.min(minY, bounds.y); + maxX = Math.max(maxX, bounds.x + bounds.width); + maxY = Math.max(maxY, bounds.y + bounds.height); + } + } + + if (minX === Infinity || minY === Infinity || maxX === -Infinity || maxY === -Infinity) { + return null; + } + + return { + x: minX, + y: minY, + width: maxX - minX, + height: maxY - minY, + }; +} + +/** + * Calculates the bounding box of a consolidated mask by rendering it to a bitmap. + * This provides the most accurate bounds by considering the actual rendered mask pixels. + * @param objects Array of mask objects to calculate bounds for + * @param canvasWidth Width of the canvas to render to + * @param canvasHeight Height of the canvas to render to + * @returns The bounding rectangle of the rendered mask, or null if no mask pixels + */ +export function calculateMaskBoundsFromBitmap( + objects: TransformedMaskObject[], + canvasWidth: number, + canvasHeight: number +): Rect | null { + if (objects.length === 0) { + return null; + } + + // Convert transformed objects back to original types for compatibility + const originalObjects = objects.map(convertTransformedToOriginal); + + // Render the consolidated mask to a bitmap + const bitmap = maskObjectsToBitmap(originalObjects, canvasWidth, canvasHeight); + const { width, height, data } = bitmap; + + // Find the actual bounds of the rendered mask + let maskMinX = width; + let maskMinY = height; + let maskMaxX = 0; + let maskMaxY = 0; + + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const pixelIndex = (y * width + x) * 4; + const alpha = data[pixelIndex + 3] ?? 0; + + // If this pixel has any opacity, it's part of the mask + if (alpha > 0) { + maskMinX = Math.min(maskMinX, x); + maskMinY = Math.min(maskMinY, y); + maskMaxX = Math.max(maskMaxX, x); + maskMaxY = Math.max(maskMaxY, y); + } + } + } + + // If no mask pixels found, return null + if (maskMinX >= maskMaxX || maskMinY >= maskMaxY) { + return null; + } + + return { + x: maskMinX, + y: maskMinY, + width: maskMaxX - maskMinX + 1, + height: maskMaxY - maskMinY + 1, + }; +} + +/** + * Inverts a mask by creating a new mask that covers the entire container except for the original mask areas. + * @param objects Array of mask objects representing the original mask + * @param container The container rectangle to invert within + * @returns Array of mask objects representing the inverted mask + */ +export function invertMask( + objects: TransformedMaskObject[], + container: Rect +): TransformedMaskObject[] { + // Create a rectangle that covers the entire container + const fullCoverageRect: TransformedMaskObject = { + id: 'inverted_mask_rect', + type: 'rect', + rect: { + x: container.x, + y: container.y, + width: container.width, + height: container.height, + }, + color: { r: 255, g: 255, b: 255, a: 1 }, + }; + + // For each original mask object, create an eraser line that removes it + const eraserObjects: TransformedMaskObject[] = []; + + for (const obj of objects) { + if (obj.type === 'rect') { + // For rectangles, create an eraser rectangle + const eraserRect: TransformedMaskObject = { + id: `eraser_${obj.id}`, + type: 'eraser_line', + points: [ + obj.rect.x, obj.rect.y, + obj.rect.x + obj.rect.width, obj.rect.y, + obj.rect.x + obj.rect.width, obj.rect.y + obj.rect.height, + obj.rect.x, obj.rect.y + obj.rect.height, + obj.rect.x, obj.rect.y, // Close the rectangle + ], + strokeWidth: 1, + clip: container, + }; + eraserObjects.push(eraserRect); + } else if ( + obj.type === 'brush_line' || + obj.type === 'brush_line_with_pressure' || + obj.type === 'eraser_line' || + obj.type === 'eraser_line_with_pressure' + ) { + // For lines, create an eraser line with the same points + const eraserLine: TransformedMaskObject = { + id: `eraser_${obj.id}`, + type: 'eraser_line', + points: [...obj.points], + strokeWidth: obj.strokeWidth, + clip: container, + }; + eraserObjects.push(eraserLine); + } + // Note: Image objects are not handled in inversion as they're not commonly used in masks + } + + return [fullCoverageRect, ...eraserObjects]; +} + +/** + * Ensures all mask objects are clipped to the current bounding box boundaries. + * This prevents masks from extending outside the bounding box after multiple inversions. + * @param objects Array of mask objects to clip + * @param bboxRect The bounding box to clip to + * @returns Array of clipped mask objects + */ +export function ensureMaskObjectsWithinBbox( + objects: TransformedMaskObject[], + bboxRect: Rect +): TransformedMaskObject[] { + return clipMaskObjectsToContainer(objects, bboxRect); +} \ No newline at end of file From 2952988d2c0ef042a5ba2b34883415def80fb3a1 Mon Sep 17 00:00:00 2001 From: Kent Keirsey <31807370+hipsterusername@users.noreply.github.com> Date: Tue, 1 Jul 2025 11:53:34 -0400 Subject: [PATCH 08/26] lints --- .../InpaintMask/InpaintMaskBboxAdjuster.tsx | 15 +++-- .../hooks/useCanvasAdjustBboxHotkey.ts | 17 +++--- .../hooks/useCanvasInvertMaskHotkey.ts | 9 ++- .../controlLayers/util/coordinateTransform.ts | 61 ++++++++++++------- .../controlLayers/util/maskObjectTransform.ts | 45 +++++++------- 5 files changed, 86 insertions(+), 61 deletions(-) diff --git a/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskBboxAdjuster.tsx b/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskBboxAdjuster.tsx index 74390df5145..cc287db24ac 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskBboxAdjuster.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskBboxAdjuster.tsx @@ -3,17 +3,20 @@ import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { bboxChangedFromCanvas } from 'features/controlLayers/store/canvasSlice'; import { selectMaskBlur } from 'features/controlLayers/store/paramsSlice'; import { selectCanvasSlice } from 'features/controlLayers/store/selectors'; -import type { - Rect, +import type { CanvasBrushLineState, CanvasBrushLineWithPressureState, CanvasEraserLineState, CanvasEraserLineWithPressureState, - CanvasRectState, CanvasImageState, + CanvasRectState, + Rect, } from 'features/controlLayers/store/types'; -import { transformMaskObjectsRelativeToBbox, calculateMaskBoundsFromBitmap } from 'features/controlLayers/util/maskObjectTransform'; import { convertTransformedToOriginal } from 'features/controlLayers/util/coordinateTransform'; +import { + calculateMaskBoundsFromBitmap, + transformMaskObjectsRelativeToBbox, +} from 'features/controlLayers/util/maskObjectTransform'; import { memo, useCallback, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { PiCropBold } from 'react-icons/pi'; @@ -43,7 +46,7 @@ export const InpaintMaskBboxAdjuster = memo(() => { | CanvasRectState | CanvasImageState )[] = []; - + for (const mask of inpaintMasks) { if (!mask.isEnabled || !mask.objects || mask.objects.length === 0) { continue; @@ -62,7 +65,7 @@ export const InpaintMaskBboxAdjuster = memo(() => { // Calculate bounds from the rendered bitmap for accurate results const maskBounds = calculateMaskBoundsFromBitmap(allObjects, bboxRect.width, bboxRect.height); - + if (!maskBounds) { return null; } diff --git a/invokeai/frontend/web/src/features/controlLayers/hooks/useCanvasAdjustBboxHotkey.ts b/invokeai/frontend/web/src/features/controlLayers/hooks/useCanvasAdjustBboxHotkey.ts index 9e07d1f639e..7e988b2be1d 100644 --- a/invokeai/frontend/web/src/features/controlLayers/hooks/useCanvasAdjustBboxHotkey.ts +++ b/invokeai/frontend/web/src/features/controlLayers/hooks/useCanvasAdjustBboxHotkey.ts @@ -4,17 +4,20 @@ import { useCanvasIsBusy } from 'features/controlLayers/hooks/useCanvasIsBusy'; import { bboxChangedFromCanvas } from 'features/controlLayers/store/canvasSlice'; import { selectMaskBlur } from 'features/controlLayers/store/paramsSlice'; import { selectCanvasSlice } from 'features/controlLayers/store/selectors'; -import type { - Rect, +import type { CanvasBrushLineState, CanvasBrushLineWithPressureState, CanvasEraserLineState, CanvasEraserLineWithPressureState, - CanvasRectState, CanvasImageState, + CanvasRectState, + Rect, } from 'features/controlLayers/store/types'; -import { transformMaskObjectsRelativeToBbox, calculateMaskBoundsFromBitmap } from 'features/controlLayers/util/maskObjectTransform'; import { convertTransformedToOriginal } from 'features/controlLayers/util/coordinateTransform'; +import { + calculateMaskBoundsFromBitmap, + transformMaskObjectsRelativeToBbox, +} from 'features/controlLayers/util/maskObjectTransform'; import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData'; import { useCallback, useMemo } from 'react'; @@ -42,7 +45,7 @@ export const useCanvasAdjustBboxHotkey = () => { | CanvasRectState | CanvasImageState )[] = []; - + for (const mask of inpaintMasks) { if (!mask.isEnabled || !mask.objects || mask.objects.length === 0) { continue; @@ -61,7 +64,7 @@ export const useCanvasAdjustBboxHotkey = () => { // Calculate bounds from the rendered bitmap for accurate results const maskBounds = calculateMaskBoundsFromBitmap(allObjects, bboxRect.width, bboxRect.height); - + if (!maskBounds) { return null; } @@ -104,4 +107,4 @@ export const useCanvasAdjustBboxHotkey = () => { options: { enabled: isAdjustBboxAllowed && !isBusy, preventDefault: true }, dependencies: [isAdjustBboxAllowed, isBusy, handleAdjustBbox], }); -}; \ No newline at end of file +}; diff --git a/invokeai/frontend/web/src/features/controlLayers/hooks/useCanvasInvertMaskHotkey.ts b/invokeai/frontend/web/src/features/controlLayers/hooks/useCanvasInvertMaskHotkey.ts index 699c63354d4..9b9db039acc 100644 --- a/invokeai/frontend/web/src/features/controlLayers/hooks/useCanvasInvertMaskHotkey.ts +++ b/invokeai/frontend/web/src/features/controlLayers/hooks/useCanvasInvertMaskHotkey.ts @@ -3,6 +3,7 @@ import { useAssertSingleton } from 'common/hooks/useAssertSingleton'; import { useCanvasIsBusy } from 'features/controlLayers/hooks/useCanvasIsBusy'; import { inpaintMaskInverted } from 'features/controlLayers/store/canvasSlice'; import { selectCanvasSlice, selectSelectedEntityIdentifier } from 'features/controlLayers/store/selectors'; +import type { CanvasEntityIdentifier } from 'features/controlLayers/store/types'; import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData'; import { useCallback, useMemo } from 'react'; @@ -27,7 +28,11 @@ export const useCanvasInvertMaskHotkey = () => { return; } - dispatch(inpaintMaskInverted({ entityIdentifier: selectedEntityIdentifier as any })); + dispatch( + inpaintMaskInverted({ + entityIdentifier: selectedEntityIdentifier as CanvasEntityIdentifier<'inpaint_mask'>, + }) + ); }, [dispatch, selectedEntityIdentifier, canvasSlice]); const isInvertMaskAllowed = useMemo(() => { @@ -49,4 +54,4 @@ export const useCanvasInvertMaskHotkey = () => { options: { enabled: isInvertMaskAllowed && !isBusy, preventDefault: true }, dependencies: [isInvertMaskAllowed, isBusy, handleInvertMask], }); -}; \ No newline at end of file +}; diff --git a/invokeai/frontend/web/src/features/controlLayers/util/coordinateTransform.ts b/invokeai/frontend/web/src/features/controlLayers/util/coordinateTransform.ts index db7f9c1adab..71234037883 100644 --- a/invokeai/frontend/web/src/features/controlLayers/util/coordinateTransform.ts +++ b/invokeai/frontend/web/src/features/controlLayers/util/coordinateTransform.ts @@ -3,8 +3,8 @@ import type { CanvasBrushLineWithPressureState, CanvasEraserLineState, CanvasEraserLineWithPressureState, - CanvasRectState, CanvasImageState, + CanvasRectState, Coordinate, Rect, } from 'features/controlLayers/store/types'; @@ -75,7 +75,13 @@ export interface TransformedImage { * @returns A new mask object with transformed coordinates */ export function transformMaskObject( - obj: CanvasBrushLineState | CanvasBrushLineWithPressureState | CanvasEraserLineState | CanvasEraserLineWithPressureState | CanvasRectState | CanvasImageState, + obj: + | CanvasBrushLineState + | CanvasBrushLineWithPressureState + | CanvasEraserLineState + | CanvasEraserLineWithPressureState + | CanvasRectState + | CanvasImageState, offset: Coordinate ): TransformedMaskObject { switch (obj.type) { @@ -151,10 +157,7 @@ export function transformRect(rect: Rect, offset: Coordinate): Rect { * @param container The container rectangle to clip to * @returns A new mask object clipped to the container boundaries, or null if completely outside */ -export function clipMaskObjectToContainer( - obj: TransformedMaskObject, - container: Rect -): TransformedMaskObject | null { +export function clipMaskObjectToContainer(obj: TransformedMaskObject, container: Rect): TransformedMaskObject | null { switch (obj.type) { case 'brush_line': case 'brush_line_with_pressure': @@ -172,28 +175,32 @@ export function clipMaskObjectToContainer( * Clips a line object to container boundaries. */ function clipLineToContainer( - obj: TransformedBrushLine | TransformedBrushLineWithPressure | TransformedEraserLine | TransformedEraserLineWithPressure, + obj: + | TransformedBrushLine + | TransformedBrushLineWithPressure + | TransformedEraserLine + | TransformedEraserLineWithPressure, container: Rect ): typeof obj | null { // For lines, we clip the points to the container boundaries const clippedPoints: number[] = []; - + for (let i = 0; i < obj.points.length; i += 2) { const x = obj.points[i] ?? 0; const y = obj.points[i + 1] ?? 0; - + // Clip coordinates to container boundaries const clippedX = Math.max(container.x, Math.min(container.x + container.width, x)); const clippedY = Math.max(container.y, Math.min(container.y + container.height, y)); - + clippedPoints.push(clippedX, clippedY); } - + // If no points remain, return null if (clippedPoints.length === 0) { return null; } - + return { ...obj, points: clippedPoints, @@ -206,18 +213,18 @@ function clipLineToContainer( */ function clipRectToContainer(obj: TransformedRect, container: Rect): TransformedRect | null { const rect = obj.rect; - + // Calculate intersection const left = Math.max(rect.x, container.x); const top = Math.max(rect.y, container.y); const right = Math.min(rect.x + rect.width, container.x + container.width); const bottom = Math.min(rect.y + rect.height, container.y + container.height); - + // If no intersection, return null if (left >= right || top >= bottom) { return null; } - + return { ...obj, rect: { @@ -232,7 +239,7 @@ function clipRectToContainer(obj: TransformedRect, container: Rect): Transformed /** * Clips an image object to container boundaries. */ -function clipImageToContainer(obj: TransformedImage, container: Rect): TransformedImage | null { +function clipImageToContainer(obj: TransformedImage, _container: Rect): TransformedImage | null { // For images, we don't clip them - they remain as-is return obj; } @@ -260,17 +267,21 @@ export function calculateMaskObjectBounds(obj: TransformedMaskObject): Rect | nu * Calculates bounds for a line object. */ function calculateLineBounds( - obj: TransformedBrushLine | TransformedBrushLineWithPressure | TransformedEraserLine | TransformedEraserLineWithPressure + obj: + | TransformedBrushLine + | TransformedBrushLineWithPressure + | TransformedEraserLine + | TransformedEraserLineWithPressure ): Rect | null { if (obj.points.length < 2) { return null; } - + let minX = obj.points[0] ?? 0; let minY = obj.points[1] ?? 0; let maxX = minX; let maxY = minY; - + for (let i = 2; i < obj.points.length; i += 2) { const x = obj.points[i] ?? 0; const y = obj.points[i + 1] ?? 0; @@ -279,7 +290,7 @@ function calculateLineBounds( maxX = Math.max(maxX, x); maxY = Math.max(maxY, y); } - + // Add stroke width to bounds const strokeRadius = obj.strokeWidth / 2; return { @@ -310,7 +321,13 @@ function calculateImageBounds(obj: TransformedImage): Rect | null { */ export function convertTransformedToOriginal( obj: TransformedMaskObject -): CanvasBrushLineState | CanvasBrushLineWithPressureState | CanvasEraserLineState | CanvasEraserLineWithPressureState | CanvasRectState | CanvasImageState { +): + | CanvasBrushLineState + | CanvasBrushLineWithPressureState + | CanvasEraserLineState + | CanvasEraserLineWithPressureState + | CanvasRectState + | CanvasImageState { switch (obj.type) { case 'brush_line': return { @@ -337,4 +354,4 @@ export function convertTransformedToOriginal( case 'image': return obj; } -} \ No newline at end of file +} diff --git a/invokeai/frontend/web/src/features/controlLayers/util/maskObjectTransform.ts b/invokeai/frontend/web/src/features/controlLayers/util/maskObjectTransform.ts index bc8ab7b038b..d221e541c4b 100644 --- a/invokeai/frontend/web/src/features/controlLayers/util/maskObjectTransform.ts +++ b/invokeai/frontend/web/src/features/controlLayers/util/maskObjectTransform.ts @@ -3,19 +3,20 @@ import type { CanvasBrushLineWithPressureState, CanvasEraserLineState, CanvasEraserLineWithPressureState, - CanvasRectState, CanvasImageState, + CanvasRectState, Coordinate, Rect, } from 'features/controlLayers/store/types'; + +import { maskObjectsToBitmap } from './bitmapToMaskObjects'; import { - transformMaskObject, - clipMaskObjectToContainer, calculateMaskObjectBounds, + clipMaskObjectToContainer, convertTransformedToOriginal, type TransformedMaskObject, + transformMaskObject, } from './coordinateTransform'; -import { maskObjectsToBitmap } from './bitmapToMaskObjects'; /** * Transforms mask objects relative to a bounding box container. @@ -57,10 +58,7 @@ export function transformMaskObjectsRelativeToBbox( * @param container The container rectangle to clip to * @returns Array of clipped mask objects (null values are filtered out) */ -export function clipMaskObjectsToContainer( - objects: TransformedMaskObject[], - container: Rect -): TransformedMaskObject[] { +export function clipMaskObjectsToContainer(objects: TransformedMaskObject[], container: Rect): TransformedMaskObject[] { return objects .map((obj) => clipMaskObjectToContainer(obj, container)) .filter((obj): obj is TransformedMaskObject => obj !== null); @@ -122,7 +120,7 @@ export function calculateMaskBoundsFromBitmap( // Convert transformed objects back to original types for compatibility const originalObjects = objects.map(convertTransformedToOriginal); - + // Render the consolidated mask to a bitmap const bitmap = maskObjectsToBitmap(originalObjects, canvasWidth, canvasHeight); const { width, height, data } = bitmap; @@ -167,10 +165,7 @@ export function calculateMaskBoundsFromBitmap( * @param container The container rectangle to invert within * @returns Array of mask objects representing the inverted mask */ -export function invertMask( - objects: TransformedMaskObject[], - container: Rect -): TransformedMaskObject[] { +export function invertMask(objects: TransformedMaskObject[], container: Rect): TransformedMaskObject[] { // Create a rectangle that covers the entire container const fullCoverageRect: TransformedMaskObject = { id: 'inverted_mask_rect', @@ -186,7 +181,7 @@ export function invertMask( // For each original mask object, create an eraser line that removes it const eraserObjects: TransformedMaskObject[] = []; - + for (const obj of objects) { if (obj.type === 'rect') { // For rectangles, create an eraser rectangle @@ -194,11 +189,16 @@ export function invertMask( id: `eraser_${obj.id}`, type: 'eraser_line', points: [ - obj.rect.x, obj.rect.y, - obj.rect.x + obj.rect.width, obj.rect.y, - obj.rect.x + obj.rect.width, obj.rect.y + obj.rect.height, - obj.rect.x, obj.rect.y + obj.rect.height, - obj.rect.x, obj.rect.y, // Close the rectangle + obj.rect.x, + obj.rect.y, + obj.rect.x + obj.rect.width, + obj.rect.y, + obj.rect.x + obj.rect.width, + obj.rect.y + obj.rect.height, + obj.rect.x, + obj.rect.y + obj.rect.height, + obj.rect.x, + obj.rect.y, // Close the rectangle ], strokeWidth: 1, clip: container, @@ -233,9 +233,6 @@ export function invertMask( * @param bboxRect The bounding box to clip to * @returns Array of clipped mask objects */ -export function ensureMaskObjectsWithinBbox( - objects: TransformedMaskObject[], - bboxRect: Rect -): TransformedMaskObject[] { +export function ensureMaskObjectsWithinBbox(objects: TransformedMaskObject[], bboxRect: Rect): TransformedMaskObject[] { return clipMaskObjectsToContainer(objects, bboxRect); -} \ No newline at end of file +} From 312e21a62e01ea6e68cefd8800690847d7996508 Mon Sep 17 00:00:00 2001 From: Kent Keirsey <31807370+hipsterusername@users.noreply.github.com> Date: Tue, 1 Jul 2025 13:00:58 -0400 Subject: [PATCH 09/26] Revert "lints" This reverts commit 2952988d2c0ef042a5ba2b34883415def80fb3a1. --- .../InpaintMask/InpaintMaskBboxAdjuster.tsx | 15 ++--- .../hooks/useCanvasAdjustBboxHotkey.ts | 17 +++--- .../hooks/useCanvasInvertMaskHotkey.ts | 9 +-- .../controlLayers/util/coordinateTransform.ts | 61 +++++++------------ .../controlLayers/util/maskObjectTransform.ts | 45 +++++++------- 5 files changed, 61 insertions(+), 86 deletions(-) diff --git a/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskBboxAdjuster.tsx b/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskBboxAdjuster.tsx index cc287db24ac..74390df5145 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskBboxAdjuster.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskBboxAdjuster.tsx @@ -3,20 +3,17 @@ import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { bboxChangedFromCanvas } from 'features/controlLayers/store/canvasSlice'; import { selectMaskBlur } from 'features/controlLayers/store/paramsSlice'; import { selectCanvasSlice } from 'features/controlLayers/store/selectors'; -import type { +import type { + Rect, CanvasBrushLineState, CanvasBrushLineWithPressureState, CanvasEraserLineState, CanvasEraserLineWithPressureState, - CanvasImageState, CanvasRectState, - Rect, + CanvasImageState, } from 'features/controlLayers/store/types'; +import { transformMaskObjectsRelativeToBbox, calculateMaskBoundsFromBitmap } from 'features/controlLayers/util/maskObjectTransform'; import { convertTransformedToOriginal } from 'features/controlLayers/util/coordinateTransform'; -import { - calculateMaskBoundsFromBitmap, - transformMaskObjectsRelativeToBbox, -} from 'features/controlLayers/util/maskObjectTransform'; import { memo, useCallback, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { PiCropBold } from 'react-icons/pi'; @@ -46,7 +43,7 @@ export const InpaintMaskBboxAdjuster = memo(() => { | CanvasRectState | CanvasImageState )[] = []; - + for (const mask of inpaintMasks) { if (!mask.isEnabled || !mask.objects || mask.objects.length === 0) { continue; @@ -65,7 +62,7 @@ export const InpaintMaskBboxAdjuster = memo(() => { // Calculate bounds from the rendered bitmap for accurate results const maskBounds = calculateMaskBoundsFromBitmap(allObjects, bboxRect.width, bboxRect.height); - + if (!maskBounds) { return null; } diff --git a/invokeai/frontend/web/src/features/controlLayers/hooks/useCanvasAdjustBboxHotkey.ts b/invokeai/frontend/web/src/features/controlLayers/hooks/useCanvasAdjustBboxHotkey.ts index 7e988b2be1d..9e07d1f639e 100644 --- a/invokeai/frontend/web/src/features/controlLayers/hooks/useCanvasAdjustBboxHotkey.ts +++ b/invokeai/frontend/web/src/features/controlLayers/hooks/useCanvasAdjustBboxHotkey.ts @@ -4,20 +4,17 @@ import { useCanvasIsBusy } from 'features/controlLayers/hooks/useCanvasIsBusy'; import { bboxChangedFromCanvas } from 'features/controlLayers/store/canvasSlice'; import { selectMaskBlur } from 'features/controlLayers/store/paramsSlice'; import { selectCanvasSlice } from 'features/controlLayers/store/selectors'; -import type { +import type { + Rect, CanvasBrushLineState, CanvasBrushLineWithPressureState, CanvasEraserLineState, CanvasEraserLineWithPressureState, - CanvasImageState, CanvasRectState, - Rect, + CanvasImageState, } from 'features/controlLayers/store/types'; +import { transformMaskObjectsRelativeToBbox, calculateMaskBoundsFromBitmap } from 'features/controlLayers/util/maskObjectTransform'; import { convertTransformedToOriginal } from 'features/controlLayers/util/coordinateTransform'; -import { - calculateMaskBoundsFromBitmap, - transformMaskObjectsRelativeToBbox, -} from 'features/controlLayers/util/maskObjectTransform'; import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData'; import { useCallback, useMemo } from 'react'; @@ -45,7 +42,7 @@ export const useCanvasAdjustBboxHotkey = () => { | CanvasRectState | CanvasImageState )[] = []; - + for (const mask of inpaintMasks) { if (!mask.isEnabled || !mask.objects || mask.objects.length === 0) { continue; @@ -64,7 +61,7 @@ export const useCanvasAdjustBboxHotkey = () => { // Calculate bounds from the rendered bitmap for accurate results const maskBounds = calculateMaskBoundsFromBitmap(allObjects, bboxRect.width, bboxRect.height); - + if (!maskBounds) { return null; } @@ -107,4 +104,4 @@ export const useCanvasAdjustBboxHotkey = () => { options: { enabled: isAdjustBboxAllowed && !isBusy, preventDefault: true }, dependencies: [isAdjustBboxAllowed, isBusy, handleAdjustBbox], }); -}; +}; \ No newline at end of file diff --git a/invokeai/frontend/web/src/features/controlLayers/hooks/useCanvasInvertMaskHotkey.ts b/invokeai/frontend/web/src/features/controlLayers/hooks/useCanvasInvertMaskHotkey.ts index 9b9db039acc..699c63354d4 100644 --- a/invokeai/frontend/web/src/features/controlLayers/hooks/useCanvasInvertMaskHotkey.ts +++ b/invokeai/frontend/web/src/features/controlLayers/hooks/useCanvasInvertMaskHotkey.ts @@ -3,7 +3,6 @@ import { useAssertSingleton } from 'common/hooks/useAssertSingleton'; import { useCanvasIsBusy } from 'features/controlLayers/hooks/useCanvasIsBusy'; import { inpaintMaskInverted } from 'features/controlLayers/store/canvasSlice'; import { selectCanvasSlice, selectSelectedEntityIdentifier } from 'features/controlLayers/store/selectors'; -import type { CanvasEntityIdentifier } from 'features/controlLayers/store/types'; import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData'; import { useCallback, useMemo } from 'react'; @@ -28,11 +27,7 @@ export const useCanvasInvertMaskHotkey = () => { return; } - dispatch( - inpaintMaskInverted({ - entityIdentifier: selectedEntityIdentifier as CanvasEntityIdentifier<'inpaint_mask'>, - }) - ); + dispatch(inpaintMaskInverted({ entityIdentifier: selectedEntityIdentifier as any })); }, [dispatch, selectedEntityIdentifier, canvasSlice]); const isInvertMaskAllowed = useMemo(() => { @@ -54,4 +49,4 @@ export const useCanvasInvertMaskHotkey = () => { options: { enabled: isInvertMaskAllowed && !isBusy, preventDefault: true }, dependencies: [isInvertMaskAllowed, isBusy, handleInvertMask], }); -}; +}; \ No newline at end of file diff --git a/invokeai/frontend/web/src/features/controlLayers/util/coordinateTransform.ts b/invokeai/frontend/web/src/features/controlLayers/util/coordinateTransform.ts index 71234037883..db7f9c1adab 100644 --- a/invokeai/frontend/web/src/features/controlLayers/util/coordinateTransform.ts +++ b/invokeai/frontend/web/src/features/controlLayers/util/coordinateTransform.ts @@ -3,8 +3,8 @@ import type { CanvasBrushLineWithPressureState, CanvasEraserLineState, CanvasEraserLineWithPressureState, - CanvasImageState, CanvasRectState, + CanvasImageState, Coordinate, Rect, } from 'features/controlLayers/store/types'; @@ -75,13 +75,7 @@ export interface TransformedImage { * @returns A new mask object with transformed coordinates */ export function transformMaskObject( - obj: - | CanvasBrushLineState - | CanvasBrushLineWithPressureState - | CanvasEraserLineState - | CanvasEraserLineWithPressureState - | CanvasRectState - | CanvasImageState, + obj: CanvasBrushLineState | CanvasBrushLineWithPressureState | CanvasEraserLineState | CanvasEraserLineWithPressureState | CanvasRectState | CanvasImageState, offset: Coordinate ): TransformedMaskObject { switch (obj.type) { @@ -157,7 +151,10 @@ export function transformRect(rect: Rect, offset: Coordinate): Rect { * @param container The container rectangle to clip to * @returns A new mask object clipped to the container boundaries, or null if completely outside */ -export function clipMaskObjectToContainer(obj: TransformedMaskObject, container: Rect): TransformedMaskObject | null { +export function clipMaskObjectToContainer( + obj: TransformedMaskObject, + container: Rect +): TransformedMaskObject | null { switch (obj.type) { case 'brush_line': case 'brush_line_with_pressure': @@ -175,32 +172,28 @@ export function clipMaskObjectToContainer(obj: TransformedMaskObject, container: * Clips a line object to container boundaries. */ function clipLineToContainer( - obj: - | TransformedBrushLine - | TransformedBrushLineWithPressure - | TransformedEraserLine - | TransformedEraserLineWithPressure, + obj: TransformedBrushLine | TransformedBrushLineWithPressure | TransformedEraserLine | TransformedEraserLineWithPressure, container: Rect ): typeof obj | null { // For lines, we clip the points to the container boundaries const clippedPoints: number[] = []; - + for (let i = 0; i < obj.points.length; i += 2) { const x = obj.points[i] ?? 0; const y = obj.points[i + 1] ?? 0; - + // Clip coordinates to container boundaries const clippedX = Math.max(container.x, Math.min(container.x + container.width, x)); const clippedY = Math.max(container.y, Math.min(container.y + container.height, y)); - + clippedPoints.push(clippedX, clippedY); } - + // If no points remain, return null if (clippedPoints.length === 0) { return null; } - + return { ...obj, points: clippedPoints, @@ -213,18 +206,18 @@ function clipLineToContainer( */ function clipRectToContainer(obj: TransformedRect, container: Rect): TransformedRect | null { const rect = obj.rect; - + // Calculate intersection const left = Math.max(rect.x, container.x); const top = Math.max(rect.y, container.y); const right = Math.min(rect.x + rect.width, container.x + container.width); const bottom = Math.min(rect.y + rect.height, container.y + container.height); - + // If no intersection, return null if (left >= right || top >= bottom) { return null; } - + return { ...obj, rect: { @@ -239,7 +232,7 @@ function clipRectToContainer(obj: TransformedRect, container: Rect): Transformed /** * Clips an image object to container boundaries. */ -function clipImageToContainer(obj: TransformedImage, _container: Rect): TransformedImage | null { +function clipImageToContainer(obj: TransformedImage, container: Rect): TransformedImage | null { // For images, we don't clip them - they remain as-is return obj; } @@ -267,21 +260,17 @@ export function calculateMaskObjectBounds(obj: TransformedMaskObject): Rect | nu * Calculates bounds for a line object. */ function calculateLineBounds( - obj: - | TransformedBrushLine - | TransformedBrushLineWithPressure - | TransformedEraserLine - | TransformedEraserLineWithPressure + obj: TransformedBrushLine | TransformedBrushLineWithPressure | TransformedEraserLine | TransformedEraserLineWithPressure ): Rect | null { if (obj.points.length < 2) { return null; } - + let minX = obj.points[0] ?? 0; let minY = obj.points[1] ?? 0; let maxX = minX; let maxY = minY; - + for (let i = 2; i < obj.points.length; i += 2) { const x = obj.points[i] ?? 0; const y = obj.points[i + 1] ?? 0; @@ -290,7 +279,7 @@ function calculateLineBounds( maxX = Math.max(maxX, x); maxY = Math.max(maxY, y); } - + // Add stroke width to bounds const strokeRadius = obj.strokeWidth / 2; return { @@ -321,13 +310,7 @@ function calculateImageBounds(obj: TransformedImage): Rect | null { */ export function convertTransformedToOriginal( obj: TransformedMaskObject -): - | CanvasBrushLineState - | CanvasBrushLineWithPressureState - | CanvasEraserLineState - | CanvasEraserLineWithPressureState - | CanvasRectState - | CanvasImageState { +): CanvasBrushLineState | CanvasBrushLineWithPressureState | CanvasEraserLineState | CanvasEraserLineWithPressureState | CanvasRectState | CanvasImageState { switch (obj.type) { case 'brush_line': return { @@ -354,4 +337,4 @@ export function convertTransformedToOriginal( case 'image': return obj; } -} +} \ No newline at end of file diff --git a/invokeai/frontend/web/src/features/controlLayers/util/maskObjectTransform.ts b/invokeai/frontend/web/src/features/controlLayers/util/maskObjectTransform.ts index d221e541c4b..bc8ab7b038b 100644 --- a/invokeai/frontend/web/src/features/controlLayers/util/maskObjectTransform.ts +++ b/invokeai/frontend/web/src/features/controlLayers/util/maskObjectTransform.ts @@ -3,20 +3,19 @@ import type { CanvasBrushLineWithPressureState, CanvasEraserLineState, CanvasEraserLineWithPressureState, - CanvasImageState, CanvasRectState, + CanvasImageState, Coordinate, Rect, } from 'features/controlLayers/store/types'; - -import { maskObjectsToBitmap } from './bitmapToMaskObjects'; import { - calculateMaskObjectBounds, + transformMaskObject, clipMaskObjectToContainer, + calculateMaskObjectBounds, convertTransformedToOriginal, type TransformedMaskObject, - transformMaskObject, } from './coordinateTransform'; +import { maskObjectsToBitmap } from './bitmapToMaskObjects'; /** * Transforms mask objects relative to a bounding box container. @@ -58,7 +57,10 @@ export function transformMaskObjectsRelativeToBbox( * @param container The container rectangle to clip to * @returns Array of clipped mask objects (null values are filtered out) */ -export function clipMaskObjectsToContainer(objects: TransformedMaskObject[], container: Rect): TransformedMaskObject[] { +export function clipMaskObjectsToContainer( + objects: TransformedMaskObject[], + container: Rect +): TransformedMaskObject[] { return objects .map((obj) => clipMaskObjectToContainer(obj, container)) .filter((obj): obj is TransformedMaskObject => obj !== null); @@ -120,7 +122,7 @@ export function calculateMaskBoundsFromBitmap( // Convert transformed objects back to original types for compatibility const originalObjects = objects.map(convertTransformedToOriginal); - + // Render the consolidated mask to a bitmap const bitmap = maskObjectsToBitmap(originalObjects, canvasWidth, canvasHeight); const { width, height, data } = bitmap; @@ -165,7 +167,10 @@ export function calculateMaskBoundsFromBitmap( * @param container The container rectangle to invert within * @returns Array of mask objects representing the inverted mask */ -export function invertMask(objects: TransformedMaskObject[], container: Rect): TransformedMaskObject[] { +export function invertMask( + objects: TransformedMaskObject[], + container: Rect +): TransformedMaskObject[] { // Create a rectangle that covers the entire container const fullCoverageRect: TransformedMaskObject = { id: 'inverted_mask_rect', @@ -181,7 +186,7 @@ export function invertMask(objects: TransformedMaskObject[], container: Rect): T // For each original mask object, create an eraser line that removes it const eraserObjects: TransformedMaskObject[] = []; - + for (const obj of objects) { if (obj.type === 'rect') { // For rectangles, create an eraser rectangle @@ -189,16 +194,11 @@ export function invertMask(objects: TransformedMaskObject[], container: Rect): T id: `eraser_${obj.id}`, type: 'eraser_line', points: [ - obj.rect.x, - obj.rect.y, - obj.rect.x + obj.rect.width, - obj.rect.y, - obj.rect.x + obj.rect.width, - obj.rect.y + obj.rect.height, - obj.rect.x, - obj.rect.y + obj.rect.height, - obj.rect.x, - obj.rect.y, // Close the rectangle + obj.rect.x, obj.rect.y, + obj.rect.x + obj.rect.width, obj.rect.y, + obj.rect.x + obj.rect.width, obj.rect.y + obj.rect.height, + obj.rect.x, obj.rect.y + obj.rect.height, + obj.rect.x, obj.rect.y, // Close the rectangle ], strokeWidth: 1, clip: container, @@ -233,6 +233,9 @@ export function invertMask(objects: TransformedMaskObject[], container: Rect): T * @param bboxRect The bounding box to clip to * @returns Array of clipped mask objects */ -export function ensureMaskObjectsWithinBbox(objects: TransformedMaskObject[], bboxRect: Rect): TransformedMaskObject[] { +export function ensureMaskObjectsWithinBbox( + objects: TransformedMaskObject[], + bboxRect: Rect +): TransformedMaskObject[] { return clipMaskObjectsToContainer(objects, bboxRect); -} +} \ No newline at end of file From d4bd43ba42637ff1f6045ae59f26e6754abe73d0 Mon Sep 17 00:00:00 2001 From: Kent Keirsey <31807370+hipsterusername@users.noreply.github.com> Date: Tue, 1 Jul 2025 13:02:06 -0400 Subject: [PATCH 10/26] Revert "Fix bugs. Extract into transform utilities" This reverts commit f9a49e21c9a8ee81009c3328c9af96cdb5fc39ee. --- .../InpaintMask/InpaintMaskBboxAdjuster.tsx | 110 +++--- .../components/Toolbar/CanvasToolbar.tsx | 4 +- .../hooks/useCanvasAdjustBboxHotkey.ts | 100 +++--- .../controlLayers/store/canvasSlice.ts | 25 +- .../controlLayers/util/coordinateTransform.ts | 340 ------------------ .../controlLayers/util/maskObjectTransform.ts | 241 ------------- 6 files changed, 133 insertions(+), 687 deletions(-) delete mode 100644 invokeai/frontend/web/src/features/controlLayers/util/coordinateTransform.ts delete mode 100644 invokeai/frontend/web/src/features/controlLayers/util/maskObjectTransform.ts diff --git a/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskBboxAdjuster.tsx b/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskBboxAdjuster.tsx index 74390df5145..f5c39088bd7 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskBboxAdjuster.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskBboxAdjuster.tsx @@ -3,17 +3,7 @@ import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { bboxChangedFromCanvas } from 'features/controlLayers/store/canvasSlice'; import { selectMaskBlur } from 'features/controlLayers/store/paramsSlice'; import { selectCanvasSlice } from 'features/controlLayers/store/selectors'; -import type { - Rect, - CanvasBrushLineState, - CanvasBrushLineWithPressureState, - CanvasEraserLineState, - CanvasEraserLineWithPressureState, - CanvasRectState, - CanvasImageState, -} from 'features/controlLayers/store/types'; -import { transformMaskObjectsRelativeToBbox, calculateMaskBoundsFromBitmap } from 'features/controlLayers/util/maskObjectTransform'; -import { convertTransformedToOriginal } from 'features/controlLayers/util/coordinateTransform'; +import type { Rect } from 'features/controlLayers/store/types'; import { memo, useCallback, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { PiCropBold } from 'react-icons/pi'; @@ -24,9 +14,8 @@ export const InpaintMaskBboxAdjuster = memo(() => { const canvasSlice = useAppSelector(selectCanvasSlice); const maskBlur = useAppSelector(selectMaskBlur); - // Get all inpaint mask entities and bbox + // Get all inpaint mask entities const inpaintMasks = canvasSlice.inpaintMasks.entities; - const bboxRect = canvasSlice.bbox.rect; // Calculate the bounding box that contains all inpaint masks const calculateMaskBbox = useCallback((): Rect | null => { @@ -34,47 +23,84 @@ export const InpaintMaskBboxAdjuster = memo(() => { return null; } - // Collect all mask objects from enabled masks - const allObjects: ( - | CanvasBrushLineState - | CanvasBrushLineWithPressureState - | CanvasEraserLineState - | CanvasEraserLineWithPressureState - | CanvasRectState - | CanvasImageState - )[] = []; - + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + + // Iterate through all inpaint masks to find the overall bounds for (const mask of inpaintMasks) { - if (!mask.isEnabled || !mask.objects || mask.objects.length === 0) { + if (!mask.isEnabled || mask.objects.length === 0) { continue; } - // Transform objects to be relative to the bbox - const transformedObjects = transformMaskObjectsRelativeToBbox(mask.objects, bboxRect); - // Convert back to original types for compatibility - const originalObjects = transformedObjects.map(convertTransformedToOriginal); - allObjects.push(...originalObjects); - } + // Calculate bounds for this mask's objects + for (const obj of mask.objects) { + let objMinX = 0; + let objMinY = 0; + let objMaxX = 0; + let objMaxY = 0; - if (allObjects.length === 0) { - return null; + if (obj.type === 'rect') { + objMinX = mask.position.x + obj.rect.x; + objMinY = mask.position.y + obj.rect.y; + objMaxX = objMinX + obj.rect.width; + objMaxY = objMinY + obj.rect.height; + } else if ( + obj.type === 'brush_line' || + obj.type === 'brush_line_with_pressure' || + obj.type === 'eraser_line' || + obj.type === 'eraser_line_with_pressure' + ) { + // For lines, find the min/max points + for (let i = 0; i < obj.points.length; i += 2) { + const x = mask.position.x + (obj.points[i] ?? 0); + const y = mask.position.y + (obj.points[i + 1] ?? 0); + + if (i === 0) { + objMinX = objMaxX = x; + objMinY = objMaxY = y; + } else { + objMinX = Math.min(objMinX, x); + objMinY = Math.min(objMinY, y); + objMaxX = Math.max(objMaxX, x); + objMaxY = Math.max(objMaxY, y); + } + } + // Add stroke width to account for line thickness + const strokeRadius = (obj.strokeWidth ?? 50) / 2; + objMinX -= strokeRadius; + objMinY -= strokeRadius; + objMaxX += strokeRadius; + objMaxY += strokeRadius; + } else if (obj.type === 'image') { + // Image objects are positioned at the entity's position + objMinX = mask.position.x; + objMinY = mask.position.y; + objMaxX = objMinX + obj.image.width; + objMaxY = objMinY + obj.image.height; + } + + // Update overall bounds + minX = Math.min(minX, objMinX); + minY = Math.min(minY, objMinY); + maxX = Math.max(maxX, objMaxX); + maxY = Math.max(maxY, objMaxY); + } } - // Calculate bounds from the rendered bitmap for accurate results - const maskBounds = calculateMaskBoundsFromBitmap(allObjects, bboxRect.width, bboxRect.height); - - if (!maskBounds) { + // If no valid bounds found, return null + if (minX === Infinity || minY === Infinity || maxX === -Infinity || maxY === -Infinity) { return null; } - // Convert back to world coordinates relative to the bbox return { - x: bboxRect.x + maskBounds.x, - y: bboxRect.y + maskBounds.y, - width: maskBounds.width, - height: maskBounds.height, + x: minX, + y: minY, + width: maxX - minX, + height: maxY - minY, }; - }, [inpaintMasks, bboxRect]); + }, [inpaintMasks]); const maskBbox = useMemo(() => calculateMaskBbox(), [calculateMaskBbox]); diff --git a/invokeai/frontend/web/src/features/controlLayers/components/Toolbar/CanvasToolbar.tsx b/invokeai/frontend/web/src/features/controlLayers/components/Toolbar/CanvasToolbar.tsx index 062428343b4..86e57977e99 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/Toolbar/CanvasToolbar.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/Toolbar/CanvasToolbar.tsx @@ -9,15 +9,15 @@ import { CanvasToolbarResetViewButton } from 'features/controlLayers/components/ import { CanvasToolbarSaveToGalleryButton } from 'features/controlLayers/components/Toolbar/CanvasToolbarSaveToGalleryButton'; import { CanvasToolbarScale } from 'features/controlLayers/components/Toolbar/CanvasToolbarScale'; import { CanvasToolbarUndoButton } from 'features/controlLayers/components/Toolbar/CanvasToolbarUndoButton'; -import { useCanvasAdjustBboxHotkey } from 'features/controlLayers/hooks/useCanvasAdjustBboxHotkey'; import { useCanvasDeleteLayerHotkey } from 'features/controlLayers/hooks/useCanvasDeleteLayerHotkey'; import { useCanvasEntityQuickSwitchHotkey } from 'features/controlLayers/hooks/useCanvasEntityQuickSwitchHotkey'; import { useCanvasFilterHotkey } from 'features/controlLayers/hooks/useCanvasFilterHotkey'; -import { useCanvasInvertMaskHotkey } from 'features/controlLayers/hooks/useCanvasInvertMaskHotkey'; import { useCanvasResetLayerHotkey } from 'features/controlLayers/hooks/useCanvasResetLayerHotkey'; import { useCanvasToggleNonRasterLayersHotkey } from 'features/controlLayers/hooks/useCanvasToggleNonRasterLayersHotkey'; import { useCanvasTransformHotkey } from 'features/controlLayers/hooks/useCanvasTransformHotkey'; import { useCanvasUndoRedoHotkeys } from 'features/controlLayers/hooks/useCanvasUndoRedoHotkeys'; +import { useCanvasInvertMaskHotkey } from 'features/controlLayers/hooks/useCanvasInvertMaskHotkey'; +import { useCanvasAdjustBboxHotkey } from 'features/controlLayers/hooks/useCanvasAdjustBboxHotkey'; import { useNextPrevEntityHotkeys } from 'features/controlLayers/hooks/useNextPrevEntity'; import { memo } from 'react'; diff --git a/invokeai/frontend/web/src/features/controlLayers/hooks/useCanvasAdjustBboxHotkey.ts b/invokeai/frontend/web/src/features/controlLayers/hooks/useCanvasAdjustBboxHotkey.ts index 9e07d1f639e..d3a87b11319 100644 --- a/invokeai/frontend/web/src/features/controlLayers/hooks/useCanvasAdjustBboxHotkey.ts +++ b/invokeai/frontend/web/src/features/controlLayers/hooks/useCanvasAdjustBboxHotkey.ts @@ -4,18 +4,8 @@ import { useCanvasIsBusy } from 'features/controlLayers/hooks/useCanvasIsBusy'; import { bboxChangedFromCanvas } from 'features/controlLayers/store/canvasSlice'; import { selectMaskBlur } from 'features/controlLayers/store/paramsSlice'; import { selectCanvasSlice } from 'features/controlLayers/store/selectors'; -import type { - Rect, - CanvasBrushLineState, - CanvasBrushLineWithPressureState, - CanvasEraserLineState, - CanvasEraserLineWithPressureState, - CanvasRectState, - CanvasImageState, -} from 'features/controlLayers/store/types'; -import { transformMaskObjectsRelativeToBbox, calculateMaskBoundsFromBitmap } from 'features/controlLayers/util/maskObjectTransform'; -import { convertTransformedToOriginal } from 'features/controlLayers/util/coordinateTransform'; import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData'; +import type { Rect } from 'features/controlLayers/store/types'; import { useCallback, useMemo } from 'react'; export const useCanvasAdjustBboxHotkey = () => { @@ -25,55 +15,71 @@ export const useCanvasAdjustBboxHotkey = () => { const maskBlur = useAppSelector(selectMaskBlur); const isBusy = useCanvasIsBusy(); const inpaintMasks = canvasSlice.inpaintMasks.entities; - const bboxRect = canvasSlice.bbox.rect; // Calculate the bounding box that contains all inpaint masks const calculateMaskBbox = useCallback((): Rect | null => { if (inpaintMasks.length === 0) { return null; } - - // Collect all mask objects from enabled masks - const allObjects: ( - | CanvasBrushLineState - | CanvasBrushLineWithPressureState - | CanvasEraserLineState - | CanvasEraserLineWithPressureState - | CanvasRectState - | CanvasImageState - )[] = []; - + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; for (const mask of inpaintMasks) { if (!mask.isEnabled || !mask.objects || mask.objects.length === 0) { continue; } - - // Transform objects to be relative to the bbox - const transformedObjects = transformMaskObjectsRelativeToBbox(mask.objects, bboxRect); - // Convert back to original types for compatibility - const originalObjects = transformedObjects.map(convertTransformedToOriginal); - allObjects.push(...originalObjects); - } - - if (allObjects.length === 0) { - return null; + for (const obj of mask.objects) { + let objMinX = 0; + let objMinY = 0; + let objMaxX = 0; + let objMaxY = 0; + if (obj.type === 'rect') { + objMinX = mask.position.x + obj.rect.x; + objMinY = mask.position.y + obj.rect.y; + objMaxX = objMinX + obj.rect.width; + objMaxY = objMinY + obj.rect.height; + } else if ( + obj.type === 'brush_line' || + obj.type === 'brush_line_with_pressure' || + obj.type === 'eraser_line' || + obj.type === 'eraser_line_with_pressure' + ) { + for (let i = 0; i < obj.points.length; i += 2) { + const x = mask.position.x + (obj.points[i] ?? 0); + const y = mask.position.y + (obj.points[i + 1] ?? 0); + if (i === 0) { + objMinX = objMaxX = x; + objMinY = objMaxY = y; + } else { + objMinX = Math.min(objMinX, x); + objMinY = Math.min(objMinY, y); + objMaxX = Math.max(objMaxX, x); + objMaxY = Math.max(objMaxY, y); + } + } + const strokeRadius = (obj.strokeWidth ?? 50) / 2; + objMinX -= strokeRadius; + objMinY -= strokeRadius; + objMaxX += strokeRadius; + objMaxY += strokeRadius; + } else if (obj.type === 'image') { + objMinX = mask.position.x; + objMinY = mask.position.y; + objMaxX = objMinX + obj.image.width; + objMaxY = objMinY + obj.image.height; + } + minX = Math.min(minX, objMinX); + minY = Math.min(minY, objMinY); + maxX = Math.max(maxX, objMaxX); + maxY = Math.max(maxY, objMaxY); + } } - - // Calculate bounds from the rendered bitmap for accurate results - const maskBounds = calculateMaskBoundsFromBitmap(allObjects, bboxRect.width, bboxRect.height); - - if (!maskBounds) { + if (minX === Infinity || minY === Infinity || maxX === -Infinity || maxY === -Infinity) { return null; } - - // Convert back to world coordinates relative to the bbox - return { - x: bboxRect.x + maskBounds.x, - y: bboxRect.y + maskBounds.y, - width: maskBounds.width, - height: maskBounds.height, - }; - }, [inpaintMasks, bboxRect]); + return { x: minX, y: minY, width: maxX - minX, height: maxY - minY }; + }, [inpaintMasks]); const handleAdjustBbox = useCallback(() => { const maskBbox = calculateMaskBbox(); diff --git a/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts b/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts index 341f2f0e428..869cfd0473f 100644 --- a/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts +++ b/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts @@ -1010,7 +1010,10 @@ export const canvasSlice = createSlice({ return; } - // Get the current bbox dimensions for the mask + // For now, we'll use a simple approach: create a full rectangle and add eraser lines + // This is a temporary solution until we can properly handle the bitmap conversion + + // Get the bbox dimensions for the mask const bboxRect = state.bbox.rect; // Create a full rectangle covering the bbox @@ -1035,23 +1038,15 @@ export const canvasSlice = createSlice({ | CanvasBrushLineWithPressureState )[] = [fillRect]; - // Create a clip region that constrains all objects to the bbox - const bboxClip = { - x: bboxRect.x - entity.position.x, - y: bboxRect.y - entity.position.y, - width: bboxRect.width, - height: bboxRect.height, - }; - for (const obj of entity.objects) { if (obj.type === 'brush_line') { - // Convert brush line to eraser line, ensuring it's clipped to the bbox + // Convert brush line to eraser line const eraserLine: CanvasEraserLineState = { id: getPrefixedId('eraser_line'), type: 'eraser_line', strokeWidth: obj.strokeWidth, points: obj.points, - clip: bboxClip, // Always clip to the current bbox + clip: obj.clip, }; invertedObjects.push(eraserLine); } else if (obj.type === 'brush_line_with_pressure') { @@ -1061,7 +1056,7 @@ export const canvasSlice = createSlice({ type: 'eraser_line_with_pressure', strokeWidth: obj.strokeWidth, points: obj.points, - clip: bboxClip, // Always clip to the current bbox + clip: obj.clip, }; invertedObjects.push(eraserLine); } else if (obj.type === 'rect') { @@ -1085,7 +1080,7 @@ export const canvasSlice = createSlice({ type: 'eraser_line', points, strokeWidth: Math.max(width, height) / 2, // Use a stroke width that covers the rectangle - clip: bboxClip, // Always clip to the current bbox + clip: null, }; invertedObjects.push(eraserLine); } else if (obj.type === 'eraser_line') { @@ -1095,7 +1090,7 @@ export const canvasSlice = createSlice({ type: 'brush_line', strokeWidth: obj.strokeWidth, points: obj.points, - clip: bboxClip, // Always clip to the current bbox + clip: obj.clip, color: { r: 255, g: 255, b: 255, a: 1 }, }; invertedObjects.push(brushLine); @@ -1106,7 +1101,7 @@ export const canvasSlice = createSlice({ type: 'brush_line_with_pressure', strokeWidth: obj.strokeWidth, points: obj.points, - clip: bboxClip, // Always clip to the current bbox + clip: obj.clip, color: { r: 255, g: 255, b: 255, a: 1 }, }; invertedObjects.push(brushLine); diff --git a/invokeai/frontend/web/src/features/controlLayers/util/coordinateTransform.ts b/invokeai/frontend/web/src/features/controlLayers/util/coordinateTransform.ts deleted file mode 100644 index db7f9c1adab..00000000000 --- a/invokeai/frontend/web/src/features/controlLayers/util/coordinateTransform.ts +++ /dev/null @@ -1,340 +0,0 @@ -import type { - CanvasBrushLineState, - CanvasBrushLineWithPressureState, - CanvasEraserLineState, - CanvasEraserLineWithPressureState, - CanvasRectState, - CanvasImageState, - Coordinate, - Rect, -} from 'features/controlLayers/store/types'; - -/** - * Type for mask objects with transformed coordinates. - * This preserves the discriminated union structure while allowing coordinate transformations. - */ -export type TransformedMaskObject = - | TransformedBrushLine - | TransformedBrushLineWithPressure - | TransformedEraserLine - | TransformedEraserLineWithPressure - | TransformedRect - | TransformedImage; - -export interface TransformedBrushLine { - id: string; - type: 'brush_line'; - points: number[]; - strokeWidth: number; - color: { r: number; g: number; b: number; a: number }; - clip?: Rect | null; -} - -export interface TransformedBrushLineWithPressure { - id: string; - type: 'brush_line_with_pressure'; - points: number[]; - strokeWidth: number; - color: { r: number; g: number; b: number; a: number }; - clip?: Rect | null; -} - -export interface TransformedEraserLine { - id: string; - type: 'eraser_line'; - points: number[]; - strokeWidth: number; - clip?: Rect | null; -} - -export interface TransformedEraserLineWithPressure { - id: string; - type: 'eraser_line_with_pressure'; - points: number[]; - strokeWidth: number; - clip?: Rect | null; -} - -export interface TransformedRect { - id: string; - type: 'rect'; - rect: Rect; - color: { r: number; g: number; b: number; a: number }; -} - -export interface TransformedImage { - id: string; - type: 'image'; - image: { width: number; height: number; dataURL: string } | { width: number; height: number; image_name: string }; -} - -/** - * Transforms a mask object by applying a coordinate offset. - * @param obj The mask object to transform - * @param offset The offset to apply to coordinates - * @returns A new mask object with transformed coordinates - */ -export function transformMaskObject( - obj: CanvasBrushLineState | CanvasBrushLineWithPressureState | CanvasEraserLineState | CanvasEraserLineWithPressureState | CanvasRectState | CanvasImageState, - offset: Coordinate -): TransformedMaskObject { - switch (obj.type) { - case 'brush_line': - return { - ...obj, - points: transformPoints(obj.points, offset), - clip: obj.clip ? transformRect(obj.clip, offset) : null, - }; - case 'brush_line_with_pressure': - return { - ...obj, - points: transformPoints(obj.points, offset), - clip: obj.clip ? transformRect(obj.clip, offset) : null, - }; - case 'eraser_line': - return { - ...obj, - points: transformPoints(obj.points, offset), - clip: obj.clip ? transformRect(obj.clip, offset) : null, - }; - case 'eraser_line_with_pressure': - return { - ...obj, - points: transformPoints(obj.points, offset), - clip: obj.clip ? transformRect(obj.clip, offset) : null, - }; - case 'rect': - return { - ...obj, - rect: transformRect(obj.rect, offset), - }; - case 'image': - return { - ...obj, - }; - } -} - -/** - * Transforms an array of points by applying a coordinate offset. - * @param points Array of numbers representing [x1, y1, x2, y2, ...] - * @param offset The offset to apply - * @returns New array with transformed coordinates - */ -export function transformPoints(points: number[], offset: Coordinate): number[] { - const transformed: number[] = []; - for (let i = 0; i < points.length; i += 2) { - transformed.push((points[i] ?? 0) + offset.x); - transformed.push((points[i + 1] ?? 0) + offset.y); - } - return transformed; -} - -/** - * Transforms a rectangle by applying a coordinate offset. - * @param rect The rectangle to transform - * @param offset The offset to apply - * @returns New rectangle with transformed coordinates - */ -export function transformRect(rect: Rect, offset: Coordinate): Rect { - return { - x: rect.x + offset.x, - y: rect.y + offset.y, - width: rect.width, - height: rect.height, - }; -} - -/** - * Clips a mask object to the boundaries of a container rectangle. - * @param obj The mask object to clip - * @param container The container rectangle to clip to - * @returns A new mask object clipped to the container boundaries, or null if completely outside - */ -export function clipMaskObjectToContainer( - obj: TransformedMaskObject, - container: Rect -): TransformedMaskObject | null { - switch (obj.type) { - case 'brush_line': - case 'brush_line_with_pressure': - case 'eraser_line': - case 'eraser_line_with_pressure': - return clipLineToContainer(obj, container); - case 'rect': - return clipRectToContainer(obj, container); - case 'image': - return clipImageToContainer(obj, container); - } -} - -/** - * Clips a line object to container boundaries. - */ -function clipLineToContainer( - obj: TransformedBrushLine | TransformedBrushLineWithPressure | TransformedEraserLine | TransformedEraserLineWithPressure, - container: Rect -): typeof obj | null { - // For lines, we clip the points to the container boundaries - const clippedPoints: number[] = []; - - for (let i = 0; i < obj.points.length; i += 2) { - const x = obj.points[i] ?? 0; - const y = obj.points[i + 1] ?? 0; - - // Clip coordinates to container boundaries - const clippedX = Math.max(container.x, Math.min(container.x + container.width, x)); - const clippedY = Math.max(container.y, Math.min(container.y + container.height, y)); - - clippedPoints.push(clippedX, clippedY); - } - - // If no points remain, return null - if (clippedPoints.length === 0) { - return null; - } - - return { - ...obj, - points: clippedPoints, - clip: container, - }; -} - -/** - * Clips a rectangle object to container boundaries. - */ -function clipRectToContainer(obj: TransformedRect, container: Rect): TransformedRect | null { - const rect = obj.rect; - - // Calculate intersection - const left = Math.max(rect.x, container.x); - const top = Math.max(rect.y, container.y); - const right = Math.min(rect.x + rect.width, container.x + container.width); - const bottom = Math.min(rect.y + rect.height, container.y + container.height); - - // If no intersection, return null - if (left >= right || top >= bottom) { - return null; - } - - return { - ...obj, - rect: { - x: left, - y: top, - width: right - left, - height: bottom - top, - }, - }; -} - -/** - * Clips an image object to container boundaries. - */ -function clipImageToContainer(obj: TransformedImage, container: Rect): TransformedImage | null { - // For images, we don't clip them - they remain as-is - return obj; -} - -/** - * Calculates the effective bounds of a mask object. - * @param obj The mask object to calculate bounds for - * @returns The bounding rectangle, or null if the object has no effective bounds - */ -export function calculateMaskObjectBounds(obj: TransformedMaskObject): Rect | null { - switch (obj.type) { - case 'brush_line': - case 'brush_line_with_pressure': - case 'eraser_line': - case 'eraser_line_with_pressure': - return calculateLineBounds(obj); - case 'rect': - return obj.rect; - case 'image': - return calculateImageBounds(obj); - } -} - -/** - * Calculates bounds for a line object. - */ -function calculateLineBounds( - obj: TransformedBrushLine | TransformedBrushLineWithPressure | TransformedEraserLine | TransformedEraserLineWithPressure -): Rect | null { - if (obj.points.length < 2) { - return null; - } - - let minX = obj.points[0] ?? 0; - let minY = obj.points[1] ?? 0; - let maxX = minX; - let maxY = minY; - - for (let i = 2; i < obj.points.length; i += 2) { - const x = obj.points[i] ?? 0; - const y = obj.points[i + 1] ?? 0; - minX = Math.min(minX, x); - minY = Math.min(minY, y); - maxX = Math.max(maxX, x); - maxY = Math.max(maxY, y); - } - - // Add stroke width to bounds - const strokeRadius = obj.strokeWidth / 2; - return { - x: minX - strokeRadius, - y: minY - strokeRadius, - width: maxX - minX + obj.strokeWidth, - height: maxY - minY + obj.strokeWidth, - }; -} - -/** - * Calculates bounds for an image object. - */ -function calculateImageBounds(obj: TransformedImage): Rect | null { - return { - x: 0, - y: 0, - width: obj.image.width, - height: obj.image.height, - }; -} - -/** - * Converts a TransformedMaskObject back to its original mask object type. - * This is needed for compatibility with functions that expect the original types. - * @param obj The transformed mask object to convert back - * @returns The original mask object type - */ -export function convertTransformedToOriginal( - obj: TransformedMaskObject -): CanvasBrushLineState | CanvasBrushLineWithPressureState | CanvasEraserLineState | CanvasEraserLineWithPressureState | CanvasRectState | CanvasImageState { - switch (obj.type) { - case 'brush_line': - return { - ...obj, - clip: obj.clip ?? null, - }; - case 'brush_line_with_pressure': - return { - ...obj, - clip: obj.clip ?? null, - }; - case 'eraser_line': - return { - ...obj, - clip: obj.clip ?? null, - }; - case 'eraser_line_with_pressure': - return { - ...obj, - clip: obj.clip ?? null, - }; - case 'rect': - return obj; - case 'image': - return obj; - } -} \ No newline at end of file diff --git a/invokeai/frontend/web/src/features/controlLayers/util/maskObjectTransform.ts b/invokeai/frontend/web/src/features/controlLayers/util/maskObjectTransform.ts deleted file mode 100644 index bc8ab7b038b..00000000000 --- a/invokeai/frontend/web/src/features/controlLayers/util/maskObjectTransform.ts +++ /dev/null @@ -1,241 +0,0 @@ -import type { - CanvasBrushLineState, - CanvasBrushLineWithPressureState, - CanvasEraserLineState, - CanvasEraserLineWithPressureState, - CanvasRectState, - CanvasImageState, - Coordinate, - Rect, -} from 'features/controlLayers/store/types'; -import { - transformMaskObject, - clipMaskObjectToContainer, - calculateMaskObjectBounds, - convertTransformedToOriginal, - type TransformedMaskObject, -} from './coordinateTransform'; -import { maskObjectsToBitmap } from './bitmapToMaskObjects'; - -/** - * Transforms mask objects relative to a bounding box container. - * This adjusts all object coordinates to be relative to the bbox origin. - * @param objects Array of mask objects to transform - * @param bboxRect The bounding box to use as the container reference - * @returns Array of transformed mask objects - */ -export function transformMaskObjectsRelativeToBbox( - objects: ( - | CanvasBrushLineState - | CanvasBrushLineWithPressureState - | CanvasEraserLineState - | CanvasEraserLineWithPressureState - | CanvasRectState - | CanvasImageState - )[], - bboxRect: Rect -): TransformedMaskObject[] { - const transformedObjects: TransformedMaskObject[] = []; - - for (const obj of objects) { - // Calculate the offset to make coordinates relative to the bbox - const offset: Coordinate = { - x: -bboxRect.x, - y: -bboxRect.y, - }; - - const transformed = transformMaskObject(obj, offset); - transformedObjects.push(transformed); - } - - return transformedObjects; -} - -/** - * Clips all mask objects to the boundaries of a container rectangle. - * @param objects Array of mask objects to clip - * @param container The container rectangle to clip to - * @returns Array of clipped mask objects (null values are filtered out) - */ -export function clipMaskObjectsToContainer( - objects: TransformedMaskObject[], - container: Rect -): TransformedMaskObject[] { - return objects - .map((obj) => clipMaskObjectToContainer(obj, container)) - .filter((obj): obj is TransformedMaskObject => obj !== null); -} - -/** - * Calculates the effective bounds of all mask objects. - * @param objects Array of mask objects to calculate bounds for - * @returns The bounding rectangle containing all objects, or null if no objects - */ -export function calculateMaskObjectsBounds(objects: TransformedMaskObject[]): Rect | null { - if (objects.length === 0) { - return null; - } - - let minX = Infinity; - let minY = Infinity; - let maxX = -Infinity; - let maxY = -Infinity; - - for (const obj of objects) { - const bounds = calculateMaskObjectBounds(obj); - if (bounds) { - minX = Math.min(minX, bounds.x); - minY = Math.min(minY, bounds.y); - maxX = Math.max(maxX, bounds.x + bounds.width); - maxY = Math.max(maxY, bounds.y + bounds.height); - } - } - - if (minX === Infinity || minY === Infinity || maxX === -Infinity || maxY === -Infinity) { - return null; - } - - return { - x: minX, - y: minY, - width: maxX - minX, - height: maxY - minY, - }; -} - -/** - * Calculates the bounding box of a consolidated mask by rendering it to a bitmap. - * This provides the most accurate bounds by considering the actual rendered mask pixels. - * @param objects Array of mask objects to calculate bounds for - * @param canvasWidth Width of the canvas to render to - * @param canvasHeight Height of the canvas to render to - * @returns The bounding rectangle of the rendered mask, or null if no mask pixels - */ -export function calculateMaskBoundsFromBitmap( - objects: TransformedMaskObject[], - canvasWidth: number, - canvasHeight: number -): Rect | null { - if (objects.length === 0) { - return null; - } - - // Convert transformed objects back to original types for compatibility - const originalObjects = objects.map(convertTransformedToOriginal); - - // Render the consolidated mask to a bitmap - const bitmap = maskObjectsToBitmap(originalObjects, canvasWidth, canvasHeight); - const { width, height, data } = bitmap; - - // Find the actual bounds of the rendered mask - let maskMinX = width; - let maskMinY = height; - let maskMaxX = 0; - let maskMaxY = 0; - - for (let y = 0; y < height; y++) { - for (let x = 0; x < width; x++) { - const pixelIndex = (y * width + x) * 4; - const alpha = data[pixelIndex + 3] ?? 0; - - // If this pixel has any opacity, it's part of the mask - if (alpha > 0) { - maskMinX = Math.min(maskMinX, x); - maskMinY = Math.min(maskMinY, y); - maskMaxX = Math.max(maskMaxX, x); - maskMaxY = Math.max(maskMaxY, y); - } - } - } - - // If no mask pixels found, return null - if (maskMinX >= maskMaxX || maskMinY >= maskMaxY) { - return null; - } - - return { - x: maskMinX, - y: maskMinY, - width: maskMaxX - maskMinX + 1, - height: maskMaxY - maskMinY + 1, - }; -} - -/** - * Inverts a mask by creating a new mask that covers the entire container except for the original mask areas. - * @param objects Array of mask objects representing the original mask - * @param container The container rectangle to invert within - * @returns Array of mask objects representing the inverted mask - */ -export function invertMask( - objects: TransformedMaskObject[], - container: Rect -): TransformedMaskObject[] { - // Create a rectangle that covers the entire container - const fullCoverageRect: TransformedMaskObject = { - id: 'inverted_mask_rect', - type: 'rect', - rect: { - x: container.x, - y: container.y, - width: container.width, - height: container.height, - }, - color: { r: 255, g: 255, b: 255, a: 1 }, - }; - - // For each original mask object, create an eraser line that removes it - const eraserObjects: TransformedMaskObject[] = []; - - for (const obj of objects) { - if (obj.type === 'rect') { - // For rectangles, create an eraser rectangle - const eraserRect: TransformedMaskObject = { - id: `eraser_${obj.id}`, - type: 'eraser_line', - points: [ - obj.rect.x, obj.rect.y, - obj.rect.x + obj.rect.width, obj.rect.y, - obj.rect.x + obj.rect.width, obj.rect.y + obj.rect.height, - obj.rect.x, obj.rect.y + obj.rect.height, - obj.rect.x, obj.rect.y, // Close the rectangle - ], - strokeWidth: 1, - clip: container, - }; - eraserObjects.push(eraserRect); - } else if ( - obj.type === 'brush_line' || - obj.type === 'brush_line_with_pressure' || - obj.type === 'eraser_line' || - obj.type === 'eraser_line_with_pressure' - ) { - // For lines, create an eraser line with the same points - const eraserLine: TransformedMaskObject = { - id: `eraser_${obj.id}`, - type: 'eraser_line', - points: [...obj.points], - strokeWidth: obj.strokeWidth, - clip: container, - }; - eraserObjects.push(eraserLine); - } - // Note: Image objects are not handled in inversion as they're not commonly used in masks - } - - return [fullCoverageRect, ...eraserObjects]; -} - -/** - * Ensures all mask objects are clipped to the current bounding box boundaries. - * This prevents masks from extending outside the bounding box after multiple inversions. - * @param objects Array of mask objects to clip - * @param bboxRect The bounding box to clip to - * @returns Array of clipped mask objects - */ -export function ensureMaskObjectsWithinBbox( - objects: TransformedMaskObject[], - bboxRect: Rect -): TransformedMaskObject[] { - return clipMaskObjectsToContainer(objects, bboxRect); -} \ No newline at end of file From 42161a5d35324d236f82be66873131f9874afd17 Mon Sep 17 00:00:00 2001 From: Kent Keirsey <31807370+hipsterusername@users.noreply.github.com> Date: Tue, 1 Jul 2025 13:54:21 -0400 Subject: [PATCH 11/26] Actual fixes. --- .../InpaintMask/InpaintMaskBboxAdjuster.tsx | 148 +++++++++++------- .../components/Toolbar/CanvasToolbar.tsx | 4 +- .../hooks/useCanvasAdjustBboxHotkey.ts | 136 +++++++++++----- .../hooks/useCanvasInvertMaskHotkey.ts | 4 +- .../controlLayers/store/canvasSlice.ts | 123 +++++++++------ 5 files changed, 268 insertions(+), 147 deletions(-) diff --git a/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskBboxAdjuster.tsx b/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskBboxAdjuster.tsx index f5c39088bd7..e9d657d860f 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskBboxAdjuster.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskBboxAdjuster.tsx @@ -3,7 +3,16 @@ import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { bboxChangedFromCanvas } from 'features/controlLayers/store/canvasSlice'; import { selectMaskBlur } from 'features/controlLayers/store/paramsSlice'; import { selectCanvasSlice } from 'features/controlLayers/store/selectors'; -import type { Rect } from 'features/controlLayers/store/types'; +import type { + CanvasBrushLineState, + CanvasBrushLineWithPressureState, + CanvasEraserLineState, + CanvasEraserLineWithPressureState, + CanvasImageState, + CanvasRectState, + Rect, +} from 'features/controlLayers/store/types'; +import { maskObjectsToBitmap } from 'features/controlLayers/util/bitmapToMaskObjects'; import { memo, useCallback, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { PiCropBold } from 'react-icons/pi'; @@ -14,8 +23,9 @@ export const InpaintMaskBboxAdjuster = memo(() => { const canvasSlice = useAppSelector(selectCanvasSlice); const maskBlur = useAppSelector(selectMaskBlur); - // Get all inpaint mask entities + // Get all inpaint mask entities and bbox const inpaintMasks = canvasSlice.inpaintMasks.entities; + const bboxRect = canvasSlice.bbox.rect; // Calculate the bounding box that contains all inpaint masks const calculateMaskBbox = useCallback((): Rect | null => { @@ -23,84 +33,108 @@ export const InpaintMaskBboxAdjuster = memo(() => { return null; } - let minX = Infinity; - let minY = Infinity; - let maxX = -Infinity; - let maxY = -Infinity; - - // Iterate through all inpaint masks to find the overall bounds + // Use the current bbox as the reference container + const canvasWidth = bboxRect.width; + const canvasHeight = bboxRect.height; + + // Collect all mask objects and adjust their positions relative to the bbox + const allObjects: ( + | CanvasBrushLineState + | CanvasBrushLineWithPressureState + | CanvasEraserLineState + | CanvasEraserLineWithPressureState + | CanvasRectState + | CanvasImageState + )[] = []; for (const mask of inpaintMasks) { - if (!mask.isEnabled || mask.objects.length === 0) { + if (!mask.isEnabled || !mask.objects || mask.objects.length === 0) { continue; } - // Calculate bounds for this mask's objects + // Adjust object positions relative to the bbox (not the entity position) for (const obj of mask.objects) { - let objMinX = 0; - let objMinY = 0; - let objMaxX = 0; - let objMaxY = 0; - if (obj.type === 'rect') { - objMinX = mask.position.x + obj.rect.x; - objMinY = mask.position.y + obj.rect.y; - objMaxX = objMinX + obj.rect.width; - objMaxY = objMinY + obj.rect.height; + const adjustedObj = { + ...obj, + rect: { + ...obj.rect, + x: obj.rect.x + mask.position.x - bboxRect.x, + y: obj.rect.y + mask.position.y - bboxRect.y, + }, + }; + allObjects.push(adjustedObj); } else if ( obj.type === 'brush_line' || obj.type === 'brush_line_with_pressure' || obj.type === 'eraser_line' || obj.type === 'eraser_line_with_pressure' ) { - // For lines, find the min/max points + const adjustedPoints: number[] = []; for (let i = 0; i < obj.points.length; i += 2) { - const x = mask.position.x + (obj.points[i] ?? 0); - const y = mask.position.y + (obj.points[i + 1] ?? 0); - - if (i === 0) { - objMinX = objMaxX = x; - objMinY = objMaxY = y; - } else { - objMinX = Math.min(objMinX, x); - objMinY = Math.min(objMinY, y); - objMaxX = Math.max(objMaxX, x); - objMaxY = Math.max(objMaxY, y); - } + adjustedPoints.push((obj.points[i] ?? 0) + mask.position.x - bboxRect.x); + adjustedPoints.push((obj.points[i + 1] ?? 0) + mask.position.y - bboxRect.y); } - // Add stroke width to account for line thickness - const strokeRadius = (obj.strokeWidth ?? 50) / 2; - objMinX -= strokeRadius; - objMinY -= strokeRadius; - objMaxX += strokeRadius; - objMaxY += strokeRadius; + const adjustedObj = { + ...obj, + points: adjustedPoints, + }; + allObjects.push(adjustedObj); } else if (obj.type === 'image') { - // Image objects are positioned at the entity's position - objMinX = mask.position.x; - objMinY = mask.position.y; - objMaxX = objMinX + obj.image.width; - objMaxY = objMinY + obj.image.height; + // For image objects, we need to handle them differently since they don't have rect or points + // We'll skip them for now as they're not commonly used in masks + continue; } + } + } - // Update overall bounds - minX = Math.min(minX, objMinX); - minY = Math.min(minY, objMinY); - maxX = Math.max(maxX, objMaxX); - maxY = Math.max(maxY, objMaxY); + if (allObjects.length === 0) { + return null; + } + + // Render the consolidated mask to a bitmap + const bitmap = maskObjectsToBitmap(allObjects, canvasWidth, canvasHeight); + const { width, height, data } = bitmap; + + // Find the actual bounds of the rendered mask + let maskMinX = width; + let maskMinY = height; + let maskMaxX = 0; + let maskMaxY = 0; + + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const pixelIndex = (y * width + x) * 4; + const alpha = data[pixelIndex + 3] ?? 0; + + // If this pixel has any opacity, it's part of the mask + if (alpha > 0) { + maskMinX = Math.min(maskMinX, x); + maskMinY = Math.min(maskMinY, y); + maskMaxX = Math.max(maskMaxX, x); + maskMaxY = Math.max(maskMaxY, y); + } } } - // If no valid bounds found, return null - if (minX === Infinity || minY === Infinity || maxX === -Infinity || maxY === -Infinity) { + // If no mask pixels found, return null + if (maskMinX >= maskMaxX || maskMinY >= maskMaxY) { return null; } + // Clamp the mask bounds to the bbox boundaries + maskMinX = Math.max(0, maskMinX); + maskMinY = Math.max(0, maskMinY); + maskMaxX = Math.min(width - 1, maskMaxX); + maskMaxY = Math.min(height - 1, maskMaxY); + + // Convert back to world coordinates relative to the bbox return { - x: minX, - y: minY, - width: maxX - minX, - height: maxY - minY, + x: bboxRect.x + maskMinX, + y: bboxRect.y + maskMinY, + width: maskMaxX - maskMinX + 1, + height: maskMaxY - maskMinY + 1, }; - }, [inpaintMasks]); + }, [inpaintMasks, bboxRect]); const maskBbox = useMemo(() => calculateMaskBbox(), [calculateMaskBbox]); @@ -128,13 +162,13 @@ export const InpaintMaskBboxAdjuster = memo(() => { } return ( - + + + ); +}); + +InpaintMaskBboxAdjuster.displayName = 'InpaintMaskBboxAdjuster'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskList.tsx b/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskList.tsx index 8bbb49a9865..cb8cc4eeca6 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskList.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskList.tsx @@ -3,6 +3,7 @@ import { createMemoizedSelector } from 'app/store/createMemoizedSelector'; import { useAppSelector } from 'app/store/storeHooks'; import { CanvasEntityGroupList } from 'features/controlLayers/components/CanvasEntityList/CanvasEntityGroupList'; import { InpaintMask } from 'features/controlLayers/components/InpaintMask/InpaintMask'; +// import { InpaintMaskBboxAdjuster } from 'features/controlLayers/components/InpaintMask/InpaintMaskBboxAdjuster'; import { selectCanvasSlice, selectSelectedEntityIdentifier } from 'features/controlLayers/store/selectors'; import { getEntityIdentifier } from 'features/controlLayers/store/types'; import { memo } from 'react'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskMenuItems.tsx b/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskMenuItems.tsx index 2b3ba7fcb65..60aba9819e8 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskMenuItems.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskMenuItems.tsx @@ -8,6 +8,7 @@ import { CanvasEntityMenuItemsMergeDown } from 'features/controlLayers/component import { CanvasEntityMenuItemsSave } from 'features/controlLayers/components/common/CanvasEntityMenuItemsSave'; import { CanvasEntityMenuItemsTransform } from 'features/controlLayers/components/common/CanvasEntityMenuItemsTransform'; import { InpaintMaskMenuItemsAddModifiers } from 'features/controlLayers/components/InpaintMask/InpaintMaskMenuItemsAddModifiers'; +import { InpaintMaskMenuItemsAdjustBbox } from 'features/controlLayers/components/InpaintMask/InpaintMaskMenuItemsAdjustBbox'; import { InpaintMaskMenuItemsConvertToSubMenu } from 'features/controlLayers/components/InpaintMask/InpaintMaskMenuItemsConvertToSubMenu'; import { InpaintMaskMenuItemsCopyToSubMenu } from 'features/controlLayers/components/InpaintMask/InpaintMaskMenuItemsCopyToSubMenu'; import { InpaintMaskMenuItemsInvert } from 'features/controlLayers/components/InpaintMask/InpaintMaskMenuItemsInvert'; @@ -23,6 +24,7 @@ export const InpaintMaskMenuItems = memo(() => { + diff --git a/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskMenuItemsAdjustBbox.tsx b/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskMenuItemsAdjustBbox.tsx new file mode 100644 index 00000000000..2750e209c4a --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskMenuItemsAdjustBbox.tsx @@ -0,0 +1,110 @@ +import { MenuItem } from '@invoke-ai/ui-library'; +import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; +import { bboxChangedFromCanvas } from 'features/controlLayers/store/canvasSlice'; +import { selectMaskBlur } from 'features/controlLayers/store/paramsSlice'; +import { selectCanvasSlice } from 'features/controlLayers/store/selectors'; +import type { Rect } from 'features/controlLayers/store/types'; +import { memo, useCallback, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { PiCropBold } from 'react-icons/pi'; + +export const InpaintMaskMenuItemsAdjustBbox = memo(() => { + const { t } = useTranslation(); + const dispatch = useAppDispatch(); + const canvasSlice = useAppSelector(selectCanvasSlice); + const maskBlur = useAppSelector(selectMaskBlur); + const inpaintMasks = canvasSlice.inpaintMasks.entities; + + // Calculate the bounding box that contains all inpaint masks + const calculateMaskBbox = useCallback((): Rect | null => { + if (inpaintMasks.length === 0) { + return null; + } + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + for (const mask of inpaintMasks) { + if (!mask.isEnabled || mask.objects.length === 0) { + continue; + } + for (const obj of mask.objects) { + let objMinX = 0; + let objMinY = 0; + let objMaxX = 0; + let objMaxY = 0; + if (obj.type === 'rect') { + objMinX = mask.position.x + obj.rect.x; + objMinY = mask.position.y + obj.rect.y; + objMaxX = objMinX + obj.rect.width; + objMaxY = objMinY + obj.rect.height; + } else if ( + obj.type === 'brush_line' || + obj.type === 'brush_line_with_pressure' || + obj.type === 'eraser_line' || + obj.type === 'eraser_line_with_pressure' + ) { + for (let i = 0; i < obj.points.length; i += 2) { + const x = mask.position.x + (obj.points[i] ?? 0); + const y = mask.position.y + (obj.points[i + 1] ?? 0); + if (i === 0) { + objMinX = objMaxX = x; + objMinY = objMaxY = y; + } else { + objMinX = Math.min(objMinX, x); + objMinY = Math.min(objMinY, y); + objMaxX = Math.max(objMaxX, x); + objMaxY = Math.max(objMaxY, y); + } + } + const strokeRadius = (obj.strokeWidth ?? 50) / 2; + objMinX -= strokeRadius; + objMinY -= strokeRadius; + objMaxX += strokeRadius; + objMaxY += strokeRadius; + } else if (obj.type === 'image') { + objMinX = mask.position.x; + objMinY = mask.position.y; + objMaxX = objMinX + obj.image.width; + objMaxY = objMinY + obj.image.height; + } + minX = Math.min(minX, objMinX); + minY = Math.min(minY, objMinY); + maxX = Math.max(maxX, objMaxX); + maxY = Math.max(maxY, objMaxY); + } + } + if (minX === Infinity || minY === Infinity || maxX === -Infinity || maxY === -Infinity) { + return null; + } + return { x: minX, y: minY, width: maxX - minX, height: maxY - minY }; + }, [inpaintMasks]); + + const maskBbox = useMemo(() => calculateMaskBbox(), [calculateMaskBbox]); + const handleAdjustBbox = useCallback(() => { + if (!maskBbox) { + return; + } + const padding = maskBlur + 8; + const adjustedBbox: Rect = { + x: maskBbox.x - padding, + y: maskBbox.y - padding, + width: maskBbox.width + padding * 2, + height: maskBbox.height + padding * 2, + }; + dispatch(bboxChangedFromCanvas(adjustedBbox)); + }, [dispatch, maskBbox, maskBlur]); + + const hasValidMasks = inpaintMasks.some((mask) => mask.isEnabled && mask.objects.length > 0); + if (!hasValidMasks) { + return null; + } + + return ( + }> + {t('controlLayers.adjustBboxToMasks')} + + ); +}); + +InpaintMaskMenuItemsAdjustBbox.displayName = 'InpaintMaskMenuItemsAdjustBbox'; From 5958d08b511f566ca58b769c5e7579cb411a52e8 Mon Sep 17 00:00:00 2001 From: Kent Keirsey <31807370+hipsterusername@users.noreply.github.com> Date: Tue, 1 Jul 2025 10:51:46 -0400 Subject: [PATCH 18/26] Add Hotkeys --- invokeai/frontend/web/public/locales/en.json | 8 ++ .../components/Toolbar/CanvasToolbar.tsx | 4 + .../hooks/useCanvasAdjustBboxHotkey.ts | 113 ++++++++++++++++++ .../hooks/useCanvasInvertMaskHotkey.ts | 52 ++++++++ .../components/HotkeysModal/useHotkeyData.ts | 2 + 5 files changed, 179 insertions(+) create mode 100644 invokeai/frontend/web/src/features/controlLayers/hooks/useCanvasAdjustBboxHotkey.ts create mode 100644 invokeai/frontend/web/src/features/controlLayers/hooks/useCanvasInvertMaskHotkey.ts diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json index 9ed9c09463c..3e42ff5ab61 100644 --- a/invokeai/frontend/web/public/locales/en.json +++ b/invokeai/frontend/web/public/locales/en.json @@ -589,6 +589,14 @@ "toggleNonRasterLayers": { "title": "Toggle Non-Raster Layers", "desc": "Show or hide all non-raster layer categories (Control Layers, Inpaint Masks, Regional Guidance)." + }, + "invertMask": { + "title": "Invert Mask", + "desc": "Invert the selected inpaint mask. Only works when an inpaint mask is selected and has objects." + }, + "adjustBbox": { + "title": "Adjust Bbox to Masks", + "desc": "Adjust the bounding box to fit all visible inpaint masks with padding." } }, "workflows": { diff --git a/invokeai/frontend/web/src/features/controlLayers/components/Toolbar/CanvasToolbar.tsx b/invokeai/frontend/web/src/features/controlLayers/components/Toolbar/CanvasToolbar.tsx index 453d13b3c50..86e57977e99 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/Toolbar/CanvasToolbar.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/Toolbar/CanvasToolbar.tsx @@ -16,6 +16,8 @@ import { useCanvasResetLayerHotkey } from 'features/controlLayers/hooks/useCanva import { useCanvasToggleNonRasterLayersHotkey } from 'features/controlLayers/hooks/useCanvasToggleNonRasterLayersHotkey'; import { useCanvasTransformHotkey } from 'features/controlLayers/hooks/useCanvasTransformHotkey'; import { useCanvasUndoRedoHotkeys } from 'features/controlLayers/hooks/useCanvasUndoRedoHotkeys'; +import { useCanvasInvertMaskHotkey } from 'features/controlLayers/hooks/useCanvasInvertMaskHotkey'; +import { useCanvasAdjustBboxHotkey } from 'features/controlLayers/hooks/useCanvasAdjustBboxHotkey'; import { useNextPrevEntityHotkeys } from 'features/controlLayers/hooks/useNextPrevEntity'; import { memo } from 'react'; @@ -28,6 +30,8 @@ export const CanvasToolbar = memo(() => { useCanvasTransformHotkey(); useCanvasFilterHotkey(); useCanvasToggleNonRasterLayersHotkey(); + useCanvasInvertMaskHotkey(); + useCanvasAdjustBboxHotkey(); return ( diff --git a/invokeai/frontend/web/src/features/controlLayers/hooks/useCanvasAdjustBboxHotkey.ts b/invokeai/frontend/web/src/features/controlLayers/hooks/useCanvasAdjustBboxHotkey.ts new file mode 100644 index 00000000000..d3a87b11319 --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/hooks/useCanvasAdjustBboxHotkey.ts @@ -0,0 +1,113 @@ +import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; +import { useAssertSingleton } from 'common/hooks/useAssertSingleton'; +import { useCanvasIsBusy } from 'features/controlLayers/hooks/useCanvasIsBusy'; +import { bboxChangedFromCanvas } from 'features/controlLayers/store/canvasSlice'; +import { selectMaskBlur } from 'features/controlLayers/store/paramsSlice'; +import { selectCanvasSlice } from 'features/controlLayers/store/selectors'; +import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData'; +import type { Rect } from 'features/controlLayers/store/types'; +import { useCallback, useMemo } from 'react'; + +export const useCanvasAdjustBboxHotkey = () => { + useAssertSingleton('useCanvasAdjustBboxHotkey'); + const dispatch = useAppDispatch(); + const canvasSlice = useAppSelector(selectCanvasSlice); + const maskBlur = useAppSelector(selectMaskBlur); + const isBusy = useCanvasIsBusy(); + const inpaintMasks = canvasSlice.inpaintMasks.entities; + + // Calculate the bounding box that contains all inpaint masks + const calculateMaskBbox = useCallback((): Rect | null => { + if (inpaintMasks.length === 0) { + return null; + } + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + for (const mask of inpaintMasks) { + if (!mask.isEnabled || !mask.objects || mask.objects.length === 0) { + continue; + } + for (const obj of mask.objects) { + let objMinX = 0; + let objMinY = 0; + let objMaxX = 0; + let objMaxY = 0; + if (obj.type === 'rect') { + objMinX = mask.position.x + obj.rect.x; + objMinY = mask.position.y + obj.rect.y; + objMaxX = objMinX + obj.rect.width; + objMaxY = objMinY + obj.rect.height; + } else if ( + obj.type === 'brush_line' || + obj.type === 'brush_line_with_pressure' || + obj.type === 'eraser_line' || + obj.type === 'eraser_line_with_pressure' + ) { + for (let i = 0; i < obj.points.length; i += 2) { + const x = mask.position.x + (obj.points[i] ?? 0); + const y = mask.position.y + (obj.points[i + 1] ?? 0); + if (i === 0) { + objMinX = objMaxX = x; + objMinY = objMaxY = y; + } else { + objMinX = Math.min(objMinX, x); + objMinY = Math.min(objMinY, y); + objMaxX = Math.max(objMaxX, x); + objMaxY = Math.max(objMaxY, y); + } + } + const strokeRadius = (obj.strokeWidth ?? 50) / 2; + objMinX -= strokeRadius; + objMinY -= strokeRadius; + objMaxX += strokeRadius; + objMaxY += strokeRadius; + } else if (obj.type === 'image') { + objMinX = mask.position.x; + objMinY = mask.position.y; + objMaxX = objMinX + obj.image.width; + objMaxY = objMinY + obj.image.height; + } + minX = Math.min(minX, objMinX); + minY = Math.min(minY, objMinY); + maxX = Math.max(maxX, objMaxX); + maxY = Math.max(maxY, objMaxY); + } + } + if (minX === Infinity || minY === Infinity || maxX === -Infinity || maxY === -Infinity) { + return null; + } + return { x: minX, y: minY, width: maxX - minX, height: maxY - minY }; + }, [inpaintMasks]); + + const handleAdjustBbox = useCallback(() => { + const maskBbox = calculateMaskBbox(); + if (!maskBbox) { + return; + } + + const padding = maskBlur + 8; + const adjustedBbox: Rect = { + x: maskBbox.x - padding, + y: maskBbox.y - padding, + width: maskBbox.width + padding * 2, + height: maskBbox.height + padding * 2, + }; + + dispatch(bboxChangedFromCanvas(adjustedBbox)); + }, [dispatch, calculateMaskBbox, maskBlur]); + + const isAdjustBboxAllowed = useMemo(() => { + const hasValidMasks = inpaintMasks.some((mask) => mask.isEnabled && mask.objects && mask.objects.length > 0); + return hasValidMasks; + }, [inpaintMasks]); + + useRegisteredHotkeys({ + id: 'adjustBbox', + category: 'canvas', + callback: handleAdjustBbox, + options: { enabled: isAdjustBboxAllowed && !isBusy, preventDefault: true }, + dependencies: [isAdjustBboxAllowed, isBusy, handleAdjustBbox], + }); +}; \ No newline at end of file diff --git a/invokeai/frontend/web/src/features/controlLayers/hooks/useCanvasInvertMaskHotkey.ts b/invokeai/frontend/web/src/features/controlLayers/hooks/useCanvasInvertMaskHotkey.ts new file mode 100644 index 00000000000..699c63354d4 --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/hooks/useCanvasInvertMaskHotkey.ts @@ -0,0 +1,52 @@ +import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; +import { useAssertSingleton } from 'common/hooks/useAssertSingleton'; +import { useCanvasIsBusy } from 'features/controlLayers/hooks/useCanvasIsBusy'; +import { inpaintMaskInverted } from 'features/controlLayers/store/canvasSlice'; +import { selectCanvasSlice, selectSelectedEntityIdentifier } from 'features/controlLayers/store/selectors'; +import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData'; +import { useCallback, useMemo } from 'react'; + +export const useCanvasInvertMaskHotkey = () => { + useAssertSingleton('useCanvasInvertMaskHotkey'); + const dispatch = useAppDispatch(); + const selectedEntityIdentifier = useAppSelector(selectSelectedEntityIdentifier); + const canvasSlice = useAppSelector(selectCanvasSlice); + const isBusy = useCanvasIsBusy(); + + const handleInvertMask = useCallback(() => { + if (!selectedEntityIdentifier || selectedEntityIdentifier.type !== 'inpaint_mask') { + return; + } + + // Check if the selected entity has objects and there's a valid bounding box + const entity = canvasSlice.inpaintMasks.entities.find((entity) => entity.id === selectedEntityIdentifier.id); + const hasObjects = entity?.objects && entity.objects.length > 0; + const hasBbox = canvasSlice.bbox.rect.width > 0 && canvasSlice.bbox.rect.height > 0; + + if (!hasObjects || !hasBbox) { + return; + } + + dispatch(inpaintMaskInverted({ entityIdentifier: selectedEntityIdentifier as any })); + }, [dispatch, selectedEntityIdentifier, canvasSlice]); + + const isInvertMaskAllowed = useMemo(() => { + if (!selectedEntityIdentifier || selectedEntityIdentifier.type !== 'inpaint_mask') { + return false; + } + + const entity = canvasSlice.inpaintMasks.entities.find((entity) => entity.id === selectedEntityIdentifier.id); + const hasObjects = entity?.objects && entity.objects.length > 0; + const hasBbox = canvasSlice.bbox.rect.width > 0 && canvasSlice.bbox.rect.height > 0; + + return hasObjects && hasBbox; + }, [selectedEntityIdentifier, canvasSlice]); + + useRegisteredHotkeys({ + id: 'invertMask', + category: 'canvas', + callback: handleInvertMask, + options: { enabled: isInvertMaskAllowed && !isBusy, preventDefault: true }, + dependencies: [isInvertMaskAllowed, isBusy, handleInvertMask], + }); +}; \ No newline at end of file diff --git a/invokeai/frontend/web/src/features/system/components/HotkeysModal/useHotkeyData.ts b/invokeai/frontend/web/src/features/system/components/HotkeysModal/useHotkeyData.ts index 0241f45cecd..32fb49fa33b 100644 --- a/invokeai/frontend/web/src/features/system/components/HotkeysModal/useHotkeyData.ts +++ b/invokeai/frontend/web/src/features/system/components/HotkeysModal/useHotkeyData.ts @@ -123,6 +123,8 @@ export const useHotkeyData = (): HotkeysData => { addHotkey('canvas', 'applySegmentAnything', ['enter']); addHotkey('canvas', 'cancelSegmentAnything', ['esc']); addHotkey('canvas', 'toggleNonRasterLayers', ['shift+h']); + addHotkey('canvas', 'invertMask', ['shift+v']); + addHotkey('canvas', 'adjustBbox', ['shift+b']); // Workflows addHotkey('workflows', 'addNode', ['shift+a', 'space']); From 10ec64ed6950640c3ac15c133fc1742048b8dc74 Mon Sep 17 00:00:00 2001 From: Kent Keirsey <31807370+hipsterusername@users.noreply.github.com> Date: Tue, 1 Jul 2025 11:53:27 -0400 Subject: [PATCH 19/26] Fix bugs. Extract into transform utilities --- .../InpaintMask/InpaintMaskBboxAdjuster.tsx | 110 +++--- .../components/Toolbar/CanvasToolbar.tsx | 4 +- .../hooks/useCanvasAdjustBboxHotkey.ts | 100 +++--- .../controlLayers/store/canvasSlice.ts | 25 +- .../controlLayers/util/coordinateTransform.ts | 340 ++++++++++++++++++ .../controlLayers/util/maskObjectTransform.ts | 241 +++++++++++++ 6 files changed, 687 insertions(+), 133 deletions(-) create mode 100644 invokeai/frontend/web/src/features/controlLayers/util/coordinateTransform.ts create mode 100644 invokeai/frontend/web/src/features/controlLayers/util/maskObjectTransform.ts diff --git a/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskBboxAdjuster.tsx b/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskBboxAdjuster.tsx index f5c39088bd7..74390df5145 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskBboxAdjuster.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskBboxAdjuster.tsx @@ -3,7 +3,17 @@ import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { bboxChangedFromCanvas } from 'features/controlLayers/store/canvasSlice'; import { selectMaskBlur } from 'features/controlLayers/store/paramsSlice'; import { selectCanvasSlice } from 'features/controlLayers/store/selectors'; -import type { Rect } from 'features/controlLayers/store/types'; +import type { + Rect, + CanvasBrushLineState, + CanvasBrushLineWithPressureState, + CanvasEraserLineState, + CanvasEraserLineWithPressureState, + CanvasRectState, + CanvasImageState, +} from 'features/controlLayers/store/types'; +import { transformMaskObjectsRelativeToBbox, calculateMaskBoundsFromBitmap } from 'features/controlLayers/util/maskObjectTransform'; +import { convertTransformedToOriginal } from 'features/controlLayers/util/coordinateTransform'; import { memo, useCallback, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { PiCropBold } from 'react-icons/pi'; @@ -14,8 +24,9 @@ export const InpaintMaskBboxAdjuster = memo(() => { const canvasSlice = useAppSelector(selectCanvasSlice); const maskBlur = useAppSelector(selectMaskBlur); - // Get all inpaint mask entities + // Get all inpaint mask entities and bbox const inpaintMasks = canvasSlice.inpaintMasks.entities; + const bboxRect = canvasSlice.bbox.rect; // Calculate the bounding box that contains all inpaint masks const calculateMaskBbox = useCallback((): Rect | null => { @@ -23,84 +34,47 @@ export const InpaintMaskBboxAdjuster = memo(() => { return null; } - let minX = Infinity; - let minY = Infinity; - let maxX = -Infinity; - let maxY = -Infinity; - - // Iterate through all inpaint masks to find the overall bounds + // Collect all mask objects from enabled masks + const allObjects: ( + | CanvasBrushLineState + | CanvasBrushLineWithPressureState + | CanvasEraserLineState + | CanvasEraserLineWithPressureState + | CanvasRectState + | CanvasImageState + )[] = []; + for (const mask of inpaintMasks) { - if (!mask.isEnabled || mask.objects.length === 0) { + if (!mask.isEnabled || !mask.objects || mask.objects.length === 0) { continue; } - // Calculate bounds for this mask's objects - for (const obj of mask.objects) { - let objMinX = 0; - let objMinY = 0; - let objMaxX = 0; - let objMaxY = 0; - - if (obj.type === 'rect') { - objMinX = mask.position.x + obj.rect.x; - objMinY = mask.position.y + obj.rect.y; - objMaxX = objMinX + obj.rect.width; - objMaxY = objMinY + obj.rect.height; - } else if ( - obj.type === 'brush_line' || - obj.type === 'brush_line_with_pressure' || - obj.type === 'eraser_line' || - obj.type === 'eraser_line_with_pressure' - ) { - // For lines, find the min/max points - for (let i = 0; i < obj.points.length; i += 2) { - const x = mask.position.x + (obj.points[i] ?? 0); - const y = mask.position.y + (obj.points[i + 1] ?? 0); - - if (i === 0) { - objMinX = objMaxX = x; - objMinY = objMaxY = y; - } else { - objMinX = Math.min(objMinX, x); - objMinY = Math.min(objMinY, y); - objMaxX = Math.max(objMaxX, x); - objMaxY = Math.max(objMaxY, y); - } - } - // Add stroke width to account for line thickness - const strokeRadius = (obj.strokeWidth ?? 50) / 2; - objMinX -= strokeRadius; - objMinY -= strokeRadius; - objMaxX += strokeRadius; - objMaxY += strokeRadius; - } else if (obj.type === 'image') { - // Image objects are positioned at the entity's position - objMinX = mask.position.x; - objMinY = mask.position.y; - objMaxX = objMinX + obj.image.width; - objMaxY = objMinY + obj.image.height; - } + // Transform objects to be relative to the bbox + const transformedObjects = transformMaskObjectsRelativeToBbox(mask.objects, bboxRect); + // Convert back to original types for compatibility + const originalObjects = transformedObjects.map(convertTransformedToOriginal); + allObjects.push(...originalObjects); + } - // Update overall bounds - minX = Math.min(minX, objMinX); - minY = Math.min(minY, objMinY); - maxX = Math.max(maxX, objMaxX); - maxY = Math.max(maxY, objMaxY); - } + if (allObjects.length === 0) { + return null; } - // If no valid bounds found, return null - if (minX === Infinity || minY === Infinity || maxX === -Infinity || maxY === -Infinity) { + // Calculate bounds from the rendered bitmap for accurate results + const maskBounds = calculateMaskBoundsFromBitmap(allObjects, bboxRect.width, bboxRect.height); + + if (!maskBounds) { return null; } + // Convert back to world coordinates relative to the bbox return { - x: minX, - y: minY, - width: maxX - minX, - height: maxY - minY, + x: bboxRect.x + maskBounds.x, + y: bboxRect.y + maskBounds.y, + width: maskBounds.width, + height: maskBounds.height, }; - }, [inpaintMasks]); + }, [inpaintMasks, bboxRect]); const maskBbox = useMemo(() => calculateMaskBbox(), [calculateMaskBbox]); diff --git a/invokeai/frontend/web/src/features/controlLayers/components/Toolbar/CanvasToolbar.tsx b/invokeai/frontend/web/src/features/controlLayers/components/Toolbar/CanvasToolbar.tsx index 86e57977e99..062428343b4 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/Toolbar/CanvasToolbar.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/Toolbar/CanvasToolbar.tsx @@ -9,15 +9,15 @@ import { CanvasToolbarResetViewButton } from 'features/controlLayers/components/ import { CanvasToolbarSaveToGalleryButton } from 'features/controlLayers/components/Toolbar/CanvasToolbarSaveToGalleryButton'; import { CanvasToolbarScale } from 'features/controlLayers/components/Toolbar/CanvasToolbarScale'; import { CanvasToolbarUndoButton } from 'features/controlLayers/components/Toolbar/CanvasToolbarUndoButton'; +import { useCanvasAdjustBboxHotkey } from 'features/controlLayers/hooks/useCanvasAdjustBboxHotkey'; import { useCanvasDeleteLayerHotkey } from 'features/controlLayers/hooks/useCanvasDeleteLayerHotkey'; import { useCanvasEntityQuickSwitchHotkey } from 'features/controlLayers/hooks/useCanvasEntityQuickSwitchHotkey'; import { useCanvasFilterHotkey } from 'features/controlLayers/hooks/useCanvasFilterHotkey'; +import { useCanvasInvertMaskHotkey } from 'features/controlLayers/hooks/useCanvasInvertMaskHotkey'; import { useCanvasResetLayerHotkey } from 'features/controlLayers/hooks/useCanvasResetLayerHotkey'; import { useCanvasToggleNonRasterLayersHotkey } from 'features/controlLayers/hooks/useCanvasToggleNonRasterLayersHotkey'; import { useCanvasTransformHotkey } from 'features/controlLayers/hooks/useCanvasTransformHotkey'; import { useCanvasUndoRedoHotkeys } from 'features/controlLayers/hooks/useCanvasUndoRedoHotkeys'; -import { useCanvasInvertMaskHotkey } from 'features/controlLayers/hooks/useCanvasInvertMaskHotkey'; -import { useCanvasAdjustBboxHotkey } from 'features/controlLayers/hooks/useCanvasAdjustBboxHotkey'; import { useNextPrevEntityHotkeys } from 'features/controlLayers/hooks/useNextPrevEntity'; import { memo } from 'react'; diff --git a/invokeai/frontend/web/src/features/controlLayers/hooks/useCanvasAdjustBboxHotkey.ts b/invokeai/frontend/web/src/features/controlLayers/hooks/useCanvasAdjustBboxHotkey.ts index d3a87b11319..9e07d1f639e 100644 --- a/invokeai/frontend/web/src/features/controlLayers/hooks/useCanvasAdjustBboxHotkey.ts +++ b/invokeai/frontend/web/src/features/controlLayers/hooks/useCanvasAdjustBboxHotkey.ts @@ -4,8 +4,18 @@ import { useCanvasIsBusy } from 'features/controlLayers/hooks/useCanvasIsBusy'; import { bboxChangedFromCanvas } from 'features/controlLayers/store/canvasSlice'; import { selectMaskBlur } from 'features/controlLayers/store/paramsSlice'; import { selectCanvasSlice } from 'features/controlLayers/store/selectors'; +import type { + Rect, + CanvasBrushLineState, + CanvasBrushLineWithPressureState, + CanvasEraserLineState, + CanvasEraserLineWithPressureState, + CanvasRectState, + CanvasImageState, +} from 'features/controlLayers/store/types'; +import { transformMaskObjectsRelativeToBbox, calculateMaskBoundsFromBitmap } from 'features/controlLayers/util/maskObjectTransform'; +import { convertTransformedToOriginal } from 'features/controlLayers/util/coordinateTransform'; import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData'; -import type { Rect } from 'features/controlLayers/store/types'; import { useCallback, useMemo } from 'react'; export const useCanvasAdjustBboxHotkey = () => { @@ -15,71 +25,55 @@ export const useCanvasAdjustBboxHotkey = () => { const maskBlur = useAppSelector(selectMaskBlur); const isBusy = useCanvasIsBusy(); const inpaintMasks = canvasSlice.inpaintMasks.entities; + const bboxRect = canvasSlice.bbox.rect; // Calculate the bounding box that contains all inpaint masks const calculateMaskBbox = useCallback((): Rect | null => { if (inpaintMasks.length === 0) { return null; } - let minX = Infinity; - let minY = Infinity; - let maxX = -Infinity; - let maxY = -Infinity; + + // Collect all mask objects from enabled masks + const allObjects: ( + | CanvasBrushLineState + | CanvasBrushLineWithPressureState + | CanvasEraserLineState + | CanvasEraserLineWithPressureState + | CanvasRectState + | CanvasImageState + )[] = []; + for (const mask of inpaintMasks) { if (!mask.isEnabled || !mask.objects || mask.objects.length === 0) { continue; } - for (const obj of mask.objects) { - let objMinX = 0; - let objMinY = 0; - let objMaxX = 0; - let objMaxY = 0; - if (obj.type === 'rect') { - objMinX = mask.position.x + obj.rect.x; - objMinY = mask.position.y + obj.rect.y; - objMaxX = objMinX + obj.rect.width; - objMaxY = objMinY + obj.rect.height; - } else if ( - obj.type === 'brush_line' || - obj.type === 'brush_line_with_pressure' || - obj.type === 'eraser_line' || - obj.type === 'eraser_line_with_pressure' - ) { - for (let i = 0; i < obj.points.length; i += 2) { - const x = mask.position.x + (obj.points[i] ?? 0); - const y = mask.position.y + (obj.points[i + 1] ?? 0); - if (i === 0) { - objMinX = objMaxX = x; - objMinY = objMaxY = y; - } else { - objMinX = Math.min(objMinX, x); - objMinY = Math.min(objMinY, y); - objMaxX = Math.max(objMaxX, x); - objMaxY = Math.max(objMaxY, y); - } - } - const strokeRadius = (obj.strokeWidth ?? 50) / 2; - objMinX -= strokeRadius; - objMinY -= strokeRadius; - objMaxX += strokeRadius; - objMaxY += strokeRadius; - } else if (obj.type === 'image') { - objMinX = mask.position.x; - objMinY = mask.position.y; - objMaxX = objMinX + obj.image.width; - objMaxY = objMinY + obj.image.height; - } - minX = Math.min(minX, objMinX); - minY = Math.min(minY, objMinY); - maxX = Math.max(maxX, objMaxX); - maxY = Math.max(maxY, objMaxY); - } + + // Transform objects to be relative to the bbox + const transformedObjects = transformMaskObjectsRelativeToBbox(mask.objects, bboxRect); + // Convert back to original types for compatibility + const originalObjects = transformedObjects.map(convertTransformedToOriginal); + allObjects.push(...originalObjects); } - if (minX === Infinity || minY === Infinity || maxX === -Infinity || maxY === -Infinity) { + + if (allObjects.length === 0) { return null; } - return { x: minX, y: minY, width: maxX - minX, height: maxY - minY }; - }, [inpaintMasks]); + + // Calculate bounds from the rendered bitmap for accurate results + const maskBounds = calculateMaskBoundsFromBitmap(allObjects, bboxRect.width, bboxRect.height); + + if (!maskBounds) { + return null; + } + + // Convert back to world coordinates relative to the bbox + return { + x: bboxRect.x + maskBounds.x, + y: bboxRect.y + maskBounds.y, + width: maskBounds.width, + height: maskBounds.height, + }; + }, [inpaintMasks, bboxRect]); const handleAdjustBbox = useCallback(() => { const maskBbox = calculateMaskBbox(); diff --git a/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts b/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts index 869cfd0473f..341f2f0e428 100644 --- a/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts +++ b/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts @@ -1010,10 +1010,7 @@ export const canvasSlice = createSlice({ return; } - // For now, we'll use a simple approach: create a full rectangle and add eraser lines - // This is a temporary solution until we can properly handle the bitmap conversion - - // Get the bbox dimensions for the mask + // Get the current bbox dimensions for the mask const bboxRect = state.bbox.rect; // Create a full rectangle covering the bbox @@ -1038,15 +1035,23 @@ export const canvasSlice = createSlice({ | CanvasBrushLineWithPressureState )[] = [fillRect]; + // Create a clip region that constrains all objects to the bbox + const bboxClip = { + x: bboxRect.x - entity.position.x, + y: bboxRect.y - entity.position.y, + width: bboxRect.width, + height: bboxRect.height, + }; + for (const obj of entity.objects) { if (obj.type === 'brush_line') { - // Convert brush line to eraser line + // Convert brush line to eraser line, ensuring it's clipped to the bbox const eraserLine: CanvasEraserLineState = { id: getPrefixedId('eraser_line'), type: 'eraser_line', strokeWidth: obj.strokeWidth, points: obj.points, - clip: obj.clip, + clip: bboxClip, // Always clip to the current bbox }; invertedObjects.push(eraserLine); } else if (obj.type === 'brush_line_with_pressure') { @@ -1056,7 +1061,7 @@ export const canvasSlice = createSlice({ type: 'eraser_line_with_pressure', strokeWidth: obj.strokeWidth, points: obj.points, - clip: obj.clip, + clip: bboxClip, // Always clip to the current bbox }; invertedObjects.push(eraserLine); } else if (obj.type === 'rect') { @@ -1080,7 +1085,7 @@ export const canvasSlice = createSlice({ type: 'eraser_line', points, strokeWidth: Math.max(width, height) / 2, // Use a stroke width that covers the rectangle - clip: null, + clip: bboxClip, // Always clip to the current bbox }; invertedObjects.push(eraserLine); } else if (obj.type === 'eraser_line') { @@ -1090,7 +1095,7 @@ export const canvasSlice = createSlice({ type: 'brush_line', strokeWidth: obj.strokeWidth, points: obj.points, - clip: obj.clip, + clip: bboxClip, // Always clip to the current bbox color: { r: 255, g: 255, b: 255, a: 1 }, }; invertedObjects.push(brushLine); @@ -1101,7 +1106,7 @@ export const canvasSlice = createSlice({ type: 'brush_line_with_pressure', strokeWidth: obj.strokeWidth, points: obj.points, - clip: obj.clip, + clip: bboxClip, // Always clip to the current bbox color: { r: 255, g: 255, b: 255, a: 1 }, }; invertedObjects.push(brushLine); diff --git a/invokeai/frontend/web/src/features/controlLayers/util/coordinateTransform.ts b/invokeai/frontend/web/src/features/controlLayers/util/coordinateTransform.ts new file mode 100644 index 00000000000..db7f9c1adab --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/util/coordinateTransform.ts @@ -0,0 +1,340 @@ +import type { + CanvasBrushLineState, + CanvasBrushLineWithPressureState, + CanvasEraserLineState, + CanvasEraserLineWithPressureState, + CanvasRectState, + CanvasImageState, + Coordinate, + Rect, +} from 'features/controlLayers/store/types'; + +/** + * Type for mask objects with transformed coordinates. + * This preserves the discriminated union structure while allowing coordinate transformations. + */ +export type TransformedMaskObject = + | TransformedBrushLine + | TransformedBrushLineWithPressure + | TransformedEraserLine + | TransformedEraserLineWithPressure + | TransformedRect + | TransformedImage; + +export interface TransformedBrushLine { + id: string; + type: 'brush_line'; + points: number[]; + strokeWidth: number; + color: { r: number; g: number; b: number; a: number }; + clip?: Rect | null; +} + +export interface TransformedBrushLineWithPressure { + id: string; + type: 'brush_line_with_pressure'; + points: number[]; + strokeWidth: number; + color: { r: number; g: number; b: number; a: number }; + clip?: Rect | null; +} + +export interface TransformedEraserLine { + id: string; + type: 'eraser_line'; + points: number[]; + strokeWidth: number; + clip?: Rect | null; +} + +export interface TransformedEraserLineWithPressure { + id: string; + type: 'eraser_line_with_pressure'; + points: number[]; + strokeWidth: number; + clip?: Rect | null; +} + +export interface TransformedRect { + id: string; + type: 'rect'; + rect: Rect; + color: { r: number; g: number; b: number; a: number }; +} + +export interface TransformedImage { + id: string; + type: 'image'; + image: { width: number; height: number; dataURL: string } | { width: number; height: number; image_name: string }; +} + +/** + * Transforms a mask object by applying a coordinate offset. + * @param obj The mask object to transform + * @param offset The offset to apply to coordinates + * @returns A new mask object with transformed coordinates + */ +export function transformMaskObject( + obj: CanvasBrushLineState | CanvasBrushLineWithPressureState | CanvasEraserLineState | CanvasEraserLineWithPressureState | CanvasRectState | CanvasImageState, + offset: Coordinate +): TransformedMaskObject { + switch (obj.type) { + case 'brush_line': + return { + ...obj, + points: transformPoints(obj.points, offset), + clip: obj.clip ? transformRect(obj.clip, offset) : null, + }; + case 'brush_line_with_pressure': + return { + ...obj, + points: transformPoints(obj.points, offset), + clip: obj.clip ? transformRect(obj.clip, offset) : null, + }; + case 'eraser_line': + return { + ...obj, + points: transformPoints(obj.points, offset), + clip: obj.clip ? transformRect(obj.clip, offset) : null, + }; + case 'eraser_line_with_pressure': + return { + ...obj, + points: transformPoints(obj.points, offset), + clip: obj.clip ? transformRect(obj.clip, offset) : null, + }; + case 'rect': + return { + ...obj, + rect: transformRect(obj.rect, offset), + }; + case 'image': + return { + ...obj, + }; + } +} + +/** + * Transforms an array of points by applying a coordinate offset. + * @param points Array of numbers representing [x1, y1, x2, y2, ...] + * @param offset The offset to apply + * @returns New array with transformed coordinates + */ +export function transformPoints(points: number[], offset: Coordinate): number[] { + const transformed: number[] = []; + for (let i = 0; i < points.length; i += 2) { + transformed.push((points[i] ?? 0) + offset.x); + transformed.push((points[i + 1] ?? 0) + offset.y); + } + return transformed; +} + +/** + * Transforms a rectangle by applying a coordinate offset. + * @param rect The rectangle to transform + * @param offset The offset to apply + * @returns New rectangle with transformed coordinates + */ +export function transformRect(rect: Rect, offset: Coordinate): Rect { + return { + x: rect.x + offset.x, + y: rect.y + offset.y, + width: rect.width, + height: rect.height, + }; +} + +/** + * Clips a mask object to the boundaries of a container rectangle. + * @param obj The mask object to clip + * @param container The container rectangle to clip to + * @returns A new mask object clipped to the container boundaries, or null if completely outside + */ +export function clipMaskObjectToContainer( + obj: TransformedMaskObject, + container: Rect +): TransformedMaskObject | null { + switch (obj.type) { + case 'brush_line': + case 'brush_line_with_pressure': + case 'eraser_line': + case 'eraser_line_with_pressure': + return clipLineToContainer(obj, container); + case 'rect': + return clipRectToContainer(obj, container); + case 'image': + return clipImageToContainer(obj, container); + } +} + +/** + * Clips a line object to container boundaries. + */ +function clipLineToContainer( + obj: TransformedBrushLine | TransformedBrushLineWithPressure | TransformedEraserLine | TransformedEraserLineWithPressure, + container: Rect +): typeof obj | null { + // For lines, we clip the points to the container boundaries + const clippedPoints: number[] = []; + + for (let i = 0; i < obj.points.length; i += 2) { + const x = obj.points[i] ?? 0; + const y = obj.points[i + 1] ?? 0; + + // Clip coordinates to container boundaries + const clippedX = Math.max(container.x, Math.min(container.x + container.width, x)); + const clippedY = Math.max(container.y, Math.min(container.y + container.height, y)); + + clippedPoints.push(clippedX, clippedY); + } + + // If no points remain, return null + if (clippedPoints.length === 0) { + return null; + } + + return { + ...obj, + points: clippedPoints, + clip: container, + }; +} + +/** + * Clips a rectangle object to container boundaries. + */ +function clipRectToContainer(obj: TransformedRect, container: Rect): TransformedRect | null { + const rect = obj.rect; + + // Calculate intersection + const left = Math.max(rect.x, container.x); + const top = Math.max(rect.y, container.y); + const right = Math.min(rect.x + rect.width, container.x + container.width); + const bottom = Math.min(rect.y + rect.height, container.y + container.height); + + // If no intersection, return null + if (left >= right || top >= bottom) { + return null; + } + + return { + ...obj, + rect: { + x: left, + y: top, + width: right - left, + height: bottom - top, + }, + }; +} + +/** + * Clips an image object to container boundaries. + */ +function clipImageToContainer(obj: TransformedImage, container: Rect): TransformedImage | null { + // For images, we don't clip them - they remain as-is + return obj; +} + +/** + * Calculates the effective bounds of a mask object. + * @param obj The mask object to calculate bounds for + * @returns The bounding rectangle, or null if the object has no effective bounds + */ +export function calculateMaskObjectBounds(obj: TransformedMaskObject): Rect | null { + switch (obj.type) { + case 'brush_line': + case 'brush_line_with_pressure': + case 'eraser_line': + case 'eraser_line_with_pressure': + return calculateLineBounds(obj); + case 'rect': + return obj.rect; + case 'image': + return calculateImageBounds(obj); + } +} + +/** + * Calculates bounds for a line object. + */ +function calculateLineBounds( + obj: TransformedBrushLine | TransformedBrushLineWithPressure | TransformedEraserLine | TransformedEraserLineWithPressure +): Rect | null { + if (obj.points.length < 2) { + return null; + } + + let minX = obj.points[0] ?? 0; + let minY = obj.points[1] ?? 0; + let maxX = minX; + let maxY = minY; + + for (let i = 2; i < obj.points.length; i += 2) { + const x = obj.points[i] ?? 0; + const y = obj.points[i + 1] ?? 0; + minX = Math.min(minX, x); + minY = Math.min(minY, y); + maxX = Math.max(maxX, x); + maxY = Math.max(maxY, y); + } + + // Add stroke width to bounds + const strokeRadius = obj.strokeWidth / 2; + return { + x: minX - strokeRadius, + y: minY - strokeRadius, + width: maxX - minX + obj.strokeWidth, + height: maxY - minY + obj.strokeWidth, + }; +} + +/** + * Calculates bounds for an image object. + */ +function calculateImageBounds(obj: TransformedImage): Rect | null { + return { + x: 0, + y: 0, + width: obj.image.width, + height: obj.image.height, + }; +} + +/** + * Converts a TransformedMaskObject back to its original mask object type. + * This is needed for compatibility with functions that expect the original types. + * @param obj The transformed mask object to convert back + * @returns The original mask object type + */ +export function convertTransformedToOriginal( + obj: TransformedMaskObject +): CanvasBrushLineState | CanvasBrushLineWithPressureState | CanvasEraserLineState | CanvasEraserLineWithPressureState | CanvasRectState | CanvasImageState { + switch (obj.type) { + case 'brush_line': + return { + ...obj, + clip: obj.clip ?? null, + }; + case 'brush_line_with_pressure': + return { + ...obj, + clip: obj.clip ?? null, + }; + case 'eraser_line': + return { + ...obj, + clip: obj.clip ?? null, + }; + case 'eraser_line_with_pressure': + return { + ...obj, + clip: obj.clip ?? null, + }; + case 'rect': + return obj; + case 'image': + return obj; + } +} \ No newline at end of file diff --git a/invokeai/frontend/web/src/features/controlLayers/util/maskObjectTransform.ts b/invokeai/frontend/web/src/features/controlLayers/util/maskObjectTransform.ts new file mode 100644 index 00000000000..bc8ab7b038b --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/util/maskObjectTransform.ts @@ -0,0 +1,241 @@ +import type { + CanvasBrushLineState, + CanvasBrushLineWithPressureState, + CanvasEraserLineState, + CanvasEraserLineWithPressureState, + CanvasRectState, + CanvasImageState, + Coordinate, + Rect, +} from 'features/controlLayers/store/types'; +import { + transformMaskObject, + clipMaskObjectToContainer, + calculateMaskObjectBounds, + convertTransformedToOriginal, + type TransformedMaskObject, +} from './coordinateTransform'; +import { maskObjectsToBitmap } from './bitmapToMaskObjects'; + +/** + * Transforms mask objects relative to a bounding box container. + * This adjusts all object coordinates to be relative to the bbox origin. + * @param objects Array of mask objects to transform + * @param bboxRect The bounding box to use as the container reference + * @returns Array of transformed mask objects + */ +export function transformMaskObjectsRelativeToBbox( + objects: ( + | CanvasBrushLineState + | CanvasBrushLineWithPressureState + | CanvasEraserLineState + | CanvasEraserLineWithPressureState + | CanvasRectState + | CanvasImageState + )[], + bboxRect: Rect +): TransformedMaskObject[] { + const transformedObjects: TransformedMaskObject[] = []; + + for (const obj of objects) { + // Calculate the offset to make coordinates relative to the bbox + const offset: Coordinate = { + x: -bboxRect.x, + y: -bboxRect.y, + }; + + const transformed = transformMaskObject(obj, offset); + transformedObjects.push(transformed); + } + + return transformedObjects; +} + +/** + * Clips all mask objects to the boundaries of a container rectangle. + * @param objects Array of mask objects to clip + * @param container The container rectangle to clip to + * @returns Array of clipped mask objects (null values are filtered out) + */ +export function clipMaskObjectsToContainer( + objects: TransformedMaskObject[], + container: Rect +): TransformedMaskObject[] { + return objects + .map((obj) => clipMaskObjectToContainer(obj, container)) + .filter((obj): obj is TransformedMaskObject => obj !== null); +} + +/** + * Calculates the effective bounds of all mask objects. + * @param objects Array of mask objects to calculate bounds for + * @returns The bounding rectangle containing all objects, or null if no objects + */ +export function calculateMaskObjectsBounds(objects: TransformedMaskObject[]): Rect | null { + if (objects.length === 0) { + return null; + } + + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + + for (const obj of objects) { + const bounds = calculateMaskObjectBounds(obj); + if (bounds) { + minX = Math.min(minX, bounds.x); + minY = Math.min(minY, bounds.y); + maxX = Math.max(maxX, bounds.x + bounds.width); + maxY = Math.max(maxY, bounds.y + bounds.height); + } + } + + if (minX === Infinity || minY === Infinity || maxX === -Infinity || maxY === -Infinity) { + return null; + } + + return { + x: minX, + y: minY, + width: maxX - minX, + height: maxY - minY, + }; +} + +/** + * Calculates the bounding box of a consolidated mask by rendering it to a bitmap. + * This provides the most accurate bounds by considering the actual rendered mask pixels. + * @param objects Array of mask objects to calculate bounds for + * @param canvasWidth Width of the canvas to render to + * @param canvasHeight Height of the canvas to render to + * @returns The bounding rectangle of the rendered mask, or null if no mask pixels + */ +export function calculateMaskBoundsFromBitmap( + objects: TransformedMaskObject[], + canvasWidth: number, + canvasHeight: number +): Rect | null { + if (objects.length === 0) { + return null; + } + + // Convert transformed objects back to original types for compatibility + const originalObjects = objects.map(convertTransformedToOriginal); + + // Render the consolidated mask to a bitmap + const bitmap = maskObjectsToBitmap(originalObjects, canvasWidth, canvasHeight); + const { width, height, data } = bitmap; + + // Find the actual bounds of the rendered mask + let maskMinX = width; + let maskMinY = height; + let maskMaxX = 0; + let maskMaxY = 0; + + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const pixelIndex = (y * width + x) * 4; + const alpha = data[pixelIndex + 3] ?? 0; + + // If this pixel has any opacity, it's part of the mask + if (alpha > 0) { + maskMinX = Math.min(maskMinX, x); + maskMinY = Math.min(maskMinY, y); + maskMaxX = Math.max(maskMaxX, x); + maskMaxY = Math.max(maskMaxY, y); + } + } + } + + // If no mask pixels found, return null + if (maskMinX >= maskMaxX || maskMinY >= maskMaxY) { + return null; + } + + return { + x: maskMinX, + y: maskMinY, + width: maskMaxX - maskMinX + 1, + height: maskMaxY - maskMinY + 1, + }; +} + +/** + * Inverts a mask by creating a new mask that covers the entire container except for the original mask areas. + * @param objects Array of mask objects representing the original mask + * @param container The container rectangle to invert within + * @returns Array of mask objects representing the inverted mask + */ +export function invertMask( + objects: TransformedMaskObject[], + container: Rect +): TransformedMaskObject[] { + // Create a rectangle that covers the entire container + const fullCoverageRect: TransformedMaskObject = { + id: 'inverted_mask_rect', + type: 'rect', + rect: { + x: container.x, + y: container.y, + width: container.width, + height: container.height, + }, + color: { r: 255, g: 255, b: 255, a: 1 }, + }; + + // For each original mask object, create an eraser line that removes it + const eraserObjects: TransformedMaskObject[] = []; + + for (const obj of objects) { + if (obj.type === 'rect') { + // For rectangles, create an eraser rectangle + const eraserRect: TransformedMaskObject = { + id: `eraser_${obj.id}`, + type: 'eraser_line', + points: [ + obj.rect.x, obj.rect.y, + obj.rect.x + obj.rect.width, obj.rect.y, + obj.rect.x + obj.rect.width, obj.rect.y + obj.rect.height, + obj.rect.x, obj.rect.y + obj.rect.height, + obj.rect.x, obj.rect.y, // Close the rectangle + ], + strokeWidth: 1, + clip: container, + }; + eraserObjects.push(eraserRect); + } else if ( + obj.type === 'brush_line' || + obj.type === 'brush_line_with_pressure' || + obj.type === 'eraser_line' || + obj.type === 'eraser_line_with_pressure' + ) { + // For lines, create an eraser line with the same points + const eraserLine: TransformedMaskObject = { + id: `eraser_${obj.id}`, + type: 'eraser_line', + points: [...obj.points], + strokeWidth: obj.strokeWidth, + clip: container, + }; + eraserObjects.push(eraserLine); + } + // Note: Image objects are not handled in inversion as they're not commonly used in masks + } + + return [fullCoverageRect, ...eraserObjects]; +} + +/** + * Ensures all mask objects are clipped to the current bounding box boundaries. + * This prevents masks from extending outside the bounding box after multiple inversions. + * @param objects Array of mask objects to clip + * @param bboxRect The bounding box to clip to + * @returns Array of clipped mask objects + */ +export function ensureMaskObjectsWithinBbox( + objects: TransformedMaskObject[], + bboxRect: Rect +): TransformedMaskObject[] { + return clipMaskObjectsToContainer(objects, bboxRect); +} \ No newline at end of file From 6b0a02218f6ee04a510c70e004c40ee437dce199 Mon Sep 17 00:00:00 2001 From: Kent Keirsey <31807370+hipsterusername@users.noreply.github.com> Date: Tue, 1 Jul 2025 11:53:34 -0400 Subject: [PATCH 20/26] lints --- .../InpaintMask/InpaintMaskBboxAdjuster.tsx | 15 +++-- .../hooks/useCanvasAdjustBboxHotkey.ts | 17 +++--- .../hooks/useCanvasInvertMaskHotkey.ts | 9 ++- .../controlLayers/util/coordinateTransform.ts | 61 ++++++++++++------- .../controlLayers/util/maskObjectTransform.ts | 45 +++++++------- 5 files changed, 86 insertions(+), 61 deletions(-) diff --git a/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskBboxAdjuster.tsx b/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskBboxAdjuster.tsx index 74390df5145..cc287db24ac 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskBboxAdjuster.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskBboxAdjuster.tsx @@ -3,17 +3,20 @@ import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { bboxChangedFromCanvas } from 'features/controlLayers/store/canvasSlice'; import { selectMaskBlur } from 'features/controlLayers/store/paramsSlice'; import { selectCanvasSlice } from 'features/controlLayers/store/selectors'; -import type { - Rect, +import type { CanvasBrushLineState, CanvasBrushLineWithPressureState, CanvasEraserLineState, CanvasEraserLineWithPressureState, - CanvasRectState, CanvasImageState, + CanvasRectState, + Rect, } from 'features/controlLayers/store/types'; -import { transformMaskObjectsRelativeToBbox, calculateMaskBoundsFromBitmap } from 'features/controlLayers/util/maskObjectTransform'; import { convertTransformedToOriginal } from 'features/controlLayers/util/coordinateTransform'; +import { + calculateMaskBoundsFromBitmap, + transformMaskObjectsRelativeToBbox, +} from 'features/controlLayers/util/maskObjectTransform'; import { memo, useCallback, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { PiCropBold } from 'react-icons/pi'; @@ -43,7 +46,7 @@ export const InpaintMaskBboxAdjuster = memo(() => { | CanvasRectState | CanvasImageState )[] = []; - + for (const mask of inpaintMasks) { if (!mask.isEnabled || !mask.objects || mask.objects.length === 0) { continue; @@ -62,7 +65,7 @@ export const InpaintMaskBboxAdjuster = memo(() => { // Calculate bounds from the rendered bitmap for accurate results const maskBounds = calculateMaskBoundsFromBitmap(allObjects, bboxRect.width, bboxRect.height); - + if (!maskBounds) { return null; } diff --git a/invokeai/frontend/web/src/features/controlLayers/hooks/useCanvasAdjustBboxHotkey.ts b/invokeai/frontend/web/src/features/controlLayers/hooks/useCanvasAdjustBboxHotkey.ts index 9e07d1f639e..7e988b2be1d 100644 --- a/invokeai/frontend/web/src/features/controlLayers/hooks/useCanvasAdjustBboxHotkey.ts +++ b/invokeai/frontend/web/src/features/controlLayers/hooks/useCanvasAdjustBboxHotkey.ts @@ -4,17 +4,20 @@ import { useCanvasIsBusy } from 'features/controlLayers/hooks/useCanvasIsBusy'; import { bboxChangedFromCanvas } from 'features/controlLayers/store/canvasSlice'; import { selectMaskBlur } from 'features/controlLayers/store/paramsSlice'; import { selectCanvasSlice } from 'features/controlLayers/store/selectors'; -import type { - Rect, +import type { CanvasBrushLineState, CanvasBrushLineWithPressureState, CanvasEraserLineState, CanvasEraserLineWithPressureState, - CanvasRectState, CanvasImageState, + CanvasRectState, + Rect, } from 'features/controlLayers/store/types'; -import { transformMaskObjectsRelativeToBbox, calculateMaskBoundsFromBitmap } from 'features/controlLayers/util/maskObjectTransform'; import { convertTransformedToOriginal } from 'features/controlLayers/util/coordinateTransform'; +import { + calculateMaskBoundsFromBitmap, + transformMaskObjectsRelativeToBbox, +} from 'features/controlLayers/util/maskObjectTransform'; import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData'; import { useCallback, useMemo } from 'react'; @@ -42,7 +45,7 @@ export const useCanvasAdjustBboxHotkey = () => { | CanvasRectState | CanvasImageState )[] = []; - + for (const mask of inpaintMasks) { if (!mask.isEnabled || !mask.objects || mask.objects.length === 0) { continue; @@ -61,7 +64,7 @@ export const useCanvasAdjustBboxHotkey = () => { // Calculate bounds from the rendered bitmap for accurate results const maskBounds = calculateMaskBoundsFromBitmap(allObjects, bboxRect.width, bboxRect.height); - + if (!maskBounds) { return null; } @@ -104,4 +107,4 @@ export const useCanvasAdjustBboxHotkey = () => { options: { enabled: isAdjustBboxAllowed && !isBusy, preventDefault: true }, dependencies: [isAdjustBboxAllowed, isBusy, handleAdjustBbox], }); -}; \ No newline at end of file +}; diff --git a/invokeai/frontend/web/src/features/controlLayers/hooks/useCanvasInvertMaskHotkey.ts b/invokeai/frontend/web/src/features/controlLayers/hooks/useCanvasInvertMaskHotkey.ts index 699c63354d4..9b9db039acc 100644 --- a/invokeai/frontend/web/src/features/controlLayers/hooks/useCanvasInvertMaskHotkey.ts +++ b/invokeai/frontend/web/src/features/controlLayers/hooks/useCanvasInvertMaskHotkey.ts @@ -3,6 +3,7 @@ import { useAssertSingleton } from 'common/hooks/useAssertSingleton'; import { useCanvasIsBusy } from 'features/controlLayers/hooks/useCanvasIsBusy'; import { inpaintMaskInverted } from 'features/controlLayers/store/canvasSlice'; import { selectCanvasSlice, selectSelectedEntityIdentifier } from 'features/controlLayers/store/selectors'; +import type { CanvasEntityIdentifier } from 'features/controlLayers/store/types'; import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData'; import { useCallback, useMemo } from 'react'; @@ -27,7 +28,11 @@ export const useCanvasInvertMaskHotkey = () => { return; } - dispatch(inpaintMaskInverted({ entityIdentifier: selectedEntityIdentifier as any })); + dispatch( + inpaintMaskInverted({ + entityIdentifier: selectedEntityIdentifier as CanvasEntityIdentifier<'inpaint_mask'>, + }) + ); }, [dispatch, selectedEntityIdentifier, canvasSlice]); const isInvertMaskAllowed = useMemo(() => { @@ -49,4 +54,4 @@ export const useCanvasInvertMaskHotkey = () => { options: { enabled: isInvertMaskAllowed && !isBusy, preventDefault: true }, dependencies: [isInvertMaskAllowed, isBusy, handleInvertMask], }); -}; \ No newline at end of file +}; diff --git a/invokeai/frontend/web/src/features/controlLayers/util/coordinateTransform.ts b/invokeai/frontend/web/src/features/controlLayers/util/coordinateTransform.ts index db7f9c1adab..71234037883 100644 --- a/invokeai/frontend/web/src/features/controlLayers/util/coordinateTransform.ts +++ b/invokeai/frontend/web/src/features/controlLayers/util/coordinateTransform.ts @@ -3,8 +3,8 @@ import type { CanvasBrushLineWithPressureState, CanvasEraserLineState, CanvasEraserLineWithPressureState, - CanvasRectState, CanvasImageState, + CanvasRectState, Coordinate, Rect, } from 'features/controlLayers/store/types'; @@ -75,7 +75,13 @@ export interface TransformedImage { * @returns A new mask object with transformed coordinates */ export function transformMaskObject( - obj: CanvasBrushLineState | CanvasBrushLineWithPressureState | CanvasEraserLineState | CanvasEraserLineWithPressureState | CanvasRectState | CanvasImageState, + obj: + | CanvasBrushLineState + | CanvasBrushLineWithPressureState + | CanvasEraserLineState + | CanvasEraserLineWithPressureState + | CanvasRectState + | CanvasImageState, offset: Coordinate ): TransformedMaskObject { switch (obj.type) { @@ -151,10 +157,7 @@ export function transformRect(rect: Rect, offset: Coordinate): Rect { * @param container The container rectangle to clip to * @returns A new mask object clipped to the container boundaries, or null if completely outside */ -export function clipMaskObjectToContainer( - obj: TransformedMaskObject, - container: Rect -): TransformedMaskObject | null { +export function clipMaskObjectToContainer(obj: TransformedMaskObject, container: Rect): TransformedMaskObject | null { switch (obj.type) { case 'brush_line': case 'brush_line_with_pressure': @@ -172,28 +175,32 @@ export function clipMaskObjectToContainer( * Clips a line object to container boundaries. */ function clipLineToContainer( - obj: TransformedBrushLine | TransformedBrushLineWithPressure | TransformedEraserLine | TransformedEraserLineWithPressure, + obj: + | TransformedBrushLine + | TransformedBrushLineWithPressure + | TransformedEraserLine + | TransformedEraserLineWithPressure, container: Rect ): typeof obj | null { // For lines, we clip the points to the container boundaries const clippedPoints: number[] = []; - + for (let i = 0; i < obj.points.length; i += 2) { const x = obj.points[i] ?? 0; const y = obj.points[i + 1] ?? 0; - + // Clip coordinates to container boundaries const clippedX = Math.max(container.x, Math.min(container.x + container.width, x)); const clippedY = Math.max(container.y, Math.min(container.y + container.height, y)); - + clippedPoints.push(clippedX, clippedY); } - + // If no points remain, return null if (clippedPoints.length === 0) { return null; } - + return { ...obj, points: clippedPoints, @@ -206,18 +213,18 @@ function clipLineToContainer( */ function clipRectToContainer(obj: TransformedRect, container: Rect): TransformedRect | null { const rect = obj.rect; - + // Calculate intersection const left = Math.max(rect.x, container.x); const top = Math.max(rect.y, container.y); const right = Math.min(rect.x + rect.width, container.x + container.width); const bottom = Math.min(rect.y + rect.height, container.y + container.height); - + // If no intersection, return null if (left >= right || top >= bottom) { return null; } - + return { ...obj, rect: { @@ -232,7 +239,7 @@ function clipRectToContainer(obj: TransformedRect, container: Rect): Transformed /** * Clips an image object to container boundaries. */ -function clipImageToContainer(obj: TransformedImage, container: Rect): TransformedImage | null { +function clipImageToContainer(obj: TransformedImage, _container: Rect): TransformedImage | null { // For images, we don't clip them - they remain as-is return obj; } @@ -260,17 +267,21 @@ export function calculateMaskObjectBounds(obj: TransformedMaskObject): Rect | nu * Calculates bounds for a line object. */ function calculateLineBounds( - obj: TransformedBrushLine | TransformedBrushLineWithPressure | TransformedEraserLine | TransformedEraserLineWithPressure + obj: + | TransformedBrushLine + | TransformedBrushLineWithPressure + | TransformedEraserLine + | TransformedEraserLineWithPressure ): Rect | null { if (obj.points.length < 2) { return null; } - + let minX = obj.points[0] ?? 0; let minY = obj.points[1] ?? 0; let maxX = minX; let maxY = minY; - + for (let i = 2; i < obj.points.length; i += 2) { const x = obj.points[i] ?? 0; const y = obj.points[i + 1] ?? 0; @@ -279,7 +290,7 @@ function calculateLineBounds( maxX = Math.max(maxX, x); maxY = Math.max(maxY, y); } - + // Add stroke width to bounds const strokeRadius = obj.strokeWidth / 2; return { @@ -310,7 +321,13 @@ function calculateImageBounds(obj: TransformedImage): Rect | null { */ export function convertTransformedToOriginal( obj: TransformedMaskObject -): CanvasBrushLineState | CanvasBrushLineWithPressureState | CanvasEraserLineState | CanvasEraserLineWithPressureState | CanvasRectState | CanvasImageState { +): + | CanvasBrushLineState + | CanvasBrushLineWithPressureState + | CanvasEraserLineState + | CanvasEraserLineWithPressureState + | CanvasRectState + | CanvasImageState { switch (obj.type) { case 'brush_line': return { @@ -337,4 +354,4 @@ export function convertTransformedToOriginal( case 'image': return obj; } -} \ No newline at end of file +} diff --git a/invokeai/frontend/web/src/features/controlLayers/util/maskObjectTransform.ts b/invokeai/frontend/web/src/features/controlLayers/util/maskObjectTransform.ts index bc8ab7b038b..d221e541c4b 100644 --- a/invokeai/frontend/web/src/features/controlLayers/util/maskObjectTransform.ts +++ b/invokeai/frontend/web/src/features/controlLayers/util/maskObjectTransform.ts @@ -3,19 +3,20 @@ import type { CanvasBrushLineWithPressureState, CanvasEraserLineState, CanvasEraserLineWithPressureState, - CanvasRectState, CanvasImageState, + CanvasRectState, Coordinate, Rect, } from 'features/controlLayers/store/types'; + +import { maskObjectsToBitmap } from './bitmapToMaskObjects'; import { - transformMaskObject, - clipMaskObjectToContainer, calculateMaskObjectBounds, + clipMaskObjectToContainer, convertTransformedToOriginal, type TransformedMaskObject, + transformMaskObject, } from './coordinateTransform'; -import { maskObjectsToBitmap } from './bitmapToMaskObjects'; /** * Transforms mask objects relative to a bounding box container. @@ -57,10 +58,7 @@ export function transformMaskObjectsRelativeToBbox( * @param container The container rectangle to clip to * @returns Array of clipped mask objects (null values are filtered out) */ -export function clipMaskObjectsToContainer( - objects: TransformedMaskObject[], - container: Rect -): TransformedMaskObject[] { +export function clipMaskObjectsToContainer(objects: TransformedMaskObject[], container: Rect): TransformedMaskObject[] { return objects .map((obj) => clipMaskObjectToContainer(obj, container)) .filter((obj): obj is TransformedMaskObject => obj !== null); @@ -122,7 +120,7 @@ export function calculateMaskBoundsFromBitmap( // Convert transformed objects back to original types for compatibility const originalObjects = objects.map(convertTransformedToOriginal); - + // Render the consolidated mask to a bitmap const bitmap = maskObjectsToBitmap(originalObjects, canvasWidth, canvasHeight); const { width, height, data } = bitmap; @@ -167,10 +165,7 @@ export function calculateMaskBoundsFromBitmap( * @param container The container rectangle to invert within * @returns Array of mask objects representing the inverted mask */ -export function invertMask( - objects: TransformedMaskObject[], - container: Rect -): TransformedMaskObject[] { +export function invertMask(objects: TransformedMaskObject[], container: Rect): TransformedMaskObject[] { // Create a rectangle that covers the entire container const fullCoverageRect: TransformedMaskObject = { id: 'inverted_mask_rect', @@ -186,7 +181,7 @@ export function invertMask( // For each original mask object, create an eraser line that removes it const eraserObjects: TransformedMaskObject[] = []; - + for (const obj of objects) { if (obj.type === 'rect') { // For rectangles, create an eraser rectangle @@ -194,11 +189,16 @@ export function invertMask( id: `eraser_${obj.id}`, type: 'eraser_line', points: [ - obj.rect.x, obj.rect.y, - obj.rect.x + obj.rect.width, obj.rect.y, - obj.rect.x + obj.rect.width, obj.rect.y + obj.rect.height, - obj.rect.x, obj.rect.y + obj.rect.height, - obj.rect.x, obj.rect.y, // Close the rectangle + obj.rect.x, + obj.rect.y, + obj.rect.x + obj.rect.width, + obj.rect.y, + obj.rect.x + obj.rect.width, + obj.rect.y + obj.rect.height, + obj.rect.x, + obj.rect.y + obj.rect.height, + obj.rect.x, + obj.rect.y, // Close the rectangle ], strokeWidth: 1, clip: container, @@ -233,9 +233,6 @@ export function invertMask( * @param bboxRect The bounding box to clip to * @returns Array of clipped mask objects */ -export function ensureMaskObjectsWithinBbox( - objects: TransformedMaskObject[], - bboxRect: Rect -): TransformedMaskObject[] { +export function ensureMaskObjectsWithinBbox(objects: TransformedMaskObject[], bboxRect: Rect): TransformedMaskObject[] { return clipMaskObjectsToContainer(objects, bboxRect); -} \ No newline at end of file +} From f713435aa7a2b8276f82861e1402b42f5d3f503c Mon Sep 17 00:00:00 2001 From: Kent Keirsey <31807370+hipsterusername@users.noreply.github.com> Date: Tue, 1 Jul 2025 13:00:58 -0400 Subject: [PATCH 21/26] Revert "lints" This reverts commit 2952988d2c0ef042a5ba2b34883415def80fb3a1. --- .../InpaintMask/InpaintMaskBboxAdjuster.tsx | 15 ++--- .../hooks/useCanvasAdjustBboxHotkey.ts | 17 +++--- .../hooks/useCanvasInvertMaskHotkey.ts | 9 +-- .../controlLayers/util/coordinateTransform.ts | 61 +++++++------------ .../controlLayers/util/maskObjectTransform.ts | 45 +++++++------- 5 files changed, 61 insertions(+), 86 deletions(-) diff --git a/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskBboxAdjuster.tsx b/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskBboxAdjuster.tsx index cc287db24ac..74390df5145 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskBboxAdjuster.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskBboxAdjuster.tsx @@ -3,20 +3,17 @@ import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { bboxChangedFromCanvas } from 'features/controlLayers/store/canvasSlice'; import { selectMaskBlur } from 'features/controlLayers/store/paramsSlice'; import { selectCanvasSlice } from 'features/controlLayers/store/selectors'; -import type { +import type { + Rect, CanvasBrushLineState, CanvasBrushLineWithPressureState, CanvasEraserLineState, CanvasEraserLineWithPressureState, - CanvasImageState, CanvasRectState, - Rect, + CanvasImageState, } from 'features/controlLayers/store/types'; +import { transformMaskObjectsRelativeToBbox, calculateMaskBoundsFromBitmap } from 'features/controlLayers/util/maskObjectTransform'; import { convertTransformedToOriginal } from 'features/controlLayers/util/coordinateTransform'; -import { - calculateMaskBoundsFromBitmap, - transformMaskObjectsRelativeToBbox, -} from 'features/controlLayers/util/maskObjectTransform'; import { memo, useCallback, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { PiCropBold } from 'react-icons/pi'; @@ -46,7 +43,7 @@ export const InpaintMaskBboxAdjuster = memo(() => { | CanvasRectState | CanvasImageState )[] = []; - + for (const mask of inpaintMasks) { if (!mask.isEnabled || !mask.objects || mask.objects.length === 0) { continue; @@ -65,7 +62,7 @@ export const InpaintMaskBboxAdjuster = memo(() => { // Calculate bounds from the rendered bitmap for accurate results const maskBounds = calculateMaskBoundsFromBitmap(allObjects, bboxRect.width, bboxRect.height); - + if (!maskBounds) { return null; } diff --git a/invokeai/frontend/web/src/features/controlLayers/hooks/useCanvasAdjustBboxHotkey.ts b/invokeai/frontend/web/src/features/controlLayers/hooks/useCanvasAdjustBboxHotkey.ts index 7e988b2be1d..9e07d1f639e 100644 --- a/invokeai/frontend/web/src/features/controlLayers/hooks/useCanvasAdjustBboxHotkey.ts +++ b/invokeai/frontend/web/src/features/controlLayers/hooks/useCanvasAdjustBboxHotkey.ts @@ -4,20 +4,17 @@ import { useCanvasIsBusy } from 'features/controlLayers/hooks/useCanvasIsBusy'; import { bboxChangedFromCanvas } from 'features/controlLayers/store/canvasSlice'; import { selectMaskBlur } from 'features/controlLayers/store/paramsSlice'; import { selectCanvasSlice } from 'features/controlLayers/store/selectors'; -import type { +import type { + Rect, CanvasBrushLineState, CanvasBrushLineWithPressureState, CanvasEraserLineState, CanvasEraserLineWithPressureState, - CanvasImageState, CanvasRectState, - Rect, + CanvasImageState, } from 'features/controlLayers/store/types'; +import { transformMaskObjectsRelativeToBbox, calculateMaskBoundsFromBitmap } from 'features/controlLayers/util/maskObjectTransform'; import { convertTransformedToOriginal } from 'features/controlLayers/util/coordinateTransform'; -import { - calculateMaskBoundsFromBitmap, - transformMaskObjectsRelativeToBbox, -} from 'features/controlLayers/util/maskObjectTransform'; import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData'; import { useCallback, useMemo } from 'react'; @@ -45,7 +42,7 @@ export const useCanvasAdjustBboxHotkey = () => { | CanvasRectState | CanvasImageState )[] = []; - + for (const mask of inpaintMasks) { if (!mask.isEnabled || !mask.objects || mask.objects.length === 0) { continue; @@ -64,7 +61,7 @@ export const useCanvasAdjustBboxHotkey = () => { // Calculate bounds from the rendered bitmap for accurate results const maskBounds = calculateMaskBoundsFromBitmap(allObjects, bboxRect.width, bboxRect.height); - + if (!maskBounds) { return null; } @@ -107,4 +104,4 @@ export const useCanvasAdjustBboxHotkey = () => { options: { enabled: isAdjustBboxAllowed && !isBusy, preventDefault: true }, dependencies: [isAdjustBboxAllowed, isBusy, handleAdjustBbox], }); -}; +}; \ No newline at end of file diff --git a/invokeai/frontend/web/src/features/controlLayers/hooks/useCanvasInvertMaskHotkey.ts b/invokeai/frontend/web/src/features/controlLayers/hooks/useCanvasInvertMaskHotkey.ts index 9b9db039acc..699c63354d4 100644 --- a/invokeai/frontend/web/src/features/controlLayers/hooks/useCanvasInvertMaskHotkey.ts +++ b/invokeai/frontend/web/src/features/controlLayers/hooks/useCanvasInvertMaskHotkey.ts @@ -3,7 +3,6 @@ import { useAssertSingleton } from 'common/hooks/useAssertSingleton'; import { useCanvasIsBusy } from 'features/controlLayers/hooks/useCanvasIsBusy'; import { inpaintMaskInverted } from 'features/controlLayers/store/canvasSlice'; import { selectCanvasSlice, selectSelectedEntityIdentifier } from 'features/controlLayers/store/selectors'; -import type { CanvasEntityIdentifier } from 'features/controlLayers/store/types'; import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData'; import { useCallback, useMemo } from 'react'; @@ -28,11 +27,7 @@ export const useCanvasInvertMaskHotkey = () => { return; } - dispatch( - inpaintMaskInverted({ - entityIdentifier: selectedEntityIdentifier as CanvasEntityIdentifier<'inpaint_mask'>, - }) - ); + dispatch(inpaintMaskInverted({ entityIdentifier: selectedEntityIdentifier as any })); }, [dispatch, selectedEntityIdentifier, canvasSlice]); const isInvertMaskAllowed = useMemo(() => { @@ -54,4 +49,4 @@ export const useCanvasInvertMaskHotkey = () => { options: { enabled: isInvertMaskAllowed && !isBusy, preventDefault: true }, dependencies: [isInvertMaskAllowed, isBusy, handleInvertMask], }); -}; +}; \ No newline at end of file diff --git a/invokeai/frontend/web/src/features/controlLayers/util/coordinateTransform.ts b/invokeai/frontend/web/src/features/controlLayers/util/coordinateTransform.ts index 71234037883..db7f9c1adab 100644 --- a/invokeai/frontend/web/src/features/controlLayers/util/coordinateTransform.ts +++ b/invokeai/frontend/web/src/features/controlLayers/util/coordinateTransform.ts @@ -3,8 +3,8 @@ import type { CanvasBrushLineWithPressureState, CanvasEraserLineState, CanvasEraserLineWithPressureState, - CanvasImageState, CanvasRectState, + CanvasImageState, Coordinate, Rect, } from 'features/controlLayers/store/types'; @@ -75,13 +75,7 @@ export interface TransformedImage { * @returns A new mask object with transformed coordinates */ export function transformMaskObject( - obj: - | CanvasBrushLineState - | CanvasBrushLineWithPressureState - | CanvasEraserLineState - | CanvasEraserLineWithPressureState - | CanvasRectState - | CanvasImageState, + obj: CanvasBrushLineState | CanvasBrushLineWithPressureState | CanvasEraserLineState | CanvasEraserLineWithPressureState | CanvasRectState | CanvasImageState, offset: Coordinate ): TransformedMaskObject { switch (obj.type) { @@ -157,7 +151,10 @@ export function transformRect(rect: Rect, offset: Coordinate): Rect { * @param container The container rectangle to clip to * @returns A new mask object clipped to the container boundaries, or null if completely outside */ -export function clipMaskObjectToContainer(obj: TransformedMaskObject, container: Rect): TransformedMaskObject | null { +export function clipMaskObjectToContainer( + obj: TransformedMaskObject, + container: Rect +): TransformedMaskObject | null { switch (obj.type) { case 'brush_line': case 'brush_line_with_pressure': @@ -175,32 +172,28 @@ export function clipMaskObjectToContainer(obj: TransformedMaskObject, container: * Clips a line object to container boundaries. */ function clipLineToContainer( - obj: - | TransformedBrushLine - | TransformedBrushLineWithPressure - | TransformedEraserLine - | TransformedEraserLineWithPressure, + obj: TransformedBrushLine | TransformedBrushLineWithPressure | TransformedEraserLine | TransformedEraserLineWithPressure, container: Rect ): typeof obj | null { // For lines, we clip the points to the container boundaries const clippedPoints: number[] = []; - + for (let i = 0; i < obj.points.length; i += 2) { const x = obj.points[i] ?? 0; const y = obj.points[i + 1] ?? 0; - + // Clip coordinates to container boundaries const clippedX = Math.max(container.x, Math.min(container.x + container.width, x)); const clippedY = Math.max(container.y, Math.min(container.y + container.height, y)); - + clippedPoints.push(clippedX, clippedY); } - + // If no points remain, return null if (clippedPoints.length === 0) { return null; } - + return { ...obj, points: clippedPoints, @@ -213,18 +206,18 @@ function clipLineToContainer( */ function clipRectToContainer(obj: TransformedRect, container: Rect): TransformedRect | null { const rect = obj.rect; - + // Calculate intersection const left = Math.max(rect.x, container.x); const top = Math.max(rect.y, container.y); const right = Math.min(rect.x + rect.width, container.x + container.width); const bottom = Math.min(rect.y + rect.height, container.y + container.height); - + // If no intersection, return null if (left >= right || top >= bottom) { return null; } - + return { ...obj, rect: { @@ -239,7 +232,7 @@ function clipRectToContainer(obj: TransformedRect, container: Rect): Transformed /** * Clips an image object to container boundaries. */ -function clipImageToContainer(obj: TransformedImage, _container: Rect): TransformedImage | null { +function clipImageToContainer(obj: TransformedImage, container: Rect): TransformedImage | null { // For images, we don't clip them - they remain as-is return obj; } @@ -267,21 +260,17 @@ export function calculateMaskObjectBounds(obj: TransformedMaskObject): Rect | nu * Calculates bounds for a line object. */ function calculateLineBounds( - obj: - | TransformedBrushLine - | TransformedBrushLineWithPressure - | TransformedEraserLine - | TransformedEraserLineWithPressure + obj: TransformedBrushLine | TransformedBrushLineWithPressure | TransformedEraserLine | TransformedEraserLineWithPressure ): Rect | null { if (obj.points.length < 2) { return null; } - + let minX = obj.points[0] ?? 0; let minY = obj.points[1] ?? 0; let maxX = minX; let maxY = minY; - + for (let i = 2; i < obj.points.length; i += 2) { const x = obj.points[i] ?? 0; const y = obj.points[i + 1] ?? 0; @@ -290,7 +279,7 @@ function calculateLineBounds( maxX = Math.max(maxX, x); maxY = Math.max(maxY, y); } - + // Add stroke width to bounds const strokeRadius = obj.strokeWidth / 2; return { @@ -321,13 +310,7 @@ function calculateImageBounds(obj: TransformedImage): Rect | null { */ export function convertTransformedToOriginal( obj: TransformedMaskObject -): - | CanvasBrushLineState - | CanvasBrushLineWithPressureState - | CanvasEraserLineState - | CanvasEraserLineWithPressureState - | CanvasRectState - | CanvasImageState { +): CanvasBrushLineState | CanvasBrushLineWithPressureState | CanvasEraserLineState | CanvasEraserLineWithPressureState | CanvasRectState | CanvasImageState { switch (obj.type) { case 'brush_line': return { @@ -354,4 +337,4 @@ export function convertTransformedToOriginal( case 'image': return obj; } -} +} \ No newline at end of file diff --git a/invokeai/frontend/web/src/features/controlLayers/util/maskObjectTransform.ts b/invokeai/frontend/web/src/features/controlLayers/util/maskObjectTransform.ts index d221e541c4b..bc8ab7b038b 100644 --- a/invokeai/frontend/web/src/features/controlLayers/util/maskObjectTransform.ts +++ b/invokeai/frontend/web/src/features/controlLayers/util/maskObjectTransform.ts @@ -3,20 +3,19 @@ import type { CanvasBrushLineWithPressureState, CanvasEraserLineState, CanvasEraserLineWithPressureState, - CanvasImageState, CanvasRectState, + CanvasImageState, Coordinate, Rect, } from 'features/controlLayers/store/types'; - -import { maskObjectsToBitmap } from './bitmapToMaskObjects'; import { - calculateMaskObjectBounds, + transformMaskObject, clipMaskObjectToContainer, + calculateMaskObjectBounds, convertTransformedToOriginal, type TransformedMaskObject, - transformMaskObject, } from './coordinateTransform'; +import { maskObjectsToBitmap } from './bitmapToMaskObjects'; /** * Transforms mask objects relative to a bounding box container. @@ -58,7 +57,10 @@ export function transformMaskObjectsRelativeToBbox( * @param container The container rectangle to clip to * @returns Array of clipped mask objects (null values are filtered out) */ -export function clipMaskObjectsToContainer(objects: TransformedMaskObject[], container: Rect): TransformedMaskObject[] { +export function clipMaskObjectsToContainer( + objects: TransformedMaskObject[], + container: Rect +): TransformedMaskObject[] { return objects .map((obj) => clipMaskObjectToContainer(obj, container)) .filter((obj): obj is TransformedMaskObject => obj !== null); @@ -120,7 +122,7 @@ export function calculateMaskBoundsFromBitmap( // Convert transformed objects back to original types for compatibility const originalObjects = objects.map(convertTransformedToOriginal); - + // Render the consolidated mask to a bitmap const bitmap = maskObjectsToBitmap(originalObjects, canvasWidth, canvasHeight); const { width, height, data } = bitmap; @@ -165,7 +167,10 @@ export function calculateMaskBoundsFromBitmap( * @param container The container rectangle to invert within * @returns Array of mask objects representing the inverted mask */ -export function invertMask(objects: TransformedMaskObject[], container: Rect): TransformedMaskObject[] { +export function invertMask( + objects: TransformedMaskObject[], + container: Rect +): TransformedMaskObject[] { // Create a rectangle that covers the entire container const fullCoverageRect: TransformedMaskObject = { id: 'inverted_mask_rect', @@ -181,7 +186,7 @@ export function invertMask(objects: TransformedMaskObject[], container: Rect): T // For each original mask object, create an eraser line that removes it const eraserObjects: TransformedMaskObject[] = []; - + for (const obj of objects) { if (obj.type === 'rect') { // For rectangles, create an eraser rectangle @@ -189,16 +194,11 @@ export function invertMask(objects: TransformedMaskObject[], container: Rect): T id: `eraser_${obj.id}`, type: 'eraser_line', points: [ - obj.rect.x, - obj.rect.y, - obj.rect.x + obj.rect.width, - obj.rect.y, - obj.rect.x + obj.rect.width, - obj.rect.y + obj.rect.height, - obj.rect.x, - obj.rect.y + obj.rect.height, - obj.rect.x, - obj.rect.y, // Close the rectangle + obj.rect.x, obj.rect.y, + obj.rect.x + obj.rect.width, obj.rect.y, + obj.rect.x + obj.rect.width, obj.rect.y + obj.rect.height, + obj.rect.x, obj.rect.y + obj.rect.height, + obj.rect.x, obj.rect.y, // Close the rectangle ], strokeWidth: 1, clip: container, @@ -233,6 +233,9 @@ export function invertMask(objects: TransformedMaskObject[], container: Rect): T * @param bboxRect The bounding box to clip to * @returns Array of clipped mask objects */ -export function ensureMaskObjectsWithinBbox(objects: TransformedMaskObject[], bboxRect: Rect): TransformedMaskObject[] { +export function ensureMaskObjectsWithinBbox( + objects: TransformedMaskObject[], + bboxRect: Rect +): TransformedMaskObject[] { return clipMaskObjectsToContainer(objects, bboxRect); -} +} \ No newline at end of file From 3619d371a844835b3562f6410e9a0aa0f01802c9 Mon Sep 17 00:00:00 2001 From: Kent Keirsey <31807370+hipsterusername@users.noreply.github.com> Date: Tue, 1 Jul 2025 13:02:06 -0400 Subject: [PATCH 22/26] Revert "Fix bugs. Extract into transform utilities" This reverts commit f9a49e21c9a8ee81009c3328c9af96cdb5fc39ee. --- .../InpaintMask/InpaintMaskBboxAdjuster.tsx | 110 +++--- .../components/Toolbar/CanvasToolbar.tsx | 4 +- .../hooks/useCanvasAdjustBboxHotkey.ts | 100 +++--- .../controlLayers/store/canvasSlice.ts | 25 +- .../controlLayers/util/coordinateTransform.ts | 340 ------------------ .../controlLayers/util/maskObjectTransform.ts | 241 ------------- 6 files changed, 133 insertions(+), 687 deletions(-) delete mode 100644 invokeai/frontend/web/src/features/controlLayers/util/coordinateTransform.ts delete mode 100644 invokeai/frontend/web/src/features/controlLayers/util/maskObjectTransform.ts diff --git a/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskBboxAdjuster.tsx b/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskBboxAdjuster.tsx index 74390df5145..f5c39088bd7 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskBboxAdjuster.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskBboxAdjuster.tsx @@ -3,17 +3,7 @@ import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { bboxChangedFromCanvas } from 'features/controlLayers/store/canvasSlice'; import { selectMaskBlur } from 'features/controlLayers/store/paramsSlice'; import { selectCanvasSlice } from 'features/controlLayers/store/selectors'; -import type { - Rect, - CanvasBrushLineState, - CanvasBrushLineWithPressureState, - CanvasEraserLineState, - CanvasEraserLineWithPressureState, - CanvasRectState, - CanvasImageState, -} from 'features/controlLayers/store/types'; -import { transformMaskObjectsRelativeToBbox, calculateMaskBoundsFromBitmap } from 'features/controlLayers/util/maskObjectTransform'; -import { convertTransformedToOriginal } from 'features/controlLayers/util/coordinateTransform'; +import type { Rect } from 'features/controlLayers/store/types'; import { memo, useCallback, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { PiCropBold } from 'react-icons/pi'; @@ -24,9 +14,8 @@ export const InpaintMaskBboxAdjuster = memo(() => { const canvasSlice = useAppSelector(selectCanvasSlice); const maskBlur = useAppSelector(selectMaskBlur); - // Get all inpaint mask entities and bbox + // Get all inpaint mask entities const inpaintMasks = canvasSlice.inpaintMasks.entities; - const bboxRect = canvasSlice.bbox.rect; // Calculate the bounding box that contains all inpaint masks const calculateMaskBbox = useCallback((): Rect | null => { @@ -34,47 +23,84 @@ export const InpaintMaskBboxAdjuster = memo(() => { return null; } - // Collect all mask objects from enabled masks - const allObjects: ( - | CanvasBrushLineState - | CanvasBrushLineWithPressureState - | CanvasEraserLineState - | CanvasEraserLineWithPressureState - | CanvasRectState - | CanvasImageState - )[] = []; - + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + + // Iterate through all inpaint masks to find the overall bounds for (const mask of inpaintMasks) { - if (!mask.isEnabled || !mask.objects || mask.objects.length === 0) { + if (!mask.isEnabled || mask.objects.length === 0) { continue; } - // Transform objects to be relative to the bbox - const transformedObjects = transformMaskObjectsRelativeToBbox(mask.objects, bboxRect); - // Convert back to original types for compatibility - const originalObjects = transformedObjects.map(convertTransformedToOriginal); - allObjects.push(...originalObjects); - } + // Calculate bounds for this mask's objects + for (const obj of mask.objects) { + let objMinX = 0; + let objMinY = 0; + let objMaxX = 0; + let objMaxY = 0; - if (allObjects.length === 0) { - return null; + if (obj.type === 'rect') { + objMinX = mask.position.x + obj.rect.x; + objMinY = mask.position.y + obj.rect.y; + objMaxX = objMinX + obj.rect.width; + objMaxY = objMinY + obj.rect.height; + } else if ( + obj.type === 'brush_line' || + obj.type === 'brush_line_with_pressure' || + obj.type === 'eraser_line' || + obj.type === 'eraser_line_with_pressure' + ) { + // For lines, find the min/max points + for (let i = 0; i < obj.points.length; i += 2) { + const x = mask.position.x + (obj.points[i] ?? 0); + const y = mask.position.y + (obj.points[i + 1] ?? 0); + + if (i === 0) { + objMinX = objMaxX = x; + objMinY = objMaxY = y; + } else { + objMinX = Math.min(objMinX, x); + objMinY = Math.min(objMinY, y); + objMaxX = Math.max(objMaxX, x); + objMaxY = Math.max(objMaxY, y); + } + } + // Add stroke width to account for line thickness + const strokeRadius = (obj.strokeWidth ?? 50) / 2; + objMinX -= strokeRadius; + objMinY -= strokeRadius; + objMaxX += strokeRadius; + objMaxY += strokeRadius; + } else if (obj.type === 'image') { + // Image objects are positioned at the entity's position + objMinX = mask.position.x; + objMinY = mask.position.y; + objMaxX = objMinX + obj.image.width; + objMaxY = objMinY + obj.image.height; + } + + // Update overall bounds + minX = Math.min(minX, objMinX); + minY = Math.min(minY, objMinY); + maxX = Math.max(maxX, objMaxX); + maxY = Math.max(maxY, objMaxY); + } } - // Calculate bounds from the rendered bitmap for accurate results - const maskBounds = calculateMaskBoundsFromBitmap(allObjects, bboxRect.width, bboxRect.height); - - if (!maskBounds) { + // If no valid bounds found, return null + if (minX === Infinity || minY === Infinity || maxX === -Infinity || maxY === -Infinity) { return null; } - // Convert back to world coordinates relative to the bbox return { - x: bboxRect.x + maskBounds.x, - y: bboxRect.y + maskBounds.y, - width: maskBounds.width, - height: maskBounds.height, + x: minX, + y: minY, + width: maxX - minX, + height: maxY - minY, }; - }, [inpaintMasks, bboxRect]); + }, [inpaintMasks]); const maskBbox = useMemo(() => calculateMaskBbox(), [calculateMaskBbox]); diff --git a/invokeai/frontend/web/src/features/controlLayers/components/Toolbar/CanvasToolbar.tsx b/invokeai/frontend/web/src/features/controlLayers/components/Toolbar/CanvasToolbar.tsx index 062428343b4..86e57977e99 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/Toolbar/CanvasToolbar.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/Toolbar/CanvasToolbar.tsx @@ -9,15 +9,15 @@ import { CanvasToolbarResetViewButton } from 'features/controlLayers/components/ import { CanvasToolbarSaveToGalleryButton } from 'features/controlLayers/components/Toolbar/CanvasToolbarSaveToGalleryButton'; import { CanvasToolbarScale } from 'features/controlLayers/components/Toolbar/CanvasToolbarScale'; import { CanvasToolbarUndoButton } from 'features/controlLayers/components/Toolbar/CanvasToolbarUndoButton'; -import { useCanvasAdjustBboxHotkey } from 'features/controlLayers/hooks/useCanvasAdjustBboxHotkey'; import { useCanvasDeleteLayerHotkey } from 'features/controlLayers/hooks/useCanvasDeleteLayerHotkey'; import { useCanvasEntityQuickSwitchHotkey } from 'features/controlLayers/hooks/useCanvasEntityQuickSwitchHotkey'; import { useCanvasFilterHotkey } from 'features/controlLayers/hooks/useCanvasFilterHotkey'; -import { useCanvasInvertMaskHotkey } from 'features/controlLayers/hooks/useCanvasInvertMaskHotkey'; import { useCanvasResetLayerHotkey } from 'features/controlLayers/hooks/useCanvasResetLayerHotkey'; import { useCanvasToggleNonRasterLayersHotkey } from 'features/controlLayers/hooks/useCanvasToggleNonRasterLayersHotkey'; import { useCanvasTransformHotkey } from 'features/controlLayers/hooks/useCanvasTransformHotkey'; import { useCanvasUndoRedoHotkeys } from 'features/controlLayers/hooks/useCanvasUndoRedoHotkeys'; +import { useCanvasInvertMaskHotkey } from 'features/controlLayers/hooks/useCanvasInvertMaskHotkey'; +import { useCanvasAdjustBboxHotkey } from 'features/controlLayers/hooks/useCanvasAdjustBboxHotkey'; import { useNextPrevEntityHotkeys } from 'features/controlLayers/hooks/useNextPrevEntity'; import { memo } from 'react'; diff --git a/invokeai/frontend/web/src/features/controlLayers/hooks/useCanvasAdjustBboxHotkey.ts b/invokeai/frontend/web/src/features/controlLayers/hooks/useCanvasAdjustBboxHotkey.ts index 9e07d1f639e..d3a87b11319 100644 --- a/invokeai/frontend/web/src/features/controlLayers/hooks/useCanvasAdjustBboxHotkey.ts +++ b/invokeai/frontend/web/src/features/controlLayers/hooks/useCanvasAdjustBboxHotkey.ts @@ -4,18 +4,8 @@ import { useCanvasIsBusy } from 'features/controlLayers/hooks/useCanvasIsBusy'; import { bboxChangedFromCanvas } from 'features/controlLayers/store/canvasSlice'; import { selectMaskBlur } from 'features/controlLayers/store/paramsSlice'; import { selectCanvasSlice } from 'features/controlLayers/store/selectors'; -import type { - Rect, - CanvasBrushLineState, - CanvasBrushLineWithPressureState, - CanvasEraserLineState, - CanvasEraserLineWithPressureState, - CanvasRectState, - CanvasImageState, -} from 'features/controlLayers/store/types'; -import { transformMaskObjectsRelativeToBbox, calculateMaskBoundsFromBitmap } from 'features/controlLayers/util/maskObjectTransform'; -import { convertTransformedToOriginal } from 'features/controlLayers/util/coordinateTransform'; import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData'; +import type { Rect } from 'features/controlLayers/store/types'; import { useCallback, useMemo } from 'react'; export const useCanvasAdjustBboxHotkey = () => { @@ -25,55 +15,71 @@ export const useCanvasAdjustBboxHotkey = () => { const maskBlur = useAppSelector(selectMaskBlur); const isBusy = useCanvasIsBusy(); const inpaintMasks = canvasSlice.inpaintMasks.entities; - const bboxRect = canvasSlice.bbox.rect; // Calculate the bounding box that contains all inpaint masks const calculateMaskBbox = useCallback((): Rect | null => { if (inpaintMasks.length === 0) { return null; } - - // Collect all mask objects from enabled masks - const allObjects: ( - | CanvasBrushLineState - | CanvasBrushLineWithPressureState - | CanvasEraserLineState - | CanvasEraserLineWithPressureState - | CanvasRectState - | CanvasImageState - )[] = []; - + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; for (const mask of inpaintMasks) { if (!mask.isEnabled || !mask.objects || mask.objects.length === 0) { continue; } - - // Transform objects to be relative to the bbox - const transformedObjects = transformMaskObjectsRelativeToBbox(mask.objects, bboxRect); - // Convert back to original types for compatibility - const originalObjects = transformedObjects.map(convertTransformedToOriginal); - allObjects.push(...originalObjects); - } - - if (allObjects.length === 0) { - return null; + for (const obj of mask.objects) { + let objMinX = 0; + let objMinY = 0; + let objMaxX = 0; + let objMaxY = 0; + if (obj.type === 'rect') { + objMinX = mask.position.x + obj.rect.x; + objMinY = mask.position.y + obj.rect.y; + objMaxX = objMinX + obj.rect.width; + objMaxY = objMinY + obj.rect.height; + } else if ( + obj.type === 'brush_line' || + obj.type === 'brush_line_with_pressure' || + obj.type === 'eraser_line' || + obj.type === 'eraser_line_with_pressure' + ) { + for (let i = 0; i < obj.points.length; i += 2) { + const x = mask.position.x + (obj.points[i] ?? 0); + const y = mask.position.y + (obj.points[i + 1] ?? 0); + if (i === 0) { + objMinX = objMaxX = x; + objMinY = objMaxY = y; + } else { + objMinX = Math.min(objMinX, x); + objMinY = Math.min(objMinY, y); + objMaxX = Math.max(objMaxX, x); + objMaxY = Math.max(objMaxY, y); + } + } + const strokeRadius = (obj.strokeWidth ?? 50) / 2; + objMinX -= strokeRadius; + objMinY -= strokeRadius; + objMaxX += strokeRadius; + objMaxY += strokeRadius; + } else if (obj.type === 'image') { + objMinX = mask.position.x; + objMinY = mask.position.y; + objMaxX = objMinX + obj.image.width; + objMaxY = objMinY + obj.image.height; + } + minX = Math.min(minX, objMinX); + minY = Math.min(minY, objMinY); + maxX = Math.max(maxX, objMaxX); + maxY = Math.max(maxY, objMaxY); + } } - - // Calculate bounds from the rendered bitmap for accurate results - const maskBounds = calculateMaskBoundsFromBitmap(allObjects, bboxRect.width, bboxRect.height); - - if (!maskBounds) { + if (minX === Infinity || minY === Infinity || maxX === -Infinity || maxY === -Infinity) { return null; } - - // Convert back to world coordinates relative to the bbox - return { - x: bboxRect.x + maskBounds.x, - y: bboxRect.y + maskBounds.y, - width: maskBounds.width, - height: maskBounds.height, - }; - }, [inpaintMasks, bboxRect]); + return { x: minX, y: minY, width: maxX - minX, height: maxY - minY }; + }, [inpaintMasks]); const handleAdjustBbox = useCallback(() => { const maskBbox = calculateMaskBbox(); diff --git a/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts b/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts index 341f2f0e428..869cfd0473f 100644 --- a/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts +++ b/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts @@ -1010,7 +1010,10 @@ export const canvasSlice = createSlice({ return; } - // Get the current bbox dimensions for the mask + // For now, we'll use a simple approach: create a full rectangle and add eraser lines + // This is a temporary solution until we can properly handle the bitmap conversion + + // Get the bbox dimensions for the mask const bboxRect = state.bbox.rect; // Create a full rectangle covering the bbox @@ -1035,23 +1038,15 @@ export const canvasSlice = createSlice({ | CanvasBrushLineWithPressureState )[] = [fillRect]; - // Create a clip region that constrains all objects to the bbox - const bboxClip = { - x: bboxRect.x - entity.position.x, - y: bboxRect.y - entity.position.y, - width: bboxRect.width, - height: bboxRect.height, - }; - for (const obj of entity.objects) { if (obj.type === 'brush_line') { - // Convert brush line to eraser line, ensuring it's clipped to the bbox + // Convert brush line to eraser line const eraserLine: CanvasEraserLineState = { id: getPrefixedId('eraser_line'), type: 'eraser_line', strokeWidth: obj.strokeWidth, points: obj.points, - clip: bboxClip, // Always clip to the current bbox + clip: obj.clip, }; invertedObjects.push(eraserLine); } else if (obj.type === 'brush_line_with_pressure') { @@ -1061,7 +1056,7 @@ export const canvasSlice = createSlice({ type: 'eraser_line_with_pressure', strokeWidth: obj.strokeWidth, points: obj.points, - clip: bboxClip, // Always clip to the current bbox + clip: obj.clip, }; invertedObjects.push(eraserLine); } else if (obj.type === 'rect') { @@ -1085,7 +1080,7 @@ export const canvasSlice = createSlice({ type: 'eraser_line', points, strokeWidth: Math.max(width, height) / 2, // Use a stroke width that covers the rectangle - clip: bboxClip, // Always clip to the current bbox + clip: null, }; invertedObjects.push(eraserLine); } else if (obj.type === 'eraser_line') { @@ -1095,7 +1090,7 @@ export const canvasSlice = createSlice({ type: 'brush_line', strokeWidth: obj.strokeWidth, points: obj.points, - clip: bboxClip, // Always clip to the current bbox + clip: obj.clip, color: { r: 255, g: 255, b: 255, a: 1 }, }; invertedObjects.push(brushLine); @@ -1106,7 +1101,7 @@ export const canvasSlice = createSlice({ type: 'brush_line_with_pressure', strokeWidth: obj.strokeWidth, points: obj.points, - clip: bboxClip, // Always clip to the current bbox + clip: obj.clip, color: { r: 255, g: 255, b: 255, a: 1 }, }; invertedObjects.push(brushLine); diff --git a/invokeai/frontend/web/src/features/controlLayers/util/coordinateTransform.ts b/invokeai/frontend/web/src/features/controlLayers/util/coordinateTransform.ts deleted file mode 100644 index db7f9c1adab..00000000000 --- a/invokeai/frontend/web/src/features/controlLayers/util/coordinateTransform.ts +++ /dev/null @@ -1,340 +0,0 @@ -import type { - CanvasBrushLineState, - CanvasBrushLineWithPressureState, - CanvasEraserLineState, - CanvasEraserLineWithPressureState, - CanvasRectState, - CanvasImageState, - Coordinate, - Rect, -} from 'features/controlLayers/store/types'; - -/** - * Type for mask objects with transformed coordinates. - * This preserves the discriminated union structure while allowing coordinate transformations. - */ -export type TransformedMaskObject = - | TransformedBrushLine - | TransformedBrushLineWithPressure - | TransformedEraserLine - | TransformedEraserLineWithPressure - | TransformedRect - | TransformedImage; - -export interface TransformedBrushLine { - id: string; - type: 'brush_line'; - points: number[]; - strokeWidth: number; - color: { r: number; g: number; b: number; a: number }; - clip?: Rect | null; -} - -export interface TransformedBrushLineWithPressure { - id: string; - type: 'brush_line_with_pressure'; - points: number[]; - strokeWidth: number; - color: { r: number; g: number; b: number; a: number }; - clip?: Rect | null; -} - -export interface TransformedEraserLine { - id: string; - type: 'eraser_line'; - points: number[]; - strokeWidth: number; - clip?: Rect | null; -} - -export interface TransformedEraserLineWithPressure { - id: string; - type: 'eraser_line_with_pressure'; - points: number[]; - strokeWidth: number; - clip?: Rect | null; -} - -export interface TransformedRect { - id: string; - type: 'rect'; - rect: Rect; - color: { r: number; g: number; b: number; a: number }; -} - -export interface TransformedImage { - id: string; - type: 'image'; - image: { width: number; height: number; dataURL: string } | { width: number; height: number; image_name: string }; -} - -/** - * Transforms a mask object by applying a coordinate offset. - * @param obj The mask object to transform - * @param offset The offset to apply to coordinates - * @returns A new mask object with transformed coordinates - */ -export function transformMaskObject( - obj: CanvasBrushLineState | CanvasBrushLineWithPressureState | CanvasEraserLineState | CanvasEraserLineWithPressureState | CanvasRectState | CanvasImageState, - offset: Coordinate -): TransformedMaskObject { - switch (obj.type) { - case 'brush_line': - return { - ...obj, - points: transformPoints(obj.points, offset), - clip: obj.clip ? transformRect(obj.clip, offset) : null, - }; - case 'brush_line_with_pressure': - return { - ...obj, - points: transformPoints(obj.points, offset), - clip: obj.clip ? transformRect(obj.clip, offset) : null, - }; - case 'eraser_line': - return { - ...obj, - points: transformPoints(obj.points, offset), - clip: obj.clip ? transformRect(obj.clip, offset) : null, - }; - case 'eraser_line_with_pressure': - return { - ...obj, - points: transformPoints(obj.points, offset), - clip: obj.clip ? transformRect(obj.clip, offset) : null, - }; - case 'rect': - return { - ...obj, - rect: transformRect(obj.rect, offset), - }; - case 'image': - return { - ...obj, - }; - } -} - -/** - * Transforms an array of points by applying a coordinate offset. - * @param points Array of numbers representing [x1, y1, x2, y2, ...] - * @param offset The offset to apply - * @returns New array with transformed coordinates - */ -export function transformPoints(points: number[], offset: Coordinate): number[] { - const transformed: number[] = []; - for (let i = 0; i < points.length; i += 2) { - transformed.push((points[i] ?? 0) + offset.x); - transformed.push((points[i + 1] ?? 0) + offset.y); - } - return transformed; -} - -/** - * Transforms a rectangle by applying a coordinate offset. - * @param rect The rectangle to transform - * @param offset The offset to apply - * @returns New rectangle with transformed coordinates - */ -export function transformRect(rect: Rect, offset: Coordinate): Rect { - return { - x: rect.x + offset.x, - y: rect.y + offset.y, - width: rect.width, - height: rect.height, - }; -} - -/** - * Clips a mask object to the boundaries of a container rectangle. - * @param obj The mask object to clip - * @param container The container rectangle to clip to - * @returns A new mask object clipped to the container boundaries, or null if completely outside - */ -export function clipMaskObjectToContainer( - obj: TransformedMaskObject, - container: Rect -): TransformedMaskObject | null { - switch (obj.type) { - case 'brush_line': - case 'brush_line_with_pressure': - case 'eraser_line': - case 'eraser_line_with_pressure': - return clipLineToContainer(obj, container); - case 'rect': - return clipRectToContainer(obj, container); - case 'image': - return clipImageToContainer(obj, container); - } -} - -/** - * Clips a line object to container boundaries. - */ -function clipLineToContainer( - obj: TransformedBrushLine | TransformedBrushLineWithPressure | TransformedEraserLine | TransformedEraserLineWithPressure, - container: Rect -): typeof obj | null { - // For lines, we clip the points to the container boundaries - const clippedPoints: number[] = []; - - for (let i = 0; i < obj.points.length; i += 2) { - const x = obj.points[i] ?? 0; - const y = obj.points[i + 1] ?? 0; - - // Clip coordinates to container boundaries - const clippedX = Math.max(container.x, Math.min(container.x + container.width, x)); - const clippedY = Math.max(container.y, Math.min(container.y + container.height, y)); - - clippedPoints.push(clippedX, clippedY); - } - - // If no points remain, return null - if (clippedPoints.length === 0) { - return null; - } - - return { - ...obj, - points: clippedPoints, - clip: container, - }; -} - -/** - * Clips a rectangle object to container boundaries. - */ -function clipRectToContainer(obj: TransformedRect, container: Rect): TransformedRect | null { - const rect = obj.rect; - - // Calculate intersection - const left = Math.max(rect.x, container.x); - const top = Math.max(rect.y, container.y); - const right = Math.min(rect.x + rect.width, container.x + container.width); - const bottom = Math.min(rect.y + rect.height, container.y + container.height); - - // If no intersection, return null - if (left >= right || top >= bottom) { - return null; - } - - return { - ...obj, - rect: { - x: left, - y: top, - width: right - left, - height: bottom - top, - }, - }; -} - -/** - * Clips an image object to container boundaries. - */ -function clipImageToContainer(obj: TransformedImage, container: Rect): TransformedImage | null { - // For images, we don't clip them - they remain as-is - return obj; -} - -/** - * Calculates the effective bounds of a mask object. - * @param obj The mask object to calculate bounds for - * @returns The bounding rectangle, or null if the object has no effective bounds - */ -export function calculateMaskObjectBounds(obj: TransformedMaskObject): Rect | null { - switch (obj.type) { - case 'brush_line': - case 'brush_line_with_pressure': - case 'eraser_line': - case 'eraser_line_with_pressure': - return calculateLineBounds(obj); - case 'rect': - return obj.rect; - case 'image': - return calculateImageBounds(obj); - } -} - -/** - * Calculates bounds for a line object. - */ -function calculateLineBounds( - obj: TransformedBrushLine | TransformedBrushLineWithPressure | TransformedEraserLine | TransformedEraserLineWithPressure -): Rect | null { - if (obj.points.length < 2) { - return null; - } - - let minX = obj.points[0] ?? 0; - let minY = obj.points[1] ?? 0; - let maxX = minX; - let maxY = minY; - - for (let i = 2; i < obj.points.length; i += 2) { - const x = obj.points[i] ?? 0; - const y = obj.points[i + 1] ?? 0; - minX = Math.min(minX, x); - minY = Math.min(minY, y); - maxX = Math.max(maxX, x); - maxY = Math.max(maxY, y); - } - - // Add stroke width to bounds - const strokeRadius = obj.strokeWidth / 2; - return { - x: minX - strokeRadius, - y: minY - strokeRadius, - width: maxX - minX + obj.strokeWidth, - height: maxY - minY + obj.strokeWidth, - }; -} - -/** - * Calculates bounds for an image object. - */ -function calculateImageBounds(obj: TransformedImage): Rect | null { - return { - x: 0, - y: 0, - width: obj.image.width, - height: obj.image.height, - }; -} - -/** - * Converts a TransformedMaskObject back to its original mask object type. - * This is needed for compatibility with functions that expect the original types. - * @param obj The transformed mask object to convert back - * @returns The original mask object type - */ -export function convertTransformedToOriginal( - obj: TransformedMaskObject -): CanvasBrushLineState | CanvasBrushLineWithPressureState | CanvasEraserLineState | CanvasEraserLineWithPressureState | CanvasRectState | CanvasImageState { - switch (obj.type) { - case 'brush_line': - return { - ...obj, - clip: obj.clip ?? null, - }; - case 'brush_line_with_pressure': - return { - ...obj, - clip: obj.clip ?? null, - }; - case 'eraser_line': - return { - ...obj, - clip: obj.clip ?? null, - }; - case 'eraser_line_with_pressure': - return { - ...obj, - clip: obj.clip ?? null, - }; - case 'rect': - return obj; - case 'image': - return obj; - } -} \ No newline at end of file diff --git a/invokeai/frontend/web/src/features/controlLayers/util/maskObjectTransform.ts b/invokeai/frontend/web/src/features/controlLayers/util/maskObjectTransform.ts deleted file mode 100644 index bc8ab7b038b..00000000000 --- a/invokeai/frontend/web/src/features/controlLayers/util/maskObjectTransform.ts +++ /dev/null @@ -1,241 +0,0 @@ -import type { - CanvasBrushLineState, - CanvasBrushLineWithPressureState, - CanvasEraserLineState, - CanvasEraserLineWithPressureState, - CanvasRectState, - CanvasImageState, - Coordinate, - Rect, -} from 'features/controlLayers/store/types'; -import { - transformMaskObject, - clipMaskObjectToContainer, - calculateMaskObjectBounds, - convertTransformedToOriginal, - type TransformedMaskObject, -} from './coordinateTransform'; -import { maskObjectsToBitmap } from './bitmapToMaskObjects'; - -/** - * Transforms mask objects relative to a bounding box container. - * This adjusts all object coordinates to be relative to the bbox origin. - * @param objects Array of mask objects to transform - * @param bboxRect The bounding box to use as the container reference - * @returns Array of transformed mask objects - */ -export function transformMaskObjectsRelativeToBbox( - objects: ( - | CanvasBrushLineState - | CanvasBrushLineWithPressureState - | CanvasEraserLineState - | CanvasEraserLineWithPressureState - | CanvasRectState - | CanvasImageState - )[], - bboxRect: Rect -): TransformedMaskObject[] { - const transformedObjects: TransformedMaskObject[] = []; - - for (const obj of objects) { - // Calculate the offset to make coordinates relative to the bbox - const offset: Coordinate = { - x: -bboxRect.x, - y: -bboxRect.y, - }; - - const transformed = transformMaskObject(obj, offset); - transformedObjects.push(transformed); - } - - return transformedObjects; -} - -/** - * Clips all mask objects to the boundaries of a container rectangle. - * @param objects Array of mask objects to clip - * @param container The container rectangle to clip to - * @returns Array of clipped mask objects (null values are filtered out) - */ -export function clipMaskObjectsToContainer( - objects: TransformedMaskObject[], - container: Rect -): TransformedMaskObject[] { - return objects - .map((obj) => clipMaskObjectToContainer(obj, container)) - .filter((obj): obj is TransformedMaskObject => obj !== null); -} - -/** - * Calculates the effective bounds of all mask objects. - * @param objects Array of mask objects to calculate bounds for - * @returns The bounding rectangle containing all objects, or null if no objects - */ -export function calculateMaskObjectsBounds(objects: TransformedMaskObject[]): Rect | null { - if (objects.length === 0) { - return null; - } - - let minX = Infinity; - let minY = Infinity; - let maxX = -Infinity; - let maxY = -Infinity; - - for (const obj of objects) { - const bounds = calculateMaskObjectBounds(obj); - if (bounds) { - minX = Math.min(minX, bounds.x); - minY = Math.min(minY, bounds.y); - maxX = Math.max(maxX, bounds.x + bounds.width); - maxY = Math.max(maxY, bounds.y + bounds.height); - } - } - - if (minX === Infinity || minY === Infinity || maxX === -Infinity || maxY === -Infinity) { - return null; - } - - return { - x: minX, - y: minY, - width: maxX - minX, - height: maxY - minY, - }; -} - -/** - * Calculates the bounding box of a consolidated mask by rendering it to a bitmap. - * This provides the most accurate bounds by considering the actual rendered mask pixels. - * @param objects Array of mask objects to calculate bounds for - * @param canvasWidth Width of the canvas to render to - * @param canvasHeight Height of the canvas to render to - * @returns The bounding rectangle of the rendered mask, or null if no mask pixels - */ -export function calculateMaskBoundsFromBitmap( - objects: TransformedMaskObject[], - canvasWidth: number, - canvasHeight: number -): Rect | null { - if (objects.length === 0) { - return null; - } - - // Convert transformed objects back to original types for compatibility - const originalObjects = objects.map(convertTransformedToOriginal); - - // Render the consolidated mask to a bitmap - const bitmap = maskObjectsToBitmap(originalObjects, canvasWidth, canvasHeight); - const { width, height, data } = bitmap; - - // Find the actual bounds of the rendered mask - let maskMinX = width; - let maskMinY = height; - let maskMaxX = 0; - let maskMaxY = 0; - - for (let y = 0; y < height; y++) { - for (let x = 0; x < width; x++) { - const pixelIndex = (y * width + x) * 4; - const alpha = data[pixelIndex + 3] ?? 0; - - // If this pixel has any opacity, it's part of the mask - if (alpha > 0) { - maskMinX = Math.min(maskMinX, x); - maskMinY = Math.min(maskMinY, y); - maskMaxX = Math.max(maskMaxX, x); - maskMaxY = Math.max(maskMaxY, y); - } - } - } - - // If no mask pixels found, return null - if (maskMinX >= maskMaxX || maskMinY >= maskMaxY) { - return null; - } - - return { - x: maskMinX, - y: maskMinY, - width: maskMaxX - maskMinX + 1, - height: maskMaxY - maskMinY + 1, - }; -} - -/** - * Inverts a mask by creating a new mask that covers the entire container except for the original mask areas. - * @param objects Array of mask objects representing the original mask - * @param container The container rectangle to invert within - * @returns Array of mask objects representing the inverted mask - */ -export function invertMask( - objects: TransformedMaskObject[], - container: Rect -): TransformedMaskObject[] { - // Create a rectangle that covers the entire container - const fullCoverageRect: TransformedMaskObject = { - id: 'inverted_mask_rect', - type: 'rect', - rect: { - x: container.x, - y: container.y, - width: container.width, - height: container.height, - }, - color: { r: 255, g: 255, b: 255, a: 1 }, - }; - - // For each original mask object, create an eraser line that removes it - const eraserObjects: TransformedMaskObject[] = []; - - for (const obj of objects) { - if (obj.type === 'rect') { - // For rectangles, create an eraser rectangle - const eraserRect: TransformedMaskObject = { - id: `eraser_${obj.id}`, - type: 'eraser_line', - points: [ - obj.rect.x, obj.rect.y, - obj.rect.x + obj.rect.width, obj.rect.y, - obj.rect.x + obj.rect.width, obj.rect.y + obj.rect.height, - obj.rect.x, obj.rect.y + obj.rect.height, - obj.rect.x, obj.rect.y, // Close the rectangle - ], - strokeWidth: 1, - clip: container, - }; - eraserObjects.push(eraserRect); - } else if ( - obj.type === 'brush_line' || - obj.type === 'brush_line_with_pressure' || - obj.type === 'eraser_line' || - obj.type === 'eraser_line_with_pressure' - ) { - // For lines, create an eraser line with the same points - const eraserLine: TransformedMaskObject = { - id: `eraser_${obj.id}`, - type: 'eraser_line', - points: [...obj.points], - strokeWidth: obj.strokeWidth, - clip: container, - }; - eraserObjects.push(eraserLine); - } - // Note: Image objects are not handled in inversion as they're not commonly used in masks - } - - return [fullCoverageRect, ...eraserObjects]; -} - -/** - * Ensures all mask objects are clipped to the current bounding box boundaries. - * This prevents masks from extending outside the bounding box after multiple inversions. - * @param objects Array of mask objects to clip - * @param bboxRect The bounding box to clip to - * @returns Array of clipped mask objects - */ -export function ensureMaskObjectsWithinBbox( - objects: TransformedMaskObject[], - bboxRect: Rect -): TransformedMaskObject[] { - return clipMaskObjectsToContainer(objects, bboxRect); -} \ No newline at end of file From 953b9c7b2fb525431930658d63f1445fd61b6564 Mon Sep 17 00:00:00 2001 From: Kent Keirsey <31807370+hipsterusername@users.noreply.github.com> Date: Tue, 1 Jul 2025 13:54:21 -0400 Subject: [PATCH 23/26] Actual fixes. --- .../InpaintMask/InpaintMaskBboxAdjuster.tsx | 148 +++++++++++------- .../components/Toolbar/CanvasToolbar.tsx | 4 +- .../hooks/useCanvasAdjustBboxHotkey.ts | 136 +++++++++++----- .../hooks/useCanvasInvertMaskHotkey.ts | 4 +- .../controlLayers/store/canvasSlice.ts | 123 +++++++++------ 5 files changed, 268 insertions(+), 147 deletions(-) diff --git a/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskBboxAdjuster.tsx b/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskBboxAdjuster.tsx index f5c39088bd7..e9d657d860f 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskBboxAdjuster.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskBboxAdjuster.tsx @@ -3,7 +3,16 @@ import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { bboxChangedFromCanvas } from 'features/controlLayers/store/canvasSlice'; import { selectMaskBlur } from 'features/controlLayers/store/paramsSlice'; import { selectCanvasSlice } from 'features/controlLayers/store/selectors'; -import type { Rect } from 'features/controlLayers/store/types'; +import type { + CanvasBrushLineState, + CanvasBrushLineWithPressureState, + CanvasEraserLineState, + CanvasEraserLineWithPressureState, + CanvasImageState, + CanvasRectState, + Rect, +} from 'features/controlLayers/store/types'; +import { maskObjectsToBitmap } from 'features/controlLayers/util/bitmapToMaskObjects'; import { memo, useCallback, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { PiCropBold } from 'react-icons/pi'; @@ -14,8 +23,9 @@ export const InpaintMaskBboxAdjuster = memo(() => { const canvasSlice = useAppSelector(selectCanvasSlice); const maskBlur = useAppSelector(selectMaskBlur); - // Get all inpaint mask entities + // Get all inpaint mask entities and bbox const inpaintMasks = canvasSlice.inpaintMasks.entities; + const bboxRect = canvasSlice.bbox.rect; // Calculate the bounding box that contains all inpaint masks const calculateMaskBbox = useCallback((): Rect | null => { @@ -23,84 +33,108 @@ export const InpaintMaskBboxAdjuster = memo(() => { return null; } - let minX = Infinity; - let minY = Infinity; - let maxX = -Infinity; - let maxY = -Infinity; - - // Iterate through all inpaint masks to find the overall bounds + // Use the current bbox as the reference container + const canvasWidth = bboxRect.width; + const canvasHeight = bboxRect.height; + + // Collect all mask objects and adjust their positions relative to the bbox + const allObjects: ( + | CanvasBrushLineState + | CanvasBrushLineWithPressureState + | CanvasEraserLineState + | CanvasEraserLineWithPressureState + | CanvasRectState + | CanvasImageState + )[] = []; for (const mask of inpaintMasks) { - if (!mask.isEnabled || mask.objects.length === 0) { + if (!mask.isEnabled || !mask.objects || mask.objects.length === 0) { continue; } - // Calculate bounds for this mask's objects + // Adjust object positions relative to the bbox (not the entity position) for (const obj of mask.objects) { - let objMinX = 0; - let objMinY = 0; - let objMaxX = 0; - let objMaxY = 0; - if (obj.type === 'rect') { - objMinX = mask.position.x + obj.rect.x; - objMinY = mask.position.y + obj.rect.y; - objMaxX = objMinX + obj.rect.width; - objMaxY = objMinY + obj.rect.height; + const adjustedObj = { + ...obj, + rect: { + ...obj.rect, + x: obj.rect.x + mask.position.x - bboxRect.x, + y: obj.rect.y + mask.position.y - bboxRect.y, + }, + }; + allObjects.push(adjustedObj); } else if ( obj.type === 'brush_line' || obj.type === 'brush_line_with_pressure' || obj.type === 'eraser_line' || obj.type === 'eraser_line_with_pressure' ) { - // For lines, find the min/max points + const adjustedPoints: number[] = []; for (let i = 0; i < obj.points.length; i += 2) { - const x = mask.position.x + (obj.points[i] ?? 0); - const y = mask.position.y + (obj.points[i + 1] ?? 0); - - if (i === 0) { - objMinX = objMaxX = x; - objMinY = objMaxY = y; - } else { - objMinX = Math.min(objMinX, x); - objMinY = Math.min(objMinY, y); - objMaxX = Math.max(objMaxX, x); - objMaxY = Math.max(objMaxY, y); - } + adjustedPoints.push((obj.points[i] ?? 0) + mask.position.x - bboxRect.x); + adjustedPoints.push((obj.points[i + 1] ?? 0) + mask.position.y - bboxRect.y); } - // Add stroke width to account for line thickness - const strokeRadius = (obj.strokeWidth ?? 50) / 2; - objMinX -= strokeRadius; - objMinY -= strokeRadius; - objMaxX += strokeRadius; - objMaxY += strokeRadius; + const adjustedObj = { + ...obj, + points: adjustedPoints, + }; + allObjects.push(adjustedObj); } else if (obj.type === 'image') { - // Image objects are positioned at the entity's position - objMinX = mask.position.x; - objMinY = mask.position.y; - objMaxX = objMinX + obj.image.width; - objMaxY = objMinY + obj.image.height; + // For image objects, we need to handle them differently since they don't have rect or points + // We'll skip them for now as they're not commonly used in masks + continue; } + } + } - // Update overall bounds - minX = Math.min(minX, objMinX); - minY = Math.min(minY, objMinY); - maxX = Math.max(maxX, objMaxX); - maxY = Math.max(maxY, objMaxY); + if (allObjects.length === 0) { + return null; + } + + // Render the consolidated mask to a bitmap + const bitmap = maskObjectsToBitmap(allObjects, canvasWidth, canvasHeight); + const { width, height, data } = bitmap; + + // Find the actual bounds of the rendered mask + let maskMinX = width; + let maskMinY = height; + let maskMaxX = 0; + let maskMaxY = 0; + + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const pixelIndex = (y * width + x) * 4; + const alpha = data[pixelIndex + 3] ?? 0; + + // If this pixel has any opacity, it's part of the mask + if (alpha > 0) { + maskMinX = Math.min(maskMinX, x); + maskMinY = Math.min(maskMinY, y); + maskMaxX = Math.max(maskMaxX, x); + maskMaxY = Math.max(maskMaxY, y); + } } } - // If no valid bounds found, return null - if (minX === Infinity || minY === Infinity || maxX === -Infinity || maxY === -Infinity) { + // If no mask pixels found, return null + if (maskMinX >= maskMaxX || maskMinY >= maskMaxY) { return null; } + // Clamp the mask bounds to the bbox boundaries + maskMinX = Math.max(0, maskMinX); + maskMinY = Math.max(0, maskMinY); + maskMaxX = Math.min(width - 1, maskMaxX); + maskMaxY = Math.min(height - 1, maskMaxY); + + // Convert back to world coordinates relative to the bbox return { - x: minX, - y: minY, - width: maxX - minX, - height: maxY - minY, + x: bboxRect.x + maskMinX, + y: bboxRect.y + maskMinY, + width: maskMaxX - maskMinX + 1, + height: maskMaxY - maskMinY + 1, }; - }, [inpaintMasks]); + }, [inpaintMasks, bboxRect]); const maskBbox = useMemo(() => calculateMaskBbox(), [calculateMaskBbox]); @@ -128,13 +162,13 @@ export const InpaintMaskBboxAdjuster = memo(() => { } return ( - +