- 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.
184 lines
9.9 KiB
TypeScript
184 lines
9.9 KiB
TypeScript
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, 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;
|
|
|
|
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, feather: defaultFeather, generate: defaultGenerate, chromaKey: defaultChromaKey, magicWand: defaultMagicWand });
|
|
});
|
|
|
|
test("selecting a persistent tool closes an open operation", () => {
|
|
const state = createInitialAppState("Test");
|
|
state.editor.workspace = { panel: "chromaKey" };
|
|
const next = toolSetActiveCommand.execute({ state }, { tool: "brush" });
|
|
expect(next.editor.tools.activeTool).toBe("brush");
|
|
expect(next.editor.workspace.panel).toBe("none");
|
|
});
|
|
|
|
test("sets brush settings", () => {
|
|
const next = toolSetBrushSettingsCommand.execute({ state: createInitialAppState("Test") }, { color: "#ff0000", size: 24, hardness: 50 });
|
|
|
|
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 });
|
|
|
|
expect(next.editor.tools.chromaKey).toEqual({ color: "#123456", tolerance: 255, softness: 24, feather: 0, choke: 0, despeckle: 0, spill: 50 });
|
|
});
|
|
|
|
test("sets inpaint generation settings", () => {
|
|
const next = toolSetGenerateSettingsCommand.execute(
|
|
{ state: createInitialAppState("Test") },
|
|
{ mode: "inpaint", inpaint: { ...defaultGenerate.inpaint, cropPadding: 5000, growMaskBy: -4, maskExpand: -500, maskBlur: 300, maskPolarity: "revealed", maskedContent: "original" } },
|
|
);
|
|
|
|
expect(next.editor.tools.generate.mode).toBe("inpaint");
|
|
expect(next.editor.tools.generate.inpaint).toEqual({ ...defaultGenerate.inpaint, cropPadding: 2048, growMaskBy: 0, maskExpand: -256, maskBlur: 256, maskPolarity: "revealed", maskedContent: "original" });
|
|
});
|
|
|
|
test("applies outcome-oriented inpaint profile defaults", () => {
|
|
const next = toolSetGenerateSettingsCommand.execute({ state: createInitialAppState("Test") }, { mode: "inpaint", inpaint: { profile: "material" } });
|
|
expect(next.editor.tools.generate).toMatchObject({ strength: 65, inpaint: { profile: "material", maskedContent: "edges", structureControl: "canny", controlStrength: 0.55, colorMatch: false } });
|
|
});
|
|
|
|
test("applies architecture defaults and filters unsupported modes", () => {
|
|
const initial = toolSetGenerateSettingsCommand.execute({ state: createInitialAppState("Test") }, { mode: "inpaint" });
|
|
const next = toolSetGenerateSettingsCommand.execute({ state: initial }, { architecture: "z-image-turbo" });
|
|
|
|
expect(next.editor.tools.generate).toMatchObject({
|
|
architecture: "z-image-turbo",
|
|
mode: "text-to-image",
|
|
model: "auto",
|
|
textEncoder: "qwen_3_4b.safetensors",
|
|
vae: "ae.safetensors",
|
|
steps: 8,
|
|
cfg: 1,
|
|
sampler: "res_multistep",
|
|
scheduler: "simple",
|
|
});
|
|
});
|
|
|
|
test("maps user intents to supported generation modes and a compatible architecture", () => {
|
|
const zImage = toolSetGenerateSettingsCommand.execute({ state: createInitialAppState("Test") }, { architecture: "z-image" });
|
|
const replace = toolChooseGenerateIntentCommand.execute({ state: zImage }, { intent: "replace" });
|
|
const variations = toolChooseGenerateIntentCommand.execute({ state: replace }, { intent: "variations" });
|
|
|
|
expect(replace.editor.tools.generate).toMatchObject({ architecture: "sdxl", mode: "inpaint" });
|
|
expect(variations.editor.tools.generate).toMatchObject({ architecture: "sdxl", mode: "image-to-image" });
|
|
});
|
|
|
|
test("maps object removal to the dedicated inpaint profile", () => {
|
|
const removed = toolChooseGenerateIntentCommand.execute({ state: createInitialAppState("Test") }, { intent: "remove" });
|
|
expect(removed.editor.tools.generate).toMatchObject({ architecture: "sdxl", mode: "inpaint", strength: 100, inpaint: { profile: "remove", maskedContent: "neutral", maskExpand: 12 } });
|
|
});
|
|
|
|
test("sets and clears brush preview", () => {
|
|
const showing = toolSetBrushPreviewCommand.execute({ state: createInitialAppState("Test") }, { position: { x: 10, y: 20 } });
|
|
const cleared = toolSetBrushPreviewCommand.execute({ state: showing }, undefined);
|
|
|
|
expect(showing.editor.brushPreview).toEqual({ position: { x: 10, y: 20 } });
|
|
expect(cleared.editor.brushPreview).toBeUndefined();
|
|
});
|
|
|
|
test("sets and clears brush stroke preview", () => {
|
|
const showing = toolSetBrushStrokePreviewCommand.execute({ state: createInitialAppState("Test") }, { layerId: "layer", assetId: "asset", source: "preview" });
|
|
const cleared = toolSetBrushStrokePreviewCommand.execute({ state: showing }, undefined);
|
|
|
|
expect(showing.editor.brushStrokePreview).toEqual({ layerId: "layer", assetId: "asset", source: "preview" });
|
|
expect(cleared.editor.brushStrokePreview).toBeUndefined();
|
|
});
|
|
|
|
test("keeps rectangle mask gestures as two command-owned corner points", () => {
|
|
const state = createInitialAppState("Test");
|
|
state.editor = {
|
|
...state.editor,
|
|
tools: { ...state.editor.tools, activeTool: "maskRectangle", interactionMode: { type: "tool", tool: "maskRectangle" } },
|
|
maskEdit: { kind: "inpaintRegion", targetLayerId: "target", inpaintRegionId: "region", maskAssetId: "mask" },
|
|
};
|
|
const begun = toolBeginMaskShapeCommand.execute({ state }, { point: { x: 10, y: 20 }, mode: "add" });
|
|
const moved = toolAppendMaskShapeCommand.execute({ state: begun }, { point: { x: 40, y: 60 } });
|
|
const movedAgain = toolAppendMaskShapeCommand.execute({ state: moved }, { point: { x: 50, y: 70 } });
|
|
|
|
expect(movedAgain.editor.maskShapeSession).toEqual({ shape: "rectangle", mode: "add", points: [{ x: 10, y: 20 }, { x: 50, y: 70 }] });
|
|
});
|
|
|
|
test("enters and exits mask edit", () => {
|
|
const editing = toolEnterMaskEditCommand.execute({ state: stateWithMask() }, { targetLayerId: "target", maskLayerId: "mask" });
|
|
const exited = toolExitMaskEditCommand.execute({ state: editing }, undefined);
|
|
|
|
expect(editing.editor.maskEdit).toEqual({ kind: "layerMask", targetLayerId: "target", maskLayerId: "mask", maskAssetId: "mask-asset" });
|
|
expect(editing.editor.selection).toEqual({ artboardId: "a1", layerIds: ["target"] });
|
|
expect(editing.editor.tools.activeTool).toBe("brush");
|
|
expect(exited.editor.maskEdit).toBeUndefined();
|
|
});
|
|
|
|
test("sets mask view mode while editing a mask", () => {
|
|
const editing = toolEnterMaskEditCommand.execute({ state: stateWithMask() }, { targetLayerId: "target", maskLayerId: "mask" });
|
|
const alpha = toolSetMaskViewModeCommand.execute({ state: editing }, { mode: "alpha" });
|
|
const ignored = toolSetMaskViewModeCommand.execute({ state: createInitialAppState("Test") }, { mode: "blackWhite" });
|
|
|
|
expect(alpha.editor.maskEdit).toEqual({ kind: "layerMask", targetLayerId: "target", maskLayerId: "mask", maskAssetId: "mask-asset", viewMode: "alpha" });
|
|
expect(ignored.editor.maskEdit).toBeUndefined();
|
|
});
|
|
|
|
test("enters and exits temporary pan", () => {
|
|
const initial = createInitialAppState("Test");
|
|
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, feather: defaultFeather, generate: defaultGenerate, chromaKey: defaultChromaKey, magicWand: defaultMagicWand });
|
|
expect(restored.editor.tools).toEqual(initial.editor.tools);
|
|
});
|
|
});
|
|
|
|
function stateWithMask() {
|
|
return {
|
|
...createInitialAppState("Test"),
|
|
document: {
|
|
...createInitialAppState("Test").document,
|
|
artboards: [
|
|
{
|
|
id: "a1",
|
|
name: "Artboard 1",
|
|
bounds: { x: 0, y: 0, w: 320, h: 240 },
|
|
backgroundColor: "transparent" as const,
|
|
visible: true,
|
|
locked: false,
|
|
layers: [
|
|
raster("mask", "Mask"),
|
|
{ ...raster("target", "Target"), clippingMask: { maskLayerId: "mask" } },
|
|
],
|
|
},
|
|
],
|
|
},
|
|
};
|
|
}
|
|
|
|
function raster(id: string, name: string) {
|
|
return {
|
|
id,
|
|
type: "raster" as const,
|
|
name,
|
|
visible: true,
|
|
locked: false,
|
|
opacity: 1,
|
|
assetId: `${id}-asset`,
|
|
transform: { position: { x: 0, y: 0 }, scale: { x: 1, y: 1 }, rotation: 0 },
|
|
};
|
|
}
|