Source code for sec_interp.core.services.preview_service

from __future__ import annotations

"""Preview service for SecInterp.

This module provides a service to orchestrate the generation of all
preview components, including topography, structures, and drillholes.
It remains decoupled from the GUI layer.
"""

import math
from typing import Any

from qgis.core import (
    QgsDistanceArea,
)

from sec_interp.core import utils as scu
from sec_interp.core.exceptions import GeometryError, ProcessingError
from sec_interp.core.performance_metrics import PerformanceTimer
from sec_interp.core.types import (
    PreviewParams,
    PreviewResult,
)
from sec_interp.logger_config import get_logger

logger = get_logger(__name__)


[docs] class PreviewService: """Orchestrates preview data generation."""
[docs] def __init__(self, controller: Any): """Initialize with plugin controller to access other services. Args: controller: The SecInterpController instance. """ self.controller = controller
[docs] @staticmethod def calculate_max_points( canvas_width: int, manual_max: int = 1000, auto_lod: bool = True, ratio: float = 1.0, ) -> int: """Calculate the optimal number of points for rendering. Args: canvas_width: Current width of the preview canvas in pixels. manual_max: User-specified maximum points for plotting. auto_lod: Whether to use automatic level of detail (LOD). ratio: Current zoom ratio (full_extent / current_extent). Returns: The optimal number of points to use for 2D rendering. """ if auto_lod: # Optimal points is roughly 2x the pixel width for high quality rendering base_points = max(200, int(canvas_width * 2)) # Apply zoom boost if ratio is significant if ratio > 1.1: # Slight detail boost as we zoom in detail_boost = 1.0 + (math.log10(ratio) * 0.5) return int(base_points * detail_boost) return base_points return manual_max
[docs] def generate_all( self, params: PreviewParams, transform_context: Any, skip_drillholes: bool = False, ) -> PreviewResult: """Generate all preview components in a consolidated result. Args: params: Validated parameters for preview generation. transform_context: QgsCoordinateTransformContext for CRS operations. skip_drillholes: If True, skips synchronous drillhole generation. Returns: A consolidated object containing all generated preview data. """ # Phase 5: Native validation params.validate() result = PreviewResult(buffer_dist=params.buffer_dist) self.transform_context = transform_context # 1. Topography with PerformanceTimer("Topography Generation", result.metrics): # Calculate LOD interval interval = None if params.auto_lod: # Get line length line_feat = next(params.line_layer.getFeatures(), None) if not line_feat: raise GeometryError( "Section line layer has no features", {"layer": params.line_layer.name()}, ) line_len = line_feat.geometry().length() max_pts = self.calculate_max_points(params.canvas_width, params.max_points, True) interval = line_len / max_pts if max_pts > 0 else None result.topo = self.controller.profile_service.generate_topographic_profile( params.line_layer, params.raster_layer, params.band_num, interval=interval, ) if result.topo: result.metrics.record_count("Topography Points", len(result.topo)) # 2. Structures if params.struct_layer and params.dip_field and params.strike_field: with PerformanceTimer("Structure Generation", result.metrics): line_feat = next(params.line_layer.getFeatures(), None) if line_feat: line_geom = line_feat.geometry() line_azimuth = scu.calculate_line_azimuth(line_geom) result.struct = self.controller.structure_service.project_structures( line_lyr=params.line_layer, raster_lyr=params.raster_layer, struct_lyr=params.struct_layer, buffer_m=params.buffer_dist, line_az=line_azimuth, dip_field=params.dip_field, strike_field=params.strike_field, band_number=params.band_num, ) if result.struct: result.metrics.record_count("Structure Points", len(result.struct)) # 3. Drillholes if params.collar_layer and not skip_drillholes: with PerformanceTimer("Drillhole Generation", result.metrics): result.drillhole = self._generate_drillholes(params) if result.drillhole: result.metrics.record_count("Drillholes", len(result.drillhole)) return result
def _generate_drillholes(self, params: PreviewParams) -> Any | None: """Generate drillhole trace and interval data. Args: params: Preview parameters containing drillhole layer and fields. Returns: A list of drillhole data tuples, or None if no collars found or skipped. """ line_feat = next(params.line_layer.getFeatures(), None) if not line_feat: raise GeometryError( "Section line layer has no features", {"layer": params.line_layer.name()}, ) # Validation: Ensure critical drillhole fields are selected if not params.collar_id_field: logger.info("Drillhole preview skipped: No Collar ID field selected.") return None line_geom = line_feat.geometry() if line_geom.isMultipart(): lines = line_geom.asMultiPolyline() points = lines[0] if lines else [] else: points = line_geom.asPolyline() if not points: raise GeometryError("Section line has no vertices", {"layer": params.line_layer.name()}) line_start = points[0] # Setup distance area distance_area = QgsDistanceArea() distance_area.setSourceCrs(params.line_layer.crs(), self.transform_context) try: projected_collars = self.controller.drillhole_service.project_collars( collar_layer=params.collar_layer, line_geom=line_geom, line_start=line_start, distance_area=distance_area, buffer_width=params.buffer_dist, collar_id_field=params.collar_id_field, use_geometry=params.collar_use_geometry, collar_x_field=params.collar_x_field, collar_y_field=params.collar_y_field, collar_z_field=params.collar_z_field, collar_depth_field=params.collar_depth_field, dem_layer=params.raster_layer, line_crs=params.line_layer.crs(), ) except Exception as e: raise ProcessingError( "Failed to project drillhole collars", {"hole_id_field": params.collar_id_field}, ) from e if not projected_collars: return None survey_fields = { "id": params.survey_id_field, "depth": params.survey_depth_field, "azim": params.survey_azim_field, "incl": params.survey_incl_field, } interval_fields = { "id": params.interval_id_field, "from": params.interval_from_field, "to": params.interval_to_field, "lith": params.interval_lith_field, } try: _, drillhole_data = self.controller.drillhole_service.process_intervals( collar_points=projected_collars, collar_layer=params.collar_layer, survey_layer=params.survey_layer, interval_layer=params.interval_layer, collar_id_field=params.collar_id_field, use_geometry=params.collar_use_geometry, collar_x_field=params.collar_x_field, collar_y_field=params.collar_y_field, line_geom=line_geom, line_start=line_start, distance_area=distance_area, buffer_width=params.buffer_dist, section_azimuth=scu.calculate_line_azimuth(line_geom), survey_fields=survey_fields, interval_fields=interval_fields, ) except Exception as e: logger.exception(f"Failed to process drillhole intervals: {type(e).__name__}: {e}") import traceback logger.exception(traceback.format_exc()) raise ProcessingError("Failed to process drillhole intervals") from e logger.info(f"Generated {len(drillhole_data) if drillhole_data else 0} drillhole traces") return drillhole_data