Add ↔ next to Width and ↕ next to Height in the inspector. Each sets its field so the sprite matches the ratio of its assigned texture or frame, reading the frame's own rect rather than the sheet's. The buttons reuse the existing FieldActions convention, so they simply do not appear when no texture is assigned, when the sprite is already proportional, or on assets whose schema has no texture property. Comparison is a cross product with a relative tolerance: 3135 of the 40000 size pairs up to 200x200 do not survive a round trip through a division, so an exact check would leave the button standing after its own click. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
84 lines
3.9 KiB
TypeScript
84 lines
3.9 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
||
import { CreateTextureCommand } from '../../src/core/commands/assetCommands';
|
||
import { AddFrameCommand } from '../../src/core/commands/frameCommands';
|
||
import type { PropertyBag } from '../../src/core/model/Properties';
|
||
import { fitAxisToAspect, matchesAspect, texturePixelSize } from '../../src/core/properties/aspect';
|
||
import { makeSpriteScene } from '../helpers';
|
||
|
||
const FILE = `${'a1b2c3d4'.repeat(8)}.png`;
|
||
|
||
/** A project with a 64 × 48 sheet carrying one 30 × 10 frame. */
|
||
function makeSheet(width = 64, height = 48) {
|
||
const fixture = makeSpriteScene();
|
||
const texture = new CreateTextureCommand('Sheet', { file: FILE, width, height });
|
||
fixture.stack.execute(texture);
|
||
const frame = new AddFrameCommand(texture.asset.id, { x: 2, y: 4, w: 30, h: 10 });
|
||
fixture.stack.execute(frame);
|
||
return { ...fixture, textureId: texture.asset.id, frameId: frame.frameId };
|
||
}
|
||
|
||
const props = (bag: PropertyBag): PropertyBag => ({ width: 100, height: 100, ...bag });
|
||
|
||
describe('texturePixelSize', () => {
|
||
it('is the whole image for a texture id and the rectangle for a frame id', () => {
|
||
const sheet = makeSheet();
|
||
expect(texturePixelSize(sheet.project, props({ texture: sheet.textureId }))).toEqual({ w: 64, h: 48 });
|
||
expect(texturePixelSize(sheet.project, props({ texture: sheet.frameId }))).toEqual({ w: 30, h: 10 });
|
||
});
|
||
|
||
it('is null when the sprite wears nothing, or an id that no longer resolves', () => {
|
||
const sheet = makeSheet();
|
||
expect(texturePixelSize(sheet.project, props({ texture: '' }))).toBeNull();
|
||
expect(texturePixelSize(sheet.project, props({}))).toBeNull();
|
||
expect(texturePixelSize(sheet.project, props({ texture: 'gone' }))).toBeNull();
|
||
});
|
||
|
||
it('is null while the image has not been measured, so no ratio is invented', () => {
|
||
const sheet = makeSheet(0, 0);
|
||
expect(texturePixelSize(sheet.project, props({ texture: sheet.textureId }))).toBeNull();
|
||
});
|
||
});
|
||
|
||
describe('fitAxisToAspect', () => {
|
||
const size = { w: 64, h: 48 };
|
||
|
||
it('takes the other side as given and returns the proportional one', () => {
|
||
expect(fitAxisToAspect(size, props({ height: 60 }), 'width')).toBe(80);
|
||
expect(fitAxisToAspect(size, props({ width: 80 }), 'height')).toBe(60);
|
||
});
|
||
|
||
it('returns null when the side it would read from is unusable', () => {
|
||
expect(fitAxisToAspect(size, props({ height: 0 }), 'width')).toBeNull();
|
||
expect(fitAxisToAspect(size, props({ height: -5 }), 'width')).toBeNull();
|
||
expect(fitAxisToAspect(size, props({ width: Number.NaN }), 'height')).toBeNull();
|
||
});
|
||
});
|
||
|
||
describe('matchesAspect', () => {
|
||
it('compares proportions, not sizes', () => {
|
||
const size = { w: 64, h: 48 };
|
||
expect(matchesAspect(size, props({ width: 64, height: 48 }))).toBe(true);
|
||
expect(matchesAspect(size, props({ width: 8, height: 6 }))).toBe(true);
|
||
expect(matchesAspect(size, props({ width: 100, height: 100 }))).toBe(false);
|
||
});
|
||
|
||
it('holds after fitting one side, whatever the division left behind', () => {
|
||
// Measured, not assumed: of the 40 000 size pairs up to 200 × 200, 3135 do not survive the
|
||
// round trip through a division. 1 × 11 is one of them, and an exact comparison would call
|
||
// the sprite this very button just fitted not proportional – leaving the button standing.
|
||
const size = { w: 1, h: 11 };
|
||
const width = fitAxisToAspect(size, props({ height: 100 }), 'width')!;
|
||
expect(width * size.h).not.toBe(100 * size.w);
|
||
expect(matchesAspect(size, props({ width, height: 100 }))).toBe(true);
|
||
const height = fitAxisToAspect(size, props({ width }), 'height')!;
|
||
expect(height).not.toBe(100);
|
||
expect(matchesAspect(size, props({ width, height }))).toBe(true);
|
||
});
|
||
|
||
it('is false for a degenerate sprite rather than dividing by zero', () => {
|
||
const size = { w: 64, h: 48 };
|
||
expect(matchesAspect(size, props({ width: 0, height: 0 }))).toBe(false);
|
||
expect(matchesAspect(size, props({ width: Number.NaN, height: 10 }))).toBe(false);
|
||
});
|
||
});
|