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

@@ -1,6 +1,7 @@
import type { ImageDocument } from "@core/document";
import type { Vec2D } from "@core/geometry";
import type { Layer } from "@core/layer";
import { getLayerMask } from "@core/layer-mask-utils";
import type { EditorState } from "@editor/state";
import type { RgbaColor, WebGlRendererContext } from "./types";
@@ -78,13 +79,13 @@ function drawPreviewPass(
}
function resolveBrushPreview(document: ImageDocument, editor: EditorState, canvas: HTMLCanvasElement) {
if (!editor.brushPreview || (editor.tools.activeTool !== "brush" && editor.tools.activeTool !== "eraser")) return undefined;
if (!editor.brushPreview || (editor.tools.activeTool !== "brush" && editor.tools.activeTool !== "eraser" && editor.tools.activeTool !== "feather")) return undefined;
if (editor.tools.interactionMode.type === "temporary-pan" || (editor.tools.interactionMode.type === "tool" && editor.tools.interactionMode.tool === "pan")) return undefined;
const layer = resolveBrushTargetLayer(document, editor);
if (!layer) return undefined;
const size = Math.max(1, editor.tools.brush.size);
const size = Math.max(1, editor.tools.activeTool === "feather" ? editor.tools.feather.size : editor.tools.brush.size);
const zoom = editor.viewport.zoom;
return {
center: documentPointToScreenPoint(canvas, editor.brushPreview.position, editor),
@@ -92,7 +93,7 @@ function resolveBrushPreview(document: ImageDocument, editor: EditorState, canva
x: Math.max(1, Math.abs(layer.transform.scale.x) * size * zoom * 0.5),
y: Math.max(1, Math.abs(layer.transform.scale.y) * size * zoom * 0.5),
},
hardness: Math.max(0, Math.min(1, editor.tools.brush.hardness / 100)),
hardness: editor.tools.activeTool === "feather" ? 0.72 : Math.max(0, Math.min(1, editor.tools.brush.hardness / 100)),
};
}
@@ -101,7 +102,10 @@ function resolveBrushTargetLayer(document: ImageDocument, editor: EditorState):
const layerId = editor.maskEdit?.kind === "inpaintRegion" ? editor.maskEdit.targetLayerId : editor.maskEdit?.maskLayerId ?? editor.selection.layerIds[0];
if (!layerId) return undefined;
const layer = findPaintableLayer(document.artboards.flatMap((artboard) => artboard.layers), layerId);
const layers = document.artboards.flatMap((artboard) => artboard.layers);
const selectedLayer = findPaintableLayer(layers, layerId);
const maskLayerId = editor.tools.activeTool === "feather" && !editingMask && selectedLayer ? getLayerMask(selectedLayer)?.maskLayerId : undefined;
const layer = maskLayerId ? findPaintableLayer(layers, maskLayerId) : selectedLayer;
if (!layer || layer.locked || (!editingMask && !layer.visible)) return undefined;
return layer;
}