From 9445f39c8df094588d59d868840f588cdb0b69c1 Mon Sep 17 00:00:00 2001 From: MelekTaus Date: Tue, 13 Oct 2026 14:46:38 +0200 Subject: [PATCH] Space the letters of a text object closer or wider apart The Feuerwerk wordmark is set with -0.03em tracking. Text in the engine had no letter spacing, so the wordmark came out about 3 % wider than the logo. - Text has a new property Letter spacing, default 0, in the same unit as the font size. It goes between each two characters, so ten characters get it nine times. Negative values pull them together. - TextPicture hands the value to Pixi's text style. Both rasters, the smooth and the sharp one, use it, and it is part of the raster key. - The estimated text width in bounds.ts adds the spacing too, so selection, picking and "Add collider" fit the drawn text. - Like every number property it can be animated and set from a graph. That is not tried in the browser. Tests: - New unit test for the footprint with spacing. 102 tests in the three text files green. - e2e 30f-ii measures the width of "Handgloves" on screen: 237 without spacing, 282 at +5 (expected +45) and 210 at -3 (expected -27). e2e 30 on melektaus-atx: ALL PASSED, 25 checks. - Gate on melektaus-atx: CHECK EXIT 0, 307 files, 4757 tests. - No counter-check run for 30f-ii. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- src/core/properties/PropertySchema.ts | 2 ++ src/core/properties/bounds.ts | 3 ++- src/viewport/textPicture.ts | 21 ++++++++++----------- tests/core/text-and-pointer.test.ts | 6 ++++++ tools/e2e/30-font.mjs | 13 +++++++++++++ 6 files changed, 34 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 1007a9df..4c190214 100644 --- a/README.md +++ b/README.md @@ -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, 2065 checks (GM_STEPS=20 for one step) +node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 2066 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 diff --git a/src/core/properties/PropertySchema.ts b/src/core/properties/PropertySchema.ts index 33a8cb4c..5e93b278 100644 --- a/src/core/properties/PropertySchema.ts +++ b/src/core/properties/PropertySchema.ts @@ -1068,6 +1068,8 @@ const ASSET_SCHEMAS: Record = { // Empty is the editor's own typeface, the one every text was drawn in before fonts existed. { key: FONT_KEY, label: 'Font', kind: 'asset', default: '', assetTypes: ['font'] }, { key: 'fontSize', label: 'Font size', kind: 'number', default: 32, min: 1, step: 1, decimals: 0 }, + // In units like the font size, added between two characters; negative draws them closer. + { key: 'letterSpacing', label: 'Letter spacing', kind: 'number', default: 0, step: 0.1, decimals: 1 }, { key: 'color', label: 'Color', kind: 'color', default: 0xffffff }, { key: 'align', diff --git a/src/core/properties/bounds.ts b/src/core/properties/bounds.ts index 8edf8195..12e699f1 100644 --- a/src/core/properties/bounds.ts +++ b/src/core/properties/bounds.ts @@ -39,8 +39,9 @@ export function textSize(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); + const spacing = Number(props.letterSpacing ?? TEXT_DEFAULTS.letterSpacing) || 0; return { - w: Math.max(size * CHAR_WIDTH, longest * size * CHAR_WIDTH), + w: Math.max(size * CHAR_WIDTH, longest * size * CHAR_WIDTH + Math.max(0, longest - 1) * spacing), h: lines.length * size * LINE_HEIGHT, }; } diff --git a/src/viewport/textPicture.ts b/src/viewport/textPicture.ts index 3ba12c2f..38ca608c 100644 --- a/src/viewport/textPicture.ts +++ b/src/viewport/textPicture.ts @@ -98,16 +98,21 @@ export class TextPicture { 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 color = Number(item.props.color ?? 0xffffff); const resolution = textResolution(scale, item.fontSmooth); - const key = JSON.stringify([text, family, size, color, resolution]); + const key = JSON.stringify([text, family, size, spacing, color, resolution]); shownTexts.set(this.mesh, text); const stale = key !== this.rasterKey; const previous = stale ? this.texture : null; if (stale) { this.rasterKey = key; - if (item.fontSmooth) this.raster(text, family, size, color, resolution); - else this.rasterSharp(text, family, size, color); + 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); } const texture = this.texture!; const worn = wornShader(item.props, shaders); @@ -162,10 +167,7 @@ export class TextPicture { this.texture = null; } - private raster(text: string, family: string, size: number, color: number, resolution: number): void { - this.style.fontFamily = family; - this.style.fontSize = size; - this.style.fill = color; + private raster(text: string, resolution: number): void { const { canvasAndContext, frame } = CanvasTextGenerator.getCanvasAndContext({ text, style: this.style, @@ -187,10 +189,7 @@ export class TextPicture { } /** A sharp face: rasterised `SHARP_SUPERSAMPLE` times finer and cut down to hard texels. */ - private rasterSharp(text: string, family: string, size: number, color: number): void { - this.style.fontFamily = family; - this.style.fontSize = size; - this.style.fill = color; + private rasterSharp(text: string, color: number): void { const { canvasAndContext, frame } = CanvasTextGenerator.getCanvasAndContext({ text, style: this.style, diff --git a/tests/core/text-and-pointer.test.ts b/tests/core/text-and-pointer.test.ts index 9c7e74c6..6999ac8c 100644 --- a/tests/core/text-and-pointer.test.ts +++ b/tests/core/text-and-pointer.test.ts @@ -11,6 +11,12 @@ describe('text objects', () => { expect(textSize({ text: 'ab', fontSize: 100 }).h).toBeCloseTo(125, 5); expect(footprintOf('text', { text: 'abcd', fontSize: 100 }).w).toBeCloseTo(220, 5); }); + + it('widens or narrows the footprint by the letter spacing between its characters', () => { + expect(textSize({ text: 'abcd\nab', fontSize: 100, letterSpacing: 10 }).w).toBeCloseTo(250, 5); + expect(textSize({ text: 'abcd', fontSize: 100, letterSpacing: -6 }).w).toBeCloseTo(202, 5); + expect(textSize({ text: 'a', fontSize: 100, letterSpacing: 10 }).w).toBeCloseTo(55, 5); + }); }); describe('the mouse', () => { diff --git a/tools/e2e/30-font.mjs b/tools/e2e/30-font.mjs index d83f802c..8d37cd14 100644 --- a/tools/e2e/30-font.mjs +++ b/tools/e2e/30-font.mjs @@ -205,6 +205,19 @@ export async function font(t) { JSON.stringify(dangling), ); + // 30f-ii. Letter spacing goes between the ten characters of 'Handgloves', so nine times. + const textOverrides = `__gm.state.activeScene.objects.find((o) => o.id === ${JSON.stringify(TEXT_OBJECT)}).overrides`; + await mutate(`${textOverrides}.letterSpacing = 5`); + const wide = await waitFor(info, (value) => value?.width !== plain.width); + await mutate(`${textOverrides}.letterSpacing = -3`); + const tight = await waitFor(info, (value) => value?.width !== wide?.width); + await mutate(`delete ${textOverrides}.letterSpacing`); + check( + 'letter spacing widens and narrows the text by the spacing between its characters', + Math.abs(wide?.width - plain.width - 45) <= 1 && Math.abs(tight?.width - plain.width + 27) <= 1, + JSON.stringify({ plain: plain.width, wide: wide?.width, tight: tight?.width }), + ); + // 30g. Dropping a font file onto the editor uploads it and makes an asset named after the file. const dropped = await evaluate(` (async () => {