# -*- coding: utf-8 -*- # gui/components/part_visualizer.py """Визуализатор деталей с 3D моделями и фотографиями.""" from pathlib import Path import yaml from PySide6.QtWidgets import QSizePolicy from collections.abc import Callable from PySide6.QtCore import Qt, Signal, Slot from gui.containers import StackContainer, HContainer, VContainer, SContainer from gui.components.button import Button from gui.components.simple_model_viewer.model_view_widget import ModelViewWidget as SimpleModelViewWidget from gui.components.photo_view_widget import PhotoViewWidget class PartVisualizer(SContainer): """Визуализатор деталей с 3D моделями и фотографиями.""" part_loaded = Signal(str) error_occurred = Signal(str) view_mode_changed = Signal(str) # режим: '3d' | 'photos' def __init__( self, width: int = None, height: int = None, row_percentages: list = None, col_percentages: list = None, parent=None, content_fit: bool = True, ): super().__init__(width_percent=None, height_percent=None, parent=parent) self._content_fit = content_fit # Сохраняем параметры в локальные переменные self._initial_width = width self._initial_height = height # Проценты ячеек (по умолчанию: [10, 80, 10]) self._row_percentages = row_percentages or [10, 80, 10] self._col_percentages = col_percentages or [10, 80, 10] # Текущие состояния self._current_part = None self._catalog = {} self.load_catalog() # Виджеты для отображения self._model_view = SimpleModelViewWidget() self._photo_view = PhotoViewWidget(content_fit=content_fit) self._button_index = 0 self.setup_ui() self.connect_signals() # Устанавливаем начальный размер ТОЛЬКО если он был явно задан if self._initial_width is not None and self._initial_height is not None: self.set_size(self._initial_width, self._initial_height) else: # Если размер не задан, разрешаем растягивание self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) def set_size(self, width: int, height: int): """ Установить размер виджета. Вызывается из IncomingModule при изменении размера. """ # Проверяем, что width и height - целые числа try: width_int = int(width) height_int = int(height) except (ValueError, TypeError): print(f"Ошибка: неверные значения размера: width={width}, height={height}") width_int = 600 height_int = 600 self.setMinimumSize(width_int, height_int) self.setMaximumSize(width_int, height_int) # Layout обновится штатным resizeEvent. def set_cell_percentages(self, row_percentages: list, col_percentages: list): """ Установить процентные размеры ячеек. Проценты автоматически нормализуются до 100%. Args: row_percentages: Список процентов для строк [10, 80,1 0] col_percentages: Список процентов для столбцов [10, 80, 10] """ self._row_percentages = row_percentages self._col_percentages = col_percentages # Grid layout убран; метод сохранён для совместимости API. def setup_ui(self): """Настройка интерфейса визуализатора.""" root = VContainer(margin=0, spacing=8, parent=self) # Центральный стек (3D модель или фото). self._stack_container = StackContainer() self._stack_container.set_size_policy(QSizePolicy.Expanding, QSizePolicy.Expanding) self._stack_container.add_widget(self._model_view) self._stack_container.add_widget(self._photo_view) self._stack_container.set_current_index(0) # Кнопки режимов и действий. self._btn_3d_mode = self._create_button("3D модель", self.switch_to_3d_mode) self._btn_photo_mode = self._create_button("Галерея", self.switch_to_photo_mode) self._btn_reset = self._create_button("Сброс", self.on_reset_clicked) self._btn_isometric = self._create_button("Изометрия", self.on_isometric_clicked) # Кнопки видов (без стрелок). self._btn_front_view = self._create_button("Спереди", self.on_front_view_clicked) self._btn_top_view = self._create_button("Сверху", self.on_top_view_clicked) self._btn_left_view = self._create_button("Слева", self.on_left_view_clicked) self._btn_right_view = self._create_button("Справа", self.on_right_view_clicked) controls = VContainer(spacing=6) row_main = HContainer(spacing=6) row_views = HContainer(spacing=6) for btn in ( self._btn_3d_mode, self._btn_photo_mode, self._btn_reset, self._btn_isometric, ): row_main.add_widget_with_stretch(btn, 1) for btn in ( self._btn_front_view, self._btn_top_view, self._btn_left_view, self._btn_right_view, ): row_views.add_widget_with_stretch(btn, 1) controls.add_widget(row_main) controls.add_widget(row_views) self._controls_container = controls root.add_widget_with_stretch(self._stack_container, 1) root.add_widget(controls) # Инициализируем состояние кнопок. self._is_3d_mode = True self.update_button_styles() def add_control_buttons_row( self, button_specs: list[tuple[str, Callable[[], None]]], button_stretch: int = 1, ) -> list[Button]: """Добавить дополнительный ряд кнопок под стандартными контролами.""" if not button_specs: return [] row = HContainer(spacing=6) created_buttons: list[Button] = [] for text, callback in button_specs: btn = self._create_button(text, callback) row.add_widget_with_stretch(btn, button_stretch) created_buttons.append(btn) self._controls_container.add_widget(row) return created_buttons def _create_button(self, text, callback): """Создать обычную кнопку.""" btn = Button( text, index=self._next_button_index(), style="VISUALIZATION_BUTTON_FILL", active_style="VISUALIZATION_BUTTON_ACTIVE", ) btn.set_size_policy(QSizePolicy.Expanding, QSizePolicy.Expanding) btn.set_min_height(38) btn.clicked.connect(callback) return btn def _next_button_index(self) -> int: idx = self._button_index self._button_index += 1 return idx def update_button_styles(self): """Обновить стили кнопок режимов""" if self._is_3d_mode: self._btn_3d_mode.style(is_active=True) self._btn_photo_mode.style(is_active=False) else: self._btn_photo_mode.style(is_active=True) self._btn_3d_mode.style(is_active=False) def resizeEvent(self, event): """Обработчик изменения размера""" super().resizeEvent(event) # Стек/компоновка обновляются стандартным механизмом Qt resize. def set_enabled(self, enabled: bool) -> None: self.setEnabled(enabled) def set_min_width(self, width: int) -> None: self.setMinimumWidth(width) def set_min_height(self, height: int) -> None: self.setMinimumHeight(height) def set_max_width(self, width: int) -> None: self.setMaximumWidth(width) def set_max_height(self, height: int) -> None: self.setMaximumHeight(height) def set_fixed_size(self, width: int, height: int) -> None: self.setMinimumSize(width, height) self.setMaximumSize(width, height) def set_tooltip(self, text: str) -> None: self.setToolTip(text) def set_size_policy(self, horizontal, vertical) -> None: self.setSizePolicy(horizontal, vertical) # Остальные методы без изменений def load_catalog(self): """Загрузка каталога деталей""" catalog_path = Path("parts_catalog.yaml") if catalog_path.exists(): try: with open(catalog_path, "r", encoding="utf-8") as f: self._catalog = yaml.safe_load(f) or {} except Exception as e: self.error_occurred.emit(f"Ошибка загрузки каталога: {e}") self._catalog = {} def connect_signals(self): """Подключение сигналов""" self._model_view.error_occurred.connect(self.on_error) self._photo_view.error_occurred.connect(self.on_error) def switch_to_3d_mode(self): """Переключить на режим 3D""" if not self._is_3d_mode: self._is_3d_mode = True self._stack_container.set_current_index(0) self.view_mode_changed.emit("3d") self.update_button_styles() if self._current_part and '3d_model' in self._catalog.get('parts', {}).get(self._current_part, {}): self._model_view.load_model(self._catalog['parts'][self._current_part]['3d_model']) def switch_to_photo_mode(self): """Переключить на режим фото""" if self._is_3d_mode: self._is_3d_mode = False self._stack_container.set_current_index(1) self.view_mode_changed.emit("photos") self.update_button_styles() if self._current_part and 'images' in self._catalog.get('parts', {}).get(self._current_part, {}): self._photo_view.load_images(self._catalog['parts'][self._current_part]['images']) def on_top_view_clicked(self): """Обработчик кнопки верхнего вида.""" if self._is_3d_mode: self._model_view.set_top_view() else: self._show_photo_view("top", ["top", "front", "isometric"]) def on_front_view_clicked(self): """Обработчик кнопки переднего вида.""" if self._is_3d_mode: self._model_view.set_front_view() else: self._show_photo_view("front", ["front", "isometric"]) def on_left_view_clicked(self): """Обработчик кнопки левого вида.""" if self._is_3d_mode: self._model_view.set_left_view() else: self._show_photo_view("left", ["left", "front", "isometric"]) def on_right_view_clicked(self): """Обработчик кнопки правого вида.""" if self._is_3d_mode: self._model_view.set_right_view() else: self._show_photo_view("right", ["right", "front", "isometric"]) def on_reset_clicked(self): """Обработчик кнопки сброса""" if self._is_3d_mode: self._model_view.reset_view() else: self._photo_view.reset_zoom() images = getattr(self._photo_view, "_images", {}) if images: first_view = list(images.keys())[0] self._photo_view.show_view(first_view) def on_isometric_clicked(self): """Обработчик кнопки изометрического вида""" if self._is_3d_mode: self._model_view.set_isometric_view() else: self._photo_view.switch_to_isometric_or_next() @Slot(str) def on_error(self, message): """Обработчик ошибок""" self.error_occurred.emit(message) def _show_photo_view(self, primary: str, fallback_order: list[str]) -> None: images = getattr(self._photo_view, "_images", {}) if not images: return if primary in images: self._photo_view.show_view(primary) return for name in fallback_order: if name in images: self._photo_view.show_view(name) return @Slot(str) def load_part(self, part_name: str): """Загрузить деталь""" if part_name not in self._catalog.get('parts', {}): self.error_occurred.emit(f"Деталь '{part_name}' не найдена в каталоге") return part_data = self._catalog['parts'][part_name] self._current_part = part_name if self._is_3d_mode: if '3d_model' in part_data: self._model_view.load_model(part_data['3d_model']) else: self._model_view.show_error("3D модель отсутствует") else: if 'images' in part_data: self._photo_view.load_images(part_data['images']) else: self._photo_view.show_error("Фотографии отсутствуют") self.part_loaded.emit(part_name) # --------------------------------------------------------------------------- # Module workflow notes # --------------------------------------------------------------------------- # # 1) Назначение модуля: # Визуализатор деталей, объединяющий 3D-просмотрщик # (SimpleModelViewWidget) и галерею фотографий (PhotoViewWidget) # через StackContainer с кнопками переключения режимов и видов. # # 2) Зависимости модуля: # Импорты: Path (pathlib), yaml, # QSizePolicy (PySide6.QtWidgets), # Callable (collections.abc), # Qt, Signal, Slot (PySide6.QtCore), # StackContainer, HContainer, VContainer, SContainer (gui.containers), # Button (gui.components.button), # SimpleModelViewWidget (gui.components.simple_model_viewer.model_view_widget), # PhotoViewWidget (gui.components.photo_view_widget) # Хост-класс / базовый класс: SContainer # Внешние библиотеки: PySide6 (обязательна), yaml (обязательна), # pyvista (опционально, через SimpleModelViewWidget) # # 3) Экспорт: # Класс PartVisualizer — публичный виджет визуализации деталей. # Сигналы: part_loaded(str), error_occurred(str), view_mode_changed(str). # Методы: load_part(), switch_to_3d_mode(), switch_to_photo_mode(), # set_size(), set_cell_percentages(), add_control_buttons_row(), # on_top/front/left/right_view_clicked(), on_reset_clicked(), # on_isometric_clicked(). # # 4) Состояние (поля): # _content_fit: bool — режим подгонки содержимого # _initial_width/height: int — явная размерность (или None) # _row/col_percentages: list — проценты ячеек (наследие grid layout) # _current_part: str|None — ключ текущей детали из каталога # _catalog: dict — данные из parts_catalog.yaml # _model_view: SimpleModelViewWidget — 3D-просмотрщик # _photo_view: PhotoViewWidget — просмотрщик фотографий # _stack_container: StackContainer — переключатель view/photo # _is_3d_mode: bool — текущий режим # _button_index: int — автоинкрементный индекс кнопок # _btn_*: Button — кнопки управления # _controls_container: VContainer — контейнер кнопок # # 5) Последовательность действий и вызовов: # __init__(width?, height?, row_percentages?, col_percentages?, ...) # -> super().__init__(...) # -> load_catalog() — загрузка parts_catalog.yaml # -> SimpleModelViewWidget(), PhotoViewWidget() # -> setup_ui() — построение UI # -> VContainer(root) -> StackContainer с model_view и photo_view # -> кнопки переключения режимов (3D, Галерея, Сброс, Изометрия) # -> кнопки видов (Спереди, Сверху, Слева, Справа) # -> connect_signals() — подключение error сигналов # -> set_size() или setSizePolicy # load_part(part_name) # -> проверяет каталог -> загружает 3D модель или фотографии # -> part_loaded.emit(part_name) # switch_to_3d_mode() / switch_to_photo_mode() # -> set_current_index(0|1) -> view_mode_changed.emit() -> update_button_styles() # # 6) Побочные эффекты: # - Читает файл parts_catalog.yaml при создании. # - Загружает 3D модели (STL) и изображения с диска. # - Испускает сигналы part_loaded, error_occurred, view_mode_changed. # # 7) Границы ответственности: # Модуль НЕ редактирует каталог. НЕ управляет стеллажами. # НЕ взаимодействует с БД. Визуализация только для просмотра. # # 8) Обработка ошибок: # load_catalog() оборачивает yaml.safe_load в try/except, # испуская error_occurred. load_part() проверяет нахождение в каталоге. # set_size() ловит ValueError/TypeError и подставляет фоллбек 600×600. # # 9) Инварианты и контракты: # - _stack_container индекс 0 — 3D, индекс 1 — фото. # - _catalog — dict с ключом 'parts' -> {part_name: {3d_model, images}}. # - _is_3d_mode синхронизирован с _stack_container.current_index. # # 10) Правило сопровождения: # Для добавления нового вида кнопки — использовать add_control_buttons_row(). # Кнопки создавать через _create_button(). Формат каталога — # parts_catalog.yaml в корне проекта.