feat: add split to parts by tile size

This commit is contained in:
2026-10-07 14:11:32 +05:00
parent 557c74b26e
commit 32cb369ca9
9 changed files with 217 additions and 33 deletions
-4
View File
@@ -65,10 +65,6 @@
другой инструмент с хардкодед настройками? другой инструмент с хардкодед настройками?
- [ ] qr code to text, text to qr code - [ ] qr code to text, text to qr code
- [ ] bar code to text, text to bar code - [ ] bar code to text, text to bar code
- [ ] split-into-parts-png — режим «по размеру тайла»: указываешь размер тайла
(16×16, 32×32…, список `SIZE_PRESETS`), инструмент сам считает сетку —
сценарий «режу спрайтшит, знаю размер тайла, не знаю сетку» (новый режим:
схема + core, из задачи C3)
- [ ] нужен инструмент для удаления фона по выбранной точке - удалять внутри - [ ] нужен инструмент для удаления фона по выбранной точке - удалять внутри
выделенной области выделенной области
- [ ] сделать конвертацию в смайлики\награды для твитча (6 размеров или выбор - [ ] сделать конвертацию в смайлики\награды для твитча (6 размеров или выбор
@@ -83,6 +83,11 @@
} }
return spec; return spec;
} }
function isHidden(spec: FieldSpec): boolean {
const rule = spec.visibleWhen;
return rule !== undefined && values[rule.field] !== rule.equals;
}
</script> </script>
{#if isEmpty} {#if isEmpty}
@@ -99,16 +104,19 @@
: undefined} : undefined}
> >
{#each group.fields as id (id)} {#each group.fields as id (id)}
{@const Control = FIELDS[schema.fields[id].spec.kind]} {@const spec = schema.fields[id].spec}
<Control {@const Control = FIELDS[spec.kind]}
label={fieldLabel(schema.fields[id], id)} {#if !isHidden(spec)}
value={values[id]} <Control
spec={effectiveSpec(schema.fields[id].spec)} label={fieldLabel(schema.fields[id], id)}
{toolId} value={values[id]}
fieldId={id} spec={effectiveSpec(spec)}
{sourceDims} {toolId}
onchange={(v, axis) => onchange(id, v, axis)} fieldId={id}
/> {sourceDims}
onchange={(v, axis) => onchange(id, v, axis)}
/>
{/if}
{/each} {/each}
</div> </div>
{/each} {/each}
+61
View File
@@ -7,6 +7,7 @@ import {
resize, resize,
rotate90, rotate90,
splitToParts, splitToParts,
splitToTileSize,
tile, tile,
} from "./geometry"; } from "./geometry";
import { expectImageEqual, makeImage } from "./test-helpers"; import { expectImageEqual, makeImage } from "./test-helpers";
@@ -322,3 +323,63 @@ describe("splitToParts", () => {
expect(parts[0].height).toBe(6); 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);
});
});
+25
View File
@@ -87,6 +87,31 @@ export function splitToParts(
return parts; 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 { export function centerByAlpha(img: PixelImage): PixelImage {
let minX = img.width; let minX = img.width;
let minY = img.height; let minY = img.height;
+9
View File
@@ -235,6 +235,8 @@ export const en: Dict = {
colorTolerance: "Color tolerance", colorTolerance: "Color tolerance",
cols: "Cols", cols: "Cols",
columns: "Columns", columns: "Columns",
splitMode: "Split by",
tileSize: "Tile size",
component: "Component", component: "Component",
contrast: "Contrast", contrast: "Contrast",
count: "Count", count: "Count",
@@ -323,6 +325,8 @@ export const en: Dict = {
cropArea: "Crop area", cropArea: "Crop area",
fill: "Fill", fill: "Fill",
grid: "Grid", grid: "Grid",
splitMode: "Split by",
tileSize: "Tile size",
noise: "Noise", noise: "Noise",
offset: "Offset", offset: "Offset",
options: "Options", options: "Options",
@@ -375,6 +379,11 @@ export const en: Dict = {
orientationSquare: "Square — the sides are equal.", orientationSquare: "Square — the sides are equal.",
}, },
}, },
"split-into-parts": {
options: {
mode: { grid: "Grid", tile: "Tile size" },
},
},
"show-transparent": { "show-transparent": {
options: { options: {
mode: { binary: "Black & white mask", highlight: "Color highlight" }, mode: { binary: "Black & white mask", highlight: "Color highlight" },
+15
View File
@@ -236,6 +236,8 @@ export const ru: Dict = {
colorTolerance: "Допуск похожести", colorTolerance: "Допуск похожести",
cols: "Колонки", cols: "Колонки",
columns: "Столбцы", columns: "Столбцы",
splitMode: "Делить по",
tileSize: "Размер тайла",
component: "Компонент", component: "Компонент",
contrast: "Контраст", contrast: "Контраст",
count: "Количество", count: "Количество",
@@ -324,6 +326,8 @@ export const ru: Dict = {
cropArea: "Область кадрирования", cropArea: "Область кадрирования",
fill: "Заливка", fill: "Заливка",
grid: "Сетка", grid: "Сетка",
splitMode: "Режим разделения",
tileSize: "Размер тайла",
noise: "Шум", noise: "Шум",
offset: "Смещение", offset: "Смещение",
options: "Параметры", options: "Параметры",
@@ -994,6 +998,17 @@ export const ru: Dict = {
color: "Цвет фона", color: "Цвет фона",
}, },
}, },
"split-into-parts": {
params: {
mode: "Режим",
columns: "Столбцов",
rows: "Строк",
tile: "Размер тайла",
},
options: {
mode: { grid: "Сетка", tile: "Размер тайла" },
},
},
tile: { tile: {
params: { columns: "Столбцов", rows: "Строк" }, params: { columns: "Столбцов", rows: "Строк" },
}, },
+3
View File
@@ -16,6 +16,8 @@ import type { Position9 } from "./core/textdraw";
*/ */
export interface FieldSpecBase { export interface FieldSpecBase {
label?: string; label?: string;
/** Поле видно только когда другое поле равно значению (режимы инструмента). */
visibleWhen?: { field: string; equals: string };
} }
export interface NumberSpec extends FieldSpecBase { export interface NumberSpec extends FieldSpecBase {
@@ -291,6 +293,7 @@ export const field = {
maxFromSource?: boolean; maxFromSource?: boolean;
lockAspectWith?: string; lockAspectWith?: string;
presets?: boolean; presets?: boolean;
visibleWhen?: { field: string; equals: string };
}): Field<Dimension> => ({ }): Field<Dimension> => ({
spec: { kind: "dimension", ...s }, spec: { kind: "dimension", ...s },
}), }),
+19
View File
@@ -760,6 +760,25 @@ describe("split-into-parts", () => {
expect(files).toHaveLength(1024); 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 при переполнении", () => { it("выбрасывает errors.tooManyParts при переполнении", () => {
const tool = TOOLS.find((t) => t.id === "split-into-parts")!; const tool = TOOLS.find((t) => t.id === "split-into-parts")!;
expect(() => expect(() =>
+67 -19
View File
@@ -17,6 +17,7 @@ import {
resize, resize,
rotate90, rotate90,
splitToParts, splitToParts,
splitToTileSize,
symmetricCopy, symmetricCopy,
tile, tile,
trimToContent, trimToContent,
@@ -437,26 +438,58 @@ const tileTool: Tool<TileParams> = {
const MAX_SPLIT_PARTS = 1024; const MAX_SPLIT_PARTS = 1024;
interface SplitPartsParams { interface SplitPartsParams {
mode: "grid" | "tile";
columns: number; columns: number;
rows: number; rows: number;
tile: Dimension;
} }
export const splitPartsSchema = toolSchema<SplitPartsParams>({ export const splitPartsSchema = toolSchema<SplitPartsParams>(
columns: field.number({ {
label: "fields.columns", mode: field.select({
min: 1, label: "fields.splitMode",
max: 32, default: "grid",
step: 1, options: [
default: 2, { value: "grid", label: "Grid" },
}), { value: "tile", label: "Tile size" },
rows: field.number({ ],
label: "fields.rows", }),
min: 1, columns: field.number({
max: 32, label: "fields.columns",
step: 1, min: 1,
default: 2, 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<SplitPartsParams> = { const splitPartsTool: Tool<SplitPartsParams> = {
id: "split-into-parts", id: "split-into-parts",
@@ -466,8 +499,20 @@ const splitPartsTool: Tool<SplitPartsParams> = {
run: (ctx) => { run: (ctx) => {
const img = requireSource(ctx); const img = requireSource(ctx);
const params = ctx.params as SplitPartsParams; const params = ctx.params as SplitPartsParams;
const cols = Math.max(1, Math.trunc(params.columns)); const cols =
const rowsCount = Math.max(1, Math.trunc(params.rows)); 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; const count = cols * rowsCount;
if (count > MAX_SPLIT_PARTS) { if (count > MAX_SPLIT_PARTS) {
throw new ToolError("errors.tooManyParts", { throw new ToolError("errors.tooManyParts", {
@@ -475,7 +520,10 @@ const splitPartsTool: Tool<SplitPartsParams> = {
max: MAX_SPLIT_PARTS, 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 rowLen = String(rowsCount).length;
const colLen = String(cols).length; const colLen = String(cols).length;
const files: ToolImageFile[] = parts.map((image, index) => { const files: ToolImageFile[] = parts.map((image, index) => {