Files
feuerwerk/tests/core/texture.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

248 lines
10 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.
import { describe, expect, it } from 'vitest';
import { CreateTextureCommand } from '../../src/core/commands/assetCommands';
import type { TextureAsset } from '../../src/core/model/Asset';
import { findAsset, findPropertyUsages, findUsages } from '../../src/core/model/Project';
import {
DEFAULT_SLICE,
isTextureFile,
nextFrameNames,
resolveTexture,
sliceFrames,
textureRefIds,
uniqueFrameName,
} from '../../src/core/model/texture';
import { deserializeProject } from '../../src/core/store/serialize';
import { makeSpriteScene, place, roundTrip } from '../helpers';
/** A syntactically valid stored file name (64 hex characters plus an extension). */
const FILE = `${'a1b2c3d4'.repeat(8)}.png`;
const OTHER_FILE = `${'0f'.repeat(32)}.webp`;
/** Adds a texture asset carrying `file` and returns it. */
function addTexture(fixture: ReturnType<typeof makeSpriteScene>, file = FILE, name = 'Bricks') {
const command = new CreateTextureCommand(name, { file, width: 64, height: 48 });
fixture.stack.execute(command);
return command.asset;
}
describe('texture file names', () => {
it('accepts the content-addressed form the server produces', () => {
expect(isTextureFile(FILE)).toBe(true);
expect(isTextureFile(OTHER_FILE)).toBe(true);
expect(isTextureFile(`${'ab'.repeat(32)}.jpg`)).toBe(true);
});
it('rejects anything a request must not be built from', () => {
expect(isTextureFile('')).toBe(false);
expect(isTextureFile('bricks.png')).toBe(false);
// Too short, wrong case, an unsupported extension, and the two traversal shapes.
expect(isTextureFile('abc.png')).toBe(false);
expect(isTextureFile(`${'A1'.repeat(32)}.png`)).toBe(false);
expect(isTextureFile(`${'ab'.repeat(32)}.svg`)).toBe(false);
expect(isTextureFile(`../${'ab'.repeat(32)}.png`)).toBe(false);
expect(isTextureFile(`${'ab'.repeat(32)}.png/../x`)).toBe(false);
});
});
describe('resolveTexture', () => {
it('resolves a sprite property to the file and the filter', () => {
const fixture = makeSpriteScene();
const texture = addTexture(fixture);
const sprite = findAsset(fixture.project, fixture.spriteId)!;
sprite.props.texture = texture.id;
// A whole-texture reference carries no rectangle: the renderer then needs no matrix at all.
const whole = { file: FILE, rect: null, sourceWidth: 64, sourceHeight: 48 };
expect(resolveTexture(fixture.project, sprite.props)).toEqual({ ...whole, smooth: true });
texture.props.filter = 'nearest';
expect(resolveTexture(fixture.project, sprite.props)).toEqual({ ...whole, smooth: false });
});
it('resolves a frame id to the rectangle it cuts, not to the whole picture', () => {
const fixture = makeSpriteScene();
const texture = addTexture(fixture);
texture.frames.push({ id: 'f1', name: 'Idle', x: 16, y: 8, w: 32, h: 24 });
const sprite = findAsset(fixture.project, fixture.spriteId)!;
// The same property holds either kind of id, which is what keeps sprites written before
// frames existed meaning exactly what they meant.
sprite.props.texture = 'f1';
expect(resolveTexture(fixture.project, sprite.props)).toEqual({
file: FILE,
smooth: true,
rect: { x: 16, y: 8, w: 32, h: 24 },
sourceWidth: 64,
sourceHeight: 48,
});
// A frame that is gone dangles like any other reference: no picture, not a wrong one.
texture.frames.length = 0;
expect(resolveTexture(fixture.project, sprite.props)).toBeNull();
});
it('yields nothing rather than throwing when the reference cannot be used', () => {
const fixture = makeSpriteScene();
const texture = addTexture(fixture);
const sprite = findAsset(fixture.project, fixture.spriteId)!;
expect(resolveTexture(fixture.project, sprite.props)).toBeNull();
sprite.props.texture = 'does-not-exist';
expect(resolveTexture(fixture.project, sprite.props)).toBeNull();
// Pointing at an asset that is not a texture at all.
sprite.props.texture = fixture.sceneId;
expect(resolveTexture(fixture.project, sprite.props)).toBeNull();
// A texture whose upload never produced a file.
sprite.props.texture = texture.id;
texture.file = '';
expect(resolveTexture(fixture.project, sprite.props)).toBeNull();
});
it('survives the round trip, and a hand-written file name does not', () => {
const fixture = makeSpriteScene();
const texture = addTexture(fixture);
findAsset(fixture.project, fixture.spriteId)!.props.texture = texture.id;
const loaded = roundTrip(fixture.project);
const reloaded = findAsset(loaded, texture.id) as TextureAsset;
expect(reloaded.file).toBe(FILE);
expect(reloaded.width).toBe(64);
expect(resolveTexture(loaded, findAsset(loaded, fixture.spriteId)!.props)?.file).toBe(FILE);
const tampered = deserializeProject({
assets: [{ id: 't', type: 'texture', name: 'X', file: '/etc/passwd', width: -3, height: 'big' }],
});
const dropped = findAsset(tampered, 't') as TextureAsset;
expect(dropped.file).toBe('');
expect(dropped.width).toBe(0);
expect(dropped.height).toBe(0);
});
});
describe('CreateTextureCommand', () => {
it('adds and removes the asset', () => {
const fixture = makeSpriteScene();
const texture = addTexture(fixture);
expect(findAsset(fixture.project, texture.id)).toBeDefined();
fixture.stack.undo();
expect(findAsset(fixture.project, texture.id)).toBeUndefined();
fixture.stack.redo();
expect((findAsset(fixture.project, texture.id) as TextureAsset).file).toBe(FILE);
});
it('refuses a file name that was never uploaded', () => {
const fixture = makeSpriteScene();
expect(() =>
fixture.stack.execute(new CreateTextureCommand('Bad', { file: 'bricks.png', width: 1, height: 1 })),
).toThrow();
});
});
describe('findPropertyUsages', () => {
it('finds a texture that is worn but never placed', () => {
const fixture = makeSpriteScene();
const texture = addTexture(fixture);
const sprite = findAsset(fixture.project, fixture.spriteId)!;
sprite.props.texture = texture.id;
const objectId = place(fixture, fixture.spriteId, { x: 0, y: 0 });
// A texture is never placed, so the "is it used?" question `findUsages` answers is blind
// to it – that is exactly the gap this function fills.
expect(findUsages(fixture.project, texture.id)).toHaveLength(0);
const usages = findPropertyUsages(fixture.project, texture.id);
expect(usages).toHaveLength(1);
expect(usages[0]).toMatchObject({ on: 'asset', key: 'texture' });
fixture.object(objectId).overrides.texture = texture.id;
expect(findPropertyUsages(fixture.project, texture.id)).toHaveLength(2);
expect(findPropertyUsages(fixture.project, texture.id).some((u) => u.on === 'object')).toBe(true);
});
it('counts sprites wearing a frame of the texture, not just the texture itself', () => {
const fixture = makeSpriteScene();
const texture = addTexture(fixture);
texture.frames.push({ id: 'f1', name: 'Idle', x: 0, y: 0, w: 8, h: 8 });
findAsset(fixture.project, fixture.spriteId)!.props.texture = 'f1';
// Asking by the asset id alone under-reports exactly the sheets that were sliced – the
// ones with the most users – which is why deleting one asks about every id it owns.
expect(findPropertyUsages(fixture.project, texture.id)).toHaveLength(0);
expect(findPropertyUsages(fixture.project, textureRefIds(texture))).toHaveLength(1);
});
it('ignores unrelated ids and an empty reference', () => {
const fixture = makeSpriteScene();
const texture = addTexture(fixture);
findAsset(fixture.project, fixture.spriteId)!.props.texture = texture.id;
expect(findPropertyUsages(fixture.project, 'other')).toHaveLength(0);
// An empty reference must not report every sprite that has no texture.
expect(findPropertyUsages(fixture.project, '')).toHaveLength(0);
});
});
describe('slicing a sheet', () => {
it('cuts a grid in reading order and derives the cell size from the picture', () => {
const rects = sliceFrames(64, 64, { ...DEFAULT_SLICE, columns: 2, rows: 2 });
expect(rects).toEqual([
{ x: 0, y: 0, w: 32, h: 32 },
{ x: 32, y: 0, w: 32, h: 32 },
{ x: 0, y: 32, w: 32, h: 32 },
{ x: 32, y: 32, w: 32, h: 32 },
]);
});
it('accounts for an offset and for spacing between cells', () => {
// 2 columns with a 2 px border and 4 px between them: (64 − 2 − 4) / 2 = 29 px cells.
const rects = sliceFrames(64, 64, {
...DEFAULT_SLICE,
columns: 2,
rows: 1,
offsetX: 2,
spacingX: 4,
});
expect(rects).toEqual([
{ x: 2, y: 0, w: 29, h: 64 },
{ x: 35, y: 0, w: 29, h: 64 },
]);
});
it('refuses rather than guessing when the numbers cannot describe a grid', () => {
// An unmeasured picture, more cells than pixels, and the values a form can produce.
expect(sliceFrames(0, 0, DEFAULT_SLICE)).toEqual([]);
expect(sliceFrames(4, 4, { ...DEFAULT_SLICE, columns: 8, rows: 8 })).toEqual([]);
expect(sliceFrames(64, 64, { ...DEFAULT_SLICE, columns: 0 })).toEqual([]);
// NaN passes every `<` comparison, so it has to be rejected in its own right.
expect(sliceFrames(64, 64, { ...DEFAULT_SLICE, columns: NaN })).toEqual([]);
expect(sliceFrames(64, 64, { ...DEFAULT_SLICE, spacingX: NaN })).toEqual([]);
});
});
describe('frame names', () => {
const sheet = (...names: string[]): TextureAsset => ({
id: 't',
type: 'texture',
name: 'Sheet',
props: {},
file: '',
width: 64,
height: 64,
frames: names.map((name, i) => ({ id: `f${i}`, name, x: 0, y: 0, w: 1, h: 1 })),
});
it('numbers a clashing name from 2, the way asset names are numbered', () => {
expect(uniqueFrameName(sheet(), 'Idle')).toBe('Idle');
expect(uniqueFrameName(sheet('Idle'), 'Idle')).toBe('Idle 2');
expect(uniqueFrameName(sheet('Idle', 'Idle 2'), 'Idle')).toBe('Idle 3');
// A frame keeps its own name available, so renaming it to itself is not a clash.
expect(uniqueFrameName(sheet('Idle'), 'Idle', 'f0')).toBe('Idle');
});
it('numbers a whole slice from 1 and steps over names already taken', () => {
expect(nextFrameNames(sheet(), 3)).toEqual(['Frame 1', 'Frame 2', 'Frame 3']);
// Re-slicing a sheet that keeps its frames must not append a number to a number.
expect(nextFrameNames(sheet('Frame 2'), 3)).toEqual(['Frame 1', 'Frame 3', 'Frame 4']);
});
});