3397 lines
146 KiB
Python
3397 lines
146 KiB
Python
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 .records import OperationRecord
|
||
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
|
||
|
||
|
||
class WindowCoreMixin:
|
||
@Slot(object)
|
||
def _run_ui_task(self, callback) -> None:
|
||
callback()
|
||
|
||
def showEvent(self, event) -> None:
|
||
super().showEvent(event)
|
||
if getattr(self, "first_show_handled", False):
|
||
return
|
||
self.first_show_handled = True
|
||
QTimer.singleShot(0, self._after_first_show)
|
||
|
||
@Slot()
|
||
def _after_first_show(self) -> None:
|
||
self._ensure_vtk_interactor_started()
|
||
if getattr(self, "auto_load_on_show", False):
|
||
self.auto_load_on_show = False
|
||
self.load_step(self.step_path, background=True)
|
||
|
||
def _ensure_vtk_interactor_started(self) -> None:
|
||
if getattr(self, "vtk_interactor_started", False):
|
||
return
|
||
self.vtk_interactor_started = True
|
||
try:
|
||
self.vtk_widget.Initialize()
|
||
except Exception:
|
||
try:
|
||
self.interactor.Initialize()
|
||
except Exception:
|
||
pass
|
||
try:
|
||
self.vtk_widget.Start()
|
||
except Exception:
|
||
pass
|
||
try:
|
||
self.render_window.Render()
|
||
except Exception:
|
||
pass
|
||
|
||
def _is_ui_thread(self) -> bool:
|
||
return QThread.currentThread() == self.thread()
|
||
|
||
def _invoke_on_ui_thread(self, callback) -> None:
|
||
if self._is_ui_thread():
|
||
callback()
|
||
return
|
||
if hasattr(self, "ui_task_requested"):
|
||
self.ui_task_requested.emit(callback)
|
||
|
||
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()
|
||
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()
|
||
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)
|
||
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)
|
||
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)
|
||
return super().eventFilter(watched, event)
|
||
|
||
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
|
||
|
||
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)
|
||
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.
|
||
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)
|
||
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:
|
||
render_window.Render()
|
||
except Exception:
|
||
pass
|
||
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
|
||
|
||
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)
|
||
|
||
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
|
||
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
|
||
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
|
||
try:
|
||
self.render_window.Render()
|
||
except Exception:
|
||
pass
|
||
|
||
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
|
||
|
||
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
|
||
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,
|
||
) -> dict[str, object]:
|
||
new_model = StepModel.load(path)
|
||
stats = new_model.stats()
|
||
result = {
|
||
"path": path,
|
||
"model": new_model,
|
||
"stats": stats,
|
||
"deflection": deflection,
|
||
"show_internal_edges": show_internal_edges,
|
||
}
|
||
if build_polydata:
|
||
face_polydata = new_model.build_face_polydata(deflection=deflection)
|
||
result["model_polydata"] = _smooth_surface_polydata(face_polydata)
|
||
result["edge_polydata"] = new_model.build_edge_polydata(
|
||
deflection=deflection,
|
||
show_same_domain_internal_edges=show_internal_edges,
|
||
)
|
||
return result
|
||
|
||
def _load_step_background_or_sync(self, path: str | Path, *, background: bool) -> None:
|
||
if self.load_in_progress:
|
||
self.statusBar().showMessage("STEP background loading is already running.")
|
||
return
|
||
new_path = Path(path)
|
||
if not background:
|
||
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()
|
||
QTimer.singleShot(0, lambda path=new_path: self._run_deferred_initial_load(path))
|
||
|
||
@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,
|
||
) -> 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,
|
||
)
|
||
result["display"] = "ready"
|
||
except Exception as exc:
|
||
QMessageBox.critical(self, "Load failed", str(exc))
|
||
self.statusBar().showMessage("STEP load failed; current model was left unchanged.")
|
||
return False
|
||
finally:
|
||
QApplication.restoreOverrideCursor()
|
||
self._apply_loaded_model_result(result, reset_camera=True)
|
||
self.statusBar().showMessage(f"Loaded {self.step_path.name}")
|
||
return True
|
||
|
||
@Slot(object)
|
||
def _run_deferred_initial_load(self, expected_path: Path) -> None:
|
||
if self.pending_load_path != expected_path:
|
||
return
|
||
try:
|
||
self._load_step_sync(
|
||
expected_path,
|
||
deflection=self.preview_load_deflection,
|
||
show_internal_edges=self._show_same_domain_internal_edges(),
|
||
status_prefix="读取 STEP 可视化网格",
|
||
)
|
||
finally:
|
||
self._end_load_task()
|
||
|
||
@staticmethod
|
||
def _copy_polydata_for_ui_thread(polydata: object) -> object:
|
||
if polydata is None:
|
||
return None
|
||
copied = vtk.vtkPolyData()
|
||
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:
|
||
new_path = Path(result["path"])
|
||
stats = result["stats"]
|
||
self.model = result["model"]
|
||
self.step_path = new_path
|
||
self._clear_history()
|
||
if hasattr(self, "measure_text"):
|
||
self.clear_measurement()
|
||
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()
|
||
self._reset_selection()
|
||
model_polydata = result.get("model_polydata")
|
||
edge_polydata = result.get("edge_polydata")
|
||
if model_polydata is None or edge_polydata is None:
|
||
deflection = float(result.get("deflection", 0.8))
|
||
show_internal_edges = bool(result.get("show_internal_edges", self._show_same_domain_internal_edges()))
|
||
model_polydata = self.model.build_face_polydata(deflection=deflection)
|
||
edge_polydata = self.model.build_edge_polydata(
|
||
deflection=deflection,
|
||
show_same_domain_internal_edges=show_internal_edges,
|
||
)
|
||
self._rebuild_scene_from_polydata(
|
||
model_polydata,
|
||
edge_polydata,
|
||
reset_camera=reset_camera,
|
||
)
|
||
self._clear_editable_candidates()
|
||
self._clear_cylinder_candidates()
|
||
self.set_info(
|
||
{
|
||
"file": str(self.step_path),
|
||
"parts": stats.parts,
|
||
"solids": stats.solids,
|
||
"faces": stats.faces,
|
||
"edges": stats.edges,
|
||
"vertices": stats.vertices,
|
||
"display": result.get("display", "quick preview" if self.load_in_progress else "ready"),
|
||
}
|
||
)
|
||
self._update_action_states()
|
||
|
||
@Slot(object)
|
||
def _finish_initial_load(self, result: object) -> None:
|
||
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 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:
|
||
self._end_load_task()
|
||
QMessageBox.critical(self, "Load failed", message)
|
||
self.statusBar().showMessage("STEP load failed; current model was left unchanged.")
|
||
|
||
def _start_load_refine(self, initial_result: dict[str, object]) -> None:
|
||
self.statusBar().showMessage(f"Loaded {self.step_path.name}")
|
||
|
||
@Slot(object)
|
||
def _finish_load_refine(self, result: object) -> None:
|
||
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:
|
||
self._end_load_task()
|
||
self.statusBar().showMessage(f"Quick preview is available; display refinement failed: {message}")
|
||
|
||
def _end_load_task(self) -> None:
|
||
self.load_in_progress = False
|
||
self.pending_load_path = None
|
||
self._update_action_states()
|
||
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
|
||
path, _ = QFileDialog.getOpenFileName(
|
||
self,
|
||
"打开 STEP 文件",
|
||
str(self.step_path.parent if self.step_path else Path.cwd()),
|
||
"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
|
||
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()
|
||
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) -> None:
|
||
if self.model is None:
|
||
return
|
||
model_polydata = self.model.build_face_polydata()
|
||
edge_polydata = self.model.build_edge_polydata(
|
||
show_same_domain_internal_edges=self._show_same_domain_internal_edges()
|
||
)
|
||
self._rebuild_scene_from_polydata(model_polydata, edge_polydata, reset_camera=reset_camera)
|
||
|
||
def _show_same_domain_internal_edges(self) -> bool:
|
||
return bool(self.show_internal_edges_checkbox and self.show_internal_edges_checkbox.isChecked())
|
||
|
||
def _on_internal_edges_toggled(self, checked: bool) -> None:
|
||
if self.model is None or self.operation_in_progress or self.scan_in_progress or self.load_in_progress:
|
||
return
|
||
if self.scene_isolated and self.selected_kind is not None:
|
||
self.isolate_selected()
|
||
else:
|
||
self._rebuild_scene(reset_camera=False)
|
||
self._refresh_selection_highlight()
|
||
state = "显示" if checked else "隐藏"
|
||
self.statusBar().showMessage(f"已{state}同域内部拓扑边")
|
||
|
||
def isolate_selected(self) -> None:
|
||
if self.model is None:
|
||
return
|
||
if self._edit_busy("编辑计算中,暂时不能切换显示范围。"):
|
||
return
|
||
face_ids: list[int] | None = None
|
||
edge_ids: list[int] | None = None
|
||
part_ids: list[int] | None = None
|
||
label = ""
|
||
|
||
if self.selected_kind == "part" and self.selected_part_id is not None:
|
||
part_ids = [self.selected_part_id]
|
||
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 == "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.Render()
|
||
self.statusBar().showMessage("已对准选中对象")
|
||
|
||
def _selected_focus_bounds(self) -> tuple[float, float, float, float, float, float] | None:
|
||
if self.model is None:
|
||
return None
|
||
face_polydata = None
|
||
edge_polydata = None
|
||
|
||
if self.selected_kind == "part" and self.selected_part_id is not None:
|
||
face_polydata = self.model.build_face_polydata(part_ids=[self.selected_part_id])
|
||
edge_polydata = self.model.build_edge_polydata(
|
||
part_ids=[self.selected_part_id],
|
||
show_same_domain_internal_edges=self._show_same_domain_internal_edges(),
|
||
)
|
||
elif self.selected_kind == "solid" and self.selected_solid_id is not None:
|
||
face_ids = [index for index, solid_id in enumerate(self.model.face_solid_ids) if solid_id == self.selected_solid_id]
|
||
edge_ids = self.model.edge_ids_for_solid(self.selected_solid_id)
|
||
face_polydata = self.model.build_face_polydata(face_ids=face_ids)
|
||
edge_polydata = self.model.build_edge_polydata(edge_ids=edge_ids)
|
||
elif self.selected_kind == "feature" and self.selected_face_id is not None:
|
||
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 == "face" and self.selected_face_id is not None:
|
||
face_polydata = self._cached_face_overlay_polydata(face_ids=[self.selected_face_id], 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 == "face" and self.selected_face_id is not None:
|
||
self._highlight_faces(face_ids=[self.selected_face_id])
|
||
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 _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._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()
|
||
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_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.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.render_window.Render()
|
||
|
||
def _on_mode_changed(self, mode: str) -> None:
|
||
self._clear_hover(render=True)
|
||
if hasattr(self, "_update_id_select_title"):
|
||
self._update_id_select_title(mode)
|
||
|
||
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():
|
||
self._invoke_on_ui_thread(lambda x=int(x), y=int(y): self._handle_left_button_press(x, y))
|
||
return
|
||
self._handle_left_button_press(int(x), int(y))
|
||
|
||
def _handle_left_button_press(self, x: int, y: int) -> None:
|
||
self.pointer_button_down = True
|
||
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 = self._selection_target_at_position(x, y)
|
||
self.pending_hover_position = None
|
||
self.last_hover_pick_position = None
|
||
self.hover_timer.stop()
|
||
self._clear_hover(render=False)
|
||
|
||
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()
|
||
self.pointer_button_down = False
|
||
press_target = getattr(self, "left_button_press_target", None)
|
||
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.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 or self._selection_target_signature(press_target) is None:
|
||
return
|
||
self._handle_left_click(x, y, required_press_target=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))
|
||
|
||
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, "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()
|
||
target = 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.Render()
|
||
if self.selected_kind is not None:
|
||
self.statusBar().showMessage("左键按下和松开未命中同一对象,已保持当前选择")
|
||
return
|
||
if target is None:
|
||
self._cancel_hover_tracking(render=False)
|
||
if hasattr(self, "render_window"):
|
||
self.render_window.Render()
|
||
if self.selected_kind is not None:
|
||
self.statusBar().showMessage("未命中对象,已保持当前选择")
|
||
else:
|
||
self.statusBar().showMessage("未命中对象")
|
||
return
|
||
|
||
self._clear_hover(render=False)
|
||
self._select_pick_target(target)
|
||
|
||
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
|
||
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:
|
||
buttons = QApplication.mouseButtons()
|
||
if buttons != Qt.MouseButton.NoButton:
|
||
self.pointer_button_down = True
|
||
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 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 self.model is None
|
||
or self.model_actor is None
|
||
):
|
||
self._clear_hover(render=True)
|
||
return
|
||
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 self._hover_suppressed_after_camera()
|
||
):
|
||
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 self.model is None
|
||
or self.model_actor is None
|
||
or self.pending_hover_position is None
|
||
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:
|
||
return None
|
||
self.picker.InitializePickList()
|
||
self.picker.PickFromListOn()
|
||
self.picker.AddPickList(actor)
|
||
picked = self.picker.Pick(int(x), int(y), 0, self.renderer)
|
||
self.picker.PickFromListOff()
|
||
if not picked or self.picker.GetCellId() < 0:
|
||
return None
|
||
return {
|
||
"cell_id": int(self.picker.GetCellId()),
|
||
"pick_position": _vector_tuple(self.picker.GetPickPosition()),
|
||
}
|
||
|
||
def _pick_face_cell(self, x: int, y: int) -> dict[str, object] | None:
|
||
hit = self._pick_actor_cell(self.model_actor, x, y)
|
||
if hit is None or self.model_polydata is None:
|
||
return None
|
||
cell_data = self._face_cell_data(int(hit["cell_id"]))
|
||
if cell_data is None:
|
||
return None
|
||
hit.update(cell_data)
|
||
return hit
|
||
|
||
def _pick_edge_cell(self, x: int, y: int) -> dict[str, object] | None:
|
||
hit = self._pick_actor_cell(self.edge_actor, x, y)
|
||
if hit is None or self.edge_polydata is None:
|
||
return None
|
||
edge_id = self._edge_id_from_cell(int(hit["cell_id"]))
|
||
if edge_id is None:
|
||
return None
|
||
hit["edge_id"] = edge_id
|
||
return hit
|
||
|
||
def _face_cell_data(self, cell_id: int) -> dict[str, int] | None:
|
||
if self.model_polydata is None or cell_id < 0:
|
||
return None
|
||
face_arr = self.model_face_id_array
|
||
part_arr = self.model_part_id_array
|
||
solid_arr = self.model_solid_id_array
|
||
if face_arr is None or part_arr is None or solid_arr is None:
|
||
return None
|
||
return {
|
||
"face_id": int(face_arr.GetValue(cell_id)),
|
||
"part_id": int(part_arr.GetValue(cell_id)),
|
||
"solid_id": int(solid_arr.GetValue(cell_id)),
|
||
}
|
||
|
||
def _edge_id_from_cell(self, cell_id: int) -> int | None:
|
||
if self.edge_polydata is None or cell_id < 0:
|
||
return None
|
||
edge_arr = self.edge_id_array
|
||
if edge_arr is None:
|
||
return None
|
||
return int(edge_arr.GetValue(cell_id))
|
||
|
||
def _target_from_face_hit(self, hit: dict[str, object], mode: str) -> dict[str, object] | None:
|
||
face_id = int(hit["face_id"])
|
||
part_id = int(hit["part_id"])
|
||
solid_id = int(hit["solid_id"])
|
||
pick_position = hit["pick_position"]
|
||
if mode == "Part":
|
||
if part_id < 0:
|
||
return None
|
||
return {"kind": "part", "target_id": part_id, "pick_position": pick_position}
|
||
if mode == "Solid":
|
||
if solid_id < 0:
|
||
return None
|
||
return {"kind": "solid", "target_id": solid_id, "part_id": part_id, "pick_position": pick_position}
|
||
if mode == "Feature":
|
||
return {"kind": "feature", "target_id": face_id, "pick_position": pick_position}
|
||
return {"kind": "face", "target_id": face_id, "pick_position": pick_position}
|
||
|
||
def _edge_target_from_cell_hit(self, hit: dict[str, object]) -> dict[str, object] | None:
|
||
edge_id = hit.get("edge_id")
|
||
if edge_id is None:
|
||
return None
|
||
return {"kind": "edge", "target_id": int(edge_id), "pick_position": hit["pick_position"]}
|
||
|
||
def _edge_target_from_face_hit(self, hit: dict[str, object]) -> dict[str, object] | None:
|
||
if self.model is None:
|
||
return None
|
||
face_id = int(hit["face_id"])
|
||
pick_position = hit["pick_position"]
|
||
edge_ids = self.model.face_boundary_edge_ids(face_id)
|
||
edge_id = self.model.nearest_edge_id_to_point(edge_ids, pick_position)
|
||
if edge_id is None:
|
||
return None
|
||
return {"kind": "edge", "target_id": edge_id, "pick_position": pick_position}
|
||
|
||
def _target_from_edge_id(
|
||
self,
|
||
edge_id: int,
|
||
mode: str,
|
||
pick_position: tuple[float, float, float] | None,
|
||
) -> dict[str, object] | None:
|
||
if self.model is None or edge_id < 0 or edge_id >= len(self.model.edges):
|
||
return None
|
||
info = self.model.edge_info(edge_id)
|
||
part_id = int(info.get("part_id", -1))
|
||
solid_id = int(info.get("solid_id", -1))
|
||
adjacent_face_ids = _int_values(info.get("adjacent_face_ids"))
|
||
if mode == "Part" and part_id >= 0:
|
||
return {"kind": "part", "target_id": part_id, "pick_position": pick_position}
|
||
if mode == "Solid" and solid_id >= 0:
|
||
return {"kind": "solid", "target_id": solid_id, "part_id": part_id, "pick_position": pick_position}
|
||
if mode == "Feature" and adjacent_face_ids:
|
||
return {"kind": "feature", "target_id": adjacent_face_ids[0], "pick_position": pick_position}
|
||
if mode == "Face" and adjacent_face_ids:
|
||
return {"kind": "face", "target_id": adjacent_face_ids[0], "pick_position": pick_position}
|
||
if mode == "Edge":
|
||
return {"kind": "edge", "target_id": edge_id, "pick_position": pick_position}
|
||
return None
|
||
|
||
def _select_pick_target(self, target: dict[str, object]) -> None:
|
||
kind = str(target["kind"])
|
||
target_id = int(target["target_id"])
|
||
pick_position = target.get("pick_position")
|
||
if kind == "part":
|
||
self.select_part(target_id, pick_position=pick_position)
|
||
elif kind == "solid":
|
||
self.select_solid(target_id, int(target["part_id"]), pick_position=pick_position)
|
||
elif kind == "feature":
|
||
self.select_feature(target_id, pick_position=pick_position)
|
||
elif kind == "edge":
|
||
self.select_edge(target_id, pick_position=pick_position)
|
||
else:
|
||
self.select_face(target_id, pick_position=pick_position)
|
||
|
||
def _select_face_cell(
|
||
self,
|
||
cell_id: int,
|
||
mode: str,
|
||
pick_position: tuple[float, float, float] | None = None,
|
||
) -> None:
|
||
if self.model is None or self.model_polydata is None:
|
||
return
|
||
face_arr = self.model_polydata.GetCellData().GetArray("face_id")
|
||
part_arr = self.model_polydata.GetCellData().GetArray("part_id")
|
||
solid_arr = self.model_polydata.GetCellData().GetArray("solid_id")
|
||
face_id = int(face_arr.GetValue(cell_id))
|
||
part_id = int(part_arr.GetValue(cell_id))
|
||
solid_id = int(solid_arr.GetValue(cell_id))
|
||
|
||
if mode == "Part":
|
||
self.select_part(part_id, pick_position=pick_position)
|
||
elif mode == "Solid":
|
||
self.select_solid(solid_id, part_id, pick_position=pick_position)
|
||
elif mode == "Feature":
|
||
self.select_feature(face_id, pick_position=pick_position)
|
||
else:
|
||
self.select_face(face_id, pick_position=pick_position)
|
||
|
||
def _select_edge_from_cell(
|
||
self,
|
||
cell_id: int,
|
||
pick_position: tuple[float, float, float] | None = None,
|
||
) -> None:
|
||
if self.model is None or self.edge_polydata is None:
|
||
return
|
||
edge_arr = self.edge_polydata.GetCellData().GetArray("edge_id")
|
||
edge_id = int(edge_arr.GetValue(cell_id))
|
||
self.select_edge(edge_id, pick_position=pick_position)
|
||
|
||
def on_part_tree_select(self, current: QTreeWidgetItem | None, _previous: QTreeWidgetItem | None) -> None:
|
||
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}")
|
||
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}")
|
||
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))
|
||
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 == "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)
|
||
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()
|
||
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)
|
||
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.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
|
||
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 = [face_id]
|
||
selection_fields = self._selection_identity_fields(
|
||
face_id,
|
||
"特征来源 Face" if feature_mode else "Face",
|
||
)
|
||
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()
|
||
raw_note = f"(当前拓扑 Face {face_id})" if logical_id != face_id else ""
|
||
message = f"已选择Face {logical_id}{raw_note}" if not feature_mode else f"已选择特征来源 Face {logical_id}{raw_note}"
|
||
self.statusBar().showMessage(self._selection_status(message, pick_position))
|
||
|
||
def select_feature(self, face_id: int, pick_position: tuple[float, float, float] | None = None) -> None:
|
||
if self.model is None:
|
||
return
|
||
if self._edit_busy("编辑计算中,暂时不能选择特征。"):
|
||
return
|
||
info = self._feature_context_info(face_id)
|
||
info["kind"] = "feature"
|
||
selection_fields = self._selection_identity_fields(face_id, "特征来源 Face")
|
||
logical_id = int(selection_fields["selection_display_id"])
|
||
info.update(selection_fields)
|
||
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()
|
||
raw_note = f"(当前拓扑 Face {face_id})" if logical_id != face_id else ""
|
||
self.statusBar().showMessage(self._selection_status(f"已选择 {feature_type},来源 Face {logical_id}{raw_note}", 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()
|
||
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)
|
||
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()
|
||
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()
|
||
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()
|
||
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()
|
||
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()
|
||
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()
|
||
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()
|
||
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()
|
||
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
|
||
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.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)
|
||
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.Render()
|
||
|
||
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.Render()
|
||
|
||
def _hover_signature_for_target(self, target: dict[str, object] | None) -> tuple[str, int] | None:
|
||
if self.model is None or target is None:
|
||
return None
|
||
kind = str(target["kind"])
|
||
target_id = int(target["target_id"])
|
||
return (kind, target_id)
|
||
|
||
def _show_hover_target(self, target: dict[str, object] | None) -> None:
|
||
if self.model is None:
|
||
self._clear_hover(render=True)
|
||
return
|
||
signature = self._hover_signature_for_target(target)
|
||
if signature == self.hover_signature:
|
||
return
|
||
|
||
self._clear_hover(render=False)
|
||
if target is None:
|
||
self.render_window.Render()
|
||
return
|
||
|
||
kind = str(target["kind"])
|
||
target_id = int(target["target_id"])
|
||
if kind == "part":
|
||
self._highlight_hover_faces(part_ids=[target_id])
|
||
elif kind == "solid":
|
||
face_ids = [index for index, solid_id in enumerate(self.model.face_solid_ids) if solid_id == target_id]
|
||
self._highlight_hover_faces(face_ids=face_ids)
|
||
elif kind == "feature":
|
||
self._highlight_hover_faces(face_ids=[target_id])
|
||
elif kind == "edge":
|
||
self._highlight_hover_edge(target_id)
|
||
else:
|
||
self._highlight_hover_faces(face_ids=[target_id])
|
||
self.hover_signature = signature
|
||
self.render_window.Render()
|
||
|
||
def _highlight_hover_faces(self, face_ids=None, part_ids=None) -> None:
|
||
if self.model is None:
|
||
return
|
||
polydata = self._cached_face_overlay_polydata(face_ids=face_ids, part_ids=part_ids, smooth=False)
|
||
if polydata is None:
|
||
return
|
||
mapper = vtk.vtkPolyDataMapper()
|
||
mapper.SetInputData(polydata)
|
||
_enable_overlay_depth_offset(mapper)
|
||
actor = vtk.vtkActor()
|
||
actor.SetMapper(mapper)
|
||
actor.GetProperty().SetColor(1.0, 0.12, 0.06)
|
||
actor.GetProperty().SetOpacity(0.58)
|
||
actor.GetProperty().SetAmbient(0.5)
|
||
actor.GetProperty().SetDiffuse(0.7)
|
||
actor.GetProperty().SetSpecular(0.35)
|
||
actor.GetProperty().SetInterpolationToPhong()
|
||
actor.GetProperty().LightingOff()
|
||
actor.GetProperty().SetLineWidth(2)
|
||
self._offset_overlay_actor_toward_camera(actor, scale=0.00055)
|
||
self.hover_face_actor = actor
|
||
self.renderer.AddActor(actor)
|
||
|
||
def _offset_overlay_actor_toward_camera(self, actor, scale: float = 0.0005) -> None:
|
||
bounds = self.model_actor.GetBounds() if self.model_actor is not None else None
|
||
camera = self.renderer.GetActiveCamera() if hasattr(self, "renderer") else None
|
||
if bounds is None or camera is None:
|
||
return
|
||
dx = float(bounds[1] - bounds[0])
|
||
dy = float(bounds[3] - bounds[2])
|
||
dz = float(bounds[5] - bounds[4])
|
||
diagonal = math.sqrt(dx * dx + dy * dy + dz * dz)
|
||
if diagonal <= 1e-9:
|
||
return
|
||
direction = camera.GetDirectionOfProjection()
|
||
amount = diagonal * float(scale)
|
||
actor.SetPosition(
|
||
-float(direction[0]) * amount,
|
||
-float(direction[1]) * amount,
|
||
-float(direction[2]) * amount,
|
||
)
|
||
|
||
def _highlight_hover_edge(self, edge_id: int) -> None:
|
||
if self.model is None:
|
||
return
|
||
polydata = self._cached_edge_overlay_polydata(edge_id)
|
||
if polydata is None:
|
||
return
|
||
mapper = vtk.vtkDataSetMapper()
|
||
mapper.SetInputData(polydata)
|
||
_enable_overlay_depth_offset(mapper)
|
||
actor = vtk.vtkActor()
|
||
actor.SetMapper(mapper)
|
||
actor.GetProperty().SetColor(1.0, 0.08, 0.02)
|
||
actor.GetProperty().SetAmbient(0.75)
|
||
actor.GetProperty().SetDiffuse(0.8)
|
||
actor.GetProperty().SetLineWidth(4)
|
||
self.hover_edge_actor = actor
|
||
self.renderer.AddActor(actor)
|
||
|
||
def _clear_hover(self, render: bool = False) -> None:
|
||
if not hasattr(self, "renderer"):
|
||
return
|
||
removed = False
|
||
if self.hover_face_actor is not None:
|
||
self.renderer.RemoveActor(self.hover_face_actor)
|
||
self.hover_face_actor = None
|
||
removed = True
|
||
if self.hover_edge_actor is not None:
|
||
self.renderer.RemoveActor(self.hover_edge_actor)
|
||
self.hover_edge_actor = None
|
||
removed = True
|
||
self.hover_signature = None
|
||
if render and removed and hasattr(self, "render_window"):
|
||
self.render_window.Render()
|
||
|
||
def _clear_highlight(self) -> None:
|
||
if self.highlight_actor is not None:
|
||
self.renderer.RemoveActor(self.highlight_actor)
|
||
self.highlight_actor = None
|
||
if self.edge_highlight_actor is not None:
|
||
self.renderer.RemoveActor(self.edge_highlight_actor)
|
||
self.edge_highlight_actor = None
|
||
if self.pick_marker_actor is not None:
|
||
self.renderer.RemoveActor(self.pick_marker_actor)
|
||
self.pick_marker_actor = None
|
||
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.Render()
|
||
|
||
def _add_edit_preview_actor(
|
||
self,
|
||
polydata,
|
||
color: tuple[float, float, float],
|
||
opacity: float | None = None,
|
||
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)
|
||
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)
|
||
|
||
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:
|
||
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)
|
||
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.Render()
|
||
|
||
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.Render()
|
||
|
||
def _show_cylinder_resize_preview(self, face_id: int, diameter: float) -> None:
|
||
if self.model is None:
|
||
return
|
||
self.clear_edit_preview(render=False)
|
||
try:
|
||
previews = self.model.cylindrical_resize_preview_polydata(face_id, diameter)
|
||
except Exception as exc:
|
||
self.statusBar().showMessage(f"孔径调整预览不可用:{exc}")
|
||
return
|
||
for preview in previews:
|
||
role = str(preview["role"])
|
||
color = (0.0, 0.86, 0.34) if role == "fill" else (1.0, 0.18, 0.06)
|
||
opacity = 0.28 if role == "fill" else 0.32
|
||
self._add_edit_preview_actor(preview["polydata"], color, opacity=opacity)
|
||
self._start_edit_preview_pulse()
|
||
self.render_window.Render()
|
||
|
||
def _show_cylinder_boss_resize_preview(self, face_id: int, diameter: float) -> None:
|
||
if self.model is None:
|
||
return
|
||
self.clear_edit_preview(render=False)
|
||
try:
|
||
previews = self.model.cylindrical_boss_resize_preview_polydata(face_id, diameter)
|
||
except Exception as exc:
|
||
self.statusBar().showMessage(f"凸台直径调整预览不可用:{exc}")
|
||
return
|
||
for preview in previews:
|
||
role = str(preview["role"])
|
||
color = (0.0, 0.86, 0.34) if role == "fill" else (1.0, 0.18, 0.06)
|
||
opacity = 0.3 if role == "fill" else 0.34
|
||
self._add_edit_preview_actor(preview["polydata"], color, opacity=opacity)
|
||
self._start_edit_preview_pulse()
|
||
self.render_window.Render()
|
||
|
||
def _show_cylinder_boss_height_preview(self, face_id: int, target_height: float) -> None:
|
||
if self.model is None:
|
||
return
|
||
self.clear_edit_preview(render=False)
|
||
try:
|
||
polydata = self.model.cylindrical_boss_height_preview_polydata(face_id, target_height)
|
||
except Exception as exc:
|
||
self.statusBar().showMessage(f"凸台高度调整预览不可用:{exc}")
|
||
return
|
||
self._add_edit_preview_actor(polydata, (0.0, 0.72, 0.78), opacity=0.34)
|
||
self._start_edit_preview_pulse()
|
||
self.render_window.Render()
|
||
|
||
def _show_cylinder_suppress_preview(self, face_id: int) -> None:
|
||
if self.model is None:
|
||
return
|
||
self.clear_edit_preview(render=False)
|
||
try:
|
||
previews = self.model.cylindrical_suppress_preview_polydata(face_id)
|
||
except Exception as exc:
|
||
self.statusBar().showMessage(f"封堵圆柱孔预览不可用:{exc}")
|
||
return
|
||
for preview in previews:
|
||
self._add_edit_preview_actor(preview["polydata"], (0.0, 0.86, 0.34), opacity=0.3)
|
||
self._start_edit_preview_pulse()
|
||
self.render_window.Render()
|
||
|
||
def _show_cylinder_depth_preview(
|
||
self,
|
||
face_id: int,
|
||
target_depth: float,
|
||
bottom_face_id: int | None = None,
|
||
) -> None:
|
||
if self.model is None:
|
||
return
|
||
self.clear_edit_preview(render=False)
|
||
try:
|
||
previews = self.model.cylindrical_depth_preview_polydata(
|
||
face_id,
|
||
target_depth,
|
||
bottom_face_id=bottom_face_id,
|
||
)
|
||
except Exception as exc:
|
||
self.statusBar().showMessage(f"孔深调整预览不可用:{exc}")
|
||
return
|
||
for preview in previews:
|
||
role = str(preview["role"])
|
||
color = (0.0, 0.86, 0.34) if role == "fill" else (1.0, 0.18, 0.06)
|
||
opacity = 0.3 if role == "fill" else 0.34
|
||
self._add_edit_preview_actor(preview["polydata"], color, opacity=opacity)
|
||
self._start_edit_preview_pulse()
|
||
self.render_window.Render()
|
||
|
||
def _show_existing_fillet_resize_preview(self, face_id: int, target_radius: float) -> None:
|
||
if self.model is None:
|
||
return
|
||
self.clear_edit_preview(render=False)
|
||
try:
|
||
previews = self.model.existing_fillet_resize_preview_polydata(face_id, target_radius)
|
||
except Exception as exc:
|
||
self.statusBar().showMessage(f"已有圆角半径修改预览不可用:{exc}")
|
||
return
|
||
for preview in previews:
|
||
self._add_edit_preview_actor(preview["polydata"], (0.35, 0.45, 1.0), opacity=0.36)
|
||
self._start_edit_preview_pulse()
|
||
self.render_window.Render()
|
||
|
||
def _show_edge_fillet_preview(self, edge_id: int, radius: float) -> None:
|
||
if self.model is None:
|
||
return
|
||
self._show_edge_tube_preview(edge_id, radius, (0.1, 0.62, 1.0), "圆角预览")
|
||
|
||
def _show_edge_chamfer_preview(self, edge_id: int, distance: float) -> None:
|
||
if self.model is None:
|
||
return
|
||
self._show_edge_tube_preview(edge_id, distance, (1.0, 0.55, 0.08), "倒角预览")
|
||
|
||
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.Render()
|
||
|
||
def _show_edge_tube_preview(
|
||
self,
|
||
edge_id: int,
|
||
radius: float,
|
||
color: tuple[float, float, float],
|
||
label: str,
|
||
) -> None:
|
||
if self.model is None:
|
||
return
|
||
self.clear_edit_preview(render=False)
|
||
polydata = self.model.build_edge_polydata(edge_ids=[edge_id])
|
||
edge_arr = polydata.GetCellData().GetArray("edge_id")
|
||
ids = vtk.vtkIdList()
|
||
for cell_id in range(polydata.GetNumberOfCells()):
|
||
if int(edge_arr.GetValue(cell_id)) == edge_id:
|
||
ids.InsertNextId(cell_id)
|
||
break
|
||
if ids.GetNumberOfIds() == 0:
|
||
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.Render()
|
||
|
||
def _start_edit_preview_pulse(self) -> None:
|
||
if self.edit_preview_timer is None:
|
||
self.edit_preview_timer = QTimer(self)
|
||
self.edit_preview_timer.timeout.connect(self._pulse_edit_preview)
|
||
self.edit_preview_phase = 0.0
|
||
self.edit_preview_timer.start(120)
|
||
|
||
def _pulse_edit_preview(self) -> None:
|
||
if not self.edit_preview_actors:
|
||
if self.edit_preview_timer is not None:
|
||
self.edit_preview_timer.stop()
|
||
return
|
||
self.edit_preview_phase += 0.35
|
||
opacity = self.edit_preview_base_opacity * (0.75 + 0.25 * (math.sin(self.edit_preview_phase) + 1.0) / 2.0)
|
||
for actor in self.edit_preview_actors:
|
||
actor.GetProperty().SetOpacity(opacity)
|
||
self.render_window.Render()
|
||
|
||
def clear_diff_preview(self, render: bool = True) -> None:
|
||
if not hasattr(self, "renderer"):
|
||
self.diff_actors.clear()
|
||
return
|
||
for actor in self.diff_actors:
|
||
self.renderer.RemoveViewProp(actor)
|
||
self.diff_actors.clear()
|
||
if render and hasattr(self, "render_window"):
|
||
self.render_window.Render()
|
||
self.statusBar().showMessage("已清除差异预览")
|
||
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.Render()
|
||
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
|
||
|