feat: 完善 Face 一级关系编辑和稳定性

This commit is contained in:
2026-08-04 09:35:39 +08:00
parent bb44e3920d
commit 5799d5d813
29 changed files with 6295 additions and 189 deletions
+130 -29
View File
@@ -8,7 +8,11 @@ import sys
from pathlib import Path
import vtk
import vtkmodules.vtkInteractionStyle # noqa: F401
import vtkmodules.vtkRenderingFreeType # noqa: F401
import vtkmodules.vtkRenderingOpenGL2 # noqa: F401
from PySide6.QtCore import Qt, QThread, QTimer, Signal, Slot
from PySide6.QtGui import QIcon
from PySide6.QtWidgets import (
QAbstractItemView,
QApplication,
@@ -54,7 +58,19 @@ from .window_state import WindowStateMixin
_CRASH_LOG_HANDLE = None
PROJECT_ROOT = Path(__file__).resolve().parent.parent
DEFAULT_MODEL_PATH = PROJECT_ROOT / "assets" / "models" / "geom_extract.step"
APP_USER_MODEL_ID = "GeometryParametric.StepEditor"
ISOLATED_EDIT_WORKER_ARG = "--isolated-edit-worker"
def _resource_path(*relative_parts: str) -> Path:
frozen_root = getattr(sys, "_MEIPASS", None)
if frozen_root:
return Path(frozen_root).joinpath(*relative_parts)
return PROJECT_ROOT.joinpath(*relative_parts)
APP_ICON_PATH = _resource_path("assets", "ico", "logo_new.ico")
DEFAULT_MODEL_PATH = _resource_path("assets", "models", "geom_extract.step")
# Panel build switches. These work like a Python-side "#if 0": code in a
# disabled branch is kept for later, but the widgets are not constructed.
@@ -85,13 +101,41 @@ def _crash_log_requested(argv: list[str]) -> bool:
return os.environ.get("STEP_EDITOR_CRASH_LOG", "").strip().lower() in {"1", "true", "yes", "on"}
def _application_icon() -> QIcon:
icon = QIcon(str(APP_ICON_PATH))
if icon.isNull():
icon = QIcon(str(PROJECT_ROOT / "assets" / "ico" / "logo_new.ico"))
return icon
def _set_windows_taskbar_identity() -> None:
if sys.platform != "win32":
return
try:
import ctypes
ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(APP_USER_MODEL_ID)
except Exception:
pass
def _isolated_edit_worker_request(argv: list[str]) -> Path | None:
if ISOLATED_EDIT_WORKER_ARG not in argv:
return None
index = argv.index(ISOLATED_EDIT_WORKER_ARG)
if index + 1 >= len(argv):
raise ValueError(f"{ISOLATED_EDIT_WORKER_ARG} requires a request JSON path.")
return Path(argv[index + 1])
class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, InfoPanelMixin, QMainWindow):
ui_task_requested = Signal(object)
def __init__(self, step_path: str | Path, *, background_load: bool = True):
def __init__(self, step_path: str | Path, *, background_load: bool = False):
super().__init__()
self.ui_task_requested.connect(self._run_ui_task, Qt.ConnectionType.QueuedConnection)
self.setWindowTitle("几何参数化")
self.setWindowIcon(_application_icon())
self.resize(1280, 820)
self.model: StepModel | None = None
@@ -110,11 +154,13 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
self.hover_face_actor = None
self.hover_edge_actor = None
self.hover_signature: tuple[str, int] | None = None
self.hover_interval_ms = 140
self.hover_move_threshold_px = 8
self.hover_interval_ms = 260
self.hover_move_threshold_px = 10
self.pending_hover_position: tuple[int, int] | None = None
self.last_hover_pick_position: tuple[int, int] | None = None
self.camera_interaction_active = False
self.last_camera_interaction_ended_at: datetime | None = None
self.hover_after_camera_cooldown_ms = 420
self.pointer_button_down = False
self.left_button_press_position: tuple[int, int] | None = None
self.left_button_dragged = False
@@ -180,9 +226,14 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
self.load_refine_thread: QThread | None = None
self.load_refine_worker: LoadWorker | None = None
self.pending_load_path: Path | None = None
self.initial_load_deflection = 2.0
self.edit_result_deflection = 1.6
self.last_id_kind = "Face"
self.auto_load_on_show = bool(background_load)
self.vtk_interactor_started = False
self.first_show_handled = False
self.preview_load_deflection = 1.0
self.initial_load_deflection = 0.035
self.edit_result_deflection = 0.035
self.last_id_kind = "Feature"
self.feature_detection_level = "current-only"
self.property_editor_updating = False
self.property_editor_specs: list[dict[str, object]] = []
self.property_table_expanded = False
@@ -190,7 +241,6 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
self._build_ui()
self._build_vtk()
self.load_step(self.step_path, background=background_load)
def _build_ui(self) -> None:
help_tip = self._set_help_tip
@@ -303,7 +353,8 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
min-width: 8px;
}
QLabel#mouseModeLabel,
QLabel#idSelectLabel {
QLabel#idSelectLabel,
QLabel#featureDetectionLabel {
color: #4736b3;
font-weight: 700;
}
@@ -681,9 +732,9 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
self.mouse_mode_label.setMaximumWidth(62)
help_tip(self.mouse_mode_label, "这里决定鼠标点击 3D 模型时按哪种对象类型选择。")
self.mode_combo = NoWheelComboBox()
for mode in ("Part", "Solid", "Face", "Edge", "Feature"):
for mode in ("Feature", "Face", "Edge", "Solid", "Part"):
self.mode_combo.addItem(_selection_mode_label(mode), mode)
self._set_selection_mode("Face")
self._set_selection_mode("Feature")
self.mode_combo.setMinimumWidth(94)
self.mode_combo.setMaximumWidth(112)
help_tip(
@@ -703,8 +754,35 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
self.mode_id_separator = mode_separator
help_tip(mode_separator, "左侧是鼠标点选模式,右侧是按当前模式输入 ID 选择。")
mode_layout.addWidget(mode_separator)
help_tip(mode_separator, "左侧是鼠标点选模式,右侧是特征探测级别。")
detection_panel = QWidget()
detection_layout = QHBoxLayout(detection_panel)
detection_layout.setContentsMargins(8, 10, 8, 6)
detection_layout.setSpacing(6)
self.feature_detection_label = QLabel("特征探测级别")
self.feature_detection_label.setObjectName("featureDetectionLabel")
self.feature_detection_label.setMinimumWidth(88)
help_tip(self.feature_detection_label, "控制点击特征时探测多深。默认只探测当前特征的一层局部关联。")
self.feature_detection_combo = NoWheelComboBox()
self.feature_detection_combo.addItem("只识别当前特征", "current-only")
self.feature_detection_combo.addItem("探测相邻特征", "associated-only")
self.feature_detection_combo.addItem("探测二级特征", "secondary")
self.feature_detection_combo.setCurrentIndex(0)
self.feature_detection_combo.setMinimumWidth(128)
help_tip(
self.feature_detection_combo,
"默认只读取当前点击的特征,点击最流畅;需要查找周边孔、槽、凸台等关联时,再切换到相邻或二级探测。",
)
self.feature_detection_combo.currentIndexChanged.connect(
lambda _index: self._on_feature_detection_level_changed()
)
detection_layout.addWidget(self.feature_detection_label)
detection_layout.addWidget(self.feature_detection_combo, stretch=1)
mode_layout.addWidget(detection_panel, stretch=1)
id_select_panel = QWidget()
id_select_panel.setVisible(False)
select_layout = QHBoxLayout(id_select_panel)
select_layout.setContentsMargins(8, 10, 8, 6)
select_layout.setSpacing(6)
@@ -723,6 +801,9 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
self.select_id_button.clicked.connect(lambda _checked=False: self.select_by_id(self._current_selection_mode()))
self.id_input.textChanged.connect(lambda _text: self._update_action_states())
self.id_input.returnPressed.connect(lambda: self.select_by_id(self._current_selection_mode()))
self.id_select_label.setVisible(False)
self.id_input.setVisible(False)
self.select_id_button.setVisible(False)
select_layout.addWidget(self.id_select_label)
select_layout.addWidget(self.id_input)
select_layout.addWidget(self.select_id_button)
@@ -828,18 +909,18 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
export_layout.addWidget(self.repair_model_button)
export_layout.addWidget(self.repair_selected_button)
self.object_edit_box = QGroupBox("当前选中对象:未选择")
self.object_edit_box = QGroupBox("特征参数:未选择")
self.object_edit_box.setObjectName("editSection")
help_tip(
self.object_edit_box,
"选择模型对象后,这里列出当前对象的属性和值;能改的行可以输入目标值,不能改的行只读",
"选择特征后,这里列出可可靠修改的语义尺寸;面积、中心和拓扑数据位于下方诊断信息",
)
object_edit_layout = QVBoxLayout(self.object_edit_box)
object_edit_layout.setContentsMargins(0, 8, 0, 4)
object_edit_layout.setSpacing(4)
self.property_table = QTableWidget(0, 5)
self.property_table.setObjectName("propertyTable")
self.property_table.setHorizontalHeaderLabels(["属性", "当前值", "影响范围", "目标值", "操作"])
self.property_table.setHorizontalHeaderLabels(["尺寸参数", "当前值", "修改方式", "目标值", "操作"])
self.property_table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
self.property_table.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
self.property_table.setAlternatingRowColors(True)
@@ -859,20 +940,20 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
property_header.setSectionResizeMode(2, QHeaderView.ResizeMode.Fixed)
property_header.setSectionResizeMode(3, QHeaderView.ResizeMode.Fixed)
property_header.setSectionResizeMode(4, QHeaderView.ResizeMode.Stretch)
self.property_table.setColumnWidth(0, 82)
self.property_table.setColumnWidth(1, 78)
self.property_table.setColumnWidth(2, 104)
self.property_table.setColumnWidth(3, 82)
self.property_table.setColumnWidth(4, 58)
self.property_table.setColumnWidth(0, 148)
self.property_table.setColumnWidth(1, 68)
self.property_table.setColumnWidth(2, 96)
self.property_table.setColumnWidth(3, 72)
self.property_table.setColumnWidth(4, 54)
help_tip(
self.property_table,
"当前选中对象的属性表。可修改的行可以选择影响范围、输入目标值,并点击操作列里的按钮应用这一行",
"特征模式显示当前特征及局部关联特征的可变尺寸;输入目标值后,可按行应用或使用下方按钮应用当前修改",
)
self.property_table.itemChanged.connect(self._on_property_table_item_changed)
object_edit_layout.addWidget(self.property_table)
self.property_expand_button = QPushButton("展开全部属性")
self.property_expand_button = QPushButton("展开全部参数")
self.property_expand_button.setObjectName("propertyExpandBar")
help_tip(self.property_expand_button, "当前选中对象属性较多时默认只显示前 6 行;点击这里展开或收起属性表。")
help_tip(self.property_expand_button, "参数较多时默认只显示前 6 行;点击这里展开或收起完整参数列表。")
self.property_expand_button.clicked.connect(self.toggle_property_table_expanded)
self.property_expand_button.setVisible(False)
self.property_expand_button.setMaximumHeight(22)
@@ -1326,10 +1407,17 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
history_layout.addLayout(history_buttons)
panel_layout.addWidget(history_box)
info_box = QGroupBox("选中对象信息")
panel_layout.addStretch(1)
info_box = QGroupBox("诊断信息(高级)")
info_box.setObjectName("infoSection")
help_tip(info_box, "显示当前选中对象的几何、拓扑、特征判断和可用操作信息。")
info_box.setCheckable(True)
info_box.setChecked(False)
help_tip(info_box, "展开查看面积、中心、Face ID、拓扑关系和识别依据;这些内容不属于主特征尺寸。")
info_layout = QVBoxLayout(info_box)
self.diagnostic_info_content = QWidget()
diagnostic_info_layout = QVBoxLayout(self.diagnostic_info_content)
diagnostic_info_layout.setContentsMargins(0, 0, 0, 0)
info_buttons = QHBoxLayout()
self.copy_id_button = QPushButton("复制 ID")
help_tip(self.copy_id_button, "复制当前选中对象的 ID。Face/特征会优先复制逻辑 Face ID。")
@@ -1343,7 +1431,7 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
info_buttons.addWidget(self.copy_id_button)
info_buttons.addWidget(self.copy_pick_button)
info_buttons.addWidget(self.copy_info_button)
info_layout.addLayout(info_buttons)
diagnostic_info_layout.addLayout(info_buttons)
self.info_tabs = QTabWidget()
self.info_tree = QTreeWidget()
@@ -1358,8 +1446,11 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
self.info_tabs.addTab(self.info_tree, "属性表")
self.info_tabs.addTab(self.info_text, "原始文本")
help_tip(self.info_tabs, "在表格视图和原始文本视图之间切换当前选中对象信息。")
info_layout.addWidget(self.info_tabs)
panel_layout.addWidget(info_box, stretch=1)
diagnostic_info_layout.addWidget(self.info_tabs)
info_layout.addWidget(self.diagnostic_info_content)
info_box.toggled.connect(self._on_diagnostic_info_toggled)
self.diagnostic_info_content.setVisible(False)
panel_layout.addWidget(info_box)
self._update_action_states()
@@ -1368,7 +1459,7 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
self.vtk_widget.installEventFilter(self)
root_layout.addWidget(self.vtk_widget, stretch=1)
self.statusBar().showMessage("Ready")
self.statusBar().showMessage("请选择 STEP 文件,或点击“读取模型”加载到可视化区域。")
def _set_help_tip(self, widget, text: str) -> None:
widget.setToolTip(text)
@@ -1388,10 +1479,20 @@ def _parse_args(argv: list[str]) -> tuple[Path, bool]:
def main() -> int:
worker_request = _isolated_edit_worker_request(sys.argv)
if worker_request is not None:
from .isolated_edit_worker import run_request
return run_request(worker_request)
if _crash_log_requested(sys.argv):
_enable_crash_log()
path, smoke_test = _parse_args(sys.argv)
_set_windows_taskbar_identity()
app = QApplication(sys.argv)
app.setApplicationName("几何参数化")
app.setApplicationDisplayName("几何参数化")
app.setOrganizationName("GeometryParametric")
app.setWindowIcon(_application_icon())
window = StepEditorWindow(path, background_load=not smoke_test)
if smoke_test:
print("smoke test ok")
@@ -1399,7 +1500,7 @@ def main() -> int:
app.quit()
return 0
window.show()
window.vtk_widget.Start()
window._ensure_vtk_interactor_started()
return app.exec()
+252
View File
@@ -64,6 +64,185 @@ from .geometry_utils import * # noqa: F403
class FeatureMixin:
def _cylindrical_feature_first_level_plan_fields(self, face_id: int) -> dict[str, object]:
try:
topology = self.cylindrical_feature_first_level_topology(face_id)
except Exception as exc:
return {
"topology_relation_depth": 1,
"topology_relation_model": "STEP/B-Rep cylindrical-feature shared-edge first-level",
"topology_relation_status": "unavailable",
"topology_relation_message": str(exc),
"first_level_boundary_edge_ids": (),
"first_level_boundary_edge_count": 0,
"first_level_boundary_vertex_count": 0,
"first_level_adjacent_face_ids": (),
"first_level_adjacent_face_count": 0,
"cylindrical_feature_side_face_ids": (face_id,),
"cylindrical_feature_side_face_count": 1,
}
fields = {
"topology_relation_depth": topology.get("topology_relation_depth", 1),
"topology_relation_model": topology.get("topology_relation_model"),
"topology_relation_scope": topology.get("topology_relation_scope"),
"topology_relation_boundary": topology.get("topology_relation_boundary"),
"topology_relation_status": "ready",
"topology_ignored_relation_depths": topology.get("topology_ignored_relation_depths", ()),
"topology_ignored_relation_note": topology.get("topology_ignored_relation_note", ""),
"first_level_boundary_edge_ids": topology.get("cylindrical_feature_boundary_edge_ids", ()),
"first_level_boundary_edge_count": topology.get("cylindrical_feature_boundary_edge_count", 0),
"first_level_boundary_vertex_count": topology.get("cylindrical_feature_boundary_vertex_count", 0),
"first_level_adjacent_face_ids": topology.get("cylindrical_feature_adjacent_face_ids", ()),
"first_level_adjacent_face_count": topology.get("cylindrical_feature_adjacent_face_count", 0),
"first_level_adjacent_surface_types": topology.get("cylindrical_feature_adjacent_surface_types", ()),
"first_level_shared_edges_by_face": topology.get("cylindrical_feature_shared_edges_by_face", ()),
"first_level_face_ids": topology.get("cylindrical_feature_first_level_face_ids", ()),
"first_level_face_count": topology.get("cylindrical_feature_first_level_face_count", 0),
"first_level_topology_note": topology.get("first_level_topology_note", ""),
"cylindrical_feature_side_face_ids": topology.get("cylindrical_feature_side_face_ids", (face_id,)),
"cylindrical_feature_side_face_count": topology.get("cylindrical_feature_side_face_count", 1),
"cylindrical_feature_boundary_edge_ids": topology.get("cylindrical_feature_boundary_edge_ids", ()),
"cylindrical_feature_boundary_edge_count": topology.get("cylindrical_feature_boundary_edge_count", 0),
"cylindrical_feature_adjacent_face_ids": topology.get("cylindrical_feature_adjacent_face_ids", ()),
"cylindrical_feature_adjacent_face_count": topology.get("cylindrical_feature_adjacent_face_count", 0),
"cylindrical_feature_end_face_ids": topology.get("cylindrical_feature_end_face_ids", ()),
"cylindrical_feature_end_face_count": topology.get("cylindrical_feature_end_face_count", 0),
"cylindrical_feature_bottom_face_ids": topology.get("cylindrical_feature_bottom_face_ids", ()),
"cylindrical_feature_bottom_face_count": topology.get("cylindrical_feature_bottom_face_count", 0),
"cylindrical_feature_opening_face_ids": topology.get("cylindrical_feature_opening_face_ids", ()),
"cylindrical_feature_opening_face_count": topology.get("cylindrical_feature_opening_face_count", 0),
"cylindrical_feature_slot_boundary_face_ids": topology.get(
"cylindrical_feature_slot_boundary_face_ids",
(),
),
"cylindrical_feature_slot_boundary_face_count": topology.get(
"cylindrical_feature_slot_boundary_face_count",
0,
),
}
fields["first_level_edit_semantics"] = (
"Cylindrical feature edits currently use only first-level shared-edge topology: "
"the selected cylinder/slot wall is rebuilt together with its direct boundary neighbors; "
"second-level and deeper propagation is not automatic yet."
)
return fields
def _cylindrical_first_level_guard_fields(
self,
topology_fields: dict[str, object],
*,
require_slot_boundary: bool = False,
require_bottom: bool = False,
) -> dict[str, object]:
blockers: list[str] = []
warnings: list[str] = []
risk = "low"
status = str(topology_fields.get("topology_relation_status") or "")
if status != "ready":
blockers.append(
"当前孔/槽的一级关系拓扑无法确认,已阻止局部重建;请换一个更明确的孔壁/槽壁 Face。"
)
side_count = int(topology_fields.get("cylindrical_feature_side_face_count", 0) or 0)
edge_count = int(topology_fields.get("cylindrical_feature_boundary_edge_count", 0) or 0)
vertex_count = int(topology_fields.get("first_level_boundary_vertex_count", 0) or 0)
adjacent_count = int(topology_fields.get("cylindrical_feature_adjacent_face_count", 0) or 0)
slot_boundary_count = int(topology_fields.get("cylindrical_feature_slot_boundary_face_count", 0) or 0)
bottom_count = int(topology_fields.get("cylindrical_feature_bottom_face_count", 0) or 0)
if side_count <= 0:
blockers.append("没有识别到当前孔/槽的圆柱侧壁区域,不能稳定局部修改。")
if edge_count <= 0:
blockers.append("没有识别到当前孔/槽侧壁的边界 Edge,不能确定一级联动范围。")
if adjacent_count <= 0:
blockers.append("没有识别到与当前孔/槽直接共边的相邻 Face,不能保证修改后拓扑闭合。")
if require_slot_boundary and slot_boundary_count <= 0:
blockers.append("当前槽/半孔缺少直接槽边界 Face,不能稳定执行槽的局部重建。")
if require_bottom and bottom_count <= 0:
blockers.append("当前盲孔/盲槽缺少可靠底面 Face,不能稳定执行局部深度修改。")
if not blockers:
if side_count > 4 or edge_count > 20 or adjacent_count > 14:
risk = _max_risk(risk, "high")
warnings.append(
"当前孔/槽的一级关系邻域较复杂,局部布尔重建可能影响多个直接相邻面。"
)
elif side_count > 1 or edge_count > 10 or adjacent_count > 8:
risk = _max_risk(risk, "medium")
warnings.append("当前孔/槽由多个侧壁或较多相邻面组成,修改后请重点检查一级邻域。")
note = (
f"一级关系拓扑检查:侧壁 Face {side_count} 个,边界 Edge {edge_count} 条,"
f"边界 Vertex {vertex_count} 个,直接相邻 Face {adjacent_count} 个。"
)
return {
"first_level_topology_status": "blocked" if blockers else "ready",
"first_level_topology_risk": "blocked" if blockers else risk,
"first_level_topology_blockers": "".join(blockers),
"first_level_topology_warnings": "".join(warnings),
"first_level_topology_guard_note": note,
}
def _apply_cylindrical_first_level_guard(
self,
topology_fields: dict[str, object],
blockers: list[str],
warnings: list[str],
risk: str,
*,
require_slot_boundary: bool = False,
require_bottom: bool = False,
) -> str:
guard = self._cylindrical_first_level_guard_fields(
topology_fields,
require_slot_boundary=require_slot_boundary,
require_bottom=require_bottom,
)
topology_fields.update(guard)
blocker_text = str(guard.get("first_level_topology_blockers") or "").strip()
warning_text = str(guard.get("first_level_topology_warnings") or "").strip()
if blocker_text:
blockers.append(blocker_text)
if warning_text:
warnings.append(warning_text)
return _max_risk(risk, str(guard.get("first_level_topology_risk") or "low"))
def _apply_cylindrical_first_level_guard_to_readiness(
self,
readiness: dict[str, object],
topology_fields: dict[str, object],
*,
status_key: str,
risk_key: str,
note_key: str,
warnings_key: str,
blockers_key: str,
require_slot_boundary: bool = False,
require_bottom: bool = False,
) -> dict[str, object]:
guard = self._cylindrical_first_level_guard_fields(
topology_fields,
require_slot_boundary=require_slot_boundary,
require_bottom=require_bottom,
)
topology_fields.update(guard)
result = dict(readiness)
blocker_text = str(guard.get("first_level_topology_blockers") or "").strip()
warning_text = str(guard.get("first_level_topology_warnings") or "").strip()
if blocker_text:
result[status_key] = "blocked"
result[risk_key] = "blocked"
result[blockers_key] = _join_nonempty(result.get(blockers_key), blocker_text)
result[note_key] = _join_nonempty(result.get(note_key), blocker_text)
else:
result[risk_key] = _max_risk(str(result.get(risk_key) or "low"), str(guard["first_level_topology_risk"]))
if warning_text:
result[warnings_key] = _join_nonempty(result.get(warnings_key), warning_text)
result[note_key] = _join_nonempty(result.get(note_key), warning_text)
return result
def editable_feature_candidates(
self,
limit: int = 160,
@@ -727,6 +906,7 @@ class FeatureMixin:
}
current_diameter = float(info["diameter"])
feature = self.feature_info(face_id)
topology_fields = self._cylindrical_feature_first_level_plan_fields(face_id)
axis_range = self._cylindrical_axis_range(
face_id,
BRepAdaptor_Surface(self.faces[face_id]),
@@ -737,6 +917,15 @@ class FeatureMixin:
scoped_info["v_range"] = (axis_range["v_min"], axis_range["v_max"])
scoped_info.update(self._cylinder_end_opening_info(face_id, BRepAdaptor_Surface(self.faces[face_id]), axis_range))
readiness = _cylinder_resize_readiness(scoped_info, new_diameter)
readiness = self._apply_cylindrical_first_level_guard_to_readiness(
readiness,
topology_fields,
status_key="resize_status",
risk_key="resize_risk",
note_key="resize_note",
warnings_key="resize_warnings",
blockers_key="resize_blockers",
)
resize_mode = _resize_mode(current_diameter, new_diameter)
delta_diameter = new_diameter - current_diameter
diameter_delta_ratio = abs(delta_diameter) / max(current_diameter, 1e-9)
@@ -781,6 +970,7 @@ class FeatureMixin:
"resize_strategy": "same-axis-bounded-cylinder-recut",
"edit_strategy_label": "同轴圆柱重切",
"edit_semantics": edit_semantics,
**topology_fields,
**cutter_plan,
**fill_plan,
}
@@ -799,12 +989,14 @@ class FeatureMixin:
feature = self.feature_info(face_id)
except Exception:
feature = {}
topology_fields = self._cylindrical_feature_first_level_plan_fields(face_id)
blockers: list[str] = []
warnings: list[str] = [
"Cylinder axis move fills the current cylindrical hole, then cuts a same-diameter hole on the target axis."
]
risk = "medium"
risk = self._apply_cylindrical_first_level_guard(topology_fields, blockers, warnings, risk)
try:
target_center = (float(target_center[0]), float(target_center[1]), float(target_center[2]))
@@ -897,6 +1089,7 @@ class FeatureMixin:
return {
**cutter_plan,
**fill_plan,
**topology_fields,
"status": status,
"risk": risk,
"message": message,
@@ -965,6 +1158,7 @@ class FeatureMixin:
feature = self.feature_info(face_id)
except Exception:
feature = {}
topology_fields = self._cylindrical_feature_first_level_plan_fields(face_id)
current_diameter = _float_or_none(info.get("diameter"))
if current_diameter is None:
@@ -982,6 +1176,13 @@ class FeatureMixin:
"Slot parameter edit keeps the current partial-cylinder angular span, converts the target value to a cylinder diameter, and rebuilds only the local sector volume."
]
risk = "medium"
risk = self._apply_cylindrical_first_level_guard(
topology_fields,
blockers,
warnings,
risk,
require_slot_boundary=True,
)
try:
target_value = float(target_value)
@@ -1067,6 +1268,7 @@ class FeatureMixin:
"feature_slot_face_ids": feature.get("feature_slot_face_ids"),
"feature_slot_boundary_face_ids": feature.get("feature_slot_boundary_face_ids"),
"slot_note": feature.get("slot_note"),
**topology_fields,
}
if blockers:
return {
@@ -1141,6 +1343,7 @@ class FeatureMixin:
feature = self.feature_info(face_id)
except Exception:
feature = {}
topology_fields = self._cylindrical_feature_first_level_plan_fields(face_id)
current_diameter = _float_or_none(info.get("diameter"))
if current_diameter is None:
@@ -1153,6 +1356,13 @@ class FeatureMixin:
"Slot angular-span edit keeps the current cylinder radius, fills the old local sector, then cuts a new local sector around the same angular center."
]
risk = "medium"
risk = self._apply_cylindrical_first_level_guard(
topology_fields,
blockers,
warnings,
risk,
require_slot_boundary=True,
)
try:
target_angular_span = float(target_angular_span)
@@ -1241,6 +1451,7 @@ class FeatureMixin:
return {
**cutter_plan,
**fill_plan,
**topology_fields,
"status": status,
"risk": "blocked" if blockers else risk,
"message": message,
@@ -1303,12 +1514,20 @@ class FeatureMixin:
feature = self.feature_info(face_id)
except Exception:
feature = {}
topology_fields = self._cylindrical_feature_first_level_plan_fields(face_id)
blockers: list[str] = []
warnings: list[str] = [
"Slot/half-hole axis move fills the old local sector, then cuts the same sector tool on the target axis."
]
risk = "medium"
risk = self._apply_cylindrical_first_level_guard(
topology_fields,
blockers,
warnings,
risk,
require_slot_boundary=True,
)
try:
target_center = (float(target_center[0]), float(target_center[1]), float(target_center[2]))
@@ -1473,6 +1692,7 @@ class FeatureMixin:
**cutter_plan,
**fill_plan,
**pair_plan,
**topology_fields,
"status": status,
"risk": "blocked" if blockers else risk,
"message": message,
@@ -1723,6 +1943,7 @@ class FeatureMixin:
raise ValueError(f"Unknown face id {face_id}")
info = self.face_info(face_id)
feature = self.feature_info(face_id)
topology_fields = self._cylindrical_feature_first_level_plan_fields(face_id)
current_diameter = _float_or_none(info.get("diameter"))
if current_diameter is None or current_diameter <= 1e-9:
return {
@@ -1749,6 +1970,13 @@ class FeatureMixin:
blockers: list[str] = []
warnings: list[str] = []
risk = "medium"
risk = self._apply_cylindrical_first_level_guard(
topology_fields,
blockers,
warnings,
risk,
require_slot_boundary=True,
)
if not paired_plan:
if pair_face_id is None:
@@ -1815,6 +2043,7 @@ class FeatureMixin:
**cutter_plan,
**fill_plan,
**paired_plan,
**topology_fields,
"status": status,
"risk": risk,
"message": message,
@@ -1918,6 +2147,7 @@ class FeatureMixin:
current_diameter = float(info["diameter"])
feature = self.feature_info(face_id)
topology_fields = self._cylindrical_feature_first_level_plan_fields(face_id)
axis_range = self._cylindrical_axis_range(
face_id,
BRepAdaptor_Surface(self.faces[face_id]),
@@ -2130,6 +2360,7 @@ class FeatureMixin:
"message": "当前选中的 Face 不是圆柱面,不能封堵圆柱孔。",
}
feature = self.feature_info(face_id)
topology_fields = self._cylindrical_feature_first_level_plan_fields(face_id)
axis_range = self._cylindrical_axis_range(
face_id,
BRepAdaptor_Surface(self.faces[face_id]),
@@ -2140,6 +2371,15 @@ class FeatureMixin:
scoped_info["v_range"] = (axis_range["v_min"], axis_range["v_max"])
scoped_info.update(self._cylinder_end_opening_info(face_id, BRepAdaptor_Surface(self.faces[face_id]), axis_range))
readiness = _cylinder_suppress_readiness(scoped_info)
readiness = self._apply_cylindrical_first_level_guard_to_readiness(
readiness,
topology_fields,
status_key="suppress_status",
risk_key="suppress_risk",
note_key="suppress_note",
warnings_key="suppress_warnings",
blockers_key="suppress_blockers",
)
fill_plan = self._bounded_cylinder_fill_plan(face_id)
fill_plan["fill_strategy"] = "bounded-hole-suppress-fill"
fill_plan["fill_note"] = "按当前圆柱孔范围生成略带重叠的补料圆柱体,用于封堵完整通孔或盲孔。"
@@ -2169,6 +2409,7 @@ class FeatureMixin:
"feature_opening_face_ids": feature.get("feature_opening_face_ids"),
"feature_bottom_note": feature.get("feature_bottom_note"),
"resize_strategy": "fill-cylindrical-hole-volume",
**topology_fields,
"edit_strategy_label": "圆柱补料封堵",
"edit_semantics": "按当前孔轴线和估算高度生成补料圆柱体,局部 Fuse 后封堵当前完整圆柱孔。",
**fill_plan,
@@ -2191,6 +2432,7 @@ class FeatureMixin:
}
feature = self.feature_info(face_id)
topology_fields = self._cylindrical_feature_first_level_plan_fields(face_id)
context = self._blind_cylindrical_depth_context(
face_id,
info,
@@ -2212,6 +2454,15 @@ class FeatureMixin:
context.get("context_message"),
)
readiness["depth_note"] = readiness["depth_blockers"]
readiness = self._apply_cylindrical_first_level_guard_to_readiness(
readiness,
topology_fields,
status_key="depth_status",
risk_key="depth_risk",
note_key="depth_note",
warnings_key="depth_warnings",
blockers_key="depth_blockers",
)
current_depth = float(depth_info.get("hole_depth_estimate", 0.0))
delta_depth = target_depth - current_depth
@@ -2249,6 +2500,7 @@ class FeatureMixin:
"feature_bottom_detection": feature.get("feature_bottom_detection"),
"feature_bottom_note": feature.get("feature_bottom_note"),
"resize_strategy": "bounded-blind-depth-cut-or-fill",
**topology_fields,
"edit_strategy_label": "盲孔/盲槽深度切削或补料",
"edit_semantics": "沿识别到的开口到底面方向调整深度:加深时切削,变浅时从新底面到旧底面补料。",
}
+21 -3
View File
@@ -25,8 +25,17 @@ class InfoPanelMixin:
self.current_info_values = dict(info)
self.current_info_text = _info_to_text(info)
self.info_text.setPlainText(self.current_info_text)
self._populate_info_tree(info)
self.info_tabs.setCurrentWidget(self.info_tree)
diagnostic_content = getattr(self, "diagnostic_info_content", None)
diagnostics_visible = bool(
diagnostic_content is None
or not hasattr(diagnostic_content, "isVisible")
or diagnostic_content.isVisible()
)
if diagnostics_visible:
self._populate_info_tree(info)
self.info_tabs.setCurrentWidget(self.info_tree)
else:
self.info_tree.clear()
if hasattr(self, "_refresh_property_editor"):
self._refresh_property_editor()
if hasattr(self, "_update_selected_object_title"):
@@ -45,6 +54,15 @@ class InfoPanelMixin:
if hasattr(self, "mode_combo"):
self._update_action_states()
def _on_diagnostic_info_toggled(self, checked: bool) -> None:
if hasattr(self, "diagnostic_info_content"):
self.diagnostic_info_content.setVisible(bool(checked))
if checked and self.current_info_values:
self._populate_info_tree(self.current_info_values)
self.info_tabs.setCurrentWidget(self.info_tree)
elif not checked and hasattr(self, "info_tree"):
self.info_tree.clear()
def _populate_info_tree(self, info: dict[str, object]) -> None:
self.info_tree.clear()
emitted: set[str] = set()
@@ -67,7 +85,7 @@ class InfoPanelMixin:
group.setFirstColumnSpanned(True)
self.info_tree.addTopLevelItem(group)
for key, value in items:
child = QTreeWidgetItem([INFO_LABELS.get(key, key), _format_value(value)])
child = QTreeWidgetItem([INFO_LABELS.get(key, key), _format_info_value(key, value)])
child.setData(0, Qt.UserRole, key)
child.setToolTip(0, key)
child.setToolTip(1, _format_value(value))
+17 -6
View File
@@ -33,15 +33,19 @@ def _execute(model: StepModel, operation: str, args: list[object]) -> str:
return model.resize_shell_thickness(int(args[0]), float(args[1]))
if operation == "resize_shell_thickness_owning_scale":
return model.resize_shell_thickness_owning_scale(int(args[0]), float(args[1]))
if operation == "resize_cone_reference_radius":
return model.resize_conical_reference_radius(int(args[0]), float(args[1]))
if operation == "resize_cone_semi_angle":
return model.resize_conical_semi_angle(int(args[0]), float(args[1]))
if operation == "resize_sphere_radius":
return model.resize_spherical_radius(int(args[0]), float(args[1]))
if operation == "resize_torus_radius":
return model.resize_toroidal_radius(int(args[0]), float(args[1]), str(args[2]))
raise ValueError(f"Unsupported isolated edit operation: {operation}")
def main() -> int:
parser = argparse.ArgumentParser(description="Run one high-risk geometry edit in an isolated process.")
parser.add_argument("request", help="JSON request file.")
parsed = parser.parse_args()
request_path = Path(parsed.request)
def run_request(request: str | Path) -> int:
request_path = Path(request)
response_path = request_path.with_suffix(".response.json")
try:
request = json.loads(request_path.read_text(encoding="utf-8-sig"))
@@ -83,5 +87,12 @@ def main() -> int:
return 2
def main() -> int:
parser = argparse.ArgumentParser(description="Run one high-risk geometry edit in an isolated process.")
parser.add_argument("request", help="JSON request file.")
parsed = parser.parse_args()
return run_request(parsed.request)
if __name__ == "__main__":
raise SystemExit(main())
+702 -19
View File
@@ -63,6 +63,7 @@ from OCC.Core.TopAbs import (
TopAbs_OUT,
TopAbs_REVERSED,
TopAbs_SOLID,
TopAbs_VERTEX,
TopAbs_WIRE,
)
from OCC.Core.TopExp import TopExp_Explorer, topexp
@@ -110,6 +111,8 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
self._face_edge_ids_cache: dict[int, list[int]] = {}
self._edge_face_ids_cache: dict[int, list[int]] = {}
self._same_domain_face_ids_cache: dict[int, list[int]] = {}
self._face_first_level_topology_cache: dict[int, dict[str, object]] = {}
self._cylindrical_first_level_topology_cache: dict[int, dict[str, object]] = {}
self._local_face_deform_readiness_cache: dict[int, dict[str, object]] = {}
self._edge_duplicate_key_ids_cache: dict[tuple[object, ...], list[int]] | None = None
self._same_domain_internal_edge_ids_cache: set[int] | None = None
@@ -191,6 +194,8 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
self._face_edge_ids_cache.clear()
self._edge_face_ids_cache.clear()
self._same_domain_face_ids_cache.clear()
self._face_first_level_topology_cache.clear()
self._cylindrical_first_level_topology_cache.clear()
self._local_face_deform_readiness_cache.clear()
self._edge_duplicate_key_ids_cache = None
self._same_domain_internal_edge_ids_cache = None
@@ -283,8 +288,24 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
self._face_info_cache.clear()
self._feature_info_cache.clear()
self._same_domain_face_ids_cache.clear()
self._face_first_level_topology_cache.clear()
self._cylindrical_first_level_topology_cache.clear()
self._local_face_deform_readiness_cache.clear()
def _restore_face_logical_ids_if_count_matches(self, logical_ids: Iterable[int]) -> bool:
previous = tuple(int(item) for item in logical_ids)
if len(previous) != len(self.faces):
return False
self.face_logical_ids = list(previous)
self._quick_face_info_cache.clear()
self._face_info_cache.clear()
self._feature_info_cache.clear()
self._same_domain_face_ids_cache.clear()
self._face_first_level_topology_cache.clear()
self._cylindrical_first_level_topology_cache.clear()
self._local_face_deform_readiness_cache.clear()
return True
def quick_face_info(self, face_id: int) -> dict[str, object]:
if face_id < 0 or face_id >= len(self.faces):
raise ValueError(f"Unknown face id {face_id}")
@@ -323,16 +344,11 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
if surface_type == GeomAbs_Plane:
plane = surf.Plane()
direction = plane.Axis().Direction()
push_pull_direction = self._plane_push_pull_direction(face_id, surf)
info["plane_origin"] = _point_tuple(plane.Location())
info["normal"] = _dir_tuple(direction)
info["oriented_normal"] = _oriented_dir_tuple(direction, face)
info["push_pull_outward_direction"] = push_pull_direction["outward_direction"]
info["push_pull_inward_direction"] = push_pull_direction["inward_direction"]
info["push_pull_plus_side"] = push_pull_direction["plus_side_state"]
info["push_pull_minus_side"] = push_pull_direction["minus_side_state"]
info["push_pull_confidence"] = push_pull_direction["confidence"]
info["push_pull_note"] = push_pull_direction["note"]
info["push_pull_confidence"] = "unchecked"
info["push_pull_note"] = "快速选择阶段不判断材料内外方向;执行推拉时会重新计算。"
info["push_pull_status"] = "candidate"
info["feature_type"] = "可推拉平面候选"
info["feature_source_face_id"] = face_id
@@ -353,7 +369,6 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
radius = cyl.Radius()
u_span = abs(surf.LastUParameter() - surf.FirstUParameter())
swept_area = max(radius * max(u_span, 1e-9), 1e-9)
classification = self._classify_cylindrical_face(face_id, surf, detailed=False)
info["radius"] = radius
info["diameter"] = radius * 2.0
info["axis_point"] = _point_tuple(axis.Location())
@@ -361,13 +376,13 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
info["angular_span"] = u_span
info["is_full_cylinder"] = u_span >= math.tau * 0.98
info["height_estimate"] = props.Mass() / swept_area
info["feature_guess"] = classification["feature_guess"]
info["confidence"] = classification["confidence"]
info["material_toward_axis"] = classification["toward_axis"]
info["material_away_axis"] = classification["away_axis"]
info["material_vote_summary"] = classification["vote_summary"]
info["material_sample_count"] = classification["sample_count"]
info["note"] = classification["note"]
info["feature_guess"] = "cylindrical face"
info["confidence"] = "unchecked"
info["material_toward_axis"] = "not sampled"
info["material_away_axis"] = "not sampled"
info["material_vote_summary"] = "quick selection skips material sampling"
info["material_sample_count"] = 0
info["note"] = "快速选择阶段不判断孔/槽/凸台;需要语义识别时切换特征探测级别。"
info["feature_source_face_id"] = face_id
info["feature_highlight_face_ids"] = (face_id,)
if u_span < math.tau * 0.92:
@@ -572,6 +587,112 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
self._feature_info_cache[face_id] = dict(result)
return dict(result)
def associated_feature_infos(
self,
face_id: int,
*,
max_depth: int = 3,
max_scan_faces: int = 72,
max_features: int = 10,
) -> list[dict[str, object]]:
"""Detect editable feature candidates near the selected face.
STEP does not store a dependable CAD feature-history graph, so this
uses a shallow shared-edge walk. It can cross small cap/support faces to
reach a nearby hole, slot, boss, or analytic surface, but it avoids
scanning an entire solid through large carrier planes.
"""
if face_id < 0 or face_id >= len(self.faces):
raise ValueError(f"Unknown face id {face_id}")
source_info = self.feature_info(face_id)
source_area = max(float(source_info.get("area", 0.0) or 0.0), 1e-12)
source_feature_faces = set(_int_values(source_info.get("feature_face_ids"))) or {face_id}
visited = {face_id}
frontier: list[tuple[int, int]] = [(face_id, 0)]
candidate_hops: dict[int, int] = {}
while frontier and len(visited) < max_scan_faces:
current_id, depth = frontier.pop(0)
if depth >= max_depth:
continue
edge_ids = self._face_boundary_edge_ids(current_id)
neighbors = sorted(set(self._adjacent_face_ids_for_edges(edge_ids, current_id)) - {current_id})
for neighbor_id in neighbors:
candidate_hops[neighbor_id] = min(candidate_hops.get(neighbor_id, depth + 1), depth + 1)
if neighbor_id in visited or len(visited) >= max_scan_faces:
continue
visited.add(neighbor_id)
expand = True
if neighbor_id != face_id and depth >= 1:
quick = self.quick_face_info(neighbor_id)
neighbor_area = float(quick.get("area", 0.0) or 0.0)
if str(quick.get("surface", "")) == "plane" and neighbor_area > source_area * 8.0:
expand = False
if expand:
frontier.append((neighbor_id, depth + 1))
results: list[dict[str, object]] = []
seen_features: set[tuple[str, frozenset[int]]] = set()
for candidate_id, hop_count in sorted(candidate_hops.items(), key=lambda item: (item[1], item[0])):
if candidate_id in source_feature_faces:
continue
try:
info = self.feature_info(candidate_id)
except Exception:
continue
surface = str(info.get("surface", "") or "")
feature_guess = str(info.get("feature_guess", "") or "")
feature_type = str(info.get("feature_type", "") or "")
is_semantic = bool(
info.get("prismatic_extrusion_status") == "candidate"
or surface in {"cone", "sphere", "torus"}
or (
surface == "cylinder"
and feature_guess
in {
"hole/groove candidate",
"boss/outer-round candidate",
"round/fillet candidate",
}
)
)
if not is_semantic:
continue
identity_face_ids = _int_values(info.get("feature_face_ids"))
if info.get("prismatic_profile_status") == "candidate":
identity_face_ids = (
_int_values(info.get("prismatic_highlight_face_ids"))
or _int_values(info.get("feature_highlight_face_ids"))
)
feature_faces = frozenset(identity_face_ids or [candidate_id])
identity = (feature_type or feature_guess or surface, feature_faces)
if identity in seen_features:
continue
seen_features.add(identity)
related = dict(info)
related.update(
{
"association_source_face_id": candidate_id,
"association_hop_count": hop_count,
"association_relation": "shared-edge-topology",
"association_priority": (
0 if surface == "cylinder" else (1 if surface in {"cone", "sphere", "torus"} else 2)
),
}
)
results.append(related)
results.sort(
key=lambda item: (
int(item.get("association_priority", 9)),
int(item.get("association_hop_count", 99)),
int(item.get("association_source_face_id", 0)),
)
)
return results[:max_features]
def _toroidal_feature_info(self, face_id: int, info: dict[str, object]) -> dict[str, object]:
boundary_edge_ids = self._face_boundary_edge_ids(face_id)
major_radius = float(info.get("major_radius", 0.0) or 0.0)
@@ -581,9 +702,11 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
{
"kind": "feature",
"feature_type": "环面候选",
"feature_type": prismatic_info.get("feature_type", "可推拉平面候选"),
"feature_source_face_id": face_id,
"feature_face_ids": (face_id,),
"feature_highlight_face_ids": (face_id,),
"feature_highlight_face_ids": tuple(sorted(highlight_face_ids)),
"feature_boundary_edge_ids": tuple(boundary_edge_ids),
"feature_edit_actions": "修改环面主半径/小半径",
"feature_mode": (
@@ -622,6 +745,36 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
def _conical_feature_info(self, face_id: int, info: dict[str, object]) -> dict[str, object]:
boundary_edge_ids = self._face_boundary_edge_ids(face_id)
reference_radius = float(info.get("reference_radius", 0.0) or 0.0)
boundary_info: dict[str, object] = {}
axis_point = _tuple_or_none(info.get("axis_point"))
axis_direction = _tuple_normalized(_tuple_or_none(info.get("axis")))
if axis_point is not None and axis_direction is not None:
try:
circles = self._conical_face_circle_boundaries(face_id, axis_point, axis_direction)
except Exception:
circles = []
if len(circles) == 2:
sorted_circles = sorted(circles, key=lambda item: float(item["radius"]))
small = sorted_circles[0]
large = sorted_circles[1]
small_center = tuple(float(value) for value in small["center"])
large_center = tuple(float(value) for value in large["center"])
height = _vector_length(_tuple_sub(large_center, small_center))
small_radius = float(small["radius"])
large_radius = float(large["radius"])
if height > 1e-9 and large_radius > small_radius > 1e-9:
boundary_info.update(
{
"feature_cone_small_radius": small_radius,
"feature_cone_small_diameter": small_radius * 2.0,
"feature_cone_large_radius": large_radius,
"feature_cone_large_diameter": large_radius * 2.0,
"feature_cone_height": height,
"feature_cone_boundary_half_angle_degrees": math.degrees(
math.atan((large_radius - small_radius) / height)
),
}
)
result = dict(info)
result.update(
{
@@ -633,11 +786,12 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
"feature_boundary_edge_ids": tuple(boundary_edge_ids),
"feature_reference_radius": reference_radius,
"feature_reference_diameter": reference_radius * 2.0 if reference_radius > 0 else "",
"feature_edit_actions": "修改圆锥参考半径/直径",
"feature_edit_actions": "修改圆锥参考半径/直径/半角",
"feature_mode": (
"这是从 STEP/B-Rep 圆锥面直接识别出的几何候选;修改会围绕圆锥轴做径向缩放"
"不是 CAD 历史里的锥孔或倒角参数。"
"这是从 STEP/B-Rep 圆锥面直接识别出的几何候选;简单圆锥会解析重建"
"嵌入式锥孔/沉孔会优先局部重切,不是 CAD 历史里的锥孔或倒角参数。"
),
**boundary_info,
}
)
return result
@@ -646,6 +800,7 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
coplanar_face_ids = self._connected_coplanar_planar_face_ids(face_id)
boundary_edge_ids = self._region_boundary_edge_ids(coplanar_face_ids)
shell_info = self._planar_shell_region_info(face_id, coplanar_face_ids, info)
prismatic_info = self._planar_rectangular_profile_info(face_id, coplanar_face_ids, info, shell_info)
if len(coplanar_face_ids) > 1:
scope_note = f"已检测到 {len(coplanar_face_ids)} 个共面且相接/重叠的 face,推拉时会作为同一片平面区域处理。"
else:
@@ -653,6 +808,14 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
edit_actions = "推拉平面"
if shell_info.get("shell_region_status") == "candidate":
edit_actions += ";调整薄壁/壳体厚度"
if prismatic_info.get("prismatic_profile_status") == "candidate":
edit_actions = "调整规则矩形轮廓长度/宽度"
if prismatic_info.get("prismatic_extrusion_status") == "candidate":
edit_actions += ";调整棱柱高度/凹槽深度"
else:
edit_actions += ";沿法向推拉"
highlight_face_ids = set(coplanar_face_ids)
highlight_face_ids.update(_int_values(prismatic_info.get("prismatic_highlight_face_ids")))
result = dict(info)
result.update(
{
@@ -671,10 +834,247 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
"feature_edit_actions": edit_actions,
"feature_mode": "这是从 B-Rep 几何推断出的平面编辑候选,不是 CAD 历史特征。",
**shell_info,
**prismatic_info,
}
)
return result
def _planar_rectangular_profile_info(
self,
face_id: int,
coplanar_face_ids: Iterable[int],
info: dict[str, object],
shell_info: dict[str, object],
) -> dict[str, object]:
region_ids = sorted({int(item) for item in coplanar_face_ids})
if region_ids != [face_id]:
return {
"prismatic_profile_status": "not-detected",
"prismatic_profile_note": "共面区域包含多个 Face,暂不把整体包围盒当作规则矩形特征尺寸。",
}
edge_ids = self._face_boundary_edge_ids(face_id)
if len(edge_ids) != 4:
return {
"prismatic_profile_status": "not-detected",
"prismatic_profile_note": "规则矩形轮廓需要恰好四条边。",
}
direction_groups: list[dict[str, object]] = []
for edge_id in edge_ids:
try:
curve = BRepAdaptor_Curve(self.edges[edge_id])
if curve.GetType() != GeomAbs_Line:
return {
"prismatic_profile_status": "not-detected",
"prismatic_profile_note": "轮廓含非直线边,不按规则矩形特征处理。",
}
start = _point_tuple(curve.Value(curve.FirstParameter()))
end = _point_tuple(curve.Value(curve.LastParameter()))
vector = _tuple_sub(end, start)
length = math.sqrt(_tuple_dot(vector, vector))
direction = _tuple_normalized(vector)
except Exception:
direction = None
length = 0.0
if direction is None or length <= 1e-9:
return {
"prismatic_profile_status": "not-detected",
"prismatic_profile_note": "矩形轮廓存在退化边或无法读取的直线边。",
}
matched_group = None
for group in direction_groups:
group_direction = _tuple_or_none(group.get("direction"))
if group_direction is not None and abs(_tuple_dot(direction, group_direction)) >= 0.999:
matched_group = group
break
if matched_group is None:
matched_group = {"direction": direction, "lengths": [], "edge_ids": []}
direction_groups.append(matched_group)
matched_group["lengths"].append(length)
matched_group["edge_ids"].append(edge_id)
if len(direction_groups) != 2 or any(len(group["lengths"]) != 2 for group in direction_groups):
return {
"prismatic_profile_status": "not-detected",
"prismatic_profile_note": "四条边没有形成两组稳定的平行对边。",
}
first_direction = _tuple_or_none(direction_groups[0].get("direction"))
second_direction = _tuple_or_none(direction_groups[1].get("direction"))
if first_direction is None or second_direction is None or abs(_tuple_dot(first_direction, second_direction)) > 0.01:
return {
"prismatic_profile_status": "not-detected",
"prismatic_profile_note": "两组对边不垂直,不按规则矩形特征处理。",
}
for group in direction_groups:
lengths = [float(item) for item in group["lengths"]]
average = sum(lengths) / len(lengths)
if max(abs(item - average) for item in lengths) > max(average * 1e-4, 1e-7):
return {
"prismatic_profile_status": "not-detected",
"prismatic_profile_note": "矩形候选的相对边长度不一致。",
}
group["average_length"] = average
direction_groups.sort(key=lambda group: float(group["average_length"]), reverse=True)
length = float(direction_groups[0]["average_length"])
width = float(direction_groups[1]["average_length"])
area = _float_or_none(info.get("area"))
area_ratio = area / max(length * width, 1e-12) if area is not None else 0.0
if area is None or abs(area_ratio - 1.0) > 0.01:
return {
"prismatic_profile_status": "not-detected",
"prismatic_profile_note": "轮廓面积与长乘宽不一致,可能存在内孔或非矩形裁剪。",
"prismatic_profile_area_ratio": area_ratio,
}
adjacent_side_ids = sorted(set(self._adjacent_face_ids_for_edges(edge_ids, face_id)) - {face_id})
try:
opposite_face_id = int(shell_info["shell_opposite_face_id"])
except (KeyError, TypeError, ValueError):
opposite_face_id = None
connected_side_ids: list[int] = []
if opposite_face_id is not None:
for side_id in adjacent_side_ids:
side_neighbors = self._adjacent_face_ids_for_edges(self._face_boundary_edge_ids(side_id), side_id)
if opposite_face_id in side_neighbors:
connected_side_ids.append(side_id)
reference_face_ids = [opposite_face_id] if opposite_face_id is not None else []
topology_reference = False
signed_extrusion = _float_or_none(shell_info.get("shell_signed_thickness"))
if len(connected_side_ids) < 2:
source_plane = BRepAdaptor_Surface(self.faces[face_id]).Plane()
source_normal = source_plane.Axis().Direction()
solid_id = self.face_solid_ids[face_id]
tolerance = max(_shape_diagonal(self.faces[face_id]) * 1e-6, 1e-6)
groups: list[dict[str, object]] = []
for side_id in adjacent_side_ids:
neighbors = self._adjacent_face_ids_for_edges(self._face_boundary_edge_ids(side_id), side_id)
for candidate_id in neighbors:
if candidate_id == face_id or candidate_id in region_ids:
continue
if solid_id >= 0 and self.face_solid_ids[candidate_id] != solid_id:
continue
try:
candidate_surface = BRepAdaptor_Surface(self.faces[candidate_id])
if candidate_surface.GetType() != GeomAbs_Plane:
continue
candidate_plane = candidate_surface.Plane()
normal_dot = _direction_dot(source_normal, candidate_plane.Axis().Direction())
if abs(normal_dot) < 0.995:
continue
signed_distance = _axis_parameter(
source_plane.Location(),
source_normal,
candidate_plane.Location(),
)
except Exception:
continue
if abs(signed_distance) <= tolerance:
continue
matched_group = None
for group in groups:
if abs(float(group["signed_distance"]) - signed_distance) <= tolerance * 20.0:
matched_group = group
break
if matched_group is None:
matched_group = {
"signed_distance": signed_distance,
"face_ids": set(),
"side_ids": set(),
"normal_dot": normal_dot,
}
groups.append(matched_group)
matched_group["face_ids"].add(candidate_id)
matched_group["side_ids"].add(side_id)
eligible_groups = [group for group in groups if len(group["side_ids"]) >= 2]
if eligible_groups:
eligible_groups.sort(key=lambda group: (-len(group["side_ids"]), abs(float(group["signed_distance"]))))
best_group = eligible_groups[0]
reference_face_ids = sorted(int(item) for item in best_group["face_ids"])
connected_side_ids = sorted(int(item) for item in best_group["side_ids"])
opposite_face_id = reference_face_ids[0]
signed_extrusion = float(best_group["signed_distance"])
topology_reference = True
support_ratio = len(connected_side_ids) / max(len(adjacent_side_ids), 1)
shell_info.update(
{
"shell_region_status": "candidate",
"shell_region_kind": "prismatic-topology-reference",
"shell_source_face_ids": tuple(region_ids),
"shell_opposite_face_id": opposite_face_id,
"shell_thickness_estimate": abs(signed_extrusion),
"shell_signed_thickness": signed_extrusion,
"shell_overlap_ratio_estimate": support_ratio,
"shell_opposite_normal_dot": best_group["normal_dot"],
"shell_confidence": "high" if support_ratio >= 0.99 else "medium",
"shell_note": "通过矩形轮廓的相邻侧壁找到高度/深度基准。",
}
)
extrusion_candidate = opposite_face_id is not None and len(connected_side_ids) >= 2
profile_confidence = "high" if len(adjacent_side_ids) == 4 and area_ratio >= 0.999 else "medium"
feature_type = "规则矩形棱柱候选" if extrusion_candidate else "规则矩形平面候选"
feature_semantics = "generic-prismatic"
if extrusion_candidate and reference_face_ids:
reference_area = 0.0
for reference_id in reference_face_ids:
props = GProp_GProps()
brepgprop.SurfaceProperties(self.faces[reference_id], props)
reference_area += float(props.Mass())
oriented_normal = _tuple_normalized(_tuple_or_none(info.get("oriented_normal")))
source_normal_tuple = _tuple_normalized(
_dir_tuple(BRepAdaptor_Surface(self.faces[face_id]).Plane().Axis().Direction())
)
if reference_area > area * 1.2 and oriented_normal is not None and source_normal_tuple is not None:
outward_offset = float(signed_extrusion or 0.0) * _tuple_dot(source_normal_tuple, oriented_normal)
if outward_offset > 0:
feature_type = "矩形口袋候选"
feature_semantics = "subtractive-pocket"
else:
feature_type = "矩形凸台候选"
feature_semantics = "additive-boss"
result: dict[str, object] = {
"prismatic_profile_status": "candidate",
"prismatic_profile_kind": "rectangular-planar-profile",
"prismatic_profile_confidence": profile_confidence,
"confidence": profile_confidence,
"prismatic_length": length,
"prismatic_width": width,
"prismatic_length_direction": direction_groups[0]["direction"],
"prismatic_width_direction": direction_groups[1]["direction"],
"prismatic_profile_area_ratio": area_ratio,
"prismatic_side_face_ids": tuple(adjacent_side_ids),
"prismatic_connected_side_face_ids": tuple(connected_side_ids),
"prismatic_reference_face_ids": tuple(reference_face_ids),
"prismatic_feature_semantics": feature_semantics,
"prismatic_profile_note": "四条直线边形成两组等长平行对边,面积与长乘宽一致。",
"feature_type": feature_type,
"local_face_width": length,
"local_face_height": width,
"local_face_width_direction": direction_groups[0]["direction"],
"local_face_height_direction": direction_groups[1]["direction"],
}
if extrusion_candidate:
extrusion = _float_or_none(shell_info.get("shell_thickness_estimate"))
extrusion_confidence = "high" if len(connected_side_ids) == 4 else "medium"
result.update(
{
"prismatic_extrusion_status": "candidate",
"prismatic_extrusion_estimate": extrusion if extrusion is not None else "",
"prismatic_reference_face_id": opposite_face_id,
"prismatic_extrusion_confidence": extrusion_confidence,
"confidence": extrusion_confidence,
"prismatic_reference_source": "side-wall-topology" if topology_reference else "overlapping-plane",
"prismatic_highlight_face_ids": tuple(sorted({face_id, *reference_face_ids, *connected_side_ids})),
"prismatic_extrusion_note": "相对平面通过至少两个侧壁与当前矩形面相连。",
}
)
return result
def _planar_shell_region_info(
self,
face_id: int,
@@ -768,6 +1168,17 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
thickness = float(best["shell_thickness_estimate"])
overlap_ratio = float(best["shell_overlap_ratio_estimate"])
local_width = _float_or_none(info.get("local_face_width"))
local_height = _float_or_none(info.get("local_face_height"))
local_spans = [value for value in (local_width, local_height) if value is not None and value > tolerance]
if local_spans and thickness > min(local_spans) * 1.5:
return {
"shell_region_status": "not-detected",
"shell_opposite_face_id": best["shell_opposite_face_id"],
"shell_thickness_estimate": thickness,
"shell_overlap_ratio_estimate": overlap_ratio,
"shell_region_note": "相对平面距离明显大于当前面的局部短边,不按薄壁厚度处理。",
}
thin_ratio = thickness / diagonal
if overlap_ratio >= 0.55 and thin_ratio <= 0.08:
confidence = "high"
@@ -798,15 +1209,39 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
domain_info = dict(info)
domain_info["v_range"] = (axis_range["v_min"], axis_range["v_max"])
domain_info["height_estimate"] = axis_range["span"]
angular_spans: list[float] = []
for side_id in side_face_ids:
try:
side_surface = BRepAdaptor_Surface(self.faces[side_id])
angular_spans.append(abs(side_surface.LastUParameter() - side_surface.FirstUParameter()))
except Exception:
continue
combined_angular_span = min(sum(angular_spans), math.tau) if angular_spans else float(info.get("angular_span", 0.0))
domain_info["angular_span"] = combined_angular_span
end_faces = self._cylindrical_end_face_groups(face_id, adjacent_face_ids, domain_info)
end_face_ids = end_faces["end_face_ids"]
bottom_face_ids = end_faces["bottom_face_ids"]
opening_face_ids = end_faces["opening_face_ids"]
guess = str(info.get("feature_guess", "cylindrical face"))
has_two_axial_caps = bool(end_faces["start_end_face_ids"] and end_faces["end_end_face_ids"])
material_toward = str(info.get("material_toward_axis", "") or "")
material_away = str(info.get("material_away_axis", "") or "")
if (
guess == "round/fillet candidate"
and has_two_axial_caps
and material_toward == "inside"
and "outside" in material_away
):
info = dict(info)
info["feature_guess"] = "boss/outer-round candidate"
info["confidence"] = "medium"
info["note"] = "partial cylinder has material inside its axis and explicit planar caps at both ends"
domain_info["feature_guess"] = info["feature_guess"]
slot_info = self._cylindrical_slot_info(face_id, adjacent_face_ids, end_face_ids, domain_info)
fillet_info = self._cylindrical_existing_fillet_info(face_id, adjacent_face_ids, end_face_ids, domain_info)
guess = str(info.get("feature_guess", "cylindrical face"))
angular_span = float(info.get("angular_span", 0.0))
angular_span = combined_angular_span
if guess == "hole/groove candidate":
if angular_span < math.tau * 0.92:
feature_type = "槽/半孔候选"
@@ -856,6 +1291,9 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
"feature_adjacent_face_ids": tuple(adjacent_face_ids),
"same_domain_v_range": (axis_range["v_min"], axis_range["v_max"]),
"same_domain_height_estimate": axis_range["span"],
"same_domain_angular_span": combined_angular_span,
"angular_span": combined_angular_span,
"is_full_cylinder": combined_angular_span >= math.tau * 0.92,
"same_domain_range_source": axis_range["range_source"],
"same_domain_face_ids": tuple(side_face_ids),
"same_domain_face_count": len(side_face_ids),
@@ -1141,6 +1579,251 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
self._face_edge_ids_cache[face_id] = list(edge_ids)
return edge_ids
def face_first_level_topology(self, face_id: int) -> dict[str, object]:
"""Return the explicit first-level B-Rep neighborhood for a Face.
The current project defines first-level Face topology as the selected
Face region plus Faces that share a boundary Edge with that region.
Vertex-only contacts are reported through boundary vertex counts, but
they are not used as propagation edges at this stage.
"""
if face_id < 0 or face_id >= len(self.faces):
raise ValueError(f"Unknown face id {face_id}")
cached = self._face_first_level_topology_cache.get(face_id)
if cached is not None:
return dict(cached)
source_solid_id = self.face_solid_ids[face_id] if face_id < len(self.face_solid_ids) else -1
source_part_id = self.face_part_ids[face_id] if face_id < len(self.face_part_ids) else -1
try:
same_domain_face_ids = self.connected_same_domain_face_ids(face_id) or [face_id]
except Exception:
same_domain_face_ids = [face_id]
same_domain_face_ids = tuple(sorted({int(item) for item in same_domain_face_ids if 0 <= int(item) < len(self.faces)}))
if not same_domain_face_ids:
same_domain_face_ids = (face_id,)
same_domain_set = set(same_domain_face_ids)
selected_boundary_edge_ids = tuple(self._face_boundary_edge_ids(face_id))
region_boundary_edge_ids = tuple(self._region_boundary_edge_ids(same_domain_face_ids))
shared_edges_by_face: dict[int, list[int]] = {}
for edge_id in region_boundary_edge_ids:
for candidate_id in self._edge_adjacent_face_ids(edge_id):
if candidate_id in same_domain_set:
continue
if source_solid_id >= 0 and self.face_solid_ids[candidate_id] != source_solid_id:
continue
shared_edges_by_face.setdefault(candidate_id, []).append(edge_id)
adjacent_face_ids = tuple(sorted(shared_edges_by_face))
first_level_face_ids = tuple(sorted({*same_domain_face_ids, *adjacent_face_ids}))
diagonal = _shape_diagonal(self.shape)
tolerance = min(max(diagonal * 1e-7, 1e-6), 1e-3)
vertex_points_by_key: dict[tuple[int, int, int], tuple[float, float, float]] = {}
for item in same_domain_face_ids:
explorer = TopExp_Explorer(self.faces[item], TopAbs_VERTEX)
while explorer.More():
vertex = topods.Vertex(explorer.Current())
point = _point_tuple(BRep_Tool.Pnt(vertex))
vertex_points_by_key[self._local_point_key(point, tolerance)] = point
explorer.Next()
boundary_vertex_points = tuple(vertex_points_by_key[key] for key in sorted(vertex_points_by_key))
adjacent_surface_types: list[tuple[int, str]] = []
for adjacent_id in adjacent_face_ids:
adjacent_surface_types.append((adjacent_id, self.face_surface_kind(adjacent_id)))
shared_edge_refs = tuple(
{
"face_id": adjacent_id,
"edge_ids": tuple(sorted(set(edge_ids))),
"edge_count": len(set(edge_ids)),
"surface": self.face_surface_kind(adjacent_id),
}
for adjacent_id, edge_ids in sorted(shared_edges_by_face.items())
)
topology = {
"topology_relation_model": "STEP/B-Rep shared-edge first-level",
"topology_relation_depth": 1,
"topology_relation_scope": "selected same-domain region + direct shared-edge adjacent Faces",
"topology_relation_boundary": "shared-edge",
"topology_ignored_relation_depths": ("second-level", "third-level", "deeper"),
"topology_ignored_relation_note": (
"当前阶段只传播一级关系;相邻 Face 再连接出去的二级、三级拓扑只作为后续目标,不自动递归编辑。"
),
"source_face_id": face_id,
"source_part_id": source_part_id,
"source_solid_id": source_solid_id,
"same_domain_face_ids": same_domain_face_ids,
"same_domain_face_count": len(same_domain_face_ids),
"same_domain_region_kind": "same-domain-region" if len(same_domain_face_ids) > 1 else "single-face",
"selected_boundary_edge_ids": selected_boundary_edge_ids,
"selected_boundary_edge_count": len(selected_boundary_edge_ids),
"first_level_boundary_edge_ids": region_boundary_edge_ids,
"first_level_boundary_edge_count": len(region_boundary_edge_ids),
"first_level_boundary_vertex_points": boundary_vertex_points,
"first_level_boundary_vertex_count": len(boundary_vertex_points),
"first_level_adjacent_face_ids": adjacent_face_ids,
"first_level_adjacent_face_count": len(adjacent_face_ids),
"first_level_adjacent_surface_types": tuple(adjacent_surface_types),
"first_level_shared_edges_by_face": shared_edge_refs,
"first_level_face_ids": first_level_face_ids,
"first_level_face_count": len(first_level_face_ids),
"first_level_topology_note": (
f"已识别当前 Face 区域 {len(same_domain_face_ids)} 个 Face、"
f"边界 Edge {len(region_boundary_edge_ids)} 条、"
f"边界 Vertex {len(boundary_vertex_points)} 个、"
f"共享边一级相邻 Face {len(adjacent_face_ids)} 个;"
"当前编辑计划只处理这些一级关系。"
),
}
for item in same_domain_face_ids:
self._face_first_level_topology_cache[item] = dict(topology)
return dict(topology)
def cylindrical_feature_first_level_topology(self, face_id: int) -> dict[str, object]:
"""Return the first-level B-Rep neighborhood for a cylindrical feature.
For holes and slots, first-level topology means the selected cylindrical
side region and Faces that directly share one of its boundary Edges.
Paired slot ends reached through another planar wall are deliberately
not promoted to first-level topology at this stage.
"""
if face_id < 0 or face_id >= len(self.faces):
raise ValueError(f"Unknown face id {face_id}")
cached = self._cylindrical_first_level_topology_cache.get(face_id)
if cached is not None:
return dict(cached)
info = self.face_info(face_id)
if info.get("surface") != "cylinder":
raise ValueError(f"Face {face_id} is not a cylindrical feature face")
source_solid_id = self.face_solid_ids[face_id] if face_id < len(self.face_solid_ids) else -1
source_part_id = self.face_part_ids[face_id] if face_id < len(self.face_part_ids) else -1
feature = self.feature_info(face_id)
def valid_face_ids(values: object) -> tuple[int, ...]:
result: list[int] = []
for item in _int_values(values):
if item < 0 or item >= len(self.faces):
continue
if source_part_id >= 0 and self.face_part_ids[item] != source_part_id:
continue
if source_solid_id >= 0 and self.face_solid_ids[item] != source_solid_id:
continue
result.append(item)
return tuple(sorted(set(result)))
side_face_ids = valid_face_ids(feature.get("feature_side_face_ids"))
if not side_face_ids:
side_face_ids = valid_face_ids(feature.get("feature_face_ids"))
if not side_face_ids:
try:
side_face_ids = valid_face_ids(self.connected_same_domain_face_ids(face_id))
except Exception:
side_face_ids = ()
if not side_face_ids:
side_face_ids = (face_id,)
side_face_set = set(side_face_ids)
selected_boundary_edge_ids = tuple(self._face_boundary_edge_ids(face_id))
region_boundary_edge_ids = tuple(self._region_boundary_edge_ids(side_face_ids))
shared_edges_by_face: dict[int, list[int]] = {}
for edge_id in region_boundary_edge_ids:
for candidate_id in self._edge_adjacent_face_ids(edge_id):
if candidate_id in side_face_set:
continue
if source_part_id >= 0 and self.face_part_ids[candidate_id] != source_part_id:
continue
if source_solid_id >= 0 and self.face_solid_ids[candidate_id] != source_solid_id:
continue
shared_edges_by_face.setdefault(candidate_id, []).append(edge_id)
adjacent_face_ids = tuple(sorted(shared_edges_by_face))
end_face_ids = valid_face_ids(feature.get("feature_end_face_ids"))
bottom_face_ids = valid_face_ids(feature.get("feature_bottom_face_ids"))
opening_face_ids = valid_face_ids(feature.get("feature_opening_face_ids"))
slot_boundary_face_ids = valid_face_ids(feature.get("feature_slot_boundary_face_ids"))
first_level_face_ids = tuple(sorted({*side_face_ids, *adjacent_face_ids}))
diagonal = _shape_diagonal(self.shape)
tolerance = min(max(diagonal * 1e-7, 1e-6), 1e-3)
vertex_points_by_key: dict[tuple[int, int, int], tuple[float, float, float]] = {}
for edge_id in region_boundary_edge_ids:
if edge_id < 0 or edge_id >= len(self.edges):
continue
explorer = TopExp_Explorer(self.edges[edge_id], TopAbs_VERTEX)
while explorer.More():
vertex = topods.Vertex(explorer.Current())
point = _point_tuple(BRep_Tool.Pnt(vertex))
vertex_points_by_key[self._local_point_key(point, tolerance)] = point
explorer.Next()
boundary_vertex_points = tuple(vertex_points_by_key[key] for key in sorted(vertex_points_by_key))
adjacent_surface_types = tuple((item, self.face_surface_kind(item)) for item in adjacent_face_ids)
shared_edge_refs = tuple(
{
"face_id": adjacent_id,
"edge_ids": tuple(sorted(set(edge_ids))),
"edge_count": len(set(edge_ids)),
"surface": self.face_surface_kind(adjacent_id),
}
for adjacent_id, edge_ids in sorted(shared_edges_by_face.items())
)
angular_span = feature.get("slot_angular_span", feature.get("angular_span", info.get("angular_span")))
topology = {
"topology_relation_model": "STEP/B-Rep cylindrical-feature shared-edge first-level",
"topology_relation_depth": 1,
"topology_relation_scope": "selected cylindrical same-domain side region + direct shared-edge adjacent Faces",
"topology_relation_boundary": "shared-edge",
"topology_ignored_relation_depths": ("second-level", "third-level", "deeper"),
"topology_ignored_relation_note": (
"Only direct shared-edge neighbors of the cylindrical side region are treated as first-level topology. "
"Faces reached through those neighbors are recorded later as second-level or deeper relationships."
),
"source_face_id": face_id,
"source_part_id": source_part_id,
"source_solid_id": source_solid_id,
"feature_type": feature.get("feature_type"),
"feature_guess": feature.get("feature_guess", info.get("feature_guess")),
"slot_kind": feature.get("slot_kind", ""),
"cylinder_end_type": feature.get("cylinder_end_type", info.get("cylinder_end_type")),
"is_full_cylinder": bool(feature.get("is_full_cylinder", info.get("is_full_cylinder", False))),
"angular_span": angular_span,
"cylindrical_feature_side_face_ids": side_face_ids,
"cylindrical_feature_side_face_count": len(side_face_ids),
"cylindrical_feature_selected_boundary_edge_ids": selected_boundary_edge_ids,
"cylindrical_feature_selected_boundary_edge_count": len(selected_boundary_edge_ids),
"cylindrical_feature_boundary_edge_ids": region_boundary_edge_ids,
"cylindrical_feature_boundary_edge_count": len(region_boundary_edge_ids),
"cylindrical_feature_boundary_vertex_points": boundary_vertex_points,
"cylindrical_feature_boundary_vertex_count": len(boundary_vertex_points),
"cylindrical_feature_adjacent_face_ids": adjacent_face_ids,
"cylindrical_feature_adjacent_face_count": len(adjacent_face_ids),
"cylindrical_feature_adjacent_surface_types": adjacent_surface_types,
"cylindrical_feature_shared_edges_by_face": shared_edge_refs,
"cylindrical_feature_first_level_face_ids": first_level_face_ids,
"cylindrical_feature_first_level_face_count": len(first_level_face_ids),
"cylindrical_feature_end_face_ids": end_face_ids,
"cylindrical_feature_end_face_count": len(end_face_ids),
"cylindrical_feature_bottom_face_ids": bottom_face_ids,
"cylindrical_feature_bottom_face_count": len(bottom_face_ids),
"cylindrical_feature_opening_face_ids": opening_face_ids,
"cylindrical_feature_opening_face_count": len(opening_face_ids),
"cylindrical_feature_slot_boundary_face_ids": slot_boundary_face_ids,
"cylindrical_feature_slot_boundary_face_count": len(slot_boundary_face_ids),
"first_level_topology_note": (
f"Cylindrical side Faces={len(side_face_ids)}, boundary Edges={len(region_boundary_edge_ids)}, "
f"boundary Vertices={len(boundary_vertex_points)}, direct adjacent Faces={len(adjacent_face_ids)}. "
"Second-level and deeper propagation is not automatic in this stage."
),
}
for item in side_face_ids:
self._cylindrical_first_level_topology_cache[item] = dict(topology)
return dict(topology)
def _face_boundary_wire_info(self, face: TopoDS_Shape) -> dict[str, object]:
try:
boundary_wires = len(_explore(face, TopAbs_WIRE))
+1377 -27
View File
File diff suppressed because it is too large Load Diff
+20 -1
View File
@@ -140,7 +140,26 @@ class PolydataMixin:
def _ensure_mesh(self, deflection: float) -> None:
requested = max(float(deflection), 1e-9)
if self._mesh_deflection is None or requested < self._mesh_deflection * 0.999:
BRepMesh_IncrementalMesh(self.shape, requested)
if requested <= 0.04:
BRepMesh_IncrementalMesh(
self.shape,
0.35,
False,
math.radians(12.0),
True,
)
for face in self.faces:
try:
surface_type = BRepAdaptor_Surface(face).GetType()
if surface_type in {GeomAbs_Cylinder, GeomAbs_Cone}:
BRepMesh_IncrementalMesh(face, requested, False, math.radians(1.2), True)
elif surface_type in {GeomAbs_Sphere, GeomAbs_Torus}:
BRepMesh_IncrementalMesh(face, max(requested, 0.1), False, math.radians(4.0), True)
except Exception:
continue
else:
angular_degrees = min(24.0, max(12.0, 12.0 * requested / 0.35))
BRepMesh_IncrementalMesh(self.shape, requested, False, math.radians(angular_degrees), True)
self._mesh_deflection = requested
def build_snapshot_polydata(self, snapshot: dict[object, object], deflection: float = 0.8):
+4
View File
@@ -120,9 +120,11 @@ class TransformMixin:
part = self.part_by_id(part_id)
if part is None:
raise ValueError(f"未知零件 ID {part_id}")
previous_logical_ids = tuple(getattr(self, "face_logical_ids", ()))
part.shape = _translated_shape_by_vector(part.shape, vector)
_ensure_valid_shape(part.shape)
self.refresh_topology()
self._restore_face_logical_ids_if_count_matches(previous_logical_ids)
return (
f"零件已平移: 零件 {part_id}, vector={_format_tuple(vector)}, "
f"distance={float(plan['translation_distance']):g}, risk={plan['risk']}."
@@ -137,6 +139,7 @@ class TransformMixin:
if part is None:
raise ValueError(f"Unknown part id {part_id}")
previous_logical_ids = tuple(getattr(self, "face_logical_ids", ()))
part_solids = _explore(part.shape, TopAbs_SOLID)
if len(part_solids) <= 1:
part.shape = _translated_shape_by_vector(part.shape, vector)
@@ -156,6 +159,7 @@ class TransformMixin:
_ensure_valid_shape(part.shape)
self.refresh_topology()
self._restore_face_logical_ids_if_count_matches(previous_logical_ids)
return (
f"Solid translated: solid {solid_id}, part {part_id}, vector={_format_tuple(vector)}, "
f"distance={float(plan['translation_distance']):g}, risk={plan['risk']}."
+53 -14
View File
@@ -462,7 +462,7 @@ INFO_LABELS = {
"ellipse_edge_major_radius": "椭圆Edge主半径",
"ellipse_edge_minor_radius": "椭圆Edge小半径",
"reference_radius": "参考半径",
"semi_angle": "半角",
"semi_angle": "圆锥半角",
"angular_span": "角度跨度",
"height_estimate": "估算高度",
"hole_depth_estimate": "孔/槽深度估算",
@@ -606,6 +606,12 @@ INFO_LABELS = {
"existing_fillet_radius_estimate": "已有圆角半径估算",
"feature_reference_radius": "特征参考半径",
"feature_reference_diameter": "特征参考直径",
"feature_cone_small_radius": "锥孔小端半径",
"feature_cone_small_diameter": "锥孔小端直径",
"feature_cone_large_radius": "锥孔大端半径",
"feature_cone_large_diameter": "锥孔大端直径",
"feature_cone_height": "锥孔高度",
"feature_cone_boundary_half_angle_degrees": "锥孔半角",
"feature_sphere_radius": "球面半径",
"feature_sphere_diameter": "球面直径",
"feature_torus_major_radius": "环面主半径",
@@ -768,6 +774,32 @@ SELECTION_MODE_LABELS = {
SELECTION_MODE_VALUES = {label: mode for mode, label in SELECTION_MODE_LABELS.items()}
SURFACE_VALUE_LABELS = {
"plane": "平面",
"cylinder": "圆柱面",
"cone": "圆锥面 / 拔模面",
"sphere": "球面",
"torus": "环面",
"bezier surface": "Bezier 曲面",
"b-spline surface": "B-spline 曲面",
"surface of revolution": "旋转曲面",
"surface of extrusion": "拉伸曲面",
"offset surface": "偏移曲面",
"other surface": "其它曲面",
}
CURVE_VALUE_LABELS = {
"line": "直线",
"circle": "圆 / 圆弧",
"ellipse": "椭圆 / 椭圆弧",
"hyperbola": "双曲线",
"parabola": "抛物线",
"bezier curve": "Bezier 曲线",
"b-spline curve": "B-spline 曲线",
"other curve": "其它曲线",
}
def _selection_mode_label(mode: object) -> str:
mode_text = str(mode or "")
return SELECTION_MODE_LABELS.get(mode_text, mode_text or "Face")
@@ -783,7 +815,7 @@ def _format_float(value: float) -> str:
def _info_to_text(info: dict[str, object]) -> str:
return "\n".join(f"{INFO_LABELS.get(key, key)}: {_format_value(value)}" for key, value in info.items())
return "\n".join(f"{INFO_LABELS.get(key, key)}: {_format_info_value(key, value)}" for key, value in info.items())
def _part_tree_kind_label(kind: str) -> str:
@@ -807,25 +839,21 @@ def _enable_overlay_depth_offset(mapper) -> None:
def _smooth_surface_polydata(polydata):
clean = vtk.vtkCleanPolyData()
clean.SetInputData(polydata)
clean.PointMergingOn()
clean.SetTolerance(1e-7)
clean.Update()
normals = vtk.vtkPolyDataNormals()
normals.SetInputConnection(clean.GetOutputPort())
# build_face_polydata owns a separate point set for each B-Rep Face.
# Keep those boundary points separate: averaging a side-wall normal into a
# planar cap creates the dark triangular gradients seen in the UI.
normals.SetInputData(polydata)
normals.ComputePointNormalsOn()
normals.ComputeCellNormalsOff()
normals.ConsistencyOn()
normals.SplittingOn()
normals.SetFeatureAngle(35.0)
if hasattr(normals, "AutoOrientNormalsOn"):
normals.AutoOrientNormalsOn()
normals.SplittingOff()
if hasattr(normals, "NonManifoldTraversalOff"):
normals.NonManifoldTraversalOff()
normals.Update()
smoothed = vtk.vtkPolyData()
smoothed.DeepCopy(normals.GetOutput())
smoothed.ShallowCopy(normals.GetOutput())
return smoothed
@@ -882,6 +910,17 @@ def _format_value(value: object) -> str:
return str(value)
def _format_info_value(key: object, value: object) -> str:
key_text = str(key or "")
if key_text == "surface":
value_text = str(value or "")
return SURFACE_VALUE_LABELS.get(value_text, value_text)
if key_text == "curve":
value_text = str(value or "")
return CURVE_VALUE_LABELS.get(value_text, value_text)
return _format_value(value)
def _int_values(value: object) -> list[int]:
if value is None or value == "":
return []
+742 -26
View File
@@ -375,7 +375,11 @@ class WindowActionMixin:
if result != QMessageBox.StandardButton.Yes:
self.statusBar().showMessage("已取消推拉平面")
return
self._show_push_pull_preview(face_id, distance, plan=plan)
isolation = self._isolation_for_plan(plan, "push_pull_face", [face_id, distance])
if isolation is None:
self._show_push_pull_preview(face_id, distance, plan=plan)
else:
self.clear_edit_preview(render=False)
def action():
return self.model.push_pull_face(face_id, distance)
@@ -410,7 +414,7 @@ class WindowActionMixin:
},
target_kind="face",
target_id=face_id,
isolation=self._isolation_for_plan(plan, "push_pull_face", [face_id, distance]),
isolation=isolation,
)
def _quick_push_pull_plan(self, face_id: int, distance: float) -> dict[str, object]:
@@ -467,10 +471,9 @@ class WindowActionMixin:
warnings.append("面移动距离相对当前面尺寸偏大,请确认预览范围。")
outward_tuple = tuple(float(item) for item in outward)
inward_material_depth = None
inward_material_depth = _float_or_none(info.get("push_pull_inward_material_depth"))
inward_cut_ratio = None
if distance < 0 and self.model is not None:
inward_material_depth = self.model._push_pull_inward_material_depth(face_id, outward_tuple)
if distance < 0:
if inward_material_depth is not None and inward_material_depth > 1e-9:
inward_cut_ratio = distance_abs / inward_material_depth
depth_tolerance = max(
@@ -488,6 +491,10 @@ class WindowActionMixin:
elif inward_cut_ratio >= 0.6 and risk != "high":
risk = "medium"
warnings.append("向内切削距离超过当前面背后材料厚度的 60%,请确认不会切穿。")
else:
if risk == "low":
risk = "medium"
warnings.append("向内推拉的材料厚度尚未缓存;完整切穿检查会放到后台计算。")
if status != "blocked" and risk in {"medium", "high"}:
status = "caution"
@@ -605,10 +612,11 @@ class WindowActionMixin:
self.statusBar().showMessage("已取消薄壁厚度调整")
return
if str(plan.get("risk")) == "high":
isolation = self._isolation_for_plan(plan, "resize_shell_thickness", [face_id, target_thickness])
if isolation is not None:
self.clear_edit_preview(render=False)
else:
self._show_shell_thickness_preview(face_id, target_thickness)
self._show_shell_thickness_preview(face_id, target_thickness, plan=plan)
def action():
return self.model.resize_shell_thickness(face_id, target_thickness)
@@ -647,7 +655,7 @@ class WindowActionMixin:
},
target_kind="feature" if self.selected_kind == "feature" else "face",
target_id=face_id,
isolation=self._isolation_for_plan(plan, "resize_shell_thickness", [face_id, target_thickness]),
isolation=isolation,
)
def resize_shell_thickness_owning_scale(self) -> None:
@@ -984,13 +992,31 @@ class WindowActionMixin:
*,
timeout_seconds: float = 180.0,
) -> dict[str, object] | None:
if str(plan.get("risk", "")) != "high":
risk = str(plan.get("risk", ""))
isolated_face_operations = {
"push_pull_face",
"move_face_plane_offset_local",
"resize_face_area_local",
"resize_face_area",
"resize_face_size_local",
"resize_face_size_owning_scale",
"move_face_center_local",
"resize_shell_thickness",
"resize_shell_thickness_owning_scale",
"resize_cone_reference_radius",
"resize_cone_semi_angle",
"resize_sphere_radius",
"resize_torus_radius",
}
if operation not in isolated_face_operations:
return None
if risk not in {"low", "medium", "high"}:
return None
return {
"operation": operation,
"args": args,
"timeout_seconds": timeout_seconds,
"reason": "high-risk-occ-edit",
"reason": f"{risk}-risk-face-occ-edit",
}
def resize_hole(self) -> None:
@@ -1067,6 +1093,7 @@ class WindowActionMixin:
},
target_kind="face",
target_id=face_id,
isolation=isolation,
)
def resize_cylindrical_owning_scale(self) -> None:
@@ -1130,6 +1157,12 @@ class WindowActionMixin:
"affine_axis_point": plan.get("affine_axis_point"),
"affine_axis_direction": plan.get("affine_axis_direction"),
"affine_target_kind": plan.get("affine_target_kind"),
"embedded_cone_recut_mode": plan.get("embedded_cone_recut_mode"),
"embedded_cone_current_small_radius": plan.get("embedded_cone_current_small_radius"),
"embedded_cone_target_small_radius": plan.get("embedded_cone_target_small_radius"),
"embedded_cone_current_large_radius": plan.get("embedded_cone_current_large_radius"),
"embedded_cone_target_large_radius": plan.get("embedded_cone_target_large_radius"),
"embedded_cone_height": plan.get("embedded_cone_height"),
"resize_strategy": plan.get("resize_strategy"),
"edit_strategy_label": plan.get("edit_strategy_label"),
"edit_semantics": plan.get("edit_semantics"),
@@ -2564,6 +2597,13 @@ class WindowActionMixin:
"affine_axis_point": plan.get("affine_axis_point"),
"affine_axis_direction": plan.get("affine_axis_direction"),
"affine_target_kind": plan.get("affine_target_kind"),
"embedded_cone_recut_mode": plan.get("embedded_cone_recut_mode"),
"embedded_cone_fixed_small_radius": plan.get("embedded_cone_fixed_small_radius"),
"embedded_cone_current_small_radius": plan.get("embedded_cone_current_small_radius"),
"embedded_cone_target_small_radius": plan.get("embedded_cone_target_small_radius"),
"embedded_cone_current_large_radius": plan.get("embedded_cone_current_large_radius"),
"embedded_cone_target_large_radius": plan.get("embedded_cone_target_large_radius"),
"embedded_cone_height": plan.get("embedded_cone_height"),
"resize_strategy": plan.get("resize_strategy"),
"edit_strategy_label": plan.get("edit_strategy_label"),
"edit_semantics": plan.get("edit_semantics"),
@@ -4112,7 +4152,7 @@ class WindowActionMixin:
def resize_cone_reference_radius(self) -> None:
if self.model is None:
return
if self._edit_busy("请等待当前编辑完成后再修改圆锥面(整体)"):
if self._edit_busy("请等待当前编辑完成后再修改圆锥面参数"):
return
if self.selected_face_id is None:
QMessageBox.information(self, "未选择圆锥面", "请先选择一个圆锥面 Face 或圆锥面特征。")
@@ -4127,16 +4167,22 @@ class WindowActionMixin:
face_id = self.selected_face_id
plan = self.model.conical_reference_radius_plan(face_id, target_radius)
isolation = self._isolation_for_plan(plan, "resize_cone_reference_radius", [face_id, target_radius])
if plan["status"] == "blocked":
QMessageBox.information(self, "不能修改圆锥面(整体)", str(plan["message"]))
self.statusBar().showMessage("圆锥面(整体)修改已阻止")
QMessageBox.information(self, "不能修改圆锥面参数", str(plan["message"]))
self.statusBar().showMessage("圆锥面参数修改已阻止")
return
if plan["risk"] != "low":
warnings = str(plan.get("warnings", ""))
warnings_line = f"警告: {warnings}\n\n" if warnings else ""
isolation_line = (
"本次会在隔离子进程里执行高风险 OCCT 计算;如果子进程卡死或崩溃,主程序和原模型会保持不变。\n\n"
if isolation is not None
else ""
)
result = QMessageBox.question(
self,
"确认修改圆锥面(整体)",
"确认修改圆锥面参数",
(
f"Face: {face_id}\n"
f"当前参考半径: {_format_value(plan.get('current_reference_radius'))}\n"
@@ -4150,13 +4196,14 @@ class WindowActionMixin:
f"风险: {plan['risk']}\n\n"
f"{warnings_line}"
f"{plan['message']}\n\n"
f"{isolation_line}"
"继续操作会修改当前 B-Rep 结果几何,并支持失败回滚/撤销。确定继续吗?"
),
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.No,
)
if result != QMessageBox.StandardButton.Yes:
self.statusBar().showMessage("已取消圆锥面(整体)修改")
self.statusBar().showMessage("已取消圆锥面参数修改")
return
self.clear_edit_preview(render=False)
@@ -4165,7 +4212,7 @@ class WindowActionMixin:
self._run_edit_action(
action,
operation_name="修改圆锥面参数(整体)",
operation_name="修改圆锥面参数",
target=f"Face {face_id}",
parameters={
"part_id": plan.get("part_id"),
@@ -4198,6 +4245,104 @@ class WindowActionMixin:
},
target_kind="face",
target_id=face_id,
isolation=isolation,
)
def resize_cone_semi_angle(self) -> None:
if self.model is None:
return
if self._edit_busy("请等待当前编辑完成后再修改圆锥半角。"):
return
if self.selected_face_id is None:
QMessageBox.information(self, "未选择圆锥面", "请先选择一个圆锥 Face 或圆锥特征。")
return
if not hasattr(self, "cone_reference_radius_input"):
return
try:
target_angle_degrees = float(self.cone_reference_radius_input.text())
except ValueError:
QMessageBox.critical(self, "圆锥半角无效", "请输入数字形式的目标半角,单位是度。")
return
face_id = self.selected_face_id
plan = self.model.conical_semi_angle_plan(face_id, target_angle_degrees)
isolation = self._isolation_for_plan(plan, "resize_cone_semi_angle", [face_id, target_angle_degrees])
if plan["status"] == "blocked":
QMessageBox.information(self, "不能修改圆锥半角", str(plan["message"]))
self.statusBar().showMessage("圆锥半角修改已阻止")
return
if plan["risk"] != "low":
warnings = str(plan.get("warnings", ""))
warnings_line = f"警告: {warnings}\n\n" if warnings else ""
isolation_line = (
"本次会在隔离子进程里执行高风险 OCCT 计算;如果子进程卡死或崩溃,主程序和原模型会保持不变。\n\n"
if isolation is not None
else ""
)
result = QMessageBox.question(
self,
"确认修改圆锥半角",
(
f"Face: {face_id}\n"
f"当前半角: {_format_value(plan.get('semi_angle_degrees'))}\n"
f"目标半角: {_format_value(plan.get('target_semi_angle_degrees'))}\n"
f"目标参考半径: {_format_value(plan.get('target_reference_radius'))}\n"
f"变化比例: {_format_percent(plan.get('reference_radius_delta_ratio'))}\n"
f"编辑策略: {_format_value(plan.get('edit_strategy_label'))}\n"
f"影响范围: {_format_value(plan.get('edit_semantics'))}\n"
f"风险: {plan['risk']}\n\n"
f"{warnings_line}"
f"{plan['message']}\n\n"
f"{isolation_line}"
"继续操作会修改当前 B-Rep 结果几何,并支持失败回滚。确定继续吗?"
),
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.No,
)
if result != QMessageBox.StandardButton.Yes:
self.statusBar().showMessage("已取消圆锥半角修改")
return
self.clear_edit_preview(render=False)
def action():
return self.model.resize_conical_semi_angle(face_id, target_angle_degrees)
self._run_edit_action(
action,
operation_name="修改圆锥半角(整体)",
target=f"Face {face_id}",
parameters={
"part_id": plan.get("part_id"),
"solid_id": plan.get("solid_id"),
"surface": "cone",
"current_reference_radius": plan.get("current_reference_radius"),
"target_reference_radius": plan.get("target_reference_radius"),
"current_reference_diameter": plan.get("current_reference_diameter"),
"target_reference_diameter": plan.get("target_reference_diameter"),
"delta_reference_radius": plan.get("delta_reference_radius"),
"reference_radius_delta_ratio": plan.get("reference_radius_delta_ratio"),
"semi_angle": plan.get("semi_angle"),
"semi_angle_degrees": plan.get("semi_angle_degrees"),
"target_semi_angle": plan.get("target_semi_angle"),
"target_semi_angle_degrees": plan.get("target_semi_angle_degrees"),
"affine_scale": plan.get("affine_scale"),
"affine_transform_kind": plan.get("affine_transform_kind"),
"affine_transform_label": plan.get("affine_transform_label"),
"affine_axis_point": plan.get("affine_axis_point"),
"affine_axis_direction": plan.get("affine_axis_direction"),
"affine_target_kind": plan.get("affine_target_kind"),
"resize_strategy": plan.get("resize_strategy"),
"edit_strategy_label": plan.get("edit_strategy_label"),
"edit_semantics": plan.get("edit_semantics"),
"resize_status": plan.get("status"),
"resize_risk": plan.get("risk"),
"resize_message": plan.get("message"),
"resize_warnings": plan.get("warnings"),
"resize_blockers": plan.get("blockers"),
},
target_kind="face",
target_id=face_id,
isolation=isolation,
)
def resize_sphere_radius(self) -> None:
@@ -4218,6 +4363,7 @@ class WindowActionMixin:
face_id = self.selected_face_id
plan = self.model.spherical_radius_plan(face_id, target_radius)
isolation = self._isolation_for_plan(plan, "resize_sphere_radius", [face_id, target_radius])
if plan["status"] == "blocked":
QMessageBox.information(self, "不能修改球面(整体)", str(plan["message"]))
self.statusBar().showMessage("球面(整体)修改已阻止")
@@ -4225,6 +4371,11 @@ class WindowActionMixin:
if plan["risk"] != "low":
warnings = str(plan.get("warnings", ""))
warnings_line = f"警告: {warnings}\n\n" if warnings else ""
isolation_line = (
"本次会在隔离子进程里执行高风险 OCCT 计算;如果子进程卡死或崩溃,主程序和原模型会保持不变。\n\n"
if isolation is not None
else ""
)
result = QMessageBox.question(
self,
"确认修改球面(整体)",
@@ -4241,6 +4392,7 @@ class WindowActionMixin:
f"风险: {plan['risk']}\n\n"
f"{warnings_line}"
f"{plan['message']}\n\n"
f"{isolation_line}"
"继续操作会修改当前 B-Rep 结果几何,并支持失败回滚/撤销。确定继续吗?"
),
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
@@ -4285,6 +4437,7 @@ class WindowActionMixin:
},
target_kind="face",
target_id=face_id,
isolation=isolation,
)
def resize_torus_major_radius(self) -> None:
@@ -4313,6 +4466,7 @@ class WindowActionMixin:
mode_label = "主半径(整体)" if mode_key == "major" else "小半径(整体)"
face_id = self.selected_face_id
plan = self.model.toroidal_radius_plan(face_id, target_radius, mode_key)
isolation = self._isolation_for_plan(plan, "resize_torus_radius", [face_id, target_radius, mode_key])
if plan["status"] == "blocked":
QMessageBox.information(self, "不能修改环面(整体)", str(plan["message"]))
self.statusBar().showMessage("环面(整体)修改已阻止")
@@ -4320,6 +4474,11 @@ class WindowActionMixin:
if plan["risk"] != "low":
warnings = str(plan.get("warnings", ""))
warnings_line = f"警告: {warnings}\n\n" if warnings else ""
isolation_line = (
"本次会在隔离子进程里执行高风险 OCCT 计算;如果子进程卡死或崩溃,主程序和原模型会保持不变。\n\n"
if isolation is not None
else ""
)
result = QMessageBox.question(
self,
f"确认修改环面{mode_label}",
@@ -4338,6 +4497,7 @@ class WindowActionMixin:
f"风险: {plan['risk']}\n\n"
f"{warnings_line}"
f"{plan['message']}\n\n"
f"{isolation_line}"
"继续操作会修改当前 B-Rep 结果几何,并支持失败回滚/撤销。确定继续吗?"
),
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
@@ -4386,6 +4546,7 @@ class WindowActionMixin:
},
target_kind="face",
target_id=face_id,
isolation=isolation,
)
def resize_face_area(self) -> None:
@@ -6252,6 +6413,7 @@ class WindowActionMixin:
target_part_id = self._edit_context_part_id(context)
before_stats = self.model.stats()
before_part_stats = self._part_stats_or_none(target_part_id)
before_quality = self._edit_quality_info_or_none(self.model, context, target_part_id)
before_geometry = {}
isolation = context.get("isolation")
if isinstance(isolation, dict) and isolation:
@@ -6261,6 +6423,7 @@ class WindowActionMixin:
snapshot=snapshot,
before_stats=before_stats,
before_part_stats=before_part_stats,
before_quality=before_quality,
before_geometry=before_geometry,
)
try:
@@ -6268,6 +6431,17 @@ class WindowActionMixin:
after_snapshot = self.model.snapshot()
after_stats = self.model.stats()
after_part_stats = self._part_stats_or_none(target_part_id)
after_quality = self._edit_quality_info_or_none(self.model, context, target_part_id)
quality_warnings = self._verified_edit_quality_warnings(
context,
before_stats,
after_stats,
before_part_stats,
after_part_stats,
before_quality,
after_quality,
after_model=self.model,
)
after_geometry = {}
except Exception as exc:
try:
@@ -6298,7 +6472,7 @@ class WindowActionMixin:
"after_snapshot": after_snapshot,
"after_stats": after_stats,
"after_part_stats": after_part_stats,
"quality_warnings": _edit_quality_warnings(before_part_stats, after_part_stats),
"quality_warnings": quality_warnings,
"after_geometry": after_geometry,
"model_polydata": model_polydata,
"edge_polydata": edge_polydata,
@@ -6314,6 +6488,7 @@ class WindowActionMixin:
snapshot: dict[object, object],
before_stats,
before_part_stats,
before_quality: dict[str, object] | None,
before_geometry: dict[str, object],
) -> dict[str, object]:
if self.model is None:
@@ -6346,7 +6521,7 @@ class WindowActionMixin:
encoding="utf-8",
)
command = [sys.executable, "-m", "step_editor.isolated_edit_worker", str(request_path)]
command = self._isolated_edit_command(request_path)
try:
completed = subprocess.run(
command,
@@ -6385,11 +6560,23 @@ class WindowActionMixin:
new_model.filename = self.step_path
except Exception:
pass
child_message = str(response.get("message") or "隔离子进程编辑完成。")
self._preserve_isolated_face_logical_id(new_model, context, child_message)
after_snapshot = new_model.snapshot()
after_stats = new_model.stats()
after_part_stats = self._part_stats_or_none_for_model(new_model, target_part_id)
after_quality = self._edit_quality_info_or_none(new_model, context, target_part_id)
quality_warnings = self._verified_edit_quality_warnings(
context,
before_stats,
after_stats,
before_part_stats,
after_part_stats,
before_quality,
after_quality,
after_model=new_model,
)
self.model = new_model
after_snapshot = self.model.snapshot()
after_stats = self.model.stats()
after_part_stats = self._part_stats_or_none(target_part_id)
after_geometry: dict[str, object] = {}
model_polydata = None
edge_polydata = None
@@ -6404,7 +6591,6 @@ class WindowActionMixin:
model_polydata = None
edge_polydata = None
child_message = str(response.get("message") or "隔离子进程编辑完成。")
return {
"message": f"{child_message} 已通过隔离子进程完成;如果 OCC 崩溃,主程序不会被带崩。",
"snapshot": snapshot,
@@ -6414,12 +6600,47 @@ class WindowActionMixin:
"after_snapshot": after_snapshot,
"after_stats": after_stats,
"after_part_stats": after_part_stats,
"quality_warnings": _edit_quality_warnings(before_part_stats, after_part_stats),
"quality_warnings": quality_warnings,
"after_geometry": after_geometry,
"model_polydata": model_polydata,
"edge_polydata": edge_polydata,
}
def _isolated_edit_command(self, request_path: Path) -> list[str]:
if getattr(sys, "frozen", False):
return [sys.executable, "--isolated-edit-worker", str(request_path)]
return [sys.executable, "-m", "step_editor.isolated_edit_worker", str(request_path)]
def _preserve_isolated_face_logical_id(
self,
model: StepModel,
context: dict[str, object],
result_message: str,
) -> None:
if not self._context_is_face_parameter_edit(context):
return
target_logical_id = context.get("target_logical_id")
if target_logical_id is None:
return
located_text = _message_field(result_message, "nearest_face") or _message_field(result_message, "verified_face")
if located_text is None:
return
try:
face_id = int(float(located_text))
logical_id = int(target_logical_id)
except (TypeError, ValueError):
return
if not (0 <= face_id < len(model.faces)):
return
try:
face_ids = model.face_region_ids(face_id) or [face_id]
except Exception:
face_ids = [face_id]
try:
model.assign_logical_face_region_exclusive(logical_id, face_ids)
except Exception:
return
def _edit_context_part_id(self, context: dict[str, object]) -> int | None:
if self.model is None:
return None
@@ -6452,13 +6673,508 @@ class WindowActionMixin:
return None
def _part_stats_or_none(self, part_id: int | None):
if self.model is None or part_id is None:
return self._part_stats_or_none_for_model(self.model, part_id)
def _part_stats_or_none_for_model(self, model: StepModel | None, part_id: int | None):
if model is None or part_id is None:
return None
try:
return self.model.part_topology_stats(part_id)
return model.part_topology_stats(part_id)
except Exception:
return None
def _edit_quality_info_or_none(
self,
model: StepModel | None,
context: dict[str, object],
target_part_id: int | None,
) -> dict[str, object] | None:
if model is None:
return None
try:
if target_part_id is not None:
return model.export_quality_info("part", target_part_id)
return model.export_quality_info("all", None)
except Exception:
try:
return model.export_quality_info("all", None)
except Exception:
return None
def _verified_edit_quality_warnings(
self,
context: dict[str, object],
before_stats,
after_stats,
before_part_stats,
after_part_stats,
before_quality: dict[str, object] | None,
after_quality: dict[str, object] | None,
*,
after_model: StepModel | None = None,
) -> list[str]:
warnings = _edit_quality_warnings(before_part_stats, after_part_stats)
blockers: list[str] = []
if getattr(after_stats, "solids", 0) <= 0:
blockers.append("编辑结果里没有检测到 Solid,模型已保持在修改前状态。")
if getattr(after_stats, "faces", 0) <= 0:
blockers.append("编辑结果里没有检测到 Face,模型已保持在修改前状态。")
if getattr(after_stats, "edges", 0) <= 0:
blockers.append("编辑结果里没有检测到 Edge,模型已保持在修改前状态。")
if after_quality is not None:
quality_status = str(after_quality.get("quality_status", ""))
after_brep_valid = after_quality.get("brep_valid")
before_brep_valid = before_quality.get("brep_valid") if before_quality is not None else None
quality_warnings = str(after_quality.get("quality_warnings", "")).strip()
if quality_status == "blocked":
blockers.append(quality_warnings or "编辑结果质量检查被判定为不可用。")
elif after_brep_valid is False and before_brep_valid is not False:
blockers.append(quality_warnings or "编辑结果没有通过 B-Rep 校验。")
elif quality_warnings:
warnings.append(quality_warnings)
if self._context_is_face_parameter_edit(context):
if (
before_part_stats is not None
and after_part_stats is not None
and getattr(before_part_stats, "solids", 0) > 0
and getattr(after_part_stats, "solids", 0) != getattr(before_part_stats, "solids", 0)
):
blockers.append(
"Face 参数化编辑后目标特征的 Solid 数量发生变化,已阻止接受该结果。"
f" Solid: {before_part_stats.solids} -> {after_part_stats.solids}"
)
target_blocker = self._face_target_integrity_blocker(after_model, context)
if target_blocker:
blockers.append(target_blocker)
if blockers:
raise RuntimeError(" ".join(blockers))
return warnings
def _face_target_integrity_blocker(
self,
model: StepModel | None,
context: dict[str, object],
) -> str:
if model is None:
return ""
parameters = context.get("parameters")
if not isinstance(parameters, dict):
return ""
surface = str(parameters.get("surface") or "")
face_ids = self._face_target_candidate_ids(model, context)
if not face_ids:
if surface in {"cone", "sphere", "torus"} and any(
parameters.get(key) not in {"", None}
for key in (
"target_reference_radius",
"target_semi_angle_degrees",
"target_radius",
"target_major_radius",
"target_minor_radius",
)
):
return f"Face 编辑结果里没有找到目标 {surface} 面,模型已恢复到修改前状态。"
return ""
checks: list[tuple[str, bool]] = []
target_area = _float_or_none(parameters.get("target_area"))
if target_area is not None and target_area > 0:
checks.append(("面积", self._face_target_area_matches(model, face_ids, target_area)))
target_width = _float_or_none(parameters.get("target_face_width"))
target_height = _float_or_none(parameters.get("target_face_height"))
if target_width is not None or target_height is not None:
checks.append(("面宽/面高", self._face_target_size_matches(model, face_ids, target_width, target_height)))
target_center = _triple_or_none(parameters.get("target_face_center"))
if target_center is not None:
checks.append(("中心", self._face_target_center_matches(model, face_ids, target_center)))
target_position = _float_or_none(parameters.get("target_plane_position"))
plane_direction = (
_unit_triple_or_none(parameters.get("plane_direction"))
or _unit_triple_or_none(parameters.get("outward_direction"))
)
if target_position is not None and plane_direction is not None:
checks.append(
(
"面偏移",
self._face_target_plane_position_matches(
model,
face_ids,
target_position,
plane_direction,
_float_or_none(parameters.get("bbox_diagonal")),
),
)
)
target_thickness = _float_or_none(parameters.get("shell_target_thickness"))
if target_thickness is not None and target_thickness > 0:
checks.append(
(
"薄壁厚度",
self._face_target_shell_thickness_matches(model, face_ids, target_thickness),
)
)
if surface == "sphere":
target_radius = _float_or_none(parameters.get("target_radius"))
if target_radius is not None and target_radius > 0:
checks.append(("半径", self._face_target_numeric_info_matches(model, face_ids, "radius", target_radius)))
elif surface == "cone":
resize_strategy = str(parameters.get("resize_strategy") or "")
embedded_target_small_radius = _float_or_none(parameters.get("embedded_cone_target_small_radius"))
embedded_target_large_radius = _float_or_none(parameters.get("embedded_cone_target_large_radius"))
if (
embedded_target_small_radius is not None
and embedded_target_small_radius > 0
and embedded_target_large_radius is not None
and embedded_target_large_radius > embedded_target_small_radius
):
checks.append(
(
"锥孔边界半径",
self._face_target_cone_boundary_radii_match(
model,
face_ids,
embedded_target_small_radius,
embedded_target_large_radius,
),
)
)
target_reference_radius = _float_or_none(parameters.get("target_reference_radius"))
if (
target_reference_radius is not None
and target_reference_radius > 0
and "semi-angle" not in resize_strategy
and "bounded-cone-recut" not in resize_strategy
):
checks.append(
(
"参考半径",
self._face_target_numeric_info_matches(
model,
face_ids,
"reference_radius",
target_reference_radius,
),
)
)
target_semi_angle_degrees = _float_or_none(parameters.get("target_semi_angle_degrees"))
if target_semi_angle_degrees is not None and target_semi_angle_degrees > 0:
checks.append(
(
"半角",
self._face_target_angle_degrees_matches(model, face_ids, target_semi_angle_degrees),
)
)
elif surface == "torus":
target_major_radius = _float_or_none(parameters.get("target_major_radius"))
if target_major_radius is not None and target_major_radius > 0:
checks.append(
(
"主半径",
self._face_target_numeric_info_matches(
model,
face_ids,
"major_radius",
target_major_radius,
),
)
)
target_minor_radius = _float_or_none(parameters.get("target_minor_radius"))
if target_minor_radius is not None and target_minor_radius > 0:
checks.append(
(
"小半径",
self._face_target_numeric_info_matches(
model,
face_ids,
"minor_radius",
target_minor_radius,
),
)
)
failed = [name for name, ok in checks if not ok]
if not failed:
return ""
return (
"Face 编辑结果没有达到目标值;"
f"未通过的检查: {', '.join(failed)}"
"模型已恢复到修改前状态。"
)
def _face_target_candidate_ids(self, model: StepModel, context: dict[str, object]) -> list[int]:
parameters = context.get("parameters")
parameters = parameters if isinstance(parameters, dict) else {}
candidates: list[int] = []
logical_id = context.get("target_logical_id")
if logical_id is not None:
try:
candidates.extend(model.face_ids_for_logical_id(int(logical_id)))
except Exception:
pass
target_id = context.get("target_id")
try:
numeric_target_id = int(target_id) if target_id is not None else -1
except (TypeError, ValueError):
numeric_target_id = -1
if 0 <= numeric_target_id < len(model.faces):
candidates.append(numeric_target_id)
if not candidates:
candidates = list(range(len(model.faces)))
part_id = self._edit_integrity_int_or_none(parameters.get("part_id"))
solid_id = self._edit_integrity_int_or_none(parameters.get("solid_id"))
surface = str(parameters.get("surface") or "")
target_keys = (
"target_area",
"target_face_width",
"target_face_height",
"target_face_center",
"target_plane_position",
"shell_target_thickness",
"target_reference_radius",
"target_semi_angle_degrees",
"embedded_cone_target_small_radius",
"embedded_cone_target_large_radius",
"target_radius",
"target_major_radius",
"target_minor_radius",
)
has_target_value = any(parameters.get(key) not in {"", None} for key in target_keys)
def filtered(source_ids, *, require_solid: bool = True) -> list[int]:
result: list[int] = []
for face_id in source_ids:
if not (0 <= int(face_id) < len(model.faces)):
continue
if part_id is not None and int(model.face_part_ids[int(face_id)]) != part_id:
continue
if (
require_solid
and solid_id is not None
and solid_id >= 0
and int(model.face_solid_ids[int(face_id)]) != solid_id
):
continue
if surface in {"plane", "cylinder", "cone", "sphere", "torus"}:
try:
if model.face_surface_kind(int(face_id)) != surface:
continue
except Exception:
continue
if int(face_id) not in result:
result.append(int(face_id))
return result
result = filtered(candidates)
if result:
return result
if surface in {"plane", "cylinder", "cone", "sphere", "torus"} and has_target_value:
result = filtered(range(len(model.faces)))
if result:
return result
if solid_id is not None and solid_id >= 0:
result = filtered(range(len(model.faces)), require_solid=False)
return result
def _edit_integrity_int_or_none(self, value: object) -> int | None:
if value in {"", None}:
return None
try:
return int(value)
except (TypeError, ValueError):
return None
def _face_target_area_matches(self, model: StepModel, face_ids: list[int], target_area: float) -> bool:
tolerance = max(abs(target_area) * 0.02, 1e-4)
for face_id in face_ids:
try:
area = _float_or_none(model.face_info(face_id).get("area"))
except Exception:
area = None
if area is not None and abs(area - target_area) <= tolerance:
return True
return False
def _face_target_size_matches(
self,
model: StepModel,
face_ids: list[int],
target_width: float | None,
target_height: float | None,
) -> bool:
for face_id in face_ids:
try:
info = model.face_info(face_id)
except Exception:
continue
width = _float_or_none(info.get("local_face_width"))
height = _float_or_none(info.get("local_face_height"))
if target_width is not None:
if width is None or abs(width - target_width) > max(abs(target_width) * 0.02, 1e-4):
continue
if target_height is not None:
if height is None or abs(height - target_height) > max(abs(target_height) * 0.02, 1e-4):
continue
return True
return False
def _face_target_center_matches(
self,
model: StepModel,
face_ids: list[int],
target_center: tuple[float, float, float],
) -> bool:
reference = max(max(abs(item) for item in target_center), 1.0)
tolerance = max(reference * 1e-5, 1e-4)
for face_id in face_ids:
try:
info = model.face_info(face_id)
except Exception:
continue
center = _triple_or_none(info.get("area_center")) or _triple_or_none(info.get("bbox_center"))
if center is None:
continue
distance = math.sqrt(sum((center[index] - target_center[index]) ** 2 for index in range(3)))
if distance <= tolerance:
return True
return False
def _face_target_plane_position_matches(
self,
model: StepModel,
face_ids: list[int],
target_position: float,
direction: tuple[float, float, float],
bbox_diagonal: float | None,
) -> bool:
tolerance = max((bbox_diagonal or 1.0) * 1e-4, abs(target_position) * 1e-5, 1e-4)
for face_id in face_ids:
try:
info = model.face_info(face_id)
except Exception:
continue
origin = _triple_or_none(info.get("plane_origin"))
if origin is None:
continue
position = sum(origin[index] * direction[index] for index in range(3))
if abs(position - target_position) <= tolerance:
return True
return False
def _face_target_shell_thickness_matches(
self,
model: StepModel,
face_ids: list[int],
target_thickness: float,
) -> bool:
tolerance = max(abs(target_thickness) * 0.03, 1e-4)
for face_id in face_ids:
try:
info = model.feature_info(face_id)
except Exception:
continue
current = _float_or_none(info.get("shell_thickness_estimate"))
if current is not None and abs(current - target_thickness) <= tolerance:
return True
return False
def _face_target_numeric_info_matches(
self,
model: StepModel,
face_ids: list[int],
info_key: str,
target_value: float,
) -> bool:
tolerance = max(abs(target_value) * 0.01, 1e-4)
for face_id in face_ids:
try:
value = _float_or_none(model.face_info(face_id).get(info_key))
except Exception:
value = None
if value is not None and abs(value - target_value) <= tolerance:
return True
return False
def _face_target_cone_boundary_radii_match(
self,
model: StepModel,
face_ids: list[int],
target_small_radius: float,
target_large_radius: float,
) -> bool:
tolerance = max(abs(target_large_radius) * 0.01, abs(target_small_radius) * 0.01, 1e-4)
for face_id in face_ids:
try:
info = model.face_info(face_id)
axis_point = _triple_or_none(info.get("axis_point"))
axis_direction = _unit_triple_or_none(info.get("axis"))
if axis_point is None or axis_direction is None:
continue
circles = model._conical_face_circle_boundaries(face_id, axis_point, axis_direction)
except Exception:
continue
radii = sorted(float(circle["radius"]) for circle in circles)
if len(radii) != 2:
continue
if abs(radii[0] - target_small_radius) <= tolerance and abs(radii[1] - target_large_radius) <= tolerance:
return True
return False
def _face_target_angle_degrees_matches(
self,
model: StepModel,
face_ids: list[int],
target_degrees: float,
) -> bool:
tolerance = max(abs(target_degrees) * 0.01, 0.05)
for face_id in face_ids:
try:
angle = _float_or_none(model.face_info(face_id).get("semi_angle"))
except Exception:
angle = None
if angle is None:
continue
degrees = abs(math.degrees(angle))
if abs(degrees - target_degrees) <= tolerance:
return True
return False
def _context_is_face_parameter_edit(self, context: dict[str, object]) -> bool:
parameters = context.get("parameters")
if not isinstance(parameters, dict):
return False
strategy = str(parameters.get("resize_strategy") or parameters.get("translate_strategy") or "")
if any(
token in strategy
for token in (
"face",
"plane-offset",
"push-pull-planar",
"push-pull-shell",
"shell-thickness",
"cone",
"sphere",
"torus",
"radial-affine-scale",
)
):
return True
operation = str(context.get("operation_name") or "")
target_kind = str(context.get("target_kind") or "")
return target_kind in {"face", "feature"} and any(
token in operation for token in ("Face", "", "薄壁", "推拉", "圆锥", "球面", "环面")
)
def _begin_edit_task(self, operation_name: str) -> None:
self.operation_in_progress = True
self._update_action_states()
+168 -20
View File
@@ -46,6 +46,40 @@ class WindowCoreMixin:
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()
@@ -93,7 +127,11 @@ class WindowCoreMixin:
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())
@@ -105,7 +143,9 @@ class WindowCoreMixin:
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)
# Qt mouse tracking already drives hover. Avoid routing every VTK
# camera-move event through Python; that made rotation feel sticky on
# large STEP meshes.
self.interactor.AddObserver("StartInteractionEvent", self.on_camera_interaction_start)
self.interactor.AddObserver("EndInteractionEvent", self.on_camera_interaction_end)
if hasattr(self.interactor, "SetDesiredUpdateRate"):
@@ -119,6 +159,12 @@ class WindowCoreMixin:
light.SetIntensity(0.9)
self.renderer.AddLight(light)
fill_light = vtk.vtkLight()
fill_light.SetLightTypeToCameraLight()
fill_light.SetPosition(-1, -1, 1)
fill_light.SetIntensity(0.28)
self.renderer.AddLight(fill_light)
self.interactor.Initialize()
def on_camera_interaction_start(self, _obj, _event) -> None:
@@ -143,6 +189,19 @@ class WindowCoreMixin:
if not getattr(self, "camera_interaction_active", False):
return
self.camera_interaction_active = False
self.last_camera_interaction_ended_at = datetime.now()
self.pending_hover_position = None
self.last_hover_pick_position = None
def _hover_suppressed_after_camera(self) -> bool:
ended_at = getattr(self, "last_camera_interaction_ended_at", None)
if ended_at is None:
return False
cooldown_ms = int(getattr(self, "hover_after_camera_cooldown_ms", 0) or 0)
if cooldown_ms <= 0:
return False
elapsed_ms = (datetime.now() - ended_at).total_seconds() * 1000.0
return elapsed_ms < cooldown_ms
def closeEvent(self, event) -> None:
load_thread_running = bool(
@@ -213,11 +272,32 @@ class WindowCoreMixin:
self.load_in_progress = True
self.pending_load_path = new_path
self.statusBar().showMessage(f"Preparing fast load for {new_path.name}...")
self.statusBar().showMessage(f"正在读取 STEP 可视化网格:{new_path.name}...")
self._clear_hover(render=True)
self._update_action_states()
QTimer.singleShot(0, lambda path=new_path: self._run_deferred_initial_load(path))
@Slot(object)
def _run_packaged_initial_load(self, expected_path: Path) -> None:
if self.pending_load_path != expected_path:
return
try:
self._load_step_sync(
expected_path,
deflection=self.preview_load_deflection,
show_internal_edges=self._show_same_domain_internal_edges(),
status_prefix="读取 STEP 可视化网格",
)
finally:
self._end_load_task()
@Slot(object)
def _start_initial_load_worker(self, expected_path: Path) -> None:
# Qt/VTK/OCCT visualization objects are not safe to construct from this
# worker path in the current PySide build. Keep the old entry point as a
# main-thread fallback so stale signal paths cannot crash the process.
self._run_deferred_initial_load(expected_path)
def _load_step_sync(
self,
new_path: Path,
@@ -253,13 +333,29 @@ class WindowCoreMixin:
try:
self._load_step_sync(
expected_path,
deflection=self.initial_load_deflection,
show_internal_edges=True,
status_prefix="Fast loading",
deflection=self.preview_load_deflection,
show_internal_edges=self._show_same_domain_internal_edges(),
status_prefix="读取 STEP 可视化网格",
)
finally:
self._end_load_task()
@staticmethod
def _copy_polydata_for_ui_thread(polydata: object) -> object:
if polydata is None:
return None
copied = vtk.vtkPolyData()
copied.DeepCopy(polydata)
return copied
def _detach_worker_polydata_result(self, result: dict[str, object]) -> dict[str, object]:
detached = dict(result)
if detached.get("model_polydata") is not None:
detached["model_polydata"] = self._copy_polydata_for_ui_thread(detached["model_polydata"])
if detached.get("edge_polydata") is not None:
detached["edge_polydata"] = self._copy_polydata_for_ui_thread(detached["edge_polydata"])
return detached
def _apply_loaded_model_result(self, result: dict[str, object], *, reset_camera: bool) -> None:
new_path = Path(result["path"])
stats = result["stats"]
@@ -309,9 +405,18 @@ class WindowCoreMixin:
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.statusBar().showMessage(f"Loaded {self.step_path.name}")
self._end_load_task()
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))
@@ -326,14 +431,18 @@ class WindowCoreMixin:
self.statusBar().showMessage("STEP load failed; current model was left unchanged.")
def _start_load_refine(self, initial_result: dict[str, object]) -> None:
self._end_load_task()
if self.model is not None:
self.statusBar().showMessage(f"Loaded {self.step_path.name}")
self.statusBar().showMessage(f"Loaded {self.step_path.name}")
@Slot(object)
def _finish_load_refine(self, result: object) -> None:
try:
if isinstance(result, dict) and result.get("model") is self.model:
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"],
@@ -351,7 +460,7 @@ class WindowCoreMixin:
"display": "ready",
}
)
self.statusBar().showMessage(f"Loaded {self.step_path.name}")
self.statusBar().showMessage(f"精细显示已完成:{self.step_path.name}")
finally:
self._end_load_task()
@@ -385,12 +494,24 @@ class WindowCoreMixin:
"STEP 文件 (*.step *.stp);;所有文件 (*.*)",
)
if path:
self.load_step(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
self.load_step(self.step_path)
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()
@@ -1107,6 +1228,7 @@ class WindowCoreMixin:
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
@@ -1129,7 +1251,11 @@ class WindowCoreMixin:
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):
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)
@@ -1156,6 +1282,7 @@ class WindowCoreMixin:
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
@@ -1576,7 +1703,7 @@ class WindowCoreMixin:
self.selected_pick_position = pick_position
info = self.model.quick_face_info(face_id)
if feature_mode:
info = self._feature_info_for_selected_face(face_id, info)
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]
@@ -1601,7 +1728,7 @@ class WindowCoreMixin:
return
if self._edit_busy("编辑计算中,暂时不能选择特征。"):
return
info = self._feature_info_for_selected_face(face_id, self.model.quick_face_info(face_id))
info = self._feature_context_info(face_id)
info["kind"] = "feature"
self._reset_selection(clear_highlight=False, clear_info=False)
self.selected_kind = "feature"
@@ -2026,16 +2153,37 @@ class WindowCoreMixin:
self._start_edit_preview_pulse()
self.render_window.Render()
def _show_shell_thickness_preview(self, face_id: int, target_thickness: float) -> None:
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:
polydata = self.model.shell_thickness_preview_polydata(face_id, target_thickness)
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
self._add_edit_preview_actor(polydata, (0.0, 0.72, 0.78), opacity=0.34)
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()
+481 -28
View File
@@ -28,6 +28,57 @@ PROPERTY_SCOPE_COLUMN = 2
PROPERTY_TARGET_COLUMN = 3
PROPERTY_ACTION_COLUMN = 4
FEATURE_EDIT_SEMANTICS_KEYS = {
"face_first_level_topology",
"slot_edit_semantics",
"hole_edit_semantics",
"boss_edit_semantics",
"existing_fillet_edit_semantics",
"analytic_surface_edit_semantics",
"face_edit_semantics",
}
def _feature_dimension_keys(action_info: dict[str, object]) -> tuple[str, ...]:
"""Return the independent, user-facing dimensions for a feature candidate."""
surface = str(action_info.get("surface", "") or "")
feature_guess = str(action_info.get("feature_guess", "") or "")
angular_span = _float_or_none(action_info.get("angular_span"))
if surface == "plane":
if action_info.get("prismatic_profile_status") == "candidate":
keys = ["local_face_width", "local_face_height"]
if action_info.get("prismatic_extrusion_status") == "candidate":
keys.append("shell_thickness_estimate")
return tuple(keys)
if action_info.get("shell_region_status") == "candidate":
return ("shell_thickness_estimate",)
return ("face_target_normal_position",)
if surface == "cylinder":
is_partial = angular_span is not None and angular_span < math.tau * 0.92
if feature_guess == "hole/groove candidate":
if is_partial:
return (
"slot_chord_width_estimate",
"slot_sagitta_depth_estimate",
"slot_total_length_estimate",
)
return ("diameter", "hole_depth_estimate")
if feature_guess == "boss/outer-round candidate":
return ("boss_diameter", "boss_height")
if feature_guess == "round/fillet candidate":
return ("existing_fillet_radius_estimate",)
return ("generic_cylinder_diameter", "cylinder_height")
if surface == "cone":
return ("cone_reference_radius", "cone_semi_angle_degrees")
if surface == "sphere":
return ("sphere_radius",)
if surface == "torus":
return ("torus_major_radius", "torus_minor_radius")
return ()
def _record_message_field(message: str | None, key: str) -> str | None:
if not message:
@@ -109,9 +160,51 @@ class WindowStateMixin:
enriched["pick_position"] = pick_position
return enriched
def _face_first_level_selection_fields(self, face_id: int) -> dict[str, object]:
if self.model is None:
return {}
try:
topology = self.model.face_first_level_topology(face_id)
except Exception as exc:
return {
"topology_relation_depth": 1,
"topology_relation_model": "STEP/B-Rep shared-edge first-level",
"topology_relation_status": "unavailable",
"topology_relation_message": str(exc),
"same_domain_face_count": 1,
"first_level_boundary_edge_count": 0,
"first_level_boundary_vertex_count": 0,
"first_level_adjacent_face_count": 0,
"first_level_topology_note": (
"当前 Face 的一级拓扑关系暂时无法确认;只改当前面的局部重建会在计划阶段再次检查。"
),
}
return {
"topology_relation_depth": topology.get("topology_relation_depth", 1),
"topology_relation_model": topology.get("topology_relation_model"),
"topology_relation_scope": topology.get("topology_relation_scope"),
"topology_relation_boundary": topology.get("topology_relation_boundary"),
"topology_relation_status": "ready",
"topology_ignored_relation_depths": topology.get("topology_ignored_relation_depths", ()),
"topology_ignored_relation_note": topology.get("topology_ignored_relation_note", ""),
"same_domain_face_ids": topology.get("same_domain_face_ids", (face_id,)),
"same_domain_face_count": topology.get("same_domain_face_count", 1),
"same_domain_region_kind": topology.get("same_domain_region_kind", "single-face"),
"first_level_boundary_edge_ids": topology.get("first_level_boundary_edge_ids", ()),
"first_level_boundary_edge_count": topology.get("first_level_boundary_edge_count", 0),
"first_level_boundary_vertex_count": topology.get("first_level_boundary_vertex_count", 0),
"first_level_adjacent_face_ids": topology.get("first_level_adjacent_face_ids", ()),
"first_level_adjacent_face_count": topology.get("first_level_adjacent_face_count", 0),
"first_level_face_ids": topology.get("first_level_face_ids", (face_id,)),
"first_level_face_count": topology.get("first_level_face_count", 1),
"first_level_topology_note": topology.get("first_level_topology_note", ""),
}
def _feature_info_for_selected_face(self, face_id: int, fallback_info: dict[str, object]) -> dict[str, object]:
if self.model is None:
return dict(fallback_info)
if "associated_feature_infos" in fallback_info:
return dict(fallback_info)
surface = str(fallback_info.get("surface", "") or "")
if surface not in FACE_SELECTION_FEATURE_INFO_SURFACES:
return dict(fallback_info)
@@ -153,7 +246,7 @@ class WindowStateMixin:
info.setdefault("feature_edit_actions", "可查看圆柱直径/半径;复杂语义需要手动扫描或执行计划确认")
elif surface == "cone":
info.setdefault("feature_type", "圆锥面候选")
info.setdefault("feature_edit_actions", "修改圆锥参考半径/直径(整体缩放)")
info.setdefault("feature_edit_actions", "修改圆锥参考半径/直径/半角;程序会按几何选择重建或局部重切")
elif surface == "sphere":
info.setdefault("feature_type", "球面候选")
info.setdefault("feature_edit_actions", "修改球面半径/直径(整体缩放)")
@@ -168,8 +261,139 @@ class WindowStateMixin:
):
if key in fallback_info:
info.setdefault(key, fallback_info[key])
if surface == "plane":
info.update(self._face_first_level_selection_fields(face_id))
return info
def _feature_context_info(self, face_id: int) -> dict[str, object]:
if self.model is None:
return {}
detection_level = self._current_feature_detection_level()
if detection_level == "current-only":
root_info = self._feature_info_for_selected_face(face_id, self.model.quick_face_info(face_id))
else:
root_info = self.model.feature_info(face_id)
if str(root_info.get("surface", "") or "") == "plane":
root_info.update(self._face_first_level_selection_fields(face_id))
associated: list[dict[str, object]] = []
if detection_level in {"associated-only", "secondary"}:
try:
associated = self.model.associated_feature_infos(face_id)
except Exception:
associated = []
if detection_level == "secondary":
associated = self._secondary_associated_feature_infos(face_id, associated)
highlight_ids = set(_int_values(root_info.get("feature_highlight_face_ids")) or [face_id])
for item in associated:
highlight_ids.update(_int_values(item.get("feature_highlight_face_ids")))
source_id = _int_or_none(item.get("association_source_face_id"))
if source_id is not None:
highlight_ids.add(source_id)
level_label = (
"二级特征"
if detection_level == "secondary"
else ("相邻特征" if detection_level == "associated-only" else "当前特征")
)
info = dict(root_info)
info.update(
{
"associated_feature_infos": associated,
"associated_feature_count": len(associated),
"feature_detection_level": level_label,
"associated_feature_face_ids": tuple(
sorted(
{
int(item.get("association_source_face_id", -1))
for item in associated
if _int_or_none(item.get("association_source_face_id")) is not None
}
)
),
"feature_highlight_face_ids": tuple(sorted(highlight_ids)),
"feature_context_note": (
f"已按“{level_label}”沿共享边拓扑探测当前特征及 {len(associated)} 个局部关联特征;关联尺寸可在同一参数表中直接修改。"
if associated
else (
"当前为轻量识别:只读取被点击对象本身,不自动扫描周边拓扑;需要更多关联时可切换到“探测相邻特征”。"
if detection_level == "current-only"
else f"已按“{level_label}”沿共享边拓扑探测局部邻域,未发现额外的可参数化关联特征。"
)
),
}
)
return info
def _current_feature_detection_level(self) -> str:
combo = getattr(self, "feature_detection_combo", None)
if isinstance(combo, NoWheelComboBox):
value = combo.currentData()
if isinstance(value, str) and value:
return value
return str(getattr(self, "feature_detection_level", "current-only") or "current-only")
def _on_feature_detection_level_changed(self) -> None:
self.feature_detection_level = self._current_feature_detection_level()
if self.model is None or self.selected_kind != "feature" or self.selected_face_id is None:
return
try:
info = self._feature_context_info(self.selected_face_id)
except Exception:
return
self.current_info_values = dict(info)
self.current_info_text = "\n".join(f"{INFO_LABELS.get(key, key)}: {_format_value(value)}" for key, value in info.items())
self._refresh_property_editor()
if hasattr(self, "_highlight_faces"):
self._highlight_faces(_int_values(info.get("feature_highlight_face_ids")) or [self.selected_face_id])
self._update_selected_object_title()
def _secondary_associated_feature_infos(
self,
source_face_id: int,
direct_infos: list[dict[str, object]],
) -> list[dict[str, object]]:
if self.model is None:
return list(direct_infos)
results: list[dict[str, object]] = []
seen: set[tuple[str, tuple[int, ...]]] = set()
def add(info: dict[str, object]) -> None:
source_id = _int_or_none(info.get("association_source_face_id"))
if source_id is None or source_id == source_face_id:
return
face_ids = tuple(sorted(_int_values(info.get("feature_highlight_face_ids")) or [source_id]))
identity = (str(info.get("feature_type", "") or info.get("feature_guess", "")), face_ids)
if identity in seen:
return
seen.add(identity)
results.append(dict(info))
for info in direct_infos:
add(info)
for info in list(results):
parent_id = _int_or_none(info.get("association_source_face_id"))
if parent_id is None:
continue
try:
for nested in self.model.associated_feature_infos(
parent_id,
max_depth=2,
max_scan_faces=48,
max_features=6,
):
add(nested)
except Exception:
continue
results.sort(
key=lambda item: (
int(item.get("association_priority", 9)),
int(item.get("association_hop_count", 99)),
int(item.get("association_source_face_id", 0)),
)
)
return results[:14]
def _selection_status(self, message: str, pick_position: tuple[float, float, float] | None) -> str:
if pick_position is None:
return message
@@ -238,7 +462,7 @@ class WindowStateMixin:
)
self._set_control_state(
self.reload_button,
has_loaded_model and not busy,
not busy and bool(getattr(self, "step_path", None)),
"按当前显示路径读取 STEP 模型。",
wait_or_load_tip,
)
@@ -788,13 +1012,22 @@ class WindowStateMixin:
def _update_selected_object_title(self) -> None:
if not hasattr(self, "object_edit_box"):
return
self.object_edit_box.setTitle(f"当前选中对象:{self._selected_object_title_suffix()}")
section_label = "几何对象(高级)" if self.selected_kind in {"face", "edge", "solid", "part"} else "特征参数"
self.object_edit_box.setTitle(f"{section_label}{self._selected_object_title_suffix()}")
def _selected_object_title_suffix(self) -> str:
if self.selected_kind == "part" and self.selected_part_id is not None:
return f"零件 {self.selected_part_id}"
if self.selected_kind == "solid" and self.selected_solid_id is not None:
return f"Solid {self.selected_solid_id}"
if self.selected_kind == "feature" and self.selected_face_id is not None:
feature_label = self._selected_feature_label() or "特征"
confidence = str(self.current_info_values.get("confidence", "") or "")
confidence_label = {"high": "", "medium": "", "low": ""}.get(confidence, "")
confidence_suffix = f"(置信度:{confidence_label}" if confidence_label else ""
related_count = int(self.current_info_values.get("associated_feature_count", 0) or 0)
related_suffix = f" · 关联 {related_count}" if related_count else ""
return f"{feature_label}{confidence_suffix} · 来源 Face {self.selected_face_id}{related_suffix}"
if self.selected_kind == "feature" and self.selected_face_id is not None:
feature_label = self._selected_feature_label()
if feature_label:
@@ -1147,10 +1380,10 @@ class WindowStateMixin:
self.property_expand_button.setVisible(has_hidden_rows)
if expanded:
self.property_expand_button.setText(f"收起到前 {collapsed_rows}")
self.property_expand_button.setToolTip("收起当前选中对象属性表,让面板只保留最常用的前几")
self.property_expand_button.setToolTip("收起参数列表,只保留最常用的前几")
else:
self.property_expand_button.setText(f"展开全部属性 ({row_count} 项)")
self.property_expand_button.setToolTip("展开当前选中对象的完整属性表;参数化建模按钮会继续留在下方。")
self.property_expand_button.setText(f"展开全部参数 ({row_count} 项)")
self.property_expand_button.setToolTip("展开完整参数列表;参数化建模按钮会继续留在下方。")
def toggle_property_table_expanded(self) -> None:
if not hasattr(self, "property_table"):
@@ -1172,6 +1405,8 @@ class WindowStateMixin:
action_info: dict[str, object],
) -> list[dict[str, object]]:
editable_specs, used_keys = self._editable_property_specs(action_info)
if self.selected_kind == "feature":
return self._feature_context_property_specs(editable_specs, action_info)
specs = list(editable_specs)
for key, value in self._ordered_property_info_items(info):
if key in used_keys:
@@ -1180,7 +1415,7 @@ class WindowStateMixin:
{
"key": key,
"label": INFO_LABELS.get(key, key),
"current_text": _format_value(value),
"current_text": _format_info_value(key, value),
"current_raw": value,
"target_text": "",
"editable": False,
@@ -1204,6 +1439,86 @@ class WindowStateMixin:
)
return specs
def _feature_property_specs(
self,
specs: list[dict[str, object]],
action_info: dict[str, object],
) -> list[dict[str, object]]:
allowed_keys = _feature_dimension_keys(action_info)
spec_by_key = {str(spec.get("key", "")): spec for spec in specs}
dimensions: list[dict[str, object]] = []
for key in allowed_keys:
spec = spec_by_key.get(key)
if spec is None or not bool(spec.get("editable")) or not bool(spec.get("enabled")):
continue
dimension = dict(spec)
dimension["parameter_role"] = "dimension"
if action_info.get("prismatic_profile_status") == "candidate":
label_overrides = {
"local_face_width": "长度",
"local_face_height": "宽度",
"shell_thickness_estimate": "高度/深度",
}
if key in label_overrides:
dimension["label"] = label_overrides[key]
dimensions.append(dimension)
explanations = [
dict(spec)
for spec in specs
if str(spec.get("key", "")) in FEATURE_EDIT_SEMANTICS_KEYS
]
if not dimensions:
dimensions.append(
{
"key": "no_editable_feature_dimensions",
"label": "可变尺寸",
"current_text": "未识别到可靠的独立尺寸",
"current_raw": "",
"target_text": "",
"editable": False,
"enabled": False,
"status_text": "说明",
"disabled_tip": (
"当前几何仍可在诊断信息中查看,但不会把面积、中心、包围盒或底层曲面参数"
"伪装成特征设计尺寸。"
),
}
)
return dimensions + explanations
def _feature_context_property_specs(
self,
root_specs: list[dict[str, object]],
action_info: dict[str, object],
) -> list[dict[str, object]]:
root_rows = self._feature_property_specs(root_specs, action_info)
associated = action_info.get("associated_feature_infos")
if not isinstance(associated, (list, tuple)) or not associated:
return root_rows
root_dimensions = [dict(spec) for spec in root_rows if spec.get("parameter_role") == "dimension"]
root_explanations = [dict(spec) for spec in root_rows if spec.get("parameter_role") != "dimension"]
related_rows: list[dict[str, object]] = []
for index, related_info in enumerate(associated, start=1):
if not isinstance(related_info, dict):
continue
related_specs, _used = self._editable_property_specs(related_info)
feature_label = str(related_info.get("feature_type") or related_info.get("feature_guess") or "关联特征")
source_face_id = _int_or_none(related_info.get("association_source_face_id"))
for spec in self._feature_property_specs(related_specs, related_info):
if spec.get("parameter_role") != "dimension":
continue
related = dict(spec)
related["label"] = f"{feature_label} · {spec.get('label', '')}"
related["scope_text"] = f"关联 Face {source_face_id}" if source_face_id is not None else f"关联特征 {index}"
related["source_face_id"] = source_face_id
related["source_feature_info"] = dict(related_info)
related["association_index"] = index
related_rows.append(related)
return root_dimensions + related_rows + root_explanations
def _ordered_property_info_items(self, info: dict[str, object]) -> list[tuple[str, object]]:
items = self._ordered_info_items(info)
if not self._is_feature_like_info(info):
@@ -1456,6 +1771,75 @@ class WindowStateMixin:
return base
return f"{base} 当前不能只改当前面的原因:{local_face_deform_blocker}"
def cone_semi_angle_capability(current_angle_degrees: float | None) -> tuple[bool, str]:
if current_angle_degrees is None:
return True, ""
if not (
has_model
and is_cone
and self.selected_face_id is not None
and hasattr(self.model, "conical_semi_angle_plan")
):
return True, ""
delta = max(1.0, min(5.0, abs(current_angle_degrees) * 0.25))
probe_target = current_angle_degrees + delta
if probe_target >= 89.0:
probe_target = max(0.1, current_angle_degrees - delta)
if abs(probe_target - current_angle_degrees) <= 1e-7:
return False, "当前圆锥半角太接近允许范围边界,不能稳定探测可编辑性。"
try:
plan = self.model.conical_semi_angle_plan(int(self.selected_face_id), probe_target)
except Exception as exc:
return False, f"无法确认当前圆锥半角是否可稳定修改:{exc}"
strategy = str(plan.get("resize_strategy") or "")
if strategy.startswith("analytic-cone-rebuild") or strategy.startswith("bounded-cone-recut"):
return True, ""
if strategy.startswith("blocked-complex-cone-semi-angle") or strategy == "radial-affine-scale-cone-semi-angle":
message = str(plan.get("message") or "").strip()
if message:
return False, message
return (
False,
"当前 Face 是圆锥面/拔模面,但不是简单圆锥,也不是可识别的锥孔/沉孔;"
"当前版本不把它作为稳定的圆锥半角参数开放。",
)
if str(plan.get("status") or "") == "blocked":
return False, str(plan.get("message") or "当前圆锥半角不能稳定修改。")
return True, ""
def cone_reference_radius_capability(current_radius: float | None) -> tuple[bool, str]:
if current_radius is None or current_radius <= 0:
return True, ""
if not (
has_model
and is_cone
and self.selected_face_id is not None
and hasattr(self.model, "conical_reference_radius_plan")
):
return True, ""
probe_target = current_radius * 1.05
try:
plan = self.model.conical_reference_radius_plan(int(self.selected_face_id), probe_target)
except Exception as exc:
return False, f"无法确认当前圆锥参考半径是否可稳定修改:{exc}"
strategy = str(plan.get("resize_strategy") or "")
if strategy.startswith("analytic-cone-rebuild") or strategy.startswith("bounded-cone-recut"):
return True, ""
if strategy.startswith("blocked-complex-cone-reference-radius") or strategy.startswith(
"blocked-cone-reference-radius"
) or strategy == "radial-affine-scale-cone-reference-radius":
message = str(plan.get("message") or "").strip()
if message:
return False, message
return (
False,
"当前 Face 是圆锥面/拔模面,但不是简单圆锥,也不是可识别的锥孔/沉孔;"
"当前版本不把它作为稳定的参考半径/直径参数开放。",
)
if str(plan.get("status") or "") == "blocked":
return False, str(plan.get("message") or "当前圆锥参考半径不能稳定修改。")
return True, ""
def add_spec(
*,
key: str,
@@ -1641,6 +2025,26 @@ class WindowStateMixin:
),
)
elif is_plane or is_shell_candidate:
topology_depth = _int_or_none(action_info.get("topology_relation_depth"))
if topology_depth == 1:
same_domain_count = _int_or_none(action_info.get("same_domain_face_count")) or 0
boundary_edge_count = _int_or_none(action_info.get("first_level_boundary_edge_count")) or 0
boundary_vertex_count = _int_or_none(action_info.get("first_level_boundary_vertex_count")) or 0
adjacent_face_count = _int_or_none(action_info.get("first_level_adjacent_face_count")) or 0
topology_note = str(action_info.get("first_level_topology_note") or "").strip()
ignored_note = str(action_info.get("topology_ignored_relation_note") or "").strip()
topology_tip = "\n".join(item for item in (topology_note, ignored_note) if item) or (
"当前阶段只处理当前 Face、同域碎片、边界 Edge/Vertex 和共享边相邻 Face;二级、三级关系暂不自动传播。"
)
add_readonly_spec(
key="face_first_level_topology",
label="一级关系",
text=(
f"Face 区域 {same_domain_count} 个;边界 Edge {boundary_edge_count} 条;"
f"边界 Vertex {boundary_vertex_count} 个;共享边相邻 Face {adjacent_face_count} 个。"
),
tip=topology_tip,
)
face_semantics_text = "Face:先改目标值,再用“影响范围”选择改当前面、推拉或调整整个特征。"
face_semantics_tip = (
"面积是面的大小;面宽/面高是这个面自身平面里的两个方向尺寸;"
@@ -2896,33 +3300,49 @@ class WindowStateMixin:
current_reference_diameter = (
current_reference_radius * 2.0 if current_reference_radius is not None else None
)
reference_radius_supported, reference_radius_disabled_reason = cone_reference_radius_capability(
current_reference_radius
)
reference_radius_enabled = current_reference_radius is not None and reference_radius_supported
add_spec(
key="cone_reference_radius",
label="参考半径(整体)",
label="参考半径",
current_raw=current_reference_radius if current_reference_radius is not None else "",
target_text=numeric_text(current_reference_radius),
action="resize_cone_reference_radius",
target_attr="cone_reference_radius_input",
enabled=current_reference_radius is not None,
enabled_tip="输入圆锥面的目标参考半径;程序会围绕圆锥轴径向缩放所属对象",
disabled_tip="当前圆锥面缺少稳定参考半径,不能直接修改。",
enabled=reference_radius_enabled,
enabled_tip="输入圆锥面的目标参考半径;简单圆锥会解析重建,嵌入式锥孔会优先局部重切",
disabled_tip=(
reference_radius_disabled_reason
or "当前圆锥面缺少稳定参考半径,不能直接修改。"
),
value_type="positive",
range_hint=f"这不是只替换单个圆锥面的历史参数;同一对象上的其它径向尺寸会跟随变化。 {relative_range_hint(current_reference_radius, 0.25, 0.6)}",
range_hint=(
"简单圆锥会解析重建;嵌入式锥孔会优先只重切锥孔。"
f"复杂圆锥/拔模面暂不使用整体缩放兜底。 {relative_range_hint(current_reference_radius, 0.25, 0.6)}"
),
used=("reference_radius",),
**positive_minimum(),
)
add_spec(
key="cone_reference_diameter",
label="参考直径(整体)",
label="参考直径",
current_raw=current_reference_diameter if current_reference_diameter is not None else "",
target_text=numeric_text(current_reference_diameter),
action="resize_cone_reference_radius",
target_attr="cone_reference_radius_input",
enabled=current_reference_diameter is not None,
enabled_tip="输入圆锥面的目标参考直径;程序会换算为参考半径后围绕圆锥轴径向缩放所属对象",
disabled_tip="当前圆锥面缺少稳定参考直径,不能直接修改。",
enabled=current_reference_diameter is not None and reference_radius_supported,
enabled_tip="输入圆锥面的目标参考直径;程序会换算为参考半径后选择解析重建或锥孔局部重切",
disabled_tip=(
reference_radius_disabled_reason
or "当前圆锥面缺少稳定参考直径,不能直接修改。"
),
value_type="positive",
range_hint=f"这不是只替换单个圆锥面的历史参数;同一对象上的其它径向尺寸会跟随变化。 {relative_range_hint(current_reference_diameter, 0.25, 0.6)}",
range_hint=(
"简单圆锥会解析重建;嵌入式锥孔会优先只重切锥孔。"
f"复杂圆锥/拔模面暂不使用整体缩放兜底。 {relative_range_hint(current_reference_diameter, 0.25, 0.6)}"
),
target_transform="diameter_to_radius",
used=("feature_reference_diameter",),
**positive_minimum(),
@@ -2931,25 +3351,32 @@ class WindowStateMixin:
current_semi_angle_degrees = (
abs(math.degrees(current_semi_angle)) if current_semi_angle is not None else None
)
semi_angle_supported, semi_angle_disabled_reason = cone_semi_angle_capability(current_semi_angle_degrees)
semi_angle_enabled = (
current_reference_radius is not None
and current_semi_angle is not None
and semi_angle_supported
)
add_spec(
key="cone_semi_angle_degrees",
label="半角(整体)",
label="圆锥半角",
current_raw=current_semi_angle_degrees if current_semi_angle_degrees is not None else "",
target_text=numeric_text(current_semi_angle_degrees),
action="resize_cone_reference_radius",
action="resize_cone_semi_angle",
target_attr="cone_reference_radius_input",
enabled=current_reference_radius is not None and current_semi_angle is not None,
enabled_tip="输入圆锥面的目标半角,单位是度;程序会换算为目标参考半径后围绕圆锥轴径向缩放所属对象",
disabled_tip="当前圆锥面缺少稳定参考半径或半角,不能直接修改半角。",
enabled=semi_angle_enabled,
enabled_tip="输入圆锥面的目标半角,单位是度;简单圆锥会解析重建,嵌入式锥孔会优先局部重切",
disabled_tip=(
semi_angle_disabled_reason
or "当前圆锥面缺少稳定参考半径或半角,不能直接修改半角。"
),
value_type="positive",
range_hint="这是整体径向缩放所属对象,不是只替换单个圆锥面的历史半角参数;建议先小幅修改,当前版本要求半角大于 0 且小于 89 度。",
range_hint=(
"简单圆锥会解析重建;嵌入式锥孔会优先保持小端半径和深度,只改变锥孔开口。"
"复杂圆锥/拔模面暂不使用整体缩放兜底。当前版本要求半角大于 0 且小于 89 度。"
),
max_value=89.0,
max_exclusive=True,
target_transform="cone_semi_angle_degrees_to_reference_radius",
transform_context={
"current_reference_radius": current_reference_radius,
"current_semi_angle": current_semi_angle,
},
used=("semi_angle",),
**positive_minimum(),
)
@@ -3971,6 +4398,31 @@ class WindowStateMixin:
row, _spec, _text = changed[0]
self.apply_property_row_edit(row)
def _activate_property_source_feature(self, spec: dict[str, object]) -> None:
source_face_id = _int_or_none(spec.get("source_face_id"))
if source_face_id is None or source_face_id == self.selected_face_id:
return
if self.model is None or source_face_id < 0 or source_face_id >= len(self.model.faces):
raise ValueError("关联特征已经失效,请重新选择模型对象。")
info = spec.get("source_feature_info")
if not isinstance(info, dict):
info = self._feature_context_info(source_face_id)
else:
info = dict(info)
self.selected_kind = "feature"
self.selected_face_id = source_face_id
self.selected_edge_id = None
self.selected_part_id = int(info.get("part_id", self.model.face_part_ids[source_face_id]))
self.selected_solid_id = int(info.get("solid_id", self.model.face_solid_ids[source_face_id]))
self.current_info_values = dict(info)
self.current_info_text = "\n".join(
f"{INFO_LABELS.get(key, key)}: {_format_info_value(key, value)}" for key, value in info.items()
)
self._sync_id_picker("Feature", source_face_id)
if hasattr(self, "_highlight_faces"):
self._highlight_faces(_int_values(info.get("feature_highlight_face_ids")) or [source_face_id])
self._update_selected_object_title()
def apply_property_row_edit(self, row: int) -> None:
specs = getattr(self, "property_editor_specs", [])
if row < 0 or row >= len(specs):
@@ -3990,6 +4442,7 @@ class WindowStateMixin:
QMessageBox.information(self, "目标值无效", validation_error)
return
try:
self._activate_property_source_feature(spec)
if not is_command:
self._sync_property_edit_target(spec, text)
self._sync_property_preselects(spec)