Files
pythonocc-step-editor/step_editor/window_core.py
T

2590 lines
114 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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