feat: 完善 STEP 编辑器最小系统和稳定性

This commit is contained in:
2026-07-28 14:05:14 +08:00
parent 26216064e0
commit 7c263d1e96
19 changed files with 3173 additions and 931 deletions
+157 -112
View File
@@ -54,7 +54,7 @@ class WindowCoreMixin:
self.interactor.AddObserver("MouseMoveEvent", self.on_mouse_move)
light = vtk.vtkLight()
light.SetLightTypeToSceneLight()
light.SetLightTypeToHeadlight()
light.SetPosition(1, 1, 1)
light.SetIntensity(0.9)
self.renderer.AddLight(light)
@@ -62,20 +62,30 @@ class WindowCoreMixin:
self.interactor.Initialize()
def closeEvent(self, event) -> None:
if self.load_in_progress:
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
if self.operation_in_progress:
edit_thread_running = bool(self.edit_thread is not None and self.edit_thread.isRunning())
if self.operation_in_progress or edit_thread_running:
self.statusBar().showMessage("后台编辑正在计算,请等待当前操作完成后再关闭窗口。")
event.ignore()
return
if self.scan_in_progress:
self.statusBar().showMessage("后台扫描正在进行,请等待扫描完成后再关闭窗口。")
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
@@ -110,53 +120,62 @@ class WindowCoreMixin:
return
new_path = Path(path)
if not background:
self.statusBar().showMessage(f"Loading {new_path.name}...")
QApplication.setOverrideCursor(Qt.CursorShape.WaitCursor)
QApplication.processEvents()
try:
result = self._load_step_result(
new_path,
deflection=0.8,
show_internal_edges=self._show_same_domain_internal_edges(),
)
except Exception as exc:
QMessageBox.critical(self, "Load failed", str(exc))
self.statusBar().showMessage("STEP load failed; current model was left unchanged.")
return
finally:
QApplication.restoreOverrideCursor()
self._apply_loaded_model_result(result, reset_camera=True)
self.statusBar().showMessage(f"Loaded {self.step_path.name}")
self._load_step_sync(
new_path,
deflection=0.8,
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"Loading {new_path.name} in background...")
self.statusBar().showMessage(f"Preparing fast load for {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))
def action():
return self._load_step_result(
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.initial_load_deflection,
show_internal_edges=True,
build_polydata=False,
status_prefix="Fast loading",
)
thread = QThread(self)
worker = LoadWorker(action)
worker.moveToThread(thread)
thread.started.connect(worker.run)
worker.finished.connect(self._finish_initial_load)
worker.failed.connect(self._fail_initial_load)
worker.finished.connect(thread.quit)
worker.failed.connect(thread.quit)
thread.finished.connect(worker.deleteLater)
thread.finished.connect(thread.deleteLater)
thread.finished.connect(self._forget_load_thread)
self.load_thread = thread
self.load_worker = worker
thread.start()
finally:
self._end_load_task()
def _apply_loaded_model_result(self, result: dict[str, object], *, reset_camera: bool) -> None:
new_path = Path(result["path"])
@@ -194,11 +213,12 @@ class WindowCoreMixin:
"faces": stats.faces,
"edges": stats.edges,
"vertices": stats.vertices,
"display": "quick preview" if self.load_in_progress else "ready",
"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):
@@ -210,49 +230,21 @@ class WindowCoreMixin:
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:
model = initial_result.get("model")
if model is None or model is not self.model:
self._end_load_task()
return
path = Path(initial_result["path"])
stats = initial_result["stats"]
show_internal_edges = self._show_same_domain_internal_edges()
def action():
return {
"path": path,
"model": model,
"stats": stats,
"model_polydata": model.build_face_polydata(deflection=0.8),
"edge_polydata": model.build_edge_polydata(
deflection=0.8,
show_same_domain_internal_edges=show_internal_edges,
),
"show_internal_edges": show_internal_edges,
}
thread = QThread(self)
worker = LoadWorker(action)
worker.moveToThread(thread)
thread.started.connect(worker.run)
worker.finished.connect(self._finish_load_refine)
worker.failed.connect(self._fail_load_refine)
worker.finished.connect(thread.quit)
worker.failed.connect(thread.quit)
thread.finished.connect(worker.deleteLater)
thread.finished.connect(thread.deleteLater)
thread.finished.connect(self._forget_load_refine_thread)
self.load_refine_thread = thread
self.load_refine_worker = worker
thread.start()
self._end_load_task()
if self.model is not 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 result.get("model") is self.model:
@@ -277,6 +269,7 @@ class WindowCoreMixin:
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}")
@@ -285,6 +278,8 @@ class WindowCoreMixin:
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
@@ -319,6 +314,8 @@ class WindowCoreMixin:
self.clear_diff_preview(render=False)
if hasattr(self, "history_list"):
self.history_list.clear()
if hasattr(self, "export_all_button"):
self._update_action_states()
def _refresh_history_list(self) -> None:
was_blocked = self.history_list.blockSignals(True)
@@ -328,6 +325,7 @@ class WindowCoreMixin:
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("编辑计算中,暂时不能查看历史记录详情。"):
@@ -407,6 +405,10 @@ class WindowCoreMixin:
"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,
@@ -417,7 +419,7 @@ class WindowCoreMixin:
}
)
payload = {
"format": "step-editor-operation-history-v1",
"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),
@@ -558,27 +560,27 @@ class WindowCoreMixin:
if self.selected_kind == "part" and self.selected_part_id is not None:
part_ids = [self.selected_part_id]
label = f"part {self.selected_part_id}"
label = f"Part {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}"
label = f"Solid {self.selected_solid_id}"
elif self.selected_kind == "feature" and self.selected_face_id is not None:
info = self.model.feature_info(self.selected_face_id)
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"feature face {self.selected_face_id}"
label = f"Feature Face {self.selected_face_id}"
elif self.selected_kind == "face" and self.selected_face_id is not None:
face_ids = self.model.connected_same_domain_face_ids(self.selected_face_id) or [self.selected_face_id]
edge_ids = self.model.face_region_boundary_edge_ids(self.selected_face_id)
label = f"face {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}"
label = f"Edge {self.selected_edge_id}"
else:
QMessageBox.information(self, "未选择对象", "请先选择 part、solid、face、edge 或 feature。")
QMessageBox.information(self, "未选择对象", "请先选择 Part、Solid、Face、Edge 或 Feature。")
return
model_polydata = self.model.build_face_polydata(face_ids=face_ids, part_ids=part_ids)
@@ -608,7 +610,7 @@ class WindowCoreMixin:
return
bounds = self._selected_focus_bounds()
if bounds is None:
QMessageBox.information(self, "未选择对象", "请先选择 part、solid、face、edge 或 feature。")
QMessageBox.information(self, "未选择对象", "请先选择 Part、Solid、Face、Edge 或 Feature。")
return
self._fit_camera_to_bounds(bounds)
self.render_window.Render()
@@ -772,6 +774,11 @@ class WindowCoreMixin:
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
@@ -816,7 +823,7 @@ class WindowCoreMixin:
self.statusBar().showMessage("编辑计算中,请等待当前操作完成")
return
if self.scan_in_progress:
self.statusBar().showMessage("后台扫描中,请等待扫描完成后再选择对象。")
self.statusBar().showMessage("扫描中,请等待扫描完成后再选择对象。")
return
if self.model is None:
return
@@ -876,7 +883,16 @@ class WindowCoreMixin:
self._queue_hover_position(x, y)
def _queue_hover_position(self, x: int, y: int) -> None:
self.pending_hover_position = (int(x), int(y))
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 not self.hover_timer.isActive():
self.hover_timer.start(self.hover_interval_ms)
@@ -1102,7 +1118,7 @@ class WindowCoreMixin:
self.select_part(int(target_id))
def on_cylinder_row_clicked(self, row: int, _column: int) -> None:
if self._edit_busy("编辑计算中,暂时不能切换圆柱候选。"):
if self._edit_busy("编辑计算中,暂时不能切换圆柱候选。"):
return
item = self.cylinder_table.item(row, 0)
if item is None:
@@ -1127,47 +1143,51 @@ class WindowCoreMixin:
if action == "resize_cylinder":
self.mode_combo.setCurrentText("Feature")
self.select_feature(int(target_id))
self.statusBar().showMessage(f"已选择可调整孔径候选 face {target_id}")
self.statusBar().showMessage(f"已选择可调整孔径候选Face {target_id}")
elif action == "resize_boss":
self.mode_combo.setCurrentText("Feature")
self.select_feature(int(target_id))
self.statusBar().showMessage(f"已选择可调整凸台直径候选 face {target_id}")
self.statusBar().showMessage(f"已选择可调整凸台直径候选Face {target_id}")
elif action == "resize_slot_width":
self.mode_combo.setCurrentText("Feature")
self.select_feature(int(target_id))
self.statusBar().showMessage(f"已选择可调整槽/半孔宽度候选 face {target_id}")
self.statusBar().showMessage(f"已选择可调整槽/半孔宽度候选Face {target_id}")
elif action == "resize_shell_thickness":
self.mode_combo.setCurrentText("Feature")
self.select_feature(int(target_id))
self.statusBar().showMessage(f"已选择可调整薄壁厚度候选Face {target_id}")
elif action == "suppress_cylinder":
self.mode_combo.setCurrentText("Feature")
self.select_feature(int(target_id))
self.statusBar().showMessage(f"已选择可封堵圆柱孔 face {target_id}")
self.statusBar().showMessage(f"已选择可封堵圆柱孔Face {target_id}")
elif action == "resize_depth":
self.mode_combo.setCurrentText("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.mode_combo.setCurrentText("Feature")
self.select_feature(int(target_id))
self.statusBar().showMessage(f"已选择已有圆角/倒圆候选 face {target_id}")
self.statusBar().showMessage(f"已选择已有圆角/倒圆候选Face {target_id}")
elif action == "fillet_edge":
self.mode_combo.setCurrentText("Edge")
self.select_edge(int(target_id))
self.statusBar().showMessage(f"已选择可添加圆角 edge {target_id}")
self.statusBar().showMessage(f"已选择可添加圆角Edge {target_id}")
elif action == "chamfer_edge":
self.mode_combo.setCurrentText("Edge")
self.select_edge(int(target_id))
self.statusBar().showMessage(f"已选择可添加倒角 edge {target_id}")
self.statusBar().showMessage(f"已选择可添加倒角Edge {target_id}")
elif action == "resize_edge_length":
self.mode_combo.setCurrentText("Edge")
self.select_edge(int(target_id))
self.statusBar().showMessage(f"已选择可尝试调整长度的直线 edge {target_id}")
self.statusBar().showMessage(f"已选择可尝试调整长度的Edge {target_id}")
elif target_kind == "edge":
self.mode_combo.setCurrentText("Edge")
self.select_edge(int(target_id))
self.statusBar().showMessage(f"已选择 edge {target_id}")
self.statusBar().showMessage(f"已选择Edge {target_id}")
else:
self.mode_combo.setCurrentText("Face")
self.select_face(int(target_id))
self.statusBar().showMessage(f"已选择可推拉平面 face {target_id}")
self.statusBar().showMessage(f"已选择可推拉平面Face {target_id}")
def select_by_id(self, kind: str | None = None) -> None:
if self.model is None:
@@ -1240,7 +1260,7 @@ class WindowCoreMixin:
) -> None:
if self.model is None:
return
if self._edit_busy("编辑计算中,暂时不能选择 solid。"):
if self._edit_busy("编辑计算中,暂时不能选择Solid。"):
return
info = self.model.solid_info(solid_id)
self._reset_selection(clear_highlight=False)
@@ -1254,7 +1274,7 @@ class WindowCoreMixin:
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))
self.statusBar().showMessage(self._selection_status(f"已选择Solid {solid_id}", pick_position))
def select_face(
self,
@@ -1264,7 +1284,7 @@ class WindowCoreMixin:
) -> None:
if self.model is None:
return
if self._edit_busy("编辑计算中,暂时不能选择 face。"):
if self._edit_busy("编辑计算中,暂时不能选择Face。"):
return
self._reset_selection(clear_highlight=False)
self.selected_kind = "feature" if feature_mode else "face"
@@ -1279,9 +1299,10 @@ class WindowCoreMixin:
if len(highlight_face_ids) > 1:
info["same_domain_face_ids"] = tuple(highlight_face_ids)
info["same_domain_face_count"] = len(highlight_face_ids)
info["same_domain_note"] = "已高亮属于同一几何面且范围相接/重叠的连续 face 区域。"
info["same_domain_note"] = "已高亮属于同一几何面且范围相接/重叠的连续Face区域。"
logical_id = self.model.face_region_logical_id(face_id)
self._sync_cylindrical_edit_inputs(info)
input_info = self.model.feature_info(face_id) if info.get("surface") == "plane" else info
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])
@@ -1289,9 +1310,9 @@ class WindowCoreMixin:
self._sync_id_picker("Feature" if feature_mode else "Face", face_id if feature_mode else logical_id)
self.set_info(self._with_pick_info(info, pick_position))
self._update_action_states()
suffix = f",同域区域 {len(highlight_face_ids)} face" if not feature_mode and len(highlight_face_ids) > 1 else ""
raw_note = f"(拓扑 face {face_id}" if not feature_mode and logical_id != face_id else ""
message = f"已选择逻辑面区域 {logical_id}{raw_note}{suffix}" if not feature_mode else f"已选择 face {face_id}"
suffix = f",同域区域 {len(highlight_face_ids)}Face" if not feature_mode and len(highlight_face_ids) > 1 else ""
raw_note = f"(拓扑Face {face_id}" if not feature_mode and logical_id != face_id else ""
message = f"已选择逻辑面区域 {logical_id}{raw_note}{suffix}" if not feature_mode else f"已选择Face {face_id}"
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:
@@ -1314,10 +1335,16 @@ class WindowCoreMixin:
self.set_info(self._with_pick_info(info, pick_position))
feature_type = str(info.get("feature_type", "局部特征候选"))
self._update_action_states()
self.statusBar().showMessage(self._selection_status(f"已选择 {feature_type},来源 face {face_id}", pick_position))
self.statusBar().showMessage(self._selection_status(f"已选择 {feature_type},来源Face {face_id}", pick_position))
def _sync_cylindrical_edit_inputs(self, info: dict[str, object]) -> None:
fillet_radius_suggestion: str | None = None
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":
@@ -1373,6 +1400,8 @@ class WindowCoreMixin:
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()
self.boss_diameter_input.clear()
@@ -1395,7 +1424,7 @@ class WindowCoreMixin:
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。"):
if self._edit_busy("编辑计算中,暂时不能选择Edge。"):
return
self._reset_selection(clear_highlight=False)
self.selected_kind = "edge"
@@ -1410,7 +1439,7 @@ class WindowCoreMixin:
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))
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:
@@ -1635,6 +1664,19 @@ class WindowCoreMixin:
self._start_edit_preview_pulse()
self.render_window.Render()
def _show_shell_thickness_preview(self, face_id: int, target_thickness: float) -> None:
if self.model is None:
return
self.clear_edit_preview(render=False)
try:
polydata = self.model.shell_thickness_preview_polydata(face_id, target_thickness)
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_resize_preview(self, face_id: int, diameter: float) -> None:
if self.model is None:
return
@@ -1740,7 +1782,7 @@ class WindowCoreMixin:
try:
polydata = self.model.straight_edge_length_preview_polydata(edge_id, target_length, anchor_mode=anchor_mode)
except Exception as exc:
self.statusBar().showMessage(f"边长直接修改预览不可用:{exc}")
self.statusBar().showMessage(f"Edge长度修改预览不可用:{exc}")
return
if isinstance(polydata, list):
for preview in polydata:
@@ -1771,7 +1813,7 @@ class WindowCoreMixin:
ids.InsertNextId(cell_id)
break
if ids.GetNumberOfIds() == 0:
self.statusBar().showMessage(f"{label}不可用:没有找到选中的 edge。")
self.statusBar().showMessage(f"{label}不可用:没有找到选中的Edge。")
return
extract = vtk.vtkExtractCells()
extract.SetInputData(polydata)
@@ -1817,6 +1859,8 @@ class WindowCoreMixin:
if render and hasattr(self, "render_window"):
self.render_window.Render()
self.statusBar().showMessage("已清除差异预览")
if hasattr(self, "export_all_button"):
self._update_action_states()
def _show_operation_diff(self, record: OperationRecord) -> str:
if self.model is None:
@@ -1838,6 +1882,7 @@ class WindowCoreMixin:
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 (