Files
2026-04-29 08:18:54 +04:00

626 lines
28 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
# gui/components/_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) Правило сопровождения:
# - Любое изменение сценария должно сопровождаться обновлением этого блока с сохранением фактического порядка вызовов.
# - При добавлении метода указывать его место в цепочке сценария (запуск, основной шаг, завершение, вспомогательная логика).