Source code for sec_interp.gui.preview_renderer

from __future__ import annotations

"""Preview Renderer Module (PyQGIS Native).

Handles rendering of interactive previews using native QGIS resources.
This module has been refactored to delegate specialized tasks to modular components.
"""

import contextlib

from qgis.core import (
    QgsGeometry,
    QgsMapRendererCustomPainterJob,
    QgsMapSettings,
    QgsPointXY,
    QgsProject,
    QgsWkbTypes,
)
from qgis.gui import QgsMapCanvas, QgsRubberBand
from qgis.PyQt.QtCore import QRectF, QSize
from qgis.PyQt.QtGui import QColor, QImage, QPainter

from sec_interp.core.types import (
    GeologyData,
    InterpretationPolygon,
    ProfileData,
    StructureData,
)
from sec_interp.logger_config import get_logger

from .preview_axes_manager import PreviewAxesManager
from .preview_layer_factory import PreviewLayerFactory
from .preview_legend_renderer import PreviewLegendRenderer

logger = get_logger(__name__)


[docs] class PreviewRenderer: """Renders interactive preview using native PyQGIS resources. Acts as an orchestrator for several specialized modules: - PreviewLayerFactory: Handles layer creation and symbology. - PreviewAxesManager: Handles grid lines and axes labels. - PreviewOptimizer: Handles geometric simplification (LOD). - PreviewLegendRenderer: Handles legend drawing. """
[docs] def __init__(self, canvas: QgsMapCanvas | None = None): """Initialize preview renderer. Args: canvas: QgsMapCanvas instance (optional) """ self.canvas = canvas self.layers = [] self.interpretation_rubbers = [] # Specialized components self.layer_factory = PreviewLayerFactory() self.axes_manager = PreviewAxesManager() self.legend_renderer = PreviewLegendRenderer() # State for legend rendering (maintained for backward compatibility) self.has_topography = False self.has_structures = False
@property def active_units(self): """Expose active units from factory for legend compatibility.""" return self.layer_factory.active_units
[docs] def render( self, topo_data: ProfileData, geol_data: GeologyData | None = None, struct_data: StructureData | None = None, vert_exag: float = 1.0, dip_line_length: float | None = None, max_points: int = 1000, preserve_extent: bool = False, use_adaptive_sampling: bool = False, drillhole_data: list | None = None, interp_data: list[InterpretationPolygon] | None = None, show_legend: bool = True, **kwargs, ) -> tuple[QgsMapCanvas | None, list]: """Render preview with all data layers.""" logger.debug("render() called") # 1. Clean up previous layers self._cleanup_layers() self.has_topography = False self.has_structures = False # 2. Create data layers via LayerFactory topo_layer = self.layer_factory.create_topo_layer( topo_data, vert_exag, max_points, use_adaptive_sampling ) if topo_layer: self.has_topography = True topo_fill_layer = self.layer_factory.create_topo_fill_layer( topo_data, vert_exag, max_points ) geol_layer = self.layer_factory.create_geol_layer(geol_data, vert_exag, max_points) # ... rest of data layers ... # For structural layer, use topo or geol as reference reference_data = ( topo_data if topo_data else ([p for seg in geol_data for p in seg.points] if geol_data else None) ) struct_layer = self.layer_factory.create_struct_layer( struct_data, reference_data, vert_exag, dip_line_length ) if struct_layer: self.has_structures = True # Drillhole layers drillhole_layers = [] if drillhole_data: trace_layer = self.layer_factory.create_drillhole_trace_layer(drillhole_data, vert_exag) if trace_layer: drillhole_layers.append(trace_layer) interval_layer = self.layer_factory.create_drillhole_interval_layer( drillhole_data, vert_exag ) if interval_layer: drillhole_layers.append(interval_layer) # 2.5 Render interpretations (using rubber bands) if interp_data: self._render_interpretations(interp_data, vert_exag) # 3. Collect valid data layers # Order: Structures on top, then Geology, then Topography line, then Fill, then Drillholes data_layers = [ layer for layer in [ struct_layer, geol_layer, topo_layer, topo_fill_layer, *drillhole_layers, ] if layer is not None ] if not data_layers: logger.warning("No valid layers to render") return None, [] # 4. Axes and Labels extent = self._calculate_extent(data_layers) axes_layer = self.axes_manager.create_axes_layer(extent, vert_exag) labels_layer = self.axes_manager.create_axes_labels_layer(extent, vert_exag) # 5. Finalize layers list layers = [labels_layer, *data_layers, axes_layer] layers = [layer for layer in layers if layer is not None] self.layers = layers # 6. Configure canvas if self.canvas and extent: self.canvas.setLayers(layers) if not preserve_extent: # Add 10% padding to extent padded_extent = extent padded_extent.scale(1.1) self.canvas.setExtent(padded_extent) self.canvas.refresh() return self.canvas, layers
[docs] def draw_legend(self, painter: QPainter, rect: QRectF): """Draw legend on the given painter. Delegates to PreviewLegendRenderer.""" self.legend_renderer.draw_legend( painter, rect, self.active_units, self.has_topography, self.has_structures )
[docs] def export_to_image( self, layers: list, extent, width: int, height: int, output_path: str, dpi: int = 300, show_legend: bool = True, ) -> bool: """Export preview to image file. Maintains same logic but orchestrated.""" try: settings = QgsMapSettings() settings.setLayers(layers) settings.setExtent(extent) settings.setOutputSize(QSize(width, height)) settings.setOutputDpi(dpi) image = QImage(QSize(width, height), QImage.Format_ARGB32) image.fill(QColor(255, 255, 255)) painter = QPainter(image) painter.setRenderHint(QPainter.Antialiasing) job = QgsMapRendererCustomPainterJob(settings, painter) job.start() job.waitForFinished() # Delegate legend drawing if show_legend: self.draw_legend(painter, QRectF(0, 0, width, height)) painter.end() return image.save(output_path) except Exception: logger.exception("Error exporting preview") return False
def _cleanup_layers(self): """Remove previous layers from QgsProject.""" for layer in self.layers: if layer: with contextlib.suppress(Exception): QgsProject.instance().removeMapLayer(layer.id()) self.layers = [] self.layer_factory.active_units = {} # Clear interpretation rubber bands for rb in self.interpretation_rubbers: if rb: with contextlib.suppress(Exception): rb.hide() # Python garbage collection should clean up QgsRubberBand if canvas reference is lost # but explicit removal from canvas Scene is better self.canvas.scene().removeItem(rb) self.interpretation_rubbers = [] def _render_interpretations(self, interp_data: list[InterpretationPolygon], vert_exag: float): """Render interpretations as QgsRubberBand objects.""" if not self.canvas: return for interp in interp_data: if not interp.vertices_2d or len(interp.vertices_2d) < 3: continue # Create rubber band rb = QgsRubberBand(self.canvas, QgsWkbTypes.PolygonGeometry) # Set style try: poly_color = QColor(interp.color) if not poly_color.isValid(): poly_color = QColor("#FF0000") except (ValueError, TypeError): poly_color = QColor("#FF0000") poly_color.setAlpha(180) # More vibrant (approx 70%) rb.setColor(poly_color) rb.setWidth(2) # Slightly thicker border rb.setStrokeColor(poly_color.darker(160)) # More defined border # Add geometry # Points are (dist, elev) -> (x, y * exag) points = [QgsPointXY(x, y * vert_exag) for x, y in interp.vertices_2d] # Ensure closed for polygon if points[0] != points[-1]: points.append(points[0]) geom = QgsGeometry.fromPolygonXY([points]) rb.setToGeometry(geom, None) rb.show() self.interpretation_rubbers.append(rb) def _calculate_extent(self, layers: list): """Combine extents of all given layers.""" extent = None for layer in layers: layer_extent = layer.extent() if extent is None: extent = layer_extent else: extent.combineExtentWith(layer_extent) return extent