diff --git a/web/src/lib/core/affine.test.ts b/web/src/lib/core/affine.test.ts index a5b1927..1f555bb 100644 --- a/web/src/lib/core/affine.test.ts +++ b/web/src/lib/core/affine.test.ts @@ -67,14 +67,19 @@ describe('rotateFreeImage', () => { }); describe('skewImage', () => { - it('наклон X=45° сдвигает верхний правый угол', () => { - const img = makeImage(2, 2, [ - [255, 0, 0, 255], [0, 255, 0, 255], - [0, 0, 255, 255], [128, 128, 128, 255] - ]); + it('наклон X=45° расширяет холст до w+h−1 и сдвигает строки вправо', () => { + const img = makeImage(2, 2, new Array(4).fill([255, 0, 0, 255])); const out = skewImage(img, 45, 0); - expect(out.width).toBeGreaterThan(2); - expect(out.data[3]).toBe(255); + expect(out.width).toBe(3); + expect(out.height).toBe(2); + let opaque = 0; + for (let i = 3; i < out.data.length; i += 4) { + if (out.data[i] === 255) { + opaque++; + expect([out.data[i - 3], out.data[i - 2], out.data[i - 1]]).toEqual([255, 0, 0]); + } + } + expect(opaque).toBe(4); }); it('углы 0° — тождественное преобразование', () => { @@ -87,3 +92,25 @@ describe('skewImage', () => { expect([...out.data]).toEqual([...img.data]); }); }); + +describe('transformImage — заливка фона', () => { + it('сдвиг с белым фоном заполняет освободившийся край', () => { + const img = makeImage(2, 2, [ + [255, 0, 0, 255], [0, 255, 0, 255], + [0, 0, 255, 255], [128, 128, 128, 255] + ]); + const out = transformImage(img, [1, 0, 0, 1, -1, 0], 2, 2, '#ffffff'); + expect(out.width).toBe(2); + expect(out.height).toBe(2); + for (let y = 0; y < 2; y++) { + const di = (y * 2) * 4; + expect([...out.data.slice(di, di + 4)]).toEqual([255, 255, 255, 255]); + } + }); + + it('без фона освободившийся край остаётся прозрачным', () => { + const img = makeImage(2, 2, new Array(4).fill([10, 20, 30, 255])); + const out = transformImage(img, [1, 0, 0, 1, -1, 0], 2, 2); + expect(out.data[3]).toBe(0); + }); +}); diff --git a/web/src/lib/core/affine.ts b/web/src/lib/core/affine.ts index f2357f2..745ce56 100644 --- a/web/src/lib/core/affine.ts +++ b/web/src/lib/core/affine.ts @@ -1,3 +1,4 @@ +import { parseHex } from './alpha'; import { clonePixelImage, createPixelImage, type PixelImage } from './types'; import { sampleBilinear } from './geometry'; @@ -15,61 +16,75 @@ export function invertAffine([a, b, c, d, e, f]: AffineMatrix): AffineMatrix { return [ia, ib, ic, id, -(ia * e + ic * f), -(ib * e + id * f)]; } -function mulAffine(m1: AffineMatrix, m2: AffineMatrix): AffineMatrix { - return [ - m1[0] * m2[0] + m1[2] * m2[1], - m1[1] * m2[0] + m1[3] * m2[1], - m1[0] * m2[2] + m1[2] * m2[3], - m1[1] * m2[2] + m1[3] * m2[3], - m1[0] * m2[4] + m1[2] * m2[5] + m1[4], - m1[1] * m2[4] + m1[3] * m2[5] + m1[5] - ]; -} +const EPS = 1e-9; export function transformImage( img: PixelImage, dstToSrc: AffineMatrix, outWidth: number, - outHeight: number + outHeight: number, + bgHex?: string ): PixelImage { - const [a, b, c, d, e, f] = invertAffine(dstToSrc); + const [a, b, c, d, e, f] = dstToSrc; const out = createPixelImage(outWidth, outHeight); + const bg = bgHex ? parseHex(bgHex) : null; for (let y = 0; y < outHeight; y++) { for (let x = 0; x < outWidth; x++) { const sx = a * x + c * y + e; const sy = b * x + d * y + f; const di = (y * outWidth + x) * 4; - if (sx < -1 || sy < -1 || sx > img.width || sy > img.height) continue; + if (sx < -EPS || sy < -EPS || sx > img.width - 1 + EPS || sy > img.height - 1 + EPS) { + if (bg) { + out.data[di] = bg[0]; + out.data[di + 1] = bg[1]; + out.data[di + 2] = bg[2]; + out.data[di + 3] = 255; + } + continue; + } const [r, g, bl, al] = sampleBilinear(img, sx, sy); - out.data[di] = r; - out.data[di + 1] = g; - out.data[di + 2] = bl; - out.data[di + 3] = al; + if (bg) { + const sa = al / 255; + out.data[di] = r * sa + bg[0] * (1 - sa); + out.data[di + 1] = g * sa + bg[1] * (1 - sa); + out.data[di + 2] = bl * sa + bg[2] * (1 - sa); + out.data[di + 3] = 255; + } else { + out.data[di] = r; + out.data[di + 1] = g; + out.data[di + 2] = bl; + out.data[di + 3] = al; + } } } return out; } -function transformCorners( - img: PixelImage, - m: AffineMatrix -): { minX: number; minY: number; outW: number; outH: number } { - const pts = [ - [0, 0], - [img.width, 0], - [0, img.height], - [img.width, img.height] - ].map(([x, y]) => [m[0] * x + m[2] * y + m[4], m[1] * x + m[3] * y + m[5]]); - const xs = pts.map((p) => p[0]); - const ys = pts.map((p) => p[1]); - const minX = Math.min(...xs); - const minY = Math.min(...ys); - return { - minX, - minY, - outW: Math.ceil(Math.max(...xs) - minX), - outH: Math.ceil(Math.max(...ys) - minY) - }; +function centeredTransform(img: PixelImage, forward: AffineMatrix): PixelImage { + const inv = invertAffine(forward); + + let minX = Infinity; + let maxX = -Infinity; + let minY = Infinity; + let maxY = -Infinity; + for (const [px, py] of [ + [0.5, 0.5], + [img.width - 0.5, 0.5], + [0.5, img.height - 0.5], + [img.width - 0.5, img.height - 0.5] + ]) { + const qx = forward[0] * px + forward[2] * py + forward[4]; + const qy = forward[1] * px + forward[3] * py + forward[5]; + minX = Math.min(minX, qx); + maxX = Math.max(maxX, qx); + minY = Math.min(minY, qy); + maxY = Math.max(maxY, qy); + } + const outW = Math.round(maxX - minX) + 1; + const outH = Math.round(maxY - minY) + 1; + const tx = inv[0] * minX + inv[2] * minY - 0.5; + const ty = inv[1] * minX + inv[3] * minY - 0.5; + return transformImage(img, [inv[0], inv[1], inv[2], inv[3], tx, ty], outW, outH); } export function skewImage(img: PixelImage, degX: number, degY: number): PixelImage { @@ -78,25 +93,16 @@ export function skewImage(img: PixelImage, degX: number, degY: number): PixelIma if (!Number.isFinite(kx) || !Number.isFinite(ky)) { throw new Error('Углы наклона не могут быть 90° или -90°'); } - const forward: AffineMatrix = [1, ky, kx, 1, 0, 0]; - const bounds = transformCorners(img, forward); - const toOrigin: AffineMatrix = [1, 0, 0, 1, -bounds.minX, -bounds.minY]; - return transformImage(img, mulAffine(invertAffine(forward), invertAffine(toOrigin)), bounds.outW, bounds.outH); + return centeredTransform(img, [1, ky, kx, 1, 0, 0]); } export function rotateFreeImage(img: PixelImage, degrees: number): PixelImage { const rad = (degrees * Math.PI) / 180; - const cos = Math.cos(rad); - const sin = Math.sin(rad); - const forward: AffineMatrix = [cos, sin, -sin, cos, 0, 0]; - const bounds = transformCorners(img, forward); - const toOrigin: AffineMatrix = [1, 0, 0, 1, -bounds.minX, -bounds.minY]; - return transformImage( - img, - mulAffine(invertAffine(forward), invertAffine(toOrigin)), - bounds.outW, - bounds.outH - ); + let cos = Math.cos(rad); + let sin = Math.sin(rad); + if (Math.abs(sin) < 1e-12) sin = 0; + if (Math.abs(cos) < 1e-12) cos = 0; + return centeredTransform(img, [cos, sin, -sin, cos, 0, 0]); } export function zoomImage(img: PixelImage, scalePercent: number): PixelImage { diff --git a/web/src/lib/registry.ts b/web/src/lib/registry.ts index 5556a84..b82a295 100644 --- a/web/src/lib/registry.ts +++ b/web/src/lib/registry.ts @@ -914,7 +914,8 @@ export const TOOLS: ToolEntry[] = [ img, [1, 0, 0, 1, -Math.trunc(num(p, 'offsetX')), -Math.trunc(num(p, 'offsetY'))], img.width, - img.height + img.height, + str(p, 'color') ) }, { @@ -959,7 +960,7 @@ export const TOOLS: ToolEntry[] = [ { id: 'temperature-png', title: 'Температура PNG', - description: 'Отрицательные значения делают изображение теплее (оранжевее), положительные — холоднее (синеватее).', + description: 'Положительные значения делают изображение теплее (оранжевее), отрицательные — холоднее (синеватее).', category: 'color', params: [ { id: 'percent', label: 'Температура', type: 'slider', min: -100, max: 100, step: 1, default: 0 }