mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-16 03:42:20 +02:00
Photoshop-parity editor client: layers, selection/masking, brushes,
smart filters, LUT import, Quick Edit, mobile gestures, workspaces,
batch operations, and the service-worker versioning (v978->v1006).
Source: vendor/photocraft (ad1cff44) photo-editor client files, merged
onto upstream/dev (a8c147b2). Conflict resolution documented in
pr_work/A_conflicts.md; editor files taken from vendor/photocraft
(upstream's editor is an earlier snapshot of the same feature).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
112 lines
4.0 KiB
JavaScript
112 lines
4.0 KiB
JavaScript
// Perspective (keystone) warp via a projective homography.
|
||
//
|
||
// Pure array maths so it can run in the browser and in Node tests. The
|
||
// destination is described by four normalized corners (TL, TR, BR, BL) as
|
||
// fractions of the output size; source pixels are sampled by inverse mapping.
|
||
|
||
export function solveSquareToQuad(corners) {
|
||
const [p0, p1, p2, p3] = corners; // TL, TR, BR, BL
|
||
const dx1 = p1.x - p2.x;
|
||
const dx2 = p3.x - p2.x;
|
||
const dx3 = p0.x - p1.x + p2.x - p3.x;
|
||
const dy1 = p1.y - p2.y;
|
||
const dy2 = p3.y - p2.y;
|
||
const dy3 = p0.y - p1.y + p2.y - p3.y;
|
||
if (dx3 === 0 && dy3 === 0) {
|
||
// Affine.
|
||
return [p1.x - p0.x, p2.x - p1.x, p0.x, p1.y - p0.y, p2.y - p1.y, p0.y, 0, 0, 1];
|
||
}
|
||
const den = dx1 * dy2 - dx2 * dy1;
|
||
const g = (dx3 * dy2 - dx2 * dy3) / den;
|
||
const h = (dx1 * dy3 - dx3 * dy1) / den;
|
||
const a = p1.x - p0.x + g * p1.x;
|
||
const b = p3.x - p0.x + h * p3.x;
|
||
const c = p0.x;
|
||
const d = p1.y - p0.y + g * p1.y;
|
||
const e = p3.y - p0.y + h * p3.y;
|
||
const f = p0.y;
|
||
return [a, b, c, d, e, f, g, h, 1];
|
||
}
|
||
|
||
function invert3x3(m) {
|
||
const [a, b, c, d, e, f, g, h, i] = m;
|
||
const A = e * i - f * h;
|
||
const B = -(d * i - f * g);
|
||
const C = d * h - e * g;
|
||
const D = -(b * i - c * h);
|
||
const E = a * i - c * g;
|
||
const F = -(a * h - b * g);
|
||
const G = b * f - c * e;
|
||
const H = -(a * f - c * d);
|
||
const I = a * e - b * d;
|
||
const det = a * A + b * B + c * C;
|
||
if (!det) return null;
|
||
return [A / det, D / det, G / det, B / det, E / det, H / det, C / det, F / det, I / det];
|
||
}
|
||
|
||
/** Convert a `{ perspectiveX, perspectiveY }` amount pair (each −100..100) into
|
||
* destination corners for a unit square, keystoned about the centre. */
|
||
export function keystoneCorners(perspectiveX = 0, perspectiveY = 0) {
|
||
const topScale = Math.max(0.05, 1 - perspectiveX / 200); // >0 narrows the top
|
||
const sideScale = Math.max(0.05, 1 - perspectiveY / 200); // >0 narrows the sides
|
||
const insetX = (1 - topScale) / 2;
|
||
const insetY = (1 - sideScale) / 2;
|
||
return [
|
||
{ x: insetX, y: insetY }, // TL
|
||
{ x: 1 - insetX, y: insetY }, // TR
|
||
{ x: 1, y: 1 - insetY }, // BR
|
||
{ x: 0, y: 1 - insetY }, // BL
|
||
];
|
||
}
|
||
|
||
/** Warp an RGBA buffer (`Uint8ClampedArray`, `Uint8Array` or plain array) with
|
||
* an inverse-mapped homography. Returns a fresh `Uint8ClampedArray`. */
|
||
export function warpRgba(src, srcW, srcH, corners, outW, outH) {
|
||
const out = new Uint8ClampedArray(outW * outH * 4);
|
||
const H = solveSquareToQuad(corners);
|
||
const Hinv = invert3x3(H);
|
||
if (!Hinv) return out;
|
||
const [a, b, c, d, e, f, g, h] = Hinv;
|
||
for (let y = 0; y < outH; y++) {
|
||
const v = outH > 1 ? y / (outH - 1) : 0;
|
||
for (let x = 0; x < outW; x++) {
|
||
const u = outW > 1 ? x / (outW - 1) : 0;
|
||
const w = g * u + h * v + 1;
|
||
if (!w) continue;
|
||
const s = (a * u + b * v + c) / w;
|
||
const t = (d * u + e * v + f) / w;
|
||
if (s < 0 || s > 1 || t < 0 || t > 1) continue;
|
||
const sx = Math.min(srcW - 1, Math.max(0, Math.round(s * (srcW - 1))));
|
||
const sy = Math.min(srcH - 1, Math.max(0, Math.round(t * (srcH - 1))));
|
||
const si = (sy * srcW + sx) * 4;
|
||
const di = (y * outW + x) * 4;
|
||
out[di] = src[si];
|
||
out[di + 1] = src[si + 1];
|
||
out[di + 2] = src[si + 2];
|
||
out[di + 3] = src[si + 3];
|
||
}
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/** Browser helper: return a new canvas holding the warped source canvas. */
|
||
export function perspectiveWarpCanvas(sourceCanvas, corners, outW, outH) {
|
||
const srcW = sourceCanvas.width;
|
||
const srcH = sourceCanvas.height;
|
||
const sctx = sourceCanvas.getContext('2d');
|
||
const src = sctx.getImageData(0, 0, srcW, srcH).data;
|
||
const warped = warpRgba(src, srcW, srcH, corners, outW, outH);
|
||
const canvas = document.createElement('canvas');
|
||
canvas.width = outW;
|
||
canvas.height = outH;
|
||
const ctx = canvas.getContext('2d');
|
||
const image = ctx.createImageData(outW, outH);
|
||
image.data.set(warped);
|
||
ctx.putImageData(image, 0, 0);
|
||
return canvas;
|
||
}
|
||
|
||
export function isPerspectiveActive(perspectiveX = 0, perspectiveY = 0) {
|
||
return Math.abs(perspectiveX) > 0.01 || Math.abs(perspectiveY) > 0.01;
|
||
}
|