feat: 完善特征编辑和选择稳定性

This commit is contained in:
2026-07-29 15:43:28 +08:00
parent 720c2dc970
commit c937e13d43
13 changed files with 8294 additions and 290 deletions
+261 -23
View File
@@ -43,15 +43,35 @@ class WindowCoreMixin:
if watched is getattr(self, "vtk_widget", None):
event_type = event.type()
if event_type == QEvent.Type.MouseButtonRelease:
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:
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)
return super().eventFilter(watched, event)
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 _build_vtk(self) -> None:
self.renderer = vtk.vtkRenderer()
self.renderer.SetBackground(*VIEW_BACKGROUND_COLOR)
@@ -62,13 +82,19 @@ class WindowCoreMixin:
self.interactor.SetInteractorStyle(vtk.vtkInteractorStyleTrackballCamera())
self.picker = vtk.vtkCellPicker()
self.picker.SetTolerance(0.003)
self.interactor.AddObserver("LeftButtonPressEvent", self.on_left_click)
self.interactor.AddObserver("LeftButtonReleaseEvent", self.on_pointer_button_release)
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)
self.interactor.AddObserver("MouseMoveEvent", self.on_mouse_move)
self.interactor.AddObserver("StartInteractionEvent", self.on_camera_interaction_start)
self.interactor.AddObserver("EndInteractionEvent", self.on_camera_interaction_end)
if hasattr(self.interactor, "SetDesiredUpdateRate"):
self.interactor.SetDesiredUpdateRate(24.0)
if hasattr(self.interactor, "SetStillUpdateRate"):
self.interactor.SetStillUpdateRate(0.2)
light = vtk.vtkLight()
light.SetLightTypeToHeadlight()
@@ -78,6 +104,55 @@ class WindowCoreMixin:
self.interactor.Initialize()
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)
if self.edge_actor is not None:
try:
self.edge_actor_visible_before_interaction = bool(self.edge_actor.GetVisibility())
self.edge_actor.SetVisibility(False)
except Exception:
self.edge_actor_visible_before_interaction = True
if self.model_actor is not None:
try:
prop = self.model_actor.GetProperty()
self.model_interpolation_before_interaction = int(prop.GetInterpolation())
prop.SetInterpolationToGouraud()
except Exception:
self.model_interpolation_before_interaction = None
def _end_camera_interaction(self) -> None:
if not getattr(self, "camera_interaction_active", False):
return
self.camera_interaction_active = False
if self.edge_actor is not None:
try:
self.edge_actor.SetVisibility(bool(getattr(self, "edge_actor_visible_before_interaction", True)))
except Exception:
pass
if self.model_actor is not None and self.model_interpolation_before_interaction is not None:
try:
self.model_actor.GetProperty().SetInterpolation(int(self.model_interpolation_before_interaction))
except Exception:
pass
self.model_interpolation_before_interaction = None
if hasattr(self, "render_window"):
self.render_window.Render()
def closeEvent(self, event) -> None:
load_thread_running = bool(
(self.load_thread is not None and self.load_thread.isRunning())
@@ -839,18 +914,73 @@ class WindowCoreMixin:
if hasattr(self, "_update_id_select_title"):
self._update_id_select_title(mode)
def on_left_click(self, _obj, _event) -> None:
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_click(x, y))
self._invoke_on_ui_thread(lambda x=int(x), y=int(y): self._handle_left_button_press(x, y))
return
self._handle_left_click(int(x), int(y))
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.pending_hover_position = None
self.last_hover_pick_position = None
self.hover_timer.stop()
self._clear_hover(render=True)
def on_left_button_release(self, _obj, _event) -> None:
if getattr(self, "skip_next_vtk_left_release", False):
self.skip_next_vtk_left_release = False
return
try:
x, y = self.interactor.GetEventPosition()
except Exception:
x, y = 0, 0
if not self._is_ui_thread():
self._invoke_on_ui_thread(lambda x=int(x), y=int(y): self._handle_left_button_release(x, y))
return
self._handle_left_button_release(int(x), int(y))
def _handle_left_button_release(self, x: int, y: int) -> None:
self._update_left_button_drag_state(x, y)
was_dragged = bool(getattr(self, "left_button_dragged", False))
self.pointer_button_down = False
self.left_button_press_position = None
self.left_button_dragged = False
self.pending_hover_position = None
self.last_hover_pick_position = None
if getattr(self, "camera_interaction_active", False):
self._end_camera_interaction()
if was_dragged:
return
self._handle_left_click(x, y)
def _update_left_button_drag_state(self, x: int, y: int) -> None:
press_position = getattr(self, "left_button_press_position", None)
if press_position is None:
return
threshold = int(getattr(self, "left_click_drag_threshold_px", 6) or 6)
dx = abs(int(x) - int(press_position[0]))
dy = abs(int(y) - int(press_position[1]))
if max(dx, dy) > threshold:
self.left_button_dragged = True
def _handle_left_click(self, x: int, y: int) -> None:
self.pointer_button_down = True
self.pending_hover_position = None
self.last_hover_pick_position = None
self.hover_timer.stop()
@@ -868,7 +998,11 @@ class WindowCoreMixin:
mode = self._current_selection_mode()
target = self._pick_selection_target(mode, x, y)
if target is None:
self.statusBar().showMessage("未选中对象")
self._cancel_hover_tracking(render=False)
self._reset_selection(clear_highlight=True)
if hasattr(self, "render_window"):
self.render_window.Render()
self.statusBar().showMessage("已取消选择")
return
self._clear_hover(render=False)
@@ -897,6 +1031,7 @@ class WindowCoreMixin:
self.pointer_button_down = False
self.pending_hover_position = None
self.last_hover_pick_position = None
self._end_camera_interaction()
def on_mouse_move(self, _obj, _event) -> None:
try:
@@ -909,8 +1044,15 @@ class WindowCoreMixin:
self._handle_mouse_move(int(x), int(y))
def _handle_mouse_move(self, x: int, y: int) -> None:
if QApplication.mouseButtons() != Qt.MouseButton.NoButton:
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 (
@@ -934,10 +1076,7 @@ class WindowCoreMixin:
):
self._clear_hover(render=True)
return
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))
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:
@@ -1213,10 +1352,26 @@ class WindowCoreMixin:
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))
@@ -1228,7 +1383,7 @@ class WindowCoreMixin:
elif action == "resize_depth":
self._set_selection_mode("Feature")
self.select_feature(int(target_id))
self.statusBar().showMessage(f"已选择可调整盲孔深度候选Face {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))
@@ -1305,7 +1460,7 @@ class WindowCoreMixin:
if self._edit_busy("编辑计算中,暂时不能选择零件。"):
return
info = self.model.part_info(part_id)
self._reset_selection(clear_highlight=False)
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
@@ -1328,7 +1483,7 @@ class WindowCoreMixin:
if self._edit_busy("编辑计算中,暂时不能选择Solid。"):
return
info = self.model.solid_info(solid_id)
self._reset_selection(clear_highlight=False)
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
@@ -1351,7 +1506,7 @@ class WindowCoreMixin:
return
if self._edit_busy("编辑计算中,暂时不能选择Face。"):
return
self._reset_selection(clear_highlight=False)
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
@@ -1386,7 +1541,7 @@ class WindowCoreMixin:
if self._edit_busy("编辑计算中,暂时不能选择特征。"):
return
info = self.model.feature_info(face_id)
self._reset_selection(clear_highlight=False)
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
@@ -1417,7 +1572,21 @@ class WindowCoreMixin:
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:
@@ -1427,22 +1596,54 @@ class WindowCoreMixin:
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:
@@ -1469,7 +1670,15 @@ class WindowCoreMixin:
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()
@@ -1491,7 +1700,7 @@ class WindowCoreMixin:
return
if self._edit_busy("编辑计算中,暂时不能选择Edge。"):
return
self._reset_selection(clear_highlight=False)
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
@@ -1583,8 +1792,7 @@ class WindowCoreMixin:
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":
info = self.model.feature_info(target_id)
face_ids = _int_values(info.get("feature_highlight_face_ids")) or [target_id]
face_ids = self.model.connected_same_domain_face_ids(target_id) or [target_id]
self._highlight_hover_faces(face_ids=face_ids)
elif kind == "edge":
self._highlight_hover_edge(target_id)
@@ -1699,6 +1907,7 @@ class WindowCoreMixin:
polydata,
color: tuple[float, float, float],
opacity: float | None = None,
position: tuple[float, float, float] | None = None,
) -> None:
mapper = vtk.vtkPolyDataMapper()
mapper.SetInputData(polydata)
@@ -1708,16 +1917,24 @@ class WindowCoreMixin:
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) -> None:
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:
polydata = self.model.push_pull_preview_polydata(face_id, distance)
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
@@ -1725,7 +1942,15 @@ class WindowCoreMixin:
color = (0.0, 0.86, 0.34)
else:
color = (1.0, 0.18, 0.06)
self._add_edit_preview_actor(polydata, color)
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()
@@ -1776,6 +2001,19 @@ class WindowCoreMixin:
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