Add Dispatch_V0.1.1
This commit is contained in:
@@ -0,0 +1,22 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Устаревший пакет для простого просмотра моделей."""
|
||||
|
||||
from .model_view_widget import ModelViewWidget
|
||||
|
||||
__all__ = ["ModelViewWidget"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Module workflow notes
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# 1) Назначение модуля:
|
||||
# Пакетный __init__: реэкспорт SimpleModelViewWidget как ModelViewWidget
|
||||
# для автономного 3D-просмотра моделей (используется PartVisualizer).
|
||||
#
|
||||
# 2) Зависимости модуля:
|
||||
# Импортирует ModelViewWidget из .model_view_widget.
|
||||
#
|
||||
# 3) Экспорт:
|
||||
# __all__ = ["ModelViewWidget"] — единственный публичный символ.
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,384 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# gui/components/simple_model_viewer/model_view_widget.py
|
||||
"""Автономный простой 3D-просмотрщик моделей, используемый PartVisualizer."""
|
||||
|
||||
import math
|
||||
from pathlib import Path
|
||||
|
||||
from PySide6.QtCore import Qt, Signal, QEvent
|
||||
from PySide6.QtWidgets import QWidget
|
||||
|
||||
from gui.containers import VContainer
|
||||
from gui.styles import APP_STYLES
|
||||
from error_logger import log_exception
|
||||
|
||||
try:
|
||||
import pyvista as pv
|
||||
from pyvistaqt import QtInteractor
|
||||
PYVISTA_AVAILABLE = True
|
||||
except ImportError:
|
||||
PYVISTA_AVAILABLE = False
|
||||
print("Warning: PyVista is not installed. 3D preview is unavailable.")
|
||||
|
||||
|
||||
|
||||
class ModelViewWidget(QWidget):
|
||||
"""Компактный виджет предпросмотра STL с ограниченным управлением мышью."""
|
||||
|
||||
error_occurred = Signal(str)
|
||||
|
||||
def __init__(self, *_, **__):
|
||||
super().__init__()
|
||||
self._plotter = None
|
||||
self._model_loaded = False
|
||||
self._cam_rotate_active = False
|
||||
self._cam_pan_active = False
|
||||
self._cam_last_pos: tuple[float, float] | None = None
|
||||
self._setup_ui()
|
||||
|
||||
def _setup_ui(self) -> None:
|
||||
main_container = VContainer()
|
||||
main_container.layout().setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
self._model_layout = main_container.layout()
|
||||
self.setLayout(main_container.layout())
|
||||
self.setStyleSheet(APP_STYLES.get("VIEW_WIDGET_BACKGROUND", ""))
|
||||
|
||||
def load_model(self, model_path: str | Path) -> None:
|
||||
if not PYVISTA_AVAILABLE:
|
||||
self._show_error("PyVista is not installed")
|
||||
return
|
||||
|
||||
path = Path(model_path)
|
||||
if not path.exists():
|
||||
self._show_error(f"Model file not found: {path}")
|
||||
return
|
||||
|
||||
self.clear_model()
|
||||
|
||||
try:
|
||||
mesh = pv.read(path)
|
||||
self._plotter = QtInteractor(self)
|
||||
self._plotter.set_background((240 / 255, 240 / 255, 240 / 255))
|
||||
self._plotter.add_mesh(mesh, color="lightblue", show_edges=True)
|
||||
self._plotter.camera_position = "xy"
|
||||
self._plotter.reset_camera()
|
||||
self._configure_mouse_controls()
|
||||
self._model_layout.addWidget(self._plotter)
|
||||
self._model_loaded = True
|
||||
except Exception as exc:
|
||||
self._show_error(f"Model load error: {exc}")
|
||||
|
||||
def clear_model(self) -> None:
|
||||
if self._plotter is not None:
|
||||
try:
|
||||
self._plotter.close()
|
||||
except Exception as e:
|
||||
log_exception(__name__, "clear_model.close_plotter", e)
|
||||
self._plotter = None
|
||||
|
||||
while self._model_layout.count():
|
||||
item = self._model_layout.takeAt(0)
|
||||
widget = item.widget()
|
||||
if widget is not None:
|
||||
widget.setParent(None)
|
||||
widget.deleteLater()
|
||||
|
||||
self._model_loaded = False
|
||||
|
||||
def set_isometric_view(self) -> None:
|
||||
if not self._model_loaded or not self._plotter:
|
||||
return
|
||||
self._plotter.view_isometric()
|
||||
self._plotter.update()
|
||||
|
||||
def set_top_view(self) -> None:
|
||||
if not self._model_loaded or not self._plotter:
|
||||
return
|
||||
self._plotter.view_xy()
|
||||
self._plotter.update()
|
||||
|
||||
def set_front_view(self) -> None:
|
||||
if not self._model_loaded or not self._plotter:
|
||||
return
|
||||
self._plotter.view_xz()
|
||||
self._plotter.update()
|
||||
|
||||
def set_left_view(self) -> None:
|
||||
if not self._model_loaded or not self._plotter:
|
||||
return
|
||||
self._plotter.view_zy()
|
||||
self._plotter.update()
|
||||
|
||||
def set_right_view(self) -> None:
|
||||
if not self._model_loaded or not self._plotter:
|
||||
return
|
||||
self._plotter.view_yz()
|
||||
self._plotter.update()
|
||||
|
||||
def reset_view(self) -> None:
|
||||
if not self._model_loaded or not self._plotter:
|
||||
return
|
||||
self._plotter.reset_camera()
|
||||
self._plotter.camera_position = "xy"
|
||||
self._plotter.update()
|
||||
|
||||
def zoom_in(self) -> None:
|
||||
if not self._model_loaded or not self._plotter:
|
||||
return
|
||||
self._plotter.camera.zoom(1.2)
|
||||
self._plotter.update()
|
||||
|
||||
def zoom_out(self) -> None:
|
||||
if not self._model_loaded or not self._plotter:
|
||||
return
|
||||
self._plotter.camera.zoom(0.8)
|
||||
self._plotter.update()
|
||||
|
||||
def wheelEvent(self, event) -> None:
|
||||
if self._model_loaded and self._plotter:
|
||||
delta = event.angleDelta().y()
|
||||
if delta > 0:
|
||||
self.zoom_in()
|
||||
else:
|
||||
self.zoom_out()
|
||||
event.accept()
|
||||
|
||||
def resizeEvent(self, event) -> None:
|
||||
super().resizeEvent(event)
|
||||
if self._model_loaded and self._plotter:
|
||||
self._plotter.update()
|
||||
|
||||
def _show_error(self, message: str) -> None:
|
||||
self.clear_model()
|
||||
self.error_occurred.emit(message)
|
||||
|
||||
def _configure_mouse_controls(self) -> None:
|
||||
"""ПКМ — вращение, колесо — масштаб, СКМ — панорамирование, ЛКМ — отключена."""
|
||||
if not self._plotter:
|
||||
return
|
||||
try:
|
||||
self._plotter.enable_trackball_style()
|
||||
except Exception as e:
|
||||
log_exception(__name__, "_configure_mouse_controls.enable_trackball", e)
|
||||
|
||||
self._setup_trackball_right_button()
|
||||
try:
|
||||
interactor = getattr(self._plotter, "interactor", None)
|
||||
if interactor is not None:
|
||||
interactor.installEventFilter(self)
|
||||
except Exception as e:
|
||||
log_exception(__name__, "_configure_mouse_controls.install_event_filter", e)
|
||||
|
||||
def _setup_trackball_right_button(self) -> None:
|
||||
"""Настроить trackball: ЛКМ drag выключен, ПКМ/СКМ разрешены."""
|
||||
if not self._plotter:
|
||||
return
|
||||
try:
|
||||
interactor = getattr(self._plotter, "interactor", None)
|
||||
style = (
|
||||
interactor.GetInteractorStyle()
|
||||
if interactor and hasattr(interactor, "GetInteractorStyle")
|
||||
else None
|
||||
)
|
||||
if style is not None:
|
||||
if hasattr(style, "SetLeftButtonMotion"):
|
||||
style.SetLeftButtonMotion(False)
|
||||
if hasattr(style, "SetRightButtonMotion"):
|
||||
style.SetRightButtonMotion(True)
|
||||
if hasattr(style, "SetMiddleButtonMotion"):
|
||||
style.SetMiddleButtonMotion(True)
|
||||
except Exception as e:
|
||||
log_exception(__name__, "_setup_trackball_right_button.configure_style", e)
|
||||
|
||||
def _pan_camera_by_pixels(self, 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
|
||||
|
||||
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
|
||||
|
||||
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.move_camera", e)
|
||||
return
|
||||
|
||||
def eventFilter(self, watched, event):
|
||||
interactor = getattr(self._plotter, "interactor", None) if self._plotter else None
|
||||
if interactor is not None and watched is interactor:
|
||||
if event.type() == QEvent.Type.MouseButtonPress:
|
||||
if event.button() == Qt.MouseButton.RightButton:
|
||||
self._cam_rotate_active = True
|
||||
self._cam_last_pos = (event.position().x(), event.position().y())
|
||||
return True
|
||||
if event.button() == Qt.MouseButton.MiddleButton:
|
||||
self._cam_pan_active = True
|
||||
self._cam_last_pos = (event.position().x(), event.position().y())
|
||||
return True
|
||||
if event.button() == Qt.MouseButton.LeftButton:
|
||||
return True
|
||||
|
||||
if event.type() == QEvent.Type.MouseMove:
|
||||
if hasattr(event, "buttons") and (event.buttons() & Qt.MouseButton.LeftButton):
|
||||
return True
|
||||
|
||||
if (
|
||||
self._cam_rotate_active
|
||||
and hasattr(event, "buttons")
|
||||
and (event.buttons() & Qt.MouseButton.RightButton)
|
||||
):
|
||||
cx, cy = self._cam_last_pos or (event.position().x(), event.position().y())
|
||||
nx, ny = event.position().x(), event.position().y()
|
||||
dx = float(nx - cx)
|
||||
dy = float(ny - cy)
|
||||
self._cam_last_pos = (nx, ny)
|
||||
try:
|
||||
cam = self._plotter.camera
|
||||
cam.Azimuth(-dx * 0.35)
|
||||
cam.Elevation(dy * 0.35)
|
||||
cam.OrthogonalizeViewUp()
|
||||
self._plotter.update()
|
||||
except Exception as e:
|
||||
log_exception(__name__, "eventFilter.rotate_camera", e)
|
||||
return True
|
||||
|
||||
if (
|
||||
self._cam_pan_active
|
||||
and hasattr(event, "buttons")
|
||||
and (event.buttons() & Qt.MouseButton.MiddleButton)
|
||||
):
|
||||
cx, cy = self._cam_last_pos or (event.position().x(), event.position().y())
|
||||
nx, ny = event.position().x(), event.position().y()
|
||||
dx = float(nx - cx)
|
||||
dy = float(ny - cy)
|
||||
self._cam_last_pos = (nx, ny)
|
||||
self._pan_camera_by_pixels(dx, dy)
|
||||
try:
|
||||
self._plotter.update()
|
||||
except Exception as e:
|
||||
log_exception(__name__, "eventFilter.pan_update", e)
|
||||
return True
|
||||
|
||||
if event.type() == QEvent.Type.MouseButtonRelease:
|
||||
if event.button() == Qt.MouseButton.RightButton:
|
||||
self._cam_rotate_active = False
|
||||
self._cam_last_pos = None
|
||||
return True
|
||||
if event.button() == Qt.MouseButton.MiddleButton:
|
||||
self._cam_pan_active = False
|
||||
self._cam_last_pos = None
|
||||
return True
|
||||
if event.button() == Qt.MouseButton.LeftButton:
|
||||
return True
|
||||
return super().eventFilter(watched, event)
|
||||
|
||||
|
||||
__all__ = ["ModelViewWidget"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Module workflow notes
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# 1) Назначение модуля:
|
||||
# Автономный компактный 3D-просмотрщик STL-моделей, используемый
|
||||
# PartVisualizer. Управление мышью ограничено: ПКМ — вращение,
|
||||
# колёсико — зум, СКМ — панорамирование, ЛКМ отключён.
|
||||
#
|
||||
# 2) Зависимости модуля:
|
||||
# Импорты: math, Path (pathlib),
|
||||
# Qt, Signal, QEvent (PySide6.QtCore),
|
||||
# QWidget (PySide6.QtWidgets),
|
||||
# VContainer (gui.containers),
|
||||
# APP_STYLES (gui.styles)
|
||||
# Хост-класс / базовый класс: QWidget (не SContainer!)
|
||||
# Внешние библиотеки: pyvista (опционально), pyvistaqt (опционально).
|
||||
# При отсутствии — PYVISTA_AVAILABLE=False, 3D недоступен; вместо
|
||||
# ошибки выводится warning.
|
||||
#
|
||||
# 3) Экспорт:
|
||||
# Класс ModelViewWidget — компактный 3D-вьюер.
|
||||
# Сигнал: error_occurred(str).
|
||||
# Методы: load_model(path), clear_model(), set_isometric_view(),
|
||||
# set_top/front/left/right_view(), reset_view(),
|
||||
# zoom_in(), zoom_out().
|
||||
# __all__ = ["ModelViewWidget"].
|
||||
#
|
||||
# 4) Состояние (поля):
|
||||
# _plotter: QtInteractor|None — pyvista 3D-рендерер
|
||||
# _model_loaded: bool — загружена ли модель
|
||||
# _cam_rotate_active: bool — активно ли вращение ПКМ
|
||||
# _cam_pan_active: bool — активно ли панорамирование СКМ
|
||||
# _cam_last_pos: tuple|None — последняя позиция мыши
|
||||
# _model_layout: QVBoxLayout — layout для plotter-виджета
|
||||
#
|
||||
# 5) Последовательность действий и вызовов:
|
||||
# __init__()
|
||||
# -> QWidget.__init__() -> _setup_ui()
|
||||
# -> VContainer() -> setLayout -> setStyleSheet
|
||||
# load_model(model_path)
|
||||
# -> проверка PYVISTA_AVAILABLE и Path.exists()
|
||||
# -> clear_model()
|
||||
# -> pv.read(path) -> QtInteractor -> add_mesh -> camera_position
|
||||
# -> _configure_mouse_controls()
|
||||
# -> enable_trackball_style()
|
||||
# -> _setup_trackball_right_button() — отключение ЛКМ drag
|
||||
# -> interactor.installEventFilter(self)
|
||||
# eventFilter(watched, event)
|
||||
# -> RightButton: _cam_rotate_active → Azimuth/Elevation
|
||||
# -> MiddleButton: _cam_pan_active → _pan_camera_by_pixels()
|
||||
# -> LeftButton: return True (блокировка)
|
||||
# _pan_camera_by_pixels(dx, dy)
|
||||
# -> вычисление right/up-вектора камеры
|
||||
# -> camera.SetPosition/SetFocalPoint — сдвиг в экранной плоскости
|
||||
#
|
||||
# 6) Побочные эффекты:
|
||||
# - Создаёт QtInteractor (тяжёлый OpenGL-контекст).
|
||||
# - Читает STL-файлы с диска.
|
||||
# - Перехватывает mouse events через eventFilter.
|
||||
# - Испускает error_occurred.
|
||||
#
|
||||
# 7) Границы ответственности:
|
||||
# Модуль — только предпросмотр. НЕ управляет зонами, сетками,
|
||||
# стеллажами. НЕ подключается к theme_bus. НЕ поддерживает
|
||||
# multiple mesh. НЕ наследует SContainer.
|
||||
#
|
||||
# 8) Обработка ошибок:
|
||||
# PYVISTA_AVAILABLE=False → _show_error при попытке загрузки.
|
||||
# load_model() try/except → _show_error → error_occurred.
|
||||
# clear_model() try/except при закрытии plotter.
|
||||
# eventFilter — все операции камеры обёрнуты в try/except.
|
||||
#
|
||||
# 9) Инварианты и контракты:
|
||||
# - _plotter может быть None до load_model().
|
||||
# - _model_loaded == True только если plotter успешно создан и mesh добавлен.
|
||||
# - ЛКМ всегда заблокирован (eventFilter возвращает True).
|
||||
# - Зум-коэффициенты: in=1.2, out=0.8.
|
||||
#
|
||||
# 10) Правило сопровождения:
|
||||
# При обновлении PyVista API — проверить enable_trackball_style(),
|
||||
# SetLeftButtonMotion, GetInteractorStyle. eventFilter — главная
|
||||
# точка кастомизации мышиного управления. Не наследовать от
|
||||
# SContainer — виджет намеренно легковесный (QWidget).
|
||||
Reference in New Issue
Block a user