diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json
index 3e88d460e55..75ce6f7b960 100644
--- a/invokeai/frontend/web/public/locales/en.json
+++ b/invokeai/frontend/web/public/locales/en.json
@@ -3169,7 +3169,8 @@
"isolatedLayerPreview": "Isolated Layer Preview",
"isolatedLayerPreviewDesc": "Whether to show only this layer when performing operations like filtering or transforming.",
"invertBrushSizeScrollDirection": "Invert Scroll for Brush Size",
- "pressureSensitivity": "Pressure Sensitivity"
+ "pressureAffectsWidth": "Pressure Affects Width",
+ "pressureAffectsBrushOpacity": "Pressure Affects Opacity"
},
"HUD": {
"bbox": "Bbox",
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/Settings/CanvasSettingsPopover.tsx b/invokeai/frontend/web/src/features/controlLayers/components/Settings/CanvasSettingsPopover.tsx
index 31ce8bd88a7..0e4f6a6190c 100644
--- a/invokeai/frontend/web/src/features/controlLayers/components/Settings/CanvasSettingsPopover.tsx
+++ b/invokeai/frontend/web/src/features/controlLayers/components/Settings/CanvasSettingsPopover.tsx
@@ -24,7 +24,7 @@ import { CanvasSettingsIsolatedStagingPreviewSwitch } from 'features/controlLaye
import { CanvasSettingsLogDebugInfoButton } from 'features/controlLayers/components/Settings/CanvasSettingsLogDebugInfo';
import { CanvasSettingsOutputOnlyMaskedRegionsCheckbox } from 'features/controlLayers/components/Settings/CanvasSettingsOutputOnlyMaskedRegionsCheckbox';
import { CanvasSettingsPreserveMaskCheckbox } from 'features/controlLayers/components/Settings/CanvasSettingsPreserveMaskCheckbox';
-import { CanvasSettingsPressureSensitivityCheckbox } from 'features/controlLayers/components/Settings/CanvasSettingsPressureSensitivity';
+import { CanvasSettingsPressureOptions } from 'features/controlLayers/components/Settings/CanvasSettingsPressureSensitivity';
import { CanvasSettingsRecalculateRectsButton } from 'features/controlLayers/components/Settings/CanvasSettingsRecalculateRectsButton';
import { CanvasSettingsRuleOfThirdsSwitch } from 'features/controlLayers/components/Settings/CanvasSettingsRuleOfThirdsGuideSwitch';
import { CanvasSettingsSaveAllImagesToGalleryCheckbox } from 'features/controlLayers/components/Settings/CanvasSettingsSaveAllImagesToGalleryCheckbox';
@@ -60,7 +60,7 @@ export const CanvasSettingsPopover = memo(() => {
-
+
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/Settings/CanvasSettingsPressureSensitivity.tsx b/invokeai/frontend/web/src/features/controlLayers/components/Settings/CanvasSettingsPressureSensitivity.tsx
index 1f59b30abf7..5d10a204ae8 100644
--- a/invokeai/frontend/web/src/features/controlLayers/components/Settings/CanvasSettingsPressureSensitivity.tsx
+++ b/invokeai/frontend/web/src/features/controlLayers/components/Settings/CanvasSettingsPressureSensitivity.tsx
@@ -1,27 +1,39 @@
import { Checkbox, FormControl, FormLabel } from '@invoke-ai/ui-library';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import {
- selectPressureSensitivity,
- settingsPressureSensitivityToggled,
+ selectPressureAffectsOpacity,
+ selectPressureAffectsWidth,
+ settingsPressureAffectsOpacityToggled,
+ settingsPressureAffectsWidthToggled,
} from 'features/controlLayers/store/canvasSettingsSlice';
import type { ChangeEventHandler } from 'react';
-import { memo, useCallback } from 'react';
+import { Fragment, memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
-export const CanvasSettingsPressureSensitivityCheckbox = memo(() => {
+export const CanvasSettingsPressureOptions = memo(() => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
- const pressureSensitivity = useAppSelector(selectPressureSensitivity);
- const onChange = useCallback>(() => {
- dispatch(settingsPressureSensitivityToggled());
+ const pressureAffectsWidth = useAppSelector(selectPressureAffectsWidth);
+ const pressureAffectsOpacity = useAppSelector(selectPressureAffectsOpacity);
+ const onWidthChange = useCallback>(() => {
+ dispatch(settingsPressureAffectsWidthToggled());
+ }, [dispatch]);
+ const onOpacityChange = useCallback>(() => {
+ dispatch(settingsPressureAffectsOpacityToggled());
}, [dispatch]);
return (
-
- {t('controlLayers.settings.pressureSensitivity')}
-
-
+
+
+ {t('controlLayers.settings.pressureAffectsWidth')}
+
+
+
+ {t('controlLayers.settings.pressureAffectsBrushOpacity')}
+
+
+
);
});
-CanvasSettingsPressureSensitivityCheckbox.displayName = 'CanvasSettingsPressureSensitivityCheckbox';
+CanvasSettingsPressureOptions.displayName = 'CanvasSettingsPressureOptions';
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 b282580fec9..e630d03adf5 100644
--- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntity/CanvasEntityBufferObjectRenderer.ts
+++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntity/CanvasEntityBufferObjectRenderer.ts
@@ -287,6 +287,9 @@ export class CanvasEntityBufferObjectRenderer extends CanvasModuleBase {
}
// Move the buffer to the persistent objects group/renderers
+ if (this.renderer instanceof CanvasObjectBrushLineWithPressure) {
+ this.renderer.finalizePressureImage();
+ }
this.parent.renderer.adoptObjectRenderer(this.renderer);
if (pushToState) {
diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasObject/CanvasObjectBrushLineWithPressure.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasObject/CanvasObjectBrushLineWithPressure.ts
index 1dba23718b8..dc56abfc787 100644
--- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasObject/CanvasObjectBrushLineWithPressure.ts
+++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasObject/CanvasObjectBrushLineWithPressure.ts
@@ -4,6 +4,13 @@ import type { CanvasEntityBufferObjectRenderer } from 'features/controlLayers/ko
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 {
+ appendPressureStrokeRenderOpsToCanvas,
+ getPressureStrokeRenderOps,
+ getPressureStrokeRenderOpsFromPointIndex,
+ type PressureStrokeCanvasTarget,
+ renderPressureStrokeToCanvas,
+} from 'features/controlLayers/konva/pressure';
import { getSVGPathDataFromPoints } from 'features/controlLayers/konva/util';
import type { CanvasBrushLineWithPressureState } from 'features/controlLayers/store/types';
import Konva from 'konva';
@@ -21,9 +28,15 @@ export class CanvasObjectBrushLineWithPressure extends CanvasModuleBase {
readonly log: Logger;
state: CanvasBrushLineWithPressureState;
+ pressurePreview: {
+ target: PressureStrokeCanvasTarget | null;
+ renderedPointCount: number;
+ previewKey: string | null;
+ };
konva: {
group: Konva.Group;
line: Konva.Path;
+ pressureImage: Konva.Image;
};
constructor(
@@ -53,26 +66,202 @@ export class CanvasObjectBrushLineWithPressure extends CanvasModuleBase {
globalCompositeOperation: (state.globalCompositeOperation ?? 'source-over') as GlobalCompositeOperation,
perfectDrawEnabled: false,
}),
+ pressureImage: new Konva.Image({
+ name: `${this.type}:pressure_image`,
+ image: document.createElement('canvas'),
+ listening: false,
+ visible: false,
+ globalCompositeOperation: (state.globalCompositeOperation ?? 'source-over') as GlobalCompositeOperation,
+ perfectDrawEnabled: false,
+ }),
};
- this.konva.group.add(this.konva.line);
+ this.konva.group.add(this.konva.line, this.konva.pressureImage);
this.state = state;
+ this.pressurePreview = {
+ target: null,
+ renderedPointCount: 0,
+ previewKey: null,
+ };
}
+ getPressurePreviewKey = (state: CanvasBrushLineWithPressureState): string => {
+ const { id, strokeWidth, color, pressureAffectsWidth, pressureAffectsOpacity, globalCompositeOperation } = state;
+
+ return [
+ id,
+ strokeWidth,
+ color.r,
+ color.g,
+ color.b,
+ color.a,
+ pressureAffectsWidth,
+ pressureAffectsOpacity,
+ globalCompositeOperation ?? 'source-over',
+ ].join(':');
+ };
+
+ resetPressurePreview = () => {
+ this.pressurePreview.target = null;
+ this.pressurePreview.renderedPointCount = 0;
+ this.pressurePreview.previewKey = null;
+ };
+
+ syncPressureImage = (arg: { canvas: HTMLCanvasElement; x: number; y: number; globalCompositeOperation?: string }) => {
+ const { canvas, x, y, globalCompositeOperation } = arg;
+ this.konva.pressureImage.setAttrs({
+ image: canvas,
+ x,
+ y,
+ width: canvas.width,
+ height: canvas.height,
+ visible: true,
+ globalCompositeOperation: (globalCompositeOperation ?? 'source-over') as GlobalCompositeOperation,
+ });
+ this.konva.pressureImage.getLayer()?.batchDraw();
+ };
+
+ updatePressureImage = () => {
+ const { points, color, strokeWidth, globalCompositeOperation, pressureAffectsWidth, pressureAffectsOpacity } =
+ this.state;
+ const renderOps = getPressureStrokeRenderOps({
+ points,
+ strokeWidth,
+ color,
+ pressureAffectsWidth,
+ pressureAffectsOpacity,
+ });
+
+ const rasterizedStroke = renderPressureStrokeToCanvas(renderOps);
+
+ if (!rasterizedStroke) {
+ this.resetPressurePreview();
+ this.konva.pressureImage.setAttrs({
+ visible: false,
+ width: 0,
+ height: 0,
+ });
+ return;
+ }
+
+ this.syncPressureImage({
+ canvas: rasterizedStroke.canvas,
+ x: rasterizedStroke.x,
+ y: rasterizedStroke.y,
+ globalCompositeOperation,
+ });
+ };
+
+ updatePressureImagePreview = () => {
+ const { points, color, strokeWidth, globalCompositeOperation, pressureAffectsWidth, pressureAffectsOpacity } =
+ this.state;
+ const pointCount = Math.floor(points.length / 3);
+ const previewKey = this.getPressurePreviewKey(this.state);
+ const shouldResetPreview =
+ this.pressurePreview.target === null ||
+ this.pressurePreview.previewKey !== previewKey ||
+ pointCount <= this.pressurePreview.renderedPointCount;
+
+ if (shouldResetPreview) {
+ this.resetPressurePreview();
+ const renderOps = getPressureStrokeRenderOps({
+ points,
+ strokeWidth,
+ color,
+ pressureAffectsWidth,
+ pressureAffectsOpacity,
+ });
+ const target = appendPressureStrokeRenderOpsToCanvas(null, renderOps);
+
+ if (!target) {
+ this.konva.pressureImage.setAttrs({
+ visible: false,
+ width: 0,
+ height: 0,
+ });
+ return;
+ }
+
+ this.pressurePreview.target = target;
+ this.pressurePreview.renderedPointCount = pointCount;
+ this.pressurePreview.previewKey = previewKey;
+ this.syncPressureImage({
+ canvas: target.canvas,
+ x: target.x,
+ y: target.y,
+ globalCompositeOperation,
+ });
+ return;
+ }
+
+ const incrementalRenderOps = getPressureStrokeRenderOpsFromPointIndex({
+ points,
+ strokeWidth,
+ color,
+ pressureAffectsWidth,
+ pressureAffectsOpacity,
+ startPointIndex: Math.max(0, this.pressurePreview.renderedPointCount - 1),
+ });
+ const target = appendPressureStrokeRenderOpsToCanvas(this.pressurePreview.target, incrementalRenderOps);
+
+ if (!target) {
+ this.updatePressureImage();
+ return;
+ }
+
+ this.pressurePreview.target = target;
+ this.pressurePreview.renderedPointCount = pointCount;
+ this.pressurePreview.previewKey = previewKey;
+ this.syncPressureImage({
+ canvas: target.canvas,
+ x: target.x,
+ y: target.y,
+ globalCompositeOperation,
+ });
+ };
+
+ finalizePressureImage = () => {
+ if (!this.state.pressureAffectsOpacity) {
+ return;
+ }
+
+ this.resetPressurePreview();
+ this.updatePressureImage();
+ };
+
+ shouldUseNativePressurePreview = () => this.parent.type === 'buffer_renderer';
+
update(state: CanvasBrushLineWithPressureState, force = false): boolean {
if (force || this.state !== state) {
this.log.trace({ state }, 'Updating brush line with pressure');
- const { points, color, strokeWidth, globalCompositeOperation } = state;
+ const { points, color, strokeWidth, globalCompositeOperation, pressureAffectsWidth, pressureAffectsOpacity } =
+ state;
+ this.konva.line.visible(!pressureAffectsOpacity);
+ this.konva.pressureImage.visible(pressureAffectsOpacity);
this.konva.line.setAttrs({
globalCompositeOperation: (globalCompositeOperation ?? 'source-over') as GlobalCompositeOperation,
data: getSVGPathDataFromPoints(points, {
size: strokeWidth / 2,
simulatePressure: false,
last: true,
- thinning: 1,
+ thinning: pressureAffectsWidth ? 1 : 0,
}),
fill: rgbaColorToString(color),
});
this.state = state;
+ if (pressureAffectsOpacity) {
+ if (this.shouldUseNativePressurePreview()) {
+ this.updatePressureImagePreview();
+ } else {
+ this.updatePressureImage();
+ }
+ } else {
+ this.resetPressurePreview();
+ this.konva.pressureImage.setAttrs({
+ visible: false,
+ width: 0,
+ height: 0,
+ });
+ }
return true;
}
diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasObject/CanvasObjectEraserLineWithPressure.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasObject/CanvasObjectEraserLineWithPressure.ts
index c45942c312b..5808ae1d79c 100644
--- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasObject/CanvasObjectEraserLineWithPressure.ts
+++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasObject/CanvasObjectEraserLineWithPressure.ts
@@ -58,13 +58,13 @@ export class CanvasObjectEraserLineWithPressure extends CanvasModuleBase {
update(state: CanvasEraserLineWithPressureState, force = false): boolean {
if (force || this.state !== state) {
this.log.trace({ state }, 'Updating eraser line with pressure');
- const { points, strokeWidth } = state;
+ const { points, strokeWidth, pressureAffectsWidth } = state;
this.konva.line.setAttrs({
data: getSVGPathDataFromPoints(points, {
size: strokeWidth / 2,
simulatePressure: false,
last: true,
- thinning: 1,
+ thinning: pressureAffectsWidth ? 1 : 0,
}),
});
this.state = state;
diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasTool/CanvasBrushToolModule.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasTool/CanvasBrushToolModule.ts
index e02a3666501..4f77aa11bbf 100644
--- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasTool/CanvasBrushToolModule.ts
+++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasTool/CanvasBrushToolModule.ts
@@ -2,6 +2,7 @@ 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 { getShouldUsePressureForBrush } from 'features/controlLayers/konva/pressure';
import {
alignCoordForTool,
getLastPointOfLastLine,
@@ -216,14 +217,20 @@ export class CanvasBrushToolModule extends CanvasModuleBase {
selectedEntity.state.type === 'raster_layer' && selectedEntity.state.isTransparencyLocked;
const globalCompositeOperation = isTransparencyLocked ? 'source-atop' : undefined;
- if (e.evt.pointerType === 'pen' && settings.pressureSensitivity) {
- // If the pen is down and pressure sensitivity is enabled, add the point with pressure
+ const shouldUsePressure =
+ e.evt.pointerType === 'pen' &&
+ getShouldUsePressureForBrush(settings.pressureAffectsWidth, settings.pressureAffectsOpacity);
+
+ if (shouldUsePressure) {
+ // If the pen is down and pressure input is enabled, add the point with pressure
await selectedEntity.bufferRenderer.setBuffer({
id: getPrefixedId('brush_line_with_pressure'),
type: 'brush_line_with_pressure',
points: [alignedPoint.x, alignedPoint.y, e.evt.pressure],
strokeWidth: settings.brushWidth,
color: this.manager.stateApi.getCurrentColor(),
+ pressureAffectsWidth: settings.pressureAffectsWidth,
+ pressureAffectsOpacity: settings.pressureAffectsOpacity,
clip: this.parent.getClip(selectedEntity.state),
globalCompositeOperation,
});
@@ -280,7 +287,11 @@ export class CanvasBrushToolModule extends CanvasModuleBase {
selectedEntity.state.type === 'raster_layer' && selectedEntity.state.isTransparencyLocked;
const globalCompositeOperation = isTransparencyLocked ? 'source-atop' : undefined;
- if (e.evt.pointerType === 'pen' && settings.pressureSensitivity) {
+ const shouldUsePressure =
+ e.evt.pointerType === 'pen' &&
+ getShouldUsePressureForBrush(settings.pressureAffectsWidth, settings.pressureAffectsOpacity);
+
+ if (shouldUsePressure) {
// We need to get the last point of the last line to create a straight line if shift is held
const lastLinePoint = getLastPointOfLastLineWithPressure(
selectedEntity.state.objects,
@@ -313,6 +324,8 @@ export class CanvasBrushToolModule extends CanvasModuleBase {
points,
strokeWidth: settings.brushWidth,
color: this.manager.stateApi.getCurrentColor(),
+ pressureAffectsWidth: settings.pressureAffectsWidth,
+ pressureAffectsOpacity: settings.pressureAffectsOpacity,
// When shift is held, the line may extend beyond the clip region. Clip only if we are clipping to bbox. If we
// are clipping to stage, we don't need to clip at all.
clip: isShiftDraw && !settings.clipToBbox ? null : this.parent.getClip(selectedEntity.state),
@@ -411,7 +424,11 @@ export class CanvasBrushToolModule extends CanvasModuleBase {
const settings = this.manager.stateApi.getSettings();
const lastPoint = getLastPointOfLine(bufferState.points);
- const minDistance = settings.brushWidth * this.parent.config.BRUSH_SPACING_TARGET_SCALE;
+ const spacingScale =
+ bufferState.type === 'brush_line_with_pressure' && bufferState.pressureAffectsOpacity
+ ? this.parent.config.PRESSURE_OPACITY_BRUSH_SPACING_TARGET_SCALE
+ : this.parent.config.BRUSH_SPACING_TARGET_SCALE;
+ const minDistance = bufferState.strokeWidth * spacingScale;
if (!lastPoint || !isDistanceMoreThanMin(cursorPos.relative, lastPoint, minDistance)) {
return;
}
@@ -426,8 +443,8 @@ export class CanvasBrushToolModule extends CanvasModuleBase {
bufferState.points.push(alignedPoint.x, alignedPoint.y);
- // Add pressure if the pen is down and pressure sensitivity is enabled
- if (bufferState.type === 'brush_line_with_pressure' && settings.pressureSensitivity) {
+ // Preserve pressure data for the full stroke even if the settings change mid-draw.
+ if (bufferState.type === 'brush_line_with_pressure') {
bufferState.points.push(e.evt.pressure);
}
diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasTool/CanvasEraserToolModule.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasTool/CanvasEraserToolModule.ts
index 1351ea9b68b..36e5c82f12e 100644
--- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasTool/CanvasEraserToolModule.ts
+++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasTool/CanvasEraserToolModule.ts
@@ -1,6 +1,7 @@
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 { getShouldUsePressureForEraser } from 'features/controlLayers/konva/pressure';
import {
alignCoordForTool,
getLastPointOfLastLine,
@@ -183,13 +184,17 @@ export class CanvasEraserToolModule extends CanvasModuleBase {
const normalizedPoint = offsetCoord(cursorPos.relative, selectedEntity.state.position);
const alignedPoint = alignCoordForTool(normalizedPoint, settings.brushWidth);
- if (e.evt.pointerType === 'pen' && settings.pressureSensitivity) {
- // If the pen is down and pressure sensitivity is enabled, add the point with pressure
+ const shouldUsePressure =
+ e.evt.pointerType === 'pen' && getShouldUsePressureForEraser(settings.pressureAffectsWidth);
+
+ if (shouldUsePressure) {
+ // If the pen is down and pressure input is enabled, add the point with pressure
await selectedEntity.bufferRenderer.setBuffer({
id: getPrefixedId('eraser_line_with_pressure'),
type: 'eraser_line_with_pressure',
points: [alignedPoint.x, alignedPoint.y, e.evt.pressure],
strokeWidth: settings.eraserWidth,
+ pressureAffectsWidth: settings.pressureAffectsWidth,
clip: this.parent.getClip(selectedEntity.state),
});
} else {
@@ -233,7 +238,10 @@ export class CanvasEraserToolModule extends CanvasModuleBase {
const normalizedPoint = offsetCoord(cursorPos.relative, selectedEntity.state.position);
- if (e.evt.pointerType === 'pen' && settings.pressureSensitivity) {
+ const shouldUsePressure =
+ e.evt.pointerType === 'pen' && getShouldUsePressureForEraser(settings.pressureAffectsWidth);
+
+ if (shouldUsePressure) {
// We need to get the last point of the last line to create a straight line if shift is held
const lastLinePoint = getLastPointOfLastLineWithPressure(
selectedEntity.state.objects,
@@ -263,6 +271,7 @@ export class CanvasEraserToolModule extends CanvasModuleBase {
type: 'eraser_line_with_pressure',
points,
strokeWidth: settings.eraserWidth,
+ pressureAffectsWidth: settings.pressureAffectsWidth,
clip: this.parent.getClip(selectedEntity.state),
});
} else {
@@ -372,8 +381,8 @@ export class CanvasEraserToolModule extends CanvasModuleBase {
bufferState.points.push(alignedPoint.x, alignedPoint.y);
- // Add pressure if the pen is down and pressure sensitivity is enabled
- if (bufferState.type === 'eraser_line_with_pressure' && settings.pressureSensitivity) {
+ // Preserve pressure data for the full stroke even if the settings change mid-draw.
+ if (bufferState.type === 'eraser_line_with_pressure') {
bufferState.points.push(e.evt.pressure);
}
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 98e1ab7d5d9..6f7153eff3b 100644
--- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasTool/CanvasToolModule.ts
+++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasTool/CanvasToolModule.ts
@@ -51,10 +51,12 @@ const CODE_SPACE = 'Space';
type CanvasToolModuleConfig = {
BRUSH_SPACING_TARGET_SCALE: number;
+ PRESSURE_OPACITY_BRUSH_SPACING_TARGET_SCALE: number;
};
const DEFAULT_CONFIG: CanvasToolModuleConfig = {
BRUSH_SPACING_TARGET_SCALE: 0.1,
+ PRESSURE_OPACITY_BRUSH_SPACING_TARGET_SCALE: 0.05,
};
export class CanvasToolModule extends CanvasModuleBase {
diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/pressure.test.ts b/invokeai/frontend/web/src/features/controlLayers/konva/pressure.test.ts
new file mode 100644
index 00000000000..b86627cd417
--- /dev/null
+++ b/invokeai/frontend/web/src/features/controlLayers/konva/pressure.test.ts
@@ -0,0 +1,186 @@
+import { describe, expect, it } from 'vitest';
+
+import {
+ getPressureStrokeRenderBounds,
+ getPressureStrokeRenderOps,
+ getPressureStrokeRenderOpsFromPointIndex,
+ getShouldUsePressureForBrush,
+ getShouldUsePressureForEraser,
+ mergeOpacityDotAlphaAtPixel,
+} from './pressure';
+
+describe('pressure helpers', () => {
+ it('uses pressure for brush when width or opacity is enabled', () => {
+ expect(getShouldUsePressureForBrush(false, false)).toBe(false);
+ expect(getShouldUsePressureForBrush(true, false)).toBe(true);
+ expect(getShouldUsePressureForBrush(false, true)).toBe(true);
+ });
+
+ it('uses pressure for eraser only when width is enabled', () => {
+ expect(getShouldUsePressureForEraser(false)).toBe(false);
+ expect(getShouldUsePressureForEraser(true)).toBe(true);
+ });
+
+ it('builds fixed-width opacity-sensitive render ops', () => {
+ const ops = getPressureStrokeRenderOps({
+ points: [10, 20, 0.25, 30, 40, 0.75],
+ strokeWidth: 40,
+ color: { r: 255, g: 255, b: 255, a: 0.8 },
+ pressureAffectsWidth: false,
+ pressureAffectsOpacity: true,
+ });
+
+ const dots = ops.filter((op) => op.type === 'dot');
+ expect(ops.every((op) => op.type === 'dot')).toBe(true);
+ expect(dots.length).toBeGreaterThanOrEqual(20);
+ expect(dots.every((dot) => dot.radius === 20)).toBe(true);
+ expect(dots.every((dot) => dot.color.a >= 0 && dot.color.a <= 1)).toBe(true);
+ expect(dots[0]?.color.a).toBeGreaterThan(0.2);
+ expect(dots[0]?.color.a).toBeLessThanOrEqual(0.5);
+ expect(dots.at(-1)?.color.a ?? 0).toBeGreaterThan(0.25);
+ expect(dots.at(-1)?.color.a ?? 0).toBeLessThan(0.7);
+ });
+
+ it('builds width-sensitive render ops when opacity is disabled', () => {
+ const ops = getPressureStrokeRenderOps({
+ points: [10, 20, 0.25, 30, 40, 0.75],
+ strokeWidth: 40,
+ color: { r: 255, g: 255, b: 255, a: 0.8 },
+ pressureAffectsWidth: true,
+ pressureAffectsOpacity: false,
+ });
+
+ expect(ops).toHaveLength(6);
+ expect(ops[0]).toEqual({
+ type: 'dot',
+ x: 10,
+ y: 20,
+ radius: 5,
+ color: { r: 255, g: 255, b: 255, a: 0.8 },
+ });
+ const lastOp = ops.at(-1);
+ expect(lastOp?.type).toBe('dot');
+ if (lastOp?.type === 'dot') {
+ expect(lastOp.x).toBe(30);
+ expect(lastOp.y).toBe(40);
+ expect(lastOp.radius).toBe(15);
+ expect(lastOp.color.r).toBe(255);
+ expect(lastOp.color.g).toBe(255);
+ expect(lastOp.color.b).toBe(255);
+ expect(lastOp.color.a).toBe(0.8);
+ }
+
+ const segments = ops.filter((op) => op.type === 'segment');
+ expect(segments).toHaveLength(4);
+ expect(segments.map((segment) => segment.width)).toEqual([12.5, 17.5, 22.5, 27.5]);
+ expect(segments.every((segment) => segment.color.a === 0.8)).toBe(true);
+ });
+
+ it('builds a pressure-scaled dot for single-point strokes', () => {
+ const ops = getPressureStrokeRenderOps({
+ points: [10, 20, 0.25],
+ strokeWidth: 40,
+ color: { r: 255, g: 255, b: 255, a: 0.8 },
+ pressureAffectsWidth: true,
+ pressureAffectsOpacity: true,
+ });
+
+ expect(ops).toEqual([
+ {
+ type: 'dot',
+ x: 10,
+ y: 20,
+ radius: 5,
+ color: { r: 255, g: 255, b: 255, a: 0.2 },
+ },
+ ]);
+ });
+
+ it('computes render bounds for opacity-pressure strokes', () => {
+ const bounds = getPressureStrokeRenderBounds([
+ {
+ type: 'segment',
+ from: { x: 10, y: 20 },
+ to: { x: 30, y: 40 },
+ width: 40,
+ color: { r: 255, g: 255, b: 255, a: 0.4 },
+ },
+ ]);
+
+ expect(bounds).toEqual({
+ x: -12,
+ y: -2,
+ width: 64,
+ height: 64,
+ });
+ });
+
+ it('smooths interior pressure spikes before generating subsegments', () => {
+ const ops = getPressureStrokeRenderOps({
+ points: [0, 0, 0.2, 20, 0, 1, 40, 0, 0.2],
+ strokeWidth: 20,
+ color: { r: 255, g: 255, b: 255, a: 1 },
+ pressureAffectsWidth: false,
+ pressureAffectsOpacity: true,
+ });
+
+ const dots = ops.filter((op) => op.type === 'dot');
+ expect(dots.length).toBeGreaterThanOrEqual(20);
+ const midpoint = Math.floor(dots.length / 2);
+ expect(dots[0]?.color.a).toBeGreaterThan(0.2);
+ expect(dots.at(-1)?.color.a).toBeGreaterThan(0.2);
+ expect(
+ dots.every((dot, index) => index === 0 || index > midpoint || dot.color.a >= (dots[index - 1]?.color.a ?? 0))
+ ).toBe(true);
+ expect(dots.every((dot, index) => index <= midpoint || dot.color.a <= (dots[index - 1]?.color.a ?? Infinity))).toBe(
+ true
+ );
+ const peakOpacity = Math.max(...dots.map((dot) => dot.color.a));
+ expect(peakOpacity).toBeGreaterThan(0.45);
+ expect(peakOpacity).toBeLessThan(0.6);
+ });
+
+ it('builds only the appended tail ops for incremental preview', () => {
+ const ops = getPressureStrokeRenderOpsFromPointIndex({
+ points: [0, 0, 0.2, 20, 0, 0.6, 40, 0, 1],
+ strokeWidth: 40,
+ color: { r: 255, g: 255, b: 255, a: 1 },
+ pressureAffectsWidth: false,
+ pressureAffectsOpacity: true,
+ startPointIndex: 1,
+ });
+
+ const dots = ops.filter((op) => op.type === 'dot');
+ expect(ops.every((op) => op.type === 'dot')).toBe(true);
+ expect(dots.length).toBeGreaterThanOrEqual(20);
+ expect(dots.every((dot, index) => index === 0 || dot.color.a >= (dots[index - 1]?.color.a ?? 0))).toBe(true);
+ expect(dots[0]?.color.a).toBeGreaterThan(0.2);
+ expect(dots[0]?.color.a).toBeLessThan(0.4);
+ expect(dots.at(-1)?.color.a ?? 0).toBeGreaterThan(0.8);
+ expect(dots.at(-1)?.color.a ?? 0).toBeLessThan(1);
+ });
+
+ it('composites distant opacity revisits instead of clamping them to the local pass maximum', () => {
+ const samePass = mergeOpacityDotAlphaAtPixel({
+ currentAlpha: 120,
+ candidateAlpha: 180,
+ lastStrokeDistance: 10,
+ strokeDistance: 30,
+ lastRadius: 20,
+ radius: 20,
+ });
+
+ expect(samePass.alpha).toBe(180);
+
+ const revisit = mergeOpacityDotAlphaAtPixel({
+ currentAlpha: 120,
+ candidateAlpha: 180,
+ lastStrokeDistance: 10,
+ strokeDistance: 80,
+ lastRadius: 20,
+ radius: 20,
+ });
+
+ expect(revisit.alpha).toBe(Math.round(120 + (180 * (255 - 120)) / 255));
+ });
+});
diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/pressure.ts b/invokeai/frontend/web/src/features/controlLayers/konva/pressure.ts
new file mode 100644
index 00000000000..5e9c9025ae8
--- /dev/null
+++ b/invokeai/frontend/web/src/features/controlLayers/konva/pressure.ts
@@ -0,0 +1,922 @@
+import type { Coordinate, CoordinateWithPressure, Rect, RgbaColor } from 'features/controlLayers/store/types';
+
+const MIN_PRESSURE_FACTOR = 0.05;
+const PRESSURE_STROKE_RENDER_PADDING_PX = 2;
+const PRESSURE_SMOOTHING_CENTER_WEIGHT = 0.6;
+const PRESSURE_SMOOTHING_NEIGHBOR_WEIGHT = 0.2;
+const OPACITY_SMOOTHING_CENTER_WEIGHT = 0.5;
+const OPACITY_SMOOTHING_NEIGHBOR_WEIGHT = 0.25;
+const OPACITY_SMOOTHING_PASSES = 2;
+const OPACITY_ENDPOINT_CENTER_WEIGHT = 0.25;
+const OPACITY_ENDPOINT_NEIGHBOR_WEIGHT = 0.75;
+const MIN_RENDER_SEGMENT_LENGTH_PX = 4;
+const RENDER_SEGMENT_LENGTH_STROKE_WIDTH_SCALE = 0.25;
+const PRESSURE_DELTA_STEP_SCALE = 8;
+const OPACITY_DELTA_STEP_SCALE = 12;
+const MAX_RENDER_SUBSEGMENTS_PER_SEGMENT = 24;
+const MIN_OPACITY_STAMP_SPACING_PX = 1;
+const OPACITY_STAMP_SPACING_STROKE_WIDTH_SCALE = 0.03;
+const MAX_OPACITY_STAMPS_PER_SEGMENT = 128;
+const INCREMENTAL_PREVIEW_POINT_OVERLAP = 2;
+
+type PressureStrokeRenderOp =
+ | {
+ type: 'dot';
+ x: number;
+ y: number;
+ radius: number;
+ color: RgbaColor;
+ strokeDistance?: number;
+ }
+ | {
+ type: 'segment';
+ from: Coordinate;
+ to: Coordinate;
+ width: number;
+ color: RgbaColor;
+ };
+
+export type PressureStrokeCanvasTarget = {
+ canvas: HTMLCanvasElement;
+ x: number;
+ y: number;
+ imageData: ImageData;
+};
+
+type PressureStrokeOpacityDotRenderOp = Extract & {
+ strokeDistance: number;
+};
+
+const clampPressure = (pressure: number): number => Math.min(Math.max(pressure, 0), 1);
+
+const getPressureWidthFactor = (pressure: number, pressureAffectsWidth: boolean): number => {
+ if (!pressureAffectsWidth) {
+ return 1;
+ }
+
+ return Math.max(clampPressure(pressure), MIN_PRESSURE_FACTOR);
+};
+
+const getPressureOpacityFactor = (pressure: number, pressureAffectsOpacity: boolean): number => {
+ if (!pressureAffectsOpacity) {
+ return 1;
+ }
+
+ return clampPressure(pressure);
+};
+
+const scaleColorOpacity = (color: RgbaColor, opacityFactor: number): RgbaColor => ({
+ ...color,
+ a: Math.min(Math.max(color.a * opacityFactor, 0), 1),
+});
+
+const chunkPressurePoints = (points: number[]): CoordinateWithPressure[] => {
+ const chunked: CoordinateWithPressure[] = [];
+
+ for (let i = 0; i < points.length; i += 3) {
+ const x = points[i];
+ const y = points[i + 1];
+ const pressure = points[i + 2];
+
+ if (x === undefined || y === undefined || pressure === undefined) {
+ continue;
+ }
+
+ chunked.push({ x, y, pressure });
+ }
+
+ return chunked;
+};
+
+const smoothPressurePointsWithWeights = (
+ points: CoordinateWithPressure[],
+ centerWeight: number,
+ neighborWeight: number,
+ passes: number = 1
+): CoordinateWithPressure[] => {
+ if (points.length <= 2) {
+ return points;
+ }
+
+ let smoothedPoints = points;
+
+ for (let pass = 0; pass < passes; pass++) {
+ smoothedPoints = smoothedPoints.map((point, index) => {
+ if (index === 0 || index === smoothedPoints.length - 1) {
+ return point;
+ }
+
+ const prevPoint = smoothedPoints[index - 1];
+ const nextPoint = smoothedPoints[index + 1];
+
+ if (!prevPoint || !nextPoint) {
+ return point;
+ }
+
+ return {
+ ...point,
+ pressure: clampPressure(
+ prevPoint.pressure * neighborWeight + point.pressure * centerWeight + nextPoint.pressure * neighborWeight
+ ),
+ };
+ });
+ }
+
+ return smoothedPoints;
+};
+
+const smoothPressurePoints = (points: CoordinateWithPressure[]): CoordinateWithPressure[] =>
+ smoothPressurePointsWithWeights(points, PRESSURE_SMOOTHING_CENTER_WEIGHT, PRESSURE_SMOOTHING_NEIGHBOR_WEIGHT);
+
+const smoothStrokeGeometryPoints = (points: CoordinateWithPressure[]): CoordinateWithPressure[] => {
+ if (points.length <= 2) {
+ return points;
+ }
+
+ const smoothed: CoordinateWithPressure[] = [];
+ const firstPoint = points[0];
+ const lastPoint = points.at(-1);
+
+ if (!firstPoint || !lastPoint) {
+ return points;
+ }
+
+ smoothed.push(firstPoint);
+
+ for (let i = 0; i < points.length - 1; i++) {
+ const point = points[i];
+ const nextPoint = points[i + 1];
+
+ if (!point || !nextPoint) {
+ continue;
+ }
+
+ smoothed.push({
+ x: point.x * 0.75 + nextPoint.x * 0.25,
+ y: point.y * 0.75 + nextPoint.y * 0.25,
+ pressure: clampPressure(point.pressure * 0.75 + nextPoint.pressure * 0.25),
+ });
+ smoothed.push({
+ x: point.x * 0.25 + nextPoint.x * 0.75,
+ y: point.y * 0.25 + nextPoint.y * 0.75,
+ pressure: clampPressure(point.pressure * 0.25 + nextPoint.pressure * 0.75),
+ });
+ }
+
+ smoothed.push(lastPoint);
+
+ return smoothed;
+};
+
+const smoothOpacityPressurePoints = (points: CoordinateWithPressure[]): CoordinateWithPressure[] =>
+ smoothPressurePointsWithWeights(
+ points,
+ OPACITY_SMOOTHING_CENTER_WEIGHT,
+ OPACITY_SMOOTHING_NEIGHBOR_WEIGHT,
+ OPACITY_SMOOTHING_PASSES
+ );
+
+const smoothOpacityEndpointPressurePoints = (points: CoordinateWithPressure[]): CoordinateWithPressure[] => {
+ if (points.length <= 1) {
+ return points;
+ }
+
+ const smoothedPoints = [...points];
+ const firstPoint = smoothedPoints[0];
+ const secondPoint = smoothedPoints[1];
+ const lastPoint = smoothedPoints.at(-1);
+ const penultimatePoint = smoothedPoints.at(-2);
+
+ if (firstPoint && secondPoint) {
+ smoothedPoints[0] = {
+ ...firstPoint,
+ pressure: clampPressure(
+ firstPoint.pressure * OPACITY_ENDPOINT_CENTER_WEIGHT + secondPoint.pressure * OPACITY_ENDPOINT_NEIGHBOR_WEIGHT
+ ),
+ };
+ }
+
+ if (lastPoint && penultimatePoint) {
+ smoothedPoints[smoothedPoints.length - 1] = {
+ ...lastPoint,
+ pressure: clampPressure(
+ lastPoint.pressure * OPACITY_ENDPOINT_CENTER_WEIGHT +
+ penultimatePoint.pressure * OPACITY_ENDPOINT_NEIGHBOR_WEIGHT
+ ),
+ };
+ }
+
+ return smoothedPoints;
+};
+
+const buildOpacityStampRenderOps = (arg: {
+ pressurePoints: CoordinateWithPressure[];
+ strokeWidth: number;
+ color: RgbaColor;
+ pressureAffectsWidth: boolean;
+ includeLeadingDot: boolean;
+}): PressureStrokeRenderOp[] => {
+ const { pressurePoints, strokeWidth, color, pressureAffectsWidth, includeLeadingDot } = arg;
+
+ if (pressurePoints.length === 0) {
+ return [];
+ }
+
+ if (pressurePoints.length === 1) {
+ if (!includeLeadingDot) {
+ return [];
+ }
+
+ const point = pressurePoints[0];
+
+ if (!point) {
+ return [];
+ }
+
+ const widthFactor = getPressureWidthFactor(point.pressure, pressureAffectsWidth);
+ const opacityFactor = clampPressure(point.pressure);
+
+ return [
+ {
+ type: 'dot',
+ x: point.x,
+ y: point.y,
+ radius: (strokeWidth * widthFactor) / 2,
+ color: scaleColorOpacity(color, opacityFactor),
+ },
+ ];
+ }
+
+ const ops: PressureStrokeRenderOp[] = [];
+ let strokeDistance = 0;
+ for (let i = 1; i < pressurePoints.length; i++) {
+ const prevPoint = pressurePoints[i - 1];
+ const nextPoint = pressurePoints[i];
+
+ if (!prevPoint || !nextPoint) {
+ continue;
+ }
+
+ const distance = Math.hypot(nextPoint.x - prevPoint.x, nextPoint.y - prevPoint.y);
+ const averageWidthFactor =
+ (getPressureWidthFactor(prevPoint.pressure, pressureAffectsWidth) +
+ getPressureWidthFactor(nextPoint.pressure, pressureAffectsWidth)) /
+ 2;
+ const stampSpacing = Math.max(
+ MIN_OPACITY_STAMP_SPACING_PX,
+ strokeWidth * averageWidthFactor * OPACITY_STAMP_SPACING_STROKE_WIDTH_SCALE
+ );
+ const stampCount = Math.min(
+ MAX_OPACITY_STAMPS_PER_SEGMENT,
+ Math.max(
+ 1,
+ Math.ceil(distance / stampSpacing),
+ Math.ceil(Math.abs(nextPoint.pressure - prevPoint.pressure) * OPACITY_DELTA_STEP_SCALE)
+ )
+ );
+ const sampleStart = includeLeadingDot && i === 1 ? 0 : 1;
+ const segmentStartDistance = strokeDistance;
+
+ for (let sampleIndex = sampleStart; sampleIndex <= stampCount; sampleIndex++) {
+ const t = sampleIndex / stampCount;
+ const samplePoint = lerpPointWithPressure(prevPoint, nextPoint, t);
+ const widthFactor = getPressureWidthFactor(samplePoint.pressure, pressureAffectsWidth);
+ const opacityFactor = clampPressure(samplePoint.pressure);
+
+ ops.push({
+ type: 'dot',
+ x: samplePoint.x,
+ y: samplePoint.y,
+ radius: (strokeWidth * widthFactor) / 2,
+ color: scaleColorOpacity(color, opacityFactor),
+ strokeDistance: segmentStartDistance + distance * t,
+ });
+ }
+
+ strokeDistance += distance;
+ }
+
+ return ops;
+};
+
+const lerp = (from: number, to: number, t: number): number => from + (to - from) * t;
+
+const lerpPointWithPressure = (
+ from: CoordinateWithPressure,
+ to: CoordinateWithPressure,
+ t: number
+): CoordinateWithPressure => ({
+ x: lerp(from.x, to.x, t),
+ y: lerp(from.y, to.y, t),
+ pressure: lerp(from.pressure, to.pressure, t),
+});
+
+export const getShouldUsePressureForBrush = (pressureAffectsWidth: boolean, pressureAffectsOpacity: boolean): boolean =>
+ pressureAffectsWidth || pressureAffectsOpacity;
+
+export const getShouldUsePressureForEraser = (pressureAffectsWidth: boolean): boolean => pressureAffectsWidth;
+
+const getPressureStrokeRenderOpBounds = (renderOp: PressureStrokeRenderOp): Rect | null => {
+ if (renderOp.color.a <= 0) {
+ return null;
+ }
+
+ if (renderOp.type === 'dot') {
+ if (renderOp.radius <= 0) {
+ return null;
+ }
+
+ const x = Math.floor(renderOp.x - renderOp.radius - PRESSURE_STROKE_RENDER_PADDING_PX);
+ const y = Math.floor(renderOp.y - renderOp.radius - PRESSURE_STROKE_RENDER_PADDING_PX);
+ const maxX = Math.ceil(renderOp.x + renderOp.radius + PRESSURE_STROKE_RENDER_PADDING_PX);
+ const maxY = Math.ceil(renderOp.y + renderOp.radius + PRESSURE_STROKE_RENDER_PADDING_PX);
+
+ return {
+ x,
+ y,
+ width: maxX - x,
+ height: maxY - y,
+ };
+ }
+
+ if (renderOp.width <= 0) {
+ return null;
+ }
+
+ const radius = renderOp.width / 2;
+ const x = Math.floor(Math.min(renderOp.from.x, renderOp.to.x) - radius - PRESSURE_STROKE_RENDER_PADDING_PX);
+ const y = Math.floor(Math.min(renderOp.from.y, renderOp.to.y) - radius - PRESSURE_STROKE_RENDER_PADDING_PX);
+ const maxX = Math.ceil(Math.max(renderOp.from.x, renderOp.to.x) + radius + PRESSURE_STROKE_RENDER_PADDING_PX);
+ const maxY = Math.ceil(Math.max(renderOp.from.y, renderOp.to.y) + radius + PRESSURE_STROKE_RENDER_PADDING_PX);
+
+ return {
+ x,
+ y,
+ width: maxX - x,
+ height: maxY - y,
+ };
+};
+
+export const getPressureStrokeRenderBounds = (renderOps: PressureStrokeRenderOp[]): Rect | null => {
+ let minX = Infinity;
+ let minY = Infinity;
+ let maxX = -Infinity;
+ let maxY = -Infinity;
+
+ for (const renderOp of renderOps) {
+ const bounds = getPressureStrokeRenderOpBounds(renderOp);
+
+ if (!bounds) {
+ continue;
+ }
+
+ minX = Math.min(minX, bounds.x);
+ minY = Math.min(minY, bounds.y);
+ maxX = Math.max(maxX, bounds.x + bounds.width);
+ maxY = Math.max(maxY, bounds.y + bounds.height);
+ }
+
+ if (!Number.isFinite(minX) || !Number.isFinite(minY) || !Number.isFinite(maxX) || !Number.isFinite(maxY)) {
+ return null;
+ }
+
+ return {
+ x: minX,
+ y: minY,
+ width: maxX - minX,
+ height: maxY - minY,
+ };
+};
+
+const mergeRects = (a: Rect, b: Rect): Rect => {
+ const x = Math.min(a.x, b.x);
+ const y = Math.min(a.y, b.y);
+ const maxX = Math.max(a.x + a.width, b.x + b.width);
+ const maxY = Math.max(a.y + a.height, b.y + b.height);
+
+ return {
+ x,
+ y,
+ width: maxX - x,
+ height: maxY - y,
+ };
+};
+
+const isOpacityDotRenderOp = (renderOp: PressureStrokeRenderOp): renderOp is PressureStrokeOpacityDotRenderOp =>
+ renderOp.type === 'dot' && typeof renderOp.strokeDistance === 'number';
+
+const compositeSourceOverAlphaByte = (currentAlpha: number, candidateAlpha: number): number =>
+ Math.round(currentAlpha + (candidateAlpha * (255 - currentAlpha)) / 255);
+
+export const mergeOpacityDotAlphaAtPixel = (arg: {
+ currentAlpha: number;
+ candidateAlpha: number;
+ lastStrokeDistance: number;
+ strokeDistance: number;
+ lastRadius: number;
+ radius: number;
+}): { alpha: number; lastStrokeDistance: number; lastRadius: number } => {
+ const { currentAlpha, candidateAlpha, lastStrokeDistance, strokeDistance, lastRadius, radius } = arg;
+
+ if (currentAlpha <= 0 || !Number.isFinite(lastStrokeDistance)) {
+ return {
+ alpha: candidateAlpha,
+ lastStrokeDistance: strokeDistance,
+ lastRadius: radius,
+ };
+ }
+
+ const revisitDistanceThreshold = Math.max(lastRadius, radius) * 2;
+ const isSameLocalPass = strokeDistance - lastStrokeDistance <= revisitDistanceThreshold;
+
+ if (isSameLocalPass) {
+ return {
+ alpha: Math.max(currentAlpha, candidateAlpha),
+ lastStrokeDistance: strokeDistance,
+ lastRadius: Math.max(lastRadius, radius),
+ };
+ }
+
+ return {
+ alpha: compositeSourceOverAlphaByte(currentAlpha, candidateAlpha),
+ lastStrokeDistance: strokeDistance,
+ lastRadius: radius,
+ };
+};
+
+const getCanvasContext = (
+ canvas: HTMLCanvasElement,
+ width: number,
+ height: number,
+ clear: boolean = false
+): CanvasRenderingContext2D | null => {
+ if (canvas.width !== width) {
+ canvas.width = width;
+ }
+
+ if (canvas.height !== height) {
+ canvas.height = height;
+ }
+
+ const ctx = canvas.getContext('2d');
+
+ if (!ctx) {
+ return null;
+ }
+
+ if (clear) {
+ ctx.clearRect(0, 0, width, height);
+ }
+ ctx.imageSmoothingEnabled = true;
+ ctx.fillStyle = 'rgba(0, 0, 0, 1)';
+ ctx.strokeStyle = 'rgba(0, 0, 0, 1)';
+ ctx.lineCap = 'round';
+ ctx.lineJoin = 'round';
+
+ return ctx;
+};
+
+const copyImageData = (source: ImageData, destination: ImageData, offsetX: number, offsetY: number) => {
+ const sourceData = source.data;
+ const destinationData = destination.data;
+
+ for (let y = 0; y < source.height; y++) {
+ const sourceRowStart = y * source.width * 4;
+ const destinationRowStart = ((y + offsetY) * destination.width + offsetX) * 4;
+ destinationData.set(sourceData.subarray(sourceRowStart, sourceRowStart + source.width * 4), destinationRowStart);
+ }
+};
+
+const ensurePressureStrokeCanvasTarget = (
+ target: PressureStrokeCanvasTarget | null,
+ requiredBounds: Rect
+): PressureStrokeCanvasTarget | null => {
+ if (!target) {
+ const canvas = document.createElement('canvas');
+ const ctx = getCanvasContext(canvas, requiredBounds.width, requiredBounds.height);
+
+ if (!ctx) {
+ return null;
+ }
+
+ return {
+ canvas,
+ x: requiredBounds.x,
+ y: requiredBounds.y,
+ imageData: ctx.createImageData(requiredBounds.width, requiredBounds.height),
+ };
+ }
+
+ const currentBounds = {
+ x: target.x,
+ y: target.y,
+ width: target.imageData.width,
+ height: target.imageData.height,
+ };
+ const nextBounds = mergeRects(currentBounds, requiredBounds);
+
+ if (
+ nextBounds.x === currentBounds.x &&
+ nextBounds.y === currentBounds.y &&
+ nextBounds.width === currentBounds.width &&
+ nextBounds.height === currentBounds.height
+ ) {
+ return target;
+ }
+
+ const ctx = getCanvasContext(target.canvas, nextBounds.width, nextBounds.height);
+
+ if (!ctx) {
+ return null;
+ }
+
+ const nextImageData = ctx.createImageData(nextBounds.width, nextBounds.height);
+ copyImageData(target.imageData, nextImageData, currentBounds.x - nextBounds.x, currentBounds.y - nextBounds.y);
+
+ target.x = nextBounds.x;
+ target.y = nextBounds.y;
+ target.imageData = nextImageData;
+
+ return target;
+};
+
+export const appendPressureStrokeRenderOpsToCanvas = (
+ target: PressureStrokeCanvasTarget | null,
+ renderOps: PressureStrokeRenderOp[]
+): PressureStrokeCanvasTarget | null => {
+ const renderBounds = getPressureStrokeRenderBounds(renderOps);
+
+ if (!renderBounds || renderBounds.width <= 0 || renderBounds.height <= 0) {
+ return target;
+ }
+
+ const nextTarget = ensurePressureStrokeCanvasTarget(target, renderBounds);
+
+ if (!nextTarget) {
+ return null;
+ }
+
+ const ctx = getCanvasContext(nextTarget.canvas, nextTarget.imageData.width, nextTarget.imageData.height);
+
+ if (!ctx) {
+ return null;
+ }
+
+ const output = nextTarget.imageData.data;
+ const patchCanvas = document.createElement('canvas');
+ for (const renderOp of renderOps) {
+ const patchBounds = getPressureStrokeRenderOpBounds(renderOp);
+ const opacityByte = Math.round(clampPressure(renderOp.color.a) * 255);
+
+ if (!patchBounds || opacityByte === 0) {
+ continue;
+ }
+
+ const patchCtx = getCanvasContext(patchCanvas, patchBounds.width, patchBounds.height, true);
+
+ if (!patchCtx) {
+ return null;
+ }
+
+ if (renderOp.type === 'dot') {
+ patchCtx.beginPath();
+ patchCtx.arc(renderOp.x - patchBounds.x, renderOp.y - patchBounds.y, renderOp.radius, 0, Math.PI * 2);
+ patchCtx.fill();
+ } else {
+ patchCtx.lineCap = 'round';
+ patchCtx.beginPath();
+ patchCtx.lineWidth = renderOp.width;
+ patchCtx.moveTo(renderOp.from.x - patchBounds.x, renderOp.from.y - patchBounds.y);
+ patchCtx.lineTo(renderOp.to.x - patchBounds.x, renderOp.to.y - patchBounds.y);
+ patchCtx.stroke();
+ }
+
+ const patchData = patchCtx.getImageData(0, 0, patchBounds.width, patchBounds.height).data;
+ const targetOffsetX = patchBounds.x - nextTarget.x;
+ const targetOffsetY = patchBounds.y - nextTarget.y;
+
+ for (let patchY = 0; patchY < patchBounds.height; patchY++) {
+ for (let patchX = 0; patchX < patchBounds.width; patchX++) {
+ const patchIndex = (patchY * patchBounds.width + patchX) * 4;
+ const coverageAlpha = patchData[patchIndex + 3];
+
+ if (!coverageAlpha) {
+ continue;
+ }
+
+ const targetX = targetOffsetX + patchX;
+ const targetY = targetOffsetY + patchY;
+ const targetIndex = (targetY * nextTarget.imageData.width + targetX) * 4;
+ const candidateAlpha = Math.round((coverageAlpha * opacityByte) / 255);
+ const currentAlpha = output[targetIndex + 3] ?? 0;
+
+ if (candidateAlpha <= currentAlpha) {
+ continue;
+ }
+
+ output[targetIndex] = renderOp.color.r;
+ output[targetIndex + 1] = renderOp.color.g;
+ output[targetIndex + 2] = renderOp.color.b;
+ output[targetIndex + 3] = candidateAlpha;
+ }
+ }
+ }
+
+ ctx.putImageData(nextTarget.imageData, 0, 0);
+
+ return nextTarget;
+};
+
+const renderOpacityStrokeDotsToCanvas = (
+ renderOps: PressureStrokeOpacityDotRenderOp[]
+): { canvas: HTMLCanvasElement; x: number; y: number } | null => {
+ const bounds = getPressureStrokeRenderBounds(renderOps);
+
+ if (!bounds || bounds.width <= 0 || bounds.height <= 0) {
+ return null;
+ }
+
+ const canvas = document.createElement('canvas');
+ const ctx = getCanvasContext(canvas, bounds.width, bounds.height);
+
+ if (!ctx) {
+ return null;
+ }
+
+ const imageData = ctx.createImageData(bounds.width, bounds.height);
+ const output = imageData.data;
+ const lastStrokeDistances = new Float32Array(bounds.width * bounds.height);
+ lastStrokeDistances.fill(Number.NEGATIVE_INFINITY);
+ const lastRadii = new Float32Array(bounds.width * bounds.height);
+ const patchCanvas = document.createElement('canvas');
+
+ for (const renderOp of renderOps) {
+ const patchBounds = getPressureStrokeRenderOpBounds(renderOp);
+ const opacityByte = Math.round(clampPressure(renderOp.color.a) * 255);
+
+ if (!patchBounds || opacityByte === 0) {
+ continue;
+ }
+
+ const patchCtx = getCanvasContext(patchCanvas, patchBounds.width, patchBounds.height, true);
+
+ if (!patchCtx) {
+ return null;
+ }
+
+ patchCtx.beginPath();
+ patchCtx.arc(renderOp.x - patchBounds.x, renderOp.y - patchBounds.y, renderOp.radius, 0, Math.PI * 2);
+ patchCtx.fill();
+
+ const patchData = patchCtx.getImageData(0, 0, patchBounds.width, patchBounds.height).data;
+ const targetOffsetX = patchBounds.x - bounds.x;
+ const targetOffsetY = patchBounds.y - bounds.y;
+
+ for (let patchY = 0; patchY < patchBounds.height; patchY++) {
+ for (let patchX = 0; patchX < patchBounds.width; patchX++) {
+ const patchIndex = (patchY * patchBounds.width + patchX) * 4;
+ const coverageAlpha = patchData[patchIndex + 3];
+
+ if (!coverageAlpha) {
+ continue;
+ }
+
+ const targetX = targetOffsetX + patchX;
+ const targetY = targetOffsetY + patchY;
+ const pixelIndex = targetY * bounds.width + targetX;
+ const targetIndex = pixelIndex * 4;
+ const candidateAlpha = Math.round((coverageAlpha * opacityByte) / 255);
+
+ const merged = mergeOpacityDotAlphaAtPixel({
+ currentAlpha: output[targetIndex + 3] ?? 0,
+ candidateAlpha,
+ lastStrokeDistance: lastStrokeDistances[pixelIndex] ?? Number.NEGATIVE_INFINITY,
+ strokeDistance: renderOp.strokeDistance,
+ lastRadius: lastRadii[pixelIndex] ?? 0,
+ radius: renderOp.radius,
+ });
+
+ output[targetIndex] = renderOp.color.r;
+ output[targetIndex + 1] = renderOp.color.g;
+ output[targetIndex + 2] = renderOp.color.b;
+ output[targetIndex + 3] = merged.alpha;
+ lastStrokeDistances[pixelIndex] = merged.lastStrokeDistance;
+ lastRadii[pixelIndex] = merged.lastRadius;
+ }
+ }
+ }
+
+ ctx.putImageData(imageData, 0, 0);
+
+ return {
+ canvas,
+ x: bounds.x,
+ y: bounds.y,
+ };
+};
+
+export const renderPressureStrokeToCanvas = (
+ renderOps: PressureStrokeRenderOp[]
+): { canvas: HTMLCanvasElement; x: number; y: number } | null => {
+ if (renderOps.length > 0 && renderOps.every(isOpacityDotRenderOp)) {
+ return renderOpacityStrokeDotsToCanvas(renderOps);
+ }
+
+ const target = appendPressureStrokeRenderOpsToCanvas(null, renderOps);
+
+ if (!target) {
+ return null;
+ }
+
+ return {
+ canvas: target.canvas,
+ x: target.x,
+ y: target.y,
+ };
+};
+
+const buildPressureStrokeRenderOps = (arg: {
+ pressurePoints: CoordinateWithPressure[];
+ strokeWidth: number;
+ color: RgbaColor;
+ pressureAffectsWidth: boolean;
+ pressureAffectsOpacity: boolean;
+ includeLeadingDot: boolean;
+ includeTrailingDot: boolean;
+}): PressureStrokeRenderOp[] => {
+ const {
+ pressurePoints,
+ strokeWidth,
+ color,
+ pressureAffectsWidth,
+ pressureAffectsOpacity,
+ includeLeadingDot,
+ includeTrailingDot,
+ } = arg;
+
+ if (pressurePoints.length === 0) {
+ return [];
+ }
+
+ if (pressurePoints.length === 1) {
+ if (!includeLeadingDot) {
+ return [];
+ }
+
+ const point = pressurePoints[0];
+
+ if (!point) {
+ return [];
+ }
+
+ const widthFactor = getPressureWidthFactor(point.pressure, pressureAffectsWidth);
+ const opacityFactor = getPressureOpacityFactor(point.pressure, pressureAffectsOpacity);
+
+ return [
+ {
+ type: 'dot',
+ x: point.x,
+ y: point.y,
+ radius: (strokeWidth * widthFactor) / 2,
+ color: scaleColorOpacity(color, opacityFactor),
+ },
+ ];
+ }
+
+ if (pressureAffectsOpacity) {
+ return buildOpacityStampRenderOps({
+ pressurePoints,
+ strokeWidth,
+ color,
+ pressureAffectsWidth,
+ includeLeadingDot,
+ });
+ }
+
+ const ops: PressureStrokeRenderOp[] = [];
+ const targetSegmentLength = Math.max(
+ MIN_RENDER_SEGMENT_LENGTH_PX,
+ strokeWidth * RENDER_SEGMENT_LENGTH_STROKE_WIDTH_SCALE
+ );
+ const pressureDeltaStepScale = PRESSURE_DELTA_STEP_SCALE;
+ const firstPoint = pressurePoints[0];
+ const lastPoint = pressurePoints.at(-1);
+
+ if (includeLeadingDot && firstPoint) {
+ const widthFactor = getPressureWidthFactor(firstPoint.pressure, pressureAffectsWidth);
+ const opacityFactor = getPressureOpacityFactor(firstPoint.pressure, pressureAffectsOpacity);
+
+ ops.push({
+ type: 'dot',
+ x: firstPoint.x,
+ y: firstPoint.y,
+ radius: (strokeWidth * widthFactor) / 2,
+ color: scaleColorOpacity(color, opacityFactor),
+ });
+ }
+
+ for (let i = 1; i < pressurePoints.length; i++) {
+ const prevPoint = pressurePoints[i - 1];
+ const nextPoint = pressurePoints[i];
+
+ if (!prevPoint || !nextPoint) {
+ continue;
+ }
+
+ const distance = Math.hypot(nextPoint.x - prevPoint.x, nextPoint.y - prevPoint.y);
+ const subsegmentCount = Math.min(
+ MAX_RENDER_SUBSEGMENTS_PER_SEGMENT,
+ Math.max(
+ 1,
+ Math.ceil(distance / targetSegmentLength),
+ Math.ceil(Math.abs(nextPoint.pressure - prevPoint.pressure) * pressureDeltaStepScale)
+ )
+ );
+
+ for (let subsegmentIndex = 0; subsegmentIndex < subsegmentCount; subsegmentIndex++) {
+ const fromT = subsegmentIndex / subsegmentCount;
+ const toT = (subsegmentIndex + 1) / subsegmentCount;
+ const subsegmentFrom = lerpPointWithPressure(prevPoint, nextPoint, fromT);
+ const subsegmentTo = lerpPointWithPressure(prevPoint, nextPoint, toT);
+ const segmentPressure = (subsegmentFrom.pressure + subsegmentTo.pressure) / 2;
+ const widthFactor = getPressureWidthFactor(segmentPressure, pressureAffectsWidth);
+ const opacityFactor = getPressureOpacityFactor(segmentPressure, pressureAffectsOpacity);
+
+ ops.push({
+ type: 'segment',
+ from: { x: subsegmentFrom.x, y: subsegmentFrom.y },
+ to: { x: subsegmentTo.x, y: subsegmentTo.y },
+ width: strokeWidth * widthFactor,
+ color: scaleColorOpacity(color, opacityFactor),
+ });
+ }
+ }
+
+ if (includeTrailingDot && lastPoint) {
+ const widthFactor = getPressureWidthFactor(lastPoint.pressure, pressureAffectsWidth);
+ const opacityFactor = getPressureOpacityFactor(lastPoint.pressure, pressureAffectsOpacity);
+
+ ops.push({
+ type: 'dot',
+ x: lastPoint.x,
+ y: lastPoint.y,
+ radius: (strokeWidth * widthFactor) / 2,
+ color: scaleColorOpacity(color, opacityFactor),
+ });
+ }
+
+ return ops;
+};
+
+export const getPressureStrokeRenderOps = (arg: {
+ points: number[];
+ strokeWidth: number;
+ color: RgbaColor;
+ pressureAffectsWidth: boolean;
+ pressureAffectsOpacity: boolean;
+}): PressureStrokeRenderOp[] => {
+ const { points, strokeWidth, color, pressureAffectsWidth, pressureAffectsOpacity } = arg;
+ const geometryPoints = smoothStrokeGeometryPoints(smoothPressurePoints(chunkPressurePoints(points)));
+ const pressurePoints = pressureAffectsOpacity
+ ? smoothOpacityEndpointPressurePoints(smoothOpacityPressurePoints(geometryPoints))
+ : geometryPoints;
+
+ return buildPressureStrokeRenderOps({
+ pressurePoints,
+ strokeWidth,
+ color,
+ pressureAffectsWidth,
+ pressureAffectsOpacity,
+ includeLeadingDot: true,
+ includeTrailingDot: true,
+ });
+};
+
+export const getPressureStrokeRenderOpsFromPointIndex = (arg: {
+ points: number[];
+ strokeWidth: number;
+ color: RgbaColor;
+ pressureAffectsWidth: boolean;
+ pressureAffectsOpacity: boolean;
+ startPointIndex: number;
+}): PressureStrokeRenderOp[] => {
+ const { points, strokeWidth, color, pressureAffectsWidth, pressureAffectsOpacity, startPointIndex } = arg;
+ const geometryPoints = smoothStrokeGeometryPoints(
+ smoothPressurePoints(
+ chunkPressurePoints(points).slice(Math.max(0, startPointIndex - INCREMENTAL_PREVIEW_POINT_OVERLAP))
+ )
+ );
+ const pressurePoints = pressureAffectsOpacity
+ ? smoothOpacityEndpointPressurePoints(smoothOpacityPressurePoints(geometryPoints))
+ : geometryPoints;
+
+ return buildPressureStrokeRenderOps({
+ pressurePoints,
+ strokeWidth,
+ color,
+ pressureAffectsWidth,
+ pressureAffectsOpacity,
+ includeLeadingDot: false,
+ includeTrailingDot: false,
+ });
+};
diff --git a/invokeai/frontend/web/src/features/controlLayers/store/canvasSettingsSlice.test.ts b/invokeai/frontend/web/src/features/controlLayers/store/canvasSettingsSlice.test.ts
new file mode 100644
index 00000000000..b936053dac3
--- /dev/null
+++ b/invokeai/frontend/web/src/features/controlLayers/store/canvasSettingsSlice.test.ts
@@ -0,0 +1,61 @@
+import type { Equals } from 'tsafe';
+import { assert } from 'tsafe';
+import { describe, expect, it } from 'vitest';
+import type { z } from 'zod';
+
+import {
+ canvasSettingsSliceConfig,
+ settingsPressureAffectsOpacityToggled,
+ settingsPressureAffectsWidthToggled,
+} from './canvasSettingsSlice';
+
+describe('canvasSettingsSlice', () => {
+ type InitialState = ReturnType;
+ type SchemaState = z.infer;
+
+ const { reducer } = canvasSettingsSliceConfig.slice;
+ const migrate = canvasSettingsSliceConfig.persistConfig?.migrate;
+
+ it('keeps the initial state aligned with the persisted schema', () => {
+ assert>();
+ });
+
+ it('toggles pressure-width and pressure-opacity independently', () => {
+ const state = canvasSettingsSliceConfig.getInitialState();
+
+ const pressureWidthDisabled = reducer(state, settingsPressureAffectsWidthToggled());
+ const pressureOpacityEnabled = reducer(pressureWidthDisabled, settingsPressureAffectsOpacityToggled());
+
+ expect(pressureWidthDisabled.pressureAffectsWidth).toBe(false);
+ expect(pressureWidthDisabled.pressureAffectsOpacity).toBe(false);
+ expect(pressureOpacityEnabled.pressureAffectsWidth).toBe(false);
+ expect(pressureOpacityEnabled.pressureAffectsOpacity).toBe(true);
+ });
+
+ it('migrates legacy pressureSensitivity to pressureAffectsWidth and leaves opacity disabled', () => {
+ expect(migrate).toBeDefined();
+
+ const result = migrate?.({
+ ...canvasSettingsSliceConfig.getInitialState(),
+ pressureSensitivity: true,
+ pressureAffectsWidth: undefined,
+ pressureAffectsOpacity: undefined,
+ }) as InitialState;
+
+ expect(result.pressureAffectsWidth).toBe(true);
+ expect(result.pressureAffectsOpacity).toBe(false);
+ });
+
+ it('preserves explicit split pressure settings during migration', () => {
+ expect(migrate).toBeDefined();
+
+ const result = migrate?.({
+ ...canvasSettingsSliceConfig.getInitialState(),
+ pressureAffectsWidth: false,
+ pressureAffectsOpacity: true,
+ }) as InitialState;
+
+ expect(result.pressureAffectsWidth).toBe(false);
+ expect(result.pressureAffectsOpacity).toBe(true);
+ });
+});
diff --git a/invokeai/frontend/web/src/features/controlLayers/store/canvasSettingsSlice.ts b/invokeai/frontend/web/src/features/controlLayers/store/canvasSettingsSlice.ts
index 509aefdaaf2..8edc8ed8fdb 100644
--- a/invokeai/frontend/web/src/features/controlLayers/store/canvasSettingsSlice.ts
+++ b/invokeai/frontend/web/src/features/controlLayers/store/canvasSettingsSlice.ts
@@ -85,9 +85,13 @@ const zCanvasSettingsState = z.object({
*/
isolatedLayerPreview: z.boolean(),
/**
- * Whether to use pressure sensitivity for the brush and eraser tool when a pen device is used.
+ * Whether pen pressure affects brush and eraser width.
*/
- pressureSensitivity: z.boolean(),
+ pressureAffectsWidth: z.boolean(),
+ /**
+ * Whether pen pressure affects brush opacity.
+ */
+ pressureAffectsOpacity: z.boolean(),
/**
* Whether to show the rule of thirds composition guide overlay on the canvas.
*/
@@ -149,7 +153,8 @@ const getInitialState = (): CanvasSettingsState => ({
preserveMask: false,
isolatedStagingPreview: true,
isolatedLayerPreview: true,
- pressureSensitivity: true,
+ pressureAffectsWidth: true,
+ pressureAffectsOpacity: false,
ruleOfThirds: false,
saveAllImagesToGallery: false,
stagingAreaAutoSwitch: 'switch_on_start',
@@ -224,8 +229,11 @@ const slice = createSlice({
settingsIsolatedLayerPreviewToggled: (state) => {
state.isolatedLayerPreview = !state.isolatedLayerPreview;
},
- settingsPressureSensitivityToggled: (state) => {
- state.pressureSensitivity = !state.pressureSensitivity;
+ settingsPressureAffectsWidthToggled: (state) => {
+ state.pressureAffectsWidth = !state.pressureAffectsWidth;
+ },
+ settingsPressureAffectsOpacityToggled: (state) => {
+ state.pressureAffectsOpacity = !state.pressureAffectsOpacity;
},
settingsRuleOfThirdsToggled: (state) => {
state.ruleOfThirds = !state.ruleOfThirds;
@@ -285,7 +293,8 @@ export const {
settingsPreserveMaskToggled,
settingsIsolatedStagingPreviewToggled,
settingsIsolatedLayerPreviewToggled,
- settingsPressureSensitivityToggled,
+ settingsPressureAffectsWidthToggled,
+ settingsPressureAffectsOpacityToggled,
settingsRuleOfThirdsToggled,
settingsSaveAllImagesToGalleryToggled,
settingsTransformSmoothingEnabledToggled,
@@ -298,12 +307,38 @@ export const {
settingsLassoModeChanged,
} = slice.actions;
+const isRecord = (state: unknown): state is Record =>
+ typeof state === 'object' && state !== null && !Array.isArray(state);
+
+const migrateCanvasSettingsState = (state: unknown): CanvasSettingsState => {
+ if (!isRecord(state)) {
+ return zCanvasSettingsState.parse(state);
+ }
+
+ const migratedState: Record = { ...state };
+
+ if (migratedState.pressureAffectsWidth === undefined) {
+ migratedState.pressureAffectsWidth =
+ typeof state.pressureSensitivity === 'boolean'
+ ? state.pressureSensitivity
+ : getInitialState().pressureAffectsWidth;
+ }
+
+ if (migratedState.pressureAffectsOpacity === undefined) {
+ migratedState.pressureAffectsOpacity = getInitialState().pressureAffectsOpacity;
+ }
+
+ delete migratedState.pressureSensitivity;
+
+ return zCanvasSettingsState.parse(migratedState);
+};
+
export const canvasSettingsSliceConfig: SliceConfig = {
slice,
schema: zCanvasSettingsState,
getInitialState,
persistConfig: {
- migrate: (state) => zCanvasSettingsState.parse(state),
+ migrate: migrateCanvasSettingsState,
},
};
@@ -327,7 +362,8 @@ export const selectShowProgressOnCanvas = createCanvasSettingsSelector(
);
export const selectIsolatedStagingPreview = createCanvasSettingsSelector((settings) => settings.isolatedStagingPreview);
export const selectIsolatedLayerPreview = createCanvasSettingsSelector((settings) => settings.isolatedLayerPreview);
-export const selectPressureSensitivity = createCanvasSettingsSelector((settings) => settings.pressureSensitivity);
+export const selectPressureAffectsWidth = createCanvasSettingsSelector((settings) => settings.pressureAffectsWidth);
+export const selectPressureAffectsOpacity = createCanvasSettingsSelector((settings) => settings.pressureAffectsOpacity);
export const selectRuleOfThirds = createCanvasSettingsSelector((settings) => settings.ruleOfThirds);
export const selectSaveAllImagesToGallery = createCanvasSettingsSelector((settings) => settings.saveAllImagesToGallery);
export const selectStagingAreaAutoSwitch = createCanvasSettingsSelector((settings) => settings.stagingAreaAutoSwitch);
diff --git a/invokeai/frontend/web/src/features/controlLayers/store/types.ts b/invokeai/frontend/web/src/features/controlLayers/store/types.ts
index cbeccdfa930..5b42bd85664 100644
--- a/invokeai/frontend/web/src/features/controlLayers/store/types.ts
+++ b/invokeai/frontend/web/src/features/controlLayers/store/types.ts
@@ -226,6 +226,8 @@ const zCanvasBrushLineWithPressureState = z.object({
*/
points: zPointsWithPressure,
color: zRgbaColor,
+ pressureAffectsWidth: z.boolean().default(true),
+ pressureAffectsOpacity: z.boolean().default(false),
clip: zRect.nullable(),
globalCompositeOperation: z.string().optional(),
});
@@ -251,6 +253,7 @@ const zCanvasEraserLineWithPressureState = z.object({
* Points with pressure are in the format [x1, y1, pressure1, x2, y2, pressure2, ...]
*/
points: zPointsWithPressure,
+ pressureAffectsWidth: z.boolean().default(true),
clip: zRect.nullable(),
});
export type CanvasEraserLineWithPressureState = z.infer;