A sliced frame drew its borders one source pixel to one sprite unit. Pixel-art sheets are small: a 16px corner on a 400-unit button is a thin line, and the only way around it was a larger image. The texture now carries `Nine-slice scale` (default 1), next to `Filter`. It sets how many sprite units one border pixel takes, for every sliced frame of that texture. The UVs stay as they are - a corner shows the same pixels, only larger or smaller. When the sprite is too small for both borders they are still scaled down together, now starting from the scaled size. With `Repeat` the tile size scales too, otherwise a doubled border would sit beside a pattern of the old size. The value is a schema row, so inspector field, saving and undo come without their own code. Anything that is not a size above 0 reads as 1, and documents without it draw as before. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
158 lines
6.1 KiB
TypeScript
158 lines
6.1 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,
|
||
scale: 1,
|
||
});
|
||
|
||
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([]);
|
||
expect(sliceSpans(200, 24, 8, 8, 'stretch', 0)).toEqual([]);
|
||
});
|
||
|
||
it('draws the borders scaled, and samples the same pixels for them (#96)', () => {
|
||
const spans = sliceSpans(200, 24, 8, 8, 'stretch', 2);
|
||
expect(spans.map((s) => [s.p0, s.p1])).toEqual([
|
||
[0, 16],
|
||
[16, 184],
|
||
[184, 200],
|
||
]);
|
||
// Bigger on screen, not more of the picture: the corner still shows its eight pixels.
|
||
expect(spans.map((s) => [s.t0, s.t1])).toEqual([
|
||
[0, 1 / 3],
|
||
[1 / 3, 2 / 3],
|
||
[2 / 3, 1],
|
||
]);
|
||
// Below 1 the borders shrink the same way.
|
||
expect(sliceSpans(200, 24, 8, 8, 'stretch', 0.5)[0]).toMatchObject({ p0: 0, p1: 4 });
|
||
});
|
||
|
||
it('scales the repeated tile with the borders, so the pattern keeps its proportion', () => {
|
||
const middle = sliceSpans(80, 24, 8, 8, 'repeat', 2).slice(1, -1);
|
||
// A middle of 48 units in tiles of 8 × 2 = 16.
|
||
expect(middle.map((s) => [s.p0, s.p1])).toEqual([
|
||
[16, 32],
|
||
[32, 48],
|
||
[48, 64],
|
||
]);
|
||
});
|
||
|
||
it('squeezes scaled borders like unscaled ones when the sprite is too small for them', () => {
|
||
// 16 + 16 on a box of 20: both shrink to 10, the middle is gone.
|
||
expect(sliceSpans(20, 24, 8, 8, 'stretch', 2).map((s) => [s.p0, s.p1])).toEqual([
|
||
[0, 10],
|
||
[10, 20],
|
||
]);
|
||
});
|
||
});
|
||
|
||
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));
|
||
expect(sliceKey(base, 200, 100)).not.toBe(sliceKey({ ...base, scale: 2 }, 200, 100));
|
||
});
|
||
});
|