Files
feuerwerk/tests/core/drawing-cut.test.ts
adminandClaude Opus 5.5 3e05cbbed5 Let pixel art and vector art be nine-sliced and sorted (#308)
Nine-slice and the sort line hung on the texture frame alone. Drawing
and sorting already read both off the TextureRef every picture hands
out, but the generated pictures always said "no slice, no line". So
the feature moves from the frame to the picture, without a second
copy of any rule.

- PictureCut (border, fill, sortLine) is one type. TextureFrame
  extends it, PixelArtAsset and VectorArtAsset carry it as a whole.
  The texture file format does not change, and no migration is needed.
- The rules in texture.ts take a cut and a size: cutSortLine,
  setCutSlice, rawCutSlice and the rest replace the frame* versions.
  serialize.ts reads all three through one readCut.
- cutTarget.ts resolves a target to cut and size: a texture frame, a
  pixel frame's size or the vector canvas. It lives apart from
  texture.ts because vectorart.ts reaches texture.ts itself.
- SetSliceCommand and SetSortLineCommand take that target instead of
  (assetId, frameId). They refuse an asset that draws no picture.
- generatedRef fills slice and sortLine from the drawing. The whole
  strip of a pixel art asset with several frames gets none, it is no
  picture of that size. With one frame asset and frame are the same.
- Nine-slice scale is one shared schema row, now also on pixel art and
  vector art.
- cutSection.ts holds the NINE-SLICE and SORTING rows once. The frame
  inspector and the drawing inspector both build them.
- SheetCanvas draws a drawing's lines that are set, dashed as in the
  Texture tab. They are not dragged there, the painting tools own the
  canvas (as proposed in the issue).
- drawOrder falls back to the picture's height when a ref has no
  rect, or a sort line on vector art would have been ignored.

Tests:
- Gate: 232 files, 3542 tests (+8: drawing-cut.test.ts, and vector
  art in sort-by-y.test.ts). Three counter-proofs bite: no cut on a
  drawing, a cut on the whole strip, no height fallback.
- e2e 48: 44 checks (+1, sum 1732). 48l-ii reads red and blue where
  12-pixel borders at scale 2 put them. Without borders both spots
  read green.
- Looked at: the Pixel art tab with the lines, and the sliced sprite.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 19:53:51 +02:00

143 lines
7.0 KiB
TypeScript

/**
* Nine-slice and sort line on pixel art and vector art (#308): the same commands, rules and ref
* fields a texture frame has, measured against a pixel frame or the vector canvas.
*/
import { afterEach, describe, expect, it } from 'vitest';
import { CreateAssetCommand, SetAssetPropertyCommand } from '../../src/core/commands/assetCommands';
import { CommandError } from '../../src/core/commands/Command';
import { SetSliceCommand, SetSortLineCommand } from '../../src/core/commands/frameCommands';
import type { PixelArtAsset, VectorArtAsset } from '../../src/core/model/Asset';
import { registerPictureSource } from '../../src/core/model/generated';
import { pixelArtPicture } from '../../src/core/model/pixelart';
import { findAsset } from '../../src/core/model/Project';
import { resolvePicture, SLICE_SCALE_KEY } from '../../src/core/model/texture';
import { VECTOR_HEIGHT_KEY, VECTOR_WIDTH_KEY, vectorArtPicture } from '../../src/core/model/vectorart';
import { deserializeProject, serializeProject } from '../../src/core/store/serialize';
import { cutLines } from '../../src/editor/texture/sheetView';
import { makeProject, roundTrip } from '../helpers';
const RING = { left: 4, top: 4, right: 4, bottom: 4 };
function drawings() {
const fixture = makeProject();
const pixel = new CreateAssetCommand('pixelart', 'Hero');
const vector = new CreateAssetCommand('vectorart', 'Panel');
fixture.stack.execute(pixel);
fixture.stack.execute(vector);
const pixelArt = () => findAsset(fixture.project, pixel.asset.id) as PixelArtAsset;
const vectorArt = () => findAsset(fixture.project, vector.asset.id) as VectorArtAsset;
pixelArt().width = 16;
pixelArt().height = 24;
fixture.stack.execute(new SetAssetPropertyCommand(vector.asset.id, VECTOR_WIDTH_KEY, 64));
fixture.stack.execute(new SetAssetPropertyCommand(vector.asset.id, VECTOR_HEIGHT_KEY, 32));
return { ...fixture, pixelId: pixel.asset.id, vectorId: vector.asset.id, pixelArt, vectorArt };
}
describe('slicing and sorting a drawing as a whole (#308)', () => {
it('sets both on pixel art and vector art, and undo takes the keys away again', () => {
const f = drawings();
for (const [assetId, read] of [
[f.pixelId, f.pixelArt],
[f.vectorId, f.vectorArt],
] as const) {
f.stack.execute(new SetSliceCommand({ assetId }, { border: RING, fill: 'stretch' }));
f.stack.execute(new SetSortLineCommand({ assetId }, 6));
expect(read()).toMatchObject({ border: RING, fill: 'stretch', sortLine: 6 });
f.stack.undo();
f.stack.undo();
expect('border' in read() || 'fill' in read() || 'sortLine' in read()).toBe(false);
}
});
it('holds borders and line inside the frame or the canvas', () => {
const f = drawings();
const wide = { left: 40, top: 0, right: 40, bottom: 0 };
f.stack.execute(new SetSliceCommand({ assetId: f.pixelId }, { border: wide, fill: 'repeat' }));
f.stack.execute(new SetSortLineCommand({ assetId: f.pixelId }, 99));
// 16 wide: one pixel stays between the two; 24 tall is as high as the line goes.
expect(f.pixelArt()).toMatchObject({ border: { left: 8, right: 7 }, sortLine: 24 });
f.stack.execute(new SetSliceCommand({ assetId: f.vectorId }, { border: wide, fill: 'repeat' }));
f.stack.execute(new SetSortLineCommand({ assetId: f.vectorId }, 99));
expect(f.vectorArt()).toMatchObject({ border: { left: 32, right: 31 }, sortLine: 32 });
});
it('refuses an asset that draws no picture of its own', () => {
const f = drawings();
const sprite = new CreateAssetCommand('sprite', 'Box');
f.stack.execute(sprite);
expect(() => f.stack.execute(new SetSortLineCommand({ assetId: sprite.asset.id }, 3))).toThrow(
/Box cannot be sliced/,
);
expect(() => f.stack.execute(new SetSortLineCommand({ assetId: f.pixelId, frameId: 'x' }, 3))).toThrow(
CommandError,
);
});
it('survives a round trip, and loading holds it to the canvas', () => {
const f = drawings();
f.stack.execute(new SetSliceCommand({ assetId: f.vectorId }, { border: RING, fill: 'stretch' }));
f.stack.execute(new SetSortLineCommand({ assetId: f.pixelId }, 5));
const back = roundTrip(f.project);
expect(findAsset(back, f.vectorId)).toMatchObject({ border: RING, fill: 'stretch' });
expect(findAsset(back, f.pixelId)).toMatchObject({ sortLine: 5 });
const saved = serializeProject(f.project) as { assets: Record<string, unknown>[] };
const pixel = saved.assets.find((a) => a.id === f.pixelId)!;
pixel.sortLine = 70;
pixel.border = { left: 'x', top: 2.6, right: -1, bottom: 0 };
expect(findAsset(deserializeProject(saved), f.pixelId)).toMatchObject({
sortLine: 24,
border: { left: 0, top: 3, right: 0, bottom: 0 },
});
});
});
describe('the picture a drawing hands to a sprite (#308)', () => {
const removers: (() => void)[] = [];
afterEach(() => removers.splice(0).forEach((remove) => remove()));
it('carries the cut of a pixel frame, measured in the frame', () => {
removers.push(registerPictureSource('pixelart', pixelArtPicture));
const f = drawings();
f.stack.execute(new SetSliceCommand({ assetId: f.pixelId }, { border: RING, fill: 'repeat' }));
f.stack.execute(new SetSortLineCommand({ assetId: f.pixelId }, 6));
f.stack.execute(new SetAssetPropertyCommand(f.pixelId, SLICE_SCALE_KEY, 2));
const slice = { border: RING, fill: 'repeat', sourceW: 16, sourceH: 24, scale: 2 };
expect(resolvePicture(f.project, f.pixelId)).toMatchObject({ slice, sortLine: 6 });
// With a second frame the asset is the sheet of both, which is no picture of that size.
f.pixelArt().frames.push({ id: 'second', cels: {} });
expect(resolvePicture(f.project, f.pixelId)).toMatchObject({ sourceWidth: 32, slice: null });
expect(resolvePicture(f.project, f.pixelId)?.sortLine).toBeUndefined();
expect(resolvePicture(f.project, 'second')).toMatchObject({ rect: { x: 16, w: 16 }, slice, sortLine: 6 });
});
it('carries the cut of vector art, measured in the canvas', () => {
removers.push(registerPictureSource('vectorart', vectorArtPicture));
const f = drawings();
expect(resolvePicture(f.project, f.vectorId)).toMatchObject({ slice: null });
f.stack.execute(new SetSliceCommand({ assetId: f.vectorId }, { border: RING, fill: 'stretch' }));
f.stack.execute(new SetSortLineCommand({ assetId: f.vectorId }, 8));
expect(resolvePicture(f.project, f.vectorId)).toMatchObject({
rect: null,
slice: { border: RING, fill: 'stretch', sourceW: 64, sourceH: 32, scale: 1 },
sortLine: 8,
});
});
});
describe('the lines a drawing tab shows (#308)', () => {
it('draws only what is set, in canvas pixels', () => {
expect(cutLines({}, { w: 16, h: 24 })).toEqual({ borders: [], sortLine: null });
const { borders, sortLine } = cutLines(
{ border: { left: 4, top: 0, right: 0, bottom: 2 }, sortLine: 6 },
{ w: 16, h: 24 },
);
expect(borders).toEqual([
{ x1: 4, y1: 0, x2: 4, y2: 24 },
{ x1: 0, y1: 22, x2: 16, y2: 22 },
]);
expect(sortLine).toEqual({ x1: 0, y1: 18, x2: 16, y2: 18 });
});
});