From d65adcd16cb9bbd888b356784b46489e611f6ace Mon Sep 17 00:00:00 2001 From: Ku6epXBOCTuK Date: Wed, 26 Aug 2026 09:58:00 +0500 Subject: [PATCH] feat: add shape masks --- docs/plan-gap-waves.md | 2 +- web/src/lib/core/shapes.test.ts | 55 +++++++++++++++++ web/src/lib/core/shapes.ts | 80 +++++++++++++++++++++++++ web/src/lib/i18n/ru.ts | 47 +++++++++++++++ web/src/lib/registry.ts | 101 +++++++++++++++++++++++++++++++- web/src/lib/tools/tool-icons.ts | 9 ++- 6 files changed, 290 insertions(+), 4 deletions(-) create mode 100644 web/src/lib/core/shapes.test.ts create mode 100644 web/src/lib/core/shapes.ts diff --git a/docs/plan-gap-waves.md b/docs/plan-gap-waves.md index ed5450b..ca951c8 100644 --- a/docs/plan-gap-waves.md +++ b/docs/plan-gap-waves.md @@ -27,7 +27,7 @@ Ядро: предикат над пикселем → бинарная маска (с инверсией и подсветкой цветом). Состав: show-transparent, show-grayscale, show-color, light-mask, dark-mask, unique-color-mask, extract-by-color. -### W4. Фигурные маски — 4 инструмента, S +### W4. Фигурные маски — ВЫПОЛНЕНА (4 инструмента) Ядро: SDF фигуры (круг/квадрат/звезда/волна) → альфа-маска с fit-режимами. Состав: circle-mask, square-mask, star-mask, wavy-mask. diff --git a/web/src/lib/core/shapes.test.ts b/web/src/lib/core/shapes.test.ts new file mode 100644 index 0000000..2af3eba --- /dev/null +++ b/web/src/lib/core/shapes.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from 'vitest'; +import { boxTest, circleTest, renderShape, starTest, wavyTest } from './shapes'; +import { makeImage } from './test-helpers'; + +describe('тесты фигур', () => { + it('круг: центр внутри, угол снаружи', () => { + const t = circleTest(0.5); + expect(t(0, 0)).toBe(true); + expect(t(0.49, 0)).toBe(true); + expect(t(0.51, 0)).toBe(false); + expect(t(0.4, 0.4)).toBe(false); + }); + + it('прямоугольник: полуоси независимы', () => { + const t = boxTest(0.5, 0.25); + expect(t(0.45, 0.2)).toBe(true); + expect(t(0.2, 0.3)).toBe(false); + }); + + it('звезда: луч внутри дальше впадины', () => { + const t = starTest(5, 0.5, 1, 0); + expect(t(0.9, 0)).toBe(true); // вдоль луча (θ=0) + const valleyAngle = Math.PI / 5; // середина между лучами + expect(t(Math.cos(valleyAngle) * 0.8, Math.sin(valleyAngle) * 0.8)).toBe(false); + expect(t(0.4, 0)).toBe(true); // радиус впадин 0.5 — 0.4 внутри всегда + }); + + it('волна: фаза двигает границу', () => { + const a = wavyTest(0.5, 0.1, 6, 0); + const b = wavyTest(0.5, 0.1, 6, 180); + const deg = 15; // sin(6·15°)=1 → край 0.6; при фазе 180° край 0.4 + const rad = (deg * Math.PI) / 180; + const px = 0.55; + expect(a(px * Math.cos(rad), px * Math.sin(rad))).toBe(true); // край 0.6 + expect(b(px * Math.cos(rad), px * Math.sin(rad))).toBe(false); // край 0.4 + }); +}); + +describe('renderShape', () => { + const img = makeImage(4, 2, new Array(8).fill([255, 255, 255, 255])); + + it('внутри сохраняет пиксели, снаружи альфа 0', () => { + const out = renderShape(img, boxTest(0.5, 0.5)); + let opaque = 0; + for (let i = 3; i < out.data.length; i += 4) if (out.data[i] === 255) opaque++; + expect(opaque).toBe(4); + expect(out.data[4]).toBe(255); // RGB внутри фигуры сохранён + }); + + it('смещение центра переносит маску', () => { + const shifted = renderShape(makeImage(2, 2, new Array(4).fill([255, 255, 255, 255])), circleTest(0.7), 0.5); + expect(shifted.data[3]).toBe(0); + expect(shifted.data[4 + 3]).toBe(255); + }); +}); diff --git a/web/src/lib/core/shapes.ts b/web/src/lib/core/shapes.ts new file mode 100644 index 0000000..d044554 --- /dev/null +++ b/web/src/lib/core/shapes.ts @@ -0,0 +1,80 @@ +import type { PixelImage } from './types'; +import { createPixelImage } from './types'; + +export type ShapeTest = (nx: number, ny: number) => boolean; + +/** + * Координаты теста: нормированные к половине меньшей стороны изображения, + * центр в (0,0), ось Y вниз как в пикселях. + */ + +export function circleTest(radiusFrac: number): ShapeTest { + return (nx, ny) => Math.hypot(nx, ny) <= radiusFrac; +} + +export function boxTest(halfWFrac: number, halfHFrac: number): ShapeTest { + return (nx, ny) => Math.abs(nx) <= halfWFrac && Math.abs(ny) <= halfHFrac; +} + +/** Звезда с points лучами; innerFrac — радиус впадин в долях внешнего радиуса. */ +export function starTest( + points: number, + innerFrac: number, + outerFrac: number, + rotationDeg: number +): ShapeTest { + const n = Math.max(3, Math.round(points)); + const rot = (rotationDeg * Math.PI) / 180; + return (nx, ny) => { + const theta = Math.atan2(ny, nx) - rot; + const r = Math.hypot(nx, ny); + if (r > outerFrac) return false; + const t = ((theta * n) / (2 * Math.PI)) % 1; + const tri = Math.abs(t - Math.floor(t + 0.5)) * 2; // 0 на луче, 1 во впадине + const edge = outerFrac - (outerFrac - innerFrac) * tri; + return r <= edge; + }; +} + +/** Волнистый круг: радиус модулируется синусом с частотой waves. */ +export function wavyTest( + baseFrac: number, + amplitudeFrac: number, + waves: number, + phaseDeg: number +): ShapeTest { + const phase = (phaseDeg * Math.PI) / 180; + return (nx, ny) => { + const theta = Math.atan2(ny, nx); + const r = Math.hypot(nx, ny); + const edge = baseFrac + amplitudeFrac * Math.sin(waves * theta + phase); + return r <= edge; + }; +} + +/** + * Вырезает фигуру из изображения: внутри фигуры сохраняются исходные пиксели + * (с их альфой), снаружи альфа обнуляется. Смещение задаётся в долях меньшей стороны. + */ +export function renderShape( + img: PixelImage, + test: ShapeTest, + offsetXFrac = 0, + offsetYFrac = 0 +): PixelImage { + const out = createPixelImage(img.width, img.height); + const minDim = Math.min(img.width, img.height); + const cx = img.width / 2 + offsetXFrac * minDim; + const cy = img.height / 2 + offsetYFrac * minDim; + for (let y = 0; y < img.height; y++) { + for (let x = 0; x < img.width; x++) { + const di = (y * img.width + x) * 4; + if (!test((x + 0.5 - cx) / minDim, (y + 0.5 - cy) / minDim)) continue; + out.data[di] = img.data[di]; + out.data[di + 1] = img.data[di + 1]; + out.data[di + 2] = img.data[di + 2]; + out.data[di + 3] = img.data[di + 3]; + } + } + return out; +} diff --git a/web/src/lib/i18n/ru.ts b/web/src/lib/i18n/ru.ts index 113abc1..920f32e 100644 --- a/web/src/lib/i18n/ru.ts +++ b/web/src/lib/i18n/ru.ts @@ -613,6 +613,53 @@ export const ru: Dict = { display: { gray: 'Градациями серого', color: 'Пространство как RGB' } } }, + 'circle-mask-png': { + title: 'Круглая маска PNG', + description: + 'Вырезает из изображения круг. Диаметр задаётся в процентах от меньшей стороны.', + params: { + size: 'Диаметр, % меньшей стороны', + offsetX: 'Смещение X, %', + offsetY: 'Смещение Y, %' + } + }, + 'square-mask-png': { + title: 'Прямоугольная маска PNG', + description: + 'Вырезает прямоугольник со сторонами в процентах от меньшей стороны изображения.', + params: { + widthPct: 'Ширина, % меньшей стороны', + heightPct: 'Высота, % меньшей стороны', + offsetX: 'Смещение X, %', + offsetY: 'Смещение Y, %' + } + }, + 'star-mask-png': { + title: 'Маска-звезда PNG', + description: + 'Вырезает звезду с настраиваемым числом лучей, глубиной впадин и поворотом.', + params: { + points: 'Лучи', + innerRadius: 'Радиус впадин, %', + size: 'Внешний радиус, % меньшей стороны', + rotation: 'Поворот, °', + offsetX: 'Смещение X, %', + offsetY: 'Смещение Y, %' + } + }, + 'wavy-mask-png': { + title: 'Волнистая маска PNG', + description: + 'Вырезает круг с волнистым краем: радиус модулируется синусом с заданной амплитудой и частотой.', + params: { + size: 'Базовый радиус, % меньшей стороны', + amplitude: 'Амплитуда волн, %', + waves: 'Количество волн', + phase: 'Фаза, °', + offsetX: 'Смещение X, %', + offsetY: 'Смещение Y, %' + } + }, 'show-transparent-png': { title: 'Показать прозрачные области PNG', description: diff --git a/web/src/lib/registry.ts b/web/src/lib/registry.ts index 1706c9e..553018f 100644 --- a/web/src/lib/registry.ts +++ b/web/src/lib/registry.ts @@ -84,7 +84,14 @@ import { rarityPredicate, renderPredicateMask } from './core/masks'; -import { renderSpace, SPACES, type SpaceId } from './core/channels'; +import { renderSpace, SPACES, type SpaceId } from './core/channels'; +import { + boxTest, + circleTest, + renderShape, + starTest, + wavyTest +} from './core/shapes'; import { hexToPixels, pixelsToHex } from './core/text'; import { clonePixelImage, type PixelImage } from './core/types'; @@ -741,7 +748,97 @@ export const TOOLS: ToolEntry[] = [ ], run: (img, p) => tile(img, num(p, 'columns'), num(p, 'rows')) }, - { + { + id: 'circle-mask-png', + title: 'Circle Mask PNG', + description: 'Cuts the image into a circle. Diameter is set as a share of the smaller side.', + category: 'alpha', + params: [ + { id: 'size', label: 'Diameter, % of smaller side', type: 'slider', min: 20, max: 100, step: 1, default: 100 }, + { id: 'offsetX', label: 'Offset X, %', type: 'slider', min: -50, max: 50, step: 1, default: 0 }, + { id: 'offsetY', label: 'Offset Y, %', type: 'slider', min: -50, max: 50, step: 1, default: 0 } + ], + run: (img, p) => + renderShape( + img, + circleTest(num(p, 'size') / 200), + num(p, 'offsetX') / 100, + num(p, 'offsetY') / 100 + ) + }, + { + id: 'square-mask-png', + title: 'Square Mask PNG', + description: 'Cuts the image into a rectangle with sides as a share of the smaller side.', + category: 'alpha', + params: [ + { id: 'widthPct', label: 'Width, % of smaller side', type: 'slider', min: 10, max: 100, step: 1, default: 100 }, + { id: 'heightPct', label: 'Height, % of smaller side', type: 'slider', min: 10, max: 100, step: 1, default: 100 }, + { id: 'offsetX', label: 'Offset X, %', type: 'slider', min: -50, max: 50, step: 1, default: 0 }, + { id: 'offsetY', label: 'Offset Y, %', type: 'slider', min: -50, max: 50, step: 1, default: 0 } + ], + run: (img, p) => + renderShape( + img, + boxTest(num(p, 'widthPct') / 200, num(p, 'heightPct') / 200), + num(p, 'offsetX') / 100, + num(p, 'offsetY') / 100 + ) + }, + { + id: 'star-mask-png', + title: 'Star Mask PNG', + description: 'Cuts the image into an n-pointed star with adjustable inner radius and rotation.', + category: 'alpha', + params: [ + { id: 'points', label: 'Points', type: 'slider', min: 3, max: 12, step: 1, default: 5 }, + { id: 'innerRadius', label: 'Inner radius, %', type: 'slider', min: 10, max: 90, step: 1, default: 45 }, + { id: 'size', label: 'Outer radius, % of smaller side', type: 'slider', min: 20, max: 100, step: 1, default: 100 }, + { id: 'rotation', label: 'Rotation, °', type: 'slider', min: -180, max: 180, step: 1, default: 0 }, + { id: 'offsetX', label: 'Offset X, %', type: 'slider', min: -50, max: 50, step: 1, default: 0 }, + { id: 'offsetY', label: 'Offset Y, %', type: 'slider', min: -50, max: 50, step: 1, default: 0 } + ], + run: (img, p) => + renderShape( + img, + starTest( + num(p, 'points'), + num(p, 'innerRadius') / 100, + num(p, 'size') / 200, + num(p, 'rotation') + ), + num(p, 'offsetX') / 100, + num(p, 'offsetY') / 100 + ) + }, + { + id: 'wavy-mask-png', + title: 'Wavy Mask PNG', + description: + 'Cuts the image into a wavy-edged circle: radius is modulated by a sine with chosen amplitude and frequency.', + category: 'alpha', + params: [ + { id: 'size', label: 'Base radius, % of smaller side', type: 'slider', min: 20, max: 100, step: 1, default: 90 }, + { id: 'amplitude', label: 'Wave amplitude, %', type: 'slider', min: 2, max: 30, step: 1, default: 8 }, + { id: 'waves', label: 'Waves count', type: 'slider', min: 3, max: 24, step: 1, default: 8 }, + { id: 'phase', label: 'Phase, °', type: 'slider', min: 0, max: 360, step: 1, default: 0 }, + { id: 'offsetX', label: 'Offset X, %', type: 'slider', min: -50, max: 50, step: 1, default: 0 }, + { id: 'offsetY', label: 'Offset Y, %', type: 'slider', min: -50, max: 50, step: 1, default: 0 } + ], + run: (img, p) => + renderShape( + img, + wavyTest( + num(p, 'size') / 200, + num(p, 'amplitude') / 200, + num(p, 'waves'), + num(p, 'phase') + ), + num(p, 'offsetX') / 100, + num(p, 'offsetY') / 100 + ) + }, + { id: 'center-by-alpha-png', title: 'Center PNG by content', description: diff --git a/web/src/lib/tools/tool-icons.ts b/web/src/lib/tools/tool-icons.ts index 5834e3f..20d61f8 100644 --- a/web/src/lib/tools/tool-icons.ts +++ b/web/src/lib/tools/tool-icons.ts @@ -1,9 +1,10 @@ -import { +import { AppWindow, Binary, Blend, CalendarDays, ClipboardPaste, + Circle, Contrast, Crop, Crosshair, @@ -39,8 +40,10 @@ import { SearchCheck, Square, Stamp, + Star, Sun, Type, + WavesHorizontal, ZoomIn, ZoomOut } from '@lucide/svelte'; @@ -113,6 +116,10 @@ export const TOOL_ICONS: Record = { 'dark-pixel-mask-png': Moon, 'unique-color-mask-png': Dices, 'extract-color-from-png': Focus, + 'circle-mask-png': Circle, + 'square-mask-png': Square, + 'star-mask-png': Star, + 'wavy-mask-png': WavesHorizontal, 'watermark-tile-png': Stamp, 'watermark-image-png': FileImage, 'color-wheel-png': Rainbow,