Files

4375 lines
194 KiB
Python
Raw Permalink Normal View History

from __future__ import annotations
from datetime import datetime
import json
import math
from pathlib import Path
import time
import vtk
from PySide6.QtCore import QEvent, Qt, QThread, QTimer, Slot
from PySide6.QtWidgets import QToolTip
from PySide6.QtWidgets import (
QApplication,
QFileDialog,
QMessageBox,
QTableWidgetItem,
QTreeWidgetItem,
QWidget,
)
from .model import StepModel
from .asitus_bridge import run_asitus_hole_recognition
from .records import OperationRecord
from .scdm_feature_mapper import SCDM_FEATURE_CACHE_REVISION, attach_local_face_ids_to_scdm_cache, map_scdm_raw_features
from .scdm_probe import run_scdm_probe
from .scdm_schema import default_scdm_work_dir, file_fingerprint, read_json, write_json
from .ui_helpers import * # noqa: F403
from .workers import EditWorker, LoadWorker, ScanWorker
VIEW_BACKGROUND_COLOR = (226 / 255.0, 238 / 255.0, 247 / 255.0)
AXIS_X_COLOR = (0.82, 0.12, 0.10)
AXIS_Y_COLOR = (0.12, 0.58, 0.20)
AXIS_Z_COLOR = (0.10, 0.28, 0.88)
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
def _empty_edge_polydata():
polydata = vtk.vtkPolyData()
polydata.SetPoints(vtk.vtkPoints())
polydata.SetLines(vtk.vtkCellArray())
edge_arr = vtk.vtkIntArray()
edge_arr.SetName("edge_id")
part_arr = vtk.vtkIntArray()
part_arr.SetName("part_id")
polydata.GetCellData().AddArray(edge_arr)
polydata.GetCellData().AddArray(part_arr)
return polydata
def _timing_summary(timings: dict[str, object] | None, *, limit: int = 4) -> str:
if not isinstance(timings, dict) or not timings:
return ""
labels = {
"step_load": "STEP读取",
"stats": "统计",
"display_faces": "面显示",
"display_edges": "边线",
"apply_loaded": "界面刷新",
"total": "总计",
}
rows: list[tuple[str, float]] = []
for key, value in timings.items():
if key == "total":
continue
try:
seconds = float(value)
except (TypeError, ValueError):
continue
if seconds >= 0.05:
rows.append((labels.get(str(key), str(key)), seconds))
rows.sort(key=lambda item: item[1], reverse=True)
parts = [f"{label} {seconds:.1f}s" for label, seconds in rows[:limit]]
try:
total = float(timings.get("total"))
except (TypeError, ValueError):
total = 0.0
if total >= 0.05:
parts.append(f"总计 {total:.1f}s")
return "".join(parts)
class WindowCoreMixin:
@Slot(object)
def _run_ui_task(self, callback) -> None:
callback()
def _empty_edge_polydata(self):
return _empty_edge_polydata()
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) and getattr(self, "step_path", None) is not None:
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
self._render_window_safely(force=True)
def _is_ui_thread(self) -> bool:
try:
owner_thread = self.thread()
except AttributeError:
return True
return QThread.currentThread() == owner_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)
def _render_window_safely(self, *, force: bool = False) -> None:
render_window = getattr(self, "render_window", None)
if render_window is None:
return
if not self._is_ui_thread():
# VTK/Qt 渲染窗口只能在 UI 线程触碰;后台 worker 请求渲染时转成 queued UI 任务。
self._invoke_on_ui_thread(lambda force=force: self._render_window_safely(force=force))
return
if bool(getattr(self, "scene_rebuild_in_progress", False)) and not force:
return
try:
render_window.Render()
except Exception:
pass
def _reroute_to_ui_thread(self, callback) -> bool:
if self._is_ui_thread():
return False
self._invoke_on_ui_thread(callback)
return True
def eventFilter(self, watched, event):
if event.type() == QEvent.Type.ToolTip and self._should_suppress_transient_tooltip(watched):
QToolTip.hideText()
return True
if watched is getattr(self, "vtk_widget", None):
event_type = event.type()
if event_type == QEvent.Type.MouseButtonRelease:
QToolTip.hideText()
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)
self.pointer_button_down = bool(event.buttons() != Qt.MouseButton.NoButton)
elif event_type == QEvent.Type.MouseButtonPress:
QToolTip.hideText()
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)
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)
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)
elif event_type == QEvent.Type.Resize and hasattr(self, "_sync_vtk_render_window_size"):
QTimer.singleShot(0, self._sync_vtk_render_window_size)
elif watched is getattr(self, "property_table", None):
if event.type() == QEvent.Type.Resize and hasattr(self, "_resize_property_table_columns"):
QTimer.singleShot(0, self._resize_property_table_columns)
elif watched is self._relation_formula_popup_widget():
if event.type() == QEvent.Type.KeyPress and event.key() == Qt.Key.Key_Backspace:
editor = getattr(self, "relation_formula_input", None)
popup = self._relation_formula_popup_widget()
if editor is not None and hasattr(editor, "backspace"):
# QCompleter 的 popup 会抢走 Backspace/Tab 焦点;手动把按键转回输入框,
# 否则用户补全后继续编辑时会出现“失焦、删不动”的体验。
if popup is not None and hasattr(popup, "hide"):
popup.hide()
if hasattr(editor, "setFocus"):
editor.setFocus(Qt.FocusReason.OtherFocusReason)
editor.backspace()
if hasattr(self, "_update_relation_formula_completions"):
QTimer.singleShot(0, self._update_relation_formula_completions)
event.accept()
return True
elif watched is getattr(self, "relation_formula_input", None):
if event.type() == QEvent.Type.FocusIn:
if hasattr(self, "_update_relation_formula_completions"):
QTimer.singleShot(0, self._update_relation_formula_completions)
if event.type() == QEvent.Type.MouseButtonPress:
if hasattr(self, "_hide_relation_formula_completion_popup"):
self._hide_relation_formula_completion_popup()
if hasattr(watched, "setFocus"):
watched.setFocus(Qt.FocusReason.MouseFocusReason)
if hasattr(self, "_update_relation_formula_completions"):
QTimer.singleShot(0, self._update_relation_formula_completions)
if event.type() in {QEvent.Type.ShortcutOverride, QEvent.Type.KeyPress} and event.key() == Qt.Key.Key_Tab:
if bool(getattr(self, "_relation_formula_tab_completion_accepted", False)):
self._relation_formula_tab_completion_accepted = False
event.accept()
return True
if hasattr(self, "_accept_relation_formula_completion") and self._accept_relation_formula_completion():
self._relation_formula_tab_completion_accepted = event.type() == QEvent.Type.ShortcutOverride
if self._relation_formula_tab_completion_accepted:
QTimer.singleShot(0, lambda: setattr(self, "_relation_formula_tab_completion_accepted", False))
event.accept()
return True
return super().eventFilter(watched, event)
def _relation_formula_popup_widget(self):
completer = getattr(self, "relation_formula_completer", None)
if completer is None or not hasattr(completer, "popup"):
return None
try:
return completer.popup()
except RuntimeError:
return None
def _should_suppress_transient_tooltip(self, watched) -> bool:
if not isinstance(watched, QWidget):
return False
property_table = getattr(self, "property_table", None)
if isinstance(property_table, QWidget):
widget = watched
while widget is not None:
if widget is property_table:
return False
widget = widget.parentWidget()
try:
if watched is property_table.viewport():
return False
except Exception:
pass
suppressed_roots = (
getattr(self, "vtk_widget", None),
getattr(self, "object_edit_box", None),
getattr(self, "property_card_scroll", None),
getattr(self, "property_card_container", None),
)
for root in suppressed_roots:
if root is None:
continue
widget = watched
while widget is not None:
if widget is root:
return True
widget = widget.parentWidget()
return False
def _sync_vtk_render_window_size(self) -> None:
widget = getattr(self, "vtk_widget", None)
render_window = getattr(self, "render_window", None)
if widget is None or render_window is None:
return
try:
scale = widget._getPixelRatio() if hasattr(widget, "_getPixelRatio") else widget.devicePixelRatioF()
except Exception:
scale = 1.0
width = max(int(round(float(widget.width()) * float(scale))), 1)
height = max(int(round(float(widget.height()) * float(scale))), 1)
try:
current_width, current_height = render_window.GetSize()
except Exception:
current_width, current_height = width, height
if int(current_width) == width and int(current_height) == height:
return
try:
render_window.SetSize(width, height)
except Exception:
pass
try:
self.interactor.SetSize(width, height)
except Exception:
pass
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
def _configure_render_antialiasing(self) -> None:
fxaa_enabled = False
if bool(getattr(self, "prefer_fxaa_antialiasing", False)) and hasattr(self.renderer, "UseFXAAOn"):
try:
self.renderer.UseFXAAOn()
fxaa_enabled = True
except Exception:
fxaa_enabled = False
if not fxaa_enabled and hasattr(self.renderer, "UseFXAAOff"):
try:
self.renderer.UseFXAAOff()
except Exception:
pass
fallback_samples = max(int(getattr(self, "fallback_multi_samples", 2) or 0), 0)
if fxaa_enabled:
self.interactive_multi_samples = 0
self.still_multi_samples = 0
else:
self.interactive_multi_samples = fallback_samples
self.still_multi_samples = fallback_samples
self.fxaa_antialiasing_enabled = fxaa_enabled
self._set_render_window_multisamples(int(getattr(self, "still_multi_samples", fallback_samples)))
def _build_vtk(self) -> None:
self.renderer = vtk.vtkRenderer()
self.renderer.SetBackground(*VIEW_BACKGROUND_COLOR)
self.render_window = self.vtk_widget.GetRenderWindow()
self._configure_render_antialiasing()
if hasattr(self.render_window, "SetNumberOfLayers"):
self.render_window.SetNumberOfLayers(3)
if hasattr(self.renderer, "SetLayer"):
self.renderer.SetLayer(0)
self.render_window.AddRenderer(self.renderer)
self._build_scene_axes_renderer()
self._build_fps_overlay()
self._fps_frame_count = 0
self._fps_window_started_at = time.perf_counter()
self._fps_last_text_update_at = self._fps_window_started_at
self._fps_last_value = 0.0
self._fps_idle_timeout_ms = 900
self._fps_idle_zero_displayed = False
self._fps_idle_rendering_zero = False
self.fps_idle_timer = QTimer(self)
self.fps_idle_timer.setSingleShot(True)
self.fps_idle_timer.timeout.connect(self._show_idle_fps_zero)
self.render_window.AddObserver("EndEvent", self._on_render_window_end_event)
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)
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)
# 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(60.0)
2026-07-29 15:43:28 +08:00
if hasattr(self.interactor, "SetStillUpdateRate"):
self.interactor.SetStillUpdateRate(1.0)
light = vtk.vtkLight()
light.SetLightTypeToHeadlight()
light.SetPosition(1, 1, 1)
light.SetIntensity(0.9)
self.renderer.AddLight(light)
fill_light = vtk.vtkLight()
fill_light.SetLightTypeToCameraLight()
fill_light.SetPosition(-1, -1, 1)
fill_light.SetIntensity(0.28)
self.renderer.AddLight(fill_light)
self.interactor.Initialize()
self._build_orientation_marker()
def _build_scene_axes_renderer(self) -> None:
try:
axes_renderer = vtk.vtkRenderer()
axes_renderer.SetLayer(1)
axes_renderer.InteractiveOff()
axes_renderer.EraseOff()
axes_renderer.PreserveColorBufferOn()
axes_renderer.PreserveDepthBufferOff()
axes_renderer.SetActiveCamera(self.renderer.GetActiveCamera())
axes_light = vtk.vtkLight()
axes_light.SetLightTypeToHeadlight()
axes_light.SetIntensity(0.8)
axes_renderer.AddLight(axes_light)
self.render_window.AddRenderer(axes_renderer)
except Exception:
self.scene_axes_renderer = None
return
self.scene_axes_renderer = axes_renderer
def _build_fps_overlay(self) -> None:
self.fps_actor = vtk.vtkTextActor()
self.fps_actor.SetInput("FPS --")
text_property = self.fps_actor.GetTextProperty()
text_property.SetFontFamilyToArial()
text_property.SetFontSize(17)
text_property.BoldOn()
text_property.SetColor(0.05, 0.08, 0.12)
text_property.SetOpacity(0.92)
text_property.SetJustificationToRight()
text_property.SetVerticalJustificationToTop()
if hasattr(text_property, "SetBackgroundColor"):
text_property.SetBackgroundColor(1.0, 1.0, 1.0)
if hasattr(text_property, "SetBackgroundOpacity"):
text_property.SetBackgroundOpacity(0.58)
position = self.fps_actor.GetPositionCoordinate()
position.SetCoordinateSystemToNormalizedViewport()
position.SetValue(0.985, 0.982)
self.renderer.AddActor2D(self.fps_actor)
def _ensure_fps_overlay(self) -> None:
actor = getattr(self, "fps_actor", None)
if actor is None:
self._build_fps_overlay()
return
try:
self.renderer.AddActor2D(actor)
except Exception:
pass
def _on_render_window_end_event(self, _obj, _event) -> None:
now = time.perf_counter()
if bool(getattr(self, "_fps_idle_rendering_zero", False)):
self._fps_idle_rendering_zero = False
self._fps_frame_count = 0
self._fps_window_started_at = now
self._fps_last_text_update_at = now
return
self._fps_idle_zero_displayed = False
self._restart_fps_idle_timer()
self._fps_frame_count = int(getattr(self, "_fps_frame_count", 0) or 0) + 1
window_started_at = float(getattr(self, "_fps_window_started_at", now) or now)
elapsed = now - window_started_at
if elapsed < 0.35:
return
fps = self._fps_frame_count / max(elapsed, 1e-9)
previous = float(getattr(self, "_fps_last_value", 0.0) or 0.0)
if previous > 0.0:
fps = previous * 0.35 + fps * 0.65
self._fps_last_value = fps
self._fps_frame_count = 0
self._fps_window_started_at = now
actor = getattr(self, "fps_actor", None)
if actor is not None:
actor.SetInput(f"FPS {fps:4.1f}")
def _restart_fps_idle_timer(self) -> None:
timer = getattr(self, "fps_idle_timer", None)
if timer is None:
return
try:
timer.start(int(getattr(self, "_fps_idle_timeout_ms", 900) or 900))
except Exception:
pass
def _show_idle_fps_zero(self) -> None:
if bool(getattr(self, "_fps_idle_zero_displayed", False)):
return
actor = getattr(self, "fps_actor", None)
render_window = getattr(self, "render_window", None)
if actor is None or render_window is None:
return
self._fps_idle_zero_displayed = True
self._fps_last_value = 0.0
self._fps_frame_count = 0
self._fps_window_started_at = time.perf_counter()
try:
actor.SetInput("FPS 0.0")
except Exception:
return
self._fps_idle_rendering_zero = True
try:
self._render_window_safely(force=True)
finally:
self._fps_idle_rendering_zero = False
def _axis_caption_property(self, caption_actor):
try:
return caption_actor.GetCaptionTextProperty()
except Exception:
pass
try:
text_actor = caption_actor.GetTextActor()
return text_actor.GetTextProperty()
except Exception:
return None
def _style_axis_caption(self, caption_actor, color: tuple[float, float, float], scale: float) -> None:
text_property = self._axis_caption_property(caption_actor)
if text_property is not None:
try:
text_property.SetFontFamilyToArial()
text_property.BoldOn()
text_property.SetColor(*color)
text_property.SetOpacity(0.96)
except Exception:
pass
for method_name in ("SetWidth", "SetHeight"):
try:
getattr(caption_actor, method_name)(scale)
except Exception:
pass
def _configure_axes_actor(self, axes_actor, length: float, caption_scale: float) -> None:
length = max(float(length), 1e-6)
axes_actor.SetTotalLength(length, length, length)
axes_actor.SetXAxisLabelText("X")
axes_actor.SetYAxisLabelText("Y")
axes_actor.SetZAxisLabelText("Z")
axes_actor.AxisLabelsOn()
for method_name, args in (
("SetShaftTypeToCylinder", ()),
("SetCylinderRadius", (0.012,)),
("SetConeRadius", (0.045,)),
("SetSphereRadius", (0.035,)),
("SetNormalizedShaftLength", (0.78, 0.78, 0.78)),
("SetNormalizedTipLength", (0.22, 0.22, 0.22)),
):
try:
getattr(axes_actor, method_name)(*args)
except Exception:
pass
for shaft_name, tip_name, caption_name, color in (
("GetXAxisShaftProperty", "GetXAxisTipProperty", "GetXAxisCaptionActor2D", AXIS_X_COLOR),
("GetYAxisShaftProperty", "GetYAxisTipProperty", "GetYAxisCaptionActor2D", AXIS_Y_COLOR),
("GetZAxisShaftProperty", "GetZAxisTipProperty", "GetZAxisCaptionActor2D", AXIS_Z_COLOR),
):
for prop_name in (shaft_name, tip_name):
try:
prop = getattr(axes_actor, prop_name)()
prop.SetColor(*color)
prop.SetAmbient(0.25)
prop.SetDiffuse(0.78)
prop.SetSpecular(0.2)
except Exception:
pass
try:
self._style_axis_caption(getattr(axes_actor, caption_name)(), color, caption_scale)
except Exception:
pass
try:
axes_actor.PickableOff()
except Exception:
pass
@staticmethod
def _blend_axis_color(
color: tuple[float, float, float],
target: tuple[float, float, float],
amount: float,
) -> tuple[float, float, float]:
amount = min(max(float(amount), 0.0), 1.0)
return tuple(color[index] * (1.0 - amount) + target[index] * amount for index in range(3))
def _apply_axis_material(
self,
actor,
color: tuple[float, float, float],
*,
opacity: float = 1.0,
ambient: float = 0.48,
diffuse: float = 0.7,
specular: float = 0.22,
specular_power: float = 28.0,
) -> None:
try:
prop = actor.GetProperty()
prop.SetColor(*color)
prop.SetOpacity(float(opacity))
prop.SetAmbient(float(ambient))
prop.SetDiffuse(float(diffuse))
prop.SetSpecular(float(specular))
prop.SetSpecularPower(float(specular_power))
except Exception:
pass
try:
actor.PickableOff()
except Exception:
pass
def _make_axis_tube_actor(
self,
start: tuple[float, float, float],
end: tuple[float, float, float],
radius: float,
color: tuple[float, float, float],
*,
opacity: float = 1.0,
):
line = vtk.vtkLineSource()
line.SetPoint1(*start)
line.SetPoint2(*end)
tube = vtk.vtkTubeFilter()
tube.SetInputConnection(line.GetOutputPort())
tube.SetRadius(max(float(radius), 1e-9))
tube.SetNumberOfSides(28)
tube.CappingOn()
mapper = vtk.vtkPolyDataMapper()
mapper.SetInputConnection(tube.GetOutputPort())
actor = vtk.vtkActor()
actor.SetMapper(mapper)
self._apply_axis_material(actor, color, opacity=opacity)
return actor
def _make_axis_cone_actor(
self,
center: tuple[float, float, float],
direction: tuple[float, float, float],
height: float,
radius: float,
color: tuple[float, float, float],
):
cone = vtk.vtkConeSource()
cone.SetCenter(*center)
cone.SetDirection(*direction)
cone.SetHeight(max(float(height), 1e-9))
cone.SetRadius(max(float(radius), 1e-9))
cone.SetResolution(36)
mapper = vtk.vtkPolyDataMapper()
mapper.SetInputConnection(cone.GetOutputPort())
actor = vtk.vtkActor()
actor.SetMapper(mapper)
self._apply_axis_material(actor, color, ambient=0.3, diffuse=0.72, specular=0.36, specular_power=42.0)
return actor
def _make_axis_sphere_actor(
self,
center: tuple[float, float, float],
radius: float,
color: tuple[float, float, float],
):
sphere = vtk.vtkSphereSource()
sphere.SetCenter(*center)
sphere.SetRadius(max(float(radius), 1e-9))
sphere.SetThetaResolution(36)
sphere.SetPhiResolution(24)
mapper = vtk.vtkPolyDataMapper()
mapper.SetInputConnection(sphere.GetOutputPort())
actor = vtk.vtkActor()
actor.SetMapper(mapper)
self._apply_axis_material(actor, color, ambient=0.42, diffuse=0.6, specular=0.4, specular_power=46.0)
return actor
def _make_axis_label_actor(
self,
text: str,
position: tuple[float, float, float],
scale: float,
color: tuple[float, float, float],
):
vector_text = vtk.vtkVectorText()
vector_text.SetText(text)
mapper = vtk.vtkPolyDataMapper()
mapper.SetInputConnection(vector_text.GetOutputPort())
actor = vtk.vtkFollower()
actor.SetMapper(mapper)
actor.SetScale(scale, scale, scale)
actor.SetPosition(*position)
try:
actor.SetCamera(self.renderer.GetActiveCamera())
except Exception:
pass
self._apply_axis_material(actor, color, ambient=0.78, diffuse=0.38, specular=0.0)
return actor
def _set_annotated_cube_property(
self,
cube_actor,
method_name: str,
color: tuple[float, float, float],
opacity: float,
) -> None:
try:
prop = getattr(cube_actor, method_name)()
prop.SetColor(*color)
prop.SetOpacity(float(opacity))
prop.SetAmbient(0.35)
prop.SetDiffuse(0.65)
prop.SetSpecular(0.18)
except Exception:
pass
def _build_orientation_marker_prop(self):
axes_actor = vtk.vtkAxesActor()
self._configure_axes_actor(axes_actor, 1.72, caption_scale=0.14)
cube_actor = vtk.vtkAnnotatedCubeActor()
cube_actor.SetXPlusFaceText("+X")
cube_actor.SetXMinusFaceText("-X")
cube_actor.SetYPlusFaceText("+Y")
cube_actor.SetYMinusFaceText("-Y")
cube_actor.SetZPlusFaceText("+Z")
cube_actor.SetZMinusFaceText("-Z")
cube_actor.SetFaceTextScale(0.54)
cube_actor.SetTextEdgesVisibility(1)
cube_actor.SetCubeVisibility(1)
self._set_annotated_cube_property(cube_actor, "GetCubeProperty", (0.88, 0.92, 0.96), 0.72)
self._set_annotated_cube_property(cube_actor, "GetXPlusFaceProperty", AXIS_X_COLOR, 0.92)
self._set_annotated_cube_property(
cube_actor,
"GetXMinusFaceProperty",
self._blend_axis_color(AXIS_X_COLOR, (1.0, 1.0, 1.0), 0.52),
0.78,
)
self._set_annotated_cube_property(cube_actor, "GetYPlusFaceProperty", AXIS_Y_COLOR, 0.92)
self._set_annotated_cube_property(
cube_actor,
"GetYMinusFaceProperty",
self._blend_axis_color(AXIS_Y_COLOR, (1.0, 1.0, 1.0), 0.52),
0.78,
)
self._set_annotated_cube_property(cube_actor, "GetZPlusFaceProperty", AXIS_Z_COLOR, 0.92)
self._set_annotated_cube_property(
cube_actor,
"GetZMinusFaceProperty",
self._blend_axis_color(AXIS_Z_COLOR, (1.0, 1.0, 1.0), 0.52),
0.78,
)
try:
edge_property = cube_actor.GetTextEdgesProperty()
edge_property.SetColor(0.09, 0.12, 0.17)
edge_property.SetOpacity(0.75)
edge_property.SetLineWidth(1.5)
except Exception:
pass
for actor in (axes_actor, cube_actor):
try:
actor.PickableOff()
except Exception:
pass
prop = vtk.vtkPropAssembly()
prop.AddPart(axes_actor)
prop.AddPart(cube_actor)
return prop, axes_actor, cube_actor
def _build_orientation_marker(self) -> None:
try:
prop, axes_actor, cube_actor = self._build_orientation_marker_prop()
marker_widget = vtk.vtkOrientationMarkerWidget()
marker_widget.SetOrientationMarker(prop)
marker_widget.SetInteractor(self.interactor)
marker_widget.SetViewport(0.015, 0.015, 0.235, 0.235)
if hasattr(marker_widget, "SetOutlineColor"):
marker_widget.SetOutlineColor(0.72, 0.78, 0.86)
marker_widget.SetEnabled(1)
marker_widget.InteractiveOff()
except Exception:
self.orientation_marker_widget = None
self.orientation_marker_prop = None
self.orientation_marker_axes_actor = None
self.orientation_marker_cube_actor = None
return
self.orientation_marker_widget = marker_widget
self.orientation_marker_prop = prop
self.orientation_marker_axes_actor = axes_actor
self.orientation_marker_cube_actor = cube_actor
def _valid_bounds_tuple(self, bounds) -> tuple[float, float, float, float, float, float] | None:
try:
values = tuple(float(value) for value in bounds)
except (TypeError, ValueError):
return None
if len(values) != 6:
return None
if not all(math.isfinite(value) for value in values):
return None
x0, x1, y0, y1, z0, z1 = values
if x1 < x0 or y1 < y0 or z1 < z0:
return None
return values
def _current_model_bounds(self) -> tuple[float, float, float, float, float, float] | None:
polydata = getattr(self, "model_polydata", None)
if polydata is None:
return None
try:
return self._valid_bounds_tuple(polydata.GetBounds())
except Exception:
return None
def _step_coordinate_axis_length(self) -> float:
bounds = self._current_model_bounds()
if bounds is None:
return 1.0
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)
model_size = max(math.sqrt(dx * dx + dy * dy + dz * dz), dx, dy, dz, 1e-6)
return max(model_size * 0.18, 1e-4)
def _scene_coordinate_axes_anchor(self, length: float) -> tuple[float, float, float]:
# STEP display geometry is already in the file/global coordinate frame.
# Keep the scene axes at the real file origin; visibility is handled by
# the foreground renderer, not by moving the origin marker.
return (0.0, 0.0, 0.0)
def _build_scene_coordinate_axes(self, length: float, anchor: tuple[float, float, float]):
length = max(float(length), 1e-6)
assembly = vtk.vtkAssembly()
assembly.SetPosition(*anchor)
shaft_radius = length * 0.012
tip_length = length * 0.18
tip_radius = length * 0.045
negative_length = length * 0.28
label_scale = length * 0.075
origin_radius = length * 0.032
assembly.AddPart(self._make_axis_sphere_actor((0.0, 0.0, 0.0), origin_radius, (0.96, 0.98, 1.0)))
for label, direction, color in (
("X", (1.0, 0.0, 0.0), AXIS_X_COLOR),
("Y", (0.0, 1.0, 0.0), AXIS_Y_COLOR),
("Z", (0.0, 0.0, 1.0), AXIS_Z_COLOR),
):
shaft_end = tuple(direction[index] * (length - tip_length * 0.78) for index in range(3))
cone_center = tuple(direction[index] * (length - tip_length * 0.5) for index in range(3))
label_position = tuple(direction[index] * (length * 1.08) for index in range(3))
negative_start = tuple(-direction[index] * negative_length for index in range(3))
pale_color = self._blend_axis_color(color, (1.0, 1.0, 1.0), 0.48)
assembly.AddPart(
self._make_axis_tube_actor(
negative_start,
(0.0, 0.0, 0.0),
shaft_radius * 0.58,
pale_color,
opacity=0.62,
)
)
assembly.AddPart(self._make_axis_tube_actor((0.0, 0.0, 0.0), shaft_end, shaft_radius, color))
assembly.AddPart(self._make_axis_cone_actor(cone_center, direction, tip_length, tip_radius, color))
assembly.AddPart(self._make_axis_label_actor(label, label_position, label_scale, color))
try:
assembly.PickableOff()
except Exception:
pass
try:
assembly.UseBoundsOff()
except Exception:
pass
return assembly
def _ensure_step_coordinate_axes(self) -> None:
previous_actor = getattr(self, "step_coordinate_axes_actor", None)
if previous_actor is not None:
for renderer in (getattr(self, "scene_axes_renderer", None), self.renderer):
if renderer is None:
continue
try:
renderer.RemoveViewProp(previous_actor)
except Exception:
pass
axes_renderer = getattr(self, "scene_axes_renderer", None) or self.renderer
try:
axes_renderer.SetActiveCamera(self.renderer.GetActiveCamera())
except Exception:
pass
length = self._step_coordinate_axis_length()
anchor = self._scene_coordinate_axes_anchor(length)
try:
axes_actor = self._build_scene_coordinate_axes(length, anchor)
self.step_coordinate_axes_actor = axes_actor
except Exception:
axes_actor = vtk.vtkAxesActor()
axes_actor.SetPosition(*anchor)
self._configure_axes_actor(
axes_actor,
length,
caption_scale=0.055,
)
self.step_coordinate_axes_actor = axes_actor
for method_name in ("PickableOff", "UseBoundsOff"):
try:
getattr(axes_actor, method_name)()
except Exception:
pass
try:
axes_renderer.AddViewProp(axes_actor)
except Exception:
try:
axes_renderer.AddActor(axes_actor)
except Exception:
pass
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)
self._set_camera_interaction_visual_mode(True)
2026-07-29 15:43:28 +08:00
def _end_camera_interaction(self) -> None:
if not getattr(self, "camera_interaction_active", False):
return
self.camera_interaction_active = False
self._set_camera_interaction_visual_mode(False)
self.last_camera_interaction_ended_at = datetime.now()
self.pending_hover_position = None
self.last_hover_pick_position = None
def _set_render_window_multisamples(self, samples: int) -> bool:
render_window = getattr(self, "render_window", None)
if render_window is None or not hasattr(render_window, "SetMultiSamples"):
return False
target = max(int(samples), 0)
try:
if hasattr(render_window, "GetMultiSamples") and int(render_window.GetMultiSamples()) == target:
return False
except Exception:
pass
try:
render_window.SetMultiSamples(target)
return True
except Exception:
return False
def _set_camera_interaction_visual_mode(self, active: bool) -> None:
edge_actor = getattr(self, "edge_actor", None)
changed = False
if active:
if bool(getattr(self, "hide_edges_during_camera_interaction", True)) and edge_actor is not None:
try:
self.edge_visibility_before_camera_interaction = int(edge_actor.GetVisibility())
if self.edge_visibility_before_camera_interaction:
edge_actor.VisibilityOff()
changed = True
except Exception:
self.edge_visibility_before_camera_interaction = None
if bool(getattr(self, "hide_overlays_during_camera_interaction", False)):
overlay_visibility: dict[str, int] = {}
for attr_name in ("highlight_actor", "edge_highlight_actor", "pick_marker_actor"):
actor = getattr(self, attr_name, None)
if actor is None:
continue
try:
visibility = int(actor.GetVisibility())
overlay_visibility[attr_name] = visibility
if visibility:
actor.VisibilityOff()
changed = True
except Exception:
continue
self.overlay_visibility_before_camera_interaction = overlay_visibility
changed = self._set_render_window_multisamples(
int(getattr(self, "interactive_multi_samples", 0))
) or changed
self.camera_interaction_visual_changed = changed
return
if edge_actor is not None and self.edge_visibility_before_camera_interaction is not None:
try:
previous_visibility = int(self.edge_visibility_before_camera_interaction)
if int(edge_actor.GetVisibility()) != previous_visibility:
edge_actor.SetVisibility(previous_visibility)
changed = True
except Exception:
pass
self.edge_visibility_before_camera_interaction = None
overlay_visibility = getattr(self, "overlay_visibility_before_camera_interaction", {}) or {}
if isinstance(overlay_visibility, dict):
for attr_name, previous_visibility in overlay_visibility.items():
actor = getattr(self, str(attr_name), None)
if actor is None:
continue
try:
if int(actor.GetVisibility()) != int(previous_visibility):
actor.SetVisibility(int(previous_visibility))
changed = True
except Exception:
continue
self.overlay_visibility_before_camera_interaction = {}
changed = self._set_render_window_multisamples(int(getattr(self, "still_multi_samples", 4))) or changed
changed = bool(getattr(self, "camera_interaction_visual_changed", False)) or changed
self.camera_interaction_visual_changed = False
if changed:
try:
self.renderer.ResetCameraClippingRange()
except Exception:
pass
self._render_window_safely()
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
def closeEvent(self, event) -> None:
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:
self.statusBar().showMessage("STEP background loading is still running.")
event.ignore()
return
edit_thread_running = bool(self.edit_thread is not None and self.edit_thread.isRunning())
if self.operation_in_progress or edit_thread_running:
if hasattr(self, "_cancel_active_edit_for_close") and self._cancel_active_edit_for_close():
event.ignore()
return
self.statusBar().showMessage("后台编辑正在计算,请等待当前操作完成后再关闭窗口。")
event.ignore()
return
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("扫描正在进行,请等待扫描完成后再关闭窗口。")
event.ignore()
return
asitus_thread_running = bool(self.asitus_thread is not None and self.asitus_thread.isRunning())
if asitus_thread_running:
self.statusBar().showMessage("孔组识别正在后台预热,请稍后再关闭窗口。")
event.ignore()
return
scdm_thread_running = bool(self.scdm_thread is not None and self.scdm_thread.isRunning())
if scdm_thread_running:
self.statusBar().showMessage("SCDM 可修改参数正在后台识别,请稍后再关闭窗口。")
event.ignore()
return
super().closeEvent(event)
def _request_thread_quit(self, thread: QThread | None) -> None:
if thread is not None and thread.isRunning():
thread.quit()
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,
build_edges: bool = True,
) -> dict[str, object]:
timings: dict[str, float] = {}
total_started = time.perf_counter()
started = time.perf_counter()
new_model = StepModel.load(path)
timings["step_load"] = time.perf_counter() - started
started = time.perf_counter()
stats = new_model.stats()
timings["stats"] = time.perf_counter() - started
display_deflection = _large_model_display_deflection_for_stats(stats, deflection)
result = {
"path": path,
"model": new_model,
"stats": stats,
"deflection": display_deflection,
"requested_deflection": deflection,
"show_internal_edges": show_internal_edges,
"timings": timings,
}
if build_polydata:
started = time.perf_counter()
face_polydata = new_model.build_face_polydata(deflection=display_deflection)
result["model_polydata"] = _smooth_surface_polydata(face_polydata)
timings["display_faces"] = time.perf_counter() - started
if build_edges:
started = time.perf_counter()
result["edge_polydata"] = new_model.build_edge_polydata(
deflection=display_deflection,
show_same_domain_internal_edges=show_internal_edges,
)
timings["display_edges"] = time.perf_counter() - started
else:
result["edge_polydata"] = _empty_edge_polydata()
result["edge_polydata_deferred"] = True
timings["display_edges"] = 0.0
timings["total"] = time.perf_counter() - total_started
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:
self._load_step_sync(
new_path,
deflection=float(getattr(self, "preview_load_deflection", 0.35) or 0.35),
show_internal_edges=self._show_same_domain_internal_edges(),
status_prefix="Loading",
)
return
self.load_in_progress = True
self.pending_load_path = new_path
self.statusBar().showMessage(f"正在读取 STEP 可视化网格:{new_path.name}...")
self._clear_hover(render=True)
self._update_action_states()
deflection = float(getattr(self, "preview_load_deflection", 0.35) or 0.35)
show_internal_edges = self._show_same_domain_internal_edges()
load_step_result = type(self)._load_step_result
def action(path=new_path, deflection=deflection, show_internal_edges=show_internal_edges, load_step_result=load_step_result):
return load_step_result(
path,
deflection=deflection,
show_internal_edges=show_internal_edges,
build_edges=False,
)
thread = QThread(self)
worker = LoadWorker(action)
worker.moveToThread(thread)
thread.started.connect(worker.run)
worker.finished.connect(self._finish_initial_load, Qt.ConnectionType.QueuedConnection)
worker.failed.connect(self._fail_initial_load, Qt.ConnectionType.QueuedConnection)
thread.finished.connect(worker.deleteLater)
thread.finished.connect(thread.deleteLater)
thread.finished.connect(self._forget_load_thread)
self.load_thread = thread
self.load_worker = worker
thread.start()
@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 this as a main-thread
# fallback so stale signal paths cannot crash the process.
self._run_deferred_initial_load(expected_path)
def _load_step_sync(
self,
new_path: Path,
*,
deflection: float,
show_internal_edges: bool,
status_prefix: str,
defer_edges: bool = True,
reset_camera: bool = True,
) -> bool:
self.statusBar().showMessage(f"{status_prefix} {new_path.name}...")
QApplication.setOverrideCursor(Qt.CursorShape.WaitCursor)
QApplication.processEvents()
try:
result = self._load_step_result(
new_path,
deflection=deflection,
show_internal_edges=show_internal_edges,
build_edges=not defer_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()
try:
self._apply_loaded_model_result(result, reset_camera=reset_camera)
except Exception as exc:
pending = getattr(self, "pending_scdm_edit_reload", None)
if isinstance(pending, dict) and hasattr(self, "_reject_scdm_result_after_reload"):
self.pending_scdm_edit_reload = None
self._reject_scdm_result_after_reload(
pending,
{
"ok": False,
"reason": "result-load-failed",
"message": f"SCDM 修改结果 STEP 已生成,但加载到当前场景失败:{exc}",
},
)
else:
QMessageBox.critical(self, "Load failed", str(exc))
self.statusBar().showMessage("STEP load failed; current model was left unchanged.")
return False
timing_text = _timing_summary(result.get("timings") if isinstance(result, dict) else None)
suffix = f"{timing_text}" if timing_text else ""
self.statusBar().showMessage(f"Loaded {self.step_path.name}{suffix}")
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,
deflection=self.preview_load_deflection,
show_internal_edges=self._show_same_domain_internal_edges(),
status_prefix="读取 STEP 可视化网格",
defer_edges=True,
)
finally:
self._end_load_task()
@staticmethod
def _copy_polydata_for_ui_thread(polydata: object) -> object:
if polydata is None:
return None
copied = vtk.vtkPolyData()
try:
copied.DeepCopy(polydata)
except Exception:
copied.ShallowCopy(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
def _apply_loaded_model_result(self, result: dict[str, object], *, reset_camera: bool) -> None:
apply_started = time.perf_counter()
new_path = Path(result["path"])
stats = result["stats"]
pending_scdm_reload = isinstance(getattr(self, "pending_scdm_edit_reload", None), dict)
self.model = result["model"]
self.step_path = new_path
self._invalidate_scdm_feature_cache("模型已重新加载,SCDM cache 已失效。")
if not pending_scdm_reload:
self._clear_history()
if hasattr(self, "measure_text"):
self.clear_measurement()
path_text = str(self.step_path)
self.path_label.setText(path_text)
self.path_label.setToolTip(path_text)
self.path_label.setCursorPosition(0)
self._populate_part_tree()
if pending_scdm_reload:
self._reset_selection(clear_highlight=True, clear_info=False)
else:
self._reset_selection()
large_interaction_model = self._large_model_interaction_mode(stats=stats)
self.hide_edges_during_camera_interaction = large_interaction_model
self.hide_overlays_during_camera_interaction = large_interaction_model
self.hover_after_camera_cooldown_ms = 700 if large_interaction_model else 420
self.large_model_edge_overlay_skipped = False
self.large_model_hover_disabled = large_interaction_model
model_polydata = result.get("model_polydata")
edge_polydata = result.get("edge_polydata")
edge_deferred = bool(result.get("edge_polydata_deferred"))
if model_polydata is None or (edge_polydata is None and not edge_deferred):
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,
)
elif edge_polydata is None:
edge_polydata = _empty_edge_polydata()
self._rebuild_scene_from_polydata(
model_polydata,
edge_polydata,
reset_camera=reset_camera,
)
self._clear_editable_candidates()
self._clear_cylinder_candidates()
timings = result.get("timings")
if isinstance(timings, dict):
timings["apply_loaded"] = time.perf_counter() - apply_started
display_state = result.get("display", "quick preview" if self.load_in_progress else "ready")
if edge_deferred:
if large_interaction_model:
self.large_model_edge_overlay_skipped = True
display_state = f"{display_state}; edge display skipped for large model"
else:
display_state = f"{display_state}; edge display pending"
info_payload = {
"file": str(self.step_path),
"parts": stats.parts,
"solids": stats.solids,
"faces": stats.faces,
"edges": stats.edges,
"vertices": stats.vertices,
"display": display_state,
}
timing_text = _timing_summary(timings if isinstance(timings, dict) else None)
if timing_text:
info_payload["load_performance"] = timing_text
self.set_info(info_payload)
self._update_action_states()
if edge_deferred and not large_interaction_model:
QTimer.singleShot(80, self._rebuild_deferred_edge_display)
elif edge_deferred and large_interaction_model:
self.statusBar().showMessage(
f"Loaded {self.step_path.name}; 大模型已跳过全量边线补绘,旋转会更流畅,切到 Edge 选择时再按需生成。"
)
scdm_cache_restored = False if pending_scdm_reload else self._restore_scdm_feature_cache_from_disk()
if pending_scdm_reload:
QTimer.singleShot(120, lambda: self._start_scdm_probe_preload(force=True))
elif not scdm_cache_restored:
self._defer_post_import_recognition_preloads()
def _large_model_interaction_mode(self, stats: object | None = None) -> bool:
if stats is not None:
try:
return int(getattr(stats, "faces", 0) or 0) > 1000 or int(getattr(stats, "edges", 0) or 0) > 2500
except Exception:
return False
if self.model is None:
return False
try:
return len(getattr(self.model, "faces", ()) or ()) > 1000 or len(getattr(self.model, "edges", ()) or ()) > 2500
except Exception:
return False
def _defer_large_model_recognition_preloads(self) -> None:
self.scdm_feature_cache_state = "deferred"
self.scdm_feature_cache_message = (
"大模型已延后 SCDM/Analysis Situs 全量识别,优先保证查看、旋转和点选流畅;"
"本地 OCCT 快路径仍可直接用于已稳定验证的参数。"
)
if self.model is not None:
try:
self.model.fail_asitus_hole_region_load("大模型已延后 Analysis Situs 全量孔组识别。")
except Exception:
pass
if hasattr(self, "_update_current_capability_panel"):
self._update_current_capability_panel()
def _defer_post_import_recognition_preloads(self) -> None:
self.scdm_feature_cache_state = "deferred"
self.scdm_feature_cache_message = (
"模型已先完成显示;SCDM 可修改参数将在用户选择对象后按需识别,避免导入阶段与首次渲染抢占资源。"
)
if self.model is not None:
try:
self.model.fail_asitus_hole_region_load("导入后已延后 Analysis Situs 全量孔组识别。")
except Exception:
pass
if hasattr(self, "_update_current_capability_panel"):
self._update_current_capability_panel()
def _maybe_start_scdm_probe_for_selection(self) -> None:
if self.model is None or self.step_path is None:
return
if str(getattr(self, "scdm_feature_cache_state", "") or "") != "deferred":
return
thread = getattr(self, "scdm_thread", None)
if thread is not None and thread.isRunning():
return
self.scdm_feature_cache_message = (
"已选择对象,正在按需调用 SCDM 识别可修改参数;"
"识别完成前暂不把圆柱类本地兜底判断当作已确认孔/槽能力。"
)
if hasattr(self, "_update_current_capability_panel"):
self._update_current_capability_panel()
self.statusBar().showMessage("正在按需识别 SCDM 可修改参数,可继续查看模型。")
QTimer.singleShot(0, lambda: self._start_scdm_probe_preload(force=True))
def _invalidate_scdm_feature_cache(self, message: str = "") -> None:
self.scdm_feature_cache = None
self.scdm_feature_cache_state = "stale"
self.scdm_feature_cache_message = message
self.scdm_feature_cache_path = ""
if self.model is not None:
try:
self.model.scdm_feature_cache = None
except Exception:
pass
if hasattr(self, "_update_current_capability_panel"):
self._update_current_capability_panel()
def _scdm_feature_cache_valid_for_loaded_step(self, cache: object) -> bool:
if self.step_path is None or not isinstance(cache, dict):
return False
try:
fingerprint = file_fingerprint(Path(self.step_path))
except OSError:
return False
revision = _safe_int_or_none(cache.get("mapperRevision")) or 0
return str(cache.get("modelFingerprint") or "") == fingerprint and revision >= SCDM_FEATURE_CACHE_REVISION
def _install_scdm_feature_cache(self, cache: dict[str, object], *, cache_path: str = "", message: str = "") -> bool:
if not self._scdm_feature_cache_valid_for_loaded_step(cache):
self.scdm_feature_cache = None
self.scdm_feature_cache_state = "stale"
self.scdm_feature_cache_message = "本地 SCDM cache 与当前模型或当前 mapper 版本不匹配,已丢弃。"
self.scdm_feature_cache_path = ""
if self.model is not None:
try:
self.model.scdm_feature_cache = None
except Exception:
pass
if hasattr(self, "_update_current_capability_panel"):
self._update_current_capability_panel()
return False
self.scdm_feature_cache = dict(cache)
self.scdm_feature_cache_state = "ready"
self.scdm_feature_cache_message = message or "SCDM 可修改参数识别完成。"
self.scdm_feature_cache_path = str(cache_path or "")
if self.model is not None:
try:
self.model.scdm_feature_cache = dict(cache)
except Exception:
pass
if hasattr(self, "_update_current_capability_panel"):
self._update_current_capability_panel()
if hasattr(self, "_refresh_property_editor"):
self._refresh_property_editor()
return True
def _restore_scdm_feature_cache_from_disk(self) -> bool:
if self.model is None or self.step_path is None:
return False
step_path = Path(self.step_path)
try:
fingerprint = file_fingerprint(step_path)
except OSError:
return False
project_root = Path(__file__).resolve().parent.parent
work_dir = default_scdm_work_dir(step_path, project_root=project_root, fingerprint=fingerprint)
cache_path = work_dir / "scdm_feature_cache.json"
cache: dict[str, object] | None = None
if cache_path.is_file():
try:
candidate = read_json(cache_path)
revision = _safe_int_or_none(candidate.get("mapperRevision")) or 0
if str(candidate.get("modelFingerprint") or "") == fingerprint and revision >= SCDM_FEATURE_CACHE_REVISION:
cache = candidate
elif str(candidate.get("modelFingerprint") or "") == fingerprint:
self.scdm_feature_cache_message = "本地 SCDM cache 版本过旧,将在选择对象后按需重新识别。"
except Exception:
cache = None
if cache is None:
return False
installed = self._install_scdm_feature_cache(
cache,
cache_path=str(cache_path),
message="已从本地 SCDM 识别缓存恢复;模型变更后会重新识别。",
)
if not installed:
return False
self.statusBar().showMessage("已恢复本地 SCDM 识别缓存,参数表可直接使用。")
return True
def _current_scdm_feature_cache_matches_loaded_step(self) -> bool:
return self._scdm_feature_cache_valid_for_loaded_step(getattr(self, "scdm_feature_cache", None))
def _start_scdm_probe_preload(self, *, force: bool = False) -> None:
if self.model is None or self.step_path is None:
return
if not force and self._current_scdm_feature_cache_matches_loaded_step():
return
if not force and self._large_model_interaction_mode():
self._defer_large_model_recognition_preloads()
return
if self.scdm_thread is not None and self.scdm_thread.isRunning():
QTimer.singleShot(500, lambda: self._start_scdm_probe_preload(force=force))
return
step_path = Path(self.step_path)
try:
local_face_signatures = [dict(item) for item in self._scdm_local_face_signatures()]
except Exception:
local_face_signatures = []
context = {
"path": step_path,
"model_id": id(self.model),
}
self.pending_scdm_context = dict(context)
self.scdm_feature_cache_state = "running"
self.scdm_feature_cache_message = "正在识别 SCDM 可修改参数。"
self.statusBar().showMessage("正在后台识别 SCDM 可修改参数,可继续旋转查看模型。")
if hasattr(self, "_update_current_capability_panel"):
self._update_current_capability_panel()
def action(path=step_path, face_signatures=tuple(local_face_signatures)):
probe = run_scdm_probe(path, project_root=Path(__file__).resolve().parent.parent, timeout_seconds=180.0)
if not isinstance(probe, dict) or probe.get("ok") is not True:
return {
"ok": False,
"reason": str(probe.get("reason") if isinstance(probe, dict) else "probe-failed"),
"message": str(probe.get("message") if isinstance(probe, dict) else "SCDM probe failed."),
"probe": probe,
}
raw = probe.get("raw")
if not isinstance(raw, dict):
return {"ok": False, "reason": "missing-raw", "message": "SCDM probe did not return raw feature data.", "probe": probe}
cache = attach_local_face_ids_to_scdm_cache(
map_scdm_raw_features(raw),
face_signatures,
)
cache_path = Path(str(probe.get("raw_features_path") or path)).with_name("scdm_feature_cache.json")
write_json(cache_path, cache)
return {
"ok": True,
"reason": "ok",
"cache": cache,
"cache_path": str(cache_path),
"probe": probe,
}
thread = QThread(self)
worker = ScanWorker(action)
worker.moveToThread(thread)
thread.started.connect(worker.run)
worker.finished.connect(self._finish_scdm_probe_preload, Qt.ConnectionType.QueuedConnection)
worker.failed.connect(self._fail_scdm_probe_preload, Qt.ConnectionType.QueuedConnection)
thread.finished.connect(worker.deleteLater)
thread.finished.connect(thread.deleteLater)
thread.finished.connect(self._forget_scdm_thread)
self.scdm_thread = thread
self.scdm_worker = worker
try:
thread.start(QThread.Priority.LowPriority)
except TypeError:
thread.start()
def _scdm_local_face_signatures(self) -> list[dict[str, object]]:
if self.model is None:
return []
builder = getattr(self.model, "scdm_local_face_signatures", None)
if callable(builder):
try:
return [dict(item) for item in builder() if isinstance(item, dict)]
except Exception:
return []
return []
@Slot(object)
def _finish_scdm_probe_preload(self, result: object) -> None:
if self._reroute_to_ui_thread(lambda result=result: self._finish_scdm_probe_preload(result)):
return
try:
context = dict(self.pending_scdm_context or {})
if self.model is None or id(self.model) != context.get("model_id"):
return
if self.step_path is None or Path(context.get("path", "")) != Path(self.step_path):
return
if not isinstance(result, dict) or result.get("ok") is not True:
reason = str(result.get("reason") if isinstance(result, dict) else "probe-failed")
message = str(result.get("message") if isinstance(result, dict) else "SCDM probe failed.")
if isinstance(result, dict) and isinstance(result.get("backend"), dict):
self.scdm_backend_status = dict(result["backend"])
self.scdm_feature_cache = None
self.scdm_feature_cache_state = "failed"
self.scdm_feature_cache_message = message
self.statusBar().showMessage(f"SCDM 可修改参数识别未启用:{reason}")
if hasattr(self, "_update_current_capability_panel"):
self._update_current_capability_panel()
if hasattr(self, "maybe_prompt_missing_scdm_backend"):
self.maybe_prompt_missing_scdm_backend(reason=reason, message=message)
if hasattr(self, "_finish_pending_scdm_edit_reload"):
self._finish_pending_scdm_edit_reload(cache_ready=False, message=f"SCDM cache 刷新失败:{reason}")
return
cache = result.get("cache")
if not isinstance(cache, dict):
if isinstance(result.get("backend"), dict):
self.scdm_backend_status = dict(result["backend"])
self.scdm_feature_cache_state = "failed"
self.scdm_feature_cache_message = "SCDM cache 格式无效。"
self.statusBar().showMessage("SCDM 可修改参数识别失败:cache 格式无效。")
if hasattr(self, "_update_current_capability_panel"):
self._update_current_capability_panel()
if hasattr(self, "_finish_pending_scdm_edit_reload"):
self._finish_pending_scdm_edit_reload(cache_ready=False, message="SCDM cache 刷新失败:格式无效")
return
if isinstance(result.get("backend"), dict):
self.scdm_backend_status = dict(result["backend"])
installed = self._install_scdm_feature_cache(
dict(cache),
cache_path=str(result.get("cache_path") or ""),
message="SCDM 可修改参数识别完成。",
)
if not installed:
self.statusBar().showMessage("SCDM cache 与当前模型不匹配,已丢弃并保持参数表不可用。")
if hasattr(self, "_finish_pending_scdm_edit_reload"):
self._finish_pending_scdm_edit_reload(cache_ready=False, message="SCDM cache 与当前模型不匹配")
return
objects = cache.get("objects")
count = len(objects) if isinstance(objects, list) else 0
self.statusBar().showMessage(f"SCDM 可修改参数识别完成:{count} 个产品化对象。")
if hasattr(self, "_finish_pending_scdm_edit_reload"):
self._finish_pending_scdm_edit_reload(cache_ready=True)
finally:
self._request_thread_quit(self.scdm_thread)
@Slot(str)
def _fail_scdm_probe_preload(self, message: str) -> None:
if self._reroute_to_ui_thread(lambda message=message: self._fail_scdm_probe_preload(message)):
return
try:
self.scdm_feature_cache = None
self.scdm_feature_cache_state = "failed"
self.scdm_feature_cache_message = message
self.statusBar().showMessage("SCDM 可修改参数识别失败,不影响当前模型查看。")
if hasattr(self, "_update_current_capability_panel"):
self._update_current_capability_panel()
if hasattr(self, "_finish_pending_scdm_edit_reload"):
self._finish_pending_scdm_edit_reload(cache_ready=False, message=f"SCDM cache 刷新失败:{message}")
finally:
self._request_thread_quit(self.scdm_thread)
@Slot()
def _forget_scdm_thread(self) -> None:
self.scdm_thread = None
self.scdm_worker = None
self.pending_scdm_context = None
def _start_asitus_hole_recognition_preload(self, *, force: bool = False) -> None:
if self.model is None or self.step_path is None:
return
if not force and self._large_model_interaction_mode():
return
if self.asitus_thread is not None and self.asitus_thread.isRunning():
return
if not hasattr(self.model, "begin_asitus_hole_region_load"):
return
if not self.model.begin_asitus_hole_region_load():
return
step_path = Path(self.step_path)
context = {
"path": step_path,
"model_id": id(self.model),
}
self.pending_asitus_context = dict(context)
def action(path=step_path):
return run_asitus_hole_recognition(path)
thread = QThread(self)
worker = ScanWorker(action)
worker.moveToThread(thread)
thread.started.connect(worker.run)
worker.finished.connect(self._finish_asitus_hole_recognition, Qt.ConnectionType.QueuedConnection)
worker.failed.connect(self._fail_asitus_hole_recognition, Qt.ConnectionType.QueuedConnection)
thread.finished.connect(worker.deleteLater)
thread.finished.connect(thread.deleteLater)
thread.finished.connect(self._forget_asitus_thread)
self.asitus_thread = thread
self.asitus_worker = worker
try:
thread.start(QThread.Priority.LowPriority)
except TypeError:
thread.start()
@Slot(object)
def _finish_asitus_hole_recognition(self, result: object) -> None:
if self._reroute_to_ui_thread(lambda result=result: self._finish_asitus_hole_recognition(result)):
return
try:
context = dict(self.pending_asitus_context or {})
if self.model is None or id(self.model) != context.get("model_id"):
return
if self.step_path is None or Path(context.get("path", "")) != Path(self.step_path):
return
mapped_groups = self.model.install_asitus_hole_recognition_result(result)
if mapped_groups:
self._refresh_selection_after_asitus_holes(mapped_groups)
finally:
self._request_thread_quit(self.asitus_thread)
@Slot(str)
def _fail_asitus_hole_recognition(self, message: str) -> None:
if self._reroute_to_ui_thread(lambda message=message: self._fail_asitus_hole_recognition(message)):
return
try:
context = dict(self.pending_asitus_context or {})
if self.model is not None and id(self.model) == context.get("model_id"):
self.model.fail_asitus_hole_region_load(message)
finally:
self._request_thread_quit(self.asitus_thread)
def _refresh_selection_after_asitus_holes(self, mapped_groups: list[tuple[int, ...]]) -> None:
if self.model is None or self.selected_face_id is None:
return
selected_id = int(self.selected_face_id)
if not any(selected_id in group for group in mapped_groups):
return
if getattr(self, "operation_in_progress", False):
return
pick_position = self.selected_pick_position
if self.selected_kind == "feature":
self.select_feature(selected_id, pick_position=pick_position)
elif self.selected_kind == "face":
self.select_face(selected_id, pick_position=pick_position)
@Slot()
def _forget_asitus_thread(self) -> None:
self.asitus_thread = None
self.asitus_worker = None
self.pending_asitus_context = None
@Slot(object)
def _finish_initial_load(self, result: object) -> None:
if self._reroute_to_ui_thread(lambda result=result: self._finish_initial_load(result)):
return
try:
if not isinstance(result, dict):
raise RuntimeError("Load task returned an unexpected result.")
result = self._detach_worker_polydata_result(result)
self._apply_loaded_model_result(result, reset_camera=True)
self.load_in_progress = False
self.pending_load_path = None
self._update_action_states()
if hasattr(self, "_update_relation_formula_buttons"):
self._update_relation_formula_buttons()
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}")
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.")
finally:
self._request_thread_quit(self.load_thread)
@Slot(str)
def _fail_initial_load(self, message: str) -> None:
if self._reroute_to_ui_thread(lambda message=message: self._fail_initial_load(message)):
return
self._end_load_task()
QMessageBox.critical(self, "Load failed", message)
self.statusBar().showMessage("STEP load failed; current model was left unchanged.")
if hasattr(self, "_fail_pending_scdm_edit_reload"):
self._fail_pending_scdm_edit_reload(message)
def _start_load_refine(self, initial_result: dict[str, object]) -> None:
self.statusBar().showMessage(f"Loaded {self.step_path.name}")
@Slot(object)
def _finish_load_refine(self, result: object) -> None:
if self._reroute_to_ui_thread(lambda result=result: self._finish_load_refine(result)):
return
try:
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)
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",
}
)
self.statusBar().showMessage(f"精细显示已完成:{self.step_path.name}")
finally:
self._end_load_task()
@Slot(str)
def _fail_load_refine(self, message: str) -> None:
if self._reroute_to_ui_thread(lambda message=message: self._fail_load_refine(message)):
return
self._end_load_task()
self.statusBar().showMessage(f"Quick preview is available; display refinement failed: {message}")
def _end_load_task(self) -> None:
self.scene_rebuild_in_progress = False
self.load_in_progress = False
self.pending_load_path = None
self._update_action_states()
if hasattr(self, "_update_relation_formula_buttons"):
self._update_relation_formula_buttons()
self._request_thread_quit(self.load_thread)
self._request_thread_quit(self.load_refine_thread)
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
current_path = getattr(self, "step_path", None)
start_dir = current_path.parent if isinstance(current_path, Path) else Path.cwd()
path, _ = QFileDialog.getOpenFileName(
self,
"打开 STEP 文件",
str(start_dir),
"STEP 文件 (*.step *.stp);;所有文件 (*.*)",
)
if path:
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)
def reload_step(self) -> None:
if self._edit_busy("请等待当前编辑完成后再重新加载。"):
return
path_text = self.path_label.text().strip() if hasattr(self, "path_label") else ""
path = Path(path_text) if path_text else self.step_path
if path is None:
self.statusBar().showMessage("请先点击“导入几何模型”选择 STEP 文件。")
return
self._ensure_vtk_interactor_started()
self.load_step(path)
def _clear_history(self) -> None:
self.undo_stack.clear()
self.redo_stack.clear()
self.operation_history.clear()
self.redo_history.clear()
if hasattr(self, "_clear_relation_formula_runtime_state"):
self._clear_relation_formula_runtime_state(clear_items=True)
self.clear_diff_preview(render=False)
if hasattr(self, "history_list"):
self.history_list.clear()
if hasattr(self, "mode_combo"):
self._update_action_states()
def _refresh_history_list(self) -> None:
if not hasattr(self, "history_list"):
self._update_action_states()
return
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)
self._update_action_states()
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
if not hasattr(self, "history_list"):
QMessageBox.information(self, "操作历史未启用", "当前界面没有构建操作历史面板。")
return
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,
"operation_name": record.operation_name,
"target": record.target,
"parameters": safe_value(record.parameters or {}),
"result_message": record.result_message,
"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 = {
"format": "step-editor-operation-history-v2",
"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:
if not hasattr(self, "part_tree"):
return
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:
return f"零件 ID: {part_id}"
try:
info = self.model.part_info(part_id)
except Exception:
return f"零件 ID: {part_id}"
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, build_edges: bool | None = None) -> None:
if self.model is None:
return
model_polydata = self.model.build_face_polydata()
large_model = len(getattr(self.model, "faces", ()) or ()) > 1000 or len(getattr(self.model, "edges", ()) or ()) > 2500
should_build_edges = (not large_model) if build_edges is None else bool(build_edges)
if should_build_edges:
edge_polydata = self.model.build_edge_polydata(
show_same_domain_internal_edges=self._show_same_domain_internal_edges()
)
self.large_model_edge_overlay_skipped = False
else:
edge_polydata = _empty_edge_polydata()
self.large_model_edge_overlay_skipped = large_model
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, build_edges=True)
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]
label = f"零件 {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]
edge_ids = self.model.edge_ids_for_solid(self.selected_solid_id)
label = f"Solid {self.selected_solid_id}"
elif self.selected_kind == "feature" and self.selected_face_id is not None:
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 = {}
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"))
label = f"特征 Face {self.selected_face_id}"
elif self.selected_kind == "multi_feature":
info = self._multi_hole_selection_info() if hasattr(self, "_multi_hole_selection_info") else {}
face_ids = _int_values(info.get("feature_highlight_face_ids"))
if not face_ids:
face_ids = _int_values(getattr(self, "multi_selected_feature_face_ids", []))
label = f"多选孔 {len(getattr(self, 'multi_selected_hole_entries', []) or [])} 个"
elif self.selected_kind == "face" and self.selected_face_id is not None:
face_ids = [self.selected_face_id]
edge_ids = self.model.face_boundary_edge_ids(self.selected_face_id)
label = f"Face {self.selected_face_id}"
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]
label = f"Edge {self.selected_edge_id}"
else:
QMessageBox.information(self, "未选择对象", "请先选择零件、Solid、Face、Edge 或特征。")
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:
QMessageBox.information(self, "未选择对象", "请先选择零件、Solid、Face、Edge 或特征。")
return
self._fit_camera_to_bounds(bounds)
self._render_window_safely()
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:
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 = {}
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"))
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)
elif self.selected_kind == "multi_feature":
info = self._multi_hole_selection_info() if hasattr(self, "_multi_hole_selection_info") else {}
face_ids = _int_values(info.get("feature_highlight_face_ids"))
if not face_ids:
face_ids = _int_values(getattr(self, "multi_selected_feature_face_ids", []))
if face_ids:
face_polydata = self._cached_face_overlay_polydata(face_ids=face_ids, smooth=False)
elif self.selected_kind == "face" and self.selected_face_id is not None:
face_ids = self._selection_same_domain_face_ids(self.selected_face_id) or [self.selected_face_id]
face_polydata = self._cached_face_overlay_polydata(face_ids=face_ids, smooth=False)
elif self.selected_kind == "edge" and self.selected_edge_id is not None:
edge_polydata = self._cached_edge_overlay_polydata(self.selected_edge_id)
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:
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 = {}
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 == "multi_feature":
info = self._multi_hole_selection_info() if hasattr(self, "_multi_hole_selection_info") else {}
face_ids = _int_values(info.get("feature_highlight_face_ids"))
if not face_ids:
face_ids = _int_values(getattr(self, "multi_selected_feature_face_ids", []))
if face_ids:
self._highlight_faces(face_ids=face_ids)
elif self.selected_kind == "face" and self.selected_face_id is not None:
face_ids = self._selection_same_domain_face_ids(self.selected_face_id) or [self.selected_face_id]
self._highlight_faces(face_ids=face_ids)
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()
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)
def _install_edge_polydata(self, edge_polydata, *, render: bool = True) -> None:
if edge_polydata is None:
edge_polydata = _empty_edge_polydata()
old_actor = getattr(self, "edge_actor", None)
if old_actor is not None:
try:
self.renderer.RemoveActor(old_actor)
except Exception:
pass
self.edge_polydata = edge_polydata
self.edge_id_array = self.edge_polydata.GetCellData().GetArray("edge_id")
self._rebuild_edge_polydata_cell_index()
edge_mapper = vtk.vtkPolyDataMapper()
edge_mapper.SetInputData(self.edge_polydata)
_prepare_static_mapper(edge_mapper)
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.edge_actor.GetProperty().LightingOff()
self.renderer.AddActor(self.edge_actor)
self.edge_overlay_polydata_cache.clear()
if render:
self._render_window_safely()
@Slot()
def _rebuild_deferred_edge_display(self) -> None:
if self.model is None or self.operation_in_progress or self.load_in_progress:
return
if self.load_refine_thread is not None and self.load_refine_thread.isRunning():
return
started = time.perf_counter()
self.statusBar().showMessage("模型已显示,正在补充边线...")
model = self.model
path = Path(self.step_path) if self.step_path is not None else None
deflection = float(getattr(self, "preview_load_deflection", 0.35) or 0.35)
show_internal_edges = self._show_same_domain_internal_edges()
context = {"path": path, "model_id": id(model), "started": started}
def action(model=model, context=context, deflection=deflection, show_internal_edges=show_internal_edges):
edge_polydata = model.build_edge_polydata(
deflection=deflection,
show_same_domain_internal_edges=show_internal_edges,
)
return {**context, "edge_polydata": edge_polydata, "elapsed": time.perf_counter() - started}
thread = QThread(self)
worker = LoadWorker(action)
worker.moveToThread(thread)
thread.started.connect(worker.run)
worker.finished.connect(self._finish_deferred_edge_display, Qt.ConnectionType.QueuedConnection)
worker.failed.connect(self._fail_deferred_edge_display, Qt.ConnectionType.QueuedConnection)
thread.finished.connect(worker.deleteLater)
thread.finished.connect(thread.deleteLater)
thread.finished.connect(self._forget_load_refine_thread)
self.load_refine_thread = thread
self.load_refine_worker = worker
try:
thread.start(QThread.Priority.LowPriority)
except TypeError:
thread.start()
@Slot(object)
def _finish_deferred_edge_display(self, result: object) -> None:
if self._reroute_to_ui_thread(lambda result=result: self._finish_deferred_edge_display(result)):
return
try:
if not isinstance(result, dict):
return
if self.model is None or id(self.model) != result.get("model_id"):
return
if self.step_path is None or Path(result.get("path", "")) != Path(self.step_path):
return
edge_polydata = self._copy_polydata_for_ui_thread(result.get("edge_polydata"))
deferred_result = dict(result)
deferred_result["edge_polydata"] = edge_polydata
if self._defer_scene_actor_update_if_interacting(
lambda deferred_result=deferred_result: self._apply_deferred_edge_display_result(deferred_result)
):
return
self._apply_deferred_edge_display_result(deferred_result)
finally:
self._request_thread_quit(self.load_refine_thread)
def _apply_deferred_edge_display_result(self, result: dict[str, object]) -> None:
if not isinstance(result, dict):
return
if self.model is None or id(self.model) != result.get("model_id"):
return
if self.step_path is None or Path(result.get("path", "")) != Path(self.step_path):
return
if self._defer_scene_actor_update_if_interacting(
lambda result=result: self._apply_deferred_edge_display_result(result)
):
return
self._install_edge_polydata(result.get("edge_polydata"), render=True)
self.large_model_edge_overlay_skipped = False
elapsed = float(result.get("elapsed") or 0.0)
self.statusBar().showMessage(f"模型边线已补充,用时 {elapsed:.1f}s")
def _defer_scene_actor_update_if_interacting(self, callback, *, delay_ms: int = 140) -> bool:
if not self._is_ui_thread():
self._invoke_on_ui_thread(callback)
return True
if not self._scene_actor_update_should_wait_for_camera():
return False
QTimer.singleShot(max(int(delay_ms), 1), callback)
return True
def _scene_actor_update_should_wait_for_camera(self) -> bool:
if bool(getattr(self, "camera_interaction_active", False)):
return True
if bool(getattr(self, "pointer_button_down", False)):
return True
ended_at = getattr(self, "last_camera_interaction_ended_at", None)
if ended_at is None:
return False
elapsed_ms = (datetime.now() - ended_at).total_seconds() * 1000.0
return elapsed_ms < 120.0
@Slot(str)
def _fail_deferred_edge_display(self, message: str) -> None:
if self._reroute_to_ui_thread(lambda message=message: self._fail_deferred_edge_display(message)):
return
try:
self.statusBar().showMessage(f"模型已显示;边线补充失败:{message}")
finally:
self._request_thread_quit(self.load_refine_thread)
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
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))
)
ids = vtk.vtkIdList()
for cell_id in cell_ids:
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
cell_ids = self.edge_cell_ids_by_edge.get(int(edge_id))
if cell_ids is None:
cell_ids = range(self.edge_polydata.GetNumberOfCells())
ids = vtk.vtkIdList()
for cell_id in cell_ids:
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.scene_rebuild_in_progress = True
self._clear_overlay_polydata_cache()
self.renderer.RemoveAllViewProps()
self.renderer.SetBackground(*VIEW_BACKGROUND_COLOR)
self._ensure_fps_overlay()
self.scene_isolated = False
self.highlight_signature = None
point_normals = None
try:
point_normals = model_polydata.GetPointData().GetNormals()
except Exception:
point_normals = None
self.model_polydata = model_polydata if point_normals is not None else _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")
self._rebuild_polydata_cell_indexes()
mapper = vtk.vtkPolyDataMapper()
mapper.SetInputData(self.model_polydata)
_prepare_static_mapper(mapper)
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()
self.model_actor.GetProperty().EdgeVisibilityOff()
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)
self.renderer.AddActor(self.model_actor)
self.edge_actor = None
self._install_edge_polydata(edge_polydata, render=False)
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._ensure_step_coordinate_axes()
self.renderer.ResetCameraClippingRange()
self.scene_rebuild_in_progress = False
self._render_window_safely(force=True)
def _on_mode_changed(self, mode: str) -> None:
self._clear_hover(render=True)
if str(mode) == "Edge" and bool(getattr(self, "large_model_edge_overlay_skipped", False)):
QTimer.singleShot(0, self._rebuild_deferred_edge_display)
if hasattr(self, "_update_id_select_title"):
self._update_id_select_title(mode)
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
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))
return
2026-07-29 15:43:28 +08:00
self._handle_left_button_press(int(x), int(y))
2026-07-29 15:43:28 +08:00
def _handle_left_button_press(self, x: int, y: int) -> None:
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.left_button_press_camera_state = self._camera_state_signature()
self.left_button_press_target_unknown = False
self.left_button_press_target = self._selection_target_at_position(x, y)
2026-07-29 15:43:28 +08:00
self.pending_hover_position = None
self.last_hover_pick_position = None
self.hover_timer.stop()
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))
camera_changed = self._left_button_camera_changed()
2026-07-29 15:43:28 +08:00
self.pointer_button_down = False
press_target = getattr(self, "left_button_press_target", None)
press_target_unknown = bool(getattr(self, "left_button_press_target_unknown", False))
2026-07-29 15:43:28 +08:00
self.left_button_press_position = None
self.left_button_dragged = False
self.left_button_press_camera_state = None
self.left_button_press_target = None
self.left_button_press_target_unknown = False
2026-07-29 15:43:28 +08:00
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 or camera_changed:
return
if not press_target_unknown and self._selection_target_signature(press_target) is None:
2026-07-29 15:43:28 +08:00
return
self._handle_left_click(x, y, required_press_target=None if press_target_unknown else press_target)
def _camera_state_signature(self) -> tuple[float, ...] | None:
renderer = getattr(self, "renderer", None)
if renderer is None:
return None
try:
camera = renderer.GetActiveCamera()
except Exception:
return None
if camera is None:
return None
values: list[float] = []
for getter in (
"GetPosition",
"GetFocalPoint",
"GetViewUp",
):
try:
values.extend(float(item) for item in getattr(camera, getter)())
except Exception:
return None
for getter in ("GetParallelScale", "GetDistance", "GetViewAngle"):
try:
values.append(float(getattr(camera, getter)()))
except Exception:
pass
return tuple(values)
def _left_button_camera_changed(self) -> bool:
before = getattr(self, "left_button_press_camera_state", None)
after = self._camera_state_signature()
if before is None or after is None or len(before) != len(after):
return False
tolerance = float(getattr(self, "left_click_camera_tolerance", 1e-7) or 1e-7)
return any(abs(float(a) - float(b)) > tolerance for a, b in zip(before, after))
2026-07-29 15:43:28 +08:00
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 _selection_target_signature(self, target: dict[str, object] | None) -> tuple[str, int] | None:
if not isinstance(target, dict):
return None
try:
return str(target["kind"]), int(target["target_id"])
except (KeyError, TypeError, ValueError):
return None
def _selection_target_at_position(self, x: int, y: int) -> dict[str, object] | None:
if (
getattr(self, "load_in_progress", False)
or getattr(self, "operation_in_progress", False)
or getattr(self, "scan_in_progress", False)
or getattr(self, "scene_rebuild_in_progress", False)
or getattr(self, "model", None) is None
):
return None
try:
return self._pick_selection_target(self._current_selection_mode(), int(x), int(y))
except Exception:
return None
def _handle_left_click(
self,
x: int,
y: int,
required_press_target: dict[str, object] | None = None,
) -> None:
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:
self.statusBar().showMessage("扫描中,请等待扫描完成后再选择对象。")
return
if self.model is None:
return
mode = self._current_selection_mode()
if required_press_target is not None and not bool(self._large_model_interaction_mode()):
target = self._pick_selection_target(mode, x, y)
else:
target = required_press_target if required_press_target is not None else self._pick_selection_target(mode, x, y)
if required_press_target is not None:
press_signature = self._selection_target_signature(required_press_target)
release_signature = self._selection_target_signature(target)
if press_signature is None or release_signature != press_signature:
self._cancel_hover_tracking(render=False)
if hasattr(self, "render_window"):
self._render_window_safely()
if self.selected_kind is not None:
self.statusBar().showMessage("左键按下和松开未命中同一对象,已保持当前选择")
return
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_safely()
if self.selected_kind is not None:
self.statusBar().showMessage("未命中对象,已保持当前选择")
else:
self.statusBar().showMessage("未命中对象")
return
self._clear_hover(render=False)
if self._is_multi_feature_toggle_request(target):
self._toggle_multi_feature_selection(target, pick_position=target.get("pick_position"))
return
self._select_pick_target(target)
def _is_multi_feature_toggle_request(self, target: dict[str, object]) -> bool:
if str(target.get("kind") or "") not in {"feature", "face"}:
return False
try:
return bool(QApplication.keyboardModifiers() & Qt.KeyboardModifier.ControlModifier)
except Exception:
return False
def _toggle_multi_feature_selection(
self,
target: dict[str, object],
*,
pick_position: tuple[float, float, float] | None = None,
) -> None:
if self.model is None:
return
face_id = _safe_int_or_none(target.get("target_id"))
if face_id is None:
return
entry = self._hole_multi_select_entry(face_id) if hasattr(self, "_hole_multi_select_entry") else None
if entry is None:
self.statusBar().showMessage("多选第一版只支持完整圆柱孔;请按 Ctrl 选择孔壁。")
return
entries = [dict(item) for item in (getattr(self, "multi_selected_hole_entries", []) or [])]
if not entries and self.selected_kind == "feature" and self.selected_face_id is not None:
current_entry = self._hole_multi_select_entry(int(self.selected_face_id))
if current_entry is not None:
entries.append(dict(current_entry))
logical_id = int(entry.get("logical_id", face_id))
existing_index = next(
(
index
for index, item in enumerate(entries)
if int(item.get("logical_id", -1)) == logical_id
),
None,
)
if existing_index is None:
entries.append(dict(entry))
else:
entries.pop(existing_index)
if len(entries) < 2:
if entries:
self.select_feature(int(entries[0]["face_id"]), pick_position=pick_position)
else:
self._reset_selection()
return
part_ids = {
int(item["part_id"])
for item in entries
if item.get("part_id") not in {None, ""}
}
solid_ids = {
int(item["solid_id"])
for item in entries
if item.get("solid_id") not in {None, ""}
}
self._reset_selection(clear_highlight=False, clear_info=False)
self.multi_selected_hole_entries = entries
self.multi_selected_feature_face_ids = [int(item["face_id"]) for item in entries]
self.multi_selection_active = True
self.selected_kind = "multi_feature"
self.selected_face_id = int(entries[-1]["face_id"])
self.selected_edge_id = None
self.selected_part_id = next(iter(part_ids)) if len(part_ids) == 1 else None
self.selected_solid_id = next(iter(solid_ids)) if len(solid_ids) == 1 else None
self.selected_pick_position = pick_position
info = self._multi_hole_selection_info()
if hasattr(self, "_set_selection_mode"):
self._set_selection_mode("Feature")
if hasattr(self, "_sync_id_picker"):
self._sync_id_picker("Feature", int(entries[-1].get("logical_id", entries[-1]["face_id"])))
self._highlight_faces(face_ids=_int_values(info.get("feature_highlight_face_ids")) or self.multi_selected_feature_face_ids)
self._show_pick_marker(pick_position)
self.set_info(self._with_pick_info(info, pick_position))
ids_text = ", ".join(str(item.get("logical_id")) for item in entries if item.get("logical_id") is not None)
self.statusBar().showMessage(f"已多选 {len(entries)} 个孔:{ids_text}。再次 Ctrl 点击可移除。")
def on_pointer_button_press(self, _obj, _event) -> None:
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:
self.pointer_button_down = True
self.left_button_press_target = None
self.pending_hover_position = None
self.last_hover_pick_position = None
self.hover_timer.stop()
self._clear_hover(render=False)
def on_pointer_button_release(self, _obj, _event) -> None:
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:
self.pointer_button_down = False
self.left_button_press_target = None
self.pending_hover_position = None
self.last_hover_pick_position = None
2026-07-29 15:43:28 +08:00
self._end_camera_interaction()
def on_mouse_move(self, _obj, _event) -> None:
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:
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()
return
self.pointer_button_down = False
if (
self.operation_in_progress
or self.scan_in_progress
or self.load_in_progress
or getattr(self, "scene_rebuild_in_progress", False)
or self.model is None
or self.model_actor is None
or self._hover_suppressed_after_camera()
):
self._clear_hover(render=True)
return
self._queue_hover_position(x, y)
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 getattr(self, "scene_rebuild_in_progress", False)
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)
self._queue_hover_position(x, y)
def _queue_hover_position(self, x: int, y: int) -> None:
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
if (
getattr(self, "pointer_button_down", False)
or getattr(self, "camera_interaction_active", False)
or getattr(self, "scene_rebuild_in_progress", False)
or getattr(self, "large_model_hover_disabled", False)
or self._hover_suppressed_after_camera()
):
# 鼠标拖动旋转和场景重建期间不做 hover/pick,避免“只是看模型却自动选面”。
return
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
if self.hover_timer.isActive():
self.hover_timer.stop()
self.hover_timer.start(self.hover_interval_ms)
def _update_hover_target(self) -> None:
if getattr(self, "pointer_button_down", False) or getattr(self, "camera_interaction_active", False):
self._clear_hover(render=False)
return
if (
self.operation_in_progress
or self.scan_in_progress
or self.load_in_progress
or getattr(self, "scene_rebuild_in_progress", False)
or self.model is None
or self.model_actor is None
or self.pending_hover_position is None
or getattr(self, "large_model_hover_disabled", False)
or self._hover_suppressed_after_camera()
):
self._clear_hover(render=True)
return
x, y = self.pending_hover_position
self.last_hover_pick_position = (x, y)
target = self._pick_hover_target(self._current_selection_mode(), x, y)
self._show_hover_target(target)
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)
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 or bool(getattr(self, "scene_rebuild_in_progress", False)):
return None
try:
self.picker.InitializePickList()
self.picker.PickFromListOn()
self.picker.AddPickList(actor)
# 只拾取指定 actor,避免坐标轴、高亮 overlay 或边线 actor 抢到 Face/Edge 选择。
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()),
}
except Exception:
try:
self.picker.PickFromListOff()
except Exception:
pass
return None
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:
if self._edit_busy("编辑计算中,暂时不能切换结构树选择。"):
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:
self._set_selection_mode("Solid")
self.select_solid(int(target_id), int(part_id))
return
if node_kind in {"part", "assembly"} and target_id is not None:
self._set_selection_mode("Part")
self.select_part(int(target_id))
def on_cylinder_row_clicked(self, row: int, _column: int) -> None:
if not hasattr(self, "cylinder_table"):
return
if self._edit_busy("编辑计算中,暂时不能切换圆柱面候选。"):
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
self._set_selection_mode("Feature")
self.select_feature(int(face_id))
def on_editable_row_clicked(self, row: int, _column: int) -> None:
if not hasattr(self, "editable_table"):
return
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":
self._set_selection_mode("Feature")
self.select_feature(int(target_id))
self.statusBar().showMessage(f"已选择可调整孔径候选Face {target_id}")
elif action == "resize_boss":
self._set_selection_mode("Feature")
self.select_feature(int(target_id))
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}")
elif action == "resize_slot_width":
self._set_selection_mode("Feature")
self.select_feature(int(target_id))
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}")
elif action == "resize_shell_thickness":
self._set_selection_mode("Feature")
self.select_feature(int(target_id))
self.statusBar().showMessage(f"已选择可调整壳体厚度候选Face {target_id}")
elif action == "suppress_cylinder":
self._set_selection_mode("Feature")
self.select_feature(int(target_id))
self.statusBar().showMessage(f"已选择可封堵圆柱孔Face {target_id}")
elif action == "resize_depth":
self._set_selection_mode("Feature")
self.select_feature(int(target_id))
2026-07-29 15:43:28 +08:00
self.statusBar().showMessage(f"已选择可调整盲孔/盲槽深度候选Face {target_id}")
elif action == "inspect_existing_fillet":
self._set_selection_mode("Feature")
self.select_feature(int(target_id))
self.statusBar().showMessage(f"已选择已有圆角/倒圆候选Face {target_id}")
elif action == "inspect_existing_chamfer":
self._set_selection_mode("Feature")
self.select_feature(int(target_id))
self.statusBar().showMessage(f"已选择已有倒角候选Face {target_id}")
elif action == "fillet_edge":
self._set_selection_mode("Edge")
self.select_edge(int(target_id))
self.statusBar().showMessage(f"已选择可添加圆角Edge {target_id}")
elif action == "chamfer_edge":
self._set_selection_mode("Edge")
self.select_edge(int(target_id))
self.statusBar().showMessage(f"已选择可添加倒角Edge {target_id}")
elif action == "resize_edge_length":
self._set_selection_mode("Edge")
self.select_edge(int(target_id))
self.statusBar().showMessage(f"已选择可尝试调整长度的Edge {target_id}")
elif action == "resize_ellipse_edge_major_radius":
self._set_selection_mode("Edge")
self.select_edge(int(target_id))
self.statusBar().showMessage(f"已选择可调整椭圆主半径的Edge {target_id}")
elif action == "resize_ellipse_edge_minor_radius":
self._set_selection_mode("Edge")
self.select_edge(int(target_id))
self.statusBar().showMessage(f"已选择可调整椭圆小半径的Edge {target_id}")
elif target_kind == "edge":
self._set_selection_mode("Edge")
self.select_edge(int(target_id))
self.statusBar().showMessage(f"已选择Edge {target_id}")
else:
self._set_selection_mode("Face")
self.select_face(int(target_id))
self.statusBar().showMessage(f"已选择可拉伸/切除平面Face {target_id}")
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:
QMessageBox.information(self, "ID 无效", "请输入整数 ID。零件 ID 从 1 开始,Solid/Face/Edge ID 从 0 开始。")
return
kind = _selection_mode_value(kind or self._current_selection_mode())
try:
if kind == "Part":
if self.model.part_by_id(target_id) is None:
raise ValueError(f"不存在零件 ID {target_id}")
self._set_selection_mode("Part")
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]
self._set_selection_mode("Solid")
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}")
self._set_selection_mode("Face")
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:
raise ValueError(f"不存在特征来源 Face/逻辑 Face ID {target_id}")
self._set_selection_mode("Feature")
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}")
self._set_selection_mode("Edge")
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)
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()
self._maybe_start_scdm_probe_for_selection()
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
if self._edit_busy("编辑计算中,暂时不能选择Solid。"):
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)
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()
self._maybe_start_scdm_probe_for_selection()
self.statusBar().showMessage(self._selection_status(f"已选择Solid {solid_id}", pick_position))
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
if self._edit_busy("编辑计算中,暂时不能选择Face。"):
return
2026-07-29 15:43:28 +08:00
self._reset_selection(clear_highlight=False, clear_info=False)
self.selected_kind = "feature" if feature_mode else "face"
self.selected_face_id = face_id
self.selected_pick_position = pick_position
info = self.model.quick_face_info(face_id)
if feature_mode:
info = self._feature_context_info(face_id)
info["kind"] = "feature"
info.setdefault("feature_mode", "当前是几何候选判断,不等同于 CAD 历史特征")
highlight_face_ids = _int_values(info.get("feature_highlight_face_ids")) or [face_id]
else:
highlight_face_ids = self._selection_same_domain_face_ids(face_id, info) or [face_id]
selection_fields = self._selection_identity_fields(
face_id,
"特征来源 Face" if feature_mode else "Face",
region_face_ids=highlight_face_ids,
)
logical_id = int(selection_fields["selection_display_id"])
input_info = dict(info)
input_info.update(selection_fields)
self._sync_cylindrical_edit_inputs(input_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
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", logical_id)
self.set_info(self._with_pick_info(input_info, pick_position))
self._update_action_states()
self._maybe_start_scdm_probe_for_selection()
message = f"已选择Face {logical_id}" if not feature_mode else f"已选择特征来源 Face {logical_id}"
self.statusBar().showMessage(self._selection_status(message, pick_position))
def _selection_same_domain_face_ids(self, face_id: int, fallback_info: dict[str, object] | None = None) -> list[int]:
if self.model is None:
return [face_id]
fallback_ids = _int_values((fallback_info or {}).get("feature_highlight_face_ids")) or _int_values(
(fallback_info or {}).get("same_domain_face_ids")
)
if fallback_ids:
return sorted({int(item) for item in fallback_ids if 0 <= int(item) < len(self.model.faces)}) or [face_id]
if bool(getattr(self, "_large_model_interaction_mode", lambda: False)()):
return [face_id]
try:
face_ids = self.model.face_region_ids(face_id)
except Exception:
face_ids = []
if not face_ids:
try:
face_ids = self.model.connected_same_domain_face_ids(face_id)
except Exception:
face_ids = []
face_ids = sorted({int(item) for item in face_ids if 0 <= int(item) < len(self.model.faces)})
return face_ids or [face_id]
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
info = self._feature_context_info(face_id)
info["kind"] = "feature"
selection_fields = self._selection_identity_fields(
face_id,
"特征来源 Face",
region_face_ids=_int_values(info.get("feature_highlight_face_ids")) or [face_id],
)
logical_id = int(selection_fields["selection_display_id"])
info.update(selection_fields)
2026-07-29 15:43:28 +08:00
self._reset_selection(clear_highlight=False, clear_info=False)
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", logical_id)
self.set_info(self._with_pick_info(info, pick_position))
feature_type = str(info.get("feature_type", "特征候选"))
if hasattr(self, "_feature_display_label"):
feature_type = self._feature_display_label(feature_type)
self._update_action_states()
self._maybe_start_scdm_probe_for_selection()
self.statusBar().showMessage(self._selection_status(f"已选择 {feature_type},来源 Face {logical_id}", pick_position))
def _sync_cylindrical_edit_inputs(self, info: dict[str, object]) -> None:
fillet_radius_suggestion: str | None = None
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()
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()
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()
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()
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()
self.boss_diameter_input.clear()
2026-07-29 15:43:28 +08:00
if hasattr(self, "boss_height_input"):
self.boss_height_input.clear()
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()
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()
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()
self.boss_diameter_input.clear()
2026-07-29 15:43:28 +08:00
if hasattr(self, "boss_height_input"):
self.boss_height_input.clear()
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()
if hasattr(self, "shell_thickness_input"):
self.shell_thickness_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()
self.boss_diameter_input.clear()
2026-07-29 15:43:28 +08:00
if hasattr(self, "boss_height_input"):
self.boss_height_input.clear()
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
if self._edit_busy("编辑计算中,暂时不能选择Edge。"):
return
2026-07-29 15:43:28 +08:00
self._reset_selection(clear_highlight=False, clear_info=False)
self.selected_kind = "edge"
self.selected_edge_id = edge_id
self.selected_pick_position = pick_position
info = self.model.edge_info(edge_id)
info.update(self._edge_first_level_selection_fields(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()
self._maybe_start_scdm_probe_for_selection()
self.statusBar().showMessage(self._selection_status(f"已选择Edge {edge_id}", pick_position))
def _highlight_faces(self, face_ids=None, part_ids=None) -> None:
if self.model is None:
return
face_key = _int_tuple_or_none(face_ids)
part_key = _int_tuple_or_none(part_ids)
signature = ("faces", face_key, part_key)
if signature == getattr(self, "highlight_signature", None) and self.highlight_actor is not None:
return
self._clear_highlight()
polydata = self._cached_face_overlay_polydata(face_ids=face_key, part_ids=part_key, 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)
actor.GetProperty().EdgeVisibilityOff()
self._offset_overlay_actor_toward_camera(actor, scale=0.00035)
self.highlight_actor = actor
self.highlight_signature = signature
self.renderer.AddActor(actor)
self._render_window_safely()
def _highlight_edge(self, edge_id: int) -> None:
if self.model is None:
return
signature = ("edge", int(edge_id))
if signature == getattr(self, "highlight_signature", None) and self.edge_highlight_actor is not 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.highlight_signature = signature
self.renderer.AddActor(actor)
self._render_window_safely()
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_safely()
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":
face_ids = self._selection_same_domain_face_ids(target_id) or [target_id]
self._highlight_hover_faces(face_ids=face_ids)
elif kind == "edge":
self._highlight_hover_edge(target_id)
else:
face_ids = self._selection_same_domain_face_ids(target_id) or [target_id]
self._highlight_hover_faces(face_ids=face_ids)
self.hover_signature = signature
self._render_window_safely()
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)
actor.GetProperty().EdgeVisibilityOff()
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_safely()
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
self.highlight_signature = 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_safely()
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,
) -> 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]))
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:
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 预览数据")
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)
self._start_edit_preview_pulse()
self._render_window_safely()
def _show_shell_thickness_preview(
self,
face_id: int,
target_thickness: float,
plan: dict[str, object] | None = None,
) -> None:
if self.model is None:
return
self.clear_edit_preview(render=False)
try:
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("当前显示网格里没有可复用的壳体预览数据")
except Exception as exc:
self.statusBar().showMessage(f"壳体厚度调整预览不可用:{exc}")
return
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)
self._start_edit_preview_pulse()
self._render_window_safely()
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_safely()
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_safely()
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_safely()
2026-07-29 15:43:28 +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_safely()
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_safely()
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_safely()
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), "倒角预览")
def _show_edge_length_preview(
self,
edge_id: int,
target_length: float,
anchor_mode: str = "auto",
strategy_mode: str = "auto",
) -> None:
if self.model is None:
return
self.clear_edit_preview(render=False)
try:
polydata = self.model.straight_edge_length_preview_polydata(
edge_id,
target_length,
anchor_mode=anchor_mode,
strategy_mode=strategy_mode,
)
except Exception as exc:
self.statusBar().showMessage(f"Edge长度修改预览不可用:{exc}")
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_safely()
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:
self.statusBar().showMessage(f"{label}不可用:没有找到选中的Edge。")
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_safely()
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_safely()
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_safely()
self.statusBar().showMessage("已清除差异预览")
if hasattr(self, "mode_combo"):
self._update_action_states()
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_safely()
self._update_action_states()
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