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
@@ -47,7 +47,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh
|
||||
The browser suite and the three example games need a machine with a GPU:
|
||||
|
||||
```bash
|
||||
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 2066 checks (GM_STEPS=20 for one step)
|
||||
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 2067 checks (GM_STEPS=20 for one step)
|
||||
# every tool signs in first – see the Accounts page
|
||||
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
|
||||
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -63,7 +63,7 @@ export abstract class FreeViewport extends WorldViewport<EditorState> {
|
||||
protected frameContent(): void {
|
||||
const { items } = this.collectFrame();
|
||||
const boxes = items.map((item) => {
|
||||
const e = footprintExtent(item.type, item.props, item.m, item.frame);
|
||||
const e = footprintExtent(item.type, item.props, item.m, item.frame, item.font);
|
||||
return { id: item.id, x: e.left, y: e.top, w: e.right - e.left, h: e.bottom - e.top };
|
||||
});
|
||||
const { minX, minY, maxX, maxY } = boundsOf(boxes, { minX: 0, minY: 0, maxX: 0, maxY: 0 })!;
|
||||
|
||||
@@ -20,11 +20,13 @@ import {
|
||||
linearOf,
|
||||
type Mat,
|
||||
} from '../../core/model/matrix';
|
||||
import { fontFileOf } from '../../core/model/font';
|
||||
import { placementRejection } from '../../core/model/placement';
|
||||
import type { Position, Vec2 } from '../../core/model/Properties';
|
||||
import { findAsset, type ObjectHolder } from '../../core/model/Project';
|
||||
import { cellAtPoint } from '../../core/model/tilemap';
|
||||
import { footprintCorners, iconCorners } from '../../core/properties/bounds';
|
||||
import { fontOf } from '../../core/properties/fonts';
|
||||
import { localMatrixOf, parentTransform, transformOf } from '../../core/properties/transform';
|
||||
import { FramePreview, isDragFrame } from '../framePreview';
|
||||
import { panelPolicy } from '../playThrottle';
|
||||
@@ -1138,9 +1140,10 @@ export class SceneViewport extends FreeViewport {
|
||||
if (!preview || !asset) return;
|
||||
const color = preview.rejection ? Theme.colors.danger : Theme.colors.accent;
|
||||
const m = localMatrixOf(preview.at, transformOf(asset.props));
|
||||
const font = fontFileOf(this.state.project, fontOf(asset.props));
|
||||
const centre = worldToScreen(view, preview.at.x, preview.at.y);
|
||||
this.gizmos
|
||||
.poly(this.screenCorners(footprintCorners(asset.type, asset.props, m), view), true)
|
||||
.poly(this.screenCorners(footprintCorners(asset.type, asset.props, m, 0, null, font), view), true)
|
||||
.fill({ color, alpha: 0.12 })
|
||||
.stroke({ width: 2, color });
|
||||
this.gizmos
|
||||
@@ -1216,7 +1219,7 @@ export class SceneViewport extends FreeViewport {
|
||||
return this.screenCorners(
|
||||
spot
|
||||
? iconCorners(item.type, item.props, spot, this.symbolScale(), pad / view.scale)
|
||||
: footprintCorners(item.type, item.props, item.m, pad / view.scale, item.frame),
|
||||
: footprintCorners(item.type, item.props, item.m, pad / view.scale, item.frame, item.font),
|
||||
view,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -143,7 +143,7 @@ export class SizeGizmoEditor {
|
||||
const frame = contentFrame(item.m, this.contentCorners(item.id));
|
||||
if (frame) return frame;
|
||||
}
|
||||
return footprintCorners(item.type, item.props, item.m, 0, item.frame);
|
||||
return footprintCorners(item.type, item.props, item.m, 0, item.frame, item.font);
|
||||
}
|
||||
|
||||
/** The corners of everything below a container that has a shape of its own, at any depth. */
|
||||
@@ -157,7 +157,7 @@ export class SizeGizmoEditor {
|
||||
};
|
||||
return items
|
||||
.filter((i) => !SHAPELESS.has(i.type) && below(i))
|
||||
.flatMap((i) => footprintCorners(i.type, i.props, i.m, 0, i.frame));
|
||||
.flatMap((i) => footprintCorners(i.type, i.props, i.m, 0, i.frame, i.font));
|
||||
}
|
||||
|
||||
private screenBox(item: DrawItem, view: View): { corners: Vec2[]; base: Vec2; knob: Vec2 } {
|
||||
@@ -184,7 +184,7 @@ export class SizeGizmoEditor {
|
||||
e.stopPropagation();
|
||||
const tree = resolvedHolder(state.project, holder);
|
||||
const parent = parentTransform(state.project, tree, ref.object.parentId);
|
||||
const size = footprintOf(item.type, item.props, item.frame);
|
||||
const size = footprintOf(item.type, item.props, item.frame, item.font);
|
||||
const overrides: Record<string, PropertyValue | undefined> = {};
|
||||
for (const key of ['width', 'height', 'radius', 'rotation', 'scale'])
|
||||
overrides[key] = ref.object.overrides[key];
|
||||
|
||||
@@ -19,10 +19,12 @@ import { descendantsBelow } from '../core/model/walk';
|
||||
import { isOverlayCamera } from '../core/model/renderLayers';
|
||||
import { findAsset, findAssetOf, findScene, type Project } from '../core/model/Project';
|
||||
import { createSceneObject, objectName, type SceneObject } from '../core/model/SceneObject';
|
||||
import { fontFileOf } from '../core/model/font';
|
||||
import { apply, IDENTITY } from '../core/model/matrix';
|
||||
import { hasTransformProps } from '../core/properties/PropertySchema';
|
||||
import { carriedTransform, localMatrixOf, transformOf } from '../core/properties/transform';
|
||||
import { footprintContains } from '../core/properties/bounds';
|
||||
import { fontOf } from '../core/properties/fonts';
|
||||
import { pointerThrough } from '../core/properties/camera';
|
||||
import { colliderFrame, colliderShape, shapeContains } from '../core/properties/collider';
|
||||
import { resolveSceneObjectProps } from '../core/properties/resolve';
|
||||
@@ -606,7 +608,13 @@ export class World {
|
||||
return parts.some(
|
||||
(e) =>
|
||||
(e.type === 'sprite' || e.type === 'text') &&
|
||||
footprintContains(e.type, e.props, worldMatrixOf(e), point),
|
||||
footprintContains(
|
||||
e.type,
|
||||
e.props,
|
||||
worldMatrixOf(e),
|
||||
point,
|
||||
fontFileOf(this.project, fontOf(e.props)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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({
|
||||
|
||||
@@ -2,11 +2,12 @@ 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 { footprintOf, textSize } from '../../src/core/properties/bounds';
|
||||
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('measures a text footprint from its content and font size', () => {
|
||||
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);
|
||||
@@ -17,6 +18,27 @@ describe('text objects', () => {
|
||||
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', () => {
|
||||
|
||||
@@ -11,7 +11,7 @@ import { fontSmoothOf } from '../../src/core/model/font';
|
||||
import { findAssetOf } from '../../src/core/model/Project';
|
||||
import { schemaFor, schemaForAsset, schemaForObject } from '../../src/core/properties/PropertySchema';
|
||||
import { SHADER_KEY, SHADER_PROPERTY } from '../../src/core/properties/shaders';
|
||||
import { sharpCoverage, textResolution } from '../../src/viewport/textPicture';
|
||||
import { sharpCoverage, textBox, textCut, textResolution } from '../../src/viewport/textPicture';
|
||||
import { makeSpriteScene, place, roundTrip } from '../helpers';
|
||||
|
||||
function wearing(uniform = 'glow') {
|
||||
@@ -127,3 +127,37 @@ describe('the filter of a font', () => {
|
||||
expect([...sharp.data].filter((_, i) => i % 4 === 3)).toEqual([255, 0]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the box a text is drawn in', () => {
|
||||
// Manrope at 2000 units per em: Pixi's "|ÉqÅM" reaches 2019 up and 480 down, the face declares
|
||||
// 2132 and 600, and the tail of its "g" reaches 510 down.
|
||||
const pixi = { width: 812.4, lineHeight: 2499, lines: 1, ascent: 2019, descent: 480 };
|
||||
const face = { ascent: 2132, descent: 600 };
|
||||
|
||||
it('is as tall as the face’s own line box, so the tail of a "g" is inside', () => {
|
||||
const box = textBox(pixi, face);
|
||||
expect(box).toEqual({ width: 813, height: 2732, top: -113, room: 120 });
|
||||
// The baseline lies `ascent - top` below the box's top edge, and the "g" ends 510 below that.
|
||||
expect(pixi.ascent - box.top + 510).toBeLessThanOrEqual(box.height);
|
||||
});
|
||||
|
||||
it('keeps Pixi’s box wherever that one is the taller, and all of it without a face box', () => {
|
||||
expect(textBox(pixi, { ascent: 1800, descent: 700 })).toMatchObject({
|
||||
height: 2019 + 700,
|
||||
top: 0,
|
||||
room: 220,
|
||||
});
|
||||
expect(textBox(pixi, null)).toEqual({ width: 813, height: 2499, top: 0, room: 0 });
|
||||
});
|
||||
|
||||
it('spaces lines as Pixi does and adds the face’s reach above the first and below the last', () => {
|
||||
expect(textBox({ ...pixi, lines: 3 }, face).height).toBe(2132 + 2 * 2499 + 600);
|
||||
});
|
||||
|
||||
it('is cut out of the padded canvas on whole pixels, or on whole units for a sharp face', () => {
|
||||
const box = { width: 100, height: 10.4, top: -0.6, room: 1 };
|
||||
expect(textCut(box, 2, 2)).toEqual({ x: 2, y: 0, w: 200, h: 22 });
|
||||
expect(textCut({ ...box, top: -0.2 }, 2, 2)).toEqual({ x: 2, y: 1, w: 200, h: 22 });
|
||||
expect(textCut(box, 8, 1)).toEqual({ x: 8, y: 0, w: 800, h: 88 });
|
||||
});
|
||||
});
|
||||
@@ -218,6 +218,26 @@ export async function font(t) {
|
||||
JSON.stringify({ plain: plain.width, wide: wide?.width, tight: tight?.width }),
|
||||
);
|
||||
|
||||
// 30f-iii. A text is as tall as its face's own line box, not as Pixi's "|ÉqÅM" reaches, so a
|
||||
// tail like Manrope's "g" stays whole – and the frame around it is the drawn picture.
|
||||
const upright = await waitFor(info, (value) => value?.width === plain.width);
|
||||
const lineBox = await evaluate(`(() => {
|
||||
const context = new OffscreenCanvas(1, 1).getContext('2d');
|
||||
context.font = '48px ' + ${JSON.stringify(upright?.family ?? '')};
|
||||
const metrics = context.measureText('M');
|
||||
return metrics.fontBoundingBoxAscent + metrics.fontBoundingBoxDescent;
|
||||
})()`);
|
||||
const framed = await json(`__gm.objectRect(${JSON.stringify(TEXT_OBJECT)})`);
|
||||
const frameScale = framed ? framed.w / upright.width : 0;
|
||||
check(
|
||||
'a text is as tall as its face’s line box, with no glyph cut at its top or bottom, and framed as drawn',
|
||||
Math.abs(upright?.height - lineBox) <= 1 &&
|
||||
upright?.edgeInk.top === 0 &&
|
||||
upright?.edgeInk.bottom === 0 &&
|
||||
Math.abs(framed?.h / frameScale - upright?.height) <= 1,
|
||||
JSON.stringify({ upright, lineBox, framed }),
|
||||
);
|
||||
|
||||
// 30g. Dropping a font file onto the editor uploads it and makes an asset named after the file.
|
||||
const dropped = await evaluate(`
|
||||
(async () => {
|
||||
|
||||
Reference in new issue
Block a user