From 84cc56cc6cf6627382d4f76f464c787a90181308 Mon Sep 17 00:00:00 2001 From: Ku6epXBOCTuK Date: Wed, 26 Aug 2026 12:31:44 +0500 Subject: [PATCH] feat: add geometry masks --- docs/plan-gap-waves.md | 2 +- docs/tools-map.md | 25 ++--- web/src/lib/core/gap-geometry.test.ts | 129 +++++++++++++++++++++ web/src/lib/core/geometry.ts | 156 +++++++++++++++++++++++++- web/src/lib/i18n/ru.ts | 63 +++++++++++ web/src/lib/registry.ts | 156 +++++++++++++++++++++++++- web/src/lib/tools/tool-icons.ts | 5 + 7 files changed, 517 insertions(+), 19 deletions(-) create mode 100644 web/src/lib/core/gap-geometry.test.ts diff --git a/docs/plan-gap-waves.md b/docs/plan-gap-waves.md index ca951c8..cd89a91 100644 --- a/docs/plan-gap-waves.md +++ b/docs/plan-gap-waves.md @@ -32,7 +32,7 @@ Ядро: SDF фигуры (круг/квадрат/звезда/волна) → альфа-маска с fit-режимами. Состав: circle-mask, square-mask, star-mask, wavy-mask. -### W5. Геометрия-добивки — 5 инструментов, S +### W5. Геометрия-добивки — ВЫПОЛНЕНА (5 инструментов) Ядро: bbox по альфе (trim) переиспользуется тремя инструментами источника. Состав: trim-empty-space (закрывает remove border/space), change-canvas-size, change-aspect-ratio, landscape↔portrait, symmetric-copy. diff --git a/docs/tools-map.md b/docs/tools-map.md index aef4eaf..6cacb1d 100644 --- a/docs/tools-map.md +++ b/docs/tools-map.md @@ -37,6 +37,11 @@ - harden-alpha-png — threshold - despeckle-alpha-png / close-holes-png — radius - 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 - fit-on-background-png — width, height, transparent, color - 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, мультифайловый вывод → пока идея) -### Фигурные маски - -- circle-mask — diameter/fit, позиция -- square-mask — side, fit -- star-mask — rays, innerRadius, rotation -- wavy-mask — amplitude, frequency, направление края - ### Края и силуэт - feather-edges — radius (размытие только альфы) @@ -187,14 +190,6 @@ - gif-to-frames — MEDIUM (мультифайловый вывод → идея) - 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 — пипетка уже есть в превью; отдельная страница не планируется (покрыто) diff --git a/web/src/lib/core/gap-geometry.test.ts b/web/src/lib/core/gap-geometry.test.ts new file mode 100644 index 0000000..e23c7f8 --- /dev/null +++ b/web/src/lib/core/gap-geometry.test.ts @@ -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 + }); +}); diff --git a/web/src/lib/core/geometry.ts b/web/src/lib/core/geometry.ts index 850b93b..1430ec8 100644 --- a/web/src/lib/core/geometry.ts +++ b/web/src/lib/core/geometry.ts @@ -1,4 +1,4 @@ -import { parseHex } from './alpha'; +import { parseHex } from './alpha'; import { ToolError } from './errors'; import { clonePixelImage, createPixelImage, type PixelImage } from './types'; @@ -213,3 +213,157 @@ export function sampleBilinear( function clampInt(value: number, min: number, max: number): number { 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; +} \ No newline at end of file diff --git a/web/src/lib/i18n/ru.ts b/web/src/lib/i18n/ru.ts index 920f32e..60b646c 100644 --- a/web/src/lib/i18n/ru.ts +++ b/web/src/lib/i18n/ru.ts @@ -660,6 +660,69 @@ export const ru: Dict = { 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': { title: 'Показать прозрачные области PNG', description: diff --git a/web/src/lib/registry.ts b/web/src/lib/registry.ts index 553018f..bed2866 100644 --- a/web/src/lib/registry.ts +++ b/web/src/lib/registry.ts @@ -91,7 +91,16 @@ import { renderShape, starTest, 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 { clonePixelImage, type PixelImage } from './core/types'; @@ -1726,7 +1735,150 @@ export const TOOLS: ToolEntry[] = [ ], 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', title: 'Shift PNG', description: 'Moves content by the given X and Y offset.', diff --git a/web/src/lib/tools/tool-icons.ts b/web/src/lib/tools/tool-icons.ts index 20d61f8..f3ad56c 100644 --- a/web/src/lib/tools/tool-icons.ts +++ b/web/src/lib/tools/tool-icons.ts @@ -116,6 +116,11 @@ export const TOOL_ICONS: Record = { 'dark-pixel-mask-png': Moon, 'unique-color-mask-png': Dices, '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, 'square-mask-png': Square, 'star-mask-png': Star,