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.
- glplot/__init__.py +6 -0
- glplot/backend.py +15 -0
- glplot/controllers.py +69 -0
- glplot/core/__init__.py +0 -0
- glplot/core/context.py +50 -0
- glplot/core/layers.py +163 -0
- glplot/core/legacy.py +98 -0
- glplot/engine.py +1270 -0
- glplot/managers/__init__.py +0 -0
- glplot/managers/axis.py +66 -0
- glplot/managers/effects.py +343 -0
- glplot/managers/hud.py +510 -0
- glplot/managers/hud_state.py +95 -0
- glplot/managers/picking.py +174 -0
- glplot/managers/renderer_manager.py +158 -0
- glplot/options.py +120 -0
- glplot/policy.py +108 -0
- glplot/pyplot.py +735 -0
- glplot/renderers/__init__.py +0 -0
- glplot/renderers/axis.py +126 -0
- glplot/renderers/base.py +40 -0
- glplot/renderers/density.py +120 -0
- glplot/renderers/exact.py +215 -0
- glplot/renderers/interaction.py +77 -0
- glplot/renderers/line_family.py +250 -0
- glplot/renderers/patch.py +149 -0
- glplot/renderers/polyline.py +230 -0
- glplot/renderers/scatter.py +185 -0
- glplot/renderers/text.py +72 -0
- glplot/scratch/__init__.py +0 -0
- glplot/utils/__init__.py +0 -0
- glplot/utils/export.py +112 -0
- glplot/utils/gl_utils.py +32 -0
- glplot/utils/mpl_bridge.py +60 -0
- glplot/utils/shaders.py +889 -0
- glplot-0.1.0.dist-info/METADATA +75 -0
- glplot-0.1.0.dist-info/RECORD +39 -0
- glplot-0.1.0.dist-info/WHEEL +4 -0
- glplot-0.1.0.dist-info/licenses/LICENSE +0 -0
|
@@ -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)
|
glplot/renderers/text.py
ADDED
|
@@ -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
|
glplot/utils/__init__.py
ADDED
|
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})")
|
glplot/utils/gl_utils.py
ADDED
|
@@ -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
|