diff --git a/docs/plan-composite-params.md b/docs/plan-composite-params.md index 0f75e17..a00e07c 100644 --- a/docs/plan-composite-params.md +++ b/docs/plan-composite-params.md @@ -7,12 +7,13 @@ > random-noise, linear-gradient, color-spectrum, random-colors, draw-grid, > placeholder, fit-on-background, change-canvas-size, resize, crop); **составной > тип `color-pair` полностью переведён** (4 инструмента: blend-two, -> step-colors, linear-gradient, two-colors); preview +> step-colors, linear-gradient, two-colors); **составной тип `offset` +> полностью переведён** (4 инструмента: circle-mask, square-mask, +> star-mask, wavy-mask); preview > научен применять **генераторы** (`executeGenerate`, кнопка Generate) и > рендерить все kinds схемы (slider/number/color/select/checkbox/dimension/ -> color-pair). -> Следующее: `offset` (пилот circle-mask-png → square-mask → star-mask → -> wavy-mask). +> color-pair/offset). +> Следующее: `position9` (add-text → date-stamp → watermark-image). > > Ключевые файлы нового registry: `web/src/lib/registry-new/{types,index,*}.ts` > (по файлу на категорию: geometry/alpha/convert/analyze/filters/color/generate) @@ -355,8 +356,12 @@ Typed field builders + `Field` + `toolSchema

()` + `ToolSchema

` — Составной тип во всех видах: вложенный объект `pair: { from, to }` + `field.colorPair`, виджет `kit/fields/schema/ColorPairControl.svelte`, kind `color-pair` в схеме (default/sanitize). Тесты: +4 (596 passed). -26. `offset` — пилот (circle-mask-png), затем square-mask → star-mask → - wavy-mask +26. `offset` — **все 4 инструмента переведены** ✔ (`circle-mask-png`, + `square-mask-png`, `star-mask-png`, `wavy-mask-png` — run в + `registry-new/alpha.ts`). Составной тип во всех видах: вложенный объект + `offset: { x, y }` + `field.offset`, виджет + `kit/fields/schema/OffsetControl.svelte`, kind `offset` в схеме + (default/sanitize). Тесты: +4 (600 passed). 27. `position9` — перевести add-text → date-stamp → watermark-image 28. `font-style` — перевести text-to-png → add-text (если ещё не) → date-stamp diff --git a/web/src/lib/components/kit/SchemaFields.svelte b/web/src/lib/components/kit/SchemaFields.svelte index 64370ec..0fe865d 100644 --- a/web/src/lib/components/kit/SchemaFields.svelte +++ b/web/src/lib/components/kit/SchemaFields.svelte @@ -13,6 +13,7 @@ import RangeControl from "./fields/schema/RangeControl.svelte"; import SelectControl from "./fields/schema/SelectControl.svelte"; import TextControl from "./fields/schema/TextControl.svelte"; + import OffsetControl from "./fields/schema/OffsetControl.svelte"; interface FieldControlProps { label: string; @@ -30,6 +31,7 @@ checkbox: CheckboxControl, text: TextControl, dimension: DimensionField, + offset: OffsetControl, }; interface Props { diff --git a/web/src/lib/components/kit/fields/schema/OffsetControl.svelte b/web/src/lib/components/kit/fields/schema/OffsetControl.svelte new file mode 100644 index 0000000..765c46d --- /dev/null +++ b/web/src/lib/components/kit/fields/schema/OffsetControl.svelte @@ -0,0 +1,87 @@ + + +

+ {label} +
+ + +
+
+ + diff --git a/web/src/lib/registry-new/alpha.ts b/web/src/lib/registry-new/alpha.ts index ebc9b79..650c312 100644 --- a/web/src/lib/registry-new/alpha.ts +++ b/web/src/lib/registry-new/alpha.ts @@ -1,7 +1,15 @@ import type { ToolEntry } from "./types"; import { field, toolSchema } from "../registry-schema"; +import type { Offset } from "../registry-schema"; import { colorMask, removeColorToAlpha } from "../core/alpha"; import { contourImage, strokeImage } from "../core/morphology"; +import { + boxTest, + circleTest, + renderShape, + starTest, + wavyTest, +} from "../core/shapes"; interface AddStrokeParams { color: string; @@ -64,4 +72,130 @@ const removeColor: ToolEntry = { preview: (img, p) => colorMask(img, p.targetColor, p.tolerance), }; -export const alphaEntries = [addStroke, findContour, removeColor]; +interface CircleMaskParams { + size: number; + offset: Offset; +} + +const circleMaskSchema = toolSchema({ + size: field.slider({ min: 20, max: 100, step: 1, default: 100 }), + offset: field.offset({ min: -50, max: 50, x: 0, y: 0 }), +}); + +const circleMask: ToolEntry = { + 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", + schema: circleMaskSchema, + run: (img, p) => + renderShape( + img, + circleTest(p.size / 200), + p.offset.x / 100, + p.offset.y / 100, + ), +}; + +interface SquareMaskParams { + widthPct: number; + heightPct: number; + offset: Offset; +} + +const squareMaskSchema = toolSchema({ + widthPct: field.slider({ min: 10, max: 100, step: 1, default: 100 }), + heightPct: field.slider({ min: 10, max: 100, step: 1, default: 100 }), + offset: field.offset({ min: -50, max: 50, x: 0, y: 0 }), +}); + +const squareMask: ToolEntry = { + 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", + schema: squareMaskSchema, + run: (img, p) => + renderShape( + img, + boxTest(p.widthPct / 200, p.heightPct / 200), + p.offset.x / 100, + p.offset.y / 100, + ), +}; + +interface StarMaskParams { + points: number; + innerRadius: number; + size: number; + rotation: number; + offset: Offset; +} + +const starMaskSchema = toolSchema({ + points: field.slider({ min: 3, max: 12, step: 1, default: 5 }), + innerRadius: field.slider({ min: 10, max: 90, step: 1, default: 45 }), + size: field.slider({ min: 20, max: 100, step: 1, default: 100 }), + rotation: field.slider({ min: -180, max: 180, step: 1, default: 0 }), + offset: field.offset({ min: -50, max: 50, x: 0, y: 0 }), +}); + +const starMask: ToolEntry = { + 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", + schema: starMaskSchema, + run: (img, p) => + renderShape( + img, + starTest(p.points, p.innerRadius / 100, p.size / 200, p.rotation), + p.offset.x / 100, + p.offset.y / 100, + ), +}; + +interface WavyMaskParams { + size: number; + amplitude: number; + waves: number; + phase: number; + offset: Offset; +} + +const wavyMaskSchema = toolSchema({ + size: field.slider({ min: 20, max: 100, step: 1, default: 90 }), + amplitude: field.slider({ min: 2, max: 30, step: 1, default: 8 }), + waves: field.slider({ min: 3, max: 24, step: 1, default: 8 }), + phase: field.slider({ min: 0, max: 360, step: 1, default: 0 }), + offset: field.offset({ min: -50, max: 50, x: 0, y: 0 }), +}); + +const wavyMask: ToolEntry = { + 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", + schema: wavyMaskSchema, + run: (img, p) => + renderShape( + img, + wavyTest(p.size / 200, p.amplitude / 200, p.waves, p.phase), + p.offset.x / 100, + p.offset.y / 100, + ), +}; + +export const alphaEntries = [ + addStroke, + findContour, + removeColor, + circleMask, + squareMask, + starMask, + wavyMask, +]; diff --git a/web/src/lib/registry-new/registry-new.test.ts b/web/src/lib/registry-new/registry-new.test.ts index 7f6bc4e..e58d29d 100644 --- a/web/src/lib/registry-new/registry-new.test.ts +++ b/web/src/lib/registry-new/registry-new.test.ts @@ -244,6 +244,66 @@ describe("registry-new (переведённые инструменты)", () => expect(s.pair).toEqual({ from: "#ffffff", to: "#00ff00" }); expect(s.threshold).toBe(100); }); + + it("circle-mask: срезает углы и сохраняет центр", async () => { + const tool = TOOLS.find((t) => t.id === "circle-mask-png")!; + const img = await tool.run!( + solid(100, 100), + sanitizeSchemaParams(tool.schema, { + size: 100, + offset: { x: 0, y: 0 }, + }), + ); + // Внешний угол (0,0) — вне круга радиуса 50 → прозрачен + expect(img.data[3]).toBe(0); + // Центр (50,50) — внутри + expect(img.data[(50 * img.width + 50) * 4 + 3]).toBe(255); + }); + + it("circle-mask: offset сдвигает фигуру", async () => { + const tool = TOOLS.find((t) => t.id === "circle-mask-png")!; + const img = await tool.run!( + solid(100, 100), + sanitizeSchemaParams(tool.schema, { + size: 50, + offset: { x: 50, y: 0 }, + }), + ); + // Радиус 25, центр смещён к x=100; пиксель (75,50) внутри, (49,50) снаружи + expect(img.data[(50 * img.width + 75) * 4 + 3]).toBe(255); + expect(img.data[(50 * img.width + 49) * 4 + 3]).toBe(0); + }); + + it("star-mask: сохраняет центр, режет углы", async () => { + const tool = TOOLS.find((t) => t.id === "star-mask-png")!; + const img = await tool.run!( + solid(100, 100), + sanitizeSchemaParams(tool.schema, { + points: 5, + innerRadius: 45, + size: 100, + rotation: 0, + offset: { x: 0, y: 0 }, + }), + ); + expect(img.data[(50 * img.width + 50) * 4 + 3]).toBe(255); + expect(img.data[3]).toBe(0); + }); + + it("sanitize клампит offset к min/max и чинит мусор", () => { + const tool = TOOLS.find((t) => t.id === "wavy-mask-png")!; + const s = sanitizeSchemaParams(tool.schema, { + size: 90, + amplitude: 8, + waves: 8, + phase: 0, + offset: { x: 999, y: "abc", extra: true } as unknown as Record< + string, + unknown + >, + }); + expect(s.offset).toEqual({ x: 50, y: 0 }); + }); }); function solid(width: number, height: number) { diff --git a/web/src/lib/registry-schema.ts b/web/src/lib/registry-schema.ts index e079faf..b012749 100644 --- a/web/src/lib/registry-schema.ts +++ b/web/src/lib/registry-schema.ts @@ -76,6 +76,21 @@ export interface ColorPairSpec { to: string; } +/** Смещение фигуры/объекта по центру: x + y в процентах. */ +export interface Offset { + x: number; + y: number; +} + +export interface OffsetSpec { + kind: "offset"; + /** Общий диапазон для обеих осей (в процентах). */ + min: number; + max: number; + x: number; + y: number; +} + /** * «Объект as const» kind → спека поля. Единственный источник правды для * перечня kinds: `FieldSpecKind` = ключи map, `FieldSpec` = значение по любому @@ -91,6 +106,7 @@ export const fieldSpecs = { checkbox: {} as CheckboxSpec, dimension: {} as DimensionSpec, "color-pair": {} as ColorPairSpec, + offset: {} as OffsetSpec, } as const; export type FieldSpecKind = keyof typeof fieldSpecs; @@ -147,6 +163,14 @@ export const field = { colorPair: (s: { from: string; to: string }): Field => ({ spec: { kind: "color-pair", ...s }, }), + offset: (s: { + min: number; + max: number; + x: number; + y: number; + }): Field => ({ + spec: { kind: "offset", ...s }, + }), }; /** @@ -177,6 +201,8 @@ export function defaultSchemaParams

( out[key] = { width: spec.width, height: spec.height }; } else if (spec.kind === "color-pair") { out[key] = { from: spec.from, to: spec.to }; + } else if (spec.kind === "offset") { + out[key] = { x: spec.x, y: spec.y }; } else { out[key] = spec.default; } @@ -260,6 +286,21 @@ export function sanitizeSchemaParams

( out[key] = { from, to }; break; } + case "offset": { + const r = + typeof raw === "object" && raw !== null && "x" in raw && "y" in raw + ? (raw as Record) + : undefined; + const x = + typeof r?.x === "number" && Number.isFinite(r.x) ? r.x : spec.x; + const y = + typeof r?.y === "number" && Number.isFinite(r.y) ? r.y : spec.y; + out[key] = { + x: clamp(x, spec.min, spec.max), + y: clamp(y, spec.min, spec.max), + }; + break; + } } } return out;