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,174 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import numpy as np
|
|
3
|
+
from OpenGL.GL import *
|
|
4
|
+
from typing import Optional, TYPE_CHECKING, Tuple
|
|
5
|
+
from ..renderers.base import GLOffscreenTarget
|
|
6
|
+
from ..utils.gl_utils import link_program
|
|
7
|
+
from ..utils.shaders import (
|
|
8
|
+
PICKING_LINES_VS, PICKING_LINES_FS,
|
|
9
|
+
PICKING_SCATTER_VS, PICKING_SCATTER_FS,
|
|
10
|
+
PICKING_STRIP_VS, PICKING_STRIP_FS,
|
|
11
|
+
PICKING_PATCH_VS, PICKING_PATCH_FS
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from ..options import EngineOptions
|
|
16
|
+
from ..core import SceneData
|
|
17
|
+
from ..renderers.exact import GLLineBuffers
|
|
18
|
+
|
|
19
|
+
class PickingManager:
|
|
20
|
+
def __init__(self, options: EngineOptions):
|
|
21
|
+
self.options = options
|
|
22
|
+
self.target = GLOffscreenTarget()
|
|
23
|
+
self.pid_lines = -1
|
|
24
|
+
self.pid_scatter = -1
|
|
25
|
+
self.pid_strip = -1
|
|
26
|
+
self.pid_patch = -1
|
|
27
|
+
|
|
28
|
+
def initialize(self, fb_width: int, fb_height: int) -> None:
|
|
29
|
+
self.pid_lines = link_program(PICKING_LINES_VS, PICKING_LINES_FS)
|
|
30
|
+
self.pid_scatter = link_program(PICKING_SCATTER_VS, PICKING_SCATTER_FS)
|
|
31
|
+
self.pid_strip = link_program(PICKING_STRIP_VS, PICKING_STRIP_FS)
|
|
32
|
+
self.pid_patch = link_program(PICKING_PATCH_VS, PICKING_PATCH_FS)
|
|
33
|
+
self.rebuild_target(fb_width, fb_height)
|
|
34
|
+
|
|
35
|
+
def rebuild_target(self, fb_width: int, fb_height: int) -> None:
|
|
36
|
+
if self.target.fbo:
|
|
37
|
+
glDeleteFramebuffers(1, [self.target.fbo])
|
|
38
|
+
glDeleteTextures(1, [self.target.tex])
|
|
39
|
+
|
|
40
|
+
tex = glGenTextures(1)
|
|
41
|
+
glBindTexture(GL_TEXTURE_2D, tex)
|
|
42
|
+
# We store 32-bit Integer IDs
|
|
43
|
+
glTexImage2D(GL_TEXTURE_2D, 0, GL_R32I, fb_width, fb_height, 0, GL_RED_INTEGER, GL_INT, None)
|
|
44
|
+
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST)
|
|
45
|
+
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST)
|
|
46
|
+
|
|
47
|
+
fbo = glGenFramebuffers(1)
|
|
48
|
+
glBindFramebuffer(GL_FRAMEBUFFER, fbo)
|
|
49
|
+
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, tex, 0)
|
|
50
|
+
|
|
51
|
+
status = glCheckFramebufferStatus(GL_FRAMEBUFFER)
|
|
52
|
+
if status != GL_FRAMEBUFFER_COMPLETE:
|
|
53
|
+
print(f"Picking Framebuffer incomplete: {status}")
|
|
54
|
+
|
|
55
|
+
glBindFramebuffer(GL_FRAMEBUFFER, 0)
|
|
56
|
+
self.target = GLOffscreenTarget(fbo=fbo, tex=tex, width=fb_width, height=fb_height)
|
|
57
|
+
|
|
58
|
+
def draw_pick_scene(
|
|
59
|
+
self,
|
|
60
|
+
scene: SceneData,
|
|
61
|
+
buffers: Any,
|
|
62
|
+
mvp: np.ndarray,
|
|
63
|
+
window: Tuple[float, float, float, float]
|
|
64
|
+
) -> None:
|
|
65
|
+
glBindFramebuffer(GL_FRAMEBUFFER, self.target.fbo)
|
|
66
|
+
glViewport(0, 0, self.target.width, self.target.height)
|
|
67
|
+
|
|
68
|
+
# 1. State for picking: no blending, no multisampling
|
|
69
|
+
glDisable(GL_BLEND)
|
|
70
|
+
glDisable(GL_MULTISAMPLE)
|
|
71
|
+
|
|
72
|
+
# Clear with 0 (no object) using integer clear
|
|
73
|
+
glClearBufferiv(GL_COLOR, 0, np.array([0], dtype=np.int32))
|
|
74
|
+
|
|
75
|
+
current_offset = 0
|
|
76
|
+
for i, layer in enumerate(scene.layers):
|
|
77
|
+
if not layer.style.visible:
|
|
78
|
+
continue
|
|
79
|
+
|
|
80
|
+
# IDs are 1-based, 0 is "nothing"
|
|
81
|
+
layer_id_start = current_offset + 1
|
|
82
|
+
|
|
83
|
+
if layer.layer_type == "line_family":
|
|
84
|
+
if layer.ab is not None and hasattr(layer, "_gl"):
|
|
85
|
+
glUseProgram(self.pid_lines)
|
|
86
|
+
glUniformMatrix4fv(glGetUniformLocation(self.pid_lines, "u_mvp"), 1, GL_FALSE, mvp)
|
|
87
|
+
glUniform2f(glGetUniformLocation(self.pid_lines, "u_xrange"), *layer.x_range)
|
|
88
|
+
glUniform4f(glGetUniformLocation(self.pid_lines, "u_window"), *window)
|
|
89
|
+
glUniform2f(glGetUniformLocation(self.pid_lines, "u_layer_offset"), *layer.translation)
|
|
90
|
+
glUniform1i(glGetUniformLocation(self.pid_lines, "u_id_offset"), current_offset)
|
|
91
|
+
layer._gl.render(len(layer.ab))
|
|
92
|
+
current_offset += len(layer.ab)
|
|
93
|
+
|
|
94
|
+
elif layer.layer_type == "scatter":
|
|
95
|
+
if hasattr(layer, "_gl"):
|
|
96
|
+
glUseProgram(self.pid_scatter)
|
|
97
|
+
glEnable(GL_PROGRAM_POINT_SIZE)
|
|
98
|
+
glUniformMatrix4fv(glGetUniformLocation(self.pid_scatter, "u_mvp"), 1, GL_FALSE, mvp)
|
|
99
|
+
glUniform1f(glGetUniformLocation(self.pid_scatter, "u_size"), float(layer.style.point_size))
|
|
100
|
+
glUniform1i(glGetUniformLocation(self.pid_scatter, "u_id_offset"), current_offset)
|
|
101
|
+
glUniform2f(glGetUniformLocation(self.pid_scatter, "u_layer_offset"), *layer.translation)
|
|
102
|
+
glBindVertexArray(layer._gl.vao)
|
|
103
|
+
glDrawArrays(GL_POINTS, 0, layer._gl.count)
|
|
104
|
+
current_offset += layer._gl.count
|
|
105
|
+
|
|
106
|
+
elif layer.layer_type == "polyline":
|
|
107
|
+
if hasattr(layer, "_gl"):
|
|
108
|
+
# Polyline is treated as 1 object for now
|
|
109
|
+
glUseProgram(self.pid_strip)
|
|
110
|
+
glUniformMatrix4fv(glGetUniformLocation(self.pid_strip, "u_mvp"), 1, GL_FALSE, mvp)
|
|
111
|
+
glUniform1i(glGetUniformLocation(self.pid_strip, "u_id"), current_offset + 1)
|
|
112
|
+
glUniform2f(glGetUniformLocation(self.pid_strip, "u_layer_offset"), *layer.translation)
|
|
113
|
+
glBindVertexArray(layer._gl.vao)
|
|
114
|
+
# Polyline uses instanced quads (6 verts per segment)
|
|
115
|
+
glDrawElements(GL_TRIANGLES, layer._gl.instance_count * 6, GL_UNSIGNED_SHORT, None)
|
|
116
|
+
current_offset += 1
|
|
117
|
+
|
|
118
|
+
elif layer.layer_type == "patch":
|
|
119
|
+
if hasattr(layer, "_gl"):
|
|
120
|
+
glUseProgram(self.pid_patch)
|
|
121
|
+
glUniformMatrix4fv(glGetUniformLocation(self.pid_patch, "u_mvp"), 1, GL_FALSE, mvp)
|
|
122
|
+
glUniform1i(glGetUniformLocation(self.pid_patch, "u_id"), current_offset + 1)
|
|
123
|
+
glUniform2f(glGetUniformLocation(self.pid_patch, "u_layer_offset"), *layer.translation)
|
|
124
|
+
glBindVertexArray(layer._gl.vao)
|
|
125
|
+
mode = GL_TRIANGLE_STRIP
|
|
126
|
+
if getattr(layer, "mode", "") == "triangles": mode = GL_TRIANGLES
|
|
127
|
+
if layer._gl.ebo:
|
|
128
|
+
glDrawElements(mode, layer._gl.count, GL_UNSIGNED_INT, None)
|
|
129
|
+
else:
|
|
130
|
+
glDrawArrays(mode, 0, layer._gl.count)
|
|
131
|
+
current_offset += 1
|
|
132
|
+
|
|
133
|
+
glBindFramebuffer(GL_FRAMEBUFFER, 0)
|
|
134
|
+
glUseProgram(0)
|
|
135
|
+
|
|
136
|
+
def pick_readback(self, sx: float, sy: float, scene: SceneData) -> Optional[dict]:
|
|
137
|
+
"""sx, sy must be in framebuffer/pixel coordinates (already DPR-scaled)."""
|
|
138
|
+
gx = int(sx)
|
|
139
|
+
gy = int(self.target.height - sy)
|
|
140
|
+
|
|
141
|
+
if gx < 0 or gx >= self.target.width or gy < 0 or gy >= self.target.height:
|
|
142
|
+
return None
|
|
143
|
+
|
|
144
|
+
glBindFramebuffer(GL_FRAMEBUFFER, self.target.fbo)
|
|
145
|
+
pixels = glReadPixels(gx, gy, 1, 1, GL_RED_INTEGER, GL_INT)
|
|
146
|
+
glBindFramebuffer(GL_FRAMEBUFFER, 0)
|
|
147
|
+
|
|
148
|
+
val = int(np.frombuffer(pixels, dtype=np.int32)[0])
|
|
149
|
+
if val <= 0:
|
|
150
|
+
return None
|
|
151
|
+
|
|
152
|
+
# Decode based on global offsets
|
|
153
|
+
current_offset = 0
|
|
154
|
+
for i, layer in enumerate(scene.layers):
|
|
155
|
+
if not layer.style.visible: continue
|
|
156
|
+
|
|
157
|
+
count = 0
|
|
158
|
+
if layer.layer_type == "line_family":
|
|
159
|
+
count = len(layer.ab) if layer.ab is not None else 0
|
|
160
|
+
elif layer.layer_type == "scatter":
|
|
161
|
+
count = layer._gl.count if hasattr(layer, "_gl") else 0
|
|
162
|
+
elif layer.layer_type in ["polyline", "patch"]:
|
|
163
|
+
count = 1
|
|
164
|
+
|
|
165
|
+
if current_offset < val <= current_offset + count:
|
|
166
|
+
return {
|
|
167
|
+
"type": layer.layer_type,
|
|
168
|
+
"layer_id": layer.layer_id, # Use stable ID
|
|
169
|
+
"element_idx": val - current_offset - 1,
|
|
170
|
+
"layer": layer
|
|
171
|
+
}
|
|
172
|
+
current_offset += count
|
|
173
|
+
|
|
174
|
+
return None
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from typing import TYPE_CHECKING, List, Dict, Optional, Type, Tuple, Any, Iterable
|
|
3
|
+
from enum import Flag, auto
|
|
4
|
+
import numpy as np
|
|
5
|
+
|
|
6
|
+
if TYPE_CHECKING:
|
|
7
|
+
from ..engine import GPULinePlot
|
|
8
|
+
from ..core.layers import BaseLayer
|
|
9
|
+
from ..options import EngineOptions
|
|
10
|
+
|
|
11
|
+
from ..renderers.line_family import LineFamilyRenderer
|
|
12
|
+
from ..renderers.polyline import PolylineRenderer
|
|
13
|
+
from ..renderers.scatter import ScatterRenderer
|
|
14
|
+
from ..renderers.patch import PatchRenderer
|
|
15
|
+
from ..renderers.text import TextRenderer
|
|
16
|
+
from ..renderers.axis import AxisRenderer
|
|
17
|
+
|
|
18
|
+
class LayerCapability(Flag):
|
|
19
|
+
"""Flags defining what a layer can participate in."""
|
|
20
|
+
NONE = 0
|
|
21
|
+
EXACT = auto() # Can be rendered in the main pass
|
|
22
|
+
DENSITY = auto() # Can be rendered in the density pass
|
|
23
|
+
PICKING = auto() # Can be rendered in the picking pass
|
|
24
|
+
EXPORT = auto() # Can be exported to image
|
|
25
|
+
OVERLAY = auto() # Is an overlay (HUD-like)
|
|
26
|
+
|
|
27
|
+
class RendererManager:
|
|
28
|
+
"""
|
|
29
|
+
Orchestrates the rendering of layers, sorting by z-order,
|
|
30
|
+
and dispatching to specialized primitive renderers.
|
|
31
|
+
"""
|
|
32
|
+
def __init__(self, plot: GPULinePlot):
|
|
33
|
+
self.plot = plot
|
|
34
|
+
self.options = plot.options
|
|
35
|
+
|
|
36
|
+
# Capability-based renderer registration
|
|
37
|
+
# Map: layer_type -> renderer instance
|
|
38
|
+
self.renderers: Dict[str, Any] = {}
|
|
39
|
+
|
|
40
|
+
# Performance cache
|
|
41
|
+
self._sorted_layers: List[BaseLayer] = []
|
|
42
|
+
self._last_scene_hash: int = 0
|
|
43
|
+
|
|
44
|
+
def initialize(self) -> None:
|
|
45
|
+
"""Initialize all registered primitive renderers."""
|
|
46
|
+
self.renderers["line_family"] = LineFamilyRenderer(self.options)
|
|
47
|
+
self.renderers["polyline"] = PolylineRenderer(self.options)
|
|
48
|
+
self.renderers["scatter"] = ScatterRenderer(self.options)
|
|
49
|
+
self.renderers["patch"] = PatchRenderer(self.options)
|
|
50
|
+
self.renderers["text"] = TextRenderer(self.options)
|
|
51
|
+
self.renderers["axis"] = AxisRenderer(self.options)
|
|
52
|
+
for renderer in self.renderers.values():
|
|
53
|
+
renderer.initialize()
|
|
54
|
+
|
|
55
|
+
def filter_layers(self, layers: Iterable[BaseLayer], capability: LayerCapability) -> List[BaseLayer]:
|
|
56
|
+
"""
|
|
57
|
+
Filter and sort layers based on visibility and capability.
|
|
58
|
+
This fulfils the Phase 2 Capability Filtering requirement.
|
|
59
|
+
"""
|
|
60
|
+
# 1. Filter by visibility and capability metadata
|
|
61
|
+
# (In V1, we assume all layers support EXACT/EXPORT unless specified)
|
|
62
|
+
eligible = []
|
|
63
|
+
for l in layers:
|
|
64
|
+
if not l.style.visible:
|
|
65
|
+
continue
|
|
66
|
+
|
|
67
|
+
# Capability check
|
|
68
|
+
# Default to EXACT | EXPORT | DENSITY if not specified
|
|
69
|
+
caps = l.metadata.get("capabilities", LayerCapability.EXACT | LayerCapability.EXPORT | LayerCapability.DENSITY)
|
|
70
|
+
if capability in caps:
|
|
71
|
+
eligible.append(l)
|
|
72
|
+
|
|
73
|
+
# 2. Sort by zorder, maintaining stable insertion order for ties
|
|
74
|
+
return sorted(eligible, key=lambda l: l.style.zorder)
|
|
75
|
+
|
|
76
|
+
def draw_density(self, layers: List[BaseLayer], context: Any, target_fbo: int = 0, target_size: Optional[Tuple[int, int]] = None) -> None:
|
|
77
|
+
"""Modular DENSITY pass render loop."""
|
|
78
|
+
sorted_layers = self.filter_layers(layers, LayerCapability.DENSITY)
|
|
79
|
+
|
|
80
|
+
# 1. Prepare the density manager for accumulation
|
|
81
|
+
self.plot.density_renderer.begin_accum()
|
|
82
|
+
|
|
83
|
+
# 2. Accumulate each layer
|
|
84
|
+
for layer in sorted_layers:
|
|
85
|
+
renderer = self.renderers.get(layer.layer_type)
|
|
86
|
+
if renderer and hasattr(renderer, "draw_density"):
|
|
87
|
+
renderer.draw_density(layer, context)
|
|
88
|
+
|
|
89
|
+
# 3. Resolve to the target FBO
|
|
90
|
+
self.plot.density_renderer.resolve(target_fbo=target_fbo, target_size=target_size)
|
|
91
|
+
|
|
92
|
+
def draw_exact(self, layers: List[BaseLayer], context: Any) -> None:
|
|
93
|
+
"""Main EXACT pass render loop."""
|
|
94
|
+
sorted_layers = self.filter_layers(layers, LayerCapability.EXACT)
|
|
95
|
+
|
|
96
|
+
# Pre-count types for normalization
|
|
97
|
+
type_totals = {}
|
|
98
|
+
for l in sorted_layers:
|
|
99
|
+
type_totals[l.layer_type] = type_totals.get(l.layer_type, 0) + 1
|
|
100
|
+
|
|
101
|
+
type_counters = {}
|
|
102
|
+
for layer in sorted_layers:
|
|
103
|
+
total = type_totals[layer.layer_type]
|
|
104
|
+
current = type_counters.get(layer.layer_type, 0)
|
|
105
|
+
|
|
106
|
+
# Calculate normalized ID for colormapping (sequential across layers of same type)
|
|
107
|
+
id_norm = float(current) / float(max(1, total - 1)) if total > 1 else 0.5
|
|
108
|
+
|
|
109
|
+
self._dispatch_draw(layer, context, id_norm=id_norm)
|
|
110
|
+
type_counters[layer.layer_type] = current + 1
|
|
111
|
+
|
|
112
|
+
def draw_axes(self, axis_manager: Any, context: Any) -> None:
|
|
113
|
+
"""Draw the coordinate framework."""
|
|
114
|
+
self.renderers["axis"].draw(axis_manager, context)
|
|
115
|
+
|
|
116
|
+
def _dispatch_draw(self, layer: BaseLayer, context: Any, id_norm: float = 0.0) -> None:
|
|
117
|
+
"""Internal dispatcher for primitive types."""
|
|
118
|
+
renderer = self.renderers.get(layer.layer_type)
|
|
119
|
+
if renderer:
|
|
120
|
+
# Check if renderer expects id_norm
|
|
121
|
+
import inspect
|
|
122
|
+
sig = inspect.signature(renderer.draw)
|
|
123
|
+
if "id_norm" in sig.parameters:
|
|
124
|
+
renderer.draw(layer, context, id_norm=id_norm)
|
|
125
|
+
else:
|
|
126
|
+
renderer.draw(layer, context)
|
|
127
|
+
else:
|
|
128
|
+
# Fallback for Phase 0-2: legacy bridge handles drawing
|
|
129
|
+
pass
|
|
130
|
+
|
|
131
|
+
def get_bounds(self, layers: List[BaseLayer]) -> Optional[Tuple[float, float, float, float]]:
|
|
132
|
+
"""Calculate collective bounds of all layers contributing to autoscale."""
|
|
133
|
+
xmin, xmax, ymin, ymax = float('inf'), float('-inf'), float('inf'), float('-inf')
|
|
134
|
+
found = False
|
|
135
|
+
|
|
136
|
+
for layer in layers:
|
|
137
|
+
# Autoscale policy: visible layers + no semantic guide lines
|
|
138
|
+
if not layer.style.visible: continue
|
|
139
|
+
|
|
140
|
+
# Policy check: does this layer type participate in autoscale?
|
|
141
|
+
# V1 NON-GOAL: Text and guide lines (axvline) don't count by default
|
|
142
|
+
is_guide = layer.layer_type in ["semantic_line", "semantic_span", "text"]
|
|
143
|
+
if is_guide: continue
|
|
144
|
+
|
|
145
|
+
b = layer.get_intrinsic_bounds()
|
|
146
|
+
if b:
|
|
147
|
+
if not all(np.isfinite(b)): continue
|
|
148
|
+
tx, ty = layer.translation
|
|
149
|
+
xmin = min(xmin, b[0] + tx)
|
|
150
|
+
xmax = max(xmax, b[1] + tx)
|
|
151
|
+
ymin = min(ymin, b[2] + ty)
|
|
152
|
+
ymax = max(ymax, b[3] + ty)
|
|
153
|
+
found = True
|
|
154
|
+
|
|
155
|
+
if not found or xmin == float('inf'):
|
|
156
|
+
return None
|
|
157
|
+
|
|
158
|
+
return (xmin, xmax, ymin, ymax)
|
glplot/options.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from dataclasses import dataclass, field
|
|
3
|
+
from enum import Enum, auto
|
|
4
|
+
|
|
5
|
+
class RenderMode(Enum):
|
|
6
|
+
EXACT = auto()
|
|
7
|
+
INTERACTIVE = auto()
|
|
8
|
+
|
|
9
|
+
class BlendMode(Enum):
|
|
10
|
+
ALPHA = auto() # Standard transparency (SrcAlpha, OneMinusSrcAlpha)
|
|
11
|
+
ADDITIVE = auto() # Glowing accumulation (SrcAlpha, One)
|
|
12
|
+
SUBTRACTIVE = auto()# Anti-glow (RevSub, SrcAlpha, One)
|
|
13
|
+
SCREEN = auto() # Lightening (One, OneMinusSrcColor)
|
|
14
|
+
AUTO = auto() # Smart switching based on count
|
|
15
|
+
OFF = auto() # No blending
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class GlowOptions:
|
|
19
|
+
enabled: bool = False
|
|
20
|
+
threshold: float = 0.7
|
|
21
|
+
intensity: float = 0.8
|
|
22
|
+
radius_px: float = 6.0
|
|
23
|
+
resolution_scale: float = 0.5
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class GradientBackgroundOptions:
|
|
27
|
+
enabled: bool = False
|
|
28
|
+
top_color: tuple = (1.0, 1.0, 1.0)
|
|
29
|
+
bottom_color: tuple = (0.95, 0.97, 1.0)
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class GlobalStyleOverrides:
|
|
33
|
+
"""Centralized multipliers to adjust the entire scene at once."""
|
|
34
|
+
enabled: bool = True
|
|
35
|
+
alpha_multiplier: float = 1.0
|
|
36
|
+
line_width_multiplier: float = 1.0
|
|
37
|
+
point_size_multiplier: float = 1.0
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class VisualOptions:
|
|
41
|
+
background_color: tuple = (0.0, 0.0, 0.0)
|
|
42
|
+
glow: GlowOptions = field(default_factory=GlowOptions)
|
|
43
|
+
gradient_background: GradientBackgroundOptions = field(default_factory=GradientBackgroundOptions)
|
|
44
|
+
overrides: GlobalStyleOverrides = field(default_factory=GlobalStyleOverrides)
|
|
45
|
+
|
|
46
|
+
@dataclass
|
|
47
|
+
class EngineOptions:
|
|
48
|
+
window_width: int = 1400
|
|
49
|
+
window_height: int = 900
|
|
50
|
+
title: str = "Hybrid GPU Line Renderer"
|
|
51
|
+
|
|
52
|
+
# Quality / scale policy
|
|
53
|
+
lod_enabled: bool = True
|
|
54
|
+
lod_target_coverage: float = 0.35
|
|
55
|
+
default_global_alpha: float = 0.20
|
|
56
|
+
default_line_budget_per_px: int = 8
|
|
57
|
+
global_line_width: float = 1.0
|
|
58
|
+
interaction_budget_lines_per_screen_px: int = 2
|
|
59
|
+
auto_disable_blending_threshold: int = 100_000_000
|
|
60
|
+
|
|
61
|
+
# Interaction
|
|
62
|
+
drag_threshold_px: float = 4.0
|
|
63
|
+
hover_pick_hz: float = 0.0 # 0 means disabled; picking is shift-only
|
|
64
|
+
cache_refresh_hz: float = 10.0
|
|
65
|
+
cache_padding: float = 3.0
|
|
66
|
+
cache_safe_margin: float = 0.15
|
|
67
|
+
zoom_scroll_factor: float = 1.10
|
|
68
|
+
|
|
69
|
+
# Navigation tuning (Phase 2 additions planned)
|
|
70
|
+
pan_key_fraction: float = 0.08
|
|
71
|
+
zoom_key_factor: float = 1.15
|
|
72
|
+
zoom_scroll_factor: float = 1.10
|
|
73
|
+
box_zoom_min_pixels: int = 8
|
|
74
|
+
|
|
75
|
+
# Feature policies
|
|
76
|
+
enable_hud: bool = False # ask user beforehand, or set explicitly
|
|
77
|
+
|
|
78
|
+
# Axis / Framework visibility
|
|
79
|
+
axis_show_grid: bool = True
|
|
80
|
+
axis_show_labels: bool = True
|
|
81
|
+
axis_show_frame: bool = True
|
|
82
|
+
axis_grid_alpha: float = 0.1
|
|
83
|
+
axis_grid_color: tuple = (0.2, 0.2, 0.2)
|
|
84
|
+
|
|
85
|
+
enable_density_interaction_path: bool = True
|
|
86
|
+
enable_cache_interaction_path: bool = True
|
|
87
|
+
enable_clipping_optimization: bool = True
|
|
88
|
+
enable_multisample: bool = False
|
|
89
|
+
always_lod: bool = False
|
|
90
|
+
|
|
91
|
+
# Picking policy
|
|
92
|
+
shift_required_for_picking: bool = True
|
|
93
|
+
picking_radius_px: int = 5
|
|
94
|
+
|
|
95
|
+
# Export
|
|
96
|
+
export_scale: float = 2.0
|
|
97
|
+
|
|
98
|
+
# Density rendering
|
|
99
|
+
density_gain: float = 1.0
|
|
100
|
+
density_resolution_scale: float = 1.0 # 1.0 = full-res, 0.5 = faster
|
|
101
|
+
density_scheme_index: int = 0
|
|
102
|
+
density_gain_step: float = 1.25
|
|
103
|
+
density_log_scale: float = 3.0 # Divisor for log normalization
|
|
104
|
+
density_weighted: bool = False # Accumulate alpha instead of 1.0
|
|
105
|
+
|
|
106
|
+
# Style
|
|
107
|
+
blend_mode: BlendMode = BlendMode.AUTO
|
|
108
|
+
line_colormap_enabled: bool = False
|
|
109
|
+
enable_auto_alpha: bool = True # Scale alpha based on N
|
|
110
|
+
|
|
111
|
+
# Visual Effects
|
|
112
|
+
visual: VisualOptions = field(default_factory=VisualOptions)
|
|
113
|
+
|
|
114
|
+
@dataclass
|
|
115
|
+
class RuntimePolicy:
|
|
116
|
+
# Mutable runtime decisions derived from dataset size and state.
|
|
117
|
+
blending_enabled: bool = True
|
|
118
|
+
current_mode: RenderMode = RenderMode.EXACT
|
|
119
|
+
picking_enabled_this_frame: bool = False
|
|
120
|
+
hud_enabled_this_frame: bool = False
|
glplot/policy.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import numpy as np
|
|
3
|
+
from typing import TYPE_CHECKING, Tuple
|
|
4
|
+
from .options import RenderMode
|
|
5
|
+
|
|
6
|
+
if TYPE_CHECKING:
|
|
7
|
+
from .options import EngineOptions
|
|
8
|
+
from .core.legacy import SceneData, InteractionState, CacheState
|
|
9
|
+
from .core.context import RenderContext
|
|
10
|
+
|
|
11
|
+
class RenderPolicyManager:
|
|
12
|
+
"""
|
|
13
|
+
Orchestrates rendering policies including LOD, blending, and density fallbacks.
|
|
14
|
+
Implements 'Width-Aware' scaling to protect against fill-rate bottlenecks.
|
|
15
|
+
"""
|
|
16
|
+
def __init__(self, options: EngineOptions):
|
|
17
|
+
from .options import RuntimePolicy
|
|
18
|
+
self.options = options
|
|
19
|
+
self.runtime = RuntimePolicy()
|
|
20
|
+
|
|
21
|
+
def update(self, scene: SceneData, interaction: InteractionState, cache: CacheState) -> None:
|
|
22
|
+
n = scene.lines.count
|
|
23
|
+
|
|
24
|
+
if interaction.drag_active or cache.active:
|
|
25
|
+
self.runtime.current_mode = RenderMode.INTERACTIVE
|
|
26
|
+
else:
|
|
27
|
+
self.runtime.current_mode = RenderMode.EXACT
|
|
28
|
+
|
|
29
|
+
if self.options.shift_required_for_picking:
|
|
30
|
+
self.runtime.picking_enabled_this_frame = interaction.shift_down and not interaction.drag_active
|
|
31
|
+
else:
|
|
32
|
+
self.runtime.picking_enabled_this_frame = not interaction.drag_active
|
|
33
|
+
|
|
34
|
+
if self.options.enable_hud and self.runtime.current_mode == RenderMode.EXACT:
|
|
35
|
+
self.runtime.hud_enabled_this_frame = True
|
|
36
|
+
else:
|
|
37
|
+
self.runtime.hud_enabled_this_frame = False
|
|
38
|
+
|
|
39
|
+
# Blending policy
|
|
40
|
+
from .options import BlendMode
|
|
41
|
+
m = self.options.blend_mode
|
|
42
|
+
if m == BlendMode.OFF:
|
|
43
|
+
self.runtime.blending_enabled = False
|
|
44
|
+
elif m == BlendMode.AUTO:
|
|
45
|
+
self.runtime.blending_enabled = (n <= self.options.auto_disable_blending_threshold)
|
|
46
|
+
else:
|
|
47
|
+
self.runtime.blending_enabled = True
|
|
48
|
+
|
|
49
|
+
def estimate_polyline_screen_length_px(self, pts: np.ndarray, ctx: RenderContext, max_samples: int = 4096) -> float:
|
|
50
|
+
"""Estimate the total length of a polyline in screen pixels (cheaply)."""
|
|
51
|
+
if pts is None or len(pts) < 2:
|
|
52
|
+
return 0.0
|
|
53
|
+
|
|
54
|
+
# Sample long polylines to keep policy update fast
|
|
55
|
+
stride = max(1, (len(pts) - 1) // max_samples)
|
|
56
|
+
sample = pts[::stride]
|
|
57
|
+
if len(sample) < 2:
|
|
58
|
+
return 0.0
|
|
59
|
+
|
|
60
|
+
l, r, b, t = ctx.window_world
|
|
61
|
+
# Screen units per world unit
|
|
62
|
+
sx = ctx.fb_width / max(r - l, 1e-12)
|
|
63
|
+
sy = ctx.fb_height / max(t - b, 1e-12)
|
|
64
|
+
|
|
65
|
+
diffs = np.diff(sample, axis=0)
|
|
66
|
+
# Transform diffs to px and get length
|
|
67
|
+
seg_px = np.linalg.norm(diffs * np.array([sx, sy], dtype=np.float32)[None, :], axis=1)
|
|
68
|
+
|
|
69
|
+
return float(seg_px.sum() * stride)
|
|
70
|
+
|
|
71
|
+
def calculate_width_aware_lod(self, scene: SceneData, ctx: RenderContext) -> float:
|
|
72
|
+
"""
|
|
73
|
+
Calculates keep_prob based on 'Fill-Rate' budget rather than just primitive count.
|
|
74
|
+
Avoids performance degradation when using thick lines.
|
|
75
|
+
"""
|
|
76
|
+
target_coverage = self.options.lod_target_coverage
|
|
77
|
+
total_est_px2 = 0.0
|
|
78
|
+
target_px2 = target_coverage * ctx.fb_width * ctx.fb_height
|
|
79
|
+
|
|
80
|
+
overrides = self.options.visual.overrides
|
|
81
|
+
|
|
82
|
+
# 1. Line Families (Approx coverage: Count * ViewportWidth * Width)
|
|
83
|
+
if scene.lines.ab is not None:
|
|
84
|
+
width = self.options.global_line_width * overrides.line_width_multiplier
|
|
85
|
+
est = float(len(scene.lines.ab)) * ctx.fb_width * max(1.0, width)
|
|
86
|
+
total_est_px2 += est
|
|
87
|
+
|
|
88
|
+
# 2. Polylines (Approx coverage: Length * Width)
|
|
89
|
+
for layer in scene.layers:
|
|
90
|
+
from .core.layers import PolylineLayer
|
|
91
|
+
if isinstance(layer, PolylineLayer):
|
|
92
|
+
width = layer.style.line_width * overrides.line_width_multiplier
|
|
93
|
+
length = self.estimate_polyline_screen_length_px(layer.pts, ctx)
|
|
94
|
+
total_est_px2 += length * max(1.0, width)
|
|
95
|
+
|
|
96
|
+
if total_est_px2 <= target_px2:
|
|
97
|
+
return 1.0
|
|
98
|
+
|
|
99
|
+
return max(0.001, min(1.0, target_px2 / total_est_px2))
|
|
100
|
+
|
|
101
|
+
def should_force_density_mode(self, scene: SceneData, ctx: RenderContext, factor: float = 3.0) -> bool:
|
|
102
|
+
"""Determines if the scene is so complex that density mode should be forced during interaction."""
|
|
103
|
+
target_px2 = ctx.fb_width * ctx.fb_height * factor
|
|
104
|
+
|
|
105
|
+
# Reuse LOD calc logic but for a higher threshold
|
|
106
|
+
# (This is a simplified version for V1)
|
|
107
|
+
prob = self.calculate_width_aware_lod(scene, ctx, target_coverage=factor)
|
|
108
|
+
return prob < 1.0
|