feat: add shape masks

This commit is contained in:
2026-08-26 11:45:14 +05:00
parent 553601acc1
commit d65adcd16c
6 changed files with 290 additions and 4 deletions
+55
View File
@@ -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);
});
});
+80
View File
@@ -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;
}