Skip to content
Merged
Show file tree
Hide file tree
Changes from 35 commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
c49fee9
Feat(Canvas): Replace Rectangle tool with multifunctional Shapes tool.
DustyShoe Mar 8, 2026
bc68c59
Fix: Tweaked icon size on top bar
DustyShoe Mar 15, 2026
708ba51
Fix: also tweaked SVGs for Gradient tool to align with unified 16px i…
DustyShoe Mar 15, 2026
890d151
Feat: added freehand shape
DustyShoe Mar 25, 2026
58bdb0d
fix(canvas): remove duplicate lasso payload export after rebase
DustyShoe Apr 14, 2026
9ad8225
Merge branch 'invoke-ai:main' into Feat(Canvas)/Replace-Rectangle-too…
DustyShoe Apr 14, 2026
dd0dd20
Merge branch 'invoke-ai:main' into Feat(Canvas)/Replace-Rectangle-too…
DustyShoe Apr 14, 2026
d282020
Merge branch 'invoke-ai:main' into Feat(Canvas)/Replace-Rectangle-too…
DustyShoe Apr 14, 2026
9a696b1
Merge branch 'invoke-ai:main' into Feat(Canvas)/Replace-Rectangle-too…
DustyShoe Apr 16, 2026
9102fea
Merge branch 'invoke-ai:main' into Feat(Canvas)/Replace-Rectangle-too…
DustyShoe Apr 20, 2026
4c25e9b
`fix(canvas): clear polygon preview stroke on commit`
DustyShoe Apr 21, 2026
d787f15
Merge branch 'invoke-ai:main' into Feat(Canvas)/Replace-Rectangle-too…
DustyShoe Apr 21, 2026
4e18f1a
chore: remove temporary codex artifact
DustyShoe Apr 21, 2026
2b6f11e
chore: format with prettier
DustyShoe Apr 21, 2026
15c9252
Merge branch 'main' into Feat(Canvas)/Replace-Rectangle-tool-with-Sha…
DustyShoe Apr 22, 2026
4bd2593
fix(canvas): preserve shapes sessions across view switch
DustyShoe Apr 22, 2026
29ae589
chore: format with prettier
DustyShoe Apr 22, 2026
6d454d6
Merge branch 'main' into Feat(Canvas)/Replace-Rectangle-tool-with-Sha…
DustyShoe Apr 23, 2026
b1bc708
add: constrain rectangles to squares with shift
DustyShoe Apr 24, 2026
b877fc5
Merge branch 'main' into Feat(Canvas)/Replace-Rectangle-tool-with-Sha…
DustyShoe Apr 26, 2026
410f7c7
Merge branch 'main' into Feat(Canvas)/Replace-Rectangle-tool-with-Sha…
DustyShoe Apr 27, 2026
6affa7d
Merge branch 'main' into Feat(Canvas)/Replace-Rectangle-tool-with-Sha…
DustyShoe Apr 28, 2026
e157110
Merge branch 'main' into Feat(Canvas)/Replace-Rectangle-tool-with-Sha…
DustyShoe Apr 30, 2026
3b39f54
Merge branch 'main' into Feat(Canvas)/Replace-Rectangle-tool-with-Sha…
DustyShoe May 1, 2026
4eb10ba
Merge branch 'main' into Feat(Canvas)/Replace-Rectangle-tool-with-Sha…
Pfannkuchensack May 5, 2026
9c338f2
Merge branch 'main' into Feat(Canvas)/Replace-Rectangle-tool-with-Sha…
DustyShoe May 6, 2026
1b0a379
fix(canvas): refine shapes space and alt interactions
DustyShoe May 6, 2026
4a1d12c
fix(canvas): preserve polygon sessions across temporary tool switches
DustyShoe May 6, 2026
47af75b
Merge branch 'main' into Feat(Canvas)/Replace-Rectangle-tool-with-Sha…
DustyShoe May 7, 2026
712000e
Merge branch 'main' into Feat(Canvas)/Replace-Rectangle-tool-with-Sha…
DustyShoe May 8, 2026
8f0ac11
Merge branch 'main' into Feat(Canvas)/Replace-Rectangle-tool-with-Sha…
Pfannkuchensack May 8, 2026
c0823c5
refactor(i18n): reuse lasso labels for shapes polygon modes
DustyShoe May 11, 2026
7f41786
fix(i18n): merge shapes locale additions with modifier hints
DustyShoe May 11, 2026
a00fb5a
Merge branch 'main' into Feat(Canvas)/Replace-Rectangle-tool-with-Sha…
DustyShoe May 11, 2026
ae6f858
feat(canvas): add shape-specific modifier hints and docs
DustyShoe May 11, 2026
ecd7293
fix(canvas): refine shape modifier hints and toolbar overflow
DustyShoe May 11, 2026
9068da7
Merge branch 'main' into Feat(Canvas)/Replace-Rectangle-tool-with-Sha…
DustyShoe May 11, 2026
e5a0f85
fix(canvas): keep toolbar overflow clipped to the right
DustyShoe May 11, 2026
3a6a160
Merge branch 'main' into Feat(Canvas)/Replace-Rectangle-tool-with-Sha…
DustyShoe May 12, 2026
d8a8e0e
Merge branch 'main' into Feat(Canvas)/Replace-Rectangle-tool-with-Sha…
DustyShoe May 13, 2026
69ba743
fix: Escape while panning while drawing should exit shape tool
dunkeroni May 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 96 additions & 0 deletions docs/src/content/docs/features/shapes-tool.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
---
title: Shapes Tool
description: Learn how to draw filled shapes on raster and inpaint mask layers with the Shapes tool.
lastUpdated: 2026-05-11
---

import { Card, CardGrid } from '@astrojs/starlight/components';

The Shapes tool is a general-purpose filled-shape drawing tool for the canvas. It replaces the old Rectangle tool and
adds four shape modes under a single toolbar button:

- **Rect**
- **Oval**
- **Polygon**
- **Freehand**

You can activate the Shapes tool from the canvas toolbar or with the default hotkey <kbd>U</kbd>.

## Where Shapes Draws

Shapes always draws into the **active raster target**:

- On a regular raster layer, Shapes adds filled pixels to that layer.
- On an active inpaint mask layer, Shapes draws directly into the mask.

:::note
Shapes overlaps with some Lasso workflows on mask layers, but the tools are not identical. Lasso is still the more
specialized masking tool and can create a new mask layer automatically when one does not already exist.
:::

## Common Behavior

- Shapes preview live while you draw.
- The fill color uses the current active color.
- The active color's alpha is respected when adding pixels.
- Hold <kbd>Ctrl</kbd> on Windows/Linux or <kbd>Cmd</kbd> on macOS to switch to **subtractive** mode and cut pixels
out of the active layer.
- In subtractive mode, alpha is ignored and the shape fully clears pixels.
- Press <kbd>Esc</kbd> to cancel the current shape session.

:::tip
When subtractive mode is active, the canvas cursor shows a small minus badge so you can tell at a glance that the next
shape will erase instead of fill.
:::

## Shape Modes

<CardGrid>
<Card title="Rect">
Drag to draw a rectangle. Hold <kbd>Shift</kbd> to constrain to a square. Hold <kbd>Alt</kbd> to draw from the
center instead of from a corner.
</Card>
<Card title="Oval">
Drag to draw an ellipse. Hold <kbd>Shift</kbd> to constrain to a perfect circle. Hold <kbd>Alt</kbd> to draw from
the center.
</Card>
<Card title="Polygon">
Click to place vertices. Click the first point to close and commit the shape. Hold <kbd>Shift</kbd> to snap the
pending edge to horizontal, vertical, and 45 degree angles.
</Card>
<Card title="Freehand">
Click and drag to sketch a filled freehand contour. Release the pointer to commit the shape.
</Card>
</CardGrid>

## Moving and Panning During Drawing

The Shapes tool supports different <kbd>Space</kbd> behavior depending on the current mode:

- **Rect / Oval:** While the pointer is still down, hold <kbd>Space</kbd> to move the uncommitted shape instead of
resizing it. Release <kbd>Space</kbd> to continue resizing.
- **Polygon / Freehand:** Hold <kbd>Space</kbd> during an active session to pan the viewport without discarding the
unfinished shape.

This is especially useful when drawing large shapes that extend beyond the current viewport.

## Color Picking While Using Shapes

The <kbd>Alt</kbd> key behaves differently depending on the active Shapes mode:

- **Rect / Oval:** Before you start dragging, <kbd>Alt</kbd> can be used for the temporary color-picker quick-switch.
Once a drag is active, <kbd>Alt</kbd> is reserved for drawing from the center.
- **Polygon:** <kbd>Alt</kbd> remains available for the temporary color-picker quick-switch between vertex placements.
- **Freehand:** <kbd>Alt</kbd> is available before the stroke starts, but not during an active stroke.

## Practical Examples

- Use **Rect** or **Oval** to block in clean mask regions quickly.
- Use **Polygon** when you need straight edges and deliberate corner placement.
- Use **Freehand** for irregular organic regions.
- Use **subtractive mode** to cut holes back out of an existing raster or mask layer.

## Summary

The Shapes tool is the fastest way to add filled geometric or freeform regions to canvas layers. Use it for structured
fills, mask authoring, and precise subtractive edits without switching away from the current raster target.
10 changes: 8 additions & 2 deletions invokeai/frontend/web/public/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -728,8 +728,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",
Expand Down Expand Up @@ -2873,6 +2873,10 @@
"polygon": "Polygon",
"polygonHint": "Click to add points, click the first point to close."
},
"shape": {
"rect": "Rect",
"oval": "Oval"
},
"modifierHints": {
"keys": {
"control": "Ctrl",
Expand All @@ -2888,6 +2892,7 @@
},
"labels": {
"pan": "Pan",
"moveShape": "Move shape",
"pickColor": "Pick color",
"straightLine": "Straight line",
"resizeBrush": "Resize brush",
Expand All @@ -2909,6 +2914,7 @@
"tool": {
"brush": "Brush",
"eraser": "Eraser",
"shapes": "Shapes",
"rectangle": "Rectangle",
"lasso": "Lasso",
"gradient": "Gradient",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,23 +32,23 @@ export const GradientLinearIcon = memo(() => {
const id = useId();
const gradientId = `${id}-gradient-linear-diagonal`;
return (
<Box as="svg" viewBox="0 0 24 24" boxSize="22px" aria-hidden focusable={false} display="block">
<Box as="svg" viewBox="0 0 20 20" aria-hidden focusable={false} display="block">
<defs>
<linearGradient id={gradientId} x1="0" y1="1" x2="1" y2="0">
<stop offset="0%" stopColor="currentColor" stopOpacity="0.0" />
<stop offset="100%" stopColor="currentColor" stopOpacity="0.85" />
</linearGradient>
</defs>
<rect
x="4"
y="4"
x="2"
y="2"
width="16"
height="16"
rx="2"
fill={`url(#${gradientId})`}
stroke="currentColor"
strokeOpacity="0.9"
strokeWidth="1"
strokeWidth="1.2"
/>
</Box>
);
Expand All @@ -59,21 +59,21 @@ export const GradientRadialIcon = memo(() => {
const id = useId();
const gradientId = `${id}-gradient-radial`;
return (
<Box as="svg" viewBox="0 0 24 24" boxSize="22px" aria-hidden focusable={false} display="block">
<Box as="svg" viewBox="0 0 20 20" aria-hidden focusable={false} display="block">
<defs>
<radialGradient id={gradientId} cx="0.5" cy="0.5" r="0.5">
<stop offset="0%" stopColor="currentColor" stopOpacity="0.0" />
<stop offset="100%" stopColor="currentColor" stopOpacity="0.85" />
</radialGradient>
</defs>
<circle
cx="12"
cy="12"
cx="10"
cy="10"
r="8"
fill={`url(#${gradientId})`}
stroke="currentColor"
strokeOpacity="0.9"
strokeWidth="1"
strokeWidth="1.2"
/>
</Box>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -18,7 +18,7 @@ export const ToolChooser: React.FC = () => {
<ButtonGroup isAttached orientation="vertical">
<ToolBrushButton />
<ToolEraserButton />
<ToolRectButton />
<ToolShapesButton />
<ToolGradientButton />
<ToolTextButton />
<ToolLassoButton />
Expand Down
Original file line number Diff line number Diff line change
@@ -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.lasso.polygon', { defaultValue: 'Polygon' });
const freehandLabel = t('controlLayers.lasso.freehand', { defaultValue: 'Freehand' });

return (
<ButtonGroup isAttached size="sm">
<Tooltip label={rectLabel}>
<IconButton
aria-label={rectLabel}
icon={<PiRectangleBold />}
colorScheme={shapeType === 'rect' ? 'invokeBlue' : 'base'}
variant="solid"
onClick={onRectClick}
/>
</Tooltip>
<Tooltip label={ovalLabel}>
<IconButton
aria-label={ovalLabel}
icon={<PiCircleBold />}
colorScheme={shapeType === 'oval' ? 'invokeBlue' : 'base'}
variant="solid"
onClick={onOvalClick}
/>
</Tooltip>
<Tooltip label={polygonLabel}>
<IconButton
aria-label={polygonLabel}
icon={<PiPolygonBold />}
colorScheme={shapeType === 'polygon' ? 'invokeBlue' : 'base'}
variant="solid"
onClick={onPolygonClick}
/>
</Tooltip>
<Tooltip label={freehandLabel}>
<IconButton
aria-label={freehandLabel}
icon={<PiScribbleLoopBold />}
colorScheme={shapeType === 'freehand' ? 'invokeBlue' : 'base'}
variant="solid"
onClick={onFreehandClick}
/>
</Tooltip>
</ButtonGroup>
);
});

ToolShapeTypeToggle.displayName = 'ToolShapeTypeToggle';
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<Tooltip label={`${t('controlLayers.tool.rectangle')} (U)`} placement="end">
<Tooltip label={`${label} (U)`} placement="end">
<IconButton
aria-label={`${t('controlLayers.tool.rectangle')} (U)`}
icon={<PiRectangleBold />}
aria-label={`${label} (U)`}
icon={<PiShapesBold />}
colorScheme={isSelected ? 'invokeBlue' : 'base'}
variant="solid"
onClick={selectRect}
onClick={selectShapes}
/>
</Tooltip>
);
});

ToolRectButton.displayName = 'ToolRectButton';
ToolShapesButton.displayName = 'ToolShapesButton';
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -35,6 +36,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');
Expand All @@ -56,9 +58,25 @@ export const CanvasToolbar = memo(() => {
useCanvasToggleBboxHotkey();

return (
<Flex w="full" gap={2} alignItems="center" px={2}>
<Flex
w="full"
gap={2}
alignItems="center"
px={2}
sx={{
'& svg': {
width: '16px',
height: '16px',
},
}}
>
<ToolOptionsRowContainer gap={4} alignItems="center" h="full">
<ToolFillColorPicker />
{isShapeSelected && (
<Box ms={2} mt="-2px" display="flex" alignItems="center" gap={2}>
<ToolShapeTypeToggle />
</Box>
)}
Comment thread
DustyShoe marked this conversation as resolved.
{isGradientSelected && (
<Box ms={2} mt="-2px" display="flex" alignItems="center" gap={2}>
<ToolGradientClipToggle />
Expand Down
Loading
Loading