Files
feuerwerk/tests/core/text-shader.test.ts
adminandClaude Opus 5.5 d4ff652293
feuerwerk / gate (push) Successful in 5m21s
feuerwerk / suite (push) Successful in 18m31s
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>
2026-10-13 15:18:59 +02:00

164 lines
7.8 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 });
});
});