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