feat: add geometry masks

This commit is contained in:
2026-08-26 12:31:44 +05:00
parent d65adcd16c
commit 84cc56cc6c
7 changed files with 517 additions and 19 deletions
+1 -1
View File
@@ -32,7 +32,7 @@
Ядро: SDF фигуры (круг/квадрат/звезда/волна) → альфа-маска с fit-режимами. Ядро: SDF фигуры (круг/квадрат/звезда/волна) → альфа-маска с fit-режимами.
Состав: circle-mask, square-mask, star-mask, wavy-mask. Состав: circle-mask, square-mask, star-mask, wavy-mask.
### W5. Геометрия-добивки — 5 инструментов, S ### W5. Геометрия-добивки — ВЫПОЛНЕНА (5 инструментов)
Ядро: bbox по альфе (trim) переиспользуется тремя инструментами источника. Ядро: bbox по альфе (trim) переиспользуется тремя инструментами источника.
Состав: trim-empty-space (закрывает remove border/space), change-canvas-size, change-aspect-ratio, landscape↔portrait, symmetric-copy. Состав: trim-empty-space (закрывает remove border/space), change-canvas-size, change-aspect-ratio, landscape↔portrait, symmetric-copy.
+10 -15
View File
@@ -37,6 +37,11 @@
- harden-alpha-png — threshold - harden-alpha-png — threshold
- despeckle-alpha-png / close-holes-png — radius - despeckle-alpha-png / close-holes-png — radius
- center-by-alpha-png — без параметров - center-by-alpha-png — без параметров
- round-corners-png — radius
- circle-mask-png — size (диаметр, % меньшей стороны), offsetX, offsetY
- square-mask-png — widthPct, heightPct, offsetX, offsetY
- star-mask-png — points, innerRadius, size, rotation, offsetX, offsetY
- wavy-mask-png — size, amplitude, waves, phase, offsetX, offsetY
### Цвет ### Цвет
@@ -73,6 +78,11 @@
- add-border-png — thickness, color - add-border-png — thickness, color
- fit-on-background-png — width, height, transparent, color - fit-on-background-png — width, height, transparent, color
- tile-png — columns, rows - tile-png — columns, rows
- trim-empty-space-png — threshold (альфа)
- change-canvas-size-png — width, height, anchor (3×3)
- change-aspect-ratio-png — ratio (пресеты), mode (crop/pad)
- swap-orientation-png — target (portrait/landscape)
- symmetric-copy-png — axis, keepSide
### Фильтры ### Фильтры
@@ -131,13 +141,6 @@
- separate-colors — minShare слоя (MEDIUM, мультифайловый вывод → пока идея) - separate-colors — minShare слоя (MEDIUM, мультифайловый вывод → пока идея)
### Фигурные маски
- circle-mask — diameter/fit, позиция
- square-mask — side, fit
- star-mask — rays, innerRadius, rotation
- wavy-mask — amplitude, frequency, направление края
### Края и силуэт ### Края и силуэт
- feather-edges — radius (размытие только альфы) - feather-edges — radius (размытие только альфы)
@@ -187,14 +190,6 @@
- gif-to-frames — MEDIUM (мультифайловый вывод → идея) - gif-to-frames — MEDIUM (мультифайловый вывод → идея)
- change-bit-depth — MEDIUM (пересборка PNG) - change-bit-depth — MEDIUM (пересборка PNG)
### Геометрия-добивки
- trim-empty-space — порог альфы (закрывает их Remove Border/Padding/Space одной операцией)
- change-canvas-size — w, h, якорь 3×3
- change-aspect-ratio — целевое отношение, режим (обрезать/вписать)
- landscape-to-portrait / portrait-to-landscape — авто-поворот 90° по ориентации
- symmetric-copy — ось (h/v), сторона
### Прочее единичное ### Прочее единичное
- pick-a-color — пипетка уже есть в превью; отдельная страница не планируется (покрыто) - pick-a-color — пипетка уже есть в превью; отдельная страница не планируется (покрыто)
+129
View File
@@ -0,0 +1,129 @@
import { describe, expect, it } from 'vitest';
import {
changeCanvasSize,
contentBounds,
cropToRatio,
forceOrientation,
padToRatio,
symmetricCopy,
trimToContent
} from './geometry';
import { makeImage } from './test-helpers';
const bordered = () => {
// 4×4: рамка из прозрачных пикселей вокруг красного центра 2×2
const img = makeImage(4, 4, new Array(16).fill([0, 0, 0, 0]));
for (let y = 1; y <= 2; y++) {
for (let x = 1; x <= 2; x++) {
const di = (y * 4 + x) * 4;
img.data[di] = 255;
img.data[di + 3] = 255;
}
}
return img;
};
describe('contentBounds / trimToContent', () => {
it('границы по порогу альфы', () => {
expect(contentBounds(bordered(), 0)).toEqual({ x: 1, y: 1, w: 2, h: 2 });
});
it('trim обрезает поля и сохраняет содержимое', () => {
const out = trimToContent(bordered(), 0);
expect(out.width).toBe(2);
expect(out.height).toBe(2);
expect(out.data[0]).toBe(255);
expect(out.data[3]).toBe(255);
});
it('полностью прозрачное изображение → 1×1', () => {
const empty = makeImage(3, 3, new Array(9).fill([0, 0, 0, 0]));
const out = trimToContent(empty, 0);
expect(out.width).toBe(1);
expect(out.height).toBe(1);
});
});
describe('changeCanvasSize', () => {
const img = makeImage(2, 2, [
[1, 1, 1, 255],
[2, 2, 2, 255],
[3, 3, 3, 255],
[4, 4, 4, 255]
]);
it('увеличение с якорем center — прозрачные поля со всех сторон', () => {
const out = changeCanvasSize(img, 4, 4, 'center');
expect(out.width).toBe(4);
expect(out.data[3]).toBe(0);
expect(out.data[(1 * 4 + 1) * 4 + 3]).toBe(255);
});
it('увеличение с якорем top-left — контент прижат в угол', () => {
const out = changeCanvasSize(img, 4, 4, 'top-left');
expect(out.data[3]).toBe(255);
expect(out.data[(3 * 4 + 3) * 4 + 3]).toBe(0);
});
it('уменьшение обрезает как кроп от якоря bottom-right', () => {
const out = changeCanvasSize(img, 1, 1, 'bottom-right');
expect(out.data[0]).toBe(4);
});
});
describe('соотношение сторон', () => {
const wide = makeImage(400, 200, new Array(80000).fill([9, 9, 9, 255]));
it('cropToRatio 1:1 из 2:1 → квадрат по высоте', () => {
const out = cropToRatio(wide, 1);
expect(out.width).toBe(200);
expect(out.height).toBe(200);
});
it('padToRatio 1:1 из 2:1 → квадрат с прозрачными полями', () => {
const out = padToRatio(wide, 1);
expect(out.width).toBe(400);
expect(out.height).toBe(400);
expect(out.data[3]).toBe(0);
expect(out.data[(200 * 400 + 100) * 4 + 3]).toBe(255);
});
});
describe('forceOrientation / symmetricCopy', () => {
it('широкое становится высоким поворотом', () => {
const wide = makeImage(300, 100, new Array(30000).fill([5, 5, 5, 255]));
const out = forceOrientation(wide, 'portrait');
expect(out.width).toBe(100);
expect(out.height).toBe(300);
});
it('квадрат не поворачивается', () => {
const sq = makeImage(50, 50, new Array(2500).fill([5, 5, 5, 255]));
const out = forceOrientation(sq, 'portrait');
expect(out.width).toBe(50);
expect(out.height).toBe(50);
});
it('симметричная копия удваивает ширину и зеркалит правую половину', () => {
const img = makeImage(2, 1, [
[10, 0, 0, 255],
[20, 0, 0, 255]
]);
const out = symmetricCopy(img, 'vertical', 'left');
expect(out.width).toBe(4);
expect(out.data[0]).toBe(10);
expect(out.data[8]).toBe(20); // пиксель 2 = зеркало начала
expect(out.data[12]).toBe(10); // пиксель 3 = зеркало конца
});
it('вертикальная ось удваивает высоту', () => {
const img = makeImage(1, 2, [
[10, 0, 0, 255],
[30, 0, 0, 255]
]);
const out = symmetricCopy(img, 'horizontal', 'top');
expect(out.height).toBe(4);
expect(out.data[(2 * 1 + 0) * 4]).toBe(30); // строка 2 = зеркало строки 1
expect(out.data[(3 * 1 + 0) * 4]).toBe(10); // строка 3 = зеркало строки 0
});
});
+155 -1
View File
@@ -1,4 +1,4 @@
import { parseHex } from './alpha'; import { parseHex } from './alpha';
import { ToolError } from './errors'; import { ToolError } from './errors';
import { clonePixelImage, createPixelImage, type PixelImage } from './types'; import { clonePixelImage, createPixelImage, type PixelImage } from './types';
@@ -213,3 +213,157 @@ export function sampleBilinear(
function clampInt(value: number, min: number, max: number): number { function clampInt(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, Math.trunc(value))); return Math.min(max, Math.max(min, Math.trunc(value)));
} }
export type Anchor9 =
| 'top-left'
| 'top-center'
| 'top-right'
| 'middle-left'
| 'center'
| 'middle-right'
| 'bottom-left'
| 'bottom-center'
| 'bottom-right';
/** Границы контента: пиксели с альфой строго больше порога. Пустое изображение → null. */
export function contentBounds(
img: PixelImage,
alphaThreshold = 0
): { x: number; y: number; w: number; h: number } | null {
let minX = img.width;
let minY = img.height;
let maxX = -1;
let maxY = -1;
for (let y = 0; y < img.height; y++) {
for (let x = 0; x < img.width; x++) {
if (img.data[(y * img.width + x) * 4 + 3] > alphaThreshold) {
if (x < minX) minX = x;
if (y < minY) minY = y;
if (x > maxX) maxX = x;
if (y > maxY) maxY = y;
}
}
}
if (maxX < 0) return null;
return { x: minX, y: minY, w: maxX - minX + 1, h: maxY - minY + 1 };
}
/** Обрезка прозрачных полей по порогу альфы. Полностью пустое → 1×1 прозрачный пиксель. */
export function trimToContent(img: PixelImage, alphaThreshold = 0): PixelImage {
const b = contentBounds(img, alphaThreshold);
if (!b) return crop(img, 0, 0, 1, 1);
return crop(img, b.x, b.y, b.w, b.h);
}
/** Приводит холст к точному размеру: лишнее обрезается, недостающее дополняется прозрачным. */
export function changeCanvasSize(
img: PixelImage,
width: number,
height: number,
anchor: Anchor9
): PixelImage {
const out = createPixelImage(width, height);
const pasteX = anchor.endsWith('-left') ? 0 : anchor.endsWith('-right') ? width - img.width : Math.floor((width - img.width) / 2);
const pasteY = anchor.startsWith('top-') ? 0 : anchor.startsWith('bottom-') ? height - img.height : Math.floor((height - img.height) / 2);
for (let y = 0; y < height; y++) {
const sy = y - pasteY;
if (sy < 0 || sy >= img.height) continue;
for (let x = 0; x < width; x++) {
const sx = x - pasteX;
if (sx < 0 || sx >= img.width) continue;
const di = (y * width + x) * 4;
const si = (sy * img.width + sx) * 4;
out.data[di] = img.data[si];
out.data[di + 1] = img.data[si + 1];
out.data[di + 2] = img.data[si + 2];
out.data[di + 3] = img.data[si + 3];
}
}
return out;
}
/** Центральный кроп до соотношения сторон (ratio ≥ 1 = широкое). */
export function cropToRatio(img: PixelImage, ratio: number): PixelImage {
const current = img.width / img.height;
if (current > ratio) {
const w = Math.max(1, Math.round(img.height * ratio));
return crop(img, Math.floor((img.width - w) / 2), 0, w, img.height);
}
if (current < ratio) {
const h = Math.max(1, Math.round(img.width / ratio));
return crop(img, 0, Math.floor((img.height - h) / 2), img.width, h);
}
return clonePixelImage(img);
}
/** Вписывает в соотношение сторон, добавляя прозрачные поля. */
export function padToRatio(img: PixelImage, ratio: number): PixelImage {
const current = img.width / img.height;
let w = img.width;
let h = img.height;
if (current > ratio) h = Math.round(w / ratio);
else if (current < ratio) w = Math.round(h * ratio);
w = Math.max(1, w);
h = Math.max(1, h);
return changeCanvasSize(img, w, h, 'center');
}
/** Разворачивает изображение на 90°, если его ориентация не совпадает с целевой. Квадрат не трогает. */
export function forceOrientation(img: PixelImage, target: 'portrait' | 'landscape'): PixelImage {
const current = img.width > img.height ? 'landscape' : img.width < img.height ? 'portrait' : 'square';
if (current === target || current === 'square') return clonePixelImage(img);
return rotate90(img, 1);
}
/**
* Симметричная копия: к выбранной стороне оригинала добавляется его зеркало.
* axis vertical — зеркалим по вертикальной линии (ширина ×2), horizontal — по горизонтальной (высота ×2).
*/
export function symmetricCopy(
img: PixelImage,
axis: 'vertical' | 'horizontal',
keepSide: 'left' | 'right' | 'top' | 'bottom'
): PixelImage {
if (axis === 'vertical') {
const out = createPixelImage(img.width * 2, img.height);
for (let y = 0; y < img.height; y++) {
for (let x = 0; x < img.width; x++) {
const srcX = keepSide === 'left' ? x : img.width - 1 - x;
const di = (y * out.width + x) * 4;
const si = (y * img.width + srcX) * 4;
out.data[di] = img.data[si];
out.data[di + 1] = img.data[si + 1];
out.data[di + 2] = img.data[si + 2];
out.data[di + 3] = img.data[si + 3];
const mx = img.width * 2 - 1 - x;
const md = (y * out.width + mx) * 4;
out.data[md] = img.data[si];
out.data[md + 1] = img.data[si + 1];
out.data[md + 2] = img.data[si + 2];
out.data[md + 3] = img.data[si + 3];
}
}
return out;
}
const out = createPixelImage(img.width, img.height * 2);
for (let y = 0; y < img.height; y++) {
const srcY = keepSide === 'top' ? y : img.height - 1 - y;
for (let x = 0; x < img.width; x++) {
const si = (srcY * img.width + x) * 4;
const dTop = (y * out.width + x) * 4;
out.data[dTop] = img.data[si];
out.data[dTop + 1] = img.data[si + 1];
out.data[dTop + 2] = img.data[si + 2];
out.data[dTop + 3] = img.data[si + 3];
const my = img.height * 2 - 1 - y;
const md = (my * out.width + x) * 4;
out.data[md] = img.data[si];
out.data[md + 1] = img.data[si + 1];
out.data[md + 2] = img.data[si + 2];
out.data[md + 3] = img.data[si + 3];
}
}
return out;
}
+63
View File
@@ -660,6 +660,69 @@ export const ru: Dict = {
offsetY: 'Смещение Y, %' offsetY: 'Смещение Y, %'
} }
}, },
'trim-empty-space-png': {
title: 'Обрезать пустые поля PNG',
description:
'Обрезает прозрачные рамки вокруг содержимого. Пиксели с альфой выше порога считаются содержимым.',
params: { threshold: 'Порог альфы' }
},
'change-canvas-size-png': {
title: 'Изменить размер холста PNG',
description:
'Задаёт точный размер холста: лишнее обрезается, недостающее дополняется прозрачностью. Якорь выбирает, какая часть изображения остаётся.',
params: { width: 'Ширина', height: 'Высота', anchor: 'Якорь' },
options: {
anchor: {
'top-left': 'Сверху слева',
'top-center': 'Сверху по центру',
'top-right': 'Сверху справа',
'middle-left': 'По центру слева',
center: 'По центру',
'middle-right': 'По центру справа',
'bottom-left': 'Снизу слева',
'bottom-center': 'Снизу по центру',
'bottom-right': 'Снизу справа'
}
}
},
'change-aspect-ratio-png': {
title: 'Изменить соотношение сторон PNG',
description:
'Вписывает изображение в целевое соотношение сторон: обрезать центр до заполнения или дополнить прозрачностью.',
params: { ratio: 'Целевое отношение', mode: 'Режим' },
options: {
ratio: {
'1:1': '1:1',
'4:3': '4:3',
'3:4': '3:4',
'3:2': '3:2',
'2:3': '2:3',
'16:9': '16:9',
'9:16': '9:16'
},
mode: { crop: 'Обрезать до заполнения', pad: 'Дополнить до вписывания' }
}
},
'swap-orientation-png': {
title: 'Поменять ориентацию PNG',
description:
'Поворачивает изображение на 90°, если ориентация отличается от целевой — ландшафт становится портретом и наоборот. Квадрат не трогается.',
params: { target: 'Целевая ориентация' },
options: { target: { portrait: 'Портрет', landscape: 'Ландшафт' } }
},
'symmetric-copy-png': {
title: 'Симметричная копия PNG',
description:
'Удваивает холст, зеркалируя сохранённую половину на пустую — мгновенный симметричный узор.',
params: { axis: 'Линия зеркала', keepSide: 'Какая сторона остаётся' },
options: {
axis: {
vertical: 'Вертикальная (ширина ×2)',
horizontal: 'Горизонтальная (высота ×2)'
},
keepSide: { left: 'Левая', right: 'Правая', top: 'Верхняя', bottom: 'Нижняя' }
}
},
'show-transparent-png': { 'show-transparent-png': {
title: 'Показать прозрачные области PNG', title: 'Показать прозрачные области PNG',
description: description:
+154 -2
View File
@@ -91,7 +91,16 @@ import {
renderShape, renderShape,
starTest, starTest,
wavyTest wavyTest
} from './core/shapes'; } from './core/shapes';
import {
changeCanvasSize,
cropToRatio,
forceOrientation,
padToRatio,
symmetricCopy,
trimToContent,
type Anchor9
} from './core/geometry';
import { hexToPixels, pixelsToHex } from './core/text'; import { hexToPixels, pixelsToHex } from './core/text';
import { clonePixelImage, type PixelImage } from './core/types'; import { clonePixelImage, type PixelImage } from './core/types';
@@ -1726,7 +1735,150 @@ export const TOOLS: ToolEntry[] = [
], ],
run: (img, p) => zoomImage(img, num(p, 'scale')) run: (img, p) => zoomImage(img, num(p, 'scale'))
}, },
{ {
id: 'trim-empty-space-png',
title: 'Trim Empty Space PNG',
description:
'Crops transparent borders around the content. Pixels with alpha above the threshold count as content.',
category: 'geometry',
params: [
{ id: 'threshold', label: 'Alpha threshold', type: 'slider', min: 0, max: 254, step: 1, default: 0 }
],
run: (img, p) => trimToContent(img, num(p, 'threshold'))
},
{
id: 'change-canvas-size-png',
title: 'Change Canvas Size PNG',
description:
'Sets the exact canvas size: overflow is cropped, missing space is filled with transparency. Anchor picks which part of the image stays.',
category: 'geometry',
params: [
{ id: 'width', label: 'Width', type: 'number', min: 1, max: 20000, step: 1, default: 800 },
{ id: 'height', label: 'Height', type: 'number', min: 1, max: 20000, step: 1, default: 600 },
{
id: 'anchor',
label: 'Anchor',
type: 'select',
default: 'center',
options: [
{ value: 'top-left', label: 'Top left' },
{ value: 'top-center', label: 'Top center' },
{ value: 'top-right', label: 'Top right' },
{ value: 'middle-left', label: 'Middle left' },
{ value: 'center', label: 'Center' },
{ value: 'middle-right', label: 'Middle right' },
{ value: 'bottom-left', label: 'Bottom left' },
{ value: 'bottom-center', label: 'Bottom center' },
{ value: 'bottom-right', label: 'Bottom right' }
]
}
],
run: (img, p) =>
changeCanvasSize(
img,
Math.trunc(num(p, 'width')),
Math.trunc(num(p, 'height')),
str(p, 'anchor') as Anchor9
)
},
{
id: 'change-aspect-ratio-png',
title: 'Change Aspect Ratio PNG',
description:
'Fits the image into a target aspect ratio: crop the center to fill, or pad with transparency.',
category: 'geometry',
params: [
{
id: 'ratio',
label: 'Target ratio',
type: 'select',
default: '1:1',
options: [
{ value: '1:1', label: '1:1' },
{ value: '4:3', label: '4:3' },
{ value: '3:4', label: '3:4' },
{ value: '3:2', label: '3:2' },
{ value: '2:3', label: '2:3' },
{ value: '16:9', label: '16:9' },
{ value: '9:16', label: '9:16' }
]
},
{
id: 'mode',
label: 'Mode',
type: 'select',
default: 'crop',
options: [
{ value: 'crop', label: 'Crop to fill' },
{ value: 'pad', label: 'Pad to fit' }
]
}
],
run: (img, p) => {
const [rw, rh] = str(p, 'ratio').split(':').map(Number);
const ratio = rw / rh;
return str(p, 'mode') === 'pad' ? padToRatio(img, ratio) : cropToRatio(img, ratio);
}
},
{
id: 'swap-orientation-png',
title: 'Swap Orientation PNG',
description:
'Rotates the image by 90° when its orientation differs from the target — landscape becomes portrait and back. Square images are untouched.',
category: 'geometry',
params: [
{
id: 'target',
label: 'Target orientation',
type: 'select',
default: 'portrait',
options: [
{ value: 'portrait', label: 'Portrait' },
{ value: 'landscape', label: 'Landscape' }
]
}
],
run: (img, p) =>
forceOrientation(img, str(p, 'target') === 'landscape' ? 'landscape' : 'portrait')
},
{
id: 'symmetric-copy-png',
title: 'Symmetric Copy PNG',
description:
'Doubles the canvas by mirroring the kept side onto the empty half — instant symmetric pattern.',
category: 'geometry',
params: [
{
id: 'axis',
label: 'Mirror line',
type: 'select',
default: 'vertical',
options: [
{ value: 'vertical', label: 'Vertical (double width)' },
{ value: 'horizontal', label: 'Horizontal (double height)' }
]
},
{
id: 'keepSide',
label: 'Keep side',
type: 'select',
default: 'left',
options: [
{ value: 'left', label: 'Left' },
{ value: 'right', label: 'Right' },
{ value: 'top', label: 'Top' },
{ value: 'bottom', label: 'Bottom' }
]
}
],
run: (img, p) =>
symmetricCopy(
img,
str(p, 'axis') === 'horizontal' ? 'horizontal' : 'vertical',
str(p, 'keepSide') as 'left' | 'right' | 'top' | 'bottom'
)
},
{
id: 'shift-png', id: 'shift-png',
title: 'Shift PNG', title: 'Shift PNG',
description: 'Moves content by the given X and Y offset.', description: 'Moves content by the given X and Y offset.',
+5
View File
@@ -116,6 +116,11 @@ export const TOOL_ICONS: Record<string, typeof AppWindow> = {
'dark-pixel-mask-png': Moon, 'dark-pixel-mask-png': Moon,
'unique-color-mask-png': Dices, 'unique-color-mask-png': Dices,
'extract-color-from-png': Focus, 'extract-color-from-png': Focus,
'trim-empty-space-png': Crop,
'change-canvas-size-png': Frame,
'change-aspect-ratio-png': Scaling,
'swap-orientation-png': RotateCw,
'symmetric-copy-png': FlipHorizontal2,
'circle-mask-png': Circle, 'circle-mask-png': Circle,
'square-mask-png': Square, 'square-mask-png': Square,
'star-mask-png': Star, 'star-mask-png': Star,