commit a7ede6ded46d992dcfc1a306f6b73e2f95c5963d Author: Semen_Babenishev Date: Wed Apr 29 08:18:54 2026 +0400 Add Dispatch_V0.1.1 diff --git a/Dispatch_V0.1.1/__pycache__/auth_service.cpython-313.pyc b/Dispatch_V0.1.1/__pycache__/auth_service.cpython-313.pyc new file mode 100644 index 0000000..5507008 Binary files /dev/null and b/Dispatch_V0.1.1/__pycache__/auth_service.cpython-313.pyc differ diff --git a/Dispatch_V0.1.1/__pycache__/error_logger.cpython-313.pyc b/Dispatch_V0.1.1/__pycache__/error_logger.cpython-313.pyc new file mode 100644 index 0000000..b1488ad Binary files /dev/null and b/Dispatch_V0.1.1/__pycache__/error_logger.cpython-313.pyc differ diff --git a/Dispatch_V0.1.1/__pycache__/main.cpython-313.pyc b/Dispatch_V0.1.1/__pycache__/main.cpython-313.pyc new file mode 100644 index 0000000..0087210 Binary files /dev/null and b/Dispatch_V0.1.1/__pycache__/main.cpython-313.pyc differ diff --git a/Dispatch_V0.1.1/__pycache__/null_hardware_gateway.cpython-313.pyc b/Dispatch_V0.1.1/__pycache__/null_hardware_gateway.cpython-313.pyc new file mode 100644 index 0000000..4c5992a Binary files /dev/null and b/Dispatch_V0.1.1/__pycache__/null_hardware_gateway.cpython-313.pyc differ diff --git a/Dispatch_V0.1.1/__pycache__/ticket_plugin.cpython-313.pyc b/Dispatch_V0.1.1/__pycache__/ticket_plugin.cpython-313.pyc new file mode 100644 index 0000000..f6b386a Binary files /dev/null and b/Dispatch_V0.1.1/__pycache__/ticket_plugin.cpython-313.pyc differ diff --git a/Dispatch_V0.1.1/__pycache__/window.cpython-313.pyc b/Dispatch_V0.1.1/__pycache__/window.cpython-313.pyc new file mode 100644 index 0000000..c55d295 Binary files /dev/null and b/Dispatch_V0.1.1/__pycache__/window.cpython-313.pyc differ diff --git a/Dispatch_V0.1.1/application/__init__.py b/Dispatch_V0.1.1/application/__init__.py new file mode 100644 index 0000000..a879ef2 --- /dev/null +++ b/Dispatch_V0.1.1/application/__init__.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# hub/ticket/application/__init__.py + +"""Публичный application-контур Ticket.""" + +from .archive_service import ArchiveService +from .document_flow_service import DocumentFlowService +from .report_signing_service import ReportSigningService +from .specialist_assignment_service import SpecialistAssignmentService +from .task_application_service import TaskApplicationService +from .ticket_application_api import TicketApplicationApi + +__all__ = [ + "ArchiveService", + "DocumentFlowService", + "ReportSigningService", + "SpecialistAssignmentService", + "TaskApplicationService", + "TicketApplicationApi", +] diff --git a/Dispatch_V0.1.1/application/__pycache__/__init__.cpython-313.pyc b/Dispatch_V0.1.1/application/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..a458354 Binary files /dev/null and b/Dispatch_V0.1.1/application/__pycache__/__init__.cpython-313.pyc differ diff --git a/Dispatch_V0.1.1/application/__pycache__/archive_service.cpython-313.pyc b/Dispatch_V0.1.1/application/__pycache__/archive_service.cpython-313.pyc new file mode 100644 index 0000000..94c96b6 Binary files /dev/null and b/Dispatch_V0.1.1/application/__pycache__/archive_service.cpython-313.pyc differ diff --git a/Dispatch_V0.1.1/application/__pycache__/document_flow_service.cpython-313.pyc b/Dispatch_V0.1.1/application/__pycache__/document_flow_service.cpython-313.pyc new file mode 100644 index 0000000..ee3ac32 Binary files /dev/null and b/Dispatch_V0.1.1/application/__pycache__/document_flow_service.cpython-313.pyc differ diff --git a/Dispatch_V0.1.1/application/__pycache__/report_signing_service.cpython-313.pyc b/Dispatch_V0.1.1/application/__pycache__/report_signing_service.cpython-313.pyc new file mode 100644 index 0000000..97e04c6 Binary files /dev/null and b/Dispatch_V0.1.1/application/__pycache__/report_signing_service.cpython-313.pyc differ diff --git a/Dispatch_V0.1.1/application/__pycache__/specialist_assignment_service.cpython-313.pyc b/Dispatch_V0.1.1/application/__pycache__/specialist_assignment_service.cpython-313.pyc new file mode 100644 index 0000000..8f31c86 Binary files /dev/null and b/Dispatch_V0.1.1/application/__pycache__/specialist_assignment_service.cpython-313.pyc differ diff --git a/Dispatch_V0.1.1/application/__pycache__/task_application_service.cpython-313.pyc b/Dispatch_V0.1.1/application/__pycache__/task_application_service.cpython-313.pyc new file mode 100644 index 0000000..3c071b8 Binary files /dev/null and b/Dispatch_V0.1.1/application/__pycache__/task_application_service.cpython-313.pyc differ diff --git a/Dispatch_V0.1.1/application/__pycache__/ticket_application_api.cpython-313.pyc b/Dispatch_V0.1.1/application/__pycache__/ticket_application_api.cpython-313.pyc new file mode 100644 index 0000000..e04e754 Binary files /dev/null and b/Dispatch_V0.1.1/application/__pycache__/ticket_application_api.cpython-313.pyc differ diff --git a/Dispatch_V0.1.1/application/archive_service.py b/Dispatch_V0.1.1/application/archive_service.py new file mode 100644 index 0000000..1801c50 --- /dev/null +++ b/Dispatch_V0.1.1/application/archive_service.py @@ -0,0 +1,80 @@ +# -*- coding: utf-8 -*- +# hub/ticket/application/archive_service.py + +"""Application-сервис архивации задач Ticket.""" + +from __future__ import annotations + +from error_logger import log_exception + +from domain import ArchiveRecordSnapshot, TicketStateService +from domain.task import TicketTask +from state import ArchiveRecordRepository, TicketStateApi +from .document_flow_service import DocumentFlowService + + +class ArchiveService: + """Команда архивации поверх доменного сервиса и state API.""" + + def __init__( + self, + state: TicketStateApi, + state_service: TicketStateService, + archive_repository: ArchiveRecordRepository | None = None, + document_service: DocumentFlowService | None = None, + ): + self._state = state + self._state_service = state_service + self._archive_repository = archive_repository or ArchiveRecordRepository() + self._document_service = document_service + + def archive_task(self, task_id: int): + """Перевести задачу в архив и сохранить архивную запись.""" + snapshot = self._state.get_task(task_id) + if snapshot is None: + return None + + self.ensure_archive_record(snapshot) + + result = self._state_service.move_task_to_archive( + task_id, + TicketTask.from_snapshot(snapshot), + ) + if result.task is None: + return None + archived_snapshot = result.task.to_snapshot() + self._state.upsert_task(archived_snapshot) + return archived_snapshot + + def list_archive_records(self) -> list[ArchiveRecordSnapshot]: + """Вернуть все архивные записи из файлового хранилища.""" + return self._archive_repository.list_records() + + def ensure_archive_record(self, snapshot) -> None: + """Сохранить архивную запись, если её ещё нет для данного цикла задачи.""" + try: + cycle_token = self._build_cycle_token(snapshot) + if self._archive_repository.has_record(snapshot.task_id, cycle_token): + return + documents = self._collect_cycle_documents(snapshot) + self._archive_repository.save_record(snapshot, documents) + except Exception as exc: + log_exception(__name__, "ArchiveService.ensure_archive_record", exc) + + def _collect_cycle_documents(self, snapshot) -> list: + """Вернуть документы только текущего цикла задачи.""" + if self._document_service is None: + return [] + cycle_token = self._build_cycle_token(snapshot) + all_docs = self._document_service.list_documents() + return [ + d for d in all_docs + if d.task_id == snapshot.task_id and cycle_token in d.document_id + ] + + @staticmethod + def _build_cycle_token(snapshot) -> str: + from datetime import datetime as _dt + if snapshot.created_at is not None: + return snapshot.created_at.strftime("%Y%m%d_%H%M%S") + return _dt.now().strftime("%Y%m%d_%H%M%S") diff --git a/Dispatch_V0.1.1/application/document_flow_service.py b/Dispatch_V0.1.1/application/document_flow_service.py new file mode 100644 index 0000000..71aaaac --- /dev/null +++ b/Dispatch_V0.1.1/application/document_flow_service.py @@ -0,0 +1,230 @@ +# -*- coding: utf-8 -*- +# hub/ticket/application/document_flow_service.py + +"""Application-сервис генерации документов Ticket.""" + +from __future__ import annotations + +from typing import Callable + +from domain import TicketDocumentSnapshot, TicketTaskSnapshot, parse_location_parts +from domain.task import TicketTask +from domain.ticket_constants import STATE_CONFIRMATION, STATE_IN_PROGRESS +from state import TicketDocumentRepository, TicketStateApi + + +class DocumentFlowService: + """Канонический document-flow Ticket поверх state и файлового репозитория.""" + + def __init__( + self, + state: TicketStateApi, + repository: TicketDocumentRepository | None = None, + ): + self._state = state + self._repository = repository or TicketDocumentRepository() + + def create_diagnostic_report( + self, + task_id: int, + initial_cause: str, + actual_cause: str, + ) -> TicketDocumentSnapshot: + task = self._get_task_or_raise(task_id) + self._ensure_in_progress(task, "Диагностический отчёт") + self._ensure_specialist_assigned(task) + if task.diagnostic_report_signed: + raise ValueError("Диагностический отчёт уже подписан.") + if not initial_cause.strip() or not actual_cause.strip(): + raise ValueError("Заполните первичное и вторичное заключения.") + payload = self._base_payload(task) + payload.update( + { + "initial_cause": initial_cause.strip(), + "actual_cause": actual_cause.strip(), + } + ) + document = self._save_document( + task=task, + document_type="diagnostic", + title=f"Диагностический отчёт по задаче #{task.sequence_number or task.task_id}", + summary=actual_cause.strip(), + payload=payload, + content_builder=self._render_diagnostic_report, + ) + self._state.sign_report(task.task_id, "diagnostic") + return document + + def create_repair_report( + self, + task_id: int, + work_done: str, + used_parts: str, + recommendations: str, + ) -> TicketDocumentSnapshot: + task = self._get_task_or_raise(task_id) + self._ensure_in_progress(task, "Ремонтный отчёт") + self._ensure_specialist_assigned(task) + if task.repair_report_signed: + raise ValueError("Ремонтный отчёт уже подписан.") + if not work_done.strip(): + raise ValueError("Заполните поле 'Выполненные работы'.") + payload = self._base_payload(task) + payload.update( + { + "work_done": work_done.strip(), + "used_parts": used_parts.strip(), + "recommendations": recommendations.strip(), + } + ) + document = self._save_document( + task=task, + document_type="repair", + title=f"Ремонтный отчёт по задаче #{task.sequence_number or task.task_id}", + summary=work_done.strip(), + payload=payload, + content_builder=self._render_repair_report, + ) + self._state.sign_report(task.task_id, "repair") + return document + + def create_acceptance_report( + self, + task_id: int, + work_description: str, + executor_signature: str, + customer_signature: str, + ) -> TicketDocumentSnapshot: + task = self._get_task_or_raise(task_id) + if task.state_code != STATE_CONFIRMATION: + raise ValueError("Акт приёмки доступен только в состоянии подтверждения.") + if task.acceptance_report_signed: + raise ValueError("Акт приёмки уже подписан.") + if not task.diagnostic_report_signed or not task.repair_report_signed: + raise ValueError("Сначала подпишите диагностический и ремонтный отчёты.") + if not work_description.strip(): + raise ValueError("Заполните описание выполненных работ.") + if not executor_signature.strip() or not customer_signature.strip(): + raise ValueError("Укажите подписи исполнителя и заказчика.") + payload = self._base_payload(task) + payload.update( + { + "work_description": work_description.strip(), + "executor_signature": executor_signature.strip(), + "customer_signature": customer_signature.strip(), + } + ) + document = self._save_document( + task=task, + document_type="acceptance", + title=f"Акт приёмки по задаче #{task.sequence_number or task.task_id}", + summary=work_description.strip(), + payload=payload, + content_builder=self._render_acceptance_report, + ) + task_data = TicketTask.from_snapshot(task).to_record() + task_data["acceptance_report_signed"] = True + self._state.update_task(task_data) + return document + + def list_documents( + self, + document_type: str | None = None, + ) -> list[TicketDocumentSnapshot]: + """Вернуть отсортированный список документов Ticket.""" + return self._repository.list_documents(document_type) + + def _save_document( + self, + task: TicketTaskSnapshot, + document_type: str, + title: str, + summary: str, + payload: dict[str, str], + content_builder: Callable[[dict[str, str]], str], + ) -> TicketDocumentSnapshot: + document = self._repository.save_document( + task=task, + document_type=document_type, + title=title, + summary=summary, + content=content_builder(payload), + payload=payload, + ) + if document is None: + raise ValueError("Не удалось сохранить документ Ticket.") + return document + + def _get_task_or_raise(self, task_id: int) -> TicketTaskSnapshot: + task = self._state.get_task(task_id) + if task is None: + raise ValueError(f"Задача #{task_id} не найдена.") + return task + + @staticmethod + def _ensure_in_progress(task: TicketTaskSnapshot, document_name: str) -> None: + if task.state_code != STATE_IN_PROGRESS: + raise ValueError(f"{document_name} можно подписать только в состоянии 'В работе'.") + + @staticmethod + def _ensure_specialist_assigned(task: TicketTaskSnapshot) -> None: + if not task.assigned_specialist.strip(): + raise ValueError("Сначала назначьте специалиста.") + + @staticmethod + def _base_payload(task: TicketTaskSnapshot) -> dict[str, str]: + institution, room, device = parse_location_parts(task.location) + return { + "task_id": str(task.sequence_number or task.task_id), + "institution": institution, + "room": room, + "device": device, + "location": task.location, + "specialist": task.assigned_specialist, + } + + @staticmethod + def _render_diagnostic_report(payload: dict[str, str]) -> str: + return ( + f"ДИАГНОСТИЧЕСКИЙ ОТЧЁТ #{payload['task_id']}\n\n" + f"Учреждение: {payload.get('institution', '—')}\n" + f"Оборудование: {payload.get('device', '—')}\n" + f"Кабинет: {payload.get('room', '—')}\n" + f"Специалист: {payload.get('specialist', '—')}\n\n" + "Первичное заключение:\n" + f"{payload.get('initial_cause', '—')}\n\n" + "Вторичное заключение:\n" + f"{payload.get('actual_cause', '—')}\n" + ) + + @staticmethod + def _render_repair_report(payload: dict[str, str]) -> str: + return ( + f"РЕМОНТНЫЙ ОТЧЁТ #{payload['task_id']}\n\n" + f"Учреждение: {payload.get('institution', '—')}\n" + f"Оборудование: {payload.get('device', '—')}\n" + f"Кабинет: {payload.get('room', '—')}\n" + f"Специалист: {payload.get('specialist', '—')}\n\n" + "Выполненные работы:\n" + f"{payload.get('work_done', '—')}\n\n" + "Использованные запчасти:\n" + f"{payload.get('used_parts', '—')}\n\n" + "Рекомендации:\n" + f"{payload.get('recommendations', '—')}\n" + ) + + @staticmethod + def _render_acceptance_report(payload: dict[str, str]) -> str: + return ( + f"АКТ ПРИЁМКИ #{payload['task_id']}\n\n" + f"Учреждение: {payload.get('institution', '—')}\n" + f"Оборудование: {payload.get('device', '—')}\n" + f"Кабинет: {payload.get('room', '—')}\n" + f"Специалист: {payload.get('specialist', '—')}\n\n" + "Описание выполненных работ:\n" + f"{payload.get('work_description', '—')}\n\n" + "Исполнитель:\n" + f"{payload.get('executor_signature', '—')}\n\n" + "Заказчик:\n" + f"{payload.get('customer_signature', '—')}\n" + ) diff --git a/Dispatch_V0.1.1/application/report_signing_service.py b/Dispatch_V0.1.1/application/report_signing_service.py new file mode 100644 index 0000000..f68eaab --- /dev/null +++ b/Dispatch_V0.1.1/application/report_signing_service.py @@ -0,0 +1,50 @@ +# -*- coding: utf-8 -*- +# hub/ticket/application/report_signing_service.py + +"""Application-сервис подписания отчётов Ticket.""" + +from __future__ import annotations + +from domain.task import TicketTask +from domain.ticket_constants import STATE_CONFIRMATION +from state import TicketStateApi + + +class ReportSigningService: + """Команды подписания отчётов поверх канонического state API.""" + + def __init__(self, state: TicketStateApi): + self._state = state + + def sign_report(self, task_id: int, report_type: str): + """Подписать диагностический или ремонтный отчёт.""" + snapshot = self._state.get_task(task_id) + if snapshot is None: + return None + if report_type not in {"diagnostic", "repair"}: + return None + self._state.sign_report(task_id, report_type) + return self._state.get_task(task_id) + + def sign_acceptance_report(self, task_id: int): + """Подписать акт приёмки без смены доменного состояния.""" + snapshot = self._state.get_task(task_id) + if snapshot is None: + return None + task_data = TicketTask.from_snapshot(snapshot).to_record() + task_data["acceptance_report_signed"] = True + return self._state.update_task(task_data) + + def can_advance_to_confirmation(self, task_id: int) -> bool: + """Проверить готовность задачи к переходу в подтверждение.""" + return self._state.can_advance_to_confirmation(task_id) + + def can_advance_to_completed(self, task_id: int) -> bool: + """Проверить готовность задачи к переходу в выполненные.""" + snapshot = self._state.get_task(task_id) + if snapshot is None: + return False + return ( + snapshot.state_code == STATE_CONFIRMATION + and snapshot.acceptance_report_signed + ) diff --git a/Dispatch_V0.1.1/application/specialist_assignment_service.py b/Dispatch_V0.1.1/application/specialist_assignment_service.py new file mode 100644 index 0000000..03d9736 --- /dev/null +++ b/Dispatch_V0.1.1/application/specialist_assignment_service.py @@ -0,0 +1,42 @@ +# -*- coding: utf-8 -*- +# hub/ticket/application/specialist_assignment_service.py + +"""Application-сервис назначения специалистов в Ticket.""" + +from __future__ import annotations + +from domain.task import TicketTask +from state import TicketStateApi + + +SPECIALIST_PHOTOS = { + "Иванов Алексей Сергеевич": "specialist1.png", + "Петрова Мария Владимировна": "specialist2.png", + "Сидоров Дмитрий Иванович": "specialist3.png", + "Козлова Анна Петровна": "specialist4.png", + "Васильев Сергей Николаевич": "specialist5.png", + "Николаева Ольга Дмитриевна": "specialist6.png", + "Фёдоров Андрей Викторович": "specialist7.png", + "Орлова Екатерина Александровна": "specialist8.png", +} + + +class SpecialistAssignmentService: + """Команда назначения специалиста поверх канонического state API.""" + + def __init__(self, state: TicketStateApi): + self._state = state + + def assign_specialist(self, task_id: int, specialist_name: str): + """Назначить специалиста и сохранить фотографию профиля.""" + snapshot = self._state.get_task(task_id) + if snapshot is None: + return None + task_data = TicketTask.from_snapshot(snapshot).to_record() + task_data["assigned_specialist"] = specialist_name + task_data["specialist_photo"] = SPECIALIST_PHOTOS.get(specialist_name, "") + return self._state.update_task(task_data) + + def list_specialists(self) -> list[str]: + """Вернуть канонический список доступных специалистов.""" + return list(SPECIALIST_PHOTOS.keys()) diff --git a/Dispatch_V0.1.1/application/task_application_service.py b/Dispatch_V0.1.1/application/task_application_service.py new file mode 100644 index 0000000..78476d6 --- /dev/null +++ b/Dispatch_V0.1.1/application/task_application_service.py @@ -0,0 +1,321 @@ +# -*- coding: utf-8 -*- +# hub/ticket/application/task_application_service.py + +"""Единый application-фасад Ticket поверх state, domain и hardware gateway.""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +from PySide6.QtCore import QObject, Signal + +from error_logger import log_exception + +from domain import ( + ArchiveRecordSnapshot, + TicketConnectionStatus, + TicketDocumentSnapshot, + TicketHardwareStatus, + TicketStateService, + TicketTaskSnapshot, +) +from domain.task import TicketTask +from domain.ticket_constants import STATE_COMPLETED, STATE_REFUSED +from services import ServiceManager, TicketHardwareGateway +from state import TicketRuntimeState +from .archive_service import ArchiveService +from .document_flow_service import DocumentFlowService +from .report_signing_service import ReportSigningService +from .specialist_assignment_service import SpecialistAssignmentService + + +class TaskApplicationService(QObject): + """Канонический application facade Ticket.""" + + task_updated = Signal(object) + task_removed = Signal(int) + connection_changed = Signal(str, str) + active_view_changed = Signal(str) + state_loaded = Signal() + com_connection_changed = Signal(bool, str) + button_initialization_changed = Signal(bool, int) + + def __init__( + self, + state: TicketRuntimeState | None = None, + hardware_gateway: TicketHardwareGateway | None = None, + state_service: TicketStateService | None = None, + specialist_service: SpecialistAssignmentService | None = None, + report_service: ReportSigningService | None = None, + archive_service: ArchiveService | None = None, + document_service: DocumentFlowService | None = None, + parent: QObject | None = None, + ): + super().__init__(parent) + self._state = state or TicketRuntimeState() + self._hardware_gateway = hardware_gateway or ServiceManager(parent=self) + self._state_service = state_service or TicketStateService() + self._specialist_service = specialist_service or SpecialistAssignmentService(self._state) + self._report_service = report_service or ReportSigningService(self._state) + self._document_service = document_service or DocumentFlowService(self._state) + self._archive_service = archive_service or ArchiveService( + self._state, + self._state_service, + document_service=self._document_service, + ) + self._started = False + self._connect_state_signals() + + def start(self) -> None: + """Загрузить state, синхронизировать gateway и запустить сервисы.""" + if self._started: + return + self._started = True + self._state.load() + self._hardware_gateway.reset_button_states() + for task in self._state.list_tasks(): + self._hardware_gateway.set_button_state(task.task_id, task.state_code) + self._hardware_gateway.set_observer(self) + self._hardware_gateway.start() + + def stop(self) -> None: + """Остановить gateway и снять observer.""" + if not self._started: + return + self._started = False + self._hardware_gateway.set_observer(None) + self._hardware_gateway.stop() + + def list_tasks(self) -> list[TicketTaskSnapshot]: + return list(self._state.list_tasks()) + + def list_active_tasks(self) -> list[TicketTaskSnapshot]: + return list(self._state.list_active_tasks()) + + def list_archived_tasks(self) -> list[TicketTaskSnapshot]: + return list(self._state.list_archived_tasks()) + + def list_archive_records(self) -> list[ArchiveRecordSnapshot]: + return self._archive_service.list_archive_records() + + def get_task(self, task_id: int) -> TicketTaskSnapshot | None: + return self._state.get_task(task_id) + + def handle_task_action(self, raw_action: Mapping[str, Any]) -> TicketTaskSnapshot | None: + """Обработать аппаратное действие через доменную state-machine.""" + button_id = self._normalize_int(raw_action.get("button_id")) + hardware_state = self._normalize_int(raw_action.get("hardware_state")) + if button_id is None or hardware_state is None: + return None + + current_snapshot = self._state.get_task(button_id) + current_task = ( + TicketTask.from_snapshot(current_snapshot) + if current_snapshot is not None + else None + ) + result = self._state_service.handle_hardware_signal( + button_id, + hardware_state, + current_task, + ) + if result.task is not None: + self._assign_sequence_on_terminal_state(result.task) + updated_snapshot = result.task.to_snapshot() + self._state.upsert_task(updated_snapshot) + self._hardware_gateway.set_button_state( + updated_snapshot.task_id, + updated_snapshot.state_code, + ) + self._auto_save_archive_record(updated_snapshot) + return updated_snapshot + if current_snapshot is not None: + self._hardware_gateway.set_button_state( + current_snapshot.task_id, + current_snapshot.state_code, + ) + return current_snapshot + + def set_active_view(self, view_name: str) -> None: + self._state.active_view = view_name + + def submit_new_task(self, snapshot: TicketTaskSnapshot) -> TicketTaskSnapshot | None: + """Зарегистрировать заявку, созданную через форму Dispatch. + + Назначение: добавляет в runtime-state готовый снимок задачи в + состоянии «Новая заявка» (`STATE_TODO`). Вызывает `upsert_task`, + который эмитит `task_updated` — доска подхватывает карточку без + дополнительной шины событий. Аппаратный шлюз в Dispatch + отключён (`NullHardwareGateway`), поэтому синхронизация + состояния кнопок не выполняется. + """ + if snapshot is None: + return None + self._state.upsert_task(snapshot) + return self._state.get_task(snapshot.task_id) + + def allocate_new_task_id(self) -> int: + """Выдать свободный идентификатор для заявки, созданной диспетчером.""" + existing_ids = [task.task_id for task in self._state.list_tasks()] + base = max(existing_ids, default=0) + # Резервируем диапазон 1..99 за аппаратными button_id, чтобы при + # будущей реальной интеграции с COM-каналом идентификаторы заявок + # из формы не пересекались с физическими кнопками. + return max(base, 99) + 1 + + def assign_specialist( + self, + task_id: int, + specialist_name: str, + ) -> TicketTaskSnapshot | None: + snapshot = self._specialist_service.assign_specialist(task_id, specialist_name) + return self._sync_gateway_state(snapshot) + + def list_specialists(self) -> list[str]: + return self._specialist_service.list_specialists() + + def sign_report( + self, + task_id: int, + report_type: str, + ) -> TicketTaskSnapshot | None: + snapshot = self._report_service.sign_report(task_id, report_type) + return self._sync_gateway_state(snapshot) + + def sign_acceptance_report(self, task_id: int) -> TicketTaskSnapshot | None: + snapshot = self._report_service.sign_acceptance_report(task_id) + return self._sync_gateway_state(snapshot) + + def archive_task(self, task_id: int) -> TicketTaskSnapshot | None: + snapshot = self._archive_service.archive_task(task_id) + return self._sync_gateway_state(snapshot) + + def refuse_task( + self, + task_id: int, + refusal_reason: str, + ) -> TicketTaskSnapshot | None: + normalized_reason = str(refusal_reason or "").strip() + if not normalized_reason: + return None + current_snapshot = self._state.get_task(task_id) + current_task = ( + TicketTask.from_snapshot(current_snapshot) + if current_snapshot is not None + else None + ) + result = self._state_service.mark_task_as_refused( + task_id, + current_task, + normalized_reason, + ) + if result.task is None: + return None + self._assign_sequence_on_terminal_state(result.task) + snapshot = result.task.to_snapshot() + self._state.upsert_task(snapshot) + self._auto_save_archive_record(snapshot) + return self._sync_gateway_state(snapshot) + + def create_diagnostic_report( + self, task_id: int, initial_cause: str, actual_cause: str, + ) -> TicketDocumentSnapshot: + return self._document_service.create_diagnostic_report( + task_id, initial_cause, actual_cause, + ) + + def create_repair_report( + self, task_id: int, work_done: str, used_parts: str, recommendations: str, + ) -> TicketDocumentSnapshot: + return self._document_service.create_repair_report( + task_id, work_done, used_parts, recommendations, + ) + + def create_acceptance_report( + self, task_id: int, work_description: str, + executor_signature: str, customer_signature: str, + ) -> TicketDocumentSnapshot: + return self._document_service.create_acceptance_report( + task_id, work_description, executor_signature, customer_signature, + ) + + def list_documents( + self, + document_type: str | None = None, + ) -> list[TicketDocumentSnapshot]: + return self._document_service.list_documents(document_type) + + def can_advance_to_confirmation(self, task_id: int) -> bool: + return self._report_service.can_advance_to_confirmation(task_id) + + def can_advance_to_completed(self, task_id: int) -> bool: + return self._report_service.can_advance_to_completed(task_id) + + def get_active_view(self) -> str: + return self._state.active_view + + def get_gateway_status(self) -> TicketHardwareStatus: + return self._hardware_gateway.get_status() + + def on_task_action(self, raw_action: Mapping[str, Any]) -> None: + """Observer callback от hardware gateway.""" + try: + self.handle_task_action(raw_action) + except Exception as exc: + log_exception(__name__, "TaskApplicationService.on_task_action", exc) + + def on_gateway_status(self, status: TicketHardwareStatus) -> None: + """Observer callback обновления статуса hardware gateway.""" + if status.connection_status == TicketConnectionStatus.ERROR: + self._state.set_error(status.message) + else: + self._state.connection_status = status.connection_status + self._state.set_com_connection( + status.connection_status == TicketConnectionStatus.CONNECTED, + status.message, + ) + self._state.set_button_initialization( + status.buttons_initialized, + status.button_count, + ) + + def on_gateway_error(self, message: str) -> None: + """Observer callback ошибки hardware gateway.""" + self._state.set_error(message) + + def _connect_state_signals(self) -> None: + self._state.task_updated.connect(self.task_updated.emit) + self._state.task_removed.connect(self.task_removed.emit) + self._state.connection_changed.connect(self.connection_changed.emit) + self._state.active_view_changed.connect(self.active_view_changed.emit) + self._state.state_loaded.connect(self.state_loaded.emit) + self._state.com_connection_changed.connect(self.com_connection_changed.emit) + self._state.button_initialization_changed.connect( + self.button_initialization_changed.emit + ) + + def _sync_gateway_state( + self, + snapshot: TicketTaskSnapshot | None, + ) -> TicketTaskSnapshot | None: + if snapshot is None: + return None + self._hardware_gateway.set_button_state(snapshot.task_id, snapshot.state_code) + return snapshot + + def _assign_sequence_on_terminal_state(self, task: TicketTask) -> None: + """Присвоить сквозной номер при переходе в Выполненные/Отказ.""" + if task.state_code in {STATE_COMPLETED, STATE_REFUSED}: + task.sequence_number = self._state.next_sequence_number() + + def _auto_save_archive_record(self, snapshot: TicketTaskSnapshot) -> None: + if snapshot.state_code in {STATE_COMPLETED, STATE_REFUSED}: + self._archive_service.ensure_archive_record(snapshot) + + @staticmethod + def _normalize_int(value: Any) -> int | None: + try: + return int(value) + except (TypeError, ValueError): + return None diff --git a/Dispatch_V0.1.1/application/ticket_application_api.py b/Dispatch_V0.1.1/application/ticket_application_api.py new file mode 100644 index 0000000..4173560 --- /dev/null +++ b/Dispatch_V0.1.1/application/ticket_application_api.py @@ -0,0 +1,115 @@ +# -*- coding: utf-8 -*- +# hub/ticket/application/ticket_application_api.py + +"""Публичный application API модуля Ticket.""" + +from __future__ import annotations + +from typing import Any, Mapping, Protocol, Sequence + +from domain import TicketDocumentSnapshot, TicketTaskSnapshot + + +class TicketApplicationApi(Protocol): + """Контракт orchestration-слоя между UI, state и сервисами.""" + + def start(self) -> None: + """Инициализировать application-слой Ticket.""" + + def stop(self) -> None: + """Остановить активные операции и освободить ресурсы.""" + + def list_tasks(self) -> Sequence[TicketTaskSnapshot]: + """Вернуть текущий срез задач для UI.""" + + def list_active_tasks(self) -> Sequence[TicketTaskSnapshot]: + """Вернуть активные задачи для UI.""" + + def list_archived_tasks(self) -> Sequence[TicketTaskSnapshot]: + """Вернуть архивные задачи для UI.""" + + def get_task(self, task_id: int) -> TicketTaskSnapshot | None: + """Вернуть задачу по идентификатору.""" + + def handle_task_action( + self, + raw_action: Mapping[str, Any], + ) -> TicketTaskSnapshot | None: + """Обработать входящее действие от аппаратного или mock-шлюза.""" + + def set_active_view(self, view_name: str) -> None: + """Переключить активное представление Ticket.""" + + def assign_specialist( + self, + task_id: int, + specialist_name: str, + ) -> TicketTaskSnapshot | None: + """Назначить специалиста на задачу.""" + + def list_specialists(self) -> Sequence[str]: + """Вернуть список доступных специалистов.""" + + def sign_report( + self, + task_id: int, + report_type: str, + ) -> TicketTaskSnapshot | None: + """Подписать диагностический или ремонтный отчёт.""" + + def sign_acceptance_report(self, task_id: int) -> TicketTaskSnapshot | None: + """Подписать акт приёмки.""" + + def archive_task(self, task_id: int) -> TicketTaskSnapshot | None: + """Перевести задачу в архив.""" + + def refuse_task( + self, + task_id: int, + refusal_reason: str, + ) -> TicketTaskSnapshot | None: + """Перевести задачу в отказ.""" + + def create_diagnostic_report( + self, + task_id: int, + initial_cause: str, + actual_cause: str, + ) -> TicketDocumentSnapshot: + """Создать и подписать диагностический отчёт.""" + + def create_repair_report( + self, + task_id: int, + work_done: str, + used_parts: str, + recommendations: str, + ) -> TicketDocumentSnapshot: + """Создать и подписать ремонтный отчёт.""" + + def create_acceptance_report( + self, + task_id: int, + work_description: str, + executor_signature: str, + customer_signature: str, + ) -> TicketDocumentSnapshot: + """Создать и подписать акт приёмки.""" + + def list_documents( + self, + document_type: str | None = None, + ) -> Sequence[TicketDocumentSnapshot]: + """Вернуть список документов Ticket.""" + + def can_advance_to_confirmation(self, task_id: int) -> bool: + """Проверить готовность задачи к переходу в подтверждение.""" + + def can_advance_to_completed(self, task_id: int) -> bool: + """Проверить готовность задачи к переходу в выполненные.""" + + def get_active_view(self) -> str: + """Вернуть имя активного внутреннего представления Ticket.""" + + def get_gateway_status(self): + """Вернуть последний известный статус hardware gateway.""" diff --git a/Dispatch_V0.1.1/auth_service.py b/Dispatch_V0.1.1/auth_service.py new file mode 100644 index 0000000..94fd015 --- /dev/null +++ b/Dispatch_V0.1.1/auth_service.py @@ -0,0 +1,180 @@ +# -*- coding: utf-8 -*- +# hub/my_account/auth_service.py + +"""Сервис аутентификации Dispatch: проверка логина/пароля и запись сессии. + +Назначение модуля: + Полностью повторяет контракт сервиса USMS `hub/my_account/auth_service`, + но обращается к локальному каталогу `DB_dispatch` независимого + приложения Dispatch. Источники данных: + + - `DB_dispatch/0_users.py` — список учётных записей диспетчеров + и руководителей сервисной службы; + - `DB_dispatch/1_actual_state.py` — текущая активная сессия. + +Архитектурные ограничения: + - Каталог `DB_dispatch` располагается на одном уровне с каталогом + `dispatch`, поэтому путь вычисляется относительно текущего файла. + - Файлы данных читаются через `importlib.util` без подключения + к фреймворку USMS. Это сохраняет независимость дистрибутива. + - Запись сессии перезаписывает файл целиком, без частичных правок. +""" + +from __future__ import annotations + +import importlib.util +import os +import uuid +from datetime import datetime, timezone + + +def _resolve_db_dir() -> str: + """Вернуть абсолютный путь к каталогу `DB_dispatch`. + + Структура размещения: + /dispatch/hub/my_account/auth_service.py + /DB_dispatch/0_users.py + Поэтому переход — четыре уровня вверх от текущего файла, затем + спуск в каталог `DB_dispatch`. + """ + here = os.path.dirname(os.path.abspath(__file__)) + project_root = os.path.dirname(os.path.dirname(os.path.dirname(here))) + return os.path.join(project_root, "DB_dispatch") + + +_DB_DIR = _resolve_db_dir() + + +def _load_users() -> list[dict]: + """Загрузить список пользователей из `DB_dispatch/0_users.py`.""" + path = os.path.join(_DB_DIR, "0_users.py") + spec = importlib.util.spec_from_file_location("_dispatch_db_users", path) + if spec is None or spec.loader is None: + return [] + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return list(getattr(mod, "users", [])) + + +def load_facility_locations() -> dict[str, str]: + """Загрузить таблицу `facility_id → строка локации` из `DB_dispatch/2_customer_facility_list.py`.""" + path = os.path.join(_DB_DIR, "2_customer_facility_list.py") + if not os.path.exists(path): + return {} + spec = importlib.util.spec_from_file_location("_dispatch_db_facilities", path) + if spec is None or spec.loader is None: + return {} + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + raw = getattr(mod, "DEFAULT_BUTTON_LOCATIONS", {}) + return {str(k): str(v) for k, v in dict(raw).items()} + + +def load_software_list() -> dict[str, str]: + """Загрузить таблицу `software_id → наименование` из `DB_dispatch/3_software_list.py`.""" + path = os.path.join(_DB_DIR, "3_software_list.py") + if not os.path.exists(path): + return {} + spec = importlib.util.spec_from_file_location("_dispatch_db_software", path) + if spec is None or spec.loader is None: + return {} + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + raw = getattr(mod, "software_list_ru", {}) + return {str(k): str(v) for k, v in dict(raw).items()} + + +def load_malfunction_list() -> dict[str, list[str]]: + """Загрузить таблицу `software_id → список заголовков` из `DB_dispatch/4_malfunction_list.py`.""" + path = os.path.join(_DB_DIR, "4_malfunction_list.py") + if not os.path.exists(path): + return {} + spec = importlib.util.spec_from_file_location("_dispatch_db_malfunction", path) + if spec is None or spec.loader is None: + return {} + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + raw = getattr(mod, "malfunction_list_ru", {}) + return {str(k): [str(item) for item in list(values)] for k, values in dict(raw).items()} + + +def get_user_facility(user: dict | None) -> str: + """Вернуть строку локации, привязанную к пользователю. + + Источник связи — поле `facility_id` в `DB_dispatch/0_users.py` и + реестр `DEFAULT_BUTTON_LOCATIONS` из `DB_dispatch/2_customer_facility_list.py`. + Возвращает пустую строку, если пользователь не передан или связь + не настроена. + """ + if not user: + return "" + facility_id = str(user.get("facility_id", "")).strip() + if not facility_id: + return "" + locations = load_facility_locations() + return locations.get(facility_id, "") + + +def authenticate(login: str, password: str) -> tuple[dict | None, str]: + """Проверить пару логин/пароль. + + Возвращает ``(user_dict, "")`` при успехе или ``(None, error_code)``: + * ``"no_user"`` — логин не найден; + * ``"bad_password"`` — логин верный, пароль не совпадает. + """ + users = _load_users() + matched = [u for u in users if u.get("login") == login] + if not matched: + return None, "no_user" + if matched[0].get("password") == password: + return matched[0], "" + return None, "bad_password" + + +def write_session(user: dict) -> dict: + """Записать сессию в `DB_dispatch/1_actual_state.py` и вернуть её.""" + state = { + "state_id": str(uuid.uuid4()), + "user_id": user["user_id"], + "is_online": True, + "current_module": "dispatch", + "last_activity_ts": datetime.now(timezone.utc).isoformat(), + } + + path = os.path.join(_DB_DIR, "1_actual_state.py") + with open(path, "w", encoding="utf-8") as fh: + fh.write("# -*- coding: utf-8 -*-\n") + fh.write("# DB_dispatch/1_actual_state.py\n\n") + fh.write(f"actual_state = [{repr(state)}]\n") + + return state + + +def clear_session() -> None: + """Сбросить активную сессию (выход из системы).""" + path = os.path.join(_DB_DIR, "1_actual_state.py") + with open(path, "w", encoding="utf-8") as fh: + fh.write("# -*- coding: utf-8 -*-\n") + fh.write("# DB_dispatch/1_actual_state.py\n\n") + fh.write("actual_state = []\n") + + +def load_session() -> dict | None: + """Прочитать активную сессию и вернуть user-dict или None.""" + path = os.path.join(_DB_DIR, "1_actual_state.py") + if not os.path.exists(path): + return None + spec = importlib.util.spec_from_file_location("_dispatch_db_state", path) + if spec is None or spec.loader is None: + return None + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + states = getattr(mod, "actual_state", []) + if not states: + return None + user_id = states[0].get("user_id") + if not user_id: + return None + users = _load_users() + matched = [u for u in users if u.get("user_id") == user_id] + return matched[0] if matched else None diff --git a/Dispatch_V0.1.1/dispatch.bat b/Dispatch_V0.1.1/dispatch.bat new file mode 100644 index 0000000..3bde057 --- /dev/null +++ b/Dispatch_V0.1.1/dispatch.bat @@ -0,0 +1,5 @@ +@echo off +chcp 65001 >nul +cd /d "%~dp0" +python main.py +pause diff --git a/Dispatch_V0.1.1/domain/__init__.py b/Dispatch_V0.1.1/domain/__init__.py new file mode 100644 index 0000000..fa42aa3 --- /dev/null +++ b/Dispatch_V0.1.1/domain/__init__.py @@ -0,0 +1,43 @@ +# -*- coding: utf-8 -*- +# hub/ticket/domain/__init__.py + +"""Доменные типы Ticket.""" + +from .ticket_types import ( + ArchiveRecordSnapshot, + TicketConnectionStatus, + TicketDocumentSnapshot, + TicketHardwareStatus, + TicketTaskSnapshot, +) +from .location_catalog import LocationCatalog, parse_location_parts +from .task import TicketTask +from .ticket_constants import ( + HARDWARE_SIGNAL_ADVANCE, + HARDWARE_SIGNAL_INITIALIZE, + TICKET_STATE_ACTIONS, + TICKET_STATE_COLORS, + TICKET_STATE_NAMES, +) +from .ticket_state_service import TicketStateService, TicketTransitionResult +from .ticket_transition_policy import TicketTransitionPolicy, TransitionDecision + +__all__ = [ + "ArchiveRecordSnapshot", + "HARDWARE_SIGNAL_ADVANCE", + "HARDWARE_SIGNAL_INITIALIZE", + "LocationCatalog", + "TicketDocumentSnapshot", + "TicketConnectionStatus", + "TicketHardwareStatus", + "TicketStateService", + "TicketTask", + "TicketTaskSnapshot", + "TicketTransitionPolicy", + "TicketTransitionResult", + "TICKET_STATE_ACTIONS", + "TICKET_STATE_COLORS", + "TICKET_STATE_NAMES", + "TransitionDecision", + "parse_location_parts", +] diff --git a/Dispatch_V0.1.1/domain/__pycache__/__init__.cpython-313.pyc b/Dispatch_V0.1.1/domain/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..ce307ea Binary files /dev/null and b/Dispatch_V0.1.1/domain/__pycache__/__init__.cpython-313.pyc differ diff --git a/Dispatch_V0.1.1/domain/__pycache__/location_catalog.cpython-313.pyc b/Dispatch_V0.1.1/domain/__pycache__/location_catalog.cpython-313.pyc new file mode 100644 index 0000000..4f1c2cd Binary files /dev/null and b/Dispatch_V0.1.1/domain/__pycache__/location_catalog.cpython-313.pyc differ diff --git a/Dispatch_V0.1.1/domain/__pycache__/task.cpython-313.pyc b/Dispatch_V0.1.1/domain/__pycache__/task.cpython-313.pyc new file mode 100644 index 0000000..630ff14 Binary files /dev/null and b/Dispatch_V0.1.1/domain/__pycache__/task.cpython-313.pyc differ diff --git a/Dispatch_V0.1.1/domain/__pycache__/ticket_constants.cpython-313.pyc b/Dispatch_V0.1.1/domain/__pycache__/ticket_constants.cpython-313.pyc new file mode 100644 index 0000000..5227239 Binary files /dev/null and b/Dispatch_V0.1.1/domain/__pycache__/ticket_constants.cpython-313.pyc differ diff --git a/Dispatch_V0.1.1/domain/__pycache__/ticket_state_service.cpython-313.pyc b/Dispatch_V0.1.1/domain/__pycache__/ticket_state_service.cpython-313.pyc new file mode 100644 index 0000000..d17ffa8 Binary files /dev/null and b/Dispatch_V0.1.1/domain/__pycache__/ticket_state_service.cpython-313.pyc differ diff --git a/Dispatch_V0.1.1/domain/__pycache__/ticket_transition_policy.cpython-313.pyc b/Dispatch_V0.1.1/domain/__pycache__/ticket_transition_policy.cpython-313.pyc new file mode 100644 index 0000000..19bde49 Binary files /dev/null and b/Dispatch_V0.1.1/domain/__pycache__/ticket_transition_policy.cpython-313.pyc differ diff --git a/Dispatch_V0.1.1/domain/__pycache__/ticket_types.cpython-313.pyc b/Dispatch_V0.1.1/domain/__pycache__/ticket_types.cpython-313.pyc new file mode 100644 index 0000000..10d2372 Binary files /dev/null and b/Dispatch_V0.1.1/domain/__pycache__/ticket_types.cpython-313.pyc differ diff --git a/Dispatch_V0.1.1/domain/location_catalog.py b/Dispatch_V0.1.1/domain/location_catalog.py new file mode 100644 index 0000000..18c944d --- /dev/null +++ b/Dispatch_V0.1.1/domain/location_catalog.py @@ -0,0 +1,54 @@ +# -*- coding: utf-8 -*- +# hub/ticket/domain/location_catalog.py + +"""Справочник соответствия button_id и локации Ticket.""" + +from __future__ import annotations + +from typing import Mapping + + +DEFAULT_BUTTON_LOCATIONS = { + 1: "ГБУЗ ФМБА ГП№1 (томограф Siemens, каб. 101)", + 2: "ГБУЗ ФМБА ГП№2 (ангиограф Амико, каб. 205)", + 3: "ГБУЗ ФМБА ГП№3 (рентген Philips, каб. 112)", + 4: "ГБУЗ ФМБА ГП№4 (рентген Shimadzu, каб. 305)", + 5: "ГБУЗ ФМБА ГП№5 (рентген Toshiba, каб. 208)", + 6: "ГБУЗ ФМБА ГП№6 (рентген GE, каб. 410)", + 7: "ГБУЗ ФМБА ГП№7 (рентген Canon, каб. 312)", + 8: "ГБУЗ ФМБА ГП№8 (рентген Hitachi, каб. 415)", +} + + +class LocationCatalog: + """Канонический справочник локаций Ticket.""" + + def __init__(self, locations: Mapping[int, str] | None = None): + self._locations = dict(DEFAULT_BUTTON_LOCATIONS) + if locations: + self._locations.update({int(key): value for key, value in locations.items()}) + + def get_location(self, button_id: int) -> str: + """Вернуть локацию по button_id или fallback-описание.""" + return self._locations.get(button_id, f"Неизвестная локация #{button_id}") + + +def parse_location_parts(location: str) -> tuple[str, str, str]: + """Разобрать строку локации на учреждение, кабинет и оборудование.""" + normalized_location = str(location or "").strip() + if not normalized_location: + return "", "", "" + if "(" not in normalized_location or ")" not in normalized_location: + return normalized_location, "", "" + + institution = normalized_location.split("(", 1)[0].strip() + inside_brackets = normalized_location.split("(", 1)[1].split(")", 1)[0].strip() + if not inside_brackets: + return institution, "", "" + if "каб." not in inside_brackets: + return institution, "", inside_brackets + + device_part, room_part = inside_brackets.split("каб.", 1) + room = f"каб. {room_part.strip().rstrip(',')}" + device = device_part.strip().rstrip(",") + return institution, room, device diff --git a/Dispatch_V0.1.1/domain/task.py b/Dispatch_V0.1.1/domain/task.py new file mode 100644 index 0000000..3340142 --- /dev/null +++ b/Dispatch_V0.1.1/domain/task.py @@ -0,0 +1,168 @@ +# -*- coding: utf-8 -*- +# hub/ticket/domain/task.py + +"""Доменная сущность задачи Ticket и сериализация её состояния.""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime +from typing import Any, Mapping + +from .ticket_types import TicketTaskSnapshot + + +def _normalize_datetime(value: Any) -> datetime | None: + """Преобразовать строку или datetime в datetime.""" + if value is None or value == "": + return None + if isinstance(value, datetime): + return value + if not isinstance(value, str): + return None + try: + if "T" in value: + return datetime.fromisoformat(value.replace("Z", "+00:00")) + return datetime.strptime(value, "%Y-%m-%d %H:%M:%S") + except ValueError: + return None + + +def _normalize_color(value: Any) -> str: + """Нормализовать представление цвета до hex-строки.""" + if isinstance(value, str) and value.startswith("#") and len(value) >= 4: + return value + name_getter = getattr(value, "name", None) + if callable(name_getter): + try: + normalized = name_getter() + if isinstance(normalized, str) and normalized.startswith("#"): + return normalized + except Exception as _exc: + return "#FFFFFF" + return "#FFFFFF" + + +def _normalize_int(value: Any, default: int | None = None) -> int | None: + """Нормализовать число, сохранив валидное значение 0.""" + if value is None or value == "": + return default + try: + return int(value) + except (TypeError, ValueError): + return default + + +@dataclass(slots=True) +class TicketTask: + """Каноническая доменная задача Ticket.""" + + task_id: int + location: str + state_code: int + state_name: str + action_text: str = "" + color_hex: str = "#FFFFFF" + created_at: datetime | None = None + completed_at: datetime | None = None + refused_from_state: int | None = None + refusal_reason: str = "" + assigned_specialist: str = "" + specialist_photo: str = "" + diagnostic_report_signed: bool = False + repair_report_signed: bool = False + acceptance_report_signed: bool = False + sequence_number: int = 0 + + @classmethod + def from_record(cls, record: Mapping[str, Any]) -> TicketTask | None: + """Создать задачу из записи файлового хранилища.""" + raw_task_id = record.get("button_id") + if raw_task_id is None: + return None + try: + task_id = int(raw_task_id) + except (TypeError, ValueError): + return None + return cls( + task_id=task_id, + location=str(record.get("location", "")), + state_code=_normalize_int(record.get("state"), 1) or 0, + state_name=str(record.get("state_name", "")), + action_text=str(record.get("action", "")), + color_hex=_normalize_color(record.get("color", "#FFFFFF")), + created_at=_normalize_datetime(record.get("created_time")) or datetime.now(), + completed_at=_normalize_datetime(record.get("completed_time")), + refused_from_state=_normalize_int(record.get("refused_from_state")), + refusal_reason=str(record.get("refusal_reason", "")), + assigned_specialist=str(record.get("assigned_specialist", "")), + specialist_photo=str(record.get("specialist_photo", "")), + diagnostic_report_signed=bool(record.get("diagnostic_report_signed", False)), + repair_report_signed=bool(record.get("repair_report_signed", False)), + acceptance_report_signed=bool(record.get("acceptance_report_signed", False)), + sequence_number=_normalize_int(record.get("sequence_number"), 0) or 0, + ) + + @classmethod + def from_snapshot(cls, snapshot: TicketTaskSnapshot) -> TicketTask: + """Создать доменную сущность из snapshot.""" + return cls( + task_id=snapshot.task_id, + location=snapshot.location, + state_code=snapshot.state_code, + state_name=snapshot.state_name, + action_text=snapshot.action_text, + color_hex=snapshot.color_hex, + created_at=snapshot.created_at, + completed_at=snapshot.completed_at, + refused_from_state=snapshot.refused_from_state, + refusal_reason=snapshot.refusal_reason, + assigned_specialist=snapshot.assigned_specialist, + specialist_photo=snapshot.specialist_photo, + diagnostic_report_signed=snapshot.diagnostic_report_signed, + repair_report_signed=snapshot.repair_report_signed, + acceptance_report_signed=snapshot.acceptance_report_signed, + sequence_number=snapshot.sequence_number, + ) + + def to_record(self) -> dict[str, Any]: + """Преобразовать доменную сущность в запись для JSON.""" + return { + "button_id": self.task_id, + "location": self.location, + "state": self.state_code, + "action": self.action_text, + "state_name": self.state_name, + "color": self.color_hex, + "created_time": self.created_at, + "completed_time": self.completed_at, + "refused_from_state": self.refused_from_state, + "refusal_reason": self.refusal_reason, + "assigned_specialist": self.assigned_specialist, + "specialist_photo": self.specialist_photo, + "diagnostic_report_signed": self.diagnostic_report_signed, + "repair_report_signed": self.repair_report_signed, + "acceptance_report_signed": self.acceptance_report_signed, + "sequence_number": self.sequence_number, + } + + def to_snapshot(self) -> TicketTaskSnapshot: + """Вернуть неизменяемый снимок задачи для внешних слоёв.""" + return TicketTaskSnapshot( + task_id=self.task_id, + location=self.location, + state_code=self.state_code, + state_name=self.state_name, + action_text=self.action_text, + color_hex=self.color_hex, + created_at=self.created_at, + completed_at=self.completed_at, + refused_from_state=self.refused_from_state, + refusal_reason=self.refusal_reason, + assigned_specialist=self.assigned_specialist, + specialist_photo=self.specialist_photo, + diagnostic_report_signed=self.diagnostic_report_signed, + repair_report_signed=self.repair_report_signed, + acceptance_report_signed=self.acceptance_report_signed, + sequence_number=self.sequence_number, + ) diff --git a/Dispatch_V0.1.1/domain/ticket_constants.py b/Dispatch_V0.1.1/domain/ticket_constants.py new file mode 100644 index 0000000..c061f73 --- /dev/null +++ b/Dispatch_V0.1.1/domain/ticket_constants.py @@ -0,0 +1,41 @@ +# -*- coding: utf-8 -*- +# hub/ticket/domain/ticket_constants.py + +"""Константы доменной state-machine Ticket.""" + +STATE_TODO = 1 +STATE_IN_PROGRESS = 2 +STATE_CONFIRMATION = 3 +STATE_COMPLETED = 0 +STATE_REFUSED = 4 +STATE_ARCHIVED = 5 + +HARDWARE_SIGNAL_ADVANCE = frozenset({0, 1, 2, 3}) +HARDWARE_SIGNAL_INITIALIZE = 0xFF + +TICKET_STATE_NAMES = { + STATE_TODO: "К выполнению", + STATE_IN_PROGRESS: "В работе", + STATE_CONFIRMATION: "Подтверждение", + STATE_COMPLETED: "Выполненные", + STATE_REFUSED: "Отказ в обслуживании", + STATE_ARCHIVED: "Архив", +} + +TICKET_STATE_ACTIONS = { + STATE_TODO: "Инженер направлен", + STATE_IN_PROGRESS: "Выполняются работы", + STATE_CONFIRMATION: "Ожидает подтверждения", + STATE_COMPLETED: "Работа завершена", + STATE_REFUSED: "Отказ в обслуживании", + STATE_ARCHIVED: "Перемещено в архив", +} + +TICKET_STATE_COLORS = { + STATE_TODO: "#FF5938", + STATE_IN_PROGRESS: "#008BFA", + STATE_CONFIRMATION: "#FFD27A", + STATE_COMPLETED: "#36AC87", + STATE_REFUSED: "#D1D5DB", + STATE_ARCHIVED: "#9CA3AF", +} diff --git a/Dispatch_V0.1.1/domain/ticket_state_service.py b/Dispatch_V0.1.1/domain/ticket_state_service.py new file mode 100644 index 0000000..de5df74 --- /dev/null +++ b/Dispatch_V0.1.1/domain/ticket_state_service.py @@ -0,0 +1,208 @@ +# -*- coding: utf-8 -*- +# hub/ticket/domain/ticket_state_service.py + +"""Доменный сервис переходов и представления состояния Ticket.""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime + +from .location_catalog import LocationCatalog +from .task import TicketTask +from .ticket_constants import ( + HARDWARE_SIGNAL_ADVANCE, + HARDWARE_SIGNAL_INITIALIZE, + STATE_ARCHIVED, + STATE_COMPLETED, + STATE_CONFIRMATION, + STATE_IN_PROGRESS, + STATE_REFUSED, + STATE_TODO, + TICKET_STATE_ACTIONS, + TICKET_STATE_COLORS, + TICKET_STATE_NAMES, +) +from .ticket_transition_policy import TicketTransitionPolicy + + +STATE_NAMES = TICKET_STATE_NAMES +STATE_COLORS = TICKET_STATE_COLORS + + +@dataclass(frozen=True, slots=True) +class TicketTransitionResult: + """Результат обработки доменного перехода Ticket.""" + + changed: bool + task: TicketTask | None + message: str + blocked_reason: str = "" + + +class TicketStateService: + """Каноническая state-machine Ticket без зависимости от GUI и COM-сервиса.""" + + def __init__( + self, + location_catalog: LocationCatalog | None = None, + transition_policy: TicketTransitionPolicy | None = None, + ): + self._location_catalog = location_catalog or LocationCatalog() + self._transition_policy = transition_policy or TicketTransitionPolicy() + + def handle_hardware_signal( + self, + button_id: int, + hardware_state: int, + current_task: TicketTask | None, + ) -> TicketTransitionResult: + """Обработать аппаратный сигнал и вычислить доменный результат.""" + if hardware_state == HARDWARE_SIGNAL_INITIALIZE: + return TicketTransitionResult( + changed=False, + task=current_task, + message=f"Получен запрос инициализации для кнопки {button_id}.", + ) + if hardware_state not in HARDWARE_SIGNAL_ADVANCE: + return TicketTransitionResult( + changed=False, + task=current_task, + message=f"Неизвестное аппаратное состояние: {hardware_state:02X}.", + blocked_reason="unsupported_hardware_signal", + ) + return self.advance_task(button_id, current_task) + + def advance_task(self, button_id: int, current_task: TicketTask | None) -> TicketTransitionResult: + """Перевести задачу в следующее допустимое состояние.""" + if current_task is None: + task = self._build_task_for_state(button_id, STATE_TODO) + return TicketTransitionResult( + changed=True, + task=task, + message=f"Создана новая задача #{button_id} в состоянии '{task.state_name}'.", + ) + + if current_task.state_code == STATE_ARCHIVED: + task = self._reset_task_cycle(current_task, STATE_TODO) + return TicketTransitionResult( + changed=True, + task=task, + message=f"Архивная задача #{button_id} начала новый цикл.", + ) + + if current_task.state_code == STATE_REFUSED: + task = self._reset_task_cycle(current_task, STATE_TODO) + return TicketTransitionResult( + changed=True, + task=task, + message=f"Задача #{button_id} сброшена из отказа в новое выполнение.", + ) + + if current_task.state_code == STATE_COMPLETED: + task = self._reset_task_cycle(current_task, STATE_TODO) + return TicketTransitionResult( + changed=True, + task=task, + message=f"Выполненная задача #{button_id} начала новый цикл.", + ) + + decision = self._transition_policy.can_advance(current_task) + if not decision.allowed: + return TicketTransitionResult( + changed=False, + task=current_task, + message=f"Переход задачи #{button_id} отклонён.", + blocked_reason=decision.reason, + ) + + next_state = self._next_state(current_task.state_code) + task = self._clone_with_state(current_task, next_state) + return TicketTransitionResult( + changed=True, + task=task, + message=( + f"Задача #{button_id} перешла из состояния " + f"'{STATE_NAMES[current_task.state_code]}' в '{task.state_name}'." + ), + ) + + def mark_task_as_refused( + self, + button_id: int, + current_task: TicketTask | None, + refusal_reason: str = "", + ) -> TicketTransitionResult: + """Перевести задачу в состояние отказа.""" + task = current_task or self._build_task_for_state(button_id, STATE_TODO) + refused_task = self._clone_with_state(task, STATE_REFUSED) + refused_task.refused_from_state = task.state_code + refused_task.refusal_reason = str(refusal_reason or "").strip() + return TicketTransitionResult( + changed=True, + task=refused_task, + message=f"Задача #{button_id} переведена в отказ.", + ) + + def move_task_to_archive( + self, + button_id: int, + current_task: TicketTask | None, + ) -> TicketTransitionResult: + """Переместить задачу в архив как отдельное доменное правило.""" + task = current_task or self._build_task_for_state(button_id, STATE_TODO) + archived_task = self._clone_with_state(task, STATE_ARCHIVED) + return TicketTransitionResult( + changed=True, + task=archived_task, + message=f"Задача #{button_id} перемещена в архив.", + ) + + def _next_state(self, current_state: int) -> int: + if current_state == STATE_TODO: + return STATE_IN_PROGRESS + if current_state == STATE_IN_PROGRESS: + return STATE_CONFIRMATION + if current_state == STATE_CONFIRMATION: + return STATE_COMPLETED + return current_state + + def _build_task_for_state(self, button_id: int, state_code: int) -> TicketTask: + """Создать новую задачу для button_id в заданном состоянии.""" + return TicketTask( + task_id=button_id, + location=self._location_catalog.get_location(button_id), + state_code=state_code, + state_name=STATE_NAMES[state_code], + action_text=TICKET_STATE_ACTIONS[state_code], + color_hex=STATE_COLORS[state_code], + created_at=datetime.now(), + completed_at=datetime.now() if state_code == STATE_COMPLETED else None, + ) + + def _clone_with_state(self, task: TicketTask, state_code: int) -> TicketTask: + """Склонировать задачу с новым доменным состоянием.""" + return TicketTask( + task_id=task.task_id, + location=task.location, + state_code=state_code, + state_name=STATE_NAMES[state_code], + action_text=TICKET_STATE_ACTIONS[state_code], + color_hex=STATE_COLORS[state_code], + created_at=task.created_at, + completed_at=datetime.now() if state_code == STATE_COMPLETED else task.completed_at, + refused_from_state=task.refused_from_state, + refusal_reason=task.refusal_reason, + assigned_specialist=task.assigned_specialist, + specialist_photo=task.specialist_photo, + diagnostic_report_signed=task.diagnostic_report_signed, + repair_report_signed=task.repair_report_signed, + acceptance_report_signed=task.acceptance_report_signed, + sequence_number=task.sequence_number, + ) + + def _reset_task_cycle(self, task: TicketTask, state_code: int) -> TicketTask: + """Начать новый цикл задачи на основе архивной или отказной записи.""" + fresh_task = self._build_task_for_state(task.task_id, state_code) + fresh_task.location = task.location + return fresh_task diff --git a/Dispatch_V0.1.1/domain/ticket_transition_policy.py b/Dispatch_V0.1.1/domain/ticket_transition_policy.py new file mode 100644 index 0000000..49ea361 --- /dev/null +++ b/Dispatch_V0.1.1/domain/ticket_transition_policy.py @@ -0,0 +1,53 @@ +# -*- coding: utf-8 -*- +# hub/ticket/domain/ticket_transition_policy.py + +"""Политика допусков переходов между состояниями Ticket.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from .task import TicketTask +from .ticket_constants import ( + STATE_COMPLETED, + STATE_CONFIRMATION, + STATE_IN_PROGRESS, + STATE_TODO, +) + + +@dataclass(frozen=True, slots=True) +class TransitionDecision: + """Результат проверки допуска перехода.""" + + allowed: bool + reason: str = "" + + +class TicketTransitionPolicy: + """Доменные проверки допуска переходов Ticket.""" + + def can_advance(self, task: TicketTask) -> TransitionDecision: + """Проверить возможность перехода в следующее состояние.""" + if task.state_code == STATE_TODO and not task.assigned_specialist.strip(): + return TransitionDecision( + allowed=False, + reason="Без назначенного специалиста нельзя перейти в 'В работе'.", + ) + if task.state_code == STATE_IN_PROGRESS: + if not task.diagnostic_report_signed or not task.repair_report_signed: + return TransitionDecision( + allowed=False, + reason="Без двух подписанных отчётов нельзя перейти в 'Подтверждение'.", + ) + if task.state_code == STATE_CONFIRMATION and not task.acceptance_report_signed: + return TransitionDecision( + allowed=False, + reason="Без акта приёмки нельзя перейти в 'Выполненные'.", + ) + if task.state_code == STATE_COMPLETED: + return TransitionDecision( + allowed=False, + reason="Выполненная задача не должна принимать лишние сигналы до архивации.", + ) + return TransitionDecision(allowed=True) diff --git a/Dispatch_V0.1.1/domain/ticket_types.py b/Dispatch_V0.1.1/domain/ticket_types.py new file mode 100644 index 0000000..4ec4405 --- /dev/null +++ b/Dispatch_V0.1.1/domain/ticket_types.py @@ -0,0 +1,92 @@ +# -*- coding: utf-8 -*- +# hub/ticket/domain/ticket_types.py + +"""Базовые доменные типы Ticket без зависимости от legacy GUI.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import datetime +from enum import Enum +from typing import Mapping + + +class TicketConnectionStatus(str, Enum): + """Нормализованный статус аппаратного подключения Ticket.""" + + CONNECTED = "connected" + DISCONNECTED = "disconnected" + ERROR = "error" + + +@dataclass(frozen=True, slots=True) +class TicketTaskSnapshot: + """Плоский снимок задачи для обмена между слоями.""" + + task_id: int + location: str + state_code: int + state_name: str + action_text: str = "" + color_hex: str = "#FFFFFF" + created_at: datetime | None = None + completed_at: datetime | None = None + refused_from_state: int | None = None + refusal_reason: str = "" + assigned_specialist: str = "" + specialist_photo: str = "" + diagnostic_report_signed: bool = False + repair_report_signed: bool = False + acceptance_report_signed: bool = False + sequence_number: int = 0 + + +@dataclass(frozen=True, slots=True) +class TicketDocumentSnapshot: + """Плоский снимок сохранённого документа Ticket.""" + + document_id: str + task_id: int + document_type: str + title: str + created_at: datetime + location: str = "" + specialist_name: str = "" + summary: str = "" + content: str = "" + storage_path: str = "" + payload: Mapping[str, str] = field(default_factory=dict) + + +@dataclass(frozen=True, slots=True) +class TicketHardwareStatus: + """Снимок статуса аппаратного шлюза.""" + + connection_status: TicketConnectionStatus = TicketConnectionStatus.DISCONNECTED + message: str = "" + buttons_initialized: bool = False + button_count: int = 0 + + +@dataclass(frozen=True, slots=True) +class ArchiveRecordSnapshot: + """Плоский снимок архивной записи по завершённой или отказной задаче.""" + + task_id: int + location: str + pre_archive_state_code: int + pre_archive_state_name: str + color_hex: str = "#FFFFFF" + created_at: datetime | None = None + completed_at: datetime | None = None + archived_at: datetime | None = None + refused_from_state: int | None = None + refusal_reason: str = "" + assigned_specialist: str = "" + specialist_photo: str = "" + diagnostic_report_signed: bool = False + repair_report_signed: bool = False + acceptance_report_signed: bool = False + document_ids: tuple[str, ...] = () + action_text: str = "" + sequence_number: int = 0 diff --git a/Dispatch_V0.1.1/error_logger.py b/Dispatch_V0.1.1/error_logger.py new file mode 100644 index 0000000..6ba0c2e --- /dev/null +++ b/Dispatch_V0.1.1/error_logger.py @@ -0,0 +1,268 @@ +# -*- coding: utf-8 -*- +# error_logger.py +"""Централизованный модуль логирования ошибок. + +Записывает ошибки в logs/error.log с ротацией и идентификацией источника. + +Использование +------------- +:: + + from error_logger import setup_error_logging, log_exception + + # Один раз при старте приложения: + setup_error_logging() + + # В except-блоках: + except Exception as e: + log_exception(__name__, "func_name", e) + +Формат записи лога +------------------ +:: + + 2026-03-27 14:30:00 | ERROR | module | function | ExcType | message + Traceback (most recent call last): + ... +""" + +from __future__ import annotations + +import faulthandler +import logging +import sys +import threading +import traceback +from logging.handlers import RotatingFileHandler +from pathlib import Path + + +_LOG_DIR = Path(__file__).resolve().parent / "logs" +_LOG_FILE = _LOG_DIR / "error.log" +_INTERPRETER_LOG_FILE = _LOG_DIR / "interpreter.log" +_MAX_BYTES = 5 * 1024 * 1024 +_BACKUP_COUNT = 3 + +_error_logger: logging.Logger = logging.getLogger("usms.errors") +_configured = False +_interpreter_hooks_installed = False +_interpreter_stream = None + + +class _SafeRotatingFileHandler(RotatingFileHandler): + """Rotating handler, устойчивый к lock-ошибкам Windows при rollover.""" + + def handleError(self, record: logging.LogRecord) -> None: + exc = sys.exc_info()[1] + if not _is_locked_rollover_error(exc): + super().handleError(record) + return + self._reopen_stream() + try: + logging.FileHandler.emit(self, record) + except Exception as exc: + try: + sys.__stderr__.write(f"error_logger emit failure: {type(exc).__name__}: {exc}\n") + sys.__stderr__.flush() + except Exception as _fallback_exc: + return + return + + def _reopen_stream(self) -> None: + try: + if self.stream is not None: + self.stream.close() + except Exception as exc: + try: + sys.__stderr__.write(f"error_logger stream close failure: {type(exc).__name__}: {exc}\n") + sys.__stderr__.flush() + except Exception as _fallback_exc: + return + self.stream = self._open() + + +def _is_locked_rollover_error(exc: BaseException | None) -> bool: + """Определить отказ rollover из-за блокировки log-файла на Windows.""" + if not isinstance(exc, PermissionError): + return False + return getattr(exc, "winerror", None) == 32 + + +def setup_error_logging() -> None: + """Настроить файловый handler для логирования ошибок. + + Создаёт директорию ``logs/`` и добавляет ``RotatingFileHandler`` + к логгеру ``usms``, чтобы все дочерние логгеры + (``usms.warehouse``, ``usms.errors`` и т.д.) автоматически + записывали ошибки уровня ERROR и выше в файл. + """ + global _configured + if _configured: + return + + _LOG_DIR.mkdir(parents=True, exist_ok=True) + usms_root = logging.getLogger("usms") + + if any( + isinstance(handler, _SafeRotatingFileHandler) + and Path(getattr(handler, "baseFilename", "")) == _LOG_FILE + for handler in usms_root.handlers + ): + _configured = True + return + + file_handler = _SafeRotatingFileHandler( + str(_LOG_FILE), + maxBytes=_MAX_BYTES, + backupCount=_BACKUP_COUNT, + encoding="utf-8", + ) + file_handler.setLevel(logging.ERROR) + formatter = logging.Formatter( + "%(asctime)s | %(levelname)s | %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", + ) + file_handler.setFormatter(formatter) + usms_root.addHandler(file_handler) + console_handler = logging.StreamHandler(sys.stderr) + console_handler.setLevel(logging.ERROR) + console_handler.setFormatter(formatter) + usms_root.addHandler(console_handler) + if usms_root.level == logging.NOTSET or usms_root.level > logging.DEBUG: + usms_root.setLevel(logging.DEBUG) + + _configured = True + + +def install_interpreter_hooks() -> None: + """Подключить глобальные hooks для traceback и фатальных ошибок.""" + global _interpreter_hooks_installed, _interpreter_stream + if _interpreter_hooks_installed: + return + + _LOG_DIR.mkdir(parents=True, exist_ok=True) + _interpreter_stream = _INTERPRETER_LOG_FILE.open("a", encoding="utf-8") + + def _handle_unhandled_exception( + exc_type: type[BaseException], + exc_value: BaseException, + exc_tb, + ) -> None: + if issubclass(exc_type, KeyboardInterrupt): + sys.__excepthook__(exc_type, exc_value, exc_tb) + return + formatted = "".join(traceback.format_exception(exc_type, exc_value, exc_tb)) + _write_interpreter_diagnostics("UNHANDLED EXCEPTION", formatted) + _error_logger.error( + "%s | %s | %s\n%s", + "__main__", + "sys.excepthook", + exc_type.__name__, + formatted.rstrip(), + ) + + def _handle_thread_exception(args: threading.ExceptHookArgs) -> None: + formatted = "".join( + traceback.format_exception(args.exc_type, args.exc_value, args.exc_traceback) + ) + _write_interpreter_diagnostics( + f"THREAD EXCEPTION: {getattr(args.thread, 'name', 'unknown')}", + formatted, + ) + _error_logger.error( + "%s | %s | %s\n%s", + "__main__", + "threading.excepthook", + args.exc_type.__name__, + formatted.rstrip(), + ) + + def _handle_unraisable(unraisable) -> None: + formatted = "".join( + traceback.format_exception( + type(unraisable.exc_value), + unraisable.exc_value, + unraisable.exc_traceback, + ) + ) + object_repr = repr(getattr(unraisable, "object", None)) + _write_interpreter_diagnostics( + f"UNRAISABLE EXCEPTION: {object_repr}", + formatted, + ) + _error_logger.error( + "%s | %s | %s\n%s", + "__main__", + "sys.unraisablehook", + type(unraisable.exc_value).__name__, + formatted.rstrip(), + ) + + sys.excepthook = _handle_unhandled_exception + threading.excepthook = _handle_thread_exception + if hasattr(sys, "unraisablehook"): + sys.unraisablehook = _handle_unraisable + + try: + faulthandler.enable(file=_interpreter_stream, all_threads=True) + except Exception as exc: + try: + sys.__stderr__.write(f"faulthandler enable failure: {type(exc).__name__}: {exc}\n") + sys.__stderr__.flush() + except Exception as _fallback_exc: + return + + _interpreter_hooks_installed = True + + +def _write_interpreter_diagnostics(title: str, payload: str) -> None: + """Вывести интерпретаторную диагностику и в консоль, и в файл.""" + message = f"\n=== {title} ===\n{payload}" + try: + sys.__stderr__.write(message) + sys.__stderr__.flush() + except Exception as exc: + try: + sys.stderr.write(f"interpreter diagnostics stderr failure: {type(exc).__name__}: {exc}\n") + sys.stderr.flush() + except Exception as _fallback_exc: + return + try: + if _interpreter_stream is not None: + _interpreter_stream.write(message) + _interpreter_stream.flush() + except Exception as exc: + try: + sys.__stderr__.write(f"interpreter log file write failure: {type(exc).__name__}: {exc}\n") + sys.__stderr__.flush() + except Exception as _fallback_exc: + return + + +def log_exception(module: str, func: str, exc: BaseException) -> None: + """Залогировать перехваченное исключение с идентификацией источника. + + Parameters + ---------- + module : str + Имя модуля (обычно ``__name__``). + func : str + Имя функции / метода, в котором произошла ошибка. + exc : BaseException + Перехваченное исключение. + """ + exc_type = type(exc).__name__ + if exc.__traceback__ is not None: + formatted_traceback = "".join( + traceback.format_exception(type(exc), exc, exc.__traceback__) + ) + else: + formatted_traceback = f"{exc_type}: {exc}" + _error_logger.error( + "%s | %s | %s | %s\n%s", + module, + func, + exc_type, + exc, + formatted_traceback, + ) diff --git a/Dispatch_V0.1.1/gui/__init__.py b/Dispatch_V0.1.1/gui/__init__.py new file mode 100644 index 0000000..b633a94 --- /dev/null +++ b/Dispatch_V0.1.1/gui/__init__.py @@ -0,0 +1,42 @@ +# -*- coding: utf-8 -*- +# gui/__init__.py + +from __future__ import annotations + +from .styles import APP_STYLES +from .components.button import Button +from .components.label import Label + + +def __getattr__(name: str): + """Лениво отдать `MainWindow`, не создавая цикл `gui -> window -> hub -> gui`.""" + if name == "MainWindow": + from .window import MainWindow + + return MainWindow + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + +__all__ = [ + 'MainWindow', + 'APP_STYLES', + 'Button', + 'Label', +] + + +# --------------------------------------------------------------------------- +# Module workflow notes +# --------------------------------------------------------------------------- +# +# 1) Назначение модуля: +# Пакетный __init__.py для gui/. Реэкспортирует ключевые классы +# для удобного импорта: MainWindow, APP_STYLES, Button, Label. +# +# 2) Зависимости модуля: +# Реимпорт из: styles (APP_STYLES), components.button (Button), +# components.label (Label). MainWindow импортируется лениво через +# __getattr__, чтобы не создавать цикл с hub во время plugin-import. +# +# 3) Экспорт (__all__): +# MainWindow, Button, Label. +# Также доступен APP_STYLES (не в __all__, но импортируется). diff --git a/Dispatch_V0.1.1/gui/__pycache__/__init__.cpython-313.pyc b/Dispatch_V0.1.1/gui/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..237e1af Binary files /dev/null and b/Dispatch_V0.1.1/gui/__pycache__/__init__.cpython-313.pyc differ diff --git a/Dispatch_V0.1.1/gui/__pycache__/login_dialog.cpython-313.pyc b/Dispatch_V0.1.1/gui/__pycache__/login_dialog.cpython-313.pyc new file mode 100644 index 0000000..23f7ba3 Binary files /dev/null and b/Dispatch_V0.1.1/gui/__pycache__/login_dialog.cpython-313.pyc differ diff --git a/Dispatch_V0.1.1/gui/__pycache__/theme_bus.cpython-313.pyc b/Dispatch_V0.1.1/gui/__pycache__/theme_bus.cpython-313.pyc new file mode 100644 index 0000000..77c473f Binary files /dev/null and b/Dispatch_V0.1.1/gui/__pycache__/theme_bus.cpython-313.pyc differ diff --git a/Dispatch_V0.1.1/gui/components/__init__.py b/Dispatch_V0.1.1/gui/components/__init__.py new file mode 100644 index 0000000..96a3289 --- /dev/null +++ b/Dispatch_V0.1.1/gui/components/__init__.py @@ -0,0 +1,73 @@ +# -*- coding: utf-8 -*- +# gui/components/__init__.py + +"""Компоненты пользовательского интерфейса.""" + +from .button import Button +from .dialog import Dialog +from .label import Label +from .text_input import TextInput +from .coordinate_input import CoordinateInput +from .combo_box import ComboBox +from .double_spin_box import DoubleSpinBox +from .radio_button import RadioButton +from .radio_group import RadioGroup +from .toggle_button import ToggleButton +from .tab_button import TabButton +from .tab_widget import TabWidget +from .topology_tree_widget import TopologyTreeWidget +from .model_view_widget import ModelViewWidget +from .part_visualizer import PartVisualizer +from .photo_view_widget import PhotoViewWidget +from .springs import VSpring, HSpring +from .group_box import GroupBox +from .color_swatch import ColorSwatch +from .color_palette import ColorPalette +from .kanban_board import KanbanBoard, KanbanColumn, KanbanCard + +__all__ = [ + 'Button', + 'Dialog', + 'Label', + 'TextInput', + 'CoordinateInput', + 'ComboBox', + 'DoubleSpinBox', + 'RadioButton', + 'RadioGroup', + 'ToggleButton', + 'TabButton', + 'TabWidget', + 'TopologyTreeWidget', + 'ModelViewWidget', + 'PartVisualizer', + 'PhotoViewWidget', + 'VSpring', + 'HSpring', + 'GroupBox', + 'ColorSwatch', + 'ColorPalette', + 'KanbanBoard', + 'KanbanColumn', + 'KanbanCard', +] + + +# --------------------------------------------------------------------------- +# Module workflow notes +# --------------------------------------------------------------------------- +# +# 1) Назначение модуля: +# Пакетный __init__: реэкспорт всех публичных UI-компонентов проекта +# из единой точки входа gui.components. +# +# 2) Зависимости модуля: +# Импортирует все компоненты: Button, Label, TextInput, CoordinateInput, +# ComboBox, DoubleSpinBox, RadioButton, RadioGroup, ToggleButton, +# TabButton, TabWidget, TopologyTreeWidget, ModelViewWidget, +# PartVisualizer, PhotoViewWidget, VSpring, HSpring, GroupBox, +# ColorSwatch, ColorPalette. +# +# 3) Экспорт: +# __all__ — список из 20 публичных символов. +# Потребители импортируют: from gui.components import Button, Label, ... diff --git a/Dispatch_V0.1.1/gui/components/__pycache__/__init__.cpython-313.pyc b/Dispatch_V0.1.1/gui/components/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..5f4b01c Binary files /dev/null and b/Dispatch_V0.1.1/gui/components/__pycache__/__init__.cpython-313.pyc differ diff --git a/Dispatch_V0.1.1/gui/components/__pycache__/_tree_node_building.cpython-313.pyc b/Dispatch_V0.1.1/gui/components/__pycache__/_tree_node_building.cpython-313.pyc new file mode 100644 index 0000000..f011599 Binary files /dev/null and b/Dispatch_V0.1.1/gui/components/__pycache__/_tree_node_building.cpython-313.pyc differ diff --git a/Dispatch_V0.1.1/gui/components/__pycache__/_tree_state_management.cpython-313.pyc b/Dispatch_V0.1.1/gui/components/__pycache__/_tree_state_management.cpython-313.pyc new file mode 100644 index 0000000..0042a1f Binary files /dev/null and b/Dispatch_V0.1.1/gui/components/__pycache__/_tree_state_management.cpython-313.pyc differ diff --git a/Dispatch_V0.1.1/gui/components/__pycache__/button.cpython-313.pyc b/Dispatch_V0.1.1/gui/components/__pycache__/button.cpython-313.pyc new file mode 100644 index 0000000..943b05c Binary files /dev/null and b/Dispatch_V0.1.1/gui/components/__pycache__/button.cpython-313.pyc differ diff --git a/Dispatch_V0.1.1/gui/components/__pycache__/color_palette.cpython-313.pyc b/Dispatch_V0.1.1/gui/components/__pycache__/color_palette.cpython-313.pyc new file mode 100644 index 0000000..fe4188a Binary files /dev/null and b/Dispatch_V0.1.1/gui/components/__pycache__/color_palette.cpython-313.pyc differ diff --git a/Dispatch_V0.1.1/gui/components/__pycache__/color_swatch.cpython-313.pyc b/Dispatch_V0.1.1/gui/components/__pycache__/color_swatch.cpython-313.pyc new file mode 100644 index 0000000..98856d9 Binary files /dev/null and b/Dispatch_V0.1.1/gui/components/__pycache__/color_swatch.cpython-313.pyc differ diff --git a/Dispatch_V0.1.1/gui/components/__pycache__/combo_box.cpython-313.pyc b/Dispatch_V0.1.1/gui/components/__pycache__/combo_box.cpython-313.pyc new file mode 100644 index 0000000..0ec58e5 Binary files /dev/null and b/Dispatch_V0.1.1/gui/components/__pycache__/combo_box.cpython-313.pyc differ diff --git a/Dispatch_V0.1.1/gui/components/__pycache__/coordinate_input.cpython-313.pyc b/Dispatch_V0.1.1/gui/components/__pycache__/coordinate_input.cpython-313.pyc new file mode 100644 index 0000000..4980bab Binary files /dev/null and b/Dispatch_V0.1.1/gui/components/__pycache__/coordinate_input.cpython-313.pyc differ diff --git a/Dispatch_V0.1.1/gui/components/__pycache__/dialog.cpython-313.pyc b/Dispatch_V0.1.1/gui/components/__pycache__/dialog.cpython-313.pyc new file mode 100644 index 0000000..61aa20e Binary files /dev/null and b/Dispatch_V0.1.1/gui/components/__pycache__/dialog.cpython-313.pyc differ diff --git a/Dispatch_V0.1.1/gui/components/__pycache__/double_spin_box.cpython-313.pyc b/Dispatch_V0.1.1/gui/components/__pycache__/double_spin_box.cpython-313.pyc new file mode 100644 index 0000000..129c06c Binary files /dev/null and b/Dispatch_V0.1.1/gui/components/__pycache__/double_spin_box.cpython-313.pyc differ diff --git a/Dispatch_V0.1.1/gui/components/__pycache__/group_box.cpython-313.pyc b/Dispatch_V0.1.1/gui/components/__pycache__/group_box.cpython-313.pyc new file mode 100644 index 0000000..11e1caa Binary files /dev/null and b/Dispatch_V0.1.1/gui/components/__pycache__/group_box.cpython-313.pyc differ diff --git a/Dispatch_V0.1.1/gui/components/__pycache__/kanban_board.cpython-313.pyc b/Dispatch_V0.1.1/gui/components/__pycache__/kanban_board.cpython-313.pyc new file mode 100644 index 0000000..ac193d1 Binary files /dev/null and b/Dispatch_V0.1.1/gui/components/__pycache__/kanban_board.cpython-313.pyc differ diff --git a/Dispatch_V0.1.1/gui/components/__pycache__/kanban_card.cpython-313.pyc b/Dispatch_V0.1.1/gui/components/__pycache__/kanban_card.cpython-313.pyc new file mode 100644 index 0000000..36a0834 Binary files /dev/null and b/Dispatch_V0.1.1/gui/components/__pycache__/kanban_card.cpython-313.pyc differ diff --git a/Dispatch_V0.1.1/gui/components/__pycache__/kanban_column.cpython-313.pyc b/Dispatch_V0.1.1/gui/components/__pycache__/kanban_column.cpython-313.pyc new file mode 100644 index 0000000..aa20fac Binary files /dev/null and b/Dispatch_V0.1.1/gui/components/__pycache__/kanban_column.cpython-313.pyc differ diff --git a/Dispatch_V0.1.1/gui/components/__pycache__/label.cpython-313.pyc b/Dispatch_V0.1.1/gui/components/__pycache__/label.cpython-313.pyc new file mode 100644 index 0000000..5822140 Binary files /dev/null and b/Dispatch_V0.1.1/gui/components/__pycache__/label.cpython-313.pyc differ diff --git a/Dispatch_V0.1.1/gui/components/__pycache__/model_view_widget.cpython-313.pyc b/Dispatch_V0.1.1/gui/components/__pycache__/model_view_widget.cpython-313.pyc new file mode 100644 index 0000000..b98c153 Binary files /dev/null and b/Dispatch_V0.1.1/gui/components/__pycache__/model_view_widget.cpython-313.pyc differ diff --git a/Dispatch_V0.1.1/gui/components/__pycache__/part_visualizer.cpython-313.pyc b/Dispatch_V0.1.1/gui/components/__pycache__/part_visualizer.cpython-313.pyc new file mode 100644 index 0000000..e0a7ad7 Binary files /dev/null and b/Dispatch_V0.1.1/gui/components/__pycache__/part_visualizer.cpython-313.pyc differ diff --git a/Dispatch_V0.1.1/gui/components/__pycache__/photo_view_widget.cpython-313.pyc b/Dispatch_V0.1.1/gui/components/__pycache__/photo_view_widget.cpython-313.pyc new file mode 100644 index 0000000..b6dc83c Binary files /dev/null and b/Dispatch_V0.1.1/gui/components/__pycache__/photo_view_widget.cpython-313.pyc differ diff --git a/Dispatch_V0.1.1/gui/components/__pycache__/radio_button.cpython-313.pyc b/Dispatch_V0.1.1/gui/components/__pycache__/radio_button.cpython-313.pyc new file mode 100644 index 0000000..d731e99 Binary files /dev/null and b/Dispatch_V0.1.1/gui/components/__pycache__/radio_button.cpython-313.pyc differ diff --git a/Dispatch_V0.1.1/gui/components/__pycache__/radio_group.cpython-313.pyc b/Dispatch_V0.1.1/gui/components/__pycache__/radio_group.cpython-313.pyc new file mode 100644 index 0000000..c3db8bb Binary files /dev/null and b/Dispatch_V0.1.1/gui/components/__pycache__/radio_group.cpython-313.pyc differ diff --git a/Dispatch_V0.1.1/gui/components/__pycache__/springs.cpython-313.pyc b/Dispatch_V0.1.1/gui/components/__pycache__/springs.cpython-313.pyc new file mode 100644 index 0000000..2820795 Binary files /dev/null and b/Dispatch_V0.1.1/gui/components/__pycache__/springs.cpython-313.pyc differ diff --git a/Dispatch_V0.1.1/gui/components/__pycache__/tab_button.cpython-313.pyc b/Dispatch_V0.1.1/gui/components/__pycache__/tab_button.cpython-313.pyc new file mode 100644 index 0000000..11e1366 Binary files /dev/null and b/Dispatch_V0.1.1/gui/components/__pycache__/tab_button.cpython-313.pyc differ diff --git a/Dispatch_V0.1.1/gui/components/__pycache__/tab_widget.cpython-313.pyc b/Dispatch_V0.1.1/gui/components/__pycache__/tab_widget.cpython-313.pyc new file mode 100644 index 0000000..c8c0d09 Binary files /dev/null and b/Dispatch_V0.1.1/gui/components/__pycache__/tab_widget.cpython-313.pyc differ diff --git a/Dispatch_V0.1.1/gui/components/__pycache__/text_input.cpython-313.pyc b/Dispatch_V0.1.1/gui/components/__pycache__/text_input.cpython-313.pyc new file mode 100644 index 0000000..37f7d9c Binary files /dev/null and b/Dispatch_V0.1.1/gui/components/__pycache__/text_input.cpython-313.pyc differ diff --git a/Dispatch_V0.1.1/gui/components/__pycache__/toggle_button.cpython-313.pyc b/Dispatch_V0.1.1/gui/components/__pycache__/toggle_button.cpython-313.pyc new file mode 100644 index 0000000..be273bc Binary files /dev/null and b/Dispatch_V0.1.1/gui/components/__pycache__/toggle_button.cpython-313.pyc differ diff --git a/Dispatch_V0.1.1/gui/components/__pycache__/topology_tree_widget.cpython-313.pyc b/Dispatch_V0.1.1/gui/components/__pycache__/topology_tree_widget.cpython-313.pyc new file mode 100644 index 0000000..199d6a9 Binary files /dev/null and b/Dispatch_V0.1.1/gui/components/__pycache__/topology_tree_widget.cpython-313.pyc differ diff --git a/Dispatch_V0.1.1/gui/components/_tree_node_building.py b/Dispatch_V0.1.1/gui/components/_tree_node_building.py new file mode 100644 index 0000000..b3509a8 --- /dev/null +++ b/Dispatch_V0.1.1/gui/components/_tree_node_building.py @@ -0,0 +1,132 @@ +# -*- coding: utf-8 -*- +# gui/components/_tree_node_building.py +"""Сервис построения узлов дерева и ленивой загрузки (композиция).""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Dict, Any + +from PySide6.QtCore import Qt +from PySide6.QtWidgets import QTreeWidgetItem + +if TYPE_CHECKING: + from gui.components.topology_tree_widget import TopologyTreeWidget + + +class TreeNodeBuilder: + """Создание элементов QTreeWidget и ленивая загрузка дочерних узлов.""" + + def __init__(self, host: "TopologyTreeWidget") -> None: + self._host = host + + def load_root_nodes(self) -> None: + """Загрузка корневых узлов (сайтов).""" + try: + root_nodes = self._host.data_loader('site', None) + for node_data in root_nodes: + item = self._create_tree_item(node_data) + self._host._tree.addTopLevelItem(item) + if node_data.has_children and not node_data.children_loaded: + stub = QTreeWidgetItem(["Загрузка..."]) + stub.setData(0, Qt.UserRole, {"is_stub": True}) + item.addChild(stub) + except Exception as e: + error_msg = f"Ошибка загрузки корневых узлов: {e}" + print(error_msg) + self._host.dataLoadError.emit(error_msg) + + def _create_tree_item(self, node_data) -> QTreeWidgetItem: + """Создание элемента дерева на основе данных узла.""" + item = QTreeWidgetItem() + display_attrs = self._host.DISPLAY_ATTRIBUTES.get(node_data.node_type, []) + col_values = self._get_column_values( + node_data.display_data, display_attrs, node_data.node_type, + ) + for i, value in enumerate(col_values): + if i < self._host._tree.columnCount(): + item.setText(i, str(value)) + item.setData(0, Qt.UserRole, { + 'type': node_data.node_type, + 'id': node_data.node_id, + 'display_data': node_data.display_data, + 'has_children': node_data.has_children, + 'children_loaded': node_data.children_loaded, + 'raw_data': node_data.raw_data, + }) + item.setChildIndicatorPolicy( + QTreeWidgetItem.ShowIndicator if node_data.has_children + else QTreeWidgetItem.DontShowIndicator + ) + return item + + @staticmethod + def _get_column_values( + data: Dict[str, Any], attrs: list[str], node_type: str, + ) -> list[str]: + """Значения для колонок (только значения атрибутов, без ключей).""" + values = [] + if attrs: + for attr in attrs: + if attr in data and data[attr]: + values.append(str(data[attr])) + break + else: + values.append("") + else: + values.append("") + if len(attrs) > 1: + other = [str(data[a]) for a in attrs[1:] if a in data and data[a]] + sep = " " if node_type == "shelf" else ", " + values.append(sep.join(other)) + else: + values.append("") + return values + + def load_children(self, parent_item: QTreeWidgetItem) -> None: + """Ленивая загрузка дочерних элементов для узла.""" + parent_data = parent_item.data(0, Qt.UserRole) + if not parent_data: + return + parent_type = parent_data['type'] + parent_id = parent_data['id'] + node_key = f"{parent_type}:{parent_id}" + if node_key in self._host._loading_nodes: + return + self._host._loading_nodes.add(node_key) + try: + if parent_item.childCount() == 1: + child = parent_item.child(0) + child_data = child.data(0, Qt.UserRole) if child else {} + if child_data and child_data.get('is_stub'): + parent_item.removeChild(child) + children_data = self._host.data_loader( + self._get_child_type(parent_type), parent_id, + ) + for child_data in children_data: + child_item = self._create_tree_item(child_data) + parent_item.addChild(child_item) + if child_data.has_children and not child_data.children_loaded: + stub = QTreeWidgetItem(["Загрузка..."]) + stub.setData(0, Qt.UserRole, {"is_stub": True}) + child_item.addChild(stub) + parent_data['children_loaded'] = True + parent_item.setData(0, Qt.UserRole, parent_data) + self._host._tree.resizeColumnToContents(0) + except Exception as e: + msg = f"Ошибка загрузки дочерних элементов для {parent_type} {parent_id}: {e}" + print(msg) + self._host.dataLoadError.emit(msg) + err_item = QTreeWidgetItem(["Ошибка загрузки", str(e)[:50]]) + err_item.setData(0, Qt.UserRole, {"is_error": True}) + parent_item.addChild(err_item) + finally: + self._host._loading_nodes.remove(node_key) + + @staticmethod + def _get_child_type(parent_type: str) -> str: + """Тип дочерних элементов на основе типа родителя.""" + hierarchy = { + 'site': 'facility', 'facility': 'zone', 'zone': 'rack', + 'rack': 'shelf', 'shelf': 'cell', 'cell': 'volume', + } + return hierarchy.get(parent_type, '') diff --git a/Dispatch_V0.1.1/gui/components/_tree_state_management.py b/Dispatch_V0.1.1/gui/components/_tree_state_management.py new file mode 100644 index 0000000..553cec5 --- /dev/null +++ b/Dispatch_V0.1.1/gui/components/_tree_state_management.py @@ -0,0 +1,235 @@ +# -*- coding: utf-8 -*- +# gui/components/_tree_state_management.py +"""Сервис управления состоянием дерева: поиск, раскрытие, перезагрузка (композиция).""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Optional + +from PySide6.QtCore import Qt +from PySide6.QtWidgets import QTreeWidgetItem, QAbstractItemView +from error_logger import log_exception + +if TYPE_CHECKING: + from gui.components.topology_tree_widget import TopologyTreeWidget + + +class TreeStateManager: + """Поиск узлов, снимок/восстановление раскрытия, перезагрузка дерева.""" + + def __init__(self, host: "TopologyTreeWidget") -> None: + self._host = host + + def refresh_node(self, node_type: str, node_id: str) -> None: + """Обновление данных узла и его детей.""" + found_item = self._find_item_by_data(node_type, node_id) + if found_item: + while found_item.childCount() > 0: + found_item.removeChild(found_item.child(0)) + item_data = found_item.data(0, Qt.UserRole) + if item_data: + item_data['children_loaded'] = False + found_item.setData(0, Qt.UserRole, item_data) + if item_data.get('has_children'): + stub = QTreeWidgetItem(["Загрузка..."]) + stub.setData(0, Qt.UserRole, {"is_stub": True}) + found_item.addChild(stub) + self._host._tree.resizeColumnToContents(0) + + def _find_item_by_data(self, node_type: str, node_id: str) -> Optional[QTreeWidgetItem]: + """Поиск элемента дерева по типу и ID.""" + def search(item: QTreeWidgetItem) -> Optional[QTreeWidgetItem]: + d = item.data(0, Qt.UserRole) + if d and d.get('type') == node_type and d.get('id') == node_id: + return item + for i in range(item.childCount()): + result = search(item.child(i)) + if result: + return result + return None + for i in range(self._host._tree.topLevelItemCount()): + result = search(self._host._tree.topLevelItem(i)) + if result: + return result + return None + + def _find_or_load_item_by_data( + self, node_type: str, node_id: str, + ) -> Optional[QTreeWidgetItem]: + """Найти узел, лениво подгружая и раскрывая ветки.""" + node_type = str(node_type or "") + node_id = str(node_id or "") + if not node_type or not node_id: + return None + found = self._find_item_by_data(node_type, node_id) + if found is not None: + return found + + def search(item: QTreeWidgetItem) -> Optional[QTreeWidgetItem]: + d = item.data(0, Qt.UserRole) or {} + if d.get("is_stub") or d.get("is_error"): + return None + ct = str(d.get("type") or "") + ci = str(d.get("id") or "") + if ct == node_type and ci == node_id: + return item + if ct == node_type and ci != node_id: + return None + if bool(d.get("has_children")) and not bool(d.get("children_loaded")): + try: + self._host._node_builder.load_children(item) + except Exception as e: + log_exception(__name__, "_find_or_load.load_children", e) + for idx in range(item.childCount()): + result = search(item.child(idx)) + if result is not None: + try: + item.setExpanded(True) + except Exception as e: + log_exception(__name__, "_find_or_load.setExpanded", e) + return result + return None + + for idx in range(self._host._tree.topLevelItemCount()): + result = search(self._host._tree.topLevelItem(idx)) + if result is not None: + return result + return None + + @staticmethod + def _expand_item_parents(item: QTreeWidgetItem) -> None: + """Раскрыть цепочку родителей до корня.""" + parent = item.parent() + while parent is not None: + parent.setExpanded(True) + parent = parent.parent() + + def clear_tree(self) -> None: + """Полная очистка дерева.""" + self._host._tree.clear() + self._host._loading_nodes.clear() + + @staticmethod + def _item_key(item: QTreeWidgetItem) -> tuple[str, str] | None: + """Уникальный ключ узла: (type, id).""" + data = item.data(0, Qt.UserRole) or {} + nt = str(data.get("type") or "") + ni = str(data.get("id") or "") + if not nt or not ni: + return None + if data.get("is_stub") or data.get("is_error"): + return None + return nt, ni + + def _collect_expanded_paths(self) -> list[list[tuple[str, str]]]: + """Снять снимок раскрытых узлов как пути от корня.""" + paths: list[list[tuple[str, str]]] = [] + def walk(item: QTreeWidgetItem, path: list[tuple[str, str]]) -> None: + key = self._item_key(item) + if key is None: + return + path = [*path, key] + if item.isExpanded(): + paths.append(path) + for idx in range(item.childCount()): + walk(item.child(idx), path) + for idx in range(self._host._tree.topLevelItemCount()): + walk(self._host._tree.topLevelItem(idx), []) + return paths + + def _find_child_by_key(self, parent: QTreeWidgetItem, key: tuple[str, str]) -> Optional[QTreeWidgetItem]: + for idx in range(parent.childCount()): + child = parent.child(idx) + if self._item_key(child) == key: + return child + return None + + def _find_root_by_key(self, key: tuple[str, str]) -> Optional[QTreeWidgetItem]: + for idx in range(self._host._tree.topLevelItemCount()): + item = self._host._tree.topLevelItem(idx) + if self._item_key(item) == key: + return item + return None + + def _expand_path(self, path: list[tuple[str, str]]) -> None: + """Раскрыть путь root->... с подзагрузкой ленивых детей.""" + if not path: + return + current = self._find_root_by_key(path[0]) + if current is None: + return + current.setExpanded(True) + for key in path[1:]: + data = current.data(0, Qt.UserRole) or {} + if data.get("has_children") and not data.get("children_loaded"): + self._host._node_builder.load_children(current) + child = self._find_child_by_key(current, key) + if child is None: + return + child.setExpanded(True) + current = child + + def _restore_tree_state( + self, + expanded_paths: list[list[tuple[str, str]]], + selected_key: tuple[str, str] | None, + ) -> None: + """Восстановить раскрытие и выбранный элемент после reload.""" + for path in expanded_paths: + self._expand_path(path) + if selected_key: + sel = self._find_item_by_data(selected_key[0], selected_key[1]) + if sel is not None: + self._host._tree.setCurrentItem(sel) + self._host._tree.scrollToItem( + sel, QAbstractItemView.ScrollHint.PositionAtCenter, + ) + + def reload_tree(self, preserve_state: bool = True) -> None: + """Полная перезагрузка дерева с опциональным сохранением состояния.""" + expanded_paths: list[list[tuple[str, str]]] = [] + selected_key: tuple[str, str] | None = None + if preserve_state: + expanded_paths = self._collect_expanded_paths() + current = self._host._tree.currentItem() + if current is not None: + selected_key = self._item_key(current) + self.clear_tree() + self._host._node_builder.load_root_nodes() + if preserve_state: + self._restore_tree_state(expanded_paths, selected_key) + self._host._tree.resizeColumnToContents(0) + + def select_node( + self, + node_type: str, + node_id: str, + *, + emit_selected: bool = False, + allow_load: bool = True, + expand_parents: bool = True, + ) -> bool: + """Программно выделить узел дерева. Возвращает True при успехе.""" + rt = str(node_type or "") + ri = str(node_id or "") + if allow_load: + item = self._find_or_load_item_by_data(rt, ri) + else: + item = self._find_item_by_data(rt, ri) + if item is None: + return False + if expand_parents: + self._expand_item_parents(item) + self._host._tree.setCurrentItem(item) + self._host._tree.scrollToItem( + item, QAbstractItemView.ScrollHint.PositionAtCenter, + ) + if emit_selected: + d = item.data(0, Qt.UserRole) or {} + if not d.get("is_stub") and not d.get("is_error"): + self._host.nodeSelected.emit( + str(d.get("type") or ""), + str(d.get("id") or ""), + dict(d.get("display_data") or {}), + ) + return True diff --git a/Dispatch_V0.1.1/gui/components/button.py b/Dispatch_V0.1.1/gui/components/button.py new file mode 100644 index 0000000..c075a28 --- /dev/null +++ b/Dispatch_V0.1.1/gui/components/button.py @@ -0,0 +1,280 @@ +# -*- coding: utf-8 -*- +# gui/components/button.py + +from PySide6.QtWidgets import QPushButton, QSizePolicy +from PySide6.QtCore import Slot, QSize +from PySide6.QtGui import QIcon +from gui.theme_bus import theme_bus +from gui.containers.s_container import SContainer # Импортируем кастомный контейнер +from gui.styles import APP_STYLES + +class Button(SContainer): + """Навигационная кнопка на основе кастомного контейнера SContainer.""" + + def __init__(self, text: str, index: int = 0, **kwargs): + # Извлекаем параметры для передачи в SContainer + width_percent = kwargs.get("width_percent", None) + height_percent = kwargs.get("height_percent", None) + margin = kwargs.get("margin", [0, 2, 0, 2]) + style = kwargs.get("style", None) + active_style = kwargs.get("active_style", None) + is_active = kwargs.get("is_active", None) + content_fit = kwargs.get("content_fit", True) + parent = kwargs.get("parent", None) + + # Вызываем конструктор SContainer с параметрами + super().__init__( + width_percent=width_percent, + height_percent=height_percent, + margin=margin, + style=style, + active_style=active_style, + is_active=is_active, + content_fit=content_fit, + parent=parent, + ) + + self.index = index + self._theme = "dark" + self._is_active = False + self._style_key_normal = None + self._style_key_active = None + + # Создаем кнопку + self._button = QPushButton(text) + self._button.setProperty("widget_index", index) + + # Добавляем кнопку в layout контейнера + super().add_widget(self._button) + + # Настраиваем кнопку для заполнения всего доступного пространства + self._button.setSizePolicy( + QSizePolicy.Policy.Expanding, + QSizePolicy.Policy.Expanding + ) + + # Флаг для отслеживания первого обновления + self._initial_update_done = False + + if style is not None: + self._style_key_normal = style + self._style_key_active = active_style or style + if is_active is not None: + self._is_active = bool(is_active) + + self._theme = "dark" if self.palette().window().color().lightness() < 128 else "light" + self.style() + theme_bus.theme_changed.connect(self.set_theme) + + # Иконка (опционально): путь к PNG + размер иконки внутри кнопки. + # Размер иконки — это свойство QPushButton (QSize), а не layout-геометрия; + # правило 6.7 (запрет fixed-size) распространяется на разметку, не на иконки. + icon_path = kwargs.get("icon_path", None) + icon_size = kwargs.get("icon_size", 16) + if icon_path: + self._button.setIcon(QIcon(str(icon_path))) + self._button.setIconSize(QSize(int(icon_size), int(icon_size))) + + def style( + self, + style_key: str | None = None, + active_key: str | None = None, + is_active: bool | None = None, + ): + """Короткий метод применения стиля. Можно задать ключи и активность явно.""" + if style_key is not None: + self._style_key_normal = style_key + self._style_key_active = active_key or style_key + + if is_active is not None: + self._is_active = bool(is_active) + + if self._style_key_normal is not None: + active_key = self._style_key_active or self._style_key_normal + key = active_key if self._is_active else self._style_key_normal + themed = f"{key}_{self._theme.upper()}" + if themed in APP_STYLES: + key = themed + self._button.setStyleSheet(APP_STYLES.get(key, "")) + return + + if self._theme == "light": + if self._is_active and "STANDARD_BUTTON_LIGHT_THEME_ACTIVE" in APP_STYLES: + self._button.setStyleSheet(APP_STYLES["STANDARD_BUTTON_LIGHT_THEME_ACTIVE"]) + else: + self._button.setStyleSheet(APP_STYLES["STANDARD_BUTTON_LIGHT_THEME"]) + return + + if self._is_active: + self._button.setStyleSheet(APP_STYLES["STANDARD_BUTTON_DARK_THEME_ACTIVE"]) + else: + self._button.setStyleSheet(APP_STYLES["STANDARD_BUTTON_DARK_THEME"]) + + @Slot(str) + def set_theme(self, theme: str): + """Внешний слот: принимает 'dark' или 'light'.""" + theme = (theme or "").strip().lower() + if theme not in ("dark", "light"): + return # игнорируем ошибочные значения + + if self._theme == theme: + return + + self._theme = theme + self.style() + + # Делегируем clicked сигнал и другие методы внутренней кнопке + @property + def clicked(self): + return self._button.clicked + + @property + def toggled(self): + return self._button.toggled + + def click(self): + self._button.click() + + def set_text(self, text: str): + self._button.setText(text) + + def get_text(self) -> str: + return self._button.text() + + def set_tooltip(self, text: str): + self._button.setToolTip(text) + + def get_tooltip(self) -> str: + return self._button.toolTip() + + def set_checkable(self, checkable: bool): + self._button.setCheckable(checkable) + + def set_checked(self, checked: bool): + self._button.setChecked(checked) + + def is_checked(self) -> bool: + return self._button.isChecked() + + def set_enabled(self, enabled: bool): + self._button.setEnabled(enabled) + super().setEnabled(enabled) + + def set_min_width(self, width: int) -> None: + self._button.setMinimumWidth(width) + super().setMinimumWidth(width) + + def set_min_height(self, height: int) -> None: + self._button.setMinimumHeight(height) + super().setMinimumHeight(height) + + def set_max_width(self, width: int) -> None: + self._button.setMaximumWidth(width) + super().setMaximumWidth(width) + + def set_max_height(self, height: int) -> None: + self._button.setMaximumHeight(height) + super().setMaximumHeight(height) + + def set_fixed_size(self, width: int, height: int) -> None: + self._button.setMinimumSize(width, height) + self._button.setMaximumSize(width, height) + super().setMinimumSize(width, height) + super().setMaximumSize(width, height) + + def set_font(self, font): + self._button.setFont(font) + + def set_property(self, name: str, value): + super().setProperty(name, value) + self._button.setProperty(name, value) + + def set_size_policy(self, horizontal, vertical) -> None: + self._button.setSizePolicy(horizontal, vertical) + super().setSizePolicy(horizontal, vertical) + + # Переопределяем add_widget, чтобы предотвратить добавление других виджетов + def add_widget(self, widget, alignment=None): + raise NotImplementedError("Button может содержать только одну кнопку") + + + + + + +# --------------------------------------------------------------------------- +# Module workflow notes +# --------------------------------------------------------------------------- +# +# 1) Назначение модуля: +# Навигационная/функциональная кнопка, реализованная как запечатанный +# контейнер SContainer с одной внутренней QPushButton, поддерживающая +# централизованные стили APP_STYLES и автоматическое переключение +# тем (dark/light) через theme_bus. +# +# 2) Зависимости модуля: +# Импорты: QPushButton, QSizePolicy (PySide6.QtWidgets), +# Slot (PySide6.QtCore), +# theme_bus (gui.theme_bus), +# SContainer (gui.containers.s_container), +# APP_STYLES (gui.styles) +# Хост-класс / базовый класс: SContainer +# Внешние библиотеки: PySide6 (обязательна) +# +# 3) Экспорт: +# Класс Button — публичный виджет-кнопка. +# Основные методы: style(), set_theme(), set_text(), get_text(), +# set_tooltip(), set_checkable(), set_checked(), is_checked(), +# set_enabled(), set_min_width/height(), set_max_width/height(), +# set_fixed_size(), set_font(), set_property(), set_size_policy(), +# click(). +# Свойства: clicked, toggled (делегируют к внутренней QPushButton). +# +# 4) Состояние (поля): +# index: int — числовой индекс кнопки (для идентификации в группе) +# _theme: str — текущая тема ("dark" | "light") +# _is_active: bool — признак активного состояния +# _style_key_normal: str|None — ключ стиля нормального состояния +# _style_key_active: str|None — ключ стиля активного состояния +# _button: QPushButton — внутренний виджет кнопки +# _initial_update_done: bool — флаг первого обновления +# +# 5) Последовательность действий и вызовов: +# __init__(text, index, **kwargs) +# -> super().__init__(...) — инициализация SContainer +# -> QPushButton(text) — создание внутренней кнопки +# -> super().add_widget(_button) — добавление в layout контейнера +# -> style() — первичное применение стиля из APP_STYLES +# -> theme_bus.theme_changed.connect(set_theme) +# style(style_key?, active_key?, is_active?) +# -> выбор ключа на основе _is_active + _theme +# -> _button.setStyleSheet(APP_STYLES[key]) +# set_theme(theme: str) +# -> _theme = theme -> style() — перерисовка стиля +# clicked / toggled (properties) +# -> делегируют к _button.clicked / _button.toggled +# +# 6) Побочные эффекты: +# - Устанавливает stylesheet на внутреннюю QPushButton. +# - Подключается к глобальному сигналу theme_bus.theme_changed при создании. +# +# 7) Границы ответственности: +# Модуль НЕ управляет layout хоста, НЕ хранит бизнес-логику, +# НЕ регистрирует обработчики кликов (это делает потребитель). +# add_widget() заблокирован — кнопка содержит только QPushButton. +# +# 8) Обработка ошибок: +# add_widget() бросает NotImplementedError при попытке добавить +# дополнительный виджет. set_theme() молча игнорирует невалидные +# значения темы. +# +# 9) Инварианты и контракты: +# - Контейнер всегда содержит ровно одну QPushButton. +# - _theme ∈ {"dark", "light"}. +# - Если _style_key_normal задан, стиль определяется им; иначе — +# используется стандартная пара STANDARD_BUTTON_*_THEME(_ACTIVE). +# +# 10) Правило сопровождения: +# При добавлении новой темы — расширить ветку в style(). +# Не добавлять дочерние виджеты внутрь Button. +# Новые делегирующие методы дублировать на _button и super(). diff --git a/Dispatch_V0.1.1/gui/components/color_palette.py b/Dispatch_V0.1.1/gui/components/color_palette.py new file mode 100644 index 0000000..b12c17d --- /dev/null +++ b/Dispatch_V0.1.1/gui/components/color_palette.py @@ -0,0 +1,239 @@ +# -*- coding: utf-8 -*- +# gui/components/color_palette.py +"""Палитра выбора цвета — контейнеризованный компонент.""" + +from __future__ import annotations + +from PySide6.QtCore import Qt, Signal +from PySide6.QtGui import QColor +from PySide6.QtWidgets import QGridLayout, QPushButton, QSizePolicy, QWidget + +from gui.containers.s_container import SContainer + +# Стандартная палитра 32 цвета (4×8). +DEFAULT_PALETTE_COLORS: list[str] = [ + "#FF6B6B", "#FF8E72", "#FFB26B", "#FFD56B", "#F0E96B", "#B8E86B", "#7EDB79", "#5CC8A8", + "#59B6E6", "#5F9DFF", "#7A84FF", "#A07CFF", "#C27BFF", "#E07ADB", "#F57FB1", "#D97A5C", + "#B35A4A", "#8E4A3D", "#6F5A4D", "#7E7E7E", "#5C5C5C", "#3F4A5A", "#2F6F8F", "#2F8F86", + "#3C8F52", "#6D8F3C", "#8F873C", "#8F6B3C", "#8F4F3C", "#7B3C8F", "#4F3C8F", "#3C5C8F", +] + + +class ColorPalette(SContainer): + """Сетка цветных кнопок с единственным выделением. + + Сигнал ``color_selected`` испускается при клике. + Публичный API — ``set_color_hex``, ``get_color_hex``, ``set_enabled``. + """ + + color_selected = Signal(str) # #RRGGBB + + def __init__( + self, + colors: list[str] | None = None, + columns: int = 8, + selected_index: int = 0, + width_percent: int | None = None, + height_percent: int | None = None, + margin: int | tuple[int, int, int, int] = 0, + parent: QWidget | None = None, + ): + super().__init__( + width_percent=width_percent, + height_percent=height_percent, + margin=margin, + parent=parent, + ) + self._colors: list[str] = list(colors or DEFAULT_PALETTE_COLORS) + self._columns = max(1, int(columns)) + self._selected_index: int = max(0, min(int(selected_index), len(self._colors) - 1)) + self._buttons: list[QPushButton] = [] + + # Внутренний виджет с QGridLayout — палитра динамическая, + # GridContainer не подходит (ячейки без авторасширения QPushButton). + self._grid_widget = QWidget() + self._grid_layout = QGridLayout(self._grid_widget) + self._grid_layout.setContentsMargins(0, 0, 0, 0) + self._grid_layout.setHorizontalSpacing(4) + self._grid_layout.setVerticalSpacing(4) + super().add_widget(self._grid_widget) + + self._build_grid() + + # ── Публичный API ───────────────────────────────────────────── + + def get_color_hex(self) -> str: + """Вернуть выбранный цвет ``#RRGGBB``.""" + if not self._colors: + return "#7FB3D5" + idx = max(0, min(self._selected_index, len(self._colors) - 1)) + return str(self._colors[idx]) + + def set_color_hex(self, color_hex: str) -> None: + """Выбрать ближайший цвет палитры к ``color_hex``.""" + idx = self._nearest_index(str(color_hex or "")) + if idx == self._selected_index: + return + self._selected_index = idx + self._refresh_selection() + + def get_selected_index(self) -> int: + """Индекс выбранного цвета.""" + return self._selected_index + + def set_selected_index(self, index: int) -> None: + """Выбрать цвет по индексу.""" + index = max(0, min(int(index), len(self._colors) - 1)) + if index == self._selected_index: + return + self._selected_index = index + self._refresh_selection() + + def set_enabled(self, enabled: bool) -> None: + """Включить/выключить все кнопки палитры.""" + for button in self._buttons: + button.setEnabled(bool(enabled)) + super().set_enabled(enabled) + + def add_widget(self, widget, alignment=None): + raise NotImplementedError("ColorPalette is a sealed component") + + # ── Внутренние методы ───────────────────────────────────────── + + def _build_grid(self) -> None: + self._buttons = [] + for idx, color in enumerate(self._colors): + button = QPushButton("") + button.setCheckable(True) + button.setCursor(Qt.CursorShape.PointingHandCursor) + button.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) + button.clicked.connect(lambda _checked=False, i=idx: self._on_clicked(i)) + self._buttons.append(button) + row = idx // self._columns + col = idx % self._columns + self._grid_layout.addWidget(button, row, col) + self._apply_button_style(idx, selected=(idx == self._selected_index)) + self._refresh_selection() + + def _on_clicked(self, index: int) -> None: + index = max(0, min(int(index), len(self._colors) - 1)) + self._selected_index = index + self._refresh_selection() + self.color_selected.emit(self.get_color_hex()) + + def _refresh_selection(self) -> None: + for idx, button in enumerate(self._buttons): + is_selected = idx == self._selected_index + button.blockSignals(True) + button.setChecked(is_selected) + button.blockSignals(False) + self._apply_button_style(idx, selected=is_selected) + + def _apply_button_style(self, index: int, selected: bool) -> None: + if index < 0 or index >= len(self._buttons): + return + color = self._colors[index] + border_color = "#FFFFFF" if selected else "#5F5F5F" + border_width = 2 if selected else 1 + self._buttons[index].setStyleSheet( + f"QPushButton {{" + f"background-color: {color};" + f"border: {border_width}px solid {border_color};" + f"border-radius: 2px;" + f"min-height: 18px;" + f"}}" + f"QPushButton:disabled {{" + f"background-color: {color};" + f"border: {border_width}px solid #3A3A3A;" + f"}}" + ) + + def _nearest_index(self, color_hex: str) -> int: + if not self._colors: + return 0 + target = QColor(color_hex) + if not target.isValid(): + return 0 + best_idx = 0 + best_dist: int | None = None + for idx, candidate_hex in enumerate(self._colors): + candidate = QColor(candidate_hex) + dr = int(target.red()) - int(candidate.red()) + dg = int(target.green()) - int(candidate.green()) + db = int(target.blue()) - int(candidate.blue()) + dist = dr * dr + dg * dg + db * db + if best_dist is None or dist < best_dist: + best_dist = dist + best_idx = idx + return best_idx + + +# --------------------------------------------------------------------------- +# Module workflow notes +# --------------------------------------------------------------------------- +# +# 1) Назначение модуля: +# Палитра выбора цвета — сетка цветных кнопок (по умолчанию 4×8) +# с единственным выделением и сигналом color_selected, встроенная +# в контейнер SContainer для процентного позиционирования. +# +# 2) Зависимости модуля: +# Импорты: Qt, Signal (PySide6.QtCore), +# QColor (PySide6.QtGui), +# QGridLayout, QPushButton, QSizePolicy, QWidget (PySide6.QtWidgets), +# SContainer (gui.containers.s_container) +# Хост-класс / базовый класс: SContainer +# Внешние библиотеки: PySide6 (обязательна) +# +# 3) Экспорт: +# Константа DEFAULT_PALETTE_COLORS (list[str], 32 цвета). +# Класс ColorPalette — публичный компонент палитры. +# Сигнал: color_selected(str) — #RRGGBB при клике. +# Методы: get_color_hex(), set_color_hex(), get_selected_index(), +# set_selected_index(), set_enabled(). +# +# 4) Состояние (поля): +# _colors: list[str] — массив HEX-цветов палитры +# _columns: int — количество столбцов сетки +# _selected_index: int — индекс текущего выбранного цвета +# _buttons: list[QPushButton]— массив кнопок-ячеек +# _grid_widget: QWidget — внутренний виджет с QGridLayout +# _grid_layout: QGridLayout — компоновка сетки +# +# 5) Последовательность действий и вызовов: +# __init__(colors?, columns, selected_index, ...) +# -> super().__init__(...) +# -> _grid_widget + QGridLayout — контейнер для кнопок +# -> super().add_widget(_grid_widget) +# -> _build_grid() — создание QPushButton для каждого цвета +# -> для каждого цвета: QPushButton -> setCheckable -> connect(_on_clicked) +# -> _apply_button_style(idx, selected) +# -> _refresh_selection() +# set_color_hex(color_hex) +# -> _nearest_index(color_hex) — поиск ближайшего по евклидову расстоянию RGB +# -> _refresh_selection() — обновление всех кнопок +# _on_clicked(index) +# -> _refresh_selection() -> color_selected.emit(get_color_hex()) +# +# 6) Побочные эффекты: +# - Устанавливает inline stylesheet на каждую кнопку палитры. +# - Испускает сигнал color_selected при выборе цвета. +# +# 7) Границы ответственности: +# Модуль НЕ подключается к theme_bus (стили инлайновые). +# НЕ хранит «применённый» цвет зоны — только текущий выбор палитры. +# add_widget() заблокирован — компонент запечатан. +# +# 8) Обработка ошибок: +# add_widget() бросает NotImplementedError. _nearest_index() возвращает 0 +# при невалидном HEX. Индексы зажимаются в допустимый диапазон (clamp). +# +# 9) Инварианты и контракты: +# - 0 ≤ _selected_index < len(_colors). +# - Ровно одна кнопка в checked-состоянии (exclusive selection). +# - Количество кнопок == len(_colors); _columns ≥ 1. +# +# 10) Правило сопровождения: +# Для изменения набора цветов — менять DEFAULT_PALETTE_COLORS или +# передавать colors в конструктор. Не изменять логику _nearest_index +# без учёта perceptual color distance. diff --git a/Dispatch_V0.1.1/gui/components/color_swatch.py b/Dispatch_V0.1.1/gui/components/color_swatch.py new file mode 100644 index 0000000..29a0ba6 --- /dev/null +++ b/Dispatch_V0.1.1/gui/components/color_swatch.py @@ -0,0 +1,153 @@ +# -*- coding: utf-8 -*- +# gui/components/color_swatch.py +"""Цветовой маркер — контейнеризованный компонент.""" + +from __future__ import annotations + +from PySide6.QtGui import QColor, QPainter, QPen, QBrush +from PySide6.QtCore import Qt, QRect +from PySide6.QtWidgets import QWidget, QSizePolicy + +from gui.containers.s_container import SContainer + + +class ColorSwatch(SContainer): + """Квадрат-маркер цвета зоны. + + Контейнеризованный компонент gui-фреймворка. + Размеры задаются через ``width_percent`` / ``height_percent``, + отступы — через ``margin``. + """ + + def __init__( + self, + color: str = "#9E9E9EC0", + width_percent: int | None = None, + height_percent: int | None = None, + margin: int | tuple[int, int, int, int] = 0, + parent: QWidget | None = 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, + margin=margin, + parent=parent, + style=style, + active_style=active_style, + is_active=is_active, + ) + + self._swatch = _SwatchCanvas(color, parent=self) + self._swatch.setSizePolicy( + QSizePolicy.Policy.Expanding, + QSizePolicy.Policy.Expanding, + ) + self.add_widget(self._swatch) + + # ── Публичный API ───────────────────────────────────────────── + + def set_color(self, color: str) -> None: + """Установить цвет маркера (hex ``#RRGGBB`` или ``#RRGGBBAA``).""" + self._swatch.set_color(color) + + def get_color(self) -> str: + """Вернуть текущий цвет маркера.""" + return self._swatch.get_color() + + +class _SwatchCanvas(QWidget): + """Внутренний виджет, рисующий заливку + тонкую рамку.""" + + _BORDER_COLOR = QColor("#555555") + _BORDER_RADIUS = 3 + + def __init__(self, color: str, parent: QWidget | None = None): + super().__init__(parent) + self._qcolor = QColor(color) + self._hex = color + + def set_color(self, color: str) -> None: + self._hex = color + self._qcolor = QColor(color) + self.update() + + def get_color(self) -> str: + return self._hex + + def paintEvent(self, event) -> None: # noqa: N802 + painter = QPainter(self) + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + + rect = self.rect().adjusted(1, 1, -1, -1) + + painter.setPen(QPen(self._BORDER_COLOR, 1)) + painter.setBrush(QBrush(self._qcolor)) + painter.drawRoundedRect(rect, self._BORDER_RADIUS, self._BORDER_RADIUS) + + painter.end() + + +# --------------------------------------------------------------------------- +# Module workflow notes +# --------------------------------------------------------------------------- +# +# 1) Назначение модуля: +# Цветовой маркер (квадрат) для визуальной индикации цвета зоны, +# реализованный как SContainer с внутренним виджетом _SwatchCanvas, +# который рисует заливку и рамку через QPainter. +# +# 2) Зависимости модуля: +# Импорты: QColor, QPainter, QPen, QBrush (PySide6.QtGui), +# Qt, QRect (PySide6.QtCore), +# QWidget, QSizePolicy (PySide6.QtWidgets), +# SContainer (gui.containers.s_container) +# Хост-класс / базовый класс: ColorSwatch -> SContainer; +# _SwatchCanvas -> QWidget +# Внешние библиотеки: PySide6 (обязательна) +# +# 3) Экспорт: +# Класс ColorSwatch — публичный компонент маркера цвета. +# Методы: set_color(str), get_color() -> str. +# Класс _SwatchCanvas — внутренний (непубличный). +# +# 4) Состояние (поля): +# ColorSwatch: +# _swatch: _SwatchCanvas — внутренний виджет рисования +# _SwatchCanvas: +# _qcolor: QColor — текущий цвет для заливки +# _hex: str — текущий HEX-код цвета +# _BORDER_COLOR: QColor — цвет рамки (константа #555555) +# _BORDER_RADIUS: int — радиус скругления (константа 3) +# +# 5) Последовательность действий и вызовов: +# __init__(color, ...) +# -> super().__init__(...) — SContainer +# -> _SwatchCanvas(color) -> setSizePolicy(Expanding) +# -> self.add_widget(_swatch) +# set_color(color) +# -> _swatch.set_color(color) -> QColor(color) -> update() [перерисовка] +# _SwatchCanvas.paintEvent(event) +# -> QPainter -> drawRoundedRect с заливкой _qcolor и рамкой _BORDER_COLOR +# +# 6) Побочные эффекты: +# - Перерисовывает виджет при изменении цвета (update()). +# - Никаких сигналов не испускает. +# +# 7) Границы ответственности: +# Модуль только отображает цвет. НЕ взаимодействует с theme_bus. +# НЕ обрабатывает клики. НЕ связан с палитрой или выбором цвета. +# +# 8) Обработка ошибок: +# Невалидный HEX приводит к QColor.isValid() == False, что Qt обрабатывает +# как прозрачный/чёрный. Явных проверок нет. +# +# 9) Инварианты и контракты: +# - _SwatchCanvas.paintEvent всегда рисует прямоугольник с рамкой. +# - set_color / get_color симметричны по HEX-строке. +# +# 10) Правило сопровождения: +# При необходимости нового формата цвета (HSL, RGB-кортеж) — расширять +# set_color() с конвертацией. Не менять paintEvent без проверки antialiasing. diff --git a/Dispatch_V0.1.1/gui/components/combo_box.py b/Dispatch_V0.1.1/gui/components/combo_box.py new file mode 100644 index 0000000..9cbfb01 --- /dev/null +++ b/Dispatch_V0.1.1/gui/components/combo_box.py @@ -0,0 +1,281 @@ +# -*- coding: utf-8 -*- +# gui/components/combo_box.py +"""Обёртка над QComboBox с централизованными стилями.""" + +from PySide6.QtWidgets import QComboBox, QSizePolicy +from PySide6.QtCore import Slot + +from gui.styles import APP_STYLES +from gui.theme_bus import theme_bus +from gui.containers.s_container import SContainer + + +class ComboBox(SContainer): + """Кастомный QComboBox с темизацией и стилями APP_STYLES.""" + + def __init__( + self, + width_percent: int | None = None, + height_percent: int | None = None, + margin: int | tuple[int, int, int, int] = 0, + style: str = "FORM_WIDGET", + active_style: str | None = None, + is_active: bool | None = None, + content_fit: bool = True, + parent=None, + ): + super().__init__( + width_percent=width_percent, + height_percent=height_percent, + margin=margin, + style=style, + active_style=active_style, + is_active=is_active, + content_fit=content_fit, + parent=parent, + ) + self._theme = "dark" + self._is_active = False + self._base_style_key = style + self._style_key_normal = None + self._style_key_active = None + + self._combo = QComboBox() + self._combo.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) + super().add_widget(self._combo) + + if active_style is not None: + self._style_key_normal = style + self._style_key_active = active_style + if is_active is not None: + self._is_active = bool(is_active) + + self.style() + theme_bus.theme_changed.connect(self.set_theme) + + def style( + self, + style_key: str | None = None, + active_key: str | None = None, + is_active: bool | None = None, + ) -> None: + """Короткий метод применения стиля. Можно задать ключи и активность явно.""" + if style_key is not None: + self._base_style_key = style_key + if active_key is not None: + self._style_key_normal = style_key + self._style_key_active = active_key + else: + self._style_key_normal = None + self._style_key_active = None + + if is_active is not None: + self._is_active = bool(is_active) + + if self._style_key_normal is not None: + active_key = self._style_key_active or self._style_key_normal + key = active_key if self._is_active else self._style_key_normal + themed = f"{key}_{self._theme.upper()}" + if themed in APP_STYLES: + key = themed + self._combo.setStyleSheet(APP_STYLES.get(key, "")) + return + + base_key = self._base_style_key + key = base_key + + if self._theme == "light": + if self._is_active and f"{base_key}_LIGHT_ACTIVE" in APP_STYLES: + key = f"{base_key}_LIGHT_ACTIVE" + elif f"{base_key}_LIGHT" in APP_STYLES: + key = f"{base_key}_LIGHT" + else: + if self._is_active and f"{base_key}_DARK_ACTIVE" in APP_STYLES: + key = f"{base_key}_DARK_ACTIVE" + elif f"{base_key}_DARK" in APP_STYLES: + key = f"{base_key}_DARK" + + self._combo.setStyleSheet(APP_STYLES.get(key, "")) + + @Slot(str) + def set_theme(self, theme: str) -> None: + """Внешний слот: принимает 'dark' или 'light'.""" + theme = (theme or "").strip().lower() + if theme not in ("dark", "light"): + return + if self._theme == theme: + return + self._theme = theme + self.style() + + def set_items(self, items: list[str]) -> None: + """Заменить список элементов.""" + self._combo.clear() + self._combo.addItems(items) + + def set_editable(self, editable: bool) -> None: + self._combo.setEditable(editable) + + def set_enabled(self, enabled: bool) -> None: + """Управление доступностью.""" + self._combo.setEnabled(enabled) + super().setEnabled(enabled) + + def set_min_width(self, width: int) -> None: + """Минимальная ширина.""" + self._combo.setMinimumWidth(width) + super().setMinimumWidth(width) + + def set_min_height(self, height: int) -> None: + """Минимальная высота.""" + self._combo.setMinimumHeight(height) + super().setMinimumHeight(height) + + def set_max_width(self, width: int) -> None: + """Максимальная ширина.""" + self._combo.setMaximumWidth(width) + super().setMaximumWidth(width) + + def set_max_height(self, height: int) -> None: + """Максимальная высота.""" + self._combo.setMaximumHeight(height) + super().setMaximumHeight(height) + + def set_fixed_size(self, width: int, height: int) -> None: + """Фиксированный размер.""" + self._combo.setMinimumSize(width, height) + self._combo.setMaximumSize(width, height) + super().setMinimumSize(width, height) + super().setMaximumSize(width, height) + + def set_index(self, index: int) -> None: + """Установить текущий индекс.""" + self._combo.setCurrentIndex(index) + + def set_current_text(self, text: str) -> None: + self._combo.setCurrentText(text) + + def set_placeholder_text(self, text: str) -> None: + """Установить текст-заполнитель (если поддерживается Qt).""" + line_edit = self._combo.lineEdit() + if line_edit is not None: + line_edit.setPlaceholderText(text) + if hasattr(self._combo, "setPlaceholderText"): + self._combo.setPlaceholderText(text) + + def get_index(self) -> int: + """Получить текущий индекс.""" + return self._combo.currentIndex() + + def get_current_text(self) -> str: + return self._combo.currentText() + + def set_tooltip(self, text: str) -> None: + """Подсказка.""" + self._combo.setToolTip(text) + + def set_size_policy(self, horizontal, vertical) -> None: + """Политика размеров.""" + self._combo.setSizePolicy(horizontal, vertical) + super().setSizePolicy(horizontal, vertical) + + @property + def current_index_changed(self): + return self._combo.currentIndexChanged + + @property + def current_text_changed(self): + return self._combo.currentTextChanged + + @property + def text_edited(self): + line_edit = self._combo.lineEdit() + if line_edit is None: + raise AttributeError("text_edited is unavailable for non-editable ComboBox") + return line_edit.textEdited + + def set_item_enabled(self, index: int, enabled: bool) -> None: + """Включить/выключить элемент списка по индексу.""" + model = self._combo.model() + if model is None: + return + item = model.item(index) if hasattr(model, "item") else None + if item is not None and hasattr(item, "setEnabled"): + item.setEnabled(bool(enabled)) + + def show_popup(self) -> None: + self._combo.showPopup() + + def add_widget(self, widget, alignment=None): + raise NotImplementedError("ComboBox can contain only one QComboBox") + + + +# --------------------------------------------------------------------------- +# Module workflow notes +# --------------------------------------------------------------------------- +# +# 1) Назначение модуля: +# Обёртка над QComboBox, встроенная в SContainer, с поддержкой +# централизованных стилей APP_STYLES и автоматическим +# переключением тем (dark/light) через theme_bus. +# +# 2) Зависимости модуля: +# Импорты: QComboBox, QSizePolicy (PySide6.QtWidgets), +# Slot (PySide6.QtCore), +# APP_STYLES (gui.styles), +# theme_bus (gui.theme_bus), +# SContainer (gui.containers.s_container) +# Хост-класс / базовый класс: SContainer +# Внешние библиотеки: PySide6 (обязательна) +# +# 3) Экспорт: +# Класс ComboBox — публичный виджет выпадающего списка. +# Методы: style(), set_theme(), set_items(), set_index(), get_index(), +# set_placeholder_text(), set_enabled(), set_item_enabled(), +# set_min/max_width/height(), set_fixed_size(), set_tooltip(), +# set_size_policy(). +# Свойство: current_index_changed — сигнал currentIndexChanged. +# +# 4) Состояние (поля): +# _theme: str — текущая тема ("dark" | "light") +# _is_active: bool — признак активного состояния +# _base_style_key: str — базовый ключ стиля (по умолчанию "FORM_WIDGET") +# _style_key_normal: str|None — явный ключ нормального стиля +# _style_key_active: str|None — явный ключ активного стиля +# _combo: QComboBox — внутренний виджет +# +# 5) Последовательность действий и вызовов: +# __init__(style="FORM_WIDGET", ...) +# -> super().__init__(...) +# -> QComboBox() -> setSizePolicy -> super().add_widget(_combo) +# -> style() — первичное применение +# -> theme_bus.theme_changed.connect(set_theme) +# style(style_key?, active_key?, is_active?) +# -> определяет ключ через комбинацию base_key + тема + active +# -> _combo.setStyleSheet(APP_STYLES[key]) +# set_items(items) +# -> _combo.clear() -> _combo.addItems(items) +# +# 6) Побочные эффекты: +# - Устанавливает stylesheet на внутренний QComboBox. +# - Подключается к theme_bus.theme_changed при создании. +# +# 7) Границы ответственности: +# Модуль НЕ хранит бизнес-данные выбранного элемента. +# НЕ валидирует содержимое списка. +# add_widget() заблокирован — компонент запечатан. +# +# 8) Обработка ошибок: +# add_widget() бросает NotImplementedError. +# set_theme() молча игнорирует невалидные значения. +# set_item_enabled() безопасно пропускает отсутствующий model/item. +# +# 9) Инварианты и контракты: +# - Контейнер содержит ровно один QComboBox. +# - _theme ∈ {"dark", "light"}. +# - Стиль разрешается по цепочке: явный ключ → base_key + суффикс темы. +# +# 10) Правило сопровождения: +# Новые стили — добавлять в APP_STYLES с суффиксами _DARK/_LIGHT/_DARK_ACTIVE/_LIGHT_ACTIVE. +# Делегирующие методы дублировать на _combo и super(). diff --git a/Dispatch_V0.1.1/gui/components/coordinate_input.py b/Dispatch_V0.1.1/gui/components/coordinate_input.py new file mode 100644 index 0000000..994881d --- /dev/null +++ b/Dispatch_V0.1.1/gui/components/coordinate_input.py @@ -0,0 +1,248 @@ +# -*- coding: utf-8 -*- +# gui/components/coordinate_input.py +"""Виджет для ввода координат""" + +from PySide6.QtWidgets import QDoubleSpinBox, QSizePolicy +from PySide6.QtCore import Qt, Slot + +from gui.containers.s_container import SContainer +from gui.styles import APP_STYLES +from gui.theme_bus import theme_bus +from error_logger import log_exception + + +class CoordinateInput(SContainer): + """Виджет для ввода координат с валидацией""" + + def __init__( + self, + min_value: float = 0.0, + max_value: float = 100000.0, + decimals: int = 6, + step: float = 0.000001, + min_width: int = 150, + alignment: Qt.Alignment = Qt.AlignCenter, + parent=None, + style: str = "COORDINATE_INPUT", + active_style: str | None = None, + is_active: bool | None = None, + ): + super().__init__( + width_percent=None, + height_percent=None, + margin=0, + style=style, + active_style=active_style, + is_active=is_active, + parent=parent, + ) + + self._theme = "dark" + self._is_active = False + self._base_style_key = style + self._style_key_normal = None + self._style_key_active = None + + self._input = QDoubleSpinBox() + self._input.setRange(min_value, max_value) + self._input.setDecimals(decimals) + self._input.setSingleStep(step) + self._input.setMinimumWidth(min_width) + self._input.setAlignment(alignment) + self._input.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) + super().add_widget(self._input) + + if active_style is not None: + self._style_key_normal = style + self._style_key_active = active_style + if is_active is not None: + self._is_active = bool(is_active) + + self.style() + theme_bus.theme_changed.connect(self.set_theme) + + def style( + self, + style_key: str | None = None, + active_key: str | None = None, + is_active: bool | None = None, + ) -> None: + """Короткий метод применения стиля. Можно задать ключи и активность явно.""" + if style_key is not None: + self._base_style_key = style_key + if active_key is not None: + self._style_key_normal = style_key + self._style_key_active = active_key + else: + self._style_key_normal = None + self._style_key_active = None + + if is_active is not None: + self._is_active = bool(is_active) + + if self._style_key_normal is not None: + active_key = self._style_key_active or self._style_key_normal + key = active_key if self._is_active else self._style_key_normal + themed = f"{key}_{self._theme.upper()}" + if themed in APP_STYLES: + key = themed + self._input.setStyleSheet(APP_STYLES.get(key, "")) + return + + base_key = self._base_style_key + key = base_key + + if self._theme == "light": + if self._is_active and f"{base_key}_LIGHT_ACTIVE" in APP_STYLES: + key = f"{base_key}_LIGHT_ACTIVE" + elif f"{base_key}_LIGHT" in APP_STYLES: + key = f"{base_key}_LIGHT" + else: + if self._is_active and f"{base_key}_DARK_ACTIVE" in APP_STYLES: + key = f"{base_key}_DARK_ACTIVE" + elif f"{base_key}_DARK" in APP_STYLES: + key = f"{base_key}_DARK" + + self._input.setStyleSheet(APP_STYLES.get(key, "")) + + @Slot(str) + def set_theme(self, theme: str) -> None: + """Внешний слот: принимает 'dark' или 'light'.""" + theme = (theme or "").strip().lower() + if theme not in ("dark", "light"): + return + if self._theme == theme: + return + self._theme = theme + self.style() + + def set_prefix(self, prefix: str): + """Установка префикса""" + self._input.setPrefix(prefix) + + def set_range(self, min_val, max_val): + """Установка диапазона""" + self._input.setRange(min_val, max_val) + + def set_decimals(self, decimals: int): + """Установка количества десятичных знаков""" + self._input.setDecimals(decimals) + + def set_step(self, step: float) -> None: + """Установка шага""" + self._input.setSingleStep(step) + + def set_value(self, value): + """Безопасная установка значения""" + try: + self._input.setValue(float(value)) + except (ValueError, TypeError) as _exc: + log_exception(__name__, "set_value", _exc) + def get_value(self): + return self._input.value() + + @property + def valueChanged(self): + """Предоставить сигнал valueChanged из внутреннего QDoubleSpinBox.""" + return self._input.valueChanged + + def set_enabled(self, enabled: bool) -> None: + self._input.setEnabled(enabled) + super().setEnabled(enabled) + + def set_min_width(self, width: int) -> None: + self._input.setMinimumWidth(width) + + def set_min_height(self, height: int) -> None: + self._input.setMinimumHeight(height) + + def set_max_width(self, width: int) -> None: + self._input.setMaximumWidth(width) + + def set_max_height(self, height: int) -> None: + self._input.setMaximumHeight(height) + + def set_fixed_size(self, width: int, height: int) -> None: + self._input.setMinimumSize(width, height) + self._input.setMaximumSize(width, height) + + def set_tooltip(self, text: str) -> None: + self._input.setToolTip(text) + + def set_size_policy(self, horizontal, vertical) -> None: + self._input.setSizePolicy(horizontal, vertical) + super().setSizePolicy(horizontal, vertical) + + def add_widget(self, widget, alignment=None): + raise NotImplementedError("CoordinateInput can contain only one QDoubleSpinBox") + + + + +# --------------------------------------------------------------------------- +# Module workflow notes +# --------------------------------------------------------------------------- +# +# 1) Назначение модуля: +# Виджет для ввода координат (широта, долгота и т.д.) на базе +# QDoubleSpinBox внутри SContainer, с поддержкой валидации +# диапазона, настраиваемой точностью и стилями APP_STYLES. +# +# 2) Зависимости модуля: +# Импорты: QDoubleSpinBox, QSizePolicy (PySide6.QtWidgets), +# Qt, Slot (PySide6.QtCore), +# SContainer (gui.containers.s_container), +# APP_STYLES (gui.styles), +# theme_bus (gui.theme_bus) +# Хост-класс / базовый класс: SContainer +# Внешние библиотеки: PySide6 (обязательна) +# +# 3) Экспорт: +# Класс CoordinateInput — публичный виджет ввода координат. +# Методы: style(), set_theme(), set_prefix(), set_range(), +# set_decimals(), set_step(), set_value(), get_value(), +# set_enabled(), set_min/max_width/height(), set_fixed_size(), +# set_tooltip(), set_size_policy(). +# Свойство: valueChanged — сигнал QDoubleSpinBox.valueChanged. +# +# 4) Состояние (поля): +# _theme: str — текущая тема +# _is_active: bool — признак активного состояния +# _base_style_key: str — базовый ключ стиля ("COORDINATE_INPUT") +# _style_key_normal: str|None — явный ключ нормального стиля +# _style_key_active: str|None — явный ключ активного стиля +# _input: QDoubleSpinBox — внутренний виджет ввода +# +# 5) Последовательность действий и вызовов: +# __init__(min_value, max_value, decimals, step, min_width, alignment, ...) +# -> super().__init__(...) +# -> QDoubleSpinBox() с setRange, setDecimals, setSingleStep, setMinimumWidth +# -> super().add_widget(_input) +# -> style() -> theme_bus.theme_changed.connect(set_theme) +# set_value(value) +# -> try float(value) -> _input.setValue() +# -> except: pass (тихое игнорирование) +# valueChanged (property) +# -> делегирует к _input.valueChanged +# +# 6) Побочные эффекты: +# - Устанавливает stylesheet на QDoubleSpinBox. +# - Подключается к theme_bus.theme_changed. +# +# 7) Границы ответственности: +# Модуль НЕ интерпретирует значения координат семантически. +# НЕ выполняет геокодирование. add_widget() заблокирован. +# +# 8) Обработка ошибок: +# set_value() глотает ValueError/TypeError при некорректном вводе. +# add_widget() бросает NotImplementedError. +# set_theme() молча игнорирует невалидные темы. +# +# 9) Инварианты и контракты: +# - Контейнер содержит ровно один QDoubleSpinBox. +# - Значение всегда в пределах [min_value, max_value]. +# - decimals определяет точность отображения. +# +# 10) Правило сопровождения: +# При добавлении суффикса/префикса — использовать set_prefix(). +# Стили — через APP_STYLES с ключом COORDINATE_INPUT + суффиксы. diff --git a/Dispatch_V0.1.1/gui/components/dialog.py b/Dispatch_V0.1.1/gui/components/dialog.py new file mode 100644 index 0000000..531a1fd --- /dev/null +++ b/Dispatch_V0.1.1/gui/components/dialog.py @@ -0,0 +1,102 @@ +# -*- coding: utf-8 -*- +# gui/components/dialog.py + +"""Каноническая dialog-обёртка проекта.""" + +from __future__ import annotations + +from PySide6.QtCore import Slot +from PySide6.QtWidgets import QDialog, QVBoxLayout + +from gui.containers import VContainer +from gui.containers._widget_style_service import WidgetStyleService +from gui.theme_bus import theme_bus + + +class Dialog(QDialog): + """Базовая stylable-обёртка над QDialog с корневым VContainer.""" + + def __init__( + self, + title: str = "", + width: int | None = None, + height: int | None = None, + modal: bool = True, + content_margin: int | tuple[int, int, int, int] = 0, + content_spacing: int = 0, + style: str = "DIALOG", + parent=None, + ): + super().__init__(parent) + + # Зависимости + self._style_service = WidgetStyleService(self) + + # Ссылки на ключевые виджеты + self._content_container: VContainer | None = None + + # Визуальная настройка + self.setModal(bool(modal)) + if title: + self.setWindowTitle(title) + if width is not None and height is not None: + self.resize(width, height) + + # Сборка и подключение + self._build_root(content_margin, content_spacing) + self._connect_base_signals() + + # Первичная синхронизация стиля + self.style(style) + + # ── Сборка интерфейса ──────────────────────────────────────── + + def _build_root( + self, + content_margin: int | tuple[int, int, int, int], + content_spacing: int, + ) -> None: + self._content_container = VContainer( + margin=content_margin, + spacing=content_spacing, + ) + + root_layout = QVBoxLayout(self) + root_layout.setContentsMargins(0, 0, 0, 0) + root_layout.setSpacing(0) + root_layout.addWidget(self._content_container) + + # ── Подключение сигналов ───────────────────────────────────── + + def _connect_base_signals(self) -> None: + theme_bus.theme_changed.connect(self.set_theme) + + # ── Публичный API ──────────────────────────────────────────── + + @property + def content_container(self) -> VContainer: + return self._content_container + + def add_widget(self, widget) -> None: + self._content_container.add_widget(widget) + + def add_stretch(self, stretch: int = 1) -> None: + self._content_container.add_stretch(stretch) + + def style( + self, + style_key: str | None = None, + active_key: str | None = None, + is_active: bool | None = None, + ) -> None: + self._style_service.apply( + style_key=style_key, + active_key=active_key, + is_active=is_active, + ) + + # ── Обработчики событий ────────────────────────────────────── + + @Slot(str) + def set_theme(self, theme: str) -> None: + self._style_service.handle_theme_changed(theme) diff --git a/Dispatch_V0.1.1/gui/components/double_spin_box.py b/Dispatch_V0.1.1/gui/components/double_spin_box.py new file mode 100644 index 0000000..59c31fb --- /dev/null +++ b/Dispatch_V0.1.1/gui/components/double_spin_box.py @@ -0,0 +1,279 @@ +# -*- coding: utf-8 -*- +# gui/components/double_spin_box.py +"""Обёртка над QDoubleSpinBox с поддержкой централизованных APP_STYLES.""" + +from PySide6.QtWidgets import QDoubleSpinBox, QSizePolicy +from PySide6.QtCore import Qt, Slot, Signal + +from gui.containers.s_container import SContainer +from gui.styles import APP_STYLES +from gui.theme_bus import theme_bus +from error_logger import log_exception + + +class DoubleSpinBox(SContainer): + """Кастомный QDoubleSpinBox с переключением стилей по теме.""" + + stepped = Signal() + + class _StepAwareSpinBox(QDoubleSpinBox): + def __init__(self, on_step, parent=None): + super().__init__(parent) + self._on_step = on_step + + def stepBy(self, steps: int) -> None: # noqa: N802 (Qt API) + super().stepBy(steps) + if steps and callable(self._on_step): + self._on_step() + + def __init__( + self, + min_value: float = 0.0, + max_value: float = 100000.0, + decimals: int = 0, + step: float = 1.0, + suffix: str = " мм", + keyboard_tracking: bool = True, + width_percent: int | None = None, + height_percent: int | None = None, + margin: int | tuple[int, int, int, int] = 0, + style: str = "COORDINATE_INPUT", + active_style: str | None = None, + is_active: bool | None = None, + parent=None, + ): + super().__init__( + width_percent=width_percent, + height_percent=height_percent, + margin=margin, + + style=style, + active_style=active_style, + is_active=is_active, + parent=parent, + ) + + self._theme = "dark" + self._is_active = False + self._base_style_key = style + self._style_key_normal = None + self._style_key_active = None + + self._input = self._StepAwareSpinBox(self._emit_stepped) + self._input.setRange(min_value, max_value) + self._input.setDecimals(decimals) + self._input.setSingleStep(step) + self._input.setSuffix(suffix) + self._input.setAlignment(Qt.AlignCenter) + self._input.setKeyboardTracking(keyboard_tracking) + self._input.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) + super().add_widget(self._input) + + if active_style is not None: + self._style_key_normal = style + self._style_key_active = active_style + if is_active is not None: + self._is_active = bool(is_active) + + self.style() + theme_bus.theme_changed.connect(self.set_theme) + + def style( + self, + style_key: str | None = None, + active_key: str | None = None, + is_active: bool | None = None, + ) -> None: + """Применить стиль по базовым/активным ключам с учётом текущей темы.""" + if style_key is not None: + self._base_style_key = style_key + if active_key is not None: + self._style_key_normal = style_key + self._style_key_active = active_key + else: + self._style_key_normal = None + self._style_key_active = None + + if is_active is not None: + self._is_active = bool(is_active) + + if self._style_key_normal is not None: + active_key = self._style_key_active or self._style_key_normal + key = active_key if self._is_active else self._style_key_normal + themed = f"{key}_{self._theme.upper()}" + if themed in APP_STYLES: + key = themed + self._input.setStyleSheet(APP_STYLES.get(key, "")) + return + + base_key = self._base_style_key + key = base_key + if self._theme == "light": + if self._is_active and f"{base_key}_LIGHT_ACTIVE" in APP_STYLES: + key = f"{base_key}_LIGHT_ACTIVE" + elif f"{base_key}_LIGHT" in APP_STYLES: + key = f"{base_key}_LIGHT" + else: + if self._is_active and f"{base_key}_DARK_ACTIVE" in APP_STYLES: + key = f"{base_key}_DARK_ACTIVE" + elif f"{base_key}_DARK" in APP_STYLES: + key = f"{base_key}_DARK" + self._input.setStyleSheet(APP_STYLES.get(key, "")) + + @Slot(str) + 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 + self.style() + + def set_value(self, value: float) -> None: + self._input.setValue(value) + + def get_value(self) -> float: + return self._input.value() + + def get_min_value(self) -> float: + return float(self._input.minimum()) + + def get_max_value(self) -> float: + return float(self._input.maximum()) + + @property + def valueChanged(self): + return self._input.valueChanged + + @property + def editing_finished(self): + return self._input.editingFinished + + @property + def return_pressed(self): + line_edit = self._input.lineEdit() + if line_edit is None: + return self._input.editingFinished + return line_edit.returnPressed + + def set_enabled(self, enabled: bool) -> None: + self._input.setEnabled(enabled) + super().setEnabled(enabled) + + def set_min_width(self, width: int) -> None: + self._input.setMinimumWidth(width) + super().setMinimumWidth(width) + + def set_min_height(self, height: int) -> None: + self._input.setMinimumHeight(height) + super().setMinimumHeight(height) + + def set_max_width(self, width: int) -> None: + self._input.setMaximumWidth(width) + super().setMaximumWidth(width) + + def set_max_height(self, height: int) -> None: + self._input.setMaximumHeight(height) + super().setMaximumHeight(height) + + def set_fixed_size(self, width: int, height: int) -> None: + self._input.setMinimumSize(width, height) + self._input.setMaximumSize(width, height) + super().setMinimumSize(width, height) + super().setMaximumSize(width, height) + + def set_tooltip(self, text: str) -> None: + self._input.setToolTip(text) + + def set_size_policy(self, horizontal, vertical) -> None: + self._input.setSizePolicy(horizontal, vertical) + super().setSizePolicy(horizontal, vertical) + + def set_range(self, min_value: float, max_value: float) -> None: + self._input.setRange(min_value, max_value) + + def set_step(self, step: float) -> None: + self._input.setSingleStep(step) + + def commit_pending_input(self) -> None: + """Принудительно применить текст из редактора spinbox к текущему value.""" + try: + self._input.interpretText() + except Exception as e: + log_exception(__name__, "commit_pending_input", e) + + def _emit_stepped(self) -> None: + self.stepped.emit() + + def add_widget(self, widget, alignment=None): + raise NotImplementedError("DoubleSpinBox can contain only one QDoubleSpinBox") + + +# --------------------------------------------------------------------------- +# Module workflow notes +# --------------------------------------------------------------------------- +# +# 1) Назначение модуля: +# Обёртка над QDoubleSpinBox в SContainer для ввода числовых значений +# (размеры в мм и т.п.) с суффиксом, шагом, поддержкой APP_STYLES и +# автоматической сменой темы. +# +# 2) Зависимости модуля: +# Импорты: QDoubleSpinBox, QSizePolicy (PySide6.QtWidgets), +# Qt, Slot (PySide6.QtCore), +# SContainer (gui.containers.s_container), +# APP_STYLES (gui.styles), +# theme_bus (gui.theme_bus) +# Хост-класс / базовый класс: SContainer +# Внешние библиотеки: PySide6 (обязательна) +# +# 3) Экспорт: +# Класс DoubleSpinBox — публичный виджет числового ввода. +# Методы: style(), set_theme(), set_value(), get_value(), +# get_min_value(), get_max_value(), set_range(), set_step(), +# set_enabled(), set_min/max_width/height(), set_fixed_size(), +# set_tooltip(), set_size_policy(). +# Свойства: valueChanged, editing_finished. +# +# 4) Состояние (поля): +# _theme: str — текущая тема +# _is_active: bool — признак активного состояния +# _base_style_key: str — базовый ключ стиля ("COORDINATE_INPUT") +# _style_key_normal: str|None — явный нормальный стиль +# _style_key_active: str|None — явный активный стиль +# _input: QDoubleSpinBox — внутренний виджет +# +# 5) Последовательность действий и вызовов: +# __init__(min_value, max_value, decimals, step, suffix, keyboard_tracking, ...) +# -> super().__init__(...) +# -> QDoubleSpinBox() с setRange, setDecimals, setSingleStep, +# setSuffix, setAlignment(Center), setKeyboardTracking +# -> super().add_widget(_input) +# -> style() -> theme_bus.theme_changed.connect(set_theme) +# style(style_key?, active_key?, is_active?) +# -> разрешение ключа: явный → base_key + _DARK/_LIGHT + _ACTIVE +# -> _input.setStyleSheet(APP_STYLES[key]) +# +# 6) Побочные эффекты: +# - Устанавливает stylesheet на QDoubleSpinBox. +# - Подключается к theme_bus.theme_changed. +# +# 7) Границы ответственности: +# Модуль НЕ валидирует единицы измерения. НЕ конвертирует значения. +# add_widget() заблокирован. +# +# 8) Обработка ошибок: +# add_widget() бросает NotImplementedError. set_theme() игнорирует +# невалидные значения. +# +# 9) Инварианты и контракты: +# - Контейнер содержит ровно один QDoubleSpinBox. +# - Значение в пределах [min_value, max_value]. +# - keyboard_tracking определяет, испускается ли valueChanged при каждом +# нажатии клавиши или только по завершении ввода. +# +# 10) Правило сопровождения: +# Отличие от CoordinateInput: суффикс, keyboard_tracking, +# decimals=0 по умолчанию. Не дублировать этот класс для целочисленного ввода — +# достаточно decimals=0. diff --git a/Dispatch_V0.1.1/gui/components/group_box.py b/Dispatch_V0.1.1/gui/components/group_box.py new file mode 100644 index 0000000..3265bbd --- /dev/null +++ b/Dispatch_V0.1.1/gui/components/group_box.py @@ -0,0 +1,240 @@ +# -*- coding: utf-8 -*- +# gui/components/group_box.py +"""Обёртка над QGroupBox с контейнером SContainer.""" + +from __future__ import annotations + +from PySide6.QtCore import Slot, Qt +from PySide6.QtWidgets import QGroupBox, QVBoxLayout, QWidget, QSizePolicy + +from gui.containers.s_container import SContainer +from gui.styles import APP_STYLES +from gui.theme_bus import theme_bus + + +class GroupBox(SContainer): + """QGroupBox с централизованным стилем и внутренним layout по умолчанию.""" + + _ALIGN_MAP = { + "top": Qt.AlignmentFlag.AlignTop, + "bottom": Qt.AlignmentFlag.AlignBottom, + "left": Qt.AlignmentFlag.AlignLeft, + "right": Qt.AlignmentFlag.AlignRight, + "hcenter": Qt.AlignmentFlag.AlignHCenter, + "vcenter": Qt.AlignmentFlag.AlignVCenter, + "center": Qt.AlignmentFlag.AlignCenter, + } + + def __init__( + self, + title: str = "", + width_percent: int | None = None, + height_percent: int | None = None, + margin: int | tuple[int, int, int, int] = 0, + content_margins: int | tuple[int, int, int, int] = 10, + spacing: int = 8, + alignment=None, + style: str = "GROUP_BOX", + active_style: str | None = None, + is_active: bool | None = None, + content_fit: bool = True, + parent=None, + ): + super().__init__( + width_percent=width_percent, + height_percent=height_percent, + margin=margin, + style=style, + active_style=active_style, + is_active=is_active, + content_fit=content_fit, + parent=parent, + ) + + self._theme = "dark" + self._is_active = False + self._base_style_key = style + self._style_key_normal = None + self._style_key_active = None + + self._group_box = QGroupBox(title) + self._group_box.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Minimum) + self._group_layout = QVBoxLayout() + if isinstance(content_margins, (list, tuple)) and len(content_margins) == 4: + self._group_layout.setContentsMargins(*content_margins) + else: + self._group_layout.setContentsMargins(content_margins, content_margins, content_margins, content_margins) + self._group_layout.setSpacing(spacing) + if alignment is not None: + self._group_layout.setAlignment(self._normalize_alignment(alignment)) + self._group_box.setLayout(self._group_layout) + + super().add_widget(self._group_box) + + if active_style is not None: + self._style_key_normal = style + self._style_key_active = active_style + if is_active is not None: + self._is_active = bool(is_active) + + self.style() + theme_bus.theme_changed.connect(self.set_theme) + + def style( + self, + style_key: str | None = None, + active_key: str | None = None, + is_active: bool | None = None, + ) -> None: + """Короткий метод применения стиля. Можно задать ключи и активность явно.""" + if style_key is not None: + self._base_style_key = style_key + if active_key is not None: + self._style_key_normal = style_key + self._style_key_active = active_key + else: + self._style_key_normal = None + self._style_key_active = None + + if is_active is not None: + self._is_active = bool(is_active) + + if self._style_key_normal is not None: + active_key = self._style_key_active or self._style_key_normal + key = active_key if self._is_active else self._style_key_normal + themed = f"{key}_{self._theme.upper()}" + if themed in APP_STYLES: + key = themed + self._group_box.setStyleSheet(APP_STYLES.get(key, "")) + return + + base_key = self._base_style_key + key = base_key + + if self._theme == "light": + if self._is_active and f"{base_key}_LIGHT_ACTIVE" in APP_STYLES: + key = f"{base_key}_LIGHT_ACTIVE" + elif f"{base_key}_LIGHT" in APP_STYLES: + key = f"{base_key}_LIGHT" + else: + if self._is_active and f"{base_key}_DARK_ACTIVE" in APP_STYLES: + key = f"{base_key}_DARK_ACTIVE" + elif f"{base_key}_DARK" in APP_STYLES: + key = f"{base_key}_DARK" + + self._group_box.setStyleSheet(APP_STYLES.get(key, "")) + + @Slot(str) + def set_theme(self, theme: str) -> None: + """Внешний слот: принимает 'dark' или 'light'.""" + theme = (theme or "").strip().lower() + if theme not in ("dark", "light"): + return + if self._theme == theme: + return + self._theme = theme + self.style() + + def set_title(self, title: str) -> None: + self._group_box.setTitle(title) + + def get_title(self) -> str: + return self._group_box.title() + + def add_widget(self, widget: QWidget) -> None: + self._group_layout.addWidget(widget) + + def add_stretch(self, stretch: int = 1) -> None: + self._group_layout.addStretch(stretch) + + def set_margins(self, margin: int | tuple[int, int, int, int]) -> None: + if isinstance(margin, (list, tuple)) and len(margin) == 4: + self._group_layout.setContentsMargins(*margin) + else: + self._group_layout.setContentsMargins(margin, margin, margin, margin) + + def set_spacing(self, spacing: int) -> None: + self._group_layout.setSpacing(spacing) + + def _normalize_alignment(self, alignment: str | Qt.Alignment) -> Qt.Alignment: + """Преобразует строковое значение alignment в Qt.Alignment.""" + if isinstance(alignment, str): + key = alignment.strip().lower() + mapped = self._ALIGN_MAP.get(key) + if mapped is None: + raise ValueError(f"Unknown alignment '{key}'. Allowed: {list(self._ALIGN_MAP.keys())}") + return mapped + return alignment + + def set_alignment(self, alignment: str | Qt.Alignment) -> None: + """Устанавливает выравнивание layout (строка или Qt.Alignment).""" + self._group_layout.setAlignment(self._normalize_alignment(alignment)) + + +# --------------------------------------------------------------------------- +# Module workflow notes +# --------------------------------------------------------------------------- +# +# 1) Назначение модуля: +# Обёртка над QGroupBox, встроенная в SContainer, с заголовком, +# внутренним QVBoxLayout для дочерних виджетов и поддержкой стилей +# APP_STYLES + theme_bus. +# +# 2) Зависимости модуля: +# Импорты: Slot, Qt (PySide6.QtCore), +# QGroupBox, QVBoxLayout, QWidget, QSizePolicy (PySide6.QtWidgets), +# SContainer (gui.containers.s_container), +# APP_STYLES (gui.styles), +# theme_bus (gui.theme_bus) +# Хост-класс / базовый класс: SContainer +# Внешние библиотеки: PySide6 (обязательна) +# +# 3) Экспорт: +# Класс GroupBox — публичный контейнерный виджет с заголовком. +# Методы: style(), set_theme(), set_title(), get_title(), +# add_widget(QWidget), add_stretch(), set_margins(), +# set_spacing(), set_alignment(). +# +# 4) Состояние (поля): +# _theme: str — текущая тема +# _is_active: bool — признак активного состояния +# _base_style_key: str — базовый ключ стиля ("GROUP_BOX") +# _style_key_normal: str|None — явный нормальный стиль +# _style_key_active: str|None — явный активный стиль +# _group_box: QGroupBox — внутренний QGroupBox +# _group_layout: QVBoxLayout — layout внутри QGroupBox +# _ALIGN_MAP: dict — маппинг строковых выравниваний в Qt.Alignment +# +# 5) Последовательность действий и вызовов: +# __init__(title, content_margins, spacing, alignment, ...) +# -> super().__init__(...) +# -> QGroupBox(title) -> QVBoxLayout -> setContentsMargins, setSpacing +# -> setAlignment(если передано) +# -> _group_box.setLayout(_group_layout) +# -> super().add_widget(_group_box) — добавление QGroupBox в SContainer +# -> style() -> theme_bus.theme_changed.connect(set_theme) +# add_widget(widget) — ПЕРЕОПРЕДЕЛЁН: +# -> _group_layout.addWidget(widget) (добавляет внутрь QGroupBox, не в SContainer) +# +# 6) Побочные эффекты: +# - Устанавливает stylesheet на QGroupBox. +# - Подключается к theme_bus.theme_changed. +# - add_widget() изменяет _group_layout (не SContainer layout). +# +# 7) Границы ответственности: +# Модуль — визуальная группировка. НЕ реализует вложенные стили +# для дочерних элементов. НЕ ограничивает типы дочерних виджетов. +# +# 8) Обработка ошибок: +# _normalize_alignment() бросает ValueError при невалидной строке +# выравнивания. +# +# 9) Инварианты и контракты: +# - _group_box всегда имеет QVBoxLayout. +# - Допустимые строки выравнивания: top, bottom, left, right, +# hcenter, vcenter, center. +# - add_widget() GroupBox — НЕ запечатан, принимает любые QWidget. +# +# 10) Правило сопровождения: +# Если нужна горизонтальная компоновка внутри GroupBox — +# вкладывать HContainer в add_widget(), не менять _group_layout на QHBoxLayout. diff --git a/Dispatch_V0.1.1/gui/components/icons/accept_wolume_black.png b/Dispatch_V0.1.1/gui/components/icons/accept_wolume_black.png new file mode 100644 index 0000000..4ab3357 Binary files /dev/null and b/Dispatch_V0.1.1/gui/components/icons/accept_wolume_black.png differ diff --git a/Dispatch_V0.1.1/gui/components/icons/accept_wolume_white.png b/Dispatch_V0.1.1/gui/components/icons/accept_wolume_white.png new file mode 100644 index 0000000..2ebed06 Binary files /dev/null and b/Dispatch_V0.1.1/gui/components/icons/accept_wolume_white.png differ diff --git a/Dispatch_V0.1.1/gui/components/icons/camera_fixation_black.png b/Dispatch_V0.1.1/gui/components/icons/camera_fixation_black.png new file mode 100644 index 0000000..41dd07b Binary files /dev/null and b/Dispatch_V0.1.1/gui/components/icons/camera_fixation_black.png differ diff --git a/Dispatch_V0.1.1/gui/components/icons/camera_fixation_white.png b/Dispatch_V0.1.1/gui/components/icons/camera_fixation_white.png new file mode 100644 index 0000000..0e39ade Binary files /dev/null and b/Dispatch_V0.1.1/gui/components/icons/camera_fixation_white.png differ diff --git a/Dispatch_V0.1.1/gui/components/icons/create_mesh_black.png b/Dispatch_V0.1.1/gui/components/icons/create_mesh_black.png new file mode 100644 index 0000000..e5597df Binary files /dev/null and b/Dispatch_V0.1.1/gui/components/icons/create_mesh_black.png differ diff --git a/Dispatch_V0.1.1/gui/components/icons/create_mesh_white.png b/Dispatch_V0.1.1/gui/components/icons/create_mesh_white.png new file mode 100644 index 0000000..2abfc1d Binary files /dev/null and b/Dispatch_V0.1.1/gui/components/icons/create_mesh_white.png differ diff --git a/Dispatch_V0.1.1/gui/components/icons/create_volume_black.png b/Dispatch_V0.1.1/gui/components/icons/create_volume_black.png new file mode 100644 index 0000000..e8b0c78 Binary files /dev/null and b/Dispatch_V0.1.1/gui/components/icons/create_volume_black.png differ diff --git a/Dispatch_V0.1.1/gui/components/icons/create_volume_white.png b/Dispatch_V0.1.1/gui/components/icons/create_volume_white.png new file mode 100644 index 0000000..187c628 Binary files /dev/null and b/Dispatch_V0.1.1/gui/components/icons/create_volume_white.png differ diff --git a/Dispatch_V0.1.1/gui/components/icons/create_zone_black.png b/Dispatch_V0.1.1/gui/components/icons/create_zone_black.png new file mode 100644 index 0000000..621d0c2 Binary files /dev/null and b/Dispatch_V0.1.1/gui/components/icons/create_zone_black.png differ diff --git a/Dispatch_V0.1.1/gui/components/icons/create_zone_white.png b/Dispatch_V0.1.1/gui/components/icons/create_zone_white.png new file mode 100644 index 0000000..e85a498 Binary files /dev/null and b/Dispatch_V0.1.1/gui/components/icons/create_zone_white.png differ diff --git a/Dispatch_V0.1.1/gui/components/icons/delete_mesh_black.png b/Dispatch_V0.1.1/gui/components/icons/delete_mesh_black.png new file mode 100644 index 0000000..4fac99f Binary files /dev/null and b/Dispatch_V0.1.1/gui/components/icons/delete_mesh_black.png differ diff --git a/Dispatch_V0.1.1/gui/components/icons/delete_mesh_white.png b/Dispatch_V0.1.1/gui/components/icons/delete_mesh_white.png new file mode 100644 index 0000000..658837a Binary files /dev/null and b/Dispatch_V0.1.1/gui/components/icons/delete_mesh_white.png differ diff --git a/Dispatch_V0.1.1/gui/components/icons/delete_zone_black.png b/Dispatch_V0.1.1/gui/components/icons/delete_zone_black.png new file mode 100644 index 0000000..0d30fb0 Binary files /dev/null and b/Dispatch_V0.1.1/gui/components/icons/delete_zone_black.png differ diff --git a/Dispatch_V0.1.1/gui/components/icons/delete_zone_white.png b/Dispatch_V0.1.1/gui/components/icons/delete_zone_white.png new file mode 100644 index 0000000..f0772b9 Binary files /dev/null and b/Dispatch_V0.1.1/gui/components/icons/delete_zone_white.png differ diff --git a/Dispatch_V0.1.1/gui/components/icons/edit_black.png b/Dispatch_V0.1.1/gui/components/icons/edit_black.png new file mode 100644 index 0000000..53c3cf6 Binary files /dev/null and b/Dispatch_V0.1.1/gui/components/icons/edit_black.png differ diff --git a/Dispatch_V0.1.1/gui/components/icons/edit_white.png b/Dispatch_V0.1.1/gui/components/icons/edit_white.png new file mode 100644 index 0000000..2963cc1 Binary files /dev/null and b/Dispatch_V0.1.1/gui/components/icons/edit_white.png differ diff --git a/Dispatch_V0.1.1/gui/components/icons/logo_usms.png b/Dispatch_V0.1.1/gui/components/icons/logo_usms.png new file mode 100644 index 0000000..76a3022 Binary files /dev/null and b/Dispatch_V0.1.1/gui/components/icons/logo_usms.png differ diff --git a/Dispatch_V0.1.1/gui/components/icons/measure_black.png b/Dispatch_V0.1.1/gui/components/icons/measure_black.png new file mode 100644 index 0000000..15c53c2 Binary files /dev/null and b/Dispatch_V0.1.1/gui/components/icons/measure_black.png differ diff --git a/Dispatch_V0.1.1/gui/components/icons/measure_white.png b/Dispatch_V0.1.1/gui/components/icons/measure_white.png new file mode 100644 index 0000000..86ac869 Binary files /dev/null and b/Dispatch_V0.1.1/gui/components/icons/measure_white.png differ diff --git a/Dispatch_V0.1.1/gui/components/icons/remember_point_black.png b/Dispatch_V0.1.1/gui/components/icons/remember_point_black.png new file mode 100644 index 0000000..7686d72 Binary files /dev/null and b/Dispatch_V0.1.1/gui/components/icons/remember_point_black.png differ diff --git a/Dispatch_V0.1.1/gui/components/icons/remember_point_white.png b/Dispatch_V0.1.1/gui/components/icons/remember_point_white.png new file mode 100644 index 0000000..c759cb2 Binary files /dev/null and b/Dispatch_V0.1.1/gui/components/icons/remember_point_white.png differ diff --git a/Dispatch_V0.1.1/gui/components/icons/select_zone_black.png b/Dispatch_V0.1.1/gui/components/icons/select_zone_black.png new file mode 100644 index 0000000..199490d Binary files /dev/null and b/Dispatch_V0.1.1/gui/components/icons/select_zone_black.png differ diff --git a/Dispatch_V0.1.1/gui/components/icons/select_zone_white.png b/Dispatch_V0.1.1/gui/components/icons/select_zone_white.png new file mode 100644 index 0000000..cc23236 Binary files /dev/null and b/Dispatch_V0.1.1/gui/components/icons/select_zone_white.png differ diff --git a/Dispatch_V0.1.1/gui/components/icons/set_step_black.png b/Dispatch_V0.1.1/gui/components/icons/set_step_black.png new file mode 100644 index 0000000..3035fff Binary files /dev/null and b/Dispatch_V0.1.1/gui/components/icons/set_step_black.png differ diff --git a/Dispatch_V0.1.1/gui/components/icons/set_step_white.png b/Dispatch_V0.1.1/gui/components/icons/set_step_white.png new file mode 100644 index 0000000..ef8bfc3 Binary files /dev/null and b/Dispatch_V0.1.1/gui/components/icons/set_step_white.png differ diff --git a/Dispatch_V0.1.1/gui/components/icons/start_point_black.png b/Dispatch_V0.1.1/gui/components/icons/start_point_black.png new file mode 100644 index 0000000..9319cf9 Binary files /dev/null and b/Dispatch_V0.1.1/gui/components/icons/start_point_black.png differ diff --git a/Dispatch_V0.1.1/gui/components/icons/start_point_white.png b/Dispatch_V0.1.1/gui/components/icons/start_point_white.png new file mode 100644 index 0000000..c51f034 Binary files /dev/null and b/Dispatch_V0.1.1/gui/components/icons/start_point_white.png differ diff --git a/Dispatch_V0.1.1/gui/components/icons/use_current_mesh_black.png b/Dispatch_V0.1.1/gui/components/icons/use_current_mesh_black.png new file mode 100644 index 0000000..d4a1a7d Binary files /dev/null and b/Dispatch_V0.1.1/gui/components/icons/use_current_mesh_black.png differ diff --git a/Dispatch_V0.1.1/gui/components/icons/use_current_mesh_white.png b/Dispatch_V0.1.1/gui/components/icons/use_current_mesh_white.png new file mode 100644 index 0000000..3a05a8c Binary files /dev/null and b/Dispatch_V0.1.1/gui/components/icons/use_current_mesh_white.png differ diff --git a/Dispatch_V0.1.1/gui/components/kanban_board.py b/Dispatch_V0.1.1/gui/components/kanban_board.py new file mode 100644 index 0000000..56da992 --- /dev/null +++ b/Dispatch_V0.1.1/gui/components/kanban_board.py @@ -0,0 +1,96 @@ +# -*- coding: utf-8 -*- +# gui/components/kanban_board.py + +"""Переиспользуемый компонент канбан-доски.""" + +from typing import Dict, List, Optional + +from PySide6.QtCore import Signal +from PySide6.QtWidgets import QWidget + +from gui.components.kanban_card import KanbanCard +from gui.components.kanban_column import KanbanColumn +from gui.containers.h_container import HContainer +from gui.containers.s_container import SContainer + + +class KanbanBoard(SContainer): + """Канбан-доска из нескольких горизонтальных колонок.""" + + card_clicked = Signal(object) + + def __init__( + self, + width_percent: Optional[int] = None, + height_percent: Optional[int] = None, + margin: int = 0, + spacing: int = 0, + parent: Optional[QWidget] = None, + ): + super().__init__( + width_percent=width_percent, + height_percent=height_percent, + margin=margin, + spacing=spacing, + content_fit=True, + parent=parent, + ) + self._columns: Dict[object, KanbanColumn] = {} + self._columns_order: List[object] = [] + self._columns_container = HContainer( + spacing=4, + margin=[4, 4, 4, 4], + parent=self, + ) + + def add_column( + self, + column_id: object, + title: str, + color: str = "#DFE1E6", + width_percent: Optional[int] = None, + style: str = "KANBAN_COLUMN", + ) -> KanbanColumn: + """Добавить колонку на доску.""" + column = KanbanColumn( + column_id=column_id, + title=title, + color=color, + style=style, + ) + column.card_clicked.connect(self.card_clicked.emit) + self._columns[column_id] = column + self._columns_order.append(column_id) + self._columns_container.add_widget(column) + self._recompute_column_widths(width_percent) + return column + + def get_column(self, column_id: object) -> Optional[KanbanColumn]: + """Получить колонку по ID.""" + return self._columns.get(column_id) + + def get_columns(self) -> List[KanbanColumn]: + """Список всех колонок в порядке добавления.""" + return [self._columns[column_id] for column_id in self._columns_order] + + def clear_all_cards(self) -> None: + """Удалить все карточки из всех колонок.""" + for column in self._columns.values(): + column.clear_cards() + + def _recompute_column_widths(self, explicit_width: Optional[int] = None) -> None: + n_columns = len(self._columns_order) + if n_columns == 0: + return + if explicit_width is not None: + for column_id in self._columns_order: + self._columns[column_id].set_percent_sizes(width_percent=explicit_width) + return + base_width = 100 // n_columns + remainder = 100 % n_columns + for index, column_id in enumerate(self._columns_order): + width = base_width + (1 if index < remainder else 0) + self._columns[column_id].set_percent_sizes(width_percent=width) + + +__all__ = ["KanbanBoard", "KanbanColumn", "KanbanCard"] diff --git a/Dispatch_V0.1.1/gui/components/kanban_card.py b/Dispatch_V0.1.1/gui/components/kanban_card.py new file mode 100644 index 0000000..277bf68 --- /dev/null +++ b/Dispatch_V0.1.1/gui/components/kanban_card.py @@ -0,0 +1,99 @@ +# -*- coding: utf-8 -*- +# gui/components/kanban_card.py + +"""Переиспользуемая карточка для канбан-доски.""" + +from typing import Optional + +from PySide6.QtCore import Qt, Signal +from PySide6.QtWidgets import QWidget, QSizePolicy + +from gui.containers.h_container import HContainer +from gui.containers.s_container import SContainer +from gui.containers.v_container import VContainer +from gui.components.label import Label + + +class KanbanCard(SContainer): + """Одна карточка на канбан-доске.""" + + card_clicked = Signal(object) + + def __init__( + self, + card_id: object, + title: str = "", + subtitle: str = "", + status_color: str = "#DFE1E6", + style: str = "TASK_CARD", + parent: Optional[QWidget] = None, + ): + super().__init__( + width_percent=100, + margin=[6, 4, 6, 4], + style=style, + content_fit=True, + parent=parent, + ) + self._card_id = card_id + self._status_color = status_color + self._build_ui(title, subtitle, status_color) + + @property + def card_id(self) -> object: + """Идентификатор карточки.""" + return self._card_id + + def set_title(self, text: str) -> None: + """Установить текст заголовка.""" + self._title_label.set_text(text) + + def set_subtitle(self, text: str) -> None: + """Установить текст подзаголовка.""" + self._subtitle_label.set_text(text) + + def set_status_color(self, color: str) -> None: + """Установить цвет полосы состояния.""" + self._status_color = color + self._status_strip.setStyleSheet( + f"background-color: {color}; border: none; border-radius: 2px;" + ) + + def set_badge_text(self, text: str) -> None: + """Установить текст бейджа состояния.""" + self._badge_label.set_text(text) + self._badge_label.setVisible(bool(text)) + + def set_badge_style(self, style_key: str) -> None: + """Установить стиль бейджа (ключ APP_STYLES).""" + self._badge_label.style(style_key=style_key) + + def _build_ui(self, title: str, subtitle: str, status_color: str) -> None: + row = HContainer(parent=self) + + self._status_strip = QWidget() + self._status_strip.setFixedWidth(4) + self._status_strip.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Expanding) + self._status_strip.setStyleSheet( + f"background-color: {status_color}; border: none; border-radius: 2px;" + ) + row.add_widget(self._status_strip) + + content = VContainer(margin=[6, 4, 4, 4], spacing=2, parent=row) + + self._badge_label = Label("", style="TASK_BADGE") + self._badge_label.setVisible(False) + self._badge_label.set_max_height(20) + content.add_widget(self._badge_label) + + self._title_label = Label(title, style="TASK_CARD_TITLE") + content.add_widget(self._title_label) + + self._subtitle_label = Label(subtitle, style="TASK_CARD_SUBTITLE") + content.add_widget(self._subtitle_label) + + def mousePressEvent(self, event) -> None: + """Обработка клика по карточке.""" + if event.button() == Qt.LeftButton: + self.card_clicked.emit(self._card_id) + super().mousePressEvent(event) diff --git a/Dispatch_V0.1.1/gui/components/kanban_column.py b/Dispatch_V0.1.1/gui/components/kanban_column.py new file mode 100644 index 0000000..cce7d8c --- /dev/null +++ b/Dispatch_V0.1.1/gui/components/kanban_column.py @@ -0,0 +1,131 @@ +# -*- coding: utf-8 -*- +# gui/components/kanban_column.py + +"""Переиспользуемая колонка для канбан-доски.""" + +from typing import List, Optional + +from PySide6.QtCore import Signal +from PySide6.QtWidgets import QWidget + +from gui.components.kanban_card import KanbanCard +from gui.components.label import Label +from gui.components.springs import VSpring +from gui.containers.h_container import HContainer +from gui.containers.s_container import SContainer +from gui.containers.scroll_container import ScrollContainer +from gui.containers.v_container import VContainer + + +class KanbanColumn(SContainer): + """Вертикальная колонка канбан-доски с заголовком и карточками.""" + + card_clicked = Signal(object) + + def __init__( + self, + column_id: object, + title: str = "", + color: str = "#DFE1E6", + style: str = "KANBAN_COLUMN", + parent: Optional[QWidget] = None, + ): + super().__init__( + width_percent=20, + margin=[4, 0, 4, 0], + spacing=0, + style=style, + content_fit=True, + parent=parent, + ) + self._column_id = column_id + self._color = color + self._cards: List[KanbanCard] = [] + self._build_ui(title, color) + + @property + def column_id(self) -> object: + """Идентификатор колонки.""" + return self._column_id + + @property + def card_count(self) -> int: + """Количество карточек в колонке.""" + return len(self._cards) + + def set_title(self, text: str) -> None: + """Установить заголовок колонки.""" + self._title_label.set_text(text) + + def update_counter(self) -> None: + """Обновить текст счётчика.""" + self._counter_label.set_text(str(len(self._cards))) + + def add_card(self, card: KanbanCard) -> None: + """Добавить карточку в колонку перед нижней пружиной.""" + card.card_clicked.connect(self.card_clicked.emit) + self._card_container.insert_widget(len(self._cards), card) + self._cards.append(card) + self.update_counter() + + def remove_card(self, card_id: object) -> Optional[KanbanCard]: + """Удалить карточку по ID. Возвращает удалённую карточку или None.""" + for card in self._cards: + if card.card_id == card_id: + self._cards.remove(card) + self._detach_card(card) + self.update_counter() + return card + return None + + def clear_cards(self) -> None: + """Удалить все карточки из колонки.""" + for card in list(self._cards): + self._detach_card(card) + self._cards.clear() + self.update_counter() + + def get_card(self, card_id: object) -> Optional[KanbanCard]: + """Получить карточку по ID.""" + for card in self._cards: + if card.card_id == card_id: + return card + return None + + def _build_ui(self, title: str, color: str) -> None: + header = HContainer( + height_percent=6, + margin=[8, 6, 8, 2], + parent=self, + ) + + color_strip = QWidget() + color_strip.setFixedWidth(4) + color_strip.setFixedHeight(16) + color_strip.setStyleSheet( + f"background-color: {color}; border: none; border-radius: 2px;" + ) + header.add_widget(color_strip) + + self._title_label = Label(title, style="KANBAN_COLUMN_HEADER") + header.add_widget(self._title_label) + header.add_stretch() + + self._counter_label = Label("0", style="KANBAN_COUNTER") + self._counter_label.set_fixed_size(24, 24) + header.add_widget(self._counter_label) + + self._scroll = ScrollContainer( + orientation="v", + spacing=0, + content_margins=[0, 2, 0, 2], + vertical_scroll_bar_policy="as_needed", + horizontal_scroll_bar_policy="always_off", + parent=self, + ) + self._card_container = VContainer(spacing=2, parent=self._scroll) + self._card_container.add_widget(VSpring()) + + def _detach_card(self, card: KanbanCard) -> None: + self._card_container.remove_widget(card) + card.setParent(None) diff --git a/Dispatch_V0.1.1/gui/components/label.py b/Dispatch_V0.1.1/gui/components/label.py new file mode 100644 index 0000000..b59dee4 --- /dev/null +++ b/Dispatch_V0.1.1/gui/components/label.py @@ -0,0 +1,265 @@ +# -*- coding: utf-8 -*- +# gui/components/label.py +"""Стандартный компонент метки.""" + +from PySide6.QtWidgets import QLabel, QSizePolicy +from PySide6.QtCore import Qt, Slot + +from gui.containers.s_container import SContainer +from gui.styles import APP_STYLES +from gui.theme_bus import theme_bus + + +class Label(SContainer): + """Стандартная метка с выбором стиля по теме внутри SContainer.""" + + _ALIGN_MAP = { + "top": Qt.AlignmentFlag.AlignTop, + "bottom": Qt.AlignmentFlag.AlignBottom, + "left": Qt.AlignmentFlag.AlignLeft, + "right": Qt.AlignmentFlag.AlignRight, + "hcenter": Qt.AlignmentFlag.AlignHCenter, + "vcenter": Qt.AlignmentFlag.AlignVCenter, + "center": Qt.AlignmentFlag.AlignCenter, + } + + def __init__(self, text: str = "", **kwargs): + width_percent = kwargs.get("width_percent", None) + height_percent = kwargs.get("height_percent", None) + margin = kwargs.get("margin", 0) + alignment = kwargs.get("alignment", Qt.AlignCenter) + style = kwargs.get("style", "WIDGET_LABEL") + active_style = kwargs.get("active_style", None) + is_active = kwargs.get("is_active", None) + content_fit = kwargs.get("content_fit", True) + word_wrap = kwargs.get("word_wrap", False) + parent = kwargs.get("parent", None) + + super().__init__( + width_percent=width_percent, + height_percent=height_percent, + margin=margin, + style=style, + active_style=active_style, + is_active=is_active, + content_fit=content_fit, + parent=parent, + ) + + self._theme = "dark" + self._is_active: bool = False + self._base_style_key = style + self._style_key_normal = None + self._style_key_active = None + + self._label = QLabel(text) + self._apply_alignment(alignment) + if word_wrap: + self._label.setWordWrap(True) + self._label.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred) + else: + self._label.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) + super().add_widget(self._label) + + if active_style is not None: + self._style_key_normal = style + self._style_key_active = active_style + if is_active is not None: + self._is_active = bool(is_active) + + self._theme = "dark" if self.palette().window().color().lightness() < 128 else "light" + self.style() + theme_bus.theme_changed.connect(self.set_theme) + + def style( + self, + style_key: str | None = None, + active_key: str | None = None, + is_active: bool | None = None, + ) -> None: + """Короткий метод применения стиля. Можно задать ключи и активность явно.""" + if style_key is not None: + self._base_style_key = style_key + if active_key is not None: + self._style_key_normal = style_key + self._style_key_active = active_key + else: + self._style_key_normal = None + self._style_key_active = None + + if is_active is not None: + self._is_active = bool(is_active) + + if self._style_key_normal is not None: + active_key = self._style_key_active or self._style_key_normal + key = active_key if self._is_active else self._style_key_normal + themed = f"{key}_{self._theme.upper()}" + if themed in APP_STYLES: + key = themed + self._label.setStyleSheet(APP_STYLES.get(key, "")) + return + + base_key = self._base_style_key + key = base_key + + if self._theme == "light": + if self._is_active and f"{base_key}_LIGHT_ACTIVE" in APP_STYLES: + key = f"{base_key}_LIGHT_ACTIVE" + elif f"{base_key}_LIGHT" in APP_STYLES: + key = f"{base_key}_LIGHT" + else: + if self._is_active and f"{base_key}_DARK_ACTIVE" in APP_STYLES: + key = f"{base_key}_DARK_ACTIVE" + elif f"{base_key}_DARK" in APP_STYLES: + key = f"{base_key}_DARK" + + self._label.setStyleSheet(APP_STYLES.get(key, "")) + + @Slot(str) + def set_theme(self, theme: str) -> None: + """Внешний слот: принимает 'dark' или 'light'.""" + theme = (theme or "").strip().lower() + if theme not in ("dark", "light"): + return + if self._theme == theme: + return + self._theme = theme + self.style() + + def set_text(self, text: str) -> None: + self._label.setText(text) + + def set_pixmap(self, pixmap) -> None: + self._label.setPixmap(pixmap) + + def get_text(self) -> str: + return self._label.text() + + def _apply_alignment(self, alignment) -> None: + """Внутренний метод применения alignment с поддержкой строк.""" + if isinstance(alignment, str): + key = alignment.strip().lower() + alignment = self._ALIGN_MAP.get(key) + if alignment is None: + raise ValueError(f"Unknown alignment '{key}'. Allowed: {list(self._ALIGN_MAP.keys())}") + self._label.setAlignment(alignment) + + def set_alignment(self, alignment: str | Qt.Alignment) -> None: + """Установить выравнивание текста (строка или Qt.Alignment).""" + self._apply_alignment(alignment) + + def set_word_wrap(self, enabled: bool) -> None: + """Включить / выключить перенос текста по словам.""" + self._label.setWordWrap(enabled) + if enabled: + self._label.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred) + + def set_enabled(self, enabled: bool) -> None: + self._label.setEnabled(enabled) + super().setEnabled(enabled) + + def set_min_width(self, width: int) -> None: + self._label.setMinimumWidth(width) + super().setMinimumWidth(width) + + def set_min_height(self, height: int) -> None: + self._label.setMinimumHeight(height) + super().setMinimumHeight(height) + + def set_max_width(self, width: int) -> None: + self._label.setMaximumWidth(width) + super().setMaximumWidth(width) + + def set_max_height(self, height: int) -> None: + self._label.setMaximumHeight(height) + super().setMaximumHeight(height) + + def set_fixed_size(self, width: int, height: int) -> None: + self._label.setMinimumSize(width, height) + self._label.setMaximumSize(width, height) + super().setMinimumSize(width, height) + super().setMaximumSize(width, height) + + def set_tooltip(self, text: str) -> None: + self._label.setToolTip(text) + + def set_size_policy(self, horizontal, vertical) -> None: + self._label.setSizePolicy(horizontal, vertical) + super().setSizePolicy(horizontal, vertical) + + def add_widget(self, widget, alignment=None): + raise NotImplementedError("Label can contain only one QLabel") + + + + + + +# --------------------------------------------------------------------------- +# Module workflow notes +# --------------------------------------------------------------------------- +# +# 1) Назначение модуля: +# Стандартная текстовая метка в SContainer с поддержкой +# централизованных стилей APP_STYLES, темизации и строкового +# выравнивания. +# +# 2) Зависимости модуля: +# Импорты: QLabel, QSizePolicy (PySide6.QtWidgets), +# Qt, Slot (PySide6.QtCore), +# SContainer (gui.containers.s_container), +# APP_STYLES (gui.styles), +# theme_bus (gui.theme_bus) +# Хост-класс / базовый класс: SContainer +# Внешние библиотеки: PySide6 (обязательна) +# +# 3) Экспорт: +# Класс Label — публичный виджет метки. +# Методы: style(), set_theme(), set_text(), get_text(), +# set_alignment(), set_enabled(), +# set_min/max_width/height(), set_fixed_size(), +# set_tooltip(), set_size_policy(). +# +# 4) Состояние (поля): +# _theme: str — текущая тема +# _is_active: bool — признак активного состояния +# _base_style_key: str — базовый ключ стиля ("WIDGET_LABEL") +# _style_key_normal: str|None — явный нормальный стиль +# _style_key_active: str|None — явный активный стиль +# _label: QLabel — внутренний виджет метки +# _ALIGN_MAP: dict — маппинг строк → Qt.Alignment +# +# 5) Последовательность действий и вызовов: +# __init__(text="", **kwargs) +# -> извлечение параметров из kwargs +# -> super().__init__(...) +# -> QLabel(text) -> _apply_alignment -> setSizePolicy +# -> super().add_widget(_label) +# -> style() -> theme_bus.theme_changed.connect(set_theme) +# _apply_alignment(alignment) +# -> если str → маппинг через _ALIGN_MAP → _label.setAlignment() +# -> если Qt.Alignment → прямое применение +# -> ValueError при невалидной строке +# +# 6) Побочные эффекты: +# - Устанавливает stylesheet на QLabel. +# - Подключается к theme_bus.theme_changed. +# +# 7) Границы ответственности: +# Модуль НЕ поддерживает rich-text/HTML самостоятельно (хотя QLabel +# может). НЕ обрабатывает клики. add_widget() заблокирован. +# +# 8) Обработка ошибок: +# add_widget() бросает NotImplementedError. +# _apply_alignment() бросает ValueError при невалидной строке. +# set_theme() молча игнорирует невалидные значения. +# +# 9) Инварианты и контракты: +# - Контейнер содержит ровно один QLabel. +# - _theme ∈ {"dark", "light"}. +# - Стиль: явный ключ → base_key + суффикс. +# +# 10) Правило сопровождения: +# Для новых стилей — добавлять в APP_STYLES с суффиксами +# _DARK/_LIGHT + _ACTIVE. __init__ принимает **kwargs — +# при добавлении новых параметров обновлять извлечение. diff --git a/Dispatch_V0.1.1/gui/components/logo/Nutshell_Logo_ENG_Black.png b/Dispatch_V0.1.1/gui/components/logo/Nutshell_Logo_ENG_Black.png new file mode 100644 index 0000000..fa4428f Binary files /dev/null and b/Dispatch_V0.1.1/gui/components/logo/Nutshell_Logo_ENG_Black.png differ diff --git a/Dispatch_V0.1.1/gui/components/logo/Nutshell_Logo_ENG_White.png b/Dispatch_V0.1.1/gui/components/logo/Nutshell_Logo_ENG_White.png new file mode 100644 index 0000000..c504667 Binary files /dev/null and b/Dispatch_V0.1.1/gui/components/logo/Nutshell_Logo_ENG_White.png differ diff --git a/Dispatch_V0.1.1/gui/components/logo/Nutshell_Logo_with_Desctiptor_ENG_Black.png b/Dispatch_V0.1.1/gui/components/logo/Nutshell_Logo_with_Desctiptor_ENG_Black.png new file mode 100644 index 0000000..8f8034d Binary files /dev/null and b/Dispatch_V0.1.1/gui/components/logo/Nutshell_Logo_with_Desctiptor_ENG_Black.png differ diff --git a/Dispatch_V0.1.1/gui/components/logo/Nutshell_Logo_with_Desctiptor_ENG_White.png b/Dispatch_V0.1.1/gui/components/logo/Nutshell_Logo_with_Desctiptor_ENG_White.png new file mode 100644 index 0000000..628e5f5 Binary files /dev/null and b/Dispatch_V0.1.1/gui/components/logo/Nutshell_Logo_with_Desctiptor_ENG_White.png differ diff --git a/Dispatch_V0.1.1/gui/components/logo/welcome_page_dark.png b/Dispatch_V0.1.1/gui/components/logo/welcome_page_dark.png new file mode 100644 index 0000000..8df2e3b Binary files /dev/null and b/Dispatch_V0.1.1/gui/components/logo/welcome_page_dark.png differ diff --git a/Dispatch_V0.1.1/gui/components/logo/welcome_page_light.png b/Dispatch_V0.1.1/gui/components/logo/welcome_page_light.png new file mode 100644 index 0000000..2aea2e6 Binary files /dev/null and b/Dispatch_V0.1.1/gui/components/logo/welcome_page_light.png differ diff --git a/Dispatch_V0.1.1/gui/components/model_view/__init__.py b/Dispatch_V0.1.1/gui/components/model_view/__init__.py new file mode 100644 index 0000000..d968f61 --- /dev/null +++ b/Dispatch_V0.1.1/gui/components/model_view/__init__.py @@ -0,0 +1,32 @@ +# -*- coding: utf-8 -*- +# gui/components/model_view/__init__.py + +"""Внутренние mixin-модули для ModelViewWidget.""" + +from gui.components.model_view._mv_model_loading import ModelLoadingMixin +from gui.components.model_view._mv_zones import ZoneManagementMixin +from gui.components.model_view._mv_interaction import InteractionMixin +from gui.components.model_view._mv_visual import VisualHelpersMixin +from gui.components.model_view._mv_grid_core import GridCoreMixin +from gui.components.model_view._mv_dimension_lines import DimensionLinesMixin +from gui.components.model_view._mv_racks import RackPlacementMixin +from gui.components.model_view._mv_presentation import ScenePresentationMixin +from gui.components.model_view._mv_scene_modes import SceneModesMixin +from gui.components.model_view._mv_rack_transition import RackCameraTransitionMixin +from gui.components.model_view._mv_zone_transition import ZoneCameraTransitionMixin +from gui.components.model_view._mv_racks_io import RackPlacementIOMixin + +__all__ = [ + "ModelLoadingMixin", + "ZoneManagementMixin", + "InteractionMixin", + "VisualHelpersMixin", + "GridCoreMixin", + "DimensionLinesMixin", + "RackPlacementMixin", + "ScenePresentationMixin", + "SceneModesMixin", + "RackCameraTransitionMixin", + "ZoneCameraTransitionMixin", + "RackPlacementIOMixin", +] diff --git a/Dispatch_V0.1.1/gui/components/model_view/__pycache__/__init__.cpython-313.pyc b/Dispatch_V0.1.1/gui/components/model_view/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..cdfdbb0 Binary files /dev/null and b/Dispatch_V0.1.1/gui/components/model_view/__pycache__/__init__.cpython-313.pyc differ diff --git a/Dispatch_V0.1.1/gui/components/model_view/__pycache__/_interaction_scenario.cpython-313.pyc b/Dispatch_V0.1.1/gui/components/model_view/__pycache__/_interaction_scenario.cpython-313.pyc new file mode 100644 index 0000000..ed15c5d Binary files /dev/null and b/Dispatch_V0.1.1/gui/components/model_view/__pycache__/_interaction_scenario.cpython-313.pyc differ diff --git a/Dispatch_V0.1.1/gui/components/model_view/__pycache__/_mv_dim_lines_grid.cpython-313.pyc b/Dispatch_V0.1.1/gui/components/model_view/__pycache__/_mv_dim_lines_grid.cpython-313.pyc new file mode 100644 index 0000000..daabeb0 Binary files /dev/null and b/Dispatch_V0.1.1/gui/components/model_view/__pycache__/_mv_dim_lines_grid.cpython-313.pyc differ diff --git a/Dispatch_V0.1.1/gui/components/model_view/__pycache__/_mv_dim_lines_volume.cpython-313.pyc b/Dispatch_V0.1.1/gui/components/model_view/__pycache__/_mv_dim_lines_volume.cpython-313.pyc new file mode 100644 index 0000000..cb7171f Binary files /dev/null and b/Dispatch_V0.1.1/gui/components/model_view/__pycache__/_mv_dim_lines_volume.cpython-313.pyc differ diff --git a/Dispatch_V0.1.1/gui/components/model_view/__pycache__/_mv_dimension_lines.cpython-313.pyc b/Dispatch_V0.1.1/gui/components/model_view/__pycache__/_mv_dimension_lines.cpython-313.pyc new file mode 100644 index 0000000..5228ca6 Binary files /dev/null and b/Dispatch_V0.1.1/gui/components/model_view/__pycache__/_mv_dimension_lines.cpython-313.pyc differ diff --git a/Dispatch_V0.1.1/gui/components/model_view/__pycache__/_mv_grid_core.cpython-313.pyc b/Dispatch_V0.1.1/gui/components/model_view/__pycache__/_mv_grid_core.cpython-313.pyc new file mode 100644 index 0000000..2836cd8 Binary files /dev/null and b/Dispatch_V0.1.1/gui/components/model_view/__pycache__/_mv_grid_core.cpython-313.pyc differ diff --git a/Dispatch_V0.1.1/gui/components/model_view/__pycache__/_mv_grid_core_base.cpython-313.pyc b/Dispatch_V0.1.1/gui/components/model_view/__pycache__/_mv_grid_core_base.cpython-313.pyc new file mode 100644 index 0000000..8f7f517 Binary files /dev/null and b/Dispatch_V0.1.1/gui/components/model_view/__pycache__/_mv_grid_core_base.cpython-313.pyc differ diff --git a/Dispatch_V0.1.1/gui/components/model_view/__pycache__/_mv_grid_core_floor.cpython-313.pyc b/Dispatch_V0.1.1/gui/components/model_view/__pycache__/_mv_grid_core_floor.cpython-313.pyc new file mode 100644 index 0000000..1314682 Binary files /dev/null and b/Dispatch_V0.1.1/gui/components/model_view/__pycache__/_mv_grid_core_floor.cpython-313.pyc differ diff --git a/Dispatch_V0.1.1/gui/components/model_view/__pycache__/_mv_interaction.cpython-313.pyc b/Dispatch_V0.1.1/gui/components/model_view/__pycache__/_mv_interaction.cpython-313.pyc new file mode 100644 index 0000000..170e28d Binary files /dev/null and b/Dispatch_V0.1.1/gui/components/model_view/__pycache__/_mv_interaction.cpython-313.pyc differ diff --git a/Dispatch_V0.1.1/gui/components/model_view/__pycache__/_mv_interaction_core.cpython-313.pyc b/Dispatch_V0.1.1/gui/components/model_view/__pycache__/_mv_interaction_core.cpython-313.pyc new file mode 100644 index 0000000..2339627 Binary files /dev/null and b/Dispatch_V0.1.1/gui/components/model_view/__pycache__/_mv_interaction_core.cpython-313.pyc differ diff --git a/Dispatch_V0.1.1/gui/components/model_view/__pycache__/_mv_interaction_events.cpython-313.pyc b/Dispatch_V0.1.1/gui/components/model_view/__pycache__/_mv_interaction_events.cpython-313.pyc new file mode 100644 index 0000000..d9a9999 Binary files /dev/null and b/Dispatch_V0.1.1/gui/components/model_view/__pycache__/_mv_interaction_events.cpython-313.pyc differ diff --git a/Dispatch_V0.1.1/gui/components/model_view/__pycache__/_mv_interaction_nav.cpython-313.pyc b/Dispatch_V0.1.1/gui/components/model_view/__pycache__/_mv_interaction_nav.cpython-313.pyc new file mode 100644 index 0000000..cf68277 Binary files /dev/null and b/Dispatch_V0.1.1/gui/components/model_view/__pycache__/_mv_interaction_nav.cpython-313.pyc differ diff --git a/Dispatch_V0.1.1/gui/components/model_view/__pycache__/_mv_model_loading.cpython-313.pyc b/Dispatch_V0.1.1/gui/components/model_view/__pycache__/_mv_model_loading.cpython-313.pyc new file mode 100644 index 0000000..60766bd Binary files /dev/null and b/Dispatch_V0.1.1/gui/components/model_view/__pycache__/_mv_model_loading.cpython-313.pyc differ diff --git a/Dispatch_V0.1.1/gui/components/model_view/__pycache__/_mv_presentation.cpython-313.pyc b/Dispatch_V0.1.1/gui/components/model_view/__pycache__/_mv_presentation.cpython-313.pyc new file mode 100644 index 0000000..82cf3c4 Binary files /dev/null and b/Dispatch_V0.1.1/gui/components/model_view/__pycache__/_mv_presentation.cpython-313.pyc differ diff --git a/Dispatch_V0.1.1/gui/components/model_view/__pycache__/_mv_rack_geometry.cpython-313.pyc b/Dispatch_V0.1.1/gui/components/model_view/__pycache__/_mv_rack_geometry.cpython-313.pyc new file mode 100644 index 0000000..cea0348 Binary files /dev/null and b/Dispatch_V0.1.1/gui/components/model_view/__pycache__/_mv_rack_geometry.cpython-313.pyc differ diff --git a/Dispatch_V0.1.1/gui/components/model_view/__pycache__/_mv_rack_transition.cpython-313.pyc b/Dispatch_V0.1.1/gui/components/model_view/__pycache__/_mv_rack_transition.cpython-313.pyc new file mode 100644 index 0000000..5351140 Binary files /dev/null and b/Dispatch_V0.1.1/gui/components/model_view/__pycache__/_mv_rack_transition.cpython-313.pyc differ diff --git a/Dispatch_V0.1.1/gui/components/model_view/__pycache__/_mv_racks.cpython-313.pyc b/Dispatch_V0.1.1/gui/components/model_view/__pycache__/_mv_racks.cpython-313.pyc new file mode 100644 index 0000000..5d42513 Binary files /dev/null and b/Dispatch_V0.1.1/gui/components/model_view/__pycache__/_mv_racks.cpython-313.pyc differ diff --git a/Dispatch_V0.1.1/gui/components/model_view/__pycache__/_mv_racks_camera.cpython-313.pyc b/Dispatch_V0.1.1/gui/components/model_view/__pycache__/_mv_racks_camera.cpython-313.pyc new file mode 100644 index 0000000..81596b3 Binary files /dev/null and b/Dispatch_V0.1.1/gui/components/model_view/__pycache__/_mv_racks_camera.cpython-313.pyc differ diff --git a/Dispatch_V0.1.1/gui/components/model_view/__pycache__/_mv_racks_codes.cpython-313.pyc b/Dispatch_V0.1.1/gui/components/model_view/__pycache__/_mv_racks_codes.cpython-313.pyc new file mode 100644 index 0000000..96f41f4 Binary files /dev/null and b/Dispatch_V0.1.1/gui/components/model_view/__pycache__/_mv_racks_codes.cpython-313.pyc differ diff --git a/Dispatch_V0.1.1/gui/components/model_view/__pycache__/_mv_racks_collision.cpython-313.pyc b/Dispatch_V0.1.1/gui/components/model_view/__pycache__/_mv_racks_collision.cpython-313.pyc new file mode 100644 index 0000000..c96ede2 Binary files /dev/null and b/Dispatch_V0.1.1/gui/components/model_view/__pycache__/_mv_racks_collision.cpython-313.pyc differ diff --git a/Dispatch_V0.1.1/gui/components/model_view/__pycache__/_mv_racks_crud.cpython-313.pyc b/Dispatch_V0.1.1/gui/components/model_view/__pycache__/_mv_racks_crud.cpython-313.pyc new file mode 100644 index 0000000..5be6a3b Binary files /dev/null and b/Dispatch_V0.1.1/gui/components/model_view/__pycache__/_mv_racks_crud.cpython-313.pyc differ diff --git a/Dispatch_V0.1.1/gui/components/model_view/__pycache__/_mv_racks_hover.cpython-313.pyc b/Dispatch_V0.1.1/gui/components/model_view/__pycache__/_mv_racks_hover.cpython-313.pyc new file mode 100644 index 0000000..bd7ceb2 Binary files /dev/null and b/Dispatch_V0.1.1/gui/components/model_view/__pycache__/_mv_racks_hover.cpython-313.pyc differ diff --git a/Dispatch_V0.1.1/gui/components/model_view/__pycache__/_mv_racks_io.cpython-313.pyc b/Dispatch_V0.1.1/gui/components/model_view/__pycache__/_mv_racks_io.cpython-313.pyc new file mode 100644 index 0000000..5ff7709 Binary files /dev/null and b/Dispatch_V0.1.1/gui/components/model_view/__pycache__/_mv_racks_io.cpython-313.pyc differ diff --git a/Dispatch_V0.1.1/gui/components/model_view/__pycache__/_mv_racks_lifecycle.cpython-313.pyc b/Dispatch_V0.1.1/gui/components/model_view/__pycache__/_mv_racks_lifecycle.cpython-313.pyc new file mode 100644 index 0000000..abc8222 Binary files /dev/null and b/Dispatch_V0.1.1/gui/components/model_view/__pycache__/_mv_racks_lifecycle.cpython-313.pyc differ diff --git a/Dispatch_V0.1.1/gui/components/model_view/__pycache__/_mv_racks_mezzanine.cpython-313.pyc b/Dispatch_V0.1.1/gui/components/model_view/__pycache__/_mv_racks_mezzanine.cpython-313.pyc new file mode 100644 index 0000000..87b0189 Binary files /dev/null and b/Dispatch_V0.1.1/gui/components/model_view/__pycache__/_mv_racks_mezzanine.cpython-313.pyc differ diff --git a/Dispatch_V0.1.1/gui/components/model_view/__pycache__/_mv_racks_move.cpython-313.pyc b/Dispatch_V0.1.1/gui/components/model_view/__pycache__/_mv_racks_move.cpython-313.pyc new file mode 100644 index 0000000..4b708aa Binary files /dev/null and b/Dispatch_V0.1.1/gui/components/model_view/__pycache__/_mv_racks_move.cpython-313.pyc differ diff --git a/Dispatch_V0.1.1/gui/components/model_view/__pycache__/_mv_racks_picking.cpython-313.pyc b/Dispatch_V0.1.1/gui/components/model_view/__pycache__/_mv_racks_picking.cpython-313.pyc new file mode 100644 index 0000000..166c868 Binary files /dev/null and b/Dispatch_V0.1.1/gui/components/model_view/__pycache__/_mv_racks_picking.cpython-313.pyc differ diff --git a/Dispatch_V0.1.1/gui/components/model_view/__pycache__/_mv_racks_preview.cpython-313.pyc b/Dispatch_V0.1.1/gui/components/model_view/__pycache__/_mv_racks_preview.cpython-313.pyc new file mode 100644 index 0000000..2bb58a9 Binary files /dev/null and b/Dispatch_V0.1.1/gui/components/model_view/__pycache__/_mv_racks_preview.cpython-313.pyc differ diff --git a/Dispatch_V0.1.1/gui/components/model_view/__pycache__/_mv_racks_projection.cpython-313.pyc b/Dispatch_V0.1.1/gui/components/model_view/__pycache__/_mv_racks_projection.cpython-313.pyc new file mode 100644 index 0000000..97b244e Binary files /dev/null and b/Dispatch_V0.1.1/gui/components/model_view/__pycache__/_mv_racks_projection.cpython-313.pyc differ diff --git a/Dispatch_V0.1.1/gui/components/model_view/__pycache__/_mv_racks_selection.cpython-313.pyc b/Dispatch_V0.1.1/gui/components/model_view/__pycache__/_mv_racks_selection.cpython-313.pyc new file mode 100644 index 0000000..3c93d53 Binary files /dev/null and b/Dispatch_V0.1.1/gui/components/model_view/__pycache__/_mv_racks_selection.cpython-313.pyc differ diff --git a/Dispatch_V0.1.1/gui/components/model_view/__pycache__/_mv_racks_shelf.cpython-313.pyc b/Dispatch_V0.1.1/gui/components/model_view/__pycache__/_mv_racks_shelf.cpython-313.pyc new file mode 100644 index 0000000..4e58446 Binary files /dev/null and b/Dispatch_V0.1.1/gui/components/model_view/__pycache__/_mv_racks_shelf.cpython-313.pyc differ diff --git a/Dispatch_V0.1.1/gui/components/model_view/__pycache__/_mv_racks_shelf_render.cpython-313.pyc b/Dispatch_V0.1.1/gui/components/model_view/__pycache__/_mv_racks_shelf_render.cpython-313.pyc new file mode 100644 index 0000000..fce7058 Binary files /dev/null and b/Dispatch_V0.1.1/gui/components/model_view/__pycache__/_mv_racks_shelf_render.cpython-313.pyc differ diff --git a/Dispatch_V0.1.1/gui/components/model_view/__pycache__/_mv_racks_visual.cpython-313.pyc b/Dispatch_V0.1.1/gui/components/model_view/__pycache__/_mv_racks_visual.cpython-313.pyc new file mode 100644 index 0000000..e457d56 Binary files /dev/null and b/Dispatch_V0.1.1/gui/components/model_view/__pycache__/_mv_racks_visual.cpython-313.pyc differ diff --git a/Dispatch_V0.1.1/gui/components/model_view/__pycache__/_mv_racks_visual_models.cpython-313.pyc b/Dispatch_V0.1.1/gui/components/model_view/__pycache__/_mv_racks_visual_models.cpython-313.pyc new file mode 100644 index 0000000..366438e Binary files /dev/null and b/Dispatch_V0.1.1/gui/components/model_view/__pycache__/_mv_racks_visual_models.cpython-313.pyc differ diff --git a/Dispatch_V0.1.1/gui/components/model_view/__pycache__/_mv_scene_modes.cpython-313.pyc b/Dispatch_V0.1.1/gui/components/model_view/__pycache__/_mv_scene_modes.cpython-313.pyc new file mode 100644 index 0000000..4b19a1f Binary files /dev/null and b/Dispatch_V0.1.1/gui/components/model_view/__pycache__/_mv_scene_modes.cpython-313.pyc differ diff --git a/Dispatch_V0.1.1/gui/components/model_view/__pycache__/_mv_visual.cpython-313.pyc b/Dispatch_V0.1.1/gui/components/model_view/__pycache__/_mv_visual.cpython-313.pyc new file mode 100644 index 0000000..2a0a141 Binary files /dev/null and b/Dispatch_V0.1.1/gui/components/model_view/__pycache__/_mv_visual.cpython-313.pyc differ diff --git a/Dispatch_V0.1.1/gui/components/model_view/__pycache__/_mv_zone_transition.cpython-313.pyc b/Dispatch_V0.1.1/gui/components/model_view/__pycache__/_mv_zone_transition.cpython-313.pyc new file mode 100644 index 0000000..7ad4432 Binary files /dev/null and b/Dispatch_V0.1.1/gui/components/model_view/__pycache__/_mv_zone_transition.cpython-313.pyc differ diff --git a/Dispatch_V0.1.1/gui/components/model_view/__pycache__/_mv_zones.cpython-313.pyc b/Dispatch_V0.1.1/gui/components/model_view/__pycache__/_mv_zones.cpython-313.pyc new file mode 100644 index 0000000..30c335a Binary files /dev/null and b/Dispatch_V0.1.1/gui/components/model_view/__pycache__/_mv_zones.cpython-313.pyc differ diff --git a/Dispatch_V0.1.1/gui/components/model_view/__pycache__/_mv_zones_crud.cpython-313.pyc b/Dispatch_V0.1.1/gui/components/model_view/__pycache__/_mv_zones_crud.cpython-313.pyc new file mode 100644 index 0000000..8b5a89a Binary files /dev/null and b/Dispatch_V0.1.1/gui/components/model_view/__pycache__/_mv_zones_crud.cpython-313.pyc differ diff --git a/Dispatch_V0.1.1/gui/components/model_view/__pycache__/_mv_zones_highlight.cpython-313.pyc b/Dispatch_V0.1.1/gui/components/model_view/__pycache__/_mv_zones_highlight.cpython-313.pyc new file mode 100644 index 0000000..660293f Binary files /dev/null and b/Dispatch_V0.1.1/gui/components/model_view/__pycache__/_mv_zones_highlight.cpython-313.pyc differ diff --git a/Dispatch_V0.1.1/gui/components/model_view/__pycache__/_mv_zones_visual.cpython-313.pyc b/Dispatch_V0.1.1/gui/components/model_view/__pycache__/_mv_zones_visual.cpython-313.pyc new file mode 100644 index 0000000..717e9ac Binary files /dev/null and b/Dispatch_V0.1.1/gui/components/model_view/__pycache__/_mv_zones_visual.cpython-313.pyc differ diff --git a/Dispatch_V0.1.1/gui/components/model_view/__pycache__/_scenario_camera.cpython-313.pyc b/Dispatch_V0.1.1/gui/components/model_view/__pycache__/_scenario_camera.cpython-313.pyc new file mode 100644 index 0000000..b729e81 Binary files /dev/null and b/Dispatch_V0.1.1/gui/components/model_view/__pycache__/_scenario_camera.cpython-313.pyc differ diff --git a/Dispatch_V0.1.1/gui/components/model_view/__pycache__/_scenario_facility_browse.cpython-313.pyc b/Dispatch_V0.1.1/gui/components/model_view/__pycache__/_scenario_facility_browse.cpython-313.pyc new file mode 100644 index 0000000..ceaa9d7 Binary files /dev/null and b/Dispatch_V0.1.1/gui/components/model_view/__pycache__/_scenario_facility_browse.cpython-313.pyc differ diff --git a/Dispatch_V0.1.1/gui/components/model_view/_interaction_scenario.py b/Dispatch_V0.1.1/gui/components/model_view/_interaction_scenario.py new file mode 100644 index 0000000..90cf97d --- /dev/null +++ b/Dispatch_V0.1.1/gui/components/model_view/_interaction_scenario.py @@ -0,0 +1,323 @@ +# -*- coding: utf-8 -*- +# gui/components/model_view/_interaction_scenario.py +"""Базовый сценарий взаимодействия и менеджер сценариев. + +Архитектура: +- InteractionScenario — базовый класс с методами-хуками для событий. +- CameraPolicy — декларативная политика камеры для сценария. +- InteractionManager — стек сценариев + делегация событий. +""" + +from __future__ import annotations + +from enum import Enum, auto +from typing import TYPE_CHECKING + +from PySide6.QtCore import Qt, QEvent +from PySide6.QtGui import QGuiApplication +from error_logger import log_exception + +if TYPE_CHECKING: # pragma: no cover + from gui.components.model_view_widget import ModelViewWidget + + +# ── CameraPolicy ──────────────────────────────────────────────────────────── + +class CameraPolicy(Enum): + """Политика камеры для сценария.""" + FREE = auto() # ПКМ вращение, СКМ панорамирование + TOP_VIEW = auto() # Только pan/zoom, без вращения + LOCKED = auto() # Камера заблокирована + + +# ── InteractionScenario ───────────────────────────────────────────────────── + +class InteractionScenario: + """Базовый класс сценария взаимодействия. + + Каждый конкретный сценарий переопределяет нужные хуки. + Менеджер передаёт события текущему сценарию через эти методы. + """ + + name: str = "" + camera_policy: CameraPolicy = CameraPolicy.FREE + + def on_activate(self, mv: "ModelViewWidget") -> None: + """Вызывается при входе в сценарий (push/replace).""" + + def on_deactivate(self, mv: "ModelViewWidget") -> None: + """Вызывается при выходе из сценария (pop/replace/reset).""" + + def on_mouse_press(self, mv: "ModelViewWidget", event) -> bool: + """ЛКМ/ПКМ/СКМ нажатие. Вернуть True если событие поглощено.""" + return False + + def on_mouse_move(self, mv: "ModelViewWidget", event) -> bool: + """Движение мыши (с нажатой кнопкой). Вернуть True если поглощено.""" + return False + + def on_mouse_release(self, mv: "ModelViewWidget", event) -> bool: + """Отпускание кнопки мыши. Вернуть True если поглощено.""" + return False + + def on_hover(self, mv: "ModelViewWidget", event) -> None: + """Движение мыши без нажатых кнопок (hover).""" + + def on_key_press(self, mv: "ModelViewWidget", event) -> bool: + """Нажатие клавиши. Вернуть True если поглощено.""" + return False + + def on_resume(self, mv: "ModelViewWidget") -> None: + """Вызывается когда сценарий снова стал верхним (после pop вышестоящего). + + По умолчанию вызывает on_activate повторно, чтобы восстановить обработчики. + """ + self.on_activate(mv) + + def on_widget_mouse_press(self, mv: "ModelViewWidget", event) -> bool: + """mousePressEvent виджета (не eventFilter). Вернуть True если поглощено.""" + return False + + def on_widget_mouse_move(self, mv: "ModelViewWidget", event) -> bool: + """mouseMoveEvent виджета. Вернуть True если поглощено.""" + return False + + def on_widget_mouse_release(self, mv: "ModelViewWidget", event) -> bool: + """mouseReleaseEvent виджета. Вернуть True если поглощено.""" + return False + + +# ── InteractionManager ────────────────────────────────────────────────────── + +class InteractionManager: + """Стек сценариев взаимодействия. + + - Всегда есть base-layer (камерный сценарий) — он не в стеке. + - Стек содержит доменные сценарии (contour_edit, rack_placement...). + - Верхний элемент стека — текущий сценарий. + - ESC → pop() → автоматический on_deactivate(). + - reset() → очищает весь стек. + """ + + def __init__(self, mv: "ModelViewWidget") -> None: + self._mv = mv + self._stack: list[InteractionScenario] = [] + self._camera: InteractionScenario | None = None + + # -- Публичный API -------------------------------------------------------- + + def set_camera_scenario(self, scenario: InteractionScenario) -> None: + """Установить базовый камерный сценарий (всегда активен).""" + self._camera = scenario + + @property + def current(self) -> InteractionScenario | None: + """Текущий (верхний) доменный сценарий, или None.""" + return self._stack[-1] if self._stack else None + + @property + def current_name(self) -> str: + """Имя текущего сценария.""" + top = self.current + return top.name if top else "" + + def is_active(self, name: str) -> bool: + """Проверить, есть ли сценарий с данным именем в стеке.""" + key = str(name) + return any(s.name == key for s in self._stack) + + def push(self, scenario: InteractionScenario) -> None: + """Войти в новый сценарий (добавить на вершину стека).""" + scenario.on_activate(self._mv) + self._stack.append(scenario) + + def pop(self) -> InteractionScenario | None: + """Выйти из текущего сценария, вернуть его.""" + if not self._stack: + return None + scenario = self._stack.pop() + scenario.on_deactivate(self._mv) + # Восстановить обработчики нижележащего сценария + new_top = self.current + if new_top is not None: + new_top.on_resume(self._mv) + return scenario + + def pop_by_name(self, name: str) -> InteractionScenario | None: + """Удалить сценарий по имени (из любого места стека).""" + key = str(name) + for i, s in enumerate(self._stack): + if s.name == key: + was_top = (i == len(self._stack) - 1) + scenario = self._stack.pop(i) + scenario.on_deactivate(self._mv) + if was_top: + new_top = self.current + if new_top is not None: + new_top.on_resume(self._mv) + return scenario + return None + + def replace(self, scenario: InteractionScenario) -> InteractionScenario | None: + """Заменить текущий сценарий новым (без промежуточного on_resume).""" + old = None + if self._stack: + old = self._stack.pop() + old.on_deactivate(self._mv) + self.push(scenario) + return old + + def reset(self) -> None: + """Сбросить весь стек (ESC-уровень).""" + while self._stack: + scenario = self._stack.pop() + try: + scenario.on_deactivate(self._mv) + except Exception as e: + log_exception(__name__, "reset", e) + + # -- Диспетчеризация событий eventFilter ---------------------------------- + + def dispatch_event_filter(self, watched, event) -> bool | None: + """Маршрутизация события из eventFilter. + + Returns: + True — событие поглощено. + False — событие НЕ должно обрабатываться дальше. + None — передать в super().eventFilter(). + """ + top = self.current + etype = event.type() + + # 1. Нажатие клавиши → текущий сценарий + if etype == QEvent.Type.KeyPress: + if top and top.on_key_press(self._mv, event): + return True + + # 2. Mouse press/move/release → текущий сценарий, затем камера + if etype == QEvent.Type.MouseButtonPress: + if top and top.on_mouse_press(self._mv, event): + return True + if self._camera and self._dispatch_camera_press(event): + return True + + if etype == QEvent.Type.MouseMove: + # robust-классификация drag vs hover: + # учитываем Qt event buttons, глобальные кнопки и внутренние drag-флаги камеры. + ev_buttons = Qt.MouseButton.NoButton + global_buttons = Qt.MouseButton.NoButton + try: + if hasattr(event, "buttons"): + ev_buttons = event.buttons() + except Exception as e: + log_exception(__name__, "dispatch_event_filter", e) + ev_buttons = Qt.MouseButton.NoButton + try: + global_buttons = QGuiApplication.mouseButtons() + except Exception as e: + log_exception(__name__, "dispatch_event_filter", e) + global_buttons = Qt.MouseButton.NoButton + has_buttons = bool( + ev_buttons + or global_buttons + or getattr(self._mv, "_cam_rotate_active", False) + or getattr(self._mv, "_cam_pan_active", False) + ) + if has_buttons: + # Движение с зажатой кнопкой + if top and top.on_mouse_move(self._mv, event): + return True + cam_result = self._dispatch_camera_move(event) + if cam_result is not None: + return cam_result + # Не отдаём drag-события в VTK-style, чтобы исключить побочный захват камеры. + return False + else: + # Hover (без кнопок) + if top: + top.on_hover(self._mv, event) + # В origin-flow hover нужен для preview-маркера, + # но не должен уходить в VTK interactor style. + if top.name == "origin_point": + # Защита от ложного swallow после завершения origin-сценария: + # поглощаем hover только пока реально подключены origin-hover handlers. + has_hover_handlers = bool( + getattr(self._mv, "_hover_screen_handler", None) + or getattr(self._mv, "_hover_handler", None) + ) + if has_hover_handlers: + return True + # Камера не обрабатывает hover + + if etype == QEvent.Type.MouseButtonRelease: + if top and top.on_mouse_release(self._mv, event): + return True + cam_result = self._dispatch_camera_release(event) + if cam_result is not None: + return cam_result + + return None + + # -- Диспетчеризация mousePressEvent/Move/Release виджета ----------------- + + def dispatch_widget_mouse_press(self, event) -> bool: + """Делегация mousePressEvent виджета текущему сценарию.""" + top = self.current + if top and top.on_widget_mouse_press(self._mv, event): + return True + return False + + def dispatch_widget_mouse_move(self, event) -> bool: + """Делегация mouseMoveEvent виджета текущему сценарию.""" + top = self.current + if top and top.on_widget_mouse_move(self._mv, event): + return True + return False + + def dispatch_widget_mouse_release(self, event) -> bool: + """Делегация mouseReleaseEvent виджета текущему сценарию.""" + top = self.current + if top and top.on_widget_mouse_release(self._mv, event): + return True + return False + + # -- Камерные делегации --------------------------------------------------- + + def _dispatch_camera_press(self, event) -> bool: + if not self._camera: + return False + if getattr(self._mv, "_camera_locked", False): + return False + top = self.current + policy = top.camera_policy if top else CameraPolicy.FREE + if policy == CameraPolicy.LOCKED: + return False + # TOP_VIEW: блокировать вращение (ПКМ), разрешить панорамирование (СКМ) + if policy == CameraPolicy.TOP_VIEW: + if hasattr(event, "button") and event.button() == Qt.MouseButton.RightButton: + # Поглощаем ПКМ, чтобы событие не ушло в VTK-style (иначе возможен zoom/pan). + return True + return self._camera.on_mouse_press(self._mv, event) + + def _dispatch_camera_move(self, event) -> bool | None: + if not self._camera: + return None + if getattr(self._mv, "_camera_locked", False): + return None + top = self.current + policy = top.camera_policy if top else CameraPolicy.FREE + if policy == CameraPolicy.LOCKED: + return None + return self._camera.on_mouse_move(self._mv, event) or None + + def _dispatch_camera_release(self, event) -> bool | None: + if not self._camera: + return None + if getattr(self._mv, "_camera_locked", False): + return None + top = self.current + policy = top.camera_policy if top else CameraPolicy.FREE + if policy == CameraPolicy.TOP_VIEW: + if hasattr(event, "button") and event.button() == Qt.MouseButton.RightButton: + return True + return self._camera.on_mouse_release(self._mv, event) or None diff --git a/Dispatch_V0.1.1/gui/components/model_view/_mv_dim_lines_grid.py b/Dispatch_V0.1.1/gui/components/model_view/_mv_dim_lines_grid.py new file mode 100644 index 0000000..1967e20 --- /dev/null +++ b/Dispatch_V0.1.1/gui/components/model_view/_mv_dim_lines_grid.py @@ -0,0 +1,362 @@ +# -*- coding: utf-8 -*- +# gui/components/model_view/_mv_dim_lines_grid.py +"""Основные методы размерных линий (инициализация, вкл/выкл, перерисовка, подсветка).""" + +from __future__ import annotations + +import time +from typing import List, Optional, Tuple, TYPE_CHECKING + +if TYPE_CHECKING: # pragma: no cover + from gui.components.model_view_widget import ModelViewWidget + +try: + import pyvista as pv + import numpy as np +except ImportError: # pragma: no cover + pv = None + np = None + +from error_logger import log_exception +from gui.components.model_view._mv_dimension_lines import ( # noqa: E501 + _DIM_LINE_COLOR_ORIGIN, _DIM_LINE_COLOR_LAST, + _DIM_TEXT_COLOR_ORIGIN, _DIM_TEXT_COLOR_LAST, + _EXTENSION_LINE_COLOR, _LINE_WIDTH, _EXTENSION_LINE_WIDTH, _FONT_SIZE, + _DIM_OFFSET_1, _DIM_OFFSET_2, _EXTENSION_OVERSHOOT, + _LABEL_CLEARANCE, _LABEL_Z_LAYER_STEP, _SNAP_TRIGGER_FRACTION, + _HIGHLIGHT_COLOR, _HIGHLIGHT_RADIUS, _HIGHLIGHT_OPACITY, + _THROTTLE_S, _Pt3, _Seg, +) + + +class DimLineCoreMixin: + """Миксин: основная отрисовка размерных линий для ModelViewWidget.""" + + def init_dimension_lines(self: "ModelViewWidget") -> None: + """Инициализировать внутреннее состояние (вызывать из __init__).""" + self._dim_actors: list = [] + self._dim_highlight_actor = None + self._dim_origin: Optional[Tuple[float, float, float]] = None + self._dim_last_point: Optional[Tuple[float, float]] = None + self._dim_current_node: Optional[Tuple[float, float]] = None + self._dim_enabled = False + self._dim_last_redraw_t: float = 0.0 + self._measure_nodes: list[tuple[float, float, float]] = [] + self._measure_hover_node: Optional[Tuple[float, float, float]] = None + self._measure_point_a: Optional[Tuple[float, float, float]] = None + self._measure_point_b: Optional[Tuple[float, float, float]] = None + self._measure_actors: list = [] + self._measure_highlight_actor = None + + def enable_dimension_lines(self: "ModelViewWidget", origin: Tuple[float, float, float]) -> None: + """Включить размерные линии. *origin* — базовая точка.""" + self.init_dimension_lines() + self._dim_origin = origin + self._dim_last_point = None + self._dim_current_node = None + self._dim_enabled = True + + def disable_dimension_lines(self: "ModelViewWidget") -> None: + """Выключить и очистить размерные линии.""" + self._clear_dim_actors() + self._clear_dim_highlight() + self._dim_enabled = False + self._dim_origin = None + self._dim_last_point = None + self._dim_current_node = None + + def set_dim_last_point(self: "ModelViewWidget", pt: Optional[Tuple[float, float]]) -> None: + """Обновить «последнюю созданную точку» контура.""" + self._dim_last_point = pt + if self._dim_current_node is not None: + self._redraw_dim_lines() + + def handle_dim_hover(self: "ModelViewWidget", x: float, y: float) -> None: + """Обработать перемещение курсора: триггерный snap к узлу сетки.""" + if bool(getattr(self, "_contour_aux_hidden", False)): + return + if not self._dim_enabled or not self._dim_origin: + return + + now = time.monotonic() + if now - self._dim_last_redraw_t < _THROTTLE_S: + return + + step = max(1.0, float(getattr(self, "_current_zone_size", 500.0))) + trigger_r2 = (step * _SNAP_TRIGGER_FRACTION) ** 2 + best_node: Optional[Tuple[float, float]] = None + best_d2: Optional[float] = None + for nx, ny in getattr(self, "_grid_nodes", []): + d2 = (nx - x) ** 2 + (ny - y) ** 2 + if best_d2 is None or d2 < best_d2: + best_d2 = d2 + best_node = (nx, ny) + + if best_node is None or best_d2 is None: + return + if best_d2 > trigger_r2: + return + if self._dim_current_node == best_node: + return + + self._dim_current_node = best_node + self._dim_last_redraw_t = now + self._redraw_dim_lines() + + def _redraw_dim_lines(self: "ModelViewWidget") -> None: + """Полная перерисовка всех размерных линий + подсветка узла.""" + if bool(getattr(self, "_contour_aux_hidden", False)): + return + if not self._dim_enabled or self._dim_origin is None: + return + if not self._plotter or pv is None or np is None: + return + node = self._dim_current_node + if node is None: + return + + self._clear_dim_actors() + + ox, oy, oz = self._dim_origin + cx, cy = node + z = oz + 1.0 # чуть выше пола + + # Контейнеры для сбора геометрии + ext_segs: List[_Seg] = [] # намеренно не отрисовываются + origin_segs: List[_Seg] = [] # жёлтые размерные + origin_labels: List[Tuple[_Pt3, str]] = [] + + # ── 1. От origin до узла ──────────────────────────────────── + self._collect_dimension_pair( + ox, oy, cx, cy, z, _DIM_OFFSET_1, "", + x_side="bottom", + y_side="right", + label_layer=0, + ext_segs=ext_segs, + dim_segs=origin_segs, + labels=origin_labels, + ) + + # ── Создать минимум актеров ───────────────────────────────── + if origin_segs: + self._add_lines_actor(origin_segs, _DIM_LINE_COLOR_ORIGIN, _LINE_WIDTH) + if origin_labels: + self._add_labels_actor(origin_labels, _DIM_TEXT_COLOR_ORIGIN) + + # ── 3. Подсветка текущего узла ────────────────────────────── + self._show_dim_highlight(cx, cy, z) + + if hasattr(self, "_safe_render"): + self._safe_render(min_interval_s=0.05) + else: + self._plotter.render() + + @staticmethod + def _collect_dimension_pair( + base_x: float, base_y: float, + target_x: float, target_y: float, + z: float, + offset: float, + label_prefix: str, + x_side: str, + y_side: str, + label_layer: int, + ext_segs: List[_Seg], + dim_segs: List[_Seg], + labels: List[Tuple[_Pt3, str]], + ) -> None: + """Построить геометрию и подписи для одной пары размеров X/Y.""" + dx = target_x - base_x + dy = target_y - base_y + label_z = z + 10.0 + (label_layer * _LABEL_Z_LAYER_STEP) + + # Размерная линия X и сторона выноски/подписи. + if abs(dx) > 0.5: + if x_side == "top": + y_dim = max(base_y, target_y) + offset + ext_end_y = y_dim + _EXTENSION_OVERSHOOT + label_y = y_dim + _LABEL_CLEARANCE + else: + y_dim = min(base_y, target_y) - offset + ext_end_y = y_dim - _EXTENSION_OVERSHOOT + label_y = y_dim - _LABEL_CLEARANCE + p1 = (base_x, y_dim, z) + p2 = (target_x, y_dim, z) + ext_segs.append(((base_x, base_y, z), (base_x, ext_end_y, z))) + ext_segs.append(((target_x, target_y, z), (target_x, ext_end_y, z))) + dim_segs.append((p1, p2)) + mid = ((p1[0] + p2[0]) / 2, label_y, label_z) + labels.append((mid, f"{label_prefix}{abs(dx):.0f} mm")) + + # Размерная линия Y и сторона выноски/подписи. + if abs(dy) > 0.5: + if y_side == "right": + x_dim = max(base_x, target_x) + offset + ext_end_x = x_dim + _EXTENSION_OVERSHOOT + label_x = x_dim + _LABEL_CLEARANCE + else: + x_dim = min(base_x, target_x) - offset + ext_end_x = x_dim - _EXTENSION_OVERSHOOT + label_x = x_dim - _LABEL_CLEARANCE + p1 = (x_dim, base_y, z) + p2 = (x_dim, target_y, z) + ext_segs.append(((base_x, base_y, z), (ext_end_x, base_y, z))) + ext_segs.append(((target_x, target_y, z), (ext_end_x, target_y, z))) + dim_segs.append((p1, p2)) + mid = (label_x, (p1[1] + p2[1]) / 2, label_z) + labels.append((mid, f"{label_prefix}{abs(dy):.0f} mm")) + + def _add_lines_actor( + self: "ModelViewWidget", + segments: List[_Seg], + color: str, + width: float, + ) -> None: + """Объединить все отрезки в один PolyData и добавить 1 actor.""" + n = len(segments) + pts = np.empty((n * 2, 3), dtype=np.float64) + cells = np.empty(n * 3, dtype=np.int64) + for i, (a, b) in enumerate(segments): + j = i * 2 + pts[j] = a + pts[j + 1] = b + k = i * 3 + cells[k] = 2 + cells[k + 1] = j + cells[k + 2] = j + 1 + poly = pv.PolyData() + poly.points = pts + poly.lines = cells + actor = self._plotter.add_mesh( + poly, + color=color, + line_width=width, + pickable=False, + reset_camera=False, + ) + self._dim_actors.append(actor) + + def _add_labels_actor( + self: "ModelViewWidget", + labels: List[Tuple[_Pt3, str]], + text_color: str, + ) -> None: + """Добавить все подписи одним вызовом add_point_labels.""" + positions = [lbl[0] for lbl in labels] + texts = [lbl[1] for lbl in labels] + actor = self._plotter.add_point_labels( + positions, + texts, + font_size=_FONT_SIZE, + text_color=text_color, + shape=None, + show_points=False, + always_visible=True, + pickable=False, + reset_camera=False, + ) + self._dim_actors.append(actor) + + def _show_dim_highlight( + self: "ModelViewWidget", x: float, y: float, z: float, + ) -> None: + """Показать подсвечивающую сферу на узле сетки.""" + self._clear_dim_highlight() + if not self._plotter or pv is None: + return + sphere = pv.Sphere( + radius=_HIGHLIGHT_RADIUS, + center=(x, y, z), + theta_resolution=8, + phi_resolution=8, + ) + self._dim_highlight_actor = self._plotter.add_mesh( + sphere, + color=_HIGHLIGHT_COLOR, + opacity=_HIGHLIGHT_OPACITY, + pickable=False, + reset_camera=False, + ) + + def _clear_dim_highlight(self: "ModelViewWidget") -> None: + if self._dim_highlight_actor and self._plotter: + try: + self._plotter.remove_actor(self._dim_highlight_actor) + except Exception as e: + log_exception(__name__, "_clear_dim_highlight", e) + self._dim_highlight_actor = None + + def _clear_dim_actors(self: "ModelViewWidget") -> None: + """Удалить все actors размерных линий из сцены.""" + if not self._plotter: + return + for actor in self._dim_actors: + try: + self._plotter.remove_actor(actor) + except Exception as e: + log_exception(__name__, "_clear_dim_actors", e) + self._dim_actors = [] + + +# --------------------------------------------------------------------------- +# Module workflow notes +# --------------------------------------------------------------------------- +# 1) Задача модуля: +# Базовая отрисовка размерных линий по сетке в режиме построения контура. +# Модуль отслеживает ближайший узел сетки к курсору, строит размерные линии +# от опорной точки (origin) до текущего узла и показывает подсветку узла. +# +# 2) Последовательность действий и вызовов: +# +# A. Инициализация состояния: +# init_dimension_lines() +# Назначение: подготовить все поля состояния размерных линий. +# Состояние: очищает списки актеров, сбрасывает origin/текущий узел, +# выключает режим отображения и обнуляет параметры перерисовки. +# +# B. Включение и выключение: +# enable_dimension_lines(origin) +# Назначение: включить показ линий и зафиксировать опорную точку. +# -> init_dimension_lines() +# -> _dim_origin = origin, _dim_enabled = True +# +# disable_dimension_lines() +# Назначение: полностью отключить модуль и очистить визуалы. +# -> _clear_dim_actors() +# -> _clear_dim_highlight() +# -> сброс _dim_enabled/_dim_origin/_dim_last_point/_dim_current_node +# +# C. Наведение курсора: +# handle_dim_hover(x, y) +# Назначение: найти ближайший узел сетки и обновить геометрию. +# Шаги: +# 1) проверяет, что режим активен и задан origin; +# 2) применяет ограничение частоты перерисовки (_THROTTLE_S); +# 3) вычисляет ближайший узел в _grid_nodes; +# 4) проверяет порог привязки (доля шага сетки); +# 5) если узел изменился -> _redraw_dim_lines(). +# +# D. Перерисовка размерных линий: +# _redraw_dim_lines() +# Назначение: обновить все линии/подписи для текущего узла. +# -> _clear_dim_actors() +# -> _collect_dimension_pair(...) +# Формирует сегменты X/Y и подписи размеров. +# -> _add_lines_actor(...) +# Добавляет сегменты в сцену одним PolyData-актором. +# -> _add_labels_actor(...) +# Добавляет подписи одним вызовом add_point_labels. +# -> _show_dim_highlight(...) +# Показывает сферу подсветки текущего узла. +# -> _safe_render(...) или render() +# +# E. Обновление последней точки контура: +# set_dim_last_point(pt) +# Назначение: синхронизировать модуль с текущим состоянием контура. +# Если курсор уже привязан к узлу, выполняется _redraw_dim_lines(). +# +# 3) Важные ограничения: +# - Модуль работает только с узлами сетки (_grid_nodes), не с bbox объемов. +# - Геометрия рисуется только при доступных self._plotter, pyvista и numpy. +# - Список ext_segs собирается как часть расчета, но в текущей версии +# намеренно не выводится отдельным актором. diff --git a/Dispatch_V0.1.1/gui/components/model_view/_mv_dim_lines_volume.py b/Dispatch_V0.1.1/gui/components/model_view/_mv_dim_lines_volume.py new file mode 100644 index 0000000..9919acf --- /dev/null +++ b/Dispatch_V0.1.1/gui/components/model_view/_mv_dim_lines_volume.py @@ -0,0 +1,1100 @@ +# -*- coding: utf-8 -*- +# gui/components/model_view/_mv_dim_lines_volume.py +"""Методы режима измерения для размерных линий.""" + +from __future__ import annotations + +from typing import Optional, Tuple, TYPE_CHECKING + +from PySide6.QtGui import QCursor + +if TYPE_CHECKING: # pragma: no cover + from gui.components.model_view_widget import ModelViewWidget + +try: + import pyvista as pv +except ImportError: # pragma: no cover + pv = None + +from gui.components.model_view._mv_dimension_lines import ( + _DIM_LINE_COLOR_ORIGIN, + _DIM_LINE_COLOR_LAST, + _MEASURE_SNAP_MM, + _MEASURE_SNAP_PX, + _MEASURE_LABEL_COLOR, + _MEASURE_HIGHLIGHT_COLOR, + _MEASURE_HIGHLIGHT_OPACITY, +) + +from error_logger import log_exception + + +class DimLineMeasureMixin: + """Миксин: наложение режима измерения для ModelViewWidget.""" + + # ================================================================== + # Режим измерения (кнопка зоны/инструмента «Измерить») + # ================================================================== + + def start_measure_mode( + self: "ModelViewWidget", + *, + view_level: str | None = None, + zone_id: str | None = None, + rack_id: str | None = None, + ) -> None: + self._measure_point_a = None + self._measure_point_b = None + self._measure_hover_node = None + self._measure_scope_level = str(view_level or "facility").strip().lower() + self._measure_scope_zone_id = str(zone_id or "") + self._measure_scope_rack_id = str(rack_id or "") + self._build_measure_nodes_cache() + self._clear_measure_actors() + self._clear_measure_highlight() + # Сценарий взаимодействия + mgr = getattr(self, "_interaction_manager", None) + if mgr is not None: + from gui.components.model_view._scenario_custom_handler import CustomHandlerScenario + mgr.push(CustomHandlerScenario( + name="measure", + click_handler=self._on_measure_click, + hover_screen_handler=self._on_measure_hover_screen, + )) + + def stop_measure_mode(self: "ModelViewWidget", *, pop_im_scenario: bool = True) -> None: + # Убрать сценарий взаимодействия + mgr = getattr(self, "_interaction_manager", None) + if mgr is not None and bool(pop_im_scenario): + mgr.pop_by_name("measure") + self._measure_point_a = None + self._measure_point_b = None + self._measure_hover_node = None + self._measure_nodes = [] + self._measure_scope_level = "" + self._measure_scope_zone_id = "" + self._measure_scope_rack_id = "" + self._clear_measure_actors() + self._clear_measure_highlight() + + def _build_measure_nodes_cache(self: "ModelViewWidget") -> None: + nodes: list[tuple[float, float, float]] = [] + ref = getattr(self, "_measure_ref_point", (0.0, 0.0, 0.0)) + nodes.append((float(ref[0]), float(ref[1]), float(ref[2]))) + level = str(getattr(self, "_measure_scope_level", "") or "facility").strip().lower() + if level == "facility": + self._append_visible_facility_bbox_nodes(nodes) + self._append_visible_zone_bbox_nodes(nodes, include_hidden=True) + elif level == "zone": + self._append_visible_rack_bbox_nodes( + nodes, + zone_filter=str(getattr(self, "_measure_scope_zone_id", "") or ""), + ) + elif level == "rack": + self._append_rack_height_nodes( + nodes, + rack_filter=str( + getattr(self, "_measure_scope_rack_id", "") + or getattr(self, "_shelf_target_rack_id", "") + or "" + ), + ) + else: + self._append_visible_shelf_cell_volume_nodes( + nodes, + rack_filter=str( + getattr(self, "_measure_scope_rack_id", "") + or getattr(self, "_shelf_target_rack_id", "") + or "" + ), + only_selected=bool(level == "shelf"), + ) + if len(nodes) <= 1: + # Facility/global fallback when no object bbox is available. + for pt in getattr(self, "_corner_points", []) or []: + try: + nodes.append((float(pt[0]), float(pt[1]), float(pt[2]))) + except Exception as e: + log_exception(__name__, "_build_measure_nodes_cache.corner_point", e) + + uniq: list[tuple[float, float, float]] = [] + seen: set[tuple[float, float, float]] = set() + for x, y, z in nodes: + key = (round(x, 1), round(y, 1), round(z, 1)) + if key in seen: + continue + seen.add(key) + uniq.append((x, y, z)) + self._measure_nodes = uniq + + @staticmethod + def _is_actor_visible(actor) -> bool: + if actor is None: + return False + try: + return bool(actor.GetVisibility()) + except Exception as e: + log_exception(__name__, "_is_actor_visible.get_visibility", e) + return False + + @staticmethod + def _append_bbox_nodes( + nodes: list[tuple[float, float, float]], + min_x: float, + max_x: float, + min_y: float, + max_y: float, + min_z: float, + max_z: float, + ) -> None: + for x in (float(min_x), float(max_x)): + for y in (float(min_y), float(max_y)): + for z in (float(min_z), float(max_z)): + nodes.append((x, y, z)) + + def _append_visible_zone_bbox_nodes( + self: "ModelViewWidget", + nodes: list[tuple[float, float, float]], + *, + include_hidden: bool = False, + ) -> None: + zones = getattr(self, "_zones", {}) or {} + zone_data = getattr(self, "_zone_data", {}) or {} + for zid, bounds in zone_data.items(): + actor = zones.get(str(zid)) + if not include_hidden: + if actor is None or not self._is_actor_visible(actor): + continue + if not self._append_zone_contour_nodes(nodes, str(zid)): + self._append_zone_bbox_nodes(nodes, str(zid), bounds_override=bounds) + + def _append_zone_contour_nodes( + self: "ModelViewWidget", + nodes: list[tuple[float, float, float]], + zone_id: str, + ) -> bool: + polygon = list((getattr(self, "_zone_polygons", {}) or {}).get(str(zone_id), []) or []) + if len(polygon) < 3: + return False + start_h, height = (getattr(self, "_zone_heights", {}) or {}).get(str(zone_id), (0.0, 0.0)) + try: + z_min = float(start_h) + z_max = float(start_h) + float(height) + except Exception as e: + log_exception(__name__, "_append_zone_contour_nodes.parse_heights", e) + return False + if z_max < z_min: + z_min, z_max = z_max, z_min + z_mid = (z_min + z_max) / 2.0 + n = len(polygon) + for idx in range(n): + x1, y1 = polygon[idx] + x2, y2 = polygon[(idx + 1) % n] + try: + x1f, y1f = float(x1), float(y1) + x2f, y2f = float(x2), float(y2) + except Exception as e: + log_exception(__name__, "_append_zone_contour_nodes.parse_xy", e) + continue + mx = (x1f + x2f) / 2.0 + my = (y1f + y2f) / 2.0 + for z in (z_min, z_mid, z_max): + nodes.append((x1f, y1f, z)) + nodes.append((mx, my, z)) + return True + + @staticmethod + def _append_rect_perimeter_nodes( + nodes: list[tuple[float, float, float]], + min_x: float, + max_x: float, + min_y: float, + max_y: float, + z_values: list[float], + ) -> None: + mid_x = (float(min_x) + float(max_x)) / 2.0 + mid_y = (float(min_y) + float(max_y)) / 2.0 + perimeter_xy = ( + (float(min_x), float(min_y)), + (float(max_x), float(min_y)), + (float(max_x), float(max_y)), + (float(min_x), float(max_y)), + (mid_x, float(min_y)), + (float(max_x), mid_y), + (mid_x, float(max_y)), + (float(min_x), mid_y), + ) + for z in z_values: + zf = float(z) + for x, y in perimeter_xy: + nodes.append((float(x), float(y), zf)) + + def _append_mesh_contour_nodes( + self: "ModelViewWidget", + nodes: list[tuple[float, float, float]], + mesh, + ) -> bool: + if pv is None or mesh is None: + return False + try: + surface = mesh.extract_surface() + edges = surface.feature_edges( + boundary_edges=True, + feature_edges=True, + manifold_edges=False, + non_manifold_edges=True, + feature_angle=20.0, + ) + points = getattr(edges, "points", None) + if points is None or len(points) == 0: + return False + for pt in points: + nodes.append((float(pt[0]), float(pt[1]), float(pt[2]))) + return True + except Exception as e: + log_exception(__name__, "_append_mesh_contour_nodes.extract_edges", e) + return False + + def _append_visible_facility_bbox_nodes(self: "ModelViewWidget", nodes: list[tuple[float, float, float]]) -> None: + model_actors = getattr(self, "_model_actors", {}) or {} + walls_actor = model_actors.get("walls") + walls_bounds = None + if self._is_actor_visible(walls_actor): + try: + wb = walls_actor.GetBounds() + if wb and len(wb) >= 6: + walls_bounds = tuple(float(v) for v in wb[:6]) + except Exception as e: + log_exception(__name__, "_append_visible_facility_bbox_nodes.walls_bounds", e) + walls_bounds = None + + for key in ("floor", "walls", "ceiling", "truss"): + actor = model_actors.get(key) + if not self._is_actor_visible(actor): + continue + try: + bounds = actor.GetBounds() + except Exception as e: + log_exception(__name__, "_append_visible_facility_bbox_nodes.get_bounds", e) + continue + if not bounds or len(bounds) < 6: + continue + try: + min_x, max_x, min_y, max_y, min_z, max_z = [float(v) for v in bounds[:6]] + except Exception as e: + log_exception(__name__, "_append_visible_facility_bbox_nodes.parse_bounds", e) + continue + + if key == "floor": + # Привязка к полу: верхняя поверхность + торцы только по внешнему контуру + # сопряжения со стенами (без внутренней структуры модели). + px0, px1, py0, py1 = min_x, max_x, min_y, max_y + if walls_bounds is not None: + px0 = max(px0, float(walls_bounds[0])) + px1 = min(px1, float(walls_bounds[1])) + py0 = max(py0, float(walls_bounds[2])) + py1 = min(py1, float(walls_bounds[3])) + if px1 <= px0 or py1 <= py0: + px0, px1, py0, py1 = min_x, max_x, min_y, max_y + self._append_rect_perimeter_nodes(nodes, px0, px1, py0, py1, [max_z, min_z]) + continue + + if key == "ceiling": + # Для потолка используем внешний контур (нижняя/верхняя кромка). + self._append_rect_perimeter_nodes(nodes, min_x, max_x, min_y, max_y, [min_z, max_z]) + continue + + if key == "walls": + walls_mesh = getattr(self, "_walls_mesh", None) + if self._append_mesh_contour_nodes(nodes, walls_mesh): + continue + + # Для остальных (или fallback) — углы габаритного контура. + self._append_bbox_nodes(nodes, min_x, max_x, min_y, max_y, min_z, max_z) + + def _append_visible_rack_bbox_nodes( + self: "ModelViewWidget", + nodes: list[tuple[float, float, float]], + *, + zone_filter: str = "", + ) -> None: + zid_filter = str(zone_filter or "") + for entry in getattr(self, "_rack_entries", []) or []: + if zid_filter and str(entry.get("zone_id", "") or "") != zid_filter: + continue + self._append_rack_bbox_nodes(nodes, entry) + + def _append_rack_height_nodes( + self: "ModelViewWidget", + nodes: list[tuple[float, float, float]], + *, + rack_filter: str = "", + ) -> None: + """Rack-level: узлы только по вертикали (база стойки + низы полок).""" + rid_filter = str(rack_filter or "") + for entry in getattr(self, "_rack_entries", []) or []: + rid = str(entry.get("rack_id") or "") + if rid_filter and rid != rid_filter: + continue + anchors_xy: list[tuple[float, float]] = [] + bounds_xy = None + if hasattr(self, "_rack_container_bbox"): + try: + bounds_xy = self._rack_container_bbox(entry) + except Exception as e: + log_exception(__name__, "_append_rack_height_nodes.container_bbox", e) + bounds_xy = None + if (not bounds_xy or len(bounds_xy) < 4): + raw_bbox = entry.get("bbox") + if raw_bbox and len(raw_bbox) >= 4: + try: + bounds_xy = tuple(float(v) for v in raw_bbox[:4]) + except Exception as e: + log_exception(__name__, "_append_rack_height_nodes.raw_bbox", e) + bounds_xy = None + if bounds_xy and len(bounds_xy) >= 4: + min_x, max_x, min_y, max_y = [float(v) for v in bounds_xy[:4]] + mid_x = (min_x + max_x) / 2.0 + mid_y = (min_y + max_y) / 2.0 + anchors_xy.extend([ + (min_x, min_y), + (max_x, min_y), + (max_x, max_y), + (min_x, max_y), + (mid_x, mid_y), + ]) + else: + center = entry.get("center") or (0.0, 0.0) + try: + anchors_xy.append((float(center[0]), float(center[1]))) + except Exception as e: + log_exception(__name__, "_append_rack_height_nodes.center_float", e) + anchors_xy.append((0.0, 0.0)) + + base_z = None + top_z = None + for actor in list(entry.get("actors") or []): + if actor is None or not self._is_actor_visible(actor): + continue + try: + bounds = actor.GetBounds() + except Exception as e: + log_exception(__name__, "_append_rack_height_nodes.actor_get_bounds", e) + continue + if not bounds or len(bounds) < 6: + continue + try: + b = tuple(float(v) for v in bounds[:6]) + except Exception as e: + log_exception(__name__, "_append_rack_height_nodes.actor_parse_bounds", e) + continue + base_z = float(b[4]) if base_z is None else float(min(base_z, b[4])) + top_z = float(b[5]) if top_z is None else float(max(top_z, b[5])) + + if base_z is None: + zone_id = str(entry.get("zone_id") or "") + base_z = float((getattr(self, "_zone_heights", {}) or {}).get(zone_id, (0.0, 0.0))[0]) + if top_z is None or float(top_z) <= float(base_z): + params = dict(entry.get("params") or {}) + if hasattr(self, "_rack_height_mm"): + try: + top_z = float(base_z) + max(1.0, float(self._rack_height_mm(params))) + except Exception as e: + log_exception(__name__, "_append_rack_height_nodes.rack_height", e) + top_z = float(base_z) + 2000.0 + else: + top_z = float(base_z) + 2000.0 + + z_levels: set[float] = {float(base_z), float(top_z)} + shelf_actors = getattr(self, "_rack_shelf_actors", {}) or {} + for (entry_rid, _slot_id), actors in shelf_actors.items(): + if str(entry_rid or "") != rid: + continue + for actor in list(actors or []): + if actor is None or not self._is_actor_visible(actor): + continue + try: + bounds = actor.GetBounds() + except Exception as e: + log_exception(__name__, "_append_rack_height_nodes.shelf_get_bounds", e) + continue + if not bounds or len(bounds) < 6: + continue + try: + z_levels.add(float(bounds[4])) + except Exception as e: + log_exception(__name__, "_append_rack_height_nodes.shelf_z_level", e) + continue + + for x, y in anchors_xy: + for z in sorted(z_levels): + nodes.append((float(x), float(y), float(z))) + + def _append_visible_shelf_bbox_nodes( + self: "ModelViewWidget", + nodes: list[tuple[float, float, float]], + *, + rack_filter: str = "", + ) -> None: + rid_filter = str(rack_filter or "") + shelf_actors = getattr(self, "_rack_shelf_actors", {}) or {} + for (entry_rid, _slot_id), actors in shelf_actors.items(): + if rid_filter and str(entry_rid or "") != rid_filter: + continue + for actor in list(actors or []): + if not self._is_actor_visible(actor): + continue + try: + bounds = actor.GetBounds() + except Exception as e: + log_exception(__name__, "_append_visible_shelf_bbox_nodes.get_bounds", e) + continue + if not bounds or len(bounds) < 6: + continue + try: + min_x, max_x, min_y, max_y, min_z, max_z = [float(v) for v in bounds[:6]] + except Exception as e: + log_exception(__name__, "_append_visible_shelf_bbox_nodes.parse_bounds", e) + continue + self._append_bbox_nodes(nodes, min_x, max_x, min_y, max_y, min_z, max_z) + + def _append_visible_shelf_cell_volume_nodes( + self: "ModelViewWidget", + nodes: list[tuple[float, float, float]], + *, + rack_filter: str = "", + only_selected: bool = False, + ) -> None: + rid_filter = str(rack_filter or "") + selected_rid = str(getattr(self, "_selected_shelf_visual_rack_id", "") or "") + selected_sid = str(getattr(self, "_selected_shelf_visual_slot_id", "") or "") + selected_idx = max(1, int(getattr(self, "_selected_shelf_visual_index", 1) or 1)) + + shelf_actors = getattr(self, "_rack_shelf_actors", {}) or {} + for (entry_rid, slot_id), actors in shelf_actors.items(): + rid = str(entry_rid or "") + sid = str(slot_id or "") + if rid_filter and rid != rid_filter: + continue + if only_selected and (rid != selected_rid or sid != selected_sid): + continue + + payload = dict((getattr(self, "_rack_shelf_params", {}) or {}).get((rid, sid)) or {}) + for actor_index, actor in enumerate(list(actors or []), start=1): + if not self._is_actor_visible(actor): + continue + if only_selected and actor_index != selected_idx: + continue + bounds = self._measure_shelf_cell_volume_bounds( + rid, + sid, + shelf_index=actor_index, + payload=payload, + actor=actor, + ) + if bounds is None: + bounds = self._actor_bbox_bounds(actor) + if bounds is None: + continue + self._append_bbox_nodes(nodes, *bounds) + + @staticmethod + def _actor_bbox_bounds(actor) -> tuple[float, float, float, float, float, float] | None: + if actor is None: + return None + try: + bounds = actor.GetBounds() + except Exception as e: + log_exception(__name__, "_actor_bbox_bounds.get_bounds", e) + return None + if not bounds or len(bounds) < 6: + return None + try: + return tuple(float(v) for v in bounds[:6]) + except Exception as e: + log_exception(__name__, "_actor_bbox_bounds.float_conversion", e) + return None + + def _measure_shelf_cell_volume_bounds( + self: "ModelViewWidget", + rack_id: str, + slot_id: str, + *, + shelf_index: int, + payload: dict, + actor, + ) -> tuple[float, float, float, float, float, float] | None: + resolver = getattr(self, "_resolve_cell_volume_limits", None) + if not callable(resolver): + return None + + data = dict(payload or {}) + data["shelf_number_index"] = int(max(1, int(shelf_index or 1))) + if not str(data.get("shelf_id") or ""): + data["shelf_id"] = "__measure__" + + try: + limits = resolver(str(rack_id or ""), str(slot_id or ""), data) + except Exception as e: + log_exception(__name__, "_measure_shelf_cell_volume_bounds.resolve_limits", e) + limits = None + if limits is None: + return None + + try: + min_x, max_x, min_y, max_y, base_z, top_z = [float(v) for v in limits[:6]] + except Exception as e: + log_exception(__name__, "_measure_shelf_cell_volume_bounds.parse_limits", e) + return None + + thickness = self._measure_shelf_thickness_mm( + str(rack_id or ""), + str(slot_id or ""), + shelf_index=int(max(1, int(shelf_index or 1))), + payload=data, + actor=actor, + base_z=float(base_z), + ) + min_z = float(base_z - max(0.0, float(thickness))) + if min_z > base_z: + min_z = float(base_z) + return (float(min_x), float(max_x), float(min_y), float(max_y), float(min_z), float(top_z)) + + def _measure_shelf_thickness_mm( + self: "ModelViewWidget", + rack_id: str, + slot_id: str, + *, + shelf_index: int, + payload: dict, + actor, + base_z: float, + ) -> float: + actor_bounds = self._actor_bbox_bounds(actor) + if actor_bounds is not None: + actor_min_z = float(actor_bounds[4]) + if base_z > actor_min_z: + return float(base_z - actor_min_z) + + rack_type = str(payload.get("rack_type") or "").strip().upper() + if not rack_type: + getter = getattr(self, "_get_rack_entry", None) + if callable(getter): + entry = getter(str(rack_id or ""), getattr(self, "_shelf_target_zone_id", None)) + if entry is None: + entry = getter(str(rack_id or "")) + rack_type = str(((entry or {}).get("params") or {}).get("rack_type") or "").strip().upper() + + norms_getter = getattr(self, "_shelf_norms_for_rack_type", None) + if callable(norms_getter): + try: + norms = dict(norms_getter(rack_type or "PALLET") or {}) + except Exception as e: + log_exception(__name__, "_measure_shelf_thickness_mm.get_norms", e) + norms = {} + try: + thickness = float(norms.get("shelf_height_mm") or 0.0) + except Exception as e: + log_exception(__name__, "_measure_shelf_thickness_mm.parse_thickness", e) + thickness = 0.0 + if thickness > 0.0: + return float(thickness) + + if actor_bounds is not None: + return float(max(0.0, float(actor_bounds[5]) - float(actor_bounds[4]))) + return 0.0 + + def _append_zone_bbox_nodes( + self: "ModelViewWidget", + nodes: list[tuple[float, float, float]], + zone_id: str, + *, + bounds_override: tuple | list | None = None, + ) -> None: + bounds = bounds_override + if bounds is None: + bounds = (getattr(self, "_zone_data", {}) or {}).get(str(zone_id)) + if not bounds or len(bounds) < 6: + return + try: + min_x, max_x, min_y, max_y, min_z, max_z = [float(v) for v in bounds[:6]] + except Exception as e: + log_exception(__name__, "_append_zone_bbox_nodes.parse_bounds", e) + return + self._append_bbox_nodes(nodes, min_x, max_x, min_y, max_y, min_z, max_z) + + def _append_rack_bbox_nodes(self: "ModelViewWidget", nodes: list[tuple[float, float, float]], entry: dict) -> None: + # Zone-level measurement must use real transformed STL actor bounds. + slot_actor_ids = { + id(actor) + for actor in ((entry.get("slot_actors") or {}).values()) + if actor is not None + } + bounds_acc: tuple[float, float, float, float, float, float] | None = None + for actor in list(entry.get("actors") or []): + if actor is None or id(actor) in slot_actor_ids: + continue + if not self._is_actor_visible(actor): + continue + try: + bounds = actor.GetBounds() + except Exception as e: + log_exception(__name__, "_append_rack_bbox_nodes.get_bounds", e) + continue + if not bounds or len(bounds) < 6: + continue + try: + b = tuple(float(v) for v in bounds[:6]) + except Exception as e: + log_exception(__name__, "_append_rack_bbox_nodes.parse_bounds", e) + continue + if bounds_acc is None: + bounds_acc = b + else: + bounds_acc = ( + min(bounds_acc[0], b[0]), + max(bounds_acc[1], b[1]), + min(bounds_acc[2], b[2]), + max(bounds_acc[3], b[3]), + min(bounds_acc[4], b[4]), + max(bounds_acc[5], b[5]), + ) + if bounds_acc is None: + # Fallback: если STL-акторы недоступны/скрыты, используем контейнерный bbox стойки + # и восстанавливаем диапазон Z по видимым акторам или по высоте стойки. + xy_bounds = None + if hasattr(self, "_rack_container_bbox"): + try: + xy_bounds = self._rack_container_bbox(entry) + except Exception as e: + log_exception(__name__, "_append_rack_bbox_nodes.container_bbox", e) + xy_bounds = None + if (not xy_bounds or len(xy_bounds) < 4): + raw_bbox = entry.get("bbox") + if raw_bbox and len(raw_bbox) >= 4: + xy_bounds = tuple(float(v) for v in raw_bbox[:4]) + if not xy_bounds or len(xy_bounds) < 4: + return + min_x, max_x, min_y, max_y = [float(v) for v in xy_bounds[:4]] + + z_min = None + z_max = None + for actor in list(entry.get("actors") or []): + if actor is None or not self._is_actor_visible(actor): + continue + try: + bounds = actor.GetBounds() + except Exception as e: + log_exception(__name__, "_append_rack_bbox_nodes.fallback_get_bounds", e) + continue + if not bounds or len(bounds) < 6: + continue + try: + b = tuple(float(v) for v in bounds[:6]) + except Exception as e: + log_exception(__name__, "_append_rack_bbox_nodes.fallback_parse_bounds", e) + continue + z_min = float(b[4]) if z_min is None else float(min(z_min, b[4])) + z_max = float(b[5]) if z_max is None else float(max(z_max, b[5])) + + if z_min is None or z_max is None or float(z_max) <= float(z_min): + zone_id = str(entry.get("zone_id") or "") + z_ref = float((getattr(self, "_zone_heights", {}) or {}).get(zone_id, (0.0, 0.0))[0]) + params = dict(entry.get("params") or {}) + rack_height = 2000.0 + if hasattr(self, "_rack_height_mm"): + try: + rack_height = float(self._rack_height_mm(params)) + except Exception as e: + log_exception(__name__, "_append_rack_bbox_nodes.rack_height", e) + rack_height = 2000.0 + z_min = float(z_ref) + z_max = float(z_ref + max(1.0, rack_height)) + bounds_acc = (float(min_x), float(max_x), float(min_y), float(max_y), float(z_min), float(z_max)) + self._append_bbox_nodes(nodes, *bounds_acc) + + def _qt_screen_to_vtk_display(self: "ModelViewWidget", sx: float, sy: float) -> tuple[float, float]: + """Convert Qt logical coordinates to VTK display coordinates.""" + if not self._plotter: + return float(sx), float(sy) + try: + dpr = float(self._plotter.devicePixelRatio()) + except Exception as e: + log_exception(__name__, "_qt_screen_to_vtk_display.device_pixel_ratio", e) + dpr = 1.0 + sx_vtk = float(sx) * dpr + sy_vtk = float(sy) * dpr + ren_win = getattr(self._plotter, "ren_win", None) + if ren_win is not None: + try: + _w, h = ren_win.GetSize() + sy_vtk = float(h) - sy_vtk - 1.0 + except Exception as e: + log_exception(__name__, "_qt_screen_to_vtk_display.ren_win_size", e) + return sx_vtk, sy_vtk + + def _resolve_measure_node( + self: "ModelViewWidget", + sx: float, + sy: float, + *, + world_hint: Optional[Tuple[float, float, float]] = None, + ) -> Optional[Tuple[float, float, float]]: + """Unified node resolver for measure mode across hover and click flows.""" + if not self._measure_nodes: + return None + sx_vtk, sy_vtk = self._qt_screen_to_vtk_display(float(sx), float(sy)) + best_screen = None + best_screen_d2 = None + for nx, ny, nz in self._measure_nodes: + disp = self.world_to_display(nx, ny, nz) + if disp is None: + continue + dx = float(disp[0]) - sx_vtk + dy = float(disp[1]) - sy_vtk + d2 = dx * dx + dy * dy + if best_screen_d2 is None or d2 < best_screen_d2: + best_screen_d2 = d2 + best_screen = (nx, ny, nz) + if best_screen is not None and best_screen_d2 is not None and best_screen_d2 <= (_MEASURE_SNAP_PX ** 2): + return best_screen + picked = self.pick_world(float(sx_vtk), float(sy_vtk)) + if picked is None and world_hint is not None: + picked = world_hint + if picked is not None: + px, py, pz = picked + return self._resolve_measure_node_from_world(float(px), float(py), float(pz)) + return None + + def _resolve_measure_node_from_world( + self: "ModelViewWidget", + px: float, + py: float, + pz: float, + ) -> Optional[Tuple[float, float, float]]: + if not self._measure_nodes: + return None + best = None + best_d2 = None + for nx, ny, nz in self._measure_nodes: + d2 = (nx - px) ** 2 + (ny - py) ** 2 + (nz - pz) ** 2 + if best_d2 is None or d2 < best_d2: + best_d2 = d2 + best = (nx, ny, nz) + if best is not None and best_d2 is not None and best_d2 <= (_MEASURE_SNAP_MM ** 2): + return best + return None + + def _coerce_rack_vertical_node( + self: "ModelViewWidget", + node: Optional[Tuple[float, float, float]], + anchor: Optional[Tuple[float, float, float]], + ) -> Optional[Tuple[float, float, float]]: + """Rack-scope: привязать вторую точку к XY первой точки (только высота).""" + if node is None or anchor is None: + return node + level = str(getattr(self, "_measure_scope_level", "") or "").strip().lower() + if level != "rack": + return node + ax, ay, _az = anchor + nz_target = float(node[2]) + candidates = [ + (nx, ny, nz) + for nx, ny, nz in (getattr(self, "_measure_nodes", []) or []) + if abs(float(nx) - float(ax)) <= 0.5 and abs(float(ny) - float(ay)) <= 0.5 + ] + if not candidates: + return (float(ax), float(ay), nz_target) + best = min(candidates, key=lambda pt: abs(float(pt[2]) - nz_target)) + return (float(best[0]), float(best[1]), float(best[2])) + + def _on_measure_hover_screen(self: "ModelViewWidget", sx: float, sy: float) -> None: + if not self._interaction_manager.is_active("measure"): + return + node = self._resolve_measure_node(float(sx), float(sy)) + if self._measure_point_a is not None: + node = self._coerce_rack_vertical_node(node, self._measure_point_a) + if node == self._measure_hover_node: + return + self._measure_hover_node = node + if node is None: + self._clear_measure_highlight() + else: + self._show_measure_highlight(*node) + if self._measure_point_a is not None: + self._draw_measure_projection(self._measure_point_a, node) + + def _on_measure_click(self: "ModelViewWidget", x: float, y: float, z: float) -> bool: + if not self._interaction_manager.is_active("measure"): + return False + world_hint: Optional[Tuple[float, float, float]] = None + if x is not None and y is not None and z is not None: + world_hint = (float(x), float(y), float(z)) + sx = 0.0 + sy = 0.0 + if self._plotter: + try: + cursor_global = QCursor.pos() + cursor_local = self._plotter.mapFromGlobal(cursor_global) + sx = float(cursor_local.x()) + sy = float(cursor_local.y()) + except Exception as e: + log_exception(__name__, "_on_measure_click.cursor_pos", e) + node = self._resolve_measure_node(sx, sy, world_hint=world_hint) + if node is None: + return True + if self._measure_point_a is None or self._measure_point_b is not None: + self._measure_point_a = node + self._measure_point_b = None + self._draw_measure_projection(self._measure_point_a, self._measure_hover_node) + return True + node = self._coerce_rack_vertical_node(node, self._measure_point_a) + self._measure_point_b = node + self._draw_measure_projection(self._measure_point_a, self._measure_point_b) + return True + + def _draw_measure_projection( + self: "ModelViewWidget", + p1: Optional[Tuple[float, float, float]], + p2: Optional[Tuple[float, float, float]], + ) -> None: + self._clear_measure_actors() + if not self._plotter or pv is None: + return + if p1 is None or p2 is None: + return + x1, y1, z1 = p1 + x2, y2, z2 = p2 + lx1, ly1 = self._to_measure_local(x1, y1) + lx2, ly2 = self._to_measure_local(x2, y2) + wx1, wy1 = self._from_measure_local(lx1, ly1) + wx2, wy_mid = self._from_measure_local(lx2, ly1) + wx_mid, wy2 = self._from_measure_local(lx2, ly2) + mid_x = (wx1 + wx2) / 2.0 + mid_y = (wy_mid + wy2) / 2.0 + line_x = pv.Line((wx1, wy1, z1 + 2.0), (wx2, wy_mid, z1 + 2.0)) + line_y = pv.Line((wx2, wy_mid, z1 + 2.0), (wx_mid, wy2, z1 + 2.0)) + line_z = pv.Line((wx_mid, wy2, z1 + 2.0), (wx_mid, wy2, z2 + 2.0)) + actor_x = self._plotter.add_mesh( + line_x, color=_DIM_LINE_COLOR_ORIGIN, line_width=2.5, reset_camera=False, pickable=False, + ) + actor_y = self._plotter.add_mesh( + line_y, color=_DIM_LINE_COLOR_LAST, line_width=2.5, reset_camera=False, pickable=False, + ) + actor_z = self._plotter.add_mesh( + line_z, color="#00C853", line_width=2.5, reset_camera=False, pickable=False, + ) + self._measure_actors.extend([actor_x, actor_y, actor_z]) + z_mid = (z1 + z2) / 2.0 + labels = [ + ((mid_x, y1, z1 + 20.0), f"{abs(x2 - x1):.0f} мм"), + ((wx2, mid_y, z1 + 20.0), f"{abs(y2 - y1):.0f} мм"), + ((wx_mid, wy2, z_mid + 20.0), f"{abs(z2 - z1):.0f} мм"), + ] + lab_actor = self._plotter.add_point_labels( + [labels[0][0], labels[1][0], labels[2][0]], + [labels[0][1], labels[1][1], labels[2][1]], + font_size=24, + text_color=_MEASURE_LABEL_COLOR, + shape=None, + show_points=False, + always_visible=True, + pickable=False, + reset_camera=False, + ) + self._measure_actors.append(lab_actor) + if hasattr(self, "_safe_render"): + self._safe_render(min_interval_s=0.05) + else: + self._plotter.update() + + def _show_measure_highlight(self: "ModelViewWidget", x: float, y: float, z: float) -> None: + self._clear_measure_highlight() + if not self._plotter or pv is None: + return + level = str(getattr(self, "_measure_scope_level", "") or "").strip().lower() + if level == "facility": + radius = 100.0 + elif level == "shelf": + radius = 10.0 + else: + radius = 25.0 + sphere = pv.Sphere( + radius=radius, + center=(x, y, z), + theta_resolution=12, + phi_resolution=12, + ) + self._measure_highlight_actor = self._plotter.add_mesh( + sphere, + color=_MEASURE_HIGHLIGHT_COLOR, + opacity=_MEASURE_HIGHLIGHT_OPACITY, + pickable=False, + reset_camera=False, + ) + + def _clear_measure_highlight(self: "ModelViewWidget") -> None: + actor = getattr(self, "_measure_highlight_actor", None) + if actor is not None and self._plotter: + try: + self._plotter.remove_actor(actor) + except Exception as e: + log_exception(__name__, "_clear_measure_highlight.remove_actor", e) + self._measure_highlight_actor = None + + def _clear_measure_actors(self: "ModelViewWidget") -> None: + if not self._plotter: + self._measure_actors = [] + return + for actor in self._measure_actors: + try: + self._plotter.remove_actor(actor) + except Exception as e: + log_exception(__name__, "_clear_measure_actors.remove_actor", e) + self._measure_actors = [] + + def _to_measure_local(self: "ModelViewWidget", x: float, y: float) -> tuple[float, float]: + ref = getattr(self, "_measure_ref_point", (0.0, 0.0, 0.0)) + direction = getattr(self, "_measure_direction", (1.0, 1.0)) + dx = float(direction[0]) if float(direction[0]) != 0.0 else 1.0 + dy = float(direction[1]) if float(direction[1]) != 0.0 else 1.0 + return (float(x) - float(ref[0])) / dx, (float(y) - float(ref[1])) / dy + + def _from_measure_local(self: "ModelViewWidget", lx: float, ly: float) -> tuple[float, float]: + ref = getattr(self, "_measure_ref_point", (0.0, 0.0, 0.0)) + direction = getattr(self, "_measure_direction", (1.0, 1.0)) + return float(ref[0]) + float(lx) * float(direction[0]), float(ref[1]) + float(ly) * float(direction[1]) + + +# --------------------------------------------------------------------------- +# Module workflow notes +# --------------------------------------------------------------------------- +# 1) Задача модуля: +# Единый режим «Измерить» для трёхмерной сцены. +# Модуль формирует набор измерительных узлов по доступным габаритным +# прямоугольным объёмам (зоны/стеллажи), привязывает курсор к ближайшему +# узлу и отображает проекционные размерные линии между двумя выбранными +# узлами. +# +# 2) Последовательность действий и вызовов: +# +# A. Включение режима измерения: +# start_measure_mode() +# Назначение: перевести модуль в активный режим измерения. +# Состояние: сбрасывает ранее выбранные +# точки измерения: +# _measure_point_a — первая фиксированная точка измерения, +# _measure_point_b — вторая фиксированная точка измерения, +# _measure_hover_node — текущий узел под курсором. +# -> _build_measure_nodes_cache() +# Назначение: подготовить актуальный список узлов привязки. +# Состав узлов: вершины габаритных прямоугольных объёмов доступных зон и стеллажей. +# -> _append_visible_zone_bbox_nodes() +# Назначение: добавить узлы только видимых зон. +# -> _append_zone_bbox_nodes() -> _append_bbox_nodes() +# Назначение: взять границы зоны и развернуть их в 8 угловых точек. +# -> _append_visible_rack_bbox_nodes() +# Назначение: добавить узлы только видимых стеллажей. +# -> _append_rack_bbox_nodes() -> _append_bbox_nodes() +# Назначение: построить 8 углов стеллажа по его bbox и высоте. +# -> _clear_measure_actors() +# Назначение: удалить со сцены прошлые размерные линии и подписи. +# -> _clear_measure_highlight() +# Назначение: удалить прошлый актор подсветки узла. +# -> push CustomHandlerScenario("measure", click_handler, hover_screen_handler) +# Назначение: активировать сценарий измерения в InteractionManager. +# +# B. Наведение курсора (перемещение мыши): +# _on_measure_hover_screen(sx, sy) +# Вход: экранные координаты курсора в системе Qt. +# Выход: обновление активного узла наведения и его подсветки. +# -> _resolve_measure_node(sx, sy) +# Назначение: найти ближайший допустимый узел привязки. +# -> _qt_screen_to_vtk_display(sx, sy) +# Назначение: привести координаты Qt к системе окна визуализации. +# -> world_to_display(...) для всех measure_nodes +# Назначение: вычислить расстояние курсора до каждого узла на экране. +# -> fallback: pick_world(...) +# Назначение: получить мировую точку под курсором, если экранная привязка не сработала. +# -> fallback: _resolve_measure_node_from_world(...) +# Назначение: выбрать ближайший узел по расстоянию в мировых координатах. +# -> если узел найден: +# -> _show_measure_highlight(x, y, z) +# Назначение: показать визуальный маркер узла (сфера подсветки). +# -> если узел не найден: +# -> _clear_measure_highlight() +# Назначение: убрать маркер, чтобы не показывать ложную привязку. +# -> если уже выбрана точка A: +# -> _draw_measure_projection(point_a, hover_node) +# Назначение: показывать предварительный результат измерения до второго щелчка. +# +# C. ЛКМ по узлу bbox: +# _on_measure_click(x, y, z) +# Вход: мировая точка щелчка из обработчика сцены. +# Логика: повторно определяет узел через общий путь _resolve_measure_node(...). +# -> _resolve_measure_node(cursor_x, cursor_y, world_hint=(x, y, z)) +# Назначение: гарантировать одинаковые правила привязки для наведения и щелчка. +# -> первый клик: +# point_a = node, point_b = None +# _draw_measure_projection(point_a, hover_node) +# Результат: фиксируется начальная точка измерения. +# -> второй клик: +# point_b = node +# _draw_measure_projection(point_a, point_b) +# Результат: фиксируется конечная точка, рисуется итоговое измерение. +# -> третий клик и далее: +# цикл начинается заново (новая point_a) +# Результат: предыдущая пара считается завершённой, начинается новое измерение. +# +# D. Отрисовка размерных линий: +# _draw_measure_projection(p1, p2) +# Вход: две мировые точки измерения. +# Выход: три проекционные линии (X, Y, Z) и текстовые подписи расстояний. +# -> _clear_measure_actors() +# -> _to_measure_local(...) / _from_measure_local(...) +# Назначение: перевод между локальной системой измерений и мировыми координатами. +# -> add_mesh(Line X/Y/Z), add_point_labels(...) +# -> _safe_render(...) или _plotter.update() +# Назначение: обновить кадр без полной перезагрузки сцены. +# +# E. Выключение режима: +# stop_measure_mode() +# Назначение: безопасно завершить измерение и вернуть стандартные обработчики. +# -> сброс point_a/point_b/hover_node/measure_nodes +# -> _clear_measure_actors() +# -> _clear_measure_highlight() +# -> pop_by_name("measure") +# +# 3) Важное замечание по координатам (координаты Qt и координаты VTK): +# Что было: +# - В одном участке кода использовались координаты мыши из Qt-события +# (логические пиксели, Y растет сверху вниз). +# - В другом участке использовались координаты окна визуализации VTK +# (физические пиксели, Y растет снизу вверх). +# - Эти системы не эквивалентны, особенно на DPI scaling (DPR != 1.0). +# +# Как проявлялась ошибка: +# - актор подсветки узла «уезжал» относительно курсора; +# - на некоторых позициях казалось, что наведение «инвертировано» по Y; +# - snap срабатывал на соседнем/неверном узле bbox. +# +# Почему это происходило: +# - сравнение расстояния курсор->узел делалось между значениями из разных +# систем координат (Qt logical против VTK display); +# - при этом методы выбора точки и перевода координат ожидали систему VTK. +# +# Принятое правило (обязательное для этого модуля): +# - любой расчёт по экранным координатам (наведение/щелчок с привязкой) +# сначала переводим в систему VTK через +# _qt_screen_to_vtk_display(sx, sy): +# 1) logical -> physical: умножить на DPR +# 2) инвертировать Y относительно высоты render window +# - только после этого выполняем: +# world_to_display(...) для расчета расстояния до узлов +# pick_world(...) как резервный путь через геометрию сцены +# +# Короткий алгоритм: +# Qt(sx, sy) -> _qt_screen_to_vtk_display -> (sx_vtk, sy_vtk) +# -> ближайший узел через world_to_display +# -> если не попали в порог привязки: pick_world(sx_vtk, sy_vtk) +# -> ближайший узел по расстоянию в мировых координатах. diff --git a/Dispatch_V0.1.1/gui/components/model_view/_mv_dimension_lines.py b/Dispatch_V0.1.1/gui/components/model_view/_mv_dimension_lines.py new file mode 100644 index 0000000..1261f38 --- /dev/null +++ b/Dispatch_V0.1.1/gui/components/model_view/_mv_dimension_lines.py @@ -0,0 +1,99 @@ +# -*- coding: utf-8 -*- +# gui/components/model_view/_mv_dimension_lines.py +"""Выносные размерные линии в 3D-сцене PyVista. + +Модуль хранит общие константы / типы и собирает финальный +``DimensionLinesMixin`` из двух подмиксинов: + +- ``DimLineCoreMixin`` — рисование размерных линий от origin +- ``DimLineMeasureMixin`` — интерактивный режим «Измерить» + +Подмиксины лежат в ``_mv_dim_lines_grid.py`` и +``_mv_dim_lines_volume.py`` и импортируют константы из этого файла. +""" + +from __future__ import annotations + +from typing import Tuple + +# ── Настройки отображения ──────────────────────────────────────────── +_DIM_LINE_COLOR_ORIGIN = "#FFD600" # жёлтый — от origin +_DIM_LINE_COLOR_LAST = "#00E5FF" # голубой — от последней точки +_DIM_TEXT_COLOR_ORIGIN = "#FFD600" +_DIM_TEXT_COLOR_LAST = "#00E5FF" +_EXTENSION_LINE_COLOR = "#AAAAAA" # серый — выносные линии +_LINE_WIDTH = 2.0 +_EXTENSION_LINE_WIDTH = 1.0 +_FONT_SIZE = 26 + +# Смещение первого и второго ряда размерных линий (мм). +_DIM_OFFSET_1 = 800.0 # от origin (ближний) +_DIM_OFFSET_2 = 1500.0 # от последней точки (дальний) +_EXTENSION_OVERSHOOT = 200.0 +_LABEL_CLEARANCE = 180.0 +_LABEL_Z_LAYER_STEP = 18.0 + +# Триггер привязки — доля шага сетки. +_SNAP_TRIGGER_FRACTION = 1.0 / 3.0 + +# Подсветка узла +_HIGHLIGHT_COLOR = "#FFFF00" +_HIGHLIGHT_RADIUS = 40.0 # мм +_HIGHLIGHT_OPACITY = 0.85 +_MEASURE_HIGHLIGHT_COLOR = "#00A3FF" +_MEASURE_HIGHLIGHT_RADIUS = 120.0 # мм +_MEASURE_HIGHLIGHT_OPACITY = 0.95 + +# Минимальный интервал между перерисовками (секунды). +_THROTTLE_S = 0.050 +_MEASURE_SNAP_MM = 450.0 +_MEASURE_SNAP_PX = 24.0 +_MEASURE_LABEL_COLOR = "#00E5FF" + + +# ── Вспомогательные типы ───────────────────────────────────────────── +_Pt3 = Tuple[float, float, float] +_Seg = Tuple[_Pt3, _Pt3] + + +# ── Импорт подмиксинов (после констант, т.к. они импортируют их) ───── +from gui.components.model_view._mv_dim_lines_grid import DimLineCoreMixin # noqa: E402 +from gui.components.model_view._mv_dim_lines_volume import DimLineMeasureMixin # noqa: E402 + + +class DimensionLinesMixin(DimLineCoreMixin, DimLineMeasureMixin): + """Mixin для ModelViewWidget: выносные размерные линии.""" + pass + + +# --------------------------------------------------------------------------- +# Module workflow notes +# --------------------------------------------------------------------------- +# 1) Задача модуля: +# Единый фасад подсистемы размерных линий model_view. +# Файл определяет общие константы отображения/привязки и собирает +# финальный DimensionLinesMixin из двух независимых подсистем: +# - DimLineCoreMixin (размеры по узлам сетки для контура), +# - DimLineMeasureMixin (измерение по bbox объемов). +# +# 2) Почему константы размещены именно здесь: +# - Оба подмодуля импортируют одинаковые параметры цветов, толщин, +# порогов привязки и типов данных. +# - Централизация исключает дублирование и расхождения между режимами. +# - Изменение параметра в одном месте автоматически применяет его в +# обоих сценариях (сетка и измерение объемов). +# +# 3) Последовательность использования: +# - ModelViewWidget наследует DimensionLinesMixin. +# - При работе с контуром используются методы из _mv_dim_lines_grid.py. +# - При включении режима «Измерить» используются методы из +# _mv_dim_lines_volume.py. +# - Внешние модули не должны напрямую импортировать подмиксины, если +# достаточно интерфейса DimensionLinesMixin. +# +# 4) Правило сопровождения: +# - Новые общие параметры и типы добавлять в этот файл. +# - Логику сценариев добавлять в профильный подмодуль (grid/volume), +# сохраняя этот файл как композиционный и конфигурационный центр. + + diff --git a/Dispatch_V0.1.1/gui/components/model_view/_mv_grid_core.py b/Dispatch_V0.1.1/gui/components/model_view/_mv_grid_core.py new file mode 100644 index 0000000..9c46934 --- /dev/null +++ b/Dispatch_V0.1.1/gui/components/model_view/_mv_grid_core.py @@ -0,0 +1,36 @@ +# -*- coding: utf-8 -*- +# gui/components/model_view/_mv_grid_core.py +# Тонкая композиция: объединяет базовые методы сетки + помощники геометрии пола + +from __future__ import annotations + +from gui.components.model_view._mv_grid_core_base import GridCoreBaseMixin +from gui.components.model_view._mv_grid_core_floor import GridCoreFloorMixin + + +class GridCoreMixin(GridCoreBaseMixin, GridCoreFloorMixin): + """Сетка на полу: создание, тема, геометрия пола, узлы сетки.""" + + _FAST_PREVIEW_CUBE_LIMIT = 200 + _MAX_VOLUME_HEIGHT = 6000.0 + + +# --------------------------------------------------------------------------- +# Module workflow notes +# --------------------------------------------------------------------------- +# 1) Задача модуля: +# Композиционный фасад ядра сетки, объединяющий базовый и геометрический уровни. +# +# 2) Последовательность действий и вызовов: +# A. Композиционный класс GridCoreMixin: +# Назначение: объединяет поведение через GridCoreBaseMixin, GridCoreFloorMixin. +# Собственная вычислительная логика отсутствует; маршрутизация идёт в родительские миксины. +# +# 3) Важные ограничения и инварианты: +# - Модуль выполняется в составе ModelViewWidget и использует согласованные поля состояния self._... . +# - Межмодульная связность: только через фасад model_view; прямые обращения между zone, rack, shelf, cell запрещены. +# - Очистка состояния должна быть идемпотентной: повторный вызов не меняет корректное состояние в ошибочное. +# +# 4) Правило сопровождения: +# - Любое изменение сценария должно сопровождаться обновлением этого блока с сохранением фактического порядка вызовов. +# - При добавлении метода указывать его место в цепочке сценария (запуск, основной шаг, завершение, вспомогательная логика). diff --git a/Dispatch_V0.1.1/gui/components/model_view/_mv_grid_core_base.py b/Dispatch_V0.1.1/gui/components/model_view/_mv_grid_core_base.py new file mode 100644 index 0000000..8f39044 --- /dev/null +++ b/Dispatch_V0.1.1/gui/components/model_view/_mv_grid_core_base.py @@ -0,0 +1,658 @@ +# -*- coding: utf-8 -*- +# gui/components/model_view/_mv_grid_core_base.py +# Основные методы сетки: тема, жизненный цикл, видимость, узлы, плоскость Z + +from __future__ import annotations + +import hashlib +import json +import math +from pathlib import Path +from typing import Optional, Tuple, TYPE_CHECKING, Callable + +from error_logger import log_exception + +if TYPE_CHECKING: # pragma: no cover + from gui.components.model_view_widget import ModelViewWidget + +try: + import pyvista as pv +except ImportError: # pragma: no cover + pv = None + + +class GridCoreBaseMixin: + """Сетка на полу: тема, создание, видимость, кэш, узлы.""" + + def _get_grid_color(self: "ModelViewWidget") -> tuple[int, int, int]: + theme = (getattr(self, "_theme", "dark") or "dark").lower() + if theme == "dark": + return (0, 0, 0) + return (180, 180, 180) + + def _apply_grid_theme(self: "ModelViewWidget") -> None: + """Перекрасить акторы сетки в соответствии с текущей темой.""" + color = self._get_grid_color() + rgb = (color[0] / 255.0, color[1] / 255.0, color[2] / 255.0) + actors = [] + actors.extend(getattr(self, "_grid_meshes", [])) + actors.extend(getattr(self, "_grid_surface_meshes", [])) + for actor in actors: + try: + prop = actor.GetProperty() + if prop: + prop.SetColor(*rgb) + except Exception as e: + log_exception(__name__, "_apply_grid_theme.set_color", e) + try: + if self._plotter: + self._plotter.update() + except Exception as e: + log_exception(__name__, "_apply_grid_theme.plotter_update", e) + + def start_zone_selection_grid( + self: "ModelViewWidget", + cell_size: int, + grid_origin: Optional[Tuple[float, float, float]] = None, + progress_callback: Optional[Callable[[int], None]] = None, + z_size: Optional[int] = None, + ): + """Включить режим выбора зоны через сетку на полу. + + Если сетка с такими же параметрами уже построена (``_grid_ready``), + повторное построение пропускается — переиспользуются существующие + акторы и данные ячеек. + """ + if not self._models_loaded or not self._plotter: + return + + if self._floor_mesh is not None: + min_x, max_x, min_y, max_y, min_z, _ = self._floor_mesh.bounds + elif self._room_bounds: + min_x, max_x, min_y, max_y, min_z, _ = self._room_bounds + else: + return + + z_size_int = int(z_size) if z_size is not None else int(cell_size) + + if grid_origin is not None: + self._grid_origin = grid_origin + grid_z = self._get_grid_plane_z(self._grid_origin) + + # -- Проверка возможности переиспользования сетки ------------------ + can_reuse = ( + self._grid_ready + and self._grid_cached_cell_size == int(cell_size) + and self._grid_cached_z_size == z_size_int + and self._grid_cells + and self._grid_nodes + ) + + if can_reuse: + # Сбрасываем только состояние выделения, сетку показываем заново + self._zone_selection_mode = True + self._current_zone_size = cell_size + self._current_zone_z_size = z_size_int + self._selected_cells = set() + self._selected_height = 0 + self._selection_anchor = None + self._selection_start_cell = None + self._volume_locked_from_contour = False + self._contour_points = [] + self._final_contour_points = [] + self._grid_surface_meshes = getattr(self, "_grid_surface_meshes", []) + self._contour_points_actor = None + self._contour_lines_actor = None + self._last_volume_start_height = grid_z + self._show_grid_actors() + if progress_callback: + try: + progress_callback(100) + except Exception as e: + log_exception(__name__, "start_zone_selection_grid.progress_callback", e) + return + + # -- Полное построение сетки (первый раз или параметры изменились) -- + self._zone_selection_mode = True + self._current_zone_size = cell_size + self._current_zone_z_size = z_size_int + self._selected_cells = set() + self._selected_height = 0 + self._selection_anchor = None + self._selection_start_cell = None + self._volume_locked_from_contour = False + self._contour_points = [] + self._final_contour_points = [] + self._grid_nodes = [] + self._grid_surface_meshes = [] + self._contour_points_actor = None + self._contour_lines_actor = None + self._last_volume_start_height = grid_z + + built = self.create_grid_on_floor( + min_x, max_x, min_y, max_y, grid_z, cell_size, + grid_origin=self._grid_origin, + progress_callback=progress_callback, + ) + if not built: + self._zone_selection_mode = False + self._grid_ready = False + self._grid_cached_cell_size = 0 + self._grid_cached_z_size = 0 + return + + # Обновляем кэш + self._grid_ready = True + self._grid_cached_cell_size = int(cell_size) + self._grid_cached_z_size = z_size_int + + def ensure_visible_grid( + self: "ModelViewWidget", + cell_size: int, + grid_origin: Optional[Tuple[float, float, float]] = None, + z_size: Optional[int] = None, + progress_callback: Optional[Callable[[int], None]] = None, + cache_path: Optional[str] = None, + cache_extra: Optional[dict] = None, + ) -> bool: + """Обеспечить постоянное отображение сетки без включения режима разметки.""" + if not self._models_loaded or not self._plotter: + return False + cell_size_int = int(cell_size) + z_size_int = int(z_size) if z_size is not None else int(cell_size_int) + cache_meta = self._build_grid_cache_meta( + cell_size=cell_size_int, + z_size=z_size_int, + grid_origin=grid_origin, + extra=cache_extra, + ) + try: + loaded_from_cache = False + if cache_path: + loaded_from_cache = self.load_grid_cache(str(cache_path), expected_meta=cache_meta) + if not loaded_from_cache: + self.start_zone_selection_grid( + cell_size_int, + grid_origin=grid_origin, + progress_callback=progress_callback, + z_size=z_size_int, + ) + if cache_path and self.has_grid(): + self.save_grid_cache(str(cache_path), meta=cache_meta) + else: + self._show_grid_actors() + + if not self.has_grid(): + return False + if self._zone_selection_mode: + self.cancel_zone_selection() + self._show_grid_actors() + return True + except Exception as e: + log_exception(__name__, "ensure_visible_grid.main", e) + return False + + def _mesh_signature(self: "ModelViewWidget", mesh) -> dict: + if mesh is None: + return {"present": False} + try: + bounds = tuple(float(v) for v in mesh.bounds) + except Exception as e: + log_exception(__name__, "_mesh_signature.bounds", e) + bounds = () + try: + n_points = int(getattr(mesh, "n_points", 0) or 0) + except Exception as e: + log_exception(__name__, "_mesh_signature.n_points", e) + n_points = 0 + try: + n_cells = int(getattr(mesh, "n_cells", 0) or 0) + except Exception as e: + log_exception(__name__, "_mesh_signature.n_cells", e) + n_cells = 0 + raw = f"{bounds}|{n_points}|{n_cells}".encode("utf-8", errors="ignore") + digest = hashlib.sha1(raw).hexdigest()[:16] + return { + "present": True, + "bounds": [round(float(v), 4) for v in bounds], + "n_points": n_points, + "n_cells": n_cells, + "sig": digest, + } + + def _build_grid_cache_meta( + self: "ModelViewWidget", + *, + cell_size: int, + z_size: int, + grid_origin: Optional[Tuple[float, float, float]], + extra: Optional[dict] = None, + ) -> dict: + origin = tuple(grid_origin or getattr(self, "_grid_origin", (0.0, 0.0, 0.0)) or (0.0, 0.0, 0.0)) + room_bounds = tuple(getattr(self, "_room_bounds", ()) or ()) + meta = { + "version": 1, + "cell_size": int(cell_size), + "z_size": int(z_size), + "grid_origin": [round(float(origin[0]), 4), round(float(origin[1]), 4), round(float(origin[2]), 4)], + "room_bounds": [round(float(v), 4) for v in room_bounds] if room_bounds else [], + "floor": self._mesh_signature(getattr(self, "_floor_mesh", None)), + "walls": self._mesh_signature(getattr(self, "_walls_mesh", None)), + } + if extra: + meta["extra"] = dict(extra) + return meta + + def make_grid_cache_meta( + self: "ModelViewWidget", + *, + cell_size: int, + z_size: int, + grid_origin: Optional[Tuple[float, float, float]] = None, + extra: Optional[dict] = None, + ) -> dict: + """Публичный конструктор метаданных кэша сетки.""" + return self._build_grid_cache_meta( + cell_size=int(cell_size), + z_size=int(z_size), + grid_origin=grid_origin, + extra=extra, + ) + + def _grid_cache_payload( + self: "ModelViewWidget", + meta: dict, + ) -> dict: + cells = [] + for cell_id, bounds in (getattr(self, "_grid_cells", {}) or {}).items(): + try: + mn_x, mx_x, mn_y, mx_y = bounds + cells.append([ + int(cell_id), + float(mn_x), + float(mx_x), + float(mn_y), + float(mx_y), + ]) + except Exception as e: + log_exception(__name__, "_grid_cache_payload.cell_convert", e) + continue + cells.sort(key=lambda item: item[0]) + return {"meta": meta, "cells": cells} + + def save_grid_cache(self: "ModelViewWidget", cache_path: str, meta: dict) -> bool: + """Сохранить текущую сетку в файл кэша.""" + if not self.has_grid(): + return False + try: + path = Path(str(cache_path)) + path.parent.mkdir(parents=True, exist_ok=True) + payload = self._grid_cache_payload(meta) + path.write_text(json.dumps(payload, ensure_ascii=False, separators=(",", ":")), encoding="utf-8") + return True + except Exception as e: + log_exception(__name__, "save_grid_cache.write", e) + return False + + def _render_grid_actor_from_cells( + self: "ModelViewWidget", + *, + min_z: float, + ) -> bool: + if pv is None or not self._plotter: + return False + try: + for mesh in list(getattr(self, "_grid_meshes", [])): + try: + self._plotter.remove_actor(mesh) + except Exception as e: + log_exception(__name__, "_render_grid_actor_from_cells.remove_actor", e) + self._grid_meshes = [] + line_points: list[list[float]] = [] + line_cells: list[int] = [] + + def add_segment(p1: tuple[float, float, float], p2: tuple[float, float, float]) -> None: + i1 = len(line_points) + line_points.append([float(p1[0]), float(p1[1]), float(p1[2])]) + i2 = len(line_points) + line_points.append([float(p2[0]), float(p2[1]), float(p2[2])]) + line_cells.extend([2, i1, i2]) + + for mn_x, mx_x, mn_y, mx_y in (getattr(self, "_grid_cells", {}) or {}).values(): + p00 = (mn_x, mn_y, min_z) + p10 = (mx_x, mn_y, min_z) + p11 = (mx_x, mx_y, min_z) + p01 = (mn_x, mx_y, min_z) + add_segment(p00, p10) + add_segment(p10, p11) + add_segment(p11, p01) + add_segment(p01, p00) + if not line_points or not line_cells: + return False + grid_lines = pv.PolyData(line_points) + grid_lines.lines = line_cells + actor = self._plotter.add_mesh(grid_lines, color=self._get_grid_color(), line_width=1) + self._grid_meshes.append(actor) + self._plotter.update() + return True + except Exception as e: + log_exception(__name__, "_render_grid_actor_from_cells.main", e) + return False + + def load_grid_cache(self: "ModelViewWidget", cache_path: str, expected_meta: dict) -> bool: + """Загрузить сетку из файла кэша при полном совпадении метаданных.""" + try: + path = Path(str(cache_path)) + if not path.exists(): + return False + raw = path.read_text(encoding="utf-8") + payload = json.loads(raw) + cached_meta = dict(payload.get("meta") or {}) + if cached_meta != dict(expected_meta or {}): + return False + cells_raw = list(payload.get("cells") or []) + if not cells_raw: + return False + grid_cells: dict[int, tuple[float, float, float, float]] = {} + for item in cells_raw: + if not isinstance(item, list) or len(item) != 5: + continue + cell_id = int(item[0]) + mn_x = float(item[1]) + mx_x = float(item[2]) + mn_y = float(item[3]) + mx_y = float(item[4]) + grid_cells[cell_id] = (mn_x, mx_x, mn_y, mx_y) + if not grid_cells: + return False + + for mesh in list(getattr(self, "_grid_meshes", [])): + try: + self._plotter.remove_actor(mesh) + except Exception as e: + log_exception(__name__, "load_grid_cache.remove_actor", e) + self._grid_meshes = [] + self._grid_cells = grid_cells + self._rebuild_grid_nodes() + origin = expected_meta.get("grid_origin") or [0.0, 0.0, 0.0] + self._grid_origin = (float(origin[0]), float(origin[1]), float(origin[2])) + self._current_zone_size = int(expected_meta.get("cell_size", 0) or 0) + self._current_zone_z_size = int(expected_meta.get("z_size", self._current_zone_size) or self._current_zone_size) + self._grid_cached_cell_size = int(self._current_zone_size) + self._grid_cached_z_size = int(self._current_zone_z_size) + self._grid_ready = True + self._zone_selection_mode = False + grid_z = self._get_grid_plane_z(self._grid_origin) + if not self._render_grid_actor_from_cells(min_z=grid_z): + self.clear_grid() + return False + return True + except Exception as e: + log_exception(__name__, "load_grid_cache.main", e) + return False + + def create_grid_on_floor( + self: "ModelViewWidget", + min_x: float, max_x: float, + min_y: float, max_y: float, + min_z: float, + cell_size: int, + grid_origin: Optional[Tuple[float, float, float]] = None, + progress_callback: Optional[Callable[[int], None]] = None, + ) -> bool: + """Построить линии сетки на полу.""" + for mesh in self._grid_meshes: + try: + self._plotter.remove_actor(mesh) + except Exception as e: + log_exception(__name__, "create_grid_on_floor.remove_actor", e) + self._grid_meshes = [] + self._grid_cells = {} + + disp_min_x = float(min_x) + disp_max_x = float(max_x) + disp_min_y = float(min_y) + disp_max_y = float(max_y) + + # Выровнять шаг сетки по локальной опорной точке объекта. + if grid_origin is not None and cell_size > 0: + ox, oy, _ = grid_origin + disp_min_x = ox + math.floor((disp_min_x - ox) / cell_size) * cell_size + disp_max_x = ox + math.ceil((disp_max_x - ox) / cell_size) * cell_size + disp_min_y = oy + math.floor((disp_min_y - oy) / cell_size) * cell_size + disp_max_y = oy + math.ceil((disp_max_y - oy) / cell_size) * cell_size + + floor_triangles = self._extract_floor_triangles_2d() + + x = disp_min_x + cell_id = 0 + line_points: list[list[float]] = [] + line_cells: list[int] = [] + total_cols = max(1, int(math.ceil( + (disp_max_x - disp_min_x) / max(1.0, float(cell_size)) + ))) + col_idx = 0 + + def add_segment(p1: tuple[float, float, float], p2: tuple[float, float, float]) -> None: + i1 = len(line_points) + line_points.append([p1[0], p1[1], p1[2]]) + i2 = len(line_points) + line_points.append([p2[0], p2[1], p2[2]]) + line_cells.extend([2, i1, i2]) + + while x < disp_max_x: + if bool(getattr(self, "_grid_build_cancel_requested", False)): + self._grid_cells = {} + self._grid_nodes = [] + return False + y = disp_min_y + while y < disp_max_y: + if bool(getattr(self, "_grid_build_cancel_requested", False)): + self._grid_cells = {} + self._grid_nodes = [] + return False + x_next = min(x + cell_size, disp_max_x) + y_next = min(y + cell_size, disp_max_y) + if self._is_cell_inside_floor_2d(x, x_next, y, y_next, floor_triangles): + self._grid_cells[cell_id] = (x, x_next, y, y_next) + p00 = (x, y, min_z) + p10 = (x_next, y, min_z) + p11 = (x_next, y_next, min_z) + p01 = (x, y_next, min_z) + add_segment(p00, p10) + add_segment(p10, p11) + add_segment(p11, p01) + add_segment(p01, p00) + cell_id += 1 + y = y_next + x = x_next + col_idx += 1 + if progress_callback: + try: + progress_callback(min(100, int((col_idx / total_cols) * 100))) + except Exception as e: + log_exception(__name__, "create_grid_on_floor.progress_callback", e) + + if line_points and line_cells: + try: + grid_lines = pv.PolyData(line_points) + grid_lines.lines = line_cells + actor = self._plotter.add_mesh( + grid_lines, + color=self._get_grid_color(), + line_width=1, + ) + self._grid_meshes.append(actor) + except Exception as e: + log_exception(__name__, "create_grid_on_floor.add_mesh", e) + + self._rebuild_grid_nodes() + if progress_callback: + try: + progress_callback(100) + except Exception as e: + log_exception(__name__, "create_grid_on_floor.progress_complete", e) + self._plotter.update() + return True + + def has_grid(self: "ModelViewWidget") -> bool: + """Проверить наличие готовой разметочной сетки.""" + return bool( + getattr(self, "_grid_ready", False) + and getattr(self, "_grid_cells", None) + and getattr(self, "_grid_nodes", None) + ) + + def clear_grid(self: "ModelViewWidget") -> None: + """Полностью уничтожить сетку (акторы + данные + cache).""" + for mesh in list(getattr(self, "_grid_meshes", [])): + try: + self._plotter.remove_actor(mesh) + except Exception as e: + log_exception(__name__, "clear_grid.remove_actor", e) + self._grid_meshes = [] + self._grid_cells = {} + self._grid_nodes = [] + self._grid_ready = False + self._grid_cached_cell_size = 0 + self._grid_cached_z_size = 0 + + def _show_grid_actors(self: "ModelViewWidget") -> None: + """Показать скрытые акторы разметочной сетки.""" + for actor in getattr(self, "_grid_meshes", []): + try: + actor.VisibilityOn() + except Exception as e: + log_exception(__name__, "_show_grid_actors.visibility_on", e) + try: + if self._plotter: + self._plotter.update() + except Exception as e: + log_exception(__name__, "_show_grid_actors.plotter_update", e) + + def _hide_grid_actors(self: "ModelViewWidget") -> None: + """Скрыть акторы разметочной сетки без удаления.""" + for actor in getattr(self, "_grid_meshes", []): + try: + actor.VisibilityOff() + except Exception as e: + log_exception(__name__, "_hide_grid_actors.visibility_off", e) + try: + if self._plotter: + self._plotter.update() + except Exception as e: + log_exception(__name__, "_hide_grid_actors.plotter_update", e) + + def _rebuild_grid_nodes(self: "ModelViewWidget") -> None: + nodes = set() + for mn_x, mx_x, mn_y, mx_y in self._grid_cells.values(): + nodes.add((float(mn_x), float(mn_y))) + nodes.add((float(mx_x), float(mn_y))) + nodes.add((float(mx_x), float(mx_y))) + nodes.add((float(mn_x), float(mx_y))) + self._grid_nodes = list(nodes) + + def _nearest_grid_node( + self: "ModelViewWidget", x: float, y: float, + ) -> Optional[Tuple[float, float]]: + use_surface = bool( + getattr(self, "_contour_zone_overlay_enabled", False) + and getattr(self, "_contour_zone_mode", None) == "overlay" + and getattr(self, "_grid_surface_nodes", None) + ) + nodes = self._grid_surface_nodes if use_surface else self._grid_nodes + if not nodes: + return None + step = max(1.0, float(getattr(self, "_current_zone_size", 1.0))) + max_dist2 = (step * 0.55) ** 2 + best = None + best_dist2 = None + for nx, ny in nodes: + d2 = (nx - x) ** 2 + (ny - y) ** 2 + if best_dist2 is None or d2 < best_dist2: + best_dist2 = d2 + best = (nx, ny) + if best is None or best_dist2 is None or best_dist2 > max_dist2: + return None + return best + + def _get_grid_plane_z( + self: "ModelViewWidget", + grid_origin: Optional[Tuple[float, float, float]] = None, + ) -> float: + """Определить Z-плоскость построения сетки.""" + if grid_origin is not None: + try: + return float(grid_origin[2]) + except Exception as e: + log_exception(__name__, "_get_grid_plane_z.origin_z", e) + if self._floor_mesh is not None: + return float(self._floor_mesh.bounds[5]) + if self._room_bounds: + return float(self._room_bounds[5]) + return 0.0 + + +# --------------------------------------------------------------------------- +# Module workflow notes +# --------------------------------------------------------------------------- +# 1) Задача модуля: +# Базовые операции сетки: построение, отображение, скрытие и привязка к узлам. +# +# 2) Последовательность действий и вызовов: +# A. Класс GridCoreBaseMixin: точки входа +# Публичные методы сценария: +# - GridCoreBaseMixin.start_zone_selection_grid(...) +# - GridCoreBaseMixin.create_grid_on_floor(...) +# - GridCoreBaseMixin.has_grid(...) +# - GridCoreBaseMixin.clear_grid(...) +# +# B. GridCoreBaseMixin: запуск и настройка: +# GridCoreBaseMixin.start_zone_selection_grid(...) +# Назначение: Включить режим выбора зоны через сетку на полу. +# Последовательность внутренних вызовов: +# -> GridCoreBaseMixin._get_grid_plane_z(...) +# -> GridCoreBaseMixin.create_grid_on_floor(...) +# -> GridCoreBaseMixin._show_grid_actors(...) +# +# C. GridCoreBaseMixin: основной сценарий: +# GridCoreBaseMixin._apply_grid_theme(...) +# Назначение: Перекрасить акторы сетки в соответствии с текущей темой. +# Последовательность внутренних вызовов: +# -> GridCoreBaseMixin._get_grid_color(...) +# GridCoreBaseMixin.create_grid_on_floor(...) +# Назначение: Построить линии сетки на полу. +# Последовательность внутренних вызовов: +# -> GridCoreBaseMixin._rebuild_grid_nodes(...) +# -> GridCoreBaseMixin._get_grid_color(...) +# GridCoreBaseMixin.has_grid(...) +# Назначение: Проверить наличие готовой разметочной сетки. +# GridCoreBaseMixin._show_grid_actors(...) +# Назначение: Показать скрытые акторы разметочной сетки. +# GridCoreBaseMixin._rebuild_grid_nodes(...) +# Назначение: перестраивает grid nodes в рамках текущего сценария модуля. +# +# D. GridCoreBaseMixin: завершение и очистка: +# GridCoreBaseMixin.clear_grid(...) +# Назначение: Полностью уничтожить сетку (акторы + данные + cache). +# +# E. GridCoreBaseMixin: вспомогательные расчёты: +# GridCoreBaseMixin._get_grid_color(...) +# Назначение: возвращает grid color в рамках текущего сценария модуля. +# GridCoreBaseMixin._hide_grid_actors(...) +# Назначение: Скрыть акторы разметочной сетки без удаления. +# GridCoreBaseMixin._nearest_grid_node(...) +# Назначение: выполняет шаг "nearest grid node" в рамках текущего сценария модуля. +# GridCoreBaseMixin._get_grid_plane_z(...) +# Назначение: Определить Z-плоскость построения сетки. +# +# 3) Важные ограничения и инварианты: +# - Модуль выполняется в составе ModelViewWidget и использует согласованные поля состояния self._... . +# - Все операции с актёрами и камерой выполняются только при валидном self._plotter с безопасной обработкой исключений. +# - Геометрическая визуализация зависит от pyvista/vtk; при недоступности модуль обязан завершать шаг без падения сценария. +# - Межмодульная связность: только через фасад model_view; прямые обращения между zone, rack, shelf, cell запрещены. +# - Очистка состояния должна быть идемпотентной: повторный вызов не меняет корректное состояние в ошибочное. +# +# 4) Правило сопровождения: +# - Любое изменение сценария должно сопровождаться обновлением этого блока с сохранением фактического порядка вызовов. +# - При добавлении метода указывать его место в цепочке сценария (запуск, основной шаг, завершение, вспомогательная логика). diff --git a/Dispatch_V0.1.1/gui/components/model_view/_mv_grid_core_floor.py b/Dispatch_V0.1.1/gui/components/model_view/_mv_grid_core_floor.py new file mode 100644 index 0000000..ad1dcee --- /dev/null +++ b/Dispatch_V0.1.1/gui/components/model_view/_mv_grid_core_floor.py @@ -0,0 +1,136 @@ +# -*- coding: utf-8 -*- +# gui/components/model_view/_mv_grid_core_floor.py +# Помощники геометрии пола: извлечение треугольников, проверки вхождения точки/ячейки + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from error_logger import log_exception + +if TYPE_CHECKING: # pragma: no cover + from gui.components.model_view_widget import ModelViewWidget + + +class GridCoreFloorMixin: + """Геометрия пола: извлечение треугольников и 2D-проверки вхождения.""" + + # ------------------------------------------------------------------ + # Геометрия пола + # ------------------------------------------------------------------ + + def _extract_floor_triangles_2d( + self: "ModelViewWidget", + ) -> list[tuple[tuple[float, float], tuple[float, float], tuple[float, float]]]: + floor_mesh = getattr(self, "_floor_mesh", None) + if floor_mesh is None: + return [] + try: + tri = floor_mesh.extract_surface().triangulate() + pts = tri.points + faces = tri.faces + if pts is None or faces is None or len(faces) == 0: + return [] + triangles: list[tuple[tuple[float, float], tuple[float, float], tuple[float, float]]] = [] + idx = 0 + faces_len = len(faces) + while idx < faces_len: + n = int(faces[idx]) + if n < 3: + idx += n + 1 + continue + i0 = int(faces[idx + 1]) + for k in range(2, n): + i1 = int(faces[idx + k]) + i2 = int(faces[idx + k + 1]) + a = (float(pts[i0][0]), float(pts[i0][1])) + b = (float(pts[i1][0]), float(pts[i1][1])) + c = (float(pts[i2][0]), float(pts[i2][1])) + triangles.append((a, b, c)) + idx += n + 1 + return triangles + except Exception as e: + log_exception(__name__, "_extract_floor_triangles_2d", e) + return [] + + def _is_cell_inside_floor_2d( + self: "ModelViewWidget", + min_x: float, max_x: float, + min_y: float, max_y: float, + floor_triangles: list[tuple[tuple[float, float], tuple[float, float], tuple[float, float]]], + ) -> bool: + if not floor_triangles: + return True + corners = ( + (min_x, min_y), (max_x, min_y), + (max_x, max_y), (min_x, max_y), + ) + for px, py in corners: + if not self._is_point_inside_floor_2d(px, py, floor_triangles): + return False + return True + + def _is_point_inside_floor_2d( + self: "ModelViewWidget", + px: float, py: float, + floor_triangles: list[tuple[tuple[float, float], tuple[float, float], tuple[float, float]]], + ) -> bool: + for a, b, c in floor_triangles: + if self._point_in_triangle_2d(px, py, a, b, c): + return True + return False + + def _point_in_triangle_2d( + self: "ModelViewWidget", + px: float, py: float, + a: tuple[float, float], + b: tuple[float, float], + c: tuple[float, float], + eps: float = 1e-7, + ) -> bool: + def sign(p1, p2, p3): + return (p1[0] - p3[0]) * (p2[1] - p3[1]) - (p2[0] - p3[0]) * (p1[1] - p3[1]) + + p = (px, py) + d1 = sign(p, a, b) + d2 = sign(p, b, c) + d3 = sign(p, c, a) + has_neg = (d1 < -eps) or (d2 < -eps) or (d3 < -eps) + has_pos = (d1 > eps) or (d2 > eps) or (d3 > eps) + return not (has_neg and has_pos) + + +# --------------------------------------------------------------------------- +# Module workflow notes +# --------------------------------------------------------------------------- +# 1) Задача модуля: +# Геометрический контроль попадания сетки и точек в пол помещения по треугольной геометрии. +# +# 2) Последовательность действий и вызовов: +# A. Класс GridCoreFloorMixin: точки входа +# Публичные методы отсутствуют; сценарий запускается через методы родительских модулей и внутренние обработчики. +# +# B. GridCoreFloorMixin: основной сценарий: +# GridCoreFloorMixin._is_cell_inside_floor_2d(...) +# Назначение: проверяет, что cell inside floor 2d в рамках текущего сценария модуля. +# Последовательность внутренних вызовов: +# -> GridCoreFloorMixin._is_point_inside_floor_2d(...) +# GridCoreFloorMixin._is_point_inside_floor_2d(...) +# Назначение: проверяет, что point inside floor 2d в рамках текущего сценария модуля. +# Последовательность внутренних вызовов: +# -> GridCoreFloorMixin._point_in_triangle_2d(...) +# +# C. GridCoreFloorMixin: вспомогательные расчёты: +# GridCoreFloorMixin._extract_floor_triangles_2d(...) +# Назначение: извлекает floor triangles 2d в рамках текущего сценария модуля. +# GridCoreFloorMixin._point_in_triangle_2d(...) +# Назначение: выполняет шаг "point in triangle 2d" в рамках текущего сценария модуля. +# +# 3) Важные ограничения и инварианты: +# - Модуль выполняется в составе ModelViewWidget и использует согласованные поля состояния self._... . +# - Межмодульная связность: только через фасад model_view; прямые обращения между zone, rack, shelf, cell запрещены. +# - Очистка состояния должна быть идемпотентной: повторный вызов не меняет корректное состояние в ошибочное. +# +# 4) Правило сопровождения: +# - Любое изменение сценария должно сопровождаться обновлением этого блока с сохранением фактического порядка вызовов. +# - При добавлении метода указывать его место в цепочке сценария (запуск, основной шаг, завершение, вспомогательная логика). diff --git a/Dispatch_V0.1.1/gui/components/model_view/_mv_interaction.py b/Dispatch_V0.1.1/gui/components/model_view/_mv_interaction.py new file mode 100644 index 0000000..3430bf5 --- /dev/null +++ b/Dispatch_V0.1.1/gui/components/model_view/_mv_interaction.py @@ -0,0 +1,41 @@ +# -*- coding: utf-8 -*- +# gui/components/model_view/_mv_interaction.py +# Камера, клики, события мыши — композиция специализированных миксинов. + +from gui.components.model_view._mv_interaction_core import InteractionCoreMixin +from gui.components.model_view._mv_interaction_events import InteractionEventsMixin +from gui.components.model_view._mv_interaction_nav import InteractionNavigationMixin + + +class InteractionMixin( + InteractionCoreMixin, + InteractionEventsMixin, + InteractionNavigationMixin, +): + """Камера, клик-обработка, колесо мыши, eventFilter.""" + pass + + +# --------------------------------------------------------------------------- +# Module workflow notes +# --------------------------------------------------------------------------- +# 1) Задача модуля: +# Композиционный фасад взаимодействия со сценой: события мыши, навигация и базовые операции. +# +# 2) Последовательность действий и вызовов: +# A. Композиционный класс InteractionMixin: +# Назначение: объединяет поведение через InteractionCoreMixin, +# InteractionEventsMixin, InteractionNavigationMixin. +# InteractionCoreMixin — координаты, камера, утилиты interactor. +# InteractionEventsMixin — тонкий диспетчер eventFilter → InteractionManager. +# InteractionNavigationMixin — навигация, plotter click callback. +# Вся логика сценариев вынесена в _interaction_scenario.py + _scenario_*.py. +# +# 3) Важные ограничения и инварианты: +# - Модуль выполняется в составе ModelViewWidget и использует согласованные поля состояния self._... . +# - Межмодульная связность: только через фасад model_view; прямые обращения между zone, rack, shelf, cell запрещены. +# - Очистка состояния должна быть идемпотентной: повторный вызов не меняет корректное состояние в ошибочное. +# +# 4) Правило сопровождения: +# - Любое изменение сценария должно сопровождаться обновлением этого блока с сохранением фактического порядка вызовов. +# - При добавлении метода указывать его место в цепочке сценария (запуск, основной шаг, завершение, вспомогательная логика). diff --git a/Dispatch_V0.1.1/gui/components/model_view/_mv_interaction_core.py b/Dispatch_V0.1.1/gui/components/model_view/_mv_interaction_core.py new file mode 100644 index 0000000..49cdc5b --- /dev/null +++ b/Dispatch_V0.1.1/gui/components/model_view/_mv_interaction_core.py @@ -0,0 +1,406 @@ +# -*- coding: utf-8 -*- +# gui/components/model_view/_mv_interaction_core.py +# Базовые помощники взаимодействия: преобразование координат, настройка камеры, разрешение сетки + +from __future__ import annotations + +from typing import Optional, TYPE_CHECKING + +from error_logger import log_exception + +if TYPE_CHECKING: # pragma: no cover + from gui.components.model_view_widget import ModelViewWidget + + +class InteractionCoreMixin: + """Базовые вспомогательные методы взаимодействия: координаты, камера, сетка, утилиты interactor.""" + + # -- Утилиты interactor (из бывшего InteractionUtilsMixin) ----------------- + + def _is_plotter_interactor_event(self: "ModelViewWidget", watched) -> bool: + """Проверить, что событие пришло от активного interactor plotter.""" + return bool(self._plotter and watched is self._plotter.interactor) + + def _event_to_vtk_display_xy(self: "ModelViewWidget", event) -> tuple[int, int]: + """Перевести координаты события Qt в VTK display (с DPR и инверсией Y).""" + dpr = self._plotter.devicePixelRatio() + sx = int(round(event.position().x() * dpr)) + sy = int(round(event.position().y() * dpr)) + rw = self._plotter.ren_win + _, vh = rw.GetSize() if rw else (0, 0) + sy = vh - sy - 1 + return sx, sy + + # -- Координатные преобразования ------------------------------------------ + + def _event_world_on_plane(self: "ModelViewWidget", event, plane_z: float) -> Optional[tuple[float, float, float]]: + """Преобразование события мыши интерактора в мировые координаты на фиксированной плоскости.""" + if not self._plotter: + return None + try: + sx, sy = self._event_to_vtk_display_xy(event) + return self.screen_to_world_on_plane(sx, sy, plane_z) + except Exception as e: + log_exception(__name__, "_event_world_on_plane", e) + return None + + def _update_contour_drag_point(self: "ModelViewWidget", world_x: float, world_y: float) -> bool: + """Перемещение перетаскиваемой точки контура к ближайшему узлу сетки без изменения порядка.""" + idx = getattr(self, "_contour_drag_point_index", None) + if idx is None: + return False + points = list(getattr(self, "_contour_points", []) or []) + if idx < 0 or idx >= len(points): + return False + + node = self._nearest_grid_node(float(world_x), float(world_y)) + if node is None: + return False + new_node = (float(node[0]), float(node[1])) + current_node = (float(points[idx][0]), float(points[idx][1])) + if new_node == current_node: + return False + + # Не допускаем совпадения с другими узлами. + for i, pt in enumerate(points): + if i == idx: + continue + if (float(pt[0]), float(pt[1])) == new_node: + return False + + points[idx] = new_node + self._contour_points = points + + if getattr(self, "_dim_enabled", False): + self.set_dim_last_point(self._contour_points[-1] if self._contour_points else None) + self._update_contour_visualization() + self._update_selected_cells_from_contour() + emit_ready = getattr(self, "_emit_contour_ready_state", None) + if callable(emit_ready): + emit_ready() + return True + + # -- камера --------------------------------------------------------------- + + def _apply_facility_isometric_view( + self: "ModelViewWidget", + reset_camera: bool = True, + update: bool = True, + ) -> None: + """Применить единый изометрический вид facility (базовая ориентация).""" + if not self._plotter: + return + try: + self._plotter.view_isometric() + camera = getattr(self._plotter, "camera", None) + if camera is not None and hasattr(camera, "Azimuth"): + # Базовая ориентация facility: изометрия с противоположной стороны. + camera.Azimuth(180) + if hasattr(camera, "OrthogonalizeViewUp"): + camera.OrthogonalizeViewUp() + if reset_camera: + self._plotter.reset_camera() + # Поджимаем общий facility-кадр: небольшие поля вокруг модели. + if camera is not None and hasattr(camera, "zoom"): + try: + camera.zoom(1.35) + except Exception as e: + log_exception(__name__, "_apply_facility_isometric_view.camera_zoom", e) + if hasattr(self, "_reset_camera_clipping_range"): + self._reset_camera_clipping_range() + if update: + self._plotter.update() + except Exception as e: + log_exception(__name__, "_apply_facility_isometric_view", e) + + def _setup_trackball_right_button(self: "ModelViewWidget"): + """Настройка trackball стиля: правая кнопка — поворот, левая отключена.""" + try: + if self._plotter and self._plotter.interactor: + interactor = self._plotter.interactor + style = ( + interactor.GetInteractorStyle() + if hasattr(interactor, "GetInteractorStyle") + else None + ) + if style: + if hasattr(style, "SetLeftButtonMotion"): + style.SetLeftButtonMotion(False) + if hasattr(style, "SetRightButtonMotion"): + style.SetRightButtonMotion(True) + except Exception as e: + log_exception(__name__, "_setup_trackball_right_button", e) + + def _set_trackball_right_button_enabled(self: "ModelViewWidget", enabled: bool) -> None: + """Явно включить/выключить реакцию VTK-style на ПКМ.""" + try: + if not (self._plotter and self._plotter.interactor): + return + interactor = self._plotter.interactor + style = ( + interactor.GetInteractorStyle() + if hasattr(interactor, "GetInteractorStyle") + else None + ) + if style and hasattr(style, "SetRightButtonMotion"): + style.SetRightButtonMotion(bool(enabled)) + except Exception as e: + log_exception(__name__, "_set_trackball_right_button_enabled", e) + + # -- клик ----------------------------------------------------------------- + + def set_top_view_navigation(self: "ModelViewWidget", enabled: bool) -> None: + """Режим top-view: pan/zoom без вращения камеры.""" + if not self._plotter: + return + try: + if enabled: + try: + self._plotter.view_xy() + except Exception as e: + log_exception(__name__, "set_top_view_navigation.view_xy", e) + self._plotter.camera_position = "xy" + self._plotter.enable_image_style() + else: + self._plotter.enable_trackball_style() + self._setup_trackball_right_button() + self._plotter.update() + except Exception as e: + log_exception(__name__, "set_top_view_navigation", e) + + def set_isometric_view(self: "ModelViewWidget") -> None: + """Переключить камеру в изометрический вид.""" + self._apply_facility_isometric_view(reset_camera=True, update=True) + + def animate_facility_isometric_view( + self: "ModelViewWidget", + *, + duration_ms: int = 360, + steps: int = 18, + ) -> None: + """Плавно перевести камеру к общей изометрии facility.""" + if not self._plotter: + return + cam = getattr(self._plotter, "camera", None) + if cam is None: + return + + try: + start_pos = tuple(float(v) for v in cam.GetPosition()) + start_focal = tuple(float(v) for v in cam.GetFocalPoint()) + start_up = tuple(float(v) for v in cam.GetViewUp()) + except Exception as e: + log_exception(__name__, "animate_facility_isometric_view.get_camera_start", e) + self.set_isometric_view() + return + + # Рассчитываем целевую изометрию без промежуточного переключения + # живой камеры (иначе возникает визуальный "проскок" статичного кадра). + try: + bounds = getattr(self, "_room_bounds", None) + if not bounds: + zone_bounds = list((getattr(self, "_zone_data", {}) or {}).values()) + if zone_bounds: + min_x = min(float(b[0]) for b in zone_bounds) + max_x = max(float(b[1]) for b in zone_bounds) + min_y = min(float(b[2]) for b in zone_bounds) + max_y = max(float(b[3]) for b in zone_bounds) + min_z = min(float(b[4]) for b in zone_bounds) + max_z = max(float(b[5]) for b in zone_bounds) + bounds = (min_x, max_x, min_y, max_y, min_z, max_z) + if not bounds: + self.set_isometric_view() + return + + min_x, max_x, min_y, max_y, min_z, max_z = [float(v) for v in bounds] + cx = (min_x + max_x) * 0.5 + cy = (min_y + max_y) * 0.5 + sx = max(1.0, max_x - min_x) + sy = max(1.0, max_y - min_y) + sz = max(1.0, max_z - min_z) + span = max(sx, sy, sz) + cz = min_z + sz * 0.5 + + vx, vy, vz = 1.0, -1.0, 0.75 + norm = (vx * vx + vy * vy + vz * vz) ** 0.5 + vx, vy, vz = vx / norm, vy / norm, vz / norm + + ux, uy, uz = 0.0, 0.0, 1.0 + fx, fy, fz = -vx, -vy, -vz + rx = fy * uz - fz * uy + ry = fz * ux - fx * uz + rz = fx * uy - fy * ux + r_norm = (rx * rx + ry * ry + rz * rz) ** 0.5 + if r_norm <= 1e-6: + rx, ry, rz = 1.0, 0.0, 0.0 + else: + rx, ry, rz = rx / r_norm, ry / r_norm, rz / r_norm + + facility_margin = max(1.01, float(getattr(self, "_facility_iso_margin_factor", 1.16))) + facility_min_distance = max(1000.0, float(getattr(self, "_facility_iso_min_distance", 2280.0))) + facility_side_shift = max(0.0, float(getattr(self, "_facility_iso_side_shift_factor", 0.06))) + facility_z_lift = max(0.0, float(getattr(self, "_facility_iso_z_lift_factor", 0.10))) + if hasattr(self, "_compute_isometric_fit_distance"): + distance = float( + self._compute_isometric_fit_distance( + (min_x, max_x, min_y, max_y, min_z, max_z), + view_dir=(vx, vy, vz), + up_dir=(ux, uy, uz), + margin_factor=facility_margin, + min_distance=facility_min_distance, + ) + ) + else: + distance = max(facility_min_distance, span * 1.68) + target_focal = (cx, cy, cz) + target_pos = ( + cx + vx * distance + rx * (span * facility_side_shift), + cy + vy * distance + ry * (span * facility_side_shift), + cz + vz * distance + (sz * facility_z_lift), + ) + target_up = (ux, uy, uz) + except Exception as e: + log_exception(__name__, "animate_facility_isometric_view.compute_target", e) + self.set_isometric_view() + return + + if hasattr(self, "_animate_camera_transition"): + try: + self._animate_camera_transition( + start_pos=start_pos, + start_focal=start_focal, + start_up=start_up, + target_pos=target_pos, + target_focal=target_focal, + target_up=target_up, + duration_ms=int(duration_ms), + steps=int(steps), + ) + return + except Exception as e: + log_exception(__name__, "animate_facility_isometric_view.animate_transition", e) + + self.set_isometric_view() + + def _resolve_grid_click_point( + self: "ModelViewWidget", + x: Optional[float], + y: Optional[float], + z: Optional[float], + action: str, + ) -> tuple[Optional[float], Optional[float], Optional[float]]: + """Выбрать наилучшую world-точку для клика по сетке. + + Приоритет кандидатов: + 1. Прямой расчёт из позиции Qt-курсора с DPR-коррекцией + (независимо от VTK event pipeline). + 2. screen_to_world_on_plane через VTK event position + (проекция на Z-плоскость сетки, без зависимости от геометрии). + 3. pick_world (VTK picker — может попасть по чужой геометрии). + 4. Исходная точка из PyVista callback. + """ + if not self._plotter or not self._plotter.interactor: + return x, y, z + + candidates: list[tuple[Optional[float], Optional[float], Optional[float]]] = [] + if self.is_scenario_active("contour_edit"): + plane_z = self._get_contour_plane_z() + else: + plane_z = self._get_grid_plane_z(getattr(self, "_grid_origin", None)) + + # ── 1. Кандидат из позиции Qt-курсора (DPR-safe) ───────────── + try: + from PySide6.QtGui import QCursor + global_pos = QCursor.pos() + local_pos = self._plotter.mapFromGlobal(global_pos) + dpr = self._plotter.devicePixelRatio() + ren_win = self._plotter.ren_win + if ren_win: + _, win_h = ren_win.GetSize() + q_sx = int(round(local_pos.x() * dpr)) + q_sy = win_h - int(round(local_pos.y() * dpr)) - 1 + wp = self.screen_to_world_on_plane(q_sx, q_sy, plane_z) + if wp is not None: + candidates.append((wp[0], wp[1], wp[2])) + except Exception as e: + log_exception(__name__, "_resolve_grid_click_point.qt_cursor_candidate", e) + + # ── 2/3. Кандидаты из VTK event position ───────────────────── + try: + sx, sy = self._plotter.interactor.GetEventPosition() + except Exception as e: + log_exception(__name__, "_resolve_grid_click_point.get_event_position", e) + sx = sy = None + + if sx is not None and sy is not None: + try: + world = self.screen_to_world_on_plane(sx, sy, plane_z) + if world is not None: + candidates.append((world[0], world[1], world[2])) + except Exception as e: + log_exception(__name__, "_resolve_grid_click_point.screen_to_world", e) + try: + picked = self.pick_world(sx, sy) + if picked is not None: + candidates.append((picked[0], picked[1], picked[2])) + except Exception as e: + log_exception(__name__, "_resolve_grid_click_point.pick_world", e) + + # ── 4. Исходная точка из PyVista ────────────────────────────── + candidates.append((x, y, z)) + + # Для ПКМ приоритет у точки, попадающей в уже выделенную ячейку. + prefer_selected = (action == "remove") + for cx, cy, cz in candidates: + if cx is None or cy is None: + continue + found = None + try: + found = self._find_cell_by_point(float(cx), float(cy), prefer_selected=prefer_selected) + except Exception as e: + log_exception(__name__, "_resolve_grid_click_point.find_cell_by_point", e) + found = None + if found is not None: + return float(cx), float(cy), 0.0 + + # fallback: первая валидная точка + for cx, cy, cz in candidates: + if cx is not None and cy is not None: + return float(cx), float(cy), 0.0 + return x, y, z + + def _is_point_inside_room(self: "ModelViewWidget", x: float, y: float, z: float) -> bool: + """Проверка, находится ли точка внутри помещения.""" + if not self._room_bounds: + return True + mn_x, mx_x, mn_y, mx_y, mn_z, mx_z = self._room_bounds + margin = 10 + return ( + mn_x - margin <= x <= mx_x + margin + and mn_y - margin <= y <= mx_y + margin + and mn_z - margin <= z <= mx_z + margin + ) + + +# --------------------------------------------------------------------------- +# Module workflow notes +# --------------------------------------------------------------------------- +# 1) Задача модуля: +# Базовые операции взаимодействия: координатные преобразования, режимы камеры, проверка точек. +# +# 2) Последовательность действий и вызовов: +# A. Класс InteractionCoreMixin: утилиты interactor + координаты + камера + сетка. +# - _is_plotter_interactor_event() — проверка источника события +# - _event_to_vtk_display_xy() — Qt logical → VTK display +# - _event_world_on_plane() — мировые координаты на плоскости +# - set_camera_locked() — блокировка/разблокировка камеры +# - set_top_view_navigation() — top-view: pan/zoom без вращения +# - set_isometric_view() — изометрический вид +# - _update_contour_drag_point() — перемещение узла контура +# - _resolve_grid_click_point() — world-точка клика по сетке +# - _is_point_inside_room() — внутри ли помещения +# +# 3) Важные ограничения и инварианты: +# - Выполняется в составе ModelViewWidget с согласованными полями self._... . +# - Операции с актёрами/камерой только при валидном self._plotter. +# - Очистка состояния идемпотентна. diff --git a/Dispatch_V0.1.1/gui/components/model_view/_mv_interaction_events.py b/Dispatch_V0.1.1/gui/components/model_view/_mv_interaction_events.py new file mode 100644 index 0000000..c804583 --- /dev/null +++ b/Dispatch_V0.1.1/gui/components/model_view/_mv_interaction_events.py @@ -0,0 +1,54 @@ +# -*- coding: utf-8 -*- +# gui/components/model_view/_mv_interaction_events.py +# Тонкий диспетчер событий: делегирует InteractionManager. + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from PySide6.QtCore import Qt, QEvent + +if TYPE_CHECKING: # pragma: no cover + from gui.components.model_view_widget import ModelViewWidget + + +class InteractionEventsMixin: + """Тонкий диспетчер Qt-событий → InteractionManager.""" + + def eventFilter(self: "ModelViewWidget", watched, event): + """Маршрутизатор событий interactor через менеджер сценариев.""" + if not self._is_plotter_interactor_event(watched): + return super().eventFilter(watched, event) + + etype = event.type() + mgr = getattr(self, "_interaction_manager", None) + if mgr is not None: + result = mgr.dispatch_event_filter(watched, event) + if result is True: + return True + if result is False: + # По контракту InteractionManager: False = не передавать событие дальше. + return True + + return super().eventFilter(watched, event) + + def mousePressEvent(self: "ModelViewWidget", event): + """Делегация mousePressEvent текущему сценарию.""" + mgr = getattr(self, "_interaction_manager", None) + if mgr is not None and mgr.dispatch_widget_mouse_press(event): + return + super().mousePressEvent(event) + + def mouseMoveEvent(self: "ModelViewWidget", event): + """Делегация mouseMoveEvent текущему сценарию.""" + mgr = getattr(self, "_interaction_manager", None) + if mgr is not None and mgr.dispatch_widget_mouse_move(event): + return + super().mouseMoveEvent(event) + + def mouseReleaseEvent(self: "ModelViewWidget", event): + """Делегация mouseReleaseEvent текущему сценарию.""" + mgr = getattr(self, "_interaction_manager", None) + if mgr is not None and mgr.dispatch_widget_mouse_release(event): + return + super().mouseReleaseEvent(event) diff --git a/Dispatch_V0.1.1/gui/components/model_view/_mv_interaction_nav.py b/Dispatch_V0.1.1/gui/components/model_view/_mv_interaction_nav.py new file mode 100644 index 0000000..449bfba --- /dev/null +++ b/Dispatch_V0.1.1/gui/components/model_view/_mv_interaction_nav.py @@ -0,0 +1,486 @@ +# -*- coding: utf-8 -*- +# gui/components/model_view/_mv_interaction_nav.py +# Обработчики кликов и навигация: клики на сцене, масштабирование, панорамирование, колесо мыши + +from __future__ import annotations + +import math +import time +from typing import TYPE_CHECKING + +from error_logger import log_exception + +if TYPE_CHECKING: # pragma: no cover + from gui.components.model_view_widget import ModelViewWidget + + +class InteractionNavigationMixin: + """Клик-обработчики, зум, панорамирование, колесо мыши.""" + + def _pick_rack_from_cursor(self: "ModelViewWidget") -> tuple[str | None, str]: + """Определить id стеллажа под курсором и контекстную зону.""" + zone_id = str(getattr(self, "_rack_preview_zone_id", "") or "") + if not zone_id and getattr(self, "_rack_entries", None): + current_selected = str(getattr(self, "_selected_rack_id", "") or "") + if current_selected: + for entry in list(getattr(self, "_rack_entries", []) or []): + if str(entry.get("rack_id", "")) == current_selected: + zone_id = str(entry.get("zone_id", "") or "") + break + if not zone_id: + first_entry = next(iter(list(getattr(self, "_rack_entries", []) or [])), None) + if first_entry: + zone_id = str(first_entry.get("zone_id", "") or "") + + rack_id = None + if self._plotter and self._plotter.interactor and hasattr(self, "_find_rack_id_by_screen"): + try: + sx, sy = self._plotter.interactor.GetEventPosition() + rack_id = self._find_rack_id_by_screen(float(sx), float(sy), zone_id, screen_is_vtk=True) + except Exception as e: + log_exception(__name__, "_pick_rack_from_cursor", e) + rack_id = None + return rack_id, zone_id + + def _pick_zone_from_cursor( + self: "ModelViewWidget", + x: float | None, + y: float | None, + z: float | None, + ) -> str | None: + """Определить id зоны под текущим положением курсора.""" + if not self._plotter: + return None + + zone_id = None + try: + from vtkmodules.vtkRenderingCore import vtkPropPicker + + sx, sy = self._plotter.interactor.GetEventPosition() + prop_picker = vtkPropPicker() + prop_picker.Pick(sx, sy, 0, self._plotter.renderer) + picked_actor = prop_picker.GetViewProp() + if picked_actor is not None: + zone_id = self._find_zone_by_actor(picked_actor) + except Exception as e: + log_exception(__name__, "_pick_zone_from_cursor", e) + zone_id = None + + if not zone_id: + try: + sx, sy = self._plotter.interactor.GetEventPosition() + zone_id = self._find_zone_by_screen_ray(sx, sy) + except Exception as e: + log_exception(__name__, "_pick_zone_from_cursor", e) + zone_id = None + + if not zone_id: + try: + sx, sy = self._plotter.interactor.GetEventPosition() + plane_z = self._get_grid_plane_z(getattr(self, "_grid_origin", None)) + world = self.screen_to_world_on_plane(sx, sy, plane_z) + if world is not None: + best_top_z = None + for zid, polygon in self._zone_polygons.items(): + if not polygon or len(polygon) < 3: + continue + inside, _ = self._classify_point_in_polygon( + world[0], world[1], polygon, + ) + if inside: + top_z = self._get_zone_top_height(zid) + if best_top_z is None or top_z > best_top_z: + best_top_z = top_z + zone_id = zid + except Exception as e: + log_exception(__name__, "_pick_zone_from_cursor", e) + + if not zone_id: + zone_id = self._find_zone_at_point(x, y, z) + return zone_id + + def _on_plotter_click(self: "ModelViewWidget", point): + """Обработчик клика на 3D-сцене.""" + if not self._models_loaded: + return + + if self._ignore_next_plotter_click: + self._ignore_next_plotter_click = False + return + + x = y = z = None + if point is not None: + try: + x, y, z = point + except Exception as e: + log_exception(__name__, "_on_plotter_click", e) + x = y = z = None + + if self._custom_click_handler: + try: + if self._custom_click_handler(x, y, z): + return + except Exception as e: + log_exception(__name__, "_on_plotter_click", e) + return + + # Выбор полки в изолированном стеллаже (rack-level), когда выбор зон отключён. + # Полка кликабельна только при активной rack-изоляции (последовательная навигация). + if not self._zone_pick_enabled and not self._zone_selection_mode: + try: + _iso_active = bool(getattr(self, "_rack_isolation_active", False)) + rid = str(getattr(self, "_rack_isolation_rack_id", "") or "") if _iso_active else "" + if rid and self._plotter and self._plotter.interactor and hasattr(self, "_find_rendered_shelf_by_screen"): + sx, sy = self._plotter.interactor.GetEventPosition() + shelf_hit = self._find_rendered_shelf_by_screen(float(sx), float(sy), rid, screen_is_vtk=True) + if shelf_hit: + slot_id, shelf_index = shelf_hit + tree_shelf_index = int(max(1, int(shelf_index))) + try: + entry = self._get_rack_entry(rid) if hasattr(self, "_get_rack_entry") else None + rack_type = str(((entry or {}).get("params") or {}).get("rack_type") or "").strip().upper() + if rack_type == "PALLET": + tree_shelf_index = int(max(1, int(shelf_index)) + 1) + except Exception as e: + log_exception(__name__, "_on_plotter_click", e) + tree_shelf_index = int(max(1, int(shelf_index))) + self._selected_shelf_visual_rack_id = rid + self._selected_shelf_visual_slot_id = str(slot_id) + self._selected_shelf_visual_index = int(tree_shelf_index) + if hasattr(self, "_highlight_selected_rendered_shelf"): + self._highlight_selected_rendered_shelf( + rid, + str(slot_id), + int(max(1, int(shelf_index))), + ) + self.shelf_slot_selected.emit(rid, str(slot_id)) # type: ignore[attr-defined] + return + # Для PALLET разрешаем активацию уровня "под первой полкой" + # кликом по области слота (даже если slot_actor скрыт в изоляции). + try: + entry = self._get_rack_entry(rid) if hasattr(self, "_get_rack_entry") else None + zone_id = str((entry or {}).get("zone_id") or "") + slot_hit_id = self._find_slot_id_by_screen( + float(sx), + float(sy), + rid, + zone_id, + screen_is_vtk=True, + include_hidden=True, + ) if hasattr(self, "_find_slot_id_by_screen") else None + rack_type = str(((entry or {}).get("params") or {}).get("rack_type") or "").strip().upper() + if slot_hit_id and rack_type == "PALLET": + self._selected_shelf_visual_rack_id = rid + self._selected_shelf_visual_slot_id = str(slot_hit_id) + self._selected_shelf_visual_index = 1 + if hasattr(self, "_highlight_floor_shelf_polygon"): + self._highlight_floor_shelf_polygon(rid, str(slot_hit_id)) + self.shelf_slot_selected.emit(rid, str(slot_hit_id)) # type: ignore[attr-defined] + return + except Exception as e: + log_exception(__name__, "_on_plotter_click", e) + except Exception as e: + log_exception(__name__, "_on_plotter_click", e) + + if self._zone_pick_enabled and not self._zone_selection_mode and x is not None: + zone_id = self._pick_zone_from_cursor(x, y, z) + if zone_id: + self.zone_selected.emit(zone_id) + return + + # В режиме сетки/контура создание точек не зависит от фиксации камеры. + if self._zone_selection_mode: + x, y, z = self._resolve_grid_click_point(x, y, z, action="add") + if x is None: + return + z = float(z) if z is not None else 0.0 + self._handle_grid_click(x, y, z, action="add") + return + + def _on_plotter_double_click(self: "ModelViewWidget", point): + """Обработчик двойного ЛКМ: выбрать и сфокусировать зону.""" + if not self._models_loaded or self._zone_selection_mode: + return + + # Двойной ЛКМ по полке в изолированном стеллаже: синхронизировать дерево и сфокусировать полку. + # Полка доступна только при активной rack-изоляции (последовательная навигация zone→rack→shelf). + try: + _iso_active = bool(getattr(self, "_rack_isolation_active", False)) + rid = str(getattr(self, "_rack_isolation_rack_id", "") or "") if _iso_active else "" + if rid and self._plotter and self._plotter.interactor and hasattr(self, "_find_rendered_shelf_by_screen"): + sx, sy = self._plotter.interactor.GetEventPosition() + shelf_hit = self._find_rendered_shelf_by_screen(float(sx), float(sy), rid, screen_is_vtk=True) + if shelf_hit: + slot_id, shelf_index = shelf_hit + tree_shelf_index = int(max(1, int(shelf_index))) + try: + entry = self._get_rack_entry(rid) if hasattr(self, "_get_rack_entry") else None + rack_type = str(((entry or {}).get("params") or {}).get("rack_type") or "").strip().upper() + if rack_type == "PALLET": + tree_shelf_index = int(max(1, int(shelf_index)) + 1) + except Exception as e: + log_exception(__name__, "_on_plotter_double_click", e) + tree_shelf_index = int(max(1, int(shelf_index))) + self._selected_shelf_visual_rack_id = rid + self._selected_shelf_visual_slot_id = str(slot_id) + self._selected_shelf_visual_index = int(tree_shelf_index) + if hasattr(self, "_highlight_selected_rendered_shelf"): + self._highlight_selected_rendered_shelf( + rid, + str(slot_id), + int(max(1, int(shelf_index))), + ) + self.shelf_slot_selected.emit(rid, str(slot_id)) # type: ignore[attr-defined] + try: + self.shelf_slot_double_clicked.emit( + rid, + str(slot_id), + int(tree_shelf_index), + ) # type: ignore[attr-defined] + except Exception as e: + log_exception(__name__, "_on_plotter_double_click", e) + zone_id = "" + if hasattr(self, "_get_rack_entry"): + entry = self._get_rack_entry(rid) + if entry: + zone_id = str(entry.get("zone_id") or "") + if hasattr(self, "focus_on_shelf_slot_isometric"): + self.focus_on_shelf_slot_isometric(rid, str(slot_id), int(tree_shelf_index), zone_id) + return + # Для PALLET: двойной клик по области слота активирует уровень пола (index=0). + entry = self._get_rack_entry(rid) if hasattr(self, "_get_rack_entry") else None + zone_id = str((entry or {}).get("zone_id") or "") + slot_hit_id = self._find_slot_id_by_screen( + float(sx), + float(sy), + rid, + zone_id, + screen_is_vtk=True, + include_hidden=True, + ) if hasattr(self, "_find_slot_id_by_screen") else None + rack_type = str(((entry or {}).get("params") or {}).get("rack_type") or "").strip().upper() + if slot_hit_id and rack_type == "PALLET": + self._selected_shelf_visual_rack_id = rid + self._selected_shelf_visual_slot_id = str(slot_hit_id) + self._selected_shelf_visual_index = 1 + if hasattr(self, "_highlight_floor_shelf_polygon"): + self._highlight_floor_shelf_polygon(rid, str(slot_hit_id)) + self.shelf_slot_selected.emit(rid, str(slot_hit_id)) # type: ignore[attr-defined] + try: + self.shelf_slot_double_clicked.emit( + rid, + str(slot_hit_id), + 1, + ) # type: ignore[attr-defined] + except Exception as e: + log_exception(__name__, "_on_plotter_double_click", e) + if hasattr(self, "focus_on_shelf_slot_isometric"): + self.focus_on_shelf_slot_isometric(rid, str(slot_hit_id), 1, zone_id) + return + except Exception as e: + log_exception(__name__, "_on_plotter_double_click", e) + + # При двойном клике по стеллажу — выделение и фокус камеры на стеллаж. + rack_id, rack_zone_id = self._pick_rack_from_cursor() + if rack_id: + if hasattr(self, "_set_selected_rack"): + try: + self._set_selected_rack(rack_id) + except Exception as e: + log_exception(__name__, "_on_plotter_double_click", e) + try: + self.rack_double_clicked.emit(str(rack_id), str(rack_zone_id or "")) + except Exception as e: + log_exception(__name__, "_on_plotter_double_click", e) + if hasattr(self, "focus_on_rack_isometric"): + self.focus_on_rack_isometric(rack_id, rack_zone_id) + return + + if not self._zone_pick_enabled: + return + + x = y = z = None + if point is not None: + try: + x, y, z = point + except Exception as e: + log_exception(__name__, "_on_plotter_double_click", e) + x = y = z = None + + zone_id = self._pick_zone_from_cursor(x, y, z) + if not zone_id: + return + self.zone_selected.emit(zone_id) + try: + self.zone_double_clicked.emit(zone_id) + except Exception as e: + log_exception(__name__, "_on_plotter_double_click", e) + if hasattr(self, "focus_on_zone_isometric"): + self.focus_on_zone_isometric(zone_id) + + def _on_plotter_right_click(self: "ModelViewWidget", point): + """Обработчик ПКМ на 3D-сцене (используется для сокращения объёма сетки).""" + if not self._models_loaded: + return + if not self._zone_selection_mode: + return + # В contour_edit ПКМ обрабатывается через сценарий eventFilter, + # чтобы гарантированно блокировать перехват камеры. + if self.is_scenario_active("contour_edit"): + return + x = y = z = None + if point is not None: + try: + x, y, z = point + except Exception as e: + log_exception(__name__, "_on_plotter_right_click", e) + x = y = z = None + x, y, z = self._resolve_grid_click_point(x, y, z, action="remove") + if x is None: + return + z = float(z) if z is not None else 0.0 + self._handle_grid_click(x, y, z, action="remove") + + # -- масштабирование ------------------------------------------------------ + + def zoom_in(self: "ModelViewWidget"): + """Увеличение масштаба.""" + if not self._models_loaded or not self._plotter: + return + self._plotter.camera.zoom(1.2) + if hasattr(self, "_reset_camera_clipping_range"): + self._reset_camera_clipping_range() + self._plotter.update() + + def zoom_out(self: "ModelViewWidget"): + """Уменьшение масштаба.""" + if not self._models_loaded or not self._plotter: + return + self._plotter.camera.zoom(0.8) + if hasattr(self, "_reset_camera_clipping_range"): + self._reset_camera_clipping_range() + self._plotter.update() + + def _pan_camera_by_pixels(self: "ModelViewWidget", 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 + + # right = normalize(направление_взгляда × up) + 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 + + # нормализация вектора up + 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", e) + return + + def _safe_render(self: "ModelViewWidget", min_interval_s: float = 1.0 / 60.0) -> None: + """Безопасный обновляющий рендер с ограничением частоты.""" + plotter = self._plotter + if not plotter or not self._models_loaded: + return + try: + interactor = getattr(plotter, "interactor", None) + if interactor is None: + return + if hasattr(plotter, "isVisible") and not plotter.isVisible(): + return + if hasattr(interactor, "isVisible") and not interactor.isVisible(): + return + now = time.monotonic() + last_ts = float(getattr(self, "_last_safe_render_ts", 0.0)) + if now - last_ts < float(min_interval_s): + return + self._last_safe_render_ts = now + if hasattr(plotter, "update"): + plotter.update() + else: + plotter.render() + except Exception as e: + log_exception(__name__, "_safe_render", e) + return + + # -- Qt события ----------------------------------------------------------- + + def wheelEvent(self: "ModelViewWidget", event): + """Обработка колесика мыши для зума.""" + if self._camera_locked: + event.accept() + return + if self._models_loaded and self._plotter: + delta = event.angleDelta().y() + if delta > 0: + self.zoom_in() + else: + self.zoom_out() + event.accept() + + +# --------------------------------------------------------------------------- +# Module workflow notes +# --------------------------------------------------------------------------- +# 1) Задача модуля: +# Навигация камеры: клики, масштаб, панорамирование, безопасный рендер. +# +# 2) Последовательность действий и вызовов: +# A. Класс InteractionNavigationMixin: точки входа +# Публичные методы сценария: +# - InteractionNavigationMixin.zoom_in(...) +# - InteractionNavigationMixin.zoom_out(...) +# - InteractionNavigationMixin.wheelEvent(...) +# +# B. InteractionNavigationMixin: основной сценарий: +# InteractionNavigationMixin._on_plotter_click(...) +# Назначение: Обработчик клика на 3D-сцене. +# InteractionNavigationMixin._on_plotter_right_click(...) +# Назначение: Обработчик ПКМ на 3D-сцене (используется для сокращения объёма сетки). +# InteractionNavigationMixin.zoom_in(...) +# Назначение: Увеличение масштаба. +# InteractionNavigationMixin.zoom_out(...) +# Назначение: Уменьшение масштаба. +# InteractionNavigationMixin._pan_camera_by_pixels(...) +# Назначение: Сместить камеру в плоскости экрана на величину в пикселях. +# InteractionNavigationMixin.wheelEvent(...) +# Назначение: Обработка колесика мыши для зума. +# Последовательность внутренних вызовов: +# -> InteractionNavigationMixin.zoom_in(...) +# -> InteractionNavigationMixin.zoom_out(...) +# +# C. InteractionNavigationMixin: вспомогательные расчёты: +# InteractionNavigationMixin._safe_render(...) +# Назначение: Безопасный обновляющий рендер с ограничением частоты. +# +# 3) Важные ограничения и инварианты: +# - Модуль выполняется в составе ModelViewWidget и использует согласованные поля состояния self._... . +# - Все операции с актёрами и камерой выполняются только при валидном self._plotter с безопасной обработкой исключений. +# - Межмодульная связность: только через фасад model_view; прямые обращения между zone, rack, shelf, cell запрещены. +# - Очистка состояния должна быть идемпотентной: повторный вызов не меняет корректное состояние в ошибочное. +# +# 4) Правило сопровождения: +# - Любое изменение сценария должно сопровождаться обновлением этого блока с сохранением фактического порядка вызовов. +# - При добавлении метода указывать его место в цепочке сценария (запуск, основной шаг, завершение, вспомогательная логика). diff --git a/Dispatch_V0.1.1/gui/components/model_view/_mv_model_loading.py b/Dispatch_V0.1.1/gui/components/model_view/_mv_model_loading.py new file mode 100644 index 0000000..e80f134 --- /dev/null +++ b/Dispatch_V0.1.1/gui/components/model_view/_mv_model_loading.py @@ -0,0 +1,409 @@ +# -*- coding: utf-8 -*- +# gui/components/model_view/_mv_model_loading.py +# Загрузка/очистка моделей + +from __future__ import annotations + +import threading +import time +from pathlib import Path +from typing import Optional, Callable, TYPE_CHECKING + +from PySide6.QtWidgets import QApplication, QSizePolicy + +from error_logger import log_exception + +if TYPE_CHECKING: # pragma: no cover + from gui.components.model_view_widget import ModelViewWidget + +try: + import pyvista as pv + from pyvistaqt import QtInteractor + PYVISTA_AVAILABLE = True +except ImportError: + PYVISTA_AVAILABLE = False + + +class ModelLoadingMixin: + """Загрузка моделей пола, стен, потолка и ферм.""" + + # -- публичные ------------------------------------------------------------ + + def load_facility_models( + self: "ModelViewWidget", + model_path: Path, + facility_data: Optional[dict] = None, + progress_callback: Optional[Callable[[int], None]] = None, + ) -> bool: + """Загрузка всех моделей помещения (пол, стены, стеллажи).""" + if not PYVISTA_AVAILABLE: + self.show_error("Библиотека PyVista не установлена") + return False + + if not model_path.exists(): + self.show_error(f"Путь к моделям не найден: {model_path}") + return False + + def _emit_progress(value: int) -> None: + if progress_callback is None: + return + try: + progress_callback(max(0, min(100, int(value)))) + except Exception as e: + log_exception(__name__, "load_facility_models._emit_progress", e) + + self.clear_model() + + try: + _emit_progress(1) + self._plotter = QtInteractor(self) + self._plotter.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) + self._plotter.setMinimumSize(0, 0) + self._main_container.add_widget(self._plotter) + QApplication.processEvents() + if self._plotter.interactor is not None: + self._plotter.interactor.installEventFilter(self) + # Градиентный фон рендера (PyVista/VTK) + self._plotter.set_background((0.84, 0.84, 0.84), top=(0.34, 0.34, 0.34)) + + _emit_progress(5) + missing: list[str] = [] + + def _read_mesh_in_worker(path_str: str, progress_value: int): + payload: dict[str, object] = {} + + def _worker() -> None: + try: + payload["mesh"] = pv.read(path_str) + except Exception as exc: # pragma: no cover + payload["error"] = exc + + thread = threading.Thread(target=_worker, daemon=True) + thread.start() + # Поддерживаем отзывчивость UI во время тяжёлого парсинга STL. + while thread.is_alive(): + _emit_progress(progress_value) + try: + QApplication.processEvents() + except Exception as e: + log_exception(__name__, "load_facility_models._read_mesh_in_worker", e) + time.sleep(0.01) + thread.join() + if "error" in payload: + raise payload["error"] # type: ignore[misc] + return payload.get("mesh") + + def load_model( + key: str, + rel_path: Optional[str], + color: str, + opacity: float, + progress_start: int, + progress_end: int, + ): + _emit_progress(progress_start) + if not rel_path: + _emit_progress(progress_end) + return + rel = Path(rel_path) + if rel.is_absolute(): + file_path = rel + else: + if rel_path.startswith("facility/"): + base_root = model_path.parent.parent + file_path = base_root / rel + else: + file_path = model_path / rel + if not file_path.exists(): + missing.append(rel_path) + _emit_progress(progress_end) + return + try: + mesh = _read_mesh_in_worker(str(file_path), progress_start) + if mesh is None: + _emit_progress(progress_end) + return + actor = self._plotter.add_mesh( + mesh, color=color, opacity=opacity, show_edges=False, + ) + self._model_actors[key] = actor + if key == "floor": + self._floor_mesh = mesh + elif key == "walls": + self._walls_mesh = mesh + elif key == "ceiling": + self._ceiling_mesh = mesh + elif key == "truss": + self._truss_mesh = mesh + print(f"Загружена модель {key}: {file_path}") + except Exception as e: + log_exception(__name__, "load_model", e) + floor_path = facility_data.get("facility_floor") if facility_data else "floor.stl" + walls_path = facility_data.get("facility_walls") if facility_data else "walls.stl" + ceiling_path = facility_data.get("facility_ceiling") if facility_data else None + truss_path = facility_data.get("facility_truss") if facility_data else None + + model_jobs = [ + ("floor", floor_path, "#CCCCCC", 0.8), + ("walls", walls_path, "#AAAAAA", 0.6), + ("ceiling", ceiling_path, "#B0B0B0", 0.5), + ("truss", truss_path, "#888888", 0.5), + ] + total_jobs = max(1, len(model_jobs)) + for idx, (key, rel_path, color, opacity) in enumerate(model_jobs, start=1): + start_p = 10 + int(((idx - 1) / total_jobs) * 60) + end_p = 10 + int((idx / total_jobs) * 60) + load_model(key, rel_path, color, opacity, start_p, end_p) + _emit_progress(end_p) + + # Собираем уникальные вершины для выбора углов + try: + points: list[tuple[float, ...]] = [] + for mesh in (self._floor_mesh, self._walls_mesh, self._ceiling_mesh, self._truss_mesh): + if mesh is None: + continue + if hasattr(mesh, "points"): + points.extend(mesh.points.tolist()) + unique: list[tuple[float, float, float]] = [] + seen: set[tuple[float, float, float]] = set() + for px, py, pz in points: + key = (round(px, 3), round(py, 3), round(pz, 3)) + if key in seen: + continue + seen.add(key) + unique.append((px, py, pz)) + self.set_corner_points(unique) + except Exception as e: + log_exception(__name__, "load_facility_models", e) + _emit_progress(75) + + # Рассчитываем bounding box помещения + self._calculate_room_bounds() + _emit_progress(82) + + # Настраиваем стартовый изометрический вид facility. + self._apply_facility_isometric_view(reset_camera=True, update=False) + self._plotter.enable_trackball_style() + self._setup_trackball_right_button() + _emit_progress(90) + + # Подключаем обработчик кликов + self._plotter.track_click_position( + callback=self._on_plotter_click, + side="left", + double=False, + ) + self._plotter.track_click_position( + callback=self._on_plotter_double_click, + side="left", + double=True, + ) + self._plotter.track_click_position( + callback=self._on_plotter_right_click, + side="right", + double=False, + ) + _emit_progress(95) + self._models_loaded = True + self._reset_camera_clipping_range() + + print( + f"Загружено моделей: пол={self._floor_mesh is not None}, " + f"стены={self._walls_mesh is not None}, " + f"потолок={'ceiling' in self._model_actors}, " + f"фермы={'truss' in self._model_actors}" + ) + + self._missing_models = set(missing) + _emit_progress(100) + return True + + except Exception as e: + error_msg = f"Ошибка загрузки моделей: {e}" + print(error_msg) + self.show_error(error_msg) + return False + + # -- границы -------------------------------------------------------------- + + def _calculate_room_bounds(self: "ModelViewWidget") -> None: + """Рассчёт bounding box помещения на основе моделей.""" + if not self._floor_mesh: + return + try: + bounds = self._floor_mesh.bounds + if self._walls_mesh: + wb = self._walls_mesh.bounds + bounds = ( + min(bounds[0], wb[0]), + max(bounds[1], wb[1]), + min(bounds[2], wb[2]), + max(bounds[3], wb[3]), + min(bounds[4], wb[4]), + max(bounds[5], wb[5]), + ) + for mesh in (self._ceiling_mesh, self._truss_mesh): + if mesh is None: + continue + mb = mesh.bounds + bounds = ( + min(bounds[0], mb[0]), + max(bounds[1], mb[1]), + min(bounds[2], mb[2]), + max(bounds[3], mb[3]), + min(bounds[4], mb[4]), + max(bounds[5], mb[5]), + ) + self._room_bounds = bounds + print(f"Room bounds: {bounds}") + except Exception as e: + print(f"Ошибка расчета bounding box: {e}") + self._room_bounds = None + + # -- очистка / видимость -------------------------------------------------- + + def clear_model(self: "ModelViewWidget") -> None: + """Очистка области модели.""" + self._models_loaded = False + try: + self.clear_all_racks() + except Exception as e: + log_exception(__name__, "clear_model", e) + + if self._plotter is not None: + try: + interactor = getattr(self._plotter, "interactor", None) + if interactor is not None: + try: + interactor.removeEventFilter(self) + except Exception as e: + log_exception(__name__, "clear_model", e) + self._plotter.close() + except Exception as e: + log_exception(__name__, "clear_model", e) + self._plotter = None + + if hasattr(self, "_main_container") and self._main_container is not None: + content_host = getattr(self._main_container, "_content_host", None) + if content_host is not None: + layout = content_host.get_layout() + while layout.count(): + item = layout.takeAt(0) + w = item.widget() + if w is not None: + w.setParent(None) + w.deleteLater() + + self._zones.clear() + self._zone_data.clear() + self._floor_mesh = None + self._walls_mesh = None + self._ceiling_mesh = None + self._truss_mesh = None + self._rack_meshes.clear() + self._room_bounds = None + self._model_actors.clear() + + def _reload_scene(self: "ModelViewWidget") -> None: + """Перезагрузка сцены (используется при удалении зон).""" + if not self._models_loaded: + return + current_camera = self._plotter.camera_position # noqa: F841 + zones_backup = list(self._zones.items()) # noqa: F841 + self.clear_model() + print("Требуется перезагрузка моделей для обновления зон") + + def _reset_camera_clipping_range(self: "ModelViewWidget") -> None: + """Обновить near/far clipping range камеры по текущей сцене.""" + if not self._plotter: + return + try: + renderer = getattr(self._plotter, "renderer", None) + if renderer is not None: + renderer.ResetCameraClippingRange() + else: + reset_fn = getattr(self._plotter, "reset_camera_clipping_range", None) + if callable(reset_fn): + reset_fn() + except Exception as e: + log_exception(__name__, "_reset_camera_clipping_range", e) + + def set_model_visibility(self: "ModelViewWidget", key: str, visible: bool) -> None: + actor = self._model_actors.get(key) + if actor is None: + return + try: + actor.SetVisibility(1 if visible else 0) + self._plotter.update() + except Exception as e: + log_exception(__name__, "set_model_visibility", e) + + def has_model(self: "ModelViewWidget", key: str) -> bool: + return key in self._model_actors + + def is_model_visible(self: "ModelViewWidget", key: str) -> bool: + actor = self._model_actors.get(key) + if actor is None: + return False + try: + return bool(actor.GetVisibility()) + except Exception as e: + log_exception(__name__, "is_model_visible", e) + return False + + +# --------------------------------------------------------------------------- +# Module workflow notes +# --------------------------------------------------------------------------- +# 1) Задача модуля: +# Загрузка и очистка моделей помещения, пересборка сцены и управление видимостью. +# +# 2) Последовательность действий и вызовов: +# A. Класс ModelLoadingMixin: точки входа +# Публичные методы сценария: +# - ModelLoadingMixin.load_facility_models(...) +# - ModelLoadingMixin.clear_model(...) +# - ModelLoadingMixin.set_model_visibility(...) +# - ModelLoadingMixin.has_model(...) +# - ModelLoadingMixin.is_model_visible(...) +# +# B. ModelLoadingMixin: запуск и настройка: +# ModelLoadingMixin.load_facility_models(...) +# Назначение: Загрузка всех моделей помещения (пол, стены, стеллажи). +# Последовательность внутренних вызовов: +# -> ModelLoadingMixin.clear_model(...) +# -> ModelLoadingMixin._calculate_room_bounds(...) +# -> ModelLoadingMixin._reset_camera_clipping_range(...) +# ModelLoadingMixin.set_model_visibility(...) +# Назначение: устанавливает model visibility в рамках текущего сценария модуля. +# +# C. ModelLoadingMixin: основной сценарий: +# ModelLoadingMixin.has_model(...) +# Назначение: проверяет наличие model в рамках текущего сценария модуля. +# ModelLoadingMixin.is_model_visible(...) +# Назначение: проверяет, что model visible в рамках текущего сценария модуля. +# +# D. ModelLoadingMixin: завершение и очистка: +# ModelLoadingMixin.clear_model(...) +# Назначение: Очистка области модели. +# +# E. ModelLoadingMixin: вспомогательные расчёты: +# ModelLoadingMixin._calculate_room_bounds(...) +# Назначение: Рассчёт bounding box помещения на основе моделей. +# ModelLoadingMixin._reload_scene(...) +# Назначение: Перезагрузка сцены (используется при удалении зон). +# Последовательность внутренних вызовов: +# -> ModelLoadingMixin.clear_model(...) +# ModelLoadingMixin._reset_camera_clipping_range(...) +# Назначение: Обновить near/far clipping range камеры по текущей сцене. +# +# 3) Важные ограничения и инварианты: +# - Модуль выполняется в составе ModelViewWidget и использует согласованные поля состояния self._... . +# - Все операции с актёрами и камерой выполняются только при валидном self._plotter с безопасной обработкой исключений. +# - Геометрическая визуализация зависит от pyvista/vtk; при недоступности модуль обязан завершать шаг без падения сценария. +# - Межмодульная связность: только через фасад model_view; прямые обращения между zone, rack, shelf, cell запрещены. +# - Очистка состояния должна быть идемпотентной: повторный вызов не меняет корректное состояние в ошибочное. +# +# 4) Правило сопровождения: +# - Любое изменение сценария должно сопровождаться обновлением этого блока с сохранением фактического порядка вызовов. +# - При добавлении метода указывать его место в цепочке сценария (запуск, основной шаг, завершение, вспомогательная логика). diff --git a/Dispatch_V0.1.1/gui/components/model_view/_mv_presentation.py b/Dispatch_V0.1.1/gui/components/model_view/_mv_presentation.py new file mode 100644 index 0000000..390a7cd --- /dev/null +++ b/Dispatch_V0.1.1/gui/components/model_view/_mv_presentation.py @@ -0,0 +1,131 @@ +# -*- coding: utf-8 -*- +# gui/components/model_view/_mv_presentation.py +"""Фасад видимости сцены для применения декларативных спецификаций представления.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from error_logger import log_exception + +if TYPE_CHECKING: + from gui.components.model_view_widget import ModelViewWidget + + +class ScenePresentationMixin: + """Применение спецификаций видимости сцены через публичный API ModelView.""" + + @staticmethod + def _safe_call(fn, *args, **kwargs): + try: + return fn(*args, **kwargs) + except Exception as exc: + log_exception(__name__, "ScenePresentationMixin._safe_call", exc) + return None + + @staticmethod + def _rack_filter_value(spec) -> str: + rack_filter = getattr(spec, "rack_filter", "") + return str(getattr(rack_filter, "value", rack_filter) or "") + + def apply_scene_spec( + self: "ModelViewWidget", + spec, + *, + start_select_rack: bool = False, + select_rack_id: str | None = None, + ) -> None: + """Применить ``SceneVisibilitySpec``, созданный политикой представления.""" + if self is None or spec is None: + return + + zone_contour_id = getattr(spec, "zone_contour_id", None) + if zone_contour_id: + self._safe_call(self.show_zone_contour, zone_contour_id) + elif bool(getattr(spec, "zone_solids_visible", False)): + self._safe_call(self.show_all_zones) + + if bool(getattr(spec, "use_facility_contour_mode", False)) and hasattr( + self, "set_zone_facility_contour_mode" + ): + zone_ids = [str(zid) for zid in getattr(self, "_zones", {}).keys()] + for zid in zone_ids: + has_racks = self.has_racks_in_zone(zid) if hasattr(self, "has_racks_in_zone") else False + self._safe_call(self.set_zone_facility_contour_mode, zid, has_racks) + + if bool(getattr(spec, "hide_empty_zones", False)) and hasattr(self, "has_racks_in_zone"): + zone_ids = [str(zid) for zid in getattr(self, "_zones", {}).keys()] + for zid in zone_ids: + if not self.has_racks_in_zone(zid): + self._safe_call(self.set_zone_visibility, zid, False) + else: + self._safe_call(self.set_zone_facility_contour_mode, zid, False) + self._safe_call(self.set_zone_visibility, zid, False) + + self._safe_call(self.set_zone_pick_enabled, bool(getattr(spec, "zone_pick_enabled", True))) + + rack_filter = self._rack_filter_value(spec) + rack_zone_id = getattr(spec, "rack_zone_id", None) + rack_id = getattr(spec, "rack_id", None) + if rack_filter == "single_rack": + isolated = False + if hasattr(self, "show_only_rack") and rack_id: + isolated = bool(self._safe_call(self.show_only_rack, rack_id, rack_zone_id)) + if not isolated and rack_zone_id: + self._safe_call(self.show_only_zone_racks, rack_zone_id) + elif rack_filter == "zone_only": + self._safe_call(self.show_only_zone_racks, rack_zone_id) + else: + self._safe_call(self.show_all_racks) + + rack_select_mode = bool(getattr(spec, "rack_select_mode", False)) + if rack_select_mode and start_select_rack and rack_zone_id: + self._safe_call(self.start_select_rack_mode, rack_zone_id) + elif not rack_select_mode: + self._safe_call(self.stop_select_rack_mode) + + if select_rack_id and hasattr(self, "select_rack_by_id"): + self._safe_call(self.select_rack_by_id, select_rack_id, rack_zone_id) + + if bool(getattr(spec, "clear_hover", False)): + mgr = getattr(self, "_interaction_manager", None) + if mgr is not None: + self._safe_call(mgr.reset) + + self._safe_call(self.set_top_view_navigation, bool(getattr(spec, "top_view_navigation", False))) + if hasattr(self, "update_scene"): + self._safe_call(self.update_scene) + + +# --------------------------------------------------------------------------- +# Module workflow notes +# --------------------------------------------------------------------------- +# 1) Задача модуля: +# Применение наборов параметров отображения сцены и безопасный вызов методов представления. +# +# 2) Последовательность действий и вызовов: +# A. Класс ScenePresentationMixin: точки входа +# Публичные методы сценария: +# - ScenePresentationMixin.apply_scene_spec(...) +# +# B. ScenePresentationMixin: основной сценарий: +# ScenePresentationMixin.apply_scene_spec(...) +# Назначение: Применить ``SceneVisibilitySpec``, созданный политикой представления. +# Последовательность внутренних вызовов: +# -> ScenePresentationMixin._safe_call(...) +# -> ScenePresentationMixin._rack_filter_value(...) +# +# C. ScenePresentationMixin: вспомогательные расчёты: +# ScenePresentationMixin._safe_call(...) +# Назначение: выполняет шаг "safe call" в рамках текущего сценария модуля. +# ScenePresentationMixin._rack_filter_value(...) +# Назначение: выполняет шаг "rack filter value" в рамках текущего сценария модуля. +# +# 3) Важные ограничения и инварианты: +# - Модуль выполняется в составе ModelViewWidget и использует согласованные поля состояния self._... . +# - Межмодульная связность: только через фасад model_view; прямые обращения между zone, rack, shelf, cell запрещены. +# - Очистка состояния должна быть идемпотентной: повторный вызов не меняет корректное состояние в ошибочное. +# +# 4) Правило сопровождения: +# - Любое изменение сценария должно сопровождаться обновлением этого блока с сохранением фактического порядка вызовов. +# - При добавлении метода указывать его место в цепочке сценария (запуск, основной шаг, завершение, вспомогательная логика). diff --git a/Dispatch_V0.1.1/gui/components/model_view/_mv_rack_geometry.py b/Dispatch_V0.1.1/gui/components/model_view/_mv_rack_geometry.py new file mode 100644 index 0000000..8ed8a56 --- /dev/null +++ b/Dispatch_V0.1.1/gui/components/model_view/_mv_rack_geometry.py @@ -0,0 +1,197 @@ +# -*- coding: utf-8 -*- +# gui/components/model_view/_mv_rack_geometry.py +"""Локальная геометрия размещения стоек (без зависимостей от hub).""" + +from __future__ import annotations + +from typing import Any + +PLACEMENT_STEP_MM = 100 +MIN_WALL_BUFFER_MM = 100 +MIN_AISLE_MM = 900 +MIN_BACK_TO_BACK_GAP_MM = 100 + + +def rotate_xy(x: float, y: float, rotation: int) -> tuple[float, float]: + rot = int(rotation) % 360 + if rot == 90: + return -y, x + if rot == 180: + return -x, -y + if rot == 270: + return y, -x + return x, y + + +def rack_bbox( + cx: float, + cy: float, + params: dict[str, Any], + rotation: int, + wall_buffer: float = 0.0, +) -> tuple[float, float, float, float]: + width = float(params.get("footprint_width_mm", 1000)) + depth = float(params.get("footprint_depth_mm", 500)) + if int(rotation) % 180 == 90: + width, depth = depth, width + width += wall_buffer * 2.0 + depth += wall_buffer * 2.0 + half_w = width / 2.0 + half_d = depth / 2.0 + return cx - half_w, cx + half_w, cy - half_d, cy + half_d + + +def passes_clearance_rule( + a: tuple[float, float, float, float], + b: tuple[float, float, float, float], + min_gap: float, +) -> bool: + a_min_x, a_max_x, a_min_y, a_max_y = a + b_min_x, b_max_x, b_min_y, b_max_y = b + overlap_x = min(a_max_x, b_max_x) - max(a_min_x, b_min_x) + overlap_y = min(a_max_y, b_max_y) - max(a_min_y, b_min_y) + if overlap_x > 0 and overlap_y > 0: + return False + gap_x = max(0.0, max(a_min_x - b_max_x, b_min_x - a_max_x)) + gap_y = max(0.0, max(a_min_y - b_max_y, b_min_y - a_max_y)) + if overlap_y > 0 and gap_x < min_gap: + return False + if overlap_x > 0 and gap_y < min_gap: + return False + # Диагональный случай: учитываем минимальную евклидову дистанцию между прямоугольниками. + if gap_x > 0 and gap_y > 0 and (gap_x * gap_x + gap_y * gap_y) ** 0.5 < min_gap: + return False + return True + + +def bboxes_intersect( + a: tuple[float, float, float, float], + b: tuple[float, float, float, float], + eps: float = 1e-6, +) -> bool: + a_min_x, a_max_x, a_min_y, a_max_y = a + b_min_x, b_max_x, b_min_y, b_max_y = b + overlap_x = min(a_max_x, b_max_x) - max(a_min_x, b_min_x) + overlap_y = min(a_max_y, b_max_y) - max(a_min_y, b_min_y) + return overlap_x > eps and overlap_y > eps + +# зона подхода к стеллажу +def rack_aisle_bboxes( + cx: float, + cy: float, + params: dict[str, Any], + rotation: int, + aisle_mm: float = MIN_AISLE_MM, +) -> list[tuple[float, float, float, float]]: + width = float(params.get("footprint_width_mm", 1000)) + depth = float(params.get("footprint_depth_mm", 500)) + aisle = float(aisle_mm) + + # Зона доступа представлена одним объёмом (один прямоугольник). + strips = [ + (0.0, aisle / 2.0, width + 2.0 * aisle, depth + aisle), + ] + result: list[tuple[float, float, float, float]] = [] + for lx, ly, xl, yl in strips: + wx, wy = rotate_xy(lx, ly, rotation) + if int(rotation) % 180 == 90: + xl, yl = yl, xl + hxl = max(10.0, float(xl)) / 2.0 + hyl = max(10.0, float(yl)) / 2.0 + result.append((cx + wx - hxl, cx + wx + hxl, cy + wy - hyl, cy + wy + hyl)) + return result + + +def front_vector(rotation: int) -> tuple[int, int]: + rot = int(rotation) % 360 + if rot == 90: + return 1, 0 + if rot == 180: + return 0, -1 + if rot == 270: + return -1, 0 + return 0, 1 + + +def is_back_to_back(rotation_a: int, rotation_b: int) -> bool: + ax, ay = front_vector(rotation_a) + bx, by = front_vector(rotation_b) + return (ax * bx + ay * by) < 0 + + +def pillar_positions( + cx: float, + cy: float, + params: dict[str, Any], + rotation: int, +) -> list[tuple[float, float]]: + center_spans = [float(v) for v in (params.get("center_spans_mm") or [1000])] + depth = float(params.get("footprint_depth_mm", 500)) + total_width = float(sum(center_spans)) + x_points = [-total_width / 2.0] + for span in center_spans: + x_points.append(x_points[-1] + span) + y_points = (-depth / 2.0, depth / 2.0) + positions: list[tuple[float, float]] = [] + for lx in x_points: + for ly in y_points: + rx, ry = rotate_xy(lx, ly, rotation) + positions.append((cx + rx, cy + ry)) + return positions + + +def support_line_positions( + cx: float, + cy: float, + params: dict[str, Any], + rotation: int, +) -> list[tuple[float, float]]: + """Позиции опорных линий стоек (для 1 секции = 2 стойки).""" + center_spans = [float(v) for v in (params.get("center_spans_mm") or [1000])] + total_width = float(sum(center_spans)) + x_points = [-total_width / 2.0] + for span in center_spans: + x_points.append(x_points[-1] + span) + positions: list[tuple[float, float]] = [] + for lx in x_points: + rx, ry = rotate_xy(lx, 0.0, rotation) + positions.append((cx + rx, cy + ry)) + return positions + + +# --------------------------------------------------------------------------- +# Module workflow notes +# --------------------------------------------------------------------------- +# 1) Задача модуля: +# Чистые функции геометрии стеллажей: габариты, пересечения, проходы, опоры и ориентация. +# +# 2) Последовательность действий и вызовов: +# A. Функции уровня модуля: +# rotate_xy(...) +# Назначение: выполняет шаг "rotate xy" в рамках текущего сценария модуля. +# rack_bbox(...) +# Назначение: выполняет шаг "rack bbox" в рамках текущего сценария модуля. +# passes_clearance_rule(...) +# Назначение: выполняет шаг "passes clearance rule" в рамках текущего сценария модуля. +# bboxes_intersect(...) +# Назначение: выполняет шаг "bboxes intersect" в рамках текущего сценария модуля. +# rack_aisle_bboxes(...) +# Назначение: выполняет шаг "rack aisle bboxes" в рамках текущего сценария модуля. +# front_vector(...) +# Назначение: выполняет шаг "front vector" в рамках текущего сценария модуля. +# is_back_to_back(...) +# Назначение: проверяет, что back to back в рамках текущего сценария модуля. +# pillar_positions(...) +# Назначение: выполняет шаг "pillar positions" в рамках текущего сценария модуля. +# support_line_positions(...) +# Назначение: Позиции опорных линий стоек (для 1 секции = 2 стойки). +# +# 3) Важные ограничения и инварианты: +# - Модуль выполняется в составе ModelViewWidget и использует согласованные поля состояния self._... . +# - Межмодульная связность: только через фасад model_view; прямые обращения между zone, rack, shelf, cell запрещены. +# - Очистка состояния должна быть идемпотентной: повторный вызов не меняет корректное состояние в ошибочное. +# +# 4) Правило сопровождения: +# - Любое изменение сценария должно сопровождаться обновлением этого блока с сохранением фактического порядка вызовов. +# - При добавлении метода указывать его место в цепочке сценария (запуск, основной шаг, завершение, вспомогательная логика). + diff --git a/Dispatch_V0.1.1/gui/components/model_view/_mv_rack_transition.py b/Dispatch_V0.1.1/gui/components/model_view/_mv_rack_transition.py new file mode 100644 index 0000000..c0f26e7 --- /dev/null +++ b/Dispatch_V0.1.1/gui/components/model_view/_mv_rack_transition.py @@ -0,0 +1,162 @@ +# -*- coding: utf-8 -*- +# gui/components/model_view/_mv_rack_transition.py +"""Помощники плавного перехода камеры для навигации на уровне стоек.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from error_logger import log_exception + +if TYPE_CHECKING: # pragma: no cover + from gui.components.model_view_widget import ModelViewWidget + + +class RackCameraTransitionMixin: + """Анимация фокусировки камеры на выбранной стойке в изометрическом виде.""" + + def focus_on_rack_isometric( + self: "ModelViewWidget", + rack_id: str | None, + zone_id: str | None = None, + *, + duration_ms: int = 320, + ) -> bool: + if not self._plotter or not rack_id: + return False + if not hasattr(self, "_get_rack_entry"): + return False + if not hasattr(self, "_animate_camera_transition"): + return False + cam = getattr(self._plotter, "camera", None) + if cam is None: + return False + rid = str(rack_id or "") + zid = str(zone_id or "") + entry = self._get_rack_entry(rid, zid) if zid else self._get_rack_entry(rid) + if entry is None: + return False + + bounds = entry.get("bbox") + if not bounds and hasattr(self, "_rack_container_bbox"): + try: + bounds = self._rack_container_bbox(entry) + except Exception as exc: + log_exception(__name__, "focus_on_rack_isometric._rack_container_bbox", exc) + bounds = None + if not bounds: + return False + + try: + min_x, max_x, min_y, max_y = [float(v) for v in bounds[:4]] + # Оценка диапазона Z по базовой линии стойки/зоны, когда полный bbox недоступен. + min_z = 0.0 + max_z = 2000.0 + if len(bounds) >= 6: + min_z = float(bounds[4]) + max_z = float(bounds[5]) + else: + params = dict(entry.get("params") or {}) + rack_height = 2000.0 + if hasattr(self, "_rack_height_mm"): + rack_height = float(self._rack_height_mm(params)) + entry_zone_id = str(entry.get("zone_id") or "") + zone_bounds = (self._zone_data or {}).get(entry_zone_id) + if zone_bounds and len(zone_bounds) >= 6: + try: + min_z = float(zone_bounds[4]) + except Exception as exc: + log_exception(__name__, "focus_on_rack_isometric.zone_bounds", exc) + min_z = 0.0 + max_z = min_z + max(1.0, rack_height) + + cx = (min_x + max_x) * 0.5 + cy = (min_y + max_y) * 0.5 + cz = min_z + (max(1.0, max_z - min_z) * 0.54) + sx = max(1.0, max_x - min_x) + sy = max(1.0, max_y - min_y) + sz = max(1.0, max_z - min_z) + span = max(sx, sy, sz) + + start_pos = tuple(float(v) for v in cam.GetPosition()) + start_focal = tuple(float(v) for v in cam.GetFocalPoint()) + start_up = tuple(float(v) for v in cam.GetViewUp()) + + vx, vy, vz = 1.0, -1.0, 0.78 + norm = (vx * vx + vy * vy + vz * vz) ** 0.5 + vx, vy, vz = vx / norm, vy / norm, vz / norm + ux, uy, uz = 0.0, 0.0, 1.0 + fx, fy, fz = -vx, -vy, -vz + rx = fy * uz - fz * uy + ry = fz * ux - fx * uz + rz = fx * uy - fy * ux + r_norm = (rx * rx + ry * ry + rz * rz) ** 0.5 + if r_norm <= 1e-6: + rx, ry, rz = 1.0, 0.0, 0.0 + else: + rx, ry, rz = rx / r_norm, ry / r_norm, rz / r_norm + + rack_margin = max(1.01, float(getattr(self, "_rack_iso_margin_factor", 1.10))) + rack_min_distance = max(800.0, float(getattr(self, "_rack_iso_min_distance", 1700.0))) + rack_side_shift = max(0.0, float(getattr(self, "_rack_iso_side_shift_factor", 0.10))) + rack_z_lift = max(0.0, float(getattr(self, "_rack_iso_z_lift_factor", 0.08))) + rack_distance_scale = max(1.0, float(getattr(self, "_rack_iso_distance_scale", 1.40))) + if hasattr(self, "_compute_isometric_fit_distance"): + distance = float( + self._compute_isometric_fit_distance( + (min_x, max_x, min_y, max_y, min_z, max_z), + view_dir=(vx, vy, vz), + up_dir=(ux, uy, uz), + margin_factor=rack_margin, + min_distance=rack_min_distance, + ) + ) + else: + distance = max(rack_min_distance, span * 2.9) + distance *= rack_distance_scale + target_focal = (cx, cy, cz) + target_pos = ( + cx + vx * distance + rx * (span * rack_side_shift), + cy + vy * distance + ry * (span * rack_side_shift), + cz + vz * distance + (sz * rack_z_lift), + ) + self._animate_camera_transition( + start_pos=start_pos, + start_focal=start_focal, + start_up=start_up, + target_pos=target_pos, + target_focal=target_focal, + target_up=(ux, uy, uz), + duration_ms=int(duration_ms), + steps=16, + ) + return True + except Exception as exc: + log_exception(__name__, "focus_on_rack_isometric", exc) + return False + + +# --------------------------------------------------------------------------- +# Module workflow notes +# --------------------------------------------------------------------------- +# 1) Задача модуля: +# Переход камеры к выбранному стеллажу в изометрическом ракурсе. +# +# 2) Последовательность действий и вызовов: +# A. Класс RackCameraTransitionMixin: точки входа +# Публичные методы сценария: +# - RackCameraTransitionMixin.focus_on_rack_isometric(...) +# +# B. RackCameraTransitionMixin: основной сценарий: +# RackCameraTransitionMixin.focus_on_rack_isometric(...) +# Назначение: фокусирует on rack isometric в рамках текущего сценария модуля. +# +# 3) Важные ограничения и инварианты: +# - Модуль выполняется в составе ModelViewWidget и использует согласованные поля состояния self._... . +# - Все операции с актёрами и камерой выполняются только при валидном self._plotter с безопасной обработкой исключений. +# - Межмодульная связность: только через фасад model_view; прямые обращения между zone, rack, shelf, cell запрещены. +# - Очистка состояния должна быть идемпотентной: повторный вызов не меняет корректное состояние в ошибочное. +# +# 4) Правило сопровождения: +# - Любое изменение сценария должно сопровождаться обновлением этого блока с сохранением фактического порядка вызовов. +# - При добавлении метода указывать его место в цепочке сценария (запуск, основной шаг, завершение, вспомогательная логика). diff --git a/Dispatch_V0.1.1/gui/components/model_view/_mv_racks.py b/Dispatch_V0.1.1/gui/components/model_view/_mv_racks.py new file mode 100644 index 0000000..e4f5c15 --- /dev/null +++ b/Dispatch_V0.1.1/gui/components/model_view/_mv_racks.py @@ -0,0 +1,104 @@ +# -*- coding: utf-8 -*- +# gui/components/model_view/_mv_racks.py +"""Логика работы с размещением стеллажей и связанными визуальными состояниями. + +Этот модуль компонует полный ``RackPlacementMixin`` из набора +мелких подмиксинов, каждый из которых отвечает за отдельную область +логики размещения стеллажей. Также здесь хранятся константы уровня +класса, общие для всех подмиксинов. +""" + +from __future__ import annotations + +from gui.components.model_view._mv_racks_projection import RackPlacementProjectionMixin +from gui.components.model_view._mv_racks_visual import RackPlacementVisualMixin + +from gui.components.model_view._mv_racks_lifecycle import RackLifecycleMixin +from gui.components.model_view._mv_racks_crud import RackCrudMixin +from gui.components.model_view._mv_racks_camera import RackCameraMixin +from gui.components.model_view._mv_racks_shelf import RackShelfMixin +from gui.components.model_view._mv_racks_selection import RackSelectionMixin +from gui.components.model_view._mv_racks_move import RackMoveMixin +from gui.components.model_view._mv_racks_picking import RackPickingMixin +from gui.components.model_view._mv_racks_shelf_render import RackShelfRenderMixin +from gui.components.model_view._mv_racks_hover import RackHoverMixin +from gui.components.model_view._mv_racks_preview import RackPreviewMixin +from gui.components.model_view._mv_racks_codes import RackCodesMixin +from gui.components.model_view._mv_racks_collision import RackCollisionMixin +from gui.components.model_view._mv_racks_mezzanine import RackMezzanineMixin + + +class RackPlacementMixin( + RackLifecycleMixin, + RackCrudMixin, + RackCameraMixin, + RackShelfMixin, + RackSelectionMixin, + RackMoveMixin, + RackPickingMixin, + RackShelfRenderMixin, + RackHoverMixin, + RackPreviewMixin, + RackCodesMixin, + RackCollisionMixin, + RackMezzanineMixin, + RackPlacementProjectionMixin, + RackPlacementVisualMixin, +): + """Миксин с логикой размещения стеллажей внутри зоны.""" + + _PALLET_CENTER_SPAN_MM = 3706 + _PALLET_FOOTPRINT_WIDTH_SINGLE_MM = 3796 + _PALLET_DEPTH_MM = 1100 + _PALLET_HEIGHT_MM = 2505 + _PALLET_BUFFER_MM = 100 + _RACK_COLLISION_BUFFER_MM = 100 + _MAX_BUFFER_OVERLAP_MM = 100 + _MEZZANINE_CONTAINMENT_MARGIN_MM = 100 + _SHELF_NORMS = { + "A": { + "base_surface_offset_from_bbox_top_mm": 0.0, + "min_base_height_mm": 80.0, + "min_useful_height_mm": 16.0, + "min_inter_shelf_mm": 53.0, + "shelf_height_mm": 37.0, + "step_mm": 53.0, + }, + "B": { + "base_surface_offset_from_bbox_top_mm": 15.0, + "min_base_height_mm": 130.0, + "min_useful_height_mm": 68.0, + "min_inter_shelf_mm": 113.0, + "shelf_height_mm": 45.0, + "step_mm": 37.5, + }, + "PALLET": { + "base_surface_offset_from_bbox_top_mm": 55.0, + "min_base_height_mm": 252.0, + "min_useful_height_mm": 112.0, + "min_inter_shelf_mm": 250.0, + "shelf_height_mm": 250.0, + "step_mm": 50.0, + }, + } + + +# --------------------------------------------------------------------------- +# Module workflow notes +# --------------------------------------------------------------------------- +# 1) Задача модуля: +# Композиционный фасад подсистемы стеллажей. +# +# 2) Последовательность действий и вызовов: +# A. Композиционный класс RackPlacementMixin: +# Назначение: объединяет поведение через RackLifecycleMixin, RackCrudMixin, RackCameraMixin, RackShelfMixin, RackSelectionMixin, RackMoveMixin, RackPickingMixin, RackShelfRenderMixin, RackHoverMixin, RackPreviewMixin, RackCodesMixin, RackCollisionMixin, RackMezzanineMixin, RackPlacementProjectionMixin, RackPlacementVisualMixin. +# Собственная вычислительная логика отсутствует; маршрутизация идёт в родительские миксины. +# +# 3) Важные ограничения и инварианты: +# - Модуль выполняется в составе ModelViewWidget и использует согласованные поля состояния self._... . +# - Межмодульная связность: только через фасад model_view; прямые обращения между zone, rack, shelf, cell запрещены. +# - Очистка состояния должна быть идемпотентной: повторный вызов не меняет корректное состояние в ошибочное. +# +# 4) Правило сопровождения: +# - Любое изменение сценария должно сопровождаться обновлением этого блока с сохранением фактического порядка вызовов. +# - При добавлении метода указывать его место в цепочке сценария (запуск, основной шаг, завершение, вспомогательная логика). diff --git a/Dispatch_V0.1.1/gui/components/model_view/_mv_racks_camera.py b/Dispatch_V0.1.1/gui/components/model_view/_mv_racks_camera.py new file mode 100644 index 0000000..1c1b567 --- /dev/null +++ b/Dispatch_V0.1.1/gui/components/model_view/_mv_racks_camera.py @@ -0,0 +1,321 @@ +# -*- coding: utf-8 -*- +"""Вспомогательные функции камеры для изометрической фокусировки на стеллаже / слоте полки и анимированных переходов.""" + +from __future__ import annotations + +import math +from typing import TYPE_CHECKING + +from PySide6.QtCore import QTimer + +from error_logger import log_exception + +if TYPE_CHECKING: + from gui.components.model_view_widget import ModelViewWidget + + +class RackCameraMixin: + """Миксин с утилитами фокусировки камеры и анимации для видов стеллажей.""" + + def _compute_isometric_fit_distance( + self: "ModelViewWidget", + bounds: tuple[float, float, float, float, float, float], + *, + view_dir: tuple[float, float, float], + up_dir: tuple[float, float, float] = (0.0, 0.0, 1.0), + margin_factor: float = 1.10, + min_distance: float = 700.0, + ) -> float: + """Вернуть дистанцию камеры, чтобы bbox гарантированно помещался в кадр.""" + if not self._plotter: + return float(min_distance) + cam = getattr(self._plotter, "camera", None) + if cam is None: + return float(min_distance) + + min_x, max_x, min_y, max_y, min_z, max_z = [float(v) for v in bounds] + ex = max(0.5, (max_x - min_x) * 0.5) + ey = max(0.5, (max_y - min_y) * 0.5) + ez = max(0.5, (max_z - min_z) * 0.5) + + vx, vy, vz = [float(v) for v in view_dir] + ux, uy, uz = [float(v) for v in up_dir] + v_norm = max(1e-6, (vx * vx + vy * vy + vz * vz) ** 0.5) + u_norm = max(1e-6, (ux * ux + uy * uy + uz * uz) ** 0.5) + vx, vy, vz = vx / v_norm, vy / v_norm, vz / v_norm + ux, uy, uz = ux / u_norm, uy / u_norm, uz / u_norm + + fx, fy, fz = -vx, -vy, -vz + rx = fy * uz - fz * uy + ry = fz * ux - fx * uz + rz = fx * uy - fy * ux + r_norm = max(1e-6, (rx * rx + ry * ry + rz * rz) ** 0.5) + rx, ry, rz = rx / r_norm, ry / r_norm, rz / r_norm + + half_w = abs(rx) * ex + abs(ry) * ey + abs(rz) * ez + half_h = abs(ux) * ex + abs(uy) * ey + abs(uz) * ez + + view_angle_deg = 30.0 + try: + view_angle_deg = float(cam.GetViewAngle()) + except Exception as e: + log_exception(__name__, "_compute_isometric_fit_distance", e) + if view_angle_deg <= 1e-3: + view_angle_deg = 30.0 + half_vfov = (view_angle_deg * 0.5) * (3.141592653589793 / 180.0) + tan_half_v = max(1e-4, math.tan(half_vfov)) + + aspect = 1.0 + try: + ren_win = self._plotter.ren_win + if ren_win: + w, h = ren_win.GetSize() + if h: + aspect = max(1e-3, float(w) / float(h)) + except Exception as e: + log_exception(__name__, "_compute_isometric_fit_distance", e) + tan_half_h = max(1e-4, tan_half_v * aspect) + + fit_v = half_h / tan_half_v + fit_h = half_w / tan_half_h + return max(float(min_distance), max(fit_v, fit_h) * max(1.01, float(margin_factor))) + + def focus_on_shelf_slot_isometric( + self: "ModelViewWidget", + rack_id: str | None, + slot_id: str | None, + shelf_index: int | None = None, + zone_id: str | None = None, + ) -> bool: + """Переключает камеру в изометрический вид и фокусируется на выбранной секции полки.""" + if not self._plotter: + return False + rid = str(rack_id or "") + sid = str(slot_id or "") + zid = str(zone_id or "") + if not rid or not sid: + return False + entry = self._get_rack_entry(rid, zid) if zid else self._get_rack_entry(rid) + if entry is None: + return False + + target_bounds = None + requested_idx = int(1 if shelf_index is None else shelf_index) + rack_type = str((entry.get("params") or {}).get("rack_type") or "").strip().upper() + if rack_type == "PALLET": + # tree-index=1 — напольный уровень под первой полкой, + # tree-index=2..N+1 — физические полки (actor 1..N). + use_floor_slot_bounds = requested_idx <= 1 + selected_idx = max(1, requested_idx - 1) + else: + use_floor_slot_bounds = requested_idx <= 0 + selected_idx = max(1, requested_idx) + shelf_actors = list((self._rack_shelf_actors.get((rid, sid)) or [])) + if not use_floor_slot_bounds and shelf_actors and selected_idx <= len(shelf_actors): + actor = shelf_actors[selected_idx - 1] + if actor is not None: + try: + target_bounds = actor.GetBounds() + except Exception as e: + log_exception(__name__, "focus_on_shelf_slot_isometric", e) + target_bounds = None + + if not target_bounds: + slot_actor = (entry.get("slot_actors") or {}).get(sid) + if slot_actor is not None: + try: + target_bounds = slot_actor.GetBounds() + except Exception as e: + log_exception(__name__, "focus_on_shelf_slot_isometric", e) + target_bounds = None + + if not target_bounds: + return False + min_x, max_x, min_y, max_y, min_z, max_z = [float(v) for v in target_bounds] + cx = (min_x + max_x) * 0.5 + cy = (min_y + max_y) * 0.5 + sx = max(1.0, max_x - min_x) + sy = max(1.0, max_y - min_y) + sz = max(1.0, max_z - min_z) + cz = min_z + (sz * 0.60) + target_span = max(sx, sy, sz) + + cam = getattr(self._plotter, "camera", None) + if cam is None: + return False + + try: + start_pos = tuple(float(v) for v in cam.GetPosition()) + start_foc = tuple(float(v) for v in cam.GetFocalPoint()) + start_up = tuple(float(v) for v in cam.GetViewUp()) + except Exception as e: + log_exception(__name__, "focus_on_shelf_slot_isometric", e) + return False + + try: + # Вычисляем целевое изометрическое направление напрямую (без промежуточного прыжка камеры). + # На 60% приближаем "изометрию полки" к прямому (front-like) виду: + # уменьшаем боковой компонент yaw, сохраняя вертикальную составляющую. + vx, vy, vz = 0.4, -1.0, 0.8 + norm = (vx * vx + vy * vy + vz * vz) ** 0.5 + if norm <= 1e-6: + vx, vy, vz = 1.0, -1.0, 0.8 + norm = (vx * vx + vy * vy + vz * vz) ** 0.5 + vx /= norm + vy /= norm + vz /= norm + ux, uy, uz = 0.0, 0.0, 1.0 + # right = forward x up (forward направлен от камеры к точке фокуса). + fx, fy, fz = -vx, -vy, -vz + rx = fy * uz - fz * uy + ry = fz * ux - fx * uz + rz = fx * uy - fy * ux + right_norm = (rx * rx + ry * ry + rz * rz) ** 0.5 + if right_norm <= 1e-6: + rx, ry, rz = 1.0, 0.0, 0.0 + right_norm = 1.0 + rx /= right_norm + ry /= right_norm + rz /= right_norm + + # Гарантируем полное попадание слота в кадр + небольшой зазор. + distance = self._compute_isometric_fit_distance( + (min_x, max_x, min_y, max_y, min_z, max_z), + view_dir=(vx, vy, vz), + up_dir=(ux, uy, uz), + margin_factor=1.09, + min_distance=700.0, + ) + target_focal = (cx, cy, cz) + base_pos = ( + cx + vx * distance, + cy + vy * distance, + cz + vz * distance, + ) + right_shift = target_span * 0.22 + down_shift = target_span * 0.06 + target_pos = ( + base_pos[0] + rx * right_shift - ux * down_shift, + base_pos[1] + ry * right_shift - uy * down_shift, + base_pos[2] + rz * right_shift + (sz * 0.08) - uz * down_shift, + ) + target_focal = ( + target_focal[0] + rx * (target_span * 0.03), + target_focal[1] + ry * (target_span * 0.03), + target_focal[2] + (sz * 0.05), + ) + + # Анимируем переход камеры напрямую из текущей позиции в целевую изометрическую. + self._animate_camera_transition( + start_pos=start_pos, + start_focal=start_foc, + start_up=start_up, + target_pos=target_pos, + target_focal=target_focal, + target_up=(ux, uy, uz), + duration_ms=380, + steps=20, + ) + return True + except Exception as e: + log_exception(__name__, "focus_on_shelf_slot_isometric", e) + return False + + def _animate_camera_transition( + self: "ModelViewWidget", + *, + start_pos: tuple[float, float, float], + start_focal: tuple[float, float, float], + start_up: tuple[float, float, float], + target_pos: tuple[float, float, float], + target_focal: tuple[float, float, float], + target_up: tuple[float, float, float], + duration_ms: int = 320, + steps: int = 16, + ) -> None: + """Анимирует переход камеры со сглаживанием smoothstep.""" + if not self._plotter or not getattr(self._plotter, "camera", None): + return + cam = self._plotter.camera + total_steps = max(1, int(steps)) + interval_ms = max(10, int(duration_ms / total_steps)) + + self._camera_anim_seq = int(getattr(self, "_camera_anim_seq", 0)) + 1 + anim_seq = self._camera_anim_seq + old_timer = getattr(self, "_camera_anim_timer", None) + if old_timer is not None: + try: + old_timer.stop() + except Exception as e: + log_exception(__name__, "_animate_camera_transition", e) + timer = QTimer() + self._camera_anim_timer = timer + state = {"step": 0} + + def _lerp3(a: tuple[float, float, float], b: tuple[float, float, float], t: float) -> tuple[float, float, float]: + return ( + float(a[0] + (b[0] - a[0]) * t), + float(a[1] + (b[1] - a[1]) * t), + float(a[2] + (b[2] - a[2]) * t), + ) + + def _tick() -> None: + if anim_seq != getattr(self, "_camera_anim_seq", 0): + timer.stop() + return + state["step"] = int(state["step"]) + 1 + linear_t = min(1.0, float(state["step"]) / float(total_steps)) + # smoothstep: плавное начало/конец без перелёта. + t = linear_t * linear_t * (3.0 - 2.0 * linear_t) + pos_t = _lerp3(start_pos, target_pos, t) + focal_t = _lerp3(start_focal, target_focal, t) + up_t = _lerp3(start_up, target_up, t) + try: + cam.SetPosition(*pos_t) + cam.SetFocalPoint(*focal_t) + if hasattr(cam, "SetViewUp"): + cam.SetViewUp(*up_t) + if hasattr(self, "_reset_camera_clipping_range"): + self._reset_camera_clipping_range() + self._plotter.update() + except Exception as e: + log_exception(__name__, "_animate_camera_transition._tick", e) + timer.stop() + return + if linear_t >= 1.0: + timer.stop() + + timer.timeout.connect(_tick) + timer.start(interval_ms) + + +# --------------------------------------------------------------------------- +# Module workflow notes +# --------------------------------------------------------------------------- +# 1) Задача модуля: +# Камера стеллажей: фокус на ячейке и плавная анимация перехода. +# +# 2) Последовательность действий и вызовов: +# A. Класс RackCameraMixin: точки входа +# Публичные методы сценария: +# - RackCameraMixin.focus_on_shelf_slot_isometric(...) +# +# B. RackCameraMixin: основной сценарий: +# RackCameraMixin.focus_on_shelf_slot_isometric(...) +# Назначение: Переключает камеру в изометрический вид и фокусируется на выбранной секции полки. +# Последовательность внутренних вызовов: +# -> RackCameraMixin._animate_camera_transition(...) +# +# C. RackCameraMixin: вспомогательные расчёты: +# RackCameraMixin._animate_camera_transition(...) +# Назначение: Анимирует переход камеры со сглаживанием smoothstep. +# +# 3) Важные ограничения и инварианты: +# - Модуль выполняется в составе ModelViewWidget и использует согласованные поля состояния self._... . +# - Все операции с актёрами и камерой выполняются только при валидном self._plotter с безопасной обработкой исключений. +# - Межмодульная связность: только через фасад model_view; прямые обращения между zone, rack, shelf, cell запрещены. +# - Очистка состояния должна быть идемпотентной: повторный вызов не меняет корректное состояние в ошибочное. +# +# 4) Правило сопровождения: +# - Любое изменение сценария должно сопровождаться обновлением этого блока с сохранением фактического порядка вызовов. +# - При добавлении метода указывать его место в цепочке сценария (запуск, основной шаг, завершение, вспомогательная логика). diff --git a/Dispatch_V0.1.1/gui/components/model_view/_mv_racks_codes.py b/Dispatch_V0.1.1/gui/components/model_view/_mv_racks_codes.py new file mode 100644 index 0000000..562f167 --- /dev/null +++ b/Dispatch_V0.1.1/gui/components/model_view/_mv_racks_codes.py @@ -0,0 +1,440 @@ +# -*- coding: utf-8 -*- +"""Вспомогательные функции кодов стеллажей — утилиты именования, нумерации и определения стиля.""" + +from __future__ import annotations + +import re +from typing import Any, TYPE_CHECKING +from error_logger import log_exception + +if TYPE_CHECKING: + from gui.components.model_view_widget import ModelViewWidget + + +class RackCodesMixin: + """Чисто логические утилиты для работы с кодами стеллажей (без зависимостей от Qt / VTK).""" + + @staticmethod + def _split_code_parts(value: Any) -> tuple[str, str]: + raw = str(value or "").strip().upper() + if raw.startswith("MEZO_"): + suffix = "".join(ch for ch in raw[5:] if "A" <= ch <= "Z") + return "MEZO_", suffix + letters = "".join(ch for ch in raw if "A" <= ch <= "Z") + return "", letters + + @staticmethod + def _code_prefix_for_params(params: dict[str, Any]) -> str: + rack_type = str(dict(params or {}).get("rack_type") or "").strip().lower() + if rack_type in {"mezo", "mezz", "mezzanine"}: + return "MEZO_" + additional_model = dict(dict(params or {}).get("additional_model") or {}) + explicit = additional_model.get("is_mezzanine") + if isinstance(explicit, bool): + return "MEZO_" if explicit else "" + model_kind = str(additional_model.get("model_kind") or "").strip().lower() + if model_kind in {"mezo", "mezz", "mezzanine"}: + return "MEZO_" + haystack = " ".join( + str(v) + for v in ( + additional_model.get("model_name"), + additional_model.get("model_path"), + rack_type, + ) + if v + ).lower() + if any(tag in haystack for tag in ("mezo", "mezz", "Р Сез", "mezan", "mezon")): + return "MEZO_" + return "" + + @classmethod + def _normalize_rack_code( + cls, + value: Any, + fallback: str = "A", + prefix: str = "", + ) -> str: + _given_prefix, letters = cls._split_code_parts(value) + if not letters: + _fb_prefix, fb_letters = cls._split_code_parts(fallback) + letters = fb_letters + if not letters: + letters = "A" + if str(prefix or "").upper() == "MEZO_": + return f"MEZO_{letters}" + return letters + + @staticmethod + def _normalize_numbering_direction(value: Any) -> str: + direction = str(value or "").strip().lower() + if direction in {"right_to_left", "rtl"}: + return "right_to_left" + return "left_to_right" + + @staticmethod + def _canonical_center_span(rack_type: Any, span_width_mm: Any, fallback_center_mm: Any) -> int: + kind = str(rack_type or "").strip().upper() + width = int(span_width_mm) + fallback = int(fallback_center_mm) + if kind == "A": + if width == 1000: + return 1033 + if width == 1300: + return 1333 + if kind == "B": + if width == 900: + return 950 + if width == 1200: + return 1250 + if width == 1500: + return 1550 + return fallback + + @classmethod + def _build_span_codes(cls, code: str, spans_count: int, numbering_direction: str) -> list[str]: + prefix, letters = cls._split_code_parts(code) + base = letters or "A" + count = max(1, int(spans_count or 1)) + ordered = [f"{prefix}{base}{idx:02d}" for idx in range(1, count + 1)] + if cls._normalize_numbering_direction(numbering_direction) == "right_to_left": + return list(reversed(ordered)) + return ordered + + @classmethod + def _letters_to_number(cls, letters: str) -> int: + _prefix, clean = cls._split_code_parts(letters) + value = 0 + for ch in clean: + value = value * 26 + (ord(ch) - ord("A") + 1) + return value + + @staticmethod + def _number_to_letters(number: int) -> str: + n = max(1, int(number)) + out = [] + while n > 0: + n -= 1 + out.append(chr(ord("A") + (n % 26))) + n //= 26 + return "".join(reversed(out)) + + def _next_zone_rack_code(self: "ModelViewWidget", zone_id: str, params: dict[str, Any] | None = None) -> str: + zid = str(zone_id or "") + source_params = dict(params or {}) + wanted_prefix = self._code_prefix_for_params(source_params) + used_numbers: set[int] = set() + for entry in self._rack_entries: + if zid and str(entry.get("zone_id", "")) != zid: + continue + entry_params = dict(entry.get("params") or {}) + code = str(entry.get("code") or entry_params.get("code") or "") + if not code: + continue + entry_prefix = self._code_prefix_for_params(entry_params) + if not entry_prefix: + parsed_prefix, _letters = self._split_code_parts(code) + entry_prefix = parsed_prefix + if str(entry_prefix or "") != str(wanted_prefix or ""): + continue + code_num = int(self._letters_to_number(code)) + if code_num > 0: + used_numbers.add(code_num) + + # Сохраняем ручной код, если он свободен в целевой зоне/префиксе. + preferred_code = self._normalize_rack_code( + source_params.get("code"), + fallback="MEZO_A" if wanted_prefix else "A", + prefix=wanted_prefix, + ) + preferred_num = int(self._letters_to_number(preferred_code)) + if preferred_num > 0 and preferred_num not in used_numbers: + if wanted_prefix: + return f"{wanted_prefix}{self._number_to_letters(preferred_num)}" + return self._number_to_letters(preferred_num) + + # Автоматический режим: выбираем ближайший свободный слот, начиная с A. + next_num = 1 + while next_num in used_numbers: + next_num += 1 + next_letters = self._number_to_letters(next_num) + if wanted_prefix: + return f"{wanted_prefix}{next_letters}" + return next_letters + + def _normalize_rack_params( + self: "ModelViewWidget", + params: dict[str, Any], + fallback_code: str | None = None, + ) -> dict[str, Any]: + normalized = dict(params or {}) + prefix = self._code_prefix_for_params(normalized) + default_code = "MEZO_A" if prefix else "A" + code = self._normalize_rack_code( + normalized.get("code"), + fallback=fallback_code or default_code, + prefix=prefix, + ) + spans_count = max(1, int(normalized.get("spans_count", 1))) + raw_span_widths = [int(v) for v in (normalized.get("span_widths_mm") or []) if int(v) > 0] + if not raw_span_widths: + raw_span_widths = [int(normalized.get("footprint_width_mm", 1000))] + if len(raw_span_widths) < spans_count: + raw_span_widths.extend([raw_span_widths[-1]] * (spans_count - len(raw_span_widths))) + raw_span_widths = raw_span_widths[:spans_count] + + if self._is_pallet_params(normalized): + center_spans = [self._PALLET_CENTER_SPAN_MM for _ in range(spans_count)] + else: + center_spans = [int(v) for v in (normalized.get("center_spans_mm") or raw_span_widths) if int(v) > 0] + if not center_spans: + center_spans = list(raw_span_widths) + if len(center_spans) < spans_count: + center_spans.extend([center_spans[-1]] * (spans_count - len(center_spans))) + center_spans = center_spans[:spans_count] + rack_type = str(normalized.get("rack_type") or "") + center_spans = [ + self._canonical_center_span(rack_type, raw_span_widths[i], center_spans[i]) + for i in range(spans_count) + ] + + if self._is_pallet_params(normalized): + raw_span_widths = [self._PALLET_FOOTPRINT_WIDTH_SINGLE_MM for _ in range(spans_count)] + footprint_width = int( + sum(center_spans) + + (self._PALLET_FOOTPRINT_WIDTH_SINGLE_MM - self._PALLET_CENTER_SPAN_MM) + ) + normalized["depth_mm"] = int(self._PALLET_DEPTH_MM) + normalized["footprint_depth_mm"] = int(self._PALLET_DEPTH_MM) + normalized["footprint_height_mm"] = int(self._PALLET_HEIGHT_MM) + else: + footprint_width = int(sum(center_spans)) + numbering_direction = self._normalize_numbering_direction( + normalized.get("numbering_direction", "left_to_right") + ) + normalized["code"] = code + normalized["spans_count"] = spans_count + normalized["span_widths_mm"] = raw_span_widths + normalized["center_spans_mm"] = center_spans + normalized["footprint_width_mm"] = footprint_width + normalized["numbering_direction"] = numbering_direction + normalized["span_codes"] = self._build_span_codes(code, spans_count, numbering_direction) + return normalized + + def _resolve_rack_style( + self: "ModelViewWidget", + params: dict[str, Any], + fallback_color: str = "#7FB3D5", + fallback_opacity: float = 0.9, + ) -> tuple[str, float]: + color = str(params.get("display_color") or fallback_color).strip() + opacity = params.get("display_opacity", fallback_opacity) + try: + opacity = float(opacity) + except Exception as exc: + log_exception(__name__, "_resolve_rack_style", exc) + opacity = float(fallback_opacity) + opacity = max(0.05, min(1.0, opacity)) + match = re.fullmatch(r"#([0-9A-Fa-f]{8})", color) + if match: + hex_rgba = match.group(1) + color = f"#{hex_rgba[2:]}" + try: + opacity = max(0.05, min(1.0, int(hex_rgba[:2], 16) / 255.0)) + except Exception as _exc: + log_exception(__name__, "_resolve_rack_style", _exc) + if not re.fullmatch(r"#([0-9A-Fa-f]{6})", color): + color = fallback_color + return color, opacity + + def _apply_rack_shelf_opacity(self: "ModelViewWidget", rack_id: str) -> None: + """Устанавливает полную непрозрачность актёров стеллажа при наличии полок, иначе восстанавливает исходную.""" + rid = str(rack_id or "") + if not rid: + return + entry = self._get_rack_entry(rid) + if entry is None: + return + slot_actor_ids = { + id(actor) + for actor in (entry.get("slot_actors") or {}).values() + if actor is not None + } + has_shelves = any(str(k[0]) == rid for k in self._rack_shelf_params) + if has_shelves: + target_opacity = 1.0 + else: + params = dict(entry.get("params") or {}) + _color, target_opacity = self._resolve_rack_style(params) + for actor in entry.get("actors", []): + try: + if id(actor) in slot_actor_ids: + # Прокси-объекты слотов управляются логикой размещения полок. + continue + prop = actor.GetProperty() + if prop is not None: + prop.SetOpacity(target_opacity) + except Exception as _exc: + log_exception(__name__, "_apply_rack_shelf_opacity", _exc) + for (entry_rid, slot_id), actors in (self._rack_shelf_actors or {}).items(): + if str(entry_rid) != rid: + continue + for idx, actor in enumerate(list(actors or []), start=1): + if actor is None: + continue + try: + prop = actor.GetProperty() + if prop is not None: + slot_opacity = float(self._shelf_cell_visual_opacity(rid, str(slot_id), idx)) + prop.SetOpacity(float(slot_opacity)) + except Exception as _exc: + log_exception(__name__, "_apply_rack_shelf_opacity", _exc) + def _shelf_cell_visual_opacity( + self: "ModelViewWidget", + rack_id: str, + slot_id: str, + shelf_index: int | None = None, + ) -> float: + rid = str(rack_id or "") + sid = str(slot_id or "") + if not rid or not sid: + return 0.65 + if shelf_index is not None: + idx = max(1, int(shelf_index or 1)) + indexed_storage = dict(getattr(self, "_rack_shelf_cell_links_indexed", {}) or {}) + has_any_indexed = any(str(k[0]) == rid and str(k[1]) == sid for k in indexed_storage.keys()) + if has_any_indexed: + linked_idx = bool(indexed_storage.get((rid, sid, idx), False)) + return 1.0 if linked_idx else 0.65 + linked = bool((self._rack_shelf_cell_links or {}).get((rid, sid), False)) + return 1.0 if linked else 0.65 + + def set_shelf_cell_marker( + self: "ModelViewWidget", + rack_id: str, + slot_id: str, + linked: bool, + ) -> None: + rid = str(rack_id or "") + sid = str(slot_id or "") + if not rid or not sid: + return + self._rack_shelf_cell_links[(rid, sid)] = bool(linked) + self._apply_rack_shelf_opacity(rid) + if self._plotter: + self._plotter.update() + + def set_rack_shelf_cell_markers( + self: "ModelViewWidget", + rack_id: str, + slot_markers: dict[str, bool], + ) -> None: + rid = str(rack_id or "") + if not rid: + return + markers = dict(slot_markers or {}) + keys_to_drop = [key for key in (self._rack_shelf_cell_links or {}).keys() if str(key[0]) == rid] + for key in keys_to_drop: + self._rack_shelf_cell_links.pop(key, None) + for sid, linked in markers.items(): + slot_id = str(sid or "") + if not slot_id: + continue + self._rack_shelf_cell_links[(rid, slot_id)] = bool(linked) + self._apply_rack_shelf_opacity(rid) + if self._plotter: + self._plotter.update() + + def set_rack_shelf_cell_markers_indexed( + self: "ModelViewWidget", + rack_id: str, + slot_index_markers: dict[str, bool], + ) -> None: + """Точечные маркеры размещения ячеек на уровне конкретной полки (slot + index).""" + rid = str(rack_id or "") + if not rid: + return + markers = dict(slot_index_markers or {}) + keys_to_drop = [key for key in (self._rack_shelf_cell_links_indexed or {}).keys() if str(key[0]) == rid] + for key in keys_to_drop: + self._rack_shelf_cell_links_indexed.pop(key, None) + for key, linked in markers.items(): + raw = str(key or "") + if "::" not in raw: + continue + slot_id, raw_idx = raw.split("::", 1) + slot_id = str(slot_id or "") + if not slot_id: + continue + try: + idx = max(1, int(raw_idx)) + except Exception as exc: + log_exception(__name__, "set_rack_shelf_cell_markers_indexed", exc) + continue + self._rack_shelf_cell_links_indexed[(rid, slot_id, idx)] = bool(linked) + self._apply_rack_shelf_opacity(rid) + if self._plotter: + self._plotter.update() + + +# --------------------------------------------------------------------------- +# Module workflow notes +# --------------------------------------------------------------------------- +# 1) Задача модуля: +# Нормализация и генерация кодов стеллажей и Секций. +# +# 2) Последовательность действий и вызовов: +# A. Класс RackCodesMixin: точки входа +# Публичные методы отсутствуют; сценарий запускается через методы родительских модулей и внутренние обработчики. +# +# B. RackCodesMixin: основной сценарий: +# RackCodesMixin._resolve_rack_style(...) +# Назначение: определяет rack style в рамках текущего сценария модуля. +# RackCodesMixin._apply_rack_shelf_opacity(...) +# Назначение: Устанавливает полную непрозрачность актёров стеллажа при наличии полок, иначе восстанавливает исходную. +# Последовательность внутренних вызовов: +# -> RackCodesMixin._resolve_rack_style(...) +# +# C. RackCodesMixin: вспомогательные расчёты: +# RackCodesMixin._split_code_parts(...) +# Назначение: разделяет code parts в рамках текущего сценария модуля. +# RackCodesMixin._code_prefix_for_params(...) +# Назначение: выполняет шаг "code prefix for params" в рамках текущего сценария модуля. +# RackCodesMixin._normalize_rack_code(...) +# Назначение: нормализует rack code в рамках текущего сценария модуля. +# RackCodesMixin._normalize_numbering_direction(...) +# Назначение: нормализует numbering direction в рамках текущего сценария модуля. +# RackCodesMixin._canonical_center_span(...) +# Назначение: выполняет шаг "canonical center span" в рамках текущего сценария модуля. +# RackCodesMixin._build_span_codes(...) +# Назначение: строит span codes в рамках текущего сценария модуля. +# RackCodesMixin._letters_to_number(...) +# Назначение: выполняет шаг "letters to number" в рамках текущего сценария модуля. +# RackCodesMixin._number_to_letters(...) +# Назначение: выполняет шаг "number to letters" в рамках текущего сценария модуля. +# RackCodesMixin._next_zone_rack_code(...) +# Назначение: выполняет шаг "next zone rack code" в рамках текущего сценария модуля. +# Последовательность внутренних вызовов: +# -> RackCodesMixin._code_prefix_for_params(...) +# -> RackCodesMixin._normalize_rack_code(...) +# -> RackCodesMixin._number_to_letters(...) +# -> RackCodesMixin._letters_to_number(...) +# -> RackCodesMixin._split_code_parts(...) +# RackCodesMixin._normalize_rack_params(...) +# Назначение: нормализует rack params в рамках текущего сценария модуля. +# Последовательность внутренних вызовов: +# -> RackCodesMixin._code_prefix_for_params(...) +# -> RackCodesMixin._normalize_rack_code(...) +# -> RackCodesMixin._normalize_numbering_direction(...) +# -> RackCodesMixin._build_span_codes(...) +# -> RackCodesMixin._canonical_center_span(...) +# +# 3) Важные ограничения и инварианты: +# - Модуль выполняется в составе ModelViewWidget и использует согласованные поля состояния self._... . +# - Межмодульная связность: только через фасад model_view; прямые обращения между zone, rack, shelf, cell запрещены. +# - Очистка состояния должна быть идемпотентной: повторный вызов не меняет корректное состояние в ошибочное. +# +# 4) Правило сопровождения: +# - Любое изменение сценария должно сопровождаться обновлением этого блока с сохранением фактического порядка вызовов. +# - При добавлении метода указывать его место в цепочке сценария (запуск, основной шаг, завершение, вспомогательная логика). + diff --git a/Dispatch_V0.1.1/gui/components/model_view/_mv_racks_collision.py b/Dispatch_V0.1.1/gui/components/model_view/_mv_racks_collision.py new file mode 100644 index 0000000..2319f74 --- /dev/null +++ b/Dispatch_V0.1.1/gui/components/model_view/_mv_racks_collision.py @@ -0,0 +1,378 @@ +# -*- coding: utf-8 -*- +"""Миксин обнаружения столкновений и валидации стеллажей.""" +from __future__ import annotations + +from typing import Any, TYPE_CHECKING + +from gui.components.model_view._mv_rack_geometry import ( + MIN_AISLE_MM, MIN_WALL_BUFFER_MM, bboxes_intersect, + rack_bbox, rack_aisle_bboxes, +) +from error_logger import log_exception + +if TYPE_CHECKING: + from gui.components.model_view_widget import ModelViewWidget + + +class RackCollisionMixin: + """Вспомогательные методы проверки столкновений / валидации стеллажей, подмешиваемые в *ModelViewWidget*.""" + + # -- высота / модель ----------------------------------------------------- + + @staticmethod + def _rack_height_mm(params: dict[str, Any]) -> float: + try: + return max(0.0, float(params.get("footprint_height_mm", 1800))) + except Exception as exc: + log_exception(__name__, "_rack_height_mm", exc) + return 1800.0 + + @staticmethod + def _has_additional_model(params: dict[str, Any]) -> bool: + additional_model = dict(params.get("additional_model") or {}) + if additional_model.get("model_path"): + return True + # Обратная совместимость: старые данные могут хранить путь на верхнем уровне. + return bool(params.get("model_path") or params.get("custom_model_path")) + + def _is_imported_model_entry(self: "ModelViewWidget", entry: dict[str, Any]) -> bool: + params = dict((entry or {}).get("params") or {}) + if self._has_additional_model(params): + return True + rack_type = str(params.get("rack_type") or "").strip().lower() + return rack_type in {"mezo", "mezz", "mezzanine", "custom", "additional_model"} + + def _force_show_imported_model_entry(self: "ModelViewWidget", entry: dict[str, Any]) -> bool: + return bool(self._imported_models_enabled) and self._is_imported_model_entry(entry) + + def set_imported_models_enabled(self: "ModelViewWidget", enabled: bool) -> None: + """Устанавливает глобальную политику видимости для импортированных пользовательских моделей.""" + self._imported_models_enabled = bool(enabled) + for entry in self._rack_entries: + if not self._is_imported_model_entry(entry): + continue + visible = bool(self._imported_models_enabled) + for actor in entry.get("actors", []): + try: + actor.SetVisibility(1 if visible else 0) + except Exception as _exc: + log_exception(__name__, "set_imported_models_enabled", _exc) + self._set_rack_shelf_actors_visibility(str(entry.get("rack_id") or ""), visible) + self._apply_rack_helper_visibility_policy(entry, visible) + if self._plotter: + self._plotter.update() + + @staticmethod + def _is_pallet_params(params: dict[str, Any]) -> bool: + return str(dict(params or {}).get("rack_type") or "").strip().lower() == "pallet" + + # -- bbox / буферы ------------------------------------------------------- + + @classmethod + def _rack_access_aisle_mm(cls, params: dict[str, Any]) -> float: + if cls._is_pallet_params(params): + return 2300.0 + return float(MIN_AISLE_MM) + + @classmethod + def _rack_wall_buffer_mm(cls, params: dict[str, Any]) -> float: + if cls._is_pallet_params(params): + return float(getattr(cls, "_PALLET_BUFFER_MM", MIN_WALL_BUFFER_MM)) + return 0.0 + + @classmethod + def _rack_placement_bbox( + cls, + cx: float, + cy: float, + params: dict[str, Any], + rotation: int, + ) -> tuple[float, float, float, float]: + return rack_bbox( + cx, + cy, + params, + rotation, + wall_buffer=cls._rack_wall_buffer_mm(params), + ) + + @classmethod + def _rack_collision_buffer_mm(cls, params: dict[str, Any]) -> float: + # Буфер столкновений единый для проверок стеллаж-стеллаж. + # Для PALLET не менее его настроенного буфера. + base = float(getattr(cls, "_RACK_COLLISION_BUFFER_MM", MIN_WALL_BUFFER_MM)) + if cls._is_pallet_params(params): + return max(base, float(getattr(cls, "_PALLET_BUFFER_MM", MIN_WALL_BUFFER_MM))) + return base + + @classmethod + def _rack_collision_bbox( + cls, + cx: float, + cy: float, + params: dict[str, Any], + rotation: int, + ) -> tuple[float, float, float, float]: + return rack_bbox( + cx, + cy, + params, + rotation, + wall_buffer=cls._rack_collision_buffer_mm(params), + ) + + @staticmethod + def _bbox_overlap_xy_mm( + a: tuple[float, float, float, float], + b: tuple[float, float, float, float], + ) -> tuple[float, float]: + a_min_x, a_max_x, a_min_y, a_max_y = a + b_min_x, b_max_x, b_min_y, b_max_y = b + overlap_x = min(a_max_x, b_max_x) - max(a_min_x, b_min_x) + overlap_y = min(a_max_y, b_max_y) - max(a_min_y, b_min_y) + return float(overlap_x), float(overlap_y) + + @staticmethod + def _bbox_contains( + outer: tuple[float, float, float, float], + inner: tuple[float, float, float, float], + margin_mm: float = 0.0, + ) -> bool: + o_min_x, o_max_x, o_min_y, o_max_y = outer + i_min_x, i_max_x, i_min_y, i_max_y = inner + m = max(0.0, float(margin_mm)) + return ( + i_min_x >= (o_min_x + m) + and i_max_x <= (o_max_x - m) + and i_min_y >= (o_min_y + m) + and i_max_y <= (o_max_y - m) + ) + + @classmethod + def _buffer_overlap_exceeds_limit( + cls, + a: tuple[float, float, float, float], + b: tuple[float, float, float, float], + ) -> bool: + overlap_x, overlap_y = cls._bbox_overlap_xy_mm(a, b) + if overlap_x <= 0.0 or overlap_y <= 0.0: + return False + # Для расположений бок-о-бок или торец-к-торцу важна только ось проникновения. + penetration = min(overlap_x, overlap_y) + return penetration > float(getattr(cls, "_MAX_BUFFER_OVERLAP_MM", 100.0)) + + # -- валидация и видимость слотов ----------------------------------------- + + def _validate_rack_position( + self: "ModelViewWidget", + cx: float, + cy: float, + zone_id: str, + params: dict[str, Any], + rotation: int, + ignore_rack_id: str | None = None, + ) -> bool: + min_x, max_x, min_y, max_y = self._rack_placement_bbox(cx, cy, params, rotation) + z_ref = self._zone_heights.get(zone_id, (0.0, 0.0))[0] + corners = [(min_x, min_y), (max_x, min_y), (max_x, max_y), (min_x, max_y)] + for px, py in corners: + if not self._point_in_zone_or_boundary(px, py, zone_id, plane_z=z_ref): + return False + if self._room_bounds: + rb = self._room_bounds + if min_x < rb[0] or max_x > rb[1] or min_y < rb[2] or max_y > rb[3]: + return False + cand = self._rack_placement_bbox(cx, cy, params, rotation) + cand_collision = self._rack_collision_bbox(cx, cy, params, rotation) + cand_is_mezzanine = self._is_mezzanine_params(params) + cand_has_aisle = not self._has_additional_model(params) + cand_aisles = rack_aisle_bboxes( + cx, cy, params, rotation, + aisle_mm=self._rack_access_aisle_mm(params), + ) if cand_has_aisle else [] + cand_columns = self._mezzanine_column_bboxes(cx, cy, params, rotation) if cand_is_mezzanine else [] + for entry in self._rack_entries: + if entry["zone_id"] != zone_id: + continue + if ignore_rack_id and str(entry.get("rack_id", "")) == str(ignore_rack_id): + continue + existing_center = entry.get("center") or (0.0, 0.0) + existing_params = dict(entry.get("params") or {}) + existing_rotation = int(entry.get("rotation", 0)) + ex_x, ex_y = float(existing_center[0]), float(existing_center[1]) + existing_body = self._rack_placement_bbox(ex_x, ex_y, existing_params, existing_rotation) + existing_collision = self._rack_collision_bbox(ex_x, ex_y, existing_params, existing_rotation) + existing_is_mezzanine = self._is_mezzanine_params(existing_params) + existing_has_aisle = not self._has_additional_model(existing_params) + existing_aisles = rack_aisle_bboxes( + ex_x, ex_y, existing_params, existing_rotation, + aisle_mm=self._rack_access_aisle_mm(existing_params), + ) if existing_has_aisle else [] + existing_columns = self._mezzanine_column_bboxes( + ex_x, ex_y, existing_params, existing_rotation, + ) if existing_is_mezzanine else [] + # 1) Пересечение тел по умолчанию запрещено. + if bboxes_intersect(cand, existing_body): + if not self._can_share_xy_with_mezzanine(params, existing_params): + return False + if existing_is_mezzanine: + # Стеллаж под мезонином должен полностью находиться внутри контура мезонина. + if not self._bbox_contains( + existing_body, + cand, + margin_mm=float(getattr(self, "_MEZZANINE_CONTAINMENT_MARGIN_MM", 100.0)), + ): + return False + if not existing_columns: + return False + if any(bboxes_intersect(cand, col_bbox) for col_bbox in existing_columns): + return False + if cand_is_mezzanine: + # Существующий стеллаж должен полностью находиться внутри контура мезонина-кандидата. + if not self._bbox_contains( + cand, + existing_body, + margin_mm=float(getattr(self, "_MEZZANINE_CONTAINMENT_MARGIN_MM", 100.0)), + ): + return False + if not cand_columns: + return False + if any(bboxes_intersect(existing_body, col_bbox) for col_bbox in cand_columns): + return False + if existing_is_mezzanine and existing_columns: + if any( + bboxes_intersect(cand_col, ex_col) + for cand_col in cand_columns + for ex_col in existing_columns + ): + return False + # 1.1) Буферные зоны столкновений могут перекрываться до 100 мм (не более). + if (not cand_is_mezzanine) and (not existing_is_mezzanine): + if self._buffer_overlap_exceeds_limit(cand_collision, existing_collision): + return False + # 2) Тело кандидата не должно пересекать существующие зоны проходов. + if existing_aisles: + if any(bboxes_intersect(cand, ex_aisle) for ex_aisle in existing_aisles): + return False + # 3) Зоны проходов кандидата не должны пересекать существующие тела. + if (not existing_is_mezzanine) and cand_aisles: + if any(bboxes_intersect(existing_body, cand_aisle) for cand_aisle in cand_aisles): + return False + return True + + def _on_rack_slot_visibility_changed( + self: "ModelViewWidget", + rack_id: str, + slot_id: str, + occupied: bool, + ) -> None: + self.set_rack_slot_occupied(rack_id, slot_id, occupied) + + def set_rack_slot_occupied( + self: "ModelViewWidget", + rack_id: str, + slot_id: str, + occupied: bool = True, + ) -> bool: + rid = str(rack_id or "") + sid = str(slot_id or "") + if not rid or not sid: + return False + for entry in self._rack_entries: + if str(entry.get("rack_id", "")) != rid: + continue + actor = (entry.get("slot_actors") or {}).get(sid) + if actor is None: + return False + try: + actor.SetVisibility(0 if occupied else 1) + except Exception as exc: + log_exception(__name__, "set_rack_slot_occupied", exc) + return False + hidden = entry.setdefault("hidden_slot_ids", set()) + if occupied: + hidden.add(sid) + else: + hidden.discard(sid) + if self._plotter: + self._plotter.update() + return True + return False + + +# --------------------------------------------------------------------------- +# Module workflow notes +# --------------------------------------------------------------------------- +# 1) Задача модуля: +# Проверка коллизий и ограничений размещения стеллажей. +# +# 2) Последовательность действий и вызовов: +# A. Класс RackCollisionMixin: точки входа +# Публичные методы сценария: +# - RackCollisionMixin.set_imported_models_enabled(...) +# - RackCollisionMixin.set_rack_slot_occupied(...) +# +# B. RackCollisionMixin: запуск и настройка: +# RackCollisionMixin.set_imported_models_enabled(...) +# Назначение: Устанавливает глобальную политику видимости для импортированных пользовательских моделей. +# Последовательность внутренних вызовов: +# -> RackCollisionMixin._is_imported_model_entry(...) +# RackCollisionMixin.set_rack_slot_occupied(...) +# Назначение: устанавливает rack slot occupied в рамках текущего сценария модуля. +# +# C. RackCollisionMixin: основной сценарий: +# RackCollisionMixin._has_additional_model(...) +# Назначение: проверяет наличие additional model в рамках текущего сценария модуля. +# RackCollisionMixin._is_imported_model_entry(...) +# Назначение: проверяет, что imported model entry в рамках текущего сценария модуля. +# Последовательность внутренних вызовов: +# -> RackCollisionMixin._has_additional_model(...) +# RackCollisionMixin._is_pallet_params(...) +# Назначение: проверяет, что pallet params в рамках текущего сценария модуля. +# RackCollisionMixin._validate_rack_position(...) +# Назначение: проверяет корректность rack position в рамках текущего сценария модуля. +# Последовательность внутренних вызовов: +# -> RackCollisionMixin._rack_placement_bbox(...) +# -> RackCollisionMixin._rack_collision_bbox(...) +# -> RackCollisionMixin._has_additional_model(...) +# -> RackCollisionMixin._buffer_overlap_exceeds_limit(...) +# -> RackCollisionMixin._rack_access_aisle_mm(...) +# -> RackCollisionMixin._bbox_contains(...) +# RackCollisionMixin._on_rack_slot_visibility_changed(...) +# Назначение: выполняет шаг "on rack slot visibility changed" в рамках текущего сценария модуля. +# Последовательность внутренних вызовов: +# -> RackCollisionMixin.set_rack_slot_occupied(...) +# +# D. RackCollisionMixin: вспомогательные расчёты: +# RackCollisionMixin._rack_height_mm(...) +# Назначение: выполняет шаг "rack height mm" в рамках текущего сценария модуля. +# RackCollisionMixin._force_show_imported_model_entry(...) +# Назначение: выполняет шаг "force show imported model entry" в рамках текущего сценария модуля. +# Последовательность внутренних вызовов: +# -> RackCollisionMixin._is_imported_model_entry(...) +# RackCollisionMixin._rack_access_aisle_mm(...) +# Назначение: выполняет шаг "rack access aisle mm" в рамках текущего сценария модуля. +# RackCollisionMixin._rack_wall_buffer_mm(...) +# Назначение: выполняет шаг "rack wall buffer mm" в рамках текущего сценария модуля. +# RackCollisionMixin._rack_placement_bbox(...) +# Назначение: выполняет шаг "rack placement bbox" в рамках текущего сценария модуля. +# RackCollisionMixin._rack_collision_buffer_mm(...) +# Назначение: выполняет шаг "rack collision buffer mm" в рамках текущего сценария модуля. +# RackCollisionMixin._rack_collision_bbox(...) +# Назначение: выполняет шаг "rack collision bbox" в рамках текущего сценария модуля. +# RackCollisionMixin._bbox_overlap_xy_mm(...) +# Назначение: выполняет шаг "bbox overlap xy mm" в рамках текущего сценария модуля. +# RackCollisionMixin._bbox_contains(...) +# Назначение: выполняет шаг "bbox contains" в рамках текущего сценария модуля. +# RackCollisionMixin._buffer_overlap_exceeds_limit(...) +# Назначение: выполняет шаг "buffer overlap exceeds limit" в рамках текущего сценария модуля. +# +# 3) Важные ограничения и инварианты: +# - Модуль выполняется в составе ModelViewWidget и использует согласованные поля состояния self._... . +# - Все операции с актёрами и камерой выполняются только при валидном self._plotter с безопасной обработкой исключений. +# - Межмодульная связность: только через фасад model_view; прямые обращения между zone, rack, shelf, cell запрещены. +# - Очистка состояния должна быть идемпотентной: повторный вызов не меняет корректное состояние в ошибочное. +# +# 4) Правило сопровождения: +# - Любое изменение сценария должно сопровождаться обновлением этого блока с сохранением фактического порядка вызовов. +# - При добавлении метода указывать его место в цепочке сценария (запуск, основной шаг, завершение, вспомогательная логика). diff --git a/Dispatch_V0.1.1/gui/components/model_view/_mv_racks_crud.py b/Dispatch_V0.1.1/gui/components/model_view/_mv_racks_crud.py new file mode 100644 index 0000000..288bd8f --- /dev/null +++ b/Dispatch_V0.1.1/gui/components/model_view/_mv_racks_crud.py @@ -0,0 +1,425 @@ +# -*- coding: utf-8 -*- +"""CRUD-миксин стеллажей — вспомогательные методы добавления / удаления / видимости для ModelViewWidget.""" + +from __future__ import annotations + +from typing import Any, TYPE_CHECKING +from error_logger import log_exception + +if TYPE_CHECKING: + from gui.components.model_view_widget import ModelViewWidget + + +class RackCrudMixin: + """Операции создания-чтения-обновления-удаления стеллажей, выделенные из _mv_racks.""" + + def _clear_removed_rack_visuals(self: "ModelViewWidget", rack_id: str) -> None: + rid = str(rack_id or "") + if not rid: + return + try: + self._clear_slot_shelf_actors(rid, clear_slot_id=None) + except Exception as _exc: + log_exception(__name__, "_clear_removed_rack_visuals", _exc) + try: + if hasattr(self, "clear_cell_grid_visualization"): + self.clear_cell_grid_visualization(rid) + except Exception as _exc: + log_exception(__name__, "_clear_removed_rack_visuals", _exc) + try: + params_storage = getattr(self, "_rack_shelf_params", {}) or {} + keys = [k for k in list(params_storage.keys()) if isinstance(k, tuple) and str(k[0]) == rid] + for key in keys: + params_storage.pop(key, None) + except Exception as _exc: + log_exception(__name__, "_clear_removed_rack_visuals", _exc) + def remove_last_rack(self: "ModelViewWidget", zone_id: str | None) -> bool: + if not zone_id: + return False + for idx in range(len(self._rack_entries) - 1, -1, -1): + entry = self._rack_entries[idx] + if entry["zone_id"] != zone_id: + continue + rid = str(entry.get("rack_id") or "") + for actor in entry.get("actors", []): + try: + self._plotter.remove_actor(actor) + except Exception as _exc: + log_exception(__name__, "remove_last_rack", _exc) + if rid: + self._clear_removed_rack_visuals(rid) + self._rack_entries.pop(idx) + if self._plotter: + self._plotter.update() + self.rack_layout_changed.emit(str(zone_id)) + if str(self._hover_rack_id or "") == rid: + self._clear_hover_rack_highlight() + if str(self._selected_rack_id or "") == rid: + self._set_selected_rack(None) + if str(getattr(self, "_rack_isolation_rack_id", "") or "") == rid: + self._rack_isolation_active = False + self._rack_isolation_rack_id = None + self._clear_rack_isolation_visual() + if str(getattr(self, "_shelf_target_rack_id", "") or "") == rid: + self.stop_shelf_placement_ext(clear_selection=True, clear_rendered_shelves=True) + if str(getattr(self, "_selected_shelf_bbox_rack_id", "") or "") == rid: + self._clear_selected_rendered_shelf(render=False) + return True + return False + + def remove_selected_rack(self: "ModelViewWidget") -> bool: + rid = str(self._selected_rack_id or "") + if not rid: + return False + for idx in range(len(self._rack_entries) - 1, -1, -1): + entry = self._rack_entries[idx] + if str(entry.get("rack_id", "")) != rid: + continue + zone_id = str(entry.get("zone_id", "")) + for actor in entry.get("actors", []): + try: + self._plotter.remove_actor(actor) + except Exception as _exc: + log_exception(__name__, "remove_selected_rack", _exc) + self._clear_removed_rack_visuals(rid) + self._rack_entries.pop(idx) + if str(self._hover_rack_id or "") == rid: + self._clear_hover_rack_highlight() + self._set_selected_rack(None) + if str(getattr(self, "_rack_isolation_rack_id", "") or "") == rid: + self._rack_isolation_active = False + self._rack_isolation_rack_id = None + self._clear_rack_isolation_visual() + if str(getattr(self, "_shelf_target_rack_id", "") or "") == rid: + self.stop_shelf_placement_ext(clear_selection=True, clear_rendered_shelves=True) + if str(getattr(self, "_selected_shelf_bbox_rack_id", "") or "") == rid: + self._clear_selected_rendered_shelf(render=False) + if self._plotter: + self._plotter.update() + self.rack_layout_changed.emit(zone_id) + return True + return False + + def clear_all_racks(self: "ModelViewWidget") -> None: + self._rack_isolation_active = False + self._rack_isolation_rack_id = None + self._clear_rack_isolation_visual() + self._clear_rack_preview() + self._clear_hover_rack_highlight() + if hasattr(self, "_clear_selected_rendered_shelf"): + self._clear_selected_rendered_shelf(render=False) + self._set_selected_rack(None) + self._last_moved_rack_id = None + for entry in self._rack_entries: + for actor in entry.get("actors", []): + try: + self._plotter.remove_actor(actor) + except Exception as _exc: + log_exception(__name__, "clear_all_racks", _exc) + rack_id = str(entry.get("rack_id") or "") + if rack_id: + self._clear_slot_shelf_actors(rack_id, clear_slot_id=None) + self._rack_entries = [] + if self._plotter: + self._plotter.update() + + def show_only_zone_racks(self: "ModelViewWidget", zone_id: str | None) -> None: + self._rack_isolation_active = False + self._rack_isolation_rack_id = None + self._clear_rack_isolation_visual() + if hasattr(self, "_clear_selected_rendered_shelf"): + self._clear_selected_rendered_shelf(render=False) + if str(zone_id or "") != str(self._rack_preview_zone_id or ""): + self._clear_hover_rack_highlight() + for entry in self._rack_entries: + is_imported = self._is_imported_model_entry(entry) + if is_imported: + visible = bool(self._imported_models_enabled) + else: + visible = (zone_id is not None) and (entry["zone_id"] == zone_id) + for actor in entry.get("actors", []): + try: + actor.SetVisibility(1 if visible else 0) + except Exception as _exc: + log_exception(__name__, "show_only_zone_racks", _exc) + self._set_rack_shelf_actors_visibility(str(entry.get("rack_id") or ""), visible) + self._apply_rack_helper_visibility_policy(entry, visible) + if self._selected_rack_id: + selected = self.get_selected_rack_entry() + if selected is None or str(selected.get("zone_id", "")) != str(zone_id or ""): + self._set_selected_rack(None) + else: + self._apply_selected_rack_visual(str(self._selected_rack_id)) + if self._plotter: + self._plotter.update() + + def show_only_rack(self: "ModelViewWidget", rack_id: str | None, zone_id: str | None = None) -> bool: + rid = str(rack_id or "") + zid = str(zone_id or "") + if not rid: + return False + if str(getattr(self, "_selected_shelf_bbox_rack_id", "") or "") != rid: + if hasattr(self, "_clear_selected_rendered_shelf"): + self._clear_selected_rendered_shelf(render=False) + prev_isolation_id = str(getattr(self, "_rack_isolation_rack_id", "") or "") + if prev_isolation_id and prev_isolation_id != rid: + # Переключение изолированного стеллажа: сначала восстанавливаем свойства предыдущего актёра. + self._clear_rack_isolation_visual() + if str(self._hover_rack_id or "") != rid: + self._clear_hover_rack_highlight() + + found = False + for entry in self._rack_entries: + same_rack = str(entry.get("rack_id", "")) == rid + same_zone = True if not zid else str(entry.get("zone_id", "")) == zid + is_imported = self._is_imported_model_entry(entry) + if is_imported: + visible = bool(self._imported_models_enabled) + else: + visible = same_rack and same_zone + if same_rack and same_zone: + found = True + for actor in entry.get("actors", []): + try: + actor.SetVisibility(1 if visible else 0) + except Exception as _exc: + log_exception(__name__, "show_only_rack", _exc) + self._set_rack_shelf_actors_visibility(str(entry.get("rack_id") or ""), visible) + self._apply_rack_helper_visibility_policy(entry, visible) + + if not found: + self._rack_isolation_active = False + self._rack_isolation_rack_id = None + self._clear_rack_isolation_visual() + if zid: + self.show_only_zone_racks(zid) + return False + + self._rack_isolation_active = True + self._rack_isolation_rack_id = rid + self._apply_rack_isolation_visual(rid) + # Применяем стиль выделения только после активации изоляции, чтобы вспомогательные + # объёмы сохраняли исходную непрозрачность при возврате на уровень зоны. + self._set_selected_rack(rid) + if self._plotter: + self._plotter.update() + return True + + def show_only_shelf_in_rack( + self: "ModelViewWidget", + rack_id: str | None, + slot_id: str | None, + shelf_index: int | None = None, + zone_id: str | None = None, + ) -> bool: + """Показывает только одну полку (доску) внутри изолированного стеллажа; стойки стеллажа остаются видимыми.""" + rid = str(rack_id or "") + sid = str(slot_id or "") + if not rid or not sid: + return False + if not self.show_only_rack(rid, zone_id): + return False + + requested_index = int(1 if shelf_index is None else shelf_index) + rack_type = "" + found_target_slot = False + entry = self._get_rack_entry(rid, str(zone_id or "")) if zone_id else self._get_rack_entry(rid) + if entry is not None: + rack_type = str((entry.get("params") or {}).get("rack_type") or "").strip().upper() + # Единый контракт индекса: внешне (tree/ui) используется tree-index. + # Для PALLET tree-index=1 соответствует напольному уровню (под первой полкой), + # а физические полки рендерятся актёрами с индексами 1..N для tree-index=2..N+1. + if rack_type == "PALLET": + is_floor_level = requested_index <= 1 + target_index = max(1, requested_index - 1) + else: + is_floor_level = requested_index <= 0 + target_index = max(1, requested_index) + if entry is not None and sid in (entry.get("slot_actors") or {}): + found_target_slot = True + shown_any = False + + for (entry_rid, entry_sid), actors in (self._rack_shelf_actors or {}).items(): + same_rack = str(entry_rid) == rid + same_slot = str(entry_sid) == sid + if same_rack and same_slot: + found_target_slot = True + for actor_index, actor in enumerate(list(actors or []), start=1): + if actor is None: + continue + visible = bool(same_rack and same_slot and actor_index == target_index and not is_floor_level) + if visible: + shown_any = True + try: + actor.SetVisibility(1 if visible else 0) + except Exception as _exc: + log_exception(__name__, "show_only_shelf_in_rack", _exc) + # Запасной вариант: если запрошенный индекс не существует, показываем все полки целевого слота. + if found_target_slot and not shown_any and not is_floor_level: + for (entry_rid, entry_sid), actors in (self._rack_shelf_actors or {}).items(): + same_slot = str(entry_rid) == rid and str(entry_sid) == sid + for actor in list(actors or []): + if actor is None: + continue + try: + actor.SetVisibility(1 if same_slot else 0) + except Exception as _exc: + log_exception(__name__, "show_only_shelf_in_rack", _exc) + target_index = 1 + + if found_target_slot: + if is_floor_level and hasattr(self, "_highlight_floor_shelf_polygon"): + self._highlight_floor_shelf_polygon(rid, sid) + elif hasattr(self, "_highlight_selected_rendered_shelf"): + self._highlight_selected_rendered_shelf(rid, sid, target_index) + + if self._plotter: + self._plotter.update() + return found_target_slot + + def show_all_racks(self: "ModelViewWidget") -> None: + self._rack_isolation_active = False + self._rack_isolation_rack_id = None + self._clear_rack_isolation_visual() + self._clear_hover_rack_highlight() + if hasattr(self, "_clear_selected_rendered_shelf"): + self._clear_selected_rendered_shelf(render=False) + for entry in self._rack_entries: + visible = not self._is_imported_model_entry(entry) or bool(self._imported_models_enabled) + for actor in entry.get("actors", []): + try: + actor.SetVisibility(1 if visible else 0) + except Exception as _exc: + log_exception(__name__, "show_all_racks", _exc) + self._set_rack_shelf_actors_visibility(str(entry.get("rack_id") or ""), visible) + self._apply_rack_helper_visibility_policy(entry, visible) + if self._selected_rack_id: + self._apply_selected_rack_visual(str(self._selected_rack_id)) + if self._plotter: + self._plotter.update() + + def _set_rack_shelf_actors_visibility(self: "ModelViewWidget", rack_id: str, visible: bool) -> None: + rid = str(rack_id or "") + if not rid: + return + shelf_actors = getattr(self, "_rack_shelf_actors", {}) or {} + for (entry_rid, _slot_id), actors in shelf_actors.items(): + if str(entry_rid) != rid: + continue + for actor in actors or []: + try: + actor.SetVisibility(1 if visible else 0) + except Exception as _exc: + log_exception(__name__, "_set_rack_shelf_actors_visibility", _exc) + # Сетка ячеек (preview/final/dim) должна следовать политике видимости стеллажа. + for storage_name in ("_cell_preview_actors", "_cell_final_actors", "_cell_dim_actors"): + storage = getattr(self, storage_name, {}) or {} + for key, actors in storage.items(): + if not isinstance(key, tuple) or not key: + continue + if str(key[0]) != rid: + continue + for actor in actors or []: + try: + actor.SetVisibility(1 if visible else 0) + except Exception as _exc: + log_exception(__name__, "_set_rack_shelf_actors_visibility", _exc) + if hasattr(self, "_apply_cell_grid_edit_isolation_visibility"): + self._apply_cell_grid_edit_isolation_visibility() + + def _apply_rack_helper_visibility_policy( + self: "ModelViewWidget", + entry: dict[str, Any], + base_visible: bool, + ) -> None: + if not bool(entry.get("shelf_helpers_hidden")): + return + slot_actor_ids = { + id(actor) + for actor in (entry.get("slot_actors") or {}).values() + if actor is not None + } + for idx, actor in enumerate(entry.get("actors", [])): + if actor is None: + continue + try: + if idx == 0: + actor.SetVisibility(1 if base_visible else 0) + else: + actor.SetVisibility(0) + if id(actor) in slot_actor_ids: + actor.SetVisibility(0) + except Exception as _exc: + log_exception(__name__, "_apply_rack_helper_visibility_policy", _exc) + def has_racks_in_zone(self: "ModelViewWidget", zone_id: str | None) -> bool: + zid = str(zone_id or "") + if not zid: + return False + return any(str(e.get("zone_id", "")) == zid for e in self._rack_entries) + + +# --------------------------------------------------------------------------- +# Module workflow notes +# --------------------------------------------------------------------------- +# 1) Задача модуля: +# CRUD-операции стеллажей и управление их видимостью. +# +# 2) Последовательность действий и вызовов: +# A. Класс RackCrudMixin: точки входа +# Публичные методы сценария: +# - RackCrudMixin.remove_last_rack(...) +# - RackCrudMixin.remove_selected_rack(...) +# - RackCrudMixin.clear_all_racks(...) +# - RackCrudMixin.show_only_zone_racks(...) +# - RackCrudMixin.show_only_rack(...) +# - RackCrudMixin.show_only_shelf_in_rack(...) +# - RackCrudMixin.show_all_racks(...) +# - RackCrudMixin.has_racks_in_zone(...) +# +# B. RackCrudMixin: запуск и настройка: +# RackCrudMixin._set_rack_shelf_actors_visibility(...) +# Назначение: устанавливает rack shelf actors visibility в рамках текущего сценария модуля. +# +# C. RackCrudMixin: основной сценарий: +# RackCrudMixin.show_only_zone_racks(...) +# Назначение: показывает only zone racks в рамках текущего сценария модуля. +# Последовательность внутренних вызовов: +# -> RackCrudMixin._set_rack_shelf_actors_visibility(...) +# -> RackCrudMixin._apply_rack_helper_visibility_policy(...) +# RackCrudMixin.show_only_rack(...) +# Назначение: показывает only rack в рамках текущего сценария модуля. +# Последовательность внутренних вызовов: +# -> RackCrudMixin._set_rack_shelf_actors_visibility(...) +# -> RackCrudMixin._apply_rack_helper_visibility_policy(...) +# -> RackCrudMixin.show_only_zone_racks(...) +# RackCrudMixin.show_only_shelf_in_rack(...) +# Назначение: Показывает только одну полку (доску) внутри изолированного стеллажа; стойки стеллажа остаются видимыми. +# Последовательность внутренних вызовов: +# -> RackCrudMixin.show_only_rack(...) +# RackCrudMixin.show_all_racks(...) +# Назначение: показывает all racks в рамках текущего сценария модуля. +# Последовательность внутренних вызовов: +# -> RackCrudMixin._set_rack_shelf_actors_visibility(...) +# -> RackCrudMixin._apply_rack_helper_visibility_policy(...) +# RackCrudMixin._apply_rack_helper_visibility_policy(...) +# Назначение: применяет rack helper visibility policy в рамках текущего сценария модуля. +# RackCrudMixin.has_racks_in_zone(...) +# Назначение: проверяет наличие racks in zone в рамках текущего сценария модуля. +# +# D. RackCrudMixin: завершение и очистка: +# RackCrudMixin.remove_last_rack(...) +# Назначение: удаляет last rack в рамках текущего сценария модуля. +# RackCrudMixin.remove_selected_rack(...) +# Назначение: удаляет selected rack в рамках текущего сценария модуля. +# RackCrudMixin.clear_all_racks(...) +# Назначение: очищает all racks в рамках текущего сценария модуля. +# +# 3) Важные ограничения и инварианты: +# - Модуль выполняется в составе ModelViewWidget и использует согласованные поля состояния self._... . +# - Все операции с актёрами и камерой выполняются только при валидном self._plotter с безопасной обработкой исключений. +# - Межмодульная связность: только через фасад model_view; прямые обращения между zone, rack, shelf, cell запрещены. +# - Очистка состояния должна быть идемпотентной: повторный вызов не меняет корректное состояние в ошибочное. +# +# 4) Правило сопровождения: +# - Любое изменение сценария должно сопровождаться обновлением этого блока с сохранением фактического порядка вызовов. +# - При добавлении метода указывать его место в цепочке сценария (запуск, основной шаг, завершение, вспомогательная логика). diff --git a/Dispatch_V0.1.1/gui/components/model_view/_mv_racks_hover.py b/Dispatch_V0.1.1/gui/components/model_view/_mv_racks_hover.py new file mode 100644 index 0000000..63ce670 --- /dev/null +++ b/Dispatch_V0.1.1/gui/components/model_view/_mv_racks_hover.py @@ -0,0 +1,552 @@ +# -*- coding: utf-8 -*- +# gui/components/model_view/_mv_racks_hover.py +"""Вспомогательные функции подсветки при наведении и активного выделения стеллажей и слотов полок.""" + +from __future__ import annotations + +from typing import Any, TYPE_CHECKING + +try: + import pyvista as pv + + _PV = True +except ImportError: + _PV = False + +from gui.components.model_view._mv_rack_geometry import rack_bbox +from error_logger import log_exception + +if TYPE_CHECKING: + from gui.components.model_view_widget import ModelViewWidget + + +class RackHoverMixin: + """Логика подсветки при наведении и активного выделения для стеллажей и слотов полок.""" + + def _actor_contour_mesh(self: "ModelViewWidget", actor): + if not _PV or actor is None: + return None + try: + bounds = actor.GetBounds() + except Exception as exc: + log_exception(__name__, "_actor_contour_mesh", exc) + return None + if not bounds or len(bounds) != 6: + return None + min_x, max_x, min_y, max_y, min_z, max_z = [float(v) for v in bounds] + points = [ + (min_x, min_y, min_z), + (max_x, min_y, min_z), + (max_x, max_y, min_z), + (min_x, max_y, min_z), + (min_x, min_y, max_z), + (max_x, min_y, max_z), + (max_x, max_y, max_z), + (min_x, max_y, max_z), + ] + edges = ( + (0, 1), (1, 2), (2, 3), (3, 0), + (4, 5), (5, 6), (6, 7), (7, 4), + (0, 4), (1, 5), (2, 6), (3, 7), + ) + lines = [] + for i, j in edges: + lines.extend([2, i, j]) + contour = pv.PolyData(points) + contour.lines = lines + return contour + + def _highlight_selected_rendered_shelf( + self: "ModelViewWidget", + rack_id: str, + slot_id: str, + shelf_index: int, + ) -> None: + rid = str(rack_id or "") + sid = str(slot_id or "") + idx = max(1, int(shelf_index or 1)) + if not rid or not sid or not self._plotter: + self._clear_selected_rendered_shelf(render=False) + return + if ( + str(self._selected_shelf_bbox_rack_id or "") == rid + and str(self._selected_shelf_bbox_slot_id or "") == sid + and int(self._selected_shelf_bbox_index or 0) == idx + and self._selected_shelf_bbox_actor is not None + ): + return + self._clear_selected_rendered_shelf(render=False) + actors = list((self._rack_shelf_actors or {}).get((rid, sid)) or []) + if not actors: + return + actor = actors[min(len(actors) - 1, max(0, idx - 1))] + contour = self._actor_contour_mesh(actor) + if contour is None: + return + try: + self._selected_shelf_bbox_actor = self._plotter.add_mesh( + contour, + color=(1.0, 1.0, 0.3), + line_width=3.0, + pickable=False, + name="_selected_shelf_bbox_contour", + ) + self._selected_shelf_bbox_rack_id = rid + self._selected_shelf_bbox_slot_id = sid + self._selected_shelf_bbox_index = idx + except Exception as exc: + log_exception(__name__, "_highlight_selected_rendered_shelf", exc) + self._selected_shelf_bbox_actor = None + self._selected_shelf_bbox_rack_id = None + self._selected_shelf_bbox_slot_id = None + self._selected_shelf_bbox_index = None + return + if hasattr(self, "_safe_render"): + self._safe_render(min_interval_s=1.0 / 60.0) + else: + self._plotter.update() + + def _floor_shelf_plane_mesh(self: "ModelViewWidget", slot_actor): + if not _PV or slot_actor is None: + return None + try: + bounds = slot_actor.GetBounds() + except Exception as exc: + log_exception(__name__, "_floor_shelf_plane_mesh", exc) + return None + if not bounds or len(bounds) != 6: + return None + min_x, max_x, min_y, max_y, min_z, _max_z = [float(v) for v in bounds] + # Небольшой подъём полигона над базовой плоскостью, чтобы исключить z-fighting. + plane_z = float(min_z + 2.0) + points = [ + (min_x, min_y, plane_z), + (max_x, min_y, plane_z), + (max_x, max_y, plane_z), + (min_x, max_y, plane_z), + ] + plane = pv.PolyData(points) + plane.faces = [4, 0, 1, 2, 3] + return plane + + def _highlight_floor_shelf_polygon( + self: "ModelViewWidget", + rack_id: str, + slot_id: str, + ) -> None: + rid = str(rack_id or "") + sid = str(slot_id or "") + if not rid or not sid or not self._plotter: + self._clear_floor_shelf_polygon(render=False) + return + if ( + str(self._selected_floor_shelf_rack_id or "") == rid + and str(self._selected_floor_shelf_slot_id or "") == sid + and self._selected_floor_shelf_actor is not None + ): + return + bbox_actor = getattr(self, "_selected_shelf_bbox_actor", None) + if bbox_actor is not None and self._plotter: + try: + self._plotter.remove_actor(bbox_actor) + except Exception as _exc: + log_exception(__name__, "_highlight_floor_shelf_polygon", _exc) + self._selected_shelf_bbox_actor = None + self._selected_shelf_bbox_rack_id = None + self._selected_shelf_bbox_slot_id = None + self._selected_shelf_bbox_index = None + self._clear_floor_shelf_polygon(render=False) + entry = self._get_rack_entry(rid, self._shelf_target_zone_id) + if entry is None: + entry = self._get_rack_entry(rid) + if entry is None: + return + slot_actor = (entry.get("slot_actors") or {}).get(sid) + mesh = self._floor_shelf_plane_mesh(slot_actor) + if mesh is None: + return + try: + self._selected_floor_shelf_actor = self._plotter.add_mesh( + mesh, + color=(1.0, 1.0, 0.3), + edge_color=(1.0, 1.0, 0.3), + show_edges=True, + opacity=0.28, + line_width=2.5, + pickable=False, + name="_selected_floor_shelf_polygon", + ) + self._selected_floor_shelf_rack_id = rid + self._selected_floor_shelf_slot_id = sid + except Exception as exc: + log_exception(__name__, "_highlight_floor_shelf_polygon", exc) + self._selected_floor_shelf_actor = None + self._selected_floor_shelf_rack_id = None + self._selected_floor_shelf_slot_id = None + return + if hasattr(self, "_safe_render"): + self._safe_render(min_interval_s=1.0 / 60.0) + else: + self._plotter.update() + + def _clear_floor_shelf_polygon(self: "ModelViewWidget", render: bool = True) -> None: + actor = getattr(self, "_selected_floor_shelf_actor", None) + if actor is not None and self._plotter: + try: + self._plotter.remove_actor(actor) + except Exception as _exc: + log_exception(__name__, "_clear_floor_shelf_polygon", _exc) + self._selected_floor_shelf_actor = None + self._selected_floor_shelf_rack_id = None + self._selected_floor_shelf_slot_id = None + if not render: + return + if hasattr(self, "_safe_render"): + self._safe_render(min_interval_s=1.0 / 60.0) + elif self._plotter: + self._plotter.update() + + def _clear_selected_rendered_shelf(self: "ModelViewWidget", render: bool = True) -> None: + self._clear_floor_shelf_polygon(render=False) + actor = getattr(self, "_selected_shelf_bbox_actor", None) + if actor is not None and self._plotter: + try: + self._plotter.remove_actor(actor) + except Exception as _exc: + log_exception(__name__, "_clear_selected_rendered_shelf", _exc) + self._selected_shelf_bbox_actor = None + self._selected_shelf_bbox_rack_id = None + self._selected_shelf_bbox_slot_id = None + self._selected_shelf_bbox_index = None + if not render: + return + if hasattr(self, "_safe_render"): + self._safe_render(min_interval_s=1.0 / 60.0) + elif self._plotter: + self._plotter.update() + + def _highlight_shelf_slot_hover(self: "ModelViewWidget", slot_id: str) -> None: + rid = str(self._shelf_target_rack_id or "") + zid = str(self._shelf_target_zone_id or "") + entry = self._get_rack_entry(rid, zid) + if entry is None or not self._plotter: + return + self._clear_shelf_slot_hover() + slot_actor = (entry.get("slot_actors") or {}).get(str(slot_id)) + contour = self._slot_contour_mesh(slot_actor) + if contour is None: + return + try: + self._hover_shelf_slot_actor = self._plotter.add_mesh( + contour, + color=(1.0, 1.0, 0.3), + line_width=3.0, + pickable=False, + name="_hover_shelf_slot_contour", + ) + self._hover_shelf_slot_id = str(slot_id) + except Exception as exc: + log_exception(__name__, "_highlight_shelf_slot_hover", exc) + self._hover_shelf_slot_actor = None + self._hover_shelf_slot_id = None + return + self._plotter.update() + + def _clear_shelf_slot_hover(self: "ModelViewWidget") -> None: + actor = getattr(self, "_hover_shelf_slot_actor", None) + if actor is not None and self._plotter: + try: + self._plotter.remove_actor(actor) + except Exception as _exc: + log_exception(__name__, "_clear_shelf_slot_hover", _exc) + self._hover_shelf_slot_actor = None + self._hover_shelf_slot_id = None + + def _set_active_shelf_slot(self: "ModelViewWidget", slot_id: str) -> None: + rid = str(self._shelf_target_rack_id or "") + zid = str(self._shelf_target_zone_id or "") + entry = self._get_rack_entry(rid, zid) + if entry is None or not self._plotter: + return + self._clear_selected_rendered_shelf(render=False) + self._clear_shelf_slot_active() + self._clear_shelf_slot_hover() + slot_actor = (entry.get("slot_actors") or {}).get(str(slot_id)) + contour = self._slot_contour_mesh(slot_actor) + if contour is None: + return + try: + self._active_shelf_slot_actor = self._plotter.add_mesh( + contour, + color=(0.25, 0.55, 1.0), + line_width=3.0, + pickable=False, + name="_active_shelf_slot_contour", + ) + self._active_shelf_slot_id = str(slot_id) + except Exception as exc: + log_exception(__name__, "_set_active_shelf_slot", exc) + self._active_shelf_slot_actor = None + self._active_shelf_slot_id = None + self._plotter.update() + + def _clear_shelf_slot_active(self: "ModelViewWidget") -> None: + actor = getattr(self, "_active_shelf_slot_actor", None) + if actor is not None and self._plotter: + try: + self._plotter.remove_actor(actor) + except Exception as _exc: + log_exception(__name__, "_clear_shelf_slot_active", _exc) + self._active_shelf_slot_actor = None + self._active_shelf_slot_id = None + + def _find_rack_id_by_actor(self: "ModelViewWidget", picked_actor, zone_id: str) -> str | None: + zid = str(zone_id or "") + if picked_actor is None: + return None + for entry in reversed(self._rack_entries): + if zid and str(entry.get("zone_id", "")) != zid: + continue + for actor in entry.get("actors", []): + if actor is None: + continue + try: + if not bool(actor.GetVisibility()): + continue + except Exception as _exc: + log_exception(__name__, "_find_rack_id_by_actor", _exc) + if actor is picked_actor or actor == picked_actor: + return str(entry.get("rack_id", "")) + try: + if ( + hasattr(actor, "GetAddressAsString") + and hasattr(picked_actor, "GetAddressAsString") + and actor.GetAddressAsString("") == picked_actor.GetAddressAsString("") + ): + return str(entry.get("rack_id", "")) + except Exception as _exc: + log_exception(__name__, "_find_rack_id_by_actor", _exc) + return None + + def _find_rack_id_by_point(self: "ModelViewWidget", x: float, y: float, zone_id: str) -> str | None: + zid = str(zone_id or "") + for entry in reversed(self._rack_entries): + if zid and str(entry.get("zone_id", "")) != zid: + continue + # Пропускаем скрытые импортированные модели, чтобы они не перехватывали выбор. + if not self._imported_models_enabled and self._is_imported_model_entry(entry): + continue + min_x, max_x, min_y, max_y = self._rack_pick_bbox(entry) + if min_x <= x <= max_x and min_y <= y <= max_y: + return str(entry.get("rack_id", "")) + return None + + def _rack_pick_bbox(self: "ModelViewWidget", entry: dict[str, Any]) -> tuple[float, float, float, float]: + """Полный bbox габаритов для выбора (включая стойки/рёбра).""" + center = entry.get("center") or (0.0, 0.0) + params = dict(entry.get("params") or {}) + rotation = int(entry.get("rotation", 0)) % 360 + return entry.get("bbox") or rack_bbox( + float(center[0]), float(center[1]), params, rotation, + ) + + def _rack_container_bbox(self: "ModelViewWidget", entry: dict[str, Any]) -> tuple[float, float, float, float]: + """Внешние XY-границы геометрии стеллажа (без вспомогательных проходов/буферов).""" + actors = list(entry.get("actors") or []) + primary_actor = actors[0] if actors else None + if primary_actor is not None: + try: + b = primary_actor.GetBounds() + if b and len(b) == 6: + min_x, max_x, min_y, max_y = float(b[0]), float(b[1]), float(b[2]), float(b[3]) + if min_x < max_x and min_y < max_y: + return (min_x, max_x, min_y, max_y) + except Exception as _exc: + log_exception(__name__, "_rack_container_bbox", _exc) + # Резервный вариант: параметрическая аппроксимация при недоступных границах актёра. + center = entry.get("center") or (0.0, 0.0) + params = dict(entry.get("params") or {}) + rotation = int(entry.get("rotation", 0)) % 360 + if self._has_additional_model(params): + return entry.get("bbox") or rack_bbox(float(center[0]), float(center[1]), params, rotation) + cx = float(center[0]) + cy = float(center[1]) + + if self._is_pallet_params(params): + width = max(10.0, float(params.get("footprint_width_mm", self._PALLET_FOOTPRINT_WIDTH_SINGLE_MM))) + depth = max(10.0, float(params.get("footprint_depth_mm", self._PALLET_DEPTH_MM))) + else: + width = max(10.0, float(params.get("footprint_width_mm", 1000)) - 60.0) + depth = max(10.0, float(params.get("footprint_depth_mm", 500)) - 60.0) + if int(rotation) % 180 == 90: + width, depth = depth, width + return ( + cx - width / 2.0, + cx + width / 2.0, + cy - depth / 2.0, + cy + depth / 2.0, + ) + + def _build_rack_hover_contour_mesh(self: "ModelViewWidget", entry: dict[str, Any]): + if not _PV: + return None + bbox = self._rack_container_bbox(entry) + min_x, max_x, min_y, max_y = bbox + bottom_z = None + top_z = None + actors = list(entry.get("actors") or []) + primary_actor = actors[0] if actors else None + if primary_actor is not None: + try: + b = primary_actor.GetBounds() + if b and len(b) == 6: + min_z, max_z = float(b[4]), float(b[5]) + if min_z < max_z: + bottom_z = min_z + 2.0 + top_z = max_z + 2.0 + except Exception as _exc: + log_exception(__name__, "_build_rack_hover_contour_mesh", _exc) + if bottom_z is None or top_z is None: + params = dict(entry.get("params") or {}) + z_ref = float(self._zone_heights.get(str(entry.get("zone_id") or ""), (0.0, 0.0))[0]) + bottom_z = float(z_ref + 2.0) + top_z = float(z_ref + max(10.0, self._rack_height_mm(params)) + 2.0) + points = [ + # Нижний прямоугольник. + (float(min_x), float(min_y), bottom_z), # 0 + (float(max_x), float(min_y), bottom_z), # 1 + (float(max_x), float(max_y), bottom_z), # 2 + (float(min_x), float(max_y), bottom_z), # 3 + # Верхний прямоугольник. + (float(min_x), float(min_y), top_z), # 4 + (float(max_x), float(min_y), top_z), # 5 + (float(max_x), float(max_y), top_z), # 6 + (float(min_x), float(max_y), top_z), # 7 + ] + line_cells = [] + edges = ( + (0, 1), (1, 2), (2, 3), (3, 0), # низ + (4, 5), (5, 6), (6, 7), (7, 4), # верх + (0, 4), (1, 5), (2, 6), (3, 7), # вертикали + ) + for i, j in edges: + line_cells.extend([2, i, j]) + contour = pv.PolyData(points) + contour.lines = line_cells + return contour + + def _highlight_hover_rack(self: "ModelViewWidget", rack_id: str) -> None: + rid = str(rack_id or "") + if not rid: + self._clear_hover_rack_highlight() + return + if rid == str(self._hover_rack_id or "") and self._hover_rack_contour_actor is not None: + return + self._clear_hover_rack_highlight(render=False) + entry = None + for candidate in self._rack_entries: + if str(candidate.get("rack_id", "")) == rid: + entry = candidate + break + if entry is None or not self._plotter: + return + contour = self._build_rack_hover_contour_mesh(entry) + if contour is None: + return + try: + self._hover_rack_contour_actor = self._plotter.add_mesh( + contour, + color=(1.0, 1.0, 0.3), + line_width=3.0, + name="_hover_rack_contour", + pickable=False, + ) + self._hover_rack_id = rid + except Exception as exc: + log_exception(__name__, "_highlight_hover_rack", exc) + self._hover_rack_contour_actor = None + self._hover_rack_id = None + return + if hasattr(self, "_safe_render"): + self._safe_render(min_interval_s=1.0 / 60.0) + elif self._plotter: + self._plotter.update() + + def _clear_hover_rack_highlight(self: "ModelViewWidget", render: bool = True) -> None: + actor = getattr(self, "_hover_rack_contour_actor", None) + if actor is not None and self._plotter is not None: + try: + self._plotter.remove_actor(actor) + except Exception as _exc: + log_exception(__name__, "_clear_hover_rack_highlight", _exc) + self._hover_rack_contour_actor = None + self._hover_rack_id = None + if render: + if hasattr(self, "_safe_render"): + self._safe_render(min_interval_s=1.0 / 60.0) + elif self._plotter: + self._plotter.update() + + +# --------------------------------------------------------------------------- +# Module workflow notes +# --------------------------------------------------------------------------- +# 1) Задача модуля: +# Наведение и подсветка стеллажей и ячеек. +# +# 2) Последовательность действий и вызовов: +# A. Класс RackHoverMixin: точки входа +# Публичные методы отсутствуют; сценарий запускается через методы родительских модулей и внутренние обработчики. +# +# B. RackHoverMixin: запуск и настройка: +# RackHoverMixin._set_active_shelf_slot(...) +# Назначение: устанавливает active shelf slot в рамках текущего сценария модуля. +# Последовательность внутренних вызовов: +# -> RackHoverMixin._clear_shelf_slot_active(...) +# -> RackHoverMixin._clear_shelf_slot_hover(...) +# +# C. RackHoverMixin: основной сценарий: +# RackHoverMixin._find_rack_id_by_actor(...) +# Назначение: находит rack id by actor в рамках текущего сценария модуля. +# RackHoverMixin._find_rack_id_by_point(...) +# Назначение: находит rack id by point в рамках текущего сценария модуля. +# Последовательность внутренних вызовов: +# -> RackHoverMixin._rack_pick_bbox(...) +# +# D. RackHoverMixin: завершение и очистка: +# RackHoverMixin._clear_shelf_slot_hover(...) +# Назначение: очищает shelf slot hover в рамках текущего сценария модуля. +# RackHoverMixin._clear_shelf_slot_active(...) +# Назначение: очищает shelf slot active в рамках текущего сценария модуля. +# RackHoverMixin._clear_hover_rack_highlight(...) +# Назначение: очищает hover rack highlight в рамках текущего сценария модуля. +# +# E. RackHoverMixin: вспомогательные расчёты: +# RackHoverMixin._highlight_shelf_slot_hover(...) +# Назначение: подсвечивает shelf slot hover в рамках текущего сценария модуля. +# Последовательность внутренних вызовов: +# -> RackHoverMixin._clear_shelf_slot_hover(...) +# RackHoverMixin._rack_pick_bbox(...) +# Назначение: Полный bbox габаритов для выбора (включая стойки/рёбра). +# RackHoverMixin._rack_container_bbox(...) +# Назначение: Внешние XY-границы геометрии стеллажа (без вспомогательных проходов/буферов). +# RackHoverMixin._build_rack_hover_contour_mesh(...) +# Назначение: строит rack hover contour mesh в рамках текущего сценария модуля. +# Последовательность внутренних вызовов: +# -> RackHoverMixin._rack_container_bbox(...) +# RackHoverMixin._highlight_hover_rack(...) +# Назначение: подсвечивает hover rack в рамках текущего сценария модуля. +# Последовательность внутренних вызовов: +# -> RackHoverMixin._clear_hover_rack_highlight(...) +# -> RackHoverMixin._build_rack_hover_contour_mesh(...) +# +# 3) Важные ограничения и инварианты: +# - Модуль выполняется в составе ModelViewWidget и использует согласованные поля состояния self._... . +# - Все операции с актёрами и камерой выполняются только при валидном self._plotter с безопасной обработкой исключений. +# - Геометрическая визуализация зависит от pyvista/vtk; при недоступности модуль обязан завершать шаг без падения сценария. +# - Межмодульная связность: только через фасад model_view; прямые обращения между zone, rack, shelf, cell запрещены. +# - Очистка состояния должна быть идемпотентной: повторный вызов не меняет корректное состояние в ошибочное. +# +# 4) Правило сопровождения: +# - Любое изменение сценария должно сопровождаться обновлением этого блока с сохранением фактического порядка вызовов. +# - При добавлении метода указывать его место в цепочке сценария (запуск, основной шаг, завершение, вспомогательная логика). diff --git a/Dispatch_V0.1.1/gui/components/model_view/_mv_racks_io.py b/Dispatch_V0.1.1/gui/components/model_view/_mv_racks_io.py new file mode 100644 index 0000000..7a314f1 --- /dev/null +++ b/Dispatch_V0.1.1/gui/components/model_view/_mv_racks_io.py @@ -0,0 +1,252 @@ +# -*- coding: utf-8 -*- +# gui/components/model_view/_mv_racks_io.py +"""IO-операции для восстановление/сериализации размещённых стоек.""" + +from __future__ import annotations + +import uuid +from pathlib import Path +from typing import Any, Callable, TYPE_CHECKING + +from gui.components.model_view._mv_rack_geometry import rack_bbox +from error_logger import log_exception + +if TYPE_CHECKING: # pragma: no cover + from gui.components.model_view_widget import ModelViewWidget + + +class RackPlacementIOMixin: + """Mixin IO-операций для RackPlacementMixin.""" + + def get_zone_rack_layout(self: "ModelViewWidget", zone_id: str) -> list[dict[str, Any]]: + zid = str(zone_id or "") + payload: list[dict[str, Any]] = [] + for entry in self._rack_entries: + if entry.get("zone_id") != zid: + continue + params = dict(entry.get("params") or {}) + if hasattr(self, "_normalize_rack_params"): + try: + params = self._normalize_rack_params( + params, + fallback_code=str(entry.get("code") or params.get("code") or "A"), + ) + except Exception as _exc: + log_exception(__name__, "get_zone_rack_layout", _exc) + center = entry.get("center") or (0.0, 0.0) + payload.append( + { + "rack_id": str(entry.get("rack_id") or str(uuid.uuid4())), + "code": str(entry.get("code") or params.get("code") or "A"), + "name": str(entry.get("name") or "Rack"), + "created_ts": str(entry.get("created_ts") or ""), + "rack_type": str(params.get("rack_type", "A")), + "depth_mm": int(params.get("depth_mm", 500)), + "spans_count": int(params.get("spans_count", 1)), + "numbering_direction": str( + params.get("numbering_direction") + or entry.get("numbering_direction") + or "left_to_right" + ), + "span_codes": [ + str(v) + for v in ( + params.get("span_codes") + or entry.get("span_codes") + or [] + ) + ], + "uniform_width": bool(params.get("uniform_width", True)), + "span_widths_mm": [int(v) for v in (params.get("span_widths_mm") or [1000])], + "center_spans_mm": [int(v) for v in (params.get("center_spans_mm") or [1000])], + "footprint_width_mm": int(params.get("footprint_width_mm", 1000)), + "footprint_depth_mm": int(params.get("footprint_depth_mm", 500)), + "footprint_height_mm": int(params.get("footprint_height_mm", 1800)), + "pillars_count": int(params.get("pillars_count", 4)), + "center_x": float(center[0]), + "center_y": float(center[1]), + "rotation_deg": int(entry.get("rotation", 0)), + "hidden_slot_ids": sorted(str(v) for v in (entry.get("hidden_slot_ids") or set())), + "shelf_helpers_hidden": bool(entry.get("shelf_helpers_hidden", False)), + "display_color": str(params.get("display_color") or ""), + "display_opacity": float(params.get("display_opacity", 0.9)), + "additional_model": dict(params.get("additional_model") or {}), + } + ) + return payload + + def load_zone_rack_layout( + self: "ModelViewWidget", + zone_id: str, + racks: list[dict[str, Any]], + models_root: Path | None = None, + progress_callback: Callable[[int, int], None] | None = None, + ) -> None: + zid = str(zone_id or "") + self._rack_models_root = Path(models_root) if models_root else self._rack_models_root + self._remove_zone_racks(zid) + rack_items = list(racks or []) + total_items = len(rack_items) + for item_idx, item in enumerate(rack_items, start=1): + try: + if "center_x" not in item or "center_y" not in item: + continue + params = { + "code": str(item.get("code") or "A"), + "rack_type": str(item.get("rack_type", "A")), + "depth_mm": int(item.get("depth_mm", 500)), + "spans_count": int(item.get("spans_count", 1)), + "numbering_direction": str(item.get("numbering_direction") or "left_to_right"), + "span_codes": [str(v) for v in (item.get("span_codes") or [])], + "uniform_width": bool(item.get("uniform_width", True)), + "span_widths_mm": [int(v) for v in (item.get("span_widths_mm") or [1000])], + "center_spans_mm": [int(v) for v in (item.get("center_spans_mm") or [1000])], + "footprint_width_mm": int(item.get("footprint_width_mm", 1000)), + "footprint_depth_mm": int(item.get("footprint_depth_mm", 500)), + "footprint_height_mm": int(item.get("footprint_height_mm", 1800)), + "pillars_count": int(item.get("pillars_count", 4)), + "display_color": str(item.get("display_color") or ""), + "display_opacity": float(item.get("display_opacity", 0.9)), + "additional_model": dict(item.get("additional_model") or {}), + } + if hasattr(self, "_normalize_rack_params"): + try: + params = self._normalize_rack_params( + params, + fallback_code=str(item.get("code") or "A"), + ) + except Exception as _exc: + log_exception(__name__, "load_zone_rack_layout", _exc) + cx = float(item.get("center_x", 0.0)) + cy = float(item.get("center_y", 0.0)) + rotation = int(item.get("rotation_deg", 0)) % 360 + color, opacity = self._resolve_rack_style(params) + actors, slot_actors = self._spawn_rack_actors(cx, cy, zid, params, rotation, color, opacity) + if not actors: + continue + hidden_slot_ids = {str(v) for v in (item.get("hidden_slot_ids") or [])} + for slot_id in hidden_slot_ids: + actor = slot_actors.get(slot_id) + if actor is not None: + try: + actor.SetVisibility(0) + except Exception as _exc: + log_exception(__name__, "load_zone_rack_layout", _exc) + self._rack_entries.append( + { + "rack_id": str(item.get("rack_id") or str(uuid.uuid4())), + "zone_id": zid, + "center": (cx, cy), + "rotation": rotation, + "bbox": rack_bbox(cx, cy, params, rotation), + "params": params, + "actors": actors, + "slot_actors": slot_actors, + "hidden_slot_ids": hidden_slot_ids, + "shelf_helpers_hidden": bool(item.get("shelf_helpers_hidden", False)), + "code": str(item.get("code") or "A"), + "name": str(item.get("name") or "Rack"), + "created_ts": str(item.get("created_ts") or ""), + "numbering_direction": str(item.get("numbering_direction") or params.get("numbering_direction") or "left_to_right"), + "span_codes": [str(v) for v in (item.get("span_codes") or params.get("span_codes") or [])], + } + ) + self._apply_rack_helper_visibility_policy(self._rack_entries[-1], True) + finally: + if progress_callback is not None: + try: + progress_callback(item_idx, total_items) + except Exception as _exc: + log_exception(__name__, "load_zone_rack_layout", _exc) + if self._plotter: + self._plotter.update() + + def _remove_zone_racks(self: "ModelViewWidget", zone_id: str) -> None: + removed_rack_ids: set[str] = set() + remaining: list[dict[str, Any]] = [] + for entry in self._rack_entries: + if entry.get("zone_id") != zone_id: + remaining.append(entry) + continue + rack_id = str(entry.get("rack_id") or "") + if rack_id: + removed_rack_ids.add(rack_id) + for actor in entry.get("actors", []): + try: + self._plotter.remove_actor(actor) + except Exception as _exc: + log_exception(__name__, "_remove_zone_racks", _exc) + if rack_id: + self._clear_slot_shelf_actors(rack_id, clear_slot_id=None) + self._rack_entries = remaining + if not removed_rack_ids: + return + + try: + if str(getattr(self, "_selected_rack_id", "") or "") in removed_rack_ids: + self._set_selected_rack(None) + except Exception as _exc: + log_exception(__name__, "_remove_zone_racks", _exc) + try: + if str(getattr(self, "_hover_rack_id", "") or "") in removed_rack_ids: + self._clear_hover_rack_highlight() + except Exception as _exc: + log_exception(__name__, "_remove_zone_racks", _exc) + try: + if str(getattr(self, "_rack_isolation_rack_id", "") or "") in removed_rack_ids: + self._rack_isolation_active = False + self._rack_isolation_rack_id = None + self._clear_rack_isolation_visual() + except Exception as _exc: + log_exception(__name__, "_remove_zone_racks", _exc) + try: + if str(getattr(self, "_shelf_target_rack_id", "") or "") in removed_rack_ids: + self.stop_shelf_placement_ext(clear_selection=True, clear_rendered_shelves=True) + except Exception as _exc: + log_exception(__name__, "_remove_zone_racks", _exc) + try: + if str(getattr(self, "_selected_shelf_bbox_rack_id", "") or "") in removed_rack_ids: + self._clear_selected_rendered_shelf(render=False) + except Exception as _exc: + log_exception(__name__, "_remove_zone_racks", _exc) + try: + if hasattr(self, "clear_cell_grid_visualization"): + for rid in removed_rack_ids: + self.clear_cell_grid_visualization(rid) + except Exception as _exc: + log_exception(__name__, "_remove_zone_racks", _exc) +# --------------------------------------------------------------------------- +# Module workflow notes +# --------------------------------------------------------------------------- +# 1) Задача модуля: +# Сериализация и загрузка раскладок стеллажей по зонам. +# +# 2) Последовательность действий и вызовов: +# A. Класс RackPlacementIOMixin: точки входа +# Публичные методы сценария: +# - RackPlacementIOMixin.get_zone_rack_layout(...) +# - RackPlacementIOMixin.load_zone_rack_layout(...) +# +# B. RackPlacementIOMixin: запуск и настройка: +# RackPlacementIOMixin.load_zone_rack_layout(...) +# Назначение: загружает zone rack layout в рамках текущего сценария модуля. +# Последовательность внутренних вызовов: +# -> RackPlacementIOMixin._remove_zone_racks(...) +# +# C. RackPlacementIOMixin: завершение и очистка: +# RackPlacementIOMixin._remove_zone_racks(...) +# Назначение: удаляет zone racks в рамках текущего сценария модуля. +# +# D. RackPlacementIOMixin: вспомогательные расчёты: +# RackPlacementIOMixin.get_zone_rack_layout(...) +# Назначение: возвращает zone rack layout в рамках текущего сценария модуля. +# +# 3) Важные ограничения и инварианты: +# - Модуль выполняется в составе ModelViewWidget и использует согласованные поля состояния self._... . +# - Все операции с актёрами и камерой выполняются только при валидном self._plotter с безопасной обработкой исключений. +# - Межмодульная связность: только через фасад model_view; прямые обращения между zone, rack, shelf, cell запрещены. +# - Очистка состояния должна быть идемпотентной: повторный вызов не меняет корректное состояние в ошибочное. +# +# 4) Правило сопровождения: +# - Любое изменение сценария должно сопровождаться обновлением этого блока с сохранением фактического порядка вызовов. +# - При добавлении метода указывать его место в цепочке сценария (запуск, основной шаг, завершение, вспомогательная логика). diff --git a/Dispatch_V0.1.1/gui/components/model_view/_mv_racks_lifecycle.py b/Dispatch_V0.1.1/gui/components/model_view/_mv_racks_lifecycle.py new file mode 100644 index 0000000..4c7c36c --- /dev/null +++ b/Dispatch_V0.1.1/gui/components/model_view/_mv_racks_lifecycle.py @@ -0,0 +1,334 @@ +# -*- coding: utf-8 -*- +"""Миксин жизненного цикла стоек – инициализация, начало/остановка размещения, +параметры предпросмотра, генерация кодов и вспомогательные функции поворота, +извлечённые из _mv_racks.py.""" + +from __future__ import annotations + +import uuid +import re +from pathlib import Path +from typing import Any, TYPE_CHECKING +from PySide6.QtCore import QTimer + +try: + import pyvista as pv + + _PV = True +except ImportError: + _PV = False + +from gui.components.model_view._mv_rack_geometry import rack_bbox, rotate_xy +from error_logger import log_exception + +if TYPE_CHECKING: # pragma: no cover + from gui.components.model_view_widget import ModelViewWidget + + +def _ray_aabb_intersect( + origin: tuple[float, float, float], + dx: float, dy: float, dz: float, + min_x: float, max_x: float, + min_y: float, max_y: float, + min_z: float, max_z: float, +) -> float | None: + """Пересечение луча с AABB методом слэбов. Возвращает параметрическое *t* или None.""" + t_min = 0.0 + t_max = 1e30 + for o, d, lo, hi in ( + (origin[0], dx, min_x, max_x), + (origin[1], dy, min_y, max_y), + (origin[2], dz, min_z, max_z), + ): + if abs(d) < 1e-12: + if o < lo or o > hi: + return None + else: + inv_d = 1.0 / d + t1 = (lo - o) * inv_d + t2 = (hi - o) * inv_d + if t1 > t2: + t1, t2 = t2, t1 + t_min = max(t_min, t1) + t_max = min(t_max, t2) + if t_min > t_max: + return None + return t_min + + +class RackLifecycleMixin: + """Миксин с логикой жизненного цикла стоек – инициализация, начало/остановка + размещения, обновление параметров предпросмотра, генерация кодов и поворот.""" + + def init_rack_placement(self: "ModelViewWidget") -> None: + self._rack_entries: list[dict[str, Any]] = [] + self._rack_preview_actors: list = [] + self._rack_move_mode = False + self._rack_preview_valid = False + self._rack_preview_center: tuple[float, float] | None = None + self._rack_preview_shape_key: tuple[Any, ...] | None = None + self._rack_preview_rotation = 0 + self._rack_preview_params: dict[str, Any] = {} + self._rack_preview_zone_id: str | None = None + self._rack_models_root: Path | None = None + self._rack_select_mode = False + self._selected_rack_id: str | None = None + self._selected_rack_visual_id: str | None = None + self._selected_rack_visual_props: list[tuple[object, float, bool, float, tuple[float, float, float]]] = [] + self._rack_isolation_active = False + self._rack_isolation_rack_id: str | None = None + self._rack_isolation_visual_props: list[tuple[object, int, float, bool, float, tuple[float, float, float]]] = [] + self._hover_rack_id: str | None = None + self._hover_rack_contour_actor = None + self._selected_rack_contour_actor = None + self._moving_rack_entry: dict[str, Any] | None = None + self._shelf_target_zone_id: str | None = None + self._shelf_target_rack_id: str | None = None + self._hover_shelf_slot_id: str | None = None + self._active_shelf_slot_id: str | None = None + self._hover_shelf_slot_actor = None + self._active_shelf_slot_actor = None + self._rack_shelf_params: dict[tuple[str, str], dict[str, Any]] = {} + self._rack_shelf_actors: dict[tuple[str, str], list[Any]] = {} + self._rack_shelf_cell_links: dict[tuple[str, str], bool] = {} + self._rack_shelf_cell_links_indexed: dict[tuple[str, str, int], bool] = {} + self._cell_preview_actors: dict[tuple[str, str], list[Any]] = {} + self._cell_final_actors: dict[tuple[str, str], list[Any]] = {} + self._cell_dim_actors: dict[tuple[str, str], list[Any]] = {} + self._cell_ref_bounds: dict[tuple, tuple] = {} + self._cell_grid_edit_isolation_active: bool = False + self._cell_grid_edit_rack_id: str | None = None + self._cell_grid_edit_slot_id: str | None = None + self._cell_grid_edit_shelf_index: int | None = None + self._selected_shelf_visual_rack_id: str | None = None + self._selected_shelf_visual_slot_id: str | None = None + self._selected_shelf_visual_index: int | None = None + self._selected_shelf_bbox_actor = None + self._selected_shelf_bbox_rack_id: str | None = None + self._selected_shelf_bbox_slot_id: str | None = None + self._selected_shelf_bbox_index: int | None = None + self._selected_floor_shelf_actor = None + self._selected_floor_shelf_rack_id: str | None = None + self._selected_floor_shelf_slot_id: str | None = None + self._shelf_mesh_cache: dict[tuple[str, int, int], Any] = {} + self._pillar_mesh_cache: dict[tuple[str, int], tuple[Any, float]] = {} + self._additional_model_mesh_cache: dict[str, tuple[Any, float, float, float]] = {} + self._camera_anim_timer: QTimer | None = None + self._camera_anim_seq = 0 + self._last_moved_rack_id: str | None = None + self._imported_models_enabled = True + try: + self.rack_slot_visibility_changed.connect(self._on_rack_slot_visibility_changed) + except Exception as _exc: + log_exception(__name__, "init_rack_placement", _exc) + def start_rack_placement( + self: "ModelViewWidget", + zone_id: str, + params: dict[str, Any], + models_root: Path | None = None, + move_existing: bool = False, + ) -> None: + if not self._plotter or not self._models_loaded: + return + self._rack_move_mode = bool(move_existing) + self._rack_preview_zone_id = zone_id + self._rack_models_root = Path(models_root) if models_root else None + if self._rack_move_mode and self._moving_rack_entry is not None: + self._rack_preview_rotation = int(dict(self._moving_rack_entry).get("rotation", 0)) % 360 + else: + self._rack_preview_rotation = 0 + fallback_code = None + if self._moving_rack_entry is None: + fallback_code = self._next_zone_rack_code(zone_id, dict(params or {})) + else: + fallback_code = str(dict(self._moving_rack_entry).get("code") or dict(params or {}).get("code") or "A") + self.update_rack_preview_params( + self._normalize_rack_params(dict(params or {}), fallback_code=fallback_code) + ) + self._clear_hover_rack_highlight() + self.show_only_zone_racks(zone_id) + # Сценарий взаимодействия + mgr = getattr(self, "_interaction_manager", None) + if mgr is not None: + mgr.pop_by_name("rack_select") + from gui.components.model_view._scenario_custom_handler import CustomHandlerScenario + from PySide6.QtCore import Qt as _Qt + mgr.push(CustomHandlerScenario( + name="rack_placement", + click_handler=self._on_rack_click, + hover_screen_handler=self._on_rack_hover_screen, + hotkeys={ + _Qt.Key.Key_Escape: lambda: self.stop_rack_placement(clear_preview=True), + }, + )) + # Гарантируем получение KeyPress в interactor (горячая клавиша R). + try: + if self._plotter is not None: + self._plotter.setFocus() + interactor = getattr(self._plotter, "interactor", None) + if interactor is not None: + interactor.setFocus() + except Exception as _exc: + log_exception(__name__, "start_rack_placement", _exc) + def stop_rack_placement(self: "ModelViewWidget", clear_preview: bool = True) -> None: + # Убрать сценарий взаимодействия + mgr = getattr(self, "_interaction_manager", None) + if mgr is not None: + mgr.pop_by_name("rack_placement") + restore_moving = self._moving_rack_entry is not None + self._rack_move_mode = False + self._rack_preview_valid = False + self._rack_preview_center = None + if not self._interaction_manager.is_active("rack_select"): + self._clear_hover_rack_highlight() + if clear_preview: + self._clear_rack_preview() + if restore_moving: + self._restore_moving_rack_entry() + if self._plotter: + self._plotter.update() + + def update_rack_preview_params(self: "ModelViewWidget", params: dict[str, Any]) -> None: + fallback_code = str(dict(self._rack_preview_params or {}).get("code") or "A") + if self._moving_rack_entry is not None: + fallback_code = str(dict(self._moving_rack_entry).get("code") or fallback_code or "A") + elif self._rack_preview_zone_id: + fallback_code = str( + self._next_zone_rack_code( + str(self._rack_preview_zone_id), + dict(params or {}), + ) + or fallback_code + or "A" + ) + self._rack_preview_params = self._normalize_rack_params( + dict(params or {}), + fallback_code=fallback_code, + ) + if self._rack_preview_center is not None: + cx, cy = self._rack_preview_center + self._rebuild_rack_preview(cx, cy) + + def get_next_rack_code(self: "ModelViewWidget", zone_id: str, params: dict[str, Any] | None = None) -> str: + return self._next_zone_rack_code(zone_id, params) + + def rotate_rack_preview(self: "ModelViewWidget") -> None: + if self._selected_rack_id and self._moving_rack_entry is None: + if self.rotate_selected_rack(90): + return + self._rack_preview_rotation = (self._rack_preview_rotation + 90) % 360 + if self._rack_preview_center is not None: + cx, cy = self._rack_preview_center + self._rebuild_rack_preview(cx, cy) + + def rotate_selected_rack(self: "ModelViewWidget", delta_deg: int) -> bool: + rid = str(self._selected_rack_id or "") + if not rid: + return False + for idx, entry in enumerate(self._rack_entries): + if str(entry.get("rack_id", "")) != rid: + continue + center = entry.get("center") or (0.0, 0.0) + params = dict(entry.get("params") or {}) + zone_id = str(entry.get("zone_id") or "") + new_rotation = (int(entry.get("rotation", 0)) + int(delta_deg)) % 360 + if not self._validate_rack_position( + float(center[0]), + float(center[1]), + zone_id, + params, + new_rotation, + ignore_rack_id=rid, + ): + return False + for actor in entry.get("actors", []): + try: + self._plotter.remove_actor(actor) + except Exception as _exc: + log_exception(__name__, "rotate_selected_rack", _exc) + color, opacity = self._resolve_rack_style(params) + actors, slot_actors = self._spawn_rack_actors( + float(center[0]), float(center[1]), zone_id, params, new_rotation, color, opacity, + ) + if not actors: + return False + hidden_slot_ids = set(str(v) for v in (entry.get("hidden_slot_ids") or set())) + for sid in hidden_slot_ids: + actor = slot_actors.get(sid) + if actor is not None: + try: + actor.SetVisibility(0) + except Exception as _exc: + log_exception(__name__, "rotate_selected_rack", _exc) + entry["rotation"] = new_rotation + entry["bbox"] = rack_bbox(float(center[0]), float(center[1]), params, new_rotation) + entry["actors"] = actors + entry["slot_actors"] = slot_actors + entry["hidden_slot_ids"] = hidden_slot_ids + self._apply_rack_helper_visibility_policy(entry, True) + self._rack_entries[idx] = entry + if self._plotter: + self._plotter.update() + self.rack_layout_changed.emit(zone_id) + self._set_selected_rack(rid) + return True + return False + + +# --------------------------------------------------------------------------- +# Module workflow notes +# --------------------------------------------------------------------------- +# 1) Задача модуля: +# Жизненный цикл размещения стеллажа: запуск, обновление, остановка, поворот. +# +# 2) Последовательность действий и вызовов: +# A. Функции уровня модуля: +# _ray_aabb_intersect(...) +# Назначение: Пересечение луча с AABB методом слэбов. Возвращает параметрическое *t* или None. +# +# B. Класс RackLifecycleMixin: точки входа +# Публичные методы сценария: +# - RackLifecycleMixin.init_rack_placement(...) +# - RackLifecycleMixin.start_rack_placement(...) +# - RackLifecycleMixin.stop_rack_placement(...) +# - RackLifecycleMixin.update_rack_preview_params(...) +# - RackLifecycleMixin.get_next_rack_code(...) +# - RackLifecycleMixin.rotate_rack_preview(...) +# - RackLifecycleMixin.rotate_selected_rack(...) +# +# C. RackLifecycleMixin: запуск и настройка: +# RackLifecycleMixin.init_rack_placement(...) +# Назначение: инициализирует rack placement в рамках текущего сценария модуля. +# RackLifecycleMixin.start_rack_placement(...) +# Назначение: запускает rack placement в рамках текущего сценария модуля. +# Последовательность внутренних вызовов: +# -> RackLifecycleMixin.update_rack_preview_params(...) +# +# D. RackLifecycleMixin: основной сценарий: +# RackLifecycleMixin.update_rack_preview_params(...) +# Назначение: обновляет rack preview params в рамках текущего сценария модуля. +# +# E. RackLifecycleMixin: завершение и очистка: +# RackLifecycleMixin.stop_rack_placement(...) +# Назначение: останавливает rack placement в рамках текущего сценария модуля. +# +# F. RackLifecycleMixin: вспомогательные расчёты: +# RackLifecycleMixin.get_next_rack_code(...) +# Назначение: возвращает next rack code в рамках текущего сценария модуля. +# RackLifecycleMixin.rotate_rack_preview(...) +# Назначение: выполняет шаг "rotate rack preview" в рамках текущего сценария модуля. +# Последовательность внутренних вызовов: +# -> RackLifecycleMixin.rotate_selected_rack(...) +# RackLifecycleMixin.rotate_selected_rack(...) +# Назначение: выполняет шаг "rotate selected rack" в рамках текущего сценария модуля. +# +# 3) Важные ограничения и инварианты: +# - Модуль выполняется в составе ModelViewWidget и использует согласованные поля состояния self._... . +# - Все операции с актёрами и камерой выполняются только при валидном self._plotter с безопасной обработкой исключений. +# - Геометрическая визуализация зависит от pyvista/vtk; при недоступности модуль обязан завершать шаг без падения сценария. +# - Межмодульная связность: только через фасад model_view; прямые обращения между zone, rack, shelf, cell запрещены. +# - Очистка состояния должна быть идемпотентной: повторный вызов не меняет корректное состояние в ошибочное. +# +# 4) Правило сопровождения: +# - Любое изменение сценария должно сопровождаться обновлением этого блока с сохранением фактического порядка вызовов. +# - При добавлении метода указывать его место в цепочке сценария (запуск, основной шаг, завершение, вспомогательная логика). diff --git a/Dispatch_V0.1.1/gui/components/model_view/_mv_racks_mezzanine.py b/Dispatch_V0.1.1/gui/components/model_view/_mv_racks_mezzanine.py new file mode 100644 index 0000000..23a533a --- /dev/null +++ b/Dispatch_V0.1.1/gui/components/model_view/_mv_racks_mezzanine.py @@ -0,0 +1,164 @@ +# -*- coding: utf-8 -*- +"""Миксин вспомогательных функций стоек для мезонинов. + +Обеспечивает обнаружение опор колонн, проверку зазоров и логику +совместного использования XY для мезонинных конструкций в *ModelViewWidget*. +""" +from __future__ import annotations + +from pathlib import Path +from typing import Any, TYPE_CHECKING + +from error_logger import log_exception +from gui.components.model_view._mv_rack_geometry import rotate_xy + +if TYPE_CHECKING: + from gui.components.model_view_widget import ModelViewWidget + + +class RackMezzanineMixin: + """Вспомогательные функции мезонинных стоек, подмешиваемые в *ModelViewWidget*.""" + + # ------------------------------------------------------------------ + # Вспомогательные функции мезонинов + # ------------------------------------------------------------------ + + @staticmethod + def _is_mezzanine_params(params: dict[str, Any]) -> bool: + additional_model = dict(params.get("additional_model") or {}) + model_path = additional_model.get("model_path") + if not model_path: + return False + explicit = additional_model.get("is_mezzanine") + if isinstance(explicit, bool): + return explicit + model_kind = str(additional_model.get("model_kind") or "").strip().lower() + if model_kind in {"mezo", "mezz", "mezzanine"}: + return True + haystack = " ".join( + str(v) + for v in ( + additional_model.get("model_name"), + additional_model.get("model_path"), + params.get("rack_type"), + ) + if v + ).lower() + return any(tag in haystack for tag in ("mezo", "mezz", "Р Сез", "mezan", "mezon")) + + def _mezzanine_clearance_mm(self: "ModelViewWidget", params: dict[str, Any]) -> float: + additional_model = dict(params.get("additional_model") or {}) + value = additional_model.get("clearance_height_mm", params.get("footprint_height_mm", 0.0)) + try: + return max(0.0, float(value)) + except Exception as exc: + log_exception(__name__, "_mezzanine_clearance_mm", exc) + return 0.0 + + def _ensure_model_footings(self: "ModelViewWidget", params: dict[str, Any]) -> None: + additional_model = params.setdefault("additional_model", {}) + model_path = additional_model.get("model_path") + if not model_path or additional_model.get("column_footings"): + return + base_mesh, _w, _d, _h = self._get_additional_model_base_mesh(Path(model_path)) + if base_mesh is None: + return + footings, clearance_h = self._detect_column_footings(base_mesh) + if footings: + additional_model["column_footings"] = footings + if clearance_h > 0 and not additional_model.get("clearance_height_mm"): + additional_model["clearance_height_mm"] = float(clearance_h) + + def _mezzanine_column_bboxes( + self: "ModelViewWidget", + cx: float, + cy: float, + params: dict[str, Any], + rotation: int, + ) -> list[tuple[float, float, float, float]]: + if not self._is_mezzanine_params(params): + return [] + self._ensure_model_footings(params) + additional_model = dict(params.get("additional_model") or {}) + footings = list(additional_model.get("column_footings") or []) + result: list[tuple[float, float, float, float]] = [] + for ft in footings: + fx_min = float(ft.get("x_min", 0.0)) + fy_min = float(ft.get("y_min", 0.0)) + fx_max = float(ft.get("x_max", 0.0)) + fy_max = float(ft.get("y_max", 0.0)) + width = max(0.0, fx_max - fx_min) + depth = max(0.0, fy_max - fy_min) + if width <= 0.0 or depth <= 0.0: + continue + local_cx = (fx_min + fx_max) / 2.0 + local_cy = (fy_min + fy_max) / 2.0 + world_dx, world_dy = rotate_xy(local_cx, local_cy, rotation) + if int(rotation) % 180 == 90: + width, depth = depth, width + result.append( + ( + cx + world_dx - width / 2.0, + cx + world_dx + width / 2.0, + cy + world_dy - depth / 2.0, + cy + world_dy + depth / 2.0, + ) + ) + return result + + def _can_share_xy_with_mezzanine( + self: "ModelViewWidget", + candidate_params: dict[str, Any], + existing_params: dict[str, Any], + ) -> bool: + cand_is_mezzanine = self._is_mezzanine_params(candidate_params) + existing_is_mezzanine = self._is_mezzanine_params(existing_params) + if cand_is_mezzanine and existing_is_mezzanine: + return False + if existing_is_mezzanine: + clearance = self._mezzanine_clearance_mm(existing_params) + return clearance > 0.0 and self._rack_height_mm(candidate_params) <= clearance + if cand_is_mezzanine: + clearance = self._mezzanine_clearance_mm(candidate_params) + return clearance > 0.0 and self._rack_height_mm(existing_params) <= clearance + return False + + +# --------------------------------------------------------------------------- +# Module workflow notes +# --------------------------------------------------------------------------- +# 1) Задача модуля: +# Правила размещения мезонинов и проверки совместимости по геометрии. +# +# 2) Последовательность действий и вызовов: +# A. Класс RackMezzanineMixin: точки входа +# Публичные методы отсутствуют; сценарий запускается через методы родительских модулей и внутренние обработчики. +# +# B. RackMezzanineMixin: основной сценарий: +# RackMezzanineMixin._is_mezzanine_params(...) +# Назначение: проверяет, что mezzanine params в рамках текущего сценария модуля. +# RackMezzanineMixin._can_share_xy_with_mezzanine(...) +# Назначение: проверяет возможность share xy with mezzanine в рамках текущего сценария модуля. +# Последовательность внутренних вызовов: +# -> RackMezzanineMixin._is_mezzanine_params(...) +# -> RackMezzanineMixin._mezzanine_clearance_mm(...) +# +# C. RackMezzanineMixin: вспомогательные расчёты: +# RackMezzanineMixin._mezzanine_clearance_mm(...) +# Назначение: выполняет шаг "mezzanine clearance mm" в рамках текущего сценария модуля. +# RackMezzanineMixin._ensure_model_footings(...) +# Назначение: выполняет шаг "ensure model footings" в рамках текущего сценария модуля. +# RackMezzanineMixin._mezzanine_column_bboxes(...) +# Назначение: выполняет шаг "mezzanine column bboxes" в рамках текущего сценария модуля. +# Последовательность внутренних вызовов: +# -> RackMezzanineMixin._ensure_model_footings(...) +# -> RackMezzanineMixin._is_mezzanine_params(...) +# +# 3) Важные ограничения и инварианты: +# - Модуль выполняется в составе ModelViewWidget и использует согласованные поля состояния self._... . +# - Межмодульная связность: только через фасад model_view; прямые обращения между zone, rack, shelf, cell запрещены. +# - Очистка состояния должна быть идемпотентной: повторный вызов не меняет корректное состояние в ошибочное. +# +# 4) Правило сопровождения: +# - Любое изменение сценария должно сопровождаться обновлением этого блока с сохранением фактического порядка вызовов. +# - При добавлении метода указывать его место в цепочке сценария (запуск, основной шаг, завершение, вспомогательная логика). diff --git a/Dispatch_V0.1.1/gui/components/model_view/_mv_racks_move.py b/Dispatch_V0.1.1/gui/components/model_view/_mv_racks_move.py new file mode 100644 index 0000000..95336bf --- /dev/null +++ b/Dispatch_V0.1.1/gui/components/model_view/_mv_racks_move.py @@ -0,0 +1,320 @@ +# -*- coding: utf-8 -*- +"""Миксин перемещения/переноса стоек для ModelViewWidget.""" + +from __future__ import annotations + +from typing import Any, TYPE_CHECKING + +from gui.components.model_view._mv_rack_geometry import rack_bbox +from error_logger import log_exception + +if TYPE_CHECKING: + from gui.components.model_view_widget import ModelViewWidget + + +class RackMoveMixin: + + def begin_move_selected_rack(self: "ModelViewWidget") -> bool: + entry = self.get_selected_rack_entry() + if not entry: + return False + self._clear_selected_rack_visual() + self._last_moved_rack_id = None + zone_id = str(entry.get("zone_id", "")) + params = dict(entry.get("params") or {}) + # Удалить старые акторы со сцены и переключиться в режим предпросмотра перемещения. + self._moving_rack_entry = dict(entry) + self._set_moving_rack_origin_ghost(self._moving_rack_entry, True) + self._rack_entries = [e for e in self._rack_entries if str(e.get("rack_id", "")) != str(entry.get("rack_id", ""))] + self.start_rack_placement(zone_id, params, self._rack_models_root, move_existing=True) + self._set_selected_rack(str(entry.get("rack_id", ""))) + return True + + def _set_moving_rack_origin_ghost(self: "ModelViewWidget", entry: dict[str, Any], enabled: bool) -> None: + rid = str(entry.get("rack_id") or "") + if not rid: + return + if enabled: + rack_snapshot: list[tuple[Any, int, float, bool, float, tuple[float, float, float]]] = [] + for actor in list(entry.get("actors", []) or []): + if actor is None: + continue + try: + prop = actor.GetProperty() + if prop is None: + continue + edge_color = tuple(prop.GetEdgeColor()) + rack_snapshot.append( + ( + actor, + int(actor.GetVisibility()), + float(prop.GetOpacity()), + bool(prop.GetEdgeVisibility()), + float(prop.GetLineWidth()), + (float(edge_color[0]), float(edge_color[1]), float(edge_color[2])), + ) + ) + actor.SetVisibility(0) + except Exception as _exc: + log_exception(__name__, "_set_moving_rack_origin_ghost", _exc) + entry["_origin_rack_visual_snapshot"] = rack_snapshot + + shelf_snapshot: list[tuple[Any, int, float]] = [] + for (entry_rid, _slot_id), actors in (self._rack_shelf_actors or {}).items(): + if str(entry_rid) != rid: + continue + for actor in list(actors or []): + if actor is None: + continue + try: + prop = actor.GetProperty() + if prop is None: + continue + shelf_snapshot.append((actor, int(actor.GetVisibility()), float(prop.GetOpacity()))) + actor.SetVisibility(0) + except Exception as _exc: + log_exception(__name__, "_set_moving_rack_origin_ghost", _exc) + entry["_origin_shelf_visual_snapshot"] = shelf_snapshot + return + + rack_snapshot = list(entry.get("_origin_rack_visual_snapshot") or []) + for actor, visibility, opacity, edge_vis, line_width, edge_color in rack_snapshot: + try: + prop = actor.GetProperty() + if prop is None: + continue + actor.SetVisibility(int(visibility)) + prop.SetOpacity(float(opacity)) + prop.SetEdgeVisibility(bool(edge_vis)) + prop.SetLineWidth(float(line_width)) + prop.SetEdgeColor(float(edge_color[0]), float(edge_color[1]), float(edge_color[2])) + except Exception as _exc: + log_exception(__name__, "_set_moving_rack_origin_ghost", _exc) + entry.pop("_origin_rack_visual_snapshot", None) + + shelf_snapshot = list(entry.get("_origin_shelf_visual_snapshot") or []) + for actor, visibility, opacity in shelf_snapshot: + try: + prop = actor.GetProperty() + if prop is None: + continue + actor.SetVisibility(int(visibility)) + prop.SetOpacity(float(opacity)) + except Exception as _exc: + log_exception(__name__, "_set_moving_rack_origin_ghost", _exc) + entry.pop("_origin_shelf_visual_snapshot", None) + + def _restore_moving_rack_entry(self: "ModelViewWidget") -> bool: + """Восстановить перемещённую стойку на исходную позицию при отмене.""" + entry = dict(self._moving_rack_entry or {}) + if not entry: + self._moving_rack_entry = None + return False + self._set_moving_rack_origin_ghost(entry, False) + if entry.get("actors"): + center = entry.get("center") or (0.0, 0.0) + params = dict(entry.get("params") or {}) + rotation = int(entry.get("rotation", 0)) % 360 + entry["bbox"] = rack_bbox(float(center[0]), float(center[1]), params, rotation) + self._apply_rack_helper_visibility_policy(entry, True) + self._rack_entries.append(entry) + self._moving_rack_entry = None + self._set_selected_rack(str(entry.get("rack_id", ""))) + if self._plotter: + self._plotter.update() + return True + zone_id = str(entry.get("zone_id", "")) + center = entry.get("center") or (0.0, 0.0) + params = dict(entry.get("params") or {}) + rotation = int(entry.get("rotation", 0)) % 360 + color, opacity = self._resolve_rack_style(params) + actors, slot_actors = self._spawn_rack_actors( + float(center[0]), float(center[1]), zone_id, params, rotation, color, opacity, + ) + if not actors: + self._moving_rack_entry = None + return False + hidden_slot_ids = set(str(v) for v in (entry.get("hidden_slot_ids") or set())) + for sid in hidden_slot_ids: + actor = slot_actors.get(sid) + if actor is not None: + try: + actor.SetVisibility(0) + except Exception as _exc: + log_exception(__name__, "_restore_moving_rack_entry", _exc) + entry["bbox"] = rack_bbox(float(center[0]), float(center[1]), params, rotation) + entry["actors"] = actors + entry["slot_actors"] = slot_actors + entry["hidden_slot_ids"] = hidden_slot_ids + self._apply_rack_helper_visibility_policy(entry, True) + self._rack_entries.append(entry) + self._moving_rack_entry = None + self._set_selected_rack(str(entry.get("rack_id", ""))) + return True + + def update_selected_rack_params( + self: "ModelViewWidget", + params: dict[str, Any], + anchor_mode: str | None = None, + ) -> bool: + if not self.can_update_selected_rack_params(params, anchor_mode=anchor_mode): + return False + entry = self.get_selected_rack_entry() + if not entry: + return False + zone_id = str(entry.get("zone_id", "")) + center = entry.get("center") or (0.0, 0.0) + rotation = int(entry.get("rotation", 0)) % 360 + rack_id = str(entry.get("rack_id", "")) + anchor_world = None + if str(anchor_mode or "").strip().lower() == "back_numbering_side": + anchor_world = self._rack_anchor_world_point( + float(center[0]), + float(center[1]), + dict(entry.get("params") or {}), + rotation, + ) + normalized_params = self._normalize_rack_params( + dict(params or {}), + fallback_code=str(entry.get("code") or "A"), + ) + new_center_x = float(center[0]) + new_center_y = float(center[1]) + if anchor_world is not None: + new_center_x, new_center_y = self._center_from_anchor_world_point( + float(anchor_world[0]), + float(anchor_world[1]), + normalized_params, + rotation, + ) + for actor in entry.get("actors", []): + try: + self._plotter.remove_actor(actor) + except Exception as _exc: + log_exception(__name__, "update_selected_rack_params", _exc) + actors, slot_actors = self._spawn_rack_actors( + new_center_x, new_center_y, zone_id, normalized_params, rotation, + *self._resolve_rack_style(normalized_params), + ) + if not actors: + return False + hidden_slot_ids = set(str(v) for v in (entry.get("hidden_slot_ids") or set())) + for sid in hidden_slot_ids: + actor = slot_actors.get(sid) + if actor is not None: + try: + actor.SetVisibility(0) + except Exception as _exc: + log_exception(__name__, "update_selected_rack_params", _exc) + entry["params"] = normalized_params + entry["code"] = str(normalized_params.get("code") or entry.get("code") or "A") + entry["numbering_direction"] = str( + normalized_params.get("numbering_direction") + or entry.get("numbering_direction") + or "left_to_right" + ) + entry["span_codes"] = [str(v) for v in (normalized_params.get("span_codes") or entry.get("span_codes") or [])] + entry["center"] = (new_center_x, new_center_y) + entry["bbox"] = rack_bbox(new_center_x, new_center_y, normalized_params, rotation) + entry["actors"] = actors + entry["slot_actors"] = slot_actors + entry["hidden_slot_ids"] = hidden_slot_ids + self._apply_rack_helper_visibility_policy(entry, True) + # Синхронизировать геометрию полок с обновлёнными секциями/глубиной/высотой стойки. + self._rebuild_shelves_for_updated_rack_params(rack_id) + if self._plotter: + self._plotter.update() + self.rack_layout_changed.emit(zone_id) + self._set_selected_rack(rack_id) + return True + + def can_update_selected_rack_params( + self: "ModelViewWidget", + params: dict[str, Any], + anchor_mode: str | None = None, + ) -> bool: + entry = self.get_selected_rack_entry() + if not entry: + return False + zone_id = str(entry.get("zone_id", "")) + center = entry.get("center") or (0.0, 0.0) + rotation = int(entry.get("rotation", 0)) % 360 + rack_id = str(entry.get("rack_id", "")) + anchor_world = None + if str(anchor_mode or "").strip().lower() == "back_numbering_side": + anchor_world = self._rack_anchor_world_point( + float(center[0]), + float(center[1]), + dict(entry.get("params") or {}), + rotation, + ) + normalized_params = self._normalize_rack_params( + dict(params or {}), + fallback_code=str(entry.get("code") or "A"), + ) + new_center_x = float(center[0]) + new_center_y = float(center[1]) + if anchor_world is not None: + new_center_x, new_center_y = self._center_from_anchor_world_point( + float(anchor_world[0]), + float(anchor_world[1]), + normalized_params, + rotation, + ) + if not self._validate_rack_position( + new_center_x, + new_center_y, + zone_id, + normalized_params, + rotation, + ignore_rack_id=rack_id, + ): + return False + return True + + +# --------------------------------------------------------------------------- +# Module workflow notes +# --------------------------------------------------------------------------- +# 1) Задача модуля: +# Перемещение выбранного стеллажа и пересборка его состояния. +# +# 2) Последовательность действий и вызовов: +# A. Класс RackMoveMixin: точки входа +# Публичные методы сценария: +# - RackMoveMixin.begin_move_selected_rack(...) +# - RackMoveMixin.update_selected_rack_params(...) +# - RackMoveMixin.can_update_selected_rack_params(...) +# +# B. RackMoveMixin: запуск и настройка: +# RackMoveMixin.begin_move_selected_rack(...) +# Назначение: начинает move selected rack в рамках текущего сценария модуля. +# Последовательность внутренних вызовов: +# -> RackMoveMixin._set_moving_rack_origin_ghost(...) +# RackMoveMixin._set_moving_rack_origin_ghost(...) +# Назначение: устанавливает moving rack origin ghost в рамках текущего сценария модуля. +# +# C. RackMoveMixin: основной сценарий: +# RackMoveMixin.update_selected_rack_params(...) +# Назначение: обновляет selected rack params в рамках текущего сценария модуля. +# Последовательность внутренних вызовов: +# -> RackMoveMixin.can_update_selected_rack_params(...) +# RackMoveMixin.can_update_selected_rack_params(...) +# Назначение: проверяет возможность update selected rack params в рамках текущего сценария модуля. +# +# D. RackMoveMixin: завершение и очистка: +# RackMoveMixin._restore_moving_rack_entry(...) +# Назначение: Восстановить перемещённую стойку на исходную позицию при отмене. +# Последовательность внутренних вызовов: +# -> RackMoveMixin._set_moving_rack_origin_ghost(...) +# +# 3) Важные ограничения и инварианты: +# - Модуль выполняется в составе ModelViewWidget и использует согласованные поля состояния self._... . +# - Все операции с актёрами и камерой выполняются только при валидном self._plotter с безопасной обработкой исключений. +# - Межмодульная связность: только через фасад model_view; прямые обращения между zone, rack, shelf, cell запрещены. +# - Очистка состояния должна быть идемпотентной: повторный вызов не меняет корректное состояние в ошибочное. +# +# 4) Правило сопровождения: +# - Любое изменение сценария должно сопровождаться обновлением этого блока с сохранением фактического порядка вызовов. +# - При добавлении метода указывать его место в цепочке сценария (запуск, основной шаг, завершение, вспомогательная логика). + diff --git a/Dispatch_V0.1.1/gui/components/model_view/_mv_racks_picking.py b/Dispatch_V0.1.1/gui/components/model_view/_mv_racks_picking.py new file mode 100644 index 0000000..98cfc9f --- /dev/null +++ b/Dispatch_V0.1.1/gui/components/model_view/_mv_racks_picking.py @@ -0,0 +1,505 @@ +# -*- coding: utf-8 -*- +"""Логика выбора стоек и ячеек полок (экран → мировое лучевое пересечение, +наведение, клик), извлечённая из монолитного миксина стоек.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any, TYPE_CHECKING + +from gui.components.model_view._mv_racks_lifecycle import _ray_aabb_intersect +from gui.components.model_view._mv_rack_geometry import rotate_xy +from error_logger import log_exception + +if TYPE_CHECKING: + from gui.components.model_view_widget import ModelViewWidget + + +class RackPickingMixin: + """Вспомогательные функции выбора – проверка попадания в стойку / ячейку полки по экранным координатам.""" + + def _on_select_rack_click(self: "ModelViewWidget", x: float, y: float, z: float) -> bool: + if not self._interaction_manager.is_active("rack_select"): + return False + zone_id = str(self._rack_preview_zone_id or "") + locked_rack_id = str(self._rack_isolation_rack_id or "") if bool(self._rack_isolation_active) else "" + # Предпочитать текущую стойку под курсором для детерминированности ЛКМ. + rack_id = str(self._hover_rack_id or "") + if not rack_id and self._plotter and self._plotter.interactor: + try: + sx, sy = self._plotter.interactor.GetEventPosition() + rack_id = self._find_rack_id_by_screen(float(sx), float(sy), zone_id, screen_is_vtk=True) + except Exception as exc: + log_exception(__name__, "_on_select_rack_click", exc) + rack_id = None + if (not rack_id) and x is not None and y is not None: + rack_id = self._find_rack_id_by_point(float(x), float(y), zone_id) + if locked_rack_id and rack_id and str(rack_id) != locked_rack_id: + return True + if not rack_id: + self._set_selected_rack(None) + self._clear_hover_rack_highlight() + return True + self._set_selected_rack(rack_id) + return True + + def _rack_anchor_world_point( + self: "ModelViewWidget", + center_x: float, + center_y: float, + params: dict[str, Any], + rotation: int, + ) -> tuple[float, float]: + width = float(params.get("footprint_width_mm", 1000)) + depth = float(params.get("footprint_depth_mm", 500)) + numbering_direction = str(params.get("numbering_direction") or "left_to_right") + start_from_right = numbering_direction == "right_to_left" + local_x = (width / 2.0) if start_from_right else (-width / 2.0) + local_y = -depth / 2.0 # задняя сторона (передняя — +Y в локальных координатах стойки) + dx, dy = rotate_xy(local_x, local_y, rotation) + return (float(center_x) + float(dx), float(center_y) + float(dy)) + + def _center_from_anchor_world_point( + self: "ModelViewWidget", + anchor_x: float, + anchor_y: float, + params: dict[str, Any], + rotation: int, + ) -> tuple[float, float]: + width = float(params.get("footprint_width_mm", 1000)) + depth = float(params.get("footprint_depth_mm", 500)) + numbering_direction = str(params.get("numbering_direction") or "left_to_right") + start_from_right = numbering_direction == "right_to_left" + local_x = (width / 2.0) if start_from_right else (-width / 2.0) + local_y = -depth / 2.0 + dx, dy = rotate_xy(local_x, local_y, rotation) + return (float(anchor_x) - float(dx), float(anchor_y) - float(dy)) + + def _on_select_rack_hover_screen(self: "ModelViewWidget", sx: float, sy: float) -> None: + if not self._interaction_manager.is_active("rack_select"): + self._clear_hover_rack_highlight() + return + zone_id = str(self._rack_preview_zone_id or "") + locked_rack_id = str(self._rack_isolation_rack_id or "") if bool(self._rack_isolation_active) else "" + rack_id = self._find_rack_id_by_screen(float(sx), float(sy), zone_id, screen_is_vtk=False) + if locked_rack_id and rack_id and str(rack_id) != locked_rack_id: + self._clear_hover_rack_highlight() + return + if not rack_id: + self._clear_hover_rack_highlight() + return + self._highlight_hover_rack(rack_id) + + def _find_rack_id_by_screen( + self: "ModelViewWidget", + sx: float, + sy: float, + zone_id: str, + screen_is_vtk: bool = False, + ) -> str | None: + if not self._plotter or not self._plotter.renderer: + return None + + # 1) Построить луч камеры (p0 → p1) из экранного пикселя. + ray = self._screen_to_camera_ray(sx, sy, screen_is_vtk=screen_is_vtk) + if ray is not None: + result = self._find_rack_id_by_ray(ray[0], ray[1], zone_id) + if result: + return result + + # 2) Запасной вариант — выбор актора (CellPicker с допуском по рёбрам). + try: + from vtkmodules.vtkRenderingCore import vtkCellPicker + pick_x = int(round(float(sx))) + pick_y = int(round(float(sy))) + if not screen_is_vtk: + dpr = self._plotter.devicePixelRatio() + pick_x = int(round(float(sx) * dpr)) + pick_y = int(round(float(sy) * dpr)) + rw = self._plotter.ren_win + if rw: + _, h = rw.GetSize() + pick_y = h - pick_y - 1 + cell_picker = vtkCellPicker() + cell_picker.SetTolerance(0.005) + cell_picker.Pick(pick_x, pick_y, 0, self._plotter.renderer) + picked_actor = cell_picker.GetViewProp() + if picked_actor is not None: + return self._find_rack_id_by_actor(picked_actor, zone_id) + except Exception as exc: + log_exception(__name__, "_find_rack_id_by_screen", exc) + return None + return None + + def _screen_to_camera_ray( + self: "ModelViewWidget", + sx: float, + sy: float, + screen_is_vtk: bool = False, + ) -> tuple[tuple[float, float, float], tuple[float, float, float]] | None: + """Вернуть мировые координаты (near_point, far_point) для экранного пикселя.""" + if not self._plotter or not self._plotter.renderer: + return None + px = float(sx) + py = float(sy) + if not screen_is_vtk and getattr(self._plotter, "ren_win", None): + try: + dpr = float(self._plotter.devicePixelRatio()) + if dpr <= 0.0: + dpr = 1.0 + px *= dpr + py *= dpr + _vw, vh = self._plotter.ren_win.GetSize() + py = float(vh) - py - 1.0 + except Exception as exc: + log_exception(__name__, "_screen_to_camera_ray.scale", exc) + px, py = float(sx), float(sy) + try: + renderer = self._plotter.renderer + renderer.SetDisplayPoint(px, py, 0) + renderer.DisplayToWorld() + wp0 = renderer.GetWorldPoint() + renderer.SetDisplayPoint(px, py, 1) + renderer.DisplayToWorld() + wp1 = renderer.GetWorldPoint() + if wp0[3] == 0 or wp1[3] == 0: + return None + p0 = (wp0[0] / wp0[3], wp0[1] / wp0[3], wp0[2] / wp0[3]) + p1 = (wp1[0] / wp1[3], wp1[1] / wp1[3], wp1[2] / wp1[3]) + return (p0, p1) + except Exception as exc: + log_exception(__name__, "_screen_to_camera_ray", exc) + return None + + def _find_rack_id_by_ray( + self: "ModelViewWidget", + p0: tuple[float, float, float], + p1: tuple[float, float, float], + zone_id: str, + ) -> str | None: + """Проверить луч камеры по 3D-ограничивающим параллелепипедам всех стоек в зоне.""" + zid = str(zone_id or "") + dx = p1[0] - p0[0] + dy = p1[1] - p0[1] + dz = p1[2] - p0[2] + best_t = float("inf") + best_id: str | None = None + for entry in reversed(self._rack_entries): + if zid and str(entry.get("zone_id", "")) != zid: + continue + if not self._imported_models_enabled and self._is_imported_model_entry(entry): + continue + min_x, max_x, min_y, max_y = self._rack_pick_bbox(entry) + params = dict(entry.get("params") or {}) + z_ref = float(self._zone_heights.get(str(entry.get("zone_id") or ""), (0.0, 0.0))[0]) + min_z = z_ref + max_z = z_ref + max(10.0, self._rack_height_mm(params)) + t = _ray_aabb_intersect(p0, dx, dy, dz, min_x, max_x, min_y, max_y, min_z, max_z) + if t is not None and t < best_t: + best_t = t + best_id = str(entry.get("rack_id", "")) + return best_id + + def _on_select_shelf_slot_hover_screen(self: "ModelViewWidget", sx: float, sy: float) -> None: + if not self._interaction_manager.is_active("shelf_placement"): + self._clear_shelf_slot_hover() + return + rack_id = str(self._shelf_target_rack_id or "") + zone_id = str(self._shelf_target_zone_id or "") + slot_id = self._find_slot_id_by_screen(float(sx), float(sy), rack_id, zone_id, screen_is_vtk=False) + if not slot_id: + # Сохранить текущий контур наведения, чтобы избежать визуального мерцания при промахах. + return + if slot_id == str(self._active_shelf_slot_id or ""): + return + if slot_id == str(self._hover_shelf_slot_id or ""): + return + self._highlight_shelf_slot_hover(slot_id) + + def _on_select_shelf_slot_click(self: "ModelViewWidget", x: float, y: float, z: float) -> bool: + if not self._interaction_manager.is_active("shelf_placement"): + return False + rack_id = str(self._shelf_target_rack_id or "") + zone_id = str(self._shelf_target_zone_id or "") + slot_id = str(self._hover_shelf_slot_id or "") + if (not slot_id) and self._plotter and self._plotter.interactor: + try: + sx, sy = self._plotter.interactor.GetEventPosition() + slot_id = str( + self._find_slot_id_by_screen(float(sx), float(sy), rack_id, zone_id, screen_is_vtk=True) or "" + ) + except Exception as exc: + log_exception(__name__, "_on_select_shelf_slot_click", exc) + slot_id = "" + if not slot_id: + return True + self._set_active_shelf_slot(slot_id) + self._selected_shelf_visual_rack_id = rack_id + self._selected_shelf_visual_slot_id = slot_id + self._selected_shelf_visual_index = 1 + try: + self.shelf_slot_selected.emit(rack_id, slot_id) # type: ignore[attr-defined] + except Exception as _exc: + log_exception(__name__, "_on_select_shelf_slot_click", _exc) + return True + + def _find_rendered_shelf_by_screen( + self: "ModelViewWidget", + sx: float, + sy: float, + rack_id: str, + *, + screen_is_vtk: bool = False, + ) -> tuple[str, int] | None: + rid = str(rack_id or "") + if not rid or not self._plotter or not self._plotter.renderer: + return None + + try: + from vtkmodules.vtkRenderingCore import vtkPropPicker + + pick_x = int(round(float(sx))) + pick_y = int(round(float(sy))) + if not screen_is_vtk: + dpr = self._plotter.devicePixelRatio() + pick_x = int(round(float(sx) * dpr)) + pick_y = int(round(float(sy) * dpr)) + rw = self._plotter.ren_win + if rw: + _, h = rw.GetSize() + pick_y = h - pick_y - 1 + picker = vtkPropPicker() + picker.Pick(pick_x, pick_y, 0, self._plotter.renderer) + picked_actor = picker.GetViewProp() + if picked_actor is not None: + for (entry_rid, slot_id), actors in (self._rack_shelf_actors or {}).items(): + if str(entry_rid) != rid: + continue + for actor_index, actor in enumerate(list(actors or []), start=1): + if actor is None: + continue + try: + if not bool(actor.GetVisibility()): + continue + except Exception as exc: + log_exception(__name__, "_find_rendered_shelf_by_screen.visibility", exc) + continue + if actor is picked_actor or actor == picked_actor: + return (str(slot_id), int(actor_index)) + try: + if ( + hasattr(actor, "GetAddressAsString") + and hasattr(picked_actor, "GetAddressAsString") + and actor.GetAddressAsString("") == picked_actor.GetAddressAsString("") + ): + return (str(slot_id), int(actor_index)) + except Exception as _exc: + log_exception(__name__, "_find_rendered_shelf_by_screen", _exc) + except Exception as _exc: + log_exception(__name__, "_find_rendered_shelf_by_screen", _exc) + ray = self._screen_to_camera_ray(sx, sy, screen_is_vtk=screen_is_vtk) + if ray is None: + return None + p0, p1 = ray + dx = float(p1[0] - p0[0]) + dy = float(p1[1] - p0[1]) + dz = float(p1[2] - p0[2]) + best_t = float("inf") + best_hit: tuple[str, int] | None = None + for (entry_rid, slot_id), actors in (self._rack_shelf_actors or {}).items(): + if str(entry_rid) != rid: + continue + for actor_index, actor in enumerate(list(actors or []), start=1): + if actor is None: + continue + try: + if not bool(actor.GetVisibility()): + continue + bounds = actor.GetBounds() + except Exception as exc: + log_exception(__name__, "_find_rendered_shelf_by_screen.bounds", exc) + continue + if not bounds or len(bounds) != 6: + continue + min_x, max_x, min_y, max_y, min_z, max_z = [float(v) for v in bounds] + t = _ray_aabb_intersect(p0, dx, dy, dz, min_x, max_x, min_y, max_y, min_z, max_z) + if t is not None and t < best_t: + best_t = float(t) + best_hit = (str(slot_id), int(actor_index)) + return best_hit + + def _find_slot_id_by_screen( + self: "ModelViewWidget", + sx: float, + sy: float, + rack_id: str, + zone_id: str, + screen_is_vtk: bool = False, + include_hidden: bool = False, + ) -> str | None: + entry = self._get_rack_entry(rack_id, zone_id) + if entry is None or not self._plotter or not self._plotter.renderer: + return None + try: + from vtkmodules.vtkRenderingCore import vtkPropPicker + + pick_x = int(round(float(sx))) + pick_y = int(round(float(sy))) + if not screen_is_vtk: + dpr = self._plotter.devicePixelRatio() + pick_x = int(round(float(sx) * dpr)) + pick_y = int(round(float(sy) * dpr)) + rw = self._plotter.ren_win + if rw: + _, h = rw.GetSize() + pick_y = h - pick_y - 1 + picker = vtkPropPicker() + picker.Pick(pick_x, pick_y, 0, self._plotter.renderer) + picked_actor = picker.GetViewProp() + if picked_actor is not None: + for slot_id, slot_actor in (entry.get("slot_actors") or {}).items(): + if slot_actor is None: + continue + try: + if (not include_hidden) and (not bool(slot_actor.GetVisibility())): + continue + except Exception as _exc: + log_exception(__name__, "_find_slot_id_by_screen", _exc) + if slot_actor is picked_actor or slot_actor == picked_actor: + return str(slot_id) + try: + if ( + hasattr(slot_actor, "GetAddressAsString") + and hasattr(picked_actor, "GetAddressAsString") + and slot_actor.GetAddressAsString("") == picked_actor.GetAddressAsString("") + ): + return str(slot_id) + except Exception as _exc: + log_exception(__name__, "_find_slot_id_by_screen", _exc) + except Exception as _exc: + log_exception(__name__, "_find_slot_id_by_screen", _exc) + # Запасной вариант для полностью прозрачных акторов ячеек: + # определить ближайшую ячейку пересечением луча с ограничивающим параллелепипедом. + ray = self._screen_to_camera_ray(sx, sy, screen_is_vtk=screen_is_vtk) + if ray is None: + return None + p0, p1 = ray + dx = float(p1[0] - p0[0]) + dy = float(p1[1] - p0[1]) + dz = float(p1[2] - p0[2]) + best_t = float("inf") + best_slot_id: str | None = None + for slot_id, slot_actor in (entry.get("slot_actors") or {}).items(): + if slot_actor is None: + continue + try: + if (not include_hidden) and (not bool(slot_actor.GetVisibility())): + continue + bounds = slot_actor.GetBounds() + except Exception as exc: + log_exception(__name__, "_find_slot_id_by_screen.bounds", exc) + continue + if not bounds or len(bounds) != 6: + continue + min_x, max_x, min_y, max_y, min_z, max_z = [float(v) for v in bounds] + t = _ray_aabb_intersect( + p0, + dx, + dy, + dz, + min_x, + max_x, + min_y, + max_y, + min_z, + max_z, + ) + if t is not None and t < best_t: + best_t = float(t) + best_slot_id = str(slot_id) + return best_slot_id + + def _get_rack_entry(self: "ModelViewWidget", rack_id: str, zone_id: str | None = None) -> dict[str, Any] | None: + rid = str(rack_id or "") + zid = str(zone_id or "") + if not rid: + return None + for entry in self._rack_entries: + if str(entry.get("rack_id", "")) != rid: + continue + if zid and str(entry.get("zone_id", "")) != zid: + continue + return entry + return None + + def _preload_shelf_model_for_entry(self: "ModelViewWidget", entry: dict[str, Any]) -> None: + params = dict(entry.get("params") or {}) + rack_type = str(params.get("rack_type") or "A") + depth = int(params.get("depth_mm") or params.get("footprint_depth_mm") or 500) + widths = [int(v) for v in (params.get("span_widths_mm") or []) if int(v) > 0] + if not widths: + widths = [int(params.get("footprint_width_mm") or 1000)] + self._get_shelf_base_mesh(rack_type, depth, widths[0]) + + +# --------------------------------------------------------------------------- +# Module workflow notes +# --------------------------------------------------------------------------- +# 1) Задача модуля: +# Выбор стеллажей и ячеек по экранным координатам и лучу камеры. +# +# 2) Последовательность действий и вызовов: +# A. Класс RackPickingMixin: точки входа +# Публичные методы отсутствуют; сценарий запускается через методы родительских модулей и внутренние обработчики. +# +# B. RackPickingMixin: основной сценарий: +# RackPickingMixin._on_select_rack_click(...) +# Назначение: выполняет шаг "on select rack click" в рамках текущего сценария модуля. +# Последовательность внутренних вызовов: +# -> RackPickingMixin._find_rack_id_by_screen(...) +# RackPickingMixin._on_select_rack_hover_screen(...) +# Назначение: выполняет шаг "on select rack hover screen" в рамках текущего сценария модуля. +# Последовательность внутренних вызовов: +# -> RackPickingMixin._find_rack_id_by_screen(...) +# RackPickingMixin._find_rack_id_by_screen(...) +# Назначение: находит rack id by screen в рамках текущего сценария модуля. +# Последовательность внутренних вызовов: +# -> RackPickingMixin._screen_to_camera_ray(...) +# -> RackPickingMixin._find_rack_id_by_ray(...) +# RackPickingMixin._find_rack_id_by_ray(...) +# Назначение: Проверить луч камеры по 3D-ограничивающим параллелепипедам всех стоек в зоне. +# RackPickingMixin._on_select_shelf_slot_hover_screen(...) +# Назначение: выполняет шаг "on select shelf slot hover screen" в рамках текущего сценария модуля. +# Последовательность внутренних вызовов: +# -> RackPickingMixin._find_slot_id_by_screen(...) +# RackPickingMixin._on_select_shelf_slot_click(...) +# Назначение: выполняет шаг "on select shelf slot click" в рамках текущего сценария модуля. +# Последовательность внутренних вызовов: +# -> RackPickingMixin._find_slot_id_by_screen(...) +# RackPickingMixin._find_slot_id_by_screen(...) +# Назначение: находит slot id by screen в рамках текущего сценария модуля. +# Последовательность внутренних вызовов: +# -> RackPickingMixin._get_rack_entry(...) +# -> RackPickingMixin._screen_to_camera_ray(...) +# +# C. RackPickingMixin: вспомогательные расчёты: +# RackPickingMixin._rack_anchor_world_point(...) +# Назначение: выполняет шаг "rack anchor world point" в рамках текущего сценария модуля. +# RackPickingMixin._center_from_anchor_world_point(...) +# Назначение: выполняет шаг "center from anchor world point" в рамках текущего сценария модуля. +# RackPickingMixin._screen_to_camera_ray(...) +# Назначение: Вернуть мировые координаты (near_point, far_point) для экранного пикселя. +# RackPickingMixin._get_rack_entry(...) +# Назначение: возвращает rack entry в рамках текущего сценария модуля. +# RackPickingMixin._preload_shelf_model_for_entry(...) +# Назначение: выполняет шаг "preload shelf model for entry" в рамках текущего сценария модуля. +# +# 3) Важные ограничения и инварианты: +# - Модуль выполняется в составе ModelViewWidget и использует согласованные поля состояния self._... . +# - Все операции с актёрами и камерой выполняются только при валидном self._plotter с безопасной обработкой исключений. +# - Межмодульная связность: только через фасад model_view; прямые обращения между zone, rack, shelf, cell запрещены. +# - Очистка состояния должна быть идемпотентной: повторный вызов не меняет корректное состояние в ошибочное. +# +# 4) Правило сопровождения: +# - Любое изменение сценария должно сопровождаться обновлением этого блока с сохранением фактического порядка вызовов. +# - При добавлении метода указывать его место в цепочке сценария (запуск, основной шаг, завершение, вспомогательная логика). diff --git a/Dispatch_V0.1.1/gui/components/model_view/_mv_racks_preview.py b/Dispatch_V0.1.1/gui/components/model_view/_mv_racks_preview.py new file mode 100644 index 0000000..69b64ca --- /dev/null +++ b/Dispatch_V0.1.1/gui/components/model_view/_mv_racks_preview.py @@ -0,0 +1,338 @@ +# -*- coding: utf-8 -*- +"""Вспомогательные функции предпросмотра / размещения стоек для ModelViewWidget.""" + +from __future__ import annotations + +import uuid +from typing import Any, TYPE_CHECKING + +from ._mv_rack_geometry import rack_bbox +from error_logger import log_exception + +if TYPE_CHECKING: + from ..model_view_widget import ModelViewWidget + + +class RackPreviewMixin: + """Построение, перемещение, подтверждение и очистка предпросмотра стоек.""" + + def _rebuild_rack_preview(self: "ModelViewWidget", cx: float, cy: float) -> None: + prev_center = self._rack_preview_center + zone_id = self._rack_preview_zone_id + if not zone_id: + self._clear_rack_preview() + self._rack_preview_center = (cx, cy) + return + params = self._rack_preview_params + valid = self._validate_rack_position(cx, cy, zone_id, params, self._rack_preview_rotation) + self._rack_preview_valid = valid + shape_key = self._preview_shape_key(params, self._rack_preview_rotation) + + if ( + self._rack_preview_actors + and prev_center is not None + and self._rack_preview_shape_key == shape_key + ): + dx = float(cx - prev_center[0]) + dy = float(cy - prev_center[1]) + if abs(dx) > 0.0 or abs(dy) > 0.0: + self._translate_preview_actors(dx, dy) + self._set_preview_color(valid) + self._rack_preview_center = (cx, cy) + if self._plotter: + if hasattr(self, "_safe_render"): + self._safe_render(min_interval_s=1.0 / 75.0) + else: + self._plotter.update() + return + + disable_render = getattr(self._plotter, "disable_render", None) if self._plotter else None + enable_render = getattr(self._plotter, "enable_render", None) if self._plotter else None + if callable(disable_render): + try: + disable_render() + except Exception as exc: + log_exception(__name__, "_rebuild_rack_preview", exc) + disable_render = None + try: + self._clear_rack_preview() + color = "#4CAF50" if valid else "#EF5350" + actors, _slot_actors = self._spawn_rack_actors( + cx, + cy, + zone_id, + params, + self._rack_preview_rotation, + color, + 0.35, + lightweight=False, + ) + self._rack_preview_actors = actors + self._rack_preview_shape_key = shape_key + self._rack_preview_center = (cx, cy) + finally: + if callable(enable_render): + try: + enable_render() + except Exception as _exc: + log_exception(__name__, "_rebuild_rack_preview", _exc) + if self._plotter: + if hasattr(self, "_safe_render"): + self._safe_render(min_interval_s=1.0 / 75.0) + else: + self._plotter.update() + + def _commit_rack_placement(self: "ModelViewWidget", cx: float, cy: float) -> None: + zone_id = self._rack_preview_zone_id + if not zone_id: + return + params = dict(self._rack_preview_params) + if self._moving_rack_entry: + params = self._normalize_rack_params( + params, + fallback_code=str(dict(self._moving_rack_entry).get("code") or "A"), + ) + else: + params = self._normalize_rack_params( + params, + fallback_code=self._next_zone_rack_code(zone_id, params), + ) + actors, slot_actors = self._spawn_rack_actors( + cx, + cy, + zone_id, + params, + self._rack_preview_rotation, + *self._resolve_rack_style(params), + ) + if not actors: + return + bbox = rack_bbox(cx, cy, params, self._rack_preview_rotation) + moved_committed = False + if self._moving_rack_entry: + moved = dict(self._moving_rack_entry) + rack_id = str(moved.get("rack_id") or "") + for actor in list(moved.get("actors") or []): + try: + self._plotter.remove_actor(actor) + except Exception as _exc: + log_exception(__name__, "_commit_rack_placement", _exc) + if rack_id: + self._clear_slot_shelf_actors(rack_id, clear_slot_id=None) + moved["zone_id"] = zone_id + moved["center"] = (cx, cy) + moved["rotation"] = self._rack_preview_rotation + moved["bbox"] = bbox + moved["params"] = params + moved["actors"] = actors + moved["slot_actors"] = slot_actors + moved["hidden_slot_ids"] = set(str(v) for v in (moved.get("hidden_slot_ids") or set())) + moved["code"] = str(params.get("code") or moved.get("code") or "A") + moved["name"] = str(params.get("name") or moved.get("name") or "Rack") + moved["numbering_direction"] = str( + params.get("numbering_direction") + or moved.get("numbering_direction") + or "left_to_right" + ) + moved["span_codes"] = [str(v) for v in (params.get("span_codes") or moved.get("span_codes") or [])] + moved.pop("_origin_rack_visual_snapshot", None) + moved.pop("_origin_shelf_visual_snapshot", None) + self._rack_entries.append(moved) + self._moving_rack_entry = None + if rack_id: + self._rebuild_shelves_for_moved_rack(rack_id) + self._last_moved_rack_id = rack_id + moved_committed = True + else: + # Защита: режим перемещения не должен создавать новые стойки. + if bool(getattr(self, "_rack_move_mode", False)): + self._clear_rack_preview() + return + self._rack_entries.append( + { + "rack_id": str(uuid.uuid4()), + "zone_id": zone_id, + "center": (cx, cy), + "rotation": self._rack_preview_rotation, + "bbox": bbox, + "params": params, + "actors": actors, + "slot_actors": slot_actors, + "hidden_slot_ids": set(), + "code": str(params.get("code") or "A"), + "name": str(params.get("name") or "Rack"), + "numbering_direction": str(params.get("numbering_direction") or "left_to_right"), + "span_codes": [str(v) for v in (params.get("span_codes") or [])], + } + ) + self._clear_rack_preview() + if self._selected_rack_id: + self._apply_selected_rack_visual(str(self._selected_rack_id)) + self.rack_selected_changed.emit(self._selected_rack_id) + self.rack_layout_changed.emit(str(zone_id)) + if moved_committed: + # Завершить перемещение после одного успешного размещения. + self.stop_rack_placement(clear_preview=True) + # Автоматически вернуться в режим выбора стоек для этой зоны. + self.start_select_rack_mode(str(zone_id)) + + def _rebuild_shelves_for_moved_rack(self: "ModelViewWidget", rack_id: str) -> None: + rid = str(rack_id or "") + if not rid: + return + slot_keys = [ + (entry_rid, slot_id) + for (entry_rid, slot_id) in list((self._rack_shelf_params or {}).keys()) + if str(entry_rid) == rid + ] + slot_keys.sort(key=lambda key: self._slot_index_from_id(str(key[1]))) + for key in slot_keys: + slot_id = str(key[1]) + payload = dict(self._rack_shelf_params.get(key) or {}) + if not payload: + continue + normalized = self._render_shelves_for_slot(rid, slot_id, payload) + if not normalized: + continue + self._rack_shelf_params[(rid, slot_id)] = dict(normalized) + self.set_rack_slot_occupied(rid, slot_id, True) + self._set_rack_shelf_actors_visibility(rid, True) + self._apply_rack_shelf_opacity(rid) + if self._plotter: + self._plotter.update() + + def _rebuild_shelves_for_updated_rack_params(self: "ModelViewWidget", rack_id: str) -> None: + """Перерисовать существующие ряды полок после обновления геометрии/параметров стойки.""" + rid = str(rack_id or "") + if not rid: + return + entry = self._get_rack_entry(rid) + if entry is None: + return + + available_slot_ids = set(str(v) for v in (entry.get("slot_actors") or {}).keys()) + slot_keys = [ + (entry_rid, slot_id) + for (entry_rid, slot_id) in list((self._rack_shelf_params or {}).keys()) + if str(entry_rid) == rid + ] + slot_keys.sort(key=lambda key: self._slot_index_from_id(str(key[1]))) + + for key in slot_keys: + slot_id = str(key[1]) + payload = dict(self._rack_shelf_params.get(key) or {}) + if not payload: + continue + if slot_id not in available_slot_ids: + # Ячейка удалена (например, уменьшилось spans_count) — удалить устаревшие данные/акторы полок. + self._clear_slot_shelf_actors(rid, slot_id) + self._rack_shelf_params.pop((rid, slot_id), None) + continue + normalized = self._render_shelves_for_slot(rid, slot_id, payload) + if not normalized: + continue + self._rack_shelf_params[(rid, slot_id)] = dict(normalized) + self.set_rack_slot_occupied(rid, slot_id, True) + + self._set_rack_shelf_actors_visibility(rid, True) + self._apply_rack_shelf_opacity(rid) + if self._plotter: + self._plotter.update() + + def consume_last_moved_rack_id(self: "ModelViewWidget") -> str: + rack_id = str(self._last_moved_rack_id or "") + self._last_moved_rack_id = None + return rack_id + + def _clear_rack_preview(self: "ModelViewWidget") -> None: + for actor in self._rack_preview_actors: + try: + self._plotter.remove_actor(actor) + except Exception as _exc: + log_exception(__name__, "_clear_rack_preview", _exc) + self._rack_preview_actors = [] + self._rack_preview_shape_key = None + + def _preview_shape_key(self: "ModelViewWidget", params: dict[str, Any], rotation: int) -> tuple[Any, ...]: + return ( + int(params.get("footprint_width_mm", 1000)), + int(params.get("footprint_depth_mm", 500)), + int(params.get("footprint_height_mm", 1800)), + int(rotation) % 360, + ) + + def _translate_preview_actors(self: "ModelViewWidget", dx: float, dy: float) -> None: + for actor in self._rack_preview_actors: + try: + actor.AddPosition(float(dx), float(dy), 0.0) + except Exception as exc: + log_exception(__name__, "_translate_preview_actors", exc) + try: + px, py, pz = actor.GetPosition() + actor.SetPosition(float(px + dx), float(py + dy), float(pz)) + except Exception as _exc: + log_exception(__name__, "_translate_preview_actors", _exc) + def _set_preview_color(self: "ModelViewWidget", valid: bool) -> None: + if not self._rack_preview_actors: + return + rgb = (0.298, 0.686, 0.314) if valid else (0.937, 0.325, 0.314) + try: + prop = self._rack_preview_actors[0].GetProperty() + if prop is not None: + prop.SetColor(*rgb) + except Exception as _exc: + log_exception(__name__, "_set_preview_color", _exc) +# --------------------------------------------------------------------------- +# Module workflow notes +# --------------------------------------------------------------------------- +# 1) Задача модуля: +# Предпросмотр стеллажа и фиксация размещения. +# +# 2) Последовательность действий и вызовов: +# A. Класс RackPreviewMixin: точки входа +# Публичные методы сценария: +# - RackPreviewMixin.consume_last_moved_rack_id(...) +# +# B. RackPreviewMixin: запуск и настройка: +# RackPreviewMixin._set_preview_color(...) +# Назначение: устанавливает preview color в рамках текущего сценария модуля. +# +# C. RackPreviewMixin: основной сценарий: +# RackPreviewMixin._rebuild_rack_preview(...) +# Назначение: перестраивает rack preview в рамках текущего сценария модуля. +# Последовательность внутренних вызовов: +# -> RackPreviewMixin._preview_shape_key(...) +# -> RackPreviewMixin._clear_rack_preview(...) +# -> RackPreviewMixin._set_preview_color(...) +# -> RackPreviewMixin._translate_preview_actors(...) +# RackPreviewMixin._rebuild_shelves_for_moved_rack(...) +# Назначение: перестраивает shelves for moved rack в рамках текущего сценария модуля. +# RackPreviewMixin._rebuild_shelves_for_updated_rack_params(...) +# Назначение: Перерисовать существующие ряды полок после обновления геометрии/параметров стойки. +# +# D. RackPreviewMixin: завершение и очистка: +# RackPreviewMixin._clear_rack_preview(...) +# Назначение: очищает rack preview в рамках текущего сценария модуля. +# +# E. RackPreviewMixin: вспомогательные расчёты: +# RackPreviewMixin._commit_rack_placement(...) +# Назначение: выполняет шаг "commit rack placement" в рамках текущего сценария модуля. +# Последовательность внутренних вызовов: +# -> RackPreviewMixin._clear_rack_preview(...) +# -> RackPreviewMixin._rebuild_shelves_for_moved_rack(...) +# RackPreviewMixin.consume_last_moved_rack_id(...) +# Назначение: выполняет шаг "consume last moved rack id" в рамках текущего сценария модуля. +# RackPreviewMixin._preview_shape_key(...) +# Назначение: выполняет шаг "preview shape key" в рамках текущего сценария модуля. +# RackPreviewMixin._translate_preview_actors(...) +# Назначение: выполняет шаг "translate preview actors" в рамках текущего сценария модуля. +# +# 3) Важные ограничения и инварианты: +# - Модуль выполняется в составе ModelViewWidget и использует согласованные поля состояния self._... . +# - Все операции с актёрами и камерой выполняются только при валидном self._plotter с безопасной обработкой исключений. +# - Межмодульная связность: только через фасад model_view; прямые обращения между zone, rack, shelf, cell запрещены. +# - Очистка состояния должна быть идемпотентной: повторный вызов не меняет корректное состояние в ошибочное. +# +# 4) Правило сопровождения: +# - Любое изменение сценария должно сопровождаться обновлением этого блока с сохранением фактического порядка вызовов. +# - При добавлении метода указывать его место в цепочке сценария (запуск, основной шаг, завершение, вспомогательная логика). diff --git a/Dispatch_V0.1.1/gui/components/model_view/_mv_racks_projection.py b/Dispatch_V0.1.1/gui/components/model_view/_mv_racks_projection.py new file mode 100644 index 0000000..73b8b6c --- /dev/null +++ b/Dispatch_V0.1.1/gui/components/model_view/_mv_racks_projection.py @@ -0,0 +1,119 @@ +# -*- coding: utf-8 -*- +# gui/components/model_view/_mv_racks_projection.py +"""Проекционная привязка курсора к плоскости пола зоны для rack-placement.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from gui.components.model_view._mv_rack_geometry import PLACEMENT_STEP_MM +from error_logger import log_exception + +if TYPE_CHECKING: # pragma: no cover + from gui.components.model_view_widget import ModelViewWidget + + +class RackPlacementProjectionMixin: + """Проекция курсора на плоскость зоны.""" + + def _on_rack_hover_screen(self: "ModelViewWidget", sx: float, sy: float) -> None: + if not self._interaction_manager.is_active("rack_placement"): + return + world = self._project_screen_to_zone_plane(sx, sy) + if world is None: + return + x, y, _ = world + gx = round(float(x) / PLACEMENT_STEP_MM) * PLACEMENT_STEP_MM + gy = round(float(y) / PLACEMENT_STEP_MM) * PLACEMENT_STEP_MM + if self._rack_preview_center == (gx, gy): + return + self._rebuild_rack_preview(gx, gy) + + def _on_rack_click(self: "ModelViewWidget", x: float, y: float, z: float) -> bool: + if not self._interaction_manager.is_active("rack_placement"): + return False + world = self._project_cursor_to_zone_plane() + if world is None: + world = (x, y, z if z is not None else 0.0) + wx, wy, _wz = world + gx = round(float(wx) / PLACEMENT_STEP_MM) * PLACEMENT_STEP_MM + gy = round(float(wy) / PLACEMENT_STEP_MM) * PLACEMENT_STEP_MM + self._rebuild_rack_preview(gx, gy) + if not self._rack_preview_valid: + return True + self._commit_rack_placement(gx, gy) + return True + + def _project_cursor_to_zone_plane(self: "ModelViewWidget") -> tuple[float, float, float] | None: + if not self._plotter or not self._plotter.interactor: + return None + try: + sx, sy = self._plotter.interactor.GetEventPosition() + except Exception as exc: + log_exception(__name__, "_project_cursor_to_zone_plane", exc) + return None + return self._project_screen_to_zone_plane(float(sx), float(sy), screen_is_vtk=True) + + def _project_screen_to_zone_plane( + self: "ModelViewWidget", + sx: float, + sy: float, + screen_is_vtk: bool = False, + ) -> tuple[float, float, float] | None: + zone_id = self._rack_preview_zone_id + if not zone_id: + return None + z_ref = float(self._zone_heights.get(zone_id, (0.0, 0.0))[0]) + px = float(sx) + py = float(sy) + if not screen_is_vtk and self._plotter and getattr(self._plotter, "ren_win", None): + try: + dpr = float(self._plotter.devicePixelRatio()) + if dpr <= 0.0: + dpr = 1.0 + px *= dpr + py *= dpr + _vw, vh = self._plotter.ren_win.GetSize() + py = float(vh) - py - 1.0 + except Exception as exc: + log_exception(__name__, "_project_screen_to_zone_plane", exc) + px = float(sx) + py = float(sy) + return self.screen_to_world_on_plane(px, py, z_ref) + + +# --------------------------------------------------------------------------- +# Module workflow notes +# --------------------------------------------------------------------------- +# 1) Задача модуля: +# Проекция курсора на плоскость зоны для сценария размещения. +# +# 2) Последовательность действий и вызовов: +# A. Класс RackPlacementProjectionMixin: точки входа +# Публичные методы отсутствуют; сценарий запускается через методы родительских модулей и внутренние обработчики. +# +# B. RackPlacementProjectionMixin: основной сценарий: +# RackPlacementProjectionMixin._on_rack_hover_screen(...) +# Назначение: выполняет шаг "on rack hover screen" в рамках текущего сценария модуля. +# Последовательность внутренних вызовов: +# -> RackPlacementProjectionMixin._project_screen_to_zone_plane(...) +# RackPlacementProjectionMixin._on_rack_click(...) +# Назначение: выполняет шаг "on rack click" в рамках текущего сценария модуля. +# Последовательность внутренних вызовов: +# -> RackPlacementProjectionMixin._project_cursor_to_zone_plane(...) +# RackPlacementProjectionMixin._project_cursor_to_zone_plane(...) +# Назначение: проецирует cursor to zone plane в рамках текущего сценария модуля. +# Последовательность внутренних вызовов: +# -> RackPlacementProjectionMixin._project_screen_to_zone_plane(...) +# RackPlacementProjectionMixin._project_screen_to_zone_plane(...) +# Назначение: проецирует screen to zone plane в рамках текущего сценария модуля. +# +# 3) Важные ограничения и инварианты: +# - Модуль выполняется в составе ModelViewWidget и использует согласованные поля состояния self._... . +# - Все операции с актёрами и камерой выполняются только при валидном self._plotter с безопасной обработкой исключений. +# - Межмодульная связность: только через фасад model_view; прямые обращения между zone, rack, shelf, cell запрещены. +# - Очистка состояния должна быть идемпотентной: повторный вызов не меняет корректное состояние в ошибочное. +# +# 4) Правило сопровождения: +# - Любое изменение сценария должно сопровождаться обновлением этого блока с сохранением фактического порядка вызовов. +# - При добавлении метода указывать его место в цепочке сценария (запуск, основной шаг, завершение, вспомогательная логика). diff --git a/Dispatch_V0.1.1/gui/components/model_view/_mv_racks_selection.py b/Dispatch_V0.1.1/gui/components/model_view/_mv_racks_selection.py new file mode 100644 index 0000000..fc79a32 --- /dev/null +++ b/Dispatch_V0.1.1/gui/components/model_view/_mv_racks_selection.py @@ -0,0 +1,412 @@ +# -*- coding: utf-8 -*- +"""Вспомогательные функции выделения / подсветки стоек для 3D-виджета просмотра моделей.""" + +from __future__ import annotations + +from typing import Any, TYPE_CHECKING + +from gui.components.model_view._mv_rack_geometry import rack_bbox +from error_logger import log_exception + +try: + import pyvista as pv # noqa: F401 +except ImportError: + pv = None # type: ignore[assignment] + +if TYPE_CHECKING: + from gui.components.model_view_widget import ModelViewWidget + + +class RackSelectionMixin: + """Вспомогательные функции визуализации выделения, контура и изоляции стоек.""" + + def get_selected_rack_entry(self: "ModelViewWidget") -> dict[str, Any] | None: + rid = str(self._selected_rack_id or "") + if not rid: + return None + for entry in self._rack_entries: + if str(entry.get("rack_id", "")) == rid: + return entry + return None + + def get_selected_rack_display_color(self: "ModelViewWidget") -> str: + entry = self.get_selected_rack_entry() + if entry is None: + return "#59B6E6" + params = dict(entry.get("params") or {}) + color, _opacity = self._resolve_rack_style(params) + return str(color or "#59B6E6") + + def rebuild_zone_racks_for_updated_zone(self: "ModelViewWidget", zone_id: str) -> list[str]: + """Пересоздать акторы стоек/полок для зоны после обновления высоты/объёма зоны.""" + zid = str(zone_id or "") + if not zid or not self._plotter: + return [] + updated_rack_ids: list[str] = [] + for idx, entry in enumerate(list(self._rack_entries)): + if str(entry.get("zone_id") or "") != zid: + continue + rack_id = str(entry.get("rack_id") or "") + if not rack_id: + continue + center = entry.get("center") or (0.0, 0.0) + params = dict(entry.get("params") or {}) + rotation = int(entry.get("rotation", 0)) % 360 + color, opacity = self._resolve_rack_style(params) + actors, slot_actors = self._spawn_rack_actors( + float(center[0]), float(center[1]), zid, params, rotation, color, opacity, + ) + if not actors: + continue + for actor in list(entry.get("actors") or []): + try: + self._plotter.remove_actor(actor) + except Exception as _exc: + log_exception(__name__, "rebuild_zone_racks_for_updated_zone", _exc) + self._clear_slot_shelf_actors(rack_id, clear_slot_id=None) + hidden_slot_ids = set(str(v) for v in (entry.get("hidden_slot_ids") or set())) + for sid in hidden_slot_ids: + slot_actor = slot_actors.get(sid) + if slot_actor is not None: + try: + slot_actor.SetVisibility(0) + except Exception as _exc: + log_exception(__name__, "rebuild_zone_racks_for_updated_zone", _exc) + entry["bbox"] = rack_bbox(float(center[0]), float(center[1]), params, rotation) + entry["actors"] = actors + entry["slot_actors"] = slot_actors + entry["hidden_slot_ids"] = hidden_slot_ids + self._apply_rack_helper_visibility_policy(entry, True) + self._rack_entries[idx] = entry + + previous_target_zone = self._shelf_target_zone_id + try: + self._shelf_target_zone_id = zid + slot_keys = [ + (entry_rid, slot_id) + for (entry_rid, slot_id) in list((self._rack_shelf_params or {}).keys()) + if str(entry_rid) == rack_id + ] + slot_keys.sort(key=lambda key: self._slot_index_from_id(str(key[1]))) + for key in slot_keys: + slot_id = str(key[1]) + payload = dict(self._rack_shelf_params.get(key) or {}) + if not payload: + continue + normalized = self._render_shelves_for_slot(rack_id, slot_id, payload) + if not normalized: + continue + self._rack_shelf_params[(rack_id, slot_id)] = dict(normalized) + self.set_rack_slot_occupied(rack_id, slot_id, True) + finally: + self._shelf_target_zone_id = previous_target_zone + + self._set_rack_shelf_actors_visibility(rack_id, True) + self._apply_rack_shelf_opacity(rack_id) + updated_rack_ids.append(rack_id) + + if self._selected_rack_id: + self._apply_selected_rack_visual(str(self._selected_rack_id)) + self._plotter.update() + return updated_rack_ids + + def select_rack_by_id(self: "ModelViewWidget", rack_id: str | None, zone_id: str | None = None) -> bool: + rid = str(rack_id or "") + zid = str(zone_id or "") + if not rid: + self._set_selected_rack(None) + return False + for entry in self._rack_entries: + if str(entry.get("rack_id", "")) != rid: + continue + if zid and str(entry.get("zone_id", "")) != zid: + continue + self._set_selected_rack(rid) + return True + return False + + def clear_selected_rack(self: "ModelViewWidget") -> None: + """Сбросить визуальное состояние выделенной стойки и контур наведения.""" + self._set_selected_rack(None) + self._clear_hover_rack_highlight() + + def _set_selected_rack(self: "ModelViewWidget", rack_id: str | None) -> None: + rid = str(rack_id or "") + current = str(self._selected_rack_id or "") + if not rid: + self._selected_rack_id = None + self._clear_selected_rack_visual() + if current: + self.rack_selected_changed.emit("") + return + if current != rid: + self._selected_rack_id = rid + self._apply_selected_rack_visual(rid) + self.rack_selected_changed.emit(rid) + return + self._apply_selected_rack_visual(rid) + + def _clear_selected_rack_visual(self: "ModelViewWidget") -> None: + snapshot = list(getattr(self, "_selected_rack_visual_props", []) or []) + for actor, opacity, edge_vis, line_width, edge_color in snapshot: + if actor is None: + continue + try: + prop = actor.GetProperty() + prop.SetOpacity(float(opacity)) + prop.SetEdgeVisibility(bool(edge_vis)) + prop.SetLineWidth(float(line_width)) + prop.SetEdgeColor(*edge_color) + except Exception as _exc: + log_exception(__name__, "_clear_selected_rack_visual", _exc) + self._selected_rack_visual_props = [] + self._selected_rack_visual_id = None + self._clear_selected_rack_contour(render=False) + if self._rack_isolation_active and self._rack_isolation_rack_id: + self._apply_rack_isolation_visual(str(self._rack_isolation_rack_id)) + + def _apply_selected_rack_visual(self: "ModelViewWidget", rack_id: str) -> None: + rid = str(rack_id or "") + if not rid: + self._clear_selected_rack_visual() + return + entry = None + for candidate in self._rack_entries: + if str(candidate.get("rack_id", "")) == rid: + entry = candidate + break + if entry is None: + self._clear_selected_rack_visual() + return + self._clear_selected_rack_visual() + slot_actor_ids = { + id(actor) + for actor in (entry.get("slot_actors") or {}).values() + if actor is not None + } + snapshot: list[tuple[object, float, bool, float, tuple[float, float, float]]] = [] + isolation_active = bool( + self._rack_isolation_active + and str(self._rack_isolation_rack_id or "") == rid + ) + for idx, actor in enumerate(entry.get("actors", [])): + if actor is None: + continue + try: + prop = actor.GetProperty() + if prop is None: + continue + edge_color = tuple(prop.GetEdgeColor()) + snapshot.append( + ( + actor, + float(prop.GetOpacity()), + bool(prop.GetEdgeVisibility()), + float(prop.GetLineWidth()), + (float(edge_color[0]), float(edge_color[1]), float(edge_color[2])), + ) + ) + if isolation_active: + prop.SetEdgeVisibility(False) + prop.SetLineWidth(1.0) + else: + prop.SetEdgeVisibility(False) + prop.SetLineWidth(1.0) + if isolation_active and idx > 0: + continue + if id(actor) in slot_actor_ids: + if ( + self._interaction_manager.is_active("shelf_placement") + and str(getattr(self, "_shelf_target_rack_id", "") or "") == rid + ): + # В режиме редактирования полок прокси секций должны оставаться невидимыми. + prop.SetOpacity(0.0) + else: + # Объём между стойками сохраняет свою текущую прозрачность. + pass + elif idx == 0: + # Основное тело стойки: полная заливка при выделении. + prop.SetOpacity(1.0) + else: + prop.SetOpacity(min(1.0, float(prop.GetOpacity()) + 0.08)) + except Exception as _exc: + log_exception(__name__, "_apply_selected_rack_visual", _exc) + self._selected_rack_visual_props = snapshot + self._selected_rack_visual_id = rid + self._set_selected_rack_contour(entry) + if self._plotter: + self._plotter.update() + + def _set_selected_rack_contour(self: "ModelViewWidget", entry: dict[str, Any]) -> None: + self._clear_selected_rack_contour(render=False) + if not self._plotter: + return + contour = self._build_rack_hover_contour_mesh(entry) + if contour is None: + return + try: + self._selected_rack_contour_actor = self._plotter.add_mesh( + contour, + color=(0.25, 0.55, 1.0), + line_width=3.0, + name="_selected_rack_contour", + pickable=False, + ) + except Exception as exc: + log_exception(__name__, "_set_selected_rack_contour", exc) + self._selected_rack_contour_actor = None + + def _clear_selected_rack_contour(self: "ModelViewWidget", render: bool = True) -> None: + actor = getattr(self, "_selected_rack_contour_actor", None) + if actor is not None and self._plotter is not None: + try: + self._plotter.remove_actor(actor) + except Exception as _exc: + log_exception(__name__, "_clear_selected_rack_contour", _exc) + self._selected_rack_contour_actor = None + if render: + if hasattr(self, "_safe_render"): + self._safe_render(min_interval_s=1.0 / 60.0) + elif self._plotter: + self._plotter.update() + + def _clear_rack_isolation_visual(self: "ModelViewWidget") -> None: + snapshot = list(getattr(self, "_rack_isolation_visual_props", []) or []) + for actor, visibility, opacity, edge_vis, line_width, edge_color in snapshot: + if actor is None: + continue + try: + actor.SetVisibility(int(visibility)) + prop = actor.GetProperty() + if prop is None: + continue + prop.SetOpacity(float(opacity)) + prop.SetEdgeVisibility(bool(edge_vis)) + prop.SetLineWidth(float(line_width)) + prop.SetEdgeColor(*edge_color) + except Exception as _exc: + log_exception(__name__, "_clear_rack_isolation_visual", _exc) + self._rack_isolation_visual_props = [] + + def _apply_rack_isolation_visual(self: "ModelViewWidget", rack_id: str) -> None: + rid = str(rack_id or "") + if not rid: + return + entry = None + for candidate in self._rack_entries: + if str(candidate.get("rack_id", "")) == rid: + entry = candidate + break + if entry is None: + return + self._clear_rack_isolation_visual() + snapshot: list[tuple[object, int, float, bool, float, tuple[float, float, float]]] = [] + for idx, actor in enumerate(entry.get("actors", [])): + if actor is None: + continue + try: + visibility = int(actor.GetVisibility()) + prop = actor.GetProperty() + if prop is None: + continue + edge_color = tuple(prop.GetEdgeColor()) + snapshot.append( + ( + actor, + visibility, + float(prop.GetOpacity()), + bool(prop.GetEdgeVisibility()), + float(prop.GetLineWidth()), + (float(edge_color[0]), float(edge_color[1]), float(edge_color[2])), + ) + ) + is_pillar_actor = idx == 0 + actor.SetVisibility(1 if is_pillar_actor else 0) + if is_pillar_actor: + prop.SetOpacity(1.0) + # Изоляция стойки на уровне дерева не должна отрисовывать контур выделения. + prop.SetEdgeVisibility(False) + prop.SetLineWidth(1.0) + except Exception as _exc: + log_exception(__name__, "_apply_rack_isolation_visual", _exc) + self._rack_isolation_visual_props = snapshot + + +# --------------------------------------------------------------------------- +# Module workflow notes +# --------------------------------------------------------------------------- +# 1) Задача модуля: +# Выделение стеллажа и визуальная изоляция контекста. +# +# 2) Последовательность действий и вызовов: +# A. Класс RackSelectionMixin: точки входа +# Публичные методы сценария: +# - RackSelectionMixin.get_selected_rack_entry(...) +# - RackSelectionMixin.get_selected_rack_display_color(...) +# - RackSelectionMixin.rebuild_zone_racks_for_updated_zone(...) +# - RackSelectionMixin.select_rack_by_id(...) +# - RackSelectionMixin.clear_selected_rack(...) +# +# B. RackSelectionMixin: запуск и настройка: +# RackSelectionMixin._set_selected_rack(...) +# Назначение: устанавливает selected rack в рамках текущего сценария модуля. +# Последовательность внутренних вызовов: +# -> RackSelectionMixin._apply_selected_rack_visual(...) +# -> RackSelectionMixin._clear_selected_rack_visual(...) +# RackSelectionMixin._set_selected_rack_contour(...) +# Назначение: устанавливает selected rack contour в рамках текущего сценария модуля. +# Последовательность внутренних вызовов: +# -> RackSelectionMixin._clear_selected_rack_contour(...) +# +# C. RackSelectionMixin: основной сценарий: +# RackSelectionMixin.rebuild_zone_racks_for_updated_zone(...) +# Назначение: Пересоздать акторы стоек/полок для зоны после обновления высоты/объёма зоны. +# Последовательность внутренних вызовов: +# -> RackSelectionMixin._apply_selected_rack_visual(...) +# RackSelectionMixin.select_rack_by_id(...) +# Назначение: выполняет шаг "select rack by id" в рамках текущего сценария модуля. +# Последовательность внутренних вызовов: +# -> RackSelectionMixin._set_selected_rack(...) +# RackSelectionMixin._apply_selected_rack_visual(...) +# Назначение: применяет selected rack visual в рамках текущего сценария модуля. +# Последовательность внутренних вызовов: +# -> RackSelectionMixin._clear_selected_rack_visual(...) +# -> RackSelectionMixin._set_selected_rack_contour(...) +# RackSelectionMixin._apply_rack_isolation_visual(...) +# Назначение: применяет rack isolation visual в рамках текущего сценария модуля. +# Последовательность внутренних вызовов: +# -> RackSelectionMixin._clear_rack_isolation_visual(...) +# +# D. RackSelectionMixin: завершение и очистка: +# RackSelectionMixin.clear_selected_rack(...) +# Назначение: Сбросить визуальное состояние выделенной стойки и контур наведения. +# Последовательность внутренних вызовов: +# -> RackSelectionMixin._set_selected_rack(...) +# RackSelectionMixin._clear_selected_rack_visual(...) +# Назначение: очищает selected rack visual в рамках текущего сценария модуля. +# Последовательность внутренних вызовов: +# -> RackSelectionMixin._clear_selected_rack_contour(...) +# -> RackSelectionMixin._apply_rack_isolation_visual(...) +# RackSelectionMixin._clear_selected_rack_contour(...) +# Назначение: очищает selected rack contour в рамках текущего сценария модуля. +# RackSelectionMixin._clear_rack_isolation_visual(...) +# Назначение: очищает rack isolation visual в рамках текущего сценария модуля. +# +# E. RackSelectionMixin: вспомогательные расчёты: +# RackSelectionMixin.get_selected_rack_entry(...) +# Назначение: возвращает selected rack entry в рамках текущего сценария модуля. +# RackSelectionMixin.get_selected_rack_display_color(...) +# Назначение: возвращает selected rack display color в рамках текущего сценария модуля. +# Последовательность внутренних вызовов: +# -> RackSelectionMixin.get_selected_rack_entry(...) +# +# 3) Важные ограничения и инварианты: +# - Модуль выполняется в составе ModelViewWidget и использует согласованные поля состояния self._... . +# - Все операции с актёрами и камерой выполняются только при валидном self._plotter с безопасной обработкой исключений. +# - Геометрическая визуализация зависит от pyvista/vtk; при недоступности модуль обязан завершать шаг без падения сценария. +# - Межмодульная связность: только через фасад model_view; прямые обращения между zone, rack, shelf, cell запрещены. +# - Очистка состояния должна быть идемпотентной: повторный вызов не меняет корректное состояние в ошибочное. +# +# 4) Правило сопровождения: +# - Любое изменение сценария должно сопровождаться обновлением этого блока с сохранением фактического порядка вызовов. +# - При добавлении метода указывать его место в цепочке сценария (запуск, основной шаг, завершение, вспомогательная логика). diff --git a/Dispatch_V0.1.1/gui/components/model_view/_mv_racks_shelf.py b/Dispatch_V0.1.1/gui/components/model_view/_mv_racks_shelf.py new file mode 100644 index 0000000..04128ab --- /dev/null +++ b/Dispatch_V0.1.1/gui/components/model_view/_mv_racks_shelf.py @@ -0,0 +1,319 @@ +# -*- coding: utf-8 -*- +"""Миксин размещения полок стеллажа для ModelViewWidget.""" + +from __future__ import annotations + +from typing import Any, TYPE_CHECKING +from error_logger import log_exception + +if TYPE_CHECKING: + from gui.components.model_view_widget import ModelViewWidget + + +class RackShelfMixin: + """Вспомогательные методы размещения полок и секций, извлечённые из модуля стеллажей.""" + + def start_select_rack_mode(self: "ModelViewWidget", zone_id: str | None) -> None: + zid = str(zone_id or "") + self._rack_select_mode = bool(zid) + self._rack_preview_zone_id = zid if zid else self._rack_preview_zone_id + self._clear_hover_rack_highlight() + # Сценарий взаимодействия + mgr = getattr(self, "_interaction_manager", None) + if mgr is not None: + mgr.pop_by_name("rack_select") + if self._rack_select_mode: + from gui.components.model_view._scenario_custom_handler import CustomHandlerScenario + mgr.push(CustomHandlerScenario( + name="rack_select", + click_handler=self._on_select_rack_click, + hover_screen_handler=self._on_select_rack_hover_screen, + )) + + def stop_select_rack_mode(self: "ModelViewWidget") -> None: + # Убрать сценарий взаимодействия + mgr = getattr(self, "_interaction_manager", None) + if mgr is not None: + mgr.pop_by_name("rack_select") + self._rack_select_mode = False + self._clear_hover_rack_highlight() + + def start_shelf_placement(self: "ModelViewWidget", rack_id: str, zone_id: str) -> bool: + rid = str(rack_id or "") + zid = str(zone_id or "") + if not rid or not zid: + return False + if hasattr(self, "_clear_selected_rendered_shelf"): + self._clear_selected_rendered_shelf(render=False) + entry = self._get_rack_entry(rid, zid) + if entry is None: + return False + self.stop_rack_placement(clear_preview=True) + self.stop_select_rack_mode() + self._clear_selected_rack_contour(render=False) + self._clear_hover_rack_highlight(render=False) + self._clear_shelf_slot_hover() + self._clear_shelf_slot_active() + self._shelf_target_zone_id = zid + self._shelf_target_rack_id = rid + # Сценарий взаимодействия + mgr = getattr(self, "_interaction_manager", None) + if mgr is not None: + from gui.components.model_view._scenario_custom_handler import CustomHandlerScenario + mgr.push(CustomHandlerScenario( + name="shelf_placement", + click_handler=self._on_select_shelf_slot_click, + hover_screen_handler=self._on_select_shelf_slot_hover_screen, + )) + + # Сделать объёмы секций выбираемыми, но почти прозрачными. + for slot_actor in (entry.get("slot_actors") or {}).values(): + if slot_actor is None: + continue + try: + slot_actor.SetVisibility(1) + prop = slot_actor.GetProperty() + if prop is not None: + # Прокси секций должны оставаться невидимыми; видны только контуры. + prop.SetOpacity(0.0) + except Exception as _exc: + log_exception(__name__, "start_shelf_placement", _exc) + self._preload_shelf_model_for_entry(entry) + if self._plotter: + self._plotter.update() + slot_ids = [str(sid) for sid in (entry.get("slot_actors") or {}).keys()] + if len(slot_ids) == 1: + self._set_active_shelf_slot(slot_ids[0]) + try: + self.shelf_slot_selected.emit(rid, slot_ids[0]) # type: ignore[attr-defined] + except Exception as _exc: + log_exception(__name__, "start_shelf_placement", _exc) + return True + + def stop_shelf_placement(self: "ModelViewWidget", clear_selection: bool = True) -> None: + self.stop_shelf_placement_ext(clear_selection=clear_selection, clear_rendered_shelves=True) + + def stop_shelf_placement_ext( + self: "ModelViewWidget", + clear_selection: bool = True, + clear_rendered_shelves: bool = True, + ) -> None: + # Убрать сценарий взаимодействия + mgr = getattr(self, "_interaction_manager", None) + if mgr is not None: + mgr.pop_by_name("shelf_placement") + self._clear_shelf_slot_hover() + if clear_selection: + self._clear_shelf_slot_active() + if hasattr(self, "_clear_selected_rendered_shelf"): + self._clear_selected_rendered_shelf(render=False) + rid = str(self._shelf_target_rack_id or "") + zid = str(self._shelf_target_zone_id or "") + entry = self._get_rack_entry(rid, zid) if rid and zid else None + if entry is not None: + if clear_rendered_shelves: + self._clear_slot_shelf_actors(rid, clear_slot_id=None) + for slot_id, slot_actor in (entry.get("slot_actors") or {}).items(): + if slot_actor is None: + continue + try: + # Вне режима размещения полок прокси-объёмы секций должны быть скрыты. + slot_actor.SetVisibility(0) + prop = slot_actor.GetProperty() + if prop is not None: + prop.SetOpacity(0.35) + prop.SetEdgeVisibility(False) + except Exception as _exc: + log_exception(__name__, "stop_shelf_placement_ext", _exc) + self._shelf_target_zone_id = None + self._shelf_target_rack_id = None + if self._plotter: + self._plotter.update() + + def finalize_shelf_layout_for_rack(self: "ModelViewWidget", rack_id: str) -> bool: + rid = str(rack_id or "") + if not rid: + return False + entry = self._get_rack_entry(rid) + if entry is None: + return False + for slot_id in list((entry.get("slot_actors") or {}).keys()): + self.set_rack_slot_occupied(rid, str(slot_id), True) + entry["shelf_helpers_hidden"] = True + self._apply_rack_helper_visibility_policy(entry, True) + self._set_rack_shelf_actors_visibility(rid, True) + self._apply_rack_shelf_opacity(rid) + if self._plotter: + self._plotter.update() + return True + + def get_active_shelf_slot(self: "ModelViewWidget") -> tuple[str | None, str | None]: + return (self._shelf_target_rack_id, self._active_shelf_slot_id) + + def get_selected_shelf_visual(self: "ModelViewWidget") -> tuple[str | None, str | None, int | None]: + return ( + self._selected_shelf_visual_rack_id, + self._selected_shelf_visual_slot_id, + self._selected_shelf_visual_index, + ) + + def get_rack_shelf_layout(self: "ModelViewWidget", rack_id: str) -> list[dict[str, Any]]: + rid = str(rack_id or "") + if not rid: + return [] + layout: list[dict[str, Any]] = [] + for (entry_rid, slot_id), payload in self._rack_shelf_params.items(): + if str(entry_rid) != rid: + continue + layout.append({"slot_id": str(slot_id), "params": dict(payload or {})}) + layout.sort(key=lambda item: self._slot_index_from_id(str(item.get("slot_id") or ""))) + return layout + + def get_shelf_slot_params(self: "ModelViewWidget", rack_id: str, slot_id: str) -> dict[str, Any]: + rid = str(rack_id or "") + sid = str(slot_id or "") + if not rid or not sid: + return {} + return dict(self._rack_shelf_params.get((rid, sid)) or {}) + + def activate_shelf_slot( + self: "ModelViewWidget", + rack_id: str, + slot_id: str, + notify: bool = True, + ) -> bool: + rid = str(rack_id or "") + sid = str(slot_id or "") + if not rid or not sid: + return False + if str(self._shelf_target_rack_id or "") != rid: + return False + entry = self._get_rack_entry(rid, self._shelf_target_zone_id) + if entry is None: + return False + if sid not in (entry.get("slot_actors") or {}): + return False + self._set_active_shelf_slot(sid) + if notify: + try: + self.shelf_slot_selected.emit(rid, sid) # type: ignore[attr-defined] + except Exception as _exc: + log_exception(__name__, "activate_shelf_slot", _exc) + return True + + def restore_rack_shelves(self: "ModelViewWidget", rack_id: str, shelf_rows: list[dict[str, Any]]) -> bool: + rid = str(rack_id or "") + if not rid: + return False + entry = self._get_rack_entry(rid) + if entry is None: + return False + restored = False + for row in list(shelf_rows or []): + slot_id = str(row.get("slot_id") or "S1") + payload = dict(row.get("params") or {}) + if not payload: + payload = { + "shelf_code_start": str(row.get("code") or "P01"), + "shelf_depth_mm": int(row.get("d") or 500), + "shelves_count": int(row.get("grid_r") or 1), + "bottom_shelf_height_mm": float(row.get("h") or 300.0), + "height_mode": "uniform", + "useful_height_1_mm": 400.0, + "useful_heights_mm": [400.0], + } + normalized = self._render_shelves_for_slot(rid, slot_id, payload) + if not normalized: + continue + self._rack_shelf_params[(rid, slot_id)] = dict(normalized) + self.set_rack_slot_occupied(rid, slot_id, True) + restored = True + if restored: + entry["shelf_helpers_hidden"] = True + self._apply_rack_helper_visibility_policy(entry, True) + self._set_rack_shelf_actors_visibility(rid, True) + self._apply_rack_shelf_opacity(rid) + if self._plotter: + self._plotter.update() + return restored + + def set_active_shelf_slot_params(self: "ModelViewWidget", payload: dict[str, Any]) -> dict[str, Any] | None: + rack_id = str(self._shelf_target_rack_id or "") + slot_id = str(self._active_shelf_slot_id or "") + if not rack_id or not slot_id: + return None + normalized = self._render_shelves_for_slot(rack_id, slot_id, dict(payload or {})) + if not normalized: + return None + self._rack_shelf_params[(rack_id, slot_id)] = dict(normalized) + # Занятая секция скрывается от дальнейшего размещения. + self.set_rack_slot_occupied(rack_id, slot_id, True) + self._apply_rack_shelf_opacity(rack_id) + return dict(normalized) + + +# --------------------------------------------------------------------------- +# Module workflow notes +# --------------------------------------------------------------------------- +# 1) Задача модуля: +# Сценарии работы с полками стеллажей. +# +# 2) Последовательность действий и вызовов: +# A. Класс RackShelfMixin: точки входа +# Публичные методы сценария: +# - RackShelfMixin.start_select_rack_mode(...) +# - RackShelfMixin.stop_select_rack_mode(...) +# - RackShelfMixin.start_shelf_placement(...) +# - RackShelfMixin.stop_shelf_placement(...) +# - RackShelfMixin.stop_shelf_placement_ext(...) +# - RackShelfMixin.finalize_shelf_layout_for_rack(...) +# - RackShelfMixin.get_active_shelf_slot(...) +# - RackShelfMixin.get_rack_shelf_layout(...) +# - RackShelfMixin.get_shelf_slot_params(...) +# - RackShelfMixin.activate_shelf_slot(...) +# - RackShelfMixin.restore_rack_shelves(...) +# - RackShelfMixin.set_active_shelf_slot_params(...) +# +# B. RackShelfMixin: запуск и настройка: +# RackShelfMixin.start_select_rack_mode(...) +# Назначение: запускает select rack mode в рамках текущего сценария модуля. +# RackShelfMixin.start_shelf_placement(...) +# Назначение: запускает shelf placement в рамках текущего сценария модуля. +# Последовательность внутренних вызовов: +# -> RackShelfMixin.stop_select_rack_mode(...) +# RackShelfMixin.set_active_shelf_slot_params(...) +# Назначение: устанавливает active shelf slot params в рамках текущего сценария модуля. +# +# C. RackShelfMixin: завершение и очистка: +# RackShelfMixin.stop_select_rack_mode(...) +# Назначение: останавливает select rack mode в рамках текущего сценария модуля. +# RackShelfMixin.stop_shelf_placement(...) +# Назначение: останавливает shelf placement в рамках текущего сценария модуля. +# Последовательность внутренних вызовов: +# -> RackShelfMixin.stop_shelf_placement_ext(...) +# RackShelfMixin.stop_shelf_placement_ext(...) +# Назначение: останавливает shelf placement ext в рамках текущего сценария модуля. +# RackShelfMixin.finalize_shelf_layout_for_rack(...) +# Назначение: финализирует shelf layout for rack в рамках текущего сценария модуля. +# RackShelfMixin.restore_rack_shelves(...) +# Назначение: восстанавливает rack shelves в рамках текущего сценария модуля. +# +# D. RackShelfMixin: вспомогательные расчёты: +# RackShelfMixin.get_active_shelf_slot(...) +# Назначение: возвращает active shelf slot в рамках текущего сценария модуля. +# RackShelfMixin.get_rack_shelf_layout(...) +# Назначение: возвращает rack shelf layout в рамках текущего сценария модуля. +# RackShelfMixin.get_shelf_slot_params(...) +# Назначение: возвращает shelf slot params в рамках текущего сценария модуля. +# RackShelfMixin.activate_shelf_slot(...) +# Назначение: выполняет шаг "activate shelf slot" в рамках текущего сценария модуля. +# +# 3) Важные ограничения и инварианты: +# - Модуль выполняется в составе ModelViewWidget и использует согласованные поля состояния self._... . +# - Все операции с актёрами и камерой выполняются только при валидном self._plotter с безопасной обработкой исключений. +# - Межмодульная связность: только через фасад model_view; прямые обращения между zone, rack, shelf, cell запрещены. +# - Очистка состояния должна быть идемпотентной: повторный вызов не меняет корректное состояние в ошибочное. +# +# 4) Правило сопровождения: +# - Любое изменение сценария должно сопровождаться обновлением этого блока с сохранением фактического порядка вызовов. +# - При добавлении метода указывать его место в цепочке сценария (запуск, основной шаг, завершение, вспомогательная логика). diff --git a/Dispatch_V0.1.1/gui/components/model_view/_mv_racks_shelf_render.py b/Dispatch_V0.1.1/gui/components/model_view/_mv_racks_shelf_render.py new file mode 100644 index 0000000..eba070d --- /dev/null +++ b/Dispatch_V0.1.1/gui/components/model_view/_mv_racks_shelf_render.py @@ -0,0 +1,1625 @@ +# -*- coding: utf-8 -*- +"""Вспомогательные методы рендеринга полок стеллажа, извлечённые из основного модуля model-view. + +Этот миксин предоставляет все методы, необходимые для создания, размещения +и управления 3D-акторами полок, отображаемых внутри секций стеллажа. +""" +from __future__ import annotations + +import re +import math +import colorsys +from pathlib import Path +from typing import Any, TYPE_CHECKING +from error_logger import log_exception + +try: + import pyvista as pv + _PV = True +except ImportError: + _PV = False + +if TYPE_CHECKING: + from gui.components.model_view_widget import ModelViewWidget + +# Параметры палитры объёмов ячеек. +# Шаг 15° (1/24 круга) — минимально различимый цветовой шаг (≈ #FF7F00 → #FFBF00). +# Стартовый hue зависит от step_mm: +# step < 50 мм (A/B): base_hue = 0.5 + (step_mm/10 − 1) / 12 +# step >= 50 мм (PALLET): base_hue = 0.5 + (step_mm/50 − 2) / 12 +# Стартовые цвета при тир=1: +# step 10 / 100 → hue 0.500 (180°, #00FFFF — голубой) +# step 20 / 150 → hue 0.583 (210°, ≈#0280FF — синевато-голубой) +# step 30 / 200 → hue 0.667 (240°, #0000FF — синий) +# step 40 / 250 → hue 0.750 (270°, сине-фиолетовый) +# step 50 / 300 → hue 0.833 (300°, пурпурный) — и далее по кругу +# Насыщенность s=0.72, яркость v=0.88. + + +class RackShelfRenderMixin: + """Вспомогательные методы рендеринга полок — подмешивается в *ModelViewWidget*.""" + + @staticmethod + def _cell_volume_color(cell_height_mm: float, step_mm: float) -> tuple[float, float, float]: + """Цвет ячейки по 24-позиционному HSV-циклу (шаг 15°, полушаговый сдвиг). + + Стартовый hue зависит от step_mm типа стеллажа: + step 10 / 100 → hue 0.500 (180° — #00FFFF голубой) + step 20 / 150 → hue 0.583 (210° — ≈#0280FF синевато-голубой) + step 30 / 200 → hue 0.667 (240° — #0000FF синий) + и т.д. по +30° на каждый следующий типовой шаг. + + tiers = cell_height_mm / step_mm; каждые 2 тира → +15° (следующая позиция). + + Возвращает RGB-туплу (0.0–1.0) для PyVista. + """ + step = max(1.0, float(step_mm)) + h = max(1.0, float(cell_height_mm)) + # Базовая позиция определяется по размеру шага разбиения (cell_height_mm), + # а не по min step: + # A/B: 10 -> pos0 (#00FFFF), 20 -> pos1 (#0280FF), 30 -> pos2 (#0000FF), ... + # PALLET: 100 -> pos0, 150 -> pos1, 200 -> pos2, ... + if step < 50.0: + pos = int(round(h / 10.0)) - 1 + else: + pos = int(round((h - 100.0) / 50.0)) + pos = max(0, int(pos)) % 24 + + # Полная насыщенность/яркость: стартовые цвета совпадают с эталоном. + hue = (0.5 + float(pos) / 24.0) % 1.0 + rgb = colorsys.hsv_to_rgb(hue, 1.0, 1.0) + + # Точно фиксируем эталонные стартовые якоря, озвученные в требованиях. + anchor = { + 0: (0, 255, 255), # 00FFFF for 10/100 + 1: (2, 128, 255), # 0280FF for 20/150 + 2: (0, 0, 255), # 0000FF for 30/200 + }.get(pos) + if anchor is not None: + return (anchor[0] / 255.0, anchor[1] / 255.0, anchor[2] / 255.0) + return rgb + + def _clear_slot_shelf_actors(self: "ModelViewWidget", rack_id: str, clear_slot_id: str | None = None) -> None: + rid = str(rack_id or "") + target_slot = str(clear_slot_id or "") + keys = [key for key in self._rack_shelf_actors.keys() if key[0] == rid and (not target_slot or key[1] == target_slot)] + for key in keys: + for actor in self._rack_shelf_actors.get(key, []): + try: + self._plotter.remove_actor(actor) + except Exception as _exc: + log_exception(__name__, "_clear_slot_shelf_actors", _exc) + self._rack_shelf_actors.pop(key, None) + if hasattr(self, "_rack_shelf_cell_links"): + self._rack_shelf_cell_links.pop(key, None) + if hasattr(self, "_rack_shelf_cell_links_indexed"): + rid, sid = key + idx_keys = [ + k for k in list(self._rack_shelf_cell_links_indexed.keys()) + if str(k[0]) == str(rid) and str(k[1]) == str(sid) + ] + for idx_key in idx_keys: + self._rack_shelf_cell_links_indexed.pop(idx_key, None) + + def _clear_slot_cell_actors( + self: "ModelViewWidget", + storage: dict[tuple[str, str], list[Any]], + rack_id: str, + clear_slot_id: str | None = None, + ) -> None: + rid = str(rack_id or "") + target_slot = str(clear_slot_id or "") + keys = [key for key in storage.keys() if key[0] == rid and (not target_slot or key[1] == target_slot)] + for key in keys: + for actor in storage.get(key, []): + try: + self._plotter.remove_actor(actor) + except Exception as _exc: + log_exception(__name__, "_clear_slot_cell_actors", _exc) + storage.pop(key, None) + + def _clear_cell_actor_key( + self: "ModelViewWidget", + storage: dict[tuple[Any, ...], list[Any]], + key: tuple[Any, ...], + ) -> None: + for actor in list(storage.get(tuple(key), []) or []): + try: + self._plotter.remove_actor(actor) + except Exception as _exc: + log_exception(__name__, "_clear_cell_actor_key", _exc) + storage.pop(tuple(key), None) + + def clear_cell_grid_visualization( + self: "ModelViewWidget", + rack_id: str | None = None, + slot_id: str | None = None, + ) -> None: + if not self._plotter: + return + rid = str(rack_id or "") + sid = str(slot_id or "") + if rid: + self._clear_slot_cell_actors(self._cell_preview_actors, rid, sid or None) + self._clear_slot_cell_actors(self._cell_final_actors, rid, sid or None) + self._clear_slot_cell_actors(self._cell_dim_actors, rid, sid or None) + # Чистим сохранённые эталонные границы. + ref_bounds = getattr(self, "_cell_ref_bounds", {}) + stale = [k for k in list(ref_bounds.keys()) if isinstance(k, tuple) and str(k[0]) == rid and (not sid or str(k[1]) == sid)] + for k in stale: + ref_bounds.pop(k, None) + else: + for storage in (self._cell_preview_actors, self._cell_final_actors, self._cell_dim_actors): + for key in list(storage.keys()): + for actor in storage.get(key, []): + try: + self._plotter.remove_actor(actor) + except Exception as _exc: + log_exception(__name__, "clear_cell_grid_visualization", _exc) + storage.pop(key, None) + if hasattr(self, "_cell_ref_bounds"): + self._cell_ref_bounds.clear() + if hasattr(self, "_safe_render"): + self._safe_render(min_interval_s=1.0 / 60.0) + else: + self._plotter.update() + + def set_rack_cell_volume_isolation( + self: "ModelViewWidget", + rack_id: str, + slot_id: str | None = None, + shelf_index: int | None = None, + ) -> None: + """Управление видимостью объёмов ячеек внутри стеллажа. + + slot_id=None → показать все объёмы + сводные метки стеллажа (уровень rack). + slot_id задан → показать только (slot_id, shelf_index), сводные метки скрыть, + вместо них показать три размера изолированной полки (уровень shelf). + """ + rid = str(rack_id or "") + if not rid: + return + sid = str(slot_id or "") if slot_id else None + sidx = int(max(1, int(shelf_index or 1))) if shelf_index is not None else None + # ── видимость кратных объёмов ──────────────────────────────────────── + for key, actors in self._cell_final_actors.items(): + if not isinstance(key, tuple) or str(key[0]) != rid: + continue + if sid is None: + visible = True + else: + try: + visible = str(key[1]) == sid and (sidx is None or int(key[2]) == sidx) + except Exception as exc: + log_exception(__name__, "set_rack_cell_volume_isolation.key_parse", exc) + visible = False + for actor in actors or []: + try: + actor.SetVisibility(1 if visible else 0) + except Exception as _exc: + log_exception(__name__, "set_rack_cell_volume_isolation", _exc) + # ── dim-акторы ────────────────────────────────────────────────────── + if sid is None: + # Уровень rack: убрать изоляционные dim-акторы, показать сводные. + for key in list(self._cell_dim_actors.keys()): + if not isinstance(key, tuple) or str(key[0]) != rid: + continue + if len(key) >= 2 and str(key[1]).startswith("__sum__"): + # Сводная метка — показываем. + for actor in self._cell_dim_actors[key] or []: + try: + actor.SetVisibility(1) + except Exception as _exc: + log_exception(__name__, "set_rack_cell_volume_isolation", _exc) + else: + # Изоляционная метка — убираем. + self._clear_cell_actor_key(self._cell_dim_actors, key) + else: + # Уровень shelf: скрыть все сводные dim-акторы, нарисовать 3 размера для полки. + for key in list(self._cell_dim_actors.keys()): + if not isinstance(key, tuple) or str(key[0]) != rid: + continue + if len(key) >= 2 and str(key[1]).startswith("__sum__"): + for actor in self._cell_dim_actors[key] or []: + try: + actor.SetVisibility(0) + except Exception as _exc: + log_exception(__name__, "set_rack_cell_volume_isolation", _exc) + else: + self._clear_cell_actor_key(self._cell_dim_actors, key) + isolated_key = (rid, sid, sidx if sidx is not None else 1) + ref_bounds = getattr(self, "_cell_ref_bounds", {}).get(isolated_key) + if ref_bounds is not None: + self._draw_cell_dimension_lines( + isolated_key, + ref_bounds, + color="#000000", + text_color="#000000", + ) + + # ── Сводные метки шагового объёма ──────────────────────────────────────── + + def _rebuild_rack_cell_dim_summary(self: "ModelViewWidget", rack_id: str) -> None: + """Пересобрать сводные метки размеров шагового объёма для стеллажа. + + Один вертикальный размер на каждое уникальное значение высоты ячейки. + Метка: «H мм × N» (N — кол-во полок с таким разбиением), либо «H мм» если N=1. + """ + if not self._plotter or not _PV: + return + rid = str(rack_id or "") + if not rid: + return + # Удалить старые сводные dim-акторы для этого стеллажа. + sum_keys = [ + k for k in list(self._cell_dim_actors.keys()) + if isinstance(k, tuple) and str(k[0]) == rid and len(k) >= 2 and str(k[1]).startswith("__sum__") + ] + for k in sum_keys: + self._clear_cell_actor_key(self._cell_dim_actors, k) + ref_bounds_all = getattr(self, "_cell_ref_bounds", {}) + # Собрать все slot_keys этого стеллажа с уже отрисованными final-акторами. + rack_slots: list[tuple[tuple, tuple]] = [] + for key, actors in self._cell_final_actors.items(): + if not isinstance(key, tuple) or str(key[0]) != rid: + continue + if not actors: + continue + bounds = ref_bounds_all.get(key) + if bounds is None: + continue + rack_slots.append((key, bounds)) + if not rack_slots: + return + # Группировать по высоте ячейки (округлённой до 1 мм). + groups: dict[int, list[tuple[tuple, tuple]]] = {} + for key, bounds in rack_slots: + h_mm = int(round(float(bounds[5]) - float(bounds[4]))) + groups.setdefault(h_mm, []).append((key, bounds)) + # Для каждой группы — одна метка у самого левого (min_x наименьший) слота. + for h_mm, entries in groups.items(): + # Выбираем слот с наименьшим min_x. + anchor_key, anchor_bounds = min(entries, key=lambda e: float(e[1][0])) + n = len(entries) + label_text = f"{h_mm} мм × {n}" if n > 1 else f"{h_mm} мм" + sum_key = (rid, f"__sum__{h_mm}", 0) + self._draw_ref_cell_single_dim( + sum_key, + anchor_bounds, + color="#4DB3FF", + text_color="#4DB3FF", + label_override=label_text, + ) + + def rebuild_rack_cell_dim_summary(self: "ModelViewWidget", rack_id: str) -> None: + """Публичный метод: пересобрать сводные метки после рендеринга одного слота или стеллажа.""" + self._rebuild_rack_cell_dim_summary(rack_id) + + def _is_rack_visible_for_cell_grid(self: "ModelViewWidget", rack_id: str) -> bool: + rid = str(rack_id or "") + if not rid: + return False + entry = self._get_rack_entry(rid, self._shelf_target_zone_id) + if entry is None: + entry = self._get_rack_entry(rid) + if entry is None: + return False + for actor in list(entry.get("actors") or []): + if actor is None: + continue + try: + return bool(actor.GetVisibility()) + except Exception as exc: + log_exception(__name__, "_is_rack_visible_for_cell_grid", exc) + continue + return True + + def _apply_cell_grid_edit_isolation_visibility(self: "ModelViewWidget") -> None: + active = bool(getattr(self, "_cell_grid_edit_isolation_active", False)) + target_rid = str(getattr(self, "_cell_grid_edit_rack_id", "") or "") + target_sid = str(getattr(self, "_cell_grid_edit_slot_id", "") or "") + target_index = int(max(1, int(getattr(self, "_cell_grid_edit_shelf_index", 1) or 1))) + for storage_name in ("_cell_preview_actors", "_cell_final_actors", "_cell_dim_actors"): + storage = getattr(self, storage_name, {}) or {} + is_preview_storage = str(storage_name) == "_cell_preview_actors" + for key, actors in storage.items(): + if not isinstance(key, tuple) or len(key) < 3: + continue + rid = str(key[0] or "") + sid = str(key[1] or "") + try: + shelf_index = int(max(1, int(key[2] or 1))) + except Exception as exc: + log_exception(__name__, "_apply_cell_grid_edit_isolation_visibility", exc) + shelf_index = 1 + visible = bool(self._is_rack_visible_for_cell_grid(rid)) + if visible and active: + is_target = bool( + rid == target_rid + and sid == target_sid + and shelf_index == target_index + ) + # В edit-режиме оставляем только preview редактируемой сетки. + if is_preview_storage: + visible = is_target + else: + visible = False + for actor in list(actors or []): + try: + actor.SetVisibility(1 if visible else 0) + except Exception as _exc: + log_exception(__name__, "_apply_cell_grid_edit_isolation_visibility", _exc) + def set_cell_grid_edit_isolation( + self: "ModelViewWidget", + rack_id: str, + slot_id: str, + shelf_index: int, + active: bool, + ) -> None: + self._cell_grid_edit_isolation_active = bool(active) + self._cell_grid_edit_rack_id = str(rack_id or "") if active else None + self._cell_grid_edit_slot_id = str(slot_id or "") if active else None + self._cell_grid_edit_shelf_index = int(max(1, int(shelf_index or 1))) if active else None + self._apply_cell_grid_edit_isolation_visibility() + if hasattr(self, "_safe_render"): + self._safe_render(min_interval_s=1.0 / 75.0) + elif self._plotter: + self._plotter.update() + + def _get_slot_bounds(self: "ModelViewWidget", rack_id: str, slot_id: str) -> tuple[float, float, float, float, float, float] | None: + rid = str(rack_id or "") + sid = str(slot_id or "") + if not rid or not sid: + return None + entry = self._get_rack_entry(rid, self._shelf_target_zone_id) + if entry is None: + entry = self._get_rack_entry(rid) + if entry is None: + return None + slot_actor = (entry.get("slot_actors") or {}).get(sid) + if slot_actor is None: + return None + try: + bounds = slot_actor.GetBounds() + if not bounds: + return None + return tuple(float(v) for v in bounds[:6]) # type: ignore[return-value] + except Exception as exc: + log_exception(__name__, "_get_slot_bounds", exc) + return None + + @staticmethod + def _pick_useful_height_mm(useful_values: list[float], shelf_index: int) -> float: + if not useful_values: + return 100.0 + idx = max(1, int(shelf_index or 1)) + # useful heights обычно задаются как интервалы между полками. + pick_idx = min(len(useful_values) - 1, max(0, idx - 1)) + return max(1.0, float(useful_values[pick_idx])) + + @staticmethod + def _resolve_shelf_useful_height_mm( + *, + rack_type: str, + shelf_index: int, + shelf_count: int, + useful_values: list[float], + useful_under_bottom_mm: float | None, + top_useful_default_mm: float, + ) -> float: + idx = max(1, int(shelf_index or 1)) + count = max(1, int(shelf_count or 1)) + kind = str(rack_type or "").strip().upper() + top_default = max(1.0, float(top_useful_default_mm or 1.0)) + safe_values = [max(1.0, float(v)) for v in list(useful_values or [])] + + if idx >= count: + return top_default + + if kind == "PALLET": + if idx == 1: + under = useful_under_bottom_mm + if under is None: + under = safe_values[0] if safe_values else top_default + return max(1.0, float(under)) + value_idx = min(len(safe_values) - 1, max(0, idx - 2)) + if safe_values: + return max(1.0, float(safe_values[value_idx])) + return top_default + + value_idx = min(len(safe_values) - 1, max(0, idx - 1)) + if safe_values: + return max(1.0, float(safe_values[value_idx])) + return top_default + + @staticmethod + def _cell_layout_constraints_for_rack_type( + rack_type: str, + ) -> tuple[float, float, float]: + """Вернуть ограничения разметки ячеек: (отступ_по_ширине, отступ_по_глубине, useful_top_mm).""" + kind = str(rack_type or "").strip().upper() + if kind == "A": + return (3.0, 2.0, 600.0) + if kind == "B": + return (25.0, 7.0, 600.0) + return (25.0, 3.0, 1200.0) + + @staticmethod + def _pallet_inner_width_between_posts_mm( + rack_params: dict[str, Any], + shelf_params: dict[str, Any] | None = None, + ) -> float: + # Для PALLET внутренний проем между стойками задается center_spans_mm. + # Это и есть рабочая ширина первого (напольного) уровня. + spans_raw = (rack_params or {}).get("center_spans_mm") + try: + spans = [float(v) for v in (spans_raw or []) if float(v) > 0.0] + except Exception as exc: + log_exception(__name__, "_pallet_inner_width_between_posts_mm", exc) + spans = [] + if spans: + return max(1.0, float(sum(spans))) + try: + fallback = float(((shelf_params or {}).get("shelf_width_mm")) or 0.0) + if fallback > 1.0: + return fallback + except Exception as _exc: + log_exception(__name__, "_pallet_inner_width_between_posts_mm", _exc) + return 0.0 + + @staticmethod + def _actor_bounds(actor: Any) -> tuple[float, float, float, float, float, float] | None: + if actor is None: + return None + try: + bounds = actor.GetBounds() + except Exception as exc: + log_exception(__name__, "_actor_bounds.get_bounds", exc) + return None + if not bounds or len(bounds) < 6: + return None + try: + return tuple(float(v) for v in bounds[:6]) # type: ignore[return-value] + except Exception as exc: + log_exception(__name__, "_actor_bounds.float_cast", exc) + return None + + def _rack_structure_bounds(self: "ModelViewWidget", entry: dict[str, Any]) -> tuple[float, float, float, float, float, float] | None: + if not entry: + return None + slot_actor_ids = {id(actor) for actor in ((entry.get("slot_actors") or {}).values()) if actor is not None} + merged: list[float] | None = None + for actor in list(entry.get("actors") or []): + if actor is None or id(actor) in slot_actor_ids: + continue + bounds = self._actor_bounds(actor) + if bounds is None: + continue + if merged is None: + merged = [float(v) for v in bounds] + continue + merged[0] = min(merged[0], float(bounds[0])) + merged[1] = max(merged[1], float(bounds[1])) + merged[2] = min(merged[2], float(bounds[2])) + merged[3] = max(merged[3], float(bounds[3])) + merged[4] = min(merged[4], float(bounds[4])) + merged[5] = max(merged[5], float(bounds[5])) + if merged is None: + return None + return tuple(merged) # type: ignore[return-value] + + def _find_pillar_actor_bounds( + self: "ModelViewWidget", + entry: dict[str, Any], + ) -> tuple[float, float, float, float, float, float] | None: + if not entry: + return None + slot_actor_ids = {id(actor) for actor in ((entry.get("slot_actors") or {}).values()) if actor is not None} + rack_params = dict((entry or {}).get("params") or {}) + expected_w = max(1.0, float(rack_params.get("footprint_width_mm") or 0.0)) + expected_d = max(1.0, float(rack_params.get("footprint_depth_mm") or 0.0)) + best_bounds = None + best_score = None + for actor in list(entry.get("actors") or []): + if actor is None or id(actor) in slot_actor_ids: + continue + bounds = self._actor_bounds(actor) + if bounds is None: + continue + x_len = max(0.0, float(bounds[1] - bounds[0])) + y_len = max(0.0, float(bounds[3] - bounds[2])) + z_len = max(0.0, float(bounds[5] - bounds[4])) + if z_len < 100.0: + continue + score = min( + abs(x_len - expected_w) + abs(y_len - expected_d), + abs(x_len - expected_d) + abs(y_len - expected_w), + ) + if best_score is None or score < best_score: + best_score = score + best_bounds = bounds + return best_bounds + + def _pallet_inner_width_between_posts_by_bbox( + self: "ModelViewWidget", + entry: dict[str, Any], + *, + width_axis: str, + ) -> float: + pillar_bounds = self._find_pillar_actor_bounds(entry or {}) + if pillar_bounds is None: + return 0.0 + outer_x = max(0.0, float(pillar_bounds[1] - pillar_bounds[0])) + outer_y = max(0.0, float(pillar_bounds[3] - pillar_bounds[2])) + outer_span = outer_x if str(width_axis).upper() == "X" else outer_y + + post_thickness = 0.0 + rack_params = dict((entry or {}).get("params") or {}) + try: + mesh_getter = getattr(self, "_get_pillar_base_mesh", None) + if callable(mesh_getter): + base_mesh, _pillar_h = mesh_getter(rack_params) + if base_mesh is not None: + mb = base_mesh.bounds + t_x = max(0.0, float(mb[1] - mb[0])) + t_y = max(0.0, float(mb[3] - mb[2])) + post_thickness = max(0.0, min(t_x, t_y)) + except Exception as exc: + log_exception(__name__, "_pallet_inner_width_between_posts_by_bbox", exc) + post_thickness = 0.0 + + if post_thickness <= 0.0: + return max(1.0, float(outer_span)) + # Внутренний проём = внешний span - 2 * толщина стойки. + # Для PALLET это даёт корректный эталон (например, 3546 мм при соответствующем bbox). + return max(1.0, float(outer_span - (2.0 * post_thickness))) + + def _pallet_inner_bounds_between_posts_by_bbox( + self: "ModelViewWidget", + entry: dict[str, Any], + *, + width_axis: str, + ) -> tuple[float, float] | None: + pillar_bounds = self._find_pillar_actor_bounds(entry or {}) + if pillar_bounds is None: + return None + post_thickness = 0.0 + rack_params = dict((entry or {}).get("params") or {}) + try: + mesh_getter = getattr(self, "_get_pillar_base_mesh", None) + if callable(mesh_getter): + base_mesh, _pillar_h = mesh_getter(rack_params) + if base_mesh is not None: + mb = base_mesh.bounds + t_x = max(0.0, float(mb[1] - mb[0])) + t_y = max(0.0, float(mb[3] - mb[2])) + post_thickness = max(0.0, min(t_x, t_y)) + except Exception as exc: + log_exception(__name__, "_pallet_inner_bounds_between_posts_by_bbox", exc) + post_thickness = 0.0 + axis = str(width_axis).upper() + if axis == "Y": + outer_min = float(pillar_bounds[2]) + outer_max = float(pillar_bounds[3]) + else: + outer_min = float(pillar_bounds[0]) + outer_max = float(pillar_bounds[1]) + if post_thickness > 0.0: + inner_min = float(outer_min + post_thickness) + inner_max = float(outer_max - post_thickness) + else: + inner_min = float(outer_min) + inner_max = float(outer_max) + if inner_max <= inner_min: + return None + return (inner_min, inner_max) + + def _rendered_shelf_bounds( + self: "ModelViewWidget", + rack_id: str, + slot_id: str, + shelf_index: int, + ) -> tuple[float, float, float, float, float, float] | None: + key = (str(rack_id or ""), str(slot_id or "")) + actors = list((self._rack_shelf_actors or {}).get(key) or []) + idx = max(1, int(shelf_index or 1)) - 1 + if 0 <= idx < len(actors): + bounds = self._actor_bounds(actors[idx]) + if bounds is not None: + return bounds + for actor in actors: + bounds = self._actor_bounds(actor) + if bounds is not None: + return bounds + return None + + def _resolve_cell_volume_limits( + self: "ModelViewWidget", + rack_id: str, + slot_id: str, + payload: dict[str, Any], + ) -> tuple[float, float, float, float, float, float] | None: + """Границы доступного объёма разметки до дискретизации по размеру ячейки.""" + slot_bounds = self._get_slot_bounds(rack_id, slot_id) + if slot_bounds is None: + return None + min_x, max_x, min_y, max_y, slot_min_z, slot_max_z = slot_bounds + + entry = self._get_rack_entry(str(rack_id or ""), self._shelf_target_zone_id) + if entry is None: + entry = self._get_rack_entry(str(rack_id or "")) + rack_params = dict((entry or {}).get("params") or {}) + rack_type = str(payload.get("rack_type") or rack_params.get("rack_type") or "PALLET").strip().upper() + width_inset, depth_inset, top_useful_default = self._cell_layout_constraints_for_rack_type(rack_type) + + shelf_id = str(payload.get("shelf_id") or "") + shelf_index = max(1, int(payload.get("shelf_number_index") or 1)) + + shelf_params = None + rack_layout = [] + try: + if hasattr(self, "get_rack_shelf_layout"): + rack_layout = self.get_rack_shelf_layout(str(rack_id or "")) + except Exception as exc: + log_exception(__name__, "_resolve_cell_volume_limits.get_rack_shelf_layout", exc) + rack_layout = [] + for item in rack_layout: + if str(item.get("slot_id") or "") == str(slot_id or ""): + shelf_params = dict(item.get("params") or {}) + break + + structure_bounds = self._rack_structure_bounds(entry or {}) + rack_bounds = structure_bounds + if rack_bounds is None: + rack_bounds = (float(min_x), float(max_x), float(min_y), float(max_y), float(slot_min_z), float(slot_max_z)) + rack_min_x, rack_max_x, rack_min_y, rack_max_y, _rack_min_z, rack_top_z = rack_bounds + + shelf_bounds = self._rendered_shelf_bounds(str(rack_id or ""), str(slot_id or ""), shelf_index) + if shelf_bounds is None: + shelf_bounds = (float(min_x), float(max_x), float(min_y), float(max_y), float(slot_min_z), float(slot_max_z)) + shelf_min_x, shelf_max_x, shelf_min_y, shelf_max_y, _shelf_min_z, _shelf_max_z = shelf_bounds + + if rack_type == "A": + plan_min_x = float(shelf_min_x + width_inset) + plan_max_x = float(shelf_max_x - width_inset) + plan_min_y = float(shelf_min_y + depth_inset) + plan_max_y = float(shelf_max_y - depth_inset) + elif rack_type == "B": + plan_min_x = float(shelf_min_x + width_inset) + plan_max_x = float(shelf_max_x - width_inset) + plan_min_y = float(shelf_min_y + depth_inset) + plan_max_y = float(shelf_max_y - depth_inset) + else: + plan_min_x = float(shelf_min_x + width_inset) + plan_max_x = float(shelf_max_x - width_inset) + plan_min_y = float(shelf_min_y + depth_inset) + plan_max_y = float(shelf_max_y - depth_inset) + + if plan_max_x <= plan_min_x: + plan_min_x = float(shelf_min_x) + plan_max_x = float(shelf_max_x) + if plan_max_y <= plan_min_y: + plan_min_y = float(shelf_min_y) + plan_max_y = float(shelf_max_y) + if rack_type == "PALLET" and shelf_index == 1: + width_axis = "X" + if float(shelf_max_y - shelf_min_y) > float(shelf_max_x - shelf_min_x): + width_axis = "Y" + inner_bounds = self._pallet_inner_bounds_between_posts_by_bbox( + entry or {}, + width_axis=width_axis, + ) + if inner_bounds is not None: + if width_axis == "Y": + plan_min_y = max(float(shelf_min_y), float(inner_bounds[0])) + plan_max_y = min(float(shelf_max_y), float(inner_bounds[1])) + else: + plan_min_x = max(float(shelf_min_x), float(inner_bounds[0])) + plan_max_x = min(float(shelf_max_x), float(inner_bounds[1])) + inner_width = 0.0 + if inner_bounds is None: + inner_width = self._pallet_inner_width_between_posts_by_bbox( + entry or {}, + width_axis=width_axis, + ) + if inner_width <= 1.0: + inner_width = self._pallet_inner_width_between_posts_mm(rack_params, shelf_params) + if inner_bounds is None and inner_width > 1.0: + center_x = float((shelf_min_x + shelf_max_x) * 0.5) + center_y = float((shelf_min_y + shelf_max_y) * 0.5) + span_x = max(0.0, float(shelf_max_x - shelf_min_x)) + span_y = max(0.0, float(shelf_max_y - shelf_min_y)) + if span_x >= span_y: + half_inner = float(min(inner_width * 0.5, span_x * 0.5)) + plan_min_x = float(center_x - half_inner) + plan_max_x = float(center_x + half_inner) + else: + half_inner = float(min(inner_width * 0.5, span_y * 0.5)) + plan_min_y = float(center_y - half_inner) + plan_max_y = float(center_y + half_inner) + + if not shelf_id: + return (plan_min_x, plan_max_x, plan_min_y, plan_max_y, float(slot_min_z), float(slot_max_z)) + + base_heights = [] + useful_values = [] + shelf_count = 1 + if shelf_params: + base_heights = [float(v) for v in (shelf_params.get("shelf_base_heights_mm") or [])] + useful_values = [float(v) for v in (shelf_params.get("useful_heights_mm") or [])] + try: + shelf_count = max(1, int(shelf_params.get("shelves_count") or len(base_heights) or 1)) + except Exception as exc: + log_exception(__name__, "_resolve_cell_volume_limits.shelf_count", exc) + shelf_count = max(1, int(len(base_heights) or 1)) + + if not base_heights: + return (plan_min_x, plan_max_x, plan_min_y, plan_max_y, float(slot_min_z), float(slot_max_z)) + + tree_shelf_count = int(shelf_count + 1) if rack_type == "PALLET" else int(shelf_count) + if rack_type == "PALLET": + if shelf_index <= 1: + base_z = float(slot_min_z) + else: + base_idx = min(len(base_heights) - 1, max(0, shelf_index - 2)) + base_z = float(slot_min_z + float(base_heights[base_idx])) + else: + base_idx = min(len(base_heights) - 1, max(0, shelf_index - 1)) + base_z = float(slot_min_z + float(base_heights[base_idx])) + + useful_under_bottom_mm = None + if shelf_params: + try: + raw_under = shelf_params.get("useful_under_bottom_mm") + useful_under_bottom_mm = float(raw_under) if raw_under is not None else None + except Exception as exc: + log_exception(__name__, "_resolve_cell_volume_limits.useful_under_bottom", exc) + useful_under_bottom_mm = None + useful_h = float( + self._resolve_shelf_useful_height_mm( + rack_type=rack_type, + shelf_index=shelf_index, + shelf_count=tree_shelf_count, + useful_values=useful_values, + useful_under_bottom_mm=useful_under_bottom_mm, + top_useful_default_mm=top_useful_default, + ) + ) + + top_limit_z = float(base_z + max(1.0, useful_h)) + if rack_type == "PALLET": + # Для всех уровней PALLET, у которых есть верхняя физическая полка, + # полезная высота считается только до bbox этой верхней полки минус 195 мм. + # Дополнительные ограничения для таких уровней не применяются. + if shelf_index < tree_shelf_count: + upper_shelf_bounds = self._rendered_shelf_bounds( + str(rack_id or ""), + str(slot_id or ""), + int(shelf_index), + ) + if upper_shelf_bounds is not None: + upper_cap_z = float(upper_shelf_bounds[5] - 195.0) + if upper_cap_z > base_z: + top_limit_z = float(upper_cap_z) + elif structure_bounds is not None and float(rack_top_z) > float(base_z): + # Верхний уровень PALLET (без верхней полки): ограничение по конструкции. + is_zone_cap_like = abs(float(rack_top_z) - float(slot_max_z)) <= 1.0 + if not is_zone_cap_like: + top_limit_z = float(min(top_limit_z, float(rack_top_z))) + elif structure_bounds is not None and float(rack_top_z) > float(base_z): + # Для A/B оставляем ограничение по конструкции. + is_zone_cap_like = abs(float(rack_top_z) - float(slot_max_z)) <= 1.0 + if not is_zone_cap_like: + top_limit_z = float(min(top_limit_z, float(rack_top_z))) + if top_limit_z <= base_z: + top_limit_z = float(base_z + 1.0) + + return (plan_min_x, plan_max_x, plan_min_y, plan_max_y, base_z, top_limit_z) + + def get_cell_slot_usable_dimensions_mm( + self: "ModelViewWidget", + rack_id: str, + slot_id: str, + *, + shelf_id: str = "", + shelf_number_index: int = 1, + rack_type: str = "", + ) -> tuple[float, float, float] | None: + """Публичный helper: usable размеры разметки ячеек (w, d, h) для слота/полки.""" + payload = { + "shelf_id": str(shelf_id or ""), + "shelf_number_index": int(max(1, int(shelf_number_index or 1))), + "rack_type": str(rack_type or ""), + } + bounds = self._resolve_cell_volume_limits(str(rack_id or ""), str(slot_id or ""), payload) + if bounds is None: + return None + min_x, max_x, min_y, max_y, min_z, max_z = bounds + return ( + max(0.0, float(max_x - min_x)), + max(0.0, float(max_y - min_y)), + max(0.0, float(max_z - min_z)), + ) + + def _resolve_cell_volume_bounds( + self: "ModelViewWidget", + rack_id: str, + slot_id: str, + payload: dict[str, Any], + ) -> tuple[float, float, float, float, float, float] | None: + limits = self._resolve_cell_volume_limits(rack_id, slot_id, dict(payload or {})) + if limits is None: + return None + min_x, max_x, min_y, max_y, base_z, top_limit_z = limits + grid_z = max(1, int(payload.get("grid_z") or 1)) + cell_h = max(1.0, float(payload.get("cell_height_mm") or 1.0)) + + requested_top = base_z + cell_h * float(grid_z) + top_z = min(top_limit_z, requested_top) + fit_count_z = max(0, int((top_z - base_z) // cell_h)) + top_z = base_z + fit_count_z * cell_h + if top_z <= base_z: + top_z = min(top_limit_z, base_z + cell_h) + return (min_x, max_x, min_y, max_y, base_z, top_z) + + @staticmethod + def _cell_grid_edges( + bounds: tuple[float, float, float, float, float, float], + grid_r: int, + grid_c: int, + grid_z: int, + ) -> tuple[list[float], list[float], list[float]]: + min_x, max_x, min_y, max_y, min_z, max_z = bounds + gx = max(1, int(grid_c or 1)) + gy = max(1, int(grid_r or 1)) + gz = max(1, int(grid_z or 1)) + dx = (max_x - min_x) / gx + dy = (max_y - min_y) / gy + dz = (max_z - min_z) / gz + xs = [float(min_x + dx * i) for i in range(gx + 1)] + ys = [float(min_y + dy * i) for i in range(gy + 1)] + zs = [float(min_z + dz * i) for i in range(gz + 1)] + return xs, ys, zs + + @staticmethod + def _axis_edges_from_step( + axis_min: float, + axis_max: float, + step: float, + requested_count: int, + ) -> list[float]: + lo = float(axis_min) + hi = float(axis_max) + cell_step = max(0.001, float(step)) + max_fit = max(0, int((hi - lo) // cell_step)) + count = max(0, min(int(requested_count or 0), max_fit)) + if count <= 0: + return [lo, min(hi, lo + cell_step)] + return [float(lo + i * cell_step) for i in range(count + 1)] + + @staticmethod + def _build_cell_grid_lines_mesh(xs: list[float], ys: list[float], zs: list[float]): + if not _PV: + return None + points: list[tuple[float, float, float]] = [] + lines: list[int] = [] + seen: set[tuple[tuple[float, float, float], tuple[float, float, float]]] = set() + + def _add_segment(p0: tuple[float, float, float], p1: tuple[float, float, float]) -> None: + key = (p0, p1) if p0 <= p1 else (p1, p0) + if key in seen: + return + seen.add(key) + i0 = len(points) + points.append(p0) + i1 = len(points) + points.append(p1) + lines.extend([2, i0, i1]) + + x0, x1 = xs[0], xs[-1] + y0, y1 = ys[0], ys[-1] + z0, z1 = zs[0], zs[-1] + + # Линии только на внешних поверхностях объёма (без внутренней 3D-сетки). + for y in ys: + _add_segment((x0, y, z0), (x1, y, z0)) + _add_segment((x0, y, z1), (x1, y, z1)) + for z in zs: + _add_segment((x0, y0, z), (x1, y0, z)) + _add_segment((x0, y1, z), (x1, y1, z)) + + for x in xs: + _add_segment((x, y0, z0), (x, y1, z0)) + _add_segment((x, y0, z1), (x, y1, z1)) + for z in zs: + _add_segment((x0, y0, z), (x0, y1, z)) + _add_segment((x1, y0, z), (x1, y1, z)) + + for x in xs: + _add_segment((x, y0, z0), (x, y0, z1)) + _add_segment((x, y1, z0), (x, y1, z1)) + for y in ys: + _add_segment((x0, y, z0), (x0, y, z1)) + _add_segment((x1, y, z0), (x1, y, z1)) + + mesh = pv.PolyData(points) + mesh.lines = lines + return mesh + + def _closest_cell_index_to_camera( + self: "ModelViewWidget", + xs: list[float], + ys: list[float], + zs: list[float], + ) -> tuple[int, int, int]: + if not self._plotter: + return (0, 0, 0) + cam = getattr(self._plotter, "camera_position", None) + if not isinstance(cam, (list, tuple)) or len(cam) < 1: + return (0, 0, 0) + try: + cx, cy, cz = cam[0] + except Exception as exc: + log_exception(__name__, "_closest_cell_index_to_camera", exc) + return (0, 0, 0) + best = (0, 0, 0) + best_d2 = None + for ix in range(max(1, len(xs) - 1)): + mx = (xs[ix] + xs[ix + 1]) * 0.5 + for iy in range(max(1, len(ys) - 1)): + my = (ys[iy] + ys[iy + 1]) * 0.5 + for iz in range(max(1, len(zs) - 1)): + mz = (zs[iz] + zs[iz + 1]) * 0.5 + d2 = (mx - float(cx)) ** 2 + (my - float(cy)) ** 2 + (mz - float(cz)) ** 2 + if best_d2 is None or d2 < best_d2: + best_d2 = d2 + best = (ix, iy, iz) + return best + + def _draw_ref_cell_single_dim( + self: "ModelViewWidget", + slot_key: tuple[Any, ...], + cell_bounds: tuple[float, float, float, float, float, float], + *, + color: str = "#4DB3FF", + text_color: str | None = None, + label_override: str | None = None, + ) -> None: + """Один вертикальный размер (высота ячейки) у правого переднего ребра.""" + if not self._plotter or not _PV: + return + min_x, max_x, min_y, max_y, min_z, max_z = cell_bounds + cell_h = max(1.0, float(max_z - min_z)) + offset_xy = max(0.02, cell_h * 0.005) + lx = max_x + offset_xy + ly = min_y - offset_xy + label_lift = max(1.0, cell_h * 0.12) + line = pv.Line((lx, ly, min_z), (lx, ly, max_z)) + label_text = label_override if label_override is not None else f"{cell_h:.0f} мм" + label_color = str(text_color or color) + actors: list[Any] = [] + try: + actors.append(self._plotter.add_mesh( + line, color=str(color), line_width=2.0, pickable=False, reset_camera=False, + )) + actors.append(self._plotter.add_point_labels( + [((lx, ly, (min_z + max_z) * 0.5 + label_lift))], + [label_text], + font_size=16, + text_color=label_color, + shape=None, + show_points=False, + always_visible=True, + pickable=False, + reset_camera=False, + )) + except Exception as exc: + log_exception(__name__, "_draw_ref_cell_single_dim", exc) + for actor in actors: + try: + self._plotter.remove_actor(actor) + except Exception as _exc: + log_exception(__name__, "_draw_ref_cell_single_dim", _exc) + actors = [] + existing = list(self._cell_dim_actors.get(slot_key) or []) + self._cell_dim_actors[slot_key] = existing + actors + + def _draw_cell_dimension_lines( + self: "ModelViewWidget", + slot_key: tuple[Any, ...], + cell_bounds: tuple[float, float, float, float, float, float], + *, + color: str = "#4DB3FF", + text_color: str | None = None, + ) -> None: + if not self._plotter or not _PV: + return + self._clear_cell_actor_key(self._cell_dim_actors, slot_key) + min_x, max_x, min_y, max_y, min_z, max_z = cell_bounds + cell_h = max(1.0, float(max_z - min_z)) + offset = max(0.05, min(0.5, cell_h * 0.01)) + label_lift = max(2.0, min(10.0, cell_h * 0.25)) + line_x = pv.Line((min_x, min_y, min_z + offset), (max_x, min_y, min_z + offset)) + line_y = pv.Line((max_x, min_y, min_z + offset), (max_x, max_y, min_z + offset)) + line_z = pv.Line((max_x, max_y, min_z + offset), (max_x, max_y, max_z + offset)) + label_points = [ + ((min_x + max_x) * 0.5, min_y, min_z + label_lift), + (max_x, (min_y + max_y) * 0.5, min_z + label_lift), + (max_x, max_y, (min_z + max_z) * 0.5 + label_lift), + ] + labels = [ + f"{abs(max_x - min_x):.0f} мм", + f"{abs(max_y - min_y):.0f} мм", + f"{abs(max_z - min_z):.0f} мм", + ] + actors: list[Any] = [] + label_color = str(text_color or color) + try: + actors.append(self._plotter.add_mesh(line_x, color=str(color), line_width=2.4, pickable=False, reset_camera=False)) + actors.append(self._plotter.add_mesh(line_y, color=str(color), line_width=2.4, pickable=False, reset_camera=False)) + actors.append(self._plotter.add_mesh(line_z, color=str(color), line_width=2.4, pickable=False, reset_camera=False)) + actors.append( + self._plotter.add_point_labels( + label_points, + labels, + font_size=18, + text_color=label_color, + shape=None, + show_points=False, + always_visible=True, + pickable=False, + reset_camera=False, + ) + ) + except Exception as exc: + log_exception(__name__, "_draw_cell_dimension_lines", exc) + for actor in actors: + try: + self._plotter.remove_actor(actor) + except Exception as _exc: + log_exception(__name__, "_draw_cell_dimension_lines", _exc) + actors = [] + self._cell_dim_actors[slot_key] = actors + + def _render_cell_grid_for_slot( + self: "ModelViewWidget", + rack_id: str, + slot_id: str, + payload: dict[str, Any] | None, + *, + preview: bool, + ) -> bool: + if not _PV or not self._plotter: + return False + rid = str(rack_id or "") + sid = str(slot_id or "") + if not rid or not sid: + return False + raw = dict(payload or {}) + shelf_index = max(1, int(raw.get("shelf_number_index") or 1)) + grid_r = max(1, int(raw.get("grid_r") or 1)) + grid_c = max(1, int(raw.get("grid_c") or 1)) + grid_z = max(1, int(raw.get("grid_z") or 1)) + cell_w = max(1.0, float(raw.get("cell_width_mm") or 1.0)) + cell_d = max(1.0, float(raw.get("cell_depth_mm") or 1.0)) + cell_h = max(1.0, float(raw.get("cell_height_mm") or 1.0)) + step_mm = max(1.0, float(raw.get("step_mm") or 10.0)) + bounds = self._resolve_cell_volume_bounds(rid, sid, raw) + if bounds is None: + return False + slot_key = (rid, sid, int(shelf_index)) + target_storage = self._cell_preview_actors if preview else self._cell_final_actors + self._clear_cell_actor_key(target_storage, slot_key) + self._clear_cell_actor_key(self._cell_dim_actors, slot_key) + min_x, max_x, min_y, max_y, min_z, max_z = bounds + xs = self._axis_edges_from_step(min_x, max_x, cell_w, grid_c) + ys = self._axis_edges_from_step(min_y, max_y, cell_d, grid_r) + zs = self._axis_edges_from_step(min_z, max_z, cell_h, grid_z) + if len(xs) < 2 or len(ys) < 2 or len(zs) < 2: + return False + max_x = xs[-1] + max_y = ys[-1] + max_z = zs[-1] + actors: list[Any] = [] + opacity = 0.18 if preview else 0.95 + if preview: + color = "#AFC1CF" + else: + color = self._cell_volume_color(cell_h, step_mm) + try: + body = pv.Box(bounds=(min_x, max_x, min_y, max_y, min_z, max_z)) + actor_body = self._plotter.add_mesh( + body, + color=color, + opacity=opacity, + show_edges=False, + pickable=False, + reset_camera=False, + ) + actors.append(actor_body) + actor_body_edges = self._plotter.add_mesh( + body, + style="wireframe", + color="#00D6FF", + line_width=2.0, + opacity=1.0, + pickable=False, + reset_camera=False, + ) + actors.append(actor_body_edges) + except Exception as _exc: + log_exception(__name__, "_render_cell_grid_for_slot", _exc) + if preview: + try: + line_mesh = self._build_cell_grid_lines_mesh(xs, ys, zs) + if line_mesh is not None: + actor_lines = self._plotter.add_mesh( + line_mesh, + color="#00D6FF", + line_width=2.2, + opacity=1.0, + pickable=False, + reset_camera=False, + ) + actors.append(actor_lines) + except Exception as _exc: + log_exception(__name__, "_render_cell_grid_for_slot", _exc) + # Эталонная ячейка в базовом углу: наглядный единичный объём. + reference_cell_bounds = ( + xs[0], + xs[1], + ys[0], + ys[1], + zs[0], + zs[1], + ) + try: + ref_box = pv.Box(bounds=reference_cell_bounds) + ref_actor = self._plotter.add_mesh( + ref_box, + color="#FFD400", + opacity=0.95, + show_edges=False, + pickable=False, + reset_camera=False, + ) + actors.append(ref_actor) + ref_actor_edges = self._plotter.add_mesh( + ref_box, + style="wireframe", + color="#00D6FF", + line_width=1.1, + opacity=1.0, + pickable=False, + reset_camera=False, + ) + actors.append(ref_actor_edges) + except Exception as _exc: + log_exception(__name__, "_render_cell_grid_for_slot", _exc) + target_storage[slot_key] = actors + # Размерные линии: в preview — все три оси; в final — сохраняем bounds для сводного label. + if preview: + self._draw_cell_dimension_lines( + slot_key, + reference_cell_bounds, + color="#000000", + text_color="#000000", + ) + else: + # Сохраняем эталонные границы — сводная метка строится в _rebuild_rack_cell_dim_summary. + if not hasattr(self, "_cell_ref_bounds"): + self._cell_ref_bounds = {} + self._cell_ref_bounds[slot_key] = reference_cell_bounds + self._clear_cell_actor_key(self._cell_dim_actors, slot_key) + self._apply_cell_grid_edit_isolation_visibility() + if hasattr(self, "_safe_render"): + self._safe_render(min_interval_s=1.0 / 75.0) + else: + self._plotter.update() + return bool(actors) + + def render_cell_grid_preview_for_slot( + self: "ModelViewWidget", + rack_id: str, + slot_id: str, + payload: dict[str, Any] | None, + ) -> bool: + return self._render_cell_grid_for_slot(rack_id, slot_id, payload, preview=True) + + def render_cell_grid_final_for_slot( + self: "ModelViewWidget", + rack_id: str, + slot_id: str, + payload: dict[str, Any] | None, + ) -> bool: + raw = dict(payload or {}) + preview_key = ( + str(rack_id or ""), + str(slot_id or ""), + int(max(1, int(raw.get("shelf_number_index") or 1))), + ) + # Удаляем preview-акторы ДО финального рендера, чтобы сетка не мигала. + self._clear_cell_actor_key(self._cell_preview_actors, preview_key) + ok = self._render_cell_grid_for_slot(rack_id, slot_id, payload, preview=False) + return ok + + def _render_shelves_for_slot(self: "ModelViewWidget", rack_id: str, slot_id: str, payload: dict[str, Any] | None = None) -> dict[str, Any]: + if not _PV or not self._plotter: + return {} + rid = str(rack_id or "") + sid = str(slot_id or "") + if not rid or not sid: + return {} + entry = self._get_rack_entry(rid, self._shelf_target_zone_id) + if entry is None: + return {} + rack_rotation = int(entry.get("rotation", 0)) % 360 + slot_actor = (entry.get("slot_actors") or {}).get(sid) + if slot_actor is None: + return {} + previous_payload = dict(self._rack_shelf_params.get((rid, sid)) or {}) + source_payload = payload if payload is not None else previous_payload + payload = dict(source_payload or {}) + params = dict(entry.get("params") or {}) + rack_type = str(params.get("rack_type") or payload.get("rack_type") or "A") + norms = self._shelf_norms_for_rack_type(rack_type) + depth = int(params.get("depth_mm") or params.get("footprint_depth_mm") or payload.get("shelf_depth_mm") or 500) + spans = [int(v) for v in (params.get("span_widths_mm") or []) if int(v) > 0] + span_index = self._slot_index_from_id(sid) + if 0 <= span_index < len(spans): + span_width = spans[span_index] + elif spans: + span_width = spans[0] + else: + span_width = int(params.get("footprint_width_mm") or 1000) + + base_mesh = self._get_shelf_base_mesh(rack_type, depth, span_width) + if base_mesh is None: + return {} + bounds = slot_actor.GetBounds() + min_x, max_x, min_y, max_y, min_z, max_z = [float(v) for v in bounds] + useful_h = float(payload.get("useful_height_1_mm") or 400.0) + start_h = float(payload.get("bottom_shelf_height_mm") or 0.0) + count = max(1, int(payload.get("shelves_count") or 1)) + step = float(norms["step_mm"]) + shelf_h = float(norms["shelf_height_mm"]) + min_inter = float(norms["min_inter_shelf_mm"]) + base_offset_top = float(norms["base_surface_offset_from_bbox_top_mm"]) + min_base = float(norms["min_base_height_mm"]) + min_useful = float(norms["min_useful_height_mm"]) + mode = str(payload.get("height_mode") or "uniform").strip().lower() + useful_values_raw = payload.get("useful_heights_mm") + useful_values = [float(v) for v in useful_values_raw] if isinstance(useful_values_raw, list) and useful_values_raw else [float(useful_h)] + start_h = max(min_base, start_h) + start_h = self._round_up_to_step(start_h, step, start_from=min_base) + useful_values = [ + self._round_up_to_step(max(min_useful, val), step, start_from=min_useful) + for val in useful_values + ] + + def _inter_gap(index: int) -> float: + source_idx = 0 if mode != "individual" else min(index, len(useful_values) - 1) + requested_inter = float(useful_values[source_idx]) + shelf_h + inter_mm = max(min_inter, requested_inter) + return self._round_up_to_step(inter_mm, step, start_from=min_inter) + + min_new_gap = self._round_up_to_step( + max(min_inter, float(min_useful + shelf_h)), + step, + start_from=min_inter, + ) + + previous_mode = str(previous_payload.get("height_mode") or "uniform").strip().lower() + previous_count = max(1, int(previous_payload.get("shelves_count") or 1)) + previous_useful_raw = previous_payload.get("useful_heights_mm") + previous_useful = ( + [float(v) for v in previous_useful_raw] + if isinstance(previous_useful_raw, list) and previous_useful_raw + else [float(previous_payload.get("useful_height_1_mm") or min_useful)] + ) + previous_useful = [self._round_up_to_step(max(min_useful, value), step) for value in previous_useful] + previous_rel_heights_raw = previous_payload.get("shelf_base_heights_mm") + previous_rel_heights = ( + [float(v) for v in previous_rel_heights_raw] + if isinstance(previous_rel_heights_raw, list) and previous_rel_heights_raw + else [] + ) + payload_rel_heights_raw = payload.get("shelf_base_heights_mm") + payload_rel_heights = ( + [float(v) for v in payload_rel_heights_raw] + if isinstance(payload_rel_heights_raw, list) and payload_rel_heights_raw + else [] + ) + can_shift_stack = ( + bool(previous_rel_heights) + and previous_count == count + and previous_mode == mode + and previous_useful == useful_values + ) + relative_heights: list[float] = [] + if payload_rel_heights: + # Для сохранённых полок предпочитаем явно сохранённые базовые высоты, + # чтобы загруженные стеллажи отображались сразу в сохранённых позициях. + relative_heights = [max(min_base, float(value)) for value in payload_rel_heights[:count]] + while len(relative_heights) < count: + last_height = float(relative_heights[-1]) if relative_heights else float(start_h) + relative_heights.append(float(last_height + min_new_gap)) + elif can_shift_stack: + old_start = float(previous_payload.get("bottom_shelf_height_mm") or previous_rel_heights[0]) + delta = float(start_h - old_start) + relative_heights = [max(min_base, float(value) + delta) for value in previous_rel_heights[:count]] + elif ( + bool(previous_rel_heights) + and previous_count < count + and previous_mode == mode + ): + relative_heights = [float(value) for value in previous_rel_heights[:previous_count]] + if mode == "individual": + if len(relative_heights) >= 2: + extension_gap = max(min_new_gap, float(relative_heights[-1] - relative_heights[-2])) + else: + extension_gap = _inter_gap(max(0, previous_count - 1)) + else: + extension_gap = _inter_gap(0) + while len(relative_heights) < count: + last_height = float(relative_heights[-1]) if relative_heights else float(start_h) + relative_heights.append(float(last_height + extension_gap)) + else: + offset_from_start = 0.0 + for index in range(count): + relative_heights.append(float(start_h + offset_from_start)) + if index + 1 < count: + offset_from_start += _inter_gap(index) + + self._clear_slot_shelf_actors(rid, sid) + slot_key = (rid, sid) + self._rack_shelf_actors[slot_key] = [] + mb = base_mesh.bounds + local_top_z = float(mb[5]) + local_base_surface_z = local_top_z - base_offset_top + rack_bottom_z = min_z + rack_top_z = max_z + placed = 0 + placed_rel_heights: list[float] = [] + for index, rel_height in enumerate(relative_heights): + base_surface_world_z = rack_bottom_z + rel_height + # Полка должна полностью помещаться в габарит стеллажа. + shelf_top_world_z = float(base_surface_world_z + base_offset_top) + if shelf_top_world_z > rack_top_z: + continue + mesh = base_mesh.copy(deep=True) + if rack_rotation: + mesh.rotate_z(float(rack_rotation), point=(0.0, 0.0, 0.0), inplace=True) + mesh.translate( + ( + (min_x + max_x) / 2.0, + (min_y + max_y) / 2.0, + base_surface_world_z - local_base_surface_z, + ), + inplace=True, + ) + try: + actor_opacity = 0.95 + if hasattr(self, "_shelf_cell_visual_opacity"): + actor_opacity = float(self._shelf_cell_visual_opacity(rid, sid, index + 1)) + actor = self._plotter.add_mesh( + mesh, + color=self._sanitize_shelf_color(payload.get("shelf_color")), + opacity=float(actor_opacity), + show_edges=False, + pickable=False, + render=False, + ) + except TypeError: + actor_opacity = 0.95 + if hasattr(self, "_shelf_cell_visual_opacity"): + actor_opacity = float(self._shelf_cell_visual_opacity(rid, sid, index + 1)) + actor = self._plotter.add_mesh( + mesh, + color=self._sanitize_shelf_color(payload.get("shelf_color")), + opacity=float(actor_opacity), + show_edges=False, + pickable=False, + ) + self._rack_shelf_actors[slot_key].append(actor) + placed += 1 + placed_rel_heights.append(float(rel_height)) + self._plotter.update() + requested_count = max(1, int(count)) + valid_count = max(0, int(placed)) + has_overflow = bool(valid_count < requested_count) + normalized: dict[str, Any] = dict(payload) + normalized["rack_type"] = str(rack_type).strip().upper() + normalized["shelf_depth_mm"] = int(depth) + normalized["shelf_width_mm"] = int(span_width) + normalized["bottom_shelf_height_mm"] = float(relative_heights[0] if relative_heights else start_h) + normalized["shelves_count"] = int(requested_count) + normalized["valid_shelves_count"] = int(valid_count) + normalized["placement_overflow"] = bool(has_overflow) + normalized["height_mode"] = "individual" if mode == "individual" else "uniform" + normalized["shelf_base_heights_mm"] = [float(value) for value in relative_heights] + normalized["shelf_base_heights_valid_mm"] = [float(value) for value in placed_rel_heights] + normalized_values = useful_values[:max(1, requested_count - 1)] if mode == "individual" else useful_values[:1] + if not normalized_values: + normalized_values = [float(min_useful)] + normalized["useful_heights_mm"] = [float(v) for v in normalized_values] + normalized["useful_height_1_mm"] = float(normalized_values[0]) + return normalized + + @staticmethod + def _sanitize_shelf_color(value: Any) -> str: + raw = str(value or "").strip().upper() + if raw.startswith("#") and len(raw) == 9: + return raw[:7] + if raw.startswith("#") and len(raw) == 7: + return raw + return "#59B6E6" + + @staticmethod + def _slot_index_from_id(slot_id: str) -> int: + match = re.search(r"(\d+)$", str(slot_id or "")) + if not match: + return 0 + return max(0, int(match.group(1)) - 1) + + def _shelf_norms_for_rack_type(self: "ModelViewWidget", rack_type: str) -> dict[str, float]: + key = str(rack_type or "").strip().upper() + return dict(self._SHELF_NORMS.get(key, self._SHELF_NORMS["PALLET"])) + + @staticmethod + def _round_up_to_step(value: float, step: float, start_from: float = 0.0) -> float: + step_value = max(0.001, float(step)) + base = float(start_from) + current = max(base, float(value)) + delta = max(0.0, current - base) + multiplier = int((delta + step_value - 1e-9) // step_value) + stepped = base + multiplier * step_value + if stepped < current: + stepped += step_value + return float(stepped) + + def _get_shelf_base_mesh(self: "ModelViewWidget", rack_type: str, depth_mm: int, width_mm: int): + if not _PV: + return None + kind = str(rack_type or "").strip().upper() + depth = int(depth_mm or 500) + width = int(width_mm or 1000) + key = (kind, depth, width) + cached = self._shelf_mesh_cache.get(key) + if cached is not None: + return cached + path = self._find_shelf_model_path(kind, depth, width) + if path is None or not path.exists(): + return None + try: + mesh = pv.read(str(path)) + bounds = mesh.bounds + cx = (bounds[0] + bounds[1]) / 2.0 + cy = (bounds[2] + bounds[3]) / 2.0 + cz = (bounds[4] + bounds[5]) / 2.0 + mesh.translate((-cx, -cy, -cz), inplace=True) + self._shelf_mesh_cache[key] = mesh + return mesh + except Exception as exc: + log_exception(__name__, "_get_shelf_base_mesh", exc) + return None + + def _find_shelf_model_path(self: "ModelViewWidget", rack_type: str, depth_mm: int, width_mm: int) -> Path | None: + root = getattr(self, "_rack_models_root", None) + if root is None: + return None + kind = str(rack_type or "").strip().upper() + if kind == "A": + folder = Path(root) / "shelfs_type_a" + pattern = re.compile(r"shelf_(\d+)_(\d+)\.stl$", re.IGNORECASE) + elif kind == "B": + folder = Path(root) / "shelfs_type_b" + pattern = re.compile(r"shelf_type_b_(\d+)_(\d+)\.stl$", re.IGNORECASE) + else: + folder = Path(root) / "shelf_type_pallet" + pattern = re.compile(r"shelf_type_pallet_(\d+)_(\d+)\.stl$", re.IGNORECASE) + if not folder.exists(): + return None + best: tuple[int, Path] | None = None + target_d = int(depth_mm) + target_w = int(width_mm) + for file_path in folder.glob("*.stl"): + m = pattern.search(file_path.name) + if not m: + continue + d = int(m.group(1)) + w = int(m.group(2)) + score = abs(d - target_d) + abs(w - target_w) + if best is None or score < best[0]: + best = (score, file_path) + if score == 0: + break + return best[1] if best else None + + def _slot_contour_mesh(self: "ModelViewWidget", slot_actor): + if not _PV or slot_actor is None: + return None + try: + bounds = slot_actor.GetBounds() + except Exception as exc: + log_exception(__name__, "_slot_contour_mesh", exc) + return None + if not bounds: + return None + min_x, max_x, min_y, max_y, min_z, max_z = bounds + points = [ + (float(min_x), float(min_y), float(min_z)), + (float(max_x), float(min_y), float(min_z)), + (float(max_x), float(max_y), float(min_z)), + (float(min_x), float(max_y), float(min_z)), + (float(min_x), float(min_y), float(max_z)), + (float(max_x), float(min_y), float(max_z)), + (float(max_x), float(max_y), float(max_z)), + (float(min_x), float(max_y), float(max_z)), + ] + edges = ( + (0, 1), (1, 2), (2, 3), (3, 0), + (4, 5), (5, 6), (6, 7), (7, 4), + (0, 4), (1, 5), (2, 6), (3, 7), + ) + lines = [] + for i, j in edges: + lines.extend([2, i, j]) + contour = pv.PolyData(points) + contour.lines = lines + return contour + + +# --------------------------------------------------------------------------- +# Module workflow notes +# --------------------------------------------------------------------------- +# 1) Задача модуля: +# Построение и отрисовка геометрии полок. +# +# 2) Последовательность действий и вызовов: +# A. Класс RackShelfRenderMixin: точки входа +# Публичные методы отсутствуют; сценарий запускается через методы родительских модулей и внутренние обработчики. +# +# B. RackShelfRenderMixin: основной сценарий: +# RackShelfRenderMixin._find_shelf_model_path(...) +# Назначение: находит shelf model path в рамках текущего сценария модуля. +# +# C. RackShelfRenderMixin: завершение и очистка: +# RackShelfRenderMixin._clear_slot_shelf_actors(...) +# Назначение: очищает slot shelf actors в рамках текущего сценария модуля. +# +# D. RackShelfRenderMixin: вспомогательные расчёты: +# RackShelfRenderMixin._render_shelves_for_slot(...) +# Назначение: выполняет шаг "render shelves for slot" в рамках текущего сценария модуля. +# Последовательность внутренних вызовов: +# -> RackShelfRenderMixin._shelf_norms_for_rack_type(...) +# -> RackShelfRenderMixin._slot_index_from_id(...) +# -> RackShelfRenderMixin._get_shelf_base_mesh(...) +# -> RackShelfRenderMixin._round_up_to_step(...) +# -> RackShelfRenderMixin._clear_slot_shelf_actors(...) +# -> RackShelfRenderMixin._sanitize_shelf_color(...) +# RackShelfRenderMixin._sanitize_shelf_color(...) +# Назначение: очищает и нормализует shelf color в рамках текущего сценария модуля. +# RackShelfRenderMixin._slot_index_from_id(...) +# Назначение: выполняет шаг "slot index from id" в рамках текущего сценария модуля. +# RackShelfRenderMixin._shelf_norms_for_rack_type(...) +# Назначение: выполняет шаг "shelf norms for rack type" в рамках текущего сценария модуля. +# RackShelfRenderMixin._round_up_to_step(...) +# Назначение: выполняет шаг "round up to step" в рамках текущего сценария модуля. +# RackShelfRenderMixin._get_shelf_base_mesh(...) +# Назначение: возвращает shelf base mesh в рамках текущего сценария модуля. +# Последовательность внутренних вызовов: +# -> RackShelfRenderMixin._find_shelf_model_path(...) +# RackShelfRenderMixin._slot_contour_mesh(...) +# Назначение: выполняет шаг "slot contour mesh" в рамках текущего сценария модуля. +# +# 3) Важные ограничения и инварианты: +# - Модуль выполняется в составе ModelViewWidget и использует согласованные поля состояния self._... . +# - Все операции с актёрами и камерой выполняются только при валидном self._plotter с безопасной обработкой исключений. +# - Геометрическая визуализация зависит от pyvista/vtk; при недоступности модуль обязан завершать шаг без падения сценария. +# - Межмодульная связность: только через фасад model_view; прямые обращения между zone, rack, shelf, cell запрещены. +# - Очистка состояния должна быть идемпотентной: повторный вызов не меняет корректное состояние в ошибочное. +# +# 4) Правило сопровождения: +# - Любое изменение сценария должно сопровождаться обновлением этого блока с сохранением фактического порядка вызовов. +# - При добавлении метода указывать его место в цепочке сценария (запуск, основной шаг, завершение, вспомогательная логика). diff --git a/Dispatch_V0.1.1/gui/components/model_view/_mv_racks_visual.py b/Dispatch_V0.1.1/gui/components/model_view/_mv_racks_visual.py new file mode 100644 index 0000000..bceffa1 --- /dev/null +++ b/Dispatch_V0.1.1/gui/components/model_view/_mv_racks_visual.py @@ -0,0 +1,404 @@ +# -*- coding: utf-8 -*- +# gui/components/model_view/_mv_racks_visual.py +"""Визуализация стоек и вспомогательных объёмов в rack-placement.""" + +from __future__ import annotations + +import math +import re +from pathlib import Path +from typing import Any, TYPE_CHECKING + +try: + import pyvista as pv + + _PV = True +except ImportError: + _PV = False + +from gui.components.model_view._mv_rack_geometry import ( + MIN_AISLE_MM, + MIN_WALL_BUFFER_MM, + support_line_positions, +) +from gui.components.model_view._mv_racks_visual_models import RackVisualModelsMixin + +if TYPE_CHECKING: # pragma: no cover + from gui.components.model_view_widget import ModelViewWidget + + +class RackPlacementVisualMixin(RackVisualModelsMixin): + """Построение actor-ов стеллажа для предпросмотра и фиксации.""" + + def _spawn_rack_actors( + self: "ModelViewWidget", cx: float, cy: float, zone_id: str, + params: dict[str, Any], rotation: int, color: str, opacity: float, + lightweight: bool = False, + ) -> tuple[list, dict[str, Any]]: + if not _PV or not self._plotter: + return [], {} + z_ref = float(self._zone_heights.get(zone_id, (0.0, 0.0))[0]) + actors: list[Any] = [] + slot_actors: dict[str, Any] = {} + additional_model = dict(params.get("additional_model") or {}) + + def _add(m, c, o): + return self._plotter.add_mesh(m, color=c, opacity=o, show_edges=False) + + if lightweight: + if additional_model.get("model_path"): + body = self._build_additional_model_instance_mesh(cx, cy, z_ref, params, rotation) + else: + body = self._build_lightweight_body_mesh(cx, cy, z_ref, params, rotation) + if body is not None: + actors.append(_add(body, color, opacity)) + if not additional_model.get("model_path"): + for av in self._build_aisle_volumes(cx, cy, z_ref, params, rotation): + actors.append(_add(av, (1.0, 0.95, 0.10), max(0.55, opacity * 0.90))) + return actors, {} + + if additional_model.get("model_path"): + custom_mesh = self._build_additional_model_instance_mesh(cx, cy, z_ref, params, rotation) + if custom_mesh is not None: + actors.append(_add(custom_mesh, color, opacity)) + # Автоопределение опор, если они не сохранены в params (устаревшие данные). + if not additional_model.get("column_footings"): + bm, _w, _d, _h = self._get_additional_model_base_mesh(Path(additional_model["model_path"])) + if bm is not None: + footings, _ch = self._detect_column_footings(bm) + if footings: + additional_model["column_footings"] = footings + params.setdefault("additional_model", {})["column_footings"] = footings + footing_buffer = self._build_footing_buffers_mesh(cx, cy, z_ref, params, rotation) + if footing_buffer is not None: + actors.append(_add(footing_buffer, (0.10, 0.70, 1.00), max(0.75, opacity * 0.95))) + return actors, {} + + positions = support_line_positions(cx, cy, params, rotation) + pillars_mesh, pillar_height = self._build_pillars_mesh(positions, z_ref, params, rotation) + if pillars_mesh is not None: + actors.append(_add(pillars_mesh, color, opacity)) + for slot_id, mesh in self._build_rack_inner_slot_meshes( + cx, cy, z_ref, params, rotation, pillar_height, + ): + actor = _add(mesh, (0.45, 0.65, 0.95), max(0.12, opacity * 0.45)) + slot_actors[slot_id] = actor + actors.append(actor) + for av in self._build_aisle_volumes(cx, cy, z_ref, params, rotation): + actors.append(_add(av, (1.0, 0.95, 0.10), max(0.55, opacity * 0.90))) + buffer_mesh = self._build_buffer_mesh(cx, cy, z_ref, params, rotation) + if buffer_mesh is not None: + actors.append(_add(buffer_mesh, (0.10, 0.70, 1.00), max(0.75, opacity * 0.95))) + return actors, slot_actors + + # -- облегчённые / стоечные меши ----------------------------------------- + def _build_lightweight_body_mesh( + self: "ModelViewWidget", cx: float, cy: float, z_ref: float, + params: dict[str, Any], rotation: int, + ): + if not _PV: + return None + width = float(params.get("footprint_width_mm", 1000)) + depth = float(params.get("footprint_depth_mm", 500)) + height = max(100.0, float(params.get("footprint_height_mm", 1800))) + if int(rotation) % 180 == 90: + width, depth = depth, width + return pv.Cube( + center=(float(cx), float(cy), float(z_ref) + height / 2.0), + x_length=max(10.0, width), y_length=max(10.0, depth), z_length=height, + ) + + def _build_pillars_mesh( + self: "ModelViewWidget", positions: list[tuple[float, float]], + z_ref: float, params: dict[str, Any], rotation: int = 0, + ) -> tuple[Any, float]: + fallback_height = max(100.0, float(params.get("footprint_height_mm", 1800))) + if not _PV or not positions: + return None, fallback_height + base_mesh, pillar_height = self._get_pillar_base_mesh(params) + if base_mesh is None: + parts = [ + pv.Cube( + center=(px, py, z_ref + fallback_height / 2.0), + x_length=70.0, y_length=70.0, z_length=fallback_height, + ) + for px, py in positions + ] + return self._append_polydata_safe(parts), fallback_height + parts = [] + for px, py in positions: + item = base_mesh.copy(deep=True) + item.rotate_z(float(int(rotation) % 360), point=(0.0, 0.0, 0.0), inplace=True) + item.translate((px, py, z_ref), inplace=True) + parts.append(item) + return self._append_polydata_safe(parts), float(pillar_height) + + def _get_pillar_base_mesh(self: "ModelViewWidget", params: dict[str, Any]) -> tuple[Any, float]: + if not _PV: + return None, 1800.0 + rack_type = str(params.get("rack_type", "A")).upper() + depth = int(params.get("depth_mm", 500)) + key = (rack_type, depth) + cache = getattr(self, "_pillar_mesh_cache", {}) + if key in cache: + return cache[key] + def _put(v): + cache[key] = v; self._pillar_mesh_cache = cache; return v + path = self._find_pillar_model_path(rack_type, depth) + if path is None or not path.exists(): + return _put((None, 1800.0)) + try: + mesh = pv.read(str(path)) + b = mesh.bounds + mesh.translate((-((b[0]+b[1])/2), -((b[2]+b[3])/2), -b[4]), inplace=True) + return _put((mesh, max(100.0, float(b[5] - b[4])))) + except Exception as exc: + log_exception(__name__, "_get_pillar_base_mesh", exc) + return _put((None, 1800.0)) + + def _find_pillar_model_path(self: "ModelViewWidget", rack_type: str, depth: int) -> Path | None: + root = getattr(self, "_rack_models_root", None) + if root is None: + return None + folder = Path(root) / f"pillars_type_{str(rack_type).lower()}" + if not folder.exists(): + return None + token = re.escape(str(rack_type).lower()) + pat = re.compile(rf"pillar_type_{token}_(\d+)_(\d+)\.stl$", re.IGNORECASE) + best_path: Path | None = None + best_diff: int | None = None + for fp in folder.glob("*.stl"): + m = pat.search(fp.name) + if not m: + continue + diff = abs(int(m.group(1)) - int(depth)) + if best_diff is None or diff < best_diff: + best_diff, best_path = diff, fp + if diff == 0: + break + return best_path + + # -- внутренние секции / проход / буфер ----------------------------------- + def _build_rack_inner_slot_meshes( + self: "ModelViewWidget", cx: float, cy: float, z_ref: float, + params: dict[str, Any], rotation: int, pillar_height: float, + ) -> list[tuple[str, Any]]: + if not _PV: + return [] + depth = float(params.get("footprint_depth_mm", 500)) + center_spans = [int(v) for v in (params.get("center_spans_mm") or []) if int(v) > 0] + if not center_spans: + center_spans = [int(params.get("footprint_width_mm", 1000))] + z_len = max(100.0, float(pillar_height)) + z_center = z_ref + z_len / 2.0 + y_len = max(10.0, depth - 60.0) + if str(params.get("rack_type") or "").strip().lower() == "pallet": + z_len = max(100.0, float(params.get("footprint_height_mm", z_len))) + z_center = z_ref + z_len / 2.0 + y_len = max(10.0, float(params.get("footprint_depth_mm", 1100))) + total_width = float(params.get("footprint_width_mm", sum(center_spans))) + x_len = max(10.0, total_width) + if int(rotation) % 180 == 90: + x_len, y_len = y_len, x_len + mesh = pv.Cube( + center=(float(cx), float(cy), z_center), + x_length=x_len, y_length=y_len, z_length=z_len, + ) + return [("S1", mesh)] + total_width = float(sum(center_spans)) + x_cursor = -total_width / 2.0 + result: list[tuple[str, Any]] = [] + for idx, span in enumerate(center_spans, start=1): + span_len = max(10.0, float(span) - 60.0) + local_x = x_cursor + float(span) / 2.0 + world_dx, world_dy = self._rotate_local(local_x, 0.0, rotation) + xl, yl = span_len, y_len + if int(rotation) % 180 == 90: + xl, yl = yl, xl + mesh = pv.Cube( + center=(cx + world_dx, cy + world_dy, z_center), + x_length=xl, y_length=yl, z_length=z_len, + ) + result.append((f"S{idx}", mesh)) + x_cursor += float(span) + return result + + def _build_aisle_volumes( + self: "ModelViewWidget", cx: float, cy: float, z_ref: float, + params: dict[str, Any], rotation: int, + ) -> list[Any]: + if not _PV: + return [] + w = float(params.get("footprint_width_mm", 1000)) + d = float(params.get("footprint_depth_mm", 500)) + try: + a = float(self._rack_access_aisle_mm(params)) + except Exception as exc: + log_exception(__name__, "_build_aisle_volumes", exc) + a = float(MIN_AISLE_MM) + outer_left = -(w / 2.0 + a) + outer_right = (w / 2.0 + a) + y_near = d / 2.0 + y_far = d / 2.0 + a + + radius = min(900.0, a, (outer_right - outer_left) * 0.5) + arc_segments = 16 + + points_local: list[tuple[float, float]] = [] + points_local.append((outer_left, y_near)) + points_local.append((outer_right, y_near)) + points_local.append((outer_right, y_far - radius)) + + if radius > 1.0: + cx_r = outer_right - radius + cy_r = y_far - radius + for i in range(1, arc_segments + 1): + t = i / float(arc_segments) + ang = 0.0 + (1.5707963267948966 * t) + points_local.append((cx_r + radius * math.cos(ang), cy_r + radius * math.sin(ang))) + points_local.append((outer_left + radius, y_far)) + cx_l = outer_left + radius + cy_l = y_far - radius + for i in range(1, arc_segments + 1): + t = i / float(arc_segments) + ang = 1.5707963267948966 + (1.5707963267948966 * t) + points_local.append((cx_l + radius * math.cos(ang), cy_l + radius * math.sin(ang))) + else: + points_local.append((outer_right, y_far)) + points_local.append((outer_left, y_far)) + + points_local.append((outer_left, y_near)) + + base_z = float(z_ref) + 1.0 + points_world = [] + for lx, ly in points_local: + wx, wy = self._rotate_local(float(lx), float(ly), rotation) + points_world.append((float(cx + wx), float(cy + wy), base_z)) + + try: + face = [len(points_world)] + list(range(len(points_world))) + poly = pv.PolyData(points_world, faces=face) + mesh = poly.triangulate().extrude((0.0, 0.0, 5.0), capping=True) + if radius > 1.0: + # Для PALLET _rack_access_aisle_mm(params) возвращает 2300 мм. + # Используем то же значение для боковых доборов, чтобы зона + # доступа была консистентна по глубине и выносу. + side_access_mm = max(0.0, float(a)) + rear_limit_y = -d / 2.0 + tabs: list[Any] = [] + # Боковые доборы pallet: вдоль всей короткой стороны стеллажа + # (по глубине), с выносом по X от боковой грани на side_access_mm. + tab_rects = [ + [ + (w / 2.0, rear_limit_y), + (w / 2.0 + side_access_mm, rear_limit_y), + (w / 2.0 + side_access_mm, y_near), + (w / 2.0, y_near), + ], + [ + (-w / 2.0 - side_access_mm, rear_limit_y), + (-w / 2.0, rear_limit_y), + (-w / 2.0, y_near), + (-w / 2.0 - side_access_mm, y_near), + ], + ] + for rect in tab_rects: + rect_world = [] + for lx, ly in rect: + wx, wy = self._rotate_local(float(lx), float(ly), rotation) + rect_world.append((float(cx + wx), float(cy + wy), base_z)) + f = [len(rect_world)] + list(range(len(rect_world))) + tabs.append(pv.PolyData(rect_world, faces=f).triangulate().extrude((0.0, 0.0, 5.0), capping=True)) + merged = self._append_polydata_safe([mesh] + tabs) + if merged is not None: + mesh = merged + return [mesh] + except Exception as exc: + log_exception(__name__, "_build_aisle_volumes", exc) + return [] + + def _build_buffer_mesh( + self: "ModelViewWidget", cx: float, cy: float, z_ref: float, + params: dict[str, Any], rotation: int, + ): + if not _PV: + return None + depth = max(10.0, float(params.get("footprint_depth_mm", 500))) + b = float(MIN_WALL_BUFFER_MM) + center_spans = [float(v) for v in (params.get("center_spans_mm") or []) if float(v) > 0.0] + if center_spans: + inner_w = max(10.0, float(sum(center_spans)) + 70.0) + else: + inner_w = max(10.0, float(params.get("footprint_width_mm", 1000))) + inner_d = depth + outer_w = inner_w + 2.0 * b + outer_d = inner_d + 2.0 * b + tx = max(10.0, b) + ty = max(10.0, b) + strips = [ + (0.0, inner_d / 2.0 + ty / 2.0, outer_w, ty), + (0.0, -(inner_d / 2.0 + ty / 2.0), outer_w, ty), + (inner_w / 2.0 + tx / 2.0, 0.0, tx, outer_d), + (-(inner_w / 2.0 + tx / 2.0), 0.0, tx, outer_d), + ] + parts = [] + for lx, ly, xl, yl in strips: + wx, wy = self._rotate_local(lx, ly, rotation) + if int(rotation) % 180 == 90: + xl, yl = yl, xl + parts.append(pv.Cube( + center=(cx + wx, cy + wy, z_ref + 5.0), + x_length=max(10.0, xl), y_length=max(10.0, yl), z_length=10.0)) + return self._append_polydata_safe(parts) + + +# --------------------------------------------------------------------------- +# Module workflow notes +# --------------------------------------------------------------------------- +# 1) Задача модуля: +# Построение базовой визуальной геометрии стеллажей и служебных объёмов. +# +# 2) Последовательность действий и вызовов: +# A. Класс RackPlacementVisualMixin: точки входа +# Публичные методы отсутствуют; сценарий запускается через методы родительских модулей и внутренние обработчики. +# +# B. RackPlacementVisualMixin: основной сценарий: +# RackPlacementVisualMixin._find_pillar_model_path(...) +# Назначение: находит pillar model path в рамках текущего сценария модуля. +# +# C. RackPlacementVisualMixin: вспомогательные расчёты: +# RackPlacementVisualMixin._spawn_rack_actors(...) +# Назначение: выполняет шаг "spawn rack actors" в рамках текущего сценария модуля. +# Последовательность внутренних вызовов: +# -> RackPlacementVisualMixin._build_pillars_mesh(...) +# -> RackPlacementVisualMixin._build_rack_inner_slot_meshes(...) +# -> RackPlacementVisualMixin._build_aisle_volumes(...) +# -> RackPlacementVisualMixin._build_buffer_mesh(...) +# -> RackPlacementVisualMixin._build_lightweight_body_mesh(...) +# RackPlacementVisualMixin._build_lightweight_body_mesh(...) +# Назначение: строит lightweight body mesh в рамках текущего сценария модуля. +# RackPlacementVisualMixin._build_pillars_mesh(...) +# Назначение: строит pillars mesh в рамках текущего сценария модуля. +# Последовательность внутренних вызовов: +# -> RackPlacementVisualMixin._get_pillar_base_mesh(...) +# RackPlacementVisualMixin._get_pillar_base_mesh(...) +# Назначение: возвращает pillar base mesh в рамках текущего сценария модуля. +# Последовательность внутренних вызовов: +# -> RackPlacementVisualMixin._find_pillar_model_path(...) +# RackPlacementVisualMixin._build_rack_inner_slot_meshes(...) +# Назначение: строит rack inner slot meshes в рамках текущего сценария модуля. +# RackPlacementVisualMixin._build_aisle_volumes(...) +# Назначение: строит aisle volumes в рамках текущего сценария модуля. +# RackPlacementVisualMixin._build_buffer_mesh(...) +# Назначение: строит buffer mesh в рамках текущего сценария модуля. +# +# 3) Важные ограничения и инварианты: +# - Модуль выполняется в составе ModelViewWidget и использует согласованные поля состояния self._... . +# - Все операции с актёрами и камерой выполняются только при валидном self._plotter с безопасной обработкой исключений. +# - Геометрическая визуализация зависит от pyvista/vtk; при недоступности модуль обязан завершать шаг без падения сценария. +# - Межмодульная связность: только через фасад model_view; прямые обращения между zone, rack, shelf, cell запрещены. +# - Очистка состояния должна быть идемпотентной: повторный вызов не меняет корректное состояние в ошибочное. +# +# 4) Правило сопровождения: +# - Любое изменение сценария должно сопровождаться обновлением этого блока с сохранением фактического порядка вызовов. +# - При добавлении метода указывать его место в цепочке сценария (запуск, основной шаг, завершение, вспомогательная логика). diff --git a/Dispatch_V0.1.1/gui/components/model_view/_mv_racks_visual_models.py b/Dispatch_V0.1.1/gui/components/model_view/_mv_racks_visual_models.py new file mode 100644 index 0000000..c4594ae --- /dev/null +++ b/Dispatch_V0.1.1/gui/components/model_view/_mv_racks_visual_models.py @@ -0,0 +1,372 @@ +# -*- coding: utf-8 -*- +# gui/components/model_view/_mv_racks_visual_models.py +"""Логика импорта / обнаружения моделей для пользовательских STL-моделей стеллажей.""" + +from __future__ import annotations + +import math +from pathlib import Path +from typing import Any, TYPE_CHECKING + +try: + import pyvista as pv + + _PV = True +except ImportError: + _PV = False + +from gui.components.model_view._mv_rack_geometry import MIN_WALL_BUFFER_MM +from error_logger import log_exception + +if TYPE_CHECKING: # pragma: no cover + from gui.components.model_view_widget import ModelViewWidget + + +class RackVisualModelsMixin: + """Вспомогательные методы пользовательских моделей: импорт STL, определение опор, буферы.""" + + def build_additional_model_params(self: "ModelViewWidget", model_path: Path) -> dict[str, Any] | None: + mesh, width, depth, height = self._get_additional_model_base_mesh(Path(model_path)) + if mesh is None: + return None + width_mm = int(max(100, math.ceil(float(width) / 10.0) * 10)) + depth_mm = int(max(100, math.ceil(float(depth) / 10.0) * 10)) + height_mm = int(max(100, math.ceil(float(height) / 10.0) * 10)) + + # Определение опор колонн и высоты просвета из меша. + footings, clearance_h = self._detect_column_footings(mesh) + if clearance_h > 0: + height_mm = int(max(100, math.ceil(float(clearance_h) / 10.0) * 10)) + model_name = Path(model_path).name + lower_name = model_name.lower() + is_mezzanine = any(tag in lower_name for tag in ("mezo", "mezz", "мез", "mezan", "mezon")) + + return { + "rack_type": "CUSTOM", + "depth_mm": depth_mm, + "spans_count": 1, + "uniform_width": True, + "span_widths_mm": [width_mm], + "center_spans_mm": [width_mm], + "footprint_width_mm": width_mm, + "footprint_depth_mm": depth_mm, + "footprint_height_mm": height_mm, + "pillars_count": len(footings) if footings else 4, + "additional_model": { + "model_path": str(Path(model_path)), + "model_name": model_name, + "model_kind": "mezo" if is_mezzanine else "custom", + "is_mezzanine": bool(is_mezzanine), + "clearance_height_mm": height_mm, + "column_footings": footings, + }, + } + + def _build_additional_model_instance_mesh( + self: "ModelViewWidget", + cx: float, + cy: float, + z_ref: float, + params: dict[str, Any], + rotation: int, + ): + additional_model = dict(params.get("additional_model") or {}) + model_path = additional_model.get("model_path") + if not model_path: + return None + base_mesh, _w, _d, _h = self._get_additional_model_base_mesh(Path(model_path)) + if base_mesh is None: + return None + try: + mesh = base_mesh.copy(deep=True) + mesh.rotate_z(float(int(rotation) % 360), point=(0.0, 0.0, 0.0), inplace=True) + mesh.translate((float(cx), float(cy), float(z_ref)), inplace=True) + return mesh + except Exception as exc: + log_exception(__name__, "_build_additional_model_instance_mesh", exc) + return None + + def _get_additional_model_base_mesh(self: "ModelViewWidget", model_path: Path) -> tuple[Any, float, float, float]: + if not _PV: + return None, 0.0, 0.0, 0.0 + cache = getattr(self, "_additional_model_mesh_cache", {}) + key = str(Path(model_path).resolve()) + cached = cache.get(key) + if cached is not None: + return cached + file_path = Path(model_path) + if not file_path.is_absolute() and not file_path.exists(): + # Относительный путь из БД: пытаемся разрешить относительно корня проекта. + racks_root = getattr(self, "_rack_models_root", None) + if racks_root is not None: + try: + project_root = Path(racks_root).resolve().parent.parent + file_path = project_root / file_path + except Exception as _exc: + log_exception(__name__, "_get_additional_model_base_mesh", _exc) + if not file_path.exists(): + cache[key] = (None, 0.0, 0.0, 0.0) + self._additional_model_mesh_cache = cache + return cache[key] + try: + mesh = pv.read(str(file_path)) + if mesh is None: + cache[key] = (None, 0.0, 0.0, 0.0) + self._additional_model_mesh_cache = cache + return cache[key] + bounds = mesh.bounds + width = max(10.0, float(bounds[1] - bounds[0])) + depth = max(10.0, float(bounds[3] - bounds[2])) + height = max(10.0, float(bounds[5] - bounds[4])) + cx = (bounds[0] + bounds[1]) / 2.0 + cy = (bounds[2] + bounds[3]) / 2.0 + min_z = bounds[4] + mesh.translate((-cx, -cy, -min_z), inplace=True) + cache[key] = (mesh, width, depth, height) + self._additional_model_mesh_cache = cache + return cache[key] + except Exception as exc: + log_exception(__name__, "_get_additional_model_base_mesh", exc) + cache[key] = (None, 0.0, 0.0, 0.0) + self._additional_model_mesh_cache = cache + return cache[key] + + # -- утилиты --------------------------------------------------------------- + + def _append_polydata_safe(self: "ModelViewWidget", parts: list[Any]): + if not parts: + return None + try: + return pv.append_polydata(parts) + except Exception as exc: + log_exception(__name__, "_append_polydata_safe", exc) + merged = parts[0] + for item in parts[1:]: + try: + merged = merged.merge(item, merge_points=False) + except Exception as _exc: + log_exception(__name__, "_append_polydata_safe", _exc) + return merged + + def _rotate_local(self: "ModelViewWidget", x: float, y: float, rotation: int) -> tuple[float, float]: + rot = int(rotation) % 360 + if rot == 90: + return -y, x + if rot == 180: + return -x, -y + if rot == 270: + return y, -x + return x, y + + # -- Анализ опор колонн для пользовательских STL-моделей ------------------ + + def _detect_column_footings( + self: "ModelViewWidget", + base_mesh: Any, + ) -> tuple[list[dict[str, float]], float]: + """Определить позиции опор колонн и высоту просвета палубы из меша. + + Анализирует нижнюю геометрию меша, касающуюся основания, для поиска + изолированных кластеров (оснований колонн / опор). Также определяет высоту + просвета под нижней горизонтальной палубой между колоннами. + + Returns + ------- + footings : list[dict] + Каждый словарь содержит ключи ``cx``, ``cy``, ``x_min``, ``y_min``, + ``x_max``, ``y_max`` (все в локальных координатах модели, начало + в центре-снизу). + clearance_h : float + Высота (мм) от основания до нижней стороны самой низкой палубы, + перекрывающей пространство между колоннами. ``0.0``, если не удаётся определить. + """ + try: + import numpy as np + except ImportError: + return [], 0.0 + if base_mesh is None or not hasattr(base_mesh, "points"): + return [], 0.0 + + pts = np.asarray(base_mesh.points) + if len(pts) == 0: + return [], 0.0 + + z_min = float(pts[:, 2].min()) + z_max = float(pts[:, 2].max()) + total_h = z_max - z_min + + # --- 1. Собрать нижние точки (в пределах 20 мм от основания) --------- + eps_z = 20.0 + bottom_mask = pts[:, 2] < (z_min + eps_z) + bottom_xy = pts[bottom_mask][:, :2] + if len(bottom_xy) < 3: + return [], 0.0 + + # --- 2. Кластеризация на основе сетки (cell_size = 200 мм) ---------- + cell_size = 200.0 + from collections import defaultdict + + cell_pts: dict[tuple[int, int], list[int]] = defaultdict(list) + for idx in range(len(bottom_xy)): + x, y = float(bottom_xy[idx, 0]), float(bottom_xy[idx, 1]) + key = (int(round(x / cell_size)), int(round(y / cell_size))) + cell_pts[key].append(idx) + + if not cell_pts: + return [], 0.0 + + # Поиск объединений (Union-Find) + parent: dict[tuple[int, int], tuple[int, int]] = {k: k for k in cell_pts} + + def _find(a: tuple[int, int]) -> tuple[int, int]: + while parent[a] != a: + parent[a] = parent[parent[a]] + a = parent[a] + return a + + def _union(a: tuple[int, int], b: tuple[int, int]) -> None: + parent[_find(a)] = _find(b) + + for k in list(cell_pts.keys()): + for dx in (-1, 0, 1): + for dy in (-1, 0, 1): + n = (k[0] + dx, k[1] + dy) + if n in cell_pts: + _union(k, n) + + clusters: dict[tuple[int, int], list[int]] = defaultdict(list) + for k, indices in cell_pts.items(): + clusters[_find(k)].extend(indices) + + # Для определения колонн требуется не менее 2 изолированных кластеров. + if len(clusters) < 2: + return [], 0.0 + + footings: list[dict[str, float]] = [] + for indices in clusters.values(): + cxy = bottom_xy[indices] + x_min_f = float(cxy[:, 0].min()) + x_max_f = float(cxy[:, 0].max()) + y_min_f = float(cxy[:, 1].min()) + y_max_f = float(cxy[:, 1].max()) + footings.append({ + "cx": round((x_min_f + x_max_f) / 2.0, 1), + "cy": round((y_min_f + y_max_f) / 2.0, 1), + "x_min": round(x_min_f, 1), + "y_min": round(y_min_f, 1), + "x_max": round(x_max_f, 1), + "y_max": round(y_max_f, 1), + }) + + # --- 3. Определение высоты просвета ---------------------------------- + all_cx = [f["cx"] for f in footings] + all_cy = [f["cy"] for f in footings] + mid_x = (min(all_cx) + max(all_cx)) / 2.0 + mid_y = (min(all_cy) + max(all_cy)) / 2.0 + search_r = max(cell_size, total_h * 0.05) + centre_mask = ( + (np.abs(pts[:, 0] - mid_x) < search_r) + & (np.abs(pts[:, 1] - mid_y) < search_r) + ) + centre_z = pts[centre_mask, 2] if centre_mask.any() else np.array([]) + clearance_h = 0.0 + if len(centre_z) > 0: + lowest_centre_z = float(centre_z.min()) + clearance_h = max(0.0, lowest_centre_z - z_min) + + return footings, clearance_h + + def _build_footing_buffers_mesh( + self: "ModelViewWidget", + cx: float, + cy: float, + z_ref: float, + params: dict[str, Any], + rotation: int, + ): + """Построить единый внешний контур буферной зоны по крайним пяткам.""" + if not _PV: + return None + additional_model = dict(params.get("additional_model") or {}) + footings = additional_model.get("column_footings") + if not footings: + return None + + b = float(MIN_WALL_BUFFER_MM) + x_min = min(float(ft.get("x_min", 0.0)) for ft in footings) + x_max = max(float(ft.get("x_max", 0.0)) for ft in footings) + y_min = min(float(ft.get("y_min", 0.0)) for ft in footings) + y_max = max(float(ft.get("y_max", 0.0)) for ft in footings) + + inner_w = max(10.0, x_max - x_min) + inner_d = max(10.0, y_max - y_min) + local_cx = (x_min + x_max) / 2.0 + local_cy = (y_min + y_max) / 2.0 + outer_w = inner_w + 2.0 * b + outer_d = inner_d + 2.0 * b + strips = [ + (local_cx, local_cy + inner_d / 2.0 + b / 2.0, outer_w, b), + (local_cx, local_cy - (inner_d / 2.0 + b / 2.0), outer_w, b), + (local_cx + inner_w / 2.0 + b / 2.0, local_cy, b, outer_d), + (local_cx - (inner_w / 2.0 + b / 2.0), local_cy, b, outer_d), + ] + parts: list[Any] = [] + for lx, ly, xl, yl in strips: + wx, wy = self._rotate_local(lx, ly, rotation) + if int(rotation) % 180 == 90: + xl, yl = yl, xl + parts.append( + pv.Cube( + center=(cx + wx, cy + wy, z_ref + 5.0), + x_length=max(10.0, xl), + y_length=max(10.0, yl), + z_length=10.0, + ) + ) + return self._append_polydata_safe(parts) if parts else None + + +# --------------------------------------------------------------------------- +# Module workflow notes +# --------------------------------------------------------------------------- +# 1) Задача модуля: +# Дополнительные модели стеллажей и буферы оснований и опор. +# +# 2) Последовательность действий и вызовов: +# A. Класс RackVisualModelsMixin: точки входа +# Публичные методы сценария: +# - RackVisualModelsMixin.build_additional_model_params(...) +# +# B. RackVisualModelsMixin: вспомогательные расчёты: +# RackVisualModelsMixin.build_additional_model_params(...) +# Назначение: строит additional model params в рамках текущего сценария модуля. +# Последовательность внутренних вызовов: +# -> RackVisualModelsMixin._get_additional_model_base_mesh(...) +# -> RackVisualModelsMixin._detect_column_footings(...) +# RackVisualModelsMixin._build_additional_model_instance_mesh(...) +# Назначение: строит additional model instance mesh в рамках текущего сценария модуля. +# Последовательность внутренних вызовов: +# -> RackVisualModelsMixin._get_additional_model_base_mesh(...) +# RackVisualModelsMixin._get_additional_model_base_mesh(...) +# Назначение: возвращает additional model base mesh в рамках текущего сценария модуля. +# RackVisualModelsMixin._append_polydata_safe(...) +# Назначение: добавляет polydata safe в рамках текущего сценария модуля. +# RackVisualModelsMixin._rotate_local(...) +# Назначение: выполняет шаг "rotate local" в рамках текущего сценария модуля. +# RackVisualModelsMixin._detect_column_footings(...) +# Назначение: Определить позиции опор колонн и высоту просвета палубы из меша. +# RackVisualModelsMixin._build_footing_buffers_mesh(...) +# Назначение: Построить буферные контурные объёмы вокруг каждой опоры колонны. +# Последовательность внутренних вызовов: +# -> RackVisualModelsMixin._append_polydata_safe(...) +# -> RackVisualModelsMixin._rotate_local(...) +# +# 3) Важные ограничения и инварианты: +# - Модуль выполняется в составе ModelViewWidget и использует согласованные поля состояния self._... . +# - Геометрическая визуализация зависит от pyvista/vtk; при недоступности модуль обязан завершать шаг без падения сценария. +# - Межмодульная связность: только через фасад model_view; прямые обращения между zone, rack, shelf, cell запрещены. +# - Очистка состояния должна быть идемпотентной: повторный вызов не меняет корректное состояние в ошибочное. +# +# 4) Правило сопровождения: +# - Любое изменение сценария должно сопровождаться обновлением этого блока с сохранением фактического порядка вызовов. +# - При добавлении метода указывать его место в цепочке сценария (запуск, основной шаг, завершение, вспомогательная логика). diff --git a/Dispatch_V0.1.1/gui/components/model_view/_mv_scene_modes.py b/Dispatch_V0.1.1/gui/components/model_view/_mv_scene_modes.py new file mode 100644 index 0000000..4db3b25 --- /dev/null +++ b/Dispatch_V0.1.1/gui/components/model_view/_mv_scene_modes.py @@ -0,0 +1,163 @@ +# -*- coding: utf-8 -*- +# gui/components/model_view/_mv_scene_modes.py +"""Высокоуровневые переходы между режимами сцены для ModelViewWidget.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from gui.components.model_view_widget import ModelViewWidget + + +class SceneModesMixin: + """Фасад переходов между режимами facility/zone.""" + + def _apply_facility_shell_visibility(self: "ModelViewWidget") -> None: + """Профиль оболочки уровня facility: активен только пол, остальные слои отключены.""" + layer_defaults = ( + ("floor", True), + ("walls", False), + ("ceiling", False), + ("truss", False), + ) + for key, should_show in layer_defaults: + is_available = bool(self.has_model(key)) if hasattr(self, "has_model") else True + self.set_model_visibility(key, bool(should_show and is_available)) + + def set_zone_pick_mode(self: "ModelViewWidget", enabled: bool) -> None: + """Переключить режим выбора зоны с обновлением сцены.""" + self.set_zone_pick_enabled(bool(enabled)) + self.update_scene() + + def enter_facility_overview(self: "ModelViewWidget") -> None: + """Сбросить сцену к обзору уровня facility.""" + self.stop_select_rack_mode() + if hasattr(self, "stop_shelf_placement"): + self.stop_shelf_placement(clear_selection=True) + self.show_all_zones() + self.show_all_racks() + self._apply_facility_shell_visibility() + self.stop_rack_placement(clear_preview=True) + self.set_zone_pick_enabled(True) + self.update_scene() + + def enter_zone_overview(self: "ModelViewWidget", zone_id: str, *, focus: bool = True) -> None: + """Переключить сцену в режим контура зоны + выбор стеллажей.""" + zid = str(zone_id or "") + if not zid: + return + self.clear_selected_zone_highlight() + self.set_zone_pick_enabled(False) + if hasattr(self, "stop_shelf_placement"): + self.stop_shelf_placement(clear_selection=True) + self.show_zone_contour(zid) + self.show_only_zone_racks(zid) + self.stop_rack_placement(clear_preview=True) + self.start_select_rack_mode(zid) + if focus and hasattr(self, "focus_on_zone_isometric"): + self.focus_on_zone_isometric(zid) + self.update_scene() + + def prepare_rack_placement(self: "ModelViewWidget") -> None: + """Выйти из режима выбора стеллажа перед началом размещения.""" + self.stop_select_rack_mode() + self.update_scene() + + def restore_zone_rack_selection( + self: "ModelViewWidget", + zone_id: str | None, + *, + zone_view_active: bool, + ) -> None: + """Остановить превью размещения и восстановить режим выбора стеллажей.""" + self.stop_rack_placement(clear_preview=True) + zid = str(zone_id or "") + if zone_view_active and zid: + self.start_select_rack_mode(zid) + else: + self.stop_select_rack_mode() + self.update_scene() + + def apply_zone_edit_visibility(self: "ModelViewWidget") -> None: + """Скрыть слои сцены/стеллажи для упрощения редактирования контура зоны.""" + for key in ("floor", "walls", "ceiling", "truss"): + self.set_model_visibility(key, False) + self.stop_select_rack_mode() + self.show_only_zone_racks(None) + self.update_scene() + + def restore_zone_edit_visibility( + self: "ModelViewWidget", + *, + model_visibility: dict[str, bool] | None, + view_level: str, + zone_id: str | None, + rack_id: str | None, + ) -> None: + """Восстановить состояние сцены, сохранённое перед режимом редактирования зоны.""" + state = dict(model_visibility or {}) + for key in ("floor", "walls", "ceiling", "truss"): + self.set_model_visibility(key, bool(state.get(key, True))) + + zid = str(zone_id or "") + rid = str(rack_id or "") + level = str(view_level or "facility") + if level == "rack" and zid and rid and hasattr(self, "show_only_rack"): + self.show_only_rack(rid, zid) + elif level == "zone" and zid: + self.show_only_zone_racks(zid) + else: + self.show_all_racks() + self.update_scene() + + +# --------------------------------------------------------------------------- +# Module workflow notes +# --------------------------------------------------------------------------- +# 1) Задача модуля: +# Переключение режимов сцены и правил видимости между facility, zone и rack. +# +# 2) Последовательность действий и вызовов: +# A. Класс SceneModesMixin: точки входа +# Публичные методы сценария: +# - SceneModesMixin.set_zone_pick_mode(...) +# - SceneModesMixin.enter_facility_overview(...) +# - SceneModesMixin.enter_zone_overview(...) +# - SceneModesMixin.prepare_rack_placement(...) +# - SceneModesMixin.restore_zone_rack_selection(...) +# - SceneModesMixin.apply_zone_edit_visibility(...) +# - SceneModesMixin.restore_zone_edit_visibility(...) +# +# B. SceneModesMixin: запуск и настройка: +# SceneModesMixin.set_zone_pick_mode(...) +# Назначение: Переключить режим выбора зоны с обновлением сцены. +# SceneModesMixin.enter_facility_overview(...) +# Назначение: Сбросить сцену к обзору уровня facility. +# Последовательность внутренних вызовов: +# -> SceneModesMixin._apply_facility_shell_visibility(...) +# SceneModesMixin.enter_zone_overview(...) +# Назначение: Переключить сцену в режим контура зоны + выбор стеллажей. +# SceneModesMixin.prepare_rack_placement(...) +# Назначение: Выйти из режима выбора стеллажа перед началом размещения. +# +# C. SceneModesMixin: основной сценарий: +# SceneModesMixin._apply_facility_shell_visibility(...) +# Назначение: Профиль оболочки уровня facility: активен только пол, остальные слои отключены. +# SceneModesMixin.apply_zone_edit_visibility(...) +# Назначение: Скрыть слои сцены/стеллажи для упрощения редактирования контура зоны. +# +# D. SceneModesMixin: завершение и очистка: +# SceneModesMixin.restore_zone_rack_selection(...) +# Назначение: Остановить превью размещения и восстановить режим выбора стеллажей. +# SceneModesMixin.restore_zone_edit_visibility(...) +# Назначение: Восстановить состояние сцены, сохранённое перед режимом редактирования зоны. +# +# 3) Важные ограничения и инварианты: +# - Модуль выполняется в составе ModelViewWidget и использует согласованные поля состояния self._... . +# - Межмодульная связность: только через фасад model_view; прямые обращения между zone, rack, shelf, cell запрещены. +# - Очистка состояния должна быть идемпотентной: повторный вызов не меняет корректное состояние в ошибочное. +# +# 4) Правило сопровождения: +# - Любое изменение сценария должно сопровождаться обновлением этого блока с сохранением фактического порядка вызовов. +# - При добавлении метода указывать его место в цепочке сценария (запуск, основной шаг, завершение, вспомогательная логика). diff --git a/Dispatch_V0.1.1/gui/components/model_view/_mv_visual.py b/Dispatch_V0.1.1/gui/components/model_view/_mv_visual.py new file mode 100644 index 0000000..eb5b868 --- /dev/null +++ b/Dispatch_V0.1.1/gui/components/model_view/_mv_visual.py @@ -0,0 +1,625 @@ +# -*- coding: utf-8 -*- +# gui/components/_mv/_mv_visual.py +# Маркеры, квадранты, оси, координатные преобразования + +from __future__ import annotations + +import math +from typing import Optional, Tuple, TYPE_CHECKING +from error_logger import log_exception + +if TYPE_CHECKING: # pragma: no cover + from gui.components.model_view_widget import ModelViewWidget + +try: + import pyvista as pv + _PV = True +except ImportError: + _PV = False + + +class VisualHelpersMixin: + """Маркер начала координат, квадранты, оси, world↔display преобразования.""" + + # -- маркер начала координат ---------------------------------------------- + + _ORIGIN_MARKER_NAME = "__origin_marker" + _ORIGIN_PREVIEW_MARKER_NAME = "__origin_preview_marker" + + def show_origin_marker(self: "ModelViewWidget", x: float, y: float, z: float) -> None: + if not self._plotter: + return + try: + self._plotter.remove_actor(self._ORIGIN_MARKER_NAME) + except Exception as _exc: + log_exception(__name__, "show_origin_marker", _exc) + try: + sphere = pv.Sphere(radius=80, center=(x, y, z)) + self._origin_marker = self._plotter.add_mesh( + sphere, color=(1, 1, 0), opacity=0.8, name=self._ORIGIN_MARKER_NAME, + ) + self._plotter.update() + except Exception as _exc: + log_exception(__name__, "show_origin_marker", _exc) + def show_origin_preview_marker(self: "ModelViewWidget", x: float, y: float, z: float) -> None: + """Показать динамический маркер origin, следующий за курсором.""" + if not self._plotter: + return + try: + self._plotter.remove_actor(self._ORIGIN_PREVIEW_MARKER_NAME) + except Exception as _exc: + log_exception(__name__, "show_origin_preview_marker", _exc) + try: + sphere = pv.Sphere(radius=55, center=(x, y, z)) + self._origin_preview_marker = self._plotter.add_mesh( + sphere, + color=(0.2, 0.9, 1.0), + opacity=0.7, + name=self._ORIGIN_PREVIEW_MARKER_NAME, + ) + self._plotter.update() + except Exception as _exc: + log_exception(__name__, "show_origin_preview_marker", _exc) + def clear_origin_marker(self: "ModelViewWidget") -> None: + if not self._plotter: + return + try: + self._plotter.remove_actor(self._ORIGIN_MARKER_NAME) + except Exception as _exc: + log_exception(__name__, "clear_origin_marker", _exc) + self._origin_marker = None + try: + if hasattr(self, "_safe_render"): + self._safe_render(min_interval_s=0.05) + else: + self._plotter.render() + except Exception as _exc: + log_exception(__name__, "clear_origin_marker", _exc) + def clear_origin_preview_marker(self: "ModelViewWidget") -> None: + if not self._plotter: + return + try: + self._plotter.remove_actor(self._ORIGIN_PREVIEW_MARKER_NAME) + except Exception as _exc: + log_exception(__name__, "clear_origin_preview_marker", _exc) + self._origin_preview_marker = None + try: + if hasattr(self, "_safe_render"): + self._safe_render(min_interval_s=0.05) + else: + self._plotter.render() + except Exception as _exc: + log_exception(__name__, "clear_origin_preview_marker", _exc) + def clear_origin_markers(self: "ModelViewWidget") -> None: + """Очистить фиксированный и динамический маркеры origin.""" + self.clear_origin_preview_marker() + self.clear_origin_marker() + + # -- угловые точки -------------------------------------------------------- + + def set_corner_points(self: "ModelViewWidget", points: list[tuple[float, float, float]]) -> None: + self._corner_points = list(points) + + def get_corner_points(self: "ModelViewWidget") -> list[tuple[float, float, float]]: + return list(self._corner_points) + + # -- квадранты ------------------------------------------------------------- + + def show_quadrants(self: "ModelViewWidget", origin: Tuple[float, float, float], size: float = 6000.0) -> None: + if not self._plotter: + return + self.clear_quadrants() + x0, y0, z0 = origin + half = size / 2.0 + # Фиксированная высота квадранта: 100 мм. + z_min = z0 + z_max = z0 + 100.0 + + quadrant_bounds = [ + ((x0, x0 + half), (y0, y0 + half)), # 0: ++ + ((x0 - half, x0), (y0, y0 + half)), # 1: -+ + ((x0 - half, x0), (y0 - half, y0)), # 2: -- + ((x0, x0 + half), (y0 - half, y0)), # 3: +- + ] + + colors = [(0.6, 0.6, 0.6)] * 4 + + for idx, (x_range, y_range) in enumerate(quadrant_bounds): + x_min, x_max = x_range + y_min, y_max = y_range + box = pv.Box(bounds=(x_min, x_max, y_min, y_max, z_min, z_max)) + name = f"__quadrant_plane_{idx}" + actor = self._plotter.add_mesh( + box, + color=colors[idx], + opacity=0.12, + show_edges=True, + edge_color=(0.7, 0.7, 0.7), + line_width=1.0, + name=name, + ) + try: + actor.PickableOn() + except Exception as _exc: + log_exception(__name__, "show_quadrants", _exc) + self._quadrant_actors.append(actor) + + for idx, (x_range, y_range) in enumerate(quadrant_bounds): + x_min, x_max = x_range + y_min, y_max = y_range + center_x = (x_min + x_max) / 2.0 + center_y = (y_min + y_max) / 2.0 + label_pos = [center_x, center_y, z0 + 100] + + name = f"__quadrant_label_{idx}" + label_actor = self._plotter.add_point_labels( + [label_pos], + [f"Q{idx}"], + font_size=72, + text_color="yellow", + point_color="yellow", + point_size=30, + render_points_as_spheres=True, + always_visible=True, + shape_opacity=0.9, + name=name, + ) + try: + if label_actor is not None: + if hasattr(label_actor, "PickableOff"): + label_actor.PickableOff() + elif hasattr(label_actor, "SetPickable"): + label_actor.SetPickable(False) + except Exception as _exc: + log_exception(__name__, "show_quadrants", _exc) + self._quadrant_label_actors.append(name) + + self._plotter.update() + + def animate_focus_on_quadrants( + self: "ModelViewWidget", + *, + duration_ms: int = 420, + margin_factor: float = 1.2, + ) -> bool: + """Плавно сфокусировать камеру так, чтобы текущие квадранты целиком были в кадре.""" + if not self._plotter: + return False + cam = getattr(self._plotter, "camera", None) + if cam is None: + return False + actors = list(getattr(self, "_quadrant_actors", []) or []) + if not actors: + return False + try: + min_x = float("inf") + max_x = float("-inf") + min_y = float("inf") + max_y = float("-inf") + min_z = float("inf") + max_z = float("-inf") + for actor in actors: + if actor is None or not hasattr(actor, "GetBounds"): + continue + b = actor.GetBounds() + if not b or len(b) < 6: + continue + min_x = min(min_x, float(b[0])) + max_x = max(max_x, float(b[1])) + min_y = min(min_y, float(b[2])) + max_y = max(max_y, float(b[3])) + min_z = min(min_z, float(b[4])) + max_z = max(max_z, float(b[5])) + if not all(math.isfinite(v) for v in (min_x, max_x, min_y, max_y, min_z, max_z)): + return False + cx = (min_x + max_x) * 0.5 + cy = (min_y + max_y) * 0.5 + sx = max(1.0, max_x - min_x) + sy = max(1.0, max_y - min_y) + sz = max(1.0, max_z - min_z) + span = max(sx, sy, sz) * max(1.0, float(margin_factor)) + cz = min_z + (sz * 0.55) + + start_pos = tuple(float(v) for v in cam.GetPosition()) + start_focal = tuple(float(v) for v in cam.GetFocalPoint()) + start_up = tuple(float(v) for v in cam.GetViewUp()) + + vx, vy, vz = 1.0, -1.0, 0.72 + norm = (vx * vx + vy * vy + vz * vz) ** 0.5 + vx, vy, vz = vx / norm, vy / norm, vz / norm + ux, uy, uz = 0.0, 0.0, 1.0 + distance = max(3000.0, span * 2.35) + target_focal = (cx, cy, cz) + target_pos = ( + cx + vx * distance, + cy + vy * distance, + cz + vz * distance + (sz * 0.08), + ) + + if hasattr(self, "_animate_camera_transition"): + self._animate_camera_transition( + start_pos=start_pos, + start_focal=start_focal, + start_up=start_up, + target_pos=target_pos, + target_focal=target_focal, + target_up=(ux, uy, uz), + duration_ms=int(duration_ms), + steps=18, + ) + else: + cam.SetPosition(*target_pos) + cam.SetFocalPoint(*target_focal) + if hasattr(cam, "SetViewUp"): + cam.SetViewUp(ux, uy, uz) + if hasattr(self, "_reset_camera_clipping_range"): + self._reset_camera_clipping_range() + self._plotter.update() + return True + except Exception as exc: + log_exception(__name__, "animate_focus_on_quadrants", exc) + return False + + def show_grid_step_quadrants( + self: "ModelViewWidget", + origin: Tuple[float, float, float], + direction: Tuple[float, float], + step_values: list[int], + cell_size: float = 3000.0, + height: float = 100.0, + ) -> None: + """Показать 6 площадок выбора шага сетки (3x2) в положительных локальных координатах.""" + if not self._plotter: + return + self.clear_quadrants() + x0, y0, z0 = origin + dir_x = 1.0 if float(direction[0]) >= 0.0 else -1.0 + dir_y = 1.0 if float(direction[1]) >= 0.0 else -1.0 + z_min = float(z0) + z_max = float(z0) + float(height) + grid_actors = list(getattr(self, "_quadrant_grid_actors", []) or []) + for actor in grid_actors: + try: + self._plotter.remove_actor(actor) + except Exception as _exc: + log_exception(__name__, "show_grid_step_quadrants", _exc) + self._quadrant_grid_actors = [] + + def _add_segment( + pts: list[list[float]], + cells: list[int], + p1: tuple[float, float, float], + p2: tuple[float, float, float], + ) -> None: + i1 = len(pts) + pts.append([float(p1[0]), float(p1[1]), float(p1[2])]) + i2 = len(pts) + pts.append([float(p2[0]), float(p2[1]), float(p2[2])]) + cells.extend([2, i1, i2]) + + for idx, step in enumerate(step_values): + row = idx // 3 + col = idx % 3 + lx0 = float(col) * float(cell_size) + lx1 = float(col + 1) * float(cell_size) + ly0 = float(row) * float(cell_size) + ly1 = float(row + 1) * float(cell_size) + wx0 = float(x0) + lx0 * dir_x + wx1 = float(x0) + lx1 * dir_x + wy0 = float(y0) + ly0 * dir_y + wy1 = float(y0) + ly1 * dir_y + x_min, x_max = (min(wx0, wx1), max(wx0, wx1)) + y_min, y_max = (min(wy0, wy1), max(wy0, wy1)) + + box = pv.Box(bounds=(x_min, x_max, y_min, y_max, z_min, z_max)) + name = f"__quadrant_plane_{idx}" + actor = self._plotter.add_mesh( + box, + color=(0.6, 0.6, 0.6), + opacity=0.12, + show_edges=True, + edge_color=(0.7, 0.7, 0.7), + line_width=1.0, + name=name, + ) + try: + actor.PickableOn() + except Exception as _exc: + log_exception(__name__, "_add_segment", _exc) + self._quadrant_actors.append(actor) + + # Явная разметка сеткой с шагом площадки (поверх плоскости). + line_points: list[list[float]] = [] + line_cells: list[int] = [] + z_grid = float(z_max) + 0.8 + step_size = max(10.0, float(step)) + x_pos = float(x_min) + while x_pos <= float(x_max) + 1e-6: + _add_segment(line_points, line_cells, (x_pos, y_min, z_grid), (x_pos, y_max, z_grid)) + x_pos += step_size + y_pos = float(y_min) + while y_pos <= float(y_max) + 1e-6: + _add_segment(line_points, line_cells, (x_min, y_pos, z_grid), (x_max, y_pos, z_grid)) + y_pos += step_size + if line_points and line_cells: + try: + grid_lines = pv.PolyData(line_points) + grid_lines.lines = line_cells + grid_actor = self._plotter.add_mesh( + grid_lines, + color=(0.0, 0.95, 1.0), + line_width=2.0, + opacity=0.95, + lighting=False, + ) + self._quadrant_grid_actors.append(grid_actor) + except Exception as _exc: + log_exception(__name__, "_add_segment", _exc) + label_pos = [(x_min + x_max) / 2.0, (y_min + y_max) / 2.0, float(z0) + float(height)] + label_name = f"__quadrant_label_{idx}" + label_actor = self._plotter.add_point_labels( + [label_pos], + [f"{int(step)} мм"], + font_size=56, + text_color="yellow", + point_color="yellow", + point_size=24, + render_points_as_spheres=True, + always_visible=True, + shape_opacity=0.9, + name=label_name, + ) + try: + if label_actor is not None: + if hasattr(label_actor, "PickableOff"): + label_actor.PickableOff() + elif hasattr(label_actor, "SetPickable"): + label_actor.SetPickable(False) + except Exception as _exc: + log_exception(__name__, "_add_segment", _exc) + self._quadrant_label_actors.append(label_name) + + self._plotter.update() + + def highlight_quadrant(self: "ModelViewWidget", index: int) -> None: + if not self._plotter: + return + for i, actor in enumerate(self._quadrant_actors): + try: + if i == index: + actor.GetProperty().SetOpacity(1.0) + actor.GetProperty().SetColor(1.0, 1.0, 0.0) + else: + actor.GetProperty().SetOpacity(0.12) + actor.GetProperty().SetColor(0.6, 0.6, 0.6) + except Exception as _exc: + log_exception(__name__, "highlight_quadrant", _exc) + try: + if hasattr(self, "_safe_render"): + self._safe_render(min_interval_s=0.01) + else: + self._plotter.render() + except Exception as _exc: + log_exception(__name__, "highlight_quadrant", _exc) + def clear_quadrants(self: "ModelViewWidget") -> None: + if not self._plotter: + return + for actor in self._quadrant_actors: + try: + self._plotter.remove_actor(actor) + except Exception as _exc: + log_exception(__name__, "clear_quadrants", _exc) + self._quadrant_actors = [] + + for name in self._quadrant_label_actors: + try: + self._plotter.remove_actor(name) + except Exception as _exc: + log_exception(__name__, "clear_quadrants", _exc) + self._quadrant_label_actors = [] + for actor in list(getattr(self, "_quadrant_grid_actors", []) or []): + try: + self._plotter.remove_actor(actor) + except Exception as _exc: + log_exception(__name__, "clear_quadrants", _exc) + self._quadrant_grid_actors = [] + + try: + if hasattr(self, "_safe_render"): + self._safe_render(min_interval_s=0.05) + else: + self._plotter.render() + except Exception as _exc: + log_exception(__name__, "clear_quadrants", _exc) + # -- оси ------------------------------------------------------------------- + + def show_axes(self: "ModelViewWidget", origin: Tuple[float, float, float], dir_x: float, dir_y: float) -> None: + if not self._plotter: + return + for actor in self._axes_actors: + try: + self._plotter.remove_actor(actor) + except Exception as _exc: + log_exception(__name__, "show_axes", _exc) + self._axes_actors = [] + x0, y0, z0 = origin + axes = [ + ((dir_x, 0, 0), (1, 0, 0), "X"), # X: red + ((0, dir_y, 0), (0, 1, 0), "Y"), # Y: green + ((0, 0, 1), (0, 0, 1), "Z"), # Z-up: blue + ] + axis_len = 1500.0 + label_offset = 1700.0 + for direction, color, label in axes: + arrow = pv.Arrow(start=(x0, y0, z0), direction=direction, scale=axis_len) + actor = self._plotter.add_mesh( + arrow, + color=color, + opacity=1.0, + lighting=False, + ambient=1.0, + diffuse=0.0, + specular=0.0, + ) + self._axes_actors.append(actor) + try: + dx, dy, dz = float(direction[0]), float(direction[1]), float(direction[2]) + label_pos = [[x0 + dx * label_offset, y0 + dy * label_offset, z0 + dz * label_offset]] + label_actor = self._plotter.add_point_labels( + label_pos, + [label], + font_size=36, + text_color=color, + point_size=1, + shape_opacity=0.0, + always_visible=True, + ) + if label_actor is not None: + self._axes_actors.append(label_actor) + except Exception as _exc: + log_exception(__name__, "show_axes", _exc) + try: + if hasattr(self, "_reset_camera_clipping_range"): + self._reset_camera_clipping_range() + except Exception as _exc: + log_exception(__name__, "show_axes", _exc) + try: + if hasattr(self, "_safe_render"): + self._safe_render(min_interval_s=0.0) + else: + self._plotter.render() + except Exception as _exc: + log_exception(__name__, "show_axes", _exc) + # -- преобразования координат ---------------------------------------------- + + def world_to_display(self: "ModelViewWidget", x: float, y: float, z: float) -> Optional[Tuple[float, float]]: + if not self._plotter or not self._plotter.renderer: + return None + try: + self._plotter.renderer.SetWorldPoint(x, y, z, 1.0) + self._plotter.renderer.WorldToDisplay() + dx, dy, _ = self._plotter.renderer.GetDisplayPoint() + return (dx, dy) + except Exception as exc: + log_exception(__name__, "world_to_display", exc) + return None + + def pick_world(self: "ModelViewWidget", sx: float, sy: float) -> Optional[Tuple[float, float, float]]: + if not self._plotter or not self._plotter.renderer or not self._plotter.picker: + return None + try: + self._plotter.picker.Pick(sx, sy, 0, self._plotter.renderer) + pos = self._plotter.picker.GetPickPosition() + if pos is None: + return None + x, y, z = pos[0], pos[1], pos[2] + if all(math.isfinite(v) for v in (x, y, z)): + return (x, y, z) + except Exception as exc: + log_exception(__name__, "pick_world", exc) + return None + return None + + def screen_to_world_on_plane( + self: "ModelViewWidget", sx: float, sy: float, z_plane: float, + ) -> Optional[Tuple[float, float, float]]: + """Преобразовать экранные координаты в мировые на плоскости Z=z_plane.""" + if not self._plotter or not self._plotter.renderer: + return None + try: + renderer = self._plotter.renderer + renderer.SetDisplayPoint(sx, sy, 0) + renderer.DisplayToWorld() + p0 = renderer.GetWorldPoint() + renderer.SetDisplayPoint(sx, sy, 1) + renderer.DisplayToWorld() + p1 = renderer.GetWorldPoint() + if p0[3] == 0 or p1[3] == 0: + return None + p0 = (p0[0] / p0[3], p0[1] / p0[3], p0[2] / p0[3]) + p1 = (p1[0] / p1[3], p1[1] / p1[3], p1[2] / p1[3]) + + dz = p1[2] - p0[2] + if dz == 0: + return None + t = (z_plane - p0[2]) / dz + wx = p0[0] + t * (p1[0] - p0[0]) + wy = p0[1] + t * (p1[1] - p0[1]) + return (wx, wy, z_plane) + except Exception as exc: + log_exception(__name__, "screen_to_world_on_plane", exc) + return None + + +# --------------------------------------------------------------------------- +# Module workflow notes +# --------------------------------------------------------------------------- +# 1) Задача модуля: +# Визуальные вспомогательные функции: маркеры, оси, перевод координат, подбор точек. +# +# 2) Последовательность действий и вызовов: +# A. Класс VisualHelpersMixin: точки входа +# Публичные методы сценария: +# - VisualHelpersMixin.show_origin_marker(...) +# - VisualHelpersMixin.show_origin_preview_marker(...) +# - VisualHelpersMixin.clear_origin_marker(...) +# - VisualHelpersMixin.clear_origin_preview_marker(...) +# - VisualHelpersMixin.clear_origin_markers(...) +# - VisualHelpersMixin.set_corner_points(...) +# - VisualHelpersMixin.get_corner_points(...) +# - VisualHelpersMixin.show_quadrants(...) +# - VisualHelpersMixin.highlight_quadrant(...) +# - VisualHelpersMixin.clear_quadrants(...) +# - VisualHelpersMixin.show_axes(...) +# - VisualHelpersMixin.world_to_display(...) +# - VisualHelpersMixin.pick_world(...) +# - VisualHelpersMixin.screen_to_world_on_plane(...) +# +# B. VisualHelpersMixin: запуск и настройка: +# VisualHelpersMixin.set_corner_points(...) +# Назначение: устанавливает corner points в рамках текущего сценария модуля. +# +# C. VisualHelpersMixin: основной сценарий: +# VisualHelpersMixin.show_origin_marker(...) +# Назначение: показывает origin marker в рамках текущего сценария модуля. +# VisualHelpersMixin.show_origin_preview_marker(...) +# Назначение: показывает динамический маркер origin в рамках текущего сценария модуля. +# VisualHelpersMixin.show_quadrants(...) +# Назначение: показывает quadrants в рамках текущего сценария модуля. +# Последовательность внутренних вызовов: +# -> VisualHelpersMixin.clear_quadrants(...) +# VisualHelpersMixin.show_axes(...) +# Назначение: показывает axes в рамках текущего сценария модуля. +# VisualHelpersMixin.pick_world(...) +# Назначение: выполняет шаг "pick world" в рамках текущего сценария модуля. +# +# D. VisualHelpersMixin: завершение и очистка: +# VisualHelpersMixin.clear_origin_marker(...) +# Назначение: очищает origin marker в рамках текущего сценария модуля. +# VisualHelpersMixin.clear_origin_preview_marker(...) +# Назначение: очищает origin preview marker в рамках текущего сценария модуля. +# VisualHelpersMixin.clear_origin_markers(...) +# Назначение: очищает все маркеры origin в рамках текущего сценария модуля. +# VisualHelpersMixin.clear_quadrants(...) +# Назначение: очищает quadrants в рамках текущего сценария модуля. +# +# E. VisualHelpersMixin: вспомогательные расчёты: +# VisualHelpersMixin.get_corner_points(...) +# Назначение: возвращает corner points в рамках текущего сценария модуля. +# VisualHelpersMixin.highlight_quadrant(...) +# Назначение: подсвечивает quadrant в рамках текущего сценария модуля. +# VisualHelpersMixin.world_to_display(...) +# Назначение: выполняет шаг "world to display" в рамках текущего сценария модуля. +# VisualHelpersMixin.screen_to_world_on_plane(...) +# Назначение: Преобразовать экранные координаты в мировые на плоскости Z=z_plane. +# +# 3) Важные ограничения и инварианты: +# - Модуль выполняется в составе ModelViewWidget и использует согласованные поля состояния self._... . +# - Все операции с актёрами и камерой выполняются только при валидном self._plotter с безопасной обработкой исключений. +# - Геометрическая визуализация зависит от pyvista/vtk; при недоступности модуль обязан завершать шаг без падения сценария. +# - Межмодульная связность: только через фасад model_view; прямые обращения между zone, rack, shelf, cell запрещены. +# - Очистка состояния должна быть идемпотентной: повторный вызов не меняет корректное состояние в ошибочное. +# +# 4) Правило сопровождения: +# - Любое изменение сценария должно сопровождаться обновлением этого блока с сохранением фактического порядка вызовов. +# - При добавлении метода указывать его место в цепочке сценария (запуск, основной шаг, завершение, вспомогательная логика). diff --git a/Dispatch_V0.1.1/gui/components/model_view/_mv_zone_transition.py b/Dispatch_V0.1.1/gui/components/model_view/_mv_zone_transition.py new file mode 100644 index 0000000..9b1563c --- /dev/null +++ b/Dispatch_V0.1.1/gui/components/model_view/_mv_zone_transition.py @@ -0,0 +1,126 @@ +# -*- coding: utf-8 -*- +# gui/components/model_view/_mv_zone_transition.py +"""Вспомогательные методы плавного перехода камеры для навигации на уровне зон.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from error_logger import log_exception + +if TYPE_CHECKING: # pragma: no cover + from gui.components.model_view_widget import ModelViewWidget + + +class ZoneCameraTransitionMixin: + """Анимация фокусировки камеры на выбранную зону в изометрическом виде.""" + + def focus_on_zone_isometric( + self: "ModelViewWidget", + zone_id: str | None, + *, + duration_ms: int = 340, + ) -> bool: + if not self._plotter or not zone_id: + return False + bounds = (self._zone_data or {}).get(str(zone_id)) + if not bounds: + return False + cam = getattr(self._plotter, "camera", None) + if cam is None: + return False + if not hasattr(self, "_animate_camera_transition"): + return False + try: + min_x, max_x, min_y, max_y, min_z, max_z = [float(v) for v in bounds] + cx = (min_x + max_x) * 0.5 + cy = (min_y + max_y) * 0.5 + sx = max(1.0, max_x - min_x) + sy = max(1.0, max_y - min_y) + sz = max(1.0, max_z - min_z) + cz = min_z + (sz * 0.52) + span = max(sx, sy, sz) + + start_pos = tuple(float(v) for v in cam.GetPosition()) + start_focal = tuple(float(v) for v in cam.GetFocalPoint()) + start_up = tuple(float(v) for v in cam.GetViewUp()) + + vx, vy, vz = 1.0, -1.0, 0.75 + norm = (vx * vx + vy * vy + vz * vz) ** 0.5 + vx, vy, vz = vx / norm, vy / norm, vz / norm + + ux, uy, uz = 0.0, 0.0, 1.0 + fx, fy, fz = -vx, -vy, -vz + rx = fy * uz - fz * uy + ry = fz * ux - fx * uz + rz = fx * uy - fy * ux + r_norm = (rx * rx + ry * ry + rz * rz) ** 0.5 + if r_norm <= 1e-6: + rx, ry, rz = 1.0, 0.0, 0.0 + else: + rx, ry, rz = rx / r_norm, ry / r_norm, rz / r_norm + + zone_margin = max(1.01, float(getattr(self, "_zone_iso_margin_factor", 1.24))) + zone_min_distance = max(1000.0, float(getattr(self, "_zone_iso_min_distance", 2600.0))) + zone_side_shift = max(0.0, float(getattr(self, "_zone_iso_side_shift_factor", 0.05))) + zone_z_lift = max(0.0, float(getattr(self, "_zone_iso_z_lift_factor", 0.08))) + zone_distance_scale = max(1.0, float(getattr(self, "_zone_iso_distance_scale", 1.30))) + if hasattr(self, "_compute_isometric_fit_distance"): + distance = float( + self._compute_isometric_fit_distance( + (min_x, max_x, min_y, max_y, min_z, max_z), + view_dir=(vx, vy, vz), + up_dir=(ux, uy, uz), + margin_factor=zone_margin, + min_distance=zone_min_distance, + ) + ) + else: + distance = max(zone_min_distance, span * 2.4) + distance *= zone_distance_scale + target_focal = (cx, cy, cz) + target_pos = ( + cx + vx * distance + rx * (span * zone_side_shift), + cy + vy * distance + ry * (span * zone_side_shift), + cz + vz * distance + (sz * zone_z_lift), + ) + self._animate_camera_transition( + start_pos=start_pos, + start_focal=start_focal, + start_up=start_up, + target_pos=target_pos, + target_focal=target_focal, + target_up=(ux, uy, uz), + duration_ms=int(duration_ms), + steps=18, + ) + return True + except Exception as exc: + log_exception(__name__, "focus_on_zone_isometric", exc) + return False + + +# --------------------------------------------------------------------------- +# Module workflow notes +# --------------------------------------------------------------------------- +# 1) Задача модуля: +# Переход камеры к выбранной зоне. +# +# 2) Последовательность действий и вызовов: +# A. Класс ZoneCameraTransitionMixin: точки входа +# Публичные методы сценария: +# - ZoneCameraTransitionMixin.focus_on_zone_isometric(...) +# +# B. ZoneCameraTransitionMixin: основной сценарий: +# ZoneCameraTransitionMixin.focus_on_zone_isometric(...) +# Назначение: фокусирует on zone isometric в рамках текущего сценария модуля. +# +# 3) Важные ограничения и инварианты: +# - Модуль выполняется в составе ModelViewWidget и использует согласованные поля состояния self._... . +# - Все операции с актёрами и камерой выполняются только при валидном self._plotter с безопасной обработкой исключений. +# - Межмодульная связность: только через фасад model_view; прямые обращения между zone, rack, shelf, cell запрещены. +# - Очистка состояния должна быть идемпотентной: повторный вызов не меняет корректное состояние в ошибочное. +# +# 4) Правило сопровождения: +# - Любое изменение сценария должно сопровождаться обновлением этого блока с сохранением фактического порядка вызовов. +# - При добавлении метода указывать его место в цепочке сценария (запуск, основной шаг, завершение, вспомогательная логика). diff --git a/Dispatch_V0.1.1/gui/components/model_view/_mv_zones.py b/Dispatch_V0.1.1/gui/components/model_view/_mv_zones.py new file mode 100644 index 0000000..e2b9dcd --- /dev/null +++ b/Dispatch_V0.1.1/gui/components/model_view/_mv_zones.py @@ -0,0 +1,33 @@ +# -*- coding: utf-8 -*- +# gui/components/model_view/_mv_zones.py +# Управление зонами — тонкая композиция подмиксинов + +from gui.components.model_view._mv_zones_crud import ZoneCrudMixin +from gui.components.model_view._mv_zones_visual import ZoneVisualMixin +from gui.components.model_view._mv_zones_highlight import ZoneHighlightMixin + + +class ZoneManagementMixin(ZoneCrudMixin, ZoneVisualMixin, ZoneHighlightMixin): + """Добавление/обновление/удаление зон в 3D-сцене.""" + pass + + +# --------------------------------------------------------------------------- +# Module workflow notes +# --------------------------------------------------------------------------- +# 1) Задача модуля: +# Композиционный фасад подсистемы зон. +# +# 2) Последовательность действий и вызовов: +# A. Композиционный класс ZoneManagementMixin: +# Назначение: объединяет поведение через ZoneCrudMixin, ZoneVisualMixin, ZoneHighlightMixin. +# Собственная вычислительная логика отсутствует; маршрутизация идёт в родительские миксины. +# +# 3) Важные ограничения и инварианты: +# - Модуль выполняется в составе ModelViewWidget и использует согласованные поля состояния self._... . +# - Межмодульная связность: только через фасад model_view; прямые обращения между zone, rack, shelf, cell запрещены. +# - Очистка состояния должна быть идемпотентной: повторный вызов не меняет корректное состояние в ошибочное. +# +# 4) Правило сопровождения: +# - Любое изменение сценария должно сопровождаться обновлением этого блока с сохранением фактического порядка вызовов. +# - При добавлении метода указывать его место в цепочке сценария (запуск, основной шаг, завершение, вспомогательная логика). diff --git a/Dispatch_V0.1.1/gui/components/model_view/_mv_zones_crud.py b/Dispatch_V0.1.1/gui/components/model_view/_mv_zones_crud.py new file mode 100644 index 0000000..aeca0cd --- /dev/null +++ b/Dispatch_V0.1.1/gui/components/model_view/_mv_zones_crud.py @@ -0,0 +1,373 @@ +# -*- coding: utf-8 -*- +# gui/components/model_view/_mv_zones_crud.py +# CRUD зон + пространственные запросы + +from __future__ import annotations + +from typing import Optional, Tuple, TYPE_CHECKING + +from PySide6.QtGui import QColor +from error_logger import log_exception + +if TYPE_CHECKING: # pragma: no cover + from gui.components.model_view_widget import ModelViewWidget + +try: + import pyvista as pv + _PV = True +except ImportError: + _PV = False + + +class ZoneCrudMixin: + """CRUD-операции с зонами и пространственные запросы.""" + + def add_zone( + self: "ModelViewWidget", + zone_id: str, + volume_points: list[tuple[float, float]], + zone_start_height: float, + zone_height: float, + color: str, + ): + """Добавить зону по контуру (XY) и высоте.""" + if not self._models_loaded or not self._plotter: + return False + + if not volume_points or len(volume_points) < 3: + return False + + # Безопасность: если зона уже существует, сначала удаляем, чтобы избежать утечки акторов. + if zone_id in self._zones: + self.remove_zone(zone_id) + + try: + qcolor = QColor(color) + rgb_color = (qcolor.redF(), qcolor.greenF(), qcolor.blueF()) + opacity = qcolor.alphaF() + + mesh = self._build_zone_mesh(volume_points, zone_start_height, zone_height) + if mesh is None: + return False + + actor = self._plotter.add_mesh( + mesh, color=rgb_color, opacity=opacity, + show_edges=False, line_width=2, + ) + + min_x, max_x, min_y, max_y, min_z, max_z = self._compute_zone_bounds( + volume_points, zone_start_height, zone_height + ) + self._zones[zone_id] = actor + self._zone_data[zone_id] = (min_x, max_x, min_y, max_y, min_z, max_z) + self._zone_polygons[zone_id] = [(float(x), float(y)) for x, y in volume_points] + self._zone_heights[zone_id] = (float(zone_start_height), float(zone_height)) + self._plotter.update() + return True + + except Exception as e: + print(f"Zone add error: {e}") + return False + + def update_zone( + self: "ModelViewWidget", + zone_id: str, + volume_points: list[tuple[float, float]], + zone_start_height: float, + zone_height: float, + color: str, + ): + """Обновить зону.""" + self.remove_zone(zone_id) + return self.add_zone( + zone_id, + volume_points, + zone_start_height, + zone_height, + color, + ) + + def remove_zone(self: "ModelViewWidget", zone_id: str): + """Удалить зону.""" + if getattr(self, "_selected_zone_highlight_id", None) == zone_id: + self.clear_selected_zone_highlight() + if getattr(self, "_hover_highlighted_zone_id", None) == zone_id: + self._unhighlight_hover_zone() + if zone_id in self._zones: + actor = self._zones.pop(zone_id) + if actor is not None and self._plotter is not None: + try: + self._plotter.remove_actor(actor) + except Exception as e: + log_exception(__name__, "remove_zone", e) + if zone_id in self._zone_data: + del self._zone_data[zone_id] + if zone_id in self._zone_polygons: + del self._zone_polygons[zone_id] + if zone_id in self._zone_heights: + del self._zone_heights[zone_id] + self._remove_zone_facility_contour(zone_id) + + def _build_zone_mesh( + self: "ModelViewWidget", + volume_points: list[tuple[float, float]], + zone_start_height: float, + zone_height: float, + ): + if not _PV or not volume_points or len(volume_points) < 3: + return None + pts = [(float(x), float(y), float(zone_start_height)) for x, y in volume_points] + faces = [len(pts)] + list(range(len(pts))) + poly = pv.PolyData(pts, faces) + try: + poly = poly.triangulate() + except Exception as _exc: + log_exception(__name__, "_build_zone_mesh", _exc) + return poly.extrude([0.0, 0.0, float(zone_height)], capping=True) + + def _compute_zone_bounds( + self: "ModelViewWidget", + volume_points: list[tuple[float, float]], + zone_start_height: float, + zone_height: float, + ) -> Tuple[float, float, float, float, float, float]: + min_x = min(p[0] for p in volume_points) + max_x = max(p[0] for p in volume_points) + min_y = min(p[1] for p in volume_points) + max_y = max(p[1] for p in volume_points) + min_z = float(zone_start_height) + max_z = float(zone_start_height) + float(zone_height) + return min_x, max_x, min_y, max_y, min_z, max_z + + def _find_zone_at_point( + self: "ModelViewWidget", x: float, y: float, z: float, + ) -> Optional[str]: + """Найти зону по точке в мировых координатах.""" + tolerance = 1.0 + for zone_id, (mn_x, mx_x, mn_y, mx_y, mn_z, mx_z) in self._zone_data.items(): + if ( + (mn_x - tolerance) <= x <= (mx_x + tolerance) + and (mn_y - tolerance) <= y <= (mx_y + tolerance) + and (mn_z - tolerance) <= z <= (mx_z + tolerance) + ): + return zone_id + return None + + def _point_on_segment_2d( + self: "ModelViewWidget", + px: float, + py: float, + a: tuple[float, float], + b: tuple[float, float], + eps: float = 1e-6, + ) -> bool: + ax, ay = a + bx, by = b + cross = (px - ax) * (by - ay) - (py - ay) * (bx - ax) + if abs(cross) > eps: + return False + dot = (px - ax) * (bx - ax) + (py - ay) * (by - ay) + if dot < -eps: + return False + sq_len = (bx - ax) * (bx - ax) + (by - ay) * (by - ay) + if dot - sq_len > eps: + return False + return True + + def _classify_point_in_polygon( + self: "ModelViewWidget", + px: float, + py: float, + polygon: list[tuple[float, float]], + eps: float = 1e-6, + ) -> tuple[bool, bool]: + """Возвращает (внутри, граница).""" + if not polygon or len(polygon) < 3: + return False, False + inside = False + n = len(polygon) + for i in range(n): + a = polygon[i] + b = polygon[(i + 1) % n] + if self._point_on_segment_2d(px, py, a, b, eps): + return True, True + ax, ay = a + bx, by = b + intersects = ((ay > py) != (by > py)) and ( + px < (bx - ax) * (py - ay) / (by - ay + eps) + ax + ) + if intersects: + inside = not inside + return inside, False + + def _zone_intersects_height( + self: "ModelViewWidget", + zone_id: str, + plane_z: float, + eps: float = 1e-6, + ) -> bool: + start_height, height = self._zone_heights.get(zone_id, (0.0, 0.0)) + zone_min = float(start_height) + zone_max = float(start_height) + float(height) + z = float(plane_z) + return (zone_min - eps) <= z <= (zone_max + eps) + + def _classify_point_in_zones( + self: "ModelViewWidget", + px: float, + py: float, + plane_z: Optional[float] = None, + ) -> tuple[Optional[str], Optional[str]]: + """Возвращает (zone_id, kind) для верхней зоны в точке.""" + ranked = self._classify_point_in_zones_ranked(px, py, plane_z=plane_z) + if not ranked: + return None, None + zone_id, kind, _ = ranked[0] + return zone_id, kind + + def _classify_point_in_zones_ranked( + self: "ModelViewWidget", + px: float, + py: float, + plane_z: Optional[float] = None, + ) -> list[tuple[str, str, float]]: + """Возвращает все совпадающие зоны, отсортированные сверху вниз.""" + step = max(1.0, float(getattr(self, "_current_zone_size", 1.0))) + eps = max(1e-6, step * 0.25) + ignore_zone_id = getattr(self, "_contour_ignore_zone_id", None) + candidates: list[tuple[float, int, str, str]] = [] + for zone_id, polygon in self._zone_polygons.items(): + if ignore_zone_id and zone_id == ignore_zone_id: + continue + if plane_z is not None and not self._zone_intersects_height(zone_id, plane_z, eps=eps): + continue + inside, boundary = self._classify_point_in_polygon(px, py, polygon, eps=eps) + if not inside: + continue + top_z = self._get_zone_top_height(zone_id) + rank = 0 if boundary else 1 + kind = "boundary" if boundary else "inside" + candidates.append((float(top_z), rank, zone_id, kind)) + + if not candidates: + return [] + + candidates.sort(key=lambda item: (item[0], item[1]), reverse=True) + return [(zone_id, kind, top_z) for top_z, _, zone_id, kind in candidates] + + def _point_inside_any_zone( + self: "ModelViewWidget", + px: float, + py: float, + plane_z: Optional[float] = None, + ) -> bool: + step = max(1.0, float(getattr(self, "_current_zone_size", 1.0))) + eps = max(1e-6, step * 0.25) + for zone_id, polygon in self._zone_polygons.items(): + if plane_z is not None and not self._zone_intersects_height(zone_id, plane_z, eps=eps): + continue + inside, boundary = self._classify_point_in_polygon(px, py, polygon, eps=eps) + if inside and not boundary: + return True + return False + + def _point_in_zone_or_boundary( + self: "ModelViewWidget", + px: float, + py: float, + zone_id: str, + plane_z: Optional[float] = None, + ) -> bool: + polygon = self._zone_polygons.get(zone_id) + if not polygon: + return False + step = max(1.0, float(getattr(self, "_current_zone_size", 1.0))) + eps = max(1e-6, step * 0.25) + if plane_z is not None and not self._zone_intersects_height(zone_id, plane_z, eps=eps): + return False + inside, boundary = self._classify_point_in_polygon(px, py, polygon, eps=eps) + return inside or boundary + + def _get_zone_top_height(self: "ModelViewWidget", zone_id: str) -> float: + start_height, height = self._zone_heights.get(zone_id, (0.0, 0.0)) + return float(start_height) + float(height) + + +# --------------------------------------------------------------------------- +# Module workflow notes +# --------------------------------------------------------------------------- +# 1) Задача модуля: +# Создание, обновление, удаление зон и геометрические проверки принадлежности. +# +# 2) Последовательность действий и вызовов: +# A. Класс ZoneCrudMixin: точки входа +# Публичные методы сценария: +# - ZoneCrudMixin.add_zone(...) +# - ZoneCrudMixin.update_zone(...) +# - ZoneCrudMixin.remove_zone(...) +# +# B. ZoneCrudMixin: основной сценарий: +# ZoneCrudMixin.add_zone(...) +# Назначение: Добавить зону по контуру (XY) и высоте. +# Последовательность внутренних вызовов: +# -> ZoneCrudMixin.remove_zone(...) +# -> ZoneCrudMixin._build_zone_mesh(...) +# -> ZoneCrudMixin._compute_zone_bounds(...) +# ZoneCrudMixin.update_zone(...) +# Назначение: Обновить зону. +# Последовательность внутренних вызовов: +# -> ZoneCrudMixin.remove_zone(...) +# -> ZoneCrudMixin.add_zone(...) +# ZoneCrudMixin._compute_zone_bounds(...) +# Назначение: вычисляет zone bounds в рамках текущего сценария модуля. +# ZoneCrudMixin._find_zone_at_point(...) +# Назначение: Найти зону по точке в мировых координатах. +# ZoneCrudMixin._classify_point_in_polygon(...) +# Назначение: Возвращает (внутри, граница). +# Последовательность внутренних вызовов: +# -> ZoneCrudMixin._point_on_segment_2d(...) +# ZoneCrudMixin._classify_point_in_zones(...) +# Назначение: Возвращает (zone_id, kind) для верхней зоны в точке. +# Последовательность внутренних вызовов: +# -> ZoneCrudMixin._classify_point_in_zones_ranked(...) +# ZoneCrudMixin._classify_point_in_zones_ranked(...) +# Назначение: Возвращает все совпадающие зоны, отсортированные сверху вниз. +# Последовательность внутренних вызовов: +# -> ZoneCrudMixin._classify_point_in_polygon(...) +# -> ZoneCrudMixin._get_zone_top_height(...) +# -> ZoneCrudMixin._zone_intersects_height(...) +# +# C. ZoneCrudMixin: завершение и очистка: +# ZoneCrudMixin.remove_zone(...) +# Назначение: Удалить зону. +# +# D. ZoneCrudMixin: вспомогательные расчёты: +# ZoneCrudMixin._build_zone_mesh(...) +# Назначение: строит zone mesh в рамках текущего сценария модуля. +# ZoneCrudMixin._point_on_segment_2d(...) +# Назначение: выполняет шаг "point on segment 2d" в рамках текущего сценария модуля. +# ZoneCrudMixin._zone_intersects_height(...) +# Назначение: выполняет шаг "zone intersects height" в рамках текущего сценария модуля. +# ZoneCrudMixin._point_inside_any_zone(...) +# Назначение: выполняет шаг "point inside any zone" в рамках текущего сценария модуля. +# Последовательность внутренних вызовов: +# -> ZoneCrudMixin._classify_point_in_polygon(...) +# -> ZoneCrudMixin._zone_intersects_height(...) +# ZoneCrudMixin._point_in_zone_or_boundary(...) +# Назначение: выполняет шаг "point in zone or boundary" в рамках текущего сценария модуля. +# Последовательность внутренних вызовов: +# -> ZoneCrudMixin._classify_point_in_polygon(...) +# -> ZoneCrudMixin._zone_intersects_height(...) +# ZoneCrudMixin._get_zone_top_height(...) +# Назначение: возвращает zone top height в рамках текущего сценария модуля. +# +# 3) Важные ограничения и инварианты: +# - Модуль выполняется в составе ModelViewWidget и использует согласованные поля состояния self._... . +# - Все операции с актёрами и камерой выполняются только при валидном self._plotter с безопасной обработкой исключений. +# - Геометрическая визуализация зависит от pyvista/vtk; при недоступности модуль обязан завершать шаг без падения сценария. +# - Межмодульная связность: только через фасад model_view; прямые обращения между zone, rack, shelf, cell запрещены. +# - Очистка состояния должна быть идемпотентной: повторный вызов не меняет корректное состояние в ошибочное. +# +# 4) Правило сопровождения: +# - Любое изменение сценария должно сопровождаться обновлением этого блока с сохранением фактического порядка вызовов. +# - При добавлении метода указывать его место в цепочке сценария (запуск, основной шаг, завершение, вспомогательная логика). diff --git a/Dispatch_V0.1.1/gui/components/model_view/_mv_zones_highlight.py b/Dispatch_V0.1.1/gui/components/model_view/_mv_zones_highlight.py new file mode 100644 index 0000000..5754a26 --- /dev/null +++ b/Dispatch_V0.1.1/gui/components/model_view/_mv_zones_highlight.py @@ -0,0 +1,375 @@ +# -*- coding: utf-8 -*- +# gui/components/model_view/_mv_zones_highlight.py +# Подсветка зон при наведении/выделении + +from __future__ import annotations + +from typing import Optional, TYPE_CHECKING +from error_logger import log_exception + +if TYPE_CHECKING: # pragma: no cover + from gui.components.model_view_widget import ModelViewWidget + + +class ZoneHighlightMixin: + """Подсветка зон при наведении и выделении.""" + + def _find_zone_by_actor(self: "ModelViewWidget", picked_actor) -> Optional[str]: + """Найти зону по VTK-актору.""" + if picked_actor is None: + return None + for zone_id, zone_actor in self._zones.items(): + if zone_actor is picked_actor or zone_actor == picked_actor: + return zone_id + try: + if ( + hasattr(zone_actor, "GetAddressAsString") + and hasattr(picked_actor, "GetAddressAsString") + and zone_actor.GetAddressAsString("") == picked_actor.GetAddressAsString("") + ): + return zone_id + except Exception as _exc: + log_exception(__name__, "_find_zone_by_actor", _exc) + # Акторы контуров объекта также могут быть возвращены пикером. + try: + contour_actors = self._get_facility_zone_contour_actors() + except Exception as exc: + log_exception(__name__, "_find_zone_by_actor", exc) + contour_actors = {} + for zone_id, contour_actor in contour_actors.items(): + if contour_actor is picked_actor or contour_actor == picked_actor: + return zone_id + try: + if ( + hasattr(contour_actor, "GetAddressAsString") + and hasattr(picked_actor, "GetAddressAsString") + and contour_actor.GetAddressAsString("") == picked_actor.GetAddressAsString("") + ): + return zone_id + except Exception as _exc: + log_exception(__name__, "_find_zone_by_actor", _exc) + return None + + def _find_zone_by_screen_ray(self: "ModelViewWidget", sx: float, sy: float) -> Optional[str]: + """Найти ближайшую зону, пересечённую лучом камеры через точку экрана.""" + if not self._plotter or not self._plotter.renderer: + return None + + try: + renderer = self._plotter.renderer + renderer.SetDisplayPoint(float(sx), float(sy), 0.0) + renderer.DisplayToWorld() + p0w = renderer.GetWorldPoint() + renderer.SetDisplayPoint(float(sx), float(sy), 1.0) + renderer.DisplayToWorld() + p1w = renderer.GetWorldPoint() + if p0w[3] == 0 or p1w[3] == 0: + return None + p0 = (p0w[0] / p0w[3], p0w[1] / p0w[3], p0w[2] / p0w[3]) + p1 = (p1w[0] / p1w[3], p1w[1] / p1w[3], p1w[2] / p1w[3]) + except Exception as exc: + log_exception(__name__, "_find_zone_by_screen_ray", exc) + return None + + dx = float(p1[0] - p0[0]) + dy = float(p1[1] - p0[1]) + dz = float(p1[2] - p0[2]) + eps = 1e-9 + + def _axis_slab(o: float, d: float, mn: float, mx: float) -> tuple[float, float] | None: + if abs(d) <= eps: + if o < mn or o > mx: + return None + return (-float("inf"), float("inf")) + t1 = (mn - o) / d + t2 = (mx - o) / d + return (t1, t2) if t1 <= t2 else (t2, t1) + + best_zone_id = None + best_t = None + for zone_id, bounds in self._zone_data.items(): + try: + mn_x, mx_x, mn_y, mx_y, mn_z, mx_z = bounds + xr = _axis_slab(float(p0[0]), dx, float(mn_x), float(mx_x)) + yr = _axis_slab(float(p0[1]), dy, float(mn_y), float(mx_y)) + zr = _axis_slab(float(p0[2]), dz, float(mn_z), float(mx_z)) + if xr is None or yr is None or zr is None: + continue + t_enter = max(xr[0], yr[0], zr[0]) + t_exit = min(xr[1], yr[1], zr[1]) + if t_exit < max(t_enter, 0.0): + continue + t_hit = t_enter if t_enter >= 0.0 else t_exit + if t_hit < 0.0: + continue + if best_t is None or t_hit < best_t: + best_t = t_hit + best_zone_id = zone_id + except Exception as exc: + log_exception(__name__, "_find_zone_by_screen_ray", exc) + continue + return best_zone_id + + def _handle_zone_hover_event( + self: "ModelViewWidget", sx: int, sy: int, + ) -> None: + """Определить зону под курсором и подсветить/снять подсветку. + + 1. vtkPropPicker — точный пиксельный pick по видимой геометрии. + Корректно определяет глубину (Z1 vs Z2), работает на всей + поверхности, а не только на углах/рёбрах. + 2. Fallback: screen_to_world + 2D полигональный тест — только для + зон в контурном режиме (solid скрыт), которые не видны пикеру. + """ + if getattr(self, "_hide_empty_zones_mode", False): + self._unhighlight_hover_zone() + return + + selected_zone_id = str(getattr(self, "_selected_zone_highlight_id", "") or "") + if selected_zone_id and selected_zone_id not in self._zones: + self._selected_zone_highlight_id = None + + if not self._plotter or not self._plotter.renderer: + self._unhighlight_hover_zone() + return + + zone_id = None + + # --- 1. vtkPropPicker: точное определение ближайшего актора -------- + try: + from vtkmodules.vtkRenderingCore import vtkPropPicker + prop_picker = vtkPropPicker() + prop_picker.Pick(sx, sy, 0, self._plotter.renderer) + picked_actor = prop_picker.GetViewProp() + if picked_actor is not None: + zone_id = self._find_zone_by_actor(picked_actor) + except Exception as _exc: + log_exception(__name__, "_handle_zone_hover_event", _exc) + # --- 2. Запасной вариант: пересечение луча с 3D-границами зоны ----- + if zone_id is None: + zone_id = self._find_zone_by_screen_ray(sx, sy) + + # --- 3. Запасной вариант: 2D-полигональный тест с приоритетом верхней зоны + if zone_id is None: + plane_z = self._get_grid_plane_z(getattr(self, "_grid_origin", None)) + world = self.screen_to_world_on_plane(sx, sy, plane_z) + if world is not None: + wx, wy = world[0], world[1] + best_top_z = None + for zid, polygon in self._zone_polygons.items(): + if not polygon or len(polygon) < 3: + continue + inside, _ = self._classify_point_in_polygon(wx, wy, polygon) + if inside: + top_z = self._get_zone_top_height(zid) + if best_top_z is None or top_z > best_top_z: + best_top_z = top_z + zone_id = zid + + if zone_id == getattr(self, "_hover_highlighted_zone_id", None): + return # та же зона — ничего не делаем + + self._unhighlight_hover_zone() + + if zone_id is not None: + self._highlight_hover_zone(zone_id) + + def set_selected_zone_highlight(self: "ModelViewWidget", zone_id: str | None) -> None: + """Закрепить подсветку выбранной зоны до явного сброса.""" + zid = str(zone_id or "") + if not zid or zid not in self._zones: + self.clear_selected_zone_highlight() + return + if getattr(self, "_selected_zone_highlight_id", None) == zid: + if getattr(self, "_hover_highlighted_zone_id", None) != zid: + self._unhighlight_hover_zone() + self._highlight_hover_zone(zid) + return + self.clear_selected_zone_highlight() + actor = self._zones.get(zid) + if actor is not None: + try: + prop = actor.GetProperty() + self._selected_zone_original_visibility = bool(actor.GetVisibility()) + self._selected_zone_original_opacity = float(prop.GetOpacity()) + actor.SetVisibility(1) + prop.SetOpacity(1.0) + except Exception as exc: + log_exception(__name__, "set_selected_zone_highlight", exc) + self._selected_zone_original_opacity = None + self._selected_zone_original_visibility = None + else: + self._selected_zone_original_opacity = None + self._selected_zone_original_visibility = None + self._set_zone_contour_highlight(zid, active=True) + self._hover_highlighted_zone_id = zid + self._selected_zone_highlight_id = zid + self._safe_render(min_interval_s=1.0 / 60.0) + + def clear_selected_zone_highlight(self: "ModelViewWidget") -> None: + """Снять закреплённую подсветку выбранной зоны.""" + locked_zone_id = str(getattr(self, "_selected_zone_highlight_id", "") or "") + self._selected_zone_highlight_id = None + if not locked_zone_id: + return + actor = self._zones.get(locked_zone_id) + if actor is not None: + try: + prop = actor.GetProperty() + orig_opacity = getattr(self, "_selected_zone_original_opacity", None) + if orig_opacity is not None: + prop.SetOpacity(float(orig_opacity)) + orig_vis = getattr(self, "_selected_zone_original_visibility", None) + if orig_vis is not None: + actor.SetVisibility(1 if orig_vis else 0) + except Exception as _exc: + log_exception(__name__, "clear_selected_zone_highlight", _exc) + self._selected_zone_original_opacity = None + self._selected_zone_original_visibility = None + if getattr(self, "_hover_highlighted_zone_id", None) == locked_zone_id: + self._hover_highlighted_zone_id = None + self._teardown_zone_contour_if_temporary(locked_zone_id) + self._safe_render(min_interval_s=1.0 / 60.0) + + def _is_zone_in_contour_mode(self: "ModelViewWidget", zone_id: str) -> bool: + """Проверить, отображена ли зона в контурном режиме (solid скрыт).""" + actor = self._zones.get(zone_id) + if actor is None: + return False + try: + if not actor.GetVisibility(): + # Solid скрыт — проверим, есть ли facility contour + contour_actors = self._get_facility_zone_contour_actors() + contour_actor = contour_actors.get(zone_id) + if contour_actor is not None: + return True + except Exception as _exc: + log_exception(__name__, "_is_zone_in_contour_mode", _exc) + return False + + def _zone_has_rack_payload(self: "ModelViewWidget", zone_id: str) -> bool: + try: + return bool(self.has_racks_in_zone(zone_id)) + except Exception as exc: + log_exception(__name__, "_zone_has_rack_payload", exc) + return False + + def _set_zone_contour_highlight( + self: "ModelViewWidget", + zone_id: str, + active: bool, + ) -> None: + contour_actor = self._ensure_zone_facility_contour(zone_id) + if contour_actor is None: + return + try: + prop = contour_actor.GetProperty() + if prop is None: + return + if active: + prop.SetColor(1.0, 1.0, 0.3) + prop.SetLineWidth(3.0) + else: + prop.SetColor(1.0, 1.0, 1.0) + prop.SetLineWidth(2.0) + contour_actor.SetVisibility(1) + except Exception as _exc: + log_exception(__name__, "_set_zone_contour_highlight", _exc) + def _teardown_zone_contour_if_temporary(self: "ModelViewWidget", zone_id: str) -> None: + if self._zone_has_rack_payload(zone_id): + self._set_zone_contour_highlight(zone_id, active=False) + return + self._remove_zone_facility_contour(zone_id) + + def _highlight_hover_zone(self: "ModelViewWidget", zone_id: str) -> None: + """Подсветить зону при hover только по контурным линиям.""" + if zone_id not in self._zones: + return + self._set_zone_contour_highlight(zone_id, active=True) + self._hover_highlighted_zone_id = zone_id + self._safe_render(min_interval_s=1.0 / 60.0) + + def _unhighlight_hover_zone(self: "ModelViewWidget") -> None: + """Снять подсветку с ранее подсвеченной зоны.""" + zone_id = getattr(self, "_hover_highlighted_zone_id", None) + if zone_id is None: + return + self._teardown_zone_contour_if_temporary(str(zone_id)) + self._hover_highlighted_zone_id = None + self._hover_zone_original_opacity = None + self._hover_zone_original_edges = False + self._hover_zone_original_visibility = None + self._hover_zone_contour_original_color = None + self._safe_render(min_interval_s=1.0 / 60.0) + + +# --------------------------------------------------------------------------- +# Module workflow notes +# --------------------------------------------------------------------------- +# 1) Задача модуля: +# Подсветка зон при наведении и выборе. +# +# 2) Последовательность действий и вызовов: +# A. Класс ZoneHighlightMixin: точки входа +# Публичные методы сценария: +# - ZoneHighlightMixin.set_selected_zone_highlight(...) +# - ZoneHighlightMixin.clear_selected_zone_highlight(...) +# +# B. ZoneHighlightMixin: запуск и настройка: +# ZoneHighlightMixin.set_selected_zone_highlight(...) +# Назначение: Закрепить подсветку выбранной зоны до явного сброса. +# Последовательность внутренних вызовов: +# -> ZoneHighlightMixin.clear_selected_zone_highlight(...) +# -> ZoneHighlightMixin._set_zone_contour_highlight(...) +# -> ZoneHighlightMixin._unhighlight_hover_zone(...) +# -> ZoneHighlightMixin._highlight_hover_zone(...) +# ZoneHighlightMixin._set_zone_contour_highlight(...) +# Назначение: устанавливает zone contour highlight в рамках текущего сценария модуля. +# +# C. ZoneHighlightMixin: основной сценарий: +# ZoneHighlightMixin._find_zone_by_actor(...) +# Назначение: Найти зону по VTK-актору. +# ZoneHighlightMixin._find_zone_by_screen_ray(...) +# Назначение: Найти ближайшую зону, пересечённую лучом камеры через точку экрана. +# ZoneHighlightMixin._handle_zone_hover_event(...) +# Назначение: Определить зону под курсором и подсветить/снять подсветку. +# Последовательность внутренних вызовов: +# -> ZoneHighlightMixin._unhighlight_hover_zone(...) +# -> ZoneHighlightMixin._find_zone_by_screen_ray(...) +# -> ZoneHighlightMixin._highlight_hover_zone(...) +# -> ZoneHighlightMixin._find_zone_by_actor(...) +# ZoneHighlightMixin._is_zone_in_contour_mode(...) +# Назначение: Проверить, отображена ли зона в контурном режиме (solid скрыт). +# +# D. ZoneHighlightMixin: завершение и очистка: +# ZoneHighlightMixin.clear_selected_zone_highlight(...) +# Назначение: Снять закреплённую подсветку выбранной зоны. +# Последовательность внутренних вызовов: +# -> ZoneHighlightMixin._teardown_zone_contour_if_temporary(...) +# ZoneHighlightMixin._teardown_zone_contour_if_temporary(...) +# Назначение: выполняет демонтаж zone contour if temporary в рамках текущего сценария модуля. +# Последовательность внутренних вызовов: +# -> ZoneHighlightMixin._zone_has_rack_payload(...) +# -> ZoneHighlightMixin._set_zone_contour_highlight(...) +# +# E. ZoneHighlightMixin: вспомогательные расчёты: +# ZoneHighlightMixin._zone_has_rack_payload(...) +# Назначение: выполняет шаг "zone has rack payload" в рамках текущего сценария модуля. +# ZoneHighlightMixin._highlight_hover_zone(...) +# Назначение: Подсветить зону при hover только по контурным линиям. +# Последовательность внутренних вызовов: +# -> ZoneHighlightMixin._set_zone_contour_highlight(...) +# ZoneHighlightMixin._unhighlight_hover_zone(...) +# Назначение: Снять подсветку с ранее подсвеченной зоны. +# Последовательность внутренних вызовов: +# -> ZoneHighlightMixin._teardown_zone_contour_if_temporary(...) +# +# 3) Важные ограничения и инварианты: +# - Модуль выполняется в составе ModelViewWidget и использует согласованные поля состояния self._... . +# - Все операции с актёрами и камерой выполняются только при валидном self._plotter с безопасной обработкой исключений. +# - Межмодульная связность: только через фасад model_view; прямые обращения между zone, rack, shelf, cell запрещены. +# - Очистка состояния должна быть идемпотентной: повторный вызов не меняет корректное состояние в ошибочное. +# +# 4) Правило сопровождения: +# - Любое изменение сценария должно сопровождаться обновлением этого блока с сохранением фактического порядка вызовов. +# - При добавлении метода указывать его место в цепочке сценария (запуск, основной шаг, завершение, вспомогательная логика). diff --git a/Dispatch_V0.1.1/gui/components/model_view/_mv_zones_visual.py b/Dispatch_V0.1.1/gui/components/model_view/_mv_zones_visual.py new file mode 100644 index 0000000..d376aca --- /dev/null +++ b/Dispatch_V0.1.1/gui/components/model_view/_mv_zones_visual.py @@ -0,0 +1,315 @@ +# -*- coding: utf-8 -*- +# gui/components/model_view/_mv_zones_visual.py +# Визуальное управление зонами (предпросмотр, изоляция, видимость, контуры) + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from PySide6.QtGui import QColor +from error_logger import log_exception + +if TYPE_CHECKING: # pragma: no cover + from gui.components.model_view_widget import ModelViewWidget + +try: + import pyvista as pv + _PV = True +except ImportError: + _PV = False + + +class ZoneVisualMixin: + """Визуальное управление зонами: изоляция, видимость, контуры.""" + + def isolate_zone(self: "ModelViewWidget", zone_id: str) -> None: + """Скрыть все зоны кроме указанной (изоляция вида).""" + self.clear_selected_zone_highlight() + self._unhighlight_hover_zone() + for zid, actor in self._zones.items(): + if actor is None: + continue + try: + actor.SetVisibility(1 if zid == zone_id else 0) + except Exception as _exc: + log_exception(__name__, "isolate_zone", _exc) + if self._plotter: + self._plotter.update() + + def show_all_zones(self: "ModelViewWidget") -> None: + """Показать все зоны (снять изоляцию вида) и убрать контур.""" + if getattr(self, "_selected_zone_highlight_id", None): + self.clear_selected_zone_highlight() + else: + self._unhighlight_hover_zone() + self._remove_zone_contour() + self._set_facility_zone_contours_visible(True) + for actor in self._zones.values(): + if actor is None: + continue + try: + actor.SetVisibility(1) + except Exception as _exc: + log_exception(__name__, "show_all_zones", _exc) + if self._plotter: + self._plotter.update() + + def set_zone_visibility(self: "ModelViewWidget", zone_id: str, visible: bool) -> None: + """Установить видимость конкретной зоны.""" + actor = self._zones.get(zone_id) + if actor is None: + return + try: + actor.SetVisibility(1 if visible else 0) + if self._plotter: + self._plotter.update() + except Exception as _exc: + log_exception(__name__, "set_zone_visibility", _exc) + def show_zone_contour(self: "ModelViewWidget", zone_id: str) -> None: + """Скрыть все зоны и показать контурную линию (wireframe) указанной зоны. + + Сам объём (solid mesh) скрывается. Вместо него отображается каркасная + линия рёбер (wireframe), чтобы обозначить границы зоны. + """ + self.clear_selected_zone_highlight() + self._unhighlight_hover_zone() + # Скрываем все зоны (solid) + self._set_facility_zone_contours_visible(False) + for zid, actor in self._zones.items(): + if actor is None: + continue + try: + actor.SetVisibility(0) + except Exception as _exc: + log_exception(__name__, "show_zone_contour", _exc) + # Удаляем предыдущий контур, если был + self._remove_zone_contour() + + # Строим wireframe-контур выбранной зоны + polygon = self._zone_polygons.get(zone_id) + heights = self._zone_heights.get(zone_id) + if not polygon or not heights or not self._plotter: + if self._plotter: + self._plotter.update() + return + + start_h, height = heights + try: + edges = self._build_zone_outline_edges(polygon, float(start_h), float(height)) + if edges is None: + return + contour_actor = self._plotter.add_mesh( + edges, + color=(1.0, 1.0, 1.0), + line_width=2, + style="wireframe", + name="_zone_contour_outline", + ) + self._zone_contour_actor = contour_actor + except Exception as e: + log_exception(__name__, "show_zone_contour", e) + if self._plotter: + self._plotter.update() + + def _get_facility_zone_contour_actors(self: "ModelViewWidget") -> dict[str, object]: + actors = getattr(self, "_zone_facility_contour_actors", None) + if actors is None: + actors = {} + self._zone_facility_contour_actors = actors + return actors + + def _set_facility_zone_contours_visible(self: "ModelViewWidget", visible: bool) -> None: + actors = self._get_facility_zone_contour_actors() + for actor in actors.values(): + if actor is None: + continue + try: + actor.SetVisibility(1 if visible else 0) + except Exception as _exc: + log_exception(__name__, "_set_facility_zone_contours_visible", _exc) + def _ensure_zone_facility_contour(self: "ModelViewWidget", zone_id: str) -> object | None: + actors = self._get_facility_zone_contour_actors() + existing = actors.get(zone_id) + if existing is not None: + return existing + polygon = self._zone_polygons.get(zone_id) + heights = self._zone_heights.get(zone_id) + if not polygon or not heights or not self._plotter: + return None + start_h, height = heights + try: + edges = self._build_zone_outline_edges(polygon, float(start_h), float(height)) + if edges is None: + return None + actor = self._plotter.add_mesh( + edges, + color=(1.0, 1.0, 1.0), + line_width=2, + style="wireframe", + name=f"_zone_facility_contour_{zone_id}", + ) + actors[zone_id] = actor + return actor + except Exception as exc: + log_exception(__name__, "_ensure_zone_facility_contour", exc) + return None + + def _remove_zone_facility_contour(self: "ModelViewWidget", zone_id: str) -> None: + actors = self._get_facility_zone_contour_actors() + actor = actors.pop(zone_id, None) + if actor is None or self._plotter is None: + return + try: + self._plotter.remove_actor(actor) + except Exception as _exc: + log_exception(__name__, "_remove_zone_facility_contour", _exc) + def set_zone_facility_contour_mode(self: "ModelViewWidget", zone_id: str, enabled: bool) -> None: + """Установить представление зоны для уровня объекта. + + enabled=True -> скрыть solid-меш и показать контурную линию. + enabled=False -> удалить контурную линию и показать solid-меш. + """ + actor = self._zones.get(zone_id) + if actor is None: + return + if enabled: + contour_actor = self._ensure_zone_facility_contour(zone_id) + try: + actor.SetVisibility(0) + except Exception as _exc: + log_exception(__name__, "set_zone_facility_contour_mode", _exc) + if contour_actor is not None: + try: + c_prop = contour_actor.GetProperty() + if c_prop is not None: + c_prop.SetColor(1.0, 1.0, 1.0) + c_prop.SetLineWidth(2.0) + contour_actor.SetVisibility(1) + except Exception as _exc: + log_exception(__name__, "set_zone_facility_contour_mode", _exc) + else: + self._remove_zone_facility_contour(zone_id) + try: + actor.SetVisibility(1) + except Exception as _exc: + log_exception(__name__, "set_zone_facility_contour_mode", _exc) + def _build_zone_outline_edges( + self: "ModelViewWidget", + polygon: list[tuple[float, float]], + start_height: float, + height: float, + ): + """Построить только рёбра периметра для контура зоны.""" + if not _PV or not polygon or len(polygon) < 3: + return None + + n = len(polygon) + z0 = float(start_height) + z1 = float(start_height) + float(height) + + points: list[list[float]] = [] + for x, y in polygon: + points.append([float(x), float(y), z0]) + for x, y in polygon: + points.append([float(x), float(y), z1]) + + line_cells: list[int] = [] + + def _add_segment(i0: int, i1: int) -> None: + line_cells.extend([2, i0, i1]) + + for i in range(n): + j = (i + 1) % n + _add_segment(i, j) + _add_segment(i + n, j + n) + _add_segment(i, i + n) + + outline = pv.PolyData(points) + outline.lines = line_cells + return outline + + def _remove_zone_contour(self: "ModelViewWidget") -> None: + """Удалить контурную линию зоны из 3D-сцены.""" + actor = getattr(self, "_zone_contour_actor", None) + if actor is not None and self._plotter is not None: + try: + self._plotter.remove_actor(actor) + except Exception as _exc: + log_exception(__name__, "_remove_zone_contour", _exc) + self._zone_contour_actor = None + + +# --------------------------------------------------------------------------- +# Module workflow notes +# --------------------------------------------------------------------------- +# 1) Задача модуля: +# Визуализация зон: предпросмотр, контуры, изоляция и видимость. +# +# 2) Последовательность действий и вызовов: +# A. Класс ZoneVisualMixin: точки входа +# Публичные методы сценария: +# - ZoneVisualMixin.isolate_zone(...) +# - ZoneVisualMixin.show_all_zones(...) +# - ZoneVisualMixin.set_zone_visibility(...) +# - ZoneVisualMixin.show_zone_contour(...) +# - ZoneVisualMixin.set_zone_facility_contour_mode(...) +# +# B. ZoneVisualMixin: запуск и настройка: +# ZoneVisualMixin.set_zone_visibility(...) +# Назначение: Установить видимость конкретной зоны. +# ZoneVisualMixin._set_facility_zone_contours_visible(...) +# Назначение: устанавливает facility zone contours visible в рамках текущего сценария модуля. +# Последовательность внутренних вызовов: +# -> ZoneVisualMixin._get_facility_zone_contour_actors(...) +# ZoneVisualMixin.set_zone_facility_contour_mode(...) +# Назначение: Установить представление зоны для уровня объекта. +# Последовательность внутренних вызовов: +# -> ZoneVisualMixin._ensure_zone_facility_contour(...) +# -> ZoneVisualMixin._remove_zone_facility_contour(...) +# +# C. ZoneVisualMixin: основной сценарий: +# ZoneVisualMixin.show_preview_zone(...) +# Назначение: Показать простой куб предпросмотра (устаревший). +# ZoneVisualMixin.show_all_zones(...) +# Назначение: Показать все зоны (снять изоляцию вида) и убрать контур. +# Последовательность внутренних вызовов: +# -> ZoneVisualMixin._remove_zone_contour(...) +# -> ZoneVisualMixin._set_facility_zone_contours_visible(...) +# ZoneVisualMixin.show_zone_contour(...) +# Назначение: Скрыть все зоны и показать контурную линию (wireframe) указанной зоны. +# Последовательность внутренних вызовов: +# -> ZoneVisualMixin._set_facility_zone_contours_visible(...) +# -> ZoneVisualMixin._remove_zone_contour(...) +# -> ZoneVisualMixin._build_zone_outline_edges(...) +# +# D. ZoneVisualMixin: завершение и очистка: +# ZoneVisualMixin._remove_zone_facility_contour(...) +# Назначение: удаляет zone facility contour в рамках текущего сценария модуля. +# Последовательность внутренних вызовов: +# -> ZoneVisualMixin._get_facility_zone_contour_actors(...) +# ZoneVisualMixin._remove_zone_contour(...) +# Назначение: Удалить контурную линию зоны из 3D-сцены. +# +# E. ZoneVisualMixin: вспомогательные расчёты: +# ZoneVisualMixin.isolate_zone(...) +# Назначение: Скрыть все зоны кроме указанной (изоляция вида). +# ZoneVisualMixin._get_facility_zone_contour_actors(...) +# Назначение: возвращает facility zone contour actors в рамках текущего сценария модуля. +# ZoneVisualMixin._ensure_zone_facility_contour(...) +# Назначение: выполняет шаг "ensure zone facility contour" в рамках текущего сценария модуля. +# Последовательность внутренних вызовов: +# -> ZoneVisualMixin._get_facility_zone_contour_actors(...) +# -> ZoneVisualMixin._build_zone_outline_edges(...) +# ZoneVisualMixin._build_zone_outline_edges(...) +# Назначение: Построить только рёбра периметра для контура зоны. +# +# 3) Важные ограничения и инварианты: +# - Модуль выполняется в составе ModelViewWidget и использует согласованные поля состояния self._... . +# - Все операции с актёрами и камерой выполняются только при валидном self._plotter с безопасной обработкой исключений. +# - Геометрическая визуализация зависит от pyvista/vtk; при недоступности модуль обязан завершать шаг без падения сценария. +# - Межмодульная связность: только через фасад model_view; прямые обращения между zone, rack, shelf, cell запрещены. +# - Очистка состояния должна быть идемпотентной: повторный вызов не меняет корректное состояние в ошибочное. +# +# 4) Правило сопровождения: +# - Любое изменение сценария должно сопровождаться обновлением этого блока с сохранением фактического порядка вызовов. +# - При добавлении метода указывать его место в цепочке сценария (запуск, основной шаг, завершение, вспомогательная логика). diff --git a/Dispatch_V0.1.1/gui/components/model_view/_scenario_camera.py b/Dispatch_V0.1.1/gui/components/model_view/_scenario_camera.py new file mode 100644 index 0000000..76288b5 --- /dev/null +++ b/Dispatch_V0.1.1/gui/components/model_view/_scenario_camera.py @@ -0,0 +1,116 @@ +# -*- coding: utf-8 -*- +# gui/components/model_view/_scenario_camera.py +"""Камерный сценарий — базовый слой, всегда активен. + +Обрабатывает ПКМ (вращение), СКМ (панорамирование), подавление ЛКМ drag. +Политика камеры текущего доменного сценария определяет, работает ли этот слой. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from PySide6.QtCore import Qt +from PySide6.QtGui import QGuiApplication + +from gui.components.model_view._interaction_scenario import ( + CameraPolicy, + InteractionScenario, +) +from error_logger import log_exception + +if TYPE_CHECKING: # pragma: no cover + from gui.components.model_view_widget import ModelViewWidget + + +class CameraScenario(InteractionScenario): + """Базовый камерный слой: вращение (ПКМ), панорамирование (СКМ).""" + + name = "camera" + camera_policy = CameraPolicy.FREE + + def on_mouse_press(self, mv: "ModelViewWidget", event) -> bool: + if event.button() == Qt.MouseButton.RightButton: + # В режиме контура ПКМ передаётся PyVista (remove point) + if mv._zone_selection_mode and mv.is_scenario_active("contour_edit"): + return False + mv._cam_rotate_active = True + mv._cam_last_pos = (event.position().x(), event.position().y()) + return True + + if event.button() == Qt.MouseButton.MiddleButton: + mv._cam_pan_active = True + mv._cam_last_pos = (event.position().x(), event.position().y()) + return True + return False + + def on_mouse_move(self, mv: "ModelViewWidget", event) -> bool: + # Safeguard: если release-событие потерялось (после модалок/фокуса), + # синхронизируем drag-флаги с реальным состоянием кнопок мыши. + try: + real_buttons = QGuiApplication.mouseButtons() + except Exception as _exc: + real_buttons = Qt.MouseButton.NoButton + if mv._cam_rotate_active and not (real_buttons & Qt.MouseButton.RightButton): + mv._cam_rotate_active = False + mv._cam_last_pos = None + if mv._cam_pan_active and not (real_buttons & Qt.MouseButton.MiddleButton): + mv._cam_pan_active = False + mv._cam_last_pos = None + + # Подавление ЛКМ drag (не конфликтует с click-сценариями) + if hasattr(event, "buttons") and (event.buttons() & Qt.MouseButton.LeftButton): + return True + + # ПКМ drag → вращение камеры + if ( + mv._cam_rotate_active + and hasattr(event, "buttons") + and (event.buttons() & Qt.MouseButton.RightButton) + ): + cx, cy = mv._cam_last_pos or (event.position().x(), event.position().y()) + nx, ny = event.position().x(), event.position().y() + dx, dy = float(nx - cx), float(ny - cy) + mv._cam_last_pos = (nx, ny) + try: + cam = mv._plotter.camera + cam.Azimuth(-dx * 0.35) + cam.Elevation(dy * 0.35) + cam.OrthogonalizeViewUp() + if hasattr(mv, "_reset_camera_clipping_range"): + mv._reset_camera_clipping_range() + mv._safe_render(min_interval_s=1.0 / 75.0) + except Exception as _exc: + log_exception(__name__, "on_mouse_move", _exc) + return True + + # СКМ drag → панорамирование + if ( + mv._cam_pan_active + and hasattr(event, "buttons") + and (event.buttons() & Qt.MouseButton.MiddleButton) + ): + cx, cy = mv._cam_last_pos or (event.position().x(), event.position().y()) + nx, ny = event.position().x(), event.position().y() + dx, dy = float(nx - cx), float(ny - cy) + mv._cam_last_pos = (nx, ny) + mv._pan_camera_by_pixels(dx, dy) + try: + if hasattr(mv, "_reset_camera_clipping_range"): + mv._reset_camera_clipping_range() + mv._safe_render(min_interval_s=1.0 / 75.0) + except Exception as _exc: + log_exception(__name__, "on_mouse_move", _exc) + return True + return False + + def on_mouse_release(self, mv: "ModelViewWidget", event) -> bool: + if event.button() == Qt.MouseButton.RightButton: + mv._cam_rotate_active = False + mv._cam_last_pos = None + return True + if event.button() == Qt.MouseButton.MiddleButton: + mv._cam_pan_active = False + mv._cam_last_pos = None + return True + return False diff --git a/Dispatch_V0.1.1/gui/components/model_view/_scenario_contour_edit.py b/Dispatch_V0.1.1/gui/components/model_view/_scenario_contour_edit.py new file mode 100644 index 0000000..9d295f0 --- /dev/null +++ b/Dispatch_V0.1.1/gui/components/model_view/_scenario_contour_edit.py @@ -0,0 +1,179 @@ +# -*- coding: utf-8 -*- +# gui/components/model_view/_scenario_contour_edit.py +"""Сценарий редактирования контура зоны. + +Охватывает: +- Drag узлов контура (ЛКМ hold + move) +- Candidate-узел контура (hover) +- Размерные линии (hover при _dim_enabled) +- Drag высоты выделения (mousePressEvent/Move/Release виджета) +""" + +from __future__ import annotations + +import math +from typing import TYPE_CHECKING + +from PySide6.QtCore import Qt + +from gui.components.model_view._interaction_scenario import ( + CameraPolicy, + InteractionScenario, +) +from error_logger import log_exception + +if TYPE_CHECKING: # pragma: no cover + from gui.components.model_view_widget import ModelViewWidget + + +class ContourEditScenario(InteractionScenario): + """Сценарий редактирования контура: drag узлов, candidate hover, dim lines, height drag.""" + + name = "contour_edit" + camera_policy = CameraPolicy.TOP_VIEW + + # -- Активация / деактивация (backward compat) ---------------------------- + + def on_activate(self, mv: "ModelViewWidget") -> None: + mv._zone_selection_mode = True + if hasattr(mv, "_set_trackball_right_button_enabled"): + mv._set_trackball_right_button_enabled(False) + + def on_deactivate(self, mv: "ModelViewWidget") -> None: + # Флаги сбрасываются в stop_contour_definition / cancel_zone_selection + if hasattr(mv, "_set_trackball_right_button_enabled"): + mv._set_trackball_right_button_enabled(True) + + # -- eventFilter: mouse press (ЛКМ — начать drag узла) -------------------- + + def on_mouse_press(self, mv: "ModelViewWidget", event) -> bool: + if event.button() == Qt.MouseButton.RightButton: + if not mv.is_scenario_active("contour_edit"): + return False + world = mv._event_world_on_plane(event, mv._get_contour_plane_z()) + if world is not None: + mv._handle_grid_click(float(world[0]), float(world[1]), float(world[2]), action="remove") + else: + if hasattr(mv, "set_contour_auxiliary_visibility"): + mv.set_contour_auxiliary_visibility(False) + # ПКМ всегда поглощаем в contour_edit, чтобы камера не перехватывала событие. + return True + + if event.button() != Qt.MouseButton.LeftButton: + return False + if not mv.is_scenario_active("contour_edit"): + return False + + world = mv._event_world_on_plane(event, mv._get_contour_plane_z()) + if world is None: + return False + + step = max(1.0, float(getattr(mv, "_current_zone_size", 1.0))) + max_dist = step * 0.55 + + nearest_idx = mv._nearest_contour_node_index( + world[0], world[1], max_dist=max_dist, + ) + if nearest_idx is None: + return False + if bool(getattr(mv, "_contour_aux_hidden", False)) and hasattr(mv, "set_contour_auxiliary_visibility"): + mv.set_contour_auxiliary_visibility(True) + + mv._contour_drag_active = True + mv._contour_drag_point_index = int(nearest_idx) + mv._contour_drag_moved = False + return True + + # -- eventFilter: mouse move (ЛКМ зажата — drag узла) -------------------- + + def on_mouse_move(self, mv: "ModelViewWidget", event) -> bool: + if not getattr(mv, "_contour_drag_active", False): + return False + if not (hasattr(event, "buttons") and (event.buttons() & Qt.MouseButton.LeftButton)): + return False + + world = mv._event_world_on_plane(event, mv._get_contour_plane_z()) + if world is not None and mv._update_contour_drag_point(world[0], world[1]): + mv._contour_drag_moved = True + mv._safe_render(min_interval_s=1.0 / 75.0) + return True + + # -- eventFilter: mouse release (ЛКМ — завершить drag) ------------------- + + def on_mouse_release(self, mv: "ModelViewWidget", event) -> bool: + if event.button() != Qt.MouseButton.LeftButton: + return False + if not getattr(mv, "_contour_drag_active", False): + return False + + moved = bool(getattr(mv, "_contour_drag_moved", False)) + mv._contour_drag_active = False + mv._contour_drag_point_index = None + mv._contour_drag_moved = False + if moved: + mv.set_ignore_next_click(True) + return True + + # -- eventFilter: hover (candidate узел + dim lines) ---------------------- + + def on_hover(self, mv: "ModelViewWidget", event) -> None: + if not mv.is_scenario_active("contour_edit"): + return + try: + sx, sy = mv._event_to_vtk_display_xy(event) + plane_z = mv._get_contour_plane_z() + world = mv.screen_to_world_on_plane(sx, sy, plane_z) + if world is not None: + mv.update_contour_candidate_node(world[0], world[1]) + if getattr(mv, "_dim_enabled", False): + mv.handle_dim_hover(world[0], world[1]) + except Exception as _exc: + log_exception(__name__, "on_hover", _exc) + # -- eventFilter: горячие клавиши ----------------------------------------- + + def on_key_press(self, mv: "ModelViewWidget", event) -> bool: + if event.key() == Qt.Key.Key_Escape: + # Первое ESC в контурном сценарии скрывает вспомогательные + # визуалы (candidate/dim), не прерывая визард. + if hasattr(mv, "set_contour_auxiliary_visibility") and not bool(getattr(mv, "_contour_aux_hidden", False)): + mv.set_contour_auxiliary_visibility(False) + return True + cancel = getattr(mv, "_global_cancel_handler", None) + if callable(cancel): + try: + cancel() + except Exception as _exc: + log_exception(__name__, "on_key_press", _exc) + return True + return False + + # -- mousePressEvent/Move/Release: drag высоты зоны ----------------------- + + def on_widget_mouse_press(self, mv: "ModelViewWidget", event) -> bool: + if ( + mv._zone_selection_mode + and mv._selected_cells + and event.button() == Qt.MouseButton.LeftButton + ): + mv._dragging = True + mv._drag_start_y = event.y() + event.accept() + return True + return False + + def on_widget_mouse_move(self, mv: "ModelViewWidget", event) -> bool: + if mv._dragging and mv._zone_selection_mode: + delta_y = mv._drag_start_y - event.y() + if delta_y != 0: + mv.update_height_from_mouse(delta_y) + mv._drag_start_y = event.y() + event.accept() + return True + return False + + def on_widget_mouse_release(self, mv: "ModelViewWidget", event) -> bool: + if mv._dragging and event.button() == Qt.MouseButton.LeftButton: + mv._dragging = False + event.accept() + return True + return False diff --git a/Dispatch_V0.1.1/gui/components/model_view/_scenario_custom_handler.py b/Dispatch_V0.1.1/gui/components/model_view/_scenario_custom_handler.py new file mode 100644 index 0000000..611aec1 --- /dev/null +++ b/Dispatch_V0.1.1/gui/components/model_view/_scenario_custom_handler.py @@ -0,0 +1,122 @@ +# -*- coding: utf-8 -*- +# gui/components/model_view/_scenario_custom_handler.py +"""Универсальный сценарий с инжектированными обработчиками. + +Покрывает режимы, использующие сценарный push/pop в InteractionManager: +- rack_placement (размещение стеллажей) +- rack_select (выбор стеллажа в зоне) +- shelf_placement (размещение полок) +- measure (режим измерений) +- origin_point (выбор точки привязки) + +Каждый экземпляр получает уникальное имя и набор обработчиков. +""" + +from __future__ import annotations + +import math +from typing import TYPE_CHECKING, Callable, Optional + +from PySide6.QtCore import Qt + +from gui.components.model_view._interaction_scenario import ( + CameraPolicy, + InteractionScenario, +) +from error_logger import log_exception + +if TYPE_CHECKING: # pragma: no cover + from gui.components.model_view_widget import ModelViewWidget + + +class CustomHandlerScenario(InteractionScenario): + """Сценарий с инжектированными hover/click обработчиками и горячими клавишами.""" + + def __init__( + self, + name: str, + *, + camera_policy: CameraPolicy = CameraPolicy.FREE, + click_handler: Optional[Callable] = None, + hover_handler: Optional[Callable] = None, + hover_screen_handler: Optional[Callable] = None, + hotkeys: Optional[dict[int, Callable]] = None, + on_activate_fn: Optional[Callable] = None, + on_deactivate_fn: Optional[Callable] = None, + ) -> None: + self.name = name + self.camera_policy = camera_policy + self._click_handler = click_handler + self._hover_handler = hover_handler + self._hover_screen_handler = hover_screen_handler + self._hotkeys: dict[int, Callable] = hotkeys or {} + self._on_activate_fn = on_activate_fn + self._on_deactivate_fn = on_deactivate_fn + + def on_activate(self, mv: "ModelViewWidget") -> None: + # Установить обработчики в mv + if self._click_handler is not None: + mv._custom_click_handler = self._click_handler + if self._hover_handler is not None: + mv._hover_handler = self._hover_handler + if self._hover_screen_handler is not None: + mv._hover_screen_handler = self._hover_screen_handler + if self._on_activate_fn: + self._on_activate_fn(mv) + + def on_deactivate(self, mv: "ModelViewWidget") -> None: + if self._on_deactivate_fn: + self._on_deactivate_fn(mv) + # Очищаем обработчики, если они были установлены нами + if self._click_handler is not None: + if mv._custom_click_handler is self._click_handler: + mv._custom_click_handler = None + if mv._hover_handler is self._hover_handler: + mv._hover_handler = None + if mv._hover_screen_handler is self._hover_screen_handler: + mv._hover_screen_handler = None + + def on_hover(self, mv: "ModelViewWidget", event) -> None: + """Вызов инжектированных hover-обработчиков.""" + try: + if self._hover_screen_handler is not None: + self._hover_screen_handler(event.position().x(), event.position().y()) + + if self._hover_handler is not None: + pos = mv._plotter.pick_mouse_position() if mv._plotter else None + if pos is None and mv._plotter and mv._plotter.picker and mv._plotter.renderer: + try: + sx, sy = mv._event_to_vtk_display_xy(event) + mv._plotter.picker.Pick(sx, sy, 0, mv._plotter.renderer) + pos = mv._plotter.picker.GetPickPosition() + except Exception as _exc: + pos = None + if pos is not None: + x, y, z = pos[0], pos[1], pos[2] + if all(math.isfinite(v) for v in (x, y, z)): + mv._last_hover_point = (x, y, z) + self._hover_handler(x, y, z) + elif mv._last_hover_point is not None: + self._hover_handler(*mv._last_hover_point) + except Exception as _exc: + log_exception(__name__, "on_hover", _exc) + def on_key_press(self, mv: "ModelViewWidget", event) -> bool: + handler = self._hotkeys.get(event.key()) + if handler is not None: + try: + result = handler() + return bool(result) if result is not None else True + except Exception as _exc: + log_exception(__name__, "on_key_press", _exc) + return True + + # Глобальный Esc fallback + if event.key() == Qt.Key.Key_Escape: + cancel = getattr(mv, "_global_cancel_handler", None) + if callable(cancel): + try: + cancel() + except Exception as _exc: + log_exception(__name__, "on_key_press", _exc) + return True + return False diff --git a/Dispatch_V0.1.1/gui/components/model_view/_scenario_facility_browse.py b/Dispatch_V0.1.1/gui/components/model_view/_scenario_facility_browse.py new file mode 100644 index 0000000..04b4e11 --- /dev/null +++ b/Dispatch_V0.1.1/gui/components/model_view/_scenario_facility_browse.py @@ -0,0 +1,39 @@ +# -*- coding: utf-8 -*- +# gui/components/model_view/_scenario_facility_browse.py +"""Сценарий обзора уровня facility. + +Hover по зонам: подсветка зоны под курсором (vtkPropPicker). +Клики обрабатываются через plotter callback (_on_plotter_click), не здесь. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from gui.components.model_view._interaction_scenario import ( + CameraPolicy, + InteractionScenario, +) +from error_logger import log_exception + +if TYPE_CHECKING: # pragma: no cover + from gui.components.model_view_widget import ModelViewWidget + + +class FacilityBrowseScenario(InteractionScenario): + """Обзор уровня facility: hover-подсветка зон.""" + + name = "facility_browse" + camera_policy = CameraPolicy.FREE + + def on_activate(self, mv: "ModelViewWidget") -> None: + mv._zone_pick_enabled = True + + def on_hover(self, mv: "ModelViewWidget", event) -> None: + if not mv._zone_pick_enabled: + return + try: + sx, sy = mv._event_to_vtk_display_xy(event) + mv._handle_zone_hover_event(sx, sy) + except Exception as _exc: + log_exception(__name__, "on_hover", _exc) diff --git a/Dispatch_V0.1.1/gui/components/model_view/contour/__init__.py b/Dispatch_V0.1.1/gui/components/model_view/contour/__init__.py new file mode 100644 index 0000000..efd9bf4 --- /dev/null +++ b/Dispatch_V0.1.1/gui/components/model_view/contour/__init__.py @@ -0,0 +1,52 @@ +# -*- coding: utf-8 -*- +# gui/components/model_view/contour/__init__.py + +from gui.components.model_view.contour.model_view_contour_facade import ModelViewContourFacade +from gui.components.model_view.contour.legacy_contour_binding_component import LegacyContourBindingComponent +from gui.components.model_view.contour.contour_definition_component import ContourDefinitionComponent +from gui.components.model_view.contour.contour_definition_component_part2 import ( + ContourDefinitionComponentPart2, +) +from gui.components.model_view.contour.contour_overlay_component import ContourOverlayComponent +from gui.components.model_view.contour.contour_overlay_component_part2 import ( + ContourOverlayComponentPart2, +) +from gui.components.model_view.contour.contour_overlay_selection_component import ( + ContourOverlaySelectionComponent, +) +from gui.components.model_view.contour.contour_visualization_component import ( + ContourVisualizationComponent, +) +from gui.components.model_view.contour.contour_visualization_component_part2 import ( + ContourVisualizationComponentPart2, +) +from gui.components.model_view.contour.contour_geometry_component import ContourGeometryComponent +from gui.components.model_view.contour.contour_geometry_component_part2 import ( + ContourGeometryComponentPart2, +) +from gui.components.model_view.contour.contour_selection_component import ContourSelectionComponent +from gui.components.model_view.contour.contour_preview_component import ContourPreviewComponent +from gui.components.model_view.contour.contour_preview_component_part2 import ( + ContourPreviewComponentPart2, +) +from gui.components.model_view.contour.contour_preview_contour_component import ( + ContourPreviewContourComponent, +) + +__all__ = [ + "ModelViewContourFacade", + "LegacyContourBindingComponent", + "ContourDefinitionComponent", + "ContourDefinitionComponentPart2", + "ContourOverlayComponent", + "ContourOverlayComponentPart2", + "ContourOverlaySelectionComponent", + "ContourVisualizationComponent", + "ContourVisualizationComponentPart2", + "ContourGeometryComponent", + "ContourGeometryComponentPart2", + "ContourSelectionComponent", + "ContourPreviewComponent", + "ContourPreviewComponentPart2", + "ContourPreviewContourComponent", +] diff --git a/Dispatch_V0.1.1/gui/components/model_view/contour/__pycache__/__init__.cpython-313.pyc b/Dispatch_V0.1.1/gui/components/model_view/contour/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..8f509cd Binary files /dev/null and b/Dispatch_V0.1.1/gui/components/model_view/contour/__pycache__/__init__.cpython-313.pyc differ diff --git a/Dispatch_V0.1.1/gui/components/model_view/contour/__pycache__/contour_definition_component.cpython-313.pyc b/Dispatch_V0.1.1/gui/components/model_view/contour/__pycache__/contour_definition_component.cpython-313.pyc new file mode 100644 index 0000000..95551e8 Binary files /dev/null and b/Dispatch_V0.1.1/gui/components/model_view/contour/__pycache__/contour_definition_component.cpython-313.pyc differ diff --git a/Dispatch_V0.1.1/gui/components/model_view/contour/__pycache__/contour_definition_component_part2.cpython-313.pyc b/Dispatch_V0.1.1/gui/components/model_view/contour/__pycache__/contour_definition_component_part2.cpython-313.pyc new file mode 100644 index 0000000..b30e57b Binary files /dev/null and b/Dispatch_V0.1.1/gui/components/model_view/contour/__pycache__/contour_definition_component_part2.cpython-313.pyc differ diff --git a/Dispatch_V0.1.1/gui/components/model_view/contour/__pycache__/contour_geometry_component.cpython-313.pyc b/Dispatch_V0.1.1/gui/components/model_view/contour/__pycache__/contour_geometry_component.cpython-313.pyc new file mode 100644 index 0000000..0ef42df Binary files /dev/null and b/Dispatch_V0.1.1/gui/components/model_view/contour/__pycache__/contour_geometry_component.cpython-313.pyc differ diff --git a/Dispatch_V0.1.1/gui/components/model_view/contour/__pycache__/contour_geometry_component_part2.cpython-313.pyc b/Dispatch_V0.1.1/gui/components/model_view/contour/__pycache__/contour_geometry_component_part2.cpython-313.pyc new file mode 100644 index 0000000..fd04f43 Binary files /dev/null and b/Dispatch_V0.1.1/gui/components/model_view/contour/__pycache__/contour_geometry_component_part2.cpython-313.pyc differ diff --git a/Dispatch_V0.1.1/gui/components/model_view/contour/__pycache__/contour_overlay_component.cpython-313.pyc b/Dispatch_V0.1.1/gui/components/model_view/contour/__pycache__/contour_overlay_component.cpython-313.pyc new file mode 100644 index 0000000..1535630 Binary files /dev/null and b/Dispatch_V0.1.1/gui/components/model_view/contour/__pycache__/contour_overlay_component.cpython-313.pyc differ diff --git a/Dispatch_V0.1.1/gui/components/model_view/contour/__pycache__/contour_overlay_component_part2.cpython-313.pyc b/Dispatch_V0.1.1/gui/components/model_view/contour/__pycache__/contour_overlay_component_part2.cpython-313.pyc new file mode 100644 index 0000000..dd503b1 Binary files /dev/null and b/Dispatch_V0.1.1/gui/components/model_view/contour/__pycache__/contour_overlay_component_part2.cpython-313.pyc differ diff --git a/Dispatch_V0.1.1/gui/components/model_view/contour/__pycache__/contour_overlay_selection_component.cpython-313.pyc b/Dispatch_V0.1.1/gui/components/model_view/contour/__pycache__/contour_overlay_selection_component.cpython-313.pyc new file mode 100644 index 0000000..a190663 Binary files /dev/null and b/Dispatch_V0.1.1/gui/components/model_view/contour/__pycache__/contour_overlay_selection_component.cpython-313.pyc differ diff --git a/Dispatch_V0.1.1/gui/components/model_view/contour/__pycache__/contour_preview_component.cpython-313.pyc b/Dispatch_V0.1.1/gui/components/model_view/contour/__pycache__/contour_preview_component.cpython-313.pyc new file mode 100644 index 0000000..c86a40d Binary files /dev/null and b/Dispatch_V0.1.1/gui/components/model_view/contour/__pycache__/contour_preview_component.cpython-313.pyc differ diff --git a/Dispatch_V0.1.1/gui/components/model_view/contour/__pycache__/contour_preview_component_part2.cpython-313.pyc b/Dispatch_V0.1.1/gui/components/model_view/contour/__pycache__/contour_preview_component_part2.cpython-313.pyc new file mode 100644 index 0000000..ffb5c53 Binary files /dev/null and b/Dispatch_V0.1.1/gui/components/model_view/contour/__pycache__/contour_preview_component_part2.cpython-313.pyc differ diff --git a/Dispatch_V0.1.1/gui/components/model_view/contour/__pycache__/contour_preview_contour_component.cpython-313.pyc b/Dispatch_V0.1.1/gui/components/model_view/contour/__pycache__/contour_preview_contour_component.cpython-313.pyc new file mode 100644 index 0000000..9a7e923 Binary files /dev/null and b/Dispatch_V0.1.1/gui/components/model_view/contour/__pycache__/contour_preview_contour_component.cpython-313.pyc differ diff --git a/Dispatch_V0.1.1/gui/components/model_view/contour/__pycache__/contour_selection_component.cpython-313.pyc b/Dispatch_V0.1.1/gui/components/model_view/contour/__pycache__/contour_selection_component.cpython-313.pyc new file mode 100644 index 0000000..ed995f4 Binary files /dev/null and b/Dispatch_V0.1.1/gui/components/model_view/contour/__pycache__/contour_selection_component.cpython-313.pyc differ diff --git a/Dispatch_V0.1.1/gui/components/model_view/contour/__pycache__/contour_visualization_component.cpython-313.pyc b/Dispatch_V0.1.1/gui/components/model_view/contour/__pycache__/contour_visualization_component.cpython-313.pyc new file mode 100644 index 0000000..f14a4f6 Binary files /dev/null and b/Dispatch_V0.1.1/gui/components/model_view/contour/__pycache__/contour_visualization_component.cpython-313.pyc differ diff --git a/Dispatch_V0.1.1/gui/components/model_view/contour/__pycache__/contour_visualization_component_part2.cpython-313.pyc b/Dispatch_V0.1.1/gui/components/model_view/contour/__pycache__/contour_visualization_component_part2.cpython-313.pyc new file mode 100644 index 0000000..6710d40 Binary files /dev/null and b/Dispatch_V0.1.1/gui/components/model_view/contour/__pycache__/contour_visualization_component_part2.cpython-313.pyc differ diff --git a/Dispatch_V0.1.1/gui/components/model_view/contour/__pycache__/legacy_contour_binding_component.cpython-313.pyc b/Dispatch_V0.1.1/gui/components/model_view/contour/__pycache__/legacy_contour_binding_component.cpython-313.pyc new file mode 100644 index 0000000..4c5de35 Binary files /dev/null and b/Dispatch_V0.1.1/gui/components/model_view/contour/__pycache__/legacy_contour_binding_component.cpython-313.pyc differ diff --git a/Dispatch_V0.1.1/gui/components/model_view/contour/__pycache__/model_view_contour_facade.cpython-313.pyc b/Dispatch_V0.1.1/gui/components/model_view/contour/__pycache__/model_view_contour_facade.cpython-313.pyc new file mode 100644 index 0000000..3cf3b21 Binary files /dev/null and b/Dispatch_V0.1.1/gui/components/model_view/contour/__pycache__/model_view_contour_facade.cpython-313.pyc differ diff --git a/Dispatch_V0.1.1/gui/components/model_view/contour/contour_definition_component.py b/Dispatch_V0.1.1/gui/components/model_view/contour/contour_definition_component.py new file mode 100644 index 0000000..517027f --- /dev/null +++ b/Dispatch_V0.1.1/gui/components/model_view/contour/contour_definition_component.py @@ -0,0 +1,137 @@ +# -*- coding: utf-8 -*- +# gui/components/model_view/contour/contour_definition_component.py + +# -*- coding: utf-8 -*- +# gui/components/model_view/_mv_grid_contour_definition.py +# Жизненный цикл определения контура + управление точками + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from error_logger import log_exception + +if TYPE_CHECKING: # pragma: no cover + from gui.components.model_view_widget import ModelViewWidget + +class ContourDefinitionComponent: + def set_contour_point_insert_strategy(self: "ModelViewWidget", strategy: str) -> None: + """Задать стратегию вставки узлов контура: append | nearest_segment.""" + value = str(strategy or "").strip().lower() + if value not in {"append", "nearest_segment"}: + value = "append" + self._contour_point_insert_strategy = value + + def start_contour_definition(self: "ModelViewWidget") -> None: + """Включить режим построения прямоугольного контура по узлам сетки.""" + self._contour_point_insert_strategy = "append" + self._contour_aux_hidden = False + self._contour_drag_active = False + self._contour_drag_point_index = None + self._contour_drag_moved = False + self._contour_ready = False + try: + self.contour_ready_changed.emit(False) + except Exception as e: + log_exception(__name__, "start_contour_definition", e) + self._contour_points = [] + self._selected_cells = set() + self._selection_anchor = None + self._selection_start_cell = None + self._update_contour_visualization() + self._update_selection_visualization() + # Сценарий взаимодействия + mgr = getattr(self, "_interaction_manager", None) + if mgr is not None: + from gui.components.model_view._scenario_contour_edit import ContourEditScenario + mgr.push(ContourEditScenario()) + + def stop_contour_definition(self: "ModelViewWidget") -> None: + """Выключить режим построения контура.""" + # Убрать сценарий взаимодействия + mgr = getattr(self, "_interaction_manager", None) + if mgr is not None: + mgr.pop_by_name("contour_edit") + self._restore_overlay_parent_visual() + self._contour_drag_active = False + self._contour_drag_point_index = None + self._contour_drag_moved = False + self._contour_point_insert_strategy = "append" + self._contour_aux_hidden = False + was_ready = bool(getattr(self, "_contour_ready", False)) + self._contour_ready = False + if was_ready: + try: + self.contour_ready_changed.emit(False) + except Exception as e: + log_exception(__name__, "stop_contour_definition", e) + self._contour_points = [] + self._clear_contour_actors() + # Очистить размерные линии + if getattr(self, "_dim_enabled", False): + self.disable_dimension_lines() + # Очистить состояние overlay-выбора зоны, если оно было активно + self._cleanup_overlay_selection() + + def set_contour_zone_overlay_enabled(self: "ModelViewWidget", enabled: bool) -> None: + """Включить/выключить режим наложения контура на существующие зоны.""" + self._contour_zone_overlay_enabled = bool(enabled) + if not enabled: + self._restore_overlay_parent_visual() + self._contour_zone_mode = None + self._contour_zone_id = None + self._grid_plane_z_override = None + self._clear_surface_grid() + try: + self._show_grid_actors() + except Exception as e: + log_exception(__name__, "set_contour_zone_overlay_enabled", e) + + def _clear_surface_grid(self: "ModelViewWidget") -> None: + meshes = getattr(self, "_grid_surface_meshes", []) + for mesh in list(meshes): + try: + if self._plotter: + self._plotter.remove_actor(mesh) + except Exception as e: + log_exception(__name__, "_clear_surface_grid", e) + self._grid_surface_meshes = [] + self._grid_surface_nodes = [] + + def _set_ground_mode(self: "ModelViewWidget") -> None: + """Активировать разметку на базовой (напольной) сетке.""" + self._restore_overlay_parent_visual() + self._contour_zone_mode = "ground" + self._contour_zone_id = None + self._grid_plane_z_override = None + self._clear_surface_grid() + try: + self._show_grid_actors() + except Exception as e: + log_exception(__name__, "_set_ground_mode", e) + + def finalize_contour_selection(self: "ModelViewWidget") -> bool: + """Зафиксировать объём по точечной разметке и отключить дальнейшее создание кликами.""" + polygon = self._extract_orthogonal_polygon() + if polygon is None: + return False + if not self._validate_contour_closing_segment(): + return False + self._update_selected_cells_from_contour() + if not self._selected_cells: + return False + self._selected_height = 0 + self._update_selection_visualization() + self._final_contour_points = list(polygon) + self.stop_contour_definition() + self._zone_selection_mode = False + self._volume_locked_from_contour = True + try: + self._clear_surface_grid() + except Exception as e: + log_exception(__name__, "finalize_contour_selection", e) + try: + self._hide_grid_actors() + except Exception as e: + log_exception(__name__, "finalize_contour_selection", e) + return True diff --git a/Dispatch_V0.1.1/gui/components/model_view/contour/contour_definition_component_part2.py b/Dispatch_V0.1.1/gui/components/model_view/contour/contour_definition_component_part2.py new file mode 100644 index 0000000..8a2989f --- /dev/null +++ b/Dispatch_V0.1.1/gui/components/model_view/contour/contour_definition_component_part2.py @@ -0,0 +1,192 @@ +# -*- coding: utf-8 -*- +# gui/components/model_view/contour/contour_definition_component_part2.py + +# -*- coding: utf-8 -*- +# gui/components/model_view/_mv_grid_contour_definition.py +# Жизненный цикл определения контура + управление точками + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from error_logger import log_exception + +if TYPE_CHECKING: # pragma: no cover + from gui.components.model_view_widget import ModelViewWidget + +class ContourDefinitionComponentPart2: + def _append_contour_point(self: "ModelViewWidget", node: tuple[float, float]) -> bool: + """Добавить точку в контур с предсказуемой вставкой в ближайший сегмент.""" + if node is None: + return False + n = (float(node[0]), float(node[1])) + if n in self._contour_points: + return False + strategy = str(getattr(self, "_contour_point_insert_strategy", "append") or "append") + if strategy != "nearest_segment": + self._contour_points.append(n) + return True + + # Явный алгоритм вставки: + # 1) если точек < 2 — добавляем в конец; + # 2) иначе вставляем между вершинами ближайшего сегмента; + # 3) для замыкающего сегмента last->first вставляем в конец. + points = list(self._contour_points) + if len(points) < 2: + self._contour_points.append(n) + return True + + best_seg_idx = None + best_score = None + segments: list[tuple[int, tuple[float, float], tuple[float, float]]] = [] + for i in range(len(points) - 1): + segments.append((i, points[i], points[i + 1])) + if len(points) >= 3: + segments.append((len(points) - 1, points[-1], points[0])) + + for seg_idx, a, b in segments: + metrics = self._segment_metrics(n[0], n[1], a, b) + if metrics is None: + continue + dist2, on_span, orth_to_segment, orth_to_node, node_dist2 = metrics + score = self._segment_priority_score( + orth_to_segment=orth_to_segment, + orth_to_node=orth_to_node, + on_span=on_span, + dist2=dist2, + node_dist2=node_dist2, + ) + if best_score is None or score < best_score: + best_score = score + best_seg_idx = seg_idx + + if best_seg_idx is None or best_score is None: + self._contour_points.append(n) + return True + + if best_seg_idx == len(points) - 1: + self._contour_points.append(n) + return True + + self._contour_points.insert(best_seg_idx + 1, n) + return True + + def _segment_priority_score( + self: "ModelViewWidget", + *, + orth_to_segment: bool, + orth_to_node: bool, + on_span: bool, + dist2: float, + node_dist2: float, + ) -> tuple[int, int, int, float, float]: + """Оценка для вставки в сегмент. + + Приоритет: + 1) ортогональное отношение к сегменту; + 2) ортогональное отношение к одной из концевых точек сегмента; + 3) точка находится между концевыми точками сегмента; + 4) геометрическое расстояние до сегмента; + 5) расстояние до ближайшей концевой точки сегмента. + """ + return ( + 0 if orth_to_segment else 1, + 0 if orth_to_node else 1, + 0 if on_span else 1, + float(dist2), + float(node_dist2), + ) + + def _segment_metrics( + self: "ModelViewWidget", + px: float, + py: float, + a: tuple[float, float], + b: tuple[float, float], + ) -> tuple[float, bool, bool, bool, float] | None: + """Метрики близости и ортогональности для осе-выровненного сегмента.""" + ax, ay = float(a[0]), float(a[1]) + bx, by = float(b[0]), float(b[1]) + step = max(1.0, float(getattr(self, "_current_zone_size", 1.0))) + snap_tol = max(1e-6, step * 0.2) + eps = max(1e-6, step * 0.05) + if abs(ax - bx) <= eps: + on_span = (min(ay, by) - eps) <= py <= (max(ay, by) + eps) + y_proj = min(max(py, min(ay, by)), max(ay, by)) + dist2 = (px - ax) ** 2 + (py - y_proj) ** 2 + orth_to_segment = abs(px - ax) <= snap_tol and on_span + orth_to_node = ( + abs(px - ax) <= snap_tol + or abs(py - ay) <= snap_tol + or abs(py - by) <= snap_tol + ) + node_dist2 = min( + (px - ax) ** 2 + (py - ay) ** 2, + (px - bx) ** 2 + (py - by) ** 2, + ) + return float(dist2), bool(on_span), bool(orth_to_segment), bool(orth_to_node), float(node_dist2) + if abs(ay - by) <= eps: + on_span = (min(ax, bx) - eps) <= px <= (max(ax, bx) + eps) + x_proj = min(max(px, min(ax, bx)), max(ax, bx)) + dist2 = (px - x_proj) ** 2 + (py - ay) ** 2 + orth_to_segment = abs(py - ay) <= snap_tol and on_span + orth_to_node = ( + abs(py - ay) <= snap_tol + or abs(px - ax) <= snap_tol + or abs(px - bx) <= snap_tol + ) + node_dist2 = min( + (px - ax) ** 2 + (py - ay) ** 2, + (px - bx) ** 2 + (py - by) ** 2, + ) + return float(dist2), bool(on_span), bool(orth_to_segment), bool(orth_to_node), float(node_dist2) + return None + + def _nearest_contour_node_index( + self: "ModelViewWidget", + px: float, + py: float, + max_dist: float, + ) -> int | None: + """Вернуть индекс ближайшего узла контура в пределах max_dist.""" + points = list(getattr(self, "_contour_points", []) or []) + if not points: + return None + max_dist2 = float(max_dist) * float(max_dist) + best_idx = None + best_dist2 = None + for idx, (nx, ny) in enumerate(points): + d2 = (float(px) - float(nx)) ** 2 + (float(py) - float(ny)) ** 2 + if d2 > max_dist2: + continue + if best_dist2 is None or d2 < best_dist2: + best_dist2 = d2 + best_idx = idx + return best_idx + + def _remove_nearest_contour_point(self: "ModelViewWidget", x: float, y: float) -> bool: + if not self._contour_points: + return False + step = max(1.0, float(getattr(self, "_current_zone_size", 1.0))) + max_dist2 = (step * 0.55) ** 2 + best_idx = None + best_dist2 = None + for idx, (px, py) in enumerate(self._contour_points): + d2 = (px - x) ** 2 + (py - y) ** 2 + if best_dist2 is None or d2 < best_dist2: + best_dist2 = d2 + best_idx = idx + if best_idx is None or best_dist2 is None or best_dist2 > max_dist2: + return False + self._contour_points.pop(best_idx) + if not self._contour_points: + self._restore_overlay_parent_visual() + self._contour_zone_mode = None + self._contour_zone_id = None + self._grid_plane_z_override = None + self._clear_surface_grid() + try: + self._show_grid_actors() + except Exception as e: + log_exception(__name__, "_remove_nearest_contour_point", e) + return True diff --git a/Dispatch_V0.1.1/gui/components/model_view/contour/contour_geometry_component.py b/Dispatch_V0.1.1/gui/components/model_view/contour/contour_geometry_component.py new file mode 100644 index 0000000..efa0cea --- /dev/null +++ b/Dispatch_V0.1.1/gui/components/model_view/contour/contour_geometry_component.py @@ -0,0 +1,182 @@ +# -*- coding: utf-8 -*- +# gui/components/model_view/contour/contour_geometry_component.py + +# -*- coding: utf-8 -*- +# gui/components/model_view/_mv_grid_sel_geom.py +# Геометрия / помощники полигонов, поиск ячеек + +from __future__ import annotations + +from typing import Optional, Tuple, TYPE_CHECKING + +if TYPE_CHECKING: # pragma: no cover + from gui.components.model_view_widget import ModelViewWidget + +class ContourGeometryComponent: + def _is_axis_aligned_segment( + self: "ModelViewWidget", + a: tuple[float, float], + b: tuple[float, float], + eps: float = 1e-6, + ) -> bool: + return abs(a[0] - b[0]) <= eps or abs(a[1] - b[1]) <= eps + + def _extract_orthogonal_polygon( + self: "ModelViewWidget", + ) -> Optional[list[tuple[float, float]]]: + points = list(self._contour_points) + points = self._dedupe_contour_points(points) + points = self._simplify_collinear_contour_points(points, closed=True) + if len(points) < 4: + return None + eps = 1e-6 + unique = set((round(p[0], 6), round(p[1], 6)) for p in points) + if len(unique) < 4: + return None + n = len(points) + for i in range(n): + a = points[i] + b = points[(i + 1) % n] + if not self._is_axis_aligned_segment(a, b, eps): + return None + if abs(a[0] - b[0]) <= eps and abs(a[1] - b[1]) <= eps: + return None + return points + + def _dedupe_contour_points( + self: "ModelViewWidget", + points: list[tuple[float, float]], + eps: float = 1e-6, + ) -> list[tuple[float, float]]: + if not points: + return [] + out: list[tuple[float, float]] = [] + for px, py in points: + p = (float(px), float(py)) + if not out: + out.append(p) + continue + lx, ly = out[-1] + if abs(lx - p[0]) <= eps and abs(ly - p[1]) <= eps: + continue + out.append(p) + return out + + def _is_collinear_middle_point( + self: "ModelViewWidget", + prev_pt: tuple[float, float], + mid_pt: tuple[float, float], + next_pt: tuple[float, float], + eps: float = 1e-6, + ) -> bool: + px, py = prev_pt + mx, my = mid_pt + nx, ny = next_pt + if abs(px - mx) <= eps and abs(mx - nx) <= eps: + mn_y = min(py, ny) - eps + mx_y = max(py, ny) + eps + return mn_y <= my <= mx_y + if abs(py - my) <= eps and abs(my - ny) <= eps: + mn_x = min(px, nx) - eps + mx_x = max(px, nx) + eps + return mn_x <= mx <= mx_x + return False + + def _simplify_collinear_contour_points( + self: "ModelViewWidget", + points: list[tuple[float, float]], + closed: bool, + eps: float = 1e-6, + ) -> list[tuple[float, float]]: + out = list(points) + if len(out) < 3: + return out + changed = True + while changed and len(out) >= 3: + changed = False + if closed: + limit = len(out) + for i in range(limit): + if len(out) < 4: + break + prev_pt = out[(i - 1) % len(out)] + mid_pt = out[i % len(out)] + next_pt = out[(i + 1) % len(out)] + if self._is_collinear_middle_point(prev_pt, mid_pt, next_pt, eps=eps): + out.pop(i % len(out)) + changed = True + break + else: + for i in range(1, len(out) - 1): + prev_pt = out[i - 1] + mid_pt = out[i] + next_pt = out[i + 1] + if self._is_collinear_middle_point(prev_pt, mid_pt, next_pt, eps=eps): + out.pop(i) + changed = True + break + return out + + def _point_on_segment( + self: "ModelViewWidget", + px: float, py: float, + a: tuple[float, float], + b: tuple[float, float], + eps: float = 1e-6, + ) -> bool: + if abs(a[0] - b[0]) <= eps: + if abs(px - a[0]) > eps: + return False + return min(a[1], b[1]) - eps <= py <= max(a[1], b[1]) + eps + if abs(a[1] - b[1]) <= eps: + if abs(py - a[1]) > eps: + return False + return min(a[0], b[0]) - eps <= px <= max(a[0], b[0]) + eps + return False + + def _point_inside_or_on_polygon( + self: "ModelViewWidget", + px: float, py: float, + polygon: list[tuple[float, float]], + ) -> bool: + eps = 1e-6 + inside = False + n = len(polygon) + for i in range(n): + x1, y1 = polygon[i] + x2, y2 = polygon[(i + 1) % n] + if self._point_on_segment(px, py, (x1, y1), (x2, y2), eps): + return True + if (y1 > py) != (y2 > py): + denom = (y2 - y1) if abs(y2 - y1) > eps else eps + xinters = (x2 - x1) * (py - y1) / denom + x1 + if px < xinters: + inside = not inside + return inside + + def _update_selected_cells_from_contour(self: "ModelViewWidget") -> None: + polygon = self._extract_orthogonal_polygon() + if polygon is None: + self._selected_cells = set() + self._selection_anchor = None + self._selection_start_cell = None + self._update_selection_visualization() + return + + selected = set() + for cell_id, (mn_x, mx_x, mn_y, mx_y) in self._grid_cells.items(): + cx = (mn_x + mx_x) * 0.5 + cy = (mn_y + mx_y) * 0.5 + if self._point_inside_or_on_polygon(cx, cy, polygon): + selected.add(cell_id) + self._selected_cells = selected + self._selection_anchor = self._resolve_selection_anchor() + self._selection_start_cell = None + if self._selection_anchor is not None: + ax, ay = self._selection_anchor + eps = 1e-6 + for _, (mn_x, mx_x, mn_y, mx_y) in self._grid_cells.items(): + if abs(mn_x - ax) <= eps and abs(mn_y - ay) <= eps: + self._selection_start_cell = (mn_x, mx_x, mn_y, mx_y) + break + self._update_selection_visualization() diff --git a/Dispatch_V0.1.1/gui/components/model_view/contour/contour_geometry_component_part2.py b/Dispatch_V0.1.1/gui/components/model_view/contour/contour_geometry_component_part2.py new file mode 100644 index 0000000..66208cf --- /dev/null +++ b/Dispatch_V0.1.1/gui/components/model_view/contour/contour_geometry_component_part2.py @@ -0,0 +1,73 @@ +# -*- coding: utf-8 -*- +# gui/components/model_view/contour/contour_geometry_component_part2.py + +# -*- coding: utf-8 -*- +# gui/components/model_view/_mv_grid_sel_geom.py +# Геометрия / помощники полигонов, поиск ячеек + +from __future__ import annotations + +from typing import Optional, Tuple, TYPE_CHECKING + +if TYPE_CHECKING: # pragma: no cover + from gui.components.model_view_widget import ModelViewWidget + +class ContourGeometryComponentPart2: + def _find_cell_by_point( + self: "ModelViewWidget", + x: float, y: float, + eps: float = 1e-4, + prefer_selected: bool = False, + ) -> Optional[Tuple[int, Tuple[float, float, float, float]]]: + def project_dist2(bounds: tuple[float, float, float, float]) -> float: + mn_x, mx_x, mn_y, mx_y = bounds + px = min(max(x, mn_x), mx_x) + py = min(max(y, mn_y), mx_y) + return (x - px) ** 2 + (y - py) ** 2 + + candidates: list[tuple[float, int, tuple[float, float, float, float]]] = [] + selected_candidates: list[tuple[float, int, tuple[float, float, float, float]]] = [] + all_cells: list[tuple[float, int, tuple[float, float, float, float]]] = [] + selected_all_cells: list[tuple[float, int, tuple[float, float, float, float]]] = [] + + for cell_id, bounds in self._grid_cells.items(): + mn_x, mx_x, mn_y, mx_y = bounds + dist2 = project_dist2(bounds) + row = (dist2, cell_id, bounds) + all_cells.append(row) + if cell_id in self._selected_cells: + selected_all_cells.append(row) + if (mn_x - eps) <= x <= (mx_x + eps) and (mn_y - eps) <= y <= (mx_y + eps): + candidates.append(row) + if cell_id in self._selected_cells: + selected_candidates.append(row) + + if prefer_selected and selected_candidates: + selected_candidates.sort(key=lambda item: item[0]) + _, best_id, best_bounds = selected_candidates[0] + return best_id, best_bounds + + if prefer_selected and selected_all_cells: + selected_all_cells.sort(key=lambda item: item[0]) + best_dist2, best_id, best_bounds = selected_all_cells[0] + step = max(1.0, float(getattr(self, "_current_zone_size", 1.0))) + max_dist2 = (step * 0.75) ** 2 + if best_dist2 <= max_dist2: + return best_id, best_bounds + return None + + if candidates: + candidates.sort(key=lambda item: item[0]) + _, best_id, best_bounds = candidates[0] + return best_id, best_bounds + + if all_cells: + all_cells.sort(key=lambda item: item[0]) + best_dist2, best_id, best_bounds = all_cells[0] + step = max(1.0, float(getattr(self, "_current_zone_size", 1.0))) + max_dist2 = step * step + if best_dist2 <= max_dist2: + return best_id, best_bounds + return None + + return None diff --git a/Dispatch_V0.1.1/gui/components/model_view/contour/contour_overlay_component.py b/Dispatch_V0.1.1/gui/components/model_view/contour/contour_overlay_component.py new file mode 100644 index 0000000..461914b --- /dev/null +++ b/Dispatch_V0.1.1/gui/components/model_view/contour/contour_overlay_component.py @@ -0,0 +1,203 @@ +# -*- coding: utf-8 -*- +# gui/components/model_view/contour/contour_overlay_component.py + +# -*- coding: utf-8 -*- +# gui/components/model_view/_mv_grid_contour_overlay.py +# Режим наложения и обнаружение / валидация зон + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from PySide6.QtCore import QTimer +from PySide6.QtWidgets import QMessageBox + +if TYPE_CHECKING: # pragma: no cover + from gui.components.model_view_widget import ModelViewWidget + +try: + import pyvista as pv +except ImportError: # pragma: no cover + pv = None + +class ContourOverlayComponent: + def _get_contour_plane_z(self: "ModelViewWidget") -> float: + z = self._get_grid_plane_z(getattr(self, "_grid_origin", None)) + if ( + self._contour_zone_overlay_enabled + and self._contour_zone_mode == "overlay" + and self._grid_plane_z_override is not None + ): + z = float(self._grid_plane_z_override) + return float(z) + + def _get_contour_blocking_polygons(self: "ModelViewWidget") -> list[list[tuple[float, float]]]: + plane_z = self._get_contour_plane_z() + step = max(1.0, float(getattr(self, "_current_zone_size", 1.0))) + eps = max(1e-6, step * 0.25) + ignore_zone_id = self._contour_zone_id if self._contour_zone_mode == "overlay" else None + edit_ignore_zone_id = getattr(self, "_contour_ignore_zone_id", None) + blockers: list[list[tuple[float, float]]] = [] + for zone_id, polygon in self._zone_polygons.items(): + if zone_id == ignore_zone_id: + continue + if edit_ignore_zone_id and zone_id == edit_ignore_zone_id: + continue + if not self._zone_intersects_height(zone_id, plane_z, eps=eps): + continue + if polygon and len(polygon) >= 3: + blockers.append(polygon) + return blockers + + def _point_in_polygon_strict( + self: "ModelViewWidget", + px: float, + py: float, + polygon: list[tuple[float, float]], + eps: float, + ) -> bool: + inside, boundary = self._classify_point_in_polygon(px, py, polygon, eps=eps) + return bool(inside and not boundary) + + def _segments_strictly_intersect( + self: "ModelViewWidget", + a: tuple[float, float], + b: tuple[float, float], + c: tuple[float, float], + d: tuple[float, float], + eps: float, + ) -> bool: + def orient(p: tuple[float, float], q: tuple[float, float], r: tuple[float, float]) -> float: + return (q[0] - p[0]) * (r[1] - p[1]) - (q[1] - p[1]) * (r[0] - p[0]) + + o1 = orient(a, b, c) + o2 = orient(a, b, d) + o3 = orient(c, d, a) + o4 = orient(c, d, b) + + if abs(o1) <= eps or abs(o2) <= eps or abs(o3) <= eps or abs(o4) <= eps: + return False + return (o1 > 0) != (o2 > 0) and (o3 > 0) != (o4 > 0) + + def _segment_crosses_polygon_strict( + self: "ModelViewWidget", + a: tuple[float, float], + b: tuple[float, float], + polygon: list[tuple[float, float]], + eps: float, + ) -> bool: + if self._point_in_polygon_strict(a[0], a[1], polygon, eps=eps): + return True + if self._point_in_polygon_strict(b[0], b[1], polygon, eps=eps): + return True + + n = len(polygon) + for i in range(n): + c = polygon[i] + d = polygon[(i + 1) % n] + if self._segments_strictly_intersect(a, b, c, d, eps=eps): + return True + + mx = (a[0] + b[0]) * 0.5 + my = (a[1] + b[1]) * 0.5 + if self._point_in_polygon_strict(mx, my, polygon, eps=eps): + return True + return False + + def _segments_intersect_or_touch( + self: "ModelViewWidget", + a: tuple[float, float], + b: tuple[float, float], + c: tuple[float, float], + d: tuple[float, float], + eps: float, + ) -> bool: + """Пересечение отрезков с учётом касаний и коллинеарности.""" + + def orient(p: tuple[float, float], q: tuple[float, float], r: tuple[float, float]) -> float: + return (q[0] - p[0]) * (r[1] - p[1]) - (q[1] - p[1]) * (r[0] - p[0]) + + o1 = orient(a, b, c) + o2 = orient(a, b, d) + o3 = orient(c, d, a) + o4 = orient(c, d, b) + + # Общий случай пересечения. + if ((o1 > eps and o2 < -eps) or (o1 < -eps and o2 > eps)) and ( + (o3 > eps and o4 < -eps) or (o3 < -eps and o4 > eps) + ): + return True + + # Касания/коллинеарность. + if abs(o1) <= eps and self._point_on_segment_2d(c[0], c[1], a, b, eps): + return True + if abs(o2) <= eps and self._point_on_segment_2d(d[0], d[1], a, b, eps): + return True + if abs(o3) <= eps and self._point_on_segment_2d(a[0], a[1], c, d, eps): + return True + if abs(o4) <= eps and self._point_on_segment_2d(b[0], b[1], c, d, eps): + return True + return False + + def _segment_intersects_polygon_any( + self: "ModelViewWidget", + a: tuple[float, float], + b: tuple[float, float], + polygon: list[tuple[float, float]], + eps: float, + ) -> bool: + """True, если отрезок пересекает ИЛИ касается полигона.""" + a_inside, a_boundary = self._classify_point_in_polygon(a[0], a[1], polygon, eps=eps) + if a_inside or a_boundary: + return True + b_inside, b_boundary = self._classify_point_in_polygon(b[0], b[1], polygon, eps=eps) + if b_inside or b_boundary: + return True + + n = len(polygon) + for i in range(n): + c = polygon[i] + d = polygon[(i + 1) % n] + if self._segments_intersect_or_touch(a, b, c, d, eps=eps): + return True + + mx = (a[0] + b[0]) * 0.5 + my = (a[1] + b[1]) * 0.5 + m_inside, m_boundary = self._classify_point_in_polygon(mx, my, polygon, eps=eps) + return bool(m_inside or m_boundary) + + def _validate_contour_point_against_existing_volumes( + self: "ModelViewWidget", + nx: float, + ny: float, + ) -> bool: + step = max(1.0, float(getattr(self, "_current_zone_size", 1.0))) + eps = max(1e-6, step * 0.25) + blockers = self._get_contour_blocking_polygons() + + is_overlay = bool(getattr(self, "_contour_zone_mode", "") == "overlay") + + for polygon in blockers: + if is_overlay: + # В overlay допускаем смежные границы зон; запрещаем только + # попадание внутрь чужого объёма. + if self._point_in_polygon_strict(nx, ny, polygon, eps=eps): + return False + continue + inside, boundary = self._classify_point_in_polygon(nx, ny, polygon, eps=eps) + if inside or boundary: + return False + + if not self._contour_points: + return True + + a = self._contour_points[-1] + b = (float(nx), float(ny)) + for polygon in blockers: + if is_overlay: + # В overlay разрешаем касания/проход по смежной границе. + if self._segment_crosses_polygon_strict(a, b, polygon, eps=eps): + return False + elif self._segment_intersects_polygon_any(a, b, polygon, eps=eps): + return False + return True diff --git a/Dispatch_V0.1.1/gui/components/model_view/contour/contour_overlay_component_part2.py b/Dispatch_V0.1.1/gui/components/model_view/contour/contour_overlay_component_part2.py new file mode 100644 index 0000000..432004b --- /dev/null +++ b/Dispatch_V0.1.1/gui/components/model_view/contour/contour_overlay_component_part2.py @@ -0,0 +1,209 @@ +# -*- coding: utf-8 -*- +# gui/components/model_view/contour/contour_overlay_component_part2.py + +# -*- coding: utf-8 -*- +# gui/components/model_view/_mv_grid_contour_overlay.py +# Режим наложения и обнаружение / валидация зон + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from PySide6.QtCore import QTimer +from PySide6.QtWidgets import QMessageBox + +from error_logger import log_exception + +if TYPE_CHECKING: # pragma: no cover + from gui.components.model_view_widget import ModelViewWidget + +try: + import pyvista as pv +except ImportError: # pragma: no cover + pv = None + +class ContourOverlayComponentPart2: + def _validate_contour_closing_segment(self: "ModelViewWidget") -> bool: + if len(self._contour_points) < 4: + return True + step = max(1.0, float(getattr(self, "_current_zone_size", 1.0))) + eps = max(1e-6, step * 0.25) + is_overlay = bool(getattr(self, "_contour_zone_mode", "") == "overlay") + a = self._contour_points[-1] + b = self._contour_points[0] + for polygon in self._get_contour_blocking_polygons(): + if is_overlay: + if self._segment_crosses_polygon_strict(a, b, polygon, eps=eps): + return False + elif self._segment_intersects_polygon_any(a, b, polygon, eps=eps): + return False + return True + + def _set_overlay_mode(self: "ModelViewWidget", zone_id: str) -> None: + """Активировать режим наложения поверх существующей зоны.""" + self._restore_overlay_parent_visual() + self._contour_zone_mode = "overlay" + self._contour_zone_id = zone_id + top_z = self._get_zone_top_height(zone_id) + self._grid_plane_z_override = top_z + self._last_volume_start_height = top_z + actor = self._zones.get(zone_id) + if actor is not None: + try: + self._overlay_parent_zone_id = zone_id + self._overlay_parent_original_visibility = bool(actor.GetVisibility()) + self._overlay_parent_original_opacity = float(actor.GetProperty().GetOpacity()) + actor.SetVisibility(1) + actor.GetProperty().SetOpacity(1.0) + except Exception as e: + log_exception(__name__, "_set_overlay_mode", e) + self._overlay_parent_zone_id = None + self._overlay_parent_original_visibility = None + self._overlay_parent_original_opacity = None + try: + self._hide_grid_actors() + except Exception as e: + log_exception(__name__, "_set_overlay_mode", e) + self._build_surface_grid_on_zone(zone_id) + + def _build_surface_grid_on_zone(self: "ModelViewWidget", zone_id: str) -> None: + """Построить проекцию базовой сетки на верхнюю грань родительской зоны.""" + self._clear_surface_grid() + + if not self._plotter or not self._grid_cells: + return + + polygon = self._zone_polygons.get(zone_id) + if not polygon: + return + + surface_z = float(self._grid_plane_z_override or 0.0) + step = max(1.0, float(self._current_zone_size)) + eps = max(1e-6, step * 0.25) + + line_points: list[list[float]] = [] + line_cells: list[int] = [] + surface_nodes: set[tuple[float, float]] = set() + + def _add_seg(p1: tuple, p2: tuple) -> None: + i1 = len(line_points) + line_points.append([p1[0], p1[1], p1[2]]) + i2 = len(line_points) + line_points.append([p2[0], p2[1], p2[2]]) + line_cells.extend([2, i1, i2]) + + for _cid, (mn_x, mx_x, mn_y, mx_y) in self._grid_cells.items(): + cx = (mn_x + mx_x) * 0.5 + cy = (mn_y + mx_y) * 0.5 + inside, boundary = self._classify_point_in_polygon(cx, cy, polygon, eps=eps) + if not (inside or boundary): + continue + p00 = (mn_x, mn_y, surface_z) + p10 = (mx_x, mn_y, surface_z) + p11 = (mx_x, mx_y, surface_z) + p01 = (mn_x, mx_y, surface_z) + surface_nodes.add((float(mn_x), float(mn_y))) + surface_nodes.add((float(mx_x), float(mn_y))) + surface_nodes.add((float(mx_x), float(mx_y))) + surface_nodes.add((float(mn_x), float(mx_y))) + _add_seg(p00, p10) + _add_seg(p10, p11) + _add_seg(p11, p01) + _add_seg(p01, p00) + + if not line_points: + return + + try: + grid_lines = pv.PolyData(line_points) + grid_lines.lines = line_cells + color = self._get_grid_color() + rgb = (color[0] / 255.0, color[1] / 255.0, color[2] / 255.0) + actor = self._plotter.add_mesh( + grid_lines, color=rgb, line_width=1, + ) + self._grid_surface_meshes.append(actor) + self._grid_surface_nodes = list(surface_nodes) + except Exception as e: + log_exception(__name__, "_build_surface_grid_on_zone", e) + + try: + self._plotter.update() + except Exception as e: + log_exception(__name__, "_build_surface_grid_on_zone", e) + + def _resolve_overlay_first_point( + self: "ModelViewWidget", nx: float, ny: float, + ) -> bool: + """Определить режим overlay/ground при первой точке контура. + + Возвращает True если точку можно добавить, False — отклонить. + """ + ranked_zones = self._classify_point_in_zones_ranked(nx, ny) + if not ranked_zones: + return True + zone_id, kind, _ = ranked_zones[0] + + def _apply_overlay_with_height_check(candidate_zone_id: str) -> bool: + self._set_overlay_mode(candidate_zone_id) + min_height = max(1.0, float(getattr(self, "_current_zone_z_size", self._current_zone_size))) + if self._get_max_height_for_start_z(self._get_contour_plane_z()) < min_height: + self._contour_zone_mode = None + self._contour_zone_id = None + self._grid_plane_z_override = None + self._clear_surface_grid() + return False + return True + + # Фильтруем зоны с видом «inside» или «boundary» + valid_candidates = [ + (zid, k, tz) for zid, k, tz in ranked_zones + if k in ("inside", "boundary") + ] + + if kind == "inside" or kind == "boundary": + # Отложенный запрос подтверждения: вынос из VTK-callback через QTimer + def _deferred_overlay_question(): + reply = QMessageBox.question( + self, + "Расположение точки", + "Расположение вашей точки планируется\n" + "поверх существующей зоны?", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + QMessageBox.StandardButton.No, + ) + if reply == QMessageBox.StandardButton.Yes: + if len(valid_candidates) >= 2: + self._start_zone_selection_for_overlay( + nx, ny, valid_candidates, _apply_overlay_with_height_check + ) + return + if _apply_overlay_with_height_check(zone_id): + node = self._nearest_grid_node(nx, ny) + if node is not None and node not in self._contour_points: + if self._validate_contour_point_against_existing_volumes(node[0], node[1]): + if not self._append_contour_point(node): + return + if getattr(self, "_dim_enabled", False): + self.set_dim_last_point(node) + self._update_contour_visualization() + self._update_selected_cells_from_contour() + self._emit_contour_ready_state() + return + # Пользователь отказался — всегда остаёмся в ground-режиме. + self._set_ground_mode() + node = self._nearest_grid_node(nx, ny) + if node is not None and node not in self._contour_points: + if self._validate_contour_point_against_existing_volumes(node[0], node[1]): + if not self._append_contour_point(node): + return + if getattr(self, "_dim_enabled", False): + self.set_dim_last_point(node) + self._update_contour_visualization() + self._update_selected_cells_from_contour() + self._emit_contour_ready_state() + + QTimer.singleShot(0, _deferred_overlay_question) + return False + + return True diff --git a/Dispatch_V0.1.1/gui/components/model_view/contour/contour_overlay_selection_component.py b/Dispatch_V0.1.1/gui/components/model_view/contour/contour_overlay_selection_component.py new file mode 100644 index 0000000..35972fb --- /dev/null +++ b/Dispatch_V0.1.1/gui/components/model_view/contour/contour_overlay_selection_component.py @@ -0,0 +1,284 @@ +# -*- coding: utf-8 -*- +# gui/components/model_view/contour/contour_overlay_selection_component.py + +# -*- coding: utf-8 -*- +# gui/components/model_view/_mv_grid_contour_selection.py +# Интерактивный выбор зоны для наложения + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from error_logger import log_exception + +if TYPE_CHECKING: # pragma: no cover + from gui.components.model_view_widget import ModelViewWidget + +class ContourOverlaySelectionComponent: + def _start_zone_selection_for_overlay( + self: "ModelViewWidget", + nx: float, + ny: float, + candidates: list, + apply_fn, + ) -> None: + """Запустить режим выбора зоны для overlay при перекрытии 2+ зон. + + Пользователь наводит курсор на объём зоны — она подсвечивается. + ЛКМ фиксирует выбор. + """ + candidate_ids = {zid for zid, _, _ in candidates} + self._overlay_select_candidates = candidate_ids + self._overlay_select_nx = nx + self._overlay_select_ny = ny + self._overlay_select_apply_fn = apply_fn + self._overlay_select_highlighted = None + # Сохраняем исходные прозрачности зон + self._overlay_select_original_opacities = {} + self._overlay_select_original_visibility = {} + for zid in candidate_ids: + actor = self._zones.get(zid) + if actor is not None: + try: + self._overlay_select_original_opacities[zid] = actor.GetProperty().GetOpacity() + except Exception as e: + log_exception(__name__, "_start_zone_selection_for_overlay", e) + self._overlay_select_original_opacities[zid] = 1.0 + try: + self._overlay_select_original_visibility[zid] = bool(actor.GetVisibility()) + except Exception as e: + log_exception(__name__, "_start_zone_selection_for_overlay", e) + self._overlay_select_original_visibility[zid] = True + + # Устанавливаем обработчики hover + click через сценарий взаимодействия + + def _resolve_world_xy(wx: float | None, wy: float | None) -> tuple[float | None, float | None]: + try: + from PySide6.QtGui import QCursor + global_pos = QCursor.pos() + local_pos = self._plotter.mapFromGlobal(global_pos) + dpr_v = self._plotter.devicePixelRatio() + rw_v = self._plotter.ren_win + if rw_v: + _, h_v = rw_v.GetSize() + s_x = int(round(local_pos.x() * dpr_v)) + s_y = h_v - int(round(local_pos.y() * dpr_v)) - 1 + p_z = self._get_contour_plane_z() + world = self.screen_to_world_on_plane(s_x, s_y, p_z) + if world is not None: + return float(world[0]), float(world[1]) + except Exception as e: + log_exception(__name__, "_resolve_world_xy", e) + if wx is not None and wy is not None: + return float(wx), float(wy) + return None, None + + def _hover(x: float, y: float, z: float) -> None: + x, y = _resolve_world_xy(x, y) + if x is None or y is None: + return + best_zone = None + best_top_z = None + for zid in candidate_ids: + polygon = self._zone_polygons.get(zid) + if not polygon or len(polygon) < 3: + continue + inside, boundary = self._classify_point_in_polygon(float(x), float(y), polygon) + if not (inside or boundary): + continue + top_z = self._get_zone_top_height(zid) + if best_zone is None or float(top_z) > float(best_top_z): + best_zone = zid + best_top_z = top_z + if best_zone == self._overlay_select_highlighted: + return + # Снимаем подсветку с предыдущей + if self._overlay_select_highlighted is not None: + prev_actor = self._zones.get(self._overlay_select_highlighted) + orig_op = self._overlay_select_original_opacities.get( + self._overlay_select_highlighted, 0.3 + ) + orig_vis = self._overlay_select_original_visibility.get( + self._overlay_select_highlighted, True + ) + if prev_actor is not None: + try: + prev_actor.SetVisibility(1 if orig_vis else 0) + prev_actor.GetProperty().SetOpacity(orig_op) + except Exception as e: + log_exception(__name__, "_hover", e) + # Подсвечиваем новую + self._overlay_select_highlighted = best_zone + if best_zone is not None: + actor = self._zones.get(best_zone) + if actor is not None: + try: + actor.SetVisibility(1) + actor.GetProperty().SetOpacity(1.0) + except Exception as e: + log_exception(__name__, "_hover", e) + if self._plotter: + try: + self._plotter.update() + except Exception as e: + log_exception(__name__, "_hover", e) + + def _click(x: float, y: float, z: float) -> bool: + selected_zid = self._overlay_select_highlighted + if selected_zid is None: + try: + from vtkmodules.vtkRenderingCore import vtkPropPicker + sx, sy = self._plotter.interactor.GetEventPosition() + picker = vtkPropPicker() + picker.Pick(sx, sy, 0, self._plotter.renderer) + picked_actor = picker.GetViewProp() + picked_zone_id = self._find_zone_by_actor(picked_actor) + if picked_zone_id in candidate_ids: + selected_zid = picked_zone_id + except Exception as e: + log_exception(__name__, "_click", e) + if selected_zid is None: + x, y = _resolve_world_xy(x, y) + if x is None or y is None: + return True + # Попробуем найти зону по клику + best_top_z = None + for zid in candidate_ids: + polygon = self._zone_polygons.get(zid) + if not polygon or len(polygon) < 3: + continue + inside, boundary = self._classify_point_in_polygon(float(x), float(y), polygon) + if not (inside or boundary): + continue + top_z = self._get_zone_top_height(zid) + if selected_zid is None or float(top_z) > float(best_top_z): + selected_zid = zid + best_top_z = top_z + if selected_zid is None: + return True + self._finish_zone_selection_for_overlay(selected_zid) + return True + + mgr = getattr(self, "_interaction_manager", None) + if mgr is not None: + from gui.components.model_view._scenario_custom_handler import CustomHandlerScenario + mgr.push(CustomHandlerScenario( + name="overlay_select", + click_handler=_click, + hover_handler=_hover, + )) + + def _finish_zone_selection_for_overlay( + self: "ModelViewWidget", zone_id: str, + ) -> None: + """Завершить режим выбора зоны для overlay.""" + # Восстанавливаем прозрачности + original = getattr(self, "_overlay_select_original_opacities", {}) + original_visibility = getattr(self, "_overlay_select_original_visibility", {}) + for zid, opacity in original.items(): + actor = self._zones.get(zid) + if actor is not None: + try: + actor.SetVisibility(1 if original_visibility.get(zid, True) else 0) + actor.GetProperty().SetOpacity(opacity) + except Exception as e: + log_exception(__name__, "_finish_zone_selection_for_overlay", e) + + # Убрать сценарий взаимодействия overlay (on_resume восстановит нижележащий) + mgr = getattr(self, "_interaction_manager", None) + if mgr is not None: + mgr.pop_by_name("overlay_select") + + apply_fn = getattr(self, "_overlay_select_apply_fn", None) + nx = getattr(self, "_overlay_select_nx", 0.0) + ny = getattr(self, "_overlay_select_ny", 0.0) + + # Очистка временных атрибутов + for attr in ( + "_overlay_select_candidates", + "_overlay_select_nx", + "_overlay_select_ny", + "_overlay_select_apply_fn", + "_overlay_select_highlighted", + "_overlay_select_original_opacities", + "_overlay_select_original_visibility", + ): + try: + delattr(self, attr) + except AttributeError as e: + log_exception(__name__, "_finish_zone_selection_for_overlay", e) + + if apply_fn is not None: + if apply_fn(zone_id): + # Overlay применён — добавляем исходную точку как первую точку контура + node = self._nearest_grid_node(nx, ny) + if node is not None and node not in self._contour_points: + if self._validate_contour_point_against_existing_volumes(node[0], node[1]): + if not self._append_contour_point(node): + return + if getattr(self, "_dim_enabled", False): + self.set_dim_last_point(node) + self._update_contour_visualization() + self._update_selected_cells_from_contour() + self._emit_contour_ready_state() + + if self._plotter: + try: + self._plotter.update() + except Exception as e: + log_exception(__name__, "_finish_zone_selection_for_overlay", e) + + def _cleanup_overlay_selection(self: "ModelViewWidget") -> None: + """Очистить состояние overlay-выбора зоны (если было активно).""" + if not hasattr(self, "_overlay_select_candidates"): + return + # Восстанавливаем прозрачности зон + original = getattr(self, "_overlay_select_original_opacities", {}) + original_visibility = getattr(self, "_overlay_select_original_visibility", {}) + for zid, opacity in original.items(): + actor = self._zones.get(zid) + if actor is not None: + try: + actor.SetVisibility(1 if original_visibility.get(zid, True) else 0) + actor.GetProperty().SetOpacity(opacity) + except Exception as e: + log_exception(__name__, "_cleanup_overlay_selection", e) + # Убрать сценарий взаимодействия overlay + mgr = getattr(self, "_interaction_manager", None) + if mgr is not None: + mgr.pop_by_name("overlay_select") + # Очищаем временные атрибуты + for attr in ( + "_overlay_select_candidates", + "_overlay_select_nx", + "_overlay_select_ny", + "_overlay_select_apply_fn", + "_overlay_select_highlighted", + "_overlay_select_original_opacities", + "_overlay_select_original_visibility", + ): + try: + delattr(self, attr) + except AttributeError as e: + log_exception(__name__, "_cleanup_overlay_selection", e) + + def _validate_overlay_point( + self: "ModelViewWidget", nx: float, ny: float, + ) -> bool: + """Проверить допустимость точки при уже определённом overlay-режиме. + + overlay: точка должна быть внутри или на границе родительской зоны. + ground: точка НЕ должна попадать внутрь или на границу любой зоны. + """ + if self._contour_zone_mode == "overlay": + return self._point_in_zone_or_boundary( + nx, ny, self._contour_zone_id, plane_z=self._get_contour_plane_z() + ) + + if self._contour_zone_mode == "ground": + ranked = self._classify_point_in_zones_ranked( + nx, ny, plane_z=self._get_contour_plane_z() + ) + return len(ranked) == 0 + + return True diff --git a/Dispatch_V0.1.1/gui/components/model_view/contour/contour_preview_component.py b/Dispatch_V0.1.1/gui/components/model_view/contour/contour_preview_component.py new file mode 100644 index 0000000..e467c53 --- /dev/null +++ b/Dispatch_V0.1.1/gui/components/model_view/contour/contour_preview_component.py @@ -0,0 +1,200 @@ +# -*- coding: utf-8 -*- +# gui/components/model_view/contour/contour_preview_component.py + +# -*- coding: utf-8 -*- +# gui/components/model_view/_mv_grid_preview_core.py +# Основное превью/выделение: куб предпросмотра, высота/размеры, якорь, завершение/отмена. +from __future__ import annotations +from typing import Optional, Tuple, TYPE_CHECKING +from PySide6.QtGui import QColor + +from error_logger import log_exception + +if TYPE_CHECKING: # pragma: no cover + from gui.components.model_view_widget import ModelViewWidget + +try: + import pyvista as pv +except ImportError: # pragma: no cover + pv = None + +class ContourPreviewComponent: + def _get_max_height_for_start_z( + self: "ModelViewWidget", + start_z: Optional[float] = None, + ) -> float: + if start_z is None: + start_z = float(getattr(self, "_last_volume_start_height", 0.0)) + else: + start_z = float(start_z) + base_z = float(self._get_grid_plane_z(getattr(self, "_grid_origin", None))) + max_top_z = base_z + float(self._MAX_VOLUME_HEIGHT) + return max(0.0, max_top_z - start_z) + + def _update_selection_visualization(self: "ModelViewWidget"): + if not self._plotter or not self._grid_cells: + return + + camera_position = None + try: + camera_position = self._plotter.camera_position + except Exception as e: + log_exception(__name__, "_update_selection_visualization", e) + + disable_render = getattr(self._plotter, "disable_render", None) + enable_render = getattr(self._plotter, "enable_render", None) + if callable(disable_render): + try: + disable_render() + except Exception as e: + log_exception(__name__, "_update_selection_visualization", e) + disable_render = None + + try: + if self._preview_zone is not None: + self._plotter.remove_actor(self._preview_zone) + self._preview_zone = None + except Exception as e: + log_exception(__name__, "_update_selection_visualization", e) + + if not self._selected_cells: + self._selection_anchor = None + self.grid_volume_changed.emit(0.0, 0.0, 0.0) + if callable(enable_render): + try: + enable_render() + except Exception as e: + log_exception(__name__, "_update_selection_visualization", e) + self._plotter.update() + return + + cells = [self._grid_cells[cid] for cid in self._selected_cells] + mn_x = min(c[0] for c in cells) + mx_x = max(c[1] for c in cells) + mn_y = min(c[2] for c in cells) + mx_y = max(c[3] for c in cells) + floor_z = self._get_grid_plane_z(self._grid_origin) + + # Режим наложения: поднять базу объёма на верхнюю границу родительской зоны. + if ( + self._contour_zone_overlay_enabled + and self._contour_zone_mode == "overlay" + and self._grid_plane_z_override is not None + ): + floor_z = float(self._grid_plane_z_override) + + self._last_volume_start_height = floor_z + + center_x = (mn_x + mx_x) / 2 + center_y = (mn_y + mx_y) / 2 + width = mx_x - mn_x + depth = mx_y - mn_y + step = max(1.0, float(self._current_zone_size)) + z_step = max(1.0, float(getattr(self, "_current_zone_z_size", self._current_zone_size))) + + height = max(z_step, z_step + float(self._selected_height)) + max_height = self._get_max_height_for_start_z(self._last_volume_start_height) + if height > max_height: + height = max_height + self._selected_height = max(0.0, height - z_step) + center_z = floor_z + height / 2 + + try: + contour_locked = bool( + getattr(self, "_volume_locked_from_contour", False) + or self.is_scenario_active("contour_edit") + or getattr(self, "_contour_points", None) + ) + levels = max(1, int(round(height / z_step))) + total_cubes = len(self._selected_cells) * levels + use_fast_preview = (not contour_locked) and total_cubes > self._FAST_PREVIEW_CUBE_LIMIT + + cube = None + if not use_fast_preview: + if contour_locked: + polygon = self._extract_orthogonal_polygon() if hasattr(self, "_extract_orthogonal_polygon") else None + if polygon and len(polygon) >= 3: + try: + pts = [[float(px), float(py), float(floor_z)] for px, py in polygon] + faces = [len(pts)] + list(range(len(pts))) + base = pv.PolyData(pts, faces) + try: + base = base.triangulate() + except Exception as e: + log_exception(__name__, "_update_selection_visualization", e) + cube = base.extrude([0.0, 0.0, float(height)], capping=True) + except Exception as e: + log_exception(__name__, "_update_selection_visualization", e) + cube = None + if cube is None and contour_locked: + cube = pv.Cube( + center=(center_x, center_y, center_z), + x_length=width, y_length=depth, z_length=height, + ) + elif cube is None: + unit_cubes = [] + for cid in self._selected_cells: + c_mn_x, c_mx_x, c_mn_y, c_mx_y = self._grid_cells[cid] + c_cx = (c_mn_x + c_mx_x) * 0.5 + c_cy = (c_mn_y + c_mx_y) * 0.5 + c_w = c_mx_x - c_mn_x + c_d = c_mx_y - c_mn_y + if contour_locked: + unit_cubes.append(pv.Cube( + center=(c_cx, c_cy, center_z), + x_length=c_w, y_length=c_d, z_length=height, + )) + else: + for level in range(levels): + c_cz = floor_z + (level + 0.5) * z_step + unit_cubes.append(pv.Cube( + center=(c_cx, c_cy, c_cz), + x_length=c_w, y_length=c_d, z_length=z_step, + )) + if unit_cubes: + try: + merged = unit_cubes[0] + for part in unit_cubes[1:]: + merged = merged.merge(part, merge_points=True) + cube = merged + except Exception as e: + log_exception(__name__, "_update_selection_visualization", e) + cube = None + + if cube is None or not hasattr(cube, "n_points") or int(cube.n_points) == 0: + cube = pv.Cube( + center=(center_x, center_y, center_z), + x_length=width, y_length=depth, z_length=height, + ) + + qcolor = QColor(self._current_zone_color) + rgb_color = (qcolor.redF(), qcolor.greenF(), qcolor.blueF()) + opacity = qcolor.alphaF() + + self._preview_zone = self._plotter.add_mesh( + cube, + color=rgb_color, + opacity=opacity, + show_edges=False, + line_width=2, + name="selection_preview", + ) + except Exception as e: + log_exception(__name__, "_update_selection_visualization", e) + + self.grid_volume_changed.emit(float(width), float(depth), float(height)) + + try: + if camera_position is not None: + self._plotter.camera_position = camera_position + except Exception as e: + log_exception(__name__, "_update_selection_visualization", e) + if callable(enable_render): + try: + enable_render() + except Exception as e: + log_exception(__name__, "_update_selection_visualization", e) + try: + self._plotter.update() + except Exception as e: + log_exception(__name__, "_update_selection_visualization", e) diff --git a/Dispatch_V0.1.1/gui/components/model_view/contour/contour_preview_component_part2.py b/Dispatch_V0.1.1/gui/components/model_view/contour/contour_preview_component_part2.py new file mode 100644 index 0000000..3f8615f --- /dev/null +++ b/Dispatch_V0.1.1/gui/components/model_view/contour/contour_preview_component_part2.py @@ -0,0 +1,111 @@ +# -*- coding: utf-8 -*- +# gui/components/model_view/contour/contour_preview_component_part2.py + +# -*- coding: utf-8 -*- +# gui/components/model_view/_mv_grid_preview_core.py +# Основное превью/выделение: куб предпросмотра, высота/размеры, якорь, завершение/отмена. +from __future__ import annotations +from typing import Optional, Tuple, TYPE_CHECKING +from PySide6.QtGui import QColor + +from error_logger import log_exception + +if TYPE_CHECKING: # pragma: no cover + from gui.components.model_view_widget import ModelViewWidget + +try: + import pyvista as pv +except ImportError: # pragma: no cover + pv = None + +class ContourPreviewComponentPart2: + def update_height_from_mouse(self: "ModelViewWidget", mouse_y_delta: float): + """Изменить высоту объёма перетаскиванием мыши.""" + if not self._selected_cells: + return + z_step = max(1.0, float(getattr(self, "_current_zone_z_size", self._current_zone_size))) + steps = int(mouse_y_delta / 5) + new_height = max(0, self._selected_height + steps * z_step) + max_height = max(0.0, self._get_max_height_for_start_z() - z_step) + if new_height > max_height: + new_height = max_height + if new_height != self._selected_height: + self._selected_height = new_height + self._update_selection_visualization() + + def _resolve_selection_anchor( + self: "ModelViewWidget", + ) -> Optional[Tuple[float, float]]: + if not self._selected_cells or not self._grid_cells: + return None + cells = [self._grid_cells[cid] for cid in self._selected_cells if cid in self._grid_cells] + if not cells: + return None + return (min(c[0] for c in cells), min(c[2] for c in cells)) + + def cancel_zone_selection(self: "ModelViewWidget") -> None: + """Остановить режим разметки и очистить временные визуалы. + + Разметочная сетка НЕ уничтожается — она скрывается и сохраняется + для повторного использования (``_grid_ready`` остаётся True). + Для полного удаления сетки используйте ``clear_grid()``. + """ + self._zone_selection_mode = False + self._selected_cells = set() + self._selected_height = 0 + self._selection_anchor = None + self._selection_start_cell = None + self._volume_locked_from_contour = False + self._contour_points = [] + self._final_contour_points = [] + self._contour_zone_mode = None + self._contour_zone_id = None + self._contour_ignore_zone_id = None + self._grid_plane_z_override = None + self.stop_contour_definition() + self._cleanup_overlay_selection() + self._clear_surface_grid() + + # Скрываем акторы сетки вместо удаления — для переиспользования + self._hide_grid_actors() + + try: + if self._preview_zone is not None: + self._plotter.remove_actor(self._preview_zone) + self._preview_zone = None + except Exception as e: + log_exception(__name__, "cancel_zone_selection", e) + + if getattr(self, "_dim_enabled", False): + try: + self.disable_dimension_lines() + except Exception as e: + log_exception(__name__, "cancel_zone_selection", e) + + try: + self._plotter.update() + except Exception as e: + log_exception(__name__, "cancel_zone_selection", e) + self.grid_volume_changed.emit(0.0, 0.0, 0.0) + + def finish_zone_selection( + self: "ModelViewWidget", + ) -> Optional[Tuple[float, float, float, float, float, float]]: + """Завершить выбор зоны: вернуть (mn_x, mx_x, mn_y, mx_y, center_z, height) и сбросить состояние.""" + if not self._selected_cells or not self._grid_cells: + self.cancel_zone_selection() + return None + + cells = [self._grid_cells[cid] for cid in self._selected_cells] + mn_x = min(c[0] for c in cells) + mx_x = max(c[1] for c in cells) + mn_y = min(c[2] for c in cells) + mx_y = max(c[3] for c in cells) + base_z = float(getattr(self, "_last_volume_start_height", self._get_grid_plane_z(self._grid_origin))) + z_step = max(1.0, float(getattr(self, "_current_zone_z_size", self._current_zone_size))) + full_height = max(z_step, z_step + float(self._selected_height)) + full_height = min(full_height, self._get_max_height_for_start_z(base_z)) + center_z = base_z + full_height / 2 + + self.cancel_zone_selection() + return (mn_x, mx_x, mn_y, mx_y, center_z, full_height) diff --git a/Dispatch_V0.1.1/gui/components/model_view/contour/contour_preview_contour_component.py b/Dispatch_V0.1.1/gui/components/model_view/contour/contour_preview_contour_component.py new file mode 100644 index 0000000..b71dd06 --- /dev/null +++ b/Dispatch_V0.1.1/gui/components/model_view/contour/contour_preview_contour_component.py @@ -0,0 +1,174 @@ +# -*- coding: utf-8 -*- +# gui/components/model_view/contour/contour_preview_contour_component.py + +# -*- coding: utf-8 -*- +# gui/components/model_view/_mv_grid_preview_contour.py +# Восстановление/загрузка контура, задание размеров и помощники запроса выбранного объёма. +from __future__ import annotations +from typing import TYPE_CHECKING + +from error_logger import log_exception + +if TYPE_CHECKING: # pragma: no cover + from gui.components.model_view_widget import ModelViewWidget + +class ContourPreviewContourComponent: + def set_selected_volume_dimensions( + self: "ModelViewWidget", + dim_x: float, dim_y: float, dim_z: float, + ) -> None: + """Изменить разметку по значениям из панели свойств зоны.""" + if not self._grid_cells or not self._selected_cells: + return + + step = max(1.0, float(self._current_zone_size)) + z_step = max(1.0, float(getattr(self, "_current_zone_z_size", self._current_zone_size))) + max_height = self._get_max_height_for_start_z() + dim_z = min(float(dim_z), float(max_height)) + cells_z = max(1, int(round(max(z_step, dim_z) / z_step))) + + # Контурный или заблокированный режим — меняем только высоту. + if ( + self.is_scenario_active("contour_edit") + or getattr(self, "_contour_points", None) + or getattr(self, "_volume_locked_from_contour", False) + ): + self._selected_height = float(max(0, min( + (cells_z - 1) * z_step, self._get_max_height_for_start_z() - z_step + ))) + self._update_selection_visualization() + return + + if not self._zone_selection_mode: + return + + cells_x = max(1, int(round(max(step, dim_x) / step))) + cells_y = max(1, int(round(max(step, dim_y) / step))) + + anchor = self._selection_anchor or self._resolve_selection_anchor() + if not anchor: + return + anchor_x, anchor_y = anchor + target_max_x = anchor_x + cells_x * step + target_max_y = anchor_y + cells_y * step + + new_selected = set() + eps = 1e-6 + for cid, (c_mn_x, c_mx_x, c_mn_y, c_mx_y) in self._grid_cells.items(): + if ( + c_mn_x >= anchor_x - eps and c_mx_x <= target_max_x + eps + and c_mn_y >= anchor_y - eps and c_mx_y <= target_max_y + eps + ): + new_selected.add(cid) + + if new_selected: + self._selected_cells = new_selected + self._selected_height = float(max(0, min( + (cells_z - 1) * z_step, self._get_max_height_for_start_z() - z_step + ))) + self._selection_anchor = (anchor_x, anchor_y) + self._update_selection_visualization() + + def restore_contour_definition_from_final(self: "ModelViewWidget") -> bool: + """Восстановить режим редактирования контура из финализированных точек контура.""" + final_points = list(getattr(self, "_final_contour_points", []) or []) + if len(final_points) < 4: + return False + if not self._grid_cells: + return False + try: + self._zone_selection_mode = True + self._volume_locked_from_contour = False + self._contour_points = [(float(x), float(y)) for x, y in final_points] + if ( + getattr(self, "_contour_zone_overlay_enabled", False) + and getattr(self, "_contour_zone_mode", None) == "overlay" + and getattr(self, "_contour_zone_id", None) + ): + try: + self._hide_grid_actors() + self._build_surface_grid_on_zone(str(self._contour_zone_id)) + except Exception as e: + log_exception(__name__, "restore_contour_definition_from_final", e) + else: + try: + self._show_grid_actors() + except Exception as e: + log_exception(__name__, "restore_contour_definition_from_final", e) + self._update_selected_cells_from_contour() + self._update_contour_visualization() + self._update_selection_visualization() + emit_ready = getattr(self, "_emit_contour_ready_state", None) + if callable(emit_ready): + emit_ready() + return True + except Exception as e: + log_exception(__name__, "restore_contour_definition_from_final", e) + return False + + def load_contour_definition(self: "ModelViewWidget", points: list[tuple[float, float]]) -> bool: + """Загрузить существующие точки контура в активный режим редактирования контура.""" + src_points = list(points or []) + if len(src_points) < 4 or not self._grid_cells: + return False + try: + normalized: list[tuple[float, float]] = [] + for px, py in src_points: + nx = float(px) + ny = float(py) + node = self._nearest_grid_node(nx, ny) + if node is not None: + normalized.append((float(node[0]), float(node[1]))) + else: + normalized.append((nx, ny)) + normalized = self._dedupe_contour_points(normalized) + normalized = self._simplify_collinear_contour_points(normalized, closed=True) + if len(normalized) < 4: + return False + self._zone_selection_mode = True + self._volume_locked_from_contour = False + self._selected_height = 0 + self._contour_points = [(float(x), float(y)) for x, y in normalized] + self._final_contour_points = list(self._contour_points) + self._update_selected_cells_from_contour() + self._update_contour_visualization() + self._update_selection_visualization() + emit_ready = getattr(self, "_emit_contour_ready_state", None) + if callable(emit_ready): + emit_ready() + return bool(self._selected_cells) + except Exception as e: + log_exception(__name__, "load_contour_definition", e) + return False + + def get_selected_volume_points(self: "ModelViewWidget") -> list[tuple[float, float]]: + """Вернуть точки контура выбранного объёма (XY).""" + if getattr(self, "_final_contour_points", None): + return [(float(x), float(y)) for x, y in self._final_contour_points] + polygon = self._extract_orthogonal_polygon() if hasattr(self, "_extract_orthogonal_polygon") else None + if polygon: + return [(float(x), float(y)) for x, y in polygon] + if not self._selected_cells: + return [] + cells = [self._grid_cells[cid] for cid in self._selected_cells] + mn_x = min(c[0] for c in cells) + mx_x = max(c[1] for c in cells) + mn_y = min(c[2] for c in cells) + mx_y = max(c[3] for c in cells) + return [ + (float(mn_x), float(mn_y)), + (float(mx_x), float(mn_y)), + (float(mx_x), float(mx_y)), + (float(mn_x), float(mx_y)), + ] + + def get_selected_volume_start_height(self: "ModelViewWidget") -> float: + """Получить начальную высоту выбранного объёма (Z).""" + return float(getattr(self, "_last_volume_start_height", 0.0)) + + def get_selected_volume_height(self: "ModelViewWidget") -> float: + """Получить полную высоту выбранного объёма.""" + z_step = max(1.0, float(getattr(self, "_current_zone_z_size", self._current_zone_size))) + height = max(z_step, z_step + float(self._selected_height)) + max_height = self._get_max_height_for_start_z() + return float(min(height, max_height)) diff --git a/Dispatch_V0.1.1/gui/components/model_view/contour/contour_selection_component.py b/Dispatch_V0.1.1/gui/components/model_view/contour/contour_selection_component.py new file mode 100644 index 0000000..01569f3 --- /dev/null +++ b/Dispatch_V0.1.1/gui/components/model_view/contour/contour_selection_component.py @@ -0,0 +1,220 @@ +# -*- coding: utf-8 -*- +# gui/components/model_view/contour/contour_selection_component.py + +# -*- coding: utf-8 -*- +# gui/components/model_view/_mv_grid_sel_handlers.py +# Обработчики кликов и управление состоянием выделения + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from error_logger import log_exception + +if TYPE_CHECKING: # pragma: no cover + from gui.components.model_view_widget import ModelViewWidget + +class ContourSelectionComponent: + def _is_contour_ready_for_volume(self: "ModelViewWidget") -> bool: + polygon = self._extract_orthogonal_polygon() + if polygon is None: + return False + if not self._selected_cells: + return False + try: + if not self._validate_contour_closing_segment(): + return False + except Exception as e: + log_exception(__name__, "_is_contour_ready_for_volume", e) + return False + return True + + def _emit_contour_ready_state(self: "ModelViewWidget") -> None: + ready = bool(self.is_scenario_active("contour_edit") and self._is_contour_ready_for_volume()) + prev = bool(getattr(self, "_contour_ready", False)) + self._contour_ready = ready + if prev != ready: + try: + self.contour_ready_changed.emit(ready) + except Exception as e: + log_exception(__name__, "_emit_contour_ready_state", e) + + def _handle_grid_click( + self: "ModelViewWidget", + x: float, y: float, z: float, + action: str = "add", + ): + # --- Режим контура --- + if self.is_scenario_active("contour_edit"): + if action == "add": + node = self._nearest_grid_node(x, y) + if node is None: + return + if bool(getattr(self, "_contour_aux_hidden", False)): + if hasattr(self, "set_contour_auxiliary_visibility"): + self.set_contour_auxiliary_visibility(True) + if node in self._contour_points: + return + + # -- Валидация наложения -- + if self._contour_zone_overlay_enabled: + if self._contour_zone_mode is None: + # Первая точка — определяем режим overlay/ground. + if not self._resolve_overlay_first_point(node[0], node[1]): + return + else: + # Последующие точки — проверяем допустимость. + if not self._validate_overlay_point(node[0], node[1]): + return + + if not self._validate_contour_point_against_existing_volumes(node[0], node[1]): + return + + if hasattr(self, "_append_contour_point"): + if not self._append_contour_point(node): + return + else: + self._contour_points.append(node) + # Обновить «последнюю точку» для размерных линий + if getattr(self, "_dim_enabled", False): + self.set_dim_last_point(node) + self._update_contour_visualization() + self._update_selected_cells_from_contour() + self._emit_contour_ready_state() + return + if action == "remove": + removed = self._remove_nearest_contour_point(x, y) + if removed: + # Пересчитать последнюю точку после удаления + if getattr(self, "_dim_enabled", False): + if self._contour_points: + self.set_dim_last_point(self._contour_points[-1]) + else: + self.set_dim_last_point(None) + self._update_contour_visualization() + self._update_selected_cells_from_contour() + else: + # ПКМ вне узла: скрыть вспомогательные акторы для чистого обзора. + if hasattr(self, "set_contour_auxiliary_visibility"): + self.set_contour_auxiliary_visibility(False) + self._emit_contour_ready_state() + return + + # --- Обычный режим сетки --- + found = self._find_cell_by_point(x, y, prefer_selected=(action == "remove")) + if not found: + return + cell_id, (mn_x, mx_x, mn_y, mx_y) = found + + if action == "add": + self._handle_grid_add(cell_id, mn_x, mx_x, mn_y, mx_y) + elif action == "remove": + self._handle_grid_remove(cell_id, mn_x, mx_x, mn_y, mx_y) + else: + return + + self._selection_anchor = self._resolve_selection_anchor() + self._update_selection_visualization() + + def _handle_grid_add( + self: "ModelViewWidget", + cell_id: int, + mn_x: float, mx_x: float, + mn_y: float, mx_y: float, + ): + """ЛКМ: расширить выделение прямоугольником от стартовой ячейки.""" + eps = 1e-6 + if not self._selected_cells: + self._selection_start_cell = (mn_x, mx_x, mn_y, mx_y) + self._selected_cells.add(cell_id) + return + + start_cell = self._selection_start_cell + if start_cell is None: + start_cell = (mn_x, mx_x, mn_y, mx_y) + self._selection_start_cell = start_cell + s_mn_x, s_mx_x, s_mn_y, s_mx_y = start_cell + add_min_x = min(s_mn_x, mn_x) + add_max_x = max(s_mx_x, mx_x) + add_min_y = min(s_mn_y, mn_y) + add_max_y = max(s_mx_y, mx_y) + + to_add = set() + for candidate_id, (c_mn_x, c_mx_x, c_mn_y, c_mx_y) in self._grid_cells.items(): + if ( + c_mn_x >= add_min_x - eps + and c_mx_x <= add_max_x + eps + and c_mn_y >= add_min_y - eps + and c_mx_y <= add_max_y + eps + ): + if candidate_id not in self._selected_cells: + to_add.add(candidate_id) + if not to_add: + return + self._selected_cells.update(to_add) + + def _handle_grid_remove( + self: "ModelViewWidget", + cell_id: int, + mn_x: float, mx_x: float, + mn_y: float, mx_y: float, + ): + """ПКМ: усечение выделения.""" + if not self._selected_cells: + return + + selected_cells = [ + self._grid_cells[cid] + for cid in self._selected_cells + if cid in self._grid_cells + ] + if not selected_cells: + return + + sel_min_x = min(c[0] for c in selected_cells) + sel_max_x = max(c[1] for c in selected_cells) + sel_min_y = min(c[2] for c in selected_cells) + sel_max_y = max(c[3] for c in selected_cells) + eps = 1e-6 + + # ПКМ за пределами текущего объёма — игнорируем. + if ( + mx_x < sel_min_x - eps or mn_x > sel_max_x + eps + or mx_y < sel_min_y - eps or mn_y > sel_max_y + eps + ): + return + + start_cell = self._selection_start_cell + if start_cell is None: + start_cell = (mn_x, mx_x, mn_y, mx_y) + self._selection_start_cell = start_cell + + # ПКМ по стартовой ячейке — сброс всего выделения. + s_mn_x, s_mx_x, s_mn_y, s_mx_y = start_cell + if ( + abs(mn_x - s_mn_x) <= eps and abs(mx_x - s_mx_x) <= eps + and abs(mn_y - s_mn_y) <= eps and abs(mx_y - s_mx_y) <= eps + ): + self._selected_cells = set() + self._selection_anchor = None + self._update_selection_visualization() + return + + keep_min_x = min(s_mn_x, mn_x) + keep_max_x = max(s_mx_x, mx_x) + keep_min_y = min(s_mn_y, mn_y) + keep_max_y = max(s_mx_y, mx_y) + + trimmed_cells = set() + for selected_id in self._selected_cells: + c_mn_x, c_mx_x, c_mn_y, c_mx_y = self._grid_cells[selected_id] + if ( + c_mn_x >= keep_min_x - eps + and c_mx_x <= keep_max_x + eps + and c_mn_y >= keep_min_y - eps + and c_mx_y <= keep_max_y + eps + ): + trimmed_cells.add(selected_id) + if not trimmed_cells: + return + self._selected_cells = trimmed_cells diff --git a/Dispatch_V0.1.1/gui/components/model_view/contour/contour_visualization_component.py b/Dispatch_V0.1.1/gui/components/model_view/contour/contour_visualization_component.py new file mode 100644 index 0000000..0ff482b --- /dev/null +++ b/Dispatch_V0.1.1/gui/components/model_view/contour/contour_visualization_component.py @@ -0,0 +1,219 @@ +# -*- coding: utf-8 -*- +# gui/components/model_view/contour/contour_visualization_component.py + +# -*- coding: utf-8 -*- +# gui/components/model_view/_mv_grid_contour_viz.py +# Визуализация контура + +from __future__ import annotations + +import math +from typing import TYPE_CHECKING + +from error_logger import log_exception + +if TYPE_CHECKING: # pragma: no cover + from gui.components.model_view_widget import ModelViewWidget + +try: + import pyvista as pv +except ImportError: # pragma: no cover + pv = None + +class ContourVisualizationComponent: + def set_contour_auxiliary_visibility(self: "ModelViewWidget", visible: bool) -> None: + """Показать/скрыть вспомогательные акторы контура (candidate + dim helpers).""" + show = bool(visible) + self._contour_aux_hidden = not show + if show: + # Восстановить размерные линии при следующем валидном узле/hover. + if ( + getattr(self, "_dim_enabled", False) + and getattr(self, "_dim_current_node", None) is not None + and hasattr(self, "_redraw_dim_lines") + ): + try: + self._redraw_dim_lines() + except Exception as e: + log_exception(__name__, "set_contour_auxiliary_visibility", e) + return + # Скрыть candidate и дельта-подписи. + self._hide_contour_candidate_visuals() + # Скрыть размерные линии и подсветку узла, но не выключать сам режим. + if hasattr(self, "_clear_dim_actors"): + try: + self._clear_dim_actors() + except Exception as e: + log_exception(__name__, "set_contour_auxiliary_visibility", e) + if hasattr(self, "_clear_dim_highlight"): + try: + self._clear_dim_highlight() + except Exception as e: + log_exception(__name__, "set_contour_auxiliary_visibility", e) + + def _clear_contour_actors(self: "ModelViewWidget") -> None: + if not self._plotter: + return + for actor_name in ( + "selection_contour_points", + "selection_contour_point_labels", + "selection_contour_lines", + "selection_contour_candidate", + "selection_contour_delta_line", + "selection_contour_delta_label", + "selection_contour_segment_labels", + ): + try: + self._plotter.remove_actor(actor_name) + except Exception as e: + log_exception(__name__, "_clear_contour_actors", e) + self._contour_candidate_actor = None + self._contour_candidate_last_node = None + + def _update_contour_visualization(self: "ModelViewWidget") -> None: + if not self._plotter: + return + self._clear_contour_actors() + if not self._contour_points: + try: + self._plotter.update() + except Exception as e: + log_exception(__name__, "_update_contour_visualization", e) + return + floor_z = self._get_grid_plane_z(getattr(self, "_grid_origin", None)) + # Overlay: рисовать точки контура на уровне верха родительской зоны. + if ( + self._contour_zone_overlay_enabled + and self._contour_zone_mode == "overlay" + and self._grid_plane_z_override is not None + ): + floor_z = float(self._grid_plane_z_override) + z = floor_z + 2.0 + try: + pts = [[px, py, z] for px, py in self._contour_points] + poly_pts = pv.PolyData(pts) + self._plotter.add_mesh( + poly_pts, + color=(0.1, 0.5, 1.0), + point_size=11, + render_points_as_spheres=True, + name="selection_contour_points", + ) + point_labels = [str(i + 1) for i in range(len(pts))] + label_points = [[p[0], p[1], p[2] + 3.0] for p in pts] + self._plotter.add_point_labels( + label_points, + point_labels, + font_size=16, + text_color="#EAF4FF", + shape=None, + show_points=False, + always_visible=True, + pickable=False, + reset_camera=False, + name="selection_contour_point_labels", + ) + if len(pts) >= 2: + line_points: list[list[float]] = [] + line_cells: list[int] = [] + seg_labels: list[str] = [] + seg_mids: list[list[float]] = [] + for i in range(len(pts) - 1): + i1 = len(line_points) + line_points.append(pts[i]) + i2 = len(line_points) + line_points.append(pts[i + 1]) + line_cells.extend([2, i1, i2]) + dx = float(pts[i + 1][0]) - float(pts[i][0]) + dy = float(pts[i + 1][1]) - float(pts[i][1]) + dist = math.hypot(dx, dy) + if dist > 1e-3: + seg_mids.append( + [ + 0.5 * (float(pts[i][0]) + float(pts[i + 1][0])), + 0.5 * (float(pts[i][1]) + float(pts[i + 1][1])), + float(z) + 2.8, + ] + ) + seg_labels.append(f"{dist:.0f} мм") + if len(pts) >= 4: + i1 = len(line_points) + line_points.append(pts[-1]) + i2 = len(line_points) + line_points.append(pts[0]) + line_cells.extend([2, i1, i2]) + contour_lines = pv.PolyData(line_points) + contour_lines.lines = line_cells + self._plotter.add_mesh( + contour_lines, + color=(0.1, 0.5, 1.0), + line_width=2, + name="selection_contour_lines", + ) + if seg_mids and seg_labels: + self._plotter.add_point_labels( + seg_mids, + seg_labels, + font_size=20, + text_color="#00A3FF", + shape=None, + show_points=False, + always_visible=True, + pickable=False, + reset_camera=False, + name="selection_contour_segment_labels", + ) + self._plotter.update() + except Exception as e: + log_exception(__name__, "_update_contour_visualization", e) + + def update_contour_candidate_node(self: "ModelViewWidget", x: float, y: float) -> None: + """Подсветить потенциальный узел новой точки контура.""" + if not self._plotter: + return + if bool(getattr(self, "_contour_aux_hidden", False)): + return + if not self.is_scenario_active("contour_edit"): + self._hide_contour_candidate_visuals() + return + node = self._nearest_grid_node(float(x), float(y)) + if node is None: + self._hide_contour_candidate_visuals() + return + plane_z = self._get_contour_plane_z() + node_key = (float(node[0]), float(node[1]), float(plane_z)) + if getattr(self, "_contour_candidate_last_node", None) == node_key: + return + self._contour_candidate_last_node = node_key + try: + pt = pv.PolyData([[float(node[0]), float(node[1]), float(plane_z) + 2.2]]) + candidate_actor = getattr(self, "_contour_candidate_actor", None) + if candidate_actor is None: + candidate_actor = self._plotter.add_mesh( + pt, + color=(1.0, 0.95, 0.2), + point_size=16, + render_points_as_spheres=True, + name="selection_contour_candidate", + ) + self._contour_candidate_actor = candidate_actor + else: + try: + mapper = candidate_actor.GetMapper() + mapper.SetInputData(pt) + mapper.Update() + candidate_actor.SetVisibility(1) + except Exception as e: + log_exception(__name__, "update_contour_candidate_node", e) + self._plotter.remove_actor("selection_contour_candidate") + self._contour_candidate_actor = self._plotter.add_mesh( + pt, + color=(1.0, 0.95, 0.2), + point_size=16, + render_points_as_spheres=True, + name="selection_contour_candidate", + ) + self._draw_candidate_delta_label(float(node[0]), float(node[1]), float(plane_z) + 2.2) + self._safe_render(min_interval_s=1.0 / 75.0) + except Exception as e: + log_exception(__name__, "update_contour_candidate_node", e) diff --git a/Dispatch_V0.1.1/gui/components/model_view/contour/contour_visualization_component_part2.py b/Dispatch_V0.1.1/gui/components/model_view/contour/contour_visualization_component_part2.py new file mode 100644 index 0000000..789ebf4 --- /dev/null +++ b/Dispatch_V0.1.1/gui/components/model_view/contour/contour_visualization_component_part2.py @@ -0,0 +1,147 @@ +# -*- coding: utf-8 -*- +# gui/components/model_view/contour/contour_visualization_component_part2.py + +# -*- coding: utf-8 -*- +# gui/components/model_view/_mv_grid_contour_viz.py +# Визуализация контура + +from __future__ import annotations + +import math +from typing import TYPE_CHECKING + +from error_logger import log_exception + +if TYPE_CHECKING: # pragma: no cover + from gui.components.model_view_widget import ModelViewWidget + +try: + import pyvista as pv +except ImportError: # pragma: no cover + pv = None + +class ContourVisualizationComponentPart2: + def _hide_contour_candidate_visuals(self: "ModelViewWidget") -> None: + """Скрыть candidate/дельта визуалы без частого пересоздания.""" + actor = getattr(self, "_contour_candidate_actor", None) + if actor is not None: + try: + actor.SetVisibility(0) + except Exception as e: + log_exception(__name__, "_hide_contour_candidate_visuals", e) + for actor_name in ( + "selection_contour_delta_line", + "selection_contour_delta_label", + ): + try: + self._plotter.remove_actor(actor_name) + except Exception as e: + log_exception(__name__, "_hide_contour_candidate_visuals", e) + self._contour_candidate_last_node = None + + def _draw_candidate_delta_label(self: "ModelViewWidget", nx: float, ny: float, z: float) -> None: + """Отрисовать расстояние между последней точкой контура и текущим узлом-кандидатом.""" + if not self._plotter or pv is None: + return + if len(getattr(self, "_contour_points", [])) < 1: + return + last = self._contour_points[-1] + lx, ly = float(last[0]), float(last[1]) + dx, dy = nx - lx, ny - ly + seg_len = math.hypot(dx, dy) + if seg_len <= 1e-3: + return + ux, uy = dx / seg_len, dy / seg_len + step = max(1.0, float(getattr(self, "_current_zone_size", 500.0))) + label_text = f"{seg_len:.0f} мм" + if hasattr(pv, "Text3D"): + try: + text_mesh = pv.Text3D(label_text, depth=0.2) + b = text_mesh.bounds + text_w = max(1e-6, float(b[1] - b[0])) + text_h = max(1e-6, float(b[3] - b[2])) + base_w = max(step * 0.25, seg_len * 0.55) + base_h = max(step * 0.12, 40.0) + max_w = min(seg_len * 0.88, base_w * self._DELTA_LABEL_SCALE_FACTOR) + max_h = base_h * self._DELTA_LABEL_SCALE_FACTOR + scale = min(max_w / text_w, max_h / text_h) + text_mesh.scale([scale, scale, scale], inplace=True) + sb = text_mesh.bounds + cx, cy, cz = 0.5*(sb[0]+sb[1]), 0.5*(sb[2]+sb[3]), 0.5*(sb[4]+sb[5]) + text_mesh.translate([-cx, -cy, -cz], inplace=True) + angle = math.degrees(math.atan2(uy, ux)) + if angle > 90.0: + angle -= 180.0 + elif angle < -90.0: + angle += 180.0 + text_mesh.rotate_z(angle, inplace=True) + mx = 0.5 * (lx + nx) + my = 0.5 * (ly + ny) + text_mesh.translate([mx, my, z], inplace=True) + tb = text_mesh.bounds + label_span = max( + abs(tb[1] - tb[0]), + abs(tb[3] - tb[2]), + ) + gap = min(seg_len * 0.9, label_span + max(step * 0.20, 90.0)) + if seg_len > gap + 1e-3: + half_run = 0.5 * (seg_len - gap) + p0 = (lx, ly, z) + p1 = (lx + ux * half_run, ly + uy * half_run, z) + p2 = (nx - ux * half_run, ny - uy * half_run, z) + p3 = (nx, ny, z) + line_pts = [list(p0), list(p1), list(p2), list(p3)] + line_cells = [2, 0, 1, 2, 2, 3] + helper = pv.PolyData(line_pts) + helper.lines = line_cells + self._plotter.add_mesh( + helper, + color=(1.0, 0.95, 0.2), + line_width=2, + pickable=False, + name="selection_contour_delta_line", + ) + self._plotter.add_mesh( + text_mesh, + color=(1.0, 0.95, 0.2), + pickable=False, + lighting=False, + name="selection_contour_delta_label", + ) + return + except Exception as e: + log_exception(__name__, "_draw_candidate_delta_label", e) + # Запасной вариант без поддержки ориентированного 3D-текста. + mx = 0.5 * (lx + nx) + my = 0.5 * (ly + ny) + self._plotter.add_point_labels( + [[mx, my, z]], + [label_text], + font_size=max(12, int(round(18 * self._DELTA_LABEL_SCALE_FACTOR))), + text_color="#FFF176", + shape=None, + show_points=False, + always_visible=True, + pickable=False, + reset_camera=False, + name="selection_contour_delta_label", + ) + + def _restore_overlay_parent_visual(self: "ModelViewWidget") -> None: + zone_id = str(getattr(self, "_overlay_parent_zone_id", "") or "") + if not zone_id: + return + actor = self._zones.get(zone_id) + if actor is not None: + try: + orig_vis = getattr(self, "_overlay_parent_original_visibility", None) + orig_opacity = getattr(self, "_overlay_parent_original_opacity", None) + if orig_vis is not None: + actor.SetVisibility(1 if bool(orig_vis) else 0) + if orig_opacity is not None: + actor.GetProperty().SetOpacity(float(orig_opacity)) + except Exception as e: + log_exception(__name__, "_restore_overlay_parent_visual", e) + self._overlay_parent_zone_id = None + self._overlay_parent_original_visibility = None + self._overlay_parent_original_opacity = None diff --git a/Dispatch_V0.1.1/gui/components/model_view/contour/legacy_contour_binding_component.py b/Dispatch_V0.1.1/gui/components/model_view/contour/legacy_contour_binding_component.py new file mode 100644 index 0000000..82ec5b1 --- /dev/null +++ b/Dispatch_V0.1.1/gui/components/model_view/contour/legacy_contour_binding_component.py @@ -0,0 +1,84 @@ +# -*- coding: utf-8 -*- +# gui/components/model_view/contour/legacy_contour_binding_component.py + +from __future__ import annotations + +from inspect import isfunction +from types import MethodType +from typing import TYPE_CHECKING + +from gui.components.model_view.contour.contour_definition_component import ContourDefinitionComponent +from gui.components.model_view.contour.contour_definition_component_part2 import ( + ContourDefinitionComponentPart2, +) +from gui.components.model_view.contour.contour_overlay_component import ContourOverlayComponent +from gui.components.model_view.contour.contour_overlay_component_part2 import ( + ContourOverlayComponentPart2, +) +from gui.components.model_view.contour.contour_overlay_selection_component import ( + ContourOverlaySelectionComponent, +) +from gui.components.model_view.contour.contour_visualization_component import ( + ContourVisualizationComponent, +) +from gui.components.model_view.contour.contour_visualization_component_part2 import ( + ContourVisualizationComponentPart2, +) +from gui.components.model_view.contour.contour_geometry_component import ContourGeometryComponent +from gui.components.model_view.contour.contour_geometry_component_part2 import ( + ContourGeometryComponentPart2, +) +from gui.components.model_view.contour.contour_selection_component import ContourSelectionComponent +from gui.components.model_view.contour.contour_preview_component import ContourPreviewComponent +from gui.components.model_view.contour.contour_preview_component_part2 import ( + ContourPreviewComponentPart2, +) +from gui.components.model_view.contour.contour_preview_contour_component import ( + ContourPreviewContourComponent, +) + +if TYPE_CHECKING: + from gui.components.model_view_widget import ModelViewWidget + + +class LegacyContourBindingComponent: + """Компонент привязки legacy contour-методов к ModelViewWidget. + + Нужен как транзитный слой: позволяет убрать contour mixin-ы из MRO, + сохранив поведение через композицию. + """ + + def __init__(self, model_view: "ModelViewWidget") -> None: + self._mv = model_view + self._classes = ( + ContourDefinitionComponent, + ContourDefinitionComponentPart2, + ContourOverlayComponent, + ContourOverlayComponentPart2, + ContourOverlaySelectionComponent, + ContourVisualizationComponent, + ContourVisualizationComponentPart2, + ContourGeometryComponent, + ContourGeometryComponentPart2, + ContourSelectionComponent, + ContourPreviewComponent, + ContourPreviewComponentPart2, + ContourPreviewContourComponent, + ) + + def install(self) -> None: + host = self._mv + for cls in self._classes: + self._bind_class_methods(host, cls) + + @staticmethod + def _bind_class_methods(host: object, cls: type) -> None: + for name, member in cls.__dict__.items(): + if name.startswith("__"): + continue + if not isfunction(member): + continue + # Не перезаписываем существующие методы/атрибуты. + if hasattr(host, name): + continue + setattr(host, name, MethodType(member, host)) diff --git a/Dispatch_V0.1.1/gui/components/model_view/contour/model_view_contour_facade.py b/Dispatch_V0.1.1/gui/components/model_view/contour/model_view_contour_facade.py new file mode 100644 index 0000000..fe91b84 --- /dev/null +++ b/Dispatch_V0.1.1/gui/components/model_view/contour/model_view_contour_facade.py @@ -0,0 +1,103 @@ +# -*- coding: utf-8 -*- +# gui/components/model_view/contour/model_view_contour_facade.py + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from error_logger import log_exception + +if TYPE_CHECKING: + from gui.components.model_view_widget import ModelViewWidget + + +class ModelViewContourFacade: + """Контурный фасад для ModelViewWidget. + + Фасад вводится как стабильная точка входа для последующей миграции + от mixin-реализаций к компонентам без массового переписывания + вызывающего кода за один шаг. + """ + + def __init__(self, model_view: "ModelViewWidget") -> None: + self._mv = model_view + + def start_contour_mode(self, *, overlay_enabled: bool) -> bool: + mv = self._mv + if not hasattr(mv, "start_contour_definition"): + return False + mv.start_contour_definition() + if hasattr(mv, "set_contour_zone_overlay_enabled"): + mv.set_contour_zone_overlay_enabled(bool(overlay_enabled)) + return True + + def stop_contour_mode(self) -> bool: + mv = self._mv + if not hasattr(mv, "stop_contour_definition"): + return False + mv.stop_contour_definition() + return True + + def finalize_contour(self) -> bool: + mv = self._mv + fn = getattr(mv, "finalize_contour_selection", None) + if not callable(fn): + return False + try: + return bool(fn()) + except Exception as e: + log_exception(__name__, "finalize_contour", e) + return False + + def restore_contour_from_final(self) -> bool: + mv = self._mv + fn = getattr(mv, "restore_contour_definition_from_final", None) + if not callable(fn): + return False + try: + return bool(fn()) + except Exception as e: + log_exception(__name__, "restore_contour_from_final", e) + return False + + def load_contour_definition(self, points: list[tuple[float, float]]) -> bool: + mv = self._mv + fn = getattr(mv, "load_contour_definition", None) + if not callable(fn): + return False + try: + return bool(fn(points)) + except Exception as e: + log_exception(__name__, "load_contour_definition", e) + return False + + def set_aux_visibility(self, visible: bool) -> None: + mv = self._mv + fn = getattr(mv, "set_contour_auxiliary_visibility", None) + if callable(fn): + try: + fn(bool(visible)) + except Exception as e: + log_exception(__name__, "set_aux_visibility", e) + return + + def is_contour_active(self) -> bool: + mv = self._mv + fn = getattr(mv, "is_scenario_active", None) + if callable(fn): + try: + return bool(fn("contour_edit")) + except Exception as e: + log_exception(__name__, "is_contour_active", e) + return False + return False + + def set_overlay_enabled(self, enabled: bool) -> None: + mv = self._mv + fn = getattr(mv, "set_contour_zone_overlay_enabled", None) + if callable(fn): + try: + fn(bool(enabled)) + except Exception as e: + log_exception(__name__, "set_overlay_enabled", e) + return diff --git a/Dispatch_V0.1.1/gui/components/model_view_widget.py b/Dispatch_V0.1.1/gui/components/model_view_widget.py new file mode 100644 index 0000000..a272e38 --- /dev/null +++ b/Dispatch_V0.1.1/gui/components/model_view_widget.py @@ -0,0 +1,272 @@ +# -*- 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 diff --git a/Dispatch_V0.1.1/gui/components/part_visualizer.py b/Dispatch_V0.1.1/gui/components/part_visualizer.py new file mode 100644 index 0000000..fc79be7 --- /dev/null +++ b/Dispatch_V0.1.1/gui/components/part_visualizer.py @@ -0,0 +1,437 @@ +# -*- 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 в корне проекта. diff --git a/Dispatch_V0.1.1/gui/components/photo_view_widget.py b/Dispatch_V0.1.1/gui/components/photo_view_widget.py new file mode 100644 index 0000000..2714c7d --- /dev/null +++ b/Dispatch_V0.1.1/gui/components/photo_view_widget.py @@ -0,0 +1,277 @@ +# -*- coding: utf-8 -*- +# gui/components/photo_view_widget.py +"""Виджет для отображения фотографий видов""" + +from pathlib import Path + +from PySide6.QtWidgets import QLabel, QSizePolicy +from PySide6.QtCore import Qt, Signal +from PySide6.QtGui import QPixmap + +from gui.containers import VContainer, SContainer +from gui.styles import APP_STYLES + + +class PhotoViewWidget(SContainer): + """Виджет для отображения фотографий видов""" + + error_occurred = Signal(str) + + def __init__(self, content_fit: bool = True): + super().__init__(width_percent=None, height_percent=None) + self._content_fit = content_fit + self._current_zoom = 1.0 + self._current_view = None + self._images = {} # {имя_вида: QPixmap} + self._base_pixmap = None + self.setup_ui() + + def setup_ui(self): + main_container = VContainer( + content_fit=self._content_fit, + parent=self, + ) + + self._image_label = QLabel() + self._image_label.setAlignment(Qt.AlignCenter) + self._image_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) + self._image_label.setMinimumSize(1, 1) + + # Важно: scaledContents=False, иначе некорректно комбинируется с зумом/KeepAspectRatio + self._image_label.setScaledContents(False) + + # Устанавливаем фон как у главного окна + self.setStyleSheet(APP_STYLES.get("VIEW_WIDGET_BACKGROUND", "")) + self._image_label.setStyleSheet(APP_STYLES.get("VIEW_IMAGE_BACKGROUND", "")) + + main_container.add_widget_with_stretch(self._image_label, 1) + def load_images(self, images_dict): + """Загрузка изображений""" + self.clear_images() + + if not images_dict: + self.show_error("Изображения не указаны") + return + + loaded_count = 0 + for view_name, image_path in images_dict.items(): + p = Path(image_path) + if not p.exists(): + continue + + pixmap = QPixmap(str(p)) + if pixmap.isNull(): + continue + + self._images[view_name] = pixmap + loaded_count += 1 + + if loaded_count == 0: + self.show_error("Не удалось загрузить ни одного изображения") + return + + first_view = list(self._images.keys())[0] + self.show_view(first_view) + + def clear_images(self): + """Очистка изображений""" + self._images.clear() + self._current_view = None + self._base_pixmap = None + self._image_label.clear() + self._current_zoom = 1.0 + + def show_view(self, view_name): + """Показать изображение вида""" + if view_name not in self._images: + return + + self._current_view = view_name + self._base_pixmap = self._images[view_name] + self.update_display() + + def switch_view_cycle(self, preferred_order): + """Циклическое переключение вида с предпочтительным порядком""" + if not self._images or not self._current_view: + return + + available_views = [v for v in preferred_order if v in self._images] + if not available_views: + return + + if self._current_view in available_views: + current_index = available_views.index(self._current_view) + next_index = (current_index + 1) % len(available_views) + else: + next_index = 0 + + self.show_view(available_views[next_index]) + + def switch_to_isometric_or_next(self): + """Переключение на изометрический вид или следующий в цикле""" + if not self._images: + return + + if 'isometric' in self._images and self._current_view != 'isometric': + self.show_view('isometric') + return + + available_views = list(self._images.keys()) + if not available_views: + return + + if self._current_view in available_views: + i = available_views.index(self._current_view) + self.show_view(available_views[(i + 1) % len(available_views)]) + else: + self.show_view(available_views[0]) + + def update_display(self): + """Обновление отображения изображения с учетом зума""" + if self._base_pixmap is None or self._base_pixmap.isNull(): + return + + # Базовый размер — размер label, дальше умножаем на zoom + target = self._image_label.size() + if target.width() <= 1 or target.height() <= 1: + return + + w = max(1, int(target.width() * self._current_zoom)) + h = max(1, int(target.height() * self._current_zoom)) + + scaled = self._base_pixmap.scaled(w, h, Qt.KeepAspectRatio, Qt.SmoothTransformation) + self._image_label.setPixmap(scaled) + + def zoom_in(self): + self._current_zoom = min(self._current_zoom * 1.2, 5.0) + self.update_display() + + def zoom_out(self): + self._current_zoom = max(self._current_zoom / 1.2, 0.2) + self.update_display() + + def reset_zoom(self): + self._current_zoom = 1.0 + self.update_display() + + def show_error(self, message): + self.clear_images() + self.error_occurred.emit(message) + + def wheelEvent(self, event): + if self._images: + delta = event.angleDelta().y() + if delta > 0: + self.zoom_in() + else: + self.zoom_out() + event.accept() + + def resizeEvent(self, event): + super().resizeEvent(event) + self.update_display() + + 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) + + + + + + +# --------------------------------------------------------------------------- +# Module workflow notes +# --------------------------------------------------------------------------- +# +# 1) Назначение модуля: +# Виджет для отображения фотографий видов детали с поддержкой +# зума (колесо мыши), циклического переключения видов и +# масштабирования с сохранением пропорций (KeepAspectRatio). +# +# 2) Зависимости модуля: +# Импорты: Path (pathlib), +# QLabel, QSizePolicy (PySide6.QtWidgets), +# Qt, Signal (PySide6.QtCore), +# QPixmap (PySide6.QtGui), +# VContainer, SContainer (gui.containers), +# APP_STYLES (gui.styles) +# Хост-класс / базовый класс: SContainer +# Внешние библиотеки: PySide6 (обязательна) +# +# 3) Экспорт: +# Класс PhotoViewWidget — виджет фотогалереи. +# Сигнал: error_occurred(str). +# Методы: load_images(dict), clear_images(), show_view(str), +# switch_view_cycle(list), switch_to_isometric_or_next(), +# update_display(), zoom_in(), zoom_out(), reset_zoom(), +# show_error(). +# +# 4) Состояние (поля): +# _content_fit: bool — режим подгонки +# _current_zoom: float — текущий множитель зума (1.0 = 100%) +# _current_view: str|None — имя текущего вида +# _images: dict — {имя_вида: QPixmap} +# _base_pixmap: QPixmap|None— исходный pixmap текущего вида +# _image_label: QLabel — виджет отображения изображения +# +# 5) Последовательность действий и вызовов: +# __init__(content_fit) +# -> super().__init__(...) +# -> setup_ui() — VContainer + QLabel с Expanding + stylesheet +# load_images(images_dict) +# -> clear_images() -> для каждого пути: Path.exists() -> QPixmap +# -> show_view(first_view) +# show_view(view_name) +# -> _base_pixmap = _images[view_name] -> update_display() +# update_display() +# -> размер label * _current_zoom -> scaled(KeepAspectRatio, SmoothTransformation) +# -> _image_label.setPixmap(scaled) +# wheelEvent(event) +# -> delta > 0: zoom_in() | delta < 0: zoom_out() +# resizeEvent(event) +# -> update_display() — адаптация к новому размеру +# +# 6) Побочные эффекты: +# - Загружает файлы изображений с диска. +# - Устанавливает stylesheet через APP_STYLES. +# - Испускает error_occurred при ошибках. +# +# 7) Границы ответственности: +# Модуль НЕ управляет источником изображений (каталогом). +# НЕ редактирует файлы. НЕ подключается к theme_bus. +# +# 8) Обработка ошибок: +# load_images() проверяет Path.exists() и QPixmap.isNull(). +# show_error() очищает изображения и испускает error_occurred. +# +# 9) Инварианты и контракты: +# - _current_zoom ∈ [0.2, 5.0]. +# - scaledContents = False — масштабирование управляется вручную. +# - Если _base_pixmap is None — update_display() не делает ничего. +# +# 10) Правило сопровождения: +# Для добавления поддержки drag-and-drop или аннотаций — расширять +# подкласс, не модифицировать этот файл. Зум-границы (0.2–5.0) — +# константы в zoom_in/zoom_out. diff --git a/Dispatch_V0.1.1/gui/components/radio_button.py b/Dispatch_V0.1.1/gui/components/radio_button.py new file mode 100644 index 0000000..a6e7209 --- /dev/null +++ b/Dispatch_V0.1.1/gui/components/radio_button.py @@ -0,0 +1,249 @@ +# -*- coding: utf-8 -*- +# gui/components/radio_button.py +"""Обёртка над QRadioButton с централизованными стилями.""" + +from PySide6.QtWidgets import QRadioButton, QSizePolicy, QButtonGroup +from PySide6.QtCore import Slot + +from gui.styles import APP_STYLES +from gui.theme_bus import theme_bus +from gui.containers.s_container import SContainer + + +class RadioButton(SContainer): + """Кастомный QRadioButton в SContainer с поддержкой выравнивания.""" + + def __init__( + self, + text: str = "", + width_percent: int | None = None, + height_percent: int | None = None, + margin: int | tuple[int, int, int, int] = 0, + style: str = "RADIO_BUTTON", + active_style: str | None = None, + is_active: bool | None = None, + content_fit: bool = True, + parent=None, + ): + super().__init__( + width_percent=width_percent, + height_percent=height_percent, + margin=margin, + style=style, + active_style=active_style, + is_active=is_active, + content_fit=content_fit, + parent=parent, + ) + self._theme = "dark" + self._is_active = False + self._base_style_key = style + self._style_key_normal = None + self._style_key_active = None + + self._button = QRadioButton(text) + self._button.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Preferred) + super().add_widget(self._button) + + if active_style is not None: + self._style_key_normal = style + self._style_key_active = active_style + if is_active is not None: + self._is_active = bool(is_active) + + self.style() + theme_bus.theme_changed.connect(self.set_theme) + + def style( + self, + style_key: str | None = None, + active_key: str | None = None, + is_active: bool | None = None, + ) -> None: + """Короткий метод применения стиля. Можно задать ключи и активность явно.""" + if style_key is not None: + self._base_style_key = style_key + if active_key is not None: + self._style_key_normal = style_key + self._style_key_active = active_key + else: + self._style_key_normal = None + self._style_key_active = None + + if is_active is not None: + self._is_active = bool(is_active) + + if self._style_key_normal is not None: + active_key = self._style_key_active or self._style_key_normal + key = active_key if self._is_active else self._style_key_normal + themed = f"{key}_{self._theme.upper()}" + if themed in APP_STYLES: + key = themed + self._button.setStyleSheet(APP_STYLES.get(key, "")) + return + + base_key = self._base_style_key + key = base_key + + if self._theme == "light": + if self._is_active and f"{base_key}_LIGHT_ACTIVE" in APP_STYLES: + key = f"{base_key}_LIGHT_ACTIVE" + elif f"{base_key}_LIGHT" in APP_STYLES: + key = f"{base_key}_LIGHT" + else: + if self._is_active and f"{base_key}_DARK_ACTIVE" in APP_STYLES: + key = f"{base_key}_DARK_ACTIVE" + elif f"{base_key}_DARK" in APP_STYLES: + key = f"{base_key}_DARK" + + self._button.setStyleSheet(APP_STYLES.get(key, "")) + + @Slot(str) + def set_theme(self, theme: str) -> None: + """Внешний слот: принимает 'dark' или 'light'.""" + theme = (theme or "").strip().lower() + if theme not in ("dark", "light"): + return + if self._theme == theme: + return + self._theme = theme + self.style() + + def set_text(self, text: str) -> None: + self._button.setText(text) + + def get_text(self) -> str: + return self._button.text() + + def set_checked(self, checked: bool) -> None: + self._button.setChecked(checked) + + def is_checked(self) -> bool: + return self._button.isChecked() + + def set_enabled(self, enabled: bool) -> None: + self._button.setEnabled(enabled) + super().setEnabled(enabled) + + def set_min_width(self, width: int) -> None: + self._button.setMinimumWidth(width) + super().setMinimumWidth(width) + + def set_min_height(self, height: int) -> None: + self._button.setMinimumHeight(height) + super().setMinimumHeight(height) + + def set_max_width(self, width: int) -> None: + self._button.setMaximumWidth(width) + super().setMaximumWidth(width) + + def set_max_height(self, height: int) -> None: + self._button.setMaximumHeight(height) + super().setMaximumHeight(height) + + def set_fixed_size(self, width: int, height: int) -> None: + self._button.setMinimumSize(width, height) + self._button.setMaximumSize(width, height) + super().setMinimumSize(width, height) + super().setMaximumSize(width, height) + + def set_tooltip(self, text: str) -> None: + self._button.setToolTip(text) + + def set_size_policy(self, horizontal, vertical) -> None: + self._button.setSizePolicy(horizontal, vertical) + super().setSizePolicy(horizontal, vertical) + + def set_alignment(self, alignment) -> None: + """Установить выравнивание внутренней кнопки в контейнере.""" + self.set_widget_alignment(self._button, alignment) + + def set_margins(self, margin: int | tuple[int, int, int, int]) -> None: + """Установить отступы контейнера.""" + if isinstance(margin, (list, tuple)) and len(margin) == 4: + self.layout().setContentsMargins(*margin) + else: + self.layout().setContentsMargins(margin, margin, margin, margin) + + def add_to_group(self, group: QButtonGroup) -> None: + """Добавить кнопку в QButtonGroup, не раскрывая внутренний виджет.""" + group.addButton(self._button) + + @property + def toggled(self): + return self._button.toggled + + @property + def clicked(self): + return self._button.clicked + + def add_widget(self, widget, alignment=None): + raise NotImplementedError("RadioButton может содержать только одну кнопку") + + +# --------------------------------------------------------------------------- +# Module workflow notes +# --------------------------------------------------------------------------- +# +# 1) Назначение модуля: +# Обёртка над QRadioButton в SContainer с поддержкой APP_STYLES, +# темизации и интеграции с QButtonGroup через метод add_to_group(). +# +# 2) Зависимости модуля: +# Импорты: QRadioButton, QSizePolicy, QButtonGroup (PySide6.QtWidgets), +# Slot (PySide6.QtCore), +# APP_STYLES (gui.styles), +# theme_bus (gui.theme_bus), +# SContainer (gui.containers.s_container) +# Хост-класс / базовый класс: SContainer +# Внешние библиотеки: PySide6 (обязательна) +# +# 3) Экспорт: +# Класс RadioButton — публичный виджет радиокнопки. +# Методы: style(), set_theme(), set_text(), get_text(), +# set_checked(), is_checked(), set_enabled(), +# set_alignment(), set_margins(), add_to_group(QButtonGroup), +# set_min/max_width/height(), set_fixed_size(), +# set_tooltip(), set_size_policy(). +# Свойства: toggled, clicked. +# +# 4) Состояние (поля): +# _theme: str — текущая тема +# _is_active: bool — признак активного состояния +# _base_style_key: str — базовый ключ стиля ("RADIO_BUTTON") +# _style_key_normal: str|None — явный нормальный стиль +# _style_key_active: str|None — явный активный стиль +# _button: QRadioButton — внутренний виджет +# +# 5) Последовательность действий и вызовов: +# __init__(text, ...) +# -> super().__init__(...) +# -> QRadioButton(text) -> setSizePolicy(Preferred) +# -> super().add_widget(_button) +# -> style() -> theme_bus.theme_changed.connect(set_theme) +# add_to_group(group: QButtonGroup) +# -> group.addButton(self._button) — инкапсуляция внутреннего виджета +# toggled / clicked (properties) +# -> делегируют к _button.toggled / _button.clicked +# +# 6) Побочные эффекты: +# - Устанавливает stylesheet на QRadioButton. +# - Подключается к theme_bus.theme_changed. +# - add_to_group() модифицирует внешний QButtonGroup. +# +# 7) Границы ответственности: +# Модуль НЕ управляет взаимоисключающим выбором самостоятельно (это +# задача RadioGroup/QButtonGroup). add_widget() заблокирован. +# +# 8) Обработка ошибок: +# add_widget() бросает NotImplementedError. +# set_theme() молча игнорирует невалидные значения. +# +# 9) Инварианты и контракты: +# - Контейнер содержит ровно один QRadioButton. +# - _theme ∈ {"dark", "light"}. +# +# 10) Правило сопровождения: +# Для группировки — использовать RadioGroup.add_button(this). +# Не вызывать group.addButton(_button) напрямую — использовать +# add_to_group() для сохранения инкапсуляции. diff --git a/Dispatch_V0.1.1/gui/components/radio_group.py b/Dispatch_V0.1.1/gui/components/radio_group.py new file mode 100644 index 0000000..1505ee4 --- /dev/null +++ b/Dispatch_V0.1.1/gui/components/radio_group.py @@ -0,0 +1,109 @@ +# -*- coding: utf-8 -*- +# gui/components/radio_group.py +"""Контейнеризированная обёртка над QButtonGroup.""" + +from __future__ import annotations + +from PySide6.QtWidgets import QButtonGroup + +from gui.containers.s_container import SContainer + + +class RadioGroup(SContainer): + """Логическая группа RadioButton с взаимоисключающим выбором. + + Оборачивает ``QButtonGroup`` в ``SContainer``-совместимый компонент, + исключая прямое использование Qt-классов в прикладном коде. + + Визуально компонент невидим (zero-size), используется только для + логической привязки RadioButton друг к другу. + """ + + def __init__( + self, + exclusive: bool = True, + parent=None, + ): + super().__init__( + width_percent=None, + height_percent=None, + margin=0, + parent=parent, + ) + self.setFixedSize(0, 0) + self._group = QButtonGroup(self) + self._group.setExclusive(exclusive) + + # ── Публичный API ───────────────────────────────────────────── + + def add_button(self, radio_button) -> None: + """Добавить ``RadioButton`` в группу.""" + radio_button.add_to_group(self._group) + + def set_exclusive(self, exclusive: bool) -> None: + self._group.setExclusive(exclusive) + + def is_exclusive(self) -> bool: + return self._group.exclusive() + + @property + def button_clicked(self): + """Сигнал ``QButtonGroup.buttonClicked``.""" + return self._group.buttonClicked + + @property + def button_toggled(self): + """Сигнал ``QButtonGroup.buttonToggled``.""" + return self._group.buttonToggled + + +# --------------------------------------------------------------------------- +# Module workflow notes +# --------------------------------------------------------------------------- +# +# 1) Назначение модуля: +# Логическая группа RadioButton с взаимоисключающим выбором, +# оборачивающая QButtonGroup в SContainer-совместимый компонент. +# Визуально невидим (fixedSize 0×0), используется только для +# логической привязки. +# +# 2) Зависимости модуля: +# Импорты: QButtonGroup (PySide6.QtWidgets), +# SContainer (gui.containers.s_container) +# Хост-класс / базовый класс: SContainer +# Внешние библиотеки: PySide6 (обязательна) +# +# 3) Экспорт: +# Класс RadioGroup — публичный компонент логической группировки. +# Методы: add_button(RadioButton), set_exclusive(bool), is_exclusive(). +# Свойства: button_clicked, button_toggled (сигналы QButtonGroup). +# +# 4) Состояние (поля): +# _group: QButtonGroup — внутренняя группа кнопок +# +# 5) Последовательность действий и вызовов: +# __init__(exclusive=True, parent=None) +# -> super().__init__(...) -> setFixedSize(0, 0) +# -> QButtonGroup(self) -> setExclusive(exclusive) +# add_button(radio_button) +# -> radio_button.add_to_group(_group) +# (делегирует RadioButton → group.addButton) +# +# 6) Побочные эффекты: +# - Модифицирует QButtonGroup (добавление кнопок, смена exclusive). +# - Размер виджета зафиксирован 0×0 — невидимый. +# +# 7) Границы ответственности: +# Модуль НЕ управляет визуальным представлением кнопок. +# НЕ применяет стили. НЕ подключается к theme_bus. +# +# 8) Обработка ошибок: +# Нет явной обработки. Ожидает корректный RadioButton на входе. +# +# 9) Инварианты и контракты: +# - По умолчанию exclusive=True — только одна кнопка может быть выбрана. +# - FixedSize(0, 0) — виджет не занимает место в layout. +# +# 10) Правило сопровождения: +# Не добавлять визуальные элементы. Не менять размер с 0×0. +# add_button() принимает только RadioButton (не QRadioButton напрямую). diff --git a/Dispatch_V0.1.1/gui/components/simple_model_viewer/__init__.py b/Dispatch_V0.1.1/gui/components/simple_model_viewer/__init__.py new file mode 100644 index 0000000..7d89212 --- /dev/null +++ b/Dispatch_V0.1.1/gui/components/simple_model_viewer/__init__.py @@ -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"] — единственный публичный символ. + diff --git a/Dispatch_V0.1.1/gui/components/simple_model_viewer/__pycache__/__init__.cpython-313.pyc b/Dispatch_V0.1.1/gui/components/simple_model_viewer/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..13b3234 Binary files /dev/null and b/Dispatch_V0.1.1/gui/components/simple_model_viewer/__pycache__/__init__.cpython-313.pyc differ diff --git a/Dispatch_V0.1.1/gui/components/simple_model_viewer/__pycache__/model_view_widget.cpython-313.pyc b/Dispatch_V0.1.1/gui/components/simple_model_viewer/__pycache__/model_view_widget.cpython-313.pyc new file mode 100644 index 0000000..1d2c1e7 Binary files /dev/null and b/Dispatch_V0.1.1/gui/components/simple_model_viewer/__pycache__/model_view_widget.cpython-313.pyc differ diff --git a/Dispatch_V0.1.1/gui/components/simple_model_viewer/model_view_widget.py b/Dispatch_V0.1.1/gui/components/simple_model_viewer/model_view_widget.py new file mode 100644 index 0000000..dce267e --- /dev/null +++ b/Dispatch_V0.1.1/gui/components/simple_model_viewer/model_view_widget.py @@ -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). diff --git a/Dispatch_V0.1.1/gui/components/springs.py b/Dispatch_V0.1.1/gui/components/springs.py new file mode 100644 index 0000000..4b39df8 --- /dev/null +++ b/Dispatch_V0.1.1/gui/components/springs.py @@ -0,0 +1,123 @@ +# -*- coding: utf-8 -*- +# gui/components/springs.py +"""Простые пружины для компоновок.""" + +from PySide6.QtWidgets import QWidget, QSizePolicy + + +class VSpring(QWidget): + """Вертикальная пружина.""" + + def __init__(self, parent=None): + super().__init__(parent) + self.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Expanding) + + 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) + + +class HSpring(QWidget): + """Горизонтальная пружина.""" + + def __init__(self, parent=None): + super().__init__(parent) + self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum) + + 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) + + + + + +# --------------------------------------------------------------------------- +# Module workflow notes +# --------------------------------------------------------------------------- +# +# 1) Назначение модуля: +# Простые пружины (spacer-виджеты) для вертикальной и горизонтальной +# компоновки: VSpring растягивается вертикально, HSpring — горизонтально. +# +# 2) Зависимости модуля: +# Импорты: QWidget, QSizePolicy (PySide6.QtWidgets) +# Хост-класс / базовый класс: QWidget (не SContainer!) +# Внешние библиотеки: PySide6 (обязательна) +# +# 3) Экспорт: +# Класс VSpring — вертикальная пружина (Minimum × Expanding). +# Класс HSpring — горизонтальная пружина (Expanding × Minimum). +# Оба класса предоставляют стандартный API размеров: +# set_enabled(), set_min/max_width/height(), set_fixed_size(), +# set_tooltip(), set_size_policy(). +# +# 4) Состояние (поля): +# Нет собственных полей. Только наследованные от QWidget. +# +# 5) Последовательность действий и вызовов: +# __init__(parent=None) +# -> super().__init__(parent) +# -> setSizePolicy(Minimum, Expanding) для VSpring +# -> setSizePolicy(Expanding, Minimum) для HSpring +# +# 6) Побочные эффекты: +# Нет — чистые пустые виджеты-распорки. +# +# 7) Границы ответственности: +# Только распорка в layout. Не рисует, не обрабатывает события, +# не подключается к theme_bus, не имеет стилей. +# +# 8) Обработка ошибок: +# Нет обработки ошибок. Виджет пассивен. +# +# 9) Инварианты и контракты: +# - VSpring всегда Expanding по вертикали, Minimum по горизонтали. +# - HSpring всегда Expanding по горизонтали, Minimum по вертикали. +# +# 10) Правило сопровождения: +# Не наследовать от SContainer — пружины должны быть максимально +# лёгкими. Использовать для заполнения свободного пространства +# в VContainer/HContainer. diff --git a/Dispatch_V0.1.1/gui/components/tab_button.py b/Dispatch_V0.1.1/gui/components/tab_button.py new file mode 100644 index 0000000..3cc0530 --- /dev/null +++ b/Dispatch_V0.1.1/gui/components/tab_button.py @@ -0,0 +1,219 @@ +# -*- coding: utf-8 -*- +# gui/components/tab_button.py + +from PySide6.QtWidgets import QPushButton, QSizePolicy +from PySide6.QtCore import Slot +from gui.containers.s_container import SContainer +from gui.styles import APP_STYLES +from gui.theme_bus import theme_bus + + +class TabButton(SContainer): + """Обёртка кнопки вкладки со стилями, зависящими от темы.""" + + def __init__(self, text: str, index: int, **kwargs): + width_percent = kwargs.get("width_percent", None) + height_percent = kwargs.get("height_percent", None) + margin = kwargs.get("margin", [0, 2, 0, 2]) + style = kwargs.get("style", None) + active_style = kwargs.get("active_style", None) + is_active = kwargs.get("is_active", None) + content_fit = kwargs.get("content_fit", True) + + super().__init__( + width_percent=width_percent, + height_percent=height_percent, + margin=margin, + style=style, + active_style=active_style, + is_active=is_active, + content_fit=content_fit, + ) + + self.index = index + self._theme = "dark" + + self._button = QPushButton(text) + self._button.setProperty("tab_index", index) + + super().add_widget(self._button) + + self._button.setSizePolicy( + QSizePolicy.Policy.Expanding, + QSizePolicy.Policy.Expanding + ) + + self._style_key_normal = style or "TAB_BUTTON_NORMAL" + self._style_key_active = active_style or "TAB_BUTTON_ACTIVE" + self._is_active = bool(is_active) if is_active is not None else False + self.style() + theme_bus.theme_changed.connect(self.set_theme) + + def style( + self, + style_key: str | None = None, + active_key: str | None = None, + is_active: bool | None = None, + ): + if style_key is not None: + self._style_key_normal = style_key + self._style_key_active = active_key or style_key + if is_active is not None: + self._is_active = bool(is_active) + normal_key = self._style_key_normal + active_key_to_use = self._style_key_active + + if self._theme == "light": + themed_normal = f"{normal_key}_LIGHT" + themed_active = f"{active_key_to_use}_LIGHT" + if themed_normal in APP_STYLES: + normal_key = themed_normal + if themed_active in APP_STYLES: + active_key_to_use = themed_active + + key = active_key_to_use if self._is_active else normal_key + self._button.setStyleSheet(APP_STYLES.get(key, "")) + + @Slot(str) + def set_theme(self, theme: str): + theme = (theme or "").strip().lower() + if theme not in ("dark", "light"): + return + if self._theme == theme: + return + self._theme = theme + self.style() + + @property + def clicked(self): + return self._button.clicked + + def click(self): + self._button.click() + + def set_text(self, text: str): + self._button.setText(text) + + def get_text(self) -> str: + return self._button.text() + + def set_enabled(self, enabled: bool) -> None: + self._button.setEnabled(enabled) + super().setEnabled(enabled) + + def set_tooltip(self, text: str) -> None: + self._button.setToolTip(text) + + def set_min_width(self, width: int) -> None: + self._button.setMinimumWidth(width) + super().setMinimumWidth(width) + + def set_min_height(self, height: int) -> None: + self._button.setMinimumHeight(height) + super().setMinimumHeight(height) + + def set_max_width(self, width: int) -> None: + self._button.setMaximumWidth(width) + super().setMaximumWidth(width) + + def set_max_height(self, height: int) -> None: + self._button.setMaximumHeight(height) + super().setMaximumHeight(height) + + def set_fixed_size(self, width: int, height: int) -> None: + self._button.setMinimumSize(width, height) + self._button.setMaximumSize(width, height) + super().setMinimumSize(width, height) + super().setMaximumSize(width, height) + + def set_checkable(self, checkable: bool) -> None: + self._button.setCheckable(checkable) + + def set_checked(self, checked: bool) -> None: + self._button.setChecked(checked) + + def is_checked(self) -> bool: + return self._button.isChecked() + + def set_size_policy(self, horizontal, vertical) -> None: + self._button.setSizePolicy(horizontal, vertical) + super().setSizePolicy(horizontal, vertical) + + def set_property(self, name: str, value) -> None: + super().setProperty(name, value) + self._button.setProperty(name, value) + + def add_widget(self, widget, alignment=None): + raise NotImplementedError("TabButton can contain only one QPushButton") + + +# --------------------------------------------------------------------------- +# Module workflow notes +# --------------------------------------------------------------------------- +# +# 1) Назначение модуля: +# Кнопка вкладки для TabWidget, реализованная как SContainer +# с QPushButton внутри и стилями, зависящими от темы. +# Поддерживает нормальное и активное состояние для визуализации +# текущей вкладки. +# +# 2) Зависимости модуля: +# Импорты: QPushButton, QSizePolicy (PySide6.QtWidgets), +# Slot (PySide6.QtCore), +# SContainer (gui.containers.s_container), +# APP_STYLES (gui.styles), +# theme_bus (gui.theme_bus) +# Хост-класс / базовый класс: SContainer +# Внешние библиотеки: PySide6 (обязательна) +# +# 3) Экспорт: +# Класс TabButton — кнопка вкладки. +# Методы: style(), set_theme(), click(), set_text(), get_text(), +# set_enabled(), set_tooltip(), set_checkable(), set_checked(), +# is_checked(), set_min/max_width/height(), set_fixed_size(), +# set_size_policy(), set_property(). +# Свойство: clicked. +# +# 4) Состояние (поля): +# index: int — номер вкладки +# _theme: str — текущая тема +# _button: QPushButton — внутренняя кнопка +# _style_key_normal: str — ключ нормального стиля (TAB_BUTTON_NORMAL) +# _style_key_active: str — ключ активного стиля (TAB_BUTTON_ACTIVE) +# _is_active: bool — признак активной вкладки +# +# 5) Последовательность действий и вызовов: +# __init__(text, index, **kwargs) +# -> super().__init__(...) +# -> QPushButton(text) -> setProperty("tab_index", index) +# -> super().add_widget(_button) -> setSizePolicy(Expanding) +# -> style() -> theme_bus.theme_changed.connect(set_theme) +# style(style_key?, active_key?, is_active?) +# -> для light-темы: проверить суффикс _LIGHT в APP_STYLES +# -> выбрать active или normal ключ +# -> _button.setStyleSheet(APP_STYLES[key]) +# Вызывается из TabWidget._apply_active_style(index) +# +# 6) Побочные эффекты: +# - Устанавливает stylesheet на QPushButton. +# - Подключается к theme_bus.theme_changed. +# +# 7) Границы ответственности: +# Модуль НЕ управляет содержимым вкладок. +# НЕ переключает страницы — это делает TabWidget. +# add_widget() заблокирован. +# +# 8) Обработка ошибок: +# add_widget() бросает NotImplementedError. +# set_theme() молча игнорирует невалидные значения. +# +# 9) Инварианты и контракты: +# - Контейнер содержит ровно одну QPushButton. +# - _theme ∈ {"dark", "light"}. +# - index совпадает с порядковым номером вкладки в TabWidget. +# +# 10) Правило сопровождения: +# Стили TAB_BUTTON_NORMAL / TAB_BUTTON_ACTIVE должны быть +# определены в APP_STYLES. Для light-темы — суффикс _LIGHT. +# Не менять index после добавления в TabWidget (переиндексация +# делается в TabWidget.remove_tab()). diff --git a/Dispatch_V0.1.1/gui/components/tab_widget.py b/Dispatch_V0.1.1/gui/components/tab_widget.py new file mode 100644 index 0000000..b622f63 --- /dev/null +++ b/Dispatch_V0.1.1/gui/components/tab_widget.py @@ -0,0 +1,335 @@ +# -*- coding: utf-8 -*- +# gui/components/tab_widget.py + +from typing import Any, List, Optional, Type + +from PySide6.QtCore import Signal +from PySide6.QtGui import QResizeEvent, QShowEvent +from PySide6.QtWidgets import QWidget + +from gui.containers.s_container import SContainer +from gui.containers.h_container import HContainer +from gui.containers.stack_container import StackContainer + +from gui.components.tab_button import TabButton +from error_logger import log_exception + + +class TabWidget(SContainer): + """ + Процентный TabWidget: + - Верхняя панель вкладок (HContainer) заданной высоты в % + - Контент (StackContainer + QStackedWidget) занимает оставшуюся высоту + """ + + currentChanged = Signal(int) + + def __init__( + self, + width_percent: Optional[int] = None, + height_percent: Optional[int] = None, + tab_bar_height_percent: int = 8, + margin: int = 0, + parent: Optional[QWidget] = None, + content_fit: bool = True, + style: Optional[str] = None, + active_style: Optional[str] = None, + is_active: Optional[bool] = None, + button_cls: Type = TabButton, + button_style_normal: Optional[str] = None, + button_style_active: Optional[str] = None, + button_width_percent: Optional[int] = None, + button_height_percent: Optional[int] = None, + button_margin: Optional[list[int]] = None, + button_inner_margin: Optional[int] = None, + tab_bar_content_fit: bool = True, + ): + super().__init__( + width_percent=width_percent, + height_percent=height_percent, + orientation="v", + margin=margin, + content_fit=content_fit, + parent=parent + ) + + if not (1 <= tab_bar_height_percent <= 99): + raise ValueError("tab_bar_height_percent должен быть в диапазоне 1..99") + + self._tab_bar_height_percent = tab_bar_height_percent + self._content_height_percent = 100 - tab_bar_height_percent + + # Верхняя панель вкладок + self._tab_bar = HContainer( + height_percent=self._tab_bar_height_percent, + margin=0, + content_fit=tab_bar_content_fit, + ) + + # Контентная часть + self._content_container = StackContainer( + width_percent=100, + height_percent=self._content_height_percent, + margin=0 + ) + + # Добавляем в основной VContainer + self.add_widget(self._tab_bar) + self.add_widget(self._content_container) + + self._button_cls = button_cls + self._button_style_normal = button_style_normal + self._button_style_active = button_style_active + self._button_width_percent = button_width_percent + self._button_height_percent = button_height_percent + self._button_margin = button_margin + self._button_inner_margin = button_inner_margin + self._buttons: List[Any] = [] + + if style is not None or active_style is not None or is_active is not None: + self.style(style_key=style, active_key=active_style, is_active=is_active) + + # -------------------- Публичный API -------------------- + + def showEvent(self, event: QShowEvent) -> None: + super().showEvent(event) + self._recompute_tab_button_widths() + + def resizeEvent(self, event: QResizeEvent) -> None: + super().resizeEvent(event) + self._recompute_tab_button_widths() + + def style( + self, + style_key: Optional[str] = None, + active_key: Optional[str] = None, + is_active: Optional[bool] = None, + ) -> None: + """Короткий метод применения стиля для TabWidget.""" + super().style(style_key=style_key, active_key=active_key, is_active=is_active) + + def add_tab(self, widget: QWidget, title: str) -> int: + """ + Добавляет вкладку и страницу. + Возвращает индекс новой вкладки. + """ + index = len(self._buttons) + + btn = self._button_cls( + text=title, + index=index, + width_percent=self._button_width_percent, + height_percent=self._button_height_percent or 100, + margin=self._button_margin if self._button_margin is not None else 0, + text_left_margin=self._button_inner_margin, + style=self._button_style_normal, + active_style=self._button_style_active, + ) + + btn.clicked.connect(lambda checked=False, i=index: self.set_current_index(i)) + + self._tab_bar.add_widget(btn) + self._content_container.add_widget(widget) + + self._buttons.append(btn) + self._recompute_tab_button_widths() + + # Если это первая вкладка — активируем + if index == 0: + self.set_current_index(0) + + return index + + def set_current_index(self, index: int) -> None: + if index < 0 or index >= self.count(): + return + + self._content_container.set_current_index(index) + self._apply_active_style(index) + self.currentChanged.emit(index) + + def current_index(self) -> int: + return self._content_container.current_index() + + def widget(self, index: int) -> Optional[QWidget]: + return self._content_container.widget(index) + + def count(self) -> int: + return self._content_container.count() + + def remove_tab(self, index: int) -> None: + if index < 0 or index >= self.count(): + return + + # 1) убрать кнопку + btn = self._buttons.pop(index) + btn.setParent(None) + btn.deleteLater() + + # 2) убрать страницу + w = self._content_container.widget(index) + if w is not None: + self._content_container.remove_widget(w) + if w is not None: + w.setParent(None) + + # 3) переиндексация кнопок и их callback + for i, b in enumerate(self._buttons): + b.index = i + if hasattr(b, "set_property"): + b.set_property("tab_index", i) + try: + b.clicked.disconnect() + except Exception as e: + log_exception(__name__, "remove_tab.disconnect", e) + b.clicked.connect(lambda checked=False, ii=i: self.set_current_index(ii)) + + self._recompute_tab_button_widths() + + # 4) корректировка текущей + if self.count() > 0: + self.set_current_index(min(index, self.count() - 1)) + + def set_tab_text(self, index: int, text: str) -> None: + if 0 <= index < len(self._buttons): + self._buttons[index].set_text(text) + + def set_tab_enabled(self, index: int, enabled: bool) -> None: + if 0 <= index < len(self._buttons): + self._buttons[index].set_enabled(enabled) + + def add_tab_bar_spacer(self, stretch: int = 1) -> None: + """Добавляет растягиваемый спейсер в панель вкладок (прижимает кнопки влево).""" + self._tab_bar.add_stretch(stretch) + + # -------------------- Внутренние -------------------- + + def _apply_active_style(self, active_index: int) -> None: + for i, b in enumerate(self._buttons): + if i == active_index: + b.style(is_active=True) + else: + b.style(is_active=False) + + def _recompute_tab_button_widths(self) -> None: + """ + Делит 100% ширины между кнопками вкладок. + Важное: проценты целые, сумма строго 100. + """ + n = len(self._buttons) + if n == 0: + return + + base = 100 // n + rem = 100 % n + + for i, b in enumerate(self._buttons): + if self._button_width_percent is not None: + w = self._button_width_percent + else: + w = base + (1 if i < rem else 0) + h = self._button_height_percent or 100 + # API PercentSizedWidget + b.set_percent_sizes(width_percent=w, height_percent=h) + + # Чтобы компоновка пересчиталась сразу + self._tab_bar.invalidate_layout() + tab_bar_layout = self._tab_bar.get_layout() + if tab_bar_layout is not None: + tab_bar_layout.activate() + self._tab_bar.updateGeometry() + self.updateGeometry() + + + + + +# --------------------------------------------------------------------------- +# Module workflow notes +# --------------------------------------------------------------------------- +# +# 1) Назначение модуля: +# Процентный TabWidget с верхней панелью вкладок (HContainer) +# заданной высоты в % и контентной частью (StackContainer), +# занимающей оставшуюся высоту. +# +# 2) Зависимости модуля: +# Импорты: Any, List, Optional, Type (typing), +# Signal, Qt (PySide6.QtCore), +# QResizeEvent, QShowEvent (PySide6.QtGui), +# QWidget (PySide6.QtWidgets), +# SContainer (gui.containers.s_container), +# HContainer (gui.containers.h_container), +# StackContainer (gui.containers.stack_container), +# TabButton (gui.components.tab_button) +# Хост-класс / базовый класс: SContainer (orientation="v") +# Внешние библиотеки: PySide6 (обязательна) +# +# 3) Экспорт: +# Класс TabWidget — контейнер с вкладками. +# Сигнал: currentChanged(int). +# Методы: add_tab(widget, title) -> index, set_current_index(int), +# current_index(), widget(index), count(), remove_tab(index), +# set_tab_text(), set_tab_enabled(), add_tab_bar_spacer(), +# style(). +# +# 4) Состояние (поля): +# _tab_bar_height_percent: int — высота панели вкладок в % +# _content_height_percent: int — высота контента (100 - tab_bar) +# _tab_bar: HContainer — горизонтальный контейнер кнопок +# _content_container: StackContainer — стек страниц +# _button_cls: Type — класс кнопки (по умолчанию TabButton) +# _button_style_normal: str|None — нормальный стиль кнопок +# _button_style_active: str|None — активный стиль кнопок +# _button_width/height_percent: int|None — размеры кнопок +# _button_margin: list[int]|None — отступы кнопок +# _button_inner_margin: int|None — внутренний отступ текста +# _buttons: list[TabButton] — список кнопок вкладок +# +# 5) Последовательность действий и вызовов: +# __init__(tab_bar_height_percent, ...) +# -> super().__init__(orientation="v") +# -> HContainer(height_percent) — панель вкладок +# -> StackContainer(height_percent) — стек контента +# -> self.add_widget(_tab_bar) + self.add_widget(_content_container) +# add_tab(widget, title) +# -> TabButton(text, index, ...) -> clicked.connect(set_current_index) +# -> _tab_bar.add_widget(btn) -> _content_container.add_widget(widget) +# -> _recompute_tab_button_widths() +# -> если первая вкладка → set_current_index(0) +# set_current_index(index) +# -> _content_container.set_current_index(index) +# -> _apply_active_style(index) — is_active=True для текущей, False для остальных +# -> currentChanged.emit(index) +# remove_tab(index) +# -> удалить кнопку, удалить страницу, переиндексировать оставшиеся +# -> _recompute_tab_button_widths() +# _recompute_tab_button_widths() +# -> 100% / n кнопок → set_percent_sizes на каждую кнопку +# -> invalidate_layout + activate + updateGeometry +# +# 6) Побочные эффекты: +# - Модифицирует _tab_bar и _content_container при add/remove. +# - Испускает currentChanged при смене вкладки. +# - Пересчитывает ширину кнопок при showEvent/resizeEvent. +# +# 7) Границы ответственности: +# Модуль управляет переключением страниц и визуальным состоянием +# вкладок. НЕ определяет стили кнопок — делегирует TabButton. +# НЕ создаёт содержимое страниц. +# +# 8) Обработка ошибок: +# tab_bar_height_percent вне [1, 99] → ValueError. +# set_current_index() / remove_tab() — проверка диапазона индекса. +# disconnect() в remove_tab() обёрнут в try/except. +# +# 9) Инварианты и контракты: +# - tab_bar_height_percent ∈ [1, 99]. +# - len(_buttons) == _content_container.count(). +# - Сумма width_percent всех кнопок == 100 (при автораскладке). +# - Индексы кнопок совпадают с индексами страниц в StackContainer. +# +# 10) Правило сопровождения: +# Для кастомизации кнопок — передать button_cls в конструктор. +# Не манипулировать _tab_bar и _content_container напрямую — +# использовать add_tab() / remove_tab(). diff --git a/Dispatch_V0.1.1/gui/components/text_input.py b/Dispatch_V0.1.1/gui/components/text_input.py new file mode 100644 index 0000000..30ba976 --- /dev/null +++ b/Dispatch_V0.1.1/gui/components/text_input.py @@ -0,0 +1,289 @@ +# -*- coding: utf-8 -*- +# gui/components/text_input.py +"""Поле ввода текста""" + +from PySide6.QtWidgets import QApplication, QLineEdit, QSizePolicy, QTextEdit +from PySide6.QtCore import Slot +from gui.styles import APP_STYLES +from gui.theme_bus import theme_bus +from gui.containers.s_container import SContainer +from error_logger import log_exception + + +class TextInput(SContainer): + """Поле ввода текста на базе SContainer.""" + + def __init__( + self, + text: str = "", + placeholder: str = "", + width_percent: int | None = None, + height_percent: int | None = None, + margin: int | tuple[int, int, int, int] = 0, + parent=None, + style: str = "TEXT_INPUT", + active_style: str | None = None, + is_active: bool | None = None, + content_fit: bool = True, + multiline: bool = False, + ): + super().__init__( + width_percent=width_percent, + height_percent=height_percent, + margin=margin, + style=style, + active_style=active_style, + is_active=is_active, + content_fit=content_fit, + parent=parent, + ) + self._theme = "dark" + self._is_active = False + self._base_style_key = style + self._style_key_normal = None + self._style_key_active = None + self._is_multiline = bool(multiline) + + if self._is_multiline: + self._input = QTextEdit() + self._input.setPlainText(text) + self._input.setPlaceholderText(placeholder) + else: + self._input = QLineEdit(text) + self._input.setPlaceholderText(placeholder) + self._input.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) + super().add_widget(self._input) + if active_style is not None: + self._style_key_normal = style + self._style_key_active = active_style + if is_active is not None: + self._is_active = bool(is_active) + + self._theme = "dark" if self.palette().window().color().lightness() < 128 else "light" + self.style() + theme_bus.theme_changed.connect(self.set_theme) + + def style( + self, + style_key: str | None = None, + active_key: str | None = None, + is_active: bool | None = None, + ) -> None: + """Короткий метод применения стиля. Можно задать ключи и активность явно.""" + if style_key is not None: + self._base_style_key = style_key + if active_key is not None: + self._style_key_normal = style_key + self._style_key_active = active_key + else: + self._style_key_normal = None + self._style_key_active = None + + if is_active is not None: + self._is_active = bool(is_active) + + if self._style_key_normal is not None: + active_key = self._style_key_active or self._style_key_normal + key = active_key if self._is_active else self._style_key_normal + themed = f"{key}_{self._theme.upper()}" + if themed in APP_STYLES: + key = themed + self._input.setStyleSheet(APP_STYLES.get(key, "")) + return + + base_key = self._base_style_key + key = base_key + + if self._theme == "light": + if self._is_active and f"{base_key}_LIGHT_ACTIVE" in APP_STYLES: + key = f"{base_key}_LIGHT_ACTIVE" + elif f"{base_key}_LIGHT" in APP_STYLES: + key = f"{base_key}_LIGHT" + else: + if self._is_active and f"{base_key}_DARK_ACTIVE" in APP_STYLES: + key = f"{base_key}_DARK_ACTIVE" + elif f"{base_key}_DARK" in APP_STYLES: + key = f"{base_key}_DARK" + + self._input.setStyleSheet(APP_STYLES.get(key, "")) + + @Slot(str) + def set_theme(self, theme: str) -> None: + """Внешний слот: принимает 'dark' или 'light'.""" + theme = (theme or "").strip().lower() + if theme not in ("dark", "light"): + return + if self._theme == theme: + return + self._theme = theme + self.style() + + def set_text(self, text: str) -> None: + if self._is_multiline: + self._input.setPlainText(text) + return + self._input.setText(text) + + def get_text(self) -> str: + if self._is_multiline: + return self._input.toPlainText() + return self._input.text() + + def clear(self) -> None: + self._input.clear() + + def set_placeholder(self, text: str) -> None: + self._input.setPlaceholderText(text) + + def set_enabled(self, enabled: bool) -> None: + self._input.setEnabled(enabled) + super().setEnabled(enabled) + + def set_read_only(self, readonly: bool) -> None: + self._input.setReadOnly(readonly) + + def set_validator(self, validator) -> None: + if self._is_multiline: + return + self._input.setValidator(validator) + + def install_event_filter(self, event_filter_obj) -> None: + self._input.installEventFilter(event_filter_obj) + + def has_focus_within(self) -> bool: + focused = QApplication.focusWidget() + if focused is None: + return False + if focused is self._input: + return True + try: + return bool(self._input.isAncestorOf(focused)) + except Exception as e: + log_exception(__name__, "has_focus_within", e) + return False + + def clear_focus(self) -> None: + self._input.clearFocus() + + def set_min_width(self, width: int) -> None: + self._input.setMinimumWidth(width) + super().setMinimumWidth(width) + + def set_min_height(self, height: int) -> None: + self._input.setMinimumHeight(height) + super().setMinimumHeight(height) + + def set_max_width(self, width: int) -> None: + self._input.setMaximumWidth(width) + super().setMaximumWidth(width) + + def set_max_height(self, height: int) -> None: + self._input.setMaximumHeight(height) + super().setMaximumHeight(height) + + def set_fixed_size(self, width: int, height: int) -> None: + self._input.setMinimumSize(width, height) + self._input.setMaximumSize(width, height) + super().setMinimumSize(width, height) + super().setMaximumSize(width, height) + + def set_tooltip(self, text: str) -> None: + self._input.setToolTip(text) + + def set_size_policy(self, horizontal, vertical) -> None: + self._input.setSizePolicy(horizontal, vertical) + super().setSizePolicy(horizontal, vertical) + + def add_widget(self, widget, alignment=None): + raise NotImplementedError("TextInput can contain only one QLineEdit") + + @property + def text_changed(self): + return self._input.textChanged + + @property + def return_pressed(self): + if self._is_multiline: + raise AttributeError("return_pressed is unavailable for multiline TextInput") + return self._input.returnPressed + + @property + def editing_finished(self): + if self._is_multiline: + raise AttributeError("editing_finished is unavailable for multiline TextInput") + return self._input.editingFinished + + @property + def text_edited(self): + if self._is_multiline: + raise AttributeError("text_edited is unavailable for multiline TextInput") + return self._input.textEdited + + + + + +# --------------------------------------------------------------------------- +# Module workflow notes +# --------------------------------------------------------------------------- +# +# 1) Назначение модуля: +# Поле ввода текста на базе QLineEdit в SContainer с поддержкой +# placeholder, централизованных стилей APP_STYLES и автоматической +# темизации. +# +# 2) Зависимости модуля: +# Импорты: QLineEdit, QSizePolicy (PySide6.QtWidgets), +# Slot (PySide6.QtCore), +# APP_STYLES (gui.styles), +# theme_bus (gui.theme_bus), +# SContainer (gui.containers.s_container) +# Хост-класс / базовый класс: SContainer +# Внешние библиотеки: PySide6 (обязательна) +# +# 3) Экспорт: +# Класс TextInput — публичный виджет поля ввода. +# Методы: style(), set_theme(), set_text(), get_text(), clear(), +# set_enabled(), set_read_only(), +# set_min/max_width/height(), set_fixed_size(), +# set_tooltip(), set_size_policy(). +# Свойство: text_changed — сигнал QLineEdit.textChanged. +# +# 4) Состояние (поля): +# _theme: str — текущая тема +# _is_active: bool — признак активного состояния +# _base_style_key: str — базовый ключ стиля ("TEXT_INPUT") +# _style_key_normal: str|None — явный нормальный стиль +# _style_key_active: str|None — явный активный стиль +# _input: QLineEdit — внутренний виджет +# +# 5) Последовательность действий и вызовов: +# __init__(text, placeholder, ...) +# -> super().__init__(...) +# -> QLineEdit(text) -> setPlaceholderText -> setSizePolicy +# -> super().add_widget(_input) +# -> style() -> theme_bus.theme_changed.connect(set_theme) +# text_changed (property) +# -> делегирует к _input.textChanged +# +# 6) Побочные эффекты: +# - Устанавливает stylesheet на QLineEdit. +# - Подключается к theme_bus.theme_changed. +# +# 7) Границы ответственности: +# Модуль НЕ валидирует содержимое ввода. +# НЕ поддерживает маски ввода (для этого — наследник). +# add_widget() заблокирован. +# +# 8) Обработка ошибок: +# add_widget() бросает NotImplementedError. +# set_theme() молча игнорирует невалидные значения. +# +# 9) Инварианты и контракты: +# - Контейнер содержит ровно один QLineEdit. +# - _theme ∈ {"dark", "light"}. +# +# 10) Правило сопровождения: +# Для добавления валидации — использовать QValidator извне через +# _input (расширить API при необходимости). Стили — через APP_STYLES +# с ключом TEXT_INPUT + суффиксы. diff --git a/Dispatch_V0.1.1/gui/components/toggle_button.py b/Dispatch_V0.1.1/gui/components/toggle_button.py new file mode 100644 index 0000000..97b232a --- /dev/null +++ b/Dispatch_V0.1.1/gui/components/toggle_button.py @@ -0,0 +1,261 @@ +# -*- coding: utf-8 -*- +# gui/components/toggle_button.py +"""Компонент кнопки-переключателя на основе SContainer.""" + +from PySide6.QtWidgets import QToolButton, QSizePolicy +from PySide6.QtCore import Slot + +from gui.theme_bus import theme_bus +from gui.containers.s_container import SContainer +from gui.styles import APP_STYLES + + +class ToggleButton(SContainer): + """Кнопка-переключатель на основе SContainer со стилями, зависящими от темы.""" + + def __init__( + self, + text: str, + index: int = 0, + width_percent: int | None = None, + height_percent: int | None = None, + margin: int | tuple[int, int, int, int] = (0, 2, 0, 2), + style: str | None = None, + active_style: str | None = None, + is_active: bool | None = None, + content_fit: bool = True, + parent=None, + ): + super().__init__( + width_percent=width_percent, + height_percent=height_percent, + margin=margin, + style=style, + active_style=active_style, + is_active=is_active, + content_fit=content_fit, + parent=parent, + ) + + self.index = index + self._theme = "dark" + self._is_active = False + self._style_key_normal = None + self._style_key_active = None + + self._button = QToolButton() + self._button.setText(text) + self._button.setProperty("widget_index", index) + self._button.setCheckable(True) + self._button.setSizePolicy( + QSizePolicy.Policy.Expanding, + QSizePolicy.Policy.Expanding, + ) + super().add_widget(self._button) + + self._button.toggled.connect(self._on_toggled) + + if style is not None: + self._style_key_normal = style + self._style_key_active = active_style or style + if is_active is not None: + self._is_active = bool(is_active) + + self.style() + theme_bus.theme_changed.connect(self.set_theme) + + def _on_toggled(self, checked: bool) -> None: + self.style(is_active=checked) + + def style( + self, + style_key: str | None = None, + active_key: str | None = None, + is_active: bool | None = None, + ) -> None: + """Короткий метод применения стиля. Можно задать ключи и активность явно.""" + if style_key is not None: + self._style_key_normal = style_key + self._style_key_active = active_key or style_key + + if is_active is not None: + self._is_active = bool(is_active) + + if self._style_key_normal is not None: + normal_key = self._style_key_normal + active_key = self._style_key_active or self._style_key_normal + + if self._theme == "light": + themed_normal = f"{normal_key}_LIGHT" + themed_active = f"{active_key}_LIGHT" + if themed_normal in APP_STYLES: + normal_key = themed_normal + if themed_active in APP_STYLES: + active_key = themed_active + + key = active_key if self._is_active else normal_key + self._button.setStyleSheet(APP_STYLES.get(key, "")) + return + + if self._theme == "light": + if self._is_active and "STANDARD_BUTTON_LIGHT_THEME_ACTIVE" in APP_STYLES: + self._button.setStyleSheet(APP_STYLES["STANDARD_BUTTON_LIGHT_THEME_ACTIVE"]) + else: + self._button.setStyleSheet(APP_STYLES["STANDARD_BUTTON_LIGHT_THEME"]) + return + + if self._is_active: + self._button.setStyleSheet(APP_STYLES["STANDARD_BUTTON_DARK_THEME_ACTIVE"]) + else: + self._button.setStyleSheet(APP_STYLES["STANDARD_BUTTON_DARK_THEME"]) + + @Slot(str) + def set_theme(self, theme: str) -> None: + """Внешний слот: принимает 'dark' или 'light'.""" + theme = (theme or "").strip().lower() + if theme not in ("dark", "light"): + return + if self._theme == theme: + return + self._theme = theme + self.style() + + @property + def clicked(self): + return self._button.clicked + + @property + def toggled(self): + return self._button.toggled + + def click(self) -> None: + self._button.click() + + def set_text(self, text: str) -> None: + self._button.setText(text) + + def get_text(self) -> str: + return self._button.text() + + def set_tooltip(self, text: str) -> None: + self._button.setToolTip(text) + + def get_tooltip(self) -> str: + return self._button.toolTip() + + def set_checkable(self, checkable: bool) -> None: + self._button.setCheckable(checkable) + + def set_checked(self, checked: bool) -> None: + self._button.setChecked(checked) + + def is_checked(self) -> bool: + return self._button.isChecked() + + def set_enabled(self, enabled: bool) -> None: + self._button.setEnabled(enabled) + super().setEnabled(enabled) + + def set_min_width(self, width: int) -> None: + self._button.setMinimumWidth(width) + super().setMinimumWidth(width) + + def set_min_height(self, height: int) -> None: + self._button.setMinimumHeight(height) + super().setMinimumHeight(height) + + def set_max_width(self, width: int) -> None: + self._button.setMaximumWidth(width) + super().setMaximumWidth(width) + + def set_max_height(self, height: int) -> None: + self._button.setMaximumHeight(height) + super().setMaximumHeight(height) + + def set_fixed_size(self, width: int, height: int) -> None: + self._button.setMinimumSize(width, height) + self._button.setMaximumSize(width, height) + super().setMinimumSize(width, height) + super().setMaximumSize(width, height) + + def set_size_policy(self, horizontal, vertical) -> None: + self._button.setSizePolicy(horizontal, vertical) + super().setSizePolicy(horizontal, vertical) + + def add_widget(self, widget, alignment=None): + raise NotImplementedError("ToggleButton может содержать только одну кнопку") + + +# --------------------------------------------------------------------------- +# Module workflow notes +# --------------------------------------------------------------------------- +# +# 1) Назначение модуля: +# Кнопка-переключатель (toggle) на основе QToolButton в SContainer, +# автоматически переключающая стиль при toggled и поддерживающая +# темизацию через theme_bus. +# +# 2) Зависимости модуля: +# Импорты: QToolButton, QSizePolicy (PySide6.QtWidgets), +# Slot (PySide6.QtCore), +# theme_bus (gui.theme_bus), +# SContainer (gui.containers.s_container), +# APP_STYLES (gui.styles) +# Хост-класс / базовый класс: SContainer +# Внешние библиотеки: PySide6 (обязательна) +# +# 3) Экспорт: +# Класс ToggleButton — публичный виджет-переключатель. +# Методы: style(), set_theme(), click(), set_text(), get_text(), +# set_tooltip(), get_tooltip(), set_checkable(), set_checked(), +# is_checked(), set_enabled(), set_min/max_width/height(), +# set_fixed_size(), set_size_policy(). +# Свойства: clicked, toggled. +# +# 4) Состояние (поля): +# index: int — числовой индекс +# _theme: str — текущая тема +# _is_active: bool — признак активного состояния +# _style_key_normal: str|None — ключ нормального стиля +# _style_key_active: str|None — ключ активного стиля +# _button: QToolButton — внутренний виджет (checkable) +# +# 5) Последовательность действий и вызовов: +# __init__(text, index, ...) +# -> super().__init__(...) +# -> QToolButton() -> setText, setCheckable(True), setSizePolicy +# -> super().add_widget(_button) +# -> _button.toggled.connect(_on_toggled) — автосмена стиля +# -> style() -> theme_bus.theme_changed.connect(set_theme) +# _on_toggled(checked: bool) +# -> style(is_active=checked) — переключение стиля при нажатии +# style(style_key?, active_key?, is_active?) +# -> если _style_key_normal задан: +# -> проверить themed-варианты (_LIGHT) в APP_STYLES +# -> выбрать active или normal ключ +# -> иначе: STANDARD_BUTTON_*_THEME(_ACTIVE) +# -> _button.setStyleSheet(APP_STYLES[key]) +# +# 6) Побочные эффекты: +# - Устанавливает stylesheet на QToolButton. +# - Подключается к theme_bus.theme_changed. +# - При toggle — автоматически меняет стиль. +# +# 7) Границы ответственности: +# Модуль НЕ хранит бизнес-логику переключения. +# НЕ группирует кнопки (для этого — RadioGroup/QButtonGroup). +# add_widget() заблокирован. +# +# 8) Обработка ошибок: +# add_widget() бросает NotImplementedError. +# set_theme() молча игнорирует невалидные значения. +# +# 9) Инварианты и контракты: +# - _button всегда checkable. +# - _is_active синхронизирован с checked-состоянием через _on_toggled. +# - _theme ∈ {"dark", "light"}. +# +# 10) Правило сопровождения: +# Отличие от Button: использует QToolButton (checkable по умолчанию), +# автоматически переключает стиль при toggled. Не путать с TabButton +# (специализирован для TabWidget). diff --git a/Dispatch_V0.1.1/gui/components/topology_tree_widget.py b/Dispatch_V0.1.1/gui/components/topology_tree_widget.py new file mode 100644 index 0000000..db28e34 --- /dev/null +++ b/Dispatch_V0.1.1/gui/components/topology_tree_widget.py @@ -0,0 +1,244 @@ +# -*- coding: utf-8 -*- +# gui/components/topology_tree_widget.py +""" +Виджет дерева топологии склада с ленивой загрузкой. +""" + +from __future__ import annotations + +from typing import Optional, Dict, Any, Callable +from dataclasses import dataclass + +from PySide6.QtCore import Qt, Signal, QTimer +from PySide6.QtWidgets import QTreeWidget, QTreeWidgetItem, QAbstractItemView, QHeaderView, QSizePolicy +from PySide6.QtGui import QFont + +from gui.containers.s_container import SContainer +from gui.styles import APP_STYLES +from gui.theme_bus import theme_bus +from gui.components._tree_node_building import TreeNodeBuilder +from gui.components._tree_state_management import TreeStateManager + + +@dataclass +class TreeNodeData: + """Данные узла дерева.""" + node_type: str # типы узлов: 'site', 'facility', 'zone', 'rack', 'shelf', 'cell', 'volume' + node_id: str # UUID или другой идентификатор + display_data: Dict[str, Any] # Данные для отображения + has_children: bool = False + children_loaded: bool = False + raw_data: Optional[Dict[str, Any]] = None # Полные данные узла + + +class TopologyTreeWidget(SContainer): + """Виджет дерева топологии склада с ленивой загрузкой.""" + + # Сигналы + nodeSelected = Signal(str, str, dict) # тип, ид, данные отображения + nodeDoubleClicked = Signal(str, str, dict) # тип, ид, данные отображения + dataLoadError = Signal(str) # сообщение об ошибке + + # Карта отображения атрибутов для каждого типа узла (только ключи) + DISPLAY_ATTRIBUTES = { + 'site': ['code', 'site_city'], + 'facility': ['facility_type'], + 'zone': ['code', 'name'], + 'rack': ['code', 'name'], + 'shelf': ['code', 'name', 'params', 'size'], + 'cell': ['r', 'c', 'z'], + 'volume': ['code', 'volume_mode', 'max_w', 'max_h', 'max_d'] + } + + def __init__( + self, + data_loader: Callable[[str, Optional[str]], list[TreeNodeData]], + width_percent: int | None = None, + height_percent: int | None = None, + margin: int | tuple[int, int, int, int] = 0, + style: str = "TREE_WIDGET_TOP_FLAT", + parent=None + ): + super().__init__( + width_percent=width_percent, + height_percent=height_percent, + margin=margin, + parent=parent, + style=style, + ) + + self.data_loader = data_loader + self._loading_nodes = set() + self._tree = None + self._tree_style_key = style + + # Сервисы (композиция вместо mixin) + self._node_builder = TreeNodeBuilder(self) + self._state_mgr = TreeStateManager(self) + + self._tree = QTreeWidget() + self._tree.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) + super().add_widget(self._tree) + self._apply_tree_style() + + self._setup_ui() + self._connect_signals() + theme_bus.theme_changed.connect(self._apply_tree_style) + + QTimer.singleShot(0, self._node_builder.load_root_nodes) + + # -- Настройка интерфейса ------------------------------------------------------------- + + def _setup_ui(self) -> None: + """Настройка внешнего вида и поведения виджета.""" + self._tree.setColumnCount(2) + self._tree.setHeaderHidden(True) + + header = self._tree.header() + header.setStretchLastSection(True) + header.setSectionResizeMode(0, QHeaderView.ResizeToContents) + header.setSectionResizeMode(1, QHeaderView.Stretch) + + self._tree.setSelectionMode(QAbstractItemView.SingleSelection) + self._tree.setSelectionBehavior(QAbstractItemView.SelectRows) + self._tree.setAnimated(True) + self._tree.setUniformRowHeights(True) + self._tree.setExpandsOnDoubleClick(False) + self._tree.setItemsExpandable(True) + + font = QFont() + font.setPointSize(10) + self._tree.setFont(font) + + def _apply_tree_style(self, *_args) -> None: + """Явно применяем стиль к QTreeWidget.""" + if not self._tree_style_key: + return + if self._tree is None: + return + self._tree.setStyleSheet(APP_STYLES.get(self._tree_style_key, "")) + + def _connect_signals(self) -> None: + """Подключение внутренних сигналов.""" + self._tree.itemClicked.connect(self._on_item_clicked) + self._tree.itemDoubleClicked.connect(self._on_item_double_clicked) + self._tree.itemExpanded.connect(self._on_item_expanded) + self._tree.itemCollapsed.connect(self._on_item_collapsed) + + # -- Обработчики событий ------------------------------------------------------- + + def _on_item_clicked(self, item: QTreeWidgetItem, column: int) -> None: + """Обработчик одинарного клика на элементе дерева.""" + item_data = item.data(0, Qt.UserRole) + if not item_data: + return + if item_data.get('is_stub') or item_data.get('is_error'): + return + self.nodeSelected.emit(item_data['type'], item_data['id'], item_data['display_data']) + + def _on_item_double_clicked(self, item: QTreeWidgetItem, column: int) -> None: + """Обработчик двойного клика на элементе дерева.""" + item_data = item.data(0, Qt.UserRole) + if not item_data: + return + if item_data.get('is_stub') or item_data.get('is_error'): + return + self.nodeDoubleClicked.emit(item_data['type'], item_data['id'], item_data['display_data']) + + def _on_item_expanded(self, item: QTreeWidgetItem) -> None: + """Обработчик раскрытия элемента дерева.""" + item_data = item.data(0, Qt.UserRole) + if not item_data: + return + if item_data.get('has_children') and not item_data.get('children_loaded'): + self._node_builder.load_children(item) + self.resizeColumnToContents(0) + + def _on_item_collapsed(self, item: QTreeWidgetItem) -> None: + """Обработчик сворачивания элемента дерева.""" + item_data = item.data(0, Qt.UserRole) + if not item_data: + return + + # -- Методы-адаптеры QWidget ---------------------------------------------- + + def __getattr__(self, name: str): + if name == "_tree": + return None + tree = self.__dict__.get("_tree") + if tree is None: + return super().__getattribute__(name) + return getattr(tree, name) + + def viewport(self): + if self._tree is None: + return None + return self._tree.viewport() + + def setMouseTracking(self, enabled: bool) -> None: + super().setMouseTracking(enabled) + if self._tree is not None: + self._tree.setMouseTracking(enabled) + viewport = self._tree.viewport() + if viewport is not None: + viewport.setMouseTracking(enabled) + + def installEventFilter(self, filter_obj) -> None: + super().installEventFilter(filter_obj) + if self._tree is not None: + self._tree.installEventFilter(filter_obj) + viewport = self._tree.viewport() + if viewport is not None: + viewport.installEventFilter(filter_obj) + + 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) + + # -- Делегирование к сервисам (публичный API) ---------------------------- + + def reload_tree(self, preserve_state: bool = True) -> None: + self._state_mgr.reload_tree(preserve_state) + + def clear_tree(self) -> None: + self._state_mgr.clear_tree() + + def refresh_node(self, node_type: str, node_id: str) -> None: + self._state_mgr.refresh_node(node_type, node_id) + + def select_node( + self, + node_type: str, + node_id: str, + *, + emit_selected: bool = False, + allow_load: bool = True, + expand_parents: bool = True, + ) -> bool: + return self._state_mgr.select_node( + node_type, node_id, + emit_selected=emit_selected, + allow_load=allow_load, + expand_parents=expand_parents, + ) diff --git a/Dispatch_V0.1.1/gui/containers/__init__.py b/Dispatch_V0.1.1/gui/containers/__init__.py new file mode 100644 index 0000000..2549e86 --- /dev/null +++ b/Dispatch_V0.1.1/gui/containers/__init__.py @@ -0,0 +1,38 @@ +# -*- coding: utf-8 -*- +# gui/containers/__init__.py + +from .v_container import VContainer +from .h_container import HContainer +from .s_container import SContainer +from .grid_container import GridContainer +from .stack_container import StackContainer +from .scroll_container import ScrollContainer + +__all__ = [ + 'VContainer', + 'HContainer', + 'SContainer', + 'GridContainer', + 'StackContainer', + 'ScrollContainer', +] + + + + +# --------------------------------------------------------------------------- +# Module workflow notes (compact) +# --------------------------------------------------------------------------- +# +# 1) Назначение модуля: +# Пакетный __init__.py для gui/containers. Реэкспортирует все публичные +# контейнерные классы для удобного импорта: +# ``from gui.containers import VContainer, HContainer, ...`` +# +# 2) Зависимости модуля: +# Реимпорт из: v_container, h_container, s_container, grid_container, +# stack_container, scroll_container, stylable_mixin. +# +# 3) Экспорт (__all__): +# VContainer, HContainer, SContainer, GridContainer, +# StackContainer, ScrollContainer, StylableMixin. diff --git a/Dispatch_V0.1.1/gui/containers/__pycache__/__init__.cpython-313.pyc b/Dispatch_V0.1.1/gui/containers/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..c0dfb4a Binary files /dev/null and b/Dispatch_V0.1.1/gui/containers/__pycache__/__init__.cpython-313.pyc differ diff --git a/Dispatch_V0.1.1/gui/containers/__pycache__/_parent_resize_emitter.cpython-313.pyc b/Dispatch_V0.1.1/gui/containers/__pycache__/_parent_resize_emitter.cpython-313.pyc new file mode 100644 index 0000000..58e6958 Binary files /dev/null and b/Dispatch_V0.1.1/gui/containers/__pycache__/_parent_resize_emitter.cpython-313.pyc differ diff --git a/Dispatch_V0.1.1/gui/containers/__pycache__/_widget_style_service.cpython-313.pyc b/Dispatch_V0.1.1/gui/containers/__pycache__/_widget_style_service.cpython-313.pyc new file mode 100644 index 0000000..728623a Binary files /dev/null and b/Dispatch_V0.1.1/gui/containers/__pycache__/_widget_style_service.cpython-313.pyc differ diff --git a/Dispatch_V0.1.1/gui/containers/__pycache__/content_host.cpython-313.pyc b/Dispatch_V0.1.1/gui/containers/__pycache__/content_host.cpython-313.pyc new file mode 100644 index 0000000..c14259a Binary files /dev/null and b/Dispatch_V0.1.1/gui/containers/__pycache__/content_host.cpython-313.pyc differ diff --git a/Dispatch_V0.1.1/gui/containers/__pycache__/grid_container.cpython-313.pyc b/Dispatch_V0.1.1/gui/containers/__pycache__/grid_container.cpython-313.pyc new file mode 100644 index 0000000..cd33adf Binary files /dev/null and b/Dispatch_V0.1.1/gui/containers/__pycache__/grid_container.cpython-313.pyc differ diff --git a/Dispatch_V0.1.1/gui/containers/__pycache__/h_container.cpython-313.pyc b/Dispatch_V0.1.1/gui/containers/__pycache__/h_container.cpython-313.pyc new file mode 100644 index 0000000..ddaaeef Binary files /dev/null and b/Dispatch_V0.1.1/gui/containers/__pycache__/h_container.cpython-313.pyc differ diff --git a/Dispatch_V0.1.1/gui/containers/__pycache__/percent_sized_widget.cpython-313.pyc b/Dispatch_V0.1.1/gui/containers/__pycache__/percent_sized_widget.cpython-313.pyc new file mode 100644 index 0000000..1ee04f8 Binary files /dev/null and b/Dispatch_V0.1.1/gui/containers/__pycache__/percent_sized_widget.cpython-313.pyc differ diff --git a/Dispatch_V0.1.1/gui/containers/__pycache__/s_container.cpython-313.pyc b/Dispatch_V0.1.1/gui/containers/__pycache__/s_container.cpython-313.pyc new file mode 100644 index 0000000..54bd9d8 Binary files /dev/null and b/Dispatch_V0.1.1/gui/containers/__pycache__/s_container.cpython-313.pyc differ diff --git a/Dispatch_V0.1.1/gui/containers/__pycache__/scroll_container.cpython-313.pyc b/Dispatch_V0.1.1/gui/containers/__pycache__/scroll_container.cpython-313.pyc new file mode 100644 index 0000000..04659bb Binary files /dev/null and b/Dispatch_V0.1.1/gui/containers/__pycache__/scroll_container.cpython-313.pyc differ diff --git a/Dispatch_V0.1.1/gui/containers/__pycache__/stack_container.cpython-313.pyc b/Dispatch_V0.1.1/gui/containers/__pycache__/stack_container.cpython-313.pyc new file mode 100644 index 0000000..7d71389 Binary files /dev/null and b/Dispatch_V0.1.1/gui/containers/__pycache__/stack_container.cpython-313.pyc differ diff --git a/Dispatch_V0.1.1/gui/containers/__pycache__/v_container.cpython-313.pyc b/Dispatch_V0.1.1/gui/containers/__pycache__/v_container.cpython-313.pyc new file mode 100644 index 0000000..c33c767 Binary files /dev/null and b/Dispatch_V0.1.1/gui/containers/__pycache__/v_container.cpython-313.pyc differ diff --git a/Dispatch_V0.1.1/gui/containers/_parent_resize_emitter.py b/Dispatch_V0.1.1/gui/containers/_parent_resize_emitter.py new file mode 100644 index 0000000..f0f9d89 --- /dev/null +++ b/Dispatch_V0.1.1/gui/containers/_parent_resize_emitter.py @@ -0,0 +1,96 @@ +# -*- coding: utf-8 -*- +# gui/containers/_parent_resize_emitter.py +"""Эмиттер resize-событий родительского виджета (один экземпляр на parent).""" + +from __future__ import annotations + +from PySide6.QtCore import QObject, QEvent, QSize, QTimer, Signal +from PySide6.QtWidgets import QWidget +from shiboken6 import isValid + + +class ParentResizeEmitter(QObject): + """ + Перехватывает Resize-события родителя через eventFilter. + Один экземпляр на каждого родителя, разделяется всеми потомками. + + Эмитирует две фазы: + 1) parent_resized — сразу после coalesced singleShot(0); + 2) parent_rebuild_finished — отдельным singleShot(0) после фазы 1. + Между фазами Qt успевает обработать layout.activate() подписчиков фазы 1, + поэтому подписчики фазы 2 видят финальную, стабильную геометрию родителя. + """ + parent_resized = Signal() + parent_rebuild_finished = Signal() + + def __init__(self, parent_widget: QWidget): + super().__init__(parent_widget) + self._parent_widget: QWidget | None = parent_widget + self._last_size = parent_widget.size() + self._emit_pending = False + self._finish_pending = False + # destroyed.connect(self._on_parent_destroyed) намеренно не используем: + # порядок разрушения Python-обёрток эмиттера и parent_widget не определён, + # и Qt может вызвать слот на полу-разрушенном объекте, выдавая + # "Slot 'ParentResizeEmitter::' not found". Все queued-обработчики + # (_emit_parent_resized, _emit_rebuild_finished) защищены проверкой + # _is_valid_qobject(_parent_widget), поэтому ссылка на мёртвый + # parent безопасна — событий просто не будет. + parent_widget.installEventFilter(self) + + def eventFilter(self, watched: QObject, event: QEvent) -> bool: + # Защита от полу-разрушенного состояния: Qt может вызвать eventFilter + # на эмиттере, чей Python-объект уже частично очищен (атрибуты удалены), + # но C++ instance ещё жив и обрабатывает события из очереди. + parent_widget = getattr(self, "_parent_widget", None) + if not self._is_valid_qobject(parent_widget): + return False + if watched is not parent_widget: + return False + if not self._is_valid_qobject(watched): + return False + try: + current_size = watched.size() + except RuntimeError: + # Underlying C++ QWidget was deleted; shiboken raises RuntimeError on method calls. + return False + last_size = getattr(self, "_last_size", None) + if current_size != last_size: + self._last_size = current_size + self._schedule_parent_resized() + return False + + def _on_parent_destroyed(self) -> None: + self._parent_widget = None + self._emit_pending = False + self._finish_pending = False + self._last_size = QSize() + + def _schedule_parent_resized(self) -> None: + if self._emit_pending: + return + self._emit_pending = True + QTimer.singleShot(0, self._emit_parent_resized) + + def _emit_parent_resized(self) -> None: + self._emit_pending = False + if not self._is_valid_qobject(self._parent_widget): + return + self.parent_resized.emit() + self._schedule_rebuild_finished() + + def _schedule_rebuild_finished(self) -> None: + if self._finish_pending: + return + self._finish_pending = True + QTimer.singleShot(0, self._emit_rebuild_finished) + + def _emit_rebuild_finished(self) -> None: + self._finish_pending = False + if not self._is_valid_qobject(self._parent_widget): + return + self.parent_rebuild_finished.emit() + + @staticmethod + def _is_valid_qobject(obj: object) -> bool: + return obj is not None and isValid(obj) diff --git a/Dispatch_V0.1.1/gui/containers/_widget_style_service.py b/Dispatch_V0.1.1/gui/containers/_widget_style_service.py new file mode 100644 index 0000000..fb8aeff --- /dev/null +++ b/Dispatch_V0.1.1/gui/containers/_widget_style_service.py @@ -0,0 +1,132 @@ +# -*- coding: utf-8 -*- +# gui/containers/_widget_style_service.py +"""Сервис локальной стилизации host-виджета без протекания style в subtree.""" + +from __future__ import annotations + +import re +from typing import Optional, TYPE_CHECKING + +from PySide6.QtCore import Qt + +from gui.styles import APP_STYLES + +if TYPE_CHECKING: + from PySide6.QtWidgets import QWidget + + +_SELECTOR_BLOCK_RE = re.compile(r"([^{}]+)\{") + + +class WidgetStyleService: + """Хранит explicit/inherited/effective style и применяет QSS только к host.""" + + def __init__(self, host: QWidget) -> None: + self._host = host + self._theme = "dark" if host.palette().window().color().lightness() < 128 else "light" + self._is_active = False + self._explicit_style_key: Optional[str] = None + self._explicit_active_style_key: Optional[str] = None + self._inherited_style_key: Optional[str] = None + self._last_style_role = "" + self._last_stylesheet = "" + self._last_styled_background = False + + @property + def explicit_style_key(self) -> Optional[str]: + return self._explicit_style_key + + @property + def inherited_style_key(self) -> Optional[str]: + return self._inherited_style_key + + @property + def effective_style_key(self) -> Optional[str]: + return self._explicit_style_key or self._inherited_style_key + + def has_explicit_style(self) -> bool: + return self._explicit_style_key is not None + + def apply( + self, + style_key: Optional[str] = None, + active_key: Optional[str] = None, + is_active: Optional[bool] = None, + ) -> bool: + previous_effective = self.effective_style_key + previous_render = self._current_render_key() + if style_key is not None: + self._explicit_style_key = style_key + self._explicit_active_style_key = style_key if active_key is None else active_key + elif active_key is not None: + self._explicit_active_style_key = active_key + if is_active is not None: + self._is_active = bool(is_active) + effective_changed = previous_effective != self.effective_style_key + if effective_changed or previous_render != self._current_render_key(): + self._refresh_host() + return effective_changed + + def set_inherited_style(self, style_key: Optional[str]) -> bool: + previous_effective = self.effective_style_key + previous_render = self._current_render_key() + self._inherited_style_key = style_key + effective_changed = previous_effective != self.effective_style_key + if effective_changed or previous_render != self._current_render_key(): + self._refresh_host() + return effective_changed + + def handle_theme_changed(self, theme: str) -> None: + theme = (theme or "").strip().lower() + if theme in {"dark", "light"} and theme != self._theme: + self._theme = theme + self._refresh_host() + + def _refresh_host(self) -> None: + style_role = self.effective_style_key or "" + render_key = self._current_render_key() + resolved_key = self._resolve_theme_key(render_key) + css = APP_STYLES.get(resolved_key, "") if resolved_key else "" + stylesheet = self._scope_css_to_host(css, style_role) if css and style_role else "" + styled_background = bool(css) + if style_role != self._last_style_role: + self._host.setProperty("style_role", style_role) + self._last_style_role = style_role + if stylesheet != self._last_stylesheet: + self._host.setStyleSheet(stylesheet) + self._last_stylesheet = stylesheet + if styled_background != self._last_styled_background: + self._host.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, styled_background) + self._last_styled_background = styled_background + + def _resolve_theme_key(self, style_key: Optional[str]) -> Optional[str]: + if not style_key: + return None + themed_key = f"{style_key}_{self._theme.upper()}" + if themed_key in APP_STYLES: + return themed_key + return style_key if style_key in APP_STYLES else None + + def _current_render_key(self) -> Optional[str]: + return self._explicit_active_style_key if self._is_active else self.effective_style_key + + def _scope_css_to_host(self, css: str, style_role: str) -> str: + def replace(match: re.Match[str]) -> str: + selectors = [] + for raw_selector in match.group(1).split(","): + selector = raw_selector.strip() + selectors.append(self._scope_selector(selector, style_role)) + return ", ".join(selectors) + " {" + + return _SELECTOR_BLOCK_RE.sub(replace, css) + + @staticmethod + def _scope_selector(selector: str, style_role: str) -> str: + if not selector or any(token in selector for token in (" ", ">", "+", "~")): + return selector + if 'style_role="' in selector: + return selector + pseudo_index = selector.find(":") + base = selector if pseudo_index < 0 else selector[:pseudo_index] + suffix = "" if pseudo_index < 0 else selector[pseudo_index:] + return f'{base}[style_role="{style_role}"]{suffix}' diff --git a/Dispatch_V0.1.1/gui/containers/content_host.py b/Dispatch_V0.1.1/gui/containers/content_host.py new file mode 100644 index 0000000..188327f --- /dev/null +++ b/Dispatch_V0.1.1/gui/containers/content_host.py @@ -0,0 +1,145 @@ +# -*- coding: utf-8 -*- +# gui/containers/content_host.py + +from PySide6.QtCore import Qt +from PySide6.QtWidgets import QVBoxLayout, QHBoxLayout, QWidget, QSizePolicy +from .percent_sized_widget import PercentSizedWidget + + +class ContentHost(PercentSizedWidget): + """Внутренний хост контента с процентными размерами и базовым layout.""" + + def __init__( + self, + width_percent: int | None = None, + height_percent: int | None = None, + orientation: str = "v", + margin: int | tuple[int, int, int, int] = 0, + spacing: int = 0, + parent: QWidget | None = None, + ): + super().__init__(width_percent, height_percent, parent) + + if orientation == "h": + self._layout = QHBoxLayout(self) + else: + self._layout = QVBoxLayout(self) + + self._layout.setSpacing(spacing) + + if isinstance(margin, (list, tuple)) and len(margin) == 4: + self._layout.setContentsMargins(*margin) + else: + self._layout.setContentsMargins(margin, margin, margin, margin) + + # Контент-хост по умолчанию растягивается внутри контейнера + self.set_content_fit(True) + + def set_content_fit(self, expand: bool) -> None: + """Управление растягиванием контента внутри контейнера.""" + if expand: + self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) + else: + self.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Preferred) + + def _auto_add_to_parent(self) -> None: + """Контент-хост добавляется вручную контейнером.""" + return + + def add_widget(self, widget: QWidget, alignment=None) -> None: + """Добавляет виджет в layout контейнера.""" + self._layout.addWidget(widget) + self._notify_children_layout_changed() + + def insert_widget(self, index: int, widget: QWidget) -> None: + """Вставляет виджет в layout контейнера по индексу.""" + self._layout.insertWidget(index, widget) + self._notify_children_layout_changed() + + def remove_widget(self, widget: QWidget) -> None: + """Удаляет виджет из layout контейнера.""" + self._layout.removeWidget(widget) + self._notify_children_layout_changed() + + def add_widget_with_stretch(self, widget: QWidget, stretch: int, alignment=None) -> None: + """Добавляет виджет с stretch в layout контейнера.""" + self._layout.addWidget(widget, stretch) + self._notify_children_layout_changed() + + def add_stretch(self, stretch: int = 1) -> None: + self._layout.addStretch(stretch) + + def get_layout(self): + return self._layout + + def _notify_children_layout_changed(self) -> None: + """Канал «состав детей»: после изменения списка детей host'а просим + каждого percent-sized потомка перепланировать свой пересчёт. + Коалесцирование обеспечивается флагом _update_pending в каждом потомке — + несколько add_widget(...) подряд дают только один singleShot(0) на потомка. + """ + for index in range(self._layout.count()): + item = self._layout.itemAt(index) + if item is None: + continue + child_widget = item.widget() + if isinstance(child_widget, PercentSizedWidget): + child_widget.schedule_percent_update() + + +# --------------------------------------------------------------------------- +# Module workflow notes +# --------------------------------------------------------------------------- +# +# 1) Назначение модуля: +# Внутренний хост контента — промежуточный виджет, который размещается +# внутри контейнеров (SContainer, GridContainer, StackContainer и пр.) +# и содержит реальные дочерние виджеты. Поддерживает процентные размеры, +# вертикальную/горизонтальную ориентацию layout'а, отступы и spacing. +# Является деталью реализации контейнеров, а не частью публичного API. +# +# 2) Зависимости модуля: +# Импорты: Qt, QVBoxLayout, QHBoxLayout, QWidget, QSizePolicy (PySide6) +# Хост/базовый класс: PercentSizedWidget (percent_sized_widget.py) +# Внешние библиотеки: PySide6 +# +# 3) Экспорт: +# Класс ContentHost — внутренний хост контента с layout. +# Методы: set_content_fit(), add_widget(), add_widget_with_stretch(), +# add_stretch(), get_layout() +# +# 4) Состояние (поля): +# _layout : QVBoxLayout | QHBoxLayout — layout для размещения потомков, +# выбирается по параметру orientation ("v"/"h"). +# +# 5) Последовательность действий и вызовов: +# __init__(params) -> super().__init__(width_percent, height_percent) +# -> создание QVBoxLayout/QHBoxLayout +# -> setSpacing() -> setContentsMargins() +# -> set_content_fit(True) — SizePolicy = Expanding по умолчанию +# add_widget(widget) -> _layout.addWidget(widget) +# add_widget_with_stretch(widget, stretch) -> _layout.addWidget(widget, stretch) +# +# 6) Побочные эффекты: +# Устанавливает SizePolicy на self при вызове set_content_fit(). +# _auto_add_to_parent() переопределён как no-op — ContentHost добавляется +# контейнером вручную, а не через механизм авто-добавления PercentSizedWidget. +# +# 7) Границы ответственности: +# НЕ управляет собственными процентами — это делает PercentSizedWidget. +# НЕ стилизуется (не наследует StylableMixin). +# НЕ дублирует логику контейнера; лишь предоставляет layout. +# +# 8) Обработка ошибок: +# Нет явной обработки; некорректные margin/spacing молча приведут +# к ошибке Qt. Если margin — не tuple(4), воспринимается как int. +# +# 9) Инварианты и контракты: +# - orientation ∈ {"v", "h"}, иначе умолчание — вертикальный. +# - margin: int или tuple(4). Иной тип вызовет ошибку setContentsMargins. +# - _auto_add_to_parent всегда возвращает None. +# +# 10) Правило сопровождения: +# Не расширять публичный интерфейс ContentHost — он должен оставаться +# тонкой обёрткой. Новые фичи (стилизация, alignment) добавлять +# в контейнеры, использующие ContentHost, а не сюда. diff --git a/Dispatch_V0.1.1/gui/containers/grid_container.py b/Dispatch_V0.1.1/gui/containers/grid_container.py new file mode 100644 index 0000000..1fdb3f4 --- /dev/null +++ b/Dispatch_V0.1.1/gui/containers/grid_container.py @@ -0,0 +1,333 @@ +# -*- coding: utf-8 -*- +# gui/containers/grid_container.py + +from PySide6.QtCore import Qt +from PySide6.QtWidgets import QGridLayout, QLayout, QWidget, QSizePolicy +from .percent_sized_widget import PercentSizedWidget +from .content_host import ContentHost + + +class GridContainer(PercentSizedWidget): + """Сеточный контейнер с размерами в процентах от родителя.""" + + def __init__( + self, + width_percent: int | float | None = None, + height_percent: int | float | None = None, + margin: int | tuple[int, int, int, int] = 0, + content_margins: int | tuple[int, int, int, int] | None = None, + spacing: int | None = None, + horizontal_spacing: int = 0, + vertical_spacing: int = 0, + content_width_percent: int | None = None, + content_height_percent: int | None = None, + content_width: int | None = None, + content_height: int | None = None, + content_fit: bool = True, + row_percentages: list[int | float] | None = None, + col_percentages: list[int | float] | None = None, + row_stretches: list[int] | None = None, + col_stretches: list[int] | None = None, + parent: QWidget | None = None, + style: str | None = None, + active_style: str | None = None, + is_active: bool | None = None, + ): + super().__init__(width_percent, height_percent, parent) + self._init_stylable() + + self._outer_layout = QGridLayout(self) + self._outer_layout.setSpacing(0) + + applied_margins = content_margins if content_margins is not None else margin + if isinstance(applied_margins, (list, tuple)) and len(applied_margins) == 4: + self._outer_layout.setContentsMargins(*applied_margins) + else: + self._outer_layout.setContentsMargins(applied_margins, applied_margins, applied_margins, applied_margins) + + self._content_host = ContentHost( + width_percent=content_width_percent, + height_percent=content_height_percent, + orientation="v", + margin=0, + spacing=0, + parent=self, + ) + self._content_host.set_content_fit(content_fit) + + if content_width is not None or content_height is not None: + w = content_width if content_width is not None else self._content_host.sizeHint().width() + h = content_height if content_height is not None else self._content_host.sizeHint().height() + self._content_host.set_fixed_size(w, h) + + self._grid_widget = QWidget(self._content_host) + self._layout = QGridLayout(self._grid_widget) + + if spacing is not None: + self._layout.setHorizontalSpacing(spacing) + self._layout.setVerticalSpacing(spacing) + else: + self._layout.setHorizontalSpacing(horizontal_spacing) + self._layout.setVerticalSpacing(vertical_spacing) + + self._content_host.add_widget(self._grid_widget) + + self._outer_layout.addWidget(self._content_host, 0, 0) + self._outer_layout.setRowStretch(0, 1) + self._outer_layout.setColumnStretch(0, 1) + + self._row_percentages = [] + self._col_percentages = [] + + if row_percentages is not None and col_percentages is not None: + self.set_cell_percentages(row_percentages, col_percentages) + + if row_stretches is not None: + self.set_row_stretches(row_stretches) + if col_stretches is not None: + self.set_col_stretches(col_stretches) + + if style is not None or active_style is not None or is_active is not None: + self._apply_style(style_key=style, active_key=active_style, is_active=is_active) + + + def set_cell_percentages(self, row_percentages: list[int | float], col_percentages: list[int | float]): + """Устанавливает процентное соотношение строк и столбцов.""" + if not row_percentages or not col_percentages: + raise ValueError("Не указано процентное соотношение ячеек сетки") + + self._row_percentages = self.normalize_percentages(row_percentages) + self._col_percentages = self.normalize_percentages(col_percentages) + + for row, percent in enumerate(self._row_percentages): + self._layout.setRowStretch(row, int(percent)) + + for col, percent in enumerate(self._col_percentages): + self._layout.setColumnStretch(col, int(percent)) + + def normalize_percentages(self, percentages: list[int | float]) -> list[float]: + """Нормализует список процентов до суммы 100.""" + total = sum(percentages) + if total == 0: + return [100 / len(percentages)] * len(percentages) + elif total != 100: + return [p / total * 100 for p in percentages] + return percentages.copy() + + def add_widget(self, widget: QWidget, row: int, col: int, + row_span: int = 1, col_span: int = 1, + alignment: str | None = None) -> None: + + if row + row_span - 1 >= len(self._row_percentages) or col + col_span - 1 >= len(self._col_percentages): + raise IndexError(f"Ячейка ({row}, {col}) с span ({row_span}, {col_span}) вне диапазона сетки") + + self._layout.addWidget(widget, row, col, row_span, col_span) + if isinstance(widget, PercentSizedWidget): + widget.schedule_percent_update() + + def set_spacing(self, horizontal: int, vertical: int) -> None: + """ + Устанавливает раздельные отступы между ячейками по горизонтали и вертикали. + + Args: + horizontal: Отступ между колонками в пикселях. + vertical: Отступ между строками в пикселях. + """ + self._layout.setHorizontalSpacing(horizontal) + self._layout.setVerticalSpacing(vertical) + + def get_layout(self) -> QLayout: + return self._outer_layout + + def set_margins(self, margin: int | tuple[int, int, int, int]) -> None: + if isinstance(margin, (list, tuple)) and len(margin) == 4: + self._outer_layout.setContentsMargins(*margin) + else: + self._outer_layout.setContentsMargins(margin, margin, margin, margin) + + def set_alignment(self, alignment: str) -> None: + raise NotImplementedError("Qt alignment for containers is disabled; use content springs.") + + def set_column_minimum_width(self, col: int, width: int) -> None: + """ + Устанавливает минимальную ширину колонки. + + Args: + col: Индекс колонки (начиная с 0). + width: Минимальная ширина в пикселях. + """ + if col < 0: + raise ValueError(f"Индекс колонки не может быть отрицательным: {col}") + self._layout.setColumnMinimumWidth(col, width) + + def set_column_min_widths(self, widths: list[int]) -> None: + for col, width in enumerate(widths): + self.set_column_minimum_width(col, width) + + def set_row_stretches(self, stretches: list[int]) -> None: + for row, stretch in enumerate(stretches): + self._layout.setRowStretch(row, int(stretch)) + + def set_col_stretches(self, stretches: list[int]) -> None: + for col, stretch in enumerate(stretches): + self._layout.setColumnStretch(col, int(stretch)) + + def set_row_minimum_height(self, row: int, height: int) -> None: + """ + Устанавливает минимальную высоту строки. + + Args: + row: Индекс строки (начиная с 0). + height: Минимальная высота в пикселях. + """ + if row < 0: + raise ValueError(f"Индекс строки не может быть отрицательным: {row}") + self._layout.setRowMinimumHeight(row, height) + + def set_row_min_heights(self, heights: list[int]) -> None: + for row, height in enumerate(heights): + self.set_row_minimum_height(row, height) + + def get_available_size_for_content(self) -> tuple[int, int]: + """Полезная внутренняя область (без учёта margin).""" + margins = self._outer_layout.contentsMargins() + w = self.width() - margins.left() - margins.right() + h = self.height() - margins.top() - margins.bottom() + return max(0, w), max(0, h) + + +# ============================================================================ +# Примеры использования GridContainer +# ============================================================================ +# +# Базовый пример: форма с двумя колонками (labels и inputs) +# ---------------------------------------------------------------------------- +# grid = GridContainer(width_percent=100, margin=6, parent=parent_widget) +# grid.set_cell_percentages(row_percentages=[1, 1, 1], col_percentages=[40, 60]) +# grid.set_spacing(horizontal=12, vertical=8) +# grid.set_column_minimum_width(0, 120) +# +# # Добавление виджетов (row, col) +# grid.add_widget(label1, 0, 0) +# grid.add_widget(input1, 0, 1) +# grid.add_widget(label2, 1, 0) +# grid.add_widget(input2, 1, 1) +# grid.add_widget(label3, 2, 0) +# grid.add_widget(input3, 2, 1) +# +# ============================================================================ +# Пример: форма со stretch колонок (для пропорционального роста) +# ---------------------------------------------------------------------------- +# grid = GridContainer(width_percent=100, height_percent=50) +# grid.set_cell_percentages(row_percentages=[1, 1], col_percentages=[1, 2]) +# grid.set_spacing(horizontal=12, vertical=2) +# grid.set_column_minimum_width(0, 140) +# +# # Первая колонка растёт в 1x, вторая в 2x +# grid.add_widget(code_label, 0, 0) +# grid.add_widget(code_input, 0, 1) +# grid.add_widget(name_label, 1, 0) +# grid.add_widget(name_input, 1, 1) +# +# ============================================================================ +# Сложная сетка с объединением ячеек (span) +# ---------------------------------------------------------------------------- +# grid = GridContainer(width_percent=100, height_percent=100, spacing=10) +# grid.set_cell_percentages( +# row_percentages=[20, 30, 50], +# col_percentages=[25, 25, 25, 25] +# ) +# +# # Виджет занимает 2 колонки (row_span=1, col_span=2) +# grid.add_widget(header_widget, 0, 0, row_span=1, col_span=4) +# grid.add_widget(sidebar_widget, 1, 0, row_span=2, col_span=1) +# grid.add_widget(content_widget, 1, 1, row_span=2, col_span=3) +# +# ============================================================================ +# Важные замечания +# ---------------------------------------------------------------------------- +# 1. set_cell_percentages ОБЯЗАТЕЛЕН перед add_widget, иначе IndexError +# 2. row_percentages и col_percentages автоматически нормализуются до 100% +# Например: [1, 2, 1] → [25%, 50%, 25%] +# 3. spacing применяется ко всем ячейкам; set_spacing позволяет раздельный +# horizontal/vertical spacing для более тонкой настройки +# 4. Minimum width/height гарантируют, что ячейка не сожмётся ниже лимита +# даже при малом размере родителя +# 5. Для форм с парами label-input рекомендуется: +# - col_percentages=[1, 2] (label уже, input шире) +# - set_column_minimum_width(0, 120-140) для label +# - horizontal_spacing=12, vertical_spacing=2-8 +# ============================================================================ + + + + +# --------------------------------------------------------------------------- +# Module workflow notes +# --------------------------------------------------------------------------- +# +# 1) Назначение модуля: +# Сеточный контейнер с размерами в процентах от родителя. Обеспечивает +# размещение виджетов в строках/столбцах с процентным распределением, +# поддержкой span (объединения ячеек), выравнивания через spring-based +# alignment и стилизации через APP_STYLES/тему. +# +# 2) Зависимости модуля: +# Импорты: Qt, QGridLayout, QLayout, QWidget, QSizePolicy (PySide6) +# Хост/базовый класс: StylableMixin + PercentSizedWidget (MRO) +# Внутренние: ContentHost (content_host.py) +# Внешние библиотеки: PySide6 +# +# 3) Экспорт: +# Класс GridContainer — сеточный контейнер. +# Методы: set_cell_percentages(), normalize_percentages(), add_widget(), +# set_spacing(), get_layout(), set_margins(), set_alignment(), +# set_column_minimum_width(), set_column_min_widths(), +# set_row_stretches(), set_col_stretches(), +# set_row_minimum_height(), set_row_min_heights(), +# get_available_size_for_content() +# +# 4) Состояние (поля): +# _outer_layout : QGridLayout — внешний layout (spring-based alignment). +# _content_host : ContentHost — промежуточный хост для внутренней сетки. +# _grid_widget : QWidget — виджет, содержащий внутренний QGridLayout. +# _layout : QGridLayout — внутренний layout для пользовательских ячеек. +# _row_percentages: list[float] — нормализованные доли строк (до 100%). +# _col_percentages: list[float] — нормализованные доли столбцов (до 100%). +# +# 5) Последовательность действий и вызовов: +# __init__(params) -> super().__init__(w%, h%, parent) +# -> _init_stylable() -> создание _outer_layout (QGridLayout) +# -> создание ContentHost -> создание _grid_widget + _layout (QGridLayout) +# -> _content_host.add_widget(_grid_widget) +# -> set_cell_percentages() если row/col_percentages заданы +# -> set_row_stretches() / set_col_stretches() если заданы +# -> _apply_style() если style/active_style/is_active заданы +# add_widget(widget, row, col, ...) -> проверка bounds -> _layout.addWidget() +# set_cell_percentages(rows, cols) -> normalize -> setRowStretch/setColumnStretch +# +# 6) Побочные эффекты: +# Мутирует stretch-значения QGridLayout при set_cell_percentages/stretches. +# _apply_style() устанавливает stylesheet на self. +# set_alignment() бросает NotImplementedError — запрещён. +# +# 7) Границы ответственности: +# НЕ размещает виджеты автоматически — требует явного add_widget(row, col). +# НЕ поддерживает alignment. +# НЕ управляет auto_add_children (нет флага _auto_add_children). +# +# 8) Обработка ошибок: +# set_cell_percentages: ValueError при пустых row/col_percentages. +# add_widget: IndexError при выходе за пределы сетки. +# set_column_minimum_width / set_row_minimum_height: ValueError при index < 0. +# _parse_alignment_springs: удалён, alignment запрещён. +# +# 9) Инварианты и контракты: +# - set_cell_percentages ОБЯЗАТЕЛЕН до add_widget, иначе IndexError. +# - row/col_percentages нормализуются до суммы 100%. +# - content_margins и margin — не одно и то же: content_margins приоритетнее. +# +# 10) Правило сопровождения: +# Не менять двухуровневую компоновку (outer_layout → content_host → grid). +# Не добавлять alignment в конструктор — запрещено правилами. +# Примеры использования — в блоке комментариев внизу файла. diff --git a/Dispatch_V0.1.1/gui/containers/h_container.py b/Dispatch_V0.1.1/gui/containers/h_container.py new file mode 100644 index 0000000..527b750 --- /dev/null +++ b/Dispatch_V0.1.1/gui/containers/h_container.py @@ -0,0 +1,101 @@ +# -*- coding: utf-8 -*- +# gui/containers/h_container.py +"""Горизонтальный контейнер — тонкая обёртка над SContainer(orientation='h'). + +Удобство: ``HContainer(height_percent=10)`` вместо +``SContainer(height_percent=10, orientation='h')``. + +Ширина всегда Expanding (width_percent=None). +""" + +from PySide6.QtWidgets import QWidget +from .s_container import SContainer + + +class HContainer(SContainer): + """Горизонтальный контейнер с высотой в процентах от родителя.""" + + def __init__( + self, + height_percent: int | float | None = None, + margin: int | tuple[int, int, int, int] = 0, + spacing: int = 0, + content_width_percent: int | None = None, + content_height_percent: int | None = None, + content_width: int | None = None, + content_height: int | None = None, + content_fit: bool = True, + content_driven: bool = False, + parent: QWidget | None = None, + style: str | None = None, + active_style: str | None = None, + is_active: bool | None = None, + ): + super().__init__( + width_percent=None, + height_percent=height_percent, + margin=margin, + spacing=spacing, + orientation="h", + content_width_percent=content_width_percent, + content_height_percent=content_height_percent, + content_width=content_width, + content_height=content_height, + content_fit=content_fit, + content_driven=content_driven, + parent=parent, + style=style, + active_style=active_style, + is_active=is_active, + ) + + + + +# --------------------------------------------------------------------------- +# Module workflow notes +# --------------------------------------------------------------------------- +# +# 1) Назначение модуля: +# Горизонтальный контейнер — тонкая обёртка над SContainer с фиксированной +# ориентацией "h". Удобный синтаксический сахар: HContainer(height_percent=10) +# вместо SContainer(height_percent=10, orientation='h'). Ширина всегда +# Expanding (width_percent=None по умолчанию). +# +# 2) Зависимости модуля: +# Импорты: QWidget (PySide6) +# Хост/базовый класс: SContainer (s_container.py) +# Внешние библиотеки: PySide6 +# +# 3) Экспорт: +# Класс HContainer — горизонтальный контейнер с высотой в процентах. +# +# 4) Состояние (поля): +# Собственных полей нет — всё наследуется от SContainer. +# Параметр alignment в __init__ принимается, но игнорируется +# (deprecated, для обратной совместимости). +# +# 5) Последовательность действий и вызовов: +# __init__(params) -> super().__init__(width_percent=None, orientation="h", ...) +# Все методы делегируются SContainer: add_widget(), add_stretch(), +# set_margins(), set_spacing(), get_layout() и т.д. +# +# 6) Побочные эффекты: +# Те же, что и у SContainer: стилизация через _apply_style(), +# флаг _auto_add_children=True, subscribe на theme_bus. +# +# 7) Границы ответственности: +# НЕ добавляет собственной логики — только фиксирует orientation="h" +# и width_percent=None. Вся логика — в SContainer. +# +# 8) Обработка ошибок: +# Делегируется SContainer. +# +# 9) Инварианты и контракты: +# - width_percent всегда None → горизонтальная ось Expanding. +# - orientation всегда "h". +# +# 10) Правило сопровождения: +# Не добавлять сюда логику. Если нужна новая функциональность — +# добавлять в SContainer, чтобы HContainer и VContainer наследовали +# её автоматически. diff --git a/Dispatch_V0.1.1/gui/containers/percent_sized_widget.py b/Dispatch_V0.1.1/gui/containers/percent_sized_widget.py new file mode 100644 index 0000000..1bb3eec --- /dev/null +++ b/Dispatch_V0.1.1/gui/containers/percent_sized_widget.py @@ -0,0 +1,349 @@ +# -*- coding: utf-8 -*- +# gui/containers/percent_sized_widget.py +"""Базовый виджет с процентными размерами и локальной моделью style-наследования.""" + +from __future__ import annotations + +import weakref +from typing import Optional + +from PySide6.QtCore import QEvent, QObject, QSize, QTimer, Signal, Slot +from PySide6.QtWidgets import QSizePolicy, QWidget +from shiboken6 import isValid + +from error_logger import log_exception +from gui.containers._parent_resize_emitter import ParentResizeEmitter +from gui.containers._widget_style_service import WidgetStyleService +from gui.theme_bus import theme_bus + + +class PercentSizedWidget(QWidget): + """Базовый виджет с размерами в процентах от родителя.""" + + percent_size_changed = Signal(object) + _EMITTER_OBJECT_NAME = "_percent_sized_widget_parent_emitter" + + def __init__( + self, + width_percent: int | float | None = None, + height_percent: int | float | None = None, + parent: QWidget | None = None, + content_driven: bool = False, + ): + super().__init__(parent) + self._width_percent = self._validate_percent(width_percent, "width_percent") + self._height_percent = self._validate_percent(height_percent, "height_percent") + self._content_driven = content_driven + self.index = 0 + self._target_width: Optional[int] = None + self._target_height: Optional[int] = None + self._prev_parent_width = 0 + self._prev_parent_height = 0 + self._update_pending = False + self._in_update = False + self._percent_values_dirty = True + self._parent_emitter: Optional[ParentResizeEmitter] = None + self._parent_emitter_owner: Optional[QWidget] = None + self._style_service: Optional[WidgetStyleService] = None + self._style_parent: Optional[PercentSizedWidget] = None + self._style_children: weakref.WeakSet[PercentSizedWidget] = weakref.WeakSet() + self._setup_size_policy() + self._sync_parent_attachment() + self.schedule_percent_update() + self._auto_add_to_parent() + self._sync_parent_attachment() + + def changeEvent(self, event: QEvent) -> None: + self._sync_parent_attachment() + super().changeEvent(event) + + def showEvent(self, event: QEvent) -> None: + super().showEvent(event) + self._sync_parent_attachment() + self._rebind_style_parent() + self._percent_values_dirty = True + self.schedule_percent_update() + + def _reattach_to_new_parent(self) -> None: + self._sync_parent_attachment() + + def _detach_from_current_parent(self) -> None: + if not self._parent_emitter: + return + try: + self._parent_emitter.parent_resized.disconnect(self._on_parent_resized) + except RuntimeError as e: + log_exception(__name__, "_detach_from_current_parent", e) + try: + self._parent_emitter.parent_rebuild_finished.disconnect(self._on_parent_rebuild_finished) + except RuntimeError as e: + log_exception(__name__, "_detach_from_current_parent.rebuild_finished", e) + self._parent_emitter = None + self._parent_emitter_owner = None + + def _attach_to_parent(self, parent: QWidget) -> None: + emitter = self._find_parent_emitter(parent) + if not emitter: + emitter = ParentResizeEmitter(parent) + emitter.setObjectName(self._EMITTER_OBJECT_NAME) + emitter.parent_resized.connect(self._on_parent_resized) + emitter.parent_rebuild_finished.connect(self._on_parent_rebuild_finished) + self._parent_emitter = emitter + self._parent_emitter_owner = parent + + def _sync_parent_attachment(self) -> None: + if not isValid(self): + return + try: + parent = self.parentWidget() + except RuntimeError as e: + log_exception(__name__, "_sync_parent_attachment", e) + return + if self._parent_emitter_owner is not None and not isValid(self._parent_emitter_owner): + self._parent_emitter_owner = None + current_parent = parent if isinstance(parent, QWidget) else None + if current_parent is self._parent_emitter_owner: + return + self._detach_from_current_parent() + if current_parent is None: + return + self._attach_to_parent(current_parent) + self._rebind_style_parent() + self._percent_values_dirty = True + self.schedule_percent_update() + + def _find_parent_emitter(self, parent: QWidget) -> ParentResizeEmitter | None: + for child in parent.children(): + if not isinstance(child, QObject): + continue + if not isinstance(child, ParentResizeEmitter): + continue + if child.objectName() == self._EMITTER_OBJECT_NAME: + return child + return None + + def on_children_rebuild_finished(self, slot) -> None: + """Подписать ``slot`` на фазу 2 каскада percent-sized для детей этого виджета. + + Сигнал ``parent_rebuild_finished`` per-parent эмиттера срабатывает после + того, как дети-PercentSizedWidget ``self`` обработали ``parent_resized`` + (фаза 1) и Qt активировал layout. Это единственная точка, в которой + виджет-родитель видит стабильную геометрию своих процентных детей. + + Эмиттер создаётся eagerly, чтобы подписка работала и в момент, когда + у ``self`` ещё нет процентных детей (инициализационный сценарий). + """ + emitter = self._find_parent_emitter(self) + if emitter is None: + emitter = ParentResizeEmitter(self) + emitter.setObjectName(self._EMITTER_OBJECT_NAME) + emitter.parent_rebuild_finished.connect(slot) + + def _auto_add_to_parent(self) -> None: + try: + parent = self.parentWidget() + except RuntimeError as e: + log_exception(__name__, "_auto_add_to_parent", e) + return + if not isinstance(parent, QWidget) or not getattr(parent, "_auto_add_children", False): + return + if hasattr(parent, "add_widget"): + try: + parent.add_widget(self) + return + except Exception as e: + log_exception(__name__, "_auto_add_to_parent.add_widget", e) + layout = parent.layout() + if layout is not None and layout.indexOf(self) == -1: + layout.addWidget(self) + + @staticmethod + def _validate_percent(percent: int | float | None, param_name: str) -> float | None: + if percent is None: + return None + if not isinstance(percent, (int, float)): + raise TypeError(f"{param_name} должен быть числом (int/float) или None") + value = round(float(percent), 2) + if not 0.01 <= value <= 100.0: + raise ValueError(f"{param_name} должен быть в диапазоне от 0.01 до 100.00") + return value + + def _setup_size_policy(self) -> None: + fallback = QSizePolicy.Policy.Preferred if self._content_driven else QSizePolicy.Policy.Expanding + horizontal = fallback if self._width_percent is None else QSizePolicy.Policy.Fixed + vertical = fallback if self._height_percent is None else QSizePolicy.Policy.Fixed + self.setSizePolicy(horizontal, vertical) + + def sizeHint(self) -> QSize: + base = super().sizeHint() + width = self._target_width if self._target_width is not None else base.width() + height = self._target_height if self._target_height is not None else base.height() + return QSize(max(0, width), max(0, height)) + + def minimumSizeHint(self) -> QSize: + return QSize(0, 0) + + def set_percent_sizes( + self, + width_percent: int | float | None = None, + height_percent: int | float | None = None, + ) -> None: + self._width_percent = self._validate_percent(width_percent, "width_percent") + self._height_percent = self._validate_percent(height_percent, "height_percent") + self._percent_values_dirty = True + self._setup_size_policy() + self._update_percent_size() + + def set_enabled(self, enabled: bool) -> None: self.setEnabled(enabled) + def is_enabled(self) -> bool: return self.isEnabled() + def set_visible(self, visible: bool) -> None: self.setVisible(visible) + 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_tooltip(self, text: str) -> None: self.setToolTip(text) + def set_size_policy(self, horizontal, vertical) -> None: self.setSizePolicy(horizontal, vertical) + def get_target_sizes(self) -> tuple[int | None, int | None]: return self._target_width, self._target_height + def get_percent_sizes(self) -> tuple[float | None, float | None]: return self._width_percent, self._height_percent + def has_percent_sizing(self) -> bool: return self._width_percent is not None or self._height_percent is not None + + def set_width_pixels(self, width_pixels: int) -> None: + try: + parent = self.parentWidget() + except RuntimeError as e: + log_exception(__name__, "set_width_pixels", e) + return + if not isinstance(parent, QWidget): + return + parent_width = max(1, parent.contentsRect().width()) + self.set_percent_sizes(width_percent=round(max(0.01, min(100.0, width_pixels * 100.0 / parent_width)), 2)) + + def set_fixed_size(self, width: int, height: int) -> None: + self.setMinimumSize(width, height) + self.setMaximumSize(width, height) + + @property + def width_percent(self) -> float | None: return self._width_percent + + @width_percent.setter + def width_percent(self, value: int | float | None) -> None: self.set_percent_sizes(width_percent=value) + + @property + def height_percent(self) -> float | None: return self._height_percent + + @height_percent.setter + def height_percent(self, value: int | float | None) -> None: self.set_percent_sizes(height_percent=value) + + def _init_stylable(self) -> None: + self._style_service = WidgetStyleService(self) + theme_bus.theme_changed.connect(self.set_theme) + self._rebind_style_parent() + + def _apply_style(self, style_key=None, active_key=None, is_active=None) -> None: + if self._style_service and self._style_service.apply(style_key=style_key, active_key=active_key, is_active=is_active): + self._sync_descendant_inherited_styles() + + def style(self, style_key=None, active_key=None, is_active=None) -> None: + self._apply_style(style_key=style_key, active_key=active_key, is_active=is_active) + + @Slot(str) + def set_theme(self, theme: str) -> None: + if self._style_service: + self._style_service.handle_theme_changed(theme) + + def _sync_inherited_style(self) -> bool: + if not self._style_service: + return False + inherited_style = None + if self._style_parent and self._style_parent._style_service: + inherited_style = self._style_parent._style_service.effective_style_key + return self._style_service.set_inherited_style(inherited_style) + + def _find_style_parent(self) -> PercentSizedWidget | None: + parent = self.parentWidget() + while isinstance(parent, QWidget): + if isinstance(parent, PercentSizedWidget) and parent._style_service: + return parent + parent = parent.parentWidget() + return None + + def _sync_descendant_inherited_styles(self) -> None: + if not self._style_service: + return + inherited_style = self._style_service.effective_style_key + for child in tuple(self._style_children): + child._inherit_style_from_parent(inherited_style) + + def _inherit_style_from_parent(self, inherited_style: str | None) -> bool: + if not self._style_service: + return False + if self._style_service.set_inherited_style(inherited_style): + self._sync_descendant_inherited_styles() + return True + return False + + def _rebind_style_parent(self) -> None: + if not self._style_service: + return + new_style_parent = self._find_style_parent() + if new_style_parent is not self._style_parent: + if self._style_parent is not None: + self._style_parent._style_children.discard(self) + self._style_parent = new_style_parent + if self._style_parent is not None: + self._style_parent._style_children.add(self) + if self._sync_inherited_style(): + self._sync_descendant_inherited_styles() + + def _on_parent_resized(self) -> None: + self._update_percent_size() + + def _on_parent_rebuild_finished(self) -> None: + # Фаза 2: родитель завершил собственное перестроение и геометрия стабильна. + # Форсируем пересчёт даже если parent_width/parent_height численно совпали + # с промежуточным значением, зафиксированным в фазе 1. + self._percent_values_dirty = True + self._update_percent_size() + + def schedule_percent_update(self) -> None: + if self._update_pending: + return + self._update_pending = True + QTimer.singleShot(0, self._update_percent_size) + + def _trigger_children_update(self) -> None: + for child in self.children(): + if isinstance(child, PercentSizedWidget) and child.parentWidget() is self: + child._update_percent_size() + + def _update_percent_size(self) -> None: + if self._in_update: + return + self._in_update = True + self._update_pending = False + try: + self._sync_parent_attachment() + try: + parent = self.parentWidget() + except RuntimeError as e: + log_exception(__name__, "_update_percent_size", e) + return + if not isinstance(parent, QWidget): + return + rect = parent.contentsRect() + parent_width, parent_height = max(0, rect.width()), max(0, rect.height()) + changed = parent_width != self._prev_parent_width or parent_height != self._prev_parent_height + self._prev_parent_width = parent_width + self._prev_parent_height = parent_height + if not changed and not self._percent_values_dirty: + return + self._percent_values_dirty = False + self._target_width = round(parent_width * self._width_percent / 100.0) if self._width_percent is not None else None + self._target_height = round(parent_height * self._height_percent / 100.0) if self._height_percent is not None else None + self.updateGeometry() + layout = parent.layout() + if layout: + layout.activate() + self._trigger_children_update() + finally: + self._in_update = False diff --git a/Dispatch_V0.1.1/gui/containers/s_container.py b/Dispatch_V0.1.1/gui/containers/s_container.py new file mode 100644 index 0000000..01a79b1 --- /dev/null +++ b/Dispatch_V0.1.1/gui/containers/s_container.py @@ -0,0 +1,197 @@ +# -*- coding: utf-8 -*- +# gui/containers/s_container.py +"""Универсальный контейнер с процентным масштабированием по обеим осям. + +Orientation ("v" | "h") определяет направление стэкирования потомков. +По умолчанию "v" — вертикальный стэк (самый частый паттерн). + +При width_percent=None ось X — Expanding. +При height_percent=None ось Y — Expanding. +Таким образом: + SContainer() → растягивается по обеим осям + SContainer(width_percent=30) → ≡ VContainer (фикс. ширина, свободная высота) + SContainer(height_percent=20) → ≡ HContainer (фикс. высота, свободная ширина) + SContainer(width_percent=30, height_percent=50) → обе оси фиксированы +""" + +from PySide6.QtWidgets import QVBoxLayout, QHBoxLayout, QLayout, QWidget +from .percent_sized_widget import PercentSizedWidget +from .content_host import ContentHost + + +class SContainer(PercentSizedWidget): + """Универсальный контейнер с процентным масштабированием.""" + + def __init__( + self, + width_percent: int | float | None = None, + height_percent: int | float | None = None, + margin: int | tuple[int, int, int, int] = 0, + spacing: int = 0, + orientation: str = "v", + content_width_percent: int | None = None, + content_height_percent: int | None = None, + content_width: int | None = None, + content_height: int | None = None, + content_fit: bool = True, + content_driven: bool = False, + parent: QWidget | None = None, + style: str | None = None, + active_style: str | None = None, + is_active: bool | None = None, + ): + super().__init__(width_percent, height_percent, parent, content_driven=content_driven) + self._init_stylable() + self._auto_add_children = True + + # Внешний layout — единственный, без промежуточного QGridLayout. + if orientation == "h": + self._layout: QLayout = QHBoxLayout(self) + else: + self._layout: QLayout = QVBoxLayout(self) + self._layout.setSpacing(0) + + if isinstance(margin, (list, tuple)) and len(margin) == 4: + self._layout.setContentsMargins(*margin) + else: + self._layout.setContentsMargins(margin, margin, margin, margin) + + self._content_host = ContentHost( + width_percent=content_width_percent, + height_percent=content_height_percent, + orientation=orientation, + margin=0, + spacing=spacing, + parent=self, + ) + self._content_host.set_content_fit(content_fit) + + if content_width is not None or content_height is not None: + w = content_width if content_width is not None else self._content_host.sizeHint().width() + h = content_height if content_height is not None else self._content_host.sizeHint().height() + self._content_host.set_fixed_size(w, h) + + self._layout.addWidget(self._content_host) + + if style is not None or active_style is not None or is_active is not None: + self._apply_style(style_key=style, active_key=active_style, is_active=is_active) + + # ── публичный API ── + + def add_widget(self, widget: QWidget, alignment=None) -> None: + """Добавляет виджет в layout контейнера.""" + self._content_host.add_widget(widget) + + def insert_widget(self, index: int, widget: QWidget) -> None: + """Вставляет виджет в layout контента по индексу.""" + self._content_host.insert_widget(index, widget) + + def remove_widget(self, widget: QWidget) -> None: + """Удаляет виджет из layout контента.""" + self._content_host.remove_widget(widget) + + def add_widget_with_stretch(self, widget: QWidget, stretch: int, alignment=None) -> None: + self._content_host.add_widget_with_stretch(widget, stretch) + + def add_stretch(self, stretch: int = 1) -> None: + self._content_host.add_stretch(stretch) + + def invalidate_layout(self) -> None: + self._content_host.get_layout().invalidate() + + def get_layout(self) -> QLayout: + return self._layout + + def set_margins(self, margin: int | tuple[int, int, int, int]) -> None: + if isinstance(margin, (list, tuple)) and len(margin) == 4: + self._layout.setContentsMargins(*margin) + else: + self._layout.setContentsMargins(margin, margin, margin, margin) + + def set_spacing(self, spacing: int) -> None: + self._content_host.get_layout().setSpacing(spacing) + + def set_alignment(self, alignment: str) -> None: + raise NotImplementedError("Qt alignment for containers is disabled; use content springs.") + + def set_widget_alignment(self, widget: QWidget, alignment: str) -> None: + raise NotImplementedError("Qt alignment for containers is disabled; use content springs.") + + def get_available_size_for_content(self) -> tuple[int, int]: + """Полезная внутренняя область (без учёта margin).""" + margins = self._layout.contentsMargins() + w = self.width() - margins.left() - margins.right() + h = self.height() - margins.top() - margins.bottom() + return max(0, w), max(0, h) + + + + +# --------------------------------------------------------------------------- +# Module workflow notes +# --------------------------------------------------------------------------- +# +# 1) Назначение модуля: +# Универсальный контейнер с процентным масштабированием по обеим осям. +# Ориентация ("v"|"h") определяет направление стэкирования потомков. +# Является базовым классом для VContainer и HContainer. +# Эквивалентности: SContainer(w%=30) ≡ VContainer(w%=30), +# SContainer(h%=20) ≡ HContainer(h%=20). +# +# 2) Зависимости модуля: +# Импорты: QVBoxLayout, QHBoxLayout, QLayout, QWidget (PySide6) +# Хост/базовый класс: StylableMixin + PercentSizedWidget (MRO) +# Внутренние: ContentHost (content_host.py), StylableMixin (stylable_mixin.py) +# Внешние библиотеки: PySide6 +# +# 3) Экспорт: +# Класс SContainer — универсальный контейнер. +# Методы: add_widget(), add_widget_with_stretch(), add_stretch(), +# invalidate_layout(), get_layout(), set_margins(), +# set_spacing(), set_alignment(), set_widget_alignment(), +# get_available_size_for_content() +# +# 4) Состояние (поля): +# _layout : QVBoxLayout|QHBoxLayout — внешний layout. +# _content_host : ContentHost — промежуточный хост для потомков. +# _auto_add_children : bool = True — дочерние виджеты авто-добавляются. +# +# 5) Последовательность действий и вызовов: +# __init__(params) -> super().__init__(w%, h%, parent) +# -> _init_stylable() -> создание QVBoxLayout/QHBoxLayout по orientation +# -> setSpacing(0) на _layout -> setContentsMargins(margin) +# -> создание ContentHost(orientation, spacing) +# -> _content_host.set_content_fit(content_fit) +# -> set_fixed_size(content_width, content_height) если заданы +# -> _layout.addWidget(_content_host) +# -> _apply_style() если style задан +# add_widget(w) -> _content_host.add_widget(w) +# set_spacing(s) -> _content_host.get_layout().setSpacing(s) +# +# 6) Побочные эффекты: +# _auto_add_children = True — дочерние PercentSizedWidget авто-добавляются. +# set_alignment() и set_widget_alignment() бросают NotImplementedError. +# _apply_style() устанавливает stylesheet. +# Подписка на theme_bus через StylableMixin. +# +# 7) Границы ответственности: +# НЕ поддерживает spring-based alignment (в отличие от GridContainer). +# НЕ содержит сетку — только линейный layout. +# НЕ управляет scroll — это ScrollContainer. +# +# 8) Обработка ошибок: +# set_alignment() → NotImplementedError. +# set_widget_alignment() → NotImplementedError. +# Для обоих: «Qt alignment for containers is disabled; use content springs.» +# +# 9) Инварианты и контракты: +# - orientation ∈ {"v", "h"}, иначе умолчание — вертикальный. +# - При w%=None → ось X = Expanding; при h%=None → ось Y = Expanding. +# - spacing внешнего _layout всегда 0 (spacing применяется к ContentHost). +# - alignment параметр в __init__ — deprecated, игнорируется. +# +# 10) Правило сопровождения: +# Новая логика должна быть совместима с VContainer/HContainer — +# они наследуют SContainer. Не вводить логику, специфичную только +# для одной ориентации. Spacing: внешний layout = 0, внутренний +# (ContentHost) = spacing параметр. diff --git a/Dispatch_V0.1.1/gui/containers/scroll_container.py b/Dispatch_V0.1.1/gui/containers/scroll_container.py new file mode 100644 index 0000000..4bbc7d0 --- /dev/null +++ b/Dispatch_V0.1.1/gui/containers/scroll_container.py @@ -0,0 +1,191 @@ +# -*- coding: utf-8 -*- +# gui/containers/scroll_container.py +"""Прокручиваемый контейнер с процентным sizing и контейнерным API проекта.""" + +from __future__ import annotations + +from PySide6.QtCore import Qt +from PySide6.QtWidgets import QFrame, QLayout, QScrollArea, QVBoxLayout, QWidget + +from .content_host import ContentHost +from .percent_sized_widget import PercentSizedWidget + + +class ScrollContainer(PercentSizedWidget): + """Контейнер-обёртка над QScrollArea с поддержкой percent sizing.""" + + def __init__( + self, + width_percent: int | float | None = None, + height_percent: int | float | None = None, + margin: int | tuple[int, int, int, int] = 0, + content_margins: int | tuple[int, int, int, int] = 0, + spacing: int = 0, + orientation: str = "v", + widget_resizable: bool = True, + vertical_scroll_bar_policy: str | Qt.ScrollBarPolicy = "as_needed", + horizontal_scroll_bar_policy: str | Qt.ScrollBarPolicy = "always_off", + content_fit: bool = False, + parent: QWidget | None = None, + style: str | None = None, + active_style: str | None = None, + is_active: bool | None = None, + ): + super().__init__(width_percent, height_percent, parent) + self._init_stylable() + self._auto_add_children = True + + self._scroll_area = QScrollArea(self) + self._scroll_area.setWidgetResizable(bool(widget_resizable)) + self._scroll_area.setFrameShape(QFrame.Shape.NoFrame) + + self._content_host = ContentHost( + orientation=orientation, + margin=content_margins, + spacing=spacing, + parent=self, + ) + self._content_host.set_content_fit(bool(content_fit)) + self._content_host.get_layout().setSizeConstraint(QLayout.SizeConstraint.SetMinAndMaxSize) + self._scroll_area.setWidget(self._content_host) + + self._layout: QVBoxLayout = QVBoxLayout(self) + self._layout.setSpacing(0) + self.set_margins(margin) + self._layout.addWidget(self._scroll_area) + + self.set_vertical_scroll_bar_policy(vertical_scroll_bar_policy) + self.set_horizontal_scroll_bar_policy(horizontal_scroll_bar_policy) + + if style is not None or active_style is not None or is_active is not None: + self._apply_style(style_key=style, active_key=active_style, is_active=is_active) + + @staticmethod + def _normalize_scroll_policy(policy: str | Qt.ScrollBarPolicy) -> Qt.ScrollBarPolicy: + if isinstance(policy, Qt.ScrollBarPolicy): + return policy + token = str(policy or "").strip().lower() + mapping = { + "as_needed": Qt.ScrollBarPolicy.ScrollBarAsNeeded, + "always_off": Qt.ScrollBarPolicy.ScrollBarAlwaysOff, + "always_on": Qt.ScrollBarPolicy.ScrollBarAlwaysOn, + } + if token not in mapping: + raise ValueError( + "Unknown scroll policy. Allowed: 'as_needed', 'always_off', 'always_on'." + ) + return mapping[token] + + def add_widget(self, widget: QWidget, alignment=None) -> None: + self._content_host.add_widget(widget) + + def add_widget_with_stretch(self, widget: QWidget, stretch: int, alignment=None) -> None: + self._content_host.add_widget_with_stretch(widget, stretch) + + def add_stretch(self, stretch: int = 1) -> None: + self._content_host.add_stretch(stretch) + + def invalidate_layout(self) -> None: + self._content_host.get_layout().invalidate() + + def get_layout(self) -> QLayout: + return self._layout + + def set_margins(self, margin: int | tuple[int, int, int, int]) -> None: + if isinstance(margin, (list, tuple)) and len(margin) == 4: + self._layout.setContentsMargins(*margin) + else: + self._layout.setContentsMargins(margin, margin, margin, margin) + + def set_content_margins(self, margin: int | tuple[int, int, int, int]) -> None: + layout = self._content_host.get_layout() + if isinstance(margin, (list, tuple)) and len(margin) == 4: + layout.setContentsMargins(*margin) + else: + layout.setContentsMargins(margin, margin, margin, margin) + + def set_spacing(self, spacing: int) -> None: + self._content_host.get_layout().setSpacing(spacing) + + def set_widget_resizable(self, enabled: bool) -> None: + self._scroll_area.setWidgetResizable(bool(enabled)) + + def set_vertical_scroll_bar_policy(self, policy: str | Qt.ScrollBarPolicy) -> None: + self._scroll_area.setVerticalScrollBarPolicy(self._normalize_scroll_policy(policy)) + + def set_horizontal_scroll_bar_policy(self, policy: str | Qt.ScrollBarPolicy) -> None: + self._scroll_area.setHorizontalScrollBarPolicy(self._normalize_scroll_policy(policy)) + + @property + def scroll_area(self) -> QScrollArea: + return self._scroll_area + + +# --------------------------------------------------------------------------- +# Module workflow notes +# --------------------------------------------------------------------------- +# +# 1) Назначение модуля: +# Прокручиваемый контейнер — обёртка над QScrollArea с поддержкой +# процентного sizing и контейнерного API проекта (add_widget, стилизация, +# тема). Используется для длинных списков, форм и панелей, которые +# не помещаются в видимую область. +# +# 2) Зависимости модуля: +# Импорты: Qt, QFrame, QLayout, QScrollArea, QVBoxLayout, QWidget (PySide6) +# Хост/базовый класс: StylableMixin + PercentSizedWidget (MRO) +# Внутренние: ContentHost (content_host.py), StylableMixin (stylable_mixin.py) +# Внешние библиотеки: PySide6 +# +# 3) Экспорт: +# Класс ScrollContainer — прокручиваемый контейнер. +# Методы: add_widget(), add_widget_with_stretch(), add_stretch(), +# invalidate_layout(), get_layout(), set_margins(), +# set_content_margins(), set_spacing(), set_widget_resizable(), +# set_vertical_scroll_bar_policy(), set_horizontal_scroll_bar_policy() +# Свойство: scroll_area (доступ к QScrollArea). +# +# 4) Состояние (поля): +# _scroll_area : QScrollArea — Qt scroll area (NoFrame). +# _content_host : ContentHost — внутренний хост с layout для потомков. +# _layout : QVBoxLayout — внешний layout самого контейнера. +# _auto_add_children: bool = True — потомки авто-добавляются. +# +# 5) Последовательность действий и вызовов: +# __init__(params) -> super().__init__(w%, h%, parent) +# -> _init_stylable() -> создание QScrollArea (NoFrame) +# -> создание ContentHost(orientation, content_margins, spacing) +# -> _content_host.set_content_fit(content_fit) +# -> _content_host.get_layout().setSizeConstraint(SetMinAndMaxSize) +# -> _scroll_area.setWidget(_content_host) +# -> создание _layout (QVBoxLayout) -> _layout.addWidget(_scroll_area) +# -> set_vertical/horizontal_scroll_bar_policy() +# -> _apply_style() если style задан +# add_widget(w) -> _content_host.add_widget(w) +# +# 6) Побочные эффекты: +# ContentHost помещается внутрь QScrollArea как scrollable widget. +# SizeConstraint = SetMinAndMaxSize — контролирует поведение scroll. +# _auto_add_children = True — дочерние PercentSizedWidget авто-добавляются. +# _apply_style() устанавливает stylesheet на self. +# +# 7) Границы ответственности: +# НЕ управляет содержимым скролла — это делает ContentHost. +# НЕ реализует собственный scroll — делегирует QScrollArea. +# НЕ применяет alignment/springs. +# +# 8) Обработка ошибок: +# _normalize_scroll_policy: ValueError при невалидной строке политики. +# Допустимые значения: "as_needed", "always_off", "always_on". +# +# 9) Инварианты и контракты: +# - scroll_bar_policy ∈ {"as_needed", "always_off", "always_on"} или +# Qt.ScrollBarPolicy enum. +# - По умолчанию vertical = as_needed, horizontal = always_off. +# - content_fit по умолчанию False (в отличие от других контейнеров). +# - widget_resizable по умолчанию True. +# +# 10) Правило сопровождения: +# При изменении scroll-политик проверять комбинацию с content_fit +# и widget_resizable — они взаимозависимы. SizeConstraint +# (SetMinAndMaxSize) критичен для правильного поведения scroll. diff --git a/Dispatch_V0.1.1/gui/containers/stack_container.py b/Dispatch_V0.1.1/gui/containers/stack_container.py new file mode 100644 index 0000000..1a1a599 --- /dev/null +++ b/Dispatch_V0.1.1/gui/containers/stack_container.py @@ -0,0 +1,187 @@ +# -*- coding: utf-8 -*- +# gui/containers/stack_container.py + +from PySide6.QtCore import Qt +from PySide6.QtWidgets import QStackedWidget, QGridLayout, QLayout, QWidget +from .percent_sized_widget import PercentSizedWidget +from .content_host import ContentHost + + +class StackContainer(PercentSizedWidget): + """Контейнер-обёртка над QStackedWidget с размерами в процентах от родителя.""" + + def __init__( + self, + width_percent: int | float | None = None, + height_percent: int | float | None = None, + margin: int | tuple[int, int, int, int] = 0, + content_margins: int | tuple[int, int, int, int] | None = None, + spacing: int = 0, + content_width_percent: int | None = None, + content_height_percent: int | None = None, + content_width: int | None = None, + content_height: int | None = None, + content_fit: bool = True, + parent: QWidget | None = None, + style: str | None = None, + active_style: str | None = None, + is_active: bool | None = None, + ): + super().__init__(width_percent, height_percent, parent) + self._init_stylable() + self._auto_add_children = True + + self._stack = QStackedWidget(self) + self._content_host = ContentHost( + width_percent=content_width_percent, + height_percent=content_height_percent, + orientation="v", + margin=0, + spacing=spacing, + parent=self, + ) + self._content_host.set_content_fit(content_fit) + self._content_host.add_widget(self._stack) + self._outer_layout = QGridLayout(self) + self._outer_layout.setSpacing(0) + + applied_margins = content_margins if content_margins is not None else margin + if isinstance(applied_margins, (list, tuple)) and len(applied_margins) == 4: + self._outer_layout.setContentsMargins(*applied_margins) + else: + self._outer_layout.setContentsMargins(applied_margins, applied_margins, applied_margins, applied_margins) + + if content_width is not None or content_height is not None: + w = content_width if content_width is not None else self._content_host.sizeHint().width() + h = content_height if content_height is not None else self._content_host.sizeHint().height() + self._content_host.set_fixed_size(w, h) + + self._outer_layout.addWidget(self._content_host, 0, 0) + self._outer_layout.setRowStretch(0, 1) + self._outer_layout.setColumnStretch(0, 1) + + if spacing: + self._outer_layout.setSpacing(spacing) + + if style is not None or active_style is not None or is_active is not None: + self._apply_style(style_key=style, active_key=active_style, is_active=is_active) + + # Методы работы со страницами (интерфейс похож на QStackedWidget) + def add_widget(self, widget: QWidget) -> int: + index = self._stack.addWidget(widget) + if isinstance(widget, PercentSizedWidget): + widget.schedule_percent_update() + return index + + def set_current_index(self, index: int) -> None: + self._stack.setCurrentIndex(index) + + def set_current_widget(self, widget: QWidget) -> None: + self._stack.setCurrentWidget(widget) + + def current_index(self) -> int: + return self._stack.currentIndex() + + def current_widget(self) -> QWidget | None: + return self._stack.currentWidget() + + def count(self) -> int: + return self._stack.count() + + def widget(self, index: int) -> QWidget | None: + return self._stack.widget(index) + + def remove_widget(self, widget: QWidget) -> None: + self._stack.removeWidget(widget) + + def get_available_size_for_content(self) -> tuple[int, int]: + """Полезная внутренняя область (без учёта margin).""" + margins = self._outer_layout.contentsMargins() + w = self.width() - margins.left() - margins.right() + h = self.height() - margins.top() - margins.bottom() + return max(0, w), max(0, h) + + def get_layout(self) -> QLayout: + return self._outer_layout + + def set_margins(self, margin: int | tuple[int, int, int, int]) -> None: + if isinstance(margin, (list, tuple)) and len(margin) == 4: + self._outer_layout.setContentsMargins(*margin) + else: + self._outer_layout.setContentsMargins(margin, margin, margin, margin) + + def set_spacing(self, spacing: int) -> None: + self._outer_layout.setSpacing(spacing) + + def set_alignment(self, alignment: str) -> None: + raise NotImplementedError("Qt alignment for containers is disabled; use content springs.") + + @property + def stacked_widget(self) -> QStackedWidget: + return self._stack + + + + +# --------------------------------------------------------------------------- +# Module workflow notes +# --------------------------------------------------------------------------- +# +# 1) Назначение модуля: +# Контейнер-обёртка над QStackedWidget с процентным sizing. +# Реализует переключение «страниц» (одна видна +# в каждый момент). Используется для панели плагинов, табов и т.п. +# +# 2) Зависимости модуля: +# Импорты: Qt, QStackedWidget, QGridLayout, QLayout, QWidget (PySide6) +# Хост/базовый класс: StylableMixin + PercentSizedWidget (MRO) +# Внутренние: ContentHost (content_host.py), StylableMixin (stylable_mixin.py) +# Внешние библиотеки: PySide6 +# +# 3) Экспорт: +# Класс StackContainer — стековый контейнер. +# Методы: add_widget(), set_current_index(), set_current_widget(), +# current_index(), current_widget(), count(), widget(), +# remove_widget(), get_available_size_for_content(), +# get_layout(), set_margins(), set_spacing(), set_alignment() +# Свойство: stacked_widget (доступ к QStackedWidget). +# +# 4) Состояние (поля): +# _stack : QStackedWidget — Qt stacked widget. +# _content_host : ContentHost — промежуточный хост. +# _outer_layout : QGridLayout — внешний layout (spring-based). +# _auto_add_children : bool = True — авто-добавление потомков. +# +# 5) Последовательность действий и вызовов: +# __init__(params) -> super().__init__(w%, h%, parent) +# -> _init_stylable() -> создание QStackedWidget +# -> создание ContentHost -> _content_host.add_widget(_stack) +# -> создание _outer_layout (QGridLayout) +# -> setContentsMargins(applied_margins) +# -> _apply_style() если style задан +# add_widget(w) -> _stack.addWidget(w) → возвращает int index +# set_current_index(i) -> _stack.setCurrentIndex(i) +# +# 6) Побочные эффекты: +# QStackedWidget скрывает все страницы, кроме текущей. +# _auto_add_children = True — дочерние виджеты авто-добавляются. +# set_alignment() бросает NotImplementedError. +# _apply_style() применяет stylesheet. +# +# 7) Границы ответственности: +# НЕ управляет навигацией между страницами — только хранит и показывает. +# НЕ создаёт кнопок/табов для переключения — это делает вызывающий код. +# НЕ поддерживает Qt alignment (только springs). +# +# 8) Обработка ошибок: +# set_alignment() → NotImplementedError. +# Защита от нетипичных значений alignment — параметр удалён. +# +# 9) Инварианты и контракты: +# - add_widget возвращает index добавленной страницы. +# - content_margins приоритетнее margin. +# +# 10) Правило сопровождения: +# Интерфейс CRUD-страниц (add/remove/set_current) повторяет +# QStackedWidget — при обновлении Qt API проверять совместимость. +# Не добавлять alignment в конструктор — запрещено правилами. diff --git a/Dispatch_V0.1.1/gui/containers/v_container.py b/Dispatch_V0.1.1/gui/containers/v_container.py new file mode 100644 index 0000000..127f441 --- /dev/null +++ b/Dispatch_V0.1.1/gui/containers/v_container.py @@ -0,0 +1,101 @@ +# -*- coding: utf-8 -*- +# gui/containers/v_container.py +"""Вертикальный контейнер — тонкая обёртка над SContainer(orientation='v'). + +Удобство: ``VContainer(width_percent=30)`` вместо +``SContainer(width_percent=30, orientation='v')``. + +Высота всегда Expanding (height_percent=None). +""" + +from PySide6.QtWidgets import QWidget +from .s_container import SContainer + + +class VContainer(SContainer): + """Вертикальный контейнер с шириной в процентах от родителя.""" + + def __init__( + self, + width_percent: int | float | None = None, + margin: int | tuple[int, int, int, int] = 0, + spacing: int = 0, + content_width_percent: int | None = None, + content_height_percent: int | None = None, + content_width: int | None = None, + content_height: int | None = None, + content_fit: bool = True, + content_driven: bool = False, + parent: QWidget | None = None, + style: str | None = None, + active_style: str | None = None, + is_active: bool | None = None, + ): + super().__init__( + width_percent=width_percent, + height_percent=None, + margin=margin, + spacing=spacing, + orientation="v", + content_width_percent=content_width_percent, + content_height_percent=content_height_percent, + content_width=content_width, + content_height=content_height, + content_fit=content_fit, + content_driven=content_driven, + parent=parent, + style=style, + active_style=active_style, + is_active=is_active, + ) + + + + +# --------------------------------------------------------------------------- +# Module workflow notes +# --------------------------------------------------------------------------- +# +# 1) Назначение модуля: +# Вертикальный контейнер — тонкая обёртка над SContainer с фиксированной +# ориентацией "v". Удобный синтаксический сахар: VContainer(width_percent=30) +# вместо SContainer(width_percent=30, orientation='v'). Высота всегда +# Expanding (height_percent=None по умолчанию). +# +# 2) Зависимости модуля: +# Импорты: QWidget (PySide6) +# Хост/базовый класс: SContainer (s_container.py) +# Внешние библиотеки: PySide6 +# +# 3) Экспорт: +# Класс VContainer — вертикальный контейнер с шириной в процентах. +# +# 4) Состояние (поля): +# Собственных полей нет — всё наследуется от SContainer. +# Параметр alignment в __init__ принимается, но игнорируется +# (deprecated, для обратной совместимости). +# +# 5) Последовательность действий и вызовов: +# __init__(params) -> super().__init__(height_percent=None, orientation="v", ...) +# Все методы делегируются SContainer: add_widget(), add_stretch(), +# set_margins(), set_spacing(), get_layout() и т.д. +# +# 6) Побочные эффекты: +# Те же, что и у SContainer: стилизация через _apply_style(), +# флаг _auto_add_children=True, subscribe на theme_bus. +# +# 7) Границы ответственности: +# НЕ добавляет собственной логики — только фиксирует orientation="v" +# и height_percent=None. Вся логика — в SContainer. +# +# 8) Обработка ошибок: +# Делегируется SContainer. +# +# 9) Инварианты и контракты: +# - height_percent всегда None → вертикальная ось Expanding. +# - orientation всегда "v". +# +# 10) Правило сопровождения: +# Не добавлять сюда логику. Если нужна новая функциональность — +# добавлять в SContainer, чтобы VContainer и HContainer наследовали +# её автоматически. diff --git a/Dispatch_V0.1.1/gui/login_dialog.py b/Dispatch_V0.1.1/gui/login_dialog.py new file mode 100644 index 0000000..5ca90a5 --- /dev/null +++ b/Dispatch_V0.1.1/gui/login_dialog.py @@ -0,0 +1,270 @@ +# -*- coding: utf-8 -*- +# gui/login_dialog.py + +"""Модальный диалог авторизации пользователя в составе Dispatch. + +Назначение модуля: + Локальная копия диалога авторизации USMS, обращённая к сервису + `hub.my_account.auth_service` независимого приложения Dispatch. + Источник учётных данных — каталог `DB_dispatch`. + +Архитектурные ограничения: + - QSS подключается только через реестр `APP_STYLES`, без inline-стилей. + - Все элементы построены через канонические обёртки `Button`, + `Dialog`, `Label`, `SContainer`, `VContainer`, `HContainer`. + - Локальные процентные доли элементов вычислены вручную, без + хелперов layout. +""" + +from __future__ import annotations + +from PySide6.QtCore import Qt, Slot +from PySide6.QtWidgets import QLineEdit, QPushButton + +from gui.components import Button, Dialog, Label +from gui.containers import HContainer, SContainer, VContainer +from gui.styles import APP_STYLES +from gui.theme_bus import theme_bus + + +class LoginDialog(Dialog): + """Диалог входа: два поля (логин / пароль), кнопки Отмена / Войти.""" + + def __init__(self, parent=None): + self._login_input: QLineEdit | None = None + self._password_input: QLineEdit | None = None + self._login_container: SContainer | None = None + self._password_container: SContainer | None = None + self._login_error_label: Label | None = None + self._password_error_label: Label | None = None + self._cancel_button: Button | None = None + self._submit_button: Button | None = None + self._theme: str = "dark" + + super().__init__( + title="Авторизация", + width=400, + height=340, + modal=True, + parent=parent, + ) + self._theme = "dark" if self.palette().window().color().lightness() < 128 else "light" + self._setup_ui() + self._connect_signals() + for pb in self.findChildren(QPushButton): + pb.setAutoDefault(False) + pb.setDefault(False) + self._apply_theme(self._theme) + + # ── UI assembly ────────────────────────────────────────────── + + def _setup_ui(self) -> None: + root = VContainer(margin=[28, 8, 28, 24], spacing=0) + self.add_widget(root) + + root.add_widget(self._build_title()) + root.add_widget(self._build_login_field()) + root.add_widget(self._build_password_field()) + root.add_widget(self._build_actions()) + + def _build_title(self) -> Label: + return Label( + "Вход в систему", + height_percent=14, + style="LOGIN_TITLE", + ) + + def _build_login_field(self) -> SContainer: + field = SContainer( + height_percent=24, + spacing=2, + orientation="v", + content_fit=False, + ) + + field.add_widget(Label( + "Логин", + height_percent=28, + style="LOGIN_FIELD_LABEL", + )) + + self._login_container = SContainer( + height_percent=42, + orientation="h", + content_fit=False, + ) + self._login_input = QLineEdit() + self._login_input.setPlaceholderText("Введите логин") + self._login_container.add_widget(self._login_input) + field.add_widget(self._login_container) + + self._login_error_label = Label( + "", + height_percent=24, + style="LOGIN_ERROR_LABEL", + ) + self._login_error_label.set_visible(False) + field.add_widget(self._login_error_label) + + return field + + def _build_password_field(self) -> SContainer: + field = SContainer( + height_percent=24, + spacing=2, + orientation="v", + content_fit=False, + ) + + field.add_widget(Label( + "Пароль", + height_percent=28, + style="LOGIN_FIELD_LABEL", + )) + + self._password_container = SContainer( + height_percent=42, + orientation="h", + content_fit=False, + ) + self._password_input = QLineEdit() + self._password_input.setPlaceholderText("Введите пароль") + self._password_input.setEchoMode(QLineEdit.EchoMode.Password) + self._password_container.add_widget(self._password_input) + field.add_widget(self._password_container) + + self._password_error_label = Label( + "", + height_percent=24, + style="LOGIN_ERROR_LABEL", + ) + self._password_error_label.set_visible(False) + field.add_widget(self._password_error_label) + + return field + + def _build_actions(self) -> HContainer: + actions = HContainer( + height_percent=16, + spacing=0, + content_fit=False, + ) + + SContainer(width_percent=10, height_percent=100, parent=actions) + + self._cancel_button = Button( + "Отмена", + width_percent=26, + height_percent=100, + margin=0, + style="LOGIN_CANCEL_BUTTON", + content_fit=False, + ) + actions.add_widget(self._cancel_button) + + SContainer(width_percent=28, height_percent=100, parent=actions) + + self._submit_button = Button( + "Войти", + width_percent=26, + height_percent=100, + margin=0, + style="LOGIN_SUBMIT_BUTTON", + content_fit=False, + ) + actions.add_widget(self._submit_button) + + SContainer(width_percent=10, height_percent=100, parent=actions) + return actions + + # ── Signals ────────────────────────────────────────────────── + + def _connect_signals(self) -> None: + self._cancel_button.clicked.connect(self.reject) + self._submit_button.clicked.connect(self._on_submit) + self._login_input.returnPressed.connect(self._on_submit) + self._password_input.returnPressed.connect(self._on_submit) + self._login_input.textChanged.connect(self._clear_login_error) + self._password_input.textChanged.connect(self._clear_password_error) + theme_bus.theme_changed.connect(self._on_theme_changed) + + # ── Validation ─────────────────────────────────────────────── + + def _on_submit(self) -> None: + # Импорт локального сервиса аутентификации Dispatch. + # Источник учётных записей — `DB_dispatch/0_users.py`. + from auth_service import authenticate + + login = self._login_input.text().strip() + password = self._password_input.text().strip() + + has_error = False + if not login: + self._show_login_error("Поле логин не может быть пустым") + has_error = True + if not password: + self._show_password_error("Поле пароль не может быть пустым") + has_error = True + if has_error: + return + + user, err = authenticate(login, password) + if err == "no_user": + self._show_login_error("Пользователь не существует") + self._show_password_error("Пользователь не существует") + return + if err == "bad_password": + self._show_password_error("Неверный пароль") + return + + self._authenticated_user = user + self.accept() + + def get_authenticated_user(self) -> dict | None: + return getattr(self, "_authenticated_user", None) + + # ── Error display ──────────────────────────────────────────── + + def _show_login_error(self, msg: str) -> None: + self._login_error_label.set_text(msg) + self._login_error_label.set_visible(True) + self._apply_input_style(self._login_container, error=True) + + def _show_password_error(self, msg: str) -> None: + self._password_error_label.set_text(msg) + self._password_error_label.set_visible(True) + self._apply_input_style(self._password_container, error=True) + + @Slot() + def _clear_login_error(self) -> None: + self._login_error_label.set_text("") + self._login_error_label.set_visible(False) + self._apply_input_style(self._login_container, error=False) + + @Slot() + def _clear_password_error(self) -> None: + self._password_error_label.set_text("") + self._password_error_label.set_visible(False) + self._apply_input_style(self._password_container, error=False) + + # ── Theming ────────────────────────────────────────────────── + + @Slot(str) + def _on_theme_changed(self, theme: str) -> None: + self._apply_theme(theme) + + def _apply_theme(self, theme: str) -> None: + self._theme = theme + suffix = "_LIGHT" if theme == "light" else "_DARK" + + dialog_qss = APP_STYLES.get(f"LOGIN_DIALOG{suffix}", "") + self.setStyleSheet(dialog_qss) + + self._apply_input_style(self._login_container, error=False) + self._apply_input_style(self._password_container, error=False) + + def _apply_input_style(self, container: SContainer, error: bool) -> None: + suffix = "_LIGHT" if self._theme == "light" else "_DARK" + key = f"LOGIN_INPUT_ERROR{suffix}" if error else f"LOGIN_INPUT{suffix}" + qss = APP_STYLES.get(key, "") + container.setStyleSheet(qss) diff --git a/Dispatch_V0.1.1/gui/styles/__init__.py b/Dispatch_V0.1.1/gui/styles/__init__.py new file mode 100644 index 0000000..9e39472 --- /dev/null +++ b/Dispatch_V0.1.1/gui/styles/__init__.py @@ -0,0 +1,56 @@ +# -*- coding: utf-8 -*- +# gui/styles/__init__.py +from .buttons import BUTTONS +from .labels import LABELS +from .inputs import INPUTS +from .widgets import WIDGETS +from .ticket import TICKET +from .ticket_cards import TICKET_CARDS +from .ticket_details import TICKET_DETAILS +from .ticket_refusal_dialog import TICKET_REFUSAL_DIALOG +from .ticket_specialist_dialog import TICKET_SPECIALIST_DIALOG +from .ticket_document_dialog import TICKET_DOCUMENT_DIALOG +from .ticket_reports import TICKET_REPORTS +from .login_dialog import LOGIN_DIALOG +from .profile_dossier import PROFILE_DOSSIER +from .quality_assurance import QUALITY_ASSURANCE +from .quality_assurance_word_workspace import QUALITY_ASSURANCE_WORD_WORKSPACE +from .welcome import WELCOME + +APP_STYLES = {} +APP_STYLES.update(BUTTONS) +APP_STYLES.update(LABELS) +APP_STYLES.update(INPUTS) +APP_STYLES.update(WIDGETS) +APP_STYLES.update(TICKET) +APP_STYLES.update(TICKET_CARDS) +APP_STYLES.update(TICKET_DETAILS) +APP_STYLES.update(TICKET_REFUSAL_DIALOG) +APP_STYLES.update(TICKET_SPECIALIST_DIALOG) +APP_STYLES.update(TICKET_DOCUMENT_DIALOG) +APP_STYLES.update(TICKET_REPORTS) +APP_STYLES.update(LOGIN_DIALOG) +APP_STYLES.update(PROFILE_DOSSIER) +APP_STYLES.update(QUALITY_ASSURANCE) +APP_STYLES.update(QUALITY_ASSURANCE_WORD_WORKSPACE) +APP_STYLES.update(WELCOME) + + + + +# --------------------------------------------------------------------------- +# Module workflow notes (compact) +# --------------------------------------------------------------------------- +# +# 1) Назначение модуля: +# Пакетный __init__.py для gui/styles. Аггрегирует все словари стилей +# (BUTTONS, LABELS, INPUTS, WIDGETS и Ticket-профили) в единый реестр +# APP_STYLES для использования через StylableMixin._apply_style(). +# +# 2) Зависимости модуля: +# Реимпорт из: buttons (BUTTONS), labels (LABELS), inputs (INPUTS), +# widgets (WIDGETS), ticket* style-модулей. +# +# 3) Экспорт: +# APP_STYLES: dict[str, str] — глобальный реестр всех QSS-стилей. +# Также реэкспортирует BUTTONS, LABELS, INPUTS, WIDGETS и Ticket-словари. diff --git a/Dispatch_V0.1.1/gui/styles/__pycache__/__init__.cpython-313.pyc b/Dispatch_V0.1.1/gui/styles/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..24ac469 Binary files /dev/null and b/Dispatch_V0.1.1/gui/styles/__pycache__/__init__.cpython-313.pyc differ diff --git a/Dispatch_V0.1.1/gui/styles/__pycache__/buttons.cpython-313.pyc b/Dispatch_V0.1.1/gui/styles/__pycache__/buttons.cpython-313.pyc new file mode 100644 index 0000000..ecd9b22 Binary files /dev/null and b/Dispatch_V0.1.1/gui/styles/__pycache__/buttons.cpython-313.pyc differ diff --git a/Dispatch_V0.1.1/gui/styles/__pycache__/inputs.cpython-313.pyc b/Dispatch_V0.1.1/gui/styles/__pycache__/inputs.cpython-313.pyc new file mode 100644 index 0000000..bf01947 Binary files /dev/null and b/Dispatch_V0.1.1/gui/styles/__pycache__/inputs.cpython-313.pyc differ diff --git a/Dispatch_V0.1.1/gui/styles/__pycache__/labels.cpython-313.pyc b/Dispatch_V0.1.1/gui/styles/__pycache__/labels.cpython-313.pyc new file mode 100644 index 0000000..9122818 Binary files /dev/null and b/Dispatch_V0.1.1/gui/styles/__pycache__/labels.cpython-313.pyc differ diff --git a/Dispatch_V0.1.1/gui/styles/__pycache__/login_dialog.cpython-313.pyc b/Dispatch_V0.1.1/gui/styles/__pycache__/login_dialog.cpython-313.pyc new file mode 100644 index 0000000..5c0d41f Binary files /dev/null and b/Dispatch_V0.1.1/gui/styles/__pycache__/login_dialog.cpython-313.pyc differ diff --git a/Dispatch_V0.1.1/gui/styles/__pycache__/profile_dossier.cpython-313.pyc b/Dispatch_V0.1.1/gui/styles/__pycache__/profile_dossier.cpython-313.pyc new file mode 100644 index 0000000..3e731a2 Binary files /dev/null and b/Dispatch_V0.1.1/gui/styles/__pycache__/profile_dossier.cpython-313.pyc differ diff --git a/Dispatch_V0.1.1/gui/styles/__pycache__/quality_assurance.cpython-313.pyc b/Dispatch_V0.1.1/gui/styles/__pycache__/quality_assurance.cpython-313.pyc new file mode 100644 index 0000000..5c830c7 Binary files /dev/null and b/Dispatch_V0.1.1/gui/styles/__pycache__/quality_assurance.cpython-313.pyc differ diff --git a/Dispatch_V0.1.1/gui/styles/__pycache__/quality_assurance_word_workspace.cpython-313.pyc b/Dispatch_V0.1.1/gui/styles/__pycache__/quality_assurance_word_workspace.cpython-313.pyc new file mode 100644 index 0000000..0e7383f Binary files /dev/null and b/Dispatch_V0.1.1/gui/styles/__pycache__/quality_assurance_word_workspace.cpython-313.pyc differ diff --git a/Dispatch_V0.1.1/gui/styles/__pycache__/ticket.cpython-313.pyc b/Dispatch_V0.1.1/gui/styles/__pycache__/ticket.cpython-313.pyc new file mode 100644 index 0000000..2c1fe7f Binary files /dev/null and b/Dispatch_V0.1.1/gui/styles/__pycache__/ticket.cpython-313.pyc differ diff --git a/Dispatch_V0.1.1/gui/styles/__pycache__/ticket_cards.cpython-313.pyc b/Dispatch_V0.1.1/gui/styles/__pycache__/ticket_cards.cpython-313.pyc new file mode 100644 index 0000000..8a7fd27 Binary files /dev/null and b/Dispatch_V0.1.1/gui/styles/__pycache__/ticket_cards.cpython-313.pyc differ diff --git a/Dispatch_V0.1.1/gui/styles/__pycache__/ticket_details.cpython-313.pyc b/Dispatch_V0.1.1/gui/styles/__pycache__/ticket_details.cpython-313.pyc new file mode 100644 index 0000000..ed17a02 Binary files /dev/null and b/Dispatch_V0.1.1/gui/styles/__pycache__/ticket_details.cpython-313.pyc differ diff --git a/Dispatch_V0.1.1/gui/styles/__pycache__/ticket_document_dialog.cpython-313.pyc b/Dispatch_V0.1.1/gui/styles/__pycache__/ticket_document_dialog.cpython-313.pyc new file mode 100644 index 0000000..647fde6 Binary files /dev/null and b/Dispatch_V0.1.1/gui/styles/__pycache__/ticket_document_dialog.cpython-313.pyc differ diff --git a/Dispatch_V0.1.1/gui/styles/__pycache__/ticket_refusal_dialog.cpython-313.pyc b/Dispatch_V0.1.1/gui/styles/__pycache__/ticket_refusal_dialog.cpython-313.pyc new file mode 100644 index 0000000..9dff2d7 Binary files /dev/null and b/Dispatch_V0.1.1/gui/styles/__pycache__/ticket_refusal_dialog.cpython-313.pyc differ diff --git a/Dispatch_V0.1.1/gui/styles/__pycache__/ticket_reports.cpython-313.pyc b/Dispatch_V0.1.1/gui/styles/__pycache__/ticket_reports.cpython-313.pyc new file mode 100644 index 0000000..02c1dd1 Binary files /dev/null and b/Dispatch_V0.1.1/gui/styles/__pycache__/ticket_reports.cpython-313.pyc differ diff --git a/Dispatch_V0.1.1/gui/styles/__pycache__/ticket_specialist_dialog.cpython-313.pyc b/Dispatch_V0.1.1/gui/styles/__pycache__/ticket_specialist_dialog.cpython-313.pyc new file mode 100644 index 0000000..d936ce5 Binary files /dev/null and b/Dispatch_V0.1.1/gui/styles/__pycache__/ticket_specialist_dialog.cpython-313.pyc differ diff --git a/Dispatch_V0.1.1/gui/styles/__pycache__/welcome.cpython-313.pyc b/Dispatch_V0.1.1/gui/styles/__pycache__/welcome.cpython-313.pyc new file mode 100644 index 0000000..47f5535 Binary files /dev/null and b/Dispatch_V0.1.1/gui/styles/__pycache__/welcome.cpython-313.pyc differ diff --git a/Dispatch_V0.1.1/gui/styles/__pycache__/widgets.cpython-313.pyc b/Dispatch_V0.1.1/gui/styles/__pycache__/widgets.cpython-313.pyc new file mode 100644 index 0000000..5d2a047 Binary files /dev/null and b/Dispatch_V0.1.1/gui/styles/__pycache__/widgets.cpython-313.pyc differ diff --git a/Dispatch_V0.1.1/gui/styles/assets/arrow_down.svg b/Dispatch_V0.1.1/gui/styles/assets/arrow_down.svg new file mode 100644 index 0000000..439930f --- /dev/null +++ b/Dispatch_V0.1.1/gui/styles/assets/arrow_down.svg @@ -0,0 +1,3 @@ + + + diff --git a/Dispatch_V0.1.1/gui/styles/buttons.py b/Dispatch_V0.1.1/gui/styles/buttons.py new file mode 100644 index 0000000..2a98fcd --- /dev/null +++ b/Dispatch_V0.1.1/gui/styles/buttons.py @@ -0,0 +1,970 @@ +# -*- coding: utf-8 -*- +# gui/styles/buttons.py +"""Стили для всех видов кнопок""" +from pathlib import Path +import re + +LIGHT_TEXT_PRIMARY = "#172B4D" +LIGHT_TEXT_SECONDARY = "#5E6C84" +LIGHT_TEXT_DISABLED = "#A5ADBA" +LIGHT_PANEL = "#FFFFFF" +LIGHT_BORDER = "#DFE1E6" +LIGHT_HOVER = "#EBECF0" +LIGHT_PRESSED = "#DFE1E6" +LIGHT_ACCENT_SOFT = "#DEEBFF" +LIGHT_ACCENT_BORDER = "#85B8FF" + +BUTTONS = { + + "BUTTON_PRIMARY": """ + QPushButton { + background-color: palette(highlight); + color: #ffffff; + border: 1px solid palette(highlight); + border-radius: 0px; + padding: 8px 14px; + font-size: 14px; + font-weight: 600; + } + QPushButton:hover { + background-color: #0747A6; + border-color: #0747A6; + } + QPushButton:pressed { + background-color: #043A7D; + border-color: #043A7D; + } + QPushButton:disabled { + background-color: palette(mid); + color: #42526E; + border-color: palette(mid); + } + """, + + "BUTTON_SECONDARY": """ + QPushButton { + background-color: palette(button); + color: palette(button-text); + border: 1px solid palette(mid); + border-radius: 0px; + padding: 4px 8px; + font-size: 12px; + min-width: 100px; + min-height: 30px; + } + QPushButton:hover { + background-color: #EBECF0; + } + QPushButton:pressed { + background-color: #DFE1E6; + } + """, + + "BUTTON_SMALL": """ + QPushButton { + background-color: palette(button); + color: palette(button-text); + border: 1px solid palette(mid); + border-radius: 0px; + padding: 6px 12px; + font-size: 12px; + min-width: 80px; + min-height: 30px; + } + QPushButton:hover { + background-color: #EBECF0; + } + QPushButton:pressed { + background-color: #DFE1E6; + } + QPushButton:checked { + background-color: palette(highlight); + color: #ffffff; + border-color: palette(highlight); + } + """, + + "MEASURE_TOGGLE": """ + QToolButton { + background-color: transparent; + color: white; + border: 1px solid #555555; + font-size: 16px; + padding-left: 6px; + qproperty-icon: url("gui/components/icons/measure_white.png"); + qproperty-iconSize: 18px 18px; + qproperty-toolButtonStyle: ToolButtonTextBesideIcon; + } + QToolButton:hover { background-color: #4a4a4a; } + QToolButton:pressed { background-color: #5a5a5a; } + """, + + "MEASURE_TOGGLE_ACTIVE": """ + QToolButton { + background-color: rgba(255, 255, 255, 0.3); + color: white; + border: 1px solid #cfcfcf; + font-size: 16px; + padding-left: 6px; + qproperty-icon: url("gui/components/icons/measure_white.png"); + qproperty-iconSize: 18px 18px; + qproperty-toolButtonStyle: ToolButtonTextBesideIcon; + } + QToolButton:hover { background-color: #6a6a6a; } + QToolButton:pressed { background-color: #7a7a7a; } + """, + "MEASURE_TEXT_TOGGLE": """ + QToolButton { + background-color: transparent; + color: white; + border: 1px solid #555555; + font-size: 16px; + padding-left: 0px; + qproperty-toolButtonStyle: ToolButtonTextOnly; + } + QToolButton:hover { background-color: #4a4a4a; } + QToolButton:pressed { background-color: #5a5a5a; } + """, + + "MEASURE_TEXT_TOGGLE_ACTIVE": """ + QToolButton { + background-color: rgba(255, 255, 255, 0.3); + color: white; + border: 1px solid #cfcfcf; + font-size: 16px; + padding-left: 0px; + qproperty-toolButtonStyle: ToolButtonTextOnly; + } + QToolButton:hover { background-color: #6a6a6a; } + QToolButton:pressed { background-color: #7a7a7a; } + """, + + "CREATE_ZONE_TOGGLE": """ + QToolButton { + background-color: transparent; + color: white; + border: 1px solid #555555; + font-size: 16px; + padding-left: 6px; + qproperty-icon: url("gui/components/icons/create_zone_white.png"); + qproperty-iconSize: 18px 18px; + qproperty-toolButtonStyle: ToolButtonTextBesideIcon; + } + QToolButton:hover { background-color: #4a4a4a; } + QToolButton:pressed { background-color: #5a5a5a; } + """, + + "CREATE_ZONE_TOGGLE_ACTIVE": """ + QToolButton { + background-color: rgba(255, 255, 255, 0.3); + color: white; + border: 1px solid #cfcfcf; + font-size: 16px; + padding-left: 6px; + qproperty-icon: url("gui/components/icons/create_zone_white.png"); + qproperty-iconSize: 18px 18px; + qproperty-toolButtonStyle: ToolButtonTextBesideIcon; + } + QToolButton:hover { background-color: #6a6a6a; } + QToolButton:pressed { background-color: #7a7a7a; } + """, + + "DELETE_ZONE_TOGGLE": """ + QToolButton { + background-color: transparent; + color: white; + border: 1px solid #555555; + font-size: 16px; + padding-left: 6px; + qproperty-icon: url("gui/components/icons/delete_zone_white.png"); + qproperty-iconSize: 18px 18px; + qproperty-toolButtonStyle: ToolButtonTextBesideIcon; + } + QToolButton:hover { background-color: #4a4a4a; } + QToolButton:pressed { background-color: #5a5a5a; } + """, + + "DELETE_ZONE_TOGGLE_ACTIVE": """ + QToolButton { + background-color: rgba(255, 255, 255, 0.3); + color: white; + border: 1px solid #cfcfcf; + font-size: 16px; + padding-left: 6px; + qproperty-icon: url("gui/components/icons/delete_zone_white.png"); + qproperty-iconSize: 18px 18px; + qproperty-toolButtonStyle: ToolButtonTextBesideIcon; + } + QToolButton:hover { background-color: #6a6a6a; } + QToolButton:pressed { background-color: #7a7a7a; } + """, + + "SELECT_ZONE_TOGGLE": """ + QToolButton { + background-color: transparent; + color: white; + border: 1px solid #555555; + font-size: 16px; + padding-left: 6px; + qproperty-icon: url("gui/components/icons/select_zone_white.png"); + qproperty-iconSize: 18px 18px; + qproperty-toolButtonStyle: ToolButtonTextBesideIcon; + } + QToolButton:hover { background-color: #4a4a4a; } + QToolButton:pressed { background-color: #5a5a5a; } + """, + + "SELECT_ZONE_TOGGLE_ACTIVE": """ + QToolButton { + background-color: rgba(255, 255, 255, 0.3); + color: white; + border: 1px solid #cfcfcf; + font-size: 16px; + padding-left: 6px; + qproperty-icon: url("gui/components/icons/select_zone_white.png"); + qproperty-iconSize: 18px 18px; + qproperty-toolButtonStyle: ToolButtonTextBesideIcon; + } + QToolButton:hover { background-color: #6a6a6a; } + QToolButton:pressed { background-color: #7a7a7a; } + """, + "EDIT_ZONE_TOGGLE": """ + QToolButton { + background-color: transparent; + color: white; + border: 1px solid #555555; + font-size: 16px; + padding-left: 6px; + qproperty-icon: url("gui/components/icons/edit_white.png"); + qproperty-iconSize: 18px 18px; + qproperty-toolButtonStyle: ToolButtonTextBesideIcon; + } + QToolButton:hover { background-color: #4a4a4a; } + QToolButton:pressed { background-color: #5a5a5a; } + """, + + "EDIT_ZONE_TOGGLE_ACTIVE": """ + QToolButton { + background-color: rgba(255, 255, 255, 0.3); + color: white; + border: 1px solid #cfcfcf; + font-size: 16px; + padding-left: 6px; + qproperty-icon: url("gui/components/icons/edit_white.png"); + qproperty-iconSize: 18px 18px; + qproperty-toolButtonStyle: ToolButtonTextBesideIcon; + } + QToolButton:hover { background-color: #6a6a6a; } + QToolButton:pressed { background-color: #7a7a7a; } + """, + + "CREATE_NEW_MESH_TOGGLE": """ + QToolButton { + background-color: transparent; + color: white; + border: 1px solid #555555; + font-size: 16px; + padding-left: 6px; + qproperty-icon: url("gui/components/icons/create_mesh_white.png"); + qproperty-iconSize: 18px 18px; + qproperty-toolButtonStyle: ToolButtonTextBesideIcon; + } + QToolButton:hover { background-color: #4a4a4a; } + QToolButton:pressed { background-color: #5a5a5a; } + """, + + "CREATE_NEW_MESH_TOGGLE_ACTIVE": """ + QToolButton { + background-color: rgba(255, 255, 255, 0.3); + color: white; + border: 1px solid #cfcfcf; + font-size: 16px; + padding-left: 6px; + qproperty-icon: url("gui/components/icons/create_mesh_white.png"); + qproperty-iconSize: 18px 18px; + qproperty-toolButtonStyle: ToolButtonTextBesideIcon; + } + QToolButton:hover { background-color: #6a6a6a; } + QToolButton:pressed { background-color: #7a7a7a; } + """, + + "USE_CURRENT_MESH_TOGGLE": """ + QToolButton { + background-color: transparent; + color: white; + border: 1px solid #555555; + font-size: 16px; + padding-left: 6px; + qproperty-icon: url("gui/components/icons/use_current_mesh_white.png"); + qproperty-iconSize: 18px 18px; + qproperty-toolButtonStyle: ToolButtonTextBesideIcon; + } + QToolButton:hover { background-color: #4a4a4a; } + QToolButton:pressed { background-color: #5a5a5a; } + """, + + "USE_CURRENT_MESH_TOGGLE_ACTIVE": """ + QToolButton { + background-color: rgba(255, 255, 255, 0.3); + color: white; + border: 1px solid #cfcfcf; + font-size: 16px; + padding-left: 6px; + qproperty-icon: url("gui/components/icons/use_current_mesh_white.png"); + qproperty-iconSize: 18px 18px; + qproperty-toolButtonStyle: ToolButtonTextBesideIcon; + } + QToolButton:hover { background-color: #6a6a6a; } + QToolButton:pressed { background-color: #7a7a7a; } + """, + + "START_POINT_TOGGLE": """ + QToolButton { + background-color: transparent; + color: white; + border: 1px solid #555555; + font-size: 16px; + padding-left: 6px; + qproperty-icon: url("gui/components/icons/start_point_white.png"); + qproperty-iconSize: 18px 18px; + qproperty-toolButtonStyle: ToolButtonTextBesideIcon; + } + QToolButton:hover { background-color: #4a4a4a; } + QToolButton:pressed { background-color: #5a5a5a; } + """, + + "START_POINT_TOGGLE_ACTIVE": """ + QToolButton { + background-color: rgba(255, 255, 255, 0.3); + color: white; + border: 1px solid #cfcfcf; + font-size: 16px; + padding-left: 6px; + qproperty-icon: url("gui/components/icons/start_point_white.png"); + qproperty-iconSize: 18px 18px; + qproperty-toolButtonStyle: ToolButtonTextBesideIcon; + } + QToolButton:hover { background-color: #6a6a6a; } + QToolButton:pressed { background-color: #7a7a7a; } + """, + + "CREATE_MESH_TOGGLE": """ + QToolButton { + background-color: transparent; + color: white; + border: 1px solid #555555; + font-size: 16px; + padding-left: 6px; + qproperty-icon: url("gui/components/icons/create_mesh_white.png"); + qproperty-iconSize: 18px 18px; + qproperty-toolButtonStyle: ToolButtonTextBesideIcon; + } + QToolButton:hover { background-color: #4a4a4a; } + QToolButton:pressed { background-color: #5a5a5a; } + """, + + "CREATE_MESH_TOGGLE_ACTIVE": """ + QToolButton { + background-color: rgba(255, 255, 255, 0.3); + color: white; + border: 1px solid #cfcfcf; + font-size: 16px; + padding-left: 6px; + qproperty-icon: url("gui/components/icons/create_mesh_white.png"); + qproperty-iconSize: 18px 18px; + qproperty-toolButtonStyle: ToolButtonTextBesideIcon; + } + QToolButton:hover { background-color: #6a6a6a; } + QToolButton:pressed { background-color: #7a7a7a; } + """, + "REMEMBER_POINT_TOGGLE": """ + QToolButton { + background-color: transparent; + color: white; + border: 1px solid #555555; + font-size: 16px; + padding-left: 6px; + qproperty-icon: url("gui/components/icons/remember_point_white.png"); + qproperty-iconSize: 18px 18px; + qproperty-toolButtonStyle: ToolButtonTextBesideIcon; + } + QToolButton:hover { background-color: #4a4a4a; } + QToolButton:pressed { background-color: #5a5a5a; } + """, + + "REMEMBER_POINT_TOGGLE_ACTIVE": """ + QToolButton { + background-color: rgba(255, 255, 255, 0.3); + color: white; + border: 1px solid #cfcfcf; + font-size: 16px; + padding-left: 6px; + qproperty-icon: url("gui/components/icons/remember_point_white.png"); + qproperty-iconSize: 18px 18px; + qproperty-toolButtonStyle: ToolButtonTextBesideIcon; + } + QToolButton:hover { background-color: #6a6a6a; } + QToolButton:pressed { background-color: #7a7a7a; } + """, + + "SET_GRID_STEP_TOGGLE": """ + QToolButton { + background-color: transparent; + color: white; + border: 1px solid #555555; + font-size: 16px; + padding-left: 6px; + qproperty-icon: url("gui/components/icons/set_step_white.png"); + qproperty-iconSize: 18px 18px; + qproperty-toolButtonStyle: ToolButtonTextBesideIcon; + } + QToolButton:hover { background-color: #4a4a4a; } + QToolButton:pressed { background-color: #5a5a5a; } + """, + + "SET_GRID_STEP_TOGGLE_ACTIVE": """ + QToolButton { + background-color: rgba(255, 255, 255, 0.3); + color: white; + border: 1px solid #cfcfcf; + font-size: 16px; + padding-left: 6px; + qproperty-icon: url("gui/components/icons/set_step_white.png"); + qproperty-iconSize: 18px 18px; + qproperty-toolButtonStyle: ToolButtonTextBesideIcon; + } + QToolButton:hover { background-color: #6a6a6a; } + QToolButton:pressed { background-color: #7a7a7a; } + """, + + "CREATE_VOLUME_TOGGLE": """ + QToolButton { + background-color: transparent; + color: white; + border: 1px solid #555555; + font-size: 16px; + padding-left: 6px; + qproperty-icon: url("gui/components/icons/create_volume_white.png"); + qproperty-iconSize: 18px 18px; + qproperty-toolButtonStyle: ToolButtonTextBesideIcon; + } + QToolButton:hover { background-color: #4a4a4a; } + QToolButton:pressed { background-color: #5a5a5a; } + """, + + "CREATE_VOLUME_TOGGLE_ACTIVE": """ + QToolButton { + background-color: rgba(255, 255, 255, 0.3); + color: white; + border: 1px solid #cfcfcf; + font-size: 16px; + padding-left: 6px; + qproperty-icon: url("gui/components/icons/create_volume_white.png"); + qproperty-iconSize: 18px 18px; + qproperty-toolButtonStyle: ToolButtonTextBesideIcon; + } + QToolButton:hover { background-color: #6a6a6a; } + QToolButton:pressed { background-color: #7a7a7a; } + """, + + "REMEMBER_VOLUME_TOGGLE": """ + QToolButton { + background-color: transparent; + color: white; + border: 1px solid #555555; + font-size: 16px; + padding-left: 6px; + qproperty-icon: url("gui/components/icons/accept_wolume_white.png"); + qproperty-iconSize: 18px 18px; + qproperty-toolButtonStyle: ToolButtonTextBesideIcon; + } + QToolButton:hover { background-color: #4a4a4a; } + QToolButton:pressed { background-color: #5a5a5a; } + """, + + "REMEMBER_VOLUME_TOGGLE_ACTIVE": """ + QToolButton { + background-color: rgba(255, 255, 255, 0.3); + color: white; + border: 1px solid #cfcfcf; + font-size: 16px; + padding-left: 6px; + qproperty-icon: url("gui/components/icons/accept_wolume_white.png"); + qproperty-iconSize: 18px 18px; + qproperty-toolButtonStyle: ToolButtonTextBesideIcon; + } + QToolButton:hover { background-color: #6a6a6a; } + QToolButton:pressed { background-color: #7a7a7a; } + """, + "MODEL_TOGGLE": """ + QToolButton { + background-color: transparent; + color: white; + border: 1px solid #555555; + font-size: 14px; + padding-left: 6px; + qproperty-toolButtonStyle: ToolButtonTextBesideIcon; + } + QToolButton:disabled { + background-color: #1f1f1f; + color: #7a7a7a; + border: 1px solid #2a2a2a; + } + QToolButton:hover { background-color: #4a4a4a; } + QToolButton:pressed { background-color: #5a5a5a; } + """, + + "MODEL_TOGGLE_ACTIVE": """ + QToolButton { + background-color: rgba(255, 255, 255, 0.3); + color: white; + border: 1px solid #cfcfcf; + font-size: 14px; + padding-left: 6px; + qproperty-toolButtonStyle: ToolButtonTextBesideIcon; + } + QToolButton:hover { background-color: #6a6a6a; } + QToolButton:pressed { background-color: #7a7a7a; } + """, + "STANDARD_BUTTON_DARK_THEME": """ + QPushButton { + background-color: #3c3c3c; + color: white; + border: none; + font-size: 16px; + text-align: left; + border-radius: 0px; + padding-left: 10px; + } + QPushButton:hover { background-color: #4a4a4a; } + QPushButton:pressed { background-color: #5a5a5a; } + QPushButton:disabled { + background-color: #2e2e2e; + color: #606060; + border: none; + } + """, + + "STANDARD_BUTTON_DARK_THEME_ACTIVE": """ + QPushButton { + background-color: #5a5a5a; + color: white; + border: none; + font-size: 16px; + text-align: left; + border-radius: 0px; + padding-left: 10px; + } + QPushButton:hover { background-color: #6a6a6a; } + QPushButton:pressed { background-color: #7a7a7a; } + QPushButton:disabled { + background-color: #2e2e2e; + color: #606060; + border: none; + } + """, + + "STANDARD_BUTTON_LIGHT_THEME": """ + QPushButton { + background-color: #FFFFFF; + color: #172B4D; + border: 1px solid #DFE1E6; + font-size: 16px; + border-radius: 0px; + text-align: left; + padding-left: 10px; + } + QPushButton:hover { + background-color: #EBECF0; + border-color: #DFE1E6; + } + QPushButton:pressed { background-color: #DFE1E6; } + QPushButton:disabled { + background-color: #F4F5F7; + color: #B3BAC5; + border: 1px solid #EBECF0; + } + """, + + "STANDARD_BUTTON_LIGHT_THEME_ACTIVE": """ + QPushButton { + background-color: #FFFFFF; + color: #0C66E4; + border: 1px solid #85B8FF; + font-size: 16px; + border-radius: 0px; + font-weight: 600; + text-align: left; + padding-left: 10px; + } + QPushButton:hover { background-color: #EBECF0; } + QPushButton:pressed { background-color: #DFE1E6; } + QPushButton:disabled { + background-color: #F4F5F7; + color: #B3BAC5; + border: 1px solid #EBECF0; + font-weight: normal; + } + """, + + "TAB_BUTTON_ACTIVE": """ + QPushButton { + background-color: #5a5a5a; + color: white; + border: 1px solid #6A6A6A; + font-size: 16px; + font-weight: bold; + text-align: center; + border-radius: 0px; + margin 10px; + } + QPushButton:hover { + background-color: #6a6a6a; + } + QPushButton:pressed { + background-color: #7a7a7a; + } + QPushButton:disabled { + background-color: #2e2e2e; + color: #606060; + border: 1px solid #333333; + } + """, + + "TAB_BUTTON_NORMAL": """ + QPushButton { + background-color: #3c3c3c; + color: #DCDCDC; + border: 1px solid #3A3A3A; + font-size: 16px; + text-align: center; + border-radius: 0px; + margin 10px; + } + QPushButton:hover { + background-color: #4a4a4a; + } + QPushButton:pressed { + background-color: #5a5a5a; + } + QPushButton:disabled { + background-color: #2e2e2e; + color: #606060; + border: 1px solid #333333; + } + """, + + "VISUALIZATION_BUTTON_ACTIVE": """ + QPushButton { + background-color: #0078d4; + color: #ffffff; + border: 1px solid #0078d4; + border-radius: 0px; + padding: 4px 8px; + font-size: 14px; + font-weight: bold; + } + QPushButton:hover { + background-color: #106ebe; + border-color: #106ebe; + } + QPushButton:pressed { + background-color: #005a9e; + } + """, + + "VISUALIZATION_BUTTON_FILL": """ + QPushButton { + background-color: palette(button); + color: palette(button-text); + border: 1px solid palette(mid); + border-radius: 0px; + padding: 4px 8px; + font-size: 14px; + font-weight: 600; + } + QPushButton:hover { + border-color: palette(highlight); + } + QPushButton:pressed { + background-color: palette(light); + } + """, + "TAB_BUTTON_ACTIVE_LIGHT": """ + QPushButton { + background-color: #FFFFFF; + color: #0C66E4; + border: 1px solid #85B8FF; + font-size: 16px; + font-weight: 600; + text-align: center; + border-radius: 0px; + margin 10px; + } + QPushButton:hover { + background-color: #EBECF0; + } + QPushButton:pressed { + background-color: #DFE1E6; + } + QPushButton:disabled { + background-color: #F4F5F7; + color: #B3BAC5; + border: 1px solid #EBECF0; + } + """, + + "TAB_BUTTON_NORMAL_LIGHT": """ + QPushButton { + background-color: #FFFFFF; + color: #172B4D; + border: 1px solid #DFE1E6; + font-size: 16px; + text-align: center; + border-radius: 0px; + margin 10px; + } + QPushButton:hover { + background-color: #EBECF0; + } + QPushButton:pressed { + background-color: #DFE1E6; + } + QPushButton:disabled { + background-color: #F4F5F7; + color: #B3BAC5; + border: 1px solid #EBECF0; + } + """, + + "RACK_TAB_BUTTON_ACTIVE": """ + QPushButton { + background-color: #5F738A; + color: #FFFFFF; + border: 1px solid #6F8298; + border-radius: 3px; + padding: 2px 8px; + font-size: 13px; + font-weight: 600; + text-align: center; + } + QPushButton:hover { + background-color: #6A7F97; + border-color: #7A8FA6; + } + QPushButton:pressed { + background-color: #52657A; + border-color: #62778D; + } + """, + + "RACK_TAB_BUTTON_NORMAL": """ + QPushButton { + background-color: #3A3F45; + color: #D9DEE3; + border: 1px solid #4A5058; + border-radius: 3px; + padding: 2px 8px; + font-size: 13px; + font-weight: 500; + text-align: center; + } + QPushButton:hover { + background-color: #434A53; + border-color: #5A616A; + } + QPushButton:pressed { + background-color: #4D5661; + border-color: #636D78; + } + """, + + "RACK_TAB_BUTTON_ACTIVE_LIGHT": """ + QPushButton { + background-color: #DCEBFF; + color: #0C66E4; + border: 1px solid #85B8FF; + border-radius: 3px; + padding: 2px 8px; + font-size: 13px; + font-weight: 600; + text-align: center; + } + QPushButton:hover { + background-color: #CFE1FF; + border-color: #6FA8FF; + } + QPushButton:pressed { + background-color: #BFD6FF; + border-color: #5D9BFF; + } + """, + + "RACK_TAB_BUTTON_NORMAL_LIGHT": """ + QPushButton { + background-color: #FFFFFF; + color: #172B4D; + border: 1px solid #DFE1E6; + border-radius: 3px; + padding: 2px 8px; + font-size: 13px; + font-weight: 500; + text-align: center; + } + QPushButton:hover { + background-color: #F4F5F7; + border-color: #CDD4DD; + } + QPushButton:pressed { + background-color: #EBECF0; + border-color: #C1C7D0; + } + """, +} + +_ICONS_DIR = Path(__file__).resolve().parents[1] / "components" / "icons" + + +def _replace_white_icons_with_black(style_text: str) -> str: + """Заменить *_white.png на *_black.png, только если файл чёрной иконки существует.""" + pattern = re.compile(r'([a-z0-9_]+)_white\.png', re.IGNORECASE) + + def _swap(match): + icon_base = match.group(1) + black_name = f"{icon_base}_black.png" + if (_ICONS_DIR / black_name).exists(): + return black_name + return match.group(0) + + return pattern.sub(_swap, style_text) + + +def _make_light_toggle_style(style_text: str, is_active: bool = False) -> str: + """Построить читаемый вариант светлой темы для стилей QToolButton панели инструментов зон.""" + result = _replace_white_icons_with_black(style_text) + replacements = [ + ("color: white;", f"color: {LIGHT_TEXT_PRIMARY};"), + ("color: #ffffff;", f"color: {LIGHT_TEXT_PRIMARY};"), + ("border: 1px solid #555555;", f"border: 1px solid {LIGHT_BORDER};"), + ("QToolButton:hover { background-color: #4a4a4a; }", f"QToolButton:hover {{ background-color: {LIGHT_HOVER}; }}"), + ("QToolButton:pressed { background-color: #5a5a5a; }", f"QToolButton:pressed {{ background-color: {LIGHT_PRESSED}; }}"), + ("background-color: #1f1f1f;", f"background-color: {LIGHT_PANEL};"), + ("color: #7a7a7a;", f"color: {LIGHT_TEXT_SECONDARY};"), + ("border: 1px solid #2a2a2a;", f"border: 1px solid {LIGHT_BORDER};"), + ] + for old, new in replacements: + result = result.replace(old, new) + + if is_active: + result = result.replace( + "background-color: rgba(255, 255, 255, 0.3);", + f"background-color: {LIGHT_ACCENT_SOFT};", + ) + result = result.replace("border: 1px solid #cfcfcf;", f"border: 1px solid {LIGHT_ACCENT_BORDER};") + result = result.replace("QToolButton:hover { background-color: #6a6a6a; }", "QToolButton:hover { background-color: #CFE1FF; }") + result = result.replace("QToolButton:pressed { background-color: #7a7a7a; }", "QToolButton:pressed { background-color: #B3D4FF; }") + else: + result = result.replace("background-color: transparent;", f"background-color: {LIGHT_PANEL};") + result = result.replace("color: #9a9a9a;", f"color: {LIGHT_TEXT_DISABLED};") + result = result.replace("color: #1a1a1a;", f"color: {LIGHT_TEXT_PRIMARY};") + return result + + +_ZONE_TOOL_TOGGLE_KEYS = ( + "MEASURE_TEXT_TOGGLE", + "MEASURE_TEXT_TOGGLE_ACTIVE", + "CREATE_ZONE_TOGGLE", + "CREATE_ZONE_TOGGLE_ACTIVE", + "DELETE_ZONE_TOGGLE", + "DELETE_ZONE_TOGGLE_ACTIVE", + "SELECT_ZONE_TOGGLE", + "SELECT_ZONE_TOGGLE_ACTIVE", + "EDIT_ZONE_TOGGLE", + "EDIT_ZONE_TOGGLE_ACTIVE", + "CREATE_NEW_MESH_TOGGLE", + "CREATE_NEW_MESH_TOGGLE_ACTIVE", + "USE_CURRENT_MESH_TOGGLE", + "USE_CURRENT_MESH_TOGGLE_ACTIVE", + "START_POINT_TOGGLE", + "START_POINT_TOGGLE_ACTIVE", + "REMEMBER_POINT_TOGGLE", + "REMEMBER_POINT_TOGGLE_ACTIVE", + "SET_GRID_STEP_TOGGLE", + "SET_GRID_STEP_TOGGLE_ACTIVE", + "CREATE_VOLUME_TOGGLE", + "CREATE_VOLUME_TOGGLE_ACTIVE", + "REMEMBER_VOLUME_TOGGLE", + "REMEMBER_VOLUME_TOGGLE_ACTIVE", + "MODEL_TOGGLE", + "MODEL_TOGGLE_ACTIVE", +) + +for _key in _ZONE_TOOL_TOGGLE_KEYS: + _style = BUTTONS.get(_key) + if _style: + BUTTONS[f"{_key}_LIGHT"] = _make_light_toggle_style( + _style, + is_active=_key.endswith("_ACTIVE"), + ) + + +# --------------------------------------------------------------------------- +# Module workflow notes +# --------------------------------------------------------------------------- +# +# 1) Назначение модуля: +# Определение всех стилей (QSS) для кнопок приложения: primary, secondary, +# standard, tab, visualization, toggle-кнопки инструментов (measure, zone, +# mesh, volume, model и др.) в тёмной и светлой темах. Содержит автогенерацию +# светлых вариантов toggle-стилей через _make_light_toggle_style(). +# +# 2) Зависимости модуля: +# Импорты: Path (pathlib), re +# Хост/базовый класс: нет (модуль-словарь) +# Внешние библиотеки: нет (стандартная библиотека) +# +# 3) Экспорт: +# Словарь BUTTONS: dict[str, str] — ключ → QSS-строка. +# Константы цветов светлой темы: LIGHT_TEXT_PRIMARY, LIGHT_TEXT_SECONDARY, +# LIGHT_TEXT_DISABLED, LIGHT_PANEL, LIGHT_BORDER, LIGHT_HOVER, +# LIGHT_PRESSED, LIGHT_ACCENT_SOFT, LIGHT_ACCENT_BORDER. +# +# 4) Состояние (поля): +# BUTTONS : dict[str, str] — основной реестр стилей кнопок. +# _ICONS_DIR : Path — путь к gui/components/icons/ (для подстановки иконок). +# _ZONE_TOOL_TOGGLE_KEYS: tuple[str] — ключи toggle-стилей для автогенерации. +# +# 5) Последовательность действий и вызовов: +# 1. Определение словаря BUTTONS с тёмными стилями. +# 2. _ICONS_DIR = Path → gui/components/icons/ +# 3. _replace_white_icons_with_black(style_text) — regex-замена *_white.png +# на *_black.png, если файл существует. +# 4. _make_light_toggle_style(style_text, is_active) — построение светлого +# варианта toggle-стилей: замена цветов, бордюров, hover/pressed. +# 5. Цикл по _ZONE_TOOL_TOGGLE_KEYS: для каждого ключа создаётся +# BUTTONS["{key}_LIGHT"] через _make_light_toggle_style(). +# +# 6) Побочные эффекты: +# Мутирует словарь BUTTONS при импорте модуля — добавляет *_LIGHT ключи. +# Проверяет наличие файлов иконок через Path.exists() на этапе импорта. +# +# 7) Границы ответственности: +# НЕ применяет стили к виджетам — только хранит QSS-строки. +# НЕ взаимодействует с theme_bus — переключение темы в StylableMixin. +# НЕ содержит стили для не-кнопочных виджетов. +# +# 8) Обработка ошибок: +# _replace_white_icons_with_black: если чёрная иконка не найдена — +# оригинальное имя сохраняется (fallback). +# Нет исключений при отсутствии ключей в BUTTONS.get(). +# +# 9) Инварианты и контракты: +# - Каждый toggle-стиль имеет пару: KEY и KEY_ACTIVE. +# - Автогенерированные светлые стили имеют суффикс _LIGHT. +# - Все QSS-строки — валидный Qt StyleSheet синтаксис. +# +# 10) Правило сопровождения: +# Новые toggle-стили добавлять в BUTTONS + в _ZONE_TOOL_TOGGLE_KEYS +# для автогенерации светлого варианта. Иконки _white.png и _black.png +# должны существовать парами в gui/components/icons/. При добавлении +# non-toggle стилей — добавлять *_LIGHT вариант вручную. diff --git a/Dispatch_V0.1.1/gui/styles/inputs.py b/Dispatch_V0.1.1/gui/styles/inputs.py new file mode 100644 index 0000000..4508407 --- /dev/null +++ b/Dispatch_V0.1.1/gui/styles/inputs.py @@ -0,0 +1,439 @@ +# -*- coding: utf-8 -*- +# gui/styles/inputs.py + +"""Стили для полей ввода""" + +INPUTS = { + + "COORDINATE_INPUT": """ + QDoubleSpinBox { + border: 1px solid palette(mid); + border-radius: 0px; + font-size: 14px; + background-color: palette(base); + color: palette(text); + padding: 4px 8px; + } + QDoubleSpinBox:focus { + border: 2px solid palette(highlight); + outline: none; + } + """, + + "COORDINATE_INPUT_DARK": """ + QDoubleSpinBox { + border: 1px solid #5a5a5a; + border-radius: 0px; + font-size: 14px; + background-color: #2e2e2e; + color: #f2f2f2; + padding: 4px 8px; + } + QDoubleSpinBox:focus { + border: 2px solid #3b82f6; + outline: none; + } + """, + + "COORDINATE_INPUT_LIGHT": """ + QDoubleSpinBox { + border: 1px solid #DFE1E6; + border-radius: 0px; + font-size: 14px; + background-color: #FFFFFF; + color: #172B4D; + padding: 4px 8px; + } + QDoubleSpinBox:focus { + border: 2px solid #0C66E4; + outline: none; + } + """, + + "COORDINATE_INPUT_DARK_ACTIVE": """ + QDoubleSpinBox { + border: 2px solid #3b82f6; + border-radius: 0px; + font-size: 14px; + background-color: #2e2e2e; + color: #f2f2f2; + padding: 4px 8px; + } + """, + + "COORDINATE_INPUT_LIGHT_ACTIVE": """ + QDoubleSpinBox { + border: 2px solid #0C66E4; + border-radius: 0px; + font-size: 14px; + background-color: #FFFFFF; + color: #172B4D; + padding: 4px 8px; + } + """, + + "ERROR_DIALOG_TEXT": """ + QTextEdit { + background-color: white; + border: 1px solid #ccc; + border-radius: 0px; + font-family: monospace; + font-size: 12px; + } + """, + + "FORM_WIDGET": """ + QWidget { + background-color: transparent; + } + QLabel { + color: palette(window-text); + font-size: 14px; + } + QLineEdit, QComboBox, QSpinBox, QDoubleSpinBox, QTextEdit { + border: 1px solid palette(mid); + border-radius: 0px; + font-size: 14px; + background-color: palette(base); + color: palette(text); + padding: 4px 8px; + } + QRadioButton { + color: palette(window-text); + background-color: transparent; + } + QRadioButton::indicator:unchecked { + background-color: palette(base); + border: 1px solid palette(mid); + border-radius: 0px; + } + QRadioButton::indicator:checked { + background-color: palette(base); + border: 2px solid palette(highlight); + border-radius: 0px; + } + QLineEdit:focus, QComboBox:focus, QSpinBox:focus, QDoubleSpinBox:focus, QTextEdit:focus { + border: 2px solid palette(highlight); + outline: none; + } + """, + + "FORM_WIDGET_DARK": """ + QWidget { + background-color: transparent; + } + QLabel, QRadioButton { + color: #f2f2f2; + font-size: 14px; + } + QLineEdit, QComboBox, QSpinBox, QDoubleSpinBox, QTextEdit { + border: 1px solid #5a5a5a; + border-radius: 0px; + font-size: 14px; + background-color: #2e2e2e; + color: #f2f2f2; + padding: 4px 8px; + } + QRadioButton::indicator:unchecked { + background-color: #2e2e2e; + border: 1px solid #7a7a7a; + border-radius: 0px; + } + QRadioButton::indicator:checked { + background-color: #2e2e2e; + border: 2px solid #3b82f6; + border-radius: 0px; + } + QLineEdit:focus, QComboBox:focus, QSpinBox:focus, QDoubleSpinBox:focus, QTextEdit:focus { + border: 2px solid #3b82f6; + outline: none; + } + """, + + "FORM_WIDGET_LIGHT": """ + QWidget { + background-color: transparent; + } + QLabel, QRadioButton { + color: #172B4D; + font-size: 14px; + } + QLineEdit, QComboBox, QSpinBox, QDoubleSpinBox, QTextEdit { + border: 1px solid #DFE1E6; + border-radius: 0px; + font-size: 14px; + background-color: #FFFFFF; + color: #172B4D; + padding: 4px 8px; + } + QRadioButton::indicator:unchecked { + background-color: #FFFFFF; + border: 1px solid #DFE1E6; + border-radius: 0px; + } + QRadioButton::indicator:checked { + background-color: #FFFFFF; + border: 2px solid #0C66E4; + border-radius: 0px; + } + QLineEdit:focus, QComboBox:focus, QSpinBox:focus, QDoubleSpinBox:focus, QTextEdit:focus { + border: 2px solid #0C66E4; + outline: none; + } + QLineEdit::placeholder { + color: #5E6C84; + } + """, + + "FORM_WIDGET_DARK_ACTIVE": """ + QWidget { + background-color: transparent; + } + QLabel { + color: #ffffff; + font-size: 14px; + } + QLineEdit, QComboBox, QSpinBox, QDoubleSpinBox, QTextEdit { + border: 2px solid #0078d4; + border-radius: 0px; + font-size: 14px; + background-color: #333; + color: #ffffff; + padding: 4px 8px; + } + QRadioButton { + color: #ffffff; + background-color: transparent; + } + QRadioButton::indicator:unchecked { + background-color: #666; + border: 1px solid #999; + border-radius: 0px; + } + QRadioButton::indicator:checked { + background-color: #ffffff; + border: 1px solid #999; + border-radius: 0px; + } + """, + "FORM_WIDGET_LIGHT_ACTIVE": """ + QWidget { + background-color: transparent; + } + QLabel { + color: #172B4D; + font-size: 14px; + } + QLineEdit, QComboBox, QSpinBox, QDoubleSpinBox, QTextEdit { + border: 2px solid #0C66E4; + border-radius: 0px; + font-size: 14px; + background-color: #FFFFFF; + color: #172B4D; + padding: 4px 8px; + } + QRadioButton { + color: #172B4D; + background-color: transparent; + } + QRadioButton::indicator:unchecked { + background-color: #FFFFFF; + border: 1px solid #DFE1E6; + border-radius: 0px; + } + QRadioButton::indicator:checked { + background-color: #FFFFFF; + border: 2px solid #0C66E4; + border-radius: 0px; + } + """, + "TEXT_INPUT": """ + QLineEdit { + border: 1px solid palette(mid); + border-radius: 0px; + font-size: 14px; + background-color: palette(base); + color: palette(text); + padding: 4px 8px; + } + QLineEdit:focus { + border: 2px solid palette(highlight); + outline: none; + } + """, + + "TEXT_INPUT_DARK": """ + QLineEdit { + border: 1px solid #5a5a5a; + border-radius: 0px; + font-size: 14px; + background-color: #2e2e2e; + color: #f2f2f2; + padding: 4px 8px; + } + QLineEdit:focus { + border: 2px solid #3b82f6; + outline: none; + } + """, + + "TEXT_INPUT_LIGHT": """ + QLineEdit { + border: 1px solid #DFE1E6; + border-radius: 0px; + font-size: 14px; + background-color: #FFFFFF; + color: #172B4D; + padding: 4px 8px; + } + QLineEdit:focus { + border: 2px solid #0C66E4; + outline: none; + } + QLineEdit::placeholder { + color: #5E6C84; + } + """, + + "TEXT_INPUT_DARK_ACTIVE": """ + QLineEdit { + border: 2px solid #0078d4; + border-radius: 0px; + font-size: 14px; + background-color: #333; + color: #ffffff; + padding: 4px 8px; + } + """, + + "TEXT_INPUT_LIGHT_ACTIVE": """ + QLineEdit { + border: 2px solid #0C66E4; + border-radius: 0px; + font-size: 14px; + background-color: #FFFFFF; + color: #172B4D; + padding: 4px 8px; + } + """, + + "RADIO_BUTTON": """ + QWidget { + background-color: transparent; + border: none; + } + QRadioButton { + color: palette(window-text); + background-color: transparent; + border: none; + } + QRadioButton::indicator:unchecked { + background-color: palette(base); + border: 1px solid palette(mid); + border-radius: 0px; + } + QRadioButton::indicator:checked { + background-color: palette(base); + border: 2px solid palette(highlight); + border-radius: 0px; + } + """, + + "RADIO_BUTTON_DARK": """ + QWidget { + background-color: transparent; + border: none; + } + QRadioButton { + color: #f2f2f2; + background-color: transparent; + border: none; + } + QRadioButton::indicator:unchecked { + background-color: #2e2e2e; + border: 1px solid #7a7a7a; + border-radius: 0px; + } + QRadioButton::indicator:checked { + background-color: #2e2e2e; + border: 2px solid #3b82f6; + border-radius: 0px; + } + """, + + "RADIO_BUTTON_LIGHT": """ + QWidget { + background-color: transparent; + border: none; + } + QRadioButton { + color: #172B4D; + background-color: transparent; + border: none; + } + QRadioButton::indicator:unchecked { + background-color: #FFFFFF; + border: 1px solid #DFE1E6; + border-radius: 0px; + } + QRadioButton::indicator:checked { + background-color: #FFFFFF; + border: 2px solid #0C66E4; + border-radius: 0px; + } + """, +} + + + + +# --------------------------------------------------------------------------- +# Module workflow notes +# --------------------------------------------------------------------------- +# +# 1) Назначение модуля: +# Определение QSS-стилей для полей ввода: координатные input'ы +# (QDoubleSpinBox), текстовые поля (QLineEdit), формы (QLineEdit + +# QComboBox + QSpinBox + QRadioButton + QTextEdit), радио-кнопки. +# Поддержка тем: стандартная (palette), dark, light, active-варианты. +# +# 2) Зависимости модуля: +# Импорты: нет +# Хост/базовый класс: нет (модуль-словарь) +# Внешние библиотеки: нет +# +# 3) Экспорт: +# Словарь INPUTS: dict[str, str] — ключ → QSS-строка. +# Ключи: COORDINATE_INPUT, COORDINATE_INPUT_DARK, COORDINATE_INPUT_LIGHT, +# ERROR_DIALOG_TEXT, FORM_WIDGET, FORM_WIDGET_DARK, FORM_WIDGET_LIGHT, +# FORM_WIDGET_DARK_ACTIVE, FORM_WIDGET_LIGHT_ACTIVE, +# TEXT_INPUT, TEXT_INPUT_DARK, TEXT_INPUT_LIGHT, +# TEXT_INPUT_DARK_ACTIVE, TEXT_INPUT_LIGHT_ACTIVE, +# RADIO_BUTTON, RADIO_BUTTON_DARK, RADIO_BUTTON_LIGHT. +# +# 4) Состояние (поля): +# INPUTS: dict[str, str] — единственный экспортируемый словарь. +# +# 5) Последовательность действий и вызовов: +# Нет вызовов; модуль содержит только декларативные QSS-строки. +# Импортируется в gui/styles/__init__.py → APP_STYLES.update(INPUTS). +# +# 6) Побочные эффекты: +# Нет побочных эффектов при импорте. +# +# 7) Границы ответственности: +# НЕ применяет стили — только хранит QSS-строки. +# НЕ содержит стили для кнопок, меток или контейнеров. +# +# 8) Обработка ошибок: +# Нет; модуль декларативный. +# +# 9) Инварианты и контракты: +# - Каждый ключ имеет варианты: базовый (palette), _DARK, _LIGHT. +# - Active-варианты имеют суффикс _DARK_ACTIVE / _LIGHT_ACTIVE. +# - Все QSS-строки — валидный Qt StyleSheet синтаксис. +# +# 10) Правило сопровождения: +# Новые стили полей ввода добавлять сюда, соблюдая паттерн +# KEY / KEY_DARK / KEY_LIGHT. Active-варианты — по необходимости. +# Не дублировать стили кнопок (→ buttons.py) или меток (→ labels.py). diff --git a/Dispatch_V0.1.1/gui/styles/labels.py b/Dispatch_V0.1.1/gui/styles/labels.py new file mode 100644 index 0000000..7da902a --- /dev/null +++ b/Dispatch_V0.1.1/gui/styles/labels.py @@ -0,0 +1,270 @@ +# -*- coding: utf-8 -*- +# gui/styles/labels.py +"""Стили для QLabel и текстовых меток""" + +LABELS = { + + "ERROR_LABEL": """ + color: #c0392b; + font-weight: bold; + """, + + "INFO_LABEL": """ + QLabel { + font-size: 22px; + font-weight: 600; + color: palette(window-text); + background-color: transparent; + } + """, + "INFO_LABEL_LIGHT": """ + QLabel { + font-size: 22px; + font-weight: 600; + color: #172B4D; + background-color: transparent; + } + """, + + "INFO_NOTE_LABEL": """ + color: #42526E; + font-size: 12px; + """, + + "INFO_TEXT_BORDERED": """ + QLabel { + font-size: 14px; + font-weight: 600; + color: palette(window-text); + background-color: palette(base); + border: 1px solid palette(mid); + border-radius: 0px; + padding: 4px 8px; + qproperty-alignment: 'AlignVCenter | AlignLeft'; + } + """, + "INFO_TEXT_BORDERED_LIGHT": """ + QLabel { + font-size: 14px; + font-weight: 600; + color: #172B4D; + background-color: #FFFFFF; + border: 1px solid #DFE1E6; + border-radius: 0px; + padding: 4px 8px; + qproperty-alignment: 'AlignVCenter | AlignLeft'; + } + """, + + "INFO_TEXT_GRAY": """ + QLabel { + font-size: 14px; + font-weight: 600; + color: palette(window-text); + border: none; + qproperty-alignment: 'AlignBottom | AlignLeft'; + } + """, + + "INFO_TEXT_GRAY_SMALL": """ + QLabel { + font-size: 12px; + font-weight: 600; + color: palette(window-text); + border: none; + qproperty-alignment: 'AlignBottom | AlignLeft'; + } + """, + + "INFO_TEXT": """ + QLabel { + font-size: 14px; + font-weight: 600; + color: palette(window-text); + border: none; + padding: 8px 0px; + qproperty-alignment: 'AlignBottom | AlignLeft'; + } + """, + + "MODULE_TITLE": """ + QLabel { + font-size: 32px; + font-weight: bold; + color: palette(window-text); + margin-bottom: 10px; + background-color: transparent; + } + """, + "MODULE_TITLE_LIGHT": """ + QLabel { + font-size: 32px; + font-weight: bold; + color: #172B4D; + margin-bottom: 10px; + background-color: transparent; + } + """, + + "PROJECT_TREE_LABEL": """ + QLabel { + font-size: 14px; + font-weight: 600; + color: palette(window-text); + background-color: palette(base); + border: 1px solid palette(mid); + border-radius: 0px; + + min-width: 400px; + min-height: 200px; + } + """, + + "STATUS_LABEL": """ + QLabel { + font-size: 14px; + color: palette(window-text); + background-color: palette(base); + border: 1px solid palette(mid); + border-radius: 0px; + padding: 2px 8px; + } + """, + "STATUS_LABEL_LIGHT": """ + QLabel { + font-size: 14px; + color: #172B4D; + background-color: #FFFFFF; + border: 1px solid #DFE1E6; + border-radius: 0px; + padding: 2px 8px; + } + """, + + "TITLE_BAR": """ + QLabel { + background-color: #3C3C3C; + color: palette(button-text); + border: 1px solid palette(mid); + border-top-left-radius: 0px; + border-top-right-radius: 0px; + border-bottom-left-radius: 0px; + border-bottom-right-radius: 0px; + font-size: 16px; + text-align: center; + } + """, + "TITLE_BAR_LIGHT": """ + QLabel { + background-color: #FFFFFF; + color: #172B4D; + border: 1px solid #DFE1E6; + border-top-left-radius: 0px; + border-top-right-radius: 0px; + border-bottom-left-radius: 0px; + border-bottom-right-radius: 0px; + font-size: 16px; + text-align: center; + } + """, + "WIDGET_LABEL": """ + QLabel { + font-size: 48px; + font-weight: bold; + color: palette(window-text); + background-color: palette(base); + border: 1px solid palette(mid); + border-radius: 0px; + min-width: 400px; + min-height: 200px; + } + """, + + "ZONE_PROPERTIES_TITLE": """ + QLabel { + background-color: #3C3C3C; + color: palette(button-text); + border-left: 0px; + border-right: 0px; + border-top: 0px; + border-bottom: 1px solid palette(mid); + border-top-left-radius: 0px; + border-top-right-radius: 0px; + border-bottom-left-radius: 0px; + border-bottom-right-radius: 0px; + font-size: 16px; + text-align: center; + } + """, + "ZONE_PROPERTIES_TITLE_LIGHT": """ + QLabel { + background-color: #F4F5F7; + color: #172B4D; + border-left: 0px; + border-right: 0px; + border-top: 0px; + border-bottom: 1px solid #DFE1E6; + border-top-left-radius: 0px; + border-top-right-radius: 0px; + border-bottom-left-radius: 0px; + border-bottom-right-radius: 0px; + font-size: 16px; + text-align: center; + } + """, +} + + + + + +# --------------------------------------------------------------------------- +# Module workflow notes +# --------------------------------------------------------------------------- +# +# 1) Назначение модуля: +# Определение QSS-стилей для QLabel и текстовых меток: заголовки модулей, +# информационные метки, статусные строки, title bar'ы, зонные заголовки +# свойств, метки проектного дерева и стилизованные виджет-метки. +# Поддержка тем: стандартная (palette) и _LIGHT-варианты. +# +# 2) Зависимости модуля: +# Импорты: нет +# Хост/базовый класс: нет (модуль-словарь) +# Внешние библиотеки: нет +# +# 3) Экспорт: +# Словарь LABELS: dict[str, str] — ключ → QSS-строка. +# Ключи: ERROR_LABEL, INFO_LABEL, INFO_LABEL_LIGHT, INFO_NOTE_LABEL, +# INFO_TEXT_BORDERED, INFO_TEXT_BORDERED_LIGHT, INFO_TEXT_GRAY, +# INFO_TEXT_GRAY_SMALL, INFO_TEXT, MODULE_TITLE, MODULE_TITLE_LIGHT, +# PROJECT_TREE_LABEL, STATUS_LABEL, STATUS_LABEL_LIGHT, +# TITLE_BAR, TITLE_BAR_LIGHT, WIDGET_LABEL, +# ZONE_PROPERTIES_TITLE, ZONE_PROPERTIES_TITLE_LIGHT. +# +# 4) Состояние (поля): +# LABELS: dict[str, str] — единственный экспортируемый словарь. +# +# 5) Последовательность действий и вызовов: +# Нет вызовов; модуль декларативный. +# Импортируется в gui/styles/__init__.py → APP_STYLES.update(LABELS). +# +# 6) Побочные эффекты: +# Нет побочных эффектов при импорте. +# +# 7) Границы ответственности: +# НЕ применяет стили — только хранит QSS-строки. +# НЕ содержит стили для кнопок (→ buttons.py) или полей ввода (→ inputs.py). +# +# 8) Обработка ошибок: +# Нет; модуль декларативный. +# +# 9) Инварианты и контракты: +# - Каждый ключ с поддержкой светлой темы имеет суффикс _LIGHT. +# - Все QSS-строки — валидный Qt StyleSheet синтаксис. +# - ERROR_LABEL использует inline-стиль (без селектора QLabel {}). +# +# 10) Правило сопровождения: +# Новые стили меток добавлять сюда по паттерну KEY / KEY_LIGHT. +# Для стилей с qproperty-alignment использовать строковые значения +# Qt ('AlignVCenter | AlignLeft'). Не смешивать стили кнопок и меток. diff --git a/Dispatch_V0.1.1/gui/styles/login_dialog.py b/Dispatch_V0.1.1/gui/styles/login_dialog.py new file mode 100644 index 0000000..a291656 --- /dev/null +++ b/Dispatch_V0.1.1/gui/styles/login_dialog.py @@ -0,0 +1,250 @@ +# -*- coding: utf-8 -*- +# gui/styles/login_dialog.py + +"""Стили для диалога авторизации.""" + +LOGIN_DIALOG = { + + "LOGIN_DIALOG_DARK": """ + QDialog { + background-color: #1e1e1e; + } + """, + + "LOGIN_DIALOG_LIGHT": """ + QDialog { + background-color: #FFFFFF; + } + """, + + "LOGIN_TITLE_DARK": """ + QLabel { + color: #f2f2f2; + font-size: 16px; + font-weight: 600; + background-color: transparent; + } + """, + + "LOGIN_TITLE_LIGHT": """ + QLabel { + color: #172B4D; + font-size: 16px; + font-weight: 600; + background-color: transparent; + } + """, + + "LOGIN_FIELD_LABEL_DARK": """ + QLabel { + color: #b0b0b0; + font-size: 13px; + font-weight: 500; + background-color: transparent; + } + """, + + "LOGIN_FIELD_LABEL_LIGHT": """ + QLabel { + color: #5E6C84; + font-size: 13px; + font-weight: 500; + background-color: transparent; + } + """, + + "LOGIN_INPUT_DARK": """ + QFrame { + background-color: #2e2e2e; + border: 1px solid #5a5a5a; + border-radius: 0px; + } + QLineEdit, QTextEdit { + background-color: #2e2e2e; + color: #f2f2f2; + border: none; + font-size: 14px; + padding: 4px 8px; + } + QLineEdit:focus, QTextEdit:focus { + border: 1px solid #3b82f6; + } + QLineEdit::placeholder { + color: #7a7a7a; + } + """, + + "LOGIN_INPUT_LIGHT": """ + QFrame { + background-color: #FFFFFF; + border: 1px solid #DFE1E6; + border-radius: 0px; + } + QLineEdit, QTextEdit { + background-color: #FFFFFF; + color: #172B4D; + border: none; + font-size: 14px; + padding: 4px 8px; + } + QLineEdit:focus, QTextEdit:focus { + border: 1px solid #0C66E4; + } + QLineEdit::placeholder { + color: #5E6C84; + } + """, + + "LOGIN_INPUT_ERROR_DARK": """ + QFrame { + background-color: #2e2e2e; + border: 2px solid #e74c3c; + border-radius: 0px; + } + QLineEdit, QTextEdit { + background-color: #2e2e2e; + color: #f2f2f2; + border: none; + font-size: 14px; + padding: 4px 8px; + } + QLineEdit:focus, QTextEdit:focus { + border: 1px solid #e74c3c; + } + QLineEdit::placeholder { + color: #7a7a7a; + } + """, + + "LOGIN_INPUT_ERROR_LIGHT": """ + QFrame { + background-color: #FFFFFF; + border: 2px solid #e74c3c; + border-radius: 0px; + } + QLineEdit, QTextEdit { + background-color: #FFFFFF; + color: #172B4D; + border: none; + font-size: 14px; + padding: 4px 8px; + } + QLineEdit:focus, QTextEdit:focus { + border: 1px solid #e74c3c; + } + QLineEdit::placeholder { + color: #5E6C84; + } + """, + + "LOGIN_ERROR_LABEL_DARK": """ + QLabel { + color: #e74c3c; + font-size: 12px; + background-color: transparent; + } + """, + + "LOGIN_ERROR_LABEL_LIGHT": """ + QLabel { + color: #e74c3c; + font-size: 12px; + background-color: transparent; + } + """, + + "LOGIN_NAV_BUTTON_DARK": """ + QPushButton { + background-color: #3c3c3c; + color: white; + border: none; + font-size: 16px; + text-align: center; + border-radius: 0px; + } + QPushButton:hover { background-color: #4a4a4a; } + QPushButton:pressed { background-color: #5a5a5a; } + """, + + "LOGIN_NAV_BUTTON_DARK_ACTIVE": """ + QPushButton { + background-color: #5a5a5a; + color: white; + border: none; + font-size: 16px; + text-align: center; + border-radius: 0px; + } + QPushButton:hover { background-color: #6a6a6a; } + QPushButton:pressed { background-color: #7a7a7a; } + """, + + "LOGIN_NAV_BUTTON_LIGHT": """ + QPushButton { + background-color: #FFFFFF; + color: #172B4D; + border: 1px solid #DFE1E6; + font-size: 16px; + border-radius: 0px; + text-align: center; + } + QPushButton:hover { + background-color: #EBECF0; + border-color: #DFE1E6; + } + QPushButton:pressed { background-color: #DFE1E6; } + """, + + "LOGIN_NAV_BUTTON_LIGHT_ACTIVE": """ + QPushButton { + background-color: #FFFFFF; + color: #0C66E4; + border: 1px solid #85B8FF; + font-size: 16px; + border-radius: 0px; + font-weight: 600; + text-align: center; + } + QPushButton:hover { background-color: #EBECF0; } + QPushButton:pressed { background-color: #DFE1E6; } + """, + + "LOGIN_CANCEL_BUTTON": """ + QPushButton { + background-color: transparent; + color: #6B84A5; + border: 1px solid #D7DEE8; + border-radius: 0px; + font-size: 14px; + font-weight: 500; + padding: 8px 16px; + } + QPushButton:hover { + background-color: rgba(12, 102, 228, 0.06); + border-color: #A8B8CF; + } + QPushButton:pressed { + background-color: rgba(12, 102, 228, 0.10); + } + """, + + "LOGIN_SUBMIT_BUTTON": """ + QPushButton { + background-color: #0C66E4; + color: #FFFFFF; + border: 1px solid #0C66E4; + border-radius: 0px; + font-size: 14px; + font-weight: 500; + padding: 8px 16px; + } + QPushButton:hover { + background-color: #0055CC; + border-color: #0055CC; + } + QPushButton:pressed { + background-color: #09326C; + border-color: #09326C; + } + """, +} diff --git a/Dispatch_V0.1.1/gui/styles/profile_dossier.py b/Dispatch_V0.1.1/gui/styles/profile_dossier.py new file mode 100644 index 0000000..d81c8bf --- /dev/null +++ b/Dispatch_V0.1.1/gui/styles/profile_dossier.py @@ -0,0 +1,199 @@ +# -*- coding: utf-8 -*- +# gui/styles/profile_dossier.py + +"""Стили карточки досье сотрудника (Profile Dossier).""" + +PROFILE_DOSSIER: dict[str, str] = { + # ── Контейнер страницы (самый глубокий фон — как TICKET_SHELL_ROOT) ── + "DOSSIER_SPREAD_DARK": """ + QWidget { + background-color: #232323; + border: none; + border-radius: 0px; + } + """, + "DOSSIER_SPREAD_LIGHT": """ + QWidget { + background-color: #F4F5F7; + border: none; + border-radius: 0px; + } + """, + + # ── Колонка (средний слой — как TICKET_BOARD_COLUMN_BODY) ──── + "DOSSIER_COLUMN_DARK": """ + QWidget { + background-color: #2B2B2B; + } + """, + "DOSSIER_COLUMN_LIGHT": """ + QWidget { + background-color: #F4F5F7; + } + """, + + # ── Блок секции данных (верхний слой — как TASK_CARD) ──────── + "DOSSIER_DATA_BLOCK_DARK": """ + QWidget { + background-color: #3C3C3C; + border: 1px solid #5A5A5A; + } + """, + "DOSSIER_DATA_BLOCK_LIGHT": """ + QWidget { + background-color: #FFFFFF; + border: 1px solid #DFE1E6; + } + """, + + # ── Вертикальный разделитель ───────────────────────────────── + "DOSSIER_DIVIDER_DARK": """ + QWidget { + background-color: #444444; + border: none; + } + """, + "DOSSIER_DIVIDER_LIGHT": """ + QWidget { + background-color: #DFE1E6; + border: none; + } + """, + + # ── Внутренний контейнер (сброс наследования от родителя) ─────── + "DOSSIER_INNER_DARK": """ + QWidget { + background: transparent; + border: none; + } + """, + "DOSSIER_INNER_LIGHT": """ + QWidget { + background: transparent; + border: none; + } + """, + + + # ── Текст заголовка секции ─────────────────────────────────── + "DOSSIER_TITLE_HEADER_DARK": """ + QLabel { + color: #7CB9FF; + font-size: 14px; + font-weight: 600; + padding: 4px 10px; + background: transparent; + border: none; + qproperty-alignment: 'AlignVCenter | AlignHorizontalCenter'; + } + """, + "DOSSIER_TITLE_HEADER_LIGHT": """ + QLabel { + color: #0C66E4; + font-size: 14px; + font-weight: 600; + padding: 4px 10px; + background: transparent; + border: none; + qproperty-alignment: 'AlignLeft | AlignVCenter'; + } + """, + "DOSSIER_SECTION_TITLE_DARK": """ + QLabel { + color: #7CB9FF; + font-size: 40px; + font-weight: 600; + padding: 4px 10px; + background: transparent; + border: none; + qproperty-alignment: 'AlignVCenter | AlignHorizontalCenter'; + } + """, + "DOSSIER_SECTION_TITLE_LIGHT": """ + QLabel { + color: #0C66E4; + font-size: 14px; + font-weight: 600; + padding: 4px 10px; + background: transparent; + border: none; + qproperty-alignment: 'AlignLeft | AlignVCenter'; + } + """, + + # ── Контейнер фотографии ───────────────────────────────────── + "DOSSIER_PHOTO_DARK": """ + QWidget { + background-color: #363636; + border: 1px solid #4a4a4a; + } + """, + "DOSSIER_PHOTO_LIGHT": """ + QWidget { + background-color: #F4F5F7; + border: 1px solid #DFE1E6; + } + """, + + # ── Подпись поля ───────────────────────────────────────────── + "DOSSIER_FIELD_LABEL_DARK": """ + QLabel { + color: #8B95A5; + font-size: 13px; + padding: 2px 6px; + background: transparent; + border: none; + qproperty-alignment: 'AlignLeft | AlignTop'; + } + """, + "DOSSIER_FIELD_LABEL_LIGHT": """ + QLabel { + color: #6B778C; + font-size: 13px; + padding: 2px 6px; + background: transparent; + border: none; + qproperty-alignment: 'AlignLeft | AlignTop'; + } + """, + + # ── Значение поля ──────────────────────────────────────────── + "DOSSIER_FIELD_VALUE_DARK": """ + QLabel { + color: #DCDCDC; + font-size: 14px; + padding: 2px 18px; + background: transparent; + border: none; + qproperty-alignment: 'AlignLeft'; + } + """, + "DOSSIER_FIELD_VALUE_LIGHT": """ + QLabel { + color: #172B4D; + font-size: 14px; + padding: 2px 18px; + background: transparent; + border: none; + qproperty-alignment: 'AlignLeft'; + } + """, + + # ── Текст «Фото отсутствует» ──────────────────────────────── + "DOSSIER_NO_PHOTO_DARK": """ + QLabel { + color: #2B2B2B; + font-size: 13px; + background: transparent; + border: none; + } + """, + "DOSSIER_NO_PHOTO_LIGHT": """ + QLabel { + color: #F7F8F9; + font-size: 13px; + background: transparent; + border: none; + } + """, +} diff --git a/Dispatch_V0.1.1/gui/styles/quality_assurance.py b/Dispatch_V0.1.1/gui/styles/quality_assurance.py new file mode 100644 index 0000000..2519796 --- /dev/null +++ b/Dispatch_V0.1.1/gui/styles/quality_assurance.py @@ -0,0 +1,342 @@ +# -*- coding: utf-8 -*- +# gui/styles/quality_assurance.py + +"""Styles for Quality Assurance pages.""" + + +QUALITY_ASSURANCE: dict[str, str] = { + "QA_PAGE_TITLE_DARK": """ + QLabel { + color: #E6EDF7; + font-size: 24px; + font-weight: 700; + padding: 0px; + background: transparent; + border: none; + qproperty-alignment: 'AlignLeft | AlignVCenter'; + } + """, + "QA_PAGE_TITLE_LIGHT": """ + QLabel { + color: #172B4D; + font-size: 24px; + font-weight: 700; + padding: 0px; + background: transparent; + border: none; + qproperty-alignment: 'AlignLeft | AlignVCenter'; + } + """, + "QA_SECTION_TITLE_DARK": """ + QLabel { + color: #E6EDF7; + font-size: 22px; + font-weight: 700; + padding: 0px; + background: transparent; + border: none; + qproperty-alignment: 'AlignLeft | AlignVCenter'; + } + """, + "QA_SECTION_TITLE_LIGHT": """ + QLabel { + color: #172B4D; + font-size: 22px; + font-weight: 700; + padding: 0px; + background: transparent; + border: none; + qproperty-alignment: 'AlignLeft | AlignVCenter'; + } + """, + "QA_PLACEHOLDER_DESCRIPTION_DARK": """ + QLabel { + color: #B8C0CC; + font-size: 16px; + font-weight: 600; + padding: 0px; + background: transparent; + border: none; + qproperty-alignment: 'AlignLeft | AlignTop'; + } + """, + "QA_PLACEHOLDER_DESCRIPTION_LIGHT": """ + QLabel { + color: #42526E; + font-size: 16px; + font-weight: 600; + padding: 0px; + background: transparent; + border: none; + qproperty-alignment: 'AlignLeft | AlignTop'; + } + """, + "QA_PLACEHOLDER_NOTE_DARK": """ + QLabel { + color: #8C9BAB; + font-size: 14px; + font-weight: 600; + padding: 0px; + background: transparent; + border: none; + qproperty-alignment: 'AlignLeft | AlignTop'; + } + """, + "QA_PLACEHOLDER_NOTE_LIGHT": """ + QLabel { + color: #5E6C84; + font-size: 14px; + font-weight: 600; + padding: 0px; + background: transparent; + border: none; + qproperty-alignment: 'AlignLeft | AlignTop'; + } + """, + "QA_ADMIN_INFO_COMBO_DARK": """ + QComboBox { + border: 1px solid #5a5a5a; + border-radius: 0px; + font-size: 14px; + background-color: #2e2e2e; + color: #A7B3C2; + padding: 4px 8px; + combobox-popup: 0; + } + QComboBox:focus { + border: 2px solid #3b82f6; + outline: none; + } + QComboBox QAbstractItemView { + background-color: #2e2e2e; + color: #E6EDF7; + selection-background-color: #3b82f6; + selection-color: #FFFFFF; + border: 1px solid #5a5a5a; + outline: none; + } + """, + "QA_ADMIN_INFO_COMBO_LIGHT": """ + QComboBox { + border: 1px solid #DFE1E6; + border-radius: 0px; + font-size: 14px; + background-color: #FFFFFF; + color: #7A869A; + padding: 4px 8px; + combobox-popup: 0; + } + QComboBox:focus { + border: 2px solid #0C66E4; + outline: none; + } + QComboBox QAbstractItemView { + background-color: #FFFFFF; + color: #172B4D; + selection-background-color: #0C66E4; + selection-color: #FFFFFF; + border: 1px solid #DFE1E6; + outline: none; + } + """, + "QA_DESIGN_BOARD_NEW_DOCUMENT_REQUEST": """ + QPushButton { + background-color: #6B86CC; + color: #FFFFFF; + border: 1px solid rgba(255, 255, 255, 0.16); + border-radius: 0px; + padding: 20px 16px; + font-size: 18px; + font-weight: 700; + text-align: center; + } + QPushButton:hover { + background-color: #6B86CC; + border: 1px solid rgba(255, 255, 255, 0.34); + } + QPushButton:pressed { + background-color: #6B86CC; + border: 1px solid rgba(0, 0, 0, 0.22); + } + QPushButton:disabled { + background-color: #6B86CC; + color: rgba(255, 255, 255, 0.70); + border: 1px solid rgba(255, 255, 255, 0.12); + } + """, + "QA_DESIGN_BOARD_UNDER_REVIEW": """ + QPushButton { + background-color: #AD71DD; + color: #FFFFFF; + border: 1px solid rgba(255, 255, 255, 0.16); + border-radius: 0px; + padding: 20px 16px; + font-size: 18px; + font-weight: 700; + text-align: center; + } + QPushButton:hover { + background-color: #AD71DD; + border: 1px solid rgba(255, 255, 255, 0.34); + } + QPushButton:pressed { + background-color: #AD71DD; + border: 1px solid rgba(0, 0, 0, 0.22); + } + QPushButton:disabled { + background-color: #AD71DD; + color: rgba(255, 255, 255, 0.70); + border: 1px solid rgba(255, 255, 255, 0.12); + } + """, + "QA_DESIGN_BOARD_IN_DRAFTING": """ + QPushButton { + background-color: #6B86CC; + color: #FFFFFF; + border: 1px solid rgba(255, 255, 255, 0.16); + border-radius: 0px; + padding: 20px 16px; + font-size: 18px; + font-weight: 700; + text-align: center; + } + QPushButton:hover { + background-color: #6B86CC; + border: 1px solid rgba(255, 255, 255, 0.34); + } + QPushButton:pressed { + background-color: #6B86CC; + border: 1px solid rgba(0, 0, 0, 0.22); + } + QPushButton:disabled { + background-color: #6B86CC; + color: rgba(255, 255, 255, 0.70); + border: 1px solid rgba(255, 255, 255, 0.12); + } + """, + "QA_DESIGN_BOARD_IN_APPROVAL": """ + QPushButton { + background-color: #C2945E; + color: #FFFFFF; + border: 1px solid rgba(255, 255, 255, 0.16); + border-radius: 0px; + padding: 20px 16px; + font-size: 18px; + font-weight: 700; + text-align: center; + } + QPushButton:hover { + background-color: #C2945E; + border: 1px solid rgba(255, 255, 255, 0.34); + } + QPushButton:pressed { + background-color: #C2945E; + border: 1px solid rgba(0, 0, 0, 0.22); + } + QPushButton:disabled { + background-color: #C2945E; + color: rgba(255, 255, 255, 0.70); + border: 1px solid rgba(255, 255, 255, 0.12); + } + """, + "QA_DESIGN_BOARD_TO_BE_SIGNED": """ + QPushButton { + background-color: #D86727; + color: #FFFFFF; + border: 1px solid rgba(255, 255, 255, 0.16); + border-radius: 0px; + padding: 20px 16px; + font-size: 18px; + font-weight: 700; + text-align: center; + } + QPushButton:hover { + background-color: #D86727; + border: 1px solid rgba(255, 255, 255, 0.34); + } + QPushButton:pressed { + background-color: #D86727; + border: 1px solid rgba(0, 0, 0, 0.22); + } + QPushButton:disabled { + background-color: #D86727; + color: rgba(255, 255, 255, 0.70); + border: 1px solid rgba(255, 255, 255, 0.12); + } + """, + "QA_DESIGN_BOARD_PUBLISHED": """ + QPushButton { + background-color: #58B522; + color: #FFFFFF; + border: 1px solid rgba(255, 255, 255, 0.16); + border-radius: 0px; + padding: 20px 16px; + font-size: 18px; + font-weight: 700; + text-align: center; + } + QPushButton:hover { + background-color: #58B522; + border: 1px solid rgba(255, 255, 255, 0.34); + } + QPushButton:pressed { + background-color: #58B522; + border: 1px solid rgba(0, 0, 0, 0.22); + } + QPushButton:disabled { + background-color: #58B522; + color: rgba(255, 255, 255, 0.70); + border: 1px solid rgba(255, 255, 255, 0.12); + } + """, + "QA_DESIGN_BOARD_CLOSED_ARCHIVED": """ + QPushButton { + background-color: #96A0B6; + color: #FFFFFF; + border: 1px solid rgba(255, 255, 255, 0.16); + border-radius: 0px; + padding: 20px 16px; + font-size: 18px; + font-weight: 700; + text-align: center; + } + QPushButton:hover { + background-color: #96A0B6; + border: 1px solid rgba(255, 255, 255, 0.34); + } + QPushButton:pressed { + background-color: #96A0B6; + border: 1px solid rgba(0, 0, 0, 0.22); + } + QPushButton:disabled { + background-color: #96A0B6; + color: rgba(255, 255, 255, 0.70); + border: 1px solid rgba(255, 255, 255, 0.12); + } + """, + "QA_DESIGN_BOARD_CANCELLED": """ + QPushButton { + background-color: #DB3C3C; + color: #FFFFFF; + border: 1px solid rgba(255, 255, 255, 0.16); + border-radius: 0px; + padding: 20px 16px; + font-size: 18px; + font-weight: 700; + text-align: center; + } + QPushButton:hover { + background-color: #DB3C3C; + border: 1px solid rgba(255, 255, 255, 0.34); + } + QPushButton:pressed { + background-color: #DB3C3C; + border: 1px solid rgba(0, 0, 0, 0.22); + } + QPushButton:disabled { + background-color: #DB3C3C; + color: rgba(255, 255, 255, 0.70); + border: 1px solid rgba(255, 255, 255, 0.12); + } + """, +} diff --git a/Dispatch_V0.1.1/gui/styles/quality_assurance_word_workspace.py b/Dispatch_V0.1.1/gui/styles/quality_assurance_word_workspace.py new file mode 100644 index 0000000..795ccea --- /dev/null +++ b/Dispatch_V0.1.1/gui/styles/quality_assurance_word_workspace.py @@ -0,0 +1,590 @@ +# -*- coding: utf-8 -*- +# gui/styles/quality_assurance_word_workspace.py +"""Стили ленты QA Word workspace. + +Словарь читается сверху вниз в порядке визуальной иерархии ленты: + + 1. Поверхности (ribbon surface, topbar, tab strip, group, style card). + 2. Кнопки верхней панели (badge, topbar button, topbar toggle). + 3. Вкладки (file tab, tab, tab active). + 4. Инструменты ribbon (large button, tool button). + 5. Поля ввода (combo, search). + 6. Текстовые метки (topbar text, topbar title, group title, + style text, style caption). + +Каждая пара `*_DARK` / `*_LIGHT` — две темы одного ключа, записанные +рядом. Все значения (цвета, размеры, вес, отступы) указаны прямо +в QSS-строке, без промежуточных helper-функций. +""" + +QUALITY_ASSURANCE_WORD_WORKSPACE = { + # ===================================================================== + # 1. Поверхности + # ===================================================================== + + # --- Общая поверхность ленты ---------------------------------------- + "QA_WORD_WORKSPACE_RIBBON_SURFACE_DARK": """ + QWidget { + background-color: #1E1F21; + border: 1px solid #2B2D31; + border-radius: 0px; + } + """, + "QA_WORD_WORKSPACE_RIBBON_SURFACE_LIGHT": """ + QWidget { + background-color: #F4F6F8; + border: 1px solid #D8DEE6; + border-radius: 0px; + } + """, + + # --- Верхняя панель -------------------------------------------------- + "QA_WORD_WORKSPACE_TOPBAR_DARK": """ + QWidget { + background-color: #1F2023; + border: 1px solid #1F2023; + border-radius: 0px; + } + """, + "QA_WORD_WORKSPACE_TOPBAR_LIGHT": """ + QWidget { + background-color: #FFFFFF; + border: 1px solid #D6DAE1; + border-radius: 0px; + } + """, + + # --- Полоса вкладок -------------------------------------------------- + "QA_WORD_WORKSPACE_TAB_STRIP_DARK": """ + QWidget { + background-color: #242629; + border: 1px solid #34373B; + border-radius: 0px; + } + """, + "QA_WORD_WORKSPACE_TAB_STRIP_LIGHT": """ + QWidget { + background-color: #FFFFFF; + border: 1px solid #D6DAE1; + border-radius: 0px; + } + """, + + # --- Ribbon-группа --------------------------------------------------- + "QA_WORD_WORKSPACE_GROUP_DARK": """ + QWidget { + background-color: #242629; + border: 1px solid #3A3D42; + border-radius: 0px; + } + """, + "QA_WORD_WORKSPACE_GROUP_LIGHT": """ + QWidget { + background-color: #FFFFFF; + border: 1px solid #D8DEE6; + border-radius: 0px; + } + """, + + # --- Карточка стиля (обычная / активная) ----------------------------- + "QA_WORD_WORKSPACE_STYLE_CARD_DARK": """ + QWidget { + background-color: #FBFBFC; + border: 1px solid #D6DAE1; + border-radius: 0px; + } + """, + "QA_WORD_WORKSPACE_STYLE_CARD_LIGHT": """ + QWidget { + background-color: #FFFFFF; + border: 1px solid #D6DAE1; + border-radius: 0px; + } + """, + "QA_WORD_WORKSPACE_STYLE_CARD_ACTIVE_DARK": """ + QWidget { + background-color: #FBFBFC; + border: 1px solid #679B62; + border-radius: 0px; + } + """, + "QA_WORD_WORKSPACE_STYLE_CARD_ACTIVE_LIGHT": """ + QWidget { + background-color: #FFFFFF; + border: 1px solid #679B62; + border-radius: 0px; + } + """, + + # ===================================================================== + # 2. Кнопки верхней панели + # ===================================================================== + + # --- Логотип-бейдж «W» ----------------------------------------------- + "QA_WORD_WORKSPACE_BADGE_DARK": """ + QPushButton { + background-color: #185ABD; + color: #FFFFFF; + border: 1px solid #185ABD; + border-radius: 0px; + padding: 2px 0px; + font-family: 'Segoe UI'; + font-size: 11px; + font-weight: 700; + } + QPushButton:hover { background-color: #2A6DCE; border-color: #2A6DCE; } + QPushButton:pressed { background-color: #15499A; border-color: #15499A; } + """, + "QA_WORD_WORKSPACE_BADGE_LIGHT": """ + QPushButton { + background-color: #185ABD; + color: #FFFFFF; + border: 1px solid #185ABD; + border-radius: 0px; + padding: 2px 0px; + font-family: 'Segoe UI'; + font-size: 11px; + font-weight: 700; + } + QPushButton:hover { background-color: #2A6DCE; border-color: #2A6DCE; } + QPushButton:pressed { background-color: #15499A; border-color: #15499A; } + """, + + # --- Стандартная кнопка верхней панели (Save/Undo/Redo/окно) --------- + "QA_WORD_WORKSPACE_TOPBAR_BUTTON_DARK": """ + QPushButton { + background-color: #25282C; + color: #F1F3F5; + border: 1px solid #3C4045; + border-radius: 0px; + padding: 3px 6px; + font-family: 'Segoe UI'; + font-size: 11px; + font-weight: 600; + } + QPushButton:hover { background-color: #33373C; border-color: #33373C; } + QPushButton:pressed { background-color: #202327; border-color: #202327; } + """, + "QA_WORD_WORKSPACE_TOPBAR_BUTTON_LIGHT": """ + QPushButton { + background-color: #FFFFFF; + color: #243248; + border: 1px solid #D1D8E2; + border-radius: 0px; + padding: 3px 6px; + font-family: 'Segoe UI'; + font-size: 11px; + font-weight: 600; + } + QPushButton:hover { background-color: #EFF3F7; border-color: #EFF3F7; } + QPushButton:pressed { background-color: #E4EAF1; border-color: #E4EAF1; } + """, + + # --- Переключатель «On» автосохранения ------------------------------- + "QA_WORD_WORKSPACE_TOPBAR_TOGGLE_DARK": """ + QPushButton { + background-color: #4D535A; + color: #FFFFFF; + border: 1px solid #4D535A; + border-radius: 0px; + padding: 3px 8px; + font-family: 'Segoe UI'; + font-size: 11px; + font-weight: 700; + } + QPushButton:hover { background-color: #5B626A; border-color: #5B626A; } + QPushButton:pressed { background-color: #43494F; border-color: #43494F; } + """, + "QA_WORD_WORKSPACE_TOPBAR_TOGGLE_LIGHT": """ + QPushButton { + background-color: #CFD8E3; + color: #172B4D; + border: 1px solid #CFD8E3; + border-radius: 0px; + padding: 3px 8px; + font-family: 'Segoe UI'; + font-size: 11px; + font-weight: 700; + } + QPushButton:hover { background-color: #DCE4ED; border-color: #DCE4ED; } + QPushButton:pressed { background-color: #C0CCD9; border-color: #C0CCD9; } + """, + + # ===================================================================== + # 3. Вкладки + # ===================================================================== + + # --- «Файл» (зелёная вкладка) ---------------------------------------- + "QA_WORD_WORKSPACE_FILE_TAB_DARK": """ + QPushButton { + background-color: #2F7D32; + color: #FFFFFF; + border: 1px solid #2F7D32; + border-radius: 0px; + padding: 6px 12px; + font-family: 'Segoe UI'; + font-size: 12px; + font-weight: 700; + } + QPushButton:hover { background-color: #388E3C; border-color: #388E3C; } + QPushButton:pressed { background-color: #2B6F2D; border-color: #2B6F2D; } + """, + "QA_WORD_WORKSPACE_FILE_TAB_LIGHT": """ + QPushButton { + background-color: #2F7D32; + color: #FFFFFF; + border: 1px solid #2F7D32; + border-radius: 0px; + padding: 6px 12px; + font-family: 'Segoe UI'; + font-size: 12px; + font-weight: 700; + } + QPushButton:hover { background-color: #388E3C; border-color: #388E3C; } + QPushButton:pressed { background-color: #2B6F2D; border-color: #2B6F2D; } + """, + + # --- Обычная вкладка (Вставка / Макет / …) --------------------------- + "QA_WORD_WORKSPACE_TAB_DARK": """ + QPushButton { + background-color: #242629; + color: #E7EBEF; + border: 1px solid #242629; + border-radius: 0px; + padding: 6px 12px; + font-family: 'Segoe UI'; + font-size: 12px; + font-weight: 600; + } + QPushButton:hover { background-color: #303338; border-color: #303338; } + QPushButton:pressed { background-color: #23262A; border-color: #23262A; } + """, + "QA_WORD_WORKSPACE_TAB_LIGHT": """ + QPushButton { + background-color: #FFFFFF; + color: #243248; + border: 1px solid #E6EBF1; + border-radius: 0px; + padding: 6px 12px; + font-family: 'Segoe UI'; + font-size: 12px; + font-weight: 600; + } + QPushButton:hover { background-color: #F0F4F8; border-color: #F0F4F8; } + QPushButton:pressed { background-color: #DCE4ED; border-color: #DCE4ED; } + """, + + # --- Активная вкладка («Главная») ------------------------------------ + "QA_WORD_WORKSPACE_TAB_ACTIVE_DARK": """ + QPushButton { + background-color: #2F3237; + color: #FFFFFF; + border: 1px solid #4E535A; + border-radius: 0px; + padding: 6px 12px; + font-family: 'Segoe UI'; + font-size: 12px; + font-weight: 700; + } + QPushButton:hover { background-color: #383D43; border-color: #383D43; } + QPushButton:pressed { background-color: #292D31; border-color: #292D31; } + """, + "QA_WORD_WORKSPACE_TAB_ACTIVE_LIGHT": """ + QPushButton { + background-color: #FFFFFF; + color: #172B4D; + border: 1px solid #C7D1DD; + border-radius: 0px; + padding: 6px 12px; + font-family: 'Segoe UI'; + font-size: 12px; + font-weight: 700; + } + QPushButton:hover { background-color: #F6F8FB; border-color: #F6F8FB; } + QPushButton:pressed { background-color: #E8EDF3; border-color: #E8EDF3; } + """, + + # ===================================================================== + # 4. Инструменты ribbon + # ===================================================================== + + # --- Крупная кнопка группы («Вставить», «Сетка») --------------------- + "QA_WORD_WORKSPACE_LARGE_BUTTON_DARK": """ + QPushButton { + background-color: #2A2E33; + color: #F1F3F5; + border: 1px solid #40454B; + border-radius: 0px; + padding: 6px 8px; + font-family: 'Segoe UI'; + font-size: 11px; + font-weight: 600; + } + QPushButton:hover { background-color: #34393F; border-color: #34393F; } + QPushButton:pressed { background-color: #25292D; border-color: #25292D; } + """, + "QA_WORD_WORKSPACE_LARGE_BUTTON_LIGHT": """ + QPushButton { + background-color: #F8FAFC; + color: #172B4D; + border: 1px solid #D2DAE5; + border-radius: 0px; + padding: 6px 8px; + font-family: 'Segoe UI'; + font-size: 11px; + font-weight: 600; + } + QPushButton:hover { background-color: #EFF3F7; border-color: #EFF3F7; } + QPushButton:pressed { background-color: #E6ECF3; border-color: #E6ECF3; } + """, + + # --- Мелкая инструментальная кнопка (B/I/U, L/C/R/J, «Найти» и т. п.) + "QA_WORD_WORKSPACE_TOOL_BUTTON_DARK": """ + QPushButton { + background-color: #25282C; + color: #F1F3F5; + border: 1px solid #3A3E43; + border-radius: 0px; + padding: 4px 6px; + font-family: 'Segoe UI'; + font-size: 11px; + font-weight: 600; + } + QPushButton:hover { background-color: #30353A; border-color: #30353A; } + QPushButton:pressed { background-color: #23272B; border-color: #23272B; } + """, + "QA_WORD_WORKSPACE_TOOL_BUTTON_LIGHT": """ + QPushButton { + background-color: #FFFFFF; + color: #243248; + border: 1px solid #D1D8E2; + border-radius: 0px; + padding: 4px 6px; + font-family: 'Segoe UI'; + font-size: 11px; + font-weight: 600; + } + QPushButton:hover { background-color: #EFF3F7; border-color: #EFF3F7; } + QPushButton:pressed { background-color: #E4EAF1; border-color: #E4EAF1; } + """, + + # ===================================================================== + # 5. Поля ввода + # ===================================================================== + + # --- Выпадающий список ribbon ---------------------------------------- + "QA_WORD_WORKSPACE_COMBO_DARK": """ + QComboBox { + background-color: #25282C; + color: #F1F3F5; + border: 1px solid #3A3E43; + border-radius: 0px; + padding: 2px 6px; + font-family: 'Segoe UI'; + font-size: 11px; + combobox-popup: 0; + } + QComboBox:focus { + border: 1px solid #5B9BD5; + } + QComboBox QAbstractItemView { + background-color: #25282C; + color: #F1F3F5; + border: 1px solid #3A3E43; + selection-background-color: #5B9BD5; + selection-color: #FFFFFF; + } + """, + "QA_WORD_WORKSPACE_COMBO_LIGHT": """ + QComboBox { + background-color: #FFFFFF; + color: #243248; + border: 1px solid #D1D8E2; + border-radius: 0px; + padding: 2px 6px; + font-family: 'Segoe UI'; + font-size: 11px; + combobox-popup: 0; + } + QComboBox:focus { + border: 1px solid #0C66E4; + } + QComboBox QAbstractItemView { + background-color: #FFFFFF; + color: #243248; + border: 1px solid #D1D8E2; + selection-background-color: #0C66E4; + selection-color: #FFFFFF; + } + """, + + # --- Поле поиска верхней панели -------------------------------------- + "QA_WORD_WORKSPACE_SEARCH_DARK": """ + QLineEdit { + background-color: #2A2D32; + color: #F1F3F5; + border: 1px solid #3D4248; + border-radius: 0px; + padding: 4px 10px; + font-family: 'Segoe UI'; + font-size: 12px; + } + QLineEdit:focus { + border: 1px solid #5B9BD5; + } + QLineEdit::placeholder { + color: #9CA7B5; + } + """, + "QA_WORD_WORKSPACE_SEARCH_LIGHT": """ + QLineEdit { + background-color: #FFFFFF; + color: #172B4D; + border: 1px solid #CDD6E1; + border-radius: 0px; + padding: 4px 10px; + font-family: 'Segoe UI'; + font-size: 12px; + } + QLineEdit:focus { + border: 1px solid #0C66E4; + } + QLineEdit::placeholder { + color: #6B778C; + } + """, + + # ===================================================================== + # 6. Текстовые метки + # ===================================================================== + + # --- Надпись «Автосохранение» в верхней панели ----------------------- + "QA_WORD_WORKSPACE_TOPBAR_TEXT_DARK": """ + QLabel { + color: #E7EBEF; + background-color: transparent; + border: none; + padding: 0px; + font-family: 'Segoe UI'; + font-size: 12px; + font-weight: 600; + qproperty-alignment: 'AlignCenter'; + } + """, + "QA_WORD_WORKSPACE_TOPBAR_TEXT_LIGHT": """ + QLabel { + color: #243248; + background-color: transparent; + border: none; + padding: 0px; + font-family: 'Segoe UI'; + font-size: 12px; + font-weight: 600; + qproperty-alignment: 'AlignCenter'; + } + """, + + # --- Заголовок документа в верхней панели ---------------------------- + "QA_WORD_WORKSPACE_TOPBAR_TITLE_DARK": """ + QLabel { + color: #FFFFFF; + background-color: transparent; + border: none; + padding: 0px 8px; + font-family: 'Segoe UI'; + font-size: 13px; + font-weight: 600; + qproperty-alignment: 'AlignCenter'; + } + """, + "QA_WORD_WORKSPACE_TOPBAR_TITLE_LIGHT": """ + QLabel { + color: #172B4D; + background-color: transparent; + border: none; + padding: 0px 8px; + font-family: 'Segoe UI'; + font-size: 13px; + font-weight: 600; + qproperty-alignment: 'AlignCenter'; + } + """, + + # --- Заголовок ribbon-группы («Буфер обмена», «Шрифт», …) ------------ + "QA_WORD_WORKSPACE_GROUP_TITLE_DARK": """ + QLabel { + color: #E7EBEF; + background-color: transparent; + border: none; + padding: 0px; + font-family: 'Segoe UI'; + font-size: 12px; + font-weight: 700; + qproperty-alignment: 'AlignCenter'; + } + """, + "QA_WORD_WORKSPACE_GROUP_TITLE_LIGHT": """ + QLabel { + color: #243248; + background-color: transparent; + border: none; + padding: 0px; + font-family: 'Segoe UI'; + font-size: 12px; + font-weight: 700; + qproperty-alignment: 'AlignCenter'; + } + """, + + # --- Основной текст карточки стиля ---------------------------------- + "QA_WORD_WORKSPACE_STYLE_TEXT_DARK": """ + QLabel { + color: #172B4D; + background-color: transparent; + border: none; + padding: 4px 6px 0px 6px; + font-family: 'Segoe UI'; + font-size: 14px; + font-weight: 700; + qproperty-alignment: 'AlignLeft | AlignTop'; + } + """, + "QA_WORD_WORKSPACE_STYLE_TEXT_LIGHT": """ + QLabel { + color: #172B4D; + background-color: transparent; + border: none; + padding: 4px 6px 0px 6px; + font-family: 'Segoe UI'; + font-size: 14px; + font-weight: 700; + qproperty-alignment: 'AlignLeft | AlignTop'; + } + """, + + # --- Подпись карточки стиля («Обычный», «Заголовок 1», …) ------------ + "QA_WORD_WORKSPACE_STYLE_CAPTION_DARK": """ + QLabel { + color: #4F5B6B; + background-color: transparent; + border: none; + padding: 0px 6px 4px 6px; + font-family: 'Segoe UI'; + font-size: 10px; + font-weight: 600; + qproperty-alignment: 'AlignLeft | AlignTop'; + } + """, + "QA_WORD_WORKSPACE_STYLE_CAPTION_LIGHT": """ + QLabel { + color: #4F5B6B; + background-color: transparent; + border: none; + padding: 0px 6px 4px 6px; + font-family: 'Segoe UI'; + font-size: 10px; + font-weight: 600; + qproperty-alignment: 'AlignLeft | AlignTop'; + } + """, +} diff --git a/Dispatch_V0.1.1/gui/styles/ticket.py b/Dispatch_V0.1.1/gui/styles/ticket.py new file mode 100644 index 0000000..b96b021 --- /dev/null +++ b/Dispatch_V0.1.1/gui/styles/ticket.py @@ -0,0 +1,559 @@ +# -*- coding: utf-8 -*- +# gui/styles/ticket.py + +"""Стили для модуля Ticket (канбан-доска, карточки задач, бейджи, фильтры).""" + +TICKET = { + + # ------------------------------------------------------------------ + # Канбан-колонка + # ------------------------------------------------------------------ + + "KANBAN_COLUMN": """ + QFrame { + background-color: palette(base); + border: 1px solid palette(mid); + border-radius: 6px; + } + """, + + "KANBAN_COLUMN_LIGHT": """ + QFrame { + background-color: #F7F8F9; + border: 1px solid #DFE1E6; + border-radius: 6px; + } + """, + + "KANBAN_COLUMN_DARK": """ + QFrame { + background-color: #2D2D2D; + border: 1px solid #5A5A5A; + border-radius: 6px; + } + """, + + # ------------------------------------------------------------------ + # Заголовок колонки + # ------------------------------------------------------------------ + + "KANBAN_COLUMN_HEADER": """ + QLabel { + font-size: 13px; + font-weight: 600; + color: palette(window-text); + background-color: transparent; + text-transform: uppercase; + } + """, + + "KANBAN_COLUMN_HEADER_LIGHT": """ + QLabel { + font-size: 13px; + font-weight: 600; + color: #5E6C84; + background-color: transparent; + text-transform: uppercase; + } + """, + + "KANBAN_COLUMN_HEADER_DARK": """ + QLabel { + font-size: 13px; + font-weight: 600; + color: #B8C0CC; + background-color: transparent; + text-transform: uppercase; + } + """, + + # ------------------------------------------------------------------ + # Счётчик задач в заголовке колонки + # ------------------------------------------------------------------ + + "KANBAN_COUNTER": """ + QLabel { + font-size: 12px; + font-weight: 600; + color: palette(window-text); + background-color: palette(light); + border-radius: 8px; + min-width: 20px; + min-height: 20px; + qproperty-alignment: AlignCenter; + } + """, + + "KANBAN_COUNTER_LIGHT": """ + QLabel { + font-size: 12px; + font-weight: 600; + color: #5E6C84; + background-color: #EBECF0; + border-radius: 8px; + min-width: 20px; + min-height: 20px; + qproperty-alignment: AlignCenter; + } + """, + + "KANBAN_COUNTER_DARK": """ + QLabel { + font-size: 12px; + font-weight: 600; + color: #B8C0CC; + background-color: #3C3C3C; + border-radius: 8px; + min-width: 20px; + min-height: 20px; + qproperty-alignment: AlignCenter; + } + """, + + # ------------------------------------------------------------------ + # Карточка задачи + # ------------------------------------------------------------------ + + "TASK_CARD": """ + QFrame { + background-color: palette(button); + border: 1px solid palette(mid); + border-radius: 6px; + } + QFrame:hover { + border-color: palette(highlight); + } + """, + + "TASK_CARD_LIGHT": """ + QFrame { + background-color: #FFFFFF; + border: 1px solid #DFE1E6; + border-radius: 6px; + } + QFrame:hover { + border-color: #85B8FF; + } + """, + + "TASK_CARD_DARK": """ + QFrame { + background-color: #3C3C3C; + border: 1px solid #5A5A5A; + border-radius: 6px; + } + QFrame:hover { + border-color: #5B9FFF; + } + """, + + # ------------------------------------------------------------------ + # Заголовок карточки задачи + # ------------------------------------------------------------------ + + "TASK_CARD_TITLE": """ + QLabel { + font-size: 14px; + font-weight: 600; + color: palette(window-text); + background-color: transparent; + } + """, + + "TASK_CARD_TITLE_LIGHT": """ + QLabel { + font-size: 14px; + font-weight: 600; + color: #172B4D; + background-color: transparent; + } + """, + + "TASK_CARD_TITLE_DARK": """ + QLabel { + font-size: 14px; + font-weight: 600; + color: #F2F2F2; + background-color: transparent; + } + """, + + # ------------------------------------------------------------------ + # Подпись карточки (локация, время) + # ------------------------------------------------------------------ + + "TASK_CARD_SUBTITLE": """ + QLabel { + font-size: 12px; + color: palette(dark); + background-color: transparent; + } + """, + + "TASK_CARD_SUBTITLE_LIGHT": """ + QLabel { + font-size: 12px; + color: #5E6C84; + background-color: transparent; + } + """, + + "TASK_CARD_SUBTITLE_DARK": """ + QLabel { + font-size: 12px; + color: #8C9BAB; + background-color: transparent; + } + """, + + # ------------------------------------------------------------------ + # Бейдж состояния задачи + # ------------------------------------------------------------------ + + "TASK_BADGE": """ + QLabel { + font-size: 10px; + font-weight: 600; + color: #FFFFFF; + border-radius: 3px; + min-height: 18px; + } + """, + + # ------------------------------------------------------------------ + # Панель фильтров над доской + # ------------------------------------------------------------------ + + "FILTER_BUTTON": """ + QPushButton { + background-color: palette(button); + color: palette(button-text); + border: 1px solid palette(mid); + font-size: 13px; + font-weight: 500; + } + QPushButton:hover { + background-color: palette(light); + } + """, + + "FILTER_BUTTON_LIGHT": """ + QPushButton { + background-color: #FFFFFF; + color: #172B4D; + border: 1px solid #DFE1E6; + font-size: 13px; + font-weight: 500; + } + QPushButton:hover { + background-color: #EBECF0; + } + """, + + "FILTER_BUTTON_DARK": """ + QPushButton { + background-color: #3C3C3C; + color: #F2F2F2; + border: 1px solid #5A5A5A; + font-size: 13px; + font-weight: 500; + } + QPushButton:hover { + background-color: #4A4A4A; + } + """, + + "FILTER_BUTTON_ACTIVE": """ + QPushButton { + background-color: palette(highlight); + color: #FFFFFF; + border: 1px solid palette(highlight); + border-radius: 6px; + font-size: 13px; + font-weight: 500; + } + QPushButton:hover { + background-color: #0747A6; + border-color: #0747A6; + } + """, + + "FILTER_BUTTON_ACTIVE_LIGHT": """ + QPushButton { + background-color: #0C66E4; + color: #FFFFFF; + border: 1px solid #0C66E4; + border-radius: 6px; + font-size: 13px; + font-weight: 500; + } + QPushButton:hover { + background-color: #0747A6; + border-color: #0747A6; + } + """, + + "FILTER_BUTTON_ACTIVE_DARK": """ + QPushButton { + background-color: #5B9FFF; + color: #000000; + border: 1px solid #5B9FFF; + border-radius: 6px; + font-size: 13px; + font-weight: 500; + } + QPushButton:hover { + background-color: #85B8FF; + border-color: #85B8FF; + } + """, + + # ------------------------------------------------------------------ + # COM-порт статус виджет + # ------------------------------------------------------------------ + + "COM_STATUS_CONNECTED": """ + QLabel { + font-size: 11px; + color: #36AC87; + background-color: transparent; + } + """, + + "COM_STATUS_DISCONNECTED": """ + QLabel { + font-size: 11px; + color: #FF5938; + background-color: transparent; + } + """, + + # ------------------------------------------------------------------ + # Архив / Отчёты / Акты — общие элементы списков + # ------------------------------------------------------------------ + "TICKET_LIST_CONTAINER": """ + QWidget { + background-color: palette(base); + } + """, + + "TICKET_LIST_ITEM": """ + QWidget { + background-color: palette(button); + border-radius: 4px; + } + QWidget:hover { + border-color: palette(highlight); + } + """, + + "TICKET_LIST_ITEM_SELECTED": """ + QWidget { + background-color: palette(light); + border-radius: 4px; + } + """, + + "TICKET_LIST_HEADER": """ + QLabel { + font-size: 15px; + font-weight: 600; + color: palette(window-text); + background-color: transparent; + } + """, + + "TICKET_LIST_TITLE": """ + QLabel { + font-size: 13px; + font-weight: 600; + color: palette(window-text); + background-color: transparent; + } + """, + + "TICKET_LIST_SUBTITLE": """ + QLabel { + font-size: 12px; + color: palette(dark); + background-color: transparent; + } + """, + + "TICKET_PREVIEW_AREA": """ + QTextEdit { + background-color: palette(base); + color: palette(window-text); + font-size: 13px; + } + """, + + "TICKET_EMPTY_LABEL": """ + QLabel { + font-size: 14px; + color: palette(dark); + background-color: transparent; + } + """, + + "TICKET_SHELL_ROOT": """ + QWidget { + background-color: #2B2B2B; + border: none; + } + """, + + "TICKET_SURFACE_HOST": """ + QWidget { + background-color: #2B2B2B; + border: none; + } + """, + + "TICKET_SURFACE_HOST_LIGHT": """ + QWidget { + background-color: #F4F5F7; + border: none; + } + """, + + "TICKET_SHELL_ROOT_LIGHT": """ + QWidget { + background-color: #F4F5F7; + border: none; + } + """, + + "TICKET_BOARD_COLUMN_BODY": """ + QWidget { + background-color: #FFFFFF; + border: 1px solid #E5E7EB; + border-radius: 0px; + } + """, + + "TICKET_BOARD_COLUMN_BODY_DARK": """ + QWidget { + background-color: #2D2D2D; + border: 1px solid #3C3C3C; + border-radius: 0px; + } + """, + + "TICKET_BOARD_COLUMN_HEADER": """ + QWidget { + background-color: #5A5A5A; + border: none; + border-radius: 0px; + } + """, + + "TICKET_BOARD_COLUMN_HEADER_DARK": """ + QWidget { + background-color: #5A5A5A; + border: none; + border-radius: 0px; + } + """, + + "TICKET_BOARD_COLUMN_HEADER_LIGHT": """ + QWidget { + background-color: #FFFFFF; + border: none; + border-radius: 0px; + } + """, + + "TICKET_BOARD_COLUMN_BODY_LIGHT": """ + QWidget { + background-color: #FFFFFF; + border: 1px solid #D0D7E2; + border-radius: 0px; + } + """, + + "TICKET_BOARD_COLUMN_TITLE": """ + QLabel { + background-color: transparent; + color: #F3F4F6; + border: none; + font-size: 18px; + font-weight: 500; + qproperty-alignment: 'AlignVCenter | AlignLeft'; + } + """, + + "TICKET_BOARD_COLUMN_TITLE_LIGHT": """ + QLabel { + background-color: transparent; + color: #172B4D; + border: none; + font-size: 18px; + font-weight: 500; + qproperty-alignment: 'AlignVCenter | AlignLeft'; + } + """, + + "TICKET_BOARD_COUNTER_TEXT_WHITE": """ + QLabel { + background-color: transparent; + border: none; + color: #FFFFFF; + font-size: 18px; + font-weight: 600; + qproperty-alignment: 'AlignCenter'; + } + """, + + "TICKET_BOARD_COUNTER_TEXT_MUTED": """ + QLabel { + background-color: transparent; + border: none; + color: #4B5563; + font-size: 18px; + font-weight: 600; + qproperty-alignment: 'AlignCenter'; + } + """, + + "TICKET_BOARD_COUNTER_SHELL_TODO": """ + QWidget { + background-color: #FF5938; + border: none; + border-radius: 999px; + } + """, + + "TICKET_BOARD_COUNTER_SHELL_IN_PROGRESS": """ + QWidget { + background-color: #008BFA; + border: none; + border-radius: 999px; + } + """, + + "TICKET_BOARD_COUNTER_SHELL_CONFIRMATION": """ + QWidget { + background-color: #FFD27A; + border: none; + border-radius: 999px; + } + """, + + "TICKET_BOARD_COUNTER_SHELL_COMPLETED": """ + QWidget { + background-color: #36AC87; + border: none; + border-radius: 999px; + } + """, + + "TICKET_BOARD_COUNTER_SHELL_REFUSED": """ + QWidget { + background-color: #D1D5DB; + border: none; + border-radius: 999px; + } + """, + +} diff --git a/Dispatch_V0.1.1/gui/styles/ticket_cards.py b/Dispatch_V0.1.1/gui/styles/ticket_cards.py new file mode 100644 index 0000000..e89e462 --- /dev/null +++ b/Dispatch_V0.1.1/gui/styles/ticket_cards.py @@ -0,0 +1,186 @@ +# -*- coding: utf-8 -*- +# gui/styles/ticket_cards.py + +"""Card-style registry для Ticket kanban-карточек.""" + +TICKET_CARDS = { + "TICKET_TASK_CARD_ROOT_TODO": """ + QWidget#ticket_task_card { + background-color: #FF5938; + border: 1px solid transparent; + border-radius: 0px; + } + QWidget#ticket_task_card:hover { + border-color: #FFFFFF; + } + """, + "TICKET_TASK_CARD_ROOT_IN_PROGRESS": """ + QWidget#ticket_task_card { + background-color: #008BFA; + border: 1px solid transparent; + border-radius: 0px; + } + QWidget#ticket_task_card:hover { + border-color: #FFFFFF; + } + """, + "TICKET_TASK_CARD_ROOT_CONFIRMATION": """ + QWidget#ticket_task_card { + background-color: #FFD27A; + border: 1px solid transparent; + border-radius: 0px; + } + QWidget#ticket_task_card:hover { + border-color: #172B4D; + } + """, + "TICKET_TASK_CARD_ROOT_COMPLETED": """ + QWidget#ticket_task_card { + background-color: #36AC87; + border: 1px solid transparent; + border-radius: 0px; + } + QWidget#ticket_task_card:hover { + border-color: #FFFFFF; + } + """, + "TICKET_TASK_CARD_ROOT_REFUSED": """ + QWidget#ticket_task_card { + background-color: #D1D5DB; + border: 1px solid transparent; + border-radius: 0px; + } + QWidget#ticket_task_card:hover { + border-color: #4B5563; + } + """, + "TICKET_TASK_CARD_TITLE_LIGHT": """ + QLabel { + background-color: transparent; + border: none; + color: #FFFFFF; + font-size: 14px; + font-weight: 600; + } + """, + "TICKET_TASK_CARD_TITLE_DARK": """ + QLabel { + background-color: transparent; + border: none; + color: #111111; + font-size: 14px; + font-weight: 600; + } + """, + "TICKET_TASK_CARD_TEXT_LIGHT": """ + QLabel { + background-color: transparent; + border: none; + color: #FFFFFF; + font-size: 12px; + font-weight: 500; + } + """, + "TICKET_TASK_CARD_TEXT_DARK": """ + QLabel { + background-color: transparent; + border: none; + color: #111111; + font-size: 12px; + font-weight: 500; + } + """, + "TICKET_TASK_CARD_META_LIGHT": """ + QLabel { + background-color: transparent; + border: none; + color: rgba(255, 255, 255, 0.82); + font-size: 11px; + font-weight: 500; + } + """, + "TICKET_TASK_CARD_META_DARK": """ + QLabel { + background-color: transparent; + border: none; + color: #111111; + font-size: 11px; + font-weight: 500; + } + """, + "TICKET_TASK_CARD_STATUS_LIGHT": """ + QLabel { + background-color: transparent; + border: none; + color: #FFFFFF; + font-size: 12px; + font-weight: 600; + } + """, + "TICKET_TASK_CARD_STAGE_ACTIVE_LIGHT": """ + QLabel { + background-color: rgba(255, 255, 255, 0.22); + border: none; + border-radius: 10px; + color: #FFFFFF; + font-size: 10px; + font-weight: 700; + } + """, + "TICKET_TASK_CARD_STAGE_INACTIVE_LIGHT": """ + QLabel { + background-color: transparent; + border: 1px solid rgba(255, 255, 255, 0.36); + border-radius: 10px; + color: rgba(255, 255, 255, 0.78); + font-size: 10px; + font-weight: 700; + } + """, + "TICKET_TASK_CARD_STAGE_ACTIVE_DARK": """ + QLabel { + background-color: rgba(23, 43, 77, 0.14); + border: none; + border-radius: 10px; + color: #172B4D; + font-size: 10px; + font-weight: 700; + } + """, + "TICKET_TASK_CARD_STAGE_INACTIVE_DARK": """ + QLabel { + background-color: transparent; + border: 1px solid rgba(23, 43, 77, 0.24); + border-radius: 10px; + color: #4B5563; + font-size: 10px; + font-weight: 700; + } + """, + "TICKET_TASK_CARD_AVATAR_LIGHT": """ + QLabel { + background-color: rgba(255, 255, 255, 0.14); + border: 2px solid #FFFFFF; + border-radius: 22px; + color: #FFFFFF; + font-size: 12px; + font-weight: 700; + } + """, + "TICKET_TASK_CARD_AVATAR_DARK": """ + QLabel { + background-color: rgba(255, 255, 255, 0.42); + border: 2px solid #FFFFFF; + border-radius: 22px; + color: #172B4D; + font-size: 12px; + font-weight: 700; + } + """, + "TICKET_TASK_CARD_AVATAR_IMAGE": """ + QLabel { + background-color: transparent; + border: none; + } + """, +} diff --git a/Dispatch_V0.1.1/gui/styles/ticket_details.py b/Dispatch_V0.1.1/gui/styles/ticket_details.py new file mode 100644 index 0000000..2d3de60 --- /dev/null +++ b/Dispatch_V0.1.1/gui/styles/ticket_details.py @@ -0,0 +1,221 @@ +# -*- coding: utf-8 -*- +# gui/styles/ticket_details.py + +"""Стили dialog-экрана подробностей Ticket.""" + +TICKET_DETAILS = { + "TICKET_DETAILS_SUMMARY_TITLE_DARK": """ + QLabel { + background-color: transparent; + color: #F5F7FA; + font-size: 16px; + font-weight: 700; + } + """, + "TICKET_DETAILS_SUMMARY_TITLE_LIGHT": """ + QLabel { + background-color: transparent; + color: #0F2F61; + font-size: 16px; + font-weight: 700; + } + """, + "TICKET_DETAILS_SUMMARY_VALUE_DARK": """ + QLabel { + background-color: transparent; + color: #F5F7FA; + font-size: 16px; + font-weight: 400; + } + """, + "TICKET_DETAILS_SUMMARY_VALUE_LIGHT": """ + QLabel { + background-color: transparent; + color: #0F2F61; + font-size: 16px; + font-weight: 400; + } + """, + "TICKET_DETAILS_SECTION_TITLE_DARK": """ + QLabel { + background-color: transparent; + color: #F5F7FA; + font-size: 18px; + font-weight: 700; + } + """, + "TICKET_DETAILS_SECTION_TITLE_LIGHT": """ + QLabel { + background-color: transparent; + color: #0F2F61; + font-size: 18px; + font-weight: 700; + } + """, + "TICKET_DETAILS_DIVIDER_DARK": """ + QLabel { + background-color: #3C4553; + border: none; + min-height: 1px; + max-height: 1px; + } + """, + "TICKET_DETAILS_DIVIDER_LIGHT": """ + QLabel { + background-color: #D6DCE5; + border: none; + min-height: 1px; + max-height: 1px; + } + """, + "TICKET_DETAILS_STAGE_ROW": """ + QWidget { + background-color: transparent; + border: 1px solid transparent; + border-radius: 10px; + } + """, + "TICKET_DETAILS_STAGE_ROW_ACTIVE": """ + QWidget { + background-color: transparent; + border: 1px solid transparent; + border-radius: 10px; + } + QWidget:hover { + background-color: rgba(12, 102, 228, 0.10); + } + """, + "TICKET_DETAILS_STAGE_TEXT_ACTIVE_DARK": """ + QLabel { + background-color: transparent; + color: #F5F7FA; + font-size: 18px; + font-weight: 500; + } + """, + "TICKET_DETAILS_STAGE_TEXT_ACTIVE_LIGHT": """ + QLabel { + background-color: transparent; + color: #172B4D; + font-size: 18px; + font-weight: 500; + } + """, + "TICKET_DETAILS_STAGE_TEXT_INACTIVE_DARK": """ + QLabel { + background-color: transparent; + color: #8C9BAB; + font-size: 18px; + font-weight: 400; + } + """, + "TICKET_DETAILS_STAGE_TEXT_INACTIVE_LIGHT": """ + QLabel { + background-color: transparent; + color: #6B778C; + font-size: 18px; + font-weight: 400; + } + """, + "TICKET_DETAILS_EMPLOYEE_NAME_DARK": """ + QLabel { + background-color: transparent; + color: #F5F7FA; + font-size: 17px; + font-weight: 500; + } + """, + "TICKET_DETAILS_EMPLOYEE_NAME_LIGHT": """ + QLabel { + background-color: transparent; + color: #172B4D; + font-size: 17px; + font-weight: 500; + } + """, + "TICKET_DETAILS_EMPLOYEE_ROLE_DARK": """ + QLabel { + background-color: transparent; + color: #D0D7E2; + font-size: 17px; + font-weight: 400; + } + """, + "TICKET_DETAILS_EMPLOYEE_ROLE_LIGHT": """ + QLabel { + background-color: transparent; + color: #172B4D; + font-size: 17px; + font-weight: 400; + } + """, + "TICKET_DETAILS_REFUSE_BUTTON": """ + QPushButton { + background-color: #DE350B; + color: #FFFFFF; + border: none; + border-radius: 8px; + font-size: 16px; + font-weight: 500; + min-width: 260px; + min-height: 48px; + padding: 10px 18px; + } + QPushButton:hover { + background-color: #BF2600; + } + QPushButton:pressed { + background-color: #A32000; + } + QPushButton:disabled { + background-color: #9AA5B1; + color: #FFFFFF; + } + """, + "TICKET_DETAILS_REFUSE_BUTTON_DARK": """ + QPushButton { + background-color: #DE350B; + color: #FFFFFF; + border: none; + border-radius: 8px; + font-size: 16px; + font-weight: 500; + min-width: 260px; + min-height: 48px; + padding: 10px 18px; + } + QPushButton:hover { + background-color: #BF2600; + } + QPushButton:pressed { + background-color: #A32000; + } + QPushButton:disabled { + background-color: #9AA5B1; + color: #FFFFFF; + } + """, + "TICKET_DETAILS_REFUSE_BUTTON_LIGHT": """ + QPushButton { + background-color: #DE350B; + color: #111111; + border: none; + border-radius: 8px; + font-size: 16px; + font-weight: 500; + min-width: 260px; + min-height: 48px; + padding: 10px 18px; + } + QPushButton:hover { + background-color: #BF2600; + } + QPushButton:pressed { + background-color: #A32000; + } + QPushButton:disabled { + background-color: #9AA5B1; + color: #6B778C; + } + """, +} diff --git a/Dispatch_V0.1.1/gui/styles/ticket_document_dialog.py b/Dispatch_V0.1.1/gui/styles/ticket_document_dialog.py new file mode 100644 index 0000000..65a0fcd --- /dev/null +++ b/Dispatch_V0.1.1/gui/styles/ticket_document_dialog.py @@ -0,0 +1,79 @@ +# -*- coding: utf-8 -*- +# gui/styles/ticket_document_dialog.py + +"""Стили dialog-окон документов Ticket.""" + +TICKET_DOCUMENT_DIALOG = { + "TICKET_DOCUMENT_TEXTAREA_DARK": """ + QTextEdit { + background-color: #23272F; + color: #F5F7FA; + border: 1px solid #3C4553; + border-radius: 12px; + font-size: 16px; + padding: 12px 14px; + } + QTextEdit:focus { + border: 1px solid #0C66E4; + } + """, + "TICKET_DOCUMENT_TEXTAREA_LIGHT": """ + QTextEdit { + background-color: #FFFFFF; + color: #172B4D; + border: 1px solid #D7DEE8; + border-radius: 12px; + font-size: 16px; + padding: 12px 14px; + } + QTextEdit:focus { + border: 1px solid #0C66E4; + } + """, + "TICKET_DOCUMENT_CANCEL_BUTTON": """ + QPushButton { + background-color: transparent; + color: #6B84A5; + border: 1px solid #D7DEE8; + border-radius: 10px; + font-size: 16px; + font-weight: 500; + padding: 10px 20px; + } + QPushButton:hover { + background-color: rgba(12, 102, 228, 0.06); + border-color: #A8B8CF; + } + QPushButton:pressed { + background-color: rgba(12, 102, 228, 0.10); + } + QPushButton:disabled { + color: #A8B8CF; + border-color: #E1E6ED; + } + """, + "TICKET_DOCUMENT_SUBMIT_BUTTON": """ + QPushButton { + background-color: #0C66E4; + color: #FFFFFF; + border: 1px solid #0C66E4; + border-radius: 10px; + font-size: 16px; + font-weight: 500; + padding: 10px 20px; + } + QPushButton:hover { + background-color: #0055CC; + border-color: #0055CC; + } + QPushButton:pressed { + background-color: #09326C; + border-color: #09326C; + } + QPushButton:disabled { + background-color: #D5D9E2; + color: #7890AF; + border-color: #D5D9E2; + } + """, +} diff --git a/Dispatch_V0.1.1/gui/styles/ticket_refusal_dialog.py b/Dispatch_V0.1.1/gui/styles/ticket_refusal_dialog.py new file mode 100644 index 0000000..ef1b2ef --- /dev/null +++ b/Dispatch_V0.1.1/gui/styles/ticket_refusal_dialog.py @@ -0,0 +1,71 @@ +# -*- coding: utf-8 -*- +# gui/styles/ticket_refusal_dialog.py + +"""Стили dialog-экрана отказа Ticket.""" + +TICKET_REFUSAL_DIALOG = { + "TICKET_REFUSAL_HEADING_DARK": """ + QLabel { + background-color: transparent; + color: #F5F7FA; + font-size: 16px; + font-weight: 700; + } + """, + "TICKET_REFUSAL_HEADING_LIGHT": """ + QLabel { + background-color: transparent; + color: #0F2F61; + font-size: 16px; + font-weight: 700; + } + """, + "TICKET_REFUSAL_LOCATION_TITLE_DARK": """ + QLabel { + background-color: transparent; + color: #F5F7FA; + font-size: 16px; + font-weight: 700; + } + """, + "TICKET_REFUSAL_LOCATION_TITLE_LIGHT": """ + QLabel { + background-color: transparent; + color: #0F2F61; + font-size: 16px; + font-weight: 700; + } + """, + "TICKET_REFUSAL_LOCATION_VALUE_DARK": """ + QLabel { + background-color: transparent; + color: #F5F7FA; + font-size: 16px; + font-weight: 400; + } + """, + "TICKET_REFUSAL_LOCATION_VALUE_LIGHT": """ + QLabel { + background-color: transparent; + color: #0F2F61; + font-size: 16px; + font-weight: 400; + } + """, + "TICKET_REFUSAL_WARNING_DARK": """ + QLabel { + background-color: transparent; + color: #FF8F73; + font-size: 16px; + font-weight: 500; + } + """, + "TICKET_REFUSAL_WARNING_LIGHT": """ + QLabel { + background-color: transparent; + color: #0F2F61; + font-size: 16px; + font-weight: 500; + } + """, +} diff --git a/Dispatch_V0.1.1/gui/styles/ticket_reports.py b/Dispatch_V0.1.1/gui/styles/ticket_reports.py new file mode 100644 index 0000000..99a044b --- /dev/null +++ b/Dispatch_V0.1.1/gui/styles/ticket_reports.py @@ -0,0 +1,184 @@ +# -*- coding: utf-8 -*- +# gui/styles/ticket_reports.py + +"""Стили страницы отчётов Ticket (колонка-канбан, карточки, предпросмотр).""" + +TICKET_REPORTS = { + # ------------------------------------------------------------------ + # Карточка отчёта — корневой контейнер + # ------------------------------------------------------------------ + "TICKET_REPORT_CARD_ROOT": """ + QWidget#ticket_report_card { + background-color: #FFFFFF; + border: 1px solid #E5E7EB; + border-radius: 0px; + } + QWidget#ticket_report_card:hover { + border-color: #0C66E4; + } + """, + "TICKET_REPORT_CARD_ROOT_DARK": """ + QWidget#ticket_report_card { + background-color: #2D2D2D; + border: 1px solid #3C3C3C; + border-radius: 0px; + } + QWidget#ticket_report_card:hover { + border-color: #5B9FFF; + } + """, + "TICKET_REPORT_CARD_ROOT_SELECTED": """ + QWidget#ticket_report_card { + background-color: #E9F2FF; + border: 1px solid #0C66E4; + border-radius: 0px; + } + """, + "TICKET_REPORT_CARD_ROOT_SELECTED_DARK": """ + QWidget#ticket_report_card { + background-color: #1C3A5F; + border: 1px solid #5B9FFF; + border-radius: 0px; + } + """, + "TICKET_REPORT_CARD_CONTENT": """ + QWidget { + background-color: transparent; + border: none; + } + """, + + # ------------------------------------------------------------------ + # Карточка отчёта — текстовые стили + # ------------------------------------------------------------------ + "TICKET_REPORT_CARD_TITLE": """ + QLabel { + background-color: transparent; + border: none; + color: #172B4D; + font-size: 14px; + font-weight: 600; + } + """, + "TICKET_REPORT_CARD_TITLE_DARK": """ + QLabel { + background-color: transparent; + border: none; + color: #F3F4F6; + font-size: 14px; + font-weight: 600; + } + """, + "TICKET_REPORT_CARD_SUBTITLE": """ + QLabel { + background-color: transparent; + border: none; + color: #5E6C84; + font-size: 12px; + font-weight: 400; + } + """, + "TICKET_REPORT_CARD_SUBTITLE_DARK": """ + QLabel { + background-color: transparent; + border: none; + color: #8C9BAB; + font-size: 12px; + font-weight: 400; + } + """, + "TICKET_REPORT_CARD_META": """ + QLabel { + background-color: transparent; + border: none; + color: #5E6C84; + font-size: 11px; + font-weight: 400; + } + """, + "TICKET_REPORT_CARD_META_DARK": """ + QLabel { + background-color: transparent; + border: none; + color: #6B7A8D; + font-size: 11px; + font-weight: 400; + } + """, + + # ------------------------------------------------------------------ + # Колонка списка отчётов — body (подложка под карточками) + # ------------------------------------------------------------------ + "TICKET_REPORT_COLUMN_BODY": """ + QWidget { + background-color: #FFFFFF; + border: 1px solid #E5E7EB; + border-radius: 0px; + } + """, + "TICKET_REPORT_COLUMN_BODY_DARK": """ + QWidget { + background-color: #2D2D2D; + border: 1px solid #3C3C3C; + border-radius: 0px; + } + """, + + # ------------------------------------------------------------------ + # Панель предпросмотра отчёта — body + # ------------------------------------------------------------------ + "TICKET_REPORT_PREVIEW_BODY": """ + QWidget { + background-color: #FFFFFF; + border: 1px solid #E5E7EB; + border-radius: 0px; + } + """, + "TICKET_REPORT_PREVIEW_BODY_DARK": """ + QWidget { + background-color: #2D2D2D; + border: 1px solid #3C3C3C; + border-radius: 0px; + } + """, + + # ------------------------------------------------------------------ + # Текстовая область предпросмотра (увеличенный кегль) + # ------------------------------------------------------------------ + "TICKET_REPORT_PREVIEW_AREA": """ + QTextEdit { + background-color: #FFFFFF; + color: #172B4D; + font-size: 18px; + border: none; + padding: 12px 14px; + } + """, + "TICKET_REPORT_PREVIEW_AREA_DARK": """ + QTextEdit { + background-color: #2D2D2D; + color: #F3F4F6; + font-size: 18px; + border: none; + padding: 12px 14px; + } + """, + + # ------------------------------------------------------------------ + # Пустой placeholder + # ------------------------------------------------------------------ + "TICKET_REPORT_EMPTY_LABEL": """ + QLabel { + font-size: 14px; + color: #5E6C84; + background-color: transparent; + } + """, + "TICKET_REPORT_EMPTY_LABEL_DARK": """ + QLabel { + font-size: 14px; + color: #8C9BAB; + background-color: transparent; + } + """, +} diff --git a/Dispatch_V0.1.1/gui/styles/ticket_specialist_dialog.py b/Dispatch_V0.1.1/gui/styles/ticket_specialist_dialog.py new file mode 100644 index 0000000..7ed47c5 --- /dev/null +++ b/Dispatch_V0.1.1/gui/styles/ticket_specialist_dialog.py @@ -0,0 +1,59 @@ +# -*- coding: utf-8 -*- +# gui/styles/ticket_specialist_dialog.py + +"""Стили окна выбора специалиста Ticket.""" + +TICKET_SPECIALIST_DIALOG = { + "TICKET_SPECIALIST_HINT_DARK": """ + QLabel { + background-color: transparent; + color: #F5F7FA; + font-size: 17px; + font-weight: 500; + } + """, + "TICKET_SPECIALIST_HINT_LIGHT": """ + QLabel { + background-color: transparent; + color: #0F2F61; + font-size: 17px; + font-weight: 500; + } + """, + "TICKET_SPECIALIST_ROLE_DARK": """ + QLabel { + background-color: transparent; + color: #AEB9C8; + font-size: 16px; + font-weight: 400; + } + """, + "TICKET_SPECIALIST_ROLE_LIGHT": """ + QLabel { + background-color: transparent; + color: #667A9A; + font-size: 16px; + font-weight: 400; + } + """, + "TICKET_SPECIALIST_ITEM": """ + QWidget#ticket_specialist_row { + background-color: transparent; + border: 1px solid transparent; + border-radius: 16px; + } + QWidget#ticket_specialist_row:hover { + background-color: rgba(12, 102, 228, 0.06); + } + """, + "TICKET_SPECIALIST_ITEM_SELECTED": """ + QWidget#ticket_specialist_row { + background-color: rgba(12, 102, 228, 0.10); + border: 1px solid rgba(12, 102, 228, 0.28); + border-radius: 16px; + } + QWidget#ticket_specialist_row:hover { + background-color: rgba(12, 102, 228, 0.14); + } + """, +} diff --git a/Dispatch_V0.1.1/gui/styles/welcome.py b/Dispatch_V0.1.1/gui/styles/welcome.py new file mode 100644 index 0000000..0f74a09 --- /dev/null +++ b/Dispatch_V0.1.1/gui/styles/welcome.py @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- +# gui/styles/welcome.py + +"""Стили для страницы приветствия.""" + +WELCOME = { + "WELCOME_IMAGE_FRAME_DARK": """ + QFrame { + border: 1px solid #5a5a5a; + border-radius: 4px; + background: #1e1e1e; + } + """, + "WELCOME_IMAGE_FRAME_LIGHT": """ + QFrame { + border: 1px solid #DFE1E6; + border-radius: 4px; + background: #FFFFFF; + } + """, +} diff --git a/Dispatch_V0.1.1/gui/styles/widgets.py b/Dispatch_V0.1.1/gui/styles/widgets.py new file mode 100644 index 0000000..4ec0719 --- /dev/null +++ b/Dispatch_V0.1.1/gui/styles/widgets.py @@ -0,0 +1,422 @@ +# -*- coding: utf-8 -*- +# gui/styles/widgets.py + +"""Стили для контейнеров, диалогов, сплиттеров и т.д.""" + +WIDGETS = { + + "DIALOG": """ + QDialog { + background-color: palette(window); + } + """, + + "DIALOG_BUTTON_BOX": """ + QDialogButtonBox { + background-color: transparent; + } + QDialogButtonBox QPushButton { + background-color: palette(button); + color: palette(button-text); + border: 1px solid palette(mid); + border-radius: 0px; + font-size: 14px; + min-height: 32px; + padding: 4px 10px; + } + QDialogButtonBox QPushButton:hover { border-color: palette(highlight); } + QDialogButtonBox QPushButton:pressed { background-color: palette(light); } + QDialogButtonBox QPushButton:disabled { color: palette(mid); border-color: palette(dark); } + """, + + "GROUP_BOX": """ + QGroupBox { + font-size: 14px; + font-weight: 600; + color: palette(window-text); + border: 1px solid palette(mid); + border-radius: 0px; + margin-top: 6px; + background-color: palette(base); + } + QGroupBox::title { + subcontrol-origin: margin; + subcontrol-position: top left; + background-color: palette(base); + color: palette(window-text); + padding: 2px 5px 2px 9px; + } + """, + + "GROUP_BOX_DARK": """ + QGroupBox { + font-size: 14px; + font-weight: 600; + color: #f2f2f2; + border: 1px solid #5a5a5a; + border-radius: 0px; + margin-top: 6px; + background-color: #2d2d2d; + } + QGroupBox::title { + subcontrol-origin: margin; + subcontrol-position: top left; + background-color: #2d2d2d; + color: #f2f2f2; + padding: 2px 5px 2px 9px; + } + """, + + "GROUP_BOX_LIGHT": """ + QGroupBox { + font-size: 14px; + font-weight: 600; + color: #172B4D; + border: 1px solid #DFE1E6; + border-radius: 0px; + margin-top: 6px; + background-color: #FFFFFF; + } + QGroupBox::title { + subcontrol-origin: margin; + subcontrol-position: top left; + background-color: #FFFFFF; + color: #172B4D; + padding: 2px 5px 2px 9px; + } + """, + + "GROUP_BOX_CENTER_TITLE": """ + QGroupBox { + font-size: 14px; + font-weight: 600; + color: palette(window-text); + border: 1px solid palette(mid); + border-radius: 0px; + margin-top: 6px; + background-color: palette(base); + } + QGroupBox::title { + subcontrol-position: top center; + background-color: transparent; + color: palette(window-text); + padding: 12px 5px 2px 9px; + } + """, + + "GROUP_BOX_CENTER_TITLE_DARK": """ + QGroupBox { + font-size: 14px; + font-weight: 600; + color: #f2f2f2; + border: 1px solid #5a5a5a; + border-radius: 0px; + margin-top: 6px; + background-color: #2d2d2d; + } + QGroupBox::title { + subcontrol-position: top center; + background-color: transparent; + color: #f2f2f2; + padding: 12px 5px 2px 9px; + } + """, + + "GROUP_BOX_CENTER_TITLE_LIGHT": """ + QGroupBox { + font-size: 14px; + font-weight: 600; + color: #172B4D; + border: 1px solid #DFE1E6; + border-radius: 0px; + margin-top: 6px; + background-color: #FFFFFF; + } + QGroupBox::title { + subcontrol-position: top center; + background-color: transparent; + color: #172B4D; + padding: 12px 5px 2px 9px; + } + """, + + "W": """ + background-color: rgba(255, 255, 255, 0.15); + border: 1px solid #ffffff; + border-radius: 0px; + outline: 1px solid #007bff; + """, + + "W_FRAME": """ + QWidget#main_container { + background-color: rgba(255, 255, 255, 0.15); + border: 1px solid #ffffff; + border-radius: 0px; + outline: 1px solid #007bff; + } + """, + + "B": """ + background-color: rgba(0, 0, 0, 0.15); + border: 1px solid #000000; + border-radius: 0px; + outline: 1px solid #ff8400; + """, + + "MODEL_VIEW_PANEL": """ + QWidget#model_view_panel { + background-color: palette(base); + border: 1px solid palette(mid); + border-radius: 0px; + } + """, + + "SPLITTER": """ + QSplitter::handle { + background-color: palette(mid); + width: 4px; + } + QSplitter::handle:hover { + background-color: palette(highlight); + } + """, + "STACKED_WIDGET": """ + QStackedWidget { + background-color: palette(window); + } + """, + + "SCROLL_CONTAINER": """ + QScrollArea { + background-color: transparent; + border: none; + } + QScrollArea > QWidget > QWidget { + background-color: transparent; + border: none; + } + """, + + "DEFAULT": """ + QWidget { + background-color: transparent; + border: none; + border-radius: 0px; + } + """, + + "TOOLS_PANEL_CONTAINER": """ + QWidget { + background-color: palette(base); + border: 1px solid palette(mid); + } + """, + + "TOOLS_PANEL_CONTAINER_TRANSPARENT": """ + QWidget { + background-color: transparent; + border: 1px solid palette(mid); + border-radius: 0px; + } + """, + + "TREE_WIDGET": """ + QTreeWidget { + background-color: palette(base); + border: 1px solid palette(mid); + border-radius: 0px; + font-size: 14px; + color: palette(text); + } + QTreeWidget::item { + color: palette(text); + } + QTreeWidget::item:selected { + background-color: palette(highlight); + color: palette(highlighted-text); + } + QHeaderView::section { + background-color: palette(button); + color: palette(button-text); + border: 1px solid palette(mid); + } + """, + + "TREE_WIDGET_TOP_FLAT": """ + QTreeWidget { + background-color: palette(base); + border: 1px solid palette(mid); + border-top-left-radius: 0px; + border-top-right-radius: 0px; + border-bottom-left-radius: 0px; + border-bottom-right-radius: 0px; + font-size: 14px; + color: palette(text); + } + QLabel#properties_body_label, + QWidget#properties_body_label { + background-color: palette(base); + border: 1px solid palette(mid); + border-top-left-radius: 0px; + border-top-right-radius: 0px; + border-bottom-left-radius: 0px; + border-bottom-right-radius: 0px; + color: palette(text); + font-size: 13px; + font-weight: 600; + + } + QTreeWidget::item { + color: palette(text); + } + QTreeWidget::item:selected { + background-color: palette(highlight); + color: palette(highlighted-text); + } + QHeaderView::section { + background-color: palette(button); + color: palette(button-text); + border: 1px solid palette(mid); + } + """, + + "TOPOLOGY_TREE_PANEL": """ + background-color: palette(base); + border: 1px solid palette(mid); + border-radius: 0px; + + QTreeWidget { + background-color: #3C3C3C; + border: 1px solid palette(mid); + border-radius: 0px; + font-size: 14px; + color: palette(text); + } + QTreeWidget::item { + background-color: #3C3C3C; + color: palette(text); + } + QTreeWidget::item:selected { + background-color: palette(highlight); + color: palette(highlighted-text); + } + QHeaderView::section { + background-color: #3C3C3C; + color: palette(button-text); + border: 1px solid palette(mid); + } + """, + + "VIEW_IMAGE_BACKGROUND": """ + background-color: palette(window); + """, + + "VIEW_WIDGET_BACKGROUND": """ + background-color: palette(base); + border: 1px solid palette(mid); + border-radius: 0px; + """, + # Стили для модуля зонирования + "ZONE_PROPERTIES_GROUP": """ + QGroupBox { + font-weight: 600; + border: 1px solid palette(mid); + border-radius: 0px; + margin-top: 12px; + padding-top: 10px; + background-color: palette(base); + color: palette(window-text); + } + QGroupBox::title { + subcontrol-origin: margin; + left: 10px; + padding: 0 5px 0 5px; + color: palette(window-text); + } + """, + + "ZONE_PROPERTIES_PANEL": """ + QWidget { + border: 1px solid palette(mid); + background-color: palette(base); + } + """, + + "ZONE_PROPERTIES_HOST": """ + QWidget { + border: none; + background-color: palette(window); + } + """, + + "ZONE_PROPERTIES_WIDGET": """ + QWidget#zone_properties_widget { + background-color: palette(window); + } + """, + + "MAIN_WINDOW_DARK": """ + QMainWindow { background-color: #2b2b2b; } + """, + + "MAIN_WINDOW_LIGHT": """ + QMainWindow { background-color: #F4F5F7; } + """, +} + + +# --------------------------------------------------------------------------- +# Module workflow notes +# --------------------------------------------------------------------------- +# +# 1) Назначение модуля: +# Определение QSS-стилей для контейнеров, диалогов, сплиттеров, +# group box'ов, scroll-контейнеров, tree widget'ов, панелей свойств +# зон, модели вида и прочих составных виджетов. Поддержка тем: +# база (palette), _DARK, _LIGHT варианты. +# +# 2) Зависимости модуля: +# Импорты: нет +# Хост/базовый класс: нет (модуль-словарь) +# Внешние библиотеки: нет +# +# 3) Экспорт: +# Словарь WIDGETS: dict[str, str] — ключ → QSS-строка. +# Ключи: DIALOG, DIALOG_BUTTON_BOX, GROUP_BOX, GROUP_BOX_DARK, +# GROUP_BOX_LIGHT, GROUP_BOX_CENTER_TITLE, GROUP_BOX_CENTER_TITLE_DARK, +# GROUP_BOX_CENTER_TITLE_LIGHT, W, W_FRAME, B, +# MODEL_VIEW_PANEL, SPLITTER, STACKED_WIDGET, SCROLL_CONTAINER, +# DEFAULT, TOOLS_PANEL_CONTAINER, TOOLS_PANEL_CONTAINER_TRANSPARENT, +# TREE_WIDGET, TREE_WIDGET_TOP_FLAT, TOPOLOGY_TREE_PANEL, +# VIEW_IMAGE_BACKGROUND, VIEW_WIDGET_BACKGROUND, +# ZONE_PROPERTIES_GROUP, ZONE_PROPERTIES_PANEL, +# ZONE_PROPERTIES_HOST, ZONE_PROPERTIES_WIDGET. +# +# 4) Состояние (поля): +# WIDGETS: dict[str, str] — единственный экспортируемый словарь. +# +# 5) Последовательность действий и вызовов: +# Нет вызовов; модуль декларативный. +# Импортируется в gui/styles/__init__.py → APP_STYLES.update(WIDGETS). +# +# 6) Побочные эффекты: +# Нет побочных эффектов при импорте. +# +# 7) Границы ответственности: +# НЕ применяет стили — только хранит QSS-строки. +# НЕ содержит стили для кнопок (→ buttons.py), меток (→ labels.py) +# или полей ввода (→ inputs.py). +# +# 8) Обработка ошибок: +# Нет; модуль декларативный. +# +# 9) Инварианты и контракты: +# - Тематические варианты: KEY / KEY_DARK / KEY_LIGHT. +# - W и B — debug-стили с полупрозрачным фоном и outline. +# - GROUP_BOX_CENTER_TITLE — вариант с центрированным заголовком. +# - Все QSS-строки — валидный Qt StyleSheet синтаксис. +# +# 10) Правило сопровождения: +# Новые стили контейнеров/диалогов/виджетов добавлять сюда. +# Соблюдать паттерн KEY / KEY_DARK / KEY_LIGHT. +# Debug-стили (W, B) не использовать в production коде. diff --git a/Dispatch_V0.1.1/gui/theme_bus.py b/Dispatch_V0.1.1/gui/theme_bus.py new file mode 100644 index 0000000..f42d9da --- /dev/null +++ b/Dispatch_V0.1.1/gui/theme_bus.py @@ -0,0 +1,48 @@ +# -*- coding: utf-8 -*- +# gui/theme_bus.py + +from PySide6.QtCore import QObject, Signal + +class ThemeBus(QObject): + theme_changed = Signal(str) # "dark" | "light" + +theme_bus = ThemeBus() + + +# для использования снаружи: +# theme_bus.theme_changed.emit("light") +# theme_bus.theme_changed.emit("dark") + + +# Подключение снаружи: +# from gui.theme.theme_bus import ThemeBus +# from gui.theme.theme_bus import theme_bus + +# theme_bus = ThemeBus() + +# btn = Button("Склад", index=0, theme_source=theme_bus) + +# theme_bus.theme_changed.emit("light") +# theme_bus.theme_changed.emit("dark") + + + + +# --------------------------------------------------------------------------- +# Module workflow notes (compact) +# --------------------------------------------------------------------------- +# +# 1) Назначение модуля: +# Глобальная шина событий для переключения темы (dark/light). +# Содержит QObject-синглтон theme_bus с сигналом theme_changed(str). +# Все StylableMixin-виджеты подписаны на этот сигнал. +# +# 2) Зависимости модуля: +# Импорты: QObject, Signal (PySide6.QtCore) +# Хост/базовый класс: QObject +# Внешние библиотеки: PySide6 +# +# 3) Экспорт: +# Класс ThemeBus — QObject с сигналом theme_changed: Signal(str). +# Экземпляр theme_bus — глобальный синглтон. +# Использование: theme_bus.theme_changed.emit("light"|"dark"). diff --git a/Dispatch_V0.1.1/logs/error.log b/Dispatch_V0.1.1/logs/error.log new file mode 100644 index 0000000..e69de29 diff --git a/Dispatch_V0.1.1/logs/interpreter.log b/Dispatch_V0.1.1/logs/interpreter.log new file mode 100644 index 0000000..e69de29 diff --git a/Dispatch_V0.1.1/main.py b/Dispatch_V0.1.1/main.py new file mode 100644 index 0000000..14e85f3 --- /dev/null +++ b/Dispatch_V0.1.1/main.py @@ -0,0 +1,118 @@ +# -*- coding: utf-8 -*- +# dispatch/main.py + +"""Точка входа независимого приложения Dispatch. + +Назначение модуля: + Минимальная процедура запуска отдельного приложения, которое + переиспользует модуль `hub.ticket` без копирования его логики. + +Последовательность инициализации: + 1. Подготовка диагностики и кодировок до тяжёлых импортов GUI. + 2. Создание `QApplication` и применение системной темы. + 3. Создание `DispatchMainWindow` и эмиссия текущей темы. + 4. Запуск цикла событий `QApplication`. +""" + +import sys + +from error_logger import install_interpreter_hooks, log_exception, setup_error_logging + + +def _configure_console_encoding() -> None: + """Настроить кодировку консоли для корректного вывода кириллицы.""" + import os + + try: + if sys.platform.startswith("win"): + import ctypes + + kernel32 = ctypes.windll.kernel32 + kernel32.SetConsoleOutputCP(65001) + kernel32.SetConsoleCP(65001) + if hasattr(sys.stdout, "reconfigure"): + sys.stdout.reconfigure(encoding="utf-8") + if hasattr(sys.stderr, "reconfigure"): + sys.stderr.reconfigure(encoding="utf-8") + os.environ["PYTHONIOENCODING"] = "utf-8" + except Exception as exc: + log_exception(__name__, "_configure_console_encoding", exc) + + +def _bootstrap_runtime_diagnostics() -> None: + """Включить журнал и перехват исключений до тяжёлых импортов.""" + _configure_console_encoding() + setup_error_logging() + install_interpreter_hooks() + + +def _setup_theme(app) -> str: + """Определить системную тему и применить базовую палитру окна.""" + from PySide6.QtCore import Qt + from PySide6.QtGui import QColor, QPalette + + theme = "dark" + color_scheme = Qt.ColorScheme.Unknown + if hasattr(app.styleHints(), "preferredColorScheme"): + color_scheme = app.styleHints().preferredColorScheme() + + if color_scheme == Qt.ColorScheme.Unknown: + window_color = app.palette().color(QPalette.ColorRole.Window) + color_scheme = ( + Qt.ColorScheme.Light + if window_color.lightness() >= 128 + else Qt.ColorScheme.Dark + ) + + if color_scheme == Qt.ColorScheme.Dark: + theme = "dark" + palette = app.palette() + palette.setColor(QPalette.ColorRole.Window, QColor("#232323")) + palette.setColor(QPalette.ColorRole.WindowText, Qt.GlobalColor.white) + palette.setColor(QPalette.ColorRole.Base, QColor("#3C3C3C")) + palette.setColor(QPalette.ColorRole.Text, Qt.GlobalColor.white) + palette.setColor(QPalette.ColorRole.Button, Qt.GlobalColor.darkGray) + palette.setColor(QPalette.ColorRole.ButtonText, Qt.GlobalColor.white) + palette.setColor(QPalette.ColorRole.Highlight, QColor("#5B9FFF")) + palette.setColor(QPalette.ColorRole.HighlightedText, Qt.GlobalColor.black) + app.setPalette(palette) + else: + theme = "light" + palette = app.palette() + palette.setColor(QPalette.ColorRole.Window, QColor("#F8F9FB")) + palette.setColor(QPalette.ColorRole.WindowText, QColor("#172B4D")) + palette.setColor(QPalette.ColorRole.Base, QColor("#F8F9FB")) + palette.setColor(QPalette.ColorRole.Text, QColor("#172B4D")) + palette.setColor(QPalette.ColorRole.Button, QColor("#FFFFFF")) + palette.setColor(QPalette.ColorRole.ButtonText, QColor("#172B4D")) + palette.setColor(QPalette.ColorRole.Highlight, QColor("#0C66E4")) + palette.setColor(QPalette.ColorRole.HighlightedText, QColor("#FFFFFF")) + app.setPalette(palette) + return theme + + +def main() -> None: + """Запустить независимое приложение Dispatch.""" + _bootstrap_runtime_diagnostics() + + from PySide6.QtWidgets import QApplication + + from gui.theme_bus import theme_bus + + from window import DispatchMainWindow + + app = QApplication(sys.argv) + app.setStyle("Fusion") + app.setApplicationName("Dispatch") + + current_theme = _setup_theme(app) + + window = DispatchMainWindow() + theme_bus.theme_changed.emit(current_theme) + window.show() + + sys.exit(app.exec()) + + +if __name__ == "__main__": + main() diff --git a/Dispatch_V0.1.1/module_contract.py b/Dispatch_V0.1.1/module_contract.py new file mode 100644 index 0000000..a476b0b --- /dev/null +++ b/Dispatch_V0.1.1/module_contract.py @@ -0,0 +1,75 @@ +# -*- coding: utf-8 -*- +# hub/ticket/module_contract.py + +"""Контракт каркаса Ticket для этапа 01 миграции.""" + +from dataclasses import dataclass + + +TARGET_TICKET_DIRECTORIES = ( + "application", + "domain", + "services", + "state", + "ui", +) + +PUBLIC_API_SYMBOLS = ( + "TicketPlugin", + "TicketApplicationApi", + "TicketStateApi", + "TicketHardwareGateway", +) + +LEGACY_BEHAVIOR_SOURCES = { + "application": ( + "presenter/task_controller.py", + "presenter/report_controller.py", + "presenter/specialist_controller.py", + "presenter/archive_controller.py", + ), + "state": ( + "model/app_state.py", + "model/persistence.py", + ), + "services": ( + "services/service_manager.py", + "services/serial_service.py", + "services/mock_service.py", + ), + "ui": ( + "view/board_module.py", + "view/task_widget_base.py", + "view/task_widget_details.py", + "view/reports_view.py", + "view/acts_view.py", + "view/report_dialog.py", + "view/acceptance_report_dialog.py", + ), +} + +FORBIDDEN_DIRECT_TRANSFER_FILES = ( + "client.py", + "client.bat", + "view/gui_main.py", + "view/left_panel_module.py", + "view/top_bar_module.py", + "model/task_state_manager.py", + "model/serial_service.py", +) + + +@dataclass(frozen=True, slots=True) +class TicketModuleContract: + """Краткое описание публичных точек входа нового Ticket.""" + + plugin_class_name: str = "TicketPlugin" + plugin_display_name: str = "Ticket" + state_api_name: str = "TicketStateApi" + application_api_name: str = "TicketApplicationApi" + hardware_gateway_api_name: str = "TicketHardwareGateway" + + +def build_ticket_module_contract() -> TicketModuleContract: + """Вернуть краткий публичный контракт нового модуля Ticket.""" + return TicketModuleContract() diff --git a/Dispatch_V0.1.1/null_hardware_gateway.py b/Dispatch_V0.1.1/null_hardware_gateway.py new file mode 100644 index 0000000..6e9c3d7 --- /dev/null +++ b/Dispatch_V0.1.1/null_hardware_gateway.py @@ -0,0 +1,96 @@ +# -*- coding: utf-8 -*- +# dispatch/null_hardware_gateway.py + +"""Пустой реализатор `TicketHardwareGateway` для приложения Dispatch. + +Назначение модуля: + В составе Dispatch модуль работы с COM-портом отключён по требованию + приложения. Application-сервис Ticket по архитектурному контракту + обязан получать на вход реализацию `TicketHardwareGateway` и + обращаться к ней при старте, остановке и обновлении состояний кнопок. + + Данный класс предоставляет канонически совместимую, но полностью + бездействующую реализацию шлюза: + - `start` / `stop` ничего не делают; + - `set_observer` принимает наблюдателя и сразу публикует ему + нейтральный статус подключения; + - методы управления состояниями кнопок принимают вызовы и + молча игнорируют их; + - `get_status` всегда возвращает «отключён». + +Архитектурные ограничения: + - Реализация полностью независима от каталога `gui/`. + - Никакой предметной логики Ticket здесь не содержится: класс + исключительно закрывает контракт `TicketHardwareGateway`. +""" + +from __future__ import annotations + +from typing import Any, Mapping + +from PySide6.QtCore import QObject + +from domain import TicketConnectionStatus, TicketHardwareStatus +from services import TicketHardwareObserver + + +class NullHardwareGateway(QObject): + """Бездействующий шлюз: закрывает контракт `TicketHardwareGateway`.""" + + def __init__(self, parent: QObject | None = None): + super().__init__(parent) + self._observer: TicketHardwareObserver | None = None + self._status = TicketHardwareStatus( + connection_status=TicketConnectionStatus.DISCONNECTED, + message="hardware module is disabled in Dispatch", + buttons_initialized=False, + button_count=0, + ) + + # ── управление жизненным циклом ── + + def start(self) -> None: + """Обозначить факт «запуска» без обращения к внешнему оборудованию.""" + if self._observer is not None: + self._observer.on_gateway_status(self._status) + + def stop(self) -> None: + """Завершить работу: ничего не освобождаем, состояние уже нейтральное.""" + return + + # ── подписка наблюдателя ── + + def set_observer(self, observer: TicketHardwareObserver | None) -> None: + """Назначить получателя событий и сразу опубликовать текущий статус.""" + self._observer = observer + if observer is not None: + observer.on_gateway_status(self._status) + + def get_status(self) -> TicketHardwareStatus: + """Вернуть постоянно-нейтральный статус шлюза.""" + return self._status + + # ── управление состояниями кнопок ── + + def set_button_state(self, button_id: int, state_code: int) -> None: + """Принять обновление состояния кнопки и проигнорировать его.""" + return + + def remove_button_state(self, button_id: int) -> None: + """Принять снятие состояния кнопки и проигнорировать его.""" + return + + def reset_button_states(self) -> None: + """Сбросить «состояния»: для null-шлюза это операция без эффекта.""" + return + + # ── совместимость с возможными внешними действиями ── + + def emit_external_action(self, raw_action: Mapping[str, Any]) -> None: + """Доставить внешнее действие наблюдателю, если он назначен. + + Метод оставлен для возможной интеграции внешних источников событий + в будущем; в текущем составе Dispatch не вызывается. + """ + if self._observer is not None: + self._observer.on_task_action(raw_action) diff --git a/Dispatch_V0.1.1/services/__init__.py b/Dispatch_V0.1.1/services/__init__.py new file mode 100644 index 0000000..6b90104 --- /dev/null +++ b/Dispatch_V0.1.1/services/__init__.py @@ -0,0 +1,23 @@ +# -*- coding: utf-8 -*- +# hub/ticket/services/__init__.py + +"""Публичный сервисный контур Ticket.""" + +from .base_service import BaseService +from .hardware_gateway import TicketHardwareGateway, TicketHardwareObserver +from .mock_service import MockService +from .serial_service import SERIAL_BAUDRATE, SERIAL_PORT, SERIAL_TIMEOUT, SerialService, probe_serial_port +from .service_manager import ServiceManager + +__all__ = [ + "BaseService", + "MockService", + "SERIAL_BAUDRATE", + "SERIAL_PORT", + "SERIAL_TIMEOUT", + "SerialService", + "ServiceManager", + "TicketHardwareGateway", + "TicketHardwareObserver", + "probe_serial_port", +] diff --git a/Dispatch_V0.1.1/services/__pycache__/__init__.cpython-313.pyc b/Dispatch_V0.1.1/services/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..256b3de Binary files /dev/null and b/Dispatch_V0.1.1/services/__pycache__/__init__.cpython-313.pyc differ diff --git a/Dispatch_V0.1.1/services/__pycache__/base_service.cpython-313.pyc b/Dispatch_V0.1.1/services/__pycache__/base_service.cpython-313.pyc new file mode 100644 index 0000000..a288cc0 Binary files /dev/null and b/Dispatch_V0.1.1/services/__pycache__/base_service.cpython-313.pyc differ diff --git a/Dispatch_V0.1.1/services/__pycache__/hardware_gateway.cpython-313.pyc b/Dispatch_V0.1.1/services/__pycache__/hardware_gateway.cpython-313.pyc new file mode 100644 index 0000000..9ba3121 Binary files /dev/null and b/Dispatch_V0.1.1/services/__pycache__/hardware_gateway.cpython-313.pyc differ diff --git a/Dispatch_V0.1.1/services/__pycache__/mock_service.cpython-313.pyc b/Dispatch_V0.1.1/services/__pycache__/mock_service.cpython-313.pyc new file mode 100644 index 0000000..feb1b9d Binary files /dev/null and b/Dispatch_V0.1.1/services/__pycache__/mock_service.cpython-313.pyc differ diff --git a/Dispatch_V0.1.1/services/__pycache__/serial_service.cpython-313.pyc b/Dispatch_V0.1.1/services/__pycache__/serial_service.cpython-313.pyc new file mode 100644 index 0000000..3d9fc36 Binary files /dev/null and b/Dispatch_V0.1.1/services/__pycache__/serial_service.cpython-313.pyc differ diff --git a/Dispatch_V0.1.1/services/__pycache__/service_manager.cpython-313.pyc b/Dispatch_V0.1.1/services/__pycache__/service_manager.cpython-313.pyc new file mode 100644 index 0000000..ca443b9 Binary files /dev/null and b/Dispatch_V0.1.1/services/__pycache__/service_manager.cpython-313.pyc differ diff --git a/Dispatch_V0.1.1/services/base_service.py b/Dispatch_V0.1.1/services/base_service.py new file mode 100644 index 0000000..e35a070 --- /dev/null +++ b/Dispatch_V0.1.1/services/base_service.py @@ -0,0 +1,93 @@ +# -*- coding: utf-8 -*- +# hub/ticket/services/base_service.py + +"""Базовый транспортный сервис Ticket без доменной логики.""" + +from __future__ import annotations + +from dataclasses import replace + +from PySide6.QtCore import QObject, Signal + +from domain import TicketConnectionStatus, TicketHardwareStatus +from domain.ticket_constants import STATE_TODO + + +class BaseService(QObject): + """Общий контракт transport-сервисов Ticket.""" + + action_triggered = Signal(object) + error_occurred = Signal(str) + com_status_changed = Signal(bool, str) + buttons_initialized = Signal(bool, int) + port_disconnected = Signal() + + def __init__(self, parent: QObject | None = None): + super().__init__(parent) + self._button_states: dict[int, int] = {} + self._status = TicketHardwareStatus() + + def start(self) -> None: + raise NotImplementedError + + def stop(self) -> None: + raise NotImplementedError + + def is_running(self) -> bool: + raise NotImplementedError + + def get_status(self) -> TicketHardwareStatus: + """Вернуть последний известный статус transport-сервиса.""" + return self._status + + def set_button_state(self, button_id: int, state_code: int) -> None: + """Сохранить последнее известное состояние кнопки.""" + self._button_states[int(button_id)] = int(state_code) + + def remove_button_state(self, button_id: int) -> None: + """Удалить состояние кнопки из локального transport-кэша.""" + self._button_states.pop(int(button_id), None) + + def reset_button_states(self) -> None: + """Сбросить все известные состояния кнопок.""" + self._button_states.clear() + + def _get_button_state(self, button_id: int) -> int: + return self._button_states.get(int(button_id), STATE_TODO) + + def _set_connection_status( + self, + connection_status: TicketConnectionStatus, + message: str, + ) -> None: + if ( + self._status.connection_status == connection_status + and self._status.message == message + ): + return + self._status = replace( + self._status, + connection_status=connection_status, + message=message, + ) + self.com_status_changed.emit( + connection_status == TicketConnectionStatus.CONNECTED, + message, + ) + + def _set_button_initialization( + self, + is_initialized: bool, + button_count: int, + ) -> None: + if ( + self._status.buttons_initialized == is_initialized + and self._status.button_count == button_count + ): + return + self._status = replace( + self._status, + buttons_initialized=is_initialized, + button_count=button_count, + ) + self.buttons_initialized.emit(is_initialized, button_count) diff --git a/Dispatch_V0.1.1/services/hardware_gateway.py b/Dispatch_V0.1.1/services/hardware_gateway.py new file mode 100644 index 0000000..e235c48 --- /dev/null +++ b/Dispatch_V0.1.1/services/hardware_gateway.py @@ -0,0 +1,48 @@ +# -*- coding: utf-8 -*- +# hub/ticket/services/hardware_gateway.py + +"""Публичный контракт аппаратного шлюза Ticket.""" + +from __future__ import annotations + +from typing import Any, Mapping, Protocol + +from domain import TicketHardwareStatus + + +class TicketHardwareObserver(Protocol): + """Получатель событий от аппаратного или mock-шлюза.""" + + def on_task_action(self, raw_action: Mapping[str, Any]) -> None: + """Принять внешнее действие по задаче.""" + + def on_gateway_status(self, status: TicketHardwareStatus) -> None: + """Принять обновление статуса подключения и инициализации.""" + + def on_gateway_error(self, message: str) -> None: + """Принять сообщение об ошибке шлюза.""" + + +class TicketHardwareGateway(Protocol): + """Минимальный публичный API сервиса ввода событий Ticket.""" + + def start(self) -> None: + """Запустить внешний источник событий.""" + + def stop(self) -> None: + """Остановить внешний источник событий.""" + + def get_status(self) -> TicketHardwareStatus: + """Вернуть текущий статус шлюза.""" + + def set_observer(self, observer: TicketHardwareObserver | None) -> None: + """Назначить получателя событий приложения.""" + + def set_button_state(self, button_id: int, state_code: int) -> None: + """Синхронизировать каноническое состояние кнопки для аппаратного шлюза.""" + + def remove_button_state(self, button_id: int) -> None: + """Удалить известное состояние кнопки из шлюза.""" + + def reset_button_states(self) -> None: + """Сбросить все известные состояния кнопок при смене контекста.""" diff --git a/Dispatch_V0.1.1/services/mock_service.py b/Dispatch_V0.1.1/services/mock_service.py new file mode 100644 index 0000000..577fef0 --- /dev/null +++ b/Dispatch_V0.1.1/services/mock_service.py @@ -0,0 +1,33 @@ +# -*- coding: utf-8 -*- +# hub/ticket/services/mock_service.py + +"""Offline transport-сервис Ticket.""" + +from __future__ import annotations + +from PySide6.QtCore import QObject + +from domain import TicketConnectionStatus +from .base_service import BaseService + + +class MockService(BaseService): + """Безопасный offline-режим при недоступном COM-порте.""" + + def __init__(self, parent: QObject | None = None): + super().__init__(parent) + self._running = False + + def start(self) -> None: + self._running = True + self._set_connection_status( + TicketConnectionStatus.DISCONNECTED, + "Оффлайн-режим", + ) + self._set_button_initialization(False, 0) + + def stop(self) -> None: + self._running = False + + def is_running(self) -> bool: + return self._running diff --git a/Dispatch_V0.1.1/services/serial_service.py b/Dispatch_V0.1.1/services/serial_service.py new file mode 100644 index 0000000..370378a --- /dev/null +++ b/Dispatch_V0.1.1/services/serial_service.py @@ -0,0 +1,316 @@ +# -*- coding: utf-8 -*- +# hub/ticket/services/serial_service.py + +"""Serial transport-сервис Ticket с подтверждением состояний кнопок.""" + +from __future__ import annotations + +import threading +import time +from typing import Any + +from PySide6.QtCore import QObject + +from error_logger import log_exception + +from domain import TicketConnectionStatus +from domain.ticket_constants import HARDWARE_SIGNAL_INITIALIZE +from .base_service import BaseService + +try: + import serial + from serial.tools import list_ports +except ImportError: + serial = None + list_ports = None +SERIAL_PORT = "COM21" +SERIAL_BAUDRATE = 9600 +SERIAL_TIMEOUT = 0.1 +def probe_serial_port(port: str) -> bool: + """Проверить доступность COM-порта без запуска transport-потока.""" + if serial is None or list_ports is None: + return False + try: + available_ports = {item.device for item in list_ports.comports()} + except Exception as exc: + log_exception(__name__, "probe_serial_port", exc) + return False + if port not in available_ports: + return False + serial_port = None + try: + serial_port = serial.Serial(port=port, baudrate=SERIAL_BAUDRATE, timeout=SERIAL_TIMEOUT) + return True + except Exception as exc: + log_exception(__name__, "probe_serial_port.serial_open", exc) + return False + finally: + if serial_port is not None and serial_port.is_open: + serial_port.close() +class SerialService(BaseService): + """Transport-сервис чтения и записи пакетов COM-порта.""" + + def __init__( + self, + port: str = SERIAL_PORT, + baudrate: int = SERIAL_BAUDRATE, + timeout: float = SERIAL_TIMEOUT, + resend_interval_sec: float = 1.0, + reconnect_delay_sec: float = 1.0, + max_connect_attempts: int = 3, + parent: QObject | None = None, + ): + super().__init__(parent) + self._port_name = port + self._baudrate = baudrate + self._timeout = timeout + self._resend_interval_sec = resend_interval_sec + self._reconnect_delay_sec = reconnect_delay_sec + self._max_connect_attempts = max_connect_attempts + self._lock = threading.Lock() + self._stop_event = threading.Event() + self._thread: threading.Thread | None = None + self._serial_port: Any | None = None + self._buffer = bytearray() + self._initialized_buttons: set[int] = set() + self._pending_states: dict[int, int] = {} + self._retry_deadlines: dict[int, float] = {} + + def start(self) -> None: + if self.is_running(): + return + if serial is None: + message = "pyserial недоступен, serial transport не может быть запущен." + self.error_occurred.emit(message) + self._set_connection_status(TicketConnectionStatus.ERROR, message) + self._set_button_initialization(False, 0) + return + self._stop_event.clear() + self._thread = threading.Thread(target=self._run_loop, name="TicketSerialService", daemon=True) + self._thread.start() + + def stop(self) -> None: + self._stop_event.set() + self._close_port("Порт закрыт") + if self._thread is not None: + self._thread.join(timeout=1.0) + self._thread = None + + def is_running(self) -> bool: + return self._thread is not None and self._thread.is_alive() + + def set_button_state(self, button_id: int, state_code: int) -> None: + super().set_button_state(button_id, state_code) + with self._lock: + button_id = int(button_id) + state_code = int(state_code) + self._pending_states[button_id] = state_code + serial_port = self._serial_port + port_open = serial_port is not None and serial_port.is_open + is_initialized = button_id in self._initialized_buttons + if port_open: + self._send_state(button_id, state_code, schedule_retry=is_initialized) + + def remove_button_state(self, button_id: int) -> None: + super().remove_button_state(button_id) + with self._lock: + button_id = int(button_id) + self._pending_states.pop(button_id, None) + self._retry_deadlines.pop(button_id, None) + + def reset_button_states(self) -> None: + super().reset_button_states() + with self._lock: + self._pending_states.clear() + self._retry_deadlines.clear() + + def _run_loop(self) -> None: + attempts = 0 + while not self._stop_event.is_set(): + if not self._is_port_open(): + if self._connect_port(): + attempts = 0 + self._flush_known_states() + else: + attempts += 1 + if attempts >= self._max_connect_attempts: + try: + self.error_occurred.emit( + f"Не удалось подключиться к {self._port_name}." + ) + self.port_disconnected.emit() + except RuntimeError: + pass + return + self._stop_event.wait(self._reconnect_delay_sec) + continue + try: + self._read_available_packets() + self._retry_pending_states_if_needed() + except Exception as exc: + log_exception(__name__, "SerialService._run_loop", exc) + try: + self.port_disconnected.emit() + except RuntimeError: + return + self._close_port("Порт закрыт") + self._stop_event.wait(self._reconnect_delay_sec) + continue + self._stop_event.wait(0.05) + + def _connect_port(self) -> bool: + try: + serial_port = serial.Serial( + port=self._port_name, + baudrate=self._baudrate, + timeout=self._timeout, + write_timeout=0.5, + inter_byte_timeout=0.005, + ) + serial_port.reset_input_buffer() + serial_port.reset_output_buffer() + except Exception as exc: + log_exception(__name__, "SerialService._connect_port", exc) + self._set_connection_status( + TicketConnectionStatus.ERROR, + f"Ошибка подключения к {self._port_name}: {exc}", + ) + self._set_button_initialization(False, 0) + return False + with self._lock: + self._serial_port = serial_port + self._buffer.clear() + self._set_connection_status( + TicketConnectionStatus.CONNECTED, + f"{self._port_name} ({self._baudrate} бод)", + ) + self._set_button_initialization(False, 0) + return True + + def _read_available_packets(self) -> None: + with self._lock: + serial_port = self._serial_port + if serial_port is None or not serial_port.is_open: + return + data_available = getattr(serial_port, "in_waiting", 0) + if data_available <= 0: + return + payload = serial_port.read(data_available) + if not payload: + return + with self._lock: + self._buffer.extend(payload) + self._process_buffer() + + def _process_buffer(self) -> None: + while True: + with self._lock: + if len(self._buffer) < 4: + return + packet_index = self._find_packet_start() + if packet_index is None: + if len(self._buffer) > 100: + self._buffer.clear() + return + button_id = self._buffer[packet_index] + hardware_state = self._buffer[packet_index + 1] + del self._buffer[: packet_index + 4] + self._handle_packet(button_id, hardware_state) + + def _find_packet_start(self) -> int | None: + buffer_length = len(self._buffer) + for index in range(buffer_length - 3): + if self._buffer[index + 2] == 0x0D and self._buffer[index + 3] == 0x0A: + return index + return None + + def _handle_packet(self, button_id: int, hardware_state: int) -> None: + if not 1 <= button_id <= 8: + return + if hardware_state == HARDWARE_SIGNAL_INITIALIZE: + self._handle_initialization_request(button_id) + return + if hardware_state == 0xAA: + self._handle_confirmation(button_id) + return + if hardware_state not in (0, 1, 2, 3): + self.error_occurred.emit( + f"Неизвестное аппаратное состояние: {hardware_state:02X}" + ) + return + self.action_triggered.emit( + { + "event": "advance", + "button_id": button_id, + "hardware_state": hardware_state, + "current_state_code": self._get_button_state(button_id), + } + ) + + def _handle_initialization_request(self, button_id: int) -> None: + with self._lock: + self._initialized_buttons.add(button_id) + button_count = len(self._initialized_buttons) + self._set_button_initialization(True, button_count) + self._send_state(button_id, self._get_button_state(button_id)) + + def _handle_confirmation(self, button_id: int) -> None: + with self._lock: + self._pending_states.pop(button_id, None) + self._retry_deadlines.pop(button_id, None) + + def _retry_pending_states_if_needed(self) -> None: + now = time.monotonic() + with self._lock: + pending_items = list(self._retry_deadlines.items()) + for button_id, retry_deadline in pending_items: + if retry_deadline <= now: + self._send_state(button_id, self._get_button_state(button_id)) + + def _flush_known_states(self) -> None: + """Отправить все известные состояния кнопок однократно (без retry).""" + for button_id, state_code in list(self._button_states.items()): + self._send_state(button_id, state_code, schedule_retry=False) + + def _send_state(self, button_id: int, state_code: int, *, schedule_retry: bool = True) -> bool: + packet = bytes([int(button_id), int(state_code) & 0xFF, 0x0D, 0x0A]) + try: + with self._lock: + serial_port = self._serial_port + if serial_port is None or not serial_port.is_open: + return False + serial_port.write(packet) + serial_port.flush() + if schedule_retry: + self._pending_states[int(button_id)] = int(state_code) + self._retry_deadlines[int(button_id)] = time.monotonic() + self._resend_interval_sec + except Exception as exc: + log_exception(__name__, "SerialService._send_state", exc) + try: + self.port_disconnected.emit() + except RuntimeError: + return False + self._close_port("Порт закрыт") + return False + return True + + def _close_port(self, message: str) -> None: + with self._lock: + serial_port = self._serial_port + self._serial_port = None + self._buffer.clear() + self._initialized_buttons.clear() + self._pending_states.clear() + self._retry_deadlines.clear() + if serial_port is not None and serial_port.is_open: + try: + serial_port.close() + except Exception as exc: + log_exception(__name__, "SerialService._close_port", exc) + self._set_button_initialization(False, 0) + self._set_connection_status(TicketConnectionStatus.DISCONNECTED, message) + + def _is_port_open(self) -> bool: + with self._lock: + serial_port = self._serial_port + return serial_port is not None and serial_port.is_open diff --git a/Dispatch_V0.1.1/services/service_manager.py b/Dispatch_V0.1.1/services/service_manager.py new file mode 100644 index 0000000..2a119d6 --- /dev/null +++ b/Dispatch_V0.1.1/services/service_manager.py @@ -0,0 +1,177 @@ +# -*- coding: utf-8 -*- +# hub/ticket/services/service_manager.py + +"""Единый hardware gateway Ticket поверх serial/mock transport-сервисов.""" + +from __future__ import annotations + +from PySide6.QtCore import QObject, QTimer, Signal + +from domain import TicketHardwareStatus +from .base_service import BaseService +from .hardware_gateway import TicketHardwareObserver +from .mock_service import MockService +from .serial_service import SERIAL_BAUDRATE, SERIAL_PORT, SerialService, probe_serial_port + + +PROBE_INTERVAL_SEC = 3 + + +class ServiceManager(QObject): + """Канонический hardware gateway Ticket.""" + + service_changed = Signal(str, bool) + action_triggered = Signal(object) + error_occurred = Signal(str) + com_status_changed = Signal(bool, str) + buttons_initialized = Signal(bool, int) + port_disconnected = Signal() + + def __init__( + self, + serial_port: str = SERIAL_PORT, + serial_baudrate: int = SERIAL_BAUDRATE, + probe_interval_sec: int = PROBE_INTERVAL_SEC, + parent: QObject | None = None, + ): + super().__init__(parent) + self._serial_port = serial_port + self._serial_baudrate = serial_baudrate + self._observer: TicketHardwareObserver | None = None + self._current_service: BaseService | None = None + self._current_service_name = "none" + self._button_states: dict[int, int] = {} + self._status = TicketHardwareStatus() + self._probe_timer = QTimer(self) + self._probe_timer.setInterval(probe_interval_sec * 1000) + self._probe_timer.timeout.connect(self._on_probe_timer) + + def start(self) -> None: + if self._current_service is not None: + return + if probe_serial_port(self._serial_port): + self._start_serial_service() + else: + self._start_mock_service() + + def stop(self) -> None: + self._probe_timer.stop() + self._shutdown_current_service() + + def get_status(self) -> TicketHardwareStatus: + return self._status + + def set_observer(self, observer: TicketHardwareObserver | None) -> None: + self._observer = observer + if observer is not None: + observer.on_gateway_status(self._status) + + def set_button_state(self, button_id: int, state_code: int) -> None: + self._button_states[int(button_id)] = int(state_code) + if self._current_service is not None: + self._current_service.set_button_state(button_id, state_code) + + def remove_button_state(self, button_id: int) -> None: + self._button_states.pop(int(button_id), None) + if self._current_service is not None: + self._current_service.remove_button_state(button_id) + + def reset_button_states(self) -> None: + self._button_states.clear() + if self._current_service is not None: + self._current_service.reset_button_states() + + def _start_serial_service(self) -> None: + service = SerialService( + port=self._serial_port, + baudrate=self._serial_baudrate, + parent=self, + ) + if not self._activate_service(service, "serial", True): + self._start_mock_service() + return + self._probe_timer.stop() + + def _start_mock_service(self) -> None: + service = MockService(parent=self) + self._activate_service(service, "mock", False) + self._probe_timer.start() + + def _activate_service( + self, + service: BaseService, + service_name: str, + is_connected: bool, + ) -> bool: + self._shutdown_current_service() + self._connect_service_signals(service) + for button_id, state_code in self._button_states.items(): + service.set_button_state(button_id, state_code) + service.start() + if not service.is_running() and service_name == "serial": + service.deleteLater() + return False + self._current_service = service + self._current_service_name = service_name + self._refresh_status() + self.service_changed.emit(service_name, is_connected) + return True + + def _connect_service_signals(self, service: BaseService) -> None: + service.action_triggered.connect(self._on_action_triggered) + service.error_occurred.connect(self._on_error_occurred) + service.com_status_changed.connect(self._on_com_status_changed) + service.buttons_initialized.connect(self._on_buttons_initialized) + service.port_disconnected.connect(self._on_port_disconnected) + + def _shutdown_current_service(self) -> None: + if self._current_service is None: + return + current_service = self._current_service + self._current_service = None + self._current_service_name = "none" + current_service.stop() + current_service.deleteLater() + self._status = TicketHardwareStatus() + self._notify_status_observer() + + def _refresh_status(self) -> None: + if self._current_service is None: + self._status = TicketHardwareStatus() + else: + self._status = self._current_service.get_status() + self._notify_status_observer() + + def _notify_status_observer(self) -> None: + if self._observer is not None: + self._observer.on_gateway_status(self._status) + + def _on_action_triggered(self, raw_action: object) -> None: + self.action_triggered.emit(raw_action) + if self._observer is not None and isinstance(raw_action, dict): + self._observer.on_task_action(raw_action) + + def _on_error_occurred(self, message: str) -> None: + self.error_occurred.emit(message) + if self._observer is not None: + self._observer.on_gateway_error(message) + + def _on_com_status_changed(self, is_connected: bool, message: str) -> None: + self._refresh_status() + self.com_status_changed.emit(is_connected, message) + + def _on_buttons_initialized(self, is_initialized: bool, button_count: int) -> None: + self._refresh_status() + self.buttons_initialized.emit(is_initialized, button_count) + + def _on_port_disconnected(self) -> None: + self.port_disconnected.emit() + if self._current_service_name == "serial": + self._start_mock_service() + + def _on_probe_timer(self) -> None: + port_available = probe_serial_port(self._serial_port) + if port_available and self._current_service_name == "mock": + self._start_serial_service() + elif not port_available and self._current_service_name == "serial": + self._start_mock_service() diff --git a/Dispatch_V0.1.1/state/__init__.py b/Dispatch_V0.1.1/state/__init__.py new file mode 100644 index 0000000..3709de3 --- /dev/null +++ b/Dispatch_V0.1.1/state/__init__.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- +# hub/ticket/state/__init__.py + +"""Публичный state-контур Ticket.""" + +from .document_repository import TicketDocumentRepository +from .paths import ACTS_DIR, ARCHIVE_DIR, DATA_DIR, REPORTS_DIR, ROOT_DIR, TASKS_FILE +from .archive_record_repository import ArchiveRecordRepository +from .repository import TicketStateRepository +from .runtime_state import TicketRuntimeState +from .ticket_state_api import TicketStateApi + +__all__ = [ + "ACTS_DIR", + "ARCHIVE_DIR", + "ArchiveRecordRepository", + "DATA_DIR", + "REPORTS_DIR", + "ROOT_DIR", + "TASKS_FILE", + "TicketDocumentRepository", + "TicketRuntimeState", + "TicketStateApi", + "TicketStateRepository", +] diff --git a/Dispatch_V0.1.1/state/__pycache__/__init__.cpython-313.pyc b/Dispatch_V0.1.1/state/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..b01a6a5 Binary files /dev/null and b/Dispatch_V0.1.1/state/__pycache__/__init__.cpython-313.pyc differ diff --git a/Dispatch_V0.1.1/state/__pycache__/archive_record_repository.cpython-313.pyc b/Dispatch_V0.1.1/state/__pycache__/archive_record_repository.cpython-313.pyc new file mode 100644 index 0000000..365e3d5 Binary files /dev/null and b/Dispatch_V0.1.1/state/__pycache__/archive_record_repository.cpython-313.pyc differ diff --git a/Dispatch_V0.1.1/state/__pycache__/document_repository.cpython-313.pyc b/Dispatch_V0.1.1/state/__pycache__/document_repository.cpython-313.pyc new file mode 100644 index 0000000..9ae078a Binary files /dev/null and b/Dispatch_V0.1.1/state/__pycache__/document_repository.cpython-313.pyc differ diff --git a/Dispatch_V0.1.1/state/__pycache__/paths.cpython-313.pyc b/Dispatch_V0.1.1/state/__pycache__/paths.cpython-313.pyc new file mode 100644 index 0000000..e8462a9 Binary files /dev/null and b/Dispatch_V0.1.1/state/__pycache__/paths.cpython-313.pyc differ diff --git a/Dispatch_V0.1.1/state/__pycache__/repository.cpython-313.pyc b/Dispatch_V0.1.1/state/__pycache__/repository.cpython-313.pyc new file mode 100644 index 0000000..95b0086 Binary files /dev/null and b/Dispatch_V0.1.1/state/__pycache__/repository.cpython-313.pyc differ diff --git a/Dispatch_V0.1.1/state/__pycache__/runtime_state.cpython-313.pyc b/Dispatch_V0.1.1/state/__pycache__/runtime_state.cpython-313.pyc new file mode 100644 index 0000000..fffce75 Binary files /dev/null and b/Dispatch_V0.1.1/state/__pycache__/runtime_state.cpython-313.pyc differ diff --git a/Dispatch_V0.1.1/state/__pycache__/ticket_state_api.cpython-313.pyc b/Dispatch_V0.1.1/state/__pycache__/ticket_state_api.cpython-313.pyc new file mode 100644 index 0000000..cdf9d1b Binary files /dev/null and b/Dispatch_V0.1.1/state/__pycache__/ticket_state_api.cpython-313.pyc differ diff --git a/Dispatch_V0.1.1/state/archive_record_repository.py b/Dispatch_V0.1.1/state/archive_record_repository.py new file mode 100644 index 0000000..8420989 --- /dev/null +++ b/Dispatch_V0.1.1/state/archive_record_repository.py @@ -0,0 +1,195 @@ +# -*- coding: utf-8 -*- +# hub/ticket/state/archive_record_repository.py + +"""Файловый репозиторий архивных записей Ticket.""" + +from __future__ import annotations + +import json +from datetime import datetime +from pathlib import Path +from typing import Any + +from error_logger import log_exception + +from domain import ArchiveRecordSnapshot, TicketDocumentSnapshot, TicketTaskSnapshot +from .paths import ARCHIVE_DIR, ensure_storage_directories + + +class ArchiveRecordRepository: + """Каноническое файловое хранилище архивных записей Ticket.""" + + def __init__(self, archive_dir: Path = ARCHIVE_DIR): + self._archive_dir = archive_dir + ensure_storage_directories() + self._archive_dir.mkdir(parents=True, exist_ok=True) + + def has_record(self, task_id: int, cycle_token: str = "") -> bool: + """Проверить, существует ли запись для задачи (и цикла).""" + if not cycle_token: + pattern = f"archive_task_{task_id}_*.json" + return any(self._archive_dir.glob(pattern)) + for path in self._archive_dir.glob(f"archive_task_{task_id}_*.json"): + record = self._load_record(path) + if record is None: + continue + if record.created_at is not None: + token = record.created_at.strftime("%Y%m%d_%H%M%S") + if token == cycle_token: + return True + return False + + def save_record( + self, + task: TicketTaskSnapshot, + documents: list[TicketDocumentSnapshot], + ) -> ArchiveRecordSnapshot | None: + """Сохранить архивную запись для завершённой/отказной задачи.""" + now = datetime.now() + pre_state_code = self._resolve_pre_archive_state(task) + pre_state_name = self._resolve_pre_archive_state_name(task, pre_state_code) + document_ids = tuple(doc.document_id for doc in documents) + + record = ArchiveRecordSnapshot( + task_id=task.task_id, + location=task.location, + pre_archive_state_code=pre_state_code, + pre_archive_state_name=pre_state_name, + color_hex=task.color_hex, + created_at=task.created_at, + completed_at=task.completed_at, + archived_at=now, + refused_from_state=task.refused_from_state, + refusal_reason=task.refusal_reason, + assigned_specialist=task.assigned_specialist, + specialist_photo=task.specialist_photo, + diagnostic_report_signed=task.diagnostic_report_signed, + repair_report_signed=task.repair_report_signed, + acceptance_report_signed=task.acceptance_report_signed, + document_ids=document_ids, + action_text=task.action_text, + sequence_number=task.sequence_number, + ) + + file_path = self._record_path(task.task_id, now) + payload = self._serialize(record) + try: + with open(file_path, "w", encoding="utf-8") as handle: + json.dump(payload, handle, indent=2, ensure_ascii=False) + except Exception as exc: + log_exception(__name__, "ArchiveRecordRepository.save_record", exc) + return None + return record + + def list_records(self) -> list[ArchiveRecordSnapshot]: + """Загрузить все архивные записи из каталога.""" + records: list[ArchiveRecordSnapshot] = [] + for path in self._archive_dir.glob("*.json"): + record = self._load_record(path) + if record is not None: + records.append(record) + records.sort(key=lambda r: r.archived_at or datetime.min, reverse=True) + return records + + def _load_record(self, path: Path) -> ArchiveRecordSnapshot | None: + try: + with open(path, "r", encoding="utf-8") as handle: + data = json.load(handle) + except Exception as exc: + log_exception(__name__, "ArchiveRecordRepository._load_record", exc) + return None + if not isinstance(data, dict): + return None + return self._deserialize(data) + + def _record_path(self, task_id: int, archived_at: datetime) -> Path: + stamp = archived_at.strftime("%Y%m%d_%H%M%S") + return self._archive_dir / f"archive_task_{task_id}_{stamp}.json" + + @staticmethod + def _resolve_pre_archive_state(task: TicketTaskSnapshot) -> int: + from ..domain.ticket_constants import STATE_COMPLETED, STATE_REFUSED + if task.state_code == STATE_COMPLETED: + return STATE_COMPLETED + if task.state_code == STATE_REFUSED: + return STATE_REFUSED + if task.refused_from_state is not None: + return STATE_REFUSED + return STATE_COMPLETED + + @staticmethod + def _resolve_pre_archive_state_name(task: TicketTaskSnapshot, state_code: int) -> str: + from ..domain.ticket_constants import STATE_REFUSED + if state_code == STATE_REFUSED: + return "Отказ в обслуживании" + return "Выполненные" + + @staticmethod + def _serialize(record: ArchiveRecordSnapshot) -> dict[str, Any]: + return { + "task_id": record.task_id, + "location": record.location, + "pre_archive_state_code": record.pre_archive_state_code, + "pre_archive_state_name": record.pre_archive_state_name, + "color_hex": record.color_hex, + "created_at": record.created_at.isoformat() if record.created_at else None, + "completed_at": record.completed_at.isoformat() if record.completed_at else None, + "archived_at": record.archived_at.isoformat() if record.archived_at else None, + "refused_from_state": record.refused_from_state, + "refusal_reason": record.refusal_reason, + "assigned_specialist": record.assigned_specialist, + "specialist_photo": record.specialist_photo, + "diagnostic_report_signed": record.diagnostic_report_signed, + "repair_report_signed": record.repair_report_signed, + "acceptance_report_signed": record.acceptance_report_signed, + "document_ids": list(record.document_ids), + "action_text": record.action_text, + "sequence_number": record.sequence_number, + } + + @staticmethod + def _deserialize(data: dict[str, Any]) -> ArchiveRecordSnapshot | None: + try: + task_id = int(data["task_id"]) + except (KeyError, TypeError, ValueError): + return None + return ArchiveRecordSnapshot( + task_id=task_id, + location=str(data.get("location", "")), + pre_archive_state_code=int(data.get("pre_archive_state_code", 0)), + pre_archive_state_name=str(data.get("pre_archive_state_name", "")), + color_hex=str(data.get("color_hex", "#FFFFFF")), + created_at=_parse_datetime(data.get("created_at")), + completed_at=_parse_datetime(data.get("completed_at")), + archived_at=_parse_datetime(data.get("archived_at")), + refused_from_state=_parse_optional_int(data.get("refused_from_state")), + refusal_reason=str(data.get("refusal_reason", "")), + assigned_specialist=str(data.get("assigned_specialist", "")), + specialist_photo=str(data.get("specialist_photo", "")), + diagnostic_report_signed=bool(data.get("diagnostic_report_signed")), + repair_report_signed=bool(data.get("repair_report_signed")), + acceptance_report_signed=bool(data.get("acceptance_report_signed")), + document_ids=tuple(data.get("document_ids", ())), + action_text=str(data.get("action_text", "")), + sequence_number=int(data.get("sequence_number", 0) or 0), + ) + + +def _parse_datetime(value: Any) -> datetime | None: + if value is None: + return None + if isinstance(value, str): + try: + return datetime.fromisoformat(value) + except ValueError: + return None + return None + + +def _parse_optional_int(value: Any) -> int | None: + if value is None: + return None + try: + return int(value) + except (TypeError, ValueError): + return None diff --git a/Dispatch_V0.1.1/state/document_repository.py b/Dispatch_V0.1.1/state/document_repository.py new file mode 100644 index 0000000..13ca129 --- /dev/null +++ b/Dispatch_V0.1.1/state/document_repository.py @@ -0,0 +1,187 @@ +# -*- coding: utf-8 -*- +# hub/ticket/state/document_repository.py + +"""Файловый репозиторий документов Ticket.""" + +from __future__ import annotations + +import json +from datetime import datetime +from pathlib import Path +from typing import Any, Iterable, Mapping + +from error_logger import log_exception + +from domain import TicketDocumentSnapshot, TicketTaskSnapshot +from .paths import ACTS_DIR, REPORTS_DIR, ensure_storage_directories + + +REPORT_DOCUMENT_TYPES = {"diagnostic", "repair"} + + +class TicketDocumentRepository: + """Каноническое файловое хранилище отчётов и актов Ticket.""" + + def __init__( + self, + reports_dir: Path = REPORTS_DIR, + acts_dir: Path = ACTS_DIR, + ): + self._reports_dir = reports_dir + self._acts_dir = acts_dir + ensure_storage_directories() + self._reports_dir.mkdir(parents=True, exist_ok=True) + self._acts_dir.mkdir(parents=True, exist_ok=True) + + def save_document( + self, + task: TicketTaskSnapshot, + document_type: str, + title: str, + summary: str, + content: str, + payload: Mapping[str, str], + ) -> TicketDocumentSnapshot | None: + """Сохранить документ в каноническое JSON-хранилище.""" + created_at = datetime.now() + document_id = self._build_document_id(task, document_type) + storage_path = self._directory_for_type(document_type) / f"{document_id}.json" + snapshot = TicketDocumentSnapshot( + document_id=document_id, + task_id=task.task_id, + document_type=document_type, + title=title, + created_at=created_at, + location=task.location, + specialist_name=task.assigned_specialist, + summary=summary, + content=content, + storage_path=str(storage_path), + payload=dict(payload), + ) + payload_data = self._serialize_snapshot(snapshot) + try: + with open(storage_path, "w", encoding="utf-8") as handle: + json.dump( + payload_data, + handle, + indent=2, + ensure_ascii=False, + ) + except Exception as exc: + log_exception(__name__, "TicketDocumentRepository.save_document", exc) + return None + return snapshot + + def list_documents( + self, + document_type: str | None = None, + ) -> list[TicketDocumentSnapshot]: + """Вернуть список документов с optional-фильтром по типу.""" + snapshots: list[TicketDocumentSnapshot] = [] + for directory in self._directories_for_filter(document_type): + for path in directory.glob("*.json"): + snapshot = self._load_snapshot(path) + if snapshot is None: + continue + if document_type == "report" and snapshot.document_type not in REPORT_DOCUMENT_TYPES: + continue + if document_type not in {None, "report"} and snapshot.document_type != document_type: + continue + snapshots.append(snapshot) + snapshots.sort(key=lambda item: item.created_at, reverse=True) + return snapshots + + def _load_snapshot(self, storage_path: Path) -> TicketDocumentSnapshot | None: + try: + with open(storage_path, "r", encoding="utf-8") as handle: + payload = json.load(handle) + except Exception as exc: + log_exception(__name__, "TicketDocumentRepository._load_snapshot", exc) + return None + if not isinstance(payload, dict): + return None + return self._deserialize_snapshot(payload, storage_path) + + def _deserialize_snapshot( + self, + payload: Mapping[str, Any], + storage_path: Path, + ) -> TicketDocumentSnapshot | None: + raw_created_at = payload.get("created_at") + if not isinstance(raw_created_at, str): + return None + try: + created_at = datetime.fromisoformat(raw_created_at) + except ValueError: + return None + raw_document_id = payload.get("document_id") + raw_task_id = payload.get("task_id") + raw_document_type = payload.get("document_type") + raw_title = payload.get("title") + if not all( + isinstance(value, str) + for value in (raw_document_id, raw_document_type, raw_title) + ): + return None + try: + task_id = int(raw_task_id) + except (TypeError, ValueError): + return None + raw_payload = payload.get("payload") + snapshot_payload: dict[str, str] = {} + if isinstance(raw_payload, dict): + snapshot_payload = { + str(key): str(value) + for key, value in raw_payload.items() + if value is not None + } + return TicketDocumentSnapshot( + document_id=raw_document_id, + task_id=task_id, + document_type=raw_document_type, + title=raw_title, + created_at=created_at, + location=str(payload.get("location", "")), + specialist_name=str(payload.get("specialist_name", "")), + summary=str(payload.get("summary", "")), + content=str(payload.get("content", "")), + storage_path=str(storage_path), + payload=snapshot_payload, + ) + + def _serialize_snapshot(self, snapshot: TicketDocumentSnapshot) -> dict[str, Any]: + return { + "document_id": snapshot.document_id, + "task_id": snapshot.task_id, + "document_type": snapshot.document_type, + "title": snapshot.title, + "created_at": snapshot.created_at.isoformat(), + "location": snapshot.location, + "specialist_name": snapshot.specialist_name, + "summary": snapshot.summary, + "content": snapshot.content, + "storage_path": snapshot.storage_path, + "payload": dict(snapshot.payload), + } + + def _directories_for_filter(self, document_type: str | None) -> Iterable[Path]: + if document_type == "acceptance": + return (self._acts_dir,) + if document_type in REPORT_DOCUMENT_TYPES or document_type in {None, "report"}: + return (self._reports_dir, self._acts_dir) if document_type is None else (self._reports_dir,) + return (self._reports_dir, self._acts_dir) + + def _directory_for_type(self, document_type: str) -> Path: + if document_type == "acceptance": + return self._acts_dir + return self._reports_dir + + @staticmethod + def _build_document_id(task: TicketTaskSnapshot, document_type: str) -> str: + cycle_token = ( + task.created_at.strftime("%Y%m%d_%H%M%S") + if task.created_at is not None + else datetime.now().strftime("%Y%m%d_%H%M%S") + ) + return f"task_{task.task_id}_{cycle_token}_{document_type}" diff --git a/Dispatch_V0.1.1/state/paths.py b/Dispatch_V0.1.1/state/paths.py new file mode 100644 index 0000000..78d7f4d --- /dev/null +++ b/Dispatch_V0.1.1/state/paths.py @@ -0,0 +1,36 @@ +# -*- coding: utf-8 -*- +# hub/ticket/state/paths.py + +"""Пути хранения Ticket внутри общей системы.""" + +from __future__ import annotations + +import os +from pathlib import Path + +from PySide6.QtCore import QStandardPaths + + +def _default_root_dir() -> Path: + """Вернуть корневую директорию хранения Ticket.""" + base = QStandardPaths.writableLocation(QStandardPaths.AppDataLocation) + root_base = Path(base) if base else Path.cwd() / ".usms_data" + return root_base / "ticket" + + +ROOT_DIR = Path(os.environ.get("USMS_TICKET_ROOT_DIR", _default_root_dir())) +ACTS_DIR = Path(os.environ.get("USMS_TICKET_ACTS_DIR", ROOT_DIR / "acts")) +REPORTS_DIR = Path(os.environ.get("USMS_TICKET_REPORTS_DIR", ROOT_DIR / "reports")) +_PROJECT_ROOT = Path(__file__).resolve().parents[3] +ARCHIVE_DIR = Path(os.environ.get("USMS_TICKET_ARCHIVE_DIR", _PROJECT_ROOT / "DB data")) +DATA_DIR = Path(os.environ.get("USMS_TICKET_DATA_DIR", ROOT_DIR / "data")) +TASKS_FILE = DATA_DIR / "tasks.json" + + +def ensure_storage_directories() -> None: + """Создать каталоги хранения Ticket при фактической работе с данными.""" + ROOT_DIR.mkdir(parents=True, exist_ok=True) + ACTS_DIR.mkdir(parents=True, exist_ok=True) + REPORTS_DIR.mkdir(parents=True, exist_ok=True) + ARCHIVE_DIR.mkdir(parents=True, exist_ok=True) + DATA_DIR.mkdir(parents=True, exist_ok=True) diff --git a/Dispatch_V0.1.1/state/repository.py b/Dispatch_V0.1.1/state/repository.py new file mode 100644 index 0000000..8698e3b --- /dev/null +++ b/Dispatch_V0.1.1/state/repository.py @@ -0,0 +1,72 @@ +# -*- coding: utf-8 -*- +# hub/ticket/state/repository.py + +"""Файловый репозиторий Ticket для хранения задач.""" + +from __future__ import annotations + +import json +from datetime import datetime +from pathlib import Path +from typing import Any + +from error_logger import log_exception + +from domain.task import TicketTask +from .paths import ensure_storage_directories + + +class TicketStateRepository: + """Канонический файловый репозиторий состояния Ticket.""" + + def __init__(self, tasks_file: Path): + self._tasks_file = tasks_file + ensure_storage_directories() + self._tasks_file.parent.mkdir(parents=True, exist_ok=True) + + def load_tasks(self) -> list[TicketTask]: + """Загрузить все задачи из хранилища.""" + try: + if not self._tasks_file.exists(): + return [] + with open(self._tasks_file, "r", encoding="utf-8") as handle: + raw_tasks = json.load(handle) + except Exception as exc: + log_exception(__name__, "TicketStateRepository.load_tasks", exc) + return [] + + if not isinstance(raw_tasks, list): + return [] + + tasks: list[TicketTask] = [] + for raw_task in raw_tasks: + if not isinstance(raw_task, dict): + continue + task = TicketTask.from_record(raw_task) + if task is not None: + tasks.append(task) + return tasks + + def save_tasks(self, tasks: list[TicketTask]) -> bool: + """Сохранить все задачи в каноническое JSON-хранилище.""" + try: + payload = [task.to_record() for task in tasks] + with open(self._tasks_file, "w", encoding="utf-8") as handle: + json.dump( + payload, + handle, + indent=2, + ensure_ascii=False, + default=self._json_serializer, + ) + except Exception as exc: + log_exception(__name__, "TicketStateRepository.save_tasks", exc) + return False + return True + + @staticmethod + def _json_serializer(value: Any) -> str: + """Сериализовать datetime и родственные значения для JSON.""" + if isinstance(value, datetime): + return value.isoformat() + raise TypeError(f"Object of type {type(value)} is not JSON serializable") diff --git a/Dispatch_V0.1.1/state/runtime_state.py b/Dispatch_V0.1.1/state/runtime_state.py new file mode 100644 index 0000000..31df281 --- /dev/null +++ b/Dispatch_V0.1.1/state/runtime_state.py @@ -0,0 +1,251 @@ +# -*- coding: utf-8 -*- +# hub/ticket/state/runtime_state.py + +"""Runtime-state Ticket с Qt-сигналами и единым путём сохранения.""" + +from __future__ import annotations + +from datetime import datetime +from typing import Any, Mapping + +from PySide6.QtCore import QObject, Signal + +from error_logger import log_exception + +from domain import TicketConnectionStatus, TicketTaskSnapshot +from domain.task import TicketTask, _normalize_color, _normalize_datetime, _normalize_int +from .paths import TASKS_FILE +from .repository import TicketStateRepository + + +class TicketRuntimeState(QObject): + """Канонический runtime-state Ticket поверх файлового репозитория.""" + + task_updated = Signal(object) + task_removed = Signal(int) + connection_changed = Signal(str, str) + active_view_changed = Signal(str) + state_loaded = Signal() + com_connection_changed = Signal(bool, str) + button_initialization_changed = Signal(bool, int) + + def __init__(self, repository: TicketStateRepository | None = None): + super().__init__() + self._repository = repository or TicketStateRepository(TASKS_FILE) + self._tasks: dict[int, TicketTask] = {} + self._connection_status = TicketConnectionStatus.DISCONNECTED + self._active_view = "board" + self._com_connected = False + self._buttons_initialized = False + self._button_count = 0 + self._sequence_counter = 0 + + def load(self) -> None: + """Загрузить задачи из репозитория в память.""" + self._tasks = {task.task_id: task for task in self._repository.load_tasks()} + self._sequence_counter = max( + (t.sequence_number for t in self._tasks.values()), default=0, + ) + self.state_loaded.emit() + for task in self.list_tasks(): + self.task_updated.emit(task) + + def list_tasks(self) -> list[TicketTaskSnapshot]: + """Вернуть снимки всех активных и архивных задач.""" + return [task.to_snapshot() for task in self._tasks.values()] + + def list_active_tasks(self) -> list[TicketTaskSnapshot]: + """Вернуть снимки только неархивных задач.""" + return [task.to_snapshot() for task in self._tasks.values() if task.state_code != 5] + + def list_archived_tasks(self) -> list[TicketTaskSnapshot]: + """Вернуть снимки архивных задач.""" + return [task.to_snapshot() for task in self._tasks.values() if task.state_code == 5] + + def get_task(self, task_id: int) -> TicketTaskSnapshot | None: + """Вернуть снимок задачи по идентификатору.""" + task = self._tasks.get(task_id) + return task.to_snapshot() if task else None + + def next_sequence_number(self) -> int: + """Вернуть следующий сквозной номер задачи.""" + self._sequence_counter += 1 + return self._sequence_counter + + def upsert_task(self, task: TicketTaskSnapshot) -> None: + """Создать или обновить задачу по готовому snapshot.""" + self._tasks[task.task_id] = TicketTask.from_snapshot(task) + self._save_and_emit(task.task_id) + + def update_task(self, task_data: Mapping[str, Any]) -> TicketTaskSnapshot | None: + """Обновить задачу по legacy-совместимому словарю полей.""" + raw_task_id = task_data.get("button_id") + if raw_task_id is None: + return None + try: + task_id = int(raw_task_id) + except (TypeError, ValueError): + return None + + task = self._tasks.get(task_id) + if task is None: + created_task = TicketTask.from_record(task_data) + if created_task is None: + return None + task = created_task + task.created_at = task.created_at or datetime.now() + self._tasks[task_id] = task + else: + self._apply_update(task, task_data) + + self._save_and_emit(task_id) + return self.get_task(task_id) + + def remove_task(self, task_id: int) -> None: + """Удалить задачу из runtime-state и хранилища.""" + if task_id not in self._tasks: + return + del self._tasks[task_id] + self._persist() + self.task_removed.emit(task_id) + + def can_advance_to_confirmation(self, task_id: int) -> bool: + """Проверить, что обязательные отчёты подписаны.""" + task = self._tasks.get(task_id) + if task is None: + return False + return task.diagnostic_report_signed and task.repair_report_signed + + def sign_report(self, task_id: int, report_type: str) -> None: + """Отметить подписание диагностического или ремонтного отчёта.""" + task = self._tasks.get(task_id) + if task is None: + return + if report_type == "diagnostic": + task.diagnostic_report_signed = True + elif report_type == "repair": + task.repair_report_signed = True + else: + return + self._save_and_emit(task_id) + + def set_error(self, message: str) -> None: + """Перевести статус подключения в ошибку и уведомить подписчиков.""" + self._connection_status = TicketConnectionStatus.ERROR + self.connection_changed.emit(self._connection_status.value, message) + + @property + def connection_status(self) -> TicketConnectionStatus: + return self._connection_status + + @connection_status.setter + def connection_status(self, value: TicketConnectionStatus) -> None: + if self._connection_status == value: + return + self._connection_status = value + self.connection_changed.emit(value.value, "") + + @property + def active_view(self) -> str: + return self._active_view + + @active_view.setter + def active_view(self, value: str) -> None: + if self._active_view == value: + return + self._active_view = value + self.active_view_changed.emit(value) + + @property + def com_connected(self) -> bool: + return self._com_connected + + @com_connected.setter + def com_connected(self, value: bool) -> None: + if self._com_connected == value: + return + self._com_connected = value + self.com_connection_changed.emit(value, "") + + @property + def buttons_initialized(self) -> bool: + return self._buttons_initialized + + @buttons_initialized.setter + def buttons_initialized(self, value: bool) -> None: + if self._buttons_initialized == value: + return + self._buttons_initialized = value + self.button_initialization_changed.emit(value, self._button_count) + + def set_button_initialization(self, is_initialized: bool, button_count: int) -> None: + """Единая точка обновления статуса инициализации кнопок.""" + self._buttons_initialized = is_initialized + self._button_count = button_count + self.button_initialization_changed.emit(is_initialized, button_count) + + def set_com_connection(self, is_connected: bool, message: str) -> None: + """Единая точка обновления статуса COM-подключения.""" + self._com_connected = is_connected + self.com_connection_changed.emit(is_connected, message) + + def _apply_update(self, task: TicketTask, task_data: Mapping[str, Any]) -> None: + """Применить обновление к уже существующей задаче.""" + new_state = task_data.get("state") + if new_state is not None: + try: + normalized_state = int(new_state) + except (TypeError, ValueError): + normalized_state = task.state_code + else: + normalized_state = task.state_code + + if normalized_state == 4 and task.state_code != 4: + task.refused_from_state = task.state_code + elif normalized_state != 4 and "location" in task_data: + task.location = str(task_data.get("location", task.location)) + + task.state_code = normalized_state + if "action" in task_data: + task.action_text = str(task_data.get("action", task.action_text)) + if "state_name" in task_data: + task.state_name = str(task_data.get("state_name", task.state_name)) + if "color" in task_data: + task.color_hex = _normalize_color(task_data.get("color")) + if "created_time" in task_data: + task.created_at = _normalize_datetime(task_data.get("created_time")) or task.created_at + if "completed_time" in task_data: + task.completed_at = _normalize_datetime(task_data.get("completed_time")) + if "refused_from_state" in task_data: + task.refused_from_state = _normalize_int( + task_data.get("refused_from_state"), + task.refused_from_state, + ) + if "refusal_reason" in task_data and task_data.get("refusal_reason") is not None: + task.refusal_reason = str(task_data.get("refusal_reason", "")).strip() + if "assigned_specialist" in task_data and task_data.get("assigned_specialist") is not None: + task.assigned_specialist = str(task_data.get("assigned_specialist", "")) + if "specialist_photo" in task_data and task_data.get("specialist_photo") is not None: + task.specialist_photo = str(task_data.get("specialist_photo", "")) + if "diagnostic_report_signed" in task_data and task_data.get("diagnostic_report_signed") is not None: + task.diagnostic_report_signed = bool(task_data.get("diagnostic_report_signed")) + if "repair_report_signed" in task_data and task_data.get("repair_report_signed") is not None: + task.repair_report_signed = bool(task_data.get("repair_report_signed")) + if "acceptance_report_signed" in task_data and task_data.get("acceptance_report_signed") is not None: + task.acceptance_report_signed = bool(task_data.get("acceptance_report_signed")) + if normalized_state == 0 and task.completed_at is None: + task.completed_at = datetime.now() + + def _save_and_emit(self, task_id: int) -> None: + """Сохранить состояние и уведомить подписчиков о задаче.""" + self._persist() + snapshot = self.get_task(task_id) + if snapshot is not None: + self.task_updated.emit(snapshot) + + def _persist(self) -> None: + """Сохранить весь state в репозиторий.""" + try: + self._repository.save_tasks(list(self._tasks.values())) + except Exception as exc: + log_exception(__name__, "TicketRuntimeState._persist", exc) diff --git a/Dispatch_V0.1.1/state/ticket_state_api.py b/Dispatch_V0.1.1/state/ticket_state_api.py new file mode 100644 index 0000000..6722420 --- /dev/null +++ b/Dispatch_V0.1.1/state/ticket_state_api.py @@ -0,0 +1,44 @@ +# -*- coding: utf-8 -*- +# hub/ticket/state/ticket_state_api.py + +"""Публичный state API модуля Ticket.""" + +from __future__ import annotations + +from typing import Any, Mapping, Protocol, Sequence + +from domain import TicketTaskSnapshot + + +class TicketStateApi(Protocol): + """Контракт для канонического runtime-state и persistence-контура Ticket.""" + + def load(self) -> None: + """Загрузить состояние из репозитория.""" + + def list_tasks(self) -> Sequence[TicketTaskSnapshot]: + """Вернуть все известные задачи.""" + + def list_active_tasks(self) -> Sequence[TicketTaskSnapshot]: + """Вернуть активные задачи.""" + + def list_archived_tasks(self) -> Sequence[TicketTaskSnapshot]: + """Вернуть архивные задачи.""" + + def get_task(self, task_id: int) -> TicketTaskSnapshot | None: + """Вернуть задачу по идентификатору.""" + + def upsert_task(self, task: TicketTaskSnapshot) -> None: + """Создать или обновить задачу в каноническом состоянии.""" + + def update_task(self, task_data: Mapping[str, Any]) -> TicketTaskSnapshot | None: + """Обновить задачу по словарю совместимых полей.""" + + def remove_task(self, task_id: int) -> None: + """Удалить задачу из состояния.""" + + def sign_report(self, task_id: int, report_type: str) -> None: + """Зафиксировать подписание отчёта.""" + + def can_advance_to_confirmation(self, task_id: int) -> bool: + """Проверить готовность задачи к подтверждению.""" diff --git a/Dispatch_V0.1.1/ticket_plugin.py b/Dispatch_V0.1.1/ticket_plugin.py new file mode 100644 index 0000000..acdd50f --- /dev/null +++ b/Dispatch_V0.1.1/ticket_plugin.py @@ -0,0 +1,57 @@ +# -*- coding: utf-8 -*- +# hub/ticket/ticket_plugin.py + +"""Plugin-shell модуля Ticket без переноса standalone-оболочки.""" + +from error_logger import log_exception +from gui.containers import SContainer, VContainer + +from application import TaskApplicationService +from ui import TicketShell + + +PLUGIN_DISPLAY_NAME = "Ticket" + + +class TicketPlugin(SContainer): + """Корневая точка входа Ticket внутри общей plugin-системы.""" + + def __init__(self, application_service: TaskApplicationService | None = None): + super().__init__(width_percent=100, height_percent=100) + self._application_service = application_service or TaskApplicationService(parent=self) + self._shell: TicketShell | None = None + self._is_cleaned = False + self._setup_ui() + self._start_application() + + def _setup_ui(self) -> None: + """Собрать plugin-shell Ticket с локальной навигацией.""" + try: + main_container = VContainer(margin=16, spacing=10, parent=self) + self._shell = TicketShell(application=self._application_service, parent=self) + main_container.add_widget(self._shell) + except Exception as exc: + log_exception(__name__, "TicketPlugin._setup_ui", exc) + raise + + def _start_application(self) -> None: + """Запустить application-слой Ticket после сборки shell.""" + try: + self._application_service.start() + except Exception as exc: + log_exception(__name__, "TicketPlugin._start_application", exc) + raise + + def cleanup(self) -> None: + """Безопасно остановить application-слой Ticket.""" + if self._is_cleaned: + return + self._is_cleaned = True + try: + self._application_service.stop() + except Exception as exc: + log_exception(__name__, "TicketPlugin.cleanup", exc) + + def closeEvent(self, event) -> None: + self.cleanup() + super().closeEvent(event) diff --git a/Dispatch_V0.1.1/ui/__init__.py b/Dispatch_V0.1.1/ui/__init__.py new file mode 100644 index 0000000..26b078c --- /dev/null +++ b/Dispatch_V0.1.1/ui/__init__.py @@ -0,0 +1,30 @@ +# -*- coding: utf-8 -*- +# hub/ticket/ui/__init__.py + +"""UI-контур Ticket.""" + +from .dialogs import AcceptanceDialog, DiagnosticReportDialog, RepairReportDialog, SpecialistDialog +from .pages import ActsPage, ArchivePage, ReportViewer, ReportsPage +from .ticket_board_page import TicketBoardPage +from .ticket_placeholder_page import TicketPlaceholderPage +from .ticket_shell import TicketShell +from .cards import TaskCard, TaskCardView +from .details import TaskDetailsActions, TaskDetailsDialog + +__all__ = [ + "TicketBoardPage", + "TicketPlaceholderPage", + "TicketShell", + "TaskCard", + "TaskCardView", + "TaskDetailsActions", + "TaskDetailsDialog", + "AcceptanceDialog", + "DiagnosticReportDialog", + "RepairReportDialog", + "SpecialistDialog", + "ActsPage", + "ArchivePage", + "ReportsPage", + "ReportViewer", +] diff --git a/Dispatch_V0.1.1/ui/__pycache__/__init__.cpython-313.pyc b/Dispatch_V0.1.1/ui/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..bc2002e Binary files /dev/null and b/Dispatch_V0.1.1/ui/__pycache__/__init__.cpython-313.pyc differ diff --git a/Dispatch_V0.1.1/ui/__pycache__/task_view_formatters.cpython-313.pyc b/Dispatch_V0.1.1/ui/__pycache__/task_view_formatters.cpython-313.pyc new file mode 100644 index 0000000..83e96de Binary files /dev/null and b/Dispatch_V0.1.1/ui/__pycache__/task_view_formatters.cpython-313.pyc differ diff --git a/Dispatch_V0.1.1/ui/__pycache__/ticket_board_page.cpython-313.pyc b/Dispatch_V0.1.1/ui/__pycache__/ticket_board_page.cpython-313.pyc new file mode 100644 index 0000000..a684870 Binary files /dev/null and b/Dispatch_V0.1.1/ui/__pycache__/ticket_board_page.cpython-313.pyc differ diff --git a/Dispatch_V0.1.1/ui/__pycache__/ticket_create_page.cpython-313.pyc b/Dispatch_V0.1.1/ui/__pycache__/ticket_create_page.cpython-313.pyc new file mode 100644 index 0000000..8e32d2a Binary files /dev/null and b/Dispatch_V0.1.1/ui/__pycache__/ticket_create_page.cpython-313.pyc differ diff --git a/Dispatch_V0.1.1/ui/__pycache__/ticket_message_dialog.cpython-313.pyc b/Dispatch_V0.1.1/ui/__pycache__/ticket_message_dialog.cpython-313.pyc new file mode 100644 index 0000000..ec618ff Binary files /dev/null and b/Dispatch_V0.1.1/ui/__pycache__/ticket_message_dialog.cpython-313.pyc differ diff --git a/Dispatch_V0.1.1/ui/__pycache__/ticket_placeholder_page.cpython-313.pyc b/Dispatch_V0.1.1/ui/__pycache__/ticket_placeholder_page.cpython-313.pyc new file mode 100644 index 0000000..94032b4 Binary files /dev/null and b/Dispatch_V0.1.1/ui/__pycache__/ticket_placeholder_page.cpython-313.pyc differ diff --git a/Dispatch_V0.1.1/ui/__pycache__/ticket_shell.cpython-313.pyc b/Dispatch_V0.1.1/ui/__pycache__/ticket_shell.cpython-313.pyc new file mode 100644 index 0000000..0c389f5 Binary files /dev/null and b/Dispatch_V0.1.1/ui/__pycache__/ticket_shell.cpython-313.pyc differ diff --git a/Dispatch_V0.1.1/ui/cards/__init__.py b/Dispatch_V0.1.1/ui/cards/__init__.py new file mode 100644 index 0000000..850b18b --- /dev/null +++ b/Dispatch_V0.1.1/ui/cards/__init__.py @@ -0,0 +1,9 @@ +# -*- coding: utf-8 -*- +# hub/ticket/ui/cards/__init__.py + +"""Карточки задач Ticket.""" + +from .task_card import TaskCard +from .task_card_view import TaskCardView + +__all__ = ["TaskCard", "TaskCardView"] diff --git a/Dispatch_V0.1.1/ui/cards/__pycache__/__init__.cpython-313.pyc b/Dispatch_V0.1.1/ui/cards/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..ee0249a Binary files /dev/null and b/Dispatch_V0.1.1/ui/cards/__pycache__/__init__.cpython-313.pyc differ diff --git a/Dispatch_V0.1.1/ui/cards/__pycache__/task_card.cpython-313.pyc b/Dispatch_V0.1.1/ui/cards/__pycache__/task_card.cpython-313.pyc new file mode 100644 index 0000000..56a77ae Binary files /dev/null and b/Dispatch_V0.1.1/ui/cards/__pycache__/task_card.cpython-313.pyc differ diff --git a/Dispatch_V0.1.1/ui/cards/__pycache__/task_card_pixmap_factory.cpython-313.pyc b/Dispatch_V0.1.1/ui/cards/__pycache__/task_card_pixmap_factory.cpython-313.pyc new file mode 100644 index 0000000..e1dc780 Binary files /dev/null and b/Dispatch_V0.1.1/ui/cards/__pycache__/task_card_pixmap_factory.cpython-313.pyc differ diff --git a/Dispatch_V0.1.1/ui/cards/__pycache__/task_card_view.cpython-313.pyc b/Dispatch_V0.1.1/ui/cards/__pycache__/task_card_view.cpython-313.pyc new file mode 100644 index 0000000..e2f481b Binary files /dev/null and b/Dispatch_V0.1.1/ui/cards/__pycache__/task_card_view.cpython-313.pyc differ diff --git a/Dispatch_V0.1.1/ui/cards/task_card.py b/Dispatch_V0.1.1/ui/cards/task_card.py new file mode 100644 index 0000000..9a64ccc --- /dev/null +++ b/Dispatch_V0.1.1/ui/cards/task_card.py @@ -0,0 +1,11 @@ +# -*- coding: utf-8 -*- +# hub/ticket/ui/cards/task_card.py + +"""Тонкий entry-point карточки Ticket.""" + +from .task_card_view import TaskCardView + + +class TaskCard(TaskCardView): + """Runtime-entry карточки задачи на доске Ticket.""" + diff --git a/Dispatch_V0.1.1/ui/cards/task_card_pixmap_factory.py b/Dispatch_V0.1.1/ui/cards/task_card_pixmap_factory.py new file mode 100644 index 0000000..e1febcb --- /dev/null +++ b/Dispatch_V0.1.1/ui/cards/task_card_pixmap_factory.py @@ -0,0 +1,129 @@ +# -*- coding: utf-8 -*- +# hub/ticket/ui/cards/task_card_pixmap_factory.py + +"""Pixmap-хелперы для карточки Ticket.""" + +from __future__ import annotations + +from PySide6.QtCore import Qt +from PySide6.QtGui import QColor, QFont, QPainter, QPainterPath, QPixmap + + +def compute_square_size(width: int, height: int, padding: int = 0) -> int: + """Вернуть квадратный размер по меньшей стороне с учётом внутреннего отступа.""" + available = min(width, height) - padding + return max(0, available) + + +def build_avatar_pixmap(source: QPixmap, size: int) -> QPixmap: + """Построить круглый avatar-pixmap из исходной фотографии.""" + avatar = QPixmap(size, size) + avatar.fill(Qt.GlobalColor.transparent) + + painter = QPainter(avatar) + painter.setRenderHint(QPainter.RenderHint.Antialiasing, True) + + outer_padding = 1 + outer_size = size - (outer_padding * 2) + outer_path = QPainterPath() + outer_path.addEllipse(outer_padding, outer_padding, outer_size, outer_size) + painter.fillPath(outer_path, QColor("#A3A3A3")) + + inner_padding = outer_padding + 2 + inner_size = size - (inner_padding * 2) + inner_path = QPainterPath() + inner_path.addEllipse(inner_padding, inner_padding, inner_size, inner_size) + painter.setClipPath(inner_path) + + scaled = source.scaled( + inner_size, + inner_size, + Qt.AspectRatioMode.KeepAspectRatioByExpanding, + Qt.TransformationMode.SmoothTransformation, + ) + source_x = max(0, (scaled.width() - inner_size) // 2) + source_y = max(0, (scaled.height() - inner_size) // 2) + painter.drawPixmap(inner_padding, inner_padding, scaled, source_x, source_y, inner_size, inner_size) + painter.end() + + return avatar + + +def build_placeholder_avatar_pixmap(size: int) -> QPixmap: + """Построить круглый placeholder-аватар с вопросительным знаком.""" + avatar = QPixmap(size, size) + avatar.fill(Qt.GlobalColor.transparent) + + painter = QPainter(avatar) + painter.setRenderHint(QPainter.RenderHint.Antialiasing, True) + + outer_padding = 1 + circle_size = size - (outer_padding * 2) + circle_path = QPainterPath() + circle_path.addEllipse(outer_padding, outer_padding, circle_size, circle_size) + painter.fillPath(circle_path, QColor("#F6A493")) + painter.setPen(QColor("#FFFFFF")) + painter.drawPath(circle_path) + + font = QFont() + font.setPixelSize(18) + font.setBold(True) + painter.setFont(font) + painter.setPen(QColor("#172B4D")) + painter.drawText(outer_padding, outer_padding, circle_size, circle_size, Qt.AlignmentFlag.AlignCenter, "?") + painter.end() + + return avatar + + +def load_scaled_icon_pixmap(image_path: str, width: int, height: int, padding: int = 0) -> QPixmap | None: + """Загрузить и отмасштабировать stage-icon по фактическому размеру ячейки.""" + if not image_path: + return None + pixmap = QPixmap(image_path) + if pixmap.isNull(): + return None + icon_size = compute_square_size(width, height, padding) + if icon_size <= 0: + return None + return pixmap.scaled( + icon_size, + icon_size, + Qt.AspectRatioMode.KeepAspectRatio, + Qt.TransformationMode.SmoothTransformation, + ) + + +def load_tinted_icon_pixmap( + image_path: str, + width: int, + height: int, + color_hex: str, + padding: int = 0, +) -> QPixmap | None: + """Загрузить stage-icon и перекрасить его в нужный цвет.""" + scaled = load_scaled_icon_pixmap(image_path, width, height, padding) + if scaled is None: + return None + tinted = QPixmap(scaled.size()) + tinted.fill(Qt.GlobalColor.transparent) + + painter = QPainter(tinted) + painter.drawPixmap(0, 0, scaled) + painter.setCompositionMode(QPainter.CompositionMode.CompositionMode_SourceIn) + painter.fillRect(tinted.rect(), QColor(color_hex)) + painter.end() + return tinted + + +def load_avatar_pixmap(image_path: str, width: int, height: int, padding: int = 0) -> QPixmap | None: + """Загрузить фото специалиста и подготовить круглый avatar-pixmap.""" + if not image_path: + return None + pixmap = QPixmap(image_path) + if pixmap.isNull(): + return None + avatar_size = compute_square_size(width, height, padding) + if avatar_size <= 0: + return None + return build_avatar_pixmap(pixmap, avatar_size) diff --git a/Dispatch_V0.1.1/ui/cards/task_card_view.py b/Dispatch_V0.1.1/ui/cards/task_card_view.py new file mode 100644 index 0000000..c5fb7ae --- /dev/null +++ b/Dispatch_V0.1.1/ui/cards/task_card_view.py @@ -0,0 +1,209 @@ +# -*- coding: utf-8 -*- +# hub/ticket/ui/cards/task_card_view.py + +"""Ticket task card view.""" + +from __future__ import annotations + +from PySide6.QtCore import Qt, QTimer, Signal +from PySide6.QtWidgets import QSizePolicy + +from gui.components import Label +from gui.containers import SContainer, VContainer + +from domain import TicketTaskSnapshot +from domain.ticket_constants import ( + STATE_COMPLETED, + STATE_CONFIRMATION, + STATE_IN_PROGRESS, + STATE_REFUSED, + STATE_TODO, +) +from ui.task_view_formatters import ( + build_specialist_card_text, + build_specialist_photo_path, + build_task_fault_title, +) +from .task_card_pixmap_factory import ( + build_placeholder_avatar_pixmap, + compute_square_size, + load_avatar_pixmap, +) + + +class TaskCardView(SContainer): + """Compact Ticket card without action logic.""" + + card_clicked = Signal(object) + card_height_changed = Signal() + + _ROOT_STYLE_BY_STATE = { + STATE_TODO: "TICKET_TASK_CARD_ROOT_TODO", + STATE_IN_PROGRESS: "TICKET_TASK_CARD_ROOT_IN_PROGRESS", + STATE_CONFIRMATION: "TICKET_TASK_CARD_ROOT_CONFIRMATION", + STATE_COMPLETED: "TICKET_TASK_CARD_ROOT_COMPLETED", + STATE_REFUSED: "TICKET_TASK_CARD_ROOT_REFUSED", + } + _DARK_TEXT_STATES = {STATE_TODO, STATE_CONFIRMATION, STATE_REFUSED} + + def __init__( + self, + task: TicketTaskSnapshot, + parent=None, + ): + super().__init__( + width_percent=100, + margin=0, + content_fit=True, + parent=parent, + ) + self._card_id = task.task_id + self._fault_title_label: Label | None = None + self._avatar_label: Label | None = None + self._current_task: TicketTaskSnapshot | None = None + self._height_sync_in_progress = False + self._pixmap_refresh_pending = False + self._setup_ui() + self.update_task(task) + + @property + def card_id(self) -> object: + return self._card_id + + def _setup_ui(self) -> None: + self.setObjectName("ticket_task_card") + self.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True) + self.setCursor(Qt.CursorShape.PointingHandCursor) + self.set_size_policy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) + + # Content-shell: горизонтальное разделение текстовой зоны и аватара. + content = SContainer( + width_percent=100, + height_percent=100, + orientation="h", + margin=8, + spacing=0, + style="TICKET_TASK_CARD_CONTENT", + parent=self, + ) + + # Text-column: левая вертикальная зона с единственным текстовым полем заголовка. + text_column = VContainer( + width_percent=70, + spacing=0, + parent=content, + ) + + # Единственное текстовое поле карточки: краткий заголовок неисправности. + self._fault_title_label = Label( + "", + alignment="left", + height_percent=100, + parent=text_column, + ) + + # Avatar-column: правая зона для фото или плейсхолдера специалиста. + avatar_column = VContainer( + width_percent=26, + spacing=0, + parent=content, + ) + self._avatar_label = Label( + "", + alignment="center", + width_percent=100, + height_percent=100, + style="TICKET_TASK_CARD_AVATAR_IMAGE", + parent=avatar_column, + ) + + def update_task(self, task: TicketTaskSnapshot) -> None: + self._card_id = task.task_id + self._current_task = task + + if self._fault_title_label is not None: + self._fault_title_label.set_text(build_task_fault_title(task)) + self._fault_title_label.set_tooltip(task.location or "") + + self._apply_state_styles(task) + self._update_avatar(task) + self._sync_card_height() + self._schedule_pixmap_refresh() + + def _apply_state_styles(self, task: TicketTaskSnapshot) -> None: + style_suffix = "DARK" if task.state_code in self._DARK_TEXT_STATES else "LIGHT" + self.style(self._ROOT_STYLE_BY_STATE.get(task.state_code, "TICKET_TASK_CARD_ROOT_TODO")) + if self._fault_title_label is not None: + self._fault_title_label.style(f"TICKET_TASK_CARD_TITLE_{style_suffix}") + + def _update_avatar(self, task: TicketTaskSnapshot) -> None: + if self._avatar_label is None: + return + avatar_path = build_specialist_photo_path(task.assigned_specialist, task.specialist_photo) + self._avatar_label.set_tooltip(task.assigned_specialist or "Специалист не назначен") + pixmap = load_avatar_pixmap(avatar_path, self._avatar_label.width(), self._avatar_label.height(), padding=4) + if pixmap is not None: + self._avatar_label.set_text("") + self._avatar_label.set_pixmap(pixmap) + return + self._avatar_label.set_text("") + placeholder_size = self._label_square_size(self._avatar_label) + if placeholder_size > 0: + self._avatar_label.set_pixmap(build_placeholder_avatar_pixmap(placeholder_size)) + + @staticmethod + def _label_square_size(label: Label, padding: int = 0) -> int: + return compute_square_size(label.width(), label.height(), padding) + + def resizeEvent(self, event) -> None: + super().resizeEvent(event) + self._sync_card_height() + if self._current_task is not None: + self._update_avatar(self._current_task) + self._schedule_pixmap_refresh() + + def _schedule_pixmap_refresh(self) -> None: + """Запланировать отложенный рендер pixmap-контента. + + Процентные размеры дочерних Label вычисляются через + QTimer.singleShot(0) в PercentSizedWidget, поэтому при первом + показе карточки (или при динамической вставке из COM-сигнала) + label.width()/height() ещё равны 0 в момент resizeEvent. + Отложенный вызов гарантирует, что к моменту рендера layout + дочерних виджетов уже завершён. + """ + if self._pixmap_refresh_pending: + return + self._pixmap_refresh_pending = True + QTimer.singleShot(0, self._deferred_pixmap_refresh) + + def _deferred_pixmap_refresh(self) -> None: + self._pixmap_refresh_pending = False + if self._current_task is not None: + self._update_avatar(self._current_task) + + def _sync_card_height(self) -> None: + if self._height_sync_in_progress or self.width() <= 0: + return + target_height = max(1, round(self.width() / 2.745)) + if self.height() == target_height: + return + self._height_sync_in_progress = True + self.setFixedHeight(target_height) + self._height_sync_in_progress = False + self.card_height_changed.emit() + + def _on_parent_rebuild_finished(self) -> None: + # Фаза 2 каскада percent-sized: родительская колонка завершила + # перестроение, ширина карточки уже стабильная. Только теперь + # пересчитываем собственную высоту по соотношению width/2.745 + # и обновляем pixmap-контент. + super()._on_parent_rebuild_finished() + self._sync_card_height() + if self._current_task is not None: + self._schedule_pixmap_refresh() + + def mousePressEvent(self, event) -> None: + if event.button() == Qt.MouseButton.LeftButton: + self.card_clicked.emit(self._card_id) + super().mousePressEvent(event) diff --git a/Dispatch_V0.1.1/ui/details/__init__.py b/Dispatch_V0.1.1/ui/details/__init__.py new file mode 100644 index 0000000..0914d63 --- /dev/null +++ b/Dispatch_V0.1.1/ui/details/__init__.py @@ -0,0 +1,9 @@ +# -*- coding: utf-8 -*- +# hub/ticket/ui/details/__init__.py + +"""Экран деталей и действия Ticket.""" + +from .task_details_actions import TaskDetailsActions +from .task_details_dialog import TaskDetailsDialog + +__all__ = ["TaskDetailsActions", "TaskDetailsDialog"] diff --git a/Dispatch_V0.1.1/ui/details/__pycache__/__init__.cpython-313.pyc b/Dispatch_V0.1.1/ui/details/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..c457028 Binary files /dev/null and b/Dispatch_V0.1.1/ui/details/__pycache__/__init__.cpython-313.pyc differ diff --git a/Dispatch_V0.1.1/ui/details/__pycache__/task_details_actions.cpython-313.pyc b/Dispatch_V0.1.1/ui/details/__pycache__/task_details_actions.cpython-313.pyc new file mode 100644 index 0000000..7459f88 Binary files /dev/null and b/Dispatch_V0.1.1/ui/details/__pycache__/task_details_actions.cpython-313.pyc differ diff --git a/Dispatch_V0.1.1/ui/details/__pycache__/task_details_dialog.cpython-313.pyc b/Dispatch_V0.1.1/ui/details/__pycache__/task_details_dialog.cpython-313.pyc new file mode 100644 index 0000000..b2f875c Binary files /dev/null and b/Dispatch_V0.1.1/ui/details/__pycache__/task_details_dialog.cpython-313.pyc differ diff --git a/Dispatch_V0.1.1/ui/details/__pycache__/task_details_view_data.cpython-313.pyc b/Dispatch_V0.1.1/ui/details/__pycache__/task_details_view_data.cpython-313.pyc new file mode 100644 index 0000000..f42f92f Binary files /dev/null and b/Dispatch_V0.1.1/ui/details/__pycache__/task_details_view_data.cpython-313.pyc differ diff --git a/Dispatch_V0.1.1/ui/details/__pycache__/task_stage_action_row.cpython-313.pyc b/Dispatch_V0.1.1/ui/details/__pycache__/task_stage_action_row.cpython-313.pyc new file mode 100644 index 0000000..bc9ebf8 Binary files /dev/null and b/Dispatch_V0.1.1/ui/details/__pycache__/task_stage_action_row.cpython-313.pyc differ diff --git a/Dispatch_V0.1.1/ui/details/task_details_actions.py b/Dispatch_V0.1.1/ui/details/task_details_actions.py new file mode 100644 index 0000000..46e389a --- /dev/null +++ b/Dispatch_V0.1.1/ui/details/task_details_actions.py @@ -0,0 +1,135 @@ +# -*- coding: utf-8 -*- +# hub/ticket/ui/details/task_details_actions.py + +"""Отдельный модуль действий экрана деталей Ticket.""" + +from __future__ import annotations + +from application.ticket_application_api import TicketApplicationApi +from domain import TicketDocumentSnapshot, TicketTaskSnapshot +from ui.dialogs import ( + AcceptanceDialog, + DiagnosticReportDialog, + RepairReportDialog, + SpecialistDialog, + TaskRefusalDialog, +) +from ui.ticket_message_dialog import TicketMessageDialog + + +class TaskDetailsActions: + """Обёртка над application-командами для details-экрана.""" + + def __init__(self, application: TicketApplicationApi): + self._application = application + + def assign_specialist( + self, + task: TicketTaskSnapshot, + parent=None, + ) -> TicketTaskSnapshot | None: + dialog = SpecialistDialog( + specialists=self._application.list_specialists(), + parent=parent, + ) + if dialog.exec() != dialog.DialogCode.Accepted: + return None + specialist_name = dialog.selected_specialist.strip() + if not specialist_name: + self._show_warning(parent, "Имя специалиста не указано.") + return None + snapshot = self._application.assign_specialist(task.task_id, specialist_name) + if snapshot is None: + self._show_warning(parent, "Не удалось назначить специалиста.") + return snapshot + + def sign_diagnostic( + self, + task: TicketTaskSnapshot, + parent=None, + ) -> TicketDocumentSnapshot | None: + dialog = DiagnosticReportDialog(task, parent=parent) + if dialog.exec() != dialog.DialogCode.Accepted: + return None + try: + return self._application.create_diagnostic_report( + task.task_id, + **dialog.build_payload(), + ) + except ValueError as exc: + self._show_warning(parent, str(exc)) + return None + + def sign_repair( + self, + task: TicketTaskSnapshot, + parent=None, + ) -> TicketDocumentSnapshot | None: + dialog = RepairReportDialog(task, parent=parent) + if dialog.exec() != dialog.DialogCode.Accepted: + return None + try: + return self._application.create_repair_report( + task.task_id, + **dialog.build_payload(), + ) + except ValueError as exc: + self._show_warning(parent, str(exc)) + return None + + def sign_acceptance( + self, + task: TicketTaskSnapshot, + parent=None, + ) -> TicketDocumentSnapshot | None: + dialog = AcceptanceDialog(task, parent=parent) + if dialog.exec() != dialog.DialogCode.Accepted: + return None + try: + return self._application.create_acceptance_report( + task.task_id, + **dialog.build_payload(), + ) + except ValueError as exc: + self._show_warning(parent, str(exc)) + return None + + def archive_task( + self, + task: TicketTaskSnapshot, + parent=None, + ) -> TicketTaskSnapshot | None: + answer = TicketMessageDialog.ask_confirmation( + parent=parent, + title="Архивация задачи", + message=f"Переместить задачу #{task.sequence_number or task.task_id} в архив?", + accept_text="В архив", + reject_text="Отмена", + ) + if not answer: + return None + snapshot = self._application.archive_task(task.task_id) + if snapshot is None: + self._show_warning(parent, "Не удалось переместить задачу в архив.") + return snapshot + + def refuse_task( + self, + task: TicketTaskSnapshot, + parent=None, + ) -> TicketTaskSnapshot | None: + dialog = TaskRefusalDialog(task, parent=parent) + if dialog.exec() != dialog.DialogCode.Accepted: + return None + refusal_reason = dialog.refusal_reason + if not refusal_reason: + self._show_warning(parent, "Причина отказа не указана.") + return None + snapshot = self._application.refuse_task(task.task_id, refusal_reason) + if snapshot is None: + self._show_warning(parent, "Не удалось перевести задачу в отказ.") + return snapshot + + @staticmethod + def _show_warning(parent, text: str) -> None: + TicketMessageDialog.show_warning(parent, "Ticket", text) diff --git a/Dispatch_V0.1.1/ui/details/task_details_dialog.py b/Dispatch_V0.1.1/ui/details/task_details_dialog.py new file mode 100644 index 0000000..b1466d9 --- /dev/null +++ b/Dispatch_V0.1.1/ui/details/task_details_dialog.py @@ -0,0 +1,279 @@ +# -*- coding: utf-8 -*- +# hub/ticket/ui/details/task_details_dialog.py + +"""Dialog-экран подробностей задачи Ticket в ecco-геометрии.""" + +from __future__ import annotations + +from gui.components import Button, Dialog, Label +from gui.containers import HContainer, VContainer + +from application.ticket_application_api import TicketApplicationApi +from domain import TicketTaskSnapshot +from ui.cards.task_card_pixmap_factory import ( + build_placeholder_avatar_pixmap, + load_avatar_pixmap, +) +from .task_details_actions import TaskDetailsActions +from .task_details_view_data import ( + build_employee_view_data, + build_task_stage_rows, + build_task_summary_rows, + can_archive_task, + can_refuse_task, +) +from .task_stage_action_row import TaskStageActionRow + + +class TaskDetailsDialog(Dialog): + """Диалог подробностей задачи и доступных действий по этапам.""" + + _SUMMARY_TITLES = ( + "Учреждение", + "Оборудование", + "Кабинет", + "Назначение", + ) + _STAGE_ORDER = ( + "specialist", + "diagnostic", + "repair", + "acceptance", + ) + + def __init__( + self, + application: TicketApplicationApi, + task_id: int, + parent=None, + ): + self._application = application + self._task_id = task_id + self._task: TicketTaskSnapshot | None = None + self._actions = TaskDetailsActions(application) + self._summary_value_labels: dict[str, Label] = {} + self._stage_rows: dict[str, TaskStageActionRow] = {} + self._employee_avatar_label: Label | None = None + self._employee_name_label: Label | None = None + self._employee_role_label: Label | None = None + self._refuse_button: Button | None = None + super().__init__( + title="Подробности", + width=360, + height=600, + modal=True, + parent=parent, + ) + self._setup_ui() + self._connect_signals() + self._reload_task() + + def _setup_ui(self) -> None: + # Root-контейнер окна: раскладывает summary, этапы, сотрудника и footer по вертикали. + root = VContainer(margin=[24, 20, 24, 20], spacing=20) + self.add_widget(root) + root.add_widget(self._build_summary_section()) + root.add_widget(Label("", style="TICKET_DETAILS_DIVIDER")) + root.add_widget(self._build_stages_section()) + root.add_widget(self._build_employee_section()) + root.add_stretch() + root.add_widget(self._build_footer_section()) + + def _build_summary_section(self) -> VContainer: + # Summary-section: верхняя сводка по задаче с основными идентификационными полями. + summary = VContainer(spacing=12, content_fit=True) + for title in self._SUMMARY_TITLES: + summary.add_widget(self._build_summary_row(title)) + return summary + + def _build_summary_row(self, title: str) -> HContainer: + # Summary-row: одна горизонтальная строка конкретного поля в блоке сводки. + row = HContainer(spacing=10, content_fit=True) + title_label = Label( + f"{title}:", + alignment="left", + style="TICKET_DETAILS_SUMMARY_TITLE", + ) + value_label = Label( + "", + alignment="left", + style="TICKET_DETAILS_SUMMARY_VALUE", + ) + self._summary_value_labels[title] = value_label + row.add_widget(title_label) + row.add_widget_with_stretch(value_label, 1) + return row + + def _build_stages_section(self) -> VContainer: + # Stages-section: блок со списком шагов выполнения и доступных действий по ним. + section = VContainer(spacing=12, content_fit=True) + section.add_widget( + Label( + "Этапы выполнения заявки", + alignment="left", + style="TICKET_DETAILS_SECTION_TITLE", + ) + ) + # Stage-list: стек интерактивных строк этапов внутри секции stages. + stage_list = VContainer(spacing=12, content_fit=True) + for stage_key in self._STAGE_ORDER: + stage_row = TaskStageActionRow(stage_key) + stage_row.clicked.connect(self._on_stage_clicked) + self._stage_rows[stage_key] = stage_row + stage_list.add_widget(stage_row) + section.add_widget(stage_list) + return section + + def _build_employee_section(self) -> VContainer: + # Employee-section: отдельный блок ответственного сотрудника и его служебной информации. + section = VContainer(spacing=12, content_fit=True) + section.add_widget( + Label( + "Ответственный сотрудник", + alignment="left", + style="TICKET_DETAILS_SECTION_TITLE", + ) + ) + + # Employee-row: горизонтальная строка карточки сотрудника с аватаром и текстом. + employee_row = HContainer(spacing=16, content_fit=True) + self._employee_avatar_label = Label("", style="TICKET_TASK_CARD_AVATAR_IMAGE") + self._employee_avatar_label.set_fixed_size(64, 64) + self._employee_name_label = Label( + "", + alignment="left", + style="TICKET_DETAILS_EMPLOYEE_NAME", + ) + self._employee_role_label = Label( + "", + alignment="left", + style="TICKET_DETAILS_EMPLOYEE_ROLE", + ) + + # Info-column: вертикальный столбец имени и должности рядом с аватаром. + info_column = VContainer(spacing=2, content_fit=True) + info_column.add_widget(self._employee_name_label) + info_column.add_widget(self._employee_role_label) + + employee_row.add_widget(self._employee_avatar_label) + employee_row.add_widget_with_stretch(info_column, 1) + section.add_widget(employee_row) + return section + + def _build_footer_section(self) -> HContainer: + # Footer-row: нижняя линия действий с центрированными кнопками. + footer = HContainer(spacing=12, content_fit=True) + self._refuse_button = Button( + "Отказать в обслуживании", + style="TICKET_DETAILS_REFUSE_BUTTON", + content_fit=True, + ) + self._archive_button = Button( + "В архив", + style="TICKET_DETAILS_REFUSE_BUTTON", + content_fit=True, + ) + footer.add_stretch() + footer.add_widget(self._refuse_button) + footer.add_widget(self._archive_button) + footer.add_stretch() + return footer + + def _connect_signals(self) -> None: + self._application.task_updated.connect(self._on_task_updated) + self._application.task_removed.connect(self._on_task_removed) + if self._refuse_button is not None: + self._refuse_button.clicked.connect(self._on_refuse_clicked) + if self._archive_button is not None: + self._archive_button.clicked.connect(self._on_archive_clicked) + + def _reload_task(self) -> None: + task = self._application.get_task(self._task_id) + if task is None: + self.reject() + return + self._task = task + self._update_view(task) + + def _update_view(self, task: TicketTaskSnapshot) -> None: + for title, value in build_task_summary_rows(task): + label = self._summary_value_labels.get(title) + if label is not None: + label.set_text(value) + label.set_tooltip(value) + + for stage_view in build_task_stage_rows(task): + stage_row = self._stage_rows.get(stage_view.key) + if stage_row is not None: + stage_row.configure( + text=stage_view.text, + icon_path=stage_view.icon_path, + emphasized=stage_view.emphasized, + clickable=stage_view.clickable, + ) + + self._update_employee_section(task) + if self._refuse_button is not None: + self._refuse_button.set_visible(can_refuse_task(task)) + if self._archive_button is not None: + self._archive_button.set_visible(can_archive_task(task)) + + def _update_employee_section(self, task: TicketTaskSnapshot) -> None: + employee_data = build_employee_view_data(task) + if self._employee_name_label is not None: + self._employee_name_label.set_text(employee_data["name"]) + if self._employee_role_label is not None: + role_text = employee_data["position"].strip() + self._employee_role_label.set_text(role_text) + self._employee_role_label.set_visible(bool(role_text)) + if self._employee_avatar_label is None: + return + + avatar_pixmap = load_avatar_pixmap( + employee_data["photo_path"], + 64, + 64, + padding=2, + ) + if avatar_pixmap is None: + avatar_pixmap = build_placeholder_avatar_pixmap(64) + self._employee_avatar_label.set_pixmap(avatar_pixmap) + + def _on_task_updated(self, task: TicketTaskSnapshot) -> None: + if task.task_id != self._task_id: + return + self._task = task + self._update_view(task) + + def _on_task_removed(self, task_id: int) -> None: + if task_id == self._task_id: + self.reject() + + def _on_stage_clicked(self, stage_key: str) -> None: + if self._task is None: + return + if stage_key == "specialist": + self._actions.assign_specialist(self._task, self) + return + if stage_key == "diagnostic": + self._actions.sign_diagnostic(self._task, self) + return + if stage_key == "repair": + self._actions.sign_repair(self._task, self) + return + if stage_key == "acceptance": + self._actions.sign_acceptance(self._task, self) + + def _on_refuse_clicked(self) -> None: + if self._task is None: + return + snapshot = self._actions.refuse_task(self._task, self) + if snapshot is not None: + self.accept() + + def _on_archive_clicked(self) -> None: + if self._task is None: + return + snapshot = self._actions.archive_task(self._task, self) + if snapshot is not None: + self.accept() diff --git a/Dispatch_V0.1.1/ui/details/task_details_view_data.py b/Dispatch_V0.1.1/ui/details/task_details_view_data.py new file mode 100644 index 0000000..f498db1 --- /dev/null +++ b/Dispatch_V0.1.1/ui/details/task_details_view_data.py @@ -0,0 +1,116 @@ +# -*- coding: utf-8 -*- +# hub/ticket/ui/details/task_details_view_data.py + +"""Форматирование данных для нового dialog-экрана подробностей Ticket.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from domain import TicketTaskSnapshot, parse_location_parts +from domain.ticket_constants import STATE_ARCHIVED, STATE_COMPLETED, STATE_REFUSED +from ui.task_view_formatters import ( + build_specialist_card_info, + build_specialist_photo_path, + build_stage_icon_path, + can_assign_specialist, + can_sign_acceptance, + can_sign_diagnostic, + can_sign_repair, +) + + +@dataclass(frozen=True, slots=True) +class TaskStageRowData: + """View-data одной строки этапа выполнения заявки.""" + + key: str + text: str + icon_path: str + emphasized: bool + clickable: bool + + +def build_task_summary_rows(task: TicketTaskSnapshot) -> tuple[tuple[str, str], ...]: + institution, room, device = parse_location_parts(task.location or "") + return ( + ("Учреждение", institution or "Локация не указана"), + ("Оборудование", device or "Аппарат не указан"), + ("Кабинет", room or "Кабинет не указан"), + ("Назначение", build_task_status_text(task)), + ) + + +def build_task_status_text(task: TicketTaskSnapshot) -> str: + specialist_info = build_specialist_card_info(task.assigned_specialist) + short_name = specialist_info["short_name"].strip() + if not short_name: + return "Специалист не назначен" + return short_name + + +def build_employee_view_data(task: TicketTaskSnapshot) -> dict[str, str]: + specialist_info = build_specialist_card_info(task.assigned_specialist) + short_name = specialist_info["short_name"].strip() + position = specialist_info["position"].strip() + return { + "name": short_name or "Специалист не назначен", + "position": position if short_name else "Ожидает назначения", + "photo_path": build_specialist_photo_path( + task.assigned_specialist, + task.specialist_photo, + ), + } + + +def build_task_stage_rows(task: TicketTaskSnapshot) -> tuple[TaskStageRowData, ...]: + return ( + TaskStageRowData( + key="specialist", + text="Специалист назначен" if task.assigned_specialist.strip() else "Назначить специалиста", + icon_path=build_stage_icon_path("specialist", True), + emphasized=bool(task.assigned_specialist.strip()), + clickable=can_assign_specialist(task), + ), + TaskStageRowData( + key="diagnostic", + text=( + "Отчёт диагностики составлен" + if task.diagnostic_report_signed + else "Составить отчёт диагностики" + ), + icon_path=build_stage_icon_path("diagnostic", True), + emphasized=bool(task.diagnostic_report_signed), + clickable=can_sign_diagnostic(task), + ), + TaskStageRowData( + key="repair", + text=( + "Отчёт по ремонту составлен" + if task.repair_report_signed + else "Составить отчёт по ремонту" + ), + icon_path=build_stage_icon_path("repair", True), + emphasized=bool(task.repair_report_signed), + clickable=can_sign_repair(task), + ), + TaskStageRowData( + key="acceptance", + text=( + "Акт приёмки работ подписан" + if task.acceptance_report_signed + else "Составить акт приёмки работ" + ), + icon_path=build_stage_icon_path("acceptance", True), + emphasized=bool(task.acceptance_report_signed), + clickable=can_sign_acceptance(task), + ), + ) + + +def can_refuse_task(task: TicketTaskSnapshot) -> bool: + return task.state_code not in {STATE_COMPLETED, STATE_REFUSED, STATE_ARCHIVED} + + +def can_archive_task(task: TicketTaskSnapshot) -> bool: + return task.state_code in {STATE_COMPLETED, STATE_REFUSED} diff --git a/Dispatch_V0.1.1/ui/details/task_stage_action_row.py b/Dispatch_V0.1.1/ui/details/task_stage_action_row.py new file mode 100644 index 0000000..af229a3 --- /dev/null +++ b/Dispatch_V0.1.1/ui/details/task_stage_action_row.py @@ -0,0 +1,112 @@ +# -*- coding: utf-8 -*- +# hub/ticket/ui/details/task_stage_action_row.py + +"""Clickable task stage row for Ticket details.""" + +from __future__ import annotations + +from PySide6.QtCore import Qt, Signal, Slot +from PySide6.QtGui import QMouseEvent + +from gui.components import Label +from gui.containers import HContainer +from gui.theme_bus import theme_bus + +from ui.cards.task_card_pixmap_factory import load_tinted_icon_pixmap + + +class TaskStageActionRow(HContainer): + """Row with icon, text and optional click action.""" + + clicked = Signal(str) + + def __init__(self, stage_key: str, parent=None): + super().__init__( + margin=[4, 2, 4, 2], + spacing=12, + content_fit=True, + parent=parent, + style="TICKET_DETAILS_STAGE_ROW", + active_style="TICKET_DETAILS_STAGE_ROW_ACTIVE", + is_active=False, + ) + self._stage_key = stage_key + self._icon_path = "" + self._theme = "dark" if self.palette().window().color().lightness() < 128 else "light" + self._is_clickable = False + self._is_highlighted = False + self._icon_label: Label | None = None + self._text_label: Label | None = None + self._setup_ui() + theme_bus.theme_changed.connect(self.set_theme) + + def _setup_ui(self) -> None: + # Root-row этапа: держит иконку, текст этапа и свободное место для выравнивания строки. + self._icon_label = Label("", style="TICKET_TASK_CARD_AVATAR_IMAGE") + self._icon_label.set_fixed_size(22, 22) + self._text_label = Label( + "", + alignment="left", + style="TICKET_DETAILS_STAGE_TEXT_INACTIVE", + active_style="TICKET_DETAILS_STAGE_TEXT_ACTIVE", + is_active=False, + ) + self.add_widget(self._icon_label) + self.add_widget(self._text_label) + self.add_stretch() + + def configure( + self, + text: str, + icon_path: str, + emphasized: bool, + clickable: bool, + ) -> None: + self._icon_path = icon_path + self._is_clickable = bool(clickable) + self._is_highlighted = bool(emphasized) or self._is_clickable + + if self._text_label is not None: + self._text_label.set_text(text) + self._text_label.style(is_active=self._is_highlighted) + + self.style(is_active=self._is_clickable) + self.setCursor( + Qt.CursorShape.PointingHandCursor + if self._is_clickable + else Qt.CursorShape.ArrowCursor + ) + self._refresh_icon() + + @Slot(str) + def set_theme(self, theme: str) -> None: + normalized_theme = (theme or "").strip().lower() + if normalized_theme in {"dark", "light"}: + self._theme = normalized_theme + super().set_theme(theme) + self._refresh_icon() + + def mousePressEvent(self, event: QMouseEvent) -> None: + if self._is_clickable and event.button() == Qt.MouseButton.LeftButton: + self.clicked.emit(self._stage_key) + super().mousePressEvent(event) + + def _refresh_icon(self) -> None: + if self._icon_label is None or not self._icon_path: + return + icon_width = self._icon_label.width() or 22 + icon_height = self._icon_label.height() or 22 + pixmap = load_tinted_icon_pixmap( + self._icon_path, + icon_width, + icon_height, + self._resolve_icon_color(), + padding=2, + ) + if pixmap is not None: + self._icon_label.set_pixmap(pixmap) + + def _resolve_icon_color(self) -> str: + if self._is_highlighted: + return "#F5F7FA" if self._theme == "dark" else "#172B4D" + return "#8C9BAB" if self._theme == "dark" else "#6B778C" diff --git a/Dispatch_V0.1.1/ui/dialogs/__init__.py b/Dispatch_V0.1.1/ui/dialogs/__init__.py new file mode 100644 index 0000000..9363da5 --- /dev/null +++ b/Dispatch_V0.1.1/ui/dialogs/__init__.py @@ -0,0 +1,17 @@ +# -*- coding: utf-8 -*- +# hub/ticket/ui/dialogs/__init__.py + +"""Диалоги Ticket.""" + +from .acceptance_dialog import AcceptanceDialog +from .specialist_dialog import SpecialistDialog +from .task_refusal_dialog import TaskRefusalDialog +from .report_dialogs import DiagnosticReportDialog, RepairReportDialog + +__all__ = [ + "AcceptanceDialog", + "DiagnosticReportDialog", + "RepairReportDialog", + "SpecialistDialog", + "TaskRefusalDialog", +] diff --git a/Dispatch_V0.1.1/ui/dialogs/__pycache__/__init__.cpython-313.pyc b/Dispatch_V0.1.1/ui/dialogs/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..fad6627 Binary files /dev/null and b/Dispatch_V0.1.1/ui/dialogs/__pycache__/__init__.cpython-313.pyc differ diff --git a/Dispatch_V0.1.1/ui/dialogs/__pycache__/acceptance_dialog.cpython-313.pyc b/Dispatch_V0.1.1/ui/dialogs/__pycache__/acceptance_dialog.cpython-313.pyc new file mode 100644 index 0000000..3061e3a Binary files /dev/null and b/Dispatch_V0.1.1/ui/dialogs/__pycache__/acceptance_dialog.cpython-313.pyc differ diff --git a/Dispatch_V0.1.1/ui/dialogs/__pycache__/base_document_dialog.cpython-313.pyc b/Dispatch_V0.1.1/ui/dialogs/__pycache__/base_document_dialog.cpython-313.pyc new file mode 100644 index 0000000..68c689d Binary files /dev/null and b/Dispatch_V0.1.1/ui/dialogs/__pycache__/base_document_dialog.cpython-313.pyc differ diff --git a/Dispatch_V0.1.1/ui/dialogs/__pycache__/specialist_dialog.cpython-313.pyc b/Dispatch_V0.1.1/ui/dialogs/__pycache__/specialist_dialog.cpython-313.pyc new file mode 100644 index 0000000..c30f853 Binary files /dev/null and b/Dispatch_V0.1.1/ui/dialogs/__pycache__/specialist_dialog.cpython-313.pyc differ diff --git a/Dispatch_V0.1.1/ui/dialogs/__pycache__/task_refusal_dialog.cpython-313.pyc b/Dispatch_V0.1.1/ui/dialogs/__pycache__/task_refusal_dialog.cpython-313.pyc new file mode 100644 index 0000000..6c78691 Binary files /dev/null and b/Dispatch_V0.1.1/ui/dialogs/__pycache__/task_refusal_dialog.cpython-313.pyc differ diff --git a/Dispatch_V0.1.1/ui/dialogs/acceptance_dialog.py b/Dispatch_V0.1.1/ui/dialogs/acceptance_dialog.py new file mode 100644 index 0000000..1fefc11 --- /dev/null +++ b/Dispatch_V0.1.1/ui/dialogs/acceptance_dialog.py @@ -0,0 +1,86 @@ +# -*- coding: utf-8 -*- +# hub/ticket/ui/dialogs/acceptance_dialog.py + +"""UI-диалог акта приёмки Ticket.""" + +from __future__ import annotations + +from gui.containers import SContainer + +from domain import TicketTaskSnapshot +from .base_document_dialog import BaseDocumentDialog + + +class AcceptanceDialog(BaseDocumentDialog): + """Диалог подписания акта приёмки.""" + + def __init__( + self, + task: TicketTaskSnapshot, + parent=None, + ): + self._work_description = None + self._executor_signature = None + self._customer_signature = None + super().__init__( + task=task, + title="Акт приёмки", + submit_text="Подписать акт", + parent=parent, + ) + + def build_payload(self) -> dict[str, str]: + return { + "work_description": self._work_description.get_text().strip(), + "executor_signature": self._executor_signature.get_text().strip(), + "customer_signature": self._customer_signature.get_text().strip(), + } + + def _is_ready(self) -> bool: + payload = self.build_payload() + return bool( + payload["work_description"] + and payload["executor_signature"] + and payload["customer_signature"] + ) + + def _build_form(self, container: SContainer) -> None: + work_description_shell = SContainer( + height_percent=40, + orientation="v", + spacing=6, + content_fit=False, + ) + self._work_description = self._populate_text_block( + work_description_shell, + "Описание выполненных работ", + "Опишите объём работ, который передаётся заказчику.", + ) + container.add_widget(work_description_shell) + + executor_signature_shell = SContainer( + height_percent=23, + orientation="v", + spacing=6, + content_fit=False, + ) + self._executor_signature = self._populate_text_block( + executor_signature_shell, + "Исполнитель", + "Укажите ФИО и должность исполнителя.", + ) + container.add_widget(executor_signature_shell) + + customer_signature_shell = SContainer( + height_percent=23, + orientation="v", + spacing=6, + content_fit=False, + ) + self._customer_signature = self._populate_text_block( + customer_signature_shell, + "Заказчик", + "Укажите ФИО и должность представителя заказчика.", + ) + container.add_widget(customer_signature_shell) + self._refresh_submit_state() diff --git a/Dispatch_V0.1.1/ui/dialogs/base_document_dialog.py b/Dispatch_V0.1.1/ui/dialogs/base_document_dialog.py new file mode 100644 index 0000000..c56ddcd --- /dev/null +++ b/Dispatch_V0.1.1/ui/dialogs/base_document_dialog.py @@ -0,0 +1,218 @@ +# -*- coding: utf-8 -*- +# hub/ticket/ui/dialogs/base_document_dialog.py + +"""Базовый UI-диалог документов Ticket без файловой и доменной логики.""" + +from __future__ import annotations + +from gui.components import Button, Dialog, Label, TextInput +from gui.containers import HContainer, SContainer, VContainer + +from domain import TicketTaskSnapshot, parse_location_parts +from ui.task_view_formatters import build_specialist_card_info + + +class BaseDocumentDialog(Dialog): + """Базовый modal-диалог для ввода данных документа Ticket.""" + + def __init__( + self, + task: TicketTaskSnapshot, + title: str, + submit_text: str, + parent=None, + ): + self._task = task + self._cancel_button: Button | None = None + self._submit_button: Button | None = None + self._form_text_edits: list[TextInput] = [] + super().__init__( + title=title, + width=540, + height=740, + modal=True, + parent=parent, + ) + self._setup_ui(submit_text) + self._connect_signals() + self._refresh_submit_state() + + def _setup_ui(self, submit_text: str) -> None: + # Root-контейнер документа: собирает все основные зоны окна сверху вниз. + root = VContainer(margin=[24, 20, 24, 20], spacing=0) + self.add_widget(root) + root.add_widget(self._build_summary_shell()) + root.add_widget(self._build_divider()) + root.add_widget(self._build_form_shell()) + root.add_widget(self._build_actions_section(submit_text)) + + def _build_summary_shell(self) -> SContainer: + # Summary-shell: верхняя область окна с базовой карточкой контекста документа. + summary_shell = SContainer( + height_percent=24, + orientation="v", + content_fit=False, + ) + # Summary-content: вертикальный стек строк внутри summary-shell. + summary_content = VContainer(spacing=10, content_fit=True, parent=summary_shell) + for row in self._build_summary_rows(): + summary_content.add_widget(row) + return summary_shell + + def _build_summary_rows(self) -> list[HContainer]: + institution, room, device = parse_location_parts(self._task.location) + rows: list[HContainer] = [] + for title, value in ( + ("Учреждение", institution or "Локация не указана"), + ("Оборудование", device or "Аппарат не указан"), + ("Кабинет", room or "Кабинет не указан"), + ("Специалист", self._build_specialist_summary()), + ): + rows.append(self._build_summary_row(title, value)) + return rows + + def _build_summary_row(self, title: str, value: str) -> HContainer: + # Summary-row: горизонтальная строка для пары "заголовок поля + значение". + row = HContainer(spacing=10, content_fit=True) + title_label = Label( + f"{title}:", + alignment="left", + style="TICKET_DETAILS_SUMMARY_TITLE", + ) + value_label = Label( + value, + alignment="left", + style="TICKET_DETAILS_SUMMARY_VALUE", + ) + value_label.set_tooltip(value) + row.add_widget(title_label) + row.add_widget_with_stretch(value_label, 1) + return row + + def _build_divider(self) -> Label: + return Label( + "", + style="TICKET_DETAILS_DIVIDER", + height_percent=1, + ) + + def _build_form_shell(self) -> SContainer: + # Form-shell: центральная рабочая зона, куда наследник добавляет поля документа. + form_shell = SContainer( + height_percent=58, + orientation="v", + spacing=10, + content_fit=False, + ) + self._build_form(form_shell) + return form_shell + + def _build_actions_section(self, submit_text: str) -> HContainer: + # Actions-row: нижняя линия действий с фиксированной процентной сеткой. + actions = HContainer( + height_percent=11, + spacing=0, + content_fit=False, + ) + # Левый spacer-контейнер: формирует стартовый отступ перед кнопкой отмены. + SContainer( + width_percent=10, + height_percent=100, + parent=actions, + ) + self._cancel_button = Button( + "Отмена", + width_percent=26, + height_percent=100, + margin=0, + style="TICKET_DOCUMENT_CANCEL_BUTTON", + content_fit=False, + ) + actions.add_widget(self._cancel_button) + # Центральный spacer-контейнер: удерживает зазор между двумя action-кнопками. + SContainer( + width_percent=4, + height_percent=100, + parent=actions, + ) + self._submit_button = Button( + submit_text, + width_percent=50, + height_percent=100, + margin=0, + style="TICKET_DOCUMENT_SUBMIT_BUTTON", + content_fit=False, + ) + actions.add_widget(self._submit_button) + # Правый spacer-контейнер: завершает строку действий симметричным отступом. + SContainer( + width_percent=10, + height_percent=100, + parent=actions, + ) + self._submit_button.set_enabled(False) + return actions + + def _connect_signals(self) -> None: + if self._cancel_button is not None: + self._cancel_button.clicked.connect(self.reject) + if self._submit_button is not None: + self._submit_button.clicked.connect(self.accept) + for text_edit in self._form_text_edits: + text_edit.text_changed.connect(self._refresh_submit_state) + self._connect_form_signals() + + def build_payload(self) -> dict[str, str]: + """Вернуть данные формы. Реализуется в наследниках.""" + raise NotImplementedError + + def _build_form(self, container: SContainer) -> None: + """Построить специфическую часть формы.""" + raise NotImplementedError + + def _is_ready(self) -> bool: + """Проверить, что форма готова к отправке.""" + raise NotImplementedError + + def _connect_form_signals(self) -> None: + """Подключить сигналы элементов формы, если кроме текстовых полей есть другие элементы.""" + return None + + def _populate_text_block( + self, + field_shell: SContainer, + title: str, + placeholder: str, + ) -> TextInput: + # Field-shell: ожидает внутри себя заголовок поля и текстовую область конкретной секции. + field_shell.add_widget( + Label( + title, + alignment="left", + style="TICKET_DETAILS_SECTION_TITLE", + ) + ) + text_edit = TextInput( + placeholder=placeholder, + style="TICKET_DOCUMENT_TEXTAREA", + multiline=True, + content_fit=False, + ) + self._form_text_edits.append(text_edit) + field_shell.add_widget_with_stretch(text_edit, 1) + return text_edit + + def _refresh_submit_state(self) -> None: + if self._submit_button is not None: + self._submit_button.set_enabled(self._is_ready()) + + def _build_specialist_summary(self) -> str: + specialist_name = self._task.assigned_specialist.strip() + if not specialist_name: + return "Не назначен" + specialist_info = build_specialist_card_info(specialist_name) + short_name = specialist_info["short_name"].strip() or specialist_name + position = specialist_info["position"].strip() + if not position: + return short_name + return f"{short_name}, {position}" diff --git a/Dispatch_V0.1.1/ui/dialogs/report_dialogs/__init__.py b/Dispatch_V0.1.1/ui/dialogs/report_dialogs/__init__.py new file mode 100644 index 0000000..bc09c92 --- /dev/null +++ b/Dispatch_V0.1.1/ui/dialogs/report_dialogs/__init__.py @@ -0,0 +1,11 @@ +# -*- coding: utf-8 -*- +# hub/ticket/ui/dialogs/report_dialogs/__init__.py + +"""Диалоги отчётов Ticket.""" + +from .report_dialog import DiagnosticReportDialog, RepairReportDialog + +__all__ = [ + "DiagnosticReportDialog", + "RepairReportDialog", +] diff --git a/Dispatch_V0.1.1/ui/dialogs/report_dialogs/__pycache__/__init__.cpython-313.pyc b/Dispatch_V0.1.1/ui/dialogs/report_dialogs/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..fd416ac Binary files /dev/null and b/Dispatch_V0.1.1/ui/dialogs/report_dialogs/__pycache__/__init__.cpython-313.pyc differ diff --git a/Dispatch_V0.1.1/ui/dialogs/report_dialogs/__pycache__/report_dialog.cpython-313.pyc b/Dispatch_V0.1.1/ui/dialogs/report_dialogs/__pycache__/report_dialog.cpython-313.pyc new file mode 100644 index 0000000..6422ae2 Binary files /dev/null and b/Dispatch_V0.1.1/ui/dialogs/report_dialogs/__pycache__/report_dialog.cpython-313.pyc differ diff --git a/Dispatch_V0.1.1/ui/dialogs/report_dialogs/report_dialog.py b/Dispatch_V0.1.1/ui/dialogs/report_dialogs/report_dialog.py new file mode 100644 index 0000000..71ea322 --- /dev/null +++ b/Dispatch_V0.1.1/ui/dialogs/report_dialogs/report_dialog.py @@ -0,0 +1,137 @@ +# -*- coding: utf-8 -*- +# hub/ticket/ui/dialogs/report_dialogs/report_dialog.py + +"""UI-диалоги отчётов Ticket поверх application document-flow.""" + +from __future__ import annotations + +from gui.containers import SContainer + +from domain import TicketTaskSnapshot +from ui.dialogs.base_document_dialog import BaseDocumentDialog + + +class DiagnosticReportDialog(BaseDocumentDialog): + """Диалог диагностического отчёта.""" + + def __init__( + self, + task: TicketTaskSnapshot, + parent=None, + ): + self._initial_cause = None + self._actual_cause = None + super().__init__( + task=task, + title="Диагностический отчёт", + submit_text="Подписать диагностику", + parent=parent, + ) + + def build_payload(self) -> dict[str, str]: + return { + "initial_cause": self._initial_cause.get_text().strip(), + "actual_cause": self._actual_cause.get_text().strip(), + } + + def _is_ready(self) -> bool: + payload = self.build_payload() + return bool(payload["initial_cause"] and payload["actual_cause"]) + + def _build_form(self, container: SContainer) -> None: + initial_cause_shell = SContainer( + height_percent=48, + orientation="v", + spacing=6, + content_fit=False, + ) + self._initial_cause = self._populate_text_block( + initial_cause_shell, + "Первичное заключение", + "Кратко опишите исходную причину неисправности.", + ) + container.add_widget(initial_cause_shell) + + actual_cause_shell = SContainer( + height_percent=48, + orientation="v", + spacing=6, + content_fit=False, + ) + self._actual_cause = self._populate_text_block( + actual_cause_shell, + "Вторичное заключение", + "Зафиксируйте подтверждённую причину по итогам диагностики.", + ) + container.add_widget(actual_cause_shell) + self._refresh_submit_state() + + +class RepairReportDialog(BaseDocumentDialog): + """Диалог ремонтного отчёта.""" + + def __init__( + self, + task: TicketTaskSnapshot, + parent=None, + ): + self._work_done = None + self._used_parts = None + self._recommendations = None + super().__init__( + task=task, + title="Ремонтный отчёт", + submit_text="Подписать ремонт", + parent=parent, + ) + + def build_payload(self) -> dict[str, str]: + return { + "work_done": self._work_done.get_text().strip(), + "used_parts": self._used_parts.get_text().strip(), + "recommendations": self._recommendations.get_text().strip(), + } + + def _is_ready(self) -> bool: + return bool(self.build_payload()["work_done"]) + + def _build_form(self, container: SContainer) -> None: + work_done_shell = SContainer( + height_percent=31, + orientation="v", + spacing=6, + content_fit=False, + ) + self._work_done = self._populate_text_block( + work_done_shell, + "Выполненные работы", + "Опишите фактически выполненные работы.", + ) + container.add_widget(work_done_shell) + + used_parts_shell = SContainer( + height_percent=31, + orientation="v", + spacing=6, + content_fit=False, + ) + self._used_parts = self._populate_text_block( + used_parts_shell, + "Использованные запчасти", + "Перечислите использованные узлы и материалы, если они были.", + ) + container.add_widget(used_parts_shell) + + recommendations_shell = SContainer( + height_percent=31, + orientation="v", + spacing=6, + content_fit=False, + ) + self._recommendations = self._populate_text_block( + recommendations_shell, + "Рекомендации", + "Добавьте рекомендации для следующего обслуживания.", + ) + container.add_widget(recommendations_shell) + self._refresh_submit_state() diff --git a/Dispatch_V0.1.1/ui/dialogs/specialist_dialog.py b/Dispatch_V0.1.1/ui/dialogs/specialist_dialog.py new file mode 100644 index 0000000..85d5c32 --- /dev/null +++ b/Dispatch_V0.1.1/ui/dialogs/specialist_dialog.py @@ -0,0 +1,223 @@ +# -*- coding: utf-8 -*- +# hub/ticket/ui/dialogs/specialist_dialog.py + +"""UI-диалог выбора специалиста Ticket.""" + +from __future__ import annotations + +from collections.abc import Sequence + +from PySide6.QtCore import Qt, Signal +from PySide6.QtGui import QMouseEvent + +from gui.components import Button, Dialog, Label +from gui.containers import HContainer, ScrollContainer, SContainer, VContainer + +from ui.cards.task_card_pixmap_factory import ( + build_placeholder_avatar_pixmap, + load_avatar_pixmap, +) +from ui.task_view_formatters import ( + build_specialist_card_info, + build_specialist_photo_path, +) + + +class _SpecialistRow(SContainer): + """Строка выбора специалиста с фото, именем и должностью.""" + + clicked = Signal(str) + activated = Signal(str) + + def __init__(self, specialist_name: str, parent=None): + super().__init__( + margin=0, + spacing=0, + content_fit=True, + parent=parent, + style="TICKET_SPECIALIST_ITEM", + active_style="TICKET_SPECIALIST_ITEM_SELECTED", + is_active=False, + ) + self._specialist_name = specialist_name + self._info = build_specialist_card_info(specialist_name) + self._name_label: Label | None = None + self._role_label: Label | None = None + self._avatar_label: Label | None = None + self.setObjectName("ticket_specialist_row") + self.setCursor(Qt.CursorShape.PointingHandCursor) + self.set_min_height(88) + self._setup_ui() + + @property + def specialist_name(self) -> str: + return self._specialist_name + + def set_selected(self, selected: bool) -> None: + self.style(is_active=selected) + + def mousePressEvent(self, event: QMouseEvent) -> None: + if event.button() == Qt.MouseButton.LeftButton: + self.clicked.emit(self._specialist_name) + super().mousePressEvent(event) + + def mouseDoubleClickEvent(self, event: QMouseEvent) -> None: + if event.button() == Qt.MouseButton.LeftButton: + self.clicked.emit(self._specialist_name) + self.activated.emit(self._specialist_name) + super().mouseDoubleClickEvent(event) + + def _setup_ui(self) -> None: + # Body-row карточки специалиста: фото слева и текстовый блок справа. + body = HContainer(margin=[12, 10, 12, 10], spacing=16, content_fit=True, parent=self) + body.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, True) + + self._avatar_label = Label("", style="TICKET_TASK_CARD_AVATAR_IMAGE") + self._avatar_label.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, True) + self._avatar_label.set_fixed_size(72, 72) + self._avatar_label.set_pixmap(self._build_avatar_pixmap()) + + self._name_label = Label( + self._specialist_name, + alignment="left", + style="TICKET_DETAILS_SECTION_TITLE", + ) + self._name_label.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, True) + + self._role_label = Label( + self._info.get("position", "").strip() or "Специалист", + alignment="left", + style="TICKET_SPECIALIST_ROLE", + ) + self._role_label.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, True) + + # Text-block специалиста: вертикальный контейнер имени и должности. + text_block = VContainer(spacing=2, content_fit=True) + text_block.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, True) + text_block.add_widget(self._name_label) + text_block.add_widget(self._role_label) + + body.add_widget(self._avatar_label) + body.add_widget_with_stretch(text_block, 1) + + def _build_avatar_pixmap(self): + photo_path = build_specialist_photo_path( + self._specialist_name, + self._info.get("photo", ""), + ) + avatar = load_avatar_pixmap(photo_path, 72, 72, padding=2) + if avatar is None: + return build_placeholder_avatar_pixmap(72) + return avatar + + +class SpecialistDialog(Dialog): + """Диалог выбора специалиста без application-логики.""" + + def __init__( + self, + specialists: Sequence[str], + parent=None, + ): + self._specialists = [str(item).strip() for item in specialists if str(item).strip()] + self._selected_specialist = "" + self._rows: dict[str, _SpecialistRow] = {} + self._cancel_button: Button | None = None + self._submit_button: Button | None = None + super().__init__( + title="Выбор специалиста", + width=540, + height=740, + modal=True, + parent=parent, + ) + self._setup_ui() + self._connect_signals() + self._refresh_submit_state() + + @property + def selected_specialist(self) -> str: + return self._selected_specialist + + def _setup_ui(self) -> None: + # Root-контейнер диалога: подсказка, список специалистов и action-строка. + main_container = VContainer(margin=[22, 20, 22, 20], spacing=16) + self.add_widget(main_container) + main_container.add_widget( + Label( + "Выберите специалиста для назначения на\nзадачу:", + alignment="left", + style="TICKET_SPECIALIST_HINT", + ) + ) + main_container.add_widget_with_stretch(self._build_list(), 1) + main_container.add_widget(self._build_actions()) + + def _build_list(self) -> ScrollContainer: + # Scroll-list специалистов: прокручиваемая область с вариантами назначения. + scroll = ScrollContainer( + spacing=12, + orientation="v", + content_margins=[0, 0, 0, 0], + vertical_scroll_bar_policy="as_needed", + horizontal_scroll_bar_policy="always_off", + style="SCROLL_CONTAINER", + ) + for specialist_name in self._specialists: + row = _SpecialistRow(specialist_name) + self._rows[specialist_name] = row + scroll.add_widget(row) + return scroll + + def _build_actions(self) -> HContainer: + # Actions-row диалога: выравнивает кнопки отмены и подтверждения выбора. + actions = HContainer(spacing=18, content_fit=True) + actions.add_stretch() + self._cancel_button = Button( + "Отмена", + style="TICKET_DOCUMENT_CANCEL_BUTTON", + content_fit=True, + ) + self._cancel_button.set_min_width(160) + self._cancel_button.set_min_height(56) + self._submit_button = Button( + "Выбрать", + style="TICKET_DOCUMENT_SUBMIT_BUTTON", + content_fit=True, + ) + self._submit_button.set_min_width(180) + self._submit_button.set_min_height(56) + actions.add_widget(self._cancel_button) + actions.add_widget(self._submit_button) + return actions + + def _connect_signals(self) -> None: + for row in self._rows.values(): + row.clicked.connect(self._on_row_clicked) + row.activated.connect(self._on_row_activated) + if self._submit_button is not None: + self._submit_button.clicked.connect(self._handle_accept) + if self._cancel_button is not None: + self._cancel_button.clicked.connect(self.reject) + + def _refresh_submit_state(self) -> None: + if self._submit_button is not None: + self._submit_button.set_enabled(bool(self._selected_specialist)) + + def _set_selected_specialist(self, specialist_name: str) -> None: + self._selected_specialist = specialist_name if specialist_name in self._rows else "" + for row_name, row in self._rows.items(): + row.set_selected(row_name == self._selected_specialist) + self._refresh_submit_state() + + def _handle_accept(self) -> None: + if not self._selected_specialist: + return + self.accept() + + def _on_row_clicked(self, specialist_name: str) -> None: + self._set_selected_specialist(specialist_name) + + def _on_row_activated(self, specialist_name: str) -> None: + self._set_selected_specialist(specialist_name) + self._handle_accept() diff --git a/Dispatch_V0.1.1/ui/dialogs/task_refusal_dialog.py b/Dispatch_V0.1.1/ui/dialogs/task_refusal_dialog.py new file mode 100644 index 0000000..df22f58 --- /dev/null +++ b/Dispatch_V0.1.1/ui/dialogs/task_refusal_dialog.py @@ -0,0 +1,135 @@ +# -*- coding: utf-8 -*- +# hub/ticket/ui/dialogs/task_refusal_dialog.py + +"""Диалог подтверждения отказа задачи Ticket.""" + +from __future__ import annotations + +from gui.components import Button, Dialog, Label, TextInput +from gui.containers import HContainer, VContainer + +from domain import TicketTaskSnapshot, parse_location_parts + + +class TaskRefusalDialog(Dialog): + """Диалог ввода обязательной причины отказа по задаче.""" + + def __init__(self, task: TicketTaskSnapshot, parent=None): + self._task = task + self._reason_input: TextInput | None = None + self._cancel_button: Button | None = None + self._submit_button: Button | None = None + super().__init__( + title="Отказ в обслуживании", + width=500, + height=460, + modal=True, + parent=parent, + ) + self._setup_ui() + self._connect_signals() + self._refresh_submit_state() + + @property + def refusal_reason(self) -> str: + if self._reason_input is None: + return "" + return self._reason_input.get_text().strip() + + def _setup_ui(self) -> None: + # Root-контейнер окна отказа: предупреждение, контекст задачи, поле причины и actions. + main_container = VContainer(margin=[24, 20, 24, 20], spacing=16) + self.add_widget(main_container) + main_container.add_widget( + Label( + "Вы уверены, что хотите отказать в обслуживании?", + alignment="left", + style="TICKET_REFUSAL_HEADING", + ) + ) + main_container.add_widget(self._build_location_row()) + main_container.add_widget( + Label( + 'Задача будет перемещена в колонку "Отказ"', + alignment="left", + style="TICKET_REFUSAL_WARNING", + ) + ) + main_container.add_widget( + Label( + "Причина отказа", + alignment="left", + style="TICKET_REFUSAL_HEADING", + ) + ) + main_container.add_widget_with_stretch(self._build_reason_input(), 1) + main_container.add_widget(self._build_actions()) + + def _build_location_row(self) -> HContainer: + # Location-row: показывает локацию задачи, чтобы отказ происходил в явном контексте. + row = HContainer(spacing=10, content_fit=True) + row.add_widget( + Label( + "Локация и кабинет:", + alignment="left", + style="TICKET_REFUSAL_LOCATION_TITLE", + ) + ) + value_label = Label( + self._build_location_text(), + alignment="left", + style="TICKET_REFUSAL_LOCATION_VALUE", + ) + value_label.set_tooltip(self._build_location_text()) + row.add_widget_with_stretch(value_label, 1) + return row + + def _build_reason_input(self) -> TextInput: + self._reason_input = TextInput( + placeholder="Укажите причину отказа", + style="TICKET_DOCUMENT_TEXTAREA", + multiline=True, + ) + self._reason_input.set_min_height(170) + return self._reason_input + + def _build_actions(self) -> HContainer: + # Actions-row окна отказа: собирает кнопки отмены и финального подтверждения. + actions = HContainer(spacing=18, content_fit=True) + actions.add_stretch() + self._cancel_button = Button( + "Отмена", + style="TICKET_DOCUMENT_CANCEL_BUTTON", + content_fit=True, + ) + self._submit_button = Button( + "Подтвердить отказ", + style="TICKET_DOCUMENT_SUBMIT_BUTTON", + content_fit=True, + ) + actions.add_widget(self._cancel_button) + actions.add_widget(self._submit_button) + return actions + + def _connect_signals(self) -> None: + if self._reason_input is not None: + self._reason_input.text_changed.connect(self._refresh_submit_state) + if self._cancel_button is not None: + self._cancel_button.clicked.connect(self.reject) + if self._submit_button is not None: + self._submit_button.clicked.connect(self._handle_accept) + + def _refresh_submit_state(self) -> None: + if self._submit_button is not None: + self._submit_button.set_enabled(bool(self.refusal_reason)) + + def _handle_accept(self) -> None: + if not self.refusal_reason: + return + self.accept() + + def _build_location_text(self) -> str: + institution, room, _ = parse_location_parts(self._task.location or "") + normalized_institution = institution or "Локация не указана" + normalized_room = room or "Кабинет не указан" + return f"{normalized_institution}, {normalized_room}" diff --git a/Dispatch_V0.1.1/ui/pages/__init__.py b/Dispatch_V0.1.1/ui/pages/__init__.py new file mode 100644 index 0000000..158a04a --- /dev/null +++ b/Dispatch_V0.1.1/ui/pages/__init__.py @@ -0,0 +1,16 @@ +# -*- coding: utf-8 -*- +# hub/ticket/ui/pages/__init__.py + +"""Страницы Ticket.""" + +from .acts_page import ActsPage +from .archive_page import ArchivePage +from .reports_page import ReportsPage +from .report_viewer import ReportViewer + +__all__ = [ + "ActsPage", + "ArchivePage", + "ReportsPage", + "ReportViewer", +] diff --git a/Dispatch_V0.1.1/ui/pages/__pycache__/__init__.cpython-313.pyc b/Dispatch_V0.1.1/ui/pages/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..bbae075 Binary files /dev/null and b/Dispatch_V0.1.1/ui/pages/__pycache__/__init__.cpython-313.pyc differ diff --git a/Dispatch_V0.1.1/ui/pages/__pycache__/acts_page.cpython-313.pyc b/Dispatch_V0.1.1/ui/pages/__pycache__/acts_page.cpython-313.pyc new file mode 100644 index 0000000..4963640 Binary files /dev/null and b/Dispatch_V0.1.1/ui/pages/__pycache__/acts_page.cpython-313.pyc differ diff --git a/Dispatch_V0.1.1/ui/pages/__pycache__/archive_page.cpython-313.pyc b/Dispatch_V0.1.1/ui/pages/__pycache__/archive_page.cpython-313.pyc new file mode 100644 index 0000000..1f15d46 Binary files /dev/null and b/Dispatch_V0.1.1/ui/pages/__pycache__/archive_page.cpython-313.pyc differ diff --git a/Dispatch_V0.1.1/ui/pages/__pycache__/archive_view_helpers.cpython-313.pyc b/Dispatch_V0.1.1/ui/pages/__pycache__/archive_view_helpers.cpython-313.pyc new file mode 100644 index 0000000..6ce6b27 Binary files /dev/null and b/Dispatch_V0.1.1/ui/pages/__pycache__/archive_view_helpers.cpython-313.pyc differ diff --git a/Dispatch_V0.1.1/ui/pages/__pycache__/report_viewer.cpython-313.pyc b/Dispatch_V0.1.1/ui/pages/__pycache__/report_viewer.cpython-313.pyc new file mode 100644 index 0000000..fb79fff Binary files /dev/null and b/Dispatch_V0.1.1/ui/pages/__pycache__/report_viewer.cpython-313.pyc differ diff --git a/Dispatch_V0.1.1/ui/pages/__pycache__/reports_page.cpython-313.pyc b/Dispatch_V0.1.1/ui/pages/__pycache__/reports_page.cpython-313.pyc new file mode 100644 index 0000000..4dbf850 Binary files /dev/null and b/Dispatch_V0.1.1/ui/pages/__pycache__/reports_page.cpython-313.pyc differ diff --git a/Dispatch_V0.1.1/ui/pages/acts_page.py b/Dispatch_V0.1.1/ui/pages/acts_page.py new file mode 100644 index 0000000..a34d4aa --- /dev/null +++ b/Dispatch_V0.1.1/ui/pages/acts_page.py @@ -0,0 +1,335 @@ +# -*- coding: utf-8 -*- +# hub/ticket/ui/pages/acts_page.py + +"""Самостоятельная страница актов Ticket.""" + +from __future__ import annotations + +from PySide6.QtCore import Qt, Signal +from PySide6.QtWidgets import QSizePolicy + +from gui.components import Label, TextInput +from gui.containers import HContainer, SContainer, ScrollContainer, VContainer + +from application import TaskApplicationService +from domain import TicketDocumentSnapshot + + +# ------------------------------------------------------------------------- +# Карточка акта (по образцу _ReportCardView) +# ------------------------------------------------------------------------- + +class _ActCardView(SContainer): + """Карточка акта: заголовок, дата+учреждение, аппарат+кабинет.""" + + card_clicked = Signal(str) + + def __init__(self, document: TicketDocumentSnapshot, parent=None): + super().__init__( + width_percent=100, + margin=0, + content_fit=True, + parent=parent, + ) + self._document_id = document.document_id + self._title_label: Label | None = None + self._subtitle_label: Label | None = None + self._meta_label: Label | None = None + self._selected = False + self._height_sync_in_progress = False + self.setObjectName("ticket_report_card") + self.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True) + self.setCursor(Qt.CursorShape.PointingHandCursor) + self.set_size_policy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) + self._setup_ui() + self._fill(document) + + @property + def document_id(self) -> str: + return self._document_id + + def set_selected(self, selected: bool) -> None: + self._selected = selected + self._apply_root_style() + + def _setup_ui(self) -> None: + content = SContainer( + width_percent=100, + height_percent=100, + margin=8, + spacing=0, + style="TICKET_REPORT_CARD_CONTENT", + parent=self, + ) + text_column = VContainer( + width_percent=100, + spacing=2, + parent=content, + ) + + self._title_label = Label("", alignment="left", parent=text_column) + self._subtitle_label = Label("", alignment="left", parent=text_column) + self._meta_label = Label("", alignment="left", parent=text_column) + + def _fill(self, document: TicketDocumentSnapshot) -> None: + title = document.title or "Акт" + created_at = document.created_at.strftime("%d.%m.%Y %H:%M") + facility = document.payload.get("facility") or document.location or "" + if "(" in facility: + facility = facility[:facility.index("(")].strip() + device = document.payload.get("device") or "" + cabinet = document.payload.get("cabinet") or "" + + subtitle_parts = [p for p in (created_at, facility) if p] + meta_parts = [p for p in (device, cabinet) if p] + + if self._title_label is not None: + self._title_label.set_text(title) + if self._subtitle_label is not None: + self._subtitle_label.set_text(" , ".join(subtitle_parts)) + if self._meta_label is not None: + self._meta_label.set_text(" - ".join(meta_parts)) + + self._apply_root_style() + self._apply_text_styles() + + def _apply_root_style(self) -> None: + if self._selected: + self.style("TICKET_REPORT_CARD_ROOT_SELECTED") + else: + self.style("TICKET_REPORT_CARD_ROOT") + + def _apply_text_styles(self) -> None: + if self._title_label is not None: + self._title_label.style("TICKET_REPORT_CARD_TITLE") + if self._subtitle_label is not None: + self._subtitle_label.style("TICKET_REPORT_CARD_SUBTITLE") + if self._meta_label is not None: + self._meta_label.style("TICKET_REPORT_CARD_META") + + def mousePressEvent(self, event) -> None: + if event.button() == Qt.MouseButton.LeftButton: + self.card_clicked.emit(self._document_id) + super().mousePressEvent(event) + + def resizeEvent(self, event) -> None: + super().resizeEvent(event) + self._sync_card_height() + + def _sync_card_height(self) -> None: + if self._height_sync_in_progress or self.width() <= 0: + return + target_height = max(1, round(self.width() / 2.745)) + if self.height() == target_height: + return + self._height_sync_in_progress = True + self.setFixedHeight(target_height) + self._height_sync_in_progress = False + + +# ------------------------------------------------------------------------- +# Страница актов +# ------------------------------------------------------------------------- + +class ActsPage(SContainer): + """Страница актов, полностью локализованная внутри собственного класса.""" + + def __init__(self, application: TaskApplicationService, parent=None): + super().__init__(width_percent=100, height_percent=100, parent=parent) + self._application = application + self._documents: dict[str, TicketDocumentSnapshot] = {} + self._selected_document_id: str | None = None + self._card_host: VContainer | None = None + self._preview: TextInput | None = None + self._empty_list_label: Label | None = None + self._cards: dict[str, _ActCardView] = {} + self._setup_ui() + self._connect_signals() + self._apply_initial_state() + self._reload_documents() + + # -- UI ---------------------------------------------------------------- + + def _setup_ui(self) -> None: + board_row = HContainer( + margin=[0, 0, 0, 0], + height_percent=100, + spacing=16, + style="TICKET_SURFACE_HOST", + parent=self, + ) + + board_row.add_widget(self._build_list_column()) + board_row.add_widget(self._build_preview_column()) + + def _build_list_column(self) -> SContainer: + column = SContainer(spacing=12, parent=None) + + header = HContainer( + height_percent=5.37, + margin=0, + spacing=16, + content_fit=False, + style="TICKET_BOARD_COLUMN_HEADER", + ) + header.add_widget( + Label("Список документов", margin=[12, 0, 0, 0], style="TICKET_BOARD_COLUMN_TITLE"), + ) + header.add_stretch() + + body = SContainer( + margin=0, + style="TICKET_REPORT_COLUMN_BODY", + ) + + scroll = ScrollContainer( + margin=0, + spacing=0, + orientation="v", + vertical_scroll_bar_policy="always_off", + horizontal_scroll_bar_policy="always_off", + style="SCROLL_CONTAINER", + parent=body, + ) + scroll.scroll_area.verticalScrollBar().setSingleStep(48) + + self._card_host = VContainer( + spacing=12, + content_fit=False, + parent=scroll, + ) + self._card_host.set_size_policy( + QSizePolicy.Policy.Expanding, + QSizePolicy.Policy.Fixed, + ) + + column.add_widget(header) + column.add_widget(body) + return column + + def _build_preview_column(self) -> SContainer: + column = SContainer(spacing=12, width_percent=79.83, parent=None) + + header = HContainer( + height_percent=5.37, + margin=0, + spacing=16, + content_fit=False, + style="TICKET_BOARD_COLUMN_HEADER", + ) + header.add_widget( + Label("Просмотр", margin=[12, 0, 0, 0], style="TICKET_BOARD_COLUMN_TITLE"), + ) + header.add_stretch() + + body = SContainer( + margin=0, + style="TICKET_REPORT_PREVIEW_BODY", + ) + preview_inner = VContainer(margin=0, spacing=0, parent=body) + self._preview = TextInput(style="TICKET_REPORT_PREVIEW_AREA", multiline=True) + self._preview.set_read_only(True) + preview_inner.add_widget_with_stretch(self._preview, 1) + + column.add_widget(header) + column.add_widget(body) + return column + + # -- Signals ----------------------------------------------------------- + + def _connect_signals(self) -> None: + self._application.task_updated.connect(self._reload_documents) + self._application.state_loaded.connect(self._reload_documents) + + def _apply_initial_state(self) -> None: + self._clear_preview() + + # -- Data refresh ------------------------------------------------------ + + def _reload_documents(self, *_args) -> None: + ordered_documents = self._application.list_documents("acceptance") + self._documents = { + document.document_id: document + for document in ordered_documents + } + self._rebuild_cards(ordered_documents) + + if self._selected_document_id in self._documents: + self._select_document(self._selected_document_id, update_preview=True) + return + + self._selected_document_id = None + self._update_card_selection() + self._clear_preview() + + def _rebuild_cards(self, documents: list[TicketDocumentSnapshot]) -> None: + if self._card_host is None: + return + + for card in list(self._cards.values()): + self._card_host.remove_widget(card) + card.setParent(None) + self._cards.clear() + + if self._empty_list_label is not None: + self._card_host.remove_widget(self._empty_list_label) + self._empty_list_label.setParent(None) + self._empty_list_label = None + + if not documents: + self._empty_list_label = Label( + "Подписанные акты пока не созданы.", + alignment="left", + style="TICKET_REPORT_EMPTY_LABEL", + ) + self._card_host.insert_widget(0, self._empty_list_label) + return + + for index, document in enumerate(documents): + card = _ActCardView(document) + card.card_clicked.connect(self._on_card_clicked) + self._cards[document.document_id] = card + self._card_host.insert_widget(index, card) + + self._update_card_selection() + + # -- Selection --------------------------------------------------------- + + def _select_document(self, document_id: str | None, update_preview: bool) -> None: + normalized = document_id if document_id in self._documents else None + self._selected_document_id = normalized + self._update_card_selection() + + if not update_preview: + return + + document = self._current_document() + if document is None: + self._clear_preview() + return + self._render_document(document) + + def _update_card_selection(self) -> None: + for doc_id, card in self._cards.items(): + card.set_selected(doc_id == self._selected_document_id) + + def _current_document(self) -> TicketDocumentSnapshot | None: + if self._selected_document_id is None: + return None + return self._documents.get(self._selected_document_id) + + def _render_document(self, document: TicketDocumentSnapshot) -> None: + if self._preview is None: + return + self._preview.set_text(document.content or document.summary or document.title) + + def _clear_preview(self) -> None: + if self._preview is not None: + self._preview.set_text("") + + def _on_card_clicked(self, document_id: str) -> None: + self._select_document(document_id, update_preview=True) + + def showEvent(self, event) -> None: + super().showEvent(event) + self._reload_documents() diff --git a/Dispatch_V0.1.1/ui/pages/archive_page.py b/Dispatch_V0.1.1/ui/pages/archive_page.py new file mode 100644 index 0000000..83b2717 --- /dev/null +++ b/Dispatch_V0.1.1/ui/pages/archive_page.py @@ -0,0 +1,237 @@ +# -*- coding: utf-8 -*- +# hub/ticket/ui/pages/archive_page.py + +"""Самостоятельная страница архива Ticket.""" + +from __future__ import annotations + +from PySide6.QtCore import Qt +from PySide6.QtWidgets import QSizePolicy + +from gui.components import Label, TextInput +from gui.containers import HContainer, SContainer, ScrollContainer, VContainer + +from application import TaskApplicationService +from domain import ArchiveRecordSnapshot, TicketDocumentSnapshot +from ui.cards import TaskCardView +from .archive_view_helpers import ( + build_preview_lines, + cycle_token_from_record, + record_to_task_snapshot, +) + + +# ------------------------------------------------------------------------- +# Страница архива +# ------------------------------------------------------------------------- + +class ArchivePage(SContainer): + """Страница архива с карточками в стиле доски задач и панелью просмотра.""" + + def __init__(self, application: TaskApplicationService, parent=None): + super().__init__(width_percent=100, height_percent=100, parent=parent) + self._application = application + self._records: dict[str, ArchiveRecordSnapshot] = {} + self._selected_record_key: str | None = None + self._card_host: VContainer | None = None + self._preview: TextInput | None = None + self._empty_list_label: Label | None = None + self._cards: dict[str, TaskCardView] = {} + self._card_key_by_task_id: dict[int, str] = {} + self._setup_ui() + self._connect_signals() + self._reload_records() + + # -- UI ---------------------------------------------------------------- + + def _setup_ui(self) -> None: + board_row = HContainer( + margin=[0, 0, 0, 0], + height_percent=100, + spacing=16, + style="TICKET_SURFACE_HOST", + parent=self, + ) + + board_row.add_widget(self._build_list_column()) + board_row.add_widget(self._build_preview_column()) + + def _build_list_column(self) -> SContainer: + column = SContainer(spacing=12, parent=None) + + header = HContainer( + height_percent=5.37, + margin=0, + spacing=16, + content_fit=False, + style="TICKET_BOARD_COLUMN_HEADER", + ) + header.add_widget( + Label("Архив задач", margin=[12, 0, 0, 0], style="TICKET_BOARD_COLUMN_TITLE"), + ) + header.add_stretch() + + body = SContainer( + margin=0, + style="TICKET_REPORT_COLUMN_BODY", + ) + + scroll = ScrollContainer( + margin=0, + spacing=0, + orientation="v", + vertical_scroll_bar_policy="always_off", + horizontal_scroll_bar_policy="always_off", + style="SCROLL_CONTAINER", + parent=body, + ) + scroll.scroll_area.verticalScrollBar().setSingleStep(48) + + self._card_host = VContainer( + spacing=12, + content_fit=False, + parent=scroll, + ) + self._card_host.set_size_policy( + QSizePolicy.Policy.Expanding, + QSizePolicy.Policy.Fixed, + ) + + column.add_widget(header) + column.add_widget(body) + return column + + def _build_preview_column(self) -> SContainer: + column = SContainer(spacing=12, width_percent=79.83, parent=None) + + header = HContainer( + height_percent=5.37, + margin=0, + spacing=16, + content_fit=False, + style="TICKET_BOARD_COLUMN_HEADER", + ) + header.add_widget( + Label("Просмотр", margin=[12, 0, 0, 0], style="TICKET_BOARD_COLUMN_TITLE"), + ) + header.add_stretch() + + body = SContainer( + margin=0, + style="TICKET_REPORT_PREVIEW_BODY", + ) + preview_inner = VContainer(margin=0, spacing=0, parent=body) + self._preview = TextInput(style="TICKET_REPORT_PREVIEW_AREA", multiline=True) + self._preview.set_read_only(True) + self._preview._input.setVerticalScrollBarPolicy( + Qt.ScrollBarPolicy.ScrollBarAlwaysOff, + ) + preview_inner.add_widget_with_stretch(self._preview, 1) + + column.add_widget(header) + column.add_widget(body) + return column + + # -- Signals ----------------------------------------------------------- + + def _connect_signals(self) -> None: + self._application.task_updated.connect(self._reload_records) + self._application.task_removed.connect(self._reload_records) + self._application.state_loaded.connect(self._reload_records) + + # -- Data refresh ------------------------------------------------------ + + @staticmethod + def _record_key(record: ArchiveRecordSnapshot) -> str: + token = cycle_token_from_record(record) + return f"{record.task_id}_{token}" if token else str(record.task_id) + + def _reload_records(self, *_args) -> None: + records = self._application.list_archive_records() + self._records = {self._record_key(r): r for r in records} + self._rebuild_cards(records) + + if self._selected_record_key in self._records: + self._select_record(self._selected_record_key, update_preview=True) + return + + self._selected_record_key = None + self._clear_preview() + + def _rebuild_cards(self, records: list[ArchiveRecordSnapshot]) -> None: + if self._card_host is None: + return + + for card in list(self._cards.values()): + self._card_host.remove_widget(card) + card.setParent(None) + self._cards.clear() + self._card_key_by_task_id.clear() + + if self._empty_list_label is not None: + self._card_host.remove_widget(self._empty_list_label) + self._empty_list_label.setParent(None) + self._empty_list_label = None + + if not records: + self._empty_list_label = Label( + "Архивные задачи пока отсутствуют.", + alignment="left", + style="TICKET_REPORT_EMPTY_LABEL", + ) + self._card_host.insert_widget(0, self._empty_list_label) + return + + for index, record in enumerate(records): + key = self._record_key(record) + synthetic_task = record_to_task_snapshot(record) + card = TaskCardView(synthetic_task) + card.card_clicked.connect(self._on_card_clicked) + self._cards[key] = card + self._card_key_by_task_id[record.task_id] = key + self._card_host.insert_widget(index, card) + + # -- Selection --------------------------------------------------------- + + def _select_record(self, key: str | None, update_preview: bool) -> None: + normalized = key if key in self._records else None + self._selected_record_key = normalized + if not update_preview: + return + record = self._records.get(normalized) if normalized is not None else None + if record is None: + self._clear_preview() + return + self._render_record(record) + + def _render_record(self, record: ArchiveRecordSnapshot) -> None: + if self._preview is None: + return + documents = self._load_cycle_documents(record) + lines = build_preview_lines(record, documents) + self._preview.set_text("\n".join(lines)) + + def _load_cycle_documents( + self, record: ArchiveRecordSnapshot, + ) -> list[TicketDocumentSnapshot]: + """Загрузить только документы текущего цикла задачи.""" + cycle_token = cycle_token_from_record(record) + all_docs = self._application.list_documents() + return [ + d for d in all_docs + if d.task_id == record.task_id and cycle_token in d.document_id + ] + + def _clear_preview(self) -> None: + if self._preview is not None: + self._preview.set_text("") + + def _on_card_clicked(self, card_id: object) -> None: + if isinstance(card_id, int): + key = self._card_key_by_task_id.get(card_id) + if key is not None: + self._select_record(key, update_preview=True) + + def showEvent(self, event) -> None: + super().showEvent(event) + self._reload_records() diff --git a/Dispatch_V0.1.1/ui/pages/archive_view_helpers.py b/Dispatch_V0.1.1/ui/pages/archive_view_helpers.py new file mode 100644 index 0000000..c4cb997 --- /dev/null +++ b/Dispatch_V0.1.1/ui/pages/archive_view_helpers.py @@ -0,0 +1,129 @@ +# -*- coding: utf-8 -*- +# hub/ticket/ui/pages/archive_view_helpers.py + +"""Вспомогательные функции построения представления архива.""" + +from __future__ import annotations + +from domain import ArchiveRecordSnapshot, TicketDocumentSnapshot, TicketTaskSnapshot +from domain.ticket_constants import STATE_REFUSED, TICKET_STATE_COLORS, TICKET_STATE_NAMES +from ui.task_view_formatters import format_datetime, split_task_location + + +def record_to_task_snapshot(record: ArchiveRecordSnapshot) -> TicketTaskSnapshot: + """Построить синтетический TicketTaskSnapshot для отрисовки карточки.""" + state_code = record.pre_archive_state_code + return TicketTaskSnapshot( + task_id=record.task_id, + location=record.location, + state_code=state_code, + state_name=TICKET_STATE_NAMES.get(state_code, "Архив"), + action_text=record.action_text, + color_hex=TICKET_STATE_COLORS.get(state_code, record.color_hex), + created_at=record.created_at, + completed_at=record.completed_at, + refused_from_state=record.refused_from_state, + refusal_reason=record.refusal_reason, + assigned_specialist=record.assigned_specialist, + specialist_photo=record.specialist_photo, + diagnostic_report_signed=record.diagnostic_report_signed, + repair_report_signed=record.repair_report_signed, + acceptance_report_signed=record.acceptance_report_signed, + sequence_number=record.sequence_number, + ) + + +def cycle_token_from_record(record: ArchiveRecordSnapshot) -> str: + """Получить cycle_token из created_at архивной записи.""" + if record.created_at is not None: + return record.created_at.strftime("%Y%m%d_%H%M%S") + return "" + + +def build_preview_lines( + record: ArchiveRecordSnapshot, + documents: list[TicketDocumentSnapshot], +) -> list[str]: + """Собрать текст предпросмотра архивной записи.""" + institution, device, room = split_task_location(record.location) + is_refused = record.pre_archive_state_code == STATE_REFUSED + + lines: list[str] = [] + seq = record.sequence_number or record.task_id + lines.append(f"Задача #{seq}") + lines.append(f"Статус: {record.pre_archive_state_name}") + lines.append("") + + lines.append(f"Учреждение: {institution}") + lines.append(f"Оборудование: {device}") + lines.append(f"Кабинет: {room}") + lines.append("") + + lines.append(f"Создана: {format_datetime(record.created_at)}") + if record.completed_at is not None: + label = "Отказано" if is_refused else "Завершена" + lines.append(f"{label}: {format_datetime(record.completed_at)}") + lines.append(f"Архивирована: {format_datetime(record.archived_at)}") + lines.append("") + + lines.append("─── Ход работ ───") + lines.append("") + + specialist = record.assigned_specialist.strip() + lines.append(f"Специалист: {specialist or 'Не назначен'}") + lines.append( + f"Диагностика: {'Подписан' if record.diagnostic_report_signed else 'Не подписан'}" + ) + lines.append( + f"Ремонт: {'Подписан' if record.repair_report_signed else 'Не подписан'}" + ) + lines.append( + f"Приёмка: {'Подписан' if record.acceptance_report_signed else 'Не подписан'}" + ) + lines.append("") + + if is_refused and record.refusal_reason: + lines.append("─── Причина отказа ───") + lines.append("") + lines.append(record.refusal_reason) + lines.append("") + + if documents: + for doc in documents: + _append_document_block(lines, doc) + + return lines + + +_BASE_PAYLOAD_KEYS = frozenset({ + "task_id", "institution", "room", "device", "location", "specialist", +}) + +_PAYLOAD_KEY_LABELS: dict[str, str] = { + "initial_cause": "Первичное заключение", + "actual_cause": "Вторичное заключение", + "work_done": "Выполненные работы", + "used_parts": "Использованные запчасти", + "recommendations": "Рекомендации", + "work_description": "Описание работ", + "executor_signature": "Исполнитель", + "customer_signature": "Заказчик", +} + + +def _append_document_block( + lines: list[str], doc: TicketDocumentSnapshot, +) -> None: + """Добавить блок документа без дублирования данных заголовка записи.""" + doc_date = format_datetime(doc.created_at) + lines.append(f"─── {doc.title} ({doc_date}) ───") + lines.append("") + if doc.payload: + for key, value in doc.payload.items(): + if not value or key in _BASE_PAYLOAD_KEYS: + continue + label = _PAYLOAD_KEY_LABELS.get(key, key) + lines.append(f"{label}: {value}") + elif doc.summary: + lines.append(doc.summary) + lines.append("") diff --git a/Dispatch_V0.1.1/ui/pages/document_browser_page.py b/Dispatch_V0.1.1/ui/pages/document_browser_page.py new file mode 100644 index 0000000..5bc2729 --- /dev/null +++ b/Dispatch_V0.1.1/ui/pages/document_browser_page.py @@ -0,0 +1,153 @@ +# -*- coding: utf-8 -*- +# hub/ticket/ui/pages/document_browser_page.py + +"""Общая страница просмотра документов Ticket.""" + +from __future__ import annotations + +from gui.components import Button, Label, TextInput +from gui.containers import HContainer, SContainer, VContainer + +from application import TaskApplicationService +from domain import TicketDocumentSnapshot +from ui.ticket_selection_list import TicketSelectionEntry, TicketSelectionList +from .report_viewer import ReportViewer + + +class DocumentBrowserPage(SContainer): + """Базовая страница списка документов и предпросмотра.""" + + def __init__( + self, + application: TaskApplicationService, + title: str, + empty_message: str, + document_type: str, + parent=None, + ): + super().__init__(width_percent=100, height_percent=100, parent=parent) + self._application = application + self._title = title + self._empty_message = empty_message + self._document_type = document_type + self._documents: dict[str, TicketDocumentSnapshot] = {} + self._list_widget: TicketSelectionList | None = None + self._preview: TextInput | None = None + self._open_button: Button | None = None + self._setup_ui() + self._connect_signals() + self._reload_documents() + + def _setup_ui(self) -> None: + # Root-контейнер страницы документов: заголовок плюс двухпанельная область. + main_container = VContainer(margin=12, spacing=10, parent=self) + title_label = Label(self._title, alignment="left", style="TICKET_LIST_HEADER") + # Content-row документов: раскладывает список документов и их предпросмотр. + content_row = HContainer(spacing=10, parent=main_container) + + # Левая панель списка документов + list_panel = SContainer(style="TICKET_LIST_CONTAINER") + # List-layout: внутренний контейнер заголовка и списка документов. + list_layout = VContainer(margin=12, spacing=8, parent=list_panel) + list_title_label = Label("Список документов", + margin=[0, 0, 0, 8], + alignment="left", + style="TICKET_LIST_TITLE") + + self._list_widget = TicketSelectionList() + list_layout.add_widget(list_title_label) + list_layout.add_widget(self._list_widget) + + # Правая панель предпросмотра + preview_panel = SContainer(style="TICKET_LIST_CONTAINER") + # Preview-layout: внутренний контейнер заголовка, preview-поля и кнопки открытия. + preview_layout = VContainer(margin=12, spacing=8, parent=preview_panel) + preview_title_label = Label("Предпросмотр", alignment="left", style="TICKET_LIST_TITLE") + self._preview = TextInput(style="TICKET_PREVIEW_AREA", multiline=True) + self._preview.set_read_only(True) + self._open_button = Button("Открыть документ", style="FILTER_BUTTON", content_fit=True) + self._open_button.set_enabled(False) + preview_layout.add_widget(preview_title_label) + preview_layout.add_widget(self._preview) + preview_layout.add_widget(self._open_button) + + content_row.add_widget_with_stretch(list_panel, 4) + content_row.add_widget_with_stretch(preview_panel, 7) + main_container.add_widget(title_label) + main_container.add_widget(content_row) + + def _connect_signals(self) -> None: + # Application-состояние + self._application.task_updated.connect(self._reload_documents) + self._application.state_loaded.connect(self._reload_documents) + + # UI-события страницы + if self._list_widget is not None: + self._list_widget.selection_changed.connect(self._update_preview) + self._list_widget.item_activated.connect(self._open_current_document) + if self._open_button is not None: + self._open_button.clicked.connect(self._open_current_document) + + def _reload_documents(self, *_args) -> None: + if self._list_widget is None or self._preview is None: + return + current_document_id = self._current_document_id() + self._documents = { + document.document_id: document + for document in self._application.list_documents(self._document_type) + } + entries = [ + TicketSelectionEntry( + entry_id=document.document_id, + title=document.title, + subtitle=document.created_at.strftime("%d.%m.%Y %H:%M"), + ) + for document in self._documents.values() + ] + self._list_widget.set_entries(entries) + if not self._documents: + self._preview.set_text(self._empty_message) + if self._open_button is not None: + self._open_button.set_enabled(False) + return + self._restore_selection(current_document_id) + self._update_preview() + + def _restore_selection(self, document_id: str | None) -> None: + if self._list_widget is None: + return + self._list_widget.set_current_entry(document_id) + + def _update_preview(self, *_args) -> None: + if self._preview is None: + return + document = self._current_document() + if document is None: + self._preview.set_text(self._empty_message) + if self._open_button is not None: + self._open_button.set_enabled(False) + return + self._preview.set_text(document.content or document.summary or document.title) + if self._open_button is not None: + self._open_button.set_enabled(True) + + def _current_document(self) -> TicketDocumentSnapshot | None: + document_id = self._current_document_id() + if document_id is None: + return None + return self._documents.get(document_id) + + def _current_document_id(self) -> str | None: + if self._list_widget is None: + return None + entry_id = self._list_widget.current_entry_id() + return str(entry_id) if entry_id is not None else None + + def _open_current_document(self, *_args) -> None: + document = self._current_document() + if document is not None: + ReportViewer(document, parent=self).exec() + + def showEvent(self, event) -> None: + super().showEvent(event) + self._reload_documents() diff --git a/Dispatch_V0.1.1/ui/pages/report_viewer.py b/Dispatch_V0.1.1/ui/pages/report_viewer.py new file mode 100644 index 0000000..8e4306b --- /dev/null +++ b/Dispatch_V0.1.1/ui/pages/report_viewer.py @@ -0,0 +1,69 @@ +# -*- coding: utf-8 -*- +# hub/ticket/ui/pages/report_viewer.py + +"""Просмотрщик документа Ticket.""" + +from __future__ import annotations + +from gui.components import Button, Dialog, Label, TextInput +from gui.containers import VContainer + +from domain import TicketDocumentSnapshot + + +class ReportViewer(Dialog): + """Простой viewer сохранённого документа Ticket.""" + + def __init__( + self, + document: TicketDocumentSnapshot, + parent=None, + ): + self._document = document + self._close_button: Button | None = None + super().__init__( + title=document.title, + width=720, + height=760, + modal=True, + parent=parent, + ) + self._setup_ui() + self._connect_signals() + + def _setup_ui(self) -> None: + # Root-контейнер viewer-окна: заголовок, метаданные, текст документа и кнопка закрытия. + main_container = VContainer(margin=20, + spacing=12) + self.add_widget(main_container) + + title_label = Label( + self._document.title, + alignment="left", + style="TICKET_LIST_HEADER", + ) + metadata_label = Label( + ( + f"Задача #{self._document.payload.get('task_id', self._document.task_id)}\n" + f"{self._document.created_at.strftime('%d.%m.%Y %H:%M')}\n" + f"{self._document.location or 'Локация не указана'}" + ), + alignment="left", + style="TICKET_LIST_SUBTITLE", + ) + viewer = TextInput( + text=self._document.content or self._document.summary, + style="TICKET_PREVIEW_AREA", + multiline=True, + ) + viewer.set_read_only(True) + self._close_button = Button("Закрыть", style="FILTER_BUTTON", content_fit=True) + + main_container.add_widget(title_label) + main_container.add_widget(metadata_label) + main_container.add_widget(viewer) + main_container.add_widget(self._close_button) + + def _connect_signals(self) -> None: + if self._close_button is not None: + self._close_button.clicked.connect(self.accept) diff --git a/Dispatch_V0.1.1/ui/pages/reports_page.py b/Dispatch_V0.1.1/ui/pages/reports_page.py new file mode 100644 index 0000000..3a9852a --- /dev/null +++ b/Dispatch_V0.1.1/ui/pages/reports_page.py @@ -0,0 +1,340 @@ +# -*- coding: utf-8 -*- +# hub/ticket/ui/pages/reports_page.py + +"""Самостоятельная страница отчётов Ticket.""" + +from __future__ import annotations + +from PySide6.QtCore import Qt, Signal +from PySide6.QtWidgets import QSizePolicy + +from gui.components import Label, TextInput +from gui.containers import HContainer, SContainer, ScrollContainer, VContainer + +from application import TaskApplicationService +from domain import TicketDocumentSnapshot + + +# ------------------------------------------------------------------------- +# Карточка отчёта (по образцу TaskCardView) +# ------------------------------------------------------------------------- + +class _ReportCardView(SContainer): + """Карточка отчёта: заголовок, дата+учреждение, аппарат+кабинет.""" + + card_clicked = Signal(str) + + def __init__(self, document: TicketDocumentSnapshot, parent=None): + super().__init__( + width_percent=100, + margin=0, + content_fit=True, + parent=parent, + ) + self._document_id = document.document_id + self._title_label: Label | None = None + self._subtitle_label: Label | None = None + self._meta_label: Label | None = None + self._selected = False + self._height_sync_in_progress = False + self.setObjectName("ticket_report_card") + self.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True) + self.setCursor(Qt.CursorShape.PointingHandCursor) + self.set_size_policy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) + self._setup_ui() + self._fill(document) + + @property + def document_id(self) -> str: + return self._document_id + + def set_selected(self, selected: bool) -> None: + self._selected = selected + self._apply_root_style() + + def _setup_ui(self) -> None: + content = SContainer( + width_percent=100, + height_percent=100, + margin=8, + spacing=0, + style="TICKET_REPORT_CARD_CONTENT", + parent=self, + ) + text_column = VContainer( + width_percent=100, + spacing=2, + parent=content, + ) + + self._title_label = Label("", alignment="left", parent=text_column) + self._subtitle_label = Label("", alignment="left", parent=text_column) + self._meta_label = Label("", alignment="left", parent=text_column) + + def _fill(self, document: TicketDocumentSnapshot) -> None: + title = document.title or "Отчёт" + created_at = document.created_at.strftime("%d.%m.%Y %H:%M") + facility = document.payload.get("facility") or document.location or "" + # Убрать название аппарата в скобках (дублирует 3-ю строку) + if "(" in facility: + facility = facility[:facility.index("(")].strip() + device = document.payload.get("device") or "" + cabinet = document.payload.get("cabinet") or "" + + subtitle_parts = [p for p in (created_at, facility) if p] + meta_parts = [p for p in (device, cabinet) if p] + + if self._title_label is not None: + self._title_label.set_text(title) + if self._subtitle_label is not None: + self._subtitle_label.set_text(" , ".join(subtitle_parts)) + if self._meta_label is not None: + self._meta_label.set_text(" - ".join(meta_parts)) + + self._apply_root_style() + self._apply_text_styles() + + def _apply_root_style(self) -> None: + if self._selected: + self.style("TICKET_REPORT_CARD_ROOT_SELECTED") + else: + self.style("TICKET_REPORT_CARD_ROOT") + + def _apply_text_styles(self) -> None: + if self._title_label is not None: + self._title_label.style("TICKET_REPORT_CARD_TITLE") + if self._subtitle_label is not None: + self._subtitle_label.style("TICKET_REPORT_CARD_SUBTITLE") + if self._meta_label is not None: + self._meta_label.style("TICKET_REPORT_CARD_META") + + def mousePressEvent(self, event) -> None: + if event.button() == Qt.MouseButton.LeftButton: + self.card_clicked.emit(self._document_id) + super().mousePressEvent(event) + + def resizeEvent(self, event) -> None: + super().resizeEvent(event) + self._sync_card_height() + + def _sync_card_height(self) -> None: + if self._height_sync_in_progress or self.width() <= 0: + return + target_height = max(1, round(self.width() / 2.745)) + if self.height() == target_height: + return + self._height_sync_in_progress = True + self.setFixedHeight(target_height) + self._height_sync_in_progress = False + + +# ------------------------------------------------------------------------- +# Страница отчётов +# ------------------------------------------------------------------------- + +class ReportsPage(SContainer): + """Страница отчётов, полностью локализованная внутри собственного класса.""" + + def __init__(self, application: TaskApplicationService, parent=None): + super().__init__(width_percent=100, height_percent=100, parent=parent) + self._application = application + self._documents: dict[str, TicketDocumentSnapshot] = {} + self._selected_document_id: str | None = None + self._card_host: VContainer | None = None + self._preview: TextInput | None = None + self._empty_list_label: Label | None = None + self._cards: dict[str, _ReportCardView] = {} + self._setup_ui() + self._connect_signals() + self._apply_initial_state() + self._reload_documents() + + # -- UI ---------------------------------------------------------------- + + def _setup_ui(self) -> None: + board_row = HContainer( + margin=[0, 0, 0, 0], + height_percent=100, + spacing=16, + style="TICKET_SURFACE_HOST", + parent=self, + ) + + board_row.add_widget(self._build_list_column()) + board_row.add_widget(self._build_preview_column()) + + def _build_list_column(self) -> SContainer: + column = SContainer(spacing=12, parent=None) + + # Header (как в _TicketBoardColumn, но без счётчика) + header = HContainer( + height_percent=5.37, + margin=0, + spacing=16, + content_fit=False, + style="TICKET_BOARD_COLUMN_HEADER", + ) + header.add_widget( + Label("Список документов", margin=[12, 0, 0, 0], style="TICKET_BOARD_COLUMN_TITLE"), + ) + header.add_stretch() + + # Body + body = SContainer( + margin=0, + style="TICKET_REPORT_COLUMN_BODY", + ) + + scroll = ScrollContainer( + margin=0, + spacing=0, + orientation="v", + vertical_scroll_bar_policy="always_off", + horizontal_scroll_bar_policy="always_off", + style="SCROLL_CONTAINER", + parent=body, + ) + scroll.scroll_area.verticalScrollBar().setSingleStep(48) + + self._card_host = VContainer( + spacing=12, + content_fit=False, + parent=scroll, + ) + self._card_host.set_size_policy( + QSizePolicy.Policy.Expanding, + QSizePolicy.Policy.Fixed, + ) + + column.add_widget(header) + column.add_widget(body) + return column + + def _build_preview_column(self) -> SContainer: + column = SContainer(spacing=12, width_percent=79.83, parent=None) + + # Header + header = HContainer( + height_percent=5.37, + margin=0, + spacing=16, + content_fit=False, + style="TICKET_BOARD_COLUMN_HEADER", + ) + header.add_widget( + Label("Просмотр", margin=[12, 0, 0, 0], style="TICKET_BOARD_COLUMN_TITLE"), + ) + header.add_stretch() + + # Body — preview + body = SContainer( + margin=0, + style="TICKET_REPORT_PREVIEW_BODY", + ) + preview_inner = VContainer(margin=0, spacing=0, parent=body) + self._preview = TextInput(style="TICKET_REPORT_PREVIEW_AREA", multiline=True) + self._preview.set_read_only(True) + preview_inner.add_widget_with_stretch(self._preview, 1) + + column.add_widget(header) + column.add_widget(body) + return column + + # -- Signals ----------------------------------------------------------- + + def _connect_signals(self) -> None: + self._application.task_updated.connect(self._reload_documents) + self._application.state_loaded.connect(self._reload_documents) + + def _apply_initial_state(self) -> None: + self._clear_preview() + + # -- Data refresh ------------------------------------------------------ + + def _reload_documents(self, *_args) -> None: + ordered_documents = self._application.list_documents("report") + self._documents = { + document.document_id: document + for document in ordered_documents + } + self._rebuild_cards(ordered_documents) + + if self._selected_document_id in self._documents: + self._select_document(self._selected_document_id, update_preview=True) + return + + self._selected_document_id = None + self._update_card_selection() + self._clear_preview() + + def _rebuild_cards(self, documents: list[TicketDocumentSnapshot]) -> None: + if self._card_host is None: + return + + for card in list(self._cards.values()): + self._card_host.remove_widget(card) + card.setParent(None) + self._cards.clear() + + if self._empty_list_label is not None: + self._card_host.remove_widget(self._empty_list_label) + self._empty_list_label.setParent(None) + self._empty_list_label = None + + if not documents: + self._empty_list_label = Label( + "Подписанные отчёты пока не созданы.", + alignment="left", + style="TICKET_REPORT_EMPTY_LABEL", + ) + self._card_host.insert_widget(0, self._empty_list_label) + return + + for index, document in enumerate(documents): + card = _ReportCardView(document) + card.card_clicked.connect(self._on_card_clicked) + self._cards[document.document_id] = card + self._card_host.insert_widget(index, card) + + self._update_card_selection() + + # -- Selection --------------------------------------------------------- + + def _select_document(self, document_id: str | None, update_preview: bool) -> None: + normalized = document_id if document_id in self._documents else None + self._selected_document_id = normalized + self._update_card_selection() + + if not update_preview: + return + + document = self._current_document() + if document is None: + self._clear_preview() + return + self._render_document(document) + + def _update_card_selection(self) -> None: + for doc_id, card in self._cards.items(): + card.set_selected(doc_id == self._selected_document_id) + + def _current_document(self) -> TicketDocumentSnapshot | None: + if self._selected_document_id is None: + return None + return self._documents.get(self._selected_document_id) + + def _render_document(self, document: TicketDocumentSnapshot) -> None: + if self._preview is None: + return + self._preview.set_text(document.content or document.summary or document.title) + + def _clear_preview(self) -> None: + if self._preview is not None: + self._preview.set_text("") + + def _on_card_clicked(self, document_id: str) -> None: + self._select_document(document_id, update_preview=True) + + def showEvent(self, event) -> None: + super().showEvent(event) + self._reload_documents() diff --git a/Dispatch_V0.1.1/ui/task_view_formatters.py b/Dispatch_V0.1.1/ui/task_view_formatters.py new file mode 100644 index 0000000..52cc9b9 --- /dev/null +++ b/Dispatch_V0.1.1/ui/task_view_formatters.py @@ -0,0 +1,289 @@ +# -*- coding: utf-8 -*- +# hub/ticket/ui/task_view_formatters.py + +"""Форматирование отображения задач Ticket для карточек и деталей.""" + +from __future__ import annotations + +from datetime import datetime +from pathlib import Path + +from domain import TicketTaskSnapshot, parse_location_parts +from domain.ticket_constants import ( + STATE_COMPLETED, + STATE_CONFIRMATION, + STATE_IN_PROGRESS, + STATE_REFUSED, + STATE_TODO, +) + +_REPO_ROOT = Path(__file__).resolve().parents[3] +_TICKET_ICONS_DIR = _REPO_ROOT / "gui" / "icons" +_TICKET_SPECIALISTS_DIR = _TICKET_ICONS_DIR / "specialists" +_DEFAULT_SPECIALIST_PHOTO = "specialist.png" +_DEFAULT_STAGE_ICON = "close.png" +_STAGE_ICON_FILENAMES = { + "specialist": "face-man-shimmer-outline.png", + "diagnostic": "wrench-outline.png", + "repair": "wrench-outline.png", + "acceptance": "pencil-outline.png", +} +_SPECIALIST_CARD_INFO = { + "Иванов Алексей Сергеевич": ("Иванов А.С.", "Инженер-электроник", "specialist1.png"), + "Петрова Мария Владимировна": ("Петрова М.В.", "Техник-рентгенолог", "specialist2.png"), + "Сидоров Дмитрий Иванович": ("Сидоров Д.И.", "Инженер КИПиА", "specialist3.png"), + "Козлова Анна Петровна": ("Козлова А.П.", "Специалист по ТО", "specialist4.png"), + "Васильев Сергей Николаевич": ("Васильев С.Н.", "Инженер-программист", "specialist5.png"), + "Николаева Ольга Дмитриевна": ("Николаева О.Д.", "Техник-электрик", "specialist6.png"), + "Фёдоров Андрей Викторович": ("Фёдоров А.В.", "Инженер-механик", "specialist7.png"), + "Орлова Екатерина Александровна": ("Орлова Е.А.", "Специалист по диагностике", "specialist8.png"), +} + + +def build_task_title(task: TicketTaskSnapshot) -> str: + if task.sequence_number: + return f"Задача #{task.sequence_number}" + return f"Задача #{task.task_id}" + + +def build_task_card_subtitle(task: TicketTaskSnapshot) -> str: + specialist_text = task.assigned_specialist.strip() or "Специалист не назначен" + return "\n".join( + ( + task.location or "Локация не указана", + specialist_text, + build_documents_summary(task), + ) + ) + + +def build_documents_summary(task: TicketTaskSnapshot) -> str: + return ( + f"Д: {_bool_text(task.diagnostic_report_signed)} | " + f"Р: {_bool_text(task.repair_report_signed)} | " + f"А: {_bool_text(task.acceptance_report_signed)}" + ) + + +def split_task_location(location: str) -> tuple[str, str, str]: + normalized_location = (location or "").strip() + if not normalized_location: + return ("Локация не указана", "Аппарат не указан", "Кабинет не указан") + institution, room, device = parse_location_parts(normalized_location) + institution = institution or "Локация не указана" + device = device or "Аппарат не указан" + room = room or "Кабинет не указан" + + return ( + shorten_card_text(institution, limit=36), + shorten_card_text(device, limit=34), + shorten_card_text(room, limit=24), + ) + + +def build_task_footer_text(task: TicketTaskSnapshot) -> str: + return build_specialist_card_text(task.assigned_specialist) + + +def build_task_fault_title(task: TicketTaskSnapshot) -> str: + """Извлечь краткий заголовок неисправности из строки локации задачи. + + Формат локации, формируемый формой создания заявки: + «Учреждение (Аппарат — Краткий заголовок, каб. №)». + Парсер `parse_location_parts` возвращает блок «Аппарат — Краткий + заголовок» в позиции `device`. Заголовок неисправности — это + содержимое после разделителя « — ». При его отсутствии (например, + для тестовых задач без формы) возвращается само устройство, а в + предельном случае — оригинальная строка локации. + """ + institution, _device, _room = split_task_location(task.location) + _, _, raw_device = parse_location_parts(task.location or "") + device_text = raw_device.strip() or institution + separator = " — " + if separator in device_text: + title = device_text.split(separator, 1)[1].strip() + if title: + return shorten_card_text(title, limit=64) + if device_text: + return shorten_card_text(device_text, limit=64) + return shorten_card_text(task.location or "Заголовок не указан", limit=64) + + +def build_task_stage_flags(task: TicketTaskSnapshot) -> tuple[tuple[str, bool], ...]: + return ( + ("specialist", bool(task.assigned_specialist.strip())), + ("diagnostic", bool(task.diagnostic_report_signed)), + ("repair", bool(task.repair_report_signed)), + ("acceptance", bool(task.acceptance_report_signed)), + ) + + +def build_specialist_initials(specialist_name: str) -> str: + normalized_name = (specialist_name or "").strip() + if not normalized_name: + return "?" + parts = [part for part in normalized_name.split() if part] + if len(parts) >= 2: + return f"{parts[0][0]}{parts[1][0]}".upper() + if len(parts[0]) >= 2: + return parts[0][:2].upper() + return parts[0][:1].upper() + + +def build_specialist_card_text(specialist_name: str) -> str: + specialist_info = build_specialist_card_info(specialist_name) + short_name = specialist_info["short_name"] + position = specialist_info["position"] + if not short_name: + return "Специалист не назначен" + if not position: + return shorten_card_text(short_name, limit=40) + return shorten_card_text(f"{short_name} • {position}", limit=42) + + +def build_specialist_card_info(specialist_name: str) -> dict[str, str]: + normalized_name = (specialist_name or "").strip() + if not normalized_name: + return {"short_name": "", "position": "", "photo": ""} + short_name, position, photo_filename = _SPECIALIST_CARD_INFO.get( + normalized_name, + _fallback_specialist_card_info(normalized_name), + ) + return { + "short_name": short_name, + "position": position, + "photo": photo_filename, + } + + +def build_specialist_photo_path(specialist_name: str, photo_filename: str) -> str: + normalized_filename = (photo_filename or "").strip() + if normalized_filename: + return _resolve_specialist_photo_path(normalized_filename) + specialist_info = build_specialist_card_info(specialist_name) + photo_from_mapping = specialist_info.get("photo", "").strip() + if not photo_from_mapping: + return "" + return _resolve_specialist_photo_path(photo_from_mapping) + + +def build_stage_icon_path(stage_key: str, is_active: bool) -> str: + filename = _STAGE_ICON_FILENAMES.get(stage_key, _DEFAULT_STAGE_ICON) if is_active else _DEFAULT_STAGE_ICON + icon_path = _TICKET_ICONS_DIR / filename + if not icon_path.exists(): + return "" + return str(icon_path) + + +def shorten_card_text(text: str, limit: int) -> str: + normalized_text = " ".join((text or "").split()) + if len(normalized_text) <= limit: + return normalized_text + if limit <= 3: + return normalized_text[:limit] + return f"{normalized_text[:limit - 3].rstrip()}..." + + +def build_stage_rows(task: TicketTaskSnapshot) -> tuple[tuple[str, str], ...]: + specialist_status = ( + f"Назначен: {task.assigned_specialist}" + if task.assigned_specialist.strip() + else "Не назначен" + ) + diagnostic_status = ( + "Диагностический отчёт подписан" + if task.diagnostic_report_signed + else "Диагностический отчёт ожидается" + ) + repair_status = ( + "Ремонтный отчёт подписан" + if task.repair_report_signed + else "Ремонтный отчёт ожидается" + ) + acceptance_status = ( + "Акт приёмки подписан" + if task.acceptance_report_signed + else "Акт приёмки ожидается" + ) + return ( + ("Специалист", specialist_status), + ("Диагностика", diagnostic_status), + ("Ремонт", repair_status), + ("Приёмка", acceptance_status), + ) + + +def build_action_hint(task: TicketTaskSnapshot) -> str: + if task.state_code == STATE_TODO: + return "Назначьте специалиста и переведите задачу аппаратной кнопкой в работу." + if task.state_code == STATE_IN_PROGRESS: + if not task.assigned_specialist.strip(): + return "Сначала назначьте специалиста, затем подпишите диагностический и ремонтный отчёты." + if not task.diagnostic_report_signed or not task.repair_report_signed: + return "Для перехода к подтверждению подпишите диагностический и ремонтный отчёты." + return "Отчёты готовы. Следующий аппаратный переход переведёт задачу к подтверждению." + if task.state_code == STATE_CONFIRMATION: + if not task.acceptance_report_signed: + return "Подпишите акт приёмки, чтобы задача могла перейти в выполненные." + return "Акт готов. Следующий аппаратный переход завершит задачу." + if task.state_code == STATE_COMPLETED: + return "Задача завершена. Её можно переместить в архив." + if task.state_code == STATE_REFUSED: + return "Задача завершилась отказом. Её можно переместить в архив." + return "Архивная запись доступна только для просмотра." + + +def format_datetime(value: datetime | None) -> str: + if value is None: + return "—" + return value.strftime("%d.%m.%Y %H:%M") + + +def can_assign_specialist(task: TicketTaskSnapshot) -> bool: + return task.state_code in {STATE_TODO, STATE_IN_PROGRESS, STATE_CONFIRMATION} + + +def can_sign_diagnostic(task: TicketTaskSnapshot) -> bool: + return ( + task.state_code == STATE_IN_PROGRESS + and bool(task.assigned_specialist.strip()) + and not task.diagnostic_report_signed + ) + + +def can_sign_repair(task: TicketTaskSnapshot) -> bool: + return ( + task.state_code == STATE_IN_PROGRESS + and bool(task.assigned_specialist.strip()) + and not task.repair_report_signed + ) + + +def can_sign_acceptance(task: TicketTaskSnapshot) -> bool: + return task.state_code == STATE_CONFIRMATION and not task.acceptance_report_signed + + +def can_archive(task: TicketTaskSnapshot) -> bool: + return task.state_code in {STATE_COMPLETED, STATE_REFUSED} + + +def _bool_text(value: bool) -> str: + return "Да" if value else "Нет" + + +def _fallback_specialist_card_info(specialist_name: str) -> tuple[str, str, str]: + parts = [part for part in specialist_name.split() if part] + if len(parts) >= 3: + initials = f"{parts[1][0]}.{parts[2][0]}." + return (f"{parts[0]} {initials}", "Специалист", "") + return (specialist_name, "Специалист", "") + + +def _resolve_specialist_photo_path(photo_filename: str) -> str: + photo_path = _TICKET_SPECIALISTS_DIR / photo_filename + if photo_path.exists(): + return str(photo_path) + fallback_path = _TICKET_SPECIALISTS_DIR / _DEFAULT_SPECIALIST_PHOTO + if fallback_path.exists(): + return str(fallback_path) + return "" diff --git a/Dispatch_V0.1.1/ui/ticket_board_page.py b/Dispatch_V0.1.1/ui/ticket_board_page.py new file mode 100644 index 0000000..1c08d0c --- /dev/null +++ b/Dispatch_V0.1.1/ui/ticket_board_page.py @@ -0,0 +1,372 @@ +# -*- coding: utf-8 -*- +# hub/ticket/ui/ticket_board_page.py + +"""Ticket board page.""" + +from __future__ import annotations + +from PySide6.QtWidgets import QSizePolicy + +from gui.components import Label +from gui.containers import HContainer, ScrollContainer, SContainer, VContainer + +from application import TaskApplicationService +from domain import TicketTaskSnapshot +from domain.ticket_constants import ( + STATE_COMPLETED, + STATE_CONFIRMATION, + STATE_IN_PROGRESS, + STATE_REFUSED, + STATE_TODO, +) +from .cards import TaskCard +from .details import TaskDetailsDialog + + +class _TicketBoardColumn(SContainer): + """Single Ticket board column.""" + + def __init__(self, parent=None): + super().__init__(spacing=12, parent=parent) + self._cards: dict[object, TaskCard] = {} + self._badge_label: Label | None = None + self._card_host: VContainer | None = None + self._setup_ui() + + def _setup_ui(self) -> None: + # Верхний header-контейнер колонки: объединяет счётчик задач и заголовок этапа. + header = HContainer( + height_percent=5.37, + margin=0, + spacing=16, + content_fit=False, + style="TICKET_BOARD_COLUMN_HEADER", + parent=self, + ) + + # Badge-shell слева в header: резервирует место под цветной счётчик статуса. + header.add_widget(self._build_badge(header)) + # Центральный текстовый блок header: показывает название этапа колонки. + header.add_widget(self._build_title_label()) + header.add_stretch() + + # Body-shell колонки: визуальная подложка под список карточек текущего этапа. + body = SContainer( + margin=0, + style="TICKET_BOARD_COLUMN_BODY", + parent=self, + ) + + # Scroll-host внутри body: даёт колонке собственную область прокрутки карточек. + scroll = ScrollContainer( + margin=0, + spacing=0, + orientation="v", + vertical_scroll_bar_policy="always_off", + horizontal_scroll_bar_policy="always_off", + style="SCROLL_CONTAINER", + parent=body, + ) + scroll.scroll_area.verticalScrollBar().setSingleStep(48) + + # Card-host: вертикальный стек карточек, который динамически растёт по содержимому. + self._card_host = VContainer( + spacing=12, + content_fit=False, + parent=scroll, + ) + self._card_host.set_size_policy( + QSizePolicy.Policy.Expanding, + QSizePolicy.Policy.Fixed, + ) + # Подписка на фазу 2 каскада percent-sized для детей _card_host. + # Срабатывает после того, как все карточки получили parent_resized, + # отработали _on_parent_rebuild_finished -> _sync_card_height -> + # setFixedHeight(target). К этому моменту minimumHeight каждой + # карточки уже отражает её итоговую высоту, и сумма stack_height + # вычисляется корректно за один проход без промежуточных «дёрганий». + self._card_host.on_children_rebuild_finished(self._sync_card_host_height) + self._sync_card_host_height() + + def _build_badge(self, parent) -> SContainer: + raise NotImplementedError + + def _build_title_label(self) -> Label: + raise NotImplementedError + + def add_card(self, card: TaskCard) -> None: + if self._card_host is None: + return + self.remove_card(card.card_id) + # Подписка на per-card сигнал нужна для сценария add без resize + # колонки: _card_host не получает Resize event, его phase-2 emitter + # молчит, поэтому пересчёт стека инициирует сама карточка после + # своего первого _sync_card_height -> setFixedHeight(). + card.card_height_changed.connect(self._sync_card_host_height) + self._cards[card.card_id] = card + self._card_host.insert_widget(len(self._cards) - 1, card) + # Синхронный _sync_card_host_height НЕ вызываем: высота карточки + # ещё нулевая (фаза 2 каскада придёт через QTimer.singleShot(0)). + # Пересчёт стека выполнится по card_height_changed (add без resize) + # либо по _card_host.parent_rebuild_finished (add с resize). + self._update_counter() + + def remove_card(self, card_id: object) -> TaskCard | None: + card = self._cards.pop(card_id, None) + if card is None or self._card_host is None: + return None + self._card_host.remove_widget(card) + card.setParent(None) + # Удалённая карточка фазу 2 уже не пришлёт; синхронный пересчёт + # обязателен, иначе stack_height застрянет на устаревшей сумме. + self._sync_card_host_height() + self._update_counter() + return card + + def clear_cards(self) -> None: + for card in list(self._cards.values()): + if self._card_host is not None: + self._card_host.remove_widget(card) + card.setParent(None) + self._cards.clear() + # Аналогично remove_card: после очистки фазы 2 от детей не будет. + self._sync_card_host_height() + self._update_counter() + + def _update_counter(self) -> None: + if self._badge_label is not None: + self._badge_label.set_text(str(len(self._cards))) + + def _sync_card_host_height(self) -> None: + if self._card_host is None: + return + # Используем card.minimumHeight(): после _sync_card_height() карточка + # вызывает setFixedHeight(target), который атомарно выставляет + # min == max == target. В отличие от card.height(), это значение + # доступно сразу, до прогона layout-цикла, что гарантирует + # корректный stack_height в момент эмиссии card_height_changed. + stack_height = sum( + max(card.minimumHeight(), card.height()) + for card in self._cards.values() + ) + card_count = len(self._cards) + if card_count > 1: + stack_height += 12 * (card_count - 1) + self._card_host.set_min_height(stack_height) + self._card_host.set_max_height(stack_height) + + +class _TodoTicketBoardColumn(_TicketBoardColumn): + """Ticket board column for TODO state.""" + + def _build_badge(self, parent) -> SContainer: + # Badge-контейнер колонки TODO: цветной фон и центрирование значения счётчика. + badge_container = SContainer( + width_percent=12, + height_percent=100, + content_fit=False, + style="TICKET_BOARD_COUNTER_SHELL_TODO", + parent=parent, + ) + self._badge_label = Label( + "0", + style="TICKET_BOARD_COUNTER_TEXT_WHITE", + parent=badge_container, + ) + return badge_container + + def _build_title_label(self) -> Label: + return Label("Новая заявка", style="TICKET_BOARD_COLUMN_TITLE") + + +class _InProgressTicketBoardColumn(_TicketBoardColumn): + """Ticket board column for IN_PROGRESS state.""" + + def _build_badge(self, parent) -> SContainer: + # Badge-контейнер колонки IN_PROGRESS: цветовой маркер и число задач в работе. + badge_container = SContainer( + width_percent=12, + height_percent=100, + content_fit=False, + style="TICKET_BOARD_COUNTER_SHELL_IN_PROGRESS", + parent=parent, + ) + self._badge_label = Label( + "0", + style="TICKET_BOARD_COUNTER_TEXT_WHITE", + parent=badge_container, + ) + return badge_container + + def _build_title_label(self) -> Label: + return Label("Заявка принята к работе", style="TICKET_BOARD_COLUMN_TITLE") + + +class _ConfirmationTicketBoardColumn(_TicketBoardColumn): + """Ticket board column for CONFIRMATION state.""" + + def _build_badge(self, parent) -> SContainer: + # Badge-контейнер колонки CONFIRMATION: показывает объём задач на подтверждении. + badge_container = SContainer( + width_percent=12, + height_percent=100, + content_fit=False, + style="TICKET_BOARD_COUNTER_SHELL_CONFIRMATION", + parent=parent, + ) + self._badge_label = Label( + "0", + style="TICKET_BOARD_COUNTER_TEXT_WHITE", + parent=badge_container, + ) + return badge_container + + def _build_title_label(self) -> Label: + return Label("Заявка на подтверждении", style="TICKET_BOARD_COLUMN_TITLE") + + +class _CompletedTicketBoardColumn(_TicketBoardColumn): + """Ticket board column for COMPLETED state.""" + + def _build_badge(self, parent) -> SContainer: + # Badge-контейнер колонки COMPLETED: визуально отделяет завершённые задачи. + badge_container = SContainer( + width_percent=12, + height_percent=100, + content_fit=False, + style="TICKET_BOARD_COUNTER_SHELL_COMPLETED", + parent=parent, + ) + self._badge_label = Label( + "0", + style="TICKET_BOARD_COUNTER_TEXT_WHITE", + parent=badge_container, + ) + return badge_container + + def _build_title_label(self) -> Label: + return Label("Заявка закрыта", style="TICKET_BOARD_COLUMN_TITLE") + + +class _RefusedTicketBoardColumn(_TicketBoardColumn): + """Ticket board column for REFUSED state.""" + + def _build_badge(self, parent) -> SContainer: + # Badge-контейнер колонки REFUSED: выделяет счётчик задач со статусом отказа. + badge_container = SContainer( + width_percent=12, + height_percent=100, + content_fit=False, + style="TICKET_BOARD_COUNTER_SHELL_REFUSED", + parent=parent, + ) + self._badge_label = Label( + "0", + style="TICKET_BOARD_COUNTER_TEXT_MUTED", + parent=badge_container, + ) + return badge_container + + def _build_title_label(self) -> Label: + return Label("Отменённая заявка", style="TICKET_BOARD_COLUMN_TITLE") + + +class TicketBoardPage(SContainer): + """Ticket board connected to application signals.""" + + def __init__(self, application: TaskApplicationService, parent=None): + super().__init__( + width_percent=100, + height_percent=100, + parent=parent, + style="TICKET_SHELL_ROOT", + ) + self._application = application + self._columns: dict[int, _TicketBoardColumn] = {} + self._task_columns: dict[int, int] = {} + self._setup_ui() + self._connect_signals() + self._reload_board() + + def _setup_ui(self) -> None: + # Главный row-контейнер доски: раскладывает все статусные колонки по горизонтали. + board_row = HContainer( + margin=[0, 0, 0, 0], + height_percent=100, + spacing=16, + style="TICKET_SURFACE_HOST", + parent=self, + ) + + todo_column = _TodoTicketBoardColumn() + in_progress_column = _InProgressTicketBoardColumn() + confirmation_column = _ConfirmationTicketBoardColumn() + completed_column = _CompletedTicketBoardColumn() + refused_column = _RefusedTicketBoardColumn() + + self._columns[STATE_TODO] = todo_column + self._columns[STATE_IN_PROGRESS] = in_progress_column + self._columns[STATE_CONFIRMATION] = confirmation_column + self._columns[STATE_COMPLETED] = completed_column + self._columns[STATE_REFUSED] = refused_column + + board_row.add_widget(todo_column) + board_row.add_widget(in_progress_column) + board_row.add_widget(confirmation_column) + board_row.add_widget(completed_column) + board_row.add_widget(refused_column) + + def _connect_signals(self) -> None: + self._application.task_updated.connect(self._on_task_updated) + self._application.task_removed.connect(self._on_task_removed) + self._application.state_loaded.connect(self._reload_board) + + def _reload_board(self, *_args) -> None: + for column in self._columns.values(): + column.clear_cards() + self._task_columns.clear() + for task in self._application.list_active_tasks(): + self._upsert_task(task) + + def _upsert_task(self, task: TicketTaskSnapshot) -> None: + column = self._columns.get(task.state_code) + if column is None: + self._remove_task(task.task_id) + return + self._remove_task(task.task_id) + card = TaskCard(task) + card.card_clicked.connect(self._on_card_clicked) + column.add_card(card) + self._task_columns[task.task_id] = task.state_code + + def _remove_task(self, task_id: int) -> None: + state_code = self._task_columns.pop(task_id, None) + if state_code is not None: + column = self._columns.get(state_code) + if column is not None: + column.remove_card(task_id) + return + for column in self._columns.values(): + if column.remove_card(task_id) is not None: + return + + def _on_task_updated(self, task: TicketTaskSnapshot) -> None: + if isinstance(task, TicketTaskSnapshot): + self._upsert_task(task) + + def _on_task_removed(self, task_id: int) -> None: + self._remove_task(task_id) + + def _on_card_clicked(self, task_id: object) -> None: + try: + normalized_task_id = int(task_id) + except (TypeError, ValueError): + return + task = self._application.get_task(normalized_task_id) + if task is None: + return + TaskDetailsDialog( + application=self._application, + task_id=normalized_task_id, + parent=self, + ).exec() diff --git a/Dispatch_V0.1.1/ui/ticket_create_page.py b/Dispatch_V0.1.1/ui/ticket_create_page.py new file mode 100644 index 0000000..c1ec029 --- /dev/null +++ b/Dispatch_V0.1.1/ui/ticket_create_page.py @@ -0,0 +1,418 @@ +# -*- coding: utf-8 -*- +# hub/ticket/ui/ticket_create_page.py + +"""Форма создания заявки в составе независимого приложения Dispatch. + +Назначение модуля: + Страница «Создать заявку», разделённая на две колонки: + + - Левая колонка занимает 70% ширины и содержит выпадающий список + программного продукта, выпадающий список краткого заголовка + неисправности и многострочное поле подробного описания + неисправности. + - Правая колонка занимает 30% ширины и содержит блок действий + формы: «Создать заявку», «Отмена», «Прикрепить файл». + +Источники данных: + - `DB_dispatch/3_software_list.py` — справочник программных + продуктов (ключ `software_id` → наименование). + - `DB_dispatch/4_malfunction_list.py` — справочник кратких + заголовков неисправностей (ключ `software_id` → перечень + формулировок). + - `DB_dispatch/0_users.py` и `DB_dispatch/2_customer_facility_list.py` + — учётная запись и место установки заявителя; используются + для формирования строки локации заявки. + +Архитектурные ограничения: + - QSS-литералы и фиксированные размеры в прикладном коде не + используются: оформление подаётся через ключи `APP_STYLES`, + геометрия задаётся процентными долями. + - Все элементы построены на канонических обёртках `Button`, + `Label`, `ComboBox`, `TextInput`, `SContainer`, `HContainer`, + `VContainer`. + - Доступ к каталогу `DB_dispatch` осуществляется только через + сервис `hub.my_account.auth_service`. +""" + +from __future__ import annotations + +from datetime import datetime +from typing import Callable + +from gui.components import Button, ComboBox, Label, TextInput +from gui.containers import HContainer, SContainer, VContainer + +from application import TaskApplicationService +from domain import TicketTaskSnapshot +from domain.location_catalog import parse_location_parts +from domain.ticket_constants import ( + STATE_TODO, + TICKET_STATE_ACTIONS, + TICKET_STATE_COLORS, + TICKET_STATE_NAMES, +) + + +# Справочные тексты в комбобоксах: используются как placeholder, +# чтобы пользователю было понятно назначение поля до открытия списка. +_PRODUCT_PLACEHOLDER = "Программный продукт" +_SUMMARY_PLACEHOLDER = "Краткий заголовок неисправности" + + +class TicketCreatePage(SContainer): + """Двухколоночная форма создания заявки и её отправки на доску `STATE_TODO`.""" + + def __init__( + self, + application: TaskApplicationService, + on_finish: Callable[[], None], + parent=None, + ): + super().__init__( + width_percent=100, + height_percent=100, + margin=0, + parent=parent, + style="TICKET_SHELL_ROOT", + ) + self._application = application + self._on_finish = on_finish + + # Состояние активной сессии заявителя; используется только для + # формирования строки локации, в интерфейсе не отображается. + self._signed_user: dict | None = None + self._user_institution: str = "" + self._user_room: str = "" + + # Справочные таблицы DB_dispatch и индекс «наименование → id» + # для определения списка неисправностей по выбранному продукту. + self._software_by_id: dict[str, str] = {} + self._software_id_by_name: dict[str, str] = {} + self._malfunction_by_software_id: dict[str, list[str]] = {} + + # Поля левой колонки. + self._product_combo: ComboBox | None = None + self._summary_combo: ComboBox | None = None + self._description_input: TextInput | None = None + + # Действия формы. + self._submit_button: Button | None = None + self._cancel_button: Button | None = None + self._attach_button: Button | None = None + self._error_label: Label | None = None + + self._setup_ui() + self._connect_signals() + self._load_reference_data() + + # ── Сборка интерфейса ─────────────────────────────────────────────── + + def _setup_ui(self) -> None: + # Корневой контейнер страницы — горизонтальная компоновка с + # явными долями 70% (левая колонка) и 30% (правая колонка). + # `HContainer` всегда занимает 100% ширины родителя, поэтому + # параметр `width_percent` ему не передаётся. + root = HContainer( + height_percent=100, + margin=[24, 18, 24, 18], + spacing=18, + content_fit=False, + parent=self, + ) + + # ── Левая колонка: поля заявки (70%). ──────────────────────── + # `VContainer` всегда занимает 100% высоты родителя, поэтому + # `height_percent` опускается; ширина колонки задаётся явно. + left = VContainer( + width_percent=70, + margin=0, + spacing=12, + content_fit=False, + parent=root, + ) + + left.add_widget(Label( + "Создать заявку", + height_percent=8, + style="LOGIN_TITLE", + )) + + # Комбобокс программного продукта: справочный текст-подсказка + # отображается в редактируемой строке до выбора пункта. + self._product_combo = ComboBox( + width_percent=100, + height_percent=8, + content_fit=False, + ) + self._product_combo.set_editable(True) + self._product_combo.set_placeholder_text(_PRODUCT_PLACEHOLDER) + left.add_widget(self._product_combo) + + # Комбобокс краткого заголовка неисправности: пункты списка + # обновляются после выбора программного продукта. + self._summary_combo = ComboBox( + width_percent=100, + height_percent=8, + content_fit=False, + ) + self._summary_combo.set_editable(True) + self._summary_combo.set_placeholder_text(_SUMMARY_PLACEHOLDER) + left.add_widget(self._summary_combo) + + self._description_input = TextInput( + placeholder=( + "Подробно опишите проявления неисправности, шаги " + "воспроизведения, коды ошибок и предпринятые действия." + ), + width_percent=100, + height_percent=68, + content_fit=False, + multiline=True, + ) + left.add_widget(self._description_input) + + # ── Правая колонка: блок действий (30%). ────────────────────── + right = VContainer( + width_percent=30, + margin=0, + spacing=12, + content_fit=False, + parent=root, + ) + + self._error_label = Label( + "", + height_percent=8, + style="LOGIN_ERROR_LABEL", + ) + self._error_label.set_visible(False) + right.add_widget(self._error_label) + + right.add_stretch(1) + + # Блок действий: «Создать заявку», «Отмена», «Прикрепить файл». + # `VContainer` принимает только ширину в процентах от родителя; + # высота блока определяется собственными процентами кнопок. + actions = VContainer( + width_percent=100, + margin=0, + spacing=8, + content_fit=False, + parent=right, + ) + self._submit_button = Button( + "Создать заявку", + width_percent=100, + height_percent=30, + margin=0, + style="LOGIN_SUBMIT_BUTTON", + content_fit=False, + ) + self._cancel_button = Button( + "Отмена", + width_percent=100, + height_percent=30, + margin=0, + style="LOGIN_CANCEL_BUTTON", + content_fit=False, + ) + self._attach_button = Button( + "Прикрепить файл", + width_percent=100, + height_percent=30, + margin=0, + style="LOGIN_NAV_BUTTON", + content_fit=False, + ) + actions.add_widget(self._submit_button) + actions.add_widget(self._cancel_button) + actions.add_widget(self._attach_button) + right.add_widget(actions) + + # ── Сигналы и справочные данные ───────────────────────────────────── + + def _connect_signals(self) -> None: + if self._submit_button is not None: + self._submit_button.clicked.connect(self._on_submit_clicked) + if self._cancel_button is not None: + self._cancel_button.clicked.connect(self._on_cancel_clicked) + if self._attach_button is not None: + self._attach_button.clicked.connect(self._on_attach_clicked) + if self._product_combo is not None: + self._product_combo.current_text_changed.connect( + self._on_product_changed, + ) + + def _load_reference_data(self) -> None: + """Загрузить справочники программных продуктов и неисправностей. + + Источники — `DB_dispatch/3_software_list.py` и + `DB_dispatch/4_malfunction_list.py`. Полученные таблицы + используются для наполнения комбобоксов и для определения + перечня неисправностей по выбранному программному продукту. + """ + # Импорт внутри метода исключает циклическую зависимость + # между UI-слоем Ticket и сервисом учётных записей Dispatch. + from auth_service import ( + load_malfunction_list, + load_software_list, + ) + + self._software_by_id = load_software_list() + self._malfunction_by_software_id = load_malfunction_list() + self._software_id_by_name = { + name: software_id + for software_id, name in self._software_by_id.items() + } + self._populate_product_combo() + self._populate_summary_combo("") + + def _populate_product_combo(self) -> None: + if self._product_combo is None: + return + names = sorted(self._software_by_id.values()) + self._product_combo.set_items(["", *names]) + self._product_combo.set_index(0) + + def _populate_summary_combo(self, software_name: str) -> None: + if self._summary_combo is None: + return + software_id = self._software_id_by_name.get(software_name, "") + if software_id: + titles = list(self._malfunction_by_software_id.get(software_id, [])) + else: + # Пока программный продукт не выбран, показываем полный + # перечень заголовков из `DB_dispatch/4_malfunction_list.py`, + # сохраняя порядок появления и убирая дубликаты. + titles = [] + seen: set[str] = set() + for values in self._malfunction_by_software_id.values(): + for title in values: + if title in seen: + continue + seen.add(title) + titles.append(title) + self._summary_combo.set_items(["", *titles]) + self._summary_combo.set_index(0) + + def _on_product_changed(self, text: str) -> None: + """Обновить набор кратких заголовков под выбранный продукт.""" + self._populate_summary_combo((text or "").strip()) + + def refresh_user_session(self) -> None: + """Подтянуть реквизиты заявителя из активной сессии Dispatch. + + Метод сохраняет данные пользователя только во внутренних полях: + учреждение и кабинет нужны для формирования строки локации + заявки. В интерфейсе реквизиты заявителя не отображаются. + """ + from auth_service import get_user_facility, load_session + + user = load_session() + self._signed_user = user + if user is None: + self._user_institution = "" + self._user_room = "" + return + + institution, room, _product = parse_location_parts(get_user_facility(user)) + self._user_institution = institution + self._user_room = room + + # ── Обработчики действий ──────────────────────────────────────────── + + def _on_cancel_clicked(self, _checked: bool = False) -> None: + """Сбросить форму и вернуть пользователя на доску заявок.""" + self._reset_form() + self._on_finish() + + def _on_attach_clicked(self, _checked: bool = False) -> None: + """Заглушка действия прикрепления файла. + + Полнофункциональная загрузка вложений выходит за рамки + текущего этапа; кнопка зарезервирована в разметке и + подключает обработчик-уведомление, чтобы пользователь не + воспринимал отсутствие реакции как сбой. + """ + self._show_error("Прикрепление файла будет доступно на следующем этапе.") + + def _on_submit_clicked(self, _checked: bool = False) -> None: + """Проверить поля, собрать snapshot и зарегистрировать заявку.""" + if self._signed_user is None: + self._show_error("Войдите в систему перед созданием заявки.") + return + + product = self._read_combo_text(self._product_combo) + summary = self._read_combo_text(self._summary_combo) + description = ( + self._description_input.get_text() if self._description_input else "" + ).strip() + + if not product or not summary or not description: + self._show_error( + "Выберите программный продукт, краткий заголовок и заполните описание.", + ) + return + if not self._user_institution: + self._show_error( + "Для учётной записи не задано место установки в DB_dispatch.", + ) + return + + location = self._compose_location(product, summary) + snapshot = self._build_snapshot(location) + self._application.submit_new_task(snapshot) + self._reset_form() + self._hide_error() + self._on_finish() + + @staticmethod + def _read_combo_text(combo: ComboBox | None) -> str: + if combo is None: + return "" + return combo.get_current_text().strip() + + def _compose_location(self, product: str, summary: str) -> str: + # Формат локации совпадает с парсером `parse_location_parts`: + # «Учреждение (Аппарат — заголовок, каб. №)». Это даёт корректное + # отображение карточки на доске без дополнительных адаптеров. + device_segment = f"{product} — {summary}" if summary else product + if not self._user_room: + return f"{self._user_institution} ({device_segment})" + return f"{self._user_institution} ({device_segment}, {self._user_room})" + + def _build_snapshot(self, location: str) -> TicketTaskSnapshot: + action_text = TICKET_STATE_ACTIONS.get(STATE_TODO, "") + return TicketTaskSnapshot( + task_id=self._application.allocate_new_task_id(), + location=location, + state_code=STATE_TODO, + state_name=TICKET_STATE_NAMES.get(STATE_TODO, ""), + action_text=action_text, + color_hex=TICKET_STATE_COLORS.get(STATE_TODO, "#FFFFFF"), + created_at=datetime.now(), + ) + + def _reset_form(self) -> None: + """Очистить редактируемые поля левой колонки.""" + if self._product_combo is not None: + self._product_combo.set_index(0) + if self._summary_combo is not None: + self._summary_combo.set_index(0) + if self._description_input is not None: + self._description_input.clear() + self._hide_error() + + # ── Сообщения об ошибках ──────────────────────────────────────────── + + def _show_error(self, message: str) -> None: + if self._error_label is None: + return + self._error_label.set_text(message) + self._error_label.set_visible(True) + + def _hide_error(self) -> None: + if self._error_label is None: + return + self._error_label.set_text("") + self._error_label.set_visible(False) diff --git a/Dispatch_V0.1.1/ui/ticket_message_dialog.py b/Dispatch_V0.1.1/ui/ticket_message_dialog.py new file mode 100644 index 0000000..2509473 --- /dev/null +++ b/Dispatch_V0.1.1/ui/ticket_message_dialog.py @@ -0,0 +1,115 @@ +# -*- coding: utf-8 -*- +# hub/ticket/ui/ticket_message_dialog.py + +"""Простые диалоги Ticket на локальной GUI-библиотеке.""" + +from __future__ import annotations + +from gui.components import Button, Dialog, Label, TextInput +from gui.containers import HContainer, VContainer + + +class TicketMessageDialog(Dialog): + """Унифицированный диалог предупреждения или подтверждения.""" + + def __init__( + self, + title: str, + message: str, + accept_text: str = "ОК", + reject_text: str | None = None, + parent=None, + ): + self._title = title + self._message = message + self._accept_text = accept_text + self._reject_text = reject_text + self._accept_button: Button | None = None + self._reject_button: Button | None = None + super().__init__( + title=title, + width=420, + height=220, + modal=True, + parent=parent, + ) + self._setup_ui() + self._connect_signals() + + @classmethod + def ask_confirmation( + cls, + parent, + title: str, + message: str, + accept_text: str = "Подтвердить", + reject_text: str = "Отмена", + ) -> bool: + dialog = cls( + title=title, + message=message, + accept_text=accept_text, + reject_text=reject_text, + parent=parent, + ) + return dialog.exec() == cls.DialogCode.Accepted + + @classmethod + def show_warning( + cls, + parent, + title: str, + message: str, + ) -> None: + cls( + title=title, + message=message, + accept_text="Закрыть", + reject_text=None, + parent=parent, + ).exec() + + def _setup_ui(self) -> None: + # Root-контейнер диалога сообщения: заголовок, текстовое поле и строка кнопок. + main_container = VContainer(margin=16, spacing=12) + self.add_widget(main_container) + + title_label = Label( + self._title, + alignment="left", + style="TICKET_LIST_HEADER", + ) + message_view = TextInput( + text=self._message, + style="TICKET_PREVIEW_AREA", + multiline=True, + ) + message_view.set_read_only(True) + message_view.set_min_height(96) + + # Actions-row диалога: выравнивает кнопки подтверждения и, при необходимости, отмены. + actions = HContainer(spacing=8, content_fit=True) + actions.add_stretch() + if self._reject_text is not None: + self._reject_button = Button( + self._reject_text, + style="FILTER_BUTTON", + content_fit=True, + ) + actions.add_widget(self._reject_button) + self._accept_button = Button( + self._accept_text, + style="FILTER_BUTTON_ACTIVE", + content_fit=True, + ) + actions.add_widget(self._accept_button) + + main_container.add_widget(title_label) + main_container.add_widget(message_view) + main_container.add_widget(actions) + + def _connect_signals(self) -> None: + if self._accept_button is not None: + self._accept_button.clicked.connect(self.accept) + if self._reject_button is not None: + self._reject_button.clicked.connect(self.reject) diff --git a/Dispatch_V0.1.1/ui/ticket_placeholder_page.py b/Dispatch_V0.1.1/ui/ticket_placeholder_page.py new file mode 100644 index 0000000..5d8467e --- /dev/null +++ b/Dispatch_V0.1.1/ui/ticket_placeholder_page.py @@ -0,0 +1,40 @@ +# -*- coding: utf-8 -*- +# hub/ticket/ui/ticket_placeholder_page.py + +"""Временная страница-заглушка Ticket для внутренних разделов shell.""" + +from gui.components.label import Label +from gui.containers import SContainer, VContainer + + +class TicketPlaceholderPage(SContainer): + """Временная страница для разделов, которые ещё будут перенесены.""" + + def __init__( + self, + title: str, + description: str, + notes: tuple[str, ...] = (), + parent=None, + ): + super().__init__(width_percent=100, height_percent=100, parent=parent) + self._title = title + self._description = description + self._notes = notes + self._setup_ui() + + def _setup_ui(self) -> None: + """Показать временную страницу до переноса полноценного UI.""" + # Root-контейнер заглушки: показывает название раздела, описание и вспомогательные заметки. + main_container = VContainer(margin=18, spacing=10, parent=self) + title_label = Label(self._title, alignment="left", style="TICKET_LIST_HEADER") + description_label = Label( + self._description, + alignment="left", + style="TICKET_EMPTY_LABEL", + ) + main_container.add_widget(title_label) + main_container.add_widget(description_label) + for note in self._notes: + note_label = Label(note, alignment="left", style="TICKET_LIST_SUBTITLE") + main_container.add_widget(note_label) diff --git a/Dispatch_V0.1.1/ui/ticket_selection_list.py b/Dispatch_V0.1.1/ui/ticket_selection_list.py new file mode 100644 index 0000000..99756e0 --- /dev/null +++ b/Dispatch_V0.1.1/ui/ticket_selection_list.py @@ -0,0 +1,171 @@ +# -*- coding: utf-8 -*- +# hub/ticket/ui/ticket_selection_list.py + +"""Контейнерный список выбора Ticket на локальной GUI-библиотеке.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from PySide6.QtCore import Qt, Signal + +from gui.components import Label, VSpring +from gui.containers import ScrollContainer, SContainer, VContainer + + +@dataclass(frozen=True, slots=True) +class TicketSelectionEntry: + """Описывает одну запись в контейнерном списке Ticket.""" + + entry_id: object + title: str + subtitle: str = "" + + +class _TicketSelectionItem(SContainer): + """Визуальный элемент выбора записи Ticket.""" + + clicked = Signal(object) + activated = Signal(object) + + def __init__( + self, + entry: TicketSelectionEntry, + parent=None, + ): + super().__init__( + margin=0, + spacing=2, + content_fit=True, + parent=parent, + style="TICKET_LIST_ITEM", + active_style="TICKET_LIST_ITEM_SELECTED", + is_active=False, + ) + self._entry = entry + self.setCursor(Qt.CursorShape.PointingHandCursor) + self._setup_ui() + + @property + def entry_id(self) -> object: + return self._entry.entry_id + + def set_selected(self, selected: bool) -> None: + self.style(is_active=selected) + + def mousePressEvent(self, event) -> None: + if event.button() == Qt.MouseButton.LeftButton: + self.clicked.emit(self._entry.entry_id) + super().mousePressEvent(event) + + def mouseDoubleClickEvent(self, event) -> None: + if event.button() == Qt.MouseButton.LeftButton: + self.clicked.emit(self._entry.entry_id) + self.activated.emit(self._entry.entry_id) + super().mouseDoubleClickEvent(event) + + def _setup_ui(self) -> None: + # Body элемента списка: вертикальный блок title/subtitle внутри кликабельной записи. + body = VContainer(margin=10, spacing=2, parent=self) + body.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, True) + title_label = Label( + self._entry.title, + alignment="left", + style="TICKET_LIST_TITLE", + ) + title_label.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, True) + body.add_widget(title_label) + + if self._entry.subtitle: + subtitle_label = Label( + self._entry.subtitle, + alignment="left", + style="TICKET_LIST_SUBTITLE", + ) + subtitle_label.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, True) + body.add_widget(subtitle_label) + + +class TicketSelectionList(SContainer): + """Переиспользуемый список выбора на контейнерах и локальных компонентах.""" + + selection_changed = Signal(object) + item_activated = Signal(object) + + def __init__(self, parent=None): + super().__init__(spacing=0, parent=parent) + self._items: dict[object, _TicketSelectionItem] = {} + self._current_entry_id: object | None = None + self._items_host: VContainer | None = None + self._setup_ui() + + def set_entries(self, entries: list[TicketSelectionEntry]) -> None: + previous_entry_id = self._current_entry_id + self.clear_entries() + for entry in entries: + self._add_entry(entry) + if not self._items: + self._current_entry_id = None + self.selection_changed.emit(None) + return + target_entry_id = previous_entry_id if previous_entry_id in self._items else entries[0].entry_id + self.set_current_entry(target_entry_id) + + def clear_entries(self) -> None: + if self._items_host is None: + self._items.clear() + self._current_entry_id = None + return + for item in list(self._items.values()): + self._items_host.remove_widget(item) + item.setParent(None) + self._items.clear() + self._current_entry_id = None + + def current_entry_id(self) -> object | None: + return self._current_entry_id + + def has_selection(self) -> bool: + return self._current_entry_id is not None + + def set_current_entry(self, entry_id: object | None) -> None: + normalized_entry_id = entry_id if entry_id in self._items else None + if normalized_entry_id == self._current_entry_id: + return + self._current_entry_id = normalized_entry_id + for item_entry_id, item in self._items.items(): + item.set_selected(item_entry_id == normalized_entry_id) + self.selection_changed.emit(normalized_entry_id) + + def _setup_ui(self) -> None: + # Scroll-контейнер списка: внешняя прокручиваемая оболочка всех записей TicketSelectionList. + scroll = ScrollContainer( + margin=0, + content_margins=[0, 0, 0, 0], + spacing=6, + orientation="v", + vertical_scroll_bar_policy="as_needed", + horizontal_scroll_bar_policy="always_off", + style="SCROLL_CONTAINER", + parent=self, + ) + # Items-host: вертикальный стек элементов списка с нижней пружиной для прилипания вверх. + self._items_host = VContainer(spacing=6, parent=scroll) + self._items_host.add_widget(VSpring()) + + def _add_entry(self, entry: TicketSelectionEntry) -> None: + if self._items_host is None: + return + item = _TicketSelectionItem(entry) + item.clicked.connect(self._on_item_clicked) + item.activated.connect(self._on_item_activated) + self._items[entry.entry_id] = item + self._items_host.insert_widget(len(self._items) - 1, item) + + def _on_item_clicked(self, entry_id: object) -> None: + self.set_current_entry(entry_id) + + def _on_item_activated(self, entry_id: object) -> None: + if entry_id != self._current_entry_id: + self.set_current_entry(entry_id) + self.item_activated.emit(entry_id) diff --git a/Dispatch_V0.1.1/ui/ticket_shell.py b/Dispatch_V0.1.1/ui/ticket_shell.py new file mode 100644 index 0000000..0239033 --- /dev/null +++ b/Dispatch_V0.1.1/ui/ticket_shell.py @@ -0,0 +1,400 @@ +# -*- coding: utf-8 -*- +# hub/ticket/ui/ticket_shell.py + +"""Оболочка модуля Ticket в составе независимого приложения Dispatch. + +Назначение модуля: + Минимальная shell-страница Dispatch: + - Единая горизонтальная шапка из трёх равных по ширине кнопок: + кнопка-логотип (открывает контактный диалог), кнопка раздела + «Ваши заявки», кнопка авторизации `Log In` / `Log Out`. + - Центральная область с доской задач Ticket. + - Модуль работы с COM-портом в Dispatch отключён, поэтому строка + состояния COM-канала и подписки на сигналы шлюза удалены. + +Архитектурные ограничения: + - Стилевые ключи берутся только из внешнего реестра `APP_STYLES`; + локальные QSS-литералы не используются. + - Все три элемента шапки — экземпляры локальной обёртки `Button` + и распределяют пространство равными долями `stretch=1`. + - Логотип реализован как канонический `Button` с иконкой + (`icon_path`), без кастомного raw-Qt компонента и без inline QSS. +""" + +from __future__ import annotations + +import os +from functools import partial + +from gui.components.button import Button +from gui.components.dialog import Dialog +from gui.components.label import Label +from gui.containers import HContainer, SContainer, StackContainer, VContainer +from gui.theme_bus import theme_bus + +from application import TaskApplicationService +from .pages import ArchivePage +from .ticket_board_page import TicketBoardPage +from .ticket_create_page import TicketCreatePage + + +# Контактные телефоны для модального окна. Текст вынесен в константы, +# чтобы единственное место правки находилось в верхней части файла. +_CONTACT_PHONE_GENERAL = "+7 (000) 000-00-00" +_CONTACT_PHONE_DISPATCHER = "+7 (000) 000-00-00" +_CONTACT_PHONE_SERVICE_HEAD = "+7 (000) 000-00-00" + + +class _ContactsDialog(Dialog): + """Простое модальное окно с контактными данными организации.""" + + def __init__(self, parent=None): + super().__init__( + title="Контактные данные", + width=420, + height=260, + modal=True, + parent=parent, + ) + self._setup_ui() + + def _setup_ui(self) -> None: + body = VContainer( + margin=[24, 20, 24, 20], + spacing=12, + parent=None, + ) + self.add_widget(body) + + body.add_widget(Label( + "Контакты службы сервисного обслуживания", + height_percent=20, + style="LOGIN_TITLE", + )) + body.add_widget(Label( + f"Общий телефон: {_CONTACT_PHONE_GENERAL}", + height_percent=20, + style="LOGIN_FIELD_LABEL", + )) + body.add_widget(Label( + f"Диспетчер: {_CONTACT_PHONE_DISPATCHER}", + height_percent=20, + style="LOGIN_FIELD_LABEL", + )) + body.add_widget(Label( + f"Руководитель службы: {_CONTACT_PHONE_SERVICE_HEAD}", + height_percent=20, + style="LOGIN_FIELD_LABEL", + )) + + actions = SContainer( + height_percent=20, + orientation="h", + content_fit=False, + ) + actions.add_stretch(1) + close_button = Button( + text="Закрыть", + height_percent=100, + margin=0, + content_fit=False, + ) + close_button.clicked.connect(self.accept) + actions.add_widget(close_button) + body.add_widget(actions) + + +class TicketShell(SContainer): + """Корневая оболочка Dispatch: шапка из трёх кнопок и доска задач.""" + + def __init__( + self, + application: TaskApplicationService, + parent=None, + ): + super().__init__( + width_percent=100, + height_percent=100, + margin=0, + spacing=0, + parent=parent, + style="TICKET_SHELL_ROOT", + ) + self._application = application + self._page_stack: StackContainer | None = None + self._theme = "dark" if self.palette().window().color().lightness() < 128 else "light" + self._active_view_name = "board" + self._page_index_by_name: dict[str, int] = {} + self._nav_buttons: dict[str, Button] = {} + self._login_button: Button | None = None + self._logo_button: Button | None = None + self._is_logged_in: bool = False + self._logged_in_user: dict | None = None + self._create_page: TicketCreatePage | None = None + self._logo_dark_path, self._logo_light_path = self._resolve_logo_paths() + self._setup_ui() + self._connect_signals() + self._sync_from_application() + self._restore_session() + + # ── разметка интерфейса ── + + def _setup_ui(self) -> None: + # Единая шапка: один HContainer, три равные по ширине кнопки. + # Высота шапки — 6% высоты shell. Распределение долей + # выполняется через `add_widget_with_stretch(button, 1)` для + # каждой кнопки, что даёт ровно одинаковые сегменты. + top_bar = HContainer( + height_percent=6, + margin=[0, 0, 0, 12], + spacing=16, + content_fit=False, + style="TICKET_SURFACE_HOST", + parent=self, + ) + + # Кнопка-логотип: при нажатии открывается модальный диалог с + # контактными данными. Иконка логотипа подбирается под тему. + self._logo_button = Button( + text="", + height_percent=100, + margin=0, + is_active=False, + content_fit=False, + icon_path=self._logo_dark_path, + icon_size=40, + ) + + board_button = Button( + text="Ваши заявки", + height_percent=100, + margin=0, + is_active=True, + content_fit=False, + ) + self._nav_buttons["board"] = board_button + + create_button = Button( + text="Создать заявку", + height_percent=100, + margin=0, + is_active=False, + content_fit=False, + ) + self._nav_buttons["create"] = create_button + + archive_button = Button( + text="Архив моих заявок", + height_percent=100, + margin=0, + is_active=False, + content_fit=False, + ) + self._nav_buttons["archive"] = archive_button + + self._login_button = Button( + text="Log In", + height_percent=100, + margin=0, + is_active=False, + content_fit=False, + style="LOGIN_NAV_BUTTON", + ) + + top_bar.add_widget_with_stretch(self._logo_button, 1) + top_bar.add_widget_with_stretch(board_button, 1) + top_bar.add_widget_with_stretch(create_button, 1) + top_bar.add_widget_with_stretch(archive_button, 1) + top_bar.add_widget_with_stretch(self._login_button, 1) + + # Центральный stack-контейнер: в Dispatch удерживает три страницы — доску, создание и архив. + self._page_stack = StackContainer(margin=0, parent=self) + + board_page = TicketBoardPage(application=self._application) + create_page = TicketCreatePage( + application=self._application, + on_finish=self._on_create_form_finished, + ) + self._create_page = create_page + archive_page = ArchivePage(application=self._application) + + self._page_index_by_name["board"] = self._page_stack.add_widget(board_page) + self._page_index_by_name["create"] = self._page_stack.add_widget(create_page) + self._page_index_by_name["archive"] = self._page_stack.add_widget(archive_page) + self._page_stack.set_current_index(self._page_index_by_name["board"]) + + def _connect_signals(self) -> None: + self._nav_buttons["board"].clicked.connect(partial(self._on_navigation_requested, "board")) + self._nav_buttons["create"].clicked.connect(partial(self._on_navigation_requested, "create")) + self._nav_buttons["archive"].clicked.connect(partial(self._on_navigation_requested, "archive")) + if self._login_button is not None: + self._login_button.clicked.connect(self._on_login_button_clicked) + if self._logo_button is not None: + self._logo_button.clicked.connect(self._on_logo_button_clicked) + + self._application.active_view_changed.connect(self._on_active_view_changed) + theme_bus.theme_changed.connect(self._on_theme_changed) + + def _sync_from_application(self) -> None: + self._set_active_page(self._application.get_active_view()) + + # ── навигация и страницы ── + + def _set_active_page(self, view_name: str) -> None: + if self._page_stack is None: + return + normalized_name = view_name if view_name in self._page_index_by_name else "board" + self._active_view_name = normalized_name + target_index = self._page_index_by_name.get(normalized_name) + if target_index is None: + return + if self._page_stack.current_index() != target_index: + self._page_stack.set_current_index(target_index) + self._apply_navigation_theme() + + def _on_active_view_changed(self, view_name: str) -> None: + self._set_active_page(view_name) + + def _on_navigation_requested(self, view_name: str, _checked: bool = False) -> None: + if view_name == "create" and self._create_page is not None: + # При каждом входе на страницу форма подтягивает актуальные данные заявителя. + self._create_page.refresh_user_session() + self._application.set_active_view(view_name) + + def _on_create_form_finished(self) -> None: + """После сохранения или отмены формы вернуть пользователя на доску заявок.""" + self._application.set_active_view("board") + + # ── авторизация ── + + def _on_login_button_clicked(self, _checked: bool = False) -> None: + """Переключить состояние сессии: вход через диалог или выход. + + Источник учётных данных — каталог `DB_dispatch`. Сценарий + полностью повторяет схему USMS: при отсутствии активного + пользователя открывается модальный диалог авторизации, + иначе выполняется выход и очистка файла активной сессии. + """ + if self._login_button is None: + return + if self._logged_in_user is not None: + self._do_logout() + else: + self._do_login() + + def _do_login(self) -> None: + """Открыть диалог авторизации и при успехе записать сессию.""" + # Импорты вынесены в момент вызова, чтобы избежать циклической + # загрузки модулей при старте приложения Dispatch. + from gui.login_dialog import LoginDialog + from auth_service import write_session + + dialog = LoginDialog(parent=self) + if dialog.exec() != LoginDialog.DialogCode.Accepted: + return + user = dialog.get_authenticated_user() + if user is None: + return + write_session(user) + self._apply_logged_in_state(user) + + def _do_logout(self) -> None: + """Очистить запись активной сессии и вернуть кнопку в Log In.""" + from auth_service import clear_session + + clear_session() + self._apply_logged_out_state() + + def _restore_session(self) -> None: + """Восстановить состояние входа из `DB_dispatch/1_actual_state.py`.""" + from auth_service import load_session + + user = load_session() + if user is not None: + self._apply_logged_in_state(user) + + def _apply_logged_in_state(self, user: dict) -> None: + """Перевести кнопку входа в состояние Log Out для активного пользователя.""" + self._logged_in_user = user + self._is_logged_in = True + if self._login_button is None: + return + self._login_button.set_text("Log Out") + self._login_button.style(is_active=True) + + def _apply_logged_out_state(self) -> None: + """Перевести кнопку входа обратно в состояние Log In.""" + self._logged_in_user = None + self._is_logged_in = False + if self._login_button is None: + return + self._login_button.set_text("Log In") + self._login_button.style(is_active=False) + + # ── контактный диалог ── + + def _on_logo_button_clicked(self, _checked: bool = False) -> None: + """Открыть модальное окно с контактными телефонами организации.""" + dialog = _ContactsDialog(parent=self) + dialog.exec() + + # ── оформление и тема ── + + def _on_theme_changed(self, theme: str) -> None: + self._apply_theme(theme) + + def _apply_theme(self, theme: str) -> None: + normalized_theme = (theme or "").strip().lower() + if normalized_theme not in {"dark", "light"}: + return + self._theme = normalized_theme + self._apply_navigation_theme() + self._update_logo_icon(normalized_theme) + + def _apply_navigation_theme(self) -> None: + normal_key, active_key = self._navigation_style_keys() + for page_name, button in self._nav_buttons.items(): + button.style( + style_key=normal_key, + active_key=active_key, + is_active=page_name == self._active_view_name, + ) + + def _navigation_style_keys(self) -> tuple[str, str]: + if self._theme == "light": + return ("TAB_BUTTON_NORMAL_LIGHT", "TAB_BUTTON_ACTIVE_LIGHT") + return ("TAB_BUTTON_NORMAL", "TAB_BUTTON_ACTIVE") + + def _resolve_logo_paths(self) -> tuple[str, str]: + """Вычислить пути файлов логотипа из локального каталога `gui/components/logo`.""" + # __file__ = .../dispatch/hub/ticket/ui/ticket_shell.py + # project_root = .../dispatch + project_root = os.path.dirname( + os.path.dirname( + os.path.dirname( + os.path.dirname(os.path.abspath(__file__)) + ) + ) + ) + logo_dir = os.path.join(project_root, "gui", "components", "logo") + return ( + os.path.join(logo_dir, "Nutshell_Logo_ENG_White.png"), + os.path.join(logo_dir, "Nutshell_Logo_ENG_Black.png"), + ) + + def _update_logo_icon(self, theme: str) -> None: + """Подобрать иконку кнопки-логотипа под текущую тему оформления.""" + if self._logo_button is None: + return + is_light = str(theme or "").strip().lower() == "light" + path = self._logo_light_path if is_light else self._logo_dark_path + from PySide6.QtCore import QSize + from PySide6.QtGui import QIcon + + # Используем штатное API QPushButton, доступ к которому Button предоставляет + # как делегат через свойство `clicked`. Иконку обновляем в обход публичного API, + # потому что Button не имеет канонического `set_icon` метода в текущем wrapper-слое. + inner_button = getattr(self._logo_button, "_button", None) + if inner_button is not None: + inner_button.setIcon(QIcon(path)) + inner_button.setIconSize(QSize(40, 40)) diff --git a/Dispatch_V0.1.1/window.py b/Dispatch_V0.1.1/window.py new file mode 100644 index 0000000..2ae1525 --- /dev/null +++ b/Dispatch_V0.1.1/window.py @@ -0,0 +1,104 @@ +# -*- coding: utf-8 -*- +# dispatch/window.py + +"""Главное окно независимого приложения Dispatch. + +Назначение модуля: + Минимальный графический каркас, выполняющий единственную обязанность — + разместить корневой виджет модуля `hub.ticket.TicketPlugin` в окне + верхнего уровня. Управление содержимым модуля Ticket остаётся + полностью внутри самого модуля. + +Архитектурные ограничения: + - Окно не содержит предметной логики и не обращается к внутренним + полям модуля Ticket: взаимодействие выполняется только через его + публичный API (`TicketPlugin`). + - Стилевое оформление берётся из внешнего реестра `APP_STYLES`; + локальные QSS-литералы запрещены (правило 6.3). + - Отказ модуля Ticket изолирован: при ошибке инициализации окно + продолжает существовать, ошибка фиксируется в журнале. +""" + +from PySide6.QtCore import Qt, QEvent, QTimer +from PySide6.QtWidgets import QMainWindow + +from error_logger import log_exception +from gui.containers.percent_sized_widget import PercentSizedWidget +from gui.styles import APP_STYLES +from gui.theme_bus import theme_bus as _default_theme_bus +from application import TaskApplicationService +from ticket_plugin import TicketPlugin + +from null_hardware_gateway import NullHardwareGateway + + +class DispatchMainWindow(QMainWindow): + """Главное окно приложения Dispatch с единственным модулем Ticket.""" + + def __init__(self, theme_bus=None): + super().__init__() + self._theme_bus = theme_bus or _default_theme_bus + self._ticket_plugin: TicketPlugin | None = None + self._setup_window() + self._setup_ui() + self._connect_signals() + + def _setup_window(self) -> None: + """Настроить параметры окна верхнего уровня.""" + self.setWindowTitle("Dispatch — Ticket") + self.setMinimumSize(1200, 800) + self.setStyleSheet(APP_STYLES.get("MAIN_WINDOW_DARK", "")) + self.showMaximized() + + def _setup_ui(self) -> None: + """Разместить корневой виджет модуля Ticket в качестве центрального. + + В составе Dispatch модуль работы с COM-портом отключён: приложение + собирает application-сервис Ticket с null-шлюзом и передаёт его + в `TicketPlugin`, который владеет жизненным циклом сервиса. + """ + try: + null_gateway = NullHardwareGateway(parent=self) + application_service = TaskApplicationService( + hardware_gateway=null_gateway, + parent=self, + ) + self._ticket_plugin = TicketPlugin(application_service=application_service) + self.setCentralWidget(self._ticket_plugin) + except Exception as exc: + log_exception(__name__, "DispatchMainWindow._setup_ui", exc) + raise + + def _connect_signals(self) -> None: + """Подключить шину тем для согласованного оформления окна.""" + self._theme_bus.theme_changed.connect(self._on_theme_changed) + + def _on_theme_changed(self, theme: str) -> None: + """Применить тему окна по ключам из внешнего реестра стилей.""" + is_light = str(theme or "").strip().lower() == "light" + key = "MAIN_WINDOW_LIGHT" if is_light else "MAIN_WINDOW_DARK" + self.setStyleSheet(APP_STYLES.get(key, "")) + + def changeEvent(self, event): + """Пересчитать процентную разметку при восстановлении окна.""" + if event.type() == QEvent.Type.WindowStateChange: + if not (self.windowState() & Qt.WindowState.WindowMinimized): + QTimer.singleShot(0, self._refresh_percent_layout) + return super().changeEvent(event) + + def _refresh_percent_layout(self) -> None: + """Принудительно пересчитать процентные размеры дочерних виджетов.""" + for widget in self.findChildren(PercentSizedWidget): + try: + widget.schedule_percent_update() + except Exception as exc: + log_exception(__name__, "DispatchMainWindow._refresh_percent_layout", exc) + + def closeEvent(self, event) -> None: + """Гарантировать корректную остановку модуля Ticket при закрытии.""" + if self._ticket_plugin is not None: + try: + self._ticket_plugin.cleanup() + except Exception as exc: + log_exception(__name__, "DispatchMainWindow.closeEvent", exc) + super().closeEvent(event)