from __future__ import annotations from datetime import datetime import json import math from pathlib import Path import vtk from PySide6.QtCore import QEvent, Qt, QThread, QTimer, Slot from PySide6.QtWidgets import ( QApplication, QFileDialog, QMessageBox, QTableWidgetItem, QTreeWidgetItem, ) 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) 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 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) self.render_window = self.vtk_widget.GetRenderWindow() if hasattr(self.render_window, "SetMultiSamples"): self.render_window.SetMultiSamples(0) self.render_window.AddRenderer(self.renderer) if hasattr(self.renderer, "UseFXAAOff"): self.renderer.UseFXAAOff() 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(24.0) if hasattr(self.interactor, "SetStillUpdateRate"): self.interactor.SetStillUpdateRate(0.2) 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() 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) def _end_camera_interaction(self) -> None: if not getattr(self, "camera_interaction_active", False): return self.camera_interaction_active = False self.last_camera_interaction_ended_at = datetime.now() self.pending_hover_position = None self.last_hover_pick_position = None 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: result["model_polydata"] = new_model.build_face_polydata(deflection=deflection) 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=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"正在读取 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 the old entry point 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.DeepCopy(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.scene_isolated = False self.model_polydata = _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.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.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.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)) 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.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 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.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.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 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] logical_id = int(info.get("logical_face_id", face_id)) input_info = dict(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]) self._show_pick_marker(pick_position) 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(input_info, pick_position)) self._update_action_states() raw_note = f"(拓扑Face {face_id})" if not feature_mode and logical_id != face_id else "" message = f"已选择Face {logical_id}{raw_note}" 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: if self.model is None: return if self._edit_busy("编辑计算中,暂时不能选择特征。"): return info = self._feature_context_info(face_id) info["kind"] = "feature" 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", face_id) 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)) 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) 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 self._clear_highlight() polydata = self._cached_face_overlay_polydata(face_ids=face_ids, part_ids=part_ids, 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.renderer.AddActor(actor) self.render_window.Render() def _highlight_edge(self, edge_id: int) -> None: if self.model is 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.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 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