feat: 推进SCDM-first后端接入和大模型编辑优化

This commit is contained in:
2026-08-19 10:28:09 +08:00
parent 722256a41f
commit a3eb7e2476
28 changed files with 9666 additions and 168 deletions
+242 -17
View File
@@ -106,6 +106,7 @@ def _edit_timing_summary(timings: object, *, limit: int = 5) -> str:
"validate": "结果校验",
"display_faces": "面显示",
"display_edges": "边线",
"result_face_mapping": "结果Face定位",
"finish_ui": "界面刷新",
"total": "总计",
}
@@ -507,7 +508,14 @@ class WindowActionMixin:
if plan["status"] == "blocked":
self._show_blocked_plan_message(operation_name, plan, "拉伸/切除平面已阻止")
return
if plan["risk"] != "low":
if keep_relations:
operation_key = "push_pull_face_keep_relations"
isolation = self._isolation_for_plan(plan, operation_key, [face_id, distance])
else:
operation_key = "push_pull_face"
isolation = self._isolation_for_plan(plan, operation_key, [face_id, distance])
if plan["risk"] != "low" and not self._can_skip_edit_confirmation(plan, isolation):
warnings = str(plan.get("warnings", ""))
warnings_line = f"警告: {warnings}\n\n" if warnings else ""
isolation_line = (
@@ -538,10 +546,6 @@ class WindowActionMixin:
if result != QMessageBox.StandardButton.Yes:
self.statusBar().showMessage("已取消拉伸/切除平面")
return
if keep_relations:
isolation = self._isolation_for_plan(plan, "push_pull_face_keep_relations", [face_id, distance])
else:
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:
@@ -629,6 +633,7 @@ class WindowActionMixin:
target_kind="face",
target_id=face_id,
isolation=isolation,
operation_key=operation_key,
)
def _quick_push_pull_plan(self, face_id: int, distance: float) -> dict[str, object]:
@@ -840,13 +845,31 @@ class WindowActionMixin:
if model_face_count < 600:
return False
inner_wires = int(quick_plan.get("inner_boundary_wires") or 0)
boundary_wires = int(quick_plan.get("boundary_wires") or 0)
inner_wires = int(
quick_plan.get("inner_boundary_wires")
or quick_plan.get("selected_inner_boundary_wires")
or 0
)
boundary_wires = int(
quick_plan.get("boundary_wires")
or quick_plan.get("selected_boundary_wires")
or 0
)
if inner_wires <= 0 and boundary_wires <= 1 and not bool(quick_plan.get("has_inner_boundaries")):
return False
# Large STEP + holed planar caps are exactly where a full plan can spend
# seconds scanning topology before the actual isolated edit even starts.
boundary_edges = int(
quick_plan.get("first_level_boundary_edge_count")
or quick_plan.get("selected_boundary_edge_count")
or 0
)
if boundary_wires and boundary_wires <= 16 and inner_wires <= 12:
return False
if boundary_edges and boundary_edges <= 120 and inner_wires <= 12:
return False
# Very large STEP + extremely fragmented holed caps can still spend
# noticeable time scanning topology before the actual edit starts.
return abs(float(distance)) > 1e-9
def _deferred_push_pull_model_plan(
@@ -1727,13 +1750,66 @@ class WindowActionMixin:
return None
if risk not in {"low", "medium", "high"}:
return None
prefer_smooth_process = self._prefer_isolated_process_for_large_interactive_edit(plan, operation)
if not prefer_smooth_process and self._can_run_inprocess_background_edit(plan, operation):
return None
return {
"operation": operation,
"args": args,
"timeout_seconds": timeout_seconds,
"reason": f"{risk}-risk-isolated-occ-edit",
"reason": "large-model-smooth-ui-isolated-occ-edit" if prefer_smooth_process else f"{risk}-risk-isolated-occ-edit",
}
def _can_run_inprocess_background_edit(self, plan: dict[str, object], operation: str) -> bool:
if operation != "push_pull_face":
return False
if str(plan.get("planar_cap_extension_method") or "") == "boundary-shell-rebuild":
return True
if str(plan.get("cylindrical_cap_extension_method") or "") == "local-shell-rebuild":
return True
if bool(plan.get("ui_deferred_model_plan")) and int(plan.get("selected_inner_boundary_wires", 0) or 0) > 0:
return True
return False
def _prefer_isolated_process_for_large_interactive_edit(self, plan: dict[str, object], operation: str) -> bool:
if operation not in {"push_pull_face", "push_pull_face_keep_relations"}:
return False
method = str(plan.get("planar_cap_extension_method") or plan.get("cylindrical_cap_extension_method") or "")
if method not in {"boundary-shell-rebuild", "local-shell-rebuild"}:
return False
if method == "local-shell-rebuild":
return True
if bool(getattr(self, "_large_model_interaction_mode", lambda: False)()):
return True
boundary_edges = _int_or_none(plan.get("first_level_boundary_edge_count")) or _int_or_none(
plan.get("planar_cap_boundary_edge_count")
) or 0
adjacent_faces = _int_or_none(plan.get("first_level_adjacent_face_count")) or _int_or_none(
plan.get("planar_cap_adjacent_face_count")
) or 0
inner_wires = _int_or_none(plan.get("selected_inner_boundary_wires")) or _int_or_none(
plan.get("planar_cap_inner_boundary_wires")
) or 0
return boundary_edges >= 32 or adjacent_faces >= 32 or inner_wires >= 2
def _skip_before_quality_check_for_large_edit(
self,
operation_name: str,
operation_key: str | None = None,
) -> bool:
if operation_key not in {"push_pull_face", "push_pull_face_keep_relations"} and "拉伸/切除" not in str(
operation_name or ""
):
return False
return bool(getattr(self, "_large_model_interaction_mode", lambda: False)())
def _can_skip_edit_confirmation(self, plan: dict[str, object], isolation: dict[str, object] | None) -> bool:
if str(plan.get("status") or "") == "blocked":
return False
if not isinstance(isolation, dict):
return False
return isolation.get("reason") == "large-model-smooth-ui-isolated-occ-edit"
def _edit_failure_diagnostics(self, context: dict[str, object]) -> str:
parameters = context.get("parameters")
if not isinstance(parameters, dict):
@@ -7683,6 +7759,8 @@ class WindowActionMixin:
@Slot(object)
def _finish_scan_task_result(self, result: object) -> None:
if hasattr(self, "_reroute_to_ui_thread") and self._reroute_to_ui_thread(lambda result=result: self._finish_scan_task_result(result)):
return
scan_kind = self.pending_scan_kind
context = dict(self.pending_scan_context or {})
if scan_kind == "editable":
@@ -7701,6 +7779,8 @@ class WindowActionMixin:
@Slot(str)
def _fail_scan_task_result(self, message: str) -> None:
if hasattr(self, "_reroute_to_ui_thread") and self._reroute_to_ui_thread(lambda message=message: self._fail_scan_task_result(message)):
return
scan_kind = self.pending_scan_kind
if scan_kind == "editable":
self._fail_editable_scan(message)
@@ -7978,6 +8058,7 @@ class WindowActionMixin:
target_kind: str | None = None,
target_id: int | None = None,
isolation: dict[str, object] | None = None,
operation_key: str | None = None,
) -> None:
if self.model is None:
return
@@ -7985,11 +8066,15 @@ class WindowActionMixin:
self.statusBar().showMessage("后台编辑正在计算,请等待当前操作完成。")
return
target_logical_id = self._edit_target_logical_id(target_kind, target_id)
result_deflection = float(getattr(self, "edit_result_deflection", 1.6))
result_deflection = _large_model_display_deflection_for_model(
self.model,
float(getattr(self, "edit_result_deflection", 1.6)),
)
if operation_name == "拉伸/切除平面":
result_deflection = max(result_deflection, 0.35)
context = {
"operation_name": operation_name,
"operation_key": operation_key or "",
"target": target,
"parameters": parameters,
"target_kind": target_kind,
@@ -8000,6 +8085,10 @@ class WindowActionMixin:
"edit_result_deflection": result_deflection,
"defer_edge_polydata": True,
"isolation": dict(isolation or {}),
"skip_before_quality_check": self._skip_before_quality_check_for_large_edit(
operation_name,
operation_key,
),
}
blocker = self._edit_preflight_blocker(context)
if blocker is not None:
@@ -8046,7 +8135,11 @@ 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_quality = (
None
if bool(context.get("skip_before_quality_check"))
else self._edit_quality_info_or_none(self.model, context, target_part_id)
)
before_geometry = {}
timings["snapshot"] = time.perf_counter() - started
isolation = context.get("isolation")
@@ -8262,7 +8355,9 @@ class WindowActionMixin:
except Exception:
pass
child_message = str(response.get("message") or "隔离子进程编辑完成。")
started = time.perf_counter()
self._preserve_isolated_face_logical_id(new_model, context, child_message)
timings["result_face_mapping"] = time.perf_counter() - started
started = time.perf_counter()
after_snapshot = new_model.snapshot()
after_stats = new_model.stats()
@@ -8305,7 +8400,7 @@ class WindowActionMixin:
timings["total"] = time.perf_counter() - total_started
return {
"message": f"{child_message} 已通过隔离子进程完成;如果 OCC 崩溃,主程序不会被带崩",
"message": f"{child_message} 已通过独立后台几何进程完成,主界面会保持可响应",
"snapshot": snapshot,
"before_stats": before_stats,
"before_part_stats": before_part_stats,
@@ -8375,9 +8470,6 @@ class WindowActionMixin:
logical_id = int(target_logical_id)
except (TypeError, ValueError):
return
candidate_ids: list[int] = []
if 0 <= face_id < len(model.faces):
candidate_ids.append(face_id)
parameters = context.get("parameters")
parameters = parameters if isinstance(parameters, dict) else {}
target_position = _float_or_none(parameters.get("target_plane_position"))
@@ -8385,6 +8477,29 @@ class WindowActionMixin:
_unit_triple_or_none(parameters.get("plane_direction"))
or _unit_triple_or_none(parameters.get("outward_direction"))
)
if 0 <= face_id < len(model.faces):
if target_position is not None and plane_direction is not None:
if self._face_target_plane_position_matches(
model,
[face_id],
target_position,
plane_direction,
_float_or_none(parameters.get("bbox_diagonal")),
):
try:
model.assign_logical_face_region_exclusive(logical_id, [face_id])
return
except Exception:
pass
elif self._assign_isolated_logical_face_candidate(model, logical_id, face_id, context):
return
isolation = context.get("isolation")
if isinstance(isolation, dict) and isolation.get("reason") == "large-model-smooth-ui-isolated-occ-edit":
return
candidate_ids: list[int] = []
if 0 <= face_id < len(model.faces):
candidate_ids.append(face_id)
if target_position is not None and plane_direction is not None:
part_id = self._edit_integrity_int_or_none(parameters.get("part_id"))
solid_id = self._edit_integrity_int_or_none(parameters.get("solid_id"))
@@ -8422,6 +8537,26 @@ class WindowActionMixin:
except Exception:
continue
def _assign_isolated_logical_face_candidate(
self,
model: StepModel,
logical_id: int,
face_id: int,
context: dict[str, object],
) -> bool:
try:
if bool(getattr(self, "_large_model_interaction_mode", lambda: False)()) or (
isinstance(context.get("isolation"), dict)
and context["isolation"].get("reason") == "large-model-smooth-ui-isolated-occ-edit"
):
face_ids = [int(face_id)]
else:
face_ids = model.face_region_ids(int(face_id)) or [int(face_id)]
model.assign_logical_face_region_exclusive(int(logical_id), face_ids)
return True
except Exception:
return False
def _edit_context_part_id(self, context: dict[str, object]) -> int | None:
if self.model is None:
return None
@@ -9169,10 +9304,16 @@ class WindowActionMixin:
pick_position=context["pick_position"],
before_snapshot=result["snapshot"],
after_snapshot=result["after_snapshot"],
isolation=dict(context.get("isolation") or {}),
)
model_polydata = result.get("model_polydata")
edge_polydata = result.get("edge_polydata")
edge_deferred = bool(result.get("edge_polydata_deferred"))
large_model = len(getattr(self.model, "faces", ()) or ()) > 1000 or len(getattr(self.model, "edges", ()) or ()) > 2500
if edge_deferred and large_model:
self.large_model_edge_overlay_skipped = True
elif not edge_deferred:
self.large_model_edge_overlay_skipped = False
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)
@@ -9213,7 +9354,8 @@ class WindowActionMixin:
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")):
edge_deferred = bool(result.get("edge_polydata_deferred"))
if edge_deferred and not bool(getattr(self, "large_model_edge_overlay_skipped", False)):
QTimer.singleShot(80, self._rebuild_deferred_edge_display)
if result.get("quality_warnings"):
self.statusBar().showMessage("编辑完成,但有质量警告,请查看操作历史详情")
@@ -9221,6 +9363,8 @@ class WindowActionMixin:
selection_note = ";已保持当前选择" if self.selected_kind is not None else ""
timing_note = f";耗时 {timing_text}" if timing_text else ""
edge_note = ";边线稍后补充" if bool(result.get("edge_polydata_deferred")) else ""
if edge_deferred and bool(getattr(self, "large_model_edge_overlay_skipped", False)):
edge_note = ";边线按需生成"
self.statusBar().showMessage(f"{message}{selection_note}{timing_note}{edge_note}")
if self.selected_kind is None:
timing_detail = f"\n\n性能耗时:{timing_text}" if timing_text else ""
@@ -9293,6 +9437,84 @@ class WindowActionMixin:
self.edit_thread = None
self.edit_worker = None
def _operation_parameters_with_recognition_sources(
self,
parameters: dict[str, object],
target_kind: str | None,
target_id: int | None,
) -> dict[str, object]:
result = dict(parameters or {})
if result.get("recognition_source") not in {None, ""}:
return result
evidence = [result, getattr(self, "current_info_values", {})]
if (
target_kind in {"face", "feature"}
and target_id is not None
and getattr(self, "model", None) is not None
):
try:
evidence.append(self.model.quick_face_info(int(target_id)))
except Exception:
pass
result["recognition_source"] = (
"Analysis Situs + internal StepModel"
if any(self._operation_has_analysis_situs_evidence(item) for item in evidence)
else "internal StepModel"
)
return result
def _operation_backend_log_lines(
self,
parameters: dict[str, object],
result_message: str,
*,
isolation: dict[str, object] | None = None,
) -> list[str]:
execution = "isolated OCCT subprocess" if isinstance(isolation, dict) and isolation else "Qt background worker"
recognition = str(parameters.get("recognition_source") or "").strip()
if not recognition:
recognition = (
"Analysis Situs + internal StepModel"
if self._operation_has_analysis_situs_evidence(parameters)
or self._operation_has_analysis_situs_evidence(result_message)
else "internal StepModel"
)
return [
"backend: OCCT",
f"execution: {execution}",
f"recognition: {recognition}",
]
def _operation_has_analysis_situs_evidence(self, value: object) -> bool:
if self._operation_value_is_empty(value):
return False
if isinstance(value, str):
lowered = value.lower()
return "analysis situs" in lowered or "analysis-situs" in lowered
if isinstance(value, dict):
for key, item in value.items():
key_text = str(key).lower()
if (
key_text.startswith("asitus_")
or key_text.startswith("analysis_situs_")
or key_text.startswith("external_recognition_")
) and not self._operation_value_is_empty(item):
return True
if self._operation_has_analysis_situs_evidence(item):
return True
return False
if isinstance(value, (tuple, list, set)):
return any(self._operation_has_analysis_situs_evidence(item) for item in value)
return False
@staticmethod
def _operation_value_is_empty(value: object) -> bool:
if value is None or value == "":
return True
if isinstance(value, (tuple, list, set, dict)) and not value:
return True
return False
def _make_operation_record(
self,
operation_name: str,
@@ -9312,7 +9534,9 @@ class WindowActionMixin:
pick_position: tuple[float, float, float] | None = None,
before_snapshot: dict[int, object] | None = None,
after_snapshot: dict[int, object] | None = None,
isolation: dict[str, object] | None = None,
) -> OperationRecord:
parameters = self._operation_parameters_with_recognition_sources(parameters, target_kind, target_id)
target_summary = target
if target_kind in {"face", "feature"} and target_logical_id is not None:
target_summary = f"{target_kind} logical {target_logical_id}"
@@ -9377,6 +9601,7 @@ class WindowActionMixin:
f"target: {target}",
f"target_kind: {target_kind or ''}",
f"target_id: {target_id if target_id is not None else ''}",
*self._operation_backend_log_lines(parameters, result_message, isolation=isolation),
"parameters:",
]
if target_logical_id is not None: