diff --git a/CHANGELOG.md b/CHANGELOG.md index 80243f9e..cd28c7be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 #### Changed +- **text:** `verticalAlign: 'middle'` centers the band from the first line's cap height to the last line's baseline, instead of the string's rendered glyphs. A label no longer moves when its text changes ("LEVEL 1" and "QUIT", or a lowercase label gaining a "g"), and labels centered at the same point share a baseline. `createButton`, `createDropdown` and `createTooltip` labels move with it. If you offset a `'middle'` label by hand to put its capitals in the middle, remove the offset +- **text:** The font atlas generator measures `metrics.capHeight` on the reference capital's ink, without the distance field padding around its quad, and the shipped default font is updated (`capHeight` goes from `0.881` to `0.690` em). `verticalAlign: 'capline'` now puts the top of a capital exactly on the anchor, so `'capline'` text sits about `0.19` em (times `size`) higher than before; adjust positions you tuned around the old value. Regenerate your own atlases with `forge-generate-font-atlas` to correct their `capHeight` - **rendering:** `Color`'s red, green and blue are no longer clamped to `1`, so a tint can make a sprite brighter than its texture: a button can rest at `Color.white` and brighten on hover with a `hoverColor` such as `new Color(1.2, 1.2, 1.2)`, and on an `hdr` camera a sprite tinted `new Color(3, 3, 3)` blooms more than a white-tinted one. On the canvas or an 8-bit render target, each channel of the result still stops at full brightness. Negative channels are still clamped to `0` and alpha to `[0, 1]`, and `toRGBAString` writes channels above `1` as `255`. If you dimmed sprites below `1` at rest so they could brighten, tint them `Color.white` at rest and above `1` when brightened instead. If you relied on values above `1` being clamped (for example a color eased with an overshooting easing), clamp them yourself. `Color.fromHSLA` now throws for a saturation or lightness outside `0`-`100` - **ecs:** Entities are now generational handles, so a reference to a removed entity can never point at an unrelated entity that took its place. A handle packs a slot index and a generation into the same `number`; a reused slot gets a new handle, so the removed entity's handle stops matching anything: `getComponent` returns `null` for it, the new `EcsWorld.isAlive(entity)` returns `false`, and `removeEntity` on it does nothing and returns `false` (it now returns `true` when it removes an entity). Removing the same entity twice in a tick no longer hands its id to two later entities, and the least recently freed slot is reused first. An entity now stays alive until `removeEntity`: `removeComponent` no longer removes an entity whose last component it removed, so call `removeEntity` yourself if you relied on that. `addComponent` and `addTag` now throw for a removed entity (or a handle the world didn't create) instead of silently writing to it. Entities of reused slots are no longer small numbers, so don't do arithmetic on handles or use them as array indices. New exports from `ecs`: `entityIndex`, `entityGeneration` and `formatEntity`, for debugging; error messages that name an entity now print its index and generation (e.g. `12v3`) - **math:** Every angle and direction now follows one convention: radians, `0` along `+X`, positive turning towards `+Y` (counter-clockwise, since the world is Y-up). `Vec2.up` is now `(0, 1)` and `Vec2.down` is `(0, -1)`; if you used `Vec2.up` to mean "down the screen", use `Vec2.down`. `radiansToVector(angle)` now returns `(cos angle, sin angle)`, so `radiansToVector(0)` is `(1, 0)` and it's the inverse of `vectorToRadians`; drop any `+ Math.PI / 2` you added to round-trip between them, and add `- Math.PI / 2` when facing a direction with art drawn facing up. `applyExplosiveForce` and circle-circle collisions now push coincident bodies up rather than down diff --git a/assets/fonts/default/default.json b/assets/fonts/default/default.json index 5a426250..fe6f4423 100644 --- a/assets/fonts/default/default.json +++ b/assets/fonts/default/default.json @@ -10,7 +10,7 @@ "lineHeight": 1.0952380952380953, "ascender": 0.9285714285714286, "descender": -0.40476190476190477, - "capHeight": 0.8809523809523809 + "capHeight": 0.6904761904761905 }, "glyphs": [ { diff --git a/design/text-cap-height-centering.md b/design/text-cap-height-centering.md deleted file mode 100644 index 5ef708b9..00000000 --- a/design/text-cap-height-centering.md +++ /dev/null @@ -1,182 +0,0 @@ -# Design: Vertically Centered Text Centers Its Cap Height - -| | | -| ------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **Status** | Draft, for review | -| **Kind** | Defect | -| **Found in** | Galactic Journey demo: `src/ui/create-menu-button.ts` (label placed by hand "centered on its cap height"), `src/main-menu/create-controls-diagram.ts` (`textCenteredAt`), `titleBaseline` formulas in six panels (stats, flight history, pilot, leaderboard, how-to-play, settings) | -| **Engine version at time of writing** | `0.25.8` | -| **Related** | [`ui-system.md`](./ui-system.md) | - -## 0. Targeted modules - -| Path | Change | Notes | -| ------------------------------------------------------------------- | -------- | ------------------------------------------------------------------------------- | -| `scripts/generate-font-atlas.mjs` | Modified | `capHeight` measured without the distance-field padding | -| `assets/fonts/default/`, `documentation-site/static/fonts/default/` | Modified | Regenerated with the corrected `capHeight` | -| `src/text/utilities/shape-text.ts` | Modified | `'middle'` centers the band from the first line's cap line to the last baseline | -| `src/text/components/text-component.ts` | Modified | Doc comment | -| `e2e/fixtures/scenes/text-effects-overlap.ts` | Modified | Its synthetic atlas gains a `capHeight` | -| `documentation-site/docs/docs/text/`, docs demos | Modified | Vertical alignment | - ---- - -## 1. Summary - -`TextEcsComponent.verticalAlign: 'middle'` centers the string's actual -ink: the tallest and deepest glyphs it happens to contain. So the same -label shifts when its text changes ("LEVEL 1" and "QUIT" sit at -different heights, and a lowercase label jumps when a "g" appears), and -two buttons side by side don't line up. `createButton`, `createDropdown` -and `createTooltip` use `'middle'` for their labels, so Forge's own -controls have this. - -The demo's UI centers text the way designers expect, on the cap height: -the band between the baseline and the top of a capital. It does that by -hand, computing `baseline = center + capHeight * size / 2` in its menu -button (rebuilt rather than using `createButton`), in a -`textCenteredAt` helper, and in a `titleBaseline` formula in six panels. - -`'middle'` exists because the alternative it was compared with, centering -the font's ascender-to-descender box, sits too high for strings without -descenders. Centering the cap-height band fixes that too, and doesn't -depend on the string. - -One prerequisite: the cap height Forge stores is wrong. The atlas -generator takes the top of "H"'s plane bounds, which include the distance -field's padding around the glyph: in the default font, "H" spans from -0.881 em down to -0.190 em although it sits on the baseline, so the stored -cap height is about 0.19 em too tall. `'capline'` is off by that much -today, and cap-height centering would be off by half of it. The demo's -hand-written formulas carry the same error. - ---- - -## 2. Scope - -### In scope - -- Measuring `capHeight` without the padding, and regenerating the - shipped atlases. This moves `'capline'` too, to where it was meant to be. -- Redefining `'middle'` as cap-height centering, for one line and many. -- Migrating callers whose positions were tuned around ink centering. - -### Out of scope - -- **Centering on the x-height** for all-lowercase text. A cap-height - center sits a little low for it; no case in the demo or the docs needs - the alternative. -- **`'top'`, `'bottom'` and `'baseline'`.** They anchor to the ascender, - descender and baseline, which are documented as safe outer bounds and - are unchanged. - ---- - -## 3. How established engines and tools handle this - -- **TextMesh Pro** (Unity) has vertical alignment modes for both - behaviors: `Capline` centers the cap height in the box, and `Geometry` - (shown as "Midline") centers the text mesh's extents, which is what - Forge's `'middle'` does today. -- **CSS**: `text-box-trim` with `text-box-edge: cap alphabetic` trims a - line box to the cap height above and the alphabetic baseline below, so - ordinary centering then centers the cap band, independent of content. -- **Design tools** (Figma's vertical trim, "cap height to baseline") - offer the same box for exactly this reason: content-independent optical - centering, from the first line's cap height to the last line's - baseline. - ---- - -## 4. Design - -### 4.1 The metric - -The generator subtracts the padding it adds around every glyph (half the -distance range, converted to em) from the reference capital's plane-bounds -top when it computes `capHeight`. The two shipped atlases are regenerated. -`formatVersion` doesn't change: the field means the same thing, it's now -measured correctly. - -### 4.2 Centering - -For `verticalAlign: 'middle'`, the shaped block is centered on the band -from the first line's cap line (`capHeight * size` above its baseline) to -the last line's baseline: - -``` -offset = -(capTop + lastBaseline) / 2 -capTop = capHeight * size -lastBaseline = -(lineCount - 1) * lineHeight -``` - -It reads only font metrics and the line count, so a string's position no -longer depends on its glyphs. The ink-bounds computation used only by -`'middle'` (`getInkBounds`) is deleted. - -`createButton`'s label then sits where a designer would put it. The demo's -`textCenteredAt` and `titleBaseline` formulas become `'middle'`. Its menu -button still positions its label and sub-label together itself, since -they're two texts of different sizes centered as one block. - ---- - -## 5. Phases - -### Phase 1: Cap-height centering - -| # | Task | Size | -| --- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---- | -| 1.1 | Generator measures `capHeight` without padding; regenerate both shipped atlases | S | -| 1.2 | New `'middle'` offset; `getInkBounds` removed; tests for one and three lines | S | -| 1.3 | `text-effects-overlap` e2e scene: its synthetic atlas gets `capHeight`, and its centered-glyph assertion and comment are updated | S | -| 1.4 | Check the `'middle'` callers in `/src` (`createButton`, `createDropdown`, `createTooltip`) and the 11 docs demos that use it, visually | S | -| 1.5 | Text guide (`rendering-text.md`'s vertical alignment section), the vertical-alignment docs demo's description, `text-component.ts` and `shape-text.ts` comments; changelog under `#### Changed` | S | - -**Definition of done:** two labels with different text, centered at the -same point, share a baseline; `'capline'` touches the top of an "H". - ---- - -## 6. Decision log - -### DL-1: Change `'middle'` rather than add `'capMiddle'` - -**Options.** (a) Redefine `'middle'`. (b) Add a new value and keep ink -centering, as TextMesh Pro keeps its `Geometry` mode. - -**Decision: (a).** - -**Rationale.** Content-dependent centering is the defect: no UI wants a -label to move when its text changes, and nothing in the engine, the docs -demos or the demo uses ink centering on purpose. Keeping it would leave -the wrong behavior as the obvious choice. - -### DL-2: Fix the metric at the generator - -**Rationale.** `capHeight` is a font metric; the padding is an artifact of -how glyphs are stored in the atlas. Correcting it where it's measured -fixes `'capline'`, the new `'middle'` and every game that reads the -metric. - ---- - -## 7. Open questions - -None. - ---- - -## 8. Testing considerations - -- Generator: a glyph that sits on the baseline measures a cap height equal - to its ink top. -- `shapeText`: `'middle'` offsets for strings with and without - ascenders/descenders are equal; multi-line blocks center on the band. -- UI suites that assert label positions are updated to the new offsets. - -## 9. Documentation and demo follow-up - -- `text/` guide: what each vertical alignment anchors to. -- Demo: regenerate its font atlas; `textCenteredAt` and the - `titleBaseline` formulas become `verticalAlign: 'middle'`. diff --git a/documentation-site/docs/docs/text/loading-a-font-atlas.md b/documentation-site/docs/docs/text/loading-a-font-atlas.md index d7135b5b..b460e077 100644 --- a/documentation-site/docs/docs/text/loading-a-font-atlas.md +++ b/documentation-site/docs/docs/text/loading-a-font-atlas.md @@ -94,6 +94,12 @@ const glyph = fontAtlas.data.glyphs.get('A'.codePointAt(0)!); your desired render size to get world/screen units. `planeBounds` is **Y-up**, relative to the glyph's baseline, matching the rest of Forge's Y-up conventions. +- `planeBounds` include the distance field's padding around the glyph's + ink (half the `distanceRange`), because outlines and glows draw into + it. `ascender` and `descender` are measured from those padded bounds, so + they're safe outer bounds for everything a glyph renders. `capHeight` is + measured on the letter itself (a flat capital like "H"), without the + padding. - `atlasBounds` is the glyph's texture rect, normalized `0` to `1`, with `top` closer to the top of the atlas image than `bottom`. - Both `planeBounds` and `atlasBounds` are `null` for glyphs with no visible diff --git a/documentation-site/docs/docs/text/rendering-text.md b/documentation-site/docs/docs/text/rendering-text.md index 20a9d714..09a22177 100644 --- a/documentation-site/docs/docs/text/rendering-text.md +++ b/documentation-site/docs/docs/text/rendering-text.md @@ -131,26 +131,24 @@ addTextComponent(world, label, { [UI: Labels and Text](../ui/labels-and-text.md), which sets this automatically for its own stretch-anchored labels). - `verticalAlign` (`'top'` | `'middle'` | `'bottom'` | `'baseline'` | - `'capline'`, default `'top'`) positions the shaped block's visible ink - relative to the entity's position, not its line-height box (which - typically doesn't match the ink's own extent). - - `'top'`, `'bottom'`, `'baseline'`, and `'capline'` all anchor to a - fixed reference that doesn't depend on this specific string's rendered - bounds, so a line's position stays stable as its text is edited: - `'top'` anchors the font's ascender, so text hangs _below_ the - entity's position; `'bottom'` anchors the font's descender, so text - sits _above_ it; `'capline'` is `'top'` but anchored to the font's cap - height (the top of a capital letter like "H") instead of its ascender - (the top of the font's _tallest_ glyphs, including ascenders like - "b"/"d"/"h" that reach higher than a flat capital) - useful for a - title or label set in caps, where anchoring to the taller ascender - would leave a visible gap above the text; `'baseline'` anchors the - first line's own baseline directly, most useful for single-line text. - - `'middle'` instead centers this exact string's _actual_ rendered ink: a - font's ascender is typically taller than its descender is deep (most - glyphs have no descender at all), so centering on the font's metrics - would bias every descender-less string (numbers, titles, most short UI - labels) above the true visual center of its box. + `'capline'`, default `'top'`) positions the shaped block relative to the + entity's position. Every value anchors to the font's metrics and the + number of lines, never to the glyphs the string happens to contain, so a + label doesn't move when its text changes, and two labels aligned to the + same point share a baseline. + - `'top'` anchors the first line's ascender (the top of the font's + tallest glyphs, such as "b"/"d"/"h"), so text hangs _below_ the + entity's position. + - `'bottom'` anchors the last line's descender, so text sits _above_ it. + - `'capline'` anchors the first line's cap height, the top of a capital + letter like "H", so the top of a title set in caps touches the + position. + - `'baseline'` anchors the first line's baseline. + - `'middle'` centers the band from the first line's cap height to the last + line's baseline on the position. That's where a designer centers a + label in a button: capitals and digits sit exactly in the middle, and + descenders like "g"/"y" hang below the band. `createButton`, + `createDropdown` and `createTooltip` center their labels this way. - `lineHeight` (default `1`) multiplies the font atlas's own authored line height to control the vertical distance between line baselines. diff --git a/documentation-site/src/pages/demos/text/_create-vertical-alignment-examples.ts b/documentation-site/src/pages/demos/text/_create-vertical-alignment-examples.ts index 8717ed58..ac5acea9 100644 --- a/documentation-site/src/pages/demos/text/_create-vertical-alignment-examples.ts +++ b/documentation-site/src/pages/demos/text/_create-vertical-alignment-examples.ts @@ -40,9 +40,10 @@ const columns: { * block of text against a shared, highlighted anchor line at the same * entity position, so the difference between them is exactly what moves * relative to that line - `top` hangs below it, `bottom` sits above it, - * `middle` straddles it on this exact text's own rendered ink, `baseline` - * puts the first line's baseline directly on it, and `capline` hangs below - * it like `top` but from the shorter cap height instead of the ascender. + * `middle` centers the band from the first line's cap height to the last + * line's baseline on it, `baseline` puts the first line's baseline + * directly on it, and `capline` hangs below it like `top` but from the + * shorter cap height instead of the ascender. * @param world - The ECS world to add label entities to. * @param fontAtlas - The font atlas every label draws from. * @param whiteSprite - A plain white sprite template for the anchor lines. diff --git a/documentation-site/src/pages/demos/text/index.tsx b/documentation-site/src/pages/demos/text/index.tsx index 6c378b5e..c037efe0 100644 --- a/documentation-site/src/pages/demos/text/index.tsx +++ b/documentation-site/src/pages/demos/text/index.tsx @@ -157,7 +157,7 @@ export default function Text(): JSX.Element { 'A demo showcasing MSDF text rendering, multi-line layout, alignment, live reflow, an interactive playground, and outline/soft-shadow effects with addTextComponent and createTextShapingEcsSystem.', }} header="Text Rendering" - blurb="A showcase of MSDF text rendering using the engine's shipped default font atlas (Liberation Sans, SIL OFL 1.1 - zero font setup required): every horizontalAlign value (left/center/right/justify) wrapping the same sentence, every verticalAlign value (top/middle/bottom) positioned against a shared anchor line, a few lineHeight multipliers compared side by side, a paragraph whose maxWidth oscillates every frame (driving createTextShapingEcsSystem to reflow it live), an interactive playground you can type into using the controls above, and - at the bottom - outline/soft-shadow effects at a conservative, documented-safe size (see the Text Effects guide for why). Every guide box/line is sized from shapeText's own computed bounds, not guessed." + blurb="A showcase of MSDF text rendering using the engine's shipped default font atlas (Liberation Sans, SIL OFL 1.1 - zero font setup required): every horizontalAlign value (left/center/right/justify) wrapping the same sentence, every verticalAlign value (top/middle/bottom/baseline/capline) positioned against a shared anchor line, with middle centering the cap-height-to-baseline band, a few lineHeight multipliers compared side by side, a paragraph whose maxWidth oscillates every frame (driving createTextShapingEcsSystem to reflow it live), an interactive playground you can type into using the controls above, and - at the bottom - outline/soft-shadow effects at a conservative, documented-safe size (see the Text Effects guide for why). Every guide box/line is sized from shapeText's own computed bounds, not guessed." createGame={createGame} interactions={ => { disabledColor: new Color(0.6, 0.6, 0.6, 0.6), }; - // A topLeft-anchored label's `verticalAlign: middle` centers its ink on - // the rect's pivot line - its `anchoredPosition.y` itself, not the middle + // A topLeft-anchored label's `verticalAlign: middle` centers its cap + // height on the rect's pivot line - its `anchoredPosition.y` itself, not the middle // of its (otherwise-unused, for a caption) rect. So to land a // caption's vertical center on a same-row toggle's center, its y must be // offset down by half the toggle's height, not simply match the toggle's diff --git a/e2e/fixtures/scenes/text-effects-overlap.ts b/e2e/fixtures/scenes/text-effects-overlap.ts index 65b9b4ec..7c240ad4 100644 --- a/e2e/fixtures/scenes/text-effects-overlap.ts +++ b/e2e/fixtures/scenes/text-effects-overlap.ts @@ -47,6 +47,14 @@ const glyphBounds = { top: 0.5 + glyphHalfWidthEm, }; +// The synthetic glyph's ink top, in em above its baseline: its quad (and +// ink) is centered half an em above the baseline. +const syntheticCapHeightEm = 0.5 + inkHalfFraction; + +// World Y of every glyph's center under `verticalAlign: 'middle'`, which +// centers the band from the baseline to `syntheticCapHeightEm` on y = 0. +const glyphCenterWorldY = (0.5 - syntheticCapHeightEm / 2) * SIZE; + // The advance (in em) between the two glyphs is chosen so that their // *padded quads* (ink + the atlas's own baked-in padding on each side) // overlap by 11 world units while their *ink* stays a real, positive 5 @@ -247,15 +255,15 @@ export const createScene: CreateScene = async ( height: SYNTHETIC_GLYPH_TILE_SIZE, }, distanceRange: SYNTHETIC_GLYPH_DISTANCE_RANGE, - // Sum to 1.0 so `verticalAlign: 'middle'` centers this glyph's own - // ink (which spans exactly 1 em vertically around its own center, by - // construction) on the entity's position - see the derivation this - // module's own doc comment gives for `advanceEm`. + // `capHeight` is the synthetic glyph's ink top, the way a real + // atlas measures it on "H". `verticalAlign: 'middle'` centers the + // band from the baseline to it, which puts the glyph's center at + // `glyphCenterWorldY`. metrics: { lineHeight: 1, - ascender: 0.5, - descender: 0.5, - capHeight: 0.5, + ascender: glyphBounds.top, + descender: glyphBounds.bottom, + capHeight: syntheticCapHeightEm, }, glyphs: new Map([ [ @@ -354,7 +362,7 @@ export const createScene: CreateScene = async ( glyphACenterWorldX: 0.5 * SIZE, glyphCOuterEdgeWorldX, glyphCCenterWorldX, - glyphCenterWorldY: 0, + glyphCenterWorldY, sampleColorAt( worldX: number, diff --git a/scripts/generate-font-atlas.mjs b/scripts/generate-font-atlas.mjs index 68fbea75..3cbb9a71 100755 --- a/scripts/generate-font-atlas.mjs +++ b/scripts/generate-font-atlas.mjs @@ -167,11 +167,18 @@ const CAP_HEIGHT_REFERENCE_CHARACTERS = ['H', 'I', 'E', 'F', 'L', 'T']; * contains none of them - e.g. a numbers-only or symbols-only atlas - since * that's the same "safe outer bound" fallback `ascender`/`descender` * themselves use when there's no ink to measure at all. + * + * A glyph's plane bounds include the distance field's padding around its + * ink, so the reference capital's `top` sits `padding` above the letter's + * real top. `capHeight` is a typographic metric (`'capline'` puts the top + * of an "H" on the anchor, `'middle'` centers the band from it to the + * baseline), so the padding is subtracted. * @param glyphs - Every normalized glyph in this atlas. + * @param padding - The distance field padding around every glyph, in em units. * @param fallback - The value to use when no reference capital is present. * @returns The measured (or fallback) cap height, in em units. */ -function computeCapHeight(glyphs, fallback) { +function computeCapHeight(glyphs, padding, fallback) { const glyphsByCodePoint = new Map( glyphs.map((glyph) => [glyph.codePoint, glyph]), ); @@ -180,7 +187,7 @@ function computeCapHeight(glyphs, fallback) { const glyph = glyphsByCodePoint.get(character.codePointAt(0)); if (glyph?.planeBounds) { - return glyph.planeBounds.top; + return glyph.planeBounds.top - padding; } } @@ -220,6 +227,10 @@ function normalizeBmfontJson(raw) { .map((glyph) => glyph.planeBounds.bottom); const ascender = inkTops.length > 0 ? Math.max(...inkTops) : base / fontSize; + // msdf-bmfont-xml pads every glyph by `distanceRange >> 1` pixels on each + // side and includes that padding in the glyph's offsets and size. + const padding = Math.floor(raw.distanceField.distanceRange / 2) / fontSize; + const kerning = {}; for (const pair of raw.kernings) { @@ -238,21 +249,19 @@ function normalizeBmfontJson(raw) { // metrics, but they routinely undershoot the font's actually rendered // ink - e.g. this engine's shipped default font renders "b"/"d"/"h"/ // "i"/"l" taller than `base` accounts for, and "("/")"/"j" lower than - // `lineHeight - base` accounts for. `ascender`/`descender` exist - // specifically to bound the block's *visible* ink for `verticalAlign` - // (see `getVerticalAlignOffset` in `shape-text.ts`), so a metric that - // undershoots real ink makes every alignment sit off by the shortfall - // - `'top'`/`'bottom'`-anchored glyphs poke past the anchor, and - // `'middle'` centers on the wrong point. Deriving them from the - // actual rendered bounds of every glyph in this charset instead - // guarantees no glyph ever pokes past a `'top'`/`'bottom'`-aligned - // anchor. + // `lineHeight - base` accounts for. `ascender`/`descender` are the + // outer bounds `'top'`/`'bottom'` anchor to (see + // `getVerticalAlignOffset` in `shape-text.ts`), so they're taken from + // every glyph's plane bounds *including* the distance field padding: + // outlines, glows and shadows draw into that padding, so nothing a + // glyph renders pokes past a `'top'`/`'bottom'`-aligned anchor. + // `capHeight`, by contrast, is a typographic metric and excludes it. ascender, descender: inkBottoms.length > 0 ? Math.min(...inkBottoms) : (base - lineHeight) / fontSize, - capHeight: computeCapHeight(glyphs, ascender), + capHeight: computeCapHeight(glyphs, padding, ascender), }, glyphs, kerning, diff --git a/src/text/components/text-component.ts b/src/text/components/text-component.ts index 6c494c64..ea035774 100644 --- a/src/text/components/text-component.ts +++ b/src/text/components/text-component.ts @@ -59,25 +59,22 @@ export interface TextDefaultedOptions { horizontalAlign: TextHorizontalAlign; /** - * Vertical alignment of the shaped block's visible ink relative to the - * entity's position - not the font's line-height box, which typically - * doesn't match the ink's own extent. + * Vertical alignment of the shaped block relative to the entity's + * position. Every mode anchors to the font's metrics (and the line + * count), never to the glyphs this string contains, so a label doesn't + * move when its text changes: * - * `'top'`, `'bottom'`, `'capline'`, and `'baseline'` all anchor to a - * fixed reference that doesn't depend on this specific string's rendered - * bounds, so a line's position stays stable as its text is edited: - * `'top'` anchors the font's ascender (so text hangs *below* the - * entity's position), `'bottom'` anchors the font's descender (so text - * sits *above* it), `'capline'` anchors the font's cap height - the top - * of a capital letter like "H", shorter than `ascender`'s "tallest glyph - * including ascenders like b/d/h" - and `'baseline'` anchors the first - * line's own baseline directly (most useful for single-line text). - * - * `'middle'` instead centers this exact string's *actual* rendered ink, - * since a font's ascender is typically taller than its descender is - * deep - most glyphs have no descender at all - so centering on the - * font's metrics would bias every descender-less string (numbers, - * titles, most short UI labels) above the true visual center of its box. + * - `'top'` anchors the first line's ascender (the top of the font's + * tallest glyphs), so text hangs below the position. + * - `'bottom'` anchors the last line's descender, so text sits above it. + * - `'capline'` anchors the first line's cap height (the top of a capital + * like "H"), lower than the ascender of "b"/"d"/"h". Use it to put the + * top of a title set in caps exactly on the position. + * - `'baseline'` anchors the first line's baseline. + * - `'middle'` centers the band from the first line's cap height to the + * last line's baseline on the position, which is where a designer + * centers a label in a button. Lowercase descenders hang below the + * band. */ verticalAlign: TextVerticalAlign; diff --git a/src/text/font-atlas/font-atlas-data.ts b/src/text/font-atlas/font-atlas-data.ts index 8bb67d62..d3c26876 100644 --- a/src/text/font-atlas/font-atlas-data.ts +++ b/src/text/font-atlas/font-atlas-data.ts @@ -55,9 +55,11 @@ export interface FontAtlasMetrics { /** * The distance from the baseline to the top of the font's capital letters * (e.g. "H"), excluding ascenders like "b"/"d"/"h" that reach higher than - * a capital's flat top. Used by `TextEcsComponent.verticalAlign: - * 'capline'` to anchor a title/label to its capital letters specifically, - * ignoring both true ascenders and any descenders. + * a capital's flat top. Measured on the letter itself, without the + * distance field padding around its quad. `TextEcsComponent.verticalAlign` + * reads it for `'capline'` (the top of a capital on the anchor) and + * `'middle'` (the band from the cap line to the baseline centered on the + * anchor). */ capHeight: number; } diff --git a/src/text/utilities/shape-text.test.ts b/src/text/utilities/shape-text.test.ts index 475167cc..98edf424 100644 --- a/src/text/utilities/shape-text.test.ts +++ b/src/text/utilities/shape-text.test.ts @@ -53,10 +53,9 @@ function buildFixtureFontAtlasData(): FontAtlasData { ], [ X_CODE_POINT, - // An x-height glyph: unlike "A"/"V" it reaches neither the font's - // ascender (0.9) nor its descender (-0.2), so it's the fixture - // `'middle'` uses to tell "centered on this string's actual ink" - // apart from "centered on the font's ascender/descender metrics". + // An x-height glyph: it reaches neither the cap height (0.7) nor + // the ascender (0.9), so `'middle'` tests use it to check a line's + // position doesn't depend on how tall its glyphs are. { codePoint: X_CODE_POINT, advance: 0.5, @@ -511,8 +510,7 @@ describe('shapeText', () => { }); describe('vertical alignment', () => { - // Anchored to the block's visible ink (ascender/descender), not its - // line-height box: fixture metrics are `ascender: 0.9`, `descender: + // Anchored to the font's metrics, not the line-height box: fixture metrics are `ascender: 0.9`, `descender: // -0.2`, so at size 10, `inkTop = 9` and (for a 3-line, `actualLineHeight: // 12` block) `inkBottom = -(3 - 1) * 12 + -0.2 * 10 = -26`. @@ -538,51 +536,58 @@ describe('shapeText', () => { expect(glyphs[4].offset.y).toBeCloseTo(3.5 - 24 + 26); }); - it('anchors the block by the vertical center of its own rendered ink', () => { + it('centers a single line on the band from its cap height to its baseline', () => { + const { glyphs } = shapeText('A', buildFixtureFontAtlasData(), { + size: 10, + verticalAlign: 'middle', + }); + + // The band spans the baseline (0) to the cap line (0.7 * 10 = 7); + // shifting by `-7 / 2` puts its center at y = 0. "A" fills the band + // exactly, so its own center lands there too. + expect(glyphs[0].offset.y).toBeCloseTo(0); + }); + + it("centers a multi-line block on the band from the first line's cap height to the last line's baseline", () => { const { glyphs } = shapeText('AV AV AV', buildFixtureFontAtlasData(), { size: 10, maxWidth: 20, verticalAlign: 'middle', }); - // "A"/"V" both reach exactly the fixture's ascender/descender (0.7 top - // vs ascender 0.9, 0 bottom vs descender -0.2 - each 0.2em short by - // design, so this case can't tell "centered on rendered ink" apart - // from "centered on the font's ascender/descender metrics"; see the - // "centers on this string's own ink, not the font's ascender/ - // descender" test below for that). The block's actual rendered ink - // spans from the first line's top (0.7 * 10 = 7) to the last line's - // bottom (-(3 - 1) * 12 + 0 * 10 = -24); shifting by - // `-(7 + -24) / 2` = 8.5 centers that (not the line-height box) on - // y = 0. + // The band runs from the first line's cap line (7) to the last + // line's baseline (-(3 - 1) * 12 = -24); shifting by `-(7 - 24) / 2` + // = 8.5 centers it on y = 0. expect(glyphs[0].offset.y).toBeCloseTo(3.5 + 8.5); expect(glyphs[4].offset.y).toBeCloseTo(3.5 - 24 + 8.5); }); - it("centers on this string's own ink, not the font's ascender/descender", () => { - const { glyphs } = shapeText('x', buildFixtureFontAtlasData(), { + it("doesn't move a line when its glyphs reach different heights", () => { + const capital = shapeText('A', buildFixtureFontAtlasData(), { + size: 10, + verticalAlign: 'middle', + }); + const xHeight = shapeText('x', buildFixtureFontAtlasData(), { size: 10, verticalAlign: 'middle', }); - // "x"'s baseline-relative center is `0 * 10 + 5 / 2` = 2.5 - well - // short of the fixture's ascender (0.9) and descender (-0.2). - // Centering on the font's metrics would shift by `-(9 + -2) / 2` = - // -3.5, landing at `2.5 - 3.5` = -1; centering on "x"'s own rendered - // ink instead shifts by `-(5 + 0) / 2` = -2.5, putting its actual - // (not the font's nominal) vertical center at y = 0. - expect(glyphs[0].offset.y).toBeCloseTo(0); + // "A" reaches the cap line and "x" only half as high, but both sit + // on the same baseline, so their quads' bottoms (the baseline, as + // neither glyph has a descender) line up. + const capitalBottom = capital.glyphs[0].offset.y - 3.5; + const xHeightBottom = xHeight.glyphs[0].offset.y - 2.5; + + expect(xHeightBottom).toBeCloseTo(capitalBottom); + expect(capitalBottom).toBeCloseTo(-3.5); }); - it('falls back to the font metrics when there is no visible ink to center on', () => { + it('shapes a string with no visible glyphs', () => { const { glyphs, bounds } = shapeText(' ', buildFixtureFontAtlasData(), { size: 10, verticalAlign: 'middle', }); - // A single space has no glyph quads at all, so there's no rendered - // ink for `'middle'` to measure - this must not throw or divide by - // an empty extent, and still shapes (an invisible, but valid) block. expect(glyphs).toHaveLength(0); expect(bounds.height).toBeCloseTo(12); }); diff --git a/src/text/utilities/shape-text.ts b/src/text/utilities/shape-text.ts index df7ed430..b82d148c 100644 --- a/src/text/utilities/shape-text.ts +++ b/src/text/utilities/shape-text.ts @@ -32,8 +32,8 @@ export interface ShapeTextOptions { horizontalAlign?: 'left' | 'center' | 'right' | 'justify'; /** - * Vertical alignment of the shaped block's visible ink relative to its - * anchor (the origin every glyph offset is relative to) - see + * Vertical alignment of the shaped block relative to its anchor (the + * origin every glyph offset is relative to) - see * `TextDefaultedOptions.verticalAlign` for the precise semantics. * Defaults to `'top'`. */ @@ -344,82 +344,30 @@ function getJustifyGapStretch( return (maxWidth - line.width) / (line.words.length - 1); } -/** - * The vertical extent actually covered by a set of positioned glyphs, used - * to center `'middle'`-aligned text on what's actually rendered (see - * {@link getVerticalAlignOffset}). - */ -interface InkBounds { - /** The highest point covered by any glyph's quad. */ - top: number; - - /** The lowest point covered by any glyph's quad. */ - bottom: number; -} - -/** - * Computes the vertical extent `glyphs` actually covers - the highest and - * lowest point of any glyph's quad - or `null` if `glyphs` is empty (e.g. an - * empty or all-whitespace string, which has no ink to center on). - * @param glyphs - Every glyph in the shaped block, already positioned with - * each line's un-offset baseline (line `0` at `y = 0`; see {@link shapeText}). - * @returns The block's actual ink extent, or `null` if `glyphs` is empty. - */ -function getInkBounds(glyphs: GlyphQuad[]): InkBounds | null { - if (glyphs.length === 0) { - return null; - } - - let top = -Infinity; - let bottom = Infinity; - - for (const glyph of glyphs) { - const glyphTop = glyph.offset.y + glyph.size.y / 2; - const glyphBottom = glyph.offset.y - glyph.size.y / 2; - - top = Math.max(top, glyphTop); - bottom = Math.min(bottom, glyphBottom); - } - - return { top, bottom }; -} - /** * Computes the offset added to the whole shaped block to realize - * `verticalAlign`, rather than the line-height box `actualLineHeight` - * implies. Anchoring to the line-height box instead of the ink is the more - * obvious thing to try, but it's wrong: a capital letter's ink sits almost - * entirely *above* its baseline, so a `'top'` alignment built from "line 0's - * unshifted baseline sits at the box's top" would place most of the text - * *above* the anchor, the opposite of what `'top'` is supposed to mean, and - * `'middle'` would never actually cross through the visible glyphs. + * `verticalAlign`. Every mode anchors to the font's own metrics and the line + * count, never to the glyphs this string happens to contain, so a label + * stays put when its text changes and two labels aligned to the same point + * share a baseline. * - * `'top'`/`'bottom'`/`'capline'`/`'baseline'` all anchor to the font's own - * metrics (or, for `'baseline'`, to nothing at all - see below) rather than - * this specific string's actual rendered bounds, so that (e.g.) a - * multi-line paragraph's line positions - and a single label's position as - * its text is edited - stay stable instead of shifting by a fraction of a - * line every time the tallest/lowest glyph currently present happens to - * change. `'middle'`, however, centers on `inkBounds` - the *actual* - * rendered extent of this exact string - since a font's ascender is - * typically taller than its descender is deep (most glyphs have no - * descender at all), so centering on the font's metrics instead would - * systematically bias every descender-less string (numbers, titles, most - * short UI labels) above the true visual center of its box; `inkBounds` - * doesn't have this bias, and a `'middle'`-aligned string being fully - * replaced is already exactly the kind of content change a UI expects to - * reflow around, unlike `'top'`/`'bottom'`/`'capline'`/`'baseline'`'s - * "editing this line" case. + * The line-height box isn't used as the reference: a capital letter's ink + * sits almost entirely above its baseline, so a `'top'` built from "line 0's + * baseline sits at the box's top" would put most of the text above the + * anchor. * - * `'capline'` is `'top'` with the font's `capHeight` (the top of a capital - * letter like "H") in place of its `ascender` (the top of the font's - * *tallest* glyphs, including ascenders like "b"/"d"/"h" that reach higher - * than a flat capital) - useful for a title or label set in caps, where - * anchoring to the taller `ascender` would leave a visible gap above the - * text's actual top. `'baseline'` anchors line `0`'s own baseline, which - * (per the paragraph below) is already sitting at `y = 0` before this - * offset is applied - so, uniquely among every mode, it's *always* `0`, - * regardless of `lineCount`/`actualLineHeight`/the font's metrics. + * - `'top'` anchors the first line's ascender (the top of the font's + * tallest glyphs), and `'bottom'` the last line's descender, so no glyph + * crosses the anchor. + * - `'capline'` anchors the first line's cap height (the top of a flat + * capital like "H"), which sits lower than the ascender of "b"/"d"/"h". + * - `'baseline'` anchors the first line's baseline, which already sits at + * `y = 0` before this offset, so it's always `0`. + * - `'middle'` centers the band from the first line's cap line to the last + * line's baseline - the box designers center text in (TextMesh Pro's + * `Capline`, CSS `text-box-edge: cap alphabetic`). Centering the + * ascender-to-descender box instead would sit too high for the many + * strings with no descenders. * * Before this offset, line `0`'s baseline sits at `y = 0` and each * following line's baseline is `actualLineHeight` further in the negative @@ -429,9 +377,6 @@ function getInkBounds(glyphs: GlyphQuad[]): InkBounds | null { * @param size - Font size, in world units. * @param lineCount - The number of lines in the shaped block. * @param actualLineHeight - The distance between two lines' baselines, in world units. - * @param inkBounds - The block's actual rendered vertical extent (see - * {@link getInkBounds}), or `null` if it has no visible glyphs. Only read - * for `'middle'`; every other mode always uses the font's own metrics. * @returns The Y offset to add to every glyph. */ function getVerticalAlignOffset( @@ -440,34 +385,27 @@ function getVerticalAlignOffset( size: number, lineCount: number, actualLineHeight: number, - inkBounds: InkBounds | null, ): number { + const { ascender, descender, capHeight } = fontAtlasData.metrics; + const lastBaseline = -(lineCount - 1) * actualLineHeight; + if (verticalAlign === 'baseline') { return 0; } if (verticalAlign === 'capline') { - return -(fontAtlasData.metrics.capHeight * size); - } - - const inkTop = fontAtlasData.metrics.ascender * size; - const inkBottom = - -(lineCount - 1) * actualLineHeight + - fontAtlasData.metrics.descender * size; - - if (verticalAlign === 'bottom') { - return -inkBottom; + return -(capHeight * size); } if (verticalAlign === 'middle') { - if (inkBounds !== null) { - return -(inkBounds.top + inkBounds.bottom) / 2; - } + return -(capHeight * size + lastBaseline) / 2; + } - return -(inkTop + inkBottom) / 2; + if (verticalAlign === 'bottom') { + return -(lastBaseline + descender * size); } - return -inkTop; + return -(ascender * size); } /** @@ -536,9 +474,8 @@ export function shapeText( // existing behavior before this field was added. const boxLeftOffset = -horizontalAlignPivot * alignmentWidth; - // Built first with each line's *un-offset* baseline (line 0 at y = 0), so - // `'middle'` can measure this exact block's actual rendered ink before - // `verticalOffset` (which depends on that measurement) is applied below. + // Built with each line's un-offset baseline (line 0 at y = 0); + // `verticalOffset` is applied to every glyph afterwards. const glyphs: GlyphQuad[] = []; lines.forEach((line, lineIndex) => { @@ -583,7 +520,6 @@ export function shapeText( size, lines.length, actualLineHeight, - verticalAlign === 'middle' ? getInkBounds(glyphs) : null, ); for (const glyph of glyphs) {