A new Texture tab next to Scene/Game/Graph shows a texture sheet and lets frames be drawn on it: drag to create, move, resize via eight handles, edit name and rect in the inspector, Delete to remove. Every change is a command, so one gesture is one undo step. "Slice..." cuts a grid from columns and rows with optional offset and spacing, previewing the resulting frame count and cell size live. Sprites pick a frame instead of the whole texture through the same `texture` property: it now holds either a texture id or a frame id. Frame ids come from the same generator and are document-wide unique, so one picker, one property and one set of instance overrides cover both cases, and every sprite written before this still means the same thing. A Graphics fill ignores a Pixi sub-texture's frame and draws the whole image, because it derives its uvs from the shape bounds and the inverted style matrix. The cutout is therefore a matrix (`viewport/textureFill.ts`), which keeps a sheet at a single GPU upload no matter how many of its frames are on screen at once. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
105 lines
4.7 KiB
TypeScript
105 lines
4.7 KiB
TypeScript
/**
|
||
* The fill matrix is what makes a sprite wear one frame of a sheet, and it is only ever seen
|
||
* as pixels on a canvas – so it is pinned here against the identity its doc comment states,
|
||
* `uv = inverse(matrix) ∘ normalise(shapeBounds)`, rather than against the formula it uses.
|
||
* The checks therefore invert the matrix themselves and ask where the corners of the shape
|
||
* land in the image; that is an independent statement of what the renderer must achieve.
|
||
*/
|
||
import { describe, expect, it } from 'vitest';
|
||
import type { FrameRect } from '../../src/core/model/texture';
|
||
import { frameFillMatrix, type MatrixValues } from '../../src/viewport/textureFill';
|
||
|
||
/**
|
||
* Where the shape point `(u, v)` – normalised, so (0,0) is the shape's top-left corner and
|
||
* (1,1) its bottom-right – samples the image, in UV coordinates over the whole picture.
|
||
* This is Pixi's `inverse(style.matrix)` applied to the normalised point, written out for a
|
||
* general affine matrix so the test does not assume the axis-aligned shape of the real one.
|
||
*/
|
||
function sampleAt(m: MatrixValues, u: number, v: number): { u: number; v: number } {
|
||
const det = m.a * m.d - m.b * m.c;
|
||
const dx = u - m.tx;
|
||
const dy = v - m.ty;
|
||
return { u: (m.d * dx - m.c * dy) / det, v: (m.a * dy - m.b * dx) / det };
|
||
}
|
||
|
||
/** Asserts that the shape's four corners sample exactly the four corners of `rect`. */
|
||
function expectCovers(m: MatrixValues, rect: FrameRect, sourceWidth: number, sourceHeight: number): void {
|
||
const left = rect.x / sourceWidth;
|
||
const right = (rect.x + rect.w) / sourceWidth;
|
||
const top = rect.y / sourceHeight;
|
||
const bottom = (rect.y + rect.h) / sourceHeight;
|
||
for (const [u, v, expectedU, expectedV] of [
|
||
[0, 0, left, top],
|
||
[1, 0, right, top],
|
||
[0, 1, left, bottom],
|
||
[1, 1, right, bottom],
|
||
] as const) {
|
||
const got = sampleAt(m, u, v);
|
||
expect(got.u).toBeCloseTo(expectedU, 12);
|
||
expect(got.v).toBeCloseTo(expectedV, 12);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Compares the six numbers one by one instead of the object as a whole: a frame sitting on the
|
||
* top or left edge produces a negative zero, which `toEqual` counts as a different value.
|
||
*/
|
||
function expectMatrix(m: MatrixValues, expected: MatrixValues): void {
|
||
for (const key of ['a', 'b', 'c', 'd', 'tx', 'ty'] as const) expect(m[key]).toBeCloseTo(expected[key], 12);
|
||
}
|
||
|
||
describe('frameFillMatrix', () => {
|
||
it('declines when the image size is unknown, so the sprite keeps showing the whole picture', () => {
|
||
const rect = { x: 0, y: 0, w: 32, h: 32 };
|
||
// What an upload that has not reported its size yet looks like in the model.
|
||
expect(frameFillMatrix(rect, 0, 0)).toBeNull();
|
||
expect(frameFillMatrix(rect, 64, 0)).toBeNull();
|
||
expect(frameFillMatrix(rect, -64, 64)).toBeNull();
|
||
});
|
||
|
||
it('declines for a frame with no area, which cannot be spread over anything', () => {
|
||
expect(frameFillMatrix({ x: 0, y: 0, w: 0, h: 32 }, 64, 64)).toBeNull();
|
||
expect(frameFillMatrix({ x: 0, y: 0, w: 32, h: 0 }, 64, 64)).toBeNull();
|
||
});
|
||
|
||
it('maps the shape onto the top-right quadrant of a four-quadrant sheet', () => {
|
||
const rect = { x: 32, y: 0, w: 32, h: 32 };
|
||
const m = frameFillMatrix(rect, 64, 64)!;
|
||
|
||
// Half the sheet across, so the shape has to be stretched twofold in both directions, and
|
||
// shifted by one shape-width to start at the sheet's midpoint.
|
||
expectMatrix(m, { a: 2, b: 0, c: 0, d: 2, tx: -1, ty: 0 });
|
||
expectCovers(m, rect, 64, 64);
|
||
});
|
||
|
||
it('stretches the axes independently for a non-square frame', () => {
|
||
// A column strip: an eighth of the sheet wide and its full height.
|
||
const rect = { x: 0, y: 0, w: 16, h: 64 };
|
||
const m = frameFillMatrix(rect, 128, 64)!;
|
||
|
||
expectMatrix(m, { a: 8, b: 0, c: 0, d: 1, tx: 0, ty: 0 });
|
||
expectCovers(m, rect, 128, 64);
|
||
});
|
||
|
||
it('offsets in frame-widths, not in pixels or UV', () => {
|
||
// A small frame well inside a large sheet: three frame-widths from the left, two frame
|
||
// heights down. This is the case that catches an offset expressed in the wrong unit,
|
||
// because pixels, UV and frame-widths all differ here.
|
||
const rect = { x: 48, y: 16, w: 16, h: 8 };
|
||
const m = frameFillMatrix(rect, 256, 128)!;
|
||
|
||
expectMatrix(m, { a: 16, b: 0, c: 0, d: 16, tx: -3, ty: -2 });
|
||
expectCovers(m, rect, 256, 128);
|
||
});
|
||
|
||
it('is the identity for a frame covering the whole sheet', () => {
|
||
// Which is the reason a whole-image sprite is drawn with no matrix at all: the matrix it
|
||
// would get says nothing, and an absent one is what Pixi is fastest at.
|
||
const rect = { x: 0, y: 0, w: 200, h: 100 };
|
||
const m = frameFillMatrix(rect, 200, 100)!;
|
||
|
||
expectMatrix(m, { a: 1, b: 0, c: 0, d: 1, tx: 0, ty: 0 });
|
||
expectCovers(m, rect, 200, 100);
|
||
});
|
||
});
|