Files
Pien-Studio/apps/web/lib/brush-painter.ts
T

100 lines
3.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
export type BrushOptions = {
color: string;
size: number;
opacity: number;
hardness: number; // 01: 0 = fully soft, 1 = hard edge
};
export type BrushStroke = {
canvas: HTMLCanvasElement;
ctx: CanvasRenderingContext2D;
width: number;
height: number;
};
function hexToRgb(hex: string): { r: number; g: number; b: number } {
const clean = hex.replace("#", "");
return {
r: parseInt(clean.slice(0, 2), 16),
g: parseInt(clean.slice(2, 4), 16),
b: parseInt(clean.slice(4, 6), 16),
};
}
function drawDab(
ctx: CanvasRenderingContext2D,
x: number,
y: number,
options: BrushOptions,
) {
const r = options.size / 2;
const { r: cr, g: cg, b: cb } = hexToRgb(options.color);
const gradient = ctx.createRadialGradient(x, y, 0, x, y, r);
const innerStop = Math.max(0, Math.min(1, options.hardness));
gradient.addColorStop(0, `rgba(${cr},${cg},${cb},${options.opacity})`);
gradient.addColorStop(innerStop, `rgba(${cr},${cg},${cb},${options.opacity})`);
gradient.addColorStop(1, `rgba(${cr},${cg},${cb},0)`);
ctx.beginPath();
ctx.arc(x, y, r, 0, Math.PI * 2);
ctx.fillStyle = gradient;
ctx.fill();
}
/** Creates a fresh stroke canvas sized to the layer. */
export function createStroke(width: number, height: number): BrushStroke {
const canvas = document.createElement("canvas");
canvas.width = Math.max(1, Math.round(width));
canvas.height = Math.max(1, Math.round(height));
const ctx = canvas.getContext("2d");
if (!ctx) throw new Error("Cannot create brush canvas context");
return { canvas, ctx, width: canvas.width, height: canvas.height };
}
/** Paints a segment of a stroke from (x0,y0) to (x1,y1) using interpolated dabs. */
export function paintSegment(
stroke: BrushStroke,
x0: number,
y0: number,
x1: number,
y1: number,
options: BrushOptions,
) {
const dx = x1 - x0;
const dy = y1 - y0;
const dist = Math.sqrt(dx * dx + dy * dy);
const step = Math.max(1, options.size * 0.25);
const steps = Math.max(1, Math.ceil(dist / step));
for (let i = 0; i <= steps; i++) {
const t = steps === 0 ? 0 : i / steps;
drawDab(stroke.ctx, x0 + dx * t, y0 + dy * t, options);
}
}
/** Merges stroke canvas on top of the source image and returns a data URL. */
export function commitStroke(sourceUri: string, stroke: BrushStroke): Promise<string> {
return new Promise((resolve, reject) => {
const image = new Image();
image.crossOrigin = "anonymous";
image.onload = () => {
const canvas = document.createElement("canvas");
canvas.width = image.naturalWidth;
canvas.height = image.naturalHeight;
const ctx = canvas.getContext("2d");
if (!ctx) {
reject(new Error("Cannot create merge canvas context"));
return;
}
ctx.drawImage(image, 0, 0);
// Scale stroke canvas to match image natural size
ctx.drawImage(stroke.canvas, 0, 0, canvas.width, canvas.height);
resolve(canvas.toDataURL("image/png"));
};
image.onerror = reject;
image.src = sourceUri;
});
}