"""
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
# ================================================================
# 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()