Add Dispatch_V0.1.1
This commit is contained in:
32
Dispatch_V0.1.1/gui/components/model_view/__init__.py
Normal file
32
Dispatch_V0.1.1/gui/components/model_view/__init__.py
Normal 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",
|
||||
]
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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
|
||||
362
Dispatch_V0.1.1/gui/components/model_view/_mv_dim_lines_grid.py
Normal file
362
Dispatch_V0.1.1/gui/components/model_view/_mv_dim_lines_grid.py
Normal 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 собирается как часть расчета, но в текущей версии
|
||||
# намеренно не выводится отдельным актором.
|
||||
1100
Dispatch_V0.1.1/gui/components/model_view/_mv_dim_lines_volume.py
Normal file
1100
Dispatch_V0.1.1/gui/components/model_view/_mv_dim_lines_volume.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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),
|
||||
# сохраняя этот файл как композиционный и конфигурационный центр.
|
||||
|
||||
|
||||
36
Dispatch_V0.1.1/gui/components/model_view/_mv_grid_core.py
Normal file
36
Dispatch_V0.1.1/gui/components/model_view/_mv_grid_core.py
Normal 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) Правило сопровождения:
|
||||
# - Любое изменение сценария должно сопровождаться обновлением этого блока с сохранением фактического порядка вызовов.
|
||||
# - При добавлении метода указывать его место в цепочке сценария (запуск, основной шаг, завершение, вспомогательная логика).
|
||||
658
Dispatch_V0.1.1/gui/components/model_view/_mv_grid_core_base.py
Normal file
658
Dispatch_V0.1.1/gui/components/model_view/_mv_grid_core_base.py
Normal 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) Правило сопровождения:
|
||||
# - Любое изменение сценария должно сопровождаться обновлением этого блока с сохранением фактического порядка вызовов.
|
||||
# - При добавлении метода указывать его место в цепочке сценария (запуск, основной шаг, завершение, вспомогательная логика).
|
||||
136
Dispatch_V0.1.1/gui/components/model_view/_mv_grid_core_floor.py
Normal file
136
Dispatch_V0.1.1/gui/components/model_view/_mv_grid_core_floor.py
Normal 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) Правило сопровождения:
|
||||
# - Любое изменение сценария должно сопровождаться обновлением этого блока с сохранением фактического порядка вызовов.
|
||||
# - При добавлении метода указывать его место в цепочке сценария (запуск, основной шаг, завершение, вспомогательная логика).
|
||||
41
Dispatch_V0.1.1/gui/components/model_view/_mv_interaction.py
Normal file
41
Dispatch_V0.1.1/gui/components/model_view/_mv_interaction.py
Normal 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) Правило сопровождения:
|
||||
# - Любое изменение сценария должно сопровождаться обновлением этого блока с сохранением фактического порядка вызовов.
|
||||
# - При добавлении метода указывать его место в цепочке сценария (запуск, основной шаг, завершение, вспомогательная логика).
|
||||
@@ -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.
|
||||
# - Очистка состояния идемпотентна.
|
||||
@@ -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)
|
||||
486
Dispatch_V0.1.1/gui/components/model_view/_mv_interaction_nav.py
Normal file
486
Dispatch_V0.1.1/gui/components/model_view/_mv_interaction_nav.py
Normal 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) Правило сопровождения:
|
||||
# - Любое изменение сценария должно сопровождаться обновлением этого блока с сохранением фактического порядка вызовов.
|
||||
# - При добавлении метода указывать его место в цепочке сценария (запуск, основной шаг, завершение, вспомогательная логика).
|
||||
409
Dispatch_V0.1.1/gui/components/model_view/_mv_model_loading.py
Normal file
409
Dispatch_V0.1.1/gui/components/model_view/_mv_model_loading.py
Normal 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) Правило сопровождения:
|
||||
# - Любое изменение сценария должно сопровождаться обновлением этого блока с сохранением фактического порядка вызовов.
|
||||
# - При добавлении метода указывать его место в цепочке сценария (запуск, основной шаг, завершение, вспомогательная логика).
|
||||
131
Dispatch_V0.1.1/gui/components/model_view/_mv_presentation.py
Normal file
131
Dispatch_V0.1.1/gui/components/model_view/_mv_presentation.py
Normal 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) Правило сопровождения:
|
||||
# - Любое изменение сценария должно сопровождаться обновлением этого блока с сохранением фактического порядка вызовов.
|
||||
# - При добавлении метода указывать его место в цепочке сценария (запуск, основной шаг, завершение, вспомогательная логика).
|
||||
197
Dispatch_V0.1.1/gui/components/model_view/_mv_rack_geometry.py
Normal file
197
Dispatch_V0.1.1/gui/components/model_view/_mv_rack_geometry.py
Normal 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) Правило сопровождения:
|
||||
# - Любое изменение сценария должно сопровождаться обновлением этого блока с сохранением фактического порядка вызовов.
|
||||
# - При добавлении метода указывать его место в цепочке сценария (запуск, основной шаг, завершение, вспомогательная логика).
|
||||
|
||||
162
Dispatch_V0.1.1/gui/components/model_view/_mv_rack_transition.py
Normal file
162
Dispatch_V0.1.1/gui/components/model_view/_mv_rack_transition.py
Normal 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) Правило сопровождения:
|
||||
# - Любое изменение сценария должно сопровождаться обновлением этого блока с сохранением фактического порядка вызовов.
|
||||
# - При добавлении метода указывать его место в цепочке сценария (запуск, основной шаг, завершение, вспомогательная логика).
|
||||
104
Dispatch_V0.1.1/gui/components/model_view/_mv_racks.py
Normal file
104
Dispatch_V0.1.1/gui/components/model_view/_mv_racks.py
Normal 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) Правило сопровождения:
|
||||
# - Любое изменение сценария должно сопровождаться обновлением этого блока с сохранением фактического порядка вызовов.
|
||||
# - При добавлении метода указывать его место в цепочке сценария (запуск, основной шаг, завершение, вспомогательная логика).
|
||||
321
Dispatch_V0.1.1/gui/components/model_view/_mv_racks_camera.py
Normal file
321
Dispatch_V0.1.1/gui/components/model_view/_mv_racks_camera.py
Normal 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) Правило сопровождения:
|
||||
# - Любое изменение сценария должно сопровождаться обновлением этого блока с сохранением фактического порядка вызовов.
|
||||
# - При добавлении метода указывать его место в цепочке сценария (запуск, основной шаг, завершение, вспомогательная логика).
|
||||
440
Dispatch_V0.1.1/gui/components/model_view/_mv_racks_codes.py
Normal file
440
Dispatch_V0.1.1/gui/components/model_view/_mv_racks_codes.py
Normal 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) Правило сопровождения:
|
||||
# - Любое изменение сценария должно сопровождаться обновлением этого блока с сохранением фактического порядка вызовов.
|
||||
# - При добавлении метода указывать его место в цепочке сценария (запуск, основной шаг, завершение, вспомогательная логика).
|
||||
|
||||
378
Dispatch_V0.1.1/gui/components/model_view/_mv_racks_collision.py
Normal file
378
Dispatch_V0.1.1/gui/components/model_view/_mv_racks_collision.py
Normal 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) Правило сопровождения:
|
||||
# - Любое изменение сценария должно сопровождаться обновлением этого блока с сохранением фактического порядка вызовов.
|
||||
# - При добавлении метода указывать его место в цепочке сценария (запуск, основной шаг, завершение, вспомогательная логика).
|
||||
425
Dispatch_V0.1.1/gui/components/model_view/_mv_racks_crud.py
Normal file
425
Dispatch_V0.1.1/gui/components/model_view/_mv_racks_crud.py
Normal 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) Правило сопровождения:
|
||||
# - Любое изменение сценария должно сопровождаться обновлением этого блока с сохранением фактического порядка вызовов.
|
||||
# - При добавлении метода указывать его место в цепочке сценария (запуск, основной шаг, завершение, вспомогательная логика).
|
||||
552
Dispatch_V0.1.1/gui/components/model_view/_mv_racks_hover.py
Normal file
552
Dispatch_V0.1.1/gui/components/model_view/_mv_racks_hover.py
Normal 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) Правило сопровождения:
|
||||
# - Любое изменение сценария должно сопровождаться обновлением этого блока с сохранением фактического порядка вызовов.
|
||||
# - При добавлении метода указывать его место в цепочке сценария (запуск, основной шаг, завершение, вспомогательная логика).
|
||||
252
Dispatch_V0.1.1/gui/components/model_view/_mv_racks_io.py
Normal file
252
Dispatch_V0.1.1/gui/components/model_view/_mv_racks_io.py
Normal 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) Правило сопровождения:
|
||||
# - Любое изменение сценария должно сопровождаться обновлением этого блока с сохранением фактического порядка вызовов.
|
||||
# - При добавлении метода указывать его место в цепочке сценария (запуск, основной шаг, завершение, вспомогательная логика).
|
||||
334
Dispatch_V0.1.1/gui/components/model_view/_mv_racks_lifecycle.py
Normal file
334
Dispatch_V0.1.1/gui/components/model_view/_mv_racks_lifecycle.py
Normal 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) Правило сопровождения:
|
||||
# - Любое изменение сценария должно сопровождаться обновлением этого блока с сохранением фактического порядка вызовов.
|
||||
# - При добавлении метода указывать его место в цепочке сценария (запуск, основной шаг, завершение, вспомогательная логика).
|
||||
164
Dispatch_V0.1.1/gui/components/model_view/_mv_racks_mezzanine.py
Normal file
164
Dispatch_V0.1.1/gui/components/model_view/_mv_racks_mezzanine.py
Normal 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) Правило сопровождения:
|
||||
# - Любое изменение сценария должно сопровождаться обновлением этого блока с сохранением фактического порядка вызовов.
|
||||
# - При добавлении метода указывать его место в цепочке сценария (запуск, основной шаг, завершение, вспомогательная логика).
|
||||
320
Dispatch_V0.1.1/gui/components/model_view/_mv_racks_move.py
Normal file
320
Dispatch_V0.1.1/gui/components/model_view/_mv_racks_move.py
Normal 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) Правило сопровождения:
|
||||
# - Любое изменение сценария должно сопровождаться обновлением этого блока с сохранением фактического порядка вызовов.
|
||||
# - При добавлении метода указывать его место в цепочке сценария (запуск, основной шаг, завершение, вспомогательная логика).
|
||||
|
||||
505
Dispatch_V0.1.1/gui/components/model_view/_mv_racks_picking.py
Normal file
505
Dispatch_V0.1.1/gui/components/model_view/_mv_racks_picking.py
Normal 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) Правило сопровождения:
|
||||
# - Любое изменение сценария должно сопровождаться обновлением этого блока с сохранением фактического порядка вызовов.
|
||||
# - При добавлении метода указывать его место в цепочке сценария (запуск, основной шаг, завершение, вспомогательная логика).
|
||||
338
Dispatch_V0.1.1/gui/components/model_view/_mv_racks_preview.py
Normal file
338
Dispatch_V0.1.1/gui/components/model_view/_mv_racks_preview.py
Normal 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) Правило сопровождения:
|
||||
# - Любое изменение сценария должно сопровождаться обновлением этого блока с сохранением фактического порядка вызовов.
|
||||
# - При добавлении метода указывать его место в цепочке сценария (запуск, основной шаг, завершение, вспомогательная логика).
|
||||
@@ -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) Правило сопровождения:
|
||||
# - Любое изменение сценария должно сопровождаться обновлением этого блока с сохранением фактического порядка вызовов.
|
||||
# - При добавлении метода указывать его место в цепочке сценария (запуск, основной шаг, завершение, вспомогательная логика).
|
||||
412
Dispatch_V0.1.1/gui/components/model_view/_mv_racks_selection.py
Normal file
412
Dispatch_V0.1.1/gui/components/model_view/_mv_racks_selection.py
Normal 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) Правило сопровождения:
|
||||
# - Любое изменение сценария должно сопровождаться обновлением этого блока с сохранением фактического порядка вызовов.
|
||||
# - При добавлении метода указывать его место в цепочке сценария (запуск, основной шаг, завершение, вспомогательная логика).
|
||||
319
Dispatch_V0.1.1/gui/components/model_view/_mv_racks_shelf.py
Normal file
319
Dispatch_V0.1.1/gui/components/model_view/_mv_racks_shelf.py
Normal 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) Правило сопровождения:
|
||||
# - Любое изменение сценария должно сопровождаться обновлением этого блока с сохранением фактического порядка вызовов.
|
||||
# - При добавлении метода указывать его место в цепочке сценария (запуск, основной шаг, завершение, вспомогательная логика).
|
||||
1625
Dispatch_V0.1.1/gui/components/model_view/_mv_racks_shelf_render.py
Normal file
1625
Dispatch_V0.1.1/gui/components/model_view/_mv_racks_shelf_render.py
Normal file
File diff suppressed because it is too large
Load Diff
404
Dispatch_V0.1.1/gui/components/model_view/_mv_racks_visual.py
Normal file
404
Dispatch_V0.1.1/gui/components/model_view/_mv_racks_visual.py
Normal 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) Правило сопровождения:
|
||||
# - Любое изменение сценария должно сопровождаться обновлением этого блока с сохранением фактического порядка вызовов.
|
||||
# - При добавлении метода указывать его место в цепочке сценария (запуск, основной шаг, завершение, вспомогательная логика).
|
||||
@@ -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) Правило сопровождения:
|
||||
# - Любое изменение сценария должно сопровождаться обновлением этого блока с сохранением фактического порядка вызовов.
|
||||
# - При добавлении метода указывать его место в цепочке сценария (запуск, основной шаг, завершение, вспомогательная логика).
|
||||
163
Dispatch_V0.1.1/gui/components/model_view/_mv_scene_modes.py
Normal file
163
Dispatch_V0.1.1/gui/components/model_view/_mv_scene_modes.py
Normal 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) Правило сопровождения:
|
||||
# - Любое изменение сценария должно сопровождаться обновлением этого блока с сохранением фактического порядка вызовов.
|
||||
# - При добавлении метода указывать его место в цепочке сценария (запуск, основной шаг, завершение, вспомогательная логика).
|
||||
625
Dispatch_V0.1.1/gui/components/model_view/_mv_visual.py
Normal file
625
Dispatch_V0.1.1/gui/components/model_view/_mv_visual.py
Normal 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) Правило сопровождения:
|
||||
# - Любое изменение сценария должно сопровождаться обновлением этого блока с сохранением фактического порядка вызовов.
|
||||
# - При добавлении метода указывать его место в цепочке сценария (запуск, основной шаг, завершение, вспомогательная логика).
|
||||
126
Dispatch_V0.1.1/gui/components/model_view/_mv_zone_transition.py
Normal file
126
Dispatch_V0.1.1/gui/components/model_view/_mv_zone_transition.py
Normal 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) Правило сопровождения:
|
||||
# - Любое изменение сценария должно сопровождаться обновлением этого блока с сохранением фактического порядка вызовов.
|
||||
# - При добавлении метода указывать его место в цепочке сценария (запуск, основной шаг, завершение, вспомогательная логика).
|
||||
33
Dispatch_V0.1.1/gui/components/model_view/_mv_zones.py
Normal file
33
Dispatch_V0.1.1/gui/components/model_view/_mv_zones.py
Normal 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) Правило сопровождения:
|
||||
# - Любое изменение сценария должно сопровождаться обновлением этого блока с сохранением фактического порядка вызовов.
|
||||
# - При добавлении метода указывать его место в цепочке сценария (запуск, основной шаг, завершение, вспомогательная логика).
|
||||
373
Dispatch_V0.1.1/gui/components/model_view/_mv_zones_crud.py
Normal file
373
Dispatch_V0.1.1/gui/components/model_view/_mv_zones_crud.py
Normal 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) Правило сопровождения:
|
||||
# - Любое изменение сценария должно сопровождаться обновлением этого блока с сохранением фактического порядка вызовов.
|
||||
# - При добавлении метода указывать его место в цепочке сценария (запуск, основной шаг, завершение, вспомогательная логика).
|
||||
375
Dispatch_V0.1.1/gui/components/model_view/_mv_zones_highlight.py
Normal file
375
Dispatch_V0.1.1/gui/components/model_view/_mv_zones_highlight.py
Normal 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) Правило сопровождения:
|
||||
# - Любое изменение сценария должно сопровождаться обновлением этого блока с сохранением фактического порядка вызовов.
|
||||
# - При добавлении метода указывать его место в цепочке сценария (запуск, основной шаг, завершение, вспомогательная логика).
|
||||
315
Dispatch_V0.1.1/gui/components/model_view/_mv_zones_visual.py
Normal file
315
Dispatch_V0.1.1/gui/components/model_view/_mv_zones_visual.py
Normal 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) Правило сопровождения:
|
||||
# - Любое изменение сценария должно сопровождаться обновлением этого блока с сохранением фактического порядка вызовов.
|
||||
# - При добавлении метода указывать его место в цепочке сценария (запуск, основной шаг, завершение, вспомогательная логика).
|
||||
116
Dispatch_V0.1.1/gui/components/model_view/_scenario_camera.py
Normal file
116
Dispatch_V0.1.1/gui/components/model_view/_scenario_camera.py
Normal 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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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",
|
||||
]
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user