"""
/***************************************************************************
 qgSurf - plugin for Quantum GIS

 geologic stereoplots
-------------------

    Begin                : 2015.04.18
    Copyright            : (C) 2015-2026 by Mauro Alberti
    Email                : alberti dot m65 at gmail dot com

 ***************************************************************************/

# licensed under the terms of GNU GPL 3

/***************************************************************************
 *                                                                         *
 *   This program is free software; you can redistribute it and/or modify  *
 *   it under the terms of the GNU General Public License as published by  *
 *   the Free Software Foundation; either version 3 of the License, or     *
 *   (at your option) any later version.                                   *
 *                                                                         *
 ***************************************************************************/
"""

import math
import numbers
from typing import Tuple, List, Dict

from builtins import map

import numpy as np
from matplotlib.figure import Figure

from qgis.core import Qgis, QgsMessageLog

from ..external.apsg import StereoNet, Lin as aLin, Fol as aFol, Fault as aFault

from geogst.external_wrappers.apsg.faults import *
from geogst.core.geology.utils.faults import *
from geogst.core.geology.utils.parsing import *
from geogst.core.utils.inspections.errors import *
from geogst.core.geology.orientations import *
from geogst.plots.parameters import *

from ..apps.plots.export import *
from ..apps.qgis.canvas import *
from ..apps.qgis.messages import *
from ..apps.qgis.points import *
from ..apps.qt.colors import *

from ..ui.auxiliary_windows.stereonet import *

from ..configurations.general_params import *
from ..utils.qt_helpers import is_qt_object_deleted


class InputLayerDataCharacteristics:

    def __init__(
        self,
        has_plane_data: bool,
        pln_az_type: str,
        pln_dip_type: str,
        has_line_trpl_data: bool,
        ln_az_type: str,
        ln_dip_type: str,
        has_ln_ms_data: bool,
        ln_ms_type: str,
        has_rake_data: bool,
        ln_rake_type: str
    ):

        self.has_plane_data = has_plane_data
        self.pln_az_type = pln_az_type
        self.pln_dip_type = pln_dip_type
        self.has_line_trpl_data = has_line_trpl_data
        self.ln_az_type = ln_az_type
        self.ln_dip_type = ln_dip_type
        self.has_ln_ms_data = has_ln_ms_data
        self.ln_ms_type = ln_ms_type
        self.has_rake_data = has_rake_data
        self.ln_rake_type = ln_rake_type


class SourceLayerParams:

    def __init__(
        self,
        layer_src_data=False,
        src_layer=None,
        attitude_fld_names=None,
        input_data_types=None,
        depth_fld_name=None,
        layer_is_3d=False,
    ):

        self.layer_src_data = layer_src_data
        self.src_layer = src_layer
        self.attitude_fld_names = [] if attitude_fld_names is None else attitude_fld_names
        self.input_data_types = [] if input_data_types is None else input_data_types
        self.depth_fld_name = depth_fld_name
        self.layer_is_3d = layer_is_3d


class SourceTextParams:

    def __init__(
        self,
        text_src_data=False,
        geostructural_data=None
    ):

        self.text_src_data = text_src_data
        self.geostructural_data = [] if geostructural_data is None else geostructural_data


def parse_plane_dirdir(
    azimuth_type: str,
    az_raw: numbers.Real
) -> numbers.Real:

    if azimuth_type == "strike rhr":
        dip_dir = az_raw + 90.0
    else:
        dip_dir = az_raw

    return dip_dir % 360.0


def parse_fault_mov_sense(
    raw_values: List
) -> Tuple[numbers.Real, numbers.Real, numbers.Real, numbers.Real, str]:

    azim, dip_ang, lin_trend, lin_plunge = list(map(float, raw_values[:4]))
    mov_sense = str(raw_values[4])

    return azim, dip_ang, lin_trend, lin_plunge, mov_sense


def extract_record_values_from_text_row(
    data_type: str,
    azimuth_type: str,
    row: str,
    sep: str,
) -> Tuple[Union[None, dict], Error]:
    """
    Extract record values from text row.
    """

    try:

        record_dict = dict()

        raw_values = row.split(sep)

        if data_type == "planes":

            azim, dip_ang = list(map(float, raw_values))

            record_dict["pln_dipdir"] = parse_plane_dirdir(azimuth_type, azim)
            record_dict["pln_dipang"] = dip_ang

        elif data_type == "axes":

            trend, plunge = list(map(float, raw_values))

            record_dict["ln_tr"] = trend
            record_dict["ln_pl"] = plunge

        elif data_type == "planes & axes":

            azim, dip_ang, trend, plunge = list(map(float, raw_values))

            record_dict["pln_dipdir"] = parse_plane_dirdir(azimuth_type, azim)
            record_dict["pln_dipang"] = dip_ang
            record_dict["ln_tr"] = trend
            record_dict["ln_pl"] = plunge

        elif data_type == "fault planes with slickenline trend, plunge and movement sense":

            azim, dip_ang, slick_tr, slick_pl, mov_sense = parse_fault_mov_sense(raw_values)

            record_dict["pln_dipdir"] = parse_plane_dirdir(azimuth_type, azim)
            record_dict["pln_dipang"] = dip_ang
            record_dict["ln_tr"] = slick_tr
            record_dict["ln_pl"] = slick_pl
            record_dict["ln_ms"] = mov_sense

        elif data_type == "fault planes with rake":

            azim, dip_ang, rake = list(map(float, raw_values))

            record_dict["pln_dipdir"] = parse_plane_dirdir(azimuth_type, azim)
            record_dict["pln_dipang"] = dip_ang
            record_dict["ln_rk"] = rake

        else:

            return None, Error(
                    True,
                    caller_name(),
                    Exception(f"Unimplemented input data type: {data_type}")
                )

        return record_dict, Error()

    except Exception as e:

        return None, Error(
                True,
                caller_name(),
                e,
                traceback.format_exc()
            )


def extract_orientations_from_text(
        data_type,
        azimuth_type,
        text,
        sep=','
) -> Tuple[Union[None, List[Dict]], Error]:
    """
    Extracts orientations from a òist of text rows.
    """

    try:

        geostructural_data = []

        if text is None or text == '':
            return None, Error(
                True,
                caller_name(),
                Exception("No value available")
            )

        rows = text.split("\n")

        if len(rows) == 0:

            return None, Error(
                True,
                caller_name(),
                Exception("No value available")
            )

        for row_ndx, row in enumerate(rows):

            if row:

                data_dict, err = extract_record_values_from_text_row(
                    data_type,
                    azimuth_type,
                    row,
                    sep,
                )

                if err:

                    return None, err

                if not data_dict:

                    return None, Error(
                        True,
                        caller_name(),
                        Exception("Error with input")
                    )

                geostructural_data.append(data_dict)

        return geostructural_data, Error()

    except Exception as e:

        return None, Error(
                True,
                caller_name(),
                e,
                traceback.format_exc()
            )


def parse_field_choice(val, choose_message):

    if val == choose_message:
        return ''
    else:
        return val


def extract_input_layer_params_from_dialog(
    dialog,
) -> Tuple[Union[None, dict], Error]:

    try:

        dInputLayerParams = dict()

        dInputLayerParams["pln_azimuth_type"] = dialog.cmbInputLyrPlaneOrAzimType.currentText()
        dInputLayerParams["pln_azimuth_name_field"] = parse_field_choice(
            dialog.cmbInputPlaneAzimSrcFld.currentText(),
            UNDEFINED_TEXT)

        dInputLayerParams["pln_dip_type"] = dialog.cmbInputPlaneOrientDipType.currentText()
        dInputLayerParams["pln_dip_name_field"] = parse_field_choice(
            dialog.cmbInputPlaneDipSrcFld.currentText(),
            UNDEFINED_TEXT)

        dInputLayerParams["ln_azimuth_type"] = dialog.cmbInputAxisAzimType.currentText()
        dInputLayerParams["ln_azimuth_name_field"] = parse_field_choice(
            dialog.cmbInputAxisAzimSrcFld.currentText(),
            UNDEFINED_TEXT)

        dInputLayerParams["ln_dip_type"] = dialog.cmbInputAxisDipType.currentText()
        dInputLayerParams["ln_dip_name_field"] = parse_field_choice(
            dialog.cmbInputAxisDipSrcFld.currentText(),
            UNDEFINED_TEXT)

        dInputLayerParams["ln_movsen_type"] = dialog.cmbInputAxisMovSenseType.currentText()
        dInputLayerParams["ln_movsen_name_field"] = parse_field_choice(
            dialog.cmbInputAxisMovSenseSrcFld.currentText(),
            UNDEFINED_TEXT)

        dInputLayerParams["ln_rake_type"] = dialog.cmbInputAxisRakeType.currentText()
        dInputLayerParams["ln_rake_name_field"] = parse_field_choice(
            dialog.cmbInputAxisRakeSrcFld.currentText(),
            UNDEFINED_TEXT)

        dInputLayerParams["depth_name_field"] = parse_field_choice(
            dialog.cmbInputDepthFld.currentText(),
            UNDEFINED_TEXT)

        return dInputLayerParams, Error()

    except Exception as e:

        return None, Error(
                True,
                caller_name(),
                e,
                traceback.format_exc()
            )


def valid_input_layer_params(
    dInputLayerParams: dict,
) -> bool:

    if dInputLayerParams["pln_azimuth_name_field"] is not None and \
                    dInputLayerParams["pln_dip_name_field"] is not None:
        return True
    elif dInputLayerParams["ln_azimuth_name_field"] is not None and \
                    dInputLayerParams["ln_dip_name_field"] is not None:
        return True
    else:
        return False


def input_layer_data_characteristics(
    input_layer_params: dict,
) -> Tuple[Union[None, InputLayerDataCharacteristics], Error]:

    try:

        # define type for planar data

        bIsPlaneDefined = True if input_layer_params["pln_azimuth_name_field"] and input_layer_params[
            "pln_dip_name_field"] else False

        if bIsPlaneDefined:
            tPlaneAzimType = "dip_dir" if input_layer_params["pln_azimuth_type"] == "dip direction" else "strike_rhr"
            tPlaneDipType = "dip"
        else:
            tPlaneAzimType = None
            tPlaneDipType = None

        # define type for linear data

        bIsLnTrndPlngDefined = True if input_layer_params["ln_azimuth_name_field"] and input_layer_params[
            "ln_dip_name_field"] else False
        bIsLnMovSnsDefined = True if bIsLnTrndPlngDefined and input_layer_params["ln_movsen_name_field"] else False
        bIsLnRkDefined = True if bIsPlaneDefined and input_layer_params["ln_rake_name_field"] else False

        if bIsLnTrndPlngDefined:
            tLineAzimType = "trend"
            tLineDipType = "plunge"
        else:
            tLineAzimType = None
            tLineDipType = None

        if bIsLnMovSnsDefined:
            tLineMovSenseType = "movement_sense"
        else:
            tLineMovSenseType = None

        if bIsLnRkDefined:
            tLineRakeType = "rake"
        else:
            tLineRakeType = None

        return InputLayerDataCharacteristics(
            has_plane_data=bIsPlaneDefined,
            pln_az_type=tPlaneAzimType,
            pln_dip_type=tPlaneDipType,
            has_line_trpl_data=bIsLnTrndPlngDefined,
            ln_az_type=tLineAzimType,
            ln_dip_type=tLineDipType,
            has_ln_ms_data=bIsLnMovSnsDefined,
            ln_ms_type=tLineMovSenseType,
            has_rake_data=bIsLnRkDefined,
            ln_rake_type=tLineRakeType
        ), Error()

    except Exception as e:

        return None, Error(
                True,
                caller_name(),
                e,
                traceback.format_exc()
            )


def extract_user_defined_stereoplot_style(
        dialog
) -> Tuple[Union[None, StereonetStyleParams], Error]:

    try:

        line_color = dialog.btnLineColor.color().name()
        line_style = dialog.cmbLineStyle.currentText()
        line_width = dialog.cmbLineWidth.currentText()
        line_transp = dialog.cmbLineTransp.currentText()

        marker_color = dialog.btnPointColor.color().name()
        marker_style = dialog.cmbPointStyle.currentText()
        marker_size = dialog.cmbPointSize.currentText()
        marker_transp = dialog.cmbPointTransp.currentText()

        p_axis_color = dialog.btnPAxisColor.color().name()
        t_axis_color = dialog.btnTAxisColor.color().name()
        pt_axis_style = dialog.cmbPTAxisStyle.currentText()
        pt_axis_size = dialog.cmbPTAxisSize.currentText()
        pt_axis_transp = dialog.cmbPTAxisTransp.currentText()

        tl_color = dialog.btnTLColor.color().name()
        tl_marker_style = dialog.cmbTLMarkerStyle.currentText()
        tl_marker_size = dialog.cmbTLMarkerSize.currentText()
        tl_marker_transp = dialog.cmbTLTransp.currentText()

        return StereonetStyleParams(
            line_color,
            line_style,
            line_width,
            line_transp,
            marker_color,
            marker_style,
            marker_size,
            marker_transp,
            p_axis_color,
            t_axis_color,
            pt_axis_style,
            pt_axis_size,
            pt_axis_transp,
            tl_color,
            tl_marker_style,
            tl_marker_size,
            tl_marker_transp,
        ), Error()

    except Exception as e:

        return None, Error(
                True,
                caller_name(),
                e,
                traceback.format_exc()
            )


def parse_layer_data(
    input_data_types: InputLayerDataCharacteristics,
    structural_data
) -> Tuple[List[dict], int]:
    # create list of text holders for data to extract

    data_types_to_extract = [("x", float), ("y", float)]

    if input_data_types.has_plane_data:
        data_types_to_extract += [
            (
                "pln_dipdir",
                dipdir_str_to_float if input_data_types.pln_az_type == "dip_dir" else strikerhr_str_to_dipdir_float
            ),
            (
                "pln_dipang",
                float
            )
        ]

    if input_data_types.has_line_trpl_data:
        data_types_to_extract += [("ln_tr", float), ("ln_pl", float)]

    if input_data_types.has_ln_ms_data:
        data_types_to_extract.append(("ln_ms", str))

    if input_data_types.has_rake_data:
        data_types_to_extract.append(("ln_rk", float))

    # extract and parse raw data

    source_structural_values = []
    skipped = 0

    for rec in structural_data:

        try:

            dRecord = dict()

            for ndx, (key, func) in enumerate(data_types_to_extract):
                dRecord[key] = func(rec[ndx])

            source_structural_values.append(dRecord)

        except Exception as err:

            skipped += 1
            QgsMessageLog.logMessage(
                f"Skipping layer record {rec}: {err}",
                "qgSurf",
                Qgis.MessageLevel.Warning
            )

    return source_structural_values, skipped


def parse_marker_size(
    marker_size: str
) -> numbers.Real:

    return float(marker_size.split()[0])


def parse_transparency(
    transparency: str
) -> numbers.Real:

    return 1.0 - (float(transparency[:-1]) / 100.0)  # removes final '%' from input value


def filter_plane_data(
    struct_vals: List[dict]
) -> List[Tuple[numbers.Real, numbers.Real]]:

    plane_data = []

    for row in struct_vals:

        if not ("pln_dipdir" in row and "pln_dipang" in row):

            continue

        else:

            plane_data.append((row["pln_dipdir"], row["pln_dipang"]))

    return plane_data


def filter_line_data(
    struct_vals: List[dict]
) -> Tuple[List[Tuple[numbers.Real, numbers.Real]], int]:

    line_data = []
    skipped = 0

    for row in struct_vals:

        if not (("ln_tr" in row and "ln_pl" in row) or
                ("pln_dipdir" in row and "pln_dipang" in row and "ln_rk" in row)):

            continue

        else:

            if "pln_dipdir" in row and "pln_dipang" in row and "ln_rk" in row:

                value, err = downaxis_from_rake(row["pln_dipdir"], row["pln_dipang"], row["ln_rk"])

                if err:
                    skipped += 1
                    QgsMessageLog.logMessage(
                        f"Skipping line row (dip_dir={row['pln_dipdir']}, dip_ang={row['pln_dipang']}, rake={row['ln_rk']}): {err}",
                        "qgSurf",
                        Qgis.MessageLevel.Warning
                    )
                    continue

                lin_tr, lin_pl = value

            elif "ln_tr" in row and "ln_pl" in row:

                lin_tr, lin_pl = row["ln_tr"], row["ln_pl"]

            else:

                continue

            line_data.append((lin_tr, lin_pl))

    return line_data, skipped


def filter_fault_slikenline_data(
    struct_vals: List[dict]
) -> Tuple[List[Tuple[numbers.Real, numbers.Real, numbers.Real, numbers.Real, numbers.Integral]], int]:

    fault_slickenline_data = []
    skipped = 0

    for row in struct_vals:

        if "pln_dipdir" in row and "pln_dipang" in row:

            dip_dir = row["pln_dipdir"]
            dip_ang = row["pln_dipang"]

            if "ln_rk" in row:

                rake = row["ln_rk"]

                # rake must be in the -180 / +180 interval

                if rake < -180:
                    rake = -180
                elif rake > 180:
                    rake = 180

                # apsg do not handle rake = 0° or +/- 180°

                if rake == 0.0:
                    rake = 0.1
                elif rake == 180:
                    rake = 179.9
                elif rake == -180:
                    rake = -179.9

                value, err = downaxis_from_rake(dip_dir, dip_ang, rake)

                if err:
                    skipped += 1
                    QgsMessageLog.logMessage(
                        f"Skipping fault-slickenline row (dip_dir={dip_dir}, dip_ang={dip_ang}, rake={rake}): {err}",
                        "qgSurf",
                        Qgis.MessageLevel.Warning
                    )
                    continue

                lin_tr, lin_pl = value

                sense = rake_to_apsg_movsense(rake)

            elif "ln_tr" in row and "ln_pl" in row and "ln_ms" in row:

                lin_tr, lin_pl = row["ln_tr"], row["ln_pl"]

                mov_sense = row["ln_ms"].upper()

                if mov_sense == "":

                    continue

                else:

                    try:
                        sense = movsense_to_apsg_movsense(mov_sense)
                    except RakeInputException as err:
                        skipped += 1
                        QgsMessageLog.logMessage(
                            f"Skipping fault-slickenline row (dip_dir={dip_dir}, dip_ang={dip_ang}, movement_sense={mov_sense}): {err}",
                            "qgSurf",
                            Qgis.MessageLevel.Warning
                        )
                        continue

            else:

                continue

            fault_slickenline_data.append((dip_dir, dip_ang, lin_tr, lin_pl, sense))

        else:

            continue

    return fault_slickenline_data, skipped


def _nice_round_bounds(vmin: float, vmax: float) -> Tuple[float, float]:
    """Round (vmin, vmax) outward to a 'nice' step derived from their span."""

    if vmin == vmax:
        return vmin - 1.0, vmax + 1.0

    span = vmax - vmin
    magnitude = 10 ** math.floor(math.log10(span))
    step = magnitude

    if span / step <= 2:
        step /= 5
    elif span / step <= 5:
        step /= 2

    lo = math.floor(vmin / step) * step
    hi = math.ceil(vmax / step) * step

    return lo, hi


class RectangleLinkFloatingWindow(QWidget):
    """Floating, always-on-top window showing the stereoplot (and, optionally,
    a vertical depth histogram) for the data currently within the canvas-linked
    rectangle. Kept separate from StereoplotWidget's own stereonet/depth panel,
    which stay fixed on the regular (non-rectangle-filtered) dataset."""

    window_closed = pyqtSignal()

    DEPTH_BIN_COUNT = 20

    def __init__(self, module_name):

        super(RectangleLinkFloatingWindow, self).__init__(
            None,
            Qt.WindowType.Tool | Qt.WindowType.WindowStaysOnTopHint
        )
        self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, False)
        self.setWindowTitle('{} - {}'.format(plugin_name, module_name))

        self.stereonet = StereoNet()

        self._depth_bin_edges = None
        self._depth_total_pct = None
        self._depth_range = None

        self.depth_fig = None
        self.depth_ax = None
        self._depth_canvas = None
        self._depth_panel_visible = False
        self._depth_is_z = False

        self._layout = QHBoxLayout()
        self._layout.addWidget(self.stereonet.fig.canvas, 3)
        self.setLayout(self._layout)

        self.resize(700, 500)

    def clear_stereoplot(self):

        self.stereonet.cla()
        self.stereonet.fig.canvas.draw_idle()

    def set_depth_visible(self, visible, total_depth_values=None, is_z=False):

        self._depth_is_z = is_z

        if visible and not self._depth_panel_visible:

            FigureCanvasClass = type(self.stereonet.fig.canvas)

            self.depth_fig = Figure(constrained_layout=True)
            self.depth_ax = self.depth_fig.add_subplot(111)
            self._depth_canvas = FigureCanvasClass(self.depth_fig)

            self._layout.addWidget(self._depth_canvas, 2)
            self._depth_panel_visible = True

        elif not visible and self._depth_panel_visible:

            self._layout.removeWidget(self._depth_canvas)
            self._depth_canvas.setParent(None)

            self._depth_canvas = None
            self.depth_fig = None
            self.depth_ax = None
            self._depth_panel_visible = False

        if visible:
            self._set_total_depth_data(total_depth_values or [])

    def _set_total_depth_data(self, total_depth_values):

        values = np.asarray(total_depth_values, dtype=float)

        if values.size == 0:
            self._depth_bin_edges = None
            self._depth_total_pct = None
            self._depth_range = None
            return

        lo, hi = _nice_round_bounds(float(values.min()), float(values.max()))

        self._depth_range = (lo, hi)
        self._depth_bin_edges = np.linspace(lo, hi, self.DEPTH_BIN_COUNT + 1)

        total_counts, _ = np.histogram(values, bins=self._depth_bin_edges)
        total = total_counts.sum()
        self._depth_total_pct = (total_counts / total * 100) if total else total_counts.astype(float)

    def refresh_depth(self, local_depth_values):

        if not self._depth_panel_visible or self._depth_bin_edges is None:
            return

        edges = self._depth_bin_edges
        centers = (edges[:-1] + edges[1:]) / 2
        bin_height = edges[1] - edges[0]

        local_values = np.asarray(local_depth_values, dtype=float)

        if local_values.size:
            local_counts, _ = np.histogram(local_values, bins=edges)
            local_total = local_counts.sum()
            local_pct = (local_counts / local_total * 100) if local_total else local_counts.astype(float)
        else:
            local_pct = np.zeros(len(centers))

        self.depth_ax.clear()

        self.depth_ax.barh(centers, local_pct, height=bin_height, color="tab:blue", alpha=0.7, label="Local")
        self.depth_ax.plot(self._depth_total_pct, centers, linestyle="--", color="0.3", label="Total")

        lo, hi = self._depth_range

        if self._depth_is_z:
            # Z coordinate: natural axis sense, most negative (deepest) at the bottom
            self.depth_ax.set_ylim(lo, hi)
            self.depth_ax.set_ylabel("Z")
        else:
            # explicit depth field: positive-down convention, larger depth at the bottom
            self.depth_ax.set_ylim(hi, lo)
            self.depth_ax.set_ylabel("Depth")

        self.depth_ax.set_xlim(0, 100)  # percentage axis: fixed, constant width
        self.depth_ax.set_xlabel("%")
        self.depth_ax.legend(loc="best", fontsize="small")

        self.depth_fig.canvas.draw_idle()

    def closeEvent(self, event):

        self.window_closed.emit()
        event.accept()


class StereoplotWidget(QWidget):

    window_closed = pyqtSignal()

    def __init__(self,
                 module_name,
                 canvas,
                 settings_name,
                 plugin_folder):

        super(StereoplotWidget, self).__init__()
        self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, False)
        self.canvas = canvas
        self.module_name = module_name
        self.settings_name = settings_name
        self.plugin_folder = plugin_folder

        # settings stored for plugin

        settings = QSettings(self.settings_name, plugin_name)

        # stored setting values for plot style

        line_color = settings.value("StereoplotWidget/line_color", None)
        line_style = settings.value("StereoplotWidget/line_style", None)
        line_width = settings.value("StereoplotWidget/line_width", None)
        line_transp = settings.value("StereoplotWidget/line_transp", None)
        marker_color = settings.value("StereoplotWidget/marker_color", None)
        marker_style = settings.value("StereoplotWidget/marker_style", None)
        marker_size = settings.value("StereoplotWidget/marker_size", None)
        marker_transp = settings.value("StereoplotWidget/point_transp", None)

        p_axis_color = settings.value("StereoplotWidget/p_axis_color", None)
        t_axis_color = settings.value("StereoplotWidget/t_axis_color", None)
        pt_axis_style = settings.value("StereoplotWidget/pt_axis_style", None)
        pt_axis_size = settings.value("StereoplotWidget/pt_axis_size", None)
        pt_axis_transp = settings.value("StereoplotWidget/pt_axis_transp", None)

        tl_color = settings.value("StereoplotWidget/tl_color", None)
        tl_marker_style = settings.value("StereoplotWidget/tl_marker_style", None)
        tl_marker_size = settings.value("StereoplotWidget/tl_marker_size", None)
        tl_marker_transp = settings.value("StereoplotWidget/tl_marker_transp", None)

        self.stereonet_params = StereonetStyleParams(
            line_color,
            line_style,
            line_width,
            line_transp,
            marker_color,
            marker_style,
            marker_size,
            marker_transp,
            p_axis_color,
            t_axis_color,
            pt_axis_style,
            pt_axis_size,
            pt_axis_transp,
            tl_color,
            tl_marker_style,
            tl_marker_size,
            tl_marker_transp,
        )

        # stored setting values for figure export

        self.dExportParams = dict()
        self.dExportParams["expfig_width_inch"] = settings.value("StereoplotWidget/expfig_width_inch", "10")
        self.dExportParams["expfig_res_dpi"] = settings.value("StereoplotWidget/expfig_res_dpi", "200")
        self.dExportParams["expfig_font_size_pts"] = settings.value("StereoplotWidget/expfig_font_size_pts", "12")

        # reset all data definitions

        self._selection_connected_layer = None

        self.reset_layer_src_data()
        self.reset_text_src_data()

        # rectangle-link state (canvas-linked live filtering)

        self.last_plot_setts = None
        self.rect_maptool = None
        self._previous_maptool = None
        self._pending_rect = None
        self._replot_timer = QTimer(self)
        self._replot_timer.setSingleShot(True)
        self._replot_timer.timeout.connect(self._replot_within_last_rect)
        self.rect_floating_window = None

        # create gui window

        self.setup_gui()


    def reset_layer_src_data(self):

        self._disconnect_layer_selection()
        self.source_layer_params = SourceLayerParams()
        self._update_depth_histogram_availability()

    def _live_source_layer(self):
        """
        The input layer, if the QGIS object behind it is still alive.

        A layer belongs to the project holding it: loading another project, or
        removing the layer from this one, destroys the C++ object while the Python
        reference kept here survives it. Every later use of that reference raises
        "wrapped C/C++ object of type QgsVectorLayer has been deleted", which is
        what plotting did after a change of project.

        The stale definition is dropped as soon as it is found, so that the tool
        asks for the input to be defined again instead of failing on a layer that
        is no longer there.

        :return: the layer, or None if there is none or it has been destroyed.
        """

        layer = self.source_layer_params.src_layer

        if layer is None:
            return None

        if is_qt_object_deleted(layer):
            self.reset_layer_src_data()
            return None

        return layer

    def _connect_layer_selection(self, layer):

        self._disconnect_layer_selection()

        if layer is not None:
            layer.selectionChanged.connect(self._on_layer_selection_changed)
            self._selection_connected_layer = layer

    def _disconnect_layer_selection(self):

        if self._selection_connected_layer is not None:

            try:
                self._selection_connected_layer.selectionChanged.disconnect(self._on_layer_selection_changed)
            except (RuntimeError, TypeError):
                pass

            self._selection_connected_layer = None

    def _on_layer_selection_changed(self, *args):

        self._refresh_regular_plot()

    def _update_depth_histogram_availability(self):

        if not hasattr(self, 'chkDepthHistogram'):
            return

        available = self.source_layer_params.layer_src_data and (
            self.source_layer_params.layer_is_3d or self.source_layer_params.depth_fld_name is not None
        )

        self.chkDepthHistogram.setEnabled(available)

        if not available and self.chkDepthHistogram.isChecked():
            self.chkDepthHistogram.setChecked(False)

    def reset_text_src_data(self):

        self.source_text_params = SourceTextParams()

    def setup_gui(self):

        dialog_layout = QVBoxLayout()
        main_widget = QTabWidget()

        main_widget.addTab(self.setup_stereoplot_tab(), "Stereoplot")

        main_widget.addTab(self.setup_help_tab(), "Help")

        dialog_layout.addWidget(main_widget)
        self.setLayout(dialog_layout)

        self.adjustSize()
        self.setWindowTitle('{} - {}'.format(plugin_name, self.module_name))

    def setup_stereoplot_tab(self):

        widget = QWidget()

        layout = QVBoxLayout()

        self.stereonet = StereoNet()

        layout.addWidget(self.stereonet.fig.canvas)

        self.pshDefineInput = QPushButton(self.tr("Input data"))
        self.pshDefineInput.clicked.connect(self.define_input)
        layout.addWidget(self.pshDefineInput)

        self.pshDefineStyle = QPushButton(self.tr("Plot style"))
        self.pshDefineStyle.clicked.connect(self.define_style)
        layout.addWidget(self.pshDefineStyle)

        self.pshDefineStereoplot = QPushButton(self.tr("Plot data"))
        self.pshDefineStereoplot.clicked.connect(self.define_stereoplot)
        layout.addWidget(self.pshDefineStereoplot)

        self.pshLinkRectangle = QPushButton(self.tr("Link canvas rectangle"))
        self.pshLinkRectangle.setCheckable(True)
        self.pshLinkRectangle.toggled.connect(self.toggle_rectangle_link)
        layout.addWidget(self.pshLinkRectangle)

        self.pshRedefineRectangle = QPushButton(self.tr("Redefine rectangle"))
        self.pshRedefineRectangle.setEnabled(False)
        self.pshRedefineRectangle.clicked.connect(self.redefine_rectangle)
        layout.addWidget(self.pshRedefineRectangle)

        self.chkDepthHistogram = QCheckBox(self.tr("Depth histogram"))
        self.chkDepthHistogram.setEnabled(False)
        self.chkDepthHistogram.toggled.connect(self.toggle_depth_histogram)
        layout.addWidget(self.chkDepthHistogram)

        self.pshClearStereoplot = QPushButton(self.tr("Clear stereonet"))
        self.pshClearStereoplot.clicked.connect(self.clear_stereoplot)
        layout.addWidget(self.pshClearStereoplot)

        self.pshSaveFigure = QPushButton(self.tr("Save figure"))
        self.pshSaveFigure.clicked.connect(self.save_figure)
        layout.addWidget(self.pshSaveFigure)

        widget.setLayout(layout)

        return widget

    def define_input(self):

        llyrLoadedPointLayers = loaded_point_layers()

        dialog = StereoplotInputDlg(llyrLoadedPointLayers)

        if dialog.exec():

            if dialog.tabWdgt.currentIndex() == 0:  # layer as input

                self.reset_text_src_data()  # discard text-derived data

                # check that input layer is defined
                try:

                    lyrInputLayer = llyrLoadedPointLayers[dialog.cmbInputLayers.currentIndex() - 1]

                except:

                    warn_qgis(
                        self.module_name,
                        "Incorrect point layer choice"
                    )
                    return

                # extract input type definitions

                input_layer_params, err = extract_input_layer_params_from_dialog(dialog)

                if err:
                    warn_qgis(
                        self.module_name,
                        f"{str(err)}",
                    )
                    return

                if not valid_input_layer_params(input_layer_params):
                    warn_qgis(
                        self.module_name,
                        "Invalid/incomplete parameters"
                    )
                    return

                # get used field names in the point attribute table

                ltAttitudeFldNms = [input_layer_params["pln_azimuth_name_field"],
                                    input_layer_params["pln_dip_name_field"],
                                    input_layer_params["ln_azimuth_name_field"],
                                    input_layer_params["ln_dip_name_field"],
                                    input_layer_params["ln_movsen_name_field"],
                                    input_layer_params["ln_rake_name_field"],]

                # set input data presence and type

                layer_src_data = True
                src_layer = lyrInputLayer
                attitude_fld_names = ltAttitudeFldNms

                input_data_types, err = input_layer_data_characteristics(input_layer_params)

                if err:
                    warn_qgis(
                        self.module_name,
                        f"{str(err)}"
                    )
                    return

                layer_is_3d = QgsWkbTypes.hasZ(lyrInputLayer.wkbType())
                depth_fld_name = None if layer_is_3d else (input_layer_params["depth_name_field"] or None)

                self.source_layer_params = SourceLayerParams(
                    layer_src_data,
                    src_layer,
                    attitude_fld_names,
                    input_data_types,
                    depth_fld_name,
                    layer_is_3d,
                )

                self._connect_layer_selection(src_layer)
                self._update_depth_histogram_availability()

            elif dialog.tabWdgt.currentIndex() == 1:  # text as input

                self.reset_layer_src_data()  # discard layer-derived data

                try:

                    data_type = dialog.cmbInputDataType.currentText()
                    plane_azim_type = dialog.cmbInputPlaneOrAzimType.currentText()
                    raw_values = dialog.plntxtedInputValues.toPlainText()

                except:

                    warn_qgis(
                        self.module_name,
                        "Incorrect text input"
                    )
                    return

                results, err = extract_orientations_from_text(
                    data_type,
                    plane_azim_type,
                    raw_values
                )

                if err:
                    warn_qgis(
                        self.module_name,
                        str(err)
                    )
                    return

                self.source_text_params = SourceTextParams(
                    True,
                    results
                )

            else:  # unknown choice

                self.reset_text_src_data()  # discard text-derived data
                self.reset_layer_src_data()  # discard layer-derived data

                warn_qgis(
                    self.module_name,
                    "Error with input data choice"
                )
                return

            info_qgis(
                self.module_name,
                f"Input data defined"
            )

    def define_style(self):

        dialog = StereonetStyleDlg(self.stereonet_params)

        if dialog.exec():

            result, err = extract_user_defined_stereoplot_style(dialog)
            if err:
                warn_qgis(
                    self.module_name,
                    f"{err!r} "
                )
                return

            self.stereonet_params = result
            self.update_style_settings()

    def plot_dataset(
            self,
            structural_values,
            plot_setts,
            stereonet=None):

        if stereonet is None:
            stereonet = self.stereonet

        line_style = self.stereonet_params.line_style
        line_width = parse_marker_size(self.stereonet_params.line_width)
        line_color = fract_rgb_from_qcolor_name(self.stereonet_params.line_color)
        line_alpha = parse_transparency(self.stereonet_params.line_transp)

        marker_style = marker_styles[self.stereonet_params.marker_style]
        marker_size = parse_marker_size(self.stereonet_params.marker_size)
        marker_color = fract_rgb_from_qcolor_name(self.stereonet_params.marker_color)
        marker_alpha = parse_transparency(self.stereonet_params.marker_transp)

        pt_axis_style = marker_styles[self.stereonet_params.pt_axis_style]
        pt_axis_size = parse_marker_size(self.stereonet_params.pt_axis_size)
        pt_axis_alpha = parse_transparency(self.stereonet_params.pt_axis_transp)
        p_axis_color = fract_rgb_from_qcolor_name(self.stereonet_params.p_axis_color)
        t_axis_color = fract_rgb_from_qcolor_name(self.stereonet_params.t_axis_color)

        tl_marker_style = marker_styles[self.stereonet_params.tl_marker_style]
        tl_marker_size = parse_marker_size(self.stereonet_params.tl_marker_size)
        tl_alpha = parse_transparency(self.stereonet_params.tl_marker_transp)
        tl_color = fract_rgb_from_qcolor_name(self.stereonet_params.tl_color)

        if plot_setts["bPlotPlanes"]:

            if plot_setts["tPlotPlanesFormat"] not in ["great circles", "normal axes"]:
                raise ValueError(f"Unexpected tPlotPlanesFormat value: {plot_setts['tPlotPlanesFormat']!r}")

            plane_data = filter_plane_data(structural_values)

            if not plane_data:

                warn_qgis(
                    self.module_name,
                    "No plane data"
                )

            else:

                plane_skipped = 0

                for plane_dda in plane_data:

                    if plot_setts["tPlotPlanesFormat"] == "great circles":

                        p = aFol(*plane_dda)
                        stereonet.plane(
                            p,
                            linestyle=line_style,
                            linewidth=line_width,
                            color=line_color,
                            alpha=line_alpha
                        )

                    elif plot_setts["tPlotPlanesFormat"] == "normal axes":

                        try:
                            line_rec = Plane(*plane_dda).normal_direction().d
                        except Exception as err:
                            plane_skipped += 1
                            QgsMessageLog.logMessage(
                                f"Skipping plane row (dip_dir={plane_dda[0]}, dip_ang={plane_dda[1]}): {err}",
                                "qgSurf",
                                Qgis.MessageLevel.Warning
                            )
                            continue

                        l = aLin(*line_rec)
                        stereonet.line(
                            l,
                            marker=marker_style,
                            markersize=marker_size,
                            color=marker_color,
                            alpha=marker_alpha
                        )

                    else:

                        raise Exception("Not yet implemented")

                if plane_skipped:

                    warn_qgis(
                        self.module_name,
                        f"{plane_skipped} plane record(s) skipped (invalid dip angle)"
                    )

        if plot_setts["bPlotPlaneswithRake"]:

            flt_slik_data, flt_slik_skipped = filter_fault_slikenline_data(structural_values)

            if flt_slik_skipped:

                warn_qgis(
                    self.module_name,
                    f"{flt_slik_skipped} fault-slickenline record(s) skipped (invalid dip angle or rake)"
                )

            if not flt_slik_data:

                warn_qgis(
                    self.module_name,
                    "No fault-slickenline data"
                )

            else:

                for flt_slick in flt_slik_data:

                    dip_dir, dip_ang, lin_tr, lin_pl, sense = flt_slick

                    flt = aFault(dip_dir, dip_ang, lin_tr, lin_pl, sense)

                    if plot_setts["tPlotPlaneswithRakeFormat"] == "faults with skickenlines":

                        stereonet.fault(
                            flt,
                            linestyle=line_style,
                            linewidth=line_width,
                            color=line_color,
                            alpha=line_alpha,
                            marker_size=marker_size,
                            marker_color=marker_color,
                            marker_alpha=marker_alpha
                        )

                    else:

                        raise Exception("Not yet implemented")

        if plot_setts["bPlotAxes"]:

            line_data, line_skipped = filter_line_data(structural_values)

            if line_skipped:

                warn_qgis(
                    self.module_name,
                    f"{line_skipped} line record(s) skipped (invalid dip angle or rake)"
                )

            if not line_data:

                warn_qgis(
                    self.module_name,
                    "No line data"
                )

            else:

                for line_rec in line_data:

                    if plot_setts["tPlotAxesFormat"] == "poles":

                        l = aLin(*line_rec)
                        stereonet.line(
                            l,
                            marker=marker_style,
                            markersize=marker_size,
                            color=marker_color,
                            alpha=marker_alpha
                        )

                    elif plot_setts["tPlotAxesFormat"] == "perpendicular planes":

                        az, pl = line_rec
                        plane_dda = Axis(az, pl).normal_plane().vals
                        p = aFol(*plane_dda)

                        stereonet.plane(
                            p,
                            linestyle=line_style,
                            linewidth=line_width,
                            color=line_color,
                            alpha=line_alpha
                        )

                    else:

                        raise Exception("Not yet implemented")

        if plot_setts["bPlotPTAxes"]:

            flt_slik_data, pt_skipped = filter_fault_slikenline_data(structural_values)

            if not flt_slik_data:

                warn_qgis(
                    self.module_name,
                    "No fault-slickenline data"
                )

            else:

                pt_legend_shown = False

                for flt_slick in flt_slik_data:

                    dip_dir, dip_ang, lin_tr, lin_pl, sense = flt_slick

                    try:

                        flt = aFault(dip_dir, dip_ang, lin_tr, lin_pl, sense)
                        p_axis = flt.p
                        t_axis = flt.t

                    except Exception as err:

                        pt_skipped += 1
                        QgsMessageLog.logMessage(
                            f"Skipping P-T axes row (dip_dir={dip_dir}, dip_ang={dip_ang}, lin_tr={lin_tr}, lin_pl={lin_pl}): {err}",
                            "qgSurf",
                            Qgis.MessageLevel.Warning
                        )
                        continue

                    stereonet.line(
                        p_axis,
                        marker=pt_axis_style,
                        markersize=pt_axis_size,
                        color=p_axis_color,
                        alpha=pt_axis_alpha,
                        label=None if pt_legend_shown else "P axis"
                    )

                    stereonet.line(
                        t_axis,
                        marker=pt_axis_style,
                        markersize=pt_axis_size,
                        color=t_axis_color,
                        alpha=pt_axis_alpha,
                        label=None if pt_legend_shown else "T axis"
                    )

                    pt_legend_shown = True

            if pt_skipped:

                warn_qgis(
                    self.module_name,
                    f"{pt_skipped} P-T axes record(s) skipped (invalid dip angle, rake or movement sense)"
                )

        if plot_setts["bPlotTangentLineation"]:

            flt_slik_data, tl_skipped = filter_fault_slikenline_data(structural_values)

            if not flt_slik_data:

                warn_qgis(
                    self.module_name,
                    "No fault-slickenline data"
                )

            else:

                for flt_slick in flt_slik_data:

                    dip_dir, dip_ang, lin_tr, lin_pl, sense = flt_slick

                    flt = aFault(dip_dir, dip_ang, lin_tr, lin_pl, sense)

                    stereonet.line(
                        flt.fvec.aslin,
                        marker=tl_marker_style,
                        markersize=tl_marker_size,
                        color=tl_color,
                        alpha=tl_alpha,
                    )

                    stereonet.hoeppner(
                        flt,
                        marker_size=tl_marker_size,
                        marker_color=tl_color,
                        marker_alpha=tl_alpha,
                    )

            if tl_skipped:

                warn_qgis(
                    self.module_name,
                    f"{tl_skipped} tangent lineation record(s) skipped (invalid dip angle or rake)"
                )

        # Explicit, canvas-targeted redraw: StereoNet's own draw() calls the
        # global plt.draw(), which only repaints whichever figure pyplot
        # currently considers "current" - not necessarily this one if other
        # pyplot figures exist. Don't rely on it.
        stereonet.fig.canvas.draw_idle()

    def _load_layer_structural_values(self, feature_request=None):

        layer = self._live_source_layer()

        if layer is None:
            return None

        lGeoStructurData = read_qgis_pt_layer(
            layer,
            self.source_layer_params.attitude_fld_names,
            feature_request=feature_request
        )

        lStructuralValues, parse_skipped = parse_layer_data(
            self.source_layer_params.input_data_types,
            lGeoStructurData
        )

        if parse_skipped:

            warn_qgis(
                self.module_name,
                f"{parse_skipped} layer record(s) skipped (invalid/non-numeric field values)"
            )

        return lStructuralValues

    def _regular_feature_request(self):
        """Feature request for the 'regular' (non-rectangle-linked) plot: restricted
        to the layer's current QGIS selection when one exists, otherwise all features."""

        layer = self._live_source_layer()

        if layer is not None and layer.selectedFeatureCount() > 0:
            return QgsFeatureRequest().setFilterFids(layer.selectedFeatureIds())

        return None

    def _all_layer_depth_values(self):

        if not self.source_layer_params.layer_src_data:
            return []

        return self._load_depth_values(None)

    def _load_regular_structural_values(self):

        if self.source_layer_params.layer_src_data:
            return self._load_layer_structural_values(feature_request=self._regular_feature_request())
        elif self.source_text_params.text_src_data:
            return self.source_text_params.geostructural_data

        return None

    def _refresh_regular_plot(self):
        """Redraw the dock's own stereonet/depth histogram from the regular
        dataset (current QGIS selection if any, else all layer records)."""

        if self.last_plot_setts is None:
            return

        lStructuralValues = self._load_regular_structural_values()

        if lStructuralValues is None:
            return

        self.clear_stereoplot()
        self.plot_dataset(lStructuralValues, self.last_plot_setts)

    def define_stereoplot(self):

        # una sorgente da layer definita, ma con il layer ormai distrutto, va
        # segnalata per quello che è: altrimenti l'azzeramento la farebbe apparire
        # come un'assenza di dati, senza dire che erano stati definiti e perché non
        # ci sono più
        if self.source_layer_params.layer_src_data and self._live_source_layer() is None:

            warn_qgis(
                self.module_name,
                "The input layer is no longer available: it was removed from the "
                "project, or it belonged to a project that has been closed. "
                "Define the input data again."
            )
            return

        if not (self.source_layer_params.layer_src_data or self.source_text_params.text_src_data):

            warn_qgis(
                self.module_name,
                "No data to plot"
            )
            return

        if self.source_layer_params.layer_src_data and self.source_text_params.text_src_data:

            warn_qgis(
                self.module_name,
                "Both layer and text sources defined"
            )
            return

        dialog = PlotStereonetDialog()

        if dialog.exec():

            dPlotSettings = dict(bPlotPlanes=dialog.chkPlanes.isChecked(),
                                 tPlotPlanesFormat=dialog.cmbPlanesType.currentText(),
                                 bPlotPlaneswithRake=dialog.chkPlaneswithRake.isChecked(),
                                 tPlotPlaneswithRakeFormat=dialog.cmbPlaneswithRakeType.currentText(),
                                 bPlotAxes=dialog.chkAxes.isChecked(),
                                 tPlotAxesFormat=dialog.cmbAxesType.currentText(),
                                 bPlotPTAxes=dialog.chkPTAxes.isChecked(),
                                 bPlotTangentLineation=dialog.chkTangentLineation.isChecked())

            self.last_plot_setts = dPlotSettings

            self._refresh_regular_plot()

    def toggle_rectangle_link(self, checked):

        if not checked:

            self._deactivate_rectangle_link()
            return

        if self.source_text_params.text_src_data:

            warn_qgis(
                self.module_name,
                "Canvas rectangle linking requires a layer input, not pasted text"
            )
            self.pshLinkRectangle.setChecked(False)
            return

        if not self.source_layer_params.layer_src_data:

            warn_qgis(
                self.module_name,
                "Define a layer input first"
            )
            self.pshLinkRectangle.setChecked(False)
            return

        if self.last_plot_setts is None:

            self.last_plot_setts = dict(
                bPlotPlanes=True,
                tPlotPlanesFormat="great circles",
                bPlotPlaneswithRake=False,
                tPlotPlaneswithRakeFormat="faults with skickenlines",
                bPlotAxes=True,
                tPlotAxesFormat="poles",
                bPlotPTAxes=False,
                bPlotTangentLineation=False
            )

        self._previous_maptool = self.canvas.mapTool()

        self.rect_maptool = RectangleMapTool(self.canvas)
        self.rect_maptool.rectangleDefined.connect(self.on_rectangle_update)
        self.rect_maptool.rectangleMoving.connect(self.on_rectangle_update)
        self.rect_maptool.rectangleMoved.connect(self.on_rectangle_update)

        self.canvas.setMapTool(self.rect_maptool)

        self.pshRedefineRectangle.setEnabled(True)

        if self.rect_floating_window is None:
            self.rect_floating_window = RectangleLinkFloatingWindow(self.module_name)
            self.rect_floating_window.window_closed.connect(self._on_floating_window_closed)

        self.rect_floating_window.clear_stereoplot()
        self.rect_floating_window.set_depth_visible(
            self.chkDepthHistogram.isChecked(),
            self._all_layer_depth_values() if self.chkDepthHistogram.isChecked() else None,
            is_z=self.source_layer_params.layer_is_3d
        )
        self.rect_floating_window.show()
        self.rect_floating_window.raise_()

        info_qgis(
            self.module_name,
            "Drag on the canvas to draw the rectangle, then drag inside it to pan it"
        )

    def _on_floating_window_closed(self):

        if self.pshLinkRectangle.isChecked():
            self.pshLinkRectangle.setChecked(False)

    def redefine_rectangle(self):

        if self.rect_maptool is not None:
            self.rect_maptool.redefine()

    def _deactivate_rectangle_link(self):

        if self.rect_maptool is not None:

            try:
                self.canvas.unsetMapTool(self.rect_maptool)
            except RuntimeError:
                pass

            if self._previous_maptool is not None:
                try:
                    self.canvas.setMapTool(self._previous_maptool)
                except RuntimeError:
                    pass

            self.rect_maptool = None
            self._previous_maptool = None

        self._pending_rect = None
        self._replot_timer.stop()

        self.pshRedefineRectangle.setEnabled(False)

        if self.pshLinkRectangle.isChecked():
            self.pshLinkRectangle.setChecked(False)

        if self.rect_floating_window is not None:
            self.rect_floating_window.close()

    def toggle_depth_histogram(self, checked):

        if self.rect_floating_window is not None:
            self.rect_floating_window.set_depth_visible(
                checked,
                self._all_layer_depth_values() if checked else None,
                is_z=self.source_layer_params.layer_is_3d
            )

        if checked and self._pending_rect is not None:
            self._replot_within_last_rect()

    def _load_depth_values(self, feature_request):

        layer = self._live_source_layer()

        if layer is None:
            return []

        layer_is_3d = self.source_layer_params.layer_is_3d
        depth_fld_name = self.source_layer_params.depth_fld_name

        field_idx = -1
        if not layer_is_3d and depth_fld_name:
            field_idx = layer.fields().indexFromName(depth_fld_name)

        values = []

        features = layer.getFeatures(feature_request) if feature_request is not None else layer.getFeatures()

        for feature in features:

            if layer_is_3d:

                z = feature.geometry().vertexAt(0).z()
                if z is not None and np.isfinite(z):
                    values.append(z)

            elif field_idx >= 0:

                raw_value = feature.attribute(field_idx)
                try:
                    value = float(raw_value)
                except (TypeError, ValueError):
                    continue
                if np.isfinite(value):
                    values.append(value)

        return values

    def on_rectangle_update(self, rect_tuple):

        self._pending_rect = rect_tuple
        self._replot_timer.start(150)

    def _replot_within_last_rect(self):

        if self._pending_rect is None or self.last_plot_setts is None:
            return

        if not self.source_layer_params.layer_src_data:
            return

        if self.rect_floating_window is None:
            return

        xmin, ymin, xmax, ymax = self._pending_rect
        qgs_rect = QgsRectangle(xmin, ymin, xmax, ymax)

        layer = self._live_source_layer()

        if layer is None:
            return

        canvas_crs = self.canvas.mapSettings().destinationCrs()
        layer_crs = layer.crs()

        if canvas_crs != layer_crs:
            transform = QgsCoordinateTransform(canvas_crs, layer_crs, QgsProject.instance())
            qgs_rect = transform.transformBoundingBox(qgs_rect)

        feature_request = QgsFeatureRequest().setFilterRect(qgs_rect)

        lStructuralValues = self._load_layer_structural_values(feature_request=feature_request)

        self.rect_floating_window.clear_stereoplot()
        self.plot_dataset(lStructuralValues, self.last_plot_setts, stereonet=self.rect_floating_window.stereonet)

        if self.chkDepthHistogram.isChecked():
            self.rect_floating_window.refresh_depth(self._load_depth_values(feature_request))

    def clear_stereoplot(self):

        self.stereonet.cla()
        self.stereonet.fig.canvas.draw_idle()

    def save_figure(self):

        dialog = FigureExportDialog(
            plugin_name,
            self.dExportParams
        )

        if dialog.exec():

            try:

                self.dExportParams["expfig_res_dpi"] = dialog.qleFigResolutionDpi.text()
                fig_resolution_dpi = int(self.dExportParams["expfig_res_dpi"])

            except:

                warn_qgis(
                    self.module_name,
                    "Error in figure resolution value"
                )
                return

            try:

                fig_outpath = str(dialog.qleFigureOutPath.text())

            except:

                warn_qgis(
                    self.module_name,
                    "Error in figure output path"
                )
                return

        else:

            warn_qgis(
                self.module_name,
                "No export figure defined"
            )
            return

        try:

            self.stereonet.fig.savefig(str(fig_outpath), dpi=fig_resolution_dpi)
            success, msg = True, "Image saved"

        except Exception as e:

            success, msg = False, f"Exception with image saving: {e!r}"

        if success:

            ok_qgis(
                self.module_name,
                msg
            )

        else:

            warn_qgis(
                self.module_name,
                msg
            )

    def update_style_settings(self):

        settings = QSettings(self.settings_name, plugin_name)
        settings.setValue("StereoplotWidget/line_color", self.stereonet_params.line_color)
        settings.setValue("StereoplotWidget/line_style", self.stereonet_params.line_style)
        settings.setValue("StereoplotWidget/line_width", self.stereonet_params.line_width)
        settings.setValue("StereoplotWidget/line_transp", self.stereonet_params.line_transp)

        settings.setValue("StereoplotWidget/marker_color", self.stereonet_params.marker_color)
        settings.setValue("StereoplotWidget/marker_style", self.stereonet_params.marker_style)
        settings.setValue("StereoplotWidget/marker_size", self.stereonet_params.marker_size)
        settings.setValue("StereoplotWidget/point_transp", self.stereonet_params.marker_transp)

        settings.setValue("StereoplotWidget/p_axis_color", self.stereonet_params.p_axis_color)
        settings.setValue("StereoplotWidget/t_axis_color", self.stereonet_params.t_axis_color)
        settings.setValue("StereoplotWidget/pt_axis_style", self.stereonet_params.pt_axis_style)
        settings.setValue("StereoplotWidget/pt_axis_size", self.stereonet_params.pt_axis_size)
        settings.setValue("StereoplotWidget/pt_axis_transp", self.stereonet_params.pt_axis_transp)

        settings.setValue("StereoplotWidget/tl_color", self.stereonet_params.tl_color)
        settings.setValue("StereoplotWidget/tl_marker_style", self.stereonet_params.tl_marker_style)
        settings.setValue("StereoplotWidget/tl_marker_size", self.stereonet_params.tl_marker_size)
        settings.setValue("StereoplotWidget/tl_marker_transp", self.stereonet_params.tl_marker_transp)

    def closeEvent(self, event):

        # todo: define if this function it's reached or not, and how to change in negative case

        self._deactivate_rectangle_link()

        settings = QSettings(self.settings_name, plugin_name)
        settings.setValue("StereoplotWidget/size", self.size())
        settings.setValue("StereoplotWidget/position", self.pos())

        self.window_closed.emit()

    def setup_help_tab(self):

        qwdtHelp = QWidget()
        qlytHelp = QVBoxLayout()

        # Help section

        qtbrHelp = QTextBrowser(qwdtHelp)
        url_path = f"file:///{self.plugin_folder}/help/help_stereonet.html"
        qtbrHelp.setSource(QUrl(url_path))
        qtbrHelp.setSearchPaths([f'{self.plugin_folder}/help/images/stereonet'])
        qlytHelp.addWidget(qtbrHelp)

        qwdtHelp.setLayout(qlytHelp)

        return qwdtHelp





