simvx.graphics.draw2d_text

MSDF text rendering for Draw2D: the ONE 2D text builder.

This is the single glyph layout + measure + emit path for the whole engine. It feeds both the immediate Draw2D op stream (text appends a TEXT :class:Op) and the item pipeline (which re-runs the same layout natively via :func:layout_glyph_run). The desktop overlay TextRenderer.draw_text path it replaced is deleted; only the MSDF atlas generation in :mod:text_renderer survives (both paths share it).

Three text-layout reconciliations:

  • Font-size unit = font_scale * 16 is canonical (_LOGICAL_BASE = 16); the old divergent 14 is replaced so a label renders the same pixel height whichever node draws it and whichever backend. The * sy content-scale factor multiplies on top (applied by the caller / the _xf_sc factor).

  • Kerning is ported from the deleted overlay path – the cursor advances by font.get_kerning(prev, ch) * scale between glyph pairs, so the kerned advance is pixel-identical to the old overlay (0.00 px diff).

  • Quad rounding = ceil (fully contains the glyph + padding) replaces the old max(1.0, ...). Alignment is an :class:~simvx.core.HAlign /

    class:

    ~simvx.core.VAlign member or its plain string.

Text never silently goes blank. A character the primary font lacks is drawn from the font fallback chain; one no available font can draw at all is drawn as a hollow box in the text’s own colour, and reported once per codepoint, rather than advancing the cursor over an invisible gap. Text drawn large enough to read them writes the codepoint out, one box per byte, so the character can be identified from the screen rather than guessed at.

Module Contents

Classes

Draw2DTextMixin

Mixin providing MSDF text rendering and measurement for Draw2D.

Functions

layout_glyph_run

Lay out text into MSDF glyph quads in LOCAL space (the item path).

Data

log

API

simvx.graphics.draw2d_text.log

‘getLogger(…)’

class simvx.graphics.draw2d_text.Draw2DTextMixin

Mixin providing MSDF text rendering and measurement for Draw2D.

classmethod set_font(path: str | None = None, size: int = 48) → None

Load an MSDF font atlas via the shared TextRenderer.

classmethod text(text, pos=None, *, fill=None, scale=1.0, align: simvx.core.alignment.HAlign | str = HAlign.LEFT, valign: simvx.core.alignment.VAlign | str = VAlign.TOP, stroke=None, stroke_width=0.0, rect=None, fit_to_width=False, min_scale=None, cell_width=0.0, rotation=0.0)

Draw text at pos or inside rect, in the colour fill.

Two positioning modes:

  • pos=(x, y): text anchored at the given coordinate. align (:class:~simvx.core.HAlign) anchors each line’s left edge / centre / right edge on x.

  • rect=(x, y, w, h): text positioned by align and valign (:class:~simvx.core.VAlign) inside the rect. fit_to_width=True shrinks scale so the text fits rect’s width, clamped to min_scale.

scale is a multiplier on the logical 16-pixel em, so scale=2 draws at roughly 32 pixels.

stroke_width (in glyph-em units, e.g. 0.08) draws a 4-direction offset copy of the run in stroke (default opaque black at the fill’s alpha) UNDER the main run: readable text on any background. min_scale=None applies the ~10px readable safety net; an explicit value honours the caller’s floor exactly.

cell_width is the pitch of the character grid this text sits on, in the pixels it is laid out in (before any Draw2D transform), and is how a caller that has a grid says so: a terminal, a code view, a tabular readout. It changes one thing: a character no font can draw occupies exactly one cell, drawn as the largest form that fits it, instead of taking the room a box asks for in proportional text. The columns after it stay on the grid, and nothing it draws reaches the column beside it, which is what a caller that places every character itself needs. Text that is not on a grid leaves it at zero and is drawn exactly as before.

rotation (radians) turns the run about its anchor, pos or the rect’s top-left corner, in the same sense as a sprite’s rotation.

align and valign take the member or its plain string.

classmethod text_height(text, scale=1.0)

Height of text in pixels at scale.

Multi-line strings (containing \n) accumulate line heights using the font’s line-height metric. Returns 0 for empty input.

classmethod text_ascent(scale=1.0)

Distance in pixels from the top of a line to its baseline at scale.

classmethod text_size(text, scale=1.0)

Return (width, height) in pixels at scale.

classmethod text_pixels(text: str, scale: float = 1.0, colour: Any = (1.0, 1.0, 1.0, 1.0)) → numpy.ndarray

text rasterised into an RGBA uint8 image, top row first, sized to fit it.

For text that belongs to an image: a card face, a sign, a label on a sprite that rotates. Baked in, it is part of the image and costs one textured quad wherever the image is drawn. It is drawn from the font 2D text uses, on every backend. scale is the multiplier

Meth:

text takes, so scale=1 is 16 px, and colour is 0-1 RGB(A) written as given, the way a texture’s own pixels are.

One line. The image keeps a 2 px margin so ink that overhangs the pen or the ascender is not clipped, so its pen origin, the left end of the baseline, is pixel (2, 2 + text_ascent(scale)).

Raises: ValueError: text holds a line break. RuntimeError: no font is loaded, so there is nothing to draw with.

classmethod fit_scale(text, max_width, *, base_scale=1.0, min_scale=None, cell_width=0.0)

Largest scale ≤ base_scale that fits text within max_width.

Returns base_scale when the text already fits or when inputs are degenerate. The returned value matches what :meth:text would actually use, so callers can compute width metrics that align with what’s drawn.

min_scale semantics mirror :meth:text:

  • None (default): clamps from below by the readable-pixel floor (~10px) so the returned scale never produces illegible MSDF output.

  • explicit value: the caller’s floor is honoured exactly, bypassing the readable safety net.

classmethod text_width(text, scale=1, *, cell_width=0.0)

Width of text in pixels at scale.

Multi-line strings (containing \n) return the widest line; newlines themselves contribute no horizontal advance. The walk is

Meth:

_layout_run’s, without the geometry: the same advances, and the same rule for which neighbours a kerning pair reaches across.

cell_width is the caller’s character-grid pitch, in the pixels this returns, and means what it does on :meth:text: a boxed character is reserved one cell, so the width comes back as the run is drawn.

A character the primary face lacks but another draws is measured at the borrowing face’s advance without consulting the fallback chain here: the walk below is preceded by _ensure_with_fallback, which resolves the character through that chain and packs its glyph into the atlas, so it is an ordinary atlas cell by the time the walk reaches it.

Func:

~simvx.core.text.measure_text_width asks the same lookup directly and gets the same face at the same size, which is why the two agree. The chain refuses to lend a glyph for a character defined to occupy no space, so neither walk can be handed one to measure.

simvx.graphics.draw2d_text.layout_glyph_run(text, pos=None, *, colour=None, scale=1.0, rect=None, alignment: simvx.core.alignment.HAlign | str = HAlign.LEFT, vertical_alignment: simvx.core.alignment.VAlign | str = VAlign.TOP, fit_to_width=False, min_scale=None, outline=0.0, outline_colour=None, cell_width=0.0, rotation=0.0, pixel_origin=(0.0, 0.0), pixel_scale=1.0)

Lay out text into MSDF glyph quads in LOCAL space (the item path).

The native-emission counterpart of :meth:Draw2DTextMixin.text: it runs the SAME single layout (kerning, *16 unit, ceil, centre) but without baking any Draw2D transform and without appending an Op – it returns (verts, indices) so the item builder can stow them as a GLYPH item’s geometry (geometry stays camera-/parent-free, the node’s transform rides the item’s transform row). Returns ([], []) when there is no font or no visible glyphs.

Because both paths funnel through :meth:Draw2DTextMixin._layout_run, the item-path glyph geometry is byte-identical to the op-path geometry at the same position/scale – which is what makes flag-ON text match flag-OFF. That covers the missing-glyph box’s choice of form as well: no form_scale is passed here because there is no transform to exclude, and the op path passes its own untransformed scale so it reaches the same choice. cell_width rides through unchanged, so a monospace caller reaches the same grid whichever path draws it.

pixel_origin and pixel_scale are the translation and the uniform scale the caller will bake the returned quads by. The run is laid out at scale * pixel_scale, snapped to whole pixels at that size, and returned divided by pixel_scale, so the bake puts every glyph on the pixel grid at the size it is shown. That is the op path’s own behaviour, which lays out after folding its transform in; laying out at 1x and multiplying would put most glyphs between pixels at 1.25x and 1.5x.