# -*- coding: utf-8 -*-
# gui/components/model_view/_mv_dim_lines_volume.py
"""Методы режима измерения для размерных линий."""

from __future__ import annotations

from typing import Optional, Tuple, TYPE_CHECKING

from PySide6.QtGui import QCursor

if TYPE_CHECKING:  # pragma: no cover
    from gui.components.model_view_widget import ModelViewWidget

try:
    import pyvista as pv
except ImportError:  # pragma: no cover
    pv = None

from gui.components.model_view._mv_dimension_lines import (
    _DIM_LINE_COLOR_ORIGIN,
    _DIM_LINE_COLOR_LAST,
    _MEASURE_SNAP_MM,
    _MEASURE_SNAP_PX,
    _MEASURE_LABEL_COLOR,
    _MEASURE_HIGHLIGHT_COLOR,
    _MEASURE_HIGHLIGHT_OPACITY,
)

from error_logger import log_exception


class DimLineMeasureMixin:
    """Миксин: наложение режима измерения для ModelViewWidget."""

    # ==================================================================
    # Режим измерения (кнопка зоны/инструмента «Измерить»)
    # ==================================================================

    def start_measure_mode(
        self: "ModelViewWidget",
        *,
        view_level: str | None = None,
        zone_id: str | None = None,
        rack_id: str | None = None,
    ) -> None:
        self._measure_point_a = None
        self._measure_point_b = None
        self._measure_hover_node = None
        self._measure_scope_level = str(view_level or "facility").strip().lower()
        self._measure_scope_zone_id = str(zone_id or "")
        self._measure_scope_rack_id = str(rack_id or "")
        self._build_measure_nodes_cache()
        self._clear_measure_actors()
        self._clear_measure_highlight()
        # Сценарий взаимодействия
        mgr = getattr(self, "_interaction_manager", None)
        if mgr is not None:
            from gui.components.model_view._scenario_custom_handler import CustomHandlerScenario
            mgr.push(CustomHandlerScenario(
                name="measure",
                click_handler=self._on_measure_click,
                hover_screen_handler=self._on_measure_hover_screen,
            ))

    def stop_measure_mode(self: "ModelViewWidget", *, pop_im_scenario: bool = True) -> None:
        # Убрать сценарий взаимодействия
        mgr = getattr(self, "_interaction_manager", None)
        if mgr is not None and bool(pop_im_scenario):
            mgr.pop_by_name("measure")
        self._measure_point_a = None
        self._measure_point_b = None
        self._measure_hover_node = None
        self._measure_nodes = []
        self._measure_scope_level = ""
        self._measure_scope_zone_id = ""
        self._measure_scope_rack_id = ""
        self._clear_measure_actors()
        self._clear_measure_highlight()

    def _build_measure_nodes_cache(self: "ModelViewWidget") -> None:
        nodes: list[tuple[float, float, float]] = []
        ref = getattr(self, "_measure_ref_point", (0.0, 0.0, 0.0))
        nodes.append((float(ref[0]), float(ref[1]), float(ref[2])))
        level = str(getattr(self, "_measure_scope_level", "") or "facility").strip().lower()
        if level == "facility":
            self._append_visible_facility_bbox_nodes(nodes)
            self._append_visible_zone_bbox_nodes(nodes, include_hidden=True)
        elif level == "zone":
            self._append_visible_rack_bbox_nodes(
                nodes,
                zone_filter=str(getattr(self, "_measure_scope_zone_id", "") or ""),
            )
        elif level == "rack":
            self._append_rack_height_nodes(
                nodes,
                rack_filter=str(
                    getattr(self, "_measure_scope_rack_id", "")
                    or getattr(self, "_shelf_target_rack_id", "")
                    or ""
                ),
            )
        else:
            self._append_visible_shelf_cell_volume_nodes(
                nodes,
                rack_filter=str(
                    getattr(self, "_measure_scope_rack_id", "")
                    or getattr(self, "_shelf_target_rack_id", "")
                    or ""
                ),
                only_selected=bool(level == "shelf"),
            )
        if len(nodes) <= 1:
            # Facility/global fallback when no object bbox is available.
            for pt in getattr(self, "_corner_points", []) or []:
                try:
                    nodes.append((float(pt[0]), float(pt[1]), float(pt[2])))
                except Exception as e:
                    log_exception(__name__, "_build_measure_nodes_cache.corner_point", e)

        uniq: list[tuple[float, float, float]] = []
        seen: set[tuple[float, float, float]] = set()
        for x, y, z in nodes:
            key = (round(x, 1), round(y, 1), round(z, 1))
            if key in seen:
                continue
            seen.add(key)
            uniq.append((x, y, z))
        self._measure_nodes = uniq

    @staticmethod
    def _is_actor_visible(actor) -> bool:
        if actor is None:
            return False
        try:
            return bool(actor.GetVisibility())
        except Exception as e:
            log_exception(__name__, "_is_actor_visible.get_visibility", e)
            return False

    @staticmethod
    def _append_bbox_nodes(
        nodes: list[tuple[float, float, float]],
        min_x: float,
        max_x: float,
        min_y: float,
        max_y: float,
        min_z: float,
        max_z: float,
        ) -> None:
        for x in (float(min_x), float(max_x)):
            for y in (float(min_y), float(max_y)):
                for z in (float(min_z), float(max_z)):
                    nodes.append((x, y, z))

    def _append_visible_zone_bbox_nodes(
        self: "ModelViewWidget",
        nodes: list[tuple[float, float, float]],
        *,
        include_hidden: bool = False,
    ) -> None:
        zones = getattr(self, "_zones", {}) or {}
        zone_data = getattr(self, "_zone_data", {}) or {}
        for zid, bounds in zone_data.items():
            actor = zones.get(str(zid))
            if not include_hidden:
                if actor is None or not self._is_actor_visible(actor):
                    continue
            if not self._append_zone_contour_nodes(nodes, str(zid)):
                self._append_zone_bbox_nodes(nodes, str(zid), bounds_override=bounds)

    def _append_zone_contour_nodes(
        self: "ModelViewWidget",
        nodes: list[tuple[float, float, float]],
        zone_id: str,
    ) -> bool:
        polygon = list((getattr(self, "_zone_polygons", {}) or {}).get(str(zone_id), []) or [])
        if len(polygon) < 3:
            return False
        start_h, height = (getattr(self, "_zone_heights", {}) or {}).get(str(zone_id), (0.0, 0.0))
        try:
            z_min = float(start_h)
            z_max = float(start_h) + float(height)
        except Exception as e:
            log_exception(__name__, "_append_zone_contour_nodes.parse_heights", e)
            return False
        if z_max < z_min:
            z_min, z_max = z_max, z_min
        z_mid = (z_min + z_max) / 2.0
        n = len(polygon)
        for idx in range(n):
            x1, y1 = polygon[idx]
            x2, y2 = polygon[(idx + 1) % n]
            try:
                x1f, y1f = float(x1), float(y1)
                x2f, y2f = float(x2), float(y2)
            except Exception as e:
                log_exception(__name__, "_append_zone_contour_nodes.parse_xy", e)
                continue
            mx = (x1f + x2f) / 2.0
            my = (y1f + y2f) / 2.0
            for z in (z_min, z_mid, z_max):
                nodes.append((x1f, y1f, z))
                nodes.append((mx, my, z))
        return True

    @staticmethod
    def _append_rect_perimeter_nodes(
        nodes: list[tuple[float, float, float]],
        min_x: float,
        max_x: float,
        min_y: float,
        max_y: float,
        z_values: list[float],
    ) -> None:
        mid_x = (float(min_x) + float(max_x)) / 2.0
        mid_y = (float(min_y) + float(max_y)) / 2.0
        perimeter_xy = (
            (float(min_x), float(min_y)),
            (float(max_x), float(min_y)),
            (float(max_x), float(max_y)),
            (float(min_x), float(max_y)),
            (mid_x, float(min_y)),
            (float(max_x), mid_y),
            (mid_x, float(max_y)),
            (float(min_x), mid_y),
        )
        for z in z_values:
            zf = float(z)
            for x, y in perimeter_xy:
                nodes.append((float(x), float(y), zf))

    def _append_mesh_contour_nodes(
        self: "ModelViewWidget",
        nodes: list[tuple[float, float, float]],
        mesh,
    ) -> bool:
        if pv is None or mesh is None:
            return False
        try:
            surface = mesh.extract_surface()
            edges = surface.feature_edges(
                boundary_edges=True,
                feature_edges=True,
                manifold_edges=False,
                non_manifold_edges=True,
                feature_angle=20.0,
            )
            points = getattr(edges, "points", None)
            if points is None or len(points) == 0:
                return False
            for pt in points:
                nodes.append((float(pt[0]), float(pt[1]), float(pt[2])))
            return True
        except Exception as e:
            log_exception(__name__, "_append_mesh_contour_nodes.extract_edges", e)
            return False

    def _append_visible_facility_bbox_nodes(self: "ModelViewWidget", nodes: list[tuple[float, float, float]]) -> None:
        model_actors = getattr(self, "_model_actors", {}) or {}
        walls_actor = model_actors.get("walls")
        walls_bounds = None
        if self._is_actor_visible(walls_actor):
            try:
                wb = walls_actor.GetBounds()
                if wb and len(wb) >= 6:
                    walls_bounds = tuple(float(v) for v in wb[:6])
            except Exception as e:
                log_exception(__name__, "_append_visible_facility_bbox_nodes.walls_bounds", e)
                walls_bounds = None

        for key in ("floor", "walls", "ceiling", "truss"):
            actor = model_actors.get(key)
            if not self._is_actor_visible(actor):
                continue
            try:
                bounds = actor.GetBounds()
            except Exception as e:
                log_exception(__name__, "_append_visible_facility_bbox_nodes.get_bounds", e)
                continue
            if not bounds or len(bounds) < 6:
                continue
            try:
                min_x, max_x, min_y, max_y, min_z, max_z = [float(v) for v in bounds[:6]]
            except Exception as e:
                log_exception(__name__, "_append_visible_facility_bbox_nodes.parse_bounds", e)
                continue

            if key == "floor":
                # Привязка к полу: верхняя поверхность + торцы только по внешнему контуру
                # сопряжения со стенами (без внутренней структуры модели).
                px0, px1, py0, py1 = min_x, max_x, min_y, max_y
                if walls_bounds is not None:
                    px0 = max(px0, float(walls_bounds[0]))
                    px1 = min(px1, float(walls_bounds[1]))
                    py0 = max(py0, float(walls_bounds[2]))
                    py1 = min(py1, float(walls_bounds[3]))
                    if px1 <= px0 or py1 <= py0:
                        px0, px1, py0, py1 = min_x, max_x, min_y, max_y
                self._append_rect_perimeter_nodes(nodes, px0, px1, py0, py1, [max_z, min_z])
                continue

            if key == "ceiling":
                # Для потолка используем внешний контур (нижняя/верхняя кромка).
                self._append_rect_perimeter_nodes(nodes, min_x, max_x, min_y, max_y, [min_z, max_z])
                continue

            if key == "walls":
                walls_mesh = getattr(self, "_walls_mesh", None)
                if self._append_mesh_contour_nodes(nodes, walls_mesh):
                    continue

            # Для остальных (или fallback) — углы габаритного контура.
            self._append_bbox_nodes(nodes, min_x, max_x, min_y, max_y, min_z, max_z)

    def _append_visible_rack_bbox_nodes(
        self: "ModelViewWidget",
        nodes: list[tuple[float, float, float]],
        *,
        zone_filter: str = "",
    ) -> None:
        zid_filter = str(zone_filter or "")
        for entry in getattr(self, "_rack_entries", []) or []:
            if zid_filter and str(entry.get("zone_id", "") or "") != zid_filter:
                continue
            self._append_rack_bbox_nodes(nodes, entry)

    def _append_rack_height_nodes(
        self: "ModelViewWidget",
        nodes: list[tuple[float, float, float]],
        *,
        rack_filter: str = "",
    ) -> None:
        """Rack-level: узлы только по вертикали (база стойки + низы полок)."""
        rid_filter = str(rack_filter or "")
        for entry in getattr(self, "_rack_entries", []) or []:
            rid = str(entry.get("rack_id") or "")
            if rid_filter and rid != rid_filter:
                continue
            anchors_xy: list[tuple[float, float]] = []
            bounds_xy = None
            if hasattr(self, "_rack_container_bbox"):
                try:
                    bounds_xy = self._rack_container_bbox(entry)
                except Exception as e:
                    log_exception(__name__, "_append_rack_height_nodes.container_bbox", e)
                    bounds_xy = None
            if (not bounds_xy or len(bounds_xy) < 4):
                raw_bbox = entry.get("bbox")
                if raw_bbox and len(raw_bbox) >= 4:
                    try:
                        bounds_xy = tuple(float(v) for v in raw_bbox[:4])
                    except Exception as e:
                        log_exception(__name__, "_append_rack_height_nodes.raw_bbox", e)
                        bounds_xy = None
            if bounds_xy and len(bounds_xy) >= 4:
                min_x, max_x, min_y, max_y = [float(v) for v in bounds_xy[:4]]
                mid_x = (min_x + max_x) / 2.0
                mid_y = (min_y + max_y) / 2.0
                anchors_xy.extend([
                    (min_x, min_y),
                    (max_x, min_y),
                    (max_x, max_y),
                    (min_x, max_y),
                    (mid_x, mid_y),
                ])
            else:
                center = entry.get("center") or (0.0, 0.0)
                try:
                    anchors_xy.append((float(center[0]), float(center[1])))
                except Exception as e:
                    log_exception(__name__, "_append_rack_height_nodes.center_float", e)
                    anchors_xy.append((0.0, 0.0))

            base_z = None
            top_z = None
            for actor in list(entry.get("actors") or []):
                if actor is None or not self._is_actor_visible(actor):
                    continue
                try:
                    bounds = actor.GetBounds()
                except Exception as e:
                    log_exception(__name__, "_append_rack_height_nodes.actor_get_bounds", e)
                    continue
                if not bounds or len(bounds) < 6:
                    continue
                try:
                    b = tuple(float(v) for v in bounds[:6])
                except Exception as e:
                    log_exception(__name__, "_append_rack_height_nodes.actor_parse_bounds", e)
                    continue
                base_z = float(b[4]) if base_z is None else float(min(base_z, b[4]))
                top_z = float(b[5]) if top_z is None else float(max(top_z, b[5]))

            if base_z is None:
                zone_id = str(entry.get("zone_id") or "")
                base_z = float((getattr(self, "_zone_heights", {}) or {}).get(zone_id, (0.0, 0.0))[0])
            if top_z is None or float(top_z) <= float(base_z):
                params = dict(entry.get("params") or {})
                if hasattr(self, "_rack_height_mm"):
                    try:
                        top_z = float(base_z) + max(1.0, float(self._rack_height_mm(params)))
                    except Exception as e:
                        log_exception(__name__, "_append_rack_height_nodes.rack_height", e)
                        top_z = float(base_z) + 2000.0
                else:
                    top_z = float(base_z) + 2000.0

            z_levels: set[float] = {float(base_z), float(top_z)}
            shelf_actors = getattr(self, "_rack_shelf_actors", {}) or {}
            for (entry_rid, _slot_id), actors in shelf_actors.items():
                if str(entry_rid or "") != rid:
                    continue
                for actor in list(actors or []):
                    if actor is None or not self._is_actor_visible(actor):
                        continue
                    try:
                        bounds = actor.GetBounds()
                    except Exception as e:
                        log_exception(__name__, "_append_rack_height_nodes.shelf_get_bounds", e)
                        continue
                    if not bounds or len(bounds) < 6:
                        continue
                    try:
                        z_levels.add(float(bounds[4]))
                    except Exception as e:
                        log_exception(__name__, "_append_rack_height_nodes.shelf_z_level", e)
                        continue

            for x, y in anchors_xy:
                for z in sorted(z_levels):
                    nodes.append((float(x), float(y), float(z)))

    def _append_visible_shelf_bbox_nodes(
        self: "ModelViewWidget",
        nodes: list[tuple[float, float, float]],
        *,
        rack_filter: str = "",
    ) -> None:
        rid_filter = str(rack_filter or "")
        shelf_actors = getattr(self, "_rack_shelf_actors", {}) or {}
        for (entry_rid, _slot_id), actors in shelf_actors.items():
            if rid_filter and str(entry_rid or "") != rid_filter:
                continue
            for actor in list(actors or []):
                if not self._is_actor_visible(actor):
                    continue
                try:
                    bounds = actor.GetBounds()
                except Exception as e:
                    log_exception(__name__, "_append_visible_shelf_bbox_nodes.get_bounds", e)
                    continue
                if not bounds or len(bounds) < 6:
                    continue
                try:
                    min_x, max_x, min_y, max_y, min_z, max_z = [float(v) for v in bounds[:6]]
                except Exception as e:
                    log_exception(__name__, "_append_visible_shelf_bbox_nodes.parse_bounds", e)
                    continue
                self._append_bbox_nodes(nodes, min_x, max_x, min_y, max_y, min_z, max_z)

    def _append_visible_shelf_cell_volume_nodes(
        self: "ModelViewWidget",
        nodes: list[tuple[float, float, float]],
        *,
        rack_filter: str = "",
        only_selected: bool = False,
    ) -> None:
        rid_filter = str(rack_filter or "")
        selected_rid = str(getattr(self, "_selected_shelf_visual_rack_id", "") or "")
        selected_sid = str(getattr(self, "_selected_shelf_visual_slot_id", "") or "")
        selected_idx = max(1, int(getattr(self, "_selected_shelf_visual_index", 1) or 1))

        shelf_actors = getattr(self, "_rack_shelf_actors", {}) or {}
        for (entry_rid, slot_id), actors in shelf_actors.items():
            rid = str(entry_rid or "")
            sid = str(slot_id or "")
            if rid_filter and rid != rid_filter:
                continue
            if only_selected and (rid != selected_rid or sid != selected_sid):
                continue

            payload = dict((getattr(self, "_rack_shelf_params", {}) or {}).get((rid, sid)) or {})
            for actor_index, actor in enumerate(list(actors or []), start=1):
                if not self._is_actor_visible(actor):
                    continue
                if only_selected and actor_index != selected_idx:
                    continue
                bounds = self._measure_shelf_cell_volume_bounds(
                    rid,
                    sid,
                    shelf_index=actor_index,
                    payload=payload,
                    actor=actor,
                )
                if bounds is None:
                    bounds = self._actor_bbox_bounds(actor)
                if bounds is None:
                    continue
                self._append_bbox_nodes(nodes, *bounds)

    @staticmethod
    def _actor_bbox_bounds(actor) -> tuple[float, float, float, float, float, float] | None:
        if actor is None:
            return None
        try:
            bounds = actor.GetBounds()
        except Exception as e:
            log_exception(__name__, "_actor_bbox_bounds.get_bounds", e)
            return None
        if not bounds or len(bounds) < 6:
            return None
        try:
            return tuple(float(v) for v in bounds[:6])
        except Exception as e:
            log_exception(__name__, "_actor_bbox_bounds.float_conversion", e)
            return None

    def _measure_shelf_cell_volume_bounds(
        self: "ModelViewWidget",
        rack_id: str,
        slot_id: str,
        *,
        shelf_index: int,
        payload: dict,
        actor,
    ) -> tuple[float, float, float, float, float, float] | None:
        resolver = getattr(self, "_resolve_cell_volume_limits", None)
        if not callable(resolver):
            return None

        data = dict(payload or {})
        data["shelf_number_index"] = int(max(1, int(shelf_index or 1)))
        if not str(data.get("shelf_id") or ""):
            data["shelf_id"] = "__measure__"

        try:
            limits = resolver(str(rack_id or ""), str(slot_id or ""), data)
        except Exception as e:
            log_exception(__name__, "_measure_shelf_cell_volume_bounds.resolve_limits", e)
            limits = None
        if limits is None:
            return None

        try:
            min_x, max_x, min_y, max_y, base_z, top_z = [float(v) for v in limits[:6]]
        except Exception as e:
            log_exception(__name__, "_measure_shelf_cell_volume_bounds.parse_limits", e)
            return None

        thickness = self._measure_shelf_thickness_mm(
            str(rack_id or ""),
            str(slot_id or ""),
            shelf_index=int(max(1, int(shelf_index or 1))),
            payload=data,
            actor=actor,
            base_z=float(base_z),
        )
        min_z = float(base_z - max(0.0, float(thickness)))
        if min_z > base_z:
            min_z = float(base_z)
        return (float(min_x), float(max_x), float(min_y), float(max_y), float(min_z), float(top_z))

    def _measure_shelf_thickness_mm(
        self: "ModelViewWidget",
        rack_id: str,
        slot_id: str,
        *,
        shelf_index: int,
        payload: dict,
        actor,
        base_z: float,
    ) -> float:
        actor_bounds = self._actor_bbox_bounds(actor)
        if actor_bounds is not None:
            actor_min_z = float(actor_bounds[4])
            if base_z > actor_min_z:
                return float(base_z - actor_min_z)

        rack_type = str(payload.get("rack_type") or "").strip().upper()
        if not rack_type:
            getter = getattr(self, "_get_rack_entry", None)
            if callable(getter):
                entry = getter(str(rack_id or ""), getattr(self, "_shelf_target_zone_id", None))
                if entry is None:
                    entry = getter(str(rack_id or ""))
                rack_type = str(((entry or {}).get("params") or {}).get("rack_type") or "").strip().upper()

        norms_getter = getattr(self, "_shelf_norms_for_rack_type", None)
        if callable(norms_getter):
            try:
                norms = dict(norms_getter(rack_type or "PALLET") or {})
            except Exception as e:
                log_exception(__name__, "_measure_shelf_thickness_mm.get_norms", e)
                norms = {}
            try:
                thickness = float(norms.get("shelf_height_mm") or 0.0)
            except Exception as e:
                log_exception(__name__, "_measure_shelf_thickness_mm.parse_thickness", e)
                thickness = 0.0
            if thickness > 0.0:
                return float(thickness)

        if actor_bounds is not None:
            return float(max(0.0, float(actor_bounds[5]) - float(actor_bounds[4])))
        return 0.0

    def _append_zone_bbox_nodes(
        self: "ModelViewWidget",
        nodes: list[tuple[float, float, float]],
        zone_id: str,
        *,
        bounds_override: tuple | list | None = None,
    ) -> None:
        bounds = bounds_override
        if bounds is None:
            bounds = (getattr(self, "_zone_data", {}) or {}).get(str(zone_id))
        if not bounds or len(bounds) < 6:
            return
        try:
            min_x, max_x, min_y, max_y, min_z, max_z = [float(v) for v in bounds[:6]]
        except Exception as e:
            log_exception(__name__, "_append_zone_bbox_nodes.parse_bounds", e)
            return
        self._append_bbox_nodes(nodes, min_x, max_x, min_y, max_y, min_z, max_z)

    def _append_rack_bbox_nodes(self: "ModelViewWidget", nodes: list[tuple[float, float, float]], entry: dict) -> None:
        # Zone-level measurement must use real transformed STL actor bounds.
        slot_actor_ids = {
            id(actor)
            for actor in ((entry.get("slot_actors") or {}).values())
            if actor is not None
        }
        bounds_acc: tuple[float, float, float, float, float, float] | None = None
        for actor in list(entry.get("actors") or []):
            if actor is None or id(actor) in slot_actor_ids:
                continue
            if not self._is_actor_visible(actor):
                continue
            try:
                bounds = actor.GetBounds()
            except Exception as e:
                log_exception(__name__, "_append_rack_bbox_nodes.get_bounds", e)
                continue
            if not bounds or len(bounds) < 6:
                continue
            try:
                b = tuple(float(v) for v in bounds[:6])
            except Exception as e:
                log_exception(__name__, "_append_rack_bbox_nodes.parse_bounds", e)
                continue
            if bounds_acc is None:
                bounds_acc = b
            else:
                bounds_acc = (
                    min(bounds_acc[0], b[0]),
                    max(bounds_acc[1], b[1]),
                    min(bounds_acc[2], b[2]),
                    max(bounds_acc[3], b[3]),
                    min(bounds_acc[4], b[4]),
                    max(bounds_acc[5], b[5]),
                )
        if bounds_acc is None:
            # Fallback: если STL-акторы недоступны/скрыты, используем контейнерный bbox стойки
            # и восстанавливаем диапазон Z по видимым акторам или по высоте стойки.
            xy_bounds = None
            if hasattr(self, "_rack_container_bbox"):
                try:
                    xy_bounds = self._rack_container_bbox(entry)
                except Exception as e:
                    log_exception(__name__, "_append_rack_bbox_nodes.container_bbox", e)
                    xy_bounds = None
            if (not xy_bounds or len(xy_bounds) < 4):
                raw_bbox = entry.get("bbox")
                if raw_bbox and len(raw_bbox) >= 4:
                    xy_bounds = tuple(float(v) for v in raw_bbox[:4])
            if not xy_bounds or len(xy_bounds) < 4:
                return
            min_x, max_x, min_y, max_y = [float(v) for v in xy_bounds[:4]]

            z_min = None
            z_max = None
            for actor in list(entry.get("actors") or []):
                if actor is None or not self._is_actor_visible(actor):
                    continue
                try:
                    bounds = actor.GetBounds()
                except Exception as e:
                    log_exception(__name__, "_append_rack_bbox_nodes.fallback_get_bounds", e)
                    continue
                if not bounds or len(bounds) < 6:
                    continue
                try:
                    b = tuple(float(v) for v in bounds[:6])
                except Exception as e:
                    log_exception(__name__, "_append_rack_bbox_nodes.fallback_parse_bounds", e)
                    continue
                z_min = float(b[4]) if z_min is None else float(min(z_min, b[4]))
                z_max = float(b[5]) if z_max is None else float(max(z_max, b[5]))

            if z_min is None or z_max is None or float(z_max) <= float(z_min):
                zone_id = str(entry.get("zone_id") or "")
                z_ref = float((getattr(self, "_zone_heights", {}) or {}).get(zone_id, (0.0, 0.0))[0])
                params = dict(entry.get("params") or {})
                rack_height = 2000.0
                if hasattr(self, "_rack_height_mm"):
                    try:
                        rack_height = float(self._rack_height_mm(params))
                    except Exception as e:
                        log_exception(__name__, "_append_rack_bbox_nodes.rack_height", e)
                        rack_height = 2000.0
                z_min = float(z_ref)
                z_max = float(z_ref + max(1.0, rack_height))
            bounds_acc = (float(min_x), float(max_x), float(min_y), float(max_y), float(z_min), float(z_max))
        self._append_bbox_nodes(nodes, *bounds_acc)

    def _qt_screen_to_vtk_display(self: "ModelViewWidget", sx: float, sy: float) -> tuple[float, float]:
        """Convert Qt logical coordinates to VTK display coordinates."""
        if not self._plotter:
            return float(sx), float(sy)
        try:
            dpr = float(self._plotter.devicePixelRatio())
        except Exception as e:
            log_exception(__name__, "_qt_screen_to_vtk_display.device_pixel_ratio", e)
            dpr = 1.0
        sx_vtk = float(sx) * dpr
        sy_vtk = float(sy) * dpr
        ren_win = getattr(self._plotter, "ren_win", None)
        if ren_win is not None:
            try:
                _w, h = ren_win.GetSize()
                sy_vtk = float(h) - sy_vtk - 1.0
            except Exception as e:
                log_exception(__name__, "_qt_screen_to_vtk_display.ren_win_size", e)
        return sx_vtk, sy_vtk

    def _resolve_measure_node(
        self: "ModelViewWidget",
        sx: float,
        sy: float,
        *,
        world_hint: Optional[Tuple[float, float, float]] = None,
    ) -> Optional[Tuple[float, float, float]]:
        """Unified node resolver for measure mode across hover and click flows."""
        if not self._measure_nodes:
            return None
        sx_vtk, sy_vtk = self._qt_screen_to_vtk_display(float(sx), float(sy))
        best_screen = None
        best_screen_d2 = None
        for nx, ny, nz in self._measure_nodes:
            disp = self.world_to_display(nx, ny, nz)
            if disp is None:
                continue
            dx = float(disp[0]) - sx_vtk
            dy = float(disp[1]) - sy_vtk
            d2 = dx * dx + dy * dy
            if best_screen_d2 is None or d2 < best_screen_d2:
                best_screen_d2 = d2
                best_screen = (nx, ny, nz)
        if best_screen is not None and best_screen_d2 is not None and best_screen_d2 <= (_MEASURE_SNAP_PX ** 2):
            return best_screen
        picked = self.pick_world(float(sx_vtk), float(sy_vtk))
        if picked is None and world_hint is not None:
            picked = world_hint
        if picked is not None:
            px, py, pz = picked
            return self._resolve_measure_node_from_world(float(px), float(py), float(pz))
        return None

    def _resolve_measure_node_from_world(
        self: "ModelViewWidget",
        px: float,
        py: float,
        pz: float,
    ) -> Optional[Tuple[float, float, float]]:
        if not self._measure_nodes:
            return None
        best = None
        best_d2 = None
        for nx, ny, nz in self._measure_nodes:
            d2 = (nx - px) ** 2 + (ny - py) ** 2 + (nz - pz) ** 2
            if best_d2 is None or d2 < best_d2:
                best_d2 = d2
                best = (nx, ny, nz)
        if best is not None and best_d2 is not None and best_d2 <= (_MEASURE_SNAP_MM ** 2):
            return best
        return None

    def _coerce_rack_vertical_node(
        self: "ModelViewWidget",
        node: Optional[Tuple[float, float, float]],
        anchor: Optional[Tuple[float, float, float]],
    ) -> Optional[Tuple[float, float, float]]:
        """Rack-scope: привязать вторую точку к XY первой точки (только высота)."""
        if node is None or anchor is None:
            return node
        level = str(getattr(self, "_measure_scope_level", "") or "").strip().lower()
        if level != "rack":
            return node
        ax, ay, _az = anchor
        nz_target = float(node[2])
        candidates = [
            (nx, ny, nz)
            for nx, ny, nz in (getattr(self, "_measure_nodes", []) or [])
            if abs(float(nx) - float(ax)) <= 0.5 and abs(float(ny) - float(ay)) <= 0.5
        ]
        if not candidates:
            return (float(ax), float(ay), nz_target)
        best = min(candidates, key=lambda pt: abs(float(pt[2]) - nz_target))
        return (float(best[0]), float(best[1]), float(best[2]))

    def _on_measure_hover_screen(self: "ModelViewWidget", sx: float, sy: float) -> None:
        if not self._interaction_manager.is_active("measure"):
            return
        node = self._resolve_measure_node(float(sx), float(sy))
        if self._measure_point_a is not None:
            node = self._coerce_rack_vertical_node(node, self._measure_point_a)
        if node == self._measure_hover_node:
            return
        self._measure_hover_node = node
        if node is None:
            self._clear_measure_highlight()
        else:
            self._show_measure_highlight(*node)
        if self._measure_point_a is not None:
            self._draw_measure_projection(self._measure_point_a, node)

    def _on_measure_click(self: "ModelViewWidget", x: float, y: float, z: float) -> bool:
        if not self._interaction_manager.is_active("measure"):
            return False
        world_hint: Optional[Tuple[float, float, float]] = None
        if x is not None and y is not None and z is not None:
            world_hint = (float(x), float(y), float(z))
        sx = 0.0
        sy = 0.0
        if self._plotter:
            try:
                cursor_global = QCursor.pos()
                cursor_local = self._plotter.mapFromGlobal(cursor_global)
                sx = float(cursor_local.x())
                sy = float(cursor_local.y())
            except Exception as e:
                log_exception(__name__, "_on_measure_click.cursor_pos", e)
        node = self._resolve_measure_node(sx, sy, world_hint=world_hint)
        if node is None:
            return True
        if self._measure_point_a is None or self._measure_point_b is not None:
            self._measure_point_a = node
            self._measure_point_b = None
            self._draw_measure_projection(self._measure_point_a, self._measure_hover_node)
            return True
        node = self._coerce_rack_vertical_node(node, self._measure_point_a)
        self._measure_point_b = node
        self._draw_measure_projection(self._measure_point_a, self._measure_point_b)
        return True

    def _draw_measure_projection(
        self: "ModelViewWidget",
        p1: Optional[Tuple[float, float, float]],
        p2: Optional[Tuple[float, float, float]],
    ) -> None:
        self._clear_measure_actors()
        if not self._plotter or pv is None:
            return
        if p1 is None or p2 is None:
            return
        x1, y1, z1 = p1
        x2, y2, z2 = p2
        lx1, ly1 = self._to_measure_local(x1, y1)
        lx2, ly2 = self._to_measure_local(x2, y2)
        wx1, wy1 = self._from_measure_local(lx1, ly1)
        wx2, wy_mid = self._from_measure_local(lx2, ly1)
        wx_mid, wy2 = self._from_measure_local(lx2, ly2)
        mid_x = (wx1 + wx2) / 2.0
        mid_y = (wy_mid + wy2) / 2.0
        line_x = pv.Line((wx1, wy1, z1 + 2.0), (wx2, wy_mid, z1 + 2.0))
        line_y = pv.Line((wx2, wy_mid, z1 + 2.0), (wx_mid, wy2, z1 + 2.0))
        line_z = pv.Line((wx_mid, wy2, z1 + 2.0), (wx_mid, wy2, z2 + 2.0))
        actor_x = self._plotter.add_mesh(
            line_x, color=_DIM_LINE_COLOR_ORIGIN, line_width=2.5, reset_camera=False, pickable=False,
        )
        actor_y = self._plotter.add_mesh(
            line_y, color=_DIM_LINE_COLOR_LAST, line_width=2.5, reset_camera=False, pickable=False,
        )
        actor_z = self._plotter.add_mesh(
            line_z, color="#00C853", line_width=2.5, reset_camera=False, pickable=False,
        )
        self._measure_actors.extend([actor_x, actor_y, actor_z])
        z_mid = (z1 + z2) / 2.0
        labels = [
            ((mid_x, y1, z1 + 20.0), f"{abs(x2 - x1):.0f} мм"),
            ((wx2, mid_y, z1 + 20.0), f"{abs(y2 - y1):.0f} мм"),
            ((wx_mid, wy2, z_mid + 20.0), f"{abs(z2 - z1):.0f} мм"),
        ]
        lab_actor = self._plotter.add_point_labels(
            [labels[0][0], labels[1][0], labels[2][0]],
            [labels[0][1], labels[1][1], labels[2][1]],
            font_size=24,
            text_color=_MEASURE_LABEL_COLOR,
            shape=None,
            show_points=False,
            always_visible=True,
            pickable=False,
            reset_camera=False,
        )
        self._measure_actors.append(lab_actor)
        if hasattr(self, "_safe_render"):
            self._safe_render(min_interval_s=0.05)
        else:
            self._plotter.update()

    def _show_measure_highlight(self: "ModelViewWidget", x: float, y: float, z: float) -> None:
        self._clear_measure_highlight()
        if not self._plotter or pv is None:
            return
        level = str(getattr(self, "_measure_scope_level", "") or "").strip().lower()
        if level == "facility":
            radius = 100.0
        elif level == "shelf":
            radius = 10.0
        else:
            radius = 25.0
        sphere = pv.Sphere(
            radius=radius,
            center=(x, y, z),
            theta_resolution=12,
            phi_resolution=12,
        )
        self._measure_highlight_actor = self._plotter.add_mesh(
            sphere,
            color=_MEASURE_HIGHLIGHT_COLOR,
            opacity=_MEASURE_HIGHLIGHT_OPACITY,
            pickable=False,
            reset_camera=False,
        )

    def _clear_measure_highlight(self: "ModelViewWidget") -> None:
        actor = getattr(self, "_measure_highlight_actor", None)
        if actor is not None and self._plotter:
            try:
                self._plotter.remove_actor(actor)
            except Exception as e:
                log_exception(__name__, "_clear_measure_highlight.remove_actor", e)
        self._measure_highlight_actor = None

    def _clear_measure_actors(self: "ModelViewWidget") -> None:
        if not self._plotter:
            self._measure_actors = []
            return
        for actor in self._measure_actors:
            try:
                self._plotter.remove_actor(actor)
            except Exception as e:
                log_exception(__name__, "_clear_measure_actors.remove_actor", e)
        self._measure_actors = []

    def _to_measure_local(self: "ModelViewWidget", x: float, y: float) -> tuple[float, float]:
        ref = getattr(self, "_measure_ref_point", (0.0, 0.0, 0.0))
        direction = getattr(self, "_measure_direction", (1.0, 1.0))
        dx = float(direction[0]) if float(direction[0]) != 0.0 else 1.0
        dy = float(direction[1]) if float(direction[1]) != 0.0 else 1.0
        return (float(x) - float(ref[0])) / dx, (float(y) - float(ref[1])) / dy

    def _from_measure_local(self: "ModelViewWidget", lx: float, ly: float) -> tuple[float, float]:
        ref = getattr(self, "_measure_ref_point", (0.0, 0.0, 0.0))
        direction = getattr(self, "_measure_direction", (1.0, 1.0))
        return float(ref[0]) + float(lx) * float(direction[0]), float(ref[1]) + float(ly) * float(direction[1])


# ---------------------------------------------------------------------------
# Module workflow notes
# ---------------------------------------------------------------------------
# 1) Задача модуля:
#    Единый режим «Измерить» для трёхмерной сцены.
#    Модуль формирует набор измерительных узлов по доступным габаритным
#    прямоугольным объёмам (зоны/стеллажи), привязывает курсор к ближайшему
#    узлу и отображает проекционные размерные линии между двумя выбранными
#    узлами.
#
# 2) Последовательность действий и вызовов:
#
#    A. Включение режима измерения:
#       start_measure_mode()
#         Назначение: перевести модуль в активный режим измерения.
#         Состояние: сбрасывает ранее выбранные
#         точки измерения:
#           _measure_point_a  — первая фиксированная точка измерения,
#           _measure_point_b  — вторая фиксированная точка измерения,
#           _measure_hover_node — текущий узел под курсором.
#         -> _build_measure_nodes_cache()
#            Назначение: подготовить актуальный список узлов привязки.
#            Состав узлов: вершины габаритных прямоугольных объёмов доступных зон и стеллажей.
#            -> _append_visible_zone_bbox_nodes()
#               Назначение: добавить узлы только видимых зон.
#               -> _append_zone_bbox_nodes() -> _append_bbox_nodes()
#                  Назначение: взять границы зоны и развернуть их в 8 угловых точек.
#            -> _append_visible_rack_bbox_nodes()
#               Назначение: добавить узлы только видимых стеллажей.
#               -> _append_rack_bbox_nodes() -> _append_bbox_nodes()
#                  Назначение: построить 8 углов стеллажа по его bbox и высоте.
#         -> _clear_measure_actors()
#            Назначение: удалить со сцены прошлые размерные линии и подписи.
#         -> _clear_measure_highlight()
#            Назначение: удалить прошлый актор подсветки узла.
#         -> push CustomHandlerScenario("measure", click_handler, hover_screen_handler)
#            Назначение: активировать сценарий измерения в InteractionManager.
#
#    B. Наведение курсора (перемещение мыши):
#       _on_measure_hover_screen(sx, sy)
#         Вход: экранные координаты курсора в системе Qt.
#         Выход: обновление активного узла наведения и его подсветки.
#         -> _resolve_measure_node(sx, sy)
#            Назначение: найти ближайший допустимый узел привязки.
#            -> _qt_screen_to_vtk_display(sx, sy)
#               Назначение: привести координаты Qt к системе окна визуализации.
#            -> world_to_display(...) для всех measure_nodes
#               Назначение: вычислить расстояние курсора до каждого узла на экране.
#            -> fallback: pick_world(...)
#               Назначение: получить мировую точку под курсором, если экранная привязка не сработала.
#            -> fallback: _resolve_measure_node_from_world(...)
#               Назначение: выбрать ближайший узел по расстоянию в мировых координатах.
#         -> если узел найден:
#            -> _show_measure_highlight(x, y, z)
#               Назначение: показать визуальный маркер узла (сфера подсветки).
#         -> если узел не найден:
#            -> _clear_measure_highlight()
#               Назначение: убрать маркер, чтобы не показывать ложную привязку.
#         -> если уже выбрана точка A:
#            -> _draw_measure_projection(point_a, hover_node)
#               Назначение: показывать предварительный результат измерения до второго щелчка.
#
#    C. ЛКМ по узлу bbox:
#       _on_measure_click(x, y, z)
#         Вход: мировая точка щелчка из обработчика сцены.
#         Логика: повторно определяет узел через общий путь _resolve_measure_node(...).
#         -> _resolve_measure_node(cursor_x, cursor_y, world_hint=(x, y, z))
#            Назначение: гарантировать одинаковые правила привязки для наведения и щелчка.
#         -> первый клик:
#            point_a = node, point_b = None
#            _draw_measure_projection(point_a, hover_node)
#            Результат: фиксируется начальная точка измерения.
#         -> второй клик:
#            point_b = node
#            _draw_measure_projection(point_a, point_b)
#            Результат: фиксируется конечная точка, рисуется итоговое измерение.
#         -> третий клик и далее:
#            цикл начинается заново (новая point_a)
#            Результат: предыдущая пара считается завершённой, начинается новое измерение.
#
#    D. Отрисовка размерных линий:
#       _draw_measure_projection(p1, p2)
#         Вход: две мировые точки измерения.
#         Выход: три проекционные линии (X, Y, Z) и текстовые подписи расстояний.
#         -> _clear_measure_actors()
#         -> _to_measure_local(...) / _from_measure_local(...)
#            Назначение: перевод между локальной системой измерений и мировыми координатами.
#         -> add_mesh(Line X/Y/Z), add_point_labels(...)
#         -> _safe_render(...) или _plotter.update()
#            Назначение: обновить кадр без полной перезагрузки сцены.
#
#    E. Выключение режима:
#       stop_measure_mode()
#         Назначение: безопасно завершить измерение и вернуть стандартные обработчики.
#         -> сброс point_a/point_b/hover_node/measure_nodes
#         -> _clear_measure_actors()
#         -> _clear_measure_highlight()
#         -> pop_by_name("measure")
#
# 3) Важное замечание по координатам (координаты Qt и координаты VTK):
#    Что было:
#    - В одном участке кода использовались координаты мыши из Qt-события
#      (логические пиксели, Y растет сверху вниз).
#    - В другом участке использовались координаты окна визуализации VTK
#      (физические пиксели, Y растет снизу вверх).
#    - Эти системы не эквивалентны, особенно на DPI scaling (DPR != 1.0).
#
#    Как проявлялась ошибка:
#    - актор подсветки узла «уезжал» относительно курсора;
#    - на некоторых позициях казалось, что наведение «инвертировано» по Y;
#    - snap срабатывал на соседнем/неверном узле bbox.
#
#    Почему это происходило:
#    - сравнение расстояния курсор->узел делалось между значениями из разных
#      систем координат (Qt logical против VTK display);
#    - при этом методы выбора точки и перевода координат ожидали систему VTK.
#
#    Принятое правило (обязательное для этого модуля):
#    - любой расчёт по экранным координатам (наведение/щелчок с привязкой)
#      сначала переводим в систему VTK через
#      _qt_screen_to_vtk_display(sx, sy):
#         1) logical -> physical: умножить на DPR
#         2) инвертировать Y относительно высоты render window
#    - только после этого выполняем:
#         world_to_display(...) для расчета расстояния до узлов
#         pick_world(...) как резервный путь через геометрию сцены
#
#    Короткий алгоритм:
#    Qt(sx, sy) -> _qt_screen_to_vtk_display -> (sx_vtk, sy_vtk)
#    -> ближайший узел через world_to_display
#    -> если не попали в порог привязки: pick_world(sx_vtk, sy_vtk)
#    -> ближайший узел по расстоянию в мировых координатах.
