2026-07-27 18:28:26 +08:00
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
|
|
from datetime import datetime
|
|
|
|
|
|
import json
|
|
|
|
|
|
import math
|
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
|
|
|
|
|
|
import vtk
|
|
|
|
|
|
from PySide6.QtCore import QEvent, 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, LoadWorker, ScanWorker
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-28 18:30:58 +08:00
|
|
|
|
VIEW_BACKGROUND_COLOR = (226 / 255.0, 238 / 255.0, 247 / 255.0)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-30 17:54:01 +08:00
|
|
|
|
def _prepare_static_mapper(mapper) -> None:
|
|
|
|
|
|
"""Hint that displayed STEP geometry is static while the camera moves."""
|
|
|
|
|
|
if hasattr(mapper, "ScalarVisibilityOff"):
|
|
|
|
|
|
mapper.ScalarVisibilityOff()
|
|
|
|
|
|
if hasattr(mapper, "StaticOn"):
|
|
|
|
|
|
mapper.StaticOn()
|
|
|
|
|
|
elif hasattr(mapper, "SetStatic"):
|
|
|
|
|
|
mapper.SetStatic(True)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _safe_int_or_none(value: object) -> int | None:
|
|
|
|
|
|
try:
|
|
|
|
|
|
return int(value)
|
|
|
|
|
|
except (TypeError, ValueError):
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-27 18:28:26 +08:00
|
|
|
|
class WindowCoreMixin:
|
2026-07-28 18:30:58 +08:00
|
|
|
|
@Slot(object)
|
|
|
|
|
|
def _run_ui_task(self, callback) -> None:
|
|
|
|
|
|
callback()
|
|
|
|
|
|
|
2026-08-04 09:35:39 +08:00
|
|
|
|
def showEvent(self, event) -> None:
|
|
|
|
|
|
super().showEvent(event)
|
|
|
|
|
|
if getattr(self, "first_show_handled", False):
|
|
|
|
|
|
return
|
|
|
|
|
|
self.first_show_handled = True
|
|
|
|
|
|
QTimer.singleShot(0, self._after_first_show)
|
|
|
|
|
|
|
|
|
|
|
|
@Slot()
|
|
|
|
|
|
def _after_first_show(self) -> None:
|
|
|
|
|
|
self._ensure_vtk_interactor_started()
|
|
|
|
|
|
if getattr(self, "auto_load_on_show", False):
|
|
|
|
|
|
self.auto_load_on_show = False
|
|
|
|
|
|
self.load_step(self.step_path, background=True)
|
|
|
|
|
|
|
|
|
|
|
|
def _ensure_vtk_interactor_started(self) -> None:
|
|
|
|
|
|
if getattr(self, "vtk_interactor_started", False):
|
|
|
|
|
|
return
|
|
|
|
|
|
self.vtk_interactor_started = True
|
|
|
|
|
|
try:
|
|
|
|
|
|
self.vtk_widget.Initialize()
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
try:
|
|
|
|
|
|
self.interactor.Initialize()
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|
|
|
|
|
|
try:
|
|
|
|
|
|
self.vtk_widget.Start()
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|
|
|
|
|
|
try:
|
|
|
|
|
|
self.render_window.Render()
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|
|
|
|
|
|
|
2026-07-28 18:30:58 +08:00
|
|
|
|
def _is_ui_thread(self) -> bool:
|
|
|
|
|
|
return QThread.currentThread() == self.thread()
|
|
|
|
|
|
|
|
|
|
|
|
def _invoke_on_ui_thread(self, callback) -> None:
|
|
|
|
|
|
if self._is_ui_thread():
|
|
|
|
|
|
callback()
|
|
|
|
|
|
return
|
|
|
|
|
|
if hasattr(self, "ui_task_requested"):
|
|
|
|
|
|
self.ui_task_requested.emit(callback)
|
|
|
|
|
|
|
2026-07-27 18:28:26 +08:00
|
|
|
|
def eventFilter(self, watched, event):
|
|
|
|
|
|
if watched is getattr(self, "vtk_widget", None):
|
|
|
|
|
|
event_type = event.type()
|
|
|
|
|
|
if event_type == QEvent.Type.MouseButtonRelease:
|
2026-07-29 15:43:28 +08:00
|
|
|
|
if event.button() == Qt.MouseButton.LeftButton:
|
|
|
|
|
|
x, y = self._vtk_position_from_qt_event(event)
|
|
|
|
|
|
self.skip_next_vtk_left_release = True
|
|
|
|
|
|
self._handle_left_button_release(x, y)
|
2026-07-27 18:28:26 +08:00
|
|
|
|
self.pointer_button_down = bool(event.buttons() != Qt.MouseButton.NoButton)
|
|
|
|
|
|
elif event_type == QEvent.Type.MouseButtonPress:
|
2026-07-29 15:43:28 +08:00
|
|
|
|
if event.button() == Qt.MouseButton.LeftButton:
|
|
|
|
|
|
x, y = self._vtk_position_from_qt_event(event)
|
|
|
|
|
|
self.skip_next_vtk_left_press = True
|
|
|
|
|
|
self._handle_left_button_press(x, y)
|
2026-07-27 18:28:26 +08:00
|
|
|
|
self.pointer_button_down = True
|
|
|
|
|
|
elif event_type == QEvent.Type.MouseMove:
|
|
|
|
|
|
self.pointer_button_down = bool(event.buttons() != Qt.MouseButton.NoButton)
|
2026-07-29 15:43:28 +08:00
|
|
|
|
if event.buttons() & Qt.MouseButton.LeftButton:
|
|
|
|
|
|
x, y = self._vtk_position_from_qt_event(event)
|
|
|
|
|
|
self._update_left_button_drag_state(x, y)
|
2026-07-27 18:28:26 +08:00
|
|
|
|
if not self.pointer_button_down:
|
|
|
|
|
|
self._queue_hover_from_qt_event(event)
|
2026-07-29 15:43:28 +08:00
|
|
|
|
elif event_type == QEvent.Type.Leave:
|
|
|
|
|
|
self._cancel_hover_tracking(render=True)
|
2026-07-27 18:28:26 +08:00
|
|
|
|
return super().eventFilter(watched, event)
|
|
|
|
|
|
|
2026-07-29 15:43:28 +08:00
|
|
|
|
def _vtk_position_from_qt_event(self, event) -> tuple[int, int]:
|
|
|
|
|
|
position = event.position() if hasattr(event, "position") else event.pos()
|
|
|
|
|
|
scale = self.vtk_widget._getPixelRatio() if hasattr(self.vtk_widget, "_getPixelRatio") else 1.0
|
|
|
|
|
|
x = int(round(float(position.x()) * scale))
|
|
|
|
|
|
y = int(round((float(self.vtk_widget.height()) - float(position.y()) - 1.0) * scale))
|
|
|
|
|
|
return x, y
|
|
|
|
|
|
|
2026-07-27 18:28:26 +08:00
|
|
|
|
def _build_vtk(self) -> None:
|
|
|
|
|
|
self.renderer = vtk.vtkRenderer()
|
2026-07-28 18:30:58 +08:00
|
|
|
|
self.renderer.SetBackground(*VIEW_BACKGROUND_COLOR)
|
2026-07-27 18:28:26 +08:00
|
|
|
|
self.render_window = self.vtk_widget.GetRenderWindow()
|
2026-08-04 09:35:39 +08:00
|
|
|
|
if hasattr(self.render_window, "SetMultiSamples"):
|
|
|
|
|
|
self.render_window.SetMultiSamples(0)
|
2026-07-27 18:28:26 +08:00
|
|
|
|
self.render_window.AddRenderer(self.renderer)
|
2026-08-04 09:35:39 +08:00
|
|
|
|
if hasattr(self.renderer, "UseFXAAOff"):
|
|
|
|
|
|
self.renderer.UseFXAAOff()
|
2026-07-27 18:28:26 +08:00
|
|
|
|
|
|
|
|
|
|
self.interactor = self.render_window.GetInteractor()
|
|
|
|
|
|
self.interactor.SetInteractorStyle(vtk.vtkInteractorStyleTrackballCamera())
|
|
|
|
|
|
self.picker = vtk.vtkCellPicker()
|
|
|
|
|
|
self.picker.SetTolerance(0.003)
|
2026-07-29 15:43:28 +08:00
|
|
|
|
self.interactor.AddObserver("LeftButtonPressEvent", self.on_left_button_press)
|
|
|
|
|
|
self.interactor.AddObserver("LeftButtonReleaseEvent", self.on_left_button_release)
|
2026-07-27 18:28:26 +08:00
|
|
|
|
self.interactor.AddObserver("MiddleButtonPressEvent", self.on_pointer_button_press)
|
|
|
|
|
|
self.interactor.AddObserver("MiddleButtonReleaseEvent", self.on_pointer_button_release)
|
|
|
|
|
|
self.interactor.AddObserver("RightButtonPressEvent", self.on_pointer_button_press)
|
|
|
|
|
|
self.interactor.AddObserver("RightButtonReleaseEvent", self.on_pointer_button_release)
|
2026-08-04 09:35:39 +08:00
|
|
|
|
# Qt mouse tracking already drives hover. Avoid routing every VTK
|
|
|
|
|
|
# camera-move event through Python; that made rotation feel sticky on
|
|
|
|
|
|
# large STEP meshes.
|
2026-07-29 15:43:28 +08:00
|
|
|
|
self.interactor.AddObserver("StartInteractionEvent", self.on_camera_interaction_start)
|
|
|
|
|
|
self.interactor.AddObserver("EndInteractionEvent", self.on_camera_interaction_end)
|
|
|
|
|
|
if hasattr(self.interactor, "SetDesiredUpdateRate"):
|
|
|
|
|
|
self.interactor.SetDesiredUpdateRate(24.0)
|
|
|
|
|
|
if hasattr(self.interactor, "SetStillUpdateRate"):
|
|
|
|
|
|
self.interactor.SetStillUpdateRate(0.2)
|
2026-07-27 18:28:26 +08:00
|
|
|
|
|
|
|
|
|
|
light = vtk.vtkLight()
|
2026-07-28 14:05:14 +08:00
|
|
|
|
light.SetLightTypeToHeadlight()
|
2026-07-27 18:28:26 +08:00
|
|
|
|
light.SetPosition(1, 1, 1)
|
|
|
|
|
|
light.SetIntensity(0.9)
|
|
|
|
|
|
self.renderer.AddLight(light)
|
|
|
|
|
|
|
2026-08-04 09:35:39 +08:00
|
|
|
|
fill_light = vtk.vtkLight()
|
|
|
|
|
|
fill_light.SetLightTypeToCameraLight()
|
|
|
|
|
|
fill_light.SetPosition(-1, -1, 1)
|
|
|
|
|
|
fill_light.SetIntensity(0.28)
|
|
|
|
|
|
self.renderer.AddLight(fill_light)
|
|
|
|
|
|
|
2026-07-27 18:28:26 +08:00
|
|
|
|
self.interactor.Initialize()
|
|
|
|
|
|
|
2026-07-29 15:43:28 +08:00
|
|
|
|
def on_camera_interaction_start(self, _obj, _event) -> None:
|
|
|
|
|
|
if not self._is_ui_thread():
|
|
|
|
|
|
self._invoke_on_ui_thread(self._begin_camera_interaction)
|
|
|
|
|
|
return
|
|
|
|
|
|
self._begin_camera_interaction()
|
|
|
|
|
|
|
|
|
|
|
|
def on_camera_interaction_end(self, _obj, _event) -> None:
|
|
|
|
|
|
if not self._is_ui_thread():
|
|
|
|
|
|
self._invoke_on_ui_thread(self._end_camera_interaction)
|
|
|
|
|
|
return
|
|
|
|
|
|
self._end_camera_interaction()
|
|
|
|
|
|
|
|
|
|
|
|
def _begin_camera_interaction(self) -> None:
|
|
|
|
|
|
if getattr(self, "camera_interaction_active", False):
|
|
|
|
|
|
return
|
|
|
|
|
|
self.camera_interaction_active = True
|
|
|
|
|
|
self._cancel_hover_tracking(render=False)
|
|
|
|
|
|
|
|
|
|
|
|
def _end_camera_interaction(self) -> None:
|
|
|
|
|
|
if not getattr(self, "camera_interaction_active", False):
|
|
|
|
|
|
return
|
|
|
|
|
|
self.camera_interaction_active = False
|
2026-08-04 09:35:39 +08:00
|
|
|
|
self.last_camera_interaction_ended_at = datetime.now()
|
|
|
|
|
|
self.pending_hover_position = None
|
|
|
|
|
|
self.last_hover_pick_position = None
|
|
|
|
|
|
|
|
|
|
|
|
def _hover_suppressed_after_camera(self) -> bool:
|
|
|
|
|
|
ended_at = getattr(self, "last_camera_interaction_ended_at", None)
|
|
|
|
|
|
if ended_at is None:
|
|
|
|
|
|
return False
|
|
|
|
|
|
cooldown_ms = int(getattr(self, "hover_after_camera_cooldown_ms", 0) or 0)
|
|
|
|
|
|
if cooldown_ms <= 0:
|
|
|
|
|
|
return False
|
|
|
|
|
|
elapsed_ms = (datetime.now() - ended_at).total_seconds() * 1000.0
|
|
|
|
|
|
return elapsed_ms < cooldown_ms
|
2026-07-29 15:43:28 +08:00
|
|
|
|
|
2026-07-27 18:28:26 +08:00
|
|
|
|
def closeEvent(self, event) -> None:
|
2026-07-28 14:05:14 +08:00
|
|
|
|
load_thread_running = bool(
|
|
|
|
|
|
(self.load_thread is not None and self.load_thread.isRunning())
|
|
|
|
|
|
or (self.load_refine_thread is not None and self.load_refine_thread.isRunning())
|
|
|
|
|
|
)
|
|
|
|
|
|
if self.load_in_progress or load_thread_running:
|
2026-07-27 18:28:26 +08:00
|
|
|
|
self.statusBar().showMessage("STEP background loading is still running.")
|
|
|
|
|
|
event.ignore()
|
|
|
|
|
|
return
|
2026-07-28 14:05:14 +08:00
|
|
|
|
edit_thread_running = bool(self.edit_thread is not None and self.edit_thread.isRunning())
|
|
|
|
|
|
if self.operation_in_progress or edit_thread_running:
|
2026-08-04 18:15:29 +08:00
|
|
|
|
if hasattr(self, "_cancel_active_edit_for_close") and self._cancel_active_edit_for_close():
|
|
|
|
|
|
event.ignore()
|
|
|
|
|
|
return
|
2026-07-27 18:28:26 +08:00
|
|
|
|
self.statusBar().showMessage("后台编辑正在计算,请等待当前操作完成后再关闭窗口。")
|
|
|
|
|
|
event.ignore()
|
|
|
|
|
|
return
|
2026-07-28 14:05:14 +08:00
|
|
|
|
scan_thread_running = bool(self.scan_thread is not None and self.scan_thread.isRunning())
|
|
|
|
|
|
if self.scan_in_progress or scan_thread_running:
|
|
|
|
|
|
self.statusBar().showMessage("扫描正在进行,请等待扫描完成后再关闭窗口。")
|
2026-07-27 18:28:26 +08:00
|
|
|
|
event.ignore()
|
|
|
|
|
|
return
|
|
|
|
|
|
super().closeEvent(event)
|
|
|
|
|
|
|
2026-07-28 14:05:14 +08:00
|
|
|
|
def _request_thread_quit(self, thread: QThread | None) -> None:
|
|
|
|
|
|
if thread is not None and thread.isRunning():
|
|
|
|
|
|
thread.quit()
|
|
|
|
|
|
|
2026-07-27 18:28:26 +08:00
|
|
|
|
def load_step(self, path: str | Path, *, background: bool = True) -> None:
|
|
|
|
|
|
self._load_step_background_or_sync(path, background=background)
|
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
|
def _load_step_result(
|
|
|
|
|
|
path: Path,
|
|
|
|
|
|
deflection: float,
|
|
|
|
|
|
show_internal_edges: bool,
|
|
|
|
|
|
build_polydata: bool = True,
|
|
|
|
|
|
) -> dict[str, object]:
|
|
|
|
|
|
new_model = StepModel.load(path)
|
|
|
|
|
|
stats = new_model.stats()
|
|
|
|
|
|
result = {
|
|
|
|
|
|
"path": path,
|
|
|
|
|
|
"model": new_model,
|
|
|
|
|
|
"stats": stats,
|
|
|
|
|
|
"deflection": deflection,
|
|
|
|
|
|
"show_internal_edges": show_internal_edges,
|
|
|
|
|
|
}
|
|
|
|
|
|
if build_polydata:
|
|
|
|
|
|
result["model_polydata"] = new_model.build_face_polydata(deflection=deflection)
|
|
|
|
|
|
result["edge_polydata"] = new_model.build_edge_polydata(
|
|
|
|
|
|
deflection=deflection,
|
|
|
|
|
|
show_same_domain_internal_edges=show_internal_edges,
|
|
|
|
|
|
)
|
|
|
|
|
|
return result
|
|
|
|
|
|
|
|
|
|
|
|
def _load_step_background_or_sync(self, path: str | Path, *, background: bool) -> None:
|
|
|
|
|
|
if self.load_in_progress:
|
|
|
|
|
|
self.statusBar().showMessage("STEP background loading is already running.")
|
|
|
|
|
|
return
|
|
|
|
|
|
new_path = Path(path)
|
|
|
|
|
|
if not background:
|
2026-07-28 14:05:14 +08:00
|
|
|
|
self._load_step_sync(
|
|
|
|
|
|
new_path,
|
|
|
|
|
|
deflection=0.8,
|
|
|
|
|
|
show_internal_edges=self._show_same_domain_internal_edges(),
|
|
|
|
|
|
status_prefix="Loading",
|
|
|
|
|
|
)
|
2026-07-27 18:28:26 +08:00
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
|
|
self.load_in_progress = True
|
|
|
|
|
|
self.pending_load_path = new_path
|
2026-08-04 09:35:39 +08:00
|
|
|
|
self.statusBar().showMessage(f"正在读取 STEP 可视化网格:{new_path.name}...")
|
2026-07-27 18:28:26 +08:00
|
|
|
|
self._clear_hover(render=True)
|
|
|
|
|
|
self._update_action_states()
|
2026-07-28 14:05:14 +08:00
|
|
|
|
QTimer.singleShot(0, lambda path=new_path: self._run_deferred_initial_load(path))
|
2026-07-27 18:28:26 +08:00
|
|
|
|
|
2026-08-04 09:35:39 +08:00
|
|
|
|
@Slot(object)
|
|
|
|
|
|
def _run_packaged_initial_load(self, expected_path: Path) -> None:
|
|
|
|
|
|
if self.pending_load_path != expected_path:
|
|
|
|
|
|
return
|
|
|
|
|
|
try:
|
|
|
|
|
|
self._load_step_sync(
|
|
|
|
|
|
expected_path,
|
|
|
|
|
|
deflection=self.preview_load_deflection,
|
|
|
|
|
|
show_internal_edges=self._show_same_domain_internal_edges(),
|
|
|
|
|
|
status_prefix="读取 STEP 可视化网格",
|
|
|
|
|
|
)
|
|
|
|
|
|
finally:
|
|
|
|
|
|
self._end_load_task()
|
|
|
|
|
|
|
|
|
|
|
|
@Slot(object)
|
|
|
|
|
|
def _start_initial_load_worker(self, expected_path: Path) -> None:
|
|
|
|
|
|
# Qt/VTK/OCCT visualization objects are not safe to construct from this
|
|
|
|
|
|
# worker path in the current PySide build. Keep the old entry point as a
|
|
|
|
|
|
# main-thread fallback so stale signal paths cannot crash the process.
|
|
|
|
|
|
self._run_deferred_initial_load(expected_path)
|
|
|
|
|
|
|
2026-07-28 14:05:14 +08:00
|
|
|
|
def _load_step_sync(
|
|
|
|
|
|
self,
|
|
|
|
|
|
new_path: Path,
|
|
|
|
|
|
*,
|
|
|
|
|
|
deflection: float,
|
|
|
|
|
|
show_internal_edges: bool,
|
|
|
|
|
|
status_prefix: str,
|
|
|
|
|
|
) -> bool:
|
|
|
|
|
|
self.statusBar().showMessage(f"{status_prefix} {new_path.name}...")
|
|
|
|
|
|
QApplication.setOverrideCursor(Qt.CursorShape.WaitCursor)
|
|
|
|
|
|
QApplication.processEvents()
|
|
|
|
|
|
try:
|
|
|
|
|
|
result = self._load_step_result(
|
2026-07-27 18:28:26 +08:00
|
|
|
|
new_path,
|
2026-07-28 14:05:14 +08:00
|
|
|
|
deflection=deflection,
|
|
|
|
|
|
show_internal_edges=show_internal_edges,
|
|
|
|
|
|
)
|
|
|
|
|
|
result["display"] = "ready"
|
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
|
QMessageBox.critical(self, "Load failed", str(exc))
|
|
|
|
|
|
self.statusBar().showMessage("STEP load failed; current model was left unchanged.")
|
|
|
|
|
|
return False
|
|
|
|
|
|
finally:
|
|
|
|
|
|
QApplication.restoreOverrideCursor()
|
|
|
|
|
|
self._apply_loaded_model_result(result, reset_camera=True)
|
|
|
|
|
|
self.statusBar().showMessage(f"Loaded {self.step_path.name}")
|
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
|
|
@Slot(object)
|
|
|
|
|
|
def _run_deferred_initial_load(self, expected_path: Path) -> None:
|
|
|
|
|
|
if self.pending_load_path != expected_path:
|
|
|
|
|
|
return
|
|
|
|
|
|
try:
|
|
|
|
|
|
self._load_step_sync(
|
|
|
|
|
|
expected_path,
|
2026-08-04 09:35:39 +08:00
|
|
|
|
deflection=self.preview_load_deflection,
|
|
|
|
|
|
show_internal_edges=self._show_same_domain_internal_edges(),
|
|
|
|
|
|
status_prefix="读取 STEP 可视化网格",
|
2026-07-27 18:28:26 +08:00
|
|
|
|
)
|
2026-07-28 14:05:14 +08:00
|
|
|
|
finally:
|
|
|
|
|
|
self._end_load_task()
|
2026-07-27 18:28:26 +08:00
|
|
|
|
|
2026-08-04 09:35:39 +08:00
|
|
|
|
@staticmethod
|
|
|
|
|
|
def _copy_polydata_for_ui_thread(polydata: object) -> object:
|
|
|
|
|
|
if polydata is None:
|
|
|
|
|
|
return None
|
|
|
|
|
|
copied = vtk.vtkPolyData()
|
|
|
|
|
|
copied.DeepCopy(polydata)
|
|
|
|
|
|
return copied
|
|
|
|
|
|
|
|
|
|
|
|
def _detach_worker_polydata_result(self, result: dict[str, object]) -> dict[str, object]:
|
|
|
|
|
|
detached = dict(result)
|
|
|
|
|
|
if detached.get("model_polydata") is not None:
|
|
|
|
|
|
detached["model_polydata"] = self._copy_polydata_for_ui_thread(detached["model_polydata"])
|
|
|
|
|
|
if detached.get("edge_polydata") is not None:
|
|
|
|
|
|
detached["edge_polydata"] = self._copy_polydata_for_ui_thread(detached["edge_polydata"])
|
|
|
|
|
|
return detached
|
|
|
|
|
|
|
2026-07-27 18:28:26 +08:00
|
|
|
|
def _apply_loaded_model_result(self, result: dict[str, object], *, reset_camera: bool) -> None:
|
|
|
|
|
|
new_path = Path(result["path"])
|
|
|
|
|
|
stats = result["stats"]
|
|
|
|
|
|
self.model = result["model"]
|
|
|
|
|
|
self.step_path = new_path
|
|
|
|
|
|
self._clear_history()
|
|
|
|
|
|
if hasattr(self, "measure_text"):
|
|
|
|
|
|
self.clear_measurement()
|
2026-07-28 18:30:58 +08:00
|
|
|
|
path_text = str(self.step_path)
|
|
|
|
|
|
self.path_label.setText(path_text)
|
|
|
|
|
|
self.path_label.setToolTip(path_text)
|
|
|
|
|
|
self.path_label.setCursorPosition(0)
|
2026-07-27 18:28:26 +08:00
|
|
|
|
self._populate_part_tree()
|
|
|
|
|
|
self._reset_selection()
|
|
|
|
|
|
model_polydata = result.get("model_polydata")
|
|
|
|
|
|
edge_polydata = result.get("edge_polydata")
|
|
|
|
|
|
if model_polydata is None or edge_polydata is None:
|
|
|
|
|
|
deflection = float(result.get("deflection", 0.8))
|
|
|
|
|
|
show_internal_edges = bool(result.get("show_internal_edges", self._show_same_domain_internal_edges()))
|
|
|
|
|
|
model_polydata = self.model.build_face_polydata(deflection=deflection)
|
|
|
|
|
|
edge_polydata = self.model.build_edge_polydata(
|
|
|
|
|
|
deflection=deflection,
|
|
|
|
|
|
show_same_domain_internal_edges=show_internal_edges,
|
|
|
|
|
|
)
|
|
|
|
|
|
self._rebuild_scene_from_polydata(
|
|
|
|
|
|
model_polydata,
|
|
|
|
|
|
edge_polydata,
|
|
|
|
|
|
reset_camera=reset_camera,
|
|
|
|
|
|
)
|
|
|
|
|
|
self._clear_editable_candidates()
|
|
|
|
|
|
self._clear_cylinder_candidates()
|
|
|
|
|
|
self.set_info(
|
|
|
|
|
|
{
|
|
|
|
|
|
"file": str(self.step_path),
|
|
|
|
|
|
"parts": stats.parts,
|
|
|
|
|
|
"solids": stats.solids,
|
|
|
|
|
|
"faces": stats.faces,
|
|
|
|
|
|
"edges": stats.edges,
|
|
|
|
|
|
"vertices": stats.vertices,
|
2026-07-28 14:05:14 +08:00
|
|
|
|
"display": result.get("display", "quick preview" if self.load_in_progress else "ready"),
|
2026-07-27 18:28:26 +08:00
|
|
|
|
}
|
|
|
|
|
|
)
|
|
|
|
|
|
self._update_action_states()
|
|
|
|
|
|
|
2026-07-28 14:05:14 +08:00
|
|
|
|
@Slot(object)
|
2026-07-27 18:28:26 +08:00
|
|
|
|
def _finish_initial_load(self, result: object) -> None:
|
|
|
|
|
|
try:
|
|
|
|
|
|
if not isinstance(result, dict):
|
|
|
|
|
|
raise RuntimeError("Load task returned an unexpected result.")
|
2026-08-04 09:35:39 +08:00
|
|
|
|
result = self._detach_worker_polydata_result(result)
|
2026-07-27 18:28:26 +08:00
|
|
|
|
self._apply_loaded_model_result(result, reset_camera=True)
|
2026-08-04 09:35:39 +08:00
|
|
|
|
self.load_in_progress = False
|
|
|
|
|
|
self.pending_load_path = None
|
|
|
|
|
|
self._update_action_states()
|
|
|
|
|
|
if float(getattr(self, "preview_load_deflection", 0.0) or 0.0) > float(
|
|
|
|
|
|
getattr(self, "initial_load_deflection", 0.0) or 0.0
|
|
|
|
|
|
):
|
|
|
|
|
|
self.statusBar().showMessage(f"已快速显示模型:{self.step_path.name},正在后台细化显示...")
|
|
|
|
|
|
self._start_load_refine(result)
|
|
|
|
|
|
else:
|
|
|
|
|
|
self.statusBar().showMessage(f"Loaded {self.step_path.name}")
|
2026-07-27 18:28:26 +08:00
|
|
|
|
except Exception as exc:
|
|
|
|
|
|
self._end_load_task()
|
|
|
|
|
|
QMessageBox.critical(self, "Load failed", str(exc))
|
|
|
|
|
|
self.statusBar().showMessage("STEP load failed; current model was left unchanged.")
|
2026-07-28 14:05:14 +08:00
|
|
|
|
finally:
|
|
|
|
|
|
self._request_thread_quit(self.load_thread)
|
2026-07-27 18:28:26 +08:00
|
|
|
|
|
2026-07-28 14:05:14 +08:00
|
|
|
|
@Slot(str)
|
2026-07-27 18:28:26 +08:00
|
|
|
|
def _fail_initial_load(self, message: str) -> None:
|
|
|
|
|
|
self._end_load_task()
|
|
|
|
|
|
QMessageBox.critical(self, "Load failed", message)
|
|
|
|
|
|
self.statusBar().showMessage("STEP load failed; current model was left unchanged.")
|
|
|
|
|
|
|
|
|
|
|
|
def _start_load_refine(self, initial_result: dict[str, object]) -> None:
|
2026-08-04 09:35:39 +08:00
|
|
|
|
self.statusBar().showMessage(f"Loaded {self.step_path.name}")
|
2026-07-27 18:28:26 +08:00
|
|
|
|
|
2026-07-28 14:05:14 +08:00
|
|
|
|
@Slot(object)
|
2026-07-27 18:28:26 +08:00
|
|
|
|
def _finish_load_refine(self, result: object) -> None:
|
|
|
|
|
|
try:
|
2026-08-04 09:35:39 +08:00
|
|
|
|
if (
|
|
|
|
|
|
isinstance(result, dict)
|
|
|
|
|
|
and Path(result.get("path", "")) == self.step_path
|
|
|
|
|
|
and not getattr(self, "operation_history", [])
|
|
|
|
|
|
):
|
|
|
|
|
|
self.model = result["model"]
|
|
|
|
|
|
result = self._detach_worker_polydata_result(result)
|
2026-07-27 18:28:26 +08:00
|
|
|
|
self._rebuild_scene_from_polydata(
|
|
|
|
|
|
result["model_polydata"],
|
|
|
|
|
|
result["edge_polydata"],
|
|
|
|
|
|
reset_camera=False,
|
|
|
|
|
|
)
|
|
|
|
|
|
stats = result["stats"]
|
|
|
|
|
|
self.set_info(
|
|
|
|
|
|
{
|
|
|
|
|
|
"file": str(self.step_path),
|
|
|
|
|
|
"parts": stats.parts,
|
|
|
|
|
|
"solids": stats.solids,
|
|
|
|
|
|
"faces": stats.faces,
|
|
|
|
|
|
"edges": stats.edges,
|
|
|
|
|
|
"vertices": stats.vertices,
|
|
|
|
|
|
"display": "ready",
|
|
|
|
|
|
}
|
|
|
|
|
|
)
|
2026-08-04 09:35:39 +08:00
|
|
|
|
self.statusBar().showMessage(f"精细显示已完成:{self.step_path.name}")
|
2026-07-27 18:28:26 +08:00
|
|
|
|
finally:
|
|
|
|
|
|
self._end_load_task()
|
|
|
|
|
|
|
2026-07-28 14:05:14 +08:00
|
|
|
|
@Slot(str)
|
2026-07-27 18:28:26 +08:00
|
|
|
|
def _fail_load_refine(self, message: str) -> None:
|
|
|
|
|
|
self._end_load_task()
|
|
|
|
|
|
self.statusBar().showMessage(f"Quick preview is available; display refinement failed: {message}")
|
|
|
|
|
|
|
|
|
|
|
|
def _end_load_task(self) -> None:
|
|
|
|
|
|
self.load_in_progress = False
|
|
|
|
|
|
self.pending_load_path = None
|
|
|
|
|
|
self._update_action_states()
|
2026-07-28 14:05:14 +08:00
|
|
|
|
self._request_thread_quit(self.load_thread)
|
|
|
|
|
|
self._request_thread_quit(self.load_refine_thread)
|
2026-07-27 18:28:26 +08:00
|
|
|
|
|
|
|
|
|
|
def _forget_load_thread(self) -> None:
|
|
|
|
|
|
self.load_thread = None
|
|
|
|
|
|
self.load_worker = None
|
|
|
|
|
|
|
|
|
|
|
|
def _forget_load_refine_thread(self) -> None:
|
|
|
|
|
|
self.load_refine_thread = None
|
|
|
|
|
|
self.load_refine_worker = None
|
|
|
|
|
|
|
|
|
|
|
|
def open_step(self) -> None:
|
|
|
|
|
|
if self._edit_busy("请等待当前编辑完成后再打开文件。"):
|
|
|
|
|
|
return
|
|
|
|
|
|
path, _ = QFileDialog.getOpenFileName(
|
|
|
|
|
|
self,
|
|
|
|
|
|
"打开 STEP 文件",
|
|
|
|
|
|
str(self.step_path.parent if self.step_path else Path.cwd()),
|
|
|
|
|
|
"STEP 文件 (*.step *.stp);;所有文件 (*.*)",
|
|
|
|
|
|
)
|
|
|
|
|
|
if path:
|
2026-08-04 09:35:39 +08:00
|
|
|
|
self.step_path = Path(path)
|
|
|
|
|
|
if hasattr(self, "path_label"):
|
|
|
|
|
|
path_text = str(self.step_path)
|
|
|
|
|
|
self.path_label.setText(path_text)
|
|
|
|
|
|
self.path_label.setToolTip(path_text)
|
|
|
|
|
|
self.path_label.setCursorPosition(0)
|
|
|
|
|
|
self.statusBar().showMessage(f"已选择 STEP 文件:{self.step_path.name},正在读取模型...")
|
|
|
|
|
|
self._update_action_states()
|
|
|
|
|
|
self._ensure_vtk_interactor_started()
|
|
|
|
|
|
self.load_step(self.step_path, background=True)
|
2026-07-27 18:28:26 +08:00
|
|
|
|
|
|
|
|
|
|
def reload_step(self) -> None:
|
|
|
|
|
|
if self._edit_busy("请等待当前编辑完成后再重新加载。"):
|
|
|
|
|
|
return
|
2026-08-04 09:35:39 +08:00
|
|
|
|
path_text = self.path_label.text().strip() if hasattr(self, "path_label") else ""
|
|
|
|
|
|
path = Path(path_text) if path_text else self.step_path
|
|
|
|
|
|
self._ensure_vtk_interactor_started()
|
|
|
|
|
|
self.load_step(path)
|
2026-07-27 18:28:26 +08:00
|
|
|
|
|
|
|
|
|
|
def _clear_history(self) -> None:
|
|
|
|
|
|
self.undo_stack.clear()
|
|
|
|
|
|
self.redo_stack.clear()
|
|
|
|
|
|
self.operation_history.clear()
|
|
|
|
|
|
self.redo_history.clear()
|
|
|
|
|
|
self.clear_diff_preview(render=False)
|
|
|
|
|
|
if hasattr(self, "history_list"):
|
|
|
|
|
|
self.history_list.clear()
|
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 _refresh_history_list(self) -> None:
|
2026-07-28 18:30:58 +08:00
|
|
|
|
if not hasattr(self, "history_list"):
|
|
|
|
|
|
self._update_action_states()
|
|
|
|
|
|
return
|
2026-07-27 18:28:26 +08:00
|
|
|
|
was_blocked = self.history_list.blockSignals(True)
|
|
|
|
|
|
try:
|
|
|
|
|
|
self.history_list.clear()
|
|
|
|
|
|
for index, entry in enumerate(self.operation_history, start=1):
|
|
|
|
|
|
self.history_list.addItem(f"{index}. {entry.summary}")
|
|
|
|
|
|
finally:
|
|
|
|
|
|
self.history_list.blockSignals(was_blocked)
|
2026-07-28 14:05:14 +08:00
|
|
|
|
self._update_action_states()
|
2026-07-27 18:28:26 +08:00
|
|
|
|
|
|
|
|
|
|
def on_history_row_changed(self, row: int) -> None:
|
|
|
|
|
|
if self._edit_busy("编辑计算中,暂时不能查看历史记录详情。"):
|
|
|
|
|
|
return
|
|
|
|
|
|
if 0 <= row < len(self.operation_history):
|
|
|
|
|
|
record = self.operation_history[row]
|
|
|
|
|
|
locate_message = self._locate_operation_record(record)
|
|
|
|
|
|
diff_message = self._show_operation_diff(record)
|
|
|
|
|
|
detail = record.detail
|
|
|
|
|
|
if locate_message:
|
|
|
|
|
|
detail = f"{detail}\n\n{locate_message}"
|
|
|
|
|
|
if diff_message:
|
|
|
|
|
|
detail = f"{detail}\n\n{diff_message}"
|
|
|
|
|
|
self.set_plain_info(detail)
|
|
|
|
|
|
|
|
|
|
|
|
def export_diff_report(self) -> None:
|
|
|
|
|
|
if self.model is None:
|
|
|
|
|
|
return
|
|
|
|
|
|
if self._edit_busy("编辑计算中,暂时不能导出差异报告。"):
|
|
|
|
|
|
return
|
2026-07-28 18:30:58 +08:00
|
|
|
|
if not hasattr(self, "history_list"):
|
|
|
|
|
|
QMessageBox.information(self, "操作历史未启用", "当前界面没有构建操作历史面板。")
|
|
|
|
|
|
return
|
2026-07-27 18:28:26 +08:00
|
|
|
|
row = self.history_list.currentRow()
|
|
|
|
|
|
if row < 0 or row >= len(self.operation_history):
|
|
|
|
|
|
QMessageBox.information(self, "未选择历史记录", "请先选择一条操作历史。")
|
|
|
|
|
|
return
|
|
|
|
|
|
record = self.operation_history[row]
|
|
|
|
|
|
self._ensure_record_diff_stats(record)
|
|
|
|
|
|
target, _ = QFileDialog.getSaveFileName(
|
|
|
|
|
|
self,
|
|
|
|
|
|
"导出差异报告",
|
|
|
|
|
|
str(self.step_path.parent / f"{self.step_path.stem}_diff_{row + 1}.txt"),
|
|
|
|
|
|
"文本文件 (*.txt);;所有文件 (*.*)",
|
|
|
|
|
|
)
|
|
|
|
|
|
if not target:
|
|
|
|
|
|
return
|
|
|
|
|
|
report = self._diff_report_text(record, row + 1)
|
|
|
|
|
|
try:
|
|
|
|
|
|
Path(target).write_text(report, encoding="utf-8")
|
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
|
QMessageBox.critical(self, "导出失败", str(exc))
|
|
|
|
|
|
self.statusBar().showMessage("差异报告导出失败")
|
|
|
|
|
|
return
|
|
|
|
|
|
self.statusBar().showMessage(f"已导出差异报告 {Path(target).name}")
|
|
|
|
|
|
self.set_plain_info(f"已导出差异报告:{target}\n\n{report}")
|
|
|
|
|
|
|
|
|
|
|
|
def export_operation_history(self) -> None:
|
|
|
|
|
|
if self.model is None:
|
|
|
|
|
|
return
|
|
|
|
|
|
if self._edit_busy("编辑计算中,暂时不能导出编辑历史。"):
|
|
|
|
|
|
return
|
|
|
|
|
|
if not self.operation_history:
|
|
|
|
|
|
QMessageBox.information(self, "没有编辑历史", "当前模型还没有可导出的编辑历史。")
|
|
|
|
|
|
return
|
|
|
|
|
|
target, _ = QFileDialog.getSaveFileName(
|
|
|
|
|
|
self,
|
|
|
|
|
|
"导出编辑历史",
|
|
|
|
|
|
str(self.step_path.parent / f"{self.step_path.stem}_operation_history.json"),
|
|
|
|
|
|
"JSON 文件 (*.json);;所有文件 (*.*)",
|
|
|
|
|
|
)
|
|
|
|
|
|
if not target:
|
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
|
|
def safe_value(value):
|
|
|
|
|
|
if value is None or isinstance(value, (str, int, float, bool)):
|
|
|
|
|
|
return value
|
|
|
|
|
|
if isinstance(value, tuple):
|
|
|
|
|
|
return [safe_value(item) for item in value]
|
|
|
|
|
|
if isinstance(value, list):
|
|
|
|
|
|
return [safe_value(item) for item in value]
|
|
|
|
|
|
if isinstance(value, dict):
|
|
|
|
|
|
return {str(key): safe_value(item) for key, item in value.items()}
|
|
|
|
|
|
return str(value)
|
|
|
|
|
|
|
|
|
|
|
|
records = []
|
|
|
|
|
|
for index, record in enumerate(self.operation_history, start=1):
|
|
|
|
|
|
records.append(
|
|
|
|
|
|
{
|
|
|
|
|
|
"index": index,
|
|
|
|
|
|
"summary": record.summary,
|
|
|
|
|
|
"detail": record.detail,
|
2026-07-28 14:05:14 +08:00
|
|
|
|
"operation_name": record.operation_name,
|
|
|
|
|
|
"target": record.target,
|
|
|
|
|
|
"parameters": safe_value(record.parameters or {}),
|
|
|
|
|
|
"result_message": record.result_message,
|
2026-07-27 18:28:26 +08:00
|
|
|
|
"target_kind": record.target_kind,
|
|
|
|
|
|
"target_id": record.target_id,
|
|
|
|
|
|
"target_logical_id": record.target_logical_id,
|
|
|
|
|
|
"pick_position": safe_value(record.pick_position),
|
|
|
|
|
|
"diff_stats": safe_value(record.diff_stats),
|
|
|
|
|
|
"has_before_snapshot": record.before_snapshot is not None,
|
|
|
|
|
|
"has_after_snapshot": record.after_snapshot is not None,
|
|
|
|
|
|
}
|
|
|
|
|
|
)
|
|
|
|
|
|
payload = {
|
2026-07-28 14:05:14 +08:00
|
|
|
|
"format": "step-editor-operation-history-v2",
|
2026-07-27 18:28:26 +08:00
|
|
|
|
"generated_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
|
|
|
|
|
"source_file": str(self.step_path),
|
|
|
|
|
|
"record_count": len(records),
|
|
|
|
|
|
"records": records,
|
|
|
|
|
|
"note": "STEP 通常不包含原 CAD 参数化建模历史;这里导出的是本软件加载后执行的编辑记录。",
|
|
|
|
|
|
}
|
|
|
|
|
|
try:
|
|
|
|
|
|
Path(target).write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
|
QMessageBox.critical(self, "导出失败", str(exc))
|
|
|
|
|
|
self.statusBar().showMessage("编辑历史导出失败")
|
|
|
|
|
|
return
|
|
|
|
|
|
self.statusBar().showMessage(f"已导出编辑历史 {Path(target).name}")
|
|
|
|
|
|
self.set_plain_info(f"已导出编辑历史:{target}\n\n记录数:{len(records)}")
|
|
|
|
|
|
|
|
|
|
|
|
def _populate_part_tree(self) -> None:
|
2026-07-28 18:30:58 +08:00
|
|
|
|
if not hasattr(self, "part_tree"):
|
|
|
|
|
|
return
|
2026-07-27 18:28:26 +08:00
|
|
|
|
was_blocked = self.part_tree.blockSignals(True)
|
|
|
|
|
|
try:
|
|
|
|
|
|
self.part_tree.clear()
|
|
|
|
|
|
if self.model is None:
|
|
|
|
|
|
return
|
|
|
|
|
|
inserted: dict[int, QTreeWidgetItem] = {}
|
|
|
|
|
|
for part in self.model.parts:
|
|
|
|
|
|
item = QTreeWidgetItem(
|
|
|
|
|
|
[
|
|
|
|
|
|
self._part_tree_part_name(part.id, part.name, part.kind),
|
|
|
|
|
|
self._part_tree_part_detail(part.id, part.kind),
|
|
|
|
|
|
]
|
|
|
|
|
|
)
|
|
|
|
|
|
item.setData(0, PART_TREE_KIND_ROLE, part.kind)
|
|
|
|
|
|
item.setData(0, PART_TREE_ID_ROLE, part.id)
|
|
|
|
|
|
item.setToolTip(0, self._part_tree_part_tooltip(part.id))
|
|
|
|
|
|
item.setToolTip(1, self._part_tree_part_tooltip(part.id))
|
|
|
|
|
|
parent = inserted.get(part.parent_id)
|
|
|
|
|
|
if parent is None:
|
|
|
|
|
|
self.part_tree.addTopLevelItem(item)
|
|
|
|
|
|
else:
|
|
|
|
|
|
parent.addChild(item)
|
|
|
|
|
|
inserted[part.id] = item
|
|
|
|
|
|
|
|
|
|
|
|
for solid_id, (part_id, _solid) in enumerate(self.model.solids):
|
|
|
|
|
|
parent = inserted.get(part_id)
|
|
|
|
|
|
if parent is None:
|
|
|
|
|
|
continue
|
|
|
|
|
|
item = QTreeWidgetItem([f"实体 {solid_id}", self._part_tree_solid_detail(solid_id)])
|
|
|
|
|
|
item.setData(0, PART_TREE_KIND_ROLE, "solid")
|
|
|
|
|
|
item.setData(0, PART_TREE_ID_ROLE, solid_id)
|
|
|
|
|
|
item.setData(0, PART_TREE_PART_ID_ROLE, part_id)
|
|
|
|
|
|
item.setToolTip(0, f"Solid ID: {solid_id}\n所属零件 ID: {part_id}")
|
|
|
|
|
|
item.setToolTip(1, f"Solid ID: {solid_id}\n所属零件 ID: {part_id}")
|
|
|
|
|
|
parent.addChild(item)
|
|
|
|
|
|
|
|
|
|
|
|
self.part_tree.expandAll()
|
|
|
|
|
|
self.part_tree.resizeColumnToContents(0)
|
|
|
|
|
|
self.part_tree.resizeColumnToContents(1)
|
|
|
|
|
|
finally:
|
|
|
|
|
|
self.part_tree.blockSignals(was_blocked)
|
|
|
|
|
|
|
|
|
|
|
|
def _part_tree_part_name(self, part_id: int, name: str, kind: str) -> str:
|
|
|
|
|
|
kind_label = _part_tree_kind_label(kind)
|
|
|
|
|
|
clean_name = str(name).strip()
|
|
|
|
|
|
if clean_name:
|
|
|
|
|
|
return f"{kind_label} {part_id}:{clean_name}"
|
|
|
|
|
|
return f"{kind_label} {part_id}"
|
|
|
|
|
|
|
|
|
|
|
|
def _part_tree_part_detail(self, part_id: int, kind: str) -> str:
|
|
|
|
|
|
if self.model is None:
|
|
|
|
|
|
return _part_tree_kind_label(kind)
|
|
|
|
|
|
try:
|
|
|
|
|
|
info = self.model.part_info(part_id)
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
return _part_tree_kind_label(kind)
|
|
|
|
|
|
return (
|
|
|
|
|
|
f"{_part_tree_kind_label(kind)} | "
|
|
|
|
|
|
f"实体 {info.get('solids', 0)} 个 | "
|
|
|
|
|
|
f"面 {info.get('faces', 0)} 个 | "
|
|
|
|
|
|
f"边 {info.get('edges', 0)} 条"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
def _part_tree_part_tooltip(self, part_id: int) -> str:
|
|
|
|
|
|
if self.model is None:
|
2026-07-28 18:30:58 +08:00
|
|
|
|
return f"零件 ID: {part_id}"
|
2026-07-27 18:28:26 +08:00
|
|
|
|
try:
|
|
|
|
|
|
info = self.model.part_info(part_id)
|
|
|
|
|
|
except Exception:
|
2026-07-28 18:30:58 +08:00
|
|
|
|
return f"零件 ID: {part_id}"
|
2026-07-27 18:28:26 +08:00
|
|
|
|
lines = [
|
|
|
|
|
|
f"ID: {part_id}",
|
|
|
|
|
|
f"类型: {_part_tree_kind_label(str(info.get('kind', '')))}",
|
|
|
|
|
|
f"名称: {info.get('name', '')}",
|
|
|
|
|
|
]
|
|
|
|
|
|
path = str(info.get("path", "")).strip()
|
|
|
|
|
|
if path:
|
|
|
|
|
|
lines.append(f"路径: {path}")
|
|
|
|
|
|
return "\n".join(lines)
|
|
|
|
|
|
|
|
|
|
|
|
def _part_tree_solid_detail(self, solid_id: int) -> str:
|
|
|
|
|
|
if self.model is None:
|
|
|
|
|
|
return "实体"
|
|
|
|
|
|
try:
|
|
|
|
|
|
info = self.model.solid_info(solid_id)
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
return "实体"
|
|
|
|
|
|
return f"面 {info.get('faces', 0)} 个 | 边 {info.get('edges', 0)} 条"
|
|
|
|
|
|
|
|
|
|
|
|
def _rebuild_scene(self, reset_camera: bool = False) -> None:
|
|
|
|
|
|
if self.model is None:
|
|
|
|
|
|
return
|
|
|
|
|
|
model_polydata = self.model.build_face_polydata()
|
|
|
|
|
|
edge_polydata = self.model.build_edge_polydata(
|
|
|
|
|
|
show_same_domain_internal_edges=self._show_same_domain_internal_edges()
|
|
|
|
|
|
)
|
|
|
|
|
|
self._rebuild_scene_from_polydata(model_polydata, edge_polydata, reset_camera=reset_camera)
|
|
|
|
|
|
|
|
|
|
|
|
def _show_same_domain_internal_edges(self) -> bool:
|
|
|
|
|
|
return bool(self.show_internal_edges_checkbox and self.show_internal_edges_checkbox.isChecked())
|
|
|
|
|
|
|
|
|
|
|
|
def _on_internal_edges_toggled(self, checked: bool) -> None:
|
|
|
|
|
|
if self.model is None or self.operation_in_progress or self.scan_in_progress or self.load_in_progress:
|
|
|
|
|
|
return
|
|
|
|
|
|
if self.scene_isolated and self.selected_kind is not None:
|
|
|
|
|
|
self.isolate_selected()
|
|
|
|
|
|
else:
|
|
|
|
|
|
self._rebuild_scene(reset_camera=False)
|
|
|
|
|
|
self._refresh_selection_highlight()
|
|
|
|
|
|
state = "显示" if checked else "隐藏"
|
|
|
|
|
|
self.statusBar().showMessage(f"已{state}同域内部拓扑边")
|
|
|
|
|
|
|
|
|
|
|
|
def isolate_selected(self) -> None:
|
|
|
|
|
|
if self.model is None:
|
|
|
|
|
|
return
|
|
|
|
|
|
if self._edit_busy("编辑计算中,暂时不能切换显示范围。"):
|
|
|
|
|
|
return
|
|
|
|
|
|
face_ids: list[int] | None = None
|
|
|
|
|
|
edge_ids: list[int] | None = None
|
|
|
|
|
|
part_ids: list[int] | None = None
|
|
|
|
|
|
label = ""
|
|
|
|
|
|
|
|
|
|
|
|
if self.selected_kind == "part" and self.selected_part_id is not None:
|
|
|
|
|
|
part_ids = [self.selected_part_id]
|
2026-07-28 18:30:58 +08:00
|
|
|
|
label = f"零件 {self.selected_part_id}"
|
2026-07-27 18:28:26 +08:00
|
|
|
|
elif self.selected_kind == "solid" and self.selected_solid_id is not None:
|
|
|
|
|
|
face_ids = [index for index, solid_id in enumerate(self.model.face_solid_ids) if solid_id == self.selected_solid_id]
|
|
|
|
|
|
edge_ids = self.model.edge_ids_for_solid(self.selected_solid_id)
|
2026-07-28 14:05:14 +08:00
|
|
|
|
label = f"Solid {self.selected_solid_id}"
|
2026-07-27 18:28:26 +08:00
|
|
|
|
elif self.selected_kind == "feature" and self.selected_face_id is not None:
|
2026-07-30 17:54:01 +08:00
|
|
|
|
info = dict(getattr(self, "current_info_values", {}) or {})
|
|
|
|
|
|
if _safe_int_or_none(info.get("feature_source_face_id", self.selected_face_id)) != self.selected_face_id:
|
|
|
|
|
|
info = {}
|
2026-07-27 18:28:26 +08:00
|
|
|
|
face_ids = _int_values(info.get("feature_highlight_face_ids")) or [self.selected_face_id]
|
|
|
|
|
|
edge_ids = _int_values(info.get("feature_boundary_edge_ids"))
|
2026-07-28 18:30:58 +08:00
|
|
|
|
label = f"特征 Face {self.selected_face_id}"
|
2026-07-27 18:28:26 +08:00
|
|
|
|
elif self.selected_kind == "face" and self.selected_face_id is not None:
|
2026-07-30 17:54:01 +08:00
|
|
|
|
face_ids = [self.selected_face_id]
|
|
|
|
|
|
edge_ids = self.model.face_boundary_edge_ids(self.selected_face_id)
|
2026-07-28 14:05:14 +08:00
|
|
|
|
label = f"Face {self.selected_face_id}"
|
2026-07-27 18:28:26 +08:00
|
|
|
|
elif self.selected_kind == "edge" and self.selected_edge_id is not None:
|
|
|
|
|
|
info = self.model.edge_info(self.selected_edge_id)
|
|
|
|
|
|
face_ids = _int_values(info.get("adjacent_face_ids"))
|
|
|
|
|
|
edge_ids = [self.selected_edge_id]
|
2026-07-28 14:05:14 +08:00
|
|
|
|
label = f"Edge {self.selected_edge_id}"
|
2026-07-27 18:28:26 +08:00
|
|
|
|
else:
|
2026-07-28 18:30:58 +08:00
|
|
|
|
QMessageBox.information(self, "未选择对象", "请先选择零件、Solid、Face、Edge 或特征。")
|
2026-07-27 18:28:26 +08:00
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
|
|
model_polydata = self.model.build_face_polydata(face_ids=face_ids, part_ids=part_ids)
|
|
|
|
|
|
edge_polydata = self.model.build_edge_polydata(
|
|
|
|
|
|
edge_ids=edge_ids,
|
|
|
|
|
|
part_ids=part_ids,
|
|
|
|
|
|
show_same_domain_internal_edges=self._show_same_domain_internal_edges(),
|
|
|
|
|
|
)
|
|
|
|
|
|
self._rebuild_scene_from_polydata(model_polydata, edge_polydata, reset_camera=True)
|
|
|
|
|
|
self.scene_isolated = True
|
|
|
|
|
|
self._refresh_selection_highlight()
|
|
|
|
|
|
self.statusBar().showMessage(f"已只显示选中对象:{label}")
|
|
|
|
|
|
|
|
|
|
|
|
def show_all_geometry(self) -> None:
|
|
|
|
|
|
if self.model is None:
|
|
|
|
|
|
return
|
|
|
|
|
|
if self._edit_busy("编辑计算中,暂时不能切换显示范围。"):
|
|
|
|
|
|
return
|
|
|
|
|
|
self._rebuild_scene(reset_camera=True)
|
|
|
|
|
|
self._refresh_selection_highlight()
|
|
|
|
|
|
self.statusBar().showMessage("已显示完整模型")
|
|
|
|
|
|
|
|
|
|
|
|
def fit_selected(self) -> None:
|
|
|
|
|
|
if self.model is None:
|
|
|
|
|
|
return
|
|
|
|
|
|
if self._edit_busy("编辑计算中,暂时不能调整视角。"):
|
|
|
|
|
|
return
|
|
|
|
|
|
bounds = self._selected_focus_bounds()
|
|
|
|
|
|
if bounds is None:
|
2026-07-28 18:30:58 +08:00
|
|
|
|
QMessageBox.information(self, "未选择对象", "请先选择零件、Solid、Face、Edge 或特征。")
|
2026-07-27 18:28:26 +08:00
|
|
|
|
return
|
|
|
|
|
|
self._fit_camera_to_bounds(bounds)
|
|
|
|
|
|
self.render_window.Render()
|
|
|
|
|
|
self.statusBar().showMessage("已对准选中对象")
|
|
|
|
|
|
|
|
|
|
|
|
def _selected_focus_bounds(self) -> tuple[float, float, float, float, float, float] | None:
|
|
|
|
|
|
if self.model is None:
|
|
|
|
|
|
return None
|
|
|
|
|
|
face_polydata = None
|
|
|
|
|
|
edge_polydata = None
|
|
|
|
|
|
|
|
|
|
|
|
if self.selected_kind == "part" and self.selected_part_id is not None:
|
|
|
|
|
|
face_polydata = self.model.build_face_polydata(part_ids=[self.selected_part_id])
|
|
|
|
|
|
edge_polydata = self.model.build_edge_polydata(
|
|
|
|
|
|
part_ids=[self.selected_part_id],
|
|
|
|
|
|
show_same_domain_internal_edges=self._show_same_domain_internal_edges(),
|
|
|
|
|
|
)
|
|
|
|
|
|
elif self.selected_kind == "solid" and self.selected_solid_id is not None:
|
|
|
|
|
|
face_ids = [index for index, solid_id in enumerate(self.model.face_solid_ids) if solid_id == self.selected_solid_id]
|
|
|
|
|
|
edge_ids = self.model.edge_ids_for_solid(self.selected_solid_id)
|
|
|
|
|
|
face_polydata = self.model.build_face_polydata(face_ids=face_ids)
|
|
|
|
|
|
edge_polydata = self.model.build_edge_polydata(edge_ids=edge_ids)
|
|
|
|
|
|
elif self.selected_kind == "feature" and self.selected_face_id is not None:
|
2026-07-30 17:54:01 +08:00
|
|
|
|
info = dict(getattr(self, "current_info_values", {}) or {})
|
|
|
|
|
|
if _safe_int_or_none(info.get("feature_source_face_id", self.selected_face_id)) != self.selected_face_id:
|
|
|
|
|
|
info = {}
|
2026-07-27 18:28:26 +08:00
|
|
|
|
face_ids = _int_values(info.get("feature_highlight_face_ids")) or [self.selected_face_id]
|
|
|
|
|
|
edge_ids = _int_values(info.get("feature_boundary_edge_ids"))
|
2026-07-30 17:54:01 +08:00
|
|
|
|
face_polydata = self._cached_face_overlay_polydata(face_ids=face_ids, smooth=False)
|
|
|
|
|
|
if edge_ids:
|
|
|
|
|
|
edge_polydata = self.model.build_edge_polydata(edge_ids=edge_ids)
|
2026-07-27 18:28:26 +08:00
|
|
|
|
elif self.selected_kind == "face" and self.selected_face_id is not None:
|
2026-07-30 17:54:01 +08:00
|
|
|
|
face_polydata = self._cached_face_overlay_polydata(face_ids=[self.selected_face_id], smooth=False)
|
2026-07-27 18:28:26 +08:00
|
|
|
|
elif self.selected_kind == "edge" and self.selected_edge_id is not None:
|
2026-07-30 17:54:01 +08:00
|
|
|
|
edge_polydata = self._cached_edge_overlay_polydata(self.selected_edge_id)
|
2026-07-27 18:28:26 +08:00
|
|
|
|
|
|
|
|
|
|
return _merge_polydata_bounds(face_polydata, edge_polydata)
|
|
|
|
|
|
|
|
|
|
|
|
def _fit_camera_to_bounds(self, bounds: tuple[float, float, float, float, float, float]) -> None:
|
|
|
|
|
|
x0, x1, y0, y1, z0, z1 = bounds
|
|
|
|
|
|
dx = max(x1 - x0, 0.0)
|
|
|
|
|
|
dy = max(y1 - y0, 0.0)
|
|
|
|
|
|
dz = max(z1 - z0, 0.0)
|
|
|
|
|
|
diagonal = max(math.sqrt(dx * dx + dy * dy + dz * dz), 1.0)
|
|
|
|
|
|
pad = diagonal * 0.18
|
|
|
|
|
|
padded_bounds = (x0 - pad, x1 + pad, y0 - pad, y1 + pad, z0 - pad, z1 + pad)
|
|
|
|
|
|
try:
|
|
|
|
|
|
self.renderer.ResetCamera(padded_bounds)
|
|
|
|
|
|
except TypeError:
|
|
|
|
|
|
self.renderer.ResetCamera(*padded_bounds)
|
|
|
|
|
|
self.renderer.ResetCameraClippingRange()
|
|
|
|
|
|
|
|
|
|
|
|
def _refresh_selection_highlight(self) -> None:
|
|
|
|
|
|
if self.model is None:
|
|
|
|
|
|
return
|
|
|
|
|
|
if self.selected_kind == "part" and self.selected_part_id is not None:
|
|
|
|
|
|
self._highlight_faces(part_ids=[self.selected_part_id])
|
|
|
|
|
|
elif self.selected_kind == "solid" and self.selected_solid_id is not None:
|
|
|
|
|
|
face_ids = [index for index, solid_id in enumerate(self.model.face_solid_ids) if solid_id == self.selected_solid_id]
|
|
|
|
|
|
self._highlight_faces(face_ids=face_ids)
|
|
|
|
|
|
elif self.selected_kind == "feature" and self.selected_face_id is not None:
|
2026-07-30 17:54:01 +08:00
|
|
|
|
info = dict(getattr(self, "current_info_values", {}) or {})
|
|
|
|
|
|
if _safe_int_or_none(info.get("feature_source_face_id", self.selected_face_id)) != self.selected_face_id:
|
|
|
|
|
|
info = {}
|
2026-07-27 18:28:26 +08:00
|
|
|
|
face_ids = _int_values(info.get("feature_highlight_face_ids")) or [self.selected_face_id]
|
|
|
|
|
|
self._highlight_faces(face_ids=face_ids)
|
|
|
|
|
|
elif self.selected_kind == "face" and self.selected_face_id is not None:
|
2026-07-30 17:54:01 +08:00
|
|
|
|
self._highlight_faces(face_ids=[self.selected_face_id])
|
2026-07-27 18:28:26 +08:00
|
|
|
|
elif self.selected_kind == "edge" and self.selected_edge_id is not None:
|
|
|
|
|
|
self._highlight_edge(self.selected_edge_id)
|
|
|
|
|
|
|
|
|
|
|
|
def _clear_overlay_polydata_cache(self) -> None:
|
|
|
|
|
|
self.face_overlay_polydata_cache.clear()
|
|
|
|
|
|
self.edge_overlay_polydata_cache.clear()
|
|
|
|
|
|
|
2026-07-30 17:54:01 +08:00
|
|
|
|
def _rebuild_polydata_cell_indexes(self) -> None:
|
|
|
|
|
|
self.model_face_cell_ids_by_face = {}
|
|
|
|
|
|
self.model_face_cell_ids_by_part = {}
|
|
|
|
|
|
self.model_face_cell_ids_by_solid = {}
|
|
|
|
|
|
if self.model_polydata is None:
|
|
|
|
|
|
return
|
|
|
|
|
|
face_arr = self.model_face_id_array
|
|
|
|
|
|
part_arr = self.model_part_id_array
|
|
|
|
|
|
solid_arr = self.model_solid_id_array
|
|
|
|
|
|
if face_arr is None or part_arr is None or solid_arr is None:
|
|
|
|
|
|
return
|
|
|
|
|
|
for cell_id in range(self.model_polydata.GetNumberOfCells()):
|
|
|
|
|
|
face_id = int(face_arr.GetValue(cell_id))
|
|
|
|
|
|
part_id = int(part_arr.GetValue(cell_id))
|
|
|
|
|
|
solid_id = int(solid_arr.GetValue(cell_id))
|
|
|
|
|
|
self.model_face_cell_ids_by_face.setdefault(face_id, []).append(cell_id)
|
|
|
|
|
|
self.model_face_cell_ids_by_part.setdefault(part_id, []).append(cell_id)
|
|
|
|
|
|
self.model_face_cell_ids_by_solid.setdefault(solid_id, []).append(cell_id)
|
|
|
|
|
|
|
|
|
|
|
|
def _rebuild_edge_polydata_cell_index(self) -> None:
|
|
|
|
|
|
self.edge_cell_ids_by_edge = {}
|
|
|
|
|
|
if self.edge_polydata is None or self.edge_id_array is None:
|
|
|
|
|
|
return
|
|
|
|
|
|
for cell_id in range(self.edge_polydata.GetNumberOfCells()):
|
|
|
|
|
|
edge_id = int(self.edge_id_array.GetValue(cell_id))
|
|
|
|
|
|
self.edge_cell_ids_by_edge.setdefault(edge_id, []).append(cell_id)
|
|
|
|
|
|
|
2026-07-27 18:28:26 +08:00
|
|
|
|
def _remember_overlay_cache_item(self, cache: dict, key: object, value: object) -> object:
|
|
|
|
|
|
if len(cache) >= self.overlay_cache_limit:
|
|
|
|
|
|
try:
|
|
|
|
|
|
cache.pop(next(iter(cache)))
|
|
|
|
|
|
except StopIteration:
|
|
|
|
|
|
pass
|
|
|
|
|
|
cache[key] = value
|
|
|
|
|
|
return value
|
|
|
|
|
|
|
|
|
|
|
|
def _cached_face_overlay_polydata(self, face_ids=None, part_ids=None, smooth: bool = True):
|
|
|
|
|
|
if self.model is None or self.model_polydata is None:
|
|
|
|
|
|
return None
|
|
|
|
|
|
face_key = _int_tuple_or_none(face_ids)
|
|
|
|
|
|
part_key = _int_tuple_or_none(part_ids)
|
|
|
|
|
|
key = (face_key, part_key, bool(smooth))
|
|
|
|
|
|
cached = self.face_overlay_polydata_cache.get(key)
|
|
|
|
|
|
if cached is not None:
|
|
|
|
|
|
return cached
|
|
|
|
|
|
polydata = self._extract_visible_face_polydata(face_key, part_key)
|
|
|
|
|
|
return self._remember_overlay_cache_item(self.face_overlay_polydata_cache, key, polydata)
|
|
|
|
|
|
|
|
|
|
|
|
def _cached_edge_overlay_polydata(self, edge_id: int):
|
|
|
|
|
|
if self.model is None or self.edge_polydata is None:
|
|
|
|
|
|
return None
|
|
|
|
|
|
key = int(edge_id)
|
|
|
|
|
|
cached = self.edge_overlay_polydata_cache.get(key)
|
|
|
|
|
|
if cached is not None:
|
|
|
|
|
|
return cached
|
|
|
|
|
|
polydata = self._extract_visible_edge_polydata(key)
|
|
|
|
|
|
return self._remember_overlay_cache_item(self.edge_overlay_polydata_cache, key, polydata)
|
|
|
|
|
|
|
|
|
|
|
|
def _extract_visible_face_polydata(self, face_ids=None, part_ids=None):
|
|
|
|
|
|
if self.model_polydata is None:
|
|
|
|
|
|
return None
|
|
|
|
|
|
face_set = set(face_ids) if face_ids is not None else None
|
|
|
|
|
|
part_set = set(part_ids) if part_ids is not None else None
|
|
|
|
|
|
face_arr = self.model_face_id_array
|
|
|
|
|
|
part_arr = self.model_part_id_array
|
|
|
|
|
|
if face_arr is None or part_arr is None:
|
|
|
|
|
|
return None
|
2026-07-30 17:54:01 +08:00
|
|
|
|
candidate_cell_ids = None
|
|
|
|
|
|
if face_set is not None and self.model_face_cell_ids_by_face:
|
|
|
|
|
|
candidate_cell_ids = []
|
|
|
|
|
|
for face_id in face_set:
|
|
|
|
|
|
candidate_cell_ids.extend(self.model_face_cell_ids_by_face.get(int(face_id), ()))
|
|
|
|
|
|
elif part_set is not None and self.model_face_cell_ids_by_part:
|
|
|
|
|
|
candidate_cell_ids = []
|
|
|
|
|
|
for part_id in part_set:
|
|
|
|
|
|
candidate_cell_ids.extend(self.model_face_cell_ids_by_part.get(int(part_id), ()))
|
|
|
|
|
|
cell_ids = (
|
|
|
|
|
|
range(self.model_polydata.GetNumberOfCells())
|
|
|
|
|
|
if candidate_cell_ids is None
|
|
|
|
|
|
else sorted(set(candidate_cell_ids))
|
|
|
|
|
|
)
|
2026-07-27 18:28:26 +08:00
|
|
|
|
ids = vtk.vtkIdList()
|
2026-07-30 17:54:01 +08:00
|
|
|
|
for cell_id in cell_ids:
|
2026-07-27 18:28:26 +08:00
|
|
|
|
if face_set is not None and int(face_arr.GetValue(cell_id)) not in face_set:
|
|
|
|
|
|
continue
|
|
|
|
|
|
if part_set is not None and int(part_arr.GetValue(cell_id)) not in part_set:
|
|
|
|
|
|
continue
|
|
|
|
|
|
ids.InsertNextId(cell_id)
|
|
|
|
|
|
return self._extract_cells_as_polydata(self.model_polydata, ids)
|
|
|
|
|
|
|
|
|
|
|
|
def _extract_visible_edge_polydata(self, edge_id: int):
|
|
|
|
|
|
if self.edge_polydata is None or self.edge_id_array is None:
|
|
|
|
|
|
return None
|
2026-07-30 17:54:01 +08:00
|
|
|
|
cell_ids = self.edge_cell_ids_by_edge.get(int(edge_id))
|
|
|
|
|
|
if cell_ids is None:
|
|
|
|
|
|
cell_ids = range(self.edge_polydata.GetNumberOfCells())
|
2026-07-27 18:28:26 +08:00
|
|
|
|
ids = vtk.vtkIdList()
|
2026-07-30 17:54:01 +08:00
|
|
|
|
for cell_id in cell_ids:
|
2026-07-27 18:28:26 +08:00
|
|
|
|
if int(self.edge_id_array.GetValue(cell_id)) == edge_id:
|
|
|
|
|
|
ids.InsertNextId(cell_id)
|
|
|
|
|
|
return self._extract_cells_as_polydata(self.edge_polydata, ids)
|
|
|
|
|
|
|
|
|
|
|
|
def _extract_cells_as_polydata(self, source, ids):
|
|
|
|
|
|
if ids.GetNumberOfIds() == 0:
|
|
|
|
|
|
return None
|
|
|
|
|
|
extract = vtk.vtkExtractCells()
|
|
|
|
|
|
extract.SetInputData(source)
|
|
|
|
|
|
extract.SetCellList(ids)
|
|
|
|
|
|
extract.Update()
|
|
|
|
|
|
geometry = vtk.vtkGeometryFilter()
|
|
|
|
|
|
geometry.SetInputConnection(extract.GetOutputPort())
|
|
|
|
|
|
geometry.Update()
|
|
|
|
|
|
polydata = vtk.vtkPolyData()
|
|
|
|
|
|
polydata.ShallowCopy(geometry.GetOutput())
|
|
|
|
|
|
return polydata
|
|
|
|
|
|
|
|
|
|
|
|
def _rebuild_scene_from_polydata(self, model_polydata, edge_polydata, reset_camera: bool = False) -> None:
|
|
|
|
|
|
self._clear_overlay_polydata_cache()
|
|
|
|
|
|
self.renderer.RemoveAllViewProps()
|
2026-07-28 18:30:58 +08:00
|
|
|
|
self.renderer.SetBackground(*VIEW_BACKGROUND_COLOR)
|
2026-07-27 18:28:26 +08:00
|
|
|
|
self.scene_isolated = False
|
|
|
|
|
|
|
|
|
|
|
|
self.model_polydata = _smooth_surface_polydata(model_polydata)
|
|
|
|
|
|
self.model_face_id_array = self.model_polydata.GetCellData().GetArray("face_id")
|
|
|
|
|
|
self.model_part_id_array = self.model_polydata.GetCellData().GetArray("part_id")
|
|
|
|
|
|
self.model_solid_id_array = self.model_polydata.GetCellData().GetArray("solid_id")
|
2026-07-30 17:54:01 +08:00
|
|
|
|
self._rebuild_polydata_cell_indexes()
|
2026-07-27 18:28:26 +08:00
|
|
|
|
mapper = vtk.vtkPolyDataMapper()
|
|
|
|
|
|
mapper.SetInputData(self.model_polydata)
|
2026-07-30 17:54:01 +08:00
|
|
|
|
_prepare_static_mapper(mapper)
|
2026-07-27 18:28:26 +08:00
|
|
|
|
self.model_actor = vtk.vtkActor()
|
|
|
|
|
|
self.model_actor.SetMapper(mapper)
|
|
|
|
|
|
self.model_actor.GetProperty().SetColor(0.68, 0.72, 0.73)
|
|
|
|
|
|
self.model_actor.GetProperty().SetDiffuse(0.82)
|
|
|
|
|
|
self.model_actor.GetProperty().SetSpecular(0.25)
|
|
|
|
|
|
self.model_actor.GetProperty().SetSpecularPower(18)
|
|
|
|
|
|
self.model_actor.GetProperty().SetInterpolationToPhong()
|
2026-07-28 14:05:14 +08:00
|
|
|
|
backface_property = vtk.vtkProperty()
|
|
|
|
|
|
backface_property.SetColor(0.58, 0.62, 0.64)
|
|
|
|
|
|
backface_property.SetDiffuse(0.9)
|
|
|
|
|
|
backface_property.SetAmbient(0.28)
|
|
|
|
|
|
self.model_actor.SetBackfaceProperty(backface_property)
|
2026-07-27 18:28:26 +08:00
|
|
|
|
self.renderer.AddActor(self.model_actor)
|
|
|
|
|
|
|
|
|
|
|
|
self.edge_polydata = edge_polydata
|
|
|
|
|
|
self.edge_id_array = self.edge_polydata.GetCellData().GetArray("edge_id")
|
2026-07-30 17:54:01 +08:00
|
|
|
|
self._rebuild_edge_polydata_cell_index()
|
2026-07-27 18:28:26 +08:00
|
|
|
|
edge_mapper = vtk.vtkPolyDataMapper()
|
|
|
|
|
|
edge_mapper.SetInputData(self.edge_polydata)
|
2026-07-30 17:54:01 +08:00
|
|
|
|
_prepare_static_mapper(edge_mapper)
|
2026-07-27 18:28:26 +08:00
|
|
|
|
self.edge_actor = vtk.vtkActor()
|
|
|
|
|
|
self.edge_actor.SetMapper(edge_mapper)
|
|
|
|
|
|
self.edge_actor.GetProperty().SetColor(0.08, 0.09, 0.1)
|
|
|
|
|
|
self.edge_actor.GetProperty().SetLineWidth(1.0)
|
|
|
|
|
|
self.renderer.AddActor(self.edge_actor)
|
|
|
|
|
|
|
|
|
|
|
|
self.highlight_actor = None
|
|
|
|
|
|
self.edge_highlight_actor = None
|
|
|
|
|
|
self.hover_face_actor = None
|
|
|
|
|
|
self.hover_edge_actor = None
|
|
|
|
|
|
self.hover_signature = None
|
|
|
|
|
|
self.pending_hover_position = None
|
|
|
|
|
|
self.last_hover_pick_position = None
|
|
|
|
|
|
self.pick_marker_actor = None
|
|
|
|
|
|
self.edit_preview_actor = None
|
|
|
|
|
|
self.edit_preview_actors = []
|
|
|
|
|
|
self.diff_actors = []
|
|
|
|
|
|
if reset_camera:
|
|
|
|
|
|
self.renderer.ResetCamera()
|
|
|
|
|
|
self.render_window.Render()
|
|
|
|
|
|
|
|
|
|
|
|
def _on_mode_changed(self, mode: str) -> None:
|
|
|
|
|
|
self._clear_hover(render=True)
|
2026-07-28 18:30:58 +08:00
|
|
|
|
if hasattr(self, "_update_id_select_title"):
|
|
|
|
|
|
self._update_id_select_title(mode)
|
2026-07-27 18:28:26 +08:00
|
|
|
|
|
2026-07-29 15:43:28 +08:00
|
|
|
|
def _cancel_hover_tracking(self, render: bool = True) -> None:
|
|
|
|
|
|
self.pending_hover_position = None
|
|
|
|
|
|
self.last_hover_pick_position = None
|
|
|
|
|
|
if hasattr(self, "hover_timer"):
|
|
|
|
|
|
self.hover_timer.stop()
|
|
|
|
|
|
self._clear_hover(render=render)
|
|
|
|
|
|
|
|
|
|
|
|
def on_left_button_press(self, _obj, _event) -> None:
|
|
|
|
|
|
if getattr(self, "skip_next_vtk_left_press", False):
|
|
|
|
|
|
self.skip_next_vtk_left_press = False
|
|
|
|
|
|
return
|
2026-07-28 18:30:58 +08:00
|
|
|
|
try:
|
|
|
|
|
|
x, y = self.interactor.GetEventPosition()
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
x, y = 0, 0
|
|
|
|
|
|
if not self._is_ui_thread():
|
2026-07-29 15:43:28 +08:00
|
|
|
|
self._invoke_on_ui_thread(lambda x=int(x), y=int(y): self._handle_left_button_press(x, y))
|
2026-07-28 18:30:58 +08:00
|
|
|
|
return
|
2026-07-29 15:43:28 +08:00
|
|
|
|
self._handle_left_button_press(int(x), int(y))
|
2026-07-28 18:30:58 +08:00
|
|
|
|
|
2026-07-29 15:43:28 +08:00
|
|
|
|
def _handle_left_button_press(self, x: int, y: int) -> None:
|
2026-07-27 18:28:26 +08:00
|
|
|
|
self.pointer_button_down = True
|
2026-07-29 15:43:28 +08:00
|
|
|
|
self.left_button_press_position = (int(x), int(y))
|
|
|
|
|
|
self.left_button_dragged = False
|
|
|
|
|
|
self.pending_hover_position = None
|
|
|
|
|
|
self.last_hover_pick_position = None
|
|
|
|
|
|
self.hover_timer.stop()
|
2026-07-30 17:54:01 +08:00
|
|
|
|
self._clear_hover(render=False)
|
2026-07-29 15:43:28 +08:00
|
|
|
|
|
|
|
|
|
|
def on_left_button_release(self, _obj, _event) -> None:
|
|
|
|
|
|
if getattr(self, "skip_next_vtk_left_release", False):
|
|
|
|
|
|
self.skip_next_vtk_left_release = False
|
|
|
|
|
|
return
|
|
|
|
|
|
try:
|
|
|
|
|
|
x, y = self.interactor.GetEventPosition()
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
x, y = 0, 0
|
|
|
|
|
|
if not self._is_ui_thread():
|
|
|
|
|
|
self._invoke_on_ui_thread(lambda x=int(x), y=int(y): self._handle_left_button_release(x, y))
|
|
|
|
|
|
return
|
|
|
|
|
|
self._handle_left_button_release(int(x), int(y))
|
|
|
|
|
|
|
|
|
|
|
|
def _handle_left_button_release(self, x: int, y: int) -> None:
|
|
|
|
|
|
self._update_left_button_drag_state(x, y)
|
|
|
|
|
|
was_dragged = bool(getattr(self, "left_button_dragged", False))
|
|
|
|
|
|
self.pointer_button_down = False
|
|
|
|
|
|
self.left_button_press_position = None
|
|
|
|
|
|
self.left_button_dragged = False
|
|
|
|
|
|
self.pending_hover_position = None
|
|
|
|
|
|
self.last_hover_pick_position = None
|
|
|
|
|
|
if getattr(self, "camera_interaction_active", False):
|
|
|
|
|
|
self._end_camera_interaction()
|
|
|
|
|
|
if was_dragged:
|
|
|
|
|
|
return
|
|
|
|
|
|
self._handle_left_click(x, y)
|
|
|
|
|
|
|
|
|
|
|
|
def _update_left_button_drag_state(self, x: int, y: int) -> None:
|
|
|
|
|
|
press_position = getattr(self, "left_button_press_position", None)
|
|
|
|
|
|
if press_position is None:
|
|
|
|
|
|
return
|
|
|
|
|
|
threshold = int(getattr(self, "left_click_drag_threshold_px", 6) or 6)
|
|
|
|
|
|
dx = abs(int(x) - int(press_position[0]))
|
|
|
|
|
|
dy = abs(int(y) - int(press_position[1]))
|
|
|
|
|
|
if max(dx, dy) > threshold:
|
|
|
|
|
|
self.left_button_dragged = True
|
|
|
|
|
|
|
|
|
|
|
|
def _handle_left_click(self, x: int, y: int) -> None:
|
2026-07-27 18:28:26 +08:00
|
|
|
|
self.pending_hover_position = None
|
|
|
|
|
|
self.last_hover_pick_position = None
|
|
|
|
|
|
self.hover_timer.stop()
|
|
|
|
|
|
if self.load_in_progress:
|
|
|
|
|
|
self.statusBar().showMessage("STEP background loading is still running.")
|
|
|
|
|
|
return
|
|
|
|
|
|
if self.operation_in_progress:
|
|
|
|
|
|
self.statusBar().showMessage("编辑计算中,请等待当前操作完成")
|
|
|
|
|
|
return
|
|
|
|
|
|
if self.scan_in_progress:
|
2026-07-28 14:05:14 +08:00
|
|
|
|
self.statusBar().showMessage("扫描中,请等待扫描完成后再选择对象。")
|
2026-07-27 18:28:26 +08:00
|
|
|
|
return
|
|
|
|
|
|
if self.model is None:
|
|
|
|
|
|
return
|
2026-07-28 18:30:58 +08:00
|
|
|
|
mode = self._current_selection_mode()
|
2026-07-27 18:28:26 +08:00
|
|
|
|
target = self._pick_selection_target(mode, x, y)
|
|
|
|
|
|
if target is None:
|
2026-07-29 15:43:28 +08:00
|
|
|
|
self._cancel_hover_tracking(render=False)
|
|
|
|
|
|
if hasattr(self, "render_window"):
|
|
|
|
|
|
self.render_window.Render()
|
2026-07-30 17:54:01 +08:00
|
|
|
|
if self.selected_kind is not None:
|
|
|
|
|
|
self.statusBar().showMessage("未命中对象,已保持当前选择")
|
|
|
|
|
|
else:
|
|
|
|
|
|
self.statusBar().showMessage("未命中对象")
|
2026-07-27 18:28:26 +08:00
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
|
|
self._clear_hover(render=False)
|
|
|
|
|
|
self._select_pick_target(target)
|
|
|
|
|
|
|
|
|
|
|
|
def on_pointer_button_press(self, _obj, _event) -> None:
|
2026-07-28 18:30:58 +08:00
|
|
|
|
if not self._is_ui_thread():
|
|
|
|
|
|
self._invoke_on_ui_thread(self._handle_pointer_button_press)
|
|
|
|
|
|
return
|
|
|
|
|
|
self._handle_pointer_button_press()
|
|
|
|
|
|
|
|
|
|
|
|
def _handle_pointer_button_press(self) -> None:
|
2026-07-27 18:28:26 +08:00
|
|
|
|
self.pointer_button_down = True
|
|
|
|
|
|
self.pending_hover_position = None
|
|
|
|
|
|
self.last_hover_pick_position = None
|
|
|
|
|
|
self.hover_timer.stop()
|
2026-07-30 17:54:01 +08:00
|
|
|
|
self._clear_hover(render=False)
|
2026-07-27 18:28:26 +08:00
|
|
|
|
|
|
|
|
|
|
def on_pointer_button_release(self, _obj, _event) -> None:
|
2026-07-28 18:30:58 +08:00
|
|
|
|
if not self._is_ui_thread():
|
|
|
|
|
|
self._invoke_on_ui_thread(self._handle_pointer_button_release)
|
|
|
|
|
|
return
|
|
|
|
|
|
self._handle_pointer_button_release()
|
|
|
|
|
|
|
|
|
|
|
|
def _handle_pointer_button_release(self) -> None:
|
2026-07-27 18:28:26 +08:00
|
|
|
|
self.pointer_button_down = False
|
|
|
|
|
|
self.pending_hover_position = None
|
|
|
|
|
|
self.last_hover_pick_position = None
|
2026-07-29 15:43:28 +08:00
|
|
|
|
self._end_camera_interaction()
|
2026-07-27 18:28:26 +08:00
|
|
|
|
|
|
|
|
|
|
def on_mouse_move(self, _obj, _event) -> None:
|
2026-07-28 18:30:58 +08:00
|
|
|
|
try:
|
|
|
|
|
|
x, y = self.interactor.GetEventPosition()
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
x, y = 0, 0
|
|
|
|
|
|
if not self._is_ui_thread():
|
|
|
|
|
|
self._invoke_on_ui_thread(lambda x=int(x), y=int(y): self._handle_mouse_move(x, y))
|
|
|
|
|
|
return
|
|
|
|
|
|
self._handle_mouse_move(int(x), int(y))
|
|
|
|
|
|
|
|
|
|
|
|
def _handle_mouse_move(self, x: int, y: int) -> None:
|
2026-07-29 15:43:28 +08:00
|
|
|
|
buttons = QApplication.mouseButtons()
|
|
|
|
|
|
if buttons != Qt.MouseButton.NoButton:
|
2026-07-27 18:28:26 +08:00
|
|
|
|
self.pointer_button_down = True
|
2026-07-29 15:43:28 +08:00
|
|
|
|
if buttons & Qt.MouseButton.LeftButton:
|
|
|
|
|
|
self._update_left_button_drag_state(x, y)
|
|
|
|
|
|
if getattr(self, "left_button_dragged", False):
|
|
|
|
|
|
self._begin_camera_interaction()
|
|
|
|
|
|
else:
|
|
|
|
|
|
self._begin_camera_interaction()
|
2026-07-27 18:28:26 +08:00
|
|
|
|
return
|
|
|
|
|
|
self.pointer_button_down = False
|
|
|
|
|
|
if (
|
|
|
|
|
|
self.operation_in_progress
|
|
|
|
|
|
or self.scan_in_progress
|
|
|
|
|
|
or self.load_in_progress
|
|
|
|
|
|
or self.model is None
|
|
|
|
|
|
or self.model_actor is None
|
2026-08-04 09:35:39 +08:00
|
|
|
|
or self._hover_suppressed_after_camera()
|
2026-07-27 18:28:26 +08:00
|
|
|
|
):
|
|
|
|
|
|
self._clear_hover(render=True)
|
|
|
|
|
|
return
|
2026-07-28 18:30:58 +08:00
|
|
|
|
self._queue_hover_position(x, y)
|
2026-07-27 18:28:26 +08:00
|
|
|
|
|
|
|
|
|
|
def _queue_hover_from_qt_event(self, event) -> None:
|
|
|
|
|
|
if (
|
|
|
|
|
|
self.operation_in_progress
|
|
|
|
|
|
or self.scan_in_progress
|
|
|
|
|
|
or self.load_in_progress
|
|
|
|
|
|
or self.model is None
|
|
|
|
|
|
or self.model_actor is None
|
|
|
|
|
|
):
|
|
|
|
|
|
self._clear_hover(render=True)
|
|
|
|
|
|
return
|
2026-07-29 15:43:28 +08:00
|
|
|
|
x, y = self._vtk_position_from_qt_event(event)
|
2026-07-27 18:28:26 +08:00
|
|
|
|
self._queue_hover_position(x, y)
|
|
|
|
|
|
|
|
|
|
|
|
def _queue_hover_position(self, x: int, y: int) -> None:
|
2026-07-28 18:30:58 +08:00
|
|
|
|
if not self._is_ui_thread():
|
|
|
|
|
|
self._invoke_on_ui_thread(lambda x=int(x), y=int(y): self._queue_hover_position(x, y))
|
|
|
|
|
|
return
|
2026-08-04 09:35:39 +08:00
|
|
|
|
if (
|
|
|
|
|
|
getattr(self, "pointer_button_down", False)
|
|
|
|
|
|
or getattr(self, "camera_interaction_active", False)
|
|
|
|
|
|
or self._hover_suppressed_after_camera()
|
|
|
|
|
|
):
|
2026-07-30 17:54:01 +08:00
|
|
|
|
return
|
2026-07-28 14:05:14 +08:00
|
|
|
|
position = (int(x), int(y))
|
|
|
|
|
|
threshold = int(getattr(self, "hover_move_threshold_px", 0) or 0)
|
|
|
|
|
|
if threshold > 0:
|
|
|
|
|
|
previous = self.pending_hover_position or self.last_hover_pick_position
|
|
|
|
|
|
if previous is not None:
|
|
|
|
|
|
dx = abs(position[0] - int(previous[0]))
|
|
|
|
|
|
dy = abs(position[1] - int(previous[1]))
|
|
|
|
|
|
if max(dx, dy) < threshold:
|
|
|
|
|
|
return
|
|
|
|
|
|
self.pending_hover_position = position
|
2026-07-30 17:54:01 +08:00
|
|
|
|
if self.hover_timer.isActive():
|
|
|
|
|
|
self.hover_timer.stop()
|
|
|
|
|
|
self.hover_timer.start(self.hover_interval_ms)
|
2026-07-27 18:28:26 +08:00
|
|
|
|
|
|
|
|
|
|
def _update_hover_target(self) -> None:
|
2026-07-30 17:54:01 +08:00
|
|
|
|
if getattr(self, "pointer_button_down", False) or getattr(self, "camera_interaction_active", False):
|
|
|
|
|
|
self._clear_hover(render=False)
|
|
|
|
|
|
return
|
2026-07-27 18:28:26 +08:00
|
|
|
|
if (
|
|
|
|
|
|
self.operation_in_progress
|
|
|
|
|
|
or self.scan_in_progress
|
|
|
|
|
|
or self.load_in_progress
|
|
|
|
|
|
or self.model is None
|
|
|
|
|
|
or self.model_actor is None
|
|
|
|
|
|
or self.pending_hover_position is None
|
2026-08-04 09:35:39 +08:00
|
|
|
|
or self._hover_suppressed_after_camera()
|
2026-07-27 18:28:26 +08:00
|
|
|
|
):
|
|
|
|
|
|
self._clear_hover(render=True)
|
|
|
|
|
|
return
|
|
|
|
|
|
x, y = self.pending_hover_position
|
|
|
|
|
|
self.last_hover_pick_position = (x, y)
|
2026-07-30 17:54:01 +08:00
|
|
|
|
target = self._pick_hover_target(self._current_selection_mode(), x, y)
|
2026-07-27 18:28:26 +08:00
|
|
|
|
self._show_hover_target(target)
|
|
|
|
|
|
|
2026-07-30 17:54:01 +08:00
|
|
|
|
def _pick_hover_target(self, mode: str, x: int, y: int) -> dict[str, object] | None:
|
|
|
|
|
|
if self.model is None:
|
|
|
|
|
|
return None
|
|
|
|
|
|
if mode == "Edge":
|
|
|
|
|
|
edge_hit = self._pick_edge_cell(x, y)
|
|
|
|
|
|
return self._edge_target_from_cell_hit(edge_hit) if edge_hit is not None else None
|
|
|
|
|
|
|
|
|
|
|
|
face_hit = self._pick_face_cell(x, y)
|
|
|
|
|
|
if face_hit is None:
|
|
|
|
|
|
return None
|
|
|
|
|
|
return self._target_from_face_hit(face_hit, mode)
|
|
|
|
|
|
|
2026-07-27 18:28:26 +08:00
|
|
|
|
def _pick_selection_target(self, mode: str, x: int, y: int) -> dict[str, object] | None:
|
|
|
|
|
|
if self.model is None:
|
|
|
|
|
|
return None
|
|
|
|
|
|
if mode == "Edge":
|
|
|
|
|
|
edge_hit = self._pick_edge_cell(x, y)
|
|
|
|
|
|
if edge_hit is not None:
|
|
|
|
|
|
return self._edge_target_from_cell_hit(edge_hit)
|
|
|
|
|
|
face_hit = self._pick_face_cell(x, y)
|
|
|
|
|
|
if face_hit is not None:
|
|
|
|
|
|
return self._edge_target_from_face_hit(face_hit)
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
face_hit = self._pick_face_cell(x, y)
|
|
|
|
|
|
if face_hit is not None:
|
|
|
|
|
|
return self._target_from_face_hit(face_hit, mode)
|
|
|
|
|
|
|
|
|
|
|
|
edge_hit = self._pick_edge_cell(x, y)
|
|
|
|
|
|
if edge_hit is not None:
|
|
|
|
|
|
edge_target = self._edge_target_from_cell_hit(edge_hit)
|
|
|
|
|
|
if edge_target is not None:
|
|
|
|
|
|
return self._target_from_edge_id(int(edge_target["target_id"]), mode, edge_target["pick_position"])
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
def _pick_actor_cell(self, actor, x: int, y: int) -> dict[str, object] | None:
|
|
|
|
|
|
if actor is None:
|
|
|
|
|
|
return None
|
|
|
|
|
|
self.picker.InitializePickList()
|
|
|
|
|
|
self.picker.PickFromListOn()
|
|
|
|
|
|
self.picker.AddPickList(actor)
|
|
|
|
|
|
picked = self.picker.Pick(int(x), int(y), 0, self.renderer)
|
|
|
|
|
|
self.picker.PickFromListOff()
|
|
|
|
|
|
if not picked or self.picker.GetCellId() < 0:
|
|
|
|
|
|
return None
|
|
|
|
|
|
return {
|
|
|
|
|
|
"cell_id": int(self.picker.GetCellId()),
|
|
|
|
|
|
"pick_position": _vector_tuple(self.picker.GetPickPosition()),
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
def _pick_face_cell(self, x: int, y: int) -> dict[str, object] | None:
|
|
|
|
|
|
hit = self._pick_actor_cell(self.model_actor, x, y)
|
|
|
|
|
|
if hit is None or self.model_polydata is None:
|
|
|
|
|
|
return None
|
|
|
|
|
|
cell_data = self._face_cell_data(int(hit["cell_id"]))
|
|
|
|
|
|
if cell_data is None:
|
|
|
|
|
|
return None
|
|
|
|
|
|
hit.update(cell_data)
|
|
|
|
|
|
return hit
|
|
|
|
|
|
|
|
|
|
|
|
def _pick_edge_cell(self, x: int, y: int) -> dict[str, object] | None:
|
|
|
|
|
|
hit = self._pick_actor_cell(self.edge_actor, x, y)
|
|
|
|
|
|
if hit is None or self.edge_polydata is None:
|
|
|
|
|
|
return None
|
|
|
|
|
|
edge_id = self._edge_id_from_cell(int(hit["cell_id"]))
|
|
|
|
|
|
if edge_id is None:
|
|
|
|
|
|
return None
|
|
|
|
|
|
hit["edge_id"] = edge_id
|
|
|
|
|
|
return hit
|
|
|
|
|
|
|
|
|
|
|
|
def _face_cell_data(self, cell_id: int) -> dict[str, int] | None:
|
|
|
|
|
|
if self.model_polydata is None or cell_id < 0:
|
|
|
|
|
|
return None
|
|
|
|
|
|
face_arr = self.model_face_id_array
|
|
|
|
|
|
part_arr = self.model_part_id_array
|
|
|
|
|
|
solid_arr = self.model_solid_id_array
|
|
|
|
|
|
if face_arr is None or part_arr is None or solid_arr is None:
|
|
|
|
|
|
return None
|
|
|
|
|
|
return {
|
|
|
|
|
|
"face_id": int(face_arr.GetValue(cell_id)),
|
|
|
|
|
|
"part_id": int(part_arr.GetValue(cell_id)),
|
|
|
|
|
|
"solid_id": int(solid_arr.GetValue(cell_id)),
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
def _edge_id_from_cell(self, cell_id: int) -> int | None:
|
|
|
|
|
|
if self.edge_polydata is None or cell_id < 0:
|
|
|
|
|
|
return None
|
|
|
|
|
|
edge_arr = self.edge_id_array
|
|
|
|
|
|
if edge_arr is None:
|
|
|
|
|
|
return None
|
|
|
|
|
|
return int(edge_arr.GetValue(cell_id))
|
|
|
|
|
|
|
|
|
|
|
|
def _target_from_face_hit(self, hit: dict[str, object], mode: str) -> dict[str, object] | None:
|
|
|
|
|
|
face_id = int(hit["face_id"])
|
|
|
|
|
|
part_id = int(hit["part_id"])
|
|
|
|
|
|
solid_id = int(hit["solid_id"])
|
|
|
|
|
|
pick_position = hit["pick_position"]
|
|
|
|
|
|
if mode == "Part":
|
|
|
|
|
|
if part_id < 0:
|
|
|
|
|
|
return None
|
|
|
|
|
|
return {"kind": "part", "target_id": part_id, "pick_position": pick_position}
|
|
|
|
|
|
if mode == "Solid":
|
|
|
|
|
|
if solid_id < 0:
|
|
|
|
|
|
return None
|
|
|
|
|
|
return {"kind": "solid", "target_id": solid_id, "part_id": part_id, "pick_position": pick_position}
|
|
|
|
|
|
if mode == "Feature":
|
|
|
|
|
|
return {"kind": "feature", "target_id": face_id, "pick_position": pick_position}
|
|
|
|
|
|
return {"kind": "face", "target_id": face_id, "pick_position": pick_position}
|
|
|
|
|
|
|
|
|
|
|
|
def _edge_target_from_cell_hit(self, hit: dict[str, object]) -> dict[str, object] | None:
|
|
|
|
|
|
edge_id = hit.get("edge_id")
|
|
|
|
|
|
if edge_id is None:
|
|
|
|
|
|
return None
|
|
|
|
|
|
return {"kind": "edge", "target_id": int(edge_id), "pick_position": hit["pick_position"]}
|
|
|
|
|
|
|
|
|
|
|
|
def _edge_target_from_face_hit(self, hit: dict[str, object]) -> dict[str, object] | None:
|
|
|
|
|
|
if self.model is None:
|
|
|
|
|
|
return None
|
|
|
|
|
|
face_id = int(hit["face_id"])
|
|
|
|
|
|
pick_position = hit["pick_position"]
|
|
|
|
|
|
edge_ids = self.model.face_boundary_edge_ids(face_id)
|
|
|
|
|
|
edge_id = self.model.nearest_edge_id_to_point(edge_ids, pick_position)
|
|
|
|
|
|
if edge_id is None:
|
|
|
|
|
|
return None
|
|
|
|
|
|
return {"kind": "edge", "target_id": edge_id, "pick_position": pick_position}
|
|
|
|
|
|
|
|
|
|
|
|
def _target_from_edge_id(
|
|
|
|
|
|
self,
|
|
|
|
|
|
edge_id: int,
|
|
|
|
|
|
mode: str,
|
|
|
|
|
|
pick_position: tuple[float, float, float] | None,
|
|
|
|
|
|
) -> dict[str, object] | None:
|
|
|
|
|
|
if self.model is None or edge_id < 0 or edge_id >= len(self.model.edges):
|
|
|
|
|
|
return None
|
|
|
|
|
|
info = self.model.edge_info(edge_id)
|
|
|
|
|
|
part_id = int(info.get("part_id", -1))
|
|
|
|
|
|
solid_id = int(info.get("solid_id", -1))
|
|
|
|
|
|
adjacent_face_ids = _int_values(info.get("adjacent_face_ids"))
|
|
|
|
|
|
if mode == "Part" and part_id >= 0:
|
|
|
|
|
|
return {"kind": "part", "target_id": part_id, "pick_position": pick_position}
|
|
|
|
|
|
if mode == "Solid" and solid_id >= 0:
|
|
|
|
|
|
return {"kind": "solid", "target_id": solid_id, "part_id": part_id, "pick_position": pick_position}
|
|
|
|
|
|
if mode == "Feature" and adjacent_face_ids:
|
|
|
|
|
|
return {"kind": "feature", "target_id": adjacent_face_ids[0], "pick_position": pick_position}
|
|
|
|
|
|
if mode == "Face" and adjacent_face_ids:
|
|
|
|
|
|
return {"kind": "face", "target_id": adjacent_face_ids[0], "pick_position": pick_position}
|
|
|
|
|
|
if mode == "Edge":
|
|
|
|
|
|
return {"kind": "edge", "target_id": edge_id, "pick_position": pick_position}
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
def _select_pick_target(self, target: dict[str, object]) -> None:
|
|
|
|
|
|
kind = str(target["kind"])
|
|
|
|
|
|
target_id = int(target["target_id"])
|
|
|
|
|
|
pick_position = target.get("pick_position")
|
|
|
|
|
|
if kind == "part":
|
|
|
|
|
|
self.select_part(target_id, pick_position=pick_position)
|
|
|
|
|
|
elif kind == "solid":
|
|
|
|
|
|
self.select_solid(target_id, int(target["part_id"]), pick_position=pick_position)
|
|
|
|
|
|
elif kind == "feature":
|
|
|
|
|
|
self.select_feature(target_id, pick_position=pick_position)
|
|
|
|
|
|
elif kind == "edge":
|
|
|
|
|
|
self.select_edge(target_id, pick_position=pick_position)
|
|
|
|
|
|
else:
|
|
|
|
|
|
self.select_face(target_id, pick_position=pick_position)
|
|
|
|
|
|
|
|
|
|
|
|
def _select_face_cell(
|
|
|
|
|
|
self,
|
|
|
|
|
|
cell_id: int,
|
|
|
|
|
|
mode: str,
|
|
|
|
|
|
pick_position: tuple[float, float, float] | None = None,
|
|
|
|
|
|
) -> None:
|
|
|
|
|
|
if self.model is None or self.model_polydata is None:
|
|
|
|
|
|
return
|
|
|
|
|
|
face_arr = self.model_polydata.GetCellData().GetArray("face_id")
|
|
|
|
|
|
part_arr = self.model_polydata.GetCellData().GetArray("part_id")
|
|
|
|
|
|
solid_arr = self.model_polydata.GetCellData().GetArray("solid_id")
|
|
|
|
|
|
face_id = int(face_arr.GetValue(cell_id))
|
|
|
|
|
|
part_id = int(part_arr.GetValue(cell_id))
|
|
|
|
|
|
solid_id = int(solid_arr.GetValue(cell_id))
|
|
|
|
|
|
|
|
|
|
|
|
if mode == "Part":
|
|
|
|
|
|
self.select_part(part_id, pick_position=pick_position)
|
|
|
|
|
|
elif mode == "Solid":
|
|
|
|
|
|
self.select_solid(solid_id, part_id, pick_position=pick_position)
|
|
|
|
|
|
elif mode == "Feature":
|
|
|
|
|
|
self.select_feature(face_id, pick_position=pick_position)
|
|
|
|
|
|
else:
|
|
|
|
|
|
self.select_face(face_id, pick_position=pick_position)
|
|
|
|
|
|
|
|
|
|
|
|
def _select_edge_from_cell(
|
|
|
|
|
|
self,
|
|
|
|
|
|
cell_id: int,
|
|
|
|
|
|
pick_position: tuple[float, float, float] | None = None,
|
|
|
|
|
|
) -> None:
|
|
|
|
|
|
if self.model is None or self.edge_polydata is None:
|
|
|
|
|
|
return
|
|
|
|
|
|
edge_arr = self.edge_polydata.GetCellData().GetArray("edge_id")
|
|
|
|
|
|
edge_id = int(edge_arr.GetValue(cell_id))
|
|
|
|
|
|
self.select_edge(edge_id, pick_position=pick_position)
|
|
|
|
|
|
|
|
|
|
|
|
def on_part_tree_select(self, current: QTreeWidgetItem | None, _previous: QTreeWidgetItem | None) -> None:
|
2026-07-28 18:30:58 +08:00
|
|
|
|
if self._edit_busy("编辑计算中,暂时不能切换结构树选择。"):
|
2026-07-27 18:28:26 +08:00
|
|
|
|
return
|
|
|
|
|
|
if current is None:
|
|
|
|
|
|
return
|
|
|
|
|
|
node_kind = current.data(0, PART_TREE_KIND_ROLE)
|
|
|
|
|
|
target_id = current.data(0, PART_TREE_ID_ROLE)
|
|
|
|
|
|
if node_kind == "solid" and target_id is not None:
|
|
|
|
|
|
part_id = current.data(0, PART_TREE_PART_ID_ROLE)
|
|
|
|
|
|
if part_id is not None:
|
2026-07-28 18:30:58 +08:00
|
|
|
|
self._set_selection_mode("Solid")
|
2026-07-27 18:28:26 +08:00
|
|
|
|
self.select_solid(int(target_id), int(part_id))
|
|
|
|
|
|
return
|
|
|
|
|
|
if node_kind in {"part", "assembly"} and target_id is not None:
|
2026-07-28 18:30:58 +08:00
|
|
|
|
self._set_selection_mode("Part")
|
2026-07-27 18:28:26 +08:00
|
|
|
|
self.select_part(int(target_id))
|
|
|
|
|
|
|
|
|
|
|
|
def on_cylinder_row_clicked(self, row: int, _column: int) -> None:
|
2026-07-28 18:30:58 +08:00
|
|
|
|
if not hasattr(self, "cylinder_table"):
|
|
|
|
|
|
return
|
2026-07-28 14:05:14 +08:00
|
|
|
|
if self._edit_busy("编辑计算中,暂时不能切换圆柱面候选。"):
|
2026-07-27 18:28:26 +08:00
|
|
|
|
return
|
|
|
|
|
|
item = self.cylinder_table.item(row, 0)
|
|
|
|
|
|
if item is None:
|
|
|
|
|
|
return
|
|
|
|
|
|
face_id = item.data(Qt.UserRole)
|
|
|
|
|
|
if face_id is None:
|
|
|
|
|
|
return
|
2026-07-28 18:30:58 +08:00
|
|
|
|
self._set_selection_mode("Feature")
|
2026-07-27 18:28:26 +08:00
|
|
|
|
self.select_feature(int(face_id))
|
|
|
|
|
|
|
|
|
|
|
|
def on_editable_row_clicked(self, row: int, _column: int) -> None:
|
2026-07-28 18:30:58 +08:00
|
|
|
|
if not hasattr(self, "editable_table"):
|
|
|
|
|
|
return
|
2026-07-27 18:28:26 +08:00
|
|
|
|
if self._edit_busy("编辑计算中,暂时不能切换可编辑对象。"):
|
|
|
|
|
|
return
|
|
|
|
|
|
item = self.editable_table.item(row, 0)
|
|
|
|
|
|
if item is None:
|
|
|
|
|
|
return
|
|
|
|
|
|
target_id = item.data(EDITABLE_TARGET_ID_ROLE)
|
|
|
|
|
|
target_kind = item.data(EDITABLE_TARGET_KIND_ROLE)
|
|
|
|
|
|
action = item.data(EDITABLE_ACTION_ROLE)
|
|
|
|
|
|
if target_id is None:
|
|
|
|
|
|
return
|
|
|
|
|
|
if action == "resize_cylinder":
|
2026-07-28 18:30:58 +08:00
|
|
|
|
self._set_selection_mode("Feature")
|
2026-07-27 18:28:26 +08:00
|
|
|
|
self.select_feature(int(target_id))
|
2026-07-28 14:05:14 +08:00
|
|
|
|
self.statusBar().showMessage(f"已选择可调整孔径候选Face {target_id}")
|
2026-07-27 18:28:26 +08:00
|
|
|
|
elif action == "resize_boss":
|
2026-07-28 18:30:58 +08:00
|
|
|
|
self._set_selection_mode("Feature")
|
2026-07-27 18:28:26 +08:00
|
|
|
|
self.select_feature(int(target_id))
|
2026-07-28 14:05:14 +08:00
|
|
|
|
self.statusBar().showMessage(f"已选择可调整凸台直径候选Face {target_id}")
|
2026-07-29 15:43:28 +08:00
|
|
|
|
elif action == "resize_boss_height":
|
|
|
|
|
|
self._set_selection_mode("Feature")
|
|
|
|
|
|
self.select_feature(int(target_id))
|
|
|
|
|
|
self.statusBar().showMessage(f"已选择可调整凸台高度候选Face {target_id}")
|
|
|
|
|
|
elif action == "move_boss_axis":
|
|
|
|
|
|
self._set_selection_mode("Feature")
|
|
|
|
|
|
self.select_feature(int(target_id))
|
|
|
|
|
|
self.statusBar().showMessage(f"已选择可移动凸台轴心候选Face {target_id}")
|
2026-07-27 18:28:26 +08:00
|
|
|
|
elif action == "resize_slot_width":
|
2026-07-28 18:30:58 +08:00
|
|
|
|
self._set_selection_mode("Feature")
|
2026-07-27 18:28:26 +08:00
|
|
|
|
self.select_feature(int(target_id))
|
2026-07-28 14:05:14 +08:00
|
|
|
|
self.statusBar().showMessage(f"已选择可调整槽/半孔宽度候选Face {target_id}")
|
2026-07-29 15:43:28 +08:00
|
|
|
|
elif action == "resize_slot_depth":
|
|
|
|
|
|
self._set_selection_mode("Feature")
|
|
|
|
|
|
self.select_feature(int(target_id))
|
|
|
|
|
|
self.statusBar().showMessage(f"已选择可调整槽/半孔深度候选Face {target_id}")
|
|
|
|
|
|
elif action == "resize_slot_arc_length":
|
|
|
|
|
|
self._set_selection_mode("Feature")
|
|
|
|
|
|
self.select_feature(int(target_id))
|
|
|
|
|
|
self.statusBar().showMessage(f"已选择可调整槽/半孔圆弧长度候选Face {target_id}")
|
2026-07-28 14:05:14 +08:00
|
|
|
|
elif action == "resize_shell_thickness":
|
2026-07-28 18:30:58 +08:00
|
|
|
|
self._set_selection_mode("Feature")
|
2026-07-28 14:05:14 +08:00
|
|
|
|
self.select_feature(int(target_id))
|
|
|
|
|
|
self.statusBar().showMessage(f"已选择可调整薄壁厚度候选Face {target_id}")
|
2026-07-27 18:28:26 +08:00
|
|
|
|
elif action == "suppress_cylinder":
|
2026-07-28 18:30:58 +08:00
|
|
|
|
self._set_selection_mode("Feature")
|
2026-07-27 18:28:26 +08:00
|
|
|
|
self.select_feature(int(target_id))
|
2026-07-28 14:05:14 +08:00
|
|
|
|
self.statusBar().showMessage(f"已选择可封堵圆柱孔Face {target_id}")
|
2026-07-27 18:28:26 +08:00
|
|
|
|
elif action == "resize_depth":
|
2026-07-28 18:30:58 +08:00
|
|
|
|
self._set_selection_mode("Feature")
|
2026-07-27 18:28:26 +08:00
|
|
|
|
self.select_feature(int(target_id))
|
2026-07-29 15:43:28 +08:00
|
|
|
|
self.statusBar().showMessage(f"已选择可调整盲孔/盲槽深度候选Face {target_id}")
|
2026-07-27 18:28:26 +08:00
|
|
|
|
elif action == "inspect_existing_fillet":
|
2026-07-28 18:30:58 +08:00
|
|
|
|
self._set_selection_mode("Feature")
|
2026-07-27 18:28:26 +08:00
|
|
|
|
self.select_feature(int(target_id))
|
2026-07-28 14:05:14 +08:00
|
|
|
|
self.statusBar().showMessage(f"已选择已有圆角/倒圆候选Face {target_id}")
|
2026-07-27 18:28:26 +08:00
|
|
|
|
elif action == "fillet_edge":
|
2026-07-28 18:30:58 +08:00
|
|
|
|
self._set_selection_mode("Edge")
|
2026-07-27 18:28:26 +08:00
|
|
|
|
self.select_edge(int(target_id))
|
2026-07-28 14:05:14 +08:00
|
|
|
|
self.statusBar().showMessage(f"已选择可添加圆角Edge {target_id}")
|
2026-07-27 18:28:26 +08:00
|
|
|
|
elif action == "chamfer_edge":
|
2026-07-28 18:30:58 +08:00
|
|
|
|
self._set_selection_mode("Edge")
|
2026-07-27 18:28:26 +08:00
|
|
|
|
self.select_edge(int(target_id))
|
2026-07-28 14:05:14 +08:00
|
|
|
|
self.statusBar().showMessage(f"已选择可添加倒角Edge {target_id}")
|
2026-07-27 18:28:26 +08:00
|
|
|
|
elif action == "resize_edge_length":
|
2026-07-28 18:30:58 +08:00
|
|
|
|
self._set_selection_mode("Edge")
|
2026-07-27 18:28:26 +08:00
|
|
|
|
self.select_edge(int(target_id))
|
2026-07-28 14:05:14 +08:00
|
|
|
|
self.statusBar().showMessage(f"已选择可尝试调整长度的Edge {target_id}")
|
2026-07-27 18:28:26 +08:00
|
|
|
|
elif target_kind == "edge":
|
2026-07-28 18:30:58 +08:00
|
|
|
|
self._set_selection_mode("Edge")
|
2026-07-27 18:28:26 +08:00
|
|
|
|
self.select_edge(int(target_id))
|
2026-07-28 14:05:14 +08:00
|
|
|
|
self.statusBar().showMessage(f"已选择Edge {target_id}")
|
2026-07-27 18:28:26 +08:00
|
|
|
|
else:
|
2026-07-28 18:30:58 +08:00
|
|
|
|
self._set_selection_mode("Face")
|
2026-07-27 18:28:26 +08:00
|
|
|
|
self.select_face(int(target_id))
|
2026-07-28 14:05:14 +08:00
|
|
|
|
self.statusBar().showMessage(f"已选择可推拉平面Face {target_id}")
|
2026-07-27 18:28:26 +08:00
|
|
|
|
|
|
|
|
|
|
def select_by_id(self, kind: str | None = None) -> None:
|
|
|
|
|
|
if self.model is None:
|
|
|
|
|
|
return
|
|
|
|
|
|
if self._edit_busy("编辑计算中,暂时不能切换选择对象。"):
|
|
|
|
|
|
return
|
|
|
|
|
|
try:
|
|
|
|
|
|
target_id = int(self.id_input.text())
|
|
|
|
|
|
except ValueError:
|
2026-07-28 18:30:58 +08:00
|
|
|
|
QMessageBox.information(self, "ID 无效", "请输入整数 ID。零件 ID 从 1 开始,Solid/Face/Edge ID 从 0 开始。")
|
2026-07-27 18:28:26 +08:00
|
|
|
|
return
|
|
|
|
|
|
|
2026-07-28 18:30:58 +08:00
|
|
|
|
kind = _selection_mode_value(kind or self._current_selection_mode())
|
2026-07-27 18:28:26 +08:00
|
|
|
|
try:
|
|
|
|
|
|
if kind == "Part":
|
|
|
|
|
|
if self.model.part_by_id(target_id) is None:
|
2026-07-28 18:30:58 +08:00
|
|
|
|
raise ValueError(f"不存在零件 ID {target_id}")
|
|
|
|
|
|
self._set_selection_mode("Part")
|
2026-07-27 18:28:26 +08:00
|
|
|
|
self.select_part(target_id)
|
|
|
|
|
|
elif kind == "Solid":
|
|
|
|
|
|
if target_id < 0 or target_id >= len(self.model.solids):
|
|
|
|
|
|
raise ValueError(f"不存在 Solid ID {target_id}")
|
|
|
|
|
|
part_id = self.model.solids[target_id][0]
|
2026-07-28 18:30:58 +08:00
|
|
|
|
self._set_selection_mode("Solid")
|
2026-07-27 18:28:26 +08:00
|
|
|
|
self.select_solid(target_id, part_id)
|
|
|
|
|
|
elif kind == "Face":
|
|
|
|
|
|
resolved_face_id = self.model.resolve_face_selection_id(target_id)
|
|
|
|
|
|
if resolved_face_id is None:
|
|
|
|
|
|
raise ValueError(f"不存在 Face/逻辑 Face ID {target_id}")
|
2026-07-28 18:30:58 +08:00
|
|
|
|
self._set_selection_mode("Face")
|
2026-07-27 18:28:26 +08:00
|
|
|
|
self.select_face(resolved_face_id)
|
|
|
|
|
|
elif kind == "Feature":
|
|
|
|
|
|
resolved_face_id = self.model.resolve_face_selection_id(target_id)
|
|
|
|
|
|
if resolved_face_id is None:
|
2026-07-28 18:30:58 +08:00
|
|
|
|
raise ValueError(f"不存在特征来源 Face/逻辑 Face ID {target_id}")
|
|
|
|
|
|
self._set_selection_mode("Feature")
|
2026-07-27 18:28:26 +08:00
|
|
|
|
self.select_feature(resolved_face_id)
|
|
|
|
|
|
elif kind == "Edge":
|
|
|
|
|
|
if target_id < 0 or target_id >= len(self.model.edges):
|
|
|
|
|
|
raise ValueError(f"不存在 Edge ID {target_id}")
|
2026-07-28 18:30:58 +08:00
|
|
|
|
self._set_selection_mode("Edge")
|
2026-07-27 18:28:26 +08:00
|
|
|
|
self.select_edge(target_id)
|
|
|
|
|
|
self.last_id_kind = kind
|
|
|
|
|
|
except ValueError as exc:
|
|
|
|
|
|
QMessageBox.information(self, "未找到对象", str(exc))
|
|
|
|
|
|
|
|
|
|
|
|
def select_part(self, part_id: int, pick_position: tuple[float, float, float] | None = None) -> None:
|
|
|
|
|
|
if self.model is None:
|
|
|
|
|
|
return
|
|
|
|
|
|
if self._edit_busy("编辑计算中,暂时不能选择零件。"):
|
|
|
|
|
|
return
|
|
|
|
|
|
info = self.model.part_info(part_id)
|
2026-07-29 15:43:28 +08:00
|
|
|
|
self._reset_selection(clear_highlight=False, clear_info=False)
|
2026-07-27 18:28:26 +08:00
|
|
|
|
self.selected_kind = "part"
|
|
|
|
|
|
self.selected_part_id = part_id
|
|
|
|
|
|
self.selected_pick_position = pick_position
|
|
|
|
|
|
self._highlight_faces(part_ids=[part_id])
|
|
|
|
|
|
self._show_pick_marker(pick_position)
|
|
|
|
|
|
self._sync_id_picker("Part", part_id)
|
|
|
|
|
|
self.set_info(self._with_pick_info(info, pick_position))
|
|
|
|
|
|
self._update_action_states()
|
|
|
|
|
|
kind_label = _part_tree_kind_label(str(info.get("kind", "part")))
|
|
|
|
|
|
self.statusBar().showMessage(self._selection_status(f"已选择{kind_label} {part_id}", pick_position))
|
|
|
|
|
|
|
|
|
|
|
|
def select_solid(
|
|
|
|
|
|
self,
|
|
|
|
|
|
solid_id: int,
|
|
|
|
|
|
part_id: int,
|
|
|
|
|
|
pick_position: tuple[float, float, float] | None = None,
|
|
|
|
|
|
) -> None:
|
|
|
|
|
|
if self.model is None:
|
|
|
|
|
|
return
|
2026-07-28 14:05:14 +08:00
|
|
|
|
if self._edit_busy("编辑计算中,暂时不能选择Solid。"):
|
2026-07-27 18:28:26 +08:00
|
|
|
|
return
|
|
|
|
|
|
info = self.model.solid_info(solid_id)
|
2026-07-29 15:43:28 +08:00
|
|
|
|
self._reset_selection(clear_highlight=False, clear_info=False)
|
2026-07-27 18:28:26 +08:00
|
|
|
|
self.selected_kind = "solid"
|
|
|
|
|
|
self.selected_part_id = part_id
|
|
|
|
|
|
self.selected_solid_id = solid_id
|
|
|
|
|
|
self.selected_pick_position = pick_position
|
|
|
|
|
|
face_ids = [i for i, sid in enumerate(self.model.face_solid_ids) if sid == solid_id]
|
|
|
|
|
|
self._highlight_faces(face_ids=face_ids)
|
|
|
|
|
|
self._show_pick_marker(pick_position)
|
|
|
|
|
|
self._sync_id_picker("Solid", solid_id)
|
|
|
|
|
|
self.set_info(self._with_pick_info(info, pick_position))
|
|
|
|
|
|
self._update_action_states()
|
2026-07-28 14:05:14 +08:00
|
|
|
|
self.statusBar().showMessage(self._selection_status(f"已选择Solid {solid_id}", pick_position))
|
2026-07-27 18:28:26 +08:00
|
|
|
|
|
|
|
|
|
|
def select_face(
|
|
|
|
|
|
self,
|
|
|
|
|
|
face_id: int,
|
|
|
|
|
|
feature_mode: bool = False,
|
|
|
|
|
|
pick_position: tuple[float, float, float] | None = None,
|
|
|
|
|
|
) -> None:
|
|
|
|
|
|
if self.model is None:
|
|
|
|
|
|
return
|
2026-07-28 14:05:14 +08:00
|
|
|
|
if self._edit_busy("编辑计算中,暂时不能选择Face。"):
|
2026-07-27 18:28:26 +08:00
|
|
|
|
return
|
2026-07-29 15:43:28 +08:00
|
|
|
|
self._reset_selection(clear_highlight=False, clear_info=False)
|
2026-07-27 18:28:26 +08:00
|
|
|
|
self.selected_kind = "feature" if feature_mode else "face"
|
|
|
|
|
|
self.selected_face_id = face_id
|
|
|
|
|
|
self.selected_pick_position = pick_position
|
2026-07-30 17:54:01 +08:00
|
|
|
|
info = self.model.quick_face_info(face_id)
|
2026-07-27 18:28:26 +08:00
|
|
|
|
if feature_mode:
|
2026-08-04 09:35:39 +08:00
|
|
|
|
info = self._feature_context_info(face_id)
|
2026-07-30 17:54:01 +08:00
|
|
|
|
info["kind"] = "feature"
|
|
|
|
|
|
info.setdefault("feature_mode", "当前是几何候选判断,不等同于 CAD 历史特征")
|
|
|
|
|
|
highlight_face_ids = _int_values(info.get("feature_highlight_face_ids")) or [face_id]
|
2026-07-27 18:28:26 +08:00
|
|
|
|
else:
|
2026-07-30 17:54:01 +08:00
|
|
|
|
highlight_face_ids = [face_id]
|
|
|
|
|
|
logical_id = int(info.get("logical_face_id", face_id))
|
|
|
|
|
|
input_info = dict(info)
|
2026-07-28 14:05:14 +08:00
|
|
|
|
self._sync_cylindrical_edit_inputs(input_info)
|
2026-07-27 18:28:26 +08:00
|
|
|
|
self.selected_part_id = int(info["part_id"])
|
|
|
|
|
|
self.selected_solid_id = int(info["solid_id"]) if int(info["solid_id"]) >= 0 else None
|
|
|
|
|
|
self._highlight_faces(face_ids=highlight_face_ids or [face_id])
|
|
|
|
|
|
self._show_pick_marker(pick_position)
|
|
|
|
|
|
self._sync_id_picker("Feature" if feature_mode else "Face", face_id if feature_mode else logical_id)
|
2026-07-30 17:54:01 +08:00
|
|
|
|
self.set_info(self._with_pick_info(input_info, pick_position))
|
2026-07-27 18:28:26 +08:00
|
|
|
|
self._update_action_states()
|
2026-07-28 14:05:14 +08:00
|
|
|
|
raw_note = f"(拓扑Face {face_id})" if not feature_mode and logical_id != face_id else ""
|
2026-07-30 17:54:01 +08:00
|
|
|
|
message = f"已选择Face {logical_id}{raw_note}" if not feature_mode else f"已选择特征来源Face {face_id}"
|
2026-07-27 18:28:26 +08:00
|
|
|
|
self.statusBar().showMessage(self._selection_status(message, pick_position))
|
|
|
|
|
|
|
|
|
|
|
|
def select_feature(self, face_id: int, pick_position: tuple[float, float, float] | None = None) -> None:
|
|
|
|
|
|
if self.model is None:
|
|
|
|
|
|
return
|
|
|
|
|
|
if self._edit_busy("编辑计算中,暂时不能选择特征。"):
|
|
|
|
|
|
return
|
2026-08-04 09:35:39 +08:00
|
|
|
|
info = self._feature_context_info(face_id)
|
2026-07-30 17:54:01 +08:00
|
|
|
|
info["kind"] = "feature"
|
2026-07-29 15:43:28 +08:00
|
|
|
|
self._reset_selection(clear_highlight=False, clear_info=False)
|
2026-07-27 18:28:26 +08:00
|
|
|
|
self.selected_kind = "feature"
|
|
|
|
|
|
self.selected_face_id = face_id
|
|
|
|
|
|
self.selected_pick_position = pick_position
|
|
|
|
|
|
self._sync_cylindrical_edit_inputs(info)
|
|
|
|
|
|
self.selected_part_id = int(info["part_id"])
|
|
|
|
|
|
self.selected_solid_id = int(info["solid_id"]) if int(info["solid_id"]) >= 0 else None
|
|
|
|
|
|
highlight_face_ids = _int_values(info.get("feature_highlight_face_ids"))
|
|
|
|
|
|
self._highlight_faces(face_ids=highlight_face_ids or [face_id])
|
|
|
|
|
|
self._show_pick_marker(pick_position)
|
|
|
|
|
|
self._sync_id_picker("Feature", face_id)
|
|
|
|
|
|
self.set_info(self._with_pick_info(info, pick_position))
|
2026-07-28 18:30:58 +08:00
|
|
|
|
feature_type = str(info.get("feature_type", "特征候选"))
|
2026-07-27 18:28:26 +08:00
|
|
|
|
self._update_action_states()
|
2026-07-28 14:05:14 +08:00
|
|
|
|
self.statusBar().showMessage(self._selection_status(f"已选择 {feature_type},来源Face {face_id}", pick_position))
|
2026-07-27 18:28:26 +08:00
|
|
|
|
|
|
|
|
|
|
def _sync_cylindrical_edit_inputs(self, info: dict[str, object]) -> None:
|
|
|
|
|
|
fillet_radius_suggestion: str | None = None
|
2026-07-30 17:54:01 +08:00
|
|
|
|
for target_attr in (
|
|
|
|
|
|
"hole_center_x_input",
|
|
|
|
|
|
"hole_center_y_input",
|
|
|
|
|
|
"hole_center_z_input",
|
|
|
|
|
|
"slot_center_x_input",
|
|
|
|
|
|
"slot_center_y_input",
|
|
|
|
|
|
"slot_center_z_input",
|
|
|
|
|
|
"boss_center_x_input",
|
|
|
|
|
|
"boss_center_y_input",
|
|
|
|
|
|
"boss_center_z_input",
|
|
|
|
|
|
"cone_reference_radius_input",
|
|
|
|
|
|
"sphere_radius_input",
|
|
|
|
|
|
"torus_radius_input",
|
|
|
|
|
|
"face_area_input",
|
|
|
|
|
|
):
|
|
|
|
|
|
if hasattr(self, target_attr):
|
|
|
|
|
|
getattr(self, target_attr).clear()
|
2026-07-28 14:05:14 +08:00
|
|
|
|
shell_thickness = _float_or_none(info.get("shell_thickness_estimate"))
|
|
|
|
|
|
if hasattr(self, "shell_thickness_input"):
|
|
|
|
|
|
if info.get("shell_region_status") == "candidate" and shell_thickness is not None and shell_thickness > 0:
|
|
|
|
|
|
self.shell_thickness_input.setText(_format_float(shell_thickness * 1.2))
|
|
|
|
|
|
else:
|
|
|
|
|
|
self.shell_thickness_input.clear()
|
2026-07-27 18:28:26 +08:00
|
|
|
|
if "diameter" in info:
|
|
|
|
|
|
feature_guess = str(info.get("feature_guess", ""))
|
|
|
|
|
|
if feature_guess == "boss/outer-round candidate":
|
|
|
|
|
|
suggested_diameter = _format_float(float(info["diameter"]) * 1.2)
|
|
|
|
|
|
self.hole_diameter_input.clear()
|
|
|
|
|
|
if hasattr(self, "slot_width_input"):
|
|
|
|
|
|
self.slot_width_input.clear()
|
2026-07-29 15:43:28 +08:00
|
|
|
|
if hasattr(self, "slot_depth_input"):
|
|
|
|
|
|
self.slot_depth_input.clear()
|
|
|
|
|
|
if hasattr(self, "slot_arc_length_input"):
|
|
|
|
|
|
self.slot_arc_length_input.clear()
|
|
|
|
|
|
if hasattr(self, "slot_total_length_input"):
|
|
|
|
|
|
self.slot_total_length_input.clear()
|
2026-07-27 18:28:26 +08:00
|
|
|
|
self.boss_diameter_input.setText(suggested_diameter)
|
2026-07-29 15:43:28 +08:00
|
|
|
|
if hasattr(self, "boss_height_input"):
|
|
|
|
|
|
boss_height = _float_or_none(info.get("same_domain_height_estimate"))
|
|
|
|
|
|
if boss_height is None:
|
|
|
|
|
|
boss_height = _float_or_none(info.get("height_estimate"))
|
|
|
|
|
|
if boss_height is not None and boss_height > 0:
|
|
|
|
|
|
self.boss_height_input.setText(_format_float(boss_height * 1.2))
|
|
|
|
|
|
else:
|
|
|
|
|
|
self.boss_height_input.clear()
|
2026-07-27 18:28:26 +08:00
|
|
|
|
elif feature_guess == "round/fillet candidate":
|
|
|
|
|
|
radius = _float_or_none(info.get("existing_fillet_radius_estimate"))
|
|
|
|
|
|
if radius is None:
|
|
|
|
|
|
radius = _float_or_none(info.get("radius"))
|
|
|
|
|
|
if radius is not None:
|
|
|
|
|
|
fillet_radius_suggestion = _format_float(max(radius * 1.2, 0.01))
|
|
|
|
|
|
self.hole_diameter_input.clear()
|
|
|
|
|
|
if hasattr(self, "slot_width_input"):
|
|
|
|
|
|
self.slot_width_input.clear()
|
2026-07-29 15:43:28 +08:00
|
|
|
|
if hasattr(self, "slot_depth_input"):
|
|
|
|
|
|
self.slot_depth_input.clear()
|
|
|
|
|
|
if hasattr(self, "slot_arc_length_input"):
|
|
|
|
|
|
self.slot_arc_length_input.clear()
|
|
|
|
|
|
if hasattr(self, "slot_total_length_input"):
|
|
|
|
|
|
self.slot_total_length_input.clear()
|
2026-07-27 18:28:26 +08:00
|
|
|
|
self.boss_diameter_input.clear()
|
2026-07-29 15:43:28 +08:00
|
|
|
|
if hasattr(self, "boss_height_input"):
|
|
|
|
|
|
self.boss_height_input.clear()
|
2026-07-27 18:28:26 +08:00
|
|
|
|
else:
|
|
|
|
|
|
suggested_diameter = _format_float(float(info["diameter"]) * 1.2)
|
|
|
|
|
|
self.hole_diameter_input.setText(suggested_diameter)
|
|
|
|
|
|
self.boss_diameter_input.clear()
|
2026-07-29 15:43:28 +08:00
|
|
|
|
if hasattr(self, "boss_height_input"):
|
|
|
|
|
|
self.boss_height_input.clear()
|
2026-07-27 18:28:26 +08:00
|
|
|
|
if hasattr(self, "slot_width_input"):
|
|
|
|
|
|
slot_width = _float_or_none(info.get("slot_chord_width_estimate"))
|
|
|
|
|
|
if slot_width is not None and slot_width > 0:
|
|
|
|
|
|
self.slot_width_input.setText(_format_float(slot_width * 1.2))
|
|
|
|
|
|
else:
|
|
|
|
|
|
self.slot_width_input.clear()
|
2026-07-29 15:43:28 +08:00
|
|
|
|
if hasattr(self, "slot_depth_input"):
|
|
|
|
|
|
slot_depth = _float_or_none(info.get("slot_sagitta_depth_estimate"))
|
|
|
|
|
|
if slot_depth is not None and slot_depth > 0:
|
|
|
|
|
|
self.slot_depth_input.setText(_format_float(slot_depth * 1.2))
|
|
|
|
|
|
else:
|
|
|
|
|
|
self.slot_depth_input.clear()
|
|
|
|
|
|
if hasattr(self, "slot_arc_length_input"):
|
|
|
|
|
|
slot_arc = _float_or_none(info.get("slot_arc_length_estimate"))
|
|
|
|
|
|
if slot_arc is not None and slot_arc > 0:
|
|
|
|
|
|
self.slot_arc_length_input.setText(_format_float(slot_arc * 1.2))
|
|
|
|
|
|
else:
|
|
|
|
|
|
self.slot_arc_length_input.clear()
|
|
|
|
|
|
if hasattr(self, "slot_total_length_input"):
|
|
|
|
|
|
self.slot_total_length_input.clear()
|
2026-07-27 18:28:26 +08:00
|
|
|
|
else:
|
|
|
|
|
|
self.hole_diameter_input.clear()
|
|
|
|
|
|
if hasattr(self, "slot_width_input"):
|
|
|
|
|
|
self.slot_width_input.clear()
|
2026-07-29 15:43:28 +08:00
|
|
|
|
if hasattr(self, "slot_depth_input"):
|
|
|
|
|
|
self.slot_depth_input.clear()
|
|
|
|
|
|
if hasattr(self, "slot_arc_length_input"):
|
|
|
|
|
|
self.slot_arc_length_input.clear()
|
|
|
|
|
|
if hasattr(self, "slot_total_length_input"):
|
|
|
|
|
|
self.slot_total_length_input.clear()
|
2026-07-27 18:28:26 +08:00
|
|
|
|
self.boss_diameter_input.clear()
|
2026-07-29 15:43:28 +08:00
|
|
|
|
if hasattr(self, "boss_height_input"):
|
|
|
|
|
|
self.boss_height_input.clear()
|
2026-07-27 18:28:26 +08:00
|
|
|
|
if info.get("cylinder_end_type") == "blind" and "hole_depth_estimate" in info:
|
|
|
|
|
|
self.hole_depth_input.setText(_format_float(float(info["hole_depth_estimate"]) * 1.2))
|
|
|
|
|
|
else:
|
|
|
|
|
|
self.hole_depth_input.clear()
|
|
|
|
|
|
if hasattr(self, "hole_bottom_face_input"):
|
|
|
|
|
|
bottom_face_ids = _int_values(info.get("feature_bottom_face_ids"))
|
|
|
|
|
|
if bottom_face_ids:
|
|
|
|
|
|
self.hole_bottom_face_input.setText(str(bottom_face_ids[0]))
|
|
|
|
|
|
else:
|
|
|
|
|
|
self.hole_bottom_face_input.clear()
|
|
|
|
|
|
if hasattr(self, "edge_fillet_radius_input"):
|
|
|
|
|
|
if fillet_radius_suggestion is None:
|
|
|
|
|
|
self.edge_fillet_radius_input.clear()
|
|
|
|
|
|
else:
|
|
|
|
|
|
self.edge_fillet_radius_input.setText(fillet_radius_suggestion)
|
|
|
|
|
|
if hasattr(self, "edge_chamfer_distance_input"):
|
|
|
|
|
|
self.edge_chamfer_distance_input.clear()
|
|
|
|
|
|
if hasattr(self, "edge_target_length_input"):
|
|
|
|
|
|
self.edge_target_length_input.clear()
|
|
|
|
|
|
|
|
|
|
|
|
def _sync_edge_edit_inputs(self, info: dict[str, object]) -> None:
|
|
|
|
|
|
self.hole_diameter_input.clear()
|
2026-07-28 14:05:14 +08:00
|
|
|
|
if hasattr(self, "shell_thickness_input"):
|
|
|
|
|
|
self.shell_thickness_input.clear()
|
2026-07-27 18:28:26 +08:00
|
|
|
|
if hasattr(self, "slot_width_input"):
|
|
|
|
|
|
self.slot_width_input.clear()
|
2026-07-29 15:43:28 +08:00
|
|
|
|
if hasattr(self, "slot_depth_input"):
|
|
|
|
|
|
self.slot_depth_input.clear()
|
|
|
|
|
|
if hasattr(self, "slot_arc_length_input"):
|
|
|
|
|
|
self.slot_arc_length_input.clear()
|
|
|
|
|
|
if hasattr(self, "slot_total_length_input"):
|
|
|
|
|
|
self.slot_total_length_input.clear()
|
2026-07-27 18:28:26 +08:00
|
|
|
|
self.boss_diameter_input.clear()
|
2026-07-29 15:43:28 +08:00
|
|
|
|
if hasattr(self, "boss_height_input"):
|
|
|
|
|
|
self.boss_height_input.clear()
|
2026-07-27 18:28:26 +08:00
|
|
|
|
self.hole_depth_input.clear()
|
|
|
|
|
|
if hasattr(self, "hole_bottom_face_input"):
|
|
|
|
|
|
self.hole_bottom_face_input.clear()
|
|
|
|
|
|
if "length" in info:
|
|
|
|
|
|
length = float(info["length"])
|
|
|
|
|
|
self.edge_target_length_input.setText(_format_float(length))
|
|
|
|
|
|
else:
|
|
|
|
|
|
self.edge_target_length_input.clear()
|
|
|
|
|
|
if info.get("curve") == "line" and "length" in info:
|
|
|
|
|
|
length = float(info["length"])
|
|
|
|
|
|
self.edge_fillet_radius_input.setText(_format_float(max(length * 0.05, 0.01)))
|
|
|
|
|
|
self.edge_chamfer_distance_input.setText(_format_float(max(length * 0.04, 0.01)))
|
|
|
|
|
|
else:
|
|
|
|
|
|
self.edge_fillet_radius_input.clear()
|
|
|
|
|
|
self.edge_chamfer_distance_input.clear()
|
|
|
|
|
|
|
|
|
|
|
|
def select_edge(self, edge_id: int, pick_position: tuple[float, float, float] | None = None) -> None:
|
|
|
|
|
|
if self.model is None:
|
|
|
|
|
|
return
|
2026-07-28 14:05:14 +08:00
|
|
|
|
if self._edit_busy("编辑计算中,暂时不能选择Edge。"):
|
2026-07-27 18:28:26 +08:00
|
|
|
|
return
|
2026-07-29 15:43:28 +08:00
|
|
|
|
self._reset_selection(clear_highlight=False, clear_info=False)
|
2026-07-27 18:28:26 +08:00
|
|
|
|
self.selected_kind = "edge"
|
|
|
|
|
|
self.selected_edge_id = edge_id
|
|
|
|
|
|
self.selected_pick_position = pick_position
|
|
|
|
|
|
info = self.model.edge_info(edge_id)
|
|
|
|
|
|
self._sync_edge_edit_inputs(info)
|
|
|
|
|
|
self.selected_part_id = int(info["part_id"])
|
|
|
|
|
|
self.selected_solid_id = int(info["solid_id"]) if int(info.get("solid_id", -1)) >= 0 else None
|
|
|
|
|
|
self._highlight_edge(edge_id)
|
|
|
|
|
|
self._show_pick_marker(pick_position)
|
|
|
|
|
|
self._sync_id_picker("Edge", edge_id)
|
|
|
|
|
|
self.set_info(self._with_pick_info(info, pick_position))
|
|
|
|
|
|
self._update_action_states()
|
2026-07-28 14:05:14 +08:00
|
|
|
|
self.statusBar().showMessage(self._selection_status(f"已选择Edge {edge_id}", pick_position))
|
2026-07-27 18:28:26 +08:00
|
|
|
|
|
|
|
|
|
|
def _highlight_faces(self, face_ids=None, part_ids=None) -> None:
|
|
|
|
|
|
if self.model is None:
|
|
|
|
|
|
return
|
|
|
|
|
|
self._clear_highlight()
|
|
|
|
|
|
polydata = self._cached_face_overlay_polydata(face_ids=face_ids, part_ids=part_ids, smooth=True)
|
|
|
|
|
|
if polydata is None:
|
|
|
|
|
|
return
|
|
|
|
|
|
mapper = vtk.vtkPolyDataMapper()
|
|
|
|
|
|
mapper.SetInputData(polydata)
|
|
|
|
|
|
_enable_overlay_depth_offset(mapper)
|
|
|
|
|
|
actor = vtk.vtkActor()
|
|
|
|
|
|
actor.SetMapper(mapper)
|
|
|
|
|
|
actor.GetProperty().SetColor(1.0, 0.72, 0.08)
|
|
|
|
|
|
actor.GetProperty().SetOpacity(0.82)
|
|
|
|
|
|
actor.GetProperty().SetAmbient(0.45)
|
|
|
|
|
|
actor.GetProperty().SetDiffuse(0.65)
|
|
|
|
|
|
actor.GetProperty().SetSpecular(0.35)
|
|
|
|
|
|
actor.GetProperty().SetInterpolationToPhong()
|
|
|
|
|
|
actor.GetProperty().SetLineWidth(2)
|
|
|
|
|
|
self._offset_overlay_actor_toward_camera(actor, scale=0.00035)
|
|
|
|
|
|
self.highlight_actor = actor
|
|
|
|
|
|
self.renderer.AddActor(actor)
|
|
|
|
|
|
self.render_window.Render()
|
|
|
|
|
|
|
|
|
|
|
|
def _highlight_edge(self, edge_id: int) -> None:
|
|
|
|
|
|
if self.model is None:
|
|
|
|
|
|
return
|
|
|
|
|
|
self._clear_highlight()
|
|
|
|
|
|
polydata = self._cached_edge_overlay_polydata(edge_id)
|
|
|
|
|
|
if polydata is None:
|
|
|
|
|
|
return
|
|
|
|
|
|
mapper = vtk.vtkDataSetMapper()
|
|
|
|
|
|
mapper.SetInputData(polydata)
|
|
|
|
|
|
_enable_overlay_depth_offset(mapper)
|
|
|
|
|
|
actor = vtk.vtkActor()
|
|
|
|
|
|
actor.SetMapper(mapper)
|
|
|
|
|
|
actor.GetProperty().SetColor(1.0, 0.78, 0.0)
|
|
|
|
|
|
actor.GetProperty().SetAmbient(0.7)
|
|
|
|
|
|
actor.GetProperty().SetDiffuse(0.8)
|
|
|
|
|
|
actor.GetProperty().SetLineWidth(5)
|
|
|
|
|
|
self.edge_highlight_actor = actor
|
|
|
|
|
|
self.renderer.AddActor(actor)
|
|
|
|
|
|
self.render_window.Render()
|
|
|
|
|
|
|
|
|
|
|
|
def _hover_signature_for_target(self, target: dict[str, object] | None) -> tuple[str, int] | None:
|
|
|
|
|
|
if self.model is None or target is None:
|
|
|
|
|
|
return None
|
|
|
|
|
|
kind = str(target["kind"])
|
|
|
|
|
|
target_id = int(target["target_id"])
|
|
|
|
|
|
return (kind, target_id)
|
|
|
|
|
|
|
|
|
|
|
|
def _show_hover_target(self, target: dict[str, object] | None) -> None:
|
|
|
|
|
|
if self.model is None:
|
|
|
|
|
|
self._clear_hover(render=True)
|
|
|
|
|
|
return
|
|
|
|
|
|
signature = self._hover_signature_for_target(target)
|
|
|
|
|
|
if signature == self.hover_signature:
|
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
|
|
self._clear_hover(render=False)
|
|
|
|
|
|
if target is None:
|
|
|
|
|
|
self.render_window.Render()
|
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
|
|
kind = str(target["kind"])
|
|
|
|
|
|
target_id = int(target["target_id"])
|
|
|
|
|
|
if kind == "part":
|
|
|
|
|
|
self._highlight_hover_faces(part_ids=[target_id])
|
|
|
|
|
|
elif kind == "solid":
|
|
|
|
|
|
face_ids = [index for index, solid_id in enumerate(self.model.face_solid_ids) if solid_id == target_id]
|
|
|
|
|
|
self._highlight_hover_faces(face_ids=face_ids)
|
|
|
|
|
|
elif kind == "feature":
|
2026-07-30 17:54:01 +08:00
|
|
|
|
self._highlight_hover_faces(face_ids=[target_id])
|
2026-07-27 18:28:26 +08:00
|
|
|
|
elif kind == "edge":
|
|
|
|
|
|
self._highlight_hover_edge(target_id)
|
|
|
|
|
|
else:
|
2026-07-30 17:54:01 +08:00
|
|
|
|
self._highlight_hover_faces(face_ids=[target_id])
|
2026-07-27 18:28:26 +08:00
|
|
|
|
self.hover_signature = signature
|
|
|
|
|
|
self.render_window.Render()
|
|
|
|
|
|
|
|
|
|
|
|
def _highlight_hover_faces(self, face_ids=None, part_ids=None) -> None:
|
|
|
|
|
|
if self.model is None:
|
|
|
|
|
|
return
|
|
|
|
|
|
polydata = self._cached_face_overlay_polydata(face_ids=face_ids, part_ids=part_ids, smooth=False)
|
|
|
|
|
|
if polydata is None:
|
|
|
|
|
|
return
|
|
|
|
|
|
mapper = vtk.vtkPolyDataMapper()
|
|
|
|
|
|
mapper.SetInputData(polydata)
|
|
|
|
|
|
_enable_overlay_depth_offset(mapper)
|
|
|
|
|
|
actor = vtk.vtkActor()
|
|
|
|
|
|
actor.SetMapper(mapper)
|
|
|
|
|
|
actor.GetProperty().SetColor(1.0, 0.12, 0.06)
|
|
|
|
|
|
actor.GetProperty().SetOpacity(0.58)
|
|
|
|
|
|
actor.GetProperty().SetAmbient(0.5)
|
|
|
|
|
|
actor.GetProperty().SetDiffuse(0.7)
|
|
|
|
|
|
actor.GetProperty().SetSpecular(0.35)
|
|
|
|
|
|
actor.GetProperty().SetInterpolationToPhong()
|
|
|
|
|
|
actor.GetProperty().LightingOff()
|
|
|
|
|
|
actor.GetProperty().SetLineWidth(2)
|
|
|
|
|
|
self._offset_overlay_actor_toward_camera(actor, scale=0.00055)
|
|
|
|
|
|
self.hover_face_actor = actor
|
|
|
|
|
|
self.renderer.AddActor(actor)
|
|
|
|
|
|
|
|
|
|
|
|
def _offset_overlay_actor_toward_camera(self, actor, scale: float = 0.0005) -> None:
|
|
|
|
|
|
bounds = self.model_actor.GetBounds() if self.model_actor is not None else None
|
|
|
|
|
|
camera = self.renderer.GetActiveCamera() if hasattr(self, "renderer") else None
|
|
|
|
|
|
if bounds is None or camera is None:
|
|
|
|
|
|
return
|
|
|
|
|
|
dx = float(bounds[1] - bounds[0])
|
|
|
|
|
|
dy = float(bounds[3] - bounds[2])
|
|
|
|
|
|
dz = float(bounds[5] - bounds[4])
|
|
|
|
|
|
diagonal = math.sqrt(dx * dx + dy * dy + dz * dz)
|
|
|
|
|
|
if diagonal <= 1e-9:
|
|
|
|
|
|
return
|
|
|
|
|
|
direction = camera.GetDirectionOfProjection()
|
|
|
|
|
|
amount = diagonal * float(scale)
|
|
|
|
|
|
actor.SetPosition(
|
|
|
|
|
|
-float(direction[0]) * amount,
|
|
|
|
|
|
-float(direction[1]) * amount,
|
|
|
|
|
|
-float(direction[2]) * amount,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
def _highlight_hover_edge(self, edge_id: int) -> None:
|
|
|
|
|
|
if self.model is None:
|
|
|
|
|
|
return
|
|
|
|
|
|
polydata = self._cached_edge_overlay_polydata(edge_id)
|
|
|
|
|
|
if polydata is None:
|
|
|
|
|
|
return
|
|
|
|
|
|
mapper = vtk.vtkDataSetMapper()
|
|
|
|
|
|
mapper.SetInputData(polydata)
|
|
|
|
|
|
_enable_overlay_depth_offset(mapper)
|
|
|
|
|
|
actor = vtk.vtkActor()
|
|
|
|
|
|
actor.SetMapper(mapper)
|
|
|
|
|
|
actor.GetProperty().SetColor(1.0, 0.08, 0.02)
|
|
|
|
|
|
actor.GetProperty().SetAmbient(0.75)
|
|
|
|
|
|
actor.GetProperty().SetDiffuse(0.8)
|
|
|
|
|
|
actor.GetProperty().SetLineWidth(4)
|
|
|
|
|
|
self.hover_edge_actor = actor
|
|
|
|
|
|
self.renderer.AddActor(actor)
|
|
|
|
|
|
|
|
|
|
|
|
def _clear_hover(self, render: bool = False) -> None:
|
|
|
|
|
|
if not hasattr(self, "renderer"):
|
|
|
|
|
|
return
|
|
|
|
|
|
removed = False
|
|
|
|
|
|
if self.hover_face_actor is not None:
|
|
|
|
|
|
self.renderer.RemoveActor(self.hover_face_actor)
|
|
|
|
|
|
self.hover_face_actor = None
|
|
|
|
|
|
removed = True
|
|
|
|
|
|
if self.hover_edge_actor is not None:
|
|
|
|
|
|
self.renderer.RemoveActor(self.hover_edge_actor)
|
|
|
|
|
|
self.hover_edge_actor = None
|
|
|
|
|
|
removed = True
|
|
|
|
|
|
self.hover_signature = None
|
|
|
|
|
|
if render and removed and hasattr(self, "render_window"):
|
|
|
|
|
|
self.render_window.Render()
|
|
|
|
|
|
|
|
|
|
|
|
def _clear_highlight(self) -> None:
|
|
|
|
|
|
if self.highlight_actor is not None:
|
|
|
|
|
|
self.renderer.RemoveActor(self.highlight_actor)
|
|
|
|
|
|
self.highlight_actor = None
|
|
|
|
|
|
if self.edge_highlight_actor is not None:
|
|
|
|
|
|
self.renderer.RemoveActor(self.edge_highlight_actor)
|
|
|
|
|
|
self.edge_highlight_actor = None
|
|
|
|
|
|
if self.pick_marker_actor is not None:
|
|
|
|
|
|
self.renderer.RemoveActor(self.pick_marker_actor)
|
|
|
|
|
|
self.pick_marker_actor = None
|
|
|
|
|
|
|
|
|
|
|
|
def clear_edit_preview(self, render: bool = True) -> None:
|
|
|
|
|
|
if self.edit_preview_timer is not None:
|
|
|
|
|
|
self.edit_preview_timer.stop()
|
|
|
|
|
|
if hasattr(self, "renderer"):
|
|
|
|
|
|
for actor in self.edit_preview_actors:
|
|
|
|
|
|
self.renderer.RemoveActor(actor)
|
|
|
|
|
|
if self.edit_preview_actor is not None and self.edit_preview_actor not in self.edit_preview_actors:
|
|
|
|
|
|
self.renderer.RemoveActor(self.edit_preview_actor)
|
|
|
|
|
|
self.edit_preview_actors = []
|
|
|
|
|
|
self.edit_preview_actor = None
|
|
|
|
|
|
if render and hasattr(self, "render_window"):
|
|
|
|
|
|
self.render_window.Render()
|
|
|
|
|
|
|
|
|
|
|
|
def _add_edit_preview_actor(
|
|
|
|
|
|
self,
|
|
|
|
|
|
polydata,
|
|
|
|
|
|
color: tuple[float, float, float],
|
|
|
|
|
|
opacity: float | None = None,
|
2026-07-29 15:43:28 +08:00
|
|
|
|
position: tuple[float, float, float] | None = None,
|
2026-07-27 18:28:26 +08:00
|
|
|
|
) -> None:
|
|
|
|
|
|
mapper = vtk.vtkPolyDataMapper()
|
|
|
|
|
|
mapper.SetInputData(polydata)
|
|
|
|
|
|
actor = vtk.vtkActor()
|
|
|
|
|
|
actor.SetMapper(mapper)
|
|
|
|
|
|
actor.GetProperty().SetColor(*color)
|
|
|
|
|
|
actor.GetProperty().SetOpacity(opacity if opacity is not None else self.edit_preview_base_opacity)
|
|
|
|
|
|
actor.GetProperty().SetSpecular(0.28)
|
|
|
|
|
|
actor.GetProperty().SetLineWidth(1)
|
2026-07-29 15:43:28 +08:00
|
|
|
|
if position is not None:
|
|
|
|
|
|
actor.SetPosition(float(position[0]), float(position[1]), float(position[2]))
|
2026-07-27 18:28:26 +08:00
|
|
|
|
self.edit_preview_actors.append(actor)
|
|
|
|
|
|
self.edit_preview_actor = self.edit_preview_actor or actor
|
|
|
|
|
|
self.renderer.AddActor(actor)
|
|
|
|
|
|
|
2026-07-29 15:43:28 +08:00
|
|
|
|
def _show_push_pull_preview(self, face_id: int, distance: float, plan: dict[str, object] | None = None) -> None:
|
2026-07-27 18:28:26 +08:00
|
|
|
|
if self.model is None:
|
|
|
|
|
|
return
|
|
|
|
|
|
self.clear_edit_preview(render=False)
|
|
|
|
|
|
try:
|
2026-07-29 15:43:28 +08:00
|
|
|
|
plan = dict(plan or self.model.push_pull_plan(face_id, distance))
|
|
|
|
|
|
scope_face_ids = _int_values(plan.get("push_pull_scope_face_ids")) or [face_id]
|
|
|
|
|
|
polydata = self._cached_face_overlay_polydata(face_ids=scope_face_ids, smooth=False)
|
|
|
|
|
|
if polydata is None:
|
|
|
|
|
|
polydata = self._cached_face_overlay_polydata(face_ids=[face_id], smooth=False)
|
|
|
|
|
|
if polydata is None:
|
|
|
|
|
|
raise RuntimeError("当前显示网格里没有可复用的选中 Face 预览数据")
|
2026-07-27 18:28:26 +08:00
|
|
|
|
except Exception as exc:
|
|
|
|
|
|
self.statusBar().showMessage(f"推拉预览不可用:{exc}")
|
|
|
|
|
|
return
|
|
|
|
|
|
if distance >= 0:
|
|
|
|
|
|
color = (0.0, 0.86, 0.34)
|
|
|
|
|
|
else:
|
|
|
|
|
|
color = (1.0, 0.18, 0.06)
|
2026-07-29 15:43:28 +08:00
|
|
|
|
outward = plan.get("outward_direction") if isinstance(plan, dict) else None
|
|
|
|
|
|
if not isinstance(outward, (tuple, list)) or len(outward) != 3:
|
|
|
|
|
|
outward = (0.0, 0.0, 0.0)
|
|
|
|
|
|
position = (
|
|
|
|
|
|
float(outward[0]) * float(distance),
|
|
|
|
|
|
float(outward[1]) * float(distance),
|
|
|
|
|
|
float(outward[2]) * float(distance),
|
|
|
|
|
|
)
|
|
|
|
|
|
self._add_edit_preview_actor(polydata, color, opacity=0.38, position=position)
|
2026-07-27 18:28:26 +08:00
|
|
|
|
self._start_edit_preview_pulse()
|
|
|
|
|
|
self.render_window.Render()
|
|
|
|
|
|
|
2026-08-04 09:35:39 +08:00
|
|
|
|
def _show_shell_thickness_preview(
|
|
|
|
|
|
self,
|
|
|
|
|
|
face_id: int,
|
|
|
|
|
|
target_thickness: float,
|
|
|
|
|
|
plan: dict[str, object] | None = None,
|
|
|
|
|
|
) -> None:
|
2026-07-28 14:05:14 +08:00
|
|
|
|
if self.model is None:
|
|
|
|
|
|
return
|
|
|
|
|
|
self.clear_edit_preview(render=False)
|
|
|
|
|
|
try:
|
2026-08-04 09:35:39 +08:00
|
|
|
|
plan = dict(plan or self.model.shell_thickness_plan(face_id, target_thickness))
|
|
|
|
|
|
scope_face_ids = _int_values(plan.get("shell_source_face_ids")) or [face_id]
|
|
|
|
|
|
polydata = self._cached_face_overlay_polydata(face_ids=scope_face_ids, smooth=False)
|
|
|
|
|
|
if polydata is None:
|
|
|
|
|
|
polydata = self._cached_face_overlay_polydata(face_ids=[face_id], smooth=False)
|
|
|
|
|
|
if polydata is None:
|
|
|
|
|
|
raise RuntimeError("当前显示网格里没有可复用的薄壁预览数据")
|
2026-07-28 14:05:14 +08:00
|
|
|
|
except Exception as exc:
|
|
|
|
|
|
self.statusBar().showMessage(f"薄壁厚度调整预览不可用:{exc}")
|
|
|
|
|
|
return
|
2026-08-04 09:35:39 +08:00
|
|
|
|
movement = _triple_or_none(plan.get("shell_desired_movement_vector")) if isinstance(plan, dict) else None
|
|
|
|
|
|
if movement is None and isinstance(plan, dict):
|
|
|
|
|
|
outward = _triple_or_none(plan.get("outward_direction"))
|
|
|
|
|
|
distance = _float_or_none(plan.get("push_pull_distance"))
|
|
|
|
|
|
if outward is not None and distance is not None:
|
|
|
|
|
|
movement = (
|
|
|
|
|
|
float(outward[0]) * float(distance),
|
|
|
|
|
|
float(outward[1]) * float(distance),
|
|
|
|
|
|
float(outward[2]) * float(distance),
|
|
|
|
|
|
)
|
|
|
|
|
|
self._add_edit_preview_actor(polydata, (0.0, 0.72, 0.78), opacity=0.34, position=movement)
|
2026-07-28 14:05:14 +08:00
|
|
|
|
self._start_edit_preview_pulse()
|
|
|
|
|
|
self.render_window.Render()
|
|
|
|
|
|
|
2026-07-27 18:28:26 +08:00
|
|
|
|
def _show_cylinder_resize_preview(self, face_id: int, diameter: float) -> None:
|
|
|
|
|
|
if self.model is None:
|
|
|
|
|
|
return
|
|
|
|
|
|
self.clear_edit_preview(render=False)
|
|
|
|
|
|
try:
|
|
|
|
|
|
previews = self.model.cylindrical_resize_preview_polydata(face_id, diameter)
|
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
|
self.statusBar().showMessage(f"孔径调整预览不可用:{exc}")
|
|
|
|
|
|
return
|
|
|
|
|
|
for preview in previews:
|
|
|
|
|
|
role = str(preview["role"])
|
|
|
|
|
|
color = (0.0, 0.86, 0.34) if role == "fill" else (1.0, 0.18, 0.06)
|
|
|
|
|
|
opacity = 0.28 if role == "fill" else 0.32
|
|
|
|
|
|
self._add_edit_preview_actor(preview["polydata"], color, opacity=opacity)
|
|
|
|
|
|
self._start_edit_preview_pulse()
|
|
|
|
|
|
self.render_window.Render()
|
|
|
|
|
|
|
|
|
|
|
|
def _show_cylinder_boss_resize_preview(self, face_id: int, diameter: float) -> None:
|
|
|
|
|
|
if self.model is None:
|
|
|
|
|
|
return
|
|
|
|
|
|
self.clear_edit_preview(render=False)
|
|
|
|
|
|
try:
|
|
|
|
|
|
previews = self.model.cylindrical_boss_resize_preview_polydata(face_id, diameter)
|
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
|
self.statusBar().showMessage(f"凸台直径调整预览不可用:{exc}")
|
|
|
|
|
|
return
|
|
|
|
|
|
for preview in previews:
|
|
|
|
|
|
role = str(preview["role"])
|
|
|
|
|
|
color = (0.0, 0.86, 0.34) if role == "fill" else (1.0, 0.18, 0.06)
|
|
|
|
|
|
opacity = 0.3 if role == "fill" else 0.34
|
|
|
|
|
|
self._add_edit_preview_actor(preview["polydata"], color, opacity=opacity)
|
|
|
|
|
|
self._start_edit_preview_pulse()
|
|
|
|
|
|
self.render_window.Render()
|
|
|
|
|
|
|
2026-07-29 15:43:28 +08:00
|
|
|
|
def _show_cylinder_boss_height_preview(self, face_id: int, target_height: float) -> None:
|
|
|
|
|
|
if self.model is None:
|
|
|
|
|
|
return
|
|
|
|
|
|
self.clear_edit_preview(render=False)
|
|
|
|
|
|
try:
|
|
|
|
|
|
polydata = self.model.cylindrical_boss_height_preview_polydata(face_id, target_height)
|
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
|
self.statusBar().showMessage(f"凸台高度调整预览不可用:{exc}")
|
|
|
|
|
|
return
|
|
|
|
|
|
self._add_edit_preview_actor(polydata, (0.0, 0.72, 0.78), opacity=0.34)
|
|
|
|
|
|
self._start_edit_preview_pulse()
|
|
|
|
|
|
self.render_window.Render()
|
|
|
|
|
|
|
2026-07-27 18:28:26 +08:00
|
|
|
|
def _show_cylinder_suppress_preview(self, face_id: int) -> None:
|
|
|
|
|
|
if self.model is None:
|
|
|
|
|
|
return
|
|
|
|
|
|
self.clear_edit_preview(render=False)
|
|
|
|
|
|
try:
|
|
|
|
|
|
previews = self.model.cylindrical_suppress_preview_polydata(face_id)
|
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
|
self.statusBar().showMessage(f"封堵圆柱孔预览不可用:{exc}")
|
|
|
|
|
|
return
|
|
|
|
|
|
for preview in previews:
|
|
|
|
|
|
self._add_edit_preview_actor(preview["polydata"], (0.0, 0.86, 0.34), opacity=0.3)
|
|
|
|
|
|
self._start_edit_preview_pulse()
|
|
|
|
|
|
self.render_window.Render()
|
|
|
|
|
|
|
|
|
|
|
|
def _show_cylinder_depth_preview(
|
|
|
|
|
|
self,
|
|
|
|
|
|
face_id: int,
|
|
|
|
|
|
target_depth: float,
|
|
|
|
|
|
bottom_face_id: int | None = None,
|
|
|
|
|
|
) -> None:
|
|
|
|
|
|
if self.model is None:
|
|
|
|
|
|
return
|
|
|
|
|
|
self.clear_edit_preview(render=False)
|
|
|
|
|
|
try:
|
|
|
|
|
|
previews = self.model.cylindrical_depth_preview_polydata(
|
|
|
|
|
|
face_id,
|
|
|
|
|
|
target_depth,
|
|
|
|
|
|
bottom_face_id=bottom_face_id,
|
|
|
|
|
|
)
|
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
|
self.statusBar().showMessage(f"孔深调整预览不可用:{exc}")
|
|
|
|
|
|
return
|
|
|
|
|
|
for preview in previews:
|
|
|
|
|
|
role = str(preview["role"])
|
|
|
|
|
|
color = (0.0, 0.86, 0.34) if role == "fill" else (1.0, 0.18, 0.06)
|
|
|
|
|
|
opacity = 0.3 if role == "fill" else 0.34
|
|
|
|
|
|
self._add_edit_preview_actor(preview["polydata"], color, opacity=opacity)
|
|
|
|
|
|
self._start_edit_preview_pulse()
|
|
|
|
|
|
self.render_window.Render()
|
|
|
|
|
|
|
|
|
|
|
|
def _show_existing_fillet_resize_preview(self, face_id: int, target_radius: float) -> None:
|
|
|
|
|
|
if self.model is None:
|
|
|
|
|
|
return
|
|
|
|
|
|
self.clear_edit_preview(render=False)
|
|
|
|
|
|
try:
|
|
|
|
|
|
previews = self.model.existing_fillet_resize_preview_polydata(face_id, target_radius)
|
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
|
self.statusBar().showMessage(f"已有圆角半径修改预览不可用:{exc}")
|
|
|
|
|
|
return
|
|
|
|
|
|
for preview in previews:
|
|
|
|
|
|
self._add_edit_preview_actor(preview["polydata"], (0.35, 0.45, 1.0), opacity=0.36)
|
|
|
|
|
|
self._start_edit_preview_pulse()
|
|
|
|
|
|
self.render_window.Render()
|
|
|
|
|
|
|
|
|
|
|
|
def _show_edge_fillet_preview(self, edge_id: int, radius: float) -> None:
|
|
|
|
|
|
if self.model is None:
|
|
|
|
|
|
return
|
|
|
|
|
|
self._show_edge_tube_preview(edge_id, radius, (0.1, 0.62, 1.0), "圆角预览")
|
|
|
|
|
|
|
|
|
|
|
|
def _show_edge_chamfer_preview(self, edge_id: int, distance: float) -> None:
|
|
|
|
|
|
if self.model is None:
|
|
|
|
|
|
return
|
|
|
|
|
|
self._show_edge_tube_preview(edge_id, distance, (1.0, 0.55, 0.08), "倒角预览")
|
|
|
|
|
|
|
2026-07-30 17:54:01 +08:00
|
|
|
|
def _show_edge_length_preview(
|
|
|
|
|
|
self,
|
|
|
|
|
|
edge_id: int,
|
|
|
|
|
|
target_length: float,
|
|
|
|
|
|
anchor_mode: str = "auto",
|
|
|
|
|
|
strategy_mode: str = "auto",
|
|
|
|
|
|
) -> None:
|
2026-07-27 18:28:26 +08:00
|
|
|
|
if self.model is None:
|
|
|
|
|
|
return
|
|
|
|
|
|
self.clear_edit_preview(render=False)
|
|
|
|
|
|
try:
|
2026-07-30 17:54:01 +08:00
|
|
|
|
polydata = self.model.straight_edge_length_preview_polydata(
|
|
|
|
|
|
edge_id,
|
|
|
|
|
|
target_length,
|
|
|
|
|
|
anchor_mode=anchor_mode,
|
|
|
|
|
|
strategy_mode=strategy_mode,
|
|
|
|
|
|
)
|
2026-07-27 18:28:26 +08:00
|
|
|
|
except Exception as exc:
|
2026-07-28 14:05:14 +08:00
|
|
|
|
self.statusBar().showMessage(f"Edge长度修改预览不可用:{exc}")
|
2026-07-27 18:28:26 +08:00
|
|
|
|
return
|
|
|
|
|
|
if isinstance(polydata, list):
|
|
|
|
|
|
for preview in polydata:
|
|
|
|
|
|
role = str(preview.get("role", ""))
|
|
|
|
|
|
color = (0.0, 0.86, 0.34) if role == "fill" else (1.0, 0.18, 0.06)
|
|
|
|
|
|
opacity = 0.3 if role == "fill" else 0.34
|
|
|
|
|
|
self._add_edit_preview_actor(preview["polydata"], color, opacity=opacity)
|
|
|
|
|
|
else:
|
|
|
|
|
|
self._add_edit_preview_actor(polydata, (0.0, 0.72, 0.78), opacity=0.34)
|
|
|
|
|
|
self._start_edit_preview_pulse()
|
|
|
|
|
|
self.render_window.Render()
|
|
|
|
|
|
|
|
|
|
|
|
def _show_edge_tube_preview(
|
|
|
|
|
|
self,
|
|
|
|
|
|
edge_id: int,
|
|
|
|
|
|
radius: float,
|
|
|
|
|
|
color: tuple[float, float, float],
|
|
|
|
|
|
label: str,
|
|
|
|
|
|
) -> None:
|
|
|
|
|
|
if self.model is None:
|
|
|
|
|
|
return
|
|
|
|
|
|
self.clear_edit_preview(render=False)
|
|
|
|
|
|
polydata = self.model.build_edge_polydata(edge_ids=[edge_id])
|
|
|
|
|
|
edge_arr = polydata.GetCellData().GetArray("edge_id")
|
|
|
|
|
|
ids = vtk.vtkIdList()
|
|
|
|
|
|
for cell_id in range(polydata.GetNumberOfCells()):
|
|
|
|
|
|
if int(edge_arr.GetValue(cell_id)) == edge_id:
|
|
|
|
|
|
ids.InsertNextId(cell_id)
|
|
|
|
|
|
break
|
|
|
|
|
|
if ids.GetNumberOfIds() == 0:
|
2026-07-28 14:05:14 +08:00
|
|
|
|
self.statusBar().showMessage(f"{label}不可用:没有找到选中的Edge。")
|
2026-07-27 18:28:26 +08:00
|
|
|
|
return
|
|
|
|
|
|
extract = vtk.vtkExtractCells()
|
|
|
|
|
|
extract.SetInputData(polydata)
|
|
|
|
|
|
extract.SetCellList(ids)
|
|
|
|
|
|
extract.Update()
|
|
|
|
|
|
geometry = vtk.vtkGeometryFilter()
|
|
|
|
|
|
geometry.SetInputConnection(extract.GetOutputPort())
|
|
|
|
|
|
tube = vtk.vtkTubeFilter()
|
|
|
|
|
|
tube.SetInputConnection(geometry.GetOutputPort())
|
|
|
|
|
|
tube.SetRadius(radius)
|
|
|
|
|
|
tube.SetNumberOfSides(24)
|
|
|
|
|
|
tube.CappingOn()
|
|
|
|
|
|
tube.Update()
|
|
|
|
|
|
self._add_edit_preview_actor(tube.GetOutput(), color, opacity=0.34)
|
|
|
|
|
|
self._start_edit_preview_pulse()
|
|
|
|
|
|
self.render_window.Render()
|
|
|
|
|
|
|
|
|
|
|
|
def _start_edit_preview_pulse(self) -> None:
|
|
|
|
|
|
if self.edit_preview_timer is None:
|
|
|
|
|
|
self.edit_preview_timer = QTimer(self)
|
|
|
|
|
|
self.edit_preview_timer.timeout.connect(self._pulse_edit_preview)
|
|
|
|
|
|
self.edit_preview_phase = 0.0
|
|
|
|
|
|
self.edit_preview_timer.start(120)
|
|
|
|
|
|
|
|
|
|
|
|
def _pulse_edit_preview(self) -> None:
|
|
|
|
|
|
if not self.edit_preview_actors:
|
|
|
|
|
|
if self.edit_preview_timer is not None:
|
|
|
|
|
|
self.edit_preview_timer.stop()
|
|
|
|
|
|
return
|
|
|
|
|
|
self.edit_preview_phase += 0.35
|
|
|
|
|
|
opacity = self.edit_preview_base_opacity * (0.75 + 0.25 * (math.sin(self.edit_preview_phase) + 1.0) / 2.0)
|
|
|
|
|
|
for actor in self.edit_preview_actors:
|
|
|
|
|
|
actor.GetProperty().SetOpacity(opacity)
|
|
|
|
|
|
self.render_window.Render()
|
|
|
|
|
|
|
|
|
|
|
|
def clear_diff_preview(self, render: bool = True) -> None:
|
|
|
|
|
|
if not hasattr(self, "renderer"):
|
|
|
|
|
|
self.diff_actors.clear()
|
|
|
|
|
|
return
|
|
|
|
|
|
for actor in self.diff_actors:
|
|
|
|
|
|
self.renderer.RemoveViewProp(actor)
|
|
|
|
|
|
self.diff_actors.clear()
|
|
|
|
|
|
if render and hasattr(self, "render_window"):
|
|
|
|
|
|
self.render_window.Render()
|
|
|
|
|
|
self.statusBar().showMessage("已清除差异预览")
|
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 _show_operation_diff(self, record: OperationRecord) -> str:
|
|
|
|
|
|
if self.model is None:
|
|
|
|
|
|
return ""
|
|
|
|
|
|
self.clear_diff_preview(render=False)
|
|
|
|
|
|
if record.before_snapshot is None or record.after_snapshot is None:
|
|
|
|
|
|
return "差异预览: 这条历史记录没有可显示的前后模型快照。"
|
|
|
|
|
|
|
|
|
|
|
|
before_polydata = self.model.build_snapshot_polydata(record.before_snapshot)
|
|
|
|
|
|
after_polydata = self.model.build_snapshot_polydata(record.after_snapshot)
|
|
|
|
|
|
before_actor = self._make_diff_actor(before_polydata, color=(1.0, 0.16, 0.08), opacity=0.24)
|
|
|
|
|
|
after_actor = self._make_diff_actor(after_polydata, color=(0.0, 0.9, 0.28), opacity=0.16)
|
|
|
|
|
|
heatmap_actor, scalar_bar, heatmap_stats = self._make_distance_heatmap_props(before_polydata, after_polydata)
|
|
|
|
|
|
self.diff_actors = [before_actor, after_actor]
|
|
|
|
|
|
if heatmap_actor is not None:
|
|
|
|
|
|
self.diff_actors.append(heatmap_actor)
|
|
|
|
|
|
if scalar_bar is not None:
|
|
|
|
|
|
self.diff_actors.append(scalar_bar)
|
|
|
|
|
|
for actor in self.diff_actors:
|
|
|
|
|
|
self.renderer.AddViewProp(actor)
|
|
|
|
|
|
self.render_window.Render()
|
2026-07-28 14:05:14 +08:00
|
|
|
|
self._update_action_states()
|
2026-07-27 18:28:26 +08:00
|
|
|
|
if heatmap_stats:
|
|
|
|
|
|
record.diff_stats = heatmap_stats
|
|
|
|
|
|
return (
|
|
|
|
|
|
"差异预览: 红色半透明为编辑前,绿色半透明为编辑后;"
|
|
|
|
|
|
"热力图覆盖在编辑后模型上,蓝色接近无变化,黄色/红色表示变化更大。\n"
|
|
|
|
|
|
f"热力图统计: max_distance={_format_value(heatmap_stats['max_distance'])}, "
|
|
|
|
|
|
f"mean_distance={_format_value(heatmap_stats['mean_distance'])}, "
|
|
|
|
|
|
f"changed_points={heatmap_stats['changed_points']}/{heatmap_stats['points']} "
|
|
|
|
|
|
f"({_format_value(heatmap_stats['changed_ratio'] * 100.0)}%)."
|
|
|
|
|
|
)
|
|
|
|
|
|
return "差异预览: 红色半透明为编辑前,绿色半透明为编辑后;热力图无法生成。"
|
|
|
|
|
|
|
|
|
|
|
|
def _ensure_record_diff_stats(self, record: OperationRecord) -> dict[str, object]:
|
|
|
|
|
|
if record.diff_stats is not None:
|
|
|
|
|
|
return record.diff_stats
|
|
|
|
|
|
if self.model is None or record.before_snapshot is None or record.after_snapshot is None:
|
|
|
|
|
|
record.diff_stats = {}
|
|
|
|
|
|
return record.diff_stats
|
|
|
|
|
|
before_polydata = self.model.build_snapshot_polydata(record.before_snapshot)
|
|
|
|
|
|
after_polydata = self.model.build_snapshot_polydata(record.after_snapshot)
|
|
|
|
|
|
_heat_polydata, stats = self._build_distance_heatmap_polydata(before_polydata, after_polydata)
|
|
|
|
|
|
record.diff_stats = stats
|
|
|
|
|
|
return stats
|
|
|
|
|
|
|
|
|
|
|
|
def _diff_report_text(self, record: OperationRecord, history_index: int) -> str:
|
|
|
|
|
|
lines = [
|
|
|
|
|
|
"STEP 编辑差异报告",
|
|
|
|
|
|
"",
|
|
|
|
|
|
f"generated_at: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}",
|
|
|
|
|
|
f"source_file: {self.step_path}",
|
|
|
|
|
|
f"history_index: {history_index}",
|
|
|
|
|
|
f"summary: {record.summary}",
|
|
|
|
|
|
"",
|
|
|
|
|
|
"操作详情:",
|
|
|
|
|
|
record.detail,
|
|
|
|
|
|
"",
|
|
|
|
|
|
"距离热力图统计:",
|
|
|
|
|
|
]
|
|
|
|
|
|
stats = record.diff_stats or {}
|
|
|
|
|
|
if stats:
|
|
|
|
|
|
lines.extend(
|
|
|
|
|
|
[
|
|
|
|
|
|
f" points: {stats.get('points', '')}",
|
|
|
|
|
|
f" max_distance: {_format_value(stats.get('max_distance', ''))}",
|
|
|
|
|
|
f" mean_distance: {_format_value(stats.get('mean_distance', ''))}",
|
|
|
|
|
|
f" changed_points: {stats.get('changed_points', '')}",
|
|
|
|
|
|
f" changed_ratio: {_format_value(float(stats.get('changed_ratio', 0.0)) * 100.0)}%",
|
|
|
|
|
|
f" changed_threshold: {_format_value(stats.get('changed_threshold', ''))}",
|
|
|
|
|
|
]
|
|
|
|
|
|
)
|
|
|
|
|
|
else:
|
|
|
|
|
|
lines.append(" unavailable")
|
|
|
|
|
|
lines.extend(
|
|
|
|
|
|
[
|
|
|
|
|
|
"",
|
|
|
|
|
|
"说明:",
|
|
|
|
|
|
" 热力图使用“编辑后模型顶点到编辑前模型表面”的距离估算。",
|
|
|
|
|
|
" 它适合做可视化诊断和修改留档,不等同于完整 CAD 公差报告。",
|
|
|
|
|
|
]
|
|
|
|
|
|
)
|
|
|
|
|
|
return "\n".join(lines)
|
|
|
|
|
|
|
|
|
|
|
|
def _make_diff_actor(self, polydata, color: tuple[float, float, float], opacity: float):
|
|
|
|
|
|
mapper = vtk.vtkPolyDataMapper()
|
|
|
|
|
|
mapper.SetInputData(polydata)
|
|
|
|
|
|
actor = vtk.vtkActor()
|
|
|
|
|
|
actor.SetMapper(mapper)
|
|
|
|
|
|
actor.GetProperty().SetColor(*color)
|
|
|
|
|
|
actor.GetProperty().SetOpacity(opacity)
|
|
|
|
|
|
actor.GetProperty().SetSpecular(0.15)
|
|
|
|
|
|
actor.GetProperty().SetLineWidth(1)
|
|
|
|
|
|
return actor
|
|
|
|
|
|
|
|
|
|
|
|
def _make_distance_heatmap_props(self, before_polydata, after_polydata):
|
|
|
|
|
|
heat_polydata, stats = self._build_distance_heatmap_polydata(before_polydata, after_polydata)
|
|
|
|
|
|
if heat_polydata is None:
|
|
|
|
|
|
return None, None, {}
|
|
|
|
|
|
max_distance = float(stats["max_distance"])
|
|
|
|
|
|
|
|
|
|
|
|
lut = self._make_heatmap_lookup_table(max_distance)
|
|
|
|
|
|
mapper = vtk.vtkPolyDataMapper()
|
|
|
|
|
|
mapper.SetInputData(heat_polydata)
|
|
|
|
|
|
mapper.SetLookupTable(lut)
|
|
|
|
|
|
mapper.SetScalarRange(0.0, max(max_distance, 1e-9))
|
|
|
|
|
|
mapper.SetScalarModeToUsePointData()
|
|
|
|
|
|
mapper.ScalarVisibilityOn()
|
|
|
|
|
|
|
|
|
|
|
|
actor = vtk.vtkActor()
|
|
|
|
|
|
actor.SetMapper(mapper)
|
|
|
|
|
|
actor.GetProperty().SetOpacity(0.82)
|
|
|
|
|
|
actor.GetProperty().SetSpecular(0.22)
|
|
|
|
|
|
actor.GetProperty().SetSpecularPower(16)
|
|
|
|
|
|
|
|
|
|
|
|
scalar_bar = vtk.vtkScalarBarActor()
|
|
|
|
|
|
scalar_bar.SetLookupTable(lut)
|
|
|
|
|
|
scalar_bar.SetTitle("distance")
|
|
|
|
|
|
scalar_bar.SetNumberOfLabels(4)
|
|
|
|
|
|
scalar_bar.SetWidth(0.08)
|
|
|
|
|
|
scalar_bar.SetHeight(0.32)
|
|
|
|
|
|
scalar_bar.SetPosition(0.89, 0.05)
|
|
|
|
|
|
scalar_bar.GetTitleTextProperty().SetColor(1.0, 1.0, 1.0)
|
|
|
|
|
|
scalar_bar.GetLabelTextProperty().SetColor(1.0, 1.0, 1.0)
|
|
|
|
|
|
|
|
|
|
|
|
return actor, scalar_bar, stats
|
|
|
|
|
|
|
|
|
|
|
|
def _build_distance_heatmap_polydata(self, before_polydata, after_polydata):
|
|
|
|
|
|
point_count = after_polydata.GetNumberOfPoints()
|
|
|
|
|
|
if before_polydata.GetNumberOfPoints() == 0 or point_count == 0:
|
|
|
|
|
|
return None, {}
|
|
|
|
|
|
heat_polydata = vtk.vtkPolyData()
|
|
|
|
|
|
heat_polydata.DeepCopy(after_polydata)
|
|
|
|
|
|
distance = vtk.vtkImplicitPolyDataDistance()
|
|
|
|
|
|
distance.SetInput(before_polydata)
|
|
|
|
|
|
|
|
|
|
|
|
values = vtk.vtkFloatArray()
|
|
|
|
|
|
values.SetName("edit_distance")
|
|
|
|
|
|
values.SetNumberOfValues(point_count)
|
|
|
|
|
|
max_distance = 0.0
|
|
|
|
|
|
total_distance = 0.0
|
|
|
|
|
|
raw_values: list[float] = []
|
|
|
|
|
|
for point_id in range(point_count):
|
|
|
|
|
|
point = heat_polydata.GetPoint(point_id)
|
|
|
|
|
|
value = abs(float(distance.EvaluateFunction(point)))
|
|
|
|
|
|
raw_values.append(value)
|
|
|
|
|
|
values.SetValue(point_id, value)
|
|
|
|
|
|
total_distance += value
|
|
|
|
|
|
max_distance = max(max_distance, value)
|
|
|
|
|
|
|
|
|
|
|
|
heat_polydata.GetPointData().SetScalars(values)
|
|
|
|
|
|
changed_threshold = max(max_distance * 0.01, 1e-6)
|
|
|
|
|
|
changed_points = sum(1 for value in raw_values if value > changed_threshold)
|
|
|
|
|
|
stats = {
|
|
|
|
|
|
"points": point_count,
|
|
|
|
|
|
"max_distance": max_distance,
|
|
|
|
|
|
"mean_distance": total_distance / point_count,
|
|
|
|
|
|
"changed_points": changed_points,
|
|
|
|
|
|
"changed_ratio": changed_points / point_count,
|
|
|
|
|
|
"changed_threshold": changed_threshold,
|
|
|
|
|
|
}
|
|
|
|
|
|
return heat_polydata, stats
|
|
|
|
|
|
|
|
|
|
|
|
def _make_heatmap_lookup_table(self, max_distance: float):
|
|
|
|
|
|
lut = vtk.vtkLookupTable()
|
|
|
|
|
|
lut.SetNumberOfTableValues(256)
|
|
|
|
|
|
lut.SetRange(0.0, max(max_distance, 1e-9))
|
|
|
|
|
|
lut.Build()
|
|
|
|
|
|
stops = [
|
|
|
|
|
|
(0.0, (0.08, 0.16, 0.85)),
|
|
|
|
|
|
(0.35, (0.0, 0.72, 1.0)),
|
|
|
|
|
|
(0.7, (1.0, 0.9, 0.08)),
|
|
|
|
|
|
(1.0, (1.0, 0.08, 0.02)),
|
|
|
|
|
|
]
|
|
|
|
|
|
for index in range(256):
|
|
|
|
|
|
t = index / 255.0
|
|
|
|
|
|
left = stops[0]
|
|
|
|
|
|
right = stops[-1]
|
|
|
|
|
|
for stop_index in range(len(stops) - 1):
|
|
|
|
|
|
if stops[stop_index][0] <= t <= stops[stop_index + 1][0]:
|
|
|
|
|
|
left = stops[stop_index]
|
|
|
|
|
|
right = stops[stop_index + 1]
|
|
|
|
|
|
break
|
|
|
|
|
|
span = max(right[0] - left[0], 1e-9)
|
|
|
|
|
|
local_t = (t - left[0]) / span
|
|
|
|
|
|
color = tuple(left[1][axis] + (right[1][axis] - left[1][axis]) * local_t for axis in range(3))
|
|
|
|
|
|
lut.SetTableValue(index, color[0], color[1], color[2], 1.0)
|
|
|
|
|
|
return lut
|
|
|
|
|
|
|