2026-07-27 18:28:26 +08:00
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
from datetime import datetime
|
|
|
|
|
import math
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
|
|
|
|
import vtk
|
|
|
|
|
from PySide6.QtCore import Qt, QThread, QTimer, Slot
|
|
|
|
|
from PySide6.QtWidgets import (
|
|
|
|
|
QApplication,
|
|
|
|
|
QFileDialog,
|
|
|
|
|
QMessageBox,
|
|
|
|
|
QTableWidgetItem,
|
|
|
|
|
QTreeWidgetItem,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
from .model import StepModel
|
|
|
|
|
from .records import OperationRecord
|
|
|
|
|
from .ui_helpers import * # noqa: F403
|
|
|
|
|
from .workers import EditWorker, ScanWorker
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class InfoPanelMixin:
|
|
|
|
|
def set_info(self, info: dict[str, object]) -> None:
|
|
|
|
|
self.current_info_values = dict(info)
|
|
|
|
|
self.current_info_text = _info_to_text(info)
|
|
|
|
|
self.info_text.setPlainText(self.current_info_text)
|
2026-08-04 09:35:39 +08:00
|
|
|
diagnostic_content = getattr(self, "diagnostic_info_content", None)
|
|
|
|
|
diagnostics_visible = bool(
|
|
|
|
|
diagnostic_content is None
|
|
|
|
|
or not hasattr(diagnostic_content, "isVisible")
|
|
|
|
|
or diagnostic_content.isVisible()
|
|
|
|
|
)
|
|
|
|
|
if diagnostics_visible:
|
|
|
|
|
self._populate_info_tree(info)
|
|
|
|
|
self.info_tabs.setCurrentWidget(self.info_tree)
|
|
|
|
|
else:
|
|
|
|
|
self.info_tree.clear()
|
2026-07-28 18:30:58 +08:00
|
|
|
if hasattr(self, "_refresh_property_editor"):
|
|
|
|
|
self._refresh_property_editor()
|
2026-07-29 15:43:28 +08:00
|
|
|
if hasattr(self, "_update_selected_object_title"):
|
|
|
|
|
self._update_selected_object_title()
|
2026-07-28 18:30:58 +08:00
|
|
|
if hasattr(self, "mode_combo"):
|
2026-07-28 14:05:14 +08:00
|
|
|
self._update_action_states()
|
2026-07-27 18:28:26 +08:00
|
|
|
|
|
|
|
|
def set_plain_info(self, text: str) -> None:
|
|
|
|
|
self.current_info_values = {}
|
|
|
|
|
self.current_info_text = text
|
|
|
|
|
self.info_tree.clear()
|
|
|
|
|
self.info_text.setPlainText(text)
|
|
|
|
|
self.info_tabs.setCurrentWidget(self.info_text)
|
2026-07-28 18:30:58 +08:00
|
|
|
if hasattr(self, "_clear_property_editor"):
|
|
|
|
|
self._clear_property_editor()
|
|
|
|
|
if hasattr(self, "mode_combo"):
|
2026-07-28 14:05:14 +08:00
|
|
|
self._update_action_states()
|
2026-07-27 18:28:26 +08:00
|
|
|
|
2026-08-04 09:35:39 +08:00
|
|
|
def _on_diagnostic_info_toggled(self, checked: bool) -> None:
|
|
|
|
|
if hasattr(self, "diagnostic_info_content"):
|
|
|
|
|
self.diagnostic_info_content.setVisible(bool(checked))
|
|
|
|
|
if checked and self.current_info_values:
|
|
|
|
|
self._populate_info_tree(self.current_info_values)
|
|
|
|
|
self.info_tabs.setCurrentWidget(self.info_tree)
|
|
|
|
|
elif not checked and hasattr(self, "info_tree"):
|
|
|
|
|
self.info_tree.clear()
|
|
|
|
|
|
2026-07-27 18:28:26 +08:00
|
|
|
def _populate_info_tree(self, info: dict[str, object]) -> None:
|
|
|
|
|
self.info_tree.clear()
|
|
|
|
|
emitted: set[str] = set()
|
|
|
|
|
for group_name, keys in INFO_GROUPS:
|
|
|
|
|
items = [(key, info[key]) for key in keys if key in info]
|
|
|
|
|
if not items:
|
|
|
|
|
continue
|
|
|
|
|
self._add_info_group(group_name, items)
|
|
|
|
|
emitted.update(key for key, _value in items)
|
|
|
|
|
|
|
|
|
|
remaining = [(key, value) for key, value in info.items() if key not in emitted]
|
|
|
|
|
if remaining:
|
|
|
|
|
self._add_info_group("其他", remaining)
|
|
|
|
|
|
|
|
|
|
self.info_tree.expandAll()
|
|
|
|
|
self.info_tree.resizeColumnToContents(0)
|
|
|
|
|
|
|
|
|
|
def _add_info_group(self, group_name: str, items: list[tuple[str, object]]) -> None:
|
|
|
|
|
group = QTreeWidgetItem([group_name, ""])
|
|
|
|
|
group.setFirstColumnSpanned(True)
|
|
|
|
|
self.info_tree.addTopLevelItem(group)
|
|
|
|
|
for key, value in items:
|
2026-08-04 09:35:39 +08:00
|
|
|
child = QTreeWidgetItem([INFO_LABELS.get(key, key), _format_info_value(key, value)])
|
2026-07-27 18:28:26 +08:00
|
|
|
child.setData(0, Qt.UserRole, key)
|
|
|
|
|
child.setToolTip(0, key)
|
|
|
|
|
child.setToolTip(1, _format_value(value))
|
|
|
|
|
group.addChild(child)
|
|
|
|
|
|
|
|
|
|
def copy_selected_id(self) -> None:
|
|
|
|
|
text = self._selected_id_text()
|
|
|
|
|
if not text:
|
|
|
|
|
self.statusBar().showMessage("没有可复制的对象 ID")
|
|
|
|
|
return
|
|
|
|
|
QApplication.clipboard().setText(text)
|
|
|
|
|
self.statusBar().showMessage(f"已复制 {text}")
|
|
|
|
|
|
|
|
|
|
def copy_pick_position(self) -> None:
|
|
|
|
|
pick_position = self.selected_pick_position
|
|
|
|
|
if pick_position is None and "pick_position" in self.current_info_values:
|
|
|
|
|
value = self.current_info_values["pick_position"]
|
|
|
|
|
if isinstance(value, tuple) and len(value) == 3:
|
|
|
|
|
pick_position = (float(value[0]), float(value[1]), float(value[2]))
|
|
|
|
|
if pick_position is None:
|
|
|
|
|
self.statusBar().showMessage("没有可复制的拾取坐标")
|
|
|
|
|
return
|
|
|
|
|
text = _format_value(pick_position)
|
|
|
|
|
QApplication.clipboard().setText(text)
|
|
|
|
|
self.statusBar().showMessage(f"已复制拾取坐标 {text}")
|
|
|
|
|
|
|
|
|
|
def copy_current_info(self) -> None:
|
|
|
|
|
if not self.current_info_text:
|
|
|
|
|
self.statusBar().showMessage("没有可复制的信息")
|
|
|
|
|
return
|
|
|
|
|
QApplication.clipboard().setText(self.current_info_text)
|
|
|
|
|
self.statusBar().showMessage("已复制当前信息")
|
|
|
|
|
|
|
|
|
|
def set_measure_point(self, label: str) -> None:
|
|
|
|
|
point, source = self._current_measure_point()
|
|
|
|
|
if point is None:
|
|
|
|
|
QMessageBox.information(self, "没有可测量的点", "请先在模型中选择一个对象,最好用鼠标点击到具体位置。")
|
|
|
|
|
self.statusBar().showMessage("没有可设为测量点的坐标")
|
|
|
|
|
return
|
|
|
|
|
if label.upper() == "A":
|
|
|
|
|
self.measure_point_a = point
|
|
|
|
|
self.measure_label_a = source
|
|
|
|
|
self.statusBar().showMessage(f"已设置测量点 A: {_format_value(point)}")
|
|
|
|
|
else:
|
|
|
|
|
self.measure_point_b = point
|
|
|
|
|
self.measure_label_b = source
|
|
|
|
|
self.statusBar().showMessage(f"已设置测量点 B: {_format_value(point)}")
|
|
|
|
|
self._refresh_measurement_panel()
|
2026-07-28 14:05:14 +08:00
|
|
|
self._update_action_states()
|
2026-07-27 18:28:26 +08:00
|
|
|
|
|
|
|
|
def clear_measurement(self) -> None:
|
|
|
|
|
self.measure_point_a = None
|
|
|
|
|
self.measure_point_b = None
|
|
|
|
|
self.measure_label_a = ""
|
|
|
|
|
self.measure_label_b = ""
|
|
|
|
|
self._clear_measure_actor()
|
|
|
|
|
self._refresh_measurement_panel()
|
|
|
|
|
self.statusBar().showMessage("已清除测量")
|
2026-07-28 14:05:14 +08:00
|
|
|
self._update_action_states()
|
2026-07-27 18:28:26 +08:00
|
|
|
|
|
|
|
|
def copy_measurement(self) -> None:
|
|
|
|
|
text = self.measure_text.toPlainText() if hasattr(self, "measure_text") else ""
|
|
|
|
|
if not text or "尚未设置" in text:
|
|
|
|
|
self.statusBar().showMessage("没有可复制的测量结果")
|
|
|
|
|
return
|
|
|
|
|
QApplication.clipboard().setText(text)
|
|
|
|
|
self.statusBar().showMessage("已复制测量结果")
|
|
|
|
|
|
|
|
|
|
def _current_measure_point(self) -> tuple[tuple[float, float, float] | None, str]:
|
|
|
|
|
selected_label = self._selected_id_text() or "当前对象"
|
|
|
|
|
if self.selected_pick_position is not None:
|
|
|
|
|
return self._tuple3(self.selected_pick_position), f"{selected_label} 拾取点"
|
|
|
|
|
|
|
|
|
|
for key, readable in (
|
|
|
|
|
("pick_position", "拾取点"),
|
|
|
|
|
("area_center", "面积中心"),
|
|
|
|
|
("length_center", "长度中心"),
|
|
|
|
|
("surface_center", "表面积中心"),
|
|
|
|
|
("center_of_mass", "重心"),
|
|
|
|
|
("center", "中心"),
|
|
|
|
|
("rotation_center", "旋转中心"),
|
|
|
|
|
):
|
|
|
|
|
point = self._tuple3(self.current_info_values.get(key))
|
|
|
|
|
if point is not None:
|
|
|
|
|
return point, f"{selected_label} {readable}"
|
|
|
|
|
|
|
|
|
|
bbox_min = self._tuple3(self.current_info_values.get("bbox_min"))
|
|
|
|
|
bbox_max = self._tuple3(self.current_info_values.get("bbox_max"))
|
|
|
|
|
if bbox_min is not None and bbox_max is not None:
|
|
|
|
|
center = (
|
|
|
|
|
(bbox_min[0] + bbox_max[0]) / 2.0,
|
|
|
|
|
(bbox_min[1] + bbox_max[1]) / 2.0,
|
|
|
|
|
(bbox_min[2] + bbox_max[2]) / 2.0,
|
|
|
|
|
)
|
|
|
|
|
return center, f"{selected_label} 包围盒中心"
|
|
|
|
|
return None, ""
|
|
|
|
|
|
|
|
|
|
def _refresh_measurement_panel(self) -> None:
|
|
|
|
|
if not hasattr(self, "measure_text"):
|
|
|
|
|
return
|
|
|
|
|
lines = ["两点测量"]
|
|
|
|
|
if self.measure_point_a is None:
|
|
|
|
|
lines.append("A: 尚未设置")
|
|
|
|
|
else:
|
|
|
|
|
lines.append(f"A: {_format_value(self.measure_point_a)}")
|
|
|
|
|
if self.measure_label_a:
|
|
|
|
|
lines.append(f" 来源: {self.measure_label_a}")
|
|
|
|
|
if self.measure_point_b is None:
|
|
|
|
|
lines.append("B: 尚未设置")
|
|
|
|
|
else:
|
|
|
|
|
lines.append(f"B: {_format_value(self.measure_point_b)}")
|
|
|
|
|
if self.measure_label_b:
|
|
|
|
|
lines.append(f" 来源: {self.measure_label_b}")
|
|
|
|
|
|
|
|
|
|
if self.measure_point_a is not None and self.measure_point_b is not None:
|
|
|
|
|
dx = self.measure_point_b[0] - self.measure_point_a[0]
|
|
|
|
|
dy = self.measure_point_b[1] - self.measure_point_a[1]
|
|
|
|
|
dz = self.measure_point_b[2] - self.measure_point_a[2]
|
|
|
|
|
distance = math.sqrt(dx * dx + dy * dy + dz * dz)
|
|
|
|
|
lines.extend(
|
|
|
|
|
[
|
|
|
|
|
f"距离: {_format_value(distance)}",
|
|
|
|
|
f"ΔX/ΔY/ΔZ: {_format_value((dx, dy, dz))}",
|
|
|
|
|
]
|
|
|
|
|
)
|
|
|
|
|
else:
|
|
|
|
|
lines.append("距离: 需要同时设置 A 和 B")
|
|
|
|
|
self.measure_text.setPlainText("\n".join(lines))
|
|
|
|
|
self._update_measure_actor()
|
|
|
|
|
|
|
|
|
|
def _update_measure_actor(self) -> None:
|
|
|
|
|
self._clear_measure_actor(render=False)
|
|
|
|
|
if self.measure_point_a is None or self.measure_point_b is None:
|
|
|
|
|
return
|
|
|
|
|
if not hasattr(self, "renderer") or not hasattr(self, "render_window"):
|
|
|
|
|
return
|
|
|
|
|
line = vtk.vtkLineSource()
|
|
|
|
|
line.SetPoint1(*self.measure_point_a)
|
|
|
|
|
line.SetPoint2(*self.measure_point_b)
|
|
|
|
|
mapper = vtk.vtkPolyDataMapper()
|
|
|
|
|
mapper.SetInputConnection(line.GetOutputPort())
|
|
|
|
|
actor = vtk.vtkActor()
|
|
|
|
|
actor.SetMapper(mapper)
|
|
|
|
|
actor.GetProperty().SetColor(0.1, 0.95, 1.0)
|
|
|
|
|
actor.GetProperty().SetLineWidth(4)
|
|
|
|
|
actor.GetProperty().SetAmbient(0.9)
|
|
|
|
|
actor.PickableOff()
|
|
|
|
|
self.measure_actor = actor
|
|
|
|
|
self.renderer.AddActor(actor)
|
|
|
|
|
self.render_window.Render()
|
|
|
|
|
|
|
|
|
|
def _clear_measure_actor(self, render: bool = True) -> None:
|
|
|
|
|
actor = getattr(self, "measure_actor", None)
|
|
|
|
|
if actor is not None and hasattr(self, "renderer"):
|
|
|
|
|
try:
|
|
|
|
|
self.renderer.RemoveActor(actor)
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
self.measure_actor = None
|
|
|
|
|
if render and hasattr(self, "render_window"):
|
|
|
|
|
self.render_window.Render()
|
|
|
|
|
|
|
|
|
|
def _tuple3(self, value: object) -> tuple[float, float, float] | None:
|
|
|
|
|
if isinstance(value, (tuple, list)) and len(value) == 3:
|
|
|
|
|
try:
|
|
|
|
|
return (float(value[0]), float(value[1]), float(value[2]))
|
|
|
|
|
except (TypeError, ValueError):
|
|
|
|
|
return None
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
def _selected_id_text(self) -> str:
|
|
|
|
|
if self.selected_kind == "part" and self.selected_part_id is not None:
|
|
|
|
|
return f"part {self.selected_part_id}"
|
|
|
|
|
if self.selected_kind == "solid" and self.selected_solid_id is not None:
|
|
|
|
|
return f"solid {self.selected_solid_id}"
|
|
|
|
|
if self.selected_kind in {"face", "feature"} and self.selected_face_id is not None:
|
|
|
|
|
return f"face {self.selected_face_id}"
|
|
|
|
|
if self.selected_kind == "edge" and self.selected_edge_id is not None:
|
|
|
|
|
return f"edge {self.selected_edge_id}"
|
|
|
|
|
for kind, key in (("face", "face_id"), ("edge", "edge_id"), ("solid", "solid_id"), ("part", "part_id")):
|
|
|
|
|
if key in self.current_info_values:
|
|
|
|
|
return f"{kind} {self.current_info_values[key]}"
|
|
|
|
|
return ""
|
|
|
|
|
|