Source code for 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 legacy flag-OFF ``Draw2D`` op stream (``draw_text`` appends a
``TEXT`` :class:`Op`) and the flag-ON 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, ...)``; **align spelling = ``'centre'``** (UK, repo rule) -- the
  banned US ``'center'`` is accepted transitionally and mapped to ``'centre'``.
"""

import logging
import math
from typing import TYPE_CHECKING, Any, ClassVar

from .draw2d_ops import Op, OpKind

if TYPE_CHECKING:
    from simvx.core.text import Font, MSDFAtlas

log = logging.getLogger(__name__)


def _normalise_align(align: str) -> str:
    """Canonicalise a horizontal-align spelling to ``'left'``/``'centre'``/``'right'``.

    The repo rule is the UK ``'centre'``; the banned US ``'center'`` is accepted
    transitionally and mapped, so any caller still passing
    ``'center'`` keeps working while the canonical spelling is ``'centre'``.
    """
    return "centre" if align == "center" else align


[docs] class Draw2DTextMixin: """Mixin providing MSDF text rendering and measurement for Draw2D.""" # ---- TYPE_CHECKING-only sibling-attribute declarations ---- # These attributes / classmethods live on other Draw2D mixins or on the # final Draw2D class itself; declaring them here in TYPE_CHECKING blocks # makes mypy stop flagging legitimate cross-mixin access without # affecting runtime (no shadowing of the real defs). if TYPE_CHECKING: _ops: ClassVar[list[Op]] _current_clip: ClassVar[tuple[int, int, int, int] | None] _has_xf: ClassVar[bool] _pending_screen_space: ClassVar[bool] _pending_post_layer: ClassVar[int] @classmethod def _xf_pt(cls, x: float, y: float) -> tuple[float, float]: ... @classmethod def _xf_sc(cls) -> float: ... @classmethod def _norm_colour(cls, c: Any) -> tuple[float, float, float, float]: ... # MSDF font state -- delegated to shared TextRenderer _font: "MSDFAtlas | None" = None _font_path: str | None = None _font_obj: "Font | None" = None _base_height: float = 16.0 # Logical em base for text sizing: # ``scale`` is a multiplier on this base, so ``scale=1`` renders text at 16 # logical pixels. Canonical engine-wide -- the overlay's ``font_scale*16`` is # adopted; the old divergent ``14`` is gone, so the same nominal scale renders # the same height whichever node/backend draws it. HiDPI content-scale (the # ``_xf_sc`` factor at the GPU boundary) multiplies on top. _LOGICAL_BASE: float = 16.0 # Readable-pixel floor: MSDF sub-pixel sampling can't reconstruct glyphs # cleanly below ~10px, so the default safety net clamps scale up to this # value. Callers who pass an explicit ``min_scale`` opt out of the floor # and take responsibility for legibility themselves. _READABLE_MIN_PIXELS: float = 10.0 _text_width_cache: dict[tuple[str, float], float] = {}
[docs] @classmethod def set_font(cls, path: str | None = None, size: int = 48) -> None: """Load an MSDF font atlas via the shared TextRenderer.""" from .text_renderer import _find_font, get_shared_text_renderer if path is None: path = _find_font() if path is None: log.warning("Draw2D: no font found, text rendering disabled") return try: tr = get_shared_text_renderer() if tr is None: log.warning("Draw2D: no text renderer available, text rendering disabled") return atlas = tr.get_atlas(path, font_size=size) cls._font = atlas cls._font_obj = atlas.font cls._font_path = path except (ImportError, OSError) as exc: log.warning("Draw2D: failed to load font %s: %s", path, exc)
@classmethod def _ensure_font(cls) -> None: """Lazy-init font on first text call.""" if cls._font is None: cls.set_font() @classmethod def _resolve_layout(cls, text, pos, scale, rect, alignment, vertical_alignment, fit_to_width, min_scale): """Resolve ``(start_x, start_y, display_scale, per_line_offsets)`` for a draw. Shared by :meth:`draw_text` (op path) and :func:`layout_glyph_run` (item path) so the two never drift. Applies the ``rect`` block alignment, the ``fit_to_width`` shrink, the readable-pixel safety net, and converts ``scale`` (a multiplier on ``_LOGICAL_BASE``) into a ``display_scale`` (atlas-pixels -> screen-pixels). ``per_line_offsets`` is the per-line x anchor offset for ``'centre'``/``'right'`` point-anchored draws (rect mode already folds alignment into ``start_x``, so it is empty there). Returns ``None`` if there is no font. """ if cls._font is None or cls._font_obj is None: return None align = _normalise_align(alignment) if rect is not None: rx, ry, rw, rh = rect[0], rect[1], rect[2], rect[3] if fit_to_width: scale = cls.fit_scale(text, rw, base_scale=scale, min_scale=min_scale) eff = scale if min_scale is not None else max(scale, cls._READABLE_MIN_PIXELS / cls._LOGICAL_BASE) tw = cls.text_width(text, eff) glyph_h = cls._LOGICAL_BASE * eff n_lines = text.count("\n") + 1 th = glyph_h if n_lines == 1 else glyph_h * 1.2 * n_lines valign = "centre" if vertical_alignment == "center" else vertical_alignment x = rx + (rw - tw) / 2 if align == "centre" else rx + rw - tw if align == "right" else rx y = ry + (rh - th) / 2 if valign == "centre" else ry + rh - th if valign == "bottom" else ry start_x, start_y = x, y line_offsets: dict[str, float] = {} else: if pos is None: pos = (0.0, 0.0) if hasattr(pos, "x"): start_x, start_y = float(pos.x), float(pos.y) else: start_x, start_y = float(pos[0]), float(pos[1]) eff = scale if min_scale is not None else max(scale, cls._READABLE_MIN_PIXELS / cls._LOGICAL_BASE) # Point-anchored alignment: offset each line by its measured width so # ``start_x`` lands on the line's centre / right edge (overlay parity). line_offsets = {} if align != "left": factor = 0.5 if align == "centre" else 1.0 for line in dict.fromkeys(text.split("\n")): line_offsets[line] = -cls.text_width(line, eff) * factor display_scale = cls._LOGICAL_BASE * eff / cls._font_obj.size return start_x, start_y, display_scale, line_offsets
[docs] @classmethod def draw_text( cls, text, pos=None, *, colour=None, scale=1.0, rect=None, alignment="left", vertical_alignment="top", fit_to_width=False, min_scale=None, outline=0.0, outline_colour=None, screen_space=False, ): """Draw text at ``pos`` or inside ``rect`` with optional alignment. ``screen_space=True`` bypasses the active Camera2D transform (the screen-pinned HUD-label case -- the behaviour Text2D's deleted overlay pass had: text stays fixed while the world camera pans). Two positioning modes: - ``pos=(x, y)``: text anchored at the given coordinate. ``alignment`` (``'left'``/``'centre'``/``'right'``) anchors each line's left edge / centre / right edge on ``x`` (the overlay-parity point anchor). - ``rect=(x, y, w, h)``: text positioned by ``alignment`` and ``vertical_alignment`` (top/centre/bottom) inside the rect. ``fit_to_width=True`` shrinks ``scale`` so the text fits ``rect.w``, clamped to ``min_scale``. ``outline`` (in glyph-em units, e.g. ``0.08``) draws a 4-direction offset copy of the run in ``outline_colour`` (default opaque black) UNDER the main run -- the common port "readable text on any background" pattern. ``min_scale=None`` applies the ~10px readable safety net; an explicit value honours the caller's floor exactly. Both the US ``'center'`` and the UK ``'centre'`` are accepted; ``'centre'`` is canonical (repo spelling). """ cls._pending_screen_space = bool(screen_space) cls._ensure_font() if cls._font is None: return from .text_renderer import get_shared_text_renderer _tr = get_shared_text_renderer() if _tr is not None: _tr._ensure_with_fallback(cls._font, text) resolved = cls._resolve_layout(text, pos, scale, rect, alignment, vertical_alignment, fit_to_width, min_scale) if resolved is None: return start_x, start_y, display_scale, line_offsets = resolved if cls._has_xf and not screen_space: start_x, start_y = cls._xf_pt(start_x, start_y) display_scale *= cls._xf_sc() c = cls._norm_colour(colour) verts: list[tuple] = [] indices: list[int] = [] if outline > 0.0: oc = cls._norm_colour(outline_colour) if outline_colour is not None else (0.0, 0.0, 0.0, c[3]) d = outline * (cls._font_obj.size * display_scale) for dx, dy in ((-d, 0), (d, 0), (0, -d), (0, d)): cls._layout_run(text, start_x + dx, start_y + dy, display_scale, oc, line_offsets, verts, indices) cls._layout_run(text, start_x, start_y, display_scale, c, line_offsets, verts, indices) if verts: cls._ops.append( Op(OpKind.TEXT, cls._current_clip, verts, indices, -1, "alpha", cls._pending_screen_space, cls._pending_post_layer) )
@classmethod def _layout_run(cls, text, start_x, start_y, display_scale, colour, line_offsets, verts, indices): """Append one kerned MSDF glyph run into ``verts``/``indices`` (local quads). The ONE glyph layout: pixel-snapped baseline, ``-pad`` atlas bleed offset, ``ceil`` quad dims, and **kerning** (``font.get_kerning``) between glyph pairs -- byte-identical to the deleted overlay layout. Used by both the op path (:meth:`draw_text`) and the native item path (:func:`layout_glyph_run`). """ font = cls._font font_obj = cls._font_obj pad = font.glyph_padding lines = text.split("\n") line_idx = 0 cursor_x = start_x + line_offsets.get(lines[0], 0.0) cursor_y = start_y baseline_y = round(cursor_y + font_obj.ascender * display_scale) prev_char = None for ch in text: if ch == "\n": line_idx += 1 cursor_y += font_obj.line_height * display_scale baseline_y = round(cursor_y + font_obj.ascender * display_scale) cursor_x = start_x + line_offsets.get(lines[line_idx], 0.0) prev_char = None continue if ch == " " or ch not in font.regions: if font_obj.has_glyph(ch): cursor_x += font_obj.get_glyph(ch).advance_x * display_scale else: cursor_x += font_obj.size * 0.6 * display_scale # Tofu placeholder prev_char = ch continue if prev_char: cursor_x += font_obj.get_kerning(prev_char, ch) * display_scale region = font.regions[ch] gm = region.metrics # Snap x to pixel grid for crisp vertical stems; -pad for atlas bleed. qx = round(cursor_x + (gm.bearing_x - pad) * display_scale) qy = baseline_y - (gm.bearing_y + pad) * display_scale # no per-glyph y round qw = math.ceil(region.w * display_scale) # ceil: fully contain glyph + pad qh = math.ceil(region.h * display_scale) base = len(verts) verts.extend( [ (qx, qy, region.u0, region.v0, *colour), (qx + qw, qy, region.u1, region.v0, *colour), (qx + qw, qy + qh, region.u1, region.v1, *colour), (qx, qy + qh, region.u0, region.v1, *colour), ] ) indices.extend([base, base + 1, base + 2, base, base + 2, base + 3]) cursor_x += gm.advance_x * display_scale prev_char = ch
[docs] @classmethod def text_height(cls, 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. """ if not text: return 0 cls._ensure_font() line_count = text.count("\n") + 1 if cls._font_obj is None: return cls._LOGICAL_BASE * scale * 1.2 * line_count display_scale = cls._LOGICAL_BASE * scale / cls._font_obj.size return cls._font_obj.line_height * display_scale * line_count
[docs] @classmethod def text_size(cls, text, scale=1.0): """Return ``(width, height)`` in pixels at ``scale``.""" return (cls.text_width(text, scale), cls.text_height(text, scale))
[docs] @classmethod def fit_scale(cls, text, max_width, *, base_scale=1.0, min_scale=None): """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:`draw_text` would actually use, so callers can compute width metrics that align with what's drawn. ``min_scale`` semantics mirror :meth:`draw_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. """ floor = cls._READABLE_MIN_PIXELS / cls._LOGICAL_BASE if min_scale is None else min_scale if not text or max_width <= 0: return base_scale w = cls.text_width(text, base_scale) if w <= max_width: return base_scale return max(floor, base_scale * max_width / w)
[docs] @classmethod def text_width(cls, text, scale=1): """Width of ``text`` in pixels at ``scale``. Multi-line strings (containing ``\\n``) return the widest line; newlines themselves contribute no horizontal advance. """ if not text: return 0 key = (text, scale) cached = cls._text_width_cache.get(key) if cached is not None: return cached cls._ensure_font() if cls._font is None: return 0 display_scale = cls._LOGICAL_BASE * scale / cls._font_obj.size from .text_renderer import get_shared_text_renderer _tr = get_shared_text_renderer() if _tr is not None: _tr._ensure_with_fallback(cls._font, text) regions = cls._font.regions font_obj = cls._font_obj tofu = font_obj.size * 0.6 max_line = 0.0 line = 0.0 prev = None for ch in text: if ch == "\n": if line > max_line: max_line = line line = 0.0 prev = None continue region = regions.get(ch) if region is not None: if prev: line += font_obj.get_kerning(prev, ch) # kerning parity with layout line += region.metrics.advance_x elif font_obj.has_glyph(ch): line += font_obj.get_glyph(ch).advance_x else: line += tofu prev = ch if line > max_line: max_line = line result = max_line * display_scale cls._text_width_cache[key] = result return result
[docs] def layout_glyph_run( text, pos=None, *, colour=None, scale=1.0, rect=None, alignment="left", vertical_alignment="top", fit_to_width=False, min_scale=None, outline=0.0, outline_colour=None, ): """Lay out ``text`` into MSDF glyph quads in LOCAL space (the item path). The native-emission counterpart of :meth:`Draw2DTextMixin.draw_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. """ from .draw2d import Draw2D Draw2D._ensure_font() if Draw2D._font is None: return [], [] from .text_renderer import get_shared_text_renderer _tr = get_shared_text_renderer() if _tr is not None: _tr._ensure_with_fallback(Draw2D._font, text) resolved = Draw2D._resolve_layout( text, pos, scale, rect, alignment, vertical_alignment, fit_to_width, min_scale ) if resolved is None: return [], [] start_x, start_y, display_scale, line_offsets = resolved c = Draw2D._norm_colour(colour) verts: list[tuple] = [] indices: list[int] = [] if outline > 0.0: oc = Draw2D._norm_colour(outline_colour) if outline_colour is not None else (0.0, 0.0, 0.0, c[3]) d = outline * (Draw2D._font_obj.size * display_scale) for dx, dy in ((-d, 0), (d, 0), (0, -d), (0, d)): Draw2D._layout_run(text, start_x + dx, start_y + dy, display_scale, oc, line_offsets, verts, indices) Draw2D._layout_run(text, start_x, start_y, display_scale, c, line_offsets, verts, indices) return verts, indices