Files
feuerwerk/tests/viewport/texture-fill.test.ts
T
rootandClaude Opus 5 35a1a23af8 Add a Texture tab for defining frames
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>
2026-09-08 14:27:23 +02:00

105 lines
4.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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);
});
});