438 lines
19 KiB
Python
438 lines
19 KiB
Python
# -*- 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 в корне проекта.
|