A text was drawn into a texture as tall as Pixi measures the string "|ÉqÅM". Whatever reached further, like the "g" of Manrope (0.015 em below the "q"), was cut off at the bottom. The blue frame around a text was only estimated from the font size and the number of letters, so it was far too wide for most texts. - textPicture.ts takes the height of a line from the font itself (fontBoundingBoxAscent and fontBoundingBoxDescent, as a browser does). Pixi draws with a margin, and the picture is cut to that box afterwards. Where Pixi's own box is larger, Pixi's box wins. - Both raster paths (linear and nearest) use the cut. - The height no longer depends on the letters typed, so a text does not jump while it is edited. - bounds.ts asks a measurer that textPicture.ts registers (setTextMeasure). Selection, picking in a running game, framing the camera, the drop preview and culling all get the measured size. The footprint functions take the font file for this. - Unit tests in node have no canvas and keep the estimate (estimatedTextSize). - Texts get taller: Source Sans 3 at 48 px is 68 instead of 55 px, Manrope is 1.366 em instead of 1.25 em. The glyphs stay in place, the centre moves by less than half a pixel. A shader on a text gets a taller UV rectangle. Tests: - Gate on melektaus-atx green: 307 files, 4762 tests. - 5 new unit tests for textBox, textCut and the measurer hook. - e2e 30: 26 checks, 30f-iii new. The texture is 68 high, the font box is 68, the frame equals the drawn picture (237x68). - Counter test: with Pixi's old box the texture is 55 high and 30f-iii is red. - Not checked in the browser: the Manrope caption of the user and texts with several lines. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
84 lines
3.9 KiB
TypeScript
84 lines
3.9 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
||
import { CreateAssetCommand } from '../../src/core/commands/assetCommands';
|
||
import { AddColliderCommand } from '../../src/core/commands/colliderCommands';
|
||
import { SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands';
|
||
import { footprintContains, footprintOf, setTextMeasure, textSize } from '../../src/core/properties/bounds';
|
||
import { IDENTITY } from '../../src/core/model/matrix';
|
||
import { makeSpriteScene, place, startWorld } from '../helpers';
|
||
|
||
describe('text objects', () => {
|
||
it('estimates a text footprint from its content and font size where nothing measures', () => {
|
||
expect(textSize({ text: 'ab', fontSize: 100 }).w).toBeCloseTo(110, 5);
|
||
expect(textSize({ text: 'ab', fontSize: 100 }).h).toBeCloseTo(125, 5);
|
||
expect(footprintOf('text', { text: 'abcd', fontSize: 100 }).w).toBeCloseTo(220, 5);
|
||
});
|
||
|
||
it('widens or narrows the footprint by the letter spacing between its characters', () => {
|
||
expect(textSize({ text: 'abcd\nab', fontSize: 100, letterSpacing: 10 }).w).toBeCloseTo(250, 5);
|
||
expect(textSize({ text: 'abcd', fontSize: 100, letterSpacing: -6 }).w).toBeCloseTo(202, 5);
|
||
expect(textSize({ text: 'a', fontSize: 100, letterSpacing: 10 }).w).toBeCloseTo(55, 5);
|
||
});
|
||
|
||
it('takes the measured size, in the text’s own font, once something measures', () => {
|
||
const asked: string[] = [];
|
||
setTextMeasure((props, font) => {
|
||
asked.push(font);
|
||
return props.text === 'unmeasured' ? null : { w: 300, h: 80 };
|
||
});
|
||
try {
|
||
expect(footprintOf('text', { text: 'Ag', fontSize: 100 }, null, 'face.ttf')).toEqual({ w: 300, h: 80 });
|
||
expect(
|
||
footprintContains('text', { text: 'Ag', align: 'center' }, IDENTITY, { x: 140, y: 0 }, 'a.ttf'),
|
||
).toBe(true);
|
||
expect(footprintContains('text', { text: 'Ag', align: 'center' }, IDENTITY, { x: 0, y: 45 })).toBe(
|
||
false,
|
||
);
|
||
expect(asked).toEqual(['face.ttf', 'a.ttf', '']);
|
||
expect(textSize({ text: 'unmeasured', fontSize: 100 }).h).toBeCloseTo(125, 5);
|
||
} finally {
|
||
setTextMeasure(null);
|
||
}
|
||
});
|
||
});
|
||
|
||
describe('the mouse', () => {
|
||
it('hit-tests an object through its colliders, else through its sprites and texts', () => {
|
||
const f = makeSpriteScene('Button');
|
||
const { stack, sceneId } = f;
|
||
const button = place(f, f.spriteId, { x: 0, y: 0 });
|
||
const label = new CreateAssetCommand('text', 'Label');
|
||
stack.execute(label);
|
||
place(f, label.asset.id, { x: 0, y: 200 }, button);
|
||
|
||
const { world } = startWorld(f);
|
||
const entity = world.findByObjectId(button)!;
|
||
expect(world.hitTest(entity, 10, 10)).toBe(true); // inside the 100×100 sprite
|
||
expect(world.hitTest(entity, 10, 200)).toBe(true); // on the label parented below it
|
||
expect(world.hitTest(entity, 400, 400)).toBe(false);
|
||
|
||
// A collider takes over the hit test for the whole subtree.
|
||
const collider = new AddColliderCommand(sceneId, button);
|
||
stack.execute(collider);
|
||
stack.execute(new SetOverrideCommand(sceneId, collider.objectId, 'width', 20));
|
||
world.syncFromModel();
|
||
expect(world.hitTest(entity, 5, 5)).toBe(true);
|
||
expect(world.hitTest(entity, 40, 0)).toBe(false); // outside the 20-wide collider
|
||
expect(world.hitTest(entity, 10, 200)).toBe(false); // the label no longer answers
|
||
|
||
// Switched off, nothing of it can be hit.
|
||
entity.props.enabled = false;
|
||
expect(world.hitTest(entity, 5, 5)).toBe(false);
|
||
});
|
||
|
||
it('reports pointer edges for exactly one tick', () => {
|
||
const { world } = startWorld(makeSpriteScene());
|
||
world.movePointer(12, 34, true);
|
||
world.setPointerDown(true);
|
||
expect(world.pointer).toMatchObject({ x: 12, y: 34, inside: true, down: true, pressed: true });
|
||
world.tick(0.1);
|
||
expect(world.pointer).toMatchObject({ down: true, pressed: false, released: false });
|
||
world.setPointerDown(false);
|
||
expect(world.pointer).toMatchObject({ down: false, released: true });
|
||
});
|
||
});
|