- 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.
131 lines
7.0 KiB
TypeScript
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);
|
|
}
|