Source code for wolfhece.opengl.text_renderer2d

"""
Shared-resource text renderer using SDF glyph atlas.

Renders text strings as per-glyph quads textured from a
:class:`~wolfhece.opengl.glyph_atlas.GlyphAtlas`.

Features:
- **Pixel-size or world-size** text (same dual-mode as polyline width)
- **Multiline** text (split on ``\\n``)
- **Rotation** and **alignment** (left / centre / right)
- **Glow / outline** via SDF thresholding
- **Animations**: pulse, wave, typewriter

Usage::

    tr = TextRenderer2D.get_instance()
    tr.draw_text("Hello\\nWorld", x, y, mvp, viewport,
                 font_size=16, glow_enabled=True)

Author: HECE - University of Liege, Pierre Archambeau
Date: 2026

Copyright (c) 2026 University of Liege. All rights reserved.
"""

from __future__ import annotations

import logging
import ctypes
import math
from collections import OrderedDict
from typing import Callable
import numpy as np
from pathlib import Path

from OpenGL.GL import (
    glCreateShader, glShaderSource, glCompileShader, glGetShaderiv,
    glGetShaderInfoLog, glDeleteShader,
    glCreateProgram, glAttachShader, glLinkProgram, glGetProgramiv,
    glGetProgramInfoLog, glDeleteProgram,
    glUseProgram, glGetUniformLocation,
    glGenVertexArrays, glBindVertexArray, glDeleteVertexArrays,
    glGenBuffers, glBindBuffer, glBufferData, glDeleteBuffers,
    glEnableVertexAttribArray, glVertexAttribPointer,
    glDrawArrays,
    glEnable, glDisable, glIsEnabled, glBlendFunc,
    glGetIntegerv, glPolygonMode,
    glUniform1f, glUniform1i, glUniform4f,
    glUniformMatrix4fv,
    GL_VERTEX_SHADER, GL_FRAGMENT_SHADER,
    GL_COMPILE_STATUS, GL_LINK_STATUS, GL_FALSE,
    GL_ARRAY_BUFFER, GL_DYNAMIC_DRAW,
    GL_TRIANGLES,
    GL_FLOAT,
    GL_BLEND, GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA,
    GL_DEPTH_TEST, GL_POLYGON_MODE, GL_FILL, GL_FRONT_AND_BACK,
)

from .glyph_atlas import GlyphAtlas

[docs] SHADER_DIR = Path(__file__).resolve().parent.parent / "shaders"
# VBO layout: x, y, u, v, char_idx = 5 floats per vertex
[docs] _STRIDE = 5
[docs] _BYTES_PER_FLOAT = 4
# ================================================================ # Vertex builder (pure Python/numpy — no OpenGL dependency) # ================================================================
[docs] def build_text_vertices( text: str, atlas: GlyphAtlas, x: float, y: float, scale: float, rotation: float = 0.0, alignment: str = 'left', line_spacing: float = 1.2, ) -> tuple[np.ndarray, int]: """Build per-glyph quad vertex data for a text string. :param text: Text with optional ``\\n`` for multiline. :param atlas: :class:`GlyphAtlas` to use for metrics. :param x: Anchor X in world coordinates. :param y: Anchor Y in world coordinates. :param scale: World-space size of one em. :param rotation: Counter-clockwise rotation **in radians**. :param alignment: ``'left'``, ``'center'``, or ``'right'``. :param line_spacing: Line spacing as a multiple of *line_height*. :return: ``(vbo_data, vertex_count)`` — layout is ``[x, y, u, v, char_idx]`` per vertex, 6 vertices per visible glyph (two triangles). """ # Convert literal backslash-n (typed in UI fields) to real newlines. text = text.replace('\\n', '\n') lines = text.split('\n') total_chars = sum(len(ln) for ln in lines) if total_chars == 0: return np.zeros(0, dtype=np.float32), 0 cos_r = math.cos(rotation) sin_r = math.sin(rotation) # Pre-allocate (6 vertices × 5 floats per visible glyph) buf = np.empty(total_chars * 6 * _STRIDE, dtype=np.float32) vi = 0 # float-index into *buf* vert_count = 0 global_char_idx = 0 for line_idx, line in enumerate(lines): # --- measure line width for alignment --- line_w = 0.0 for ch in line: m = atlas.get_metrics(ch) if m is not None: line_w += m.advance line_w *= scale if alignment == 'center': ox = -line_w * 0.5 elif alignment == 'right': ox = -line_w else: ox = 0.0 # Vertical offset (lines stack downward) oy = -line_idx * atlas.line_height * scale * line_spacing cursor = ox for ch in line: m = atlas.get_metrics(ch) if m is None: global_char_idx += 1 continue ci = global_char_idx / max(total_chars - 1, 1) if m.quad_w <= 0 or m.quad_h <= 0: # Whitespace — advance cursor only cursor += m.advance * scale global_char_idx += 1 continue # Quad corners in LOCAL space (before rotation) # offset_y is in font coords (Y-down, negative = above baseline) lx = cursor + m.offset_x * scale ly = oy - m.offset_y * scale # flip to Y-up lw = m.quad_w * scale lh = m.quad_h * scale # TL, TR, BR, BL corners_local = ( (lx, ly), (lx + lw, ly), (lx + lw, ly - lh), (lx, ly - lh), ) # Rotate and translate to anchor corners = [] for cx, cy in corners_local: rx = cx * cos_r - cy * sin_r + x ry = cx * sin_r + cy * cos_r + y corners.append((rx, ry)) # UV corners u0 = m.uv_x v0 = m.uv_y # top of cell (low v) u1 = m.uv_x + m.uv_w v1 = m.uv_y + m.uv_h # bottom of cell (high v) uv = ((u0, v0), (u1, v0), (u1, v1), (u0, v1)) # Two triangles: TL-TR-BL, TR-BR-BL for idx in (0, 1, 3, 1, 2, 3): buf[vi] = corners[idx][0] buf[vi + 1] = corners[idx][1] buf[vi + 2] = uv[idx][0] buf[vi + 3] = uv[idx][1] buf[vi + 4] = ci vi += _STRIDE vert_count += 1 cursor += m.advance * scale global_char_idx += 1 return buf[:vi], vert_count
[docs] def measure_text( text: str, atlas: GlyphAtlas, scale: float, line_spacing: float = 1.2, ) -> tuple[float, float]: """Measure the bounding box of *text* in world units. :return: ``(width, height)`` — width of the widest line, total height including line spacing. """ lines = text.split('\n') max_w = 0.0 for line in lines: w = 0.0 for ch in line: m = atlas.get_metrics(ch) if m is not None: w += m.advance max_w = max(max_w, w * scale) n = len(lines) total_h = atlas.line_height * scale * (1 + (n - 1) * line_spacing) return max_w, total_h
# ================================================================ # Text along polyline — vertex builder # ================================================================
[docs] def _interpolate_polyline( points: np.ndarray, cum_dist: np.ndarray, s: float, ) -> tuple[float, float, float, float]: """Interpolate position and tangent along a polyline at distance *s*. :param points: ``(N, 2)`` vertex array. :param cum_dist: ``(N,)`` cumulative distance array. :param s: Curvilinear abscissa to evaluate. :return: ``(x, y, tx, ty)`` — position and **unit** tangent. """ s = max(cum_dist[0], min(s, cum_dist[-1])) idx = np.searchsorted(cum_dist, s, side='right') - 1 idx = max(0, min(idx, len(cum_dist) - 2)) seg_len = cum_dist[idx + 1] - cum_dist[idx] if seg_len < 1e-12: t = 0.0 else: t = (s - cum_dist[idx]) / seg_len px = points[idx, 0] + t * (points[idx + 1, 0] - points[idx, 0]) py = points[idx, 1] + t * (points[idx + 1, 1] - points[idx, 1]) dx = points[idx + 1, 0] - points[idx, 0] dy = points[idx + 1, 1] - points[idx, 1] length = math.sqrt(dx * dx + dy * dy) if length < 1e-12: tx, ty = 1.0, 0.0 else: tx, ty = dx / length, dy / length return px, py, tx, ty
[docs] def build_text_along_polyline( text: str, atlas: GlyphAtlas, points: np.ndarray, cum_dist: np.ndarray, scale: float, offset_along: float = 0.0, offset_perp: float = 0.0, alignment: str = 'left', ) -> tuple[np.ndarray, int]: """Build per-glyph quad vertex data for text that follows a polyline. Each character is placed at the appropriate curvilinear distance along the polyline and rotated to match the local tangent direction. :param text: Text string (single line). :param atlas: SDF :class:`GlyphAtlas` for metrics. :param points: ``(N, 2)`` polyline vertices in world coords. :param cum_dist: ``(N,)`` cumulative distances (from :meth:`get_sz`). :param scale: World-space size of one em. :param offset_along: Shift the start of the text along the polyline (in world units). Positive = downstream. :param offset_perp: Perpendicular offset from the polyline (in world units). Positive = left side. :param alignment: ``'left'`` | ``'center'`` | ``'right'``. :return: ``(vbo_data, vertex_count)`` — same layout as :func:`build_text_vertices`. """ if len(text) == 0 or len(points) < 2: return np.zeros(0, dtype=np.float32), 0 total_length = cum_dist[-1] - cum_dist[0] # Measure total text width text_width = 0.0 for ch in text: m = atlas.get_metrics(ch) if m is not None: text_width += m.advance * scale # Compute start position along polyline if alignment == 'center': start_s = offset_along + (total_length - text_width) * 0.5 elif alignment == 'right': start_s = offset_along + total_length - text_width else: start_s = offset_along buf = np.empty(len(text) * 6 * _STRIDE, dtype=np.float32) vi = 0 vert_count = 0 total_chars = len(text) cursor_s = start_s for char_idx, ch in enumerate(text): m = atlas.get_metrics(ch) if m is None: continue ci = char_idx / max(total_chars - 1, 1) if m.quad_w <= 0 or m.quad_h <= 0: cursor_s += m.advance * scale continue # Centre of character along polyline char_center_s = cursor_s + m.advance * scale * 0.5 # Clamp to polyline range if char_center_s < cum_dist[0] or char_center_s > cum_dist[-1]: cursor_s += m.advance * scale continue px, py, tx, ty = _interpolate_polyline(points, cum_dist, char_center_s) # Normal (perpendicular, pointing left) nx, ny = -ty, tx # Apply perpendicular offset px += nx * offset_perp py += ny * offset_perp # Local coordinate system: tangent = X-right, normal = Y-up cos_r = tx sin_r = ty # Quad in local character space (centred on character) half_adv = m.advance * scale * 0.5 lx = -half_adv + m.offset_x * scale ly = -m.offset_y * scale lw = m.quad_w * scale lh = m.quad_h * scale corners_local = ( (lx, ly), (lx + lw, ly), (lx + lw, ly - lh), (lx, ly - lh), ) corners = [] for cx, cy in corners_local: rx = cx * cos_r - cy * sin_r + px ry = cx * sin_r + cy * cos_r + py corners.append((rx, ry)) u0 = m.uv_x v0 = m.uv_y u1 = m.uv_x + m.uv_w v1 = m.uv_y + m.uv_h uv = ((u0, v0), (u1, v0), (u1, v1), (u0, v1)) for idx in (0, 1, 3, 1, 2, 3): buf[vi] = corners[idx][0] buf[vi + 1] = corners[idx][1] buf[vi + 2] = uv[idx][0] buf[vi + 3] = uv[idx][1] buf[vi + 4] = ci vi += _STRIDE vert_count += 1 cursor_s += m.advance * scale return buf[:vi], vert_count
# ================================================================ # Snap-to-polyline utility # ================================================================
[docs] def snap_to_polyline( mouse_x: float, mouse_y: float, points: np.ndarray, cum_dist: np.ndarray, ) -> tuple[float, float, float, float, float, float]: """Find the closest point on a polyline to a mouse position. Uses vectorised numpy operations — no Shapely dependency. :param mouse_x: Mouse X in world coordinates. :param mouse_y: Mouse Y in world coordinates. :param points: ``(N, 2)`` polyline vertices. :param cum_dist: ``(N,)`` cumulative distances. :return: ``(snap_x, snap_y, curvi_dist, total_length, tangent_x, tangent_y)`` """ n = len(points) if n < 2: return points[0, 0], points[0, 1], 0.0, 0.0, 1.0, 0.0 # Vectorised closest-point-on-segment computation ax = points[:-1, 0] ay = points[:-1, 1] bx = points[1:, 0] by = points[1:, 1] dx = bx - ax dy = by - ay seg_len_sq = dx * dx + dy * dy seg_len_sq = np.maximum(seg_len_sq, 1e-24) t = ((mouse_x - ax) * dx + (mouse_y - ay) * dy) / seg_len_sq t = np.clip(t, 0.0, 1.0) proj_x = ax + t * dx proj_y = ay + t * dy dist_sq = (proj_x - mouse_x) ** 2 + (proj_y - mouse_y) ** 2 best = np.argmin(dist_sq) snap_x = float(proj_x[best]) snap_y = float(proj_y[best]) t_best = float(t[best]) seg_len = math.sqrt(float(dx[best]) ** 2 + float(dy[best]) ** 2) curvi = float(cum_dist[best]) + t_best * seg_len total = float(cum_dist[-1]) if seg_len < 1e-12: tang_x, tang_y = 1.0, 0.0 else: tang_x = float(dx[best]) / seg_len tang_y = float(dy[best]) / seg_len return snap_x, snap_y, curvi, total, tang_x, tang_y
# ================================================================ # TextRenderer2D singleton # ================================================================
[docs] class TextRenderer2D: """Singleton text renderer: compiles shaders once, manages shared VAO/VBO. Call :meth:`draw_text` for each piece of text to render. """
[docs] _instance: "TextRenderer2D | None" = None
[docs] _TEXT_GEOM_CACHE_MAX = 512
[docs] _TEXT_MEASURE_CACHE_MAX = 256
def __init__(self):
[docs] self._program: int | None = None
[docs] self._locs: dict | None = None
[docs] self._vao: int | None = None
[docs] self._vbo: int | None = None
[docs] self._text_geom_cache: OrderedDict = OrderedDict()
[docs] self._text_measure_cache: OrderedDict = OrderedDict()
@classmethod
[docs] def get_instance(cls) -> "TextRenderer2D": """Return the singleton, creating it lazily.""" if cls._instance is None: cls._instance = cls() return cls._instance
# ---------------------------------------------------------------- # Shader compilation # ----------------------------------------------------------------
[docs] def _init_program(self): """Compile and link vertex + fragment shaders.""" if self._program is not None: return vs = self._compile(GL_VERTEX_SHADER, SHADER_DIR / "text_vertex.glsl") fs = self._compile(GL_FRAGMENT_SHADER, SHADER_DIR / "text_frag.glsl") prog = glCreateProgram() glAttachShader(prog, vs) glAttachShader(prog, fs) glLinkProgram(prog) if glGetProgramiv(prog, GL_LINK_STATUS) == GL_FALSE: info = glGetProgramInfoLog(prog) glDeleteProgram(prog) glDeleteShader(vs) glDeleteShader(fs) raise RuntimeError(f"Text shader link failed: {info}") glDeleteShader(vs) glDeleteShader(fs) self._program = prog glUseProgram(prog) self._locs = {} for name in ('mvp', 'atlas', 'textColor', 'smoothing', 'glowEnabled', 'glowWidth', 'glowColor', 'animMode', 'animPhase', 'animSpeed'): self._locs[name] = glGetUniformLocation(prog, name) glUseProgram(0)
@staticmethod
[docs] def _compile(shader_type: int, path: Path) -> int: """Compile a single GLSL shader from *path*.""" s = glCreateShader(shader_type) with open(path, 'r') as f: glShaderSource(s, f.read()) glCompileShader(s) if glGetShaderiv(s, GL_COMPILE_STATUS) == GL_FALSE: info = glGetShaderInfoLog(s) glDeleteShader(s) raise RuntimeError(f"Shader compile error ({path.name}): {info}") return s
# ---------------------------------------------------------------- # VAO / VBO # ----------------------------------------------------------------
[docs] def _ensure_vao_vbo(self): """Create VAO/VBO if not yet allocated.""" if self._vao is not None: return self._vao = glGenVertexArrays(1) self._vbo = glGenBuffers(1) glBindVertexArray(self._vao) glBindBuffer(GL_ARRAY_BUFFER, self._vbo) stride = _STRIDE * _BYTES_PER_FLOAT # location 0: aPos (vec2) glEnableVertexAttribArray(0) glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, stride, ctypes.c_void_p(0)) # location 1: aTexCoord (vec2) glEnableVertexAttribArray(1) glVertexAttribPointer(1, 2, GL_FLOAT, GL_FALSE, stride, ctypes.c_void_p(2 * _BYTES_PER_FLOAT)) # location 2: aCharIdx (float) glEnableVertexAttribArray(2) glVertexAttribPointer(2, 1, GL_FLOAT, GL_FALSE, stride, ctypes.c_void_p(4 * _BYTES_PER_FLOAT)) glBindVertexArray(0)
[docs] def _upload(self, vbo_data: np.ndarray): """Upload vertex data to the shared VBO.""" glBindBuffer(GL_ARRAY_BUFFER, self._vbo) glBufferData(GL_ARRAY_BUFFER, vbo_data.nbytes, vbo_data, GL_DYNAMIC_DRAW) glBindBuffer(GL_ARRAY_BUFFER, 0)
# ---------------------------------------------------------------- # Small CPU caches (geometry / measure) # ---------------------------------------------------------------- @staticmethod
[docs] def _q(val: float, ndigits: int = 7) -> float: """Quantize a float for stable cache keys.""" return round(float(val), ndigits)
@staticmethod
[docs] def _cache_get(cache: OrderedDict, key): """Get value from OrderedDict LRU cache and refresh recency.""" if key in cache: cache.move_to_end(key) return cache[key] return None
@staticmethod
[docs] def _cache_set(cache: OrderedDict, key, value, max_size: int): """Insert value into OrderedDict LRU cache with bounded size.""" cache[key] = value cache.move_to_end(key) while len(cache) > max_size: cache.popitem(last=False)
[docs] def _measure_text_cached(self, text: str, atlas: GlyphAtlas, line_spacing: float) -> tuple[float, float]: """Measure text with a small LRU cache (hot path in width-priority mode).""" key = ( atlas.font_name.lower(), text, self._q(line_spacing), ) cached = self._cache_get(self._text_measure_cache, key) if cached is not None: return cached val = measure_text(text, atlas, scale=1.0, line_spacing=line_spacing) self._cache_set(self._text_measure_cache, key, val, self._TEXT_MEASURE_CACHE_MAX) return val
[docs] def _build_text_vertices_cached(self, text: str, atlas: GlyphAtlas, x: float, y: float, scale: float, rotation: float, alignment: str, line_spacing: float) -> tuple[np.ndarray, int]: """Build per-glyph vertices using an LRU cache for static labels.""" key = ( atlas.font_name.lower(), text, self._q(x), self._q(y), self._q(scale), self._q(rotation), alignment, self._q(line_spacing), ) cached = self._cache_get(self._text_geom_cache, key) if cached is not None: return cached vbo_data, vert_count = build_text_vertices( text, atlas, x, y, scale, rotation=rotation, alignment=alignment, line_spacing=line_spacing, ) self._cache_set(self._text_geom_cache, key, (vbo_data, vert_count), self._TEXT_GEOM_CACHE_MAX) return vbo_data, vert_count
# ---------------------------------------------------------------- # Public drawing API # ----------------------------------------------------------------
[docs] def draw_text( self, text: str, x: float, y: float, mvp: np.ndarray, viewport: tuple[int, int], *, font_name: str = "arial.ttf", font_size: float = 14.0, color: tuple[float, ...] = (1.0, 1.0, 1.0, 1.0), size_in_pixels: bool = True, world_height: float | None = None, world_width: float | None = None, rotation: float = 0.0, alignment: str = 'left', line_spacing: float = 1.2, smoothing: float = 1.0, glow_enabled: bool = False, glow_width: float = 0.15, glow_color: tuple[float, ...] = (1.0, 1.0, 1.0, 0.5), anim_mode: int = 0, anim_phase: float = 0.0, anim_speed: float = 1.0, ): """Render *text* at world position (x, y). :param text: Text string (supports ``\\n`` for multiline). :param x: Anchor X in world coordinates. :param y: Anchor Y in world coordinates. :param mvp: 4×4 model-view-projection (column-major float32). :param viewport: ``(width_px, height_px)``. :param font_name: TrueType font file name. :param font_size: Size in pixels (if *size_in_pixels*) or world units. :param color: ``(r, g, b, a)`` each in ``[0, 1]``. :param size_in_pixels: If ``True`` *font_size* is screen pixels. :param world_height: If set, overrides *font_size* with this world-unit height. :param world_width: If set (and *world_height* is ``None``), scales the text so its measured width matches this world-unit value. :param rotation: Counter-clockwise rotation in **degrees**. :param alignment: ``'left'`` | ``'center'`` | ``'right'``. :param line_spacing: Line spacing multiplier. :param smoothing: SDF edge-width multiplier (1.0 = standard AA). :param glow_enabled: Enable glow / outline halo. :param glow_width: SDF threshold offset for glow (e.g. 0.15). :param glow_color: ``(r, g, b, a)`` for the glow. :param anim_mode: 0 = none, 1 = pulse, 2 = wave, 3 = typewriter. :param anim_phase: Phase in ``[0, 1]``. :param anim_speed: Speed multiplier (reserved for future use). """ if not text: return self._init_program() self._ensure_vao_vbo() # Atlas atlas = GlyphAtlas.get(font_name) # Scale: world units per em if world_height is not None: scale = world_height elif world_width is not None: # Width-priority mode: derive scale from measured text width. ref_width, _ = self._measure_text_cached( text.replace('\\n', '\n'), atlas, line_spacing=line_spacing, ) if ref_width > 1e-12: scale = float(world_width) / ref_width else: scale = 0.0 elif size_in_pixels: ppwu = abs(mvp[0, 0]) * viewport[0] * 0.5 world_per_px = 1.0 / max(ppwu, 1e-12) scale = font_size * world_per_px else: scale = font_size if scale <= 0.0: return # Build vertices rot_rad = rotation * math.pi / 180.0 vbo_data, vert_count = self._build_text_vertices_cached( text, atlas, x, y, scale, rotation=rot_rad, alignment=alignment, line_spacing=line_spacing, ) if vert_count == 0: return mvp = np.ascontiguousarray(mvp, dtype=np.float32) # Upload self._upload(vbo_data) # Activate shader glUseProgram(self._program) locs = self._locs glUniformMatrix4fv(locs['mvp'], 1, GL_FALSE, mvp) glUniform1i(locs['atlas'], 0) # texture unit 0 glUniform4f(locs['textColor'], *color) glUniform1f(locs['smoothing'], smoothing) glUniform1i(locs['glowEnabled'], 1 if glow_enabled else 0) glUniform1f(locs['glowWidth'], glow_width) glUniform4f(locs['glowColor'], *glow_color) glUniform1i(locs['animMode'], anim_mode) glUniform1f(locs['animPhase'], anim_phase) glUniform1f(locs['animSpeed'], anim_speed) # Draw glEnable(GL_BLEND) glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA) # Save & set GL state ------------------------------------------ depth_was_enabled = glIsEnabled(GL_DEPTH_TEST) if depth_was_enabled: glDisable(GL_DEPTH_TEST) prev_poly_mode = glGetIntegerv(GL_POLYGON_MODE) glPolygonMode(GL_FRONT_AND_BACK, GL_FILL) # --------------------------------------------------------------- atlas.bind(0) glBindVertexArray(self._vao) glDrawArrays(GL_TRIANGLES, 0, vert_count) glBindVertexArray(0) # Restore GL state ---------------------------------------------- if prev_poly_mode is not None and len(prev_poly_mode) >= 2: glPolygonMode(GL_FRONT_AND_BACK, int(prev_poly_mode[0])) if depth_was_enabled: glEnable(GL_DEPTH_TEST) # --------------------------------------------------------------- glDisable(GL_BLEND) glUseProgram(0)
# ---------------------------------------------------------------- # Text along polyline # ----------------------------------------------------------------
[docs] def draw_text_along_polyline( self, text: str, points: np.ndarray, cum_dist: np.ndarray, mvp: np.ndarray, viewport: tuple[int, int], *, font_name: str = "arial.ttf", font_size: float = 14.0, color: tuple[float, ...] = (1.0, 1.0, 1.0, 1.0), size_in_pixels: bool = True, world_height: float | None = None, world_width: float | None = None, offset_along: float = 0.0, offset_perp: float = 0.0, alignment: str = 'left', smoothing: float = 1.0, glow_enabled: bool = False, glow_width: float = 0.15, glow_color: tuple[float, ...] = (1.0, 1.0, 1.0, 0.5), anim_mode: int = 0, anim_phase: float = 0.0, anim_speed: float = 1.0, ): """Render *text* along a polyline, each character following the tangent. :param text: Text string (single line). :param points: ``(N, 2)`` polyline vertices in world coords. :param cum_dist: ``(N,)`` cumulative distances. :param mvp: 4×4 model-view-projection (column-major float32). :param viewport: ``(width_px, height_px)``. :param font_name: TrueType font file name. :param font_size: Size in pixels (if *size_in_pixels*) or world units. :param color: ``(r, g, b, a)`` each in ``[0, 1]``. :param size_in_pixels: If ``True`` *font_size* is screen pixels. :param world_height: If set, overrides *font_size* with this world-unit height. :param world_width: If set (and *world_height* is ``None``), scales the text so its measured width matches this world-unit value. :param offset_along: Shift text start along polyline (world units). :param offset_perp: Perpendicular offset (world units, +left). :param alignment: ``'left'`` | ``'center'`` | ``'right'``. :param smoothing: SDF edge-width multiplier. :param glow_enabled: Enable glow / outline halo. :param glow_width: SDF threshold offset for glow. :param glow_color: ``(r, g, b, a)`` for the glow. :param anim_mode: 0 = none, 1 = pulse, 2 = wave, 3 = typewriter. :param anim_phase: Phase in ``[0, 1]``. :param anim_speed: Speed multiplier. """ if not text or len(points) < 2: return self._init_program() self._ensure_vao_vbo() atlas = GlyphAtlas.get(font_name) # Scale: world units per em if world_height is not None: scale = world_height elif world_width is not None: ref_width, _ = measure_text( text.replace('\\n', '\n'), atlas, scale=1.0, line_spacing=1.2, ) if ref_width > 1e-12: scale = float(world_width) / ref_width else: scale = 0.0 elif size_in_pixels: ppwu = abs(mvp[0, 0]) * viewport[0] * 0.5 world_per_px = 1.0 / max(ppwu, 1e-12) scale = font_size * world_per_px else: scale = font_size if scale <= 0.0: return vbo_data, vert_count = build_text_along_polyline( text, atlas, points, cum_dist, scale, offset_along=offset_along, offset_perp=offset_perp, alignment=alignment, ) if vert_count == 0: return mvp = np.ascontiguousarray(mvp, dtype=np.float32) self._upload(vbo_data) glUseProgram(self._program) locs = self._locs glUniformMatrix4fv(locs['mvp'], 1, GL_FALSE, mvp) glUniform1i(locs['atlas'], 0) glUniform4f(locs['textColor'], *color) glUniform1f(locs['smoothing'], smoothing) glUniform1i(locs['glowEnabled'], 1 if glow_enabled else 0) glUniform1f(locs['glowWidth'], glow_width) glUniform4f(locs['glowColor'], *glow_color) glUniform1i(locs['animMode'], anim_mode) glUniform1f(locs['animPhase'], anim_phase) glUniform1f(locs['animSpeed'], anim_speed) glEnable(GL_BLEND) glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA) # Save & set GL state ------------------------------------------ depth_was_enabled = glIsEnabled(GL_DEPTH_TEST) if depth_was_enabled: glDisable(GL_DEPTH_TEST) prev_poly_mode = glGetIntegerv(GL_POLYGON_MODE) glPolygonMode(GL_FRONT_AND_BACK, GL_FILL) # --------------------------------------------------------------- atlas.bind(0) glBindVertexArray(self._vao) glDrawArrays(GL_TRIANGLES, 0, vert_count) glBindVertexArray(0) # Restore GL state ---------------------------------------------- if prev_poly_mode is not None and len(prev_poly_mode) >= 2: glPolygonMode(GL_FRONT_AND_BACK, int(prev_poly_mode[0])) if depth_was_enabled: glEnable(GL_DEPTH_TEST) # --------------------------------------------------------------- glDisable(GL_BLEND) glUseProgram(0)
# ---------------------------------------------------------------- # Dynamic tracking label # ----------------------------------------------------------------
[docs] def draw_tracking_label( self, mouse_x: float, mouse_y: float, points: np.ndarray, cum_dist: np.ndarray, mvp: np.ndarray, viewport: tuple[int, int], *, format_func: "Callable[[float, float], str] | None" = None, font_name: str = "arial.ttf", font_size: float = 14.0, color: tuple[float, ...] = (1.0, 1.0, 0.0, 1.0), size_in_pixels: bool = True, world_height: float | None = None, offset_perp: float = 0.0, smoothing: float = 1.0, glow_enabled: bool = True, glow_width: float = 0.2, glow_color: tuple[float, ...] = (0.0, 0.0, 0.0, 0.8), snap_radius: float | None = None, ): """Render a label that tracks the mouse snapped to the polyline. The label shows dynamic content (by default the curvilinear distance) and follows the mouse position projected onto the polyline. :param mouse_x: Mouse X in world coordinates. :param mouse_y: Mouse Y in world coordinates. :param points: ``(N, 2)`` polyline vertices in world coords. :param cum_dist: ``(N,)`` cumulative distances. :param mvp: 4×4 model-view-projection (column-major float32). :param viewport: ``(width_px, height_px)``. :param format_func: ``(curvi_dist, total_length) -> str``. Defaults to ``"d = {curvi:.2f} m"``. :param font_name: TrueType font file name. :param font_size: Size in pixels (if *size_in_pixels*) or world units. :param color: ``(r, g, b, a)`` — yellow by default. :param size_in_pixels: If ``True``, *font_size* is screen pixels. :param world_height: If set, overrides *font_size*. :param offset_perp: Perpendicular offset (world units, positive = left of polyline direction). :param smoothing: SDF edge-width multiplier. :param glow_enabled: Enable background halo for readability. :param glow_width: Glow SDF threshold. :param glow_color: ``(r, g, b, a)`` — dark semi-transparent default. :param snap_radius: If set, label is hidden when the mouse is farther than this distance (in world units) from the polyline. """ if len(points) < 2: return sx, sy, curvi, total, tang_x, tang_y = snap_to_polyline( mouse_x, mouse_y, points, cum_dist, ) # Optional snap radius filtering if snap_radius is not None: dist = math.sqrt((sx - mouse_x) ** 2 + (sy - mouse_y) ** 2) if dist > snap_radius: return # Format label text if format_func is not None: label = format_func(curvi, total) else: label = f"d = {curvi:.2f} m" # Compute rotation from tangent (degrees) rotation = math.degrees(math.atan2(tang_y, tang_x)) # Apply perpendicular offset nx, ny = -tang_y, tang_x draw_x = sx + nx * offset_perp draw_y = sy + ny * offset_perp self.draw_text( label, draw_x, draw_y, mvp, viewport, font_name=font_name, font_size=font_size, color=color, size_in_pixels=size_in_pixels, world_height=world_height, rotation=rotation, alignment='left', smoothing=smoothing, glow_enabled=glow_enabled, glow_width=glow_width, glow_color=glow_color, )
# ---------------------------------------------------------------- # Cleanup # ----------------------------------------------------------------
[docs] def destroy(self): """Free GPU resources.""" if self._vao is not None: glDeleteVertexArrays(1, [self._vao]) self._vao = None if self._vbo is not None: glDeleteBuffers(1, [self._vbo]) self._vbo = None if self._program is not None: glDeleteProgram(self._program) self._program = None self._text_geom_cache.clear() self._text_measure_cache.clear()