Add Dispatch_V0.1.1
This commit is contained in:
@@ -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",
|
||||
]
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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))
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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))
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user