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

2618 lines
115 KiB
Python
Raw Normal View History

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:
2026-07-29 15:43:28 +08:00
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:
2026-07-29 15:43:28 +08:00
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)
2026-07-29 15:43:28 +08:00
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)
2026-07-29 15:43:28 +08:00
elif event_type == QEvent.Type.Leave:
self._cancel_hover_tracking(render=True)
return super().eventFilter(watched, event)
2026-07-29 15:43:28 +08:00
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)
2026-07-29 15:43:28 +08:00
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.
2026-07-29 15:43:28 +08:00
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(60.0)
2026-07-29 15:43:28 +08:00
if hasattr(self.interactor, "SetStillUpdateRate"):
self.interactor.SetStillUpdateRate(1.0)
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()
2026-07-29 15:43:28 +08:00
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
2026-07-29 15:43:28 +08:00
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:
face_polydata = new_model.build_face_polydata(deflection=deflection)
result["model_polydata"] = _smooth_surface_polydata(face_polydata)
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 this 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.ShallowCopy(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.highlight_signature = None
point_normals = None
try:
point_normals = model_polydata.GetPointData().GetNormals()
except Exception:
point_normals = None
self.model_polydata = model_polydata if point_normals is not None else _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)
2026-07-29 15:43:28 +08:00
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():
2026-07-29 15:43:28 +08:00
self._invoke_on_ui_thread(lambda x=int(x), y=int(y): self._handle_left_button_press(x, y))
return
2026-07-29 15:43:28 +08:00
self._handle_left_button_press(int(x), int(y))
2026-07-29 15:43:28 +08:00
def _handle_left_button_press(self, x: int, y: int) -> None:
self.pointer_button_down = True
2026-07-29 15:43:28 +08:00
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)
2026-07-29 15:43:28 +08:00
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:
2026-07-29 15:43:28 +08:00
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
2026-07-29 15:43:28 +08:00
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:
2026-07-29 15:43:28 +08:00
buttons = QApplication.mouseButtons()
if buttons != Qt.MouseButton.NoButton:
self.pointer_button_down = True
2026-07-29 15:43:28 +08:00
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
2026-07-29 15:43:28 +08:00
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}")
2026-07-29 15:43:28 +08:00
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}")
2026-07-29 15:43:28 +08:00
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))
2026-07-29 15:43:28 +08:00
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)
2026-07-29 15:43:28 +08:00
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)
2026-07-29 15:43:28 +08:00
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
2026-07-29 15:43:28 +08:00
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]
selection_fields = self._selection_identity_fields(
face_id,
"特征来源 Face" if feature_mode else "Face",
)
logical_id = int(selection_fields["selection_display_id"])
input_info = dict(info)
input_info.update(selection_fields)
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", logical_id)
self.set_info(self._with_pick_info(input_info, pick_position))
self._update_action_states()
raw_note = f"(当前拓扑 Face {face_id}" if logical_id != face_id else ""
message = f"已选择Face {logical_id}{raw_note}" if not feature_mode else f"已选择特征来源 Face {logical_id}{raw_note}"
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"
selection_fields = self._selection_identity_fields(face_id, "特征来源 Face")
logical_id = int(selection_fields["selection_display_id"])
info.update(selection_fields)
2026-07-29 15:43:28 +08:00
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", logical_id)
self.set_info(self._with_pick_info(info, pick_position))
feature_type = str(info.get("feature_type", "特征候选"))
self._update_action_states()
raw_note = f"(当前拓扑 Face {face_id}" if logical_id != face_id else ""
self.statusBar().showMessage(self._selection_status(f"已选择 {feature_type},来源 Face {logical_id}{raw_note}", 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()
2026-07-29 15:43:28 +08:00
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)
2026-07-29 15:43:28 +08:00
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()
2026-07-29 15:43:28 +08:00
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()
2026-07-29 15:43:28 +08:00
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()
2026-07-29 15:43:28 +08:00
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()
2026-07-29 15:43:28 +08:00
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()
2026-07-29 15:43:28 +08:00
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()
2026-07-29 15:43:28 +08:00
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()
2026-07-29 15:43:28 +08:00
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()
2026-07-29 15:43:28 +08:00
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
2026-07-29 15:43:28 +08:00
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)
info.update(self._edge_first_level_selection_fields(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
face_key = _int_tuple_or_none(face_ids)
part_key = _int_tuple_or_none(part_ids)
signature = ("faces", face_key, part_key)
if signature == getattr(self, "highlight_signature", None) and self.highlight_actor is not None:
return
self._clear_highlight()
polydata = self._cached_face_overlay_polydata(face_ids=face_key, part_ids=part_key, 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.highlight_signature = signature
self.renderer.AddActor(actor)
self.render_window.Render()
def _highlight_edge(self, edge_id: int) -> None:
if self.model is None:
return
signature = ("edge", int(edge_id))
if signature == getattr(self, "highlight_signature", None) and self.edge_highlight_actor is not 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.highlight_signature = signature
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
self.highlight_signature = 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,
2026-07-29 15:43:28 +08:00
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)
2026-07-29 15:43:28 +08:00
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)
2026-07-29 15:43:28 +08:00
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:
2026-07-29 15:43:28 +08:00
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)
2026-07-29 15:43:28 +08:00
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()
2026-07-29 15:43:28 +08:00
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