feat: add shape masks
This commit is contained in:
@@ -27,7 +27,7 @@
|
|||||||
Ядро: предикат над пикселем → бинарная маска (с инверсией и подсветкой цветом).
|
Ядро: предикат над пикселем → бинарная маска (с инверсией и подсветкой цветом).
|
||||||
Состав: show-transparent, show-grayscale, show-color, light-mask, dark-mask, unique-color-mask, extract-by-color.
|
Состав: show-transparent, show-grayscale, show-color, light-mask, dark-mask, unique-color-mask, extract-by-color.
|
||||||
|
|
||||||
### W4. Фигурные маски — 4 инструмента, S
|
### W4. Фигурные маски — ВЫПОЛНЕНА (4 инструмента)
|
||||||
|
|
||||||
Ядро: SDF фигуры (круг/квадрат/звезда/волна) → альфа-маска с fit-режимами.
|
Ядро: SDF фигуры (круг/квадрат/звезда/волна) → альфа-маска с fit-режимами.
|
||||||
Состав: circle-mask, square-mask, star-mask, wavy-mask.
|
Состав: circle-mask, square-mask, star-mask, wavy-mask.
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -613,6 +613,53 @@ export const ru: Dict = {
|
|||||||
display: { gray: 'Градациями серого', color: 'Пространство как RGB' }
|
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': {
|
'show-transparent-png': {
|
||||||
title: 'Показать прозрачные области PNG',
|
title: 'Показать прозрачные области PNG',
|
||||||
description:
|
description:
|
||||||
|
|||||||
+99
-2
@@ -84,7 +84,14 @@ import {
|
|||||||
rarityPredicate,
|
rarityPredicate,
|
||||||
renderPredicateMask
|
renderPredicateMask
|
||||||
} from './core/masks';
|
} 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 { hexToPixels, pixelsToHex } from './core/text';
|
||||||
import { clonePixelImage, type PixelImage } from './core/types';
|
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'))
|
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',
|
id: 'center-by-alpha-png',
|
||||||
title: 'Center PNG by content',
|
title: 'Center PNG by content',
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import {
|
import {
|
||||||
AppWindow,
|
AppWindow,
|
||||||
Binary,
|
Binary,
|
||||||
Blend,
|
Blend,
|
||||||
CalendarDays,
|
CalendarDays,
|
||||||
ClipboardPaste,
|
ClipboardPaste,
|
||||||
|
Circle,
|
||||||
Contrast,
|
Contrast,
|
||||||
Crop,
|
Crop,
|
||||||
Crosshair,
|
Crosshair,
|
||||||
@@ -39,8 +40,10 @@ import {
|
|||||||
SearchCheck,
|
SearchCheck,
|
||||||
Square,
|
Square,
|
||||||
Stamp,
|
Stamp,
|
||||||
|
Star,
|
||||||
Sun,
|
Sun,
|
||||||
Type,
|
Type,
|
||||||
|
WavesHorizontal,
|
||||||
ZoomIn,
|
ZoomIn,
|
||||||
ZoomOut
|
ZoomOut
|
||||||
} from '@lucide/svelte';
|
} from '@lucide/svelte';
|
||||||
@@ -113,6 +116,10 @@ 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,
|
||||||
|
'circle-mask-png': Circle,
|
||||||
|
'square-mask-png': Square,
|
||||||
|
'star-mask-png': Star,
|
||||||
|
'wavy-mask-png': WavesHorizontal,
|
||||||
'watermark-tile-png': Stamp,
|
'watermark-tile-png': Stamp,
|
||||||
'watermark-image-png': FileImage,
|
'watermark-image-png': FileImage,
|
||||||
'color-wheel-png': Rainbow,
|
'color-wheel-png': Rainbow,
|
||||||
|
|||||||
Reference in New Issue
Block a user