# -*- 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) Правило сопровождения: # - Любое изменение сценария должно сопровождаться обновлением этого блока с сохранением фактического порядка вызовов. # - При добавлении метода указывать его место в цепочке сценария (запуск, основной шаг, завершение, вспомогательная логика).