Add Dispatch_V0.1.1

This commit is contained in:
2026-04-29 08:18:54 +04:00
commit a7ede6ded4
404 changed files with 39167 additions and 0 deletions

View File

@@ -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",
]

View File

@@ -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

View File

@@ -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

View File

@@ -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()

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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)

View File

@@ -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))

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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))

View File

@@ -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