From 38565382c3b8b1d5c591ad9e3d09a8438f579ced Mon Sep 17 00:00:00 2001 From: syntaxbullet Date: Sat, 11 Jul 2026 22:08:25 +0200 Subject: [PATCH] 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. --- commands/document.ts | 9 +- commands/ids.ts | 1 + commands/payloads.ts | 3 +- commands/tool.test.ts | 13 +- commands/tool.ts | 37 ++++- editor/index.ts | 2 +- editor/state.ts | 2 + editor/tools.ts | 12 +- input/tool-keybinds.test.ts | 20 +++ input/tool-keybinds.ts | 1 + input/transform-controls.ts | 2 +- operations/paint/brush.ts | 169 +++++++++++++++++++---- platform/browser/brushRaster.test.ts | 40 ++++++ platform/browser/brushRaster.ts | 105 +++++++++++++- renderer/brush-preview.ts | 12 +- renderer/layers.ts | 15 +- view/App.tsx | 3 +- view/BottomControlsIsland.tsx | 12 +- view/ToolOverlay.tsx | 4 +- view/bottom-controls/FeatherControls.tsx | 46 ++++++ view/canvas/cursor.test.ts | 4 + view/canvas/cursor.ts | 2 +- view/canvas/renderFrame.test.ts | 11 ++ view/canvas/renderFrame.ts | 15 +- view/canvas/useCanvasInput.ts | 10 +- view/paletteItems.tsx | 4 +- view/toolLabels.ts | 2 + 27 files changed, 491 insertions(+), 65 deletions(-) create mode 100644 input/tool-keybinds.test.ts create mode 100644 platform/browser/brushRaster.test.ts create mode 100644 view/bottom-controls/FeatherControls.tsx diff --git a/commands/document.ts b/commands/document.ts index acbeedc..c0906bf 100644 --- a/commands/document.ts +++ b/commands/document.ts @@ -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 = 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 = 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" }, }, }, }; diff --git a/commands/ids.ts b/commands/ids.ts index fed46f4..9f0bbc1 100644 --- a/commands/ids.ts +++ b/commands/ids.ts @@ -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", diff --git a/commands/payloads.ts b/commands/payloads.ts index 9f6043b..a9bf305 100644 --- a/commands/payloads.ts +++ b/commands/payloads.ts @@ -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; diff --git a/commands/tool.test.ts b/commands/tool.test.ts index 8a437e1..21356ba 100644 --- a/commands/tool.test.ts +++ b/commands/tool.test.ts @@ -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); }); }); diff --git a/commands/tool.ts b/commands/tool.ts index 67bbce7..3b5c66b 100644 --- a/commands/tool.ts +++ b/commands/tool.ts @@ -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; +export type ToolSetFeatherSettingsPayload = Partial; export type ToolSetGenerateSettingsPayload = Omit, "inpaint" | "outpaint"> & { inpaint?: Partial; @@ -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 = }, }; +export const toolSetFeatherSettingsCommand: Command = { + 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 = { id: commandIds.toolSetChromaKeySettings, name: "Set chroma key settings", @@ -274,7 +300,13 @@ export const toolSetBrushStrokePreviewCommand: Command { + test("selects the feather tool with F", () => { + const dispatched: unknown[] = []; + const consumed = handleToolKey({ + event: { key: "f", code: "KeyF", altKey: false, ctrlKey: false, metaKey: false, shiftKey: false }, + dispatch: ((commandId: unknown, payload: unknown) => { + dispatched.push({ commandId, payload }); + return undefined; + }) as unknown as Dispatch, + }); + + expect(consumed).toBe(true); + expect(dispatched).toEqual([{ commandId: commandIds.toolSetActive, payload: { tool: "feather" } }]); + }); +}); diff --git a/input/tool-keybinds.ts b/input/tool-keybinds.ts index a6aba6b..e359bd9 100644 --- a/input/tool-keybinds.ts +++ b/input/tool-keybinds.ts @@ -5,6 +5,7 @@ import type { KeybindEvent } from "./keyboard"; const toolKeybinds = { b: "brush", e: "eraser", + f: "feather", w: "magicWand", p: "pan", s: "select", diff --git a/input/transform-controls.ts b/input/transform-controls.ts index e51d236..8c7e894 100644 --- a/input/transform-controls.ts +++ b/input/transform-controls.ts @@ -16,7 +16,7 @@ import type { PointerInputEvent } from "./pointer"; type TransformHandle = "body" | "nw" | "n" | "ne" | "e" | "se" | "s" | "sw" | "w"; -type InputToolId = "select" | "brush" | "eraser" | "magicWand" | "semanticSelect" | "maskLasso" | "maskRectangle" | "pan"; +type InputToolId = "select" | "brush" | "eraser" | "feather" | "magicWand" | "semanticSelect" | "maskLasso" | "maskRectangle" | "pan"; type InputInteractionMode = | { type: "tool"; tool: InputToolId } diff --git a/operations/paint/brush.ts b/operations/paint/brush.ts index d1afc31..18fc5e7 100644 --- a/operations/paint/brush.ts +++ b/operations/paint/brush.ts @@ -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; previousPoint: Vec2D; - mode: "brush" | "eraser"; + mode: "brush" | "eraser" | "feather"; changed?: boolean; pending?: Promise; 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(``)}`; +} + 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; diff --git a/platform/browser/brushRaster.test.ts b/platform/browser/brushRaster.test.ts new file mode 100644 index 0000000..ba170c7 --- /dev/null +++ b/platform/browser/brushRaster.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, test } from "bun:test"; +import { blendFeatherPatch } from "./brushRaster"; + +describe("feather brush raster blending", () => { + test("mixes blurred mask values inside the stroke and preserves pixels outside it", () => { + const original = rgbaRow([255, 255, 255, 255, 255]); + const blurred = rgbaRow([0, 64, 128, 192, 255]); + + blendFeatherPatch(original, blurred, 5, 1, { x: 0, y: 0 }, { + from: { x: 2.5, y: 0.5 }, + to: { x: 2.5, y: 0.5 }, + size: 3, + strength: 100, + }); + + expect(alphaValues(original)).toEqual([255, 64, 128, 192, 255]); + }); + + test("applies strength as a non-destructive mix", () => { + const original = rgbaRow([255]); + const blurred = rgbaRow([0]); + + blendFeatherPatch(original, blurred, 1, 1, { x: 0, y: 0 }, { + from: { x: 0.5, y: 0.5 }, + to: { x: 0.5, y: 0.5 }, + size: 10, + strength: 50, + }); + + expect(alphaValues(original)).toEqual([128]); + }); +}); + +function rgbaRow(alpha: number[]) { + return new Uint8ClampedArray(alpha.flatMap((value) => [255, 255, 255, value])); +} + +function alphaValues(data: Uint8ClampedArray) { + return Array.from({ length: data.length / 4 }, (_, index) => data[index * 4 + 3]); +} diff --git a/platform/browser/brushRaster.ts b/platform/browser/brushRaster.ts index 60e7086..e37e98e 100644 --- a/platform/browser/brushRaster.ts +++ b/platform/browser/brushRaster.ts @@ -5,12 +5,26 @@ export type BrushSurface = { readonly resource: object; }; -type InternalSurface = BrushSurface & { canvas: HTMLCanvasElement; context: CanvasRenderingContext2D }; +type InternalSurface = BrushSurface & { + canvas: HTMLCanvasElement; + context: CanvasRenderingContext2D; + featherSource: HTMLCanvasElement; + featherBlur: HTMLCanvasElement; +}; export function createBrushSurface(width: number, height: number, source: string): BrushSurface | undefined { const canvas = document.createElement("canvas"); canvas.width = Math.max(1, Math.round(width)); canvas.height = Math.max(1, Math.round(height)); const context = canvas.getContext("2d"); if (!context) return undefined; - const surface = { width: canvas.width, height: canvas.height, canvas, context, resource: {}, ready: Promise.resolve(false) } as InternalSurface; + const surface = { + width: canvas.width, + height: canvas.height, + canvas, + context, + featherSource: document.createElement("canvas"), + featherBlur: document.createElement("canvas"), + resource: {}, + ready: Promise.resolve(false), + } as InternalSurface; (surface as { ready: Promise }).ready = loadImage(source).then((image) => { context.clearRect(0, 0, canvas.width, canvas.height); context.drawImage(image, 0, 0, canvas.width, canvas.height); return true; }).catch(() => false); return surface; } @@ -20,6 +34,74 @@ export function drawBrushSegment(surface: BrushSurface, options: { from: { x: nu context.save(); context.globalAlpha = Math.max(0, Math.min(1, options.opacity / 100)) * Math.max(0.01, Math.min(1, options.flow / 100)); context.globalCompositeOperation = options.mode === "eraser" ? "destination-out" : "source-over"; context.strokeStyle = options.color; context.shadowColor = options.mode === "eraser" ? "rgba(0,0,0,1)" : options.color; context.shadowBlur = (1 - hardness) * options.size; context.lineWidth = options.size; context.lineCap = "round"; context.lineJoin = "round"; context.beginPath(); context.moveTo(options.from.x, options.from.y); context.lineTo(options.to.x, options.to.y); context.stroke(); context.restore(); } +export function drawFeatherSegment(surface: BrushSurface, options: { + from: { x: number; y: number }; + to: { x: number; y: number }; + size: number; + radius: number; + strength: number; +}) { + const target = internal(surface); + const brushRadius = Math.max(0.5, options.size / 2); + const blurRadius = Math.max(1, Math.round(options.radius)); + const padding = Math.ceil(blurRadius * 2.5); + const x1 = Math.max(0, Math.floor(Math.min(options.from.x, options.to.x) - brushRadius - padding)); + const y1 = Math.max(0, Math.floor(Math.min(options.from.y, options.to.y) - brushRadius - padding)); + const x2 = Math.min(target.width, Math.ceil(Math.max(options.from.x, options.to.x) + brushRadius + padding)); + const y2 = Math.min(target.height, Math.ceil(Math.max(options.from.y, options.to.y) + brushRadius + padding)); + const width = x2 - x1; + const height = y2 - y1; + if (width <= 0 || height <= 0) return; + + const scratchWidth = width + padding * 2; + const scratchHeight = height + padding * 2; + resizeCanvas(target.featherSource, scratchWidth, scratchHeight); + resizeCanvas(target.featherBlur, scratchWidth, scratchHeight); + const sourceContext = target.featherSource.getContext("2d"); + const blurContext = target.featherBlur.getContext("2d"); + if (!sourceContext || !blurContext) return; + + sourceContext.clearRect(0, 0, scratchWidth, scratchHeight); + sourceContext.drawImage(target.canvas, x1, y1, width, height, padding, padding, width, height); + blurContext.clearRect(0, 0, scratchWidth, scratchHeight); + blurContext.save(); + blurContext.filter = `blur(${blurRadius}px)`; + blurContext.drawImage(target.featherSource, 0, 0); + blurContext.restore(); + + const original = target.context.getImageData(x1, y1, width, height); + const blurred = blurContext.getImageData(padding, padding, width, height); + blendFeatherPatch(original.data, blurred.data, width, height, { x: x1, y: y1 }, options); + target.context.putImageData(original, x1, y1); +} + +export function blendFeatherPatch( + original: Uint8ClampedArray, + blurred: Uint8ClampedArray, + width: number, + height: number, + origin: { x: number; y: number }, + options: { from: { x: number; y: number }; to: { x: number; y: number }; size: number; strength: number }, +) { + const brushRadius = Math.max(0.5, options.size / 2); + const strength = Math.max(0.01, Math.min(1, options.strength / 100)); + for (let y = 0; y < height; y += 1) { + for (let x = 0; x < width; x += 1) { + const distance = distanceToSegment(origin.x + x + 0.5, origin.y + y + 0.5, options.from, options.to); + if (distance >= brushRadius) continue; + const normalized = distance / brushRadius; + const coverage = 1 - smoothstep(0.72, 1, normalized); + const mix = coverage * strength; + const index = (y * width + x) * 4; + for (let channel = 0; channel < 4; channel += 1) { + const current = original[index + channel] ?? 0; + const feathered = blurred[index + channel] ?? 0; + original[index + channel] = Math.round(current + (feathered - current) * mix); + } + } + } +} + export function brushSurfaceDataUrl(surface: BrushSurface) { try { return internal(surface).canvas.toDataURL("image/png"); } catch { return undefined; } } export function brushSurfaceObjectUrl(surface: BrushSurface) { return new Promise((resolve) => internal(surface).canvas.toBlob((blob) => resolve(blob ? URL.createObjectURL(blob) : undefined), "image/png")); } export function releaseObjectUrl(source?: string) { if (source) URL.revokeObjectURL(source); } @@ -27,3 +109,22 @@ export function scheduleFrame(callback: () => void) { return requestAnimationFra export function cancelFrame(id: number) { cancelAnimationFrame(id); } function internal(surface: BrushSurface) { return surface as InternalSurface; } function loadImage(source: string) { return new Promise((resolve, reject) => { const image = new Image(); image.onload = () => resolve(image); image.onerror = () => reject(new Error("Failed to load raster layer")); image.src = source; }); } + +function resizeCanvas(canvas: HTMLCanvasElement, width: number, height: number) { + if (canvas.width !== width) canvas.width = width; + if (canvas.height !== height) canvas.height = height; +} + +function distanceToSegment(x: number, y: number, from: { x: number; y: number }, to: { x: number; y: number }) { + const dx = to.x - from.x; + const dy = to.y - from.y; + const lengthSquared = dx * dx + dy * dy; + if (lengthSquared <= 0.0001) return Math.hypot(x - from.x, y - from.y); + const amount = Math.max(0, Math.min(1, ((x - from.x) * dx + (y - from.y) * dy) / lengthSquared)); + return Math.hypot(x - (from.x + dx * amount), y - (from.y + dy * amount)); +} + +function smoothstep(edge0: number, edge1: number, value: number) { + const amount = Math.max(0, Math.min(1, (value - edge0) / Math.max(0.0001, edge1 - edge0))); + return amount * amount * (3 - 2 * amount); +} diff --git a/renderer/brush-preview.ts b/renderer/brush-preview.ts index 91c446b..a745432 100644 --- a/renderer/brush-preview.ts +++ b/renderer/brush-preview.ts @@ -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; } diff --git a/renderer/layers.ts b/renderer/layers.ts index 9dfab12..ad02994 100644 --- a/renderer/layers.ts +++ b/renderer/layers.ts @@ -120,10 +120,19 @@ function renderLeafLayer( } const layerMask = getLayerMask(layer); const maskLayer = !editingMaskLayer && layerMask?.enabled ? documentIndex.layerById.get(layerMask.maskLayerId) : undefined; - const maskAsset = assetWithBrushStrokePreview(maskLayer && (maskLayer.type === "image" || maskLayer.type === "raster") ? documentIndex.assetById.get(maskLayer.assetId) : undefined, editor); + const pendingMaskPreview = editor.brushStrokePreview?.pendingTargetLayerId === layer.id && editor.brushStrokePreview.intrinsicSize + ? { + id: editor.brushStrokePreview.assetId, + name: `${layer.name} Mask Preview`, + mimeType: "image/png", + source: editor.brushStrokePreview.source, + intrinsicSize: editor.brushStrokePreview.intrinsicSize, + } + : undefined; + const maskAsset = assetWithBrushStrokePreview(maskLayer && (maskLayer.type === "image" || maskLayer.type === "raster") ? documentIndex.assetById.get(maskLayer.assetId) : undefined, editor) ?? pendingMaskPreview; const maskBounds = maskLayer ? resolveIndexedLayerBounds(documentIndex, maskLayer) : undefined; - const maskRect = maskBounds ? documentRectToScreenRect(context.canvas, maskBounds, editor.viewport) : undefined; - const activeMaskTarget = Boolean(editor.maskEdit?.targetLayerId === layer.id && editor.maskEdit.maskLayerId === layerMask?.maskLayerId); + const maskRect = maskBounds ? documentRectToScreenRect(context.canvas, maskBounds, editor.viewport) : pendingMaskPreview ? rect : undefined; + const activeMaskTarget = Boolean((editor.maskEdit?.targetLayerId === layer.id && editor.maskEdit.maskLayerId === layerMask?.maskLayerId) || pendingMaskPreview); const showMaskRevealPreview = editor.tools.activeTool === "brush" && activeMaskTarget && maskViewMode === "composite"; if (asset && maskAsset && maskRect && activeMaskTarget) { diff --git a/view/App.tsx b/view/App.tsx index 2de965a..c550da6 100644 --- a/view/App.tsx +++ b/view/App.tsx @@ -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} diff --git a/view/BottomControlsIsland.tsx b/view/BottomControlsIsland.tsx index 08de1a1..1c3ae77 100644 --- a/view/BottomControlsIsland.tsx +++ b/view/BottomControlsIsland.tsx @@ -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 ) : operation === "chromaKey" ? ( - ) : (activeTool === "brush" || activeTool === "eraser") && brushHint ? ( + ) : (activeTool === "brush" || activeTool === "eraser" || activeTool === "feather") && brushHint ? ( ) : activeTool === "brush" || activeTool === "eraser" ? ( + ) : activeTool === "feather" ? ( + ) : activeTool === "magicWand" ? ( ) : 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 (
{tool} diff --git a/view/ToolOverlay.tsx b/view/ToolOverlay.tsx index 6a69b07..3131692 100644 --- a/view/ToolOverlay.tsx +++ b/view/ToolOverlay.tsx @@ -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": diff --git a/view/bottom-controls/FeatherControls.tsx b/view/bottom-controls/FeatherControls.tsx new file mode 100644 index 0000000..224fb52 --- /dev/null +++ b/view/bottom-controls/FeatherControls.tsx @@ -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 ( +
+ + + {editingMask ? "Feather mask" : "Feather layer mask"} + + dispatch(commandIds.toolSetFeatherSettings, { size })} /> + + dispatch(commandIds.toolSetFeatherSettings, { radius })} /> + + dispatch(commandIds.toolSetFeatherSettings, { strength })} /> + + dispatch(commandIds.toolSetFeatherSettings, { smoothing })} /> + + {editingMask ? ( + <> + + + + ) : null} +
+ ); +} + +function FeatherSlider({ label, min, max, value, suffix = "", onValueChange }: { label: string; min: number; max: number; value: number; suffix?: string; onValueChange: (value: number) => void }) { + return ( + + ); +} + +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"}`; +} diff --git a/view/canvas/cursor.test.ts b/view/canvas/cursor.test.ts index 6542ba2..30a0cec 100644 --- a/view/canvas/cursor.test.ts +++ b/view/canvas/cursor.test.ts @@ -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"); + }); }); diff --git a/view/canvas/cursor.ts b/view/canvas/cursor.ts index 1071515..09117d8 100644 --- a/view/canvas/cursor.ts +++ b/view/canvas/cursor.ts @@ -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"; } diff --git a/view/canvas/renderFrame.test.ts b/view/canvas/renderFrame.test.ts index e0479d9..22af344 100644 --- a/view/canvas/renderFrame.test.ts +++ b/view/canvas/renderFrame.test.ts @@ -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); }); diff --git a/view/canvas/renderFrame.ts b/view/canvas/renderFrame.ts index 4970207..2b25808 100644 --- a/view/canvas/renderFrame.ts +++ b/view/canvas/renderFrame.ts @@ -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; } diff --git a/view/canvas/useCanvasInput.ts b/view/canvas/useCanvasInput.ts index 70d8ae1..d5a27f2 100644 --- a/view/canvas/useCanvasInput.ts +++ b/view/canvas/useCanvasInput.ts @@ -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; } diff --git a/view/paletteItems.tsx b/view/paletteItems.tsx index 93b23ef..ac4c37e 100644 --- a/view/paletteItems.tsx +++ b/view/paletteItems.tsx @@ -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 ; case "eraser": return ; + case "feather": + return ; case "magicWand": return ; case "semanticSelect": diff --git a/view/toolLabels.ts b/view/toolLabels.ts index 6993cba..3b30899 100644 --- a/view/toolLabels.ts +++ b/view/toolLabels.ts @@ -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":