Add Dispatch_V0.1.1

This commit is contained in:
2026-04-29 08:18:54 +04:00
commit a7ede6ded4
404 changed files with 39167 additions and 0 deletions

View File

@@ -0,0 +1,32 @@
# -*- coding: utf-8 -*-
# gui/components/model_view/__init__.py
"""Внутренние mixin-модули для ModelViewWidget."""
from gui.components.model_view._mv_model_loading import ModelLoadingMixin
from gui.components.model_view._mv_zones import ZoneManagementMixin
from gui.components.model_view._mv_interaction import InteractionMixin
from gui.components.model_view._mv_visual import VisualHelpersMixin
from gui.components.model_view._mv_grid_core import GridCoreMixin
from gui.components.model_view._mv_dimension_lines import DimensionLinesMixin
from gui.components.model_view._mv_racks import RackPlacementMixin
from gui.components.model_view._mv_presentation import ScenePresentationMixin
from gui.components.model_view._mv_scene_modes import SceneModesMixin
from gui.components.model_view._mv_rack_transition import RackCameraTransitionMixin
from gui.components.model_view._mv_zone_transition import ZoneCameraTransitionMixin
from gui.components.model_view._mv_racks_io import RackPlacementIOMixin
__all__ = [
"ModelLoadingMixin",
"ZoneManagementMixin",
"InteractionMixin",
"VisualHelpersMixin",
"GridCoreMixin",
"DimensionLinesMixin",
"RackPlacementMixin",
"ScenePresentationMixin",
"SceneModesMixin",
"RackCameraTransitionMixin",
"ZoneCameraTransitionMixin",
"RackPlacementIOMixin",
]

View File

@@ -0,0 +1,323 @@
# -*- coding: utf-8 -*-
# gui/components/model_view/_interaction_scenario.py
"""Базовый сценарий взаимодействия и менеджер сценариев.
Архитектура:
- InteractionScenario — базовый класс с методами-хуками для событий.
- CameraPolicy — декларативная политика камеры для сценария.
- InteractionManager — стек сценариев + делегация событий.
"""
from __future__ import annotations
from enum import Enum, auto
from typing import TYPE_CHECKING
from PySide6.QtCore import Qt, QEvent
from PySide6.QtGui import QGuiApplication
from error_logger import log_exception
if TYPE_CHECKING: # pragma: no cover
from gui.components.model_view_widget import ModelViewWidget
# ── CameraPolicy ────────────────────────────────────────────────────────────
class CameraPolicy(Enum):
"""Политика камеры для сценария."""
FREE = auto() # ПКМ вращение, СКМ панорамирование
TOP_VIEW = auto() # Только pan/zoom, без вращения
LOCKED = auto() # Камера заблокирована
# ── InteractionScenario ─────────────────────────────────────────────────────
class InteractionScenario:
"""Базовый класс сценария взаимодействия.
Каждый конкретный сценарий переопределяет нужные хуки.
Менеджер передаёт события текущему сценарию через эти методы.
"""
name: str = ""
camera_policy: CameraPolicy = CameraPolicy.FREE
def on_activate(self, mv: "ModelViewWidget") -> None:
"""Вызывается при входе в сценарий (push/replace)."""
def on_deactivate(self, mv: "ModelViewWidget") -> None:
"""Вызывается при выходе из сценария (pop/replace/reset)."""
def on_mouse_press(self, mv: "ModelViewWidget", event) -> bool:
"""ЛКМ/ПКМ/СКМ нажатие. Вернуть True если событие поглощено."""
return False
def on_mouse_move(self, mv: "ModelViewWidget", event) -> bool:
"""Движение мыши (с нажатой кнопкой). Вернуть True если поглощено."""
return False
def on_mouse_release(self, mv: "ModelViewWidget", event) -> bool:
"""Отпускание кнопки мыши. Вернуть True если поглощено."""
return False
def on_hover(self, mv: "ModelViewWidget", event) -> None:
"""Движение мыши без нажатых кнопок (hover)."""
def on_key_press(self, mv: "ModelViewWidget", event) -> bool:
"""Нажатие клавиши. Вернуть True если поглощено."""
return False
def on_resume(self, mv: "ModelViewWidget") -> None:
"""Вызывается когда сценарий снова стал верхним (после pop вышестоящего).
По умолчанию вызывает on_activate повторно, чтобы восстановить обработчики.
"""
self.on_activate(mv)
def on_widget_mouse_press(self, mv: "ModelViewWidget", event) -> bool:
"""mousePressEvent виджета (не eventFilter). Вернуть True если поглощено."""
return False
def on_widget_mouse_move(self, mv: "ModelViewWidget", event) -> bool:
"""mouseMoveEvent виджета. Вернуть True если поглощено."""
return False
def on_widget_mouse_release(self, mv: "ModelViewWidget", event) -> bool:
"""mouseReleaseEvent виджета. Вернуть True если поглощено."""
return False
# ── InteractionManager ──────────────────────────────────────────────────────
class InteractionManager:
"""Стек сценариев взаимодействия.
- Всегда есть base-layer (камерный сценарий) — он не в стеке.
- Стек содержит доменные сценарии (contour_edit, rack_placement...).
- Верхний элемент стека — текущий сценарий.
- ESC → pop() → автоматический on_deactivate().
- reset() → очищает весь стек.
"""
def __init__(self, mv: "ModelViewWidget") -> None:
self._mv = mv
self._stack: list[InteractionScenario] = []
self._camera: InteractionScenario | None = None
# -- Публичный API --------------------------------------------------------
def set_camera_scenario(self, scenario: InteractionScenario) -> None:
"""Установить базовый камерный сценарий (всегда активен)."""
self._camera = scenario
@property
def current(self) -> InteractionScenario | None:
"""Текущий (верхний) доменный сценарий, или None."""
return self._stack[-1] if self._stack else None
@property
def current_name(self) -> str:
"""Имя текущего сценария."""
top = self.current
return top.name if top else ""
def is_active(self, name: str) -> bool:
"""Проверить, есть ли сценарий с данным именем в стеке."""
key = str(name)
return any(s.name == key for s in self._stack)
def push(self, scenario: InteractionScenario) -> None:
"""Войти в новый сценарий (добавить на вершину стека)."""
scenario.on_activate(self._mv)
self._stack.append(scenario)
def pop(self) -> InteractionScenario | None:
"""Выйти из текущего сценария, вернуть его."""
if not self._stack:
return None
scenario = self._stack.pop()
scenario.on_deactivate(self._mv)
# Восстановить обработчики нижележащего сценария
new_top = self.current
if new_top is not None:
new_top.on_resume(self._mv)
return scenario
def pop_by_name(self, name: str) -> InteractionScenario | None:
"""Удалить сценарий по имени (из любого места стека)."""
key = str(name)
for i, s in enumerate(self._stack):
if s.name == key:
was_top = (i == len(self._stack) - 1)
scenario = self._stack.pop(i)
scenario.on_deactivate(self._mv)
if was_top:
new_top = self.current
if new_top is not None:
new_top.on_resume(self._mv)
return scenario
return None
def replace(self, scenario: InteractionScenario) -> InteractionScenario | None:
"""Заменить текущий сценарий новым (без промежуточного on_resume)."""
old = None
if self._stack:
old = self._stack.pop()
old.on_deactivate(self._mv)
self.push(scenario)
return old
def reset(self) -> None:
"""Сбросить весь стек (ESC-уровень)."""
while self._stack:
scenario = self._stack.pop()
try:
scenario.on_deactivate(self._mv)
except Exception as e:
log_exception(__name__, "reset", e)
# -- Диспетчеризация событий eventFilter ----------------------------------
def dispatch_event_filter(self, watched, event) -> bool | None:
"""Маршрутизация события из eventFilter.
Returns:
True — событие поглощено.
False — событие НЕ должно обрабатываться дальше.
None — передать в super().eventFilter().
"""
top = self.current
etype = event.type()
# 1. Нажатие клавиши → текущий сценарий
if etype == QEvent.Type.KeyPress:
if top and top.on_key_press(self._mv, event):
return True
# 2. Mouse press/move/release → текущий сценарий, затем камера
if etype == QEvent.Type.MouseButtonPress:
if top and top.on_mouse_press(self._mv, event):
return True
if self._camera and self._dispatch_camera_press(event):
return True
if etype == QEvent.Type.MouseMove:
# robust-классификация drag vs hover:
# учитываем Qt event buttons, глобальные кнопки и внутренние drag-флаги камеры.
ev_buttons = Qt.MouseButton.NoButton
global_buttons = Qt.MouseButton.NoButton
try:
if hasattr(event, "buttons"):
ev_buttons = event.buttons()
except Exception as e:
log_exception(__name__, "dispatch_event_filter", e)
ev_buttons = Qt.MouseButton.NoButton
try:
global_buttons = QGuiApplication.mouseButtons()
except Exception as e:
log_exception(__name__, "dispatch_event_filter", e)
global_buttons = Qt.MouseButton.NoButton
has_buttons = bool(
ev_buttons
or global_buttons
or getattr(self._mv, "_cam_rotate_active", False)
or getattr(self._mv, "_cam_pan_active", False)
)
if has_buttons:
# Движение с зажатой кнопкой
if top and top.on_mouse_move(self._mv, event):
return True
cam_result = self._dispatch_camera_move(event)
if cam_result is not None:
return cam_result
# Не отдаём drag-события в VTK-style, чтобы исключить побочный захват камеры.
return False
else:
# Hover (без кнопок)
if top:
top.on_hover(self._mv, event)
# В origin-flow hover нужен для preview-маркера,
# но не должен уходить в VTK interactor style.
if top.name == "origin_point":
# Защита от ложного swallow после завершения origin-сценария:
# поглощаем hover только пока реально подключены origin-hover handlers.
has_hover_handlers = bool(
getattr(self._mv, "_hover_screen_handler", None)
or getattr(self._mv, "_hover_handler", None)
)
if has_hover_handlers:
return True
# Камера не обрабатывает hover
if etype == QEvent.Type.MouseButtonRelease:
if top and top.on_mouse_release(self._mv, event):
return True
cam_result = self._dispatch_camera_release(event)
if cam_result is not None:
return cam_result
return None
# -- Диспетчеризация mousePressEvent/Move/Release виджета -----------------
def dispatch_widget_mouse_press(self, event) -> bool:
"""Делегация mousePressEvent виджета текущему сценарию."""
top = self.current
if top and top.on_widget_mouse_press(self._mv, event):
return True
return False
def dispatch_widget_mouse_move(self, event) -> bool:
"""Делегация mouseMoveEvent виджета текущему сценарию."""
top = self.current
if top and top.on_widget_mouse_move(self._mv, event):
return True
return False
def dispatch_widget_mouse_release(self, event) -> bool:
"""Делегация mouseReleaseEvent виджета текущему сценарию."""
top = self.current
if top and top.on_widget_mouse_release(self._mv, event):
return True
return False
# -- Камерные делегации ---------------------------------------------------
def _dispatch_camera_press(self, event) -> bool:
if not self._camera:
return False
if getattr(self._mv, "_camera_locked", False):
return False
top = self.current
policy = top.camera_policy if top else CameraPolicy.FREE
if policy == CameraPolicy.LOCKED:
return False
# TOP_VIEW: блокировать вращение (ПКМ), разрешить панорамирование (СКМ)
if policy == CameraPolicy.TOP_VIEW:
if hasattr(event, "button") and event.button() == Qt.MouseButton.RightButton:
# Поглощаем ПКМ, чтобы событие не ушло в VTK-style (иначе возможен zoom/pan).
return True
return self._camera.on_mouse_press(self._mv, event)
def _dispatch_camera_move(self, event) -> bool | None:
if not self._camera:
return None
if getattr(self._mv, "_camera_locked", False):
return None
top = self.current
policy = top.camera_policy if top else CameraPolicy.FREE
if policy == CameraPolicy.LOCKED:
return None
return self._camera.on_mouse_move(self._mv, event) or None
def _dispatch_camera_release(self, event) -> bool | None:
if not self._camera:
return None
if getattr(self._mv, "_camera_locked", False):
return None
top = self.current
policy = top.camera_policy if top else CameraPolicy.FREE
if policy == CameraPolicy.TOP_VIEW:
if hasattr(event, "button") and event.button() == Qt.MouseButton.RightButton:
return True
return self._camera.on_mouse_release(self._mv, event) or None

View File

@@ -0,0 +1,362 @@
# -*- coding: utf-8 -*-
# gui/components/model_view/_mv_dim_lines_grid.py
"""Основные методы размерных линий (инициализация, вкл/выкл, перерисовка, подсветка)."""
from __future__ import annotations
import time
from typing import List, Optional, Tuple, TYPE_CHECKING
if TYPE_CHECKING: # pragma: no cover
from gui.components.model_view_widget import ModelViewWidget
try:
import pyvista as pv
import numpy as np
except ImportError: # pragma: no cover
pv = None
np = None
from error_logger import log_exception
from gui.components.model_view._mv_dimension_lines import ( # noqa: E501
_DIM_LINE_COLOR_ORIGIN, _DIM_LINE_COLOR_LAST,
_DIM_TEXT_COLOR_ORIGIN, _DIM_TEXT_COLOR_LAST,
_EXTENSION_LINE_COLOR, _LINE_WIDTH, _EXTENSION_LINE_WIDTH, _FONT_SIZE,
_DIM_OFFSET_1, _DIM_OFFSET_2, _EXTENSION_OVERSHOOT,
_LABEL_CLEARANCE, _LABEL_Z_LAYER_STEP, _SNAP_TRIGGER_FRACTION,
_HIGHLIGHT_COLOR, _HIGHLIGHT_RADIUS, _HIGHLIGHT_OPACITY,
_THROTTLE_S, _Pt3, _Seg,
)
class DimLineCoreMixin:
"""Миксин: основная отрисовка размерных линий для ModelViewWidget."""
def init_dimension_lines(self: "ModelViewWidget") -> None:
"""Инициализировать внутреннее состояние (вызывать из __init__)."""
self._dim_actors: list = []
self._dim_highlight_actor = None
self._dim_origin: Optional[Tuple[float, float, float]] = None
self._dim_last_point: Optional[Tuple[float, float]] = None
self._dim_current_node: Optional[Tuple[float, float]] = None
self._dim_enabled = False
self._dim_last_redraw_t: float = 0.0
self._measure_nodes: list[tuple[float, float, float]] = []
self._measure_hover_node: Optional[Tuple[float, float, float]] = None
self._measure_point_a: Optional[Tuple[float, float, float]] = None
self._measure_point_b: Optional[Tuple[float, float, float]] = None
self._measure_actors: list = []
self._measure_highlight_actor = None
def enable_dimension_lines(self: "ModelViewWidget", origin: Tuple[float, float, float]) -> None:
"""Включить размерные линии. *origin* — базовая точка."""
self.init_dimension_lines()
self._dim_origin = origin
self._dim_last_point = None
self._dim_current_node = None
self._dim_enabled = True
def disable_dimension_lines(self: "ModelViewWidget") -> None:
"""Выключить и очистить размерные линии."""
self._clear_dim_actors()
self._clear_dim_highlight()
self._dim_enabled = False
self._dim_origin = None
self._dim_last_point = None
self._dim_current_node = None
def set_dim_last_point(self: "ModelViewWidget", pt: Optional[Tuple[float, float]]) -> None:
"""Обновить «последнюю созданную точку» контура."""
self._dim_last_point = pt
if self._dim_current_node is not None:
self._redraw_dim_lines()
def handle_dim_hover(self: "ModelViewWidget", x: float, y: float) -> None:
"""Обработать перемещение курсора: триггерный snap к узлу сетки."""
if bool(getattr(self, "_contour_aux_hidden", False)):
return
if not self._dim_enabled or not self._dim_origin:
return
now = time.monotonic()
if now - self._dim_last_redraw_t < _THROTTLE_S:
return
step = max(1.0, float(getattr(self, "_current_zone_size", 500.0)))
trigger_r2 = (step * _SNAP_TRIGGER_FRACTION) ** 2
best_node: Optional[Tuple[float, float]] = None
best_d2: Optional[float] = None
for nx, ny in getattr(self, "_grid_nodes", []):
d2 = (nx - x) ** 2 + (ny - y) ** 2
if best_d2 is None or d2 < best_d2:
best_d2 = d2
best_node = (nx, ny)
if best_node is None or best_d2 is None:
return
if best_d2 > trigger_r2:
return
if self._dim_current_node == best_node:
return
self._dim_current_node = best_node
self._dim_last_redraw_t = now
self._redraw_dim_lines()
def _redraw_dim_lines(self: "ModelViewWidget") -> None:
"""Полная перерисовка всех размерных линий + подсветка узла."""
if bool(getattr(self, "_contour_aux_hidden", False)):
return
if not self._dim_enabled or self._dim_origin is None:
return
if not self._plotter or pv is None or np is None:
return
node = self._dim_current_node
if node is None:
return
self._clear_dim_actors()
ox, oy, oz = self._dim_origin
cx, cy = node
z = oz + 1.0 # чуть выше пола
# Контейнеры для сбора геометрии
ext_segs: List[_Seg] = [] # намеренно не отрисовываются
origin_segs: List[_Seg] = [] # жёлтые размерные
origin_labels: List[Tuple[_Pt3, str]] = []
# ── 1. От origin до узла ────────────────────────────────────
self._collect_dimension_pair(
ox, oy, cx, cy, z, _DIM_OFFSET_1, "",
x_side="bottom",
y_side="right",
label_layer=0,
ext_segs=ext_segs,
dim_segs=origin_segs,
labels=origin_labels,
)
# ── Создать минимум актеров ─────────────────────────────────
if origin_segs:
self._add_lines_actor(origin_segs, _DIM_LINE_COLOR_ORIGIN, _LINE_WIDTH)
if origin_labels:
self._add_labels_actor(origin_labels, _DIM_TEXT_COLOR_ORIGIN)
# ── 3. Подсветка текущего узла ──────────────────────────────
self._show_dim_highlight(cx, cy, z)
if hasattr(self, "_safe_render"):
self._safe_render(min_interval_s=0.05)
else:
self._plotter.render()
@staticmethod
def _collect_dimension_pair(
base_x: float, base_y: float,
target_x: float, target_y: float,
z: float,
offset: float,
label_prefix: str,
x_side: str,
y_side: str,
label_layer: int,
ext_segs: List[_Seg],
dim_segs: List[_Seg],
labels: List[Tuple[_Pt3, str]],
) -> None:
"""Построить геометрию и подписи для одной пары размеров X/Y."""
dx = target_x - base_x
dy = target_y - base_y
label_z = z + 10.0 + (label_layer * _LABEL_Z_LAYER_STEP)
# Размерная линия X и сторона выноски/подписи.
if abs(dx) > 0.5:
if x_side == "top":
y_dim = max(base_y, target_y) + offset
ext_end_y = y_dim + _EXTENSION_OVERSHOOT
label_y = y_dim + _LABEL_CLEARANCE
else:
y_dim = min(base_y, target_y) - offset
ext_end_y = y_dim - _EXTENSION_OVERSHOOT
label_y = y_dim - _LABEL_CLEARANCE
p1 = (base_x, y_dim, z)
p2 = (target_x, y_dim, z)
ext_segs.append(((base_x, base_y, z), (base_x, ext_end_y, z)))
ext_segs.append(((target_x, target_y, z), (target_x, ext_end_y, z)))
dim_segs.append((p1, p2))
mid = ((p1[0] + p2[0]) / 2, label_y, label_z)
labels.append((mid, f"{label_prefix}{abs(dx):.0f} mm"))
# Размерная линия Y и сторона выноски/подписи.
if abs(dy) > 0.5:
if y_side == "right":
x_dim = max(base_x, target_x) + offset
ext_end_x = x_dim + _EXTENSION_OVERSHOOT
label_x = x_dim + _LABEL_CLEARANCE
else:
x_dim = min(base_x, target_x) - offset
ext_end_x = x_dim - _EXTENSION_OVERSHOOT
label_x = x_dim - _LABEL_CLEARANCE
p1 = (x_dim, base_y, z)
p2 = (x_dim, target_y, z)
ext_segs.append(((base_x, base_y, z), (ext_end_x, base_y, z)))
ext_segs.append(((target_x, target_y, z), (ext_end_x, target_y, z)))
dim_segs.append((p1, p2))
mid = (label_x, (p1[1] + p2[1]) / 2, label_z)
labels.append((mid, f"{label_prefix}{abs(dy):.0f} mm"))
def _add_lines_actor(
self: "ModelViewWidget",
segments: List[_Seg],
color: str,
width: float,
) -> None:
"""Объединить все отрезки в один PolyData и добавить 1 actor."""
n = len(segments)
pts = np.empty((n * 2, 3), dtype=np.float64)
cells = np.empty(n * 3, dtype=np.int64)
for i, (a, b) in enumerate(segments):
j = i * 2
pts[j] = a
pts[j + 1] = b
k = i * 3
cells[k] = 2
cells[k + 1] = j
cells[k + 2] = j + 1
poly = pv.PolyData()
poly.points = pts
poly.lines = cells
actor = self._plotter.add_mesh(
poly,
color=color,
line_width=width,
pickable=False,
reset_camera=False,
)
self._dim_actors.append(actor)
def _add_labels_actor(
self: "ModelViewWidget",
labels: List[Tuple[_Pt3, str]],
text_color: str,
) -> None:
"""Добавить все подписи одним вызовом add_point_labels."""
positions = [lbl[0] for lbl in labels]
texts = [lbl[1] for lbl in labels]
actor = self._plotter.add_point_labels(
positions,
texts,
font_size=_FONT_SIZE,
text_color=text_color,
shape=None,
show_points=False,
always_visible=True,
pickable=False,
reset_camera=False,
)
self._dim_actors.append(actor)
def _show_dim_highlight(
self: "ModelViewWidget", x: float, y: float, z: float,
) -> None:
"""Показать подсвечивающую сферу на узле сетки."""
self._clear_dim_highlight()
if not self._plotter or pv is None:
return
sphere = pv.Sphere(
radius=_HIGHLIGHT_RADIUS,
center=(x, y, z),
theta_resolution=8,
phi_resolution=8,
)
self._dim_highlight_actor = self._plotter.add_mesh(
sphere,
color=_HIGHLIGHT_COLOR,
opacity=_HIGHLIGHT_OPACITY,
pickable=False,
reset_camera=False,
)
def _clear_dim_highlight(self: "ModelViewWidget") -> None:
if self._dim_highlight_actor and self._plotter:
try:
self._plotter.remove_actor(self._dim_highlight_actor)
except Exception as e:
log_exception(__name__, "_clear_dim_highlight", e)
self._dim_highlight_actor = None
def _clear_dim_actors(self: "ModelViewWidget") -> None:
"""Удалить все actors размерных линий из сцены."""
if not self._plotter:
return
for actor in self._dim_actors:
try:
self._plotter.remove_actor(actor)
except Exception as e:
log_exception(__name__, "_clear_dim_actors", e)
self._dim_actors = []
# ---------------------------------------------------------------------------
# Module workflow notes
# ---------------------------------------------------------------------------
# 1) Задача модуля:
# Базовая отрисовка размерных линий по сетке в режиме построения контура.
# Модуль отслеживает ближайший узел сетки к курсору, строит размерные линии
# от опорной точки (origin) до текущего узла и показывает подсветку узла.
#
# 2) Последовательность действий и вызовов:
#
# A. Инициализация состояния:
# init_dimension_lines()
# Назначение: подготовить все поля состояния размерных линий.
# Состояние: очищает списки актеров, сбрасывает origin/текущий узел,
# выключает режим отображения и обнуляет параметры перерисовки.
#
# B. Включение и выключение:
# enable_dimension_lines(origin)
# Назначение: включить показ линий и зафиксировать опорную точку.
# -> init_dimension_lines()
# -> _dim_origin = origin, _dim_enabled = True
#
# disable_dimension_lines()
# Назначение: полностью отключить модуль и очистить визуалы.
# -> _clear_dim_actors()
# -> _clear_dim_highlight()
# -> сброс _dim_enabled/_dim_origin/_dim_last_point/_dim_current_node
#
# C. Наведение курсора:
# handle_dim_hover(x, y)
# Назначение: найти ближайший узел сетки и обновить геометрию.
# Шаги:
# 1) проверяет, что режим активен и задан origin;
# 2) применяет ограничение частоты перерисовки (_THROTTLE_S);
# 3) вычисляет ближайший узел в _grid_nodes;
# 4) проверяет порог привязки (доля шага сетки);
# 5) если узел изменился -> _redraw_dim_lines().
#
# D. Перерисовка размерных линий:
# _redraw_dim_lines()
# Назначение: обновить все линии/подписи для текущего узла.
# -> _clear_dim_actors()
# -> _collect_dimension_pair(...)
# Формирует сегменты X/Y и подписи размеров.
# -> _add_lines_actor(...)
# Добавляет сегменты в сцену одним PolyData-актором.
# -> _add_labels_actor(...)
# Добавляет подписи одним вызовом add_point_labels.
# -> _show_dim_highlight(...)
# Показывает сферу подсветки текущего узла.
# -> _safe_render(...) или render()
#
# E. Обновление последней точки контура:
# set_dim_last_point(pt)
# Назначение: синхронизировать модуль с текущим состоянием контура.
# Если курсор уже привязан к узлу, выполняется _redraw_dim_lines().
#
# 3) Важные ограничения:
# - Модуль работает только с узлами сетки (_grid_nodes), не с bbox объемов.
# - Геометрия рисуется только при доступных self._plotter, pyvista и numpy.
# - Список ext_segs собирается как часть расчета, но в текущей версии
# намеренно не выводится отдельным актором.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,99 @@
# -*- coding: utf-8 -*-
# gui/components/model_view/_mv_dimension_lines.py
"""Выносные размерные линии в 3D-сцене PyVista.
Модуль хранит общие константы / типы и собирает финальный
``DimensionLinesMixin`` из двух подмиксинов:
- ``DimLineCoreMixin`` — рисование размерных линий от origin
- ``DimLineMeasureMixin`` — интерактивный режим «Измерить»
Подмиксины лежат в ``_mv_dim_lines_grid.py`` и
``_mv_dim_lines_volume.py`` и импортируют константы из этого файла.
"""
from __future__ import annotations
from typing import Tuple
# ── Настройки отображения ────────────────────────────────────────────
_DIM_LINE_COLOR_ORIGIN = "#FFD600" # жёлтый — от origin
_DIM_LINE_COLOR_LAST = "#00E5FF" # голубой — от последней точки
_DIM_TEXT_COLOR_ORIGIN = "#FFD600"
_DIM_TEXT_COLOR_LAST = "#00E5FF"
_EXTENSION_LINE_COLOR = "#AAAAAA" # серый — выносные линии
_LINE_WIDTH = 2.0
_EXTENSION_LINE_WIDTH = 1.0
_FONT_SIZE = 26
# Смещение первого и второго ряда размерных линий (мм).
_DIM_OFFSET_1 = 800.0 # от origin (ближний)
_DIM_OFFSET_2 = 1500.0 # от последней точки (дальний)
_EXTENSION_OVERSHOOT = 200.0
_LABEL_CLEARANCE = 180.0
_LABEL_Z_LAYER_STEP = 18.0
# Триггер привязки — доля шага сетки.
_SNAP_TRIGGER_FRACTION = 1.0 / 3.0
# Подсветка узла
_HIGHLIGHT_COLOR = "#FFFF00"
_HIGHLIGHT_RADIUS = 40.0 # мм
_HIGHLIGHT_OPACITY = 0.85
_MEASURE_HIGHLIGHT_COLOR = "#00A3FF"
_MEASURE_HIGHLIGHT_RADIUS = 120.0 # мм
_MEASURE_HIGHLIGHT_OPACITY = 0.95
# Минимальный интервал между перерисовками (секунды).
_THROTTLE_S = 0.050
_MEASURE_SNAP_MM = 450.0
_MEASURE_SNAP_PX = 24.0
_MEASURE_LABEL_COLOR = "#00E5FF"
# ── Вспомогательные типы ─────────────────────────────────────────────
_Pt3 = Tuple[float, float, float]
_Seg = Tuple[_Pt3, _Pt3]
# ── Импорт подмиксинов (после констант, т.к. они импортируют их) ─────
from gui.components.model_view._mv_dim_lines_grid import DimLineCoreMixin # noqa: E402
from gui.components.model_view._mv_dim_lines_volume import DimLineMeasureMixin # noqa: E402
class DimensionLinesMixin(DimLineCoreMixin, DimLineMeasureMixin):
"""Mixin для ModelViewWidget: выносные размерные линии."""
pass
# ---------------------------------------------------------------------------
# Module workflow notes
# ---------------------------------------------------------------------------
# 1) Задача модуля:
# Единый фасад подсистемы размерных линий model_view.
# Файл определяет общие константы отображения/привязки и собирает
# финальный DimensionLinesMixin из двух независимых подсистем:
# - DimLineCoreMixin (размеры по узлам сетки для контура),
# - DimLineMeasureMixin (измерение по bbox объемов).
#
# 2) Почему константы размещены именно здесь:
# - Оба подмодуля импортируют одинаковые параметры цветов, толщин,
# порогов привязки и типов данных.
# - Централизация исключает дублирование и расхождения между режимами.
# - Изменение параметра в одном месте автоматически применяет его в
# обоих сценариях (сетка и измерение объемов).
#
# 3) Последовательность использования:
# - ModelViewWidget наследует DimensionLinesMixin.
# - При работе с контуром используются методы из _mv_dim_lines_grid.py.
# - При включении режима «Измерить» используются методы из
# _mv_dim_lines_volume.py.
# - Внешние модули не должны напрямую импортировать подмиксины, если
# достаточно интерфейса DimensionLinesMixin.
#
# 4) Правило сопровождения:
# - Новые общие параметры и типы добавлять в этот файл.
# - Логику сценариев добавлять в профильный подмодуль (grid/volume),
# сохраняя этот файл как композиционный и конфигурационный центр.

View File

@@ -0,0 +1,36 @@
# -*- coding: utf-8 -*-
# gui/components/model_view/_mv_grid_core.py
# Тонкая композиция: объединяет базовые методы сетки + помощники геометрии пола
from __future__ import annotations
from gui.components.model_view._mv_grid_core_base import GridCoreBaseMixin
from gui.components.model_view._mv_grid_core_floor import GridCoreFloorMixin
class GridCoreMixin(GridCoreBaseMixin, GridCoreFloorMixin):
"""Сетка на полу: создание, тема, геометрия пола, узлы сетки."""
_FAST_PREVIEW_CUBE_LIMIT = 200
_MAX_VOLUME_HEIGHT = 6000.0
# ---------------------------------------------------------------------------
# Module workflow notes
# ---------------------------------------------------------------------------
# 1) Задача модуля:
# Композиционный фасад ядра сетки, объединяющий базовый и геометрический уровни.
#
# 2) Последовательность действий и вызовов:
# A. Композиционный класс GridCoreMixin:
# Назначение: объединяет поведение через GridCoreBaseMixin, GridCoreFloorMixin.
# Собственная вычислительная логика отсутствует; маршрутизация идёт в родительские миксины.
#
# 3) Важные ограничения и инварианты:
# - Модуль выполняется в составе ModelViewWidget и использует согласованные поля состояния self._... .
# - Межмодульная связность: только через фасад model_view; прямые обращения между zone, rack, shelf, cell запрещены.
# - Очистка состояния должна быть идемпотентной: повторный вызов не меняет корректное состояние в ошибочное.
#
# 4) Правило сопровождения:
# - Любое изменение сценария должно сопровождаться обновлением этого блока с сохранением фактического порядка вызовов.
# - При добавлении метода указывать его место в цепочке сценария (запуск, основной шаг, завершение, вспомогательная логика).

View File

@@ -0,0 +1,658 @@
# -*- coding: utf-8 -*-
# gui/components/model_view/_mv_grid_core_base.py
# Основные методы сетки: тема, жизненный цикл, видимость, узлы, плоскость Z
from __future__ import annotations
import hashlib
import json
import math
from pathlib import Path
from typing import Optional, Tuple, TYPE_CHECKING, Callable
from error_logger import log_exception
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
class GridCoreBaseMixin:
"""Сетка на полу: тема, создание, видимость, кэш, узлы."""
def _get_grid_color(self: "ModelViewWidget") -> tuple[int, int, int]:
theme = (getattr(self, "_theme", "dark") or "dark").lower()
if theme == "dark":
return (0, 0, 0)
return (180, 180, 180)
def _apply_grid_theme(self: "ModelViewWidget") -> None:
"""Перекрасить акторы сетки в соответствии с текущей темой."""
color = self._get_grid_color()
rgb = (color[0] / 255.0, color[1] / 255.0, color[2] / 255.0)
actors = []
actors.extend(getattr(self, "_grid_meshes", []))
actors.extend(getattr(self, "_grid_surface_meshes", []))
for actor in actors:
try:
prop = actor.GetProperty()
if prop:
prop.SetColor(*rgb)
except Exception as e:
log_exception(__name__, "_apply_grid_theme.set_color", e)
try:
if self._plotter:
self._plotter.update()
except Exception as e:
log_exception(__name__, "_apply_grid_theme.plotter_update", e)
def start_zone_selection_grid(
self: "ModelViewWidget",
cell_size: int,
grid_origin: Optional[Tuple[float, float, float]] = None,
progress_callback: Optional[Callable[[int], None]] = None,
z_size: Optional[int] = None,
):
"""Включить режим выбора зоны через сетку на полу.
Если сетка с такими же параметрами уже построена (``_grid_ready``),
повторное построение пропускается — переиспользуются существующие
акторы и данные ячеек.
"""
if not self._models_loaded or not self._plotter:
return
if self._floor_mesh is not None:
min_x, max_x, min_y, max_y, min_z, _ = self._floor_mesh.bounds
elif self._room_bounds:
min_x, max_x, min_y, max_y, min_z, _ = self._room_bounds
else:
return
z_size_int = int(z_size) if z_size is not None else int(cell_size)
if grid_origin is not None:
self._grid_origin = grid_origin
grid_z = self._get_grid_plane_z(self._grid_origin)
# -- Проверка возможности переиспользования сетки ------------------
can_reuse = (
self._grid_ready
and self._grid_cached_cell_size == int(cell_size)
and self._grid_cached_z_size == z_size_int
and self._grid_cells
and self._grid_nodes
)
if can_reuse:
# Сбрасываем только состояние выделения, сетку показываем заново
self._zone_selection_mode = True
self._current_zone_size = cell_size
self._current_zone_z_size = z_size_int
self._selected_cells = set()
self._selected_height = 0
self._selection_anchor = None
self._selection_start_cell = None
self._volume_locked_from_contour = False
self._contour_points = []
self._final_contour_points = []
self._grid_surface_meshes = getattr(self, "_grid_surface_meshes", [])
self._contour_points_actor = None
self._contour_lines_actor = None
self._last_volume_start_height = grid_z
self._show_grid_actors()
if progress_callback:
try:
progress_callback(100)
except Exception as e:
log_exception(__name__, "start_zone_selection_grid.progress_callback", e)
return
# -- Полное построение сетки (первый раз или параметры изменились) --
self._zone_selection_mode = True
self._current_zone_size = cell_size
self._current_zone_z_size = z_size_int
self._selected_cells = set()
self._selected_height = 0
self._selection_anchor = None
self._selection_start_cell = None
self._volume_locked_from_contour = False
self._contour_points = []
self._final_contour_points = []
self._grid_nodes = []
self._grid_surface_meshes = []
self._contour_points_actor = None
self._contour_lines_actor = None
self._last_volume_start_height = grid_z
built = self.create_grid_on_floor(
min_x, max_x, min_y, max_y, grid_z, cell_size,
grid_origin=self._grid_origin,
progress_callback=progress_callback,
)
if not built:
self._zone_selection_mode = False
self._grid_ready = False
self._grid_cached_cell_size = 0
self._grid_cached_z_size = 0
return
# Обновляем кэш
self._grid_ready = True
self._grid_cached_cell_size = int(cell_size)
self._grid_cached_z_size = z_size_int
def ensure_visible_grid(
self: "ModelViewWidget",
cell_size: int,
grid_origin: Optional[Tuple[float, float, float]] = None,
z_size: Optional[int] = None,
progress_callback: Optional[Callable[[int], None]] = None,
cache_path: Optional[str] = None,
cache_extra: Optional[dict] = None,
) -> bool:
"""Обеспечить постоянное отображение сетки без включения режима разметки."""
if not self._models_loaded or not self._plotter:
return False
cell_size_int = int(cell_size)
z_size_int = int(z_size) if z_size is not None else int(cell_size_int)
cache_meta = self._build_grid_cache_meta(
cell_size=cell_size_int,
z_size=z_size_int,
grid_origin=grid_origin,
extra=cache_extra,
)
try:
loaded_from_cache = False
if cache_path:
loaded_from_cache = self.load_grid_cache(str(cache_path), expected_meta=cache_meta)
if not loaded_from_cache:
self.start_zone_selection_grid(
cell_size_int,
grid_origin=grid_origin,
progress_callback=progress_callback,
z_size=z_size_int,
)
if cache_path and self.has_grid():
self.save_grid_cache(str(cache_path), meta=cache_meta)
else:
self._show_grid_actors()
if not self.has_grid():
return False
if self._zone_selection_mode:
self.cancel_zone_selection()
self._show_grid_actors()
return True
except Exception as e:
log_exception(__name__, "ensure_visible_grid.main", e)
return False
def _mesh_signature(self: "ModelViewWidget", mesh) -> dict:
if mesh is None:
return {"present": False}
try:
bounds = tuple(float(v) for v in mesh.bounds)
except Exception as e:
log_exception(__name__, "_mesh_signature.bounds", e)
bounds = ()
try:
n_points = int(getattr(mesh, "n_points", 0) or 0)
except Exception as e:
log_exception(__name__, "_mesh_signature.n_points", e)
n_points = 0
try:
n_cells = int(getattr(mesh, "n_cells", 0) or 0)
except Exception as e:
log_exception(__name__, "_mesh_signature.n_cells", e)
n_cells = 0
raw = f"{bounds}|{n_points}|{n_cells}".encode("utf-8", errors="ignore")
digest = hashlib.sha1(raw).hexdigest()[:16]
return {
"present": True,
"bounds": [round(float(v), 4) for v in bounds],
"n_points": n_points,
"n_cells": n_cells,
"sig": digest,
}
def _build_grid_cache_meta(
self: "ModelViewWidget",
*,
cell_size: int,
z_size: int,
grid_origin: Optional[Tuple[float, float, float]],
extra: Optional[dict] = None,
) -> dict:
origin = tuple(grid_origin or getattr(self, "_grid_origin", (0.0, 0.0, 0.0)) or (0.0, 0.0, 0.0))
room_bounds = tuple(getattr(self, "_room_bounds", ()) or ())
meta = {
"version": 1,
"cell_size": int(cell_size),
"z_size": int(z_size),
"grid_origin": [round(float(origin[0]), 4), round(float(origin[1]), 4), round(float(origin[2]), 4)],
"room_bounds": [round(float(v), 4) for v in room_bounds] if room_bounds else [],
"floor": self._mesh_signature(getattr(self, "_floor_mesh", None)),
"walls": self._mesh_signature(getattr(self, "_walls_mesh", None)),
}
if extra:
meta["extra"] = dict(extra)
return meta
def make_grid_cache_meta(
self: "ModelViewWidget",
*,
cell_size: int,
z_size: int,
grid_origin: Optional[Tuple[float, float, float]] = None,
extra: Optional[dict] = None,
) -> dict:
"""Публичный конструктор метаданных кэша сетки."""
return self._build_grid_cache_meta(
cell_size=int(cell_size),
z_size=int(z_size),
grid_origin=grid_origin,
extra=extra,
)
def _grid_cache_payload(
self: "ModelViewWidget",
meta: dict,
) -> dict:
cells = []
for cell_id, bounds in (getattr(self, "_grid_cells", {}) or {}).items():
try:
mn_x, mx_x, mn_y, mx_y = bounds
cells.append([
int(cell_id),
float(mn_x),
float(mx_x),
float(mn_y),
float(mx_y),
])
except Exception as e:
log_exception(__name__, "_grid_cache_payload.cell_convert", e)
continue
cells.sort(key=lambda item: item[0])
return {"meta": meta, "cells": cells}
def save_grid_cache(self: "ModelViewWidget", cache_path: str, meta: dict) -> bool:
"""Сохранить текущую сетку в файл кэша."""
if not self.has_grid():
return False
try:
path = Path(str(cache_path))
path.parent.mkdir(parents=True, exist_ok=True)
payload = self._grid_cache_payload(meta)
path.write_text(json.dumps(payload, ensure_ascii=False, separators=(",", ":")), encoding="utf-8")
return True
except Exception as e:
log_exception(__name__, "save_grid_cache.write", e)
return False
def _render_grid_actor_from_cells(
self: "ModelViewWidget",
*,
min_z: float,
) -> bool:
if pv is None or not self._plotter:
return False
try:
for mesh in list(getattr(self, "_grid_meshes", [])):
try:
self._plotter.remove_actor(mesh)
except Exception as e:
log_exception(__name__, "_render_grid_actor_from_cells.remove_actor", e)
self._grid_meshes = []
line_points: list[list[float]] = []
line_cells: list[int] = []
def add_segment(p1: tuple[float, float, float], p2: tuple[float, float, float]) -> None:
i1 = len(line_points)
line_points.append([float(p1[0]), float(p1[1]), float(p1[2])])
i2 = len(line_points)
line_points.append([float(p2[0]), float(p2[1]), float(p2[2])])
line_cells.extend([2, i1, i2])
for mn_x, mx_x, mn_y, mx_y in (getattr(self, "_grid_cells", {}) or {}).values():
p00 = (mn_x, mn_y, min_z)
p10 = (mx_x, mn_y, min_z)
p11 = (mx_x, mx_y, min_z)
p01 = (mn_x, mx_y, min_z)
add_segment(p00, p10)
add_segment(p10, p11)
add_segment(p11, p01)
add_segment(p01, p00)
if not line_points or not line_cells:
return False
grid_lines = pv.PolyData(line_points)
grid_lines.lines = line_cells
actor = self._plotter.add_mesh(grid_lines, color=self._get_grid_color(), line_width=1)
self._grid_meshes.append(actor)
self._plotter.update()
return True
except Exception as e:
log_exception(__name__, "_render_grid_actor_from_cells.main", e)
return False
def load_grid_cache(self: "ModelViewWidget", cache_path: str, expected_meta: dict) -> bool:
"""Загрузить сетку из файла кэша при полном совпадении метаданных."""
try:
path = Path(str(cache_path))
if not path.exists():
return False
raw = path.read_text(encoding="utf-8")
payload = json.loads(raw)
cached_meta = dict(payload.get("meta") or {})
if cached_meta != dict(expected_meta or {}):
return False
cells_raw = list(payload.get("cells") or [])
if not cells_raw:
return False
grid_cells: dict[int, tuple[float, float, float, float]] = {}
for item in cells_raw:
if not isinstance(item, list) or len(item) != 5:
continue
cell_id = int(item[0])
mn_x = float(item[1])
mx_x = float(item[2])
mn_y = float(item[3])
mx_y = float(item[4])
grid_cells[cell_id] = (mn_x, mx_x, mn_y, mx_y)
if not grid_cells:
return False
for mesh in list(getattr(self, "_grid_meshes", [])):
try:
self._plotter.remove_actor(mesh)
except Exception as e:
log_exception(__name__, "load_grid_cache.remove_actor", e)
self._grid_meshes = []
self._grid_cells = grid_cells
self._rebuild_grid_nodes()
origin = expected_meta.get("grid_origin") or [0.0, 0.0, 0.0]
self._grid_origin = (float(origin[0]), float(origin[1]), float(origin[2]))
self._current_zone_size = int(expected_meta.get("cell_size", 0) or 0)
self._current_zone_z_size = int(expected_meta.get("z_size", self._current_zone_size) or self._current_zone_size)
self._grid_cached_cell_size = int(self._current_zone_size)
self._grid_cached_z_size = int(self._current_zone_z_size)
self._grid_ready = True
self._zone_selection_mode = False
grid_z = self._get_grid_plane_z(self._grid_origin)
if not self._render_grid_actor_from_cells(min_z=grid_z):
self.clear_grid()
return False
return True
except Exception as e:
log_exception(__name__, "load_grid_cache.main", e)
return False
def create_grid_on_floor(
self: "ModelViewWidget",
min_x: float, max_x: float,
min_y: float, max_y: float,
min_z: float,
cell_size: int,
grid_origin: Optional[Tuple[float, float, float]] = None,
progress_callback: Optional[Callable[[int], None]] = None,
) -> bool:
"""Построить линии сетки на полу."""
for mesh in self._grid_meshes:
try:
self._plotter.remove_actor(mesh)
except Exception as e:
log_exception(__name__, "create_grid_on_floor.remove_actor", e)
self._grid_meshes = []
self._grid_cells = {}
disp_min_x = float(min_x)
disp_max_x = float(max_x)
disp_min_y = float(min_y)
disp_max_y = float(max_y)
# Выровнять шаг сетки по локальной опорной точке объекта.
if grid_origin is not None and cell_size > 0:
ox, oy, _ = grid_origin
disp_min_x = ox + math.floor((disp_min_x - ox) / cell_size) * cell_size
disp_max_x = ox + math.ceil((disp_max_x - ox) / cell_size) * cell_size
disp_min_y = oy + math.floor((disp_min_y - oy) / cell_size) * cell_size
disp_max_y = oy + math.ceil((disp_max_y - oy) / cell_size) * cell_size
floor_triangles = self._extract_floor_triangles_2d()
x = disp_min_x
cell_id = 0
line_points: list[list[float]] = []
line_cells: list[int] = []
total_cols = max(1, int(math.ceil(
(disp_max_x - disp_min_x) / max(1.0, float(cell_size))
)))
col_idx = 0
def add_segment(p1: tuple[float, float, float], p2: tuple[float, float, float]) -> None:
i1 = len(line_points)
line_points.append([p1[0], p1[1], p1[2]])
i2 = len(line_points)
line_points.append([p2[0], p2[1], p2[2]])
line_cells.extend([2, i1, i2])
while x < disp_max_x:
if bool(getattr(self, "_grid_build_cancel_requested", False)):
self._grid_cells = {}
self._grid_nodes = []
return False
y = disp_min_y
while y < disp_max_y:
if bool(getattr(self, "_grid_build_cancel_requested", False)):
self._grid_cells = {}
self._grid_nodes = []
return False
x_next = min(x + cell_size, disp_max_x)
y_next = min(y + cell_size, disp_max_y)
if self._is_cell_inside_floor_2d(x, x_next, y, y_next, floor_triangles):
self._grid_cells[cell_id] = (x, x_next, y, y_next)
p00 = (x, y, min_z)
p10 = (x_next, y, min_z)
p11 = (x_next, y_next, min_z)
p01 = (x, y_next, min_z)
add_segment(p00, p10)
add_segment(p10, p11)
add_segment(p11, p01)
add_segment(p01, p00)
cell_id += 1
y = y_next
x = x_next
col_idx += 1
if progress_callback:
try:
progress_callback(min(100, int((col_idx / total_cols) * 100)))
except Exception as e:
log_exception(__name__, "create_grid_on_floor.progress_callback", e)
if line_points and line_cells:
try:
grid_lines = pv.PolyData(line_points)
grid_lines.lines = line_cells
actor = self._plotter.add_mesh(
grid_lines,
color=self._get_grid_color(),
line_width=1,
)
self._grid_meshes.append(actor)
except Exception as e:
log_exception(__name__, "create_grid_on_floor.add_mesh", e)
self._rebuild_grid_nodes()
if progress_callback:
try:
progress_callback(100)
except Exception as e:
log_exception(__name__, "create_grid_on_floor.progress_complete", e)
self._plotter.update()
return True
def has_grid(self: "ModelViewWidget") -> bool:
"""Проверить наличие готовой разметочной сетки."""
return bool(
getattr(self, "_grid_ready", False)
and getattr(self, "_grid_cells", None)
and getattr(self, "_grid_nodes", None)
)
def clear_grid(self: "ModelViewWidget") -> None:
"""Полностью уничтожить сетку (акторы + данные + cache)."""
for mesh in list(getattr(self, "_grid_meshes", [])):
try:
self._plotter.remove_actor(mesh)
except Exception as e:
log_exception(__name__, "clear_grid.remove_actor", e)
self._grid_meshes = []
self._grid_cells = {}
self._grid_nodes = []
self._grid_ready = False
self._grid_cached_cell_size = 0
self._grid_cached_z_size = 0
def _show_grid_actors(self: "ModelViewWidget") -> None:
"""Показать скрытые акторы разметочной сетки."""
for actor in getattr(self, "_grid_meshes", []):
try:
actor.VisibilityOn()
except Exception as e:
log_exception(__name__, "_show_grid_actors.visibility_on", e)
try:
if self._plotter:
self._plotter.update()
except Exception as e:
log_exception(__name__, "_show_grid_actors.plotter_update", e)
def _hide_grid_actors(self: "ModelViewWidget") -> None:
"""Скрыть акторы разметочной сетки без удаления."""
for actor in getattr(self, "_grid_meshes", []):
try:
actor.VisibilityOff()
except Exception as e:
log_exception(__name__, "_hide_grid_actors.visibility_off", e)
try:
if self._plotter:
self._plotter.update()
except Exception as e:
log_exception(__name__, "_hide_grid_actors.plotter_update", e)
def _rebuild_grid_nodes(self: "ModelViewWidget") -> None:
nodes = set()
for mn_x, mx_x, mn_y, mx_y in self._grid_cells.values():
nodes.add((float(mn_x), float(mn_y)))
nodes.add((float(mx_x), float(mn_y)))
nodes.add((float(mx_x), float(mx_y)))
nodes.add((float(mn_x), float(mx_y)))
self._grid_nodes = list(nodes)
def _nearest_grid_node(
self: "ModelViewWidget", x: float, y: float,
) -> Optional[Tuple[float, float]]:
use_surface = bool(
getattr(self, "_contour_zone_overlay_enabled", False)
and getattr(self, "_contour_zone_mode", None) == "overlay"
and getattr(self, "_grid_surface_nodes", None)
)
nodes = self._grid_surface_nodes if use_surface else self._grid_nodes
if not nodes:
return None
step = max(1.0, float(getattr(self, "_current_zone_size", 1.0)))
max_dist2 = (step * 0.55) ** 2
best = None
best_dist2 = None
for nx, ny in nodes:
d2 = (nx - x) ** 2 + (ny - y) ** 2
if best_dist2 is None or d2 < best_dist2:
best_dist2 = d2
best = (nx, ny)
if best is None or best_dist2 is None or best_dist2 > max_dist2:
return None
return best
def _get_grid_plane_z(
self: "ModelViewWidget",
grid_origin: Optional[Tuple[float, float, float]] = None,
) -> float:
"""Определить Z-плоскость построения сетки."""
if grid_origin is not None:
try:
return float(grid_origin[2])
except Exception as e:
log_exception(__name__, "_get_grid_plane_z.origin_z", e)
if self._floor_mesh is not None:
return float(self._floor_mesh.bounds[5])
if self._room_bounds:
return float(self._room_bounds[5])
return 0.0
# ---------------------------------------------------------------------------
# Module workflow notes
# ---------------------------------------------------------------------------
# 1) Задача модуля:
# Базовые операции сетки: построение, отображение, скрытие и привязка к узлам.
#
# 2) Последовательность действий и вызовов:
# A. Класс GridCoreBaseMixin: точки входа
# Публичные методы сценария:
# - GridCoreBaseMixin.start_zone_selection_grid(...)
# - GridCoreBaseMixin.create_grid_on_floor(...)
# - GridCoreBaseMixin.has_grid(...)
# - GridCoreBaseMixin.clear_grid(...)
#
# B. GridCoreBaseMixin: запуск и настройка:
# GridCoreBaseMixin.start_zone_selection_grid(...)
# Назначение: Включить режим выбора зоны через сетку на полу.
# Последовательность внутренних вызовов:
# -> GridCoreBaseMixin._get_grid_plane_z(...)
# -> GridCoreBaseMixin.create_grid_on_floor(...)
# -> GridCoreBaseMixin._show_grid_actors(...)
#
# C. GridCoreBaseMixin: основной сценарий:
# GridCoreBaseMixin._apply_grid_theme(...)
# Назначение: Перекрасить акторы сетки в соответствии с текущей темой.
# Последовательность внутренних вызовов:
# -> GridCoreBaseMixin._get_grid_color(...)
# GridCoreBaseMixin.create_grid_on_floor(...)
# Назначение: Построить линии сетки на полу.
# Последовательность внутренних вызовов:
# -> GridCoreBaseMixin._rebuild_grid_nodes(...)
# -> GridCoreBaseMixin._get_grid_color(...)
# GridCoreBaseMixin.has_grid(...)
# Назначение: Проверить наличие готовой разметочной сетки.
# GridCoreBaseMixin._show_grid_actors(...)
# Назначение: Показать скрытые акторы разметочной сетки.
# GridCoreBaseMixin._rebuild_grid_nodes(...)
# Назначение: перестраивает grid nodes в рамках текущего сценария модуля.
#
# D. GridCoreBaseMixin: завершение и очистка:
# GridCoreBaseMixin.clear_grid(...)
# Назначение: Полностью уничтожить сетку (акторы + данные + cache).
#
# E. GridCoreBaseMixin: вспомогательные расчёты:
# GridCoreBaseMixin._get_grid_color(...)
# Назначение: возвращает grid color в рамках текущего сценария модуля.
# GridCoreBaseMixin._hide_grid_actors(...)
# Назначение: Скрыть акторы разметочной сетки без удаления.
# GridCoreBaseMixin._nearest_grid_node(...)
# Назначение: выполняет шаг "nearest grid node" в рамках текущего сценария модуля.
# GridCoreBaseMixin._get_grid_plane_z(...)
# Назначение: Определить Z-плоскость построения сетки.
#
# 3) Важные ограничения и инварианты:
# - Модуль выполняется в составе ModelViewWidget и использует согласованные поля состояния self._... .
# - Все операции с актёрами и камерой выполняются только при валидном self._plotter с безопасной обработкой исключений.
# - Геометрическая визуализация зависит от pyvista/vtk; при недоступности модуль обязан завершать шаг без падения сценария.
# - Межмодульная связность: только через фасад model_view; прямые обращения между zone, rack, shelf, cell запрещены.
# - Очистка состояния должна быть идемпотентной: повторный вызов не меняет корректное состояние в ошибочное.
#
# 4) Правило сопровождения:
# - Любое изменение сценария должно сопровождаться обновлением этого блока с сохранением фактического порядка вызовов.
# - При добавлении метода указывать его место в цепочке сценария (запуск, основной шаг, завершение, вспомогательная логика).

View File

@@ -0,0 +1,136 @@
# -*- coding: utf-8 -*-
# gui/components/model_view/_mv_grid_core_floor.py
# Помощники геометрии пола: извлечение треугольников, проверки вхождения точки/ячейки
from __future__ import annotations
from typing import TYPE_CHECKING
from error_logger import log_exception
if TYPE_CHECKING: # pragma: no cover
from gui.components.model_view_widget import ModelViewWidget
class GridCoreFloorMixin:
"""Геометрия пола: извлечение треугольников и 2D-проверки вхождения."""
# ------------------------------------------------------------------
# Геометрия пола
# ------------------------------------------------------------------
def _extract_floor_triangles_2d(
self: "ModelViewWidget",
) -> list[tuple[tuple[float, float], tuple[float, float], tuple[float, float]]]:
floor_mesh = getattr(self, "_floor_mesh", None)
if floor_mesh is None:
return []
try:
tri = floor_mesh.extract_surface().triangulate()
pts = tri.points
faces = tri.faces
if pts is None or faces is None or len(faces) == 0:
return []
triangles: list[tuple[tuple[float, float], tuple[float, float], tuple[float, float]]] = []
idx = 0
faces_len = len(faces)
while idx < faces_len:
n = int(faces[idx])
if n < 3:
idx += n + 1
continue
i0 = int(faces[idx + 1])
for k in range(2, n):
i1 = int(faces[idx + k])
i2 = int(faces[idx + k + 1])
a = (float(pts[i0][0]), float(pts[i0][1]))
b = (float(pts[i1][0]), float(pts[i1][1]))
c = (float(pts[i2][0]), float(pts[i2][1]))
triangles.append((a, b, c))
idx += n + 1
return triangles
except Exception as e:
log_exception(__name__, "_extract_floor_triangles_2d", e)
return []
def _is_cell_inside_floor_2d(
self: "ModelViewWidget",
min_x: float, max_x: float,
min_y: float, max_y: float,
floor_triangles: list[tuple[tuple[float, float], tuple[float, float], tuple[float, float]]],
) -> bool:
if not floor_triangles:
return True
corners = (
(min_x, min_y), (max_x, min_y),
(max_x, max_y), (min_x, max_y),
)
for px, py in corners:
if not self._is_point_inside_floor_2d(px, py, floor_triangles):
return False
return True
def _is_point_inside_floor_2d(
self: "ModelViewWidget",
px: float, py: float,
floor_triangles: list[tuple[tuple[float, float], tuple[float, float], tuple[float, float]]],
) -> bool:
for a, b, c in floor_triangles:
if self._point_in_triangle_2d(px, py, a, b, c):
return True
return False
def _point_in_triangle_2d(
self: "ModelViewWidget",
px: float, py: float,
a: tuple[float, float],
b: tuple[float, float],
c: tuple[float, float],
eps: float = 1e-7,
) -> bool:
def sign(p1, p2, p3):
return (p1[0] - p3[0]) * (p2[1] - p3[1]) - (p2[0] - p3[0]) * (p1[1] - p3[1])
p = (px, py)
d1 = sign(p, a, b)
d2 = sign(p, b, c)
d3 = sign(p, c, a)
has_neg = (d1 < -eps) or (d2 < -eps) or (d3 < -eps)
has_pos = (d1 > eps) or (d2 > eps) or (d3 > eps)
return not (has_neg and has_pos)
# ---------------------------------------------------------------------------
# Module workflow notes
# ---------------------------------------------------------------------------
# 1) Задача модуля:
# Геометрический контроль попадания сетки и точек в пол помещения по треугольной геометрии.
#
# 2) Последовательность действий и вызовов:
# A. Класс GridCoreFloorMixin: точки входа
# Публичные методы отсутствуют; сценарий запускается через методы родительских модулей и внутренние обработчики.
#
# B. GridCoreFloorMixin: основной сценарий:
# GridCoreFloorMixin._is_cell_inside_floor_2d(...)
# Назначение: проверяет, что cell inside floor 2d в рамках текущего сценария модуля.
# Последовательность внутренних вызовов:
# -> GridCoreFloorMixin._is_point_inside_floor_2d(...)
# GridCoreFloorMixin._is_point_inside_floor_2d(...)
# Назначение: проверяет, что point inside floor 2d в рамках текущего сценария модуля.
# Последовательность внутренних вызовов:
# -> GridCoreFloorMixin._point_in_triangle_2d(...)
#
# C. GridCoreFloorMixin: вспомогательные расчёты:
# GridCoreFloorMixin._extract_floor_triangles_2d(...)
# Назначение: извлекает floor triangles 2d в рамках текущего сценария модуля.
# GridCoreFloorMixin._point_in_triangle_2d(...)
# Назначение: выполняет шаг "point in triangle 2d" в рамках текущего сценария модуля.
#
# 3) Важные ограничения и инварианты:
# - Модуль выполняется в составе ModelViewWidget и использует согласованные поля состояния self._... .
# - Межмодульная связность: только через фасад model_view; прямые обращения между zone, rack, shelf, cell запрещены.
# - Очистка состояния должна быть идемпотентной: повторный вызов не меняет корректное состояние в ошибочное.
#
# 4) Правило сопровождения:
# - Любое изменение сценария должно сопровождаться обновлением этого блока с сохранением фактического порядка вызовов.
# - При добавлении метода указывать его место в цепочке сценария (запуск, основной шаг, завершение, вспомогательная логика).

View File

@@ -0,0 +1,41 @@
# -*- coding: utf-8 -*-
# gui/components/model_view/_mv_interaction.py
# Камера, клики, события мыши — композиция специализированных миксинов.
from gui.components.model_view._mv_interaction_core import InteractionCoreMixin
from gui.components.model_view._mv_interaction_events import InteractionEventsMixin
from gui.components.model_view._mv_interaction_nav import InteractionNavigationMixin
class InteractionMixin(
InteractionCoreMixin,
InteractionEventsMixin,
InteractionNavigationMixin,
):
"""Камера, клик-обработка, колесо мыши, eventFilter."""
pass
# ---------------------------------------------------------------------------
# Module workflow notes
# ---------------------------------------------------------------------------
# 1) Задача модуля:
# Композиционный фасад взаимодействия со сценой: события мыши, навигация и базовые операции.
#
# 2) Последовательность действий и вызовов:
# A. Композиционный класс InteractionMixin:
# Назначение: объединяет поведение через InteractionCoreMixin,
# InteractionEventsMixin, InteractionNavigationMixin.
# InteractionCoreMixin — координаты, камера, утилиты interactor.
# InteractionEventsMixin — тонкий диспетчер eventFilter → InteractionManager.
# InteractionNavigationMixin — навигация, plotter click callback.
# Вся логика сценариев вынесена в _interaction_scenario.py + _scenario_*.py.
#
# 3) Важные ограничения и инварианты:
# - Модуль выполняется в составе ModelViewWidget и использует согласованные поля состояния self._... .
# - Межмодульная связность: только через фасад model_view; прямые обращения между zone, rack, shelf, cell запрещены.
# - Очистка состояния должна быть идемпотентной: повторный вызов не меняет корректное состояние в ошибочное.
#
# 4) Правило сопровождения:
# - Любое изменение сценария должно сопровождаться обновлением этого блока с сохранением фактического порядка вызовов.
# - При добавлении метода указывать его место в цепочке сценария (запуск, основной шаг, завершение, вспомогательная логика).

View File

@@ -0,0 +1,406 @@
# -*- coding: utf-8 -*-
# gui/components/model_view/_mv_interaction_core.py
# Базовые помощники взаимодействия: преобразование координат, настройка камеры, разрешение сетки
from __future__ import annotations
from typing import Optional, TYPE_CHECKING
from error_logger import log_exception
if TYPE_CHECKING: # pragma: no cover
from gui.components.model_view_widget import ModelViewWidget
class InteractionCoreMixin:
"""Базовые вспомогательные методы взаимодействия: координаты, камера, сетка, утилиты interactor."""
# -- Утилиты interactor (из бывшего InteractionUtilsMixin) -----------------
def _is_plotter_interactor_event(self: "ModelViewWidget", watched) -> bool:
"""Проверить, что событие пришло от активного interactor plotter."""
return bool(self._plotter and watched is self._plotter.interactor)
def _event_to_vtk_display_xy(self: "ModelViewWidget", event) -> tuple[int, int]:
"""Перевести координаты события Qt в VTK display (с DPR и инверсией Y)."""
dpr = self._plotter.devicePixelRatio()
sx = int(round(event.position().x() * dpr))
sy = int(round(event.position().y() * dpr))
rw = self._plotter.ren_win
_, vh = rw.GetSize() if rw else (0, 0)
sy = vh - sy - 1
return sx, sy
# -- Координатные преобразования ------------------------------------------
def _event_world_on_plane(self: "ModelViewWidget", event, plane_z: float) -> Optional[tuple[float, float, float]]:
"""Преобразование события мыши интерактора в мировые координаты на фиксированной плоскости."""
if not self._plotter:
return None
try:
sx, sy = self._event_to_vtk_display_xy(event)
return self.screen_to_world_on_plane(sx, sy, plane_z)
except Exception as e:
log_exception(__name__, "_event_world_on_plane", e)
return None
def _update_contour_drag_point(self: "ModelViewWidget", world_x: float, world_y: float) -> bool:
"""Перемещение перетаскиваемой точки контура к ближайшему узлу сетки без изменения порядка."""
idx = getattr(self, "_contour_drag_point_index", None)
if idx is None:
return False
points = list(getattr(self, "_contour_points", []) or [])
if idx < 0 or idx >= len(points):
return False
node = self._nearest_grid_node(float(world_x), float(world_y))
if node is None:
return False
new_node = (float(node[0]), float(node[1]))
current_node = (float(points[idx][0]), float(points[idx][1]))
if new_node == current_node:
return False
# Не допускаем совпадения с другими узлами.
for i, pt in enumerate(points):
if i == idx:
continue
if (float(pt[0]), float(pt[1])) == new_node:
return False
points[idx] = new_node
self._contour_points = points
if getattr(self, "_dim_enabled", False):
self.set_dim_last_point(self._contour_points[-1] if self._contour_points else None)
self._update_contour_visualization()
self._update_selected_cells_from_contour()
emit_ready = getattr(self, "_emit_contour_ready_state", None)
if callable(emit_ready):
emit_ready()
return True
# -- камера ---------------------------------------------------------------
def _apply_facility_isometric_view(
self: "ModelViewWidget",
reset_camera: bool = True,
update: bool = True,
) -> None:
"""Применить единый изометрический вид facility (базовая ориентация)."""
if not self._plotter:
return
try:
self._plotter.view_isometric()
camera = getattr(self._plotter, "camera", None)
if camera is not None and hasattr(camera, "Azimuth"):
# Базовая ориентация facility: изометрия с противоположной стороны.
camera.Azimuth(180)
if hasattr(camera, "OrthogonalizeViewUp"):
camera.OrthogonalizeViewUp()
if reset_camera:
self._plotter.reset_camera()
# Поджимаем общий facility-кадр: небольшие поля вокруг модели.
if camera is not None and hasattr(camera, "zoom"):
try:
camera.zoom(1.35)
except Exception as e:
log_exception(__name__, "_apply_facility_isometric_view.camera_zoom", e)
if hasattr(self, "_reset_camera_clipping_range"):
self._reset_camera_clipping_range()
if update:
self._plotter.update()
except Exception as e:
log_exception(__name__, "_apply_facility_isometric_view", e)
def _setup_trackball_right_button(self: "ModelViewWidget"):
"""Настройка trackball стиля: правая кнопка — поворот, левая отключена."""
try:
if self._plotter and self._plotter.interactor:
interactor = self._plotter.interactor
style = (
interactor.GetInteractorStyle()
if hasattr(interactor, "GetInteractorStyle")
else None
)
if style:
if hasattr(style, "SetLeftButtonMotion"):
style.SetLeftButtonMotion(False)
if hasattr(style, "SetRightButtonMotion"):
style.SetRightButtonMotion(True)
except Exception as e:
log_exception(__name__, "_setup_trackball_right_button", e)
def _set_trackball_right_button_enabled(self: "ModelViewWidget", enabled: bool) -> None:
"""Явно включить/выключить реакцию VTK-style на ПКМ."""
try:
if not (self._plotter and self._plotter.interactor):
return
interactor = self._plotter.interactor
style = (
interactor.GetInteractorStyle()
if hasattr(interactor, "GetInteractorStyle")
else None
)
if style and hasattr(style, "SetRightButtonMotion"):
style.SetRightButtonMotion(bool(enabled))
except Exception as e:
log_exception(__name__, "_set_trackball_right_button_enabled", e)
# -- клик -----------------------------------------------------------------
def set_top_view_navigation(self: "ModelViewWidget", enabled: bool) -> None:
"""Режим top-view: pan/zoom без вращения камеры."""
if not self._plotter:
return
try:
if enabled:
try:
self._plotter.view_xy()
except Exception as e:
log_exception(__name__, "set_top_view_navigation.view_xy", e)
self._plotter.camera_position = "xy"
self._plotter.enable_image_style()
else:
self._plotter.enable_trackball_style()
self._setup_trackball_right_button()
self._plotter.update()
except Exception as e:
log_exception(__name__, "set_top_view_navigation", e)
def set_isometric_view(self: "ModelViewWidget") -> None:
"""Переключить камеру в изометрический вид."""
self._apply_facility_isometric_view(reset_camera=True, update=True)
def animate_facility_isometric_view(
self: "ModelViewWidget",
*,
duration_ms: int = 360,
steps: int = 18,
) -> None:
"""Плавно перевести камеру к общей изометрии facility."""
if not self._plotter:
return
cam = getattr(self._plotter, "camera", None)
if cam is None:
return
try:
start_pos = tuple(float(v) for v in cam.GetPosition())
start_focal = tuple(float(v) for v in cam.GetFocalPoint())
start_up = tuple(float(v) for v in cam.GetViewUp())
except Exception as e:
log_exception(__name__, "animate_facility_isometric_view.get_camera_start", e)
self.set_isometric_view()
return
# Рассчитываем целевую изометрию без промежуточного переключения
# живой камеры (иначе возникает визуальный "проскок" статичного кадра).
try:
bounds = getattr(self, "_room_bounds", None)
if not bounds:
zone_bounds = list((getattr(self, "_zone_data", {}) or {}).values())
if zone_bounds:
min_x = min(float(b[0]) for b in zone_bounds)
max_x = max(float(b[1]) for b in zone_bounds)
min_y = min(float(b[2]) for b in zone_bounds)
max_y = max(float(b[3]) for b in zone_bounds)
min_z = min(float(b[4]) for b in zone_bounds)
max_z = max(float(b[5]) for b in zone_bounds)
bounds = (min_x, max_x, min_y, max_y, min_z, max_z)
if not bounds:
self.set_isometric_view()
return
min_x, max_x, min_y, max_y, min_z, max_z = [float(v) for v in bounds]
cx = (min_x + max_x) * 0.5
cy = (min_y + max_y) * 0.5
sx = max(1.0, max_x - min_x)
sy = max(1.0, max_y - min_y)
sz = max(1.0, max_z - min_z)
span = max(sx, sy, sz)
cz = min_z + sz * 0.5
vx, vy, vz = 1.0, -1.0, 0.75
norm = (vx * vx + vy * vy + vz * vz) ** 0.5
vx, vy, vz = vx / norm, vy / norm, vz / norm
ux, uy, uz = 0.0, 0.0, 1.0
fx, fy, fz = -vx, -vy, -vz
rx = fy * uz - fz * uy
ry = fz * ux - fx * uz
rz = fx * uy - fy * ux
r_norm = (rx * rx + ry * ry + rz * rz) ** 0.5
if r_norm <= 1e-6:
rx, ry, rz = 1.0, 0.0, 0.0
else:
rx, ry, rz = rx / r_norm, ry / r_norm, rz / r_norm
facility_margin = max(1.01, float(getattr(self, "_facility_iso_margin_factor", 1.16)))
facility_min_distance = max(1000.0, float(getattr(self, "_facility_iso_min_distance", 2280.0)))
facility_side_shift = max(0.0, float(getattr(self, "_facility_iso_side_shift_factor", 0.06)))
facility_z_lift = max(0.0, float(getattr(self, "_facility_iso_z_lift_factor", 0.10)))
if hasattr(self, "_compute_isometric_fit_distance"):
distance = float(
self._compute_isometric_fit_distance(
(min_x, max_x, min_y, max_y, min_z, max_z),
view_dir=(vx, vy, vz),
up_dir=(ux, uy, uz),
margin_factor=facility_margin,
min_distance=facility_min_distance,
)
)
else:
distance = max(facility_min_distance, span * 1.68)
target_focal = (cx, cy, cz)
target_pos = (
cx + vx * distance + rx * (span * facility_side_shift),
cy + vy * distance + ry * (span * facility_side_shift),
cz + vz * distance + (sz * facility_z_lift),
)
target_up = (ux, uy, uz)
except Exception as e:
log_exception(__name__, "animate_facility_isometric_view.compute_target", e)
self.set_isometric_view()
return
if hasattr(self, "_animate_camera_transition"):
try:
self._animate_camera_transition(
start_pos=start_pos,
start_focal=start_focal,
start_up=start_up,
target_pos=target_pos,
target_focal=target_focal,
target_up=target_up,
duration_ms=int(duration_ms),
steps=int(steps),
)
return
except Exception as e:
log_exception(__name__, "animate_facility_isometric_view.animate_transition", e)
self.set_isometric_view()
def _resolve_grid_click_point(
self: "ModelViewWidget",
x: Optional[float],
y: Optional[float],
z: Optional[float],
action: str,
) -> tuple[Optional[float], Optional[float], Optional[float]]:
"""Выбрать наилучшую world-точку для клика по сетке.
Приоритет кандидатов:
1. Прямой расчёт из позиции Qt-курсора с DPR-коррекцией
(независимо от VTK event pipeline).
2. screen_to_world_on_plane через VTK event position
(проекция на Z-плоскость сетки, без зависимости от геометрии).
3. pick_world (VTK picker — может попасть по чужой геометрии).
4. Исходная точка из PyVista callback.
"""
if not self._plotter or not self._plotter.interactor:
return x, y, z
candidates: list[tuple[Optional[float], Optional[float], Optional[float]]] = []
if self.is_scenario_active("contour_edit"):
plane_z = self._get_contour_plane_z()
else:
plane_z = self._get_grid_plane_z(getattr(self, "_grid_origin", None))
# ── 1. Кандидат из позиции Qt-курсора (DPR-safe) ─────────────
try:
from PySide6.QtGui import QCursor
global_pos = QCursor.pos()
local_pos = self._plotter.mapFromGlobal(global_pos)
dpr = self._plotter.devicePixelRatio()
ren_win = self._plotter.ren_win
if ren_win:
_, win_h = ren_win.GetSize()
q_sx = int(round(local_pos.x() * dpr))
q_sy = win_h - int(round(local_pos.y() * dpr)) - 1
wp = self.screen_to_world_on_plane(q_sx, q_sy, plane_z)
if wp is not None:
candidates.append((wp[0], wp[1], wp[2]))
except Exception as e:
log_exception(__name__, "_resolve_grid_click_point.qt_cursor_candidate", e)
# ── 2/3. Кандидаты из VTK event position ─────────────────────
try:
sx, sy = self._plotter.interactor.GetEventPosition()
except Exception as e:
log_exception(__name__, "_resolve_grid_click_point.get_event_position", e)
sx = sy = None
if sx is not None and sy is not None:
try:
world = self.screen_to_world_on_plane(sx, sy, plane_z)
if world is not None:
candidates.append((world[0], world[1], world[2]))
except Exception as e:
log_exception(__name__, "_resolve_grid_click_point.screen_to_world", e)
try:
picked = self.pick_world(sx, sy)
if picked is not None:
candidates.append((picked[0], picked[1], picked[2]))
except Exception as e:
log_exception(__name__, "_resolve_grid_click_point.pick_world", e)
# ── 4. Исходная точка из PyVista ──────────────────────────────
candidates.append((x, y, z))
# Для ПКМ приоритет у точки, попадающей в уже выделенную ячейку.
prefer_selected = (action == "remove")
for cx, cy, cz in candidates:
if cx is None or cy is None:
continue
found = None
try:
found = self._find_cell_by_point(float(cx), float(cy), prefer_selected=prefer_selected)
except Exception as e:
log_exception(__name__, "_resolve_grid_click_point.find_cell_by_point", e)
found = None
if found is not None:
return float(cx), float(cy), 0.0
# fallback: первая валидная точка
for cx, cy, cz in candidates:
if cx is not None and cy is not None:
return float(cx), float(cy), 0.0
return x, y, z
def _is_point_inside_room(self: "ModelViewWidget", x: float, y: float, z: float) -> bool:
"""Проверка, находится ли точка внутри помещения."""
if not self._room_bounds:
return True
mn_x, mx_x, mn_y, mx_y, mn_z, mx_z = self._room_bounds
margin = 10
return (
mn_x - margin <= x <= mx_x + margin
and mn_y - margin <= y <= mx_y + margin
and mn_z - margin <= z <= mx_z + margin
)
# ---------------------------------------------------------------------------
# Module workflow notes
# ---------------------------------------------------------------------------
# 1) Задача модуля:
# Базовые операции взаимодействия: координатные преобразования, режимы камеры, проверка точек.
#
# 2) Последовательность действий и вызовов:
# A. Класс InteractionCoreMixin: утилиты interactor + координаты + камера + сетка.
# - _is_plotter_interactor_event() — проверка источника события
# - _event_to_vtk_display_xy() — Qt logical → VTK display
# - _event_world_on_plane() — мировые координаты на плоскости
# - set_camera_locked() — блокировка/разблокировка камеры
# - set_top_view_navigation() — top-view: pan/zoom без вращения
# - set_isometric_view() — изометрический вид
# - _update_contour_drag_point() — перемещение узла контура
# - _resolve_grid_click_point() — world-точка клика по сетке
# - _is_point_inside_room() — внутри ли помещения
#
# 3) Важные ограничения и инварианты:
# - Выполняется в составе ModelViewWidget с согласованными полями self._... .
# - Операции с актёрами/камерой только при валидном self._plotter.
# - Очистка состояния идемпотентна.

View File

@@ -0,0 +1,54 @@
# -*- coding: utf-8 -*-
# gui/components/model_view/_mv_interaction_events.py
# Тонкий диспетчер событий: делегирует InteractionManager.
from __future__ import annotations
from typing import TYPE_CHECKING
from PySide6.QtCore import Qt, QEvent
if TYPE_CHECKING: # pragma: no cover
from gui.components.model_view_widget import ModelViewWidget
class InteractionEventsMixin:
"""Тонкий диспетчер Qt-событий → InteractionManager."""
def eventFilter(self: "ModelViewWidget", watched, event):
"""Маршрутизатор событий interactor через менеджер сценариев."""
if not self._is_plotter_interactor_event(watched):
return super().eventFilter(watched, event)
etype = event.type()
mgr = getattr(self, "_interaction_manager", None)
if mgr is not None:
result = mgr.dispatch_event_filter(watched, event)
if result is True:
return True
if result is False:
# По контракту InteractionManager: False = не передавать событие дальше.
return True
return super().eventFilter(watched, event)
def mousePressEvent(self: "ModelViewWidget", event):
"""Делегация mousePressEvent текущему сценарию."""
mgr = getattr(self, "_interaction_manager", None)
if mgr is not None and mgr.dispatch_widget_mouse_press(event):
return
super().mousePressEvent(event)
def mouseMoveEvent(self: "ModelViewWidget", event):
"""Делегация mouseMoveEvent текущему сценарию."""
mgr = getattr(self, "_interaction_manager", None)
if mgr is not None and mgr.dispatch_widget_mouse_move(event):
return
super().mouseMoveEvent(event)
def mouseReleaseEvent(self: "ModelViewWidget", event):
"""Делегация mouseReleaseEvent текущему сценарию."""
mgr = getattr(self, "_interaction_manager", None)
if mgr is not None and mgr.dispatch_widget_mouse_release(event):
return
super().mouseReleaseEvent(event)

View File

@@ -0,0 +1,486 @@
# -*- coding: utf-8 -*-
# gui/components/model_view/_mv_interaction_nav.py
# Обработчики кликов и навигация: клики на сцене, масштабирование, панорамирование, колесо мыши
from __future__ import annotations
import math
import time
from typing import TYPE_CHECKING
from error_logger import log_exception
if TYPE_CHECKING: # pragma: no cover
from gui.components.model_view_widget import ModelViewWidget
class InteractionNavigationMixin:
"""Клик-обработчики, зум, панорамирование, колесо мыши."""
def _pick_rack_from_cursor(self: "ModelViewWidget") -> tuple[str | None, str]:
"""Определить id стеллажа под курсором и контекстную зону."""
zone_id = str(getattr(self, "_rack_preview_zone_id", "") or "")
if not zone_id and getattr(self, "_rack_entries", None):
current_selected = str(getattr(self, "_selected_rack_id", "") or "")
if current_selected:
for entry in list(getattr(self, "_rack_entries", []) or []):
if str(entry.get("rack_id", "")) == current_selected:
zone_id = str(entry.get("zone_id", "") or "")
break
if not zone_id:
first_entry = next(iter(list(getattr(self, "_rack_entries", []) or [])), None)
if first_entry:
zone_id = str(first_entry.get("zone_id", "") or "")
rack_id = None
if self._plotter and self._plotter.interactor and hasattr(self, "_find_rack_id_by_screen"):
try:
sx, sy = self._plotter.interactor.GetEventPosition()
rack_id = self._find_rack_id_by_screen(float(sx), float(sy), zone_id, screen_is_vtk=True)
except Exception as e:
log_exception(__name__, "_pick_rack_from_cursor", e)
rack_id = None
return rack_id, zone_id
def _pick_zone_from_cursor(
self: "ModelViewWidget",
x: float | None,
y: float | None,
z: float | None,
) -> str | None:
"""Определить id зоны под текущим положением курсора."""
if not self._plotter:
return None
zone_id = None
try:
from vtkmodules.vtkRenderingCore import vtkPropPicker
sx, sy = self._plotter.interactor.GetEventPosition()
prop_picker = vtkPropPicker()
prop_picker.Pick(sx, sy, 0, self._plotter.renderer)
picked_actor = prop_picker.GetViewProp()
if picked_actor is not None:
zone_id = self._find_zone_by_actor(picked_actor)
except Exception as e:
log_exception(__name__, "_pick_zone_from_cursor", e)
zone_id = None
if not zone_id:
try:
sx, sy = self._plotter.interactor.GetEventPosition()
zone_id = self._find_zone_by_screen_ray(sx, sy)
except Exception as e:
log_exception(__name__, "_pick_zone_from_cursor", e)
zone_id = None
if not zone_id:
try:
sx, sy = self._plotter.interactor.GetEventPosition()
plane_z = self._get_grid_plane_z(getattr(self, "_grid_origin", None))
world = self.screen_to_world_on_plane(sx, sy, plane_z)
if world is not None:
best_top_z = None
for zid, polygon in self._zone_polygons.items():
if not polygon or len(polygon) < 3:
continue
inside, _ = self._classify_point_in_polygon(
world[0], world[1], polygon,
)
if inside:
top_z = self._get_zone_top_height(zid)
if best_top_z is None or top_z > best_top_z:
best_top_z = top_z
zone_id = zid
except Exception as e:
log_exception(__name__, "_pick_zone_from_cursor", e)
if not zone_id:
zone_id = self._find_zone_at_point(x, y, z)
return zone_id
def _on_plotter_click(self: "ModelViewWidget", point):
"""Обработчик клика на 3D-сцене."""
if not self._models_loaded:
return
if self._ignore_next_plotter_click:
self._ignore_next_plotter_click = False
return
x = y = z = None
if point is not None:
try:
x, y, z = point
except Exception as e:
log_exception(__name__, "_on_plotter_click", e)
x = y = z = None
if self._custom_click_handler:
try:
if self._custom_click_handler(x, y, z):
return
except Exception as e:
log_exception(__name__, "_on_plotter_click", e)
return
# Выбор полки в изолированном стеллаже (rack-level), когда выбор зон отключён.
# Полка кликабельна только при активной rack-изоляции (последовательная навигация).
if not self._zone_pick_enabled and not self._zone_selection_mode:
try:
_iso_active = bool(getattr(self, "_rack_isolation_active", False))
rid = str(getattr(self, "_rack_isolation_rack_id", "") or "") if _iso_active else ""
if rid and self._plotter and self._plotter.interactor and hasattr(self, "_find_rendered_shelf_by_screen"):
sx, sy = self._plotter.interactor.GetEventPosition()
shelf_hit = self._find_rendered_shelf_by_screen(float(sx), float(sy), rid, screen_is_vtk=True)
if shelf_hit:
slot_id, shelf_index = shelf_hit
tree_shelf_index = int(max(1, int(shelf_index)))
try:
entry = self._get_rack_entry(rid) if hasattr(self, "_get_rack_entry") else None
rack_type = str(((entry or {}).get("params") or {}).get("rack_type") or "").strip().upper()
if rack_type == "PALLET":
tree_shelf_index = int(max(1, int(shelf_index)) + 1)
except Exception as e:
log_exception(__name__, "_on_plotter_click", e)
tree_shelf_index = int(max(1, int(shelf_index)))
self._selected_shelf_visual_rack_id = rid
self._selected_shelf_visual_slot_id = str(slot_id)
self._selected_shelf_visual_index = int(tree_shelf_index)
if hasattr(self, "_highlight_selected_rendered_shelf"):
self._highlight_selected_rendered_shelf(
rid,
str(slot_id),
int(max(1, int(shelf_index))),
)
self.shelf_slot_selected.emit(rid, str(slot_id)) # type: ignore[attr-defined]
return
# Для PALLET разрешаем активацию уровня "под первой полкой"
# кликом по области слота (даже если slot_actor скрыт в изоляции).
try:
entry = self._get_rack_entry(rid) if hasattr(self, "_get_rack_entry") else None
zone_id = str((entry or {}).get("zone_id") or "")
slot_hit_id = self._find_slot_id_by_screen(
float(sx),
float(sy),
rid,
zone_id,
screen_is_vtk=True,
include_hidden=True,
) if hasattr(self, "_find_slot_id_by_screen") else None
rack_type = str(((entry or {}).get("params") or {}).get("rack_type") or "").strip().upper()
if slot_hit_id and rack_type == "PALLET":
self._selected_shelf_visual_rack_id = rid
self._selected_shelf_visual_slot_id = str(slot_hit_id)
self._selected_shelf_visual_index = 1
if hasattr(self, "_highlight_floor_shelf_polygon"):
self._highlight_floor_shelf_polygon(rid, str(slot_hit_id))
self.shelf_slot_selected.emit(rid, str(slot_hit_id)) # type: ignore[attr-defined]
return
except Exception as e:
log_exception(__name__, "_on_plotter_click", e)
except Exception as e:
log_exception(__name__, "_on_plotter_click", e)
if self._zone_pick_enabled and not self._zone_selection_mode and x is not None:
zone_id = self._pick_zone_from_cursor(x, y, z)
if zone_id:
self.zone_selected.emit(zone_id)
return
# В режиме сетки/контура создание точек не зависит от фиксации камеры.
if self._zone_selection_mode:
x, y, z = self._resolve_grid_click_point(x, y, z, action="add")
if x is None:
return
z = float(z) if z is not None else 0.0
self._handle_grid_click(x, y, z, action="add")
return
def _on_plotter_double_click(self: "ModelViewWidget", point):
"""Обработчик двойного ЛКМ: выбрать и сфокусировать зону."""
if not self._models_loaded or self._zone_selection_mode:
return
# Двойной ЛКМ по полке в изолированном стеллаже: синхронизировать дерево и сфокусировать полку.
# Полка доступна только при активной rack-изоляции (последовательная навигация zone→rack→shelf).
try:
_iso_active = bool(getattr(self, "_rack_isolation_active", False))
rid = str(getattr(self, "_rack_isolation_rack_id", "") or "") if _iso_active else ""
if rid and self._plotter and self._plotter.interactor and hasattr(self, "_find_rendered_shelf_by_screen"):
sx, sy = self._plotter.interactor.GetEventPosition()
shelf_hit = self._find_rendered_shelf_by_screen(float(sx), float(sy), rid, screen_is_vtk=True)
if shelf_hit:
slot_id, shelf_index = shelf_hit
tree_shelf_index = int(max(1, int(shelf_index)))
try:
entry = self._get_rack_entry(rid) if hasattr(self, "_get_rack_entry") else None
rack_type = str(((entry or {}).get("params") or {}).get("rack_type") or "").strip().upper()
if rack_type == "PALLET":
tree_shelf_index = int(max(1, int(shelf_index)) + 1)
except Exception as e:
log_exception(__name__, "_on_plotter_double_click", e)
tree_shelf_index = int(max(1, int(shelf_index)))
self._selected_shelf_visual_rack_id = rid
self._selected_shelf_visual_slot_id = str(slot_id)
self._selected_shelf_visual_index = int(tree_shelf_index)
if hasattr(self, "_highlight_selected_rendered_shelf"):
self._highlight_selected_rendered_shelf(
rid,
str(slot_id),
int(max(1, int(shelf_index))),
)
self.shelf_slot_selected.emit(rid, str(slot_id)) # type: ignore[attr-defined]
try:
self.shelf_slot_double_clicked.emit(
rid,
str(slot_id),
int(tree_shelf_index),
) # type: ignore[attr-defined]
except Exception as e:
log_exception(__name__, "_on_plotter_double_click", e)
zone_id = ""
if hasattr(self, "_get_rack_entry"):
entry = self._get_rack_entry(rid)
if entry:
zone_id = str(entry.get("zone_id") or "")
if hasattr(self, "focus_on_shelf_slot_isometric"):
self.focus_on_shelf_slot_isometric(rid, str(slot_id), int(tree_shelf_index), zone_id)
return
# Для PALLET: двойной клик по области слота активирует уровень пола (index=0).
entry = self._get_rack_entry(rid) if hasattr(self, "_get_rack_entry") else None
zone_id = str((entry or {}).get("zone_id") or "")
slot_hit_id = self._find_slot_id_by_screen(
float(sx),
float(sy),
rid,
zone_id,
screen_is_vtk=True,
include_hidden=True,
) if hasattr(self, "_find_slot_id_by_screen") else None
rack_type = str(((entry or {}).get("params") or {}).get("rack_type") or "").strip().upper()
if slot_hit_id and rack_type == "PALLET":
self._selected_shelf_visual_rack_id = rid
self._selected_shelf_visual_slot_id = str(slot_hit_id)
self._selected_shelf_visual_index = 1
if hasattr(self, "_highlight_floor_shelf_polygon"):
self._highlight_floor_shelf_polygon(rid, str(slot_hit_id))
self.shelf_slot_selected.emit(rid, str(slot_hit_id)) # type: ignore[attr-defined]
try:
self.shelf_slot_double_clicked.emit(
rid,
str(slot_hit_id),
1,
) # type: ignore[attr-defined]
except Exception as e:
log_exception(__name__, "_on_plotter_double_click", e)
if hasattr(self, "focus_on_shelf_slot_isometric"):
self.focus_on_shelf_slot_isometric(rid, str(slot_hit_id), 1, zone_id)
return
except Exception as e:
log_exception(__name__, "_on_plotter_double_click", e)
# При двойном клике по стеллажу — выделение и фокус камеры на стеллаж.
rack_id, rack_zone_id = self._pick_rack_from_cursor()
if rack_id:
if hasattr(self, "_set_selected_rack"):
try:
self._set_selected_rack(rack_id)
except Exception as e:
log_exception(__name__, "_on_plotter_double_click", e)
try:
self.rack_double_clicked.emit(str(rack_id), str(rack_zone_id or ""))
except Exception as e:
log_exception(__name__, "_on_plotter_double_click", e)
if hasattr(self, "focus_on_rack_isometric"):
self.focus_on_rack_isometric(rack_id, rack_zone_id)
return
if not self._zone_pick_enabled:
return
x = y = z = None
if point is not None:
try:
x, y, z = point
except Exception as e:
log_exception(__name__, "_on_plotter_double_click", e)
x = y = z = None
zone_id = self._pick_zone_from_cursor(x, y, z)
if not zone_id:
return
self.zone_selected.emit(zone_id)
try:
self.zone_double_clicked.emit(zone_id)
except Exception as e:
log_exception(__name__, "_on_plotter_double_click", e)
if hasattr(self, "focus_on_zone_isometric"):
self.focus_on_zone_isometric(zone_id)
def _on_plotter_right_click(self: "ModelViewWidget", point):
"""Обработчик ПКМ на 3D-сцене (используется для сокращения объёма сетки)."""
if not self._models_loaded:
return
if not self._zone_selection_mode:
return
# В contour_edit ПКМ обрабатывается через сценарий eventFilter,
# чтобы гарантированно блокировать перехват камеры.
if self.is_scenario_active("contour_edit"):
return
x = y = z = None
if point is not None:
try:
x, y, z = point
except Exception as e:
log_exception(__name__, "_on_plotter_right_click", e)
x = y = z = None
x, y, z = self._resolve_grid_click_point(x, y, z, action="remove")
if x is None:
return
z = float(z) if z is not None else 0.0
self._handle_grid_click(x, y, z, action="remove")
# -- масштабирование ------------------------------------------------------
def zoom_in(self: "ModelViewWidget"):
"""Увеличение масштаба."""
if not self._models_loaded or not self._plotter:
return
self._plotter.camera.zoom(1.2)
if hasattr(self, "_reset_camera_clipping_range"):
self._reset_camera_clipping_range()
self._plotter.update()
def zoom_out(self: "ModelViewWidget"):
"""Уменьшение масштаба."""
if not self._models_loaded or not self._plotter:
return
self._plotter.camera.zoom(0.8)
if hasattr(self, "_reset_camera_clipping_range"):
self._reset_camera_clipping_range()
self._plotter.update()
def _pan_camera_by_pixels(self: "ModelViewWidget", dx: float, dy: float) -> None:
"""Сместить камеру в плоскости экрана на величину в пикселях."""
if not self._plotter or not getattr(self._plotter, "camera", None):
return
try:
cam = self._plotter.camera
pos = cam.GetPosition()
foc = cam.GetFocalPoint()
up = cam.GetViewUp()
vx = foc[0] - pos[0]
vy = foc[1] - pos[1]
vz = foc[2] - pos[2]
dist = math.sqrt(vx * vx + vy * vy + vz * vz) or 1.0
# right = normalize(направление_взгляда × up)
rx = vy * up[2] - vz * up[1]
ry = vz * up[0] - vx * up[2]
rz = vx * up[1] - vy * up[0]
rlen = math.sqrt(rx * rx + ry * ry + rz * rz) or 1.0
rx, ry, rz = rx / rlen, ry / rlen, rz / rlen
# нормализация вектора up
ulen = math.sqrt(up[0] * up[0] + up[1] * up[1] + up[2] * up[2]) or 1.0
ux, uy, uz = up[0] / ulen, up[1] / ulen, up[2] / ulen
# масштаб переноса: пропорционален удалению камеры
scale = dist / 700.0
tx = (-dx * rx + dy * ux) * scale
ty = (-dx * ry + dy * uy) * scale
tz = (-dx * rz + dy * uz) * scale
cam.SetPosition(pos[0] + tx, pos[1] + ty, pos[2] + tz)
cam.SetFocalPoint(foc[0] + tx, foc[1] + ty, foc[2] + tz)
except Exception as e:
log_exception(__name__, "_pan_camera_by_pixels", e)
return
def _safe_render(self: "ModelViewWidget", min_interval_s: float = 1.0 / 60.0) -> None:
"""Безопасный обновляющий рендер с ограничением частоты."""
plotter = self._plotter
if not plotter or not self._models_loaded:
return
try:
interactor = getattr(plotter, "interactor", None)
if interactor is None:
return
if hasattr(plotter, "isVisible") and not plotter.isVisible():
return
if hasattr(interactor, "isVisible") and not interactor.isVisible():
return
now = time.monotonic()
last_ts = float(getattr(self, "_last_safe_render_ts", 0.0))
if now - last_ts < float(min_interval_s):
return
self._last_safe_render_ts = now
if hasattr(plotter, "update"):
plotter.update()
else:
plotter.render()
except Exception as e:
log_exception(__name__, "_safe_render", e)
return
# -- Qt события -----------------------------------------------------------
def wheelEvent(self: "ModelViewWidget", event):
"""Обработка колесика мыши для зума."""
if self._camera_locked:
event.accept()
return
if self._models_loaded and self._plotter:
delta = event.angleDelta().y()
if delta > 0:
self.zoom_in()
else:
self.zoom_out()
event.accept()
# ---------------------------------------------------------------------------
# Module workflow notes
# ---------------------------------------------------------------------------
# 1) Задача модуля:
# Навигация камеры: клики, масштаб, панорамирование, безопасный рендер.
#
# 2) Последовательность действий и вызовов:
# A. Класс InteractionNavigationMixin: точки входа
# Публичные методы сценария:
# - InteractionNavigationMixin.zoom_in(...)
# - InteractionNavigationMixin.zoom_out(...)
# - InteractionNavigationMixin.wheelEvent(...)
#
# B. InteractionNavigationMixin: основной сценарий:
# InteractionNavigationMixin._on_plotter_click(...)
# Назначение: Обработчик клика на 3D-сцене.
# InteractionNavigationMixin._on_plotter_right_click(...)
# Назначение: Обработчик ПКМ на 3D-сцене (используется для сокращения объёма сетки).
# InteractionNavigationMixin.zoom_in(...)
# Назначение: Увеличение масштаба.
# InteractionNavigationMixin.zoom_out(...)
# Назначение: Уменьшение масштаба.
# InteractionNavigationMixin._pan_camera_by_pixels(...)
# Назначение: Сместить камеру в плоскости экрана на величину в пикселях.
# InteractionNavigationMixin.wheelEvent(...)
# Назначение: Обработка колесика мыши для зума.
# Последовательность внутренних вызовов:
# -> InteractionNavigationMixin.zoom_in(...)
# -> InteractionNavigationMixin.zoom_out(...)
#
# C. InteractionNavigationMixin: вспомогательные расчёты:
# InteractionNavigationMixin._safe_render(...)
# Назначение: Безопасный обновляющий рендер с ограничением частоты.
#
# 3) Важные ограничения и инварианты:
# - Модуль выполняется в составе ModelViewWidget и использует согласованные поля состояния self._... .
# - Все операции с актёрами и камерой выполняются только при валидном self._plotter с безопасной обработкой исключений.
# - Межмодульная связность: только через фасад model_view; прямые обращения между zone, rack, shelf, cell запрещены.
# - Очистка состояния должна быть идемпотентной: повторный вызов не меняет корректное состояние в ошибочное.
#
# 4) Правило сопровождения:
# - Любое изменение сценария должно сопровождаться обновлением этого блока с сохранением фактического порядка вызовов.
# - При добавлении метода указывать его место в цепочке сценария (запуск, основной шаг, завершение, вспомогательная логика).

View File

@@ -0,0 +1,409 @@
# -*- coding: utf-8 -*-
# gui/components/model_view/_mv_model_loading.py
# Загрузка/очистка моделей
from __future__ import annotations
import threading
import time
from pathlib import Path
from typing import Optional, Callable, TYPE_CHECKING
from PySide6.QtWidgets import QApplication, QSizePolicy
from error_logger import log_exception
if TYPE_CHECKING: # pragma: no cover
from gui.components.model_view_widget import ModelViewWidget
try:
import pyvista as pv
from pyvistaqt import QtInteractor
PYVISTA_AVAILABLE = True
except ImportError:
PYVISTA_AVAILABLE = False
class ModelLoadingMixin:
"""Загрузка моделей пола, стен, потолка и ферм."""
# -- публичные ------------------------------------------------------------
def load_facility_models(
self: "ModelViewWidget",
model_path: Path,
facility_data: Optional[dict] = None,
progress_callback: Optional[Callable[[int], None]] = None,
) -> bool:
"""Загрузка всех моделей помещения (пол, стены, стеллажи)."""
if not PYVISTA_AVAILABLE:
self.show_error("Библиотека PyVista не установлена")
return False
if not model_path.exists():
self.show_error(f"Путь к моделям не найден: {model_path}")
return False
def _emit_progress(value: int) -> None:
if progress_callback is None:
return
try:
progress_callback(max(0, min(100, int(value))))
except Exception as e:
log_exception(__name__, "load_facility_models._emit_progress", e)
self.clear_model()
try:
_emit_progress(1)
self._plotter = QtInteractor(self)
self._plotter.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
self._plotter.setMinimumSize(0, 0)
self._main_container.add_widget(self._plotter)
QApplication.processEvents()
if self._plotter.interactor is not None:
self._plotter.interactor.installEventFilter(self)
# Градиентный фон рендера (PyVista/VTK)
self._plotter.set_background((0.84, 0.84, 0.84), top=(0.34, 0.34, 0.34))
_emit_progress(5)
missing: list[str] = []
def _read_mesh_in_worker(path_str: str, progress_value: int):
payload: dict[str, object] = {}
def _worker() -> None:
try:
payload["mesh"] = pv.read(path_str)
except Exception as exc: # pragma: no cover
payload["error"] = exc
thread = threading.Thread(target=_worker, daemon=True)
thread.start()
# Поддерживаем отзывчивость UI во время тяжёлого парсинга STL.
while thread.is_alive():
_emit_progress(progress_value)
try:
QApplication.processEvents()
except Exception as e:
log_exception(__name__, "load_facility_models._read_mesh_in_worker", e)
time.sleep(0.01)
thread.join()
if "error" in payload:
raise payload["error"] # type: ignore[misc]
return payload.get("mesh")
def load_model(
key: str,
rel_path: Optional[str],
color: str,
opacity: float,
progress_start: int,
progress_end: int,
):
_emit_progress(progress_start)
if not rel_path:
_emit_progress(progress_end)
return
rel = Path(rel_path)
if rel.is_absolute():
file_path = rel
else:
if rel_path.startswith("facility/"):
base_root = model_path.parent.parent
file_path = base_root / rel
else:
file_path = model_path / rel
if not file_path.exists():
missing.append(rel_path)
_emit_progress(progress_end)
return
try:
mesh = _read_mesh_in_worker(str(file_path), progress_start)
if mesh is None:
_emit_progress(progress_end)
return
actor = self._plotter.add_mesh(
mesh, color=color, opacity=opacity, show_edges=False,
)
self._model_actors[key] = actor
if key == "floor":
self._floor_mesh = mesh
elif key == "walls":
self._walls_mesh = mesh
elif key == "ceiling":
self._ceiling_mesh = mesh
elif key == "truss":
self._truss_mesh = mesh
print(f"Загружена модель {key}: {file_path}")
except Exception as e:
log_exception(__name__, "load_model", e)
floor_path = facility_data.get("facility_floor") if facility_data else "floor.stl"
walls_path = facility_data.get("facility_walls") if facility_data else "walls.stl"
ceiling_path = facility_data.get("facility_ceiling") if facility_data else None
truss_path = facility_data.get("facility_truss") if facility_data else None
model_jobs = [
("floor", floor_path, "#CCCCCC", 0.8),
("walls", walls_path, "#AAAAAA", 0.6),
("ceiling", ceiling_path, "#B0B0B0", 0.5),
("truss", truss_path, "#888888", 0.5),
]
total_jobs = max(1, len(model_jobs))
for idx, (key, rel_path, color, opacity) in enumerate(model_jobs, start=1):
start_p = 10 + int(((idx - 1) / total_jobs) * 60)
end_p = 10 + int((idx / total_jobs) * 60)
load_model(key, rel_path, color, opacity, start_p, end_p)
_emit_progress(end_p)
# Собираем уникальные вершины для выбора углов
try:
points: list[tuple[float, ...]] = []
for mesh in (self._floor_mesh, self._walls_mesh, self._ceiling_mesh, self._truss_mesh):
if mesh is None:
continue
if hasattr(mesh, "points"):
points.extend(mesh.points.tolist())
unique: list[tuple[float, float, float]] = []
seen: set[tuple[float, float, float]] = set()
for px, py, pz in points:
key = (round(px, 3), round(py, 3), round(pz, 3))
if key in seen:
continue
seen.add(key)
unique.append((px, py, pz))
self.set_corner_points(unique)
except Exception as e:
log_exception(__name__, "load_facility_models", e)
_emit_progress(75)
# Рассчитываем bounding box помещения
self._calculate_room_bounds()
_emit_progress(82)
# Настраиваем стартовый изометрический вид facility.
self._apply_facility_isometric_view(reset_camera=True, update=False)
self._plotter.enable_trackball_style()
self._setup_trackball_right_button()
_emit_progress(90)
# Подключаем обработчик кликов
self._plotter.track_click_position(
callback=self._on_plotter_click,
side="left",
double=False,
)
self._plotter.track_click_position(
callback=self._on_plotter_double_click,
side="left",
double=True,
)
self._plotter.track_click_position(
callback=self._on_plotter_right_click,
side="right",
double=False,
)
_emit_progress(95)
self._models_loaded = True
self._reset_camera_clipping_range()
print(
f"Загружено моделей: пол={self._floor_mesh is not None}, "
f"стены={self._walls_mesh is not None}, "
f"потолок={'ceiling' in self._model_actors}, "
f"фермы={'truss' in self._model_actors}"
)
self._missing_models = set(missing)
_emit_progress(100)
return True
except Exception as e:
error_msg = f"Ошибка загрузки моделей: {e}"
print(error_msg)
self.show_error(error_msg)
return False
# -- границы --------------------------------------------------------------
def _calculate_room_bounds(self: "ModelViewWidget") -> None:
"""Рассчёт bounding box помещения на основе моделей."""
if not self._floor_mesh:
return
try:
bounds = self._floor_mesh.bounds
if self._walls_mesh:
wb = self._walls_mesh.bounds
bounds = (
min(bounds[0], wb[0]),
max(bounds[1], wb[1]),
min(bounds[2], wb[2]),
max(bounds[3], wb[3]),
min(bounds[4], wb[4]),
max(bounds[5], wb[5]),
)
for mesh in (self._ceiling_mesh, self._truss_mesh):
if mesh is None:
continue
mb = mesh.bounds
bounds = (
min(bounds[0], mb[0]),
max(bounds[1], mb[1]),
min(bounds[2], mb[2]),
max(bounds[3], mb[3]),
min(bounds[4], mb[4]),
max(bounds[5], mb[5]),
)
self._room_bounds = bounds
print(f"Room bounds: {bounds}")
except Exception as e:
print(f"Ошибка расчета bounding box: {e}")
self._room_bounds = None
# -- очистка / видимость --------------------------------------------------
def clear_model(self: "ModelViewWidget") -> None:
"""Очистка области модели."""
self._models_loaded = False
try:
self.clear_all_racks()
except Exception as e:
log_exception(__name__, "clear_model", e)
if self._plotter is not None:
try:
interactor = getattr(self._plotter, "interactor", None)
if interactor is not None:
try:
interactor.removeEventFilter(self)
except Exception as e:
log_exception(__name__, "clear_model", e)
self._plotter.close()
except Exception as e:
log_exception(__name__, "clear_model", e)
self._plotter = None
if hasattr(self, "_main_container") and self._main_container is not None:
content_host = getattr(self._main_container, "_content_host", None)
if content_host is not None:
layout = content_host.get_layout()
while layout.count():
item = layout.takeAt(0)
w = item.widget()
if w is not None:
w.setParent(None)
w.deleteLater()
self._zones.clear()
self._zone_data.clear()
self._floor_mesh = None
self._walls_mesh = None
self._ceiling_mesh = None
self._truss_mesh = None
self._rack_meshes.clear()
self._room_bounds = None
self._model_actors.clear()
def _reload_scene(self: "ModelViewWidget") -> None:
"""Перезагрузка сцены (используется при удалении зон)."""
if not self._models_loaded:
return
current_camera = self._plotter.camera_position # noqa: F841
zones_backup = list(self._zones.items()) # noqa: F841
self.clear_model()
print("Требуется перезагрузка моделей для обновления зон")
def _reset_camera_clipping_range(self: "ModelViewWidget") -> None:
"""Обновить near/far clipping range камеры по текущей сцене."""
if not self._plotter:
return
try:
renderer = getattr(self._plotter, "renderer", None)
if renderer is not None:
renderer.ResetCameraClippingRange()
else:
reset_fn = getattr(self._plotter, "reset_camera_clipping_range", None)
if callable(reset_fn):
reset_fn()
except Exception as e:
log_exception(__name__, "_reset_camera_clipping_range", e)
def set_model_visibility(self: "ModelViewWidget", key: str, visible: bool) -> None:
actor = self._model_actors.get(key)
if actor is None:
return
try:
actor.SetVisibility(1 if visible else 0)
self._plotter.update()
except Exception as e:
log_exception(__name__, "set_model_visibility", e)
def has_model(self: "ModelViewWidget", key: str) -> bool:
return key in self._model_actors
def is_model_visible(self: "ModelViewWidget", key: str) -> bool:
actor = self._model_actors.get(key)
if actor is None:
return False
try:
return bool(actor.GetVisibility())
except Exception as e:
log_exception(__name__, "is_model_visible", e)
return False
# ---------------------------------------------------------------------------
# Module workflow notes
# ---------------------------------------------------------------------------
# 1) Задача модуля:
# Загрузка и очистка моделей помещения, пересборка сцены и управление видимостью.
#
# 2) Последовательность действий и вызовов:
# A. Класс ModelLoadingMixin: точки входа
# Публичные методы сценария:
# - ModelLoadingMixin.load_facility_models(...)
# - ModelLoadingMixin.clear_model(...)
# - ModelLoadingMixin.set_model_visibility(...)
# - ModelLoadingMixin.has_model(...)
# - ModelLoadingMixin.is_model_visible(...)
#
# B. ModelLoadingMixin: запуск и настройка:
# ModelLoadingMixin.load_facility_models(...)
# Назначение: Загрузка всех моделей помещения (пол, стены, стеллажи).
# Последовательность внутренних вызовов:
# -> ModelLoadingMixin.clear_model(...)
# -> ModelLoadingMixin._calculate_room_bounds(...)
# -> ModelLoadingMixin._reset_camera_clipping_range(...)
# ModelLoadingMixin.set_model_visibility(...)
# Назначение: устанавливает model visibility в рамках текущего сценария модуля.
#
# C. ModelLoadingMixin: основной сценарий:
# ModelLoadingMixin.has_model(...)
# Назначение: проверяет наличие model в рамках текущего сценария модуля.
# ModelLoadingMixin.is_model_visible(...)
# Назначение: проверяет, что model visible в рамках текущего сценария модуля.
#
# D. ModelLoadingMixin: завершение и очистка:
# ModelLoadingMixin.clear_model(...)
# Назначение: Очистка области модели.
#
# E. ModelLoadingMixin: вспомогательные расчёты:
# ModelLoadingMixin._calculate_room_bounds(...)
# Назначение: Рассчёт bounding box помещения на основе моделей.
# ModelLoadingMixin._reload_scene(...)
# Назначение: Перезагрузка сцены (используется при удалении зон).
# Последовательность внутренних вызовов:
# -> ModelLoadingMixin.clear_model(...)
# ModelLoadingMixin._reset_camera_clipping_range(...)
# Назначение: Обновить near/far clipping range камеры по текущей сцене.
#
# 3) Важные ограничения и инварианты:
# - Модуль выполняется в составе ModelViewWidget и использует согласованные поля состояния self._... .
# - Все операции с актёрами и камерой выполняются только при валидном self._plotter с безопасной обработкой исключений.
# - Геометрическая визуализация зависит от pyvista/vtk; при недоступности модуль обязан завершать шаг без падения сценария.
# - Межмодульная связность: только через фасад model_view; прямые обращения между zone, rack, shelf, cell запрещены.
# - Очистка состояния должна быть идемпотентной: повторный вызов не меняет корректное состояние в ошибочное.
#
# 4) Правило сопровождения:
# - Любое изменение сценария должно сопровождаться обновлением этого блока с сохранением фактического порядка вызовов.
# - При добавлении метода указывать его место в цепочке сценария (запуск, основной шаг, завершение, вспомогательная логика).

View File

@@ -0,0 +1,131 @@
# -*- coding: utf-8 -*-
# gui/components/model_view/_mv_presentation.py
"""Фасад видимости сцены для применения декларативных спецификаций представления."""
from __future__ import annotations
from typing import TYPE_CHECKING
from error_logger import log_exception
if TYPE_CHECKING:
from gui.components.model_view_widget import ModelViewWidget
class ScenePresentationMixin:
"""Применение спецификаций видимости сцены через публичный API ModelView."""
@staticmethod
def _safe_call(fn, *args, **kwargs):
try:
return fn(*args, **kwargs)
except Exception as exc:
log_exception(__name__, "ScenePresentationMixin._safe_call", exc)
return None
@staticmethod
def _rack_filter_value(spec) -> str:
rack_filter = getattr(spec, "rack_filter", "")
return str(getattr(rack_filter, "value", rack_filter) or "")
def apply_scene_spec(
self: "ModelViewWidget",
spec,
*,
start_select_rack: bool = False,
select_rack_id: str | None = None,
) -> None:
"""Применить ``SceneVisibilitySpec``, созданный политикой представления."""
if self is None or spec is None:
return
zone_contour_id = getattr(spec, "zone_contour_id", None)
if zone_contour_id:
self._safe_call(self.show_zone_contour, zone_contour_id)
elif bool(getattr(spec, "zone_solids_visible", False)):
self._safe_call(self.show_all_zones)
if bool(getattr(spec, "use_facility_contour_mode", False)) and hasattr(
self, "set_zone_facility_contour_mode"
):
zone_ids = [str(zid) for zid in getattr(self, "_zones", {}).keys()]
for zid in zone_ids:
has_racks = self.has_racks_in_zone(zid) if hasattr(self, "has_racks_in_zone") else False
self._safe_call(self.set_zone_facility_contour_mode, zid, has_racks)
if bool(getattr(spec, "hide_empty_zones", False)) and hasattr(self, "has_racks_in_zone"):
zone_ids = [str(zid) for zid in getattr(self, "_zones", {}).keys()]
for zid in zone_ids:
if not self.has_racks_in_zone(zid):
self._safe_call(self.set_zone_visibility, zid, False)
else:
self._safe_call(self.set_zone_facility_contour_mode, zid, False)
self._safe_call(self.set_zone_visibility, zid, False)
self._safe_call(self.set_zone_pick_enabled, bool(getattr(spec, "zone_pick_enabled", True)))
rack_filter = self._rack_filter_value(spec)
rack_zone_id = getattr(spec, "rack_zone_id", None)
rack_id = getattr(spec, "rack_id", None)
if rack_filter == "single_rack":
isolated = False
if hasattr(self, "show_only_rack") and rack_id:
isolated = bool(self._safe_call(self.show_only_rack, rack_id, rack_zone_id))
if not isolated and rack_zone_id:
self._safe_call(self.show_only_zone_racks, rack_zone_id)
elif rack_filter == "zone_only":
self._safe_call(self.show_only_zone_racks, rack_zone_id)
else:
self._safe_call(self.show_all_racks)
rack_select_mode = bool(getattr(spec, "rack_select_mode", False))
if rack_select_mode and start_select_rack and rack_zone_id:
self._safe_call(self.start_select_rack_mode, rack_zone_id)
elif not rack_select_mode:
self._safe_call(self.stop_select_rack_mode)
if select_rack_id and hasattr(self, "select_rack_by_id"):
self._safe_call(self.select_rack_by_id, select_rack_id, rack_zone_id)
if bool(getattr(spec, "clear_hover", False)):
mgr = getattr(self, "_interaction_manager", None)
if mgr is not None:
self._safe_call(mgr.reset)
self._safe_call(self.set_top_view_navigation, bool(getattr(spec, "top_view_navigation", False)))
if hasattr(self, "update_scene"):
self._safe_call(self.update_scene)
# ---------------------------------------------------------------------------
# Module workflow notes
# ---------------------------------------------------------------------------
# 1) Задача модуля:
# Применение наборов параметров отображения сцены и безопасный вызов методов представления.
#
# 2) Последовательность действий и вызовов:
# A. Класс ScenePresentationMixin: точки входа
# Публичные методы сценария:
# - ScenePresentationMixin.apply_scene_spec(...)
#
# B. ScenePresentationMixin: основной сценарий:
# ScenePresentationMixin.apply_scene_spec(...)
# Назначение: Применить ``SceneVisibilitySpec``, созданный политикой представления.
# Последовательность внутренних вызовов:
# -> ScenePresentationMixin._safe_call(...)
# -> ScenePresentationMixin._rack_filter_value(...)
#
# C. ScenePresentationMixin: вспомогательные расчёты:
# ScenePresentationMixin._safe_call(...)
# Назначение: выполняет шаг "safe call" в рамках текущего сценария модуля.
# ScenePresentationMixin._rack_filter_value(...)
# Назначение: выполняет шаг "rack filter value" в рамках текущего сценария модуля.
#
# 3) Важные ограничения и инварианты:
# - Модуль выполняется в составе ModelViewWidget и использует согласованные поля состояния self._... .
# - Межмодульная связность: только через фасад model_view; прямые обращения между zone, rack, shelf, cell запрещены.
# - Очистка состояния должна быть идемпотентной: повторный вызов не меняет корректное состояние в ошибочное.
#
# 4) Правило сопровождения:
# - Любое изменение сценария должно сопровождаться обновлением этого блока с сохранением фактического порядка вызовов.
# - При добавлении метода указывать его место в цепочке сценария (запуск, основной шаг, завершение, вспомогательная логика).

View File

@@ -0,0 +1,197 @@
# -*- coding: utf-8 -*-
# gui/components/model_view/_mv_rack_geometry.py
"""Локальная геометрия размещения стоек (без зависимостей от hub)."""
from __future__ import annotations
from typing import Any
PLACEMENT_STEP_MM = 100
MIN_WALL_BUFFER_MM = 100
MIN_AISLE_MM = 900
MIN_BACK_TO_BACK_GAP_MM = 100
def rotate_xy(x: float, y: float, rotation: int) -> tuple[float, float]:
rot = int(rotation) % 360
if rot == 90:
return -y, x
if rot == 180:
return -x, -y
if rot == 270:
return y, -x
return x, y
def rack_bbox(
cx: float,
cy: float,
params: dict[str, Any],
rotation: int,
wall_buffer: float = 0.0,
) -> tuple[float, float, float, float]:
width = float(params.get("footprint_width_mm", 1000))
depth = float(params.get("footprint_depth_mm", 500))
if int(rotation) % 180 == 90:
width, depth = depth, width
width += wall_buffer * 2.0
depth += wall_buffer * 2.0
half_w = width / 2.0
half_d = depth / 2.0
return cx - half_w, cx + half_w, cy - half_d, cy + half_d
def passes_clearance_rule(
a: tuple[float, float, float, float],
b: tuple[float, float, float, float],
min_gap: float,
) -> bool:
a_min_x, a_max_x, a_min_y, a_max_y = a
b_min_x, b_max_x, b_min_y, b_max_y = b
overlap_x = min(a_max_x, b_max_x) - max(a_min_x, b_min_x)
overlap_y = min(a_max_y, b_max_y) - max(a_min_y, b_min_y)
if overlap_x > 0 and overlap_y > 0:
return False
gap_x = max(0.0, max(a_min_x - b_max_x, b_min_x - a_max_x))
gap_y = max(0.0, max(a_min_y - b_max_y, b_min_y - a_max_y))
if overlap_y > 0 and gap_x < min_gap:
return False
if overlap_x > 0 and gap_y < min_gap:
return False
# Диагональный случай: учитываем минимальную евклидову дистанцию между прямоугольниками.
if gap_x > 0 and gap_y > 0 and (gap_x * gap_x + gap_y * gap_y) ** 0.5 < min_gap:
return False
return True
def bboxes_intersect(
a: tuple[float, float, float, float],
b: tuple[float, float, float, float],
eps: float = 1e-6,
) -> bool:
a_min_x, a_max_x, a_min_y, a_max_y = a
b_min_x, b_max_x, b_min_y, b_max_y = b
overlap_x = min(a_max_x, b_max_x) - max(a_min_x, b_min_x)
overlap_y = min(a_max_y, b_max_y) - max(a_min_y, b_min_y)
return overlap_x > eps and overlap_y > eps
# зона подхода к стеллажу
def rack_aisle_bboxes(
cx: float,
cy: float,
params: dict[str, Any],
rotation: int,
aisle_mm: float = MIN_AISLE_MM,
) -> list[tuple[float, float, float, float]]:
width = float(params.get("footprint_width_mm", 1000))
depth = float(params.get("footprint_depth_mm", 500))
aisle = float(aisle_mm)
# Зона доступа представлена одним объёмом (один прямоугольник).
strips = [
(0.0, aisle / 2.0, width + 2.0 * aisle, depth + aisle),
]
result: list[tuple[float, float, float, float]] = []
for lx, ly, xl, yl in strips:
wx, wy = rotate_xy(lx, ly, rotation)
if int(rotation) % 180 == 90:
xl, yl = yl, xl
hxl = max(10.0, float(xl)) / 2.0
hyl = max(10.0, float(yl)) / 2.0
result.append((cx + wx - hxl, cx + wx + hxl, cy + wy - hyl, cy + wy + hyl))
return result
def front_vector(rotation: int) -> tuple[int, int]:
rot = int(rotation) % 360
if rot == 90:
return 1, 0
if rot == 180:
return 0, -1
if rot == 270:
return -1, 0
return 0, 1
def is_back_to_back(rotation_a: int, rotation_b: int) -> bool:
ax, ay = front_vector(rotation_a)
bx, by = front_vector(rotation_b)
return (ax * bx + ay * by) < 0
def pillar_positions(
cx: float,
cy: float,
params: dict[str, Any],
rotation: int,
) -> list[tuple[float, float]]:
center_spans = [float(v) for v in (params.get("center_spans_mm") or [1000])]
depth = float(params.get("footprint_depth_mm", 500))
total_width = float(sum(center_spans))
x_points = [-total_width / 2.0]
for span in center_spans:
x_points.append(x_points[-1] + span)
y_points = (-depth / 2.0, depth / 2.0)
positions: list[tuple[float, float]] = []
for lx in x_points:
for ly in y_points:
rx, ry = rotate_xy(lx, ly, rotation)
positions.append((cx + rx, cy + ry))
return positions
def support_line_positions(
cx: float,
cy: float,
params: dict[str, Any],
rotation: int,
) -> list[tuple[float, float]]:
"""Позиции опорных линий стоек (для 1 секции = 2 стойки)."""
center_spans = [float(v) for v in (params.get("center_spans_mm") or [1000])]
total_width = float(sum(center_spans))
x_points = [-total_width / 2.0]
for span in center_spans:
x_points.append(x_points[-1] + span)
positions: list[tuple[float, float]] = []
for lx in x_points:
rx, ry = rotate_xy(lx, 0.0, rotation)
positions.append((cx + rx, cy + ry))
return positions
# ---------------------------------------------------------------------------
# Module workflow notes
# ---------------------------------------------------------------------------
# 1) Задача модуля:
# Чистые функции геометрии стеллажей: габариты, пересечения, проходы, опоры и ориентация.
#
# 2) Последовательность действий и вызовов:
# A. Функции уровня модуля:
# rotate_xy(...)
# Назначение: выполняет шаг "rotate xy" в рамках текущего сценария модуля.
# rack_bbox(...)
# Назначение: выполняет шаг "rack bbox" в рамках текущего сценария модуля.
# passes_clearance_rule(...)
# Назначение: выполняет шаг "passes clearance rule" в рамках текущего сценария модуля.
# bboxes_intersect(...)
# Назначение: выполняет шаг "bboxes intersect" в рамках текущего сценария модуля.
# rack_aisle_bboxes(...)
# Назначение: выполняет шаг "rack aisle bboxes" в рамках текущего сценария модуля.
# front_vector(...)
# Назначение: выполняет шаг "front vector" в рамках текущего сценария модуля.
# is_back_to_back(...)
# Назначение: проверяет, что back to back в рамках текущего сценария модуля.
# pillar_positions(...)
# Назначение: выполняет шаг "pillar positions" в рамках текущего сценария модуля.
# support_line_positions(...)
# Назначение: Позиции опорных линий стоек (для 1 секции = 2 стойки).
#
# 3) Важные ограничения и инварианты:
# - Модуль выполняется в составе ModelViewWidget и использует согласованные поля состояния self._... .
# - Межмодульная связность: только через фасад model_view; прямые обращения между zone, rack, shelf, cell запрещены.
# - Очистка состояния должна быть идемпотентной: повторный вызов не меняет корректное состояние в ошибочное.
#
# 4) Правило сопровождения:
# - Любое изменение сценария должно сопровождаться обновлением этого блока с сохранением фактического порядка вызовов.
# - При добавлении метода указывать его место в цепочке сценария (запуск, основной шаг, завершение, вспомогательная логика).

View File

@@ -0,0 +1,162 @@
# -*- coding: utf-8 -*-
# gui/components/model_view/_mv_rack_transition.py
"""Помощники плавного перехода камеры для навигации на уровне стоек."""
from __future__ import annotations
from typing import TYPE_CHECKING
from error_logger import log_exception
if TYPE_CHECKING: # pragma: no cover
from gui.components.model_view_widget import ModelViewWidget
class RackCameraTransitionMixin:
"""Анимация фокусировки камеры на выбранной стойке в изометрическом виде."""
def focus_on_rack_isometric(
self: "ModelViewWidget",
rack_id: str | None,
zone_id: str | None = None,
*,
duration_ms: int = 320,
) -> bool:
if not self._plotter or not rack_id:
return False
if not hasattr(self, "_get_rack_entry"):
return False
if not hasattr(self, "_animate_camera_transition"):
return False
cam = getattr(self._plotter, "camera", None)
if cam is None:
return False
rid = str(rack_id or "")
zid = str(zone_id or "")
entry = self._get_rack_entry(rid, zid) if zid else self._get_rack_entry(rid)
if entry is None:
return False
bounds = entry.get("bbox")
if not bounds and hasattr(self, "_rack_container_bbox"):
try:
bounds = self._rack_container_bbox(entry)
except Exception as exc:
log_exception(__name__, "focus_on_rack_isometric._rack_container_bbox", exc)
bounds = None
if not bounds:
return False
try:
min_x, max_x, min_y, max_y = [float(v) for v in bounds[:4]]
# Оценка диапазона Z по базовой линии стойки/зоны, когда полный bbox недоступен.
min_z = 0.0
max_z = 2000.0
if len(bounds) >= 6:
min_z = float(bounds[4])
max_z = float(bounds[5])
else:
params = dict(entry.get("params") or {})
rack_height = 2000.0
if hasattr(self, "_rack_height_mm"):
rack_height = float(self._rack_height_mm(params))
entry_zone_id = str(entry.get("zone_id") or "")
zone_bounds = (self._zone_data or {}).get(entry_zone_id)
if zone_bounds and len(zone_bounds) >= 6:
try:
min_z = float(zone_bounds[4])
except Exception as exc:
log_exception(__name__, "focus_on_rack_isometric.zone_bounds", exc)
min_z = 0.0
max_z = min_z + max(1.0, rack_height)
cx = (min_x + max_x) * 0.5
cy = (min_y + max_y) * 0.5
cz = min_z + (max(1.0, max_z - min_z) * 0.54)
sx = max(1.0, max_x - min_x)
sy = max(1.0, max_y - min_y)
sz = max(1.0, max_z - min_z)
span = max(sx, sy, sz)
start_pos = tuple(float(v) for v in cam.GetPosition())
start_focal = tuple(float(v) for v in cam.GetFocalPoint())
start_up = tuple(float(v) for v in cam.GetViewUp())
vx, vy, vz = 1.0, -1.0, 0.78
norm = (vx * vx + vy * vy + vz * vz) ** 0.5
vx, vy, vz = vx / norm, vy / norm, vz / norm
ux, uy, uz = 0.0, 0.0, 1.0
fx, fy, fz = -vx, -vy, -vz
rx = fy * uz - fz * uy
ry = fz * ux - fx * uz
rz = fx * uy - fy * ux
r_norm = (rx * rx + ry * ry + rz * rz) ** 0.5
if r_norm <= 1e-6:
rx, ry, rz = 1.0, 0.0, 0.0
else:
rx, ry, rz = rx / r_norm, ry / r_norm, rz / r_norm
rack_margin = max(1.01, float(getattr(self, "_rack_iso_margin_factor", 1.10)))
rack_min_distance = max(800.0, float(getattr(self, "_rack_iso_min_distance", 1700.0)))
rack_side_shift = max(0.0, float(getattr(self, "_rack_iso_side_shift_factor", 0.10)))
rack_z_lift = max(0.0, float(getattr(self, "_rack_iso_z_lift_factor", 0.08)))
rack_distance_scale = max(1.0, float(getattr(self, "_rack_iso_distance_scale", 1.40)))
if hasattr(self, "_compute_isometric_fit_distance"):
distance = float(
self._compute_isometric_fit_distance(
(min_x, max_x, min_y, max_y, min_z, max_z),
view_dir=(vx, vy, vz),
up_dir=(ux, uy, uz),
margin_factor=rack_margin,
min_distance=rack_min_distance,
)
)
else:
distance = max(rack_min_distance, span * 2.9)
distance *= rack_distance_scale
target_focal = (cx, cy, cz)
target_pos = (
cx + vx * distance + rx * (span * rack_side_shift),
cy + vy * distance + ry * (span * rack_side_shift),
cz + vz * distance + (sz * rack_z_lift),
)
self._animate_camera_transition(
start_pos=start_pos,
start_focal=start_focal,
start_up=start_up,
target_pos=target_pos,
target_focal=target_focal,
target_up=(ux, uy, uz),
duration_ms=int(duration_ms),
steps=16,
)
return True
except Exception as exc:
log_exception(__name__, "focus_on_rack_isometric", exc)
return False
# ---------------------------------------------------------------------------
# Module workflow notes
# ---------------------------------------------------------------------------
# 1) Задача модуля:
# Переход камеры к выбранному стеллажу в изометрическом ракурсе.
#
# 2) Последовательность действий и вызовов:
# A. Класс RackCameraTransitionMixin: точки входа
# Публичные методы сценария:
# - RackCameraTransitionMixin.focus_on_rack_isometric(...)
#
# B. RackCameraTransitionMixin: основной сценарий:
# RackCameraTransitionMixin.focus_on_rack_isometric(...)
# Назначение: фокусирует on rack isometric в рамках текущего сценария модуля.
#
# 3) Важные ограничения и инварианты:
# - Модуль выполняется в составе ModelViewWidget и использует согласованные поля состояния self._... .
# - Все операции с актёрами и камерой выполняются только при валидном self._plotter с безопасной обработкой исключений.
# - Межмодульная связность: только через фасад model_view; прямые обращения между zone, rack, shelf, cell запрещены.
# - Очистка состояния должна быть идемпотентной: повторный вызов не меняет корректное состояние в ошибочное.
#
# 4) Правило сопровождения:
# - Любое изменение сценария должно сопровождаться обновлением этого блока с сохранением фактического порядка вызовов.
# - При добавлении метода указывать его место в цепочке сценария (запуск, основной шаг, завершение, вспомогательная логика).

View File

@@ -0,0 +1,104 @@
# -*- coding: utf-8 -*-
# gui/components/model_view/_mv_racks.py
"""Логика работы с размещением стеллажей и связанными визуальными состояниями.
Этот модуль компонует полный ``RackPlacementMixin`` из набора
мелких подмиксинов, каждый из которых отвечает за отдельную область
логики размещения стеллажей. Также здесь хранятся константы уровня
класса, общие для всех подмиксинов.
"""
from __future__ import annotations
from gui.components.model_view._mv_racks_projection import RackPlacementProjectionMixin
from gui.components.model_view._mv_racks_visual import RackPlacementVisualMixin
from gui.components.model_view._mv_racks_lifecycle import RackLifecycleMixin
from gui.components.model_view._mv_racks_crud import RackCrudMixin
from gui.components.model_view._mv_racks_camera import RackCameraMixin
from gui.components.model_view._mv_racks_shelf import RackShelfMixin
from gui.components.model_view._mv_racks_selection import RackSelectionMixin
from gui.components.model_view._mv_racks_move import RackMoveMixin
from gui.components.model_view._mv_racks_picking import RackPickingMixin
from gui.components.model_view._mv_racks_shelf_render import RackShelfRenderMixin
from gui.components.model_view._mv_racks_hover import RackHoverMixin
from gui.components.model_view._mv_racks_preview import RackPreviewMixin
from gui.components.model_view._mv_racks_codes import RackCodesMixin
from gui.components.model_view._mv_racks_collision import RackCollisionMixin
from gui.components.model_view._mv_racks_mezzanine import RackMezzanineMixin
class RackPlacementMixin(
RackLifecycleMixin,
RackCrudMixin,
RackCameraMixin,
RackShelfMixin,
RackSelectionMixin,
RackMoveMixin,
RackPickingMixin,
RackShelfRenderMixin,
RackHoverMixin,
RackPreviewMixin,
RackCodesMixin,
RackCollisionMixin,
RackMezzanineMixin,
RackPlacementProjectionMixin,
RackPlacementVisualMixin,
):
"""Миксин с логикой размещения стеллажей внутри зоны."""
_PALLET_CENTER_SPAN_MM = 3706
_PALLET_FOOTPRINT_WIDTH_SINGLE_MM = 3796
_PALLET_DEPTH_MM = 1100
_PALLET_HEIGHT_MM = 2505
_PALLET_BUFFER_MM = 100
_RACK_COLLISION_BUFFER_MM = 100
_MAX_BUFFER_OVERLAP_MM = 100
_MEZZANINE_CONTAINMENT_MARGIN_MM = 100
_SHELF_NORMS = {
"A": {
"base_surface_offset_from_bbox_top_mm": 0.0,
"min_base_height_mm": 80.0,
"min_useful_height_mm": 16.0,
"min_inter_shelf_mm": 53.0,
"shelf_height_mm": 37.0,
"step_mm": 53.0,
},
"B": {
"base_surface_offset_from_bbox_top_mm": 15.0,
"min_base_height_mm": 130.0,
"min_useful_height_mm": 68.0,
"min_inter_shelf_mm": 113.0,
"shelf_height_mm": 45.0,
"step_mm": 37.5,
},
"PALLET": {
"base_surface_offset_from_bbox_top_mm": 55.0,
"min_base_height_mm": 252.0,
"min_useful_height_mm": 112.0,
"min_inter_shelf_mm": 250.0,
"shelf_height_mm": 250.0,
"step_mm": 50.0,
},
}
# ---------------------------------------------------------------------------
# Module workflow notes
# ---------------------------------------------------------------------------
# 1) Задача модуля:
# Композиционный фасад подсистемы стеллажей.
#
# 2) Последовательность действий и вызовов:
# A. Композиционный класс RackPlacementMixin:
# Назначение: объединяет поведение через RackLifecycleMixin, RackCrudMixin, RackCameraMixin, RackShelfMixin, RackSelectionMixin, RackMoveMixin, RackPickingMixin, RackShelfRenderMixin, RackHoverMixin, RackPreviewMixin, RackCodesMixin, RackCollisionMixin, RackMezzanineMixin, RackPlacementProjectionMixin, RackPlacementVisualMixin.
# Собственная вычислительная логика отсутствует; маршрутизация идёт в родительские миксины.
#
# 3) Важные ограничения и инварианты:
# - Модуль выполняется в составе ModelViewWidget и использует согласованные поля состояния self._... .
# - Межмодульная связность: только через фасад model_view; прямые обращения между zone, rack, shelf, cell запрещены.
# - Очистка состояния должна быть идемпотентной: повторный вызов не меняет корректное состояние в ошибочное.
#
# 4) Правило сопровождения:
# - Любое изменение сценария должно сопровождаться обновлением этого блока с сохранением фактического порядка вызовов.
# - При добавлении метода указывать его место в цепочке сценария (запуск, основной шаг, завершение, вспомогательная логика).

View File

@@ -0,0 +1,321 @@
# -*- coding: utf-8 -*-
"""Вспомогательные функции камеры для изометрической фокусировки на стеллаже / слоте полки и анимированных переходов."""
from __future__ import annotations
import math
from typing import TYPE_CHECKING
from PySide6.QtCore import QTimer
from error_logger import log_exception
if TYPE_CHECKING:
from gui.components.model_view_widget import ModelViewWidget
class RackCameraMixin:
"""Миксин с утилитами фокусировки камеры и анимации для видов стеллажей."""
def _compute_isometric_fit_distance(
self: "ModelViewWidget",
bounds: tuple[float, float, float, float, float, float],
*,
view_dir: tuple[float, float, float],
up_dir: tuple[float, float, float] = (0.0, 0.0, 1.0),
margin_factor: float = 1.10,
min_distance: float = 700.0,
) -> float:
"""Вернуть дистанцию камеры, чтобы bbox гарантированно помещался в кадр."""
if not self._plotter:
return float(min_distance)
cam = getattr(self._plotter, "camera", None)
if cam is None:
return float(min_distance)
min_x, max_x, min_y, max_y, min_z, max_z = [float(v) for v in bounds]
ex = max(0.5, (max_x - min_x) * 0.5)
ey = max(0.5, (max_y - min_y) * 0.5)
ez = max(0.5, (max_z - min_z) * 0.5)
vx, vy, vz = [float(v) for v in view_dir]
ux, uy, uz = [float(v) for v in up_dir]
v_norm = max(1e-6, (vx * vx + vy * vy + vz * vz) ** 0.5)
u_norm = max(1e-6, (ux * ux + uy * uy + uz * uz) ** 0.5)
vx, vy, vz = vx / v_norm, vy / v_norm, vz / v_norm
ux, uy, uz = ux / u_norm, uy / u_norm, uz / u_norm
fx, fy, fz = -vx, -vy, -vz
rx = fy * uz - fz * uy
ry = fz * ux - fx * uz
rz = fx * uy - fy * ux
r_norm = max(1e-6, (rx * rx + ry * ry + rz * rz) ** 0.5)
rx, ry, rz = rx / r_norm, ry / r_norm, rz / r_norm
half_w = abs(rx) * ex + abs(ry) * ey + abs(rz) * ez
half_h = abs(ux) * ex + abs(uy) * ey + abs(uz) * ez
view_angle_deg = 30.0
try:
view_angle_deg = float(cam.GetViewAngle())
except Exception as e:
log_exception(__name__, "_compute_isometric_fit_distance", e)
if view_angle_deg <= 1e-3:
view_angle_deg = 30.0
half_vfov = (view_angle_deg * 0.5) * (3.141592653589793 / 180.0)
tan_half_v = max(1e-4, math.tan(half_vfov))
aspect = 1.0
try:
ren_win = self._plotter.ren_win
if ren_win:
w, h = ren_win.GetSize()
if h:
aspect = max(1e-3, float(w) / float(h))
except Exception as e:
log_exception(__name__, "_compute_isometric_fit_distance", e)
tan_half_h = max(1e-4, tan_half_v * aspect)
fit_v = half_h / tan_half_v
fit_h = half_w / tan_half_h
return max(float(min_distance), max(fit_v, fit_h) * max(1.01, float(margin_factor)))
def focus_on_shelf_slot_isometric(
self: "ModelViewWidget",
rack_id: str | None,
slot_id: str | None,
shelf_index: int | None = None,
zone_id: str | None = None,
) -> bool:
"""Переключает камеру в изометрический вид и фокусируется на выбранной секции полки."""
if not self._plotter:
return False
rid = str(rack_id or "")
sid = str(slot_id or "")
zid = str(zone_id or "")
if not rid or not sid:
return False
entry = self._get_rack_entry(rid, zid) if zid else self._get_rack_entry(rid)
if entry is None:
return False
target_bounds = None
requested_idx = int(1 if shelf_index is None else shelf_index)
rack_type = str((entry.get("params") or {}).get("rack_type") or "").strip().upper()
if rack_type == "PALLET":
# tree-index=1 — напольный уровень под первой полкой,
# tree-index=2..N+1 — физические полки (actor 1..N).
use_floor_slot_bounds = requested_idx <= 1
selected_idx = max(1, requested_idx - 1)
else:
use_floor_slot_bounds = requested_idx <= 0
selected_idx = max(1, requested_idx)
shelf_actors = list((self._rack_shelf_actors.get((rid, sid)) or []))
if not use_floor_slot_bounds and shelf_actors and selected_idx <= len(shelf_actors):
actor = shelf_actors[selected_idx - 1]
if actor is not None:
try:
target_bounds = actor.GetBounds()
except Exception as e:
log_exception(__name__, "focus_on_shelf_slot_isometric", e)
target_bounds = None
if not target_bounds:
slot_actor = (entry.get("slot_actors") or {}).get(sid)
if slot_actor is not None:
try:
target_bounds = slot_actor.GetBounds()
except Exception as e:
log_exception(__name__, "focus_on_shelf_slot_isometric", e)
target_bounds = None
if not target_bounds:
return False
min_x, max_x, min_y, max_y, min_z, max_z = [float(v) for v in target_bounds]
cx = (min_x + max_x) * 0.5
cy = (min_y + max_y) * 0.5
sx = max(1.0, max_x - min_x)
sy = max(1.0, max_y - min_y)
sz = max(1.0, max_z - min_z)
cz = min_z + (sz * 0.60)
target_span = max(sx, sy, sz)
cam = getattr(self._plotter, "camera", None)
if cam is None:
return False
try:
start_pos = tuple(float(v) for v in cam.GetPosition())
start_foc = tuple(float(v) for v in cam.GetFocalPoint())
start_up = tuple(float(v) for v in cam.GetViewUp())
except Exception as e:
log_exception(__name__, "focus_on_shelf_slot_isometric", e)
return False
try:
# Вычисляем целевое изометрическое направление напрямую (без промежуточного прыжка камеры).
# На 60% приближаем "изометрию полки" к прямому (front-like) виду:
# уменьшаем боковой компонент yaw, сохраняя вертикальную составляющую.
vx, vy, vz = 0.4, -1.0, 0.8
norm = (vx * vx + vy * vy + vz * vz) ** 0.5
if norm <= 1e-6:
vx, vy, vz = 1.0, -1.0, 0.8
norm = (vx * vx + vy * vy + vz * vz) ** 0.5
vx /= norm
vy /= norm
vz /= norm
ux, uy, uz = 0.0, 0.0, 1.0
# right = forward x up (forward направлен от камеры к точке фокуса).
fx, fy, fz = -vx, -vy, -vz
rx = fy * uz - fz * uy
ry = fz * ux - fx * uz
rz = fx * uy - fy * ux
right_norm = (rx * rx + ry * ry + rz * rz) ** 0.5
if right_norm <= 1e-6:
rx, ry, rz = 1.0, 0.0, 0.0
right_norm = 1.0
rx /= right_norm
ry /= right_norm
rz /= right_norm
# Гарантируем полное попадание слота в кадр + небольшой зазор.
distance = self._compute_isometric_fit_distance(
(min_x, max_x, min_y, max_y, min_z, max_z),
view_dir=(vx, vy, vz),
up_dir=(ux, uy, uz),
margin_factor=1.09,
min_distance=700.0,
)
target_focal = (cx, cy, cz)
base_pos = (
cx + vx * distance,
cy + vy * distance,
cz + vz * distance,
)
right_shift = target_span * 0.22
down_shift = target_span * 0.06
target_pos = (
base_pos[0] + rx * right_shift - ux * down_shift,
base_pos[1] + ry * right_shift - uy * down_shift,
base_pos[2] + rz * right_shift + (sz * 0.08) - uz * down_shift,
)
target_focal = (
target_focal[0] + rx * (target_span * 0.03),
target_focal[1] + ry * (target_span * 0.03),
target_focal[2] + (sz * 0.05),
)
# Анимируем переход камеры напрямую из текущей позиции в целевую изометрическую.
self._animate_camera_transition(
start_pos=start_pos,
start_focal=start_foc,
start_up=start_up,
target_pos=target_pos,
target_focal=target_focal,
target_up=(ux, uy, uz),
duration_ms=380,
steps=20,
)
return True
except Exception as e:
log_exception(__name__, "focus_on_shelf_slot_isometric", e)
return False
def _animate_camera_transition(
self: "ModelViewWidget",
*,
start_pos: tuple[float, float, float],
start_focal: tuple[float, float, float],
start_up: tuple[float, float, float],
target_pos: tuple[float, float, float],
target_focal: tuple[float, float, float],
target_up: tuple[float, float, float],
duration_ms: int = 320,
steps: int = 16,
) -> None:
"""Анимирует переход камеры со сглаживанием smoothstep."""
if not self._plotter or not getattr(self._plotter, "camera", None):
return
cam = self._plotter.camera
total_steps = max(1, int(steps))
interval_ms = max(10, int(duration_ms / total_steps))
self._camera_anim_seq = int(getattr(self, "_camera_anim_seq", 0)) + 1
anim_seq = self._camera_anim_seq
old_timer = getattr(self, "_camera_anim_timer", None)
if old_timer is not None:
try:
old_timer.stop()
except Exception as e:
log_exception(__name__, "_animate_camera_transition", e)
timer = QTimer()
self._camera_anim_timer = timer
state = {"step": 0}
def _lerp3(a: tuple[float, float, float], b: tuple[float, float, float], t: float) -> tuple[float, float, float]:
return (
float(a[0] + (b[0] - a[0]) * t),
float(a[1] + (b[1] - a[1]) * t),
float(a[2] + (b[2] - a[2]) * t),
)
def _tick() -> None:
if anim_seq != getattr(self, "_camera_anim_seq", 0):
timer.stop()
return
state["step"] = int(state["step"]) + 1
linear_t = min(1.0, float(state["step"]) / float(total_steps))
# smoothstep: плавное начало/конец без перелёта.
t = linear_t * linear_t * (3.0 - 2.0 * linear_t)
pos_t = _lerp3(start_pos, target_pos, t)
focal_t = _lerp3(start_focal, target_focal, t)
up_t = _lerp3(start_up, target_up, t)
try:
cam.SetPosition(*pos_t)
cam.SetFocalPoint(*focal_t)
if hasattr(cam, "SetViewUp"):
cam.SetViewUp(*up_t)
if hasattr(self, "_reset_camera_clipping_range"):
self._reset_camera_clipping_range()
self._plotter.update()
except Exception as e:
log_exception(__name__, "_animate_camera_transition._tick", e)
timer.stop()
return
if linear_t >= 1.0:
timer.stop()
timer.timeout.connect(_tick)
timer.start(interval_ms)
# ---------------------------------------------------------------------------
# Module workflow notes
# ---------------------------------------------------------------------------
# 1) Задача модуля:
# Камера стеллажей: фокус на ячейке и плавная анимация перехода.
#
# 2) Последовательность действий и вызовов:
# A. Класс RackCameraMixin: точки входа
# Публичные методы сценария:
# - RackCameraMixin.focus_on_shelf_slot_isometric(...)
#
# B. RackCameraMixin: основной сценарий:
# RackCameraMixin.focus_on_shelf_slot_isometric(...)
# Назначение: Переключает камеру в изометрический вид и фокусируется на выбранной секции полки.
# Последовательность внутренних вызовов:
# -> RackCameraMixin._animate_camera_transition(...)
#
# C. RackCameraMixin: вспомогательные расчёты:
# RackCameraMixin._animate_camera_transition(...)
# Назначение: Анимирует переход камеры со сглаживанием smoothstep.
#
# 3) Важные ограничения и инварианты:
# - Модуль выполняется в составе ModelViewWidget и использует согласованные поля состояния self._... .
# - Все операции с актёрами и камерой выполняются только при валидном self._plotter с безопасной обработкой исключений.
# - Межмодульная связность: только через фасад model_view; прямые обращения между zone, rack, shelf, cell запрещены.
# - Очистка состояния должна быть идемпотентной: повторный вызов не меняет корректное состояние в ошибочное.
#
# 4) Правило сопровождения:
# - Любое изменение сценария должно сопровождаться обновлением этого блока с сохранением фактического порядка вызовов.
# - При добавлении метода указывать его место в цепочке сценария (запуск, основной шаг, завершение, вспомогательная логика).

View File

@@ -0,0 +1,440 @@
# -*- coding: utf-8 -*-
"""Вспомогательные функции кодов стеллажей — утилиты именования, нумерации и определения стиля."""
from __future__ import annotations
import re
from typing import Any, TYPE_CHECKING
from error_logger import log_exception
if TYPE_CHECKING:
from gui.components.model_view_widget import ModelViewWidget
class RackCodesMixin:
"""Чисто логические утилиты для работы с кодами стеллажей (без зависимостей от Qt / VTK)."""
@staticmethod
def _split_code_parts(value: Any) -> tuple[str, str]:
raw = str(value or "").strip().upper()
if raw.startswith("MEZO_"):
suffix = "".join(ch for ch in raw[5:] if "A" <= ch <= "Z")
return "MEZO_", suffix
letters = "".join(ch for ch in raw if "A" <= ch <= "Z")
return "", letters
@staticmethod
def _code_prefix_for_params(params: dict[str, Any]) -> str:
rack_type = str(dict(params or {}).get("rack_type") or "").strip().lower()
if rack_type in {"mezo", "mezz", "mezzanine"}:
return "MEZO_"
additional_model = dict(dict(params or {}).get("additional_model") or {})
explicit = additional_model.get("is_mezzanine")
if isinstance(explicit, bool):
return "MEZO_" if explicit else ""
model_kind = str(additional_model.get("model_kind") or "").strip().lower()
if model_kind in {"mezo", "mezz", "mezzanine"}:
return "MEZO_"
haystack = " ".join(
str(v)
for v in (
additional_model.get("model_name"),
additional_model.get("model_path"),
rack_type,
)
if v
).lower()
if any(tag in haystack for tag in ("mezo", "mezz", "Р Сез", "mezan", "mezon")):
return "MEZO_"
return ""
@classmethod
def _normalize_rack_code(
cls,
value: Any,
fallback: str = "A",
prefix: str = "",
) -> str:
_given_prefix, letters = cls._split_code_parts(value)
if not letters:
_fb_prefix, fb_letters = cls._split_code_parts(fallback)
letters = fb_letters
if not letters:
letters = "A"
if str(prefix or "").upper() == "MEZO_":
return f"MEZO_{letters}"
return letters
@staticmethod
def _normalize_numbering_direction(value: Any) -> str:
direction = str(value or "").strip().lower()
if direction in {"right_to_left", "rtl"}:
return "right_to_left"
return "left_to_right"
@staticmethod
def _canonical_center_span(rack_type: Any, span_width_mm: Any, fallback_center_mm: Any) -> int:
kind = str(rack_type or "").strip().upper()
width = int(span_width_mm)
fallback = int(fallback_center_mm)
if kind == "A":
if width == 1000:
return 1033
if width == 1300:
return 1333
if kind == "B":
if width == 900:
return 950
if width == 1200:
return 1250
if width == 1500:
return 1550
return fallback
@classmethod
def _build_span_codes(cls, code: str, spans_count: int, numbering_direction: str) -> list[str]:
prefix, letters = cls._split_code_parts(code)
base = letters or "A"
count = max(1, int(spans_count or 1))
ordered = [f"{prefix}{base}{idx:02d}" for idx in range(1, count + 1)]
if cls._normalize_numbering_direction(numbering_direction) == "right_to_left":
return list(reversed(ordered))
return ordered
@classmethod
def _letters_to_number(cls, letters: str) -> int:
_prefix, clean = cls._split_code_parts(letters)
value = 0
for ch in clean:
value = value * 26 + (ord(ch) - ord("A") + 1)
return value
@staticmethod
def _number_to_letters(number: int) -> str:
n = max(1, int(number))
out = []
while n > 0:
n -= 1
out.append(chr(ord("A") + (n % 26)))
n //= 26
return "".join(reversed(out))
def _next_zone_rack_code(self: "ModelViewWidget", zone_id: str, params: dict[str, Any] | None = None) -> str:
zid = str(zone_id or "")
source_params = dict(params or {})
wanted_prefix = self._code_prefix_for_params(source_params)
used_numbers: set[int] = set()
for entry in self._rack_entries:
if zid and str(entry.get("zone_id", "")) != zid:
continue
entry_params = dict(entry.get("params") or {})
code = str(entry.get("code") or entry_params.get("code") or "")
if not code:
continue
entry_prefix = self._code_prefix_for_params(entry_params)
if not entry_prefix:
parsed_prefix, _letters = self._split_code_parts(code)
entry_prefix = parsed_prefix
if str(entry_prefix or "") != str(wanted_prefix or ""):
continue
code_num = int(self._letters_to_number(code))
if code_num > 0:
used_numbers.add(code_num)
# Сохраняем ручной код, если он свободен в целевой зоне/префиксе.
preferred_code = self._normalize_rack_code(
source_params.get("code"),
fallback="MEZO_A" if wanted_prefix else "A",
prefix=wanted_prefix,
)
preferred_num = int(self._letters_to_number(preferred_code))
if preferred_num > 0 and preferred_num not in used_numbers:
if wanted_prefix:
return f"{wanted_prefix}{self._number_to_letters(preferred_num)}"
return self._number_to_letters(preferred_num)
# Автоматический режим: выбираем ближайший свободный слот, начиная с A.
next_num = 1
while next_num in used_numbers:
next_num += 1
next_letters = self._number_to_letters(next_num)
if wanted_prefix:
return f"{wanted_prefix}{next_letters}"
return next_letters
def _normalize_rack_params(
self: "ModelViewWidget",
params: dict[str, Any],
fallback_code: str | None = None,
) -> dict[str, Any]:
normalized = dict(params or {})
prefix = self._code_prefix_for_params(normalized)
default_code = "MEZO_A" if prefix else "A"
code = self._normalize_rack_code(
normalized.get("code"),
fallback=fallback_code or default_code,
prefix=prefix,
)
spans_count = max(1, int(normalized.get("spans_count", 1)))
raw_span_widths = [int(v) for v in (normalized.get("span_widths_mm") or []) if int(v) > 0]
if not raw_span_widths:
raw_span_widths = [int(normalized.get("footprint_width_mm", 1000))]
if len(raw_span_widths) < spans_count:
raw_span_widths.extend([raw_span_widths[-1]] * (spans_count - len(raw_span_widths)))
raw_span_widths = raw_span_widths[:spans_count]
if self._is_pallet_params(normalized):
center_spans = [self._PALLET_CENTER_SPAN_MM for _ in range(spans_count)]
else:
center_spans = [int(v) for v in (normalized.get("center_spans_mm") or raw_span_widths) if int(v) > 0]
if not center_spans:
center_spans = list(raw_span_widths)
if len(center_spans) < spans_count:
center_spans.extend([center_spans[-1]] * (spans_count - len(center_spans)))
center_spans = center_spans[:spans_count]
rack_type = str(normalized.get("rack_type") or "")
center_spans = [
self._canonical_center_span(rack_type, raw_span_widths[i], center_spans[i])
for i in range(spans_count)
]
if self._is_pallet_params(normalized):
raw_span_widths = [self._PALLET_FOOTPRINT_WIDTH_SINGLE_MM for _ in range(spans_count)]
footprint_width = int(
sum(center_spans)
+ (self._PALLET_FOOTPRINT_WIDTH_SINGLE_MM - self._PALLET_CENTER_SPAN_MM)
)
normalized["depth_mm"] = int(self._PALLET_DEPTH_MM)
normalized["footprint_depth_mm"] = int(self._PALLET_DEPTH_MM)
normalized["footprint_height_mm"] = int(self._PALLET_HEIGHT_MM)
else:
footprint_width = int(sum(center_spans))
numbering_direction = self._normalize_numbering_direction(
normalized.get("numbering_direction", "left_to_right")
)
normalized["code"] = code
normalized["spans_count"] = spans_count
normalized["span_widths_mm"] = raw_span_widths
normalized["center_spans_mm"] = center_spans
normalized["footprint_width_mm"] = footprint_width
normalized["numbering_direction"] = numbering_direction
normalized["span_codes"] = self._build_span_codes(code, spans_count, numbering_direction)
return normalized
def _resolve_rack_style(
self: "ModelViewWidget",
params: dict[str, Any],
fallback_color: str = "#7FB3D5",
fallback_opacity: float = 0.9,
) -> tuple[str, float]:
color = str(params.get("display_color") or fallback_color).strip()
opacity = params.get("display_opacity", fallback_opacity)
try:
opacity = float(opacity)
except Exception as exc:
log_exception(__name__, "_resolve_rack_style", exc)
opacity = float(fallback_opacity)
opacity = max(0.05, min(1.0, opacity))
match = re.fullmatch(r"#([0-9A-Fa-f]{8})", color)
if match:
hex_rgba = match.group(1)
color = f"#{hex_rgba[2:]}"
try:
opacity = max(0.05, min(1.0, int(hex_rgba[:2], 16) / 255.0))
except Exception as _exc:
log_exception(__name__, "_resolve_rack_style", _exc)
if not re.fullmatch(r"#([0-9A-Fa-f]{6})", color):
color = fallback_color
return color, opacity
def _apply_rack_shelf_opacity(self: "ModelViewWidget", rack_id: str) -> None:
"""Устанавливает полную непрозрачность актёров стеллажа при наличии полок, иначе восстанавливает исходную."""
rid = str(rack_id or "")
if not rid:
return
entry = self._get_rack_entry(rid)
if entry is None:
return
slot_actor_ids = {
id(actor)
for actor in (entry.get("slot_actors") or {}).values()
if actor is not None
}
has_shelves = any(str(k[0]) == rid for k in self._rack_shelf_params)
if has_shelves:
target_opacity = 1.0
else:
params = dict(entry.get("params") or {})
_color, target_opacity = self._resolve_rack_style(params)
for actor in entry.get("actors", []):
try:
if id(actor) in slot_actor_ids:
# Прокси-объекты слотов управляются логикой размещения полок.
continue
prop = actor.GetProperty()
if prop is not None:
prop.SetOpacity(target_opacity)
except Exception as _exc:
log_exception(__name__, "_apply_rack_shelf_opacity", _exc)
for (entry_rid, slot_id), actors in (self._rack_shelf_actors or {}).items():
if str(entry_rid) != rid:
continue
for idx, actor in enumerate(list(actors or []), start=1):
if actor is None:
continue
try:
prop = actor.GetProperty()
if prop is not None:
slot_opacity = float(self._shelf_cell_visual_opacity(rid, str(slot_id), idx))
prop.SetOpacity(float(slot_opacity))
except Exception as _exc:
log_exception(__name__, "_apply_rack_shelf_opacity", _exc)
def _shelf_cell_visual_opacity(
self: "ModelViewWidget",
rack_id: str,
slot_id: str,
shelf_index: int | None = None,
) -> float:
rid = str(rack_id or "")
sid = str(slot_id or "")
if not rid or not sid:
return 0.65
if shelf_index is not None:
idx = max(1, int(shelf_index or 1))
indexed_storage = dict(getattr(self, "_rack_shelf_cell_links_indexed", {}) or {})
has_any_indexed = any(str(k[0]) == rid and str(k[1]) == sid for k in indexed_storage.keys())
if has_any_indexed:
linked_idx = bool(indexed_storage.get((rid, sid, idx), False))
return 1.0 if linked_idx else 0.65
linked = bool((self._rack_shelf_cell_links or {}).get((rid, sid), False))
return 1.0 if linked else 0.65
def set_shelf_cell_marker(
self: "ModelViewWidget",
rack_id: str,
slot_id: str,
linked: bool,
) -> None:
rid = str(rack_id or "")
sid = str(slot_id or "")
if not rid or not sid:
return
self._rack_shelf_cell_links[(rid, sid)] = bool(linked)
self._apply_rack_shelf_opacity(rid)
if self._plotter:
self._plotter.update()
def set_rack_shelf_cell_markers(
self: "ModelViewWidget",
rack_id: str,
slot_markers: dict[str, bool],
) -> None:
rid = str(rack_id or "")
if not rid:
return
markers = dict(slot_markers or {})
keys_to_drop = [key for key in (self._rack_shelf_cell_links or {}).keys() if str(key[0]) == rid]
for key in keys_to_drop:
self._rack_shelf_cell_links.pop(key, None)
for sid, linked in markers.items():
slot_id = str(sid or "")
if not slot_id:
continue
self._rack_shelf_cell_links[(rid, slot_id)] = bool(linked)
self._apply_rack_shelf_opacity(rid)
if self._plotter:
self._plotter.update()
def set_rack_shelf_cell_markers_indexed(
self: "ModelViewWidget",
rack_id: str,
slot_index_markers: dict[str, bool],
) -> None:
"""Точечные маркеры размещения ячеек на уровне конкретной полки (slot + index)."""
rid = str(rack_id or "")
if not rid:
return
markers = dict(slot_index_markers or {})
keys_to_drop = [key for key in (self._rack_shelf_cell_links_indexed or {}).keys() if str(key[0]) == rid]
for key in keys_to_drop:
self._rack_shelf_cell_links_indexed.pop(key, None)
for key, linked in markers.items():
raw = str(key or "")
if "::" not in raw:
continue
slot_id, raw_idx = raw.split("::", 1)
slot_id = str(slot_id or "")
if not slot_id:
continue
try:
idx = max(1, int(raw_idx))
except Exception as exc:
log_exception(__name__, "set_rack_shelf_cell_markers_indexed", exc)
continue
self._rack_shelf_cell_links_indexed[(rid, slot_id, idx)] = bool(linked)
self._apply_rack_shelf_opacity(rid)
if self._plotter:
self._plotter.update()
# ---------------------------------------------------------------------------
# Module workflow notes
# ---------------------------------------------------------------------------
# 1) Задача модуля:
# Нормализация и генерация кодов стеллажей и Секций.
#
# 2) Последовательность действий и вызовов:
# A. Класс RackCodesMixin: точки входа
# Публичные методы отсутствуют; сценарий запускается через методы родительских модулей и внутренние обработчики.
#
# B. RackCodesMixin: основной сценарий:
# RackCodesMixin._resolve_rack_style(...)
# Назначение: определяет rack style в рамках текущего сценария модуля.
# RackCodesMixin._apply_rack_shelf_opacity(...)
# Назначение: Устанавливает полную непрозрачность актёров стеллажа при наличии полок, иначе восстанавливает исходную.
# Последовательность внутренних вызовов:
# -> RackCodesMixin._resolve_rack_style(...)
#
# C. RackCodesMixin: вспомогательные расчёты:
# RackCodesMixin._split_code_parts(...)
# Назначение: разделяет code parts в рамках текущего сценария модуля.
# RackCodesMixin._code_prefix_for_params(...)
# Назначение: выполняет шаг "code prefix for params" в рамках текущего сценария модуля.
# RackCodesMixin._normalize_rack_code(...)
# Назначение: нормализует rack code в рамках текущего сценария модуля.
# RackCodesMixin._normalize_numbering_direction(...)
# Назначение: нормализует numbering direction в рамках текущего сценария модуля.
# RackCodesMixin._canonical_center_span(...)
# Назначение: выполняет шаг "canonical center span" в рамках текущего сценария модуля.
# RackCodesMixin._build_span_codes(...)
# Назначение: строит span codes в рамках текущего сценария модуля.
# RackCodesMixin._letters_to_number(...)
# Назначение: выполняет шаг "letters to number" в рамках текущего сценария модуля.
# RackCodesMixin._number_to_letters(...)
# Назначение: выполняет шаг "number to letters" в рамках текущего сценария модуля.
# RackCodesMixin._next_zone_rack_code(...)
# Назначение: выполняет шаг "next zone rack code" в рамках текущего сценария модуля.
# Последовательность внутренних вызовов:
# -> RackCodesMixin._code_prefix_for_params(...)
# -> RackCodesMixin._normalize_rack_code(...)
# -> RackCodesMixin._number_to_letters(...)
# -> RackCodesMixin._letters_to_number(...)
# -> RackCodesMixin._split_code_parts(...)
# RackCodesMixin._normalize_rack_params(...)
# Назначение: нормализует rack params в рамках текущего сценария модуля.
# Последовательность внутренних вызовов:
# -> RackCodesMixin._code_prefix_for_params(...)
# -> RackCodesMixin._normalize_rack_code(...)
# -> RackCodesMixin._normalize_numbering_direction(...)
# -> RackCodesMixin._build_span_codes(...)
# -> RackCodesMixin._canonical_center_span(...)
#
# 3) Важные ограничения и инварианты:
# - Модуль выполняется в составе ModelViewWidget и использует согласованные поля состояния self._... .
# - Межмодульная связность: только через фасад model_view; прямые обращения между zone, rack, shelf, cell запрещены.
# - Очистка состояния должна быть идемпотентной: повторный вызов не меняет корректное состояние в ошибочное.
#
# 4) Правило сопровождения:
# - Любое изменение сценария должно сопровождаться обновлением этого блока с сохранением фактического порядка вызовов.
# - При добавлении метода указывать его место в цепочке сценария (запуск, основной шаг, завершение, вспомогательная логика).

View File

@@ -0,0 +1,378 @@
# -*- coding: utf-8 -*-
"""Миксин обнаружения столкновений и валидации стеллажей."""
from __future__ import annotations
from typing import Any, TYPE_CHECKING
from gui.components.model_view._mv_rack_geometry import (
MIN_AISLE_MM, MIN_WALL_BUFFER_MM, bboxes_intersect,
rack_bbox, rack_aisle_bboxes,
)
from error_logger import log_exception
if TYPE_CHECKING:
from gui.components.model_view_widget import ModelViewWidget
class RackCollisionMixin:
"""Вспомогательные методы проверки столкновений / валидации стеллажей, подмешиваемые в *ModelViewWidget*."""
# -- высота / модель -----------------------------------------------------
@staticmethod
def _rack_height_mm(params: dict[str, Any]) -> float:
try:
return max(0.0, float(params.get("footprint_height_mm", 1800)))
except Exception as exc:
log_exception(__name__, "_rack_height_mm", exc)
return 1800.0
@staticmethod
def _has_additional_model(params: dict[str, Any]) -> bool:
additional_model = dict(params.get("additional_model") or {})
if additional_model.get("model_path"):
return True
# Обратная совместимость: старые данные могут хранить путь на верхнем уровне.
return bool(params.get("model_path") or params.get("custom_model_path"))
def _is_imported_model_entry(self: "ModelViewWidget", entry: dict[str, Any]) -> bool:
params = dict((entry or {}).get("params") or {})
if self._has_additional_model(params):
return True
rack_type = str(params.get("rack_type") or "").strip().lower()
return rack_type in {"mezo", "mezz", "mezzanine", "custom", "additional_model"}
def _force_show_imported_model_entry(self: "ModelViewWidget", entry: dict[str, Any]) -> bool:
return bool(self._imported_models_enabled) and self._is_imported_model_entry(entry)
def set_imported_models_enabled(self: "ModelViewWidget", enabled: bool) -> None:
"""Устанавливает глобальную политику видимости для импортированных пользовательских моделей."""
self._imported_models_enabled = bool(enabled)
for entry in self._rack_entries:
if not self._is_imported_model_entry(entry):
continue
visible = bool(self._imported_models_enabled)
for actor in entry.get("actors", []):
try:
actor.SetVisibility(1 if visible else 0)
except Exception as _exc:
log_exception(__name__, "set_imported_models_enabled", _exc)
self._set_rack_shelf_actors_visibility(str(entry.get("rack_id") or ""), visible)
self._apply_rack_helper_visibility_policy(entry, visible)
if self._plotter:
self._plotter.update()
@staticmethod
def _is_pallet_params(params: dict[str, Any]) -> bool:
return str(dict(params or {}).get("rack_type") or "").strip().lower() == "pallet"
# -- bbox / буферы -------------------------------------------------------
@classmethod
def _rack_access_aisle_mm(cls, params: dict[str, Any]) -> float:
if cls._is_pallet_params(params):
return 2300.0
return float(MIN_AISLE_MM)
@classmethod
def _rack_wall_buffer_mm(cls, params: dict[str, Any]) -> float:
if cls._is_pallet_params(params):
return float(getattr(cls, "_PALLET_BUFFER_MM", MIN_WALL_BUFFER_MM))
return 0.0
@classmethod
def _rack_placement_bbox(
cls,
cx: float,
cy: float,
params: dict[str, Any],
rotation: int,
) -> tuple[float, float, float, float]:
return rack_bbox(
cx,
cy,
params,
rotation,
wall_buffer=cls._rack_wall_buffer_mm(params),
)
@classmethod
def _rack_collision_buffer_mm(cls, params: dict[str, Any]) -> float:
# Буфер столкновений единый для проверок стеллаж-стеллаж.
# Для PALLET не менее его настроенного буфера.
base = float(getattr(cls, "_RACK_COLLISION_BUFFER_MM", MIN_WALL_BUFFER_MM))
if cls._is_pallet_params(params):
return max(base, float(getattr(cls, "_PALLET_BUFFER_MM", MIN_WALL_BUFFER_MM)))
return base
@classmethod
def _rack_collision_bbox(
cls,
cx: float,
cy: float,
params: dict[str, Any],
rotation: int,
) -> tuple[float, float, float, float]:
return rack_bbox(
cx,
cy,
params,
rotation,
wall_buffer=cls._rack_collision_buffer_mm(params),
)
@staticmethod
def _bbox_overlap_xy_mm(
a: tuple[float, float, float, float],
b: tuple[float, float, float, float],
) -> tuple[float, float]:
a_min_x, a_max_x, a_min_y, a_max_y = a
b_min_x, b_max_x, b_min_y, b_max_y = b
overlap_x = min(a_max_x, b_max_x) - max(a_min_x, b_min_x)
overlap_y = min(a_max_y, b_max_y) - max(a_min_y, b_min_y)
return float(overlap_x), float(overlap_y)
@staticmethod
def _bbox_contains(
outer: tuple[float, float, float, float],
inner: tuple[float, float, float, float],
margin_mm: float = 0.0,
) -> bool:
o_min_x, o_max_x, o_min_y, o_max_y = outer
i_min_x, i_max_x, i_min_y, i_max_y = inner
m = max(0.0, float(margin_mm))
return (
i_min_x >= (o_min_x + m)
and i_max_x <= (o_max_x - m)
and i_min_y >= (o_min_y + m)
and i_max_y <= (o_max_y - m)
)
@classmethod
def _buffer_overlap_exceeds_limit(
cls,
a: tuple[float, float, float, float],
b: tuple[float, float, float, float],
) -> bool:
overlap_x, overlap_y = cls._bbox_overlap_xy_mm(a, b)
if overlap_x <= 0.0 or overlap_y <= 0.0:
return False
# Для расположений бок-о-бок или торец-к-торцу важна только ось проникновения.
penetration = min(overlap_x, overlap_y)
return penetration > float(getattr(cls, "_MAX_BUFFER_OVERLAP_MM", 100.0))
# -- валидация и видимость слотов -----------------------------------------
def _validate_rack_position(
self: "ModelViewWidget",
cx: float,
cy: float,
zone_id: str,
params: dict[str, Any],
rotation: int,
ignore_rack_id: str | None = None,
) -> bool:
min_x, max_x, min_y, max_y = self._rack_placement_bbox(cx, cy, params, rotation)
z_ref = self._zone_heights.get(zone_id, (0.0, 0.0))[0]
corners = [(min_x, min_y), (max_x, min_y), (max_x, max_y), (min_x, max_y)]
for px, py in corners:
if not self._point_in_zone_or_boundary(px, py, zone_id, plane_z=z_ref):
return False
if self._room_bounds:
rb = self._room_bounds
if min_x < rb[0] or max_x > rb[1] or min_y < rb[2] or max_y > rb[3]:
return False
cand = self._rack_placement_bbox(cx, cy, params, rotation)
cand_collision = self._rack_collision_bbox(cx, cy, params, rotation)
cand_is_mezzanine = self._is_mezzanine_params(params)
cand_has_aisle = not self._has_additional_model(params)
cand_aisles = rack_aisle_bboxes(
cx, cy, params, rotation,
aisle_mm=self._rack_access_aisle_mm(params),
) if cand_has_aisle else []
cand_columns = self._mezzanine_column_bboxes(cx, cy, params, rotation) if cand_is_mezzanine else []
for entry in self._rack_entries:
if entry["zone_id"] != zone_id:
continue
if ignore_rack_id and str(entry.get("rack_id", "")) == str(ignore_rack_id):
continue
existing_center = entry.get("center") or (0.0, 0.0)
existing_params = dict(entry.get("params") or {})
existing_rotation = int(entry.get("rotation", 0))
ex_x, ex_y = float(existing_center[0]), float(existing_center[1])
existing_body = self._rack_placement_bbox(ex_x, ex_y, existing_params, existing_rotation)
existing_collision = self._rack_collision_bbox(ex_x, ex_y, existing_params, existing_rotation)
existing_is_mezzanine = self._is_mezzanine_params(existing_params)
existing_has_aisle = not self._has_additional_model(existing_params)
existing_aisles = rack_aisle_bboxes(
ex_x, ex_y, existing_params, existing_rotation,
aisle_mm=self._rack_access_aisle_mm(existing_params),
) if existing_has_aisle else []
existing_columns = self._mezzanine_column_bboxes(
ex_x, ex_y, existing_params, existing_rotation,
) if existing_is_mezzanine else []
# 1) Пересечение тел по умолчанию запрещено.
if bboxes_intersect(cand, existing_body):
if not self._can_share_xy_with_mezzanine(params, existing_params):
return False
if existing_is_mezzanine:
# Стеллаж под мезонином должен полностью находиться внутри контура мезонина.
if not self._bbox_contains(
existing_body,
cand,
margin_mm=float(getattr(self, "_MEZZANINE_CONTAINMENT_MARGIN_MM", 100.0)),
):
return False
if not existing_columns:
return False
if any(bboxes_intersect(cand, col_bbox) for col_bbox in existing_columns):
return False
if cand_is_mezzanine:
# Существующий стеллаж должен полностью находиться внутри контура мезонина-кандидата.
if not self._bbox_contains(
cand,
existing_body,
margin_mm=float(getattr(self, "_MEZZANINE_CONTAINMENT_MARGIN_MM", 100.0)),
):
return False
if not cand_columns:
return False
if any(bboxes_intersect(existing_body, col_bbox) for col_bbox in cand_columns):
return False
if existing_is_mezzanine and existing_columns:
if any(
bboxes_intersect(cand_col, ex_col)
for cand_col in cand_columns
for ex_col in existing_columns
):
return False
# 1.1) Буферные зоны столкновений могут перекрываться до 100 мм (не более).
if (not cand_is_mezzanine) and (not existing_is_mezzanine):
if self._buffer_overlap_exceeds_limit(cand_collision, existing_collision):
return False
# 2) Тело кандидата не должно пересекать существующие зоны проходов.
if existing_aisles:
if any(bboxes_intersect(cand, ex_aisle) for ex_aisle in existing_aisles):
return False
# 3) Зоны проходов кандидата не должны пересекать существующие тела.
if (not existing_is_mezzanine) and cand_aisles:
if any(bboxes_intersect(existing_body, cand_aisle) for cand_aisle in cand_aisles):
return False
return True
def _on_rack_slot_visibility_changed(
self: "ModelViewWidget",
rack_id: str,
slot_id: str,
occupied: bool,
) -> None:
self.set_rack_slot_occupied(rack_id, slot_id, occupied)
def set_rack_slot_occupied(
self: "ModelViewWidget",
rack_id: str,
slot_id: str,
occupied: bool = True,
) -> bool:
rid = str(rack_id or "")
sid = str(slot_id or "")
if not rid or not sid:
return False
for entry in self._rack_entries:
if str(entry.get("rack_id", "")) != rid:
continue
actor = (entry.get("slot_actors") or {}).get(sid)
if actor is None:
return False
try:
actor.SetVisibility(0 if occupied else 1)
except Exception as exc:
log_exception(__name__, "set_rack_slot_occupied", exc)
return False
hidden = entry.setdefault("hidden_slot_ids", set())
if occupied:
hidden.add(sid)
else:
hidden.discard(sid)
if self._plotter:
self._plotter.update()
return True
return False
# ---------------------------------------------------------------------------
# Module workflow notes
# ---------------------------------------------------------------------------
# 1) Задача модуля:
# Проверка коллизий и ограничений размещения стеллажей.
#
# 2) Последовательность действий и вызовов:
# A. Класс RackCollisionMixin: точки входа
# Публичные методы сценария:
# - RackCollisionMixin.set_imported_models_enabled(...)
# - RackCollisionMixin.set_rack_slot_occupied(...)
#
# B. RackCollisionMixin: запуск и настройка:
# RackCollisionMixin.set_imported_models_enabled(...)
# Назначение: Устанавливает глобальную политику видимости для импортированных пользовательских моделей.
# Последовательность внутренних вызовов:
# -> RackCollisionMixin._is_imported_model_entry(...)
# RackCollisionMixin.set_rack_slot_occupied(...)
# Назначение: устанавливает rack slot occupied в рамках текущего сценария модуля.
#
# C. RackCollisionMixin: основной сценарий:
# RackCollisionMixin._has_additional_model(...)
# Назначение: проверяет наличие additional model в рамках текущего сценария модуля.
# RackCollisionMixin._is_imported_model_entry(...)
# Назначение: проверяет, что imported model entry в рамках текущего сценария модуля.
# Последовательность внутренних вызовов:
# -> RackCollisionMixin._has_additional_model(...)
# RackCollisionMixin._is_pallet_params(...)
# Назначение: проверяет, что pallet params в рамках текущего сценария модуля.
# RackCollisionMixin._validate_rack_position(...)
# Назначение: проверяет корректность rack position в рамках текущего сценария модуля.
# Последовательность внутренних вызовов:
# -> RackCollisionMixin._rack_placement_bbox(...)
# -> RackCollisionMixin._rack_collision_bbox(...)
# -> RackCollisionMixin._has_additional_model(...)
# -> RackCollisionMixin._buffer_overlap_exceeds_limit(...)
# -> RackCollisionMixin._rack_access_aisle_mm(...)
# -> RackCollisionMixin._bbox_contains(...)
# RackCollisionMixin._on_rack_slot_visibility_changed(...)
# Назначение: выполняет шаг "on rack slot visibility changed" в рамках текущего сценария модуля.
# Последовательность внутренних вызовов:
# -> RackCollisionMixin.set_rack_slot_occupied(...)
#
# D. RackCollisionMixin: вспомогательные расчёты:
# RackCollisionMixin._rack_height_mm(...)
# Назначение: выполняет шаг "rack height mm" в рамках текущего сценария модуля.
# RackCollisionMixin._force_show_imported_model_entry(...)
# Назначение: выполняет шаг "force show imported model entry" в рамках текущего сценария модуля.
# Последовательность внутренних вызовов:
# -> RackCollisionMixin._is_imported_model_entry(...)
# RackCollisionMixin._rack_access_aisle_mm(...)
# Назначение: выполняет шаг "rack access aisle mm" в рамках текущего сценария модуля.
# RackCollisionMixin._rack_wall_buffer_mm(...)
# Назначение: выполняет шаг "rack wall buffer mm" в рамках текущего сценария модуля.
# RackCollisionMixin._rack_placement_bbox(...)
# Назначение: выполняет шаг "rack placement bbox" в рамках текущего сценария модуля.
# RackCollisionMixin._rack_collision_buffer_mm(...)
# Назначение: выполняет шаг "rack collision buffer mm" в рамках текущего сценария модуля.
# RackCollisionMixin._rack_collision_bbox(...)
# Назначение: выполняет шаг "rack collision bbox" в рамках текущего сценария модуля.
# RackCollisionMixin._bbox_overlap_xy_mm(...)
# Назначение: выполняет шаг "bbox overlap xy mm" в рамках текущего сценария модуля.
# RackCollisionMixin._bbox_contains(...)
# Назначение: выполняет шаг "bbox contains" в рамках текущего сценария модуля.
# RackCollisionMixin._buffer_overlap_exceeds_limit(...)
# Назначение: выполняет шаг "buffer overlap exceeds limit" в рамках текущего сценария модуля.
#
# 3) Важные ограничения и инварианты:
# - Модуль выполняется в составе ModelViewWidget и использует согласованные поля состояния self._... .
# - Все операции с актёрами и камерой выполняются только при валидном self._plotter с безопасной обработкой исключений.
# - Межмодульная связность: только через фасад model_view; прямые обращения между zone, rack, shelf, cell запрещены.
# - Очистка состояния должна быть идемпотентной: повторный вызов не меняет корректное состояние в ошибочное.
#
# 4) Правило сопровождения:
# - Любое изменение сценария должно сопровождаться обновлением этого блока с сохранением фактического порядка вызовов.
# - При добавлении метода указывать его место в цепочке сценария (запуск, основной шаг, завершение, вспомогательная логика).

View File

@@ -0,0 +1,425 @@
# -*- coding: utf-8 -*-
"""CRUD-миксин стеллажей — вспомогательные методы добавления / удаления / видимости для ModelViewWidget."""
from __future__ import annotations
from typing import Any, TYPE_CHECKING
from error_logger import log_exception
if TYPE_CHECKING:
from gui.components.model_view_widget import ModelViewWidget
class RackCrudMixin:
"""Операции создания-чтения-обновления-удаления стеллажей, выделенные из _mv_racks."""
def _clear_removed_rack_visuals(self: "ModelViewWidget", rack_id: str) -> None:
rid = str(rack_id or "")
if not rid:
return
try:
self._clear_slot_shelf_actors(rid, clear_slot_id=None)
except Exception as _exc:
log_exception(__name__, "_clear_removed_rack_visuals", _exc)
try:
if hasattr(self, "clear_cell_grid_visualization"):
self.clear_cell_grid_visualization(rid)
except Exception as _exc:
log_exception(__name__, "_clear_removed_rack_visuals", _exc)
try:
params_storage = getattr(self, "_rack_shelf_params", {}) or {}
keys = [k for k in list(params_storage.keys()) if isinstance(k, tuple) and str(k[0]) == rid]
for key in keys:
params_storage.pop(key, None)
except Exception as _exc:
log_exception(__name__, "_clear_removed_rack_visuals", _exc)
def remove_last_rack(self: "ModelViewWidget", zone_id: str | None) -> bool:
if not zone_id:
return False
for idx in range(len(self._rack_entries) - 1, -1, -1):
entry = self._rack_entries[idx]
if entry["zone_id"] != zone_id:
continue
rid = str(entry.get("rack_id") or "")
for actor in entry.get("actors", []):
try:
self._plotter.remove_actor(actor)
except Exception as _exc:
log_exception(__name__, "remove_last_rack", _exc)
if rid:
self._clear_removed_rack_visuals(rid)
self._rack_entries.pop(idx)
if self._plotter:
self._plotter.update()
self.rack_layout_changed.emit(str(zone_id))
if str(self._hover_rack_id or "") == rid:
self._clear_hover_rack_highlight()
if str(self._selected_rack_id or "") == rid:
self._set_selected_rack(None)
if str(getattr(self, "_rack_isolation_rack_id", "") or "") == rid:
self._rack_isolation_active = False
self._rack_isolation_rack_id = None
self._clear_rack_isolation_visual()
if str(getattr(self, "_shelf_target_rack_id", "") or "") == rid:
self.stop_shelf_placement_ext(clear_selection=True, clear_rendered_shelves=True)
if str(getattr(self, "_selected_shelf_bbox_rack_id", "") or "") == rid:
self._clear_selected_rendered_shelf(render=False)
return True
return False
def remove_selected_rack(self: "ModelViewWidget") -> bool:
rid = str(self._selected_rack_id or "")
if not rid:
return False
for idx in range(len(self._rack_entries) - 1, -1, -1):
entry = self._rack_entries[idx]
if str(entry.get("rack_id", "")) != rid:
continue
zone_id = str(entry.get("zone_id", ""))
for actor in entry.get("actors", []):
try:
self._plotter.remove_actor(actor)
except Exception as _exc:
log_exception(__name__, "remove_selected_rack", _exc)
self._clear_removed_rack_visuals(rid)
self._rack_entries.pop(idx)
if str(self._hover_rack_id or "") == rid:
self._clear_hover_rack_highlight()
self._set_selected_rack(None)
if str(getattr(self, "_rack_isolation_rack_id", "") or "") == rid:
self._rack_isolation_active = False
self._rack_isolation_rack_id = None
self._clear_rack_isolation_visual()
if str(getattr(self, "_shelf_target_rack_id", "") or "") == rid:
self.stop_shelf_placement_ext(clear_selection=True, clear_rendered_shelves=True)
if str(getattr(self, "_selected_shelf_bbox_rack_id", "") or "") == rid:
self._clear_selected_rendered_shelf(render=False)
if self._plotter:
self._plotter.update()
self.rack_layout_changed.emit(zone_id)
return True
return False
def clear_all_racks(self: "ModelViewWidget") -> None:
self._rack_isolation_active = False
self._rack_isolation_rack_id = None
self._clear_rack_isolation_visual()
self._clear_rack_preview()
self._clear_hover_rack_highlight()
if hasattr(self, "_clear_selected_rendered_shelf"):
self._clear_selected_rendered_shelf(render=False)
self._set_selected_rack(None)
self._last_moved_rack_id = None
for entry in self._rack_entries:
for actor in entry.get("actors", []):
try:
self._plotter.remove_actor(actor)
except Exception as _exc:
log_exception(__name__, "clear_all_racks", _exc)
rack_id = str(entry.get("rack_id") or "")
if rack_id:
self._clear_slot_shelf_actors(rack_id, clear_slot_id=None)
self._rack_entries = []
if self._plotter:
self._plotter.update()
def show_only_zone_racks(self: "ModelViewWidget", zone_id: str | None) -> None:
self._rack_isolation_active = False
self._rack_isolation_rack_id = None
self._clear_rack_isolation_visual()
if hasattr(self, "_clear_selected_rendered_shelf"):
self._clear_selected_rendered_shelf(render=False)
if str(zone_id or "") != str(self._rack_preview_zone_id or ""):
self._clear_hover_rack_highlight()
for entry in self._rack_entries:
is_imported = self._is_imported_model_entry(entry)
if is_imported:
visible = bool(self._imported_models_enabled)
else:
visible = (zone_id is not None) and (entry["zone_id"] == zone_id)
for actor in entry.get("actors", []):
try:
actor.SetVisibility(1 if visible else 0)
except Exception as _exc:
log_exception(__name__, "show_only_zone_racks", _exc)
self._set_rack_shelf_actors_visibility(str(entry.get("rack_id") or ""), visible)
self._apply_rack_helper_visibility_policy(entry, visible)
if self._selected_rack_id:
selected = self.get_selected_rack_entry()
if selected is None or str(selected.get("zone_id", "")) != str(zone_id or ""):
self._set_selected_rack(None)
else:
self._apply_selected_rack_visual(str(self._selected_rack_id))
if self._plotter:
self._plotter.update()
def show_only_rack(self: "ModelViewWidget", rack_id: str | None, zone_id: str | None = None) -> bool:
rid = str(rack_id or "")
zid = str(zone_id or "")
if not rid:
return False
if str(getattr(self, "_selected_shelf_bbox_rack_id", "") or "") != rid:
if hasattr(self, "_clear_selected_rendered_shelf"):
self._clear_selected_rendered_shelf(render=False)
prev_isolation_id = str(getattr(self, "_rack_isolation_rack_id", "") or "")
if prev_isolation_id and prev_isolation_id != rid:
# Переключение изолированного стеллажа: сначала восстанавливаем свойства предыдущего актёра.
self._clear_rack_isolation_visual()
if str(self._hover_rack_id or "") != rid:
self._clear_hover_rack_highlight()
found = False
for entry in self._rack_entries:
same_rack = str(entry.get("rack_id", "")) == rid
same_zone = True if not zid else str(entry.get("zone_id", "")) == zid
is_imported = self._is_imported_model_entry(entry)
if is_imported:
visible = bool(self._imported_models_enabled)
else:
visible = same_rack and same_zone
if same_rack and same_zone:
found = True
for actor in entry.get("actors", []):
try:
actor.SetVisibility(1 if visible else 0)
except Exception as _exc:
log_exception(__name__, "show_only_rack", _exc)
self._set_rack_shelf_actors_visibility(str(entry.get("rack_id") or ""), visible)
self._apply_rack_helper_visibility_policy(entry, visible)
if not found:
self._rack_isolation_active = False
self._rack_isolation_rack_id = None
self._clear_rack_isolation_visual()
if zid:
self.show_only_zone_racks(zid)
return False
self._rack_isolation_active = True
self._rack_isolation_rack_id = rid
self._apply_rack_isolation_visual(rid)
# Применяем стиль выделения только после активации изоляции, чтобы вспомогательные
# объёмы сохраняли исходную непрозрачность при возврате на уровень зоны.
self._set_selected_rack(rid)
if self._plotter:
self._plotter.update()
return True
def show_only_shelf_in_rack(
self: "ModelViewWidget",
rack_id: str | None,
slot_id: str | None,
shelf_index: int | None = None,
zone_id: str | None = None,
) -> bool:
"""Показывает только одну полку (доску) внутри изолированного стеллажа; стойки стеллажа остаются видимыми."""
rid = str(rack_id or "")
sid = str(slot_id or "")
if not rid or not sid:
return False
if not self.show_only_rack(rid, zone_id):
return False
requested_index = int(1 if shelf_index is None else shelf_index)
rack_type = ""
found_target_slot = False
entry = self._get_rack_entry(rid, str(zone_id or "")) if zone_id else self._get_rack_entry(rid)
if entry is not None:
rack_type = str((entry.get("params") or {}).get("rack_type") or "").strip().upper()
# Единый контракт индекса: внешне (tree/ui) используется tree-index.
# Для PALLET tree-index=1 соответствует напольному уровню (под первой полкой),
# а физические полки рендерятся актёрами с индексами 1..N для tree-index=2..N+1.
if rack_type == "PALLET":
is_floor_level = requested_index <= 1
target_index = max(1, requested_index - 1)
else:
is_floor_level = requested_index <= 0
target_index = max(1, requested_index)
if entry is not None and sid in (entry.get("slot_actors") or {}):
found_target_slot = True
shown_any = False
for (entry_rid, entry_sid), actors in (self._rack_shelf_actors or {}).items():
same_rack = str(entry_rid) == rid
same_slot = str(entry_sid) == sid
if same_rack and same_slot:
found_target_slot = True
for actor_index, actor in enumerate(list(actors or []), start=1):
if actor is None:
continue
visible = bool(same_rack and same_slot and actor_index == target_index and not is_floor_level)
if visible:
shown_any = True
try:
actor.SetVisibility(1 if visible else 0)
except Exception as _exc:
log_exception(__name__, "show_only_shelf_in_rack", _exc)
# Запасной вариант: если запрошенный индекс не существует, показываем все полки целевого слота.
if found_target_slot and not shown_any and not is_floor_level:
for (entry_rid, entry_sid), actors in (self._rack_shelf_actors or {}).items():
same_slot = str(entry_rid) == rid and str(entry_sid) == sid
for actor in list(actors or []):
if actor is None:
continue
try:
actor.SetVisibility(1 if same_slot else 0)
except Exception as _exc:
log_exception(__name__, "show_only_shelf_in_rack", _exc)
target_index = 1
if found_target_slot:
if is_floor_level and hasattr(self, "_highlight_floor_shelf_polygon"):
self._highlight_floor_shelf_polygon(rid, sid)
elif hasattr(self, "_highlight_selected_rendered_shelf"):
self._highlight_selected_rendered_shelf(rid, sid, target_index)
if self._plotter:
self._plotter.update()
return found_target_slot
def show_all_racks(self: "ModelViewWidget") -> None:
self._rack_isolation_active = False
self._rack_isolation_rack_id = None
self._clear_rack_isolation_visual()
self._clear_hover_rack_highlight()
if hasattr(self, "_clear_selected_rendered_shelf"):
self._clear_selected_rendered_shelf(render=False)
for entry in self._rack_entries:
visible = not self._is_imported_model_entry(entry) or bool(self._imported_models_enabled)
for actor in entry.get("actors", []):
try:
actor.SetVisibility(1 if visible else 0)
except Exception as _exc:
log_exception(__name__, "show_all_racks", _exc)
self._set_rack_shelf_actors_visibility(str(entry.get("rack_id") or ""), visible)
self._apply_rack_helper_visibility_policy(entry, visible)
if self._selected_rack_id:
self._apply_selected_rack_visual(str(self._selected_rack_id))
if self._plotter:
self._plotter.update()
def _set_rack_shelf_actors_visibility(self: "ModelViewWidget", rack_id: str, visible: bool) -> None:
rid = str(rack_id or "")
if not rid:
return
shelf_actors = getattr(self, "_rack_shelf_actors", {}) or {}
for (entry_rid, _slot_id), actors in shelf_actors.items():
if str(entry_rid) != rid:
continue
for actor in actors or []:
try:
actor.SetVisibility(1 if visible else 0)
except Exception as _exc:
log_exception(__name__, "_set_rack_shelf_actors_visibility", _exc)
# Сетка ячеек (preview/final/dim) должна следовать политике видимости стеллажа.
for storage_name in ("_cell_preview_actors", "_cell_final_actors", "_cell_dim_actors"):
storage = getattr(self, storage_name, {}) or {}
for key, actors in storage.items():
if not isinstance(key, tuple) or not key:
continue
if str(key[0]) != rid:
continue
for actor in actors or []:
try:
actor.SetVisibility(1 if visible else 0)
except Exception as _exc:
log_exception(__name__, "_set_rack_shelf_actors_visibility", _exc)
if hasattr(self, "_apply_cell_grid_edit_isolation_visibility"):
self._apply_cell_grid_edit_isolation_visibility()
def _apply_rack_helper_visibility_policy(
self: "ModelViewWidget",
entry: dict[str, Any],
base_visible: bool,
) -> None:
if not bool(entry.get("shelf_helpers_hidden")):
return
slot_actor_ids = {
id(actor)
for actor in (entry.get("slot_actors") or {}).values()
if actor is not None
}
for idx, actor in enumerate(entry.get("actors", [])):
if actor is None:
continue
try:
if idx == 0:
actor.SetVisibility(1 if base_visible else 0)
else:
actor.SetVisibility(0)
if id(actor) in slot_actor_ids:
actor.SetVisibility(0)
except Exception as _exc:
log_exception(__name__, "_apply_rack_helper_visibility_policy", _exc)
def has_racks_in_zone(self: "ModelViewWidget", zone_id: str | None) -> bool:
zid = str(zone_id or "")
if not zid:
return False
return any(str(e.get("zone_id", "")) == zid for e in self._rack_entries)
# ---------------------------------------------------------------------------
# Module workflow notes
# ---------------------------------------------------------------------------
# 1) Задача модуля:
# CRUD-операции стеллажей и управление их видимостью.
#
# 2) Последовательность действий и вызовов:
# A. Класс RackCrudMixin: точки входа
# Публичные методы сценария:
# - RackCrudMixin.remove_last_rack(...)
# - RackCrudMixin.remove_selected_rack(...)
# - RackCrudMixin.clear_all_racks(...)
# - RackCrudMixin.show_only_zone_racks(...)
# - RackCrudMixin.show_only_rack(...)
# - RackCrudMixin.show_only_shelf_in_rack(...)
# - RackCrudMixin.show_all_racks(...)
# - RackCrudMixin.has_racks_in_zone(...)
#
# B. RackCrudMixin: запуск и настройка:
# RackCrudMixin._set_rack_shelf_actors_visibility(...)
# Назначение: устанавливает rack shelf actors visibility в рамках текущего сценария модуля.
#
# C. RackCrudMixin: основной сценарий:
# RackCrudMixin.show_only_zone_racks(...)
# Назначение: показывает only zone racks в рамках текущего сценария модуля.
# Последовательность внутренних вызовов:
# -> RackCrudMixin._set_rack_shelf_actors_visibility(...)
# -> RackCrudMixin._apply_rack_helper_visibility_policy(...)
# RackCrudMixin.show_only_rack(...)
# Назначение: показывает only rack в рамках текущего сценария модуля.
# Последовательность внутренних вызовов:
# -> RackCrudMixin._set_rack_shelf_actors_visibility(...)
# -> RackCrudMixin._apply_rack_helper_visibility_policy(...)
# -> RackCrudMixin.show_only_zone_racks(...)
# RackCrudMixin.show_only_shelf_in_rack(...)
# Назначение: Показывает только одну полку (доску) внутри изолированного стеллажа; стойки стеллажа остаются видимыми.
# Последовательность внутренних вызовов:
# -> RackCrudMixin.show_only_rack(...)
# RackCrudMixin.show_all_racks(...)
# Назначение: показывает all racks в рамках текущего сценария модуля.
# Последовательность внутренних вызовов:
# -> RackCrudMixin._set_rack_shelf_actors_visibility(...)
# -> RackCrudMixin._apply_rack_helper_visibility_policy(...)
# RackCrudMixin._apply_rack_helper_visibility_policy(...)
# Назначение: применяет rack helper visibility policy в рамках текущего сценария модуля.
# RackCrudMixin.has_racks_in_zone(...)
# Назначение: проверяет наличие racks in zone в рамках текущего сценария модуля.
#
# D. RackCrudMixin: завершение и очистка:
# RackCrudMixin.remove_last_rack(...)
# Назначение: удаляет last rack в рамках текущего сценария модуля.
# RackCrudMixin.remove_selected_rack(...)
# Назначение: удаляет selected rack в рамках текущего сценария модуля.
# RackCrudMixin.clear_all_racks(...)
# Назначение: очищает all racks в рамках текущего сценария модуля.
#
# 3) Важные ограничения и инварианты:
# - Модуль выполняется в составе ModelViewWidget и использует согласованные поля состояния self._... .
# - Все операции с актёрами и камерой выполняются только при валидном self._plotter с безопасной обработкой исключений.
# - Межмодульная связность: только через фасад model_view; прямые обращения между zone, rack, shelf, cell запрещены.
# - Очистка состояния должна быть идемпотентной: повторный вызов не меняет корректное состояние в ошибочное.
#
# 4) Правило сопровождения:
# - Любое изменение сценария должно сопровождаться обновлением этого блока с сохранением фактического порядка вызовов.
# - При добавлении метода указывать его место в цепочке сценария (запуск, основной шаг, завершение, вспомогательная логика).

View File

@@ -0,0 +1,552 @@
# -*- coding: utf-8 -*-
# gui/components/model_view/_mv_racks_hover.py
"""Вспомогательные функции подсветки при наведении и активного выделения стеллажей и слотов полок."""
from __future__ import annotations
from typing import Any, TYPE_CHECKING
try:
import pyvista as pv
_PV = True
except ImportError:
_PV = False
from gui.components.model_view._mv_rack_geometry import rack_bbox
from error_logger import log_exception
if TYPE_CHECKING:
from gui.components.model_view_widget import ModelViewWidget
class RackHoverMixin:
"""Логика подсветки при наведении и активного выделения для стеллажей и слотов полок."""
def _actor_contour_mesh(self: "ModelViewWidget", actor):
if not _PV or actor is None:
return None
try:
bounds = actor.GetBounds()
except Exception as exc:
log_exception(__name__, "_actor_contour_mesh", exc)
return None
if not bounds or len(bounds) != 6:
return None
min_x, max_x, min_y, max_y, min_z, max_z = [float(v) for v in bounds]
points = [
(min_x, min_y, min_z),
(max_x, min_y, min_z),
(max_x, max_y, min_z),
(min_x, max_y, min_z),
(min_x, min_y, max_z),
(max_x, min_y, max_z),
(max_x, max_y, max_z),
(min_x, max_y, 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
def _highlight_selected_rendered_shelf(
self: "ModelViewWidget",
rack_id: str,
slot_id: str,
shelf_index: int,
) -> None:
rid = str(rack_id or "")
sid = str(slot_id or "")
idx = max(1, int(shelf_index or 1))
if not rid or not sid or not self._plotter:
self._clear_selected_rendered_shelf(render=False)
return
if (
str(self._selected_shelf_bbox_rack_id or "") == rid
and str(self._selected_shelf_bbox_slot_id or "") == sid
and int(self._selected_shelf_bbox_index or 0) == idx
and self._selected_shelf_bbox_actor is not None
):
return
self._clear_selected_rendered_shelf(render=False)
actors = list((self._rack_shelf_actors or {}).get((rid, sid)) or [])
if not actors:
return
actor = actors[min(len(actors) - 1, max(0, idx - 1))]
contour = self._actor_contour_mesh(actor)
if contour is None:
return
try:
self._selected_shelf_bbox_actor = self._plotter.add_mesh(
contour,
color=(1.0, 1.0, 0.3),
line_width=3.0,
pickable=False,
name="_selected_shelf_bbox_contour",
)
self._selected_shelf_bbox_rack_id = rid
self._selected_shelf_bbox_slot_id = sid
self._selected_shelf_bbox_index = idx
except Exception as exc:
log_exception(__name__, "_highlight_selected_rendered_shelf", exc)
self._selected_shelf_bbox_actor = None
self._selected_shelf_bbox_rack_id = None
self._selected_shelf_bbox_slot_id = None
self._selected_shelf_bbox_index = None
return
if hasattr(self, "_safe_render"):
self._safe_render(min_interval_s=1.0 / 60.0)
else:
self._plotter.update()
def _floor_shelf_plane_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__, "_floor_shelf_plane_mesh", exc)
return None
if not bounds or len(bounds) != 6:
return None
min_x, max_x, min_y, max_y, min_z, _max_z = [float(v) for v in bounds]
# Небольшой подъём полигона над базовой плоскостью, чтобы исключить z-fighting.
plane_z = float(min_z + 2.0)
points = [
(min_x, min_y, plane_z),
(max_x, min_y, plane_z),
(max_x, max_y, plane_z),
(min_x, max_y, plane_z),
]
plane = pv.PolyData(points)
plane.faces = [4, 0, 1, 2, 3]
return plane
def _highlight_floor_shelf_polygon(
self: "ModelViewWidget",
rack_id: str,
slot_id: str,
) -> None:
rid = str(rack_id or "")
sid = str(slot_id or "")
if not rid or not sid or not self._plotter:
self._clear_floor_shelf_polygon(render=False)
return
if (
str(self._selected_floor_shelf_rack_id or "") == rid
and str(self._selected_floor_shelf_slot_id or "") == sid
and self._selected_floor_shelf_actor is not None
):
return
bbox_actor = getattr(self, "_selected_shelf_bbox_actor", None)
if bbox_actor is not None and self._plotter:
try:
self._plotter.remove_actor(bbox_actor)
except Exception as _exc:
log_exception(__name__, "_highlight_floor_shelf_polygon", _exc)
self._selected_shelf_bbox_actor = None
self._selected_shelf_bbox_rack_id = None
self._selected_shelf_bbox_slot_id = None
self._selected_shelf_bbox_index = None
self._clear_floor_shelf_polygon(render=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
slot_actor = (entry.get("slot_actors") or {}).get(sid)
mesh = self._floor_shelf_plane_mesh(slot_actor)
if mesh is None:
return
try:
self._selected_floor_shelf_actor = self._plotter.add_mesh(
mesh,
color=(1.0, 1.0, 0.3),
edge_color=(1.0, 1.0, 0.3),
show_edges=True,
opacity=0.28,
line_width=2.5,
pickable=False,
name="_selected_floor_shelf_polygon",
)
self._selected_floor_shelf_rack_id = rid
self._selected_floor_shelf_slot_id = sid
except Exception as exc:
log_exception(__name__, "_highlight_floor_shelf_polygon", exc)
self._selected_floor_shelf_actor = None
self._selected_floor_shelf_rack_id = None
self._selected_floor_shelf_slot_id = None
return
if hasattr(self, "_safe_render"):
self._safe_render(min_interval_s=1.0 / 60.0)
else:
self._plotter.update()
def _clear_floor_shelf_polygon(self: "ModelViewWidget", render: bool = True) -> None:
actor = getattr(self, "_selected_floor_shelf_actor", None)
if actor is not None and self._plotter:
try:
self._plotter.remove_actor(actor)
except Exception as _exc:
log_exception(__name__, "_clear_floor_shelf_polygon", _exc)
self._selected_floor_shelf_actor = None
self._selected_floor_shelf_rack_id = None
self._selected_floor_shelf_slot_id = None
if not render:
return
if hasattr(self, "_safe_render"):
self._safe_render(min_interval_s=1.0 / 60.0)
elif self._plotter:
self._plotter.update()
def _clear_selected_rendered_shelf(self: "ModelViewWidget", render: bool = True) -> None:
self._clear_floor_shelf_polygon(render=False)
actor = getattr(self, "_selected_shelf_bbox_actor", None)
if actor is not None and self._plotter:
try:
self._plotter.remove_actor(actor)
except Exception as _exc:
log_exception(__name__, "_clear_selected_rendered_shelf", _exc)
self._selected_shelf_bbox_actor = None
self._selected_shelf_bbox_rack_id = None
self._selected_shelf_bbox_slot_id = None
self._selected_shelf_bbox_index = None
if not render:
return
if hasattr(self, "_safe_render"):
self._safe_render(min_interval_s=1.0 / 60.0)
elif self._plotter:
self._plotter.update()
def _highlight_shelf_slot_hover(self: "ModelViewWidget", slot_id: str) -> None:
rid = str(self._shelf_target_rack_id or "")
zid = str(self._shelf_target_zone_id or "")
entry = self._get_rack_entry(rid, zid)
if entry is None or not self._plotter:
return
self._clear_shelf_slot_hover()
slot_actor = (entry.get("slot_actors") or {}).get(str(slot_id))
contour = self._slot_contour_mesh(slot_actor)
if contour is None:
return
try:
self._hover_shelf_slot_actor = self._plotter.add_mesh(
contour,
color=(1.0, 1.0, 0.3),
line_width=3.0,
pickable=False,
name="_hover_shelf_slot_contour",
)
self._hover_shelf_slot_id = str(slot_id)
except Exception as exc:
log_exception(__name__, "_highlight_shelf_slot_hover", exc)
self._hover_shelf_slot_actor = None
self._hover_shelf_slot_id = None
return
self._plotter.update()
def _clear_shelf_slot_hover(self: "ModelViewWidget") -> None:
actor = getattr(self, "_hover_shelf_slot_actor", None)
if actor is not None and self._plotter:
try:
self._plotter.remove_actor(actor)
except Exception as _exc:
log_exception(__name__, "_clear_shelf_slot_hover", _exc)
self._hover_shelf_slot_actor = None
self._hover_shelf_slot_id = None
def _set_active_shelf_slot(self: "ModelViewWidget", slot_id: str) -> None:
rid = str(self._shelf_target_rack_id or "")
zid = str(self._shelf_target_zone_id or "")
entry = self._get_rack_entry(rid, zid)
if entry is None or not self._plotter:
return
self._clear_selected_rendered_shelf(render=False)
self._clear_shelf_slot_active()
self._clear_shelf_slot_hover()
slot_actor = (entry.get("slot_actors") or {}).get(str(slot_id))
contour = self._slot_contour_mesh(slot_actor)
if contour is None:
return
try:
self._active_shelf_slot_actor = self._plotter.add_mesh(
contour,
color=(0.25, 0.55, 1.0),
line_width=3.0,
pickable=False,
name="_active_shelf_slot_contour",
)
self._active_shelf_slot_id = str(slot_id)
except Exception as exc:
log_exception(__name__, "_set_active_shelf_slot", exc)
self._active_shelf_slot_actor = None
self._active_shelf_slot_id = None
self._plotter.update()
def _clear_shelf_slot_active(self: "ModelViewWidget") -> None:
actor = getattr(self, "_active_shelf_slot_actor", None)
if actor is not None and self._plotter:
try:
self._plotter.remove_actor(actor)
except Exception as _exc:
log_exception(__name__, "_clear_shelf_slot_active", _exc)
self._active_shelf_slot_actor = None
self._active_shelf_slot_id = None
def _find_rack_id_by_actor(self: "ModelViewWidget", picked_actor, zone_id: str) -> str | None:
zid = str(zone_id or "")
if picked_actor is None:
return None
for entry in reversed(self._rack_entries):
if zid and str(entry.get("zone_id", "")) != zid:
continue
for actor in entry.get("actors", []):
if actor is None:
continue
try:
if not bool(actor.GetVisibility()):
continue
except Exception as _exc:
log_exception(__name__, "_find_rack_id_by_actor", _exc)
if actor is picked_actor or actor == picked_actor:
return str(entry.get("rack_id", ""))
try:
if (
hasattr(actor, "GetAddressAsString")
and hasattr(picked_actor, "GetAddressAsString")
and actor.GetAddressAsString("") == picked_actor.GetAddressAsString("")
):
return str(entry.get("rack_id", ""))
except Exception as _exc:
log_exception(__name__, "_find_rack_id_by_actor", _exc)
return None
def _find_rack_id_by_point(self: "ModelViewWidget", x: float, y: float, zone_id: str) -> str | None:
zid = str(zone_id or "")
for entry in reversed(self._rack_entries):
if zid and str(entry.get("zone_id", "")) != zid:
continue
# Пропускаем скрытые импортированные модели, чтобы они не перехватывали выбор.
if not self._imported_models_enabled and self._is_imported_model_entry(entry):
continue
min_x, max_x, min_y, max_y = self._rack_pick_bbox(entry)
if min_x <= x <= max_x and min_y <= y <= max_y:
return str(entry.get("rack_id", ""))
return None
def _rack_pick_bbox(self: "ModelViewWidget", entry: dict[str, Any]) -> tuple[float, float, float, float]:
"""Полный bbox габаритов для выбора (включая стойки/рёбра)."""
center = entry.get("center") or (0.0, 0.0)
params = dict(entry.get("params") or {})
rotation = int(entry.get("rotation", 0)) % 360
return entry.get("bbox") or rack_bbox(
float(center[0]), float(center[1]), params, rotation,
)
def _rack_container_bbox(self: "ModelViewWidget", entry: dict[str, Any]) -> tuple[float, float, float, float]:
"""Внешние XY-границы геометрии стеллажа (без вспомогательных проходов/буферов)."""
actors = list(entry.get("actors") or [])
primary_actor = actors[0] if actors else None
if primary_actor is not None:
try:
b = primary_actor.GetBounds()
if b and len(b) == 6:
min_x, max_x, min_y, max_y = float(b[0]), float(b[1]), float(b[2]), float(b[3])
if min_x < max_x and min_y < max_y:
return (min_x, max_x, min_y, max_y)
except Exception as _exc:
log_exception(__name__, "_rack_container_bbox", _exc)
# Резервный вариант: параметрическая аппроксимация при недоступных границах актёра.
center = entry.get("center") or (0.0, 0.0)
params = dict(entry.get("params") or {})
rotation = int(entry.get("rotation", 0)) % 360
if self._has_additional_model(params):
return entry.get("bbox") or rack_bbox(float(center[0]), float(center[1]), params, rotation)
cx = float(center[0])
cy = float(center[1])
if self._is_pallet_params(params):
width = max(10.0, float(params.get("footprint_width_mm", self._PALLET_FOOTPRINT_WIDTH_SINGLE_MM)))
depth = max(10.0, float(params.get("footprint_depth_mm", self._PALLET_DEPTH_MM)))
else:
width = max(10.0, float(params.get("footprint_width_mm", 1000)) - 60.0)
depth = max(10.0, float(params.get("footprint_depth_mm", 500)) - 60.0)
if int(rotation) % 180 == 90:
width, depth = depth, width
return (
cx - width / 2.0,
cx + width / 2.0,
cy - depth / 2.0,
cy + depth / 2.0,
)
def _build_rack_hover_contour_mesh(self: "ModelViewWidget", entry: dict[str, Any]):
if not _PV:
return None
bbox = self._rack_container_bbox(entry)
min_x, max_x, min_y, max_y = bbox
bottom_z = None
top_z = None
actors = list(entry.get("actors") or [])
primary_actor = actors[0] if actors else None
if primary_actor is not None:
try:
b = primary_actor.GetBounds()
if b and len(b) == 6:
min_z, max_z = float(b[4]), float(b[5])
if min_z < max_z:
bottom_z = min_z + 2.0
top_z = max_z + 2.0
except Exception as _exc:
log_exception(__name__, "_build_rack_hover_contour_mesh", _exc)
if bottom_z is None or top_z is None:
params = dict(entry.get("params") or {})
z_ref = float(self._zone_heights.get(str(entry.get("zone_id") or ""), (0.0, 0.0))[0])
bottom_z = float(z_ref + 2.0)
top_z = float(z_ref + max(10.0, self._rack_height_mm(params)) + 2.0)
points = [
# Нижний прямоугольник.
(float(min_x), float(min_y), bottom_z), # 0
(float(max_x), float(min_y), bottom_z), # 1
(float(max_x), float(max_y), bottom_z), # 2
(float(min_x), float(max_y), bottom_z), # 3
# Верхний прямоугольник.
(float(min_x), float(min_y), top_z), # 4
(float(max_x), float(min_y), top_z), # 5
(float(max_x), float(max_y), top_z), # 6
(float(min_x), float(max_y), top_z), # 7
]
line_cells = []
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), # вертикали
)
for i, j in edges:
line_cells.extend([2, i, j])
contour = pv.PolyData(points)
contour.lines = line_cells
return contour
def _highlight_hover_rack(self: "ModelViewWidget", rack_id: str) -> None:
rid = str(rack_id or "")
if not rid:
self._clear_hover_rack_highlight()
return
if rid == str(self._hover_rack_id or "") and self._hover_rack_contour_actor is not None:
return
self._clear_hover_rack_highlight(render=False)
entry = None
for candidate in self._rack_entries:
if str(candidate.get("rack_id", "")) == rid:
entry = candidate
break
if entry is None or not self._plotter:
return
contour = self._build_rack_hover_contour_mesh(entry)
if contour is None:
return
try:
self._hover_rack_contour_actor = self._plotter.add_mesh(
contour,
color=(1.0, 1.0, 0.3),
line_width=3.0,
name="_hover_rack_contour",
pickable=False,
)
self._hover_rack_id = rid
except Exception as exc:
log_exception(__name__, "_highlight_hover_rack", exc)
self._hover_rack_contour_actor = None
self._hover_rack_id = None
return
if hasattr(self, "_safe_render"):
self._safe_render(min_interval_s=1.0 / 60.0)
elif self._plotter:
self._plotter.update()
def _clear_hover_rack_highlight(self: "ModelViewWidget", render: bool = True) -> None:
actor = getattr(self, "_hover_rack_contour_actor", None)
if actor is not None and self._plotter is not None:
try:
self._plotter.remove_actor(actor)
except Exception as _exc:
log_exception(__name__, "_clear_hover_rack_highlight", _exc)
self._hover_rack_contour_actor = None
self._hover_rack_id = None
if render:
if hasattr(self, "_safe_render"):
self._safe_render(min_interval_s=1.0 / 60.0)
elif self._plotter:
self._plotter.update()
# ---------------------------------------------------------------------------
# Module workflow notes
# ---------------------------------------------------------------------------
# 1) Задача модуля:
# Наведение и подсветка стеллажей и ячеек.
#
# 2) Последовательность действий и вызовов:
# A. Класс RackHoverMixin: точки входа
# Публичные методы отсутствуют; сценарий запускается через методы родительских модулей и внутренние обработчики.
#
# B. RackHoverMixin: запуск и настройка:
# RackHoverMixin._set_active_shelf_slot(...)
# Назначение: устанавливает active shelf slot в рамках текущего сценария модуля.
# Последовательность внутренних вызовов:
# -> RackHoverMixin._clear_shelf_slot_active(...)
# -> RackHoverMixin._clear_shelf_slot_hover(...)
#
# C. RackHoverMixin: основной сценарий:
# RackHoverMixin._find_rack_id_by_actor(...)
# Назначение: находит rack id by actor в рамках текущего сценария модуля.
# RackHoverMixin._find_rack_id_by_point(...)
# Назначение: находит rack id by point в рамках текущего сценария модуля.
# Последовательность внутренних вызовов:
# -> RackHoverMixin._rack_pick_bbox(...)
#
# D. RackHoverMixin: завершение и очистка:
# RackHoverMixin._clear_shelf_slot_hover(...)
# Назначение: очищает shelf slot hover в рамках текущего сценария модуля.
# RackHoverMixin._clear_shelf_slot_active(...)
# Назначение: очищает shelf slot active в рамках текущего сценария модуля.
# RackHoverMixin._clear_hover_rack_highlight(...)
# Назначение: очищает hover rack highlight в рамках текущего сценария модуля.
#
# E. RackHoverMixin: вспомогательные расчёты:
# RackHoverMixin._highlight_shelf_slot_hover(...)
# Назначение: подсвечивает shelf slot hover в рамках текущего сценария модуля.
# Последовательность внутренних вызовов:
# -> RackHoverMixin._clear_shelf_slot_hover(...)
# RackHoverMixin._rack_pick_bbox(...)
# Назначение: Полный bbox габаритов для выбора (включая стойки/рёбра).
# RackHoverMixin._rack_container_bbox(...)
# Назначение: Внешние XY-границы геометрии стеллажа (без вспомогательных проходов/буферов).
# RackHoverMixin._build_rack_hover_contour_mesh(...)
# Назначение: строит rack hover contour mesh в рамках текущего сценария модуля.
# Последовательность внутренних вызовов:
# -> RackHoverMixin._rack_container_bbox(...)
# RackHoverMixin._highlight_hover_rack(...)
# Назначение: подсвечивает hover rack в рамках текущего сценария модуля.
# Последовательность внутренних вызовов:
# -> RackHoverMixin._clear_hover_rack_highlight(...)
# -> RackHoverMixin._build_rack_hover_contour_mesh(...)
#
# 3) Важные ограничения и инварианты:
# - Модуль выполняется в составе ModelViewWidget и использует согласованные поля состояния self._... .
# - Все операции с актёрами и камерой выполняются только при валидном self._plotter с безопасной обработкой исключений.
# - Геометрическая визуализация зависит от pyvista/vtk; при недоступности модуль обязан завершать шаг без падения сценария.
# - Межмодульная связность: только через фасад model_view; прямые обращения между zone, rack, shelf, cell запрещены.
# - Очистка состояния должна быть идемпотентной: повторный вызов не меняет корректное состояние в ошибочное.
#
# 4) Правило сопровождения:
# - Любое изменение сценария должно сопровождаться обновлением этого блока с сохранением фактического порядка вызовов.
# - При добавлении метода указывать его место в цепочке сценария (запуск, основной шаг, завершение, вспомогательная логика).

View File

@@ -0,0 +1,252 @@
# -*- coding: utf-8 -*-
# gui/components/model_view/_mv_racks_io.py
"""IO-операции для восстановление/сериализации размещённых стоек."""
from __future__ import annotations
import uuid
from pathlib import Path
from typing import Any, Callable, TYPE_CHECKING
from gui.components.model_view._mv_rack_geometry import rack_bbox
from error_logger import log_exception
if TYPE_CHECKING: # pragma: no cover
from gui.components.model_view_widget import ModelViewWidget
class RackPlacementIOMixin:
"""Mixin IO-операций для RackPlacementMixin."""
def get_zone_rack_layout(self: "ModelViewWidget", zone_id: str) -> list[dict[str, Any]]:
zid = str(zone_id or "")
payload: list[dict[str, Any]] = []
for entry in self._rack_entries:
if entry.get("zone_id") != zid:
continue
params = dict(entry.get("params") or {})
if hasattr(self, "_normalize_rack_params"):
try:
params = self._normalize_rack_params(
params,
fallback_code=str(entry.get("code") or params.get("code") or "A"),
)
except Exception as _exc:
log_exception(__name__, "get_zone_rack_layout", _exc)
center = entry.get("center") or (0.0, 0.0)
payload.append(
{
"rack_id": str(entry.get("rack_id") or str(uuid.uuid4())),
"code": str(entry.get("code") or params.get("code") or "A"),
"name": str(entry.get("name") or "Rack"),
"created_ts": str(entry.get("created_ts") or ""),
"rack_type": str(params.get("rack_type", "A")),
"depth_mm": int(params.get("depth_mm", 500)),
"spans_count": int(params.get("spans_count", 1)),
"numbering_direction": str(
params.get("numbering_direction")
or entry.get("numbering_direction")
or "left_to_right"
),
"span_codes": [
str(v)
for v in (
params.get("span_codes")
or entry.get("span_codes")
or []
)
],
"uniform_width": bool(params.get("uniform_width", True)),
"span_widths_mm": [int(v) for v in (params.get("span_widths_mm") or [1000])],
"center_spans_mm": [int(v) for v in (params.get("center_spans_mm") or [1000])],
"footprint_width_mm": int(params.get("footprint_width_mm", 1000)),
"footprint_depth_mm": int(params.get("footprint_depth_mm", 500)),
"footprint_height_mm": int(params.get("footprint_height_mm", 1800)),
"pillars_count": int(params.get("pillars_count", 4)),
"center_x": float(center[0]),
"center_y": float(center[1]),
"rotation_deg": int(entry.get("rotation", 0)),
"hidden_slot_ids": sorted(str(v) for v in (entry.get("hidden_slot_ids") or set())),
"shelf_helpers_hidden": bool(entry.get("shelf_helpers_hidden", False)),
"display_color": str(params.get("display_color") or ""),
"display_opacity": float(params.get("display_opacity", 0.9)),
"additional_model": dict(params.get("additional_model") or {}),
}
)
return payload
def load_zone_rack_layout(
self: "ModelViewWidget",
zone_id: str,
racks: list[dict[str, Any]],
models_root: Path | None = None,
progress_callback: Callable[[int, int], None] | None = None,
) -> None:
zid = str(zone_id or "")
self._rack_models_root = Path(models_root) if models_root else self._rack_models_root
self._remove_zone_racks(zid)
rack_items = list(racks or [])
total_items = len(rack_items)
for item_idx, item in enumerate(rack_items, start=1):
try:
if "center_x" not in item or "center_y" not in item:
continue
params = {
"code": str(item.get("code") or "A"),
"rack_type": str(item.get("rack_type", "A")),
"depth_mm": int(item.get("depth_mm", 500)),
"spans_count": int(item.get("spans_count", 1)),
"numbering_direction": str(item.get("numbering_direction") or "left_to_right"),
"span_codes": [str(v) for v in (item.get("span_codes") or [])],
"uniform_width": bool(item.get("uniform_width", True)),
"span_widths_mm": [int(v) for v in (item.get("span_widths_mm") or [1000])],
"center_spans_mm": [int(v) for v in (item.get("center_spans_mm") or [1000])],
"footprint_width_mm": int(item.get("footprint_width_mm", 1000)),
"footprint_depth_mm": int(item.get("footprint_depth_mm", 500)),
"footprint_height_mm": int(item.get("footprint_height_mm", 1800)),
"pillars_count": int(item.get("pillars_count", 4)),
"display_color": str(item.get("display_color") or ""),
"display_opacity": float(item.get("display_opacity", 0.9)),
"additional_model": dict(item.get("additional_model") or {}),
}
if hasattr(self, "_normalize_rack_params"):
try:
params = self._normalize_rack_params(
params,
fallback_code=str(item.get("code") or "A"),
)
except Exception as _exc:
log_exception(__name__, "load_zone_rack_layout", _exc)
cx = float(item.get("center_x", 0.0))
cy = float(item.get("center_y", 0.0))
rotation = int(item.get("rotation_deg", 0)) % 360
color, opacity = self._resolve_rack_style(params)
actors, slot_actors = self._spawn_rack_actors(cx, cy, zid, params, rotation, color, opacity)
if not actors:
continue
hidden_slot_ids = {str(v) for v in (item.get("hidden_slot_ids") or [])}
for slot_id in hidden_slot_ids:
actor = slot_actors.get(slot_id)
if actor is not None:
try:
actor.SetVisibility(0)
except Exception as _exc:
log_exception(__name__, "load_zone_rack_layout", _exc)
self._rack_entries.append(
{
"rack_id": str(item.get("rack_id") or str(uuid.uuid4())),
"zone_id": zid,
"center": (cx, cy),
"rotation": rotation,
"bbox": rack_bbox(cx, cy, params, rotation),
"params": params,
"actors": actors,
"slot_actors": slot_actors,
"hidden_slot_ids": hidden_slot_ids,
"shelf_helpers_hidden": bool(item.get("shelf_helpers_hidden", False)),
"code": str(item.get("code") or "A"),
"name": str(item.get("name") or "Rack"),
"created_ts": str(item.get("created_ts") or ""),
"numbering_direction": str(item.get("numbering_direction") or params.get("numbering_direction") or "left_to_right"),
"span_codes": [str(v) for v in (item.get("span_codes") or params.get("span_codes") or [])],
}
)
self._apply_rack_helper_visibility_policy(self._rack_entries[-1], True)
finally:
if progress_callback is not None:
try:
progress_callback(item_idx, total_items)
except Exception as _exc:
log_exception(__name__, "load_zone_rack_layout", _exc)
if self._plotter:
self._plotter.update()
def _remove_zone_racks(self: "ModelViewWidget", zone_id: str) -> None:
removed_rack_ids: set[str] = set()
remaining: list[dict[str, Any]] = []
for entry in self._rack_entries:
if entry.get("zone_id") != zone_id:
remaining.append(entry)
continue
rack_id = str(entry.get("rack_id") or "")
if rack_id:
removed_rack_ids.add(rack_id)
for actor in entry.get("actors", []):
try:
self._plotter.remove_actor(actor)
except Exception as _exc:
log_exception(__name__, "_remove_zone_racks", _exc)
if rack_id:
self._clear_slot_shelf_actors(rack_id, clear_slot_id=None)
self._rack_entries = remaining
if not removed_rack_ids:
return
try:
if str(getattr(self, "_selected_rack_id", "") or "") in removed_rack_ids:
self._set_selected_rack(None)
except Exception as _exc:
log_exception(__name__, "_remove_zone_racks", _exc)
try:
if str(getattr(self, "_hover_rack_id", "") or "") in removed_rack_ids:
self._clear_hover_rack_highlight()
except Exception as _exc:
log_exception(__name__, "_remove_zone_racks", _exc)
try:
if str(getattr(self, "_rack_isolation_rack_id", "") or "") in removed_rack_ids:
self._rack_isolation_active = False
self._rack_isolation_rack_id = None
self._clear_rack_isolation_visual()
except Exception as _exc:
log_exception(__name__, "_remove_zone_racks", _exc)
try:
if str(getattr(self, "_shelf_target_rack_id", "") or "") in removed_rack_ids:
self.stop_shelf_placement_ext(clear_selection=True, clear_rendered_shelves=True)
except Exception as _exc:
log_exception(__name__, "_remove_zone_racks", _exc)
try:
if str(getattr(self, "_selected_shelf_bbox_rack_id", "") or "") in removed_rack_ids:
self._clear_selected_rendered_shelf(render=False)
except Exception as _exc:
log_exception(__name__, "_remove_zone_racks", _exc)
try:
if hasattr(self, "clear_cell_grid_visualization"):
for rid in removed_rack_ids:
self.clear_cell_grid_visualization(rid)
except Exception as _exc:
log_exception(__name__, "_remove_zone_racks", _exc)
# ---------------------------------------------------------------------------
# Module workflow notes
# ---------------------------------------------------------------------------
# 1) Задача модуля:
# Сериализация и загрузка раскладок стеллажей по зонам.
#
# 2) Последовательность действий и вызовов:
# A. Класс RackPlacementIOMixin: точки входа
# Публичные методы сценария:
# - RackPlacementIOMixin.get_zone_rack_layout(...)
# - RackPlacementIOMixin.load_zone_rack_layout(...)
#
# B. RackPlacementIOMixin: запуск и настройка:
# RackPlacementIOMixin.load_zone_rack_layout(...)
# Назначение: загружает zone rack layout в рамках текущего сценария модуля.
# Последовательность внутренних вызовов:
# -> RackPlacementIOMixin._remove_zone_racks(...)
#
# C. RackPlacementIOMixin: завершение и очистка:
# RackPlacementIOMixin._remove_zone_racks(...)
# Назначение: удаляет zone racks в рамках текущего сценария модуля.
#
# D. RackPlacementIOMixin: вспомогательные расчёты:
# RackPlacementIOMixin.get_zone_rack_layout(...)
# Назначение: возвращает zone rack layout в рамках текущего сценария модуля.
#
# 3) Важные ограничения и инварианты:
# - Модуль выполняется в составе ModelViewWidget и использует согласованные поля состояния self._... .
# - Все операции с актёрами и камерой выполняются только при валидном self._plotter с безопасной обработкой исключений.
# - Межмодульная связность: только через фасад model_view; прямые обращения между zone, rack, shelf, cell запрещены.
# - Очистка состояния должна быть идемпотентной: повторный вызов не меняет корректное состояние в ошибочное.
#
# 4) Правило сопровождения:
# - Любое изменение сценария должно сопровождаться обновлением этого блока с сохранением фактического порядка вызовов.
# - При добавлении метода указывать его место в цепочке сценария (запуск, основной шаг, завершение, вспомогательная логика).

View File

@@ -0,0 +1,334 @@
# -*- coding: utf-8 -*-
"""Миксин жизненного цикла стоек инициализация, начало/остановка размещения,
параметры предпросмотра, генерация кодов и вспомогательные функции поворота,
извлечённые из _mv_racks.py."""
from __future__ import annotations
import uuid
import re
from pathlib import Path
from typing import Any, TYPE_CHECKING
from PySide6.QtCore import QTimer
try:
import pyvista as pv
_PV = True
except ImportError:
_PV = False
from gui.components.model_view._mv_rack_geometry import rack_bbox, rotate_xy
from error_logger import log_exception
if TYPE_CHECKING: # pragma: no cover
from gui.components.model_view_widget import ModelViewWidget
def _ray_aabb_intersect(
origin: tuple[float, float, float],
dx: float, dy: float, dz: float,
min_x: float, max_x: float,
min_y: float, max_y: float,
min_z: float, max_z: float,
) -> float | None:
"""Пересечение луча с AABB методом слэбов. Возвращает параметрическое *t* или None."""
t_min = 0.0
t_max = 1e30
for o, d, lo, hi in (
(origin[0], dx, min_x, max_x),
(origin[1], dy, min_y, max_y),
(origin[2], dz, min_z, max_z),
):
if abs(d) < 1e-12:
if o < lo or o > hi:
return None
else:
inv_d = 1.0 / d
t1 = (lo - o) * inv_d
t2 = (hi - o) * inv_d
if t1 > t2:
t1, t2 = t2, t1
t_min = max(t_min, t1)
t_max = min(t_max, t2)
if t_min > t_max:
return None
return t_min
class RackLifecycleMixin:
"""Миксин с логикой жизненного цикла стоек инициализация, начало/остановка
размещения, обновление параметров предпросмотра, генерация кодов и поворот."""
def init_rack_placement(self: "ModelViewWidget") -> None:
self._rack_entries: list[dict[str, Any]] = []
self._rack_preview_actors: list = []
self._rack_move_mode = False
self._rack_preview_valid = False
self._rack_preview_center: tuple[float, float] | None = None
self._rack_preview_shape_key: tuple[Any, ...] | None = None
self._rack_preview_rotation = 0
self._rack_preview_params: dict[str, Any] = {}
self._rack_preview_zone_id: str | None = None
self._rack_models_root: Path | None = None
self._rack_select_mode = False
self._selected_rack_id: str | None = None
self._selected_rack_visual_id: str | None = None
self._selected_rack_visual_props: list[tuple[object, float, bool, float, tuple[float, float, float]]] = []
self._rack_isolation_active = False
self._rack_isolation_rack_id: str | None = None
self._rack_isolation_visual_props: list[tuple[object, int, float, bool, float, tuple[float, float, float]]] = []
self._hover_rack_id: str | None = None
self._hover_rack_contour_actor = None
self._selected_rack_contour_actor = None
self._moving_rack_entry: dict[str, Any] | None = None
self._shelf_target_zone_id: str | None = None
self._shelf_target_rack_id: str | None = None
self._hover_shelf_slot_id: str | None = None
self._active_shelf_slot_id: str | None = None
self._hover_shelf_slot_actor = None
self._active_shelf_slot_actor = None
self._rack_shelf_params: dict[tuple[str, str], dict[str, Any]] = {}
self._rack_shelf_actors: dict[tuple[str, str], list[Any]] = {}
self._rack_shelf_cell_links: dict[tuple[str, str], bool] = {}
self._rack_shelf_cell_links_indexed: dict[tuple[str, str, int], bool] = {}
self._cell_preview_actors: dict[tuple[str, str], list[Any]] = {}
self._cell_final_actors: dict[tuple[str, str], list[Any]] = {}
self._cell_dim_actors: dict[tuple[str, str], list[Any]] = {}
self._cell_ref_bounds: dict[tuple, tuple] = {}
self._cell_grid_edit_isolation_active: bool = False
self._cell_grid_edit_rack_id: str | None = None
self._cell_grid_edit_slot_id: str | None = None
self._cell_grid_edit_shelf_index: int | None = None
self._selected_shelf_visual_rack_id: str | None = None
self._selected_shelf_visual_slot_id: str | None = None
self._selected_shelf_visual_index: int | None = None
self._selected_shelf_bbox_actor = None
self._selected_shelf_bbox_rack_id: str | None = None
self._selected_shelf_bbox_slot_id: str | None = None
self._selected_shelf_bbox_index: int | None = None
self._selected_floor_shelf_actor = None
self._selected_floor_shelf_rack_id: str | None = None
self._selected_floor_shelf_slot_id: str | None = None
self._shelf_mesh_cache: dict[tuple[str, int, int], Any] = {}
self._pillar_mesh_cache: dict[tuple[str, int], tuple[Any, float]] = {}
self._additional_model_mesh_cache: dict[str, tuple[Any, float, float, float]] = {}
self._camera_anim_timer: QTimer | None = None
self._camera_anim_seq = 0
self._last_moved_rack_id: str | None = None
self._imported_models_enabled = True
try:
self.rack_slot_visibility_changed.connect(self._on_rack_slot_visibility_changed)
except Exception as _exc:
log_exception(__name__, "init_rack_placement", _exc)
def start_rack_placement(
self: "ModelViewWidget",
zone_id: str,
params: dict[str, Any],
models_root: Path | None = None,
move_existing: bool = False,
) -> None:
if not self._plotter or not self._models_loaded:
return
self._rack_move_mode = bool(move_existing)
self._rack_preview_zone_id = zone_id
self._rack_models_root = Path(models_root) if models_root else None
if self._rack_move_mode and self._moving_rack_entry is not None:
self._rack_preview_rotation = int(dict(self._moving_rack_entry).get("rotation", 0)) % 360
else:
self._rack_preview_rotation = 0
fallback_code = None
if self._moving_rack_entry is None:
fallback_code = self._next_zone_rack_code(zone_id, dict(params or {}))
else:
fallback_code = str(dict(self._moving_rack_entry).get("code") or dict(params or {}).get("code") or "A")
self.update_rack_preview_params(
self._normalize_rack_params(dict(params or {}), fallback_code=fallback_code)
)
self._clear_hover_rack_highlight()
self.show_only_zone_racks(zone_id)
# Сценарий взаимодействия
mgr = getattr(self, "_interaction_manager", None)
if mgr is not None:
mgr.pop_by_name("rack_select")
from gui.components.model_view._scenario_custom_handler import CustomHandlerScenario
from PySide6.QtCore import Qt as _Qt
mgr.push(CustomHandlerScenario(
name="rack_placement",
click_handler=self._on_rack_click,
hover_screen_handler=self._on_rack_hover_screen,
hotkeys={
_Qt.Key.Key_Escape: lambda: self.stop_rack_placement(clear_preview=True),
},
))
# Гарантируем получение KeyPress в interactor (горячая клавиша R).
try:
if self._plotter is not None:
self._plotter.setFocus()
interactor = getattr(self._plotter, "interactor", None)
if interactor is not None:
interactor.setFocus()
except Exception as _exc:
log_exception(__name__, "start_rack_placement", _exc)
def stop_rack_placement(self: "ModelViewWidget", clear_preview: bool = True) -> None:
# Убрать сценарий взаимодействия
mgr = getattr(self, "_interaction_manager", None)
if mgr is not None:
mgr.pop_by_name("rack_placement")
restore_moving = self._moving_rack_entry is not None
self._rack_move_mode = False
self._rack_preview_valid = False
self._rack_preview_center = None
if not self._interaction_manager.is_active("rack_select"):
self._clear_hover_rack_highlight()
if clear_preview:
self._clear_rack_preview()
if restore_moving:
self._restore_moving_rack_entry()
if self._plotter:
self._plotter.update()
def update_rack_preview_params(self: "ModelViewWidget", params: dict[str, Any]) -> None:
fallback_code = str(dict(self._rack_preview_params or {}).get("code") or "A")
if self._moving_rack_entry is not None:
fallback_code = str(dict(self._moving_rack_entry).get("code") or fallback_code or "A")
elif self._rack_preview_zone_id:
fallback_code = str(
self._next_zone_rack_code(
str(self._rack_preview_zone_id),
dict(params or {}),
)
or fallback_code
or "A"
)
self._rack_preview_params = self._normalize_rack_params(
dict(params or {}),
fallback_code=fallback_code,
)
if self._rack_preview_center is not None:
cx, cy = self._rack_preview_center
self._rebuild_rack_preview(cx, cy)
def get_next_rack_code(self: "ModelViewWidget", zone_id: str, params: dict[str, Any] | None = None) -> str:
return self._next_zone_rack_code(zone_id, params)
def rotate_rack_preview(self: "ModelViewWidget") -> None:
if self._selected_rack_id and self._moving_rack_entry is None:
if self.rotate_selected_rack(90):
return
self._rack_preview_rotation = (self._rack_preview_rotation + 90) % 360
if self._rack_preview_center is not None:
cx, cy = self._rack_preview_center
self._rebuild_rack_preview(cx, cy)
def rotate_selected_rack(self: "ModelViewWidget", delta_deg: int) -> bool:
rid = str(self._selected_rack_id or "")
if not rid:
return False
for idx, entry in enumerate(self._rack_entries):
if str(entry.get("rack_id", "")) != rid:
continue
center = entry.get("center") or (0.0, 0.0)
params = dict(entry.get("params") or {})
zone_id = str(entry.get("zone_id") or "")
new_rotation = (int(entry.get("rotation", 0)) + int(delta_deg)) % 360
if not self._validate_rack_position(
float(center[0]),
float(center[1]),
zone_id,
params,
new_rotation,
ignore_rack_id=rid,
):
return False
for actor in entry.get("actors", []):
try:
self._plotter.remove_actor(actor)
except Exception as _exc:
log_exception(__name__, "rotate_selected_rack", _exc)
color, opacity = self._resolve_rack_style(params)
actors, slot_actors = self._spawn_rack_actors(
float(center[0]), float(center[1]), zone_id, params, new_rotation, color, opacity,
)
if not actors:
return False
hidden_slot_ids = set(str(v) for v in (entry.get("hidden_slot_ids") or set()))
for sid in hidden_slot_ids:
actor = slot_actors.get(sid)
if actor is not None:
try:
actor.SetVisibility(0)
except Exception as _exc:
log_exception(__name__, "rotate_selected_rack", _exc)
entry["rotation"] = new_rotation
entry["bbox"] = rack_bbox(float(center[0]), float(center[1]), params, new_rotation)
entry["actors"] = actors
entry["slot_actors"] = slot_actors
entry["hidden_slot_ids"] = hidden_slot_ids
self._apply_rack_helper_visibility_policy(entry, True)
self._rack_entries[idx] = entry
if self._plotter:
self._plotter.update()
self.rack_layout_changed.emit(zone_id)
self._set_selected_rack(rid)
return True
return False
# ---------------------------------------------------------------------------
# Module workflow notes
# ---------------------------------------------------------------------------
# 1) Задача модуля:
# Жизненный цикл размещения стеллажа: запуск, обновление, остановка, поворот.
#
# 2) Последовательность действий и вызовов:
# A. Функции уровня модуля:
# _ray_aabb_intersect(...)
# Назначение: Пересечение луча с AABB методом слэбов. Возвращает параметрическое *t* или None.
#
# B. Класс RackLifecycleMixin: точки входа
# Публичные методы сценария:
# - RackLifecycleMixin.init_rack_placement(...)
# - RackLifecycleMixin.start_rack_placement(...)
# - RackLifecycleMixin.stop_rack_placement(...)
# - RackLifecycleMixin.update_rack_preview_params(...)
# - RackLifecycleMixin.get_next_rack_code(...)
# - RackLifecycleMixin.rotate_rack_preview(...)
# - RackLifecycleMixin.rotate_selected_rack(...)
#
# C. RackLifecycleMixin: запуск и настройка:
# RackLifecycleMixin.init_rack_placement(...)
# Назначение: инициализирует rack placement в рамках текущего сценария модуля.
# RackLifecycleMixin.start_rack_placement(...)
# Назначение: запускает rack placement в рамках текущего сценария модуля.
# Последовательность внутренних вызовов:
# -> RackLifecycleMixin.update_rack_preview_params(...)
#
# D. RackLifecycleMixin: основной сценарий:
# RackLifecycleMixin.update_rack_preview_params(...)
# Назначение: обновляет rack preview params в рамках текущего сценария модуля.
#
# E. RackLifecycleMixin: завершение и очистка:
# RackLifecycleMixin.stop_rack_placement(...)
# Назначение: останавливает rack placement в рамках текущего сценария модуля.
#
# F. RackLifecycleMixin: вспомогательные расчёты:
# RackLifecycleMixin.get_next_rack_code(...)
# Назначение: возвращает next rack code в рамках текущего сценария модуля.
# RackLifecycleMixin.rotate_rack_preview(...)
# Назначение: выполняет шаг "rotate rack preview" в рамках текущего сценария модуля.
# Последовательность внутренних вызовов:
# -> RackLifecycleMixin.rotate_selected_rack(...)
# RackLifecycleMixin.rotate_selected_rack(...)
# Назначение: выполняет шаг "rotate selected rack" в рамках текущего сценария модуля.
#
# 3) Важные ограничения и инварианты:
# - Модуль выполняется в составе ModelViewWidget и использует согласованные поля состояния self._... .
# - Все операции с актёрами и камерой выполняются только при валидном self._plotter с безопасной обработкой исключений.
# - Геометрическая визуализация зависит от pyvista/vtk; при недоступности модуль обязан завершать шаг без падения сценария.
# - Межмодульная связность: только через фасад model_view; прямые обращения между zone, rack, shelf, cell запрещены.
# - Очистка состояния должна быть идемпотентной: повторный вызов не меняет корректное состояние в ошибочное.
#
# 4) Правило сопровождения:
# - Любое изменение сценария должно сопровождаться обновлением этого блока с сохранением фактического порядка вызовов.
# - При добавлении метода указывать его место в цепочке сценария (запуск, основной шаг, завершение, вспомогательная логика).

View File

@@ -0,0 +1,164 @@
# -*- coding: utf-8 -*-
"""Миксин вспомогательных функций стоек для мезонинов.
Обеспечивает обнаружение опор колонн, проверку зазоров и логику
совместного использования XY для мезонинных конструкций в *ModelViewWidget*.
"""
from __future__ import annotations
from pathlib import Path
from typing import Any, TYPE_CHECKING
from error_logger import log_exception
from gui.components.model_view._mv_rack_geometry import rotate_xy
if TYPE_CHECKING:
from gui.components.model_view_widget import ModelViewWidget
class RackMezzanineMixin:
"""Вспомогательные функции мезонинных стоек, подмешиваемые в *ModelViewWidget*."""
# ------------------------------------------------------------------
# Вспомогательные функции мезонинов
# ------------------------------------------------------------------
@staticmethod
def _is_mezzanine_params(params: dict[str, Any]) -> bool:
additional_model = dict(params.get("additional_model") or {})
model_path = additional_model.get("model_path")
if not model_path:
return False
explicit = additional_model.get("is_mezzanine")
if isinstance(explicit, bool):
return explicit
model_kind = str(additional_model.get("model_kind") or "").strip().lower()
if model_kind in {"mezo", "mezz", "mezzanine"}:
return True
haystack = " ".join(
str(v)
for v in (
additional_model.get("model_name"),
additional_model.get("model_path"),
params.get("rack_type"),
)
if v
).lower()
return any(tag in haystack for tag in ("mezo", "mezz", "Р Сез", "mezan", "mezon"))
def _mezzanine_clearance_mm(self: "ModelViewWidget", params: dict[str, Any]) -> float:
additional_model = dict(params.get("additional_model") or {})
value = additional_model.get("clearance_height_mm", params.get("footprint_height_mm", 0.0))
try:
return max(0.0, float(value))
except Exception as exc:
log_exception(__name__, "_mezzanine_clearance_mm", exc)
return 0.0
def _ensure_model_footings(self: "ModelViewWidget", params: dict[str, Any]) -> None:
additional_model = params.setdefault("additional_model", {})
model_path = additional_model.get("model_path")
if not model_path or additional_model.get("column_footings"):
return
base_mesh, _w, _d, _h = self._get_additional_model_base_mesh(Path(model_path))
if base_mesh is None:
return
footings, clearance_h = self._detect_column_footings(base_mesh)
if footings:
additional_model["column_footings"] = footings
if clearance_h > 0 and not additional_model.get("clearance_height_mm"):
additional_model["clearance_height_mm"] = float(clearance_h)
def _mezzanine_column_bboxes(
self: "ModelViewWidget",
cx: float,
cy: float,
params: dict[str, Any],
rotation: int,
) -> list[tuple[float, float, float, float]]:
if not self._is_mezzanine_params(params):
return []
self._ensure_model_footings(params)
additional_model = dict(params.get("additional_model") or {})
footings = list(additional_model.get("column_footings") or [])
result: list[tuple[float, float, float, float]] = []
for ft in footings:
fx_min = float(ft.get("x_min", 0.0))
fy_min = float(ft.get("y_min", 0.0))
fx_max = float(ft.get("x_max", 0.0))
fy_max = float(ft.get("y_max", 0.0))
width = max(0.0, fx_max - fx_min)
depth = max(0.0, fy_max - fy_min)
if width <= 0.0 or depth <= 0.0:
continue
local_cx = (fx_min + fx_max) / 2.0
local_cy = (fy_min + fy_max) / 2.0
world_dx, world_dy = rotate_xy(local_cx, local_cy, rotation)
if int(rotation) % 180 == 90:
width, depth = depth, width
result.append(
(
cx + world_dx - width / 2.0,
cx + world_dx + width / 2.0,
cy + world_dy - depth / 2.0,
cy + world_dy + depth / 2.0,
)
)
return result
def _can_share_xy_with_mezzanine(
self: "ModelViewWidget",
candidate_params: dict[str, Any],
existing_params: dict[str, Any],
) -> bool:
cand_is_mezzanine = self._is_mezzanine_params(candidate_params)
existing_is_mezzanine = self._is_mezzanine_params(existing_params)
if cand_is_mezzanine and existing_is_mezzanine:
return False
if existing_is_mezzanine:
clearance = self._mezzanine_clearance_mm(existing_params)
return clearance > 0.0 and self._rack_height_mm(candidate_params) <= clearance
if cand_is_mezzanine:
clearance = self._mezzanine_clearance_mm(candidate_params)
return clearance > 0.0 and self._rack_height_mm(existing_params) <= clearance
return False
# ---------------------------------------------------------------------------
# Module workflow notes
# ---------------------------------------------------------------------------
# 1) Задача модуля:
# Правила размещения мезонинов и проверки совместимости по геометрии.
#
# 2) Последовательность действий и вызовов:
# A. Класс RackMezzanineMixin: точки входа
# Публичные методы отсутствуют; сценарий запускается через методы родительских модулей и внутренние обработчики.
#
# B. RackMezzanineMixin: основной сценарий:
# RackMezzanineMixin._is_mezzanine_params(...)
# Назначение: проверяет, что mezzanine params в рамках текущего сценария модуля.
# RackMezzanineMixin._can_share_xy_with_mezzanine(...)
# Назначение: проверяет возможность share xy with mezzanine в рамках текущего сценария модуля.
# Последовательность внутренних вызовов:
# -> RackMezzanineMixin._is_mezzanine_params(...)
# -> RackMezzanineMixin._mezzanine_clearance_mm(...)
#
# C. RackMezzanineMixin: вспомогательные расчёты:
# RackMezzanineMixin._mezzanine_clearance_mm(...)
# Назначение: выполняет шаг "mezzanine clearance mm" в рамках текущего сценария модуля.
# RackMezzanineMixin._ensure_model_footings(...)
# Назначение: выполняет шаг "ensure model footings" в рамках текущего сценария модуля.
# RackMezzanineMixin._mezzanine_column_bboxes(...)
# Назначение: выполняет шаг "mezzanine column bboxes" в рамках текущего сценария модуля.
# Последовательность внутренних вызовов:
# -> RackMezzanineMixin._ensure_model_footings(...)
# -> RackMezzanineMixin._is_mezzanine_params(...)
#
# 3) Важные ограничения и инварианты:
# - Модуль выполняется в составе ModelViewWidget и использует согласованные поля состояния self._... .
# - Межмодульная связность: только через фасад model_view; прямые обращения между zone, rack, shelf, cell запрещены.
# - Очистка состояния должна быть идемпотентной: повторный вызов не меняет корректное состояние в ошибочное.
#
# 4) Правило сопровождения:
# - Любое изменение сценария должно сопровождаться обновлением этого блока с сохранением фактического порядка вызовов.
# - При добавлении метода указывать его место в цепочке сценария (запуск, основной шаг, завершение, вспомогательная логика).

View File

@@ -0,0 +1,320 @@
# -*- coding: utf-8 -*-
"""Миксин перемещения/переноса стоек для ModelViewWidget."""
from __future__ import annotations
from typing import Any, TYPE_CHECKING
from gui.components.model_view._mv_rack_geometry import rack_bbox
from error_logger import log_exception
if TYPE_CHECKING:
from gui.components.model_view_widget import ModelViewWidget
class RackMoveMixin:
def begin_move_selected_rack(self: "ModelViewWidget") -> bool:
entry = self.get_selected_rack_entry()
if not entry:
return False
self._clear_selected_rack_visual()
self._last_moved_rack_id = None
zone_id = str(entry.get("zone_id", ""))
params = dict(entry.get("params") or {})
# Удалить старые акторы со сцены и переключиться в режим предпросмотра перемещения.
self._moving_rack_entry = dict(entry)
self._set_moving_rack_origin_ghost(self._moving_rack_entry, True)
self._rack_entries = [e for e in self._rack_entries if str(e.get("rack_id", "")) != str(entry.get("rack_id", ""))]
self.start_rack_placement(zone_id, params, self._rack_models_root, move_existing=True)
self._set_selected_rack(str(entry.get("rack_id", "")))
return True
def _set_moving_rack_origin_ghost(self: "ModelViewWidget", entry: dict[str, Any], enabled: bool) -> None:
rid = str(entry.get("rack_id") or "")
if not rid:
return
if enabled:
rack_snapshot: list[tuple[Any, int, float, bool, float, tuple[float, float, float]]] = []
for actor in list(entry.get("actors", []) or []):
if actor is None:
continue
try:
prop = actor.GetProperty()
if prop is None:
continue
edge_color = tuple(prop.GetEdgeColor())
rack_snapshot.append(
(
actor,
int(actor.GetVisibility()),
float(prop.GetOpacity()),
bool(prop.GetEdgeVisibility()),
float(prop.GetLineWidth()),
(float(edge_color[0]), float(edge_color[1]), float(edge_color[2])),
)
)
actor.SetVisibility(0)
except Exception as _exc:
log_exception(__name__, "_set_moving_rack_origin_ghost", _exc)
entry["_origin_rack_visual_snapshot"] = rack_snapshot
shelf_snapshot: list[tuple[Any, int, float]] = []
for (entry_rid, _slot_id), actors in (self._rack_shelf_actors or {}).items():
if str(entry_rid) != rid:
continue
for actor in list(actors or []):
if actor is None:
continue
try:
prop = actor.GetProperty()
if prop is None:
continue
shelf_snapshot.append((actor, int(actor.GetVisibility()), float(prop.GetOpacity())))
actor.SetVisibility(0)
except Exception as _exc:
log_exception(__name__, "_set_moving_rack_origin_ghost", _exc)
entry["_origin_shelf_visual_snapshot"] = shelf_snapshot
return
rack_snapshot = list(entry.get("_origin_rack_visual_snapshot") or [])
for actor, visibility, opacity, edge_vis, line_width, edge_color in rack_snapshot:
try:
prop = actor.GetProperty()
if prop is None:
continue
actor.SetVisibility(int(visibility))
prop.SetOpacity(float(opacity))
prop.SetEdgeVisibility(bool(edge_vis))
prop.SetLineWidth(float(line_width))
prop.SetEdgeColor(float(edge_color[0]), float(edge_color[1]), float(edge_color[2]))
except Exception as _exc:
log_exception(__name__, "_set_moving_rack_origin_ghost", _exc)
entry.pop("_origin_rack_visual_snapshot", None)
shelf_snapshot = list(entry.get("_origin_shelf_visual_snapshot") or [])
for actor, visibility, opacity in shelf_snapshot:
try:
prop = actor.GetProperty()
if prop is None:
continue
actor.SetVisibility(int(visibility))
prop.SetOpacity(float(opacity))
except Exception as _exc:
log_exception(__name__, "_set_moving_rack_origin_ghost", _exc)
entry.pop("_origin_shelf_visual_snapshot", None)
def _restore_moving_rack_entry(self: "ModelViewWidget") -> bool:
"""Восстановить перемещённую стойку на исходную позицию при отмене."""
entry = dict(self._moving_rack_entry or {})
if not entry:
self._moving_rack_entry = None
return False
self._set_moving_rack_origin_ghost(entry, False)
if entry.get("actors"):
center = entry.get("center") or (0.0, 0.0)
params = dict(entry.get("params") or {})
rotation = int(entry.get("rotation", 0)) % 360
entry["bbox"] = rack_bbox(float(center[0]), float(center[1]), params, rotation)
self._apply_rack_helper_visibility_policy(entry, True)
self._rack_entries.append(entry)
self._moving_rack_entry = None
self._set_selected_rack(str(entry.get("rack_id", "")))
if self._plotter:
self._plotter.update()
return True
zone_id = str(entry.get("zone_id", ""))
center = entry.get("center") or (0.0, 0.0)
params = dict(entry.get("params") or {})
rotation = int(entry.get("rotation", 0)) % 360
color, opacity = self._resolve_rack_style(params)
actors, slot_actors = self._spawn_rack_actors(
float(center[0]), float(center[1]), zone_id, params, rotation, color, opacity,
)
if not actors:
self._moving_rack_entry = None
return False
hidden_slot_ids = set(str(v) for v in (entry.get("hidden_slot_ids") or set()))
for sid in hidden_slot_ids:
actor = slot_actors.get(sid)
if actor is not None:
try:
actor.SetVisibility(0)
except Exception as _exc:
log_exception(__name__, "_restore_moving_rack_entry", _exc)
entry["bbox"] = rack_bbox(float(center[0]), float(center[1]), params, rotation)
entry["actors"] = actors
entry["slot_actors"] = slot_actors
entry["hidden_slot_ids"] = hidden_slot_ids
self._apply_rack_helper_visibility_policy(entry, True)
self._rack_entries.append(entry)
self._moving_rack_entry = None
self._set_selected_rack(str(entry.get("rack_id", "")))
return True
def update_selected_rack_params(
self: "ModelViewWidget",
params: dict[str, Any],
anchor_mode: str | None = None,
) -> bool:
if not self.can_update_selected_rack_params(params, anchor_mode=anchor_mode):
return False
entry = self.get_selected_rack_entry()
if not entry:
return False
zone_id = str(entry.get("zone_id", ""))
center = entry.get("center") or (0.0, 0.0)
rotation = int(entry.get("rotation", 0)) % 360
rack_id = str(entry.get("rack_id", ""))
anchor_world = None
if str(anchor_mode or "").strip().lower() == "back_numbering_side":
anchor_world = self._rack_anchor_world_point(
float(center[0]),
float(center[1]),
dict(entry.get("params") or {}),
rotation,
)
normalized_params = self._normalize_rack_params(
dict(params or {}),
fallback_code=str(entry.get("code") or "A"),
)
new_center_x = float(center[0])
new_center_y = float(center[1])
if anchor_world is not None:
new_center_x, new_center_y = self._center_from_anchor_world_point(
float(anchor_world[0]),
float(anchor_world[1]),
normalized_params,
rotation,
)
for actor in entry.get("actors", []):
try:
self._plotter.remove_actor(actor)
except Exception as _exc:
log_exception(__name__, "update_selected_rack_params", _exc)
actors, slot_actors = self._spawn_rack_actors(
new_center_x, new_center_y, zone_id, normalized_params, rotation,
*self._resolve_rack_style(normalized_params),
)
if not actors:
return False
hidden_slot_ids = set(str(v) for v in (entry.get("hidden_slot_ids") or set()))
for sid in hidden_slot_ids:
actor = slot_actors.get(sid)
if actor is not None:
try:
actor.SetVisibility(0)
except Exception as _exc:
log_exception(__name__, "update_selected_rack_params", _exc)
entry["params"] = normalized_params
entry["code"] = str(normalized_params.get("code") or entry.get("code") or "A")
entry["numbering_direction"] = str(
normalized_params.get("numbering_direction")
or entry.get("numbering_direction")
or "left_to_right"
)
entry["span_codes"] = [str(v) for v in (normalized_params.get("span_codes") or entry.get("span_codes") or [])]
entry["center"] = (new_center_x, new_center_y)
entry["bbox"] = rack_bbox(new_center_x, new_center_y, normalized_params, rotation)
entry["actors"] = actors
entry["slot_actors"] = slot_actors
entry["hidden_slot_ids"] = hidden_slot_ids
self._apply_rack_helper_visibility_policy(entry, True)
# Синхронизировать геометрию полок с обновлёнными секциями/глубиной/высотой стойки.
self._rebuild_shelves_for_updated_rack_params(rack_id)
if self._plotter:
self._plotter.update()
self.rack_layout_changed.emit(zone_id)
self._set_selected_rack(rack_id)
return True
def can_update_selected_rack_params(
self: "ModelViewWidget",
params: dict[str, Any],
anchor_mode: str | None = None,
) -> bool:
entry = self.get_selected_rack_entry()
if not entry:
return False
zone_id = str(entry.get("zone_id", ""))
center = entry.get("center") or (0.0, 0.0)
rotation = int(entry.get("rotation", 0)) % 360
rack_id = str(entry.get("rack_id", ""))
anchor_world = None
if str(anchor_mode or "").strip().lower() == "back_numbering_side":
anchor_world = self._rack_anchor_world_point(
float(center[0]),
float(center[1]),
dict(entry.get("params") or {}),
rotation,
)
normalized_params = self._normalize_rack_params(
dict(params or {}),
fallback_code=str(entry.get("code") or "A"),
)
new_center_x = float(center[0])
new_center_y = float(center[1])
if anchor_world is not None:
new_center_x, new_center_y = self._center_from_anchor_world_point(
float(anchor_world[0]),
float(anchor_world[1]),
normalized_params,
rotation,
)
if not self._validate_rack_position(
new_center_x,
new_center_y,
zone_id,
normalized_params,
rotation,
ignore_rack_id=rack_id,
):
return False
return True
# ---------------------------------------------------------------------------
# Module workflow notes
# ---------------------------------------------------------------------------
# 1) Задача модуля:
# Перемещение выбранного стеллажа и пересборка его состояния.
#
# 2) Последовательность действий и вызовов:
# A. Класс RackMoveMixin: точки входа
# Публичные методы сценария:
# - RackMoveMixin.begin_move_selected_rack(...)
# - RackMoveMixin.update_selected_rack_params(...)
# - RackMoveMixin.can_update_selected_rack_params(...)
#
# B. RackMoveMixin: запуск и настройка:
# RackMoveMixin.begin_move_selected_rack(...)
# Назначение: начинает move selected rack в рамках текущего сценария модуля.
# Последовательность внутренних вызовов:
# -> RackMoveMixin._set_moving_rack_origin_ghost(...)
# RackMoveMixin._set_moving_rack_origin_ghost(...)
# Назначение: устанавливает moving rack origin ghost в рамках текущего сценария модуля.
#
# C. RackMoveMixin: основной сценарий:
# RackMoveMixin.update_selected_rack_params(...)
# Назначение: обновляет selected rack params в рамках текущего сценария модуля.
# Последовательность внутренних вызовов:
# -> RackMoveMixin.can_update_selected_rack_params(...)
# RackMoveMixin.can_update_selected_rack_params(...)
# Назначение: проверяет возможность update selected rack params в рамках текущего сценария модуля.
#
# D. RackMoveMixin: завершение и очистка:
# RackMoveMixin._restore_moving_rack_entry(...)
# Назначение: Восстановить перемещённую стойку на исходную позицию при отмене.
# Последовательность внутренних вызовов:
# -> RackMoveMixin._set_moving_rack_origin_ghost(...)
#
# 3) Важные ограничения и инварианты:
# - Модуль выполняется в составе ModelViewWidget и использует согласованные поля состояния self._... .
# - Все операции с актёрами и камерой выполняются только при валидном self._plotter с безопасной обработкой исключений.
# - Межмодульная связность: только через фасад model_view; прямые обращения между zone, rack, shelf, cell запрещены.
# - Очистка состояния должна быть идемпотентной: повторный вызов не меняет корректное состояние в ошибочное.
#
# 4) Правило сопровождения:
# - Любое изменение сценария должно сопровождаться обновлением этого блока с сохранением фактического порядка вызовов.
# - При добавлении метода указывать его место в цепочке сценария (запуск, основной шаг, завершение, вспомогательная логика).

View File

@@ -0,0 +1,505 @@
# -*- coding: utf-8 -*-
"""Логика выбора стоек и ячеек полок (экран → мировое лучевое пересечение,
наведение, клик), извлечённая из монолитного миксина стоек."""
from __future__ import annotations
from pathlib import Path
from typing import Any, TYPE_CHECKING
from gui.components.model_view._mv_racks_lifecycle import _ray_aabb_intersect
from gui.components.model_view._mv_rack_geometry import rotate_xy
from error_logger import log_exception
if TYPE_CHECKING:
from gui.components.model_view_widget import ModelViewWidget
class RackPickingMixin:
"""Вспомогательные функции выбора проверка попадания в стойку / ячейку полки по экранным координатам."""
def _on_select_rack_click(self: "ModelViewWidget", x: float, y: float, z: float) -> bool:
if not self._interaction_manager.is_active("rack_select"):
return False
zone_id = str(self._rack_preview_zone_id or "")
locked_rack_id = str(self._rack_isolation_rack_id or "") if bool(self._rack_isolation_active) else ""
# Предпочитать текущую стойку под курсором для детерминированности ЛКМ.
rack_id = str(self._hover_rack_id or "")
if not rack_id and self._plotter and self._plotter.interactor:
try:
sx, sy = self._plotter.interactor.GetEventPosition()
rack_id = self._find_rack_id_by_screen(float(sx), float(sy), zone_id, screen_is_vtk=True)
except Exception as exc:
log_exception(__name__, "_on_select_rack_click", exc)
rack_id = None
if (not rack_id) and x is not None and y is not None:
rack_id = self._find_rack_id_by_point(float(x), float(y), zone_id)
if locked_rack_id and rack_id and str(rack_id) != locked_rack_id:
return True
if not rack_id:
self._set_selected_rack(None)
self._clear_hover_rack_highlight()
return True
self._set_selected_rack(rack_id)
return True
def _rack_anchor_world_point(
self: "ModelViewWidget",
center_x: float,
center_y: float,
params: dict[str, Any],
rotation: int,
) -> tuple[float, float]:
width = float(params.get("footprint_width_mm", 1000))
depth = float(params.get("footprint_depth_mm", 500))
numbering_direction = str(params.get("numbering_direction") or "left_to_right")
start_from_right = numbering_direction == "right_to_left"
local_x = (width / 2.0) if start_from_right else (-width / 2.0)
local_y = -depth / 2.0 # задняя сторона (передняя — +Y в локальных координатах стойки)
dx, dy = rotate_xy(local_x, local_y, rotation)
return (float(center_x) + float(dx), float(center_y) + float(dy))
def _center_from_anchor_world_point(
self: "ModelViewWidget",
anchor_x: float,
anchor_y: float,
params: dict[str, Any],
rotation: int,
) -> tuple[float, float]:
width = float(params.get("footprint_width_mm", 1000))
depth = float(params.get("footprint_depth_mm", 500))
numbering_direction = str(params.get("numbering_direction") or "left_to_right")
start_from_right = numbering_direction == "right_to_left"
local_x = (width / 2.0) if start_from_right else (-width / 2.0)
local_y = -depth / 2.0
dx, dy = rotate_xy(local_x, local_y, rotation)
return (float(anchor_x) - float(dx), float(anchor_y) - float(dy))
def _on_select_rack_hover_screen(self: "ModelViewWidget", sx: float, sy: float) -> None:
if not self._interaction_manager.is_active("rack_select"):
self._clear_hover_rack_highlight()
return
zone_id = str(self._rack_preview_zone_id or "")
locked_rack_id = str(self._rack_isolation_rack_id or "") if bool(self._rack_isolation_active) else ""
rack_id = self._find_rack_id_by_screen(float(sx), float(sy), zone_id, screen_is_vtk=False)
if locked_rack_id and rack_id and str(rack_id) != locked_rack_id:
self._clear_hover_rack_highlight()
return
if not rack_id:
self._clear_hover_rack_highlight()
return
self._highlight_hover_rack(rack_id)
def _find_rack_id_by_screen(
self: "ModelViewWidget",
sx: float,
sy: float,
zone_id: str,
screen_is_vtk: bool = False,
) -> str | None:
if not self._plotter or not self._plotter.renderer:
return None
# 1) Построить луч камеры (p0 → p1) из экранного пикселя.
ray = self._screen_to_camera_ray(sx, sy, screen_is_vtk=screen_is_vtk)
if ray is not None:
result = self._find_rack_id_by_ray(ray[0], ray[1], zone_id)
if result:
return result
# 2) Запасной вариант — выбор актора (CellPicker с допуском по рёбрам).
try:
from vtkmodules.vtkRenderingCore import vtkCellPicker
pick_x = int(round(float(sx)))
pick_y = int(round(float(sy)))
if not screen_is_vtk:
dpr = self._plotter.devicePixelRatio()
pick_x = int(round(float(sx) * dpr))
pick_y = int(round(float(sy) * dpr))
rw = self._plotter.ren_win
if rw:
_, h = rw.GetSize()
pick_y = h - pick_y - 1
cell_picker = vtkCellPicker()
cell_picker.SetTolerance(0.005)
cell_picker.Pick(pick_x, pick_y, 0, self._plotter.renderer)
picked_actor = cell_picker.GetViewProp()
if picked_actor is not None:
return self._find_rack_id_by_actor(picked_actor, zone_id)
except Exception as exc:
log_exception(__name__, "_find_rack_id_by_screen", exc)
return None
return None
def _screen_to_camera_ray(
self: "ModelViewWidget",
sx: float,
sy: float,
screen_is_vtk: bool = False,
) -> tuple[tuple[float, float, float], tuple[float, float, float]] | None:
"""Вернуть мировые координаты (near_point, far_point) для экранного пикселя."""
if not self._plotter or not self._plotter.renderer:
return None
px = float(sx)
py = float(sy)
if not screen_is_vtk and getattr(self._plotter, "ren_win", None):
try:
dpr = float(self._plotter.devicePixelRatio())
if dpr <= 0.0:
dpr = 1.0
px *= dpr
py *= dpr
_vw, vh = self._plotter.ren_win.GetSize()
py = float(vh) - py - 1.0
except Exception as exc:
log_exception(__name__, "_screen_to_camera_ray.scale", exc)
px, py = float(sx), float(sy)
try:
renderer = self._plotter.renderer
renderer.SetDisplayPoint(px, py, 0)
renderer.DisplayToWorld()
wp0 = renderer.GetWorldPoint()
renderer.SetDisplayPoint(px, py, 1)
renderer.DisplayToWorld()
wp1 = renderer.GetWorldPoint()
if wp0[3] == 0 or wp1[3] == 0:
return None
p0 = (wp0[0] / wp0[3], wp0[1] / wp0[3], wp0[2] / wp0[3])
p1 = (wp1[0] / wp1[3], wp1[1] / wp1[3], wp1[2] / wp1[3])
return (p0, p1)
except Exception as exc:
log_exception(__name__, "_screen_to_camera_ray", exc)
return None
def _find_rack_id_by_ray(
self: "ModelViewWidget",
p0: tuple[float, float, float],
p1: tuple[float, float, float],
zone_id: str,
) -> str | None:
"""Проверить луч камеры по 3D-ограничивающим параллелепипедам всех стоек в зоне."""
zid = str(zone_id or "")
dx = p1[0] - p0[0]
dy = p1[1] - p0[1]
dz = p1[2] - p0[2]
best_t = float("inf")
best_id: str | None = None
for entry in reversed(self._rack_entries):
if zid and str(entry.get("zone_id", "")) != zid:
continue
if not self._imported_models_enabled and self._is_imported_model_entry(entry):
continue
min_x, max_x, min_y, max_y = self._rack_pick_bbox(entry)
params = dict(entry.get("params") or {})
z_ref = float(self._zone_heights.get(str(entry.get("zone_id") or ""), (0.0, 0.0))[0])
min_z = z_ref
max_z = z_ref + max(10.0, self._rack_height_mm(params))
t = _ray_aabb_intersect(p0, dx, dy, dz, min_x, max_x, min_y, max_y, min_z, max_z)
if t is not None and t < best_t:
best_t = t
best_id = str(entry.get("rack_id", ""))
return best_id
def _on_select_shelf_slot_hover_screen(self: "ModelViewWidget", sx: float, sy: float) -> None:
if not self._interaction_manager.is_active("shelf_placement"):
self._clear_shelf_slot_hover()
return
rack_id = str(self._shelf_target_rack_id or "")
zone_id = str(self._shelf_target_zone_id or "")
slot_id = self._find_slot_id_by_screen(float(sx), float(sy), rack_id, zone_id, screen_is_vtk=False)
if not slot_id:
# Сохранить текущий контур наведения, чтобы избежать визуального мерцания при промахах.
return
if slot_id == str(self._active_shelf_slot_id or ""):
return
if slot_id == str(self._hover_shelf_slot_id or ""):
return
self._highlight_shelf_slot_hover(slot_id)
def _on_select_shelf_slot_click(self: "ModelViewWidget", x: float, y: float, z: float) -> bool:
if not self._interaction_manager.is_active("shelf_placement"):
return False
rack_id = str(self._shelf_target_rack_id or "")
zone_id = str(self._shelf_target_zone_id or "")
slot_id = str(self._hover_shelf_slot_id or "")
if (not slot_id) and self._plotter and self._plotter.interactor:
try:
sx, sy = self._plotter.interactor.GetEventPosition()
slot_id = str(
self._find_slot_id_by_screen(float(sx), float(sy), rack_id, zone_id, screen_is_vtk=True) or ""
)
except Exception as exc:
log_exception(__name__, "_on_select_shelf_slot_click", exc)
slot_id = ""
if not slot_id:
return True
self._set_active_shelf_slot(slot_id)
self._selected_shelf_visual_rack_id = rack_id
self._selected_shelf_visual_slot_id = slot_id
self._selected_shelf_visual_index = 1
try:
self.shelf_slot_selected.emit(rack_id, slot_id) # type: ignore[attr-defined]
except Exception as _exc:
log_exception(__name__, "_on_select_shelf_slot_click", _exc)
return True
def _find_rendered_shelf_by_screen(
self: "ModelViewWidget",
sx: float,
sy: float,
rack_id: str,
*,
screen_is_vtk: bool = False,
) -> tuple[str, int] | None:
rid = str(rack_id or "")
if not rid or not self._plotter or not self._plotter.renderer:
return None
try:
from vtkmodules.vtkRenderingCore import vtkPropPicker
pick_x = int(round(float(sx)))
pick_y = int(round(float(sy)))
if not screen_is_vtk:
dpr = self._plotter.devicePixelRatio()
pick_x = int(round(float(sx) * dpr))
pick_y = int(round(float(sy) * dpr))
rw = self._plotter.ren_win
if rw:
_, h = rw.GetSize()
pick_y = h - pick_y - 1
picker = vtkPropPicker()
picker.Pick(pick_x, pick_y, 0, self._plotter.renderer)
picked_actor = picker.GetViewProp()
if picked_actor is not None:
for (entry_rid, slot_id), actors in (self._rack_shelf_actors or {}).items():
if str(entry_rid) != rid:
continue
for actor_index, actor in enumerate(list(actors or []), start=1):
if actor is None:
continue
try:
if not bool(actor.GetVisibility()):
continue
except Exception as exc:
log_exception(__name__, "_find_rendered_shelf_by_screen.visibility", exc)
continue
if actor is picked_actor or actor == picked_actor:
return (str(slot_id), int(actor_index))
try:
if (
hasattr(actor, "GetAddressAsString")
and hasattr(picked_actor, "GetAddressAsString")
and actor.GetAddressAsString("") == picked_actor.GetAddressAsString("")
):
return (str(slot_id), int(actor_index))
except Exception as _exc:
log_exception(__name__, "_find_rendered_shelf_by_screen", _exc)
except Exception as _exc:
log_exception(__name__, "_find_rendered_shelf_by_screen", _exc)
ray = self._screen_to_camera_ray(sx, sy, screen_is_vtk=screen_is_vtk)
if ray is None:
return None
p0, p1 = ray
dx = float(p1[0] - p0[0])
dy = float(p1[1] - p0[1])
dz = float(p1[2] - p0[2])
best_t = float("inf")
best_hit: tuple[str, int] | None = None
for (entry_rid, slot_id), actors in (self._rack_shelf_actors or {}).items():
if str(entry_rid) != rid:
continue
for actor_index, actor in enumerate(list(actors or []), start=1):
if actor is None:
continue
try:
if not bool(actor.GetVisibility()):
continue
bounds = actor.GetBounds()
except Exception as exc:
log_exception(__name__, "_find_rendered_shelf_by_screen.bounds", exc)
continue
if not bounds or len(bounds) != 6:
continue
min_x, max_x, min_y, max_y, min_z, max_z = [float(v) for v in bounds]
t = _ray_aabb_intersect(p0, dx, dy, dz, min_x, max_x, min_y, max_y, min_z, max_z)
if t is not None and t < best_t:
best_t = float(t)
best_hit = (str(slot_id), int(actor_index))
return best_hit
def _find_slot_id_by_screen(
self: "ModelViewWidget",
sx: float,
sy: float,
rack_id: str,
zone_id: str,
screen_is_vtk: bool = False,
include_hidden: bool = False,
) -> str | None:
entry = self._get_rack_entry(rack_id, zone_id)
if entry is None or not self._plotter or not self._plotter.renderer:
return None
try:
from vtkmodules.vtkRenderingCore import vtkPropPicker
pick_x = int(round(float(sx)))
pick_y = int(round(float(sy)))
if not screen_is_vtk:
dpr = self._plotter.devicePixelRatio()
pick_x = int(round(float(sx) * dpr))
pick_y = int(round(float(sy) * dpr))
rw = self._plotter.ren_win
if rw:
_, h = rw.GetSize()
pick_y = h - pick_y - 1
picker = vtkPropPicker()
picker.Pick(pick_x, pick_y, 0, self._plotter.renderer)
picked_actor = picker.GetViewProp()
if picked_actor is not None:
for slot_id, slot_actor in (entry.get("slot_actors") or {}).items():
if slot_actor is None:
continue
try:
if (not include_hidden) and (not bool(slot_actor.GetVisibility())):
continue
except Exception as _exc:
log_exception(__name__, "_find_slot_id_by_screen", _exc)
if slot_actor is picked_actor or slot_actor == picked_actor:
return str(slot_id)
try:
if (
hasattr(slot_actor, "GetAddressAsString")
and hasattr(picked_actor, "GetAddressAsString")
and slot_actor.GetAddressAsString("") == picked_actor.GetAddressAsString("")
):
return str(slot_id)
except Exception as _exc:
log_exception(__name__, "_find_slot_id_by_screen", _exc)
except Exception as _exc:
log_exception(__name__, "_find_slot_id_by_screen", _exc)
# Запасной вариант для полностью прозрачных акторов ячеек:
# определить ближайшую ячейку пересечением луча с ограничивающим параллелепипедом.
ray = self._screen_to_camera_ray(sx, sy, screen_is_vtk=screen_is_vtk)
if ray is None:
return None
p0, p1 = ray
dx = float(p1[0] - p0[0])
dy = float(p1[1] - p0[1])
dz = float(p1[2] - p0[2])
best_t = float("inf")
best_slot_id: str | None = None
for slot_id, slot_actor in (entry.get("slot_actors") or {}).items():
if slot_actor is None:
continue
try:
if (not include_hidden) and (not bool(slot_actor.GetVisibility())):
continue
bounds = slot_actor.GetBounds()
except Exception as exc:
log_exception(__name__, "_find_slot_id_by_screen.bounds", exc)
continue
if not bounds or len(bounds) != 6:
continue
min_x, max_x, min_y, max_y, min_z, max_z = [float(v) for v in bounds]
t = _ray_aabb_intersect(
p0,
dx,
dy,
dz,
min_x,
max_x,
min_y,
max_y,
min_z,
max_z,
)
if t is not None and t < best_t:
best_t = float(t)
best_slot_id = str(slot_id)
return best_slot_id
def _get_rack_entry(self: "ModelViewWidget", rack_id: str, zone_id: str | None = None) -> dict[str, Any] | None:
rid = str(rack_id or "")
zid = str(zone_id or "")
if not rid:
return None
for entry in self._rack_entries:
if str(entry.get("rack_id", "")) != rid:
continue
if zid and str(entry.get("zone_id", "")) != zid:
continue
return entry
return None
def _preload_shelf_model_for_entry(self: "ModelViewWidget", entry: dict[str, Any]) -> None:
params = dict(entry.get("params") or {})
rack_type = str(params.get("rack_type") or "A")
depth = int(params.get("depth_mm") or params.get("footprint_depth_mm") or 500)
widths = [int(v) for v in (params.get("span_widths_mm") or []) if int(v) > 0]
if not widths:
widths = [int(params.get("footprint_width_mm") or 1000)]
self._get_shelf_base_mesh(rack_type, depth, widths[0])
# ---------------------------------------------------------------------------
# Module workflow notes
# ---------------------------------------------------------------------------
# 1) Задача модуля:
# Выбор стеллажей и ячеек по экранным координатам и лучу камеры.
#
# 2) Последовательность действий и вызовов:
# A. Класс RackPickingMixin: точки входа
# Публичные методы отсутствуют; сценарий запускается через методы родительских модулей и внутренние обработчики.
#
# B. RackPickingMixin: основной сценарий:
# RackPickingMixin._on_select_rack_click(...)
# Назначение: выполняет шаг "on select rack click" в рамках текущего сценария модуля.
# Последовательность внутренних вызовов:
# -> RackPickingMixin._find_rack_id_by_screen(...)
# RackPickingMixin._on_select_rack_hover_screen(...)
# Назначение: выполняет шаг "on select rack hover screen" в рамках текущего сценария модуля.
# Последовательность внутренних вызовов:
# -> RackPickingMixin._find_rack_id_by_screen(...)
# RackPickingMixin._find_rack_id_by_screen(...)
# Назначение: находит rack id by screen в рамках текущего сценария модуля.
# Последовательность внутренних вызовов:
# -> RackPickingMixin._screen_to_camera_ray(...)
# -> RackPickingMixin._find_rack_id_by_ray(...)
# RackPickingMixin._find_rack_id_by_ray(...)
# Назначение: Проверить луч камеры по 3D-ограничивающим параллелепипедам всех стоек в зоне.
# RackPickingMixin._on_select_shelf_slot_hover_screen(...)
# Назначение: выполняет шаг "on select shelf slot hover screen" в рамках текущего сценария модуля.
# Последовательность внутренних вызовов:
# -> RackPickingMixin._find_slot_id_by_screen(...)
# RackPickingMixin._on_select_shelf_slot_click(...)
# Назначение: выполняет шаг "on select shelf slot click" в рамках текущего сценария модуля.
# Последовательность внутренних вызовов:
# -> RackPickingMixin._find_slot_id_by_screen(...)
# RackPickingMixin._find_slot_id_by_screen(...)
# Назначение: находит slot id by screen в рамках текущего сценария модуля.
# Последовательность внутренних вызовов:
# -> RackPickingMixin._get_rack_entry(...)
# -> RackPickingMixin._screen_to_camera_ray(...)
#
# C. RackPickingMixin: вспомогательные расчёты:
# RackPickingMixin._rack_anchor_world_point(...)
# Назначение: выполняет шаг "rack anchor world point" в рамках текущего сценария модуля.
# RackPickingMixin._center_from_anchor_world_point(...)
# Назначение: выполняет шаг "center from anchor world point" в рамках текущего сценария модуля.
# RackPickingMixin._screen_to_camera_ray(...)
# Назначение: Вернуть мировые координаты (near_point, far_point) для экранного пикселя.
# RackPickingMixin._get_rack_entry(...)
# Назначение: возвращает rack entry в рамках текущего сценария модуля.
# RackPickingMixin._preload_shelf_model_for_entry(...)
# Назначение: выполняет шаг "preload shelf model for entry" в рамках текущего сценария модуля.
#
# 3) Важные ограничения и инварианты:
# - Модуль выполняется в составе ModelViewWidget и использует согласованные поля состояния self._... .
# - Все операции с актёрами и камерой выполняются только при валидном self._plotter с безопасной обработкой исключений.
# - Межмодульная связность: только через фасад model_view; прямые обращения между zone, rack, shelf, cell запрещены.
# - Очистка состояния должна быть идемпотентной: повторный вызов не меняет корректное состояние в ошибочное.
#
# 4) Правило сопровождения:
# - Любое изменение сценария должно сопровождаться обновлением этого блока с сохранением фактического порядка вызовов.
# - При добавлении метода указывать его место в цепочке сценария (запуск, основной шаг, завершение, вспомогательная логика).

View File

@@ -0,0 +1,338 @@
# -*- coding: utf-8 -*-
"""Вспомогательные функции предпросмотра / размещения стоек для ModelViewWidget."""
from __future__ import annotations
import uuid
from typing import Any, TYPE_CHECKING
from ._mv_rack_geometry import rack_bbox
from error_logger import log_exception
if TYPE_CHECKING:
from ..model_view_widget import ModelViewWidget
class RackPreviewMixin:
"""Построение, перемещение, подтверждение и очистка предпросмотра стоек."""
def _rebuild_rack_preview(self: "ModelViewWidget", cx: float, cy: float) -> None:
prev_center = self._rack_preview_center
zone_id = self._rack_preview_zone_id
if not zone_id:
self._clear_rack_preview()
self._rack_preview_center = (cx, cy)
return
params = self._rack_preview_params
valid = self._validate_rack_position(cx, cy, zone_id, params, self._rack_preview_rotation)
self._rack_preview_valid = valid
shape_key = self._preview_shape_key(params, self._rack_preview_rotation)
if (
self._rack_preview_actors
and prev_center is not None
and self._rack_preview_shape_key == shape_key
):
dx = float(cx - prev_center[0])
dy = float(cy - prev_center[1])
if abs(dx) > 0.0 or abs(dy) > 0.0:
self._translate_preview_actors(dx, dy)
self._set_preview_color(valid)
self._rack_preview_center = (cx, cy)
if self._plotter:
if hasattr(self, "_safe_render"):
self._safe_render(min_interval_s=1.0 / 75.0)
else:
self._plotter.update()
return
disable_render = getattr(self._plotter, "disable_render", None) if self._plotter else None
enable_render = getattr(self._plotter, "enable_render", None) if self._plotter else None
if callable(disable_render):
try:
disable_render()
except Exception as exc:
log_exception(__name__, "_rebuild_rack_preview", exc)
disable_render = None
try:
self._clear_rack_preview()
color = "#4CAF50" if valid else "#EF5350"
actors, _slot_actors = self._spawn_rack_actors(
cx,
cy,
zone_id,
params,
self._rack_preview_rotation,
color,
0.35,
lightweight=False,
)
self._rack_preview_actors = actors
self._rack_preview_shape_key = shape_key
self._rack_preview_center = (cx, cy)
finally:
if callable(enable_render):
try:
enable_render()
except Exception as _exc:
log_exception(__name__, "_rebuild_rack_preview", _exc)
if self._plotter:
if hasattr(self, "_safe_render"):
self._safe_render(min_interval_s=1.0 / 75.0)
else:
self._plotter.update()
def _commit_rack_placement(self: "ModelViewWidget", cx: float, cy: float) -> None:
zone_id = self._rack_preview_zone_id
if not zone_id:
return
params = dict(self._rack_preview_params)
if self._moving_rack_entry:
params = self._normalize_rack_params(
params,
fallback_code=str(dict(self._moving_rack_entry).get("code") or "A"),
)
else:
params = self._normalize_rack_params(
params,
fallback_code=self._next_zone_rack_code(zone_id, params),
)
actors, slot_actors = self._spawn_rack_actors(
cx,
cy,
zone_id,
params,
self._rack_preview_rotation,
*self._resolve_rack_style(params),
)
if not actors:
return
bbox = rack_bbox(cx, cy, params, self._rack_preview_rotation)
moved_committed = False
if self._moving_rack_entry:
moved = dict(self._moving_rack_entry)
rack_id = str(moved.get("rack_id") or "")
for actor in list(moved.get("actors") or []):
try:
self._plotter.remove_actor(actor)
except Exception as _exc:
log_exception(__name__, "_commit_rack_placement", _exc)
if rack_id:
self._clear_slot_shelf_actors(rack_id, clear_slot_id=None)
moved["zone_id"] = zone_id
moved["center"] = (cx, cy)
moved["rotation"] = self._rack_preview_rotation
moved["bbox"] = bbox
moved["params"] = params
moved["actors"] = actors
moved["slot_actors"] = slot_actors
moved["hidden_slot_ids"] = set(str(v) for v in (moved.get("hidden_slot_ids") or set()))
moved["code"] = str(params.get("code") or moved.get("code") or "A")
moved["name"] = str(params.get("name") or moved.get("name") or "Rack")
moved["numbering_direction"] = str(
params.get("numbering_direction")
or moved.get("numbering_direction")
or "left_to_right"
)
moved["span_codes"] = [str(v) for v in (params.get("span_codes") or moved.get("span_codes") or [])]
moved.pop("_origin_rack_visual_snapshot", None)
moved.pop("_origin_shelf_visual_snapshot", None)
self._rack_entries.append(moved)
self._moving_rack_entry = None
if rack_id:
self._rebuild_shelves_for_moved_rack(rack_id)
self._last_moved_rack_id = rack_id
moved_committed = True
else:
# Защита: режим перемещения не должен создавать новые стойки.
if bool(getattr(self, "_rack_move_mode", False)):
self._clear_rack_preview()
return
self._rack_entries.append(
{
"rack_id": str(uuid.uuid4()),
"zone_id": zone_id,
"center": (cx, cy),
"rotation": self._rack_preview_rotation,
"bbox": bbox,
"params": params,
"actors": actors,
"slot_actors": slot_actors,
"hidden_slot_ids": set(),
"code": str(params.get("code") or "A"),
"name": str(params.get("name") or "Rack"),
"numbering_direction": str(params.get("numbering_direction") or "left_to_right"),
"span_codes": [str(v) for v in (params.get("span_codes") or [])],
}
)
self._clear_rack_preview()
if self._selected_rack_id:
self._apply_selected_rack_visual(str(self._selected_rack_id))
self.rack_selected_changed.emit(self._selected_rack_id)
self.rack_layout_changed.emit(str(zone_id))
if moved_committed:
# Завершить перемещение после одного успешного размещения.
self.stop_rack_placement(clear_preview=True)
# Автоматически вернуться в режим выбора стоек для этой зоны.
self.start_select_rack_mode(str(zone_id))
def _rebuild_shelves_for_moved_rack(self: "ModelViewWidget", rack_id: str) -> None:
rid = str(rack_id or "")
if not rid:
return
slot_keys = [
(entry_rid, slot_id)
for (entry_rid, slot_id) in list((self._rack_shelf_params or {}).keys())
if str(entry_rid) == rid
]
slot_keys.sort(key=lambda key: self._slot_index_from_id(str(key[1])))
for key in slot_keys:
slot_id = str(key[1])
payload = dict(self._rack_shelf_params.get(key) or {})
if not payload:
continue
normalized = self._render_shelves_for_slot(rid, slot_id, payload)
if not normalized:
continue
self._rack_shelf_params[(rid, slot_id)] = dict(normalized)
self.set_rack_slot_occupied(rid, slot_id, True)
self._set_rack_shelf_actors_visibility(rid, True)
self._apply_rack_shelf_opacity(rid)
if self._plotter:
self._plotter.update()
def _rebuild_shelves_for_updated_rack_params(self: "ModelViewWidget", rack_id: str) -> None:
"""Перерисовать существующие ряды полок после обновления геометрии/параметров стойки."""
rid = str(rack_id or "")
if not rid:
return
entry = self._get_rack_entry(rid)
if entry is None:
return
available_slot_ids = set(str(v) for v in (entry.get("slot_actors") or {}).keys())
slot_keys = [
(entry_rid, slot_id)
for (entry_rid, slot_id) in list((self._rack_shelf_params or {}).keys())
if str(entry_rid) == rid
]
slot_keys.sort(key=lambda key: self._slot_index_from_id(str(key[1])))
for key in slot_keys:
slot_id = str(key[1])
payload = dict(self._rack_shelf_params.get(key) or {})
if not payload:
continue
if slot_id not in available_slot_ids:
# Ячейка удалена (например, уменьшилось spans_count) — удалить устаревшие данные/акторы полок.
self._clear_slot_shelf_actors(rid, slot_id)
self._rack_shelf_params.pop((rid, slot_id), None)
continue
normalized = self._render_shelves_for_slot(rid, slot_id, payload)
if not normalized:
continue
self._rack_shelf_params[(rid, slot_id)] = dict(normalized)
self.set_rack_slot_occupied(rid, slot_id, True)
self._set_rack_shelf_actors_visibility(rid, True)
self._apply_rack_shelf_opacity(rid)
if self._plotter:
self._plotter.update()
def consume_last_moved_rack_id(self: "ModelViewWidget") -> str:
rack_id = str(self._last_moved_rack_id or "")
self._last_moved_rack_id = None
return rack_id
def _clear_rack_preview(self: "ModelViewWidget") -> None:
for actor in self._rack_preview_actors:
try:
self._plotter.remove_actor(actor)
except Exception as _exc:
log_exception(__name__, "_clear_rack_preview", _exc)
self._rack_preview_actors = []
self._rack_preview_shape_key = None
def _preview_shape_key(self: "ModelViewWidget", params: dict[str, Any], rotation: int) -> tuple[Any, ...]:
return (
int(params.get("footprint_width_mm", 1000)),
int(params.get("footprint_depth_mm", 500)),
int(params.get("footprint_height_mm", 1800)),
int(rotation) % 360,
)
def _translate_preview_actors(self: "ModelViewWidget", dx: float, dy: float) -> None:
for actor in self._rack_preview_actors:
try:
actor.AddPosition(float(dx), float(dy), 0.0)
except Exception as exc:
log_exception(__name__, "_translate_preview_actors", exc)
try:
px, py, pz = actor.GetPosition()
actor.SetPosition(float(px + dx), float(py + dy), float(pz))
except Exception as _exc:
log_exception(__name__, "_translate_preview_actors", _exc)
def _set_preview_color(self: "ModelViewWidget", valid: bool) -> None:
if not self._rack_preview_actors:
return
rgb = (0.298, 0.686, 0.314) if valid else (0.937, 0.325, 0.314)
try:
prop = self._rack_preview_actors[0].GetProperty()
if prop is not None:
prop.SetColor(*rgb)
except Exception as _exc:
log_exception(__name__, "_set_preview_color", _exc)
# ---------------------------------------------------------------------------
# Module workflow notes
# ---------------------------------------------------------------------------
# 1) Задача модуля:
# Предпросмотр стеллажа и фиксация размещения.
#
# 2) Последовательность действий и вызовов:
# A. Класс RackPreviewMixin: точки входа
# Публичные методы сценария:
# - RackPreviewMixin.consume_last_moved_rack_id(...)
#
# B. RackPreviewMixin: запуск и настройка:
# RackPreviewMixin._set_preview_color(...)
# Назначение: устанавливает preview color в рамках текущего сценария модуля.
#
# C. RackPreviewMixin: основной сценарий:
# RackPreviewMixin._rebuild_rack_preview(...)
# Назначение: перестраивает rack preview в рамках текущего сценария модуля.
# Последовательность внутренних вызовов:
# -> RackPreviewMixin._preview_shape_key(...)
# -> RackPreviewMixin._clear_rack_preview(...)
# -> RackPreviewMixin._set_preview_color(...)
# -> RackPreviewMixin._translate_preview_actors(...)
# RackPreviewMixin._rebuild_shelves_for_moved_rack(...)
# Назначение: перестраивает shelves for moved rack в рамках текущего сценария модуля.
# RackPreviewMixin._rebuild_shelves_for_updated_rack_params(...)
# Назначение: Перерисовать существующие ряды полок после обновления геометрии/параметров стойки.
#
# D. RackPreviewMixin: завершение и очистка:
# RackPreviewMixin._clear_rack_preview(...)
# Назначение: очищает rack preview в рамках текущего сценария модуля.
#
# E. RackPreviewMixin: вспомогательные расчёты:
# RackPreviewMixin._commit_rack_placement(...)
# Назначение: выполняет шаг "commit rack placement" в рамках текущего сценария модуля.
# Последовательность внутренних вызовов:
# -> RackPreviewMixin._clear_rack_preview(...)
# -> RackPreviewMixin._rebuild_shelves_for_moved_rack(...)
# RackPreviewMixin.consume_last_moved_rack_id(...)
# Назначение: выполняет шаг "consume last moved rack id" в рамках текущего сценария модуля.
# RackPreviewMixin._preview_shape_key(...)
# Назначение: выполняет шаг "preview shape key" в рамках текущего сценария модуля.
# RackPreviewMixin._translate_preview_actors(...)
# Назначение: выполняет шаг "translate preview actors" в рамках текущего сценария модуля.
#
# 3) Важные ограничения и инварианты:
# - Модуль выполняется в составе ModelViewWidget и использует согласованные поля состояния self._... .
# - Все операции с актёрами и камерой выполняются только при валидном self._plotter с безопасной обработкой исключений.
# - Межмодульная связность: только через фасад model_view; прямые обращения между zone, rack, shelf, cell запрещены.
# - Очистка состояния должна быть идемпотентной: повторный вызов не меняет корректное состояние в ошибочное.
#
# 4) Правило сопровождения:
# - Любое изменение сценария должно сопровождаться обновлением этого блока с сохранением фактического порядка вызовов.
# - При добавлении метода указывать его место в цепочке сценария (запуск, основной шаг, завершение, вспомогательная логика).

View File

@@ -0,0 +1,119 @@
# -*- coding: utf-8 -*-
# gui/components/model_view/_mv_racks_projection.py
"""Проекционная привязка курсора к плоскости пола зоны для rack-placement."""
from __future__ import annotations
from typing import TYPE_CHECKING
from gui.components.model_view._mv_rack_geometry import PLACEMENT_STEP_MM
from error_logger import log_exception
if TYPE_CHECKING: # pragma: no cover
from gui.components.model_view_widget import ModelViewWidget
class RackPlacementProjectionMixin:
"""Проекция курсора на плоскость зоны."""
def _on_rack_hover_screen(self: "ModelViewWidget", sx: float, sy: float) -> None:
if not self._interaction_manager.is_active("rack_placement"):
return
world = self._project_screen_to_zone_plane(sx, sy)
if world is None:
return
x, y, _ = world
gx = round(float(x) / PLACEMENT_STEP_MM) * PLACEMENT_STEP_MM
gy = round(float(y) / PLACEMENT_STEP_MM) * PLACEMENT_STEP_MM
if self._rack_preview_center == (gx, gy):
return
self._rebuild_rack_preview(gx, gy)
def _on_rack_click(self: "ModelViewWidget", x: float, y: float, z: float) -> bool:
if not self._interaction_manager.is_active("rack_placement"):
return False
world = self._project_cursor_to_zone_plane()
if world is None:
world = (x, y, z if z is not None else 0.0)
wx, wy, _wz = world
gx = round(float(wx) / PLACEMENT_STEP_MM) * PLACEMENT_STEP_MM
gy = round(float(wy) / PLACEMENT_STEP_MM) * PLACEMENT_STEP_MM
self._rebuild_rack_preview(gx, gy)
if not self._rack_preview_valid:
return True
self._commit_rack_placement(gx, gy)
return True
def _project_cursor_to_zone_plane(self: "ModelViewWidget") -> tuple[float, float, float] | None:
if not self._plotter or not self._plotter.interactor:
return None
try:
sx, sy = self._plotter.interactor.GetEventPosition()
except Exception as exc:
log_exception(__name__, "_project_cursor_to_zone_plane", exc)
return None
return self._project_screen_to_zone_plane(float(sx), float(sy), screen_is_vtk=True)
def _project_screen_to_zone_plane(
self: "ModelViewWidget",
sx: float,
sy: float,
screen_is_vtk: bool = False,
) -> tuple[float, float, float] | None:
zone_id = self._rack_preview_zone_id
if not zone_id:
return None
z_ref = float(self._zone_heights.get(zone_id, (0.0, 0.0))[0])
px = float(sx)
py = float(sy)
if not screen_is_vtk and self._plotter and getattr(self._plotter, "ren_win", None):
try:
dpr = float(self._plotter.devicePixelRatio())
if dpr <= 0.0:
dpr = 1.0
px *= dpr
py *= dpr
_vw, vh = self._plotter.ren_win.GetSize()
py = float(vh) - py - 1.0
except Exception as exc:
log_exception(__name__, "_project_screen_to_zone_plane", exc)
px = float(sx)
py = float(sy)
return self.screen_to_world_on_plane(px, py, z_ref)
# ---------------------------------------------------------------------------
# Module workflow notes
# ---------------------------------------------------------------------------
# 1) Задача модуля:
# Проекция курсора на плоскость зоны для сценария размещения.
#
# 2) Последовательность действий и вызовов:
# A. Класс RackPlacementProjectionMixin: точки входа
# Публичные методы отсутствуют; сценарий запускается через методы родительских модулей и внутренние обработчики.
#
# B. RackPlacementProjectionMixin: основной сценарий:
# RackPlacementProjectionMixin._on_rack_hover_screen(...)
# Назначение: выполняет шаг "on rack hover screen" в рамках текущего сценария модуля.
# Последовательность внутренних вызовов:
# -> RackPlacementProjectionMixin._project_screen_to_zone_plane(...)
# RackPlacementProjectionMixin._on_rack_click(...)
# Назначение: выполняет шаг "on rack click" в рамках текущего сценария модуля.
# Последовательность внутренних вызовов:
# -> RackPlacementProjectionMixin._project_cursor_to_zone_plane(...)
# RackPlacementProjectionMixin._project_cursor_to_zone_plane(...)
# Назначение: проецирует cursor to zone plane в рамках текущего сценария модуля.
# Последовательность внутренних вызовов:
# -> RackPlacementProjectionMixin._project_screen_to_zone_plane(...)
# RackPlacementProjectionMixin._project_screen_to_zone_plane(...)
# Назначение: проецирует screen to zone plane в рамках текущего сценария модуля.
#
# 3) Важные ограничения и инварианты:
# - Модуль выполняется в составе ModelViewWidget и использует согласованные поля состояния self._... .
# - Все операции с актёрами и камерой выполняются только при валидном self._plotter с безопасной обработкой исключений.
# - Межмодульная связность: только через фасад model_view; прямые обращения между zone, rack, shelf, cell запрещены.
# - Очистка состояния должна быть идемпотентной: повторный вызов не меняет корректное состояние в ошибочное.
#
# 4) Правило сопровождения:
# - Любое изменение сценария должно сопровождаться обновлением этого блока с сохранением фактического порядка вызовов.
# - При добавлении метода указывать его место в цепочке сценария (запуск, основной шаг, завершение, вспомогательная логика).

View File

@@ -0,0 +1,412 @@
# -*- coding: utf-8 -*-
"""Вспомогательные функции выделения / подсветки стоек для 3D-виджета просмотра моделей."""
from __future__ import annotations
from typing import Any, TYPE_CHECKING
from gui.components.model_view._mv_rack_geometry import rack_bbox
from error_logger import log_exception
try:
import pyvista as pv # noqa: F401
except ImportError:
pv = None # type: ignore[assignment]
if TYPE_CHECKING:
from gui.components.model_view_widget import ModelViewWidget
class RackSelectionMixin:
"""Вспомогательные функции визуализации выделения, контура и изоляции стоек."""
def get_selected_rack_entry(self: "ModelViewWidget") -> dict[str, Any] | None:
rid = str(self._selected_rack_id or "")
if not rid:
return None
for entry in self._rack_entries:
if str(entry.get("rack_id", "")) == rid:
return entry
return None
def get_selected_rack_display_color(self: "ModelViewWidget") -> str:
entry = self.get_selected_rack_entry()
if entry is None:
return "#59B6E6"
params = dict(entry.get("params") or {})
color, _opacity = self._resolve_rack_style(params)
return str(color or "#59B6E6")
def rebuild_zone_racks_for_updated_zone(self: "ModelViewWidget", zone_id: str) -> list[str]:
"""Пересоздать акторы стоек/полок для зоны после обновления высоты/объёма зоны."""
zid = str(zone_id or "")
if not zid or not self._plotter:
return []
updated_rack_ids: list[str] = []
for idx, entry in enumerate(list(self._rack_entries)):
if str(entry.get("zone_id") or "") != zid:
continue
rack_id = str(entry.get("rack_id") or "")
if not rack_id:
continue
center = entry.get("center") or (0.0, 0.0)
params = dict(entry.get("params") or {})
rotation = int(entry.get("rotation", 0)) % 360
color, opacity = self._resolve_rack_style(params)
actors, slot_actors = self._spawn_rack_actors(
float(center[0]), float(center[1]), zid, params, rotation, color, opacity,
)
if not actors:
continue
for actor in list(entry.get("actors") or []):
try:
self._plotter.remove_actor(actor)
except Exception as _exc:
log_exception(__name__, "rebuild_zone_racks_for_updated_zone", _exc)
self._clear_slot_shelf_actors(rack_id, clear_slot_id=None)
hidden_slot_ids = set(str(v) for v in (entry.get("hidden_slot_ids") or set()))
for sid in hidden_slot_ids:
slot_actor = slot_actors.get(sid)
if slot_actor is not None:
try:
slot_actor.SetVisibility(0)
except Exception as _exc:
log_exception(__name__, "rebuild_zone_racks_for_updated_zone", _exc)
entry["bbox"] = rack_bbox(float(center[0]), float(center[1]), params, rotation)
entry["actors"] = actors
entry["slot_actors"] = slot_actors
entry["hidden_slot_ids"] = hidden_slot_ids
self._apply_rack_helper_visibility_policy(entry, True)
self._rack_entries[idx] = entry
previous_target_zone = self._shelf_target_zone_id
try:
self._shelf_target_zone_id = zid
slot_keys = [
(entry_rid, slot_id)
for (entry_rid, slot_id) in list((self._rack_shelf_params or {}).keys())
if str(entry_rid) == rack_id
]
slot_keys.sort(key=lambda key: self._slot_index_from_id(str(key[1])))
for key in slot_keys:
slot_id = str(key[1])
payload = dict(self._rack_shelf_params.get(key) or {})
if not payload:
continue
normalized = self._render_shelves_for_slot(rack_id, slot_id, payload)
if not normalized:
continue
self._rack_shelf_params[(rack_id, slot_id)] = dict(normalized)
self.set_rack_slot_occupied(rack_id, slot_id, True)
finally:
self._shelf_target_zone_id = previous_target_zone
self._set_rack_shelf_actors_visibility(rack_id, True)
self._apply_rack_shelf_opacity(rack_id)
updated_rack_ids.append(rack_id)
if self._selected_rack_id:
self._apply_selected_rack_visual(str(self._selected_rack_id))
self._plotter.update()
return updated_rack_ids
def select_rack_by_id(self: "ModelViewWidget", rack_id: str | None, zone_id: str | None = None) -> bool:
rid = str(rack_id or "")
zid = str(zone_id or "")
if not rid:
self._set_selected_rack(None)
return False
for entry in self._rack_entries:
if str(entry.get("rack_id", "")) != rid:
continue
if zid and str(entry.get("zone_id", "")) != zid:
continue
self._set_selected_rack(rid)
return True
return False
def clear_selected_rack(self: "ModelViewWidget") -> None:
"""Сбросить визуальное состояние выделенной стойки и контур наведения."""
self._set_selected_rack(None)
self._clear_hover_rack_highlight()
def _set_selected_rack(self: "ModelViewWidget", rack_id: str | None) -> None:
rid = str(rack_id or "")
current = str(self._selected_rack_id or "")
if not rid:
self._selected_rack_id = None
self._clear_selected_rack_visual()
if current:
self.rack_selected_changed.emit("")
return
if current != rid:
self._selected_rack_id = rid
self._apply_selected_rack_visual(rid)
self.rack_selected_changed.emit(rid)
return
self._apply_selected_rack_visual(rid)
def _clear_selected_rack_visual(self: "ModelViewWidget") -> None:
snapshot = list(getattr(self, "_selected_rack_visual_props", []) or [])
for actor, opacity, edge_vis, line_width, edge_color in snapshot:
if actor is None:
continue
try:
prop = actor.GetProperty()
prop.SetOpacity(float(opacity))
prop.SetEdgeVisibility(bool(edge_vis))
prop.SetLineWidth(float(line_width))
prop.SetEdgeColor(*edge_color)
except Exception as _exc:
log_exception(__name__, "_clear_selected_rack_visual", _exc)
self._selected_rack_visual_props = []
self._selected_rack_visual_id = None
self._clear_selected_rack_contour(render=False)
if self._rack_isolation_active and self._rack_isolation_rack_id:
self._apply_rack_isolation_visual(str(self._rack_isolation_rack_id))
def _apply_selected_rack_visual(self: "ModelViewWidget", rack_id: str) -> None:
rid = str(rack_id or "")
if not rid:
self._clear_selected_rack_visual()
return
entry = None
for candidate in self._rack_entries:
if str(candidate.get("rack_id", "")) == rid:
entry = candidate
break
if entry is None:
self._clear_selected_rack_visual()
return
self._clear_selected_rack_visual()
slot_actor_ids = {
id(actor)
for actor in (entry.get("slot_actors") or {}).values()
if actor is not None
}
snapshot: list[tuple[object, float, bool, float, tuple[float, float, float]]] = []
isolation_active = bool(
self._rack_isolation_active
and str(self._rack_isolation_rack_id or "") == rid
)
for idx, actor in enumerate(entry.get("actors", [])):
if actor is None:
continue
try:
prop = actor.GetProperty()
if prop is None:
continue
edge_color = tuple(prop.GetEdgeColor())
snapshot.append(
(
actor,
float(prop.GetOpacity()),
bool(prop.GetEdgeVisibility()),
float(prop.GetLineWidth()),
(float(edge_color[0]), float(edge_color[1]), float(edge_color[2])),
)
)
if isolation_active:
prop.SetEdgeVisibility(False)
prop.SetLineWidth(1.0)
else:
prop.SetEdgeVisibility(False)
prop.SetLineWidth(1.0)
if isolation_active and idx > 0:
continue
if id(actor) in slot_actor_ids:
if (
self._interaction_manager.is_active("shelf_placement")
and str(getattr(self, "_shelf_target_rack_id", "") or "") == rid
):
# В режиме редактирования полок прокси секций должны оставаться невидимыми.
prop.SetOpacity(0.0)
else:
# Объём между стойками сохраняет свою текущую прозрачность.
pass
elif idx == 0:
# Основное тело стойки: полная заливка при выделении.
prop.SetOpacity(1.0)
else:
prop.SetOpacity(min(1.0, float(prop.GetOpacity()) + 0.08))
except Exception as _exc:
log_exception(__name__, "_apply_selected_rack_visual", _exc)
self._selected_rack_visual_props = snapshot
self._selected_rack_visual_id = rid
self._set_selected_rack_contour(entry)
if self._plotter:
self._plotter.update()
def _set_selected_rack_contour(self: "ModelViewWidget", entry: dict[str, Any]) -> None:
self._clear_selected_rack_contour(render=False)
if not self._plotter:
return
contour = self._build_rack_hover_contour_mesh(entry)
if contour is None:
return
try:
self._selected_rack_contour_actor = self._plotter.add_mesh(
contour,
color=(0.25, 0.55, 1.0),
line_width=3.0,
name="_selected_rack_contour",
pickable=False,
)
except Exception as exc:
log_exception(__name__, "_set_selected_rack_contour", exc)
self._selected_rack_contour_actor = None
def _clear_selected_rack_contour(self: "ModelViewWidget", render: bool = True) -> None:
actor = getattr(self, "_selected_rack_contour_actor", None)
if actor is not None and self._plotter is not None:
try:
self._plotter.remove_actor(actor)
except Exception as _exc:
log_exception(__name__, "_clear_selected_rack_contour", _exc)
self._selected_rack_contour_actor = None
if render:
if hasattr(self, "_safe_render"):
self._safe_render(min_interval_s=1.0 / 60.0)
elif self._plotter:
self._plotter.update()
def _clear_rack_isolation_visual(self: "ModelViewWidget") -> None:
snapshot = list(getattr(self, "_rack_isolation_visual_props", []) or [])
for actor, visibility, opacity, edge_vis, line_width, edge_color in snapshot:
if actor is None:
continue
try:
actor.SetVisibility(int(visibility))
prop = actor.GetProperty()
if prop is None:
continue
prop.SetOpacity(float(opacity))
prop.SetEdgeVisibility(bool(edge_vis))
prop.SetLineWidth(float(line_width))
prop.SetEdgeColor(*edge_color)
except Exception as _exc:
log_exception(__name__, "_clear_rack_isolation_visual", _exc)
self._rack_isolation_visual_props = []
def _apply_rack_isolation_visual(self: "ModelViewWidget", rack_id: str) -> None:
rid = str(rack_id or "")
if not rid:
return
entry = None
for candidate in self._rack_entries:
if str(candidate.get("rack_id", "")) == rid:
entry = candidate
break
if entry is None:
return
self._clear_rack_isolation_visual()
snapshot: list[tuple[object, int, float, bool, float, tuple[float, float, float]]] = []
for idx, actor in enumerate(entry.get("actors", [])):
if actor is None:
continue
try:
visibility = int(actor.GetVisibility())
prop = actor.GetProperty()
if prop is None:
continue
edge_color = tuple(prop.GetEdgeColor())
snapshot.append(
(
actor,
visibility,
float(prop.GetOpacity()),
bool(prop.GetEdgeVisibility()),
float(prop.GetLineWidth()),
(float(edge_color[0]), float(edge_color[1]), float(edge_color[2])),
)
)
is_pillar_actor = idx == 0
actor.SetVisibility(1 if is_pillar_actor else 0)
if is_pillar_actor:
prop.SetOpacity(1.0)
# Изоляция стойки на уровне дерева не должна отрисовывать контур выделения.
prop.SetEdgeVisibility(False)
prop.SetLineWidth(1.0)
except Exception as _exc:
log_exception(__name__, "_apply_rack_isolation_visual", _exc)
self._rack_isolation_visual_props = snapshot
# ---------------------------------------------------------------------------
# Module workflow notes
# ---------------------------------------------------------------------------
# 1) Задача модуля:
# Выделение стеллажа и визуальная изоляция контекста.
#
# 2) Последовательность действий и вызовов:
# A. Класс RackSelectionMixin: точки входа
# Публичные методы сценария:
# - RackSelectionMixin.get_selected_rack_entry(...)
# - RackSelectionMixin.get_selected_rack_display_color(...)
# - RackSelectionMixin.rebuild_zone_racks_for_updated_zone(...)
# - RackSelectionMixin.select_rack_by_id(...)
# - RackSelectionMixin.clear_selected_rack(...)
#
# B. RackSelectionMixin: запуск и настройка:
# RackSelectionMixin._set_selected_rack(...)
# Назначение: устанавливает selected rack в рамках текущего сценария модуля.
# Последовательность внутренних вызовов:
# -> RackSelectionMixin._apply_selected_rack_visual(...)
# -> RackSelectionMixin._clear_selected_rack_visual(...)
# RackSelectionMixin._set_selected_rack_contour(...)
# Назначение: устанавливает selected rack contour в рамках текущего сценария модуля.
# Последовательность внутренних вызовов:
# -> RackSelectionMixin._clear_selected_rack_contour(...)
#
# C. RackSelectionMixin: основной сценарий:
# RackSelectionMixin.rebuild_zone_racks_for_updated_zone(...)
# Назначение: Пересоздать акторы стоек/полок для зоны после обновления высоты/объёма зоны.
# Последовательность внутренних вызовов:
# -> RackSelectionMixin._apply_selected_rack_visual(...)
# RackSelectionMixin.select_rack_by_id(...)
# Назначение: выполняет шаг "select rack by id" в рамках текущего сценария модуля.
# Последовательность внутренних вызовов:
# -> RackSelectionMixin._set_selected_rack(...)
# RackSelectionMixin._apply_selected_rack_visual(...)
# Назначение: применяет selected rack visual в рамках текущего сценария модуля.
# Последовательность внутренних вызовов:
# -> RackSelectionMixin._clear_selected_rack_visual(...)
# -> RackSelectionMixin._set_selected_rack_contour(...)
# RackSelectionMixin._apply_rack_isolation_visual(...)
# Назначение: применяет rack isolation visual в рамках текущего сценария модуля.
# Последовательность внутренних вызовов:
# -> RackSelectionMixin._clear_rack_isolation_visual(...)
#
# D. RackSelectionMixin: завершение и очистка:
# RackSelectionMixin.clear_selected_rack(...)
# Назначение: Сбросить визуальное состояние выделенной стойки и контур наведения.
# Последовательность внутренних вызовов:
# -> RackSelectionMixin._set_selected_rack(...)
# RackSelectionMixin._clear_selected_rack_visual(...)
# Назначение: очищает selected rack visual в рамках текущего сценария модуля.
# Последовательность внутренних вызовов:
# -> RackSelectionMixin._clear_selected_rack_contour(...)
# -> RackSelectionMixin._apply_rack_isolation_visual(...)
# RackSelectionMixin._clear_selected_rack_contour(...)
# Назначение: очищает selected rack contour в рамках текущего сценария модуля.
# RackSelectionMixin._clear_rack_isolation_visual(...)
# Назначение: очищает rack isolation visual в рамках текущего сценария модуля.
#
# E. RackSelectionMixin: вспомогательные расчёты:
# RackSelectionMixin.get_selected_rack_entry(...)
# Назначение: возвращает selected rack entry в рамках текущего сценария модуля.
# RackSelectionMixin.get_selected_rack_display_color(...)
# Назначение: возвращает selected rack display color в рамках текущего сценария модуля.
# Последовательность внутренних вызовов:
# -> RackSelectionMixin.get_selected_rack_entry(...)
#
# 3) Важные ограничения и инварианты:
# - Модуль выполняется в составе ModelViewWidget и использует согласованные поля состояния self._... .
# - Все операции с актёрами и камерой выполняются только при валидном self._plotter с безопасной обработкой исключений.
# - Геометрическая визуализация зависит от pyvista/vtk; при недоступности модуль обязан завершать шаг без падения сценария.
# - Межмодульная связность: только через фасад model_view; прямые обращения между zone, rack, shelf, cell запрещены.
# - Очистка состояния должна быть идемпотентной: повторный вызов не меняет корректное состояние в ошибочное.
#
# 4) Правило сопровождения:
# - Любое изменение сценария должно сопровождаться обновлением этого блока с сохранением фактического порядка вызовов.
# - При добавлении метода указывать его место в цепочке сценария (запуск, основной шаг, завершение, вспомогательная логика).

View File

@@ -0,0 +1,319 @@
# -*- coding: utf-8 -*-
"""Миксин размещения полок стеллажа для ModelViewWidget."""
from __future__ import annotations
from typing import Any, TYPE_CHECKING
from error_logger import log_exception
if TYPE_CHECKING:
from gui.components.model_view_widget import ModelViewWidget
class RackShelfMixin:
"""Вспомогательные методы размещения полок и секций, извлечённые из модуля стеллажей."""
def start_select_rack_mode(self: "ModelViewWidget", zone_id: str | None) -> None:
zid = str(zone_id or "")
self._rack_select_mode = bool(zid)
self._rack_preview_zone_id = zid if zid else self._rack_preview_zone_id
self._clear_hover_rack_highlight()
# Сценарий взаимодействия
mgr = getattr(self, "_interaction_manager", None)
if mgr is not None:
mgr.pop_by_name("rack_select")
if self._rack_select_mode:
from gui.components.model_view._scenario_custom_handler import CustomHandlerScenario
mgr.push(CustomHandlerScenario(
name="rack_select",
click_handler=self._on_select_rack_click,
hover_screen_handler=self._on_select_rack_hover_screen,
))
def stop_select_rack_mode(self: "ModelViewWidget") -> None:
# Убрать сценарий взаимодействия
mgr = getattr(self, "_interaction_manager", None)
if mgr is not None:
mgr.pop_by_name("rack_select")
self._rack_select_mode = False
self._clear_hover_rack_highlight()
def start_shelf_placement(self: "ModelViewWidget", rack_id: str, zone_id: str) -> bool:
rid = str(rack_id or "")
zid = str(zone_id or "")
if not rid or not zid:
return False
if hasattr(self, "_clear_selected_rendered_shelf"):
self._clear_selected_rendered_shelf(render=False)
entry = self._get_rack_entry(rid, zid)
if entry is None:
return False
self.stop_rack_placement(clear_preview=True)
self.stop_select_rack_mode()
self._clear_selected_rack_contour(render=False)
self._clear_hover_rack_highlight(render=False)
self._clear_shelf_slot_hover()
self._clear_shelf_slot_active()
self._shelf_target_zone_id = zid
self._shelf_target_rack_id = rid
# Сценарий взаимодействия
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="shelf_placement",
click_handler=self._on_select_shelf_slot_click,
hover_screen_handler=self._on_select_shelf_slot_hover_screen,
))
# Сделать объёмы секций выбираемыми, но почти прозрачными.
for slot_actor in (entry.get("slot_actors") or {}).values():
if slot_actor is None:
continue
try:
slot_actor.SetVisibility(1)
prop = slot_actor.GetProperty()
if prop is not None:
# Прокси секций должны оставаться невидимыми; видны только контуры.
prop.SetOpacity(0.0)
except Exception as _exc:
log_exception(__name__, "start_shelf_placement", _exc)
self._preload_shelf_model_for_entry(entry)
if self._plotter:
self._plotter.update()
slot_ids = [str(sid) for sid in (entry.get("slot_actors") or {}).keys()]
if len(slot_ids) == 1:
self._set_active_shelf_slot(slot_ids[0])
try:
self.shelf_slot_selected.emit(rid, slot_ids[0]) # type: ignore[attr-defined]
except Exception as _exc:
log_exception(__name__, "start_shelf_placement", _exc)
return True
def stop_shelf_placement(self: "ModelViewWidget", clear_selection: bool = True) -> None:
self.stop_shelf_placement_ext(clear_selection=clear_selection, clear_rendered_shelves=True)
def stop_shelf_placement_ext(
self: "ModelViewWidget",
clear_selection: bool = True,
clear_rendered_shelves: bool = True,
) -> None:
# Убрать сценарий взаимодействия
mgr = getattr(self, "_interaction_manager", None)
if mgr is not None:
mgr.pop_by_name("shelf_placement")
self._clear_shelf_slot_hover()
if clear_selection:
self._clear_shelf_slot_active()
if hasattr(self, "_clear_selected_rendered_shelf"):
self._clear_selected_rendered_shelf(render=False)
rid = str(self._shelf_target_rack_id or "")
zid = str(self._shelf_target_zone_id or "")
entry = self._get_rack_entry(rid, zid) if rid and zid else None
if entry is not None:
if clear_rendered_shelves:
self._clear_slot_shelf_actors(rid, clear_slot_id=None)
for slot_id, slot_actor in (entry.get("slot_actors") or {}).items():
if slot_actor is None:
continue
try:
# Вне режима размещения полок прокси-объёмы секций должны быть скрыты.
slot_actor.SetVisibility(0)
prop = slot_actor.GetProperty()
if prop is not None:
prop.SetOpacity(0.35)
prop.SetEdgeVisibility(False)
except Exception as _exc:
log_exception(__name__, "stop_shelf_placement_ext", _exc)
self._shelf_target_zone_id = None
self._shelf_target_rack_id = None
if self._plotter:
self._plotter.update()
def finalize_shelf_layout_for_rack(self: "ModelViewWidget", rack_id: str) -> bool:
rid = str(rack_id or "")
if not rid:
return False
entry = self._get_rack_entry(rid)
if entry is None:
return False
for slot_id in list((entry.get("slot_actors") or {}).keys()):
self.set_rack_slot_occupied(rid, str(slot_id), True)
entry["shelf_helpers_hidden"] = True
self._apply_rack_helper_visibility_policy(entry, True)
self._set_rack_shelf_actors_visibility(rid, True)
self._apply_rack_shelf_opacity(rid)
if self._plotter:
self._plotter.update()
return True
def get_active_shelf_slot(self: "ModelViewWidget") -> tuple[str | None, str | None]:
return (self._shelf_target_rack_id, self._active_shelf_slot_id)
def get_selected_shelf_visual(self: "ModelViewWidget") -> tuple[str | None, str | None, int | None]:
return (
self._selected_shelf_visual_rack_id,
self._selected_shelf_visual_slot_id,
self._selected_shelf_visual_index,
)
def get_rack_shelf_layout(self: "ModelViewWidget", rack_id: str) -> list[dict[str, Any]]:
rid = str(rack_id or "")
if not rid:
return []
layout: list[dict[str, Any]] = []
for (entry_rid, slot_id), payload in self._rack_shelf_params.items():
if str(entry_rid) != rid:
continue
layout.append({"slot_id": str(slot_id), "params": dict(payload or {})})
layout.sort(key=lambda item: self._slot_index_from_id(str(item.get("slot_id") or "")))
return layout
def get_shelf_slot_params(self: "ModelViewWidget", rack_id: str, slot_id: str) -> dict[str, Any]:
rid = str(rack_id or "")
sid = str(slot_id or "")
if not rid or not sid:
return {}
return dict(self._rack_shelf_params.get((rid, sid)) or {})
def activate_shelf_slot(
self: "ModelViewWidget",
rack_id: str,
slot_id: str,
notify: bool = True,
) -> bool:
rid = str(rack_id or "")
sid = str(slot_id or "")
if not rid or not sid:
return False
if str(self._shelf_target_rack_id or "") != rid:
return False
entry = self._get_rack_entry(rid, self._shelf_target_zone_id)
if entry is None:
return False
if sid not in (entry.get("slot_actors") or {}):
return False
self._set_active_shelf_slot(sid)
if notify:
try:
self.shelf_slot_selected.emit(rid, sid) # type: ignore[attr-defined]
except Exception as _exc:
log_exception(__name__, "activate_shelf_slot", _exc)
return True
def restore_rack_shelves(self: "ModelViewWidget", rack_id: str, shelf_rows: list[dict[str, Any]]) -> bool:
rid = str(rack_id or "")
if not rid:
return False
entry = self._get_rack_entry(rid)
if entry is None:
return False
restored = False
for row in list(shelf_rows or []):
slot_id = str(row.get("slot_id") or "S1")
payload = dict(row.get("params") or {})
if not payload:
payload = {
"shelf_code_start": str(row.get("code") or "P01"),
"shelf_depth_mm": int(row.get("d") or 500),
"shelves_count": int(row.get("grid_r") or 1),
"bottom_shelf_height_mm": float(row.get("h") or 300.0),
"height_mode": "uniform",
"useful_height_1_mm": 400.0,
"useful_heights_mm": [400.0],
}
normalized = self._render_shelves_for_slot(rid, slot_id, payload)
if not normalized:
continue
self._rack_shelf_params[(rid, slot_id)] = dict(normalized)
self.set_rack_slot_occupied(rid, slot_id, True)
restored = True
if restored:
entry["shelf_helpers_hidden"] = True
self._apply_rack_helper_visibility_policy(entry, True)
self._set_rack_shelf_actors_visibility(rid, True)
self._apply_rack_shelf_opacity(rid)
if self._plotter:
self._plotter.update()
return restored
def set_active_shelf_slot_params(self: "ModelViewWidget", payload: dict[str, Any]) -> dict[str, Any] | None:
rack_id = str(self._shelf_target_rack_id or "")
slot_id = str(self._active_shelf_slot_id or "")
if not rack_id or not slot_id:
return None
normalized = self._render_shelves_for_slot(rack_id, slot_id, dict(payload or {}))
if not normalized:
return None
self._rack_shelf_params[(rack_id, slot_id)] = dict(normalized)
# Занятая секция скрывается от дальнейшего размещения.
self.set_rack_slot_occupied(rack_id, slot_id, True)
self._apply_rack_shelf_opacity(rack_id)
return dict(normalized)
# ---------------------------------------------------------------------------
# Module workflow notes
# ---------------------------------------------------------------------------
# 1) Задача модуля:
# Сценарии работы с полками стеллажей.
#
# 2) Последовательность действий и вызовов:
# A. Класс RackShelfMixin: точки входа
# Публичные методы сценария:
# - RackShelfMixin.start_select_rack_mode(...)
# - RackShelfMixin.stop_select_rack_mode(...)
# - RackShelfMixin.start_shelf_placement(...)
# - RackShelfMixin.stop_shelf_placement(...)
# - RackShelfMixin.stop_shelf_placement_ext(...)
# - RackShelfMixin.finalize_shelf_layout_for_rack(...)
# - RackShelfMixin.get_active_shelf_slot(...)
# - RackShelfMixin.get_rack_shelf_layout(...)
# - RackShelfMixin.get_shelf_slot_params(...)
# - RackShelfMixin.activate_shelf_slot(...)
# - RackShelfMixin.restore_rack_shelves(...)
# - RackShelfMixin.set_active_shelf_slot_params(...)
#
# B. RackShelfMixin: запуск и настройка:
# RackShelfMixin.start_select_rack_mode(...)
# Назначение: запускает select rack mode в рамках текущего сценария модуля.
# RackShelfMixin.start_shelf_placement(...)
# Назначение: запускает shelf placement в рамках текущего сценария модуля.
# Последовательность внутренних вызовов:
# -> RackShelfMixin.stop_select_rack_mode(...)
# RackShelfMixin.set_active_shelf_slot_params(...)
# Назначение: устанавливает active shelf slot params в рамках текущего сценария модуля.
#
# C. RackShelfMixin: завершение и очистка:
# RackShelfMixin.stop_select_rack_mode(...)
# Назначение: останавливает select rack mode в рамках текущего сценария модуля.
# RackShelfMixin.stop_shelf_placement(...)
# Назначение: останавливает shelf placement в рамках текущего сценария модуля.
# Последовательность внутренних вызовов:
# -> RackShelfMixin.stop_shelf_placement_ext(...)
# RackShelfMixin.stop_shelf_placement_ext(...)
# Назначение: останавливает shelf placement ext в рамках текущего сценария модуля.
# RackShelfMixin.finalize_shelf_layout_for_rack(...)
# Назначение: финализирует shelf layout for rack в рамках текущего сценария модуля.
# RackShelfMixin.restore_rack_shelves(...)
# Назначение: восстанавливает rack shelves в рамках текущего сценария модуля.
#
# D. RackShelfMixin: вспомогательные расчёты:
# RackShelfMixin.get_active_shelf_slot(...)
# Назначение: возвращает active shelf slot в рамках текущего сценария модуля.
# RackShelfMixin.get_rack_shelf_layout(...)
# Назначение: возвращает rack shelf layout в рамках текущего сценария модуля.
# RackShelfMixin.get_shelf_slot_params(...)
# Назначение: возвращает shelf slot params в рамках текущего сценария модуля.
# RackShelfMixin.activate_shelf_slot(...)
# Назначение: выполняет шаг "activate shelf slot" в рамках текущего сценария модуля.
#
# 3) Важные ограничения и инварианты:
# - Модуль выполняется в составе ModelViewWidget и использует согласованные поля состояния self._... .
# - Все операции с актёрами и камерой выполняются только при валидном self._plotter с безопасной обработкой исключений.
# - Межмодульная связность: только через фасад model_view; прямые обращения между zone, rack, shelf, cell запрещены.
# - Очистка состояния должна быть идемпотентной: повторный вызов не меняет корректное состояние в ошибочное.
#
# 4) Правило сопровождения:
# - Любое изменение сценария должно сопровождаться обновлением этого блока с сохранением фактического порядка вызовов.
# - При добавлении метода указывать его место в цепочке сценария (запуск, основной шаг, завершение, вспомогательная логика).

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,404 @@
# -*- coding: utf-8 -*-
# gui/components/model_view/_mv_racks_visual.py
"""Визуализация стоек и вспомогательных объёмов в rack-placement."""
from __future__ import annotations
import math
import re
from pathlib import Path
from typing import Any, TYPE_CHECKING
try:
import pyvista as pv
_PV = True
except ImportError:
_PV = False
from gui.components.model_view._mv_rack_geometry import (
MIN_AISLE_MM,
MIN_WALL_BUFFER_MM,
support_line_positions,
)
from gui.components.model_view._mv_racks_visual_models import RackVisualModelsMixin
if TYPE_CHECKING: # pragma: no cover
from gui.components.model_view_widget import ModelViewWidget
class RackPlacementVisualMixin(RackVisualModelsMixin):
"""Построение actor-ов стеллажа для предпросмотра и фиксации."""
def _spawn_rack_actors(
self: "ModelViewWidget", cx: float, cy: float, zone_id: str,
params: dict[str, Any], rotation: int, color: str, opacity: float,
lightweight: bool = False,
) -> tuple[list, dict[str, Any]]:
if not _PV or not self._plotter:
return [], {}
z_ref = float(self._zone_heights.get(zone_id, (0.0, 0.0))[0])
actors: list[Any] = []
slot_actors: dict[str, Any] = {}
additional_model = dict(params.get("additional_model") or {})
def _add(m, c, o):
return self._plotter.add_mesh(m, color=c, opacity=o, show_edges=False)
if lightweight:
if additional_model.get("model_path"):
body = self._build_additional_model_instance_mesh(cx, cy, z_ref, params, rotation)
else:
body = self._build_lightweight_body_mesh(cx, cy, z_ref, params, rotation)
if body is not None:
actors.append(_add(body, color, opacity))
if not additional_model.get("model_path"):
for av in self._build_aisle_volumes(cx, cy, z_ref, params, rotation):
actors.append(_add(av, (1.0, 0.95, 0.10), max(0.55, opacity * 0.90)))
return actors, {}
if additional_model.get("model_path"):
custom_mesh = self._build_additional_model_instance_mesh(cx, cy, z_ref, params, rotation)
if custom_mesh is not None:
actors.append(_add(custom_mesh, color, opacity))
# Автоопределение опор, если они не сохранены в params (устаревшие данные).
if not additional_model.get("column_footings"):
bm, _w, _d, _h = self._get_additional_model_base_mesh(Path(additional_model["model_path"]))
if bm is not None:
footings, _ch = self._detect_column_footings(bm)
if footings:
additional_model["column_footings"] = footings
params.setdefault("additional_model", {})["column_footings"] = footings
footing_buffer = self._build_footing_buffers_mesh(cx, cy, z_ref, params, rotation)
if footing_buffer is not None:
actors.append(_add(footing_buffer, (0.10, 0.70, 1.00), max(0.75, opacity * 0.95)))
return actors, {}
positions = support_line_positions(cx, cy, params, rotation)
pillars_mesh, pillar_height = self._build_pillars_mesh(positions, z_ref, params, rotation)
if pillars_mesh is not None:
actors.append(_add(pillars_mesh, color, opacity))
for slot_id, mesh in self._build_rack_inner_slot_meshes(
cx, cy, z_ref, params, rotation, pillar_height,
):
actor = _add(mesh, (0.45, 0.65, 0.95), max(0.12, opacity * 0.45))
slot_actors[slot_id] = actor
actors.append(actor)
for av in self._build_aisle_volumes(cx, cy, z_ref, params, rotation):
actors.append(_add(av, (1.0, 0.95, 0.10), max(0.55, opacity * 0.90)))
buffer_mesh = self._build_buffer_mesh(cx, cy, z_ref, params, rotation)
if buffer_mesh is not None:
actors.append(_add(buffer_mesh, (0.10, 0.70, 1.00), max(0.75, opacity * 0.95)))
return actors, slot_actors
# -- облегчённые / стоечные меши -----------------------------------------
def _build_lightweight_body_mesh(
self: "ModelViewWidget", cx: float, cy: float, z_ref: float,
params: dict[str, Any], rotation: int,
):
if not _PV:
return None
width = float(params.get("footprint_width_mm", 1000))
depth = float(params.get("footprint_depth_mm", 500))
height = max(100.0, float(params.get("footprint_height_mm", 1800)))
if int(rotation) % 180 == 90:
width, depth = depth, width
return pv.Cube(
center=(float(cx), float(cy), float(z_ref) + height / 2.0),
x_length=max(10.0, width), y_length=max(10.0, depth), z_length=height,
)
def _build_pillars_mesh(
self: "ModelViewWidget", positions: list[tuple[float, float]],
z_ref: float, params: dict[str, Any], rotation: int = 0,
) -> tuple[Any, float]:
fallback_height = max(100.0, float(params.get("footprint_height_mm", 1800)))
if not _PV or not positions:
return None, fallback_height
base_mesh, pillar_height = self._get_pillar_base_mesh(params)
if base_mesh is None:
parts = [
pv.Cube(
center=(px, py, z_ref + fallback_height / 2.0),
x_length=70.0, y_length=70.0, z_length=fallback_height,
)
for px, py in positions
]
return self._append_polydata_safe(parts), fallback_height
parts = []
for px, py in positions:
item = base_mesh.copy(deep=True)
item.rotate_z(float(int(rotation) % 360), point=(0.0, 0.0, 0.0), inplace=True)
item.translate((px, py, z_ref), inplace=True)
parts.append(item)
return self._append_polydata_safe(parts), float(pillar_height)
def _get_pillar_base_mesh(self: "ModelViewWidget", params: dict[str, Any]) -> tuple[Any, float]:
if not _PV:
return None, 1800.0
rack_type = str(params.get("rack_type", "A")).upper()
depth = int(params.get("depth_mm", 500))
key = (rack_type, depth)
cache = getattr(self, "_pillar_mesh_cache", {})
if key in cache:
return cache[key]
def _put(v):
cache[key] = v; self._pillar_mesh_cache = cache; return v
path = self._find_pillar_model_path(rack_type, depth)
if path is None or not path.exists():
return _put((None, 1800.0))
try:
mesh = pv.read(str(path))
b = mesh.bounds
mesh.translate((-((b[0]+b[1])/2), -((b[2]+b[3])/2), -b[4]), inplace=True)
return _put((mesh, max(100.0, float(b[5] - b[4]))))
except Exception as exc:
log_exception(__name__, "_get_pillar_base_mesh", exc)
return _put((None, 1800.0))
def _find_pillar_model_path(self: "ModelViewWidget", rack_type: str, depth: int) -> Path | None:
root = getattr(self, "_rack_models_root", None)
if root is None:
return None
folder = Path(root) / f"pillars_type_{str(rack_type).lower()}"
if not folder.exists():
return None
token = re.escape(str(rack_type).lower())
pat = re.compile(rf"pillar_type_{token}_(\d+)_(\d+)\.stl$", re.IGNORECASE)
best_path: Path | None = None
best_diff: int | None = None
for fp in folder.glob("*.stl"):
m = pat.search(fp.name)
if not m:
continue
diff = abs(int(m.group(1)) - int(depth))
if best_diff is None or diff < best_diff:
best_diff, best_path = diff, fp
if diff == 0:
break
return best_path
# -- внутренние секции / проход / буфер -----------------------------------
def _build_rack_inner_slot_meshes(
self: "ModelViewWidget", cx: float, cy: float, z_ref: float,
params: dict[str, Any], rotation: int, pillar_height: float,
) -> list[tuple[str, Any]]:
if not _PV:
return []
depth = float(params.get("footprint_depth_mm", 500))
center_spans = [int(v) for v in (params.get("center_spans_mm") or []) if int(v) > 0]
if not center_spans:
center_spans = [int(params.get("footprint_width_mm", 1000))]
z_len = max(100.0, float(pillar_height))
z_center = z_ref + z_len / 2.0
y_len = max(10.0, depth - 60.0)
if str(params.get("rack_type") or "").strip().lower() == "pallet":
z_len = max(100.0, float(params.get("footprint_height_mm", z_len)))
z_center = z_ref + z_len / 2.0
y_len = max(10.0, float(params.get("footprint_depth_mm", 1100)))
total_width = float(params.get("footprint_width_mm", sum(center_spans)))
x_len = max(10.0, total_width)
if int(rotation) % 180 == 90:
x_len, y_len = y_len, x_len
mesh = pv.Cube(
center=(float(cx), float(cy), z_center),
x_length=x_len, y_length=y_len, z_length=z_len,
)
return [("S1", mesh)]
total_width = float(sum(center_spans))
x_cursor = -total_width / 2.0
result: list[tuple[str, Any]] = []
for idx, span in enumerate(center_spans, start=1):
span_len = max(10.0, float(span) - 60.0)
local_x = x_cursor + float(span) / 2.0
world_dx, world_dy = self._rotate_local(local_x, 0.0, rotation)
xl, yl = span_len, y_len
if int(rotation) % 180 == 90:
xl, yl = yl, xl
mesh = pv.Cube(
center=(cx + world_dx, cy + world_dy, z_center),
x_length=xl, y_length=yl, z_length=z_len,
)
result.append((f"S{idx}", mesh))
x_cursor += float(span)
return result
def _build_aisle_volumes(
self: "ModelViewWidget", cx: float, cy: float, z_ref: float,
params: dict[str, Any], rotation: int,
) -> list[Any]:
if not _PV:
return []
w = float(params.get("footprint_width_mm", 1000))
d = float(params.get("footprint_depth_mm", 500))
try:
a = float(self._rack_access_aisle_mm(params))
except Exception as exc:
log_exception(__name__, "_build_aisle_volumes", exc)
a = float(MIN_AISLE_MM)
outer_left = -(w / 2.0 + a)
outer_right = (w / 2.0 + a)
y_near = d / 2.0
y_far = d / 2.0 + a
radius = min(900.0, a, (outer_right - outer_left) * 0.5)
arc_segments = 16
points_local: list[tuple[float, float]] = []
points_local.append((outer_left, y_near))
points_local.append((outer_right, y_near))
points_local.append((outer_right, y_far - radius))
if radius > 1.0:
cx_r = outer_right - radius
cy_r = y_far - radius
for i in range(1, arc_segments + 1):
t = i / float(arc_segments)
ang = 0.0 + (1.5707963267948966 * t)
points_local.append((cx_r + radius * math.cos(ang), cy_r + radius * math.sin(ang)))
points_local.append((outer_left + radius, y_far))
cx_l = outer_left + radius
cy_l = y_far - radius
for i in range(1, arc_segments + 1):
t = i / float(arc_segments)
ang = 1.5707963267948966 + (1.5707963267948966 * t)
points_local.append((cx_l + radius * math.cos(ang), cy_l + radius * math.sin(ang)))
else:
points_local.append((outer_right, y_far))
points_local.append((outer_left, y_far))
points_local.append((outer_left, y_near))
base_z = float(z_ref) + 1.0
points_world = []
for lx, ly in points_local:
wx, wy = self._rotate_local(float(lx), float(ly), rotation)
points_world.append((float(cx + wx), float(cy + wy), base_z))
try:
face = [len(points_world)] + list(range(len(points_world)))
poly = pv.PolyData(points_world, faces=face)
mesh = poly.triangulate().extrude((0.0, 0.0, 5.0), capping=True)
if radius > 1.0:
# Для PALLET _rack_access_aisle_mm(params) возвращает 2300 мм.
# Используем то же значение для боковых доборов, чтобы зона
# доступа была консистентна по глубине и выносу.
side_access_mm = max(0.0, float(a))
rear_limit_y = -d / 2.0
tabs: list[Any] = []
# Боковые доборы pallet: вдоль всей короткой стороны стеллажа
# (по глубине), с выносом по X от боковой грани на side_access_mm.
tab_rects = [
[
(w / 2.0, rear_limit_y),
(w / 2.0 + side_access_mm, rear_limit_y),
(w / 2.0 + side_access_mm, y_near),
(w / 2.0, y_near),
],
[
(-w / 2.0 - side_access_mm, rear_limit_y),
(-w / 2.0, rear_limit_y),
(-w / 2.0, y_near),
(-w / 2.0 - side_access_mm, y_near),
],
]
for rect in tab_rects:
rect_world = []
for lx, ly in rect:
wx, wy = self._rotate_local(float(lx), float(ly), rotation)
rect_world.append((float(cx + wx), float(cy + wy), base_z))
f = [len(rect_world)] + list(range(len(rect_world)))
tabs.append(pv.PolyData(rect_world, faces=f).triangulate().extrude((0.0, 0.0, 5.0), capping=True))
merged = self._append_polydata_safe([mesh] + tabs)
if merged is not None:
mesh = merged
return [mesh]
except Exception as exc:
log_exception(__name__, "_build_aisle_volumes", exc)
return []
def _build_buffer_mesh(
self: "ModelViewWidget", cx: float, cy: float, z_ref: float,
params: dict[str, Any], rotation: int,
):
if not _PV:
return None
depth = max(10.0, float(params.get("footprint_depth_mm", 500)))
b = float(MIN_WALL_BUFFER_MM)
center_spans = [float(v) for v in (params.get("center_spans_mm") or []) if float(v) > 0.0]
if center_spans:
inner_w = max(10.0, float(sum(center_spans)) + 70.0)
else:
inner_w = max(10.0, float(params.get("footprint_width_mm", 1000)))
inner_d = depth
outer_w = inner_w + 2.0 * b
outer_d = inner_d + 2.0 * b
tx = max(10.0, b)
ty = max(10.0, b)
strips = [
(0.0, inner_d / 2.0 + ty / 2.0, outer_w, ty),
(0.0, -(inner_d / 2.0 + ty / 2.0), outer_w, ty),
(inner_w / 2.0 + tx / 2.0, 0.0, tx, outer_d),
(-(inner_w / 2.0 + tx / 2.0), 0.0, tx, outer_d),
]
parts = []
for lx, ly, xl, yl in strips:
wx, wy = self._rotate_local(lx, ly, rotation)
if int(rotation) % 180 == 90:
xl, yl = yl, xl
parts.append(pv.Cube(
center=(cx + wx, cy + wy, z_ref + 5.0),
x_length=max(10.0, xl), y_length=max(10.0, yl), z_length=10.0))
return self._append_polydata_safe(parts)
# ---------------------------------------------------------------------------
# Module workflow notes
# ---------------------------------------------------------------------------
# 1) Задача модуля:
# Построение базовой визуальной геометрии стеллажей и служебных объёмов.
#
# 2) Последовательность действий и вызовов:
# A. Класс RackPlacementVisualMixin: точки входа
# Публичные методы отсутствуют; сценарий запускается через методы родительских модулей и внутренние обработчики.
#
# B. RackPlacementVisualMixin: основной сценарий:
# RackPlacementVisualMixin._find_pillar_model_path(...)
# Назначение: находит pillar model path в рамках текущего сценария модуля.
#
# C. RackPlacementVisualMixin: вспомогательные расчёты:
# RackPlacementVisualMixin._spawn_rack_actors(...)
# Назначение: выполняет шаг "spawn rack actors" в рамках текущего сценария модуля.
# Последовательность внутренних вызовов:
# -> RackPlacementVisualMixin._build_pillars_mesh(...)
# -> RackPlacementVisualMixin._build_rack_inner_slot_meshes(...)
# -> RackPlacementVisualMixin._build_aisle_volumes(...)
# -> RackPlacementVisualMixin._build_buffer_mesh(...)
# -> RackPlacementVisualMixin._build_lightweight_body_mesh(...)
# RackPlacementVisualMixin._build_lightweight_body_mesh(...)
# Назначение: строит lightweight body mesh в рамках текущего сценария модуля.
# RackPlacementVisualMixin._build_pillars_mesh(...)
# Назначение: строит pillars mesh в рамках текущего сценария модуля.
# Последовательность внутренних вызовов:
# -> RackPlacementVisualMixin._get_pillar_base_mesh(...)
# RackPlacementVisualMixin._get_pillar_base_mesh(...)
# Назначение: возвращает pillar base mesh в рамках текущего сценария модуля.
# Последовательность внутренних вызовов:
# -> RackPlacementVisualMixin._find_pillar_model_path(...)
# RackPlacementVisualMixin._build_rack_inner_slot_meshes(...)
# Назначение: строит rack inner slot meshes в рамках текущего сценария модуля.
# RackPlacementVisualMixin._build_aisle_volumes(...)
# Назначение: строит aisle volumes в рамках текущего сценария модуля.
# RackPlacementVisualMixin._build_buffer_mesh(...)
# Назначение: строит buffer mesh в рамках текущего сценария модуля.
#
# 3) Важные ограничения и инварианты:
# - Модуль выполняется в составе ModelViewWidget и использует согласованные поля состояния self._... .
# - Все операции с актёрами и камерой выполняются только при валидном self._plotter с безопасной обработкой исключений.
# - Геометрическая визуализация зависит от pyvista/vtk; при недоступности модуль обязан завершать шаг без падения сценария.
# - Межмодульная связность: только через фасад model_view; прямые обращения между zone, rack, shelf, cell запрещены.
# - Очистка состояния должна быть идемпотентной: повторный вызов не меняет корректное состояние в ошибочное.
#
# 4) Правило сопровождения:
# - Любое изменение сценария должно сопровождаться обновлением этого блока с сохранением фактического порядка вызовов.
# - При добавлении метода указывать его место в цепочке сценария (запуск, основной шаг, завершение, вспомогательная логика).

View File

@@ -0,0 +1,372 @@
# -*- coding: utf-8 -*-
# gui/components/model_view/_mv_racks_visual_models.py
"""Логика импорта / обнаружения моделей для пользовательских STL-моделей стеллажей."""
from __future__ import annotations
import math
from pathlib import Path
from typing import Any, TYPE_CHECKING
try:
import pyvista as pv
_PV = True
except ImportError:
_PV = False
from gui.components.model_view._mv_rack_geometry import MIN_WALL_BUFFER_MM
from error_logger import log_exception
if TYPE_CHECKING: # pragma: no cover
from gui.components.model_view_widget import ModelViewWidget
class RackVisualModelsMixin:
"""Вспомогательные методы пользовательских моделей: импорт STL, определение опор, буферы."""
def build_additional_model_params(self: "ModelViewWidget", model_path: Path) -> dict[str, Any] | None:
mesh, width, depth, height = self._get_additional_model_base_mesh(Path(model_path))
if mesh is None:
return None
width_mm = int(max(100, math.ceil(float(width) / 10.0) * 10))
depth_mm = int(max(100, math.ceil(float(depth) / 10.0) * 10))
height_mm = int(max(100, math.ceil(float(height) / 10.0) * 10))
# Определение опор колонн и высоты просвета из меша.
footings, clearance_h = self._detect_column_footings(mesh)
if clearance_h > 0:
height_mm = int(max(100, math.ceil(float(clearance_h) / 10.0) * 10))
model_name = Path(model_path).name
lower_name = model_name.lower()
is_mezzanine = any(tag in lower_name for tag in ("mezo", "mezz", "мез", "mezan", "mezon"))
return {
"rack_type": "CUSTOM",
"depth_mm": depth_mm,
"spans_count": 1,
"uniform_width": True,
"span_widths_mm": [width_mm],
"center_spans_mm": [width_mm],
"footprint_width_mm": width_mm,
"footprint_depth_mm": depth_mm,
"footprint_height_mm": height_mm,
"pillars_count": len(footings) if footings else 4,
"additional_model": {
"model_path": str(Path(model_path)),
"model_name": model_name,
"model_kind": "mezo" if is_mezzanine else "custom",
"is_mezzanine": bool(is_mezzanine),
"clearance_height_mm": height_mm,
"column_footings": footings,
},
}
def _build_additional_model_instance_mesh(
self: "ModelViewWidget",
cx: float,
cy: float,
z_ref: float,
params: dict[str, Any],
rotation: int,
):
additional_model = dict(params.get("additional_model") or {})
model_path = additional_model.get("model_path")
if not model_path:
return None
base_mesh, _w, _d, _h = self._get_additional_model_base_mesh(Path(model_path))
if base_mesh is None:
return None
try:
mesh = base_mesh.copy(deep=True)
mesh.rotate_z(float(int(rotation) % 360), point=(0.0, 0.0, 0.0), inplace=True)
mesh.translate((float(cx), float(cy), float(z_ref)), inplace=True)
return mesh
except Exception as exc:
log_exception(__name__, "_build_additional_model_instance_mesh", exc)
return None
def _get_additional_model_base_mesh(self: "ModelViewWidget", model_path: Path) -> tuple[Any, float, float, float]:
if not _PV:
return None, 0.0, 0.0, 0.0
cache = getattr(self, "_additional_model_mesh_cache", {})
key = str(Path(model_path).resolve())
cached = cache.get(key)
if cached is not None:
return cached
file_path = Path(model_path)
if not file_path.is_absolute() and not file_path.exists():
# Относительный путь из БД: пытаемся разрешить относительно корня проекта.
racks_root = getattr(self, "_rack_models_root", None)
if racks_root is not None:
try:
project_root = Path(racks_root).resolve().parent.parent
file_path = project_root / file_path
except Exception as _exc:
log_exception(__name__, "_get_additional_model_base_mesh", _exc)
if not file_path.exists():
cache[key] = (None, 0.0, 0.0, 0.0)
self._additional_model_mesh_cache = cache
return cache[key]
try:
mesh = pv.read(str(file_path))
if mesh is None:
cache[key] = (None, 0.0, 0.0, 0.0)
self._additional_model_mesh_cache = cache
return cache[key]
bounds = mesh.bounds
width = max(10.0, float(bounds[1] - bounds[0]))
depth = max(10.0, float(bounds[3] - bounds[2]))
height = max(10.0, float(bounds[5] - bounds[4]))
cx = (bounds[0] + bounds[1]) / 2.0
cy = (bounds[2] + bounds[3]) / 2.0
min_z = bounds[4]
mesh.translate((-cx, -cy, -min_z), inplace=True)
cache[key] = (mesh, width, depth, height)
self._additional_model_mesh_cache = cache
return cache[key]
except Exception as exc:
log_exception(__name__, "_get_additional_model_base_mesh", exc)
cache[key] = (None, 0.0, 0.0, 0.0)
self._additional_model_mesh_cache = cache
return cache[key]
# -- утилиты ---------------------------------------------------------------
def _append_polydata_safe(self: "ModelViewWidget", parts: list[Any]):
if not parts:
return None
try:
return pv.append_polydata(parts)
except Exception as exc:
log_exception(__name__, "_append_polydata_safe", exc)
merged = parts[0]
for item in parts[1:]:
try:
merged = merged.merge(item, merge_points=False)
except Exception as _exc:
log_exception(__name__, "_append_polydata_safe", _exc)
return merged
def _rotate_local(self: "ModelViewWidget", x: float, y: float, rotation: int) -> tuple[float, float]:
rot = int(rotation) % 360
if rot == 90:
return -y, x
if rot == 180:
return -x, -y
if rot == 270:
return y, -x
return x, y
# -- Анализ опор колонн для пользовательских STL-моделей ------------------
def _detect_column_footings(
self: "ModelViewWidget",
base_mesh: Any,
) -> tuple[list[dict[str, float]], float]:
"""Определить позиции опор колонн и высоту просвета палубы из меша.
Анализирует нижнюю геометрию меша, касающуюся основания, для поиска
изолированных кластеров (оснований колонн / опор). Также определяет высоту
просвета под нижней горизонтальной палубой между колоннами.
Returns
-------
footings : list[dict]
Каждый словарь содержит ключи ``cx``, ``cy``, ``x_min``, ``y_min``,
``x_max``, ``y_max`` (все в локальных координатах модели, начало
в центре-снизу).
clearance_h : float
Высота (мм) от основания до нижней стороны самой низкой палубы,
перекрывающей пространство между колоннами. ``0.0``, если не удаётся определить.
"""
try:
import numpy as np
except ImportError:
return [], 0.0
if base_mesh is None or not hasattr(base_mesh, "points"):
return [], 0.0
pts = np.asarray(base_mesh.points)
if len(pts) == 0:
return [], 0.0
z_min = float(pts[:, 2].min())
z_max = float(pts[:, 2].max())
total_h = z_max - z_min
# --- 1. Собрать нижние точки (в пределах 20 мм от основания) ---------
eps_z = 20.0
bottom_mask = pts[:, 2] < (z_min + eps_z)
bottom_xy = pts[bottom_mask][:, :2]
if len(bottom_xy) < 3:
return [], 0.0
# --- 2. Кластеризация на основе сетки (cell_size = 200 мм) ----------
cell_size = 200.0
from collections import defaultdict
cell_pts: dict[tuple[int, int], list[int]] = defaultdict(list)
for idx in range(len(bottom_xy)):
x, y = float(bottom_xy[idx, 0]), float(bottom_xy[idx, 1])
key = (int(round(x / cell_size)), int(round(y / cell_size)))
cell_pts[key].append(idx)
if not cell_pts:
return [], 0.0
# Поиск объединений (Union-Find)
parent: dict[tuple[int, int], tuple[int, int]] = {k: k for k in cell_pts}
def _find(a: tuple[int, int]) -> tuple[int, int]:
while parent[a] != a:
parent[a] = parent[parent[a]]
a = parent[a]
return a
def _union(a: tuple[int, int], b: tuple[int, int]) -> None:
parent[_find(a)] = _find(b)
for k in list(cell_pts.keys()):
for dx in (-1, 0, 1):
for dy in (-1, 0, 1):
n = (k[0] + dx, k[1] + dy)
if n in cell_pts:
_union(k, n)
clusters: dict[tuple[int, int], list[int]] = defaultdict(list)
for k, indices in cell_pts.items():
clusters[_find(k)].extend(indices)
# Для определения колонн требуется не менее 2 изолированных кластеров.
if len(clusters) < 2:
return [], 0.0
footings: list[dict[str, float]] = []
for indices in clusters.values():
cxy = bottom_xy[indices]
x_min_f = float(cxy[:, 0].min())
x_max_f = float(cxy[:, 0].max())
y_min_f = float(cxy[:, 1].min())
y_max_f = float(cxy[:, 1].max())
footings.append({
"cx": round((x_min_f + x_max_f) / 2.0, 1),
"cy": round((y_min_f + y_max_f) / 2.0, 1),
"x_min": round(x_min_f, 1),
"y_min": round(y_min_f, 1),
"x_max": round(x_max_f, 1),
"y_max": round(y_max_f, 1),
})
# --- 3. Определение высоты просвета ----------------------------------
all_cx = [f["cx"] for f in footings]
all_cy = [f["cy"] for f in footings]
mid_x = (min(all_cx) + max(all_cx)) / 2.0
mid_y = (min(all_cy) + max(all_cy)) / 2.0
search_r = max(cell_size, total_h * 0.05)
centre_mask = (
(np.abs(pts[:, 0] - mid_x) < search_r)
& (np.abs(pts[:, 1] - mid_y) < search_r)
)
centre_z = pts[centre_mask, 2] if centre_mask.any() else np.array([])
clearance_h = 0.0
if len(centre_z) > 0:
lowest_centre_z = float(centre_z.min())
clearance_h = max(0.0, lowest_centre_z - z_min)
return footings, clearance_h
def _build_footing_buffers_mesh(
self: "ModelViewWidget",
cx: float,
cy: float,
z_ref: float,
params: dict[str, Any],
rotation: int,
):
"""Построить единый внешний контур буферной зоны по крайним пяткам."""
if not _PV:
return None
additional_model = dict(params.get("additional_model") or {})
footings = additional_model.get("column_footings")
if not footings:
return None
b = float(MIN_WALL_BUFFER_MM)
x_min = min(float(ft.get("x_min", 0.0)) for ft in footings)
x_max = max(float(ft.get("x_max", 0.0)) for ft in footings)
y_min = min(float(ft.get("y_min", 0.0)) for ft in footings)
y_max = max(float(ft.get("y_max", 0.0)) for ft in footings)
inner_w = max(10.0, x_max - x_min)
inner_d = max(10.0, y_max - y_min)
local_cx = (x_min + x_max) / 2.0
local_cy = (y_min + y_max) / 2.0
outer_w = inner_w + 2.0 * b
outer_d = inner_d + 2.0 * b
strips = [
(local_cx, local_cy + inner_d / 2.0 + b / 2.0, outer_w, b),
(local_cx, local_cy - (inner_d / 2.0 + b / 2.0), outer_w, b),
(local_cx + inner_w / 2.0 + b / 2.0, local_cy, b, outer_d),
(local_cx - (inner_w / 2.0 + b / 2.0), local_cy, b, outer_d),
]
parts: list[Any] = []
for lx, ly, xl, yl in strips:
wx, wy = self._rotate_local(lx, ly, rotation)
if int(rotation) % 180 == 90:
xl, yl = yl, xl
parts.append(
pv.Cube(
center=(cx + wx, cy + wy, z_ref + 5.0),
x_length=max(10.0, xl),
y_length=max(10.0, yl),
z_length=10.0,
)
)
return self._append_polydata_safe(parts) if parts else None
# ---------------------------------------------------------------------------
# Module workflow notes
# ---------------------------------------------------------------------------
# 1) Задача модуля:
# Дополнительные модели стеллажей и буферы оснований и опор.
#
# 2) Последовательность действий и вызовов:
# A. Класс RackVisualModelsMixin: точки входа
# Публичные методы сценария:
# - RackVisualModelsMixin.build_additional_model_params(...)
#
# B. RackVisualModelsMixin: вспомогательные расчёты:
# RackVisualModelsMixin.build_additional_model_params(...)
# Назначение: строит additional model params в рамках текущего сценария модуля.
# Последовательность внутренних вызовов:
# -> RackVisualModelsMixin._get_additional_model_base_mesh(...)
# -> RackVisualModelsMixin._detect_column_footings(...)
# RackVisualModelsMixin._build_additional_model_instance_mesh(...)
# Назначение: строит additional model instance mesh в рамках текущего сценария модуля.
# Последовательность внутренних вызовов:
# -> RackVisualModelsMixin._get_additional_model_base_mesh(...)
# RackVisualModelsMixin._get_additional_model_base_mesh(...)
# Назначение: возвращает additional model base mesh в рамках текущего сценария модуля.
# RackVisualModelsMixin._append_polydata_safe(...)
# Назначение: добавляет polydata safe в рамках текущего сценария модуля.
# RackVisualModelsMixin._rotate_local(...)
# Назначение: выполняет шаг "rotate local" в рамках текущего сценария модуля.
# RackVisualModelsMixin._detect_column_footings(...)
# Назначение: Определить позиции опор колонн и высоту просвета палубы из меша.
# RackVisualModelsMixin._build_footing_buffers_mesh(...)
# Назначение: Построить буферные контурные объёмы вокруг каждой опоры колонны.
# Последовательность внутренних вызовов:
# -> RackVisualModelsMixin._append_polydata_safe(...)
# -> RackVisualModelsMixin._rotate_local(...)
#
# 3) Важные ограничения и инварианты:
# - Модуль выполняется в составе ModelViewWidget и использует согласованные поля состояния self._... .
# - Геометрическая визуализация зависит от pyvista/vtk; при недоступности модуль обязан завершать шаг без падения сценария.
# - Межмодульная связность: только через фасад model_view; прямые обращения между zone, rack, shelf, cell запрещены.
# - Очистка состояния должна быть идемпотентной: повторный вызов не меняет корректное состояние в ошибочное.
#
# 4) Правило сопровождения:
# - Любое изменение сценария должно сопровождаться обновлением этого блока с сохранением фактического порядка вызовов.
# - При добавлении метода указывать его место в цепочке сценария (запуск, основной шаг, завершение, вспомогательная логика).

View File

@@ -0,0 +1,163 @@
# -*- coding: utf-8 -*-
# gui/components/model_view/_mv_scene_modes.py
"""Высокоуровневые переходы между режимами сцены для ModelViewWidget."""
from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from gui.components.model_view_widget import ModelViewWidget
class SceneModesMixin:
"""Фасад переходов между режимами facility/zone."""
def _apply_facility_shell_visibility(self: "ModelViewWidget") -> None:
"""Профиль оболочки уровня facility: активен только пол, остальные слои отключены."""
layer_defaults = (
("floor", True),
("walls", False),
("ceiling", False),
("truss", False),
)
for key, should_show in layer_defaults:
is_available = bool(self.has_model(key)) if hasattr(self, "has_model") else True
self.set_model_visibility(key, bool(should_show and is_available))
def set_zone_pick_mode(self: "ModelViewWidget", enabled: bool) -> None:
"""Переключить режим выбора зоны с обновлением сцены."""
self.set_zone_pick_enabled(bool(enabled))
self.update_scene()
def enter_facility_overview(self: "ModelViewWidget") -> None:
"""Сбросить сцену к обзору уровня facility."""
self.stop_select_rack_mode()
if hasattr(self, "stop_shelf_placement"):
self.stop_shelf_placement(clear_selection=True)
self.show_all_zones()
self.show_all_racks()
self._apply_facility_shell_visibility()
self.stop_rack_placement(clear_preview=True)
self.set_zone_pick_enabled(True)
self.update_scene()
def enter_zone_overview(self: "ModelViewWidget", zone_id: str, *, focus: bool = True) -> None:
"""Переключить сцену в режим контура зоны + выбор стеллажей."""
zid = str(zone_id or "")
if not zid:
return
self.clear_selected_zone_highlight()
self.set_zone_pick_enabled(False)
if hasattr(self, "stop_shelf_placement"):
self.stop_shelf_placement(clear_selection=True)
self.show_zone_contour(zid)
self.show_only_zone_racks(zid)
self.stop_rack_placement(clear_preview=True)
self.start_select_rack_mode(zid)
if focus and hasattr(self, "focus_on_zone_isometric"):
self.focus_on_zone_isometric(zid)
self.update_scene()
def prepare_rack_placement(self: "ModelViewWidget") -> None:
"""Выйти из режима выбора стеллажа перед началом размещения."""
self.stop_select_rack_mode()
self.update_scene()
def restore_zone_rack_selection(
self: "ModelViewWidget",
zone_id: str | None,
*,
zone_view_active: bool,
) -> None:
"""Остановить превью размещения и восстановить режим выбора стеллажей."""
self.stop_rack_placement(clear_preview=True)
zid = str(zone_id or "")
if zone_view_active and zid:
self.start_select_rack_mode(zid)
else:
self.stop_select_rack_mode()
self.update_scene()
def apply_zone_edit_visibility(self: "ModelViewWidget") -> None:
"""Скрыть слои сцены/стеллажи для упрощения редактирования контура зоны."""
for key in ("floor", "walls", "ceiling", "truss"):
self.set_model_visibility(key, False)
self.stop_select_rack_mode()
self.show_only_zone_racks(None)
self.update_scene()
def restore_zone_edit_visibility(
self: "ModelViewWidget",
*,
model_visibility: dict[str, bool] | None,
view_level: str,
zone_id: str | None,
rack_id: str | None,
) -> None:
"""Восстановить состояние сцены, сохранённое перед режимом редактирования зоны."""
state = dict(model_visibility or {})
for key in ("floor", "walls", "ceiling", "truss"):
self.set_model_visibility(key, bool(state.get(key, True)))
zid = str(zone_id or "")
rid = str(rack_id or "")
level = str(view_level or "facility")
if level == "rack" and zid and rid and hasattr(self, "show_only_rack"):
self.show_only_rack(rid, zid)
elif level == "zone" and zid:
self.show_only_zone_racks(zid)
else:
self.show_all_racks()
self.update_scene()
# ---------------------------------------------------------------------------
# Module workflow notes
# ---------------------------------------------------------------------------
# 1) Задача модуля:
# Переключение режимов сцены и правил видимости между facility, zone и rack.
#
# 2) Последовательность действий и вызовов:
# A. Класс SceneModesMixin: точки входа
# Публичные методы сценария:
# - SceneModesMixin.set_zone_pick_mode(...)
# - SceneModesMixin.enter_facility_overview(...)
# - SceneModesMixin.enter_zone_overview(...)
# - SceneModesMixin.prepare_rack_placement(...)
# - SceneModesMixin.restore_zone_rack_selection(...)
# - SceneModesMixin.apply_zone_edit_visibility(...)
# - SceneModesMixin.restore_zone_edit_visibility(...)
#
# B. SceneModesMixin: запуск и настройка:
# SceneModesMixin.set_zone_pick_mode(...)
# Назначение: Переключить режим выбора зоны с обновлением сцены.
# SceneModesMixin.enter_facility_overview(...)
# Назначение: Сбросить сцену к обзору уровня facility.
# Последовательность внутренних вызовов:
# -> SceneModesMixin._apply_facility_shell_visibility(...)
# SceneModesMixin.enter_zone_overview(...)
# Назначение: Переключить сцену в режим контура зоны + выбор стеллажей.
# SceneModesMixin.prepare_rack_placement(...)
# Назначение: Выйти из режима выбора стеллажа перед началом размещения.
#
# C. SceneModesMixin: основной сценарий:
# SceneModesMixin._apply_facility_shell_visibility(...)
# Назначение: Профиль оболочки уровня facility: активен только пол, остальные слои отключены.
# SceneModesMixin.apply_zone_edit_visibility(...)
# Назначение: Скрыть слои сцены/стеллажи для упрощения редактирования контура зоны.
#
# D. SceneModesMixin: завершение и очистка:
# SceneModesMixin.restore_zone_rack_selection(...)
# Назначение: Остановить превью размещения и восстановить режим выбора стеллажей.
# SceneModesMixin.restore_zone_edit_visibility(...)
# Назначение: Восстановить состояние сцены, сохранённое перед режимом редактирования зоны.
#
# 3) Важные ограничения и инварианты:
# - Модуль выполняется в составе ModelViewWidget и использует согласованные поля состояния self._... .
# - Межмодульная связность: только через фасад model_view; прямые обращения между zone, rack, shelf, cell запрещены.
# - Очистка состояния должна быть идемпотентной: повторный вызов не меняет корректное состояние в ошибочное.
#
# 4) Правило сопровождения:
# - Любое изменение сценария должно сопровождаться обновлением этого блока с сохранением фактического порядка вызовов.
# - При добавлении метода указывать его место в цепочке сценария (запуск, основной шаг, завершение, вспомогательная логика).

View File

@@ -0,0 +1,625 @@
# -*- coding: utf-8 -*-
# gui/components/_mv/_mv_visual.py
# Маркеры, квадранты, оси, координатные преобразования
from __future__ import annotations
import math
from typing import Optional, Tuple, TYPE_CHECKING
from error_logger import log_exception
if TYPE_CHECKING: # pragma: no cover
from gui.components.model_view_widget import ModelViewWidget
try:
import pyvista as pv
_PV = True
except ImportError:
_PV = False
class VisualHelpersMixin:
"""Маркер начала координат, квадранты, оси, world↔display преобразования."""
# -- маркер начала координат ----------------------------------------------
_ORIGIN_MARKER_NAME = "__origin_marker"
_ORIGIN_PREVIEW_MARKER_NAME = "__origin_preview_marker"
def show_origin_marker(self: "ModelViewWidget", x: float, y: float, z: float) -> None:
if not self._plotter:
return
try:
self._plotter.remove_actor(self._ORIGIN_MARKER_NAME)
except Exception as _exc:
log_exception(__name__, "show_origin_marker", _exc)
try:
sphere = pv.Sphere(radius=80, center=(x, y, z))
self._origin_marker = self._plotter.add_mesh(
sphere, color=(1, 1, 0), opacity=0.8, name=self._ORIGIN_MARKER_NAME,
)
self._plotter.update()
except Exception as _exc:
log_exception(__name__, "show_origin_marker", _exc)
def show_origin_preview_marker(self: "ModelViewWidget", x: float, y: float, z: float) -> None:
"""Показать динамический маркер origin, следующий за курсором."""
if not self._plotter:
return
try:
self._plotter.remove_actor(self._ORIGIN_PREVIEW_MARKER_NAME)
except Exception as _exc:
log_exception(__name__, "show_origin_preview_marker", _exc)
try:
sphere = pv.Sphere(radius=55, center=(x, y, z))
self._origin_preview_marker = self._plotter.add_mesh(
sphere,
color=(0.2, 0.9, 1.0),
opacity=0.7,
name=self._ORIGIN_PREVIEW_MARKER_NAME,
)
self._plotter.update()
except Exception as _exc:
log_exception(__name__, "show_origin_preview_marker", _exc)
def clear_origin_marker(self: "ModelViewWidget") -> None:
if not self._plotter:
return
try:
self._plotter.remove_actor(self._ORIGIN_MARKER_NAME)
except Exception as _exc:
log_exception(__name__, "clear_origin_marker", _exc)
self._origin_marker = None
try:
if hasattr(self, "_safe_render"):
self._safe_render(min_interval_s=0.05)
else:
self._plotter.render()
except Exception as _exc:
log_exception(__name__, "clear_origin_marker", _exc)
def clear_origin_preview_marker(self: "ModelViewWidget") -> None:
if not self._plotter:
return
try:
self._plotter.remove_actor(self._ORIGIN_PREVIEW_MARKER_NAME)
except Exception as _exc:
log_exception(__name__, "clear_origin_preview_marker", _exc)
self._origin_preview_marker = None
try:
if hasattr(self, "_safe_render"):
self._safe_render(min_interval_s=0.05)
else:
self._plotter.render()
except Exception as _exc:
log_exception(__name__, "clear_origin_preview_marker", _exc)
def clear_origin_markers(self: "ModelViewWidget") -> None:
"""Очистить фиксированный и динамический маркеры origin."""
self.clear_origin_preview_marker()
self.clear_origin_marker()
# -- угловые точки --------------------------------------------------------
def set_corner_points(self: "ModelViewWidget", points: list[tuple[float, float, float]]) -> None:
self._corner_points = list(points)
def get_corner_points(self: "ModelViewWidget") -> list[tuple[float, float, float]]:
return list(self._corner_points)
# -- квадранты -------------------------------------------------------------
def show_quadrants(self: "ModelViewWidget", origin: Tuple[float, float, float], size: float = 6000.0) -> None:
if not self._plotter:
return
self.clear_quadrants()
x0, y0, z0 = origin
half = size / 2.0
# Фиксированная высота квадранта: 100 мм.
z_min = z0
z_max = z0 + 100.0
quadrant_bounds = [
((x0, x0 + half), (y0, y0 + half)), # 0: ++
((x0 - half, x0), (y0, y0 + half)), # 1: -+
((x0 - half, x0), (y0 - half, y0)), # 2: --
((x0, x0 + half), (y0 - half, y0)), # 3: +-
]
colors = [(0.6, 0.6, 0.6)] * 4
for idx, (x_range, y_range) in enumerate(quadrant_bounds):
x_min, x_max = x_range
y_min, y_max = y_range
box = pv.Box(bounds=(x_min, x_max, y_min, y_max, z_min, z_max))
name = f"__quadrant_plane_{idx}"
actor = self._plotter.add_mesh(
box,
color=colors[idx],
opacity=0.12,
show_edges=True,
edge_color=(0.7, 0.7, 0.7),
line_width=1.0,
name=name,
)
try:
actor.PickableOn()
except Exception as _exc:
log_exception(__name__, "show_quadrants", _exc)
self._quadrant_actors.append(actor)
for idx, (x_range, y_range) in enumerate(quadrant_bounds):
x_min, x_max = x_range
y_min, y_max = y_range
center_x = (x_min + x_max) / 2.0
center_y = (y_min + y_max) / 2.0
label_pos = [center_x, center_y, z0 + 100]
name = f"__quadrant_label_{idx}"
label_actor = self._plotter.add_point_labels(
[label_pos],
[f"Q{idx}"],
font_size=72,
text_color="yellow",
point_color="yellow",
point_size=30,
render_points_as_spheres=True,
always_visible=True,
shape_opacity=0.9,
name=name,
)
try:
if label_actor is not None:
if hasattr(label_actor, "PickableOff"):
label_actor.PickableOff()
elif hasattr(label_actor, "SetPickable"):
label_actor.SetPickable(False)
except Exception as _exc:
log_exception(__name__, "show_quadrants", _exc)
self._quadrant_label_actors.append(name)
self._plotter.update()
def animate_focus_on_quadrants(
self: "ModelViewWidget",
*,
duration_ms: int = 420,
margin_factor: float = 1.2,
) -> bool:
"""Плавно сфокусировать камеру так, чтобы текущие квадранты целиком были в кадре."""
if not self._plotter:
return False
cam = getattr(self._plotter, "camera", None)
if cam is None:
return False
actors = list(getattr(self, "_quadrant_actors", []) or [])
if not actors:
return False
try:
min_x = float("inf")
max_x = float("-inf")
min_y = float("inf")
max_y = float("-inf")
min_z = float("inf")
max_z = float("-inf")
for actor in actors:
if actor is None or not hasattr(actor, "GetBounds"):
continue
b = actor.GetBounds()
if not b or len(b) < 6:
continue
min_x = min(min_x, float(b[0]))
max_x = max(max_x, float(b[1]))
min_y = min(min_y, float(b[2]))
max_y = max(max_y, float(b[3]))
min_z = min(min_z, float(b[4]))
max_z = max(max_z, float(b[5]))
if not all(math.isfinite(v) for v in (min_x, max_x, min_y, max_y, min_z, max_z)):
return False
cx = (min_x + max_x) * 0.5
cy = (min_y + max_y) * 0.5
sx = max(1.0, max_x - min_x)
sy = max(1.0, max_y - min_y)
sz = max(1.0, max_z - min_z)
span = max(sx, sy, sz) * max(1.0, float(margin_factor))
cz = min_z + (sz * 0.55)
start_pos = tuple(float(v) for v in cam.GetPosition())
start_focal = tuple(float(v) for v in cam.GetFocalPoint())
start_up = tuple(float(v) for v in cam.GetViewUp())
vx, vy, vz = 1.0, -1.0, 0.72
norm = (vx * vx + vy * vy + vz * vz) ** 0.5
vx, vy, vz = vx / norm, vy / norm, vz / norm
ux, uy, uz = 0.0, 0.0, 1.0
distance = max(3000.0, span * 2.35)
target_focal = (cx, cy, cz)
target_pos = (
cx + vx * distance,
cy + vy * distance,
cz + vz * distance + (sz * 0.08),
)
if hasattr(self, "_animate_camera_transition"):
self._animate_camera_transition(
start_pos=start_pos,
start_focal=start_focal,
start_up=start_up,
target_pos=target_pos,
target_focal=target_focal,
target_up=(ux, uy, uz),
duration_ms=int(duration_ms),
steps=18,
)
else:
cam.SetPosition(*target_pos)
cam.SetFocalPoint(*target_focal)
if hasattr(cam, "SetViewUp"):
cam.SetViewUp(ux, uy, uz)
if hasattr(self, "_reset_camera_clipping_range"):
self._reset_camera_clipping_range()
self._plotter.update()
return True
except Exception as exc:
log_exception(__name__, "animate_focus_on_quadrants", exc)
return False
def show_grid_step_quadrants(
self: "ModelViewWidget",
origin: Tuple[float, float, float],
direction: Tuple[float, float],
step_values: list[int],
cell_size: float = 3000.0,
height: float = 100.0,
) -> None:
"""Показать 6 площадок выбора шага сетки (3x2) в положительных локальных координатах."""
if not self._plotter:
return
self.clear_quadrants()
x0, y0, z0 = origin
dir_x = 1.0 if float(direction[0]) >= 0.0 else -1.0
dir_y = 1.0 if float(direction[1]) >= 0.0 else -1.0
z_min = float(z0)
z_max = float(z0) + float(height)
grid_actors = list(getattr(self, "_quadrant_grid_actors", []) or [])
for actor in grid_actors:
try:
self._plotter.remove_actor(actor)
except Exception as _exc:
log_exception(__name__, "show_grid_step_quadrants", _exc)
self._quadrant_grid_actors = []
def _add_segment(
pts: list[list[float]],
cells: list[int],
p1: tuple[float, float, float],
p2: tuple[float, float, float],
) -> None:
i1 = len(pts)
pts.append([float(p1[0]), float(p1[1]), float(p1[2])])
i2 = len(pts)
pts.append([float(p2[0]), float(p2[1]), float(p2[2])])
cells.extend([2, i1, i2])
for idx, step in enumerate(step_values):
row = idx // 3
col = idx % 3
lx0 = float(col) * float(cell_size)
lx1 = float(col + 1) * float(cell_size)
ly0 = float(row) * float(cell_size)
ly1 = float(row + 1) * float(cell_size)
wx0 = float(x0) + lx0 * dir_x
wx1 = float(x0) + lx1 * dir_x
wy0 = float(y0) + ly0 * dir_y
wy1 = float(y0) + ly1 * dir_y
x_min, x_max = (min(wx0, wx1), max(wx0, wx1))
y_min, y_max = (min(wy0, wy1), max(wy0, wy1))
box = pv.Box(bounds=(x_min, x_max, y_min, y_max, z_min, z_max))
name = f"__quadrant_plane_{idx}"
actor = self._plotter.add_mesh(
box,
color=(0.6, 0.6, 0.6),
opacity=0.12,
show_edges=True,
edge_color=(0.7, 0.7, 0.7),
line_width=1.0,
name=name,
)
try:
actor.PickableOn()
except Exception as _exc:
log_exception(__name__, "_add_segment", _exc)
self._quadrant_actors.append(actor)
# Явная разметка сеткой с шагом площадки (поверх плоскости).
line_points: list[list[float]] = []
line_cells: list[int] = []
z_grid = float(z_max) + 0.8
step_size = max(10.0, float(step))
x_pos = float(x_min)
while x_pos <= float(x_max) + 1e-6:
_add_segment(line_points, line_cells, (x_pos, y_min, z_grid), (x_pos, y_max, z_grid))
x_pos += step_size
y_pos = float(y_min)
while y_pos <= float(y_max) + 1e-6:
_add_segment(line_points, line_cells, (x_min, y_pos, z_grid), (x_max, y_pos, z_grid))
y_pos += step_size
if line_points and line_cells:
try:
grid_lines = pv.PolyData(line_points)
grid_lines.lines = line_cells
grid_actor = self._plotter.add_mesh(
grid_lines,
color=(0.0, 0.95, 1.0),
line_width=2.0,
opacity=0.95,
lighting=False,
)
self._quadrant_grid_actors.append(grid_actor)
except Exception as _exc:
log_exception(__name__, "_add_segment", _exc)
label_pos = [(x_min + x_max) / 2.0, (y_min + y_max) / 2.0, float(z0) + float(height)]
label_name = f"__quadrant_label_{idx}"
label_actor = self._plotter.add_point_labels(
[label_pos],
[f"{int(step)} мм"],
font_size=56,
text_color="yellow",
point_color="yellow",
point_size=24,
render_points_as_spheres=True,
always_visible=True,
shape_opacity=0.9,
name=label_name,
)
try:
if label_actor is not None:
if hasattr(label_actor, "PickableOff"):
label_actor.PickableOff()
elif hasattr(label_actor, "SetPickable"):
label_actor.SetPickable(False)
except Exception as _exc:
log_exception(__name__, "_add_segment", _exc)
self._quadrant_label_actors.append(label_name)
self._plotter.update()
def highlight_quadrant(self: "ModelViewWidget", index: int) -> None:
if not self._plotter:
return
for i, actor in enumerate(self._quadrant_actors):
try:
if i == index:
actor.GetProperty().SetOpacity(1.0)
actor.GetProperty().SetColor(1.0, 1.0, 0.0)
else:
actor.GetProperty().SetOpacity(0.12)
actor.GetProperty().SetColor(0.6, 0.6, 0.6)
except Exception as _exc:
log_exception(__name__, "highlight_quadrant", _exc)
try:
if hasattr(self, "_safe_render"):
self._safe_render(min_interval_s=0.01)
else:
self._plotter.render()
except Exception as _exc:
log_exception(__name__, "highlight_quadrant", _exc)
def clear_quadrants(self: "ModelViewWidget") -> None:
if not self._plotter:
return
for actor in self._quadrant_actors:
try:
self._plotter.remove_actor(actor)
except Exception as _exc:
log_exception(__name__, "clear_quadrants", _exc)
self._quadrant_actors = []
for name in self._quadrant_label_actors:
try:
self._plotter.remove_actor(name)
except Exception as _exc:
log_exception(__name__, "clear_quadrants", _exc)
self._quadrant_label_actors = []
for actor in list(getattr(self, "_quadrant_grid_actors", []) or []):
try:
self._plotter.remove_actor(actor)
except Exception as _exc:
log_exception(__name__, "clear_quadrants", _exc)
self._quadrant_grid_actors = []
try:
if hasattr(self, "_safe_render"):
self._safe_render(min_interval_s=0.05)
else:
self._plotter.render()
except Exception as _exc:
log_exception(__name__, "clear_quadrants", _exc)
# -- оси -------------------------------------------------------------------
def show_axes(self: "ModelViewWidget", origin: Tuple[float, float, float], dir_x: float, dir_y: float) -> None:
if not self._plotter:
return
for actor in self._axes_actors:
try:
self._plotter.remove_actor(actor)
except Exception as _exc:
log_exception(__name__, "show_axes", _exc)
self._axes_actors = []
x0, y0, z0 = origin
axes = [
((dir_x, 0, 0), (1, 0, 0), "X"), # X: red
((0, dir_y, 0), (0, 1, 0), "Y"), # Y: green
((0, 0, 1), (0, 0, 1), "Z"), # Z-up: blue
]
axis_len = 1500.0
label_offset = 1700.0
for direction, color, label in axes:
arrow = pv.Arrow(start=(x0, y0, z0), direction=direction, scale=axis_len)
actor = self._plotter.add_mesh(
arrow,
color=color,
opacity=1.0,
lighting=False,
ambient=1.0,
diffuse=0.0,
specular=0.0,
)
self._axes_actors.append(actor)
try:
dx, dy, dz = float(direction[0]), float(direction[1]), float(direction[2])
label_pos = [[x0 + dx * label_offset, y0 + dy * label_offset, z0 + dz * label_offset]]
label_actor = self._plotter.add_point_labels(
label_pos,
[label],
font_size=36,
text_color=color,
point_size=1,
shape_opacity=0.0,
always_visible=True,
)
if label_actor is not None:
self._axes_actors.append(label_actor)
except Exception as _exc:
log_exception(__name__, "show_axes", _exc)
try:
if hasattr(self, "_reset_camera_clipping_range"):
self._reset_camera_clipping_range()
except Exception as _exc:
log_exception(__name__, "show_axes", _exc)
try:
if hasattr(self, "_safe_render"):
self._safe_render(min_interval_s=0.0)
else:
self._plotter.render()
except Exception as _exc:
log_exception(__name__, "show_axes", _exc)
# -- преобразования координат ----------------------------------------------
def world_to_display(self: "ModelViewWidget", x: float, y: float, z: float) -> Optional[Tuple[float, float]]:
if not self._plotter or not self._plotter.renderer:
return None
try:
self._plotter.renderer.SetWorldPoint(x, y, z, 1.0)
self._plotter.renderer.WorldToDisplay()
dx, dy, _ = self._plotter.renderer.GetDisplayPoint()
return (dx, dy)
except Exception as exc:
log_exception(__name__, "world_to_display", exc)
return None
def pick_world(self: "ModelViewWidget", sx: float, sy: float) -> Optional[Tuple[float, float, float]]:
if not self._plotter or not self._plotter.renderer or not self._plotter.picker:
return None
try:
self._plotter.picker.Pick(sx, sy, 0, self._plotter.renderer)
pos = self._plotter.picker.GetPickPosition()
if pos is None:
return None
x, y, z = pos[0], pos[1], pos[2]
if all(math.isfinite(v) for v in (x, y, z)):
return (x, y, z)
except Exception as exc:
log_exception(__name__, "pick_world", exc)
return None
return None
def screen_to_world_on_plane(
self: "ModelViewWidget", sx: float, sy: float, z_plane: float,
) -> Optional[Tuple[float, float, float]]:
"""Преобразовать экранные координаты в мировые на плоскости Z=z_plane."""
if not self._plotter or not self._plotter.renderer:
return None
try:
renderer = self._plotter.renderer
renderer.SetDisplayPoint(sx, sy, 0)
renderer.DisplayToWorld()
p0 = renderer.GetWorldPoint()
renderer.SetDisplayPoint(sx, sy, 1)
renderer.DisplayToWorld()
p1 = renderer.GetWorldPoint()
if p0[3] == 0 or p1[3] == 0:
return None
p0 = (p0[0] / p0[3], p0[1] / p0[3], p0[2] / p0[3])
p1 = (p1[0] / p1[3], p1[1] / p1[3], p1[2] / p1[3])
dz = p1[2] - p0[2]
if dz == 0:
return None
t = (z_plane - p0[2]) / dz
wx = p0[0] + t * (p1[0] - p0[0])
wy = p0[1] + t * (p1[1] - p0[1])
return (wx, wy, z_plane)
except Exception as exc:
log_exception(__name__, "screen_to_world_on_plane", exc)
return None
# ---------------------------------------------------------------------------
# Module workflow notes
# ---------------------------------------------------------------------------
# 1) Задача модуля:
# Визуальные вспомогательные функции: маркеры, оси, перевод координат, подбор точек.
#
# 2) Последовательность действий и вызовов:
# A. Класс VisualHelpersMixin: точки входа
# Публичные методы сценария:
# - VisualHelpersMixin.show_origin_marker(...)
# - VisualHelpersMixin.show_origin_preview_marker(...)
# - VisualHelpersMixin.clear_origin_marker(...)
# - VisualHelpersMixin.clear_origin_preview_marker(...)
# - VisualHelpersMixin.clear_origin_markers(...)
# - VisualHelpersMixin.set_corner_points(...)
# - VisualHelpersMixin.get_corner_points(...)
# - VisualHelpersMixin.show_quadrants(...)
# - VisualHelpersMixin.highlight_quadrant(...)
# - VisualHelpersMixin.clear_quadrants(...)
# - VisualHelpersMixin.show_axes(...)
# - VisualHelpersMixin.world_to_display(...)
# - VisualHelpersMixin.pick_world(...)
# - VisualHelpersMixin.screen_to_world_on_plane(...)
#
# B. VisualHelpersMixin: запуск и настройка:
# VisualHelpersMixin.set_corner_points(...)
# Назначение: устанавливает corner points в рамках текущего сценария модуля.
#
# C. VisualHelpersMixin: основной сценарий:
# VisualHelpersMixin.show_origin_marker(...)
# Назначение: показывает origin marker в рамках текущего сценария модуля.
# VisualHelpersMixin.show_origin_preview_marker(...)
# Назначение: показывает динамический маркер origin в рамках текущего сценария модуля.
# VisualHelpersMixin.show_quadrants(...)
# Назначение: показывает quadrants в рамках текущего сценария модуля.
# Последовательность внутренних вызовов:
# -> VisualHelpersMixin.clear_quadrants(...)
# VisualHelpersMixin.show_axes(...)
# Назначение: показывает axes в рамках текущего сценария модуля.
# VisualHelpersMixin.pick_world(...)
# Назначение: выполняет шаг "pick world" в рамках текущего сценария модуля.
#
# D. VisualHelpersMixin: завершение и очистка:
# VisualHelpersMixin.clear_origin_marker(...)
# Назначение: очищает origin marker в рамках текущего сценария модуля.
# VisualHelpersMixin.clear_origin_preview_marker(...)
# Назначение: очищает origin preview marker в рамках текущего сценария модуля.
# VisualHelpersMixin.clear_origin_markers(...)
# Назначение: очищает все маркеры origin в рамках текущего сценария модуля.
# VisualHelpersMixin.clear_quadrants(...)
# Назначение: очищает quadrants в рамках текущего сценария модуля.
#
# E. VisualHelpersMixin: вспомогательные расчёты:
# VisualHelpersMixin.get_corner_points(...)
# Назначение: возвращает corner points в рамках текущего сценария модуля.
# VisualHelpersMixin.highlight_quadrant(...)
# Назначение: подсвечивает quadrant в рамках текущего сценария модуля.
# VisualHelpersMixin.world_to_display(...)
# Назначение: выполняет шаг "world to display" в рамках текущего сценария модуля.
# VisualHelpersMixin.screen_to_world_on_plane(...)
# Назначение: Преобразовать экранные координаты в мировые на плоскости Z=z_plane.
#
# 3) Важные ограничения и инварианты:
# - Модуль выполняется в составе ModelViewWidget и использует согласованные поля состояния self._... .
# - Все операции с актёрами и камерой выполняются только при валидном self._plotter с безопасной обработкой исключений.
# - Геометрическая визуализация зависит от pyvista/vtk; при недоступности модуль обязан завершать шаг без падения сценария.
# - Межмодульная связность: только через фасад model_view; прямые обращения между zone, rack, shelf, cell запрещены.
# - Очистка состояния должна быть идемпотентной: повторный вызов не меняет корректное состояние в ошибочное.
#
# 4) Правило сопровождения:
# - Любое изменение сценария должно сопровождаться обновлением этого блока с сохранением фактического порядка вызовов.
# - При добавлении метода указывать его место в цепочке сценария (запуск, основной шаг, завершение, вспомогательная логика).

View File

@@ -0,0 +1,126 @@
# -*- coding: utf-8 -*-
# gui/components/model_view/_mv_zone_transition.py
"""Вспомогательные методы плавного перехода камеры для навигации на уровне зон."""
from __future__ import annotations
from typing import TYPE_CHECKING
from error_logger import log_exception
if TYPE_CHECKING: # pragma: no cover
from gui.components.model_view_widget import ModelViewWidget
class ZoneCameraTransitionMixin:
"""Анимация фокусировки камеры на выбранную зону в изометрическом виде."""
def focus_on_zone_isometric(
self: "ModelViewWidget",
zone_id: str | None,
*,
duration_ms: int = 340,
) -> bool:
if not self._plotter or not zone_id:
return False
bounds = (self._zone_data or {}).get(str(zone_id))
if not bounds:
return False
cam = getattr(self._plotter, "camera", None)
if cam is None:
return False
if not hasattr(self, "_animate_camera_transition"):
return False
try:
min_x, max_x, min_y, max_y, min_z, max_z = [float(v) for v in bounds]
cx = (min_x + max_x) * 0.5
cy = (min_y + max_y) * 0.5
sx = max(1.0, max_x - min_x)
sy = max(1.0, max_y - min_y)
sz = max(1.0, max_z - min_z)
cz = min_z + (sz * 0.52)
span = max(sx, sy, sz)
start_pos = tuple(float(v) for v in cam.GetPosition())
start_focal = tuple(float(v) for v in cam.GetFocalPoint())
start_up = tuple(float(v) for v in cam.GetViewUp())
vx, vy, vz = 1.0, -1.0, 0.75
norm = (vx * vx + vy * vy + vz * vz) ** 0.5
vx, vy, vz = vx / norm, vy / norm, vz / norm
ux, uy, uz = 0.0, 0.0, 1.0
fx, fy, fz = -vx, -vy, -vz
rx = fy * uz - fz * uy
ry = fz * ux - fx * uz
rz = fx * uy - fy * ux
r_norm = (rx * rx + ry * ry + rz * rz) ** 0.5
if r_norm <= 1e-6:
rx, ry, rz = 1.0, 0.0, 0.0
else:
rx, ry, rz = rx / r_norm, ry / r_norm, rz / r_norm
zone_margin = max(1.01, float(getattr(self, "_zone_iso_margin_factor", 1.24)))
zone_min_distance = max(1000.0, float(getattr(self, "_zone_iso_min_distance", 2600.0)))
zone_side_shift = max(0.0, float(getattr(self, "_zone_iso_side_shift_factor", 0.05)))
zone_z_lift = max(0.0, float(getattr(self, "_zone_iso_z_lift_factor", 0.08)))
zone_distance_scale = max(1.0, float(getattr(self, "_zone_iso_distance_scale", 1.30)))
if hasattr(self, "_compute_isometric_fit_distance"):
distance = float(
self._compute_isometric_fit_distance(
(min_x, max_x, min_y, max_y, min_z, max_z),
view_dir=(vx, vy, vz),
up_dir=(ux, uy, uz),
margin_factor=zone_margin,
min_distance=zone_min_distance,
)
)
else:
distance = max(zone_min_distance, span * 2.4)
distance *= zone_distance_scale
target_focal = (cx, cy, cz)
target_pos = (
cx + vx * distance + rx * (span * zone_side_shift),
cy + vy * distance + ry * (span * zone_side_shift),
cz + vz * distance + (sz * zone_z_lift),
)
self._animate_camera_transition(
start_pos=start_pos,
start_focal=start_focal,
start_up=start_up,
target_pos=target_pos,
target_focal=target_focal,
target_up=(ux, uy, uz),
duration_ms=int(duration_ms),
steps=18,
)
return True
except Exception as exc:
log_exception(__name__, "focus_on_zone_isometric", exc)
return False
# ---------------------------------------------------------------------------
# Module workflow notes
# ---------------------------------------------------------------------------
# 1) Задача модуля:
# Переход камеры к выбранной зоне.
#
# 2) Последовательность действий и вызовов:
# A. Класс ZoneCameraTransitionMixin: точки входа
# Публичные методы сценария:
# - ZoneCameraTransitionMixin.focus_on_zone_isometric(...)
#
# B. ZoneCameraTransitionMixin: основной сценарий:
# ZoneCameraTransitionMixin.focus_on_zone_isometric(...)
# Назначение: фокусирует on zone isometric в рамках текущего сценария модуля.
#
# 3) Важные ограничения и инварианты:
# - Модуль выполняется в составе ModelViewWidget и использует согласованные поля состояния self._... .
# - Все операции с актёрами и камерой выполняются только при валидном self._plotter с безопасной обработкой исключений.
# - Межмодульная связность: только через фасад model_view; прямые обращения между zone, rack, shelf, cell запрещены.
# - Очистка состояния должна быть идемпотентной: повторный вызов не меняет корректное состояние в ошибочное.
#
# 4) Правило сопровождения:
# - Любое изменение сценария должно сопровождаться обновлением этого блока с сохранением фактического порядка вызовов.
# - При добавлении метода указывать его место в цепочке сценария (запуск, основной шаг, завершение, вспомогательная логика).

View File

@@ -0,0 +1,33 @@
# -*- coding: utf-8 -*-
# gui/components/model_view/_mv_zones.py
# Управление зонами — тонкая композиция подмиксинов
from gui.components.model_view._mv_zones_crud import ZoneCrudMixin
from gui.components.model_view._mv_zones_visual import ZoneVisualMixin
from gui.components.model_view._mv_zones_highlight import ZoneHighlightMixin
class ZoneManagementMixin(ZoneCrudMixin, ZoneVisualMixin, ZoneHighlightMixin):
"""Добавление/обновление/удаление зон в 3D-сцене."""
pass
# ---------------------------------------------------------------------------
# Module workflow notes
# ---------------------------------------------------------------------------
# 1) Задача модуля:
# Композиционный фасад подсистемы зон.
#
# 2) Последовательность действий и вызовов:
# A. Композиционный класс ZoneManagementMixin:
# Назначение: объединяет поведение через ZoneCrudMixin, ZoneVisualMixin, ZoneHighlightMixin.
# Собственная вычислительная логика отсутствует; маршрутизация идёт в родительские миксины.
#
# 3) Важные ограничения и инварианты:
# - Модуль выполняется в составе ModelViewWidget и использует согласованные поля состояния self._... .
# - Межмодульная связность: только через фасад model_view; прямые обращения между zone, rack, shelf, cell запрещены.
# - Очистка состояния должна быть идемпотентной: повторный вызов не меняет корректное состояние в ошибочное.
#
# 4) Правило сопровождения:
# - Любое изменение сценария должно сопровождаться обновлением этого блока с сохранением фактического порядка вызовов.
# - При добавлении метода указывать его место в цепочке сценария (запуск, основной шаг, завершение, вспомогательная логика).

View File

@@ -0,0 +1,373 @@
# -*- coding: utf-8 -*-
# gui/components/model_view/_mv_zones_crud.py
# CRUD зон + пространственные запросы
from __future__ import annotations
from typing import Optional, Tuple, TYPE_CHECKING
from PySide6.QtGui import QColor
from error_logger import log_exception
if TYPE_CHECKING: # pragma: no cover
from gui.components.model_view_widget import ModelViewWidget
try:
import pyvista as pv
_PV = True
except ImportError:
_PV = False
class ZoneCrudMixin:
"""CRUD-операции с зонами и пространственные запросы."""
def add_zone(
self: "ModelViewWidget",
zone_id: str,
volume_points: list[tuple[float, float]],
zone_start_height: float,
zone_height: float,
color: str,
):
"""Добавить зону по контуру (XY) и высоте."""
if not self._models_loaded or not self._plotter:
return False
if not volume_points or len(volume_points) < 3:
return False
# Безопасность: если зона уже существует, сначала удаляем, чтобы избежать утечки акторов.
if zone_id in self._zones:
self.remove_zone(zone_id)
try:
qcolor = QColor(color)
rgb_color = (qcolor.redF(), qcolor.greenF(), qcolor.blueF())
opacity = qcolor.alphaF()
mesh = self._build_zone_mesh(volume_points, zone_start_height, zone_height)
if mesh is None:
return False
actor = self._plotter.add_mesh(
mesh, color=rgb_color, opacity=opacity,
show_edges=False, line_width=2,
)
min_x, max_x, min_y, max_y, min_z, max_z = self._compute_zone_bounds(
volume_points, zone_start_height, zone_height
)
self._zones[zone_id] = actor
self._zone_data[zone_id] = (min_x, max_x, min_y, max_y, min_z, max_z)
self._zone_polygons[zone_id] = [(float(x), float(y)) for x, y in volume_points]
self._zone_heights[zone_id] = (float(zone_start_height), float(zone_height))
self._plotter.update()
return True
except Exception as e:
print(f"Zone add error: {e}")
return False
def update_zone(
self: "ModelViewWidget",
zone_id: str,
volume_points: list[tuple[float, float]],
zone_start_height: float,
zone_height: float,
color: str,
):
"""Обновить зону."""
self.remove_zone(zone_id)
return self.add_zone(
zone_id,
volume_points,
zone_start_height,
zone_height,
color,
)
def remove_zone(self: "ModelViewWidget", zone_id: str):
"""Удалить зону."""
if getattr(self, "_selected_zone_highlight_id", None) == zone_id:
self.clear_selected_zone_highlight()
if getattr(self, "_hover_highlighted_zone_id", None) == zone_id:
self._unhighlight_hover_zone()
if zone_id in self._zones:
actor = self._zones.pop(zone_id)
if actor is not None and self._plotter is not None:
try:
self._plotter.remove_actor(actor)
except Exception as e:
log_exception(__name__, "remove_zone", e)
if zone_id in self._zone_data:
del self._zone_data[zone_id]
if zone_id in self._zone_polygons:
del self._zone_polygons[zone_id]
if zone_id in self._zone_heights:
del self._zone_heights[zone_id]
self._remove_zone_facility_contour(zone_id)
def _build_zone_mesh(
self: "ModelViewWidget",
volume_points: list[tuple[float, float]],
zone_start_height: float,
zone_height: float,
):
if not _PV or not volume_points or len(volume_points) < 3:
return None
pts = [(float(x), float(y), float(zone_start_height)) for x, y in volume_points]
faces = [len(pts)] + list(range(len(pts)))
poly = pv.PolyData(pts, faces)
try:
poly = poly.triangulate()
except Exception as _exc:
log_exception(__name__, "_build_zone_mesh", _exc)
return poly.extrude([0.0, 0.0, float(zone_height)], capping=True)
def _compute_zone_bounds(
self: "ModelViewWidget",
volume_points: list[tuple[float, float]],
zone_start_height: float,
zone_height: float,
) -> Tuple[float, float, float, float, float, float]:
min_x = min(p[0] for p in volume_points)
max_x = max(p[0] for p in volume_points)
min_y = min(p[1] for p in volume_points)
max_y = max(p[1] for p in volume_points)
min_z = float(zone_start_height)
max_z = float(zone_start_height) + float(zone_height)
return min_x, max_x, min_y, max_y, min_z, max_z
def _find_zone_at_point(
self: "ModelViewWidget", x: float, y: float, z: float,
) -> Optional[str]:
"""Найти зону по точке в мировых координатах."""
tolerance = 1.0
for zone_id, (mn_x, mx_x, mn_y, mx_y, mn_z, mx_z) in self._zone_data.items():
if (
(mn_x - tolerance) <= x <= (mx_x + tolerance)
and (mn_y - tolerance) <= y <= (mx_y + tolerance)
and (mn_z - tolerance) <= z <= (mx_z + tolerance)
):
return zone_id
return None
def _point_on_segment_2d(
self: "ModelViewWidget",
px: float,
py: float,
a: tuple[float, float],
b: tuple[float, float],
eps: float = 1e-6,
) -> bool:
ax, ay = a
bx, by = b
cross = (px - ax) * (by - ay) - (py - ay) * (bx - ax)
if abs(cross) > eps:
return False
dot = (px - ax) * (bx - ax) + (py - ay) * (by - ay)
if dot < -eps:
return False
sq_len = (bx - ax) * (bx - ax) + (by - ay) * (by - ay)
if dot - sq_len > eps:
return False
return True
def _classify_point_in_polygon(
self: "ModelViewWidget",
px: float,
py: float,
polygon: list[tuple[float, float]],
eps: float = 1e-6,
) -> tuple[bool, bool]:
"""Возвращает (внутри, граница)."""
if not polygon or len(polygon) < 3:
return False, False
inside = False
n = len(polygon)
for i in range(n):
a = polygon[i]
b = polygon[(i + 1) % n]
if self._point_on_segment_2d(px, py, a, b, eps):
return True, True
ax, ay = a
bx, by = b
intersects = ((ay > py) != (by > py)) and (
px < (bx - ax) * (py - ay) / (by - ay + eps) + ax
)
if intersects:
inside = not inside
return inside, False
def _zone_intersects_height(
self: "ModelViewWidget",
zone_id: str,
plane_z: float,
eps: float = 1e-6,
) -> bool:
start_height, height = self._zone_heights.get(zone_id, (0.0, 0.0))
zone_min = float(start_height)
zone_max = float(start_height) + float(height)
z = float(plane_z)
return (zone_min - eps) <= z <= (zone_max + eps)
def _classify_point_in_zones(
self: "ModelViewWidget",
px: float,
py: float,
plane_z: Optional[float] = None,
) -> tuple[Optional[str], Optional[str]]:
"""Возвращает (zone_id, kind) для верхней зоны в точке."""
ranked = self._classify_point_in_zones_ranked(px, py, plane_z=plane_z)
if not ranked:
return None, None
zone_id, kind, _ = ranked[0]
return zone_id, kind
def _classify_point_in_zones_ranked(
self: "ModelViewWidget",
px: float,
py: float,
plane_z: Optional[float] = None,
) -> list[tuple[str, str, float]]:
"""Возвращает все совпадающие зоны, отсортированные сверху вниз."""
step = max(1.0, float(getattr(self, "_current_zone_size", 1.0)))
eps = max(1e-6, step * 0.25)
ignore_zone_id = getattr(self, "_contour_ignore_zone_id", None)
candidates: list[tuple[float, int, str, str]] = []
for zone_id, polygon in self._zone_polygons.items():
if ignore_zone_id and zone_id == ignore_zone_id:
continue
if plane_z is not None and not self._zone_intersects_height(zone_id, plane_z, eps=eps):
continue
inside, boundary = self._classify_point_in_polygon(px, py, polygon, eps=eps)
if not inside:
continue
top_z = self._get_zone_top_height(zone_id)
rank = 0 if boundary else 1
kind = "boundary" if boundary else "inside"
candidates.append((float(top_z), rank, zone_id, kind))
if not candidates:
return []
candidates.sort(key=lambda item: (item[0], item[1]), reverse=True)
return [(zone_id, kind, top_z) for top_z, _, zone_id, kind in candidates]
def _point_inside_any_zone(
self: "ModelViewWidget",
px: float,
py: float,
plane_z: Optional[float] = None,
) -> bool:
step = max(1.0, float(getattr(self, "_current_zone_size", 1.0)))
eps = max(1e-6, step * 0.25)
for zone_id, polygon in self._zone_polygons.items():
if plane_z is not None and not self._zone_intersects_height(zone_id, plane_z, eps=eps):
continue
inside, boundary = self._classify_point_in_polygon(px, py, polygon, eps=eps)
if inside and not boundary:
return True
return False
def _point_in_zone_or_boundary(
self: "ModelViewWidget",
px: float,
py: float,
zone_id: str,
plane_z: Optional[float] = None,
) -> bool:
polygon = self._zone_polygons.get(zone_id)
if not polygon:
return False
step = max(1.0, float(getattr(self, "_current_zone_size", 1.0)))
eps = max(1e-6, step * 0.25)
if plane_z is not None and not self._zone_intersects_height(zone_id, plane_z, eps=eps):
return False
inside, boundary = self._classify_point_in_polygon(px, py, polygon, eps=eps)
return inside or boundary
def _get_zone_top_height(self: "ModelViewWidget", zone_id: str) -> float:
start_height, height = self._zone_heights.get(zone_id, (0.0, 0.0))
return float(start_height) + float(height)
# ---------------------------------------------------------------------------
# Module workflow notes
# ---------------------------------------------------------------------------
# 1) Задача модуля:
# Создание, обновление, удаление зон и геометрические проверки принадлежности.
#
# 2) Последовательность действий и вызовов:
# A. Класс ZoneCrudMixin: точки входа
# Публичные методы сценария:
# - ZoneCrudMixin.add_zone(...)
# - ZoneCrudMixin.update_zone(...)
# - ZoneCrudMixin.remove_zone(...)
#
# B. ZoneCrudMixin: основной сценарий:
# ZoneCrudMixin.add_zone(...)
# Назначение: Добавить зону по контуру (XY) и высоте.
# Последовательность внутренних вызовов:
# -> ZoneCrudMixin.remove_zone(...)
# -> ZoneCrudMixin._build_zone_mesh(...)
# -> ZoneCrudMixin._compute_zone_bounds(...)
# ZoneCrudMixin.update_zone(...)
# Назначение: Обновить зону.
# Последовательность внутренних вызовов:
# -> ZoneCrudMixin.remove_zone(...)
# -> ZoneCrudMixin.add_zone(...)
# ZoneCrudMixin._compute_zone_bounds(...)
# Назначение: вычисляет zone bounds в рамках текущего сценария модуля.
# ZoneCrudMixin._find_zone_at_point(...)
# Назначение: Найти зону по точке в мировых координатах.
# ZoneCrudMixin._classify_point_in_polygon(...)
# Назначение: Возвращает (внутри, граница).
# Последовательность внутренних вызовов:
# -> ZoneCrudMixin._point_on_segment_2d(...)
# ZoneCrudMixin._classify_point_in_zones(...)
# Назначение: Возвращает (zone_id, kind) для верхней зоны в точке.
# Последовательность внутренних вызовов:
# -> ZoneCrudMixin._classify_point_in_zones_ranked(...)
# ZoneCrudMixin._classify_point_in_zones_ranked(...)
# Назначение: Возвращает все совпадающие зоны, отсортированные сверху вниз.
# Последовательность внутренних вызовов:
# -> ZoneCrudMixin._classify_point_in_polygon(...)
# -> ZoneCrudMixin._get_zone_top_height(...)
# -> ZoneCrudMixin._zone_intersects_height(...)
#
# C. ZoneCrudMixin: завершение и очистка:
# ZoneCrudMixin.remove_zone(...)
# Назначение: Удалить зону.
#
# D. ZoneCrudMixin: вспомогательные расчёты:
# ZoneCrudMixin._build_zone_mesh(...)
# Назначение: строит zone mesh в рамках текущего сценария модуля.
# ZoneCrudMixin._point_on_segment_2d(...)
# Назначение: выполняет шаг "point on segment 2d" в рамках текущего сценария модуля.
# ZoneCrudMixin._zone_intersects_height(...)
# Назначение: выполняет шаг "zone intersects height" в рамках текущего сценария модуля.
# ZoneCrudMixin._point_inside_any_zone(...)
# Назначение: выполняет шаг "point inside any zone" в рамках текущего сценария модуля.
# Последовательность внутренних вызовов:
# -> ZoneCrudMixin._classify_point_in_polygon(...)
# -> ZoneCrudMixin._zone_intersects_height(...)
# ZoneCrudMixin._point_in_zone_or_boundary(...)
# Назначение: выполняет шаг "point in zone or boundary" в рамках текущего сценария модуля.
# Последовательность внутренних вызовов:
# -> ZoneCrudMixin._classify_point_in_polygon(...)
# -> ZoneCrudMixin._zone_intersects_height(...)
# ZoneCrudMixin._get_zone_top_height(...)
# Назначение: возвращает zone top height в рамках текущего сценария модуля.
#
# 3) Важные ограничения и инварианты:
# - Модуль выполняется в составе ModelViewWidget и использует согласованные поля состояния self._... .
# - Все операции с актёрами и камерой выполняются только при валидном self._plotter с безопасной обработкой исключений.
# - Геометрическая визуализация зависит от pyvista/vtk; при недоступности модуль обязан завершать шаг без падения сценария.
# - Межмодульная связность: только через фасад model_view; прямые обращения между zone, rack, shelf, cell запрещены.
# - Очистка состояния должна быть идемпотентной: повторный вызов не меняет корректное состояние в ошибочное.
#
# 4) Правило сопровождения:
# - Любое изменение сценария должно сопровождаться обновлением этого блока с сохранением фактического порядка вызовов.
# - При добавлении метода указывать его место в цепочке сценария (запуск, основной шаг, завершение, вспомогательная логика).

View File

@@ -0,0 +1,375 @@
# -*- coding: utf-8 -*-
# gui/components/model_view/_mv_zones_highlight.py
# Подсветка зон при наведении/выделении
from __future__ import annotations
from typing import Optional, TYPE_CHECKING
from error_logger import log_exception
if TYPE_CHECKING: # pragma: no cover
from gui.components.model_view_widget import ModelViewWidget
class ZoneHighlightMixin:
"""Подсветка зон при наведении и выделении."""
def _find_zone_by_actor(self: "ModelViewWidget", picked_actor) -> Optional[str]:
"""Найти зону по VTK-актору."""
if picked_actor is None:
return None
for zone_id, zone_actor in self._zones.items():
if zone_actor is picked_actor or zone_actor == picked_actor:
return zone_id
try:
if (
hasattr(zone_actor, "GetAddressAsString")
and hasattr(picked_actor, "GetAddressAsString")
and zone_actor.GetAddressAsString("") == picked_actor.GetAddressAsString("")
):
return zone_id
except Exception as _exc:
log_exception(__name__, "_find_zone_by_actor", _exc)
# Акторы контуров объекта также могут быть возвращены пикером.
try:
contour_actors = self._get_facility_zone_contour_actors()
except Exception as exc:
log_exception(__name__, "_find_zone_by_actor", exc)
contour_actors = {}
for zone_id, contour_actor in contour_actors.items():
if contour_actor is picked_actor or contour_actor == picked_actor:
return zone_id
try:
if (
hasattr(contour_actor, "GetAddressAsString")
and hasattr(picked_actor, "GetAddressAsString")
and contour_actor.GetAddressAsString("") == picked_actor.GetAddressAsString("")
):
return zone_id
except Exception as _exc:
log_exception(__name__, "_find_zone_by_actor", _exc)
return None
def _find_zone_by_screen_ray(self: "ModelViewWidget", sx: float, sy: float) -> Optional[str]:
"""Найти ближайшую зону, пересечённую лучом камеры через точку экрана."""
if not self._plotter or not self._plotter.renderer:
return None
try:
renderer = self._plotter.renderer
renderer.SetDisplayPoint(float(sx), float(sy), 0.0)
renderer.DisplayToWorld()
p0w = renderer.GetWorldPoint()
renderer.SetDisplayPoint(float(sx), float(sy), 1.0)
renderer.DisplayToWorld()
p1w = renderer.GetWorldPoint()
if p0w[3] == 0 or p1w[3] == 0:
return None
p0 = (p0w[0] / p0w[3], p0w[1] / p0w[3], p0w[2] / p0w[3])
p1 = (p1w[0] / p1w[3], p1w[1] / p1w[3], p1w[2] / p1w[3])
except Exception as exc:
log_exception(__name__, "_find_zone_by_screen_ray", exc)
return None
dx = float(p1[0] - p0[0])
dy = float(p1[1] - p0[1])
dz = float(p1[2] - p0[2])
eps = 1e-9
def _axis_slab(o: float, d: float, mn: float, mx: float) -> tuple[float, float] | None:
if abs(d) <= eps:
if o < mn or o > mx:
return None
return (-float("inf"), float("inf"))
t1 = (mn - o) / d
t2 = (mx - o) / d
return (t1, t2) if t1 <= t2 else (t2, t1)
best_zone_id = None
best_t = None
for zone_id, bounds in self._zone_data.items():
try:
mn_x, mx_x, mn_y, mx_y, mn_z, mx_z = bounds
xr = _axis_slab(float(p0[0]), dx, float(mn_x), float(mx_x))
yr = _axis_slab(float(p0[1]), dy, float(mn_y), float(mx_y))
zr = _axis_slab(float(p0[2]), dz, float(mn_z), float(mx_z))
if xr is None or yr is None or zr is None:
continue
t_enter = max(xr[0], yr[0], zr[0])
t_exit = min(xr[1], yr[1], zr[1])
if t_exit < max(t_enter, 0.0):
continue
t_hit = t_enter if t_enter >= 0.0 else t_exit
if t_hit < 0.0:
continue
if best_t is None or t_hit < best_t:
best_t = t_hit
best_zone_id = zone_id
except Exception as exc:
log_exception(__name__, "_find_zone_by_screen_ray", exc)
continue
return best_zone_id
def _handle_zone_hover_event(
self: "ModelViewWidget", sx: int, sy: int,
) -> None:
"""Определить зону под курсором и подсветить/снять подсветку.
1. vtkPropPicker — точный пиксельный pick по видимой геометрии.
Корректно определяет глубину (Z1 vs Z2), работает на всей
поверхности, а не только на углах/рёбрах.
2. Fallback: screen_to_world + 2D полигональный тест — только для
зон в контурном режиме (solid скрыт), которые не видны пикеру.
"""
if getattr(self, "_hide_empty_zones_mode", False):
self._unhighlight_hover_zone()
return
selected_zone_id = str(getattr(self, "_selected_zone_highlight_id", "") or "")
if selected_zone_id and selected_zone_id not in self._zones:
self._selected_zone_highlight_id = None
if not self._plotter or not self._plotter.renderer:
self._unhighlight_hover_zone()
return
zone_id = None
# --- 1. vtkPropPicker: точное определение ближайшего актора --------
try:
from vtkmodules.vtkRenderingCore import vtkPropPicker
prop_picker = vtkPropPicker()
prop_picker.Pick(sx, sy, 0, self._plotter.renderer)
picked_actor = prop_picker.GetViewProp()
if picked_actor is not None:
zone_id = self._find_zone_by_actor(picked_actor)
except Exception as _exc:
log_exception(__name__, "_handle_zone_hover_event", _exc)
# --- 2. Запасной вариант: пересечение луча с 3D-границами зоны -----
if zone_id is None:
zone_id = self._find_zone_by_screen_ray(sx, sy)
# --- 3. Запасной вариант: 2D-полигональный тест с приоритетом верхней зоны
if zone_id is None:
plane_z = self._get_grid_plane_z(getattr(self, "_grid_origin", None))
world = self.screen_to_world_on_plane(sx, sy, plane_z)
if world is not None:
wx, wy = world[0], world[1]
best_top_z = None
for zid, polygon in self._zone_polygons.items():
if not polygon or len(polygon) < 3:
continue
inside, _ = self._classify_point_in_polygon(wx, wy, polygon)
if inside:
top_z = self._get_zone_top_height(zid)
if best_top_z is None or top_z > best_top_z:
best_top_z = top_z
zone_id = zid
if zone_id == getattr(self, "_hover_highlighted_zone_id", None):
return # та же зона — ничего не делаем
self._unhighlight_hover_zone()
if zone_id is not None:
self._highlight_hover_zone(zone_id)
def set_selected_zone_highlight(self: "ModelViewWidget", zone_id: str | None) -> None:
"""Закрепить подсветку выбранной зоны до явного сброса."""
zid = str(zone_id or "")
if not zid or zid not in self._zones:
self.clear_selected_zone_highlight()
return
if getattr(self, "_selected_zone_highlight_id", None) == zid:
if getattr(self, "_hover_highlighted_zone_id", None) != zid:
self._unhighlight_hover_zone()
self._highlight_hover_zone(zid)
return
self.clear_selected_zone_highlight()
actor = self._zones.get(zid)
if actor is not None:
try:
prop = actor.GetProperty()
self._selected_zone_original_visibility = bool(actor.GetVisibility())
self._selected_zone_original_opacity = float(prop.GetOpacity())
actor.SetVisibility(1)
prop.SetOpacity(1.0)
except Exception as exc:
log_exception(__name__, "set_selected_zone_highlight", exc)
self._selected_zone_original_opacity = None
self._selected_zone_original_visibility = None
else:
self._selected_zone_original_opacity = None
self._selected_zone_original_visibility = None
self._set_zone_contour_highlight(zid, active=True)
self._hover_highlighted_zone_id = zid
self._selected_zone_highlight_id = zid
self._safe_render(min_interval_s=1.0 / 60.0)
def clear_selected_zone_highlight(self: "ModelViewWidget") -> None:
"""Снять закреплённую подсветку выбранной зоны."""
locked_zone_id = str(getattr(self, "_selected_zone_highlight_id", "") or "")
self._selected_zone_highlight_id = None
if not locked_zone_id:
return
actor = self._zones.get(locked_zone_id)
if actor is not None:
try:
prop = actor.GetProperty()
orig_opacity = getattr(self, "_selected_zone_original_opacity", None)
if orig_opacity is not None:
prop.SetOpacity(float(orig_opacity))
orig_vis = getattr(self, "_selected_zone_original_visibility", None)
if orig_vis is not None:
actor.SetVisibility(1 if orig_vis else 0)
except Exception as _exc:
log_exception(__name__, "clear_selected_zone_highlight", _exc)
self._selected_zone_original_opacity = None
self._selected_zone_original_visibility = None
if getattr(self, "_hover_highlighted_zone_id", None) == locked_zone_id:
self._hover_highlighted_zone_id = None
self._teardown_zone_contour_if_temporary(locked_zone_id)
self._safe_render(min_interval_s=1.0 / 60.0)
def _is_zone_in_contour_mode(self: "ModelViewWidget", zone_id: str) -> bool:
"""Проверить, отображена ли зона в контурном режиме (solid скрыт)."""
actor = self._zones.get(zone_id)
if actor is None:
return False
try:
if not actor.GetVisibility():
# Solid скрыт — проверим, есть ли facility contour
contour_actors = self._get_facility_zone_contour_actors()
contour_actor = contour_actors.get(zone_id)
if contour_actor is not None:
return True
except Exception as _exc:
log_exception(__name__, "_is_zone_in_contour_mode", _exc)
return False
def _zone_has_rack_payload(self: "ModelViewWidget", zone_id: str) -> bool:
try:
return bool(self.has_racks_in_zone(zone_id))
except Exception as exc:
log_exception(__name__, "_zone_has_rack_payload", exc)
return False
def _set_zone_contour_highlight(
self: "ModelViewWidget",
zone_id: str,
active: bool,
) -> None:
contour_actor = self._ensure_zone_facility_contour(zone_id)
if contour_actor is None:
return
try:
prop = contour_actor.GetProperty()
if prop is None:
return
if active:
prop.SetColor(1.0, 1.0, 0.3)
prop.SetLineWidth(3.0)
else:
prop.SetColor(1.0, 1.0, 1.0)
prop.SetLineWidth(2.0)
contour_actor.SetVisibility(1)
except Exception as _exc:
log_exception(__name__, "_set_zone_contour_highlight", _exc)
def _teardown_zone_contour_if_temporary(self: "ModelViewWidget", zone_id: str) -> None:
if self._zone_has_rack_payload(zone_id):
self._set_zone_contour_highlight(zone_id, active=False)
return
self._remove_zone_facility_contour(zone_id)
def _highlight_hover_zone(self: "ModelViewWidget", zone_id: str) -> None:
"""Подсветить зону при hover только по контурным линиям."""
if zone_id not in self._zones:
return
self._set_zone_contour_highlight(zone_id, active=True)
self._hover_highlighted_zone_id = zone_id
self._safe_render(min_interval_s=1.0 / 60.0)
def _unhighlight_hover_zone(self: "ModelViewWidget") -> None:
"""Снять подсветку с ранее подсвеченной зоны."""
zone_id = getattr(self, "_hover_highlighted_zone_id", None)
if zone_id is None:
return
self._teardown_zone_contour_if_temporary(str(zone_id))
self._hover_highlighted_zone_id = None
self._hover_zone_original_opacity = None
self._hover_zone_original_edges = False
self._hover_zone_original_visibility = None
self._hover_zone_contour_original_color = None
self._safe_render(min_interval_s=1.0 / 60.0)
# ---------------------------------------------------------------------------
# Module workflow notes
# ---------------------------------------------------------------------------
# 1) Задача модуля:
# Подсветка зон при наведении и выборе.
#
# 2) Последовательность действий и вызовов:
# A. Класс ZoneHighlightMixin: точки входа
# Публичные методы сценария:
# - ZoneHighlightMixin.set_selected_zone_highlight(...)
# - ZoneHighlightMixin.clear_selected_zone_highlight(...)
#
# B. ZoneHighlightMixin: запуск и настройка:
# ZoneHighlightMixin.set_selected_zone_highlight(...)
# Назначение: Закрепить подсветку выбранной зоны до явного сброса.
# Последовательность внутренних вызовов:
# -> ZoneHighlightMixin.clear_selected_zone_highlight(...)
# -> ZoneHighlightMixin._set_zone_contour_highlight(...)
# -> ZoneHighlightMixin._unhighlight_hover_zone(...)
# -> ZoneHighlightMixin._highlight_hover_zone(...)
# ZoneHighlightMixin._set_zone_contour_highlight(...)
# Назначение: устанавливает zone contour highlight в рамках текущего сценария модуля.
#
# C. ZoneHighlightMixin: основной сценарий:
# ZoneHighlightMixin._find_zone_by_actor(...)
# Назначение: Найти зону по VTK-актору.
# ZoneHighlightMixin._find_zone_by_screen_ray(...)
# Назначение: Найти ближайшую зону, пересечённую лучом камеры через точку экрана.
# ZoneHighlightMixin._handle_zone_hover_event(...)
# Назначение: Определить зону под курсором и подсветить/снять подсветку.
# Последовательность внутренних вызовов:
# -> ZoneHighlightMixin._unhighlight_hover_zone(...)
# -> ZoneHighlightMixin._find_zone_by_screen_ray(...)
# -> ZoneHighlightMixin._highlight_hover_zone(...)
# -> ZoneHighlightMixin._find_zone_by_actor(...)
# ZoneHighlightMixin._is_zone_in_contour_mode(...)
# Назначение: Проверить, отображена ли зона в контурном режиме (solid скрыт).
#
# D. ZoneHighlightMixin: завершение и очистка:
# ZoneHighlightMixin.clear_selected_zone_highlight(...)
# Назначение: Снять закреплённую подсветку выбранной зоны.
# Последовательность внутренних вызовов:
# -> ZoneHighlightMixin._teardown_zone_contour_if_temporary(...)
# ZoneHighlightMixin._teardown_zone_contour_if_temporary(...)
# Назначение: выполняет демонтаж zone contour if temporary в рамках текущего сценария модуля.
# Последовательность внутренних вызовов:
# -> ZoneHighlightMixin._zone_has_rack_payload(...)
# -> ZoneHighlightMixin._set_zone_contour_highlight(...)
#
# E. ZoneHighlightMixin: вспомогательные расчёты:
# ZoneHighlightMixin._zone_has_rack_payload(...)
# Назначение: выполняет шаг "zone has rack payload" в рамках текущего сценария модуля.
# ZoneHighlightMixin._highlight_hover_zone(...)
# Назначение: Подсветить зону при hover только по контурным линиям.
# Последовательность внутренних вызовов:
# -> ZoneHighlightMixin._set_zone_contour_highlight(...)
# ZoneHighlightMixin._unhighlight_hover_zone(...)
# Назначение: Снять подсветку с ранее подсвеченной зоны.
# Последовательность внутренних вызовов:
# -> ZoneHighlightMixin._teardown_zone_contour_if_temporary(...)
#
# 3) Важные ограничения и инварианты:
# - Модуль выполняется в составе ModelViewWidget и использует согласованные поля состояния self._... .
# - Все операции с актёрами и камерой выполняются только при валидном self._plotter с безопасной обработкой исключений.
# - Межмодульная связность: только через фасад model_view; прямые обращения между zone, rack, shelf, cell запрещены.
# - Очистка состояния должна быть идемпотентной: повторный вызов не меняет корректное состояние в ошибочное.
#
# 4) Правило сопровождения:
# - Любое изменение сценария должно сопровождаться обновлением этого блока с сохранением фактического порядка вызовов.
# - При добавлении метода указывать его место в цепочке сценария (запуск, основной шаг, завершение, вспомогательная логика).

View File

@@ -0,0 +1,315 @@
# -*- coding: utf-8 -*-
# gui/components/model_view/_mv_zones_visual.py
# Визуальное управление зонами (предпросмотр, изоляция, видимость, контуры)
from __future__ import annotations
from typing import TYPE_CHECKING
from PySide6.QtGui import QColor
from error_logger import log_exception
if TYPE_CHECKING: # pragma: no cover
from gui.components.model_view_widget import ModelViewWidget
try:
import pyvista as pv
_PV = True
except ImportError:
_PV = False
class ZoneVisualMixin:
"""Визуальное управление зонами: изоляция, видимость, контуры."""
def isolate_zone(self: "ModelViewWidget", zone_id: str) -> None:
"""Скрыть все зоны кроме указанной (изоляция вида)."""
self.clear_selected_zone_highlight()
self._unhighlight_hover_zone()
for zid, actor in self._zones.items():
if actor is None:
continue
try:
actor.SetVisibility(1 if zid == zone_id else 0)
except Exception as _exc:
log_exception(__name__, "isolate_zone", _exc)
if self._plotter:
self._plotter.update()
def show_all_zones(self: "ModelViewWidget") -> None:
"""Показать все зоны (снять изоляцию вида) и убрать контур."""
if getattr(self, "_selected_zone_highlight_id", None):
self.clear_selected_zone_highlight()
else:
self._unhighlight_hover_zone()
self._remove_zone_contour()
self._set_facility_zone_contours_visible(True)
for actor in self._zones.values():
if actor is None:
continue
try:
actor.SetVisibility(1)
except Exception as _exc:
log_exception(__name__, "show_all_zones", _exc)
if self._plotter:
self._plotter.update()
def set_zone_visibility(self: "ModelViewWidget", zone_id: str, visible: bool) -> None:
"""Установить видимость конкретной зоны."""
actor = self._zones.get(zone_id)
if actor is None:
return
try:
actor.SetVisibility(1 if visible else 0)
if self._plotter:
self._plotter.update()
except Exception as _exc:
log_exception(__name__, "set_zone_visibility", _exc)
def show_zone_contour(self: "ModelViewWidget", zone_id: str) -> None:
"""Скрыть все зоны и показать контурную линию (wireframe) указанной зоны.
Сам объём (solid mesh) скрывается. Вместо него отображается каркасная
линия рёбер (wireframe), чтобы обозначить границы зоны.
"""
self.clear_selected_zone_highlight()
self._unhighlight_hover_zone()
# Скрываем все зоны (solid)
self._set_facility_zone_contours_visible(False)
for zid, actor in self._zones.items():
if actor is None:
continue
try:
actor.SetVisibility(0)
except Exception as _exc:
log_exception(__name__, "show_zone_contour", _exc)
# Удаляем предыдущий контур, если был
self._remove_zone_contour()
# Строим wireframe-контур выбранной зоны
polygon = self._zone_polygons.get(zone_id)
heights = self._zone_heights.get(zone_id)
if not polygon or not heights or not self._plotter:
if self._plotter:
self._plotter.update()
return
start_h, height = heights
try:
edges = self._build_zone_outline_edges(polygon, float(start_h), float(height))
if edges is None:
return
contour_actor = self._plotter.add_mesh(
edges,
color=(1.0, 1.0, 1.0),
line_width=2,
style="wireframe",
name="_zone_contour_outline",
)
self._zone_contour_actor = contour_actor
except Exception as e:
log_exception(__name__, "show_zone_contour", e)
if self._plotter:
self._plotter.update()
def _get_facility_zone_contour_actors(self: "ModelViewWidget") -> dict[str, object]:
actors = getattr(self, "_zone_facility_contour_actors", None)
if actors is None:
actors = {}
self._zone_facility_contour_actors = actors
return actors
def _set_facility_zone_contours_visible(self: "ModelViewWidget", visible: bool) -> None:
actors = self._get_facility_zone_contour_actors()
for actor in actors.values():
if actor is None:
continue
try:
actor.SetVisibility(1 if visible else 0)
except Exception as _exc:
log_exception(__name__, "_set_facility_zone_contours_visible", _exc)
def _ensure_zone_facility_contour(self: "ModelViewWidget", zone_id: str) -> object | None:
actors = self._get_facility_zone_contour_actors()
existing = actors.get(zone_id)
if existing is not None:
return existing
polygon = self._zone_polygons.get(zone_id)
heights = self._zone_heights.get(zone_id)
if not polygon or not heights or not self._plotter:
return None
start_h, height = heights
try:
edges = self._build_zone_outline_edges(polygon, float(start_h), float(height))
if edges is None:
return None
actor = self._plotter.add_mesh(
edges,
color=(1.0, 1.0, 1.0),
line_width=2,
style="wireframe",
name=f"_zone_facility_contour_{zone_id}",
)
actors[zone_id] = actor
return actor
except Exception as exc:
log_exception(__name__, "_ensure_zone_facility_contour", exc)
return None
def _remove_zone_facility_contour(self: "ModelViewWidget", zone_id: str) -> None:
actors = self._get_facility_zone_contour_actors()
actor = actors.pop(zone_id, None)
if actor is None or self._plotter is None:
return
try:
self._plotter.remove_actor(actor)
except Exception as _exc:
log_exception(__name__, "_remove_zone_facility_contour", _exc)
def set_zone_facility_contour_mode(self: "ModelViewWidget", zone_id: str, enabled: bool) -> None:
"""Установить представление зоны для уровня объекта.
enabled=True -> скрыть solid-меш и показать контурную линию.
enabled=False -> удалить контурную линию и показать solid-меш.
"""
actor = self._zones.get(zone_id)
if actor is None:
return
if enabled:
contour_actor = self._ensure_zone_facility_contour(zone_id)
try:
actor.SetVisibility(0)
except Exception as _exc:
log_exception(__name__, "set_zone_facility_contour_mode", _exc)
if contour_actor is not None:
try:
c_prop = contour_actor.GetProperty()
if c_prop is not None:
c_prop.SetColor(1.0, 1.0, 1.0)
c_prop.SetLineWidth(2.0)
contour_actor.SetVisibility(1)
except Exception as _exc:
log_exception(__name__, "set_zone_facility_contour_mode", _exc)
else:
self._remove_zone_facility_contour(zone_id)
try:
actor.SetVisibility(1)
except Exception as _exc:
log_exception(__name__, "set_zone_facility_contour_mode", _exc)
def _build_zone_outline_edges(
self: "ModelViewWidget",
polygon: list[tuple[float, float]],
start_height: float,
height: float,
):
"""Построить только рёбра периметра для контура зоны."""
if not _PV or not polygon or len(polygon) < 3:
return None
n = len(polygon)
z0 = float(start_height)
z1 = float(start_height) + float(height)
points: list[list[float]] = []
for x, y in polygon:
points.append([float(x), float(y), z0])
for x, y in polygon:
points.append([float(x), float(y), z1])
line_cells: list[int] = []
def _add_segment(i0: int, i1: int) -> None:
line_cells.extend([2, i0, i1])
for i in range(n):
j = (i + 1) % n
_add_segment(i, j)
_add_segment(i + n, j + n)
_add_segment(i, i + n)
outline = pv.PolyData(points)
outline.lines = line_cells
return outline
def _remove_zone_contour(self: "ModelViewWidget") -> None:
"""Удалить контурную линию зоны из 3D-сцены."""
actor = getattr(self, "_zone_contour_actor", None)
if actor is not None and self._plotter is not None:
try:
self._plotter.remove_actor(actor)
except Exception as _exc:
log_exception(__name__, "_remove_zone_contour", _exc)
self._zone_contour_actor = None
# ---------------------------------------------------------------------------
# Module workflow notes
# ---------------------------------------------------------------------------
# 1) Задача модуля:
# Визуализация зон: предпросмотр, контуры, изоляция и видимость.
#
# 2) Последовательность действий и вызовов:
# A. Класс ZoneVisualMixin: точки входа
# Публичные методы сценария:
# - ZoneVisualMixin.isolate_zone(...)
# - ZoneVisualMixin.show_all_zones(...)
# - ZoneVisualMixin.set_zone_visibility(...)
# - ZoneVisualMixin.show_zone_contour(...)
# - ZoneVisualMixin.set_zone_facility_contour_mode(...)
#
# B. ZoneVisualMixin: запуск и настройка:
# ZoneVisualMixin.set_zone_visibility(...)
# Назначение: Установить видимость конкретной зоны.
# ZoneVisualMixin._set_facility_zone_contours_visible(...)
# Назначение: устанавливает facility zone contours visible в рамках текущего сценария модуля.
# Последовательность внутренних вызовов:
# -> ZoneVisualMixin._get_facility_zone_contour_actors(...)
# ZoneVisualMixin.set_zone_facility_contour_mode(...)
# Назначение: Установить представление зоны для уровня объекта.
# Последовательность внутренних вызовов:
# -> ZoneVisualMixin._ensure_zone_facility_contour(...)
# -> ZoneVisualMixin._remove_zone_facility_contour(...)
#
# C. ZoneVisualMixin: основной сценарий:
# ZoneVisualMixin.show_preview_zone(...)
# Назначение: Показать простой куб предпросмотра (устаревший).
# ZoneVisualMixin.show_all_zones(...)
# Назначение: Показать все зоны (снять изоляцию вида) и убрать контур.
# Последовательность внутренних вызовов:
# -> ZoneVisualMixin._remove_zone_contour(...)
# -> ZoneVisualMixin._set_facility_zone_contours_visible(...)
# ZoneVisualMixin.show_zone_contour(...)
# Назначение: Скрыть все зоны и показать контурную линию (wireframe) указанной зоны.
# Последовательность внутренних вызовов:
# -> ZoneVisualMixin._set_facility_zone_contours_visible(...)
# -> ZoneVisualMixin._remove_zone_contour(...)
# -> ZoneVisualMixin._build_zone_outline_edges(...)
#
# D. ZoneVisualMixin: завершение и очистка:
# ZoneVisualMixin._remove_zone_facility_contour(...)
# Назначение: удаляет zone facility contour в рамках текущего сценария модуля.
# Последовательность внутренних вызовов:
# -> ZoneVisualMixin._get_facility_zone_contour_actors(...)
# ZoneVisualMixin._remove_zone_contour(...)
# Назначение: Удалить контурную линию зоны из 3D-сцены.
#
# E. ZoneVisualMixin: вспомогательные расчёты:
# ZoneVisualMixin.isolate_zone(...)
# Назначение: Скрыть все зоны кроме указанной (изоляция вида).
# ZoneVisualMixin._get_facility_zone_contour_actors(...)
# Назначение: возвращает facility zone contour actors в рамках текущего сценария модуля.
# ZoneVisualMixin._ensure_zone_facility_contour(...)
# Назначение: выполняет шаг "ensure zone facility contour" в рамках текущего сценария модуля.
# Последовательность внутренних вызовов:
# -> ZoneVisualMixin._get_facility_zone_contour_actors(...)
# -> ZoneVisualMixin._build_zone_outline_edges(...)
# ZoneVisualMixin._build_zone_outline_edges(...)
# Назначение: Построить только рёбра периметра для контура зоны.
#
# 3) Важные ограничения и инварианты:
# - Модуль выполняется в составе ModelViewWidget и использует согласованные поля состояния self._... .
# - Все операции с актёрами и камерой выполняются только при валидном self._plotter с безопасной обработкой исключений.
# - Геометрическая визуализация зависит от pyvista/vtk; при недоступности модуль обязан завершать шаг без падения сценария.
# - Межмодульная связность: только через фасад model_view; прямые обращения между zone, rack, shelf, cell запрещены.
# - Очистка состояния должна быть идемпотентной: повторный вызов не меняет корректное состояние в ошибочное.
#
# 4) Правило сопровождения:
# - Любое изменение сценария должно сопровождаться обновлением этого блока с сохранением фактического порядка вызовов.
# - При добавлении метода указывать его место в цепочке сценария (запуск, основной шаг, завершение, вспомогательная логика).

View File

@@ -0,0 +1,116 @@
# -*- coding: utf-8 -*-
# gui/components/model_view/_scenario_camera.py
"""Камерный сценарий — базовый слой, всегда активен.
Обрабатывает ПКМ (вращение), СКМ (панорамирование), подавление ЛКМ drag.
Политика камеры текущего доменного сценария определяет, работает ли этот слой.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from PySide6.QtCore import Qt
from PySide6.QtGui import QGuiApplication
from gui.components.model_view._interaction_scenario import (
CameraPolicy,
InteractionScenario,
)
from error_logger import log_exception
if TYPE_CHECKING: # pragma: no cover
from gui.components.model_view_widget import ModelViewWidget
class CameraScenario(InteractionScenario):
"""Базовый камерный слой: вращение (ПКМ), панорамирование (СКМ)."""
name = "camera"
camera_policy = CameraPolicy.FREE
def on_mouse_press(self, mv: "ModelViewWidget", event) -> bool:
if event.button() == Qt.MouseButton.RightButton:
# В режиме контура ПКМ передаётся PyVista (remove point)
if mv._zone_selection_mode and mv.is_scenario_active("contour_edit"):
return False
mv._cam_rotate_active = True
mv._cam_last_pos = (event.position().x(), event.position().y())
return True
if event.button() == Qt.MouseButton.MiddleButton:
mv._cam_pan_active = True
mv._cam_last_pos = (event.position().x(), event.position().y())
return True
return False
def on_mouse_move(self, mv: "ModelViewWidget", event) -> bool:
# Safeguard: если release-событие потерялось (после модалок/фокуса),
# синхронизируем drag-флаги с реальным состоянием кнопок мыши.
try:
real_buttons = QGuiApplication.mouseButtons()
except Exception as _exc:
real_buttons = Qt.MouseButton.NoButton
if mv._cam_rotate_active and not (real_buttons & Qt.MouseButton.RightButton):
mv._cam_rotate_active = False
mv._cam_last_pos = None
if mv._cam_pan_active and not (real_buttons & Qt.MouseButton.MiddleButton):
mv._cam_pan_active = False
mv._cam_last_pos = None
# Подавление ЛКМ drag (не конфликтует с click-сценариями)
if hasattr(event, "buttons") and (event.buttons() & Qt.MouseButton.LeftButton):
return True
# ПКМ drag → вращение камеры
if (
mv._cam_rotate_active
and hasattr(event, "buttons")
and (event.buttons() & Qt.MouseButton.RightButton)
):
cx, cy = mv._cam_last_pos or (event.position().x(), event.position().y())
nx, ny = event.position().x(), event.position().y()
dx, dy = float(nx - cx), float(ny - cy)
mv._cam_last_pos = (nx, ny)
try:
cam = mv._plotter.camera
cam.Azimuth(-dx * 0.35)
cam.Elevation(dy * 0.35)
cam.OrthogonalizeViewUp()
if hasattr(mv, "_reset_camera_clipping_range"):
mv._reset_camera_clipping_range()
mv._safe_render(min_interval_s=1.0 / 75.0)
except Exception as _exc:
log_exception(__name__, "on_mouse_move", _exc)
return True
# СКМ drag → панорамирование
if (
mv._cam_pan_active
and hasattr(event, "buttons")
and (event.buttons() & Qt.MouseButton.MiddleButton)
):
cx, cy = mv._cam_last_pos or (event.position().x(), event.position().y())
nx, ny = event.position().x(), event.position().y()
dx, dy = float(nx - cx), float(ny - cy)
mv._cam_last_pos = (nx, ny)
mv._pan_camera_by_pixels(dx, dy)
try:
if hasattr(mv, "_reset_camera_clipping_range"):
mv._reset_camera_clipping_range()
mv._safe_render(min_interval_s=1.0 / 75.0)
except Exception as _exc:
log_exception(__name__, "on_mouse_move", _exc)
return True
return False
def on_mouse_release(self, mv: "ModelViewWidget", event) -> bool:
if event.button() == Qt.MouseButton.RightButton:
mv._cam_rotate_active = False
mv._cam_last_pos = None
return True
if event.button() == Qt.MouseButton.MiddleButton:
mv._cam_pan_active = False
mv._cam_last_pos = None
return True
return False

View File

@@ -0,0 +1,179 @@
# -*- coding: utf-8 -*-
# gui/components/model_view/_scenario_contour_edit.py
"""Сценарий редактирования контура зоны.
Охватывает:
- Drag узлов контура (ЛКМ hold + move)
- Candidate-узел контура (hover)
- Размерные линии (hover при _dim_enabled)
- Drag высоты выделения (mousePressEvent/Move/Release виджета)
"""
from __future__ import annotations
import math
from typing import TYPE_CHECKING
from PySide6.QtCore import Qt
from gui.components.model_view._interaction_scenario import (
CameraPolicy,
InteractionScenario,
)
from error_logger import log_exception
if TYPE_CHECKING: # pragma: no cover
from gui.components.model_view_widget import ModelViewWidget
class ContourEditScenario(InteractionScenario):
"""Сценарий редактирования контура: drag узлов, candidate hover, dim lines, height drag."""
name = "contour_edit"
camera_policy = CameraPolicy.TOP_VIEW
# -- Активация / деактивация (backward compat) ----------------------------
def on_activate(self, mv: "ModelViewWidget") -> None:
mv._zone_selection_mode = True
if hasattr(mv, "_set_trackball_right_button_enabled"):
mv._set_trackball_right_button_enabled(False)
def on_deactivate(self, mv: "ModelViewWidget") -> None:
# Флаги сбрасываются в stop_contour_definition / cancel_zone_selection
if hasattr(mv, "_set_trackball_right_button_enabled"):
mv._set_trackball_right_button_enabled(True)
# -- eventFilter: mouse press (ЛКМ — начать drag узла) --------------------
def on_mouse_press(self, mv: "ModelViewWidget", event) -> bool:
if event.button() == Qt.MouseButton.RightButton:
if not mv.is_scenario_active("contour_edit"):
return False
world = mv._event_world_on_plane(event, mv._get_contour_plane_z())
if world is not None:
mv._handle_grid_click(float(world[0]), float(world[1]), float(world[2]), action="remove")
else:
if hasattr(mv, "set_contour_auxiliary_visibility"):
mv.set_contour_auxiliary_visibility(False)
# ПКМ всегда поглощаем в contour_edit, чтобы камера не перехватывала событие.
return True
if event.button() != Qt.MouseButton.LeftButton:
return False
if not mv.is_scenario_active("contour_edit"):
return False
world = mv._event_world_on_plane(event, mv._get_contour_plane_z())
if world is None:
return False
step = max(1.0, float(getattr(mv, "_current_zone_size", 1.0)))
max_dist = step * 0.55
nearest_idx = mv._nearest_contour_node_index(
world[0], world[1], max_dist=max_dist,
)
if nearest_idx is None:
return False
if bool(getattr(mv, "_contour_aux_hidden", False)) and hasattr(mv, "set_contour_auxiliary_visibility"):
mv.set_contour_auxiliary_visibility(True)
mv._contour_drag_active = True
mv._contour_drag_point_index = int(nearest_idx)
mv._contour_drag_moved = False
return True
# -- eventFilter: mouse move (ЛКМ зажата — drag узла) --------------------
def on_mouse_move(self, mv: "ModelViewWidget", event) -> bool:
if not getattr(mv, "_contour_drag_active", False):
return False
if not (hasattr(event, "buttons") and (event.buttons() & Qt.MouseButton.LeftButton)):
return False
world = mv._event_world_on_plane(event, mv._get_contour_plane_z())
if world is not None and mv._update_contour_drag_point(world[0], world[1]):
mv._contour_drag_moved = True
mv._safe_render(min_interval_s=1.0 / 75.0)
return True
# -- eventFilter: mouse release (ЛКМ — завершить drag) -------------------
def on_mouse_release(self, mv: "ModelViewWidget", event) -> bool:
if event.button() != Qt.MouseButton.LeftButton:
return False
if not getattr(mv, "_contour_drag_active", False):
return False
moved = bool(getattr(mv, "_contour_drag_moved", False))
mv._contour_drag_active = False
mv._contour_drag_point_index = None
mv._contour_drag_moved = False
if moved:
mv.set_ignore_next_click(True)
return True
# -- eventFilter: hover (candidate узел + dim lines) ----------------------
def on_hover(self, mv: "ModelViewWidget", event) -> None:
if not mv.is_scenario_active("contour_edit"):
return
try:
sx, sy = mv._event_to_vtk_display_xy(event)
plane_z = mv._get_contour_plane_z()
world = mv.screen_to_world_on_plane(sx, sy, plane_z)
if world is not None:
mv.update_contour_candidate_node(world[0], world[1])
if getattr(mv, "_dim_enabled", False):
mv.handle_dim_hover(world[0], world[1])
except Exception as _exc:
log_exception(__name__, "on_hover", _exc)
# -- eventFilter: горячие клавиши -----------------------------------------
def on_key_press(self, mv: "ModelViewWidget", event) -> bool:
if event.key() == Qt.Key.Key_Escape:
# Первое ESC в контурном сценарии скрывает вспомогательные
# визуалы (candidate/dim), не прерывая визард.
if hasattr(mv, "set_contour_auxiliary_visibility") and not bool(getattr(mv, "_contour_aux_hidden", False)):
mv.set_contour_auxiliary_visibility(False)
return True
cancel = getattr(mv, "_global_cancel_handler", None)
if callable(cancel):
try:
cancel()
except Exception as _exc:
log_exception(__name__, "on_key_press", _exc)
return True
return False
# -- mousePressEvent/Move/Release: drag высоты зоны -----------------------
def on_widget_mouse_press(self, mv: "ModelViewWidget", event) -> bool:
if (
mv._zone_selection_mode
and mv._selected_cells
and event.button() == Qt.MouseButton.LeftButton
):
mv._dragging = True
mv._drag_start_y = event.y()
event.accept()
return True
return False
def on_widget_mouse_move(self, mv: "ModelViewWidget", event) -> bool:
if mv._dragging and mv._zone_selection_mode:
delta_y = mv._drag_start_y - event.y()
if delta_y != 0:
mv.update_height_from_mouse(delta_y)
mv._drag_start_y = event.y()
event.accept()
return True
return False
def on_widget_mouse_release(self, mv: "ModelViewWidget", event) -> bool:
if mv._dragging and event.button() == Qt.MouseButton.LeftButton:
mv._dragging = False
event.accept()
return True
return False

View File

@@ -0,0 +1,122 @@
# -*- coding: utf-8 -*-
# gui/components/model_view/_scenario_custom_handler.py
"""Универсальный сценарий с инжектированными обработчиками.
Покрывает режимы, использующие сценарный push/pop в InteractionManager:
- rack_placement (размещение стеллажей)
- rack_select (выбор стеллажа в зоне)
- shelf_placement (размещение полок)
- measure (режим измерений)
- origin_point (выбор точки привязки)
Каждый экземпляр получает уникальное имя и набор обработчиков.
"""
from __future__ import annotations
import math
from typing import TYPE_CHECKING, Callable, Optional
from PySide6.QtCore import Qt
from gui.components.model_view._interaction_scenario import (
CameraPolicy,
InteractionScenario,
)
from error_logger import log_exception
if TYPE_CHECKING: # pragma: no cover
from gui.components.model_view_widget import ModelViewWidget
class CustomHandlerScenario(InteractionScenario):
"""Сценарий с инжектированными hover/click обработчиками и горячими клавишами."""
def __init__(
self,
name: str,
*,
camera_policy: CameraPolicy = CameraPolicy.FREE,
click_handler: Optional[Callable] = None,
hover_handler: Optional[Callable] = None,
hover_screen_handler: Optional[Callable] = None,
hotkeys: Optional[dict[int, Callable]] = None,
on_activate_fn: Optional[Callable] = None,
on_deactivate_fn: Optional[Callable] = None,
) -> None:
self.name = name
self.camera_policy = camera_policy
self._click_handler = click_handler
self._hover_handler = hover_handler
self._hover_screen_handler = hover_screen_handler
self._hotkeys: dict[int, Callable] = hotkeys or {}
self._on_activate_fn = on_activate_fn
self._on_deactivate_fn = on_deactivate_fn
def on_activate(self, mv: "ModelViewWidget") -> None:
# Установить обработчики в mv
if self._click_handler is not None:
mv._custom_click_handler = self._click_handler
if self._hover_handler is not None:
mv._hover_handler = self._hover_handler
if self._hover_screen_handler is not None:
mv._hover_screen_handler = self._hover_screen_handler
if self._on_activate_fn:
self._on_activate_fn(mv)
def on_deactivate(self, mv: "ModelViewWidget") -> None:
if self._on_deactivate_fn:
self._on_deactivate_fn(mv)
# Очищаем обработчики, если они были установлены нами
if self._click_handler is not None:
if mv._custom_click_handler is self._click_handler:
mv._custom_click_handler = None
if mv._hover_handler is self._hover_handler:
mv._hover_handler = None
if mv._hover_screen_handler is self._hover_screen_handler:
mv._hover_screen_handler = None
def on_hover(self, mv: "ModelViewWidget", event) -> None:
"""Вызов инжектированных hover-обработчиков."""
try:
if self._hover_screen_handler is not None:
self._hover_screen_handler(event.position().x(), event.position().y())
if self._hover_handler is not None:
pos = mv._plotter.pick_mouse_position() if mv._plotter else None
if pos is None and mv._plotter and mv._plotter.picker and mv._plotter.renderer:
try:
sx, sy = mv._event_to_vtk_display_xy(event)
mv._plotter.picker.Pick(sx, sy, 0, mv._plotter.renderer)
pos = mv._plotter.picker.GetPickPosition()
except Exception as _exc:
pos = None
if pos is not None:
x, y, z = pos[0], pos[1], pos[2]
if all(math.isfinite(v) for v in (x, y, z)):
mv._last_hover_point = (x, y, z)
self._hover_handler(x, y, z)
elif mv._last_hover_point is not None:
self._hover_handler(*mv._last_hover_point)
except Exception as _exc:
log_exception(__name__, "on_hover", _exc)
def on_key_press(self, mv: "ModelViewWidget", event) -> bool:
handler = self._hotkeys.get(event.key())
if handler is not None:
try:
result = handler()
return bool(result) if result is not None else True
except Exception as _exc:
log_exception(__name__, "on_key_press", _exc)
return True
# Глобальный Esc fallback
if event.key() == Qt.Key.Key_Escape:
cancel = getattr(mv, "_global_cancel_handler", None)
if callable(cancel):
try:
cancel()
except Exception as _exc:
log_exception(__name__, "on_key_press", _exc)
return True
return False

View File

@@ -0,0 +1,39 @@
# -*- coding: utf-8 -*-
# gui/components/model_view/_scenario_facility_browse.py
"""Сценарий обзора уровня facility.
Hover по зонам: подсветка зоны под курсором (vtkPropPicker).
Клики обрабатываются через plotter callback (_on_plotter_click), не здесь.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from gui.components.model_view._interaction_scenario import (
CameraPolicy,
InteractionScenario,
)
from error_logger import log_exception
if TYPE_CHECKING: # pragma: no cover
from gui.components.model_view_widget import ModelViewWidget
class FacilityBrowseScenario(InteractionScenario):
"""Обзор уровня facility: hover-подсветка зон."""
name = "facility_browse"
camera_policy = CameraPolicy.FREE
def on_activate(self, mv: "ModelViewWidget") -> None:
mv._zone_pick_enabled = True
def on_hover(self, mv: "ModelViewWidget", event) -> None:
if not mv._zone_pick_enabled:
return
try:
sx, sy = mv._event_to_vtk_display_xy(event)
mv._handle_zone_hover_event(sx, sy)
except Exception as _exc:
log_exception(__name__, "on_hover", _exc)

View File

@@ -0,0 +1,52 @@
# -*- coding: utf-8 -*-
# gui/components/model_view/contour/__init__.py
from gui.components.model_view.contour.model_view_contour_facade import ModelViewContourFacade
from gui.components.model_view.contour.legacy_contour_binding_component import LegacyContourBindingComponent
from gui.components.model_view.contour.contour_definition_component import ContourDefinitionComponent
from gui.components.model_view.contour.contour_definition_component_part2 import (
ContourDefinitionComponentPart2,
)
from gui.components.model_view.contour.contour_overlay_component import ContourOverlayComponent
from gui.components.model_view.contour.contour_overlay_component_part2 import (
ContourOverlayComponentPart2,
)
from gui.components.model_view.contour.contour_overlay_selection_component import (
ContourOverlaySelectionComponent,
)
from gui.components.model_view.contour.contour_visualization_component import (
ContourVisualizationComponent,
)
from gui.components.model_view.contour.contour_visualization_component_part2 import (
ContourVisualizationComponentPart2,
)
from gui.components.model_view.contour.contour_geometry_component import ContourGeometryComponent
from gui.components.model_view.contour.contour_geometry_component_part2 import (
ContourGeometryComponentPart2,
)
from gui.components.model_view.contour.contour_selection_component import ContourSelectionComponent
from gui.components.model_view.contour.contour_preview_component import ContourPreviewComponent
from gui.components.model_view.contour.contour_preview_component_part2 import (
ContourPreviewComponentPart2,
)
from gui.components.model_view.contour.contour_preview_contour_component import (
ContourPreviewContourComponent,
)
__all__ = [
"ModelViewContourFacade",
"LegacyContourBindingComponent",
"ContourDefinitionComponent",
"ContourDefinitionComponentPart2",
"ContourOverlayComponent",
"ContourOverlayComponentPart2",
"ContourOverlaySelectionComponent",
"ContourVisualizationComponent",
"ContourVisualizationComponentPart2",
"ContourGeometryComponent",
"ContourGeometryComponentPart2",
"ContourSelectionComponent",
"ContourPreviewComponent",
"ContourPreviewComponentPart2",
"ContourPreviewContourComponent",
]

Some files were not shown because too many files have changed in this diff Show More