Files
odysseus/static/js/editor/stroke-pipeline.js
pewdiepie-archdaemon e5c99a5eee Odysseus v1.0
2026-05-31 23:58:26 +09:00

163 lines
6.6 KiB
JavaScript
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.
/**
* Stroke pipeline — paints one segment (last-position → current
* position) onto the active layer (or its active mask sub-layer).
*
* `strokeTo` dispatches by tool:
* - clone → cloneStrokeTo (custom stamp-based paint loop)
* - brush → source-over with opacity × flow + softness blur
* - eraser → destination-out with opacity × flow + softness blur
* - inpaint → source-over (paint) or destination-out (erase) with
* full alpha on the mask canvas
*
* If the active parent has an active mask sub-layer, brush / eraser /
* inpaint target the mask canvas instead of the layer's pixel canvas.
*
* @param {{
* activeLayer: () => object | null,
* getActiveMaskLayer: () => object | null,
* composite: () => void,
* }} deps
*/
import { state } from './state.js';
export function createStrokePipeline({ activeLayer, getActiveMaskLayer, composite }) {
function cloneStrokeTo(x, y, layer) {
if (!state.cloneSourceSnapshot) return;
const off = state.layerOffsets.get(layer.id) || { x: 0, y: 0 };
const dx = x - state.cloneStrokeStartX;
const dy = y - state.cloneStrokeStartY;
const srcX = state.cloneSourceX + dx;
const srcY = state.cloneSourceY + dy;
const ctx = layer.ctx;
const radius = Math.max(1, state.brushSize / 2);
// Walk last → current in roughly half-brush steps so stamps
// overlap into a continuous brush trail.
const lastSrcX = state.cloneSourceX + (state.lastX - state.cloneStrokeStartX);
const lastSrcY = state.cloneSourceY + (state.lastY - state.cloneStrokeStartY);
const dist = Math.hypot(x - state.lastX, y - state.lastY);
const step = Math.max(1, radius * 0.5);
const steps = Math.max(1, Math.ceil(dist / step));
const stampSize = Math.max(2, Math.ceil(radius * 2));
const stampRadius = stampSize / 2;
const stamp = document.createElement('canvas');
stamp.width = stampSize;
stamp.height = stampSize;
const stampCtx = stamp.getContext('2d');
const softness = Math.max(0, Math.min(1, state.cloneSoftness / 300));
const hardStop = stampRadius * (1 - softness);
ctx.save();
ctx.globalAlpha = (state.cloneOpacity / 100) * (state.cloneFlow / 100);
for (let i = 1; i <= steps; i++) {
const t = i / steps;
const px = state.lastX + (x - state.lastX) * t - off.x;
const py = state.lastY + (y - state.lastY) * t - off.y;
const sx = lastSrcX + (srcX - lastSrcX) * t;
const sy = lastSrcY + (srcY - lastSrcY) * t;
stampCtx.clearRect(0, 0, stampSize, stampSize);
stampCtx.globalCompositeOperation = 'source-over';
stampCtx.drawImage(
state.cloneSourceSnapshot,
sx - stampRadius, sy - stampRadius, stampSize, stampSize,
0, 0, stampSize, stampSize,
);
stampCtx.globalCompositeOperation = 'destination-in';
const mask = stampCtx.createRadialGradient(stampRadius, stampRadius, hardStop, stampRadius, stampRadius, stampRadius);
mask.addColorStop(0, 'rgba(0,0,0,1)');
mask.addColorStop(1, 'rgba(0,0,0,0)');
stampCtx.fillStyle = mask;
stampCtx.fillRect(0, 0, stampSize, stampSize);
ctx.drawImage(stamp, px - stampRadius, py - stampRadius);
}
ctx.restore();
state.lastX = x;
state.lastY = y;
composite();
}
function strokeTo(x, y) {
const layer = activeLayer();
if (!layer) return;
// Clone uses a stamp-based paint loop, not the line-stroke
// pipeline below.
if (state.tool === 'clone') return cloneStrokeTo(x, y, layer);
// If the active parent has an active mask sub-layer, brush /
// eraser / inpaint paint the mask canvas instead of the layer's
// pixel canvas. Brush adds to the mask, Eraser carves it away,
// Inpaint still works (its mask plumbing was already pointed at
// the same canvas).
const activeMask = getActiveMaskLayer();
const paintingMask = !!activeMask &&
(state.tool === 'brush' || state.tool === 'eraser' || state.tool === 'inpaint');
const ctx = paintingMask
? activeMask.ctx
: (state.tool === 'inpaint' ? state.maskCtx : layer.ctx);
const off = state.layerOffsets.get(layer.id) || { x: 0, y: 0 };
ctx.save();
ctx.lineWidth = state.brushSize;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
if (state.tool === 'eraser') {
ctx.globalCompositeOperation = 'destination-out';
// Effective alpha = opacity × flow. Opacity = max strength a
// stroke can reach; flow = how much erases per pass.
ctx.globalAlpha = (state.eraserOpacity / 100) * (state.eraserFlow / 100);
ctx.strokeStyle = 'rgba(0,0,0,1)';
if (state.eraserSoftness > 0) {
const blurPx = (state.eraserSoftness / 100) * (state.brushSize / 2);
ctx.filter = `blur(${blurPx.toFixed(2)}px)`;
}
} else if (state.tool === 'brush') {
// Brush — state.color onto the layer (or white onto an active
// mask sub-layer). Mask painting forces full alpha so masks
// stay a clean binary by default (a sub-100% brush would
// silently paint partial-strength mask pixels).
ctx.globalCompositeOperation = 'source-over';
ctx.strokeStyle = paintingMask ? 'rgba(255,255,255,1)' : state.color;
if (paintingMask) {
ctx.globalAlpha = 1;
} else {
ctx.globalAlpha = (state.brushOpacity / 100) * (state.brushFlow / 100);
if (state.brushSoftness > 0) {
const blurPx = (state.brushSoftness / 100) * (state.brushSize / 2);
ctx.filter = `blur(${blurPx.toFixed(2)}px)`;
}
}
} else if (state.tool === 'inpaint') {
if (state.inpaintEraseStroke) {
ctx.globalCompositeOperation = 'destination-out';
ctx.strokeStyle = 'rgba(0,0,0,1)';
} else {
ctx.globalCompositeOperation = 'source-over';
// Diffusion server expects white = inpaint area. The red
// overlay is rendered separately in composite() for the user.
ctx.strokeStyle = 'rgba(255,255,255,1)';
}
} else {
ctx.globalCompositeOperation = 'source-over';
ctx.strokeStyle = state.color;
}
// Mask canvases are always full-image (no per-layer offset), so
// painting onto a mask uses canvas-coord origin too — same as
// inpaint.
const onMaskOrInpaint = paintingMask || state.tool === 'inpaint';
const drawX = onMaskOrInpaint ? 0 : off.x;
const drawY = onMaskOrInpaint ? 0 : off.y;
ctx.beginPath();
ctx.moveTo(state.lastX - drawX, state.lastY - drawY);
ctx.lineTo(x - drawX, y - drawY);
ctx.stroke();
ctx.restore();
state.lastX = x;
state.lastY = y;
composite();
}
return { strokeTo, cloneStrokeTo };
}