Source code for simvx.core.nodes_2d.text

"""Text2D -- screen-pinned text drawn through the one 2D text builder."""

import math

from ..descriptors import Property
from ..properties import Colour
from .node2d import Node2D


[docs] class Text2D(Node2D): """Screen-pinned text for HUD / UI. Works in both 2D and 3D modes. Text2D draws through the ONE 2D text builder (``renderer.draw_text``): the item path emits a native kerned MSDF ``GLYPH`` item, and the ``Draw2D`` ``_draw_recursive`` walk (editor play-mode / web) appends a matching ``TEXT`` op -- both from the same layout, so they render identically. The text is **screen-pinned**: it draws in screen space (``screen_space=True``) so it stays fixed while a Camera2D pans the world -- the usual HUD behaviour. It draws at its :attr:`~Node2D.world_position` and at its :attr:`~Node2D.world_scale`, so a label parented under a moving, scaled node is carried *and* resized by that node; parented at the root (the common HUD case) world equals local, ``position`` is the screen coordinate directly and ``font_scale`` is the only size input. Sizing: ``font_scale`` is a multiplier on the 16px logical em (``font_scale=1`` -> 16 logical px) and the node's world scale multiplies on top of it, the same way a Sprite2D's draw size is its texture size times its world scale. A world scale of 0 on either axis hides the label, as it hides a sprite. Three properties of the 2D text builder shape what an ancestor transform can do to the glyphs: * **One glyph size, not two.** A glyph run is laid out from a single scalar size, so a non-uniform world scale is reduced to the geometric mean ``sqrt(|sx * sy|)``: exact for a uniform scale, area-preserving otherwise, and never silently favouring one axis. A ``rect`` box keeps its per-axis extent (``w * |sx|``, ``h * |sy|``) because a box can express both axes. A negative (mirrored) scale contributes its magnitude only; glyph runs are not mirrored. * **Axis-aligned runs.** The builder emits upright glyph quads and takes no rotation, so a rotated ancestor moves the anchor (via ``world_position``) but leaves the text upright. A ``rect`` box cannot follow the rotation either, so it is placed by its CENTRE: the authored centre lands exactly where the ancestor puts it and the upright extent is rebuilt around it, which is the closest an axis-aligned box can sit to the region meant. * **A readable-pixel floor the builder applies by default**, clamping tiny text up to ~10px. A world-scaled label opts out of it, so the glyphs shrink in proportion with the box (and with ``fit_to_width``) instead of clamping and overflowing; an unscaled label keeps the floor. Scaling an ancestor far enough down therefore makes the text illegibly small, exactly as it makes a sprite unreadably small. Common port patterns are first-class node Properties: * ``outline`` / ``outline_colour`` -- a readable halo on any background. * ``rect`` -- position + align inside a box instead of at a point. * ``fit_to_width`` -- shrink ``font_scale`` so the text fits ``rect`` width. """ text = Property("", hint="Text to display") font_scale = Property(1.0, range=(0.1, 100.0), hint="Font size (multiplier on 16px em)") colour = Colour((1.0, 1.0, 1.0, 1.0)) # Horizontal anchor for ``position`` / box alignment inside ``rect``: "left" # puts position.x at the text's left edge, "centre" centres on it, "right" puts # it at the right edge. Each line is measured by the builder, so centring is # exact. align = Property("left", enum=["left", "centre", "right"], hint="Horizontal anchor") # Outline halo: a 4-direction offset copy in # ``outline_colour`` under the glyphs. ``outline`` is in glyph-em units (~0.08 # is a typical 1-2px halo); 0 disables it (the common case, zero added cost). outline = Property(0.0, range=(0.0, 0.5), hint="Outline thickness (em units; 0 = none)") outline_colour = Colour((0.0, 0.0, 0.0, 1.0)) # Box mode: when set, the text is positioned + aligned inside this rect # (x, y, w, h) instead of anchored at ``position``. The box lives in the same # space as ``position`` (the parent's), so an ancestor transform carries it; # its extent scales with the node's world scale, so the whole box grows with a # scaled ancestor and ``fit_to_width`` fits in that same scaled space (the # readable-pixel floor is waived there so box and glyphs shrink together). rect = Property(None, hint="(x, y, w, h) box to align text inside (None = point at position)") # Shrink font_scale so the text fits the box (or position.x..+width) horizontally. fit_to_width = Property(False, hint="Shrink font_scale so text fits the rect/box width") def _world_box(self, box, sx: float, sy: float, scaled: bool) -> tuple[float, float, float, float]: """Map the parent-space ``rect`` to the axis-aligned world box to draw in. ``position`` and ``rect`` both live in the parent's space. ``position`` maps through :attr:`~Node2D.world_position`; ``rect`` carries its own origin and extent, so it needs the parent transform applied by hand: the extent scales with the node's world scale and the origin rides the parent. An axis-aligned box cannot follow a rotation, so a rotated parent places the box by its CENTRE (see the class docstring) instead of by its corner, which would leave the box hanging off the wrong side of the anchor. Without a parent rotation that is the transformed corner exactly, and with no ``Node2D`` parent the box passes through untouched. """ bw, bh = (box[2] * abs(sx), box[3] * abs(sy)) if scaled else (box[2], box[3]) parent = self.parent if not isinstance(parent, Node2D): return float(box[0]), float(box[1]), bw, bh ppos, pscale, prot = parent.world_transform if not prot: return float(ppos.x + box[0] * pscale.x), float(ppos.y + box[1] * pscale.y), bw, bh cx, cy = (box[0] + box[2] * 0.5) * pscale.x, (box[1] + box[3] * 0.5) * pscale.y c, s = math.cos(prot), math.sin(prot) cx, cy = cx * c - cy * s, cx * s + cy * c return float(ppos.x + cx - bw * 0.5), float(ppos.y + cy - bh * 0.5), bw, bh
[docs] def on_draw(self, renderer) -> None: if not self.text: return ox, oy = getattr(self.tree, "overlay_offset", (0.0, 0.0)) if self.tree is not None else (0.0, 0.0) fc = self.colour if fc[0] > 1.0 or fc[1] > 1.0 or fc[2] > 1.0: # normalise 0-255 int colours fc = tuple(c / 255.0 for c in fc) oc = self.outline_colour if oc[0] > 1.0 or oc[1] > 1.0 or oc[2] > 1.0: oc = tuple(c / 255.0 for c in oc) # One dirty-flag check for all three world components (this is on_draw). # ``tolist`` pulls both scale axes across the numpy boundary in one call, # cheaper than indexing them one at a time. pos, wscale, _rotation = self.world_transform sx, sy = wscale.tolist() scaled = sx != 1.0 or sy != 1.0 if scaled: k = math.sqrt(abs(sx * sy)) if k == 0.0: return # a zero world scale hides the label, as it hides a sprite scale = self.font_scale * k # Scaled text opts out of the builder's readable-pixel floor: box and # glyphs must shrink together or a fitted label overflows its own box. min_scale: float | None = 0.0 else: # Unscaled (the overwhelmingly common unparented HUD label) passes # font_scale and the box extent through untouched, floor and all. scale = self.font_scale min_scale = None box = self.rect if box is not None: bx, by, bw, bh = self._world_box(box, sx, sy, scaled) renderer.draw_text( self.text, rect=(bx + ox, by + oy, bw, bh), scale=scale, colour=fc, alignment=self.align, fit_to_width=self.fit_to_width, min_scale=min_scale, outline=self.outline, outline_colour=oc, screen_space=True, ) else: renderer.draw_text( self.text, (pos.x + ox, pos.y + oy), scale=scale, colour=fc, alignment=self.align, fit_to_width=self.fit_to_width, min_scale=min_scale, outline=self.outline, outline_colour=oc, screen_space=True, )