# -*- coding: utf-8 -*-
"""Вспомогательные методы рендеринга полок стеллажа, извлечённые из основного модуля model-view.

Этот миксин предоставляет все методы, необходимые для создания, размещения
и управления 3D-акторами полок, отображаемых внутри секций стеллажа.
"""
from __future__ import annotations

import re
import math
import colorsys
from pathlib import Path
from typing import Any, TYPE_CHECKING
from error_logger import log_exception

try:
    import pyvista as pv
    _PV = True
except ImportError:
    _PV = False

if TYPE_CHECKING:
    from gui.components.model_view_widget import ModelViewWidget

# Параметры палитры объёмов ячеек.
# Шаг 15° (1/24 круга) — минимально различимый цветовой шаг (≈ #FF7F00 → #FFBF00).
# Стартовый hue зависит от step_mm:
#   step <  50 мм (A/B):     base_hue = 0.5 + (step_mm/10  − 1) / 12
#   step >= 50 мм (PALLET):  base_hue = 0.5 + (step_mm/50  − 2) / 12
# Стартовые цвета при тир=1:
#   step  10 /  100 → hue 0.500 (180°, #00FFFF — голубой)
#   step  20 /  150 → hue 0.583 (210°, ≈#0280FF — синевато-голубой)
#   step  30 /  200 → hue 0.667 (240°, #0000FF — синий)
#   step  40 /  250 → hue 0.750 (270°, сине-фиолетовый)
#   step  50 /  300 → hue 0.833 (300°, пурпурный) — и далее по кругу
# Насыщенность s=0.72, яркость v=0.88.


class RackShelfRenderMixin:
    """Вспомогательные методы рендеринга полок — подмешивается в *ModelViewWidget*."""

    @staticmethod
    def _cell_volume_color(cell_height_mm: float, step_mm: float) -> tuple[float, float, float]:
        """Цвет ячейки по 24-позиционному HSV-циклу (шаг 15°, полушаговый сдвиг).

        Стартовый hue зависит от step_mm типа стеллажа:
          step 10  / 100 → hue 0.500 (180° — #00FFFF голубой)
          step 20  / 150 → hue 0.583 (210° — ≈#0280FF синевато-голубой)
          step 30  / 200 → hue 0.667 (240° — #0000FF синий)
          и т.д. по +30° на каждый следующий типовой шаг.

        tiers = cell_height_mm / step_mm; каждые 2 тира → +15° (следующая позиция).

        Возвращает RGB-туплу (0.0–1.0) для PyVista.
        """
        step = max(1.0, float(step_mm))
        h = max(1.0, float(cell_height_mm))
        # Базовая позиция определяется по размеру шага разбиения (cell_height_mm),
        # а не по min step:
        # A/B: 10 -> pos0 (#00FFFF), 20 -> pos1 (#0280FF), 30 -> pos2 (#0000FF), ...
        # PALLET: 100 -> pos0, 150 -> pos1, 200 -> pos2, ...
        if step < 50.0:
            pos = int(round(h / 10.0)) - 1
        else:
            pos = int(round((h - 100.0) / 50.0))
        pos = max(0, int(pos)) % 24

        # Полная насыщенность/яркость: стартовые цвета совпадают с эталоном.
        hue = (0.5 + float(pos) / 24.0) % 1.0
        rgb = colorsys.hsv_to_rgb(hue, 1.0, 1.0)

        # Точно фиксируем эталонные стартовые якоря, озвученные в требованиях.
        anchor = {
            0: (0, 255, 255),   # 00FFFF for 10/100
            1: (2, 128, 255),   # 0280FF for 20/150
            2: (0, 0, 255),     # 0000FF for 30/200
        }.get(pos)
        if anchor is not None:
            return (anchor[0] / 255.0, anchor[1] / 255.0, anchor[2] / 255.0)
        return rgb

    def _clear_slot_shelf_actors(self: "ModelViewWidget", rack_id: str, clear_slot_id: str | None = None) -> None:
        rid = str(rack_id or "")
        target_slot = str(clear_slot_id or "")
        keys = [key for key in self._rack_shelf_actors.keys() if key[0] == rid and (not target_slot or key[1] == target_slot)]
        for key in keys:
            for actor in self._rack_shelf_actors.get(key, []):
                try:
                    self._plotter.remove_actor(actor)
                except Exception as _exc:
                    log_exception(__name__, "_clear_slot_shelf_actors", _exc)
            self._rack_shelf_actors.pop(key, None)
            if hasattr(self, "_rack_shelf_cell_links"):
                self._rack_shelf_cell_links.pop(key, None)
            if hasattr(self, "_rack_shelf_cell_links_indexed"):
                rid, sid = key
                idx_keys = [
                    k for k in list(self._rack_shelf_cell_links_indexed.keys())
                    if str(k[0]) == str(rid) and str(k[1]) == str(sid)
                ]
                for idx_key in idx_keys:
                    self._rack_shelf_cell_links_indexed.pop(idx_key, None)

    def _clear_slot_cell_actors(
        self: "ModelViewWidget",
        storage: dict[tuple[str, str], list[Any]],
        rack_id: str,
        clear_slot_id: str | None = None,
    ) -> None:
        rid = str(rack_id or "")
        target_slot = str(clear_slot_id or "")
        keys = [key for key in storage.keys() if key[0] == rid and (not target_slot or key[1] == target_slot)]
        for key in keys:
            for actor in storage.get(key, []):
                try:
                    self._plotter.remove_actor(actor)
                except Exception as _exc:
                    log_exception(__name__, "_clear_slot_cell_actors", _exc)
            storage.pop(key, None)

    def _clear_cell_actor_key(
        self: "ModelViewWidget",
        storage: dict[tuple[Any, ...], list[Any]],
        key: tuple[Any, ...],
    ) -> None:
        for actor in list(storage.get(tuple(key), []) or []):
            try:
                self._plotter.remove_actor(actor)
            except Exception as _exc:
                log_exception(__name__, "_clear_cell_actor_key", _exc)
        storage.pop(tuple(key), None)

    def clear_cell_grid_visualization(
        self: "ModelViewWidget",
        rack_id: str | None = None,
        slot_id: str | None = None,
    ) -> None:
        if not self._plotter:
            return
        rid = str(rack_id or "")
        sid = str(slot_id or "")
        if rid:
            self._clear_slot_cell_actors(self._cell_preview_actors, rid, sid or None)
            self._clear_slot_cell_actors(self._cell_final_actors, rid, sid or None)
            self._clear_slot_cell_actors(self._cell_dim_actors, rid, sid or None)
            # Чистим сохранённые эталонные границы.
            ref_bounds = getattr(self, "_cell_ref_bounds", {})
            stale = [k for k in list(ref_bounds.keys()) if isinstance(k, tuple) and str(k[0]) == rid and (not sid or str(k[1]) == sid)]
            for k in stale:
                ref_bounds.pop(k, None)
        else:
            for storage in (self._cell_preview_actors, self._cell_final_actors, self._cell_dim_actors):
                for key in list(storage.keys()):
                    for actor in storage.get(key, []):
                        try:
                            self._plotter.remove_actor(actor)
                        except Exception as _exc:
                            log_exception(__name__, "clear_cell_grid_visualization", _exc)
                    storage.pop(key, None)
            if hasattr(self, "_cell_ref_bounds"):
                self._cell_ref_bounds.clear()
        if hasattr(self, "_safe_render"):
            self._safe_render(min_interval_s=1.0 / 60.0)
        else:
            self._plotter.update()

    def set_rack_cell_volume_isolation(
        self: "ModelViewWidget",
        rack_id: str,
        slot_id: str | None = None,
        shelf_index: int | None = None,
    ) -> None:
        """Управление видимостью объёмов ячеек внутри стеллажа.

        slot_id=None → показать все объёмы + сводные метки стеллажа (уровень rack).
        slot_id задан → показать только (slot_id, shelf_index), сводные метки скрыть,
                        вместо них показать три размера изолированной полки (уровень shelf).
        """
        rid = str(rack_id or "")
        if not rid:
            return
        sid = str(slot_id or "") if slot_id else None
        sidx = int(max(1, int(shelf_index or 1))) if shelf_index is not None else None
        # ── видимость кратных объёмов ────────────────────────────────────────
        for key, actors in self._cell_final_actors.items():
            if not isinstance(key, tuple) or str(key[0]) != rid:
                continue
            if sid is None:
                visible = True
            else:
                try:
                    visible = str(key[1]) == sid and (sidx is None or int(key[2]) == sidx)
                except Exception as exc:
                    log_exception(__name__, "set_rack_cell_volume_isolation.key_parse", exc)
                    visible = False
            for actor in actors or []:
                try:
                    actor.SetVisibility(1 if visible else 0)
                except Exception as _exc:
                    log_exception(__name__, "set_rack_cell_volume_isolation", _exc)
        # ── dim-акторы ──────────────────────────────────────────────────────
        if sid is None:
            # Уровень rack: убрать изоляционные dim-акторы, показать сводные.
            for key in list(self._cell_dim_actors.keys()):
                if not isinstance(key, tuple) or str(key[0]) != rid:
                    continue
                if len(key) >= 2 and str(key[1]).startswith("__sum__"):
                    # Сводная метка — показываем.
                    for actor in self._cell_dim_actors[key] or []:
                        try:
                            actor.SetVisibility(1)
                        except Exception as _exc:
                            log_exception(__name__, "set_rack_cell_volume_isolation", _exc)
                else:
                    # Изоляционная метка — убираем.
                    self._clear_cell_actor_key(self._cell_dim_actors, key)
        else:
            # Уровень shelf: скрыть все сводные dim-акторы, нарисовать 3 размера для полки.
            for key in list(self._cell_dim_actors.keys()):
                if not isinstance(key, tuple) or str(key[0]) != rid:
                    continue
                if len(key) >= 2 and str(key[1]).startswith("__sum__"):
                    for actor in self._cell_dim_actors[key] or []:
                        try:
                            actor.SetVisibility(0)
                        except Exception as _exc:
                            log_exception(__name__, "set_rack_cell_volume_isolation", _exc)
                else:
                    self._clear_cell_actor_key(self._cell_dim_actors, key)
            isolated_key = (rid, sid, sidx if sidx is not None else 1)
            ref_bounds = getattr(self, "_cell_ref_bounds", {}).get(isolated_key)
            if ref_bounds is not None:
                self._draw_cell_dimension_lines(
                    isolated_key,
                    ref_bounds,
                    color="#000000",
                    text_color="#000000",
                )

    # ── Сводные метки шагового объёма ────────────────────────────────────────

    def _rebuild_rack_cell_dim_summary(self: "ModelViewWidget", rack_id: str) -> None:
        """Пересобрать сводные метки размеров шагового объёма для стеллажа.

        Один вертикальный размер на каждое уникальное значение высоты ячейки.
        Метка: «H мм × N» (N — кол-во полок с таким разбиением), либо «H мм» если N=1.
        """
        if not self._plotter or not _PV:
            return
        rid = str(rack_id or "")
        if not rid:
            return
        # Удалить старые сводные dim-акторы для этого стеллажа.
        sum_keys = [
            k for k in list(self._cell_dim_actors.keys())
            if isinstance(k, tuple) and str(k[0]) == rid and len(k) >= 2 and str(k[1]).startswith("__sum__")
        ]
        for k in sum_keys:
            self._clear_cell_actor_key(self._cell_dim_actors, k)
        ref_bounds_all = getattr(self, "_cell_ref_bounds", {})
        # Собрать все slot_keys этого стеллажа с уже отрисованными final-акторами.
        rack_slots: list[tuple[tuple, tuple]] = []
        for key, actors in self._cell_final_actors.items():
            if not isinstance(key, tuple) or str(key[0]) != rid:
                continue
            if not actors:
                continue
            bounds = ref_bounds_all.get(key)
            if bounds is None:
                continue
            rack_slots.append((key, bounds))
        if not rack_slots:
            return
        # Группировать по высоте ячейки (округлённой до 1 мм).
        groups: dict[int, list[tuple[tuple, tuple]]] = {}
        for key, bounds in rack_slots:
            h_mm = int(round(float(bounds[5]) - float(bounds[4])))
            groups.setdefault(h_mm, []).append((key, bounds))
        # Для каждой группы — одна метка у самого левого (min_x наименьший) слота.
        for h_mm, entries in groups.items():
            # Выбираем слот с наименьшим min_x.
            anchor_key, anchor_bounds = min(entries, key=lambda e: float(e[1][0]))
            n = len(entries)
            label_text = f"{h_mm} мм × {n}" if n > 1 else f"{h_mm} мм"
            sum_key = (rid, f"__sum__{h_mm}", 0)
            self._draw_ref_cell_single_dim(
                sum_key,
                anchor_bounds,
                color="#4DB3FF",
                text_color="#4DB3FF",
                label_override=label_text,
            )

    def rebuild_rack_cell_dim_summary(self: "ModelViewWidget", rack_id: str) -> None:
        """Публичный метод: пересобрать сводные метки после рендеринга одного слота или стеллажа."""
        self._rebuild_rack_cell_dim_summary(rack_id)

    def _is_rack_visible_for_cell_grid(self: "ModelViewWidget", rack_id: str) -> bool:
        rid = str(rack_id or "")
        if not rid:
            return False
        entry = self._get_rack_entry(rid, self._shelf_target_zone_id)
        if entry is None:
            entry = self._get_rack_entry(rid)
        if entry is None:
            return False
        for actor in list(entry.get("actors") or []):
            if actor is None:
                continue
            try:
                return bool(actor.GetVisibility())
            except Exception as exc:
                log_exception(__name__, "_is_rack_visible_for_cell_grid", exc)
                continue
        return True

    def _apply_cell_grid_edit_isolation_visibility(self: "ModelViewWidget") -> None:
        active = bool(getattr(self, "_cell_grid_edit_isolation_active", False))
        target_rid = str(getattr(self, "_cell_grid_edit_rack_id", "") or "")
        target_sid = str(getattr(self, "_cell_grid_edit_slot_id", "") or "")
        target_index = int(max(1, int(getattr(self, "_cell_grid_edit_shelf_index", 1) or 1)))
        for storage_name in ("_cell_preview_actors", "_cell_final_actors", "_cell_dim_actors"):
            storage = getattr(self, storage_name, {}) or {}
            is_preview_storage = str(storage_name) == "_cell_preview_actors"
            for key, actors in storage.items():
                if not isinstance(key, tuple) or len(key) < 3:
                    continue
                rid = str(key[0] or "")
                sid = str(key[1] or "")
                try:
                    shelf_index = int(max(1, int(key[2] or 1)))
                except Exception as exc:
                    log_exception(__name__, "_apply_cell_grid_edit_isolation_visibility", exc)
                    shelf_index = 1
                visible = bool(self._is_rack_visible_for_cell_grid(rid))
                if visible and active:
                    is_target = bool(
                        rid == target_rid
                        and sid == target_sid
                        and shelf_index == target_index
                    )
                    # В edit-режиме оставляем только preview редактируемой сетки.
                    if is_preview_storage:
                        visible = is_target
                    else:
                        visible = False
                for actor in list(actors or []):
                    try:
                        actor.SetVisibility(1 if visible else 0)
                    except Exception as _exc:
                        log_exception(__name__, "_apply_cell_grid_edit_isolation_visibility", _exc)
    def set_cell_grid_edit_isolation(
        self: "ModelViewWidget",
        rack_id: str,
        slot_id: str,
        shelf_index: int,
        active: bool,
    ) -> None:
        self._cell_grid_edit_isolation_active = bool(active)
        self._cell_grid_edit_rack_id = str(rack_id or "") if active else None
        self._cell_grid_edit_slot_id = str(slot_id or "") if active else None
        self._cell_grid_edit_shelf_index = int(max(1, int(shelf_index or 1))) if active else None
        self._apply_cell_grid_edit_isolation_visibility()
        if hasattr(self, "_safe_render"):
            self._safe_render(min_interval_s=1.0 / 75.0)
        elif self._plotter:
            self._plotter.update()

    def _get_slot_bounds(self: "ModelViewWidget", rack_id: str, slot_id: str) -> tuple[float, float, float, float, float, float] | None:
        rid = str(rack_id or "")
        sid = str(slot_id or "")
        if not rid or not sid:
            return None
        entry = self._get_rack_entry(rid, self._shelf_target_zone_id)
        if entry is None:
            entry = self._get_rack_entry(rid)
        if entry is None:
            return None
        slot_actor = (entry.get("slot_actors") or {}).get(sid)
        if slot_actor is None:
            return None
        try:
            bounds = slot_actor.GetBounds()
            if not bounds:
                return None
            return tuple(float(v) for v in bounds[:6])  # type: ignore[return-value]
        except Exception as exc:
            log_exception(__name__, "_get_slot_bounds", exc)
            return None

    @staticmethod
    def _pick_useful_height_mm(useful_values: list[float], shelf_index: int) -> float:
        if not useful_values:
            return 100.0
        idx = max(1, int(shelf_index or 1))
        # useful heights обычно задаются как интервалы между полками.
        pick_idx = min(len(useful_values) - 1, max(0, idx - 1))
        return max(1.0, float(useful_values[pick_idx]))

    @staticmethod
    def _resolve_shelf_useful_height_mm(
        *,
        rack_type: str,
        shelf_index: int,
        shelf_count: int,
        useful_values: list[float],
        useful_under_bottom_mm: float | None,
        top_useful_default_mm: float,
    ) -> float:
        idx = max(1, int(shelf_index or 1))
        count = max(1, int(shelf_count or 1))
        kind = str(rack_type or "").strip().upper()
        top_default = max(1.0, float(top_useful_default_mm or 1.0))
        safe_values = [max(1.0, float(v)) for v in list(useful_values or [])]

        if idx >= count:
            return top_default

        if kind == "PALLET":
            if idx == 1:
                under = useful_under_bottom_mm
                if under is None:
                    under = safe_values[0] if safe_values else top_default
                return max(1.0, float(under))
            value_idx = min(len(safe_values) - 1, max(0, idx - 2))
            if safe_values:
                return max(1.0, float(safe_values[value_idx]))
            return top_default

        value_idx = min(len(safe_values) - 1, max(0, idx - 1))
        if safe_values:
            return max(1.0, float(safe_values[value_idx]))
        return top_default

    @staticmethod
    def _cell_layout_constraints_for_rack_type(
        rack_type: str,
    ) -> tuple[float, float, float]:
        """Вернуть ограничения разметки ячеек: (отступ_по_ширине, отступ_по_глубине, useful_top_mm)."""
        kind = str(rack_type or "").strip().upper()
        if kind == "A":
            return (3.0, 2.0, 600.0)
        if kind == "B":
            return (25.0, 7.0, 600.0)
        return (25.0, 3.0, 1200.0)

    @staticmethod
    def _pallet_inner_width_between_posts_mm(
        rack_params: dict[str, Any],
        shelf_params: dict[str, Any] | None = None,
    ) -> float:
        # Для PALLET внутренний проем между стойками задается center_spans_mm.
        # Это и есть рабочая ширина первого (напольного) уровня.
        spans_raw = (rack_params or {}).get("center_spans_mm")
        try:
            spans = [float(v) for v in (spans_raw or []) if float(v) > 0.0]
        except Exception as exc:
            log_exception(__name__, "_pallet_inner_width_between_posts_mm", exc)
            spans = []
        if spans:
            return max(1.0, float(sum(spans)))
        try:
            fallback = float(((shelf_params or {}).get("shelf_width_mm")) or 0.0)
            if fallback > 1.0:
                return fallback
        except Exception as _exc:
            log_exception(__name__, "_pallet_inner_width_between_posts_mm", _exc)
        return 0.0

    @staticmethod
    def _actor_bounds(actor: Any) -> tuple[float, float, float, float, float, float] | None:
        if actor is None:
            return None
        try:
            bounds = actor.GetBounds()
        except Exception as exc:
            log_exception(__name__, "_actor_bounds.get_bounds", exc)
            return None
        if not bounds or len(bounds) < 6:
            return None
        try:
            return tuple(float(v) for v in bounds[:6])  # type: ignore[return-value]
        except Exception as exc:
            log_exception(__name__, "_actor_bounds.float_cast", exc)
            return None

    def _rack_structure_bounds(self: "ModelViewWidget", entry: dict[str, Any]) -> tuple[float, float, float, float, float, float] | None:
        if not entry:
            return None
        slot_actor_ids = {id(actor) for actor in ((entry.get("slot_actors") or {}).values()) if actor is not None}
        merged: list[float] | None = None
        for actor in list(entry.get("actors") or []):
            if actor is None or id(actor) in slot_actor_ids:
                continue
            bounds = self._actor_bounds(actor)
            if bounds is None:
                continue
            if merged is None:
                merged = [float(v) for v in bounds]
                continue
            merged[0] = min(merged[0], float(bounds[0]))
            merged[1] = max(merged[1], float(bounds[1]))
            merged[2] = min(merged[2], float(bounds[2]))
            merged[3] = max(merged[3], float(bounds[3]))
            merged[4] = min(merged[4], float(bounds[4]))
            merged[5] = max(merged[5], float(bounds[5]))
        if merged is None:
            return None
        return tuple(merged)  # type: ignore[return-value]

    def _find_pillar_actor_bounds(
        self: "ModelViewWidget",
        entry: dict[str, Any],
    ) -> tuple[float, float, float, float, float, float] | None:
        if not entry:
            return None
        slot_actor_ids = {id(actor) for actor in ((entry.get("slot_actors") or {}).values()) if actor is not None}
        rack_params = dict((entry or {}).get("params") or {})
        expected_w = max(1.0, float(rack_params.get("footprint_width_mm") or 0.0))
        expected_d = max(1.0, float(rack_params.get("footprint_depth_mm") or 0.0))
        best_bounds = None
        best_score = None
        for actor in list(entry.get("actors") or []):
            if actor is None or id(actor) in slot_actor_ids:
                continue
            bounds = self._actor_bounds(actor)
            if bounds is None:
                continue
            x_len = max(0.0, float(bounds[1] - bounds[0]))
            y_len = max(0.0, float(bounds[3] - bounds[2]))
            z_len = max(0.0, float(bounds[5] - bounds[4]))
            if z_len < 100.0:
                continue
            score = min(
                abs(x_len - expected_w) + abs(y_len - expected_d),
                abs(x_len - expected_d) + abs(y_len - expected_w),
            )
            if best_score is None or score < best_score:
                best_score = score
                best_bounds = bounds
        return best_bounds

    def _pallet_inner_width_between_posts_by_bbox(
        self: "ModelViewWidget",
        entry: dict[str, Any],
        *,
        width_axis: str,
    ) -> float:
        pillar_bounds = self._find_pillar_actor_bounds(entry or {})
        if pillar_bounds is None:
            return 0.0
        outer_x = max(0.0, float(pillar_bounds[1] - pillar_bounds[0]))
        outer_y = max(0.0, float(pillar_bounds[3] - pillar_bounds[2]))
        outer_span = outer_x if str(width_axis).upper() == "X" else outer_y

        post_thickness = 0.0
        rack_params = dict((entry or {}).get("params") or {})
        try:
            mesh_getter = getattr(self, "_get_pillar_base_mesh", None)
            if callable(mesh_getter):
                base_mesh, _pillar_h = mesh_getter(rack_params)
                if base_mesh is not None:
                    mb = base_mesh.bounds
                    t_x = max(0.0, float(mb[1] - mb[0]))
                    t_y = max(0.0, float(mb[3] - mb[2]))
                    post_thickness = max(0.0, min(t_x, t_y))
        except Exception as exc:
            log_exception(__name__, "_pallet_inner_width_between_posts_by_bbox", exc)
            post_thickness = 0.0

        if post_thickness <= 0.0:
            return max(1.0, float(outer_span))
        # Внутренний проём = внешний span - 2 * толщина стойки.
        # Для PALLET это даёт корректный эталон (например, 3546 мм при соответствующем bbox).
        return max(1.0, float(outer_span - (2.0 * post_thickness)))

    def _pallet_inner_bounds_between_posts_by_bbox(
        self: "ModelViewWidget",
        entry: dict[str, Any],
        *,
        width_axis: str,
    ) -> tuple[float, float] | None:
        pillar_bounds = self._find_pillar_actor_bounds(entry or {})
        if pillar_bounds is None:
            return None
        post_thickness = 0.0
        rack_params = dict((entry or {}).get("params") or {})
        try:
            mesh_getter = getattr(self, "_get_pillar_base_mesh", None)
            if callable(mesh_getter):
                base_mesh, _pillar_h = mesh_getter(rack_params)
                if base_mesh is not None:
                    mb = base_mesh.bounds
                    t_x = max(0.0, float(mb[1] - mb[0]))
                    t_y = max(0.0, float(mb[3] - mb[2]))
                    post_thickness = max(0.0, min(t_x, t_y))
        except Exception as exc:
            log_exception(__name__, "_pallet_inner_bounds_between_posts_by_bbox", exc)
            post_thickness = 0.0
        axis = str(width_axis).upper()
        if axis == "Y":
            outer_min = float(pillar_bounds[2])
            outer_max = float(pillar_bounds[3])
        else:
            outer_min = float(pillar_bounds[0])
            outer_max = float(pillar_bounds[1])
        if post_thickness > 0.0:
            inner_min = float(outer_min + post_thickness)
            inner_max = float(outer_max - post_thickness)
        else:
            inner_min = float(outer_min)
            inner_max = float(outer_max)
        if inner_max <= inner_min:
            return None
        return (inner_min, inner_max)

    def _rendered_shelf_bounds(
        self: "ModelViewWidget",
        rack_id: str,
        slot_id: str,
        shelf_index: int,
    ) -> tuple[float, float, float, float, float, float] | None:
        key = (str(rack_id or ""), str(slot_id or ""))
        actors = list((self._rack_shelf_actors or {}).get(key) or [])
        idx = max(1, int(shelf_index or 1)) - 1
        if 0 <= idx < len(actors):
            bounds = self._actor_bounds(actors[idx])
            if bounds is not None:
                return bounds
        for actor in actors:
            bounds = self._actor_bounds(actor)
            if bounds is not None:
                return bounds
        return None

    def _resolve_cell_volume_limits(
        self: "ModelViewWidget",
        rack_id: str,
        slot_id: str,
        payload: dict[str, Any],
    ) -> tuple[float, float, float, float, float, float] | None:
        """Границы доступного объёма разметки до дискретизации по размеру ячейки."""
        slot_bounds = self._get_slot_bounds(rack_id, slot_id)
        if slot_bounds is None:
            return None
        min_x, max_x, min_y, max_y, slot_min_z, slot_max_z = slot_bounds

        entry = self._get_rack_entry(str(rack_id or ""), self._shelf_target_zone_id)
        if entry is None:
            entry = self._get_rack_entry(str(rack_id or ""))
        rack_params = dict((entry or {}).get("params") or {})
        rack_type = str(payload.get("rack_type") or rack_params.get("rack_type") or "PALLET").strip().upper()
        width_inset, depth_inset, top_useful_default = self._cell_layout_constraints_for_rack_type(rack_type)

        shelf_id = str(payload.get("shelf_id") or "")
        shelf_index = max(1, int(payload.get("shelf_number_index") or 1))

        shelf_params = None
        rack_layout = []
        try:
            if hasattr(self, "get_rack_shelf_layout"):
                rack_layout = self.get_rack_shelf_layout(str(rack_id or ""))
        except Exception as exc:
            log_exception(__name__, "_resolve_cell_volume_limits.get_rack_shelf_layout", exc)
            rack_layout = []
        for item in rack_layout:
            if str(item.get("slot_id") or "") == str(slot_id or ""):
                shelf_params = dict(item.get("params") or {})
                break

        structure_bounds = self._rack_structure_bounds(entry or {})
        rack_bounds = structure_bounds
        if rack_bounds is None:
            rack_bounds = (float(min_x), float(max_x), float(min_y), float(max_y), float(slot_min_z), float(slot_max_z))
        rack_min_x, rack_max_x, rack_min_y, rack_max_y, _rack_min_z, rack_top_z = rack_bounds

        shelf_bounds = self._rendered_shelf_bounds(str(rack_id or ""), str(slot_id or ""), shelf_index)
        if shelf_bounds is None:
            shelf_bounds = (float(min_x), float(max_x), float(min_y), float(max_y), float(slot_min_z), float(slot_max_z))
        shelf_min_x, shelf_max_x, shelf_min_y, shelf_max_y, _shelf_min_z, _shelf_max_z = shelf_bounds

        if rack_type == "A":
            plan_min_x = float(shelf_min_x + width_inset)
            plan_max_x = float(shelf_max_x - width_inset)
            plan_min_y = float(shelf_min_y + depth_inset)
            plan_max_y = float(shelf_max_y - depth_inset)
        elif rack_type == "B":
            plan_min_x = float(shelf_min_x + width_inset)
            plan_max_x = float(shelf_max_x - width_inset)
            plan_min_y = float(shelf_min_y + depth_inset)
            plan_max_y = float(shelf_max_y - depth_inset)
        else:
            plan_min_x = float(shelf_min_x + width_inset)
            plan_max_x = float(shelf_max_x - width_inset)
            plan_min_y = float(shelf_min_y + depth_inset)
            plan_max_y = float(shelf_max_y - depth_inset)

        if plan_max_x <= plan_min_x:
            plan_min_x = float(shelf_min_x)
            plan_max_x = float(shelf_max_x)
        if plan_max_y <= plan_min_y:
            plan_min_y = float(shelf_min_y)
            plan_max_y = float(shelf_max_y)
        if rack_type == "PALLET" and shelf_index == 1:
            width_axis = "X"
            if float(shelf_max_y - shelf_min_y) > float(shelf_max_x - shelf_min_x):
                width_axis = "Y"
            inner_bounds = self._pallet_inner_bounds_between_posts_by_bbox(
                entry or {},
                width_axis=width_axis,
            )
            if inner_bounds is not None:
                if width_axis == "Y":
                    plan_min_y = max(float(shelf_min_y), float(inner_bounds[0]))
                    plan_max_y = min(float(shelf_max_y), float(inner_bounds[1]))
                else:
                    plan_min_x = max(float(shelf_min_x), float(inner_bounds[0]))
                    plan_max_x = min(float(shelf_max_x), float(inner_bounds[1]))
            inner_width = 0.0
            if inner_bounds is None:
                inner_width = self._pallet_inner_width_between_posts_by_bbox(
                    entry or {},
                    width_axis=width_axis,
                )
                if inner_width <= 1.0:
                    inner_width = self._pallet_inner_width_between_posts_mm(rack_params, shelf_params)
            if inner_bounds is None and inner_width > 1.0:
                center_x = float((shelf_min_x + shelf_max_x) * 0.5)
                center_y = float((shelf_min_y + shelf_max_y) * 0.5)
                span_x = max(0.0, float(shelf_max_x - shelf_min_x))
                span_y = max(0.0, float(shelf_max_y - shelf_min_y))
                if span_x >= span_y:
                    half_inner = float(min(inner_width * 0.5, span_x * 0.5))
                    plan_min_x = float(center_x - half_inner)
                    plan_max_x = float(center_x + half_inner)
                else:
                    half_inner = float(min(inner_width * 0.5, span_y * 0.5))
                    plan_min_y = float(center_y - half_inner)
                    plan_max_y = float(center_y + half_inner)

        if not shelf_id:
            return (plan_min_x, plan_max_x, plan_min_y, plan_max_y, float(slot_min_z), float(slot_max_z))

        base_heights = []
        useful_values = []
        shelf_count = 1
        if shelf_params:
            base_heights = [float(v) for v in (shelf_params.get("shelf_base_heights_mm") or [])]
            useful_values = [float(v) for v in (shelf_params.get("useful_heights_mm") or [])]
            try:
                shelf_count = max(1, int(shelf_params.get("shelves_count") or len(base_heights) or 1))
            except Exception as exc:
                log_exception(__name__, "_resolve_cell_volume_limits.shelf_count", exc)
                shelf_count = max(1, int(len(base_heights) or 1))

        if not base_heights:
            return (plan_min_x, plan_max_x, plan_min_y, plan_max_y, float(slot_min_z), float(slot_max_z))

        tree_shelf_count = int(shelf_count + 1) if rack_type == "PALLET" else int(shelf_count)
        if rack_type == "PALLET":
            if shelf_index <= 1:
                base_z = float(slot_min_z)
            else:
                base_idx = min(len(base_heights) - 1, max(0, shelf_index - 2))
                base_z = float(slot_min_z + float(base_heights[base_idx]))
        else:
            base_idx = min(len(base_heights) - 1, max(0, shelf_index - 1))
            base_z = float(slot_min_z + float(base_heights[base_idx]))

        useful_under_bottom_mm = None
        if shelf_params:
            try:
                raw_under = shelf_params.get("useful_under_bottom_mm")
                useful_under_bottom_mm = float(raw_under) if raw_under is not None else None
            except Exception as exc:
                log_exception(__name__, "_resolve_cell_volume_limits.useful_under_bottom", exc)
                useful_under_bottom_mm = None
        useful_h = float(
            self._resolve_shelf_useful_height_mm(
                rack_type=rack_type,
                shelf_index=shelf_index,
                shelf_count=tree_shelf_count,
                useful_values=useful_values,
                useful_under_bottom_mm=useful_under_bottom_mm,
                top_useful_default_mm=top_useful_default,
            )
        )

        top_limit_z = float(base_z + max(1.0, useful_h))
        if rack_type == "PALLET":
            # Для всех уровней PALLET, у которых есть верхняя физическая полка,
            # полезная высота считается только до bbox этой верхней полки минус 195 мм.
            # Дополнительные ограничения для таких уровней не применяются.
            if shelf_index < tree_shelf_count:
                upper_shelf_bounds = self._rendered_shelf_bounds(
                    str(rack_id or ""),
                    str(slot_id or ""),
                    int(shelf_index),
                )
                if upper_shelf_bounds is not None:
                    upper_cap_z = float(upper_shelf_bounds[5] - 195.0)
                    if upper_cap_z > base_z:
                        top_limit_z = float(upper_cap_z)
            elif structure_bounds is not None and float(rack_top_z) > float(base_z):
                # Верхний уровень PALLET (без верхней полки): ограничение по конструкции.
                is_zone_cap_like = abs(float(rack_top_z) - float(slot_max_z)) <= 1.0
                if not is_zone_cap_like:
                    top_limit_z = float(min(top_limit_z, float(rack_top_z)))
        elif structure_bounds is not None and float(rack_top_z) > float(base_z):
            # Для A/B оставляем ограничение по конструкции.
            is_zone_cap_like = abs(float(rack_top_z) - float(slot_max_z)) <= 1.0
            if not is_zone_cap_like:
                top_limit_z = float(min(top_limit_z, float(rack_top_z)))
        if top_limit_z <= base_z:
            top_limit_z = float(base_z + 1.0)

        return (plan_min_x, plan_max_x, plan_min_y, plan_max_y, base_z, top_limit_z)

    def get_cell_slot_usable_dimensions_mm(
        self: "ModelViewWidget",
        rack_id: str,
        slot_id: str,
        *,
        shelf_id: str = "",
        shelf_number_index: int = 1,
        rack_type: str = "",
    ) -> tuple[float, float, float] | None:
        """Публичный helper: usable размеры разметки ячеек (w, d, h) для слота/полки."""
        payload = {
            "shelf_id": str(shelf_id or ""),
            "shelf_number_index": int(max(1, int(shelf_number_index or 1))),
            "rack_type": str(rack_type or ""),
        }
        bounds = self._resolve_cell_volume_limits(str(rack_id or ""), str(slot_id or ""), payload)
        if bounds is None:
            return None
        min_x, max_x, min_y, max_y, min_z, max_z = bounds
        return (
            max(0.0, float(max_x - min_x)),
            max(0.0, float(max_y - min_y)),
            max(0.0, float(max_z - min_z)),
        )

    def _resolve_cell_volume_bounds(
        self: "ModelViewWidget",
        rack_id: str,
        slot_id: str,
        payload: dict[str, Any],
    ) -> tuple[float, float, float, float, float, float] | None:
        limits = self._resolve_cell_volume_limits(rack_id, slot_id, dict(payload or {}))
        if limits is None:
            return None
        min_x, max_x, min_y, max_y, base_z, top_limit_z = limits
        grid_z = max(1, int(payload.get("grid_z") or 1))
        cell_h = max(1.0, float(payload.get("cell_height_mm") or 1.0))

        requested_top = base_z + cell_h * float(grid_z)
        top_z = min(top_limit_z, requested_top)
        fit_count_z = max(0, int((top_z - base_z) // cell_h))
        top_z = base_z + fit_count_z * cell_h
        if top_z <= base_z:
            top_z = min(top_limit_z, base_z + cell_h)
        return (min_x, max_x, min_y, max_y, base_z, top_z)

    @staticmethod
    def _cell_grid_edges(
        bounds: tuple[float, float, float, float, float, float],
        grid_r: int,
        grid_c: int,
        grid_z: int,
    ) -> tuple[list[float], list[float], list[float]]:
        min_x, max_x, min_y, max_y, min_z, max_z = bounds
        gx = max(1, int(grid_c or 1))
        gy = max(1, int(grid_r or 1))
        gz = max(1, int(grid_z or 1))
        dx = (max_x - min_x) / gx
        dy = (max_y - min_y) / gy
        dz = (max_z - min_z) / gz
        xs = [float(min_x + dx * i) for i in range(gx + 1)]
        ys = [float(min_y + dy * i) for i in range(gy + 1)]
        zs = [float(min_z + dz * i) for i in range(gz + 1)]
        return xs, ys, zs

    @staticmethod
    def _axis_edges_from_step(
        axis_min: float,
        axis_max: float,
        step: float,
        requested_count: int,
    ) -> list[float]:
        lo = float(axis_min)
        hi = float(axis_max)
        cell_step = max(0.001, float(step))
        max_fit = max(0, int((hi - lo) // cell_step))
        count = max(0, min(int(requested_count or 0), max_fit))
        if count <= 0:
            return [lo, min(hi, lo + cell_step)]
        return [float(lo + i * cell_step) for i in range(count + 1)]

    @staticmethod
    def _build_cell_grid_lines_mesh(xs: list[float], ys: list[float], zs: list[float]):
        if not _PV:
            return None
        points: list[tuple[float, float, float]] = []
        lines: list[int] = []
        seen: set[tuple[tuple[float, float, float], tuple[float, float, float]]] = set()

        def _add_segment(p0: tuple[float, float, float], p1: tuple[float, float, float]) -> None:
            key = (p0, p1) if p0 <= p1 else (p1, p0)
            if key in seen:
                return
            seen.add(key)
            i0 = len(points)
            points.append(p0)
            i1 = len(points)
            points.append(p1)
            lines.extend([2, i0, i1])

        x0, x1 = xs[0], xs[-1]
        y0, y1 = ys[0], ys[-1]
        z0, z1 = zs[0], zs[-1]

        # Линии только на внешних поверхностях объёма (без внутренней 3D-сетки).
        for y in ys:
            _add_segment((x0, y, z0), (x1, y, z0))
            _add_segment((x0, y, z1), (x1, y, z1))
        for z in zs:
            _add_segment((x0, y0, z), (x1, y0, z))
            _add_segment((x0, y1, z), (x1, y1, z))

        for x in xs:
            _add_segment((x, y0, z0), (x, y1, z0))
            _add_segment((x, y0, z1), (x, y1, z1))
        for z in zs:
            _add_segment((x0, y0, z), (x0, y1, z))
            _add_segment((x1, y0, z), (x1, y1, z))

        for x in xs:
            _add_segment((x, y0, z0), (x, y0, z1))
            _add_segment((x, y1, z0), (x, y1, z1))
        for y in ys:
            _add_segment((x0, y, z0), (x0, y, z1))
            _add_segment((x1, y, z0), (x1, y, z1))

        mesh = pv.PolyData(points)
        mesh.lines = lines
        return mesh

    def _closest_cell_index_to_camera(
        self: "ModelViewWidget",
        xs: list[float],
        ys: list[float],
        zs: list[float],
    ) -> tuple[int, int, int]:
        if not self._plotter:
            return (0, 0, 0)
        cam = getattr(self._plotter, "camera_position", None)
        if not isinstance(cam, (list, tuple)) or len(cam) < 1:
            return (0, 0, 0)
        try:
            cx, cy, cz = cam[0]
        except Exception as exc:
            log_exception(__name__, "_closest_cell_index_to_camera", exc)
            return (0, 0, 0)
        best = (0, 0, 0)
        best_d2 = None
        for ix in range(max(1, len(xs) - 1)):
            mx = (xs[ix] + xs[ix + 1]) * 0.5
            for iy in range(max(1, len(ys) - 1)):
                my = (ys[iy] + ys[iy + 1]) * 0.5
                for iz in range(max(1, len(zs) - 1)):
                    mz = (zs[iz] + zs[iz + 1]) * 0.5
                    d2 = (mx - float(cx)) ** 2 + (my - float(cy)) ** 2 + (mz - float(cz)) ** 2
                    if best_d2 is None or d2 < best_d2:
                        best_d2 = d2
                        best = (ix, iy, iz)
        return best

    def _draw_ref_cell_single_dim(
        self: "ModelViewWidget",
        slot_key: tuple[Any, ...],
        cell_bounds: tuple[float, float, float, float, float, float],
        *,
        color: str = "#4DB3FF",
        text_color: str | None = None,
        label_override: str | None = None,
    ) -> None:
        """Один вертикальный размер (высота ячейки) у правого переднего ребра."""
        if not self._plotter or not _PV:
            return
        min_x, max_x, min_y, max_y, min_z, max_z = cell_bounds
        cell_h = max(1.0, float(max_z - min_z))
        offset_xy = max(0.02, cell_h * 0.005)
        lx = max_x + offset_xy
        ly = min_y - offset_xy
        label_lift = max(1.0, cell_h * 0.12)
        line = pv.Line((lx, ly, min_z), (lx, ly, max_z))
        label_text = label_override if label_override is not None else f"{cell_h:.0f} мм"
        label_color = str(text_color or color)
        actors: list[Any] = []
        try:
            actors.append(self._plotter.add_mesh(
                line, color=str(color), line_width=2.0, pickable=False, reset_camera=False,
            ))
            actors.append(self._plotter.add_point_labels(
                [((lx, ly, (min_z + max_z) * 0.5 + label_lift))],
                [label_text],
                font_size=16,
                text_color=label_color,
                shape=None,
                show_points=False,
                always_visible=True,
                pickable=False,
                reset_camera=False,
            ))
        except Exception as exc:
            log_exception(__name__, "_draw_ref_cell_single_dim", exc)
            for actor in actors:
                try:
                    self._plotter.remove_actor(actor)
                except Exception as _exc:
                    log_exception(__name__, "_draw_ref_cell_single_dim", _exc)
            actors = []
        existing = list(self._cell_dim_actors.get(slot_key) or [])
        self._cell_dim_actors[slot_key] = existing + actors

    def _draw_cell_dimension_lines(
        self: "ModelViewWidget",
        slot_key: tuple[Any, ...],
        cell_bounds: tuple[float, float, float, float, float, float],
        *,
        color: str = "#4DB3FF",
        text_color: str | None = None,
    ) -> None:
        if not self._plotter or not _PV:
            return
        self._clear_cell_actor_key(self._cell_dim_actors, slot_key)
        min_x, max_x, min_y, max_y, min_z, max_z = cell_bounds
        cell_h = max(1.0, float(max_z - min_z))
        offset = max(0.05, min(0.5, cell_h * 0.01))
        label_lift = max(2.0, min(10.0, cell_h * 0.25))
        line_x = pv.Line((min_x, min_y, min_z + offset), (max_x, min_y, min_z + offset))
        line_y = pv.Line((max_x, min_y, min_z + offset), (max_x, max_y, min_z + offset))
        line_z = pv.Line((max_x, max_y, min_z + offset), (max_x, max_y, max_z + offset))
        label_points = [
            ((min_x + max_x) * 0.5, min_y, min_z + label_lift),
            (max_x, (min_y + max_y) * 0.5, min_z + label_lift),
            (max_x, max_y, (min_z + max_z) * 0.5 + label_lift),
        ]
        labels = [
            f"{abs(max_x - min_x):.0f} мм",
            f"{abs(max_y - min_y):.0f} мм",
            f"{abs(max_z - min_z):.0f} мм",
        ]
        actors: list[Any] = []
        label_color = str(text_color or color)
        try:
            actors.append(self._plotter.add_mesh(line_x, color=str(color), line_width=2.4, pickable=False, reset_camera=False))
            actors.append(self._plotter.add_mesh(line_y, color=str(color), line_width=2.4, pickable=False, reset_camera=False))
            actors.append(self._plotter.add_mesh(line_z, color=str(color), line_width=2.4, pickable=False, reset_camera=False))
            actors.append(
                self._plotter.add_point_labels(
                    label_points,
                    labels,
                    font_size=18,
                    text_color=label_color,
                    shape=None,
                    show_points=False,
                    always_visible=True,
                    pickable=False,
                    reset_camera=False,
                )
            )
        except Exception as exc:
            log_exception(__name__, "_draw_cell_dimension_lines", exc)
            for actor in actors:
                try:
                    self._plotter.remove_actor(actor)
                except Exception as _exc:
                    log_exception(__name__, "_draw_cell_dimension_lines", _exc)
            actors = []
        self._cell_dim_actors[slot_key] = actors

    def _render_cell_grid_for_slot(
        self: "ModelViewWidget",
        rack_id: str,
        slot_id: str,
        payload: dict[str, Any] | None,
        *,
        preview: bool,
    ) -> bool:
        if not _PV or not self._plotter:
            return False
        rid = str(rack_id or "")
        sid = str(slot_id or "")
        if not rid or not sid:
            return False
        raw = dict(payload or {})
        shelf_index = max(1, int(raw.get("shelf_number_index") or 1))
        grid_r = max(1, int(raw.get("grid_r") or 1))
        grid_c = max(1, int(raw.get("grid_c") or 1))
        grid_z = max(1, int(raw.get("grid_z") or 1))
        cell_w = max(1.0, float(raw.get("cell_width_mm") or 1.0))
        cell_d = max(1.0, float(raw.get("cell_depth_mm") or 1.0))
        cell_h = max(1.0, float(raw.get("cell_height_mm") or 1.0))
        step_mm = max(1.0, float(raw.get("step_mm") or 10.0))
        bounds = self._resolve_cell_volume_bounds(rid, sid, raw)
        if bounds is None:
            return False
        slot_key = (rid, sid, int(shelf_index))
        target_storage = self._cell_preview_actors if preview else self._cell_final_actors
        self._clear_cell_actor_key(target_storage, slot_key)
        self._clear_cell_actor_key(self._cell_dim_actors, slot_key)
        min_x, max_x, min_y, max_y, min_z, max_z = bounds
        xs = self._axis_edges_from_step(min_x, max_x, cell_w, grid_c)
        ys = self._axis_edges_from_step(min_y, max_y, cell_d, grid_r)
        zs = self._axis_edges_from_step(min_z, max_z, cell_h, grid_z)
        if len(xs) < 2 or len(ys) < 2 or len(zs) < 2:
            return False
        max_x = xs[-1]
        max_y = ys[-1]
        max_z = zs[-1]
        actors: list[Any] = []
        opacity = 0.18 if preview else 0.95
        if preview:
            color = "#AFC1CF"
        else:
            color = self._cell_volume_color(cell_h, step_mm)
        try:
            body = pv.Box(bounds=(min_x, max_x, min_y, max_y, min_z, max_z))
            actor_body = self._plotter.add_mesh(
                body,
                color=color,
                opacity=opacity,
                show_edges=False,
                pickable=False,
                reset_camera=False,
            )
            actors.append(actor_body)
            actor_body_edges = self._plotter.add_mesh(
                body,
                style="wireframe",
                color="#00D6FF",
                line_width=2.0,
                opacity=1.0,
                pickable=False,
                reset_camera=False,
            )
            actors.append(actor_body_edges)
        except Exception as _exc:
            log_exception(__name__, "_render_cell_grid_for_slot", _exc)
        if preview:
            try:
                line_mesh = self._build_cell_grid_lines_mesh(xs, ys, zs)
                if line_mesh is not None:
                    actor_lines = self._plotter.add_mesh(
                        line_mesh,
                        color="#00D6FF",
                        line_width=2.2,
                        opacity=1.0,
                        pickable=False,
                        reset_camera=False,
                    )
                    actors.append(actor_lines)
            except Exception as _exc:
                log_exception(__name__, "_render_cell_grid_for_slot", _exc)
        # Эталонная ячейка в базовом углу: наглядный единичный объём.
        reference_cell_bounds = (
            xs[0],
            xs[1],
            ys[0],
            ys[1],
            zs[0],
            zs[1],
        )
        try:
            ref_box = pv.Box(bounds=reference_cell_bounds)
            ref_actor = self._plotter.add_mesh(
                ref_box,
                color="#FFD400",
                opacity=0.95,
                show_edges=False,
                pickable=False,
                reset_camera=False,
            )
            actors.append(ref_actor)
            ref_actor_edges = self._plotter.add_mesh(
                ref_box,
                style="wireframe",
                color="#00D6FF",
                line_width=1.1,
                opacity=1.0,
                pickable=False,
                reset_camera=False,
            )
            actors.append(ref_actor_edges)
        except Exception as _exc:
            log_exception(__name__, "_render_cell_grid_for_slot", _exc)
        target_storage[slot_key] = actors
        # Размерные линии: в preview — все три оси; в final — сохраняем bounds для сводного label.
        if preview:
            self._draw_cell_dimension_lines(
                slot_key,
                reference_cell_bounds,
                color="#000000",
                text_color="#000000",
            )
        else:
            # Сохраняем эталонные границы — сводная метка строится в _rebuild_rack_cell_dim_summary.
            if not hasattr(self, "_cell_ref_bounds"):
                self._cell_ref_bounds = {}
            self._cell_ref_bounds[slot_key] = reference_cell_bounds
            self._clear_cell_actor_key(self._cell_dim_actors, slot_key)
        self._apply_cell_grid_edit_isolation_visibility()
        if hasattr(self, "_safe_render"):
            self._safe_render(min_interval_s=1.0 / 75.0)
        else:
            self._plotter.update()
        return bool(actors)

    def render_cell_grid_preview_for_slot(
        self: "ModelViewWidget",
        rack_id: str,
        slot_id: str,
        payload: dict[str, Any] | None,
    ) -> bool:
        return self._render_cell_grid_for_slot(rack_id, slot_id, payload, preview=True)

    def render_cell_grid_final_for_slot(
        self: "ModelViewWidget",
        rack_id: str,
        slot_id: str,
        payload: dict[str, Any] | None,
    ) -> bool:
        raw = dict(payload or {})
        preview_key = (
            str(rack_id or ""),
            str(slot_id or ""),
            int(max(1, int(raw.get("shelf_number_index") or 1))),
        )
        # Удаляем preview-акторы ДО финального рендера, чтобы сетка не мигала.
        self._clear_cell_actor_key(self._cell_preview_actors, preview_key)
        ok = self._render_cell_grid_for_slot(rack_id, slot_id, payload, preview=False)
        return ok

    def _render_shelves_for_slot(self: "ModelViewWidget", rack_id: str, slot_id: str, payload: dict[str, Any] | None = None) -> dict[str, Any]:
        if not _PV or not self._plotter:
            return {}
        rid = str(rack_id or "")
        sid = str(slot_id or "")
        if not rid or not sid:
            return {}
        entry = self._get_rack_entry(rid, self._shelf_target_zone_id)
        if entry is None:
            return {}
        rack_rotation = int(entry.get("rotation", 0)) % 360
        slot_actor = (entry.get("slot_actors") or {}).get(sid)
        if slot_actor is None:
            return {}
        previous_payload = dict(self._rack_shelf_params.get((rid, sid)) or {})
        source_payload = payload if payload is not None else previous_payload
        payload = dict(source_payload or {})
        params = dict(entry.get("params") or {})
        rack_type = str(params.get("rack_type") or payload.get("rack_type") or "A")
        norms = self._shelf_norms_for_rack_type(rack_type)
        depth = int(params.get("depth_mm") or params.get("footprint_depth_mm") or payload.get("shelf_depth_mm") or 500)
        spans = [int(v) for v in (params.get("span_widths_mm") or []) if int(v) > 0]
        span_index = self._slot_index_from_id(sid)
        if 0 <= span_index < len(spans):
            span_width = spans[span_index]
        elif spans:
            span_width = spans[0]
        else:
            span_width = int(params.get("footprint_width_mm") or 1000)

        base_mesh = self._get_shelf_base_mesh(rack_type, depth, span_width)
        if base_mesh is None:
            return {}
        bounds = slot_actor.GetBounds()
        min_x, max_x, min_y, max_y, min_z, max_z = [float(v) for v in bounds]
        useful_h = float(payload.get("useful_height_1_mm") or 400.0)
        start_h = float(payload.get("bottom_shelf_height_mm") or 0.0)
        count = max(1, int(payload.get("shelves_count") or 1))
        step = float(norms["step_mm"])
        shelf_h = float(norms["shelf_height_mm"])
        min_inter = float(norms["min_inter_shelf_mm"])
        base_offset_top = float(norms["base_surface_offset_from_bbox_top_mm"])
        min_base = float(norms["min_base_height_mm"])
        min_useful = float(norms["min_useful_height_mm"])
        mode = str(payload.get("height_mode") or "uniform").strip().lower()
        useful_values_raw = payload.get("useful_heights_mm")
        useful_values = [float(v) for v in useful_values_raw] if isinstance(useful_values_raw, list) and useful_values_raw else [float(useful_h)]
        start_h = max(min_base, start_h)
        start_h = self._round_up_to_step(start_h, step, start_from=min_base)
        useful_values = [
            self._round_up_to_step(max(min_useful, val), step, start_from=min_useful)
            for val in useful_values
        ]

        def _inter_gap(index: int) -> float:
            source_idx = 0 if mode != "individual" else min(index, len(useful_values) - 1)
            requested_inter = float(useful_values[source_idx]) + shelf_h
            inter_mm = max(min_inter, requested_inter)
            return self._round_up_to_step(inter_mm, step, start_from=min_inter)

        min_new_gap = self._round_up_to_step(
            max(min_inter, float(min_useful + shelf_h)),
            step,
            start_from=min_inter,
        )

        previous_mode = str(previous_payload.get("height_mode") or "uniform").strip().lower()
        previous_count = max(1, int(previous_payload.get("shelves_count") or 1))
        previous_useful_raw = previous_payload.get("useful_heights_mm")
        previous_useful = (
            [float(v) for v in previous_useful_raw]
            if isinstance(previous_useful_raw, list) and previous_useful_raw
            else [float(previous_payload.get("useful_height_1_mm") or min_useful)]
        )
        previous_useful = [self._round_up_to_step(max(min_useful, value), step) for value in previous_useful]
        previous_rel_heights_raw = previous_payload.get("shelf_base_heights_mm")
        previous_rel_heights = (
            [float(v) for v in previous_rel_heights_raw]
            if isinstance(previous_rel_heights_raw, list) and previous_rel_heights_raw
            else []
        )
        payload_rel_heights_raw = payload.get("shelf_base_heights_mm")
        payload_rel_heights = (
            [float(v) for v in payload_rel_heights_raw]
            if isinstance(payload_rel_heights_raw, list) and payload_rel_heights_raw
            else []
        )
        can_shift_stack = (
            bool(previous_rel_heights)
            and previous_count == count
            and previous_mode == mode
            and previous_useful == useful_values
        )
        relative_heights: list[float] = []
        if payload_rel_heights:
            # Для сохранённых полок предпочитаем явно сохранённые базовые высоты,
            # чтобы загруженные стеллажи отображались сразу в сохранённых позициях.
            relative_heights = [max(min_base, float(value)) for value in payload_rel_heights[:count]]
            while len(relative_heights) < count:
                last_height = float(relative_heights[-1]) if relative_heights else float(start_h)
                relative_heights.append(float(last_height + min_new_gap))
        elif can_shift_stack:
            old_start = float(previous_payload.get("bottom_shelf_height_mm") or previous_rel_heights[0])
            delta = float(start_h - old_start)
            relative_heights = [max(min_base, float(value) + delta) for value in previous_rel_heights[:count]]
        elif (
            bool(previous_rel_heights)
            and previous_count < count
            and previous_mode == mode
        ):
            relative_heights = [float(value) for value in previous_rel_heights[:previous_count]]
            if mode == "individual":
                if len(relative_heights) >= 2:
                    extension_gap = max(min_new_gap, float(relative_heights[-1] - relative_heights[-2]))
                else:
                    extension_gap = _inter_gap(max(0, previous_count - 1))
            else:
                extension_gap = _inter_gap(0)
            while len(relative_heights) < count:
                last_height = float(relative_heights[-1]) if relative_heights else float(start_h)
                relative_heights.append(float(last_height + extension_gap))
        else:
            offset_from_start = 0.0
            for index in range(count):
                relative_heights.append(float(start_h + offset_from_start))
                if index + 1 < count:
                    offset_from_start += _inter_gap(index)

        self._clear_slot_shelf_actors(rid, sid)
        slot_key = (rid, sid)
        self._rack_shelf_actors[slot_key] = []
        mb = base_mesh.bounds
        local_top_z = float(mb[5])
        local_base_surface_z = local_top_z - base_offset_top
        rack_bottom_z = min_z
        rack_top_z = max_z
        placed = 0
        placed_rel_heights: list[float] = []
        for index, rel_height in enumerate(relative_heights):
            base_surface_world_z = rack_bottom_z + rel_height
            # Полка должна полностью помещаться в габарит стеллажа.
            shelf_top_world_z = float(base_surface_world_z + base_offset_top)
            if shelf_top_world_z > rack_top_z:
                continue
            mesh = base_mesh.copy(deep=True)
            if rack_rotation:
                mesh.rotate_z(float(rack_rotation), point=(0.0, 0.0, 0.0), inplace=True)
            mesh.translate(
                (
                    (min_x + max_x) / 2.0,
                    (min_y + max_y) / 2.0,
                    base_surface_world_z - local_base_surface_z,
                ),
                inplace=True,
            )
            try:
                actor_opacity = 0.95
                if hasattr(self, "_shelf_cell_visual_opacity"):
                    actor_opacity = float(self._shelf_cell_visual_opacity(rid, sid, index + 1))
                actor = self._plotter.add_mesh(
                    mesh,
                    color=self._sanitize_shelf_color(payload.get("shelf_color")),
                    opacity=float(actor_opacity),
                    show_edges=False,
                    pickable=False,
                    render=False,
                )
            except TypeError:
                actor_opacity = 0.95
                if hasattr(self, "_shelf_cell_visual_opacity"):
                    actor_opacity = float(self._shelf_cell_visual_opacity(rid, sid, index + 1))
                actor = self._plotter.add_mesh(
                    mesh,
                    color=self._sanitize_shelf_color(payload.get("shelf_color")),
                    opacity=float(actor_opacity),
                    show_edges=False,
                    pickable=False,
                )
            self._rack_shelf_actors[slot_key].append(actor)
            placed += 1
            placed_rel_heights.append(float(rel_height))
        self._plotter.update()
        requested_count = max(1, int(count))
        valid_count = max(0, int(placed))
        has_overflow = bool(valid_count < requested_count)
        normalized: dict[str, Any] = dict(payload)
        normalized["rack_type"] = str(rack_type).strip().upper()
        normalized["shelf_depth_mm"] = int(depth)
        normalized["shelf_width_mm"] = int(span_width)
        normalized["bottom_shelf_height_mm"] = float(relative_heights[0] if relative_heights else start_h)
        normalized["shelves_count"] = int(requested_count)
        normalized["valid_shelves_count"] = int(valid_count)
        normalized["placement_overflow"] = bool(has_overflow)
        normalized["height_mode"] = "individual" if mode == "individual" else "uniform"
        normalized["shelf_base_heights_mm"] = [float(value) for value in relative_heights]
        normalized["shelf_base_heights_valid_mm"] = [float(value) for value in placed_rel_heights]
        normalized_values = useful_values[:max(1, requested_count - 1)] if mode == "individual" else useful_values[:1]
        if not normalized_values:
            normalized_values = [float(min_useful)]
        normalized["useful_heights_mm"] = [float(v) for v in normalized_values]
        normalized["useful_height_1_mm"] = float(normalized_values[0])
        return normalized

    @staticmethod
    def _sanitize_shelf_color(value: Any) -> str:
        raw = str(value or "").strip().upper()
        if raw.startswith("#") and len(raw) == 9:
            return raw[:7]
        if raw.startswith("#") and len(raw) == 7:
            return raw
        return "#59B6E6"

    @staticmethod
    def _slot_index_from_id(slot_id: str) -> int:
        match = re.search(r"(\d+)$", str(slot_id or ""))
        if not match:
            return 0
        return max(0, int(match.group(1)) - 1)

    def _shelf_norms_for_rack_type(self: "ModelViewWidget", rack_type: str) -> dict[str, float]:
        key = str(rack_type or "").strip().upper()
        return dict(self._SHELF_NORMS.get(key, self._SHELF_NORMS["PALLET"]))

    @staticmethod
    def _round_up_to_step(value: float, step: float, start_from: float = 0.0) -> float:
        step_value = max(0.001, float(step))
        base = float(start_from)
        current = max(base, float(value))
        delta = max(0.0, current - base)
        multiplier = int((delta + step_value - 1e-9) // step_value)
        stepped = base + multiplier * step_value
        if stepped < current:
            stepped += step_value
        return float(stepped)

    def _get_shelf_base_mesh(self: "ModelViewWidget", rack_type: str, depth_mm: int, width_mm: int):
        if not _PV:
            return None
        kind = str(rack_type or "").strip().upper()
        depth = int(depth_mm or 500)
        width = int(width_mm or 1000)
        key = (kind, depth, width)
        cached = self._shelf_mesh_cache.get(key)
        if cached is not None:
            return cached
        path = self._find_shelf_model_path(kind, depth, width)
        if path is None or not path.exists():
            return None
        try:
            mesh = pv.read(str(path))
            bounds = mesh.bounds
            cx = (bounds[0] + bounds[1]) / 2.0
            cy = (bounds[2] + bounds[3]) / 2.0
            cz = (bounds[4] + bounds[5]) / 2.0
            mesh.translate((-cx, -cy, -cz), inplace=True)
            self._shelf_mesh_cache[key] = mesh
            return mesh
        except Exception as exc:
            log_exception(__name__, "_get_shelf_base_mesh", exc)
            return None

    def _find_shelf_model_path(self: "ModelViewWidget", rack_type: str, depth_mm: int, width_mm: int) -> Path | None:
        root = getattr(self, "_rack_models_root", None)
        if root is None:
            return None
        kind = str(rack_type or "").strip().upper()
        if kind == "A":
            folder = Path(root) / "shelfs_type_a"
            pattern = re.compile(r"shelf_(\d+)_(\d+)\.stl$", re.IGNORECASE)
        elif kind == "B":
            folder = Path(root) / "shelfs_type_b"
            pattern = re.compile(r"shelf_type_b_(\d+)_(\d+)\.stl$", re.IGNORECASE)
        else:
            folder = Path(root) / "shelf_type_pallet"
            pattern = re.compile(r"shelf_type_pallet_(\d+)_(\d+)\.stl$", re.IGNORECASE)
        if not folder.exists():
            return None
        best: tuple[int, Path] | None = None
        target_d = int(depth_mm)
        target_w = int(width_mm)
        for file_path in folder.glob("*.stl"):
            m = pattern.search(file_path.name)
            if not m:
                continue
            d = int(m.group(1))
            w = int(m.group(2))
            score = abs(d - target_d) + abs(w - target_w)
            if best is None or score < best[0]:
                best = (score, file_path)
                if score == 0:
                    break
        return best[1] if best else None

    def _slot_contour_mesh(self: "ModelViewWidget", slot_actor):
        if not _PV or slot_actor is None:
            return None
        try:
            bounds = slot_actor.GetBounds()
        except Exception as exc:
            log_exception(__name__, "_slot_contour_mesh", exc)
            return None
        if not bounds:
            return None
        min_x, max_x, min_y, max_y, min_z, max_z = bounds
        points = [
            (float(min_x), float(min_y), float(min_z)),
            (float(max_x), float(min_y), float(min_z)),
            (float(max_x), float(max_y), float(min_z)),
            (float(min_x), float(max_y), float(min_z)),
            (float(min_x), float(min_y), float(max_z)),
            (float(max_x), float(min_y), float(max_z)),
            (float(max_x), float(max_y), float(max_z)),
            (float(min_x), float(max_y), float(max_z)),
        ]
        edges = (
            (0, 1), (1, 2), (2, 3), (3, 0),
            (4, 5), (5, 6), (6, 7), (7, 4),
            (0, 4), (1, 5), (2, 6), (3, 7),
        )
        lines = []
        for i, j in edges:
            lines.extend([2, i, j])
        contour = pv.PolyData(points)
        contour.lines = lines
        return contour


# ---------------------------------------------------------------------------
# Module workflow notes
# ---------------------------------------------------------------------------
# 1) Задача модуля:
#    Построение и отрисовка геометрии полок.
#
# 2) Последовательность действий и вызовов:
#    A. Класс RackShelfRenderMixin: точки входа
#       Публичные методы отсутствуют; сценарий запускается через методы родительских модулей и внутренние обработчики.
#
#    B. RackShelfRenderMixin: основной сценарий:
#       RackShelfRenderMixin._find_shelf_model_path(...)
#         Назначение: находит shelf model path в рамках текущего сценария модуля.
#
#    C. RackShelfRenderMixin: завершение и очистка:
#       RackShelfRenderMixin._clear_slot_shelf_actors(...)
#         Назначение: очищает slot shelf actors в рамках текущего сценария модуля.
#
#    D. RackShelfRenderMixin: вспомогательные расчёты:
#       RackShelfRenderMixin._render_shelves_for_slot(...)
#         Назначение: выполняет шаг "render shelves for slot" в рамках текущего сценария модуля.
#         Последовательность внутренних вызовов:
#         -> RackShelfRenderMixin._shelf_norms_for_rack_type(...)
#         -> RackShelfRenderMixin._slot_index_from_id(...)
#         -> RackShelfRenderMixin._get_shelf_base_mesh(...)
#         -> RackShelfRenderMixin._round_up_to_step(...)
#         -> RackShelfRenderMixin._clear_slot_shelf_actors(...)
#         -> RackShelfRenderMixin._sanitize_shelf_color(...)
#       RackShelfRenderMixin._sanitize_shelf_color(...)
#         Назначение: очищает и нормализует shelf color в рамках текущего сценария модуля.
#       RackShelfRenderMixin._slot_index_from_id(...)
#         Назначение: выполняет шаг "slot index from id" в рамках текущего сценария модуля.
#       RackShelfRenderMixin._shelf_norms_for_rack_type(...)
#         Назначение: выполняет шаг "shelf norms for rack type" в рамках текущего сценария модуля.
#       RackShelfRenderMixin._round_up_to_step(...)
#         Назначение: выполняет шаг "round up to step" в рамках текущего сценария модуля.
#       RackShelfRenderMixin._get_shelf_base_mesh(...)
#         Назначение: возвращает shelf base mesh в рамках текущего сценария модуля.
#         Последовательность внутренних вызовов:
#         -> RackShelfRenderMixin._find_shelf_model_path(...)
#       RackShelfRenderMixin._slot_contour_mesh(...)
#         Назначение: выполняет шаг "slot contour mesh" в рамках текущего сценария модуля.
#
# 3) Важные ограничения и инварианты:
#    - Модуль выполняется в составе ModelViewWidget и использует согласованные поля состояния self._... .
#    - Все операции с актёрами и камерой выполняются только при валидном self._plotter с безопасной обработкой исключений.
#    - Геометрическая визуализация зависит от pyvista/vtk; при недоступности модуль обязан завершать шаг без падения сценария.
#    - Межмодульная связность: только через фасад model_view; прямые обращения между zone, rack, shelf, cell запрещены.
#    - Очистка состояния должна быть идемпотентной: повторный вызов не меняет корректное состояние в ошибочное.
#
# 4) Правило сопровождения:
#    - Любое изменение сценария должно сопровождаться обновлением этого блока с сохранением фактического порядка вызовов.
#    - При добавлении метода указывать его место в цепочке сценария (запуск, основной шаг, завершение, вспомогательная логика).
