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>
164 lines
7.8 KiB
TypeScript
164 lines
7.8 KiB
TypeScript
/**
|
||
* A text wears a shader the way a sprite does (#169), over the rectangle of its rendered glyphs, and
|
||
* a font asset says how its glyphs are magnified, the way a texture does.
|
||
*/
|
||
import { describe, expect, it } from 'vitest';
|
||
import { CreateAssetCommand, SetAssetPropertyCommand } from '../../src/core/commands/assetCommands';
|
||
import { AddNodeCommand } from '../../src/core/commands/graphCommands';
|
||
import { SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands';
|
||
import { animatableDefsOf } from '../../src/core/model/animation';
|
||
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, textBox, textCut, textResolution } from '../../src/viewport/textPicture';
|
||
import { makeSpriteScene, place, roundTrip } from '../helpers';
|
||
|
||
function wearing(uniform = 'glow') {
|
||
const f = makeSpriteScene();
|
||
const text = new CreateAssetCommand('text', 'Title');
|
||
const shader = new CreateAssetCommand('shader', 'Glow');
|
||
f.stack.execute(text);
|
||
f.stack.execute(shader);
|
||
const declared = { name: uniform, default: 3, min: 0, max: 10 };
|
||
f.stack.execute(new AddNodeCommand(shader.asset.id, SHADER_PROPERTY, 0, 0, declared));
|
||
const textId = text.asset.id;
|
||
const shaderId = shader.asset.id;
|
||
const asset = () => findAssetOf(f.project, textId, 'text')!;
|
||
const keys = () => schemaForAsset(f.project, asset()).map((d) => d.key);
|
||
const wear = () => f.stack.execute(new SetAssetPropertyCommand(textId, SHADER_KEY, shaderId));
|
||
return { ...f, textId, shaderId, asset, keys, wear };
|
||
}
|
||
|
||
describe('a text wearing a shader', () => {
|
||
it('has the picker after its own rows, and the shader’s rows once one is picked', () => {
|
||
const f = wearing();
|
||
expect(schemaFor('text').at(-1)).toMatchObject({
|
||
key: SHADER_KEY,
|
||
kind: 'asset',
|
||
assetTypes: ['shader'],
|
||
default: '',
|
||
});
|
||
expect(f.keys()).not.toContain('glow');
|
||
f.wear();
|
||
expect(f.keys().at(-1)).toBe('glow');
|
||
expect(schemaForAsset(f.project, f.asset()).at(-1)).toMatchObject({ kind: 'number', default: 3 });
|
||
});
|
||
|
||
it('keeps a name it already has: `fontSize` stays the text’s own row', () => {
|
||
const f = wearing('fontSize');
|
||
f.wear();
|
||
expect(f.keys().filter((k) => k === 'fontSize')).toEqual(['fontSize']);
|
||
expect(schemaForAsset(f.project, f.asset()).find((d) => d.key === 'fontSize')).toMatchObject({
|
||
label: 'Font size',
|
||
default: 32,
|
||
});
|
||
});
|
||
|
||
it('gives a placed text the rows of the shader it overrides', () => {
|
||
const f = wearing();
|
||
const id = place(f, f.textId, { x: 0, y: 0 });
|
||
f.stack.execute(new SetOverrideCommand(f.sceneId, id, SHADER_KEY, f.shaderId));
|
||
f.stack.execute(new SetOverrideCommand(f.sceneId, id, 'glow', 99));
|
||
expect(schemaForObject(f.project, f.asset(), f.object(id)).map((d) => d.key)).toContain('glow');
|
||
// Through its row: clamped to the node's range.
|
||
expect(f.object(id).overrides.glow).toBe(10);
|
||
});
|
||
|
||
it('carries the shader and its values through a save, on the asset and on an override', () => {
|
||
const f = wearing();
|
||
f.wear();
|
||
f.stack.execute(new SetAssetPropertyCommand(f.textId, 'glow', 5));
|
||
const id = place(f, f.textId, { x: 0, y: 0 });
|
||
f.stack.execute(new SetOverrideCommand(f.sceneId, id, 'glow', 7));
|
||
const loaded = roundTrip(f.project);
|
||
expect(findAssetOf(loaded, f.textId, 'text')!.props).toMatchObject({ shader: f.shaderId, glow: 5 });
|
||
const placed = findAssetOf(loaded, f.sceneId, 'scene')!.objects.find((o) => o.id === id)!;
|
||
expect(placed.overrides).toEqual({ glow: 7 });
|
||
});
|
||
|
||
it('offers the shader’s parameters to an animation', () => {
|
||
const f = wearing();
|
||
f.wear();
|
||
expect(animatableDefsOf(f.project, f.asset()).map((d) => d.key)).toContain('glow');
|
||
});
|
||
});
|
||
|
||
describe('the filter of a font', () => {
|
||
it('is a row of the font asset, smooth by default', () => {
|
||
expect(schemaFor('font')).toEqual([expect.objectContaining({ key: 'filter', default: 'linear' })]);
|
||
});
|
||
|
||
it('tells whether the text wearing the font is drawn smooth', () => {
|
||
const f = makeSpriteScene();
|
||
const font = new CreateAssetCommand('font', 'Pixel');
|
||
f.stack.execute(font);
|
||
const id = font.asset.id;
|
||
expect(fontSmoothOf(f.project, id)).toBe(true);
|
||
f.stack.execute(new SetAssetPropertyCommand(id, 'filter', 'nearest'));
|
||
expect(fontSmoothOf(f.project, id)).toBe(false);
|
||
// The editor's own face, and one that is gone, stay smooth.
|
||
expect(fontSmoothOf(f.project, '')).toBe(true);
|
||
expect(fontSmoothOf(f.project, 'gone')).toBe(true);
|
||
expect(findAssetOf(roundTrip(f.project), id, 'font')!.props.filter).toBe('nearest');
|
||
});
|
||
|
||
it('rasterises a sharp face at one pixel per unit, a smooth one with the zoom', () => {
|
||
expect([0.5, 1, 1.37, 2, 3].map((scale) => textResolution(scale, false))).toEqual([1, 1, 1, 1, 1]);
|
||
expect([0.5, 1, 1.37, 2, 3].map((scale) => textResolution(scale, true))).toEqual([1, 2, 3, 4, 4]);
|
||
});
|
||
|
||
it('cuts a finely rasterised face down to hard texels without half coverage', () => {
|
||
// 4×2 source at factor 2 → 2×1: the left block carries a smoothed edge (Windows' DirectWrite look)
|
||
// with 3/4 ink, the right one only a fringe of 1/4 – kept and dropped.
|
||
const alpha = [255, 255, 96, 0, 255, 160, 0, 64];
|
||
const src = new Uint8ClampedArray(alpha.flatMap((a) => [255, 255, 255, a]));
|
||
const sharp = sharpCoverage(src, 4, 2, 2, 0x336699);
|
||
expect([sharp.width, sharp.height]).toEqual([2, 1]);
|
||
expect([...sharp.data]).toEqual([0x33, 0x66, 0x99, 255, 0, 0, 0, 0]);
|
||
});
|
||
|
||
it('counts the part of a border block beyond the source as empty', () => {
|
||
// 3×1 at factor 2, fully inked: the first block holds two real texels of four (half – kept), the
|
||
// second only one – under half, dropped.
|
||
const src = new Uint8ClampedArray([0, 0, 0, 255, 0, 0, 0, 255, 0, 0, 0, 255]);
|
||
const sharp = sharpCoverage(src, 3, 1, 2, 0xffffff);
|
||
expect([sharp.width, sharp.height]).toEqual([2, 1]);
|
||
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 });
|
||
});
|
||
});
|