Files
image-studio/platform/browser/brushRaster.ts
syntaxbullet 38565382c3 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.
2026-07-11 22:08:25 +02:00

131 lines
7.0 KiB
TypeScript

export type BrushSurface = {
readonly width: number;
readonly height: number;
readonly ready: Promise<boolean>;
readonly resource: object;
};
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,
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;
}
export function drawBrushSegment(surface: BrushSurface, options: { from: { x: number; y: number }; to: { x: number; y: number }; color: string; size: number; hardness: number; opacity: number; flow: number; mode: "brush" | "eraser" }) {
const context = internal(surface).context; const hardness = Math.max(0, Math.min(100, options.hardness)) / 100;
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); }
export function scheduleFrame(callback: () => void) { return requestAnimationFrame(callback); }
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);
}