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;