e2D 2.0.0__cp313-cp313-win_amd64.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
e2D/text_renderer.py ADDED
@@ -0,0 +1,491 @@
1
+ from enum import Enum
2
+ from typing import Optional
3
+ from PIL import Image, ImageFont
4
+ from attr import dataclass
5
+ import numpy as np
6
+ import moderngl
7
+
8
+ @dataclass
9
+ class TextStyle:
10
+ font: str = "arial.ttf"
11
+ font_size: int = 32
12
+ color: tuple[float, float, float, float] = (1.0, 1.0, 1.0, 1.0)
13
+ bg_color: tuple[float, float, float, float] = (0.0, 0.0, 0.0, 0.9)
14
+ bg_margin: float | tuple[float, float, float, float] = 15.0
15
+ bg_border_radius: float | tuple[float, float, float, float] = 15.0
16
+
17
+ class Pivots(Enum):
18
+ TOP_LEFT = 0
19
+ TOP_MIDDLE = 1
20
+ TOP_RIGHT = 2
21
+ LEFT = 3
22
+ CENTER = 4
23
+ RIGHT = 5
24
+ BOTTOM_LEFT = 6
25
+ BOTTOM_MIDDLE = 7
26
+ BOTTOM_RIGHT = 8
27
+
28
+ DEFAULT_TEXT_STYLE = TextStyle()
29
+
30
+ class TextLabel:
31
+ def __init__(self, ctx: moderngl.Context, prog: moderngl.Program, texture: moderngl.Texture, vertices: list,
32
+ bg_prog: Optional[moderngl.Program] = None, bg_vertices: Optional[list] = None) -> None:
33
+ """
34
+ A pre-rendered text label for efficient drawing.
35
+ To generate select a option below:
36
+ - use TextRenderer.create_label()
37
+ - rootEnv.print(..., save_cache = True) will return a TextLabel.
38
+ """
39
+ self.ctx = ctx
40
+ self.prog = prog
41
+ self.texture = texture
42
+ self.vertices = vertices
43
+ self.vbo = self.ctx.buffer(np.array(vertices, dtype='f4').tobytes())
44
+ self.vao = self.ctx.vertex_array(self.prog, [
45
+ (self.vbo, '2f 2f 4f', 'in_pos', 'in_uv', 'in_color')
46
+ ])
47
+
48
+ # Background rendering
49
+ self.bg_prog = bg_prog
50
+ self.bg_vertices = bg_vertices
51
+ if bg_prog and bg_vertices:
52
+ self.bg_vbo = self.ctx.buffer(np.array(bg_vertices, dtype='f4').tobytes())
53
+ self.bg_vao = self.ctx.vertex_array(self.bg_prog, [
54
+ (self.bg_vbo, '2f 4f 4f 4f', 'in_pos', 'in_color', 'in_rect', 'in_radius')
55
+ ])
56
+ else:
57
+ self.bg_vbo = None
58
+ self.bg_vao = None
59
+
60
+ def draw(self) -> None:
61
+ self.ctx.enable(moderngl.BLEND)
62
+
63
+ # Draw background first if exists
64
+ if self.bg_vao and self.bg_prog:
65
+ self.bg_prog['resolution'] = self.ctx.viewport[2:]
66
+ self.bg_vao.render(moderngl.TRIANGLES)
67
+
68
+ # Draw text
69
+ self.prog['resolution'] = self.ctx.viewport[2:]
70
+ self.texture.use(0)
71
+ self.vao.render(moderngl.TRIANGLES)
72
+
73
+ class TextRenderer:
74
+ """
75
+ Renders text using a texture atlas generated from a TTF font via Pillow.
76
+ Supports multiple fonts and sizes with caching for optimization.
77
+ """
78
+ def __init__(self, ctx: moderngl.Context) -> None:
79
+ self.ctx = ctx
80
+
81
+ # Cache for font atlases: (font_path, font_size) -> {font, char_data, texture}
82
+ self.font_cache = {}
83
+
84
+ # Character set to render
85
+ self.chars = " !\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~"
86
+
87
+ # Background shader for rounded rectangles
88
+ self.bg_prog = self.ctx.program(
89
+ vertex_shader="""
90
+ #version 430
91
+ uniform vec2 resolution;
92
+
93
+ in vec2 in_pos;
94
+ in vec4 in_color;
95
+ in vec4 in_rect; // x, y, width, height
96
+ in vec4 in_radius; // top-left, top-right, bottom-right, bottom-left
97
+
98
+ out vec4 v_color;
99
+ out vec2 v_pos;
100
+ out vec4 v_rect;
101
+ out vec4 v_radius;
102
+
103
+ void main() {
104
+ vec2 ndc = (in_pos / resolution) * 2.0 - 1.0;
105
+ ndc.y = -ndc.y;
106
+ gl_Position = vec4(ndc, 0.0, 1.0);
107
+ v_color = in_color;
108
+ v_pos = in_pos;
109
+ v_rect = in_rect;
110
+ v_radius = in_radius;
111
+ }
112
+ """,
113
+ fragment_shader="""
114
+ #version 430
115
+
116
+ in vec4 v_color;
117
+ in vec2 v_pos;
118
+ in vec4 v_rect;
119
+ in vec4 v_radius;
120
+ out vec4 f_color;
121
+
122
+ float roundedBoxSDF(vec2 center, vec2 size, vec4 radius) {
123
+ vec2 q = abs(center) - size + vec2(radius.x);
124
+
125
+ // Select the appropriate corner radius
126
+ float r = radius.x;
127
+ if (center.x > 0.0 && center.y > 0.0) r = radius.z; // bottom-right
128
+ else if (center.x > 0.0 && center.y < 0.0) r = radius.y; // top-right
129
+ else if (center.x < 0.0 && center.y > 0.0) r = radius.w; // bottom-left
130
+ else r = radius.x; // top-left
131
+
132
+ return min(max(q.x, q.y), 0.0) + length(max(q, 0.0)) - r;
133
+ }
134
+
135
+ void main() {
136
+ vec2 rect_center = v_rect.xy + v_rect.zw * 0.5;
137
+ vec2 rect_size = v_rect.zw * 0.5;
138
+ vec2 pos_from_center = v_pos - rect_center;
139
+
140
+ float dist = roundedBoxSDF(pos_from_center, rect_size, v_radius);
141
+ float alpha = 1.0 - smoothstep(-1.0, 1.0, dist);
142
+
143
+ f_color = vec4(v_color.rgb, v_color.a * alpha);
144
+ }
145
+ """
146
+ )
147
+
148
+ # Background VBO
149
+ self.bg_vbo = self.ctx.buffer(reserve=4096)
150
+ self.bg_vao = self.ctx.vertex_array(self.bg_prog, [
151
+ (self.bg_vbo, '2f 4f 4f 4f', 'in_pos', 'in_color', 'in_rect', 'in_radius')
152
+ ])
153
+
154
+ # Shader
155
+ self.prog = self.ctx.program(
156
+ vertex_shader="""
157
+ #version 430
158
+ uniform vec2 resolution;
159
+
160
+ in vec2 in_pos;
161
+ in vec2 in_uv;
162
+ in vec4 in_color;
163
+
164
+ out vec2 v_uv;
165
+ out vec4 v_color;
166
+
167
+ void main() {
168
+ // Pixel to NDC
169
+ vec2 ndc = (in_pos / resolution) * 2.0 - 1.0;
170
+ ndc.y = -ndc.y; // Flip Y
171
+ gl_Position = vec4(ndc, 0.0, 1.0);
172
+ v_uv = in_uv;
173
+ v_color = in_color;
174
+ }
175
+ """,
176
+ fragment_shader="""
177
+ #version 430
178
+ uniform sampler2D tex;
179
+
180
+ in vec2 v_uv;
181
+ in vec4 v_color;
182
+ out vec4 f_color;
183
+
184
+ void main() {
185
+ float alpha = texture(tex, v_uv).a;
186
+ f_color = vec4(v_color.rgb, v_color.a * alpha);
187
+ }
188
+ """
189
+ )
190
+
191
+ # Dynamic VBO for immediate mode
192
+ self.vbo = self.ctx.buffer(reserve=65536) # 64KB
193
+ self.vao = self.ctx.vertex_array(self.prog, [
194
+ (self.vbo, '2f 2f 4f', 'in_pos', 'in_uv', 'in_color')
195
+ ])
196
+
197
+ def _get_or_create_font_atlas(self, font_path: str, font_size: int) -> dict:
198
+ """Get or create a cached font atlas for the given font and size."""
199
+ cache_key = (font_path, font_size)
200
+
201
+ if cache_key in self.font_cache:
202
+ return self.font_cache[cache_key]
203
+
204
+ # Load font
205
+ try:
206
+ font = ImageFont.truetype(font_path, font_size)
207
+ except IOError:
208
+ print(f"Warning: Could not load font '{font_path}'. Using default.")
209
+ font = ImageFont.load_default()
210
+
211
+ # Generate Atlas
212
+ char_data = {}
213
+ atlas_w, atlas_h = 1024, 1024
214
+ atlas_img = Image.new('RGBA', (atlas_w, atlas_h), (0, 0, 0, 0))
215
+
216
+ x, y = 0, 0
217
+ max_h = 0
218
+
219
+ for char in self.chars:
220
+ mask = font.getmask(char)
221
+ w, h = mask.size
222
+
223
+ if x + w >= atlas_w:
224
+ x = 0
225
+ y += max_h + 2
226
+ max_h = 0
227
+
228
+ # Create char image
229
+ char_img = Image.new('RGBA', (w, h), (255, 255, 255, 255))
230
+ mask_img = Image.new('L', (w, h))
231
+ mask_img.im.paste(mask, (0, 0, w, h))
232
+ atlas_img.paste(char_img, (x, y), mask_img)
233
+
234
+ char_data[char] = {
235
+ 'x': x, 'y': y, 'w': w, 'h': h,
236
+ 'uv': (x/atlas_w, y/atlas_h, w/atlas_w, h/atlas_h)
237
+ }
238
+
239
+ x += w + 2
240
+ max_h = max(max_h, h)
241
+
242
+ # Create texture
243
+ texture = self.ctx.texture(atlas_img.size, 4, atlas_img.tobytes())
244
+ texture.filter = (moderngl.LINEAR, moderngl.LINEAR)
245
+
246
+ # Cache it
247
+ font_atlas = {
248
+ 'font': font,
249
+ 'char_data': char_data,
250
+ 'texture': texture
251
+ }
252
+ self.font_cache[cache_key] = font_atlas
253
+
254
+ return font_atlas
255
+
256
+ def get_text_width(self, text: str, scale: float = 1.0, style: TextStyle = DEFAULT_TEXT_STYLE) -> float:
257
+ """Calculate the width of the text."""
258
+ font_atlas = self._get_or_create_font_atlas(style.font, style.font_size)
259
+ char_data = font_atlas['char_data']
260
+
261
+ total_w = 0
262
+ for char in text:
263
+ if char in char_data:
264
+ data = char_data[char]
265
+ total_w += (data['w'] * scale) + (2 * scale)
266
+ return total_w
267
+
268
+ def _get_text_bounds(self, text: str, scale: float = 1.0, style: TextStyle = DEFAULT_TEXT_STYLE) -> tuple[float, float]:
269
+ """Calculate the bounding box dimensions (width, height) of the text."""
270
+ font_atlas = self._get_or_create_font_atlas(style.font, style.font_size)
271
+ char_data = font_atlas['char_data']
272
+
273
+ total_w = 0
274
+ ref_char = 'M' if 'M' in char_data else self.chars[0]
275
+ line_height = char_data[ref_char]['h'] * scale
276
+
277
+ for char in text:
278
+ if char in char_data:
279
+ data = char_data[char]
280
+ total_w += (data['w'] * scale) + (2 * scale)
281
+
282
+ return total_w, line_height
283
+
284
+ def _normalize_margin(self, margin: float | tuple[float, float, float, float]) -> tuple[float, float, float, float]:
285
+ """Normalize margin to (top, right, bottom, left)."""
286
+ if isinstance(margin, (int, float)):
287
+ return (margin, margin, margin, margin)
288
+ return margin
289
+
290
+ def _normalize_radius(self, radius: float | tuple[float, float, float, float]) -> tuple[float, float, float, float]:
291
+ """Normalize border radius to (top-left, top-right, bottom-right, bottom-left)."""
292
+ if isinstance(radius, (int, float)):
293
+ return (radius, radius, radius, radius)
294
+ return radius
295
+
296
+ def _generate_background_vertices(self, x: float, y: float, width: float, height: float,
297
+ bg_color: tuple, margin: tuple[float, float, float, float],
298
+ radius: tuple[float, float, float, float]) -> list[float]:
299
+ """Generate vertices for a rounded rectangle background."""
300
+ # Apply margin
301
+ bg_x = x - margin[3] # left
302
+ bg_y = y - margin[0] # top
303
+ bg_w = width + margin[1] + margin[3] # right + left
304
+ bg_h = height + margin[0] + margin[2] # top + bottom
305
+
306
+ vertices = []
307
+ rect_data = [bg_x, bg_y, bg_w, bg_h]
308
+
309
+ # Two triangles forming a quad
310
+ # Each vertex: (x, y, r, g, b, a, rect_x, rect_y, rect_w, rect_h, radius_tl, radius_tr, radius_br, radius_bl)
311
+
312
+ # Triangle 1
313
+ vertices.extend([bg_x, bg_y, *bg_color, *rect_data, *radius]) # top-left
314
+ vertices.extend([bg_x + bg_w, bg_y, *bg_color, *rect_data, *radius]) # top-right
315
+ vertices.extend([bg_x, bg_y + bg_h, *bg_color, *rect_data, *radius]) # bottom-left
316
+
317
+ # Triangle 2
318
+ vertices.extend([bg_x + bg_w, bg_y, *bg_color, *rect_data, *radius]) # top-right
319
+ vertices.extend([bg_x, bg_y + bg_h, *bg_color, *rect_data, *radius]) # bottom-left
320
+ vertices.extend([bg_x + bg_w, bg_y + bg_h, *bg_color, *rect_data, *radius]) # bottom-right
321
+
322
+ return vertices
323
+
324
+ def _generate_vertices(self, text: str, pos: tuple[float, float], scale: float = 1.0,
325
+ color: tuple = (1.0, 1.0, 1.0, 1.0), pivot: Pivots = Pivots.TOP_LEFT, char_data: dict = {} ) -> list[float]:
326
+
327
+ if not char_data:
328
+ raise ValueError("char_data is required for _generate_vertices")
329
+ # Calculate text size first for pivoting
330
+ total_w = 0
331
+ max_h = 0
332
+
333
+ # Determine max height for proper vertical alignment instead of per-glyph height
334
+ # This fixes the "jittery" baseline issue by ensuring all chars use the same vertical metric
335
+ # Use 'M' or 'H' or '|' as a reference for height if available, otherwise use max found
336
+ ref_char = 'M' if 'M' in char_data else self.chars[0]
337
+ line_height = char_data[ref_char]['h'] * scale
338
+
339
+ for char in text:
340
+ if char in char_data:
341
+ data = char_data[char]
342
+ total_w += (data['w'] * scale) + (2 * scale)
343
+ max_h = max(max_h, data['h'] * scale)
344
+
345
+ # Use consistent line height for vertical alignment
346
+ max_h = line_height if line_height > 0 else max_h
347
+
348
+ # Adjust start position based on pivot
349
+ start_x, start_y = pos
350
+ if pivot == Pivots.TOP_RIGHT:
351
+ start_x -= total_w
352
+ elif pivot == Pivots.BOTTOM_LEFT:
353
+ start_y -= max_h
354
+ elif pivot == Pivots.BOTTOM_RIGHT:
355
+ start_x -= total_w
356
+ start_y -= max_h
357
+ elif pivot == Pivots.CENTER:
358
+ start_x -= total_w / 2
359
+ start_y -= max_h / 2
360
+
361
+ vertices = []
362
+ cursor_x = start_x
363
+ cursor_y = start_y
364
+
365
+ for char in text:
366
+ if char not in char_data:
367
+ continue
368
+
369
+ data = char_data[char]
370
+ w = data['w'] * scale
371
+ h = data['h'] * scale
372
+
373
+ # Align characters to bottom of line (baseline alignment)
374
+ # Offset smaller characters down so they sit on the same baseline as taller ones
375
+ y_offset = max_h - h
376
+
377
+ # Quad vertices (x, y, u, v, r, g, b, a)
378
+ # TL
379
+ vertices.extend([cursor_x, cursor_y + y_offset, data['uv'][0], data['uv'][1], *color])
380
+
381
+ # TR
382
+ vertices.extend([cursor_x + w, cursor_y + y_offset, data['uv'][0] + data['uv'][2], data['uv'][1], *color])
383
+
384
+ # BL
385
+ vertices.extend([cursor_x, cursor_y + y_offset + h, data['uv'][0], data['uv'][1] + data['uv'][3], *color])
386
+
387
+ # Triangle 2
388
+ # TR
389
+ vertices.extend([cursor_x + w, cursor_y + y_offset, data['uv'][0] + data['uv'][2], data['uv'][1], *color])
390
+ # BL
391
+ vertices.extend([cursor_x, cursor_y + y_offset + h, data['uv'][0], data['uv'][1] + data['uv'][3], *color])
392
+ # BR
393
+ vertices.extend([cursor_x + w, cursor_y + y_offset + h, data['uv'][0] + data['uv'][2], data['uv'][1] + data['uv'][3], *color])
394
+
395
+ cursor_x += w + (2 * scale) # Spacing
396
+
397
+ return vertices
398
+
399
+ def draw_text(self, text: str, pos: tuple[float, float], scale: float = 1.0, style: TextStyle = DEFAULT_TEXT_STYLE, pivot: Pivots = Pivots.TOP_LEFT) -> None:
400
+ if not text:
401
+ return
402
+
403
+ # Get font atlas for this style
404
+ font_atlas = self._get_or_create_font_atlas(style.font, style.font_size)
405
+ char_data = font_atlas['char_data']
406
+ texture = font_atlas['texture']
407
+
408
+ # Get text dimensions for background
409
+ text_width, text_height = self._get_text_bounds(text, scale, style)
410
+
411
+ # Adjust position based on pivot for background calculation
412
+ bg_x, bg_y = pos
413
+ if pivot == Pivots.TOP_RIGHT:
414
+ bg_x -= text_width
415
+ elif pivot == Pivots.BOTTOM_LEFT:
416
+ bg_y -= text_height
417
+ elif pivot == Pivots.BOTTOM_RIGHT:
418
+ bg_x -= text_width
419
+ bg_y -= text_height
420
+ elif pivot == Pivots.CENTER:
421
+ bg_x -= text_width / 2
422
+ bg_y -= text_height / 2
423
+
424
+ # Draw background if specified
425
+ if style.bg_color[3] > 0: # Only draw if alpha > 0
426
+ margin = self._normalize_margin(style.bg_margin)
427
+ radius = self._normalize_radius(style.bg_border_radius)
428
+ bg_vertices = self._generate_background_vertices(bg_x, bg_y, text_width, text_height,
429
+ style.bg_color, margin, radius)
430
+
431
+ bg_data = np.array(bg_vertices, dtype='f4').tobytes()
432
+ self.bg_vbo.write(bg_data)
433
+ self.bg_prog['resolution'] = self.ctx.viewport[2:]
434
+ self.ctx.enable(moderngl.BLEND)
435
+ self.bg_vao.render(moderngl.TRIANGLES, vertices=len(bg_vertices)//14)
436
+
437
+ # Draw text
438
+ vertices = self._generate_vertices(text, pos, scale, style.color, pivot, char_data)
439
+ if not vertices:
440
+ return
441
+
442
+ # Update VBO
443
+ data_bytes = np.array(vertices, dtype='f4').tobytes()
444
+ self.vbo.write(data_bytes)
445
+
446
+ # Update Uniforms
447
+ self.prog['resolution'] = self.ctx.viewport[2:]
448
+ texture.use(0)
449
+
450
+ # Draw
451
+ self.ctx.enable(moderngl.BLEND)
452
+ self.vao.render(moderngl.TRIANGLES, vertices=len(vertices)//8)
453
+
454
+ def create_label(self, text: str, x: float, y: float, scale: float = 1.0, style: TextStyle = DEFAULT_TEXT_STYLE, pivot: Pivots = Pivots.TOP_LEFT) -> TextLabel:
455
+ if not text:
456
+ # Return empty label with default texture
457
+ font_atlas = self._get_or_create_font_atlas(style.font, style.font_size)
458
+ return TextLabel(self.ctx, self.prog, font_atlas['texture'], [])
459
+
460
+ # Get font atlas for this style
461
+ font_atlas = self._get_or_create_font_atlas(style.font, style.font_size)
462
+ char_data = font_atlas['char_data']
463
+ texture = font_atlas['texture']
464
+
465
+ # Generate text vertices
466
+ vertices = self._generate_vertices(text, (x, y), scale, style.color, pivot, char_data)
467
+
468
+ # Generate background vertices if needed
469
+ bg_vertices = None
470
+ if style.bg_color[3] > 0:
471
+ text_width, text_height = self._get_text_bounds(text, scale, style)
472
+
473
+ # Adjust position based on pivot
474
+ bg_x, bg_y = x, y
475
+ if pivot == Pivots.TOP_RIGHT:
476
+ bg_x -= text_width
477
+ elif pivot == Pivots.BOTTOM_LEFT:
478
+ bg_y -= text_height
479
+ elif pivot == Pivots.BOTTOM_RIGHT:
480
+ bg_x -= text_width
481
+ bg_y -= text_height
482
+ elif pivot == Pivots.CENTER:
483
+ bg_x -= text_width / 2
484
+ bg_y -= text_height / 2
485
+
486
+ margin = self._normalize_margin(style.bg_margin)
487
+ radius = self._normalize_radius(style.bg_border_radius)
488
+ bg_vertices = self._generate_background_vertices(bg_x, bg_y, text_width, text_height,
489
+ style.bg_color, margin, radius)
490
+
491
+ return TextLabel(self.ctx, self.prog, texture, vertices, self.bg_prog, bg_vertices)