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
- [ ] bar code to text, text to bar code
- [ ] split-into-parts-png — режим «по размеру тайла»: указываешь размер тайла
(16×16, 32×32…, список `SIZE_PRESETS`), инструмент сам считает сетку —
сценарий «режу спрайтшит, знаю размер тайла, не знаю сетку» (новый режим:
схема + core, из задачи C3)
- [ ] нужен инструмент для удаления фона по выбранной точке - удалять внутри
выделенной области
- [ ] сделать конвертацию в смайлики\награды для твитча (6 размеров или выбор
@@ -83,6 +83,11 @@
}
return spec;
}
function isHidden(spec: FieldSpec): boolean {
const rule = spec.visibleWhen;
return rule !== undefined && values[rule.field] !== rule.equals;
}
</script>
{#if isEmpty}
@@ -99,16 +104,19 @@
: undefined}
>
{#each group.fields as id (id)}
{@const Control = FIELDS[schema.fields[id].spec.kind]}
{@const spec = schema.fields[id].spec}
{@const Control = FIELDS[spec.kind]}
{#if !isHidden(spec)}
<Control
label={fieldLabel(schema.fields[id], id)}
value={values[id]}
spec={effectiveSpec(schema.fields[id].spec)}
spec={effectiveSpec(spec)}
{toolId}
fieldId={id}
{sourceDims}
onchange={(v, axis) => onchange(id, v, axis)}
/>
{/if}
{/each}
</div>
{/each}
+61
View File
@@ -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);
});
});
+25
View File
@@ -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;
+9
View File
@@ -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" },
+15
View File
@@ -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: "Строк" },
},
+3
View File
@@ -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<Dimension> => ({
spec: { kind: "dimension", ...s },
}),
+19
View File
@@ -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(() =>
+53 -5
View File
@@ -17,6 +17,7 @@ import {
resize,
rotate90,
splitToParts,
splitToTileSize,
symmetricCopy,
tile,
trimToContent,
@@ -437,17 +438,29 @@ const tileTool: Tool<TileParams> = {
const MAX_SPLIT_PARTS = 1024;
interface SplitPartsParams {
mode: "grid" | "tile";
columns: number;
rows: number;
tile: Dimension;
}
export const splitPartsSchema = toolSchema<SplitPartsParams>({
export const splitPartsSchema = toolSchema<SplitPartsParams>(
{
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",
@@ -455,8 +468,28 @@ export const splitPartsSchema = toolSchema<SplitPartsParams>({
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> = {
id: "split-into-parts",
@@ -466,8 +499,20 @@ const splitPartsTool: Tool<SplitPartsParams> = {
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<SplitPartsParams> = {
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) => {