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/shapes.py ADDED
@@ -0,0 +1,1081 @@
1
+ import moderngl
2
+ import numpy as np
3
+ from .commons import get_pattr, get_pattr_value, set_pattr_value
4
+ from typing import Optional, Sequence
5
+ from enum import Enum
6
+
7
+
8
+ class FillMode(Enum):
9
+ FILL = 0
10
+ STROKE = 1
11
+ FILL_STROKE = 2
12
+
13
+ class ShapeLabel:
14
+ """A pre-rendered shape for efficient repeated drawing."""
15
+ def __init__(self, ctx: moderngl.Context, prog: moderngl.Program,
16
+ vbo: moderngl.Buffer, vertex_count: int, shape_type: str = 'line') -> None:
17
+ self.ctx = ctx
18
+ self.prog = prog
19
+ self.vbo = vbo
20
+ self.vertex_count = vertex_count
21
+ self.shape_type = shape_type
22
+
23
+ # Create VAO based on shape type
24
+ if shape_type == 'circle':
25
+ self.vao = self.ctx.vertex_array(self.prog, [
26
+ (self.vbo, '2f 4f 1f 4f 1f 1f 2f', 'in_pos', 'in_color', 'in_radius',
27
+ 'in_border_color', 'in_border_width', 'in_aa', 'in_center')
28
+ ])
29
+ elif shape_type == 'rect':
30
+ self.vao = self.ctx.vertex_array(self.prog, [
31
+ (self.vbo, '2f 4f 1f 4f 1f 1f 2f 2f', 'in_pos', 'in_color', 'in_radius',
32
+ 'in_border_color', 'in_border_width', 'in_aa', 'in_size', 'in_local_pos')
33
+ ])
34
+ else: # line
35
+ self.vao = self.ctx.vertex_array(self.prog, [
36
+ (self.vbo, '2f 4f', 'in_pos', 'in_color')
37
+ ])
38
+
39
+ def draw(self) -> None:
40
+ """Draw the cached shape."""
41
+ self.ctx.enable(moderngl.BLEND)
42
+ set_pattr_value(self.prog, 'resolution', self.ctx.viewport[2:])
43
+ self.vao.render(moderngl.TRIANGLES, vertices=self.vertex_count)
44
+
45
+
46
+ class InstancedShapeBatch:
47
+ """High-performance instanced batch for drawing thousands of shapes with minimal CPU overhead."""
48
+ def __init__(self, ctx: moderngl.Context, prog: moderngl.Program, shape_type: str = 'circle', max_instances: int = 100000) -> None:
49
+ self.ctx = ctx
50
+ self.prog = prog
51
+ self.shape_type = shape_type
52
+ self.max_instances = max_instances
53
+ self.instance_count = 0
54
+
55
+ # Per-instance data (stored once, reused for drawing)
56
+ if shape_type == 'circle':
57
+ # Per-instance: center(2f), color(4f), radius(1f), border_color(4f), border_width(1f), aa(1f) = 13 floats
58
+ self.floats_per_instance = 13
59
+ self.instance_buffer = self.ctx.buffer(reserve=max_instances * self.floats_per_instance * 4, dynamic=True)
60
+
61
+ # Template quad (6 vertices for 2 triangles) - shared by all instances
62
+ # These are the LOCAL positions that will be offset by instance data
63
+ quad_verts = np.array([
64
+ -1.0, -1.0,
65
+ 1.0, -1.0,
66
+ -1.0, 1.0,
67
+ 1.0, -1.0,
68
+ -1.0, 1.0,
69
+ 1.0, 1.0
70
+ ], dtype='f4')
71
+ self.quad_vbo = self.ctx.buffer(quad_verts.tobytes())
72
+
73
+ # Create VAO with per-vertex and per-instance attributes
74
+ self.vao = self.ctx.vertex_array(
75
+ self.prog,
76
+ [
77
+ (self.quad_vbo, '2f', 'in_vertex'), # Per-vertex (6 vertices)
78
+ (self.instance_buffer, '2f 4f 1f 4f 1f 1f/i', 'in_center', 'in_color', 'in_radius',
79
+ 'in_border_color', 'in_border_width', 'in_aa') # Per-instance (divisor = 1)
80
+ ]
81
+ )
82
+ elif shape_type == 'rect':
83
+ # Per-instance: center(2f), size(2f), color(4f), radius(1f), border_color(4f), border_width(1f), aa(1f), rotation(1f) = 16 floats
84
+ self.floats_per_instance = 16
85
+ self.instance_buffer = self.ctx.buffer(reserve=max_instances * self.floats_per_instance * 4, dynamic=True)
86
+
87
+ quad_verts = np.array([
88
+ -1.0, -1.0,
89
+ 1.0, -1.0,
90
+ -1.0, 1.0,
91
+ 1.0, -1.0,
92
+ -1.0, 1.0,
93
+ 1.0, 1.0
94
+ ], dtype='f4')
95
+ self.quad_vbo = self.ctx.buffer(quad_verts.tobytes())
96
+
97
+ self.vao = self.ctx.vertex_array(
98
+ self.prog,
99
+ [
100
+ (self.quad_vbo, '2f', 'in_vertex'),
101
+ (self.instance_buffer, '2f 2f 4f 1f 4f 1f 1f 1f/i', 'in_center', 'in_size', 'in_color',
102
+ 'in_radius', 'in_border_color', 'in_border_width', 'in_aa', 'in_rotation')
103
+ ]
104
+ )
105
+ elif shape_type == 'line':
106
+ # Per-instance: start(2f), end(2f), width(1f), color(4f) = 9 floats
107
+ self.floats_per_instance = 9
108
+ self.instance_buffer = self.ctx.buffer(reserve=max_instances * self.floats_per_instance * 4, dynamic=True)
109
+
110
+ # Quad template for line segment
111
+ quad_verts = np.array([
112
+ -1.0, -1.0,
113
+ 1.0, -1.0,
114
+ -1.0, 1.0,
115
+ 1.0, -1.0,
116
+ -1.0, 1.0,
117
+ 1.0, 1.0
118
+ ], dtype='f4')
119
+ self.quad_vbo = self.ctx.buffer(quad_verts.tobytes())
120
+
121
+ self.vao = self.ctx.vertex_array(
122
+ self.prog,
123
+ [
124
+ (self.quad_vbo, '2f', 'in_quad_pos'),
125
+ (self.instance_buffer, '2f 2f 1f 4f/i', 'in_start', 'in_end', 'in_width', 'in_color')
126
+ ]
127
+ )
128
+
129
+ self.instance_data = []
130
+
131
+ def add_circle(self, center: tuple[float, float], radius: float,
132
+ color: tuple[float, float, float, float] = (1.0, 1.0, 1.0, 1.0),
133
+ border_color: tuple[float, float, float, float] = (0.0, 0.0, 0.0, 0.0),
134
+ border_width: float = 0.0,
135
+ antialiasing: float = 1.0) -> None:
136
+ """Add a circle instance to the batch."""
137
+ self.instance_data.extend([*center, *color, radius, *border_color, border_width, antialiasing])
138
+ self.instance_count += 1
139
+
140
+ def add_circles_numpy(self, centers: np.ndarray, radii: np.ndarray,
141
+ colors: np.ndarray,
142
+ border_colors: Optional[np.ndarray] = None,
143
+ border_widths: Optional[np.ndarray] = None,
144
+ antialiasing: float = 1.0) -> None:
145
+ """Add multiple circles efficiently using numpy arrays (10-50x faster than loop).
146
+
147
+ Args:
148
+ centers: (N, 2) array of (x, y) positions
149
+ radii: (N,) array of radii
150
+ colors: (N, 4) array of (r, g, b, a) colors
151
+ border_colors: (N, 4) array or None (defaults to transparent)
152
+ border_widths: (N,) array or None (defaults to 0)
153
+ antialiasing: Antialiasing width (scalar applied to all)
154
+ """
155
+ n = len(centers)
156
+ if border_colors is None:
157
+ border_colors = np.zeros((n, 4), dtype='f4')
158
+ if border_widths is None:
159
+ border_widths = np.zeros(n, dtype='f4')
160
+
161
+ # Build interleaved data: [cx, cy, r, g, b, a, radius, br, bg, bb, ba, bw, aa] * N
162
+ # Format: center(2), color(4), radius(1), border_color(4), border_width(1), aa(1) = 13 floats
163
+ data = np.empty((n, 13), dtype='f4')
164
+ data[:, 0:2] = centers
165
+ data[:, 2:6] = colors
166
+ data[:, 6] = radii
167
+ data[:, 7:11] = border_colors
168
+ data[:, 11] = border_widths
169
+ data[:, 12] = antialiasing
170
+
171
+ self.instance_data.extend(data.ravel())
172
+ self.instance_count += n
173
+
174
+ def add_rect(self, center: tuple[float, float], size: tuple[float, float],
175
+ color: tuple[float, float, float, float] = (1.0, 1.0, 1.0, 1.0),
176
+ corner_radius: float = 0.0,
177
+ border_color: tuple[float, float, float, float] = (0.0, 0.0, 0.0, 0.0),
178
+ border_width: float = 0.0,
179
+ antialiasing: float = 1.0,
180
+ rotation: float = 0.0) -> None:
181
+ """Add a rectangle instance to the batch."""
182
+ self.instance_data.extend([*center, *size, *color, corner_radius, *border_color, border_width, antialiasing, rotation])
183
+ self.instance_count += 1
184
+
185
+ def add_rects_numpy(self, centers: np.ndarray, sizes: np.ndarray,
186
+ colors: np.ndarray,
187
+ corner_radii: Optional[np.ndarray] = None,
188
+ border_colors: Optional[np.ndarray] = None,
189
+ border_widths: Optional[np.ndarray] = None,
190
+ antialiasing: float = 1.0,
191
+ rotations: Optional[np.ndarray] = None) -> None:
192
+ """Add multiple rectangles efficiently using numpy arrays (10-50x faster than loop).
193
+
194
+ Args:
195
+ centers: (N, 2) array of (x, y) positions
196
+ sizes: (N, 2) array of (width, height)
197
+ colors: (N, 4) array of (r, g, b, a) colors
198
+ corner_radii: (N,) array or None (defaults to 0)
199
+ border_colors: (N, 4) array or None (defaults to transparent)
200
+ border_widths: (N,) array or None (defaults to 0)
201
+ antialiasing: Antialiasing width (scalar applied to all)
202
+ rotations: (N,) array or None (defaults to 0)
203
+ """
204
+ n = len(centers)
205
+ if corner_radii is None:
206
+ corner_radii = np.zeros(n, dtype='f4')
207
+ if border_colors is None:
208
+ border_colors = np.zeros((n, 4), dtype='f4')
209
+ if border_widths is None:
210
+ border_widths = np.zeros(n, dtype='f4')
211
+ if rotations is None:
212
+ rotations = np.zeros(n, dtype='f4')
213
+
214
+ # Format: center(2), size(2), color(4), radius(1), border_color(4), border_width(1), aa(1), rotation(1) = 16 floats
215
+ data = np.empty((n, 16), dtype='f4')
216
+ data[:, 0:2] = centers
217
+ data[:, 2:4] = sizes
218
+ data[:, 4:8] = colors
219
+ data[:, 8] = corner_radii
220
+ data[:, 9:13] = border_colors
221
+ data[:, 13] = border_widths
222
+ data[:, 14] = antialiasing
223
+ data[:, 15] = rotations
224
+
225
+ self.instance_data.extend(data.ravel())
226
+ self.instance_count += n
227
+
228
+ def add_line(self, start: tuple[float, float], end: tuple[float, float],
229
+ width: float = 1.0,
230
+ color: tuple[float, float, float, float] = (1.0, 1.0, 1.0, 1.0)) -> None:
231
+ """Add a line instance to the batch."""
232
+ self.instance_data.extend([*start, *end, width, *color])
233
+ self.instance_count += 1
234
+
235
+ def add_lines_numpy(self, starts: np.ndarray, ends: np.ndarray,
236
+ widths: np.ndarray, colors: np.ndarray) -> None:
237
+ """Add multiple lines efficiently using numpy arrays (10-50x faster than loop).
238
+
239
+ Args:
240
+ starts: (N, 2) array of start (x, y) positions
241
+ ends: (N, 2) array of end (x, y) positions
242
+ widths: (N,) array of line widths
243
+ colors: (N, 4) array of (r, g, b, a) colors
244
+ """
245
+ n = len(starts)
246
+
247
+ # Format: start(2), end(2), width(1), color(4) = 9 floats
248
+ data = np.empty((n, 9), dtype='f4')
249
+ data[:, 0:2] = starts
250
+ data[:, 2:4] = ends
251
+ data[:, 4] = widths
252
+ data[:, 5:9] = colors
253
+
254
+ self.instance_data.extend(data.ravel())
255
+ self.instance_count += n
256
+
257
+ def flush(self) -> None:
258
+ """Draw all instances in a single draw call."""
259
+ if self.instance_count == 0:
260
+ return
261
+
262
+ # Upload instance data
263
+ data = np.array(self.instance_data, dtype='f4')
264
+ self.instance_buffer.write(data.tobytes())
265
+
266
+ # Draw all instances with one call
267
+ self.ctx.enable(moderngl.BLEND)
268
+ set_pattr_value(self.prog, 'resolution', self.ctx.viewport[2:])
269
+ self.vao.render(moderngl.TRIANGLES, vertices=6, instances=self.instance_count)
270
+
271
+ self.instance_data.clear()
272
+ self.instance_count = 0
273
+
274
+ def clear(self) -> None:
275
+ """Clear the batch without drawing."""
276
+ self.instance_data.clear()
277
+ self.instance_count = 0
278
+
279
+
280
+ class ShapeRenderer:
281
+ """
282
+ High-performance 2D shape renderer using SDF (Signed Distance Functions) and GPU shaders.
283
+ Supports immediate mode, cached drawing, and batched rendering.
284
+ """
285
+
286
+ def __init__(self, ctx: moderngl.Context) -> None:
287
+ self.ctx = ctx
288
+
289
+ # ===== INSTANCED Circle Shader (for high-performance batching) =====
290
+ self.circle_instanced_prog = self.ctx.program(
291
+ vertex_shader="""
292
+ #version 430
293
+ uniform vec2 resolution;
294
+
295
+ in vec2 in_vertex; // Template quad vertex: (-1,-1) to (1,1)
296
+ in vec2 in_center; // Per-instance
297
+ in vec4 in_color; // Per-instance
298
+ in float in_radius; // Per-instance
299
+ in vec4 in_border_color; // Per-instance
300
+ in float in_border_width; // Per-instance
301
+ in float in_aa; // Per-instance
302
+
303
+ out vec4 v_color;
304
+ out vec2 v_local_pos;
305
+ out float v_radius;
306
+ out vec4 v_border_color;
307
+ out float v_border_width;
308
+ out float v_aa;
309
+
310
+ void main() {
311
+ // Expand vertex by radius + border + aa
312
+ float expand = in_radius + in_border_width + in_aa * 2.0;
313
+ vec2 world_pos = in_center + in_vertex * expand;
314
+
315
+ vec2 ndc = (world_pos / resolution) * 2.0 - 1.0;
316
+ ndc.y = -ndc.y;
317
+ gl_Position = vec4(ndc, 0.0, 1.0);
318
+
319
+ v_color = in_color;
320
+ v_local_pos = in_vertex * expand; // Local position for SDF
321
+ v_radius = in_radius;
322
+ v_border_color = in_border_color;
323
+ v_border_width = in_border_width;
324
+ v_aa = in_aa;
325
+ }
326
+ """,
327
+ fragment_shader="""
328
+ #version 430
329
+
330
+ in vec4 v_color;
331
+ in vec2 v_local_pos;
332
+ in float v_radius;
333
+ in vec4 v_border_color;
334
+ in float v_border_width;
335
+ in float v_aa;
336
+
337
+ out vec4 f_color;
338
+
339
+ void main() {
340
+ float dist = length(v_local_pos) - v_radius;
341
+
342
+ if (v_border_width > 0.0) {
343
+ float outer_dist = abs(dist);
344
+ float inner_dist = abs(dist + v_border_width);
345
+ float alpha_outer = 1.0 - smoothstep(0.0, v_aa, outer_dist);
346
+ float alpha_inner = 1.0 - smoothstep(0.0, v_aa, inner_dist);
347
+ float border_alpha = alpha_outer * (1.0 - alpha_inner);
348
+ float fill_alpha = 1.0 - smoothstep(-v_aa, v_aa, dist);
349
+ vec4 fill_color = vec4(v_color.rgb, v_color.a * fill_alpha);
350
+ vec4 border_col = vec4(v_border_color.rgb, v_border_color.a * border_alpha);
351
+ f_color = mix(fill_color, border_col, border_alpha / max(border_alpha + fill_alpha, 0.001));
352
+ } else {
353
+ float alpha = 1.0 - smoothstep(-v_aa, v_aa, dist);
354
+ f_color = vec4(v_color.rgb, v_color.a * alpha);
355
+ }
356
+ }
357
+ """
358
+ )
359
+
360
+ # ===== INSTANCED Rectangle Shader =====
361
+ self.rect_instanced_prog = self.ctx.program(
362
+ vertex_shader="""
363
+ #version 430
364
+ uniform vec2 resolution;
365
+
366
+ in vec2 in_vertex;
367
+ in vec2 in_center;
368
+ in vec2 in_size;
369
+ in vec4 in_color;
370
+ in float in_radius;
371
+ in vec4 in_border_color;
372
+ in float in_border_width;
373
+ in float in_aa;
374
+ in float in_rotation;
375
+
376
+ out vec4 v_color;
377
+ out vec2 v_local_pos;
378
+ out float v_radius;
379
+ out vec4 v_border_color;
380
+ out float v_border_width;
381
+ out float v_aa;
382
+ out vec2 v_size;
383
+
384
+ void main() {
385
+ float expand = in_border_width + in_aa * 2.0;
386
+ vec2 expanded_size = in_size + expand;
387
+
388
+ // Apply rotation
389
+ float cos_a = cos(in_rotation);
390
+ float sin_a = sin(in_rotation);
391
+ vec2 rotated = vec2(
392
+ in_vertex.x * cos_a - in_vertex.y * sin_a,
393
+ in_vertex.x * sin_a + in_vertex.y * cos_a
394
+ );
395
+
396
+ vec2 world_pos = in_center + rotated * expanded_size;
397
+
398
+ vec2 ndc = (world_pos / resolution) * 2.0 - 1.0;
399
+ ndc.y = -ndc.y;
400
+ gl_Position = vec4(ndc, 0.0, 1.0);
401
+
402
+ v_color = in_color;
403
+ v_local_pos = rotated * expanded_size;
404
+ v_radius = in_radius;
405
+ v_border_color = in_border_color;
406
+ v_border_width = in_border_width;
407
+ v_aa = in_aa;
408
+ v_size = in_size;
409
+ }
410
+ """,
411
+ fragment_shader="""
412
+ #version 430
413
+
414
+ in vec4 v_color;
415
+ in vec2 v_local_pos;
416
+ in float v_radius;
417
+ in vec4 v_border_color;
418
+ in float v_border_width;
419
+ in float v_aa;
420
+ in vec2 v_size;
421
+
422
+ out vec4 f_color;
423
+
424
+ float roundedBoxSDF(vec2 center, vec2 size, float radius) {
425
+ vec2 q = abs(center) - size + radius;
426
+ return min(max(q.x, q.y), 0.0) + length(max(q, 0.0)) - radius;
427
+ }
428
+
429
+ void main() {
430
+ float dist = roundedBoxSDF(v_local_pos, v_size, v_radius);
431
+
432
+ if (v_border_width > 0.0) {
433
+ float outer_dist = abs(dist);
434
+ float inner_dist = abs(dist + v_border_width);
435
+ float alpha_outer = 1.0 - smoothstep(0.0, v_aa, outer_dist);
436
+ float alpha_inner = 1.0 - smoothstep(0.0, v_aa, inner_dist);
437
+ float border_alpha = alpha_outer * (1.0 - alpha_inner);
438
+ float fill_alpha = 1.0 - smoothstep(-v_aa, v_aa, dist);
439
+ vec4 fill_color = vec4(v_color.rgb, v_color.a * fill_alpha);
440
+ vec4 border_col = vec4(v_border_color.rgb, v_border_color.a * border_alpha);
441
+ f_color = mix(fill_color, border_col, border_alpha / max(border_alpha + fill_alpha, 0.001));
442
+ } else {
443
+ float alpha = 1.0 - smoothstep(-v_aa, v_aa, dist);
444
+ f_color = vec4(v_color.rgb, v_color.a * alpha);
445
+ }
446
+ }
447
+ """
448
+ )
449
+
450
+ self.line_instanced_prog = self.ctx.program(
451
+ vertex_shader="""
452
+ #version 430
453
+
454
+ // Per-vertex attributes (quad corners: 4 vertices per instance)
455
+ in vec2 in_quad_pos; // (-1,-1), (1,-1), (1,1), (-1,1)
456
+
457
+ // Per-instance attributes
458
+ in vec2 in_start;
459
+ in vec2 in_end;
460
+ in float in_width;
461
+ in vec4 in_color;
462
+
463
+ out vec4 v_color;
464
+
465
+ uniform vec2 resolution;
466
+
467
+ void main() {
468
+ // Calculate line direction and perpendicular
469
+ vec2 line_vec = in_end - in_start;
470
+ float line_length = length(line_vec);
471
+ vec2 line_dir = line_vec / line_length;
472
+ vec2 line_perp = vec2(-line_dir.y, line_dir.x);
473
+
474
+ // Calculate half-width
475
+ float half_width = in_width * 0.5;
476
+
477
+ // Expand quad along line direction and perpendicular
478
+ vec2 world_pos = in_start +
479
+ line_dir * (in_quad_pos.x * line_length * 0.5 + line_length * 0.5) +
480
+ line_perp * (in_quad_pos.y * half_width);
481
+
482
+ // Convert to NDC
483
+ vec2 ndc = (world_pos / resolution) * 2.0 - 1.0;
484
+ ndc.y = -ndc.y;
485
+
486
+ gl_Position = vec4(ndc, 0.0, 1.0);
487
+ v_color = in_color;
488
+ }
489
+ """,
490
+ fragment_shader="""
491
+ #version 430
492
+
493
+ in vec4 v_color;
494
+ out vec4 fragColor;
495
+
496
+ uniform vec2 resolution;
497
+
498
+ void main() {
499
+ fragColor = v_color;
500
+ }
501
+ """
502
+ )
503
+
504
+
505
+ # ===== SDF Circle Shader =====
506
+ self.circle_prog = self.ctx.program(
507
+ vertex_shader="""
508
+ #version 430
509
+ uniform vec2 resolution;
510
+
511
+ in vec2 in_pos;
512
+ in vec4 in_color;
513
+ in float in_radius;
514
+ in vec4 in_border_color;
515
+ in float in_border_width;
516
+ in float in_aa;
517
+ in vec2 in_center;
518
+
519
+ out vec4 v_color;
520
+ out vec2 v_local_pos;
521
+ out float v_radius;
522
+ out vec4 v_border_color;
523
+ out float v_border_width;
524
+ out float v_aa;
525
+
526
+ void main() {
527
+ vec2 ndc = (in_pos / resolution) * 2.0 - 1.0;
528
+ ndc.y = -ndc.y;
529
+ gl_Position = vec4(ndc, 0.0, 1.0);
530
+ v_color = in_color;
531
+ v_local_pos = in_pos - in_center;
532
+ v_radius = in_radius;
533
+ v_border_color = in_border_color;
534
+ v_border_width = in_border_width;
535
+ v_aa = in_aa;
536
+ }
537
+ """,
538
+ fragment_shader="""
539
+ #version 430
540
+
541
+ in vec4 v_color;
542
+ in vec2 v_local_pos;
543
+ in float v_radius;
544
+ in vec4 v_border_color;
545
+ in float v_border_width;
546
+ in float v_aa;
547
+
548
+ out vec4 f_color;
549
+
550
+ void main() {
551
+ float dist = length(v_local_pos) - v_radius;
552
+
553
+ if (v_border_width > 0.0) {
554
+ float outer_dist = abs(dist);
555
+ float inner_dist = abs(dist + v_border_width);
556
+ float alpha_outer = 1.0 - smoothstep(0.0, v_aa, outer_dist);
557
+ float alpha_inner = 1.0 - smoothstep(0.0, v_aa, inner_dist);
558
+ float border_alpha = alpha_outer * (1.0 - alpha_inner);
559
+ float fill_alpha = 1.0 - smoothstep(-v_aa, v_aa, dist);
560
+ vec4 fill_color = vec4(v_color.rgb, v_color.a * fill_alpha);
561
+ vec4 border_col = vec4(v_border_color.rgb, v_border_color.a * border_alpha);
562
+ f_color = mix(fill_color, border_col, border_alpha / max(border_alpha + fill_alpha, 0.001));
563
+ } else {
564
+ float alpha = 1.0 - smoothstep(-v_aa, v_aa, dist);
565
+ f_color = vec4(v_color.rgb, v_color.a * alpha);
566
+ }
567
+ }
568
+ """
569
+ )
570
+
571
+ # ===== SDF Rectangle Shader =====
572
+ self.rect_prog = self.ctx.program(
573
+ vertex_shader="""
574
+ #version 430
575
+ uniform vec2 resolution;
576
+
577
+ in vec2 in_pos;
578
+ in vec4 in_color;
579
+ in float in_radius;
580
+ in vec4 in_border_color;
581
+ in float in_border_width;
582
+ in float in_aa;
583
+ in vec2 in_size;
584
+ in vec2 in_local_pos;
585
+
586
+ out vec4 v_color;
587
+ out vec2 v_local_pos;
588
+ out float v_radius;
589
+ out vec4 v_border_color;
590
+ out float v_border_width;
591
+ out float v_aa;
592
+ out vec2 v_size;
593
+
594
+ void main() {
595
+ vec2 ndc = (in_pos / resolution) * 2.0 - 1.0;
596
+ ndc.y = -ndc.y;
597
+ gl_Position = vec4(ndc, 0.0, 1.0);
598
+ v_color = in_color;
599
+ v_local_pos = in_local_pos;
600
+ v_radius = in_radius;
601
+ v_border_color = in_border_color;
602
+ v_border_width = in_border_width;
603
+ v_aa = in_aa;
604
+ v_size = in_size;
605
+ }
606
+ """,
607
+ fragment_shader="""
608
+ #version 430
609
+
610
+ in vec4 v_color;
611
+ in vec2 v_local_pos;
612
+ in float v_radius;
613
+ in vec4 v_border_color;
614
+ in float v_border_width;
615
+ in float v_aa;
616
+ in vec2 v_size;
617
+
618
+ out vec4 f_color;
619
+
620
+ float roundedBoxSDF(vec2 center, vec2 size, float radius) {
621
+ vec2 q = abs(center) - size + radius;
622
+ return min(max(q.x, q.y), 0.0) + length(max(q, 0.0)) - radius;
623
+ }
624
+
625
+ void main() {
626
+ float dist = roundedBoxSDF(v_local_pos, v_size, v_radius);
627
+
628
+ if (v_border_width > 0.0) {
629
+ float outer_dist = abs(dist);
630
+ float inner_dist = abs(dist + v_border_width);
631
+ float alpha_outer = 1.0 - smoothstep(0.0, v_aa, outer_dist);
632
+ float alpha_inner = 1.0 - smoothstep(0.0, v_aa, inner_dist);
633
+ float border_alpha = alpha_outer * (1.0 - alpha_inner);
634
+ float fill_alpha = 1.0 - smoothstep(-v_aa, v_aa, dist);
635
+ vec4 fill_color = vec4(v_color.rgb, v_color.a * fill_alpha);
636
+ vec4 border_col = vec4(v_border_color.rgb, v_border_color.a * border_alpha);
637
+ f_color = mix(fill_color, border_col, border_alpha / max(border_alpha + fill_alpha, 0.001));
638
+ } else {
639
+ float alpha = 1.0 - smoothstep(-v_aa, v_aa, dist);
640
+ f_color = vec4(v_color.rgb, v_color.a * alpha);
641
+ }
642
+ }
643
+ """
644
+ )
645
+
646
+ # ===== Line Shader (immediate mode) =====
647
+ self.line_prog = self.ctx.program(
648
+ vertex_shader="""
649
+ #version 430
650
+ uniform vec2 resolution;
651
+
652
+ in vec2 in_pos;
653
+ in vec4 in_color;
654
+
655
+ out vec4 v_color;
656
+
657
+ void main() {
658
+ vec2 ndc = (in_pos / resolution) * 2.0 - 1.0;
659
+ ndc.y = -ndc.y;
660
+ gl_Position = vec4(ndc, 0.0, 1.0);
661
+ v_color = in_color;
662
+ }
663
+ """,
664
+ fragment_shader="""
665
+ #version 430
666
+
667
+ in vec4 v_color;
668
+ out vec4 f_color;
669
+
670
+ void main() {
671
+ f_color = v_color;
672
+ }
673
+ """
674
+ )
675
+
676
+ # Dynamic VBOs for immediate mode
677
+ self.circle_vbo = self.ctx.buffer(reserve=65536)
678
+ self.rect_vbo = self.ctx.buffer(reserve=65536)
679
+ self.line_vbo = self.ctx.buffer(reserve=262144) # Larger for polylines
680
+
681
+ # VAOs for immediate mode
682
+ # Circle format: pos(2f), color(4f), radius(1f), border_color(4f), border_width(1f), aa(1f), center(2f) = 15 floats
683
+ self.circle_vao = self.ctx.vertex_array(self.circle_prog, [
684
+ (self.circle_vbo, '2f 4f 1f 4f 1f 1f 2f', 'in_pos', 'in_color', 'in_radius',
685
+ 'in_border_color', 'in_border_width', 'in_aa', 'in_center')
686
+ ])
687
+
688
+ # Rect format: pos(2f), color(4f), radius(1f), border_color(4f), border_width(1f), aa(1f), size(2f), local_pos(2f) = 17 floats
689
+ self.rect_vao = self.ctx.vertex_array(self.rect_prog, [
690
+ (self.rect_vbo, '2f 4f 1f 4f 1f 1f 2f 2f', 'in_pos', 'in_color', 'in_radius',
691
+ 'in_border_color', 'in_border_width', 'in_aa', 'in_size', 'in_local_pos')
692
+ ])
693
+
694
+ self.line_vao = self.ctx.vertex_array(self.line_prog, [
695
+ (self.line_vbo, '2f 4f', 'in_pos', 'in_color')
696
+ ])
697
+
698
+ # ========== CIRCLE ==========
699
+
700
+ def _generate_circle_vertices(self, center: tuple[float, float], radius: float,
701
+ color: tuple[float, float, float, float] = (1.0, 1.0, 1.0, 1.0),
702
+ rotation: float = 0.0,
703
+ border_color: tuple[float, float, float, float] = (0.0, 0.0, 0.0, 0.0),
704
+ border_width: float = 0.0,
705
+ antialiasing: float = 1.0) -> list[float]:
706
+ """Generate vertices for a circle (as a quad).
707
+ Format: pos(2f), color(4f), radius(1f), border_color(4f), border_width(1f), aa(1f), center(2f)
708
+ """
709
+ cx, cy = center
710
+
711
+ # Expand for border and antialiasing
712
+ expand = radius + border_width + antialiasing * 2
713
+
714
+ # Quad vertices (2 triangles) in screen space
715
+ quad_positions = [
716
+ (cx - expand, cy - expand),
717
+ (cx + expand, cy - expand),
718
+ (cx - expand, cy + expand),
719
+ (cx + expand, cy - expand),
720
+ (cx - expand, cy + expand),
721
+ (cx + expand, cy + expand)
722
+ ]
723
+
724
+ vertices = []
725
+ for pos in quad_positions:
726
+ vertices.extend([
727
+ *pos, # in_pos (screen position)
728
+ *color, # in_color
729
+ radius, # in_radius
730
+ *border_color, # in_border_color
731
+ border_width, # in_border_width
732
+ antialiasing, # in_aa
733
+ cx, cy # in_center (circle center)
734
+ ])
735
+
736
+ return vertices
737
+
738
+ def draw_circle(self, center: tuple[float, float], radius: float,
739
+ color: tuple[float, float, float, float] = (1.0, 1.0, 1.0, 1.0),
740
+ rotation: float = 0.0,
741
+ border_color: tuple[float, float, float, float] = (0.0, 0.0, 0.0, 0.0),
742
+ border_width: float = 0.0,
743
+ antialiasing: float = 1.0) -> None:
744
+ """
745
+ Draw a circle immediately.
746
+
747
+ Args:
748
+ center: (x, y) position in screen coordinates
749
+ radius: Circle radius in pixels
750
+ color: (r, g, b, a) fill color
751
+ rotation: Rotation angle in radians (not used for circles, included for API consistency)
752
+ border_color: (r, g, b, a) border color
753
+ border_width: Border width in pixels (0 = no border)
754
+ antialiasing: Antialiasing smoothness in pixels
755
+ """
756
+ vertices = self._generate_circle_vertices(center, radius, color, rotation,
757
+ border_color, border_width, antialiasing)
758
+
759
+ data = np.array(vertices, dtype='f4')
760
+ self.circle_vbo.write(data.tobytes())
761
+
762
+ self.ctx.enable(moderngl.BLEND)
763
+ set_pattr_value(self.circle_prog, 'resolution', self.ctx.viewport[2:])
764
+ self.circle_vao.render(moderngl.TRIANGLES, vertices=6)
765
+
766
+ def create_circle(self, center: tuple[float, float], radius: float,
767
+ color: tuple[float, float, float, float] = (1.0, 1.0, 1.0, 1.0),
768
+ rotation: float = 0.0,
769
+ border_color: tuple[float, float, float, float] = (0.0, 0.0, 0.0, 0.0),
770
+ border_width: float = 0.0,
771
+ antialiasing: float = 1.0) -> ShapeLabel:
772
+ """Create a cached circle for repeated drawing."""
773
+ vertices = self._generate_circle_vertices(center, radius, color, rotation,
774
+ border_color, border_width, antialiasing)
775
+
776
+ data = np.array(vertices, dtype='f4')
777
+ vbo = self.ctx.buffer(data.tobytes())
778
+
779
+ return ShapeLabel(self.ctx, self.circle_prog, vbo, 6, 'circle')
780
+
781
+ # ========== RECTANGLE ==========
782
+
783
+ def _generate_rect_vertices(self, position: tuple[float, float], size: tuple[float, float],
784
+ color: tuple[float, float, float, float] = (1.0, 1.0, 1.0, 1.0),
785
+ rotation: float = 0.0,
786
+ corner_radius: float = 0.0,
787
+ border_color: tuple[float, float, float, float] = (0.0, 0.0, 0.0, 0.0),
788
+ border_width: float = 0.0,
789
+ antialiasing: float = 1.0) -> list[float]:
790
+ """Generate vertices for a rectangle.
791
+ Format: pos(2f), color(4f), radius(1f), border_color(4f), border_width(1f), aa(1f), size(2f), local_pos(2f)
792
+ """
793
+ x, y = position
794
+ w, h = size
795
+
796
+ # Center of rectangle
797
+ cx, cy = x + w / 2, y + h / 2
798
+
799
+ # Expand for border and antialiasing
800
+ expand = border_width + antialiasing * 2
801
+
802
+ # Rotation matrix
803
+ cos_a = np.cos(rotation)
804
+ sin_a = np.sin(rotation)
805
+
806
+ # Half dimensions
807
+ hw, hh = w / 2 + expand, h / 2 + expand
808
+ half_size = (w / 2, h / 2)
809
+
810
+ # Local corner positions
811
+ local_corners = [
812
+ (-hw, -hh), (hw, -hh), (-hw, hh),
813
+ (hw, -hh), (-hw, hh), (hw, hh)
814
+ ]
815
+
816
+ vertices = []
817
+ for lx, ly in local_corners:
818
+ # Apply rotation and translate to screen space
819
+ rx = lx * cos_a - ly * sin_a + cx
820
+ ry = lx * sin_a + ly * cos_a + cy
821
+
822
+ vertices.extend([
823
+ rx, ry, # in_pos (screen position)
824
+ *color, # in_color
825
+ corner_radius, # in_radius
826
+ *border_color, # in_border_color
827
+ border_width, # in_border_width
828
+ antialiasing, # in_aa
829
+ *half_size, # in_size (half-width, half-height for SDF)
830
+ lx, ly # in_local_pos (local position relative to center)
831
+ ])
832
+
833
+ return vertices
834
+
835
+ def draw_rect(self, position: tuple[float, float], size: tuple[float, float],
836
+ color: tuple[float, float, float, float] = (1.0, 1.0, 1.0, 1.0),
837
+ rotation: float = 0.0,
838
+ corner_radius: float = 0.0,
839
+ border_color: tuple[float, float, float, float] = (0.0, 0.0, 0.0, 0.0),
840
+ border_width: float = 0.0,
841
+ antialiasing: float = 1.0) -> None:
842
+ """
843
+ Draw a rectangle immediately.
844
+
845
+ Args:
846
+ position: (x, y) top-left corner in screen coordinates
847
+ size: (width, height) in pixels
848
+ color: (r, g, b, a) fill color
849
+ rotation: Rotation angle in radians around center
850
+ corner_radius: Radius for rounded corners in pixels
851
+ border_color: (r, g, b, a) border color
852
+ border_width: Border width in pixels (0 = no border)
853
+ antialiasing: Antialiasing smoothness in pixels
854
+ """
855
+ vertices = self._generate_rect_vertices(position, size, color, rotation,
856
+ corner_radius, border_color, border_width, antialiasing)
857
+
858
+ data = np.array(vertices, dtype='f4')
859
+ self.rect_vbo.write(data.tobytes())
860
+
861
+ self.ctx.enable(moderngl.BLEND)
862
+ set_pattr_value(self.rect_prog, 'resolution', self.ctx.viewport[2:])
863
+ self.rect_vao.render(moderngl.TRIANGLES, vertices=6)
864
+
865
+ def create_rect(self, position: tuple[float, float], size: tuple[float, float],
866
+ color: tuple[float, float, float, float] = (1.0, 1.0, 1.0, 1.0),
867
+ rotation: float = 0.0,
868
+ corner_radius: float = 0.0,
869
+ border_color: tuple[float, float, float, float] = (0.0, 0.0, 0.0, 0.0),
870
+ border_width: float = 0.0,
871
+ antialiasing: float = 1.0) -> ShapeLabel:
872
+ """Create a cached rectangle for repeated drawing."""
873
+ vertices = self._generate_rect_vertices(position, size, color, rotation,
874
+ corner_radius, border_color, border_width, antialiasing)
875
+
876
+ data = np.array(vertices, dtype='f4')
877
+ vbo = self.ctx.buffer(data.tobytes())
878
+
879
+ return ShapeLabel(self.ctx, self.rect_prog, vbo, 6, 'rect')
880
+
881
+ # ========== LINES ==========
882
+
883
+ def _generate_line_segment_vertices(self, start: tuple[float, float], end: tuple[float, float],
884
+ width: float, color: tuple[float, float, float, float],
885
+ antialias: float = 1.0) -> list[float]:
886
+ """Generate vertices for a line segment as a quad."""
887
+ x1, y1 = start
888
+ x2, y2 = end
889
+
890
+ # Calculate perpendicular direction
891
+ dx = x2 - x1
892
+ dy = y2 - y1
893
+ length = np.sqrt(dx * dx + dy * dy)
894
+
895
+ if length < 0.001:
896
+ return []
897
+
898
+ # Normalized perpendicular
899
+ px = -dy / length
900
+ py = dx / length
901
+
902
+ # Half width
903
+ hw = width / 2 + antialias
904
+
905
+ # Quad corners
906
+ vertices = []
907
+ quad = [
908
+ (x1 + px * hw, y1 + py * hw),
909
+ (x2 + px * hw, y2 + py * hw),
910
+ (x1 - px * hw, y1 - py * hw),
911
+ (x2 + px * hw, y2 + py * hw),
912
+ (x1 - px * hw, y1 - py * hw),
913
+ (x2 - px * hw, y2 - py * hw)
914
+ ]
915
+
916
+ for x, y in quad:
917
+ vertices.extend([x, y, *color])
918
+
919
+ return vertices
920
+
921
+ def draw_line(self, start: tuple[float, float], end: tuple[float, float],
922
+ width: float = 1.0,
923
+ color: tuple[float, float, float, float] = (1.0, 1.0, 1.0, 1.0),
924
+ antialiasing: float = 1.0) -> None:
925
+ """
926
+ Draw a single line segment.
927
+
928
+ Args:
929
+ start: (x, y) start point in screen coordinates
930
+ end: (x, y) end point in screen coordinates
931
+ width: Line width in pixels
932
+ color: (r, g, b, a) line color
933
+ antialiasing: Antialiasing smoothness in pixels
934
+ """
935
+ vertices = self._generate_line_segment_vertices(start, end, width, color, antialiasing)
936
+
937
+ if not vertices:
938
+ return
939
+
940
+ data = np.array(vertices, dtype='f4')
941
+ self.line_vbo.write(data.tobytes())
942
+
943
+ self.ctx.enable(moderngl.BLEND)
944
+ set_pattr_value(self.line_prog, 'resolution', self.ctx.viewport[2:])
945
+ self.line_vao.render(moderngl.TRIANGLES, vertices=6)
946
+
947
+ def draw_lines(self, points: np.ndarray | Sequence[tuple[float, float]],
948
+ width: float = 1.0,
949
+ color: tuple[float, float, float, float] | np.ndarray = (1.0, 1.0, 1.0, 1.0),
950
+ antialiasing: float = 1.0,
951
+ closed: bool = False) -> None:
952
+ """
953
+ Draw a polyline (connected line segments).
954
+
955
+ Args:
956
+ points: Array of (x, y) points, shape (N, 2) or list of tuples
957
+ width: Line width in pixels
958
+ color: Single (r, g, b, a) color or array of colors per segment
959
+ antialiasing: Antialiasing smoothness in pixels
960
+ closed: If True, connect last point to first point
961
+ """
962
+ if isinstance(points, np.ndarray):
963
+ points_array = points
964
+ else:
965
+ points_array = np.array(points, dtype='f4')
966
+
967
+ if len(points_array) < 2:
968
+ return
969
+
970
+ # Handle color
971
+ if isinstance(color, np.ndarray):
972
+ colors = color
973
+ else:
974
+ colors = np.tile(color, (len(points_array) - 1, 1))
975
+
976
+ vertices = []
977
+
978
+ # Generate line segments
979
+ for i in range(len(points_array) - 1):
980
+ start = tuple(points_array[i])
981
+ end = tuple(points_array[i + 1])
982
+ seg_color = tuple(colors[i]) if len(colors) > 1 else tuple(colors[0])
983
+
984
+ seg_verts = self._generate_line_segment_vertices(start, end, width, seg_color, antialiasing)
985
+ vertices.extend(seg_verts)
986
+
987
+ # Close the loop if requested
988
+ if closed:
989
+ start = tuple(points_array[-1])
990
+ end = tuple(points_array[0])
991
+ seg_color = tuple(colors[-1]) if len(colors) > 1 else tuple(colors[0])
992
+ seg_verts = self._generate_line_segment_vertices(start, end, width, seg_color, antialiasing)
993
+ vertices.extend(seg_verts)
994
+
995
+ if not vertices:
996
+ return
997
+
998
+ data = np.array(vertices, dtype='f4')
999
+ self.line_vbo.write(data.tobytes())
1000
+
1001
+ self.ctx.enable(moderngl.BLEND)
1002
+ set_pattr_value(self.line_prog, 'resolution', self.ctx.viewport[2:])
1003
+ num_segments = (len(points_array) - 1) + (1 if closed else 0)
1004
+ self.line_vao.render(moderngl.TRIANGLES, vertices=num_segments * 6)
1005
+
1006
+ def create_line(self, start: tuple[float, float], end: tuple[float, float],
1007
+ width: float = 1.0,
1008
+ color: tuple[float, float, float, float] = (1.0, 1.0, 1.0, 1.0),
1009
+ antialiasing: float = 1.0) -> ShapeLabel:
1010
+ """Create a cached line for repeated drawing."""
1011
+ vertices = self._generate_line_segment_vertices(start, end, width, color, antialiasing)
1012
+
1013
+ data = np.array(vertices, dtype='f4')
1014
+ vbo = self.ctx.buffer(data.tobytes())
1015
+
1016
+ return ShapeLabel(self.ctx, self.line_prog, vbo, 6, 'line')
1017
+
1018
+ def create_lines(self, points: np.ndarray | Sequence[tuple[float, float]],
1019
+ width: float = 1.0,
1020
+ color: tuple[float, float, float, float] | np.ndarray = (1.0, 1.0, 1.0, 1.0),
1021
+ antialiasing: float = 1.0,
1022
+ closed: bool = False) -> ShapeLabel:
1023
+ """Create a cached polyline for repeated drawing."""
1024
+ if isinstance(points, np.ndarray):
1025
+ points_array = points
1026
+ else:
1027
+ points_array = np.array(points, dtype='f4')
1028
+
1029
+ if isinstance(color, np.ndarray):
1030
+ colors = color
1031
+ else:
1032
+ colors = np.tile(color, (len(points_array) - 1, 1))
1033
+
1034
+ vertices = []
1035
+
1036
+ for i in range(len(points_array) - 1):
1037
+ start = tuple(points_array[i])
1038
+ end = tuple(points_array[i + 1])
1039
+ seg_color = tuple(colors[i]) if len(colors) > 1 else tuple(colors[0])
1040
+
1041
+ seg_verts = self._generate_line_segment_vertices(start, end, width, seg_color, antialiasing)
1042
+ vertices.extend(seg_verts)
1043
+
1044
+ if closed:
1045
+ start = tuple(points_array[-1])
1046
+ end = tuple(points_array[0])
1047
+ seg_color = tuple(colors[-1]) if len(colors) > 1 else tuple(colors[0])
1048
+ seg_verts = self._generate_line_segment_vertices(start, end, width, seg_color, antialiasing)
1049
+ vertices.extend(seg_verts)
1050
+
1051
+ data = np.array(vertices, dtype='f4')
1052
+ vbo = self.ctx.buffer(data.tobytes())
1053
+
1054
+ num_segments = (len(points_array) - 1) + (1 if closed else 0)
1055
+ return ShapeLabel(self.ctx, self.line_prog, vbo, num_segments * 6, 'line')
1056
+
1057
+ # ========== BATCHING ==========
1058
+
1059
+ def create_circle_batch(self, max_shapes: int = 10000) -> InstancedShapeBatch:
1060
+ """Create a batch for drawing multiple circles efficiently using GPU instancing.
1061
+
1062
+ Args:
1063
+ max_shapes: Maximum number of shapes in the batch
1064
+ """
1065
+ return InstancedShapeBatch(self.ctx, self.circle_instanced_prog, 'circle', max_shapes)
1066
+
1067
+ def create_rect_batch(self, max_shapes: int = 10000) -> InstancedShapeBatch:
1068
+ """Create a batch for drawing multiple rectangles efficiently using GPU instancing.
1069
+
1070
+ Args:
1071
+ max_shapes: Maximum number of shapes in the batch
1072
+ """
1073
+ return InstancedShapeBatch(self.ctx, self.rect_instanced_prog, 'rect', max_shapes)
1074
+
1075
+ def create_line_batch(self, max_shapes: int = 10000) -> InstancedShapeBatch:
1076
+ """Create a batch for drawing multiple lines efficiently using GPU instancing.
1077
+
1078
+ Args:
1079
+ max_shapes: Maximum number of lines in the batch
1080
+ """
1081
+ return InstancedShapeBatch(self.ctx, self.line_instanced_prog, 'line', max_shapes)