Files
pythonocc-step-editor/step_editor/window_core.py
T
nikelaluo 26216064e0 feat: 拆分 STEP 编辑器并完善最小系统
将原来的 main.py/step_model.py 拆分为 step_editor 包,补充测量、同域高亮、模型修复、历史导出、槽宽/凸台/边长等 MVP 编辑能力,并更新 README 和忽略规则。
2026-07-27 18:28:26 +08:00

2008 lines
88 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
class WindowCoreMixin:
def eventFilter(self, watched, event):
if watched is getattr(self, "vtk_widget", None):
event_type = event.type()
if event_type == QEvent.Type.MouseButtonRelease:
self.pointer_button_down = bool(event.buttons() != Qt.MouseButton.NoButton)
elif event_type == QEvent.Type.MouseButtonPress:
self.pointer_button_down = True
elif event_type == QEvent.Type.MouseMove:
self.pointer_button_down = bool(event.buttons() != Qt.MouseButton.NoButton)
if not self.pointer_button_down:
self._queue_hover_from_qt_event(event)
return super().eventFilter(watched, event)
def _build_vtk(self) -> None:
self.renderer = vtk.vtkRenderer()
self.renderer.SetBackground(0.11, 0.13, 0.15)
self.render_window = self.vtk_widget.GetRenderWindow()
self.render_window.AddRenderer(self.renderer)
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_click)
self.interactor.AddObserver("LeftButtonReleaseEvent", self.on_pointer_button_release)
self.interactor.AddObserver("MiddleButtonPressEvent", self.on_pointer_button_press)
self.interactor.AddObserver("MiddleButtonReleaseEvent", self.on_pointer_button_release)
self.interactor.AddObserver("RightButtonPressEvent", self.on_pointer_button_press)
self.interactor.AddObserver("RightButtonReleaseEvent", self.on_pointer_button_release)
self.interactor.AddObserver("MouseMoveEvent", self.on_mouse_move)
light = vtk.vtkLight()
light.SetLightTypeToSceneLight()
light.SetPosition(1, 1, 1)
light.SetIntensity(0.9)
self.renderer.AddLight(light)
self.interactor.Initialize()
def closeEvent(self, event) -> None:
if self.load_in_progress:
self.statusBar().showMessage("STEP background loading is still running.")
event.ignore()
return
if self.operation_in_progress:
self.statusBar().showMessage("后台编辑正在计算,请等待当前操作完成后再关闭窗口。")
event.ignore()
return
if self.scan_in_progress:
self.statusBar().showMessage("后台扫描正在进行,请等待扫描完成后再关闭窗口。")
event.ignore()
return
super().closeEvent(event)
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.statusBar().showMessage(f"Loading {new_path.name}...")
QApplication.setOverrideCursor(Qt.CursorShape.WaitCursor)
QApplication.processEvents()
try:
result = self._load_step_result(
new_path,
deflection=0.8,
show_internal_edges=self._show_same_domain_internal_edges(),
)
except Exception as exc:
QMessageBox.critical(self, "Load failed", str(exc))
self.statusBar().showMessage("STEP load failed; current model was left unchanged.")
return
finally:
QApplication.restoreOverrideCursor()
self._apply_loaded_model_result(result, reset_camera=True)
self.statusBar().showMessage(f"Loaded {self.step_path.name}")
return
self.load_in_progress = True
self.pending_load_path = new_path
self.statusBar().showMessage(f"Loading {new_path.name} in background...")
self._clear_hover(render=True)
self._update_action_states()
def action():
return self._load_step_result(
new_path,
deflection=self.initial_load_deflection,
show_internal_edges=True,
build_polydata=False,
)
thread = QThread(self)
worker = LoadWorker(action)
worker.moveToThread(thread)
thread.started.connect(worker.run)
worker.finished.connect(self._finish_initial_load)
worker.failed.connect(self._fail_initial_load)
worker.finished.connect(thread.quit)
worker.failed.connect(thread.quit)
thread.finished.connect(worker.deleteLater)
thread.finished.connect(thread.deleteLater)
thread.finished.connect(self._forget_load_thread)
self.load_thread = thread
self.load_worker = worker
thread.start()
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()
self.path_label.setText(str(self.step_path))
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": "quick preview" if self.load_in_progress else "ready",
}
)
self._update_action_states()
def _finish_initial_load(self, result: object) -> None:
try:
if not isinstance(result, dict):
raise RuntimeError("Load task returned an unexpected result.")
self._apply_loaded_model_result(result, reset_camera=True)
self.statusBar().showMessage(f"Loaded {self.step_path.name}")
self._end_load_task()
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.")
def _fail_initial_load(self, message: str) -> None:
self._end_load_task()
QMessageBox.critical(self, "Load failed", message)
self.statusBar().showMessage("STEP load failed; current model was left unchanged.")
def _start_load_refine(self, initial_result: dict[str, object]) -> None:
model = initial_result.get("model")
if model is None or model is not self.model:
self._end_load_task()
return
path = Path(initial_result["path"])
stats = initial_result["stats"]
show_internal_edges = self._show_same_domain_internal_edges()
def action():
return {
"path": path,
"model": model,
"stats": stats,
"model_polydata": model.build_face_polydata(deflection=0.8),
"edge_polydata": model.build_edge_polydata(
deflection=0.8,
show_same_domain_internal_edges=show_internal_edges,
),
"show_internal_edges": show_internal_edges,
}
thread = QThread(self)
worker = LoadWorker(action)
worker.moveToThread(thread)
thread.started.connect(worker.run)
worker.finished.connect(self._finish_load_refine)
worker.failed.connect(self._fail_load_refine)
worker.finished.connect(thread.quit)
worker.failed.connect(thread.quit)
thread.finished.connect(worker.deleteLater)
thread.finished.connect(thread.deleteLater)
thread.finished.connect(self._forget_load_refine_thread)
self.load_refine_thread = thread
self.load_refine_worker = worker
thread.start()
def _finish_load_refine(self, result: object) -> None:
try:
if isinstance(result, dict) and result.get("model") is self.model:
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"Loaded {self.step_path.name}")
finally:
self._end_load_task()
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()
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.load_step(path)
def reload_step(self) -> None:
if self._edit_busy("请等待当前编辑完成后再重新加载。"):
return
self.load_step(self.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()
def _refresh_history_list(self) -> None:
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)
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
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,
"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-v1",
"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:
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"Part ID: {part_id}"
try:
info = self.model.part_info(part_id)
except Exception:
return f"Part 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"part {self.selected_part_id}"
elif self.selected_kind == "solid" and self.selected_solid_id is not None:
face_ids = [index for index, solid_id in enumerate(self.model.face_solid_ids) if solid_id == self.selected_solid_id]
edge_ids = self.model.edge_ids_for_solid(self.selected_solid_id)
label = f"solid {self.selected_solid_id}"
elif self.selected_kind == "feature" and self.selected_face_id is not None:
info = self.model.feature_info(self.selected_face_id)
face_ids = _int_values(info.get("feature_highlight_face_ids")) or [self.selected_face_id]
edge_ids = _int_values(info.get("feature_boundary_edge_ids"))
label = f"feature face {self.selected_face_id}"
elif self.selected_kind == "face" and self.selected_face_id is not None:
face_ids = self.model.connected_same_domain_face_ids(self.selected_face_id) or [self.selected_face_id]
edge_ids = self.model.face_region_boundary_edge_ids(self.selected_face_id)
label = f"face {self.selected_face_id}"
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, "未选择对象", "请先选择 part、solid、face、edge 或 feature。")
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, "未选择对象", "请先选择 part、solid、face、edge 或 feature。")
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 = self.model.feature_info(self.selected_face_id)
face_ids = _int_values(info.get("feature_highlight_face_ids")) or [self.selected_face_id]
edge_ids = _int_values(info.get("feature_boundary_edge_ids"))
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 == "face" and self.selected_face_id is not None:
face_ids = self.model.connected_same_domain_face_ids(self.selected_face_id) or [self.selected_face_id]
face_polydata = self.model.build_face_polydata(face_ids=face_ids)
elif self.selected_kind == "edge" and self.selected_edge_id is not None:
edge_polydata = self.model.build_edge_polydata(edge_ids=[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 = self.model.feature_info(self.selected_face_id)
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:
face_ids = self.model.connected_same_domain_face_ids(self.selected_face_id) or [self.selected_face_id]
self._highlight_faces(face_ids=face_ids)
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 _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
ids = vtk.vtkIdList()
for cell_id in range(self.model_polydata.GetNumberOfCells()):
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
ids = vtk.vtkIdList()
for cell_id in range(self.edge_polydata.GetNumberOfCells()):
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(0.11, 0.13, 0.15)
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")
mapper = vtk.vtkPolyDataMapper()
mapper.SetInputData(self.model_polydata)
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()
self.renderer.AddActor(self.model_actor)
self.edge_polydata = edge_polydata
self.edge_id_array = self.edge_polydata.GetCellData().GetArray("edge_id")
edge_mapper = vtk.vtkPolyDataMapper()
edge_mapper.SetInputData(self.edge_polydata)
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, "id_mode_display"):
self.id_mode_display.setText(mode)
def on_left_click(self, _obj, _event) -> None:
self.pointer_button_down = True
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.mode_combo.currentText()
x, y = self.interactor.GetEventPosition()
target = self._pick_selection_target(mode, x, y)
if target is None:
self.statusBar().showMessage("未选中对象")
return
self._clear_hover(render=False)
self._select_pick_target(target)
def on_pointer_button_press(self, _obj, _event) -> 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=True)
def on_pointer_button_release(self, _obj, _event) -> None:
self.pointer_button_down = False
self.pending_hover_position = None
self.last_hover_pick_position = None
def on_mouse_move(self, _obj, _event) -> None:
if QApplication.mouseButtons() != Qt.MouseButton.NoButton:
self.pointer_button_down = True
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
):
self._clear_hover(render=True)
return
x, y = self.interactor.GetEventPosition()
self._queue_hover_position(int(x), int(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
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))
self._queue_hover_position(x, y)
def _queue_hover_position(self, x: int, y: int) -> None:
self.pending_hover_position = (int(x), int(y))
if not self.hover_timer.isActive():
self.hover_timer.start(self.hover_interval_ms)
def _update_hover_target(self) -> 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
or self.pending_hover_position is None
):
self._clear_hover(render=True)
return
x, y = self.pending_hover_position
self.last_hover_pick_position = (x, y)
target = self._pick_selection_target(self.mode_combo.currentText(), x, y)
self._show_hover_target(target)
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.mode_combo.setCurrentText("Solid")
self.select_solid(int(target_id), int(part_id))
return
if node_kind in {"part", "assembly"} and target_id is not None:
self.mode_combo.setCurrentText("Part")
self.select_part(int(target_id))
def on_cylinder_row_clicked(self, row: int, _column: int) -> None:
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.mode_combo.setCurrentText("Feature")
self.select_feature(int(face_id))
def on_editable_row_clicked(self, row: int, _column: int) -> None:
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.mode_combo.setCurrentText("Feature")
self.select_feature(int(target_id))
self.statusBar().showMessage(f"已选择可调整孔径候选 face {target_id}")
elif action == "resize_boss":
self.mode_combo.setCurrentText("Feature")
self.select_feature(int(target_id))
self.statusBar().showMessage(f"已选择可调整凸台直径候选 face {target_id}")
elif action == "resize_slot_width":
self.mode_combo.setCurrentText("Feature")
self.select_feature(int(target_id))
self.statusBar().showMessage(f"已选择可调整槽/半孔宽度候选 face {target_id}")
elif action == "suppress_cylinder":
self.mode_combo.setCurrentText("Feature")
self.select_feature(int(target_id))
self.statusBar().showMessage(f"已选择可封堵圆柱孔 face {target_id}")
elif action == "resize_depth":
self.mode_combo.setCurrentText("Feature")
self.select_feature(int(target_id))
self.statusBar().showMessage(f"已选择可调整盲孔深度候选 face {target_id}")
elif action == "inspect_existing_fillet":
self.mode_combo.setCurrentText("Feature")
self.select_feature(int(target_id))
self.statusBar().showMessage(f"已选择已有圆角/倒圆候选 face {target_id}")
elif action == "fillet_edge":
self.mode_combo.setCurrentText("Edge")
self.select_edge(int(target_id))
self.statusBar().showMessage(f"已选择可添加圆角 edge {target_id}")
elif action == "chamfer_edge":
self.mode_combo.setCurrentText("Edge")
self.select_edge(int(target_id))
self.statusBar().showMessage(f"已选择可添加倒角 edge {target_id}")
elif action == "resize_edge_length":
self.mode_combo.setCurrentText("Edge")
self.select_edge(int(target_id))
self.statusBar().showMessage(f"已选择可尝试调整长度的直线 edge {target_id}")
elif target_kind == "edge":
self.mode_combo.setCurrentText("Edge")
self.select_edge(int(target_id))
self.statusBar().showMessage(f"已选择 edge {target_id}")
else:
self.mode_combo.setCurrentText("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。Part ID 从 1 开始,Solid/Face/Edge ID 从 0 开始。")
return
kind = kind or self.mode_combo.currentText()
try:
if kind == "Part":
if self.model.part_by_id(target_id) is None:
raise ValueError(f"不存在 Part ID {target_id}")
self.mode_combo.setCurrentText("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.mode_combo.setCurrentText("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.mode_combo.setCurrentText("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"不存在 Feature 来源 Face/逻辑 Face ID {target_id}")
self.mode_combo.setCurrentText("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.mode_combo.setCurrentText("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)
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)
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)
self.selected_kind = "feature" if feature_mode else "face"
self.selected_face_id = face_id
self.selected_pick_position = pick_position
info = self.model.face_info(face_id)
if feature_mode:
info["feature_mode"] = "当前是几何候选判断,不等同于 CAD 历史特征"
highlight_face_ids = [face_id]
else:
highlight_face_ids = self.model.connected_same_domain_face_ids(face_id)
if len(highlight_face_ids) > 1:
info["same_domain_face_ids"] = tuple(highlight_face_ids)
info["same_domain_face_count"] = len(highlight_face_ids)
info["same_domain_note"] = "已高亮属于同一几何面且范围相接/重叠的连续 face 区域。"
logical_id = self.model.face_region_logical_id(face_id)
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
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(info, pick_position))
self._update_action_states()
suffix = f",同域区域 {len(highlight_face_ids)} 个 face" if not feature_mode and len(highlight_face_ids) > 1 else ""
raw_note = f"(拓扑 face {face_id}" if not feature_mode and logical_id != face_id else ""
message = f"已选择逻辑面区域 {logical_id}{raw_note}{suffix}" if not feature_mode else f"已选择 face {face_id}"
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.model.feature_info(face_id)
self._reset_selection(clear_highlight=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
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()
self.boss_diameter_input.setText(suggested_diameter)
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()
self.boss_diameter_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, "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()
else:
self.hole_diameter_input.clear()
if hasattr(self, "slot_width_input"):
self.slot_width_input.clear()
self.boss_diameter_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, "slot_width_input"):
self.slot_width_input.clear()
self.boss_diameter_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)
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"])
if kind in {"face", "feature"} and 0 <= target_id < len(self.model.faces):
try:
return (kind, self.model.face_region_logical_id(target_id))
except Exception:
return (kind, 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":
info = self.model.feature_info(target_id)
face_ids = _int_values(info.get("feature_highlight_face_ids")) or [target_id]
self._highlight_hover_faces(face_ids=face_ids)
elif kind == "edge":
self._highlight_hover_edge(target_id)
else:
face_ids = self.model.connected_same_domain_face_ids(target_id)
self._highlight_hover_faces(face_ids=face_ids or [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,
) -> 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)
self.edit_preview_actors.append(actor)
self.edit_preview_actor = self.edit_preview_actor or actor
self.renderer.AddActor(actor)
def _show_push_pull_preview(self, face_id: int, distance: float) -> None:
if self.model is None:
return
self.clear_edit_preview(render=False)
try:
polydata = self.model.push_pull_preview_polydata(face_id, distance)
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)
self._add_edit_preview_actor(polydata, color)
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_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") -> 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)
except Exception as exc:
self.statusBar().showMessage(f"边长直接修改预览不可用:{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("已清除差异预览")
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()
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