feat: 完善一级关系参数化建模与参数导出

This commit is contained in:
2026-08-11 18:28:16 +08:00
parent 6cb99a1273
commit 19364d81b5
20 changed files with 917 additions and 167 deletions
+52 -17
View File
@@ -54,7 +54,7 @@ from .info_panel import InfoPanelMixin
from .ui_helpers import * # noqa: F403
from .window_actions import WindowActionMixin
from .window_core import WindowCoreMixin
from .window_state import WindowStateMixin
from .window_state import PROPERTY_TABLE_HEADERS, WindowStateMixin
_CRASH_LOG_HANDLE = None
@@ -145,7 +145,7 @@ def _isolated_edit_worker_request(argv: list[str]) -> Path | None:
class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, InfoPanelMixin, QMainWindow):
ui_task_requested = Signal(object)
def __init__(self, step_path: str | Path, *, background_load: bool = False):
def __init__(self, step_path: str | Path | None = None, *, background_load: bool = False):
_suppress_vtk_output_window()
super().__init__()
self.ui_task_requested.connect(self._run_ui_task, Qt.ConnectionType.QueuedConnection)
@@ -154,7 +154,7 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
self.resize(1280, 820)
self.model: StepModel | None = None
self.step_path = Path(step_path)
self.step_path = Path(step_path) if step_path else None
self.selected_kind: str | None = None
self.selected_part_id: int | None = None
self.selected_solid_id: int | None = None
@@ -585,6 +585,32 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
color: #8f99a8;
font-weight: 650;
}
QPushButton#exportParametersButton {
background: #f0fdfa;
border: 1px solid #0f766e;
border-bottom-color: #115e59;
border-radius: 6px;
color: #134e4a;
font-weight: 750;
min-height: 30px;
padding: 6px 11px;
}
QPushButton#exportParametersButton:hover {
background: #ccfbf1;
border-color: #0d9488;
}
QPushButton#exportParametersButton:pressed {
background: #99f6e4;
border-color: #0f766e;
padding-top: 7px;
padding-bottom: 5px;
}
QPushButton#exportParametersButton:disabled {
background: #eef2f6;
border: 1px dashed #bcc7d4;
color: #8f99a8;
font-weight: 650;
}
QPushButton#propertyRowEditButton {
background: #ea580c;
border: 1px solid #c2410c;
@@ -885,12 +911,13 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
self.open_button.setMinimumWidth(96)
help_tip(self.open_button, "选择并导入一个 .step 或 .stp 几何模型。打开失败时会保留当前模型。")
self.open_button.clicked.connect(self.open_step)
self.path_label = QLineEdit(str(self.step_path))
self.path_label = QLineEdit(str(self.step_path) if self.step_path is not None else "")
self.path_label.setObjectName("stepPathDisplay")
self.path_label.setReadOnly(True)
self.path_label.setMinimumWidth(120)
help_tip(self.path_label, "当前 STEP 文件的完整路径。可以选中文字复制路径。")
self.path_label.setToolTip(str(self.step_path))
self.path_label.setPlaceholderText("未选择 STEP 文件")
self.path_label.setToolTip(str(self.step_path) if self.step_path is not None else "未选择 STEP 文件")
self.path_label.setCursorPosition(0)
self.reload_button = QPushButton("读取模型")
self.reload_button.setMinimumWidth(78)
@@ -1086,7 +1113,7 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
export_box.setObjectName("exportSection")
help_tip(export_box, "把当前模型或选中对象导出为 STEP,也可以做基础质量检查和修复。")
export_layout = QVBoxLayout(export_box)
self.export_all_button = QPushButton("导出当前完整 STEP")
self.export_all_button = QPushButton("导出模型")
help_tip(self.export_all_button, "把当前编辑后的整个模型导出为 STEP 文件。导出前会做基础质量检查。")
self.export_all_button.clicked.connect(self.export_all)
self.export_part_button = QPushButton("导出选中零件")
@@ -1132,9 +1159,9 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
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, 4, self.object_edit_box)
self.property_table = QTableWidget(0, len(PROPERTY_TABLE_HEADERS), self.object_edit_box)
self.property_table.setObjectName("propertyTable")
self.property_table.setHorizontalHeaderLabels(["尺寸参数", "当前值", "建模意图", "目标值"])
self.property_table.setHorizontalHeaderLabels(list(PROPERTY_TABLE_HEADERS))
self.property_table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
self.property_table.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
self.property_table.setAlternatingRowColors(True)
@@ -1153,11 +1180,12 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
property_header.setSectionResizeMode(1, QHeaderView.ResizeMode.Fixed)
property_header.setSectionResizeMode(2, QHeaderView.ResizeMode.Fixed)
property_header.setSectionResizeMode(3, QHeaderView.ResizeMode.Fixed)
property_header.setSectionResizeMode(3, QHeaderView.ResizeMode.Stretch)
self.property_table.setColumnWidth(0, 148)
self.property_table.setColumnWidth(1, 92)
self.property_table.setColumnWidth(2, 112)
self.property_table.setColumnWidth(3, 96)
property_header.setSectionResizeMode(4, QHeaderView.ResizeMode.Fixed)
self.property_table.setColumnWidth(0, 112)
self.property_table.setColumnWidth(1, 104)
self.property_table.setColumnWidth(2, 88)
self.property_table.setColumnWidth(3, 82)
self.property_table.setColumnWidth(4, 66)
self.property_table.installEventFilter(self)
help_tip(
self.property_table,
@@ -1217,14 +1245,21 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
self.apply_property_button.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
help_tip(self.apply_property_button, "应用当前被修改的一个参数;一次只执行一个几何修改,成功后可撤销。")
self.apply_property_button.clicked.connect(self.apply_current_property_edit)
self.quick_export_all_button = QPushButton("导出当前完整STEP")
self.quick_export_all_button = QPushButton("导出模型")
self.quick_export_all_button.setObjectName("quickExportStepButton")
self.quick_export_all_button.setMinimumHeight(34)
self.quick_export_all_button.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
help_tip(self.quick_export_all_button, "把当前编辑后的完整模型导出为 STEP 文件。导出前会做基础质量检查。")
self.quick_export_all_button.clicked.connect(self.export_all)
self.export_parameters_button = QPushButton("导出参数")
self.export_parameters_button.setObjectName("exportParametersButton")
self.export_parameters_button.setMinimumHeight(34)
self.export_parameters_button.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
help_tip(self.export_parameters_button, "把已勾选为输入参数的尺寸行导出为 data.json。")
self.export_parameters_button.clicked.connect(self.export_selected_parameters)
property_action_row.addWidget(self.apply_property_button)
property_action_row.addWidget(self.quick_export_all_button)
property_action_row.addWidget(self.export_parameters_button)
object_edit_layout.addLayout(property_action_row)
edit_box = QGroupBox(panel)
@@ -1741,10 +1776,10 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
def _parse_args(argv: list[str]) -> tuple[Path, bool]:
def _parse_args(argv: list[str]) -> tuple[Path | None, bool]:
smoke_test = "--smoke-test" in argv
paths = [arg for arg in argv[1:] if not arg.startswith("--")]
path = Path(paths[0]) if paths else DEFAULT_MODEL_PATH
path = Path(paths[0]) if paths else None
return path, smoke_test
@@ -1764,7 +1799,7 @@ def main() -> int:
app.setApplicationDisplayName("几何参数化")
app.setOrganizationName("GeometryParametric")
app.setWindowIcon(_application_icon())
window = StepEditorWindow(path, background_load=not smoke_test)
window = StepEditorWindow(path, background_load=bool(path) and not smoke_test)
if smoke_test:
print("smoke test ok")
window.close()
+4 -1
View File
@@ -61,7 +61,7 @@ from OCC.Extend.TopologyUtils import TopologyExplorer, discretize_edge
from .constants import CURVE_TYPES, SNAPSHOT_FACE_LOGICAL_IDS_KEY, SURFACE_TYPES
from .geometry_utils import * # noqa: F403
from .step_io import _prepare_shape_for_step_export, _write_step
from .step_io import _prepare_shape_for_step_export, _write_brep, _write_step
class ExportMixin:
@@ -71,6 +71,9 @@ class ExportMixin:
)
_write_step(export_shape, Path(filename))
def export_internal_brep(self, filename: str | Path) -> None:
_write_brep(self.shape, Path(filename))
def export_quality_info(self, scope: str, target_id: int | None = None) -> dict[str, object]:
if scope == "all":
return _shape_quality_info("当前完整模型", self.shape, expect_solid=False)
+36 -6
View File
@@ -288,6 +288,19 @@ class FeatureMixin:
max_scan_edges: int | None = None,
progress_callback: Callable[[], None] | None = None,
) -> list[dict[str, object]]:
limit = max(1, int(limit))
normalized_max_faces = None if max_scan_faces is None else max(0, int(max_scan_faces))
normalized_max_edges = None if max_scan_edges is None else max(0, int(max_scan_edges))
cache_key = (
"editable",
limit,
bool(detailed),
normalized_max_faces,
normalized_max_edges,
)
cache = getattr(self, "_editable_feature_candidates_cache", None)
if isinstance(cache, dict) and cache_key in cache:
return [dict(item) for item in cache[cache_key]]
per_type_limit = max(1, limit // 5)
candidates: list[dict[str, object]] = []
@@ -315,7 +328,7 @@ class FeatureMixin:
for item in self.cylindrical_feature_candidates(
limit=cylinder_scan_limit,
include_end_info=True,
max_scan_faces=max_scan_faces,
max_scan_faces=normalized_max_faces,
progress_callback=progress_callback,
):
feature_guess = str(item["feature_guess"])
@@ -680,7 +693,7 @@ class FeatureMixin:
shell_thickness_limit = max(2, min(per_type_limit, limit // 10))
shell_candidate_rows: list[dict[str, object]] = []
shell_scan_pool_limit = max(shell_thickness_limit * 4, 12)
face_scan_limit = len(self.faces) if max_scan_faces is None else min(len(self.faces), max(0, int(max_scan_faces)))
face_scan_limit = len(self.faces) if normalized_max_faces is None else min(len(self.faces), normalized_max_faces)
for face_id, face in enumerate(self.faces[:face_scan_limit]):
if progress_callback is not None and face_id % 30 == 0:
progress_callback()
@@ -832,7 +845,7 @@ class FeatureMixin:
ellipse_edge_major_radius_count = 0
ellipse_edge_minor_radius_count = 0
edge_type_limit = max(1, per_type_limit // 3)
edge_scan_limit = len(self.edges) if max_scan_edges is None else min(len(self.edges), max(0, int(max_scan_edges)))
edge_scan_limit = len(self.edges) if normalized_max_edges is None else min(len(self.edges), normalized_max_edges)
for edge_id, edge in enumerate(self.edges[:edge_scan_limit]):
if progress_callback is not None and edge_id % 80 == 0:
progress_callback()
@@ -995,7 +1008,10 @@ class FeatureMixin:
for candidate in candidates:
candidate["recognition_user_priority"] = feature_recognition_sort_key(candidate)[0]
candidates.sort(key=feature_recognition_sort_key)
return candidates[:limit]
result = [dict(item) for item in candidates[:limit]]
if isinstance(cache, dict):
cache[cache_key] = [dict(item) for item in result]
return [dict(item) for item in result]
def cylindrical_feature_candidates(
self,
@@ -1004,8 +1020,19 @@ class FeatureMixin:
max_scan_faces: int | None = None,
progress_callback: Callable[[], None] | None = None,
) -> list[dict[str, object]]:
limit = max(1, int(limit))
normalized_max_faces = None if max_scan_faces is None else max(0, int(max_scan_faces))
cache_key = (
"cylinder",
limit,
bool(include_end_info),
normalized_max_faces,
)
cache = getattr(self, "_cylindrical_feature_candidates_cache", None)
if isinstance(cache, dict) and cache_key in cache:
return [dict(item) for item in cache[cache_key]]
candidates: list[dict[str, object]] = []
face_scan_limit = len(self.faces) if max_scan_faces is None else min(len(self.faces), max(0, int(max_scan_faces)))
face_scan_limit = len(self.faces) if normalized_max_faces is None else min(len(self.faces), normalized_max_faces)
for face_id, face in enumerate(self.faces[:face_scan_limit]):
if progress_callback is not None and face_id % 30 == 0:
progress_callback()
@@ -1075,7 +1102,10 @@ class FeatureMixin:
candidates.append(candidate)
if len(candidates) >= limit:
break
return candidates
result = [dict(item) for item in candidates]
if isinstance(cache, dict):
cache[cache_key] = [dict(item) for item in result]
return [dict(item) for item in result]
def cylindrical_resize_plan(self, face_id: int, new_diameter: float) -> dict[str, object]:
if face_id < 0 or face_id >= len(self.faces):
+10
View File
@@ -625,7 +625,16 @@ def _axis_aligned_edge_candidates(
return [edge for _score, edge in candidates]
def _enable_occt_builder_parallel(builder) -> None:
if hasattr(builder, "SetRunParallel"):
try:
builder.SetRunParallel(True)
except Exception:
pass
def _finalize_boolean_result(op, operation_name: str, *, use_glue: bool | None = None) -> TopoDS_Shape:
_enable_occt_builder_parallel(op)
op.SetNonDestructive(True)
glue_enabled = "cut" not in operation_name.lower() if use_glue is None else bool(use_glue)
if glue_enabled and hasattr(op, "SetGlue"):
@@ -668,6 +677,7 @@ def _simplify_boolean_builder(builder) -> None:
def _finalize_builder_result(builder, operation_name: str) -> TopoDS_Shape:
_enable_occt_builder_parallel(builder)
builder.Build()
if hasattr(builder, "IsDone") and not builder.IsDone():
raise RuntimeError(f"{operation_name} operation failed.")
+23 -2
View File
@@ -144,6 +144,24 @@ def _execute(model: StepModel, operation: str, args: list[object]) -> str:
raise ValueError(f"Unsupported isolated edit operation: {operation}")
def _load_request_model(path: Path, file_format: str) -> StepModel:
if file_format == "brep":
return StepModel.load_internal_brep(path)
if file_format == "step":
return StepModel.load(path)
raise ValueError(f"Unsupported isolated edit input format: {file_format}")
def _export_response_model(model: StepModel, path: Path, file_format: str) -> None:
if file_format == "brep":
model.export_internal_brep(path)
return
if file_format == "step":
model.export_all(path)
return
raise ValueError(f"Unsupported isolated edit output format: {file_format}")
def run_request(request: str | Path) -> int:
request_path = Path(request)
response_path = request_path.with_suffix(".response.json")
@@ -153,10 +171,12 @@ def run_request(request: str | Path) -> int:
output_path = Path(str(request["output_path"]))
operation = str(request["operation"])
args = list(request.get("args") or [])
input_format = str(request.get("input_format") or "step").strip().lower()
output_format = str(request.get("output_format") or input_format).strip().lower()
model = StepModel.load(input_path)
model = _load_request_model(input_path, input_format)
message = _execute(model, operation, args)
model.export_all(output_path)
_export_response_model(model, output_path, output_format)
response_path.write_text(
json.dumps(
{
@@ -164,6 +184,7 @@ def run_request(request: str | Path) -> int:
"message": message,
"stats": model.stats().__dict__,
"output_path": str(output_path),
"output_format": output_format,
},
ensure_ascii=False,
indent=2,
+19
View File
@@ -90,6 +90,7 @@ from .transforms import TransformMixin
from .geometry_utils import * # noqa: F403
from .model_types import PartNode, TopologyStats
from .step_io import (
_load_brep_shape,
_load_plain_step,
_load_with_xcaf,
_parse_product_names,
@@ -130,6 +131,8 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
self._edge_duplicate_key_ids_cache: dict[tuple[object, ...], list[int]] | None = None
self._same_domain_internal_edge_ids_cache: set[int] | None = None
self._same_domain_duplicate_edge_ids_cache: set[int] | None = None
self._editable_feature_candidates_cache: dict[tuple[object, ...], list[dict[str, object]]] = {}
self._cylindrical_feature_candidates_cache: dict[tuple[object, ...], list[dict[str, object]]] = {}
self._face_polydata_cache: dict[tuple[object, ...], object] = {}
self._edge_polydata_cache: dict[tuple[object, ...], object] = {}
self._polydata_cache_limit = 96
@@ -137,6 +140,13 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
self._face_mesh_deflections: dict[int, float] = {}
self.refresh_topology()
@classmethod
def from_shape(cls, filename: str | Path, shape: TopoDS_Shape, *, name: str | None = None) -> "StepModel":
path = Path(filename)
part_name = name or path.stem or "model"
parts = [PartNode(1, part_name, "part", shape, path=part_name)]
return cls(path, parts, shape)
@classmethod
def load(cls, filename: str | Path) -> "StepModel":
path = Path(filename)
@@ -151,6 +161,13 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
parts = [PartNode(1, fallback_name, "part", whole_shape, path=fallback_name)]
return cls(path, parts, whole_shape)
@classmethod
def load_internal_brep(cls, filename: str | Path) -> "StepModel":
path = Path(filename)
if not path.exists():
raise FileNotFoundError(path)
return cls.from_shape(path, _load_brep_shape(path), name=path.stem)
def display_parts(self) -> list[PartNode]:
leaf_parts = [p for p in self.parts if p.kind == "part" and not p.shape.IsNull()]
if leaf_parts:
@@ -220,6 +237,8 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
self._edge_duplicate_key_ids_cache = None
self._same_domain_internal_edge_ids_cache = None
self._same_domain_duplicate_edge_ids_cache = None
self._editable_feature_candidates_cache.clear()
self._cylindrical_feature_candidates_cache.clear()
self._face_polydata_cache.clear()
self._edge_polydata_cache.clear()
self._mesh_deflection = None
+20
View File
@@ -4,6 +4,8 @@ import re
from pathlib import Path
from OCC.Core.BRepBuilderAPI import BRepBuilderAPI_Transform
from OCC.Core.BRep import BRep_Builder
from OCC.Core.BRepTools import breptools
from OCC.Core.IFSelect import IFSelect_RetDone
from OCC.Core.Interface import Interface_Static
from OCC.Core.STEPCAFControl import STEPCAFControl_Reader
@@ -126,6 +128,24 @@ def _write_step(shape: TopoDS_Shape, filename: Path) -> None:
raise IOError(f"Could not write STEP file: {filename}")
def _load_brep_shape(filename: str | Path) -> TopoDS_Shape:
path = Path(filename)
shape = TopoDS_Shape()
builder = BRep_Builder()
if not breptools.Read(shape, str(path), builder) or shape.IsNull():
raise IOError(f"Could not read BREP file: {path}")
return shape
def _write_brep(shape: TopoDS_Shape, filename: str | Path) -> None:
if shape.IsNull():
raise ValueError("Cannot export a null shape.")
path = Path(filename)
path.parent.mkdir(parents=True, exist_ok=True)
if not breptools.Write(shape, str(path)):
raise IOError(f"Could not write BREP file: {path}")
def _prepare_shape_for_step_export(shape: TopoDS_Shape) -> TopoDS_Shape:
if shape.IsNull():
return shape
+165 -19
View File
@@ -7,9 +7,10 @@ from pathlib import Path
import subprocess
import sys
import tempfile
import time
import vtk
from PySide6.QtCore import Qt, QThread, Slot
from PySide6.QtCore import Qt, QThread, QTimer, Slot
from PySide6.QtWidgets import (
QApplication,
QFileDialog,
@@ -84,7 +85,78 @@ def _compact_user_message(value: object, limit: int = 360) -> str:
return f"{text[: max(0, limit - 1)].rstrip()}..."
def _edit_timing_summary(timings: object, *, limit: int = 5) -> str:
if not isinstance(timings, dict) or not timings:
return ""
labels = {
"snapshot": "快照",
"precheck": "预检",
"edit_geometry": "几何计算",
"isolated_export": "内部传模",
"isolated_worker": "子进程计算",
"isolated_result_load": "读取结果",
"validate": "结果校验",
"display_faces": "面显示",
"display_edges": "边线",
"finish_ui": "界面刷新",
"total": "总计",
}
rows: list[tuple[str, float]] = []
for key, value in timings.items():
if key == "total":
continue
try:
seconds = float(value)
except (TypeError, ValueError):
continue
if seconds >= 0.05:
rows.append((labels.get(str(key), str(key)), seconds))
rows.sort(key=lambda item: item[1], reverse=True)
parts = [f"{label} {seconds:.1f}s" for label, seconds in rows[:limit]]
try:
total = float(timings.get("total"))
except (TypeError, ValueError):
total = 0.0
if total >= 0.05:
parts.append(f"总计 {total:.1f}s")
return "".join(parts)
class WindowActionMixin:
def _empty_edge_polydata(self):
polydata = vtk.vtkPolyData()
polydata.SetPoints(vtk.vtkPoints())
polydata.SetLines(vtk.vtkCellArray())
edge_arr = vtk.vtkIntArray()
edge_arr.SetName("edge_id")
part_arr = vtk.vtkIntArray()
part_arr.SetName("part_id")
polydata.GetCellData().AddArray(edge_arr)
polydata.GetCellData().AddArray(part_arr)
return polydata
def _parameter_export_output_path(self) -> Path:
return Path(__file__).resolve().parent.parent / "data.json"
def export_selected_parameters(self) -> None:
rows = self._selected_parameter_export_rows() if hasattr(self, "_selected_parameter_export_rows") else []
if not rows:
if hasattr(self, "_update_parameter_export_state"):
self._update_parameter_export_state()
self.statusBar().showMessage("请先在“输入参数”列勾选至少一个尺寸参数。")
return
output_path = self._parameter_export_output_path()
try:
output_path.write_text(json.dumps(rows, ensure_ascii=False, indent=4), encoding="utf-8")
except OSError as exc:
QMessageBox.warning(self, "导出参数失败", f"无法写入 {output_path.name}{exc}")
return
self.statusBar().showMessage(f"已导出 {len(rows)} 个输入参数到 {output_path.name}")
if hasattr(self, "set_plain_info"):
names = "".join(str(row.get("displayName", "")) for row in rows[:8] if row.get("displayName"))
suffix = "……" if len(rows) > 8 else ""
self.set_plain_info(f"已导出参数文件:{output_path}\n参数数量:{len(rows)}\n参数:{names}{suffix}")
def export_all(self) -> None:
if self.model is None:
return
@@ -92,10 +164,11 @@ class WindowActionMixin:
return
if not self._confirm_export_quality("all"):
return
source_path = self.step_path if isinstance(getattr(self, "step_path", None), Path) else Path.cwd() / "model.step"
target, _ = QFileDialog.getSaveFileName(
self,
"导出当前完整 STEP",
str(self.step_path.parent / f"{self.step_path.stem}_edited.step"),
"导出模型",
str(source_path.parent / f"{source_path.stem}_edited.step"),
"STEP 文件 (*.step *.stp);;所有文件 (*.*)",
)
if not target:
@@ -7651,6 +7724,7 @@ class WindowActionMixin:
"pick_position": self.selected_pick_position,
"show_same_domain_internal_edges": self._show_same_domain_internal_edges(),
"edit_result_deflection": result_deflection,
"defer_edge_polydata": True,
"isolation": dict(isolation or {}),
}
blocker = self._edit_preflight_blocker(context)
@@ -7691,12 +7765,16 @@ class WindowActionMixin:
def job():
if self.model is None:
raise RuntimeError("Model is not loaded.")
timings: dict[str, float] = {}
total_started = time.perf_counter()
started = time.perf_counter()
snapshot = self.model.snapshot()
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 = {}
timings["snapshot"] = time.perf_counter() - started
isolation = context.get("isolation")
if isinstance(isolation, dict) and isolation:
return self._run_isolated_edit_job(
@@ -7707,9 +7785,14 @@ class WindowActionMixin:
before_part_stats=before_part_stats,
before_quality=before_quality,
before_geometry=before_geometry,
base_timings=timings,
total_started=total_started,
)
try:
started = time.perf_counter()
result = action()
timings["edit_geometry"] = time.perf_counter() - started
started = time.perf_counter()
after_snapshot = self.model.snapshot()
after_stats = self.model.stats()
after_part_stats = self._part_stats_or_none(target_part_id)
@@ -7724,6 +7807,7 @@ class WindowActionMixin:
after_quality,
after_model=self.model,
)
timings["validate"] = time.perf_counter() - started
after_geometry = {}
except Exception as exc:
try:
@@ -7738,17 +7822,27 @@ class WindowActionMixin:
) from exc
model_polydata = None
edge_polydata = None
edge_deferred = bool(context.get("defer_edge_polydata", True))
try:
deflection = float(context.get("edit_result_deflection", 1.6))
started = time.perf_counter()
face_polydata = self.model.build_face_polydata(deflection=deflection)
model_polydata = _smooth_surface_polydata(face_polydata)
edge_polydata = self.model.build_edge_polydata(
deflection=deflection,
show_same_domain_internal_edges=bool(context.get("show_same_domain_internal_edges", False))
)
timings["display_faces"] = time.perf_counter() - started
if edge_deferred:
edge_polydata = self._empty_edge_polydata()
timings["display_edges"] = 0.0
else:
started = time.perf_counter()
edge_polydata = self.model.build_edge_polydata(
deflection=deflection,
show_same_domain_internal_edges=bool(context.get("show_same_domain_internal_edges", False)),
)
timings["display_edges"] = time.perf_counter() - started
except Exception:
model_polydata = None
edge_polydata = None
timings["total"] = time.perf_counter() - total_started
return {
"message": str(result),
"snapshot": snapshot,
@@ -7762,6 +7856,8 @@ class WindowActionMixin:
"after_geometry": after_geometry,
"model_polydata": model_polydata,
"edge_polydata": edge_polydata,
"edge_polydata_deferred": edge_deferred,
"timings": timings,
}
return job
@@ -7776,9 +7872,14 @@ class WindowActionMixin:
before_part_stats,
before_quality: dict[str, object] | None,
before_geometry: dict[str, object],
base_timings: dict[str, float] | None = None,
total_started: float | None = None,
) -> dict[str, object]:
if self.model is None:
raise RuntimeError("Model is not loaded.")
timings = dict(base_timings or {})
if total_started is None:
total_started = time.perf_counter()
target_part_id = self._edit_context_part_id(context)
timeout_seconds = float(isolation.get("timeout_seconds") or 180.0)
operation = str(isolation.get("operation") or "").strip()
@@ -7789,15 +7890,26 @@ class WindowActionMixin:
project_root = Path(__file__).resolve().parent.parent
with tempfile.TemporaryDirectory(prefix="geom_param_isolated_edit_") as temp_dir:
temp_root = Path(temp_dir)
input_path = temp_root / "input.step"
output_path = temp_root / "output.step"
exchange_format = str(isolation.get("exchange_format") or "brep").strip().lower()
if exchange_format not in {"brep", "step"}:
exchange_format = "brep"
suffix = ".brep" if exchange_format == "brep" else ".step"
input_path = temp_root / f"input{suffix}"
output_path = temp_root / f"output{suffix}"
request_path = temp_root / "request.json"
self.model.export_all(input_path)
started = time.perf_counter()
if exchange_format == "brep":
self.model.export_internal_brep(input_path)
else:
self.model.export_all(input_path)
timings["isolated_export"] = time.perf_counter() - started
request_path.write_text(
json.dumps(
{
"input_path": str(input_path),
"output_path": str(output_path),
"input_format": exchange_format,
"output_format": exchange_format,
"operation": operation,
"args": args,
},
@@ -7811,6 +7923,7 @@ class WindowActionMixin:
self.isolated_edit_cancel_requested = False
process: subprocess.Popen[str] | None = None
try:
started = time.perf_counter()
process = subprocess.Popen(
command,
cwd=project_root,
@@ -7823,6 +7936,7 @@ class WindowActionMixin:
self.active_isolated_edit_process = process
stdout, stderr = process.communicate(timeout=timeout_seconds)
completed = subprocess.CompletedProcess(command, process.returncode, stdout, stderr)
timings["isolated_worker"] = time.perf_counter() - started
except subprocess.TimeoutExpired as exc:
self._terminate_isolated_edit_process(process)
try:
@@ -7856,15 +7970,21 @@ class WindowActionMixin:
f"{self._edit_failure_diagnostics(context)}"
)
if not output_path.exists():
raise RuntimeError("隔离子进程报告成功,但没有生成结果 STEP;原模型保持不变。")
raise RuntimeError("隔离子进程报告成功,但没有生成结果文件;原模型保持不变。")
new_model = StepModel.load(output_path)
started = time.perf_counter()
if exchange_format == "brep":
new_model = StepModel.load_internal_brep(output_path)
else:
new_model = StepModel.load(output_path)
timings["isolated_result_load"] = time.perf_counter() - started
try:
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)
started = time.perf_counter()
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)
@@ -7879,20 +7999,31 @@ class WindowActionMixin:
after_quality,
after_model=new_model,
)
timings["validate"] = time.perf_counter() - started
after_geometry: dict[str, object] = {}
model_polydata = None
edge_polydata = None
edge_deferred = bool(context.get("defer_edge_polydata", True))
try:
deflection = float(context.get("edit_result_deflection", 1.6))
started = time.perf_counter()
face_polydata = new_model.build_face_polydata(deflection=deflection)
model_polydata = _smooth_surface_polydata(face_polydata)
edge_polydata = new_model.build_edge_polydata(
deflection=deflection,
show_same_domain_internal_edges=bool(context.get("show_same_domain_internal_edges", False)),
)
timings["display_faces"] = time.perf_counter() - started
if edge_deferred:
edge_polydata = self._empty_edge_polydata()
timings["display_edges"] = 0.0
else:
started = time.perf_counter()
edge_polydata = new_model.build_edge_polydata(
deflection=deflection,
show_same_domain_internal_edges=bool(context.get("show_same_domain_internal_edges", False)),
)
timings["display_edges"] = time.perf_counter() - started
except Exception:
model_polydata = None
edge_polydata = None
timings["total"] = time.perf_counter() - total_started
return {
"message": f"{child_message} 已通过隔离子进程完成;如果 OCC 崩溃,主程序不会被带崩。",
@@ -7908,6 +8039,8 @@ class WindowActionMixin:
"after_geometry": after_geometry,
"model_polydata": model_polydata,
"edge_polydata": edge_polydata,
"edge_polydata_deferred": edge_deferred,
"timings": timings,
}
def _isolated_edit_command(self, request_path: Path) -> list[str]:
@@ -8710,6 +8843,7 @@ class WindowActionMixin:
if self.model is None:
self._end_edit_task(clear_preview=True)
return
finish_started = time.perf_counter()
self._set_edit_status_text("布尔计算已完成,正在刷新模型显示和历史记录...")
if not isinstance(result, dict):
self._end_edit_task(clear_preview=True)
@@ -8742,16 +8876,22 @@ class WindowActionMixin:
)
model_polydata = result.get("model_polydata")
edge_polydata = result.get("edge_polydata")
if model_polydata is None or edge_polydata is None:
edge_deferred = bool(result.get("edge_polydata_deferred"))
if model_polydata is None or (edge_polydata is None and not edge_deferred):
deflection = float(context.get("edit_result_deflection", 1.6))
model_polydata = self.model.build_face_polydata(deflection=deflection)
edge_polydata = self.model.build_edge_polydata(
deflection=deflection,
show_same_domain_internal_edges=bool(context.get("show_same_domain_internal_edges", False))
)
elif edge_polydata is None:
edge_polydata = self._empty_edge_polydata()
self.clear_edit_preview(render=False)
self._populate_part_tree()
self._rebuild_scene_from_polydata(model_polydata, edge_polydata, reset_camera=False)
timings = result.get("timings")
if isinstance(timings, dict):
timings["finish_ui"] = time.perf_counter() - finish_started
locator_note = self._locate_operation_record(record)
except Exception as exc:
rollback_message = self._restore_failed_edit_snapshot(result.get("snapshot") if isinstance(result, dict) else None)
@@ -8768,13 +8908,19 @@ class WindowActionMixin:
self._clear_cylinder_candidates()
self._refresh_history_list()
self._end_edit_task(clear_preview=False)
timing_text = _edit_timing_summary(result.get("timings"))
if bool(result.get("edge_polydata_deferred")):
QTimer.singleShot(80, self._rebuild_deferred_edge_display)
if result.get("quality_warnings"):
self.statusBar().showMessage("编辑完成,但有质量警告,请查看操作历史详情")
else:
selection_note = ";已保持当前选择" if self.selected_kind is not None else ""
self.statusBar().showMessage(f"{message}{selection_note}")
timing_note = f";耗时 {timing_text}" if timing_text else ""
edge_note = ";边线稍后补充" if bool(result.get("edge_polydata_deferred")) else ""
self.statusBar().showMessage(f"{message}{selection_note}{timing_note}{edge_note}")
if self.selected_kind is None:
self.set_plain_info(f"{record.detail}\n\n{locator_note}")
timing_detail = f"\n\n性能耗时:{timing_text}" if timing_text else ""
self.set_plain_info(f"{record.detail}{timing_detail}\n\n{locator_note}")
@Slot(str)
def _fail_edit_action(self, message: str) -> None:
+155 -31
View File
@@ -47,11 +47,59 @@ def _safe_int_or_none(value: object) -> int | None:
return None
def _empty_edge_polydata():
polydata = vtk.vtkPolyData()
polydata.SetPoints(vtk.vtkPoints())
polydata.SetLines(vtk.vtkCellArray())
edge_arr = vtk.vtkIntArray()
edge_arr.SetName("edge_id")
part_arr = vtk.vtkIntArray()
part_arr.SetName("part_id")
polydata.GetCellData().AddArray(edge_arr)
polydata.GetCellData().AddArray(part_arr)
return polydata
def _timing_summary(timings: dict[str, object] | None, *, limit: int = 4) -> str:
if not isinstance(timings, dict) or not timings:
return ""
labels = {
"step_load": "STEP读取",
"stats": "统计",
"display_faces": "面显示",
"display_edges": "边线",
"apply_loaded": "界面刷新",
"total": "总计",
}
rows: list[tuple[str, float]] = []
for key, value in timings.items():
if key == "total":
continue
try:
seconds = float(value)
except (TypeError, ValueError):
continue
if seconds >= 0.05:
rows.append((labels.get(str(key), str(key)), seconds))
rows.sort(key=lambda item: item[1], reverse=True)
parts = [f"{label} {seconds:.1f}s" for label, seconds in rows[:limit]]
try:
total = float(timings.get("total"))
except (TypeError, ValueError):
total = 0.0
if total >= 0.05:
parts.append(f"总计 {total:.1f}s")
return "".join(parts)
class WindowCoreMixin:
@Slot(object)
def _run_ui_task(self, callback) -> None:
callback()
def _empty_edge_polydata(self):
return _empty_edge_polydata()
def showEvent(self, event) -> None:
super().showEvent(event)
if getattr(self, "first_show_handled", False):
@@ -62,7 +110,7 @@ class WindowCoreMixin:
@Slot()
def _after_first_show(self) -> None:
self._ensure_vtk_interactor_started()
if getattr(self, "auto_load_on_show", False):
if getattr(self, "auto_load_on_show", False) and getattr(self, "step_path", None) is not None:
self.auto_load_on_show = False
self.load_step(self.step_path, background=True)
@@ -927,23 +975,41 @@ class WindowCoreMixin:
deflection: float,
show_internal_edges: bool,
build_polydata: bool = True,
build_edges: bool = True,
) -> dict[str, object]:
timings: dict[str, float] = {}
total_started = time.perf_counter()
started = time.perf_counter()
new_model = StepModel.load(path)
timings["step_load"] = time.perf_counter() - started
started = time.perf_counter()
stats = new_model.stats()
timings["stats"] = time.perf_counter() - started
result = {
"path": path,
"model": new_model,
"stats": stats,
"deflection": deflection,
"show_internal_edges": show_internal_edges,
"timings": timings,
}
if build_polydata:
started = time.perf_counter()
face_polydata = new_model.build_face_polydata(deflection=deflection)
result["model_polydata"] = _smooth_surface_polydata(face_polydata)
result["edge_polydata"] = new_model.build_edge_polydata(
deflection=deflection,
show_same_domain_internal_edges=show_internal_edges,
)
timings["display_faces"] = time.perf_counter() - started
if build_edges:
started = time.perf_counter()
result["edge_polydata"] = new_model.build_edge_polydata(
deflection=deflection,
show_same_domain_internal_edges=show_internal_edges,
)
timings["display_edges"] = time.perf_counter() - started
else:
result["edge_polydata"] = _empty_edge_polydata()
result["edge_polydata_deferred"] = True
timings["display_edges"] = 0.0
timings["total"] = time.perf_counter() - total_started
return result
def _load_step_background_or_sync(self, path: str | Path, *, background: bool) -> None:
@@ -995,6 +1061,7 @@ class WindowCoreMixin:
deflection: float,
show_internal_edges: bool,
status_prefix: str,
defer_edges: bool = True,
) -> bool:
self.statusBar().showMessage(f"{status_prefix} {new_path.name}...")
QApplication.setOverrideCursor(Qt.CursorShape.WaitCursor)
@@ -1004,6 +1071,7 @@ class WindowCoreMixin:
new_path,
deflection=deflection,
show_internal_edges=show_internal_edges,
build_edges=not defer_edges,
)
result["display"] = "ready"
except Exception as exc:
@@ -1013,7 +1081,9 @@ class WindowCoreMixin:
finally:
QApplication.restoreOverrideCursor()
self._apply_loaded_model_result(result, reset_camera=True)
self.statusBar().showMessage(f"Loaded {self.step_path.name}")
timing_text = _timing_summary(result.get("timings") if isinstance(result, dict) else None)
suffix = f"{timing_text}" if timing_text else ""
self.statusBar().showMessage(f"Loaded {self.step_path.name}{suffix}")
return True
@Slot(object)
@@ -1026,6 +1096,7 @@ class WindowCoreMixin:
deflection=self.preview_load_deflection,
show_internal_edges=self._show_same_domain_internal_edges(),
status_prefix="读取 STEP 可视化网格",
defer_edges=True,
)
finally:
self._end_load_task()
@@ -1047,6 +1118,7 @@ class WindowCoreMixin:
return detached
def _apply_loaded_model_result(self, result: dict[str, object], *, reset_camera: bool) -> None:
apply_started = time.perf_counter()
new_path = Path(result["path"])
stats = result["stats"]
self.model = result["model"]
@@ -1062,7 +1134,8 @@ class WindowCoreMixin:
self._reset_selection()
model_polydata = result.get("model_polydata")
edge_polydata = result.get("edge_polydata")
if model_polydata is None or edge_polydata is None:
edge_deferred = bool(result.get("edge_polydata_deferred"))
if model_polydata is None or (edge_polydata is None and not edge_deferred):
deflection = float(result.get("deflection", 0.8))
show_internal_edges = bool(result.get("show_internal_edges", self._show_same_domain_internal_edges()))
model_polydata = self.model.build_face_polydata(deflection=deflection)
@@ -1070,6 +1143,8 @@ class WindowCoreMixin:
deflection=deflection,
show_same_domain_internal_edges=show_internal_edges,
)
elif edge_polydata is None:
edge_polydata = _empty_edge_polydata()
self._rebuild_scene_from_polydata(
model_polydata,
edge_polydata,
@@ -1077,18 +1152,28 @@ class WindowCoreMixin:
)
self._clear_editable_candidates()
self._clear_cylinder_candidates()
self.set_info(
{
"file": str(self.step_path),
"parts": stats.parts,
"solids": stats.solids,
"faces": stats.faces,
"edges": stats.edges,
"vertices": stats.vertices,
"display": result.get("display", "quick preview" if self.load_in_progress else "ready"),
}
)
timings = result.get("timings")
if isinstance(timings, dict):
timings["apply_loaded"] = time.perf_counter() - apply_started
display_state = result.get("display", "quick preview" if self.load_in_progress else "ready")
if edge_deferred:
display_state = f"{display_state}; edge display pending"
info_payload = {
"file": str(self.step_path),
"parts": stats.parts,
"solids": stats.solids,
"faces": stats.faces,
"edges": stats.edges,
"vertices": stats.vertices,
"display": display_state,
}
timing_text = _timing_summary(timings if isinstance(timings, dict) else None)
if timing_text:
info_payload["load_performance"] = timing_text
self.set_info(info_payload)
self._update_action_states()
if edge_deferred:
QTimer.singleShot(80, self._rebuild_deferred_edge_display)
@Slot(object)
def _finish_initial_load(self, result: object) -> None:
@@ -1177,10 +1262,12 @@ class WindowCoreMixin:
def open_step(self) -> None:
if self._edit_busy("请等待当前编辑完成后再打开文件。"):
return
current_path = getattr(self, "step_path", None)
start_dir = current_path.parent if isinstance(current_path, Path) else Path.cwd()
path, _ = QFileDialog.getOpenFileName(
self,
"打开 STEP 文件",
str(self.step_path.parent if self.step_path else Path.cwd()),
str(start_dir),
"STEP 文件 (*.step *.stp);;所有文件 (*.*)",
)
if path:
@@ -1200,6 +1287,9 @@ class WindowCoreMixin:
return
path_text = self.path_label.text().strip() if hasattr(self, "path_label") else ""
path = Path(path_text) if path_text else self.step_path
if path is None:
self.statusBar().showMessage("请先点击“导入几何模型”选择 STEP 文件。")
return
self._ensure_vtk_interactor_started()
self.load_step(path)
@@ -1620,6 +1710,50 @@ class WindowCoreMixin:
edge_id = int(self.edge_id_array.GetValue(cell_id))
self.edge_cell_ids_by_edge.setdefault(edge_id, []).append(cell_id)
def _install_edge_polydata(self, edge_polydata, *, render: bool = True) -> None:
if edge_polydata is None:
edge_polydata = _empty_edge_polydata()
old_actor = getattr(self, "edge_actor", None)
if old_actor is not None:
try:
self.renderer.RemoveActor(old_actor)
except Exception:
pass
self.edge_polydata = edge_polydata
self.edge_id_array = self.edge_polydata.GetCellData().GetArray("edge_id")
self._rebuild_edge_polydata_cell_index()
edge_mapper = vtk.vtkPolyDataMapper()
edge_mapper.SetInputData(self.edge_polydata)
_prepare_static_mapper(edge_mapper)
self.edge_actor = vtk.vtkActor()
self.edge_actor.SetMapper(edge_mapper)
self.edge_actor.GetProperty().SetColor(0.08, 0.09, 0.1)
self.edge_actor.GetProperty().SetLineWidth(1.0)
self.edge_actor.GetProperty().LightingOff()
self.renderer.AddActor(self.edge_actor)
self.edge_overlay_polydata_cache.clear()
if render:
self.render_window.Render()
@Slot()
def _rebuild_deferred_edge_display(self) -> None:
if self.model is None or self.operation_in_progress or self.load_in_progress:
return
started = time.perf_counter()
self.statusBar().showMessage("模型已显示,正在补充边线...")
QApplication.processEvents()
try:
edge_polydata = self.model.build_edge_polydata(
deflection=float(getattr(self, "preview_load_deflection", 0.35) or 0.35),
show_same_domain_internal_edges=self._show_same_domain_internal_edges(),
)
self._install_edge_polydata(edge_polydata, render=True)
except Exception as exc:
self.statusBar().showMessage(f"模型已显示;边线补充失败:{exc}")
return
elapsed = time.perf_counter() - started
self.statusBar().showMessage(f"模型边线已补充,用时 {elapsed:.1f}s")
def _remember_overlay_cache_item(self, cache: dict, key: object, value: object) -> object:
if len(cache) >= self.overlay_cache_limit:
try:
@@ -1744,18 +1878,8 @@ class WindowCoreMixin:
self.model_actor.SetBackfaceProperty(backface_property)
self.renderer.AddActor(self.model_actor)
self.edge_polydata = edge_polydata
self.edge_id_array = self.edge_polydata.GetCellData().GetArray("edge_id")
self._rebuild_edge_polydata_cell_index()
edge_mapper = vtk.vtkPolyDataMapper()
edge_mapper.SetInputData(self.edge_polydata)
_prepare_static_mapper(edge_mapper)
self.edge_actor = vtk.vtkActor()
self.edge_actor.SetMapper(edge_mapper)
self.edge_actor.GetProperty().SetColor(0.08, 0.09, 0.1)
self.edge_actor.GetProperty().SetLineWidth(1.0)
self.edge_actor.GetProperty().LightingOff()
self.renderer.AddActor(self.edge_actor)
self.edge_actor = None
self._install_edge_polydata(edge_polydata, render=False)
self.highlight_actor = None
self.edge_highlight_actor = None
+176 -27
View File
@@ -7,6 +7,7 @@ import time
from PySide6.QtCore import Qt, QThread, QTimer, Slot
from PySide6.QtWidgets import (
QApplication,
QCheckBox,
QFileDialog,
QFrame,
QHBoxLayout,
@@ -36,8 +37,15 @@ PROPERTY_LABEL_COLUMN = 0
PROPERTY_CURRENT_COLUMN = 1
PROPERTY_SCOPE_COLUMN = 2
PROPERTY_TARGET_COLUMN = 3
PROPERTY_TABLE_MIN_COLUMN_WIDTHS = (72, 118, 72, 82)
PROPERTY_TABLE_PREFERRED_COLUMN_WIDTHS = (118, 168, 108, 104)
PROPERTY_INPUT_COLUMN = 4
PROPERTY_TABLE_HEADERS = ("尺寸参数", "当前值", "建模意图", "目标值", "输入参数")
PROPERTY_TABLE_MIN_COLUMN_WIDTHS = (70, 72, 66, 62, 50)
PROPERTY_TABLE_PREFERRED_COLUMN_WIDTHS = (108, 104, 88, 82, 66)
PROPERTY_TEMPORARILY_HIDDEN_PARAMETER_KEYS = {
"face_center_position",
"edge_center_point",
}
PROPERTY_TEMPORARILY_HIDDEN_PARAMETER_LABELS = {"中心"}
PROPERTY_COMMAND_ORDER = ("offset", "move", "scale", "rotate", "feature", "diagnostics")
PROPERTY_COMMAND_LABELS = {
"offset": "偏移",
@@ -98,7 +106,7 @@ PROPERTY_EXPLANATION_TOOLTIPS = {
}
def _property_table_column_widths(available_width: int) -> tuple[int, int, int, int]:
def _property_table_column_widths(available_width: int) -> tuple[int, ...]:
"""Prefer current value visibility while keeping modeling intent and target usable."""
column_count = len(PROPERTY_TABLE_MIN_COLUMN_WIDTHS)
available = max(int(available_width or 0), column_count * 44)
@@ -110,7 +118,7 @@ def _property_table_column_widths(available_width: int) -> tuple[int, int, int,
if available >= preferred_total:
widths = [int(value) for value in preferred]
extra = available - preferred_total
weights = (0.26, 0.38, 0.18, 0.18)
weights = (0.30, 0.24, 0.18, 0.16, 0.12)
for index, weight in enumerate(weights):
addition = int(extra * weight)
widths[index] += addition
@@ -126,10 +134,10 @@ def _property_table_column_widths(available_width: int) -> tuple[int, int, int,
widths[1] += available - sum(widths)
return tuple(widths) # type: ignore[return-value]
floors = (48, 96, 44, 56)
floors = (44, 44, 44, 44, 44)
widths = [int(value) for value in minimum]
deficit = min_total - available
for index in (0, 2, 3, 1):
for index in (0, 2, 3, 1, 4):
if deficit <= 0:
break
reducible = max(0, widths[index] - floors[index])
@@ -149,6 +157,16 @@ def _compact_property_card_text(text: str, limit: int = 220) -> str:
return f"{compact[: max(0, limit - 1)].rstrip()}"
def _property_parameter_is_visible(spec: dict[str, object]) -> bool:
key = str(spec.get("key", "") or "")
label = str(spec.get("label", "") or "").strip()
if key in PROPERTY_TEMPORARILY_HIDDEN_PARAMETER_KEYS:
return False
if label in PROPERTY_TEMPORARILY_HIDDEN_PARAMETER_LABELS:
return False
return True
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 "")
@@ -817,7 +835,7 @@ class WindowStateMixin:
self._set_control_state(
self.quick_export_all_button,
has_model,
"导出当前完整 STEP 模型。",
"导出当前模型。",
wait_or_load_tip,
)
if hasattr(self, "part_tree"):
@@ -852,7 +870,7 @@ class WindowStateMixin:
self._set_control_state(
self.export_all_button,
has_model,
"导出当前完整 STEP 模型。",
"导出当前模型。",
wait_or_load_tip,
)
self._set_control_state(
@@ -1548,11 +1566,11 @@ class WindowStateMixin:
headline = "当前支持:Face、孔、槽、凸台、圆角/倒角、壳体、Edge"
detail = ""
tooltip = (
"Face:面内长度/宽度、中心、偏移、壳体厚度。\n"
"Face:面内长度/宽度、偏移、壳体厚度。\n"
"孔/槽:孔径、轴心、封堵、盲孔/盲槽深度、槽宽/槽深/弧长/总长。\n"
"凸台:圆柱凸台直径/高度/轴心;矩形凸台/矩形槽口袋长宽、中心、高度/深度;多台阶矩形凸台顶层规则台阶。\n"
"凸台:圆柱凸台直径/高度/轴心;矩形凸台/矩形槽口袋长宽、高度/深度;多台阶矩形凸台顶层规则台阶。\n"
"圆角/倒角:简单已有圆角半径/弧长、简单等半径圆角链半径/弧长、已有等距倒角距离,直线 Edge 新增圆角/倒角。\n"
"Edge/解析曲面:直线 Edge 长度/端点/中心、圆/椭圆 Edge、简单圆锥/球/环面。\n"
"Edge/解析曲面:直线 Edge 长度/端点、圆/椭圆 Edge、简单圆锥/球/环面。\n"
"一级关系:Face/Edge 平行垂直事实、Face 共面/同域碎片、一级同轴圆柱事实;部分 Face/Edge 操作可选择保持关系。\n"
"受限:复杂链式特征、二级/三级拓扑传播和原 CAD 历史恢复。"
)
@@ -1985,7 +2003,9 @@ class WindowStateMixin:
editable=False,
)
target_item.setToolTip(self._property_target_tooltip(effective_spec, editable=editable))
row_items = (label_item, current_item, scope_item, target_item)
input_item = self._property_table_item("", editable=False)
input_item.setToolTip("勾选后点击“导出参数”,会把这一行作为输入参数写入 data.json。")
row_items = (label_item, current_item, scope_item, target_item, input_item)
self._style_property_row_items(row_items, editable=editable, spec=effective_spec)
for column, item in enumerate(row_items):
item.setToolTip(item.toolTip() or item.text())
@@ -2002,6 +2022,10 @@ class WindowStateMixin:
self.property_table.removeCellWidget(row, PROPERTY_TARGET_COLUMN)
else:
self.property_table.removeCellWidget(row, PROPERTY_TARGET_COLUMN)
self._set_property_input_checkbox(
row,
exportable=bool(input_editable and effective_spec.get("label")),
)
self._clear_property_command_bar()
self._clear_property_cards()
self._update_current_capability_panel()
@@ -2032,12 +2056,13 @@ class WindowStateMixin:
def _style_property_row_items(
self,
items: tuple[QTableWidgetItem, QTableWidgetItem, QTableWidgetItem, QTableWidgetItem],
items: tuple[QTableWidgetItem, ...],
*,
editable: bool,
spec: dict[str, object] | None = None,
) -> None:
label_item, current_item, scope_item, target_item = items
label_item, current_item, scope_item, target_item = items[:4]
input_item = items[4] if len(items) > 4 else None
if spec is not None and bool(spec.get("pin_top")):
for item in items:
item.setBackground(QColor("#eef6ff"))
@@ -2045,6 +2070,8 @@ class WindowStateMixin:
current_item.setForeground(QColor("#0f172a"))
scope_item.setForeground(QColor("#0369a1"))
target_item.setForeground(QColor("#64748b"))
if input_item is not None:
input_item.setForeground(QColor("#64748b"))
label_font = label_item.font()
label_font.setBold(True)
label_item.setFont(label_font)
@@ -2053,13 +2080,15 @@ class WindowStateMixin:
current_item.setFont(current_font)
return
if editable:
row_backgrounds = ("#fff7ed", "#fffbeb", "#fff7ed", "#fff7ed")
row_backgrounds = ("#fff7ed", "#fffbeb", "#fff7ed", "#fff7ed", "#fff7ed")
for item, color in zip(items, row_backgrounds):
item.setBackground(QColor(color))
label_item.setForeground(QColor("#7c2d12"))
current_item.setForeground(QColor("#431407"))
scope_item.setForeground(QColor("#9a3412"))
target_item.setForeground(QColor("#111827"))
if input_item is not None:
input_item.setForeground(QColor("#7c2d12"))
label_font = label_item.font()
label_font.setBold(True)
label_item.setFont(label_font)
@@ -2070,6 +2099,9 @@ class WindowStateMixin:
scope_item.setForeground(QColor("#64748b"))
target_item.setBackground(QColor("#eef2f6"))
target_item.setForeground(QColor("#8f99a8"))
if input_item is not None:
input_item.setBackground(QColor("#eef2f6"))
input_item.setForeground(QColor("#8f99a8"))
def _property_scope_default(self, spec: dict[str, object]) -> str:
modes = spec.get("scope_modes")
@@ -2145,6 +2177,39 @@ class WindowStateMixin:
editor.returnPressed.connect(self.apply_current_property_edit)
self.property_table.setCellWidget(row, PROPERTY_TARGET_COLUMN, editor)
def _set_property_input_checkbox(self, row: int, *, exportable: bool) -> None:
if not hasattr(self, "property_table"):
return
container = QWidget()
container.setObjectName("propertyInputParameterCell")
layout = QHBoxLayout(container)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
checkbox = QCheckBox(container)
checkbox.setObjectName("propertyInputParameterCheckbox")
checkbox.setChecked(False)
checkbox.setEnabled(exportable)
checkbox.setCursor(Qt.CursorShape.PointingHandCursor if exportable else Qt.CursorShape.ArrowCursor)
tip = "勾选后点击“导出参数”,会把这一行作为输入参数写入 data.json。"
if not exportable:
tip = "当前行不是可导出的尺寸输入参数。"
checkbox.setToolTip(tip)
container.setToolTip(tip)
checkbox.toggled.connect(lambda _checked=False: self._update_parameter_export_state())
layout.addWidget(checkbox)
self.property_table.setCellWidget(row, PROPERTY_INPUT_COLUMN, container)
def _property_input_checkbox(self, row: int) -> QCheckBox | None:
if not hasattr(self, "property_table"):
return None
widget = self.property_table.cellWidget(row, PROPERTY_INPUT_COLUMN)
if isinstance(widget, QCheckBox):
return widget
if isinstance(widget, QWidget):
return widget.findChild(QCheckBox)
return None
def _set_property_scope_editor(self, row: int, spec: dict[str, object]) -> None:
modes = spec.get("scope_modes")
if not isinstance(modes, dict) or not modes:
@@ -2213,6 +2278,23 @@ class WindowStateMixin:
target_item = self.property_table.item(row, PROPERTY_TARGET_COLUMN)
if target_item is not None:
target_item.setToolTip(self._property_target_tooltip(effective_spec, editable=editable))
checkbox = self._property_input_checkbox(row)
if checkbox is not None:
exportable = bool(input_editable and effective_spec.get("label"))
if not exportable and checkbox.isChecked():
was_blocked = checkbox.blockSignals(True)
try:
checkbox.setChecked(False)
finally:
checkbox.blockSignals(was_blocked)
tip = (
"勾选后点击“导出参数”,会把这一行作为输入参数写入 data.json。"
if exportable
else "当前行不是可导出的尺寸输入参数。"
)
checkbox.setEnabled(exportable)
checkbox.setCursor(Qt.CursorShape.PointingHandCursor if exportable else Qt.CursorShape.ArrowCursor)
checkbox.setToolTip(tip)
self._update_current_capability_panel()
self._update_property_apply_state()
@@ -2345,6 +2427,7 @@ class WindowStateMixin:
and bool(spec.get("enabled"))
and bool(spec.get("action"))
and str(spec.get("value_type", "number")) != "command"
and _property_parameter_is_visible(spec)
]
def _feature_property_specs(
@@ -2381,6 +2464,7 @@ class WindowStateMixin:
or not bool(spec.get("editable"))
or (not bool(spec.get("enabled")) and not prismatic_size_key and not prismatic_center_key)
or str(spec.get("value_type", "number")) == "command"
or not _property_parameter_is_visible(spec)
):
continue
dimension = dict(spec)
@@ -4546,7 +4630,7 @@ class WindowStateMixin:
"action": "resize_boss_height",
"target_attr": "boss_height_input",
"enabled": boss_height_can_local,
"enabled_tip": "输入完整圆柱凸台的目标高度;点击修改时程序会再计算并确认可拉伸/切除的凸台端盖 Face。",
"enabled_tip": "输入完整圆柱凸台的目标高度;点击修改时程序会再计算并校验可拉伸/切除的凸台端盖 Face。",
"disabled_tip": "当前凸台候选缺少稳定高度或端盖信息,暂不放行高度修改。",
"range_hint": relative_range_hint(current_boss_height, 0.3, 0.8),
},
@@ -4663,7 +4747,7 @@ class WindowStateMixin:
"action": "resize_cylinder_height",
"target_attr": "boss_height_input",
"enabled": bool(is_full_cylinder and current_cylinder_height is not None),
"enabled_tip": "输入完整圆柱面的目标高度;点击修改时程序会再计算并确认可拉伸/切除的圆柱端盖 Face。",
"enabled_tip": "输入完整圆柱面的目标高度;点击修改时程序会再计算并校验可拉伸/切除的圆柱端盖 Face。",
"disabled_tip": "当前圆柱面不是完整圆柱,或缺少稳定高度信息。",
"range_hint": relative_range_hint(current_cylinder_height, 0.3, 0.8),
},
@@ -5775,17 +5859,70 @@ class WindowStateMixin:
card.style().unpolish(card)
card.style().polish(card)
setattr(widget, "_geom_param_button_state", button_state)
if not hasattr(self, "apply_property_button"):
if hasattr(self, "apply_property_button"):
changed = self._changed_property_rows()
enabled = bool(has_model and changed)
disabled_tip = "请先选择对象,并在属性表中修改一个可编辑目标值。"
if changed and len(changed) > 1:
disabled_tip = "当前一次只执行一个几何修改;请只保留一行目标值不同,再点击参数化建模。"
self._set_control_state(
self.apply_property_button,
enabled and len(changed) == 1,
"应用当前被修改的参数。",
disabled_tip,
)
self._update_parameter_export_state(has_model)
def _selected_parameter_export_rows(self) -> list[dict[str, str]]:
if not hasattr(self, "property_table"):
return []
rows: list[dict[str, str]] = []
specs = getattr(self, "property_editor_specs", [])
row_count = min(self.property_table.rowCount(), len(specs))
for row in range(row_count):
checkbox = self._property_input_checkbox(row)
if checkbox is None or not checkbox.isEnabled() or not checkbox.isChecked():
continue
spec = self._effective_property_spec(specs[row], row=row)
if str(spec.get("value_type", "number")) == "command":
continue
label_item = self.property_table.item(row, PROPERTY_LABEL_COLUMN)
current_item = self.property_table.item(row, PROPERTY_CURRENT_COLUMN)
label = (label_item.text() if label_item is not None else str(spec.get("label", ""))).strip()
current = (
current_item.text()
if current_item is not None
else str(spec.get("current_text", spec.get("current_raw", "")))
).strip()
if not label:
continue
rows.append(
{
"name": label,
"displayName": label,
"type": "number",
"ioRole": "input",
"default": current,
}
)
return rows
def _update_parameter_export_state(self, has_model: bool | None = None) -> None:
if not hasattr(self, "export_parameters_button"):
return
changed = self._changed_property_rows()
enabled = bool(has_model and changed)
disabled_tip = "请先选择对象,并在属性表中修改一个可编辑目标值。"
if changed and len(changed) > 1:
disabled_tip = "当前一次只执行一个几何修改;请只保留一行目标值不同,再点击参数化建模。"
if has_model is None:
has_model = self.model is not None and not (
self.operation_in_progress or self.scan_in_progress or self.load_in_progress
)
selected_rows = self._selected_parameter_export_rows()
if selected_rows:
disabled_tip = "请等待当前后台任务完成后再导出参数。"
else:
disabled_tip = "请先在“输入参数”列勾选至少一个尺寸参数。"
self._set_control_state(
self.apply_property_button,
enabled and len(changed) == 1,
"应用当前被修改的参数",
self.export_parameters_button,
bool(has_model and selected_rows),
f"导出已勾选的 {len(selected_rows)} 个输入参数到 data.json",
disabled_tip,
)
@@ -6060,7 +6197,19 @@ class WindowStateMixin:
if action is None:
QMessageBox.information(self, "暂不支持", f"当前属性没有可用的执行入口:{action_name}")
return
action()
self._run_parametric_property_action_directly(action)
def _run_parametric_property_action_directly(self, action) -> None:
original_question = QMessageBox.question
def auto_yes(*_args, **_kwargs):
return QMessageBox.StandardButton.Yes
try:
QMessageBox.question = auto_yes
action()
finally:
QMessageBox.question = original_question
def set_manual_hole_bottom_face(self) -> None:
self._update_action_states()