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,12 +1,14 @@
import { commandIds } from "@commands/ids";
import type { ImageDocument } from "@core/document";
import type { Vec2D } from "@core/geometry";
import type { AssetId, LayerId } from "@core/id";
import type { Layer } from "@core/layer";
import { getLayerMask } from "@core/layer-mask-utils";
import type { RasterLayer } from "@core/raster-layer";
import type { MaskEditState, SelectionState } from "@editor/state";
import { isPanInteractionMode, type ToolState } from "@editor/tools";
import type { AppStore } from "@editor/store";
import { brushSurfaceDataUrl, brushSurfaceObjectUrl, cancelFrame, createBrushSurface, drawBrushSegment, releaseObjectUrl, scheduleFrame, type BrushSurface } from "@platform/browser/brushRaster";
import { brushSurfaceDataUrl, brushSurfaceObjectUrl, cancelFrame, createBrushSurface, drawBrushSegment, drawFeatherSegment, releaseObjectUrl, scheduleFrame, type BrushSurface } from "@platform/browser/brushRaster";
export type BrushSession = {
layerId: string;
@@ -16,7 +18,7 @@ export type BrushSession = {
surface: BrushSurface;
ready: Promise<boolean>;
previousPoint: Vec2D;
mode: "brush" | "eraser";
mode: "brush" | "eraser" | "feather";
changed?: boolean;
pending?: Promise<void>;
cancelled?: boolean;
@@ -26,6 +28,12 @@ export type BrushSession = {
previewFrame?: number;
previewSource?: string;
targetLayer: RasterLayer;
featherRadius?: number;
pendingLayerMask?: {
targetLayerId: LayerId;
asset: ImageDocument["assets"][number];
maskLayer: RasterLayer;
};
};
export type BrushTargetEditorState = {
@@ -36,7 +44,7 @@ export type BrushTargetEditorState = {
export function beginBrushSession(document: ImageDocument, editor: BrushTargetEditorState, point: Vec2D): BrushSession | undefined {
const target = resolveBrushTarget(document, editor);
if (!target || (editor.tools.activeTool !== "brush" && editor.tools.activeTool !== "eraser")) return undefined;
if (!target || !isPaintTool(editor.tools.activeTool)) return undefined;
const { layer, asset } = target;
const surface = createBrushSurface(asset.intrinsicSize.w, asset.intrinsicSize.h, asset.source);
@@ -52,17 +60,19 @@ export function beginBrushSession(document: ImageDocument, editor: BrushTargetEd
previousPoint: point,
mode: editor.tools.activeTool,
targetLayer: layer,
pendingLayerMask: target.pendingLayerMask,
};
return session;
}
export function canPreviewBrush(document: ImageDocument, editor: BrushTargetEditorState): boolean {
if (editor.tools.activeTool === "feather" && !editor.maskEdit) return Boolean(resolveSelectedFeatherLayer(document, editor));
return Boolean(resolveBrushTarget(document, editor));
}
export function brushUnavailableHint(document: ImageDocument, editor: BrushTargetEditorState): string | undefined {
if (isPanInteractionMode(editor.tools.interactionMode) || (editor.tools.activeTool !== "brush" && editor.tools.activeTool !== "eraser")) return undefined;
if (resolveBrushTarget(document, editor)) return undefined;
if (isPanInteractionMode(editor.tools.interactionMode) || !isPaintTool(editor.tools.activeTool)) return undefined;
if (canPreviewBrush(document, editor)) return undefined;
const layerId = editor.maskEdit?.maskLayerId ?? editor.selection.layerIds[0];
if (!layerId) {
@@ -73,14 +83,14 @@ export function brushUnavailableHint(document: ImageDocument, editor: BrushTarge
const layer = findLayer(document.artboards.flatMap((artboard) => artboard.layers), layerId);
if (!layer) return "Select a raster layer or layer mask to paint.";
if (layer.locked) return "Unlock this layer before painting.";
if (layer.type === "image") return "Image layers are non-destructive. Add a layer mask to paint or erase.";
if (layer.type === "image" && editor.tools.activeTool !== "feather") return "Image layers are non-destructive. Add a layer mask to paint or erase.";
if (layer.type === "group") return "Select a raster layer inside the group to paint.";
if (!editor.maskEdit && !layer.visible) return "Show this layer before painting.";
return "Select a raster layer or layer mask to paint.";
}
function resolveBrushTarget(document: ImageDocument, editor: BrushTargetEditorState): { layer: RasterLayer; asset: ImageDocument["assets"][number] } | undefined {
if (isPanInteractionMode(editor.tools.interactionMode) || (editor.tools.activeTool !== "brush" && editor.tools.activeTool !== "eraser")) return undefined;
function resolveBrushTarget(document: ImageDocument, editor: BrushTargetEditorState): { layer: RasterLayer; asset: ImageDocument["assets"][number]; pendingLayerMask?: BrushSession["pendingLayerMask"] } | undefined {
if (isPanInteractionMode(editor.tools.interactionMode) || !isPaintTool(editor.tools.activeTool)) return undefined;
if (editor.maskEdit?.kind === "inpaintRegion") {
const target = findLayer(document.artboards.flatMap((artboard) => artboard.layers), editor.maskEdit.targetLayerId);
const asset = document.assets.find((candidate) => candidate.id === editor.maskEdit?.maskAssetId);
@@ -93,8 +103,51 @@ function resolveBrushTarget(document: ImageDocument, editor: BrushTargetEditorSt
const editingMask = Boolean(editor.maskEdit);
const layerId = editor.maskEdit?.maskLayerId ?? editor.selection.layerIds[0];
if (!layerId) return undefined;
const layer = findRasterLayer(document.artboards.flatMap((artboard) => artboard.layers), layerId);
if (!layer || layer.locked || (!editingMask && !layer.visible)) return undefined;
const layers = document.artboards.flatMap((artboard) => artboard.layers);
const selectedLayer = findLayer(layers, layerId);
if (!selectedLayer || selectedLayer.locked || (!editingMask && !selectedLayer.visible)) return undefined;
if (editor.tools.activeTool === "feather" && !editingMask && (selectedLayer.type === "image" || selectedLayer.type === "raster")) {
const maskLayerId = getLayerMask(selectedLayer)?.maskLayerId;
const attachedMask = maskLayerId ? findLayer(layers, maskLayerId) : undefined;
const attachedMaskLayer = attachedMask && (attachedMask.type === "image" || attachedMask.type === "raster") ? { ...attachedMask, type: "raster" as const } : undefined;
const maskAsset = attachedMaskLayer ? document.assets.find((candidate) => candidate.id === attachedMaskLayer.assetId) : undefined;
if (attachedMaskLayer && maskAsset) return { layer: attachedMaskLayer, asset: maskAsset };
const sourceAsset = document.assets.find((candidate) => candidate.id === selectedLayer.assetId);
if (!sourceAsset) return undefined;
const width = Math.max(1, Math.round(sourceAsset.intrinsicSize.w));
const height = Math.max(1, Math.round(sourceAsset.intrinsicSize.h));
const source = opaqueMaskSource(width, height);
const assetId = crypto.randomUUID() as AssetId;
const newMaskLayerId = crypto.randomUUID() as LayerId;
const sourceRect = selectedLayer.sourceRect ?? { x: 0, y: 0, w: width, h: height };
const maskLayer: RasterLayer = {
id: newMaskLayerId,
type: "raster",
name: `${selectedLayer.name} Mask`,
visible: true,
locked: false,
opacity: 1,
assetId,
transform: {
position: {
x: selectedLayer.transform.position.x + sourceRect.x * selectedLayer.transform.scale.x,
y: selectedLayer.transform.position.y + sourceRect.y * selectedLayer.transform.scale.y,
},
scale: {
x: (sourceRect.w * selectedLayer.transform.scale.x) / width,
y: (sourceRect.h * selectedLayer.transform.scale.y) / height,
},
rotation: selectedLayer.transform.rotation,
},
};
const asset = { id: assetId, name: maskLayer.name, mimeType: "image/svg+xml", source, intrinsicSize: { w: width, h: height } };
return { layer: maskLayer, asset, pendingLayerMask: { targetLayerId: selectedLayer.id, asset, maskLayer } };
}
const layer = selectedLayer.type === "raster" ? selectedLayer : undefined;
if (!layer) return undefined;
const asset = document.assets.find((candidate) => candidate.id === layer.assetId);
return asset ? { layer, asset } : undefined;
}
@@ -111,10 +164,12 @@ export function updateBrushSession(options: {
smoothing: number;
pressure: number;
pressureSize: boolean;
featherRadius?: number;
featherStrength?: number;
}): BrushSession {
const state = options.store.getState();
const asset = state.document.assets.find((candidate) => candidate.id === options.session.assetId);
if (!asset) return options.session;
if (!asset && !options.session.pendingLayerMask) return options.session;
const layer = options.session.targetLayer;
const from = options.session.previousPoint;
@@ -127,16 +182,30 @@ export function updateBrushSession(options: {
if (options.session.cancelled) return;
if (!(await options.session.ready) || options.session.cancelled) return;
drawBrushSegment(options.session.surface, {
from: documentPointToAssetPoint(from, layer, options.session.width, options.session.height),
to: documentPointToAssetPoint(to, layer, options.session.width, options.session.height),
color: state.editor.maskEdit ? "#ffffff" : options.color,
size: options.size * (options.pressureSize ? Math.max(0.1, options.pressure) : 1),
hardness: options.hardness,
opacity: options.opacity,
flow: options.flow,
mode: options.session.mode,
});
const assetFrom = documentPointToAssetPoint(from, layer, options.session.width, options.session.height);
const assetTo = documentPointToAssetPoint(to, layer, options.session.width, options.session.height);
const size = options.size * (options.pressureSize ? Math.max(0.1, options.pressure) : 1);
if (options.session.mode === "feather") {
options.session.featherRadius = options.featherRadius ?? 16;
drawFeatherSegment(options.session.surface, {
from: assetFrom,
to: assetTo,
size,
radius: options.featherRadius ?? 16,
strength: options.featherStrength ?? 65,
});
} else {
drawBrushSegment(options.session.surface, {
from: assetFrom,
to: assetTo,
color: state.editor.maskEdit ? "#ffffff" : options.color,
size,
hardness: options.hardness,
opacity: options.opacity,
flow: options.flow,
mode: options.session.mode,
});
}
if (options.session.cancelled) return;
options.session.changed = true;
@@ -155,10 +224,22 @@ export async function commitBrushSession(options: { store: AppStore; session: Br
if (source) {
const state = options.store.getState();
const maskEdit = state.editor.maskEdit;
if (maskEdit?.kind === "inpaintRegion" && maskEdit.inpaintRegionId && maskEdit.maskAssetId === options.session.assetId) {
options.store.dispatch(commandIds.documentApplyInpaintRegionMaskOperation, { regionId: maskEdit.inpaintRegionId, source, mimeType: "image/png", operation: { type: "paint" } });
const operation = options.session.mode === "feather"
? { type: "feather" as const, radius: Math.round(options.session.featherRadius ?? 16) }
: { type: "paint" as const };
if (options.session.pendingLayerMask) {
options.store.dispatch(commandIds.documentAddLayerMask, {
layerId: options.session.pendingLayerMask.targetLayerId,
asset: { ...options.session.pendingLayerMask.asset, source, mimeType: "image/png" },
maskLayer: options.session.pendingLayerMask.maskLayer,
activeTool: "feather",
});
} else if (maskEdit?.kind === "inpaintRegion" && maskEdit.inpaintRegionId && maskEdit.maskAssetId === options.session.assetId) {
options.store.dispatch(commandIds.documentApplyInpaintRegionMaskOperation, { regionId: maskEdit.inpaintRegionId, source, mimeType: "image/png", operation });
} else if (maskEdit?.maskLayerId === options.session.layerId) {
options.store.dispatch(commandIds.documentApplyLayerMaskOperation, { maskLayerId: maskEdit.maskLayerId, source, mimeType: "image/png", operation: { type: "paint" } });
options.store.dispatch(commandIds.documentApplyLayerMaskOperation, { maskLayerId: maskEdit.maskLayerId, source, mimeType: "image/png", operation });
} else if (isAttachedLayerMask(state.document, options.session.layerId)) {
options.store.dispatch(commandIds.documentApplyLayerMaskOperation, { maskLayerId: options.session.layerId, source, mimeType: "image/png", operation });
} else {
options.store.dispatch(commandIds.documentUpdateAssetSource, { assetId: options.session.assetId, source });
}
@@ -167,6 +248,33 @@ export async function commitBrushSession(options: { store: AppStore; session: Br
closeBrushStrokePreview(options.session);
}
function isPaintTool(tool: ToolState["activeTool"]): tool is "brush" | "eraser" | "feather" {
return tool === "brush" || tool === "eraser" || tool === "feather";
}
function resolveSelectedFeatherLayer(document: ImageDocument, editor: BrushTargetEditorState) {
if (isPanInteractionMode(editor.tools.interactionMode)) return undefined;
const layerId = editor.selection.layerIds[0];
if (!layerId) return undefined;
const layer = findLayer(document.artboards.flatMap((artboard) => artboard.layers), layerId);
return layer && (layer.type === "image" || layer.type === "raster") && layer.visible && !layer.locked ? layer : undefined;
}
function isAttachedLayerMask(document: ImageDocument, maskLayerId: string) {
const stack = document.artboards.flatMap((artboard) => artboard.layers);
while (stack.length > 0) {
const layer = stack.pop();
if (!layer) continue;
if (getLayerMask(layer)?.maskLayerId === maskLayerId) return true;
if (layer.type === "group") stack.push(...layer.children);
}
return false;
}
function opaqueMaskSource(width: number, height: number) {
return `data:image/svg+xml,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}"><rect width="${width}" height="${height}" fill="white"/></svg>`)}`;
}
export function cancelBrushSession(options: { store: AppStore; session: BrushSession }) {
options.session.cancelled = true;
options.store.dispatch(commandIds.toolSetBrushStrokePreview, undefined);
@@ -225,7 +333,13 @@ async function publishBrushStrokePreview(options: { store: AppStore; session: Br
const previousSource = options.session.previewSource;
options.session.previewSource = source;
options.store.dispatch(commandIds.toolSetBrushStrokePreview, { layerId: options.session.layerId, assetId: options.session.assetId, source });
options.store.dispatch(commandIds.toolSetBrushStrokePreview, {
layerId: options.session.layerId,
assetId: options.session.assetId,
source,
pendingTargetLayerId: options.session.pendingLayerMask?.targetLayerId,
intrinsicSize: options.session.pendingLayerMask ? { w: options.session.width, h: options.session.height } : undefined,
});
releaseObjectUrl(previousSource);
if (options.session.previewRequested) requestBrushStrokePreview(options);
@@ -243,11 +357,6 @@ function closeBrushStrokePreview(session: BrushSession) {
}
}
function findRasterLayer(layers: Layer[], layerId: string): RasterLayer | undefined {
const layer = findLayer(layers, layerId);
return layer?.type === "raster" ? layer : undefined;
}
function findLayer(layers: Layer[], layerId: string): Layer | undefined {
for (const layer of layers) {
if (layer.id === layerId) return layer;