From 19f2e13b8874b60bb0d34d2d0ce8a575bfd9fe5c Mon Sep 17 00:00:00 2001 From: Ku6epXBOCTuK Date: Sat, 5 Sep 2026 16:09:39 +0500 Subject: [PATCH] feat: add two colors field control --- docs/plan-composite-params.md | 18 +++- .../lib/components/kit/SchemaFields.svelte | 2 + .../kit/fields/schema/ColorPairControl.svelte | 97 +++++++++++++++++++ web/src/lib/registry-new/color.ts | 25 ++++- web/src/lib/registry-new/generate.ts | 77 +++++++++++++-- web/src/lib/registry-new/registry-new.test.ts | 66 ++++++++++++- web/src/lib/registry-schema.ts | 39 +++++++- 7 files changed, 307 insertions(+), 17 deletions(-) create mode 100644 web/src/lib/components/kit/fields/schema/ColorPairControl.svelte diff --git a/docs/plan-composite-params.md b/docs/plan-composite-params.md index 1e35930..0f75e17 100644 --- a/docs/plan-composite-params.md +++ b/docs/plan-composite-params.md @@ -5,10 +5,14 @@ > все одиночные инструменты (18 шт). Фаза 3 — **составной тип `dimension` > полностью переведён** (12 инструментов: create-empty, single-color, > random-noise, linear-gradient, color-spectrum, random-colors, draw-grid, -> placeholder, fit-on-background, change-canvas-size, resize, crop); preview +> placeholder, fit-on-background, change-canvas-size, resize, crop); **составной +> тип `color-pair` полностью переведён** (4 инструмента: blend-two, +> step-colors, linear-gradient, two-colors); preview > научен применять **генераторы** (`executeGenerate`, кнопка Generate) и -> рендерить все kinds схемы (slider/number/color/select/checkbox/dimension). -> Следующее: `color-pair` (пилот blend-two-png → two-colors → step-colors). +> рендерить все kinds схемы (slider/number/color/select/checkbox/dimension/ +> color-pair). +> Следующее: `offset` (пилот circle-mask-png → square-mask → star-mask → +> wavy-mask). > > Ключевые файлы нового registry: `web/src/lib/registry-new/{types,index,*}.ts` > (по файлу на категорию: geometry/alpha/convert/analyze/filters/color/generate) @@ -345,8 +349,12 @@ Typed field builders + `Field` + `toolSchema

()` + `ToolSchema

` — Preview: генераторы применяются через `executeGenerate` (кнопка Generate), `SchemaFields` рендерит все kinds схемы (slider/number/color/select/ checkbox/dimension). Тесты: 592 passed. -25. `color-pair` — пилот (blend-two-png), затем two-colors → step-colors → - linear-gradient (уже переведён в dimension, здесь добавляется к нему) +25. `color-pair` — **все 4 инструмента переведены** ✔ (`blend-two-png`, + `step-colors-png`, `linear-gradient-png` — генераторы в + `registry-new/generate.ts`, `two-colors-png` — run в `registry-new/color.ts`). + Составной тип во всех видах: вложенный объект `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 27. `position9` — перевести add-text → date-stamp → watermark-image diff --git a/web/src/lib/components/kit/SchemaFields.svelte b/web/src/lib/components/kit/SchemaFields.svelte index 9597fce..a3ec54a 100644 --- a/web/src/lib/components/kit/SchemaFields.svelte +++ b/web/src/lib/components/kit/SchemaFields.svelte @@ -3,6 +3,7 @@ import type { ToolSchema } from "$lib/registry-schema"; import CheckboxControl from "./fields/schema/CheckboxControl.svelte"; import ColorControl from "./fields/schema/ColorControl.svelte"; + import ColorPairControl from "./fields/schema/ColorPairControl.svelte"; import RangeControl from "./fields/schema/RangeControl.svelte"; import SelectControl from "./fields/schema/SelectControl.svelte"; import TextControl from "./fields/schema/TextControl.svelte"; @@ -20,6 +21,7 @@ number: RangeControl, slider: RangeControl, color: ColorControl, + "color-pair": ColorPairControl, select: SelectControl, checkbox: CheckboxControl, text: TextControl, diff --git a/web/src/lib/components/kit/fields/schema/ColorPairControl.svelte b/web/src/lib/components/kit/fields/schema/ColorPairControl.svelte new file mode 100644 index 0000000..29e3dcd --- /dev/null +++ b/web/src/lib/components/kit/fields/schema/ColorPairControl.svelte @@ -0,0 +1,97 @@ + + +

+ {label} +
+ + +
+
+ + diff --git a/web/src/lib/registry-new/color.ts b/web/src/lib/registry-new/color.ts index 7c8b6a9..c584194 100644 --- a/web/src/lib/registry-new/color.ts +++ b/web/src/lib/registry-new/color.ts @@ -1,9 +1,29 @@ -import { gammaCorrection, temperature, tint } from "../core/color"; +import { gammaCorrection, temperature, twoColors, tint } from "../core/color"; import { parseHexList } from "../core/palette"; import { ditherImage, mapToNearest, quantizeImage } from "../core/quantize"; -import { field, toolSchema } from "../registry-schema"; +import { field, toolSchema, type ColorPair } from "../registry-schema"; import type { ToolEntry } from "./types"; +interface TwoColorsParams { + pair: ColorPair; + threshold: number; +} + +export const twoColorsSchema = toolSchema({ + pair: field.colorPair({ from: "#ffffff", to: "#000000" }), + threshold: field.slider({ min: 0, max: 100, step: 1, default: 50 }), +}); + +const twoColorsTool: ToolEntry = { + id: "two-colors-png", + title: "Two colors PNG", + description: + "Recolors the image into two chosen colors by luminance threshold.", + category: "color", + schema: twoColorsSchema, + run: (img, p) => twoColors(img, p.pair.from, p.pair.to, p.threshold), +}; + interface GammaParams { value: number; } @@ -123,6 +143,7 @@ const ditheringTool: ToolEntry = { }; export const colorEntries = [ + twoColorsTool, gammaTool, temperatureTool, tintTool, diff --git a/web/src/lib/registry-new/generate.ts b/web/src/lib/registry-new/generate.ts index f346f7e..6e8d088 100644 --- a/web/src/lib/registry-new/generate.ts +++ b/web/src/lib/registry-new/generate.ts @@ -2,8 +2,18 @@ import { renderTextToImage } from "../core/domText"; import { colorSpectrum, drawGrid, randomColorBlocks } from "../core/gen-tools"; import { gradientImage, noiseImage, solidImage } from "../core/generate"; import { changeCanvasSize } from "../core/geometry"; -import { hexToRgb } from "../core/palette"; -import { field, toolSchema, type Dimension } from "../registry-schema"; +import { + hexToRgb, + renderBlend, + renderSwatches, + stepColors, +} from "../core/palette"; +import { + field, + toolSchema, + type ColorPair, + type Dimension, +} from "../registry-schema"; import type { ToolEntry } from "./types"; function rgba(hex: string): [number, number, number, number] { @@ -89,15 +99,13 @@ const randomNoise: ToolEntry = { interface LinearGradientParams { size: Dimension; - fromColor: string; - toColor: string; + pair: ColorPair; direction: "horizontal" | "vertical"; } export const linearGradientSchema = toolSchema({ size: field.dimension({ min: 1, max: 20000, width: 800, height: 600 }), - fromColor: field.color({ default: "#000000" }), - toColor: field.color({ default: "#ffffff" }), + pair: field.colorPair({ from: "#000000", to: "#ffffff" }), direction: field.select({ default: "horizontal", options: [ @@ -117,7 +125,7 @@ const linearGradient: ToolEntry = { generate: (p) => { const w = Math.trunc(p.size.width); const h = Math.trunc(p.size.height); - return gradientImage(w, h, rgba(p.fromColor), rgba(p.toColor), p.direction); + return gradientImage(w, h, rgba(p.pair.from), rgba(p.pair.to), p.direction); }, }; @@ -263,6 +271,59 @@ const placeholder: ToolEntry = { }, }; +interface BlendTwoParams { + pair: ColorPair; + width: number; +} + +export const blendTwoSchema = toolSchema({ + pair: field.colorPair({ from: "#000000", to: "#ffffff" }), + width: field.slider({ min: 128, max: 1024, step: 16, default: 512 }), +}); + +const blendTwo: ToolEntry = { + id: "blend-two-png", + title: "Blend Two Colors PNG", + description: "A continuous horizontal gradient between two colors.", + category: "generate", + schema: blendTwoSchema, + generate: (p) => renderBlend(p.pair.from, p.pair.to, p.width), +}; + +interface StepColorsParams { + pair: ColorPair; + steps: number; + width: number; + layout: "strip" | "grid"; +} + +export const stepColorsSchema = toolSchema({ + pair: field.colorPair({ from: "#000000", to: "#ffffff" }), + steps: field.slider({ min: 2, max: 12, step: 1, default: 6 }), + width: field.slider({ min: 128, max: 1024, step: 16, default: 512 }), + layout: field.select({ + default: "grid", + options: [ + { value: "grid", label: "Grid" }, + { value: "strip", label: "Strip" }, + ], + }), +}); + +const stepColorsTool: ToolEntry = { + id: "step-colors-png", + title: "Color Steps PNG", + description: "A discrete set of evenly spaced steps between two colors.", + category: "generate", + schema: stepColorsSchema, + generate: (p) => + renderSwatches( + stepColors(p.pair.from, p.pair.to, p.steps), + p.width, + p.layout, + ), +}; + export const generateEntries = [ createEmpty, singleColor, @@ -272,4 +333,6 @@ export const generateEntries = [ randomColors, drawGridTool, placeholder, + blendTwo, + stepColorsTool, ]; diff --git a/web/src/lib/registry-new/registry-new.test.ts b/web/src/lib/registry-new/registry-new.test.ts index f53b773..7f6bc4e 100644 --- a/web/src/lib/registry-new/registry-new.test.ts +++ b/web/src/lib/registry-new/registry-new.test.ts @@ -65,8 +65,7 @@ describe("registry-new (переведённые инструменты)", () => "linear-gradient-png", { size: { width: 40, height: 30 }, - fromColor: "#000000", - toColor: "#ffffff", + pair: { from: "#000000", to: "#ffffff" }, direction: "horizontal", }, ], @@ -182,6 +181,69 @@ describe("registry-new (переведённые инструменты)", () => expect(topLeft).toBe(255); expect(center).toBe(0); }); + + it("blend-two: дефолты пары и работа генератора", async () => { + const tool = TOOLS.find((t) => t.id === "blend-two-png")!; + const d = defaultSchemaParams(tool.schema); + expect(d.pair).toEqual({ from: "#000000", to: "#ffffff" }); + const img = await tool.generate!( + sanitizeSchemaParams(tool.schema, { + pair: { from: "#ff0000", to: "#0000ff" }, + width: 128, + }), + ); + expect(img.width).toBe(128); + }); + + it("step-colors: рендерит steps полос по паре", async () => { + const tool = TOOLS.find((t) => t.id === "step-colors-png")!; + const img = await tool.generate!( + sanitizeSchemaParams(tool.schema, { + pair: { from: "#000000", to: "#ffffff" }, + steps: 4, + width: 128, + layout: "strip", + }), + ); + expect(img.width).toBe(128); + }); + + it("two-colors: перекрашивает светлые/тёмные пиксели по паре", async () => { + const tool = TOOLS.find((t) => t.id === "two-colors-png")!; + // 50×1, левая половина тёмная, правая светлая + const data = new Uint8ClampedArray(50 * 4).fill(255); + const img = { width: 50, height: 1, data }; + for (let x = 0; x < 25; x++) { + const i = x * 4; + data[i] = 0; + data[i + 1] = 0; + data[i + 2] = 0; + } + const out = await tool.run!( + img, + sanitizeSchemaParams(tool.schema, { + pair: { from: "#ffffff", to: "#ff0000" }, + threshold: 50, + }), + ); + // Светлый пиксель → from (#ffffff), тёмный → to (#ff0000) + expect(out.data[0]).toBe(255); + expect(out.data[1]).toBe(0); + expect(out.data[2]).toBe(0); + expect(out.data[100]).toBe(255); + expect(out.data[101]).toBe(255); + expect(out.data[102]).toBe(255); + }); + + it("sanitize чинит мусор в паре и клампит threshold", () => { + const tool = TOOLS.find((t) => t.id === "two-colors-png")!; + const s = sanitizeSchemaParams(tool.schema, { + pair: { from: "not-a-color", to: "#00ff00", extra: 1 }, + threshold: 999, + }); + expect(s.pair).toEqual({ from: "#ffffff", to: "#00ff00" }); + expect(s.threshold).toBe(100); + }); }); function solid(width: number, height: number) { diff --git a/web/src/lib/registry-schema.ts b/web/src/lib/registry-schema.ts index 83a2285..8e64d3d 100644 --- a/web/src/lib/registry-schema.ts +++ b/web/src/lib/registry-schema.ts @@ -64,6 +64,18 @@ export interface DimensionSpec { height: number; } +/** Пара цветов «от → к» (градиенты, сведение к двум цветам и т.п.). */ +export interface ColorPair { + from: string; + to: string; +} + +export interface ColorPairSpec { + kind: "color-pair"; + from: string; + to: string; +} + export type FieldSpec = | NumberSpec | SliderSpec @@ -71,7 +83,8 @@ export type FieldSpec = | SelectSpec | TextSpec | CheckboxSpec - | DimensionSpec; + | DimensionSpec + | ColorPairSpec; /** `Field`: runtime-спека поля + phantom-тип ожидаемого значения (number|string|boolean). */ export interface Field { @@ -120,6 +133,9 @@ export const field = { }): Field => ({ spec: { kind: "dimension", ...s }, }), + colorPair: (s: { from: string; to: string }): Field => ({ + spec: { kind: "color-pair", ...s }, + }), }; /** @@ -148,6 +164,8 @@ export function defaultSchemaParams

( const spec = schema.fields[key].spec; if (spec.kind === "dimension") { out[key] = { width: spec.width, height: spec.height }; + } else if (spec.kind === "color-pair") { + out[key] = { from: spec.from, to: spec.to }; } else { out[key] = spec.default; } @@ -212,6 +230,25 @@ export function sanitizeSchemaParams

( }; break; } + case "color-pair": { + const r = + typeof raw === "object" && + raw !== null && + "from" in raw && + "to" in raw + ? (raw as Record) + : undefined; + const from = + typeof r?.from === "string" && /^#[0-9a-f]{6}$/i.test(r.from) + ? r.from + : spec.from; + const to = + typeof r?.to === "string" && /^#[0-9a-f]{6}$/i.test(r.to) + ? r.to + : spec.to; + out[key] = { from, to }; + break; + } } } return out;