diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json index 75c5ad6671f..bed05b46c7f 100644 --- a/invokeai/frontend/web/public/locales/en.json +++ b/invokeai/frontend/web/public/locales/en.json @@ -714,8 +714,8 @@ "desc": "Select the move tool." }, "selectRectTool": { - "title": "Rect Tool", - "desc": "Select the rect tool." + "title": "Shapes Tool", + "desc": "Select the shapes tool." }, "selectLassoTool": { "title": "Lasso Tool", @@ -2835,9 +2835,16 @@ "polygon": "Polygon", "polygonHint": "Click to add points, click the first point to close." }, + "shape": { + "rect": "Rect", + "oval": "Oval", + "polygon": "Polygon", + "freehand": "Freehand" + }, "tool": { "brush": "Brush", "eraser": "Eraser", + "shapes": "Shapes", "rectangle": "Rectangle", "lasso": "Lasso", "gradient": "Gradient", diff --git a/invokeai/frontend/web/src/features/controlLayers/components/Tool/GradientIcons.tsx b/invokeai/frontend/web/src/features/controlLayers/components/Tool/GradientIcons.tsx index b09e46d7320..61074015e76 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/Tool/GradientIcons.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/Tool/GradientIcons.tsx @@ -32,7 +32,7 @@ export const GradientLinearIcon = memo(() => { const id = useId(); const gradientId = `${id}-gradient-linear-diagonal`; return ( - + @@ -40,15 +40,15 @@ export const GradientLinearIcon = memo(() => { ); @@ -59,7 +59,7 @@ export const GradientRadialIcon = memo(() => { const id = useId(); const gradientId = `${id}-gradient-radial`; return ( - + @@ -67,13 +67,13 @@ export const GradientRadialIcon = memo(() => { ); diff --git a/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolChooser.tsx b/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolChooser.tsx index 30d82722072..c0291f8e587 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolChooser.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolChooser.tsx @@ -5,7 +5,7 @@ import { ToolColorPickerButton } from 'features/controlLayers/components/Tool/To import { ToolGradientButton } from 'features/controlLayers/components/Tool/ToolGradientButton'; import { ToolLassoButton } from 'features/controlLayers/components/Tool/ToolLassoButton'; import { ToolMoveButton } from 'features/controlLayers/components/Tool/ToolMoveButton'; -import { ToolRectButton } from 'features/controlLayers/components/Tool/ToolRectButton'; +import { ToolShapesButton } from 'features/controlLayers/components/Tool/ToolShapesButton'; import { ToolTextButton } from 'features/controlLayers/components/Tool/ToolTextButton'; import React from 'react'; @@ -18,7 +18,7 @@ export const ToolChooser: React.FC = () => { - + diff --git a/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolShapeTypeToggle.tsx b/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolShapeTypeToggle.tsx new file mode 100644 index 00000000000..a64939e71d3 --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolShapeTypeToggle.tsx @@ -0,0 +1,65 @@ +import { ButtonGroup, IconButton, Tooltip } from '@invoke-ai/ui-library'; +import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; +import { selectShapeType, settingsShapeTypeChanged } from 'features/controlLayers/store/canvasSettingsSlice'; +import { memo, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { PiCircleBold, PiPolygonBold, PiRectangleBold, PiScribbleLoopBold } from 'react-icons/pi'; + +export const ToolShapeTypeToggle = memo(() => { + const { t } = useTranslation(); + const shapeType = useAppSelector(selectShapeType); + const dispatch = useAppDispatch(); + + const onRectClick = useCallback(() => dispatch(settingsShapeTypeChanged('rect')), [dispatch]); + const onOvalClick = useCallback(() => dispatch(settingsShapeTypeChanged('oval')), [dispatch]); + const onPolygonClick = useCallback(() => dispatch(settingsShapeTypeChanged('polygon')), [dispatch]); + const onFreehandClick = useCallback(() => dispatch(settingsShapeTypeChanged('freehand')), [dispatch]); + + const rectLabel = t('controlLayers.shape.rect', { defaultValue: 'Rect' }); + const ovalLabel = t('controlLayers.shape.oval', { defaultValue: 'Oval' }); + const polygonLabel = t('controlLayers.shape.polygon', { defaultValue: 'Polygon' }); + const freehandLabel = t('controlLayers.shape.freehand', { defaultValue: 'Freehand' }); + + return ( + + + } + colorScheme={shapeType === 'rect' ? 'invokeBlue' : 'base'} + variant="solid" + onClick={onRectClick} + /> + + + } + colorScheme={shapeType === 'oval' ? 'invokeBlue' : 'base'} + variant="solid" + onClick={onOvalClick} + /> + + + } + colorScheme={shapeType === 'polygon' ? 'invokeBlue' : 'base'} + variant="solid" + onClick={onPolygonClick} + /> + + + } + colorScheme={shapeType === 'freehand' ? 'invokeBlue' : 'base'} + variant="solid" + onClick={onFreehandClick} + /> + + + ); +}); + +ToolShapeTypeToggle.displayName = 'ToolShapeTypeToggle'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolRectButton.tsx b/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolShapesButton.tsx similarity index 58% rename from invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolRectButton.tsx rename to invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolShapesButton.tsx index 93029390883..3f6c546d2cf 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolRectButton.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolShapesButton.tsx @@ -3,32 +3,33 @@ import { useSelectTool, useToolIsSelected } from 'features/controlLayers/compone import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData'; import { memo } from 'react'; import { useTranslation } from 'react-i18next'; -import { PiRectangleBold } from 'react-icons/pi'; +import { PiShapesBold } from 'react-icons/pi'; -export const ToolRectButton = memo(() => { +export const ToolShapesButton = memo(() => { const { t } = useTranslation(); const isSelected = useToolIsSelected('rect'); - const selectRect = useSelectTool('rect'); + const selectShapes = useSelectTool('rect'); + const label = t('controlLayers.tool.shapes', { defaultValue: 'Shapes' }); useRegisteredHotkeys({ id: 'selectRectTool', category: 'canvas', - callback: selectRect, + callback: selectShapes, options: { enabled: !isSelected }, - dependencies: [isSelected, selectRect], + dependencies: [isSelected, selectShapes], }); return ( - + } + aria-label={`${label} (U)`} + icon={} colorScheme={isSelected ? 'invokeBlue' : 'base'} variant="solid" - onClick={selectRect} + onClick={selectShapes} /> ); }); -ToolRectButton.displayName = 'ToolRectButton'; +ToolShapesButton.displayName = 'ToolShapesButton'; 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 fc34f4331c7..539036e7af1 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/Toolbar/CanvasToolbar.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/Toolbar/CanvasToolbar.tsx @@ -7,6 +7,7 @@ import { ToolGradientClipToggle } from 'features/controlLayers/components/Tool/T import { ToolGradientModeToggle } from 'features/controlLayers/components/Tool/ToolGradientModeToggle'; import { ToolLassoModeToggle } from 'features/controlLayers/components/Tool/ToolLassoModeToggle'; import { ToolOptionsRowContainer } from 'features/controlLayers/components/Tool/ToolOptionsRowContainer'; +import { ToolShapeTypeToggle } from 'features/controlLayers/components/Tool/ToolShapeTypeToggle'; import { ToolWidthPicker } from 'features/controlLayers/components/Tool/ToolWidthPicker'; import { CanvasToolbarFitBboxToLayersButton } from 'features/controlLayers/components/Toolbar/CanvasToolbarFitBboxToLayersButton'; import { CanvasToolbarFitBboxToMasksButton } from 'features/controlLayers/components/Toolbar/CanvasToolbarFitBboxToMasksButton'; @@ -32,6 +33,7 @@ import { memo, useMemo } from 'react'; export const CanvasToolbar = memo(() => { const isBrushSelected = useToolIsSelected('brush'); const isEraserSelected = useToolIsSelected('eraser'); + const isShapeSelected = useToolIsSelected('rect'); const isTextSelected = useToolIsSelected('text'); const isLassoSelected = useToolIsSelected('lasso'); const isGradientSelected = useToolIsSelected('gradient'); @@ -51,9 +53,25 @@ export const CanvasToolbar = memo(() => { useCanvasToggleBboxHotkey(); return ( - + + {isShapeSelected && ( + + + + )} {isGradientSelected && ( diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntity/CanvasEntityBufferObjectRenderer.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntity/CanvasEntityBufferObjectRenderer.ts index 9941761a2ee..0732b453448 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntity/CanvasEntityBufferObjectRenderer.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntity/CanvasEntityBufferObjectRenderer.ts @@ -9,6 +9,8 @@ import { CanvasObjectEraserLineWithPressure } from 'features/controlLayers/konva import { CanvasObjectGradient } from 'features/controlLayers/konva/CanvasObject/CanvasObjectGradient'; import { CanvasObjectImage } from 'features/controlLayers/konva/CanvasObject/CanvasObjectImage'; import { CanvasObjectLasso } from 'features/controlLayers/konva/CanvasObject/CanvasObjectLasso'; +import { CanvasObjectOval } from 'features/controlLayers/konva/CanvasObject/CanvasObjectOval'; +import { CanvasObjectPolygon } from 'features/controlLayers/konva/CanvasObject/CanvasObjectPolygon'; import { CanvasObjectRect } from 'features/controlLayers/konva/CanvasObject/CanvasObjectRect'; import type { AnyObjectRenderer, AnyObjectState } from 'features/controlLayers/konva/CanvasObject/types'; import { getPrefixedId } from 'features/controlLayers/konva/util'; @@ -83,6 +85,20 @@ export class CanvasEntityBufferObjectRenderer extends CanvasModuleBase { this.subscriptions.add( this.manager.tool.$tool.listen(() => { if (this.hasBuffer() && !this.manager.$isBusy.get()) { + const isTemporaryShapesViewSwitch = + this.manager.tool.tools.rect.hasSuspendableSession() && + ((this.manager.tool.$tool.get() === 'view' && this.manager.tool.$toolBuffer.get() === 'rect') || + this.manager.tool.$tool.get() === 'rect'); + + if (isTemporaryShapesViewSwitch) { + return; + } + + if (this.state?.type === 'polygon' && this.state.previewPoint) { + this.clearBuffer(); + return; + } + this.commitBuffer(); } }) @@ -153,6 +169,24 @@ export class CanvasEntityBufferObjectRenderer extends CanvasModuleBase { this.konva.group.add(this.renderer.konva.group); } + didRender = this.renderer.update(this.state, true); + } else if (this.state.type === 'oval') { + assert(this.renderer instanceof CanvasObjectOval || !this.renderer); + + if (!this.renderer) { + this.renderer = new CanvasObjectOval(this.state, this); + this.konva.group.add(this.renderer.konva.group); + } + + didRender = this.renderer.update(this.state, true); + } else if (this.state.type === 'polygon') { + assert(this.renderer instanceof CanvasObjectPolygon || !this.renderer); + + if (!this.renderer) { + this.renderer = new CanvasObjectPolygon(this.state, this); + this.konva.group.add(this.renderer.konva.group); + } + didRender = this.renderer.update(this.state, true); } else if (this.state.type === 'lasso') { assert(this.renderer instanceof CanvasObjectLasso || !this.renderer); @@ -240,28 +274,40 @@ export class CanvasEntityBufferObjectRenderer extends CanvasModuleBase { this.log.trace({ buffer: this.renderer.repr() }, 'Committing buffer'); + let committedState = this.state; + + // Polygon previews render an outline while they are still live in the buffer. + // Clear that preview state before adopting the renderer into the persistent object group. + if (committedState.type === 'polygon' && this.renderer instanceof CanvasObjectPolygon) { + committedState = { ...committedState, previewPoint: undefined }; + this.state = null; + this.renderer.update(committedState, true); + } + // Move the buffer to the persistent objects group/renderers this.parent.renderer.adoptObjectRenderer(this.renderer); if (pushToState) { const entityIdentifier = this.parent.entityIdentifier; - switch (this.state.type) { + switch (committedState.type) { case 'brush_line': case 'brush_line_with_pressure': - this.manager.stateApi.addBrushLine({ entityIdentifier, brushLine: this.state }); + this.manager.stateApi.addBrushLine({ entityIdentifier, brushLine: committedState }); break; case 'eraser_line': case 'eraser_line_with_pressure': - this.manager.stateApi.addEraserLine({ entityIdentifier, eraserLine: this.state }); + this.manager.stateApi.addEraserLine({ entityIdentifier, eraserLine: committedState }); break; case 'rect': - this.manager.stateApi.addRect({ entityIdentifier, rect: this.state }); + case 'oval': + case 'polygon': + this.manager.stateApi.addShape({ entityIdentifier, shape: committedState }); break; case 'lasso': - this.manager.stateApi.addLasso({ entityIdentifier, lasso: this.state }); + this.manager.stateApi.addLasso({ entityIdentifier, lasso: committedState }); break; case 'gradient': - this.manager.stateApi.addGradient({ entityIdentifier, gradient: this.state }); + this.manager.stateApi.addGradient({ entityIdentifier, gradient: committedState }); break; } } diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntity/CanvasEntityObjectRenderer.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntity/CanvasEntityObjectRenderer.ts index 903ccaa772c..f62ce3f9822 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntity/CanvasEntityObjectRenderer.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntity/CanvasEntityObjectRenderer.ts @@ -11,6 +11,8 @@ import { CanvasObjectEraserLineWithPressure } from 'features/controlLayers/konva import { CanvasObjectGradient } from 'features/controlLayers/konva/CanvasObject/CanvasObjectGradient'; import { CanvasObjectImage } from 'features/controlLayers/konva/CanvasObject/CanvasObjectImage'; import { CanvasObjectLasso } from 'features/controlLayers/konva/CanvasObject/CanvasObjectLasso'; +import { CanvasObjectOval } from 'features/controlLayers/konva/CanvasObject/CanvasObjectOval'; +import { CanvasObjectPolygon } from 'features/controlLayers/konva/CanvasObject/CanvasObjectPolygon'; import { CanvasObjectRect } from 'features/controlLayers/konva/CanvasObject/CanvasObjectRect'; import type { AnyObjectRenderer, AnyObjectState } from 'features/controlLayers/konva/CanvasObject/types'; import { LightnessToAlphaFilter } from 'features/controlLayers/konva/filters'; @@ -398,6 +400,26 @@ export class CanvasEntityObjectRenderer extends CanvasModuleBase { this.konva.objectGroup.add(renderer.konva.group); } + didRender = renderer.update(objectState, force || isFirstRender); + } else if (objectState.type === 'oval') { + assert(renderer instanceof CanvasObjectOval || !renderer); + + if (!renderer) { + renderer = new CanvasObjectOval(objectState, this); + this.renderers.set(renderer.id, renderer); + this.konva.objectGroup.add(renderer.konva.group); + } + + didRender = renderer.update(objectState, force || isFirstRender); + } else if (objectState.type === 'polygon') { + assert(renderer instanceof CanvasObjectPolygon || !renderer); + + if (!renderer) { + renderer = new CanvasObjectPolygon(objectState, this); + this.renderers.set(renderer.id, renderer); + this.konva.objectGroup.add(renderer.konva.group); + } + didRender = renderer.update(objectState, force || isFirstRender); } else if (objectState.type === 'lasso') { assert(renderer instanceof CanvasObjectLasso || !renderer); @@ -455,10 +477,24 @@ export class CanvasEntityObjectRenderer extends CanvasModuleBase { renderer instanceof CanvasObjectEraserLine || renderer instanceof CanvasObjectEraserLineWithPressure; const isSubtractingLasso = renderer instanceof CanvasObjectLasso && renderer.state.compositeOperation === 'destination-out'; + const isSubtractRect = + renderer instanceof CanvasObjectRect && renderer.state.compositeOperation === 'destination-out'; + const isSubtractOval = + renderer instanceof CanvasObjectOval && renderer.state.compositeOperation === 'destination-out'; + const isSubtractPolygon = + renderer instanceof CanvasObjectPolygon && renderer.state.compositeOperation === 'destination-out'; const isImage = renderer instanceof CanvasObjectImage; const imageIgnoresTransparency = isImage && renderer.state.usePixelBbox === false; const hasClip = renderer instanceof CanvasObjectBrushLine && renderer.state.clip; - if (isEraserLine || isSubtractingLasso || hasClip || (isImage && !imageIgnoresTransparency)) { + if ( + isEraserLine || + isSubtractingLasso || + isSubtractRect || + isSubtractOval || + isSubtractPolygon || + hasClip || + (isImage && !imageIgnoresTransparency) + ) { needsPixelBbox = true; break; } diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasObject/CanvasObjectOval.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasObject/CanvasObjectOval.ts new file mode 100644 index 00000000000..8c06268f768 --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasObject/CanvasObjectOval.ts @@ -0,0 +1,88 @@ +import { rgbaColorToString } from 'common/util/colorCodeTransformers'; +import { deepClone } from 'common/util/deepClone'; +import type { CanvasEntityBufferObjectRenderer } from 'features/controlLayers/konva/CanvasEntity/CanvasEntityBufferObjectRenderer'; +import type { CanvasEntityObjectRenderer } from 'features/controlLayers/konva/CanvasEntity/CanvasEntityObjectRenderer'; +import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager'; +import { CanvasModuleBase } from 'features/controlLayers/konva/CanvasModuleBase'; +import type { CanvasOvalState } from 'features/controlLayers/store/types'; +import Konva from 'konva'; +import type { Logger } from 'roarr'; + +export class CanvasObjectOval extends CanvasModuleBase { + readonly type = 'object_oval'; + readonly id: string; + readonly path: string[]; + readonly parent: CanvasEntityObjectRenderer | CanvasEntityBufferObjectRenderer; + readonly manager: CanvasManager; + readonly log: Logger; + + state: CanvasOvalState; + konva: { + group: Konva.Group; + ellipse: Konva.Ellipse; + }; + + constructor(state: CanvasOvalState, parent: CanvasEntityObjectRenderer | CanvasEntityBufferObjectRenderer) { + super(); + this.id = state.id; + this.parent = parent; + this.manager = parent.manager; + this.path = this.manager.buildPath(this); + this.log = this.manager.buildLogger(this); + + this.log.debug({ state }, 'Creating module'); + + this.konva = { + group: new Konva.Group({ name: `${this.type}:group`, listening: false }), + ellipse: new Konva.Ellipse({ + name: `${this.type}:ellipse`, + listening: false, + radiusX: 0, + radiusY: 0, + perfectDrawEnabled: false, + }), + }; + this.konva.group.add(this.konva.ellipse); + this.state = state; + } + + update(state: CanvasOvalState, force = false): boolean { + if (force || this.state !== state) { + this.log.trace({ state }, 'Updating oval'); + const { rect, color, compositeOperation } = state; + const fill = compositeOperation === 'destination-out' ? 'rgba(255,255,255,1)' : rgbaColorToString(color); + this.konva.ellipse.setAttrs({ + x: rect.x + rect.width / 2, + y: rect.y + rect.height / 2, + radiusX: rect.width / 2, + radiusY: rect.height / 2, + fill, + globalCompositeOperation: compositeOperation, + }); + this.state = state; + return true; + } + + return false; + } + + setVisibility(isVisible: boolean): void { + this.log.trace({ isVisible }, 'Setting oval visibility'); + this.konva.group.visible(isVisible); + } + + destroy = () => { + this.log.debug('Destroying module'); + this.konva.group.destroy(); + }; + + repr = () => { + return { + id: this.id, + type: this.type, + path: this.path, + parent: this.parent.id, + state: deepClone(this.state), + }; + }; +} diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasObject/CanvasObjectPolygon.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasObject/CanvasObjectPolygon.ts new file mode 100644 index 00000000000..dc54811569b --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasObject/CanvasObjectPolygon.ts @@ -0,0 +1,113 @@ +import { rgbaColorToString } from 'common/util/colorCodeTransformers'; +import { deepClone } from 'common/util/deepClone'; +import type { CanvasEntityBufferObjectRenderer } from 'features/controlLayers/konva/CanvasEntity/CanvasEntityBufferObjectRenderer'; +import type { CanvasEntityObjectRenderer } from 'features/controlLayers/konva/CanvasEntity/CanvasEntityObjectRenderer'; +import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager'; +import { CanvasModuleBase } from 'features/controlLayers/konva/CanvasModuleBase'; +import type { CanvasPolygonState, RgbaColor } from 'features/controlLayers/store/types'; +import Konva from 'konva'; +import type { Logger } from 'roarr'; + +const getPreviewStrokeColor = (color: RgbaColor) => rgbaColorToString({ ...color, a: Math.max(color.a, 0.9) }); + +export class CanvasObjectPolygon extends CanvasModuleBase { + readonly type = 'object_polygon'; + readonly id: string; + readonly path: string[]; + readonly parent: CanvasEntityObjectRenderer | CanvasEntityBufferObjectRenderer; + readonly manager: CanvasManager; + readonly log: Logger; + + state: CanvasPolygonState; + konva: { + group: Konva.Group; + fillPolygon: Konva.Line; + previewStroke: Konva.Line; + }; + + constructor(state: CanvasPolygonState, parent: CanvasEntityObjectRenderer | CanvasEntityBufferObjectRenderer) { + super(); + this.id = state.id; + this.parent = parent; + this.manager = parent.manager; + this.path = this.manager.buildPath(this); + this.log = this.manager.buildLogger(this); + + this.log.debug({ state }, 'Creating module'); + + this.konva = { + group: new Konva.Group({ name: `${this.type}:group`, listening: false }), + fillPolygon: new Konva.Line({ + name: `${this.type}:fill_polygon`, + listening: false, + closed: true, + strokeEnabled: false, + perfectDrawEnabled: false, + }), + previewStroke: new Konva.Line({ + name: `${this.type}:preview_stroke`, + listening: false, + closed: false, + fillEnabled: false, + lineCap: 'round', + lineJoin: 'round', + perfectDrawEnabled: false, + strokeWidth: 1, + }), + }; + this.konva.group.add(this.konva.fillPolygon, this.konva.previewStroke); + this.state = state; + } + + update(state: CanvasPolygonState, force = false): boolean { + if (force || this.state !== state) { + this.log.trace({ state }, 'Updating polygon'); + const combinedPoints = state.previewPoint + ? [...state.points, state.previewPoint.x, state.previewPoint.y] + : state.points; + const hasRenderablePolygon = combinedPoints.length >= 6; + const isLiveBufferPreview = this.parent.type === 'buffer_renderer' && this.parent.state?.id === state.id; + const fill = + state.compositeOperation === 'destination-out' ? 'rgba(255,255,255,1)' : rgbaColorToString(state.color); + + this.konva.fillPolygon.setAttrs({ + points: combinedPoints, + visible: hasRenderablePolygon, + fill, + globalCompositeOperation: state.compositeOperation, + }); + + this.konva.previewStroke.setAttrs({ + points: combinedPoints, + visible: (Boolean(state.previewPoint) || isLiveBufferPreview) && combinedPoints.length >= 4, + stroke: getPreviewStrokeColor(state.color), + globalCompositeOperation: 'source-over', + }); + + this.state = state; + return true; + } + + return false; + } + + setVisibility(isVisible: boolean): void { + this.log.trace({ isVisible }, 'Setting polygon visibility'); + this.konva.group.visible(isVisible); + } + + destroy = () => { + this.log.debug('Destroying module'); + this.konva.group.destroy(); + }; + + repr = () => { + return { + id: this.id, + type: this.type, + path: this.path, + parent: this.parent.id, + state: deepClone(this.state), + }; + }; +} diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasObject/CanvasObjectRect.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasObject/CanvasObjectRect.ts index 1ac8e5b5f37..e879dcd35ab 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasObject/CanvasObjectRect.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasObject/CanvasObjectRect.ts @@ -46,13 +46,15 @@ export class CanvasObjectRect extends CanvasModuleBase { this.isFirstRender = false; this.log.trace({ state }, 'Updating rect'); - const { rect, color } = state; + const { rect, color, compositeOperation } = state; + const fill = compositeOperation === 'destination-out' ? 'rgba(255,255,255,1)' : rgbaColorToString(color); this.konva.rect.setAttrs({ x: rect.x, y: rect.y, width: rect.width, height: rect.height, - fill: rgbaColorToString(color), + fill, + globalCompositeOperation: compositeOperation, }); this.state = state; return true; diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasObject/types.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasObject/types.ts index f193c0b391e..620842a9426 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasObject/types.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasObject/types.ts @@ -5,6 +5,8 @@ import type { CanvasObjectEraserLineWithPressure } from 'features/controlLayers/ import type { CanvasObjectGradient } from 'features/controlLayers/konva/CanvasObject/CanvasObjectGradient'; import type { CanvasObjectImage } from 'features/controlLayers/konva/CanvasObject/CanvasObjectImage'; import type { CanvasObjectLasso } from 'features/controlLayers/konva/CanvasObject/CanvasObjectLasso'; +import type { CanvasObjectOval } from 'features/controlLayers/konva/CanvasObject/CanvasObjectOval'; +import type { CanvasObjectPolygon } from 'features/controlLayers/konva/CanvasObject/CanvasObjectPolygon'; import type { CanvasObjectRect } from 'features/controlLayers/konva/CanvasObject/CanvasObjectRect'; import type { CanvasBrushLineState, @@ -14,6 +16,8 @@ import type { CanvasGradientState, CanvasImageState, CanvasLassoState, + CanvasOvalState, + CanvasPolygonState, CanvasRectState, } from 'features/controlLayers/store/types'; @@ -28,6 +32,8 @@ export type AnyObjectRenderer = | CanvasObjectEraserLineWithPressure | CanvasObjectRect | CanvasObjectLasso + | CanvasObjectOval + | CanvasObjectPolygon | CanvasObjectImage | CanvasObjectGradient; /** @@ -41,4 +47,6 @@ export type AnyObjectState = | CanvasImageState | CanvasRectState | CanvasLassoState + | CanvasOvalState + | CanvasPolygonState | CanvasGradientState; diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasStateApiModule.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasStateApiModule.ts index 7d4c76b0c06..26abd908e51 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasStateApiModule.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasStateApiModule.ts @@ -25,8 +25,8 @@ import { entityMovedBy, entityMovedTo, entityRasterized, - entityRectAdded, entityReset, + entityShapeAdded, inpaintMaskAdded, rasterLayerAdded, rgAdded, @@ -48,7 +48,7 @@ import type { EntityMovedByPayload, EntityMovedToPayload, EntityRasterizedPayload, - EntityRectAddedPayload, + EntityShapeAddedPayload, Rect, RgbaColor, } from 'features/controlLayers/store/types'; @@ -171,10 +171,10 @@ export class CanvasStateApiModule extends CanvasModuleBase { }; /** - * Adds a rectangle to an entity, pushing state to redux. + * Adds a shape to an entity, pushing state to redux. */ - addRect = (arg: EntityRectAddedPayload) => { - this.store.dispatch(entityRectAdded(arg)); + addShape = (arg: EntityShapeAddedPayload) => { + this.store.dispatch(entityShapeAdded(arg)); }; /** diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasTool/CanvasRectToolModule.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasTool/CanvasRectToolModule.ts deleted file mode 100644 index 3f64b0c2fc1..00000000000 --- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasTool/CanvasRectToolModule.ts +++ /dev/null @@ -1,102 +0,0 @@ -import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager'; -import { CanvasModuleBase } from 'features/controlLayers/konva/CanvasModuleBase'; -import type { CanvasToolModule } from 'features/controlLayers/konva/CanvasTool/CanvasToolModule'; -import { floorCoord, getPrefixedId, offsetCoord } from 'features/controlLayers/konva/util'; -import type { KonvaEventObject } from 'konva/lib/Node'; -import type { Logger } from 'roarr'; - -export class CanvasRectToolModule extends CanvasModuleBase { - readonly type = 'rect_tool'; - readonly id: string; - readonly path: string[]; - readonly parent: CanvasToolModule; - readonly manager: CanvasManager; - readonly log: Logger; - - constructor(parent: CanvasToolModule) { - super(); - this.id = getPrefixedId(this.type); - this.parent = parent; - this.manager = this.parent.manager; - this.path = this.manager.buildPath(this); - this.log = this.manager.buildLogger(this); - - this.log.debug('Creating module'); - } - - syncCursorStyle = () => { - this.manager.stage.setCursor('crosshair'); - }; - - onStagePointerDown = async (_e: KonvaEventObject) => { - const cursorPos = this.parent.$cursorPos.get(); - const isPrimaryPointerDown = this.parent.$isPrimaryPointerDown.get(); - const selectedEntity = this.manager.stateApi.getSelectedEntityAdapter(); - - if (!cursorPos || !isPrimaryPointerDown || !selectedEntity) { - /** - * Can't do anything without: - * - A cursor position: the cursor is not on the stage - * - The mouse is down: the user is not drawing - * - A selected entity: there is no entity to draw on - */ - return; - } - - const normalizedPoint = offsetCoord(cursorPos.relative, selectedEntity.state.position); - - await selectedEntity.bufferRenderer.setBuffer({ - id: getPrefixedId('rect'), - type: 'rect', - rect: { x: Math.round(normalizedPoint.x), y: Math.round(normalizedPoint.y), width: 0, height: 0 }, - color: this.manager.stateApi.getCurrentColor(), - }); - }; - - onStagePointerUp = (_e: KonvaEventObject) => { - const selectedEntity = this.manager.stateApi.getSelectedEntityAdapter(); - if (!selectedEntity) { - return; - } - - if (selectedEntity.bufferRenderer.state?.type === 'rect' && selectedEntity.bufferRenderer.hasBuffer()) { - selectedEntity.bufferRenderer.commitBuffer(); - } else { - selectedEntity.bufferRenderer.clearBuffer(); - } - }; - - onStagePointerMove = async (_e: KonvaEventObject) => { - const cursorPos = this.parent.$cursorPos.get(); - - if (!cursorPos) { - return; - } - - if (!this.parent.$isPrimaryPointerDown.get()) { - return; - } - - const selectedEntity = this.manager.stateApi.getSelectedEntityAdapter(); - - if (!selectedEntity) { - return; - } - - const bufferState = selectedEntity.bufferRenderer.state; - - if (!bufferState) { - return; - } - - if (bufferState.type !== 'rect') { - return; - } - - const normalizedPoint = offsetCoord(cursorPos.relative, selectedEntity.state.position); - const alignedPoint = floorCoord(normalizedPoint); - bufferState.rect.width = Math.round(alignedPoint.x - bufferState.rect.x); - bufferState.rect.height = Math.round(alignedPoint.y - bufferState.rect.y); - await selectedEntity.bufferRenderer.setBuffer(bufferState); - }; -} diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasTool/CanvasShapeToolModule.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasTool/CanvasShapeToolModule.ts new file mode 100644 index 00000000000..8b4e9af7ad8 --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasTool/CanvasShapeToolModule.ts @@ -0,0 +1,807 @@ +import { rgbaColorToString } from 'common/util/colorCodeTransformers'; +import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager'; +import { CanvasModuleBase } from 'features/controlLayers/konva/CanvasModuleBase'; +import type { CanvasToolModule } from 'features/controlLayers/konva/CanvasTool/CanvasToolModule'; +import { + addCoords, + floorCoord, + getPrefixedId, + isDistanceMoreThanMin, + offsetCoord, +} from 'features/controlLayers/konva/util'; +import { selectShapeType } from 'features/controlLayers/store/canvasSettingsSlice'; +import type { + CanvasEntityIdentifier, + CanvasPolygonState, + CanvasRectState, + Coordinate, +} from 'features/controlLayers/store/types'; +import { simplifyFlatNumbersArray } from 'features/controlLayers/util/simplify'; +import Konva from 'konva'; +import type { KonvaEventObject } from 'konva/lib/Node'; +import type { Logger } from 'roarr'; + +type CanvasShapeToolModuleConfig = { + START_POINT_RADIUS_PX: number; + START_POINT_STROKE_WIDTH_PX: number; + START_POINT_HOVER_RADIUS_DELTA_PX: number; + POLYGON_CLOSE_RADIUS_PX: number; + MIN_FREEHAND_POINT_DISTANCE_PX: number; + MAX_FREEHAND_SEGMENT_LENGTH_PX: number; + FREEHAND_SIMPLIFY_MIN_POINTS: number; + FREEHAND_SIMPLIFY_TOLERANCE: number; + PREVIEW_STROKE_COLOR: string; +}; + +const DEFAULT_CONFIG: CanvasShapeToolModuleConfig = { + START_POINT_RADIUS_PX: 4, + START_POINT_STROKE_WIDTH_PX: 2, + START_POINT_HOVER_RADIUS_DELTA_PX: 2, + POLYGON_CLOSE_RADIUS_PX: 10, + MIN_FREEHAND_POINT_DISTANCE_PX: 1, + MAX_FREEHAND_SEGMENT_LENGTH_PX: 2, + FREEHAND_SIMPLIFY_MIN_POINTS: 200, + FREEHAND_SIMPLIFY_TOLERANCE: 0.6, + PREVIEW_STROKE_COLOR: rgbaColorToString({ r: 90, g: 175, b: 255, a: 1 }), +}; + +const SUBTRACT_CURSOR = `url("data:image/svg+xml,${encodeURIComponent( + ` + + + + + + + ` +)}") 12 12, crosshair`; + +const getAxisSign = (value: number, fallback: number): number => { + if (value === 0) { + return fallback === 0 ? 1 : Math.sign(fallback); + } + return Math.sign(value); +}; + +export class CanvasShapeToolModule extends CanvasModuleBase { + readonly type = 'shape_tool'; + readonly id: string; + readonly path: string[]; + readonly parent: CanvasToolModule; + readonly manager: CanvasManager; + readonly log: Logger; + + config: CanvasShapeToolModuleConfig = DEFAULT_CONFIG; + subscriptions: Set<() => void> = new Set(); + + private activeEntityIdentifier: CanvasEntityIdentifier | null = null; + private shapeId: string | null = null; + private dragStartPoint: Coordinate | null = null; + private dragCurrentPoint: Coordinate | null = null; + private freehandPoints: Coordinate[] = []; + private isDrawingFreehand = false; + private polygonPoints: Coordinate[] = []; + private polygonPointer: Coordinate | null = null; + + konva: { + group: Konva.Group; + startPointIndicator: Konva.Circle; + }; + + constructor(parent: CanvasToolModule) { + super(); + this.id = getPrefixedId(this.type); + this.parent = parent; + this.manager = this.parent.manager; + this.path = this.manager.buildPath(this); + this.log = this.manager.buildLogger(this); + + this.log.debug('Creating module'); + + this.konva = { + group: new Konva.Group({ name: `${this.type}:group`, listening: false }), + startPointIndicator: new Konva.Circle({ + name: `${this.type}:start_point_indicator`, + listening: false, + fillEnabled: false, + stroke: this.config.PREVIEW_STROKE_COLOR, + visible: false, + perfectDrawEnabled: false, + }), + }; + this.konva.group.add(this.konva.startPointIndicator); + + this.subscriptions.add(this.manager.stateApi.$altKey.listen(this.onModifierChanged)); + this.subscriptions.add(this.manager.stateApi.$ctrlKey.listen(this.onModifierChanged)); + this.subscriptions.add(this.manager.stateApi.$metaKey.listen(this.onModifierChanged)); + this.subscriptions.add(this.manager.stateApi.$shiftKey.listen(this.onModifierChanged)); + this.subscriptions.add( + this.manager.stateApi.createStoreSubscription(selectShapeType, () => { + if (this.hasActiveSession()) { + this.cancel(); + } + this.render(); + }) + ); + } + + hasActiveSession = (): boolean => { + return Boolean( + this.dragStartPoint || this.isDrawingFreehand || this.freehandPoints.length || this.polygonPoints.length + ); + }; + + hasSuspendableSession = (): boolean => { + return Boolean(this.isDrawingFreehand || this.freehandPoints.length || this.polygonPoints.length); + }; + + hasActiveDragSession = (): boolean => { + return Boolean(this.dragStartPoint || this.isDrawingFreehand); + }; + + hasActivePolygonSession = (): boolean => { + return this.polygonPoints.length > 0; + }; + + onToolChanged = () => { + const tool = this.parent.$tool.get(); + const isTemporaryViewSwitch = + tool === 'view' && this.parent.$toolBuffer.get() === 'rect' && this.hasSuspendableSession(); + if (tool !== 'rect' && !isTemporaryViewSwitch) { + this.cancel(); + } + }; + + syncCursorStyle = () => { + this.manager.stage.setCursor(this.getCompositeOperation() === 'destination-out' ? SUBTRACT_CURSOR : 'crosshair'); + }; + + render = () => { + const tool = this.parent.$tool.get(); + const isTemporaryViewSwitch = + tool === 'view' && this.parent.$toolBuffer.get() === 'rect' && this.hasSuspendableSession(); + if (tool !== 'rect' && !isTemporaryViewSwitch) { + this.konva.startPointIndicator.visible(false); + return; + } + + if (tool === 'rect') { + this.syncCursorStyle(); + } + + this.syncStartPointIndicator(); + }; + + cancel = () => { + this.clearActiveBuffer(); + this.resetState(); + this.render(); + }; + + onStagePointerDown = async (e: KonvaEventObject) => { + const selectedEntity = this.manager.stateApi.getSelectedEntityAdapter(); + const cursorPos = this.parent.$cursorPos.get(); + if (!selectedEntity || !cursorPos) { + return; + } + + if (e.evt.button !== 0) { + return; + } + + const shapeType = this.manager.stateApi.getSettings().shapeType; + const point = this.getEntityRelativePoint(cursorPos.relative, selectedEntity.state.position); + + if (shapeType === 'polygon') { + await this.onPolygonPointerDown(point, selectedEntity.entityIdentifier, e.evt.shiftKey); + return; + } + + if (shapeType === 'freehand') { + if (!this.parent.$isPrimaryPointerDown.get()) { + return; + } + + await this.startFreehandSession(point, selectedEntity.entityIdentifier); + return; + } + + if (!this.parent.$isPrimaryPointerDown.get()) { + return; + } + + this.clearActiveBuffer(); + this.resetState(); + this.activeEntityIdentifier = selectedEntity.entityIdentifier; + this.shapeId = getPrefixedId(shapeType); + this.dragStartPoint = point; + this.dragCurrentPoint = point; + await this.updateDragBuffer(); + }; + + onStagePointerMove = async (e: KonvaEventObject) => { + const shapeType = this.manager.stateApi.getSettings().shapeType; + const activeEntity = this.getActiveEntityAdapter(); + const cursorPos = this.parent.$cursorPos.get(); + + if (!activeEntity || !cursorPos) { + return; + } + + const point = this.getEntityRelativePoint(cursorPos.relative, activeEntity.state.position); + + if (shapeType === 'polygon') { + if (!this.hasActivePolygonSession()) { + return; + } + this.polygonPointer = this.getPolygonPoint(point, e.evt.shiftKey); + await this.updatePolygonBuffer(); + this.render(); + return; + } + + if (shapeType === 'freehand') { + await this.handleFreehandPointerMove(point); + return; + } + + if (!this.parent.$isPrimaryPointerDown.get() || !this.dragStartPoint) { + return; + } + + this.dragCurrentPoint = point; + await this.updateDragBuffer(); + }; + + onWindowPointerMove = async () => { + const shapeType = this.manager.stateApi.getSettings().shapeType; + const activeEntity = this.getActiveEntityAdapter(); + const cursorPos = this.parent.$cursorPos.get(); + + if (!activeEntity || !cursorPos || !this.parent.$isPrimaryPointerDown.get()) { + return; + } + + const point = this.getEntityRelativePoint(cursorPos.relative, activeEntity.state.position); + + if (shapeType === 'freehand') { + await this.handleFreehandPointerMove(point); + return; + } + + if ((shapeType !== 'rect' && shapeType !== 'oval') || !this.dragStartPoint) { + return; + } + + this.dragCurrentPoint = point; + await this.updateDragBuffer(); + }; + + onStagePointerUp = async (_e: KonvaEventObject) => { + const shapeType = this.manager.stateApi.getSettings().shapeType; + + if (shapeType === 'polygon') { + this.render(); + return; + } + + if (shapeType === 'freehand') { + await this.commitFreehand(); + return; + } + + this.finishDragShapeSession(); + }; + + onWindowPointerUp = async () => { + if (this.isDrawingFreehand) { + await this.commitFreehand(); + return; + } + + if (!this.dragStartPoint) { + return; + } + + this.finishDragShapeSession(); + }; + + repr = () => { + return { + id: this.id, + type: this.type, + path: this.path, + activeEntityIdentifier: this.activeEntityIdentifier, + shapeId: this.shapeId, + dragStartPoint: this.dragStartPoint, + dragCurrentPoint: this.dragCurrentPoint, + freehandPoints: this.freehandPoints, + isDrawingFreehand: this.isDrawingFreehand, + polygonPoints: this.polygonPoints, + polygonPointer: this.polygonPointer, + }; + }; + + destroy = () => { + this.log.debug('Destroying module'); + this.subscriptions.forEach((unsubscribe) => unsubscribe()); + this.subscriptions.clear(); + this.konva.group.destroy(); + }; + + private onModifierChanged = () => { + const tool = this.parent.$tool.get(); + const isTemporaryViewSwitch = + tool === 'view' && this.parent.$toolBuffer.get() === 'rect' && this.hasSuspendableSession(); + if (tool !== 'rect' && !isTemporaryViewSwitch) { + return; + } + + if (tool === 'rect') { + this.syncCursorStyle(); + } + void this.updateActivePreview(); + this.render(); + }; + + private updateActivePreview = async () => { + if (this.dragStartPoint) { + await this.updateDragBuffer(); + return; + } + + if (this.isDrawingFreehand || this.freehandPoints.length > 0) { + await this.updateFreehandBuffer(); + return; + } + + if (this.hasActivePolygonSession()) { + await this.updatePolygonBuffer(); + } + }; + + private startFreehandSession = async (point: Coordinate, entityIdentifier: CanvasEntityIdentifier) => { + this.clearActiveBuffer(); + this.resetState(); + this.activeEntityIdentifier = entityIdentifier; + this.shapeId = getPrefixedId('polygon'); + this.freehandPoints = [point]; + this.isDrawingFreehand = true; + await this.updateFreehandBuffer(); + }; + + private handleFreehandPointerMove = async (point: Coordinate) => { + if (!this.isDrawingFreehand || !this.parent.$isPrimaryPointerDown.get()) { + return; + } + + const minDistance = this.manager.stage.unscale(this.config.MIN_FREEHAND_POINT_DISTANCE_PX); + const lastPoint = this.freehandPoints.at(-1) ?? null; + if (!isDistanceMoreThanMin(point, lastPoint, minDistance)) { + return; + } + + this.appendFreehandPoint(point); + await this.updateFreehandBuffer(); + }; + + private onPolygonPointerDown = async ( + point: Coordinate, + entityIdentifier: CanvasEntityIdentifier, + shouldSnap: boolean + ) => { + if ( + this.activeEntityIdentifier && + (this.activeEntityIdentifier.id !== entityIdentifier.id || + this.activeEntityIdentifier.type !== entityIdentifier.type) + ) { + this.cancel(); + } + + this.activeEntityIdentifier = entityIdentifier; + this.dragStartPoint = null; + this.dragCurrentPoint = null; + + if (this.polygonPoints.length === 0) { + this.shapeId = getPrefixedId('polygon'); + this.polygonPoints = [point]; + this.polygonPointer = point; + await this.updatePolygonBuffer(); + this.render(); + return; + } + + const startPoint = this.polygonPoints[0]; + if (!startPoint) { + return; + } + + if (this.polygonPoints.length >= 3 && this.isPointNearStart(point)) { + await this.commitPolygon(); + return; + } + + const polygonPoint = this.getPolygonPoint(point, shouldSnap); + this.polygonPoints = [...this.polygonPoints, polygonPoint]; + this.polygonPointer = polygonPoint; + await this.updatePolygonBuffer(); + this.render(); + }; + + private commitPolygon = async () => { + const activeEntity = this.getActiveEntityAdapter(); + if (!activeEntity || !this.shapeId || this.polygonPoints.length < 3) { + this.cancel(); + return; + } + + const polygonState: CanvasPolygonState = { + id: this.shapeId, + type: 'polygon', + points: this.polygonPoints.flatMap((point) => [point.x, point.y]), + color: this.manager.stateApi.getCurrentColor(), + compositeOperation: this.getCompositeOperation(), + }; + + await activeEntity.bufferRenderer.setBuffer(polygonState); + activeEntity.bufferRenderer.commitBuffer(); + this.resetState(); + this.render(); + }; + + private commitFreehand = async () => { + if (!this.isDrawingFreehand) { + return; + } + + const activeEntity = this.getActiveEntityAdapter(); + if (!activeEntity || !this.shapeId) { + this.cancel(); + return; + } + + const simplifiedPoints = this.simplifyFreehandContour(this.freehandPoints); + if (simplifiedPoints.length < 3) { + activeEntity.bufferRenderer.clearBuffer(); + this.resetState(); + this.render(); + return; + } + + const polygonState: CanvasPolygonState = { + id: this.shapeId, + type: 'polygon', + points: simplifiedPoints.flatMap((point) => [point.x, point.y]), + color: this.manager.stateApi.getCurrentColor(), + compositeOperation: this.getCompositeOperation(), + }; + + await activeEntity.bufferRenderer.setBuffer(polygonState); + activeEntity.bufferRenderer.commitBuffer(); + this.resetState(); + this.render(); + }; + + private updateDragBuffer = async () => { + const activeEntity = this.getActiveEntityAdapter(); + if (!activeEntity || !this.dragStartPoint || !this.dragCurrentPoint || !this.shapeId) { + return; + } + + const shapeType = this.manager.stateApi.getSettings().shapeType; + if (shapeType !== 'rect' && shapeType !== 'oval') { + return; + } + + const rect = this.getDragRect(this.dragStartPoint, this.dragCurrentPoint, { + fromCenter: this.manager.stateApi.$altKey.get(), + constrainSquare: this.manager.stateApi.$shiftKey.get(), + }); + + await activeEntity.bufferRenderer.setBuffer({ + id: this.shapeId, + type: shapeType, + rect, + color: this.manager.stateApi.getCurrentColor(), + compositeOperation: this.getCompositeOperation(), + }); + }; + + private updatePolygonBuffer = async () => { + const activeEntity = this.getActiveEntityAdapter(); + if (!activeEntity || !this.shapeId || this.polygonPoints.length === 0) { + return; + } + + await activeEntity.bufferRenderer.setBuffer({ + id: this.shapeId, + type: 'polygon', + points: this.polygonPoints.flatMap((point) => [point.x, point.y]), + previewPoint: this.polygonPointer ?? this.polygonPoints.at(-1), + color: this.manager.stateApi.getCurrentColor(), + compositeOperation: this.getCompositeOperation(), + }); + }; + + private updateFreehandBuffer = async () => { + const activeEntity = this.getActiveEntityAdapter(); + if (!activeEntity || !this.shapeId || this.freehandPoints.length === 0) { + return; + } + + await activeEntity.bufferRenderer.setBuffer({ + id: this.shapeId, + type: 'polygon', + points: this.freehandPoints.flatMap((point) => [point.x, point.y]), + color: this.manager.stateApi.getCurrentColor(), + compositeOperation: this.getCompositeOperation(), + }); + }; + + private syncStartPointIndicator = () => { + const activeEntity = this.getActiveEntityAdapter(); + const startPoint = this.polygonPoints[0]; + if (!activeEntity || !startPoint || this.manager.stateApi.getSettings().shapeType !== 'polygon') { + this.konva.startPointIndicator.visible(false); + return; + } + + const isHoveringStartPoint = this.getIsHoveringStartPoint(startPoint, activeEntity.state.position); + const baseRadius = this.manager.stage.unscale(this.config.START_POINT_RADIUS_PX); + const stagePoint = addCoords(startPoint, activeEntity.state.position); + + this.konva.startPointIndicator.setAttrs({ + x: stagePoint.x, + y: stagePoint.y, + radius: + baseRadius + + (isHoveringStartPoint ? this.manager.stage.unscale(this.config.START_POINT_HOVER_RADIUS_DELTA_PX) : 0), + strokeWidth: this.manager.stage.unscale(this.config.START_POINT_STROKE_WIDTH_PX), + visible: true, + }); + }; + + private getEntityRelativePoint = (point: Coordinate, position: Coordinate): Coordinate => { + return floorCoord(offsetCoord(point, position)); + }; + + private getCompositeOperation = (): CanvasRectState['compositeOperation'] => { + return this.manager.stateApi.$ctrlKey.get() || this.manager.stateApi.$metaKey.get() + ? 'destination-out' + : 'source-over'; + }; + + private getPolygonPoint = (point: Coordinate, shouldSnap: boolean): Coordinate => { + if (!shouldSnap) { + return point; + } + + const lastPoint = this.polygonPoints.at(-1); + if (!lastPoint) { + return point; + } + + const dx = point.x - lastPoint.x; + const dy = point.y - lastPoint.y; + const distance = Math.hypot(dx, dy); + if (distance === 0) { + return point; + } + + const snapAngle = Math.PI / 4; + const angle = Math.atan2(dy, dx); + const snappedAngle = Math.round(angle / snapAngle) * snapAngle; + + const snappedPoint = { + x: lastPoint.x + Math.cos(snappedAngle) * distance, + y: lastPoint.y + Math.sin(snappedAngle) * distance, + }; + + return this.alignPointToStart(snappedPoint); + }; + + private isPointNearStart = (point: Coordinate): boolean => { + const startPoint = this.polygonPoints[0]; + if (!startPoint) { + return false; + } + return Math.hypot(point.x - startPoint.x, point.y - startPoint.y) <= this.getPolygonCloseRadius(); + }; + + private getPolygonCloseRadius = (): number => { + return this.manager.stage.unscale(this.config.POLYGON_CLOSE_RADIUS_PX); + }; + + private getIsHoveringStartPoint = (startPoint: Coordinate, entityPosition: Coordinate): boolean => { + if (this.polygonPoints.length < 3) { + return false; + } + + const pointerPoint = this.parent.$cursorPos.get()?.relative; + if (!pointerPoint) { + return false; + } + + const entityRelativePointerPoint = this.getEntityRelativePoint(pointerPoint, entityPosition); + return ( + Math.hypot(entityRelativePointerPoint.x - startPoint.x, entityRelativePointerPoint.y - startPoint.y) <= + this.getPolygonCloseRadius() + ); + }; + + private alignPointToStart = (point: Coordinate): Coordinate => { + if (this.polygonPoints.length < 2) { + return point; + } + + const startPoint = this.polygonPoints[0]; + if (!startPoint) { + return point; + } + + const alignThreshold = this.getPolygonCloseRadius(); + const deltaX = Math.abs(point.x - startPoint.x); + const deltaY = Math.abs(point.y - startPoint.y); + const canAlignX = deltaX <= alignThreshold; + const canAlignY = deltaY <= alignThreshold; + + if (!canAlignX && !canAlignY) { + return point; + } + + if (canAlignX && canAlignY) { + if (deltaX <= deltaY) { + return { x: startPoint.x, y: point.y }; + } + return { x: point.x, y: startPoint.y }; + } + + if (canAlignX) { + return { x: startPoint.x, y: point.y }; + } + + return { x: point.x, y: startPoint.y }; + }; + + private appendFreehandPoint = (point: Coordinate) => { + const lastPoint = this.freehandPoints.at(-1) ?? null; + if (!lastPoint) { + this.freehandPoints.push(point); + return; + } + + const maxSegmentLength = this.manager.stage.unscale(this.config.MAX_FREEHAND_SEGMENT_LENGTH_PX); + const dx = point.x - lastPoint.x; + const dy = point.y - lastPoint.y; + const distance = Math.hypot(dx, dy); + + if (distance <= maxSegmentLength) { + this.freehandPoints.push(point); + return; + } + + const steps = Math.ceil(distance / maxSegmentLength); + for (let i = 1; i <= steps; i++) { + const t = i / steps; + this.freehandPoints.push({ + x: lastPoint.x + dx * t, + y: lastPoint.y + dy * t, + }); + } + }; + + private simplifyFreehandContour = (points: Coordinate[]): Coordinate[] => { + if (points.length < this.config.FREEHAND_SIMPLIFY_MIN_POINTS) { + return points; + } + + const simplifiedFlatPoints = simplifyFlatNumbersArray( + points.flatMap((point) => [point.x, point.y]), + { + tolerance: this.config.FREEHAND_SIMPLIFY_TOLERANCE, + highestQuality: true, + } + ); + + if (simplifiedFlatPoints.length < 6) { + return points; + } + + const simplifiedPoints = this.flatNumbersToCoords(simplifiedFlatPoints); + if (simplifiedPoints.length < 3) { + return points; + } + + return simplifiedPoints; + }; + + private flatNumbersToCoords = (points: number[]): Coordinate[] => { + const coords: Coordinate[] = []; + for (let i = 0; i < points.length; i += 2) { + const x = points[i]; + const y = points[i + 1]; + if (x === undefined || y === undefined) { + continue; + } + coords.push({ x, y }); + } + return coords; + }; + + private getDragRect = ( + start: Coordinate, + end: Coordinate, + options: { fromCenter: boolean; constrainSquare: boolean } + ): CanvasRectState['rect'] => { + let dx = end.x - start.x; + let dy = end.y - start.y; + + if (options.constrainSquare) { + const size = Math.max(Math.abs(dx), Math.abs(dy)); + const dxSign = getAxisSign(dx, dy); + const dySign = getAxisSign(dy, dx); + dx = dxSign * size; + dy = dySign * size; + } + + const x1 = options.fromCenter ? start.x - dx : start.x; + const y1 = options.fromCenter ? start.y - dy : start.y; + const x2 = options.fromCenter ? start.x + dx : start.x + dx; + const y2 = options.fromCenter ? start.y + dy : start.y + dy; + + return { + x: Math.min(x1, x2), + y: Math.min(y1, y2), + width: Math.abs(x2 - x1), + height: Math.abs(y2 - y1), + }; + }; + + private getActiveEntityAdapter = () => { + if (!this.activeEntityIdentifier) { + return null; + } + return this.manager.getAdapter(this.activeEntityIdentifier); + }; + + private finishDragShapeSession = () => { + const activeEntity = this.getActiveEntityAdapter(); + if (!activeEntity) { + this.resetState(); + this.render(); + return; + } + + const bufferState = activeEntity.bufferRenderer.state; + if ( + bufferState && + (bufferState.type === 'rect' || bufferState.type === 'oval') && + activeEntity.bufferRenderer.hasBuffer() && + bufferState.rect.width > 0 && + bufferState.rect.height > 0 + ) { + activeEntity.bufferRenderer.commitBuffer(); + } else { + activeEntity.bufferRenderer.clearBuffer(); + } + + this.resetState(); + this.render(); + }; + + private clearActiveBuffer = () => { + this.getActiveEntityAdapter()?.bufferRenderer.clearBuffer(); + }; + + private resetState = () => { + this.activeEntityIdentifier = null; + this.shapeId = null; + this.dragStartPoint = null; + this.dragCurrentPoint = null; + this.freehandPoints = []; + this.isDrawingFreehand = false; + this.polygonPoints = []; + this.polygonPointer = null; + this.konva.startPointIndicator.visible(false); + }; +} diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasTool/CanvasToolModule.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasTool/CanvasToolModule.ts index 668ac7be3ba..02d562ff28a 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasTool/CanvasToolModule.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasTool/CanvasToolModule.ts @@ -1,5 +1,6 @@ import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager'; import { CanvasModuleBase } from 'features/controlLayers/konva/CanvasModuleBase'; +import type { AnyObjectState } from 'features/controlLayers/konva/CanvasObject/types'; import { CanvasBboxToolModule } from 'features/controlLayers/konva/CanvasTool/CanvasBboxToolModule'; import { CanvasBrushToolModule } from 'features/controlLayers/konva/CanvasTool/CanvasBrushToolModule'; import { CanvasColorPickerToolModule } from 'features/controlLayers/konva/CanvasTool/CanvasColorPickerToolModule'; @@ -7,7 +8,7 @@ import { CanvasEraserToolModule } from 'features/controlLayers/konva/CanvasTool/ import { CanvasGradientToolModule } from 'features/controlLayers/konva/CanvasTool/CanvasGradientToolModule'; import { CanvasLassoToolModule } from 'features/controlLayers/konva/CanvasTool/CanvasLassoToolModule'; import { CanvasMoveToolModule } from 'features/controlLayers/konva/CanvasTool/CanvasMoveToolModule'; -import { CanvasRectToolModule } from 'features/controlLayers/konva/CanvasTool/CanvasRectToolModule'; +import { CanvasShapeToolModule } from 'features/controlLayers/konva/CanvasTool/CanvasShapeToolModule'; import { CanvasTextToolModule } from 'features/controlLayers/konva/CanvasTool/CanvasTextToolModule'; import { CanvasViewToolModule } from 'features/controlLayers/konva/CanvasTool/CanvasViewToolModule'; import { @@ -63,7 +64,7 @@ export class CanvasToolModule extends CanvasModuleBase { tools: { brush: CanvasBrushToolModule; eraser: CanvasEraserToolModule; - rect: CanvasRectToolModule; + rect: CanvasShapeToolModule; lasso: CanvasLassoToolModule; gradient: CanvasGradientToolModule; colorPicker: CanvasColorPickerToolModule; @@ -123,7 +124,7 @@ export class CanvasToolModule extends CanvasModuleBase { this.tools = { brush: new CanvasBrushToolModule(this), eraser: new CanvasEraserToolModule(this), - rect: new CanvasRectToolModule(this), + rect: new CanvasShapeToolModule(this), lasso: new CanvasLassoToolModule(this), gradient: new CanvasGradientToolModule(this), colorPicker: new CanvasColorPickerToolModule(this), @@ -140,6 +141,7 @@ export class CanvasToolModule extends CanvasModuleBase { this.konva.group.add(this.tools.brush.konva.group); this.konva.group.add(this.tools.eraser.konva.group); + this.konva.group.add(this.tools.rect.konva.group); this.konva.group.add(this.tools.colorPicker.konva.group); this.konva.group.add(this.tools.text.konva.group); this.konva.group.add(this.tools.bbox.konva.group); @@ -151,17 +153,24 @@ export class CanvasToolModule extends CanvasModuleBase { this.subscriptions.add(this.manager.stateApi.createStoreSubscription(selectCanvasSlice, this.render)); this.subscriptions.add( this.$tool.listen((tool, previousTool) => { - // Preserve pointer state during temporary view switching so lasso sessions can freeze/resume on space. - const shouldPreservePointerState = + // Preserve pointer state during temporary view switching so lasso and shapes sessions can freeze/resume on + // space. + const shouldPreserveLassoPointerState = this.$toolBuffer.get() === 'lasso' && this.tools.lasso.hasActiveSession() && ((previousTool === 'lasso' && tool === 'view') || (previousTool === 'view' && tool === 'lasso')); + const shouldPreserveShapesPointerState = + this.$toolBuffer.get() === 'rect' && + this.tools.rect.hasSuspendableSession() && + ((previousTool === 'rect' && tool === 'view') || (previousTool === 'view' && tool === 'rect')); + const shouldPreservePointerState = shouldPreserveLassoPointerState || shouldPreserveShapesPointerState; if (!shouldPreservePointerState) { // On tool switch, reset mouse state this.manager.tool.$isPrimaryPointerDown.set(false); } + this.tools.rect.onToolChanged(); this.tools.lasso.onToolChanged(); void this.tools.text.onToolChanged(); this.render(); @@ -236,6 +245,7 @@ export class CanvasToolModule extends CanvasModuleBase { this.tools.brush.render(); this.tools.eraser.render(); + this.tools.rect.render(); this.tools.colorPicker.render(); this.tools.text.render(); this.tools.bbox.render(); @@ -408,9 +418,8 @@ export class CanvasToolModule extends CanvasModuleBase { const selectedEntity = this.manager.stateApi.getSelectedEntityAdapter(); if ( - selectedEntity?.bufferRenderer.state?.type !== 'rect' && - selectedEntity?.bufferRenderer.state?.type !== 'gradient' && - selectedEntity?.bufferRenderer.hasBuffer() + selectedEntity?.bufferRenderer.hasBuffer() && + !this.shouldDeferEnterLeaveCommit(selectedEntity.bufferRenderer.state) ) { selectedEntity.bufferRenderer.commitBuffer(); return; @@ -464,7 +473,7 @@ export class CanvasToolModule extends CanvasModuleBase { } }; - onStagePointerUp = (e: KonvaEventObject) => { + onStagePointerUp = async (e: KonvaEventObject) => { if (e.target !== this.konva.stage) { return; } @@ -487,7 +496,7 @@ export class CanvasToolModule extends CanvasModuleBase { } else if (tool === 'eraser') { this.tools.eraser.onStagePointerUp(e); } else if (tool === 'rect') { - this.tools.rect.onStagePointerUp(e); + await this.tools.rect.onStagePointerUp(e); } else if (tool === 'lasso') { void this.tools.lasso.onStagePointerUp(e); } else if (tool === 'gradient') { @@ -556,9 +565,8 @@ export class CanvasToolModule extends CanvasModuleBase { if ( selectedEntity && - selectedEntity.bufferRenderer.state?.type !== 'rect' && - selectedEntity.bufferRenderer.state?.type !== 'gradient' && - selectedEntity.bufferRenderer.hasBuffer() + selectedEntity.bufferRenderer.hasBuffer() && + !this.shouldDeferEnterLeaveCommit(selectedEntity.bufferRenderer.state) ) { selectedEntity.bufferRenderer.commitBuffer(); } @@ -601,20 +609,19 @@ export class CanvasToolModule extends CanvasModuleBase { this.render(); }; - /** - * Commit the buffer on window pointer up. - * - * The user may start drawing inside the stage and then release the mouse button outside of the stage. To prevent - * whatever the user was drawing from being lost, or ending up with stale state, we need to commit the buffer - * on window pointer up. - */ - onWindowPointerUp = (_: PointerEvent) => { + onWindowPointerUp = async (_: PointerEvent) => { try { this.$isPrimaryPointerDown.set(false); void this.tools.lasso.onWindowPointerUp(); + await this.tools.rect.onWindowPointerUp(); const selectedEntity = this.manager.stateApi.getSelectedEntityAdapter(); - if (selectedEntity && selectedEntity.bufferRenderer.hasBuffer() && !this.manager.$isBusy.get()) { + if ( + selectedEntity && + selectedEntity.bufferRenderer.hasBuffer() && + !this.manager.$isBusy.get() && + !this.shouldSkipWindowPointerUpCommit(selectedEntity.bufferRenderer.state) + ) { selectedEntity.bufferRenderer.commitBuffer(); } } finally { @@ -622,36 +629,38 @@ export class CanvasToolModule extends CanvasModuleBase { } }; - onWindowPointerMove = (e: PointerEvent) => { + onWindowPointerMove = async (e: PointerEvent) => { const target = e.target; if (target instanceof Node && this.manager.stage.container.contains(target)) { return; } - if (this.$tool.get() !== 'lasso') { - return; - } - - if (!this.getCanDraw()) { - return; - } - - if (!this.$isPrimaryPointerDown.get()) { - return; - } - - if (!this.tools.lasso.hasActiveSession()) { - return; - } - try { this.$lastPointerType.set(e.pointerType); + if (!this.getCanDraw()) { + return; + } + + if (!this.$isPrimaryPointerDown.get()) { + return; + } + if (!this.syncCursorPositionsFromWindowEvent(e)) { return; } - this.tools.lasso.onWindowPointerMove(e); + if (this.$tool.get() === 'rect') { + if (!this.tools.rect.hasActiveDragSession()) { + return; + } + await this.tools.rect.onWindowPointerMove(); + } else if (this.$tool.get() === 'lasso') { + if (!this.tools.lasso.hasActiveSession()) { + return; + } + this.tools.lasso.onWindowPointerMove(e); + } } finally { this.render(); } @@ -688,6 +697,9 @@ export class CanvasToolModule extends CanvasModuleBase { if (e.key === KEY_ESCAPE) { // Cancel shape drawing on escape e.preventDefault(); + if (this.$tool.get() === 'rect') { + this.tools.rect.cancel(); + } if (this.$tool.get() === 'lasso') { this.tools.lasso.reset(); } @@ -714,6 +726,13 @@ export class CanvasToolModule extends CanvasModuleBase { if (currentTool === 'lasso' && this.tools.lasso.hasActiveSession() && this.$isPrimaryPointerDown.get()) { // Start panning immediately if user is already drawing with freehand lasso. this.manager.stage.startDragging(); + } else if ( + currentTool === 'rect' && + this.tools.rect.hasSuspendableSession() && + this.$isPrimaryPointerDown.get() + ) { + // Match lasso: allow an in-progress freehand shapes session to freeze and pan immediately on space. + this.manager.stage.startDragging(); } else { this.$cursorPos.set(null); } @@ -721,6 +740,10 @@ export class CanvasToolModule extends CanvasModuleBase { } if (e.key === KEY_ALT) { + if (this.$tool.get() === 'rect') { + e.preventDefault(); + return; + } // Select the color picker on alt key down e.preventDefault(); e.stopPropagation(); @@ -803,4 +826,20 @@ export class CanvasToolModule extends CanvasModuleBase { } this.konva.group.destroy(); }; + + private shouldDeferEnterLeaveCommit = (state: AnyObjectState | null) => { + if (!state) { + return false; + } + + if (state.type === 'rect' || state.type === 'oval' || state.type === 'gradient') { + return true; + } + + return state.type === 'polygon'; + }; + + private shouldSkipWindowPointerUpCommit = (state: AnyObjectState | null) => { + return Boolean(state?.type === 'polygon' && state.previewPoint); + }; } diff --git a/invokeai/frontend/web/src/features/controlLayers/store/canvasSettingsSlice.ts b/invokeai/frontend/web/src/features/controlLayers/store/canvasSettingsSlice.ts index 202b70e142d..509aefdaaf2 100644 --- a/invokeai/frontend/web/src/features/controlLayers/store/canvasSettingsSlice.ts +++ b/invokeai/frontend/web/src/features/controlLayers/store/canvasSettingsSlice.ts @@ -14,6 +14,7 @@ export type TransformSmoothingMode = z.infer; const zGradientType = z.enum(['linear', 'radial']); const zLassoMode = z.enum(['freehand', 'polygon']); +const zShapeType = z.enum(['rect', 'oval', 'polygon', 'freehand']); const zCanvasSettingsState = z.object({ /** @@ -115,6 +116,10 @@ const zCanvasSettingsState = z.object({ * The gradient tool type. */ gradientType: zGradientType.default('linear'), + /** + * The shape tool type. + */ + shapeType: zShapeType.default('rect'), /** * Whether the gradient tool clips to the drag gesture. */ @@ -152,6 +157,7 @@ const getInitialState = (): CanvasSettingsState => ({ transformSmoothingEnabled: false, transformSmoothingMode: 'bicubic', gradientType: 'linear', + shapeType: 'rect', gradientClipEnabled: true, lassoMode: 'freehand', }); @@ -248,6 +254,9 @@ const slice = createSlice({ settingsGradientTypeChanged: (state, action: PayloadAction) => { state.gradientType = action.payload; }, + settingsShapeTypeChanged: (state, action: PayloadAction) => { + state.shapeType = action.payload; + }, settingsGradientClipToggled: (state) => { state.gradientClipEnabled = !state.gradientClipEnabled; }, @@ -284,6 +293,7 @@ export const { settingsStagingAreaAutoSwitchChanged, settingsFillColorPickerPinnedSet, settingsGradientTypeChanged, + settingsShapeTypeChanged, settingsGradientClipToggled, settingsLassoModeChanged, } = slice.actions; @@ -326,5 +336,6 @@ export const selectTransformSmoothingEnabled = createCanvasSettingsSelector( ); export const selectTransformSmoothingMode = createCanvasSettingsSelector((settings) => settings.transformSmoothingMode); export const selectGradientType = createCanvasSettingsSelector((settings) => settings.gradientType); +export const selectShapeType = createCanvasSettingsSelector((settings) => settings.shapeType); export const selectGradientClipEnabled = createCanvasSettingsSelector((settings) => settings.gradientClipEnabled); export const selectLassoMode = createCanvasSettingsSelector((settings) => settings.lassoMode); diff --git a/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts b/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts index dad599374ce..8c30fc2332e 100644 --- a/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts +++ b/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts @@ -70,7 +70,7 @@ import type { EntityLassoAddedPayload, EntityMovedToPayload, EntityRasterizedPayload, - EntityRectAddedPayload, + EntityShapeAddedPayload, IPMethodV2, T2IAdapterConfig, ZImageControlConfig, @@ -1568,8 +1568,8 @@ const slice = createSlice({ points: eraserLine.type === 'eraser_line' ? simplifyFlatNumbersArray(eraserLine.points) : eraserLine.points, }); }, - entityRectAdded: (state, action: PayloadAction) => { - const { entityIdentifier, rect } = action.payload; + entityShapeAdded: (state, action: PayloadAction) => { + const { entityIdentifier, shape } = action.payload; const entity = selectEntity(state, entityIdentifier); if (!entity) { return; @@ -1577,7 +1577,7 @@ const slice = createSlice({ // TODO(psyche): If we add the object without splatting, the renderer will see it as the same object and not // re-render it (reference equality check). I don't like this behaviour. - entity.objects.push({ ...rect }); + entity.objects.push({ ...shape }); }, entityLassoAdded: (state, action: PayloadAction) => { const { entityIdentifier, lasso } = action.payload; @@ -1888,7 +1888,7 @@ export const { entityRasterized, entityBrushLineAdded, entityEraserLineAdded, - entityRectAdded, + entityShapeAdded, entityLassoAdded, entityGradientAdded, // Raster layer adjustments @@ -2020,7 +2020,7 @@ export const canvasSliceConfig: SliceConfig = { const doNotGroupMatcher = isAnyOf( entityBrushLineAdded, entityEraserLineAdded, - entityRectAdded, + entityShapeAdded, entityLassoAdded, entityGradientAdded ); diff --git a/invokeai/frontend/web/src/features/controlLayers/store/types.ts b/invokeai/frontend/web/src/features/controlLayers/store/types.ts index dff1981b44d..26326f92b69 100644 --- a/invokeai/frontend/web/src/features/controlLayers/store/types.ts +++ b/invokeai/frontend/web/src/features/controlLayers/store/types.ts @@ -259,6 +259,7 @@ const zCanvasRectState = z.object({ type: z.literal('rect'), rect: zRect, color: zRgbaColor, + compositeOperation: z.enum(['source-over', 'destination-out']).default('source-over'), }); export type CanvasRectState = z.infer; @@ -276,6 +277,28 @@ const zCanvasLassoState = z.object({ }); export type CanvasLassoState = z.infer; +const zCanvasOvalState = z.object({ + id: zId, + type: z.literal('oval'), + rect: zRect, + color: zRgbaColor, + compositeOperation: z.enum(['source-over', 'destination-out']).default('source-over'), +}); +export type CanvasOvalState = z.infer; + +const zCanvasPolygonState = z.object({ + id: zId, + type: z.literal('polygon'), + points: zPoints, + color: zRgbaColor, + compositeOperation: z.enum(['source-over', 'destination-out']).default('source-over'), + previewPoint: zCoordinate.optional(), +}); +export type CanvasPolygonState = z.infer; + +const zCanvasShapeState = z.union([zCanvasRectState, zCanvasOvalState, zCanvasPolygonState]); +type CanvasShapeState = z.infer; + // Gradient state includes clip metadata so the tool can optionally clip to drag gesture. const zCanvasLinearGradientState = z.object({ id: zId, @@ -324,7 +347,7 @@ const zCanvasObjectState = z.union([ zCanvasImageState, zCanvasBrushLineState, zCanvasEraserLineState, - zCanvasRectState, + zCanvasShapeState, zCanvasLassoState, zCanvasBrushLineWithPressureState, zCanvasEraserLineWithPressureState, @@ -1008,8 +1031,8 @@ export type EntityBrushLineAddedPayload = EntityIdentifierPayload<{ export type EntityEraserLineAddedPayload = EntityIdentifierPayload<{ eraserLine: CanvasEraserLineState | CanvasEraserLineWithPressureState; }>; -export type EntityRectAddedPayload = EntityIdentifierPayload<{ rect: CanvasRectState }>; export type EntityLassoAddedPayload = EntityIdentifierPayload<{ lasso: CanvasLassoState }>; +export type EntityShapeAddedPayload = EntityIdentifierPayload<{ shape: CanvasShapeState }>; export type EntityGradientAddedPayload = EntityIdentifierPayload<{ gradient: CanvasGradientState }>; export type EntityRasterizedPayload = EntityIdentifierPayload<{ imageObject: CanvasImageState;