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

+4 -4
View File
@@ -371,7 +371,7 @@ export abstract class WorldViewport<S extends ViewSource = ViewSource> extends W
const view = this.currentView();
const item = this.lastItems.find((i) => i.id === objectId);
if (!item || !view) return null;
const extent = footprintExtent(item.type, item.props, item.m, item.frame);
const extent = footprintExtent(item.type, item.props, item.m, item.frame, item.font);
const local = worldToScreen(view, (extent.left + extent.right) / 2, (extent.top + extent.bottom) / 2);
const origin = this.getGlobalPosition();
return {
@@ -434,8 +434,8 @@ export abstract class WorldViewport<S extends ViewSource = ViewSource> extends W
const seen = new Set<string>();
for (const item of items) {
seen.add(item.id);
const size = footprintOf(item.type, item.props, item.frame);
const extent = footprintExtent(item.type, item.props, item.m, item.frame);
const size = footprintOf(item.type, item.props, item.frame, item.font);
const extent = footprintExtent(item.type, item.props, item.m, item.frame, item.font);
// A switched-off object is not drawn at all, in the scene view as in the game; it is
// selected and switched back on from the hierarchy panel.
const unbound = isUnbound(item, shaders);
@@ -512,7 +512,7 @@ export abstract class WorldViewport<S extends ViewSource = ViewSource> extends W
if (!videoDrivenOf(item.props)) return false;
const key = drivenVideoKey(file, this.videoWearer(item));
if (this.videosShown.get(item.id) === key) return false;
const extent = footprintExtent(item.type, item.props, item.m, item.frame);
const extent = footprintExtent(item.type, item.props, item.m, item.frame, item.font);
if (!isUnbound(item, shaders) && !rectsIntersect(extent, bounds)) return false;
// Worn here already, so the seek to the asked picture starts with the frame held.
this.wearVideo(item);
+1 -1
View File
@@ -158,7 +158,7 @@ function standsOnY(slot: Candidate): boolean {
*/
function feetOf({ item, row }: Candidate): number {
if (row === null) {
const box = footprintExtent(item.type, item.props, item.m, item.frame);
const box = footprintExtent(item.type, item.props, item.m, item.frame, item.font);
const line = item.type === 'sprite' ? (item.texture?.sortLine ?? 0) : 0;
const frameH = item.texture?.rect?.h ?? item.texture?.sourceHeight ?? 0;
return line > 0 && frameH > 0 ? box.bottom - (line / frameH) * (box.bottom - box.top) : box.bottom;
+147 -24
View File
@@ -4,9 +4,9 @@
* UV run over the rectangle of the rendered text. Pixi's own `Text` takes no shader, and it shares
* one texture between texts of the same content and style, whatever filter each of them wants.
*/
import { CanvasSource, CanvasTextGenerator, type Mesh, Texture, TextStyle } from 'pixi.js';
import { textOf } from '../core/model/Properties';
import { textAnchor } from '../core/properties/bounds';
import { CanvasSource, CanvasTextGenerator, CanvasTextMetrics, type Mesh, Texture, TextStyle } from 'pixi.js';
import { type PropertyBag, textOf } from '../core/model/Properties';
import { setTextMeasure, textAnchor } from '../core/properties/bounds';
import { type EdgeMode, SHEET_EDGE } from '../core/shader/nodes';
import { Theme } from '../ui';
import { fontLibrary } from './FontLibrary';
@@ -71,6 +71,106 @@ export function sharpCoverage(
return { data, width: w, height: h };
}
/** Where a text lies on the canvas Pixi draws it on, in world units – see `textBox`. */
export interface TextBox {
width: number;
height: number;
/** The box's top edge, from the top of Pixi's own (unpadded) box; zero or above it. */
top: number;
/** The padding Pixi has to draw with, so that the box lies inside its canvas. */
room: number;
}
/** The faces' own line boxes, by CSS font string. */
const faceBoxes = new Map<string, { ascent: number; descent: number }>();
let faceProbe: OffscreenCanvasRenderingContext2D | null = null;
function faceBox(font: string): { ascent: number; descent: number } | null {
const known = faceBoxes.get(font);
if (known) return known;
faceProbe ??= new OffscreenCanvas(1, 1).getContext('2d');
if (!faceProbe) return null;
faceProbe.font = font;
const metrics = faceProbe.measureText('M');
// Missing in a browser that predates the line box; Pixi's own box then stands alone.
if (!Number.isFinite(metrics.fontBoundingBoxAscent)) return null;
const box = { ascent: metrics.fontBoundingBoxAscent, descent: metrics.fontBoundingBoxDescent };
faceBoxes.set(font, box);
return box;
}
/**
* Pixi makes a line as tall as "|ÉqÅM" reaches above and below the baseline, and whatever reaches
* further is off its canvas – the tail of Manrope's "g" was. A text takes the line box its face
* declares (`fontBoundingBox…`) instead, or Pixi's where that one is taller, so every glyph that
* keeps to its face's metrics is whole, and the height does not change with what is typed.
*/
export function textBox(
pixi: { width: number; lineHeight: number; lines: number; ascent: number; descent: number },
face: { ascent: number; descent: number } | null,
): TextBox {
const above = Math.max(pixi.ascent, face?.ascent ?? 0);
const below = Math.max(pixi.descent, face?.descent ?? 0);
return {
width: Math.ceil(Math.max(1, pixi.width)),
height: above + (pixi.lines - 1) * pixi.lineHeight + below,
top: pixi.ascent - above,
room: Math.ceil(Math.max(above - pixi.ascent, below - pixi.descent)),
};
}
function measureBox(text: string, style: TextStyle): TextBox {
const measured = CanvasTextMetrics.measureText(text || ' ', style);
const { ascent, descent } = measured.fontProperties;
const pixi = { width: measured.width, lineHeight: measured.lineHeight, lines: measured.lines.length };
return textBox({ ...pixi, ascent, descent }, faceBox(style._fontString));
}
/**
* The box's rectangle in the pixels of a canvas drawn at `resolution` with the box's `room` as
* padding. Its edges fall on whole steps of `1 / snap` world units: a pixel for a smooth face, a
* whole unit for a sharp one, whose blocks of `resolution` pixels each make a texel.
*/
export function textCut(
box: TextBox,
resolution: number,
snap: number,
): { x: number; y: number; w: number; h: number } {
const step = resolution / snap;
const top = Math.floor((box.room + box.top) * snap);
const bottom = Math.ceil((box.room + box.top + box.height) * snap);
return { x: box.room * resolution, y: top * step, w: box.width * resolution, h: (bottom - top) * step };
}
/** Sets what a text's props say about its glyphs, `family` being its face as the browser knows it. */
function styleText(style: TextStyle, props: PropertyBag, family: string): string {
style.fontFamily = family;
style.fontSize = Math.max(1, Number(props.fontSize ?? 32));
style.letterSpacing = Number(props.letterSpacing ?? 0) || 0;
return textOf(props.text ?? '');
}
const measureStyle = new TextStyle({ fontFamily: Theme.font, fontSize: 32 });
const measuredTexts = new Map<string, { w: number; h: number }>();
/**
* A text's footprint is the box its picture is cut to, so the frame around it, a click on it and
* the camera's framing all go by what is drawn rather than by an estimate. Without a canvas – a
* unit test in node – there is nothing to measure with, and the estimate stands.
*/
setTextMeasure((props, font) => {
if (typeof OffscreenCanvas === 'undefined') return null;
const text = styleText(measureStyle, props, fontLibrary.family(font) || Theme.font);
const key = JSON.stringify([text, measureStyle._fontString, measureStyle.letterSpacing]);
const known = measuredTexts.get(key);
if (known) return known;
const box = measureBox(text, measureStyle);
const size = { w: box.width, h: box.height };
if (measuredTexts.size >= 1000) measuredTexts.clear();
measuredTexts.set(key, size);
return size;
});
/**
* The text each picture shows, by its mesh. The mesh is a sprite quad and carries no text, so the test
* hook reads a game text through here – otherwise `locate` finds only the editor's own labels.
@@ -95,21 +195,15 @@ export class TextPicture {
sync(item: DrawItem, scale: number, shaders: ShaderSource, grid: number | null = null): void {
// An uploaded face that has not arrived yet reads as `''`; until it does, the editor's own
// typeface stands in, and the library's change event brings the redraw that swaps it.
const family = fontLibrary.family(item.font) || Theme.font;
const text = textOf(item.props.text ?? '');
const size = Math.max(1, Number(item.props.fontSize ?? 32));
const spacing = Number(item.props.letterSpacing ?? 0) || 0;
const text = styleText(this.style, item.props, fontLibrary.family(item.font) || Theme.font);
const color = Number(item.props.color ?? 0xffffff);
const resolution = textResolution(scale, item.fontSmooth);
const key = JSON.stringify([text, family, size, spacing, color, resolution]);
const key = JSON.stringify([text, this.style._fontString, this.style.letterSpacing, color, resolution]);
shownTexts.set(this.mesh, text);
const stale = key !== this.rasterKey;
const previous = stale ? this.texture : null;
if (stale) {
this.rasterKey = key;
this.style.fontFamily = family;
this.style.fontSize = size;
this.style.letterSpacing = spacing;
this.style.fill = color;
if (item.fontSmooth) this.raster(text, resolution);
else this.rasterSharp(text, color);
@@ -129,36 +223,48 @@ export class TextPicture {
}
/**
* What a check reads: the face, the width before the view's zoom, how the glyphs are sampled, and
* how many texels of the picture are neither empty nor fully covered.
* What a check reads: the face, the size before the view's zoom, how the glyphs are sampled, how
* many texels of the picture are neither empty nor fully covered, and how many in its top and its
* bottom row are inked – a glyph cut off at an edge leaves ink there.
*/
info(): {
family: string;
width: number;
height: number;
resolution: number;
smooth: boolean;
shader: string;
partial: number;
edgeInk: { top: number; bottom: number };
} {
const source = this.texture?.source;
return {
family: String(this.style.fontFamily),
width: Math.round(Math.abs(this.mesh.scale.x)),
height: Math.round(Math.abs(this.mesh.scale.y)),
resolution: source?.resolution ?? 0,
smooth: source?.style.scaleMode !== 'nearest',
shader: wornShaderId(this.mesh),
partial: this.partialTexels(),
...this.texels(),
};
}
private partialTexels(): number {
private texels(): { partial: number; edgeInk: { top: number; bottom: number } } {
const canvas = this.texture?.source.resource as OffscreenCanvas | undefined;
const context = canvas?.getContext('2d');
if (!canvas || !context) return 0;
if (!canvas || !context) return { partial: 0, edgeInk: { top: 0, bottom: 0 } };
const { data } = context.getImageData(0, 0, canvas.width, canvas.height);
const row = canvas.width * 4;
let partial = 0;
for (let i = 3; i < data.length; i += 4) if (data[i]! > 0 && data[i]! < 255) partial++;
return partial;
let top = 0;
let bottom = 0;
for (let i = 3; i < data.length; i += 4) {
const alpha = data[i]!;
if (alpha > 0 && alpha < 255) partial++;
if (alpha > 0 && i < row) top++;
if (alpha > 0 && i >= data.length - row) bottom++;
}
return { partial, edgeInk: { top, bottom } };
}
destroy(): void {
@@ -168,18 +274,32 @@ export class TextPicture {
}
private raster(text: string, resolution: number): void {
const { canvasAndContext, frame } = CanvasTextGenerator.getCanvasAndContext({
const box = measureBox(text, this.style);
this.style.padding = box.room;
const { canvasAndContext } = CanvasTextGenerator.getCanvasAndContext({
text,
style: this.style,
resolution,
});
// The pooled canvas goes back at once and may be bigger than the text, so the text is copied onto
// a canvas of exactly its size: the quad's 0..1 UV are then the text's rectangle and nothing more.
const { width, height } = frame;
const cut = textCut(box, resolution, resolution);
const width = cut.w;
const height = cut.h;
const canvas = new OffscreenCanvas(width, height);
canvas
.getContext('2d')
?.drawImage(canvasAndContext.canvas as CanvasImageSource, 0, 0, width, height, 0, 0, width, height);
?.drawImage(
canvasAndContext.canvas as CanvasImageSource,
cut.x,
cut.y,
width,
height,
0,
0,
width,
height,
);
CanvasTextGenerator.returnCanvasAndContext(canvasAndContext);
this.texture = new Texture({
source: new CanvasSource({ resource: canvas, resolution, alphaMode: 'premultiply-alpha-on-upload' }),
@@ -190,14 +310,17 @@ export class TextPicture {
/** A sharp face: rasterised `SHARP_SUPERSAMPLE` times finer and cut down to hard texels. */
private rasterSharp(text: string, color: number): void {
const { canvasAndContext, frame } = CanvasTextGenerator.getCanvasAndContext({
const box = measureBox(text, this.style);
this.style.padding = box.room;
const { canvasAndContext } = CanvasTextGenerator.getCanvasAndContext({
text,
style: this.style,
resolution: SHARP_SUPERSAMPLE,
});
const fine = canvasAndContext.context.getImageData(0, 0, frame.width, frame.height).data;
const cut = textCut(box, SHARP_SUPERSAMPLE, 1);
const fine = canvasAndContext.context.getImageData(cut.x, cut.y, cut.w, cut.h).data;
CanvasTextGenerator.returnCanvasAndContext(canvasAndContext);
const sharp = sharpCoverage(fine, frame.width, frame.height, SHARP_SUPERSAMPLE, color);
const sharp = sharpCoverage(fine, cut.w, cut.h, SHARP_SUPERSAMPLE, color);
const canvas = new OffscreenCanvas(sharp.width, sharp.height);
canvas.getContext('2d')?.putImageData(new ImageData(sharp.data, sharp.width, sharp.height), 0, 0);
this.texture = new Texture({