mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-27 08:12:22 +01:00
178 lines
6.7 KiB
JavaScript
178 lines
6.7 KiB
JavaScript
import { state } from '../state.js';
|
|
import { canvasCoords } from '../canvas-coords.js';
|
|
|
|
/**
|
|
* Liquify — Photoshop-style destructive warp brush for the active layer.
|
|
*
|
|
* Dragging samples the layer's pixels through a displacement field: Push
|
|
* drags pixels with the pointer, Twirl rotates them around the dab, Pucker
|
|
* pulls inward, Bloat pushes outward and Smooth softens the area. Each
|
|
* stroke is one undo step (the snapshot is taken on press) and the layer is
|
|
* re-composited at most once per animation frame, so the canvas shows the
|
|
* warp live without re-rendering the document on every mouse event.
|
|
*/
|
|
|
|
const MODES = new Set(['push', 'twirl', 'pucker', 'bloat', 'smooth']);
|
|
|
|
export function createLiquifyTool({ activeLayer, saveState, composite }) {
|
|
let layer = null, owner = null, last = null, live = false, raf = 0;
|
|
|
|
const radius = () => Math.max(4, Math.min(1000, Number(state.liquifySize) || 120));
|
|
const strength = () => Math.max(1, Math.min(100, Number(state.liquifyStrength) || 50)) / 100;
|
|
const mode = () => (MODES.has(state.liquifyMode) ? state.liquifyMode : 'push');
|
|
|
|
const requestComposite = () => {
|
|
if (raf) return;
|
|
raf = requestAnimationFrame(() => { raf = 0; composite(); });
|
|
};
|
|
|
|
// Pointer position in the active layer's own pixel space (layers can be
|
|
// offset from the document origin).
|
|
function layerPoint(event) {
|
|
const p = canvasCoords(event, state.mainCanvas);
|
|
const off = state.layerOffsets.get(layer.id) || { x: 0, y: 0 };
|
|
return { x: p.x - off.x, y: p.y - off.y };
|
|
}
|
|
|
|
function sampleBilinear(src, w, h, fx, fy, dst, di) {
|
|
const x = Math.max(0, Math.min(w - 1, fx - 0.5));
|
|
const y = Math.max(0, Math.min(h - 1, fy - 0.5));
|
|
const x1 = Math.floor(x), y1 = Math.floor(y);
|
|
const x2 = Math.min(w - 1, x1 + 1), y2 = Math.min(h - 1, y1 + 1);
|
|
const tx = x - x1, ty = y - y1;
|
|
const i11 = (y1 * w + x1) * 4, i21 = (y1 * w + x2) * 4;
|
|
const i12 = (y2 * w + x1) * 4, i22 = (y2 * w + x2) * 4;
|
|
const wx1 = 1 - tx, wy1 = 1 - ty, wx2 = tx, wy2 = ty;
|
|
for (let c = 0; c < 4; c++) {
|
|
dst[di + c] = src[i11 + c] * wx1 * wy1 + src[i21 + c] * wx2 * wy1
|
|
+ src[i12 + c] * wx1 * wy2 + src[i22 + c] * wx2 * wy2;
|
|
}
|
|
}
|
|
|
|
function average3x3(src, w, h, px, py) {
|
|
let r = 0, g = 0, b = 0, a = 0, n = 0;
|
|
for (let oy = -1; oy <= 1; oy++) {
|
|
for (let ox = -1; ox <= 1; ox++) {
|
|
const x = Math.max(0, Math.min(w - 1, Math.round(px) + ox));
|
|
const y = Math.max(0, Math.min(h - 1, Math.round(py) + oy));
|
|
const i = (y * w + x) * 4;
|
|
r += src[i]; g += src[i + 1]; b += src[i + 2]; a += src[i + 3]; n++;
|
|
}
|
|
}
|
|
return { r: r / n, g: g / n, b: b / n, a: a / n };
|
|
}
|
|
|
|
function dab(cx, cy, dx, dy, spin) {
|
|
const canvas = layer.canvas, ctx = layer.ctx;
|
|
const w = canvas.width, h = canvas.height;
|
|
const r = radius();
|
|
const x0 = Math.max(0, Math.floor(cx - r - 1));
|
|
const y0 = Math.max(0, Math.floor(cy - r - 1));
|
|
const x1 = Math.min(w, Math.ceil(cx + r + 1));
|
|
const y1 = Math.min(h, Math.ceil(cy + r + 1));
|
|
const bw = x1 - x0, bh = y1 - y0;
|
|
if (bw <= 0 || bh <= 0) return;
|
|
|
|
const img = ctx.getImageData(x0, y0, bw, bh);
|
|
const out = ctx.createImageData(bw, bh);
|
|
const src = img.data, dst = out.data;
|
|
const m = mode(), s = strength();
|
|
|
|
for (let y = 0; y < bh; y++) {
|
|
for (let x = 0; x < bw; x++) {
|
|
const di = (y * bw + x) * 4;
|
|
const px = x0 + x + 0.5, py = y0 + y + 0.5;
|
|
const ox = px - cx, oy = py - cy;
|
|
const dist = Math.hypot(ox, oy);
|
|
if (dist >= r) {
|
|
dst[di] = src[di]; dst[di + 1] = src[di + 1]; dst[di + 2] = src[di + 2]; dst[di + 3] = src[di + 3];
|
|
continue;
|
|
}
|
|
const t = 1 - dist / r;
|
|
const fall = t * t * (3 - 2 * t);
|
|
let sx = px, sy = py;
|
|
if (m === 'push') {
|
|
sx = px - dx * fall * s;
|
|
sy = py - dy * fall * s;
|
|
} else if (m === 'twirl') {
|
|
const ang = spin * s * 2.6 * fall * fall;
|
|
const ca = Math.cos(ang), sa = Math.sin(ang);
|
|
sx = cx + ox * ca - oy * sa;
|
|
sy = cy + ox * sa + oy * ca;
|
|
} else if (m === 'pucker') {
|
|
const k = 1 - 0.55 * s * fall;
|
|
sx = cx + ox * k;
|
|
sy = cy + oy * k;
|
|
} else if (m === 'bloat') {
|
|
const k = 1 + 0.55 * s * fall;
|
|
sx = cx + ox * k;
|
|
sy = cy + oy * k;
|
|
} else if (m === 'smooth') {
|
|
const avg = average3x3(src, bw, bh, px - x0, py - y0);
|
|
const k = fall * s * 0.9;
|
|
dst[di] = src[di] * (1 - k) + avg.r * k;
|
|
dst[di + 1] = src[di + 1] * (1 - k) + avg.g * k;
|
|
dst[di + 2] = src[di + 2] * (1 - k) + avg.b * k;
|
|
dst[di + 3] = src[di + 3] * (1 - k) + avg.a * k;
|
|
continue;
|
|
}
|
|
sampleBilinear(src, bw, bh, sx - x0, sy - y0, dst, di);
|
|
}
|
|
}
|
|
ctx.putImageData(out, x0, y0);
|
|
}
|
|
|
|
function strokeTo(point, previous) {
|
|
const dx = point.x - previous.x, dy = point.y - previous.y;
|
|
const distance = Math.hypot(dx, dy);
|
|
// Twirl direction: the pointer's motion around the dab decides clockwise
|
|
// vs counter-clockwise, matching how Photoshop's twirl brush feels.
|
|
const ox = previous.x - point.x, oy = previous.y - point.y;
|
|
const spin = (ox * dy - oy * dx) >= 0 ? 1 : -1;
|
|
const step = Math.max(1, radius() * 0.25);
|
|
const steps = Math.max(1, Math.ceil(distance / step));
|
|
for (let i = 1; i <= steps; i++) {
|
|
dab(previous.x + (dx * i) / steps, previous.y + (dy * i) / steps, dx / steps, dy / steps, spin);
|
|
}
|
|
requestComposite();
|
|
}
|
|
|
|
return {
|
|
begin(event) {
|
|
const target = activeLayer();
|
|
if (!target || !target.canvas || !target.ctx) return;
|
|
if (event.button != null && event.button !== 0) return;
|
|
event.preventDefault();
|
|
owner = target;
|
|
layer = target;
|
|
saveState('Liquify');
|
|
last = layerPoint(event);
|
|
live = true;
|
|
// A single click still warps (twirl/pucker/bloat do something useful
|
|
// on press); Push needs movement, so it is a no-op until the drag.
|
|
if (mode() !== 'push') { dab(last.x, last.y, 0, 0, 1); requestComposite(); }
|
|
state.drawing = true;
|
|
},
|
|
drag(event) {
|
|
if (!live || !layer || owner !== activeLayer() || state.tool !== 'liquify') return;
|
|
event.preventDefault();
|
|
const point = layerPoint(event);
|
|
strokeTo(point, last);
|
|
last = point;
|
|
},
|
|
end() {
|
|
if (!live) return;
|
|
live = false;
|
|
last = null;
|
|
owner = null;
|
|
state.drawing = false;
|
|
if (raf) { cancelAnimationFrame(raf); raf = 0; }
|
|
composite();
|
|
},
|
|
cancel() {
|
|
live = false; last = null; owner = null; state.drawing = false;
|
|
if (raf) { cancelAnimationFrame(raf); raf = 0; }
|
|
},
|
|
};
|
|
}
|