Files
image-studio/operations/paint/brush.ts
syntaxbullet ff762b8f17 feat: add inpaint region functionality and related tools
- Enhanced cursor behavior for new tools: semantic select, mask lasso, and mask rectangle.
- Updated mask edit state to include mask asset ID and kind.
- Implemented inpaint region commands for adding, applying, and removing inpaint regions.
- Introduced new operations for lasso and semantic selection tools.
- Created UI components for candidate review and inpaint region management.
- Added tests for inpaint region commands to ensure functionality.
- Updated various components to support new inpaint features and improve user experience.
2026-07-11 16:41:22 +02:00

261 lines
11 KiB
TypeScript

import { commandIds } from "@commands/ids";
import type { ImageDocument } from "@core/document";
import type { Vec2D } from "@core/geometry";
import type { Layer } from "@core/layer";
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";
export type BrushSession = {
layerId: string;
assetId: string;
width: number;
height: number;
surface: BrushSurface;
ready: Promise<boolean>;
previousPoint: Vec2D;
mode: "brush" | "eraser";
changed?: boolean;
pending?: Promise<void>;
cancelled?: boolean;
previewClosed?: boolean;
previewRequested?: boolean;
previewInFlight?: boolean;
previewFrame?: number;
previewSource?: string;
targetLayer: RasterLayer;
};
export type BrushTargetEditorState = {
selection: SelectionState;
tools: Pick<ToolState, "activeTool" | "interactionMode">;
maskEdit?: MaskEditState;
};
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;
const { layer, asset } = target;
const surface = createBrushSurface(asset.intrinsicSize.w, asset.intrinsicSize.h, asset.source);
if (!surface) return undefined;
const session: BrushSession = {
layerId: layer.id,
assetId: layer.assetId,
width: surface.width,
height: surface.height,
surface,
ready: surface.ready,
previousPoint: point,
mode: editor.tools.activeTool,
targetLayer: layer,
};
return session;
}
export function canPreviewBrush(document: ImageDocument, editor: BrushTargetEditorState): boolean {
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;
const layerId = editor.maskEdit?.maskLayerId ?? editor.selection.layerIds[0];
if (!layerId) {
if (editor.selection.artboardId) return "Brushes paint layers, not artboards. Select or add a raster layer first.";
return "Select a raster layer or layer mask to paint.";
}
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 === "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;
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);
if (!target || (target.type !== "image" && target.type !== "raster") || target.locked || !asset) return undefined;
return {
layer: { ...target, type: "raster", assetId: asset.id },
asset,
};
}
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 asset = document.assets.find((candidate) => candidate.id === layer.assetId);
return asset ? { layer, asset } : undefined;
}
export function updateBrushSession(options: {
store: AppStore;
session: BrushSession;
point: Vec2D;
color: string;
size: number;
hardness: number;
opacity: number;
flow: number;
smoothing: number;
pressure: number;
pressureSize: boolean;
}): BrushSession {
const state = options.store.getState();
const asset = state.document.assets.find((candidate) => candidate.id === options.session.assetId);
if (!asset) return options.session;
const layer = options.session.targetLayer;
const from = options.session.previousPoint;
const smoothing = Math.max(0, Math.min(100, options.smoothing)) / 100;
const follow = 1 - smoothing * 0.85;
const to = { x: from.x + (options.point.x - from.x) * follow, y: from.y + (options.point.y - from.y) * follow };
options.session.previousPoint = to;
options.session.pending = (options.session.pending ?? Promise.resolve())
.then(async () => {
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,
});
if (options.session.cancelled) return;
options.session.changed = true;
requestBrushStrokePreview({ store: options.store, session: options.session });
})
.catch(() => undefined);
return options.session;
}
export async function commitBrushSession(options: { store: AppStore; session: BrushSession }) {
await options.session.pending;
if (options.session.cancelled) return;
const source = options.session.changed ? brushSurfaceDataUrl(options.session.surface) : undefined;
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" } });
} else if (maskEdit?.maskLayerId === options.session.layerId) {
options.store.dispatch(commandIds.documentApplyLayerMaskOperation, { maskLayerId: maskEdit.maskLayerId, source, mimeType: "image/png", operation: { type: "paint" } });
} else {
options.store.dispatch(commandIds.documentUpdateAssetSource, { assetId: options.session.assetId, source });
}
}
options.store.dispatch(commandIds.toolSetBrushStrokePreview, undefined);
closeBrushStrokePreview(options.session);
}
export function cancelBrushSession(options: { store: AppStore; session: BrushSession }) {
options.session.cancelled = true;
options.store.dispatch(commandIds.toolSetBrushStrokePreview, undefined);
closeBrushStrokePreview(options.session);
}
function documentPointToAssetPoint(point: Vec2D, layer: RasterLayer, width: number, height: number): Vec2D {
const source = layer.sourceRect ?? { x: 0, y: 0, w: width, h: height };
const destination = {
x: layer.transform.position.x + source.x * layer.transform.scale.x,
y: layer.transform.position.y + source.y * layer.transform.scale.y,
w: source.w * layer.transform.scale.x,
h: source.h * layer.transform.scale.y,
};
const center = { x: destination.x + destination.w / 2, y: destination.y + destination.h / 2 };
const cos = Math.cos(-layer.transform.rotation);
const sin = Math.sin(-layer.transform.rotation);
const dx = point.x - center.x;
const dy = point.y - center.y;
const unrotated = { x: center.x + dx * cos - dy * sin, y: center.y + dx * sin + dy * cos };
return {
x: source.x + (unrotated.x - destination.x) / Math.max(0.0001, layer.transform.scale.x),
y: source.y + (unrotated.y - destination.y) / Math.max(0.0001, layer.transform.scale.y),
};
}
function requestBrushStrokePreview(options: { store: AppStore; session: BrushSession }) {
if (options.session.cancelled || options.session.previewClosed) return;
options.session.previewRequested = true;
if (options.session.previewFrame !== undefined || options.session.previewInFlight) return;
options.session.previewFrame = scheduleFrame(() => {
options.session.previewFrame = undefined;
void publishBrushStrokePreview(options);
});
}
async function publishBrushStrokePreview(options: { store: AppStore; session: BrushSession }) {
if (options.session.cancelled || options.session.previewClosed || !options.session.previewRequested) return;
options.session.previewRequested = false;
options.session.previewInFlight = true;
const source = await brushSurfaceObjectUrl(options.session.surface).catch(() => undefined);
options.session.previewInFlight = false;
if (!source) {
if (options.session.previewRequested) requestBrushStrokePreview(options);
return;
}
if (options.session.cancelled || options.session.previewClosed) {
releaseObjectUrl(source);
return;
}
const previousSource = options.session.previewSource;
options.session.previewSource = source;
options.store.dispatch(commandIds.toolSetBrushStrokePreview, { layerId: options.session.layerId, assetId: options.session.assetId, source });
releaseObjectUrl(previousSource);
if (options.session.previewRequested) requestBrushStrokePreview(options);
}
function closeBrushStrokePreview(session: BrushSession) {
session.previewClosed = true;
if (session.previewFrame !== undefined) {
cancelFrame(session.previewFrame);
session.previewFrame = undefined;
}
if (session.previewSource) {
releaseObjectUrl(session.previewSource);
session.previewSource = undefined;
}
}
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;
if (layer.type === "group") {
const child = findLayer(layer.children, layerId);
if (child) return child;
}
}
return undefined;
}