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:
syntaxbullet
2026-07-11 22:08:25 +02:00
parent 95043dfbdd
commit 38565382c3
27 changed files with 491 additions and 65 deletions

View File

@@ -220,11 +220,12 @@ export function App({ app }: AppProps) {
document={document}
selection={selection}
viewport={viewport}
visible={generateOpen || chromaKeyOpen || tools.activeTool === "brush" || tools.activeTool === "eraser" || tools.activeTool === "magicWand" || tools.activeTool === "semanticSelect" || tools.activeTool === "maskLasso" || tools.activeTool === "maskRectangle" || Boolean(transformBounds) || viewportActivityIsland.visible}
visible={generateOpen || chromaKeyOpen || tools.activeTool === "brush" || tools.activeTool === "eraser" || tools.activeTool === "feather" || tools.activeTool === "magicWand" || tools.activeTool === "semanticSelect" || tools.activeTool === "maskLasso" || tools.activeTool === "maskRectangle" || Boolean(transformBounds) || viewportActivityIsland.visible}
action={viewportActivityIsland.action}
activeTool={tools.activeTool}
operation={generateOpen ? "generate" : chromaKeyOpen ? "chromaKey" : undefined}
brushSettings={tools.brush}
featherSettings={tools.feather}
generateSettings={tools.generate}
generation={generation}
chromaKeySettings={tools.chromaKey}

View File

@@ -2,8 +2,9 @@ import { useMemo } from "react";
import type { AppStore } from "@editor/store";
import type { ImageDocument } from "@core/document";
import type { GenerationState, MaskViewMode, SelectionState, ViewportState } from "@editor/state";
import type { BrushSettings, ChromaKeySettings, GenerateSettings, MagicWandSettings, OperationId, ToolId } from "@editor/tools";
import type { BrushSettings, ChromaKeySettings, FeatherSettings, GenerateSettings, MagicWandSettings, OperationId, ToolId } from "@editor/tools";
import { BrushControls } from "./bottom-controls/BrushControls";
import { FeatherControls } from "./bottom-controls/FeatherControls";
import { ChromaKeyControls } from "./bottom-controls/ChromaKeyControls";
import { MagicWandControls } from "./bottom-controls/MagicWandControls";
import { GenerateActionControls } from "./bottom-controls/GenerateActionControls";
@@ -26,6 +27,7 @@ export type BottomControlsIslandProps = {
activeTool: ToolId;
operation?: OperationId;
brushSettings: BrushSettings;
featherSettings: FeatherSettings;
generateSettings: GenerateSettings;
generation: GenerationState;
chromaKeySettings: ChromaKeySettings;
@@ -41,7 +43,7 @@ export type BottomControlsIslandProps = {
documentActions: DocumentActions;
};
export function BottomControlsIsland({ document, selection, viewport, visible, action, activeTool, operation, brushSettings, generateSettings, generation, chromaKeySettings, magicWandSettings, editingMask = false, maskKind, maskViewMode = "composite", transformBounds, transformTarget, brushHint, dispatch, generationWorkflow, documentActions }: BottomControlsIslandProps) {
export function BottomControlsIsland({ document, selection, viewport, visible, action, activeTool, operation, brushSettings, featherSettings, generateSettings, generation, chromaKeySettings, magicWandSettings, editingMask = false, maskKind, maskViewMode = "composite", transformBounds, transformTarget, brushHint, dispatch, generationWorkflow, documentActions }: BottomControlsIslandProps) {
const documentIndex = useMemo(() => createDocumentReadIndex(document), [document]);
const selectedLayerInfo = selection.layerIds.length === 1 && selection.layerIds[0] ? documentIndex.layerInfoById.get(selection.layerIds[0]) : undefined;
const zoomPercent = Math.round(viewport.zoom * 100);
@@ -59,10 +61,12 @@ export function BottomControlsIsland({ document, selection, viewport, visible, a
<GenerateActionControls settings={generateSettings} generation={generation} dispatch={dispatch} workflow={generationWorkflow} generate={documentActions.generate} />
) : operation === "chromaKey" ? (
<ChromaKeyControls document={document} selection={selection} settings={chromaKeySettings} dispatch={dispatch} />
) : (activeTool === "brush" || activeTool === "eraser") && brushHint ? (
) : (activeTool === "brush" || activeTool === "eraser" || activeTool === "feather") && brushHint ? (
<BrushHint tool={activeTool} hint={brushHint} />
) : activeTool === "brush" || activeTool === "eraser" ? (
<BrushControls tool={activeTool} settings={brushSettings} editingMask={editingMask} maskKind={maskKind} maskViewMode={maskViewMode} dispatch={dispatch} />
) : activeTool === "feather" ? (
<FeatherControls settings={featherSettings} editingMask={editingMask} dispatch={dispatch} />
) : activeTool === "magicWand" ? (
<MagicWandControls settings={magicWandSettings} dispatch={dispatch} />
) : activeTool === "semanticSelect" ? (
@@ -80,7 +84,7 @@ export function BottomControlsIsland({ document, selection, viewport, visible, a
);
}
function BrushHint({ tool, hint }: { tool: "brush" | "eraser"; hint: string }) {
function BrushHint({ tool, hint }: { tool: "brush" | "eraser" | "feather"; hint: string }) {
return (
<div className="flex items-center gap-3 px-4 text-center">
<span className="rounded bg-white/[0.06] px-2 py-0.5 text-[0.65rem] font-semibold uppercase tracking-[0.12em] text-white/45">{tool}</span>

View File

@@ -1,4 +1,4 @@
import { Cursor, Eraser, Hand, PaintBrush, DropHalf, MagicWand, Sparkle, Polygon, Rectangle, Selection } from "@phosphor-icons/react";
import { Cursor, Eraser, Feather, Hand, PaintBrush, DropHalf, MagicWand, Sparkle, Polygon, Rectangle, Selection } from "@phosphor-icons/react";
import { commandIds } from "@commands/ids";
import type { AppStore } from "@editor/store";
import type { InteractionMode, OperationId, ToolId } from "@editor/tools";
@@ -66,6 +66,8 @@ function iconForTool(tool: ToolId) {
return PaintBrush;
case "eraser":
return Eraser;
case "feather":
return Feather;
case "magicWand":
return MagicWand;
case "semanticSelect":

View File

@@ -0,0 +1,46 @@
import { Feather } from "@phosphor-icons/react";
import { commandIds } from "@commands/ids";
import type { AppStore } from "@editor/store";
import type { FeatherSettings } from "@editor/tools";
import { BottomControlDivider } from "./Divider";
import { BottomControlSlider } from "./Slider";
import { bottomControlFieldClass, bottomControlIconSlotClass, bottomControlLabelClass, bottomControlMenuClass } from "./styles";
export function FeatherControls({ settings, editingMask, dispatch }: { settings: FeatherSettings; editingMask: boolean; dispatch: AppStore["dispatch"] }) {
return (
<div className={bottomControlMenuClass()}>
<span className={bottomControlIconSlotClass()} title="Feather mask edges"><Feather size={24} weight="regular" /></span>
<BottomControlDivider />
<span className="whitespace-nowrap text-xs font-medium text-white/65">{editingMask ? "Feather mask" : "Feather layer mask"}</span>
<BottomControlDivider />
<FeatherSlider label="Size" min={1} max={400} value={settings.size} onValueChange={(size) => dispatch(commandIds.toolSetFeatherSettings, { size })} />
<BottomControlDivider />
<FeatherSlider label="Radius" min={1} max={128} value={settings.radius} onValueChange={(radius) => dispatch(commandIds.toolSetFeatherSettings, { radius })} />
<BottomControlDivider />
<FeatherSlider label="Strength" min={1} max={100} value={settings.strength} suffix="%" onValueChange={(strength) => dispatch(commandIds.toolSetFeatherSettings, { strength })} />
<BottomControlDivider />
<FeatherSlider label="Smooth" min={0} max={100} value={settings.smoothing} suffix="%" onValueChange={(smoothing) => dispatch(commandIds.toolSetFeatherSettings, { smoothing })} />
<button type="button" className={toggleClass(settings.pressureSize)} aria-pressed={settings.pressureSize} title="Use pen pressure for feather brush size" onClick={() => dispatch(commandIds.toolSetFeatherSettings, { pressureSize: !settings.pressureSize })}>Pressure</button>
{editingMask ? (
<>
<BottomControlDivider />
<button type="button" className="rounded-md bg-sky-300 px-3 py-1.5 text-xs font-semibold text-slate-950 transition hover:bg-sky-200" onClick={() => dispatch(commandIds.toolExitMaskEdit, undefined)}>Done</button>
</>
) : null}
</div>
);
}
function FeatherSlider({ label, min, max, value, suffix = "", onValueChange }: { label: string; min: number; max: number; value: number; suffix?: string; onValueChange: (value: number) => void }) {
return (
<label className={bottomControlFieldClass()}>
<span className={bottomControlLabelClass()}>{label}</span>
<BottomControlSlider min={min} max={max} value={value} className="min-w-10 w-[clamp(2.5rem,8vw,6.5rem)] shrink" aria-label={`Feather ${label.toLowerCase()}`} onValueChange={onValueChange} />
<span className="w-10 text-right text-xs font-medium text-white/85">{Math.round(value)}{suffix}</span>
</label>
);
}
function toggleClass(active: boolean) {
return `rounded-md px-3 py-1.5 text-xs font-medium transition ${active ? "bg-sky-300 text-slate-950" : "bg-white/[0.06] text-white/70 hover:bg-white/10"}`;
}

View File

@@ -9,4 +9,8 @@ describe("canvas cursor", () => {
test("uses the default cursor while an operation suspends canvas tools", () => {
expect(canvasCursorClass({ type: "tool", tool: "brush" }, false, false, true, true)).toBe("cursor-default");
});
test("uses the live brush cursor for feathering", () => {
expect(canvasCursorClass({ type: "tool", tool: "feather" }, false, true, true, false)).toBe("cursor-none");
});
});

View File

@@ -4,7 +4,7 @@ export function canvasCursorClass(interactionMode: InteractionMode, isPanning: b
if (operationOpen) return "cursor-default";
if (isPanning) return "cursor-grabbing";
if (isPanInteractionMode(interactionMode)) return "cursor-grab";
if (interactionMode.type === "tool" && (interactionMode.tool === "brush" || interactionMode.tool === "eraser")) {
if (interactionMode.type === "tool" && (interactionMode.tool === "brush" || interactionMode.tool === "eraser" || interactionMode.tool === "feather")) {
if (!canBrush) return "cursor-not-allowed";
return hasBrushPreview ? "cursor-none" : "cursor-crosshair";
}

View File

@@ -78,9 +78,20 @@ describe("canvas render frame selection", () => {
brushPreview: { position: { x: 10, y: 20 } },
},
};
const changedFeather = {
...state,
editor: {
...state.editor,
tools: {
...state.editor.tools,
feather: { ...state.editor.tools.feather, size: state.editor.tools.feather.size + 1 },
},
},
};
expect(canvasRenderFramesEqual(selectCanvasRenderFrame(state), selectCanvasRenderFrame(sameSelectionValues))).toBe(true);
expectFrameChanged(state, changedBrush);
expectFrameChanged(state, changedFeather);
expectFrameChanged(state, changedPreview);
});

View File

@@ -1,7 +1,7 @@
import type { Rect, Vec2D } from "@core/geometry";
import type { RenderFrame } from "@renderer/index";
import type { AppState, BrushPreviewState, BrushStrokePreviewState, EditorState, GenerationState, MaskEditState, SelectionState, ViewportState } from "@editor/state";
import type { BrushSettings, InteractionMode } from "@editor/tools";
import type { BrushSettings, FeatherSettings, InteractionMode } from "@editor/tools";
import type { TransformSession, TransformTarget } from "@editor/transform";
export function selectCanvasRenderFrame(state: AppState): RenderFrame {
@@ -63,7 +63,12 @@ function brushPreviewStatesEqual(a: BrushPreviewState | undefined, b: BrushPrevi
function brushStrokePreviewStatesEqual(a: BrushStrokePreviewState | undefined, b: BrushStrokePreviewState | undefined): boolean {
if (a === b) return true;
if (!a || !b) return false;
return a.layerId === b.layerId && a.assetId === b.assetId && a.source === b.source;
return a.layerId === b.layerId && a.assetId === b.assetId && a.source === b.source && a.pendingTargetLayerId === b.pendingTargetLayerId && sizesEqual(a.intrinsicSize, b.intrinsicSize);
}
function sizesEqual(a: { w: number; h: number } | undefined, b: { w: number; h: number } | undefined) {
if (a === b) return true;
return Boolean(a && b && a.w === b.w && a.h === b.h);
}
function generationStatesEqual(a: GenerationState, b: GenerationState): boolean {
@@ -71,7 +76,7 @@ function generationStatesEqual(a: GenerationState, b: GenerationState): boolean
}
function visualToolStatesEqual(a: EditorState["tools"], b: EditorState["tools"]): boolean {
return a.activeTool === b.activeTool && interactionModesEqual(a.interactionMode, b.interactionMode) && brushSettingsEqual(a.brush, b.brush);
return a.activeTool === b.activeTool && interactionModesEqual(a.interactionMode, b.interactionMode) && brushSettingsEqual(a.brush, b.brush) && featherSettingsEqual(a.feather, b.feather);
}
function interactionModesEqual(a: InteractionMode, b: InteractionMode): boolean {
@@ -84,6 +89,10 @@ function brushSettingsEqual(a: BrushSettings, b: BrushSettings): boolean {
return a.color === b.color && a.size === b.size && a.hardness === b.hardness && a.opacity === b.opacity && a.flow === b.flow && a.smoothing === b.smoothing && a.pressureSize === b.pressureSize;
}
function featherSettingsEqual(a: FeatherSettings, b: FeatherSettings): boolean {
return a.size === b.size && a.radius === b.radius && a.strength === b.strength && a.smoothing === b.smoothing && a.pressureSize === b.pressureSize;
}
function vec2Equal(a: Vec2D, b: Vec2D): boolean {
return a.x === b.x && a.y === b.y;
}

View File

@@ -181,8 +181,14 @@ export function useCanvasInput(
const point = viewportPointToDocumentPoint(inputEvent.position, store.getState().editor.viewport);
store.dispatch(commandIds.toolSetBrushPreview, { position: point });
const settings = store.getState().editor.tools.brush;
brushSession.current = updateBrushSession({ store, session: brushSession.current, point, color: settings.color, size: settings.size, hardness: settings.hardness, opacity: settings.opacity, flow: settings.flow, smoothing: settings.smoothing, pressure: inputEvent.pressure ?? 1, pressureSize: settings.pressureSize });
const tools = store.getState().editor.tools;
if (brushSession.current.mode === "feather") {
const settings = tools.feather;
brushSession.current = updateBrushSession({ store, session: brushSession.current, point, color: "#ffffff", size: settings.size, hardness: 0, opacity: 100, flow: 100, smoothing: settings.smoothing, pressure: inputEvent.pressure ?? 1, pressureSize: settings.pressureSize, featherRadius: settings.radius, featherStrength: settings.strength });
} else {
const settings = tools.brush;
brushSession.current = updateBrushSession({ store, session: brushSession.current, point, color: settings.color, size: settings.size, hardness: settings.hardness, opacity: settings.opacity, flow: settings.flow, smoothing: settings.smoothing, pressure: inputEvent.pressure ?? 1, pressureSize: settings.pressureSize });
}
event.preventDefault();
return;
}

View File

@@ -1,4 +1,4 @@
import { ArrowCounterClockwise, ArrowDown, ArrowUp, CornersOut, Cursor, DownloadSimple, DropHalf, Eraser, Eye, EyeSlash, FolderOpen, FolderPlus, Hand, Lock, LockOpen, MagicWand, Minus, PaintBrush, Plus, Sparkle, Stack, Trash } from "@phosphor-icons/react";
import { ArrowCounterClockwise, ArrowDown, ArrowUp, CornersOut, Cursor, DownloadSimple, DropHalf, Eraser, Eye, EyeSlash, Feather, FolderOpen, FolderPlus, Hand, Lock, LockOpen, MagicWand, Minus, PaintBrush, Plus, Sparkle, Stack, Trash } from "@phosphor-icons/react";
import type { ReactNode } from "react";
import { commandIds } from "@commands/ids";
import type { Artboard } from "@core/artboard";
@@ -343,6 +343,8 @@ function toolIcon(tool: ToolId) {
return <PaintBrush size={20} />;
case "eraser":
return <Eraser size={20} />;
case "feather":
return <Feather size={20} />;
case "magicWand":
return <MagicWand size={20} />;
case "semanticSelect":

View File

@@ -14,6 +14,8 @@ export function labelForTool(tool: ToolId): string {
return "AI region rectangle";
case "eraser":
return "Eraser";
case "feather":
return "Feather";
case "pan":
return "Pan";
case "select":