game-maker / suite (push) Successful in 6m21s
TextureFrame carries optional `border` (left/right/top/bottom) and `fill` (stretch | repeat); absent keys mean "not sliced", so no format bump and undo restores the plain frame instead of four zeroes. The sliced sprite builds its own geometry instead of the shared UNIT_QUAD: a 4x4 grid, and for `repeat` one quad per tile carrying the frame cell's real UVs -- tiling in the geometry, not in the sampler, so a frame out of a sheet cannot pull its neighbour in. Above 64 tiles per axis it stretches, to bound the buffer. A sprite narrower than left+right scales both borders down together until the middle is exactly 0, so the corners meet at a seam and never overlap. The texture editor gets four draggable border lines plus the same four numbers in the inspector. Hit order is resize handles, then the lines, then the frame body -- handleAt reports 'move' anywhere inside a frame, so lines placed after it were unreachable. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
120 lines
4.7 KiB
TypeScript
120 lines
4.7 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
||
import type { NineSlice } from '../../src/core/model/texture';
|
||
import { MAX_TILES, sliceGeometry, sliceKey, sliceSpans } from '../../src/viewport/nineSlice';
|
||
|
||
const slice = (border: Partial<NineSlice['border']>, fill: NineSlice['fill'] = 'stretch'): NineSlice => ({
|
||
border: { left: 0, top: 0, right: 0, bottom: 0, ...border },
|
||
fill,
|
||
sourceW: 24,
|
||
sourceH: 24,
|
||
});
|
||
|
||
describe('sliceSpans', () => {
|
||
it('keeps the corners at their source size and gives the rest to the middle', () => {
|
||
const spans = sliceSpans(200, 24, 8, 8, 'stretch');
|
||
expect(spans.map((s) => [s.p0, s.p1])).toEqual([
|
||
[0, 8],
|
||
[8, 192],
|
||
[192, 200],
|
||
]);
|
||
// The UVs cut the frame at the same pixels, so the corner shows the corner.
|
||
expect(spans.map((s) => [s.t0, s.t1])).toEqual([
|
||
[0, 1 / 3],
|
||
[1 / 3, 2 / 3],
|
||
[2 / 3, 1],
|
||
]);
|
||
});
|
||
|
||
it('leaves out a part that has no room: a border of zero is no quad at all', () => {
|
||
expect(sliceSpans(200, 24, 0, 8, 'stretch').map((s) => [s.p0, s.p1])).toEqual([
|
||
[0, 192],
|
||
[192, 200],
|
||
]);
|
||
// Exactly as wide as its two borders: middle gone, the two corners meet.
|
||
expect(sliceSpans(16, 24, 8, 8, 'stretch').map((s) => [s.p0, s.p1])).toEqual([
|
||
[0, 8],
|
||
[8, 16],
|
||
]);
|
||
});
|
||
|
||
it('scales both borders down together when the sprite is smaller than they are', () => {
|
||
const spans = sliceSpans(8, 24, 8, 8, 'stretch');
|
||
// Never overlapping corners: 4 + 4 rather than 8 + 8 on a box of 8.
|
||
expect(spans.map((s) => [s.p0, s.p1])).toEqual([
|
||
[0, 4],
|
||
[4, 8],
|
||
]);
|
||
// The UVs are untouched by the squeeze: each corner still shows its own eight pixels.
|
||
expect(spans.map((s) => [s.t0, s.t1])).toEqual([
|
||
[0, 1 / 3],
|
||
[2 / 3, 1],
|
||
]);
|
||
});
|
||
|
||
it('repeats the middle in whole tiles of the frame and cuts the last one', () => {
|
||
const spans = sliceSpans(50, 24, 8, 8, 'repeat');
|
||
const middle = spans.slice(1, -1);
|
||
expect(middle.map((s) => [s.p0, s.p1])).toEqual([
|
||
[8, 16],
|
||
[16, 24],
|
||
[24, 32],
|
||
[32, 40],
|
||
[40, 42],
|
||
]);
|
||
// The cut tile shows only as much of the picture as it covers – a quarter of the eight pixels.
|
||
expect(middle.at(-1)?.t1).toBeCloseTo(1 / 3 + (2 / 8) * (1 / 3), 10);
|
||
// Every tile starts at the middle's own first pixel; none samples past the frame.
|
||
expect(middle.every((s) => s.t0 === 1 / 3 && s.t1 <= 2 / 3)).toBe(true);
|
||
});
|
||
|
||
it('stretches rather than building a tile for every pixel of a huge sprite', () => {
|
||
// A middle of 14 source pixels: exactly `MAX_TILES` of them still tile, one more stretches.
|
||
const many = sliceSpans(10 + MAX_TILES * 14, 24, 5, 5, 'repeat');
|
||
expect(many.length).toBe(2 + MAX_TILES);
|
||
const tooMany = sliceSpans(10 + (MAX_TILES + 1) * 14, 24, 5, 5, 'repeat');
|
||
expect(tooMany.length).toBe(3);
|
||
});
|
||
|
||
it('answers nothing for a box or a picture of no size', () => {
|
||
expect(sliceSpans(0, 24, 8, 8, 'stretch')).toEqual([]);
|
||
expect(sliceSpans(200, 0, 8, 8, 'stretch')).toEqual([]);
|
||
});
|
||
});
|
||
|
||
describe('sliceGeometry', () => {
|
||
it('builds one quad per part, in the 0..1 box the unit quad covers', () => {
|
||
const { positions, uvs, indices } = sliceGeometry(
|
||
slice({ left: 8, top: 8, right: 8, bottom: 8 }),
|
||
200,
|
||
200,
|
||
);
|
||
expect(indices.length).toBe(9 * 6);
|
||
expect(positions.length).toBe(9 * 8);
|
||
expect(uvs.length).toBe(positions.length);
|
||
// Fractions, not units: the sprite's size lives in the mesh's scale.
|
||
expect(Math.min(...positions)).toBe(0);
|
||
expect(Math.max(...positions)).toBe(1);
|
||
// The first quad is the top-left corner: 8 of 200 units, showing 8 of 24 pixels.
|
||
// Close, not equal: the buffers are Float32, where neither 0.04 nor a third is exact.
|
||
expect(positions[2]).toBeCloseTo(0.04, 6);
|
||
expect(uvs[2]).toBeCloseTo(1 / 3, 6);
|
||
expect([positions[0], positions[1], positions[3]]).toEqual([0, 0, 0]);
|
||
});
|
||
|
||
it('drops the rows and columns that have no room, keeping the quads rectangular', () => {
|
||
const { indices } = sliceGeometry(slice({ left: 8, right: 8 }), 200, 200);
|
||
// Three columns, one row – nothing is cut horizontally.
|
||
expect(indices.length).toBe(3 * 6);
|
||
});
|
||
});
|
||
|
||
describe('sliceKey', () => {
|
||
it('changes with anything the geometry is built from, and not with a sub-pixel of size', () => {
|
||
const base = slice({ left: 8, right: 8 });
|
||
expect(sliceKey(base, 200, 100)).toBe(sliceKey(base, 200.1, 100));
|
||
expect(sliceKey(base, 200, 100)).not.toBe(sliceKey(base, 201, 100));
|
||
expect(sliceKey(base, 200, 100)).not.toBe(sliceKey(slice({ left: 8, right: 7 }), 200, 100));
|
||
expect(sliceKey(base, 200, 100)).not.toBe(sliceKey(slice({ left: 8, right: 8 }, 'repeat'), 200, 100));
|
||
});
|
||
});
|