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,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