feat: add feather brush tool with adjustable settings and blending functionality
- Implemented feather brush tool in the brushRaster module, allowing for feathered edges in brush strokes. - Added new FeatherControls component for UI adjustments of feather settings including size, radius, strength, and smoothing. - Updated brush preview logic to accommodate feather tool alongside existing brush and eraser tools. - Enhanced layer rendering to support feather mask previews and interactions. - Introduced blending logic for feathered strokes to mix blurred mask values with original pixels. - Added unit tests for feather blending functionality and tool keybindings. - Updated cursor handling to reflect feather tool usage.
This commit is contained in:
@@ -141,6 +141,7 @@ export type DocumentAddLayerMaskPayload = {
|
||||
layerId: LayerId;
|
||||
asset: Asset;
|
||||
maskLayer: RasterLayer;
|
||||
activeTool?: "brush" | "feather";
|
||||
};
|
||||
|
||||
export type LayerMaskOperation =
|
||||
@@ -645,8 +646,8 @@ export const documentAddLayerMaskCommand: Command<DocumentAddLayerMaskPayload> =
|
||||
maskEdit: { kind: "layerMask", targetLayerId: payload.layerId, maskLayerId: existingMaskId, maskAssetId: existingMaskLayer.assetId },
|
||||
tools: {
|
||||
...state.editor.tools,
|
||||
activeTool: "brush",
|
||||
interactionMode: { type: "tool", tool: "brush" },
|
||||
activeTool: payload.activeTool ?? "brush",
|
||||
interactionMode: { type: "tool", tool: payload.activeTool ?? "brush" },
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -678,8 +679,8 @@ export const documentAddLayerMaskCommand: Command<DocumentAddLayerMaskPayload> =
|
||||
maskEdit: { kind: "layerMask", targetLayerId: payload.layerId, maskLayerId: maskLayer.id, maskAssetId: payload.asset.id },
|
||||
tools: {
|
||||
...state.editor.tools,
|
||||
activeTool: "brush",
|
||||
interactionMode: { type: "tool", tool: "brush" },
|
||||
activeTool: payload.activeTool ?? "brush",
|
||||
interactionMode: { type: "tool", tool: payload.activeTool ?? "brush" },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -40,6 +40,7 @@ export const commandIds = {
|
||||
toolSetGenerateSettings: "tool.setGenerateSettings",
|
||||
toolChooseGenerateIntent: "tool.chooseGenerateIntent",
|
||||
toolSetBrushSettings: "tool.setBrushSettings",
|
||||
toolSetFeatherSettings: "tool.setFeatherSettings",
|
||||
toolSetChromaKeySettings: "tool.setChromaKeySettings",
|
||||
toolSetMagicWandSettings: "tool.setMagicWandSettings",
|
||||
toolSetBrushPreview: "tool.setBrushPreview",
|
||||
|
||||
@@ -54,7 +54,7 @@ import type {
|
||||
CommandPaletteSetSelectedIndexPayload,
|
||||
} from "./palette";
|
||||
import type { SelectionAddLayerPayload, SelectionSetPayload } from "./selection";
|
||||
import type { ToolAppendMaskShapePayload, ToolBeginMaskShapePayload, ToolChooseGenerateIntentPayload, ToolEnterInpaintRegionEditPayload, ToolEnterMaskEditPayload, ToolSetActivePayload, ToolSetBrushPreviewPayload, ToolSetBrushSettingsPayload, ToolSetBrushStrokePreviewPayload, ToolSetChromaKeySettingsPayload, ToolSetGenerateSettingsPayload, ToolSetMagicWandSettingsPayload, ToolSetMaskViewModePayload } from "./tool";
|
||||
import type { ToolAppendMaskShapePayload, ToolBeginMaskShapePayload, ToolChooseGenerateIntentPayload, ToolEnterInpaintRegionEditPayload, ToolEnterMaskEditPayload, ToolSetActivePayload, ToolSetBrushPreviewPayload, ToolSetBrushSettingsPayload, ToolSetBrushStrokePreviewPayload, ToolSetChromaKeySettingsPayload, ToolSetFeatherSettingsPayload, ToolSetGenerateSettingsPayload, ToolSetMagicWandSettingsPayload, ToolSetMaskViewModePayload } from "./tool";
|
||||
import type { TransformBeginPayload, TransformSetBoundsPayload, TransformSetRotationPayload, TransformUpdatePayload } from "./transform";
|
||||
import type { WorkspaceSetPanelPayload } from "./workspace";
|
||||
import type { EditorSetPointerSessionPayload } from "./editor";
|
||||
@@ -110,6 +110,7 @@ export type CommandPayloads = {
|
||||
[commandIds.toolSetGenerateSettings]: ToolSetGenerateSettingsPayload;
|
||||
[commandIds.toolChooseGenerateIntent]: ToolChooseGenerateIntentPayload;
|
||||
[commandIds.toolSetBrushSettings]: ToolSetBrushSettingsPayload;
|
||||
[commandIds.toolSetFeatherSettings]: ToolSetFeatherSettingsPayload;
|
||||
[commandIds.toolSetChromaKeySettings]: ToolSetChromaKeySettingsPayload;
|
||||
[commandIds.toolSetMagicWandSettings]: ToolSetMagicWandSettingsPayload;
|
||||
[commandIds.toolSetBrushPreview]: ToolSetBrushPreviewPayload;
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { createInitialAppState } from "@editor/initial-state";
|
||||
import { initialToolState } from "@editor/tools";
|
||||
import { toolAppendMaskShapeCommand, toolBeginMaskShapeCommand, toolChooseGenerateIntentCommand, toolEnterMaskEditCommand, toolEnterTemporaryPanCommand, toolExitMaskEditCommand, toolExitTemporaryPanCommand, toolSetActiveCommand, toolSetBrushPreviewCommand, toolSetBrushSettingsCommand, toolSetBrushStrokePreviewCommand, toolSetChromaKeySettingsCommand, toolSetGenerateSettingsCommand, toolSetMaskViewModeCommand } from "./tool";
|
||||
import { toolAppendMaskShapeCommand, toolBeginMaskShapeCommand, toolChooseGenerateIntentCommand, toolEnterMaskEditCommand, toolEnterTemporaryPanCommand, toolExitMaskEditCommand, toolExitTemporaryPanCommand, toolSetActiveCommand, toolSetBrushPreviewCommand, toolSetBrushSettingsCommand, toolSetBrushStrokePreviewCommand, toolSetChromaKeySettingsCommand, toolSetFeatherSettingsCommand, toolSetGenerateSettingsCommand, toolSetMaskViewModeCommand } from "./tool";
|
||||
|
||||
const defaultBrush = { color: "#111827", size: 8, hardness: 100, opacity: 100, flow: 100, smoothing: 20, pressureSize: true };
|
||||
const defaultFeather = { size: 96, radius: 16, strength: 65, smoothing: 25, pressureSize: true };
|
||||
const defaultChromaKey = { color: "#00ff00", tolerance: 32, softness: 24, feather: 0, choke: 0, despeckle: 0, spill: 50 };
|
||||
const defaultMagicWand = { tolerance: 32, feather: 0, choke: 0, despeckle: 0, contiguous: true, mode: "replace" as const };
|
||||
const defaultGenerate = initialToolState.generate;
|
||||
@@ -11,7 +12,7 @@ const defaultGenerate = initialToolState.generate;
|
||||
describe("tool commands", () => {
|
||||
test("sets active tool", () => {
|
||||
const next = toolSetActiveCommand.execute({ state: createInitialAppState("Test") }, { tool: "brush" });
|
||||
expect(next.editor.tools).toEqual({ activeTool: "brush", interactionMode: { type: "tool", tool: "brush" }, brush: defaultBrush, generate: defaultGenerate, chromaKey: defaultChromaKey, magicWand: defaultMagicWand });
|
||||
expect(next.editor.tools).toEqual({ activeTool: "brush", interactionMode: { type: "tool", tool: "brush" }, brush: defaultBrush, feather: defaultFeather, generate: defaultGenerate, chromaKey: defaultChromaKey, magicWand: defaultMagicWand });
|
||||
});
|
||||
|
||||
test("selecting a persistent tool closes an open operation", () => {
|
||||
@@ -28,6 +29,12 @@ describe("tool commands", () => {
|
||||
expect(next.editor.tools.brush).toEqual({ ...defaultBrush, color: "#ff0000", size: 24, hardness: 50 });
|
||||
});
|
||||
|
||||
test("sets and clamps feather settings", () => {
|
||||
const next = toolSetFeatherSettingsCommand.execute({ state: createInitialAppState("Test") }, { size: 800, radius: 0, strength: 45 });
|
||||
|
||||
expect(next.editor.tools.feather).toEqual({ ...defaultFeather, size: 400, radius: 1, strength: 45 });
|
||||
});
|
||||
|
||||
test("sets chroma key settings", () => {
|
||||
const next = toolSetChromaKeySettingsCommand.execute({ state: createInitialAppState("Test") }, { color: "#123456", tolerance: 300 });
|
||||
|
||||
@@ -134,7 +141,7 @@ describe("tool commands", () => {
|
||||
const panning = toolEnterTemporaryPanCommand.execute({ state: initial }, undefined);
|
||||
const restored = toolExitTemporaryPanCommand.execute({ state: panning }, undefined);
|
||||
|
||||
expect(panning.editor.tools).toEqual({ activeTool: "select", interactionMode: { type: "temporary-pan", previousTool: "select" }, brush: defaultBrush, generate: defaultGenerate, chromaKey: defaultChromaKey, magicWand: defaultMagicWand });
|
||||
expect(panning.editor.tools).toEqual({ activeTool: "select", interactionMode: { type: "temporary-pan", previousTool: "select" }, brush: defaultBrush, feather: defaultFeather, generate: defaultGenerate, chromaKey: defaultChromaKey, magicWand: defaultMagicWand });
|
||||
expect(restored.editor.tools).toEqual(initial.editor.tools);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { Layer } from "@core/layer";
|
||||
import { getLayerMask } from "@core/layer-mask-utils";
|
||||
import type { MaskViewMode } from "@editor/state";
|
||||
import { generateArchitectureDefaults, inpaintProfileDefaults } from "@editor/tools";
|
||||
import type { BrushSettings, ChromaKeySettings, GenerateIntent, GenerateSettings, MagicWandSettings, ToolId } from "@editor/tools";
|
||||
import type { BrushSettings, ChromaKeySettings, FeatherSettings, GenerateIntent, GenerateSettings, MagicWandSettings, ToolId } from "@editor/tools";
|
||||
import type { Command } from "./command";
|
||||
import { commandIds } from "./ids";
|
||||
|
||||
@@ -14,6 +14,7 @@ export type ToolSetActivePayload = {
|
||||
};
|
||||
|
||||
export type ToolSetBrushSettingsPayload = Partial<BrushSettings>;
|
||||
export type ToolSetFeatherSettingsPayload = Partial<FeatherSettings>;
|
||||
|
||||
export type ToolSetGenerateSettingsPayload = Omit<Partial<GenerateSettings>, "inpaint" | "outpaint"> & {
|
||||
inpaint?: Partial<GenerateSettings["inpaint"]>;
|
||||
@@ -32,6 +33,8 @@ export type ToolSetBrushStrokePreviewPayload =
|
||||
layerId: LayerId;
|
||||
assetId: AssetId;
|
||||
source: string;
|
||||
pendingTargetLayerId?: LayerId;
|
||||
intrinsicSize?: { w: number; h: number };
|
||||
}
|
||||
| undefined;
|
||||
|
||||
@@ -187,6 +190,29 @@ export const toolSetBrushSettingsCommand: Command<ToolSetBrushSettingsPayload> =
|
||||
},
|
||||
};
|
||||
|
||||
export const toolSetFeatherSettingsCommand: Command<ToolSetFeatherSettingsPayload> = {
|
||||
id: commandIds.toolSetFeatherSettings,
|
||||
name: "Set feather settings",
|
||||
execute({ state }, payload) {
|
||||
return {
|
||||
...state,
|
||||
editor: {
|
||||
...state.editor,
|
||||
tools: {
|
||||
...state.editor.tools,
|
||||
feather: {
|
||||
size: clampNumber(payload.size ?? state.editor.tools.feather.size, 1, 400),
|
||||
radius: clampNumber(payload.radius ?? state.editor.tools.feather.radius, 1, 128),
|
||||
strength: clampNumber(payload.strength ?? state.editor.tools.feather.strength, 1, 100),
|
||||
smoothing: clampNumber(payload.smoothing ?? state.editor.tools.feather.smoothing, 0, 100),
|
||||
pressureSize: payload.pressureSize ?? state.editor.tools.feather.pressureSize,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export const toolSetChromaKeySettingsCommand: Command<ToolSetChromaKeySettingsPayload> = {
|
||||
id: commandIds.toolSetChromaKeySettings,
|
||||
name: "Set chroma key settings",
|
||||
@@ -274,7 +300,13 @@ export const toolSetBrushStrokePreviewCommand: Command<ToolSetBrushStrokePreview
|
||||
...state,
|
||||
editor: {
|
||||
...state.editor,
|
||||
brushStrokePreview: { layerId: payload.layerId, assetId: payload.assetId, source: payload.source },
|
||||
brushStrokePreview: {
|
||||
layerId: payload.layerId,
|
||||
assetId: payload.assetId,
|
||||
source: payload.source,
|
||||
pendingTargetLayerId: payload.pendingTargetLayerId,
|
||||
intrinsicSize: payload.intrinsicSize,
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
@@ -454,6 +486,7 @@ export const toolCommands = [
|
||||
toolSetGenerateSettingsCommand,
|
||||
toolChooseGenerateIntentCommand,
|
||||
toolSetBrushSettingsCommand,
|
||||
toolSetFeatherSettingsCommand,
|
||||
toolSetChromaKeySettingsCommand,
|
||||
toolSetMagicWandSettingsCommand,
|
||||
toolSetBrushPreviewCommand,
|
||||
|
||||
Reference in New Issue
Block a user