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:
40
platform/browser/brushRaster.test.ts
Normal file
40
platform/browser/brushRaster.test.ts
Normal file
@@ -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]);
|
||||
}
|
||||
@@ -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<boolean> }).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<string | undefined>((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<HTMLImageElement>((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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user