diff --git a/docs/backlog.md b/docs/backlog.md index 7867241..12e7982 100644 --- a/docs/backlog.md +++ b/docs/backlog.md @@ -65,10 +65,6 @@ другой инструмент с хардкодед настройками? - [ ] qr code to text, text to qr code - [ ] bar code to text, text to bar code -- [ ] split-into-parts-png — режим «по размеру тайла»: указываешь размер тайла - (16×16, 32×32…, список `SIZE_PRESETS`), инструмент сам считает сетку — - сценарий «режу спрайтшит, знаю размер тайла, не знаю сетку» (новый режим: - схема + core, из задачи C3) - [ ] нужен инструмент для удаления фона по выбранной точке - удалять внутри выделенной области - [ ] сделать конвертацию в смайлики\награды для твитча (6 размеров или выбор diff --git a/web/src/lib/components/schema/SchemaFields.svelte b/web/src/lib/components/schema/SchemaFields.svelte index 1b08106..bb399ba 100644 --- a/web/src/lib/components/schema/SchemaFields.svelte +++ b/web/src/lib/components/schema/SchemaFields.svelte @@ -83,6 +83,11 @@ } return spec; } + + function isHidden(spec: FieldSpec): boolean { + const rule = spec.visibleWhen; + return rule !== undefined && values[rule.field] !== rule.equals; + } {#if isEmpty} @@ -99,16 +104,19 @@ : undefined} > {#each group.fields as id (id)} - {@const Control = FIELDS[schema.fields[id].spec.kind]} - onchange(id, v, axis)} - /> + {@const spec = schema.fields[id].spec} + {@const Control = FIELDS[spec.kind]} + {#if !isHidden(spec)} + onchange(id, v, axis)} + /> + {/if} {/each} {/each} diff --git a/web/src/lib/core/geometry.test.ts b/web/src/lib/core/geometry.test.ts index aa11321..5c76ba3 100644 --- a/web/src/lib/core/geometry.test.ts +++ b/web/src/lib/core/geometry.test.ts @@ -7,6 +7,7 @@ import { resize, rotate90, splitToParts, + splitToTileSize, tile, } from "./geometry"; import { expectImageEqual, makeImage } from "./test-helpers"; @@ -322,3 +323,63 @@ describe("splitToParts", () => { expect(parts[0].height).toBe(6); }); }); + +describe("splitToTileSize", () => { + function grid(w: number, h: number): PixelImage { + const pixels: number[][] = []; + for (let y = 0; y < h; y++) { + for (let x = 0; x < w; x++) { + const v = y * w + x + 1; + pixels.push([v, v, v, 255]); + } + } + return makeImage(w, h, pixels); + } + + it("кратный размер: части ровно tileW×tileH без паддинга", () => { + const parts = splitToTileSize(grid(4, 4), 2, 2); + expect(parts).toHaveLength(4); + for (const part of parts) { + expect(part.width).toBe(2); + expect(part.height).toBe(2); + } + expectImageEqual(parts[1], [ + [3, 3, 3, 255], + [4, 4, 4, 255], + [7, 7, 7, 255], + [8, 8, 8, 255], + ]); + }); + + it("некратный размер: канвас дополняется прозрачным до кратности", () => { + const parts = splitToTileSize(grid(5, 3), 3, 2); + expect(parts).toHaveLength(4); + for (const part of parts) { + expect(part.width).toBe(3); + expect(part.height).toBe(2); + } + expectImageEqual(parts[1], [ + [4, 4, 4, 255], + [5, 5, 5, 255], + [0, 0, 0, 0], + [9, 9, 9, 255], + [10, 10, 10, 255], + [0, 0, 0, 0], + ]); + expectImageEqual(parts[2], [ + [11, 11, 11, 255], + [12, 12, 12, 255], + [13, 13, 13, 255], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + ]); + }); + + it("тайл больше картинки — одна часть с паддингом", () => { + const parts = splitToTileSize(grid(2, 2), 4, 4); + expect(parts).toHaveLength(1); + expect(parts[0].width).toBe(4); + expect(parts[0].height).toBe(4); + }); +}); diff --git a/web/src/lib/core/geometry.ts b/web/src/lib/core/geometry.ts index 6c280f2..5046faf 100644 --- a/web/src/lib/core/geometry.ts +++ b/web/src/lib/core/geometry.ts @@ -87,6 +87,31 @@ export function splitToParts( return parts; } +/** Нарезка по размеру тайла: канвас дополняется прозрачным до кратности. */ +export function splitToTileSize( + img: PixelImage, + tileW: number, + tileH: number, +): PixelImage[] { + const tw = Math.max(1, Math.trunc(tileW)); + const th = Math.max(1, Math.trunc(tileH)); + const cols = Math.max(1, Math.ceil(img.width / tw)); + const rows = Math.max(1, Math.ceil(img.height / th)); + const gridW = cols * tw; + const gridH = rows * th; + const padded = + gridW === img.width && gridH === img.height + ? img + : expandCanvas(img, 0, 0, gridW - img.width, gridH - img.height); + const parts: PixelImage[] = []; + for (let row = 0; row < rows; row++) { + for (let col = 0; col < cols; col++) { + parts.push(crop(padded, col * tw, row * th, tw, th)); + } + } + return parts; +} + export function centerByAlpha(img: PixelImage): PixelImage { let minX = img.width; let minY = img.height; diff --git a/web/src/lib/i18n/en.ts b/web/src/lib/i18n/en.ts index f81acbb..4531888 100644 --- a/web/src/lib/i18n/en.ts +++ b/web/src/lib/i18n/en.ts @@ -235,6 +235,8 @@ export const en: Dict = { colorTolerance: "Color tolerance", cols: "Cols", columns: "Columns", + splitMode: "Split by", + tileSize: "Tile size", component: "Component", contrast: "Contrast", count: "Count", @@ -323,6 +325,8 @@ export const en: Dict = { cropArea: "Crop area", fill: "Fill", grid: "Grid", + splitMode: "Split by", + tileSize: "Tile size", noise: "Noise", offset: "Offset", options: "Options", @@ -375,6 +379,11 @@ export const en: Dict = { orientationSquare: "Square — the sides are equal.", }, }, + "split-into-parts": { + options: { + mode: { grid: "Grid", tile: "Tile size" }, + }, + }, "show-transparent": { options: { mode: { binary: "Black & white mask", highlight: "Color highlight" }, diff --git a/web/src/lib/i18n/ru.ts b/web/src/lib/i18n/ru.ts index 2efbc9d..411e402 100644 --- a/web/src/lib/i18n/ru.ts +++ b/web/src/lib/i18n/ru.ts @@ -236,6 +236,8 @@ export const ru: Dict = { colorTolerance: "Допуск похожести", cols: "Колонки", columns: "Столбцы", + splitMode: "Делить по", + tileSize: "Размер тайла", component: "Компонент", contrast: "Контраст", count: "Количество", @@ -324,6 +326,8 @@ export const ru: Dict = { cropArea: "Область кадрирования", fill: "Заливка", grid: "Сетка", + splitMode: "Режим разделения", + tileSize: "Размер тайла", noise: "Шум", offset: "Смещение", options: "Параметры", @@ -994,6 +998,17 @@ export const ru: Dict = { color: "Цвет фона", }, }, + "split-into-parts": { + params: { + mode: "Режим", + columns: "Столбцов", + rows: "Строк", + tile: "Размер тайла", + }, + options: { + mode: { grid: "Сетка", tile: "Размер тайла" }, + }, + }, tile: { params: { columns: "Столбцов", rows: "Строк" }, }, diff --git a/web/src/lib/registry-schema.ts b/web/src/lib/registry-schema.ts index ad9274c..792b0e9 100644 --- a/web/src/lib/registry-schema.ts +++ b/web/src/lib/registry-schema.ts @@ -16,6 +16,8 @@ import type { Position9 } from "./core/textdraw"; */ export interface FieldSpecBase { label?: string; + /** Поле видно только когда другое поле равно значению (режимы инструмента). */ + visibleWhen?: { field: string; equals: string }; } export interface NumberSpec extends FieldSpecBase { @@ -291,6 +293,7 @@ export const field = { maxFromSource?: boolean; lockAspectWith?: string; presets?: boolean; + visibleWhen?: { field: string; equals: string }; }): Field => ({ spec: { kind: "dimension", ...s }, }), diff --git a/web/src/lib/registry/registry.test.ts b/web/src/lib/registry/registry.test.ts index d3b4663..375e509 100644 --- a/web/src/lib/registry/registry.test.ts +++ b/web/src/lib/registry/registry.test.ts @@ -760,6 +760,25 @@ describe("split-into-parts", () => { expect(files).toHaveLength(1024); }); + it("режим tile: сетка считается от размера тайла, части точные", async () => { + const tool = TOOLS.find((t) => t.id === "split-into-parts")!; + const files = asFiles( + await tool.run({ + source: solid(100, 70), + params: sanitizeSchemaParams(tool.schema, { + mode: "tile", + tile: { width: 32, height: 32 }, + }), + }), + ).files; + // 100/32 → 4 колонки, 70/32 → 3 строки + expect(files).toHaveLength(12); + for (const file of files) { + expect(file.image.width).toBe(32); + expect(file.image.height).toBe(32); + } + }); + it("выбрасывает errors.tooManyParts при переполнении", () => { const tool = TOOLS.find((t) => t.id === "split-into-parts")!; expect(() => diff --git a/web/src/lib/registry/tools/geometry.ts b/web/src/lib/registry/tools/geometry.ts index 5665792..08b9e1f 100644 --- a/web/src/lib/registry/tools/geometry.ts +++ b/web/src/lib/registry/tools/geometry.ts @@ -17,6 +17,7 @@ import { resize, rotate90, splitToParts, + splitToTileSize, symmetricCopy, tile, trimToContent, @@ -437,26 +438,58 @@ const tileTool: Tool = { const MAX_SPLIT_PARTS = 1024; interface SplitPartsParams { + mode: "grid" | "tile"; columns: number; rows: number; + tile: Dimension; } -export const splitPartsSchema = toolSchema({ - columns: field.number({ - label: "fields.columns", - min: 1, - max: 32, - step: 1, - default: 2, - }), - rows: field.number({ - label: "fields.rows", - min: 1, - max: 32, - step: 1, - default: 2, - }), -}); +export const splitPartsSchema = toolSchema( + { + mode: field.select({ + label: "fields.splitMode", + default: "grid", + options: [ + { value: "grid", label: "Grid" }, + { value: "tile", label: "Tile size" }, + ], + }), + columns: field.number({ + label: "fields.columns", + min: 1, + max: 32, + step: 1, + default: 2, + visibleWhen: { field: "mode", equals: "grid" }, + }), + rows: field.number({ + label: "fields.rows", + min: 1, + max: 32, + step: 1, + default: 2, + visibleWhen: { field: "mode", equals: "grid" }, + }), + tile: field.dimension({ + label: "fields.tileSize", + min: 1, + max: 20000, + width: 32, + height: 32, + presets: true, + visibleWhen: { field: "mode", equals: "tile" }, + }), + }, + { + layout: { + groups: [ + { title: "groups.splitMode", fields: ["mode"] }, + { title: "groups.grid", fields: ["columns", "rows"] }, + { title: "groups.tileSize", fields: ["tile"] }, + ], + }, + }, +); const splitPartsTool: Tool = { id: "split-into-parts", @@ -466,8 +499,20 @@ const splitPartsTool: Tool = { run: (ctx) => { const img = requireSource(ctx); const params = ctx.params as SplitPartsParams; - const cols = Math.max(1, Math.trunc(params.columns)); - const rowsCount = Math.max(1, Math.trunc(params.rows)); + const cols = + params.mode === "tile" + ? Math.max( + 1, + Math.ceil(img.width / Math.max(1, Math.trunc(params.tile.width))), + ) + : Math.max(1, Math.trunc(params.columns)); + const rowsCount = + params.mode === "tile" + ? Math.max( + 1, + Math.ceil(img.height / Math.max(1, Math.trunc(params.tile.height))), + ) + : Math.max(1, Math.trunc(params.rows)); const count = cols * rowsCount; if (count > MAX_SPLIT_PARTS) { throw new ToolError("errors.tooManyParts", { @@ -475,7 +520,10 @@ const splitPartsTool: Tool = { max: MAX_SPLIT_PARTS, }); } - const parts = splitToParts(img, cols, rowsCount); + const parts = + params.mode === "tile" + ? splitToTileSize(img, params.tile.width, params.tile.height) + : splitToParts(img, cols, rowsCount); const rowLen = String(rowsCount).length; const colLen = String(cols).length; const files: ToolImageFile[] = parts.map((image, index) => {