Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
1569058
feat(canvas): add path tool and vector layers
DustyShoe May 17, 2026
9089cbc
fix(frontend): resolve canvas vector layer lint and build errors
DustyShoe May 17, 2026
e2cfb0f
Merge branch 'main' into feat(canvas)--Splines-tool
DustyShoe May 19, 2026
129a16d
Merge branch 'main' into feat(canvas)--Splines-tool
DustyShoe May 22, 2026
18e8fd2
feat(canvas): improve spline editing and add vector-to-vector merge down
DustyShoe May 23, 2026
19012a5
Merge branch 'invoke-ai:main' into feat(canvas)--Splines-tool
DustyShoe May 27, 2026
846ff4d
Merge branch 'main' into feat(canvas)--Splines-tool
DustyShoe May 28, 2026
0fb1d89
Merge branch 'invoke-ai:main' into feat(canvas)--Splines-tool
DustyShoe Jun 13, 2026
696e49e
Merge branch 'main' into feat(canvas)--Splines-tool
DustyShoe Jun 24, 2026
26c38c5
Merge branch 'invoke-ai:main' into feat(canvas)--Splines-tool
DustyShoe Jul 5, 2026
46f4365
Merge branch 'main' into feat(canvas)--Splines-tool
DustyShoe Jul 5, 2026
9ecb713
Add explicit Corner, Smooth, and Symmetric modes for vector path points.
DustyShoe Jul 6, 2026
e51657c
Merge branch 'main' into feat(canvas)--Splines-tool
DustyShoe Jul 6, 2026
a020488
Merge branch 'invoke-ai:main' into feat(canvas)--Splines-tool
DustyShoe Jul 12, 2026
bfba08c
feat(canvas): expand spline editing and vector layer transforms
DustyShoe Jul 12, 2026
87129b9
Merge origin/main into feat(canvas)--Splines-tool
DustyShoe Jul 22, 2026
8c5fed8
Merge branch 'main' into feat(canvas)--Splines-tool
DustyShoe Jul 24, 2026
caa09fb
Merge upstream/main into feat(canvas)--Splines-tool
DustyShoe Jul 27, 2026
87f3525
Merge branch 'main' into feat(canvas)--Splines-tool
DustyShoe Jul 28, 2026
81335be
Merge branch 'main' into feat(canvas)--Splines-tool
DustyShoe Jul 29, 2026
e765318
Merge branch 'main' into feat(canvas)--Splines-tool
DustyShoe Jul 30, 2026
380795a
Merge branch 'main' into feat(canvas)--Splines-tool
DustyShoe Jul 31, 2026
d6e1354
Merge branch 'main' into feat(canvas)--Splines-tool
DustyShoe Jul 31, 2026
b7a1dec
Merge branch 'main' into feat(canvas)--Splines-tool
DustyShoe Jul 31, 2026
7a458ca
Merge branch 'main' into feat(canvas)--Splines-tool
DustyShoe Aug 1, 2026
af4cb09
Merge branch 'main' into feat(canvas)--Splines-tool
DustyShoe Aug 1, 2026
abe6f6e
Merge branch 'invoke-ai:main' into feat(canvas)--Splines-tool
DustyShoe Aug 3, 2026
73e1cb3
Merge branch 'invoke-ai:main' into feat(canvas)--Splines-tool
DustyShoe Aug 3, 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
28 changes: 27 additions & 1 deletion invokeai/frontend/web/public/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -769,6 +769,10 @@
"title": "Shapes Tool",
"desc": "Select the shapes tool."
},
"selectPathTool": {
"title": "Path Tool",
"desc": "Select the path tool."
},
"selectLassoTool": {
"title": "Lasso Tool",
"desc": "Select the lasso tool."
Expand Down Expand Up @@ -2975,12 +2979,14 @@
"addImageNoise": "Add $t(controlLayers.imageNoise)",
"addRasterLayer": "Add $t(controlLayers.rasterLayer)",
"addControlLayer": "Add $t(controlLayers.controlLayer)",
"addVectorLayer": "Add $t(controlLayers.vectorLayer)",
"addInpaintMask": "Add $t(controlLayers.inpaintMask)",
"addRegionalGuidance": "Add $t(controlLayers.regionalGuidance)",
"addGlobalReferenceImage": "Add $t(controlLayers.globalReferenceImage)",
"addDenoiseLimit": "Add $t(controlLayers.denoiseLimit)",
"rasterLayer": "Raster Layer",
"controlLayer": "Control Layer",
"vectorLayer": "Vector Layer",
"inpaintMask": "Inpaint Mask",
"invertMask": "Invert Mask",
"invertRegion": "Invert Region",
Expand Down Expand Up @@ -3031,6 +3037,8 @@
"rasterLayer_withCount_other": "Raster Layers",
"controlLayer_withCount_one": "$t(controlLayers.controlLayer)",
"controlLayer_withCount_other": "Control Layers",
"vectorLayer_withCount_one": "$t(controlLayers.vectorLayer)",
"vectorLayer_withCount_other": "Vector Layers",
"inpaintMask_withCount_one": "$t(controlLayers.inpaintMask)",
"inpaintMask_withCount_other": "Inpaint Masks",
"regionalGuidance_withCount_one": "$t(controlLayers.regionalGuidance)",
Expand All @@ -3040,11 +3048,13 @@
"opacity": "Opacity",
"regionalGuidance_withCount_hidden": "Regional Guidance ({{count}} hidden)",
"controlLayers_withCount_hidden": "Control Layers ({{count}} hidden)",
"vectorLayers_withCount_hidden": "Vector Layers ({{count}} hidden)",
"rasterLayers_withCount_hidden": "Raster Layers ({{count}} hidden)",
"globalReferenceImages_withCount_hidden": "Global Reference Images ({{count}} hidden)",
"inpaintMasks_withCount_hidden": "Inpaint Masks ({{count}} hidden)",
"regionalGuidance_withCount_visible": "Regional Guidance ({{count}})",
"controlLayers_withCount_visible": "Control Layers ({{count}})",
"vectorLayers_withCount_visible": "Vector Layers ({{count}})",
"rasterLayers_withCount_visible": "Raster Layers ({{count}})",
"globalReferenceImages_withCount_visible": "Global Reference Images ({{count}})",
"inpaintMasks_withCount_visible": "Inpaint Masks ({{count}})",
Expand All @@ -3062,6 +3072,7 @@
"copyRegionalGuidanceTo": "Copy $t(controlLayers.regionalGuidance) To",
"newRasterLayer": "New $t(controlLayers.rasterLayer)",
"newControlLayer": "New $t(controlLayers.controlLayer)",
"newVectorLayer": "New $t(controlLayers.vectorLayer)",
"newInpaintMask": "New $t(controlLayers.inpaintMask)",
"newRegionalGuidance": "New $t(controlLayers.regionalGuidance)",
"pasteTo": "Paste To",
Expand Down Expand Up @@ -3178,6 +3189,14 @@
"radial": "Radial",
"clip": "Clip Gradient"
},
"vectorEdit": {
"title": "Edit Vector Layer",
"pointType": "Point Type",
"corner": "Corner",
"smooth": "Smooth",
"symmetric": "Symmetric",
"traceAll": "Trace All Paths"
},
"lasso": {
"freehand": "Freehand",
"polygon": "Polygon",
Expand Down Expand Up @@ -3214,17 +3233,24 @@
"scaleFromCenter": "Scale from center",
"fineGrid": "Fine grid",
"commitText": "Commit",
"finishPath": "Finish path",
"applyPathEdit": "Apply edits",
"newLine": "New line",
"cancelText": "Cancel",
"cancelPath": "Cancel path",
"cancelPathEdit": "Cancel edits",
"dragText": "Drag text",
"snapRotation": "Snap rotation",
"nudgeSelection": "Nudge selection"
"nudgeSelection": "Nudge selection",
"insertPathPoint": "Insert point",
"deletePathPoint": "Delete point"
}
},
"tool": {
"brush": "Brush",
"eraser": "Eraser",
"shapes": "Shapes",
"path": "Path",
"rectangle": "Rectangle",
"lasso": "Lasso",
"gradient": "Gradient",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
useAddNewRegionalGuidanceWithARefImage,
useAddRasterLayer,
useAddRegionalGuidance,
useAddVectorLayer,
} from 'features/controlLayers/hooks/addLayerHooks';
import { useIsEntityTypeEnabled } from 'features/controlLayers/hooks/useIsEntityTypeEnabled';
import { memo } from 'react';
Expand All @@ -18,10 +19,12 @@ export const CanvasAddEntityButtons = memo(() => {
const addRegionalGuidance = useAddRegionalGuidance();
const addRasterLayer = useAddRasterLayer();
const addControlLayer = useAddControlLayer();
const addVectorLayer = useAddVectorLayer();
const addRegionalReferenceImage = useAddNewRegionalGuidanceWithARefImage();
const isRegionalGuidanceEnabled = useIsEntityTypeEnabled('regional_guidance');
const isControlLayerEnabled = useIsEntityTypeEnabled('control_layer');
const isInpaintLayerEnabled = useIsEntityTypeEnabled('inpaint_mask');
const isVectorLayerEnabled = useIsEntityTypeEnabled('vector_layer');

return (
<Flex w="full" h="full" justifyContent="center" gap={4}>
Expand Down Expand Up @@ -79,6 +82,16 @@ export const CanvasAddEntityButtons = memo(() => {
{t('controlLayers.controlLayer')}
</Button>
</InformationalPopover>
<Button
size="sm"
variant="ghost"
justifyContent="flex-start"
leftIcon={<PiPlusBold />}
onClick={addVectorLayer}
isDisabled={!isVectorLayerEnabled}
>
{t('controlLayers.vectorLayer')}
</Button>
<InformationalPopover feature="rasterLayer">
<Button
size="sm"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { InpaintMaskMenuItems } from 'features/controlLayers/components/InpaintM
import { RasterLayerMenuItems } from 'features/controlLayers/components/RasterLayer/RasterLayerMenuItems';
import { IPAdapterMenuItems } from 'features/controlLayers/components/RefImage/IPAdapterMenuItems';
import { RegionalGuidanceMenuItems } from 'features/controlLayers/components/RegionalGuidance/RegionalGuidanceMenuItems';
import { VectorLayerMenuItems } from 'features/controlLayers/components/VectorLayer/VectorLayerMenuItems';
import { CanvasEntityStateGate } from 'features/controlLayers/contexts/CanvasEntityStateGate';
import {
EntityIdentifierContext,
Expand All @@ -29,6 +30,9 @@ const CanvasContextMenuSelectedEntityMenuItemsContent = memo(() => {
if (entityIdentifier.type === 'inpaint_mask') {
return <InpaintMaskMenuItems />;
}
if (entityIdentifier.type === 'vector_layer') {
return <VectorLayerMenuItems />;
}
if (entityIdentifier.type === 'regional_guidance') {
return <RegionalGuidanceMenuItems />;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,7 @@ export const CanvasEntityGroupList = memo(({ isSelected, type, children, entityI
<Spacer />
</Flex>
{type === 'raster_layer' && <RasterLayerExportPSDButton />}
<CanvasEntityMergeVisibleButton type={type} />
{type !== 'vector_layer' && <CanvasEntityMergeVisibleButton type={type} />}
<CanvasEntityTypeIsHiddenToggle type={type} />
<CanvasEntityAddOfTypeButton type={type} />
</Flex>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { ControlLayerEntityList } from 'features/controlLayers/components/Contro
import { InpaintMaskList } from 'features/controlLayers/components/InpaintMask/InpaintMaskList';
import { RasterLayerEntityList } from 'features/controlLayers/components/RasterLayer/RasterLayerEntityList';
import { RegionalGuidanceEntityList } from 'features/controlLayers/components/RegionalGuidance/RegionalGuidanceEntityList';
import { VectorLayerEntityList } from 'features/controlLayers/components/VectorLayer/VectorLayerEntityList';
import { memo } from 'react';

export const CanvasEntityList = memo(() => {
Expand All @@ -12,6 +13,7 @@ export const CanvasEntityList = memo(() => {
<Flex flexDir="column" gap={2} data-testid="control-layers-layer-list" w="full" h="full">
<InpaintMaskList />
<RegionalGuidanceEntityList />
<VectorLayerEntityList />
<ControlLayerEntityList />
<RasterLayerEntityList />
</Flex>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
useAddNewRegionalGuidanceWithARefImage,
useAddRasterLayer,
useAddRegionalGuidance,
useAddVectorLayer,
} from 'features/controlLayers/hooks/addLayerHooks';
import { useCanvasIsBusy } from 'features/controlLayers/hooks/useCanvasIsBusy';
import { useIsEntityTypeEnabled } from 'features/controlLayers/hooks/useIsEntityTypeEnabled';
Expand All @@ -20,9 +21,11 @@ export const EntityListGlobalActionBarAddLayerMenu = memo(() => {
const addRegionalReferenceImage = useAddNewRegionalGuidanceWithARefImage();
const addRasterLayer = useAddRasterLayer();
const addControlLayer = useAddControlLayer();
const addVectorLayer = useAddVectorLayer();
const isRegionalGuidanceEnabled = useIsEntityTypeEnabled('regional_guidance');
const isControlLayerEnabled = useIsEntityTypeEnabled('control_layer');
const isInpaintLayerEnabled = useIsEntityTypeEnabled('inpaint_mask');
const isVectorLayerEnabled = useIsEntityTypeEnabled('vector_layer');

return (
<Menu>
Expand Down Expand Up @@ -53,6 +56,9 @@ export const EntityListGlobalActionBarAddLayerMenu = memo(() => {
<MenuItem icon={<PiPlusBold />} onClick={addControlLayer} isDisabled={!isControlLayerEnabled}>
{t('controlLayers.controlLayer')}
</MenuItem>
<MenuItem icon={<PiPlusBold />} onClick={addVectorLayer} isDisabled={!isVectorLayerEnabled}>
{t('controlLayers.vectorLayer')}
</MenuItem>
<MenuItem icon={<PiPlusBold />} onClick={addRasterLayer}>
{t('controlLayers.rasterLayer')}
</MenuItem>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +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 { ToolPathButton } from 'features/controlLayers/components/Tool/ToolPathButton';
import { ToolShapesButton } from 'features/controlLayers/components/Tool/ToolShapesButton';
import { ToolTextButton } from 'features/controlLayers/components/Tool/ToolTextButton';
import React from 'react';
Expand All @@ -19,6 +20,7 @@ export const ToolChooser: React.FC = () => {
<ToolBrushButton />
<ToolEraserButton />
<ToolShapesButton />
<ToolPathButton />
<ToolGradientButton />
<ToolTextButton />
<ToolLassoButton />
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { IconButton, Tooltip } from '@invoke-ai/ui-library';
import { useSelectTool, useToolIsSelected } from 'features/controlLayers/components/Tool/hooks';
import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { PiBezierCurveBold } from 'react-icons/pi';

export const ToolPathButton = memo(() => {
const { t } = useTranslation();
const isSelected = useToolIsSelected('path');
const selectPath = useSelectTool('path');
const label = t('controlLayers.tool.path', { defaultValue: 'Path' });

useRegisteredHotkeys({
id: 'selectPathTool',
category: 'canvas',
callback: selectPath,
options: { enabled: !isSelected },
dependencies: [isSelected, selectPath],
});

return (
<Tooltip label={`${label} (P)`} placement="end">
<IconButton
aria-label={`${label} (P)`}
icon={<PiBezierCurveBold />}
colorScheme={isSelected ? 'invokeBlue' : 'base'}
variant="solid"
onClick={selectPath}
/>
</Tooltip>
);
});

ToolPathButton.displayName = 'ToolPathButton';
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ const TransformContent = memo(({ adapter }: { adapter: CanvasEntityAdapter }) =>
<CanvasOperationIsolatedLayerPreviewSwitch />
</Flex>

<TransformSmoothingControls />
{adapter.state.type !== 'vector_layer' && <TransformSmoothingControls />}

<TransformFitToBboxButtons adapter={adapter} />

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { Spacer } from '@invoke-ai/ui-library';
import { CanvasEntityContainer } from 'features/controlLayers/components/CanvasEntityList/CanvasEntityContainer';
import { CanvasEntityHeader } from 'features/controlLayers/components/common/CanvasEntityHeader';
import { CanvasEntityHeaderCommonActions } from 'features/controlLayers/components/common/CanvasEntityHeaderCommonActions';
import { CanvasEntityPreviewImage } from 'features/controlLayers/components/common/CanvasEntityPreviewImage';
import { CanvasEntityEditableTitle } from 'features/controlLayers/components/common/CanvasEntityTitleEdit';
import { CanvasEntityStateGate } from 'features/controlLayers/contexts/CanvasEntityStateGate';
import { VectorLayerAdapterGate } from 'features/controlLayers/contexts/EntityAdapterContext';
import { EntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext';
import type { CanvasEntityIdentifier } from 'features/controlLayers/store/types';
import { memo, useMemo } from 'react';

type Props = {
id: string;
};

export const VectorLayer = memo(({ id }: Props) => {
const entityIdentifier = useMemo<CanvasEntityIdentifier<'vector_layer'>>(() => ({ id, type: 'vector_layer' }), [id]);

return (
<EntityIdentifierContext.Provider value={entityIdentifier}>
<VectorLayerAdapterGate>
<CanvasEntityStateGate entityIdentifier={entityIdentifier}>
<CanvasEntityContainer>
<CanvasEntityHeader>
<CanvasEntityPreviewImage />
<CanvasEntityEditableTitle />
<Spacer />
<CanvasEntityHeaderCommonActions />
</CanvasEntityHeader>
</CanvasEntityContainer>
</CanvasEntityStateGate>
</VectorLayerAdapterGate>
</EntityIdentifierContext.Provider>
);
});

VectorLayer.displayName = 'VectorLayer';
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import {
Button,
ButtonGroup,
Flex,
FormControl,
FormLabel,
Heading,
Radio,
RadioGroup,
Spacer,
Text,
} from '@invoke-ai/ui-library';
import { useStore } from '@nanostores/react';
import { useAppSelector } from 'app/store/storeHooks';
import { useCanvasManager } from 'features/controlLayers/contexts/CanvasManagerProviderGate';
import { selectCanvasSlice } from 'features/controlLayers/store/selectors';
import type { BezierPointType } from 'features/controlLayers/util/bezierPath';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';

const BEZIER_POINT_TYPES = ['corner', 'smooth', 'symmetric'] as const satisfies readonly BezierPointType[];

const isBezierPointType = (value: string): value is BezierPointType => {
return BEZIER_POINT_TYPES.includes(value as BezierPointType);
};

export const VectorLayerEditFooter = memo(() => {
const { t } = useTranslation();
const canvasManager = useCanvasManager();
const editSession = useStore(canvasManager.tool.tools.path.$editSession);
const activePointType = useAppSelector((state) => {
if (!editSession || !editSession.activePathId || editSession.activePointIndex === null) {
return null;
}

const canvas = selectCanvasSlice(state);
const layer = canvas.vectorLayers.entities.find((entity) => entity.id === editSession.entityIdentifier.id);
const path = layer?.paths.find((path) => path.id === editSession.activePathId);
return path?.points[editSession.activePointIndex]?.type ?? null;
});

const onPointTypeChange = useCallback(
(value: string) => {
if (!isBezierPointType(value)) {
return;
}
canvasManager.tool.tools.path.setActivePointType(value);
},
[canvasManager.tool.tools.path]
);

if (!editSession) {
return null;
}

return (
<Flex bg="base.800" borderRadius="base" p={4} minW={420} flexDir="column" gap={4} shadow="dark-lg">
<Heading size="md" color="base.300" userSelect="none">
{t('controlLayers.vectorEdit.title')}
</Heading>
<FormControl isDisabled={!activePointType}>
<FormLabel m={0}>{t('controlLayers.vectorEdit.pointType')}</FormLabel>
<RadioGroup value={activePointType ?? undefined} onChange={onPointTypeChange} size="sm">
<Flex alignItems="center" gap={4} color="base.300" wrap="wrap">
<Radio value="corner">
<Text>{t('controlLayers.vectorEdit.corner')}</Text>
</Radio>
<Radio value="smooth">
<Text>{t('controlLayers.vectorEdit.smooth')}</Text>
</Radio>
<Radio value="symmetric">
<Text>{t('controlLayers.vectorEdit.symmetric')}</Text>
</Radio>
</Flex>
</RadioGroup>
</FormControl>
<ButtonGroup isAttached={false} size="sm" w="full" alignItems="center">
<Spacer />
<Button onClick={canvasManager.tool.tools.path.acceptEditSession} variant="ghost">
{t('common.apply')}
</Button>
<Button onClick={canvasManager.tool.tools.path.cancel} variant="ghost">
{t('common.cancel')}
</Button>
</ButtonGroup>
</Flex>
);
});

VectorLayerEditFooter.displayName = 'VectorLayerEditFooter';
Loading
Loading