Skip to content

Commit 14fbee1

Browse files
hipsterusernamecursoragentpsychedelicious
authored
Rule of 3rds Composition Guide (#8130)
* Add Rule of 4 composition guide to canvas settings and rendering Co-authored-by: kent <kent@invoke.ai> * Rename Rule of 4 Guide to Rule of Thirds in canvas composition guide Co-authored-by: kent <kent@invoke.ai> * Updates to comp guide and naming * Fix reference * Update translation keys and organize settings. * revert to previous canvas manager for conflict * Re-add composition guide. * Fix lint * prettier * feat(ui): improve markup in canvas settings popover * feat(ui): use brand colors for canvas rule of thirds guide --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: psychedelicious <4822129+psychedelicious@users.noreply.github.com>
1 parent 5dbc32e commit 14fbee1

File tree

6 files changed

+263
-18
lines changed

6 files changed

+263
-18
lines changed

invokeai/frontend/web/public/locales/en.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -580,6 +580,12 @@
580580
"title": "Cancel Transform",
581581
"desc": "Cancel the pending transform."
582582
},
583+
"settings": {
584+
"behavior": "Behavior",
585+
"display": "Display",
586+
"grid": "Grid",
587+
"debug": "Debug"
588+
},
583589
"toggleNonRasterLayers": {
584590
"title": "Toggle Non-Raster Layers",
585591
"desc": "Show or hide all non-raster layer categories (Control Layers, Inpaint Masks, Regional Guidance)."
@@ -1916,6 +1922,7 @@
19161922
"mergingLayers": "Merging layers",
19171923
"clearHistory": "Clear History",
19181924
"bboxOverlay": "Show Bbox Overlay",
1925+
"ruleOfThirds": "Show Rule of Thirds",
19191926
"newSession": "New Session",
19201927
"clearCaches": "Clear Caches",
19211928
"recalculateRects": "Recalculate Rects",

invokeai/frontend/web/src/features/controlLayers/components/Settings/CanvasSettingsPopover.tsx

Lines changed: 65 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
import {
22
Divider,
33
Flex,
4+
Icon,
45
IconButton,
56
Popover,
67
PopoverArrow,
78
PopoverBody,
89
PopoverContent,
910
PopoverTrigger,
11+
Text,
1012
useShiftModifier,
1113
} from '@invoke-ai/ui-library';
1214
import { CanvasSettingsBboxOverlaySwitch } from 'features/controlLayers/components/Settings/CanvasSettingsBboxOverlaySwitch';
@@ -23,11 +25,12 @@ import { CanvasSettingsOutputOnlyMaskedRegionsCheckbox } from 'features/controlL
2325
import { CanvasSettingsPreserveMaskCheckbox } from 'features/controlLayers/components/Settings/CanvasSettingsPreserveMaskCheckbox';
2426
import { CanvasSettingsPressureSensitivityCheckbox } from 'features/controlLayers/components/Settings/CanvasSettingsPressureSensitivity';
2527
import { CanvasSettingsRecalculateRectsButton } from 'features/controlLayers/components/Settings/CanvasSettingsRecalculateRectsButton';
28+
import { CanvasSettingsRuleOfThirdsSwitch } from 'features/controlLayers/components/Settings/CanvasSettingsRuleOfThirdsGuideSwitch';
2629
import { CanvasSettingsShowHUDSwitch } from 'features/controlLayers/components/Settings/CanvasSettingsShowHUDSwitch';
2730
import { CanvasSettingsShowProgressOnCanvas } from 'features/controlLayers/components/Settings/CanvasSettingsShowProgressOnCanvasSwitch';
2831
import { memo } from 'react';
2932
import { useTranslation } from 'react-i18next';
30-
import { PiGearSixFill } from 'react-icons/pi';
33+
import { PiCodeFill, PiEyeFill, PiGearSixFill, PiPencilFill, PiSquaresFourFill } from 'react-icons/pi';
3134

3235
export const CanvasSettingsPopover = memo(() => {
3336
const { t } = useTranslation();
@@ -41,22 +44,57 @@ export const CanvasSettingsPopover = memo(() => {
4144
alignSelf="stretch"
4245
/>
4346
</PopoverTrigger>
44-
<PopoverContent>
47+
<PopoverContent maxW="280px">
4548
<PopoverArrow />
4649
<PopoverBody>
4750
<Flex direction="column" gap={2}>
48-
<CanvasSettingsInvertScrollCheckbox />
49-
<CanvasSettingsPreserveMaskCheckbox />
50-
<CanvasSettingsClipToBboxCheckbox />
51-
<CanvasSettingsOutputOnlyMaskedRegionsCheckbox />
52-
<CanvasSettingsSnapToGridCheckbox />
53-
<CanvasSettingsPressureSensitivityCheckbox />
54-
<CanvasSettingsShowProgressOnCanvas />
55-
<CanvasSettingsIsolatedStagingPreviewSwitch />
56-
<CanvasSettingsIsolatedLayerPreviewSwitch />
57-
<CanvasSettingsDynamicGridSwitch />
58-
<CanvasSettingsBboxOverlaySwitch />
59-
<CanvasSettingsShowHUDSwitch />
51+
{/* Behavior Settings */}
52+
<Flex direction="column" gap={1}>
53+
<Flex align="center" gap={2}>
54+
<Icon as={PiPencilFill} boxSize={4} />
55+
<Text fontWeight="bold" fontSize="sm" color="base.100">
56+
{t('hotkeys.canvas.settings.behavior')}
57+
</Text>
58+
</Flex>
59+
<CanvasSettingsInvertScrollCheckbox />
60+
<CanvasSettingsPressureSensitivityCheckbox />
61+
<CanvasSettingsPreserveMaskCheckbox />
62+
<CanvasSettingsClipToBboxCheckbox />
63+
<CanvasSettingsOutputOnlyMaskedRegionsCheckbox />
64+
</Flex>
65+
66+
<Divider />
67+
68+
{/* Display Settings */}
69+
<Flex direction="column" gap={1}>
70+
<Flex align="center" gap={2} color="base.200">
71+
<Icon as={PiEyeFill} boxSize={4} />
72+
<Text fontWeight="bold" fontSize="sm">
73+
{t('hotkeys.canvas.settings.display')}
74+
</Text>
75+
</Flex>
76+
<CanvasSettingsShowProgressOnCanvas />
77+
<CanvasSettingsIsolatedStagingPreviewSwitch />
78+
<CanvasSettingsIsolatedLayerPreviewSwitch />
79+
<CanvasSettingsBboxOverlaySwitch />
80+
<CanvasSettingsShowHUDSwitch />
81+
</Flex>
82+
83+
<Divider />
84+
85+
{/* Grid Settings */}
86+
<Flex direction="column" gap={1}>
87+
<Flex align="center" gap={2} color="base.200">
88+
<Icon as={PiSquaresFourFill} boxSize={4} />
89+
<Text fontWeight="bold" fontSize="sm">
90+
{t('hotkeys.canvas.settings.grid')}
91+
</Text>
92+
</Flex>
93+
<CanvasSettingsSnapToGridCheckbox />
94+
<CanvasSettingsDynamicGridSwitch />
95+
<CanvasSettingsRuleOfThirdsSwitch />
96+
</Flex>
97+
6098
<DebugSettings />
6199
</Flex>
62100
</PopoverBody>
@@ -68,6 +106,7 @@ export const CanvasSettingsPopover = memo(() => {
68106
CanvasSettingsPopover.displayName = 'CanvasSettingsPopover';
69107

70108
const DebugSettings = () => {
109+
const { t } = useTranslation();
71110
const shift = useShiftModifier();
72111

73112
if (!shift) {
@@ -77,10 +116,18 @@ const DebugSettings = () => {
77116
return (
78117
<>
79118
<Divider />
80-
<CanvasSettingsClearCachesButton />
81-
<CanvasSettingsRecalculateRectsButton />
82-
<CanvasSettingsLogDebugInfoButton />
83-
<CanvasSettingsClearHistoryButton />
119+
<Flex direction="column" gap={1}>
120+
<Flex align="center" gap={2} color="base.200">
121+
<Icon as={PiCodeFill} boxSize={4} />
122+
<Text fontWeight="bold" fontSize="sm">
123+
{t('hotkeys.canvas.settings.debug')}
124+
</Text>
125+
</Flex>
126+
<CanvasSettingsClearCachesButton />
127+
<CanvasSettingsRecalculateRectsButton />
128+
<CanvasSettingsLogDebugInfoButton />
129+
<CanvasSettingsClearHistoryButton />
130+
</Flex>
84131
</>
85132
);
86133
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { FormControl, FormLabel, Switch } from '@invoke-ai/ui-library';
2+
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
3+
import { selectRuleOfThirds, settingsRuleOfThirdsToggled } from 'features/controlLayers/store/canvasSettingsSlice';
4+
import { memo, useCallback } from 'react';
5+
import { useTranslation } from 'react-i18next';
6+
7+
export const CanvasSettingsRuleOfThirdsSwitch = memo(() => {
8+
const { t } = useTranslation();
9+
const dispatch = useAppDispatch();
10+
const ruleOfThirds = useAppSelector(selectRuleOfThirds);
11+
const onChange = useCallback(() => {
12+
dispatch(settingsRuleOfThirdsToggled());
13+
}, [dispatch]);
14+
15+
return (
16+
<FormControl>
17+
<FormLabel m={0} flexGrow={1}>
18+
{t('controlLayers.ruleOfThirds')}
19+
</FormLabel>
20+
<Switch size="sm" isChecked={ruleOfThirds} onChange={onChange} />
21+
</FormControl>
22+
);
23+
});
24+
25+
CanvasSettingsRuleOfThirdsSwitch.displayName = 'CanvasSettingsRuleOfThirdsSwitch';
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager';
2+
import { CanvasModuleBase } from 'features/controlLayers/konva/CanvasModuleBase';
3+
import { getPrefixedId } from 'features/controlLayers/konva/util';
4+
import { selectRuleOfThirds } from 'features/controlLayers/store/canvasSettingsSlice';
5+
import { selectBbox } from 'features/controlLayers/store/selectors';
6+
import Konva from 'konva';
7+
import type { Logger } from 'roarr';
8+
9+
/**
10+
* Renders the rule of thirds composition guide overlay on the canvas.
11+
* The guide shows a 3x3 grid within the bounding box to help with composition.
12+
*/
13+
export class CanvasCompositionGuideModule extends CanvasModuleBase {
14+
readonly type = 'composition_guide';
15+
readonly id: string;
16+
readonly path: string[];
17+
readonly parent: CanvasManager;
18+
readonly manager: CanvasManager;
19+
readonly log: Logger;
20+
21+
subscriptions: Set<() => void> = new Set();
22+
23+
/**
24+
* The Konva objects that make up the composition guide:
25+
* - A group to hold all the guide lines
26+
* - Individual line objects for the rule of thirds grid
27+
*/
28+
konva: {
29+
group: Konva.Group;
30+
verticalLine1: Konva.Line;
31+
verticalLine2: Konva.Line;
32+
horizontalLine1: Konva.Line;
33+
horizontalLine2: Konva.Line;
34+
};
35+
36+
constructor(manager: CanvasManager) {
37+
super();
38+
this.id = getPrefixedId(this.type);
39+
this.parent = manager;
40+
this.manager = manager;
41+
this.path = this.manager.buildPath(this);
42+
this.log = this.manager.buildLogger(this);
43+
44+
this.log.debug('Creating composition guide module');
45+
46+
this.konva = {
47+
group: new Konva.Group({
48+
name: `${this.type}:group`,
49+
listening: false,
50+
perfectDrawEnabled: false,
51+
}),
52+
verticalLine1: new Konva.Line({
53+
name: `${this.type}:vertical_line_1`,
54+
listening: false,
55+
stroke: 'hsl(220 12% 90% / 0.9)',
56+
strokeWidth: 1,
57+
strokeScaleEnabled: false,
58+
perfectDrawEnabled: false,
59+
dash: [5, 5],
60+
}),
61+
verticalLine2: new Konva.Line({
62+
name: `${this.type}:vertical_line_2`,
63+
listening: false,
64+
stroke: 'hsl(220 12% 90% / 0.9)',
65+
strokeWidth: 1,
66+
strokeScaleEnabled: false,
67+
perfectDrawEnabled: false,
68+
dash: [5, 5],
69+
}),
70+
horizontalLine1: new Konva.Line({
71+
name: `${this.type}:horizontal_line_1`,
72+
listening: false,
73+
stroke: 'hsl(220 12% 90% / 0.9)',
74+
strokeWidth: 1,
75+
strokeScaleEnabled: false,
76+
perfectDrawEnabled: false,
77+
dash: [5, 5],
78+
}),
79+
horizontalLine2: new Konva.Line({
80+
name: `${this.type}:horizontal_line_2`,
81+
listening: false,
82+
stroke: 'hsl(220 12% 90% / 0.9)',
83+
strokeWidth: 1,
84+
strokeScaleEnabled: false,
85+
perfectDrawEnabled: false,
86+
dash: [5, 5],
87+
}),
88+
};
89+
90+
this.konva.group.add(this.konva.verticalLine1);
91+
this.konva.group.add(this.konva.verticalLine2);
92+
this.konva.group.add(this.konva.horizontalLine1);
93+
this.konva.group.add(this.konva.horizontalLine2);
94+
95+
// Listen for changes to the rule of thirds guide setting
96+
this.subscriptions.add(this.manager.stateApi.createStoreSubscription(selectRuleOfThirds, this.render));
97+
98+
// Listen for changes to the bbox to update guide positioning
99+
this.subscriptions.add(this.manager.stateApi.createStoreSubscription(selectBbox, this.render));
100+
}
101+
102+
initialize = () => {
103+
this.log.debug('Initializing composition guide module');
104+
this.render();
105+
};
106+
107+
/**
108+
* Renders the composition guide. The guide is only visible when the setting is enabled.
109+
*/
110+
render = () => {
111+
const ruleOfThirds = this.manager.stateApi.getSettings().ruleOfThirds;
112+
const { x, y, width, height } = this.manager.stateApi.runSelector(selectBbox).rect;
113+
114+
this.konva.group.visible(ruleOfThirds);
115+
116+
if (!ruleOfThirds) {
117+
return;
118+
}
119+
120+
// Calculate the thirds positions of the bounding box
121+
const oneThirdX = x + width / 3;
122+
const twoThirdsX = x + (2 * width) / 3;
123+
const oneThirdY = y + height / 3;
124+
const twoThirdsY = y + (2 * height) / 3;
125+
126+
// Update the vertical lines (divide the bbox into thirds vertically)
127+
this.konva.verticalLine1.points([oneThirdX, y, oneThirdX, y + height]);
128+
this.konva.verticalLine2.points([twoThirdsX, y, twoThirdsX, y + height]);
129+
130+
// Update the horizontal lines (divide the bbox into thirds horizontally)
131+
this.konva.horizontalLine1.points([x, oneThirdY, x + width, oneThirdY]);
132+
this.konva.horizontalLine2.points([x, twoThirdsY, x + width, twoThirdsY]);
133+
};
134+
135+
destroy = () => {
136+
this.log.debug('Destroying composition guide module');
137+
this.subscriptions.forEach((unsubscribe) => unsubscribe());
138+
this.subscriptions.clear();
139+
this.konva.group.destroy();
140+
};
141+
142+
repr = () => {
143+
return {
144+
id: this.id,
145+
type: this.type,
146+
path: this.path,
147+
visible: this.konva.group.visible(),
148+
};
149+
};
150+
}

invokeai/frontend/web/src/features/controlLayers/konva/CanvasManager.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import { assert } from 'tsafe';
3232
import type { JsonObject } from 'type-fest';
3333

3434
import { CanvasBackgroundModule } from './CanvasBackgroundModule';
35+
import { CanvasCompositionGuideModule } from './CanvasCompositionGuideModule';
3536
import { CanvasStateApiModule } from './CanvasStateApiModule';
3637

3738
export class CanvasManager extends CanvasModuleBase {
@@ -61,6 +62,7 @@ export class CanvasManager extends CanvasModuleBase {
6162
compositor: CanvasCompositorModule;
6263
tool: CanvasToolModule;
6364
stagingArea: CanvasStagingAreaModule;
65+
compositionGuide: CanvasCompositionGuideModule;
6466

6567
konva: {
6668
previewLayer: Konva.Layer;
@@ -101,6 +103,7 @@ export class CanvasManager extends CanvasModuleBase {
101103

102104
this.compositor = new CanvasCompositorModule(this);
103105
this.stagingArea = new CanvasStagingAreaModule(this);
106+
this.compositionGuide = new CanvasCompositionGuideModule(this);
104107

105108
this.$isBusy = computed(
106109
[
@@ -129,6 +132,7 @@ export class CanvasManager extends CanvasModuleBase {
129132
// Must add in this order for correct z-index
130133
this.konva.previewLayer.add(this.stagingArea.konva.group);
131134
this.konva.previewLayer.add(this.tool.konva.group);
135+
this.konva.previewLayer.add(this.compositionGuide.konva.group);
132136
}
133137

134138
getAdapter = <T extends CanvasEntityType = CanvasEntityType>(
@@ -236,6 +240,7 @@ export class CanvasManager extends CanvasModuleBase {
236240
this.entityRenderer,
237241
this.compositor,
238242
this.stage,
243+
this.compositionGuide,
239244
];
240245
};
241246

@@ -281,6 +286,7 @@ export class CanvasManager extends CanvasModuleBase {
281286
entityRenderer: this.entityRenderer.repr(),
282287
compositor: this.compositor.repr(),
283288
stage: this.stage.repr(),
289+
compositionGuide: this.compositionGuide.repr(),
284290
};
285291
};
286292

0 commit comments

Comments
 (0)