from __future__ import annotations
"""Layer factory for SecInterp preview.
Handles creation of temporary memory layers and configuration of native QGIS symbology.
"""
import math
from typing import Any, ClassVar
from qgis.core import (
QgsCategorizedSymbolRenderer,
QgsClassificationFixedInterval,
QgsFeature,
QgsFillSymbol,
QgsGeometry,
QgsGraduatedSymbolRenderer,
QgsLineSymbol,
QgsPalLayerSettings,
QgsPointXY,
QgsProject,
QgsRendererCategory,
QgsSingleSymbolRenderer,
QgsStyle,
QgsTextFormat,
QgsVectorLayer,
QgsVectorLayerSimpleLabeling,
)
from qgis.PyQt.QtGui import QColor
from sec_interp.core.types import GeologyData, ProfileData, StructureData
from sec_interp.core.utils.geometry_utils.optimization import PreviewOptimizer
from sec_interp.logger_config import get_logger
logger = get_logger(__name__)
[docs]
class PreviewLayerFactory:
"""Factory for creating and styling QGIS memory layers for the preview."""
# Color palette for geological units
GEOLOGY_COLORS: ClassVar[list[QColor]] = [
QColor(231, 76, 60), # Red
QColor(52, 152, 219), # Blue
QColor(46, 204, 113), # Green
QColor(155, 89, 182), # Purple
QColor(241, 196, 15), # Yellow
QColor(230, 126, 34), # Orange
QColor(26, 188, 156), # Turquoise
QColor(52, 73, 94), # Dark Blue/Grey
QColor(149, 165, 166), # Grey
QColor(211, 84, 0), # Pumpkin
QColor(192, 57, 43), # Dark Red
QColor(127, 140, 141), # Dark Grey
QColor(142, 68, 173), # Wisteria
QColor(41, 128, 185), # Belize Hole
QColor(39, 174, 96), # Nephritis
QColor(22, 160, 133), # Green Sea
]
[docs]
def __init__(self):
"""Initialize the layer factory."""
self.active_units: dict[str, QColor] = {}
[docs]
def get_color_for_unit(self, name: str) -> QColor:
"""Get a consistent color for a geological unit based on its name."""
if not name:
return QColor(100, 100, 100) # Default grey
if name in self.active_units:
return self.active_units[name]
# Simple hash to map name to index
hash_val = sum(ord(c) for c in str(name))
index = hash_val % len(self.GEOLOGY_COLORS)
color = self.GEOLOGY_COLORS[index]
self.active_units[name] = color
return color
[docs]
def create_memory_layer(
self,
geometry_type: str,
name: str,
fields: str | None = None,
) -> tuple[QgsVectorLayer | None, Any]:
"""Create a memory layer with an unknown CRS.
Args:
geometry_type: "Point", "LineString", "Polygon"
name: Layer display name
fields: Optional field definition string (e.g., "field=id:integer")
Returns:
Tuple of (QgsVectorLayer, QgsDataProvider) or (None, None) if failed
"""
uri = geometry_type
if fields:
uri += f"?{fields}"
layer = QgsVectorLayer(uri, name, "memory")
if not layer.isValid():
logger.error(f"Failed to create memory layer: {name}")
return None, None
# Ensure layer has a valid CRS (Project CRS) to allow rendering
# independent of On-The-Fly transformation settings
project_crs = QgsProject.instance().crs()
if project_crs.isValid():
layer.setCrs(project_crs)
return layer, layer.dataProvider()
[docs]
def create_topo_layer(
self,
topo_data: ProfileData,
vert_exag: float = 1.0,
max_points: int = 1000,
use_adaptive_sampling: bool = False,
) -> QgsVectorLayer | None:
"""Create temporary layer for topographic profile with polychromatic elevation styling."""
if not topo_data or len(topo_data) < 2:
return None
# Apply LOD decimation
if use_adaptive_sampling:
render_data = PreviewOptimizer.adaptive_sample(topo_data, max_points=max_points)
else:
render_data = PreviewOptimizer.decimate(topo_data, max_points=max_points)
# Create layer with elevation field for polychromy
layer, provider = self.create_memory_layer("LineString", "Topography", "field=elev:double")
if not layer:
return None
# Create segments for each pair of points to allow per-segment coloring
features = []
for i in range(len(render_data) - 1):
p1 = render_data[i]
p2 = render_data[i + 1]
line_points = [
QgsPointXY(p1[0], p1[1] * vert_exag),
QgsPointXY(p2[0], p2[1] * vert_exag),
]
line_geom = QgsGeometry.fromPolylineXY(line_points)
feat = QgsFeature(layer.fields())
feat.setGeometry(line_geom)
# Use average elevation for the segment color
avg_elev = (p1[1] + p2[1]) / 2.0
feat.setAttribute("elev", avg_elev)
features.append(feat)
if not features:
return None
provider.addFeatures(features)
self._style_topo_layer(layer)
layer.updateExtents()
return layer
def _style_topo_layer(self, layer: QgsVectorLayer):
"""Apply styles to the topography layer."""
renderer = QgsGraduatedSymbolRenderer("elev")
renderer.setSourceSymbol(QgsLineSymbol.createSimple({"width": "0.8", "capstyle": "round"}))
style = QgsStyle.defaultStyle()
# Try a terrain-like default ramp
color_ramp = style.colorRamp("Spectral") or style.colorRamp("RdYlGn")
if color_ramp:
renderer.updateColorRamp(color_ramp)
renderer.setClassificationMethod(QgsClassificationFixedInterval())
renderer.updateClasses(layer, 8)
layer.setRenderer(renderer)
[docs]
def create_topo_fill_layer(
self,
topo_data: ProfileData,
vert_exag: float = 1.0,
max_points: int = 1000,
base_elevation: float | None = None,
) -> QgsVectorLayer | None:
"""Create a solid 'curtain' fill layer under the topography for depth."""
if not topo_data or len(topo_data) < 2:
return None
render_data = PreviewOptimizer.decimate(topo_data, max_points=max_points)
layer, provider = self.create_memory_layer("Polygon", "Topography Fill")
if not layer:
return None
# Calculate base line (bottom of the section)
elevs = [p[1] for p in topo_data]
if base_elevation is None:
base_elevation = min(elevs) - (max(elevs) - min(elevs)) * 0.2
base_y = base_elevation * vert_exag
# Construct polygon points
poly_points = []
# Top edge (the profile)
for d, e in render_data:
poly_points.append(QgsPointXY(d, e * vert_exag))
# Bottom edge (closing the curtain)
poly_points.append(QgsPointXY(render_data[-1][0], base_y))
poly_points.append(QgsPointXY(render_data[0][0], base_y))
poly_points.append(QgsPointXY(render_data[0][0], render_data[0][1] * vert_exag))
geom = QgsGeometry.fromPolygonXY([poly_points])
feat = QgsFeature()
feat.setGeometry(geom)
provider.addFeatures([feat])
self._style_topo_fill_layer(layer)
layer.updateExtents()
return layer
def _style_topo_fill_layer(self, layer: QgsVectorLayer):
"""Apply styles to the topography fill layer."""
symbol = QgsFillSymbol.createSimple(
{
"color": "101,67,33,30", # Earthy brown, very transparent
"outline_color": "0,0,0,0",
"outline_width": "0",
}
)
layer.setRenderer(QgsSingleSymbolRenderer(symbol))
[docs]
def create_geol_layer(
self, geol_data: GeologyData, vert_exag: float = 1.0, max_points: int = 1000
) -> QgsVectorLayer | None:
"""Create temporary layer for geological profile."""
if not geol_data:
return None
layer, provider = self.create_memory_layer("LineString", "Geology", "field=unit:string")
if not layer:
return None
unique_units = {s.unit_name for s in geol_data}
features = []
for segment in geol_data:
if not segment.points or len(segment.points) < 2:
continue
render_points = PreviewOptimizer.decimate(segment.points, max_points=max_points)
line_points = [QgsPointXY(dist, elev * vert_exag) for dist, elev in render_points]
line_geom = QgsGeometry.fromPolylineXY(line_points)
feat = QgsFeature(layer.fields())
feat.setGeometry(line_geom)
feat.setAttribute("unit", segment.unit_name)
features.append(feat)
provider.addFeatures(features)
self._style_geol_layer(layer, unique_units)
layer.updateExtents()
return layer
def _style_geol_layer(self, layer: QgsVectorLayer, unique_units: set[str]):
"""Apply styles to the geology layer."""
categories = []
for unit_name in unique_units:
color = self.get_color_for_unit(unit_name)
symbol = QgsLineSymbol.createSimple(
{
"color": f"{color.red()},{color.green()},{color.blue()}",
"width": "0.7",
"capstyle": "round",
"joinstyle": "round",
}
)
categories.append(QgsRendererCategory(unit_name, symbol, unit_name))
layer.setRenderer(QgsCategorizedSymbolRenderer("unit", categories))
[docs]
def create_struct_layer(
self,
struct_data: StructureData,
reference_data: ProfileData,
vert_exag: float = 1.0,
dip_line_length: float | None = None,
) -> QgsVectorLayer | None:
"""Create temporary layer for structural dips."""
if not struct_data:
return None
layer, provider = self.create_memory_layer("LineString", "Structures")
if not layer:
return None
if dip_line_length is not None and dip_line_length > 0:
line_length = dip_line_length
else:
if reference_data:
elevs = [e for _, e in reference_data]
e_range = max(elevs) - min(elevs)
else:
e_range = 100
line_length = e_range * 0.1
features = []
for m in struct_data:
elev = m.elevation
dist = m.distance
app_dip = m.apparent_dip
rad_dip = math.radians(abs(app_dip))
dx = line_length * math.cos(rad_dip)
dy = line_length * math.sin(rad_dip)
if app_dip < 0:
dx = -dx
p1 = QgsPointXY(dist, elev * vert_exag)
p2 = QgsPointXY(dist + dx, (elev - dy) * vert_exag)
line_geom = QgsGeometry.fromPolylineXY([p1, p2])
feat = QgsFeature()
feat.setGeometry(line_geom)
features.append(feat)
provider.addFeatures(features)
self._style_struct_layer(layer)
layer.updateExtents()
return layer
def _style_struct_layer(self, layer: QgsVectorLayer):
"""Apply styles to the structure layer."""
symbol = QgsLineSymbol.createSimple(
{"color": "204,0,0", "width": "0.5", "capstyle": "round"}
)
layer.setRenderer(QgsSingleSymbolRenderer(symbol))
[docs]
def create_drillhole_trace_layer(
self, drillhole_data: list, vert_exag: float = 1.0
) -> QgsVectorLayer | None:
"""Create temporary layer for drillhole traces."""
logger.debug(
f"create_drillhole_trace_layer called with {len(drillhole_data) if drillhole_data else 0} holes"
)
if not drillhole_data:
logger.warning("No drillhole data provided for trace layer")
return None
layer, provider = self.create_memory_layer(
"LineString", "Drillhole Traces", "field=hole_id:string"
)
if not layer:
return None
features = []
for hole_data in drillhole_data:
# hole_data is (hole_id, trace_2d, trace_3d, trace_3d_proj, segments)
if len(hole_data) >= 5:
hole_id, trace_points = hole_data[0], hole_data[1]
else:
hole_id, trace_points = hole_data[0], hole_data[1]
if not trace_points or len(trace_points) < 2:
logger.debug(
f"Skipping hole {hole_id}: insufficient trace points ({len(trace_points) if trace_points else 0})"
)
continue
render_points = [QgsPointXY(x, y * vert_exag) for x, y in trace_points]
line_geom = QgsGeometry.fromPolylineXY(render_points)
feat = QgsFeature(layer.fields())
feat.setGeometry(line_geom)
feat.setAttribute("hole_id", hole_id)
features.append(feat)
logger.info(f"Adding {len(features)} drillhole trace features to layer")
provider.addFeatures(features)
self._style_drillhole_trace_layer(layer)
layer.updateExtents()
return layer
def _style_drillhole_trace_layer(self, layer: QgsVectorLayer):
"""Apply styles to the drillhole trace layer."""
symbol = QgsLineSymbol.createSimple(
{"color": "50,50,50", "width": "0.3", "capstyle": "round"}
)
layer.setRenderer(QgsSingleSymbolRenderer(symbol))
settings = QgsPalLayerSettings()
settings.fieldName = "hole_id"
settings.placement = QgsPalLayerSettings.Placement.Line
txt_format = QgsTextFormat()
txt_format.setColor(QColor(0, 0, 0))
txt_format.setSize(8)
settings.setFormat(txt_format)
layer.setLabeling(QgsVectorLayerSimpleLabeling(settings))
layer.setLabelsEnabled(True)
[docs]
def create_drillhole_interval_layer(
self, drillhole_data: list, vert_exag: float = 1.0
) -> QgsVectorLayer | None:
"""Create temporary layer for drillhole intervals."""
if not drillhole_data:
return None
all_segments = []
for hole_data in drillhole_data:
# segments are at index 2 (legacy) or 4 (v2.7.0)
segments = hole_data[4] if len(hole_data) >= 5 else hole_data[2]
if segments:
all_segments.extend(segments)
if not all_segments:
return None
layer, provider = self.create_memory_layer(
"LineString", "Drillhole Intervals", "field=unit:string"
)
if not layer:
return None
features = []
unique_units = set()
for segment in all_segments:
if not segment.points or len(segment.points) < 2:
continue
unique_units.add(segment.unit_name)
render_points = [QgsPointXY(x, y * vert_exag) for x, y in segment.points]
line_geom = QgsGeometry.fromPolylineXY(render_points)
feat = QgsFeature(layer.fields())
feat.setGeometry(line_geom)
feat.setAttribute("unit", segment.unit_name)
features.append(feat)
provider.addFeatures(features)
self._style_drillhole_interval_layer(layer, unique_units)
layer.updateExtents()
return layer
def _style_drillhole_interval_layer(self, layer: QgsVectorLayer, unique_units: set[str]):
"""Apply styles to the drillhole interval layer."""
categories = []
for unit_name in unique_units:
color = self.get_color_for_unit(unit_name)
symbol = QgsLineSymbol.createSimple(
{
"color": f"{color.red()},{color.green()},{color.blue()}",
"width": "2.0",
"capstyle": "flat",
"joinstyle": "bevel",
}
)
categories.append(QgsRendererCategory(unit_name, symbol, unit_name))
layer.setRenderer(QgsCategorizedSymbolRenderer("unit", categories))
[docs]
def interpolate_elevation(self, reference_data: ProfileData, target_dist: float) -> float:
"""Interpolate elevation at a given distance."""
if not reference_data:
return 0
for i in range(len(reference_data) - 1):
d1, e1 = reference_data[i]
d2, e2 = reference_data[i + 1]
if d1 <= target_dist <= d2:
if d2 == d1:
return e1
t = (target_dist - d1) / (d2 - d1)
return e1 + t * (e2 - e1)
if target_dist < reference_data[0][0]:
return reference_data[0][1]
return reference_data[-1][1]