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:
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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":
|
||||
|
||||
46
view/bottom-controls/FeatherControls.tsx
Normal file
46
view/bottom-controls/FeatherControls.tsx
Normal 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"}`;
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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":
|
||||
|
||||
Reference in New Issue
Block a user