# -*- coding: utf-8 -*- # gui/components/model_view_widget.py """Виджет отображения и управления 3D-моделью с поддержкой зон. Данный файл использует фасад composition-слоя `gui.components.model_view.contour` и миксины с функционалом, которые внедрены в `ModelViewWidget`. """ from typing import Optional, Tuple, Callable from gui.containers import VContainer, SContainer from PySide6.QtCore import Signal from gui.theme_bus import theme_bus from error_logger import log_exception from gui.components.model_view._interaction_scenario import InteractionManager from gui.components.model_view._scenario_camera import CameraScenario from gui.components.model_view._scenario_facility_browse import FacilityBrowseScenario from gui.components.model_view.contour import ( ModelViewContourFacade, LegacyContourBindingComponent, ) from gui.components.model_view import ( ModelLoadingMixin, ZoneManagementMixin, InteractionMixin, VisualHelpersMixin, GridCoreMixin, DimensionLinesMixin, RackPlacementMixin, ScenePresentationMixin, SceneModesMixin, RackCameraTransitionMixin, ZoneCameraTransitionMixin, RackPlacementIOMixin, ) class ModelViewWidget( InteractionMixin, ModelLoadingMixin, ZoneManagementMixin, VisualHelpersMixin, GridCoreMixin, DimensionLinesMixin, RackPlacementMixin, ScenePresentationMixin, SceneModesMixin, RackCameraTransitionMixin, ZoneCameraTransitionMixin, RackPlacementIOMixin, SContainer, ): """Виджет для отображения 3D моделей помещения и зон.""" # Сигналы error_occurred = Signal(str) zone_selected = Signal(str) # ид. зоны zone_double_clicked = Signal(str) # двойной ЛКМ по зоне camera_mode_changed = Signal(bool) # True = заблокирована grid_volume_changed = Signal(float, float, float) # ширина, глубина, высота contour_ready_changed = Signal(bool) # контур ортогонален и пригоден для объёма rack_layout_changed = Signal(str) # zone_id rack_slot_visibility_changed = Signal(str, str, bool) # rack_id, slot_id, occupied rack_selected_changed = Signal(str) # ид. стеллажа или "" rack_double_clicked = Signal(str, str) # rack_id, zone_id shelf_slot_selected = Signal(str, str) # rack_id, slot_id shelf_slot_double_clicked = Signal(str, str, int) # rack_id, slot_id, shelf_index def __init__( self, width_percent: int | None = None, height_percent: int | None = None, margin: int | tuple[int, int, int, int] = 0, content_fit: bool = True, parent=None, style: str | None = None, active_style: str | None = None, is_active: bool | None = None, ): super().__init__( width_percent=width_percent, height_percent=height_percent, content_fit=content_fit, parent=parent, style=style, active_style=active_style, is_active=is_active, ) self._content_margin = margin self._content_fit = content_fit self._plotter = None self._models_loaded = False self._camera_locked = False self._theme = "dark" # Данные моделей self._floor_mesh = None self._walls_mesh = None self._ceiling_mesh = None self._truss_mesh = None self._rack_meshes: list = [] self._zones: dict = {} # ид. зоны -> mesh self._zone_data: dict = {} # zone_id -> (min_x, max_x, min_y, max_y, min_z, max_z) self._zone_polygons: dict = {} # ид. зоны -> list[(x, y)] self._zone_heights: dict = {} # zone_id -> (start_z, height) self._model_actors: dict = {} # ключ -> actor self._missing_models: set = set() # Интерактивное размещение self._preview_zone = None self._current_zone_size = 100 # мм self._current_zone_z_size = 100 # мм, шаг по высоте Z self._current_zone_color = "#FF6B6B80" # Ограничивающий параллелепипед self._room_bounds = None # (min_x, max_x, min_y, max_y, min_z, max_z) # Режим выбора зон с сеткой self._zone_selection_mode = False self._zone_pick_enabled = True self._selected_zone_highlight_id: Optional[str] = None self._selected_zone_original_opacity: Optional[float] = None self._selected_zone_original_visibility: Optional[bool] = None self._hover_highlighted_zone_id: Optional[str] = None self._hover_zone_original_opacity: Optional[float] = None self._hover_zone_original_edges: bool = False self._hover_zone_original_visibility: Optional[bool] = None self._hover_zone_contour_original_color: Optional[tuple] = None self._grid_meshes: list = [] self._selected_cells: set = set() self._grid_cells: dict = {} self._grid_origin = (0, 0, 0) self._selected_height = 0 self._selection_anchor = None # (min_x, min_y) self._contour_zone_overlay_enabled = False self._contour_zone_mode = None self._contour_zone_id = None self._contour_ignore_zone_id = None self._contour_ready = False self._grid_plane_z_override = None # Кэш сетки — повторное использование сетки без пересоздания self._grid_ready = False self._grid_cached_cell_size = 0 self._grid_cached_z_size = 0 # Атрибуты контура/сетки — инициализация для стабильности self._grid_nodes: list = [] self._grid_surface_meshes: list = [] self._grid_surface_nodes: list[tuple[float, float]] = [] self._contour_points: list = [] self._final_contour_points: list = [] self._contour_points_actor = None self._contour_lines_actor = None self._contour_drag_active = False self._contour_drag_point_index: Optional[int] = None self._contour_drag_moved = False self._selection_start_cell = None self._volume_locked_from_contour = False self._last_volume_start_height = 0.0 # Перетаскивание self._dragging = False self._drag_start_y = 0 self._cam_rotate_active = False self._cam_pan_active = False self._cam_last_pos: Optional[Tuple[float, float]] = None self._last_safe_render_ts = 0.0 self._custom_click_handler: Optional[Callable[[float, float, float], bool]] = None self._hover_handler: Optional[Callable[[float, float, float], None]] = None self._hover_screen_handler: Optional[Callable[[float, float], None]] = None self._origin_marker = None self._origin_preview_marker = None self._quadrant_actors: list = [] self._quadrant_label_actors: list = [] self._axes_actors: list = [] self._last_hover_point: Optional[Tuple[float, float, float]] = None self._corner_points: list[tuple[float, float, float]] = [] self._measure_ref_point: tuple[float, float, float] = (0.0, 0.0, 0.0) self._measure_direction: tuple[float, float] = (1.0, 1.0) self._ignore_next_plotter_click = False # Размерные линии self.init_dimension_lines() self.init_rack_placement() # Менеджер сценариев взаимодействия self._interaction_manager = InteractionManager(self) self._interaction_manager.set_camera_scenario(CameraScenario()) self._interaction_manager.push(FacilityBrowseScenario()) self._legacy_contour_binding = LegacyContourBindingComponent(self) self._legacy_contour_binding.install() self.contour_facade = ModelViewContourFacade(self) self.setup_ui() theme_bus.theme_changed.connect(self.set_theme) # -- Интерфейс ------------------------------------------------------------------- def setup_ui(self): """Настройка интерфейса.""" self._main_container = VContainer( margin=self._content_margin, content_fit=self._content_fit, parent=self, ) # -- простые сеттеры / свойства ------------------------------------------ def show_error(self, message): """Показать сообщение об ошибке.""" self.error_occurred.emit(message) def update_scene(self) -> None: """Перерисовать 3D-сцену.""" if self._plotter: self._plotter.update() def set_ignore_next_click(self, ignore: bool) -> None: """Пропустить следующий клик по plotter.""" self._ignore_next_plotter_click = bool(ignore) def is_camera_locked(self) -> bool: """Заблокирована ли камера.""" return bool(self._camera_locked) def is_scenario_active(self, name: str) -> bool: """Проверить, активен ли сценарий взаимодействия по имени.""" mgr = getattr(self, "_interaction_manager", None) if mgr is not None: return mgr.is_active(name) return False def pop_interaction_scenario(self, name: str) -> None: """Снять сценарий взаимодействия по имени.""" if self._interaction_manager is not None: self._interaction_manager.pop_by_name(name) def set_theme(self, theme: str) -> None: theme = (theme or "").strip().lower() if theme not in ("dark", "light"): return if self._theme == theme: return self._theme = theme if hasattr(self, "_apply_grid_theme"): try: self._apply_grid_theme() except Exception as e: log_exception(__name__, "set_theme", e) def set_zone_pick_enabled(self, enabled: bool) -> None: """Разрешить выбор существующих зон по клику.""" self._zone_pick_enabled = bool(enabled) if not enabled: self.clear_selected_zone_highlight() self._unhighlight_hover_zone() def set_current_zone_color(self, color: str) -> None: """Установить цвет предпросмотра зоны (#RRGGBBAA).""" self._current_zone_color = color @property def room_bounds(self): return self._room_bounds @property def zone_selection_mode(self) -> bool: """Возвращает режим выбора зоны в 3D сцене.""" return self._zone_selection_mode