Files
feuerwerk/tests/core/text-and-pointer.test.ts
T
adminandClaude Opus 5.5 d4ff652293
feuerwerk / gate (push) Successful in 5m21s
feuerwerk / suite (push) Successful in 18m31s
Keep descenders of a text whole and frame it at its drawn size
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>
2026-10-13 15:18:59 +02:00

84 lines
3.9 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 { 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 });
});
});