glplot 0.1.0__py3-none-any.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.
@@ -0,0 +1,185 @@
1
+ from __future__ import annotations
2
+ import ctypes as C
3
+ import numpy as np
4
+ from OpenGL.GL import *
5
+ from typing import TYPE_CHECKING, Optional, Tuple
6
+
7
+ from .base import GLScatterBuffers
8
+ from ..utils.shaders import SCATTER_VS, SCATTER_FS, DENSITY_POINTS_VS, DENSITY_POINTS_FS
9
+ from ..utils.gl_utils import link_program
10
+
11
+ if TYPE_CHECKING:
12
+ from ..core.layers import ScatterLayer
13
+ from ..core.context import RenderContext
14
+ from ..options import EngineOptions
15
+
16
+ class ScatterRenderer:
17
+ """
18
+ Primitive renderer for ScatterLayer.
19
+ Specialized for point clouds (GL_POINTS).
20
+ """
21
+ def __init__(self, options: EngineOptions):
22
+ self.options = options
23
+ self.prog = 0
24
+
25
+ # Uniform locations
26
+ self.u_mvp = -1
27
+ self.u_size = -1
28
+ self.u_alpha = -1
29
+ self.u_offset = -1
30
+
31
+ # Accumulation uniforms
32
+ self.accum_prog = 0
33
+ self.u_accum_mvp = -1
34
+ self.u_accum_size = -1
35
+ self.u_accum_alpha = -1
36
+ self.u_accum_offset = -1
37
+
38
+ def initialize(self) -> None:
39
+ """Link shaders and setup uniform locations."""
40
+ self.prog = link_program(SCATTER_VS, SCATTER_FS)
41
+ self.u_mvp = glGetUniformLocation(self.prog, "u_mvp")
42
+ self.u_size = glGetUniformLocation(self.prog, "u_size")
43
+ self.u_alpha = glGetUniformLocation(self.prog, "u_alpha")
44
+ self.u_offset = glGetUniformLocation(self.prog, "u_layer_offset")
45
+ self.u_point_size_px = glGetUniformLocation(self.prog, "u_point_size_px")
46
+ self.u_outline_enabled = glGetUniformLocation(self.prog, "u_outline_enabled")
47
+ self.u_outline_color = glGetUniformLocation(self.prog, "u_outline_color")
48
+ self.u_outline_width_px = glGetUniformLocation(self.prog, "u_outline_width_px")
49
+
50
+ # Density Accumulation Program
51
+ self.accum_prog = link_program(DENSITY_POINTS_VS, DENSITY_POINTS_FS)
52
+ self.u_accum_mvp = glGetUniformLocation(self.accum_prog, "u_mvp")
53
+ self.u_accum_size = glGetUniformLocation(self.accum_prog, "u_size")
54
+ self.u_accum_alpha = glGetUniformLocation(self.accum_prog, "u_alpha")
55
+ self.u_accum_offset = glGetUniformLocation(self.accum_prog, "u_layer_offset")
56
+
57
+ def _create_buffers(self, layer: ScatterLayer) -> GLScatterBuffers:
58
+ """Create and initialize GPU buffers for a scatter layer."""
59
+ vao = glGenVertexArrays(1)
60
+ glBindVertexArray(vao)
61
+
62
+ # 1. Point Positions
63
+ vbo_pts = glGenBuffers(1)
64
+ glBindBuffer(GL_ARRAY_BUFFER, vbo_pts)
65
+ glBufferData(GL_ARRAY_BUFFER, 16, None, GL_STATIC_DRAW) # Pre-allocate to avoid segfault
66
+ glEnableVertexAttribArray(0)
67
+ glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, 0, C.c_void_p(0))
68
+
69
+ # 2. Point Colors
70
+ vbo_col = glGenBuffers(1)
71
+ glBindBuffer(GL_ARRAY_BUFFER, vbo_col)
72
+ glBufferData(GL_ARRAY_BUFFER, 16, None, GL_STATIC_DRAW) # Pre-allocate to avoid segfault
73
+ glEnableVertexAttribArray(1)
74
+ glVertexAttribPointer(1, 4, GL_FLOAT, GL_FALSE, 0, C.c_void_p(0))
75
+
76
+ glBindVertexArray(0)
77
+ return GLScatterBuffers(vao=vao, vbo_pts=vbo_pts, vbo_col=vbo_col)
78
+
79
+ def update_gpu_data(self, layer: ScatterLayer, bufs: GLScatterBuffers) -> None:
80
+ """Upload semantic points and colors to GPU buffers."""
81
+ if layer.pts is None or len(layer.pts) == 0: return
82
+
83
+ # Upload Positions
84
+ glBindBuffer(GL_ARRAY_BUFFER, bufs.vbo_pts)
85
+ glBufferData(GL_ARRAY_BUFFER, layer.pts.nbytes, layer.pts, GL_STATIC_DRAW)
86
+
87
+ # Upload Colors
88
+ if layer.colors is not None:
89
+ # If colors are provided as a single color, broadcast it
90
+ if layer.colors.ndim == 1 and len(layer.colors) == 4:
91
+ cols = np.tile(layer.colors, (len(layer.pts), 1)).astype(np.float32)
92
+ else:
93
+ cols = layer.colors.astype(np.float32)
94
+
95
+ glBindBuffer(GL_ARRAY_BUFFER, bufs.vbo_col)
96
+ glBufferData(GL_ARRAY_BUFFER, cols.nbytes, cols, GL_STATIC_DRAW)
97
+
98
+ bufs.count = len(layer.pts)
99
+ layer.dirty.gpu_dirty = False
100
+
101
+ def draw(self, layer: ScatterLayer, ctx: RenderContext) -> None:
102
+ """Draw the scatter layer using current context."""
103
+ if layer.pts is None or len(layer.pts) == 0: return
104
+
105
+ # 1. Resource Management
106
+ if not hasattr(layer, "_gl") or layer._gl is None:
107
+ layer._gl = self._create_buffers(layer)
108
+ layer.dirty.gpu_dirty = True
109
+
110
+ if layer.dirty.gpu_dirty:
111
+ self.update_gpu_data(layer, layer._gl)
112
+
113
+ # 2. Setup OpenGL State & Shaders
114
+ glUseProgram(self.prog)
115
+ glEnable(GL_PROGRAM_POINT_SIZE)
116
+
117
+ glUniformMatrix4fv(self.u_mvp, 1, GL_TRUE, ctx.mvp)
118
+
119
+ # Style Resolution: Base * Multipliers
120
+ overrides = self.options.visual.overrides
121
+ effective_size = float(layer.style.point_size) * ctx.dpr * overrides.point_size_multiplier
122
+ effective_alpha = ctx.global_alpha * layer.style.alpha * overrides.alpha_multiplier
123
+
124
+ glUniform1f(self.u_size, effective_size)
125
+ glUniform1f(self.u_alpha, float(effective_alpha))
126
+ glUniform1f(self.u_point_size_px, effective_size)
127
+
128
+ # Outline logic
129
+ glUniform1i(self.u_outline_enabled, 1 if layer.style.point_outline_enabled else 0)
130
+ if layer.style.point_outline_enabled:
131
+ glUniform4f(self.u_outline_color, *layer.style.point_outline_color)
132
+ glUniform1f(self.u_outline_width_px, float(layer.style.point_outline_width) * ctx.dpr)
133
+
134
+ glUniform2f(self.u_offset, *layer.translation)
135
+
136
+ # 3. Draw call
137
+ glBindVertexArray(layer._gl.vao)
138
+ glDrawArrays(GL_POINTS, 0, layer._gl.count)
139
+
140
+ # Cleanup
141
+ glBindVertexArray(0)
142
+ glUseProgram(0)
143
+ glDisable(GL_PROGRAM_POINT_SIZE)
144
+
145
+ def draw_density(self, layer: ScatterLayer, ctx: RenderContext) -> None:
146
+ """Accumulate point density into the current R32F target."""
147
+ if layer.pts is None or len(layer.pts) == 0: return
148
+
149
+ # 1. Resource Management
150
+ if not hasattr(layer, "_gl") or layer._gl is None:
151
+ layer._gl = self._create_buffers(layer)
152
+ layer.dirty.gpu_dirty = True
153
+
154
+ if layer.dirty.gpu_dirty:
155
+ self.update_gpu_data(layer, layer._gl)
156
+
157
+ # 2. Setup Shaders
158
+ glUseProgram(self.accum_prog)
159
+ glEnable(GL_PROGRAM_POINT_SIZE)
160
+
161
+ glUniformMatrix4fv(self.u_accum_mvp, 1, GL_TRUE, ctx.mvp)
162
+
163
+ overrides = self.options.visual.overrides
164
+ # Use a slightly smaller size for density to prevent over-blurring
165
+ # unless user explicitly requested massive points.
166
+ effective_size = max(1.0, float(layer.style.point_size) * ctx.dpr * 0.5 * overrides.point_size_multiplier)
167
+ glUniform1f(self.u_accum_size, effective_size)
168
+
169
+ # Weighted Accumulation
170
+ if self.options.density_weighted:
171
+ alpha = ctx.global_alpha * layer.style.alpha * overrides.alpha_multiplier
172
+ glUniform1f(self.u_accum_alpha, float(alpha))
173
+ else:
174
+ glUniform1f(self.u_accum_alpha, 1.0) # Simple counting mode
175
+
176
+ glUniform2f(self.u_accum_offset, *layer.translation)
177
+
178
+ # 3. Draw call
179
+ glBindVertexArray(layer._gl.vao)
180
+ glDrawArrays(GL_POINTS, 0, layer._gl.count)
181
+
182
+ # Cleanup
183
+ glBindVertexArray(0)
184
+ glUseProgram(0)
185
+ glDisable(GL_PROGRAM_POINT_SIZE)
@@ -0,0 +1,72 @@
1
+ from __future__ import annotations
2
+ from typing import TYPE_CHECKING, Optional
3
+ import numpy as np
4
+
5
+ try:
6
+ import imgui
7
+ IMGUI_AVAILABLE = True
8
+ except ImportError:
9
+ IMGUI_AVAILABLE = False
10
+
11
+ if TYPE_CHECKING:
12
+ from ..core.layers import TextLayer
13
+ from ..core.context import RenderContext
14
+ from ..options import EngineOptions
15
+
16
+ class TextRenderer:
17
+ """
18
+ Primitive renderer for TextLayer.
19
+ Uses ImGui's background draw list to render proyected labels.
20
+ """
21
+ def __init__(self, options: EngineOptions):
22
+ self.options = options
23
+
24
+ def initialize(self) -> None:
25
+ pass
26
+
27
+ def draw(self, layer: TextLayer, ctx: RenderContext) -> None:
28
+ if not IMGUI_AVAILABLE or not layer.style.visible:
29
+ return
30
+
31
+ # 1. Project world coordinate to NDC
32
+ # Use the MVP matrix provided in the context
33
+ tx, ty = layer.translation
34
+ pos_world = np.array([layer.x + tx, layer.y + ty, 0.0, 1.0], dtype=np.float32)
35
+ pos_ndc = ctx.mvp @ pos_world
36
+
37
+ # Perspective divide
38
+ if pos_ndc[3] != 0:
39
+ pos_ndc /= pos_ndc[3]
40
+
41
+ # Clipping: if outside NDC [-1, 1], don't draw
42
+ if abs(pos_ndc[0]) > 1.1 or abs(pos_ndc[1]) > 1.1:
43
+ return
44
+
45
+ # 2. Convert NDC to screen-space (pixels)
46
+ # NDC (-1, 1) -> Screen (0, Width)
47
+ # Note: y is flipped in screen-space
48
+ screen_x = (pos_ndc[0] + 1.0) * 0.5 * ctx.width_px
49
+ screen_y = (1.0 - pos_ndc[1]) * 0.5 * ctx.height_px
50
+
51
+ # 3. Draw using ImGui
52
+ draw_list = imgui.get_background_draw_list()
53
+
54
+ color = layer.style.color if layer.style.color is not None else (0.0, 0.0, 0.0, 1.0)
55
+ # ImGui expects packed color or separate components depending on the call
56
+ # imgui.get_color_u32_rgba takes (r, g, b, a) in 0..1
57
+ u32_color = imgui.get_color_u32_rgba(*color)
58
+
59
+ # In V1, we just use the default font.
60
+ # Future enhancement: custom font sizes via pusher/pop
61
+ draw_list.add_text(screen_x, screen_y, u32_color, layer.text)
62
+
63
+ def draw_all(self, layers: List[BaseLayer], ctx: RenderContext) -> None:
64
+ """Helper to draw all text layers in the scene."""
65
+ if not IMGUI_AVAILABLE: return
66
+ for l in layers:
67
+ if l.layer_type == "text":
68
+ self.draw(l, ctx)
69
+
70
+ def draw_density(self, layer: TextLayer, ctx: RenderContext) -> None:
71
+ # Text does not participate in density
72
+ pass
File without changes
File without changes
glplot/utils/export.py ADDED
@@ -0,0 +1,112 @@
1
+ from __future__ import annotations
2
+ import time
3
+ import numpy as np
4
+ from OpenGL.GL import *
5
+ from typing import Optional, Tuple, TYPE_CHECKING
6
+ from ..options import RenderMode, BlendMode
7
+ from ..core.context import RenderContext
8
+
9
+ if TYPE_CHECKING:
10
+ from ..engine import GPULinePlot
11
+
12
+ class ExportManager:
13
+ """
14
+ Handles offscreen rendering for high-resolution exports.
15
+ """
16
+ def __init__(self, engine: GPULinePlot):
17
+ self.engine = engine
18
+
19
+ def savefig(
20
+ self,
21
+ filename: str,
22
+ scale: float = 1.0,
23
+ mode: Optional[RenderMode] = None,
24
+ exact_budget: Optional[int] = None
25
+ ) -> None:
26
+ """
27
+ Renders the current scene to an offscreen buffer at high resolution.
28
+ """
29
+ # 1. Prepare target resolution
30
+ width = int(self.engine.fb_width * scale)
31
+ height = int(self.engine.fb_height * scale)
32
+
33
+ # 2. Create offscreen resources
34
+ fbo = glGenFramebuffers(1)
35
+ glBindFramebuffer(GL_FRAMEBUFFER, fbo)
36
+
37
+ tex = glGenTextures(1)
38
+ glBindTexture(GL_TEXTURE_2D, tex)
39
+ glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB8, width, height, 0, GL_RGB, GL_UNSIGNED_BYTE, None)
40
+ glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, tex, 0)
41
+
42
+ if glCheckFramebufferStatus(GL_FRAMEBUFFER) != GL_FRAMEBUFFER_COMPLETE:
43
+ glBindFramebuffer(GL_FRAMEBUFFER, 0)
44
+ glDeleteFramebuffers(1, [fbo])
45
+ glDeleteTextures(1, [tex])
46
+ raise RuntimeError("Failed to create export framebuffer")
47
+
48
+ # 3. Save engine state and configure for export
49
+ old_viewport = glGetIntegerv(GL_VIEWPORT)
50
+ glViewport(0, 0, width, height)
51
+ glClearColor(1.0, 1.0, 1.0, 1.0)
52
+ glClear(GL_COLOR_BUFFER_BIT)
53
+
54
+ # 4. Render
55
+ # Create a proper RenderContext for high-resolution export
56
+ mvp = self.engine.camera_controller.mvp(width, height)
57
+ window = self.engine.camera_controller.world_window(width, height)
58
+
59
+ # Calculate NDC transform for high-res
60
+ ndc_scale, ndc_offset = self.engine._get_ndc_transform(window)
61
+
62
+ # Quality policy for exports
63
+ prob = 1.0
64
+ if exact_budget is not None and self.engine.scene.lines.count > 0:
65
+ prob = min(1.0, (exact_budget * width) / self.engine.scene.lines.count)
66
+
67
+ alpha = self.engine.options.default_global_alpha
68
+ if prob < 1.0:
69
+ alpha = min(1.0, alpha / (prob**0.5))
70
+
71
+ ctx = RenderContext(
72
+ mvp=mvp,
73
+ window_world=window,
74
+ ndc_scale=ndc_scale,
75
+ ndc_offset=ndc_offset,
76
+ width_px=width,
77
+ height_px=height,
78
+ fb_width=width,
79
+ fb_height=height,
80
+ dpr=scale * (self.engine.fb_width / max(self.engine.width, 1)),
81
+ mode=mode or self.engine.policy.runtime.current_mode,
82
+ global_alpha=alpha,
83
+ lod_keep_prob=prob,
84
+ time=time.perf_counter()
85
+ )
86
+
87
+ # Apply engine blending policy
88
+ self.engine._apply_blending_policy()
89
+
90
+ # Render all layers via the modular manager
91
+ layers = self.engine._get_all_layers()
92
+ if self.engine.display_density:
93
+ self.engine.renderer_manager.draw_density(layers, ctx, target_fbo=fbo, target_size=(width, height))
94
+ else:
95
+ self.engine.renderer_manager.draw_exact(layers, ctx)
96
+
97
+ # 5. Read back and save
98
+ glFinish()
99
+ pixels = glReadPixels(0, 0, width, height, GL_RGB, GL_UNSIGNED_BYTE)
100
+ image = np.frombuffer(pixels, dtype=np.uint8).reshape((height, width, 3))
101
+ image = np.flipud(image)
102
+
103
+ import matplotlib.pyplot as plt
104
+ plt.imsave(filename, image)
105
+
106
+ # 6. Cleanup
107
+ glBindFramebuffer(GL_FRAMEBUFFER, 0)
108
+ glViewport(*old_viewport)
109
+ glDeleteFramebuffers(1, [fbo])
110
+ glDeleteTextures(1, [tex])
111
+
112
+ print(f"Exported high-res image to {filename} ({width}x{height})")
@@ -0,0 +1,32 @@
1
+ import numpy as np
2
+ from OpenGL.GL import *
3
+
4
+ def ortho(l: float, r: float, b: float, t: float, n: float = -1.0, f: float = 1.0) -> np.ndarray:
5
+ rl, tb, fn = (r - l), (t - b), (f - n)
6
+ return np.array([
7
+ [2.0 / rl, 0.0, 0.0, -(r + l) / rl],
8
+ [0.0, 2.0 / tb, 0.0, -(t + b) / tb],
9
+ [0.0, 0.0, -2.0 / fn, -(f + n) / fn],
10
+ [0.0, 0.0, 0.0, 1.0],
11
+ ], dtype=np.float32)
12
+
13
+ def compile_shader(src: str, stype: int) -> int:
14
+ sid = glCreateShader(stype)
15
+ glShaderSource(sid, src)
16
+ glCompileShader(sid)
17
+ if not glGetShaderiv(sid, GL_COMPILE_STATUS):
18
+ raise RuntimeError(glGetShaderInfoLog(sid).decode(errors="ignore"))
19
+ return sid
20
+
21
+ def link_program(vs_src: str, fs_src: str) -> int:
22
+ vs = compile_shader(vs_src, GL_VERTEX_SHADER)
23
+ fs = compile_shader(fs_src, GL_FRAGMENT_SHADER)
24
+ pid = glCreateProgram()
25
+ glAttachShader(pid, vs)
26
+ glAttachShader(pid, fs)
27
+ glLinkProgram(pid)
28
+ glDeleteShader(vs)
29
+ glDeleteShader(fs)
30
+ if not glGetProgramiv(pid, GL_LINK_STATUS):
31
+ raise RuntimeError(glGetProgramInfoLog(pid).decode(errors="ignore"))
32
+ return pid
@@ -0,0 +1,60 @@
1
+ from __future__ import annotations
2
+ from dataclasses import dataclass
3
+ from typing import Tuple, Optional
4
+ import numpy as np
5
+
6
+ @dataclass
7
+ class GLPlotSnapshot:
8
+ """
9
+ Serializable container for a snapshot of a GLPlot viewport.
10
+ This can be used to transfer high-fidelity renders to other
11
+ plotting libraries like Matplotlib.
12
+ """
13
+ rgba: np.ndarray # H x W x 4 uint8
14
+ extent: Tuple[float, float, float, float] # xmin, xmax, ymin, ymax
15
+ xlim: Tuple[float, float]
16
+ ylim: Tuple[float, float]
17
+ width_px: int
18
+ height_px: int
19
+ transparent: bool
20
+
21
+ def snapshot_to_matplotlib(
22
+ snapshot: GLPlotSnapshot,
23
+ ax=None,
24
+ interpolation: str = "nearest",
25
+ preserve_aspect: bool = True,
26
+ set_limits: bool = True,
27
+ zorder: float = 0.0
28
+ ):
29
+ """
30
+ Standalone utility to embed a GLPlotSnapshot into a Matplotlib axis.
31
+ Does not require a live OpenGL context.
32
+ """
33
+ import matplotlib.pyplot as plt
34
+
35
+ if ax is None:
36
+ fig, ax = plt.subplots()
37
+ else:
38
+ fig = ax.figure
39
+
40
+ xmin, xmax, ymin, ymax = snapshot.extent
41
+
42
+ # Matplotlib's imshow with extent handles the coordinate mapping.
43
+ # We use origin="lower" because OpenGL starts at bottom-left.
44
+ artist = ax.imshow(
45
+ snapshot.rgba,
46
+ extent=(xmin, xmax, ymin, ymax),
47
+ origin="lower",
48
+ interpolation=interpolation,
49
+ zorder=zorder
50
+ )
51
+
52
+ if set_limits:
53
+ ax.set_xlim(xmin, xmax)
54
+ ax.set_ylim(ymin, ymax)
55
+
56
+ if preserve_aspect:
57
+ # Matches GLPlot's likely aspect if it was consistent
58
+ ax.set_aspect("equal", adjustable="box")
59
+
60
+ return fig, ax, artist