"""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