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
+4 -2
View File
@@ -1,11 +1,13 @@
from __future__ import annotations
from .model import StepModel
__all__ = ["StepEditorWindow", "StepModel", "main"]
def __getattr__(name: str):
if name == "StepModel":
from .model import StepModel
return StepModel
if name in {"StepEditorWindow", "main"}:
from .app import StepEditorWindow, main
+55 -11
View File
@@ -173,7 +173,9 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
self.orientation_marker_prop = None
self.step_coordinate_axes_actor = None
self.hide_edges_during_camera_interaction = False
self.hide_overlays_during_camera_interaction = False
self.edge_visibility_before_camera_interaction: int | None = None
self.overlay_visibility_before_camera_interaction: dict[str, int] = {}
self.prefer_fxaa_antialiasing = True
self.fallback_multi_samples = 2
self.interactive_multi_samples = 0
@@ -184,6 +186,8 @@ 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.large_model_edge_overlay_skipped = False
self.large_model_hover_disabled = False
self.hover_interval_ms = 260
self.hover_move_threshold_px = 10
self.pending_hover_position: tuple[int, int] | None = None
@@ -260,6 +264,17 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
self.asitus_thread: QThread | None = None
self.asitus_worker: ScanWorker | None = None
self.pending_asitus_context: dict[str, object] | None = None
self.scdm_thread: QThread | None = None
self.scdm_worker: ScanWorker | None = None
self.pending_scdm_context: dict[str, object] | None = None
self.scdm_backend_status: dict[str, object] | None = None
self.scdm_auto_config_prompt_seen = False
self.scdm_auto_config_prompt_active = False
self.scdm_feature_cache: dict[str, object] | None = None
self.scdm_feature_cache_state = "empty"
self.scdm_feature_cache_message = ""
self.scdm_feature_cache_path = ""
self.scdm_edit_runner_ready = {"face.offset", "hole.diameter", "hole.position", "slot.position", "boss.position"}
self.load_in_progress = False
self.load_thread: QThread | None = None
self.load_worker: LoadWorker | None = None
@@ -505,10 +520,40 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
border-color: #bbf7d0;
color: #14532d;
}
QPushButton#softwareProgressButton {
background: #f0fdf4;
border: 1px solid #86efac;
border-left: 5px solid #16a34a;
border-radius: 7px;
color: #14532d;
font-weight: 800;
min-height: 30px;
padding: 5px 10px;
text-align: left;
}
QPushButton#softwareProgressButton:hover {
background: #dcfce7;
border-color: #22c55e;
}
QPushButton#softwareProgressButton:pressed {
background: #bbf7d0;
border-color: #16a34a;
padding-top: 6px;
padding-bottom: 4px;
}
QLabel#capabilityHeadline {
color: #14532d;
font-weight: 800;
}
QLabel#scdmBackendStatus {
color: #166534;
font-size: 11px;
font-weight: 700;
}
QLabel#scdmBackendDetail {
color: #3f6212;
font-size: 11px;
}
QLabel#capabilityDetail {
color: #166534;
font-size: 11px;
@@ -1252,6 +1297,7 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
"特征模式显示当前特征及局部关联特征的可变尺寸;建模意图决定这次修改是局部重建、拉伸/切除、端面移动还是整体缩放。",
)
self.property_table.itemChanged.connect(self._on_property_table_item_changed)
self.property_table.itemSelectionChanged.connect(lambda: self._update_property_apply_state())
object_edit_layout.addWidget(self.property_table)
self.property_command_summary_label = QLabel("未选择可编辑对象")
self.property_command_summary_label.setObjectName("propertyCommandSummary")
@@ -1361,7 +1407,7 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
self.apply_property_button.setObjectName("parametricModelButton")
self.apply_property_button.setMinimumHeight(34)
self.apply_property_button.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
help_tip(self.apply_property_button, "应用当前被修改的参数;多个目标值会按表格顺序依次执行,失败时停止后续修改。")
help_tip(self.apply_property_button, "应用当前被修改的数值参数;命令型参数需先选中该行。多个项目会按表格顺序依次执行,失败时停止后续修改。")
self.apply_property_button.clicked.connect(self.apply_current_property_edit)
self.quick_export_all_button = QPushButton("导出模型")
self.quick_export_all_button.setObjectName("quickExportStepButton")
@@ -1697,16 +1743,14 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
edit_layout.addWidget(self.rotate_solid_button, 27, 0, 1, 2)
panel_layout.addWidget(self.object_edit_box)
self.current_capability_box = QGroupBox("软件进度")
self.current_capability_box.setObjectName("capabilitySection")
capability_layout = QVBoxLayout(self.current_capability_box)
capability_layout.setContentsMargins(8, 8, 8, 7)
capability_layout.setSpacing(2)
self.current_capability_headline = QLabel("当前支持:Face、孔/槽、Edge、凸台、圆角/倒角、壳体")
self.current_capability_headline.setObjectName("capabilityHeadline")
self.current_capability_headline.setWordWrap(True)
capability_layout.addWidget(self.current_capability_headline)
panel_layout.addWidget(self.current_capability_box)
self.current_capability_button = QPushButton("软件进度")
self.current_capability_button.setObjectName("softwareProgressButton")
self.current_capability_button.setMinimumHeight(32)
self.current_capability_button.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
help_tip(self.current_capability_button, "点击查看当前参数化能力、SCDM 后端状态和后续实施路线。")
self.current_capability_button.clicked.connect(self.show_software_progress_dialog)
panel_layout.addWidget(self.current_capability_button)
self._update_current_capability_panel()
if ENABLE_EXPORT_PANEL:
panel_layout.addWidget(export_box)
if ENABLE_VIEW_PANEL:
+18 -9
View File
@@ -3452,20 +3452,28 @@ class FeatureMixin:
plane_axis_alignment = abs(_direction_dot(plane_axis, axis_dir))
if plane_axis_alignment < 0.92:
continue
try:
axis_range = self._cylindrical_axis_range(adjacent_id, side_surf)
except Exception:
axis_range = {
"v_min": min(float(side_surf.FirstVParameter()), float(side_surf.LastVParameter())),
"v_max": max(float(side_surf.FirstVParameter()), float(side_surf.LastVParameter())),
}
v_min = float(axis_range["v_min"])
v_max = float(axis_range["v_max"])
source_v_min = min(float(side_surf.FirstVParameter()), float(side_surf.LastVParameter()))
source_v_max = max(float(side_surf.FirstVParameter()), float(side_surf.LastVParameter()))
v_min = source_v_min
v_max = source_v_max
range_source = "selected-face-v-range-fast"
height = max(v_max - v_min, 1e-9)
cap_parameter = _axis_parameter(axis_point, axis_dir, plane_point)
start_distance = abs(cap_parameter - v_min)
end_distance = abs(cap_parameter - v_max)
end_tolerance = max(height * 0.05, radius * 0.2, tolerance * 10.0, 0.05)
if start_distance > end_tolerance and end_distance > end_tolerance:
try:
axis_range = self._cylindrical_axis_range(adjacent_id, side_surf)
v_min = float(axis_range["v_min"])
v_max = float(axis_range["v_max"])
range_source = str(axis_range.get("range_source") or "same-domain-cylinder-faces")
height = max(v_max - v_min, 1e-9)
start_distance = abs(cap_parameter - v_min)
end_distance = abs(cap_parameter - v_max)
end_tolerance = max(height * 0.05, radius * 0.2, tolerance * 10.0, 0.05)
except Exception:
pass
if start_distance <= end_distance and start_distance <= end_tolerance:
outward = _neg_tuple(_dir_tuple(axis_dir))
end_label = "start"
@@ -3487,6 +3495,7 @@ class FeatureMixin:
"cap_axis_parameter": cap_parameter,
"cap_axis_start_parameter": v_min,
"cap_axis_end_parameter": v_max,
"cap_axis_range_source": range_source,
},
)
)
+75 -3
View File
@@ -226,6 +226,62 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
info.update(_shape_volume_info(self.shape))
return info
def scdm_local_face_signatures(self) -> list[dict[str, object]]:
"""Return cheap geometry hints used to map SCDM raw objects back to local Face IDs."""
signatures: list[dict[str, object]] = []
for face_id, face in enumerate(self.faces):
try:
surf = BRepAdaptor_Surface(face)
surface_type = surf.GetType()
except Exception:
continue
signature: dict[str, object] = {
"faceId": int(face_id),
"logicalFaceId": self.face_logical_id(face_id),
"surfaceType": SURFACE_TYPES.get(surface_type, f"type {surface_type}"),
}
try:
if surface_type == GeomAbs_Plane:
plane = surf.Plane()
origin = _point_tuple(plane.Location())
normal = _dir_tuple(plane.Axis().Direction())
signature["center"] = origin
signature["axis"] = normal
signature["planeOffset"] = (
origin[0] * normal[0]
+ origin[1] * normal[1]
+ origin[2] * normal[2]
)
elif surface_type == GeomAbs_Cylinder:
cylinder = surf.Cylinder()
axis = cylinder.Axis()
radius = cylinder.Radius()
signature["center"] = _point_tuple(axis.Location())
signature["axis"] = _dir_tuple(axis.Direction())
signature["radius"] = radius
signature["diameter"] = radius * 2.0
elif surface_type == GeomAbs_Cone:
cone = surf.Cone()
signature["center"] = _point_tuple(cone.Location())
signature["axis"] = _dir_tuple(cone.Axis().Direction())
signature["radius"] = cone.RefRadius()
elif surface_type == GeomAbs_Sphere:
sphere = surf.Sphere()
radius = sphere.Radius()
signature["center"] = _point_tuple(sphere.Location())
signature["radius"] = radius
signature["diameter"] = radius * 2.0
elif surface_type == GeomAbs_Torus:
torus = surf.Torus()
signature["center"] = _point_tuple(torus.Location())
signature["axis"] = _dir_tuple(torus.Axis().Direction())
signature["majorRadius"] = torus.MajorRadius()
signature["minorRadius"] = torus.MinorRadius()
except Exception:
pass
signatures.append(signature)
return signatures
def refresh_topology(self) -> None:
self._topology_refresh_generation = int(getattr(self, "_topology_refresh_generation", 0)) + 1
self.shape = _compound_from_shapes([p.shape for p in self.display_parts()])
@@ -746,7 +802,8 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
This intentionally avoids material-side sampling. It only combines
directly connected co-cylindrical fragments and uses face orientation as
a hint, so full edit plans still recompute and guard the real feature
a hint. It must not trigger Analysis Situs or the internal recognition
graph; full edit plans still recompute and guard the real feature
semantics before changing geometry.
"""
radius = _float_or_none(info.get("radius")) or 0.0
@@ -765,7 +822,7 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
same_domain_note = "快速识别:当前圆柱没有检测到直接相接的同域碎面。"
axis_range: dict[str, object] | None = None
try:
side_face_ids = self.connected_same_domain_face_ids(face_id) or [face_id]
side_face_ids = self._connected_cocylindrical_face_ids(face_id) or [face_id]
spans: list[float] = []
for side_id in side_face_ids:
side_surf = BRepAdaptor_Surface(self.faces[side_id])
@@ -1044,6 +1101,21 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
except Exception:
return None
def face_plane_position_along(self, face_id: int, direction: tuple[float, float, float]) -> tuple[float, tuple[float, float, float]] | None:
if face_id < 0 or face_id >= len(self.faces):
return None
try:
surf = BRepAdaptor_Surface(self.faces[face_id])
if surf.GetType() != GeomAbs_Plane:
return None
plane = surf.Plane()
origin = _point_tuple(plane.Location())
normal = _dir_tuple(plane.Axis().Direction())
value = origin[0] * direction[0] + origin[1] * direction[1] + origin[2] * direction[2]
return float(value), normal
except Exception:
return None
def _recognition_summary_fields(self, info: dict[str, object]) -> dict[str, object]:
surface = str(info.get("surface") or "")
user_priority = feature_recognition_priority(info)
@@ -4663,7 +4735,7 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
return list(self._internal_hole_region_cache.get(face_id, ()))
def _can_use_internal_recognition_graph(self) -> bool:
return bool(self.faces)
return bool(self.faces) and len(self.faces) <= 1000
def _load_internal_hole_regions(self) -> None:
self._internal_hole_regions_attempted = True
+23 -3
View File
@@ -11298,6 +11298,11 @@ class OperationMixin:
return None
source_plane = source_surf.Plane()
cap_plane_point = source_plane.Location()
try:
if len(_explore(self.faces[face_id], TopAbs_WIRE)) > 2:
return None
except Exception:
pass
try:
scope_face_ids = self._connected_coplanar_planar_face_ids(face_id) or [face_id]
except Exception:
@@ -11342,14 +11347,29 @@ class OperationMixin:
continue
center_axis_distance = _point_axis_distance(axis_point, axis_dir, cap_center)
axis_range = self._cylindrical_axis_range(adjacent_id, side_surf)
v_min = float(axis_range["v_min"])
v_max = float(axis_range["v_max"])
source_v_min = min(float(side_surf.FirstVParameter()), float(side_surf.LastVParameter()))
source_v_max = max(float(side_surf.FirstVParameter()), float(side_surf.LastVParameter()))
axis_range = {
"v_min": source_v_min,
"v_max": source_v_max,
"same_domain_face_ids": (adjacent_id,),
"range_source": "selected-face-v-range-fast",
}
v_min = source_v_min
v_max = source_v_max
old_height = max(v_max - v_min, 1e-9)
cap_parameter = _axis_parameter(axis_point, axis_dir, cap_plane_point)
start_distance = abs(cap_parameter - v_min)
end_distance = abs(cap_parameter - v_max)
end_tolerance = max(old_height * 0.05, radius * 0.2, tolerance * 10.0, 0.05)
if start_distance > end_tolerance and end_distance > end_tolerance:
axis_range = self._cylindrical_axis_range(adjacent_id, side_surf)
v_min = float(axis_range["v_min"])
v_max = float(axis_range["v_max"])
old_height = max(v_max - v_min, 1e-9)
start_distance = abs(cap_parameter - v_min)
end_distance = abs(cap_parameter - v_max)
end_tolerance = max(old_height * 0.05, radius * 0.2, tolerance * 10.0, 0.05)
delta_abs = abs(float(distance))
operation = "extend" if distance > 0 else "retract"
+623
View File
@@ -0,0 +1,623 @@
from __future__ import annotations
import json
import os
import re
import shutil
import subprocess
import tempfile
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Callable, Iterable, Mapping, Sequence
try: # pragma: no cover - exercised only on Windows hosts with registry access.
import winreg
except ImportError: # pragma: no cover
winreg = None # type: ignore[assignment]
SCDM_EXE_NAME = "SpaceClaim.exe"
SCDM_CACHE_RELATIVE_PATH = Path("local") / "scdm_backend.json"
SCDM_PATH_ENV_VARS = (
"STEP_EDITOR_SCDM_EXE",
"STEP_EDITOR_SPACECLAIM_EXE",
"SPACECLAIM_EXE",
)
SCDM_DISABLE_ENV = "STEP_EDITOR_DISABLE_SCDM"
SCDM_TIMEOUT_ENV = "STEP_EDITOR_SCDM_TIMEOUT"
SCDM_CACHE_SCHEMA_VERSION = 1
@dataclass(frozen=True)
class ScdmBackendInfo:
path: Path
source: str
version: str = ""
verified_at: str = ""
run_script_ok: bool = False
license_ok: bool | None = None
message: str = ""
def to_cache(self) -> dict[str, object]:
return {
"schemaVersion": SCDM_CACHE_SCHEMA_VERSION,
"path": str(self.path),
"source": self.source,
"version": self.version,
"verifiedAt": self.verified_at,
"runScriptOk": bool(self.run_script_ok),
"licenseOk": self.license_ok,
"message": self.message,
}
@classmethod
def from_cache(cls, payload: Mapping[str, object]) -> "ScdmBackendInfo | None":
raw_path = str(payload.get("path") or "").strip()
if not raw_path:
return None
path = Path(os.path.expandvars(raw_path)).expanduser()
if not _is_spaceclaim_exe(path):
return None
return cls(
path=path,
source=str(payload.get("source") or "cache"),
version=str(payload.get("version") or _version_from_path(path)),
verified_at=str(payload.get("verifiedAt") or ""),
run_script_ok=bool(payload.get("runScriptOk")),
license_ok=_optional_bool(payload.get("licenseOk")),
message=str(payload.get("message") or ""),
)
def project_root(project_root_override: str | Path | None = None) -> Path:
return Path(project_root_override).expanduser() if project_root_override else Path(__file__).resolve().parent.parent
def default_scdm_cache_path(project_root_override: str | Path | None = None) -> Path:
return project_root(project_root_override) / SCDM_CACHE_RELATIVE_PATH
def is_scdm_disabled(env: Mapping[str, str] | None = None) -> bool:
value = (env or os.environ).get(SCDM_DISABLE_ENV, "")
return value.strip().lower() in {"1", "true", "yes", "on"}
def load_scdm_backend_cache(
*,
project_root_override: str | Path | None = None,
cache_path: str | Path | None = None,
) -> ScdmBackendInfo | None:
path = Path(cache_path).expanduser() if cache_path else default_scdm_cache_path(project_root_override)
if not path.is_file():
return None
try:
payload = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return None
if not isinstance(payload, dict):
return None
return ScdmBackendInfo.from_cache(payload)
def save_scdm_backend_cache(
backend: ScdmBackendInfo,
*,
project_root_override: str | Path | None = None,
cache_path: str | Path | None = None,
) -> Path:
path = Path(cache_path).expanduser() if cache_path else default_scdm_cache_path(project_root_override)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(backend.to_cache(), ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
return path
def discover_scdm_backend_candidates(
*,
manual_path: str | Path | None = None,
include_env: bool = True,
include_registry: bool = True,
include_common: bool = True,
include_path: bool = True,
common_roots: Iterable[str | Path] | None = None,
env: Mapping[str, str] | None = None,
) -> tuple[ScdmBackendInfo, ...]:
env_map = env or os.environ
candidates: list[ScdmBackendInfo] = []
if manual_path:
candidates.extend(_info_for_user_value(manual_path, "manual"))
if include_env:
for env_name in SCDM_PATH_ENV_VARS:
raw_value = env_map.get(env_name, "").strip()
if raw_value:
candidates.extend(_info_for_user_value(raw_value, f"env:{env_name}"))
if include_registry:
candidates.extend(_registry_candidates())
if include_common:
candidates.extend(_common_install_candidates(common_roots=common_roots, env=env_map))
if include_path:
found = shutil.which(SCDM_EXE_NAME)
if found:
candidates.extend(_info_for_user_value(found, "PATH"))
return _dedupe_candidates(candidates)
def resolve_scdm_backend(
*,
project_root_override: str | Path | None = None,
cache_path: str | Path | None = None,
manual_path: str | Path | None = None,
prefer_cache: bool = True,
save_cache: bool = True,
validate: bool = False,
include_env: bool = True,
include_registry: bool = True,
include_common: bool = True,
include_path: bool = True,
common_roots: Iterable[str | Path] | None = None,
env: Mapping[str, str] | None = None,
timeout_seconds: float | None = None,
runner: Callable[..., subprocess.CompletedProcess[str]] | None = None,
) -> dict[str, object]:
env_map = env or os.environ
if is_scdm_disabled(env_map):
return {"ok": False, "reason": "disabled", "backend": None, "message": "SCDM backend is disabled by environment."}
if prefer_cache:
cached = load_scdm_backend_cache(project_root_override=project_root_override, cache_path=cache_path)
if cached is not None:
if not validate or cached.run_script_ok:
return _resolution_payload(cached, reason="cache", message="Using cached SCDM backend.")
checked = verify_scdm_backend(cached, timeout_seconds=timeout_seconds, runner=runner)
if checked.get("ok"):
verified = _verified_backend_from_result(cached, checked)
if save_cache:
save_scdm_backend_cache(verified, project_root_override=project_root_override, cache_path=cache_path)
return _resolution_payload(verified, reason="cache-verified", message="Cached SCDM backend passed smoke test.")
failures: list[dict[str, object]] = []
candidates = discover_scdm_backend_candidates(
manual_path=manual_path,
include_env=include_env,
include_registry=include_registry,
include_common=include_common,
include_path=include_path,
common_roots=common_roots,
env=env_map,
)
for candidate in candidates:
backend = candidate
if validate:
checked = verify_scdm_backend(candidate, timeout_seconds=timeout_seconds, runner=runner)
if not checked.get("ok"):
failures.append(
{
"path": str(candidate.path),
"source": candidate.source,
"reason": checked.get("reason"),
"message": checked.get("message"),
}
)
continue
backend = _verified_backend_from_result(candidate, checked)
if save_cache:
save_scdm_backend_cache(backend, project_root_override=project_root_override, cache_path=cache_path)
reason = "discovered-verified" if validate else "discovered"
return _resolution_payload(backend, reason=reason, message=f"SCDM backend resolved from {backend.source}.")
return {
"ok": False,
"reason": "missing-spaceclaim",
"backend": None,
"candidates": (),
"failures": tuple(failures),
"message": "SpaceClaim.exe was not found. Ask the user to configure the SCDM path manually.",
}
def verify_scdm_backend(
backend: ScdmBackendInfo | str | Path,
*,
timeout_seconds: float | None = None,
work_dir: str | Path | None = None,
runner: Callable[..., subprocess.CompletedProcess[str]] | None = None,
) -> dict[str, object]:
if isinstance(backend, ScdmBackendInfo):
info = backend
else:
matches = _info_for_user_value(backend, "manual")
if not matches:
return {
"ok": False,
"reason": "missing-exe",
"path": str(Path(str(backend)).expanduser()),
"message": "SpaceClaim.exe does not exist.",
}
info = matches[0]
if not _is_spaceclaim_exe(info.path):
return {"ok": False, "reason": "missing-exe", "path": str(info.path), "message": "SpaceClaim.exe does not exist."}
timeout = timeout_seconds if timeout_seconds is not None else _timeout_seconds()
temp_context = None
if work_dir is None:
temp_context = tempfile.TemporaryDirectory(prefix="step_editor_scdm_")
work_root = Path(temp_context.name)
else:
work_root = Path(work_dir).expanduser()
work_root.mkdir(parents=True, exist_ok=True)
try:
script_path = work_root / "scdm_smoke.py"
report_path = work_root / "scdm_smoke_result.json"
script_path.write_text(_smoke_script(report_path), encoding="utf-8")
command = scdm_run_script_command(info.path, script_path)
run = runner or subprocess.run
try:
completed = run(
command,
cwd=str(work_root),
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=max(float(timeout), 0.1),
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
check=False,
)
except subprocess.TimeoutExpired:
return {"ok": False, "reason": "timeout", "path": str(info.path), "message": "SCDM smoke test timed out."}
except OSError as exc:
return {"ok": False, "reason": "launch-failed", "path": str(info.path), "message": str(exc)}
returncode = int(getattr(completed, "returncode", -1))
stdout = str(getattr(completed, "stdout", "") or "")
stderr = str(getattr(completed, "stderr", "") or "")
if returncode != 0:
return {
"ok": False,
"reason": "run-script-failed",
"path": str(info.path),
"returncode": returncode,
"stdout": stdout,
"stderr": stderr,
"message": (stderr or stdout or f"SCDM returned {returncode}.").strip(),
}
if not report_path.is_file():
return {
"ok": False,
"reason": "missing-report",
"path": str(info.path),
"returncode": returncode,
"stdout": stdout,
"stderr": stderr,
"message": "SCDM smoke script finished but did not write a report.",
}
try:
report = json.loads(report_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
return {"ok": False, "reason": "bad-report", "path": str(info.path), "message": str(exc)}
if not isinstance(report, dict) or report.get("ok") is not True:
return {"ok": False, "reason": "negative-report", "path": str(info.path), "message": str(report)}
return {
"ok": True,
"reason": "ok",
"path": str(info.path),
"source": info.source,
"version": str(report.get("version") or info.version or _version_from_path(info.path)),
"verifiedAt": _utc_now(),
"runScriptOk": True,
"licenseOk": True,
"returncode": returncode,
"message": str(report.get("message") or "SCDM /RunScript smoke test passed."),
}
finally:
if temp_context is not None:
temp_context.cleanup()
def scdm_run_script_command(spaceclaim_exe: str | Path, script_path: str | Path) -> list[str]:
exe = Path(spaceclaim_exe).expanduser().resolve(strict=False)
script = Path(script_path).expanduser().resolve(strict=False)
return [
str(exe),
f"/RunScript={script}",
"/Headless=True",
"/ExitAfterScript=True",
]
def _info_for_user_value(value: str | Path, source: str) -> list[ScdmBackendInfo]:
path = _spaceclaim_path_from_value(value)
if path is None:
return []
return [ScdmBackendInfo(path=path, source=source, version=_version_from_path(path))]
def _spaceclaim_path_from_value(value: str | Path) -> Path | None:
text = os.path.expandvars(str(value)).strip().strip('"')
if not text:
return None
path = Path(text).expanduser()
possible = [path]
if path.is_dir():
possible = [
path / SCDM_EXE_NAME,
path / "SCDM" / SCDM_EXE_NAME,
]
for candidate in possible:
if _is_spaceclaim_exe(candidate):
return candidate.resolve(strict=False)
return None
def _is_spaceclaim_exe(path: Path) -> bool:
return path.name.lower() == SCDM_EXE_NAME.lower() and path.is_file()
def _registry_candidates() -> list[ScdmBackendInfo]:
if os.name != "nt" or winreg is None:
return []
candidates: list[ScdmBackendInfo] = []
app_path_keys = (
r"SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths\SpaceClaim.exe",
r"SOFTWARE\Classes\Applications\SpaceClaim.exe\shell\open\command",
)
roots = ((winreg.HKEY_CURRENT_USER, "HKCU"), (winreg.HKEY_LOCAL_MACHINE, "HKLM"))
views = (0, getattr(winreg, "KEY_WOW64_64KEY", 0), getattr(winreg, "KEY_WOW64_32KEY", 0))
for root, root_label in roots:
for access in views:
for key_path in app_path_keys:
for raw_value in _registry_key_values(root, key_path, access):
for path in _paths_from_registry_value(raw_value):
candidates.extend(_info_for_user_value(path, f"registry:{root_label}\\{key_path}"))
candidates.extend(_uninstall_registry_candidates(root, root_label, access))
return candidates
def _registry_key_values(root: int, key_path: str, access: int) -> list[str]:
values: list[str] = []
try:
with winreg.OpenKey(root, key_path, 0, winreg.KEY_READ | access) as key: # type: ignore[union-attr]
for name in ("", "Path", "InstallPath", "InstallLocation"):
try:
value, _value_type = winreg.QueryValueEx(key, name) # type: ignore[union-attr]
except OSError:
continue
if isinstance(value, str) and value.strip():
values.append(value)
except OSError:
return []
return values
def _uninstall_registry_candidates(root: int, root_label: str, access: int) -> list[ScdmBackendInfo]:
uninstall_key = r"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall"
candidates: list[ScdmBackendInfo] = []
try:
with winreg.OpenKey(root, uninstall_key, 0, winreg.KEY_READ | access) as key: # type: ignore[union-attr]
index = 0
while True:
try:
subkey_name = winreg.EnumKey(key, index) # type: ignore[union-attr]
except OSError:
break
index += 1
try:
with winreg.OpenKey(key, subkey_name, 0, winreg.KEY_READ | access) as subkey: # type: ignore[union-attr]
display_name = _registry_string(subkey, "DisplayName")
install_location = _registry_string(subkey, "InstallLocation")
except OSError:
continue
if "spaceclaim" not in display_name.lower() and "ansys" not in display_name.lower():
continue
for path in _paths_from_registry_value(install_location):
candidates.extend(_info_for_user_value(path, f"registry:{root_label}\\Uninstall"))
except OSError:
return []
return candidates
def _registry_string(key: object, name: str) -> str:
try:
value, _value_type = winreg.QueryValueEx(key, name) # type: ignore[union-attr]
except OSError:
return ""
return value if isinstance(value, str) else ""
def _paths_from_registry_value(value: str) -> list[str]:
text = value.strip()
if not text:
return []
exe = _extract_exe_from_command(text)
if exe:
return [exe]
return [
text,
str(Path(text) / SCDM_EXE_NAME),
str(Path(text) / "SCDM" / SCDM_EXE_NAME),
]
def _extract_exe_from_command(command: str) -> str:
text = command.strip()
if not text:
return ""
if text.startswith('"'):
end = text.find('"', 1)
if end > 1:
first = text[1:end]
return first if first.lower().endswith(".exe") else ""
lowered = text.lower()
index = lowered.find(".exe")
if index >= 0:
return text[: index + 4]
return ""
def _common_install_candidates(
*,
common_roots: Iterable[str | Path] | None = None,
env: Mapping[str, str] | None = None,
) -> list[ScdmBackendInfo]:
roots = list(common_roots) if common_roots is not None else _default_common_roots(env or os.environ)
candidates: list[ScdmBackendInfo] = []
for root in roots:
base = Path(os.path.expandvars(str(root))).expanduser()
if not base.is_dir():
continue
direct_paths = (
base / SCDM_EXE_NAME,
base / "SCDM" / SCDM_EXE_NAME,
)
for path in direct_paths:
candidates.extend(_info_for_user_value(path, f"common:{base}"))
version_dirs = sorted((item for item in base.glob("v*") if item.is_dir()), key=_version_sort_key, reverse=True)
for version_dir in version_dirs:
candidates.extend(_info_for_user_value(version_dir / "SCDM" / SCDM_EXE_NAME, f"common:{base}"))
return candidates
def _default_common_roots(env: Mapping[str, str]) -> tuple[Path, ...]:
roots: list[Path] = []
for env_name in ("ProgramW6432", "ProgramFiles", "ProgramFiles(x86)"):
raw = env.get(env_name, "")
if raw:
roots.append(Path(raw) / "ANSYS Inc")
for drive in ("C", "D", "E"):
roots.append(Path(f"{drive}:/Program Files/ANSYS Inc"))
roots.append(Path(f"{drive}:/softwaresInstallDir/ANSYS Inc"))
return tuple(_dedupe_paths(roots))
def _dedupe_candidates(candidates: Iterable[ScdmBackendInfo]) -> tuple[ScdmBackendInfo, ...]:
result: list[ScdmBackendInfo] = []
seen: set[str] = set()
for candidate in candidates:
key = str(candidate.path.resolve(strict=False)).casefold()
if key in seen:
continue
seen.add(key)
result.append(candidate)
return tuple(result)
def _dedupe_paths(paths: Iterable[Path]) -> list[Path]:
result: list[Path] = []
seen: set[str] = set()
for path in paths:
key = str(path.resolve(strict=False)).casefold()
if key in seen:
continue
seen.add(key)
result.append(path)
return result
def _version_sort_key(path: Path) -> tuple[int, str]:
match = re.search(r"v(\d+)", path.name, flags=re.IGNORECASE)
return (int(match.group(1)) if match else -1, path.name.lower())
def _version_from_path(path: Path) -> str:
for part in path.parts:
match = re.fullmatch(r"v\d+", part, flags=re.IGNORECASE)
if match:
return part
return ""
def _verified_backend_from_result(candidate: ScdmBackendInfo, result: Mapping[str, object]) -> ScdmBackendInfo:
return ScdmBackendInfo(
path=candidate.path,
source=candidate.source,
version=str(result.get("version") or candidate.version),
verified_at=str(result.get("verifiedAt") or _utc_now()),
run_script_ok=bool(result.get("runScriptOk")),
license_ok=_optional_bool(result.get("licenseOk")),
message=str(result.get("message") or candidate.message),
)
def _resolution_payload(backend: ScdmBackendInfo, *, reason: str, message: str) -> dict[str, object]:
return {
"ok": True,
"reason": reason,
"backend": backend,
"path": str(backend.path),
"source": backend.source,
"version": backend.version,
"verifiedAt": backend.verified_at,
"runScriptOk": backend.run_script_ok,
"licenseOk": backend.license_ok,
"message": message,
}
def _smoke_script(report_path: Path) -> str:
report_literal = repr(str(report_path))
return (
"from __future__ import print_function\n"
f"report_path = {report_literal}\n"
"version = ''\n"
"try:\n"
" version = str(Application.Version)\n"
"except Exception:\n"
" version = ''\n"
"payload = '{\"ok\": true, \"version\": \"' + version.replace('\\\\', '\\\\\\\\').replace('\"', '\\\\\"') + '\", \"message\": \"RunScript reached\"}'\n"
"handle = open(report_path, 'w')\n"
"handle.write(payload)\n"
"handle.close()\n"
)
def _timeout_seconds() -> float:
try:
return max(float(os.environ.get(SCDM_TIMEOUT_ENV, "") or 25.0), 0.1)
except ValueError:
return 25.0
def _utc_now() -> str:
return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
def _optional_bool(value: object) -> bool | None:
if value is None:
return None
if isinstance(value, bool):
return value
if isinstance(value, str):
text = value.strip().lower()
if text in {"1", "true", "yes", "on"}:
return True
if text in {"0", "false", "no", "off"}:
return False
return None
__all__ = [
"SCDM_CACHE_RELATIVE_PATH",
"SCDM_DISABLE_ENV",
"SCDM_EXE_NAME",
"SCDM_PATH_ENV_VARS",
"SCDM_TIMEOUT_ENV",
"ScdmBackendInfo",
"default_scdm_cache_path",
"discover_scdm_backend_candidates",
"is_scdm_disabled",
"load_scdm_backend_cache",
"project_root",
"resolve_scdm_backend",
"save_scdm_backend_cache",
"scdm_run_script_command",
"verify_scdm_backend",
]
+380
View File
@@ -0,0 +1,380 @@
from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass
@dataclass(frozen=True)
class ScdmCapabilityDefinition:
key: str
display_name: str
object_types: tuple[str, ...]
value_kind: str
current_fields: tuple[str, ...]
default_intent: str
backend_operation: str
post_check: str
required_backend_command_groups: tuple[tuple[str, ...], ...] = ()
productized: bool = True
roadmap_stage: str = "S5"
block_reason: str = ""
def to_payload(self) -> dict[str, object]:
return {
"key": self.key,
"displayName": self.display_name,
"objectTypes": self.object_types,
"valueKind": self.value_kind,
"currentFields": self.current_fields,
"defaultIntent": self.default_intent,
"backendOperation": self.backend_operation,
"postCheck": self.post_check,
"requiredBackendCommandGroups": self.required_backend_command_groups,
"productized": self.productized,
"roadmapStage": self.roadmap_stage,
"blockReason": self.block_reason,
}
CAPABILITY_DEFINITIONS: dict[str, ScdmCapabilityDefinition] = {
"hole.diameter": ScdmCapabilityDefinition(
key="hole.diameter",
display_name="直径",
object_types=("hole", "cylindrical_hole", "cylindrical_face_group"),
value_kind="number",
current_fields=("geometry.diameter", "geometry.radius*2"),
default_intent="修改孔径",
backend_operation="change_hole_diameter",
post_check="target_hole_diameter",
required_backend_command_groups=(("StandardHoles",), ("OffsetFaces",)),
roadmap_stage="S5",
),
"hole.position": ScdmCapabilityDefinition(
key="hole.position",
display_name="位置",
object_types=("hole", "cylindrical_hole", "cylindrical_face_group"),
value_kind="vector3",
current_fields=("geometry.center", "geometry.axisCenter"),
default_intent="移动孔",
backend_operation="move_hole_axis",
post_check="target_hole_axis_center",
required_backend_command_groups=(("Move",),),
roadmap_stage="S5",
),
"face.offset": ScdmCapabilityDefinition(
key="face.offset",
display_name="偏移",
object_types=("face", "planar_face"),
value_kind="number",
current_fields=("geometry.offset", "geometry.planeOffset", "0"),
default_intent="推拉平面",
backend_operation="pull_face_offset",
post_check="target_face_offset",
required_backend_command_groups=(("OffsetFaces",),),
roadmap_stage="S5",
),
"feature.fill": ScdmCapabilityDefinition(
key="feature.fill",
display_name="填孔/删除小特征",
object_types=("hole", "small_feature"),
value_kind="command",
current_fields=("1",),
default_intent="删除并补面",
backend_operation="fill_feature",
post_check="target_feature_removed",
required_backend_command_groups=(("Fill",), ("Delete",)),
roadmap_stage="S5",
),
"slot.width": ScdmCapabilityDefinition(
key="slot.width",
display_name="槽宽",
object_types=("slot", "obround_slot", "rectangular_slot"),
value_kind="number",
current_fields=("geometry.width",),
default_intent="修改槽宽",
backend_operation="change_slot_width",
post_check="target_slot_width",
productized=False,
roadmap_stage="S7.2",
block_reason="槽宽属于 S7 第二批能力,真实 SCDM 命令和 STEP 样例回测尚未完成。",
),
"slot.depth": ScdmCapabilityDefinition(
key="slot.depth",
display_name="槽深",
object_types=("slot", "obround_slot", "rectangular_slot"),
value_kind="number",
current_fields=("geometry.depth",),
default_intent="修改槽深",
backend_operation="change_slot_depth",
post_check="target_slot_depth",
productized=False,
roadmap_stage="S7.2",
block_reason="槽深属于 S7 第二批能力,真实 SCDM 命令和 STEP 样例回测尚未完成。",
),
"slot.position": ScdmCapabilityDefinition(
key="slot.position",
display_name="槽位置",
object_types=("slot", "obround_slot", "rectangular_slot"),
value_kind="vector3",
current_fields=("geometry.center", "geometry.axisCenter"),
default_intent="移动槽",
backend_operation="move_slot",
post_check="target_slot_center",
required_backend_command_groups=(("Move",),),
roadmap_stage="S7.2",
),
"boss.height": ScdmCapabilityDefinition(
key="boss.height",
display_name="凸台高度",
object_types=("boss", "cylindrical_boss", "rectangular_boss"),
value_kind="number",
current_fields=("geometry.height",),
default_intent="修改凸台高度",
backend_operation="change_boss_height",
post_check="target_boss_height",
productized=False,
roadmap_stage="S7.3",
block_reason="凸台高度属于 S7 第三批能力,真实 SCDM 命令和 STEP 样例回测尚未完成。",
),
"boss.diameter": ScdmCapabilityDefinition(
key="boss.diameter",
display_name="凸台直径",
object_types=("boss", "cylindrical_boss"),
value_kind="number",
current_fields=("geometry.diameter", "geometry.radius*2"),
default_intent="修改凸台直径",
backend_operation="change_boss_diameter",
post_check="target_boss_diameter",
productized=False,
roadmap_stage="S7.3",
block_reason="凸台直径属于 S7 第三批能力,真实 SCDM 命令和 STEP 样例回测尚未完成。",
),
"boss.position": ScdmCapabilityDefinition(
key="boss.position",
display_name="凸台位置",
object_types=("boss", "cylindrical_boss", "rectangular_boss"),
value_kind="vector3",
current_fields=("geometry.center", "geometry.axisCenter"),
default_intent="移动凸台",
backend_operation="move_boss",
post_check="target_boss_center",
required_backend_command_groups=(("Move",),),
roadmap_stage="S7.3",
),
"round.radius": ScdmCapabilityDefinition(
key="round.radius",
display_name="圆角半径",
object_types=("round", "fillet"),
value_kind="number",
current_fields=("geometry.radius",),
default_intent="修改圆角半径",
backend_operation="change_round_radius",
post_check="target_round_radius",
required_backend_command_groups=(("ConstantRound",),),
productized=False,
roadmap_stage="S7.4",
block_reason="圆角半径属于 S7 第四批能力,真实 SCDM 命令和 STEP 样例回测尚未完成。",
),
"chamfer.distance": ScdmCapabilityDefinition(
key="chamfer.distance",
display_name="倒角距离",
object_types=("chamfer",),
value_kind="number",
current_fields=("geometry.distance", "geometry.offset"),
default_intent="修改倒角距离",
backend_operation="change_chamfer_distance",
post_check="target_chamfer_distance",
required_backend_command_groups=(("Chamfer",),),
productized=False,
roadmap_stage="S7.4",
block_reason="倒角距离属于 S7 第四批能力,真实 SCDM 命令和 STEP 样例回测尚未完成。",
),
"feature.delete_round_or_chamfer": ScdmCapabilityDefinition(
key="feature.delete_round_or_chamfer",
display_name="删除圆角/倒角",
object_types=("round", "fillet", "chamfer"),
value_kind="command",
current_fields=("1",),
default_intent="删除圆角/倒角并补面",
backend_operation="delete_round_or_chamfer",
post_check="target_feature_removed",
required_backend_command_groups=(("Fill",), ("Delete",)),
productized=False,
roadmap_stage="S7.4",
block_reason="删除圆角/倒角属于 S7 第四批能力,真实 SCDM 命令和 STEP 样例回测尚未完成。",
),
"pattern.spacing": ScdmCapabilityDefinition(
key="pattern.spacing",
display_name="阵列间距",
object_types=("pattern", "linear_pattern"),
value_kind="number",
current_fields=("geometry.spacing", "geometry.pitch"),
default_intent="修改阵列间距",
backend_operation="change_pattern_spacing",
post_check="target_pattern_spacing",
productized=False,
roadmap_stage="S7.5",
block_reason="阵列间距属于 S7 第五批能力,真实 SCDM 命令和 STEP 样例回测尚未完成。",
),
"pattern.instance_position": ScdmCapabilityDefinition(
key="pattern.instance_position",
display_name="阵列实例位置",
object_types=("pattern", "linear_pattern"),
value_kind="vector3",
current_fields=("geometry.instanceCenter", "geometry.center"),
default_intent="移动阵列实例",
backend_operation="move_pattern_instance",
post_check="target_pattern_instance_center",
productized=False,
roadmap_stage="S7.5",
block_reason="阵列实例位置属于 S7 第五批能力,真实 SCDM 命令和 STEP 样例回测尚未完成。",
),
"shell.thickness": ScdmCapabilityDefinition(
key="shell.thickness",
display_name="壳体厚度",
object_types=("shell", "thin_wall"),
value_kind="number",
current_fields=("geometry.thickness",),
default_intent="修改壳体厚度",
backend_operation="change_shell_thickness",
post_check="target_shell_thickness",
productized=False,
roadmap_stage="S7.5",
block_reason="壳体厚度属于 S7 第五批能力,真实 SCDM 命令和 STEP 样例回测尚未完成。",
),
}
def capability_definition(key: str) -> ScdmCapabilityDefinition | None:
return CAPABILITY_DEFINITIONS.get(key)
def productized_capability_keys(raw_object: Mapping[str, object]) -> tuple[str, ...]:
return tuple(
key
for key in capability_keys_for_raw_object(raw_object, include_planned=False)
if (definition := capability_definition(key)) is not None and definition.productized
)
def planned_capability_keys(raw_object: Mapping[str, object]) -> tuple[str, ...]:
return tuple(
key
for key in capability_keys_for_raw_object(raw_object, include_planned=True)
if (definition := capability_definition(key)) is not None and not definition.productized
)
def capability_keys_for_raw_object(raw_object: Mapping[str, object], *, include_planned: bool = False) -> tuple[str, ...]:
object_type = str(raw_object.get("objectType") or "").strip().lower()
geometry = _mapping(raw_object.get("geometry"))
commands = tuple(_command_operations(raw_object.get("backendCommandCandidates")))
keys: list[str] = []
if object_type in {"hole", "cylindrical_hole", "cylindrical_face_group"}:
if _has_any(geometry, ("diameter", "radius")) or _has_command_token(commands, ("diameter", "radius")):
keys.append("hole.diameter")
if _has_any(geometry, ("center", "axisCenter")) or _has_command_token(commands, ("move", "position", "translate")):
keys.append("hole.position")
if _has_command_token(commands, ("fill", "delete", "remove")):
keys.append("feature.fill")
surface_type = str(geometry.get("surfaceType") or geometry.get("surface") or "").strip().lower()
if object_type in {"face", "planar_face"} and surface_type in {"plane", "planar", ""}:
if _has_command_token(commands, ("pull", "offset", "move_face")) or _has_any(geometry, ("normal", "planeOffset")):
keys.append("face.offset")
if object_type in {"hole", "small_feature"} and _has_command_token(commands, ("fill", "delete", "remove")):
keys.append("feature.fill")
if object_type in {"slot", "obround_slot", "rectangular_slot"}:
if _has_any(geometry, ("width",)) or _has_command_token(commands, ("slot_width", "width")):
keys.append("slot.width")
if _has_any(geometry, ("depth",)) or _has_command_token(commands, ("slot_depth", "depth")):
keys.append("slot.depth")
if _has_any(geometry, ("center", "axisCenter")) or _has_command_token(commands, ("move", "position", "translate")):
keys.append("slot.position")
if object_type in {"boss", "cylindrical_boss", "rectangular_boss"}:
if _has_any(geometry, ("height",)) or _has_command_token(commands, ("boss_height", "height")):
keys.append("boss.height")
if object_type != "rectangular_boss" and (_has_any(geometry, ("diameter", "radius")) or _has_command_token(commands, ("diameter", "radius"))):
keys.append("boss.diameter")
if _has_any(geometry, ("center", "axisCenter")) or _has_command_token(commands, ("move", "position", "translate")):
keys.append("boss.position")
if object_type in {"round", "fillet"}:
if _has_any(geometry, ("radius",)) or _has_command_token(commands, ("round_radius", "fillet_radius", "radius")):
keys.append("round.radius")
if _has_command_token(commands, ("fill", "delete", "remove")):
keys.append("feature.delete_round_or_chamfer")
if object_type == "chamfer":
if _has_any(geometry, ("distance", "offset")) or _has_command_token(commands, ("chamfer_distance", "distance", "offset")):
keys.append("chamfer.distance")
if _has_command_token(commands, ("fill", "delete", "remove")):
keys.append("feature.delete_round_or_chamfer")
if object_type in {"pattern", "linear_pattern"}:
if _has_any(geometry, ("spacing", "pitch")) or _has_command_token(commands, ("pattern_spacing", "spacing", "pitch")):
keys.append("pattern.spacing")
if _has_any(geometry, ("instanceCenter", "center")) or _has_command_token(commands, ("move_instance", "instance_position")):
keys.append("pattern.instance_position")
if object_type in {"shell", "thin_wall"}:
if _has_any(geometry, ("thickness",)) or _has_command_token(commands, ("shell_thickness", "thickness")):
keys.append("shell.thickness")
result = []
for key in keys:
definition = capability_definition(key)
if definition is None:
continue
if definition.productized or include_planned:
result.append(key)
return tuple(dict.fromkeys(result))
def _mapping(value: object) -> Mapping[str, object]:
return value if isinstance(value, Mapping) else {}
def _has_any(mapping: Mapping[str, object], names: tuple[str, ...]) -> bool:
return any(name in mapping and mapping.get(name) is not None for name in names)
def _command_operations(value: object) -> list[str]:
if not isinstance(value, list):
return []
result: list[str] = []
for item in value:
if not isinstance(item, Mapping):
continue
enabled = item.get("enabled")
if enabled is False:
continue
text = " ".join(
str(part or "")
for part in (
item.get("key"),
item.get("operation"),
item.get("command"),
item.get("type"),
)
)
result.append(text.lower())
return result
def _has_command_token(commands: tuple[str, ...], tokens: tuple[str, ...]) -> bool:
return any(token in command for command in commands for token in tokens)
__all__ = [
"CAPABILITY_DEFINITIONS",
"ScdmCapabilityDefinition",
"capability_keys_for_raw_object",
"capability_definition",
"planned_capability_keys",
"productized_capability_keys",
]
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+680
View File
@@ -0,0 +1,680 @@
from __future__ import annotations
import subprocess
from pathlib import Path
from typing import Callable
from .scdm_backend import ScdmBackendInfo, resolve_scdm_backend, save_scdm_backend_cache, scdm_run_script_command
from .scdm_schema import ScdmProbeJob, default_scdm_work_dir, file_fingerprint, read_json, utc_now, write_json
def prepare_scdm_probe_job(
step_path: str | Path,
*,
output_dir: str | Path | None = None,
project_root: str | Path | None = None,
backend: ScdmBackendInfo | None = None,
unit: str = "model",
scan_scope: str = "all",
) -> dict[str, object]:
source = Path(step_path).expanduser()
if not source.is_file():
return {"ok": False, "reason": "missing-step", "message": f"STEP file not found: {source}"}
fingerprint = file_fingerprint(source)
work_dir = Path(output_dir).expanduser() if output_dir else default_scdm_work_dir(source, project_root=project_root, fingerprint=fingerprint)
work_dir = work_dir.resolve(strict=False)
work_dir.mkdir(parents=True, exist_ok=True)
job = ScdmProbeJob(
step_path=source.resolve(strict=False),
output_dir=work_dir,
raw_features_path=work_dir / "scdm_raw_features.json",
error_path=work_dir / "error.json",
model_fingerprint=fingerprint,
unit=unit,
scan_scope=scan_scope,
backend_path=str(backend.path) if backend else "",
backend_version=backend.version if backend else "",
)
job_path = work_dir / "scdm_probe_job.json"
script_path = work_dir / "scdm_probe.py"
write_json(job_path, job.to_payload())
script_path.write_text(generate_scdm_probe_script(job_path), encoding="utf-8")
return {
"ok": True,
"reason": "ok",
"work_dir": str(work_dir),
"job_path": str(job_path),
"script_path": str(script_path),
"raw_features_path": str(job.raw_features_path),
"error_path": str(job.error_path),
"model_fingerprint": fingerprint,
}
def run_scdm_probe(
step_path: str | Path,
*,
backend: ScdmBackendInfo | None = None,
output_dir: str | Path | None = None,
project_root: str | Path | None = None,
timeout_seconds: float = 120.0,
runner: Callable[..., subprocess.CompletedProcess[str]] | None = None,
) -> dict[str, object]:
if backend is None:
resolved = resolve_scdm_backend(project_root_override=project_root, validate=False)
if not resolved.get("ok") or not isinstance(resolved.get("backend"), ScdmBackendInfo):
return {
"ok": False,
"reason": str(resolved.get("reason") or "missing-scdm"),
"message": str(resolved.get("message") or "SCDM backend is not available."),
"backend_resolution": {
"ok": bool(resolved.get("ok")),
"reason": str(resolved.get("reason") or ""),
"message": str(resolved.get("message") or ""),
},
}
backend = resolved["backend"] # type: ignore[assignment]
prepared = prepare_scdm_probe_job(step_path, output_dir=output_dir, project_root=project_root, backend=backend)
if not prepared.get("ok"):
return {"backend": backend.to_cache(), **prepared}
script_path = Path(str(prepared["script_path"]))
raw_path = Path(str(prepared["raw_features_path"]))
error_path = Path(str(prepared["error_path"]))
command = scdm_run_script_command(backend.path, script_path)
run = runner or subprocess.run
try:
completed = run(
command,
cwd=str(script_path.parent),
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=max(float(timeout_seconds), 0.1),
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
check=False,
)
except subprocess.TimeoutExpired:
write_json(error_path, {"ok": False, "reason": "timeout", "message": "SCDM probe timed out."})
return {"ok": False, "reason": "timeout", "message": "SCDM probe timed out.", "backend": backend.to_cache(), **prepared}
except OSError as exc:
write_json(error_path, {"ok": False, "reason": "launch-failed", "message": str(exc)})
return {"ok": False, "reason": "launch-failed", "message": str(exc), "backend": backend.to_cache(), **prepared}
returncode = int(getattr(completed, "returncode", -1))
if returncode != 0:
message = (str(getattr(completed, "stderr", "") or "") or str(getattr(completed, "stdout", "") or "")).strip()
write_json(
error_path,
{
"ok": False,
"reason": "probe-failed",
"returncode": returncode,
"message": message,
},
)
return {"ok": False, "reason": "probe-failed", "returncode": returncode, "message": message, "backend": backend.to_cache(), **prepared}
if not raw_path.is_file():
write_json(error_path, {"ok": False, "reason": "missing-raw-output", "message": "SCDM probe did not write raw features."})
return {
"ok": False,
"reason": "missing-raw-output",
"message": "SCDM probe did not write raw features.",
"backend": backend.to_cache(),
**prepared,
}
raw = read_json(raw_path)
verified_backend = _probe_verified_backend(backend)
try:
save_scdm_backend_cache(verified_backend, project_root_override=project_root)
except Exception:
pass
return {"ok": True, "reason": "ok", "raw": raw, "backend": verified_backend.to_cache(), **prepared}
def _probe_verified_backend(backend: ScdmBackendInfo) -> ScdmBackendInfo:
return ScdmBackendInfo(
path=backend.path,
source=backend.source,
version=backend.version,
verified_at=utc_now(),
run_script_ok=True,
license_ok=True,
message="SCDM probe completed.",
)
def generate_scdm_probe_script(job_path: str | Path) -> str:
job_literal = repr(str(Path(job_path).expanduser()))
return (
"from __future__ import print_function\n"
"import json\n"
"import traceback\n"
f"JOB_PATH = {job_literal}\n"
"\n"
"def _write_json(path, payload):\n"
" handle = open(path, 'w')\n"
" try:\n"
" handle.write(json.dumps(payload, indent=2))\n"
" finally:\n"
" handle.close()\n"
"\n"
"def _safe_name(value):\n"
" try:\n"
" return type(value).__name__\n"
" except Exception:\n"
" return ''\n"
"\n"
"def _float_attr(value, names):\n"
" for name in names:\n"
" try:\n"
" result = getattr(value, name)\n"
" return float(result)\n"
" except Exception:\n"
" pass\n"
" return None\n"
"\n"
"def _xyz(value):\n"
" if value is None:\n"
" return []\n"
" result = []\n"
" for name in ('X', 'Y', 'Z'):\n"
" try:\n"
" result.append(float(getattr(value, name)))\n"
" except Exception:\n"
" return []\n"
" return result\n"
"\n"
"def _items(collection):\n"
" if collection is None:\n"
" return []\n"
" try:\n"
" return list(collection)\n"
" except Exception:\n"
" items = []\n"
" try:\n"
" count = int(collection.Count)\n"
" for index in range(count):\n"
" items.append(collection[index])\n"
" except Exception:\n"
" pass\n"
" return items\n"
"\n"
"def _geometry_from_face(face):\n"
" geometry = {}\n"
" surface = None\n"
" for expr in ('Shape.Geometry', 'Geometry', 'Surface'):\n"
" try:\n"
" current = face\n"
" for part in expr.split('.'):\n"
" current = getattr(current, part)\n"
" surface = current\n"
" break\n"
" except Exception:\n"
" pass\n"
" surface_name = _safe_name(surface)\n"
" geometry['surfaceType'] = surface_name\n"
" lowered = surface_name.lower()\n"
" radius = _float_attr(surface, ('Radius', 'radius'))\n"
" if radius is not None:\n"
" geometry['radius'] = radius\n"
" geometry['diameter'] = radius * 2.0\n"
" try:\n"
" geometry['center'] = _xyz(surface.Frame.Origin)\n"
" except Exception:\n"
" pass\n"
" try:\n"
" geometry['axis'] = _xyz(surface.Frame.DirZ)\n"
" except Exception:\n"
" pass\n"
" try:\n"
" center = geometry.get('center') or []\n"
" axis = geometry.get('axis') or []\n"
" if len(center) == 3 and len(axis) == 3:\n"
" geometry['planeOffset'] = center[0] * axis[0] + center[1] * axis[1] + center[2] * axis[2]\n"
" except Exception:\n"
" pass\n"
" if 'plane' in lowered:\n"
" geometry['surfaceType'] = 'plane'\n"
" elif 'cylinder' in lowered:\n"
" geometry['surfaceType'] = 'cylinder'\n"
" round_info = _round_info_from_face(face, geometry)\n"
" if round_info:\n"
" geometry['roundInfo'] = round_info\n"
" return geometry\n"
"\n"
"def _round_info_from_face(face, geometry):\n"
" if str(geometry.get('surfaceType', '')).lower() != 'cylinder':\n"
" return {}\n"
" round_info_type = globals().get('RoundInfo')\n"
" if round_info_type is None:\n"
" return {}\n"
" try:\n"
" info = round_info_type.Create(face)\n"
" except Exception:\n"
" return {}\n"
" payload = {'available': True, 'type': _safe_name(info)}\n"
" for attr in ('Radius', 'RoundRadius', 'ConstantRadius'):\n"
" value = _float_attr(info, (attr, attr[0].lower() + attr[1:]))\n"
" if value is not None:\n"
" payload['radius'] = value\n"
" payload['diameter'] = value * 2.0\n"
" break\n"
" for attr in ('IsConstant', 'IsRound'):\n"
" try:\n"
" payload[attr[0].lower() + attr[1:]] = bool(getattr(info, attr))\n"
" except Exception:\n"
" pass\n"
" try:\n"
" payload['attributes'] = [name for name in dir(info) if not name.startswith('_')][:40]\n"
" except Exception:\n"
" pass\n"
" return payload\n"
"\n"
"def _path_value(value, expr):\n"
" current = value\n"
" for part in expr.split('.'):\n"
" try:\n"
" current = getattr(current, part)\n"
" except Exception:\n"
" return None\n"
" return current\n"
"\n"
"def _first_path_value(value, exprs):\n"
" for expr in exprs:\n"
" result = _path_value(value, expr)\n"
" if result is not None:\n"
" return result\n"
" return None\n"
"\n"
"def _geometry_from_edge(edge):\n"
" geometry = {}\n"
" shape = getattr(edge, 'Shape', edge)\n"
" curve = _first_path_value(edge, ('Shape.Geometry', 'Geometry', 'Shape.Curve', 'Curve', 'Shape')) or shape\n"
" geometry['curveShapeType'] = _safe_name(shape)\n"
" geometry['curveType'] = _safe_name(curve)\n"
" length = _float_attr(edge, ('Length', 'length'))\n"
" if length is None:\n"
" length = _float_attr(shape, ('Length', 'length'))\n"
" if length is not None:\n"
" geometry['length'] = length\n"
" start = _xyz(_first_path_value(edge, ('StartPoint', 'Shape.StartPoint')))\n"
" end = _xyz(_first_path_value(edge, ('EndPoint', 'Shape.EndPoint')))\n"
" if start:\n"
" geometry['startPoint'] = start\n"
" if end:\n"
" geometry['endPoint'] = end\n"
" if len(start) == 3 and len(end) == 3:\n"
" geometry['midPoint'] = [(start[i] + end[i]) * 0.5 for i in range(3)]\n"
" radius = _float_attr(curve, ('Radius', 'radius'))\n"
" if radius is not None:\n"
" geometry['radius'] = radius\n"
" geometry['diameter'] = radius * 2.0\n"
" center = _xyz(_first_path_value(curve, ('Frame.Origin', 'Circle.Frame.Origin')))\n"
" if center:\n"
" geometry['center'] = center\n"
" axis = _xyz(_first_path_value(curve, ('Frame.DirZ', 'Circle.Frame.DirZ')))\n"
" if axis:\n"
" geometry['axis'] = axis\n"
" return geometry\n"
"\n"
"def _edge_adjacent_face_ordinals(edge, face_ordinals_by_marker):\n"
" faces = []\n"
" for expr in ('Faces', 'Shape.Faces', 'GetFaces'):\n"
" value = _path_value(edge, expr)\n"
" if value is None and expr == 'GetFaces':\n"
" value = _maybe_call(edge, 'GetFaces')\n"
" faces = _items(value)\n"
" if faces:\n"
" break\n"
" ordinals = []\n"
" for face in faces:\n"
" marker = str(id(face))\n"
" if marker in face_ordinals_by_marker:\n"
" ordinals.append(face_ordinals_by_marker[marker])\n"
" return {'adjacentFaceCount': len(faces), 'adjacentFaceOrdinals': ordinals}\n"
"\n"
"def _int_or_none(value):\n"
" try:\n"
" return int(value)\n"
" except Exception:\n"
" return None\n"
"\n"
"def _edge_kind(geometry):\n"
" curve_type = str(geometry.get('curveType', '') or geometry.get('curveShapeType', '')).lower()\n"
" if geometry.get('radius') is not None or 'circle' in curve_type or 'arc' in curve_type:\n"
" return 'circular'\n"
" if 'line' in curve_type or 'segment' in curve_type:\n"
" return 'linear'\n"
" return 'other'\n"
"\n"
"def _add_edge_geometry_summary(summary, geometry):\n"
" summary['totalEdgeCount'] = int(summary.get('totalEdgeCount', 0)) + 1\n"
" kind = _edge_kind(geometry)\n"
" kind_counts = summary.setdefault('edgeKindCounts', {})\n"
" kind_counts[kind] = int(kind_counts.get(kind, 0)) + 1\n"
" radius = geometry.get('radius')\n"
" if radius is not None:\n"
" try:\n"
" radius = float(radius)\n"
" summary['circularEdgeCount'] = int(summary.get('circularEdgeCount', 0)) + 1\n"
" values = summary.setdefault('circularRadii', [])\n"
" if len(values) < 80:\n"
" values.append(radius)\n"
" except Exception:\n"
" pass\n"
" length = geometry.get('length')\n"
" if length is not None:\n"
" try:\n"
" length = float(length)\n"
" summary['minEdgeLength'] = min(float(summary.get('minEdgeLength', length)), length)\n"
" summary['maxEdgeLength'] = max(float(summary.get('maxEdgeLength', length)), length)\n"
" except Exception:\n"
" pass\n"
"\n"
"def _final_edge_geometry_summary(summary):\n"
" result = dict(summary)\n"
" radii = result.get('circularRadii')\n"
" if isinstance(radii, list) and radii:\n"
" buckets = {}\n"
" for value in radii:\n"
" try:\n"
" key = '%.6g' % float(value)\n"
" buckets[key] = int(buckets.get(key, 0)) + 1\n"
" except Exception:\n"
" pass\n"
" result['circularRadiusBuckets'] = [\n"
" {'radius': key, 'count': buckets[key]} for key in sorted(buckets.keys())[:40]\n"
" ]\n"
" result.pop('circularRadii', None)\n"
" return result\n"
"\n"
"def _record_face_adjacency(adjacency_map, body_index, edge_topology, geometry):\n"
" ordinals = []\n"
" for value in edge_topology.get('adjacentFaceOrdinals', []) or []:\n"
" number = _int_or_none(value)\n"
" if number is not None and number not in ordinals:\n"
" ordinals.append(number)\n"
" if len(ordinals) < 2:\n"
" return\n"
" ordinals.sort()\n"
" kind = _edge_kind(geometry)\n"
" for left_index in range(len(ordinals)):\n"
" for right_index in range(left_index + 1, len(ordinals)):\n"
" left = ordinals[left_index]\n"
" right = ordinals[right_index]\n"
" key = (body_index, left, right)\n"
" item = adjacency_map.setdefault(\n"
" key,\n"
" {'bodyIndex': body_index, 'faceOrdinals': [left, right], 'edgeCount': 0, 'edgeKinds': {}, 'edges': []},\n"
" )\n"
" item['edgeCount'] = int(item.get('edgeCount', 0)) + 1\n"
" edge_kinds = item.setdefault('edgeKinds', {})\n"
" edge_kinds[kind] = int(edge_kinds.get(kind, 0)) + 1\n"
" edges = item.setdefault('edges', [])\n"
" if len(edges) < 6:\n"
" edges.append({\n"
" 'edgeOrdinal': edge_topology.get('edgeOrdinal'),\n"
" 'globalEdgeOrdinal': edge_topology.get('globalEdgeOrdinal'),\n"
" 'curveType': geometry.get('curveType'),\n"
" 'kind': kind,\n"
" 'length': geometry.get('length'),\n"
" 'radius': geometry.get('radius'),\n"
" })\n"
"\n"
"def _face_adjacency_rows(adjacency_map):\n"
" rows = list(adjacency_map.values())\n"
" rows.sort(key=lambda item: (int(item.get('bodyIndex') or 0), item.get('faceOrdinals') or []))\n"
" return rows\n"
"\n"
"def _count_key(counts, key):\n"
" key = str(key or '').strip() or 'unknown'\n"
" counts[key] = int(counts.get(key, 0)) + 1\n"
"\n"
"def _feature_inventory(objects):\n"
" result = {'objectTypeCounts': {}, 'surfaceTypeCounts': {}, 'curveTypeCounts': {}, 'operationCounts': {}}\n"
" for item in objects:\n"
" if not isinstance(item, dict):\n"
" continue\n"
" _count_key(result['objectTypeCounts'], item.get('objectType'))\n"
" geometry = item.get('geometry')\n"
" if not isinstance(geometry, dict):\n"
" geometry = {}\n"
" if geometry.get('surfaceType') is not None:\n"
" _count_key(result['surfaceTypeCounts'], geometry.get('surfaceType'))\n"
" if geometry.get('curveType') is not None:\n"
" _count_key(result['curveTypeCounts'], geometry.get('curveType'))\n"
" for command in item.get('backendCommandCandidates', []) or []:\n"
" if isinstance(command, dict):\n"
" _count_key(result['operationCounts'], command.get('operation'))\n"
" return result\n"
"\n"
"def _command_candidates(object_type, geometry):\n"
" surface_type = str(geometry.get('surfaceType', '')).lower()\n"
" result = []\n"
" if object_type == 'face' and surface_type == 'plane':\n"
" result.append({'operation': 'pull_face_offset', 'enabled': True, 'parameterFields': {'distance': 0}})\n"
" if object_type in ('face', 'hole') and surface_type == 'cylinder':\n"
" result.append({'operation': 'change_hole_diameter', 'enabled': True, 'parameterFields': {'diameter': geometry.get('diameter')}})\n"
" result.append({'operation': 'move_hole_axis', 'enabled': True, 'parameterFields': {'center': geometry.get('center')}})\n"
" if object_type == 'hole' and surface_type == 'cylinder':\n"
" result.append({'operation': 'fill_feature', 'enabled': True, 'parameterFields': {}})\n"
" round_info = geometry.get('roundInfo')\n"
" if isinstance(round_info, dict) and round_info.get('radius') is not None:\n"
" result.append({'operation': 'change_round_radius', 'enabled': True, 'parameterFields': {'radius': round_info.get('radius')}})\n"
" result.append({'operation': 'delete_round_or_chamfer', 'enabled': True, 'parameterFields': {}})\n"
" return result\n"
"\n"
"def _open_step(path):\n"
" errors = []\n"
" for opener in ('DocumentOpen.Execute', 'Application.OpenDocument'):\n"
" try:\n"
" current = globals()\n"
" target = None\n"
" for part in opener.split('.'):\n"
" target = current.get(part) if isinstance(current, dict) else getattr(current, part)\n"
" current = target\n"
" target(path)\n"
" return\n"
" except Exception as exc:\n"
" errors.append(str(exc))\n"
" raise Exception('Could not open STEP: ' + '; '.join(errors))\n"
"\n"
"def _root_part():\n"
" try:\n"
" return GetRootPart()\n"
" except Exception:\n"
" pass\n"
" try:\n"
" return Application.ActiveWindow.Document.MainPart\n"
" except Exception:\n"
" return None\n"
"\n"
"def _maybe_call(target, name):\n"
" try:\n"
" value = getattr(target, name)\n"
" except Exception:\n"
" return None\n"
" try:\n"
" return value()\n"
" except Exception:\n"
" return value\n"
"\n"
"def _body_faces(body):\n"
" for name in ('Faces', 'GetFaces'):\n"
" items = _items(_maybe_call(body, name))\n"
" if items:\n"
" return items\n"
" return []\n"
"\n"
"def _body_edges(body):\n"
" for name in ('Edges', 'GetEdges'):\n"
" items = _items(_maybe_call(body, name))\n"
" if items:\n"
" return items\n"
" return []\n"
"\n"
"def _child_parts(part):\n"
" children = []\n"
" for name in ('Components', 'GetAllComponents'):\n"
" for component in _items(_maybe_call(part, name)):\n"
" for attr in ('Content', 'ContentMaster', 'Template', 'Part'):\n"
" try:\n"
" value = getattr(component, attr)\n"
" if value is not None:\n"
" children.append(value)\n"
" break\n"
" except Exception:\n"
" pass\n"
" return children\n"
"\n"
"def _all_bodies(root):\n"
" if root is None:\n"
" return []\n"
" for name in ('GetAllBodies', 'Bodies'):\n"
" items = _items(_maybe_call(root, name))\n"
" if items:\n"
" return items\n"
" bodies = []\n"
" queue = [root]\n"
" seen = set()\n"
" while queue:\n"
" part = queue.pop(0)\n"
" marker = str(id(part))\n"
" if marker in seen:\n"
" continue\n"
" seen.add(marker)\n"
" bodies.extend(_items(_maybe_call(part, 'Bodies')))\n"
" queue.extend(_child_parts(part))\n"
" return bodies\n"
"\n"
"def _hole_face_markers(bodies):\n"
" standard_holes = globals().get('StandardHoles')\n"
" if standard_holes is None:\n"
" return set()\n"
" faces = []\n"
" options = None\n"
" options_cls = globals().get('FindStandardHoleOptions')\n"
" if options_cls is not None:\n"
" try:\n"
" options = options_cls()\n"
" except Exception:\n"
" options = None\n"
" identified = []\n"
" find = getattr(standard_holes, 'Find', None)\n"
" if find is not None:\n"
" for args in ((bodies, options, None), (bodies, options), (options, None), (options,), (None,)):\n"
" try:\n"
" identified = _items(find(*args))\n"
" if identified:\n"
" break\n"
" except Exception:\n"
" pass\n"
" if identified:\n"
" try:\n"
" faces = _items(standard_holes.GetHoleFaces(identified))\n"
" except Exception:\n"
" faces = []\n"
" if not faces:\n"
" for hole in identified:\n"
" try:\n"
" faces.extend(_items(getattr(hole, 'Faces')))\n"
" except Exception:\n"
" pass\n"
" if faces:\n"
" return set(str(id(face)) for face in faces)\n"
" for args in ((bodies,), ()):\n"
" try:\n"
" faces = _items(standard_holes.GetHoleFaces(*args))\n"
" if faces:\n"
" break\n"
" except Exception:\n"
" pass\n"
" return set(str(id(face)) for face in faces)\n"
"\n"
"def _available_commands():\n"
" names = ('StandardHoles', 'OffsetFaces', 'Move', 'Fill', 'Delete', 'Chamfer', 'ConstantRound', 'RoundInfo')\n"
" result = []\n"
" for name in names:\n"
" result.append({'name': name, 'available': globals().get(name) is not None})\n"
" return result\n"
"\n"
"def main():\n"
" job = json.load(open(JOB_PATH, 'r'))\n"
" model = job.get('model', {})\n"
" outputs = job.get('outputs', {})\n"
" raw_path = outputs.get('rawFeatures')\n"
" error_path = outputs.get('error')\n"
" try:\n"
" _open_step(model.get('path'))\n"
" root = _root_part()\n"
" bodies = _all_bodies(root)\n"
" hole_face_markers = _hole_face_markers(bodies)\n"
" objects = []\n"
" face_adjacency = {}\n"
" edge_geometry_summary = {}\n"
" face_counter = 0\n"
" edge_counter = 0\n"
" for body_index, body in enumerate(bodies):\n"
" body_faces = _body_faces(body)\n"
" face_ordinals_by_marker = dict((str(id(face)), index) for index, face in enumerate(body_faces))\n"
" for face_index, face in enumerate(body_faces):\n"
" geometry = _geometry_from_face(face)\n"
" object_type = 'hole' if str(id(face)) in hole_face_markers else 'face'\n"
" if object_type == 'face' and isinstance(geometry.get('roundInfo'), dict) and geometry.get('roundInfo', {}).get('radius') is not None:\n"
" object_type = 'round'\n"
" objects.append({\n"
" 'backendId': 'body:%d/face:%d' % (body_index, face_index),\n"
" 'objectType': object_type,\n"
" 'geometry': geometry,\n"
" 'topologyHint': {'bodyIndex': body_index, 'faceOrdinal': face_index, 'globalFaceOrdinal': face_counter},\n"
" 'backendCommandCandidates': _command_candidates(object_type, geometry),\n"
" 'rawLimitations': [],\n"
" })\n"
" face_counter += 1\n"
" for edge_index, edge in enumerate(_body_edges(body)):\n"
" geometry = _geometry_from_edge(edge)\n"
" edge_topology = {'bodyIndex': body_index, 'edgeOrdinal': edge_index, 'globalEdgeOrdinal': edge_counter}\n"
" edge_topology.update(_edge_adjacent_face_ordinals(edge, face_ordinals_by_marker))\n"
" _add_edge_geometry_summary(edge_geometry_summary, geometry)\n"
" _record_face_adjacency(face_adjacency, body_index, edge_topology, geometry)\n"
" objects.append({\n"
" 'backendId': 'body:%d/edge:%d' % (body_index, edge_index),\n"
" 'objectType': 'edge',\n"
" 'geometry': geometry,\n"
" 'topologyHint': edge_topology,\n"
" 'backendCommandCandidates': [],\n"
" 'rawLimitations': [],\n"
" })\n"
" edge_counter += 1\n"
" payload = {\n"
" 'schemaVersion': 1,\n"
" 'backend': job.get('backend', {}),\n"
" 'model': model,\n"
" 'scan': job.get('scan', {}),\n"
" 'objects': objects,\n"
" 'diagnostics': {\n"
" 'availableCommands': _available_commands(),\n"
" 'faceAdjacency': _face_adjacency_rows(face_adjacency),\n"
" 'edgeGeometrySummary': _final_edge_geometry_summary(edge_geometry_summary),\n"
" 'featureInventory': _feature_inventory(objects),\n"
" },\n"
" 'summary': {'bodyCount': len(bodies), 'objectCount': len(objects), 'faceCount': face_counter, 'edgeCount': edge_counter, 'holeFaceCount': len(hole_face_markers)},\n"
" }\n"
" _write_json(raw_path, payload)\n"
" except Exception as exc:\n"
" _write_json(error_path, {'ok': False, 'reason': 'probe-exception', 'message': str(exc), 'traceback': traceback.format_exc()})\n"
" raise\n"
"\n"
"main()\n"
)
__all__ = [
"generate_scdm_probe_script",
"prepare_scdm_probe_job",
"run_scdm_probe",
]
+167
View File
@@ -0,0 +1,167 @@
from __future__ import annotations
from collections.abc import Iterable, Mapping
def property_specs_from_scdm_cache(
cache: Mapping[str, object],
*,
selected_face_ids: Iterable[int] = (),
selected_edge_ids: Iterable[int] = (),
execution_ready: bool | Iterable[str] = False,
) -> list[dict[str, object]]:
face_ids = {int(item) for item in selected_face_ids}
edge_ids = {int(item) for item in selected_edge_ids}
if not face_ids and not edge_ids:
return []
objects = cache.get("objects")
if not isinstance(objects, list):
return []
specs: list[dict[str, object]] = []
for item in objects:
if not isinstance(item, Mapping) or not _object_matches(item, face_ids=face_ids, edge_ids=edge_ids):
continue
capabilities = item.get("capabilities")
if not isinstance(capabilities, list):
continue
for capability in capabilities:
if isinstance(capability, Mapping):
spec = _capability_spec(item, capability, execution_ready=execution_ready)
if spec is not None:
specs.append(spec)
return specs
def _object_matches(raw_object: Mapping[str, object], *, face_ids: set[int], edge_ids: set[int]) -> bool:
signature = raw_object.get("geometrySignature")
if not isinstance(signature, Mapping):
return False
object_faces = set(_int_values(signature.get("faceIds")))
object_edges = set(_int_values(signature.get("edgeIds")))
return bool((face_ids and object_faces & face_ids) or (edge_ids and object_edges & edge_ids))
def _capability_spec(
raw_object: Mapping[str, object],
capability: Mapping[str, object],
*,
execution_ready: bool | Iterable[str],
) -> dict[str, object] | None:
key = str(capability.get("key") or "").strip()
label = str(capability.get("displayName") or key).strip()
if not key or not label:
return None
value_kind = str(capability.get("valueKind") or "number")
current = capability.get("currentValue")
value_type = _value_type(value_kind, key)
command_value = value_type == "command"
current_text = "可执行" if command_value else _format_value(current, value_type=value_type)
target_text = "执行" if command_value else _format_value(current, value_type=value_type)
capability_block = str(capability.get("blockReason") or "").strip()
object_block = str(raw_object.get("blockReason") or "").strip()
block_reason = capability_block or object_block
backend_operation = str(capability.get("backendOperation") or "")
post_check = str(capability.get("postCheck") or "")
can_execute = bool(_capability_execution_ready(key, execution_ready) and capability.get("editable", True) and not block_reason)
if can_execute:
disabled_tip = ""
enabled_tip = (
f"SCDM 已识别“{label}”可由 {backend_operation or '后端命令'} 修改;"
f"执行后会用 {post_check or '结果回测'} 校验。"
)
elif block_reason:
enabled_tip = ""
disabled_tip = f"SCDM 已识别该对象,但当前能力被阻止:{block_reason}"
else:
enabled_tip = ""
disabled_tip = "SCDM 已识别该参数,但 S5 修改执行器还没有接入;当前只作为后端识别结果缓存,不开放执行。"
return {
"key": f"scdm:{key}",
"label": label,
"current_raw": current if current is not None else "",
"current_text": current_text,
"target_text": target_text,
"editable": True,
"enabled": can_execute,
"status_text": "可修改" if can_execute else "暂未接入",
"scope_text": str(capability.get("defaultIntent") or "SCDM"),
"action": "apply_scdm_property_edit",
"value_type": value_type,
"enabled_tip": enabled_tip,
"disabled_tip": disabled_tip,
"range_hint": "来源:SCDM 结构化识别结果。执行前仍需生成 edit job,并在结果 STEP 上做 OCCT 校验和目标值回测。",
"min_value": 0.0 if value_type == "positive" else None,
"min_exclusive": True if value_type == "positive" else False,
"scdm_object_id": raw_object.get("objectId"),
"scdm_source_backend_id": raw_object.get("sourceBackendId"),
"scdm_capability_key": key,
"scdm_backend_operation": backend_operation,
"scdm_post_check": post_check,
"scdm_geometry_signature": raw_object.get("geometrySignature") if isinstance(raw_object.get("geometrySignature"), Mapping) else {},
}
def _value_type(value_kind: str, key: str) -> str:
if value_kind == "vector3":
return "vector3"
if value_kind == "command":
return "command"
if key.endswith(".diameter") or key.endswith(".radius"):
return "positive"
return "number"
def _capability_execution_ready(key: str, execution_ready: bool | Iterable[str]) -> bool:
if isinstance(execution_ready, bool):
return execution_ready
try:
return key in {str(item) for item in execution_ready}
except TypeError:
return False
def _format_value(value: object, *, value_type: str) -> str:
if value is None:
return ""
if value_type == "vector3":
values = _float_values(value)
return f"({values[0]:g}, {values[1]:g}, {values[2]:g})" if len(values) == 3 else ""
if isinstance(value, float):
return f"{value:g}"
return str(value)
def _float_values(value: object) -> list[float]:
if isinstance(value, (str, bytes)) or value is None:
return []
try:
values = list(value) # type: ignore[arg-type]
except TypeError:
return []
result: list[float] = []
for item in values[:3]:
try:
result.append(float(item))
except (TypeError, ValueError):
return []
return result
def _int_values(value: object) -> list[int]:
if isinstance(value, (str, bytes)) or value is None:
return []
try:
values = list(value) # type: ignore[arg-type]
except TypeError:
return []
result: list[int] = []
for item in values:
try:
result.append(int(item))
except (TypeError, ValueError):
continue
return result
__all__ = ["property_specs_from_scdm_cache"]
+683
View File
@@ -0,0 +1,683 @@
from __future__ import annotations
import math
from collections.abc import Callable, Iterable, Mapping, Sequence
from pathlib import Path
from .relation_formulas import rewrite_relation_formula_ids
def validate_scdm_edit_result(
edit_result: Mapping[str, object],
*,
before_signature: Mapping[str, object] | None = None,
before_cache: Mapping[str, object] | None = None,
after_cache: Mapping[str, object] | None = None,
capability_key: str = "",
expected_target: object = None,
edited_object_id: str = "",
tolerance: float = 1.0e-6,
brep_validator: Callable[[Path], Mapping[str, object]] | None = None,
) -> dict[str, object]:
if edit_result.get("ok") is not True:
return {
"ok": False,
"reason": str(edit_result.get("reason") or "edit-failed"),
"message": str(edit_result.get("message") or "SCDM edit did not succeed."),
"editResult": dict(edit_result),
}
output_step = Path(str(edit_result.get("output_step") or edit_result.get("outputStep") or "")).expanduser()
if not output_step.is_file():
return {
"ok": False,
"reason": "missing-output-step",
"message": f"SCDM result STEP does not exist: {output_step}",
"editResult": dict(edit_result),
}
if brep_validator is not None:
brep = dict(brep_validator(output_step))
if brep.get("ok") is not True:
return {
"ok": False,
"reason": str(brep.get("reason") or "brep-invalid"),
"message": str(brep.get("message") or "OCCT rejected the result STEP."),
"brep": brep,
"editResult": dict(edit_result),
}
else:
brep = {"ok": None, "reason": "not-run", "message": "B-Rep validation callback was not provided."}
summary_check: dict[str, object] = {"ok": None, "reason": "not-run", "message": "SCDM summary check needs the old and new caches."}
topology_check: dict[str, object] = {"ok": None, "reason": "not-run", "message": "Object drift check needs the old and new SCDM caches."}
if before_cache and after_cache:
summary_check = check_scdm_summary_delta(before_cache, after_cache, capability_key=capability_key)
if summary_check.get("ok") is False:
return {
"ok": False,
"reason": str(summary_check.get("reason") or "summary-drift"),
"message": str(summary_check.get("message") or "SCDM result changed the model summary too much."),
"summaryCheck": summary_check,
"brep": brep,
"editResult": dict(edit_result),
}
topology_check = check_scdm_unedited_objects(
before_cache,
after_cache,
edited_object_id=edited_object_id,
edited_signature=before_signature,
capability_key=capability_key,
)
if topology_check.get("ok") is not True:
return {
"ok": False,
"reason": str(topology_check.get("reason") or "unexpected-object-drift"),
"message": str(topology_check.get("message") or "SCDM result changed unrelated recognized objects."),
"topologyCheck": topology_check,
"brep": brep,
"editResult": dict(edit_result),
}
matched: dict[str, object] | None = None
if before_signature and after_cache:
match = match_scdm_object_by_signature(before_signature, after_cache, capability_key=capability_key)
status = str(match.get("status") or "")
if status != "unique":
return {
"ok": False,
"reason": f"object-match-{status or 'failed'}",
"message": str(match.get("message") or "Edited object could not be uniquely matched in the new SCDM cache."),
"match": match,
"brep": brep,
"editResult": dict(edit_result),
}
candidate = match.get("object")
if isinstance(candidate, Mapping):
matched = dict(candidate)
if expected_target is not None and matched is not None:
check = check_scdm_target(matched, capability_key=capability_key, expected_target=expected_target, tolerance=tolerance)
if check.get("ok") is not True:
return {
"ok": False,
"reason": str(check.get("reason") or "target-check-failed"),
"message": str(check.get("message") or "SCDM result did not reach the target value."),
"targetCheck": check,
"matchedObject": matched,
"brep": brep,
"editResult": dict(edit_result),
}
else:
check = {"ok": None, "reason": "not-run", "message": "Target check needs a matched object and an expected target."}
return {
"ok": True,
"reason": "ok",
"message": "SCDM edit result passed the available validation checks.",
"output_step": str(output_step),
"matchedObject": matched,
"targetCheck": check,
"summaryCheck": summary_check,
"topologyCheck": topology_check,
"brep": brep,
"editResult": dict(edit_result),
}
def match_scdm_object_by_signature(
before_signature: Mapping[str, object],
after_cache: Mapping[str, object],
*,
capability_key: str = "",
min_score: float = 5.0,
unique_margin: float = 0.75,
) -> dict[str, object]:
candidates = []
objects = after_cache.get("objects")
if not isinstance(objects, list):
return {"status": "none", "message": "New SCDM cache does not contain objects.", "candidates": []}
for raw_object in objects:
if not isinstance(raw_object, Mapping):
continue
signature = raw_object.get("geometrySignature")
if not isinstance(signature, Mapping):
continue
score = _signature_score(before_signature, signature, capability_key=capability_key)
if score <= 0:
continue
candidates.append({"score": score, "object": dict(raw_object), "geometrySignature": dict(signature)})
candidates.sort(key=lambda item: float(item.get("score") or 0.0), reverse=True)
if not candidates or float(candidates[0].get("score") or 0.0) < min_score:
return {"status": "none", "message": "No matching SCDM object reached the confidence threshold.", "candidates": candidates[:5]}
if len(candidates) > 1:
top = float(candidates[0].get("score") or 0.0)
second = float(candidates[1].get("score") or 0.0)
if top - second < unique_margin:
return {"status": "multiple", "message": "More than one SCDM object matches the old signature.", "candidates": candidates[:5]}
best = candidates[0]
return {
"status": "unique",
"message": "Matched one SCDM object.",
"score": best.get("score"),
"object": best.get("object"),
"candidates": candidates[:5],
}
def build_scdm_id_mapping(
before_cache: Mapping[str, object],
after_cache: Mapping[str, object],
*,
capability_key: str = "",
) -> dict[str, object]:
face_id_map: dict[int, int] = {}
edge_id_map: dict[int, int] = {}
object_id_map: dict[str, str] = {}
unmatched: list[str] = []
ambiguous: list[str] = []
before_objects = before_cache.get("objects")
if not isinstance(before_objects, list):
before_objects = []
for raw_object in before_objects:
if not isinstance(raw_object, Mapping):
continue
before_signature = raw_object.get("geometrySignature")
if not isinstance(before_signature, Mapping):
continue
object_id = str(raw_object.get("objectId") or "")
match = match_scdm_object_by_signature(before_signature, after_cache, capability_key=capability_key)
status = str(match.get("status") or "")
if status != "unique":
if status == "multiple":
ambiguous.append(object_id)
else:
unmatched.append(object_id)
continue
new_object = match.get("object")
if not isinstance(new_object, Mapping):
unmatched.append(object_id)
continue
new_signature = new_object.get("geometrySignature")
if not isinstance(new_signature, Mapping):
unmatched.append(object_id)
continue
new_object_id = str(new_object.get("objectId") or "")
if object_id and new_object_id:
object_id_map[object_id] = new_object_id
_extend_single_or_zipped_id_map(face_id_map, _int_values(before_signature.get("faceIds")), _int_values(new_signature.get("faceIds")))
_extend_single_or_zipped_id_map(edge_id_map, _int_values(before_signature.get("edgeIds")), _int_values(new_signature.get("edgeIds")))
return {
"ok": not unmatched and not ambiguous,
"objectIdMap": object_id_map,
"faceIdMap": face_id_map,
"edgeIdMap": edge_id_map,
"unmatched": unmatched,
"ambiguous": ambiguous,
}
def check_scdm_unedited_objects(
before_cache: Mapping[str, object],
after_cache: Mapping[str, object],
*,
edited_object_id: str = "",
edited_signature: Mapping[str, object] | None = None,
capability_key: str = "",
max_report: int = 5,
) -> dict[str, object]:
before_objects = before_cache.get("objects")
if not isinstance(before_objects, list):
return {"ok": False, "reason": "missing-before-cache", "message": "Old SCDM cache does not contain objects.", "checked": 0}
after_objects = after_cache.get("objects")
if not isinstance(after_objects, list):
return {"ok": False, "reason": "missing-after-cache", "message": "New SCDM cache does not contain objects.", "checked": 0}
unmatched: list[dict[str, object]] = []
ambiguous: list[dict[str, object]] = []
checked = 0
for raw_object in before_objects:
if not isinstance(raw_object, Mapping):
continue
object_id = str(raw_object.get("objectId") or "")
signature = raw_object.get("geometrySignature")
if not isinstance(signature, Mapping) or not _signature_has_enough_identity(signature):
continue
if object_id and edited_object_id and object_id == edited_object_id:
continue
if edited_signature and _same_signature_subject(signature, edited_signature):
continue
checked += 1
match = match_scdm_object_by_signature(signature, after_cache, capability_key=capability_key)
status = str(match.get("status") or "")
if status == "unique":
matched_object = match.get("object")
matched_signature = matched_object.get("geometrySignature") if isinstance(matched_object, Mapping) else None
if isinstance(matched_signature, Mapping) and _unchanged_signature_still_matches(signature, matched_signature):
continue
status = "none"
row = {
"objectId": object_id,
"objectType": raw_object.get("objectType"),
"status": status or "none",
"message": match.get("message"),
}
if status == "multiple":
ambiguous.append(row)
else:
unmatched.append(row)
if unmatched or ambiguous:
parts = []
if unmatched:
parts.append(f"{len(unmatched)} recognized object(s) disappeared or changed too much")
if ambiguous:
parts.append(f"{len(ambiguous)} recognized object(s) became ambiguous")
return {
"ok": False,
"reason": "unexpected-object-drift",
"message": "; ".join(parts) + ".",
"checked": checked,
"unmatched": unmatched[:max_report],
"ambiguous": ambiguous[:max_report],
}
return {
"ok": True,
"reason": "ok",
"message": "Unedited recognized objects still match after the SCDM edit.",
"checked": checked,
"unmatched": [],
"ambiguous": [],
}
def rewrite_scdm_relation_formula_ids(
text: str,
mapping: Mapping[str, object],
) -> str:
return rewrite_relation_formula_ids(
text,
_int_map(mapping.get("faceIdMap")),
_int_map(mapping.get("edgeIdMap")),
)
def check_scdm_target(
raw_object: Mapping[str, object],
*,
capability_key: str,
expected_target: object,
tolerance: float = 1.0e-6,
) -> dict[str, object]:
if capability_key == "hole.diameter":
actual = _number(_capability_value(raw_object, capability_key) or _geometry_value(raw_object, "diameter"))
if actual is None:
radius = _number(_geometry_value(raw_object, "radius"))
actual = radius * 2.0 if radius is not None else None
expected = _number(expected_target)
return _number_check(actual, expected, "hole.diameter", tolerance)
if capability_key == "hole.position":
actual_vector = _vector(_capability_value(raw_object, capability_key) or _geometry_value(raw_object, "center"))
expected_vector = _vector(expected_target)
return _vector_check(actual_vector, expected_vector, "hole.position", tolerance)
if capability_key == "face.offset":
actual = _number(_capability_value(raw_object, capability_key) or _geometry_value(raw_object, "offset") or _geometry_value(raw_object, "planeOffset"))
expected = _number(expected_target)
return _number_check(actual, expected, "face.offset", tolerance)
if capability_key == "feature.fill":
return {"ok": True, "reason": "not-applicable", "message": "feature.fill is checked by object disappearance in the caller."}
return {"ok": None, "reason": "unsupported-post-check", "message": f"No target checker is registered for {capability_key}."}
def check_scdm_summary_delta(
before_cache: Mapping[str, object],
after_cache: Mapping[str, object],
*,
capability_key: str = "",
relative_tolerance: float = 0.25,
absolute_tolerance: int = 12,
) -> dict[str, object]:
if capability_key in {"feature.fill", "feature.delete_round_or_chamfer"}:
return {"ok": None, "reason": "skipped-command-feature", "message": "Command features are expected to change Face/Edge counts."}
before_summary = _raw_summary(before_cache)
after_summary = _raw_summary(after_cache)
if not before_summary or not after_summary:
return {"ok": None, "reason": "missing-summary", "message": "SCDM raw summary was not available in both caches."}
body_before = _int_or_none(before_summary.get("bodyCount"))
body_after = _int_or_none(after_summary.get("bodyCount"))
if body_before is not None and body_after is not None and body_before != body_after:
return {
"ok": False,
"reason": "summary-drift",
"message": f"SCDM result changed body count unexpectedly: {body_before} -> {body_after}.",
"before": dict(before_summary),
"after": dict(after_summary),
}
for key, label in (("faceCount", "Face"), ("edgeCount", "Edge"), ("objectCount", "对象")):
before_value = _int_or_none(before_summary.get(key))
after_value = _int_or_none(after_summary.get(key))
if before_value is None or after_value is None:
continue
delta = abs(after_value - before_value)
limit = max(int(absolute_tolerance), int(math.ceil(abs(before_value) * float(relative_tolerance))))
if delta > limit:
return {
"ok": False,
"reason": "summary-drift",
"message": f"SCDM result changed {label} count too much: {before_value} -> {after_value}, limit {limit}.",
"before": dict(before_summary),
"after": dict(after_summary),
"metric": key,
"delta": delta,
"limit": limit,
}
return {
"ok": True,
"reason": "ok",
"message": "SCDM model summary stayed within the allowed range.",
"before": dict(before_summary),
"after": dict(after_summary),
}
def _signature_score(before: Mapping[str, object], after: Mapping[str, object], *, capability_key: str) -> float:
score = 0.0
before_type = str(before.get("objectType") or "").lower()
after_type = str(after.get("objectType") or "").lower()
if before_type and before_type == after_type:
score += 4.0
elif {before_type, after_type} <= {"hole", "cylindrical_hole", ""}:
score += 2.0
before_surface = str(before.get("surfaceType") or "").lower()
after_surface = str(after.get("surfaceType") or "").lower()
if before_surface and before_surface == after_surface:
score += 1.0
before_faces = set(_int_values(before.get("faceIds")))
after_faces = set(_int_values(after.get("faceIds")))
if before_faces and after_faces:
overlap = len(before_faces & after_faces)
if overlap:
score += 1.0 + min(overlap, 3) * 0.25
before_edges = set(_int_values(before.get("edgeIds")))
after_edges = set(_int_values(after.get("edgeIds")))
if before_edges and after_edges and before_edges & after_edges:
score += 0.5
if capability_key != "hole.position":
center_score = _vector_distance_score(_vector(before.get("center")), _vector(after.get("center")))
score += center_score
axis_score = _axis_score(_vector(before.get("axis")), _vector(after.get("axis")))
score += axis_score
if capability_key != "hole.diameter":
score += _number_similarity_score(_diameter_from_signature(before), _diameter_from_signature(after))
return score
def _signature_has_enough_identity(signature: Mapping[str, object]) -> bool:
if _vector(signature.get("center")) and _vector(signature.get("axis")):
return True
if _vector(signature.get("center")) and str(signature.get("surfaceType") or ""):
return True
if _int_values(signature.get("faceIds")) or _int_values(signature.get("edgeIds")):
return True
return False
def _same_signature_subject(left: Mapping[str, object], right: Mapping[str, object]) -> bool:
left_faces = set(_int_values(left.get("faceIds")))
right_faces = set(_int_values(right.get("faceIds")))
if left_faces and right_faces and left_faces == right_faces:
return True
left_edges = set(_int_values(left.get("edgeIds")))
right_edges = set(_int_values(right.get("edgeIds")))
if left_edges and right_edges and left_edges == right_edges:
return True
left_center = _vector(left.get("center"))
right_center = _vector(right.get("center"))
if len(left_center) == 3 and len(right_center) == 3 and _vector_error(left_center, right_center) <= 1.0e-8:
left_axis = _vector(left.get("axis"))
right_axis = _vector(right.get("axis"))
if len(left_axis) == 3 and len(right_axis) == 3 and _axis_score(left_axis, right_axis) >= 3.0:
return True
return False
def _unchanged_signature_still_matches(before: Mapping[str, object], after: Mapping[str, object]) -> bool:
before_center = _vector(before.get("center"))
after_center = _vector(after.get("center"))
if before_center and after_center and _vector_error(before_center, after_center) > _vector_tolerance(before_center, after_center):
return False
before_axis = _vector(before.get("axis"))
after_axis = _vector(after.get("axis"))
if before_axis and after_axis and _axis_score(before_axis, after_axis) < 3.0:
return False
before_diameter = _diameter_from_signature(before)
after_diameter = _diameter_from_signature(after)
if before_diameter is not None and after_diameter is not None:
tolerance = max(abs(before_diameter), abs(after_diameter), 1.0) * 1.0e-5
if abs(float(before_diameter) - float(after_diameter)) > tolerance:
return False
before_offset = _number(before.get("planeOffset"))
after_offset = _number(after.get("planeOffset"))
if before_offset is not None and after_offset is not None:
tolerance = max(abs(before_offset), abs(after_offset), 1.0) * 1.0e-5
if abs(float(before_offset) - float(after_offset)) > tolerance:
return False
return True
def _vector_tolerance(left: Sequence[float], right: Sequence[float]) -> float:
scale = 1.0
values = list(left) + list(right)
if values:
scale = max(scale, max(abs(float(item)) for item in values))
return max(scale * 1.0e-5, 1.0e-7)
def _extend_single_or_zipped_id_map(target: dict[int, int], old_ids: Sequence[int], new_ids: Sequence[int]) -> None:
old_unique = sorted(set(old_ids))
new_unique = sorted(set(new_ids))
if len(old_unique) == 1 and len(new_unique) == 1:
target[int(old_unique[0])] = int(new_unique[0])
elif len(old_unique) == len(new_unique) and len(old_unique) > 1:
for old_id, new_id in zip(old_unique, new_unique):
target[int(old_id)] = int(new_id)
def _capability_value(raw_object: Mapping[str, object], capability_key: str) -> object:
capabilities = raw_object.get("capabilities")
if not isinstance(capabilities, list):
return None
for capability in capabilities:
if isinstance(capability, Mapping) and capability.get("key") == capability_key:
return capability.get("currentValue")
return None
def _geometry_value(raw_object: Mapping[str, object], key: str) -> object:
signature = raw_object.get("geometrySignature")
if isinstance(signature, Mapping) and key in signature:
return signature.get(key)
geometry = raw_object.get("geometry")
if isinstance(geometry, Mapping) and key in geometry:
return geometry.get(key)
return None
def _number_check(actual: float | None, expected: float | None, label: str, tolerance: float) -> dict[str, object]:
if actual is None or expected is None:
return {"ok": False, "reason": "target-value-missing", "message": f"{label} target check does not have comparable values.", "actual": actual, "expected": expected}
error = abs(actual - expected)
return {
"ok": error <= tolerance,
"reason": "ok" if error <= tolerance else "target-mismatch",
"message": "Target value matched." if error <= tolerance else f"{label} actual={actual:g}, expected={expected:g}, error={error:g}.",
"actual": actual,
"expected": expected,
"error": error,
"tolerance": tolerance,
}
def _vector_check(actual: Sequence[float], expected: Sequence[float], label: str, tolerance: float) -> dict[str, object]:
if len(actual) != 3 or len(expected) != 3:
return {"ok": False, "reason": "target-value-missing", "message": f"{label} target check does not have comparable vectors.", "actual": list(actual), "expected": list(expected)}
error = math.sqrt(sum((float(actual[index]) - float(expected[index])) ** 2 for index in range(3)))
return {
"ok": error <= tolerance,
"reason": "ok" if error <= tolerance else "target-mismatch",
"message": "Target vector matched." if error <= tolerance else f"{label} vector error={error:g}.",
"actual": list(actual),
"expected": list(expected),
"error": error,
"tolerance": tolerance,
}
def _vector_error(actual: Sequence[float], expected: Sequence[float]) -> float:
if len(actual) != 3 or len(expected) != 3:
return math.inf
return math.sqrt(sum((float(actual[index]) - float(expected[index])) ** 2 for index in range(3)))
def _vector_distance_score(before: Sequence[float], after: Sequence[float]) -> float:
if len(before) != 3 or len(after) != 3:
return 0.0
distance = math.sqrt(sum((float(before[index]) - float(after[index])) ** 2 for index in range(3)))
if distance <= 1.0e-5:
return 4.0
if distance <= 1.0e-3:
return 3.0
if distance <= 1.0e-1:
return 1.5
return 0.0
def _axis_score(before: Sequence[float], after: Sequence[float]) -> float:
if len(before) != 3 or len(after) != 3:
return 0.0
before_len = math.sqrt(sum(float(item) * float(item) for item in before))
after_len = math.sqrt(sum(float(item) * float(item) for item in after))
if before_len <= 1.0e-12 or after_len <= 1.0e-12:
return 0.0
dot = abs(sum(float(before[index]) * float(after[index]) for index in range(3)) / (before_len * after_len))
if dot >= 0.999:
return 3.0
if dot >= 0.99:
return 2.0
return 0.0
def _number_similarity_score(before: float | None, after: float | None) -> float:
if before is None or after is None:
return 0.0
error = abs(float(before) - float(after))
scale = max(abs(float(before)), abs(float(after)), 1.0)
relative = error / scale
if relative <= 1.0e-5:
return 3.0
if relative <= 1.0e-3:
return 2.0
if relative <= 5.0e-2:
return 0.75
return 0.0
def _diameter_from_signature(signature: Mapping[str, object]) -> float | None:
diameter = _number(signature.get("diameter"))
if diameter is not None:
return diameter
radius = _number(signature.get("radius"))
return radius * 2.0 if radius is not None else None
def _number(value: object) -> float | None:
try:
return float(value)
except (TypeError, ValueError):
return None
def _vector(value: object) -> list[float]:
if isinstance(value, (str, bytes)) or value is None:
return []
try:
values = list(value) # type: ignore[arg-type]
except TypeError:
return []
if len(values) != 3:
return []
try:
return [float(item) for item in values]
except (TypeError, ValueError):
return []
def _int_values(value: object) -> list[int]:
if isinstance(value, (str, bytes)) or value is None:
return []
try:
values = list(value) # type: ignore[arg-type]
except TypeError:
return []
result: list[int] = []
for item in values:
try:
result.append(int(item))
except (TypeError, ValueError):
continue
return result
def _raw_summary(cache: Mapping[str, object]) -> Mapping[str, object]:
diagnostics = cache.get("diagnostics")
if not isinstance(diagnostics, Mapping):
return {}
summary = diagnostics.get("raw_summary") or diagnostics.get("summary")
return summary if isinstance(summary, Mapping) else {}
def _int_or_none(value: object) -> int | None:
try:
return int(value)
except (TypeError, ValueError):
return None
def _int_map(value: object) -> dict[int, int]:
if not isinstance(value, Mapping):
return {}
result: dict[int, int] = {}
for key, item in value.items():
try:
result[int(key)] = int(item)
except (TypeError, ValueError):
continue
return result
__all__ = [
"build_scdm_id_mapping",
"check_scdm_summary_delta",
"check_scdm_unedited_objects",
"check_scdm_target",
"match_scdm_object_by_signature",
"rewrite_scdm_relation_formula_ids",
"validate_scdm_edit_result",
]
+122
View File
@@ -0,0 +1,122 @@
from __future__ import annotations
import hashlib
import json
from collections.abc import Mapping
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
SCDM_RAW_SCHEMA_VERSION = 1
SCDM_CACHE_SCHEMA_VERSION = 1
@dataclass(frozen=True)
class ScdmProbeJob:
step_path: Path
output_dir: Path
raw_features_path: Path
error_path: Path
model_fingerprint: str
unit: str = "model"
scan_scope: str = "all"
adapter: str = "spaceclaim-v1"
backend_path: str = ""
backend_version: str = ""
created_at: str = ""
def to_payload(self) -> dict[str, object]:
return {
"schemaVersion": SCDM_RAW_SCHEMA_VERSION,
"adapter": self.adapter,
"createdAt": self.created_at or utc_now(),
"backend": {
"name": "SCDM",
"path": self.backend_path,
"version": self.backend_version,
},
"model": {
"path": str(self.step_path),
"fingerprint": self.model_fingerprint,
"unit": self.unit,
},
"scan": {
"scope": self.scan_scope,
},
"outputs": {
"rawFeatures": str(self.raw_features_path),
"error": str(self.error_path),
},
}
def utc_now() -> str:
return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
def file_fingerprint(path: str | Path) -> str:
source = Path(path)
digest = hashlib.sha256()
stat = source.stat()
digest.update(str(source.resolve(strict=False)).encode("utf-8", errors="replace"))
digest.update(str(stat.st_size).encode("ascii"))
digest.update(str(stat.st_mtime_ns).encode("ascii"))
with source.open("rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def read_json(path: str | Path) -> dict[str, object]:
payload = json.loads(Path(path).read_text(encoding="utf-8"))
if not isinstance(payload, dict):
raise ValueError(f"JSON payload must be an object: {path}")
return payload
def write_json(path: str | Path, payload: Mapping[str, object]) -> Path:
target = Path(path)
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(json.dumps(dict(payload), ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
return target
def default_scdm_work_dir(
step_path: str | Path,
*,
project_root: str | Path | None = None,
fingerprint: str | None = None,
) -> Path:
root = Path(project_root).expanduser() if project_root else Path(__file__).resolve().parent.parent
source = Path(step_path)
short = (fingerprint or file_fingerprint(source))[:12]
return root / "local" / "scdm" / f"{source.stem}_{short}"
def payload_model_fingerprint(payload: Mapping[str, object]) -> str:
model = payload.get("model")
if not isinstance(model, Mapping):
return ""
return str(model.get("fingerprint") or "")
def payload_backend_version(payload: Mapping[str, object]) -> str:
backend = payload.get("backend")
if not isinstance(backend, Mapping):
return ""
return str(backend.get("version") or "")
__all__ = [
"SCDM_CACHE_SCHEMA_VERSION",
"SCDM_RAW_SCHEMA_VERSION",
"ScdmProbeJob",
"default_scdm_work_dir",
"file_fingerprint",
"payload_backend_version",
"payload_model_fingerprint",
"read_json",
"utc_now",
"write_json",
]
+568
View File
@@ -0,0 +1,568 @@
from __future__ import annotations
from collections.abc import Mapping
from pathlib import Path
from .scdm_backend import ScdmBackendInfo, is_scdm_disabled, load_scdm_backend_cache
from .scdm_capabilities import CAPABILITY_DEFINITIONS, ScdmCapabilityDefinition
def cached_scdm_backend_payload(project_root: str | Path | None = None) -> dict[str, object] | None:
if is_scdm_disabled():
return {"disabled": True, "reason": "disabled", "message": "SCDM backend is disabled by environment."}
backend = load_scdm_backend_cache(project_root_override=project_root)
return backend.to_cache() if backend is not None else None
def summarize_scdm_runtime(
*,
backend: ScdmBackendInfo | Mapping[str, object] | None = None,
cache_state: str = "",
cache_message: str = "",
feature_cache: Mapping[str, object] | None = None,
) -> dict[str, object]:
backend_payload = _backend_payload(backend)
disabled = _backend_disabled(backend)
state = str(cache_state or "empty").strip().lower()
message = _compact(str(cache_message or "").strip(), 120)
count = _feature_cache_counts(feature_cache)
if disabled:
headline = "SCDM:已关闭,当前使用 OCCT/Analysis Situs 兜底"
path = ""
elif backend_payload:
version = str(backend_payload.get("version") or "").strip()
source = _source_label(str(backend_payload.get("source") or "").strip())
version_text = f" {version}" if version else ""
headline = f"SCDM:已配置{version_text}{source}"
path = str(backend_payload.get("path") or "").strip()
else:
headline = "SCDM:未配置,当前可用 OCCT/Analysis Situs 兜底"
path = ""
if disabled:
detail = "检测到 SCDM 禁用开关;本次不会启动 SpaceClaim.exe。"
elif state == "running":
detail = "正在后台识别可修改参数;界面可继续旋转查看模型。"
elif state == "ready":
object_text = f"{count['objects']} 个对象" if count["objects"] else "0 个对象"
capability_text = f"{count['capabilities']} 项能力" if count["capabilities"] else "0 项能力"
detail = f"识别缓存已就绪:{object_text}{capability_text}"
elif state == "failed":
reason = message or "未拿到 SCDM 识别结果"
detail = f"识别未启用:{reason};当前使用本软件已有能力。"
elif state == "deferred":
detail = message or "大模型已延后 SCDM 全量识别,优先保证查看、旋转和点选流畅。"
elif state == "stale":
detail = message or "缓存已失效,导入、编辑、撤销或重做后会后台重新识别。"
else:
detail = "导入 STEP 后会尝试启动 SCDM 识别;找不到时使用 OCCT/Analysis Situs 兜底。"
tooltip_lines = [headline, detail]
if path:
tooltip_lines.append(f"路径:{path}")
if backend_payload:
run_script_ok = backend_payload.get("runScriptOk")
license_ok = backend_payload.get("licenseOk")
tooltip_lines.append(f"/RunScript{_ok_text(run_script_ok)}")
tooltip_lines.append(f"许可证:{_ok_text(license_ok)}")
return {
"headline": headline,
"detail": detail,
"tooltip": "\n".join(line for line in tooltip_lines if line),
"backendReady": bool(backend_payload) and not disabled,
"cacheState": state,
"objectCount": count["objects"],
"capabilityCount": count["capabilities"],
}
def summarize_scdm_capability_progress(
*,
feature_cache: Mapping[str, object] | None = None,
execution_ready: bool | set[str] | list[str] | tuple[str, ...] = False,
) -> dict[str, object]:
ready_keys = _execution_ready_keys(execution_ready)
detection_counts = _cache_capability_counts(feature_cache)
blocked_counts = _cache_blocked_capability_counts(feature_cache)
planned_counts = _planned_capability_counts(feature_cache)
hint_counts = _geometry_candidate_hint_counts(feature_cache)
discovered_summary = _discovered_not_productized_summary(feature_cache)
probe_evidence = _probe_evidence_summary(feature_cache)
rows: list[dict[str, object]] = []
for key, definition in sorted(CAPABILITY_DEFINITIONS.items(), key=lambda item: (_stage_sort_key(item[1].roadmap_stage), item[0])):
detected = int(detection_counts.get(key, 0))
blocked = int(blocked_counts.get(key, 0))
planned_detected = int(planned_counts.get(key, 0))
hint_detected = int(hint_counts.get(key, 0))
runner_ready = _capability_runner_ready(key, execution_ready, ready_keys)
status, reason = _capability_progress_status(
key,
definition,
detected=detected,
blocked=blocked,
planned_detected=planned_detected,
hint_detected=hint_detected,
runner_ready=runner_ready,
)
executable = detected if definition.productized and runner_ready else 0
if blocked:
executable = max(0, executable - blocked)
rows.append(
{
"key": key,
"displayName": definition.display_name,
"roadmapStage": definition.roadmap_stage,
"productized": definition.productized,
"runnerReady": runner_ready,
"detectedCount": detected,
"plannedDetectedCount": planned_detected,
"hintDetectedCount": hint_detected,
"blockedCount": blocked,
"executableCount": executable,
"status": status,
"reason": reason,
}
)
executable_count = sum(int(row["executableCount"]) for row in rows)
productized_count = sum(1 for row in rows if bool(row["productized"]))
runner_ready_count = sum(1 for row in rows if bool(row["productized"]) and bool(row["runnerReady"]))
planned_detected_total = sum(int(row["plannedDetectedCount"]) for row in rows)
hint_detected_total = sum(int(row["hintDetectedCount"]) for row in rows)
blocked_total = sum(int(row["blockedCount"]) for row in rows)
return {
"rows": rows,
"summary": {
"defined": len(rows),
"productized": productized_count,
"runnerReady": runner_ready_count,
"detectedCapabilities": sum(int(row["detectedCount"]) for row in rows),
"executableCapabilities": executable_count,
"plannedDetected": planned_detected_total,
"geometryHints": hint_detected_total,
"backendBlocked": blocked_total,
"discoveredNotProductized": discovered_summary["count"],
"faceAdjacency": probe_evidence["faceAdjacency"],
"circularEdges": probe_evidence["circularEdges"],
"inventoryObjectTypes": probe_evidence["inventoryObjectTypes"],
"inventoryOperationCandidates": probe_evidence["inventoryOperationCandidates"],
"derivedFeatureCandidates": probe_evidence["derivedFeatureCandidates"],
},
"productizedLines": _capability_progress_lines(
row for row in rows if bool(row["productized"])
),
"plannedLines": _capability_progress_lines(
row
for row in rows
if not bool(row["productized"])
and (
int(row["plannedDetectedCount"]) > 0
or int(row["detectedCount"]) > 0
or int(row["hintDetectedCount"]) > 0
)
),
"roadmapLines": _capability_progress_lines(
row
for row in rows
if not bool(row["productized"])
and int(row["plannedDetectedCount"]) <= 0
and int(row["detectedCount"]) <= 0
and int(row["hintDetectedCount"]) <= 0
),
"discoveredNotProductized": discovered_summary,
"probeEvidence": probe_evidence,
}
def _backend_payload(backend: ScdmBackendInfo | Mapping[str, object] | None) -> dict[str, object]:
if isinstance(backend, ScdmBackendInfo):
return backend.to_cache()
if not isinstance(backend, Mapping):
return {}
nested = backend.get("backend")
if isinstance(nested, ScdmBackendInfo):
return nested.to_cache()
if isinstance(nested, Mapping):
return _backend_payload(nested)
path = str(backend.get("path") or "").strip()
if not path:
return {}
return {
"path": path,
"source": str(backend.get("source") or ""),
"version": str(backend.get("version") or ""),
"verifiedAt": str(backend.get("verifiedAt") or ""),
"runScriptOk": backend.get("runScriptOk"),
"licenseOk": backend.get("licenseOk"),
"message": str(backend.get("message") or ""),
}
def _backend_disabled(backend: ScdmBackendInfo | Mapping[str, object] | None) -> bool:
return isinstance(backend, Mapping) and bool(backend.get("disabled"))
def _feature_cache_counts(feature_cache: Mapping[str, object] | None) -> dict[str, int]:
if not isinstance(feature_cache, Mapping):
return {"objects": 0, "capabilities": 0}
objects = feature_cache.get("objects")
if not isinstance(objects, list):
return {"objects": 0, "capabilities": 0}
capability_count = 0
object_count = 0
for item in objects:
if not isinstance(item, Mapping):
continue
object_count += 1
capabilities = item.get("capabilities")
if isinstance(capabilities, list):
capability_count += sum(1 for capability in capabilities if isinstance(capability, Mapping))
return {"objects": object_count, "capabilities": capability_count}
def _cache_capability_counts(feature_cache: Mapping[str, object] | None) -> dict[str, int]:
result: dict[str, int] = {}
if not isinstance(feature_cache, Mapping):
return result
objects = feature_cache.get("objects")
if not isinstance(objects, list):
return result
for item in objects:
if not isinstance(item, Mapping):
continue
capabilities = item.get("capabilities")
if not isinstance(capabilities, list):
continue
for capability in capabilities:
if not isinstance(capability, Mapping):
continue
key = str(capability.get("key") or "").strip()
if key:
result[key] = result.get(key, 0) + 1
return result
def _cache_blocked_capability_counts(feature_cache: Mapping[str, object] | None) -> dict[str, int]:
result: dict[str, int] = {}
if not isinstance(feature_cache, Mapping):
return result
objects = feature_cache.get("objects")
if not isinstance(objects, list):
return result
for item in objects:
if not isinstance(item, Mapping):
continue
object_block = str(item.get("blockReason") or "").strip()
capabilities = item.get("capabilities")
if not isinstance(capabilities, list):
continue
for capability in capabilities:
if not isinstance(capability, Mapping):
continue
key = str(capability.get("key") or "").strip()
if not key:
continue
capability_block = str(capability.get("blockReason") or "").strip()
if object_block or capability_block or capability.get("editable") is False:
result[key] = result.get(key, 0) + 1
return result
def _planned_capability_counts(feature_cache: Mapping[str, object] | None) -> dict[str, int]:
diagnostics = _cache_diagnostics(feature_cache)
planned = diagnostics.get("planned_not_productized")
result: dict[str, int] = {}
if not isinstance(planned, list):
return result
for item in planned:
if not isinstance(item, Mapping):
continue
key = str(item.get("capabilityKey") or "").strip()
if key:
result[key] = result.get(key, 0) + 1
return result
def _geometry_candidate_hint_counts(feature_cache: Mapping[str, object] | None) -> dict[str, int]:
diagnostics = _cache_diagnostics(feature_cache)
hints = diagnostics.get("geometry_candidate_hints")
result: dict[str, int] = {}
if not isinstance(hints, list):
return result
for item in hints:
if not isinstance(item, Mapping):
continue
key = str(item.get("capabilityKey") or "").strip()
if not key:
continue
count = _int_value(item.get("evidenceCount"))
result[key] = result.get(key, 0) + max(count, 1)
return result
def _discovered_not_productized_summary(feature_cache: Mapping[str, object] | None) -> dict[str, object]:
diagnostics = _cache_diagnostics(feature_cache)
discovered = diagnostics.get("discovered_not_productized")
by_type: dict[str, int] = {}
if not isinstance(discovered, list):
return {"count": 0, "byObjectType": {}, "lines": []}
for item in discovered:
if not isinstance(item, Mapping):
continue
object_type = str(item.get("objectType") or "object").strip() or "object"
by_type[object_type] = by_type.get(object_type, 0) + 1
lines = [f"{name}{count}" for name, count in sorted(by_type.items(), key=lambda item: (-item[1], item[0]))[:6]]
return {"count": sum(by_type.values()), "byObjectType": by_type, "lines": lines}
def _probe_evidence_summary(feature_cache: Mapping[str, object] | None) -> dict[str, object]:
diagnostics = _cache_diagnostics(feature_cache)
face_adjacency = diagnostics.get("face_adjacency")
edge_summary = diagnostics.get("edge_geometry_summary")
feature_inventory = diagnostics.get("feature_inventory")
adjacency_count = len(face_adjacency) if isinstance(face_adjacency, list) else 0
edge_summary = edge_summary if isinstance(edge_summary, Mapping) else {}
feature_inventory = feature_inventory if isinstance(feature_inventory, Mapping) else {}
edge_kind_counts = edge_summary.get("edgeKindCounts")
edge_kind_counts = edge_kind_counts if isinstance(edge_kind_counts, Mapping) else {}
object_type_counts = _mapping_count_dict(feature_inventory.get("objectTypeCounts"))
surface_type_counts = _mapping_count_dict(feature_inventory.get("surfaceTypeCounts"))
operation_counts = _mapping_count_dict(feature_inventory.get("operationCounts"))
geometry_hints = diagnostics.get("geometry_candidate_hints")
geometry_hint_lines = _geometry_candidate_hint_lines(geometry_hints)
derived_candidates = diagnostics.get("derived_feature_candidates")
derived_candidate_lines = _derived_feature_candidate_lines(derived_candidates)
derived_candidate_count = len(derived_candidates) if isinstance(derived_candidates, list) else 0
circular_edges = _int_value(edge_summary.get("circularEdgeCount"))
if circular_edges <= 0:
circular_edges = _int_value(edge_kind_counts.get("circular"))
linear_edges = _int_value(edge_kind_counts.get("linear"))
total_edges = _int_value(edge_summary.get("totalEdgeCount"))
radius_buckets = edge_summary.get("circularRadiusBuckets")
radius_bucket_count = len(radius_buckets) if isinstance(radius_buckets, list) else 0
lines = []
if adjacency_count:
lines.append(f"Face 邻接 {adjacency_count}")
if total_edges:
lines.append(f"Edge {total_edges}")
if circular_edges:
lines.append(f"圆边 {circular_edges}")
if linear_edges:
lines.append(f"直边 {linear_edges}")
if radius_bucket_count:
lines.append(f"圆边半径分组 {radius_bucket_count}")
object_lines = _count_summary_lines(object_type_counts, label="对象")
surface_lines = _count_summary_lines(surface_type_counts, label="曲面")
operation_lines = _count_summary_lines(operation_counts, label="命令候选")
lines.extend(object_lines[:2])
lines.extend(surface_lines[:2])
lines.extend(operation_lines[:2])
lines.extend(derived_candidate_lines[:3])
lines.extend(geometry_hint_lines[:4])
return {
"faceAdjacency": adjacency_count,
"totalEdges": total_edges,
"circularEdges": circular_edges,
"linearEdges": linear_edges,
"radiusBucketCount": radius_bucket_count,
"inventoryObjectTypes": sum(object_type_counts.values()),
"inventorySurfaceTypes": sum(surface_type_counts.values()),
"inventoryOperationCandidates": sum(operation_counts.values()),
"derivedFeatureCandidates": derived_candidate_count,
"derivedFeatureCandidateLines": derived_candidate_lines,
"objectTypeCounts": object_type_counts,
"surfaceTypeCounts": surface_type_counts,
"operationCounts": operation_counts,
"geometryHintLines": geometry_hint_lines,
"lines": lines,
}
def _derived_feature_candidate_lines(value: object, *, limit: int = 4) -> list[str]:
if not isinstance(value, list):
return []
counts: dict[str, int] = {}
for item in value:
if not isinstance(item, Mapping):
continue
object_type = str(item.get("objectType") or "object").strip() or "object"
counts[object_type] = counts.get(object_type, 0) + 1
rows = sorted(counts.items(), key=lambda item: (-int(item[1]), item[0]))[: max(1, int(limit))]
return [f"派生候选 {name}:{count}" for name, count in rows]
def _geometry_candidate_hint_lines(value: object, *, limit: int = 4) -> list[str]:
if not isinstance(value, list):
return []
best: dict[str, dict[str, object]] = {}
for item in value:
if not isinstance(item, Mapping):
continue
key = str(item.get("capabilityKey") or "").strip()
if not key:
continue
count = max(_int_value(item.get("evidenceCount")), 1)
existing = best.get(key)
if existing is None or count > int(existing.get("evidenceCount") or 0):
best[key] = {
"displayName": str(item.get("displayName") or key),
"evidenceCount": count,
"confidence": str(item.get("confidence") or ""),
}
rows = sorted(best.items(), key=lambda item: (-int(item[1].get("evidenceCount") or 0), item[0]))[: max(1, int(limit))]
return [
f"几何候选 {payload['displayName']}:{payload['evidenceCount']}{payload['confidence'] or 'unknown'}"
for _key, payload in rows
]
def _mapping_count_dict(value: object) -> dict[str, int]:
if not isinstance(value, Mapping):
return {}
result: dict[str, int] = {}
for key, count in value.items():
text = str(key or "").strip() or "unknown"
number = _int_value(count)
if number > 0:
result[text] = number
return result
def _count_summary_lines(counts: Mapping[str, int], *, label: str, limit: int = 4) -> list[str]:
if not counts:
return []
rows = sorted(counts.items(), key=lambda item: (-int(item[1]), item[0]))[: max(1, int(limit))]
summary = "".join(f"{name}:{count}" for name, count in rows)
return [f"{label}分布 {summary}"]
def _int_value(value: object) -> int:
try:
return int(value)
except (TypeError, ValueError):
return 0
def _cache_diagnostics(feature_cache: Mapping[str, object] | None) -> Mapping[str, object]:
if not isinstance(feature_cache, Mapping):
return {}
diagnostics = feature_cache.get("diagnostics")
return diagnostics if isinstance(diagnostics, Mapping) else {}
def _execution_ready_keys(execution_ready: bool | set[str] | list[str] | tuple[str, ...]) -> set[str]:
if isinstance(execution_ready, bool):
return set()
try:
return {str(item) for item in execution_ready}
except TypeError:
return set()
def _capability_runner_ready(
key: str,
execution_ready: bool | set[str] | list[str] | tuple[str, ...],
ready_keys: set[str],
) -> bool:
return bool(execution_ready) if isinstance(execution_ready, bool) else key in ready_keys
def _capability_progress_status(
key: str,
definition: ScdmCapabilityDefinition,
*,
detected: int,
blocked: int,
planned_detected: int,
hint_detected: int,
runner_ready: bool,
) -> tuple[str, str]:
if definition.productized and runner_ready and detected > blocked:
return "已开放", "已识别到对象时会显示在特征参数表。"
if definition.productized and runner_ready and blocked:
return "已开放但被后端阻止", "当前模型里识别到该能力,但 SCDM 命令、对象状态或安全守门暂时阻止执行。"
if definition.productized and runner_ready:
return "已开放待识别", "执行链路已接入,当前 cache 还没有识别到可执行对象。"
if definition.productized and detected:
return "已识别待执行器", "能力已进入产品字典,但当前 UI 执行器还未开放。"
if definition.productized:
return "已产品化待对象", "能力已定义,等待 SCDM 在当前模型中识别到对象。"
if planned_detected or detected:
return "已识别待验证", definition.block_reason or "已识别到候选,但还没有完成真实 STEP 回测。"
if hint_detected:
return "几何证据待分类", "SCDM probe 已看到相关曲面/边/命令线索,但还没有确认成可执行特征对象。"
return "路线中待接入", definition.block_reason or f"{key} 还没有接入可执行闭环。"
def _capability_progress_lines(rows: object) -> list[str]:
result: list[str] = []
for row in rows: # type: ignore[assignment]
if not isinstance(row, Mapping):
continue
display = str(row.get("displayName") or row.get("key") or "").strip()
status = str(row.get("status") or "").strip()
detected = int(row.get("detectedCount") or 0)
planned = int(row.get("plannedDetectedCount") or 0)
hinted = int(row.get("hintDetectedCount") or 0)
blocked = int(row.get("blockedCount") or 0)
suffix_parts = []
if detected:
suffix_parts.append(f"cache {detected}")
if planned:
suffix_parts.append(f"候选 {planned}")
if hinted:
suffix_parts.append(f"证据 {hinted}")
if blocked:
suffix_parts.append(f"阻止 {blocked}")
suffix = f"{''.join(suffix_parts)}" if suffix_parts else ""
result.append(f"- {display}{status}{suffix}")
return result
def _stage_sort_key(stage: str) -> tuple[int, int, str]:
text = str(stage or "")
numbers: list[int] = []
for part in text.replace("S", "").split("."):
try:
numbers.append(int(part))
except ValueError:
pass
while len(numbers) < 2:
numbers.append(0)
return numbers[0], numbers[1], text
def _source_label(source: str) -> str:
if source.startswith("registry:"):
return "注册表"
if source.startswith("env:"):
return "环境变量"
if source.startswith("common:"):
return "常见安装目录"
if source.lower() == "path":
return "PATH"
if source == "manual":
return "手动配置"
if source == "cache":
return "缓存"
return source or "未知来源"
def _ok_text(value: object) -> str:
if value is True:
return "可用"
if value is False:
return "不可用"
return "未验证"
def _compact(text: str, limit: int) -> str:
text = " ".join(text.split())
if len(text) <= limit:
return text
return text[: max(limit - 1, 0)].rstrip() + ""
__all__ = ["cached_scdm_backend_payload", "summarize_scdm_capability_progress", "summarize_scdm_runtime"]
+52
View File
@@ -33,6 +33,18 @@ INFO_GROUPS: list[tuple[str, list[str]]] = [
"feature_source_face_id",
],
),
(
"SCDM",
[
"scdm_backend_status",
"scdm_runtime_status",
"scdm_selection_status",
"scdm_selection_enabled_capabilities",
"scdm_selection_blocked_capabilities",
"scdm_selection_capability_count",
"scdm_selection_blocked_count",
],
),
(
"拓扑",
[
@@ -529,6 +541,13 @@ INFO_LABELS = {
"associated_feature_count": "关联特征数",
"associated_feature_face_ids": "关联特征 Face",
"feature_context_note": "关联探测",
"scdm_backend_status": "SCDM 后端",
"scdm_runtime_status": "SCDM 运行状态",
"scdm_selection_status": "SCDM 当前选择",
"scdm_selection_enabled_capabilities": "SCDM 可执行能力",
"scdm_selection_blocked_capabilities": "SCDM 未开放能力",
"scdm_selection_capability_count": "SCDM 能力数量",
"scdm_selection_blocked_count": "SCDM 未开放数量",
"recognition_summary": "识别摘要",
"recognition_candidate": "识别候选",
"recognition_confidence": "识别置信度",
@@ -1178,6 +1197,39 @@ def _smooth_surface_polydata(polydata):
return smoothed
def _large_model_display_deflection(
requested: float,
*,
face_count: int = 0,
edge_count: int = 0,
) -> float:
"""Use a coarser display mesh for large STEP interaction only."""
value = max(float(requested), 1e-9)
if int(face_count or 0) > 1000 or int(edge_count or 0) > 2500:
return max(value, 1.2)
if int(face_count or 0) > 600 or int(edge_count or 0) > 1600:
return max(value, 0.6)
return value
def _large_model_display_deflection_for_model(model: object, requested: float) -> float:
faces = getattr(model, "faces", ()) or ()
edges = getattr(model, "edges", ()) or ()
return _large_model_display_deflection(
requested,
face_count=len(faces),
edge_count=len(edges),
)
def _large_model_display_deflection_for_stats(stats: object, requested: float) -> float:
return _large_model_display_deflection(
requested,
face_count=int(getattr(stats, "faces", 0) or 0),
edge_count=int(getattr(stats, "edges", 0) or 0),
)
def _format_percent(value: object) -> str:
if value is None or value == "":
return ""
+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:
+419 -29
View File
@@ -21,6 +21,9 @@ from PySide6.QtWidgets import (
from .model import StepModel
from .asitus_bridge import run_asitus_hole_recognition
from .records import OperationRecord
from .scdm_feature_mapper import attach_local_face_ids_to_scdm_cache, map_scdm_raw_features
from .scdm_probe import run_scdm_probe
from .scdm_schema import write_json
from .ui_helpers import * # noqa: F403
from .workers import EditWorker, LoadWorker, ScanWorker
@@ -145,6 +148,12 @@ class WindowCoreMixin:
if hasattr(self, "ui_task_requested"):
self.ui_task_requested.emit(callback)
def _reroute_to_ui_thread(self, callback) -> bool:
if self._is_ui_thread():
return False
self._invoke_on_ui_thread(callback)
return True
def eventFilter(self, watched, event):
if event.type() == QEvent.Type.ToolTip and self._should_suppress_transient_tooltip(watched):
QToolTip.hideText()
@@ -194,11 +203,16 @@ class WindowCoreMixin:
event.accept()
return True
elif watched is getattr(self, "relation_formula_input", None):
if event.type() == QEvent.Type.FocusIn:
if hasattr(self, "_update_relation_formula_completions"):
QTimer.singleShot(0, self._update_relation_formula_completions)
if event.type() == QEvent.Type.MouseButtonPress:
if hasattr(self, "_hide_relation_formula_completion_popup"):
self._hide_relation_formula_completion_popup()
if hasattr(watched, "setFocus"):
watched.setFocus(Qt.FocusReason.MouseFocusReason)
if hasattr(self, "_update_relation_formula_completions"):
QTimer.singleShot(0, self._update_relation_formula_completions)
if event.type() in {QEvent.Type.ShortcutOverride, QEvent.Type.KeyPress} and event.key() == Qt.Key.Key_Tab:
if bool(getattr(self, "_relation_formula_tab_completion_accepted", False)):
self._relation_formula_tab_completion_accepted = False
@@ -940,6 +954,21 @@ class WindowCoreMixin:
changed = True
except Exception:
self.edge_visibility_before_camera_interaction = None
if bool(getattr(self, "hide_overlays_during_camera_interaction", False)):
overlay_visibility: dict[str, int] = {}
for attr_name in ("highlight_actor", "edge_highlight_actor", "pick_marker_actor"):
actor = getattr(self, attr_name, None)
if actor is None:
continue
try:
visibility = int(actor.GetVisibility())
overlay_visibility[attr_name] = visibility
if visibility:
actor.VisibilityOff()
changed = True
except Exception:
continue
self.overlay_visibility_before_camera_interaction = overlay_visibility
changed = self._set_render_window_multisamples(
int(getattr(self, "interactive_multi_samples", 0))
) or changed
@@ -955,6 +984,19 @@ class WindowCoreMixin:
except Exception:
pass
self.edge_visibility_before_camera_interaction = None
overlay_visibility = getattr(self, "overlay_visibility_before_camera_interaction", {}) or {}
if isinstance(overlay_visibility, dict):
for attr_name, previous_visibility in overlay_visibility.items():
actor = getattr(self, str(attr_name), None)
if actor is None:
continue
try:
if int(actor.GetVisibility()) != int(previous_visibility):
actor.SetVisibility(int(previous_visibility))
changed = True
except Exception:
continue
self.overlay_visibility_before_camera_interaction = {}
changed = self._set_render_window_multisamples(int(getattr(self, "still_multi_samples", 4))) or changed
changed = bool(getattr(self, "camera_interaction_visual_changed", False)) or changed
self.camera_interaction_visual_changed = False
@@ -1005,6 +1047,11 @@ class WindowCoreMixin:
self.statusBar().showMessage("孔组识别正在后台预热,请稍后再关闭窗口。")
event.ignore()
return
scdm_thread_running = bool(self.scdm_thread is not None and self.scdm_thread.isRunning())
if scdm_thread_running:
self.statusBar().showMessage("SCDM 可修改参数正在后台识别,请稍后再关闭窗口。")
event.ignore()
return
super().closeEvent(event)
def _request_thread_quit(self, thread: QThread | None) -> None:
@@ -1031,23 +1078,25 @@ class WindowCoreMixin:
started = time.perf_counter()
stats = new_model.stats()
timings["stats"] = time.perf_counter() - started
display_deflection = _large_model_display_deflection_for_stats(stats, deflection)
result = {
"path": path,
"model": new_model,
"stats": stats,
"deflection": deflection,
"deflection": display_deflection,
"requested_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)
face_polydata = new_model.build_face_polydata(deflection=display_deflection)
result["model_polydata"] = _smooth_surface_polydata(face_polydata)
timings["display_faces"] = time.perf_counter() - started
if build_edges:
started = time.perf_counter()
result["edge_polydata"] = new_model.build_edge_polydata(
deflection=deflection,
deflection=display_deflection,
show_same_domain_internal_edges=show_internal_edges,
)
timings["display_edges"] = time.perf_counter() - started
@@ -1077,7 +1126,30 @@ class WindowCoreMixin:
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))
deflection = float(getattr(self, "preview_load_deflection", 0.35) or 0.35)
show_internal_edges = self._show_same_domain_internal_edges()
load_step_result = type(self)._load_step_result
def action(path=new_path, deflection=deflection, show_internal_edges=show_internal_edges, load_step_result=load_step_result):
return load_step_result(
path,
deflection=deflection,
show_internal_edges=show_internal_edges,
build_edges=False,
)
thread = QThread(self)
worker = LoadWorker(action)
worker.moveToThread(thread)
thread.started.connect(worker.run)
worker.finished.connect(self._finish_initial_load, Qt.ConnectionType.QueuedConnection)
worker.failed.connect(self._fail_initial_load, Qt.ConnectionType.QueuedConnection)
thread.finished.connect(worker.deleteLater)
thread.finished.connect(thread.deleteLater)
thread.finished.connect(self._forget_load_thread)
self.load_thread = thread
self.load_worker = worker
thread.start()
@Slot(object)
def _run_packaged_initial_load(self, expected_path: Path) -> None:
@@ -1169,6 +1241,7 @@ class WindowCoreMixin:
stats = result["stats"]
self.model = result["model"]
self.step_path = new_path
self._invalidate_scdm_feature_cache("模型已重新加载,SCDM cache 已失效。")
self._clear_history()
if hasattr(self, "measure_text"):
self.clear_measurement()
@@ -1178,6 +1251,12 @@ class WindowCoreMixin:
self.path_label.setCursorPosition(0)
self._populate_part_tree()
self._reset_selection()
large_interaction_model = self._large_model_interaction_mode(stats=stats)
self.hide_edges_during_camera_interaction = large_interaction_model
self.hide_overlays_during_camera_interaction = large_interaction_model
self.hover_after_camera_cooldown_ms = 700 if large_interaction_model else 420
self.large_model_edge_overlay_skipped = False
self.large_model_hover_disabled = large_interaction_model
model_polydata = result.get("model_polydata")
edge_polydata = result.get("edge_polydata")
edge_deferred = bool(result.get("edge_polydata_deferred"))
@@ -1203,7 +1282,11 @@ class WindowCoreMixin:
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"
if large_interaction_model:
self.large_model_edge_overlay_skipped = True
display_state = f"{display_state}; edge display skipped for large model"
else:
display_state = f"{display_state}; edge display pending"
info_payload = {
"file": str(self.step_path),
"parts": stats.parts,
@@ -1218,13 +1301,228 @@ class WindowCoreMixin:
info_payload["load_performance"] = timing_text
self.set_info(info_payload)
self._update_action_states()
if edge_deferred:
if edge_deferred and not large_interaction_model:
QTimer.singleShot(80, self._rebuild_deferred_edge_display)
QTimer.singleShot(160, self._start_asitus_hole_recognition_preload)
elif edge_deferred and large_interaction_model:
self.statusBar().showMessage(
f"Loaded {self.step_path.name}; 大模型已跳过全量边线补绘,旋转会更流畅,切到 Edge 选择时再按需生成。"
)
pending_scdm_reload = isinstance(getattr(self, "pending_scdm_edit_reload", None), dict)
if large_interaction_model and not pending_scdm_reload:
self._defer_large_model_recognition_preloads()
else:
QTimer.singleShot(160, self._start_asitus_hole_recognition_preload)
QTimer.singleShot(240, lambda: self._start_scdm_probe_preload(force=pending_scdm_reload))
def _start_asitus_hole_recognition_preload(self) -> None:
def _large_model_interaction_mode(self, stats: object | None = None) -> bool:
if stats is not None:
try:
return int(getattr(stats, "faces", 0) or 0) > 1000 or int(getattr(stats, "edges", 0) or 0) > 2500
except Exception:
return False
if self.model is None:
return False
try:
return len(getattr(self.model, "faces", ()) or ()) > 1000 or len(getattr(self.model, "edges", ()) or ()) > 2500
except Exception:
return False
def _defer_large_model_recognition_preloads(self) -> None:
self.scdm_feature_cache_state = "deferred"
self.scdm_feature_cache_message = (
"大模型已延后 SCDM/Analysis Situs 全量识别,优先保证查看、旋转和点选流畅;"
"本地 OCCT 快路径仍可直接用于已稳定验证的参数。"
)
if self.model is not None:
try:
self.model.fail_asitus_hole_region_load("大模型已延后 Analysis Situs 全量孔组识别。")
except Exception:
pass
if hasattr(self, "_update_current_capability_panel"):
self._update_current_capability_panel()
def _invalidate_scdm_feature_cache(self, message: str = "") -> None:
self.scdm_feature_cache = None
self.scdm_feature_cache_state = "stale"
self.scdm_feature_cache_message = message
self.scdm_feature_cache_path = ""
if self.model is not None:
try:
self.model.scdm_feature_cache = None
except Exception:
pass
if hasattr(self, "_update_current_capability_panel"):
self._update_current_capability_panel()
def _start_scdm_probe_preload(self, *, force: bool = False) -> None:
if self.model is None or self.step_path is None:
return
if not force and self._large_model_interaction_mode():
self._defer_large_model_recognition_preloads()
return
if self.scdm_thread is not None and self.scdm_thread.isRunning():
QTimer.singleShot(500, lambda: self._start_scdm_probe_preload(force=force))
return
step_path = Path(self.step_path)
context = {
"path": step_path,
"model_id": id(self.model),
}
model = self.model
self.pending_scdm_context = dict(context)
self.scdm_feature_cache_state = "running"
self.scdm_feature_cache_message = "正在识别 SCDM 可修改参数。"
self.statusBar().showMessage("正在后台识别 SCDM 可修改参数,可继续旋转查看模型。")
if hasattr(self, "_update_current_capability_panel"):
self._update_current_capability_panel()
def action(path=step_path, model=model):
probe = run_scdm_probe(path, project_root=Path(__file__).resolve().parent.parent, timeout_seconds=180.0)
if not isinstance(probe, dict) or probe.get("ok") is not True:
return {
"ok": False,
"reason": str(probe.get("reason") if isinstance(probe, dict) else "probe-failed"),
"message": str(probe.get("message") if isinstance(probe, dict) else "SCDM probe failed."),
"probe": probe,
}
raw = probe.get("raw")
if not isinstance(raw, dict):
return {"ok": False, "reason": "missing-raw", "message": "SCDM probe did not return raw feature data.", "probe": probe}
face_signatures = []
builder = getattr(model, "scdm_local_face_signatures", None)
if callable(builder):
try:
face_signatures = [dict(item) for item in builder() if isinstance(item, dict)]
except Exception:
face_signatures = []
cache = attach_local_face_ids_to_scdm_cache(
map_scdm_raw_features(raw),
face_signatures,
)
cache_path = Path(str(probe.get("raw_features_path") or path)).with_name("scdm_feature_cache.json")
write_json(cache_path, cache)
return {
"ok": True,
"reason": "ok",
"cache": cache,
"cache_path": str(cache_path),
"probe": probe,
}
thread = QThread(self)
worker = ScanWorker(action)
worker.moveToThread(thread)
thread.started.connect(worker.run)
worker.finished.connect(self._finish_scdm_probe_preload, Qt.ConnectionType.QueuedConnection)
worker.failed.connect(self._fail_scdm_probe_preload, Qt.ConnectionType.QueuedConnection)
thread.finished.connect(worker.deleteLater)
thread.finished.connect(thread.deleteLater)
thread.finished.connect(self._forget_scdm_thread)
self.scdm_thread = thread
self.scdm_worker = worker
try:
thread.start(QThread.Priority.LowPriority)
except TypeError:
thread.start()
def _scdm_local_face_signatures(self) -> list[dict[str, object]]:
if self.model is None:
return []
builder = getattr(self.model, "scdm_local_face_signatures", None)
if callable(builder):
try:
return [dict(item) for item in builder() if isinstance(item, dict)]
except Exception:
return []
return []
@Slot(object)
def _finish_scdm_probe_preload(self, result: object) -> None:
if self._reroute_to_ui_thread(lambda result=result: self._finish_scdm_probe_preload(result)):
return
try:
context = dict(self.pending_scdm_context or {})
if self.model is None or id(self.model) != context.get("model_id"):
return
if self.step_path is None or Path(context.get("path", "")) != Path(self.step_path):
return
if not isinstance(result, dict) or result.get("ok") is not True:
reason = str(result.get("reason") if isinstance(result, dict) else "probe-failed")
message = str(result.get("message") if isinstance(result, dict) else "SCDM probe failed.")
if isinstance(result, dict) and isinstance(result.get("backend"), dict):
self.scdm_backend_status = dict(result["backend"])
self.scdm_feature_cache = None
self.scdm_feature_cache_state = "failed"
self.scdm_feature_cache_message = message
self.statusBar().showMessage(f"SCDM 可修改参数识别未启用:{reason}")
if hasattr(self, "_update_current_capability_panel"):
self._update_current_capability_panel()
if hasattr(self, "maybe_prompt_missing_scdm_backend"):
self.maybe_prompt_missing_scdm_backend(reason=reason, message=message)
if hasattr(self, "_finish_pending_scdm_edit_reload"):
self._finish_pending_scdm_edit_reload(cache_ready=False, message=f"SCDM cache 刷新失败:{reason}")
return
cache = result.get("cache")
if not isinstance(cache, dict):
if isinstance(result.get("backend"), dict):
self.scdm_backend_status = dict(result["backend"])
self.scdm_feature_cache_state = "failed"
self.scdm_feature_cache_message = "SCDM cache 格式无效。"
self.statusBar().showMessage("SCDM 可修改参数识别失败:cache 格式无效。")
if hasattr(self, "_update_current_capability_panel"):
self._update_current_capability_panel()
if hasattr(self, "_finish_pending_scdm_edit_reload"):
self._finish_pending_scdm_edit_reload(cache_ready=False, message="SCDM cache 刷新失败:格式无效")
return
if isinstance(result.get("backend"), dict):
self.scdm_backend_status = dict(result["backend"])
self.scdm_feature_cache = dict(cache)
self.scdm_feature_cache_state = "ready"
self.scdm_feature_cache_message = "SCDM 可修改参数识别完成。"
self.scdm_feature_cache_path = str(result.get("cache_path") or "")
try:
self.model.scdm_feature_cache = dict(cache)
except Exception:
pass
objects = cache.get("objects")
count = len(objects) if isinstance(objects, list) else 0
self.statusBar().showMessage(f"SCDM 可修改参数识别完成:{count} 个产品化对象。")
if hasattr(self, "_update_current_capability_panel"):
self._update_current_capability_panel()
if hasattr(self, "_refresh_property_editor"):
self._refresh_property_editor()
if hasattr(self, "_finish_pending_scdm_edit_reload"):
self._finish_pending_scdm_edit_reload(cache_ready=True)
finally:
self._request_thread_quit(self.scdm_thread)
@Slot(str)
def _fail_scdm_probe_preload(self, message: str) -> None:
if self._reroute_to_ui_thread(lambda message=message: self._fail_scdm_probe_preload(message)):
return
try:
self.scdm_feature_cache = None
self.scdm_feature_cache_state = "failed"
self.scdm_feature_cache_message = message
self.statusBar().showMessage("SCDM 可修改参数识别失败,不影响当前模型查看。")
if hasattr(self, "_update_current_capability_panel"):
self._update_current_capability_panel()
if hasattr(self, "_finish_pending_scdm_edit_reload"):
self._finish_pending_scdm_edit_reload(cache_ready=False, message=f"SCDM cache 刷新失败:{message}")
finally:
self._request_thread_quit(self.scdm_thread)
@Slot()
def _forget_scdm_thread(self) -> None:
self.scdm_thread = None
self.scdm_worker = None
self.pending_scdm_context = None
def _start_asitus_hole_recognition_preload(self, *, force: bool = False) -> None:
if self.model is None or self.step_path is None:
return
if not force and self._large_model_interaction_mode():
return
if self.asitus_thread is not None and self.asitus_thread.isRunning():
return
if not hasattr(self.model, "begin_asitus_hole_region_load"):
@@ -1259,6 +1557,8 @@ class WindowCoreMixin:
@Slot(object)
def _finish_asitus_hole_recognition(self, result: object) -> None:
if self._reroute_to_ui_thread(lambda result=result: self._finish_asitus_hole_recognition(result)):
return
try:
context = dict(self.pending_asitus_context or {})
if self.model is None or id(self.model) != context.get("model_id"):
@@ -1273,6 +1573,8 @@ class WindowCoreMixin:
@Slot(str)
def _fail_asitus_hole_recognition(self, message: str) -> None:
if self._reroute_to_ui_thread(lambda message=message: self._fail_asitus_hole_recognition(message)):
return
try:
context = dict(self.pending_asitus_context or {})
if self.model is not None and id(self.model) == context.get("model_id"):
@@ -1302,6 +1604,8 @@ class WindowCoreMixin:
@Slot(object)
def _finish_initial_load(self, result: object) -> None:
if self._reroute_to_ui_thread(lambda result=result: self._finish_initial_load(result)):
return
try:
if not isinstance(result, dict):
raise RuntimeError("Load task returned an unexpected result.")
@@ -1328,15 +1632,21 @@ class WindowCoreMixin:
@Slot(str)
def _fail_initial_load(self, message: str) -> None:
if self._reroute_to_ui_thread(lambda message=message: self._fail_initial_load(message)):
return
self._end_load_task()
QMessageBox.critical(self, "Load failed", message)
self.statusBar().showMessage("STEP load failed; current model was left unchanged.")
if hasattr(self, "_fail_pending_scdm_edit_reload"):
self._fail_pending_scdm_edit_reload(message)
def _start_load_refine(self, initial_result: dict[str, object]) -> None:
self.statusBar().showMessage(f"Loaded {self.step_path.name}")
@Slot(object)
def _finish_load_refine(self, result: object) -> None:
if self._reroute_to_ui_thread(lambda result=result: self._finish_load_refine(result)):
return
try:
if (
isinstance(result, dict)
@@ -1368,6 +1678,8 @@ class WindowCoreMixin:
@Slot(str)
def _fail_load_refine(self, message: str) -> None:
if self._reroute_to_ui_thread(lambda message=message: self._fail_load_refine(message)):
return
self._end_load_task()
self.statusBar().showMessage(f"Quick preview is available; display refinement failed: {message}")
@@ -1651,13 +1963,20 @@ class WindowCoreMixin:
return "实体"
return f"{info.get('faces', 0)} 个 | 边 {info.get('edges', 0)}"
def _rebuild_scene(self, reset_camera: bool = False) -> None:
def _rebuild_scene(self, reset_camera: bool = False, build_edges: bool | None = None) -> None:
if self.model is None:
return
model_polydata = self.model.build_face_polydata()
edge_polydata = self.model.build_edge_polydata(
show_same_domain_internal_edges=self._show_same_domain_internal_edges()
)
large_model = len(getattr(self.model, "faces", ()) or ()) > 1000 or len(getattr(self.model, "edges", ()) or ()) > 2500
should_build_edges = (not large_model) if build_edges is None else bool(build_edges)
if should_build_edges:
edge_polydata = self.model.build_edge_polydata(
show_same_domain_internal_edges=self._show_same_domain_internal_edges()
)
self.large_model_edge_overlay_skipped = False
else:
edge_polydata = _empty_edge_polydata()
self.large_model_edge_overlay_skipped = large_model
self._rebuild_scene_from_polydata(model_polydata, edge_polydata, reset_camera=reset_camera)
def _show_same_domain_internal_edges(self) -> bool:
@@ -1669,7 +1988,7 @@ class WindowCoreMixin:
if self.scene_isolated and self.selected_kind is not None:
self.isolate_selected()
else:
self._rebuild_scene(reset_camera=False)
self._rebuild_scene(reset_camera=False, build_edges=True)
self._refresh_selection_highlight()
state = "显示" if checked else "隐藏"
self.statusBar().showMessage(f"{state}同域内部拓扑边")
@@ -1892,20 +2211,67 @@ class WindowCoreMixin:
def _rebuild_deferred_edge_display(self) -> None:
if self.model is None or self.operation_in_progress or self.load_in_progress:
return
if self.load_refine_thread is not None and self.load_refine_thread.isRunning():
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(),
model = self.model
path = Path(self.step_path) if self.step_path is not None else None
deflection = float(getattr(self, "preview_load_deflection", 0.35) or 0.35)
show_internal_edges = self._show_same_domain_internal_edges()
context = {"path": path, "model_id": id(model), "started": started}
def action(model=model, context=context, deflection=deflection, show_internal_edges=show_internal_edges):
edge_polydata = model.build_edge_polydata(
deflection=deflection,
show_same_domain_internal_edges=show_internal_edges,
)
self._install_edge_polydata(edge_polydata, render=True)
except Exception as exc:
self.statusBar().showMessage(f"模型已显示;边线补充失败:{exc}")
return {**context, "edge_polydata": edge_polydata, "elapsed": time.perf_counter() - started}
thread = QThread(self)
worker = LoadWorker(action)
worker.moveToThread(thread)
thread.started.connect(worker.run)
worker.finished.connect(self._finish_deferred_edge_display, Qt.ConnectionType.QueuedConnection)
worker.failed.connect(self._fail_deferred_edge_display, Qt.ConnectionType.QueuedConnection)
thread.finished.connect(worker.deleteLater)
thread.finished.connect(thread.deleteLater)
thread.finished.connect(self._forget_load_refine_thread)
self.load_refine_thread = thread
self.load_refine_worker = worker
try:
thread.start(QThread.Priority.LowPriority)
except TypeError:
thread.start()
@Slot(object)
def _finish_deferred_edge_display(self, result: object) -> None:
if self._reroute_to_ui_thread(lambda result=result: self._finish_deferred_edge_display(result)):
return
elapsed = time.perf_counter() - started
self.statusBar().showMessage(f"模型边线已补充,用时 {elapsed:.1f}s")
try:
if not isinstance(result, dict):
return
if self.model is None or id(self.model) != result.get("model_id"):
return
if self.step_path is None or Path(result.get("path", "")) != Path(self.step_path):
return
edge_polydata = self._copy_polydata_for_ui_thread(result.get("edge_polydata"))
self._install_edge_polydata(edge_polydata, render=True)
self.large_model_edge_overlay_skipped = False
elapsed = float(result.get("elapsed") or 0.0)
self.statusBar().showMessage(f"模型边线已补充,用时 {elapsed:.1f}s")
finally:
self._request_thread_quit(self.load_refine_thread)
@Slot(str)
def _fail_deferred_edge_display(self, message: str) -> None:
if self._reroute_to_ui_thread(lambda message=message: self._fail_deferred_edge_display(message)):
return
try:
self.statusBar().showMessage(f"模型已显示;边线补充失败:{message}")
finally:
self._request_thread_quit(self.load_refine_thread)
def _remember_overlay_cache_item(self, cache: dict, key: object, value: object) -> object:
if len(cache) >= self.overlay_cache_limit:
@@ -2054,6 +2420,8 @@ class WindowCoreMixin:
def _on_mode_changed(self, mode: str) -> None:
self._clear_hover(render=True)
if str(mode) == "Edge" and bool(getattr(self, "large_model_edge_overlay_skipped", False)):
QTimer.singleShot(0, self._rebuild_deferred_edge_display)
if hasattr(self, "_update_id_select_title"):
self._update_id_select_title(mode)
@@ -2082,6 +2450,7 @@ class WindowCoreMixin:
self.left_button_press_position = (int(x), int(y))
self.left_button_dragged = False
self.left_button_press_camera_state = self._camera_state_signature()
self.left_button_press_target_unknown = False
self.left_button_press_target = self._selection_target_at_position(x, y)
self.pending_hover_position = None
self.last_hover_pick_position = None
@@ -2107,17 +2476,21 @@ class WindowCoreMixin:
camera_changed = self._left_button_camera_changed()
self.pointer_button_down = False
press_target = getattr(self, "left_button_press_target", None)
press_target_unknown = bool(getattr(self, "left_button_press_target_unknown", False))
self.left_button_press_position = None
self.left_button_dragged = False
self.left_button_press_camera_state = None
self.left_button_press_target = None
self.left_button_press_target_unknown = False
self.pending_hover_position = None
self.last_hover_pick_position = None
if getattr(self, "camera_interaction_active", False):
self._end_camera_interaction()
if was_dragged or camera_changed or self._selection_target_signature(press_target) is None:
if was_dragged or camera_changed:
return
self._handle_left_click(x, y, required_press_target=press_target)
if not press_target_unknown and self._selection_target_signature(press_target) is None:
return
self._handle_left_click(x, y, required_press_target=None if press_target_unknown else press_target)
def _camera_state_signature(self) -> tuple[float, ...] | None:
renderer = getattr(self, "renderer", None)
@@ -2206,7 +2579,10 @@ class WindowCoreMixin:
if self.model is None:
return
mode = self._current_selection_mode()
target = self._pick_selection_target(mode, x, y)
if required_press_target is not None and not bool(self._large_model_interaction_mode()):
target = self._pick_selection_target(mode, x, y)
else:
target = required_press_target if required_press_target is not None else self._pick_selection_target(mode, x, y)
if required_press_target is not None:
press_signature = self._selection_target_signature(required_press_target)
release_signature = self._selection_target_signature(target)
@@ -2397,6 +2773,7 @@ class WindowCoreMixin:
if (
getattr(self, "pointer_button_down", False)
or getattr(self, "camera_interaction_active", False)
or getattr(self, "large_model_hover_disabled", False)
or self._hover_suppressed_after_camera()
):
return
@@ -2425,6 +2802,7 @@ class WindowCoreMixin:
or self.model is None
or self.model_actor is None
or self.pending_hover_position is None
or getattr(self, "large_model_hover_disabled", False)
or self._hover_suppressed_after_camera()
):
self._clear_hover(render=True)
@@ -2863,10 +3241,11 @@ class WindowCoreMixin:
info.setdefault("feature_mode", "当前是几何候选判断,不等同于 CAD 历史特征")
highlight_face_ids = _int_values(info.get("feature_highlight_face_ids")) or [face_id]
else:
highlight_face_ids = self._selection_same_domain_face_ids(face_id) or [face_id]
highlight_face_ids = self._selection_same_domain_face_ids(face_id, info) or [face_id]
selection_fields = self._selection_identity_fields(
face_id,
"特征来源 Face" if feature_mode else "Face",
region_face_ids=highlight_face_ids,
)
logical_id = int(selection_fields["selection_display_id"])
input_info = dict(info)
@@ -2882,9 +3261,16 @@ class WindowCoreMixin:
message = f"已选择Face {logical_id}" if not feature_mode else f"已选择特征来源 Face {logical_id}"
self.statusBar().showMessage(self._selection_status(message, pick_position))
def _selection_same_domain_face_ids(self, face_id: int) -> list[int]:
def _selection_same_domain_face_ids(self, face_id: int, fallback_info: dict[str, object] | None = None) -> list[int]:
if self.model is None:
return [face_id]
fallback_ids = _int_values((fallback_info or {}).get("feature_highlight_face_ids")) or _int_values(
(fallback_info or {}).get("same_domain_face_ids")
)
if fallback_ids:
return sorted({int(item) for item in fallback_ids if 0 <= int(item) < len(self.model.faces)}) or [face_id]
if bool(getattr(self, "_large_model_interaction_mode", lambda: False)()):
return [face_id]
try:
face_ids = self.model.face_region_ids(face_id)
except Exception:
@@ -2904,7 +3290,11 @@ class WindowCoreMixin:
return
info = self._feature_context_info(face_id)
info["kind"] = "feature"
selection_fields = self._selection_identity_fields(face_id, "特征来源 Face")
selection_fields = self._selection_identity_fields(
face_id,
"特征来源 Face",
region_face_ids=_int_values(info.get("feature_highlight_face_ids")) or [face_id],
)
logical_id = int(selection_fields["selection_display_id"])
info.update(selection_fields)
self._reset_selection(clear_highlight=False, clear_info=False)
File diff suppressed because it is too large Load Diff