Files
Dispatch/Dispatch_V0.1.1/gui/components/part_visualizer.py
2026-04-29 08:18:54 +04:00

438 lines
19 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- 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 в корне проекта.