Keep descenders of a text whole and frame it at its drawn size
feuerwerk / gate (push) Successful in 5m21s
feuerwerk / suite (push) Successful in 18m31s

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>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-13 15:18:59 +02:00
1 parent 9445f39c8d
commit d4ff652293
12 files changed
+284 -47

No files matched your search

+34 -7
View File
@@ -34,8 +34,25 @@ const JOINT_SYMBOL = 24;
const CHAR_WIDTH = 0.55;
const LINE_HEIGHT = 1.25;
/**
* How big a text is drawn, given its props and the file of its font – or null where nothing can be
* measured. The viewport sets it, since only a canvas can measure; tests in node estimate instead.
*/
export type TextMeasure = (props: PropertyBag, font: string) => { w: number; h: number } | null;
let textMeasure: TextMeasure | null = null;
export function setTextMeasure(measure: TextMeasure | null): void {
textMeasure = measure;
}
/** The text as drawn where it can be measured, `font` being its font's file; estimated otherwise. */
export function textSize(props: PropertyBag, font = ''): { w: number; h: number } {
return textMeasure?.(props, font) ?? estimatedTextSize(props);
}
/** Estimated from the content and font size, without measuring the text. */
export function textSize(props: PropertyBag): { w: number; h: number } {
export function estimatedTextSize(props: PropertyBag): { w: number; h: number } {
const size = Math.max(1, Number(props.fontSize ?? TEXT_DEFAULTS.fontSize));
const lines = textOf(props.text ?? TEXT_DEFAULTS.text).split('\n');
const longest = lines.reduce((max, line) => Math.max(max, line.length), 0);
@@ -54,19 +71,21 @@ export function textAnchor(props: PropertyBag): number {
/**
* Centred on the object's position; text is anchored instead (see `textAnchor`). A collider that
* wears a frame (#193) is that frame's size, which only the caller can look up.
* wears a frame (#193) is that frame's size, and a text is measured in its font's file – both of
* which only the caller can look up.
*/
export function footprintOf(
type: AssetType,
props: PropertyBag,
frame?: FrameOutline | null,
font = '',
): { w: number; h: number } {
switch (type) {
case 'sprite':
case 'clip':
return { w: Number(props.width ?? 1), h: Number(props.height ?? 1) };
case 'text':
return textSize(props);
return textSize(props, font);
case 'collider':
return colliderSize(props, frame);
case 'behavior':
@@ -113,9 +132,10 @@ export function footprintCorners(
m: Mat,
pad = 0,
frame?: FrameOutline | null,
font = '',
): Vec2[] {
const placed = placementOf(type, m);
const { w, h } = footprintOf(type, props, frame);
const { w, h } = footprintOf(type, props, frame, font);
const local = pad / (scaleOf(placed) || 1);
return boxCorners(placed, w + 2 * local, h + 2 * local, anchorOffset(type, props, w), 0);
}
@@ -142,12 +162,19 @@ export function footprintExtent(
props: PropertyBag,
m: Mat,
frame?: FrameOutline | null,
font = '',
): Extent {
return extentOfPoints(footprintCorners(type, props, m, 0, frame));
return extentOfPoints(footprintCorners(type, props, m, 0, frame, font));
}
export function footprintContains(type: AssetType, props: PropertyBag, m: Mat, point: Vec2): boolean {
const { w, h } = footprintOf(type, props);
export function footprintContains(
type: AssetType,
props: PropertyBag,
m: Mat,
point: Vec2,
font = '',
): boolean {
const { w, h } = footprintOf(type, props, null, font);
const local = apply(invert(placementOf(type, m)), point);
return Math.abs(local.x - anchorOffset(type, props, w)) <= w / 2 && Math.abs(local.y) <= h / 2;
}