mirror of
https://github.com/YuzuZensai/Pien-Studio.git
synced 2026-09-02 14:18:35 +00:00
100 lines
3.0 KiB
TypeScript
100 lines
3.0 KiB
TypeScript
export type BrushOptions = {
|
||
color: string;
|
||
size: number;
|
||
opacity: number;
|
||
hardness: number; // 0–1: 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;
|
||
});
|
||
}
|