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>
This commit is contained in:
1 parent
9445f39c8d
commit
d4ff652293
12 files changed
+284
-47
No files matched your search
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user